@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
|
@@ -17,7 +17,7 @@ import { tmpdir } from 'node:os'
|
|
|
17
17
|
// a throwaway home under the OS temp dir, so nothing is written into the repo
|
|
18
18
|
process.env.LEG_HOME ||= process.env.BATON_HOME || join(tmpdir(), 'leg-seed-board')
|
|
19
19
|
process.env.BATON_HOME ||= process.env.LEG_HOME
|
|
20
|
-
const { createSession, updateSession } = await import('../src/sessions.mjs')
|
|
20
|
+
const { createSession, updateSession, HANDOFF_ORDER_CAPABILITY } = await import('../src/sessions.mjs')
|
|
21
21
|
const { recordUsage, markLimited } = await import('../src/usage.mjs')
|
|
22
22
|
const { spawn } = await import('node:child_process')
|
|
23
23
|
const { writeFileSync } = await import('node:fs')
|
|
@@ -37,28 +37,40 @@ const min = 60_000, hour = 60 * min
|
|
|
37
37
|
const ago = (ms) => new Date(Date.now() - ms).toISOString()
|
|
38
38
|
const B = String.fromCharCode(92) // one backslash, unmangled by any shell
|
|
39
39
|
const w = (...parts) => parts.join(B)
|
|
40
|
-
|
|
40
|
+
// 'Projects-seed', not 'Projects': these rows print like real repos, but a
|
|
41
|
+
// button on a seeded row (End as a card, Land) runs git against the path it
|
|
42
|
+
// names. On 2026-09-17 a seeded End-as-card cut a worktree and wrote a bundle
|
|
43
|
+
// into the real recruiting-tool repository on this machine. A path that
|
|
44
|
+
// does not exist 409s instead.
|
|
45
|
+
const P = (...rest) => w('C:', 'Projects-seed', ...rest)
|
|
41
46
|
// the shape that matters is LENGTH: a real worktree path under a temp dir runs
|
|
42
47
|
// past 90 characters, which is what buried the prompt in the rejected design
|
|
43
48
|
const LONG = w('C:', 'Users', 'operator', 'AppData', 'Local', 'Temp', 'agent',
|
|
44
49
|
'C--Projects-baton--baton-worktrees-s-20260915-000000-claude-0000',
|
|
45
50
|
'00000000-0000-0000-0000-000000000000')
|
|
46
51
|
|
|
52
|
+
// `model` is the alias the leg resolved to, `waiting` is the Notification shape
|
|
53
|
+
// (a human is being waited on), `dirty` and `ahead` are what the git poll
|
|
54
|
+
// writes. One live row carries a permission prompt and another an idle prompt,
|
|
55
|
+
// because the board's whole attention story is those two rows; one live row
|
|
56
|
+
// carries no model at all, so the register's agent-alone path is on screen too.
|
|
47
57
|
const rows = [
|
|
48
58
|
{ id: 's-20260915-0049-claude-2fbf', agent: 'claude', status: 'warning', started: 4 * hour + 24 * min,
|
|
49
|
-
cwd: P('baton'), repo: P('baton'), branch: 'main',
|
|
59
|
+
cwd: P('baton'), repo: P('baton'), branch: 'main', model: 'fable', dirty: 3, ahead: 2,
|
|
60
|
+
waiting: { type: 'permission_prompt', message: 'Bash(git push origin HEAD)', since: ago(40_000) },
|
|
50
61
|
task: 'ultracode run a tournament of ideas to drastically redesign and improve the UI for this project. I do not like the current setup.' },
|
|
51
|
-
{ id: 's-20260915-0213-claude-95d3', agent: 'claude', status: 'running', started: 2 * hour + 46 * min,
|
|
52
|
-
cwd: w('C:', 'documents'), repo: null, branch: null, task: null },
|
|
62
|
+
{ id: 's-20260915-0213-claude-95d3', agent: 'claude', status: 'running', started: 2 * hour + 46 * min, quiet: 4 * min,
|
|
63
|
+
cwd: w('C:', 'documents'), repo: null, branch: null, model: 'sonnet', task: null },
|
|
53
64
|
{ id: 's-20260915-0257-claude-88e8', agent: 'claude', status: 'warning', started: 2 * hour + 2 * min,
|
|
54
|
-
cwd: P('recruiting-tool'), repo: P('recruiting-tool'), branch: 'main',
|
|
65
|
+
cwd: P('recruiting-tool'), repo: P('recruiting-tool'), branch: 'main', model: 'opus', dirty: 1,
|
|
66
|
+
waiting: { type: 'idle_prompt', message: 'Claude is waiting for your input', since: ago(11 * min) },
|
|
55
67
|
task: `<image name=screenshot.png path=${w('C:', 'Users', 'operator', 'Desktop', 'shot.png')}> [Image #1] sourcing candidates is a huge pain for my friend who recruits on LinkedIn, how can we help` },
|
|
56
68
|
{ id: 's-20260915-0455-claude-8e8a', agent: 'claude', status: 'running', started: 5 * min,
|
|
57
69
|
cwd: w('C:', 'Projects'), repo: null, branch: null, task: '/handoff-load verifier-reach-contracts' },
|
|
58
70
|
{ id: 's-20260914-2211-claude-4c10', agent: 'claude', status: 'lost', started: 6 * hour, ended: 3 * hour,
|
|
59
|
-
cwd: w(LONG, 'discovery-loop'), repo: P('discovery-loop'), branch: 'main', task: 'run the nightly discovery loop' },
|
|
71
|
+
cwd: w(LONG, 'discovery-loop'), repo: P('discovery-loop'), branch: 'main', model: 'fable', task: 'run the nightly discovery loop' },
|
|
60
72
|
{ id: 's-20260914-2010-codex-7b31', agent: 'codex', status: 'lost', started: 7 * hour, ended: 4 * hour,
|
|
61
|
-
cwd: w(LONG, 'costclaw'), repo: P('costclaw'), branch: 'main', task: 'fix the per-model price table for Opus' },
|
|
73
|
+
cwd: w(LONG, 'costclaw'), repo: P('costclaw'), branch: 'main', model: 'gpt-5.6-sol', task: 'fix the per-model price table for Opus' },
|
|
62
74
|
{ id: 's-20260914-1802-agy-d9f2', agent: 'agy', status: 'lost', started: 9 * hour, ended: 6 * hour,
|
|
63
75
|
cwd: w('C:', 'Projects'), repo: null, branch: null, task: 'summarise yesterday' },
|
|
64
76
|
{ id: 's-20260914-1533-claude-a04b', agent: 'claude', status: 'ended', started: 11 * hour, ended: 8 * hour,
|
|
@@ -67,22 +79,79 @@ const rows = [
|
|
|
67
79
|
cwd: P('costclaw'), repo: P('costclaw'), branch: 'main', task: 'monthly rollup excludes refunded calls' },
|
|
68
80
|
]
|
|
69
81
|
|
|
82
|
+
// distinct from files_touched below: the board prints basenames, and two
|
|
83
|
+
// different paths ending in the same name read as one file listed twice
|
|
84
|
+
const DIRTY = ['server.mjs', 'attach.mjs', 'usage.mjs']
|
|
85
|
+
// The ladder a terminal started with (docs/redesign-2026-09-17.md B.3): the
|
|
86
|
+
// claude models first, because a same-login switch keeps the conversation, then
|
|
87
|
+
// the other CLIs. Written onto the record the way src/attach.mjs writes it, so
|
|
88
|
+
// the picker, the per-terminal ladder editor and `Back to fable` all have the
|
|
89
|
+
// shape they render from. `orderFromLadder` of this list is the default order,
|
|
90
|
+
// which is what `ladderFor` insists on.
|
|
91
|
+
const SEED_LADDER = [
|
|
92
|
+
{ agent: 'claude', account: 'default', model: 'fable', when: 'always', cost: 'plan' },
|
|
93
|
+
{ agent: 'claude', account: 'default', model: 'opus', when: 'always', cost: 'plan' },
|
|
94
|
+
{ agent: 'claude', account: 'default', model: 'sonnet', when: 'always', cost: 'plan' },
|
|
95
|
+
{ agent: 'codex', account: 'default', model: null, when: 'always', cost: 'plan' },
|
|
96
|
+
{ agent: 'agy', account: 'default', model: null, when: 'walled-only', cost: 'free' },
|
|
97
|
+
]
|
|
70
98
|
for (const r of rows) {
|
|
71
|
-
createSession({
|
|
99
|
+
createSession({
|
|
100
|
+
id: r.id, agent: r.agent, cwd: r.cwd, repo: r.repo, branch: r.branch, argv: [r.agent],
|
|
101
|
+
runner_pid: r.ended ? 1 : livePid(), model: r.model ?? null,
|
|
102
|
+
// what a terminal started by this Leg carries: without it the board offers
|
|
103
|
+
// the ladder editor read-only and the model rail stays text
|
|
104
|
+
runtimeCapabilities: r.ended ? [] : [HANDOFF_ORDER_CAPABILITY],
|
|
105
|
+
// which CLIs were on the PATH when this terminal started. Without it the
|
|
106
|
+
// server cannot say which rung is eligible and the row prints the caveat
|
|
107
|
+
// for an older record instead of a destination.
|
|
108
|
+
installed: r.ended ? null : { claude: true, codex: true, agy: true, grok: false },
|
|
109
|
+
})
|
|
72
110
|
updateSession(r.id, {
|
|
111
|
+
...(r.ended ? {} : { handoff_ladder: SEED_LADDER.map((x) => ({ ...x })) }),
|
|
112
|
+
// the claude conversation id: a downshift on the same login resumes it,
|
|
113
|
+
// which is the only reason a picker row can say it keeps the conversation
|
|
114
|
+
...(r.ended || r.agent !== 'claude' ? {} : { agent_session_id: `conv-${r.id.slice(-4)}` }),
|
|
73
115
|
status: r.status,
|
|
74
116
|
started_at: ago(r.started),
|
|
75
|
-
last_activity: ago(r.ended ?? 0),
|
|
117
|
+
last_activity: ago(r.ended ?? r.quiet ?? 0),
|
|
76
118
|
ended_at: r.ended ? ago(r.ended) : null,
|
|
77
119
|
task: r.task,
|
|
78
120
|
turns: r.task ? 12 : 0,
|
|
121
|
+
waiting: r.waiting ?? null,
|
|
122
|
+
// `ahead` is written by the git poll in src/attach.mjs; an older record has
|
|
123
|
+
// no such key, and the row prints the token only when it is there
|
|
124
|
+
...(r.ahead ? { ahead: r.ahead } : {}),
|
|
125
|
+
files_dirty: r.dirty && r.repo ? DIRTY.slice(0, r.dirty).map((f) => w(r.cwd, 'src', f)) : [],
|
|
79
126
|
files_touched: r.repo ? [w(r.cwd, 'src', 'board', 'board.css'), w(r.cwd, 'src', 'board', 'sessions.js')] : [],
|
|
80
127
|
})
|
|
81
128
|
}
|
|
82
129
|
|
|
130
|
+
// The two windows are what an older Leg record carries; `buckets` is what the
|
|
131
|
+
// live `limits[]` array carries, and the shape here is the one verified off
|
|
132
|
+
// the endpoint on 2026-09-17: a session bucket, an account-wide weekly, and a
|
|
133
|
+
// model-scoped weekly the endpoint itself marks active. The board prints the
|
|
134
|
+
// active one, which is the whole point: 95% is the login, 63% is what will
|
|
135
|
+
// actually stop the work.
|
|
136
|
+
const fiveHourReset = Math.floor((Date.now() + 3 * hour) / 1000)
|
|
137
|
+
const weekReset = Math.floor((Date.now() + 40 * hour) / 1000)
|
|
83
138
|
recordUsage('claude', 'default',
|
|
84
|
-
{ five_hour: { pct: 38, resets_at:
|
|
85
|
-
seven_day: { pct: 95, resets_at:
|
|
139
|
+
{ five_hour: { pct: 38, resets_at: fiveHourReset },
|
|
140
|
+
seven_day: { pct: 95, resets_at: weekReset },
|
|
141
|
+
buckets: [
|
|
142
|
+
{ kind: 'session', group: 'session', model: null, percent: 38, resets_at: fiveHourReset, is_active: false, severity: 'normal' },
|
|
143
|
+
{ kind: 'weekly_all', group: 'weekly', model: null, percent: 95, resets_at: weekReset, is_active: false, severity: 'normal' },
|
|
144
|
+
{ kind: 'weekly_scoped', group: 'weekly', model: 'fable', percent: 63, resets_at: weekReset, is_active: true, severity: 'normal' },
|
|
145
|
+
// a second model family with a reading of its own. The live payload on
|
|
146
|
+
// 2026-09-17 had `seven_day_opus` null, so this is the shape the endpoint
|
|
147
|
+
// publishes once opus has been used, not a figure anyone measured; it is
|
|
148
|
+
// here because the model rail and the per-model capacity phrase have
|
|
149
|
+
// nothing to draw with one bucket.
|
|
150
|
+
{ kind: 'weekly_scoped', group: 'weekly', model: 'opus', percent: 12, resets_at: weekReset, is_active: false, severity: 'normal' },
|
|
151
|
+
],
|
|
152
|
+
// the live payload on 2026-09-17: credits are off and cannot be turned on
|
|
153
|
+
// from the API, which is what the ladder editor says under may_spend
|
|
154
|
+
extra_usage: { enabled: false, reason: 'out_of_credits', can_toggle: false, limit_minor: 12500, used_minor: 0 } },
|
|
86
155
|
'statusline', { observed_at: ago(2 * hour + 13 * min) })
|
|
87
156
|
markLimited('codex', 'default', { resets_at: Math.floor((Date.now() + 29 * hour) / 1000), reason: 'limit', source: 'hook' })
|
|
88
157
|
// agy publishes no usage figure, ever. Left unwritten on purpose.
|
package/src/accounts.mjs
CHANGED
|
@@ -43,7 +43,12 @@ export const LAYOUT = {
|
|
|
43
43
|
export function accountsFile() { return join(home(), 'accounts.json') }
|
|
44
44
|
export function accountDir(agent, name) { return join(home(), 'accounts', agent, name) }
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
// A name is a directory segment, never a path: it is joined into the CLI's
|
|
47
|
+
// config dir and into the usage record's file name. Exported so the one rule
|
|
48
|
+
// has one owner (src/preferences.mjs validates a rung's account with it, and
|
|
49
|
+
// src/usage.mjs refuses to write a record under anything else).
|
|
50
|
+
export const ACCOUNT_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/i
|
|
51
|
+
const NAME_RE = ACCOUNT_NAME_RE
|
|
47
52
|
|
|
48
53
|
export function readAccounts() {
|
|
49
54
|
const base = { claude: ['default'], codex: ['default'], agy: ['default'], grok: ['default'] }
|