@ucsandman/legcli 0.14.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.
@@ -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
  }
package/src/usage.mjs CHANGED
@@ -21,8 +21,9 @@ import { join } from 'node:path'
21
21
  import { home } from './store.mjs'
22
22
  import { writeJsonAtomic, withFileLock } from './fsx.mjs'
23
23
  import { AGENTS } from './sessions.mjs'
24
- import { ACCOUNT_NAME_RE } from './accounts.mjs'
24
+ import { ACCOUNT_NAME_RE, transcriptReachable } from './accounts.mjs'
25
25
  import { rungCost, staticCost } from './preferences.mjs'
26
+ import { isDownshift } from './buckets.mjs'
26
27
 
27
28
  export const WARN_PCT = Number((process.env.LEG_WARN_PCT || process.env.BATON_WARN_PCT) || 85)
28
29
  // A limit hit with no reset time from the agent: assume the 5-hour window.
@@ -464,6 +465,31 @@ export function rungLabel(r) {
464
465
  return `${r.agent}${r.account && r.account !== 'default' ? '/' + r.account : ''}${r.model ? '/' + r.model : ''}`
465
466
  }
466
467
 
468
+ // The hand-off that keeps the conversation instead of taking the bundle: the
469
+ // next leg starts `claude --resume <id>` inside the same transcript, so nothing
470
+ // is re-explained. Two cases, one rule, read by the terminal (src/attach.mjs)
471
+ // and by the board's picker (src/server.mjs) so the row and the switch agree:
472
+ // 1. a downshift on the same login (fixtures/live/claude/resume-model-probe.json:
473
+ // the conversation survives and only the new model answers; an upshift
474
+ // would re-read the whole context at the stronger model's price, so it
475
+ // takes the bundle);
476
+ // 2. another login of the same agent whose home can see the transcript
477
+ // (the `projects` junction src/accounts.mjs cuts for every account). A
478
+ // weekly or Fable wall on one 20x login then moves the terminal to the
479
+ // other login with the conversation it already had, at whatever model the
480
+ // rung names or the destination's own default.
481
+ // codex has a `resume` subcommand too, but composing it with `-m` and with a
482
+ // second CODEX_HOME is unobserved, so codex never claims to keep anything.
483
+ export function keepsConversation({ from, to, session }) {
484
+ if (!session?.agent_session_id || !from || !to) return false
485
+ if (from.agent !== 'claude' || to.agent !== 'claude') return false
486
+ if (isDownshift(from, to)) return true
487
+ const a = from.account ?? 'default'
488
+ const b = to.account ?? 'default'
489
+ if (a === b) return false
490
+ return transcriptReachable('claude', session.transcript_path, { from: a, to: b })
491
+ }
492
+
467
493
  // One ledger line for a rung that was passed over. Exact wording matters: this
468
494
  // is what the terminal and the card say instead of going somewhere unexplained.
469
495
  export function skipLine(r) {