@ucsandman/legcli 0.13.1 → 0.15.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 +115 -0
- package/LICENSE +2 -0
- package/README.md +62 -22
- package/bin/leg.mjs +108 -65
- package/docs/DECISIONS.md +10 -1
- package/docs/DEVIATIONS.md +33 -0
- package/docs/ERRORS.md +22 -0
- package/docs/ROADMAP-v2.md +12 -1
- package/docs/concepts.md +52 -14
- package/docs/configuration.md +5 -3
- package/docs/review-2026-09-18.md +172 -0
- package/fixtures/verified.json +1 -1
- package/package.json +2 -2
- package/src/accounts.mjs +67 -7
- package/src/attach.mjs +184 -48
- package/src/board/board.js +1 -1
- package/src/board/floor.js +1 -1
- package/src/bundle.mjs +33 -11
- package/src/digest.mjs +197 -0
- package/src/git.mjs +97 -0
- package/src/handoff.mjs +20 -2
- package/src/history/common.mjs +3 -1
- package/src/history/providers/claude.mjs +5 -1
- package/src/history/worktrees.mjs +105 -25
- package/src/launcher.mjs +1 -1
- package/src/license.mjs +38 -13
- package/src/limits.mjs +19 -1
- package/src/scheduler-status.mjs +22 -0
- package/src/scheduler.mjs +3 -14
- package/src/server.mjs +171 -38
- package/src/session-detail.mjs +25 -2
- package/src/sessions.mjs +18 -3
- package/src/synthesis.mjs +23 -4
- package/src/usage.mjs +27 -1
package/docs/concepts.md
CHANGED
|
@@ -19,7 +19,7 @@ Each session gets a directory under `$LEG_HOME/sessions/<id>/`
|
|
|
19
19
|
|
|
20
20
|
| file | what it holds |
|
|
21
21
|
|------|---------------|
|
|
22
|
-
| `session.json` | the live record the board renders
|
|
22
|
+
| `session.json` | the live record the board renders. The runner owns it, and the board's usage poller, Claude Code's hooks and a board action patch fields on it too; every write is one atomic replace under `.session.lock` (`updateSession`), so no writer can lose another's field |
|
|
23
23
|
| `events.jsonl` | the timeline (see [VOCABULARY.md](VOCABULARY.md#session-event-types)) |
|
|
24
24
|
| `control.json` | requests from the board to the runner, for example `{ handoff: true }` |
|
|
25
25
|
| `hook.log` | what Claude Code's hooks sent, claude sessions only |
|
|
@@ -70,12 +70,17 @@ override, so agy stays one account.
|
|
|
70
70
|
Your harness is shared into an extra account, never copied into a fork that
|
|
71
71
|
drifts: the directories are junctions back to the real home (claude: `hooks`,
|
|
72
72
|
`skills`, `agents`, `commands`, `plugins`, `rules`, `scripts`,
|
|
73
|
-
`output-styles`, `tools`; codex: `skills`, `prompts`, `rules`,
|
|
74
|
-
`agents`, `hooks`, `memories`, `superpowers`), and the settings
|
|
75
|
-
copied fresh before every launch (claude: `settings.json`,
|
|
73
|
+
`output-styles`, `tools`, `projects`; codex: `skills`, `prompts`, `rules`,
|
|
74
|
+
`plugins`, `agents`, `hooks`, `memories`, `superpowers`), and the settings
|
|
75
|
+
files are copied fresh before every launch (claude: `settings.json`,
|
|
76
76
|
`settings.local.json`, `CLAUDE.md`, `keybindings.json`, `statusline.ps1`,
|
|
77
77
|
`statusline-combined.ps1`; codex: `config.toml`, `AGENTS.md`). Only the login
|
|
78
|
-
itself lives in the account directory.
|
|
78
|
+
itself lives in the account directory. claude's `projects` is its
|
|
79
|
+
conversation store, shared so that a hand-off to the second login can
|
|
80
|
+
`--resume` the transcript the first one was writing (see Handoff, step 3) and
|
|
81
|
+
so the same human's auto-memory follows them; `refreshAccount()` adds a
|
|
82
|
+
junction an older account is missing before every launch, and the history
|
|
83
|
+
index skips a junctioned store so a conversation is listed once.
|
|
79
84
|
|
|
80
85
|
`leg accounts add <claude|codex> <name>` creates one and prints the single
|
|
81
86
|
line to paste to log in. `leg accounts rm` removes the junctions as links,
|
|
@@ -161,7 +166,10 @@ in that picker ("the next option in the order") is still a press of the button.
|
|
|
161
166
|
`context-handoff-bundle save --repo-local --slug leg-<session id>`, with
|
|
162
167
|
`--update <slug>` after the first time, so one bundle per session is updated
|
|
163
168
|
in place. A checkpoint runs about every two minutes while the session has
|
|
164
|
-
turns, and at every warning, limit and hand-off.
|
|
169
|
+
turns, and at every warning, limit and hand-off. The periodic checkpoint runs
|
|
170
|
+
beside the terminal's poll rather than inside it, one at a time, so a slow
|
|
171
|
+
save never stops the terminal noticing a limit or a board button; a warning,
|
|
172
|
+
a limit and a hand-off save are still finished before the next leg starts.
|
|
165
173
|
2. **Choose.** `candidates()` lists the other accounts of the same agent first,
|
|
166
174
|
then walks the ladder, and after each rung, the other accounts of that
|
|
167
175
|
rung's agent. `evaluateLadder()` walks that list top to bottom and takes the
|
|
@@ -191,14 +199,22 @@ in that picker ("the next option in the order") is still a press of the button.
|
|
|
191
199
|
ladder for an active terminal; the wrapper reads it again at the transition
|
|
192
200
|
and during all-out waiting. Machine Settings is copied only when a new
|
|
193
201
|
terminal starts.
|
|
194
|
-
3. **Switch.** The agent process is stopped and the terminal restored.
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
+
3. **Switch.** The agent process is stopped and the terminal restored. Two
|
|
203
|
+
claude moves keep the conversation instead of taking the bundle, decided by
|
|
204
|
+
one rule both the terminal and the board's picker read
|
|
205
|
+
(`keepsConversation()` in `src/usage.mjs`): a same-login move to a weaker
|
|
206
|
+
model (a downshift, by the `fable, opus, sonnet, haiku` order), which runs
|
|
207
|
+
`claude --resume <agent_session_id> --model <alias>`; and a move to another
|
|
208
|
+
claude login whose home can see the transcript through its `projects`
|
|
209
|
+
junction, which runs `claude --resume <agent_session_id>` under that
|
|
210
|
+
login's `CLAUDE_CONFIG_DIR` (with `--model` only when the rung names one).
|
|
211
|
+
Both need the agent's own session id on the record; the second also needs
|
|
212
|
+
the transcript file to exist under the destination home, checked at the
|
|
213
|
+
switch, so a login made before the junction existed takes the bundle until
|
|
214
|
+
its next launch adds it. No bundle is written into a prompt, and the
|
|
215
|
+
ledger says `kept the conversation`. Every other rung, including a
|
|
216
|
+
same-login upshift back to a stronger model or a different agent, takes
|
|
217
|
+
the bundle: the `context-handoff-bundle load <id>` output
|
|
202
218
|
(with the `## Synthesis` section prepended if
|
|
203
219
|
`.leg/SYNTHESIS-<session-id>.md` is present) is written to
|
|
204
220
|
`.leg/RESUME-<session-id>.md` and copied to `.leg/RESUME.md`, and the next
|
|
@@ -314,6 +330,28 @@ on:
|
|
|
314
330
|
`leg share off` puts the board back on `127.0.0.1` and every link stops
|
|
315
331
|
working; `leg share rotate <name>` replaces one.
|
|
316
332
|
|
|
333
|
+
## The digest
|
|
334
|
+
|
|
335
|
+
`leg digest [--since 8h|2d|<iso>] [--json]` and `GET /api/digest?since=`
|
|
336
|
+
(owner only) answer the question a person asks after eight hours away
|
|
337
|
+
(`src/digest.mjs`). It reads the session records, their `events.jsonl`, the
|
|
338
|
+
cards' ledgers, `landings.jsonl` and the usage records, and writes nothing.
|
|
339
|
+
The record is `{ since, until, volume, attention, repos, walls }`: `volume`
|
|
340
|
+
is what was read (terminals, cards, landings and events in the window, and
|
|
341
|
+
the sessions and cards on disk), printed on the first line so a quiet night
|
|
342
|
+
and an empty home cannot read the same; `attention` is what needs a person,
|
|
343
|
+
ranked (a live terminal waiting on a question, a card in `waiting_human`,
|
|
344
|
+
`needs_approval` or `paused`, a card that failed in the window, a terminal
|
|
345
|
+
that was lost in the window); `repos` groups every terminal that moved or is
|
|
346
|
+
live, every card that moved or is live, and every landing, by repository;
|
|
347
|
+
`walls` is every login and every model that is out right now, soonest reset
|
|
348
|
+
first. A terminal counts when it moved in the window or is live now, and its
|
|
349
|
+
entry carries the events worth a line (`limit`, `handoff`, `all_out`,
|
|
350
|
+
`lost`, `ended`, `landed`, `bounced`, `continued`, `harness_blocked`), never
|
|
351
|
+
the running commentary. The audit trail ([Share](#share-more-than-one-human))
|
|
352
|
+
is the same data as a flat list of actors; the digest is the same data
|
|
353
|
+
grouped by what to do about it.
|
|
354
|
+
|
|
317
355
|
## Cards, stations and pipelines
|
|
318
356
|
|
|
319
357
|
A **card is a terminal you are not sitting at.** It has the same register
|
package/docs/configuration.md
CHANGED
|
@@ -35,7 +35,7 @@ These apply to `leg claude|codex|agy|grok`.
|
|
|
35
35
|
| `LEG_WAIT_TICK_MS` | `1000` | how often the all-out countdown redraws and re-checks Ctrl-C / End while waiting for the first reset | `src/attach.mjs` |
|
|
36
36
|
| `LEG_USAGE_POLL_MS` | `60000` | how often the board asks each login's usage source (one poller per login, not one per terminal); Claude uses its usage endpoint, Codex read-only app-server rate limits, Grok its billing proxy | `src/usage-poll.mjs` |
|
|
37
37
|
| `LEG_USAGE_POLL_MAX_MS` | `600000` | the longest the board waits between readings for one login: a refusal (429, timeout, no login) doubles the wait up to this, and the first good reading puts it back to `LEG_USAGE_POLL_MS` | `src/usage-poll.mjs` |
|
|
38
|
-
| `LEG_ATTACH_POLL_MS` | `2000` | how often the session loop re-reads the taps; git is re-read every third poll | `src/attach.mjs` |
|
|
38
|
+
| `LEG_ATTACH_POLL_MS` | `2000` | how often the session loop re-reads the taps; git is re-read every third poll, as one `git status --porcelain=v2 --branch` (`src/git.mjs`) that carries the head, the branch, the dirty list and the upstream's own ahead count | `src/attach.mjs` |
|
|
39
39
|
| `LEG_CLAUDE_USAGE_URL` | `https://api.anthropic.com/api/oauth/usage` | the usage endpoint, for a test double | `src/taps/claude-usage.mjs` |
|
|
40
40
|
| `LEG_CLAUDE_ARGS`, `LEG_CODEX_ARGS`, `LEG_AGY_ARGS` | (none) | space-separated extra arguments for a leg Leg starts on its own after a hand-off (your own `leg <agent> …` args never apply to the next agent); e.g. `LEG_CODEX_ARGS="-m gpt-5.3-codex-spark"` keeps a test chain on cheap models | `src/attach.mjs` |
|
|
41
41
|
| `LEG_LIVE_DIR` | `fixtures/live/` in a dev clone, else `~/.leg/live/` | where the first real limit payload per agent and signal is kept, secrets scrubbed (`src/live-capture.mjs`); a `leg sessions simulate-limit` payload is never kept | `src/live-capture.mjs`, `scripts/live-limits.mjs` |
|
|
@@ -133,7 +133,7 @@ permission, so it is always on.
|
|
|
133
133
|
|
|
134
134
|
| variable | default | meaning | read in |
|
|
135
135
|
|----------|---------|---------|---------|
|
|
136
|
-
| `LEG_HOME` | `~/.leg` | where sessions, usage, accounts, cards, runs
|
|
136
|
+
| `LEG_HOME` | `~/.leg` | where sessions, usage, accounts, cards, runs, the pidfiles and `installed.json` (which agent CLIs answered `--version`, remembered for a day per resolved bin) live | `src/store.mjs`, `src/ledger.mjs`, `src/runner.mjs`, `src/worktree.mjs`, `src/cards.mjs`, `src/sessions.mjs`, `src/usage.mjs`, `src/accounts.mjs` |
|
|
137
137
|
| `LEG_PORT` | `4747` | board server port | `src/server.mjs`, `src/launcher.mjs`, `bin/leg.mjs` (`open`) |
|
|
138
138
|
| `LEG_BIND` | `127.0.0.1` | board server bind address | `src/server.mjs`, `src/launcher.mjs` |
|
|
139
139
|
| `LEG_TOKEN` | (none) | bearer token required for `/api/*` and the event stream once set | `src/server.mjs`, `src/auth.mjs` |
|
|
@@ -205,7 +205,9 @@ home. `src/accounts.mjs` `LAYOUT` is the whole definition.
|
|
|
205
205
|
accounts.json the named accounts per agent
|
|
206
206
|
accounts/claude/<name>/
|
|
207
207
|
hooks/ skills/ agents/ commands/ plugins/ rules/ scripts/
|
|
208
|
-
output-styles/ tools/
|
|
208
|
+
output-styles/ tools/ projects/ junctions back to ~/.claude (projects is
|
|
209
|
+
the conversation store: a hand-off to this
|
|
210
|
+
login can --resume the same transcript)
|
|
209
211
|
settings.json settings.local.json CLAUDE.md keybindings.json
|
|
210
212
|
statusline.ps1 statusline-combined.ps1
|
|
211
213
|
copies, refreshed before every launch
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Review notes, 2026-09-18: product, performance and architecture pass
|
|
2
|
+
|
|
3
|
+
A requested first-principles pass over Leg as a product and as a program.
|
|
4
|
+
Working file, not published to the docs site. What shipped from it is in
|
|
5
|
+
`CHANGELOG.md` (0.15.0); what did not is designed here so the next pass can
|
|
6
|
+
pick it up without re-deriving it. Measurements are from
|
|
7
|
+
`scratchpad/prof/BASELINE.md` (kept out of the repo) and are summarised in
|
|
8
|
+
the CHANGELOG entry with their reproduction commands.
|
|
9
|
+
|
|
10
|
+
## What Leg is, and what it should become
|
|
11
|
+
|
|
12
|
+
Today Leg is a usage-wall relay: the same terminal keeps going when a login
|
|
13
|
+
stops. Around that wedge a lot arrived in one week: rungs of (agent, login,
|
|
14
|
+
model), a board, cards and pipelines, a machine-wide history index, a
|
|
15
|
+
portable harness, a shared board with roles and an audit trail. The wedge is
|
|
16
|
+
still the identity, and the reviews agree on the two things that make it
|
|
17
|
+
more than a relay:
|
|
18
|
+
|
|
19
|
+
1. **The terminal should never lose its conversation.** A same-login
|
|
20
|
+
downshift already kept it. 0.15.0 keeps it across logins too (the
|
|
21
|
+
`projects` junction plus one `keepsConversation` rule). The remaining gap
|
|
22
|
+
is codex across logins, unprobed.
|
|
23
|
+
2. **Leg holds the record of everything every agent did on this machine**,
|
|
24
|
+
and until 0.15.0 nothing read it back as an answer to a person. `leg
|
|
25
|
+
digest` is the first reader. The audit trail was the second-last.
|
|
26
|
+
|
|
27
|
+
Where it should go, in one sentence: from "a relay that keeps the terminal
|
|
28
|
+
alive" to "the layer that knows what every agent is doing, what it cost, and
|
|
29
|
+
what needs a person, and never loses the thread". Not an orchestrator that
|
|
30
|
+
invents work; a control layer over work the human started.
|
|
31
|
+
|
|
32
|
+
## Gaps found, ranked
|
|
33
|
+
|
|
34
|
+
| gap | evidence | status |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| a second login of the same vendor lost the conversation | `isDownshift` required the same account | **shipped 0.15.0** |
|
|
37
|
+
| nothing summarised what happened while you were away | audit trail is a flat list; ledger counts on the board | **shipped 0.15.0** (`leg digest`) |
|
|
38
|
+
| an idle terminal spent ~60 git processes a minute | `gitInfo` + `aheadCount` every 6 s | **shipped 0.15.0** (one `status --porcelain=v2 --branch`) |
|
|
39
|
+
| the board rebuilt its whole view every 10 s for nothing | health timer bypassed the fingerprint | **shipped 0.15.0** |
|
|
40
|
+
| every command loaded 68 modules, `--version` included | eager imports in `bin/leg.mjs`, two gratuitous edges | **shipped 0.15.0** |
|
|
41
|
+
| the checkpoint bundle froze limit detection for the length of a python spawn | sync `chb save` inside the poll interval | **shipped 0.15.0** |
|
|
42
|
+
| `.leg/DONE` is self-reported; a card is `completed` on the agent's word | `classify()` rule 6 | designed below (verify the claim) |
|
|
43
|
+
| a wedged agent looks like a thinking one; a loop burns the ladder | no stall/loop signal beyond `quiet Nm` | designed below |
|
|
44
|
+
| every new session re-learns the repository | SYNTHESIS dies with its session | designed below (project brain) |
|
|
45
|
+
| cards and terminals are two substrates for one idea | two stores, two status vocabularies, two hand-off loops | designed below (one leg primitive) |
|
|
46
|
+
| every ledger write is a Node process (85–114 ms) | `execFileSync(node, ledger.mjs)` per event | designed below |
|
|
47
|
+
| `codex resume <id> -m <model>` and a second `CODEX_HOME` unprobed | roadmap item 6 | still open |
|
|
48
|
+
|
|
49
|
+
## Designs for what was not built
|
|
50
|
+
|
|
51
|
+
### Verify the claim (cards)
|
|
52
|
+
|
|
53
|
+
`classify()` rule 6 says exit 0 plus `.leg/DONE` is `completed`. Add a
|
|
54
|
+
`verify` policy on the card (`test|none`, default `test` for pipelines with
|
|
55
|
+
no `test` station, else `none` because the station already runs it): on
|
|
56
|
+
`completed`, run `resolveTestCommand(repo)` in the worktree with the land
|
|
57
|
+
timeout, record `{ verified: 'green'|'red'|'untested', command, tail, at }`
|
|
58
|
+
on the run, and treat `red` as `incomplete` (hands off with the tail in the
|
|
59
|
+
bundle's Open questions). A DONE with an empty diff stays `completed`: an
|
|
60
|
+
analysis card legitimately changes nothing, so a zero diff is not evidence
|
|
61
|
+
of a lie. Surface: the card row's sentence (`claimed done; tests red (3
|
|
62
|
+
failures)`), `leg card show`. Tests: a fake adapter writing DONE with green,
|
|
63
|
+
red and no test command. Not built now because the plain `build` preset is
|
|
64
|
+
the one place it applies and the `build-land` preset already verifies.
|
|
65
|
+
|
|
66
|
+
### Stalled, looping, repeating
|
|
67
|
+
|
|
68
|
+
Three derived states, printed only from measured facts:
|
|
69
|
+
|
|
70
|
+
- `stalled`: live, not waiting on a human, and no turn, no file write and no
|
|
71
|
+
commit for N minutes (N per agent, default 12). Data: `last_activity`,
|
|
72
|
+
`files_touched`, `head`. The row says `stalled 14m` where it says `quiet
|
|
73
|
+
14m` today; the head count includes it.
|
|
74
|
+
- `looping`: the same (tool, path) or the same normalised error line three
|
|
75
|
+
times in the last 40 transcript-tail entries (`messagesFromLines` exists).
|
|
76
|
+
Row: `looping: Edit src/server.mjs 4x in 6m`. Never automatic action; a
|
|
77
|
+
Hand off now button already sits beside it.
|
|
78
|
+
- `repeating`: the same test name red across two legs of one card
|
|
79
|
+
(`bounce_reason` history). Stops the chain instead of spending the next
|
|
80
|
+
rung, the way `LEG_MAX_LAND_ATTEMPTS` already caps landings.
|
|
81
|
+
|
|
82
|
+
Not built now: `looping` needs a false-positive study on real transcripts
|
|
83
|
+
before it can print anything ("print nothing rather than a wrong number").
|
|
84
|
+
`stalled` and `repeating` are a day each and should come first.
|
|
85
|
+
|
|
86
|
+
### The project brain
|
|
87
|
+
|
|
88
|
+
`<repo>/.leg/BRAIN.md`, written by Leg's code and never by an agent turn:
|
|
89
|
+
decisions (dated, with who decided), ruled-out approaches, invariants (from
|
|
90
|
+
`CLAUDE.md`/`AGENTS.md`), hot files (from `files_touched` frequency) and open
|
|
91
|
+
questions. At hand-off the session's SYNTHESIS delta merges into it; every
|
|
92
|
+
first prompt of a leg and every card contract opens with its invariants and
|
|
93
|
+
ruled-out list before the bundle dump. `leg brain` prints it, `leg brain
|
|
94
|
+
--check` reports staleness like `leg resume --check`, `leg decide "<text>"`
|
|
95
|
+
appends. It subsumes `.leg/SYNTHESIS-<id>.md` as the per-session delta. Not
|
|
96
|
+
built now because it is a new file in the human's repository with a format
|
|
97
|
+
that has to be right the first time; it deserves its own pass with the
|
|
98
|
+
synthesis schema's authors.
|
|
99
|
+
|
|
100
|
+
### One leg primitive
|
|
101
|
+
|
|
102
|
+
The terminal loop (`src/attach.mjs`) and the card loop
|
|
103
|
+
(`src/orchestrator.mjs` + `src/chain.mjs`) do the same four steps: bundle,
|
|
104
|
+
choose next, switch, wait. What differs is real: the terminal chooses from
|
|
105
|
+
live usage at hand-off time and keeps one TTY; the card walks a static chain
|
|
106
|
+
and classifies post-mortem. The extractable pieces with no semantic change
|
|
107
|
+
are `bundle → prompt` and `prepare harness → proceed?` as shared functions,
|
|
108
|
+
and a per-agent tap table (`PER_AGENT[agent].poll(session, ctx) → patch`)
|
|
109
|
+
so a fifth agent is a table row. Making a card's chain a ladder evaluated at
|
|
110
|
+
hand-off time is the product decision that would unify the rest; it belongs
|
|
111
|
+
in the roadmap, not in a refactor.
|
|
112
|
+
|
|
113
|
+
### In-process ledger writes
|
|
114
|
+
|
|
115
|
+
`src/store.mjs` spawns `node src/ledger.mjs` per event (85 ms append, 114 ms
|
|
116
|
+
update; a scheduler tick that newly blocks six cards spends 487 ms in six of
|
|
117
|
+
them). The single-writer rule is a file lock (`.card.lock`), not a process
|
|
118
|
+
boundary, so the functions can be exported and called in-process. Three
|
|
119
|
+
things make it a refactor: `die()` exits, `assertNoSecrets` exits on a
|
|
120
|
+
secret-looking summary (in-process that must throw, never take the board
|
|
121
|
+
down), and `syncNotify` is async. Also `writeActive()` rescans every
|
|
122
|
+
`card.json` on every write and should be debounced. Not built now: medium
|
|
123
|
+
risk on the card path, which is the v0.1 extra, and the terminal path never
|
|
124
|
+
touches it.
|
|
125
|
+
|
|
126
|
+
### One event schema
|
|
127
|
+
|
|
128
|
+
Sessions have ~24 event types, cards 28 (`ledger.mjs EVENT_TYPES`), with
|
|
129
|
+
different actor shapes. `audit.mjs` and `digest.mjs` both merge the two by
|
|
130
|
+
hand. One `{ v, ts, subject:{kind,id}, actor, type, summary, measured }`
|
|
131
|
+
line format would let the audit, the digest, a future `leg events --follow`
|
|
132
|
+
and an outside subscriber read one stream. Do it when a third reader
|
|
133
|
+
appears.
|
|
134
|
+
|
|
135
|
+
## Rejected, with the reason
|
|
136
|
+
|
|
137
|
+
- **Answering a permission prompt from the board.** `stdio: 'inherit'` is
|
|
138
|
+
the architecture; a PTY between the human and their agent breaks "the same
|
|
139
|
+
agent you always run".
|
|
140
|
+
- **Routing by headroom instead of rung order.** "Rung 1 first, every time"
|
|
141
|
+
is the contract the ladder editor teaches; a sort by headroom makes the
|
|
142
|
+
next destination unpredictable. A `when: below:N` rung already expresses
|
|
143
|
+
the honest version.
|
|
144
|
+
- **Inferred workflows and automatic undo.** A recipe guessed from three
|
|
145
|
+
observations is a wrong number with a button; undoing a leg destroys work
|
|
146
|
+
that may be the only copy.
|
|
147
|
+
- **Marking a DONE with no diff as a false claim.** An analysis task changes
|
|
148
|
+
nothing and is still done.
|
|
149
|
+
- **Pooling a team's logins as one capacity market.** The README's terms
|
|
150
|
+
section draws the same-vendor line at an explicit human act.
|
|
151
|
+
|
|
152
|
+
## What Leg must never do without a human
|
|
153
|
+
|
|
154
|
+
Spend credits (`may_spend` off); push or open a PR; write a settings file
|
|
155
|
+
outside `leg harness enable`'s owned regions; move a credential; delete a
|
|
156
|
+
worktree with uncommitted work or an unmerged branch; pick a second login of
|
|
157
|
+
the same vendor the human did not add; read message bodies wholesale into an
|
|
158
|
+
index; send anything off the machine; answer a permission prompt; print a
|
|
159
|
+
number it does not have.
|
|
160
|
+
|
|
161
|
+
## Next three moves
|
|
162
|
+
|
|
163
|
+
1. **Probe codex across logins**: `codex resume <id> -m <model>` under a
|
|
164
|
+
second `CODEX_HOME` with the `sessions` store junctioned the way
|
|
165
|
+
`projects` now is for claude. One live run settles whether the second
|
|
166
|
+
`keepsConversation` case can include codex.
|
|
167
|
+
2. **`stalled` and `repeating`** as above: two derived states from data
|
|
168
|
+
already on the record, each with a fixture-driven test, no automatic
|
|
169
|
+
action.
|
|
170
|
+
3. **A digest panel on the board** above Terminals on the first load of the
|
|
171
|
+
day, dismissible, fed by `/api/digest`; the CLI and the route exist, the
|
|
172
|
+
board is the human surface.
|
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": 955,
|
|
6
6
|
"clis": {
|
|
7
7
|
"claude": {
|
|
8
8
|
"label": "Claude Code",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ucsandman/legcli",
|
|
3
|
-
"version": "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.",
|
|
3
|
+
"version": "0.15.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. 14-day free trial, then $79 once, 30-day money-back guarantee.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
7
7
|
"homepage": "https://legcli.com",
|
package/src/accounts.mjs
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// back into it with directory junctions, and the settings file is refreshed
|
|
7
7
|
// from the real home before every launch. Only the login lives in the
|
|
8
8
|
// account dir. agy 1.2.0 has no config-dir override, so it stays one account.
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, copyFileSync, readdirSync, lstatSync, symlinkSync, rmSync } from 'node:fs'
|
|
10
|
-
import { join } from 'node:path'
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, copyFileSync, readdirSync, lstatSync, symlinkSync, rmSync, realpathSync } from 'node:fs'
|
|
10
|
+
import { join, relative, dirname, basename, resolve, isAbsolute } from 'node:path'
|
|
11
11
|
import { homedir } from 'node:os'
|
|
12
12
|
import { home } from './store.mjs'
|
|
13
13
|
import { writeJsonAtomic } from './fsx.mjs'
|
|
@@ -19,7 +19,12 @@ export const LAYOUT = {
|
|
|
19
19
|
claude: {
|
|
20
20
|
env: 'CLAUDE_CONFIG_DIR',
|
|
21
21
|
home: () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'),
|
|
22
|
-
|
|
22
|
+
// `projects` is the conversation store (projects/<encoded cwd>/<id>.jsonl,
|
|
23
|
+
// and the auto-memory beside it). Shared, not copied, so a hand-off to the
|
|
24
|
+
// second login can run `claude --resume <id>` on the transcript the first
|
|
25
|
+
// login was writing, and the same human's memory follows them. Claude Code
|
|
26
|
+
// does the writing; Leg reads that directory and never writes it.
|
|
27
|
+
share: ['hooks', 'skills', 'agents', 'commands', 'plugins', 'rules', 'scripts', 'output-styles', 'tools', 'projects'],
|
|
23
28
|
copy: ['settings.json', 'settings.local.json', 'CLAUDE.md', 'keybindings.json', 'statusline.ps1', 'statusline-combined.ps1'],
|
|
24
29
|
login: (dir) => `$env:CLAUDE_CONFIG_DIR='${dir}'; claude auth login`,
|
|
25
30
|
},
|
|
@@ -73,6 +78,45 @@ export function envFor(agent, account) {
|
|
|
73
78
|
return { [l.env]: accountDir(agent, account) }
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
// The config directory a login runs from: the CLI's own home for `default`,
|
|
82
|
+
// the account directory for anything else. Null for an agent Leg does not know.
|
|
83
|
+
export function homeFor(agent, account = 'default') {
|
|
84
|
+
const l = LAYOUT[agent]
|
|
85
|
+
if (!l) return null
|
|
86
|
+
return account === 'default' ? l.home() : accountDir(agent, account)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Whether the conversation file one login was writing is visible to another
|
|
90
|
+
// login of the same agent: the transcript's path relative to the source home
|
|
91
|
+
// must exist under the destination home. True through the `projects` junction
|
|
92
|
+
// an account carries; false for an account made before that junction existed
|
|
93
|
+
// (until its next launch adds it), for a transcript outside the source home,
|
|
94
|
+
// and for an agent with no per-account home at all. A false answer means the
|
|
95
|
+
// bundle, never a `--resume` that would open an empty conversation.
|
|
96
|
+
export function transcriptReachable(agent, transcriptPath, { from = 'default', to = 'default' } = {}) {
|
|
97
|
+
if (!transcriptPath || from === to || !LAYOUT[agent]?.env) return false
|
|
98
|
+
const dst = homeFor(agent, to)
|
|
99
|
+
if (!dst) return false
|
|
100
|
+
const where = realDir(dirname(transcriptPath))
|
|
101
|
+
// the transcript's place inside whichever home really holds it: the source
|
|
102
|
+
// login's, the destination's, or the CLI's own. A path a CLI reported through
|
|
103
|
+
// an account's junction resolves to the real home, and a record that kept
|
|
104
|
+
// the first login's path while the terminal moved on is still one file.
|
|
105
|
+
for (const account of new Set([from, to, 'default'])) {
|
|
106
|
+
const h = homeFor(agent, account)
|
|
107
|
+
if (!h) continue
|
|
108
|
+
const rel = relative(realDir(h), where)
|
|
109
|
+
if (rel.startsWith('..') || isAbsolute(rel)) continue
|
|
110
|
+
return existsSync(join(dst, rel, basename(transcriptPath)))
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// A directory's real path, for comparing a transcript's location against a
|
|
116
|
+
// home that may itself be reached through a junction; the path as given when
|
|
117
|
+
// it does not exist.
|
|
118
|
+
function realDir(p) { try { return realpathSync.native(p) } catch { return resolve(p) } }
|
|
119
|
+
|
|
76
120
|
function junction(target, link) {
|
|
77
121
|
if (existsSync(link)) return false
|
|
78
122
|
if (!existsSync(target)) return false
|
|
@@ -80,6 +124,22 @@ function junction(target, link) {
|
|
|
80
124
|
return true
|
|
81
125
|
}
|
|
82
126
|
|
|
127
|
+
// The shared directories, junctioned into the account dir when they are not
|
|
128
|
+
// there yet. Runs at creation and before every launch, so an account made by
|
|
129
|
+
// an older Leg picks up a directory added to `share` since (the `projects`
|
|
130
|
+
// store, for one) the next time it starts.
|
|
131
|
+
export function ensureShared(agent, name) {
|
|
132
|
+
if (name === 'default') return []
|
|
133
|
+
const l = LAYOUT[agent]
|
|
134
|
+
if (!l?.env) return []
|
|
135
|
+
const dir = accountDir(agent, name)
|
|
136
|
+
if (!existsSync(dir)) return []
|
|
137
|
+
const src = l.home()
|
|
138
|
+
const shared = []
|
|
139
|
+
for (const d of l.share) if (junction(join(src, d), join(dir, d))) shared.push(d)
|
|
140
|
+
return shared
|
|
141
|
+
}
|
|
142
|
+
|
|
83
143
|
// Create the account dir, junction the shared harness in, copy the settings.
|
|
84
144
|
export function addAccount(agent, name) {
|
|
85
145
|
if (!NAME_RE.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
|
|
@@ -88,9 +148,7 @@ export function addAccount(agent, name) {
|
|
|
88
148
|
if (!l.env) throw new Error(`${agent} has no config-dir override in the installed version; extra accounts are not possible`)
|
|
89
149
|
const dir = accountDir(agent, name)
|
|
90
150
|
mkdirSync(dir, { recursive: true })
|
|
91
|
-
const
|
|
92
|
-
const shared = []
|
|
93
|
-
for (const d of l.share) if (junction(join(src, d), join(dir, d))) shared.push(d)
|
|
151
|
+
const shared = ensureShared(agent, name)
|
|
94
152
|
refreshAccount(agent, name)
|
|
95
153
|
const acc = readAccounts()
|
|
96
154
|
if (!acc[agent]) acc[agent] = ['default']
|
|
@@ -98,11 +156,13 @@ export function addAccount(agent, name) {
|
|
|
98
156
|
return { dir, shared, login: l.login(dir), env: l.env }
|
|
99
157
|
}
|
|
100
158
|
|
|
101
|
-
// Before each launch: bring the copied files up to date with the real home
|
|
159
|
+
// Before each launch: bring the copied files up to date with the real home,
|
|
160
|
+
// and add any shared directory the account is still missing.
|
|
102
161
|
export function refreshAccount(agent, name) {
|
|
103
162
|
if (name === 'default') return []
|
|
104
163
|
const l = LAYOUT[agent]
|
|
105
164
|
const dir = accountDir(agent, name)
|
|
165
|
+
ensureShared(agent, name)
|
|
106
166
|
const copied = []
|
|
107
167
|
for (const f of l.copy) {
|
|
108
168
|
const s = join(l.home(), f)
|