@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 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
@@ -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 chains your own `statusLine` command first. Claude Code
59
- 2.1.268 did not run it when it was tried on this machine: an `echo` command
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
@@ -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 runs the user's own
250
- command first (source: code.claude.com/docs/en/settings;
251
- src/taps/claude.mjs `userStatusLine`). observed-live 2026-09-11: a Leg
252
- session ran with every user hook still firing.
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 (no artifact kept; note in the
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 is chained first either way. See
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.0",
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",
@@ -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.0'
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
@@ -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.0'
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 does not; see src/taps/claude-usage.mjs) and prints one Leg line.
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 renders its built-in status
4
- // line and does not run a custom `statusLine` command passed via --settings
5
- // or a project settings file (verified 2026-09-11 with an `echo` command at
6
- // both levels; hooks from the same --settings file do run). So Leg asks the
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).
@@ -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 line runs the user's own command first).
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 the text to print.
292
- export function handleStatusline(sessionId, p) {
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
  }