@botbuddy/cli 1.33.11 → 1.34.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.33.11",
3
+ "version": "1.34.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.mjs CHANGED
@@ -5,7 +5,7 @@ import { AGENT_KEY_RE } from "./agent-key.mjs";
5
5
  // BOT-1864: api.mjs ↔ agent-session.mjs is a deliberate import cycle — each side
6
6
  // only CALLS the other's functions at request time, never at module evaluation.
7
7
  import { resolveAgentSessionCredential } from "./agent-session.mjs";
8
- import { die, cyan, dim, yellow, prettyJson } from "./utils.mjs";
8
+ import { die, red, cyan, dim, yellow, prettyJson } from "./utils.mjs";
9
9
 
10
10
  // Same slug shape the server accepts on `?tenant=`.
11
11
  const TENANT_PIN_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
@@ -57,7 +57,7 @@ async function genericEndpoint({ tenant = null, resolvePin = resolveDefaultTenan
57
57
  // tenant-bound agent key (from `botbuddy profile setup`) is the fallback.
58
58
  // BOT-1864: the generic commands (`bb call`, resources, heartbeat/locks/tasks)
59
59
  // first honour this worktree's tier-3 session — see resolveCallAuth.
60
- async function authHeader(toolName = null, { resolveAuth = resolveCallAuth } = {}) {
60
+ async function authHeader(toolName = null, { resolveAuth = resolveCallAuth, exitCode = 1 } = {}) {
61
61
  const resolved = await resolveAuth({ toolName, preferSession: true });
62
62
  if (resolved.auth) {
63
63
  const expiresAt = resolved.ownerExpiresAt ?? null;
@@ -66,12 +66,15 @@ async function authHeader(toolName = null, { resolveAuth = resolveCallAuth } = {
66
66
  }
67
67
  return resolved;
68
68
  }
69
+ // BOT-1896: a missing/expired local credential is auth-class — callTool passes
70
+ // CALL_EXIT.AUTH so it never collapses into the generic exit 1.
71
+ const fail = (msg) => { console.error(`${red("✗")} ${msg}`); process.exit(exitCode); };
69
72
  const code = resolved.error?.error;
70
- if (code === "token_expired") die(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
73
+ if (code === "token_expired") fail(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
71
74
  if (code === "session_token_malformed") {
72
- die(`The agent session token has an unexpected shape (source: ${resolved.error.source ?? "unknown"}). Run: ${cyan("bb doctor --fix")} in this checkout.`);
75
+ fail(`The agent session token has an unexpected shape (source: ${resolved.error.source ?? "unknown"}). Run: ${cyan("bb doctor --fix")} in this checkout.`);
73
76
  }
74
- die(`Not authenticated. Run: ${cyan("botbuddy login")}`);
77
+ fail(`Not authenticated. Run: ${cyan("botbuddy login")}`);
75
78
  }
76
79
 
77
80
  // BOT-1864: a cached session the server rejects (revoked by a concurrent
@@ -96,39 +99,200 @@ async function requestEndpoint(resolved, { tenant, resolvePin }) {
96
99
  return SESSION_SOURCES.has(resolved?.source) ? toolEndpoint(tenant) : genericEndpoint({ tenant, resolvePin });
97
100
  }
98
101
 
99
- export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, resolveAuth = resolveCallAuth, log = (line) => console.log(line) } = {}) {
102
+ // BOT-1896: `botbuddy call` (and every command riding callTool) exits with a
103
+ // code that tells a tenant gate WHY it failed: AUTH when BotBuddy refused the
104
+ // credential (fail closed), TRANSPORT when BotBuddy could not be reached or
105
+ // answered with a gateway/5xx failure (the only case that may degrade to a
106
+ // local-only lock). The values reuse `botbuddy stack`'s EXIT table (stack.mjs:
107
+ // AUTH 3, BACKEND 5) — restated rather than imported because stack.mjs imports
108
+ // this module; bot1896-call-auth-exit.test.mjs pins them equal.
109
+ // ERROR (1) is the generic failure: a reachable server said something other than
110
+ // "ok" or "auth" (a proxy 404, a 429 page, …). A gate must fail closed on it —
111
+ // only TRANSPORT licenses a local-only fallback.
112
+ export const CALL_EXIT = Object.freeze({ OK: 0, ERROR: 1, AUTH: 3, TRANSPORT: 5 });
113
+
114
+ // BOT-1896: mirror of the server's AUTH_CLASS_ERROR_CODES
115
+ // (supabase/functions/_shared/lib/authDenial.ts), used when an older server
116
+ // sends a typed code without `error_class`. Pinned equal by the same test.
117
+ export const CALL_AUTH_ERROR_CODES = new Set([
118
+ "scope_denied",
119
+ "session_token_revoked",
120
+ "session_token_expired",
121
+ "session_token_scope",
122
+ "credential_unknown",
123
+ "credential_missing",
124
+ "MCP_REAUTH_TENANT_REQUIRED",
125
+ "MCP_TENANT_PIN_REQUIRED",
126
+ "MCP_TENANT_SESSION_MISMATCH",
127
+ "MCP_TENANT_MEMBERSHIP_REQUIRED",
128
+ "MCP_TENANT_PIN_UNSUPPORTED",
129
+ ]);
130
+
131
+ // BOT-1896: the codes the server's 401 gates emit when a request authenticated
132
+ // as nothing — a dead/absent credential, never a tenant refusal.
133
+ const UNAUTHENTICATED_CODES = new Set(["credential_unknown", "credential_missing", "session_token_revoked", "session_token_expired"]);
134
+
135
+ function typedCodeOf(fields) {
136
+ if (!fields || typeof fields !== "object") return null;
137
+ const raw = fields.error_code ?? fields.code;
138
+ return typeof raw === "string" && raw ? raw : null;
139
+ }
140
+
141
+ function isAuthClass(fields) {
142
+ return Boolean(fields && typeof fields === "object" &&
143
+ (fields.error_class === "auth" || CALL_AUTH_ERROR_CODES.has(typedCodeOf(fields))));
144
+ }
145
+
146
+ /**
147
+ * BOT-1896: classify one tools/call exchange. Pure — no I/O.
148
+ * kind "ok" — a normal result (printed as before, exit 0)
149
+ * kind "tool_error" — a non-auth isError result (printed as before, exit 0)
150
+ * kind "server_error" — a non-auth JSON-RPC error (die as before, exit 1)
151
+ * kind "auth" — BotBuddy refused the credential → receipt, CALL_EXIT.AUTH
152
+ * kind "transport" — no HTTP response, or a 5xx → receipt, CALL_EXIT.TRANSPORT
153
+ * kind "http_error" — any other HTTP failure (non-JSON or error-less 4xx,
154
+ * a non-JSON 2xx) → receipt, CALL_EXIT.ERROR (fail closed)
155
+ * The receipt carries the server's `error_code` and typed fields UNCHANGED. A
156
+ * 401/403 is auth even with no typed code (an older server), never transport.
157
+ */
158
+ export function classifyCallResponse({ status = null, data = null, transportError = null, parseError = null } = {}) {
159
+ if (transportError) {
160
+ return {
161
+ kind: "transport",
162
+ receipt: { ok: false, error_class: "transport", error_code: "transport_error", http_status: null, message: String(transportError?.message ?? transportError) },
163
+ };
164
+ }
165
+ const authStatus = status === 401 || status === 403;
166
+ if (parseError || !data || typeof data !== "object") {
167
+ if (authStatus) {
168
+ return { kind: "auth", receipt: { ok: false, error_class: "auth", error_code: "unauthorized", http_status: status, message: "BotBuddy refused the credential (no typed body)." } };
169
+ }
170
+ // Codex P1 (PR #970): only a 5xx gateway page reads as transport. A reachable
171
+ // endpoint answering non-JSON with anything else (a proxy 404 from a wrong
172
+ // URL, a 429 rate-limit page) must fail closed, never license a fallback.
173
+ return typeof status === "number" && status >= 500
174
+ ? { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "invalid_response", http_status: status, message: `BotBuddy returned a non-JSON response (HTTP ${status}).` } }
175
+ : httpError(status, `BotBuddy returned a non-JSON response (HTTP ${status ?? "?"}).`);
176
+ }
177
+ const rpcError = data.error && typeof data.error === "object" ? data.error : null;
178
+ if (rpcError) {
179
+ const detail = rpcError.data && typeof rpcError.data === "object" ? rpcError.data : {};
180
+ if (isAuthClass(detail) || authStatus || rpcError.code === -32001) {
181
+ return {
182
+ kind: "auth",
183
+ receipt: { ok: false, ...detail, error_class: "auth", error_code: typedCodeOf(detail) ?? "unauthorized", http_status: status, message: rpcError.message ?? null },
184
+ };
185
+ }
186
+ if (typeof status === "number" && status >= 500) {
187
+ return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: rpcError.message ?? null } };
188
+ }
189
+ return { kind: "server_error", receipt: null };
190
+ }
191
+ // A bare REST-style `{ error }` body (not JSON-RPC).
192
+ if (typeof data.error === "string") {
193
+ if (isAuthClass(data) || authStatus) {
194
+ return { kind: "auth", receipt: { ok: false, ...data, error_class: "auth", error_code: typedCodeOf(data) ?? "unauthorized", http_status: status, message: data.error } };
195
+ }
196
+ if (typeof status === "number" && status >= 500) {
197
+ return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: data.error } };
198
+ }
199
+ return { kind: "server_error", receipt: null };
200
+ }
201
+ if (authStatus) {
202
+ return { kind: "auth", receipt: { ok: false, error_class: "auth", error_code: "unauthorized", http_status: status, message: null } };
203
+ }
204
+ if (typeof status === "number" && status >= 500) {
205
+ return { kind: "transport", receipt: { ok: false, error_class: "transport", error_code: "server_unavailable", http_status: status, message: null } };
206
+ }
207
+ if (typeof status === "number" && status >= 400) {
208
+ return httpError(status, null);
209
+ }
210
+ if (data.result?.isError === true) {
211
+ const first = data.result?.content?.[0]?.text;
212
+ let envelope = null;
213
+ try { envelope = typeof first === "string" ? JSON.parse(first) : null; } catch { envelope = null; }
214
+ if (isAuthClass(envelope)) {
215
+ const { success: _success, ...fields } = envelope;
216
+ return { kind: "auth", receipt: { ok: false, ...fields, error_class: "auth", error_code: typedCodeOf(envelope), http_status: status } };
217
+ }
218
+ return { kind: "tool_error", receipt: null };
219
+ }
220
+ return { kind: "ok", receipt: null };
221
+ }
222
+
223
+ function httpError(status, message) {
224
+ return { kind: "http_error", receipt: { ok: false, error_class: "http", error_code: `http_${status ?? "unknown"}`, http_status: status, message } };
225
+ }
226
+
227
+ function authFailureLine(receipt) {
228
+ const what = receipt.error_code === "scope_denied" && receipt.tool && receipt.required_scope
229
+ ? `scope_denied: \`${receipt.tool}\` requires the \`${receipt.required_scope}\` scope${receipt.key_prefix ? ` (presented a ${receipt.key_prefix} key)` : ""}`
230
+ : receipt.error_code;
231
+ return `BotBuddy refused the credential (${what}).${receipt.remedy ? ` ${receipt.remedy}` : ""}`;
232
+ }
233
+
234
+ export async function callTool(toolName, args = {}, { fetchImpl = fetch, auth = null, tenant = null, resolvePin = resolveDefaultTenantPin, resolveAuth = resolveCallAuth, log = (line) => console.log(line), errorLog = (line) => console.error(line) } = {}) {
100
235
  const body = {
101
236
  jsonrpc: "2.0",
102
237
  id: 1,
103
238
  method: "tools/call",
104
239
  params: { name: toolName, arguments: args },
105
240
  };
241
+ // BOT-1896: never throw on the wire — a fetch rejection or a non-JSON body is
242
+ // returned as a classified transport failure instead of an uncaught exit 1.
106
243
  const send = async (resolved) => {
107
244
  const headers = { "Content-Type": "application/json", ...resolved.auth };
108
- const res = await fetchImpl(await requestEndpoint(resolved, { tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
109
- return { status: res.status, data: await res.json() };
245
+ let res;
246
+ try {
247
+ res = await fetchImpl(await requestEndpoint(resolved, { tenant, resolvePin }), { method: "POST", headers, body: JSON.stringify(body) });
248
+ } catch (transportError) {
249
+ return { status: null, data: null, transportError };
250
+ }
251
+ try {
252
+ return { status: res.status, data: await res.json() };
253
+ } catch (parseError) {
254
+ return { status: res.status, data: null, parseError };
255
+ }
110
256
  };
111
257
 
112
- const first = auth ? { auth, source: null } : await authHeader(toolName, { resolveAuth });
113
- let { status, data } = await send(first);
258
+ const first = auth ? { auth, source: null } : await authHeader(toolName, { resolveAuth, exitCode: CALL_EXIT.AUTH });
259
+ let exchange = await send(first);
114
260
  let staleSession = false;
115
- if (!auth && isRejectedCachedSessionResponse(first, status, data)) {
261
+ if (!auth && isRejectedCachedSessionResponse(first, exchange.status, exchange.data)) {
116
262
  const durable = await resolveAuth({ toolName, preferSession: false });
117
- if (durable.auth) ({ status, data } = await send(durable));
263
+ if (durable.auth) exchange = await send(durable);
118
264
  else staleSession = true;
119
265
  }
266
+ const { status, data } = exchange;
120
267
  const exportedRejected = !auth && (first.source === "env" || first.source === "flag")
121
268
  && (status === 401 || status === 403 || data?.error?.code === -32001);
122
269
 
270
+ const code = data?.error?.data?.code;
271
+ const hint = code === "MCP_TENANT_PIN_REQUIRED"
272
+ ? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
273
+ : staleSession
274
+ ? ` The cached agent session in this checkout was rejected; run ${cyan("bb doctor --fix")} here.`
275
+ : exportedRejected
276
+ ? ` The exported agent session token was rejected; unset ${cyan("BOTBUDDY_AGENT_SESSION_TOKEN")} (and its legacy aliases) to use this checkout's session or your login.`
277
+ : "";
278
+
279
+ const outcome = classifyCallResponse(exchange);
280
+ if (outcome.kind === "auth") {
281
+ log(JSON.stringify(outcome.receipt, null, 2));
282
+ errorLog(`${red("✗")} ${authFailureLine(outcome.receipt)}${hint}`);
283
+ return CALL_EXIT.AUTH;
284
+ }
285
+ if (outcome.kind === "transport") {
286
+ log(JSON.stringify(outcome.receipt, null, 2));
287
+ errorLog(`${red("✗")} BotBuddy unreachable (${outcome.receipt.error_code}${outcome.receipt.http_status ? `, HTTP ${outcome.receipt.http_status}` : ""}): ${outcome.receipt.message ?? ""}`);
288
+ return CALL_EXIT.TRANSPORT;
289
+ }
290
+ if (outcome.kind === "http_error") {
291
+ log(JSON.stringify(outcome.receipt, null, 2));
292
+ errorLog(`${red("✗")} BotBuddy answered HTTP ${outcome.receipt.http_status ?? "?"} (${outcome.receipt.error_code}); not treating this as unreachable.${hint}`);
293
+ return CALL_EXIT.ERROR;
294
+ }
123
295
  if (data.error?.message) {
124
- const code = data.error?.data?.code;
125
- const hint = code === "MCP_TENANT_PIN_REQUIRED"
126
- ? ` Pin a tenant: ${cyan("botbuddy call <tool> --pin <slug>")}, ${cyan("BOTBUDDY_TENANT=<slug>")}, or run from a repo with ${dim(".botbuddy-agent.json")}.`
127
- : staleSession
128
- ? ` The cached agent session in this checkout was rejected; run ${cyan("bb doctor --fix")} here.`
129
- : exportedRejected
130
- ? ` The exported agent session token was rejected; unset ${cyan("BOTBUDDY_AGENT_SESSION_TOKEN")} (and its legacy aliases) to use this checkout's session or your login.`
131
- : "";
132
296
  die(`Server error: ${data.error.message}${hint}`);
133
297
  }
134
298
 
@@ -260,13 +424,21 @@ export async function callToolJson(toolName, args = {}, { fetchImpl = fetch, sig
260
424
  // { error: { code: -32002, data: { code } } }). Surface that `code` so a
261
425
  // caller can tell a tenant refusal apart from a plain credential failure.
262
426
  // Best-effort: an opaque/bodyless 401/403 keeps the generic shape.
427
+ // BOT-1896: the unauthenticated 401 now carries a typed code too
428
+ // (credential_unknown / session_token_revoked / …). Those are exposed as
429
+ // `error_code` but NOT lifted into `code`, which callers (mcp-key,
430
+ // isRejectedCachedMcpSession) read as "a structured refusal other than a dead
431
+ // credential" — so a bad key keeps its not-authenticated / self-heal path.
263
432
  let code = null;
433
+ let errorCode = null;
264
434
  try {
265
435
  const body = await res.json();
266
436
  const raw = body?.error?.data?.code ?? body?.error?.code ?? body?.code ?? null;
267
- if (typeof raw === "string" && raw) code = raw;
437
+ if (typeof raw === "string" && raw && !UNAUTHENTICATED_CODES.has(raw)) code = raw;
438
+ const typed = body?.error?.data?.error_code ?? body?.error_code ?? null;
439
+ if (typeof typed === "string" && typed) errorCode = typed;
268
440
  } catch { /* opaque body */ }
269
- return { ok: false, auth: true, status: res.status, error: "unauthorized", ...(code ? { code } : {}) };
441
+ return { ok: false, auth: true, status: res.status, error: "unauthorized", ...(code ? { code } : {}), ...(errorCode ? { error_code: errorCode } : {}) };
270
442
  }
271
443
  let payload;
272
444
  try { payload = await res.json(); } catch { return { ok: false, status: res.status, error: "invalid_json" }; }
package/src/commands.mjs CHANGED
@@ -28,13 +28,16 @@ import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation
28
28
  import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
29
29
  import { doctorAgentAuth, doctorHelp, parseDoctorArgs } from "./agent-doctor.mjs";
30
30
  import { clearAgentState, selfHealAgentSession } from "./agent-session.mjs";
31
+ import { runUsage, usageStatus } from "./usage/sync.mjs";
32
+ import { installUsageAgent, usageAgentStatus } from "./usage/launchd.mjs";
31
33
 
32
34
  // BOT-1566 D2: the stale-CLI check runs for every command EXCEPT `wait` and any
33
35
  // invocation carrying `--json` — those are hot paths whose receipts (BOT-1229)
34
- // must not pay for a registry round-trip.
36
+ // must not pay for a registry round-trip. BOT-1023: nor may `usage`, which
37
+ // runs inside every `git push` under a 5 s budget.
35
38
  export function shouldCheckForUpdates(argv) {
36
39
  const [command] = argv;
37
- if (command === "wait" || command === "telemetry") return false;
40
+ if (command === "wait" || command === "telemetry" || command === "usage") return false;
38
41
  if (argv.includes("--json")) return false;
39
42
  return true;
40
43
  }
@@ -77,6 +80,16 @@ export async function run(argv, {
77
80
  case "docker": return cmdDocker(args);
78
81
  case "run": return cmdRun(args);
79
82
  case "telemetry": return cmdTelemetry(args);
83
+ // BOT-803: local Claude/Codex token usage → usage-ingest (fail-open).
84
+ case "usage": {
85
+ const code = await runUsage(args, { deps: { cliVersion: VERSION } });
86
+ // The run is bounded by --max-seconds, but a credential or socket call it
87
+ // abandoned at the deadline can keep the event loop alive for minutes
88
+ // (a pre-push lane would stall). An unref'd timer only fires when
89
+ // something is still lingering; a clean run exits on its own first.
90
+ setTimeout(() => process.exit(code), 250).unref();
91
+ return code;
92
+ }
80
93
  case "test": return cmdTest(args);
81
94
  case "wait": return runWait(args);
82
95
  case "pw": return runPw(args);
@@ -198,6 +211,11 @@ ${bold("DURABLE WORKLOADS")}
198
211
  telemetry <status|replay|doctor>
199
212
  Inspect, replay, or gate durable lifecycle delivery
200
213
 
214
+ ${bold("USAGE INGEST")}
215
+ usage sync [--since 2d] [--provider claude|codex|all] [--file <transcript>] [--dry-run] [--quiet]
216
+ Send local Claude/Codex token usage (counters only, no prompts)
217
+ usage status Last sync runs and tracked transcripts
218
+
201
219
  ${bold("AGENT WAITS")}
202
220
  wait [--any] <condition>... [options]
203
221
  Wait once for a pushed BotBuddy signal
@@ -216,13 +234,18 @@ ${bold("OTHER")}
216
234
  ${SETUP_BLOCK}`);
217
235
  }
218
236
 
219
- export async function cmdDoctor(args, { log = (line) => console.log(line), errorLog = (line) => console.error(line) } = {}) {
237
+ export async function cmdDoctor(args, {
238
+ log = (line) => console.log(line), errorLog = (line) => console.error(line),
239
+ doctor = doctorAgentAuth, usageReport = doctorUsageSync,
240
+ } = {}) {
220
241
  const parsed = parseDoctorArgs(args);
221
242
  if (parsed.help) { log(doctorHelp()); return 0; }
222
243
  if (parsed.errors.length) { errorLog(`bb doctor: ${parsed.errors.join("; ")}`); return 4; }
223
- const result = await doctorAgentAuth({ fix: parsed.fix, printExports: parsed.printExports });
244
+ const result = await doctor({ fix: parsed.fix, printExports: parsed.printExports });
245
+ // BOT-1021: informational only — never changes doctor's exit code.
246
+ const usage = await usageReport().catch(() => null);
224
247
  if (parsed.json) {
225
- log(JSON.stringify(result.report));
248
+ log(JSON.stringify(usage ? { ...result.report, usage_sync: usage } : result.report));
226
249
  } else {
227
250
  log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
228
251
  log(`client key: ${result.report.client_key.status}`);
@@ -233,11 +256,33 @@ export async function cmdDoctor(args, { log = (line) => console.log(line), error
233
256
  log(`stack lease: ${sl.capability} (host ${sl.host}, machine id ${sl.machine_id_readable ? "readable" : "unavailable"})${sl.reason ? ` — ${sl.reason}` : ""}`);
234
257
  }
235
258
  if (result.report.recovery) log(`recovery: ${result.report.recovery}`);
259
+ if (usage) log(formatUsageSyncLine(usage));
236
260
  }
237
261
  if (result.exports) log(result.exports);
238
262
  return result.exitCode;
239
263
  }
240
264
 
265
+ /** BOT-1021: is the daily sweep installed, and how did the last sync runs go? */
266
+ export async function doctorUsageSync({ agentStatus = usageAgentStatus, status = usageStatus } = {}) {
267
+ const agent = await agentStatus();
268
+ const { last_runs: runs = [] } = await status({ limit: 20 });
269
+ const pick = (run) => (run ? { at: run.at, outcome: run.outcome, ...(run.error ? { error: run.error } : {}) } : null);
270
+ return {
271
+ daily_agent: agent.supported ? (agent.installed ? "installed" : "not_installed") : "unsupported",
272
+ last_daily_run: pick(runs.find((run) => run.trigger === "daily")),
273
+ last_run: pick(runs[0]),
274
+ };
275
+ }
276
+
277
+ export function formatUsageSyncLine(usage) {
278
+ const last = usage.last_daily_run ?? usage.last_run;
279
+ const lastText = last ? `last ${usage.last_daily_run ? "daily " : ""}run ${last.outcome}${last.error ? ` (${last.error})` : ""} at ${last.at}` : "no runs yet";
280
+ const agentText = usage.daily_agent === "installed" ? "daily agent installed"
281
+ : usage.daily_agent === "unsupported" ? "daily agent n/a (launchd is macOS-only)"
282
+ : "daily agent NOT installed — run bb setup";
283
+ return `usage sync: ${agentText}; ${lastText}`;
284
+ }
285
+
241
286
  // ─── BOT-876: generic tool access ───────────────────────────────
242
287
 
243
288
  async function cmdCall(args) {
@@ -425,6 +470,7 @@ export async function cmdSetup(args, {
425
470
  clearState = clearAgentState,
426
471
  selfHeal = selfHealAgentSession,
427
472
  callerResolver = resolveCaller,
473
+ usageAgent = installUsageAgent,
428
474
  cwd = process.cwd(),
429
475
  env = process.env,
430
476
  log = (line) => console.log(line),
@@ -485,6 +531,7 @@ export async function cmdSetup(args, {
485
531
  return 5;
486
532
  }
487
533
  log(`bb setup: ${tenant} is ready (${session.source} worktree session). Run bb wait '<condition>'.`);
534
+ await reportUsageAgent(usageAgent, { cwd, log });
488
535
  return 0;
489
536
  } catch (error) {
490
537
  errorLog(`bb setup: could not prepare this worktree (${error?.code ?? "setup_failed"}). Retry bb setup after completing sign-in.`);
@@ -492,6 +539,17 @@ export async function cmdSetup(args, {
492
539
  }
493
540
  }
494
541
 
542
+ // BOT-1021: setup also (re)installs the daily usage sweep. Never fails setup —
543
+ // the worktree session is ready either way; the line says what happened.
544
+ async function reportUsageAgent(usageAgent, { cwd, log }) {
545
+ let result;
546
+ try { result = await usageAgent({ cwd }); } catch (error) { result = { status: "failed", error: error?.code ?? "install_failed" }; }
547
+ if (result?.status === "installed") log("bb setup: daily usage sync installed (ai.botbuddy.usage-sync, 03:10 local).");
548
+ else if (result?.status === "unchanged") log("bb setup: daily usage sync already installed (ai.botbuddy.usage-sync).");
549
+ else if (result?.status === "unsupported") log(`bb setup: daily usage sync skipped — ${result.notice}.`);
550
+ else log(`bb setup: daily usage sync could not be installed (${result?.error ?? "unknown"}); run bb setup again to retry.`);
551
+ }
552
+
495
553
  export function loginHelp(log = console.log) {
496
554
  log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
497
555
 
package/src/stack.mjs CHANGED
@@ -27,7 +27,7 @@ import { open, unlink, readFile } from "fs/promises";
27
27
  import { tmpdir } from "os";
28
28
  import { basename, dirname, join, relative, isAbsolute } from "path";
29
29
  import { randomUUID } from "crypto";
30
- import { callToolJson } from "./api.mjs";
30
+ import { callToolJson, CALL_AUTH_ERROR_CODES } from "./api.mjs";
31
31
  import { SERVER_URL, getConfig } from "./config.mjs";
32
32
  import { resolveOwnerToken, resolveAgentKey } from "./cli-credentials.mjs";
33
33
  import { AGENT_KEY_RE, readAgentSessionTokenEnv } from "./agent-key.mjs";
@@ -696,6 +696,9 @@ export function stackPathRootRefusalNotice(code, message) {
696
696
  * reads as "the control plane is unhealthy, retry". Every other refusal keeps BACKEND (5).
697
697
  */
698
698
  export function leaseRefusalExit(code) {
699
+ // BOT-1896: an auth-class refusal (scope_denied, a tenant mismatch, …) is the
700
+ // server refusing the credential — never BACKEND, which a gate reads as "down".
701
+ if (CALL_AUTH_ERROR_CODES.has(code)) return EXIT.AUTH;
699
702
  return code === STACK_PATH_ROOT_REFUSED_CODE ? EXIT.INVALID : EXIT.BACKEND;
700
703
  }
701
704
 
@@ -0,0 +1,157 @@
1
+ // BOT-803 — Claude Code transcript adapter (`~/.claude/projects/**/*.jsonl`).
2
+ //
3
+ // Verified on real transcripts (PoC report, 2026-09-28):
4
+ // * one API message fans out into several JSONL records that share
5
+ // `message.id` + `requestId` with identical usage. The server keys tokens on
6
+ // (provider, harness_session_id, message_id), first record wins — this
7
+ // matches the harness's own `cost-state` counters exactly;
8
+ // * `tool_use` blocks are split across those records, so the adapter emits ONE
9
+ // envelope per message whose tool_call_count is summed over the message's
10
+ // DISTINCT record uuids (the server stores, never sums, it — BOT-1019);
11
+ // * subagent transcripts live in `<session>/subagents/agent-*.jsonl`, reuse
12
+ // the parent sessionId, carry `isSidechain: true` and `stop_reason: null`
13
+ // with a stream-start output_tokens placeholder → usage_final false, partial;
14
+ // * `gitBranch` can be the literal "HEAD" and `cwd` can change mid-session, so
15
+ // the ticket key is inferred per message;
16
+ // * `cost-state` carries per-model cumulative `modelUsage` and no timestamp.
17
+ // BOT-1019's table has no row kind to hold it, and as a plain row it would
18
+ // double-count, so those records are counted and WITHHELD (never sent) until
19
+ // the server grows a reconciliation surface.
20
+ //
21
+ // Because the first record to reach the server wins, a message whose records
22
+ // are still being written must not be sent early: the trailing message of a
23
+ // live file is held back (the cursor stops at its first record) until it gains
24
+ // a final record, a later message starts, or the file goes quiet.
25
+ import {
26
+ finalizeEnvelope, inferTicketKey, modelLabel, safeLabel, str, tok, usageQuality, uuid, withRoute,
27
+ } from "./envelope.mjs";
28
+
29
+ export const CLAUDE_PROVIDER = "claude";
30
+ export const CLAUDE_PARSER_VERSION = "claude-1";
31
+
32
+ function countToolUses(content) {
33
+ if (!Array.isArray(content)) return 0;
34
+ let n = 0;
35
+ for (const block of content) if (block && block.type === "tool_use") n += 1;
36
+ return n;
37
+ }
38
+
39
+ const classesOf = (usage) => ({
40
+ input_tokens: tok(usage?.input_tokens),
41
+ output_tokens: tok(usage?.output_tokens),
42
+ cache_creation_input_tokens: tok(usage?.cache_creation_input_tokens),
43
+ cache_read_input_tokens: tok(usage?.cache_read_input_tokens),
44
+ });
45
+
46
+ /**
47
+ * @param {object} opts
48
+ * @param {(value: string) => string} opts.hmac keyed HMAC of a local path
49
+ * @param {string} opts.environment 'local' | 'cloud'
50
+ * @param {(cwd: string) => string|null} [opts.repoFor] cwd → GitHub owner/name
51
+ * @param {(cwd: string|null) => string|null} [opts.tenantFor] cwd → tenant bound to its checkout
52
+ */
53
+ export function createClaudeAdapter({ hmac, environment, repoFor = () => null, tenantFor = () => null }) {
54
+ return {
55
+ provider: CLAUDE_PROVIDER,
56
+ parserVersion: CLAUDE_PARSER_VERSION,
57
+ /**
58
+ * One file's parser. `ctx` is per-file state persisted in the cursor (only
59
+ * derived, privacy-safe values).
60
+ */
61
+ beginFile({ sourceHmac, ctx }) {
62
+ const groups = new Map();
63
+ let lastKey = null;
64
+ const stats = { refused: 0, withheld: 0 };
65
+ return {
66
+ stats,
67
+ parseRecord(record, offset) {
68
+ if (!record || typeof record !== "object") return;
69
+ if (typeof record.timestamp === "string") ctx.last_ts = record.timestamp;
70
+ if (uuid(record.sessionId)) ctx.session_id = record.sessionId;
71
+
72
+ if (record.type === "cost-state" && record.modelUsage && typeof record.modelUsage === "object") {
73
+ stats.withheld += Object.keys(record.modelUsage).length;
74
+ return;
75
+ }
76
+ const msg = record.message;
77
+ if (record.type !== "assistant" || !msg || typeof msg !== "object" || !msg.usage || typeof msg.usage !== "object") return;
78
+ const messageId = str(msg.id);
79
+ // A record without a sessionId inherits the file's; a malformed one is refused.
80
+ const sessionId = record.sessionId === undefined ? ctx.session_id ?? null : uuid(record.sessionId);
81
+ // NULLs never conflict in a unique index — refuse rather than duplicate.
82
+ if (!messageId || !sessionId) { stats.refused += 1; return; }
83
+
84
+ const key = `${sessionId}\u0000${messageId}`;
85
+ let group = groups.get(key);
86
+ if (!group) {
87
+ if (lastKey && groups.has(lastKey)) groups.get(lastKey).closed = true; // a new API response began
88
+ const cwd = str(record.cwd);
89
+ const gitBranch = safeLabel(record.gitBranch);
90
+ group = {
91
+ offset, closed: false, final: false, toolCalls: 0, uuids: new Set(),
92
+ sessionId, messageId,
93
+ requestId: str(record.requestId), recordUuid: uuid(record.uuid), occurredAt: str(record.timestamp),
94
+ model: modelLabel(msg.model), version: safeLabel(record.version), isSidechain: record.isSidechain === true,
95
+ gitBranch, cwd, usage: classesOf(msg.usage),
96
+ };
97
+ groups.set(key, group);
98
+ }
99
+ const recordId = str(record.uuid);
100
+ if (!recordId || !group.uuids.has(recordId)) {
101
+ if (recordId) group.uuids.add(recordId);
102
+ group.toolCalls += countToolUses(msg.content);
103
+ }
104
+ if (msg.stop_reason !== null && msg.stop_reason !== undefined) {
105
+ group.final = true;
106
+ group.closed = true;
107
+ group.usage = classesOf(msg.usage); // the final record's usage is authoritative
108
+ }
109
+ lastKey = key;
110
+ },
111
+ /** @returns {{ envelopes: object[], holdOffset: number|null }} */
112
+ finish({ quiescent }) {
113
+ const envelopes = [];
114
+ let holdOffset = null;
115
+ for (const group of groups.values()) {
116
+ if (!group.closed && !quiescent) {
117
+ holdOffset = holdOffset === null ? group.offset : Math.min(holdOffset, group.offset);
118
+ continue;
119
+ }
120
+ envelopes.push(envelopeFor(group));
121
+ }
122
+ // Anything after a held message is re-read with it next time.
123
+ return { envelopes: holdOffset === null ? envelopes : envelopes.filter((e) => e.source_offset < holdOffset), holdOffset };
124
+ },
125
+ };
126
+
127
+ function envelopeFor(group) {
128
+ const ticket = inferTicketKey(group.gitBranch, group.cwd);
129
+ const usage = group.usage;
130
+ return withRoute(finalizeEnvelope({
131
+ provider: CLAUDE_PROVIDER,
132
+ provider_schema_version: group.version,
133
+ harness_session_id: group.sessionId,
134
+ request_id: group.requestId,
135
+ message_id: group.messageId,
136
+ record_uuid: group.recordUuid,
137
+ occurred_at: group.occurredAt,
138
+ model: group.model,
139
+ ...usage,
140
+ tool_call_count: group.toolCalls,
141
+ is_sidechain: group.isSidechain,
142
+ usage_final: group.final,
143
+ usage_quality: usageQuality({ usageFinal: group.final, classes: Object.values(usage) }),
144
+ git_branch: group.gitBranch,
145
+ repo: group.cwd ? repoFor(group.cwd) : null,
146
+ ticket_key_inferred: ticket,
147
+ attribution_confidence: ticket ? "inferred" : null,
148
+ environment,
149
+ cwd_hmac: group.cwd ? hmac(group.cwd) : null,
150
+ source_hmac: sourceHmac,
151
+ source_offset: group.offset,
152
+ parser_version: CLAUDE_PARSER_VERSION,
153
+ }), group.cwd ? tenantFor(group.cwd) : null);
154
+ }
155
+ },
156
+ };
157
+ }