@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.
- package/CHANGELOG.md +114 -0
- package/README.md +51 -13
- package/bin/leg.mjs +104 -62
- package/docs/DECISIONS.md +8 -0
- package/docs/DEVIATIONS.md +34 -0
- package/docs/ERRORS.md +1 -1
- package/docs/ROADMAP-v2.md +12 -1
- package/docs/adapters.md +5 -2
- package/docs/cli-contracts.md +14 -6
- package/docs/concepts.md +52 -14
- package/docs/configuration.md +5 -3
- package/docs/faq.md +4 -3
- package/docs/review-2026-09-18.md +172 -0
- package/fixtures/verified.json +1 -1
- package/package.json +1 -1
- package/src/accounts.mjs +67 -7
- package/src/attach.mjs +184 -48
- package/src/board/board.js +1 -1
- package/src/board/floor.js +1 -1
- package/src/bundle.mjs +33 -11
- package/src/digest.mjs +197 -0
- package/src/git.mjs +97 -0
- package/src/handoff.mjs +20 -2
- package/src/history/common.mjs +3 -1
- package/src/history/providers/claude.mjs +5 -1
- package/src/history/worktrees.mjs +105 -25
- package/src/hook.mjs +5 -4
- package/src/launcher.mjs +1 -1
- package/src/limits.mjs +19 -1
- package/src/scheduler-status.mjs +22 -0
- package/src/scheduler.mjs +3 -14
- package/src/server.mjs +171 -38
- package/src/session-detail.mjs +25 -2
- package/src/sessions.mjs +18 -3
- package/src/synthesis.mjs +23 -4
- package/src/taps/claude-usage.mjs +5 -4
- package/src/taps/claude.mjs +34 -6
- package/src/usage.mjs +27 -1
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
|
}
|
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) {
|