@ucsandman/legcli 0.10.0 → 0.12.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 (70) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +158 -67
  3. package/bin/leg.mjs +168 -18
  4. package/docs/DECISIONS.md +10 -0
  5. package/docs/DEMO.md +20 -14
  6. package/docs/DEVIATIONS.md +1 -0
  7. package/docs/ERRORS.md +94 -0
  8. package/docs/ROADMAP-v2.md +69 -11
  9. package/docs/VOCABULARY.md +27 -0
  10. package/docs/adapters.md +93 -11
  11. package/docs/board-guide.md +401 -66
  12. package/docs/cli-contracts.md +235 -22
  13. package/docs/concepts.md +167 -19
  14. package/docs/configuration.md +113 -5
  15. package/docs/faq.md +21 -5
  16. package/docs/getting-started.md +15 -11
  17. package/docs/redesign-2026-09-17.md +477 -0
  18. package/docs/screenshots/background-1280.png +0 -0
  19. package/docs/screenshots/board-400px.png +0 -0
  20. package/docs/screenshots/board-details-open.png +0 -0
  21. package/docs/screenshots/board-drawer.png +0 -0
  22. package/docs/screenshots/board-handoff.png +0 -0
  23. package/docs/screenshots/board-running.png +0 -0
  24. package/docs/screenshots/capacity-drawer-1280.png +0 -0
  25. package/docs/screenshots/settings-ladder-1280.png +0 -0
  26. package/docs/screenshots/terminals-1280.png +0 -0
  27. package/fixtures/limits/claude/claude-fable-limit.json +11 -0
  28. package/fixtures/limits/claude/claude-model-limit.json +1 -1
  29. package/fixtures/limits/claude/claude-session-limit.json +1 -1
  30. package/fixtures/limits/claude/claude-weekly-limit.json +1 -1
  31. package/fixtures/limits/grok/grok-balance-exhausted.json +11 -0
  32. package/fixtures/live/claude/resume-model-probe.json +20 -0
  33. package/fixtures/live/claude/usage-oauth.json +87 -0
  34. package/fixtures/live/grok/cmd.txt +1 -1
  35. package/fixtures/live/grok/parsed.json +6 -3
  36. package/fixtures/live/grok/run.json +22 -10
  37. package/fixtures/verified.json +8 -1
  38. package/package.json +3 -2
  39. package/scripts/build-docs-site.mjs +4 -4
  40. package/scripts/probe.mjs +2 -1
  41. package/scripts/seed-fake-cards.mjs +59 -6
  42. package/scripts/seed-wes-board.mjs +81 -12
  43. package/src/accounts.mjs +6 -1
  44. package/src/adapters/cli.mjs +130 -0
  45. package/src/adapters/custom.mjs +271 -0
  46. package/src/adapters/grok.mjs +51 -10
  47. package/src/adapters/index.mjs +34 -7
  48. package/src/attach.mjs +350 -42
  49. package/src/audit.mjs +118 -0
  50. package/src/board/audit.js +123 -0
  51. package/src/board/board.css +134 -9
  52. package/src/board/board.js +482 -106
  53. package/src/board/index.html +89 -7
  54. package/src/board/sessions.js +1371 -113
  55. package/src/buckets.mjs +101 -0
  56. package/src/cards.mjs +9 -1
  57. package/src/chain.mjs +13 -0
  58. package/src/hook.mjs +7 -1
  59. package/src/ledger.mjs +10 -2
  60. package/src/orchestrator.mjs +13 -4
  61. package/src/preferences.mjs +214 -5
  62. package/src/scheduler.mjs +24 -1
  63. package/src/server.mjs +615 -50
  64. package/src/sessions.mjs +17 -1
  65. package/src/share.mjs +66 -6
  66. package/src/taps/claude-usage.mjs +91 -2
  67. package/src/taps/claude.mjs +144 -5
  68. package/src/taps/codex.mjs +23 -3
  69. package/src/taps/grok.mjs +4 -0
  70. package/src/usage.mjs +424 -13
