@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 +1 -1
- package/src/api.mjs +194 -22
- package/src/commands.mjs +63 -5
- package/src/stack.mjs +4 -1
- package/src/usage/claude.mjs +157 -0
- package/src/usage/codex.mjs +236 -0
- package/src/usage/cursor.mjs +151 -0
- package/src/usage/envelope.mjs +313 -0
- package/src/usage/launchd.mjs +170 -0
- package/src/usage/sync.mjs +725 -0
package/package.json
CHANGED
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")
|
|
73
|
+
if (code === "token_expired") fail(`Token expired. Run: ${cyan("botbuddy login")} to re-authenticate.`);
|
|
71
74
|
if (code === "session_token_malformed") {
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
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)
|
|
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, {
|
|
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
|
|
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
|
+
}
|