@botbuddy/cli 1.59.9 → 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/botbuddy.mjs CHANGED
@@ -1,7 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "../src/commands.mjs";
3
+ import { ensureRunDelivery } from "../src/run-delivery.mjs";
3
4
 
4
- run(process.argv.slice(2)).then((code) => {
5
+ const argv = process.argv.slice(2);
6
+
7
+ // BOT-2600: any `bb` invocation starts the single per-machine delivery process
8
+ // when finished `bb run` commands still have an unsent terminal update. A stat
9
+ // and two small reads when nothing changed; it never fails the command.
10
+ const deliveryProcessArgs = argv[0] === "run" && (argv[1] === "--worker" || argv[1] === "--deliver");
11
+ const delivery = deliveryProcessArgs ? Promise.resolve() : ensureRunDelivery().catch(() => {});
12
+
13
+ delivery.then(() => run(argv)).then((code) => {
5
14
  if (typeof code === "number") process.exitCode = code;
6
15
  }).catch((err) => {
7
16
  console.error(`\x1b[31m✗\x1b[0m ${err?.message || err}`);
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.59.9",
3
- "source_commit": "d9c73efb211e953ac2d57257a0d7d3cd21660d67",
4
- "source_sha256": "57b561506694e9a2d326bba24731ab1b0354ef38c509b452686b8a450b59bd4b",
2
+ "release_version": "1.60.0",
3
+ "source_commit": "2bde2741ba9158ce03147a2ff28f1c6a44f89e25",
4
+ "source_sha256": "7091f785b4820eccd5f9596e04def9ef58a02cbb5130ccb84bd55ae77e71ec67",
5
5
  "target": "darwin-arm64",
6
- "sha256": "ad8d7b6e3255d85f1c1237ae1b6bbd0a6b359b7909fba60b4819566bd7362c31"
6
+ "sha256": "b6fe4ed183d92a32e398cb9de3f82ac6b4634188409f51eadad457ca6b37c307"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.59.9",
3
- "source_commit": "d9c73efb211e953ac2d57257a0d7d3cd21660d67",
4
- "source_sha256": "57b561506694e9a2d326bba24731ab1b0354ef38c509b452686b8a450b59bd4b",
2
+ "release_version": "1.60.0",
3
+ "source_commit": "2bde2741ba9158ce03147a2ff28f1c6a44f89e25",
4
+ "source_sha256": "7091f785b4820eccd5f9596e04def9ef58a02cbb5130ccb84bd55ae77e71ec67",
5
5
  "target": "linux-x64",
6
- "sha256": "bf2f3a46ef151ace3e5b21633c03dc1824bee2e42fc6edd8f6898ca35d48f10c"
6
+ "sha256": "3a1839c5800e38bf9bb7a1465555e0cace01ef82d9c382fde1e036608c536fe8"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.59.9",
4
- "source_commit": "d9c73efb211e953ac2d57257a0d7d3cd21660d67",
5
- "source_sha256": "57b561506694e9a2d326bba24731ab1b0354ef38c509b452686b8a450b59bd4b",
3
+ "release_version": "1.60.0",
4
+ "source_commit": "2bde2741ba9158ce03147a2ff28f1c6a44f89e25",
5
+ "source_sha256": "7091f785b4820eccd5f9596e04def9ef58a02cbb5130ccb84bd55ae77e71ec67",
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": "ad8d7b6e3255d85f1c1237ae1b6bbd0a6b359b7909fba60b4819566bd7362c31",
11
+ "sha256": "b6fe4ed183d92a32e398cb9de3f82ac6b4634188409f51eadad457ca6b37c307",
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": "bf2f3a46ef151ace3e5b21633c03dc1824bee2e42fc6edd8f6898ca35d48f10c",
18
+ "sha256": "3a1839c5800e38bf9bb7a1465555e0cace01ef82d9c382fde1e036608c536fe8",
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.59.9",
3
+ "version": "1.60.0",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,15 +10,35 @@ import { selfHealAgentSession } from "./agent-session.mjs";
10
10
  import { machineUuid } from "./machine-id.mjs";
11
11
  import { machineHost } from "./machine-host.mjs";
12
12
 
13
+ // BOT-2599: checks `--only` can run on their own. Each is local-only (no auth,
14
+ // no network), so it is fast enough for a git hook.
15
+ export const DOCTOR_ONLY_CHECKS = Object.freeze(["git_transport"]);
16
+
13
17
  export function parseDoctorArgs(argv = []) {
14
18
  const parsed = { fix: false, printExports: false, json: false, errors: [] };
15
- for (const arg of argv) {
19
+ for (let i = 0; i < argv.length; i += 1) {
20
+ const arg = argv[i];
16
21
  if (arg === "--fix") parsed.fix = true;
17
22
  else if (arg === "--print-exports") parsed.printExports = true;
18
23
  else if (arg === "--json") parsed.json = true;
19
24
  else if (arg === "--help" || arg === "-h") parsed.help = true;
20
- else parsed.errors.push(`unknown option: ${arg}`);
25
+ else if (arg === "--only" || arg.startsWith("--only=")) {
26
+ const value = arg === "--only" ? argv[++i] : arg.slice("--only=".length);
27
+ if (!value || value.startsWith("--")) parsed.errors.push("--only needs a check name (git_transport)");
28
+ else if (!DOCTOR_ONLY_CHECKS.includes(value)) parsed.errors.push(`unknown check: ${value} (--only accepts ${DOCTOR_ONLY_CHECKS.join(", ")})`);
29
+ else parsed.only = value;
30
+ } else if (arg === "--remote" || arg.startsWith("--remote=")) {
31
+ const value = arg === "--remote" ? argv[++i] : arg.slice("--remote=".length);
32
+ if (!value) parsed.errors.push("--remote needs a remote name or URL");
33
+ else parsed.remote = value;
34
+ } else if (arg === "--url" || arg.startsWith("--url=")) {
35
+ // A pre-push hook's $2: the URL this push actually goes to.
36
+ const value = arg === "--url" ? argv[++i] : arg.slice("--url=".length);
37
+ if (!value) parsed.errors.push("--url needs the push URL");
38
+ else parsed.url = value;
39
+ } else parsed.errors.push(`unknown option: ${arg}`);
21
40
  }
41
+ if (parsed.only && parsed.printExports) parsed.errors.push("--print-exports cannot be combined with --only");
22
42
  return parsed;
23
43
  }
24
44
 
@@ -251,5 +271,5 @@ export async function doctorAgentAuth({
251
271
  }
252
272
 
253
273
  export function doctorHelp() {
254
- return "bb doctor [--fix] [--print-exports] [--json]\n\nChecks the client key, repo binding, cached agent session, and this worktree's stack-lease capability (registered host + attested machine). --fix mints a replacement session and (re)establishes the stack-lease capability; --print-exports prints its canonical shell exports. When the client key is unreachable (a Background LaunchAgent cannot read the login Keychain), --fix mints from the tier-2 MCP key exported under the binding's mcp_env name (env only, never Keychain).";
274
+ return "bb doctor [--fix] [--print-exports] [--json]\nbb doctor --only git_transport [--remote <name>] [--url <push url>] [--fix] [--json]\n\nChecks the client key, repo binding, cached agent session, this worktree's stack-lease capability (registered host + attested machine), and whether git's SSH push connection carries a keepalive (git_transport). --fix mints a replacement session and (re)establishes the stack-lease capability; --print-exports prints its canonical shell exports. When the client key is unreachable (a Background LaunchAgent cannot read the login Keychain), --fix mints from the tier-2 MCP key exported under the binding's mcp_env name (env only, never Keychain).\n\ngit_transport: git opens the SSH connection BEFORE the pre-push hook runs, so a long hook lets it idle until it is dropped and the push dies (exit 141) after every gate passed. ok = ServerAliveInterval > 0 on the push's ssh; n/a = not an ssh remote; needs_fix / unknown = not provable. --fix sets git config --local core.sshCommand \"ssh -o ServerAliveInterval=20 -o ServerAliveCountMax=60\" only when nothing is configured; an existing GIT_SSH_COMMAND, core.sshCommand or GIT_SSH is never overwritten (the exact edit is printed instead).\n\n--only git_transport runs just that check — no sign-in, no network, well under a second — for a pre-push hook: exit 0 ok/n/a, 3 needs_fix/unknown (remedy on stderr), 4 usage. A hook passes its $1 as --remote and its $2 (the URL this push goes to) as --url; without --url every push URL of the remote (default: the branch's push remote, else origin) is checked.";
255
275
  }
package/src/api.mjs CHANGED
@@ -385,9 +385,9 @@ export const SESSION_TOKEN_DENIED_TOOLS = new Set([
385
385
  // call to a specific tenant by appending ?tenant=<slug> to the endpoint (the
386
386
  // same mechanism as the documented BOTBUDDY_SERVER_URL override), letting the
387
387
  // server resolve/confirm exactly that tenant.
388
- function toolEndpoint(tenant) {
389
- if (!tenant) return SERVER_URL;
390
- const url = new URL(SERVER_URL);
388
+ function toolEndpoint(tenant, base = SERVER_URL) {
389
+ if (!tenant) return base;
390
+ const url = new URL(base);
391
391
  url.searchParams.set("tenant", tenant);
392
392
  return url.toString();
393
393
  }
@@ -457,6 +457,9 @@ export async function registerContainerHost(body, {
457
457
  export async function callToolJson(toolName, args = {}, {
458
458
  fetchImpl = fetch, signal, auth: pinnedAuth = null, tenant = null, resolvePin = resolveDefaultTenantPin,
459
459
  requireSessionToken = false, resolveAuth = resolveCallAuth,
460
+ // BOT-2600: a delivery for a sealed run goes to the endpoint the run was
461
+ // registered with, not this process's ambient one.
462
+ serverUrl = SERVER_URL,
460
463
  } = {}) {
461
464
  let auth = pinnedAuth;
462
465
  let sessionTokenSelected = false;
@@ -481,7 +484,7 @@ export async function callToolJson(toolName, args = {}, {
481
484
  const body = { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: toolName, arguments: args } };
482
485
  let res;
483
486
  try {
484
- res = await fetchImpl(toolEndpoint(pin), {
487
+ res = await fetchImpl(toolEndpoint(pin, serverUrl), {
485
488
  method: "POST",
486
489
  headers: { "Content-Type": "application/json", ...auth },
487
490
  body: JSON.stringify(body),
package/src/commands.mjs CHANGED
@@ -30,6 +30,7 @@ import { createTelemetryOutbox } from "./telemetry-outbox.mjs";
30
30
  import { attestTelemetryCredential, loadTelemetryIdentity, loadTelemetryLocation } from "./telemetry-config.mjs";
31
31
  import { deliverExecutionEvent } from "./telemetry-delivery.mjs";
32
32
  import { doctorAgentAuth, doctorHelp, parseDoctorArgs, readClientKeyStrict, repairFailureRecovery } from "./agent-doctor.mjs";
33
+ import { GIT_TRANSPORT_PASSING, checkGitTransport, formatGitTransportLine } from "./git-transport.mjs";
33
34
  import { formatPublishIp, isLoopbackIp, probeEnginePublishIp, publishIpFix } from "./docker-publish-ip.mjs";
34
35
  import { cmdHost, fixHelperWorkers, formatHelperWorkersLines, inspectHelperWorkers } from "./helper-host.mjs";
35
36
  import { clearAgentState, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
@@ -48,6 +49,9 @@ export function shouldCheckForUpdates(argv) {
48
49
  // BOT-1982: nor `test-ingest`, which runs at the tail of every test harness.
49
50
  if (command === "wait" || command === "telemetry" || command === "usage" || command === "test-ingest") return false;
50
51
  if (argv.includes("--json")) return false;
52
+ // BOT-2599: `bb doctor --only <check>` runs inside a git pre-push hook; it must
53
+ // stay local and sub-second, so it never consults the npm registry.
54
+ if (command === "doctor" && argv.some((arg) => arg === "--only" || arg.startsWith("--only="))) return false;
51
55
  return true;
52
56
  }
53
57
 
@@ -284,10 +288,13 @@ export async function cmdDoctor(args, {
284
288
  // BOT-1982: which credential test-run uploads would use — the ONE place a
285
289
  // missing upload credential is reported (the harnesses skip silently).
286
290
  testIngestReport = () => doctorTestIngest(),
291
+ // BOT-2599: will this clone's SSH push connection survive a long pre-push hook?
292
+ gitTransportReport = async ({ fix, remote, url }) => checkGitTransport({ fix, remote, url }),
287
293
  } = {}) {
288
294
  const parsed = parseDoctorArgs(args);
289
295
  if (parsed.help) { log(doctorHelp()); return 0; }
290
296
  if (parsed.errors.length) { errorLog(`bb doctor: ${parsed.errors.join("; ")}`); return 4; }
297
+ if (parsed.only === "git_transport") return doctorGitTransportOnly(parsed, { log, errorLog, gitTransportReport });
291
298
  const result = await doctor({ fix: parsed.fix, printExports: parsed.printExports });
292
299
  // BOT-2146: where would this host's engine publish a leased stack's ports?
293
300
  // Informational (read-only, never changes the exit code); reported only when
@@ -310,8 +317,13 @@ export async function cmdDoctor(args, {
310
317
  }
311
318
  // BOT-1982: informational, like usage sync — never changes the exit code.
312
319
  const testIngest = await Promise.resolve().then(testIngestReport).catch(() => null);
320
+ // BOT-2599: informational in the full report (its exit code stays the auth
321
+ // verdict existing callers key on); `--only git_transport` is the gating form.
322
+ const gitTransport = await Promise.resolve()
323
+ .then(() => gitTransportReport({ fix: parsed.fix, remote: parsed.remote ?? null, url: parsed.url ?? null }))
324
+ .catch(() => ({ check: "git_transport", status: "unknown", reason: "check_failed" }));
313
325
  if (parsed.json) {
314
- log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}), ...(testIngest ? { test_ingest: testIngest } : {}) }));
326
+ log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}), ...(helperWorkers ? { helper_workers: helperWorkers } : {}), ...(testIngest ? { test_ingest: testIngest } : {}), ...(gitTransport ? { git_transport: gitTransport } : {}) }));
315
327
  } else {
316
328
  log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
317
329
  log(`client key: ${result.report.client_key.status}`);
@@ -329,11 +341,36 @@ export async function cmdDoctor(args, {
329
341
  if (usage) log(formatUsageSyncLine(usage));
330
342
  for (const line of formatHelperWorkersLines(helperWorkers, helperFix)) log(line);
331
343
  if (testIngest) log(formatTestIngestLine(testIngest));
344
+ if (gitTransport) {
345
+ log(formatGitTransportLine(gitTransport));
346
+ if (gitTransport.recovery) log(`git transport recovery: ${gitTransport.recovery}`);
347
+ }
332
348
  }
333
349
  if (result.exports) log(result.exports);
334
350
  return result.exitCode;
335
351
  }
336
352
 
353
+ /**
354
+ * BOT-2599: `bb doctor --only git_transport` — the one check, nothing else (no
355
+ * sign-in, no network), for a pre-push hook. Exit 0 = ok / n/a; 3 = needs_fix
356
+ * or unknown (a check that cannot prove a keepalive never passes); the remedy
357
+ * goes to stderr so a hook shows it.
358
+ */
359
+ async function doctorGitTransportOnly(parsed, { log, errorLog, gitTransportReport }) {
360
+ let report;
361
+ try {
362
+ report = await gitTransportReport({ fix: parsed.fix, remote: parsed.remote ?? null, url: parsed.url ?? null });
363
+ } catch {
364
+ report = null;
365
+ }
366
+ if (!report) report = { check: "git_transport", status: "unknown", reason: "check_failed", recovery: "the git_transport check failed to run; run `bb doctor --only git_transport` by hand" };
367
+ if (parsed.json) log(JSON.stringify({ git_transport: report }));
368
+ else log(formatGitTransportLine(report));
369
+ const passing = GIT_TRANSPORT_PASSING.has(report.status);
370
+ if (!passing && report.recovery && !parsed.json) errorLog(`recovery: ${report.recovery}`);
371
+ return passing ? 0 : 3;
372
+ }
373
+
337
374
  /** BOT-1021: is the daily sweep installed, and how did the last sync runs go? */
338
375
  export async function doctorUsageSync({ agentStatus = usageAgentStatus, status = usageStatus } = {}) {
339
376
  const agent = await agentStatus();
@@ -675,6 +712,8 @@ export async function cmdSetup(args, {
675
712
  selfHeal = selfHealAgentSession,
676
713
  callerResolver = resolveCaller,
677
714
  usageAgent = installUsageAgent,
715
+ // BOT-2599: setup also gives this clone's SSH push transport a keepalive.
716
+ gitTransport = async (opts) => checkGitTransport(opts),
678
717
  cwd = process.cwd(),
679
718
  env = process.env,
680
719
  platform = process.platform,
@@ -786,6 +825,7 @@ export async function cmdSetup(args, {
786
825
  errorLog(`bb setup: warning: ${warning} — held by ${who}. Confirm the active work before editing shared resources; typed locks remain required.`);
787
826
  }
788
827
  log(`bb setup: ${tenant} is ready (${session.source} worktree session). Run bb wait '<condition>'.`);
828
+ await reportGitTransport(gitTransport, { cwd, env, log });
789
829
  await reportUsageAgent(usageAgent, { cwd, log });
790
830
  return 0;
791
831
  } catch (error) {
@@ -805,6 +845,16 @@ async function reportUsageAgent(usageAgent, { cwd, log }) {
805
845
  else log(`bb setup: daily usage sync could not be installed (${result?.error ?? "unknown"}); run bb setup again to retry.`);
806
846
  }
807
847
 
848
+ // BOT-2599: never fails setup — the session is ready either way; the line says
849
+ // whether pushes from this clone keep their SSH connection through a long hook.
850
+ async function reportGitTransport(gitTransport, { cwd, env, log }) {
851
+ let report;
852
+ try { report = await gitTransport({ cwd, env, fix: true }); } catch { report = null; }
853
+ if (!report) report = { check: "git_transport", status: "unknown", reason: "check_failed" };
854
+ log(`bb setup: ${formatGitTransportLine(report)}`);
855
+ if (report.recovery) log(`bb setup: git transport recovery: ${report.recovery}`);
856
+ }
857
+
808
858
  export function loginHelp(log = console.log) {
809
859
  log(`${bold("botbuddy login")} — authenticate via OAuth (browser + loopback callback)
810
860
 
package/src/config.mjs CHANGED
@@ -9,8 +9,8 @@ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
9
9
  // BOT-876: overridable so the CLI can be pointed at a local Supabase stack
10
10
  // (`BOTBUDDY_SERVER_URL=http://127.0.0.1:56321/functions/v1/mcp-server`).
11
11
  // Production is the default; nothing changes for normal use.
12
- export const SERVER_URL = process.env.BOTBUDDY_SERVER_URL
13
- || "https://api.bot-buddy.ai/functions/v1/mcp-server";
12
+ export const DEFAULT_SERVER_URL = "https://api.bot-buddy.ai/functions/v1/mcp-server";
13
+ export const SERVER_URL = process.env.BOTBUDDY_SERVER_URL || DEFAULT_SERVER_URL;
14
14
 
15
15
  let config = {};
16
16
 
@@ -0,0 +1,13 @@
1
+ {
2
+ "source": "production MCP (api.bot-buddy.ai) via cli/src/api.mjs callToolJson, CLI worktree of BOT-2600",
3
+ "captured_at": "2026-10-20T19:15:53.070Z",
4
+ "note": "update_command_run for a random run id. Both requests are refused before any write; no credential value is recorded.",
5
+ "invalid_run_credential": {
6
+ "request": "x-agent-api-key: the caller's live session token; run_credential: 64 random hex",
7
+ "result": { "ok": true, "data": { "error": "run_credential_invalid" }, "isError": true }
8
+ },
9
+ "unknown_session_token": {
10
+ "request": "x-agent-api-key: bb_sess_ + 64 zeros",
11
+ "result": { "ok": false, "auth": true, "status": 401, "error": "unauthorized", "error_code": "session_token_revoked" }
12
+ }
13
+ }