@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +213 -0
  2. package/README.md +95 -65
  3. package/bin/leg.mjs +123 -14
  4. package/docs/DECISIONS.md +18 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +68 -0
  8. package/docs/ROADMAP-v2.md +50 -5
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/board-guide.md +529 -96
  11. package/docs/cli-contracts.md +241 -5
  12. package/docs/concepts.md +167 -19
  13. package/docs/configuration.md +65 -1
  14. package/docs/faq.md +21 -5
  15. package/docs/getting-started.md +15 -11
  16. package/docs/redesign-2026-09-17.md +477 -0
  17. package/docs/screenshots/background-1280.png +0 -0
  18. package/docs/screenshots/board-400px.png +0 -0
  19. package/docs/screenshots/board-details-open.png +0 -0
  20. package/docs/screenshots/board-drawer.png +0 -0
  21. package/docs/screenshots/board-handoff.png +0 -0
  22. package/docs/screenshots/board-running.png +0 -0
  23. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  24. package/docs/screenshots/floor.png +0 -0
  25. package/docs/screenshots/new-card-dialog.png +0 -0
  26. package/docs/screenshots/settings-ladder-1280.png +0 -0
  27. package/docs/screenshots/terminals-1280.png +0 -0
  28. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  29. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  31. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/verified.json +1 -1
  35. package/package.json +3 -2
  36. package/scripts/board-jump-probe.mjs +335 -0
  37. package/scripts/seed-fake-cards.mjs +59 -6
  38. package/scripts/seed-wes-board.mjs +81 -12
  39. package/src/accounts.mjs +6 -1
  40. package/src/attach.mjs +378 -93
  41. package/src/audit.mjs +1 -1
  42. package/src/board/board.css +203 -11
  43. package/src/board/board.js +664 -200
  44. package/src/board/entry.js +343 -0
  45. package/src/board/floor.html +51 -39
  46. package/src/board/floor.js +585 -73
  47. package/src/board/index.html +122 -45
  48. package/src/board/sessions.js +1569 -141
  49. package/src/board/strip.js +163 -0
  50. package/src/buckets.mjs +101 -0
  51. package/src/cards.mjs +9 -1
  52. package/src/chain.mjs +13 -0
  53. package/src/hook.mjs +7 -1
  54. package/src/ledger.mjs +10 -2
  55. package/src/models.mjs +265 -0
  56. package/src/orchestrator.mjs +13 -4
  57. package/src/preferences.mjs +278 -5
  58. package/src/scheduler.mjs +24 -1
  59. package/src/server.mjs +625 -78
  60. package/src/sessions.mjs +17 -1
  61. package/src/taps/claude-usage.mjs +107 -3
  62. package/src/taps/claude.mjs +144 -5
  63. package/src/taps/codex.mjs +23 -3
  64. package/src/usage-poll.mjs +260 -0
  65. 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
- const P = (...rest) => w('C:', 'Projects', ...rest)
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({ id: r.id, agent: r.agent, cwd: r.cwd, repo: r.repo, branch: r.branch, argv: [r.agent], runner_pid: r.ended ? 1 : livePid() })
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: Math.floor((Date.now() + 3 * hour) / 1000) },
85
- seven_day: { pct: 95, resets_at: Math.floor((Date.now() + 40 * hour) / 1000) } },
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
- const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/i
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'] }