@botbuddy/cli 1.34.3 → 1.34.4

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.34.3",
3
+ "version": "1.34.4",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
package/src/commands.mjs CHANGED
@@ -5,7 +5,7 @@ import { join } from "node:path";
5
5
  import { callTool, callToolJson, readResource } from "./api.mjs";
6
6
  import { doLogin } from "./auth.mjs";
7
7
  import { runBridge } from "./codex-bridge.mjs";
8
- import { bootstrapHarnessSession, clearHarnessSession, loadHarnessSession, withHarnessBootstrapLock } from "./session-bootstrap.mjs";
8
+ import { bootstrapHarnessSession, clearHarnessSession, doctorBootstrapState, formatBootstrapLine, loadHarnessSession, withHarnessBootstrapLock } from "./session-bootstrap.mjs";
9
9
  import { loadConfig, getConfig, clearConfig, getConfigPath, SERVER_URL } from "./config.mjs";
10
10
  import { resolveOwnerToken, resolveAgentKey, clearOwnerToken } from "./cli-credentials.mjs";
11
11
  import { agentProfileKeyLabel } from "./credential-kinds.mjs";
@@ -236,7 +236,7 @@ ${SETUP_BLOCK}`);
236
236
 
237
237
  export async function cmdDoctor(args, {
238
238
  log = (line) => console.log(line), errorLog = (line) => console.error(line),
239
- doctor = doctorAgentAuth, usageReport = doctorUsageSync,
239
+ doctor = doctorAgentAuth, usageReport = doctorUsageSync, bootstrapReport = doctorBootstrapState,
240
240
  } = {}) {
241
241
  const parsed = parseDoctorArgs(args);
242
242
  if (parsed.help) { log(doctorHelp()); return 0; }
@@ -244,8 +244,10 @@ export async function cmdDoctor(args, {
244
244
  const result = await doctor({ fix: parsed.fix, printExports: parsed.printExports });
245
245
  // BOT-1021: informational only — never changes doctor's exit code.
246
246
  const usage = await usageReport().catch(() => null);
247
+ // BOT-1945 (AC-3): informational, like usage sync — never changes the exit code.
248
+ const bootstrap = await bootstrapReport().catch(() => null);
247
249
  if (parsed.json) {
248
- log(JSON.stringify(usage ? { ...result.report, usage_sync: usage } : result.report));
250
+ log(JSON.stringify({ ...result.report, ...(usage ? { usage_sync: usage } : {}), ...(bootstrap ? { bootstrap } : {}) }));
249
251
  } else {
250
252
  log(`binding: ${result.report.binding.status}${result.report.binding.tenant ? ` (${result.report.binding.tenant})` : ""}`);
251
253
  log(`client key: ${result.report.client_key.status}`);
@@ -256,6 +258,7 @@ export async function cmdDoctor(args, {
256
258
  log(`stack lease: ${sl.capability} (host ${sl.host}, machine id ${sl.machine_id_readable ? "readable" : "unavailable"})${sl.reason ? ` — ${sl.reason}` : ""}`);
257
259
  }
258
260
  if (result.report.recovery) log(`recovery: ${result.report.recovery}`);
261
+ if (bootstrap) log(formatBootstrapLine(bootstrap));
259
262
  if (usage) log(formatUsageSyncLine(usage));
260
263
  }
261
264
  if (result.exports) log(result.exports);
@@ -784,7 +787,9 @@ async function cmdBootstrap(args) {
784
787
  harness: `botbuddy-${type}-launcher`,
785
788
  });
786
789
  if (state.linkage?.status === "gap") die(state.linkage.warning || "bb bootstrap requires ticket attribution or --no-ticket-reason.");
787
- console.log(JSON.stringify({ agent_id: state.agent_id, display_name: state.display_name, session_id: state.session_id, expires_at: state.expires_at }));
790
+ // BOT-1945: name (never the bearer of) an unowned cached session this bootstrap
791
+ // replaced, so a leftover live holder is visible instead of silent.
792
+ 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 } : {}) }));
788
793
  }
789
794
 
790
795
  // BOT-1566 A4: after the local-metadata lines, ask the SERVER whether the stored
@@ -153,6 +153,55 @@ export async function heartbeatHarnessSession({
153
153
  return response.data;
154
154
  }
155
155
 
156
+ // BOT-1945: a worktree cache that this bootstrap path did not create — written by
157
+ // the self-heal / `bb setup` / MCP registration path (no `harness_origin` marker),
158
+ // or a legacy bootstrap cache with no stored generation — is not a harness
159
+ // principal this bootstrap owns and cannot be retired through the fenced durable
160
+ // close (the server refuses a generated row without its generation:
161
+ // `session_generation_required`). Retire it best-effort and NEVER fail the
162
+ // bootstrap because of it:
163
+ // 1. its own scoped bearer self-closes with if_stale (the server derives the
164
+ // generation from the token, and still refuses a live holder);
165
+ // 2. the durable credential confirms (with the generation when cached): an
166
+ // ended row answers already_closed, an active generated row stays refused;
167
+ // 3. otherwise leave it: a session that is never closed simply ages out of the
168
+ // server's holder-liveness window and its short-lived token expires.
169
+ // Returns a non-secret receipt `{ session_id, retired }`.
170
+ export function cacheOwnedByBootstrap(cached) {
171
+ return cached?.harness_origin === "bootstrap_agent_session" && !!cached.session_token_generation;
172
+ }
173
+
174
+ async function retireUnownedCachedSession({ cached, call, callOptions, resolveCleanupOptions }) {
175
+ const receipt = { session_id: cached.session_id, retired: false };
176
+ const ok = (response) => !!response?.ok && !response.isError;
177
+ try {
178
+ if (cached.session_token) {
179
+ const self = await call("close_session", { session_id: cached.session_id, if_stale: true }, {
180
+ auth: { "x-agent-api-key": cached.session_token },
181
+ }).catch(() => null);
182
+ if (ok(self)) return { ...receipt, retired: true };
183
+ // A live holder is refused by if_stale; the durable credential would be
184
+ // refused the same way, so do not widen to it.
185
+ if ((self?.data?.error || self?.error) === "session_still_active") return receipt;
186
+ }
187
+ // Codex #986 P2: confirm with the durable credential even without a cached
188
+ // generation. A bearer that fails auth often means the row was already ended
189
+ // (e.g. superseded by registration reconciliation) — the durable close then
190
+ // answers idempotent `already_closed`; an ACTIVE generated row is still
191
+ // refused (`session_generation_required`) and `if_stale` protects a live one.
192
+ const durable = callOptions ?? await resolveCleanupOptions().catch(() => null);
193
+ if (!durable) return receipt;
194
+ const confirmed = await call("close_session", {
195
+ session_id: cached.session_id,
196
+ ...(cached.session_token_generation ? { session_token_generation: cached.session_token_generation } : {}),
197
+ if_stale: true,
198
+ }, durable).catch(() => null);
199
+ return { ...receipt, retired: ok(confirmed) };
200
+ } catch {
201
+ return receipt;
202
+ }
203
+ }
204
+
156
205
  export async function bootstrapHarnessSession({
157
206
  name,
158
207
  type = "codex",
@@ -231,7 +280,10 @@ export async function bootstrapHarnessSession({
231
280
  // to retire the cached session *only if its authoritative holder-liveness
232
281
  // window elapsed*. The file's timestamp is intentionally not authority:
233
282
  // relay heartbeats can extend it after the snapshot was written.
234
- if (cached && cached.session_id !== session.session_id) {
283
+ let replacedCachedSession = null;
284
+ if (cached && cached.session_id !== session.session_id && !cacheOwnedByBootstrap(cached)) {
285
+ replacedCachedSession = await retireUnownedCachedSession({ cached, call, callOptions, resolveCleanupOptions });
286
+ } else if (cached && cached.session_id !== session.session_id) {
235
287
  const cleanupOptions = callOptions ?? await resolveCleanupOptions();
236
288
  if (!cleanupOptions) {
237
289
  // BOT-1650 (Codex round-27): the new session was admitted but there is no
@@ -370,6 +422,29 @@ export async function bootstrapHarnessSession({
370
422
  }
371
423
  throw error;
372
424
  }
373
- return { ...state, ...(displayName ? { display_name: displayName } : {}), ...(ticketSignals ? { ticket_signals: ticketSignals } : {}), ...(linkage ? { linkage } : {}) };
425
+ return { ...state, ...(replacedCachedSession ? { replaced_cached_session: replacedCachedSession } : {}), ...(displayName ? { display_name: displayName } : {}), ...(ticketSignals ? { ticket_signals: ticketSignals } : {}), ...(linkage ? { linkage } : {}) };
374
426
  });
375
427
  }
428
+
429
+ /** BOT-1945 (AC-3): non-secret `bb doctor` view of whether this worktree's cache
430
+ * is a harness session linked by `bb bootstrap` (the SessionStart hook's job).
431
+ * Only ids and presence flags — never the bearer. */
432
+ export async function doctorBootstrapState({ cwd = process.cwd(), readState = readAgentState } = {}) {
433
+ let root;
434
+ try { root = await worktreeRoot(cwd); } catch { root = cwd; }
435
+ const state = await readState(root).catch(() => null);
436
+ if (!state) return { status: "not_linked", cache: "none" };
437
+ const bootstrap = state.harness_origin === "bootstrap_agent_session";
438
+ return {
439
+ status: bootstrap ? "linked" : "not_linked",
440
+ cache: bootstrap ? "bootstrap" : "self_heal",
441
+ session_id: state.session_id ?? null,
442
+ generation: state.session_token_generation ? "cached" : "missing",
443
+ };
444
+ }
445
+
446
+ export function formatBootstrapLine(report) {
447
+ if (report?.status === "linked") return `bootstrap: linked (session ${report.session_id}, generation ${report.generation})`;
448
+ if (!report || report.cache === "none") return "bootstrap: not linked — no session cache in this worktree (the SessionStart hook runs bb bootstrap)";
449
+ return `bootstrap: not linked — cache is a self-heal session ${report.session_id} (generation ${report.generation}); the SessionStart hook's bb bootstrap replaces it`;
450
+ }