@botbuddy/cli 1.64.12 → 1.64.13

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.64.12",
3
- "source_commit": "ac15a977c04652b558eb4353f232ba4e26c87740",
4
- "source_sha256": "6eadb8a389e466c1de0b4e3ad98a4978482eef4ae4afa59013f367efdd9b8805",
2
+ "release_version": "1.64.13",
3
+ "source_commit": "db2f7f45e8e0444857a2046c18481e107e3ebf58",
4
+ "source_sha256": "1533000de11720445186669ac3bf3eb7c663962f809b4035a52fa4de9c368213",
5
5
  "target": "darwin-arm64",
6
- "sha256": "50423b55ca6646d05cc79d73f3eac4ecc55f0d7bbed05f288e8b297c2a5b3cec"
6
+ "sha256": "8c2dd7c45a7e1c697fa47e5044a1b1cd6703efbb7fc648e1042a104c784094bb"
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
- "release_version": "1.64.12",
3
- "source_commit": "ac15a977c04652b558eb4353f232ba4e26c87740",
4
- "source_sha256": "6eadb8a389e466c1de0b4e3ad98a4978482eef4ae4afa59013f367efdd9b8805",
2
+ "release_version": "1.64.13",
3
+ "source_commit": "db2f7f45e8e0444857a2046c18481e107e3ebf58",
4
+ "source_sha256": "1533000de11720445186669ac3bf3eb7c663962f809b4035a52fa4de9c368213",
5
5
  "target": "linux-x64",
6
- "sha256": "851f72b5be98d48eb631ea5dac259e0be32bcc1cf218c53e666bcdcc0bd02e6d"
6
+ "sha256": "81345fbe3fde99c1b41f7d1738f00ef2bf4be9de12fe39e0755f23546ed4ed3f"
7
7
  }
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schema_version": 1,
3
- "release_version": "1.64.12",
4
- "source_commit": "ac15a977c04652b558eb4353f232ba4e26c87740",
5
- "source_sha256": "6eadb8a389e466c1de0b4e3ad98a4978482eef4ae4afa59013f367efdd9b8805",
3
+ "release_version": "1.64.13",
4
+ "source_commit": "db2f7f45e8e0444857a2046c18481e107e3ebf58",
5
+ "source_sha256": "1533000de11720445186669ac3bf3eb7c663962f809b4035a52fa4de9c368213",
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": "50423b55ca6646d05cc79d73f3eac4ecc55f0d7bbed05f288e8b297c2a5b3cec",
11
+ "sha256": "8c2dd7c45a7e1c697fa47e5044a1b1cd6703efbb7fc648e1042a104c784094bb",
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": "851f72b5be98d48eb631ea5dac259e0be32bcc1cf218c53e666bcdcc0bd02e6d",
18
+ "sha256": "81345fbe3fde99c1b41f7d1738f00ef2bf4be9de12fe39e0755f23546ed4ed3f",
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.64.12",
3
+ "version": "1.64.13",
4
4
  "description": "BotBuddy \u2014 Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
