@ucsandman/legcli 0.7.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 (184) hide show
  1. package/CHANGELOG.md +560 -0
  2. package/LICENSE +58 -0
  3. package/NOTICE +4 -0
  4. package/README.md +558 -0
  5. package/bin/fake-agent.mjs +143 -0
  6. package/bin/leg.mjs +449 -0
  7. package/docs/DECISIONS.md +119 -0
  8. package/docs/DEMO.md +214 -0
  9. package/docs/DEVIATIONS.md +182 -0
  10. package/docs/ERRORS.md +326 -0
  11. package/docs/README.md +40 -0
  12. package/docs/REUSE.md +289 -0
  13. package/docs/ROADMAP-v2.md +55 -0
  14. package/docs/VOCABULARY.md +243 -0
  15. package/docs/adapters.md +300 -0
  16. package/docs/board-guide.md +579 -0
  17. package/docs/cli-contracts.md +401 -0
  18. package/docs/concepts.md +426 -0
  19. package/docs/configuration.md +220 -0
  20. package/docs/faq.md +206 -0
  21. package/docs/getting-started.md +251 -0
  22. package/docs/real-run.md +80 -0
  23. package/docs/review-2026-09-14.md +72 -0
  24. package/docs/screenshots/board-400px.png +0 -0
  25. package/docs/screenshots/board-details-open.png +0 -0
  26. package/docs/screenshots/board-done.png +0 -0
  27. package/docs/screenshots/board-drawer.png +0 -0
  28. package/docs/screenshots/board-empty.png +0 -0
  29. package/docs/screenshots/board-handoff.png +0 -0
  30. package/docs/screenshots/board-running.png +0 -0
  31. package/docs/screenshots/demo-1-claude-running.png +0 -0
  32. package/docs/screenshots/demo-2-limit-hit.png +0 -0
  33. package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
  34. package/docs/screenshots/demo-4-codex-running.png +0 -0
  35. package/docs/screenshots/demo-5-done.png +0 -0
  36. package/docs/screenshots/floor-final-1280.png +0 -0
  37. package/docs/screenshots/floor-final-400.png +0 -0
  38. package/docs/screenshots/floor-landing.png +0 -0
  39. package/docs/screenshots/floor.png +0 -0
  40. package/docs/screenshots/share-guest-1280.png +0 -0
  41. package/docs/screenshots/share-owner-1280.png +0 -0
  42. package/docs/screenshots/terminals-1280.png +0 -0
  43. package/docs/screenshots/thumb-done.jpg +0 -0
  44. package/docs/screenshots/thumb-handoff-details.jpg +0 -0
  45. package/docs/screenshots/thumb-limit-hit.jpg +0 -0
  46. package/docs/screenshots/youtube-thumbnail.jpg +0 -0
  47. package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
  48. package/fixtures/limits/claude/claude-budget-limit.json +11 -0
  49. package/fixtures/limits/claude/claude-max-turns.json +11 -0
  50. package/fixtures/limits/claude/claude-model-limit.json +11 -0
  51. package/fixtures/limits/claude/claude-session-limit.json +11 -0
  52. package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
  53. package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
  54. package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
  55. package/fixtures/limits/codex/codex-skills-notice.json +11 -0
  56. package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
  57. package/fixtures/limits/codex/codex-usage-limit.json +11 -0
  58. package/fixtures/limits/generic/generic-429.json +11 -0
  59. package/fixtures/limits/generic/generic-overloaded.json +11 -0
  60. package/fixtures/limits/generic/generic-quota.json +11 -0
  61. package/fixtures/limits/generic/generic-rate-limit.json +11 -0
  62. package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
  63. package/fixtures/limits/generic/generic-usage-limit.json +11 -0
  64. package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
  65. package/fixtures/limits/negative/auth-source-set.json +11 -0
  66. package/fixtures/limits/negative/compile-error.json +11 -0
  67. package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
  68. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
  69. package/fixtures/live/agy/cmd.txt +1 -0
  70. package/fixtures/live/agy/err.log +0 -0
  71. package/fixtures/live/agy/out.log +1 -0
  72. package/fixtures/live/agy/parsed.json +5 -0
  73. package/fixtures/live/agy/run.json +18 -0
  74. package/fixtures/live/agy/supervisor.log +2 -0
  75. package/fixtures/live/claude/cmd.txt +1 -0
  76. package/fixtures/live/claude/err.log +0 -0
  77. package/fixtures/live/claude/limit-rate_limit.json +21 -0
  78. package/fixtures/live/claude/out.log +1 -0
  79. package/fixtures/live/claude/parsed.json +11 -0
  80. package/fixtures/live/claude/run.json +18 -0
  81. package/fixtures/live/claude/supervisor.log +2 -0
  82. package/fixtures/live/codex/cmd.txt +1 -0
  83. package/fixtures/live/codex/err.log +1 -0
  84. package/fixtures/live/codex/last.md +2 -0
  85. package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
  86. package/fixtures/live/codex/out.log +8 -0
  87. package/fixtures/live/codex/parsed.json +15 -0
  88. package/fixtures/live/codex/run.json +18 -0
  89. package/fixtures/live/codex/supervisor.log +2 -0
  90. package/fixtures/live/grok/cmd.txt +1 -0
  91. package/fixtures/live/grok/err.log +32 -0
  92. package/fixtures/live/grok/out.log +7 -0
  93. package/fixtures/live/grok/parsed.json +5 -0
  94. package/fixtures/live/grok/run.json +18 -0
  95. package/fixtures/live/grok/supervisor.log +2 -0
  96. package/fixtures/verified.json +34 -0
  97. package/package.json +68 -0
  98. package/scripts/board-shots.mjs +69 -0
  99. package/scripts/build-docs-site.mjs +308 -0
  100. package/scripts/check-claims.mjs +150 -0
  101. package/scripts/clean-clone-check.sh +42 -0
  102. package/scripts/license-sign.mjs +23 -0
  103. package/scripts/limits-table.mjs +38 -0
  104. package/scripts/live-limits.mjs +36 -0
  105. package/scripts/privacy-check.mjs +63 -0
  106. package/scripts/probe.mjs +92 -0
  107. package/scripts/seed-fake-cards.mjs +34 -0
  108. package/scripts/seed-floor-board.mjs +69 -0
  109. package/scripts/seed-wes-board.mjs +91 -0
  110. package/scripts/stripe-setup.mjs +99 -0
  111. package/scripts/vercel-env.mjs +40 -0
  112. package/src/accounts.mjs +119 -0
  113. package/src/adapters/agy.mjs +56 -0
  114. package/src/adapters/claude.mjs +54 -0
  115. package/src/adapters/codex.mjs +73 -0
  116. package/src/adapters/common.mjs +32 -0
  117. package/src/adapters/fake.mjs +48 -0
  118. package/src/adapters/grok.mjs +51 -0
  119. package/src/adapters/index.mjs +27 -0
  120. package/src/adapters/resolve.mjs +40 -0
  121. package/src/attach.mjs +592 -0
  122. package/src/auth.mjs +85 -0
  123. package/src/board/board.css +543 -0
  124. package/src/board/board.js +1307 -0
  125. package/src/board/favicon.svg +1 -0
  126. package/src/board/floor.html +127 -0
  127. package/src/board/floor.js +670 -0
  128. package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  129. package/src/board/fonts/OFL-azeret-mono.txt +94 -0
  130. package/src/board/fonts/atkinson-board.woff2 +0 -0
  131. package/src/board/fonts/azeret-board.woff2 +0 -0
  132. package/src/board/index.html +251 -0
  133. package/src/board/sessions.js +1451 -0
  134. package/src/bundle.mjs +103 -0
  135. package/src/cards.mjs +114 -0
  136. package/src/chain.mjs +264 -0
  137. package/src/commands.mjs +79 -0
  138. package/src/contract.mjs +73 -0
  139. package/src/env.mjs +19 -0
  140. package/src/fsx.mjs +72 -0
  141. package/src/git-snapshot.mjs +152 -0
  142. package/src/handoff.mjs +171 -0
  143. package/src/hook.mjs +49 -0
  144. package/src/land.mjs +106 -0
  145. package/src/launcher.mjs +272 -0
  146. package/src/leases.mjs +86 -0
  147. package/src/ledger.mjs +369 -0
  148. package/src/license.mjs +168 -0
  149. package/src/limits.mjs +97 -0
  150. package/src/live-capture.mjs +69 -0
  151. package/src/mergequeue.mjs +225 -0
  152. package/src/orchestrator.mjs +365 -0
  153. package/src/pipeline.mjs +100 -0
  154. package/src/preferences.mjs +47 -0
  155. package/src/presets.mjs +23 -0
  156. package/src/ratelimit.mjs +49 -0
  157. package/src/redact.mjs +47 -0
  158. package/src/resume.mjs +354 -0
  159. package/src/runner.mjs +438 -0
  160. package/src/scheduler.mjs +118 -0
  161. package/src/server.mjs +845 -0
  162. package/src/session-detail.mjs +129 -0
  163. package/src/sessions.mjs +202 -0
  164. package/src/share.mjs +163 -0
  165. package/src/stations/agent.mjs +42 -0
  166. package/src/stations/build.mjs +9 -0
  167. package/src/stations/human.mjs +5 -0
  168. package/src/stations/land.mjs +6 -0
  169. package/src/stations/plan.mjs +8 -0
  170. package/src/stations/pr.mjs +31 -0
  171. package/src/stations/review.mjs +8 -0
  172. package/src/stations/test.mjs +27 -0
  173. package/src/store.mjs +90 -0
  174. package/src/sync/dashclaw.mjs +70 -0
  175. package/src/sync/index.mjs +54 -0
  176. package/src/sync/workboard.mjs +54 -0
  177. package/src/taps/agy.mjs +64 -0
  178. package/src/taps/claude-usage.mjs +67 -0
  179. package/src/taps/claude.mjs +170 -0
  180. package/src/taps/codex.mjs +286 -0
  181. package/src/trust.mjs +323 -0
  182. package/src/usage.mjs +179 -0
  183. package/src/wait.mjs +30 -0
  184. package/src/worktree.mjs +207 -0
