← reports

#1654 · claude login doesn't apply to existing thread and requires restart

Bug Medium Effort: n/a providers provider-claude-code open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: PARTIALLY REPRODUCED · root-cause confidence: medium · linked open PRs: none

TL;DR

Plain-language framing. bb does not talk to Anthropic itself for Claude threads. For every Claude thread the provider-claude-code plugin (running inside the host daemon) spawns one claude CLI process through the Claude Agent SDK and keeps that process alive for the whole life of the thread: every follow-up message is pushed into the same process's stdin (streaming input). The process is only killed when the thread is stopped/archived or when the bb server (host daemon) restarts. The CLI, not bb, decides which OAuth token goes on each API request; it reads the token from ~/.claude/.credentials.json (Linux) or the macOS Keychain, and claude login writes there. bb's usage page, by contrast, re-reads that store on every refresh.

What the reporter sees: a thread hits a Claude usage limit, they claude login into a different subscription in a terminal, the bb usage page switches to the new account, but retrying in the existing thread still fails with the rate limit until the bb server is restarted. What is actually going on: the retry goes to the same long-lived claude process, and that process memoizes the OAuth token it loaded at startup (memoize = compute once, keep the value in memory and reuse it). The CLI only drops that memo before a request when it notices a change of the mtime (last-modified timestamp) of <config dir>/.credentials.json, or (if that file does not exist, i.e. macOS Keychain users) by re-reading the Keychain through a 30 s cache that also keeps serving the old token whenever the security re-read fails or times out. A 429 does not invalidate anything. So whenever the credential store changes in a way this probe does not see, the live process is pinned to the old account for as long as it lives; only a fresh process (server restart, or stopping the idle thread) loads the new login.

On this Linux machine with the Claude Code binary bb actually spawns (claude on PATH, 2.1.234; also checked 2.1.233 and the Agent SDK's bundled 2.1.197) the plain scenario does not reproduce: claude login rewrites the file, the mtime changes, and the very next turn on the live process uses the new token (repro log run-default-2.1.234.log). The mechanism, however, reproduces deterministically as soon as the store changes without an mtime change (run-preserve-mtime-2.1.234.log): the live process keeps sending the old token, a new process sends the new one — exactly the reported symptom. On macOS (the reporter's likely platform) the same pinning happens if a leftover ~/.claude/.credentials.json sits next to the Keychain entry that claude login actually updates, or whenever the CLI's Keychain re-read fails within its 2 s timeout. I could not run macOS here, so that last step is inferred from the shipped darwin binary's code (excerpts in claude-cli-credential-cache-excerpts.txt), hence medium confidence. bb-side, the fact that survives all of this: bb never recycles the provider process after a terminal rate-limit/auth failure and therefore depends entirely on the CLI's in-process credential refresh, which has these known holes.

Claims vs findings

ClaimStatusEvidence
Hitting a Claude rate limit in a thread leaves the thread's provider process in placeVerifiedbb keeps one claude process per thread across turns and idle periods (same pid 2422530 before and after a second turn; bb-e2e-pid-persistence.txt). The 429 turn returns result is_error=true and the process stays alive and accepts the next turn (repro logs, Step 1 → Step 3). Nothing in the bridge or server tears the session down on a rate-limit result (bridge.ts replaces a session only when its SDK stream ended, getWritableThreadSession).
claude login in the terminal (different subscription) succeeds and the bb usage page updatesVerified by codeThe host daemon's usage fetch reads the credential store on every call — Keychain first, then ~/.claude/.credentials.json, no caching (provider-usage.ts#L299-L356). So the usage page always shows the account that is currently in the store, independent of what live claude processes hold in memory.
Retries on the thread still hit the rate limitPartially reproducedNot reproduced on Linux with the plain flow (file mtime changes → CLI reloads the token on the next request, both 2.1.233 and 2.1.234). Reproduced deterministically when the store changes without an mtime change (Linux analog of the macOS Keychain case): the live process keeps sending token A after the file holds token B (log).
Restarting the bb server fixes itVerifiedDone for real on a dev instance: thread thr_kfwp7tkjf8 was served by claude pid 3753262; pnpm dev:stop + scripts/bb-dev-app current killed it with the host daemon, and the next thread tell on the same thread was served by a new pid 3756083 (bb-e2e-server-restart.txt). A new process reads the credential store at startup (repro Step 4: new process → token B). Also verified in bb: bb thread stop on the idle thread releases the runtime and the next message spawns a new pid (2452544) — a lighter workaround than a server restart.
(implicit) bb is what caches the old loginRefutedbb passes no token: the session env is the daemon's env plus CLAUDE_CODE_ENTRYPOINT=cli (bridge.ts#L1366-L1376); no CLAUDE_CODE_OAUTH_TOKEN/ANTHROPIC_API_KEY handling anywhere in the repo. The memo lives inside the claude process (excerpts below).

Environment

Minimal reproduction

The repro drives the Claude Agent SDK exactly the way bb's SdkSession does (streaming-input query(): one claude process whose stdin receives every follow-up message, so one process serves several turns) against a local mock of the Anthropic API that logs the Authorization header and answers 429 (rate limited) for token A and a normal SSE reply (Server-Sent Events, the streaming HTTP response format the real API uses) for token B. It spawns the same binary bb does: claude on PATH (2.1.234 here), overridable with REPRO_CLAUDE_BIN=<path>; REPRO_CLAUDE_BIN=sdk-bundled uses the SDK's bundled 2.1.197 for comparison only. Credentials live in a throwaway CLAUDE_CONFIG_DIR, so nothing touches your real login. No bb instance and no real usage needed.

  1. Build the worktree once: pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build.
  2. Copy the script next to the plugin's node_modules (it imports the SDK from there): cp /tmp/bb-reports/issues/1654/repro/stale-oauth-repro.mjs plugins/provider-claude-code/repro-1654.mjs.
  3. Plain flow (login rewrites the credentials file): node plugins/provider-claude-code/repro-1654.mjs (prints which binary it runs; 2.1.234 retries the 429 ten times with backoff, so the first turn takes ~2–3 min). Expected (and observed): turn 2 on the live process already uses token B → exit 0, "NOT reproduced".
  4. Store changes without an mtime change (analog of a Keychain login next to a leftover file): REPRO_MODE=preserve-mtime node plugins/provider-claude-code/repro-1654.mjs. Expected if the CLI re-read credentials per request: token B on turn 2. Actual: turn 2 still sends token A and is rate-limited again; only the fresh process (turn 3) sends token B → exit 1, "BUG REPRODUCED".
  5. Optional: REPRO_CLAUDE_BIN=~/.local/share/claude/versions/2.1.233 REPRO_MODE=preserve-mtime node … for another CLI version (run-preserve-mtime-2.1.233.log, same result), or REPRO_CLAUDE_BIN=sdk-bundled for the SDK's own 2.1.197 (default, preserve-mtime; identical verdicts, that build just does not retry the 429). run-all.py runs all five variants and writes these logs.

