@ucsandman/legcli 0.11.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 +142 -0
- package/README.md +95 -65
- package/bin/leg.mjs +123 -14
- package/docs/DECISIONS.md +10 -0
- package/docs/DEMO.md +20 -14
- package/docs/DEVIATIONS.md +1 -0
- package/docs/ERRORS.md +41 -0
- package/docs/ROADMAP-v2.md +50 -5
- package/docs/VOCABULARY.md +27 -0
- package/docs/board-guide.md +401 -66
- package/docs/cli-contracts.md +199 -5
- package/docs/concepts.md +167 -19
- package/docs/configuration.md +58 -0
- 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/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/seed-fake-cards.mjs +59 -6
- package/scripts/seed-wes-board.mjs +81 -12
- package/src/accounts.mjs +6 -1
- package/src/attach.mjs +328 -46
- package/src/audit.mjs +1 -1
- package/src/board/board.css +119 -9
- package/src/board/board.js +482 -106
- package/src/board/index.html +67 -7
- package/src/board/sessions.js +1347 -117
- 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 +546 -47
- package/src/sessions.mjs +17 -1
- package/src/taps/claude-usage.mjs +91 -2
- package/src/taps/claude.mjs +144 -5
- package/src/taps/codex.mjs +23 -3
- package/src/usage.mjs +407 -12
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"produced_by": "https://code.claude.com/docs/en/costs (section: When a developer asks about a limit)",
|
|
6
6
|
"where": "any",
|
|
7
7
|
"text": "You’ve hit your session limit",
|
|
8
|
-
"pattern": "You.ve hit your session limit",
|
|
8
|
+
"pattern": "You.ve (hit|reached) your session limit",
|
|
9
9
|
"exit_code": null,
|
|
10
10
|
"classification": "limit"
|
|
11
11
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"produced_by": "https://code.claude.com/docs/en/costs (section: When a developer asks about a limit)",
|
|
6
6
|
"where": "any",
|
|
7
7
|
"text": "You’ve hit your weekly limit",
|
|
8
|
-
"pattern": "You.ve hit your weekly limit",
|
|
8
|
+
"pattern": "You.ve (hit|reached) your weekly limit",
|
|
9
9
|
"exit_code": null,
|
|
10
10
|
"classification": "limit"
|
|
11
11
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"agent": "claude",
|
|
3
|
+
"signal": "resume-with-model",
|
|
4
|
+
"captured_at": "2026-09-17T21:40:00Z",
|
|
5
|
+
"source": "observed-live",
|
|
6
|
+
"claude_version": "2.1.274",
|
|
7
|
+
"probe": [
|
|
8
|
+
"echo 'reply with exactly: ok' | claude -p --model sonnet --output-format json --max-turns 1",
|
|
9
|
+
"echo 'what did I ask you to reply with before? answer in 5 words' | claude -p --resume <session_id> --model haiku --output-format json --max-turns 1"
|
|
10
|
+
],
|
|
11
|
+
"observed": {
|
|
12
|
+
"same_session_id": true,
|
|
13
|
+
"conversation_kept": true,
|
|
14
|
+
"second_turn_result": "To reply with exactly ok.",
|
|
15
|
+
"second_turn_modelUsage_keys": ["claude-haiku-4-5-20251001"],
|
|
16
|
+
"second_turn_cacheReadInputTokens": 0,
|
|
17
|
+
"second_turn_is_error": false
|
|
18
|
+
},
|
|
19
|
+
"conclusion": "`--resume <id> --model <alias>` composes: the session id is kept, the prior turn is remembered, and only the new model answers. The resumed context is re-read at the new model's rates (cache read 0 on the first resumed turn), so a downshift keeps the conversation and an upshift pays the re-read at the higher price. Settles spec G1 (docs/redesign-2026-09-17.md)."
|
|
20
|
+
}
|
|
@@ -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
|
+
}
|
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",
|
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
|
}
|
|
@@ -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'] }
|