@botbuddy/cli 1.34.0 → 1.34.2

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.
@@ -0,0 +1,725 @@
1
+ // BOT-803 / BOT-1021 / BOT-1023 — `bb usage sync`: enumerate local Claude Code
2
+ // and Codex transcripts, parse only what changed since each file's cursor into
3
+ // allowlisted envelopes, and POST them to `usage-ingest` in batches of ≤500.
4
+ //
5
+ // One code path serves every trigger (daily launchd sweep, lefthook pre-push,
6
+ // the cloud Stop hook, manual backfill). The server's idempotency key makes any
7
+ // overlap between triggers a no-op, and the local cursor makes a repeat run cheap.
8
+ //
9
+ // Output contract: exactly one JSON receipt line (suppressed by --quiet on
10
+ // success). It carries row COUNTS only — never paths, never prompt text, never
11
+ // token totals. Fail-open: any failure exits 0 unless --strict.
12
+ import { execFileSync } from "node:child_process";
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { readdir, stat } from "node:fs/promises";
15
+ import { homedir, hostname as systemHostname } from "node:os";
16
+ import { dirname, join, resolve, sep } from "node:path";
17
+ import { AGENT_BINDING_FILE, parseAgentBinding } from "../wait-profile.mjs";
18
+ import { createClaudeAdapter } from "./claude.mjs";
19
+ import { createCodexAdapter } from "./codex.mjs";
20
+ import { envelopeRefusal, hmac as hmacWith, loadHmacKey, normalizeRepo, privacySelfCheck } from "./envelope.mjs";
21
+ import { cursorPath, loadCursorState, readCompleteLines, saveCursorState } from "./cursor.mjs";
22
+
23
+ export const BATCH_SIZE = 500;
24
+ export const PROVIDERS = Object.freeze(["claude", "codex"]);
25
+ // The server's `ingestion_runs.trigger` values (BOT-1019).
26
+ export const TRIGGERS = Object.freeze(["manual", "pre_push", "daily", "cloud_stop", "cloud_session_start"]);
27
+ // A live file whose trailing message has no final record yet is held back until
28
+ // it has been quiet this long (or the trigger is the end-of-turn cloud Stop).
29
+ export const QUIESCENT_MS = 5 * 60 * 1000;
30
+ const DEFAULT_SINCE = "2d";
31
+ const DEFAULT_MAX_SECONDS = 60;
32
+ // Stop reading new data at this fraction of the budget, leaving the rest for the POST.
33
+ const PARSE_BUDGET_FRACTION = 0.7;
34
+ // flush() sentinel: the deadline passed before a batch could start (not an error).
35
+ const OUT_OF_TIME = Symbol("out_of_time");
36
+ const DEFAULT_SERVER_URL = "https://api.bot-buddy.ai/functions/v1/mcp-server";
37
+
38
+ export class UsageUsageError extends Error {}
39
+
40
+ export function parseDuration(text) {
41
+ const match = /^(\d+)\s*([smhdw])$/i.exec(String(text ?? "").trim());
42
+ if (!match) return null;
43
+ const unit = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[match[2].toLowerCase()];
44
+ return Number(match[1]) * unit * 1000;
45
+ }
46
+
47
+ export function parseSyncArgs(argv = []) {
48
+ const opts = {
49
+ since: DEFAULT_SINCE, sinceMs: parseDuration(DEFAULT_SINCE), provider: "all", dryRun: false, quiet: false,
50
+ file: null, maxSeconds: DEFAULT_MAX_SECONDS, strict: false, trigger: "manual", rescan: false, allTenants: false, help: false,
51
+ };
52
+ const value = (i, flag) => {
53
+ const v = argv[i + 1];
54
+ if (v === undefined || v.startsWith("--")) throw new UsageUsageError(`${flag} requires a value`);
55
+ return v;
56
+ };
57
+ for (let i = 0; i < argv.length; i += 1) {
58
+ const arg = argv[i];
59
+ if (arg === "--since") { opts.since = value(i, arg); opts.sinceMs = parseDuration(opts.since); i += 1; if (opts.sinceMs === null) throw new UsageUsageError("--since must look like 2d, 12h, 30m, 1w"); }
60
+ else if (arg === "--provider") { opts.provider = value(i, arg); i += 1; if (![...PROVIDERS, "all"].includes(opts.provider)) throw new UsageUsageError("--provider must be claude, codex, or all"); }
61
+ else if (arg === "--file") { opts.file = value(i, arg); i += 1; }
62
+ else if (arg === "--max-seconds") { opts.maxSeconds = Number(value(i, arg)); i += 1; if (!Number.isFinite(opts.maxSeconds) || opts.maxSeconds <= 0) throw new UsageUsageError("--max-seconds must be a positive number"); }
63
+ else if (arg === "--trigger") { opts.trigger = value(i, arg); i += 1; if (!TRIGGERS.includes(opts.trigger)) throw new UsageUsageError(`--trigger must be one of ${TRIGGERS.join(", ")}`); }
64
+ else if (arg === "--dry-run") opts.dryRun = true;
65
+ else if (arg === "--quiet") opts.quiet = true;
66
+ else if (arg === "--strict") opts.strict = true;
67
+ else if (arg === "--rescan") opts.rescan = true;
68
+ else if (arg === "--all-tenants") opts.allTenants = true;
69
+ else if (arg === "--help" || arg === "-h") opts.help = true;
70
+ else throw new UsageUsageError(`unknown option: ${arg}`);
71
+ }
72
+ return opts;
73
+ }
74
+
75
+ export function usageIngestEndpoint(serverUrl = DEFAULT_SERVER_URL) {
76
+ const url = new URL(serverUrl);
77
+ url.pathname = url.pathname.replace(/\/mcp-server\/?$/, "/usage-ingest");
78
+ return url.toString();
79
+ }
80
+
81
+ export function transcriptRoots(env = process.env, home = homedir()) {
82
+ return {
83
+ claude: join(env.CLAUDE_CONFIG_DIR || join(home, ".claude"), "projects"),
84
+ codex: join(env.CODEX_HOME || join(home, ".codex"), "sessions"),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Every *.jsonl under `root` (recursively — subagent transcripts live in
90
+ * subdirectories) modified since `cutoffMs`. `shouldStop` is checked before
91
+ * every directory read and stat, so a huge history or a slow filesystem cannot
92
+ * outlive the run's time budget (the pre-push lane runs with 5 s).
93
+ * @returns {Promise<{ files: string[], stopped: boolean }>}
94
+ */
95
+ export async function listTranscripts(root, cutoffMs, { shouldStop = () => false, keep = () => false } = {}) {
96
+ const out = [];
97
+ let stopped = false;
98
+ const halt = () => (stopped = stopped || shouldStop());
99
+ async function walk(dir) {
100
+ if (halt()) return;
101
+ let entries;
102
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
103
+ entries.sort((a, b) => a.name.localeCompare(b.name));
104
+ for (const entry of entries) {
105
+ if (halt()) return;
106
+ const path = join(dir, entry.name);
107
+ if (entry.isDirectory()) await walk(path);
108
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
109
+ try {
110
+ const info = await stat(path);
111
+ if (info.mtimeMs >= cutoffMs || keep(path, info)) out.push(path);
112
+ } catch { /* vanished between readdir and stat */ }
113
+ }
114
+ }
115
+ }
116
+ await walk(root);
117
+ return { files: out, stopped };
118
+ }
119
+
120
+ /** Which adapter owns a single `--file`: under a Codex root, or a rollout whose first record is session_meta. */
121
+ /** The adapter a path's location names (under a known root), else null. */
122
+ export function providerByPath(path, roots) {
123
+ const abs = resolve(path);
124
+ if (abs.startsWith(resolve(roots.codex) + sep)) return "codex";
125
+ if (abs.startsWith(resolve(roots.claude) + sep)) return "claude";
126
+ return null;
127
+ }
128
+
129
+ export function detectProvider(path, roots, firstLine = "") {
130
+ const abs = resolve(path);
131
+ if (abs.startsWith(resolve(roots.codex) + sep)) return "codex";
132
+ if (abs.startsWith(resolve(roots.claude) + sep)) return "claude";
133
+ try {
134
+ const record = JSON.parse(firstLine);
135
+ if (record?.type === "session_meta" && record.payload) return "codex";
136
+ } catch { /* fall through */ }
137
+ return "claude";
138
+ }
139
+
140
+ // Variables that relocate git away from `-C <cwd>` (mirrors scripts/lib/git-env.mjs,
141
+ // BOT-1802). A git hook — the pre-push lane runs inside lefthook — exports them
142
+ // for the pushing repo, which would otherwise answer for every transcript.
143
+ const GIT_REPOSITORY_ENV = Object.freeze([
144
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_COMMON_DIR", "GIT_DIR", "GIT_INDEX_FILE",
145
+ "GIT_OBJECT_DIRECTORY", "GIT_PREFIX", "GIT_WORK_TREE",
146
+ ]);
147
+
148
+ /** cwd → GitHub owner/name via the checkout's origin remote; cached, 1 s bounded, null on any failure. */
149
+ export function createRepoResolver({ exec = execFileSync, env = process.env } = {}) {
150
+ const gitEnv = { ...env };
151
+ for (const name of GIT_REPOSITORY_ENV) delete gitEnv[name];
152
+ const cache = new Map();
153
+ return (cwd) => {
154
+ if (cache.has(cwd)) return cache.get(cwd);
155
+ let repo = null;
156
+ try {
157
+ const url = exec("git", ["-C", cwd, "config", "--get", "remote.origin.url"], {
158
+ encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"], env: gitEnv,
159
+ });
160
+ repo = normalizeRepo(String(url).trim());
161
+ } catch { repo = null; }
162
+ cache.set(cwd, repo);
163
+ return repo;
164
+ };
165
+ }
166
+
167
+ /**
168
+ * cwd → the tenant bound to its checkout (BOT-1902 tenant routing), or null
169
+ * when unbound. The binding is the checkout's committed `.botbuddy-agent.json`:
170
+ * the working-tree file at the git toplevel, else (a missing or retired
171
+ * `{ profile }` file, e.g. a stale main checkout) the copy committed at
172
+ * origin/HEAD or origin/main. A deleted worktree resolves through its nearest
173
+ * existing ancestor. Synchronous and cached per cwd and per toplevel; every
174
+ * git child runs with the repository-location variables stripped.
175
+ */
176
+ export function createTenantResolver({ exec = execFileSync, env = process.env, exists = existsSync, read = readFileSync } = {}) {
177
+ const gitEnv = { ...env };
178
+ for (const name of GIT_REPOSITORY_ENV) delete gitEnv[name];
179
+ // `-c core.fsmonitor=false`: these checkouts are read, never trusted to run a
180
+ // configured fsmonitor hook (none of these commands needs the index anyway).
181
+ const git = (cwd, args) => String(exec("git", ["-c", "core.fsmonitor=false", "-C", cwd, ...args], {
182
+ encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"], env: gitEnv,
183
+ })).trim();
184
+ const tenantOf = (raw) => { try { return parseAgentBinding(raw).tenant; } catch { return null; } };
185
+ const byCwd = new Map();
186
+ const byTop = new Map();
187
+ const forTop = (top) => {
188
+ if (byTop.has(top)) return byTop.get(top);
189
+ let tenant = null;
190
+ try { tenant = tenantOf(read(join(top, AGENT_BINDING_FILE), "utf8")); } catch { tenant = null; }
191
+ for (const ref of ["origin/HEAD", "origin/main"]) {
192
+ if (tenant) break;
193
+ // cat-file: raw blob bytes — never a textconv/filter program the checkout configured.
194
+ try { tenant = tenantOf(git(top, ["cat-file", "blob", `${ref}:${AGENT_BINDING_FILE}`])); } catch { tenant = null; }
195
+ }
196
+ byTop.set(top, tenant);
197
+ return tenant;
198
+ };
199
+ return (cwd) => {
200
+ if (typeof cwd !== "string" || !cwd) return null;
201
+ if (byCwd.has(cwd)) return byCwd.get(cwd);
202
+ let dir = cwd;
203
+ while (dir && !exists(dir)) { const up = dirname(dir); dir = up === dir ? null : up; }
204
+ let tenant = null;
205
+ if (dir) {
206
+ let top = null;
207
+ try { top = git(dir, ["rev-parse", "--show-toplevel"]) || null; } catch { top = null; }
208
+ if (top) tenant = forTop(top);
209
+ }
210
+ byCwd.set(cwd, tenant);
211
+ return tenant;
212
+ };
213
+ }
214
+
215
+ const TENANT_SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
216
+
217
+ /**
218
+ * The tenant this run posts for: an explicit BOTBUDDY_TENANT, else the binding
219
+ * of the checkout the command runs in, else the login's sole tenant. null →
220
+ * the run fails closed (tenant_unresolved).
221
+ */
222
+ export async function defaultResolveTarget({ cwd, env, tenantFor }) {
223
+ const explicit = typeof env.BOTBUDDY_TENANT === "string" ? env.BOTBUDDY_TENANT.trim() : "";
224
+ if (explicit) return TENANT_SLUG_RE.test(explicit) ? explicit : null;
225
+ const bound = tenantFor(cwd);
226
+ if (bound) return bound;
227
+ try {
228
+ const { loadConfig, getConfig } = await import("../config.mjs");
229
+ await loadConfig({ warn: () => {} });
230
+ const tenants = Array.isArray(getConfig()?.token_tenants) ? getConfig().token_tenants.filter((t) => typeof t === "string" && t) : [];
231
+ return tenants.length === 1 ? tenants[0] : null;
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+
237
+ export function usageEnvironment(env = process.env) {
238
+ return env.CLAUDE_CODE_REMOTE === "true" ? "cloud" : "local";
239
+ }
240
+
241
+ /**
242
+ * The same credential `bb wait` uses: an exported/cached tier-3 session token,
243
+ * self-healed from the durable client key when the cache is stale; otherwise
244
+ * the durable owner key (pinned to a tenant) or MCP key.
245
+ */
246
+ export async function defaultResolveAuth({ cwd, env, target, preferOwner = false }, impl = {}) {
247
+ const resolveSession = impl.resolveSession ?? (async (args) => (await import("../agent-session.mjs")).resolveAgentSessionCredential(args));
248
+ const resolveCall = impl.resolveCall ?? (async (args) => (await import("../api.mjs")).resolveCallAuth(args));
249
+ // The durable client key's scope (BOT-1571): `user` reaches any member tenant
250
+ // by pin; `tenant` is SEALED to token_tenant_id and must never be re-pinned.
251
+ const ownerScope = impl.ownerScope ?? (async () => {
252
+ const { getConfig } = await import("../config.mjs");
253
+ const cfg = getConfig() ?? {};
254
+ return { tenantMode: cfg.token_tenant_mode === "tenant" ? "tenant" : "user", tenantId: typeof cfg.token_tenant_id === "string" ? cfg.token_tenant_id : null };
255
+ });
256
+ // usage-ingest reads every credential kind from `Authorization: Bearer` (BOT-1019).
257
+ const bearer = (token) => ({ Authorization: `Bearer ${token}` });
258
+ // --all-tenants pins each pass to its tenant with the multi-tenant client key;
259
+ // a worktree session belongs to one tenant, so it is not consulted then.
260
+ if (!preferOwner) try {
261
+ const session = await resolveSession({ argv: [], env, cwd, warn: () => {} });
262
+ if (session?.token) {
263
+ // A session token writes to ITS session's tenant. Only a cached or freshly
264
+ // minted worktree session has a known tenant; an exported/flag token does
265
+ // not, so it is reported unknown and the run refuses it (fail closed).
266
+ let credentialTenant = null;
267
+ if (session.source === "cache" || session.source === "minted") {
268
+ const { readAgentState } = await import("../agent-state.mjs");
269
+ credentialTenant = (await readAgentState(cwd).catch(() => null))?.tenant ?? null;
270
+ }
271
+ return { headers: bearer(session.token), tenant: null, credentialTenant, source: session.source };
272
+ }
273
+ } catch { /* no binding / no client key: try the durable credential */ }
274
+ const resolved = await resolveCall({ env, cwd });
275
+ if (resolved.error) return { error: resolved.error.error ?? "not_authenticated" };
276
+ // An MCP key's tenant is not knowable client-side: refused (fail closed).
277
+ if (resolved.auth["x-agent-api-key"]) return { headers: bearer(resolved.auth["x-agent-api-key"]), tenant: null, credentialTenant: null, source: resolved.source };
278
+ const scope = await ownerScope();
279
+ if (scope.tenantMode === "tenant") {
280
+ // Sealed: report ITS tenant, so a run for any other tenant refuses it.
281
+ return { headers: resolved.auth, tenant: null, credentialTenant: scope.tenantId, source: resolved.source };
282
+ }
283
+ // A user (client) key reaches several tenants: it names the run's tenant as
284
+ // the body `tenant` scope request; the server still verifies membership.
285
+ return { headers: resolved.auth, tenant: target, credentialTenant: target, source: resolved.source };
286
+ }
287
+
288
+ async function postBatch({ endpoint, headers, tenant, run, envelopes, fetchImpl, timeoutMs }) {
289
+ const sent = envelopes.length;
290
+ let response;
291
+ try {
292
+ response = await fetchImpl(endpoint, {
293
+ method: "POST",
294
+ headers: { "Content-Type": "application/json", ...headers },
295
+ body: JSON.stringify({ ...(tenant ? { tenant } : {}), run, records: envelopes }),
296
+ // Only the time actually left in the run (the caller never starts past the deadline).
297
+ signal: AbortSignal.timeout(Math.max(1, Math.trunc(timeoutMs))),
298
+ });
299
+ } catch (error) {
300
+ return { ok: false, error: error?.name === "TimeoutError" || error?.name === "AbortError" ? "timeout" : "transport" };
301
+ }
302
+ let body = null;
303
+ try { body = await response.json(); } catch { body = null; }
304
+ if (response.status < 200 || response.status >= 300) {
305
+ const code = typeof body?.error === "string" && /^[a-z0-9_.:-]{1,64}$/i.test(body.error) ? body.error : null;
306
+ return { ok: false, error: code ? `http_${response.status}:${code}` : `http_${response.status}` };
307
+ }
308
+ // A 2xx is a delivery only with a well-formed receipt: a truncated/non-JSON
309
+ // body or missing counters is a retryable protocol error, so the batch stays
310
+ // queued and no cursor advances past it.
311
+ const counted = (v) => Number.isFinite(v) && v >= 0;
312
+ if (!body || typeof body !== "object" || !["seen", "inserted", "deduped", "refused"].every((k) => counted(body[k]))) {
313
+ return { ok: false, error: "ack_malformed" };
314
+ }
315
+ // …and it must account for exactly this batch (BOT-1019: seen = records sent,
316
+ // inserted + superseded + deduped + refused = seen), or nothing is acknowledged.
317
+ const superseded = body.superseded === undefined ? 0 : body.superseded;
318
+ if (!counted(superseded) || body.seen !== sent || body.inserted + superseded + body.deduped + body.refused !== sent) {
319
+ return { ok: false, error: "ack_malformed" };
320
+ }
321
+ const n = (v) => (Number.isFinite(v) && v >= 0 ? Math.trunc(v) : 0);
322
+ // Per-row refusals (BOT-1019): only the reason CODE is kept, and only when it
323
+ // is a well-formed code — never an index-aligned value or free text.
324
+ const refusalReasons = {};
325
+ if (Array.isArray(body.refusals)) {
326
+ for (const r of body.refusals) {
327
+ const reason = typeof r?.reason === "string" && /^[a-z0-9_]{1,64}$/.test(r.reason) ? r.reason : null;
328
+ // Bounded: at most 16 distinct codes per batch, however the server answers.
329
+ if (reason && (reason in refusalReasons || Object.keys(refusalReasons).length < 16)) {
330
+ refusalReasons[reason] = (refusalReasons[reason] ?? 0) + 1;
331
+ }
332
+ }
333
+ }
334
+ return {
335
+ ok: true, allRefused: body.status === "failed", refusalReasons,
336
+ seen: n(body.seen), inserted: n(body.inserted), superseded: n(body.superseded), deduped: n(body.deduped), refused: n(body.refused),
337
+ run_id: typeof body.run_id === "string" && /^[A-Za-z0-9-]{1,64}$/.test(body.run_id) ? body.run_id : null,
338
+ };
339
+ }
340
+
341
+ /**
342
+ * Run one sync. Pure over its dependencies; the CLI wrapper handles printing,
343
+ * exit codes, and fail-open.
344
+ */
345
+ export async function syncUsage(opts, deps = {}) {
346
+ const {
347
+ env = process.env, home = homedir(), cwd = process.cwd(), now = () => Date.now(),
348
+ fetchImpl = fetch, resolveAuth = defaultResolveAuth, hostname = systemHostname,
349
+ roots = transcriptRoots(env, home), repoFor = createRepoResolver(), cliVersion = "0.0.0",
350
+ tenantFor = createTenantResolver({ env }), resolveTarget = defaultResolveTarget, readLines = readCompleteLines,
351
+ serverUrl = env.BOTBUDDY_SERVER_URL || DEFAULT_SERVER_URL, statePath = cursorPath(home),
352
+ } = deps;
353
+ const startedMs = now();
354
+ const deadlineMs = startedMs + opts.maxSeconds * 1000;
355
+ const parseDeadlineMs = startedMs + opts.maxSeconds * 1000 * PARSE_BUDGET_FRACTION;
356
+ const startedAt = new Date(startedMs).toISOString();
357
+ const receipt = {
358
+ schema_version: 1, command: "usage sync", outcome: "ok", trigger: opts.trigger, dry_run: opts.dryRun,
359
+ providers: [], files_scanned: 0, files_read: 0, envelopes: 0, batches: 0,
360
+ tenant: null, skipped_other_tenant: 0, skipped_unbound: 0,
361
+ seen: 0, inserted: 0, superseded: 0, deduped: 0, refused: 0, refusal_reasons: {}, batches_all_refused: 0,
362
+ refused_local: 0, parse_errors: 0,
363
+ held_open_messages: 0, cost_state_withheld: 0,
364
+ privacy: "pass", budget_exhausted: false, run_ids: [], started_at: startedAt, duration_ms: 0,
365
+ };
366
+ const finish = (outcome, error) => {
367
+ receipt.outcome = outcome;
368
+ if (error) receipt.error = error;
369
+ receipt.duration_ms = Math.max(0, now() - startedMs);
370
+ return receipt;
371
+ };
372
+
373
+ // Tenant routing (BOT-1902): the run posts only transcripts whose checkout is
374
+ // bound to ITS tenant; with no resolvable tenant it reads nothing (fail closed).
375
+ const target = await resolveTarget({ cwd, env, tenantFor });
376
+ if (!target) return finish("error", "tenant_unresolved");
377
+ receipt.tenant = target;
378
+
379
+ const key = await loadHmacKey({ home, create: !opts.dryRun });
380
+ const hmac = (value) => hmacWith(key, value);
381
+ const cursorKeyFor = (path) => hmac(`${target}\u0000${resolve(path)}`);
382
+ const environment = usageEnvironment(env);
383
+ const state = await loadCursorState(statePath);
384
+ const cursorUpdates = {};
385
+
386
+ // Resolve the work list: one --file, or every provider root.
387
+ let plan;
388
+ if (opts.file) {
389
+ const path = resolve(cwd, opts.file);
390
+ let firstLine = "";
391
+ // Only a regular file: opening a FIFO/device would block the run forever.
392
+ try { if (!(await stat(path)).isFile()) return finish("error", "file_unreadable"); } catch { return finish("error", "file_unreadable"); }
393
+ // Sniff the first record ONLY when neither --provider nor the path (under a
394
+ // known transcript root) names the adapter, and never past the run deadline.
395
+ const byPath = opts.provider === "all" ? providerByPath(path, roots) : opts.provider;
396
+ if (!byPath) {
397
+ try {
398
+ await readLines(path, 0, (line) => { if (!firstLine && line.trim()) firstLine = line; }, {
399
+ shouldStop: () => firstLine !== "" || now() >= parseDeadlineMs,
400
+ });
401
+ } catch { return finish("error", "file_unreadable"); }
402
+ }
403
+ const provider = byPath ?? detectProvider(path, roots, firstLine);
404
+ plan = [{ provider, files: [path] }];
405
+ } else {
406
+ const cutoff = startedMs - opts.sinceMs;
407
+ const wanted = opts.provider === "all" ? PROVIDERS : [opts.provider];
408
+ plan = [];
409
+ for (const provider of wanted) {
410
+ const listed = await listTranscripts(roots[provider], cutoff, {
411
+ shouldStop: () => now() >= parseDeadlineMs,
412
+ // A file this tenant has only partly read stays in scope whatever its
413
+ // mtime: its unread tail would otherwise be lost once it ages out.
414
+ keep: (path, info) => {
415
+ const tracked = state.files[cursorKeyFor(path)];
416
+ return Boolean(tracked && Number.isFinite(tracked.offset) && tracked.offset < info.size);
417
+ },
418
+ });
419
+ if (listed.stopped) receipt.budget_exhausted = true;
420
+ plan.push({ provider, files: listed.stopped ? [] : listed.files });
421
+ }
422
+ }
423
+
424
+ let auth = null;
425
+ const ensureAuth = async () => {
426
+ if (auth) return auth;
427
+ const remaining = Math.max(1, deadlineMs - now());
428
+ auth = await Promise.race([
429
+ resolveAuth({ cwd, env, target }),
430
+ new Promise((done) => setTimeout(() => done({ error: "auth_timeout" }), remaining).unref?.()),
431
+ ]);
432
+ if (!auth.error && auth.credentialTenant !== target) auth = { error: "credential_tenant_mismatch" };
433
+ return auth;
434
+ };
435
+
436
+ for (const { provider, files } of plan) {
437
+ if (!files.length) continue;
438
+ receipt.providers.push(provider);
439
+ receipt.files_scanned += files.length;
440
+ const adapter = provider === "codex"
441
+ ? createCodexAdapter({ hmac, environment, tenantFor, repoFor })
442
+ : createClaudeAdapter({ hmac, environment, repoFor, tenantFor });
443
+ const run = {
444
+ host: hostname(), trigger: opts.trigger, provider, cli_version: cliVersion,
445
+ parser_version: adapter.parserVersion, started_at: startedAt,
446
+ };
447
+
448
+ // Envelopes are queued with a global sequence; a file's cursor commits only
449
+ // once every envelope it produced has been accepted by the server.
450
+ let queue = [];
451
+ let postedSeq = 0;
452
+ let enqueuedSeq = 0;
453
+ const parsedFiles = []; // { cursorKey, entry, endSeq }
454
+
455
+ const commitDelivered = () => {
456
+ while (parsedFiles.length && parsedFiles[0].endSeq <= postedSeq) {
457
+ const done = parsedFiles.shift();
458
+ cursorUpdates[done.cursorKey] = done.entry;
459
+ }
460
+ };
461
+ const flush = async (count) => {
462
+ const batch = queue.slice(0, count);
463
+ const privacy = privacySelfCheck(batch);
464
+ if (!privacy.pass) {
465
+ receipt.privacy = "fail";
466
+ receipt.privacy_fields = privacy.violations.map((v) => v.field);
467
+ return "privacy_check_failed";
468
+ }
469
+ if (opts.dryRun) {
470
+ receipt.batches += 1;
471
+ } else {
472
+ // Never START a request once the run's deadline has passed: the rest is
473
+ // left for the next run (cursors only advance past delivered envelopes).
474
+ if (now() >= deadlineMs) {
475
+ receipt.budget_exhausted = true;
476
+ return OUT_OF_TIME;
477
+ }
478
+ const resolved = await ensureAuth();
479
+ if (resolved.error) return resolved.error;
480
+ // Credential resolution can itself take a while: re-check before the POST.
481
+ if (now() >= deadlineMs) {
482
+ receipt.budget_exhausted = true;
483
+ return OUT_OF_TIME;
484
+ }
485
+ const result = await postBatch({
486
+ endpoint: usageIngestEndpoint(serverUrl), tenant: resolved.tenant,
487
+ headers: resolved.headers, run, envelopes: batch, fetchImpl, timeoutMs: deadlineMs - now(),
488
+ });
489
+ if (!result.ok) return result.error;
490
+ receipt.batches += 1;
491
+ receipt.seen += result.seen;
492
+ receipt.inserted += result.inserted;
493
+ receipt.superseded += result.superseded;
494
+ receipt.deduped += result.deduped;
495
+ receipt.refused += result.refused;
496
+ // A refused row is final (the server will refuse it again): the cursor
497
+ // still advances past it, and the receipt names why.
498
+ if (result.allRefused) receipt.batches_all_refused += 1;
499
+ for (const [reason, count] of Object.entries(result.refusalReasons)) {
500
+ if (!(reason in receipt.refusal_reasons) && Object.keys(receipt.refusal_reasons).length >= 16) continue;
501
+ receipt.refusal_reasons[reason] = (receipt.refusal_reasons[reason] ?? 0) + count;
502
+ }
503
+ if (result.run_id && !receipt.run_ids.includes(result.run_id)) receipt.run_ids.push(result.run_id);
504
+ }
505
+ queue = queue.slice(count);
506
+ postedSeq += count;
507
+ commitDelivered();
508
+ return null;
509
+ };
510
+
511
+ for (const path of files) {
512
+ if (now() >= parseDeadlineMs) { receipt.budget_exhausted = true; break; }
513
+ let info;
514
+ try { info = await stat(path); } catch { continue; }
515
+ const sourceHmac = hmac(resolve(path));
516
+ // Cursors are per (tenant, file): a run skips other tenants' records, so a
517
+ // shared per-file cursor would consume them for every later tenant's run.
518
+ const cursorKey = cursorKeyFor(path);
519
+ const previous = state.files[cursorKey];
520
+ let fromOffset = 0;
521
+ let ctx = {};
522
+ let reset = false;
523
+ if (previous && !opts.rescan) {
524
+ if (info.size < previous.offset) reset = true; // rewritten/truncated: start over
525
+ else { fromOffset = previous.offset; ctx = { ...(previous.ctx ?? {}) }; }
526
+ if (!reset && info.size === previous.offset) continue; // nothing new
527
+ }
528
+ receipt.files_read += 1;
529
+ const parser = adapter.beginFile({ sourceHmac, ctx });
530
+ let result;
531
+ try {
532
+ result = await readLines(path, fromOffset, (line, offset) => {
533
+ if (!line.trim()) return;
534
+ let record;
535
+ try { record = JSON.parse(line); } catch { receipt.parse_errors += 1; return; }
536
+ parser.parseRecord(record, offset);
537
+ }, { shouldStop: () => now() >= parseDeadlineMs });
538
+ } catch {
539
+ continue; // unreadable file: skip, cursor unchanged
540
+ }
541
+ if (result.stopped) receipt.budget_exhausted = true;
542
+ receipt.parse_errors += result.oversized ?? 0;
543
+ const quiescent = !result.stopped && (opts.trigger === "cloud_stop" || now() - info.mtimeMs >= QUIESCENT_MS);
544
+ const { envelopes: parsed, holdOffset } = parser.finish({ quiescent });
545
+ // Never post another tenant's (or an unbound checkout's) usage under this
546
+ // credential: skip and count it; the cursor still advances past it.
547
+ const produced = [];
548
+ for (const envelope of parsed) {
549
+ // The server's allowlist, applied BEFORE anything leaves the machine: a
550
+ // row it would refuse (bad id / timestamp) is refused here instead.
551
+ if (envelopeRefusal(envelope)) { receipt.refused_local += 1; continue; }
552
+ if (envelope.route_tenant === target) produced.push(envelope);
553
+ else if (envelope.route_tenant) receipt.skipped_other_tenant += 1;
554
+ else receipt.skipped_unbound += 1;
555
+ }
556
+ receipt.refused_local += parser.stats.refused;
557
+ receipt.cost_state_withheld += parser.stats.withheld;
558
+ if (holdOffset !== null) receipt.held_open_messages += 1;
559
+ queue.push(...produced);
560
+ enqueuedSeq += produced.length;
561
+ receipt.envelopes += produced.length;
562
+ parsedFiles.push({
563
+ cursorKey,
564
+ endSeq: enqueuedSeq,
565
+ entry: {
566
+ provider, offset: holdOffset ?? result.endOffset, size: info.size, mtime_ms: Math.trunc(info.mtimeMs),
567
+ ctx, updated_at: new Date(now()).toISOString(), ...(reset || opts.rescan ? { reset: true } : {}),
568
+ },
569
+ });
570
+ while (queue.length >= BATCH_SIZE) {
571
+ const error = await flush(BATCH_SIZE);
572
+ if (error === OUT_OF_TIME) return finalize(null);
573
+ if (error) return finalize(error);
574
+ }
575
+ commitDelivered();
576
+ if (result.stopped) break;
577
+ }
578
+ while (queue.length > 0) {
579
+ const error = await flush(Math.min(BATCH_SIZE, queue.length));
580
+ if (error === OUT_OF_TIME) return finalize(null);
581
+ if (error) return finalize(error);
582
+ }
583
+ commitDelivered();
584
+ }
585
+ return finalize(null);
586
+
587
+ async function finalize(error) {
588
+ const outcome = error ? "error" : opts.dryRun ? "dry_run" : receipt.budget_exhausted ? "partial" : "ok";
589
+ finish(outcome, error);
590
+ if (!opts.dryRun) {
591
+ const runSummary = {
592
+ at: startedAt, trigger: receipt.trigger, outcome: receipt.outcome, providers: receipt.providers,
593
+ files_read: receipt.files_read, envelopes: receipt.envelopes, inserted: receipt.inserted, superseded: receipt.superseded,
594
+ deduped: receipt.deduped, refused: receipt.refused, duration_ms: receipt.duration_ms,
595
+ ...(receipt.error ? { error: receipt.error } : {}),
596
+ };
597
+ try {
598
+ await saveCursorState(statePath, { files: cursorUpdates, runs: [runSummary] });
599
+ } catch {
600
+ receipt.cursor_write = "failed";
601
+ }
602
+ }
603
+ return receipt;
604
+ }
605
+ }
606
+
607
+ /** The tenants a user-mode (multi-tenant) client key reaches, from `bb login`; [] otherwise. */
608
+ export async function defaultListTenants() {
609
+ try {
610
+ const { loadConfig, getConfig } = await import("../config.mjs");
611
+ await loadConfig({ warn: () => {} });
612
+ const cfg = getConfig() ?? {};
613
+ if (cfg.token_tenant_mode !== "user" || !Array.isArray(cfg.token_tenants)) return [];
614
+ return [...new Set(cfg.token_tenants.filter((t) => typeof t === "string" && TENANT_SLUG_RE.test(t)))];
615
+ } catch {
616
+ return [];
617
+ }
618
+ }
619
+
620
+ /**
621
+ * `--all-tenants` (the daily LaunchAgent): one routed pass per tenant the login
622
+ * reaches, each with the client key pinned to that tenant and its own cursors,
623
+ * so every workspace's transcripts are swept however many repos the machine
624
+ * holds (Codex #982 R6). Without a multi-tenant login it is a normal run.
625
+ */
626
+ export async function syncAllTenants(opts, deps = {}) {
627
+ const tenants = await (deps.listTenants ?? defaultListTenants)();
628
+ if (!tenants.length) return syncUsage(opts, deps);
629
+ const resolveAuth = deps.resolveAuth ?? defaultResolveAuth;
630
+ const now = deps.now ?? (() => Date.now());
631
+ // ONE budget for all passes: each gets only what is left, and a tenant whose
632
+ // turn comes after the budget is spent is reported, not started.
633
+ const deadlineMs = now() + opts.maxSeconds * 1000;
634
+ const perTenant = [];
635
+ for (const tenant of tenants) {
636
+ const left = (deadlineMs - now()) / 1000;
637
+ if (left < 1) {
638
+ perTenant.push({ tenant, outcome: "not_started", envelopes: 0, inserted: 0, deduped: 0, skipped_other_tenant: 0, skipped_unbound: 0 });
639
+ continue;
640
+ }
641
+ const passOpts = { ...opts, maxSeconds: Math.min(opts.maxSeconds, left) };
642
+ deps.onPass?.(passOpts);
643
+ perTenant.push(await syncUsage(passOpts, {
644
+ ...deps,
645
+ resolveTarget: async () => tenant,
646
+ resolveAuth: (args) => resolveAuth({ ...args, preferOwner: true }),
647
+ }));
648
+ }
649
+ const sum = (k) => perTenant.reduce((t, r) => t + (Number.isFinite(r[k]) ? r[k] : 0), 0);
650
+ const outcome = perTenant.some((r) => r.outcome === "error") ? "error"
651
+ : opts.dryRun ? "dry_run"
652
+ : perTenant.some((r) => r.outcome === "partial" || r.outcome === "not_started") ? "partial" : "ok";
653
+ return {
654
+ schema_version: 1, command: "usage sync", outcome, trigger: opts.trigger, dry_run: opts.dryRun, all_tenants: true,
655
+ envelopes: sum("envelopes"), inserted: sum("inserted"), deduped: sum("deduped"), superseded: sum("superseded"),
656
+ refused: sum("refused"), refused_local: sum("refused_local"), skipped_unbound: sum("skipped_unbound"),
657
+ per_tenant: perTenant.map((r) => ({
658
+ tenant: r.tenant, outcome: r.outcome, ...(r.error ? { error: r.error } : {}), envelopes: r.envelopes,
659
+ inserted: r.inserted, deduped: r.deduped, skipped_other_tenant: r.skipped_other_tenant, skipped_unbound: r.skipped_unbound,
660
+ })),
661
+ ...(outcome === "error" ? { error: perTenant.find((r) => r.outcome === "error")?.error ?? "sync_failed" } : {}),
662
+ };
663
+ }
664
+
665
+ /** `bb usage status` — the local view: how many files are tracked and the last runs. */
666
+ export async function usageStatus({ home = homedir(), statePath = cursorPath(home), limit = 5 } = {}) {
667
+ const state = await loadCursorState(statePath);
668
+ const byProvider = {};
669
+ for (const entry of Object.values(state.files)) {
670
+ const p = entry?.provider ?? "unknown";
671
+ byProvider[p] = (byProvider[p] ?? 0) + 1;
672
+ }
673
+ return {
674
+ schema_version: 1, command: "usage status",
675
+ files_tracked: Object.keys(state.files).length, files_by_provider: byProvider,
676
+ last_runs: state.runs.slice(-limit).reverse(),
677
+ };
678
+ }
679
+
680
+ export function usageHelp() {
681
+ return `bb usage sync [--since <dur>] [--provider claude|codex|all] [--file <transcript>]
682
+ [--trigger manual|pre_push|daily|cloud_stop|cloud_session_start] [--max-seconds <n>]
683
+ [--dry-run] [--quiet] [--rescan] [--strict] [--all-tenants]
684
+ Ingest local Claude Code / Codex token usage into BotBuddy. Reads only what
685
+ changed since the last run (per-file cursors in ~/.botbuddy), sends only
686
+ allowlisted counters and ids (no prompts, no paths), prints one JSON receipt.
687
+ Fails open (exit 0) unless --strict. Defaults: --since 2d, --max-seconds 60.
688
+ bb usage status
689
+ Show locally tracked transcripts and the last sync runs.`;
690
+ }
691
+
692
+ /**
693
+ * CLI entry: `bb usage <sync|status|help>`. Fail-open — every error becomes a
694
+ * receipt and exit 0, unless --strict (exit 1). Usage errors exit 2.
695
+ */
696
+ export async function runUsage(argv, {
697
+ log = (line) => console.log(line), errorLog = (line) => console.error(line), deps = {},
698
+ } = {}) {
699
+ const [sub, ...rest] = argv;
700
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") { log(usageHelp()); return 0; }
701
+ if (sub === "status") {
702
+ try { log(JSON.stringify(await usageStatus(deps))); } catch { log(JSON.stringify({ schema_version: 1, command: "usage status", outcome: "error", error: "status_unreadable" })); }
703
+ return 0;
704
+ }
705
+ if (sub !== "sync") { errorLog(`bb usage: unknown subcommand ${sub}. ${usageHelp().split("\n")[0]}`); return 2; }
706
+ let opts;
707
+ try {
708
+ opts = parseSyncArgs(rest);
709
+ } catch (error) {
710
+ if (!(error instanceof UsageUsageError)) throw error;
711
+ errorLog(`bb usage sync: ${error.message}`);
712
+ return 2;
713
+ }
714
+ if (opts.help) { log(usageHelp()); return 0; }
715
+ let receipt;
716
+ try {
717
+ receipt = opts.allTenants ? await syncAllTenants(opts, deps) : await syncUsage(opts, deps);
718
+ } catch (error) {
719
+ const code = typeof error?.code === "string" && /^[a-z0-9_]{1,64}$/i.test(error.code) ? error.code : "sync_failed";
720
+ receipt = { schema_version: 1, command: "usage sync", outcome: "error", trigger: opts.trigger, error: code };
721
+ }
722
+ const failed = receipt.outcome === "error";
723
+ if (!opts.quiet || failed) (failed && opts.quiet ? errorLog : log)(JSON.stringify(receipt));
724
+ return failed && opts.strict ? 1 : 0;
725
+ }