@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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// codex tap — an interactive `codex` writes its whole thread to a rollout
|
|
2
|
+
// file, flushed per event (observed live 2026-09-11: file mtime == last line
|
|
3
|
+
// timestamp). Baton tails that file; no hook is injected, so codex never shows
|
|
4
|
+
// its "review new hooks" prompt for a Baton session.
|
|
5
|
+
// Shapes (observed live, codex-cli 0.153.4, originator codex-tui):
|
|
6
|
+
// session_meta.payload {id, cwd, originator, cli_version, source}
|
|
7
|
+
// event_msg.token_count.rate_limits {primary:{used_percent,window_minutes,resets_at}, secondary:{…}}
|
|
8
|
+
// primary = 300 min (5h), secondary = 10080 min (7d)
|
|
9
|
+
// event_msg.task_complete.error {message:"You've hit your usage limit…", codex_error_info:"usage_limit_exceeded"}
|
|
10
|
+
// response_item.message {role:'user'|'assistant', content:[{type:'input_text'|'output_text', text}]}
|
|
11
|
+
// Source of the error text: codex-rs/protocol/src/error.rs (UsageLimitReachedError).
|
|
12
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
13
|
+
import { existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync, fstatSync } from 'node:fs'
|
|
14
|
+
import { join } from 'node:path'
|
|
15
|
+
import { LAYOUT } from '../accounts.mjs'
|
|
16
|
+
import { sanitizeEnv } from '../env.mjs'
|
|
17
|
+
import codexAdapter from '../adapters/codex.mjs'
|
|
18
|
+
|
|
19
|
+
const LEG_VERSION = (() => { try { return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version } catch { return 'unknown' } })()
|
|
20
|
+
|
|
21
|
+
export function sessionsRootFor(codexHome = LAYOUT.codex.home()) { return join(codexHome, 'sessions') }
|
|
22
|
+
|
|
23
|
+
function sameDir(a, b) {
|
|
24
|
+
const n = (x) => String(x ?? '').replace(/\\/g, '/').replace(/\/$/, '').toLowerCase()
|
|
25
|
+
return n(a) === n(b)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const localDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
29
|
+
|
|
30
|
+
// Newest rollout created at or after `sinceMs` whose session_meta cwd is `cwd`.
|
|
31
|
+
export function findRollout({ codexHome, cwd, sinceMs, allowOlderMs = 5000 }) {
|
|
32
|
+
const root = sessionsRootFor(codexHome)
|
|
33
|
+
if (!existsSync(root)) return null
|
|
34
|
+
// codex names the day directory from LOCAL time while the rollout's own
|
|
35
|
+
// session_meta timestamp is UTC (observed live: a 20:33 EDT rollout under
|
|
36
|
+
// sessions/2026/09/10 stamped 2026-09-11T00:33:44Z). Compare local days, and
|
|
37
|
+
// keep the one before as slack around midnight.
|
|
38
|
+
const from = localDay(new Date(sinceMs - allowOlderMs - 86400000))
|
|
39
|
+
const days = []
|
|
40
|
+
for (const y of readdirSync(root)) for (const m of safeList(join(root, y))) for (const day of safeList(join(root, y, m))) {
|
|
41
|
+
if (`${y}-${m}-${day}` >= from) days.push(join(root, y, m, day))
|
|
42
|
+
}
|
|
43
|
+
const cands = []
|
|
44
|
+
for (const dir of days) for (const f of safeList(dir)) {
|
|
45
|
+
if (!f.startsWith('rollout-') || !f.endsWith('.jsonl')) continue
|
|
46
|
+
const p = join(dir, f)
|
|
47
|
+
let st
|
|
48
|
+
try { st = statSync(p) } catch { continue }
|
|
49
|
+
if (st.birthtimeMs && st.birthtimeMs < sinceMs - allowOlderMs && st.mtimeMs < sinceMs - allowOlderMs) continue
|
|
50
|
+
cands.push({ p, mtime: st.mtimeMs })
|
|
51
|
+
}
|
|
52
|
+
cands.sort((a, b) => b.mtime - a.mtime)
|
|
53
|
+
for (const c of cands) {
|
|
54
|
+
const meta = readMeta(c.p)
|
|
55
|
+
if (meta && sameDir(meta.cwd, cwd) && Date.parse(meta.timestamp ?? 0) >= sinceMs - allowOlderMs) return { path: c.p, meta }
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeList(dir) { try { return readdirSync(dir) } catch { return [] } }
|
|
61
|
+
|
|
62
|
+
export function readMeta(path) {
|
|
63
|
+
try {
|
|
64
|
+
const fd = openSync(path, 'r')
|
|
65
|
+
// session_meta carries the git snapshot and can run past 15 KB (seen live)
|
|
66
|
+
const buf = Buffer.alloc(Math.min(1048576, fstatSync(fd).size))
|
|
67
|
+
readSync(fd, buf, 0, buf.length, 0)
|
|
68
|
+
closeSync(fd)
|
|
69
|
+
const first = buf.toString('utf8').split('\n')[0]
|
|
70
|
+
const j = JSON.parse(first)
|
|
71
|
+
return j.type === 'session_meta' ? j.payload : null
|
|
72
|
+
} catch { return null }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Incremental reader: keeps a byte offset and a partial line. `from` skips
|
|
76
|
+
// what is already in the file (agy's log is per session, not per leg).
|
|
77
|
+
export function createTail(path, { from = 0 } = {}) {
|
|
78
|
+
let offset = from
|
|
79
|
+
let rest = ''
|
|
80
|
+
return {
|
|
81
|
+
path,
|
|
82
|
+
read() {
|
|
83
|
+
let st
|
|
84
|
+
try { st = statSync(path) } catch { return [] }
|
|
85
|
+
// truncated or replaced under us: read it again rather than go blind
|
|
86
|
+
if (st.size < offset) { offset = 0; rest = '' }
|
|
87
|
+
if (st.size <= offset) return []
|
|
88
|
+
const fd = openSync(path, 'r')
|
|
89
|
+
const buf = Buffer.alloc(st.size - offset)
|
|
90
|
+
readSync(fd, buf, 0, buf.length, offset)
|
|
91
|
+
closeSync(fd)
|
|
92
|
+
offset = st.size
|
|
93
|
+
const text = rest + buf.toString('utf8')
|
|
94
|
+
const lines = text.split('\n')
|
|
95
|
+
rest = lines.pop() ?? ''
|
|
96
|
+
return lines.filter(Boolean)
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const USAGE_LIMIT_RE = /hit your usage limit/i
|
|
102
|
+
const RETRY_AT_RE = /try again at ([^.]+?)(?:\.|$)/i
|
|
103
|
+
const PATCH_BLOCK_RE = /\*\*\* Begin Patch\n([\s\S]*?)\n\*\*\* End Patch/g
|
|
104
|
+
const PATCH_FILE_RE = /^\*\*\* (?:Add|Update|Delete) File: ([^\r\n]+)$/gm
|
|
105
|
+
|
|
106
|
+
function applyPatchArgument(args) {
|
|
107
|
+
const text = String(args ?? '')
|
|
108
|
+
if (text.startsWith('*** Begin Patch')) return text
|
|
109
|
+
const start = /tools\.apply_patch\(\s*(['"])/.exec(text)
|
|
110
|
+
if (!start) return text
|
|
111
|
+
const quote = start[1]
|
|
112
|
+
let value = ''
|
|
113
|
+
for (let i = start.index + start[0].length; i < text.length; i += 1) {
|
|
114
|
+
const char = text[i]
|
|
115
|
+
if (char === quote) return value
|
|
116
|
+
if (char !== '\\' || i + 1 === text.length) { value += char; continue }
|
|
117
|
+
const escaped = text[++i]
|
|
118
|
+
if (escaped === 'n') value += '\n'
|
|
119
|
+
else if (escaped === 'r') value += '\r'
|
|
120
|
+
else if (escaped === '\\' || escaped === quote) value += escaped
|
|
121
|
+
else value += `\\${escaped}`
|
|
122
|
+
}
|
|
123
|
+
return text
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function patchFiles(args) {
|
|
127
|
+
// A functions.exec payload can contain an apply_patch JavaScript string,
|
|
128
|
+
// where patch line breaks are serialized as literal "\\n". Decode only
|
|
129
|
+
// that string argument so direct patches retain literal Windows backslashes.
|
|
130
|
+
const patch = applyPatchArgument(args).replace(/\r\n/g, '\n')
|
|
131
|
+
const files = []
|
|
132
|
+
for (const block of patch.matchAll(PATCH_BLOCK_RE)) {
|
|
133
|
+
let match
|
|
134
|
+
while ((match = PATCH_FILE_RE.exec(block[1]))) files.push(match[1].trim())
|
|
135
|
+
}
|
|
136
|
+
return files
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Codex can expose either window as primary. The duration is the identity:
|
|
140
|
+
// current Pro Lite rollouts expose only primary=10080 (the weekly window).
|
|
141
|
+
export function normalizeRateLimits(rateLimits) {
|
|
142
|
+
const out = { five_hour: null, seven_day: null }
|
|
143
|
+
const entries = [rateLimits?.primary, rateLimits?.secondary]
|
|
144
|
+
for (const raw of entries) {
|
|
145
|
+
if (!raw) continue
|
|
146
|
+
const pct = Number(raw.used_percent ?? raw.usedPercent)
|
|
147
|
+
if (!Number.isFinite(pct)) continue
|
|
148
|
+
const minutes = Number(raw.window_minutes ?? raw.windowDurationMins)
|
|
149
|
+
const window = { pct, resets_at: raw.resets_at ?? raw.resetsAt ?? null, window_minutes: Number.isFinite(minutes) ? minutes : null }
|
|
150
|
+
if (minutes === 300) out.five_hour = window
|
|
151
|
+
else if (minutes === 10080) out.seven_day = window
|
|
152
|
+
// An absent or unfamiliar duration stays unknown. Primary is a transport
|
|
153
|
+
// position, not a promise that this is the 5-hour bucket.
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function parseRetryAt(msg) {
|
|
159
|
+
const m = RETRY_AT_RE.exec(msg ?? '')
|
|
160
|
+
if (!m) return null
|
|
161
|
+
// "Sep 14th, 2026 9:35 PM" → strip the ordinal suffix
|
|
162
|
+
const t = Date.parse(m[1].replace(/(\d+)(st|nd|rd|th)/, '$1'))
|
|
163
|
+
return Number.isFinite(t) ? Math.floor(t / 1000) : null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// lines → { limits, limit, messages, turnsDone, taskStarted, threadId }
|
|
167
|
+
export function parseLines(lines) {
|
|
168
|
+
const out = { limits: null, limits_at: null, limit: null, messages: [], turnsDone: 0, taskStarted: 0, threadId: null, files: [] }
|
|
169
|
+
for (const line of lines) {
|
|
170
|
+
let j
|
|
171
|
+
try { j = JSON.parse(line) } catch { continue }
|
|
172
|
+
const p = j.payload ?? {}
|
|
173
|
+
if (j.type === 'session_meta') { out.threadId = p.id ?? null; continue }
|
|
174
|
+
if (j.type === 'event_msg') {
|
|
175
|
+
if (p.type === 'token_count' && p.rate_limits) {
|
|
176
|
+
out.limits = normalizeRateLimits(p.rate_limits)
|
|
177
|
+
out.limits_at = j.timestamp ?? null
|
|
178
|
+
} else if (p.type === 'task_started') out.taskStarted += 1
|
|
179
|
+
else if (p.type === 'task_complete') {
|
|
180
|
+
out.turnsDone += 1
|
|
181
|
+
if (p.last_agent_message) out.messages.push({ role: 'assistant', text: String(p.last_agent_message).slice(0, 1500), ts: j.timestamp ?? null })
|
|
182
|
+
const err = p.error
|
|
183
|
+
if (err && (err.codex_error_info === 'usage_limit_exceeded' || USAGE_LIMIT_RE.test(err.message ?? ''))) {
|
|
184
|
+
out.limit = { reason: 'usage_limit_exceeded', detail: String(err.message ?? '').slice(0, 300), resets_at: parseRetryAt(err.message), observed_at: j.timestamp ?? null, raw: j }
|
|
185
|
+
}
|
|
186
|
+
} else if (p.type === 'error' && USAGE_LIMIT_RE.test(p.message ?? '')) {
|
|
187
|
+
out.limit = { reason: 'usage_limit_exceeded', detail: String(p.message).slice(0, 300), resets_at: parseRetryAt(p.message), observed_at: j.timestamp ?? null, raw: j }
|
|
188
|
+
}
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if (j.type === 'response_item') {
|
|
192
|
+
if (p.type === 'message' && Array.isArray(p.content)) {
|
|
193
|
+
const text = p.content.filter((c) => (c.type === 'input_text' || c.type === 'output_text') && c.text).map((c) => c.text).join('\n').trim()
|
|
194
|
+
// skip the injected context (<environment_context>, and the "# AGENTS.md instructions" block codex
|
|
195
|
+
// prepends to a thread: observed live 2026-09-11 as the first user message of every rollout)
|
|
196
|
+
if (text && !/^<[a-z_-]+>/i.test(text) && !/^# AGENTS\.md instructions/i.test(text)) out.messages.push({ role: p.role === 'user' ? 'user' : 'assistant', text: text.slice(0, 1500), ts: j.timestamp ?? null })
|
|
197
|
+
} else if (p.type === 'function_call' || p.type === 'custom_tool_call') {
|
|
198
|
+
const args = String(p.arguments ?? p.input ?? '')
|
|
199
|
+
out.files.push(...patchFiles(args))
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Read the account quota without starting a model turn. This protocol and the
|
|
207
|
+
// `ordinaryUsageAllowed` tri-state come from `codex app-server
|
|
208
|
+
// generate-json-schema --experimental`; null means unavailable and must not be
|
|
209
|
+
// inferred from percentages.
|
|
210
|
+
export function readCodexUsage({ codexHome = LAYOUT.codex.home(), timeoutMs = 8000, signal = null } = {}) {
|
|
211
|
+
return new Promise((resolvePromise) => {
|
|
212
|
+
let child
|
|
213
|
+
let settled = false
|
|
214
|
+
let stdout = ''
|
|
215
|
+
const killProbe = () => {
|
|
216
|
+
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return
|
|
217
|
+
if (process.platform === 'win32') spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
|
218
|
+
else { try { process.kill(-child.pid, 'SIGTERM') } catch { try { child.kill() } catch {} } }
|
|
219
|
+
}
|
|
220
|
+
const finish = (result, { kill = false } = {}) => {
|
|
221
|
+
if (settled) return
|
|
222
|
+
settled = true
|
|
223
|
+
clearTimeout(timer)
|
|
224
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
225
|
+
if (kill) killProbe()
|
|
226
|
+
else {
|
|
227
|
+
try { child?.stdin.end() } catch {}
|
|
228
|
+
const cleanup = setTimeout(killProbe, 1000)
|
|
229
|
+
cleanup.unref?.()
|
|
230
|
+
child?.once('exit', () => clearTimeout(cleanup))
|
|
231
|
+
}
|
|
232
|
+
resolvePromise(result)
|
|
233
|
+
}
|
|
234
|
+
const stop = (error) => {
|
|
235
|
+
finish({ ok: false, limits: null, available: null, observed_at: null, error }, { kill: true })
|
|
236
|
+
}
|
|
237
|
+
const onAbort = () => stop('codex usage read cancelled')
|
|
238
|
+
const timer = setTimeout(() => stop('codex usage read timed out'), timeoutMs)
|
|
239
|
+
timer.unref?.()
|
|
240
|
+
if (signal?.aborted) return onAbort()
|
|
241
|
+
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
242
|
+
try {
|
|
243
|
+
const { bin, viaNode, entry } = codexAdapter.resolve()
|
|
244
|
+
const args = [...(viaNode ? [entry ?? bin] : []), 'app-server', '--listen', 'stdio://']
|
|
245
|
+
const env = { ...sanitizeEnv(process.env, { interactive: true }), CODEX_HOME: codexHome }
|
|
246
|
+
child = spawn(viaNode ? process.execPath : bin, args, { env, windowsHide: true, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] })
|
|
247
|
+
child.stderr.resume()
|
|
248
|
+
} catch { return stop('codex app-server failed to start') }
|
|
249
|
+
child.on('error', () => stop('codex app-server failed to start'))
|
|
250
|
+
child.stdin.on('error', () => stop('codex app-server input closed'))
|
|
251
|
+
child.on('exit', (code) => { if (!settled) finish({ ok: false, limits: null, available: null, observed_at: null, error: `codex app-server exited (${code ?? 'unknown'})` }) })
|
|
252
|
+
child.stdout.on('data', (chunk) => {
|
|
253
|
+
stdout += chunk.toString('utf8')
|
|
254
|
+
const lines = stdout.split('\n')
|
|
255
|
+
stdout = lines.pop() ?? ''
|
|
256
|
+
for (const line of lines) {
|
|
257
|
+
let message
|
|
258
|
+
try { message = JSON.parse(line) } catch { continue }
|
|
259
|
+
if (message.id === 1 && message.result) {
|
|
260
|
+
try {
|
|
261
|
+
child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`)
|
|
262
|
+
child.stdin.write(`${JSON.stringify({ id: 2, method: 'account/rateLimits/read', params: { excludeResetCreditDetails: true } })}\n`)
|
|
263
|
+
} catch { return stop('codex app-server input closed') }
|
|
264
|
+
} else if (message.id === 2) {
|
|
265
|
+
if (message.error || !message.result) return stop('codex rate-limit read failed')
|
|
266
|
+
const snapshot = message.result.rateLimitsByLimitId?.codex ?? message.result.rateLimits
|
|
267
|
+
const limits = normalizeRateLimits(snapshot)
|
|
268
|
+
const available = typeof message.result.ordinaryUsageAllowed === 'boolean' ? message.result.ordinaryUsageAllowed : null
|
|
269
|
+
return finish({ ok: available !== null || Boolean(limits.five_hour || limits.seven_day), limits, available, observed_at: new Date().toISOString(), error: null })
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
})
|
|
273
|
+
try { child.stdin.write(`${JSON.stringify({ id: 1, method: 'initialize', params: { clientInfo: { name: 'leg', version: LEG_VERSION }, capabilities: { experimentalApi: true } } })}\n`) } catch { stop('codex app-server input closed') }
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function firstPrompt(path) {
|
|
278
|
+
if (!path || !existsSync(path)) return null
|
|
279
|
+
const r = parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean))
|
|
280
|
+
return r.messages.find((m) => m.role === 'user')?.text.slice(0, 500) ?? null
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function transcriptTail(path, limit = 8) {
|
|
284
|
+
if (!path || !existsSync(path)) return []
|
|
285
|
+
return parseLines(readFileSync(path, 'utf8').split('\n').filter(Boolean)).messages.slice(-limit)
|
|
286
|
+
}
|
package/src/trust.mjs
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// trust — records the folder-trust answer each agent CLI asks for on its first
|
|
2
|
+
// run in a directory, before Baton spawns that agent.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: the handoff is the product. When claude hits its 5-hour
|
|
5
|
+
// limit at 3am, Baton writes the bundle and starts codex in the same terminal
|
|
6
|
+
// with nobody there. If the incoming agent has never run in that folder it
|
|
7
|
+
// stops on a full-screen "Is this a project you trust?" prompt and waits for a
|
|
8
|
+
// keypress that is not coming, and the handoff the user paid for silently
|
|
9
|
+
// becomes a stalled terminal they find in the morning.
|
|
10
|
+
//
|
|
11
|
+
// Each CLI already stores that answer in a file, so the fix is to write the
|
|
12
|
+
// same answer the user would have clicked, for the repository they already
|
|
13
|
+
// chose by typing `baton claude` in it:
|
|
14
|
+
//
|
|
15
|
+
// claude ~/.claude.json projects["<repo>"].hasTrustDialogAccepted
|
|
16
|
+
// (or $CLAUDE_CONFIG_DIR/.claude.json)
|
|
17
|
+
// codex ~/.codex/config.toml [projects."<repo>"] trust_level = "trusted"
|
|
18
|
+
// agy ~/.gemini/trustedFolders.json "<repo>": "TRUST_FOLDER"
|
|
19
|
+
//
|
|
20
|
+
// For claude this is the documented remedy: its own permissions docs say to
|
|
21
|
+
// "set projects[<path>].hasTrustDialogAccepted to true in ~/.claude.json, where
|
|
22
|
+
// <path> is the repository root", and its error text prints the same sentence.
|
|
23
|
+
//
|
|
24
|
+
// Three rules this module holds to, because it writes files Baton does not own:
|
|
25
|
+
// 1. Never create a config file that is not already there. A missing file
|
|
26
|
+
// means that CLI has never run here, so its own first-run flow (login,
|
|
27
|
+
// onboarding) is about to happen with the user present anyway. Skip.
|
|
28
|
+
// 2. Never rewrite a file to say what it already says. No write, no risk.
|
|
29
|
+
// 3. Never lose what is already in the file. Read, add, write atomically,
|
|
30
|
+
// under the same cross-process lock the rest of Baton uses.
|
|
31
|
+
//
|
|
32
|
+
// BATON_TRUST=never turns all of it off; the prompts come back.
|
|
33
|
+
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs'
|
|
34
|
+
import { homedir } from 'node:os'
|
|
35
|
+
import { join, resolve, dirname, parse, isAbsolute } from 'node:path'
|
|
36
|
+
import { realPath, withFileLock, writeJsonAtomic } from './fsx.mjs'
|
|
37
|
+
|
|
38
|
+
// The repository root is the unit of trust: claude walks up from the working
|
|
39
|
+
// directory to the repo root looking for the flag, so one record covers every
|
|
40
|
+
// worktree Baton creates under <repo>/.baton-worktrees/ as well.
|
|
41
|
+
export function repoRootOf(dir) {
|
|
42
|
+
let at = realPath(dir)
|
|
43
|
+
const stop = parse(at).root
|
|
44
|
+
while (true) {
|
|
45
|
+
if (existsSync(join(at, '.git'))) return at
|
|
46
|
+
const up = dirname(at)
|
|
47
|
+
if (up === at || at === stop) return realPath(dir)
|
|
48
|
+
at = up
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function trustPolicy(env = process.env) {
|
|
53
|
+
const raw = String(env.LEG_TRUST ?? env.BATON_TRUST ?? 'auto').trim().toLowerCase()
|
|
54
|
+
return raw === 'never' || raw === 'off' || raw === '0' ? 'never' : 'auto'
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- claude ----
|
|
58
|
+
|
|
59
|
+
// A path like `C:\cfg` is absolute on Windows and a plain relative filename
|
|
60
|
+
// everywhere else, so `resolve()` on a POSIX host prepends the process's own
|
|
61
|
+
// cwd and invents a directory that was never named. These functions describe a
|
|
62
|
+
// Windows agent's config, and they have to say the same thing whatever host
|
|
63
|
+
// they are asked on, so a drive-letter path is taken as already absolute.
|
|
64
|
+
const DRIVE_ABS = /^[A-Za-z]:[\\/]/
|
|
65
|
+
|
|
66
|
+
function absolutely(p) { return DRIVE_ABS.test(p) ? p : resolve(p) }
|
|
67
|
+
|
|
68
|
+
export function claudeConfigFile(env = process.env) {
|
|
69
|
+
const dir = env.CLAUDE_CONFIG_DIR ? absolutely(env.CLAUDE_CONFIG_DIR) : homedir()
|
|
70
|
+
return join(dir, '.claude.json')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Claude Code stores a project under a normalized spelling of its path, and on
|
|
74
|
+
// Windows that spelling is forward slashes with an upper-case drive letter and
|
|
75
|
+
// no trailing separator: 100 of the 104 entries in a real ~/.claude.json are in
|
|
76
|
+
// that form, and the four that are not are older duplicates of projects that
|
|
77
|
+
// also appear in the new form. Write the wrong spelling and the entry is simply
|
|
78
|
+
// ignored: the flag is on file, the prompt still appears, and nothing says why.
|
|
79
|
+
export function claudeProjectKey(repo) {
|
|
80
|
+
// realPath() resolves symlinks and 8.3 names against the host filesystem,
|
|
81
|
+
// which is the right thing for a path that lives on it and meaningless for a
|
|
82
|
+
// drive-letter path on a POSIX host: there it silently becomes cwd + the
|
|
83
|
+
// literal. A drive-letter path is already the canonical spelling, so it is
|
|
84
|
+
// normalized directly.
|
|
85
|
+
const raw = String(repo)
|
|
86
|
+
let p = (DRIVE_ABS.test(raw) ? raw : realPath(raw)).replace(/\\/g, '/')
|
|
87
|
+
if (/^[a-z]:/.test(p)) p = p[0].toUpperCase() + p.slice(1)
|
|
88
|
+
return p.length > 3 ? p.replace(/\/+$/, '') : p
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The canonical key, plus the spellings Claude Code used in older versions.
|
|
92
|
+
// Older spellings are updated when they are already in the file and never
|
|
93
|
+
// created, so Baton corrects a stale entry without littering the config.
|
|
94
|
+
export function projectKeys(repo, existing = {}) {
|
|
95
|
+
const key = claudeProjectKey(repo)
|
|
96
|
+
const out = [key]
|
|
97
|
+
for (const v of [key.normalize('NFC'), realPath(repo), resolve(repo)]) {
|
|
98
|
+
if (!out.includes(v) && Object.prototype.hasOwnProperty.call(existing, v)) out.push(v)
|
|
99
|
+
}
|
|
100
|
+
// The older spelling is the same path written with Windows separators, so it
|
|
101
|
+
// is recognised by its spelling rather than by asking the host to resolve it:
|
|
102
|
+
// matching on the exact string only worked on a host where a backslash is a
|
|
103
|
+
// separator, and the entry Baton was meant to correct was left stale
|
|
104
|
+
// anywhere else. Nothing is created here — an entry is only ever brought up
|
|
105
|
+
// to date when it is already in the file.
|
|
106
|
+
for (const k of Object.keys(existing)) {
|
|
107
|
+
if (!out.includes(k) && k.includes('\\') && claudeProjectKey(k.replace(/\\/g, '/')) === key) out.push(k)
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A project-level CLAUDE.md that imports a file outside the working directory
|
|
113
|
+
// is what triggers the second prompt ("Allow external CLAUDE.md file imports?").
|
|
114
|
+
// Claude Code reads a CLAUDE.md from every ancestor directory of the working
|
|
115
|
+
// directory, so an import in C:\Projects\CLAUDE.md is external to a session in
|
|
116
|
+
// C:\Projects\some-repo. Returns the resolved paths, so Baton can print exactly
|
|
117
|
+
// what it approved instead of approving something invisible.
|
|
118
|
+
export function externalImports(cwd) {
|
|
119
|
+
const start = realPath(cwd)
|
|
120
|
+
const stop = parse(start).root
|
|
121
|
+
const dirs = []
|
|
122
|
+
for (let at = start; ; at = dirname(at)) {
|
|
123
|
+
dirs.push(at)
|
|
124
|
+
if (at === stop || dirname(at) === at) break
|
|
125
|
+
}
|
|
126
|
+
const home = realPath(homedir())
|
|
127
|
+
const out = []
|
|
128
|
+
for (const dir of dirs) {
|
|
129
|
+
// ~/.claude/CLAUDE.md is user memory, not project memory: its imports never
|
|
130
|
+
// raise the dialog, so they are not ours to approve either.
|
|
131
|
+
if (dir === home) continue
|
|
132
|
+
const file = join(dir, 'CLAUDE.md')
|
|
133
|
+
if (!existsSync(file)) continue
|
|
134
|
+
let text
|
|
135
|
+
try { text = readFileSync(file, 'utf8') } catch { continue }
|
|
136
|
+
for (const line of text.split('\n')) {
|
|
137
|
+
const m = line.match(/^\s*@(\S+)\s*$/)
|
|
138
|
+
if (!m) continue
|
|
139
|
+
const target = isAbsolute(m[1]) ? resolve(m[1]) : resolve(dir, m[1])
|
|
140
|
+
if (inside(target, start) || out.includes(target)) continue
|
|
141
|
+
out.push(target)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function inside(target, root) {
|
|
148
|
+
const a = process.platform === 'win32' ? target.toLowerCase() : target
|
|
149
|
+
const b = process.platform === 'win32' ? root.toLowerCase() : root
|
|
150
|
+
return a === b || a.startsWith(b.endsWith('\\') || b.endsWith('/') ? b : b + (b.includes('\\') ? '\\' : '/'))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readJson(file) {
|
|
154
|
+
try { return JSON.parse(readFileSync(file, 'utf8')) } catch { return null }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function ensureClaudeTrust(repo, { env = process.env, cwd = repo } = {}) {
|
|
158
|
+
const file = claudeConfigFile(env)
|
|
159
|
+
const root = repoRootOf(repo)
|
|
160
|
+
// Rule 1: a config that is not there means claude has never run as this
|
|
161
|
+
// user. Its own onboarding is next, with a person at the keyboard.
|
|
162
|
+
if (!existsSync(file)) return { agent: 'claude', file, wrote: [], imports: [], skipped: 'no claude config yet' }
|
|
163
|
+
const imports = externalImports(cwd)
|
|
164
|
+
let wrote = []
|
|
165
|
+
withFileLock(`${file}.baton-lock`, () => {
|
|
166
|
+
const cfg = readJson(file)
|
|
167
|
+
// Rule 3: an unreadable or non-object config is not ours to repair.
|
|
168
|
+
if (!cfg || typeof cfg !== 'object') { wrote = null; return }
|
|
169
|
+
const projects = cfg.projects && typeof cfg.projects === 'object' ? cfg.projects : {}
|
|
170
|
+
const keys = projectKeys(root, projects)
|
|
171
|
+
// Rule 4, and the one that matters most: an answer already on file is the
|
|
172
|
+
// user's, including "no". Only an ABSENT key is an unanswered question.
|
|
173
|
+
// Treating "not true" as "not asked yet" would flip a deliberate refusal
|
|
174
|
+
// to trust a folder into trust, silently, on every run in that repo.
|
|
175
|
+
const said = (o, k) => Object.prototype.hasOwnProperty.call(o, k)
|
|
176
|
+
if (keys.some((k) => projects[k] && said(projects[k], 'hasTrustDialogAccepted') && projects[k].hasTrustDialogAccepted !== true)) {
|
|
177
|
+
wrote = 'declined'
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
const changed = []
|
|
181
|
+
for (const key of keys) {
|
|
182
|
+
const was = projects[key] && typeof projects[key] === 'object' ? projects[key] : {}
|
|
183
|
+
const next = { ...was }
|
|
184
|
+
const here = []
|
|
185
|
+
if (!said(was, 'hasTrustDialogAccepted')) { next.hasTrustDialogAccepted = true; here.push('hasTrustDialogAccepted') }
|
|
186
|
+
// The imports question is answered separately, and declining it is also
|
|
187
|
+
// an answer: WarningShown true with Approved false is a recorded "no".
|
|
188
|
+
if (imports.length && !said(was, 'hasClaudeMdExternalIncludesWarningShown') && !said(was, 'hasClaudeMdExternalIncludesApproved')) {
|
|
189
|
+
next.hasClaudeMdExternalIncludesApproved = true
|
|
190
|
+
next.hasClaudeMdExternalIncludesWarningShown = true
|
|
191
|
+
here.push('hasClaudeMdExternalIncludesApproved', 'hasClaudeMdExternalIncludesWarningShown')
|
|
192
|
+
}
|
|
193
|
+
if (!here.length) continue
|
|
194
|
+
projects[key] = next
|
|
195
|
+
changed.push(...here)
|
|
196
|
+
}
|
|
197
|
+
// Rule 2: nothing to say that the file does not already say.
|
|
198
|
+
if (!changed.length) return
|
|
199
|
+
writeJsonAtomic(file, { ...cfg, projects })
|
|
200
|
+
wrote = [...new Set(changed)]
|
|
201
|
+
})
|
|
202
|
+
if (wrote === null) return { agent: 'claude', file, wrote: [], imports, skipped: 'claude config is not readable json' }
|
|
203
|
+
if (wrote === 'declined') return { agent: 'claude', file, root, wrote: [], imports, skipped: 'you answered no for this folder; Baton leaves that answer alone' }
|
|
204
|
+
return { agent: 'claude', file, root, wrote, imports, skipped: null }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- codex ----
|
|
208
|
+
|
|
209
|
+
export function codexConfigFile(env = process.env) {
|
|
210
|
+
const dir = env.CODEX_HOME ? resolve(env.CODEX_HOME) : join(homedir(), '.codex')
|
|
211
|
+
return join(dir, 'config.toml')
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// TOML basic strings escape a backslash, which is every separator on Windows.
|
|
215
|
+
const tomlPath = (p) => p.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
216
|
+
|
|
217
|
+
export function ensureCodexTrust(repo, { env = process.env } = {}) {
|
|
218
|
+
const file = codexConfigFile(env)
|
|
219
|
+
const root = repoRootOf(repo)
|
|
220
|
+
if (!existsSync(file)) return { agent: 'codex', file, wrote: [], imports: [], skipped: 'no codex config yet' }
|
|
221
|
+
let wrote = []
|
|
222
|
+
withFileLock(`${file}.baton-lock`, () => {
|
|
223
|
+
let text
|
|
224
|
+
try { text = readFileSync(file, 'utf8') } catch { wrote = null; return }
|
|
225
|
+
if (codexTrustedPaths(text).some((p) => samePath(p, root))) return
|
|
226
|
+
const block = `\n[projects."${tomlPath(root)}"]\ntrust_level = "trusted"\n`
|
|
227
|
+
// Append only: a TOML table added at the end cannot change the meaning of
|
|
228
|
+
// a table above it, and Baton never reformats a file it did not write.
|
|
229
|
+
writeTextAtomic(file, text.endsWith('\n') ? text + block : text + '\n' + block)
|
|
230
|
+
wrote = ['trust_level']
|
|
231
|
+
})
|
|
232
|
+
if (wrote === null) return { agent: 'codex', file, wrote: [], imports: [], skipped: 'codex config is not readable' }
|
|
233
|
+
return { agent: 'codex', file, root, wrote, imports: [], skipped: null }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Every folder config.toml already names. Codex writes the same folder two
|
|
237
|
+
// ways, plain and in the \\?\ extended-length form, so both unescape to the
|
|
238
|
+
// path we are about to compare against.
|
|
239
|
+
export function codexTrustedPaths(text) {
|
|
240
|
+
const out = []
|
|
241
|
+
for (const m of text.matchAll(/^\s*\[projects\."((?:[^"\\]|\\.)*)"\]/gm)) {
|
|
242
|
+
const raw = m[1].replace(/\\(.)/g, '$1')
|
|
243
|
+
out.push(raw.replace(/^\\\\\?\\/, ''))
|
|
244
|
+
}
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function samePath(a, b) {
|
|
249
|
+
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function writeTextAtomic(file, text) {
|
|
253
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
254
|
+
writeFileSync(tmp, text)
|
|
255
|
+
for (let i = 0; i < 20; i++) {
|
|
256
|
+
try { renameSync(tmp, file); return } catch (err) {
|
|
257
|
+
// Windows holds a brief lock on a file another process has open; the
|
|
258
|
+
// same retry the rest of Baton uses rather than lose the config.
|
|
259
|
+
if (!['EPERM', 'EBUSY', 'EACCES', 'EEXIST'].includes(err.code)) { try { unlinkSync(tmp) } catch {} throw err }
|
|
260
|
+
const until = Date.now() + 25
|
|
261
|
+
while (Date.now() < until) { /* spin */ }
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
try { unlinkSync(tmp) } catch {}
|
|
265
|
+
writeFileSync(file, text)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---- agy ----
|
|
269
|
+
|
|
270
|
+
export function agyTrustFile(env = process.env) {
|
|
271
|
+
const dir = env.GEMINI_CONFIG_DIR ? resolve(env.GEMINI_CONFIG_DIR) : join(homedir(), '.gemini')
|
|
272
|
+
return join(dir, 'trustedFolders.json')
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function ensureAgyTrust(repo, { env = process.env } = {}) {
|
|
276
|
+
const file = agyTrustFile(env)
|
|
277
|
+
const root = repoRootOf(repo)
|
|
278
|
+
// agy keeps trust in its own small file rather than a big config, so an
|
|
279
|
+
// absent file next to an existing ~/.gemini is just an empty answer sheet.
|
|
280
|
+
if (!existsSync(dirname(file))) return { agent: 'agy', file, wrote: [], imports: [], skipped: 'no agy config yet' }
|
|
281
|
+
let wrote = []
|
|
282
|
+
withFileLock(`${file}.baton-lock`, () => {
|
|
283
|
+
const map = existsSync(file) ? readJson(file) : {}
|
|
284
|
+
if (map === null || typeof map !== 'object') { wrote = null; return }
|
|
285
|
+
// Rule 4: a folder already in this file has been answered, whatever the
|
|
286
|
+
// answer was. Only an absent key is an unanswered question; anything else
|
|
287
|
+
// (DO_NOT_TRUST included) is the user's decision and stays.
|
|
288
|
+
if (Object.prototype.hasOwnProperty.call(map, root)) { if (map[root] !== 'TRUST_FOLDER') wrote = 'declined'; return }
|
|
289
|
+
writeJsonAtomic(file, { ...map, [root]: 'TRUST_FOLDER' })
|
|
290
|
+
wrote = ['TRUST_FOLDER']
|
|
291
|
+
})
|
|
292
|
+
if (wrote === null) return { agent: 'agy', file, wrote: [], imports: [], skipped: 'agy trust file is not readable json' }
|
|
293
|
+
if (wrote === 'declined') return { agent: 'agy', file, root, wrote: [], imports: [], skipped: 'you answered no for this folder; Baton leaves that answer alone' }
|
|
294
|
+
return { agent: 'agy', file, root, wrote, imports: [], skipped: null }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---- the one entry point ----
|
|
298
|
+
|
|
299
|
+
const ENSURE = { claude: ensureClaudeTrust, codex: ensureCodexTrust, agy: ensureAgyTrust }
|
|
300
|
+
|
|
301
|
+
// Returns a result even when nothing was written, so the caller can say why.
|
|
302
|
+
export function ensureTrust(agent, repo, { env = process.env, cwd = repo } = {}) {
|
|
303
|
+
if (trustPolicy(env) === 'never') return { agent, wrote: [], imports: [], skipped: 'BATON_TRUST=never' }
|
|
304
|
+
const fn = ENSURE[agent]
|
|
305
|
+
if (!fn) return { agent, wrote: [], imports: [], skipped: `no trust record for ${agent}` }
|
|
306
|
+
try {
|
|
307
|
+
return fn(repo, { env, cwd })
|
|
308
|
+
} catch (err) {
|
|
309
|
+
// A trust record Baton could not write is a prompt the user will answer
|
|
310
|
+
// themselves. It is never a reason to fail the session.
|
|
311
|
+
return { agent, wrote: [], imports: [], skipped: `could not write the trust record: ${err.message}` }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// The line Baton prints. Null when there is nothing worth saying.
|
|
316
|
+
export function trustLine(result) {
|
|
317
|
+
if (!result || !result.wrote?.length) return null
|
|
318
|
+
const what = result.agent === 'claude' && result.imports?.length
|
|
319
|
+
? `trusted ${result.root} for claude, and allowed ${result.imports.length} external CLAUDE.md import${result.imports.length === 1 ? '' : 's'}: ${result.imports.join(', ')}`
|
|
320
|
+
: `trusted ${result.root} for ${result.agent}`
|
|
321
|
+
return `${what} (recorded in ${result.file}; BATON_TRUST=never turns this off)`
|
|
322
|
+
}
|
|
323
|
+
|