@botbuddy/cli 1.52.0 → 1.53.0

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.0",
4
4
  "description": "BotBuddy — Swarm coordination CLI for multi-agent workflows",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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,8 @@ 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
+ harness_session_id: presentedHarness ?? undefined,
333
349
  harness: env.CLAUDE_CODE || env.CLAUDECODE ? "claude-code" : Object.keys(env).some((key) => key.startsWith("CODEX_")) ? "codex" : "cli",
334
350
  model: env.BOTBUDDY_MODEL || undefined,
335
351
  effort: env.BOTBUDDY_EFFORT || undefined,
@@ -399,6 +415,8 @@ export async function selfHealAgentSession({
399
415
  expires_at: expiry.toISOString(),
400
416
  // BOT-1891: which durable credential minted this session (never the value).
401
417
  minted_with: mintedWith,
418
+ // BOT-2425: the harness session this identity belongs to.
419
+ ...(presentedHarness ? { harness_session_id: presentedHarness } : {}),
402
420
  // BOT-1945: the non-secret incarnation fence. The durable close_session
403
421
  // refuses a generated row without it, so a cache that drops it can never be
404
422
  // 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;
@@ -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