@ucsandman/legcli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +110 -11
  3. package/bin/leg.mjs +78 -15
  4. package/docs/ERRORS.md +187 -0
  5. package/docs/README.md +3 -1
  6. package/docs/ROADMAP-v2.md +24 -11
  7. package/docs/VOCABULARY.md +1 -0
  8. package/docs/adapters.md +93 -11
  9. package/docs/board-guide.md +20 -1
  10. package/docs/cli-contracts.md +50 -17
  11. package/docs/configuration.md +56 -5
  12. package/docs/history.md +172 -0
  13. package/docs/runtime-tap.md +156 -0
  14. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  15. package/fixtures/live/grok/cmd.txt +1 -1
  16. package/fixtures/live/grok/parsed.json +6 -3
  17. package/fixtures/live/grok/run.json +22 -10
  18. package/fixtures/verified.json +8 -1
  19. package/package.json +1 -1
  20. package/scripts/build-docs-site.mjs +11 -4
  21. package/scripts/probe.mjs +2 -1
  22. package/src/accounts.mjs +5 -2
  23. package/src/adapters/cli.mjs +130 -0
  24. package/src/adapters/custom.mjs +271 -0
  25. package/src/adapters/grok.mjs +51 -10
  26. package/src/adapters/index.mjs +34 -7
  27. package/src/attach.mjs +85 -13
  28. package/src/audit.mjs +118 -0
  29. package/src/board/audit.js +123 -0
  30. package/src/board/board.css +38 -1
  31. package/src/board/board.js +14 -2
  32. package/src/board/history.js +377 -0
  33. package/src/board/index.html +55 -0
  34. package/src/board/sessions.js +49 -7
  35. package/src/history/cli.mjs +159 -0
  36. package/src/history/common.mjs +119 -0
  37. package/src/history/index.mjs +429 -0
  38. package/src/history/providers/agy.mjs +91 -0
  39. package/src/history/providers/claude.mjs +161 -0
  40. package/src/history/providers/codex.mjs +133 -0
  41. package/src/history/providers/copilot.mjs +94 -0
  42. package/src/history/providers/grok.mjs +138 -0
  43. package/src/history/worktrees.mjs +116 -0
  44. package/src/redact.mjs +23 -5
  45. package/src/server.mjs +272 -28
  46. package/src/sessions.mjs +9 -0
  47. package/src/share.mjs +66 -6
  48. package/src/taps/claude.mjs +11 -4
  49. package/src/taps/grok.mjs +4 -0
  50. package/src/taps/mod.mjs +340 -0
  51. package/src/usage.mjs +21 -5
  52. package/src/worktree.mjs +1 -1