Output of step 4 (the pinned-token case), Claude Code 2.1.234 (run-preserve-mtime-2.1.234.log)

$ REPRO_MODE=preserve-mtime node repro-1654.mjs
(exit=1)

[repro] claude binary: /home/sawyer/.local/share/claude/versions/2.1.234
[repro] claude --version: 2.1.234 (Claude Code)
[repro] wrote /tmp/bb1654-claude-config-SSUT6T/.credentials.json with sk-ant-oat01-ACCOUNT-A-rate-limited

=== Step 1: session P1 starts with token A (account A), first turn -> rate limited ===
[mock-api] HEAD /api/hello authorization=(none)
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[repro] turn 1 result: subtype=success is_error=true
[repro] turn 1: /v1/messages calls=11 tokens used=["sk-ant-oat01-ACCOUNT-A-rate-limited"]

=== Step 2: user runs `claude login` on account B (credentials file rewritten with token B) ===
[repro] (preserve-mtime) mtimeNs before=1787066343995921611 after=1787066343995921611 (unchanged)
[repro] wrote /tmp/bb1654-claude-config-SSUT6T/.credentials.json with sk-ant-oat01-ACCOUNT-B-fresh-login

=== Step 3: retry on the SAME live process P1 (what bb does when you resend in the thread) ===
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[repro] turn 2 result: subtype=success is_error=true
[repro] turn 2 (same process, after login): /v1/messages calls=11 tokens used=["sk-ant-oat01-ACCOUNT-A-rate-limited"]

=== Step 4: fresh process P2 (what a bb server restart gives you) ===
[mock-api] HEAD /api/hello authorization=(none)
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-B-fresh-login
[repro] turn 3 result: subtype=success is_error=false
[repro] turn 3 (new process): /v1/messages calls=1 tokens used=["sk-ant-oat01-ACCOUNT-B-fresh-login"]

=== SUMMARY ===
{
  "turn1": [
    "sk-ant-oat01-ACCOUNT-A-rate-limited"
  ],
  "turn2": [
    "sk-ant-oat01-ACCOUNT-A-rate-limited"
  ],
  "turn3": [
    "sk-ant-oat01-ACCOUNT-B-fresh-login"
  ]
}
BUG REPRODUCED: the live claude process kept sending token A after the credentials file changed to token B; only a new process picked up token B.

Output of step 3 (plain file rewrite, not reproduced), Claude Code 2.1.234 (run-default-2.1.234.log)

$  node repro-1654.mjs
(exit=0)

[repro] claude binary: /home/sawyer/.local/share/claude/versions/2.1.234
[repro] claude --version: 2.1.234 (Claude Code)
[repro] wrote /tmp/bb1654-claude-config-Nyiwqo/.credentials.json with sk-ant-oat01-ACCOUNT-A-rate-limited

=== Step 1: session P1 starts with token A (account A), first turn -> rate limited ===
[mock-api] HEAD /api/hello authorization=(none)
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[repro] turn 1 result: subtype=success is_error=true
[repro] turn 1: /v1/messages calls=11 tokens used=["sk-ant-oat01-ACCOUNT-A-rate-limited"]

=== Step 2: user runs `claude login` on account B (credentials file rewritten with token B) ===
[repro] wrote /tmp/bb1654-claude-config-Nyiwqo/.credentials.json with sk-ant-oat01-ACCOUNT-B-fresh-login

=== Step 3: retry on the SAME live process P1 (what bb does when you resend in the thread) ===
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-B-fresh-login
[repro] turn 2 result: subtype=success is_error=false
[repro] turn 2 (same process, after login): /v1/messages calls=1 tokens used=["sk-ant-oat01-ACCOUNT-B-fresh-login"]

=== Step 4: fresh process P2 (what a bb server restart gives you) ===
[mock-api] HEAD /api/hello authorization=(none)
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-B-fresh-login
[repro] turn 3 result: subtype=success is_error=false
[repro] turn 3 (new process): /v1/messages calls=1 tokens used=["sk-ant-oat01-ACCOUNT-B-fresh-login"]

=== SUMMARY ===
{
  "turn1": [
    "sk-ant-oat01-ACCOUNT-A-rate-limited"
  ],
  "turn2": [
    "sk-ant-oat01-ACCOUNT-B-fresh-login"
  ],
  "turn3": [
    "sk-ant-oat01-ACCOUNT-B-fresh-login"
  ]
}
NOT reproduced: the live process picked up the new token.

The repro script (1654/repro/stale-oauth-repro.mjs)

