@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/usage.mjs
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// usage — per (agent, account) limit state and the handoff chooser.
|
|
2
|
+
// $BATON_HOME/usage/<agent>--<account>.json:
|
|
3
|
+
// { agent, account, five_hour: {pct, resets_at}|null, seven_day: {...}|null,
|
|
4
|
+
// limited_until: epoch-seconds|null, limited_reason, limited_at,
|
|
5
|
+
// source, observed_at, available_at, updated_at }
|
|
6
|
+
// Sources: claude statusline JSON (rate_limits.*) and StopFailure rate_limit;
|
|
7
|
+
// codex app-server/rollout rate limits (identified by window duration) and
|
|
8
|
+
// the usage-limit error; agy only the wall itself (no percent exposed).
|
|
9
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { home } from './store.mjs'
|
|
12
|
+
import { writeJsonAtomic, withFileLock } from './fsx.mjs'
|
|
13
|
+
import { AGENTS } from './sessions.mjs'
|
|
14
|
+
|
|
15
|
+
export const WARN_PCT = Number((process.env.LEG_WARN_PCT || process.env.BATON_WARN_PCT) || 85)
|
|
16
|
+
// A limit hit with no reset time from the agent: assume the 5-hour window.
|
|
17
|
+
const DEFAULT_LIMIT_S = 5 * 3600
|
|
18
|
+
|
|
19
|
+
export function usageDir() { return join(home(), 'usage') }
|
|
20
|
+
export function usageFile(agent, account = 'default') { return join(usageDir(), `${agent}--${account}.json`) }
|
|
21
|
+
|
|
22
|
+
export function readUsage(agent, account = 'default') {
|
|
23
|
+
const f = usageFile(agent, account)
|
|
24
|
+
if (!existsSync(f)) return emptyUsage(agent, account)
|
|
25
|
+
try { return { ...emptyUsage(agent, account), ...JSON.parse(readFileSync(f, 'utf8')) } } catch { return emptyUsage(agent, account) }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emptyUsage(agent, account) {
|
|
29
|
+
return { agent, account, five_hour: null, seven_day: null, limited_until: null, limited_reason: null, limited_at: null, source: null, observed_at: null, available_at: null, updated_at: null }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function listUsage() {
|
|
33
|
+
const dir = usageDir()
|
|
34
|
+
if (!existsSync(dir)) return []
|
|
35
|
+
return readdirSync(dir).filter((n) => n.endsWith('.json')).sort().map((n) => { try { return JSON.parse(readFileSync(join(dir, n), 'utf8')) } catch { return null } }).filter(Boolean)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function write(u) {
|
|
39
|
+
mkdirSync(usageDir(), { recursive: true })
|
|
40
|
+
writeJsonAtomic(usageFile(u.agent, u.account), { ...u, updated_at: new Date().toISOString() })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// read → mutate → write under one cross-process lock, so a percentage write
|
|
44
|
+
// from the poller/status-line never erases a wall a hook set in the same moment
|
|
45
|
+
// (that race handed the baton straight back to a walled login).
|
|
46
|
+
function mutate(agent, account, fn) {
|
|
47
|
+
mkdirSync(usageDir(), { recursive: true })
|
|
48
|
+
return withFileLock(usageFile(agent, account) + '.lock', () => {
|
|
49
|
+
const u = readUsage(agent, account)
|
|
50
|
+
const out = fn(u)
|
|
51
|
+
if (out === false) return u
|
|
52
|
+
const next = out ?? u
|
|
53
|
+
write(next)
|
|
54
|
+
return next
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// windows: { five_hour: {pct, resets_at}|null, seven_day: ... }
|
|
59
|
+
// `available` must be an explicit backend answer. Percentages cannot clear a
|
|
60
|
+
// wall: Codex's rate-limit schema says null availability is unknown, even when
|
|
61
|
+
// a window is below 100%.
|
|
62
|
+
export function recordUsage(agent, account, windows, source, { observed_at = new Date().toISOString(), available = null } = {}) {
|
|
63
|
+
let applied = false
|
|
64
|
+
const value = mutate(agent, account, (u) => {
|
|
65
|
+
const seenMs = Date.parse(observed_at)
|
|
66
|
+
const currentMs = Date.parse(u.observed_at ?? u.updated_at ?? 0)
|
|
67
|
+
if (Number.isFinite(seenMs) && Number.isFinite(currentMs) && seenMs < currentMs) return false
|
|
68
|
+
if (windows.five_hour !== undefined) u.five_hour = windows.five_hour
|
|
69
|
+
if (windows.seven_day !== undefined) u.seven_day = windows.seven_day
|
|
70
|
+
u.source = source
|
|
71
|
+
u.observed_at = Number.isFinite(seenMs) ? new Date(seenMs).toISOString() : new Date().toISOString()
|
|
72
|
+
applied = true
|
|
73
|
+
if (available === true) u.available_at = u.observed_at
|
|
74
|
+
// A window that has reset clears an old wall.
|
|
75
|
+
const nowS = Math.floor(Date.now() / 1000)
|
|
76
|
+
if (u.limited_until && u.limited_until <= nowS) { u.limited_until = null; u.limited_reason = null; u.limited_at = null }
|
|
77
|
+
const wallMs = Date.parse(u.limited_at ?? u.updated_at ?? 0)
|
|
78
|
+
if (u.limited_until && available === true && (!Number.isFinite(wallMs) || !Number.isFinite(seenMs) || seenMs >= wallMs)) {
|
|
79
|
+
u.limited_until = null
|
|
80
|
+
u.limited_reason = null
|
|
81
|
+
u.limited_at = null
|
|
82
|
+
} else if (available === false) {
|
|
83
|
+
const windows = [u.five_hour, u.seven_day].filter((w) => w && Number.isFinite(w.resets_at) && w.resets_at > nowS)
|
|
84
|
+
windows.sort((a, b) => (b.pct ?? 0) - (a.pct ?? 0))
|
|
85
|
+
u.limited_until = windows.length ? windows[0].resets_at : nowS + DEFAULT_LIMIT_S
|
|
86
|
+
u.limited_reason = 'usage_limit_exceeded'
|
|
87
|
+
u.limited_at = u.observed_at
|
|
88
|
+
}
|
|
89
|
+
return u
|
|
90
|
+
})
|
|
91
|
+
return { ...value, usage_applied: applied }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function markLimited(agent, account, { resets_at = null, reason = 'limit', source, observed_at = new Date().toISOString() } = {}) {
|
|
95
|
+
let applied = false
|
|
96
|
+
const value = mutate(agent, account, (u) => {
|
|
97
|
+
const seenMs = Date.parse(observed_at)
|
|
98
|
+
const currentMs = Math.max(Date.parse(u.limited_at ?? 0) || 0, Date.parse(u.available_at ?? 0) || 0)
|
|
99
|
+
if (Number.isFinite(seenMs) && Number.isFinite(currentMs) && seenMs < currentMs) return false
|
|
100
|
+
const nowS = Math.floor(Date.now() / 1000)
|
|
101
|
+
let until = Number.isFinite(resets_at) && resets_at > nowS ? resets_at : null
|
|
102
|
+
if (!until) {
|
|
103
|
+
// Prefer the window that actually walled (highest used %) over the soonest
|
|
104
|
+
// reset: a weekly wall (100% / days away) must not be recorded as the
|
|
105
|
+
// 5-hour window's near reset, or the account is handed back and re-walls.
|
|
106
|
+
const windows = [u.five_hour, u.seven_day].filter((w) => w && Number.isFinite(w.resets_at) && w.resets_at > nowS)
|
|
107
|
+
windows.sort((a, b) => (b.pct ?? 0) - (a.pct ?? 0))
|
|
108
|
+
until = windows.length ? windows[0].resets_at : nowS + DEFAULT_LIMIT_S
|
|
109
|
+
}
|
|
110
|
+
u.limited_until = until
|
|
111
|
+
u.limited_reason = reason
|
|
112
|
+
u.limited_at = Number.isFinite(seenMs) ? new Date(seenMs).toISOString() : new Date().toISOString()
|
|
113
|
+
u.observed_at = u.limited_at
|
|
114
|
+
if (source) u.source = source
|
|
115
|
+
applied = true
|
|
116
|
+
return u
|
|
117
|
+
})
|
|
118
|
+
return { ...value, wall_applied: applied }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function clearLimited(agent, account) {
|
|
122
|
+
return mutate(agent, account, (u) => { u.limited_until = null; u.limited_reason = null; u.limited_at = null; return u })
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function usageIsStale(u, nowMs = Date.now(), maxAgeMs = 5 * 60 * 1000) {
|
|
126
|
+
const observedMs = Date.parse(u?.observed_at ?? u?.updated_at ?? 0)
|
|
127
|
+
return !Number.isFinite(observedMs) || nowMs - observedMs > maxAgeMs
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function isAvailable(u, nowS = Math.floor(Date.now() / 1000)) {
|
|
131
|
+
return !(u.limited_until && u.limited_until > nowS)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Highest used percentage across the windows we know (for warnings).
|
|
135
|
+
export function pressure(u) {
|
|
136
|
+
const p = [u.five_hour?.pct, u.seven_day?.pct].filter((x) => Number.isFinite(x))
|
|
137
|
+
return p.length ? Math.max(...p) : null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Which window is closest to the wall, for the warning text.
|
|
141
|
+
export function hottest(u) {
|
|
142
|
+
const w = [['5h', u.five_hour], ['7d', u.seven_day]].filter(([, x]) => x && Number.isFinite(x.pct))
|
|
143
|
+
w.sort((a, b) => b[1].pct - a[1].pct)
|
|
144
|
+
return w.length ? { window: w[0][0], ...w[0][1] } : null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The chain after (agent, account): other accounts of the same agent first,
|
|
148
|
+
// then every other agent in the saved order. The order is an absolute priority
|
|
149
|
+
// list, not a rotation anchored on the current agent: an agent parked last
|
|
150
|
+
// stays last whichever agent the terminal started on (codex → claude → agy
|
|
151
|
+
// hands claude to codex, never to agy first).
|
|
152
|
+
// accounts: { claude: ['default', 'work'], codex: ['default'], agy: ['default'] }
|
|
153
|
+
export function candidates({ agent, account = 'default', accounts, order = AGENTS }) {
|
|
154
|
+
const out = []
|
|
155
|
+
for (const a of accounts[agent] ?? ['default']) if (a !== account) out.push({ agent, account: a })
|
|
156
|
+
for (const ag of order) if (ag !== agent) for (const a of accounts[ag] ?? ['default']) out.push({ agent: ag, account: a })
|
|
157
|
+
return out
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// → { next: {agent, account} | null, out: [{agent, account, resets_at}] sorted by reset }
|
|
161
|
+
export function chooseNext({ agent, account, accounts, installed, order = AGENTS, nowS = Math.floor(Date.now() / 1000) }) {
|
|
162
|
+
const out = []
|
|
163
|
+
for (const c of candidates({ agent, account, accounts, order })) {
|
|
164
|
+
if (installed && installed[c.agent] === false) continue
|
|
165
|
+
const u = readUsage(c.agent, c.account)
|
|
166
|
+
if (isAvailable(u, nowS)) return { next: c, out }
|
|
167
|
+
out.push({ ...c, resets_at: u.limited_until, reason: u.limited_reason })
|
|
168
|
+
}
|
|
169
|
+
out.sort((a, b) => (a.resets_at ?? Infinity) - (b.resets_at ?? Infinity))
|
|
170
|
+
return { next: null, out }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function fmtReset(epochS) {
|
|
174
|
+
if (!Number.isFinite(epochS)) return 'unknown'
|
|
175
|
+
const d = new Date(epochS * 1000)
|
|
176
|
+
const mins = Math.round((epochS * 1000 - Date.now()) / 60000)
|
|
177
|
+
const rel = mins < 60 ? `${Math.max(0, mins)}m` : mins < 48 * 60 ? `${Math.floor(mins / 60)}h${mins % 60}m` : `${Math.floor(mins / 1440)}d`
|
|
178
|
+
return `${d.toLocaleString()} (in ${rel})`
|
|
179
|
+
}
|
package/src/wait.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// wait — when every option is walled, the terminal stays open and counts down
|
|
2
|
+
// to the first reset instead of exiting. Pure timing here; src/attach.mjs
|
|
3
|
+
// wires the countdown line, the session record and the restart from the
|
|
4
|
+
// bundle around it.
|
|
5
|
+
export function fmtCountdown(secs) {
|
|
6
|
+
const s = Math.max(0, Math.floor(secs))
|
|
7
|
+
const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const r = s % 60
|
|
8
|
+
return h ? `${h}h${String(m).padStart(2, '0')}m${String(r).padStart(2, '0')}s` : m ? `${m}m${String(r).padStart(2, '0')}s` : `${r}s`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Resolve 'ready' once resetsAt (epoch seconds) has passed, 'cancelled' when
|
|
12
|
+
// the signal aborts or isCancelled() says so. onTick(remainingSecs) runs every
|
|
13
|
+
// tickMs and once at the start. A null resetsAt resolves 'ready' at once.
|
|
14
|
+
export function waitForReset({ resetsAt, tickMs = 1000, signal = null, isCancelled = () => false, onTick = () => {} } = {}) {
|
|
15
|
+
return new Promise((resolvePromise) => {
|
|
16
|
+
let timer = null
|
|
17
|
+
const finish = (r) => { if (timer) clearInterval(timer); signal?.removeEventListener?.('abort', onAbort); resolvePromise(r) }
|
|
18
|
+
const onAbort = () => finish('cancelled')
|
|
19
|
+
const check = () => {
|
|
20
|
+
if (signal?.aborted || isCancelled()) return finish('cancelled')
|
|
21
|
+
const remaining = Number.isFinite(resetsAt) ? resetsAt - Date.now() / 1000 : 0
|
|
22
|
+
if (remaining <= 0) return finish('ready')
|
|
23
|
+
onTick(remaining)
|
|
24
|
+
}
|
|
25
|
+
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
26
|
+
check()
|
|
27
|
+
// the interval is what keeps the process alive while it waits: never unref it
|
|
28
|
+
timer = setInterval(check, tickMs)
|
|
29
|
+
})
|
|
30
|
+
}
|
package/src/worktree.mjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// worktree — one git worktree per card under <repo>/.baton-worktrees/<cardId>,
|
|
2
|
+
// on branch baton/<cardId>. Every git call runs with MSYS_NO_PATHCONV=1 (see
|
|
3
|
+
// git-snapshot.mjs) so Git Bash on Windows never rewrites an absolute path
|
|
4
|
+
// argument. Never pushes; never deletes outside <repo>/.baton-worktrees/.
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
7
|
+
import { join, resolve, sep, isAbsolute } from 'node:path'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { canonPath, realPath } from './fsx.mjs'
|
|
10
|
+
|
|
11
|
+
function git(cwd, argv) {
|
|
12
|
+
return execFileSync('git', argv, { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// git reports long, real paths; callers may hand us short or symlinked ones
|
|
16
|
+
function samePath(a, b) {
|
|
17
|
+
return canonPath(a) === canonPath(b)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isUnder(child, parent) {
|
|
21
|
+
const c = canonPath(child)
|
|
22
|
+
const p = canonPath(parent)
|
|
23
|
+
return c === p || c.startsWith(p + sep)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function isNestedWorktreePath(resolved) {
|
|
27
|
+
const segments = resolved.split(sep)
|
|
28
|
+
return segments.some((s) => { const l = process.platform === 'win32' ? s.toLowerCase() : s; return l === '.leg-worktrees' || l === '.baton-worktrees'; })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function worktreePath(repo, cardId) {
|
|
32
|
+
// long real path: git reports worktrees that way, and the short 8.3 form a
|
|
33
|
+
// caller may pass must never become the stored worktree path
|
|
34
|
+
const root = realPath(repo); const oldPath = join(root, '.baton-worktrees', cardId); if (existsSync(oldPath)) return oldPath; return join(root, '.leg-worktrees', cardId);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function branchName(cardId) {
|
|
38
|
+
return `leg/${cardId}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function validateRepo(repo) {
|
|
42
|
+
const resolved = realPath(repo)
|
|
43
|
+
const legHome = resolve(process.env.LEG_HOME || process.env.BATON_HOME || (existsSync(join(homedir(), '.leg')) ? join(homedir(), '.leg') : existsSync(join(homedir(), '.baton')) ? join(homedir(), '.baton') : join(homedir(), '.leg')))
|
|
44
|
+
if (samePath(resolved, legHome)) {
|
|
45
|
+
throw new Error(`refusing to use LEG_HOME as a repo: ${resolved}`)
|
|
46
|
+
}
|
|
47
|
+
if (isNestedWorktreePath(resolved)) {
|
|
48
|
+
throw new Error('refusing a nested worktree path as a repo')
|
|
49
|
+
}
|
|
50
|
+
if (!existsSync(resolved) || !statSync(resolved).isDirectory()) {
|
|
51
|
+
throw new Error(`not a directory: ${resolved}`)
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
git(resolved, ['rev-parse', '--show-toplevel'])
|
|
55
|
+
} catch {
|
|
56
|
+
throw new Error(`not a git repo: ${resolved}`)
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
git(resolved, ['rev-parse', 'HEAD'])
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(`repo has no commits: ${resolved}`)
|
|
62
|
+
}
|
|
63
|
+
return resolved
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseWorktreeList(output) {
|
|
67
|
+
const entries = []
|
|
68
|
+
let current = null
|
|
69
|
+
for (const line of output.split(/\r?\n/)) {
|
|
70
|
+
if (line.startsWith('worktree ')) {
|
|
71
|
+
current = { path: resolve(line.slice('worktree '.length)), head: null, branch: null }
|
|
72
|
+
entries.push(current)
|
|
73
|
+
} else if (line.startsWith('HEAD ') && current) {
|
|
74
|
+
current.head = line.slice('HEAD '.length)
|
|
75
|
+
} else if (line.startsWith('branch ') && current) {
|
|
76
|
+
current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '')
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return entries
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function list(repo) {
|
|
83
|
+
const resolved = resolve(repo)
|
|
84
|
+
const out = git(resolved, ['worktree', 'list', '--porcelain'])
|
|
85
|
+
return parseWorktreeList(out)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Never committed by a landing: Baton's own directories, and the local state a
|
|
89
|
+
// DashClaw hook writes into every directory an agent runs in.
|
|
90
|
+
export function ensureExcludeEntries(repo) {
|
|
91
|
+
// a linked worktree or submodule has `.git` as a FILE, so the shared info dir
|
|
92
|
+
// must come from git, not from assuming <repo>/.git is a directory
|
|
93
|
+
let gitDir
|
|
94
|
+
try { gitDir = git(repo, ['rev-parse', '--git-common-dir']).trim() } catch { gitDir = join(repo, '.git') }
|
|
95
|
+
const infoDir = isAbsolute(gitDir) ? join(gitDir, 'info') : join(repo, gitDir, 'info')
|
|
96
|
+
mkdirSync(infoDir, { recursive: true })
|
|
97
|
+
const excludePath = join(infoDir, 'exclude')
|
|
98
|
+
// .env / .env.* so a Land never commits a secret the agent left in the
|
|
99
|
+
// worktree; the placeholder files are re-included, since `.env.*` otherwise
|
|
100
|
+
// swallows the .env.example an agent was asked to update (an exclude entry
|
|
101
|
+
// has no effect on a file the repo already tracks)
|
|
102
|
+
const needed = ['.leg-worktrees/', '.leg/', '.baton-worktrees/', '.baton/', '.context-handoffs/', '.dashclaw-local/', '.env', '.env.*', '!.env.example', '!.env.sample']
|
|
103
|
+
const content = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : ''
|
|
104
|
+
const lines = content.split(/\r?\n/)
|
|
105
|
+
const missing = needed.filter((n) => !lines.includes(n))
|
|
106
|
+
if (missing.length === 0) return
|
|
107
|
+
const joiner = content.length && !content.endsWith('\n') ? '\n' : ''
|
|
108
|
+
writeFileSync(excludePath, content + joiner + missing.join('\n') + '\n')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The branch this repo actually calls its trunk: origin's default if there is
|
|
112
|
+
// one, else whatever the root checkout is on.
|
|
113
|
+
function defaultBranch(repo) {
|
|
114
|
+
for (const argv of [['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], ['symbolic-ref', '--short', 'HEAD']]) {
|
|
115
|
+
try { return git(repo, argv).trim().replace(/^origin\//, '') } catch {}
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function ensure(repo, cardId, { trunk = 'main' } = {}) {
|
|
121
|
+
const resolvedRepo = validateRepo(repo)
|
|
122
|
+
const wtPath = worktreePath(resolvedRepo, cardId)
|
|
123
|
+
const branch = branchName(cardId)
|
|
124
|
+
|
|
125
|
+
const already = list(resolvedRepo).find((w) => samePath(w.path, wtPath))
|
|
126
|
+
if (already && existsSync(wtPath)) {
|
|
127
|
+
ensureExcludeEntries(resolvedRepo)
|
|
128
|
+
return { path: wtPath, branch, created: false }
|
|
129
|
+
}
|
|
130
|
+
// git still lists a worktree whose directory was deleted by hand; prune the
|
|
131
|
+
// stale admin entry so `worktree add` below does not refuse the path
|
|
132
|
+
if (already) git(resolvedRepo, ['worktree', 'prune'])
|
|
133
|
+
|
|
134
|
+
let branchExists = true
|
|
135
|
+
try {
|
|
136
|
+
git(resolvedRepo, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])
|
|
137
|
+
} catch {
|
|
138
|
+
branchExists = false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = { path: wtPath, branch, created: true }
|
|
142
|
+
if (branchExists) {
|
|
143
|
+
git(resolvedRepo, ['worktree', 'add', wtPath, branch])
|
|
144
|
+
} else {
|
|
145
|
+
// A card cut from HEAD because its trunk does not exist runs its whole
|
|
146
|
+
// agent chain and then bounces every land attempt until it fails: refuse
|
|
147
|
+
// here, while it has cost nothing, and name the branch this repo uses.
|
|
148
|
+
try {
|
|
149
|
+
git(resolvedRepo, ['rev-parse', '--verify', '--quiet', trunk])
|
|
150
|
+
} catch {
|
|
151
|
+
const actual = defaultBranch(resolvedRepo)
|
|
152
|
+
throw new Error(`trunk ${trunk} does not exist in ${resolvedRepo}${actual ? `; this repo's default branch is ${actual} (add the card with --trunk ${actual})` : ''}`)
|
|
153
|
+
}
|
|
154
|
+
git(resolvedRepo, ['worktree', 'add', '-b', branch, wtPath, trunk])
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
ensureExcludeEntries(resolvedRepo)
|
|
158
|
+
return result
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function remove(repo, cardId, { deleteBranch = false, force = false } = {}) {
|
|
162
|
+
const resolvedRepo = resolve(repo)
|
|
163
|
+
const wtPath = worktreePath(resolvedRepo, cardId)
|
|
164
|
+
const legWt = join(resolvedRepo, '.leg-worktrees'); const batonWt = join(resolvedRepo, '.baton-worktrees'); if (!isUnder(wtPath, legWt) && !isUnder(wtPath, batonWt)) { throw new Error(`refusing to remove a path outside .leg-worktrees: ${wtPath}`); }
|
|
165
|
+
|
|
166
|
+
const branch = branchName(cardId)
|
|
167
|
+
const already = list(resolvedRepo).find((w) => samePath(w.path, wtPath))
|
|
168
|
+
let removed = false
|
|
169
|
+
if (already) {
|
|
170
|
+
git(resolvedRepo, ['worktree', 'remove', ...(force ? ['--force'] : []), wtPath])
|
|
171
|
+
git(resolvedRepo, ['worktree', 'prune'])
|
|
172
|
+
removed = true
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// `git branch -d` refuses an unmerged branch; only escalate to `-D` (which
|
|
176
|
+
// discards commits) when the caller explicitly forces it. branchUnmerged
|
|
177
|
+
// tells the caller work would be lost.
|
|
178
|
+
let branchDeleted = false
|
|
179
|
+
let branchUnmerged = false
|
|
180
|
+
if (deleteBranch) {
|
|
181
|
+
try {
|
|
182
|
+
git(resolvedRepo, ['branch', '-d', branch])
|
|
183
|
+
branchDeleted = true
|
|
184
|
+
} catch {
|
|
185
|
+
branchUnmerged = true
|
|
186
|
+
if (force) { try { git(resolvedRepo, ['branch', '-D', branch]); branchDeleted = true } catch { branchDeleted = false } }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { removed, branchDeleted, branchUnmerged }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Uncommitted files in a card's worktree (empty when the worktree is clean or
|
|
194
|
+
// absent). Used by the board's Remove to refuse discarding agent work silently.
|
|
195
|
+
// A git that cannot answer throws: "status failed" must never reach that guard
|
|
196
|
+
// as "clean", which is the reading that force-deletes the directory.
|
|
197
|
+
export function worktreeDirty(repo, cardId) {
|
|
198
|
+
const wtPath = worktreePath(realPath(repo), cardId)
|
|
199
|
+
if (!existsSync(wtPath)) return []
|
|
200
|
+
return git(wtPath, ['status', '--porcelain']).split(/\r?\n/).filter(Boolean)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function isWorktreeOf(repo, cardId) {
|
|
204
|
+
const resolvedRepo = resolve(repo)
|
|
205
|
+
const wtPath = worktreePath(resolvedRepo, cardId)
|
|
206
|
+
return list(resolvedRepo).some((w) => samePath(w.path, wtPath))
|
|
207
|
+
}
|