@@ -0,0 +1,172 @@
1
+ # History: every conversation on this machine
2
+
3
+ `leg history` lists the coding-agent conversations on this machine in one
4
+ place: the sessions Leg started itself, and the ones Claude Code, Codex, Grok,
5
+ Antigravity and GitHub Copilot CLI keep in their own stores, whether or not
6
+ Leg was involved. `leg worktrees` does the same for checkouts. The board's
7
+ fourth ledger cell, **Conversations**, is the same index with a search box.
8
+
9
+ Nothing moves. Claude keeps Claude's history where it always was, Codex
10
+ keeps Codex's, and so on. Leg reads those stores, writes one index file of
11
+ its own, and points back.
12
+
13
+ ## What you get
14
+
15
+ ```
16
+ leg history newest first, every agent, 50 rows
17
+ leg history --provider codex,grok one or more agents
18
+ leg history --repo leg a repository by name, or by path
19
+ leg history --search "drainer" title, repo, branch, folder or id
20
+ leg history --managed | --external only what Leg started, or only what it found
21
+ leg history --live only conversations with a process still on them
22
+ leg history --all --json everything, as JSON
23
+ leg history show <id> one conversation: where, when, its last messages, whether it can continue
24
+ leg history continue <id> start leg <agent> on it, in its folder, supervised like any other session
25
+ leg history refresh [--full] re-stat every store now; --full drops the index and re-reads everything
26
+ leg history providers the support matrix below, from the code that implements it
27
+ leg worktrees [--repo <path>] [--json] [--no-dirty]
28
+ ```
29
+
30
+ An id is `<provider>:<the agent's own id>`, for example
31
+ `claude:0fc54b51-6cfa-40cf-9823-a5a36d1ca3f3`. A unique prefix of the agent's
32
+ id, four characters or more, is enough: `leg history show 0fc5`. A Leg session
33
+ id (`s-2026…`) works too.
34
+
35
+ A row says which agent, whether the conversation is **leg** (Leg started it,
36
+ its session id follows) or **external** (found in the agent's store), whether
37
+ it is **live** (a process is still on it, where the agent leaves a marker),
38
+ the repository and branch, when it was last active, and its title.
39
+
40
+ ## Support matrix
41
+
42
+ Verified against the stores on a real machine on 2026-09-16 (Claude Code
43
+ 2.1.273, codex-cli 0.154.0, Grok CLI with `chat_format_version` 1,
44
+ Antigravity 1.2.0, Copilot CLI 1.0.80). Each provider's file header in
45
+ `src/history/providers/` names the exact files and fields it reads.
46
+
47
+ | provider | list | title | branch | live marker | transcript | continue | store read |
48
+ |---|---|---|---|---|---|---|---|
49
+ | Claude Code | yes | custom title, else the AI title, else the first prompt | yes | `sessions/<pid>.json`, pid checked | yes | `claude --resume <id>` | `~/.claude/projects/*/<id>.jsonl`, `history.jsonl`, `sessions/` |
50
+ | Codex | yes | `session_index.jsonl`, else the first prompt | yes (the `git` block) | no | yes | `codex resume <id>` | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, `session_index.jsonl`, `history.jsonl` |
51
+ | Grok | yes | `generated_title`, else the summary, else the first prompt | yes | `active_sessions.json` | yes | `grok --resume <id>` | `~/.grok/sessions/<cwd>/<id>/summary.json`, `chat_history.jsonl`, `prompt_history.jsonl` |
52
+ | Antigravity | yes | `annotations/<id>.pbtxt`, else the first prompt | no | no | **no** (SQLite) | `agy --conversation <id>` | `~/.gemini/antigravity-cli/history.jsonl`, `annotations/`, `presence/` |
53
+ | Copilot CLI | yes | the session name | yes | no | yes | **no** | `~/.copilot/session-state/<id>/workspace.yaml`, `events.jsonl` |
54
+
55
+ Extra logins made with `leg accounts add` are scanned too (their `CLAUDE_CONFIG_DIR`,
56
+ `CODEX_HOME` or `GROK_HOME` directory); a row from one carries the account
57
+ name.
58
+
59
+ **Continue** is offered only where the installed CLI's own help documents
60
+ resume-by-id, quoted in the provider file. Antigravity keeps its transcripts
61
+ in SQLite, which Leg does not open, so it lists and continues but cannot show
62
+ messages. Copilot is discoverable without being an agent Leg supervises, so it
63
+ lists and shows messages but cannot be continued through Leg; that split (a
64
+ provider Leg can *find* versus an agent Leg can *run*) is deliberate, and the
65
+ registry in `src/history/index.mjs` is separate from `src/adapters/`.
66
+
67
+ Looked at and left unsupported, each for one reason: Cursor (its chat lives
68
+ in `state.vscdb`, SQLite), opencode (`opencode.db`, SQLite), Pi (documented
69
+ under `~/.pi/agent/sessions/`, not present on the verification machine, the
70
+ cwd encoding undocumented), Hermes, aider and Windsurf (no session store).
71
+
72
+ ## How discovery works
73
+
74
+ - **One index file.** `$LEG_HOME/history/index.json` holds, per provider, per
75
+ transcript, the file's size and mtime and a small record: ids, cwd, repo,
76
+ branch, times, a scrubbed title cut to 200 characters. No message body is
77
+ ever cached. A refresh stats every file and re-reads only the ones whose
78
+ size or mtime changed; deleted files drop out.
79
+ - **Bounded reads.** Metadata comes from the first 256 KB and the last 256 KB
80
+ of a transcript, never the whole file (a busy machine has gigabytes of
81
+ them). Messages are read only when a conversation is opened, from the last
82
+ 4 MB, and every string is passed through the same redaction as the rest of
83
+ the board plus the values of the well-known key variables the process holds.
84
+ - **Repository resolution without git.** A cwd is walked up to its `.git`; a
85
+ `.git` *file* names a linked worktree and its main repository, so a
86
+ conversation that ran in a worktree groups under the repository it belongs
87
+ to. When the folder is gone, what the agent itself recorded (Claude's
88
+ `worktree-state`, Grok's `git_root_dir`) is used instead.
89
+ - **Dedup.** A Leg session records the agent's own id (`agent_session_id`)
90
+ and transcript path; a discovered record with the same id or path is the
91
+ same conversation and is shown once, marked managed, with the Leg session id
92
+ and status. A Leg session whose agent id Leg never learned still lists,
93
+ under `leg:<session id>`. `leg history continue` seeds the new session with
94
+ the id up front, so the continued conversation dedups from its first turn.
95
+ - **Hidden by default.** Subagent threads (Codex `source.subagent`, Grok
96
+ `session_kind: subagent`, Claude sidechains and the `<id>/subagents/`
97
+ directory) and sessions Claude Code itself hides (`history-suppression`)
98
+ are left out unless `--subagents` (the API's `hidden=1`) asks for them.
99
+ - **A provider failing is contained.** Each provider scans inside its own
100
+ try; a throw keeps that provider's last good entries, records the error on
101
+ its stats line, and the others still index.
102
+ - **The listing refreshes itself** when the index is older than a minute;
103
+ `leg history refresh` forces it. The CLI refreshes inline; the board hands
104
+ the refresh to a child `leg history refresh` so its own event loop keeps
105
+ serving (a scan stats thousands of files and walks every cwd). A refresh
106
+ that fails or cannot take the index lock keeps the last index, and the
107
+ listing says so.
108
+ - **A folder on a network share** (`\host\share\...`) is listed as the agent
109
+ recorded it and never probed: an unreachable host blocks every synchronous
110
+ file-system call for seconds, so no refresh touches one.
111
+ - **Antigravity's titles and activity marks** live one small file per
112
+ conversation (`annotations/<id>.pbtxt`, `presence/<id>.lock`). Each is
113
+ stat'ed on every pass and read again only when its own mtime moved; a
114
+ retitle rewrites the file in place and does not change the directory, so
115
+ the directory is no signal.
116
+
117
+ ## Worktrees
118
+
119
+ `leg worktrees` merges three sources into one list: what `git worktree list`
120
+ says for every repository Leg knows (from its sessions, its cards and the
121
+ discovered conversations), the worktrees Leg's sessions and cards recorded
122
+ (even when git no longer lists them), and the worktrees discovered
123
+ conversations ran in. Each row carries the repository, branch, whether the
124
+ directory exists, how many files are uncommitted (git status, on up to 40
125
+ checkouts per call; `null` past that or when `--no-dirty`), who owns it (the
126
+ checkout itself, a Leg session, a Leg card, or nobody Leg knows), which
127
+ conversations point at it and when the last one was active, **orphaned**
128
+ (a `.leg-worktrees/` directory no session or card records), **stale**
129
+ (exists, no live owner, nothing touched it in 14 days) and **missing** (git
130
+ or a record names it, the directory is gone).
131
+
132
+ It is a view. Removing a worktree is still `leg sessions rm`, `leg card rm`
133
+ or the board's Remove, which keep their guards.
134
+
135
+ ## Privacy
136
+
137
+ - The whole `/api/history` and `/api/worktrees` group is the owner's. On a
138
+ shared board a guest gets 403 from every route in it, the same gate as the
139
+ pipeline routes; nothing history knows is pushed over SSE.
140
+ - The API takes an id, never a path. The transcript a record names is read
141
+ only when it sits inside a provider home, an extra account's directory or
142
+ Leg's own `sessions/`; an index edited by hand to point elsewhere reads
143
+ nothing.
144
+ - Discovery writes only `$LEG_HOME/history/index.json`, its lock and its
145
+ temporary sibling, and refuses to write when `LEG_HOME` sits inside a
146
+ provider's store. No SQLite file is ever opened. Junctions and symlinks
147
+ under a store are not followed.
148
+ - `leg history continue` validates the id against the provider's own shape
149
+ (a UUID) before it becomes an argument, and the folder before it becomes
150
+ the working directory: it must exist, be a directory, and not sit under
151
+ `LEG_HOME`. The continued session shares its checkout (no worktree is cut):
152
+ the conversation's files are where it left them.
153
+
154
+ ## Adding a provider
155
+
156
+ One file in `src/history/providers/`, registered in `src/history/index.mjs`:
157
+
158
+ - `name`, `label`, `transcript` (`'supported' | 'unsupported'`), `ID_RE`
159
+ - `root(homes)`: the store's directory (from the CLI's own environment variable
160
+ when it has one; `homes` overrides it in tests)
161
+ - `scan({ home, prev })`: `{ entries, aux, scanned, parsed }` where `entries`
162
+ maps a stable key (the transcript path) to `{ mtime, size, record }`, reusing
163
+ `prev.entries[key]` when the file is unchanged; `record` carries
164
+ `native_id, cwd, branch, title, started_at, updated_at, transcript_path,
165
+ size_bytes, turns, live, native`
166
+ - `messages(record, limit)`: the last messages from a bounded tail, or `null`
167
+ - `resume(record)`: `{ supported, agent, args }` or `{ supported: false, reason }`,
168
+ with the CLI's help text quoted beside it
169
+ - optionally `liveIds(home)`
170
+
171
+ `test/history-fixture.mjs` shows the shapes each existing provider expects;
172
+ a new provider gets a builder there and a row in the matrix above.
@@ -0,0 +1,156 @@
1
+ # Runtime tap: turn boundaries, usage and a safe place to hand off
2
+
3
+ `src/taps/mod.mjs` is Leg's seam for an agent runtime that publishes structured
4
+ events about itself while it runs. It is optional, it is off until one line
5
+ wires it in, and nothing in Leg depends on it existing.
6
+
7
+ ## The gap it closes
8
+
9
+ Leg's per-agent taps read whatever each CLI leaves behind: an OAuth usage
10
+ endpoint every 60 s, a rollout file, a transcript tail. None of those says when
11
+ a turn ends. So when a limit lands, `killTree()` fires at an arbitrary instant:
12
+ mid-tool, mid-answer, with a subagent still running. The handoff bundle then
13
+ describes a moment nobody chose.
14
+
15
+ A runtime that publishes events closes that gap. The tap folds the stream into
16
+ one small record of signals, and the one Leg never had is `cleanBoundary`: the
17
+ turn is closed, no tool call is in flight, no subagent is still running.
18
+
19
+ | Field | What Leg gains |
20
+ | --- | --- |
21
+ | `turnOpen`, `lastTurnCompletedAt` | where a turn starts and ends, to the millisecond |
22
+ | `inFlightTools` | tool calls requested with no completion yet |
23
+ | `subagentsLive` | subagents still running under this session |
24
+ | `cleanBoundary` | all three at rest: a handoff here loses no work and no answer |
25
+ | `usage.contextPercent`, `contextTokens`, `contextWindow` | how full the window is, which degrades an agent long before a rate limit stops it |
26
+ | `usage.fiveHourPercent`, `sevenDayPercent` (+ their reset times) | the same two windows Leg already stores, without the 60 s poll |
27
+ | `usage.costUsd` | what the session has spent |
28
+ | `model`, `lastError` | which model is answering, and the last tool failure or denial |
29
+
30
+ ## Why the runtime side stays optional
31
+
32
+ The events come from a plugin installed in the agent's own harness, not from
33
+ anything Leg installs or launches. Leg's launcher needs no new flag, no
34
+ `--plugin-dir`, and no change to any adapter's argv: the plugin is already
35
+ installed, or it is not.
36
+
37
+ `findEventsFile()` returns `null` when the session has no events file, and that
38
+ is the ordinary case, never an error:
39
+
40
+ - the runtime has no such plugin installed
41
+ - the plugin is installed but this session has not flushed yet
42
+ - the agent is not the one that writes these events at all (codex, agy, grok)
43
+
44
+ In every one of those cases the tap does nothing, calls back never, and Leg
45
+ behaves exactly as it does today. That is the acceptance condition for this
46
+ seam: with the events file absent, no Leg behaviour changes.
47
+
48
+ ## Where the events come from
49
+
50
+ One file per runtime session, JSON per line, appended and flushed on a timer
51
+ and at the end of every main-loop turn:
52
+
53
+ ```
54
+ <config dir>/mods/state/events/<sessionId>.jsonl
55
+ ```
56
+
57
+ `<sessionId>` is the runtime's own session id, which Leg stores as
58
+ `agent_session_id` once its transcript names it (not Leg's `sid`).
59
+ `<config dir>` is the config directory that session ran under, which for Leg is
60
+ `spec.env.CLAUDE_CONFIG_DIR` (each account gets its own). `eventsDirFor()`
61
+ builds that path; `LEG_RUNTIME_EVENTS_DIR` overrides it outright, which is what
62
+ the tests use.
63
+
64
+ The runtime's own event names live in exactly one place in Leg: the `KIND`
65
+ table at the top of `src/taps/mod.mjs`. `deriveSignals()` and `toLegEvents()`
66
+ read that table, and every shape the tap exports is Leg's own, so a second
67
+ runtime with a different vocabulary is a second table and nothing else.
68
+
69
+ ## Wiring it in (one line)
70
+
71
+ Two lines total, both in `src/attach.mjs`, neither of which changes any
72
+ existing behaviour.
73
+
74
+ The import, with the other tap imports (after the `./taps/claude-usage.mjs`
75
+ line):
76
+
77
+ ```js
78
+ import { pollRuntimeTap, eventsDirFor } from './taps/mod.mjs'
79
+ ```
80
+
81
+ The wiring itself, inside `if (agent === 'claude') {`, on the line straight
82
+ after `usageTimer.unref?.()`:
83
+
84
+ ```js
85
+ const runtimeStop = pollRuntimeTap({ sessionId: () => readSession(sid)?.agent_session_id, dir: eventsDirFor(spec.env.CLAUDE_CONFIG_DIR || LAYOUT.claude.home()), onSignals: (signals, { legEvents, advice }) => { for (const ev of legEvents) appendEvent(sid, ev); updateSession(sid, { runtime: { ...signals, advice } }) } })
86
+ ```
87
+
88
+ `readSession`, `updateSession`, `appendEvent` and `LAYOUT` are already imported
89
+ there. `sessionId` is a getter on purpose: neither the id nor the file exists
90
+ when a leg starts, so the tap keeps looking until both do and the caller needs
91
+ no lazy bookkeeping in the poll loop.
92
+
93
+ Teardown is optional. The interval is `unref`'d, so it never holds the process
94
+ open, and a finished session's file simply stops growing (one `stat` every
95
+ 2 s until the leg exits). To stop it exactly, declare `let runtimeStop = null`
96
+ beside `let usageTimer = null`, drop the `const` above, and add
97
+ `runtimeStop?.()` next to `if (usageTimer) clearInterval(usageTimer)`.
98
+
99
+ What the line buys, immediately: turn-level board events (`turn_done`, the
100
+ prompt, subagents, tool failures) instead of a poll-shaped guess, and a
101
+ `session.runtime` record the board can render.
102
+
103
+ Two follow-ups this seam makes possible, both deliberately not wired here:
104
+
105
+ - feed the percentages to the chooser through the door every other tap uses:
106
+ `recordUsage('claude', account, toLegUsage(signals), 'runtime events')`
107
+ (`recordUsage` is already imported in `attach.mjs`)
108
+ - hand off at a boundary Leg chose, by acting on `advice.shouldHandoff` in the
109
+ same place the limit handoff already fires
110
+
111
+ ## Handing off on purpose
112
+
113
+ `handoffAdvice(signals, thresholds)` answers with `{ shouldHandoff, reason }`.
114
+ Defaults:
115
+
116
+ | Threshold | Default | Why |
117
+ | --- | --- | --- |
118
+ | `contextPercent` | 80 | a full window degrades an agent long before a limit stops it |
119
+ | `fiveHourPercent` | 90 | just under the wall Leg already hands off at |
120
+ | `sevenDayPercent` | 95 | a last resort; the 7-day window rarely moves first |
121
+
122
+ Being over a threshold is not enough. Over a threshold but mid-turn returns
123
+ `shouldHandoff: false` with a reason that says what it is waiting for
124
+ ("context at 84% of the window, waiting for a clean boundary (a turn is
125
+ open)"), so the board can show the wait rather than a silent stall. A
126
+ percentage the runtime has not published yet never triggers a handoff.
127
+
128
+ ## The API
129
+
130
+ | Function | Answers |
131
+ | --- | --- |
132
+ | `findEventsFile(sessionId, { dir })` | the path, or `null` when this session publishes nothing |
133
+ | `readRuntimeEvents(path, cursor)` | `{ events, cursor }` from a byte offset; a half-written last line is left for the next read, a truncated file restarts at 0, a corrupt line is skipped |
134
+ | `deriveSignals(events, prev)` | the signals record above; pure, and folding in batches equals folding at once |
135
+ | `toLegEvents(events)` | `{ type, summary }` board events, as `appendEvent(sid, ev)` takes them |
136
+ | `toLegUsage(signals)` | `{ five_hour: { pct, resets_at }, seven_day: ... }`, the window shape `recordUsage()` already stores |
137
+ | `handoffAdvice(signals, thresholds)` | `{ shouldHandoff, reason }` |
138
+ | `pollRuntimeTap({ sessionId, dir, intervalMs, thresholds, onSignals })` | the whole tap on a timer; returns `stop()` |
139
+
140
+ `emptySignals()` is the zero state, and it is what `deriveSignals([])` returns:
141
+ nothing seen, nothing in flight, every percentage `null`.
142
+
143
+ ## Tests
144
+
145
+ `test/taps-mod.test.mjs`, 11 tests, run by `node --test`. The fixture
146
+ `fixtures/runtime-events.jsonl` is one real captured session (29 events, one
147
+ turn, four tool calls, the usage frame last) with the local user name scrubbed
148
+ out of the paths and nothing else changed. Subagent, denial and error cases are
149
+ built in the test, because that capture has none.
150
+
151
+ Covered: incremental reads across a torn line, truncation and a corrupt line;
152
+ `cleanBoundary` shut by an open turn and by a tool with no completion, and
153
+ released by the turn that closes; a live subagent, a denied one, and a tool
154
+ call inside a subagent's own loop; usage extraction and its mapping to Leg's
155
+ window shape; advice at 80% context only at a clean boundary; and the no-file
156
+ fallback, where every entry point answers and nothing throws.
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "grok-balance-exhausted",
3
+ "adapter": "grok",
4
+ "source": "observed-live",
5
+ "produced_by": "grok 1.0.34 -p --output-format json on an account with no balance left, 2026-09-17: stdout {\"type\":\"error\",\"message\":\"Internal error: {\n \\\"message\\\": \\\"API error (status 402 Payment Required): Grok Build usage balance exhausted\\\",\n \\\"http_status\\\": 402\n}\"}, exit 1",
6
+ "where": "any",
7
+ "text": "API error (status 402 Payment Required): Grok Build usage balance exhausted",
8
+ "pattern": "usage balance exhausted|status 402 Payment Required",
9
+ "exit_code": null,
10
+ "classification": "limit"
11
+ }
@@ -1 +1 @@
1
- ~\.grok\bin\grok.exe -p "Create a file named hello-grok.txt in the current directory containing exactly the word hi. Then create the directory .baton if it is missing and write the file .baton/DONE containing the single line: done. Do nothing else. Do not ask questions.\n" --output-format json --permission-mode acceptEdits
1
+ ~\.grok\bin\grok.exe --prompt-file ~\.baton\cards\card-20260917-1748-probe-grok\runs\1\prompt.txt --output-format json --permission-mode acceptEdits --cwd ~\AppData\Local\Temp\claude\C--Projects-leg\9c2d72c5-4654-46ea-a183-81767ec30af5\scratchpad\probe-repo3
@@ -1,5 +1,8 @@
1
1
  {
2
- "session_id": "01a08de5-c64c-7a31-ac58-1c11c2dd7332",
3
- "last_message": null,
4
- "stop_reason": null
2
+ "session_id": null,
3
+ "last_message": "Internal error: {\n \"message\": \"API error (status 402 Payment Required): Grok Build usage balance exhausted\",\n \"http_status\": 402\n}",
4
+ "stop_reason": "error",
5
+ "subtype": null,
6
+ "is_error": true,
7
+ "num_turns": null
5
8
  }
@@ -1,18 +1,30 @@
1
1
  {
2
- "card_id": "card-20260911-0036-probe-grok",
2
+ "card_id": "card-20260917-1748-probe-grok",
3
3
  "run": 1,
4
4
  "adapter": "grok",
5
5
  "mode": "acceptEdits",
6
6
  "max_turns": null,
7
+ "model": null,
7
8
  "resume": null,
8
- "cwd": "~\\AppData\\Local\\Temp\\claude\\C--Projects\\49952fac-5ff4-486c-8395-9973cebdfdcb\\scratchpad\\toy-probe",
9
+ "cwd": "~\\AppData\\Local\\Temp\\claude\\C--Projects-leg\\9c2d72c5-4654-46ea-a183-81767ec30af5\\scratchpad\\probe-repo3",
9
10
  "status": "exited",
10
- "supervisor_pid": 70732,
11
- "agent_pid": 12580,
12
- "started_at": "2026-09-11T00:36:48.183Z",
13
- "outcome": null,
14
- "updated_at": "2026-09-11T00:37:45.845Z",
15
- "exit_code": 0,
16
- "session_id": "01a08de5-c64c-7a31-ac58-1c11c2dd7332",
17
- "ended_at": "2026-09-11T00:37:45.380Z"
11
+ "supervisor_pid": 47116,
12
+ "agent_pid": 48864,
13
+ "driver_pid": null,
14
+ "started_at": "2026-09-17T17:48:07.055Z",
15
+ "outcome": "limit",
16
+ "updated_at": "2026-09-17T17:48:18.619Z",
17
+ "exit_code": 1,
18
+ "session_id": null,
19
+ "ended_at": "2026-09-17T17:48:18.538Z",
20
+ "signal": "grok-balance-exhausted",
21
+ "handoff": true,
22
+ "reason": "observed-live limit signal: grok-balance-exhausted",
23
+ "done_marker": false,
24
+ "diff": {
25
+ "changed": true,
26
+ "files": 1,
27
+ "head_at_start": "f8d264942a080ca398751d0d481d612f73ef3d62",
28
+ "head": "f8d264942a080ca398751d0d481d612f73ef3d62"
29
+ }
18
30
  }
@@ -2,7 +2,7 @@
2
2
  "_comment": "The single source of truth for every dated, numbered claim Leg makes in public. scripts/check-claims.mjs asserts that each surface listed here states exactly these values, and --strict additionally compares the pinned CLI versions against what those CLIs currently publish. Re-verify on a real machine, then edit this file and every surface in the same commit.",
3
3
  "verifiedOn": "2026-09-11",
4
4
  "verifiedOnLong": "11 September 2026",
5
- "tests": 473,
5
+ "tests": 674,
6
6
  "clis": {
7
7
  "claude": {
8
8
  "label": "Claude Code",
@@ -22,6 +22,13 @@
22
22
  "npm": null,
23
23
  "probe": "agy --version",
24
24
  "note": "Antigravity CLI is a closed Go binary with no npm package, so drift is only visible from a machine that has it installed."
25
+ },
26
+ "grok": {
27
+ "label": "grok",
28
+ "version": "1.0.34",
29
+ "npm": null,
30
+ "probe": "grok --version",
31
+ "note": "Grok Build ships as a downloaded binary, not an npm package. Probed on 2026-09-17, separately from the 2026-09-11 sweep above: the flags came from grok --help and the headless envelope from the shipped exe, and scripts/probe.mjs reached the account and returned a real 402 wall. The success path of a grok leg is still unprobed for want of balance on the account."
25
32
  }
26
33
  },
27
34
  "surfaces": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ucsandman/legcli",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
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",
@@ -65,19 +65,26 @@ const PAGES = [
65
65
  title: 'The portable harness: carry rules, hooks, skills and MCP servers between agents',
66
66
  description: 'How Leg carries the source agent\'s working environment to the agent a handoff lands on, what moves and what does not, the policies, ownership and backups, and how secrets are handled.',
67
67
  },
68
+ {
69
+ slug: 'history',
70
+ nav: 'History',
71
+ source: 'docs/history.md',
72
+ title: 'Every conversation on this machine: leg history and leg worktrees',
73
+ description: 'One read-only index over the conversations Claude Code, Codex, Grok, Antigravity and Copilot CLI keep in their own stores, plus the sessions Leg started itself, and every checkout: the support matrix, what is read, what is written, and how to continue one.',
74
+ },
68
75
  {
69
76
  slug: 'cli-contracts',
70
77
  nav: 'What it reads',
71
78
  source: 'docs/cli-contracts.md',
72
- title: 'What Leg reads from Claude Code, Codex and agy',
73
- description: 'The exact usage endpoints, hooks, log lines and limit strings Leg reads from Claude Code, Codex and agy, each cited to its source.',
79
+ title: 'What Leg reads from Claude Code, Codex, agy and Grok',
80
+ description: 'The exact usage endpoints, hooks, log lines and limit strings Leg reads from Claude Code, Codex, agy and Grok, each cited to its source.',
74
81
  },
75
82
  {
76
83
  slug: 'adapters',
77
84
  nav: 'Adapters',
78
85
  source: 'docs/adapters.md',
79
- title: 'Leg agent adapters for Claude Code, Codex and agy',
80
- description: 'How each agent adapter spawns its CLI, what it strips from the environment, and how to tell whether a tap is live or docs-only.',
86
+ title: 'Leg agent adapters for Claude Code, Codex, agy and Grok',
87
+ description: 'How each agent adapter spawns its CLI, what it strips from the environment, how to tell whether a tap is live or docs-only, and how to add any other CLI from a JSON spec.',
81
88
  },
82
89
  {
83
90
  slug: 'faq',
package/scripts/probe.mjs CHANGED
@@ -79,7 +79,8 @@ mkdirSync(out, { recursive: true })
79
79
  for (const f of ['out.log', 'err.log', 'run.json', 'supervisor.log', 'last.md']) {
80
80
  if (existsSync(join(runDir, f))) writeFileSync(join(out, f), scrubPaths(readFileSync(join(runDir, f), 'utf8')))
81
81
  }
82
- const spec = adapter.argv({ mode, cwd: repo, prompt: PROMPT, runDir, killMs: 5400000 })
82
+ // the same opts the runner passed, so cmd.txt is the command that really ran
83
+ const spec = adapter.argv({ mode, cwd: repo, prompt: PROMPT, promptFile: join(runDir, 'prompt.txt'), runDir, killMs: 5400000 })
83
84
  writeFileSync(join(out, 'cmd.txt'), scrubPaths([spec.bin, ...spec.args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')) + '\n')
84
85
  const textOf = (f) => (existsSync(join(runDir, f)) ? readFileSync(join(runDir, f), 'utf8') : '')
85
86
  const authSource = /another auth source/i.test(textOf('err.log') + textOf('out.log'))
package/src/accounts.mjs CHANGED
@@ -43,12 +43,15 @@ export const LAYOUT = {
43
43
  export function accountsFile() { return join(home(), 'accounts.json') }
44
44
  export function accountDir(agent, name) { return join(home(), 'accounts', agent, name) }
45
45
 
46
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/i
47
+
46
48
  export function readAccounts() {
47
49
  const base = { claude: ['default'], codex: ['default'], agy: ['default'], grok: ['default'] }
48
50
  if (!existsSync(accountsFile())) return base
49
51
  try {
50
52
  const j = JSON.parse(readFileSync(accountsFile(), 'utf8'))
51
- for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => n !== 'default')]
53
+ // the same shape addAccount accepts: a name is a directory segment, never a path
54
+ for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => typeof n === 'string' && n !== 'default' && NAME_RE.test(n))]
52
55
  } catch {}
53
56
  return base
54
57
  }
@@ -74,7 +77,7 @@ function junction(target, link) {
74
77
 
75
78
  // Create the account dir, junction the shared harness in, copy the settings.
76
79
  export function addAccount(agent, name) {
77
- 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")`)
80
+ if (!NAME_RE.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
78
81
  const l = LAYOUT[agent]
79
82
  if (!l) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
80
83
  if (!l.env) throw new Error(`${agent} has no config-dir override in the installed version; extra accounts are not possible`)
@@ -0,0 +1,130 @@
1
+ // `leg adapter …` — the custom-adapter surface. A spec is a JSON file in
2
+ // $LEG_HOME/adapters/<name>.json; these verbs write, validate and explain it
3
+ // so a bad spec is caught here rather than when a card tries to spawn.
4
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
5
+ import { resolve } from 'node:path'
6
+ import { writeJsonAtomic } from '../fsx.mjs'
7
+ import { BUILTIN_NAMES, customSpecs, get as getAdapter } from './index.mjs'
8
+ import { adaptersDir, specPath, validateSpec, makeAdapter, SpecError, TEMPLATE, PLACEHOLDERS } from './custom.mjs'
9
+
10
+ const USAGE = `usage:
11
+ leg adapter list every adapter this machine has, built-in and custom
12
+ leg adapter show <name> the spec, as JSON
13
+ leg adapter check <name> validate it and print the command line a leg would run
14
+ leg adapter add <file.json> [--name n] validate a spec and install it
15
+ leg adapter rm <name> remove a custom spec
16
+ leg adapter template [--name n] print a starter spec to fill in`
17
+
18
+ function quote(a) { return /\s/.test(a) ? JSON.stringify(a) : a }
19
+
20
+ export async function adapterCommand(verb, args, { out, die }) {
21
+ const first = args._?.[0]
22
+
23
+ if (!verb || verb === 'help' || verb === '--help') { out(USAGE); return 0 }
24
+
25
+ if (verb === 'list') {
26
+ const specs = customSpecs()
27
+ if (args.json) {
28
+ out(JSON.stringify({
29
+ builtin: BUILTIN_NAMES,
30
+ custom: specs.map((s) => ({ name: s.name, file: s.file, ok: Boolean(s.adapter), error: s.error, bin: s.spec?.bin ?? null })),
31
+ }, null, 2))
32
+ return specs.some((s) => s.error) ? 1 : 0
33
+ }
34
+ out('built-in:')
35
+ for (const n of BUILTIN_NAMES) out(` ${n}`)
36
+ out(`custom (${adaptersDir()}):`)
37
+ if (!specs.length) out(' none yet — leg adapter template > my-agent.json, then leg adapter add my-agent.json')
38
+ for (const s of specs) {
39
+ out(s.adapter ? ` ${s.name.padEnd(20)} ${s.spec.bin}` : ` ${s.name.padEnd(20)} BROKEN: ${s.error}`)
40
+ }
41
+ return specs.some((s) => s.error) ? 1 : 0
42
+ }
43
+
44
+ if (verb === 'template') {
45
+ const spec = { ...TEMPLATE }
46
+ // the common case is that the command is called what the adapter is called
47
+ if (typeof args.name === 'string') { spec.name = args.name; spec.bin = args.name }
48
+ out(JSON.stringify(spec, null, 2))
49
+ return 0
50
+ }
51
+
52
+ if (verb === 'show') {
53
+ if (!first) return die(2, 'usage: leg adapter show <name>')
54
+ const hit = customSpecs().find((s) => s.name === first)
55
+ if (!hit) {
56
+ if (BUILTIN_NAMES.includes(first)) return die(2, `${first} is a built-in adapter, not a spec on disk (src/adapters/${first}.mjs)`)
57
+ return die(3, `no custom adapter called "${first}" in ${adaptersDir()}`)
58
+ }
59
+ if (hit.error) return die(1, `${first}: ${hit.error}`)
60
+ out(JSON.stringify(hit.spec, null, 2))
61
+ return 0
62
+ }
63
+
64
+ if (verb === 'check') {
65
+ if (!first) return die(2, 'usage: leg adapter check <name>')
66
+ let adapter
67
+ try { adapter = await getAdapter(first) } catch (err) { return die(3, err.message) }
68
+ const cwd = typeof args.cwd === 'string' ? args.cwd : process.cwd()
69
+ let spec
70
+ try {
71
+ spec = adapter.argv({
72
+ prompt: 'THE TASK PROMPT',
73
+ promptFile: typeof args['prompt-file'] === 'string' ? args['prompt-file'] : null,
74
+ cwd,
75
+ mode: typeof args.mode === 'string' ? args.mode : undefined,
76
+ model: typeof args.model === 'string' ? args.model : null,
77
+ runDir: null,
78
+ killMs: 5400000,
79
+ })
80
+ } catch (err) { return die(1, `${first}: ${err.message}`) }
81
+ out(`${first}: ${adapter.custom ? 'custom spec' : 'built in'}`)
82
+ out(` stdin ${adapter.stdin}`)
83
+ out(` modes ${adapter.modes.allowed.join(', ')} (default ${adapter.modes.default})`)
84
+ out(` would run ${[spec.bin, ...spec.args].map(quote).join(' ')}`)
85
+ // A spec that names a binary nothing can find spawns once and fails once;
86
+ // saying so here is the difference between a typo and a mystery.
87
+ const bin = spec.bin
88
+ const found = existsSync(bin) || !/[\\/]/.test(bin)
89
+ out(` binary ${bin}${existsSync(bin) ? ' (found)' : found ? ' (looked up on PATH at spawn time)' : ' — NOT FOUND at that path'}`)
90
+ return existsSync(bin) || found ? 0 : 1
91
+ }
92
+
93
+ if (verb === 'add') {
94
+ if (!first) return die(2, 'usage: leg adapter add <file.json> [--name <name>]')
95
+ const file = resolve(first)
96
+ if (!existsSync(file)) return die(2, `no such file: ${file}`)
97
+ let raw
98
+ try { raw = JSON.parse(readFileSync(file, 'utf8')) } catch (err) { return die(2, `${file} is not JSON: ${err.message}`) }
99
+ if (raw && typeof raw === 'object' && typeof args.name === 'string') raw.name = args.name
100
+ let spec
101
+ try { spec = validateSpec(raw, { reserved: BUILTIN_NAMES }) } catch (err) {
102
+ if (err instanceof SpecError) return die(2, err.message)
103
+ throw err
104
+ }
105
+ const dest = specPath(spec.name)
106
+ const replacing = existsSync(dest)
107
+ mkdirSync(adaptersDir(), { recursive: true })
108
+ writeJsonAtomic(dest, spec)
109
+ const adapter = makeAdapter(spec)
110
+ // the runner always writes a prompt file, so the preview shows one too
111
+ const line = adapter.argv({ prompt: 'THE TASK PROMPT', promptFile: '<run>/prompt.txt', cwd: process.cwd() })
112
+ out(`${replacing ? 'replaced' : 'added'} ${spec.name} → ${dest}`)
113
+ out(` would run ${[line.bin, ...line.args].map(quote).join(' ')}`)
114
+ out(` use it leg card add --repo <path> --task "<t>" --chain ${spec.name},claude --queue`)
115
+ return 0
116
+ }
117
+
118
+ if (verb === 'rm' || verb === 'remove') {
119
+ if (!first) return die(2, 'usage: leg adapter rm <name>')
120
+ if (BUILTIN_NAMES.includes(first)) return die(2, `${first} is built in; there is no spec file to remove`)
121
+ const dest = specPath(first)
122
+ if (!existsSync(dest)) return die(3, `no custom adapter called "${first}" in ${adaptersDir()}`)
123
+ rmSync(dest, { force: true })
124
+ out(`removed ${first} (${dest})`)
125
+ out('cards that already name it keep their chain; they will fail to launch until it is added again')
126
+ return 0
127
+ }
128
+
129
+ return die(2, `unknown: leg adapter ${verb}\n\n${USAGE}\n\nplaceholders: ${PLACEHOLDERS.map((p) => `{{${p}}}`).join(' ')}`)
130
+ }