@botbuddy/cli 1.5.5 → 1.6.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/bin/bb-pw.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runPw } from "../src/pw/run.mjs";
3
+
4
+ runPw(process.argv.slice(2)).then((code) => process.exit(code)).catch((error) => {
5
+ console.error(`bb-pw: ${error?.message ?? error}`);
6
+ process.exit(1);
7
+ });
package/bin/botbuddy.mjs CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import { run } from "../src/commands.mjs";
3
3
 
4
- run(process.argv.slice(2)).catch((err) => {
4
+ run(process.argv.slice(2)).then((code) => {
5
+ if (typeof code === "number") process.exitCode = code;
6
+ }).catch((err) => {
5
7
  console.error(`\x1b[31m✗\x1b[0m ${err?.message || err}`);
6
8
  process.exit(1);
7
9
  });
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.5.5",
3
+ "version": "1.6.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
7
- "botbuddy": "./bin/botbuddy.mjs"
7
+ "botbuddy": "./bin/botbuddy.mjs",
8
+ "bb-pw": "./bin/bb-pw.mjs"
8
9
  },
9
10
  "files": [
10
11
  "bin/",
@@ -20,9 +21,13 @@
20
21
  ],
21
22
  "license": "MIT",
22
23
  "engines": {
23
- "node": ">=18.0.0"
24
+ "node": ">=20.0.0"
24
25
  },
25
26
  "publishConfig": {
26
27
  "access": "public"
28
+ },
29
+ "dependencies": {
30
+ "@playwright/cli": "0.1.17",
31
+ "playwright": "1.62.0-alpha-1783623505000"
27
32
  }
28
33
  }
package/src/commands.mjs CHANGED
@@ -11,6 +11,7 @@ import { runWait } from "./wait.mjs";
11
11
  import { green, red, cyan, dim, bold, die } from "./utils.mjs";
12
12
  import { VERSION } from "./version.mjs";
13
13
  import { bootstrapProfile, ProfileBootstrapError, profileShellRefresh } from "./profile-bootstrap.mjs";
14
+ import { runPw } from "./pw/run.mjs";
14
15
 
