@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.
- package/CHANGELOG.md +146 -0
- package/README.md +110 -11
- package/bin/leg.mjs +78 -15
- package/docs/ERRORS.md +187 -0
- package/docs/README.md +3 -1
- package/docs/ROADMAP-v2.md +24 -11
- package/docs/VOCABULARY.md +1 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +20 -1
- package/docs/cli-contracts.md +50 -17
- package/docs/configuration.md +56 -5
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +1 -1
- package/scripts/build-docs-site.mjs +11 -4
- package/scripts/probe.mjs +2 -1
- package/src/accounts.mjs +5 -2
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +85 -13
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +38 -1
- package/src/board/board.js +14 -2
- package/src/board/history.js +377 -0
- package/src/board/index.html +55 -0
- package/src/board/sessions.js +49 -7
- package/src/history/cli.mjs +159 -0
- package/src/history/common.mjs +119 -0
- package/src/history/index.mjs +429 -0
- package/src/history/providers/agy.mjs +91 -0
- package/src/history/providers/claude.mjs +161 -0
- package/src/history/providers/codex.mjs +133 -0
- package/src/history/providers/copilot.mjs +94 -0
- package/src/history/providers/grok.mjs +138 -0
- package/src/history/worktrees.mjs +116 -0
- package/src/redact.mjs +23 -5
- package/src/server.mjs +272 -28
- package/src/sessions.mjs +9 -0
- package/src/share.mjs +66 -6
- package/src/taps/claude.mjs +11 -4
- package/src/taps/grok.mjs +4 -0
- package/src/taps/mod.mjs +340 -0
- package/src/usage.mjs +21 -5
- package/src/worktree.mjs +1 -1
package/docs/history.md
ADDED
|
@@ -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 -
|
|
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":
|
|
3
|
-
"last_message":
|
|
4
|
-
"stop_reason":
|
|
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-
|
|
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\\
|
|
9
|
+
"cwd": "~\\AppData\\Local\\Temp\\claude\\C--Projects-leg\\9c2d72c5-4654-46ea-a183-81767ec30af5\\scratchpad\\probe-repo3",
|
|
9
10
|
"status": "exited",
|
|
10
|
-
"supervisor_pid":
|
|
11
|
-
"agent_pid":
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
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
|
}
|
package/fixtures/verified.json
CHANGED
|
@@ -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":
|
|
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.
|
|
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
|
|
73
|
-
description: 'The exact usage endpoints, hooks, log lines and limit strings Leg reads from Claude Code, Codex and
|
|
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
|
|
80
|
-
description: 'How each agent adapter spawns its CLI, what it strips from the environment,
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
+
}
|