@botbuddy/cli 1.60.8 → 1.60.10

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.
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.8",
3
- "source_commit": "057670de6f7ac5dc8e8e673dab13a317aef876e9",
4
- "source_sha256": "3678a4ff49dda524b2b97e5a9d57249b64041f587da4db0c936b2f650647c6ec",
2
+ "release_version": "1.60.10",
3
+ "source_commit": "311044889f28c3b22902954ec29c3c117f46d638",
4
+ "source_sha256": "a82e951ccda7aa65571953e874a01cf30f75bc218ed5592a2bffa2e7b0d3219a",
5
5
  "target": "darwin-arm64",
6
- "sha256": "234246b7e1b8634420dbe2bceba35a4efa51a0ade206b09882f9c80a0aa66bfb"
6
+ "sha256": "c078605d49201ad3532212bbac322216b06c108c425024a7f68f6ecdab89fd5a"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.60.8",
3
- "source_commit": "057670de6f7ac5dc8e8e673dab13a317aef876e9",
4
- "source_sha256": "3678a4ff49dda524b2b97e5a9d57249b64041f587da4db0c936b2f650647c6ec",
2
+ "release_version": "1.60.10",
3
+ "source_commit": "311044889f28c3b22902954ec29c3c117f46d638",
4
+ "source_sha256": "a82e951ccda7aa65571953e874a01cf30f75bc218ed5592a2bffa2e7b0d3219a",
5
5
  "target": "linux-x64",