@@ -0,0 +1,87 @@
1
+ {
2
+ "agent": "claude",
3
+ "signal": "usage",
4
+ "source": "observed-live",
5
+ "captured_at": "2026-09-17T20:16:00Z",
6
+ "endpoint": "GET https://api.anthropic.com/api/oauth/usage",
7
+ "status": 200,
8
+ "produced_by": "docs/redesign-2026-09-17.md \"What I verified myself before writing\" (2026-09-17 20:16Z, HTTP 200, 23 keys) and the tournament candidate transcript that printed the raw limits[] rows (kind, group, percent, severity, resets_at as ISO, scope{model{id,display_name},surface}, is_active), fetched through src/taps/claude-usage.mjs fetchClaudeUsage",
9
+ "scrubbed": "no ids, no tokens, no account identifiers: only the limit rows, the two legacy windows and the spend block Leg reads",
10
+ "verified": [
11
+ "limits[] holds session 29%, weekly_all 47% and weekly_scoped 63% with scope.model.display_name \"Fable\" and is_active true",
12
+ "seven_day_opus and seven_day_sonnet are null",
13
+ "extra_usage.is_enabled false, disabled_reason \"out_of_credits\", monthly_limit 12500 (minor units)",
14
+ "spend.can_toggle false, can_purchase_credits false",
15
+ "seven_day_breakdown: Claude Code 100, Chats 0, Cowork 0"
16
+ ],
17
+ "payload": {
18
+ "five_hour": {
19
+ "utilization": 29,
20
+ "resets_at": "2026-09-17T20:30:00Z"
21
+ },
22
+ "seven_day": {
23
+ "utilization": 47,
24
+ "resets_at": "2026-09-23T19:00:00Z"
25
+ },
26
+ "seven_day_opus": null,
27
+ "seven_day_sonnet": null,
28
+ "limits": [
29
+ {
30
+ "kind": "session",
31
+ "group": "session",
32
+ "percent": 29,
33
+ "severity": "normal",
34
+ "resets_at": "2026-09-17T20:30:00Z",
35
+ "scope": null,
36
+ "is_active": false
37
+ },
38
+ {
39
+ "kind": "weekly_all",
40
+ "group": "weekly",
41
+ "percent": 47,
42
+ "severity": "normal",
43
+ "resets_at": "2026-09-23T19:00:00Z",
44
+ "scope": null,
45
+ "is_active": false
46
+ },
47
+ {
48
+ "kind": "weekly_scoped",
49
+ "group": "weekly",
50
+ "percent": 63,
51
+ "severity": "normal",
52
+ "resets_at": "2026-09-23T19:00:00Z",
53
+ "scope": {
54
+ "model": {
55
+ "id": null,
56
+ "display_name": "Fable"
57
+ },
58
+ "surface": null
59
+ },
60
+ "is_active": true
61
+ }
62
+ ],
63
+ "seven_day_breakdown": [
64
+ {
65
+ "name": "Claude Code",
66
+ "percentage": 100
67
+ },
68
+ {
69
+ "name": "Chats",
70
+ "percentage": 0
71
+ },
72
+ {
73
+ "name": "Cowork",
74
+ "percentage": 0
75
+ }
76
+ ],
77
+ "extra_usage": {
78
+ "is_enabled": false,
79
+ "disabled_reason": "out_of_credits",
80
+ "monthly_limit": 12500
81
+ },
82
+ "spend": {
83
+ "can_toggle": false,
84
+ "can_purchase_credits": false
85
+ }
86
+ }
87
+ }
@@ -1 +1 @@
1
- ~\.grok\bin\grok.exe -p "Create a file named hello-grok.txt in the current directory containing exactly the word hi. Then create the directory .baton if it is missing and write the file .baton/DONE containing the single line: done. Do nothing else. Do not ask questions.\n" --output-format json --permission-mode acceptEdits
1
+ ~\.grok\bin\grok.exe --prompt-file ~\.baton\cards\card-20260917-1748-probe-grok\runs\1\prompt.txt --output-format json --permission-mode acceptEdits --cwd ~\AppData\Local\Temp\claude\C--Projects-leg\9c2d72c5-4654-46ea-a183-81767ec30af5\scratchpad\probe-repo3
@@ -1,5 +1,8 @@
1
1
  {
2
- "session_id": "01a08de5-c64c-7a31-ac58-1c11c2dd7332",
3
- "last_message": null,
4
- "stop_reason": null
2
+ "session_id": null,
3
+ "last_message": "Internal error: {\n \"message\": \"API error (status 402 Payment Required): Grok Build usage balance exhausted\",\n \"http_status\": 402\n}",
4
+ "stop_reason": "error",
5
+ "subtype": null,
6
+ "is_error": true,
7
+ "num_turns": null
5
8
  }
@@ -1,18 +1,30 @@
1
1
  {
2
- "card_id": "card-20260911-0036-probe-grok",
2
+ "card_id": "card-20260917-1748-probe-grok",
3
3
  "run": 1,
4
4
  "adapter": "grok",
5
5
  "mode": "acceptEdits",
6
6
  "max_turns": null,
7
+ "model": null,
7
8
  "resume": null,
8
- "cwd": "~\\AppData\\Local\\Temp\\claude\\C--Projects\\49952fac-5ff4-486c-8395-9973cebdfdcb\\scratchpad\\toy-probe",
9
+ "cwd": "~\\AppData\\Local\\Temp\\claude\\C--Projects-leg\\9c2d72c5-4654-46ea-a183-81767ec30af5\\scratchpad\\probe-repo3",
9
10
  "status": "exited",
10
- "supervisor_pid": 70732,
11
- "agent_pid": 12580,
12
- "started_at": "2026-09-11T00:36:48.183Z",
13
- "outcome": null,
14
- "updated_at": "2026-09-11T00:37:45.845Z",
15
- "exit_code": 0,
16
- "session_id": "01a08de5-c64c-7a31-ac58-1c11c2dd7332",
17
- "ended_at": "2026-09-11T00:37:45.380Z"
11
+ "supervisor_pid": 47116,
12
+ "agent_pid": 48864,
13
+ "driver_pid": null,
14
+ "started_at": "2026-09-17T17:48:07.055Z",
15
+ "outcome": "limit",
16
+ "updated_at": "2026-09-17T17:48:18.619Z",
17
+ "exit_code": 1,
18
+ "session_id": null,
19
+ "ended_at": "2026-09-17T17:48:18.538Z",
20
+ "signal": "grok-balance-exhausted",
21
+ "handoff": true,
22
+ "reason": "observed-live limit signal: grok-balance-exhausted",
23
+ "done_marker": false,
24
+ "diff": {
25
+ "changed": true,
26
+ "files": 1,
27
+ "head_at_start": "f8d264942a080ca398751d0d481d612f73ef3d62",
28
+ "head": "f8d264942a080ca398751d0d481d612f73ef3d62"
29
+ }
18
30
  }
@@ -2,7 +2,7 @@
2
2
  "_comment": "The single source of truth for every dated, numbered claim Leg makes in public. scripts/check-claims.mjs asserts that each surface listed here states exactly these values, and --strict additionally compares the pinned CLI versions against what those CLIs currently publish. Re-verify on a real machine, then edit this file and every surface in the same commit.",
3
3
  "verifiedOn": "2026-09-11",
4
4
  "verifiedOnLong": "11 September 2026",
5
- "tests": 473,
5
+ "tests": 830,
6
6
  "clis": {
7
7
  "claude": {
8
8
  "label": "Claude Code",
@@ -22,6 +22,13 @@
22
22
  "npm": null,
23
23
  "probe": "agy --version",
24
24
  "note": "Antigravity CLI is a closed Go binary with no npm package, so drift is only visible from a machine that has it installed."
25
+ },
26
+ "grok": {
27
+ "label": "grok",
28
+ "version": "1.0.34",
29
+ "npm": null,
30
+ "probe": "grok --version",
31
+ "note": "Grok Build ships as a downloaded binary, not an npm package. Probed on 2026-09-17, separately from the 2026-09-11 sweep above: the flags came from grok --help and the headless envelope from the shipped exe, and scripts/probe.mjs reached the account and returned a real 402 wall. The success path of a grok leg is still unprobed for want of balance on the account."
25
32
  }
26
33
  },
27
34
  "surfaces": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ucsandman/legcli",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Usage-limit monitor and automatic handoff for Claude Code, Codex, agy and Grok. Type leg claude|codex|agy|grok and get the same interactive agent with a board alongside, auto-approve on by default, usage tracking per agent and account, a live context handoff bundle, and at the limit the next agent continuing in the same terminal. $79 once, 30-day money-back guarantee.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -67,6 +67,7 @@
67
67
  "@eslint/js": "^9",
68
68
  "eslint": "^9",
69
69
  "globals": "^16",
70
- "marked": "^18.0.13"
70
+ "marked": "^18.0.13",
71
+ "playwright": "^1.63.0"
71
72
  }
72
73
  }
@@ -76,15 +76,15 @@ const PAGES = [
76
76
  slug: 'cli-contracts',
77
77
  nav: 'What it reads',
78
78
  source: 'docs/cli-contracts.md',
79
- title: 'What Leg reads from Claude Code, Codex and agy',
80
- description: 'The exact usage endpoints, hooks, log lines and limit strings Leg reads from Claude Code, Codex and agy, each cited to its source.',
79
+ title: 'What Leg reads from Claude Code, Codex, agy and Grok',
80
+ description: 'The exact usage endpoints, hooks, log lines and limit strings Leg reads from Claude Code, Codex, agy and Grok, each cited to its source.',
81
81
  },
82
82
  {
83
83
  slug: 'adapters',
84
84
  nav: 'Adapters',
85
85
  source: 'docs/adapters.md',
86
- title: 'Leg agent adapters for Claude Code, Codex and agy',
87
- description: 'How each agent adapter spawns its CLI, what it strips from the environment, and how to tell whether a tap is live or docs-only.',
86
+ title: 'Leg agent adapters for Claude Code, Codex, agy and Grok',
87
+ description: 'How each agent adapter spawns its CLI, what it strips from the environment, how to tell whether a tap is live or docs-only, and how to add any other CLI from a JSON spec.',
88
88
  },
89
89
  {
90
90
  slug: 'faq',
package/scripts/probe.mjs CHANGED
@@ -79,7 +79,8 @@ mkdirSync(out, { recursive: true })
79
79
  for (const f of ['out.log', 'err.log', 'run.json', 'supervisor.log', 'last.md']) {
80
80
  if (existsSync(join(runDir, f))) writeFileSync(join(out, f), scrubPaths(readFileSync(join(runDir, f), 'utf8')))
81
81
  }
82
- const spec = adapter.argv({ mode, cwd: repo, prompt: PROMPT, runDir, killMs: 5400000 })
82
+ // the same opts the runner passed, so cmd.txt is the command that really ran
83
+ const spec = adapter.argv({ mode, cwd: repo, prompt: PROMPT, promptFile: join(runDir, 'prompt.txt'), runDir, killMs: 5400000 })
83
84
  writeFileSync(join(out, 'cmd.txt'), scrubPaths([spec.bin, ...spec.args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(' ')) + '\n')
84
85
  const textOf = (f) => (existsSync(join(runDir, f)) ? readFileSync(join(runDir, f), 'utf8') : '')
85
86
  const authSource = /another auth source/i.test(textOf('err.log') + textOf('out.log'))
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // seed-fake-cards — fills a LEG_HOME with N backlog cards (fake chain, build
3
3
  // pipeline) through the same path the CLI uses (src/cards.mjs createCard), for
4
- // perf tests and manual board load-testing.
5
- // node scripts/seed-fake-cards.mjs --home <dir> --repo <git repo path> --count 50
4
+ // perf tests and manual board load-testing. --finished and --live add a
5
+ // realistic mix of finished and live cards, driven through humanAction
6
+ // (src/orchestrator.mjs) rather than by hand-editing card.json.
7
+ // node scripts/seed-fake-cards.mjs --home <dir> --repo <git repo path> --count 50 --finished 10 --live 3
6
8
  import { resolve } from 'node:path'
7
9
 
8
10
  function parseArgs(argv) {
@@ -15,21 +17,72 @@ function parseArgs(argv) {
15
17
  }
16
18
 
17
19
  const args = parseArgs(process.argv.slice(2))
18
- if (!args.home) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50]\n'); process.exit(2) }
19
- if (!args.repo) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50]\n'); process.exit(2) }
20
+ if (!args.home) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50] [--finished 0] [--live 0]\n'); process.exit(2) }
21
+ if (!args.repo) { process.stderr.write('usage: seed-fake-cards.mjs --home <dir> --repo <git repo path> [--count 50] [--finished 0] [--live 0]\n'); process.exit(2) }
20
22
  const count = parseInt(args.count ?? '50', 10) || 50
23
+ const finishedCount = parseInt(args.finished ?? '0', 10) || 0
24
+ const liveCount = parseInt(args.live ?? '0', 10) || 0
21
25
 
22
26
  // LEG_HOME must be set before store.mjs (and the ledger.mjs it imports) load.
23
27
  process.env.LEG_HOME = resolve(args.home)
24
28
  process.env.BATON_HOME = process.env.LEG_HOME
25
29
  const { createCard } = await import('../src/cards.mjs')
30
+ const { humanAction } = await import('../src/orchestrator.mjs')
26
31
 
27
32
  const TITLES = ['Add retry to fetch', 'Fix flaky lease test', 'Refactor log tail', 'Wire up SSE health', 'Trim README', 'Bump adapter timeout', 'Dedupe blocked events', 'Guard null station', 'Speed up card list', 'Polish error copy']
33
+ const actor = { type: 'human', id: 'seed' }
28
34
 
29
35
  let created = 0
30
36
  for (let i = 0; i < count; i++) {
31
37
  const title = `${TITLES[i % TITLES.length]} #${i + 1}`
32
- await createCard({ repo: args.repo, task: title, title, chain: 'fake' }, { type: 'human', id: 'seed' })
38
+ await createCard({ repo: args.repo, task: title, title, chain: 'fake' }, actor)
33
39
  created += 1
34
40
  }
