@ucsandman/legcli 0.8.0 → 0.10.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 +121 -0
- package/NOTICE +8 -0
- package/README.md +639 -560
- package/bin/fake-agent.mjs +4 -4
- package/bin/leg.mjs +43 -12
- package/docs/DECISIONS.md +20 -2
- package/docs/ERRORS.md +205 -0
- package/docs/README.md +5 -1
- package/docs/REUSE.md +1 -1
- package/docs/VOCABULARY.md +22 -0
- package/docs/board-guide.md +33 -1
- package/docs/cli-contracts.md +36 -1
- package/docs/concepts.md +42 -3
- package/docs/configuration.md +23 -1
- package/docs/faq.md +19 -0
- package/docs/getting-started.md +272 -251
- package/docs/harness.md +319 -0
- package/docs/history.md +172 -0
- package/docs/runtime-tap.md +156 -0
- package/fixtures/verified.json +1 -1
- package/package.json +7 -3
- package/scripts/build-docs-site.mjs +18 -4
- package/scripts/check-branding.mjs +118 -0
- package/scripts/check-claims.mjs +1 -1
- package/scripts/license-sign.mjs +1 -1
- package/scripts/limits-table.mjs +1 -1
- package/scripts/live-limits.mjs +1 -1
- package/scripts/npm-publish-gate.mjs +114 -0
- package/scripts/probe.mjs +4 -3
- package/scripts/seed-fake-cards.mjs +4 -3
- package/scripts/seed-floor-board.mjs +5 -4
- package/scripts/seed-wes-board.mjs +5 -4
- package/scripts/stripe-setup.mjs +1 -1
- package/scripts/sync-harness-engine.mjs +159 -0
- package/scripts/sync-leg-agents.mjs +127 -0
- package/src/accounts.mjs +6 -4
- package/src/adapters/codex.mjs +1 -1
- package/src/attach.mjs +125 -23
- package/src/auth.mjs +2 -2
- package/src/board/board.css +23 -1
- package/src/board/board.js +17 -5
- package/src/board/history.js +377 -0
- package/src/board/index.html +33 -0
- package/src/board/sessions.js +95 -7
- package/src/bundle.mjs +54 -8
- package/src/chain.mjs +1 -1
- package/src/contract.mjs +4 -3
- package/src/fsx.mjs +5 -2
- package/src/handoff.mjs +6 -6
- package/src/harness/cli.mjs +281 -0
- package/src/harness/fingerprint.mjs +68 -0
- package/src/harness/index.mjs +407 -0
- package/src/harness/registry.mjs +124 -0
- package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
- package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
- package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
- package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
- package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
- package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
- 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/hook.mjs +49 -49
- package/src/land.mjs +7 -35
- package/src/launcher.mjs +38 -26
- package/src/ledger.mjs +6 -6
- package/src/license.mjs +10 -9
- package/src/live-capture.mjs +1 -1
- package/src/mergequeue.mjs +5 -5
- package/src/orchestrator.mjs +28 -4
- package/src/preferences.mjs +37 -3
- package/src/redact.mjs +24 -6
- package/src/resume.mjs +17 -15
- package/src/runner.mjs +2 -2
- package/src/scheduler.mjs +1 -1
- package/src/server.mjs +224 -18
- package/src/session-detail.mjs +15 -1
- package/src/sessions.mjs +15 -3
- package/src/share.mjs +2 -2
- package/src/stations/agent.mjs +1 -1
- package/src/sync/dashclaw.mjs +4 -4
- package/src/synthesis.mjs +165 -0
- package/src/taps/agy.mjs +2 -2
- package/src/taps/claude-usage.mjs +1 -1
- package/src/taps/claude.mjs +177 -170
- package/src/taps/codex.mjs +286 -286
- package/src/taps/grok.mjs +2 -2
- package/src/taps/mod.mjs +340 -0
- package/src/trust.mjs +205 -36
- package/src/usage.mjs +5 -1
- package/src/worktree.mjs +6 -5
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +0 -1
- package/fixtures/live/agy/supervisor.log +0 -2
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/out.log +0 -1
- package/fixtures/live/claude/supervisor.log +0 -2
- package/fixtures/live/codex/err.log +0 -1
- package/fixtures/live/codex/out.log +0 -8
- package/fixtures/live/codex/supervisor.log +0 -2
- package/fixtures/live/grok/err.log +0 -32
- package/fixtures/live/grok/out.log +0 -7
- package/fixtures/live/grok/supervisor.log +0 -2
package/docs/harness.md
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
# The portable harness
|
|
2
|
+
|
|
3
|
+
Off by default. When you turn it on, a hand-off carries two things instead of
|
|
4
|
+
one: the task (the context handoff bundle Leg has always written) and the
|
|
5
|
+
agent's **working environment**: the global working agreement, identity,
|
|
6
|
+
hooks, skills, subagents, slash commands, MCP servers and permissions of the
|
|
7
|
+
client you actually use, rendered into the native configuration of the client
|
|
8
|
+
the hand-off lands on. The promise is not byte-for-byte equivalence. It is:
|
|
9
|
+
|
|
10
|
+
> Preserve as much of the source agent's operating environment as the
|
|
11
|
+
> destination client can faithfully support, and say exactly what could not be
|
|
12
|
+
> carried over.
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
leg harness enable # detect clients, capture, show the plan, apply after you say yes
|
|
16
|
+
leg harness status # what is on, the source, when it was captured, each client's state
|
|
17
|
+
leg harness sync # re-capture and write whatever is out of date
|
|
18
|
+
leg harness check # the same, writing nothing; exit 1 when something is stale
|
|
19
|
+
leg harness explain # every item a client could not receive, and why
|
|
20
|
+
leg harness disable # stop; nothing already written is removed
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## What moves, and what does not
|
|
24
|
+
|
|
25
|
+
| component | carried as | what a destination may drop |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| rules | the source's global rules file with its `@imports` inlined, written as the destination's global rules file (`AGENTS.md`, `GEMINI.md`, or `leg-rules.md` imported from `CLAUDE.md`) | nothing; the whole agreement travels |
|
|
28
|
+
| identity | `SOUL.md` or the identity section, inlined or written to the client's own file | nothing |
|
|
29
|
+
| hooks | the same hook scripts, pointed at (never copied), in the destination's dialect; non-Codex clients run them through a shim that translates the payload | an event the client does not have (`MessageDisplay`, agy's missing `SessionStart`), a matcher whose tools have no counterpart |
|
|
30
|
+
| skills | a directory link per skill into the client's skills directory, so an edit at the source is live everywhere | a name that already exists as a real directory there, a skill the client reads natively from a shared directory |
|
|
31
|
+
| agents | one subagent file per agent in the client's format (`.md` or `.toml`), with the model tier mapped through the client's ladder | a client with no subagent surface (Gemini CLI) |
|
|
32
|
+
| commands | one prompt file per slash command (`prompts/`, `commands/`, `.toml` for Gemini) | a name that already exists and Leg did not write |
|
|
33
|
+
| MCP servers | each server in the client's own MCP file or config table, with every credential replaced by an environment reference | a transport the client lacks (Codex has no SSE), a server the user already configured there |
|
|
34
|
+
| permissions | `settings.json` allow/deny/ask for Claude Code; Codex prefix rules for `Bash(...)` patterns | anything but a `Bash(...)` pattern on Codex; clients with no machine-readable permission surface |
|
|
35
|
+
|
|
36
|
+
What never moves:
|
|
37
|
+
|
|
38
|
+
- **Credentials.** OAuth tokens, `.credentials.json`, `auth.json`, API keys in an
|
|
39
|
+
MCP `env` or `headers` block, and a token anywhere else the capture reads.
|
|
40
|
+
An `env` or `headers` value that looks like a credential becomes `${NAME}`
|
|
41
|
+
in the bundle and Leg tells you which variable to export for each client.
|
|
42
|
+
A token inside free text (the rules, the identity, an agent or command
|
|
43
|
+
body) is replaced by `[REDACTED]`; a hook whose command line carries one,
|
|
44
|
+
and an MCP server whose argument or URL carries one (a password in a
|
|
45
|
+
connection string, a secret query value, a token in the path), are not
|
|
46
|
+
carried at all, each with a warning naming it. The scan is fail-closed:
|
|
47
|
+
every field of the bundle is validated at save time and a bundle that
|
|
48
|
+
still carries a credential is refused. Tests plant tokens in every one of
|
|
49
|
+
those places, and in the places a capture must not read, and assert they
|
|
50
|
+
appear in no bundle file and no destination file.
|
|
51
|
+
- **Which login runs.** Accounts are Leg's account layer
|
|
52
|
+
(`leg accounts`, `src/accounts.mjs`); the harness describes behaviour and
|
|
53
|
+
capabilities, never who is signed in. A same-agent hand-off to a second
|
|
54
|
+
login carries no harness at all: the second login shares the first one's
|
|
55
|
+
home through the junctions the account layer made.
|
|
56
|
+
- **Project-local instructions.** A repository's `CLAUDE.md`, `AGENTS.md` or
|
|
57
|
+
`GEMINI.md` is read natively by each client from the checkout. The harness
|
|
58
|
+
carries the *global* working agreement only.
|
|
59
|
+
- **The source client itself.** The client the harness is captured from is
|
|
60
|
+
never written to.
|
|
61
|
+
|
|
62
|
+
## Precedence
|
|
63
|
+
|
|
64
|
+
When a destination starts after a hand-off, four layers of instruction are in
|
|
65
|
+
play. From most to least specific:
|
|
66
|
+
|
|
67
|
+
1. **The resume prompt and bundle** (`.leg/RESUME-<session>.md`): the task,
|
|
68
|
+
the last messages, the diff, why the previous agent stopped. Always
|
|
69
|
+
present, harness or not.
|
|
70
|
+
2. **The repository's own instruction files**: `CLAUDE.md`, `AGENTS.md`,
|
|
71
|
+
`GEMINI.md` in the checkout. Never touched by Leg; each client reads them
|
|
72
|
+
itself.
|
|
73
|
+
3. **The ported global working agreement**: the destination's global rules
|
|
74
|
+
file, written by the harness. This is where the source's `~/.claude/CLAUDE.md`
|
|
75
|
+
(with its imports) lands.
|
|
76
|
+
4. The client's own defaults.
|
|
77
|
+
|
|
78
|
+
Leg does not merge layers 2 and 3. A repository file that repeats a global rule
|
|
79
|
+
repeats it; a repository file that contradicts one wins, because that is how
|
|
80
|
+
every client already resolves project-level instructions over global ones.
|
|
81
|
+
`test/harness-policy.test.mjs` runs a sync from inside a checkout that has its
|
|
82
|
+
own `CLAUDE.md` and `AGENTS.md` and asserts both are byte-identical afterwards:
|
|
83
|
+
a sync writes under the client homes only.
|
|
84
|
+
|
|
85
|
+
## Turning it on: the first run
|
|
86
|
+
|
|
87
|
+
`leg harness enable` is the one explicit consent. It:
|
|
88
|
+
|
|
89
|
+
1. Detects which sources exist on this machine (`~/.claude/CLAUDE.md`,
|
|
90
|
+
`~/.codex/AGENTS.md`) and picks the first, or the one you name with
|
|
91
|
+
`--source`. It never guesses destructively: with two sources it says which
|
|
92
|
+
it chose and how to choose the other.
|
|
93
|
+
2. Captures the source into the neutral bundle under `~/.leg/harness/bundle/`,
|
|
94
|
+
scans it for credentials, and prints every warning.
|
|
95
|
+
3. Runs a check against every installed client and prints the table: per
|
|
96
|
+
client, per component, `✓ synced`, `✗ stale`, `- unsupported`, and the
|
|
97
|
+
count carried (`skills 2/3`), then every item that will not be carried with
|
|
98
|
+
its reason.
|
|
99
|
+
4. Asks before writing. In a terminal it prompts; without one (a script, CI)
|
|
100
|
+
it prints the plan, writes nothing, and exits 3 unless you passed `--yes`.
|
|
101
|
+
5. Applies, records ownership of every file and region it wrote, and saves the
|
|
102
|
+
consent: `preferences.json` gains `"harness": { "enabled": true, "policy":
|
|
103
|
+
"sync", "source": "claude" }`.
|
|
104
|
+
|
|
105
|
+
After that, unattended hand-offs use the saved policy and never prompt.
|
|
106
|
+
`leg harness sync` writes only on an install that gave this consent; before
|
|
107
|
+
`enable` it exits 3 and points at it, while `check`, `diff` and `explain`
|
|
108
|
+
report without writing. The board can turn the feature off or narrow the
|
|
109
|
+
policy, never turn it on or widen it: that stays a terminal command.
|
|
110
|
+
|
|
111
|
+
## Policies
|
|
112
|
+
|
|
113
|
+
`leg harness policy <warn|sync|strict>`, or `--policy` on `enable`. The
|
|
114
|
+
default when you enable is `sync`; an install that never enabled the feature
|
|
115
|
+
is `off` and behaves exactly as before.
|
|
116
|
+
|
|
117
|
+
| policy | at a hand-off, before the next agent starts |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `off` | nothing is captured, checked, written or recorded |
|
|
120
|
+
| `warn` | the source is re-captured if it changed, the destination is checked, the result is recorded on the session and printed; nothing is written to any client |
|
|
121
|
+
| `sync` | as `warn`, and a stale destination is synced: managed files and regions are written, backups taken, hand-edited files skipped and reported. The hand-off proceeds whatever the outcome. |
|
|
122
|
+
| `strict` | as `sync`, and a destination that ends `attention`, `error` or `unsupported` is refused: Leg records `harness_blocked`, prints why, tries the next option in your hand-off order, and if none is left stops the terminal with exit 5 rather than launching an agent without its environment. Strict applies to hand-offs only; the agent you start yourself is never refused. |
|
|
123
|
+
|
|
124
|
+
There is no interactive prompt on this path under any policy. A limit usually
|
|
125
|
+
lands when nobody is at the keyboard, and a prompt would wedge the terminal
|
|
126
|
+
until morning.
|
|
127
|
+
|
|
128
|
+
## States
|
|
129
|
+
|
|
130
|
+
One word per destination, the same word on the CLI, the session timeline, the
|
|
131
|
+
terminal card and the drawer:
|
|
132
|
+
|
|
133
|
+
| state | meaning |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `synced` | everything the destination supports is current and nothing was dropped |
|
|
136
|
+
| `partial` | current, but some items could not be carried (each has a reason under **Dropped**) |
|
|
137
|
+
| `stale` | the destination is behind the source; `warn` policy, or a `check` |
|
|
138
|
+
| `attention` | a managed file was hand-edited since Leg wrote it (backed up, left alone), a component errored (a malformed config file, for instance), or a `deny` or `ask` permission the destination cannot express was dropped, leaving it less restricted than the source; needs you |
|
|
139
|
+
| `unsupported` | the destination has no harness adapter (Grok) or is not installed |
|
|
140
|
+
| `blocked` | the strict policy refused this destination |
|
|
141
|
+
| `source` | the destination is the source client; nothing to carry |
|
|
142
|
+
| `same-client` | a hand-off to another login of the same client; the harness is shared already |
|
|
143
|
+
| `error` | the preparation itself failed; the reason is recorded |
|
|
144
|
+
|
|
145
|
+
## Ownership, backups, hand edits
|
|
146
|
+
|
|
147
|
+
Every file the harness writes carries `GENERATED by Leg harness` in its first
|
|
148
|
+
lines. Inside a file you also own (`config.toml`, `settings.json`,
|
|
149
|
+
`.claude.json`, agy's `hooks.json`) it writes a marked region:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
# >>> leg harness hooks start (generated, do not edit)
|
|
153
|
+
...
|
|
154
|
+
# <<< leg harness hooks end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
or, for JSON, exactly the groups or keys it inserted last time, recorded in
|
|
158
|
+
`~/.leg/harness/harness-state.json`. Everything outside is preserved byte for
|
|
159
|
+
byte; a re-run only ever removes what the harness itself added; a skill link is
|
|
160
|
+
never made over a real directory; a file Leg did not create is never
|
|
161
|
+
overwritten. Before any overwrite the previous content goes to
|
|
162
|
+
`~/.leg/harness/backups/<client>-<file>-<timestamp>.bak`. A generated file
|
|
163
|
+
that is pruned because its source went away (a subagent or command deleted at
|
|
164
|
+
the source, a permissions file that became empty) is backed up the same way
|
|
165
|
+
before it is removed.
|
|
166
|
+
|
|
167
|
+
A file you edit by hand after Leg wrote it (the ownership line removed, or
|
|
168
|
+
the content changed) is detected by hash, backed up once, and **skipped**. The
|
|
169
|
+
sync reports `attention` with the path and the remedy: edit the source client
|
|
170
|
+
instead, or `leg harness sync --force` to replace it (the backup stays).
|
|
171
|
+
|
|
172
|
+
## Performance
|
|
173
|
+
|
|
174
|
+
A hand-off must stay fast. Before capturing, Leg fingerprints the source's
|
|
175
|
+
surfaces from file metadata alone (the rules file and its imports,
|
|
176
|
+
`settings.json`, the MCP file, the agents, commands and skills directories):
|
|
177
|
+
a few dozen `stat` calls. An unchanged fingerprint reuses the bundle on disk.
|
|
178
|
+
The destination is then checked by reading its managed files, and written
|
|
179
|
+
only when something differs. `test/harness-perf.test.mjs` holds a warm
|
|
180
|
+
hand-off decision (unchanged source, synced destination) under 400 ms median
|
|
181
|
+
and a cached capture under 40 ms; on the development machine they measure
|
|
182
|
+
about 19 ms and 7 ms.
|
|
183
|
+
|
|
184
|
+
## Failure model
|
|
185
|
+
|
|
186
|
+
| failure | what happens |
|
|
187
|
+
|---|---|
|
|
188
|
+
| no source configured or detected | `error`; hand-off proceeds under `warn`/`sync`, refused under `strict`; `leg harness source` names one |
|
|
189
|
+
| destination not installed | `unsupported`; the chooser never picks a missing CLI anyway |
|
|
190
|
+
| destination config is not valid JSON/TOML | that component is `error`, nothing is written to that file, the hand-off proceeds under `warn`/`sync` |
|
|
191
|
+
| a managed file was hand-edited | `attention`; backed up, skipped, named |
|
|
192
|
+
| a skill name is a real directory at the destination | dropped with a reason; the directory is untouched |
|
|
193
|
+
| an MCP value references a variable the destination does not expand | the component note names the variable to export |
|
|
194
|
+
| a hook event or matcher the destination lacks | dropped with a reason |
|
|
195
|
+
| a `deny` or `ask` permission the destination cannot express | `attention`: the destination would be less restricted than the source; strict refuses it, sync proceeds and names it |
|
|
196
|
+
| a credential in the rules, an agent, a command, a hook command line, an MCP argument or URL | free text is redacted to `[REDACTED]`; the hook or server is not carried; each with a warning |
|
|
197
|
+
| a subagent, command or skill with an unsafe name | not carried, with a warning; the rest of the bundle still travels |
|
|
198
|
+
| a skill path with a shell metacharacter on Windows when the junction fallback is needed | the link is refused and reported; nothing is passed to a shell |
|
|
199
|
+
| two Leg processes hand off at once | one takes `~/.leg/harness/.lock` (up to 30 s); the other fails its own step rather than tearing the ownership record |
|
|
200
|
+
| a model tier the destination cannot map | passed through as a raw id and noted |
|
|
201
|
+
| the captured bundle is corrupt on disk | re-captured from the source, never trusted |
|
|
202
|
+
| an interrupted previous apply | the next sync re-derives everything from ownership records; a region is rebuilt, never appended twice |
|
|
203
|
+
| the engine itself throws | `error` on the session; the session is never corrupted; the hand-off proceeds under `warn`/`sync` |
|
|
204
|
+
|
|
205
|
+
Nothing here rolls back a user file: every overwrite is preceded by a backup,
|
|
206
|
+
and a refusal leaves the file as it was.
|
|
207
|
+
|
|
208
|
+
## Observability
|
|
209
|
+
|
|
210
|
+
Every operation leaves evidence:
|
|
211
|
+
|
|
212
|
+
- `~/.leg/harness/history.jsonl`: one line per capture, apply and hand-off
|
|
213
|
+
decision, with the source, target, bundle fingerprint, timestamps,
|
|
214
|
+
per-component states, dropped items, attention items, the files touched and
|
|
215
|
+
the backups made. `leg harness history` prints it.
|
|
216
|
+
- The session record (`session.json` → `harness`) carries the outcome for the
|
|
217
|
+
leg now running: source, fingerprint, capture and sync times, components,
|
|
218
|
+
dropped, attention, whether the hand-off proceeded.
|
|
219
|
+
- Session events `harness` and `harness_blocked`; card ledger events of the
|
|
220
|
+
same names on the background-task path.
|
|
221
|
+
- The board: a chip on the terminal row (`harness synced`, `harness partial`,
|
|
222
|
+
`harness attention`, `harness refused`) and a **Harness** section in the
|
|
223
|
+
drawer with the source, capture and sync times, the component table,
|
|
224
|
+
**Needs you**, **Dropped**, and the last eight trail entries.
|
|
225
|
+
|
|
226
|
+
No secret is ever logged: the bundle holds references, the trail holds paths
|
|
227
|
+
and states.
|
|
228
|
+
|
|
229
|
+
## Files
|
|
230
|
+
|
|
231
|
+
```
|
|
232
|
+
~/.leg/harness/
|
|
233
|
+
bundle/ the neutral bundle: manifest.json, rules.md, identity.md,
|
|
234
|
+
hooks.json, mcp.json, skills.json, permissions.json, agents/, commands/
|
|
235
|
+
capture.json source, fingerprint, the surfaces hash the capture was taken at
|
|
236
|
+
harness-state.json ownership: every file and region written, per client
|
|
237
|
+
harness-report.json the last apply or check, per client, per component
|
|
238
|
+
backups/ every file before Leg overwrote it
|
|
239
|
+
history.jsonl the evidence trail
|
|
240
|
+
policy.json optional: what is deliberately not carried (below)
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
`leg uninstall --yes` removes `~/.leg` and with it all of the above. It does
|
|
244
|
+
not remove what the harness wrote into other clients: run `leg harness
|
|
245
|
+
disable`, then delete the files carrying `GENERATED by Leg harness` and the
|
|
246
|
+
`leg harness` regions if you want them gone.
|
|
247
|
+
|
|
248
|
+
## The port policy
|
|
249
|
+
|
|
250
|
+
`~/.leg/harness/policy.json`, optional, merged over the defaults:
|
|
251
|
+
|
|
252
|
+
```jsonc
|
|
253
|
+
{
|
|
254
|
+
"rules": { "dropSectionsForTargets": ["Delegation and Model Routing"] },
|
|
255
|
+
"hooks": { "exclude": [{ "match": "capability-graph-guard", "reason": "polices Claude Code's model ladder" }] },
|
|
256
|
+
"skills": { "exclude": { "review-browser": "the destination has no browser tool" } },
|
|
257
|
+
"mcp": { "exclude": { "xapi": "one OAuth grant per client; copy nothing" } },
|
|
258
|
+
"agents": { "modelLadder": { "codex": { "opus": ["gpt-5.6-sol", "high"] } } }
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
An excluded item shows in `explain` and on the board as **excluded by policy**
|
|
263
|
+
with your reason, and does not make a destination `partial`. The defaults
|
|
264
|
+
exclude nothing; the shipped Codex model ladder maps the four Claude tiers
|
|
265
|
+
(`fable`, `opus`, `sonnet`, `haiku`) onto Codex models and can be overridden.
|
|
266
|
+
|
|
267
|
+
## Which clients
|
|
268
|
+
|
|
269
|
+
| client | source | destination | notes |
|
|
270
|
+
|---|---|---|---|
|
|
271
|
+
| Claude Code | yes | yes | the bundle's own dialect; hooks and matchers need no translation |
|
|
272
|
+
| Codex CLI | yes | yes | hooks pre-trusted with Codex's own trust hash (self-tested against a value Codex wrote); SSE servers dropped |
|
|
273
|
+
| Antigravity CLI (`agy`) | no | yes | hooks under one owned key in `hooks.json`; shares `GEMINI.md` with Gemini CLI; no permission surface |
|
|
274
|
+
| Gemini CLI | no | yes | kept coherent with agy; no subagent surface |
|
|
275
|
+
| Grok CLI | no | no | no adapter yet; reported `unsupported`, never guessed |
|
|
276
|
+
|
|
277
|
+
Paths honour `CLAUDE_CONFIG_DIR`, `CODEX_HOME` and `GEMINI_CONFIG_DIR`, the
|
|
278
|
+
same variables the trust and account layers honour.
|
|
279
|
+
|
|
280
|
+
## Leg and Agnostic AI
|
|
281
|
+
|
|
282
|
+
The capture → neutral bundle → apply engine is the
|
|
283
|
+
[Agnostic AI](https://github.com/ucsandman/Agnostic-AI) port engine (MIT).
|
|
284
|
+
Leg embeds it as a library, byte for byte, under
|
|
285
|
+
`src/harness/vendor/agnostic-ai/`, and owns everything around it: consent,
|
|
286
|
+
policy, the client registry, where state lives, the fingerprint, the evidence
|
|
287
|
+
trail and the hand-off decision (`src/harness/*.mjs`).
|
|
288
|
+
|
|
289
|
+
Drift between the two is refused, not managed:
|
|
290
|
+
|
|
291
|
+
- `src/harness/vendor/agnostic-ai/UPSTREAM.json` records the upstream commit
|
|
292
|
+
and the sha256 of every vendored file. `npm test` runs
|
|
293
|
+
`scripts/sync-harness-engine.mjs --check`, which fails on any local edit
|
|
294
|
+
under `vendor/`.
|
|
295
|
+
- A fix to the engine lands upstream first, then
|
|
296
|
+
`node scripts/sync-harness-engine.mjs <path-to-agnostic-ai>` copies it in
|
|
297
|
+
and re-records the hashes. `--diff <path>` lists what a sync would change.
|
|
298
|
+
- Upstream exposes the library through `engine/harness/index.cjs` with
|
|
299
|
+
everything a host may own as an option (`configure({ brand, secretPatterns,
|
|
300
|
+
shimPath, importRoots })`, an injected registry and policy), and its own
|
|
301
|
+
regression suite proves that boundary. Node's ESM loader imports that
|
|
302
|
+
CommonJS entry directly, so there is no build step and no dependency.
|
|
303
|
+
|
|
304
|
+
Attribution is in `NOTICE`; the vendored copy keeps its MIT licence text and
|
|
305
|
+
is not relicensed.
|
|
306
|
+
|
|
307
|
+
## Limits
|
|
308
|
+
|
|
309
|
+
- Grok has no adapter.
|
|
310
|
+
- Cursor, Windsurf and the other clients upstream supports are not in Leg's
|
|
311
|
+
registry, because Leg does not launch them.
|
|
312
|
+
- The Claude Code target imports the ported rules from `~/.claude/CLAUDE.md`
|
|
313
|
+
with one `@` line; a source that is itself Codex therefore lands as
|
|
314
|
+
`~/.claude/leg-rules.md`, not inline.
|
|
315
|
+
- A repository-level harness (project skills, project MCP) is not carried;
|
|
316
|
+
only the global one is.
|
|
317
|
+
- Persistent configuration is the only mechanism: none of the four clients
|
|
318
|
+
offers a per-session overlay for rules, hooks, skills and MCP together, so
|
|
319
|
+
the managed-region and ownership discipline above is what keeps writes safe.
|
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.
|
package/fixtures/verified.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "The single source of truth for every dated, numbered claim
|
|
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
5
|
"tests": 473,
|