@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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0 (2026-09-16)
|
|
4
|
+
|
|
5
|
+
- **Grok is a supervised and handoff agent.** `leg grok` starts Grok the same
|
|
6
|
+
way `leg claude`, `leg codex` and `leg agy` start those agents: board
|
|
7
|
+
alongside, usage tracking, and a live context handoff bundle. At the limit
|
|
8
|
+
the next agent continues in the same terminal.
|
|
9
|
+
- **Auto-approve is on by default** for new sessions. Leg injects each agent's
|
|
10
|
+
fully-permissive flag at spawn (`--dangerously-skip-permissions` for Claude
|
|
11
|
+
and agy, `--ask-for-approval never` for Codex, `--always-approve` for Grok).
|
|
12
|
+
Opt out with `--no-auto-approve`, `LEG_AUTO_APPROVE=0` /
|
|
13
|
+
`LEG_NO_AUTO_APPROVE=1`, or `"auto_approve": false` in `preferences.json`.
|
|
14
|
+
|
|
3
15
|
## 0.7.0 (2026-09-15)
|
|
4
16
|
|
|
5
17
|
- **Rebranded to Leg.** Package is now `legcli` on npm, binary command is `leg`,
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# leg
|
|
2
2
|
|
|
3
|
-
**Type `leg claude`, `leg codex` or `leg
|
|
3
|
+
**Type `leg claude`, `leg codex`, `leg agy` or `leg grok` instead of the bare command. You get the same interactive agent; Leg opens a board next to it, watches the usage limit, keeps a handoff bundle current, and when the limit hits it starts the next agent in the same terminal from that bundle.**
|
|
4
4
|
|
|
5
5
|
[](https://legcli.com/license)
|
|
6
6
|
[](https://legcli.com/docs/getting-started)
|
|
@@ -26,15 +26,15 @@ settings file and never edits yours. `leg claude --model opus` is
|
|
|
26
26
|
own worktree and a **Land** button instead of writing over the first.
|
|
27
27
|
2. **Usage tracking** per agent and account, from what each CLI already
|
|
28
28
|
exposes: Claude Code's usage endpoint and its `StopFailure` hook, Codex's
|
|
29
|
-
read-only app-server rate-limit read, agy's log.
|
|
29
|
+
read-only app-server rate-limit read, agy's log, and Grok's billing proxy endpoint.
|
|
30
30
|
3. **A context handoff bundle** ([context-handoff-bundle](https://pypi.org/project/context-handoff-bundle/))
|
|
31
31
|
refreshed as the session goes, so the work is always ready to hand off.
|
|
32
32
|
4. **The handoff itself.** Near the limit you get a warning. At the limit Leg
|
|
33
33
|
saves the bundle, stops the agent, and starts the next option in the same
|
|
34
34
|
terminal from that bundle: another login of the same agent if you added
|
|
35
35
|
one, otherwise the next agent in the order shown on the terminal card.
|
|
36
|
-
The default is claude
|
|
37
|
-
new terminals. Nothing is retyped.
|
|
36
|
+
The default is claude -> codex -> agy (with grok supported in handoff order),
|
|
37
|
+
and Settings changes the default for new terminals. Nothing is retyped.
|
|
38
38
|
When every option is out, it tells you which resets first and when, waits
|
|
39
39
|
for that reset with a countdown, and starts that agent from the bundle.
|
|
40
40
|
|
|
@@ -43,9 +43,10 @@ Subscription logins only: Leg strips `ANTHROPIC_API_KEY`,
|
|
|
43
43
|
`OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_API_BASE`, `GEMINI_API_KEY`,
|
|
44
44
|
`GOOGLE_API_KEY`, `GOOGLE_GEMINI_BASE_URL`, `GOOGLE_GENAI_USE_VERTEXAI`,
|
|
45
45
|
`GOOGLE_GENAI_USE_ENTERPRISE`, `GOOGLE_CLOUD_PROJECT`,
|
|
46
|
-
`GOOGLE_CLOUD_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS`, `
|
|
47
|
-
`CLAUDE_CODE_*`, `CLAUDE_EFFORT`, and
|
|
48
|
-
starts. It then sets
|
|
46
|
+
`GOOGLE_CLOUD_LOCATION`, `GOOGLE_APPLICATION_CREDENTIALS`, `GROK_API_KEY`,
|
|
47
|
+
`XAI_API_KEY`, `CLAUDECODE`, `CLAUDE_CODE_*`, `CLAUDE_EFFORT`, and
|
|
48
|
+
`CLAUDE_PLUGIN_DATA` before any agent starts. It then sets
|
|
49
|
+
`CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0` for a detached
|
|
49
50
|
Claude print session. Leg never edits `~/.claude/settings.json` or any other
|
|
50
51
|
settings file of yours; its hooks ride in a separate per-session `--settings`
|
|
51
52
|
file. The one thing it does write outside `~/.leg` is the folder-trust
|
|
@@ -103,7 +104,7 @@ yourself.
|
|
|
103
104
|
## 60-second run
|
|
104
105
|
|
|
105
106
|
Prerequisites: Node 22 or newer, git, Python 3 with pip, and at least one
|
|
106
|
-
logged-in agent CLI (`claude`, `codex` or `
|
|
107
|
+
logged-in agent CLI (`claude`, `codex`, `agy` or `grok`).
|
|
107
108
|
|
|
108
109
|
```
|
|
109
110
|
npm install -g @ucsandman/legcli
|
|
@@ -136,6 +137,7 @@ which.
|
|
|
136
137
|
| claude | `GET api.anthropic.com/api/oauth/usage` with the login Claude Code stored, the same data as `/usage` and the built-in status line (`five_hour`, `seven_day`, `utilization`, `resets_at`); polled every 60 s | `StopFailure` hook with `error: rate_limit` ([docs](https://code.claude.com/docs/en/hooks#stopfailure)) | one extra settings file per session via `--settings`, carrying only Leg's own hooks; `autoContinueAtUsageLimit` is set to `false` because Leg owns the handoff | observed live |
|
|
137
138
|
| codex | read-only `account/rateLimits/read` through the app-server, polled every 60 s by the board and active attach; windows are identified by duration (300 minutes = 5h, 10080 = 7d) | `task_complete.error.codex_error_info: usage_limit_exceeded`, message "You've hit your usage limit … try again at …" (`codex-rs/protocol/src/error.rs`) | no model turn and no hook are injected; the board reads the CLI backend and records only returned windows | verified by source and regression tests |
|
|
138
139
|
| agy | none exposed (agy's own status line fetches a quota summary that is written nowhere) | `RESOURCE_EXHAUSTED`, "it resets in …", "out of quota" in the log | `--log-file` per session; `~/.gemini/antigravity-cli/history.jsonl` gives the prompts and conversation id | observed live (a real `RESOURCE_EXHAUSTED` with its reset was read from the log on 2026-09-11) |
|
|
140
|
+
| grok | `GET cli-chat-proxy.grok.com/v1/billing?format=credits` and `GET cli-chat-proxy.grok.com/v1/user?include=subscription` with OAuth token from `~/.grok/auth.json` (`creditUsagePercent`, `currentPeriod` weekly reset); polled every 60 s | `-32003` rate limit error, "You've hit the rate limit for your plan. Try again later.", "Rate limited (429)", `StopFailureKind::RateLimit` (`xai-org/grok-build`) | `--debug-file grok.log` per session; `~/.grok/sessions/<encoded-cwd>/prompt_history.jsonl` gives prompts and session id | verified by source and proxy endpoint |
|
|
139
141
|
|
|
140
142
|
Why not Claude Code's status line JSON (`rate_limits.five_hour.used_percentage`):
|
|
141
143
|
on 2.1.268 the custom `statusLine` Leg passes through `--settings` did not
|
|
@@ -387,15 +389,15 @@ happens after you run `leg accounts add`; that is your call.
|
|
|
387
389
|
## CLI reference
|
|
388
390
|
|
|
389
391
|
```
|
|
390
|
-
leg claude|codex|agy [agent args…]
|
|
392
|
+
leg claude|codex|agy|grok [agent args…] the interactive agent, board alongside, handoff on limit
|
|
391
393
|
[--no-worktree] share the checkout with a live session instead of a worktree
|
|
392
394
|
leg sessions ls [--json] every session and its usage
|
|
393
395
|
leg sessions show|events <id>
|
|
394
396
|
leg sessions handoff|end <id> same as the board buttons
|
|
395
397
|
leg sessions rm <id> forget an ended session
|
|
396
|
-
leg sessions simulate-limit <id> the real limit path without a real wall (claude, agy)
|
|
398
|
+
leg sessions simulate-limit <id> the real limit path without a real wall (claude, agy, grok)
|
|
397
399
|
leg accounts ls logins and their 5h/7d usage
|
|
398
|
-
leg accounts add <claude|codex> <name> | rm <agent> <name> | terms
|
|
400
|
+
leg accounts add <claude|codex|grok> <name> | rm <agent> <name> | terms
|
|
399
401
|
leg license the license on this machine, or where to buy one
|
|
400
402
|
leg license activate <key> | deactivate | refresh (refresh renews a Team key)
|
|
401
403
|
leg share who is on the board (off by default; Team plan)
|
|
@@ -409,9 +411,9 @@ Environment, all optional: `LEG_HOME` (default `~/.leg`), `LEG_PORT`
|
|
|
409
411
|
(4747), `LEG_ACCOUNT` (start on a named login), `LEG_WARN_PCT` (85),
|
|
410
412
|
`LEG_NO_HANDOFF=1` (warn and record, never switch), `LEG_NO_OPEN=1` (do not
|
|
411
413
|
open the browser), `LEG_USAGE_POLL_MS` (60000), `LEG_CLAUDE_ARGS` /
|
|
412
|
-
`LEG_CODEX_ARGS` / `LEG_AGY_ARGS` (extra args for a leg Leg starts after
|
|
414
|
+
`LEG_CODEX_ARGS` / `LEG_AGY_ARGS` / `LEG_GROK_ARGS` (extra args for a leg Leg starts after
|
|
413
415
|
a hand-off, e.g. `-m gpt-5.3-codex-spark`), `LEG_CLAUDE_BIN`,
|
|
414
|
-
`LEG_CODEX_BIN`, `LEG_AGY_BIN`, `LEG_CHB_BIN`, `LEG_PERSON` (whose
|
|
416
|
+
`LEG_CODEX_BIN`, `LEG_AGY_BIN`, `LEG_GROK_BIN`, `LEG_CHB_BIN`, `LEG_PERSON` (whose
|
|
415
417
|
terminal this is when the board is shared), `LEG_RATE_MAX` (600 requests a
|
|
416
418
|
minute per human) and `LEG_RATE_MAX_FAILURES` (20 wrong tokens per address).
|
|
417
419
|
|
|
@@ -542,7 +544,7 @@ Leg is commercial software under the [Leg License Agreement](LICENSE).
|
|
|
542
544
|
It ships as readable JavaScript so you can see what it does on your machine,
|
|
543
545
|
and you may modify it for your own use, but not redistribute it or work
|
|
544
546
|
around the license check. Versions 0.2.0 and 0.3.0 were published under MIT
|
|
545
|
-
and remain available. The version in this source tree is 0.
|
|
547
|
+
and remain available. The version in this source tree is 0.8.0; see
|
|
546
548
|
[npm](https://www.npmjs.com/package/legcli) for published versions and
|
|
547
549
|
[CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
548
550
|
|
package/bin/leg.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { availableActions } from '../src/chain.mjs'
|
|
|
20
20
|
import { up, down, stopBoard, status, openBoard } from '../src/launcher.mjs'
|
|
21
21
|
import { attach, ensureBoard } from '../src/attach.mjs'
|
|
22
22
|
import { readShare, addPerson, removePerson, rotate as rotateToken, turnOn, turnOff, linkFor, personNamed } from '../src/share.mjs'
|
|
23
|
-
import {
|
|
23
|
+
import { SUPERVISED_AGENTS, listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, isActive, readLand, sessionDir, appendEvent } from '../src/sessions.mjs'
|
|
24
24
|
import { addAccount, removeAccount, listAccountRows, LAYOUT } from '../src/accounts.mjs'
|
|
25
25
|
import { listUsage, fmtReset } from '../src/usage.mjs'
|
|
26
26
|
import { home } from '../src/store.mjs'
|
|
@@ -88,7 +88,12 @@ function simulateLimit(s) {
|
|
|
88
88
|
appendEvent(s.session_id, { type: 'status', summary: 'simulated RESOURCE_EXHAUSTED appended to the session log' })
|
|
89
89
|
return out(`simulated: RESOURCE_EXHAUSTED appended to ${join(sessionDir(s.session_id), 'agy.log')}; the runner reads it within ${process.env.BATON_ATTACH_POLL_MS || 2000} ms and hands off to ${s.chain?.[0]?.agent ?? 'nothing'}`)
|
|
90
90
|
}
|
|
91
|
-
|
|
91
|
+
if (s.agent === 'grok') {
|
|
92
|
+
appendFileSync(join(sessionDir(s.session_id), 'grok.log'), "\nRate limited (429): You've hit the rate limit for your plan. Try again later. (simulated by leg sessions simulate-limit)\n")
|
|
93
|
+
appendEvent(s.session_id, { type: 'status', summary: 'simulated rate limit appended to the grok log' })
|
|
94
|
+
return out(`simulated: rate limit appended to ${join(sessionDir(s.session_id), 'grok.log')}; the runner reads it within ${process.env.BATON_ATTACH_POLL_MS || 2000} ms and hands off to ${s.chain?.[0]?.agent ?? 'nothing'}`)
|
|
95
|
+
}
|
|
96
|
+
die(2, `simulate-limit drives the claude hook path (and the agy/grok log); codex's wall comes from its own rollout file, which Baton never writes. Use "leg sessions handoff ${s.session_id}" to force the switch.`)
|
|
92
97
|
}
|
|
93
98
|
|
|
94
99
|
function fmtCard(c) {
|
|
@@ -97,34 +102,49 @@ function fmtCard(c) {
|
|
|
97
102
|
return `${c.card_id} [${c.status}] ${c.station}${leg} leases=${(c.leases?.length ? c.leases : ['**']).join(',')} ${String(c.title ?? c.task).slice(0, 60)}`
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
const TERMS = `Terms check (fetched 2026-09-11): Anthropic Consumer Terms forbid sharing account credentials and "bypassing any of our systems or protective measures"; the Anthropic Usage Policy forbids coordinating across multiple accounts to circumvent product guardrails; OpenAI's Terms of Use forbid sharing credentials and "circumvent any rate limits or restrictions". Two paid logins you own are not banned by name, but rotating to a second account of the same vendor because the first is rate-limited is close to that wording. Leg's default chain switches vendors (claude -> codex -> agy); a second account of one vendor is your call.`
|
|
105
|
+
const TERMS = `Terms check (fetched 2026-09-11): Anthropic Consumer Terms forbid sharing account credentials and "bypassing any of our systems or protective measures"; the Anthropic Usage Policy forbids coordinating across multiple accounts to circumvent product guardrails; OpenAI's Terms of Use forbid sharing credentials and "circumvent any rate limits or restrictions". Two paid logins you own are not banned by name, but rotating to a second account of the same vendor because the first is rate-limited is close to that wording. Leg's default chain switches vendors (claude -> codex -> agy -> grok); a second account of one vendor is your call.`
|
|
101
106
|
|
|
102
107
|
async function main() {
|
|
103
108
|
const [group, cmd, ...rest] = process.argv.slice(2)
|
|
104
109
|
const args = parseArgs(rest)
|
|
105
110
|
if (group === '--version' || group === '-v') return out(VERSION)
|
|
106
111
|
if (group === '🦿' || group === 'prosthetic' || group === 'easter-egg') {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
'
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
112
|
+
const ORANGE = '\x1b[38;5;208m'
|
|
113
|
+
const RESET = '\x1b[0m'
|
|
114
|
+
const LEG_ART = [
|
|
115
|
+
' ███████',
|
|
116
|
+
' ███████',
|
|
117
|
+
' ███████',
|
|
118
|
+
' ███████',
|
|
119
|
+
' ███████',
|
|
120
|
+
' ███████',
|
|
121
|
+
' ███████',
|
|
122
|
+
' ███████',
|
|
123
|
+
' ███████',
|
|
124
|
+
' ███████',
|
|
125
|
+
' ███████',
|
|
126
|
+
' ███████████',
|
|
127
|
+
' █████████████',
|
|
128
|
+
` ██████(${ORANGE}00${RESET})███ ← knee servo`,
|
|
129
|
+
' █████████████',
|
|
130
|
+
' ███████████',
|
|
131
|
+
' ███████',
|
|
132
|
+
' █████',
|
|
133
|
+
' █████',
|
|
134
|
+
' █████',
|
|
135
|
+
' █████',
|
|
136
|
+
' █████',
|
|
137
|
+
' █████',
|
|
138
|
+
' ██████████████████████████',
|
|
139
|
+
' ████ ████ ████ ████ ████ ████',
|
|
140
|
+
].join('\n')
|
|
141
|
+
out('🦿 LegCli: the mechanical relay runner for coding agents.\n')
|
|
142
|
+
out(LEG_ART)
|
|
143
|
+
out('\nPassing the leg to the next runner when limits hit.')
|
|
124
144
|
return
|
|
125
145
|
}
|
|
126
|
-
if (
|
|
127
|
-
// leg claude|codex|agy [agent args...]: everything after the agent name
|
|
146
|
+
if (SUPERVISED_AGENTS.includes(group)) {
|
|
147
|
+
// leg claude|codex|agy|grok [agent args...]: everything after the agent name
|
|
128
148
|
// goes straight through.
|
|
129
149
|
const code = await attach(group, [cmd, ...rest].filter((x) => x !== undefined), { open: process.env.BATON_NO_OPEN !== '1' })
|
|
130
150
|
process.exit(code)
|
|
@@ -427,11 +447,12 @@ async function main() {
|
|
|
427
447
|
out(openBoard(url) ? `opened ${url}` : `could not open a browser; visit ${url}`)
|
|
428
448
|
return
|
|
429
449
|
}
|
|
430
|
-
if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|sessions|resume|accounts|license|share|up|down|status|open|card|scheduler|uninstall)`)
|
|
450
|
+
if (group && group !== '--help' && group !== 'help') die(2, `unknown command "${group}" (claude|codex|agy|grok|sessions|resume|accounts|license|share|up|down|status|open|card|scheduler|uninstall)`)
|
|
431
451
|
out(`leg ${VERSION}, your coding agents, with a board alongside and a handoff when one hits its limit
|
|
432
|
-
claude|codex|agy [args...] the normal interactive agent in this terminal; args pass straight through
|
|
433
|
-
|
|
434
|
-
|
|
452
|
+
claude|codex|agy|grok [args...] the normal interactive agent in this terminal; args pass straight through
|
|
453
|
+
the board opens once, the session shows as a card, usage is tracked, a limit hands off
|
|
454
|
+
a second live session in one checkout gets its own worktree (--no-worktree to share)
|
|
455
|
+
auto-approve mode (--no-auto-approve to opt out)
|
|
435
456
|
sessions ls|show|events|handoff|end|rm|simulate-limit <id>
|
|
436
457
|
resume [--check] [--json] [--path <dir>] the hand-off waiting in this checkout, and whether it is still true
|
|
437
458
|
freshness is recomputed from git at read time; --check prints only the verdict
|
package/docs/adapters.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Adapters
|
|
2
2
|
|
|
3
3
|
Two things per agent: what Leg reads from an interactive session
|
|
4
|
-
(`leg claude|codex|agy`), and the headless argv the v0.1 pipeline spawns.
|
|
4
|
+
(`leg claude|codex|agy|grok`), and the headless argv the v0.1 pipeline spawns.
|
|
5
5
|
Every fact here was written against `src/taps/*.mjs`, `src/attach.mjs` and
|
|
6
6
|
`src/adapters/*.mjs`; the evidence trail, including which lines an artifact
|
|
7
7
|
backs, is [cli-contracts.md](cli-contracts.md).
|
|
@@ -120,7 +120,7 @@ documentation say docs-only.
|
|
|
120
120
|
- **The wall**: `RESOURCE_EXHAUSTED`, "it resets in %s" and "out of quota" in
|
|
121
121
|
the log. Those strings are present in `agy.exe`, and `scanLog()` also reads a
|
|
122
122
|
relative reset out of "resets in \<n>\<s|m|h|d>". Status:
|
|
123
|
-
**
|
|
123
|
+
**observed-live 2026-09-16** <!-- live:agy/agy-resource-exhausted --> —
|
|
124
124
|
`RESOURCE_EXHAUSTED (code 429): Individual quota reached … Resets in
|
|
125
125
|
71h19m42s.` appeared in a session's `agy.log` at 08:02:42Z and walled the
|
|
126
126
|
agent. No payload was kept: the capture call in `src/attach.mjs` was added
|
|
@@ -133,11 +133,25 @@ documentation say docs-only.
|
|
|
133
133
|
- **One account only**: agy 1.2.0 has no config-directory override, so
|
|
134
134
|
`leg accounts add agy …` is refused.
|
|
135
135
|
|
|
136
|
+
### grok
|
|
137
|
+
|
|
138
|
+
- **How Leg attaches**: `grok <your args>` with `--debug-file <~/.leg/sessions/<id>/grok.log>` passed by Leg (`src/attach.mjs` `spawnSpec`).
|
|
139
|
+
- **Usage percentages**: `GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` and `GET https://cli-chat-proxy.grok.com/v1/user?include=subscription` (`LEG_GROK_BILLING_URL` and `LEG_GROK_USER_URL` override), reading the OAuth token stored in `~/.grok/auth.json`. The billing endpoint reports `creditUsagePercent` and `currentPeriod` (with weekly resets). Polled every 60 s (`LEG_USAGE_POLL_MS`). If the token expires or returns 401, usage is marked unknown without crashing, and re-reads `auth.json` on the next poll.
|
|
140
|
+
- **The wall**: rate limit signals cited directly from `xai-org/grok-build`:
|
|
141
|
+
- `SamplingError::Api { status: StatusCode::TOO_MANY_REQUESTS }` (`crates/codegen/xai-grok-sampling-types/src/error.rs:304`)
|
|
142
|
+
- `RATE_LIMITED_ERROR_CODE = -32003` and user messages `RATE_LIMITED_USER_MESSAGE_OAUTH` ("You've hit the rate limit for your plan. Try again later.") and `RATE_LIMITED_USER_MESSAGE_API_KEY` ("You've hit the rate limit for your API key. Try again later.") (`crates/codegen/xai-grok-shell/src/sampling/error.rs:15, 18-21`)
|
|
143
|
+
- Headline "Rate limited (429)" and "You've hit the rate limit for your plan" (`crates/codegen/xai-grok-pager/src/app/error_display.rs:263-267`)
|
|
144
|
+
- `StopFailureKind::RateLimit` ("rate_limit") (`crates/codegen/xai-grok-hooks/src/event.rs:306-315`)
|
|
145
|
+
- Free usage exhausted: `FREE_USAGE_USER_MESSAGE` and `FREE_USAGE_EXHAUSTED_ERROR_CODE` ("subscription:free-usage-exhausted") (`crates/codegen/xai-grok-shell/src/sampling/error.rs:30, 33`)
|
|
146
|
+
`src/taps/grok.mjs` scans `grok.log` for these exact signals and extracts reset durations when available.
|
|
147
|
+
- **Prompts and session id**: `~/.grok/sessions/<url-encoded-cwd>/prompt_history.jsonl`, recorded per prompt with timestamp, `session_id`, and `prompt`.
|
|
148
|
+
- **Accounts**: Supports `GROK_HOME` override. `leg accounts add grok <name>` creates junctioned directories copying `config.toml`.
|
|
149
|
+
|
|
136
150
|
### Resume prompt per agent
|
|
137
151
|
|
|
138
152
|
After a hand-off the next agent starts in the same terminal with the pointer
|
|
139
153
|
prompt as its first positional argument: `claude "<prompt>"`,
|
|
140
|
-
`codex "<prompt>"`, `agy -i "<prompt>"` (`src/attach.mjs` `spawnSpec`).
|
|
154
|
+
`codex "<prompt>"`, `agy -i "<prompt>"`, `grok "<prompt>"` (`src/attach.mjs` `spawnSpec`).
|
|
141
155
|
|
|
142
156
|
## Headless adapters (the v0.1 pipeline)
|
|
143
157
|
|
package/docs/cli-contracts.md
CHANGED
|
@@ -191,7 +191,7 @@ stderr are 0 bytes, and codex's is the one stdin notice. observed-live.
|
|
|
191
191
|
|
|
192
192
|
## Interactive taps
|
|
193
193
|
|
|
194
|
-
What `leg claude|codex|agy` reads while the real interactive CLI runs. Same
|
|
194
|
+
What `leg claude|codex|agy|grok` reads while the real interactive CLI runs. Same
|
|
195
195
|
tagging rule: `observed-live 2026-09-11` means the build machine did it;
|
|
196
196
|
`docs-only` means the CLI's own source or documentation says so and Leg has
|
|
197
197
|
not seen it happen. Machine: Claude Code 2.1.268, codex-cli 0.153.4, agy 1.2.0.
|
|
@@ -295,7 +295,7 @@ variable and `LEG_SESSION` (source: src/attach.mjs, src/env.mjs).
|
|
|
295
295
|
"quota exhausted/exceeded" in the log, plus a relative reset parsed out of
|
|
296
296
|
"resets in \<n>\<s|m|h|d>" (source: strings present in `agy.exe`;
|
|
297
297
|
src/taps/agy.mjs `scanLog`).
|
|
298
|
-
**
|
|
298
|
+
**observed-live 2026-09-16** <!-- live:agy/agy-resource-exhausted -->:
|
|
299
299
|
`RESOURCE_EXHAUSTED (code 429): Individual quota reached … Resets in
|
|
300
300
|
71h19m42s.` appeared in a session's `agy.log` at 08:02:42Z, `scanLog` read
|
|
301
301
|
the relative reset, and the agent was walled. No payload was kept, the
|
|
@@ -310,6 +310,38 @@ variable and `LEG_SESSION` (source: src/attach.mjs, src/env.mjs).
|
|
|
310
310
|
`LAYOUT.agy.env` is `null` and `accounts add agy` is refused (source:
|
|
311
311
|
src/accounts.mjs).
|
|
312
312
|
|
|
313
|
+
### grok tap
|
|
314
|
+
|
|
315
|
+
- Attach: `grok <args> --debug-file <LEG_HOME>/sessions/<id>/grok.log` (source:
|
|
316
|
+
src/attach.mjs `spawnSpec`).
|
|
317
|
+
- Usage: `GET https://cli-chat-proxy.grok.com/v1/billing?format=credits` and
|
|
318
|
+
`GET https://cli-chat-proxy.grok.com/v1/user?include=subscription` with
|
|
319
|
+
`Authorization: Bearer <token>` read from `~/.grok/auth.json` (per-scope
|
|
320
|
+
key map or direct object). The billing endpoint reports `creditUsagePercent`
|
|
321
|
+
and `currentPeriod` (with weekly reset timestamp `end`). Polled every 60 s
|
|
322
|
+
(`LEG_USAGE_POLL_MS`). Token expiry and HTTP 401 are handled gracefully
|
|
323
|
+
without throwing, marking usage unknown until re-authenticated.
|
|
324
|
+
(source: src/taps/grok.mjs; probe against xAI proxy).
|
|
325
|
+
- The wall: exact rate-limit signals cited from `xai-org/grok-build` (Rust):
|
|
326
|
+
- `SamplingError::Api { status: StatusCode::TOO_MANY_REQUESTS }`
|
|
327
|
+
(`crates/codegen/xai-grok-sampling-types/src/error.rs:304`)
|
|
328
|
+
- `RATE_LIMITED_ERROR_CODE = -32003` and messages
|
|
329
|
+
`RATE_LIMITED_USER_MESSAGE_OAUTH` ("You've hit the rate limit for your plan. Try again later.")
|
|
330
|
+
and `RATE_LIMITED_USER_MESSAGE_API_KEY` ("You've hit the rate limit for your API key. Try again later.")
|
|
331
|
+
(`crates/codegen/xai-grok-shell/src/sampling/error.rs:15, 18-21`)
|
|
332
|
+
- Headline "Rate limited (429)" and "You've hit the rate limit for your plan"
|
|
333
|
+
(`crates/codegen/xai-grok-pager/src/app/error_display.rs:263-267`)
|
|
334
|
+
- `StopFailureKind::RateLimit` ("rate_limit")
|
|
335
|
+
(`crates/codegen/xai-grok-hooks/src/event.rs:306-315`)
|
|
336
|
+
- Free usage exhausted: `FREE_USAGE_USER_MESSAGE` and `FREE_USAGE_EXHAUSTED_ERROR_CODE`
|
|
337
|
+
("subscription:free-usage-exhausted")
|
|
338
|
+
(`crates/codegen/xai-grok-shell/src/sampling/error.rs:30, 33`)
|
|
339
|
+
`src/taps/grok.mjs` scans `grok.log` for these patterns and parses reset duration.
|
|
340
|
+
- Prompts and session id: `~/.grok/sessions/<url-encoded-cwd>/prompt_history.jsonl`,
|
|
341
|
+
one `{ timestamp, session_id, prompt }` per entry (source: src/taps/grok.mjs `promptsSince`).
|
|
342
|
+
- Accounts: `GROK_HOME` override supported; `leg accounts add grok <name>` creates
|
|
343
|
+
junctioned directories with copied `config.toml` (source: src/accounts.mjs).
|
|
344
|
+
|
|
313
345
|
### Usage store and the chooser
|
|
314
346
|
|
|
315
347
|
- `<LEG_HOME>/usage/<agent>--<account>.json`:
|
|
@@ -361,7 +393,7 @@ non-zero exit → `failed`. Every outcome except `completed`, `auth_failed` and
|
|
|
361
393
|
`killed` asks the chain to hand off.
|
|
362
394
|
|
|
363
395
|
<!-- limits-table:start -->
|
|
364
|
-
Generated by `node scripts/limits-table.mjs` from
|
|
396
|
+
Generated by `node scripts/limits-table.mjs` from 22 fixtures (4 observed-live, 18 docs-only). Classification `limit` hands the card to the next agent as a usage limit; `auth` is a failed launch (never a limit); `launch` is a failed launch that the next agent may still try; `budget` is a turn or spend cap set by Baton itself; `info` must never classify as a limit.
|
|
365
397
|
|
|
366
398
|
| id | adapter | class | where | source | text (excerpt) | produced by |
|
|
367
399
|
|----|---------|-------|-------|--------|----------------|-------------|
|
|
@@ -383,6 +415,7 @@ Generated by `node scripts/limits-table.mjs` from 21 fixtures (4 observed-live,
|
|
|
383
415
|
| generic-resource-exhausted | * | limit | any | **docs-only** | RESOURCE_EXHAUSTED | generic matcher (gRPC RESOURCE_EXHAUSTED); lowest priority |
|
|
384
416
|
| generic-usage-limit | * | limit | any | **docs-only** | usage limit | generic matcher; lowest priority |
|
|
385
417
|
| grok-not-logged-in | grok | auth | stderr | **observed-live** | To sign in, open this URL in your browser: https://accounts.x.ai/oauth2/device?user_cod | fixtures/live/grok/err.log from scripts/probe.mjs --adapter grok, 2026-09-10 (stdout JSON stopReason: Cancelled, exit 0) |
|
|
418
|
+
| grok-rate-limit | grok | limit | any | **docs-only** | You've hit the rate limit for your plan. Try again later. | crates/codegen/xai-grok-shell/src/sampling/error.rs:18 (RATE_LIMITED_USER_MESSAGE_OAUTH: "You've hit the rate limit for your plan. Try again later.") |
|
|
386
419
|
| auth-source-set | * | auth | stderr | **docs-only** | another auth source is set | project brief (Wes, 2026-09-10): stderr saying "another auth source is set" counts as a failed launch; wording not yet observed live |
|
|
387
420
|
| compile-error | * | info | stderr | **docs-only** | SyntaxError: Unexpected token ) at compileSourceTextModule (node:internal/modules/esm/ | synthetic negative fixture (a crashed agent is not a limit) |
|
|
388
421
|
| empty-stdout-exit-0 | * | info | stdout | **observed-live** | | fixtures/live/grok (exit 0, no work): silence is not a limit |
|
package/docs/configuration.md
CHANGED
|
@@ -20,11 +20,13 @@ variables yourself in that case.
|
|
|
20
20
|
|
|
21
21
|
## Interactive sessions
|
|
22
22
|
|
|
23
|
-
These apply to `leg claude|codex|agy`.
|
|
23
|
+
These apply to `leg claude|codex|agy|grok`.
|
|
24
24
|
|
|
25
25
|
| variable | default | meaning | read in |
|
|
26
26
|
|----------|---------|---------|---------|
|
|
27
27
|
| `LEG_ACCOUNT` | `default` | start the session on a named login instead of the CLI's own home | `src/attach.mjs` |
|
|
28
|
+
| `LEG_AUTO_APPROVE` | `1` | launch interactive agents in auto-approve mode (set to `0`, `false`, or `off` to disable) | `src/preferences.mjs` |
|
|
29
|
+
| `LEG_NO_AUTO_APPROVE` | (unset) | set to `1` to opt out of auto-approve mode | `src/preferences.mjs` |
|
|
28
30
|
| `LEG_WARN_PCT` | `85` | the percentage of either usage window that turns the card amber, records a `warning` event and rings the terminal bell once | `src/usage.mjs` |
|
|
29
31
|
| `LEG_NO_HANDOFF` | (unset, hand-off on) | set to `1` to warn and record but never switch agents | `src/attach.mjs` |
|
|
30
32
|
| `LEG_NO_OPEN` | (unset, opens once) | set to `1` to start the board without opening a browser | `bin/leg.mjs` |
|
|
@@ -45,6 +47,23 @@ record under `$LEG_HOME/sessions/`.
|
|
|
45
47
|
`CLAUDE_CONFIG_DIR` and `CODEX_HOME` are set for the child when the session
|
|
46
48
|
runs on a named account; see [Accounts](#accounts) below.
|
|
47
49
|
|
|
50
|
+
### Auto-approve launch mode
|
|
51
|
+
|
|
52
|
+
By default, Leg starts interactive sessions (`leg claude`, `leg codex`, `leg agy`, and `leg grok`) in permissive auto-approve mode so you never sit through repetitive tool permission prompts:
|
|
53
|
+
|
|
54
|
+
- Claude: `--dangerously-skip-permissions`
|
|
55
|
+
- Codex: `--ask-for-approval never`
|
|
56
|
+
- agy: `--dangerously-skip-permissions`
|
|
57
|
+
- Grok: `--always-approve`
|
|
58
|
+
|
|
59
|
+
These flags are injected at spawn time and only affect sessions launched through Leg. Your global CLI configurations (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`) remain untouched.
|
|
60
|
+
|
|
61
|
+
To opt out and keep standard approval prompts:
|
|
62
|
+
|
|
63
|
+
1. CLI flag: pass `--no-auto-approve` when starting a session (e.g. `leg claude --no-auto-approve`).
|
|
64
|
+
2. Environment variable: set `LEG_AUTO_APPROVE=0` or `LEG_NO_AUTO_APPROVE=1` (or legacy `BATON_AUTO_APPROVE=0` / `BATON_NO_AUTO_APPROVE=1`).
|
|
65
|
+
3. Persistent preference: set `"auto_approve": false` in `~/.leg/preferences.json`.
|
|
66
|
+
|
|
48
67
|
## Core
|
|
49
68
|
|
|
50
69
|
| variable | default | meaning | read in |
|
package/docs/getting-started.md
CHANGED
|
@@ -10,9 +10,9 @@ it. The v0.1 pipeline flow is section 7 onward.
|
|
|
10
10
|
- git.
|
|
11
11
|
- Python 3 with pip, for the `context-handoff-bundle` CLI. This is what writes
|
|
12
12
|
and reads the handoff bundles.
|
|
13
|
-
- At least one coding-agent CLI, logged in: `claude`, `codex
|
|
14
|
-
not need all
|
|
15
|
-
tracking; with two or more you also get the hand-off.
|
|
13
|
+
- At least one coding-agent CLI, logged in: `claude`, `codex`, `agy` or
|
|
14
|
+
`grok`. You do not need all four. With one installed you get the board
|
|
15
|
+
and the usage tracking; with two or more you also get the hand-off.
|
|
16
16
|
|
|
17
17
|
## 2. Install
|
|
18
18
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "grok-rate-limit",
|
|
3
|
+
"adapter": "grok",
|
|
4
|
+
"source": "docs-only",
|
|
5
|
+
"produced_by": "crates/codegen/xai-grok-shell/src/sampling/error.rs:18 (RATE_LIMITED_USER_MESSAGE_OAUTH: \"You've hit the rate limit for your plan. Try again later.\")",
|
|
6
|
+
"where": "any",
|
|
7
|
+
"text": "You've hit the rate limit for your plan. Try again later.",
|
|
8
|
+
"pattern": "You've hit the rate limit for your plan|You've hit the rate limit for your API key|Rate limited \\(429\\)|subscription:free-usage-exhausted|You've used all of your free queries",
|
|
9
|
+
"exit_code": null,
|
|
10
|
+
"classification": "limit"
|
|
11
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"agent": "agy",
|
|
3
|
+
"signal": "agy-resource-exhausted",
|
|
4
|
+
"captured_at": "2026-09-16T07:17:41.481Z",
|
|
5
|
+
"session_id": "s-20260916-065617-agy-ef0c",
|
|
6
|
+
"source": "observed-live",
|
|
7
|
+
"payload": {
|
|
8
|
+
"log_excerpt": "invalid syntax I0916 03:17:39.470463 43949 run.go:389] Run: attempt 1 failed (RESOURCE_EXHAUSTED (code 429): Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 2h35m6s.), retrying in 4s",
|
|
9
|
+
"resets_at": 1789552367
|
|
10
|
+
}
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ucsandman/legcli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Usage-limit monitor and automatic handoff for Claude Code, Codex, agy and Grok. Type leg claude|codex|agy|grok and get the same interactive agent with a board alongside, auto-approve on by default, usage tracking per agent and account, a live context handoff bundle, and at the limit the next agent continuing in the same terminal. $79 once, 30-day money-back guarantee.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
7
7
|
"homepage": "https://legcli.com",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
},
|
|
51
51
|
"scripts": {
|
|
52
52
|
"start": "node --env-file-if-exists=.env bin/leg.mjs up",
|
|
53
|
-
"test": "node --test --test-concurrency=
|
|
53
|
+
"test": "node --test --test-concurrency=4 && node scripts/privacy-check.mjs && node scripts/check-claims.mjs",
|
|
54
54
|
"lint": "eslint .",
|
|
55
55
|
"privacy": "node scripts/privacy-check.mjs",
|
|
56
56
|
"claims": "node scripts/check-claims.mjs",
|
|
@@ -185,12 +185,13 @@ function page ({ slug, title, description, body, headings }) {
|
|
|
185
185
|
<meta property="og:url" content="${url}">
|
|
186
186
|
<meta property="og:site_name" content="LegCli">
|
|
187
187
|
<meta property="og:title" content="${escapeHtml(title)}">
|
|
188
|
-
<meta property="og:
|
|
189
|
-
<meta property="og:image" content="${ORIGIN}/og.png?v=
|
|
188
|
+
<meta property="og:image" content="${ORIGIN}/og.png?v=3">
|
|
189
|
+
<meta property="og:image:secure_url" content="${ORIGIN}/og.png?v=3">
|
|
190
|
+
<meta property="og:image:type" content="image/png">
|
|
190
191
|
<meta name="twitter:card" content="summary_large_image">
|
|
191
192
|
<meta name="twitter:title" content="${escapeHtml(title)}">
|
|
192
193
|
<meta name="twitter:description" content="${escapeHtml(description)}">
|
|
193
|
-
<meta name="twitter:image" content="${ORIGIN}/og.png?v=
|
|
194
|
+
<meta name="twitter:image" content="${ORIGIN}/og.png?v=3">
|
|
194
195
|
<link rel="preload" href="/fonts/atkinson-hyperlegible-next-var.woff2" as="font" type="font/woff2" crossorigin>
|
|
195
196
|
<link rel="preload" href="/fonts/azeret-mono-var.woff2" as="font" type="font/woff2" crossorigin>
|
|
196
197
|
<link rel="stylesheet" href="/style.css">
|
package/src/accounts.mjs
CHANGED
|
@@ -31,6 +31,13 @@ export const LAYOUT = {
|
|
|
31
31
|
login: (dir) => `$env:CODEX_HOME='${dir}'; codex login`,
|
|
32
32
|
},
|
|
33
33
|
agy: { env: null, home: () => join(homedir(), '.gemini', 'antigravity-cli'), share: [], copy: [], login: null },
|
|
34
|
+
grok: {
|
|
35
|
+
env: 'GROK_HOME',
|
|
36
|
+
home: () => process.env.GROK_HOME || join(homedir(), '.grok'),
|
|
37
|
+
share: ['installed-plugins', 'skills', 'workflows'],
|
|
38
|
+
copy: ['config.toml'],
|
|
39
|
+
login: (dir) => `$env:GROK_HOME='${dir}'; grok login`,
|
|
40
|
+
},
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
export function accountsFile() { return join(home(), 'accounts.json') }
|
|
@@ -42,6 +49,7 @@ export function readAccounts() {
|
|
|
42
49
|
try {
|
|
43
50
|
const j = JSON.parse(readFileSync(accountsFile(), 'utf8'))
|
|
44
51
|
for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => n !== 'default')]
|
|
52
|
+
if (Array.isArray(j?.grok)) base.grok = ['default', ...j.grok.filter((n) => n !== 'default')]
|
|
45
53
|
} catch {}
|
|
46
54
|
return base
|
|
47
55
|
}
|
|
@@ -69,7 +77,7 @@ function junction(target, link) {
|
|
|
69
77
|
export function addAccount(agent, name) {
|
|
70
78
|
if (!/^[a-z0-9][a-z0-9_-]{0,29}$/i.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
|
|
71
79
|
const l = LAYOUT[agent]
|
|
72
|
-
if (!l) throw new Error(`unknown agent "${agent}" (claude|codex|agy)`)
|
|
80
|
+
if (!l) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
|
|
73
81
|
if (!l.env) throw new Error(`${agent} has no config-dir override in the installed version; extra accounts are not possible`)
|
|
74
82
|
const dir = accountDir(agent, name)
|
|
75
83
|
mkdirSync(dir, { recursive: true })
|
|
@@ -78,6 +86,7 @@ export function addAccount(agent, name) {
|
|
|
78
86
|
for (const d of l.share) if (junction(join(src, d), join(dir, d))) shared.push(d)
|
|
79
87
|
refreshAccount(agent, name)
|
|
80
88
|
const acc = readAccounts()
|
|
89
|
+
if (!acc[agent]) acc[agent] = ['default']
|
|
81
90
|
if (!acc[agent].includes(name)) { acc[agent].push(name); writeAccounts(acc) }
|
|
82
91
|
return { dir, shared, login: l.login(dir), env: l.env }
|
|
83
92
|
}
|
package/src/adapters/grok.mjs
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
// grok adapter
|
|
2
|
-
//
|
|
3
|
-
// the build machine had no grok login (device-code prompt, exit "Cancelled").
|
|
4
|
-
// Built from `grok --help` only; see docs/cli-contracts.md § grok. Register it
|
|
5
|
-
// after `grok` is logged in and `node scripts/probe.mjs --adapter grok` passes.
|
|
1
|
+
// grok adapter - Grok CLI headless (`grok -p <prompt> --output-format json`).
|
|
2
|
+
// Registered in index.mjs alongside claude, codex, and agy.
|
|
6
3
|
import { join } from 'node:path'
|
|
7
4
|
import { homedir } from 'node:os'
|
|
8
5
|
import { sanitizeEnv } from '../env.mjs'
|
|
@@ -38,8 +35,8 @@ const adapter = {
|
|
|
38
35
|
const j = JSON.parse(s.slice(i))
|
|
39
36
|
return {
|
|
40
37
|
session_id: j.session_id ?? j.sessionId ?? null,
|
|
41
|
-
last_message: typeof j.response === 'string' ? j.response : (typeof j.result === 'string' ? j.result : null),
|
|
42
|
-
stop_reason: j.stop_reason ?? null,
|
|
38
|
+
last_message: typeof j.response === 'string' ? j.response : (typeof j.result === 'string' ? j.result : (typeof j.text === 'string' ? j.text : null)),
|
|
39
|
+
stop_reason: j.stop_reason ?? j.stopReason ?? null,
|
|
43
40
|
raw: j,
|
|
44
41
|
}
|
|
45
42
|
} catch { continue }
|