@ucsandman/legcli 0.15.0 → 0.15.1
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 +15 -0
- package/docs/DEVIATIONS.md +1 -0
- package/docs/adapters.md +5 -2
- package/docs/cli-contracts.md +14 -6
- package/docs/faq.md +4 -3
- package/package.json +1 -1
- package/src/board/board.js +1 -1
- package/src/board/floor.js +1 -1
- package/src/hook.mjs +5 -4
- package/src/taps/claude-usage.mjs +5 -4
- package/src/taps/claude.mjs +34 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.15.1 (2026-09-19)
|
|
4
|
+
|
|
5
|
+
Your own Claude Code status line comes back.
|
|
6
|
+
|
|
7
|
+
- **Your status line runs first.** `leg claude` passes a `statusLine` through
|
|
8
|
+
`--settings`, and that key replaces yours rather than merging. Leg has
|
|
9
|
+
always read your `statusLine` command, but it only ever used its `padding`;
|
|
10
|
+
the command itself never ran, so on a build that honours a `--settings`
|
|
11
|
+
status line you saw Leg's row instead of your own. The command is now kept
|
|
12
|
+
on the session record and Leg's status-line hook runs it with the same JSON
|
|
13
|
+
Claude Code sends, printing its rows above Leg's one line. Reported by a
|
|
14
|
+
baton user on Discord. Claude Code 2.1.278 on Windows still shows its
|
|
15
|
+
built-in status line in place of any custom one passed this way, so the 5h
|
|
16
|
+
and 7d numbers keep coming from the usage endpoint (docs/faq.md).
|
|
17
|
+
|
|
3
18
|
## 0.15.0 (2026-09-18)
|
|
4
19
|
|
|
5
20
|
A second login keeps the conversation, and the runtime does less work per
|
package/docs/DEVIATIONS.md
CHANGED
|
@@ -104,6 +104,7 @@ shape differs from what the plan assumed. Same columns as the table above.
|
|
|
104
104
|
|------|------|---------------|-------------|-----|
|
|
105
105
|
| 2026-09-11 | bin/leg.mjs `simulate-limit` | "sends a real StopFailure payload with error rate_limit through src/hook.mjs" | the payload carries `baton_simulated: true`; the hook records the wall with a 2-minute reset instead of the default 5 h, never keeps it as live evidence, and the event says `(simulated)`; agy gets the RESOURCE_EXHAUSTED line appended to its Leg-owned session log; codex is refused (its wall lives in a rollout file Leg never writes) with `leg sessions handoff` as the alternative | a test must never wall the real claude login for five hours, and a simulated payload must never flip a docs row to observed-live |
|
|
106
106
|
| 2026-09-11 | src/live-capture.mjs | "save that payload under fixtures/live/" | `fixtures/live/<agent>/limit-<signal>.json` only in a dev clone (the checkout has `.git` and `fixtures/live/`), else `~/.leg/live/`; `LEG_LIVE_DIR` overrides; first arrival per (agent, signal) wins; docs rows flip through `scripts/live-limits.mjs` markers (`<!-- live:<agent>/<signal> -->`) in the same call | an installed package has no writable fixtures directory, and a doc row must flip from evidence on disk, not from memory |
|
|
107
|
+
| 2026-09-19 | src/taps/claude.mjs `writeSettings`, src/hook.mjs | the user's own `statusLine` command runs first (claimed in the file header since baton 0.2.0) | it was read only for its `padding`; the command is now kept on the session record (`user_statusline`) and the status-line hook runs it with the same stdin, printing its rows above Leg's | a baton user reported on Discord (2026-09-19) that baton replaced the status line they had configured; on a build that honours a `--settings` status line, Leg's row displaced theirs |
|
|
107
108
|
| 2026-09-11 | src/attach.mjs `spawnSpec` | the next agent starts with no args | `BATON_<AGENT>_ARGS` (space-separated) is prepended to a leg Leg starts itself after a hand-off; the user's own `baton <agent> …` args still never carry over | the live test had to keep the handed-off leg on a cheap model, and a user has the same need for a chain |
|
|
108
109
|
| 2026-09-11 | src/taps/codex.mjs | the first `role: user` message is the task | a message starting `# AGENTS.md instructions` is skipped like `<environment_context>` | observed live 2026-09-11: codex prepends the AGENTS.md block as the first user message of every rollout, so the card's task read as the instructions file |
|
|
109
110
|
| 2026-09-11 | src/attach.mjs, src/wait.mjs | all out: print the reset times and exit 3 | status `waiting` with `session.waiting = { agent, account, resets_at, since }`; a one-line countdown on stderr (rewritten in place on a TTY, once a minute otherwise); at the reset the chooser runs again and the first option back starts from the bundle; Ctrl-C or a board End quits with exit 3 and an `ended` event "quit while waiting" | the terminal is where the work is; quitting hands the human a restart to type at the reset time |
|
package/docs/adapters.md
CHANGED
|
@@ -55,8 +55,11 @@ documentation say docs-only.
|
|
|
55
55
|
`src/hook.mjs`.
|
|
56
56
|
- **Why not the status line.** Leg writes a `statusLine` entry into the same
|
|
57
57
|
settings file that would record `rate_limits.five_hour.used_percentage` and
|
|
58
|
-
`resets_at`, and
|
|
59
|
-
|
|
58
|
+
`resets_at`, and runs your own `statusLine` command first, its rows above
|
|
59
|
+
Leg's one (since 0.15.1; before that your command was read and never run,
|
|
60
|
+
so a build that honours the key showed Leg's row instead of yours). Claude
|
|
61
|
+
Code 2.1.268 and 2.1.278 did not run it when it was tried on this machine
|
|
62
|
+
(2026-09-11, 2026-09-19): an `echo` command
|
|
60
63
|
passed through `--settings` and again through a project
|
|
61
64
|
`.claude/settings.local.json` left the built-in status line in place, while
|
|
62
65
|
hooks from the same `--settings` file fired. No artifact of that check was
|
package/docs/cli-contracts.md
CHANGED
|
@@ -246,10 +246,16 @@ variable and `LEG_SESSION` (source: src/attach.mjs, src/env.mjs).
|
|
|
246
246
|
- Attach: `claude <args> -n "leg#<short id> <repo>/<branch>" --settings <LEG_HOME>/sessions/<id>/claude-settings.json`
|
|
247
247
|
(source: src/attach.mjs `spawnSpec`; src/taps/claude.mjs `writeSettings`).
|
|
248
248
|
Hooks in a `--settings` file merge with the user's rather than replacing
|
|
249
|
-
them; `statusLine` is the one key that replaces, so Leg
|
|
250
|
-
command
|
|
251
|
-
|
|
252
|
-
|
|
249
|
+
them; `statusLine` is the one key that replaces, so Leg keeps the user's own
|
|
250
|
+
command on the session record (`user_statusline`) and its status-line hook
|
|
251
|
+
runs that command with the same stdin JSON, printing its rows above Leg's
|
|
252
|
+
one (source: code.claude.com/docs/en/settings, docs/en/statusline "Display
|
|
253
|
+
multiple lines"; src/taps/claude.mjs `userStatusLine`, `userStatusLineText`;
|
|
254
|
+
src/hook.mjs). Before 0.15.1 the user's command was read only for its
|
|
255
|
+
`padding` and never run, so a build that honours a `--settings` status line
|
|
256
|
+
showed Leg's row in place of the user's (reported by a baton user,
|
|
257
|
+
2026-09-19). observed-live 2026-09-11: a Leg session ran with every user
|
|
258
|
+
hook still firing.
|
|
253
259
|
- Terminal title: `-n, --name <name>` "Set a display name for this session
|
|
254
260
|
(shown in the prompt box, /resume picker, and terminal title)" —
|
|
255
261
|
fixtures/help/claude.txt line 132. It sits in the general Options block, not
|
|
@@ -327,11 +333,13 @@ variable and `LEG_SESSION` (source: src/attach.mjs, src/env.mjs).
|
|
|
327
333
|
path can also be run live with `leg sessions simulate-limit` (the same
|
|
328
334
|
payload through `src/hook.mjs`, marked `leg_simulated`, never kept as
|
|
329
335
|
evidence).
|
|
330
|
-
- Status line, not usable on 2.1.268: a custom `statusLine` command passed
|
|
336
|
+
- Status line, not usable on 2.1.268 or 2.1.278: a custom `statusLine` command passed
|
|
331
337
|
through `--settings`, and again through a project
|
|
332
338
|
`.claude/settings.local.json`, was not run at all when it was tried; an
|
|
333
339
|
`echo` command at both levels left the built-in status line in place while
|
|
334
|
-
hooks from the same `--settings` file fired (
|
|
340
|
+
hooks from the same `--settings` file fired (probed again 2026-09-19 on
|
|
341
|
+
2.1.278 with a screenshot of two haiku sessions, not kept; 0 of 87 session
|
|
342
|
+
hook logs on this machine hold a statusline entry; note in the
|
|
335
343
|
src/taps/claude-usage.mjs header, 2026-09-11). Leg still writes the
|
|
336
344
|
`statusLine` entry, which records the
|
|
337
345
|
same `rate_limits.five_hour` / `seven_day` fields
|
package/docs/faq.md
CHANGED
|
@@ -20,14 +20,15 @@ launch, and does not count as a usage limit.
|
|
|
20
20
|
|
|
21
21
|
**Why does Leg poll an endpoint for claude's usage instead of reading the
|
|
22
22
|
status line?**
|
|
23
|
-
Because Claude Code 2.1.268 did not run a custom status line from a settings
|
|
24
|
-
file Leg controls when this was tried on 2026-09-11 (recorded in
|
|
23
|
+
Because Claude Code 2.1.268 and 2.1.278 did not run a custom status line from a settings
|
|
24
|
+
file Leg controls when this was tried on 2026-09-11 and 2026-09-19 (recorded in
|
|
25
25
|
[DEVIATIONS.md](DEVIATIONS.md)); hooks from the same `--settings` file did
|
|
26
26
|
fire. So the numbers come from
|
|
27
27
|
`GET api.anthropic.com/api/oauth/usage` with the login Claude Code already
|
|
28
28
|
stored, which is the same data `/usage` shows. Leg still writes the
|
|
29
29
|
`statusLine` entry, so the endpoint poll becomes a fallback the moment a build
|
|
30
|
-
honours it, and your own status-line command
|
|
30
|
+
honours it, and your own status-line command runs first, its rows above
|
|
31
|
+
Leg's one, either way (since 0.15.1). See
|
|
31
32
|
[adapters.md](adapters.md#claude) and `src/taps/claude-usage.mjs`.
|
|
32
33
|
|
|
33
34
|
**Why does codex get no hook when claude does?**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ucsandman/legcli",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
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. 14-day free trial, then $79 once, 30-day money-back guarantee.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
package/src/board/board.js
CHANGED
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
// release lands on disk under a board that was started before it: the page
|
|
69
69
|
// then draws controls the process has no routes for (an empty agent select,
|
|
70
70
|
// no buckets). test/files-version.test.mjs pins this to package.json.
|
|
71
|
-
const FILES_VERSION = '0.15.
|
|
71
|
+
const FILES_VERSION = '0.15.1'
|
|
72
72
|
const TIMELINE_CAP = 12
|
|
73
73
|
// mirrors LOOPBACK in src/auth.mjs; state.bind is "<host>:<port>" and an IPv6
|
|
74
74
|
// host arrives bracketed
|
package/src/board/floor.js
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
// name. test/board-updates.test.mjs pins `state.stopped` in this file by
|
|
51
51
|
// source text, which is why the module object keeps the name.
|
|
52
52
|
// pinned to package.json by test/files-version.test.mjs; see board.js FILES_VERSION
|
|
53
|
-
const FILES_VERSION = '0.15.
|
|
53
|
+
const FILES_VERSION = '0.15.1'
|
|
54
54
|
const state = { es: null, retryMs: 1000, timers: [], stopped: false, sseRequest: 0, floorRequest: 0, trunkRequest: 0, headRequest: 0, cardsRequest: 0, lastReadingAt: null, bind: (typeof location !== 'undefined' && location.host) || '127.0.0.1:4747', pendingFloor: null, pendingCards: null, cards: new Map(), blockers: new Map(), doneOpen: false, ringId: null, scheduler: {}, trunkOff: false }
|
|
55
55
|
|
|
56
56
|
// What the entry row reads on every render: the terminals (for the repo it
|
package/src/hook.mjs
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// node hook.mjs claude-hook --session <id>
|
|
7
7
|
// node hook.mjs claude-statusline --session <id>
|
|
8
8
|
// The status line entry records rate_limits when a Claude Code build runs it
|
|
9
|
-
// (2.1.268
|
|
9
|
+
// (2.1.268 and 2.1.278 did not on this machine; see src/taps/claude-usage.mjs),
|
|
10
|
+
// prints the user's own status line first, then one Leg line.
|
|
10
11
|
import { appendFileSync } from 'node:fs'
|
|
11
12
|
import { join } from 'node:path'
|
|
12
13
|
import { handleHook, handleStatusline, terminalSequenceFor } from './taps/claude.mjs'
|
|
@@ -47,9 +48,9 @@ try {
|
|
|
47
48
|
const seq = terminalSequenceFor(payload)
|
|
48
49
|
if (seq) process.stdout.write(JSON.stringify({ terminalSequence: seq }) + '\n')
|
|
49
50
|
} else if (kind === 'claude-statusline') {
|
|
50
|
-
const { text } = handleStatusline(sessionId, payload)
|
|
51
|
-
try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)}\n`) } catch {}
|
|
52
|
-
process.stdout.write(text + '\n')
|
|
51
|
+
const { text, user } = handleStatusline(sessionId, payload, raw)
|
|
52
|
+
try { appendFileSync(join(sessionDir(sessionId), 'hook.log'), `${new Date().toISOString()} statusline rate_limits=${JSON.stringify(payload.rate_limits ?? null)} user_rows=${user ? user.split('\n').length : 0}\n`) } catch {}
|
|
53
|
+
process.stdout.write((user ? user + '\n' : '') + text + '\n')
|
|
53
54
|
}
|
|
54
55
|
} catch {}
|
|
55
56
|
process.exit(0)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// claude usage — the 5h / 7d percentages for a Claude Code login, from the
|
|
2
2
|
// same endpoint Claude Code's own /usage and built-in status line read.
|
|
3
|
-
// Why not the status line: Claude Code 2.1.268
|
|
4
|
-
// line and
|
|
5
|
-
// or a project settings file (verified 2026-09-11
|
|
6
|
-
// both levels; hooks from the same
|
|
3
|
+
// Why not the status line: Claude Code 2.1.268 and 2.1.278 render their
|
|
4
|
+
// built-in status line and do not run a custom `statusLine` command passed
|
|
5
|
+
// via --settings or a project settings file (verified 2026-09-11 and
|
|
6
|
+
// 2026-09-19 with an `echo` command at both levels; hooks from the same
|
|
7
|
+
// --settings file do run). So Leg asks the
|
|
7
8
|
// usage endpoint directly with the OAuth token Claude Code stored at login.
|
|
8
9
|
// The token is read by this process only, sent only to api.anthropic.com,
|
|
9
10
|
// and never written anywhere (the ledger scrubs bearer tokens regardless).
|
package/src/taps/claude.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// claude tap — how `leg claude` sees inside a normal interactive Claude Code.
|
|
2
2
|
// Nothing in ~/.claude is edited: the session gets one extra settings file via
|
|
3
3
|
// `--settings` (hooks merge with the user's; statusLine is the only key that
|
|
4
|
-
// replaces, so Leg's status
|
|
4
|
+
// replaces, so Leg's status-line hook runs the user's own command with the
|
|
5
|
+
// same stdin and prints its rows above Leg's one line).
|
|
5
6
|
// Sources: code.claude.com/docs/en/hooks (StopFailure `error: rate_limit`),
|
|
6
7
|
// docs/en/statusline (rate_limits.five_hour/seven_day used_percentage,
|
|
7
8
|
// resets_at), docs/en/settings (`--settings` sits above user settings).
|
|
8
9
|
import { existsSync, readFileSync, openSync, closeSync, fstatSync, readSync } from 'node:fs'
|
|
10
|
+
import { spawnSync } from 'node:child_process'
|
|
9
11
|
import { join, dirname, resolve } from 'node:path'
|
|
10
12
|
import { fileURLToPath } from 'node:url'
|
|
11
13
|
import { sessionDir, updateSession, appendEvent, readSession, workRoot } from '../sessions.mjs'
|
|
@@ -64,12 +66,37 @@ export function settingsFor(sessionId, { statusLine = null } = {}) {
|
|
|
64
66
|
return settings
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
export function writeSettings(sessionId, opts) {
|
|
69
|
+
export function writeSettings(sessionId, opts = {}) {
|
|
68
70
|
const file = join(sessionDir(sessionId), 'claude-settings.json')
|
|
69
71
|
writeJsonAtomic(file, settingsFor(sessionId, opts))
|
|
72
|
+
// the hook process reads this back to run the user's command (see userStatusLineText)
|
|
73
|
+
if (opts.statusLine?.command) updateSession(sessionId, { user_statusline: { command: opts.statusLine.command } })
|
|
70
74
|
return file
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
// The shell Claude Code itself uses for a status-line command: /bin/sh, or on
|
|
78
|
+
// Windows Git Bash when installed, else PowerShell (docs/en/statusline,
|
|
79
|
+
// "Windows configuration").
|
|
80
|
+
function statusLineShell(command) {
|
|
81
|
+
if (process.platform !== 'win32') return { file: '/bin/sh', args: ['-c', command] }
|
|
82
|
+
const bash = process.env.CLAUDE_CODE_GIT_BASH_PATH || 'C:\\Program Files\\Git\\bin\\bash.exe'
|
|
83
|
+
if (existsSync(bash)) return { file: bash, args: ['-c', command] }
|
|
84
|
+
return { file: 'powershell', args: ['-NoProfile', '-Command', command] }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The user's own status line, rendered: their command gets the same JSON on
|
|
88
|
+
// stdin Claude Code handed Leg, and whatever it prints goes above Leg's row.
|
|
89
|
+
// Never throws; an absent, slow (3 s) or broken command yields ''.
|
|
90
|
+
export function userStatusLineText(session, raw) {
|
|
91
|
+
const command = session?.user_statusline?.command
|
|
92
|
+
if (!command) return ''
|
|
93
|
+
try {
|
|
94
|
+
const { file, args } = statusLineShell(command)
|
|
95
|
+
const r = spawnSync(file, args, { input: raw ?? '', encoding: 'utf8', timeout: 3000, windowsHide: true })
|
|
96
|
+
return String(r.stdout ?? '').replace(/\s+$/, '')
|
|
97
|
+
} catch { return '' }
|
|
98
|
+
}
|
|
99
|
+
|
|
73
100
|
function textOf(content) {
|
|
74
101
|
if (typeof content === 'string') return content
|
|
75
102
|
if (Array.isArray(content)) return content.filter((c) => c?.type === 'text' && c.text).map((c) => c.text).join('\n')
|
|
@@ -288,10 +315,11 @@ export function relTo(root, file) {
|
|
|
288
315
|
return r && a.toLowerCase().startsWith(r.toLowerCase() + '/') ? a.slice(r.length + 1) : a
|
|
289
316
|
}
|
|
290
317
|
|
|
291
|
-
// Status line: record the limits and print. Returns
|
|
292
|
-
|
|
318
|
+
// Status line: record the limits and print. Returns Leg's row as `text` and
|
|
319
|
+
// the user's own status line (from `raw`, the stdin JSON) as `user`.
|
|
320
|
+
export function handleStatusline(sessionId, p, raw = '') {
|
|
293
321
|
const s = readSession(sessionId)
|
|
294
|
-
if (!s) return { text: '', limits: null }
|
|
322
|
+
if (!s) return { text: '', user: '', limits: null }
|
|
295
323
|
const limits = limitsFrom(p.rate_limits)
|
|
296
324
|
if (limits) recordUsage('claude', s.account, limits, 'claude statusline')
|
|
297
325
|
const hot = limits ? [['5h', limits.five_hour], ['7d', limits.seven_day]].filter(([, w]) => w).sort((a, b) => b[1].pct - a[1].pct)[0] : null
|
|
@@ -312,5 +340,5 @@ export function handleStatusline(sessionId, p) {
|
|
|
312
340
|
const next = s.chain?.[0] ? `${s.chain[0].agent}${s.chain[0].account !== 'default' ? '/' + s.chain[0].account : ''}` : 'nothing'
|
|
313
341
|
const pct = limits ? ` 5h ${limits.five_hour ? Math.round(limits.five_hour.pct) + '%' : '-'} · 7d ${limits.seven_day ? Math.round(limits.seven_day.pct) + '%' : '-'}` : ''
|
|
314
342
|
const text = warn ? `⚠ leg: ${hot[0]} at ${Math.round(hot[1].pct)}% → next ${next}${pct}` : `leg ·${pct || ' limits pending'} · next ${next} · board ${s.board_url ?? ''}`
|
|
315
|
-
return { text, limits, warn }
|
|
343
|
+
return { text, user: userStatusLineText(s, raw), limits, warn }
|
|
316
344
|
}
|