@botbuddy/cli 1.40.0 → 1.40.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.40.0",
3
+ "version": "1.40.2",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -99,6 +99,23 @@ export function applyTurnModelOverride(bootstrappedSessions, threadId, model, re
99
99
  return true;
100
100
  }
101
101
 
102
+ // BOT-2058: a bridge-created thread does not have a visible command-result
103
+ // surface before its first turn. Put an advisory live-holder warning in that
104
+ // turn's input, so the agent sees it before it can edit or claim a resource.
105
+ export function prependTicketSessionWarning(prompt, concurrency) {
106
+ const warning = concurrency?.warning;
107
+ if (typeof warning !== "string" || !warning.trim()) return prompt;
108
+ return `BotBuddy ticket-session warning: ${warning}\n\n${prompt}`;
109
+ }
110
+
111
+ export function consumeTicketSessionWarning(bootstrappedSessions, threadId, result) {
112
+ if (result?.error) return false;
113
+ const stored = bootstrappedSessions?.get?.(threadId);
114
+ if (!stored?.ticket_session_warning) return false;
115
+ stored.ticket_session_warning = null;
116
+ return true;
117
+ }
118
+
102
119
  // BOT-1650: retire a thread's admission only after its terminal (archived)
103
120
  // lifecycle update has persisted. relayPost returns the relay's error body (no
104
121
  // throw) on a transient 4xx/5xx, so a missing thread_id means the archived
@@ -677,8 +694,11 @@ export async function runBridge(args) {
677
694
  call: callToolJson,
678
695
  callOptions: { auth: await authHeaders() },
679
696
  });
