@ucsandman/legcli 0.11.0 → 0.13.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 +213 -0
- package/README.md +95 -65
- package/bin/leg.mjs +123 -14
- package/docs/DECISIONS.md +18 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +68 -0
- package/docs/ROADMAP-v2.md +50 -5
- package/docs/VOCABULARY.md +27 -0
- package/docs/board-guide.md +529 -96
- package/docs/cli-contracts.md +241 -5
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +65 -1
- package/docs/faq.md +21 -5
- package/docs/getting-started.md +15 -11
- package/docs/redesign-2026-09-17.md +477 -0
- package/docs/screenshots/background-1280.png +0 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/capacity-drawer-1280.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/new-card-dialog.png +0 -0
- package/docs/screenshots/settings-ladder-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/fixtures/limits/claude/claude-fable-limit.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +1 -1
- package/fixtures/limits/claude/claude-session-limit.json +1 -1
- package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/verified.json +1 -1
- package/package.json +3 -2
- package/scripts/board-jump-probe.mjs +335 -0
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/attach.mjs +378 -93
- package/src/audit.mjs +1 -1
- package/src/board/board.css +203 -11
- package/src/board/board.js +664 -200
- package/src/board/entry.js +343 -0
- package/src/board/floor.html +51 -39
- package/src/board/floor.js +585 -73
- package/src/board/index.html +122 -45
- package/src/board/sessions.js +1569 -141
- package/src/board/strip.js +163 -0
- package/src/buckets.mjs +101 -0
- package/src/cards.mjs +9 -1
- package/src/chain.mjs +13 -0
- package/src/hook.mjs +7 -1
- package/src/ledger.mjs +10 -2
- package/src/models.mjs +265 -0
- package/src/orchestrator.mjs +13 -4
- package/src/preferences.mjs +278 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +625 -78
- package/src/sessions.mjs +17 -1
- package/src/taps/claude-usage.mjs +107 -3
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/usage-poll.mjs +260 -0
- package/src/usage.mjs +439 -12
package/src/sessions.mjs
CHANGED
|
@@ -48,15 +48,31 @@ export function isActive(s) { return ACTIVE.includes(s?.status) }
|
|
|
48
48
|
// it one (repo stays the main checkout, for grouping and landing), else the repo.
|
|
49
49
|
export function workRoot(s) { return s?.worktree?.path ?? s?.repo ?? s?.cwd ?? null }
|
|
50
50
|
|
|
51
|
-
export function createSession({ id, agent, account = 'default', cwd, repo = null, branch = null, argv = [], runner_pid = process.pid, chain = [], worktree = null, owner = null, handoffOrder = AGENTS, installed = null, runtimeCapabilities = [] }) {
|
|
51
|
+
export function createSession({ id, agent, account = 'default', cwd, repo = null, branch = null, argv = [], runner_pid = process.pid, chain = [], worktree = null, owner = null, handoffOrder = AGENTS, installed = null, runtimeCapabilities = [], model = null }) {
|
|
52
52
|
const session = {
|
|
53
53
|
session_id: id, agent, account, cwd, repo, branch, argv, worktree, owner,
|
|
54
54
|
repo_name: repo ? repo.split(/[\\/]/).filter(Boolean).pop() : null,
|
|
55
|
+
// the model this leg resolved to: the `--model`/`-m` the human passed, else
|
|
56
|
+
// null. Never a guessed default — a printed model nobody chose is a wrong
|
|
57
|
+
// number in disguise. For claude the runner refreshes it from the
|
|
58
|
+
// transcript's per-assistant-line `message.model`, so a silent fallback off
|
|
59
|
+
// Fable becomes visible on the row (docs/redesign-2026-09-17.md B.1).
|
|
60
|
+
model,
|
|
61
|
+
// what this terminal is waiting for, or null. Two shapes, told apart by
|
|
62
|
+
// `type`: `{ type: 'reset', agent, account, resets_at, since }` is the
|
|
63
|
+
// all-out countdown the runner writes (src/attach.mjs), and
|
|
64
|
+
// `{ type: 'permission_prompt'|'idle_prompt'|'agent_needs_input'|'quota_auto_resume', message, since }`
|
|
65
|
+
// is a human being waited on, from Claude Code's Notification hook.
|
|
66
|
+
waiting: null,
|
|
55
67
|
status: 'starting', runner_pid, pid: null,
|
|
56
68
|
started_at: now(), updated_at: now(), ended_at: null, last_activity: now(),
|
|
57
69
|
agent_session_id: null, transcript_path: null,
|
|
58
70
|
task: null, turns: 0,
|
|
59
71
|
files_touched: [], files_dirty: [], head: null, head_at_start: null,
|
|
72
|
+
// commits this checkout is ahead of its upstream (else of head_at_start),
|
|
73
|
+
// refreshed by the runner's git poll. null until it has been counted, and
|
|
74
|
+
// null again whenever it cannot be: never a zero standing in for unknown.
|
|
75
|
+
ahead: null,
|
|
60
76
|
limits: null, limit: null, warning: null,
|
|
61
77
|
bundle: null, handoff: null, chain, checkpoints: [],
|
|
62
78
|
handoff_order: normalizeHandoffOrder(handoffOrder), installed,
|
|
@@ -14,6 +14,7 @@ import http from 'node:http'
|
|
|
14
14
|
import { existsSync, readFileSync } from 'node:fs'
|
|
15
15
|
import { join } from 'node:path'
|
|
16
16
|
import { LAYOUT } from '../accounts.mjs'
|
|
17
|
+
import { MODEL_ALIASES } from '../buckets.mjs'
|
|
17
18
|
|
|
18
19
|
export const USAGE_URL = (process.env.LEG_CLAUDE_USAGE_URL || process.env.BATON_CLAUDE_USAGE_URL) || 'https://api.anthropic.com/api/oauth/usage'
|
|
19
20
|
|
|
@@ -37,6 +38,99 @@ function window(x) {
|
|
|
37
38
|
return { pct, resets_at: resets }
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
function epoch(x) {
|
|
42
|
+
if (x === null || x === undefined) return null
|
|
43
|
+
if (typeof x === 'number') return Number.isFinite(x) ? Math.floor(x > 1e12 ? x / 1000 : x) : null
|
|
44
|
+
const t = Date.parse(x)
|
|
45
|
+
return Number.isFinite(t) ? Math.floor(t / 1000) : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The model a limit row is scoped to, lowercased, or null when the row is an
|
|
49
|
+
// account-wide bucket. The endpoint is undocumented, so the scope is read by
|
|
50
|
+
// shape (a `model` object carrying a display name) rather than by a type word.
|
|
51
|
+
// A display name carries a version the rest of Leg never says ("Fable 5.1",
|
|
52
|
+
// "Claude Opus 5"), and every other model name in the system is an alias: the
|
|
53
|
+
// walls (src/buckets.mjs), the rungs (src/preferences.mjs) and the board all
|
|
54
|
+
// join on one. So the name is matched word by word against the alias list and
|
|
55
|
+
// the alias is what is stored; a name Leg does not know keeps its own
|
|
56
|
+
// lowercased text, because inventing a model is worse than printing an unknown
|
|
57
|
+
// one.
|
|
58
|
+
function scopeModel(scope) {
|
|
59
|
+
const name = scope?.model?.display_name ?? scope?.model?.displayName ?? null
|
|
60
|
+
if (typeof name !== 'string' || !name.trim()) return null
|
|
61
|
+
const raw = name.trim().toLowerCase()
|
|
62
|
+
for (const alias of MODEL_ALIASES.claude) {
|
|
63
|
+
if (raw.split(/[^a-z0-9]+/).includes(alias)) return alias
|
|
64
|
+
}
|
|
65
|
+
return raw
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function groupOf(kind) {
|
|
69
|
+
const k = String(kind ?? '')
|
|
70
|
+
if (k.startsWith('weekly')) return 'weekly'
|
|
71
|
+
if (k.startsWith('session') || k.startsWith('five_hour')) return 'session'
|
|
72
|
+
if (k.startsWith('spend') || k.startsWith('extra')) return 'spend'
|
|
73
|
+
return k || 'unknown'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// One `limits[]` row → a bucket. Percentages only: what a row *means* (whether
|
|
77
|
+
// a model switch helps) is decided in src/buckets.mjs from the wall wording,
|
|
78
|
+
// never from a number.
|
|
79
|
+
export function bucketOf(x) {
|
|
80
|
+
if (!x || typeof x !== 'object') return null
|
|
81
|
+
const kind = x.type ?? x.kind ?? x.name ?? null
|
|
82
|
+
if (!kind) return null
|
|
83
|
+
const percent = Number(x.utilization ?? x.used_percentage ?? x.used_percent ?? x.percent)
|
|
84
|
+
if (!Number.isFinite(percent)) return null
|
|
85
|
+
return {
|
|
86
|
+
kind: String(kind),
|
|
87
|
+
group: groupOf(kind),
|
|
88
|
+
model: scopeModel(x.scope),
|
|
89
|
+
percent,
|
|
90
|
+
resets_at: epoch(x.resets_at ?? x.resetsAt ?? null),
|
|
91
|
+
is_active: Boolean(x.is_active ?? x.isActive ?? false),
|
|
92
|
+
severity: typeof x.severity === 'string' ? x.severity : 'normal',
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function bucketsFrom(j) {
|
|
97
|
+
if (!Array.isArray(j?.limits)) return []
|
|
98
|
+
return j.limits.map(bucketOf).filter(Boolean)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// `extra_usage` / `spend`, the two sentences the capacity drawer prints. Only
|
|
102
|
+
// the fields that exist are carried; the eighteen codename keys the payload
|
|
103
|
+
// also holds (tangelo, iguana_necktie, ...) are never read.
|
|
104
|
+
export function extraUsageFrom(j) {
|
|
105
|
+
const e = j?.extra_usage
|
|
106
|
+
const s = j?.spend
|
|
107
|
+
if ((!e || typeof e !== 'object') && (!s || typeof s !== 'object')) return null
|
|
108
|
+
const out = {}
|
|
109
|
+
const enabled = e?.is_enabled ?? e?.enabled
|
|
110
|
+
if (typeof enabled === 'boolean') out.enabled = enabled
|
|
111
|
+
const reason = e?.disabled_reason ?? e?.reason
|
|
112
|
+
if (typeof reason === 'string') out.reason = reason
|
|
113
|
+
const canToggle = s?.can_toggle ?? e?.can_toggle
|
|
114
|
+
if (typeof canToggle === 'boolean') out.can_toggle = canToggle
|
|
115
|
+
const limitMinor = Number(e?.monthly_limit ?? e?.limit ?? s?.monthly_limit)
|
|
116
|
+
if (Number.isFinite(limitMinor)) out.limit_minor = limitMinor
|
|
117
|
+
const usedMinor = Number(e?.monthly_used ?? e?.used ?? s?.monthly_used ?? s?.used)
|
|
118
|
+
if (Number.isFinite(usedMinor)) out.used_minor = usedMinor
|
|
119
|
+
return Object.keys(out).length ? out : null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// What a failing answer is allowed to say: the status code and the `type` the
|
|
123
|
+
// body names ('rate_limit_error'), never the body itself. A login shared by
|
|
124
|
+
// several terminals answers 429 often, and the whole JSON on every one of them
|
|
125
|
+
// turned the terminal's timeline into a wall of payloads.
|
|
126
|
+
function errorType(text) {
|
|
127
|
+
try {
|
|
128
|
+
const j = JSON.parse(text)
|
|
129
|
+
const t = j?.error?.type ?? j?.type
|
|
130
|
+
return typeof t === 'string' && t && t !== 'error' ? t : null
|
|
131
|
+
} catch { return null }
|
|
132
|
+
}
|
|
133
|
+
|
|
40
134
|
function getJson(url, headers, timeoutMs) {
|
|
41
135
|
return new Promise((resolvePromise) => {
|
|
42
136
|
const u = new URL(url)
|
|
@@ -54,14 +148,24 @@ function getJson(url, headers, timeoutMs) {
|
|
|
54
148
|
})
|
|
55
149
|
}
|
|
56
150
|
|
|
57
|
-
// → { ok, limits: {five_hour, seven_day}|null, status, error, expired }
|
|
151
|
+
// → { ok, limits: {five_hour, seven_day, buckets, extra_usage}|null, status, error, expired }
|
|
152
|
+
// The two windows keep their shape and their place: every older reader of this
|
|
153
|
+
// function still gets exactly what it got before. `buckets` is OMITTED when the
|
|
154
|
+
// payload has no `limits` array, which is what an older endpoint answers: that
|
|
155
|
+
// is no information about buckets, and recordUsage's `Array.isArray` guard then
|
|
156
|
+
// leaves the last measured ones in place instead of erasing them.
|
|
58
157
|
export async function fetchClaudeUsage({ configDir = LAYOUT.claude.home(), timeoutMs = 8000, url = USAGE_URL } = {}) {
|
|
59
158
|
const t = readToken(configDir)
|
|
60
159
|
if (!t) return { ok: false, limits: null, error: 'no claude.ai login found in ' + configDir }
|
|
61
160
|
const r = await getJson(url, { Authorization: `Bearer ${t.token}`, 'anthropic-beta': 'oauth-2025-04-20', Accept: 'application/json', 'User-Agent': 'legcli' }, timeoutMs)
|
|
62
161
|
if (r.error) return { ok: false, limits: null, status: 0, error: r.error }
|
|
63
|
-
if (r.status !== 200)
|
|
162
|
+
if (r.status !== 200) {
|
|
163
|
+
const kind = errorType(r.text)
|
|
164
|
+
return { ok: false, limits: null, status: r.status, expired: t.expired, error: `usage endpoint ${r.status}${kind ? `: ${kind}` : ''}` }
|
|
165
|
+
}
|
|
64
166
|
let j
|
|
65
167
|
try { j = JSON.parse(r.text) } catch { return { ok: false, limits: null, status: r.status, error: 'usage endpoint returned no JSON' } }
|
|
66
|
-
|
|
168
|
+
const limits = { five_hour: window(j.five_hour), seven_day: window(j.seven_day), extra_usage: extraUsageFrom(j) }
|
|
169
|
+
if (Array.isArray(j.limits)) limits.buckets = bucketsFrom(j)
|
|
170
|
+
return { ok: true, limits, status: r.status, expired: t.expired, raw_keys: Object.keys(j) }
|
|
67
171
|
}
|
package/src/taps/claude.mjs
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
// Sources: code.claude.com/docs/en/hooks (StopFailure `error: rate_limit`),
|
|
6
6
|
// docs/en/statusline (rate_limits.five_hour/seven_day used_percentage,
|
|
7
7
|
// resets_at), docs/en/settings (`--settings` sits above user settings).
|
|
8
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { existsSync, readFileSync, openSync, closeSync, fstatSync, readSync } from 'node:fs'
|
|
9
9
|
import { join, dirname, resolve } from 'node:path'
|
|
10
10
|
import { fileURLToPath } from 'node:url'
|
|
11
11
|
import { sessionDir, updateSession, appendEvent, readSession, workRoot } from '../sessions.mjs'
|
|
12
12
|
import { recordUsage, markLimited, WARN_PCT } from '../usage.mjs'
|
|
13
|
+
import { bucketFromWall, MODEL_ALIASES } from '../buckets.mjs'
|
|
13
14
|
import { writeJsonAtomic } from '../fsx.mjs'
|
|
15
|
+
import { readPreferences } from '../preferences.mjs'
|
|
14
16
|
import { LAYOUT } from '../accounts.mjs'
|
|
15
17
|
|
|
16
18
|
const HOOK = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'hook.mjs')
|
|
@@ -29,6 +31,19 @@ export function userStatusLine(configDir = LAYOUT.claude.home()) {
|
|
|
29
31
|
return null
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
// The four Notification types Leg acts on, as one matcher. Notification
|
|
35
|
+
// "matches on notification type" and, not being FileChanged or StopFailure,
|
|
36
|
+
// takes `|` as the alternation separator (hooks doc lines 173, 165, 1424).
|
|
37
|
+
// The other eight types (auth_success, the elicitation family, agent_completed,
|
|
38
|
+
// quota_auto_resume_stale, quota_auto_resume_disabled) say nothing about a
|
|
39
|
+
// human being waited on, so they never start a hook process.
|
|
40
|
+
export const WAITING_TYPES = ['permission_prompt', 'idle_prompt', 'agent_needs_input']
|
|
41
|
+
export const NOTIFY_MATCHER = [...WAITING_TYPES, 'quota_auto_resume_fired'].join('|')
|
|
42
|
+
// Claude Code waiting at the limit by itself. Leg sets autoContinueAtUsageLimit
|
|
43
|
+
// false, but the human's own settings can re-enable it; two waiters on one
|
|
44
|
+
// terminal is the failure to avoid, so Leg stands down and the row says so.
|
|
45
|
+
export const QUOTA_STAND_DOWN = 'Claude Code is waiting at the limit itself; Leg is not handing this one off.'
|
|
46
|
+
|
|
32
47
|
export function settingsFor(sessionId, { statusLine = null } = {}) {
|
|
33
48
|
const cmd = (kind) => ({ type: 'command', command: `node ${q(HOOK)} ${kind} --session ${sessionId}`, timeout: 20 })
|
|
34
49
|
const settings = {
|
|
@@ -36,6 +51,7 @@ export function settingsFor(sessionId, { statusLine = null } = {}) {
|
|
|
36
51
|
SessionStart: [{ hooks: [cmd('claude-hook')] }],
|
|
37
52
|
UserPromptSubmit: [{ hooks: [cmd('claude-hook')] }],
|
|
38
53
|
PostToolUse: [{ matcher: 'Edit|Write|MultiEdit|NotebookEdit', hooks: [cmd('claude-hook')] }],
|
|
54
|
+
Notification: [{ matcher: NOTIFY_MATCHER, hooks: [cmd('claude-hook')] }],
|
|
39
55
|
Stop: [{ hooks: [cmd('claude-hook')] }],
|
|
40
56
|
StopFailure: [{ hooks: [cmd('claude-hook')] }],
|
|
41
57
|
SessionEnd: [{ hooks: [cmd('claude-hook')] }],
|
|
@@ -84,6 +100,58 @@ export function transcriptTail(path, limit = 8) {
|
|
|
84
100
|
return messagesFromLines(readFileSync(path, 'utf8').split('\n'), limit)
|
|
85
101
|
}
|
|
86
102
|
|
|
103
|
+
// A model id from a CLI (`claude-fable-5-1`) said as the name the ladder, the
|
|
104
|
+
// picker and the row use (`fable`). An id that matches no alias is kept raw:
|
|
105
|
+
// printing a model Leg does not recognise is honest, inventing one is not.
|
|
106
|
+
export function modelAlias(agent, id) {
|
|
107
|
+
const raw = String(id ?? '').trim()
|
|
108
|
+
if (!raw) return null
|
|
109
|
+
for (const alias of MODEL_ALIASES[agent] ?? []) {
|
|
110
|
+
// whitespace is a separator too: a display name reads "Claude Opus 5" where
|
|
111
|
+
// a CLI id reads "claude-opus-5", and both name the same rung
|
|
112
|
+
if (new RegExp(`(?:^|[-_\\s])${alias}(?:$|[-_.\\s])`, 'i').test(raw)) return alias
|
|
113
|
+
}
|
|
114
|
+
return raw
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The model that actually answered, from the transcript's per-assistant-line
|
|
118
|
+
// `message.model` (VERIFIED: 29 assistant lines of the newest jsonl for this
|
|
119
|
+
// repo carry "claude-fable-5-1"). This is how a silent fallback off Fable
|
|
120
|
+
// becomes visible, so it reads the file itself rather than trusting the argv.
|
|
121
|
+
//
|
|
122
|
+
// Two Windows facts shape the read. The file is appended to while Claude Code
|
|
123
|
+
// runs, so a read can land mid-write: it is retried, and a torn last line is
|
|
124
|
+
// dropped by the per-line JSON.parse rather than failing the whole read. And
|
|
125
|
+
// mtime is not a content clock here, so nothing is skipped on a timestamp — the
|
|
126
|
+
// tail is read every time and the answer is whatever the bytes say.
|
|
127
|
+
export function modelFromTranscript(path, { agent = 'claude', tailBytes = 262144, attempts = 3 } = {}) {
|
|
128
|
+
if (!path || !existsSync(path)) return null
|
|
129
|
+
for (let i = 0; i < attempts; i++) {
|
|
130
|
+
let fd = null
|
|
131
|
+
try {
|
|
132
|
+
fd = openSync(path, 'r')
|
|
133
|
+
const size = fstatSync(fd).size
|
|
134
|
+
const want = Math.min(size, tailBytes)
|
|
135
|
+
const buf = Buffer.alloc(want)
|
|
136
|
+
readSync(fd, buf, 0, want, size - want)
|
|
137
|
+
const lines = buf.toString('utf8').split('\n')
|
|
138
|
+
// a partial first line when the tail starts mid-file, a partial last line
|
|
139
|
+
// when the writer is mid-append: both are dropped by the parse below
|
|
140
|
+
for (let k = lines.length - 1; k >= 0; k--) {
|
|
141
|
+
let j
|
|
142
|
+
try { j = JSON.parse(lines[k]) } catch { continue }
|
|
143
|
+
if (j?.type !== 'assistant') continue
|
|
144
|
+
const id = j.message?.model
|
|
145
|
+
if (id) return modelAlias(agent, id)
|
|
146
|
+
}
|
|
147
|
+
return null
|
|
148
|
+
} catch {
|
|
149
|
+
// EBUSY / EPERM / a share violation while Claude Code writes: try again
|
|
150
|
+
} finally { if (fd !== null) try { closeSync(fd) } catch {} }
|
|
151
|
+
}
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
|
|
87
155
|
export function firstPrompt(path) {
|
|
88
156
|
const t = transcriptTail(path, 1000).find((m) => m.role === 'user')
|
|
89
157
|
return t ? t.text.slice(0, 500) : null
|
|
@@ -95,6 +163,40 @@ function limitsFrom(rl) {
|
|
|
95
163
|
return { five_hour: w(rl.five_hour), seven_day: w(rl.seven_day) }
|
|
96
164
|
}
|
|
97
165
|
|
|
166
|
+
// The all-out countdown the runner owns (src/attach.mjs) also lives on
|
|
167
|
+
// `waiting`. A Notification never overwrites it: while that is set the child is
|
|
168
|
+
// already dead and nobody is being waited on in the terminal.
|
|
169
|
+
const humanWait = (w) => Boolean(w) && w.type !== 'reset'
|
|
170
|
+
const clearHumanWait = (cur) => (humanWait(cur.waiting) ? { waiting: null } : {})
|
|
171
|
+
|
|
172
|
+
// What the hook prints back to Claude Code. `terminalSequence` is emitted by
|
|
173
|
+
// Claude Code itself on events that discard systemMessage and continue, which
|
|
174
|
+
// Notification is (hooks doc lines 608, 622, 1490); OSC 9 is the desktop
|
|
175
|
+
// notification Windows Terminal renders (line 617). Restricted to the OSC
|
|
176
|
+
// 0/1/2/9/99/777 allowlist, so anything in the message that could close or open
|
|
177
|
+
// a sequence is dropped rather than risking the whole field being ignored (608).
|
|
178
|
+
// Every control byte out, ESC and BEL included: one of them inside the message
|
|
179
|
+
// would close the sequence Leg is building and open whatever followed it.
|
|
180
|
+
// Written as a scan rather than a regex because a control-character class is
|
|
181
|
+
// exactly what the linter stops, and for good reason.
|
|
182
|
+
// The C1 range (U+0080 to U+009F) goes out with C0 and DEL: on a terminal that
|
|
183
|
+
// decodes C1 from UTF-8, U+009C is ST and closes the sequence Leg is building,
|
|
184
|
+
// and U+009D is OSC and opens whatever follows it. That is the same hazard as a
|
|
185
|
+
// raw ESC or BEL, in two bytes instead of one.
|
|
186
|
+
export const printable = (s) => [...String(s ?? '')].map((c) => {
|
|
187
|
+
const cp = c.codePointAt(0)
|
|
188
|
+
return cp < 0x20 || (cp >= 0x7f && cp <= 0x9f) ? ' ' : c
|
|
189
|
+
}).join('')
|
|
190
|
+
|
|
191
|
+
export function terminalSequenceFor(p, { preferences = null } = {}) {
|
|
192
|
+
if (p?.hook_event_name !== 'Notification') return null
|
|
193
|
+
if (!WAITING_TYPES.includes(String(p.notification_type ?? ''))) return null
|
|
194
|
+
const prefs = preferences ?? readPreferences()
|
|
195
|
+
if (!prefs.notify_terminal) return null
|
|
196
|
+
const text = printable(p.message).trim().slice(0, 160)
|
|
197
|
+
return `\x1b]9;${text || 'leg: this terminal is waiting on you'}\x07`
|
|
198
|
+
}
|
|
199
|
+
|
|
98
200
|
// Hook payload → session record. Returns a short line for the hook log.
|
|
99
201
|
export function handleHook(sessionId, p) {
|
|
100
202
|
const s = readSession(sessionId)
|
|
@@ -106,7 +208,8 @@ export function handleHook(sessionId, p) {
|
|
|
106
208
|
updateSession(sessionId, (cur) => ({ ...base, status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'agent_ready', summary: `claude session ${p.session_id ?? '?'} (${p.source ?? 'startup'})` } })
|
|
107
209
|
return 'session start'
|
|
108
210
|
case 'UserPromptSubmit': {
|
|
109
|
-
|
|
211
|
+
// the human typed, so whatever was being waited on has been answered
|
|
212
|
+
updateSession(sessionId, (cur) => ({ ...base, ...clearHumanWait(cur), task: cur.task ?? (p.prompt ? String(p.prompt).slice(0, 500) : null), turns: (cur.turns ?? 0) + 1 }), { event: { type: 'turn', summary: `prompt: ${String(p.prompt ?? '').slice(0, 120)}` } })
|
|
110
213
|
return 'prompt'
|
|
111
214
|
}
|
|
112
215
|
case 'PostToolUse': {
|
|
@@ -118,16 +221,52 @@ export function handleHook(sessionId, p) {
|
|
|
118
221
|
updateSession(sessionId, (cur) => ({ ...base, files_touched: cur.files_touched.includes(rel) ? cur.files_touched : [...cur.files_touched, rel].slice(-200) }))
|
|
119
222
|
return `touched ${rel}`
|
|
120
223
|
}
|
|
224
|
+
case 'Notification': {
|
|
225
|
+
const type = String(p.notification_type ?? '')
|
|
226
|
+
const since = new Date().toISOString()
|
|
227
|
+
// reducer, and the hazard the status-line handler documents: this hook is
|
|
228
|
+
// its own process and a StopFailure can be writing `status: 'limit'` in
|
|
229
|
+
// the same moment. Only `waiting` is touched from `cur` inside the lock —
|
|
230
|
+
// never status, never limit — so a Notification can never erase a wall.
|
|
231
|
+
if (type === 'quota_auto_resume_fired') {
|
|
232
|
+
updateSession(sessionId, (cur) => ({ ...base, waiting: cur.waiting?.type === 'reset' ? cur.waiting : { type: 'quota_auto_resume', message: QUOTA_STAND_DOWN, since } }),
|
|
233
|
+
{ event: { type: 'status', summary: QUOTA_STAND_DOWN } })
|
|
234
|
+
return 'notify quota_auto_resume'
|
|
235
|
+
}
|
|
236
|
+
if (!WAITING_TYPES.includes(type)) { updateSession(sessionId, base); return `notify ${type || 'unknown'}` }
|
|
237
|
+
// the question verbatim: a paraphrase of what an agent is asking for is
|
|
238
|
+
// the one thing a human cannot check against the terminal in front of them
|
|
239
|
+
const message = String(p.message ?? '').slice(0, 160)
|
|
240
|
+
updateSession(sessionId, (cur) => ({ ...base, waiting: cur.waiting?.type === 'reset' ? cur.waiting : { type, message, since } }),
|
|
241
|
+
{ event: { type: 'waiting', summary: `waiting on you (${type}): ${message}` } })
|
|
242
|
+
return `notify ${type}`
|
|
243
|
+
}
|
|
121
244
|
case 'Stop':
|
|
122
245
|
// reducer: never turn a 'limit'/'handing_off' back to 'running' by racing
|
|
123
|
-
updateSession(sessionId, (cur) => ({ ...base, status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'turn_done', summary: String(p.last_assistant_message ?? '').slice(0, 160) || 'turn done' } })
|
|
246
|
+
updateSession(sessionId, (cur) => ({ ...base, ...clearHumanWait(cur), status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'turn_done', summary: String(p.last_assistant_message ?? '').slice(0, 160) || 'turn done' } })
|
|
124
247
|
return 'stop'
|
|
125
248
|
case 'StopFailure': {
|
|
126
249
|
if (p.error === 'rate_limit') {
|
|
127
250
|
// a simulated wall (baton sessions simulate-limit) clears after two minutes so a test never walls the real login for hours
|
|
128
251
|
const simulated = Boolean(p.leg_simulated || p.baton_simulated)
|
|
129
|
-
const
|
|
130
|
-
|
|
252
|
+
const detail = String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 300)
|
|
253
|
+
// which bucket the wording walled: one model family, or the whole
|
|
254
|
+
// login. Unrecognised wording walls the login (src/buckets.mjs rule 5).
|
|
255
|
+
const hit = bucketFromWall('claude', p.last_assistant_message)
|
|
256
|
+
// markLimited takes the same cross-process lock recordUsage does, so a
|
|
257
|
+
// percentage arriving from the poller in this same moment cannot erase
|
|
258
|
+
// the wall this hook is writing (that race handed the baton straight
|
|
259
|
+
// back to a walled login).
|
|
260
|
+
const u = markLimited('claude', s.account, {
|
|
261
|
+
reason: hit.scope === 'model' ? 'model_limit' : 'rate_limit',
|
|
262
|
+
source: simulated ? 'leg simulate-limit' : 'claude StopFailure',
|
|
263
|
+
resets_at: simulated ? Math.floor(Date.now() / 1000) + 120 : null,
|
|
264
|
+
scope: hit.scope,
|
|
265
|
+
model: hit.model ?? null,
|
|
266
|
+
evidence: detail,
|
|
267
|
+
})
|
|
268
|
+
const resets = hit.scope === 'model' ? (u.walls?.[hit.model]?.limited_until ?? null) : u.limited_until
|
|
269
|
+
updateSession(sessionId, { ...base, status: 'limit', limit: { reason: 'rate_limit', detail, resets_at: resets, at: new Date().toISOString(), simulated, scope: hit.scope, model: hit.model ?? null } }, { event: { type: 'limit', summary: `claude usage limit${simulated ? ' (simulated)' : ''}${hit.scope === 'model' ? ` (${hit.model})` : ''}: ${detail.slice(0, 160)}` } })
|
|
131
270
|
return 'LIMIT'
|
|
132
271
|
}
|
|
133
272
|
appendEvent(sessionId, { type: 'error', summary: `claude ${p.error}: ${String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 160)}` })
|
package/src/taps/codex.mjs
CHANGED
|
@@ -155,6 +155,20 @@ export function normalizeRateLimits(rateLimits) {
|
|
|
155
155
|
return out
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// What the rollout says about the account in words rather than percentages:
|
|
159
|
+
// the plan it is on and the credit balance it prints. Measured, carried
|
|
160
|
+
// through untouched, and never summed with anything: they are strings from
|
|
161
|
+
// codex, not a figure Leg computed. Observed live in the newest rollout
|
|
162
|
+
// (plan_type "prolite", credits.balance "0").
|
|
163
|
+
export function factsFromRateLimits(rateLimits) {
|
|
164
|
+
const out = {}
|
|
165
|
+
const plan = rateLimits?.plan_type ?? rateLimits?.planType
|
|
166
|
+
if (typeof plan === 'string' && plan) out.plan_type = plan
|
|
167
|
+
const balance = rateLimits?.credits?.balance
|
|
168
|
+
if (typeof balance === 'string' || typeof balance === 'number') out.credits_balance = String(balance)
|
|
169
|
+
return out
|
|
170
|
+
}
|
|
171
|
+
|
|
158
172
|
function parseRetryAt(msg) {
|
|
159
173
|
const m = RETRY_AT_RE.exec(msg ?? '')
|
|
160
174
|
if (!m) return null
|
|
@@ -165,16 +179,21 @@ function parseRetryAt(msg) {
|
|
|
165
179
|
|
|
166
180
|
// lines → { limits, limit, messages, turnsDone, taskStarted, threadId }
|
|
167
181
|
export function parseLines(lines) {
|
|
168
|
-
const out = { limits: null, limits_at: null, limit: null, messages: [], turnsDone: 0, taskStarted: 0, threadId: null, files: [] }
|
|
182
|
+
const out = { limits: null, limits_at: null, limit: null, messages: [], turnsDone: 0, taskStarted: 0, threadId: null, files: [], facts: {} }
|
|
169
183
|
for (const line of lines) {
|
|
170
184
|
let j
|
|
171
185
|
try { j = JSON.parse(line) } catch { continue }
|
|
172
186
|
const p = j.payload ?? {}
|
|
173
|
-
if (j.type === 'session_meta') {
|
|
187
|
+
if (j.type === 'session_meta') {
|
|
188
|
+
out.threadId = p.id ?? null
|
|
189
|
+
if (typeof p.model === 'string' && p.model) out.facts.model = p.model
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
174
192
|
if (j.type === 'event_msg') {
|
|
175
193
|
if (p.type === 'token_count' && p.rate_limits) {
|
|
176
194
|
out.limits = normalizeRateLimits(p.rate_limits)
|
|
177
195
|
out.limits_at = j.timestamp ?? null
|
|
196
|
+
Object.assign(out.facts, factsFromRateLimits(p.rate_limits))
|
|
178
197
|
} else if (p.type === 'task_started') out.taskStarted += 1
|
|
179
198
|
else if (p.type === 'task_complete') {
|
|
180
199
|
out.turnsDone += 1
|
|
@@ -265,8 +284,9 @@ export function readCodexUsage({ codexHome = LAYOUT.codex.home(), timeoutMs = 80
|
|
|
265
284
|
if (message.error || !message.result) return stop('codex rate-limit read failed')
|
|
266
285
|
const snapshot = message.result.rateLimitsByLimitId?.codex ?? message.result.rateLimits
|
|
267
286
|
const limits = normalizeRateLimits(snapshot)
|
|
287
|
+
const facts = factsFromRateLimits(snapshot)
|
|
268
288
|
const available = typeof message.result.ordinaryUsageAllowed === 'boolean' ? message.result.ordinaryUsageAllowed : null
|
|
269
|
-
return finish({ ok: available !== null || Boolean(limits.five_hour || limits.seven_day), limits, available, observed_at: new Date().toISOString(), error: null })
|
|
289
|
+
return finish({ ok: available !== null || Boolean(limits.five_hour || limits.seven_day), limits, facts, available, observed_at: new Date().toISOString(), error: null })
|
|
270
290
|
}
|
|
271
291
|
}
|
|
272
292
|
})
|