15
16
  export async function run(argv) {
16
17
  loadConfig();
@@ -32,6 +33,7 @@ export async function run(argv) {
32
33
  case "docker": return cmdDocker(args);
33
34
  case "run": return cmdRun(args);
34
35
  case "wait": return runWait(args);
36
+ case "pw": return runPw(args);
35
37
  case "profile": return cmdProfile(args);
36
38
  case "resources": return callTool("list_resources");
37
39
  case "agents": return callTool("list_agents");
@@ -101,6 +103,10 @@ ${bold("AGENT WAITS")}
101
103
  wait [--any] <condition>... [options]
102
104
  Wait once for a pushed BotBuddy signal
103
105
 
106
+ ${bold("BROWSER LANES")}
107
+ pw <lane> <verb> [args…] Drive a lock-gated Playwright lane
108
+ pw --help Show bb-pw-compatible lane usage
109
+
104
110
  ${bold("OTHER")}
105
111
  locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
106
112
  help Show this help
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
 
4
4
  import { ensureProfileCredentialBackend, readProfileIdentity, readProfileRetryIdentity, recordProfileRetryIdentity, installProfileCredential, profileCredentialEnvironment } from "./agent-credential-store.mjs";
5
5
  import { callToolJson } from "./api.mjs";
6
+ import { latestPublicCliCommand } from "./public-invocation.mjs";
6
7
 
7
8
  const PROFILES = Object.freeze({
8
9
  "botbuddy-dev": Object.freeze({ tenant: "botbuddy" }),
@@ -92,7 +93,7 @@ export async function bootstrapProfile(profileName, {
92
93
  tenant_id: profile.tenant,
93
94
  agent_id: data.agent_id,
94
95
  credential_source: "keychain_profile_slot",
95
- shell_refresh: `source <(npx --yes @botbuddy/cli@latest profile env ${profileName})`,
96
+ shell_refresh: `source <(${latestPublicCliCommand(`profile env ${profileName}`)})`,
96
97
  gui_refresh: "$HOME/.local/bin/botbuddy-mcp-env.sh",
97
98
  };
98
99
  }
@@ -0,0 +1,7 @@
1
+ // BOT-1425: npm cannot infer a package executable once @botbuddy/cli exposes
2
+ // both botbuddy and bb-pw. Keep every installation-free command explicit.
3
+ export const LATEST_PUBLIC_CLI_PREFIX = "npm exec --yes --package=@botbuddy/cli@latest -- botbuddy";
4
+
5
+ export function latestPublicCliCommand(command = "") {
6
+ return command ? `${LATEST_PUBLIC_CLI_PREFIX} ${command}` : LATEST_PUBLIC_CLI_PREFIX;
7
+ }
@@ -0,0 +1,28 @@
1
+ import { TARGET_VERBS, parseTargetFlags, hasTargetFlags, buildLocatorTarget, classifyTarget, isSnapshotRef } from "./targets.mjs";
2
+ export const GLOBAL_VERBS = new Set(["list", "close-all", "kill-all", "reap"]);
3
+ const secret = /^@ENV:(.+)$/;
4
+ export function resolveRef(value, env = process.env) {
5
+ const match = secret.exec(String(value));
6
+ if (!match) return { value, secret: false };
7
+ if (!env[match[1]]) throw new Error(`bb-pw: env var ${match[1]} referenced by ${value} is not set`);
8
+ return { value: env[match[1]], ref: value, secret: true };
9
+ }
10
+ export const normaliseLane = (token) => String(token).replace(/^agent-0*/, "").replace(/^lane-/, "");
11
+ function validLane(lane) { return /^[1-9]\d*$/.test(lane); }
12
+ export function planInvocation(argv, env = process.env) {
13
+ if (!argv.length) throw new Error("bb-pw: usage: bb-pw <lane> <verb> [args…] (or: bb-pw <reap|list|close-all|kill-all>)");
14
+ if (GLOBAL_VERBS.has(argv[0])) return argv[0] === "reap" ? { scope: "global", verb: "reap", mode: "reap" } : { scope: "global", verb: argv[0], mode: "exec", execArgv: argv };
15
+ const lane = normaliseLane(argv[0]), verb = argv[1];
16
+ if (!validLane(lane) || !verb) throw new Error(`bb-pw: usage: lane must be a positive integer and include a verb`);
17
+ if (verb === "status") return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: "status" };
18
+ const rest = argv.slice(2); let forwarded = rest, target = null, fresh = false;
19
+ if (TARGET_VERBS.has(verb)) {
20
+ const parsed = parseTargetFlags(rest); fresh = parsed.fresh;
21
+ forwarded = hasTargetFlags(parsed.flags) ? [buildLocatorTarget(parsed.flags), ...parsed.rest] : parsed.rest;
22
+ target = forwarded.find((value) => !String(value).startsWith("--")) ?? null;
23
+ if (fresh && target && isSnapshotRef(target)) throw new Error("bb-pw: --fresh cannot target a snapshot ref; use a stable locator");
24
+ }
25
+ const resolved = forwarded.map((value) => resolveRef(value, env));
26
+ const sensitive = resolved.some((value) => value.secret);
27
+ return { scope: "lane", lane, session: `lane-${lane}`, verb, mode: sensitive ? "socket" : "exec", execArgv: sensitive ? null : [`-s=lane-${lane}`, verb, ...forwarded], socketArgs: sensitive ? [verb, ...resolved.map((value) => value.value)] : null, telemetryUrl: ["goto", "open", "go-back", "go-forward", "reload"].includes(verb) ? (resolved[0]?.secret ? resolved[0].ref : forwarded[0] ?? null) : null, secretValues: resolved.filter((value) => value.secret).map((value) => String(value.value)), rollup: verb === "close", target, targetKind: target === null ? null : classifyTarget(target), fresh };
28
+ }
@@ -0,0 +1,12 @@
1
+ import { SERVER_URL } from "../config.mjs";
2
+ export function createProfileCoordinator({ profile, identity, fetchImpl = fetch } = {}) {
3
+ if (!profile?.token || !identity?.agentId || identity.tenant !== profile.tenant) return { kind: "unverified" };
4
+ let id = 0;
5
+ async function call(name, args) {
6
+ const response = await fetchImpl(SERVER_URL, { method: "POST", headers: { "content-type": "application/json", "x-agent-api-key": profile.token }, body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method: "tools/call", params: { name, arguments: args } }) });
7
+ if (!response.ok) throw new Error(`BotBuddy lock verification returned HTTP ${response.status}`);
8
+ const json = await response.json(); if (json.error) throw new Error(json.error.message || "BotBuddy lock verification failed");
9
+ const text = json.result?.content?.find((item) => item.type === "text")?.text; try { return text ? JSON.parse(text) : {}; } catch { throw new Error("BotBuddy lock verification returned invalid JSON"); }
10
+ }
11
+ return { kind: "profile", async status({ host, slot }) { const result = await call("list_resources", { host, subtype: "playwright_lane" }); const list = Array.isArray(result) ? result : result.resources ?? []; const resource = list.find((item) => item.name === `playwright_lane:${host}:${slot}` || String(item.slot) === String(slot)); return resource ? { held: resource.status !== "free", heldBy: resource.owner_agent_id ?? null } : { held: false, heldBy: null }; }, async emit(event) { await call("record_lane_event", event); }, agentId: identity.agentId };
12
+ }
@@ -0,0 +1,13 @@
1
+ import { spawn } from "node:child_process";
2
+ import net from "node:net";
3
+ import { createRequire } from "node:module";
4
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
+ import { dirname, join } from "node:path";
6
+ import { homedir } from "node:os";
7
+ export const daemonDir = (env = process.env) => env.BB_PW_DAEMON_DIR || join(homedir(), ".botbuddy", "pw-daemon");
8
+ export function resolveCliBin(env = process.env) { if (env.BB_PW_CLI_BIN) return { cmd: "node", args: [env.BB_PW_CLI_BIN] }; const require = createRequire(import.meta.url); const pkg = require.resolve("@playwright/cli/package.json"), meta = JSON.parse(readFileSync(pkg, "utf8")), bin = typeof meta.bin === "string" ? meta.bin : meta.bin["playwright-cli"] || Object.values(meta.bin)[0]; return { cmd: "node", args: [join(dirname(pkg), bin)] }; }
9
+ export function spawnExec(plan, env = process.env) { const { cmd, args } = resolveCliBin(env); return new Promise((resolve) => { const child = spawn(cmd, [...args, ...plan.execArgv], { stdio: "inherit", env: { ...env, PWTEST_DAEMON_SESSION_DIR: daemonDir(env) } }); child.on("exit", (code) => resolve(code ?? 1)); child.on("error", () => resolve(127)); }); }
10
+ export function readSession(session, env = process.env) { const root = daemonDir(env); if (!existsSync(root)) return null; for (const item of readdirSync(root)) { try { return JSON.parse(readFileSync(join(root, item, `${session}.session`), "utf8")); } catch {} } return null; }
11
+ export function sendToDaemon(socketPath, positional, { connect = net.createConnection, cwd = process.cwd(), timeoutMs = 30000 } = {}) { return new Promise((resolve) => { let done = false, buffer = "", socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => socket.write(JSON.stringify({ id: 1, method: "run", params: { args: { _: positional }, cwd } }) + "\n")); socket.on("data", (data) => { buffer += String(data); const newline = buffer.indexOf("\n"); if (newline < 0) return; try { const reply = JSON.parse(buffer.slice(0, newline)), text = reply.result?.text ?? ""; finish(reply.error || /^### Error\b/m.test(text) ? { ok: false, error: reply.error?.message ?? reply.error ?? text } : { ok: true, text }); } catch { finish({ ok: false, error: "bb-pw: malformed daemon reply" }); } }); socket.on("error", (error) => finish({ ok: false, error: error.message })); setTimeout(() => finish({ ok: false, error: "bb-pw: daemon socket timeout" }), timeoutMs).unref(); }); }
12
+ export function socketAlive(socketPath, { connect = net.createConnection, timeoutMs = 1000 } = {}) { return new Promise((resolve) => { if (!socketPath) return resolve(false); let done = false, socket; const finish = (value) => { if (!done) { done = true; socket?.destroy(); resolve(value); } }; socket = connect(socketPath, () => finish(true)); socket.on("error", () => finish(false)); setTimeout(() => finish(false), timeoutMs).unref(); }); }
13
+ export async function socketRun(plan, env = process.env) { const session = readSession(plan.session, env); return session?.socketPath ? sendToDaemon(session.socketPath, plan.socketArgs) : { ok: false, error: `bb-pw: no open daemon for ${plan.session}; open it first` }; }
@@ -0,0 +1,4 @@
1
+ export const NAV = "navigate";
2
+ const interaction = new Set(["click", "fill", "type", "select", "press", "drag", "hover", "dblclick", "check", "uncheck"]);
3
+ export function actionTypeFromMethod(method) { const value = String(method); return ["goto", "open", "go-back", "go-forward", "reload"].includes(value) ? NAV : value === "screenshot" ? "screenshot" : value === "snapshot" ? "snapshot" : interaction.has(value) ? "interaction" : "other"; }
4
+ export function deriveSession(events, { meta = {} } = {}) { const ordered = [...events].sort((a,b) => a.ts-b.ts), type = (name) => ordered.filter((event) => actionTypeFromMethod(event.method) === name); const nav = type(NAV); return { ...meta, started_at: ordered[0]?.ts ?? null, ended_at: ordered.at(-1)?.ts ?? null, duration_ms: ordered.length ? ordered.at(-1).ts - ordered[0].ts : 0, active_ms: 0, idle_threshold_ms: 60000, navigations: nav.length, distinct_routes: [...new Set(nav.map((event) => { try { return new URL(event.url).pathname; } catch { return String(event.url).split("?")[0]; } }))], screenshots: type("screenshot").length, snapshots: type("snapshot").length, interactions: type("interaction").length, actions_total: ordered.length }; }
@@ -0,0 +1,5 @@
1
+ import { execSync } from "node:child_process";
2
+ import { readSession, socketAlive } from "./daemon.mjs";
3
+ export const parsePs = (text) => String(text).split("\n").map((line) => line.trim().match(/^(\d+)\s+(\d+)\s+(.*)$/)).filter(Boolean).map(([, pid, ppid, command]) => ({ pid: Number(pid), ppid: Number(ppid), command }));
4
+ export const selectCliDaemons = (list) => list.flatMap((row) => { const found = /cliDaemon\.js\s+(\S+)/.exec(row.command); return found ? [{ ...row, lane: found[1] }] : []; });
5
+ export async function reap({ env = process.env, stdout = process.stdout, exec = execSync, alive = socketAlive, kill = (pid) => { try { process.kill(pid, "SIGKILL"); } catch {} } } = {}) { const processes = selectCliDaemons(parsePs(exec("ps -axw -o pid=,ppid=,command=").toString())), stale = []; for (const daemon of processes) { if (!await alive(readSession(daemon.lane, env)?.socketPath)) { stale.push(daemon.pid); kill(daemon.pid); } } stdout.write(`bb-pw reap: ${stale.length} stale daemon(s) reaped${stale.length ? ` (${stale.join(",")})` : ""}\n`); return stale; }
package/src/pw/run.mjs ADDED
@@ -0,0 +1,24 @@
1
+ import os from "node:os";
2
+ import { planInvocation } from "./args.mjs";
3
+ import { actionTypeFromMethod, NAV } from "./readiness.mjs";
4
+ import { isStaleRefError, staleRefRemediation } from "./targets.mjs";
5
+ import { createProfileCoordinator } from "./coordinator.mjs";
6
+ import { resolveAgentProfile } from "../wait-profile.mjs";
7
+ import { readProfileIdentity } from "../agent-credential-store.mjs";
8
+ const hostFor = (env) => env.PLAYWRIGHT_MCP_HOST || env.HOSTNAME || os.hostname();
9
+ function help(out) { out.write("Usage: botbuddy pw [--profile <name>] <lane> <verb> [args…]\n\nAlias: bb-pw <lane> <verb> [args…]\n"); }
10
+ function redact(value, secretValues = []) { return secretValues.reduce((text, secret) => secret ? text.split(secret).join("[redacted]") : text, String(value ?? "")); }
11
+ async function gate({ env, host, lane, deps }) { if (env.BB_PW_NO_LOCK === "1") return { allowed: true }; let profile, identity; try { profile = await (deps.resolveProfile ?? resolveAgentProfile)({ cwd: deps.cwd ?? process.cwd(), env, explicitProfile: deps.profile ?? null }); identity = await (deps.readIdentity ?? readProfileIdentity)(profile.name); } catch (error) { return { allowed: false, message: `bb-pw: profile verification failed (${error.message}). Run botbuddy profile setup or set BB_PW_NO_LOCK=1 for local-only work.` }; } const coordinator = deps.coordinator ?? createProfileCoordinator({ profile, identity, fetchImpl: deps.fetch }); if (!profile.token || !identity || identity.tenant !== profile.tenant || coordinator.kind !== "profile") return { allowed: false, message: "bb-pw: profile identity is missing or tenant-mismatched. Run botbuddy profile setup or set BB_PW_NO_LOCK=1 for local-only work." }; try { const status = await coordinator.status({ host, slot: lane }); return status.held && status.heldBy === identity.agentId ? { allowed: true, coordinator } : { allowed: false, message: `bb-pw: lane lock playwright_lane:${host}:${lane} must be held by this profile. Acquire it first or set BB_PW_NO_LOCK=1 for local-only work.` }; } catch (error) { return { allowed: false, message: `bb-pw: could not verify lane lock (${error.message}). Set BB_PW_NO_LOCK=1 for local-only work.` }; } }
12
+ export async function runPw(argv, deps = {}) {
13
+ const env = deps.env ?? process.env, stdout = deps.stdout ?? process.stdout, stderr = deps.stderr ?? process.stderr; let args = [...argv]; if (["--help", "-h"].includes(args[0])) { help(stdout); return 0; } if (args[0] === "--profile") { if (!args[1]) { stderr.write("bb-pw: --profile needs a name\n"); return 2; } deps = { ...deps, profile: args[1] }; args = args.slice(2); }
14
+ let plan; try { plan = planInvocation(args, env); } catch (error) { stderr.write(`${error.message}\n`); return 2; }
15
+ const { spawnExec, socketRun } = deps.daemon ?? await import("./daemon.mjs");
16
+ if (plan.scope === "global") { if (plan.mode === "reap") { await (deps.reap ?? (await import("./reap.mjs")).reap)({ env, stdout }); return 0; } if (env.BB_PW_NO_LOCK !== "1") { stderr.write("bb-pw: close-all and kill-all require BB_PW_NO_LOCK=1 because they can affect lanes you do not own.\n"); return 3; } return spawnExec(plan, env); }
17
+ const telemetry = deps.telemetry ?? (await import("./telemetry.mjs")).makeTelemetry({ env }); const host = deps.host ?? hostFor(env);
18
+ const auth = await gate({ env, host, lane: plan.lane, deps }); if (!auth.allowed) { stderr.write(`${auth.message}\n`); return 3; }
19
+ if (plan.mode === "status") { const lock = auth.coordinator?.status ? await auth.coordinator.status({ host, slot: plan.lane }) : null; stdout.write(JSON.stringify({ lane: plan.lane, session: plan.session, host, lock, spooled_events: telemetry.count(plan.lane) }, null, 2) + "\n"); return 0; }
20
+ if (actionTypeFromMethod(plan.verb) !== "other") telemetry.append(plan.lane, { ts: Date.now(), method: plan.verb, url: actionTypeFromMethod(plan.verb) === NAV ? plan.telemetryUrl : null });
21
+ const inspect = plan.mode === "socket" || (actionTypeFromMethod(plan.verb) === "interaction" && (plan.targetKind === "ref" || plan.fresh)); let code;
22
+ if (inspect) { if (plan.fresh) await socketRun({ ...plan, socketArgs: ["snapshot"] }, env).catch(() => {}); const result = await socketRun({ ...plan, socketArgs: plan.socketArgs ?? plan.execArgv.slice(1) }, env); if (result.text) stdout.write(`${redact(result.text, plan.secretValues)}\n`); if (!result.ok) stderr.write(`${redact(plan.targetKind === "ref" && isStaleRefError(result.error) ? staleRefRemediation(plan.target) : result.error, plan.secretValues)}\n`); code = result.ok ? 0 : 1; } else code = await spawnExec(plan, env);
23
+ if (plan.rollup && code === 0) await telemetry.rollup({ lane: plan.lane, coordinator: auth.coordinator, host }); return code;
24
+ }
@@ -0,0 +1,37 @@
1
+ const REF = /^(f\d+)?e\d+$/;
2
+ export const TARGET_VERBS = new Set(["click", "dblclick", "fill", "hover", "check", "uncheck", "select"]);
3
+ export const isSnapshotRef = (value) => REF.test(String(value ?? ""));
4
+ export const classifyTarget = (value) => /^getBy[A-Z]/.test(String(value ?? "")) ? "locator" : isSnapshotRef(value) ? "ref" : "selector";
5
+ const BASE = ["role", "placeholder", "text", "testid", "label", "title", "alt"];
6
+ const quote = (value) => `'${String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
7
+ export function parseTargetFlags(args) {
8
+ const flags = {}, rest = []; let fresh = false;
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const match = /^--([a-z]+)(?:=(.*))?$/s.exec(String(args[index]));
11
+ if (!match) { rest.push(args[index]); continue; }
12
+ const [, name, inline] = match;
13
+ if (name === "fresh") { fresh = true; continue; }
14
+ if (name === "exact") { flags.exact = true; continue; }
15
+ if (![...BASE, "name"].includes(name)) { rest.push(args[index]); continue; }
16
+ const value = inline ?? args[++index];
17
+ if (value === undefined) throw new Error(`bb-pw: --${name} needs a value`);
18
+ flags[name] = value;
19
+ }
20
+ return { flags, rest, fresh };
21
+ }
22
+ export const hasTargetFlags = (flags) => Object.keys(flags).some((key) => key !== "exact");
23
+ function exact(method, value, enabled) { return enabled ? `${method}(${quote(value)}, { exact: true })` : `${method}(${quote(value)})`; }
24
+ export function buildLocatorTarget(flags) {
25
+ const bases = BASE.filter((name) => flags[name] !== undefined);
26
+ if (flags.name !== undefined && flags.role === undefined) throw new Error("bb-pw: --name qualifies --role; pass --role too");
27
+ if (bases.length !== 1) throw new Error(`bb-pw: give exactly one base locator, got: ${bases.map((name) => `--${name}`).join(", ") || "none"}`);
28
+ const base = bases[0], isExact = flags.exact === true;
29
+ if (base === "role") {
30
+ const opts = flags.name === undefined ? "" : `, { name: ${quote(flags.name)}${isExact ? ", exact: true" : ""} }`;
31
+ return `getByRole(${quote(flags.role)}${opts})`;
32
+ }
33
+ if (base === "testid") return `getByTestId(${quote(flags.testid)})`;
34
+ return exact({ placeholder: "getByPlaceholder", text: "getByText", label: "getByLabel", title: "getByTitle", alt: "getByAltText" }[base], flags[base], isExact);
35
+ }
36
+ export const isStaleRefError = (text) => /not found in the current page snapshot/i.test(String(text ?? ""));
37
+ export const staleRefRemediation = (ref) => `bb-pw: snapshot ref ${ref} is stale — capture a new snapshot or use a stable locator (--role, --text, --testid)${""}.`;
@@ -0,0 +1,14 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { deriveSession } from "./readiness.mjs";
5
+ const spoolDir = (env) => env.BB_PW_SPOOL_DIR || join(homedir(), ".botbuddy", "pw-spool");
6
+ export function makeTelemetry({ env = process.env } = {}) {
7
+ const path = (lane) => join(spoolDir(env), `lane-${lane}.jsonl`);
8
+ const readEvents = (lane) => existsSync(path(lane)) ? readFileSync(path(lane), "utf8").split("\n").filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } }) : [];
9
+ return {
10
+ append(lane, event) { mkdirSync(spoolDir(env), { recursive: true }); appendFileSync(path(lane), JSON.stringify({ ts: event.ts, method: event.method, url: event.url ?? null }) + "\n"); },
11
+ count(lane) { return readEvents(lane).length; },
12
+ async rollup({ lane, coordinator, host }) { const events = readEvents(lane); if (!events.length) return { emitted: false, count: 0 }; const session = deriveSession(events, { meta: { host, slot: String(lane), ticket_id: env.BB_PW_TICKET_ID, ticket_url: env.BB_PW_TICKET_URL, pr_id: env.BB_PW_PR_ID, pr_url: env.BB_PW_PR_URL, branch: env.BB_PW_BRANCH, commit_sha: env.BB_PW_COMMIT, environment: env.BB_PW_ENV } }); const event = { ...session, started_at: new Date(session.started_at).toISOString(), ended_at: new Date(session.ended_at).toISOString(), ended_reason: "close" }; try { if (!coordinator?.emit) return { emitted: false, count: events.length, session: event }; await coordinator.emit(event); rmSync(path(lane), { force: true }); return { emitted: true, count: events.length, session: event }; } catch { return { emitted: false, count: events.length, session: event }; } },
13
+ };
14
+ }
package/src/stack.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
  // The canonical batch flow is: stack up → test → break → fix → test → stack done.
12
12
  //
13
13
  // Design contract (matches bb-wait):
14
- // * Dependency-free (Node stdlib only) so `npx @botbuddy/cli stack` cold-starts fast.
14
+ // * Dependency-free (Node stdlib only) so an explicit `npm exec --package` invocation cold-starts fast.
15
15
  // * Exactly ONE single-line JSON receipt to stdout; all diagnostics go to stderr.
16
16
  // * Receipt ≤ 10 KB (documented, --receipt-max-bytes overridable).
17
17
  // * Documented, meaningful exit codes.
package/src/wait.mjs CHANGED
@@ -17,12 +17,13 @@ import { EXIT, parseConditions, parseSseFrames, runWaitLoop, normalizeSince, tru
17
17
  import { resolveAgentProfile, withPrincipalReceipt } from "./wait-profile.mjs";
18
18
  import { VERSION } from "./version.mjs";
19
19
  import { fileURLToPath } from "node:url";
20
+ import { latestPublicCliCommand } from "./public-invocation.mjs";
20
21
 
21
22
  // A protocol is deliberately distinct from package semver: compatible pinned
22
23
  // clients keep working until the server raises this minimum, while a stale
23
24
  // implementation gets a typed, safe upgrade instruction.
24
25
  export const WAIT_PROTOCOL_VERSION = 1;
25
- const CLI_UPGRADE_COMMAND = "npx --yes @botbuddy/cli@latest wait";
26
+ const CLI_UPGRADE_COMMAND = latestPublicCliCommand("wait");
26
27
  const MIN_RECEIPT_MAX_BYTES = 512;
27
28
 
28
29
  const HELP = `botbuddy wait — one wait command instead of a polling loop (BOT-989)
@@ -458,7 +459,7 @@ function emit(receipt, { versioned = false, maxBytes = null } = {}) {
458
459
  }
459
460
 
460
461
  function profileRecovery(profile) {
461
- return `npx --yes @botbuddy/cli@latest profile setup ${profile.name}`;
462
+ return latestPublicCliCommand(`profile setup ${profile.name}`);
462
463
  }
463
464
 
464
465
  function typedProfileError(errorCode) {
@@ -1 +0,0 @@
1
- {"schema_version":1,"source_version":"1.5.1","source_identity":"7bee97ef5e6148138f44fc770322b2bfffe059e2187b6ab7970ab98548f73763"}