// Repro for get-bb/bb#1654: a long-lived Claude Code process (the way bb's
// provider-claude-code bridge keeps one `claude` process per thread) keeps
// using the OAuth access token it loaded at startup, even after
// ~/.claude/.credentials.json (here: $CLAUDE_CONFIG_DIR/.credentials.json) is
// rewritten by `claude login`.
//
// Run from plugins/provider-claude-code (needs @anthropic-ai/claude-agent-sdk):
//   node /tmp/bb-reports/issues/1654/repro/stale-oauth-repro.mjs
//
// Which `claude` binary runs: bb resolves the CLI on PATH (BB_CLAUDE_CODE_EXECUTABLE,
// then `claude` on PATH, then ~/.local/bin/claude, ...; see
// plugins/provider-claude-code/src/bridge/session-options.ts resolveClaudeCodeExecutable)
// and passes it to the SDK as pathToClaudeCodeExecutable. Without that option the
// Agent SDK spawns its OWN bundled CLI (@anthropic-ai/claude-agent-sdk-linux-x64,
// = Claude Code 2.1.197 for SDK 0.3.197), which is NOT what bb runs. This script
// therefore mirrors bb: REPRO_CLAUDE_BIN, else `claude` on PATH, else the SDK bundle
// (REPRO_CLAUDE_BIN=sdk-bundled forces the SDK bundle, for comparison).
import { query } from "@anthropic-ai/claude-agent-sdk";
import { createServer } from "node:http";
import { mkdtempSync, writeFileSync, statSync, realpathSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";

function resolveClaudeBin() {
  if (process.env.REPRO_CLAUDE_BIN === "sdk-bundled") return null; // force the SDK's own CLI
  if (process.env.REPRO_CLAUDE_BIN) return realpathSync(process.env.REPRO_CLAUDE_BIN);
  try {
    return realpathSync(execFileSync("sh", ["-c", "command -v claude"], { encoding: "utf8" }).trim());
  } catch {
    return null; // fall back to the SDK-bundled CLI
  }
}
const claudeBin = resolveClaudeBin();
console.log(`[repro] claude binary: ${claudeBin ?? "(SDK-bundled @anthropic-ai/claude-agent-sdk-<platform> CLI)"}`);
if (claudeBin) console.log(`[repro] claude --version: ${execFileSync(claudeBin, ["--version"], { encoding: "utf8" }).trim()}`);

const TOKEN_A = "sk-ant-oat01-ACCOUNT-A-rate-limited";
const TOKEN_B = "sk-ant-oat01-ACCOUNT-B-fresh-login";

// ---- mock Anthropic API: token A is "rate limited", token B works ---------
const seen = [];
const server = createServer((req, res) => {
  let body = "";
  req.on("data", (c) => (body += c));
  req.on("end", () => {
    const auth = req.headers.authorization ?? "(none)";
    const token = auth.replace(/^Bearer /, "");
    seen.push({ path: req.url, token });
    console.log(`[mock-api] ${req.method} ${req.url} authorization=${auth}`);
    if (!req.url.startsWith("/v1/messages")) {
      res.writeHead(404).end();
      return;
    }
    if (token === TOKEN_A && process.env.REPRO_MODE !== "success-first") {
      res.writeHead(429, {
        "content-type": "application/json",
        "anthropic-ratelimit-unified-status": "rejected",
        "anthropic-ratelimit-unified-reset": String(Math.floor(Date.now() / 1000) + 3600),
      });
      res.end(JSON.stringify({ type: "error", error: { type: "rate_limit_error", message: "You have hit your usage limit (mock)" } }));
      return;
    }
    res.writeHead(200, { "content-type": "text/event-stream" });
    const ev = (t, d) => res.write(`event: ${t}\ndata: ${JSON.stringify({ type: t, ...d })}\n\n`);
    ev("message_start", { message: { id: "msg_1", type: "message", role: "assistant", model: "claude-sonnet-4-5", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 0 } } });
    ev("content_block_start", { index: 0, content_block: { type: "text", text: "" } });
    ev("content_block_delta", { index: 0, delta: { type: "text_delta", text: "ok" } });
    ev("content_block_stop", { index: 0 });
    ev("message_delta", { delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } });
    ev("message_stop", {});
    res.end();
  });
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const baseUrl = `http://127.0.0.1:${server.address().port}`;

// ---- fake CLAUDE_CONFIG_DIR with OAuth credentials -----------------------
const configDir = mkdtempSync(join(tmpdir(), "bb1654-claude-config-"));
function writeCredentials(accessToken) {
  const credPath = join(configDir, ".credentials.json");
  let previous = null;
  try { previous = statSync(credPath, { bigint: true }); } catch {}
  writeFileSync(credPath, JSON.stringify({
    claudeAiOauth: {
      accessToken,
      refreshToken: "sk-ant-ort01-unused",
      expiresAt: Date.now() + 24 * 3600 * 1000,
      scopes: ["user:inference", "user:profile"],
      subscriptionType: "max",
    },
  }));
  if (previous && process.env.REPRO_MODE === "preserve-mtime") {
    // Emulate a credential store change that the CLI's mtime probe cannot see
    // (macOS: a leftover ~/.claude/.credentials.json next to a keychain entry).
    // node's utimes only has ms precision; use python os.utime(ns=...) to restore the exact nanosecond mtime
    execFileSync("python3", ["-c", "import os,sys;os.utime(sys.argv[1], ns=(int(sys.argv[2]), int(sys.argv[3])))", credPath, String(previous.atimeNs), String(previous.mtimeNs)]);
    const now = statSync(credPath, { bigint: true });
    console.log(`[repro] (preserve-mtime) mtimeNs before=${previous.mtimeNs} after=${now.mtimeNs} (${now.mtimeNs === previous.mtimeNs ? "unchanged" : "CHANGED"})`);
  }
  console.log(`[repro] wrote ${configDir}/.credentials.json with ${accessToken}`);
}
writeCredentials(TOKEN_A);

const env = {
  ...process.env,
  CLAUDE_CONFIG_DIR: configDir,
  ANTHROPIC_BASE_URL: baseUrl,
  CLAUDE_CODE_ENTRYPOINT: "cli",
  DISABLE_TELEMETRY: "1",
  CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
  NO_PROXY: "127.0.0.1,localhost",
};
for (const k of ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CLAUDE_AGENT_SDK_CLIENT_APP", "CLAUDE_CODE_MESSAGING_SOCKET", "CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_CHILD_SESSION", "CLAUDE_PID", "CLAUDE_CODE_EXECPATH", "ANTHROPIC_MODEL"]) delete env[k];

// ---- a streaming-input SDK session, exactly like bb's SdkSession.start() --
function openSession(label) {
  let resolveNext = null;
  const queue = [];
  const prompt = { [Symbol.asyncIterator]() { return { next() {
    if (queue.length) return Promise.resolve({ value: queue.shift(), done: false });
    return new Promise((r) => (resolveNext = r));
  } }; } };
  const q = query({ prompt, options: {
    cwd: process.cwd(), env, model: "claude-sonnet-4-5", ...(claudeBin ? { pathToClaudeCodeExecutable: claudeBin } : {}),
    systemPrompt: "Reply with ok.", permissionMode: "default",
    settingSources: [], persistSession: false, includePartialMessages: false,
    tools: [], allowedTools: [],
    stderr: (d) => process.stderr.write(`[claude stderr ${label}] ${d}`),
  } });
  const results = [];
  let onResult = null;
  (async () => { for await (const m of q) {
    if (m.type === "result") { results.push(m); onResult?.(m); }
  } })().catch((e) => console.log(`[${label}] stream error: ${e.message}`));
  return {
    turn(text) {
      const msg = { type: "user", message: { role: "user", content: text }, parent_tool_use_id: null, session_id: "" };
      const done = new Promise((r) => (onResult = r));
      if (resolveNext) { const r = resolveNext; resolveNext = null; r({ value: msg, done: false }); } else queue.push(msg);
      return done;
    },
    close() { q.close(); },
  };
}

function report(step, before) {
  const calls = seen.slice(before).filter((s) => s.path.startsWith("/v1/messages"));
  const tokens = [...new Set(calls.map((c) => c.token))];
  console.log(`[repro] ${step}: /v1/messages calls=${calls.length} tokens used=${JSON.stringify(tokens)}`);
  return tokens;
}

const summary = {};
console.log("\n=== Step 1: session P1 starts with token A (account A), first turn -> rate limited ===");
const p1 = openSession("P1");
let mark = seen.length;
let r = await p1.turn("Reply only with ok.");
console.log(`[repro] turn 1 result: subtype=${r.subtype} is_error=${r.is_error}`);
summary.turn1 = report("turn 1", mark);

console.log("\n=== Step 2: user runs `claude login` on account B (credentials file rewritten with token B) ===");
writeCredentials(TOKEN_B);

console.log("\n=== Step 3: retry on the SAME live process P1 (what bb does when you resend in the thread) ===");
mark = seen.length;
r = await p1.turn("Reply only with ok.");
console.log(`[repro] turn 2 result: subtype=${r.subtype} is_error=${r.is_error}`);
summary.turn2 = report("turn 2 (same process, after login)", mark);
p1.close();

console.log("\n=== Step 4: fresh process P2 (what a bb server restart gives you) ===");
const p2 = openSession("P2");
mark = seen.length;
r = await p2.turn("Reply only with ok.");
console.log(`[repro] turn 3 result: subtype=${r.subtype} is_error=${r.is_error}`);
summary.turn3 = report("turn 3 (new process)", mark);
p2.close();

server.close();
console.log("\n=== SUMMARY ===");
console.log(JSON.stringify(summary, null, 2));
const bug = summary.turn2.includes(TOKEN_A) && !summary.turn2.includes(TOKEN_B) && summary.turn3.includes(TOKEN_B);
console.log(bug
  ? "BUG REPRODUCED: the live claude process kept sending token A after the credentials file changed to token B; only a new process picked up token B."
  : "NOT reproduced: the live process picked up the new token.");
process.exit(bug ? 1 : 0);

bb end-to-end: the provider process really is long-lived, and a bb restart replaces it

On a dev instance at 16ceb3a54 I spawned a real claude-code thread, sent a second message, and looked at the process tree under the host daemon each time (bb-e2e-pid-persistence.txt). The same claude pid served both turns and stayed alive while idle. Stopping the idle thread (bb thread stop) released it and the next message started a new pid — the workaround that does not need a server restart. On the user's own bb instance on this machine (observed with ps only) a bridge-spawned claude had been alive for ~14.8 h.

Second e2e check (revision): the same flow, but with a real bb restart in between (bb-e2e-server-restart.txt): pnpm dev:stop killed the host daemon and with it claude pid 3753262; after scripts/bb-dev-app current the next message on the same thread was served by a new pid 3756083. That is the "restart bb" step from the issue, done rather than inferred.

# bb e2e check #2: restarting the bb dev instance (server + host daemon) replaces the thread's `claude` process
# worktree wf_570fde41-63f-9 @ 16ceb3a54, own dev instance: Server http://localhost:23805, Host daemon http://127.0.0.1:31805
# data dir: /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_570fde41-63f-9-45ed1305f002
# project proj_txeksunvpf (/tmp/bb1654-rev2-scratch, host host_ymk48rmi75)

$ node packages/scripts/dist/commands/run-cli.js thread spawn --project proj_txeksunvpf --provider claude-code \
    --permission-mode accept-edits --title "1654 restart check" --prompt "Reply only with ok." --json
  -> thr_kfwp7tkjf8
$ node packages/scripts/dist/commands/run-cli.js thread wait thr_kfwp7tkjf8 --json
  matched: status idle                                  (15:17:05 UTC)

$ ss -ltnp | grep :31805        -> host daemon pid 3750764
$ pstree -p -T 3750764 | grep claude
                    |-MainThread(3753249)---claude(3753262)
$ ps -o pid,ppid,etimes,lstart,args -p 3753249,3753262
    PID    PPID ELAPSED                  STARTED COMMAND
3753249 3750764      14 Tue Aug 18 15:16:59 2026 node ... apps/host-daemon (provider-claude-code bridge)
3753262 3753249      13 Tue Aug 18 15:16:59 2026 /home/sawyer/.local/bin/claude --output-format stream-json --verbose --input-format stream-json --thinking adaptive ...

# --- restart bb (what the reporter did) ---
$ pnpm dev:stop
[2026-08-18 15:17:19] Stopping screen session bb-dev-app-projects-bb-.claude-worktrees-wf_570fde41-63f-9-dev
[2026-08-18 15:17:21] Stopping remaining dev-instance processes: 3753769
$ ps -p 3753262                 -> (no such process: the claude process died with the host daemon)
$ scripts/bb-dev-app current    -> Server http://localhost:23805, Host daemon http://127.0.0.1:31805, Dev session: running

# --- resend on the SAME thread after the restart ---
$ node packages/scripts/dist/commands/run-cli.js thread tell thr_kfwp7tkjf8 "Reply only with ok." --json
{ "threadId": "thr_kfwp7tkjf8", "ok": true, "mode": "steer" }
$ node packages/scripts/dist/commands/run-cli.js thread wait thr_kfwp7tkjf8 --json
  matched: status idle                                  (15:17:59 UTC)

$ ss -ltnp | grep :31805        -> new host daemon pid 3755187
$ pstree -p -T 3755187 | grep claude
                    `-MainThread(3756019)---claude(3756083)
$ ps -o pid,ppid,etimes,lstart,args -p 3756019,3756083
    PID    PPID ELAPSED                  STARTED COMMAND
3756019 3755187      12 Tue Aug 18 15:17:53 2026 node ... apps/host-daemon (provider-claude-code bridge)
3756083 3756019      12 Tue Aug 18 15:17:53 2026 /home/sawyer/.local/bin/claude --output-format stream-json --verbose --input-format stream-json ...
$ ps -p 3753262 -> old pid 3753262 gone

# => After a bb restart the same thread is served by a NEW claude process (pid 3756083, started 15:17:53), which reads
#    credentials fresh at startup. This is the "restart fixes it" path from the issue, verified directly (not by proxy).
# bb e2e check: one long-lived `claude` process per thread, kept alive across turns
# dev instance: worktree 16ceb3a54, App http://localhost:14796, Server http://localhost:22796, Host daemon http://127.0.0.1:30796
# data dir: /home/sawyer/.bb-dev/projects-bb-.claude-worktrees-wf_242c3e11-a10-14-36521054d605
# host daemon pid: 2414254 (found via `ss -ltnp | grep :30796`)

$ pnpm bb:dev thread spawn --project proj_gatbfz5kej --provider claude-code --permission-mode accept-edits --title "1654 pid check" --prompt "Reply only with ok." --json
  -> thread thr_bdragpqzef  (turn 1 completed, status idle at 07:35)

$ pstree -p -T 2414254 | grep -v esbuild
                    |-MainThread(2422114)---claude(2422530)

$ ps -o pid,ppid,etimes,lstart,args -p 2422114,2422530
    PID    PPID ELAPSED                  STARTED COMMAND
2422114 2414254      32 Tue Aug 18 07:34:55 2026 node ... apps/host-daemon/dist/bb-claude-code-bridge.mjs   (provider-claude-code bridge)
2422530 2422114      31 Tue Aug 18 07:34:56 2026 /home/sawyer/.local/bin/claude --output-format stream-json --verbose --input-format stream-json --thinking adaptive --thinking-display summarized --effort medium --model ... --permission-prompt-tool stdio
$ date
Tue Aug 18 07:35:29 AM UTC 2026
# thread status at this point:
$ curl -s $BB_SERVER_URL/api/v1/threads/thr_bdragpqzef | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['status'],d['runtime'])"
idle {'displayStatus': 'idle', 'hostReconnectGraceExpiresAt': None}

# --- second turn on the same thread ---
$ pnpm bb:dev thread tell thr_bdragpqzef "Reply only with ok." --json
{ "threadId": "thr_bdragpqzef", "ok": true, "mode": "steer" }
$ pnpm bb:dev thread wait thr_bdragpqzef --json
  "matched": true, "target": { "kind": "status", "status": "idle" }

$ pstree -p -T 2414254 | grep claude
                    |-MainThread(2422114)---claude(2422530)
$ ps -o pid,ppid,etimes,lstart -p 2422530
    PID    PPID ELAPSED                  STARTED
2422530 2422114      60 Tue Aug 18 07:34:56 2026

# => the SAME claude process (pid 2422530, started 07:34:56) served both turns and stays alive while the thread is idle.
# It only goes away on an explicit stop/release/archive of the thread or when the host daemon (bb server) restarts.
#
# For comparison, on the user's real bb instance on this machine (not touched, only observed with ps) a bridge-spawned
# `claude --output-format stream-json ... --permission-prompt-tool stdio` process had been alive for 53313 s (~14.8 h).

# --- workaround check: releasing the idle runtime spawns a fresh claude process on the next send ---
$ pnpm bb:dev thread stop thr_bdragpqzef --json
{ "ok": true, "threadId": "thr_bdragpqzef" }
$ pstree -p -T 2414254 | grep -c claude
0
$ pnpm bb:dev thread tell thr_bdragpqzef "Reply only with ok." --json ; pnpm bb:dev thread wait thr_bdragpqzef --json
  ok / matched status idle
$ pstree -p -T 2414254 | grep claude
                    |-MainThread(2422114)---claude(2452544)      <- NEW pid (was 2422530); this process reads credentials fresh at startup
$ date
Tue Aug 18 07:38:25 AM UTC 2026

Root cause

1. bb keeps one claude process per thread for the thread's whole life. SdkSession.start() opens a streaming-input SDK query with persistSession: true (sdk-session.ts#L211-L285); each new turn is pushInput() into that same query (sdk-session.ts#L287-L324, called from runTurnStartqueuePromptInputs in bridge.ts#L2257-L2296). The bridge only rebuilds a session when its SDK stream ended (getWritableThreadSessionreplaceEndedThreadSession, bridge.ts#L1199-L1244); a 429 result does not end the stream. The server releases an idle runtime only on an explicit stop (stopThreadForCurrentStatereleaseIdleThreadRuntime, thread-lifecycle.ts#L1479-L1553). Nothing recycles the process after a rate-limit or auth failure. bb passes no credentials of its own (buildSessionEnv), so which account a thread bills is decided entirely inside that long-lived CLI process.

2. The Claude Code CLI memoizes the OAuth token and invalidates it only on narrow triggers. From the shipped binaries (minified names, see excerpts): getClaudeAIOAuthTokens (ua = iu(() => … al().read()?.claudeAiOauth …)) and its async twin are memoized (wrapped in a cache: the first call reads the store, later calls return the remembered value until .cache.clear()). Before each API request the client runs the "refresh if needed" step, whose first action is:

async function A8_(){
  try{ let{mtimeMs:e}=await stat(join(configDir,".credentials.json")); if(e!==lastMtime) lastMtime=e, clearTokenCaches() }
  catch{ /* file missing (Keychain users) */ ua.cache.clear(); s5.cache.clear(); let t=(await s5())?.accessToken??null; … }
}

So on Linux a real claude login is picked up (mtime changes) — which is why the plain flow does not reproduce here — but any change the mtime probe cannot see leaves the memo in place for the life of the process (the preserve-mtime run). On macOS the store is the Keychain: if ~/.claude/.credentials.json is absent the CLI re-reads the Keychain through its own 30 s cache ($2o=30000), and on any failed/timed-out security find-generic-password re-read (2 s timeout, versus 10 s for the startup prefetch) it re-caches the old token for another 30 s ("[keychain] read failed; serving stale cache"); if the file is present (plaintext fallback leftover — the CLI only deletes it when a Keychain write succeeds while the Keychain held nothing before) the mtime path wins and the Keychain change is never noticed. A 429 invalidates nothing; only a 401 triggers "recovered from disk/keychain" recovery. Either way the visible symptom is the reported one: usage page (fresh read) shows account B, the thread's process keeps billing account A, restart fixes it.

Deeper issue. Even when the CLI does reload, bb offers the user no way short of a server restart to "make this thread use my current login": the rate-limit recovery path (continueThreadAfterProviderRateLimit, provider-rate-limit-recovery.ts#L302-L330) and a manual resend both push into the existing process. The thread's provider identity (providerThreadId) is persisted, so a fresh process could resume the same conversation at no cost — the bridge already does exactly that when a stream ends.

Proposed fix (first principles)

PR review

No open PRs are linked to this issue.

Related issues

Appendix

Commands run

gh issue view 1654 --repo get-bb/bb --json ...            # no comments on the issue
git checkout 16ceb3a54 && pnpm install --frozen-lockfile --prefer-offline && pnpm exec turbo run build
grep -rn "claudeAiOauth|credentials.json|oauth" apps plugins packages   # bb reads the store only in apps/host-daemon/src/provider-usage.ts
grep -rn "CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY" apps plugins packages docs   # only a bb-app env test; bb never sets a token
cp /tmp/bb-reports/issues/1654/repro/stale-oauth-repro.mjs plugins/provider-claude-code/repro-1654.mjs
node plugins/provider-claude-code/repro-1654.mjs                                    # PATH claude 2.1.234 -> run-default-2.1.234.log
REPRO_MODE=success-first node plugins/provider-claude-code/repro-1654.mjs           # 200 on turn 1: still reloads on turn 2 (Linux)
REPRO_MODE=preserve-mtime node plugins/provider-claude-code/repro-1654.mjs          # -> run-preserve-mtime-2.1.234.log (BUG REPRODUCED)
REPRO_CLAUDE_BIN=~/.local/share/claude/versions/2.1.233 node plugins/provider-claude-code/repro-1654.mjs   # 11 calls per 429 turn, reloads on turn 2
REPRO_MODE=preserve-mtime REPRO_CLAUDE_BIN=~/.local/share/claude/versions/2.1.233 node …                # -> run-preserve-mtime-2.1.233.log
REPRO_CLAUDE_BIN=sdk-bundled node …  /  REPRO_MODE=preserve-mtime REPRO_CLAUDE_BIN=sdk-bundled node …  # SDK's own 2.1.197 -> run-*-sdk-bundled-2.1.197.log
python3 /tmp/bb-reports/issues/1654/repro/run-all.py plugins/provider-claude-code/repro-1654.mjs         # all five of the above, writes the logs
strings -n 8 ~/.local/share/claude/versions/2.1.234 > /tmp/strings.txt   # then python substring search for
#   getClaudeAIOAuthTokens / ".credentials.json")) / mtimeMs / name:"keychain" / tengu_oauth_401_recovered_from_*
curl -fsSL -o /tmp/bb1654-mac/claude-darwin-arm64 https://downloads.claude.ai/claude-code-releases/2.1.234/darwin-arm64/claude  (and 2.1.233)
scripts/bb-dev-app current ; eval "$(scripts/bb-dev-app env)"
curl -s -X POST $BB_SERVER_URL/api/v1/projects -H 'content-type: application/json' -d '{"name":"qa","source":{"type":"local_path","path":"/tmp/bb1654-scratch","hostId":"host_pygkamp3h8"}}'
pnpm bb:dev thread spawn --project proj_gatbfz5kej --provider claude-code --permission-mode accept-edits --title "1654 pid check" --prompt "Reply only with ok." --json
ss -ltnp | grep :30796 ; pstree -p -T <host daemon pid> ; ps -o pid,ppid,etimes,lstart,args -p <bridge>,<claude>
pnpm bb:dev thread tell thr_bdragpqzef "Reply only with ok." --json ; pnpm bb:dev thread wait thr_bdragpqzef --json ; pstree again
pnpm bb:dev thread stop thr_bdragpqzef --json ; tell again ; pstree again   # new claude pid
# revision (second worktree/instance): server-restart check
node packages/scripts/dist/commands/run-cli.js thread spawn --project proj_txeksunvpf --provider claude-code --permission-mode accept-edits --title "1654 restart check" --prompt "Reply only with ok." --json ; thread wait
pstree -p -T <host daemon pid> | grep claude ; ps -o pid,ppid,etimes,lstart,args -p <bridge>,<claude>
pnpm dev:stop ; scripts/bb-dev-app current ; thread tell thr_kfwp7tkjf8 "Reply only with ok." ; thread wait ; pstree again   # new claude pid 3756083
git fetch origin main ; git log --oneline 16ceb3a54..origin/main            # nothing relevant
pnpm dev:stop

Claude Code CLI credential-cache excerpts (2.1.234 linux, 2.1.234/2.1.233 darwin) (file)

Excerpts of the Claude Code CLI's OAuth credential handling, recovered from the
embedded (minified) JS source in the shipped binaries. Identifiers are minified;
readable names are given in comments. Extracted with `strings -n 8 <binary>` and
python substring search (see the report's Appendix for the exact commands).

Binaries:
  linux-x64  2.1.234  ~/.local/share/claude/versions/2.1.234           (what bb spawns on this machine)
  darwin-arm64 2.1.234 and 2.1.233  https://downloads.claude.ai/claude-code-releases/<ver>/darwin-arm64/claude

--------------------------------------------------------------------------------
1) getClaudeAIOAuthTokens (sync, `ua`) and getClaudeAIOAuthTokensAsync (`s5`/`az`) are MEMOIZED
   (`iu(...)` = lodash-style memoize with `.cache`; `B0r/FCr(...)` = promise memoize). linux 2.1.234:

ua=iu(()=>{
  if(pg())return null;                                        // CLAUDE_CODE_SIMPLE / --bare
  if(V.CLAUDE_CODE_OAUTH_TOKEN)return{accessToken:V.CLAUDE_CODE_OAUTH_TOKEN, ...};
  let e=nye(),t=(r)=>({accessToken:r, ...});                  // fd token
  if(e&&(!x6t()||IH()))return t(e);
  if(IH())return null;
  try{let o=al().read()?.claudeAiOauth;if(o?.accessToken)return o}catch(r){ke(r)}   // <- read from secure storage ONCE, then cached
  if(e)return t(e);
  return null});
s5=B0r(async()=>{ ... try{let n=(await al().readAsync())?.claudeAiOauth;if(n?.accessToken)return n}catch(t){ke(t)} ... });

--------------------------------------------------------------------------------
2) The only pre-request cache invalidation: `A8_` (linux) / `OG_` (darwin 2.1.234) / `bM_` (darwin 2.1.233),
   called first thing from the "refresh OAuth token if needed" step (`q6s`/`JWs`, wrapped by `Nb`/`Fb`)
   which the API client runs before creating each request ("[API:request] Creating client ... await Fb(); let m=ua()").

async function A8_(){
  try{
    let{mtimeMs:e}=await g7t.stat(ERr.join(tee(),".credentials.json"));   // <config dir>/.credentials.json
    if(e!==Bmd)Bmd=e,OV()                                                   // mtime changed -> clear ua/s5 memo caches (OV = s8s + ...)
  }catch{
    // file missing (macOS keychain users): clear memo caches and re-read via the storage backend
    ua.cache?.clear?.(),s5.cache?.clear?.();
    let t=(await s5())?.accessToken??null;
    if(t!==Fmd)Fmd=t,rye(),QRe()
  }
}
function s8s(){ua.cache?.clear?.(),s5.cache?.clear?.(),oye()}
function OV(){s8s(),rye(),QRe()}

  => If <config dir>/.credentials.json EXISTS and its mtime is unchanged, nothing is invalidated and the process
     keeps the token it loaded at startup (this is what the preserve-mtime repro exercises on Linux).
  => Other invalidation points are only: successful token refresh/save, /login inside the same process,
     401 recovery ("tengu_oauth_401_recovered_from_disk" / "..._from_keychain"), structured-IO env update.
     A 429 (usage limit) does NOT invalidate anything.

--------------------------------------------------------------------------------
3) Storage backend `al()`.
   linux:  function al(){if(TJu)return TJu;return q4s}          // q4s = {name:"plaintext", read(){ readFileSync(<cfg>/.credentials.json) } ...}  (keychain code compiled out)
   darwin: function al(){if(MXu)return MXu;return _Xu(G3s,j2o)} // keychain-with-plaintext-fallback

   darwin keychain backend (2.1.234; identical in 2.1.233 with other minified names) has its OWN 30 s cache and
   serves STALE data when `security` fails/times out (2 s timeout on re-reads vs 10 s for the startup prefetch):

G3s={name:"keychain",
  read(){let e=AR.cache;if(Date.now()-e.cachedAt<$2o)return e.data;          // $2o = 30000 ms
    let t=AR.lastReadFailure;if(t!==null&&Date.now()-t<z3s)return e.data;      // z3s = 1000 ms
    try{ ... o=Oat(`security find-generic-password -a "${n}" -w -s "${r}"`,{timeout:qxr}) ... }catch(r){}   // qxr = 2000 ms
    if(e.data!==null)return T("[keychain] read failed; serving stale cache",{level:"warn"}),AR.cache={data:e.data,cachedAt:Date.now()},e.data;
    return AR.cache={data:null,cachedAt:Date.now()},null},
  async readAsync(){let e=AR.cache;if(Date.now()-e.cachedAt<$2o)return e.data; ...
    n=SXu().then((o)=>{ ... if(o===MI){ T("[keychain] readAsync failed; not caching a null"); AR.lastReadFailure=Date.now();
                          if(e.data!==null)AR.cache={data:e.data,cachedAt:Date.now()}; return e.data }   // failure => stale token re-cached for another 30 s
    ... })},
  invalidateCache(){iee()}, ...}
function iee(){AR.cache={data:null,cachedAt:0},AR.generation++,AR.readInFlight=null,AR.lastReadFailure=null}
var MVe="-credentials",$2o=30000,z3s=1000;   qxr=2000 (security timeout for re-reads); SF_=1e4 (startup prefetch timeout)

   The plaintext fallback file on darwin is only deleted when a keychain write succeeds AND the keychain previously
   held no entry (`_Xu.update`: `if(i===null)await t.delete(o)`), so a leftover ~/.claude/.credentials.json can
   coexist with the keychain entry that `claude login` actually updates.

--------------------------------------------------------------------------------
4) Behavioural difference seen in the repro (CORRECTED after verification): the "1 call per 429 turn" behaviour seen
   in the first draft's default runs belongs to the Agent SDK's own bundled CLI (@anthropic-ai/claude-agent-sdk-linux-x64
   0.3.197 = Claude Code 2.1.197), which the SDK spawns when no pathToClaudeCodeExecutable is given. bb never uses that
   binary: it passes the PATH `claude` (resolveClaudeCodeExecutable). The real 2.1.233 AND 2.1.234 binaries both retry
   a usage-limit 429 (10 retries with backoff, 11 calls per turn) and only then return `result is_error=true`.
   None of 2.1.197 / 2.1.233 / 2.1.234 invalidates the token cache on 429 (preserve-mtime run pins token A on all three).

2.1.233 preserve-mtime run (run-preserve-mtime-2.1.233.log; same result: 11 retried calls per 429 turn, live process pinned to token A, new process uses token B)

$ REPRO_MODE=preserve-mtime REPRO_CLAUDE_BIN=/home/sawyer/.local/share/claude/versions/2.1.233 node repro-1654.mjs
(exit=1)

[repro] claude binary: /home/sawyer/.local/share/claude/versions/2.1.233
[repro] claude --version: 2.1.233 (Claude Code)
[repro] wrote /tmp/bb1654-claude-config-jgo9GM/.credentials.json with sk-ant-oat01-ACCOUNT-A-rate-limited

=== Step 1: session P1 starts with token A (account A), first turn -> rate limited ===
[mock-api] HEAD /api/hello authorization=(none)
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[repro] turn 1 result: subtype=success is_error=true
[repro] turn 1: /v1/messages calls=11 tokens used=["sk-ant-oat01-ACCOUNT-A-rate-limited"]

=== Step 2: user runs `claude login` on account B (credentials file rewritten with token B) ===
[repro] (preserve-mtime) mtimeNs before=1787066705454553215 after=1787066705454553215 (unchanged)
[repro] wrote /tmp/bb1654-claude-config-jgo9GM/.credentials.json with sk-ant-oat01-ACCOUNT-B-fresh-login

=== Step 3: retry on the SAME live process P1 (what bb does when you resend in the thread) ===
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[repro] turn 2 result: subtype=success is_error=true
[repro] turn 2 (same process, after login): /v1/messages calls=11 tokens used=["sk-ant-oat01-ACCOUNT-A-rate-limited"]

=== Step 4: fresh process P2 (what a bb server restart gives you) ===
[mock-api] HEAD /api/hello authorization=(none)
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-B-fresh-login
[repro] turn 3 result: subtype=success is_error=false
[repro] turn 3 (new process): /v1/messages calls=1 tokens used=["sk-ant-oat01-ACCOUNT-B-fresh-login"]

=== SUMMARY ===
{
  "turn1": [
    "sk-ant-oat01-ACCOUNT-A-rate-limited"
  ],
  "turn2": [
    "sk-ant-oat01-ACCOUNT-A-rate-limited"
  ],
  "turn3": [
    "sk-ant-oat01-ACCOUNT-B-fresh-login"
  ]
}
BUG REPRODUCED: the live claude process kept sending token A after the credentials file changed to token B; only a new process picked up token B.

SDK-bundled Claude Code 2.1.197 (not what bb runs; comparison only)

Default (run-default-sdk-bundled-2.1.197.log): NOT reproduced, 1 call per 429 turn. Preserve-mtime (run-preserve-mtime-sdk-bundled-2.1.197.log): BUG REPRODUCED. Same verdicts as 2.1.233/2.1.234; the only difference is that this build does not retry the 429 in-process.

$ REPRO_MODE=preserve-mtime REPRO_CLAUDE_BIN=sdk-bundled node repro-1654.mjs
(exit=1)

[repro] claude binary: (SDK-bundled @anthropic-ai/claude-agent-sdk-<platform> CLI)
[repro] wrote /tmp/bb1654-claude-config-wdhs6Y/.credentials.json with sk-ant-oat01-ACCOUNT-A-rate-limited

=== Step 1: session P1 starts with token A (account A), first turn -> rate limited ===
[mock-api] HEAD / authorization=(none)
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[repro] turn 1 result: subtype=success is_error=true
[repro] turn 1: /v1/messages calls=1 tokens used=["sk-ant-oat01-ACCOUNT-A-rate-limited"]

=== Step 2: user runs `claude login` on account B (credentials file rewritten with token B) ===
[repro] (preserve-mtime) mtimeNs before=1787067058772330622 after=1787067058772330622 (unchanged)
[repro] wrote /tmp/bb1654-claude-config-wdhs6Y/.credentials.json with sk-ant-oat01-ACCOUNT-B-fresh-login

=== Step 3: retry on the SAME live process P1 (what bb does when you resend in the thread) ===
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-A-rate-limited
[repro] turn 2 result: subtype=success is_error=true
[repro] turn 2 (same process, after login): /v1/messages calls=1 tokens used=["sk-ant-oat01-ACCOUNT-A-rate-limited"]

=== Step 4: fresh process P2 (what a bb server restart gives you) ===
[mock-api] HEAD / authorization=(none)
[mock-api] POST /v1/messages?beta=true authorization=Bearer sk-ant-oat01-ACCOUNT-B-fresh-login
[repro] turn 3 result: subtype=success is_error=false
[repro] turn 3 (new process): /v1/messages calls=1 tokens used=["sk-ant-oat01-ACCOUNT-B-fresh-login"]

=== SUMMARY ===
{
  "turn1": [
    "sk-ant-oat01-ACCOUNT-A-rate-limited"
  ],
  "turn2": [
    "sk-ant-oat01-ACCOUNT-A-rate-limited"
  ],
  "turn3": [
    "sk-ant-oat01-ACCOUNT-B-fresh-login"
  ]
}
BUG REPRODUCED: the live claude process kept sending token A after the credentials file changed to token B; only a new process picked up token B.

Verification

An independent verifier followed the repro steps literally in a fresh worktree at 16ceb3a54 and got the same verdicts (plain → exit 0 NOT reproduced; preserve-mtime → exit 1 BUG REPRODUCED), reproduced the same-pid / bb thread stop → new-pid e2e on their own dev instance, confirmed the CLI excerpts against strings of the linux 2.1.234 and darwin binaries, checked all permalinks, and confirmed origin/main (16ceb3a54..a108fa7ef) contains no fix. They found one substantive error: the first draft's default runs did not use Claude Code 2.1.234 — without pathToClaudeCodeExecutable the Agent SDK spawns its own bundled CLI (2.1.197), so the two "2.1.234" logs were mislabeled and the derived claim that 2.1.234 "no longer retries usage-limit 429s (1 call vs 11)" was wrong. Changes in this revision: