@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.
- package/CHANGELOG.md +212 -0
- package/README.md +158 -67
- package/bin/leg.mjs +168 -18
- package/docs/DECISIONS.md +10 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +94 -0
- package/docs/ROADMAP-v2.md +69 -11
- package/docs/VOCABULARY.md +27 -0
- package/docs/adapters.md +93 -11
- package/docs/board-guide.md +401 -66
- package/docs/cli-contracts.md +235 -22
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +113 -5
- 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/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/limits/grok/grok-balance-exhausted.json +11 -0
- package/fixtures/live/claude/resume-model-probe.json +20 -0
- package/fixtures/live/claude/usage-oauth.json +87 -0
- package/fixtures/live/grok/cmd.txt +1 -1
- package/fixtures/live/grok/parsed.json +6 -3
- package/fixtures/live/grok/run.json +22 -10
- package/fixtures/verified.json +8 -1
- package/package.json +3 -2
- package/scripts/build-docs-site.mjs +4 -4
- package/scripts/probe.mjs +2 -1
- package/scripts/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/adapters/cli.mjs +130 -0
- package/src/adapters/custom.mjs +271 -0
- package/src/adapters/grok.mjs +51 -10
- package/src/adapters/index.mjs +34 -7
- package/src/attach.mjs +350 -42
- package/src/audit.mjs +118 -0
- package/src/board/audit.js +123 -0
- package/src/board/board.css +134 -9
- package/src/board/board.js +482 -106
- package/src/board/index.html +89 -7
- package/src/board/sessions.js +1371 -113
- 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/orchestrator.mjs +13 -4
- package/src/preferences.mjs +214 -5
- package/src/scheduler.mjs +24 -1
- package/src/server.mjs +615 -50
- package/src/sessions.mjs +17 -1
- package/src/share.mjs +66 -6
- package/src/taps/claude-usage.mjs +91 -2
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/taps/grok.mjs +4 -0
- 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 -
|
|
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":
|
|
3
|
-
"last_message":
|
|
4
|
-
"stop_reason":
|
|
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-
|
|
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\\
|
|
9
|
+
"cwd": "~\\AppData\\Local\\Temp\\claude\\C--Projects-leg\\9c2d72c5-4654-46ea-a183-81767ec30af5\\scratchpad\\probe-repo3",
|
|
9
10
|
"status": "exited",
|
|
10
|
-
"supervisor_pid":
|
|
11
|
-
"agent_pid":
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
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
|
}
|
package/fixtures/verified.json
CHANGED
|
@@ -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":
|
|
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.
|
|
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
|
|
80
|
-
description: 'The exact usage endpoints, hooks, log lines and limit strings Leg reads from Claude Code, Codex and
|
|
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
|
|
87
|
-
description: 'How each agent adapter spawns its CLI, what it strips from the environment,
|
|
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
|
-
|
|
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
|
-
//
|
|
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' },
|
|
38
|
+
await createCard({ repo: args.repo, task: title, title, chain: 'fake' }, actor)
|
|
33
39
|
created += 1
|
|
34
40
|
}
|
|
35
|
-
|
|
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
|
-
|
|
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'] }
|
|
@@ -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
|
+
}
|