680
- if (bootstrap.ticket_signals?.concurrency) {
681
- throw new Error(bootstrap.ticket_signals.concurrency.warning);
697
+ if (bootstrap.ticket_signals?.policy) {
698
+ throw new Error(bootstrap.ticket_signals.concurrency?.warning || "Ticket-session exclusivity could not be verified.");
699
+ }
700
+ if (bootstrap.ticket_signals?.concurrency?.holders?.length) {
701
+ console.warn(` ${yellow("!")} Ticket-session warning: ${bootstrap.ticket_signals.concurrency.warning}`);
682
702
  }
683
703
  if (bootstrap.linkage?.status === "gap") {
684
704
  throw new Error(bootstrap.linkage.warning || "Session bootstrap requires ticket attribution.");
@@ -699,6 +719,7 @@ export async function runBridge(args) {
699
719
  // overwritten to the default on the first 15s heartbeat.
700
720
  model: cmd.params.model || model,
701
721
  cwd: requestedWorktree,
722
+ ticket_session_warning: bootstrap.ticket_signals?.concurrency || null,
702
723
  });
703
724
  const linkPayload = {
704
725
  session_id: sessionId,
@@ -725,6 +746,7 @@ export async function runBridge(args) {
725
746
  display_name: bootstrap.display_name,
726
747
  session_id: bootstrap.session_id,
727
748
  expires_at: bootstrap.expires_at,
749
+ ...(bootstrap.ticket_signals?.concurrency ? { ticket_session_warning: bootstrap.ticket_signals.concurrency } : {}),
728
750
  };
729
751
  }
730
752
  break;
@@ -740,9 +762,10 @@ export async function runBridge(args) {
740
762
  result = { error: "Thread session bootstrap did not complete; start a new thread after resolving its authentication or ticket conflict." };
741
763
  break;
742
764
  }
765
+ const bootstrap = bootstrappedSessions.get(codexThreadId);
743
766
  result = await sendWs("turn/start", {
744
767
  threadId: codexThreadId,
745
- input: [{ type: "text", text: cmd.params.prompt || cmd.params.text || "" }],
768
+ input: [{ type: "text", text: prependTicketSessionWarning(cmd.params.prompt || cmd.params.text || "", bootstrap?.ticket_session_warning) }],
746
769
  ...(cmd.params.model && { model: cmd.params.model }),
747
770
  });
748
771
  // BOT-1650 (Codex round-35 P2): a per-turn model override must be written
@@ -750,6 +773,7 @@ export async function runBridge(args) {
750
773
  // creation-time model and overwrites agents.model — misattributing the
751
774
  // overridden turn on the dashboard and in effectiveness reporting.
752
775
  applyTurnModelOverride(bootstrappedSessions, codexThreadId, cmd.params.model, result);
776
+ consumeTicketSessionWarning(bootstrappedSessions, codexThreadId, result);
753
777
  break;
754
778
  }
755
779
 
package/src/commands.mjs CHANGED
@@ -399,6 +399,17 @@ export function parseLoginArgs(args) {
399
399
  return out;
400
400
  }
401
401
 
402
+ /**
403
+ * BOT-2030/BOT-2044: the sign-in mode for parsed login options. `--device` /
404
+ * `--loopback` win; `--no-browser` is a loopback-only option (print the URL,
405
+ * keep the localhost listener), so on its own it is an explicit loopback
406
+ * choice; otherwise shouldUseDeviceFlow auto-detects (SSH, display-less Linux).
407
+ */
408
+ export function resolveLoginDevice(options = {}, { env = process.env, platform = process.platform } = {}) {
409
+ const flag = typeof options.device === "boolean" ? options.device : options.noBrowser ? false : undefined;
410
+ return shouldUseDeviceFlow({ env, platform, flag });
411
+ }
412
+
402
413
  // BOT-1580: resolve the caller identity from parsed flags, falling back to the
403
414
  // BOTBUDDY_CALLER* environment variables so an agent harness can advertise who it
404
415
  // is without every wrapper having to pass flags. A flag always wins over the env.
@@ -477,7 +488,7 @@ async function cmdLogin(args, { errorLog = (line) => console.error(line) } = {})
477
488
  // BOT-1580: merge caller flags with the BOTBUDDY_CALLER* env fallback.
478
489
  const caller = resolveCaller(options, process.env);
479
490
  try {
480
- const device = shouldUseDeviceFlow({ env: process.env, platform: process.platform, flag: options.device });
491
+ const device = resolveLoginDevice(options, { env: process.env, platform: process.platform });
481
492
  await doLogin({ noBrowser: options.noBrowser, tenant: options.tenant, device, ...caller });
482
493
  } catch (err) {
483
494
  die(err.message);
@@ -507,7 +518,7 @@ export async function cmdSetup(args, {
507
518
  return error.exitCode === 2 ? 2 : 4;
508
519
  }
509
520
  if (options.help) {
510
- log("bb setup [--no-browser] [--tenant <slug>] [--device | --loopback]\n\nSigns in, verifies the committed .botbuddy-agent.json binding, and caches this worktree's session. Over SSH the sign-in uses a device code you approve from any browser (--device / --loopback force a mode). After it succeeds, use `bb wait <condition>` with no credential flags or environment variables.");
521
+ log("bb setup [--no-browser] [--tenant <slug>] [--device | --loopback]\n\nSigns in, verifies the committed .botbuddy-agent.json binding, and caches this worktree's session. Over SSH the sign-in uses a device code you approve from any browser (--device / --loopback force a mode). --loopback or --no-browser over SSH keeps the localhost callback on the remote host, so first forward its port with ssh -L <port>:127.0.0.1:<port> <host> (the port is in the printed URL), or setup times out. After it succeeds, use `bb wait <condition>` with no credential flags or environment variables.");
511
522
  return 0;
512
523
  }
513
524
  let bound;
@@ -533,19 +544,18 @@ export async function cmdSetup(args, {
533
544
  // reusable client key with a tenant-sealed one.
534
545
  await login({
535
546
  noBrowser: options.noBrowser,
536
- device: shouldUseDeviceFlow({ env, platform, flag: options.device }),
547
+ device: resolveLoginDevice(options, { env, platform }),
537
548
  ...callerResolver(options, env),
538
549
  });
539
550
  // Setup is an explicit session bootstrap/repair command. Rotate before
540
551
  // resolving so it cannot merely report an old cache as ready.
541
552
  await clearState(cwd);
542
553
  const session = await selfHeal({ cwd, env });
543
- // BOT-1649 (Codex round-6 P1): register_agent reports any OTHER live session
544
- // already holding this ticket (heartbeat < 15m) in `ticket_signals`. One
545
- // ticket = one live session is a hard stop (AGENTS.md), so refuse to report
546
- // the worktree ready — otherwise setup silently green-lights a second coding
547
- // session on the ticket. The mint above re-adopts this worktree's own agent
548
- // and is not itself a second holder.
554
+ // BOT-2058: register_agent reports any OTHER live session already holding
555
+ // this ticket (heartbeat < 15m) in `ticket_signals`. This is advisory: a
556
+ // previous harness can remain visible while a human intentionally resumes
557
+ // the ticket in a new session. Shared resources stay protected by typed
558
+ // locks, which remain exclusive.
549
559
  const holders = session.ticketSignals?.concurrency?.holders;
550
560
  if (Array.isArray(holders) && holders.length > 0) {
551
561
  const warning = session.ticketSignals.concurrency.warning
@@ -553,8 +563,7 @@ export async function cmdSetup(args, {
553
563
  const who = holders
554
564
  .map((h) => `${h.agent ?? h.agent_id}${h.branch ? ` (${h.branch})` : ""}`)
555
565
  .join(", ");
556
- errorLog(`bb setup: ${warning} — held by ${who}. Do not start a second coding session on this ticket; hand off to the live holder or wait for it to finish.`);
557
- return 5;
566
+ errorLog(`bb setup: warning: ${warning} — held by ${who}. Confirm the active work before editing shared resources; typed locks remain required.`);
558
567
  }
559
568
  log(`bb setup: ${tenant} is ready (${session.source} worktree session). Run bb wait '<condition>'.`);
560
569
  await reportUsageAgent(usageAgent, { cwd, log });
@@ -604,8 +613,9 @@ ${bold("HOW IT WORKS")}
604
613
  ${bold("OPTIONS")}
605
614
  --no-browser Don't launch a browser; print the authorization URL to open
606
615
  in a browser on THIS machine yourself. The callback listener
607
- still runs on this machine's localhost.
608
- --device Sign in with a device code: print a link and a short code,
616
+ still runs on this machine's localhost, so this implies
617
+ ${cyan("--loopback")} unless ${cyan("--device")} is also given.
618
+ --device Sign in with a device code: print a link and a short code,
609
619
  approve it in a browser on ANY machine (laptop, phone), and
610
620
  the login finishes on its own. No browser or listener here.
611
621
  --loopback Force the browser + localhost callback flow, even over SSH.
@@ -638,6 +648,9 @@ ${bold("NOTES")}
638
648
  listener and state.
639
649
  • ${bold("Remote / SSH:")} over SSH (or on Linux with no display) login uses the device
640
650
  code automatically: open the printed link on any machine and approve.
651
+ ${cyan("--loopback")} or ${cyan("--no-browser")} over SSH keeps the localhost callback on the
652
+ remote host, so first forward its port, e.g. ${cyan("ssh -L <port>:127.0.0.1:<port> <host>")}
653
+ (the port is in the printed URL), or the login times out.
641
654
 
642
655
  ${bold("RECOVERY")}
643
656
  If ${cyan("botbuddy status")} shows no client key, run ${cyan("botbuddy login")}. For an
@@ -813,7 +826,10 @@ async function cmdBootstrap(args) {
813
826
  if (state.linkage?.status === "gap") die(state.linkage.warning || "bb bootstrap requires ticket attribution or --no-ticket-reason.");
814
827
  // BOT-1945: name (never the bearer of) an unowned cached session this bootstrap
815
828
  // replaced, so a leftover live holder is visible instead of silent.
816
- console.log(JSON.stringify({ agent_id: state.agent_id, display_name: state.display_name, session_id: state.session_id, expires_at: state.expires_at, ...(state.replaced_cached_session ? { replaced_cached_session: state.replaced_cached_session } : {}) }));
829
+ // BOT-2058: the launcher must see a known-holder warning before it starts a
830
+ // new harness. Keep it out of the credential cache, but preserve the
831
+ // non-secret concurrency payload in this command receipt.
832
+ console.log(JSON.stringify({ agent_id: state.agent_id, display_name: state.display_name, session_id: state.session_id, expires_at: state.expires_at, ...(state.ticket_signals?.concurrency ? { ticket_session_warning: state.ticket_signals.concurrency } : {}), ...(state.replaced_cached_session ? { replaced_cached_session: state.replaced_cached_session } : {}) }));
817
833
  }
818
834
 
819
835
  // BOT-1566 A4: after the local-metadata lines, ask the SERVER whether the stored
@@ -273,7 +273,11 @@ export async function bootstrapHarnessSession({
273
273
  // Persist only the four session-secret fields. Ticket signals are intentionally
274
274
  // returned to the launcher, never copied into the runtime credential file.
275
275
  const { ticket_signals: ticketSignals, display_name: displayName, linkage, ...session } = response.data;
276
- if (ticketSignals?.concurrency) throw new Error(ticketSignals.concurrency.warning || "Another live session holds this ticket.");
276
+ // A live-holder report is advisory: agents frequently resume a ticket in a
277
+ // new harness session after a previous session's shutdown was not observed.
278
+ // The signal remains on the returned result so callers can show the holder;
279
+ // resource locks, not ticket presence, continue to arbitrate shared state.
280
+ if (ticketSignals?.policy) throw new Error(ticketSignals.concurrency?.warning || "Ticket-session exclusivity could not be verified.");
277
281
  if (linkage?.status === "gap") throw new Error(linkage.warning || "Session bootstrap requires ticket attribution.");
278
282
  // A new harness may be admitted only after an abandoned cache aged out of
279
283
  // the server's liveness window. Before replacing that file, ask the server