package/src/commands.mjs CHANGED
@@ -10,7 +10,7 @@ import { bootstrapHarnessSession, clearHarnessSession, doctorBootstrapState, for
10
10
  import { loadConfig, getConfig, clearConfig, getConfigPath, SERVER_URL } from "./config.mjs";
11
11
  import { resolveOwnerToken, resolveAgentKey, clearOwnerToken } from "./cli-credentials.mjs";
12
12
  import { agentProfileKeyLabel } from "./credential-kinds.mjs";
13
- import { buildAcquireResourcesPayload, LocksUsageError } from "./locks.mjs";
13
+ import { buildAcquireResourcesPayload, LocksUsageError, classifyLockResponse, lockOutcomeLines, unlockOutcomeLines, UNLOCK_QUEUED_USAGE } from "./locks.mjs";
14
14
  import { CallUsageError, discoveryUrlFor, formatDiscovery, parseCallArgs } from "./discovery.mjs";
15
15
  import { cmdStack } from "./stack.mjs";
16
16
  import { cmdManagedAllocation } from "./managed-allocation.mjs";
@@ -302,7 +302,14 @@ ${bold("STAGING SIGN-IN")} (BOT-2084)
302
302
  ${bold("LOCKS")}
303
303
  locks -m [--host name] Reserve typed local resources, including Playwright MCP lanes
304
304
  lock <resource_name> <type> Acquire one named lock (acquire_lock)
305
- unlock <resource_name> Release one named lock (release_lock)
305
+ locks/lock: exit 0 acquired · 2 queued · 1 refused.
306
+ Use a resource only after exit 0; queued means another
307
+ agent holds it and you are in line for it.
308
+ unlock <resource_name> Release one named lock you hold (release_lock); it
309
+ does not withdraw a queued request
310
+ unlock --queued <resource_id> <resource_lock_id>
311
+ Withdraw your queued request (ids from the queued
312
+ lock result; retire_queued_lock_attempt)
306
313
 
307
314
  ${bold("DURABLE WORKLOADS")} (${cyan("bb help run")})
308
315
  run --session-id <id> --environment <env> -- <command>
@@ -1713,23 +1720,41 @@ export async function cmdMcp(args, {
1713
1720
  die(`Unknown mcp subcommand: ${sub}. Run ${cyan("botbuddy mcp --help")}.`);
1714
1721
  }
1715
1722
 
1716
- function cmdLock(args) {
1723
+ // BOT-2994: each lock command returns its exit code (0 acquired · 2 queued ·
1724
+ // 1 refused; auth 3 / transport 5 unchanged) and says on stderr what the caller
1725
+ // holds. stdout keeps the server's result exactly as before.
1726
+ async function finishLock(response, lines) {
1727
+ const outcome = classifyLockResponse(response);
1728
+ for (const line of lines(outcome)) console.error(line);
1729
+ return outcome.exit;
1730
+ }
1731
+
1732
+ async function cmdLock(args) {
1717
1733
  if (args.length < 2) die("Usage: botbuddy lock <resource_name> <type>");
1718
- return callTool("acquire_lock", { resource_name: args[0], resource_type: args[1] });
1734
+ return finishLock(await callTool("acquire_lock", { resource_name: args[0], resource_type: args[1] }), lockOutcomeLines);
1719
1735
  }
1720
1736
 
1721
- function cmdLocks(args) {
1737
+ async function cmdLocks(args) {
1738
+ let payload;
1722
1739
  try {
1723
- return callTool("acquire_resources", buildAcquireResourcesPayload(args));
1740
+ payload = buildAcquireResourcesPayload(args);
1724
1741
  } catch (error) {
1725
1742
  if (error instanceof LocksUsageError) die(error.message);
1726
1743
  throw error;
1727
1744
  }
1745
+ return finishLock(await callTool("acquire_resources", payload), lockOutcomeLines);
1728
1746
  }
1729
1747
 
1730
- function cmdUnlock(args) {
1748
+ async function cmdUnlock(args) {
1749
+ // BOT-2994: release_lock only releases a lock you HOLD; a queued request is
1750
+ // withdrawn by its exact resource + queued-ledger ids (retire_queued_lock_attempt).
1751
+ if (args[0] === "--queued") {
1752
+ if (args.length !== 3 || !args[1] || !args[2] || args[1].startsWith("-") || args[2].startsWith("-")) die(UNLOCK_QUEUED_USAGE);
1753
+ const response = await callTool("retire_queued_lock_attempt", { resource_id: args[1], resource_lock_id: args[2] });
1754
+ return finishLock(response, (outcome) => unlockOutcomeLines(outcome, { queued: true }));
1755
+ }
1731
1756
  if (!args[0]) die("Usage: botbuddy unlock <resource_name>");
1732
- return callTool("release_lock", { resource_name: args[0] });
1757
+ return finishLock(await callTool("release_lock", { resource_name: args[0] }), (outcome) => unlockOutcomeLines(outcome));
1733
1758
  }
1734
1759
 
1735
1760
  function cmdTask(args) {
package/src/locks.mjs CHANGED
@@ -152,3 +152,108 @@ export function buildAcquireResourcesPayload(args, options = {}) {
152
152
  ...(environment ? { environment } : {}),
153
153
  };
154
154
  }
155
+
156
+ // BOT-2994: `bb lock`, `bb locks` and `bb unlock` exit by what the server did, not
157
+ // by whether it answered. Before this every one of them exited 0, so a shell loop
158
+ // that trusted the exit code drove another agent's Playwright lane (ENT-6416).
159
+ // 0 = acquired (or released / withdrawn); 2 = queued behind a holder (you hold
160
+ // nothing yet; wait for the grant or withdraw); 1 = refused or not understood.
161
+ // Auth (3) and transport (5) exits from callTool pass through unchanged.
162
+ export const LOCK_EXIT = Object.freeze({ ACQUIRED: 0, REFUSED: 1, QUEUED: 2 });
163
+
164
+ function parseJson(text) {
165
+ try { return JSON.parse(text); } catch { return undefined; }
166
+ }
167
+
168
+ function toolText(response) {
169
+ const content = response?.result?.content;
170
+ if (!Array.isArray(content)) return null;
171
+ const text = content.map((part) => part?.text ?? "").join("\n");
172
+ return text || null;
173
+ }
174
+
175
+ /**
176
+ * Classify a lock tool response (acquire_lock, acquire_resources, release_lock,
177
+ * retire_queued_lock_attempt) as returned by callTool: the JSON-RPC body, or a
178
+ * numeric exit when callTool already classified an auth/transport failure.
179
+ * Fails closed: an error flag, `success: false`, or an unreadable result is
180
+ * never "acquired".
181
+ */
182
+ export function classifyLockResponse(response) {
183
+ if (typeof response === "number") return { exit: response, results: [] };
184
+ const isError = response?.result?.isError === true;
185
+ const text = toolText(response);
186
+ if (text === null) return { exit: LOCK_EXIT.REFUSED, results: [] };
187
+ const parsed = parseJson(text);
188
+ let results;
189
+ if (parsed && Array.isArray(parsed.results)) {
190
+ results = parsed.results.map((r) => ({ ...r, success: r?.success === true }));
191
+ } else if (parsed && typeof parsed === "object" && "success" in parsed) {
192
+ results = [{ ...parsed, resource_name: parsed.resource_name ?? parsed.resource, success: parsed.success === true && !isError }];
193
+ } else {
194
+ // The human success string (acquire_lock / release_lock) or a plain error text.
195
+ results = [{ success: !isError, message: text }];
196
+ }
197
+ const failed = results.filter((r) => !r.success);
198
+ let exit = LOCK_EXIT.ACQUIRED;
199
+ if (failed.some((r) => r.queued !== true)) exit = LOCK_EXIT.REFUSED;
200
+ else if (failed.length) exit = LOCK_EXIT.QUEUED;
201
+ else if (isError || !results.length) exit = LOCK_EXIT.REFUSED;
202
+ return { exit, results };
203
+ }
204
+
205
+ function holderLabel(holder) {
206
+ if (!holder || typeof holder !== "object") return null;
207
+ const who = holder.agent ?? holder.agent_name ?? holder.name ?? null;
208
+ if (!who) return null;
209
+ const ticket = holder.ticket_id ? `, ${holder.ticket_id}` : "";
210
+ return `${who}${ticket}`;
211
+ }
212
+
213
+ // `bb wait 'lock:…,claim=true'` only addresses typed resources
214
+ // (`<subtype>:<host>:<slot>`); an untyped name gets no wait line.
215
+ function claimWaitFor(name) {
216
+ const parts = typeof name === "string" ? name.split(":") : [];
217
+ if (parts.length !== 3 || parts.some((p) => !p)) return null;
218
+ const [subtype, host, slot] = parts;
219
+ return `bb wait 'lock:subtype=${subtype},host=${host},slot=${slot},claim=true' --heartbeat`;
220
+ }
221
+
222
+ /** The stderr lines that tell the caller what it holds and what to do next. */
223
+ export function lockOutcomeLines({ exit, results }) {
224
+ if (exit === LOCK_EXIT.ACQUIRED || exit > LOCK_EXIT.QUEUED) return [];
225
+ const lines = [`✗ Lock not acquired (exit ${exit}: ${exit === LOCK_EXIT.QUEUED ? "queued" : "refused"}). Do not use ${results.length > 1 ? "these resources" : "this resource"} until a lock command exits 0.`];
226
+ for (const r of results.filter((x) => !x.success)) {
227
+ const name = r.resource_name ?? r.resource ?? "resource";
228
+ const holder = holderLabel(r.holder);
229
+ if (r.queued === true) {
230
+ lines.push(` ${name}: queued${holder ? ` behind ${holder}` : ""}${r.queue_depth != null ? ` (queue depth ${r.queue_depth})` : ""}.`);
231
+ const wait = claimWaitFor(name);
232
+ if (wait) lines.push(` wait to be handed it: ${wait}`);
233
+ if (r.resource_id && r.resource_lock_id) lines.push(` or withdraw the request: bb unlock --queued ${r.resource_id} ${r.resource_lock_id}`);
234
+ } else {
235
+ lines.push(` ${name}: refused${holder ? ` (held by ${holder})` : ""}${r.message ? ` — ${r.message}` : ""}`);
236
+ }
237
+ }
238
+ const held = results.filter((r) => r.success && r.resource_name);
239
+ if (held.length && results.length > 1) {
240
+ lines.push(" Acquired in this batch (release what you will not use):");
241
+ for (const r of held) lines.push(` bb unlock ${r.resource_name}`);
242
+ }
243
+ return lines;
244
+ }
245
+
246
+ /** `bb unlock` refused: say how a queued caller withdraws instead. */
247
+ export function unlockOutcomeLines({ exit, results }, { queued = false } = {}) {
248
+ if (exit === LOCK_EXIT.ACQUIRED || exit > LOCK_EXIT.QUEUED) return [];
249
+ // The first line is the server's reason; later lines are generic guidance.
250
+ const why = results.find((r) => r.message)?.message?.split("\n")[0];
251
+ if (queued) return [`✗ Queued request not withdrawn${why ? `: ${why}` : ""}.`];
252
+ return [
253
+ `✗ Lock not released${why ? `: ${why}` : ""}.`,
254
+ " bb unlock <name> releases a lock you hold; it does not withdraw a queued request.",
255
+ " To withdraw one, use the ids from the queued lock result: bb unlock --queued <resource_id> <resource_lock_id>",
256
+ ];
257
+ }
258
+
259
+ export const UNLOCK_QUEUED_USAGE = "Usage: botbuddy unlock --queued <resource_id> <resource_lock_id>";
package/src/pw/run.mjs CHANGED
@@ -92,7 +92,7 @@ async function gate({ env, host, lane, deps }) {
92
92
  // callerIds/holder let staging-auth cleanup touch only what this caller installed.
93
93
  const callerIds = [...selfAgentIds];
94
94
  if (status.held && selfAgentIds.has(status.heldBy)) return { allowed: true, coordinator, canonicalHost, holderMatch: "self", holder: status.heldBy, callerIds };
95
- if (status.held && status.ownerIsCaller === true) return { allowed: true, coordinator, canonicalHost, holderMatch: "owner", holder: status.heldBy ?? null, callerIds };
95
+ if (status.held && status.ownerIsCaller === true) return { allowed: true, coordinator, canonicalHost, holderMatch: "owner", holder: status.heldBy ?? null, holderName: status.heldByName ?? null, callerIds };
96
96
  // Distinguish an expired/free lane from one another operator holds so the
97
97
  // operator knows whether to re-acquire vs. wait for a handover (BOT-660). Name it
98
98
  // by the server's canonical host so a not-held message matches the dashboard even
@@ -296,6 +296,23 @@ async function runPwInner(argv, deps = {}) {
296
296
  // Report the host the lock service actually stored (alias-collapsed), so the
297
297
  // status view and lane telemetry match the server, not the local string (AC-4).
298
298
  const effHost = auth.canonicalHost ?? host;
299
+ // BOT-2994: a lane is driven only by the agent that holds it. The gate also
300
+ // passes a same-owner holder (BOT-1490 owner_is_caller), and every agent one
301
+ // person runs shares that owner, so an agent whose `bb lock` had only queued
302
+ // drove another agent's browser (ENT-6416, 2026-11-03). Resolve an owner-only
303
+ // match to the exact agent once (server whoami); only read-only `status` may
304
+ // proceed without it. `auth` keeps its own staging refusal receipt below.
305
+ let holderMatch = auth.holderMatch;
306
+ if (holderMatch === "owner" && auth.holder && authCtx.resolveCallerId) {
307
+ const me = await authCtx.resolveCallerId().catch(() => null);
308
+ if (me && me === auth.holder) { holderMatch = "self"; authCtx.callerIds = [...authCtx.callerIds, me]; }
309
+ }
310
+ if (holderMatch === "owner" && plan.mode !== "status" && plan.mode !== "auth") {
311
+ const lane = `playwright_lane:${effHost}:${plan.lane}`;
312
+ const who = `${auth.holder ?? "unknown"}${auth.holderName ? ` (${auth.holderName})` : ""}`;
313
+ stderr.write(`bb-pw: lane lock ${lane} is held by ${who}, another agent session of your owner, not this agent session. A lane is driven only by the agent that holds it. Claim a lane from this session with bb locks -m --lane <n> --ticket <ticket> and use it only after that exits 0, or wait to be handed this one: bb wait 'lock:subtype=playwright_lane,host=${effHost},slot=${plan.lane},claim=true' --heartbeat\n`);
314
+ return 3;
315
+ }
299
316
  // BOT-1318: resolve the lane's browser-acceptance tracker AFTER the gate (a
300
317
  // refused/unverified lane writes no lifecycle) and never for unlocked local mode.
301
318
  let tracker = null;
@@ -312,11 +329,6 @@ async function runPwInner(argv, deps = {}) {
312
329
  if (tracker && envResolution.error) { stderr.write(`${envResolution.error}\n`); return 2; }
313
330
  const environment = envResolution.environment ?? null;
314
331
  if (plan.mode === "auth") {
315
- let holderMatch = auth.holderMatch;
316
- if (holderMatch === "owner" && auth.holder && authCtx.resolveCallerId) {
317
- const me = await authCtx.resolveCallerId().catch(() => null);
318
- if (me && me === auth.holder) { holderMatch = "self"; authCtx.callerIds = [...authCtx.callerIds, me]; }
319
- }
320
332
  if (holderMatch !== "self") return stagingAuth.refuse({ stdout, stderr }, "exact_lane_holder_required", 3, {}, authVars(plan, deps));
321
333
  if (tracker) {
322
334
  const browserSession = await tracker.status();