Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
24 kB · 569 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570/** * REPL-specific wrapper around initBridgeCore. Owns the parts that read * bootstrap state — gates, cwd, session ID, git context, OAuth, title * derivation — then delegates to the bootstrap-free core. * * Split out of replBridge.ts because the sessionStorage import * (getCurrentSessionTitle) transitively pulls in src/commands.ts → the * entire slash command + React component tree (~1300 modules). Keeping * initBridgeCore in a file that doesn't touch sessionStorage lets * daemonBridge.ts import the core without bloating the Agent SDK bundle. * * Called via dynamic import by useReplBridge (auto-start) and print.ts * (SDK -p mode via query.enableRemoteControl). */
import { feature } from 'bun:bundle'import { hostname } from 'os'import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'import { getOrganizationUUID } from '../services/oauth/client.js'import { isPolicyAllowed, waitForPolicyLimitsToLoad,} from '../services/policyLimits/index.js'import type { Message } from '../types/message.js'import { checkAndRefreshOAuthTokenIfNeeded, getClaudeAIOAuthTokens, handleOAuth401Error,} from '../utils/auth.js'import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'import { logForDebugging } from '../utils/debug.js'import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'import { errorMessage } from '../utils/errors.js'import { getBranch, getRemoteUrl } from '../utils/git.js'import { toSDKMessages } from '../utils/messages/mappers.js'import { getContentText, getMessagesAfterCompactBoundary, isSyntheticMessage,} from '../utils/messages.js'import type { PermissionMode } from '../utils/permissions/PermissionMode.js'import { getCurrentSessionTitle } from '../utils/sessionStorage.js'import { extractConversationText, generateSessionTitle,} from '../utils/sessionTitle.js'import { generateShortWordSlug } from '../utils/words.js'import { getBridgeAccessToken, getBridgeBaseUrl, getBridgeTokenOverride,} from './bridgeConfig.js'import { checkBridgeMinVersion, isBridgeEnabledBlocking, isCseShimEnabled, isEnvLessBridgeEnabled,} from './bridgeEnabled.js'import { archiveBridgeSession, createBridgeSession, updateBridgeSessionTitle,} from './createSession.js'import { logBridgeSkip } from './debugUtils.js'import { checkEnvLessBridgeMinVersion } from './envLessBridgeConfig.js'import { getPollIntervalConfig } from './pollConfig.js'import type { BridgeState, ReplBridgeHandle } from './replBridge.js'import { initBridgeCore } from './replBridge.js'import { setCseShimGate } from './sessionIdCompat.js'import type { BridgeWorkerType } from './types.js'
export type InitBridgeOptions = { onInboundMessage?: (msg: SDKMessage) => void | Promise<void> onPermissionResponse?: (response: SDKControlResponse) => void onInterrupt?: () => void onSetModel?: (model: string | undefined) => void onSetMaxThinkingTokens?: (maxTokens: number | null) => void onSetPermissionMode?: ( mode: PermissionMode, ) => { ok: true } | { ok: false; error: string } onStateChange?: (state: BridgeState, detail?: string) => void initialMessages?: Message[] // Explicit session name from `/remote-control <name>`. When set, overrides // the title derived from the conversation or /rename. initialName?: string // Fresh view of the full conversation at call time. Used by onUserMessage's // count-3 derivation to call generateSessionTitle over the full conversation. // Optional — print.ts's SDK enableRemoteControl path has no REPL message // array; count-3 falls back to the single message text when absent. getMessages?: () => Message[] // UUIDs already flushed in a prior bridge session. Messages with these // UUIDs are excluded from the initial flush to avoid poisoning the // server (duplicate UUIDs across sessions cause the WS to be killed). // Mutated in place — newly flushed UUIDs are added after each flush. previouslyFlushedUUIDs?: Set<string> /** See BridgeCoreParams.perpetual. */ perpetual?: boolean /** * When true, the bridge only forwards events outbound (no SSE inbound * stream). Used by CCR mirror mode — local sessions visible on claude.ai * without enabling inbound control. */ outboundOnly?: boolean tags?: string[]}
export async function initReplBridge( options?: InitBridgeOptions,): Promise<ReplBridgeHandle | null> { const { onInboundMessage, onPermissionResponse, onInterrupt, onSetModel, onSetMaxThinkingTokens, onSetPermissionMode, onStateChange, initialMessages, getMessages, previouslyFlushedUUIDs, initialName, perpetual, outboundOnly, tags, } = options ?? {}
// Wire the cse_ shim kill switch so toCompatSessionId respects the // GrowthBook gate. Daemon/SDK paths skip this — shim defaults to active. setCseShimGate(isCseShimEnabled)
// 1. Runtime gate if (!(await isBridgeEnabledBlocking())) { logBridgeSkip('not_enabled', '[bridge:repl] Skipping: bridge not enabled') return null }
// 1b. Minimum version check — deferred to after the v1/v2 branch below, // since each implementation has its own floor (tengu_bridge_min_version // for v1, tengu_bridge_repl_v2_config.min_version for v2).
// 2. Check OAuth — must be signed in with claude.ai. Runs before the // policy check so console-auth users get the actionable "/login" hint // instead of a misleading policy error from a stale/wrong-org cache. if (!getBridgeAccessToken()) { logBridgeSkip('no_oauth', '[bridge:repl] Skipping: no OAuth tokens') onStateChange?.('failed', '/login') return null }
// 3. Check organization policy — remote control may be disabled await waitForPolicyLimitsToLoad() if (!isPolicyAllowed('allow_remote_control')) { logBridgeSkip( 'policy_denied', '[bridge:repl] Skipping: allow_remote_control policy not allowed', ) onStateChange?.('failed', "disabled by your organization's policy") return null }
// When CLAUDE_BRIDGE_OAUTH_TOKEN is set (ant-only local dev), the bridge // uses that token directly via getBridgeAccessToken() — keychain state is // irrelevant. Skip 2b/2c to preserve that decoupling: an expired keychain // token shouldn't block a bridge connection that doesn't use it. if (!getBridgeTokenOverride()) { // 2a. Cross-process backoff. If N prior processes already saw this exact // dead token (matched by expiresAt), skip silently — no event, no refresh // attempt. The count threshold tolerates transient refresh failures (auth // server 5xx, lockfile errors per auth.ts:1437/1444/1485): each process // independently retries until 3 consecutive failures prove the token dead. // Mirrors useReplBridge's MAX_CONSECUTIVE_INIT_FAILURES for in-process. // The expiresAt key is content-addressed: /login → new token → new expiresAt // → this stops matching without any explicit clear. const cfg = getGlobalConfig() if ( cfg.bridgeOauthDeadExpiresAt != null && (cfg.bridgeOauthDeadFailCount ?? 0) >= 3 && getClaudeAIOAuthTokens()?.expiresAt === cfg.bridgeOauthDeadExpiresAt ) { logForDebugging( `[bridge:repl] Skipping: cross-process backoff (dead token seen ${cfg.bridgeOauthDeadFailCount} times)`, ) return null }
// 2b. Proactively refresh if expired. Mirrors bridgeMain.ts:2096 — the REPL // bridge fires at useEffect mount BEFORE any v1/messages call, making this // usually the first OAuth request of the session. Without this, ~9% of // registrations hit the server with a >8h-expired token → 401 → withOAuthRetry // recovers, but the server logs a 401 we can avoid. VPN egress IPs observed // at 30:1 401:200 when many unrelated users cluster at the 8h TTL boundary. // // Fresh-token cost: one memoized read + one Date.now() comparison (~µs). // checkAndRefreshOAuthTokenIfNeeded clears its own cache in every path that // touches the keychain (refresh success, lockfile race, throw), so no // explicit clearOAuthTokenCache() here — that would force a blocking // keychain spawn on the 91%+ fresh-token path. await checkAndRefreshOAuthTokenIfNeeded()
// 2c. Skip if token is still expired post-refresh-attempt. Env-var / FD // tokens (auth.ts:894-917) have expiresAt=null → never trip this. But a // keychain token whose refresh token is dead (password change, org left, // token GC'd) has expiresAt<now AND refresh just failed — the client would // otherwise loop 401 forever: withOAuthRetry → handleOAuth401Error → // refresh fails again → retry with same stale token → 401 again. // Datadog 2026-03-08: single IPs generating 2,879 such 401s/day. Skip the // guaranteed-fail API call; useReplBridge surfaces the failure. // // Intentionally NOT using isOAuthTokenExpired here — that has a 5-minute // proactive-refresh buffer, which is the right heuristic for "should // refresh soon" but wrong for "provably unusable". A token with 3min left // + transient refresh endpoint blip (5xx/timeout/wifi-reconnect) would // falsely trip a buffered check; the still-valid token would connect fine. // Check actual expiry instead: past-expiry AND refresh-failed → truly dead. const tokens = getClaudeAIOAuthTokens() if (tokens && tokens.expiresAt !== null && tokens.expiresAt <= Date.now()) { logBridgeSkip( 'oauth_expired_unrefreshable', '[bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required)', ) onStateChange?.('failed', '/login') // Persist for the next process. Increments failCount when re-discovering // the same dead token (matched by expiresAt); resets to 1 for a different // token. Once count reaches 3, step 2a's early-return fires and this path // is never reached again — writes are capped at 3 per dead token. // Local const captures the narrowed type (closure loses !==null narrowing). const deadExpiresAt = tokens.expiresAt saveGlobalConfig(c => ({ ...c, bridgeOauthDeadExpiresAt: deadExpiresAt, bridgeOauthDeadFailCount: c.bridgeOauthDeadExpiresAt === deadExpiresAt ? (c.bridgeOauthDeadFailCount ?? 0) + 1 : 1, })) return null } }
// 4. Compute baseUrl — needed by both v1 (env-based) and v2 (env-less) // paths. Hoisted above the v2 gate so both can use it. const baseUrl = getBridgeBaseUrl()
// 5. Derive session title. Precedence: explicit initialName → /rename // (session storage) → last meaningful user message → generated slug. // Cosmetic only (claude.ai session list); the model never sees it. // Two flags: `hasExplicitTitle` (initialName or /rename — never auto- // overwrite) vs. `hasTitle` (any title, including auto-derived — blocks // the count-1 re-derivation but not count-3). The onUserMessage callback // (wired to both v1 and v2 below) derives from the 1st prompt and again // from the 3rd so mobile/web show a title that reflects more context. // The slug fallback (e.g. "remote-control-graceful-unicorn") makes // auto-started sessions distinguishable in the claude.ai list before the // first prompt. let title = `remote-control-${generateShortWordSlug()}` let hasTitle = false let hasExplicitTitle = false if (initialName) { title = initialName hasTitle = true hasExplicitTitle = true } else { const sessionId = getSessionId() const customTitle = sessionId ? getCurrentSessionTitle(sessionId) : undefined if (customTitle) { title = customTitle hasTitle = true hasExplicitTitle = true } else if (initialMessages && initialMessages.length > 0) { // Find the last user message that has meaningful content. Skip meta // (nudges), tool results, compact summaries ("This session is being // continued…"), non-human origins (task notifications, channel pushes), // and synthetic interrupts ([Request interrupted by user]) — none are // human-authored. Same filter as extractTitleText + isSyntheticMessage. for (let i = initialMessages.length - 1; i >= 0; i--) { const msg = initialMessages[i]! if ( msg.type !== 'user' || msg.isMeta || msg.toolUseResult || msg.isCompactSummary || (msg.origin && msg.origin.kind !== 'human') || isSyntheticMessage(msg) ) continue const rawContent = getContentText(msg.message.content) if (!rawContent) continue const derived = deriveTitle(rawContent) if (!derived) continue title = derived hasTitle = true break } } }
// Shared by both v1 and v2 — fires on every title-worthy user message until // it returns true. At count 1: deriveTitle placeholder immediately, then // generateSessionTitle (Haiku, sentence-case) fire-and-forget upgrade. At // count 3: re-generate over the full conversation. Skips entirely if the // title is explicit (/remote-control <name> or /rename) — re-checks // sessionStorage at call time so /rename between messages isn't clobbered. // Skips count 1 if initialMessages already derived (that title is fresh); // still refreshes at count 3. v2 passes cse_*; updateBridgeSessionTitle // retags internally. let userMessageCount = 0 let lastBridgeSessionId: string | undefined let genSeq = 0 const patch = ( derived: string, bridgeSessionId: string, atCount: number, ): void => { hasTitle = true title = derived logForDebugging( `[bridge:repl] derived title from message ${atCount}: ${derived}`, ) void updateBridgeSessionTitle(bridgeSessionId, derived, { baseUrl, getAccessToken: getBridgeAccessToken, }).catch(() => {}) } // Fire-and-forget Haiku generation with post-await guards. Re-checks /rename // (sessionStorage), v1 env-lost (lastBridgeSessionId), and same-session // out-of-order resolution (genSeq — count-1's Haiku resolving after count-3 // would clobber the richer title). generateSessionTitle never rejects. const generateAndPatch = (input: string, bridgeSessionId: string): void => { const gen = ++genSeq const atCount = userMessageCount void generateSessionTitle(input, AbortSignal.timeout(15_000)).then( generated => { if ( generated && gen === genSeq && lastBridgeSessionId === bridgeSessionId && !getCurrentSessionTitle(getSessionId()) ) { patch(generated, bridgeSessionId, atCount) } }, ) } const onUserMessage = (text: string, bridgeSessionId: string): boolean => { if (hasExplicitTitle || getCurrentSessionTitle(getSessionId())) { return true } // v1 env-lost re-creates the session with a new ID. Reset the count so // the new session gets its own count-3 derivation; hasTitle stays true // (new session was created via getCurrentTitle(), which reads the count-1 // title from this closure), so count-1 of the fresh cycle correctly skips. if ( lastBridgeSessionId !== undefined && lastBridgeSessionId !== bridgeSessionId ) { userMessageCount = 0 } lastBridgeSessionId = bridgeSessionId userMessageCount++ if (userMessageCount === 1 && !hasTitle) { const placeholder = deriveTitle(text) if (placeholder) patch(placeholder, bridgeSessionId, userMessageCount) generateAndPatch(text, bridgeSessionId) } else if (userMessageCount === 3) { const msgs = getMessages?.() const input = msgs ? extractConversationText(getMessagesAfterCompactBoundary(msgs)) : text generateAndPatch(input, bridgeSessionId) } // Also re-latches if v1 env-lost resets the transport's done flag past 3. return userMessageCount >= 3 }
const initialHistoryCap = getFeatureValue_CACHED_WITH_REFRESH( 'tengu_bridge_initial_history_cap', 200, 5 * 60 * 1000, )
// Fetch orgUUID before the v1/v2 branch — both paths need it. v1 for // environment registration; v2 for archive (which lives at the compat // /v1/sessions/{id}/archive, not /v1/code/sessions). Without it, v2 // archive 404s and sessions stay alive in CCR after /exit. const orgUUID = await getOrganizationUUID() if (!orgUUID) { logBridgeSkip('no_org_uuid', '[bridge:repl] Skipping: no org UUID') onStateChange?.('failed', '/login') return null }
// ── GrowthBook gate: env-less bridge ────────────────────────────────── // When enabled, skips the Environments API layer entirely (no register/ // poll/ack/heartbeat) and connects directly via POST /bridge → worker_jwt. // See server PR #292605 (renamed in #293280). REPL-only — daemon/print stay // on env-based. // // NAMING: "env-less" is distinct from "CCR v2" (the /worker/* transport). // The env-based path below can ALSO use CCR v2 via CLAUDE_CODE_USE_CCR_V2. // tengu_bridge_repl_v2 gates env-less (no poll loop), not transport version. // // perpetual (assistant-mode session continuity via bridge-pointer.json) is // env-coupled and not yet implemented here — fall back to env-based when set // so KAIROS users don't silently lose cross-restart continuity. if (isEnvLessBridgeEnabled() && !perpetual) { const versionError = await checkEnvLessBridgeMinVersion() if (versionError) { logBridgeSkip( 'version_too_old', `[bridge:repl] Skipping: ${versionError}`, true, ) onStateChange?.('failed', 'run `claude update` to upgrade') return null } logForDebugging( '[bridge:repl] Using env-less bridge path (tengu_bridge_repl_v2)', ) const { initEnvLessBridgeCore } = await import('./remoteBridgeCore.js') return initEnvLessBridgeCore({ baseUrl, orgUUID, title, getAccessToken: getBridgeAccessToken, onAuth401: handleOAuth401Error, toSDKMessages, initialHistoryCap, initialMessages, // v2 always creates a fresh server session (new cse_* id), so // previouslyFlushedUUIDs is not passed — there's no cross-session // UUID collision risk, and the ref persists across enable→disable→ // re-enable cycles which would cause the new session to receive zero // history (all UUIDs already in the set from the prior enable). // v1 handles this by calling previouslyFlushedUUIDs.clear() on fresh // session creation (replBridge.ts:768); v2 skips the param entirely. onInboundMessage, onUserMessage, onPermissionResponse, onInterrupt, onSetModel, onSetMaxThinkingTokens, onSetPermissionMode, onStateChange, outboundOnly, tags, }) }
// ── v1 path: env-based (register/poll/ack/heartbeat) ──────────────────
const versionError = checkBridgeMinVersion() if (versionError) { logBridgeSkip('version_too_old', `[bridge:repl] Skipping: ${versionError}`) onStateChange?.('failed', 'run `claude update` to upgrade') return null }
// Gather git context — this is the bootstrap-read boundary. // Everything from here down is passed explicitly to bridgeCore. const branch = await getBranch() const gitRepoUrl = await getRemoteUrl() const sessionIngressUrl = process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL ? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL : baseUrl
// Assistant-mode sessions advertise a distinct worker_type so the web UI // can filter them into a dedicated picker. KAIROS guard keeps the // assistant module out of external builds entirely. let workerType: BridgeWorkerType = 'claude_code' if (feature('KAIROS')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { isAssistantMode } = require('../assistant/index.js') as typeof import('../assistant/index.js') /* eslint-enable @typescript-eslint/no-require-imports */ if (isAssistantMode()) { workerType = 'claude_code_assistant' } }
// 6. Delegate. BridgeCoreHandle is a structural superset of // ReplBridgeHandle (adds writeSdkMessages which REPL callers don't use), // so no adapter needed — just the narrower type on the way out. return initBridgeCore({ dir: getOriginalCwd(), machineName: hostname(), branch, gitRepoUrl, title, baseUrl, sessionIngressUrl, workerType, getAccessToken: getBridgeAccessToken, createSession: opts => createBridgeSession({ ...opts, events: [], baseUrl, getAccessToken: getBridgeAccessToken, }), archiveSession: sessionId => archiveBridgeSession(sessionId, { baseUrl, getAccessToken: getBridgeAccessToken, // gracefulShutdown.ts:407 races runCleanupFunctions against 2s. // Teardown also does stopWork (parallel) + deregister (sequential), // so archive can't have the full budget. 1.5s matches v2's // teardown_archive_timeout_ms default. timeoutMs: 1500, }).catch((err: unknown) => { // archiveBridgeSession has no try/catch — 5xx/timeout/network throw // straight through. Previously swallowed silently, making archive // failures BQ-invisible and undiagnosable from debug logs. logForDebugging( `[bridge:repl] archiveBridgeSession threw: ${errorMessage(err)}`, { level: 'error' }, ) }), // getCurrentTitle is read on reconnect-after-env-lost to re-title the new // session. /rename writes to session storage; onUserMessage mutates // `title` directly — both paths are picked up here. getCurrentTitle: () => getCurrentSessionTitle(getSessionId()) ?? title, onUserMessage, toSDKMessages, onAuth401: handleOAuth401Error, getPollIntervalConfig, initialHistoryCap, initialMessages, previouslyFlushedUUIDs, onInboundMessage, onPermissionResponse, onInterrupt, onSetModel, onSetMaxThinkingTokens, onSetPermissionMode, onStateChange, perpetual, })}
const TITLE_MAX_LEN = 50
/** * Quick placeholder title: strip display tags, take the first sentence, * collapse whitespace, truncate to 50 chars. Returns undefined if the result * is empty (e.g. message was only <local-command-stdout>). Replaced by * generateSessionTitle once Haiku resolves (~1-15s). */function deriveTitle(raw: string): string | undefined { // Strip <ide_opened_file>, <session-start-hook>, etc. — these appear in // user messages when IDE/hooks inject context. stripDisplayTagsAllowEmpty // returns '' (not the original) so pure-tag messages are skipped. const clean = stripDisplayTagsAllowEmpty(raw) // First sentence is usually the intent; rest is often context/detail. // Capture group instead of lookbehind — keeps YARR JIT happy. const firstSentence = /^(.*?[.!?])\s/.exec(clean)?.[1] ?? clean // Collapse newlines/tabs — titles are single-line in the claude.ai list. const flat = firstSentence.replace(/\s+/g, ' ').trim() if (!flat) return undefined return flat.length > TITLE_MAX_LEN ? flat.slice(0, TITLE_MAX_LEN - 1) + '\u2026' : flat}