@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.
- package/CHANGELOG.md +560 -0
- package/LICENSE +58 -0
- package/NOTICE +4 -0
- package/README.md +558 -0
- package/bin/fake-agent.mjs +143 -0
- package/bin/leg.mjs +449 -0
- package/docs/DECISIONS.md +119 -0
- package/docs/DEMO.md +214 -0
- package/docs/DEVIATIONS.md +182 -0
- package/docs/ERRORS.md +326 -0
- package/docs/README.md +40 -0
- package/docs/REUSE.md +289 -0
- package/docs/ROADMAP-v2.md +55 -0
- package/docs/VOCABULARY.md +243 -0
- package/docs/adapters.md +300 -0
- package/docs/board-guide.md +579 -0
- package/docs/cli-contracts.md +401 -0
- package/docs/concepts.md +426 -0
- package/docs/configuration.md +220 -0
- package/docs/faq.md +206 -0
- package/docs/getting-started.md +251 -0
- package/docs/real-run.md +80 -0
- package/docs/review-2026-09-14.md +72 -0
- package/docs/screenshots/board-400px.png +0 -0
- package/docs/screenshots/board-details-open.png +0 -0
- package/docs/screenshots/board-done.png +0 -0
- package/docs/screenshots/board-drawer.png +0 -0
- package/docs/screenshots/board-empty.png +0 -0
- package/docs/screenshots/board-handoff.png +0 -0
- package/docs/screenshots/board-running.png +0 -0
- package/docs/screenshots/demo-1-claude-running.png +0 -0
- package/docs/screenshots/demo-2-limit-hit.png +0 -0
- package/docs/screenshots/demo-3-handoff-bundle.png +0 -0
- package/docs/screenshots/demo-4-codex-running.png +0 -0
- package/docs/screenshots/demo-5-done.png +0 -0
- package/docs/screenshots/floor-final-1280.png +0 -0
- package/docs/screenshots/floor-final-400.png +0 -0
- package/docs/screenshots/floor-landing.png +0 -0
- package/docs/screenshots/floor.png +0 -0
- package/docs/screenshots/share-guest-1280.png +0 -0
- package/docs/screenshots/share-owner-1280.png +0 -0
- package/docs/screenshots/terminals-1280.png +0 -0
- package/docs/screenshots/thumb-done.jpg +0 -0
- package/docs/screenshots/thumb-handoff-details.jpg +0 -0
- package/docs/screenshots/thumb-limit-hit.jpg +0 -0
- package/docs/screenshots/youtube-thumbnail.jpg +0 -0
- package/fixtures/limits/agy/agy-resource-exhausted.json +11 -0
- package/fixtures/limits/claude/claude-budget-limit.json +11 -0
- package/fixtures/limits/claude/claude-max-turns.json +11 -0
- package/fixtures/limits/claude/claude-model-limit.json +11 -0
- package/fixtures/limits/claude/claude-session-limit.json +11 -0
- package/fixtures/limits/claude/claude-weekly-limit.json +11 -0
- package/fixtures/limits/codex/codex-quota-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-rate-limit-exceeded.json +11 -0
- package/fixtures/limits/codex/codex-skills-notice.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit-reached.json +11 -0
- package/fixtures/limits/codex/codex-usage-limit.json +11 -0
- package/fixtures/limits/generic/generic-429.json +11 -0
- package/fixtures/limits/generic/generic-overloaded.json +11 -0
- package/fixtures/limits/generic/generic-quota.json +11 -0
- package/fixtures/limits/generic/generic-rate-limit.json +11 -0
- package/fixtures/limits/generic/generic-resource-exhausted.json +11 -0
- package/fixtures/limits/generic/generic-usage-limit.json +11 -0
- package/fixtures/limits/grok/grok-not-logged-in.json +11 -0
- package/fixtures/limits/negative/auth-source-set.json +11 -0
- package/fixtures/limits/negative/compile-error.json +11 -0
- package/fixtures/limits/negative/empty-stdout-exit-0.json +11 -0
- package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +1 -0
- package/fixtures/live/agy/cmd.txt +1 -0
- package/fixtures/live/agy/err.log +0 -0
- package/fixtures/live/agy/out.log +1 -0
- package/fixtures/live/agy/parsed.json +5 -0
- package/fixtures/live/agy/run.json +18 -0
- package/fixtures/live/agy/supervisor.log +2 -0
- package/fixtures/live/claude/cmd.txt +1 -0
- package/fixtures/live/claude/err.log +0 -0
- package/fixtures/live/claude/limit-rate_limit.json +21 -0
- package/fixtures/live/claude/out.log +1 -0
- package/fixtures/live/claude/parsed.json +11 -0
- package/fixtures/live/claude/run.json +18 -0
- package/fixtures/live/claude/supervisor.log +2 -0
- package/fixtures/live/codex/cmd.txt +1 -0
- package/fixtures/live/codex/err.log +1 -0
- package/fixtures/live/codex/last.md +2 -0
- package/fixtures/live/codex/limit-usage_limit_exceeded.json +25 -0
- package/fixtures/live/codex/out.log +8 -0
- package/fixtures/live/codex/parsed.json +15 -0
- package/fixtures/live/codex/run.json +18 -0
- package/fixtures/live/codex/supervisor.log +2 -0
- package/fixtures/live/grok/cmd.txt +1 -0
- package/fixtures/live/grok/err.log +32 -0
- package/fixtures/live/grok/out.log +7 -0
- package/fixtures/live/grok/parsed.json +5 -0
- package/fixtures/live/grok/run.json +18 -0
- package/fixtures/live/grok/supervisor.log +2 -0
- package/fixtures/verified.json +34 -0
- package/package.json +68 -0
- package/scripts/board-shots.mjs +69 -0
- package/scripts/build-docs-site.mjs +308 -0
- package/scripts/check-claims.mjs +150 -0
- package/scripts/clean-clone-check.sh +42 -0
- package/scripts/license-sign.mjs +23 -0
- package/scripts/limits-table.mjs +38 -0
- package/scripts/live-limits.mjs +36 -0
- package/scripts/privacy-check.mjs +63 -0
- package/scripts/probe.mjs +92 -0
- package/scripts/seed-fake-cards.mjs +34 -0
- package/scripts/seed-floor-board.mjs +69 -0
- package/scripts/seed-wes-board.mjs +91 -0
- package/scripts/stripe-setup.mjs +99 -0
- package/scripts/vercel-env.mjs +40 -0
- package/src/accounts.mjs +119 -0
- package/src/adapters/agy.mjs +56 -0
- package/src/adapters/claude.mjs +54 -0
- package/src/adapters/codex.mjs +73 -0
- package/src/adapters/common.mjs +32 -0
- package/src/adapters/fake.mjs +48 -0
- package/src/adapters/grok.mjs +51 -0
- package/src/adapters/index.mjs +27 -0
- package/src/adapters/resolve.mjs +40 -0
- package/src/attach.mjs +592 -0
- package/src/auth.mjs +85 -0
- package/src/board/board.css +543 -0
- package/src/board/board.js +1307 -0
- package/src/board/favicon.svg +1 -0
- package/src/board/floor.html +127 -0
- package/src/board/floor.js +670 -0
- package/src/board/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/src/board/fonts/OFL-azeret-mono.txt +94 -0
- package/src/board/fonts/atkinson-board.woff2 +0 -0
- package/src/board/fonts/azeret-board.woff2 +0 -0
- package/src/board/index.html +251 -0
- package/src/board/sessions.js +1451 -0
- package/src/bundle.mjs +103 -0
- package/src/cards.mjs +114 -0
- package/src/chain.mjs +264 -0
- package/src/commands.mjs +79 -0
- package/src/contract.mjs +73 -0
- package/src/env.mjs +19 -0
- package/src/fsx.mjs +72 -0
- package/src/git-snapshot.mjs +152 -0
- package/src/handoff.mjs +171 -0
- package/src/hook.mjs +49 -0
- package/src/land.mjs +106 -0
- package/src/launcher.mjs +272 -0
- package/src/leases.mjs +86 -0
- package/src/ledger.mjs +369 -0
- package/src/license.mjs +168 -0
- package/src/limits.mjs +97 -0
- package/src/live-capture.mjs +69 -0
- package/src/mergequeue.mjs +225 -0
- package/src/orchestrator.mjs +365 -0
- package/src/pipeline.mjs +100 -0
- package/src/preferences.mjs +47 -0
- package/src/presets.mjs +23 -0
- package/src/ratelimit.mjs +49 -0
- package/src/redact.mjs +47 -0
- package/src/resume.mjs +354 -0
- package/src/runner.mjs +438 -0
- package/src/scheduler.mjs +118 -0
- package/src/server.mjs +845 -0
- package/src/session-detail.mjs +129 -0
- package/src/sessions.mjs +202 -0
- package/src/share.mjs +163 -0
- package/src/stations/agent.mjs +42 -0
- package/src/stations/build.mjs +9 -0
- package/src/stations/human.mjs +5 -0
- package/src/stations/land.mjs +6 -0
- package/src/stations/plan.mjs +8 -0
- package/src/stations/pr.mjs +31 -0
- package/src/stations/review.mjs +8 -0
- package/src/stations/test.mjs +27 -0
- package/src/store.mjs +90 -0
- package/src/sync/dashclaw.mjs +70 -0
- package/src/sync/index.mjs +54 -0
- package/src/sync/workboard.mjs +54 -0
- package/src/taps/agy.mjs +64 -0
- package/src/taps/claude-usage.mjs +67 -0
- package/src/taps/claude.mjs +170 -0
- package/src/taps/codex.mjs +286 -0
- package/src/trust.mjs +323 -0
- package/src/usage.mjs +179 -0
- package/src/wait.mjs +30 -0
- 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
|
+
}
|
package/src/taps/agy.mjs
ADDED
|
@@ -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
|
+
}
|