package/src/store.mjs ADDED
@@ -0,0 +1,90 @@
1
+ // store — read side of the ledger plus thin wrappers around the ledger CLI
2
+ // (src/ledger.mjs stays the only writer). Everything the orchestrator,
3
+ // scheduler, board and CLI need to look at cards goes through here.
4
+ import { execFileSync } from 'node:child_process'
5
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
6
+ import { join, dirname } from 'node:path'
7
+ import { homedir } from 'node:os'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { readEvents as ledgerReadEvents } from './ledger.mjs'
10
+
11
+ const SRC = dirname(fileURLToPath(import.meta.url))
12
+ export const LEDGER = join(SRC, 'ledger.mjs')
13
+ export const RUNNER = join(SRC, 'runner.mjs')
14
+ export const LEG_ACTOR = { type: 'leg' }
15
+ export const BATON_ACTOR = LEG_ACTOR
16
+
17
+ export function home() {
18
+ if (process.env.LEG_HOME) return process.env.LEG_HOME
19
+ if (process.env.BATON_HOME) return process.env.BATON_HOME
20
+ const leg = join(homedir(), '.leg')
21
+ const baton = join(homedir(), '.baton')
22
+ if (existsSync(leg)) return leg
23
+ if (existsSync(baton)) return baton
24
+ return leg
25
+ }
26
+
27
+ export function cardDir(id) { return join(home(), 'cards', id) }
28
+
29
+ export function readCard(id) {
30
+ const f = join(cardDir(id), 'card.json')
31
+ if (!existsSync(f)) return null
32
+ try { return JSON.parse(readFileSync(f, 'utf8')) } catch { return null }
33
+ }
34
+
35
+ export function listCards() {
36
+ const root = join(home(), 'cards')
37
+ if (!existsSync(root)) return []
38
+ return readdirSync(root).sort().map(readCard).filter(Boolean)
39
+ }
40
+
41
+ export function readRuns(id) {
42
+ const dir = join(cardDir(id), 'runs')
43
+ if (!existsSync(dir)) return []
44
+ return readdirSync(dir).map(Number).filter((n) => Number.isInteger(n) && n > 0).sort((a, b) => a - b)
45
+ .map((n) => {
46
+ try { return JSON.parse(readFileSync(join(dir, String(n), 'run.json'), 'utf8')) } catch { return null }
47
+ }).filter(Boolean)
48
+ }
49
+
50
+ export function readEvents(id) { return ledgerReadEvents(id) }
51
+
52
+ function ledger(args) {
53
+ return execFileSync(process.execPath, [LEDGER, ...args], { windowsHide: true, encoding: 'utf8', env: process.env })
54
+ }
55
+
56
+ export function ledgerCreate(fields) {
57
+ const args = ['create']
58
+ for (const [k, v] of Object.entries(fields)) {
59
+ if (v === undefined || v === null) continue
60
+ args.push(`--${k}`, typeof v === 'string' ? v : JSON.stringify(v))
61
+ }
62
+ return ledger(args).trim()
63
+ }
64
+
65
+ export function ledgerAppend(id, { actor = BATON_ACTOR, type, summary, body, station, leg }) {
66
+ const args = ['append', '--card', id, '--actor', JSON.stringify(actor), '--type', type, '--summary', summary]
67
+ if (body) args.push('--body', String(body).slice(0, 4000))
68
+ if (station) args.push('--station', station)
69
+ if (leg !== undefined && leg !== null) args.push('--leg', String(leg))
70
+ ledger(args)
71
+ }
72
+
73
+ export function ledgerLog({ actor = BATON_ACTOR, type, summary, body }) {
74
+ const args = ['log', '--actor', JSON.stringify(actor), '--type', type, '--summary', summary]
75
+ if (body) args.push('--body', String(body).slice(0, 4000))
76
+ ledger(args)
77
+ }
78
+
79
+ export function ledgerUpdate(id, { status, station, leg, sessionId, patch }) {
80
+ const args = ['update', '--card', id]
81
+ if (status) args.push('--status', status)
82
+ if (station) args.push('--station', station)
83
+ if (leg !== undefined && leg !== null) args.push('--leg', String(leg))
84
+ if (sessionId) args.push('--session-id', sessionId)
85
+ if (patch && Object.keys(patch).length) args.push('--patch', JSON.stringify(patch))
86
+ if (args.length === 3) return
87
+ ledger(args)
88
+ }
89
+
90
+ export const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
@@ -0,0 +1,70 @@
1
+ // dashclaw sync — records every ledger event as a DashClaw action over native
2
+ // http/https (LESSONS 07-12: no global fetch). Off unless BATON_SYNC_DASHCLAW=1
3
+ // and DASHCLAW_URL + DASHCLAW_API_KEY are set. Field names come from
4
+ // DashClaw's validator (app/lib/validate.js ACTION_RECORD_SCHEMA): agent_id,
5
+ // action_type and declared_goal are required; status is one of running,
6
+ // completed, failed, cancelled, pending, pending_approval, blocked.
7
+ import http from 'node:http'
8
+ import https from 'node:https'
9
+
10
+ export function dashclawConfig(env = process.env) {
11
+ if ((env.LEG_SYNC_DASHCLAW || env.BATON_SYNC_DASHCLAW) !== '1') return null
12
+ const url = env.DASHCLAW_URL
13
+ const key = env.DASHCLAW_API_KEY
14
+ if (!url || !key) return null
15
+ return { url: url.replace(/\/$/, ''), key }
16
+ }
17
+
18
+ const STATUS_BY_TYPE = {
19
+ card_created: 'pending', leg_started: 'running', leg_progress: 'running', leg_exited: 'completed',
20
+ limit_detected: 'blocked', handoff_written: 'completed', leg_resumed: 'running', station_done: 'completed',
21
+ bounced: 'failed', landed: 'completed', land_warning: 'completed', land_retry: 'running', blocked_by: 'blocked',
22
+ scheduler_started: 'running', scheduler_stopped: 'completed', approval_needed: 'pending_approval', approved: 'completed',
23
+ reassigned: 'completed', paused: 'blocked', resumed: 'running', killed: 'cancelled', done: 'completed',
24
+ failed: 'failed', error: 'failed', status: 'completed',
25
+ }
26
+
27
+ // One ledger event → one action record (the request body).
28
+ export function actionForEvent(ev, card = null) {
29
+ const actor = ev.actor ?? { type: 'baton' }
30
+ const agent = actor.type === 'agent' ? `baton/${actor.adapter}` : actor.type === 'human' ? `baton/human:${actor.id}` : 'baton'
31
+ const body = {
32
+ agent_id: agent,
33
+ action_type: `baton_${ev.type}`,
34
+ declared_goal: String(ev.summary ?? ev.type).slice(0, 2000),
35
+ status: STATUS_BY_TYPE[ev.type] ?? 'completed',
36
+ reversible: true,
37
+ risk_score: ev.type === 'landed' ? 40 : ev.type === 'killed' ? 30 : 10,
38
+ trigger: ev.card_id ? `card ${ev.card_id}` : 'scheduler',
39
+ systems_touched: [ev.card_id, card?.repo].filter(Boolean).slice(0, 50),
40
+ input_summary: JSON.stringify({ card_id: ev.card_id, station: ev.station, leg: ev.leg, actor, task: card?.task ?? null }).slice(0, 4000),
41
+ timestamp_start: ev.ts,
42
+ timestamp_end: ev.ts,
43
+ }
44
+ if (ev.body) body.output_summary = String(ev.body).slice(0, 4000)
45
+ return body
46
+ }
47
+
48
+ export function request(cfg, method, path, body, { timeoutMs = 5000 } = {}) {
49
+ const data = JSON.stringify(body ?? {})
50
+ const url = new URL(cfg.url + path)
51
+ const client = url.protocol === 'https:' ? https : http
52
+ return new Promise((resolvePromise) => {
53
+ const req = client.request(url, {
54
+ method,
55
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data), Connection: 'close', 'x-api-key': cfg.key },
56
+ timeout: timeoutMs,
57
+ }, (res) => {
58
+ let text = ''
59
+ res.on('data', (c) => { text += c })
60
+ res.on('end', () => resolvePromise({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, text }))
61
+ })
62
+ req.on('timeout', () => req.destroy())
63
+ req.on('error', (err) => resolvePromise({ ok: false, status: 0, text: err.message }))
64
+ req.end(data)
65
+ })
66
+ }
67
+
68
+ export async function record(cfg, ev, card) {
69
+ return request(cfg, 'POST', '/api/actions', actionForEvent(ev, card))
70
+ }
@@ -0,0 +1,54 @@
1
+ // sync fan-out — best effort, off by default, never throws into the ledger.
2
+ // The ledger calls notify() after it has written its own files; a sync that
3
+ // fails becomes one `status` event (`[sync:<name>] …`) at most once a minute
4
+ // and a buffered retry for DashClaw (unsynced.jsonl, flushed by `ledger sync`).
5
+ import { existsSync, statSync, writeFileSync, appendFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { dashclawConfig, record } from './dashclaw.mjs'
8
+ import { workboardEnabled, mirror } from './workboard.mjs'
9
+
10
+ export function enabledSyncs(env = process.env) {
11
+ const out = []
12
+ if (workboardEnabled(env)) out.push('workboard')
13
+ if (dashclawConfig(env)) out.push('dashclaw')
14
+ return out
15
+ }
16
+
17
+ function throttled(home, name, minMs = 60000) {
18
+ const f = join(home, `sync-${name}.last-failure`)
19
+ if (existsSync(f) && Date.now() - statSync(f).mtimeMs < minMs) return true
20
+ writeFileSync(f, new Date().toISOString())
21
+ return false
22
+ }
23
+
24
+ // Called by the ledger. `report(summary)` appends the status event through the
25
+ // ledger's own writer (passed in to avoid a circular import).
26
+ export async function notify({ kind, ev, card, home, env = process.env, report = () => {} }) {
27
+ const results = {}
28
+ if (workboardEnabled(env) && card && (kind === 'create' || kind === 'status')) {
29
+ try {
30
+ const r = mirror(card.status === 'done' && kind === 'status' ? 'done' : kind, card, { home, env })
31
+ results.workboard = r
32
+ if (r.first_unavailable) report(`[sync:workboard] unavailable (plugin disabled): ${r.message}`)
33
+ else if (r.error && !throttled(home, 'workboard')) report(`[sync:workboard] failed: ${r.error}`)
34
+ } catch (err) {
35
+ results.workboard = { error: err.message }
36
+ if (!throttled(home, 'workboard')) report(`[sync:workboard] failed: ${err.message}`)
37
+ }
38
+ }
39
+ const cfg = dashclawConfig(env)
40
+ if (cfg && ev) {
41
+ try {
42
+ const r = await record(cfg, ev, card)
43
+ results.dashclaw = r
44
+ if (!r.ok) {
45
+ if (ev.card_id) appendFileSync(join(home, 'cards', ev.card_id, 'unsynced.jsonl'), JSON.stringify({ op: 'record', ev, card: card ? { repo: card.repo, task: card.task } : null }) + '\n')
46
+ if (!throttled(home, 'dashclaw')) report(`[sync:dashclaw] failed (${r.status}): ${String(r.text).slice(0, 120)}; buffered`)
47
+ }
48
+ } catch (err) {
49
+ results.dashclaw = { ok: false, text: err.message }
50
+ if (!throttled(home, 'dashclaw')) report(`[sync:dashclaw] failed: ${err.message}`)
51
+ }
52
+ }
53
+ return results
54
+ }
@@ -0,0 +1,54 @@
1
+ // workboard sync — mirrors cards to the OpenClaw Workboard through the
2
+ // `openclaw workboard` CLI, argv only (node <openclaw.mjs> …, never a shell).
3
+ // Off unless BATON_SYNC_WORKBOARD=1. On the build machine the plugin is
4
+ // disabled (`plugins.allow` excludes "workboard"; fixtures/sync/workboard-help.txt
5
+ // holds the verbatim message), so the verb mapping below is written against
6
+ // the argv stub in test/sync.test.mjs. When the plugin is enabled, adjust
7
+ // VERBS here from `openclaw workboard --help`; nothing else needs to change.
8
+ import { spawnSync } from 'node:child_process'
9
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+ import { resolveNpmCliEntry } from '../adapters/resolve.mjs'
12
+
13
+ export const VERBS = {
14
+ create: (card) => ['add', '--title', card.title ?? card.task, '--id', card.card_id, '--column', card.status],
15
+ status: (card) => ['move', card.card_id, card.status === 'done' ? 'done' : card.status],
16
+ done: (card) => ['done', card.card_id],
17
+ }
18
+
19
+ export function workboardEnabled(env = process.env) { return (env.LEG_SYNC_WORKBOARD || env.BATON_SYNC_WORKBOARD) === '1' }
20
+
21
+ export function resolveOpenclaw(env = process.env) {
22
+ if (env.OPENCLAW_BIN && /\.(mjs|cjs|js)$/.test(env.OPENCLAW_BIN)) return { bin: process.execPath, prefix: [env.OPENCLAW_BIN] }
23
+ const entry = resolveNpmCliEntry('openclaw', 'openclaw', { pkgDir: env.OPENCLAW_PKG_DIR })
24
+ if (entry) return { bin: process.execPath, prefix: [entry] }
25
+ return null
26
+ }
27
+
28
+ const UNAVAILABLE_RE = /is unavailable because|plugins\.allow/i
29
+
30
+ function markerPath(home) { return join(home, 'sync-workboard.unavailable') }
31
+
32
+ // Returns { ran, argv, unavailable, first_unavailable, error }.
33
+ export function mirror(kind, card, { home, env = process.env } = {}) {
34
+ const verb = VERBS[kind]
35
+ if (!verb) return { ran: false, error: `unknown mirror kind ${kind}` }
36
+ const marker = home ? markerPath(home) : null
37
+ if (marker && existsSync(marker)) return { ran: false, unavailable: true, first_unavailable: false }
38
+ const oc = resolveOpenclaw(env)
39
+ if (!oc) return { ran: false, error: 'openclaw not installed (set OPENCLAW_PKG_DIR or OPENCLAW_BIN)' }
40
+ const argv = ['workboard', ...verb(card)]
41
+ const r = spawnSync(oc.bin, [...oc.prefix, ...argv], { windowsHide: true, encoding: 'utf8', timeout: 20000, env })
42
+ const text = `${r.stdout ?? ''}\n${r.stderr ?? ''}`
43
+ if (UNAVAILABLE_RE.test(text)) {
44
+ if (marker) writeFileSync(marker, `${new Date().toISOString()} ${text.trim().split('\n')[0]}\n`)
45
+ return { ran: true, argv, unavailable: true, first_unavailable: true, message: text.trim().split('\n')[0] }
46
+ }
47
+ if (r.error || r.status !== 0) return { ran: true, argv, error: (r.error?.message || r.stderr || r.stdout || `exit ${r.status}`).trim().slice(0, 300) }
48
+ return { ran: true, argv, ok: true }
49
+ }
50
+
51
+ export function unavailableMessage(home) {
52
+ const p = markerPath(home)
53
+ return existsSync(p) ? readFileSync(p, 'utf8').trim() : null
54
+ }
@@ -0,0 +1,64 @@
1
+ // agy tap — Antigravity CLI 1.2.0 is a closed Go binary with no hooks and no
2
+ // usage percentage on any surface Baton can read (its own status line fetches
3
+ // a quota summary from the backend; the value is not written anywhere). What
4
+ // it does give:
5
+ // --log-file <path> one log per Baton session; the wall shows up as
6
+ // RESOURCE_EXHAUSTED / "it resets in <d>" / "out of quota"
7
+ // (strings present in agy.exe; docs-only until hit live)
8
+ // ~/.gemini/antigravity-cli/history.jsonl
9
+ // {display, timestamp, workspace, conversationId} per
10
+ // prompt (observed live) → task text + conversation id
11
+ import { existsSync, readFileSync, statSync } from 'node:fs'
12
+ import { join } from 'node:path'
13
+ import { LAYOUT } from '../accounts.mjs'
14
+
15
+ export function historyFile(agyHome = LAYOUT.agy.home()) { return join(agyHome, 'history.jsonl') }
16
+
17
+ const LIMIT_RES = [
18
+ ['agy-resource-exhausted', /RESOURCE_EXHAUSTED/],
19
+ ['agy-resets-in', /it resets in\s+\S+/i],
20
+ ['agy-out-of-quota', /out of quota/i],
21
+ ['agy-quota-exhausted', /quota (exhausted|exceeded)/i],
22
+ ]
23
+
24
+ // Log text → { signal, detail, resets_at } | null
25
+ export function scanLog(text) {
26
+ for (const [id, re] of LIMIT_RES) {
27
+ const m = re.exec(text)
28
+ if (!m) continue
29
+ const at = text.lastIndexOf(m[0])
30
+ const detail = text.slice(Math.max(0, at - 80), at + 160).replace(/\s+/g, ' ').trim()
31
+ let resets_at = null
32
+ // "Resets in 71h19m42s" has more than one unit: sum them all, not just the
33
+ // first, or the agent is restarted from the bundle before it is actually back
34
+ const dm = /resets in\s+((?:\d+\s*[smhd])+)/i.exec(text.slice(at))
35
+ if (dm) {
36
+ let secs = 0
37
+ for (const [, n, u] of dm[1].matchAll(/(\d+)\s*([smhd])/gi)) secs += parseInt(n, 10) * { s: 1, m: 60, h: 3600, d: 86400 }[u.toLowerCase()]
38
+ if (secs) resets_at = Math.floor(Date.now() / 1000) + secs
39
+ }
40
+ return { signal: id, detail, resets_at }
41
+ }
42
+ return null
43
+ }
44
+
45
+ function sameDir(a, b) {
46
+ const n = (x) => String(x ?? '').replace(/\\/g, '/').replace(/\/$/, '').toLowerCase()
47
+ return n(a) === n(b)
48
+ }
49
+
50
+ // Prompts typed into agy in `cwd` since `sinceMs`.
51
+ export function promptsSince({ agyHome, cwd, sinceMs }) {
52
+ const f = historyFile(agyHome)
53
+ if (!existsSync(f)) return []
54
+ const out = []
55
+ for (const line of readFileSync(f, 'utf8').split('\n')) {
56
+ if (!line) continue
57
+ let j
58
+ try { j = JSON.parse(line) } catch { continue }
59
+ if (j.timestamp >= sinceMs - 2000 && sameDir(j.workspace, cwd)) out.push({ text: String(j.display ?? ''), ts: j.timestamp, conversationId: j.conversationId ?? null })
60
+ }
61
+ return out
62
+ }
63
+
64
+ export function logSize(path) { try { return statSync(path).size } catch { return 0 } }
@@ -0,0 +1,67 @@
1
+ // claude usage — the 5h / 7d percentages for a Claude Code login, from the
2
+ // same endpoint Claude Code's own /usage and built-in status line read.
3
+ // Why not the status line: Claude Code 2.1.268 renders its built-in status
4
+ // line and does not run a custom `statusLine` command passed via --settings
5
+ // or a project settings file (verified 2026-09-11 with an `echo` command at
6
+ // both levels; hooks from the same --settings file do run). So Baton asks the
7
+ // usage endpoint directly with the OAuth token Claude Code stored at login.
8
+ // The token is read by this process only, sent only to api.anthropic.com,
9
+ // and never written anywhere (the ledger scrubs bearer tokens regardless).
10
+ // Native https, not fetch: Node 24 on Windows can crash at exit with an open
11
+ // fetch (test/lessons.test.mjs no-global-fetch).
12
+ import https from 'node:https'
13
+ import http from 'node:http'
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { LAYOUT } from '../accounts.mjs'
17
+
18
+ export const USAGE_URL = (process.env.LEG_CLAUDE_USAGE_URL || process.env.BATON_CLAUDE_USAGE_URL) || 'https://api.anthropic.com/api/oauth/usage'
19
+
20
+ function readToken(configDir) {
21
+ const f = join(configDir, '.credentials.json')
22
+ if (!existsSync(f)) return null
23
+ try {
24
+ const j = JSON.parse(readFileSync(f, 'utf8'))
25
+ const o = j.claudeAiOauth ?? j.oauth ?? null
26
+ if (!o?.accessToken) return null
27
+ return { token: o.accessToken, expired: Boolean(o.expiresAt && Date.now() > o.expiresAt) }
28
+ } catch { return null }
29
+ }
30
+
31
+ function window(x) {
32
+ if (!x || typeof x !== 'object') return null
33
+ const pct = Number(x.utilization ?? x.used_percentage ?? x.used_percent)
34
+ if (!Number.isFinite(pct)) return null
35
+ let resets = x.resets_at ?? x.resetsAt ?? null
36
+ if (typeof resets === 'string') { const t = Date.parse(resets); resets = Number.isFinite(t) ? Math.floor(t / 1000) : null }
37
+ return { pct, resets_at: resets }
38
+ }
39
+
40
+ function getJson(url, headers, timeoutMs) {
41
+ return new Promise((resolvePromise) => {
42
+ const u = new URL(url)
43
+ // http only for a local test double through BATON_CLAUDE_USAGE_URL
44
+ const mod = u.protocol === 'http:' ? http : https
45
+ const req = mod.request({ host: u.hostname, port: u.port || (u.protocol === 'http:' ? 80 : 443), path: u.pathname + u.search, method: 'GET', headers, timeout: timeoutMs }, (res) => {
46
+ let d = ''
47
+ res.setEncoding('utf8')
48
+ res.on('data', (c) => { d += c })
49
+ res.on('end', () => resolvePromise({ status: res.statusCode, text: d }))
50
+ })
51
+ req.on('timeout', () => { req.destroy(new Error('usage endpoint timed out')) })
52
+ req.on('error', (err) => resolvePromise({ status: 0, text: '', error: err.message }))
53
+ req.end()
54
+ })
55
+ }
56
+
57
+ // → { ok, limits: {five_hour, seven_day}|null, status, error, expired }
58
+ export async function fetchClaudeUsage({ configDir = LAYOUT.claude.home(), timeoutMs = 8000, url = USAGE_URL } = {}) {
59
+ const t = readToken(configDir)
60
+ if (!t) return { ok: false, limits: null, error: 'no claude.ai login found in ' + configDir }
61
+ const r = await getJson(url, { Authorization: `Bearer ${t.token}`, 'anthropic-beta': 'oauth-2025-04-20', Accept: 'application/json', 'User-Agent': 'legcli' }, timeoutMs)
62
+ if (r.error) return { ok: false, limits: null, status: 0, error: r.error }
63
+ if (r.status !== 200) return { ok: false, limits: null, status: r.status, expired: t.expired, error: `usage endpoint ${r.status}: ${r.text.slice(0, 120)}` }
64
+ let j
65
+ try { j = JSON.parse(r.text) } catch { return { ok: false, limits: null, status: r.status, error: 'usage endpoint returned no JSON' } }
66
+ return { ok: true, limits: { five_hour: window(j.five_hour), seven_day: window(j.seven_day) }, status: r.status, expired: t.expired, raw_keys: Object.keys(j) }
67
+ }
@@ -0,0 +1,170 @@
1
+ // claude tap — how `baton claude` sees inside a normal interactive Claude Code.
2
+ // Nothing in ~/.claude is edited: the session gets one extra settings file via
3
+ // `--settings` (hooks merge with the user's; statusLine is the only key that
4
+ // replaces, so Baton's status line runs the user's own command first).
5
+ // Sources: code.claude.com/docs/en/hooks (StopFailure `error: rate_limit`),
6
+ // docs/en/statusline (rate_limits.five_hour/seven_day used_percentage,
7
+ // resets_at), docs/en/settings (`--settings` sits above user settings).
8
+ import { existsSync, readFileSync } from 'node:fs'
9
+ import { join, dirname, resolve } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { sessionDir, updateSession, appendEvent, readSession, workRoot } from '../sessions.mjs'
12
+ import { recordUsage, markLimited, WARN_PCT } from '../usage.mjs'
13
+ import { writeJsonAtomic } from '../fsx.mjs'
14
+ import { LAYOUT } from '../accounts.mjs'
15
+
16
+ const HOOK = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'hook.mjs')
17
+ const q = (p) => `"${String(p).replace(/\\/g, '\\\\')}"`
18
+
19
+ // The user's own statusLine command, if any, so it keeps running.
20
+ export function userStatusLine(configDir = LAYOUT.claude.home()) {
21
+ for (const f of ['settings.json', 'settings.local.json']) {
22
+ const p = join(configDir, f)
23
+ if (!existsSync(p)) continue
24
+ try {
25
+ const s = JSON.parse(readFileSync(p, 'utf8'))
26
+ if (s.statusLine?.type === 'command' && s.statusLine.command) return s.statusLine
27
+ } catch {}
28
+ }
29
+ return null
30
+ }
31
+
32
+ export function settingsFor(sessionId, { statusLine = null } = {}) {
33
+ const cmd = (kind) => ({ type: 'command', command: `node ${q(HOOK)} ${kind} --session ${sessionId}`, timeout: 20 })
34
+ const settings = {
35
+ hooks: {
36
+ SessionStart: [{ hooks: [cmd('claude-hook')] }],
37
+ UserPromptSubmit: [{ hooks: [cmd('claude-hook')] }],
38
+ PostToolUse: [{ matcher: 'Edit|Write|MultiEdit|NotebookEdit', hooks: [cmd('claude-hook')] }],
39
+ Stop: [{ hooks: [cmd('claude-hook')] }],
40
+ StopFailure: [{ hooks: [cmd('claude-hook')] }],
41
+ SessionEnd: [{ hooks: [cmd('claude-hook')] }],
42
+ },
43
+ statusLine: { type: 'command', command: `node ${q(HOOK)} claude-statusline --session ${sessionId}`, padding: statusLine?.padding ?? 0 },
44
+ // Baton owns the limit: it hands the work to the next option instead of
45
+ // waiting in the session for the reset.
46
+ autoContinueAtUsageLimit: false,
47
+ }
48
+ return settings
49
+ }
50
+
51
+ export function writeSettings(sessionId, opts) {
52
+ const file = join(sessionDir(sessionId), 'claude-settings.json')
53
+ writeJsonAtomic(file, settingsFor(sessionId, opts))
54
+ return file
55
+ }
56
+
57
+ function textOf(content) {
58
+ if (typeof content === 'string') return content
59
+ if (Array.isArray(content)) return content.filter((c) => c?.type === 'text' && c.text).map((c) => c.text).join('\n')
60
+ return ''
61
+ }
62
+
63
+ // Last human/assistant messages from a Claude Code transcript (jsonl).
64
+ export function transcriptTail(path, limit = 8) {
65
+ if (!path || !existsSync(path)) return []
66
+ const out = []
67
+ for (const line of readFileSync(path, 'utf8').split('\n')) {
68
+ if (!line) continue
69
+ let j
70
+ try { j = JSON.parse(line) } catch { continue }
71
+ if (j.type !== 'user' && j.type !== 'assistant') continue
72
+ if (j.isSidechain) continue
73
+ const text = textOf(j.message?.content).trim()
74
+ if (!text || /^<[a-z-]+>/.test(text)) continue
75
+ out.push({ role: j.type, text: text.slice(0, 1500), ts: j.timestamp ?? null })
76
+ }
77
+ return out.slice(-limit)
78
+ }
79
+
80
+ export function firstPrompt(path) {
81
+ const t = transcriptTail(path, 1000).find((m) => m.role === 'user')
82
+ return t ? t.text.slice(0, 500) : null
83
+ }
84
+
85
+ function limitsFrom(rl) {
86
+ if (!rl) return null
87
+ const w = (x) => (x && Number.isFinite(x.used_percentage) ? { pct: x.used_percentage, resets_at: x.resets_at ?? null } : null)
88
+ return { five_hour: w(rl.five_hour), seven_day: w(rl.seven_day) }
89
+ }
90
+
91
+ // Hook payload → session record. Returns a short line for the hook log.
92
+ export function handleHook(sessionId, p) {
93
+ const s = readSession(sessionId)
94
+ if (!s) return 'no session'
95
+ const base = { agent_session_id: p.session_id ?? s.agent_session_id, transcript_path: p.transcript_path ?? s.transcript_path, last_activity: new Date().toISOString() }
96
+ switch (p.hook_event_name) {
97
+ case 'SessionStart':
98
+ // reducer: one hook process per tool call, so read `cur` inside the lock
99
+ updateSession(sessionId, (cur) => ({ ...base, status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'agent_ready', summary: `claude session ${p.session_id ?? '?'} (${p.source ?? 'startup'})` } })
100
+ return 'session start'
101
+ case 'UserPromptSubmit': {
102
+ updateSession(sessionId, (cur) => ({ ...base, task: cur.task ?? (p.prompt ? String(p.prompt).slice(0, 500) : null), turns: (cur.turns ?? 0) + 1 }), { event: { type: 'turn', summary: `prompt: ${String(p.prompt ?? '').slice(0, 120)}` } })
103
+ return 'prompt'
104
+ }
105
+ case 'PostToolUse': {
106
+ const file = p.tool_input?.file_path ?? p.tool_input?.notebook_path ?? null
107
+ if (!file) return 'tool (no file)'
108
+ const rel = relTo(workRoot(s), file)
109
+ // reducer: concurrent Edit/Write hooks each add their own file without
110
+ // the last writer overwriting the others' additions
111
+ updateSession(sessionId, (cur) => ({ ...base, files_touched: cur.files_touched.includes(rel) ? cur.files_touched : [...cur.files_touched, rel].slice(-200) }))
112
+ return `touched ${rel}`
113
+ }
114
+ case 'Stop':
115
+ // reducer: never turn a 'limit'/'handing_off' back to 'running' by racing
116
+ updateSession(sessionId, (cur) => ({ ...base, status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'turn_done', summary: String(p.last_assistant_message ?? '').slice(0, 160) || 'turn done' } })
117
+ return 'stop'
118
+ case 'StopFailure': {
119
+ if (p.error === 'rate_limit') {
120
+ // a simulated wall (baton sessions simulate-limit) clears after two minutes so a test never walls the real login for hours
121
+ const simulated = Boolean(p.leg_simulated || p.baton_simulated)
122
+ const u = markLimited('claude', s.account, { reason: 'rate_limit', source: simulated ? 'leg simulate-limit' : 'claude StopFailure', resets_at: simulated ? Math.floor(Date.now() / 1000) + 120 : null })
123
+ updateSession(sessionId, { ...base, status: 'limit', limit: { reason: 'rate_limit', detail: String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 300), resets_at: u.limited_until, at: new Date().toISOString(), simulated } }, { event: { type: 'limit', summary: `claude usage limit${simulated ? ' (simulated)' : ''}: ${String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 160)}` } })
124
+ return 'LIMIT'
125
+ }
126
+ appendEvent(sessionId, { type: 'error', summary: `claude ${p.error}: ${String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 160)}` })
127
+ updateSession(sessionId, base)
128
+ return `error ${p.error}`
129
+ }
130
+ case 'SessionEnd':
131
+ updateSession(sessionId, { ...base, agent_exit_reason: p.reason ?? null }, { event: { type: 'agent_exit', summary: `claude session ended (${p.reason ?? 'unknown'})` } })
132
+ return 'session end'
133
+ default:
134
+ updateSession(sessionId, base)
135
+ return p.hook_event_name ?? 'unknown'
136
+ }
137
+ }
138
+
139
+ export function relTo(root, file) {
140
+ const a = String(file).replace(/\\/g, '/')
141
+ const r = String(root ?? '').replace(/\\/g, '/').replace(/\/$/, '')
142
+ return r && a.toLowerCase().startsWith(r.toLowerCase() + '/') ? a.slice(r.length + 1) : a
143
+ }
144
+
145
+ // Status line: record the limits and print. Returns the text to print.
146
+ export function handleStatusline(sessionId, p) {
147
+ const s = readSession(sessionId)
148
+ if (!s) return { text: '', limits: null }
149
+ const limits = limitsFrom(p.rate_limits)
150
+ if (limits) recordUsage('claude', s.account, limits, 'claude statusline')
151
+ const hot = limits ? [['5h', limits.five_hour], ['7d', limits.seven_day]].filter(([, w]) => w).sort((a, b) => b[1].pct - a[1].pct)[0] : null
152
+ const warn = hot && hot[1].pct >= WARN_PCT
153
+ // reducer: the status-line hook is its own process; only nudge running↔warning
154
+ // from `cur`, so it never overwrites a 'limit' a StopFailure set at the same time
155
+ updateSession(sessionId, (cur) => {
156
+ const patch = { last_activity: new Date().toISOString() }
157
+ if (limits) patch.limits = limits
158
+ if (p.session_id && !cur.agent_session_id) patch.agent_session_id = p.session_id
159
+ if (p.transcript_path && !cur.transcript_path) patch.transcript_path = p.transcript_path
160
+ if (warn && !cur.warning) patch.warning = { window: hot[0], pct: hot[1].pct, resets_at: hot[1].resets_at, at: new Date().toISOString() }
161
+ if (!warn && cur.warning) patch.warning = null
162
+ if (warn && cur.status === 'running') patch.status = 'warning'
163
+ if (!warn && cur.status === 'warning') patch.status = 'running'
164
+ return patch
165
+ }, warn && !s.warning ? { event: { type: 'warning', summary: `claude ${hot[0]} window at ${Math.round(hot[1].pct)}%` } } : {})
166
+ const next = s.chain?.[0] ? `${s.chain[0].agent}${s.chain[0].account !== 'default' ? '/' + s.chain[0].account : ''}` : 'nothing'
167
+ const pct = limits ? ` 5h ${limits.five_hour ? Math.round(limits.five_hour.pct) + '%' : '-'} · 7d ${limits.seven_day ? Math.round(limits.seven_day.pct) + '%' : '-'}` : ''
168
+ const text = warn ? `⚠ leg: ${hot[0]} at ${Math.round(hot[1].pct)}% → next ${next}${pct}` : `leg ·${pct || ' limits pending'} · next ${next} · board ${s.board_url ?? ''}`
169
+ return { text, limits, warn }
170
+ }