Concurrent sub-agents trigger "Not logged in" errors when ~/.claude/.credentials.json is absent (valid token, macOS Keychain)
Description
When multiple Claude Code sub-agents run concurrently on macOS, all agents intermittently receive "Not logged in" errors even though the OAuth token is valid and was issued less than an hour ago. The session recovers on its own within 1-2 minutes without any user action (re-login is not required).
Environment
- Claude Code version: 2.1.81
- Node.js: v20.19.0
- OS: macOS (Keychain-based OAuth storage)
Trigger conditions
- macOS with Keychain-based OAuth storage
- 3+ concurrent long-lived sub-agent processes
~/.claude/.credentials.jsondoes not exist (default state afterclaude login)- Token is fully valid (within ~1 hour of login, far from the 8-hour expiry)
Root cause
The fault is a cascade involving two functions in cli.js:
kA9() — called at the top of _P1() on every invocation — checks the mtime of ~/.claude/.credentials.json. When the file does not exist, it unconditionally falls into the catch branch and calls hA.cache?.clear?.():
async function kA9() {
try {
let {mtimeMs: A} = await $A9(HA9(d1(), ".credentials.json"));
if (A !== Gu7) Gu7 = A, Jg6()
} catch { hA.cache?.clear?.() } // ← fires on every _P1() call when file absent
}
_P1() then acquires a file lock (Tu7.lock(z)) after hA has already been cleared. With N concurrent agents all reaching this point simultaneously:
- All N agents call
kA9()→ all N clearhA - One agent acquires
Tu7.lock(z)— the other N-1 getELOCKED ELOCKEDretries up to 5× with 1-2s backoff; if all fail,_P1()returnsfalsewithhAstill null- The losing agents' next API call attempts to read
hA()→ null → falls back to Keychain read → if the winning agent is currently writing to the Keychain (dI7.BIis momentarily null duringsecurity add-generic-password) → returns null → "Not logged in"
The 5-second dI7.BI in-process Keychain cache is the narrow window that makes the final null read probabilistic, explaining why the error is intermittent and self-resolving.
Observed log pattern
Two or more agents fail at the same second (within ~500ms of each other), followed 60-90 seconds later by a 400 cache_control.ttl error on the same agent — the 400 is not an auth error; it means auth has already recovered and the agent is back to normal API operation.
Workaround
Creating a stub credentials file eliminates the catch branch:
echo '{}' > ~/.claude/.credentials.json
With the file present and mtime stable, kA9() becomes a no-op, hA retains its cached valid token, and the lock cascade cannot occur.
Verified this does not affect the 8-hour automatic token refresh: vB(expiresAt) returns true for both "about to expire" and "already expired" states, so _P1() still refreshes correctly when needed.
Suggested fix
Option A: Create ~/.claude/.credentials.json (even as an empty {}) during claude login, so the file is always present after authentication.
Option B: In kA9(), avoid clearing hA in the catch branch when the error is ENOENT specifically — a missing file is not a signal that the token changed:
async function kA9() {
try {
let {mtimeMs: A} = await $A9(HA9(d1(), ".credentials.json"));
if (A !== Gu7) Gu7 = A, Jg6()
} catch(e) {
if (e.code !== 'ENOENT') hA.cache?.clear?.() // only clear on unexpected errors
}
}
Option B is the minimal targeted fix: it preserves the intent of kA9() (detect credential file changes) while eliminating the unnecessary cache invalidation that triggers the cascade.
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