@botbuddy/cli 1.52.0 → 1.53.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botbuddy/cli",
3
- "version": "1.52.0",
3
+ "version": "1.53.1",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -83,6 +83,11 @@ export function repairFailureRecovery(code, error = null) {
83
83
  if (code === "agent_tenant_immutable") {
84
84
  return "this worktree's agent identity belongs to another tenant; run `bb setup` to register this tenant, then `bb doctor --fix`";
85
85
  }
86
+ // BOT-2415: the cached session names a different agent than the one holding this
87
+ // worktree's technical name. Deterministic: a retry repeats the same conflict.
88
+ if (code === "agent_name_conflict") {
89
+ return "this worktree's cached session (.botbuddy/agent-state.json) names a different agent than the one holding the worktree's technical name; move that file aside, then run `bb doctor --fix`";
90
+ }
86
91
  // BOT-1750 (Codex R9 P2): `session_mint_unauthorized` means a locally-unexpired
87
92
  // client key was revoked/rejected server-side — only a fresh login replaces it, so
88
93
  // point at `bb login`, not a binding repair (the binding is valid).
@@ -14,7 +14,7 @@ import { readKeychainSecret } from "./agent-credential-store.mjs";
14
14
  import { getConfig } from "./config.mjs";
15
15
  import { acquireStackLock } from "./stack-file-lock.mjs";
16
16
  import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
17
- import { agentStatePath, clearAgentState, isAgentStateFresh, readAgentState, resolveMachineId, worktreeRoot, writeAgentState } from "./agent-state.mjs";
17
+ import { agentStatePath, cacheBelongsToHarness, clearAgentState, harnessSessionIdFromEnv, isAgentStateFresh, readAgentState, resolveMachineId, worktreeRoot, writeAgentState } from "./agent-state.mjs";
18
18
  import { machineUuid } from "./machine-id.mjs";
19
19
  import { machineHost } from "./machine-host.mjs";
20
20
 
@@ -184,7 +184,9 @@ export async function resolveAgentSessionCredential({
184
184
  const envToken = readAgentSessionTokenEnv(env);
185
185
  if (envToken) return { token: envToken, sessionId: explicitSessionId, agentId: null, source: "env" };
186
186
  const cached = await readState(cwd);
187
- if (cached && isFresh(cached, new Date())) {
187
+ // BOT-2425: a cache written for another harness session (or before the harness
188
+ // id was recorded) is not this session's identity; re-resolve it instead.
189
+ if (cached && isFresh(cached, new Date()) && cacheBelongsToHarness(cached, harnessSessionIdFromEnv(env))) {
188
190
  warnLegacyResolvedToken(cached.agent_session_token, warn);
189
191
  return withIdentity({ token: cached.agent_session_token, sessionId: explicitSessionId ?? cached.session_id, agentId: cached.agent_id, source: "cache" }, cached);
190
192
  }
@@ -246,6 +248,10 @@ export async function selfHealAgentSession({
246
248
  // (`force` deliberately bypasses ordinary reuse for an explicit repair.)
247
249
  const current = await readState(cwd);
248
250
  const renewal = rejectedCredential !== null;
251
+ // BOT-2425: the harness session this process runs in (exported by the
252
+ // SessionStart hook). A cache belongs to it only when it recorded the same id.
253
+ const envHarness = harnessSessionIdFromEnv(env);
254
+ const ownCache = !!current && cacheBelongsToHarness(current, envHarness);
249
255
  let bound;
250
256
  let registrationWorktree = cwd;
251
257
  if (renewal) {
@@ -267,7 +273,7 @@ export async function selfHealAgentSession({
267
273
  return withIdentity({ token: current.agent_session_token, sessionId: sessionId ?? current.session_id, agentId: current.agent_id, source: "cache", ticketSignals: null, stackLeaseCapability: null }, current);
268
274
  }
269
275
  }
270
- if (!force && !renewal && current && isFresh(current, now())) {
276
+ if (!force && !renewal && current && ownCache && isFresh(current, now())) {
271
277
  // A fresh cache is reused without a register call, so no capability verdict is
272
278
  // available here (bb doctor --fix forces a mint to (re)establish + report it).
273
279
  return withIdentity({ token: current.agent_session_token, sessionId: sessionId ?? current.session_id, agentId: current.agent_id, source: "cache", ticketSignals: null, stackLeaseCapability: null }, current);
@@ -318,6 +324,15 @@ export async function selfHealAgentSession({
318
324
  // can reconcile a replacement agent by thread identity if the cache is lost.
319
325
  const codexThreadId = env.CODEX_THREAD_ID || undefined;
320
326
  const prNumber = Number.parseInt(env.BOTBUDDY_PR_NUMBER ?? env.CODEX_PR_NUMBER ?? "", 10);
327
+ // BOT-2425: present the session-lifetime identity. The cached agent_id is only
328
+ // this session's when the cache belongs to its harness session; otherwise the
329
+ // harness id alone lets the server re-bind to the session's agent (the cloud
330
+ // match for a deleted file or another worktree). A renewal keeps the cache's
331
+ // own identity — it replaces a rejected token, it never changes the agent.
332
+ const presentedAgentId = renewal || ownCache ? current?.agent_id : undefined;
333
+ const presentedHarness = renewal
334
+ ? (ownCache ? envHarness ?? current?.harness_session_id : current?.harness_session_id) ?? null
335
+ : envHarness ?? (ownCache ? current?.harness_session_id : null) ?? null;
321
336
  const response = await call("register_agent", {
322
337
  name: workAgentName,
323
338
  type: "custom",
@@ -329,7 +344,11 @@ export async function selfHealAgentSession({
329
344
  machine_uuid: resolvedHardwareId || undefined,
330
345
  branch: currentBranch || undefined,
331
346
  ticket_id: ticketFromBranch(currentBranch),
332
- agent_id: current?.agent_id ?? undefined,
347
+ agent_id: presentedAgentId ?? undefined,
348
+ // A rejected-token renewal may still own locks or leases under this agent.
349
+ // Ask the server to keep it; ordinary setup/doctor repair may adopt a holder.
350
+ ...(renewal ? { preserve_agent_identity: true } : {}),
351
+ harness_session_id: presentedHarness ?? undefined,
333
352
  harness: env.CLAUDE_CODE || env.CLAUDECODE ? "claude-code" : Object.keys(env).some((key) => key.startsWith("CODEX_")) ? "codex" : "cli",
334
353
  model: env.BOTBUDDY_MODEL || undefined,
335
354
  effort: env.BOTBUDDY_EFFORT || undefined,
@@ -356,8 +375,14 @@ export async function selfHealAgentSession({
356
375
  const typedCode = typeof response?.code === "string"
357
376
  ? response.code
358
377
  : typeof data.code === "string" ? data.code : null;
359
- const code = typedCode === "agent_tenant_immutable"
378
+ // BOT-2415: a technical-name conflict is deterministic (the cached agent_id
379
+ // names a different agent than the one holding this worktree's name), so no
380
+ // retry fixes it. Recognise the typed refusal and an older server's raw 23505.
381
+ const nameConflict = typedCode === "agent_name_taken"
382
+ || /agents_name_key/.test(String(response?.error ?? data.error ?? ""));
383
+ const code = ["agent_tenant_immutable", "session_identity_mismatch"].includes(typedCode)
360
384
  ? typedCode
385
+ : nameConflict ? "agent_name_conflict"
361
386
  : response?.auth ? "session_mint_unauthorized" : "session_mint_failed";
362
387
  // BOT-1891: name the credential (never its value) so `bb doctor` can point a
363
388
  // rejected tier-2 key at rotation instead of `bb login`.
@@ -399,6 +424,8 @@ export async function selfHealAgentSession({
399
424
  expires_at: expiry.toISOString(),
400
425
  // BOT-1891: which durable credential minted this session (never the value).
401
426
  minted_with: mintedWith,
427
+ // BOT-2425: the harness session this identity belongs to.
428
+ ...(presentedHarness ? { harness_session_id: presentedHarness } : {}),
402
429
  // BOT-1945: the non-secret incarnation fence. The durable close_session
403
430
  // refuses a generated row without it, so a cache that drops it can never be
404
431
  // retired by `bb bootstrap` / `bb bootstrap close` once its bearer lapses.
@@ -21,6 +21,29 @@ export async function withStateLock(cwd, { timeoutMs } = {}, fn) {
21
21
  try { return await fn(); } finally { lease.release(); }
22
22
  }
23
23
 
24
+ /** BOT-2425: the harness session this shell belongs to, lower-cased; null when
25
+ * unknown. The SessionStart hook exports BOTBUDDY_HARNESS_SESSION_ID; Claude Code
26
+ * also sets CLAUDE_CODE_SESSION_ID in every shell it spawns. A subagent
27
+ * (CLAUDE_CODE_CHILD_SESSION=1) inherits its PARENT's session id, so it never
28
+ * claims that identity: it keeps its own worktree identity, exactly as before. */
29
+ export function harnessSessionIdFromEnv(env = process.env) {
30
+ if (env?.CLAUDE_CODE_CHILD_SESSION === "1") return null;
31
+ for (const name of ["BOTBUDDY_HARNESS_SESSION_ID", "CLAUDE_CODE_SESSION_ID"]) {
32
+ const raw = typeof env?.[name] === "string" ? env[name].trim() : "";
33
+ if (raw) return UUID_RE.test(raw) ? raw.toLowerCase() : null;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /** BOT-2425: may this cache act as THIS harness session's identity? A cache is
39
+ * reusable when no harness session is known, or when it was written for exactly
40
+ * this one. A cache with no harness id (written before BOT-2425 or by another
41
+ * shell) or another session's id is not this session's identity. */
42
+ export function cacheBelongsToHarness(state, harnessSessionId) {
43
+ if (!harnessSessionId) return true;
44
+ return typeof state?.harness_session_id === "string" && state.harness_session_id.toLowerCase() === harnessSessionId;
45
+ }
46
+
24
47
  export const AGENT_STATE_DIR = ".botbuddy";
25
48
  export const AGENT_STATE_FILE = "agent-state.json";
26
49
  export const AGENT_STATE_PATH = join(AGENT_STATE_DIR, AGENT_STATE_FILE);
@@ -90,6 +113,10 @@ function validState(value) {
90
113
  if (value.host !== undefined && !(typeof value.host === "string" && value.host.length > 0)) return false;
91
114
  if (value.worktree !== undefined && !(typeof value.worktree === "string" && value.worktree.length > 0)) return false;
92
115
  if (value.minted_at !== undefined && !(typeof value.minted_at === "string" && !Number.isNaN(Date.parse(value.minted_at)))) return false;
116
+ // BOT-2425: the harness session (Claude Code session / Codex thread) this
117
+ // cached identity belongs to — the session-lifetime identity key.
118
+ if (value.harness_session_id !== undefined
119
+ && !(typeof value.harness_session_id === "string" && UUID_RE.test(value.harness_session_id))) return false;
93
120
  // BOT-1650: the nonsecret incarnation fence for close/heartbeat idempotency.
94
121
  if (value.session_token_generation !== undefined
95
122
  && !(typeof value.session_token_generation === "string" && UUID_RE.test(value.session_token_generation))) return false;
package/src/commands.mjs CHANGED
@@ -568,7 +568,7 @@ function setupFailureRecovery(error) {
568
568
  if (code === "tenant_binding_required" || code === "invalid_binding") {
569
569
  return "fix the committed .botbuddy-agent.json binding, then retry `bb setup`";
570
570
  }
571
- if (code === "client_key_unavailable" || code === "session_mint_unauthorized") return repairFailureRecovery(code, error);
571
+ if (code === "client_key_unavailable" || code === "session_mint_unauthorized" || code === "agent_name_conflict") return repairFailureRecovery(code, error);
572
572
  return "retry `bb setup`";
573
573
  }
574
574
 
@@ -31,7 +31,7 @@ export async function loadHarnessSession({ cwd = process.cwd() } = {}) {
31
31
  /** Persist a bootstrap-issued session into the unified cache. The bootstrap RPC
32
32
  * does not echo the tenant, so derive it from the committed binding exactly as
33
33
  * selfHealAgentSession does; host/worktree/minted_at are derived locally. */
34
- export async function saveHarnessSession(session, { cwd = process.cwd(), machineHostFn = machineHost } = {}) {
34
+ export async function saveHarnessSession(session, { cwd = process.cwd(), machineHostFn = machineHost, harnessSessionId = null } = {}) {
35
35
  const root = await worktreeRoot(cwd);
36
36
  let tenant;
37
37
  try { tenant = (await readAgentBinding(root))?.tenant; } catch { tenant = undefined; }
@@ -50,6 +50,9 @@ export async function saveHarnessSession(session, { cwd = process.cwd(), machine
50
50
  // so this marker no longer gates timer relaying; it still distinguishes a
51
51
  // bootstrap-issued cache for the bootstrap bridge's own reuse checks.
52
52
  harness_origin: "bootstrap_agent_session",
53
+ // BOT-2425: the session-lifetime identity key. Later CLI mints present it with
54
+ // the agent_id, and a cache from another harness session is never reused.
55
+ ...(typeof harnessSessionId === "string" && harnessSessionId ? { harness_session_id: harnessSessionId.toLowerCase() } : {}),
53
56
  ...(session.session_token_generation ? { session_token_generation: session.session_token_generation } : {}),
54
57
  };
55
58
  await writeAgentState(root, state);
@@ -390,7 +393,7 @@ export async function bootstrapHarnessSession({
390
393
  }
391
394
  let state;
392
395
  try {
393
- state = await store(session, { cwd: worktree });
396
+ state = await store(session, { cwd: worktree, harnessSessionId });
394
397
  } catch (error) {
395
398
  // The server session is unusable if this harness cannot persist its only
396
399
  // credential. The freshly issued session bearer is scoped to this exact
package/src/stack.mjs CHANGED
@@ -985,7 +985,7 @@ export function retryingStackCall({
985
985
  // taxonomy so automation sees AUTH/BACKEND, not a CLI-defect INTERNAL. Credential
986
986
  // problems are AUTH (3); a transport/server mint failure is BACKEND (5); anything
987
987
  // unrecognized stays INTERNAL (7).
988
- const AUTH_ERROR_CODES = new Set(["client_key_required", "client_key_unavailable", "tenant_binding_required", "session_token_malformed", "session_mint_unauthorized", "session_identity_mismatch"]);
988
+ const AUTH_ERROR_CODES = new Set(["client_key_required", "client_key_unavailable", "tenant_binding_required", "session_token_malformed", "session_mint_unauthorized", "session_identity_mismatch", "agent_name_conflict"]);
989
989
  // BOT-1750 (Codex R11 P2): `session_mint_invalid` is an incompatible/malformed SERVER
990
990
  // response, not a credential problem — classify it with the transport/server failures.
991
991
  const BACKEND_ERROR_CODES = new Set(["session_mint_failed", "session_mint_invalid"]);
@@ -1423,6 +1423,7 @@ export const PARK_AUTH_BUDGET_MS = 5 * 60_000;
1423
1423
  // the regular auth budget; it is not evidence that the lease changed owner.
1424
1424
  const DEFINITIVE_REAUTH_CODES = new Set([
1425
1425
  "session_identity_mismatch", "client_key_required", "tenant_binding_required", "session_token_malformed", "agent_tenant_immutable",
1426
+ "agent_name_conflict",
1426
1427
  ]);
1427
1428
 
1428
1429
  function abortableSleep(ms, signal) {
package/src/wait.mjs CHANGED
@@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url";
20
20
  import { latestPublicCliCommand } from "./public-invocation.mjs";
21
21
  import { AGENT_KEY_RE, readAgentSessionTokenEnv, legacyAgentAliasHint } from "./agent-key.mjs";
22
22
  import { clearAgentState, hasAgentSessionRuntime, isRejectedCachedSession, resolveAgentSessionCredential, selfHealAgentSession } from "./agent-session.mjs";
23
+ import { repairFailureRecovery } from "./agent-doctor.mjs";
23
24
  import { touchAgentStateExpiry } from "./agent-state.mjs";
24
25
  import { SETUP_BLOCK } from "./setup-block.mjs";
25
26
  import { createWaitCheckpointStore, WaitCheckpointError } from "./wait-checkpoint.mjs";
@@ -1679,7 +1680,10 @@ export async function runWait(argv, { recoveryLocalWaitId = null } = {}) {
1679
1680
  opts.credentialSource = credential.source;
1680
1681
  } catch (error) {
1681
1682
  const code = error?.code ?? "session_mint_failed";
1682
- const recovery = code === "client_key_required"
1683
+ // BOT-2415: a stale cache naming another agent is not fixed by `bb setup` + retry.
1684
+ const recovery = code === "agent_name_conflict"
1685
+ ? repairFailureRecovery(code)
1686
+ : code === "client_key_required"
1683
1687
  ? "run `bb setup` from this project worktree, then retry"
1684
1688
  : code === "tenant_binding_required"
1685
1689
  ? "run from a configured project worktree, then run `bb setup` and retry"