35
- process.stdout.write(`seeded ${created} card(s) into ${process.env.BATON_HOME}\n`)
41
+
42
+ // Finished cards: queue, start, then drive a leg_result straight to done or
43
+ // failed. `chain: 'fake'` has no fallback, so a `failed` outcome exhausts the
44
+ // chain immediately (src/chain.mjs leg_result) instead of handing off.
45
+ let done = 0
46
+ let failed = 0
47
+ for (let i = 0; i < finishedCount; i++) {
48
+ const wantDone = i % 2 === 0
49
+ const title = `Seeded finished card #${i + 1}`
50
+ const card = await createCard({ repo: args.repo, task: title, title, chain: 'fake', queue: true }, actor)
51
+ humanAction(card.card_id, 'start', {}, actor)
52
+ humanAction(card.card_id, 'leg_result', { outcome: wantDone ? 'completed' : 'failed', adapter: 'fake' }, actor)
53
+ if (wantDone) done += 1
54
+ else failed += 1
55
+ }
56
+
57
+ // Live cards: cycle through queued, running, needs_approval and paused, each
58
+ // reached through the same transitions a real run takes. `fake=sleep` keeps a
59
+ // running (or queued, on a board that is actually driving its scheduler) card
60
+ // from finishing out from under the board the moment it is looked at.
61
+ const LIVE_KINDS = ['queued', 'running', 'needs_approval', 'paused']
62
+ const liveSeeded = { queued: 0, running: 0, needs_approval: 0, paused: 0 }
63
+ for (let i = 0; i < liveCount; i++) {
64
+ const kind = LIVE_KINDS[i % LIVE_KINDS.length]
65
+ const title = `Seeded live card #${i + 1}`
66
+ if (kind === 'needs_approval') {
67
+ // a second, approval-gated leg to hand off into
68
+ const card = await createCard({ repo: args.repo, task: title, title, chain: 'fake-claude,fake-codex', approve: 'fake-codex', queue: true }, actor)
69
+ humanAction(card.card_id, 'start', {}, actor)
70
+ humanAction(card.card_id, 'leg_result', { outcome: 'incomplete', adapter: 'fake-claude' }, actor)
71
+ humanAction(card.card_id, 'bundle_written', {}, actor)
72
+ } else {
73
+ const card = await createCard({ repo: args.repo, task: title, title, chain: 'fake', fake_mode: 'fake=sleep', queue: true }, actor)
74
+ if (kind === 'running' || kind === 'paused') humanAction(card.card_id, 'start', {}, actor)
75
+ if (kind === 'paused') humanAction(card.card_id, 'pause', {}, actor)
76
+ }
77
+ liveSeeded[kind] += 1
78
+ }
79
+
80
+ // test/perf.test.mjs matches "seeded N card(s)" as a substring, so the
81
+ // original --count-only sentence stays intact; the finished/live counts are
82
+ // appended rather than folded into it.
83
+ const liveSummary = LIVE_KINDS.map((k) => `${liveSeeded[k]} ${k}`).join(', ')
84
+ const extra = []
85
+ if (finishedCount) extra.push(`${finishedCount} finished (${done} done, ${failed} failed)`)
86
+ if (liveCount) extra.push(`${liveCount} live (${liveSummary})`)
87
+ const suffix = extra.length ? `, ${extra.join(', ')}` : ''
88
+ process.stdout.write(`seeded ${created} card(s)${suffix} into ${process.env.BATON_HOME}\n`)
@@ -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'] }
@@ -0,0 +1,130 @@
1
+ // `leg adapter …` — the custom-adapter surface. A spec is a JSON file in
2
+ // $LEG_HOME/adapters/<name>.json; these verbs write, validate and explain it
3
+ // so a bad spec is caught here rather than when a card tries to spawn.
4
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
5
+ import { resolve } from 'node:path'
6
+ import { writeJsonAtomic } from '../fsx.mjs'
7
+ import { BUILTIN_NAMES, customSpecs, get as getAdapter } from './index.mjs'
8
+ import { adaptersDir, specPath, validateSpec, makeAdapter, SpecError, TEMPLATE, PLACEHOLDERS } from './custom.mjs'
9
+
10
+ const USAGE = `usage:
11
+ leg adapter list every adapter this machine has, built-in and custom
12
+ leg adapter show <name> the spec, as JSON
13
+ leg adapter check <name> validate it and print the command line a leg would run
14
+ leg adapter add <file.json> [--name n] validate a spec and install it
15
+ leg adapter rm <name> remove a custom spec
16
+ leg adapter template [--name n] print a starter spec to fill in`
17
+
18
+ function quote(a) { return /\s/.test(a) ? JSON.stringify(a) : a }
19
+
20
+ export async function adapterCommand(verb, args, { out, die }) {
21
+ const first = args._?.[0]
22
+
23
+ if (!verb || verb === 'help' || verb === '--help') { out(USAGE); return 0 }
24
+
25
+ if (verb === 'list') {
26
+ const specs = customSpecs()
27
+ if (args.json) {
28
+ out(JSON.stringify({
29
+ builtin: BUILTIN_NAMES,
30
+ custom: specs.map((s) => ({ name: s.name, file: s.file, ok: Boolean(s.adapter), error: s.error, bin: s.spec?.bin ?? null })),
31
+ }, null, 2))
32
+ return specs.some((s) => s.error) ? 1 : 0
33
+ }
34
+ out('built-in:')
35
+ for (const n of BUILTIN_NAMES) out(` ${n}`)
36
+ out(`custom (${adaptersDir()}):`)
37
+ if (!specs.length) out(' none yet — leg adapter template > my-agent.json, then leg adapter add my-agent.json')
38
+ for (const s of specs) {
39
+ out(s.adapter ? ` ${s.name.padEnd(20)} ${s.spec.bin}` : ` ${s.name.padEnd(20)} BROKEN: ${s.error}`)
40
+ }
41
+ return specs.some((s) => s.error) ? 1 : 0
42
+ }
43
+
44
+ if (verb === 'template') {
45
+ const spec = { ...TEMPLATE }
46
+ // the common case is that the command is called what the adapter is called
47
+ if (typeof args.name === 'string') { spec.name = args.name; spec.bin = args.name }
48
+ out(JSON.stringify(spec, null, 2))
49
+ return 0
50
+ }
51
+
52
+ if (verb === 'show') {
53
+ if (!first) return die(2, 'usage: leg adapter show <name>')
54
+ const hit = customSpecs().find((s) => s.name === first)
55
+ if (!hit) {
56
+ if (BUILTIN_NAMES.includes(first)) return die(2, `${first} is a built-in adapter, not a spec on disk (src/adapters/${first}.mjs)`)
57
+ return die(3, `no custom adapter called "${first}" in ${adaptersDir()}`)
58
+ }
59
+ if (hit.error) return die(1, `${first}: ${hit.error}`)
60
+ out(JSON.stringify(hit.spec, null, 2))
61
+ return 0
62
+ }
63
+
64
+ if (verb === 'check') {
65
+ if (!first) return die(2, 'usage: leg adapter check <name>')
66
+ let adapter
67
+ try { adapter = await getAdapter(first) } catch (err) { return die(3, err.message) }
68
+ const cwd = typeof args.cwd === 'string' ? args.cwd : process.cwd()
69
+ let spec
70
+ try {
71
+ spec = adapter.argv({
72
+ prompt: 'THE TASK PROMPT',
73
+ promptFile: typeof args['prompt-file'] === 'string' ? args['prompt-file'] : null,
74
+ cwd,
75
+ mode: typeof args.mode === 'string' ? args.mode : undefined,
76
+ model: typeof args.model === 'string' ? args.model : null,
77
+ runDir: null,
78
+ killMs: 5400000,
79
+ })
80
+ } catch (err) { return die(1, `${first}: ${err.message}`) }
81
+ out(`${first}: ${adapter.custom ? 'custom spec' : 'built in'}`)
82
+ out(` stdin ${adapter.stdin}`)
83
+ out(` modes ${adapter.modes.allowed.join(', ')} (default ${adapter.modes.default})`)
84
+ out(` would run ${[spec.bin, ...spec.args].map(quote).join(' ')}`)
85
+ // A spec that names a binary nothing can find spawns once and fails once;
86
+ // saying so here is the difference between a typo and a mystery.
87
+ const bin = spec.bin
88
+ const found = existsSync(bin) || !/[\\/]/.test(bin)
89
+ out(` binary ${bin}${existsSync(bin) ? ' (found)' : found ? ' (looked up on PATH at spawn time)' : ' — NOT FOUND at that path'}`)
90
+ return existsSync(bin) || found ? 0 : 1
91
+ }
92
+
93
+ if (verb === 'add') {
94
+ if (!first) return die(2, 'usage: leg adapter add <file.json> [--name <name>]')
95
+ const file = resolve(first)
96
+ if (!existsSync(file)) return die(2, `no such file: ${file}`)
97
+ let raw
98
+ try { raw = JSON.parse(readFileSync(file, 'utf8')) } catch (err) { return die(2, `${file} is not JSON: ${err.message}`) }
99
+ if (raw && typeof raw === 'object' && typeof args.name === 'string') raw.name = args.name
100
+ let spec
101
+ try { spec = validateSpec(raw, { reserved: BUILTIN_NAMES }) } catch (err) {
102
+ if (err instanceof SpecError) return die(2, err.message)
103
+ throw err
104
+ }
105
+ const dest = specPath(spec.name)
106
+ const replacing = existsSync(dest)
107
+ mkdirSync(adaptersDir(), { recursive: true })
108
+ writeJsonAtomic(dest, spec)
109
+ const adapter = makeAdapter(spec)
110
+ // the runner always writes a prompt file, so the preview shows one too
111
+ const line = adapter.argv({ prompt: 'THE TASK PROMPT', promptFile: '<run>/prompt.txt', cwd: process.cwd() })
112
+ out(`${replacing ? 'replaced' : 'added'} ${spec.name} → ${dest}`)
113
+ out(` would run ${[line.bin, ...line.args].map(quote).join(' ')}`)
114
+ out(` use it leg card add --repo <path> --task "<t>" --chain ${spec.name},claude --queue`)
115
+ return 0
116
+ }
117
+
118
+ if (verb === 'rm' || verb === 'remove') {
119
+ if (!first) return die(2, 'usage: leg adapter rm <name>')
120
+ if (BUILTIN_NAMES.includes(first)) return die(2, `${first} is built in; there is no spec file to remove`)
121
+ const dest = specPath(first)
122
+ if (!existsSync(dest)) return die(3, `no custom adapter called "${first}" in ${adaptersDir()}`)
123
+ rmSync(dest, { force: true })
124
+ out(`removed ${first} (${dest})`)
125
+ out('cards that already name it keep their chain; they will fail to launch until it is added again')
126
+ return 0
127
+ }
128
+
129
+ return die(2, `unknown: leg adapter ${verb}\n\n${USAGE}\n\nplaceholders: ${PLACEHOLDERS.map((p) => `{{${p}}}`).join(' ')}`)
130
+ }