6
- "sha256": "c29069dc84ea8273ea4b560816eb130a82dfbba083a6d8149edecd8cc85b534c"
6
+ "sha256": "af6bfdf5e66b133356c620a5dd9aaacc1cee11d8f0f594bb8f5e8447cb932704"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.60.8",
4
- "source_commit": "057670de6f7ac5dc8e8e673dab13a317aef876e9",
5
- "source_sha256": "3678a4ff49dda524b2b97e5a9d57249b64041f587da4db0c936b2f650647c6ec",
3
+ "release_version": "1.60.10",
4
+ "source_commit": "311044889f28c3b22902954ec29c3c117f46d638",
5
+ "source_sha256": "a82e951ccda7aa65571953e874a01cf30f75bc218ed5592a2bffa2e7b0d3219a",
6
6
  "targets": {
7
7
  "darwin-arm64": {
8
8
  "goos": "darwin",
9
9
  "goarch": "arm64",
10
10
  "binary": "daemon/bin/darwin-arm64/botbuddy-daemon",
11
- "sha256": "234246b7e1b8634420dbe2bceba35a4efa51a0ade206b09882f9c80a0aa66bfb",
11
+ "sha256": "c078605d49201ad3532212bbac322216b06c108c425024a7f68f6ecdab89fd5a",
12
12
  "mode": "0755"
13
13
  },
14
14
  "linux-x64": {
15
15
  "goos": "linux",
16
16
  "goarch": "amd64",
17
17
  "binary": "daemon/bin/linux-x64/botbuddy-daemon",
18
- "sha256": "c29069dc84ea8273ea4b560816eb130a82dfbba083a6d8149edecd8cc85b534c",
18
+ "sha256": "af6bfdf5e66b133356c620a5dd9aaacc1cee11d8f0f594bb8f5e8447cb932704",
19
19
  "mode": "0755"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.60.8",
3
+ "version": "1.60.10",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -278,8 +278,8 @@ ${bold("STAGING SIGN-IN")} (BOT-2084)
278
278
  persona, no password (${cyan("bb pw auth --help")})
279
279
  staging-pool status [--check]
280
280
  Pool health: one reason, who acts, the next command
281
- staging-pool install|tick|mint-one|uninstall
282
- Human only, from Terminal.app (${cyan("bb staging-pool --help")})
281
+ staging-pool install --tenant <slug> | tick | mint-one | uninstall
282
+ Human only, any directory (${cyan("bb staging-pool --help")})
283
283
  help symptom staging-pool Every refusal code with its cause and next command
284
284
 
285
285
  ${bold("OTHER")}
package/src/pw/run.mjs CHANGED
@@ -11,6 +11,7 @@ import { VERSION } from "../version.mjs";
11
11
  import { readAgentSessionTokenEnv, AGENT_KEY_RE, legacyAgentAliasHint } from "../agent-key.mjs";
12
12
  import { clearAgentState, isRejectedCachedMcpSession, resolveAgentSessionCredential } from "../agent-session.mjs";
13
13
  import * as stagingAuth from "./staging-auth.mjs";
14
+ import { DOC_SECTIONS, DOC_URL } from "../staging-pool/errors.mjs";
14
15
  // BOT-1488: canonicalize the raw hostname the SAME way acquire_resources does
15
16
  // server-side, so the lane name bb-pw builds/matches/prints is the one the lock
16
17
  // kernel actually stored ("jonos-mbp:8", not "Jonos-MBP.localdomain:8").
@@ -134,7 +135,12 @@ export async function runPw(argv, deps = {}) {
134
135
  // while-loop in runPwInner). Everything after the first non-option (the lane) is a
135
136
  // Playwright workload argument and is never a BotBuddy credential.
136
137
  const PW_LEADING_FLAGS = ["--tenant", "--session-id", "--agent-session-token", "--agent-key", "--session-token", "--environment"];
137
- const selfHealErrorLine = (error) => `bb-pw: ${error?.message ?? "agent session unavailable"}. ${error?.code === "client_key_required" ? "Run `bb login` then retry." : "Run `bb doctor --fix` then retry."}\n`;
138
+ // BOT-2648 acceptance: with no tenant binding, `bb doctor --fix` cannot help: the
139
+ // caller is outside a registered agent worktree, which lane locks need.
140
+ const NO_BINDING_NEXT = "Agent: run this from your registered project worktree (the checkout with .botbuddy-agent.json; run `bb setup` there once), then retry. bb help symptom tenant_binding_required";
141
+ const selfHealErrorLine = (error) => error?.code === "tenant_binding_required"
142
+ ? `bb-pw: tenant_binding_required — ${error?.message ?? "no BotBuddy tenant binding here"} ${NO_BINDING_NEXT}. Docs: ${DOC_URL}#${DOC_SECTIONS.take}\n`
143
+ : `bb-pw: ${error?.message ?? "agent session unavailable"}. ${error?.code === "client_key_required" ? "Run `bb login` then retry." : "Run `bb doctor --fix` then retry."}\n`;
138
144
  // The leading credential options, NORMALIZED to split `flag value` pairs so an
139
145
  // inline `--agent-key=<token>` is recognized by legacyAgentAliasHint (which
140
146
  // matches exact flag tokens) exactly like the split form. Credential flags are
@@ -247,6 +253,8 @@ async function runPwInner(argv, deps = {}) {
247
253
  // BOT-2648 (Codex P2, #1323): the credential's tenant picks the installed staging pool.
248
254
  deps = { ...deps, sessionToken: credential.token, sessionId: deps.sessionId ?? credential.sessionId ?? null, credentialAgentId: credential.agentId ?? null, credentialTenant: credential.identity?.tenant ?? null };
249
255
  } catch (error) {
256
+ // BOT-2648 acceptance: an auth refusal is the standard JSON receipt (actor, next, doc, symptom).
257
+ if (plan.mode === "auth" && error?.code === "tenant_binding_required") return stagingAuth.refuse({ stdout, stderr }, "tenant_binding_required", 3, {}, authVars(plan, deps));
250
258
  stderr.write(selfHealErrorLine(error)); return 3;
251
259
  }
252
260
  }
@@ -52,6 +52,13 @@ export const ERROR_CODES = Object.freeze({
52
52
  "The lane lock is held by another agent session of your owner, not by this session.",
53
53
  "The lane was claimed from a different session or sub-agent; auth needs the exact holder.",
54
54
  `bb locks -m --lane {lane}{ticketFlag} # from THIS session, then: ${AUTH}`),
55
+ // BOT-2648 acceptance: the take runs from a registered agent worktree. Lane locks
56
+ // are per agent worktree, so a directory with no .botbuddy-agent.json has no
57
+ // agent identity to hold the lane (the pool itself never needs a checkout).
58
+ tenant_binding_required: entry("agent", "take",
59
+ "bb pw needs a registered agent worktree, and this directory has no BotBuddy tenant binding.",
60
+ "No .botbuddy-agent.json was found here or above, so there is no per-worktree agent identity to hold the lane lock.",
61
+ `bb setup # run inside your registered project worktree (the checkout with .botbuddy-agent.json); then from there: bb locks -m --lane {lane}{ticketFlag} && ${AUTH}`),
55
62
  persona_not_allowlisted: entry("agent", "take",
56
63
  "Persona \"{persona}\" is not one of this repository's staging_session_pool personas.",
57
64
  "A typo, or an email where the alias is expected. Allowed aliases: {allowed}.",
@@ -310,7 +317,7 @@ export const ERROR_CODES = Object.freeze({
310
317
  // Codes only `bb pw … auth` can raise; every other code can come from `bb staging-pool`.
311
318
  export const AUTH_ONLY_CODES = Object.freeze(new Set([
312
319
  "auth_requires_staging_environment", "auth_requires_lane_lock", "lane_not_claimed", "exact_lane_holder_required",
313
- "persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
320
+ "tenant_binding_required", "persona_not_allowlisted", "lane_browser_not_open", "browser_session_not_staging", "persistent_profile_refused",
314
321
  "session_install_failed", "staging_session_cleanup_pending", "session_not_aal2", "session_expired",
315
322
  "session_persona_mismatch", "slot_state_invalid", "slot_state_unreadable", "daemon_node_unsupported",
316
323
  "daemon_run_failed", "daemon_result_missing", "daemon_result_invalid", "pool_take_failed", "pool_empty",
@@ -10,6 +10,7 @@
10
10
  // success). It carries row COUNTS only — never paths, never prompt text, never
11
11
  // token totals. Fail-open: any failure exits 0 unless --strict.
12
12
  import { execFileSync } from "node:child_process";
13
+ import { randomUUID } from "node:crypto";
13
14
  import { existsSync, readFileSync } from "node:fs";
14
15
  import { readdir, stat } from "node:fs/promises";
15
16
  import { homedir, hostname as systemHostname } from "node:os";
@@ -90,16 +91,20 @@ export function transcriptRoots(env = process.env, home = homedir()) {
90
91
  * subdirectories) modified since `cutoffMs`. `shouldStop` is checked before
91
92
  * every directory read and stat, so a huge history or a slow filesystem cannot
92
93
  * outlive the run's time budget (the pre-push lane runs with 5 s).
93
- * @returns {Promise<{ files: string[], stopped: boolean }>}
94
+ * `errors` counts directories or files that exist but could not be read
95
+ * (EACCES, EPERM, …); a missing root or a file that vanished is not an error.
96
+ * @returns {Promise<{ files: string[], stopped: boolean, errors: number }>}
94
97
  */
95
98
  export async function listTranscripts(root, cutoffMs, { shouldStop = () => false, keep = () => false } = {}) {
96
99
  const out = [];
97
100
  let stopped = false;
101
+ let errors = 0;
102
+ const benign = (error) => error?.code === "ENOENT" || error?.code === "ENOTDIR";
98
103
  const halt = () => (stopped = stopped || shouldStop());
99
104
  async function walk(dir) {
100
105
  if (halt()) return;
101
106
  let entries;
102
- try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
107
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch (error) { if (!benign(error)) errors += 1; return; }
103
108
  entries.sort((a, b) => a.name.localeCompare(b.name));
104
109
  for (const entry of entries) {
105
110
  if (halt()) return;
@@ -109,12 +114,12 @@ export async function listTranscripts(root, cutoffMs, { shouldStop = () => false
109
114
  try {
110
115
  const info = await stat(path);
111
116
  if (info.mtimeMs >= cutoffMs || keep(path, info)) out.push(path);
112
- } catch { /* vanished between readdir and stat */ }
117
+ } catch (error) { if (!benign(error)) errors += 1; /* ENOENT: vanished between readdir and stat */ }
113
118
  }
114
119
  }
115
120
  }
116
121
  await walk(root);
117
- return { files: out, stopped };
122
+ return { files: out, stopped, errors };
118
123
  }
119
124
 
120
125
  /** Which adapter owns a single `--file`: under a Codex root, or a rollout whose first record is session_meta. */
@@ -373,19 +378,24 @@ export async function syncUsage(opts, deps = {}) {
373
378
  roots = transcriptRoots(env, home), repoFor = createRepoResolver(), branchFor = createBranchResolver({ env }), cliVersion = "0.0.0",
374
379
  tenantFor = createTenantResolver({ env }), resolveTarget = defaultResolveTarget, readLines = readCompleteLines,
375
380
  serverUrl = env.BOTBUDDY_SERVER_URL || DEFAULT_SERVER_URL, statePath = cursorPath(home),
381
+ newSyncId = randomUUID,
376
382
  } = deps;
377
383
  const startedMs = now();
378
384
  const deadlineMs = startedMs + opts.maxSeconds * 1000;
379
385
  const parseDeadlineMs = startedMs + opts.maxSeconds * 1000 * PARSE_BUDGET_FRACTION;
380
386
  const startedAt = new Date(startedMs).toISOString();
387
+ // BOT-2649: one id for this whole invocation, sent with every batch (both
388
+ // providers), so v_usage_sync_hosts judges the attempt as a whole: one
389
+ // refused batch fails the sync even when a later batch succeeded.
390
+ const syncId = newSyncId();
381
391
  const receipt = {
382
392
  schema_version: 1, command: "usage sync", outcome: "ok", trigger: opts.trigger, dry_run: opts.dryRun,
383
393
  providers: [], files_scanned: 0, files_read: 0, envelopes: 0, batches: 0,
384
394
  tenant: null, skipped_other_tenant: 0, skipped_unbound: 0,
385
395
  seen: 0, inserted: 0, superseded: 0, deduped: 0, refused: 0, refusal_reasons: {}, batches_all_refused: 0,
386
- refused_local: 0, parse_errors: 0,
396
+ refused_local: 0, parse_errors: 0, read_errors: 0,
387
397
  held_open_messages: 0, cost_state_withheld: 0,
388
- privacy: "pass", budget_exhausted: false, run_ids: [], started_at: startedAt, duration_ms: 0,
398
+ privacy: "pass", budget_exhausted: false, run_ids: [], started_at: startedAt, sync_id: syncId, duration_ms: 0,
389
399
  };
390
400
  const finish = (outcome, error) => {
391
401
  receipt.outcome = outcome;
@@ -441,6 +451,8 @@ export async function syncUsage(opts, deps = {}) {
441
451
  },
442
452
  });
443
453
  if (listed.stopped) receipt.budget_exhausted = true;
454
+ // BOT-2649: an unreadable transcript directory is a read failure, not an idle host.
455
+ receipt.read_errors += listed.errors ?? 0;
444
456
  plan.push({ provider, files: listed.stopped ? [] : listed.files });
445
457
  }
446
458
  }
@@ -466,7 +478,7 @@ export async function syncUsage(opts, deps = {}) {
466
478
  : createClaudeAdapter({ hmac, environment, repoFor, tenantFor });
467
479
  const run = {
468
480
  host: hostname(), trigger: opts.trigger, provider, cli_version: cliVersion,
469
- parser_version: adapter.parserVersion, started_at: startedAt,
481
+ parser_version: adapter.parserVersion, started_at: startedAt, sync_id: syncId,
470
482
  };
471
483
 
472
484
  // Envelopes are queued with a global sequence; a file's cursor commits only
@@ -535,7 +547,9 @@ export async function syncUsage(opts, deps = {}) {
535
547
  for (const path of files) {
536
548
  if (now() >= parseDeadlineMs) { receipt.budget_exhausted = true; break; }
537
549
  let info;
538
- try { info = await stat(path); } catch { continue; }
550
+ // A file that vanished since listing is benign; any other stat failure
551
+ // (e.g. EACCES) means this run could not read pending usage (BOT-2649).
552
+ try { info = await stat(path); } catch (error) { if (error?.code !== "ENOENT") receipt.read_errors += 1; continue; }
539
553
  const sourceHmac = hmac(resolve(path));
540
554
  // Cursors are per (tenant, file): a run skips other tenants' records, so a
541
555
  // shared per-file cursor would consume them for every later tenant's run.
@@ -560,6 +574,7 @@ export async function syncUsage(opts, deps = {}) {
560
574
  parser.parseRecord(record, offset);
561
575
  }, { shouldStop: () => now() >= parseDeadlineMs });
562
576
  } catch {
577
+ receipt.read_errors += 1; // counted: a run that could not read is not a clean idle run
563
578
  continue; // unreadable file: skip, cursor unchanged
564
579
  }
565
580
  if (result.stopped) receipt.budget_exhausted = true;
@@ -606,8 +621,43 @@ export async function syncUsage(opts, deps = {}) {
606
621
  }
607
622
  commitDelivered();
608
623
  }
624
+ // BOT-2649: a run that uploaded nothing still records one empty ok run, so an
625
+ // idle but healthy host is not aged into "stale" by v_usage_sync_hosts. Only a
626
+ // CLEAN idle run does: one that refused records locally, hit unparseable lines,
627
+ // could not read a transcript or ran out of budget did not prove usage lands, so it records nothing and the
628
+ // host ages as before rather than reading healthy (independent review on #1324).
629
+ if (!opts.dryRun && receipt.batches === 0) {
630
+ const withheld = receipt.budget_exhausted ? "budget_exhausted"
631
+ : receipt.refused_local > 0 ? "refused_local"
632
+ : receipt.parse_errors > 0 ? "parse_errors"
633
+ : receipt.read_errors > 0 ? "read_errors" : null;
634
+ if (withheld) {
635
+ receipt.empty_run_withheld = withheld;
636
+ } else {
637
+ const error = await postEmptyRun();
638
+ if (error === OUT_OF_TIME) return finalize(null);
639
+ if (error) return finalize(error);
640
+ }
641
+ }
609
642
  return finalize(null);
610
643
 
644
+ async function postEmptyRun() {
645
+ // Same deadline rules as a batch: never START a request past the budget.
646
+ if (now() >= deadlineMs) { receipt.budget_exhausted = true; return OUT_OF_TIME; }
647
+ const resolved = await ensureAuth();
648
+ if (resolved.error) return resolved.error;
649
+ if (now() >= deadlineMs) { receipt.budget_exhausted = true; return OUT_OF_TIME; }
650
+ const run = { host: hostname(), trigger: opts.trigger, cli_version: cliVersion, started_at: startedAt, sync_id: syncId };
651
+ const result = await postBatch({
652
+ endpoint: usageIngestEndpoint(serverUrl), tenant: resolved.tenant,
653
+ headers: resolved.headers, run, envelopes: [], fetchImpl, timeoutMs: deadlineMs - now(),
654
+ });
655
+ if (!result.ok) return result.error;
656
+ receipt.empty_run = true;
657
+ if (result.run_id) receipt.run_ids.push(result.run_id);
658
+ return null;
659
+ }
660
+
611
661
  async function finalize(error) {
612
662
  const outcome = error ? "error" : opts.dryRun ? "dry_run" : receipt.budget_exhausted ? "partial" : "ok";
613
663
  finish(outcome, error);