Bridge System: Bidirectional Communication Architecture Between CLI and IDE

A deep dive into Claude Code's Bridge system — bidirectional communication between CLI and IDE extensions, JWT authentication, session management, and capacity wake mechanisms

The Problem

Claude Code is both a standalone terminal tool and embeddable in VS Code and JetBrains. How does a single process serve two completely different interaction surfaces?

When you type the claude command in your terminal, a Node.js process starts, loads the REPL loop, and interacts with you via stdin/stdout. But when you click the Claude icon in VS Code's sidebar, or open a Remote Control session on claude.ai, the same CLI process is responding behind the scenes. This means:

  • A Claude Code instance running in the terminal needs to synchronize messages in real time with a remote Web UI
  • User actions on the Web UI (sending prompts, interrupting, switching models) need to be relayed to the local CLI process
  • Permission requests need to traverse processes and networks, then wait for a response
  • Session JWT tokens need automatic refresh to prevent long-running tasks from being interrupted by auth expiration
  • File attachments need to be downloaded from the web to the local machine for the CLI process's tools to use

At the heart of all this is the Bridge system — a complete bidirectional communication architecture that treats the CLI process as the "backend" and the IDE extension or Web UI as the "frontend," enabling real-time interaction through polling, WebSocket, and SSE.

Architecture Overview

The Bridge system's architecture can be summarized in one sentence: the CLI process registers as an Environment, polls for Work Items, and communicates bidirectionally with Session Ingress via WebSocket/SSE.

User Side
claude.ai Web UI
VS Code Extension
JetBrains Extension
Anthropic Backend
CCR Service
(Code Cloud Runtime)
Session Ingress
(WebSocket/SSE)
Environment API
(Polling/Heartbeat)
Local Machine
bridgeMain.ts
Standalone Bridge loop
replBridge.ts
REPL-embedded Bridge
CLI subprocess
(sessionRunner)

The system has two operating modes:

  1. Standalone Bridge mode (bridgeMain.ts): Launched by the claude remote-control command, it runs as a long-lived process that polls the server and forks a subprocess for each session. Supports concurrent multi-session and worktree isolation.
  2. REPL-embedded Bridge mode (replBridge.ts): Automatically connects during interactive REPL execution, exposing the current session to the Web UI, enabling scenarios like "coding in the terminal while monitoring on your phone."

bridgeMain.ts: The Standalone Bridge Main Loop

bridgeMain.ts is the core of standalone Bridge mode. The runBridgeLoop function implements a complete poll-dispatch-manage loop responsible for: environment registration, work polling, session spawning, heartbeat maintenance, and error recovery.

BackoffConfig and Backoff Strategy

In networked environments, transient failures are the norm. The Bridge system defines fine-grained backoff configuration:

TypeScript
1// src/bridge/bridgeMain.ts, lines 59-79
2export type BackoffConfig = {
3 connInitialMs: number
4 connCapMs: number
5 connGiveUpMs: number
6 generalInitialMs: number
7 generalCapMs: number
8 generalGiveUpMs: number
9 shutdownGraceMs?: number
10 stopWorkBaseDelayMs?: number
11}
12
13const DEFAULT_BACKOFF: BackoffConfig = {
14 connInitialMs: 2_000,
15 connCapMs: 120_000, // 2 minutes
16 connGiveUpMs: 600_000, // 10 minutes
17 generalInitialMs: 500,
18 generalCapMs: 30_000,
19 generalGiveUpMs: 600_000, // 10 minutes
20}

Backoff configuration is split into two categories: connection errors (conn*) and general errors (general*). Connection errors start at 2 seconds with a 2-minute cap and give up after 10 minutes; general errors start faster (500ms) with a 30-second cap. shutdownGraceMs controls the grace period from SIGTERM to SIGKILL (default 30 seconds), ensuring subprocesses have time to clean up.

Core Structure of the Main Loop

The runBridgeLoop function signature itself reveals the system's dependency injection design:

TypeScript
1// src/bridge/bridgeMain.ts, lines 141-152
2export async function runBridgeLoop(
3 config: BridgeConfig,
4 environmentId: string,
5 environmentSecret: string,
6 api: BridgeApiClient,
7 spawner: SessionSpawner,
8 logger: BridgeLogger,
9 signal: AbortSignal,
10 backoffConfig: BackoffConfig = DEFAULT_BACKOFF,
11 initialSessionId?: string,
12 getAccessToken?: () => string | undefined | Promise<string | undefined>,
13): Promise<void> {

The function maintains numerous state Maps internally, and these data structures together form the core of session management:

TypeScript
1// src/bridge/bridgeMain.ts, lines 163-194
2const activeSessions = new Map<string, SessionHandle>()
3const sessionStartTimes = new Map<string, number>()
4const sessionWorkIds = new Map<string, string>()
5const sessionCompatIds = new Map<string, string>()
6const sessionIngressTokens = new Map<string, string>()
7const sessionTimers = new Map<string, ReturnType<typeof setTimeout>>()
8const completedWorkIds = new Set<string>()
9const sessionWorktrees = new Map<string, {
10 worktreePath: string
11 worktreeBranch?: string
12 gitRoot?: string
13 hookBased?: boolean
14}>()
15const timedOutSessions = new Set<string>()
16const titledSessions = new Set<string>()

Worth noting here is the existence of sessionCompatIds. CCR v2's infrastructure layer uses cse_*-prefixed IDs, while the claude.ai frontend and compatibility API use session_*-prefixed IDs. Both map to the same underlying UUID but require different formats at different API endpoints. sessionCompatIds is computed once at spawn time and cached, ensuring cleanup and state updates always use a consistent key.

Work Polling and Dispatch

The core of the main loop is a while (!loopSignal.aborted) loop, where each iteration queries for new work items via pollForWork:

TypeScript
1// src/bridge/bridgeMain.ts, lines 600-612
2while (!loopSignal.aborted) {
3 const pollConfig = getPollIntervalConfig()
4
5 try {
6 const work = await api.pollForWork(
7 environmentId,
8 environmentSecret,
9 loopSignal,
10 pollConfig.reclaim_older_than_ms,
11 )
12 // ... process work result

When a session-type work item is received, the system needs to make a series of decisions:

...

For sessions that are already running, the system doesn't spawn a duplicate process. Instead, it passes the new access token to the existing subprocess — this is the critical path for JWT refresh. The server re-dispatches the work item before the JWT expires, carrying a new session_ingress_token, and Bridge injects the new token into the subprocess via existingHandle.updateAccessToken().

Heartbeat Mechanism

Heartbeats are critical for maintaining work leases. The heartbeatActiveWorkItems function iterates over all active sessions and sends heartbeats to the server:

TypeScript
1// src/bridge/bridgeMain.ts, lines 202-270
2async function heartbeatActiveWorkItems(): Promise<
3 'ok' | 'auth_failed' | 'fatal' | 'failed'
4> {
5 let anySuccess = false
6 let anyFatal = false
7 const authFailedSessions: string[] = []
8 for (const [sessionId] of activeSessions) {
9 const workId = sessionWorkIds.get(sessionId)
10 const ingressToken = sessionIngressTokens.get(sessionId)
11 if (!workId || !ingressToken) continue
12 try {
13 await api.heartbeatWork(environmentId, workId, ingressToken)
14 anySuccess = true
15 } catch (err) {
16 // ... error classification handling
17 if (err.status === 401 || err.status === 403) {
18 authFailedSessions.push(sessionId)
19 } else {
20 anyFatal = true // 404/410 = environment expired
21 }
22 }
23 }
24 // JWT expired -> trigger server re-dispatch
25 for (const sessionId of authFailedSessions) {
26 await api.reconnectSession(environmentId, sessionId)
27 }
28 // ...
29}

Heartbeats return four states: ok (at least one success), auth_failed (JWT expired, reconnection triggered), fatal (environment doesn't exist), failed (all failed). The main loop decides the next step based on the heartbeat result: auth_failed triggers re-polling to obtain a new token, fatal may lead to environment reconstruction.

Capacity Management and Poll Cadence

The Bridge's polling frequency isn't fixed — it dynamically adjusts based on current state. PollIntervalConfig defines intervals across multiple dimensions:

TypeScript
1// src/bridge/pollConfigDefaults.ts, lines 55-82
2export const DEFAULT_POLL_CONFIG: PollIntervalConfig = {
3 poll_interval_ms_not_at_capacity: 2000, // When idle: 2 seconds
4 poll_interval_ms_at_capacity: 600_000, // When full: 10 minutes
5 non_exclusive_heartbeat_interval_ms: 0, // Independent heartbeat interval (disabled by default)
6 multisession_poll_interval_ms_not_at_capacity: 2000,
7 multisession_poll_interval_ms_partial_capacity: 2000,
8 multisession_poll_interval_ms_at_capacity: 600_000,
9 reclaim_older_than_ms: 5000, // Reclaim unacknowledged work after timeout
10 session_keepalive_interval_v2_ms: 120_000, // SSE keep-alive
11}

These configurations are delivered in real time via GrowthBook, allowing the operations team to adjust global polling rates without releasing a new version. The pollConfig.ts code uses Zod schemas for strict configuration validation:

TypeScript
1// src/bridge/pollConfig.ts, lines 102-110
2export function getPollIntervalConfig(): PollIntervalConfig {
3 const raw = getFeatureValue_CACHED_WITH_REFRESH<unknown>(
4 'tengu_bridge_poll_interval_config',
5 DEFAULT_POLL_CONFIG,
6 5 * 60 * 1000, // 5-minute cache refresh
7 )
8 const parsed = pollIntervalConfigSchema().safeParse(raw)
9 return parsed.success ? parsed.data : DEFAULT_POLL_CONFIG
10}

When all session slots are occupied, Bridge enters "full-capacity heartbeat mode": it stops polling for new work and only sends heartbeats to maintain leases. Once a session ends and frees a slot, the capacity wake mechanism immediately interrupts the sleep, letting Bridge resume polling:

TypeScript
1// src/bridge/bridgeMain.ts, lines 650-687
2while (
3 !loopSignal.aborted &&
4 activeSessions.size >= config.maxSessions &&
5 (pollDeadline === null || Date.now() < pollDeadline)
6) {
7 const hbConfig = getPollIntervalConfig()
8 if (hbConfig.non_exclusive_heartbeat_interval_ms <= 0) break
9
10 const cap = capacityWake.signal()
11 hbResult = await heartbeatActiveWorkItems()
12 if (hbResult === 'auth_failed' || hbResult === 'fatal') {
13 cap.cleanup()
14 break
15 }
16 hbCycles++
17 await sleep(hbConfig.non_exclusive_heartbeat_interval_ms, cap.signal)
18 cap.cleanup()
19}

Capacity Wake Mechanism

Capacity wake (capacityWake) is an elegant signal-merging primitive defined in capacityWake.ts:

TypeScript
1// src/bridge/capacityWake.ts, lines 28-56
2export function createCapacityWake(outerSignal: AbortSignal): CapacityWake {
3 let wakeController = new AbortController()
4
5 function wake(): void {
6 wakeController.abort()
7 wakeController = new AbortController()
8 }
9
10 function signal(): CapacitySignal {
11 const merged = new AbortController()
12 const abort = (): void => merged.abort()
13 if (outerSignal.aborted || wakeController.signal.aborted) {
14 merged.abort()
15 return { signal: merged.signal, cleanup: () => {} }
16 }
17 outerSignal.addEventListener('abort', abort, { once: true })
18 const capSig = wakeController.signal
19 capSig.addEventListener('abort', abort, { once: true })
20 return {
21 signal: merged.signal,
22 cleanup: () => {
23 outerSignal.removeEventListener('abort', abort)
24 capSig.removeEventListener('abort', abort)
25 },
26 }
27 }
28
29 return { signal, wake }
30}

The design concept: before entering "full-capacity sleep," call signal() to get a merged signal. This signal fires in three scenarios:

  1. The outer loop is aborted (process shutdown)
  2. The capacity controller is aborted (wake() is called)
  3. The sleep timeout expires naturally

When a session ends, the onSessionDone callback calls capacityWake.wake(), immediately waking the main loop waiting in sleep(interval, cap.signal). wake() not only aborts the current controller but also creates a new one — ensuring the next loop iteration can sleep again. The cleanup() function removes event listeners, preventing listener accumulation on AbortSignal objects.

replBridge.ts and bridgeMain.ts use the exact same createCapacityWake primitive, eliminating the maintenance burden of two previously duplicated codebases.

bridgeMessaging.ts: The Message Protocol Layer

The message protocol layer is the Bridge system's "translator," responsible for: type determination, inbound routing, echo cancellation, and control request handling.

Type Guards and Message Filtering

The system defines strict type guards to distinguish different message types:

TypeScript
1// src/bridge/bridgeMessaging.ts, lines 36-70
2export function isSDKMessage(value: unknown): value is SDKMessage {
3 return (
4 value !== null &&
5 typeof value === 'object' &&
6 'type' in value &&
7 typeof value.type === 'string'
8 )
9}
10
11export function isSDKControlResponse(
12 value: unknown,
13): value is SDKControlResponse {
14 return (
15 value !== null &&
16 typeof value === 'object' &&
17 'type' in value &&
18 value.type === 'control_response' &&
19 'response' in value
20 )
21}
22
23export function isSDKControlRequest(
24 value: unknown,
25): value is SDKControlRequest {
26 return (
27 value !== null &&
28 typeof value === 'object' &&
29 'type' in value &&
30 value.type === 'control_request' &&
31 'request_id' in value &&
32 'request' in value
33 )
34}

Not all REPL internal messages should be sent to Bridge. isEligibleBridgeMessage performs precise filtering:

TypeScript
1// src/bridge/bridgeMessaging.ts, lines 77-88
2export function isEligibleBridgeMessage(m: Message): boolean {
3 if ((m.type === 'user' || m.type === 'assistant') && m.isVirtual) {
4 return false
5 }
6 return (
7 m.type === 'user' ||
8 m.type === 'assistant' ||
9 (m.type === 'system' && m.subtype === 'local_command')
10 )
11}

Virtual messages (generated by internal REPL calls) are not sent — Bridge/SDK consumers see the summarized tool_use/result, not the intermediate process.

Inbound Message Routing and Echo Cancellation

handleIngressMessage is the single entry point for inbound messages, implementing a critical echo cancellation mechanism:

TypeScript
1// src/bridge/bridgeMessaging.ts, lines 132-208
2export function handleIngressMessage(
3 data: string,
4 recentPostedUUIDs: BoundedUUIDSet,
5 recentInboundUUIDs: BoundedUUIDSet,
6 onInboundMessage: ((msg: SDKMessage) => void | Promise<void>) | undefined,
7 onPermissionResponse?: ((response: SDKControlResponse) => void) | undefined,
8 onControlRequest?: ((request: SDKControlRequest) => void) | undefined,
9): void {
10 try {
11 const parsed: unknown = normalizeControlMessageKeys(jsonParse(data))
12
13 // control_response is not an SDKMessage, needs to be checked first
14 if (isSDKControlResponse(parsed)) {
15 onPermissionResponse?.(parsed)
16 return
17 }
18
19 if (isSDKControlRequest(parsed)) {
20 onControlRequest?.(parsed)
21 return
22 }
23
24 if (!isSDKMessage(parsed)) return
25
26 const uuid = 'uuid' in parsed && typeof parsed.uuid === 'string'
27 ? parsed.uuid : undefined
28
29 // Echo filtering: ignore messages we sent that bounced back
30 if (uuid && recentPostedUUIDs.has(uuid)) return
31
32 // Duplicate delivery filtering: ignore already-processed inbound messages
33 if (uuid && recentInboundUUIDs.has(uuid)) return
34
35 if (parsed.type === 'user') {
36 if (uuid) recentInboundUUIDs.add(uuid)
37 void onInboundMessage?.(parsed)
38 }
39 } catch (err) {
40 // Parse failures are silently ignored
41 }
42}

Why is echo cancellation needed? Because WebSocket is bidirectional — messages sent by the CLI may be broadcast back by the server. Without echo cancellation, a user message could be executed twice by the CLI. The system uses two independent BoundedUUIDSet instances: recentPostedUUIDs filters out self-sent messages, and recentInboundUUIDs filters duplicate inbound deliveries.

BoundedUUIDSet: Ring Buffer

The echo cancellation data structure isn't a simple Set<string> — it's a capacity-limited ring buffer:

TypeScript
1// src/bridge/bridgeMessaging.ts, lines 429-461
2export class BoundedUUIDSet {
3 private readonly capacity: number
4 private readonly ring: (string | undefined)[]
5 private readonly set = new Set<string>()
6 private writeIdx = 0
7
8 constructor(capacity: number) {
9 this.capacity = capacity
10 this.ring = new Array<string | undefined>(capacity)
11 }
12
13 add(uuid: string): void {
14 if (this.set.has(uuid)) return
15 const evicted = this.ring[this.writeIdx]
16 if (evicted !== undefined) {
17 this.set.delete(evicted)
18 }
19 this.ring[this.writeIdx] = uuid
20 this.set.add(uuid)
21 this.writeIdx = (this.writeIdx + 1) % this.capacity
22 }
23
24 has(uuid: string): boolean {
25 return this.set.has(uuid)
26 }
27}

This design guarantees constant memory usage at O(capacity). Messages are added in chronological order, and the oldest entries are always the ones evicted. The default capacity of 2000 far exceeds the actual echo window (echoes typically arrive within milliseconds).

Server Control Request Handling

The server can send control requests to the CLI (initialize, switch model, interrupt, set permission mode), and the CLI must respond within 10-14 seconds, or the server will disconnect the WebSocket:

TypeScript
1// src/bridge/bridgeMessaging.ts, lines 243-391
2export function handleServerControlRequest(
3 request: SDKControlRequest,
4 handlers: ServerControlRequestHandlers,
5): void {
6 const { transport, sessionId, outboundOnly } = handlers
7 if (!transport) return
8
9 // Outbound-only mode: reject all mutable requests (but initialize must succeed)
10 if (outboundOnly && request.request.subtype !== 'initialize') {
11 // Return error response rather than a fake success
12 response = { type: 'control_response', response: {
13 subtype: 'error', request_id: request.request_id,
14 error: 'This session is outbound-only...'
15 }}
16 void transport.write(event)
17 return
18 }
19
20 switch (request.request.subtype) {
21 case 'initialize':
22 // Return minimal capability set — the REPL handles commands, models, and account info itself
23 response = { type: 'control_response', response: {
24 subtype: 'success', request_id: request.request_id,
25 response: { commands: [], models: [], account: {}, pid: process.pid }
26 }}
27 break
28 case 'set_model':
29 onSetModel?.(request.request.model)
30 // ... return success
31 break
32 case 'interrupt':
33 onInterrupt?.()
34 // ... return success
35 break
36 case 'set_permission_mode':
37 // Permission mode switching requires policy checks
38 const verdict = onSetPermissionMode?.(request.request.mode)
39 // Return success or error based on verdict
40 break
41 default:
42 // Unknown types must also be responded to, otherwise the server hangs
43 response = { type: 'control_response', response: {
44 subtype: 'error', request_id: request.request_id,
45 error: `REPL bridge does not handle: ${request.request.subtype}`
46 }}
47 }
48
49 void transport.write(event)
50}

Several notable design aspects in this code:

  1. Outbound-only mode: When Bridge is only mirroring output (not accepting remote control), all mutable requests return errors — but initialize still returns success, because the server disconnects immediately when initialize fails.
  2. Permission mode security boundary: set_permission_mode doesn't directly call transitionPermissionMode. Instead, it delegates the decision to the caller via a callback. This is because auto mode and bypassPermissions mode require additional security checks, and these checks' dependencies cannot be imported into the Bridge module (startup isolation constraint).

JWT Authentication System

Bridge authentication is based on short-lived JWTs (JSON Web Tokens). Each session's session_ingress_token has an expiration time, and the system needs to proactively refresh before expiration.

Token Decoding

jwtUtils.ts provides JWT decoding without signature verification — Bridge only needs to read the exp field to schedule refreshes; signature verification is done server-side:

TypeScript
1// src/bridge/jwtUtils.ts, lines 21-49
2export function decodeJwtPayload(token: string): unknown | null {
3 // Strip sk-ant-si- prefix (unique to Session Ingress tokens)
4 const jwt = token.startsWith('sk-ant-si-')
5 ? token.slice('sk-ant-si-'.length)
6 : token
7 const parts = jwt.split('.')
8 if (parts.length !== 3 || !parts[1]) return null
9 try {
10 return jsonParse(Buffer.from(parts[1], 'base64url').toString('utf8'))
11 } catch {
12 return null
13 }
14}
15
16export function decodeJwtExpiry(token: string): number | null {
17 const payload = decodeJwtPayload(token)
18 if (payload !== null && typeof payload === 'object'
19 && 'exp' in payload && typeof payload.exp === 'number') {
20 return payload.exp
21 }
22 return null
23}

createTokenRefreshScheduler: The Refresh Scheduler

The refresh scheduler is the core of the entire authentication system. It's a factory function that returns four methods: schedule, scheduleFromExpiresIn, cancel, and cancelAll:

TypeScript
1// src/bridge/jwtUtils.ts, lines 72-256
2export function createTokenRefreshScheduler({
3 getAccessToken,
4 onRefresh,
5 label,
6 refreshBufferMs = TOKEN_REFRESH_BUFFER_MS, // Default 5 minutes
7}: {
8 getAccessToken: () => string | undefined | Promise<string | undefined>
9 onRefresh: (sessionId: string, oauthToken: string) => void
10 label: string
11 refreshBufferMs?: number
12}) {
13 const timers = new Map<string, ReturnType<typeof setTimeout>>()
14 const failureCounts = new Map<string, number>()
15 const generations = new Map<string, number>()

The generation counter is an elegant concurrency control mechanism. Each call to schedule() or cancel() increments the generation number. The async doRefresh() checks whether the generation number has changed after await getAccessToken() returns:

TypeScript
1// src/bridge/jwtUtils.ts, lines 165-230
2async function doRefresh(sessionId: string, gen: number): Promise<void> {
3 let oauthToken: string | undefined
4 try {
5 oauthToken = await getAccessToken()
6 } catch (err) { /* ... */ }
7
8 // If the session was cancelled or rescheduled during the await, the generation changes
9 if (generations.get(sessionId) !== gen) {
10 logForDebugging(`... stale (gen ${gen} vs ${generations.get(sessionId)})`)
11 return // Abandon to avoid orphaned timers
12 }
13
14 if (!oauthToken) {
15 const failures = (failureCounts.get(sessionId) ?? 0) + 1
16 failureCounts.set(sessionId, failures)
17 // Retry up to 3 times, 60 seconds apart
18 if (failures < MAX_REFRESH_FAILURES) {
19 const retryTimer = setTimeout(doRefresh, REFRESH_RETRY_DELAY_MS,
20 sessionId, gen)
21 timers.set(sessionId, retryTimer)
22 }
23 return
24 }
25
26 failureCounts.delete(sessionId)
27 onRefresh(sessionId, oauthToken)
28
29 // Schedule the next refresh, ensuring long-lived sessions maintain authentication
30 const timer = setTimeout(doRefresh, FALLBACK_REFRESH_INTERVAL_MS, // 30 minutes
31 sessionId, gen)
32 timers.set(sessionId, timer)
33}

Core parameters of the refresh strategy:

ConstantValuePurpose
TOKEN_REFRESH_BUFFER_MS5 minutesHow long before JWT expiry to trigger a refresh
FALLBACK_REFRESH_INTERVAL_MS30 minutesFollow-up refresh interval after a successful refresh
MAX_REFRESH_FAILURES3Maximum consecutive failure count
REFRESH_RETRY_DELAY_MS60 secondsRetry interval after failure

In bridgeMain.ts, the refresh scheduler selects different strategies based on session type:

TypeScript
1// src/bridge/bridgeMain.ts, lines 284-313
2const tokenRefresh = getAccessToken
3 ? createTokenRefreshScheduler({
4 getAccessToken,
5 onRefresh: (sessionId, oauthToken) => {
6 const handle = activeSessions.get(sessionId)
7 if (!handle) return
8 if (v2Sessions.has(sessionId)) {
9 // v2 sessions: can't pass OAuth token directly, trigger re-dispatch via reconnectSession
10 void api.reconnectSession(environmentId, sessionId)
11 .catch(/* ... */)
12 } else {
13 // v1 sessions: pass OAuth token directly
14 handle.updateAccessToken(oauthToken)
15 }
16 },
17 label: 'bridge',
18 })
19 : null

The difference between v1 and v2 sessions is critical: v2 uses CCR's worker endpoints, which validate the session_id claim in the JWT — a generic OAuth token doesn't contain this claim, so v2 sessions need to go through reconnectSession to have the server re-dispatch with a new JWT.

bridgePermissionCallbacks.ts: Cross-Process Permission Callback Relay

In Bridge mode, permission decisions may happen remotely (the user clicks "Allow" or "Deny" in the claude.ai Web UI). The permission callback system defines the cross-process relay interface:

TypeScript
1// src/bridge/bridgePermissionCallbacks.ts, lines 1-43
2type BridgePermissionResponse = {
3 behavior: 'allow' | 'deny'
4 updatedInput?: Record<string, unknown>
5 updatedPermissions?: PermissionUpdate[]
6 message?: string
7}
8
9type BridgePermissionCallbacks = {
10 sendRequest(
11 requestId: string,
12 toolName: string,
13 input: Record<string, unknown>,
14 toolUseId: string,
15 description: string,
16 permissionSuggestions?: PermissionUpdate[],
17 blockedPath?: string,
18 ): void
19 sendResponse(requestId: string, response: BridgePermissionResponse): void
20 cancelRequest(requestId: string): void
21 onResponse(
22 requestId: string,
23 handler: (response: BridgePermissionResponse) => void,
24 ): () => void // Returns an unsubscribe function
25}

The design of this interface reflects several important considerations:

  1. Request-response pattern: Each permission request has a unique requestId, sent via sendRequest and subscribed via onResponse. cancelRequest notifies the Web UI to withdraw the prompt when the CLI side cancels.

  2. Modifiable input: updatedInput allows users to modify tool parameters during authorization (e.g., changing a file path), and updatedPermissions allows users to add persistent rules while authorizing ("always allow reading this directory").

  3. Type-safe validation: The isBridgePermissionResponse type guard avoids the risks of as type assertions:

TypeScript
1// src/bridge/bridgePermissionCallbacks.ts, lines 32-41
2function isBridgePermissionResponse(
3 value: unknown,
4): value is BridgePermissionResponse {
5 if (!value || typeof value !== 'object') return false
6 return (
7 'behavior' in value &&
8 (value.behavior === 'allow' || value.behavior === 'deny')
9 )
10}

In standalone Bridge mode, sessionRunner.ts captures control_request (subtype: 'can_use_tool') from the subprocess's stdout, forwards it to the server, waits for the user's decision on the Web UI, then relays the response back through the subprocess's stdin.

sessionRunner.ts: Session Lifecycle

sessionRunner.ts handles the complete subprocess lifecycle: spawn, monitor, communicate, and clean up.

Subprocess Spawning

TypeScript
1// src/bridge/sessionRunner.ts, lines 248-340
2export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
3 return {
4 spawn(opts: SessionSpawnOpts, dir: string): SessionHandle {
5 const args = [
6 ...deps.scriptArgs,
7 '--print',
8 '--sdk-url', opts.sdkUrl,
9 '--session-id', opts.sessionId,
10 '--input-format', 'stream-json',
11 '--output-format', 'stream-json',
12 '--replay-user-messages',
13 ...(deps.verbose ? ['--verbose'] : []),
14 ...(debugFile ? ['--debug-file', debugFile] : []),
15 ...(deps.permissionMode
16 ? ['--permission-mode', deps.permissionMode] : []),
17 ]
18
19 const env: NodeJS.ProcessEnv = {
20 ...deps.env,
21 CLAUDE_CODE_OAUTH_TOKEN: undefined, // Subprocess uses session token
22 CLAUDE_CODE_ENVIRONMENT_KIND: 'bridge',
23 ...(deps.sandbox && { CLAUDE_CODE_FORCE_SANDBOX: '1' }),
24 CLAUDE_CODE_SESSION_ACCESS_TOKEN: opts.accessToken,
25 ...(opts.useCcrV2 && {
26 CLAUDE_CODE_USE_CCR_V2: '1',
27 CLAUDE_CODE_WORKER_EPOCH: String(opts.workerEpoch),
28 }),
29 }
30
31 const child: ChildProcess = spawn(deps.execPath, args, {
32 cwd: dir,
33 stdio: ['pipe', 'pipe', 'pipe'],
34 env,
35 windowsHide: true,
36 })

Several key decisions:

  • CLAUDE_CODE_OAUTH_TOKEN: undefined explicitly clears the parent process's OAuth token, ensuring the subprocess uses CLAUDE_CODE_SESSION_ACCESS_TOKEN.
  • --input-format stream-json and --output-format stream-json make the subprocess communicate in NDJSON format, one JSON object per line.
  • --replay-user-messages has the subprocess replay user messages, used for extracting the first message text (for title derivation).
  • All three stdio channels use 'pipe' mode: stdin for control instructions (token refresh, permission responses), stdout for NDJSON parsing, stderr for error diagnostics.

Activity Tracking

Every line of subprocess stdout output is parsed as an activity event:

TypeScript
1// src/bridge/sessionRunner.ts, lines 107-200
2function extractActivities(
3 line: string, sessionId: string, onDebug: (msg: string) => void,
4): SessionActivity[] {
5 let parsed: unknown
6 try { parsed = jsonParse(line) } catch { return [] }
7
8 const msg = parsed as Record<string, unknown>
9 const activities: SessionActivity[] = []
10
11 switch (msg.type) {
12 case 'assistant': {
13 const content = (msg.message as any)?.content
14 if (!Array.isArray(content)) break
15 for (const block of content) {
16 if (block.type === 'tool_use') {
17 const summary = toolSummary(block.name, block.input ?? {})
18 activities.push({ type: 'tool_start', summary, timestamp: Date.now() })
19 } else if (block.type === 'text' && block.text?.length > 0) {
20 activities.push({ type: 'text', summary: block.text.slice(0, 80),
21 timestamp: Date.now() })
22 }
23 }
24 break
25 }
26 case 'result':
27 // Record completion or error
28 break
29 }
30 return activities
31}

A tool name-to-verb mapping table makes status displays more user-friendly:

TypeScript
1// src/bridge/sessionRunner.ts, lines 70-89
2const TOOL_VERBS: Record<string, string> = {
3 Read: 'Reading',
4 Write: 'Writing',
5 Edit: 'Editing',
6 Bash: 'Running',
7 Glob: 'Searching',
8 Grep: 'Searching',
9 WebFetch: 'Fetching',
10 // ...
11}

Hot Token Update

The subprocess's updateAccessToken method injects a new token via stdin without restarting the subprocess:

TypeScript
1// src/bridge/sessionRunner.ts, lines 527-543
2updateAccessToken(token: string): void {
3 handle.accessToken = token
4 handle.writeStdin(
5 jsonStringify({
6 type: 'update_environment_variables',
7 variables: { CLAUDE_CODE_SESSION_ACCESS_TOKEN: token },
8 }) + '\n',
9 )
10}

When the subprocess's StructuredIO handler receives an update_environment_variables message, it directly modifies process.env, so the next call to getSessionIngressAuthToken() automatically picks up the new token.

replBridge.ts: Exposing REPL Sessions via Bridge

Unlike the standalone Bridge, the REPL-embedded Bridge doesn't spawn subprocesses — it connects directly to Session Ingress, bidirectionally syncing the current REPL's message stream to the Web UI.

initBridgeCore: The Startup Flow

initBridgeCore is the core initialization function for the REPL Bridge. It receives all external dependencies via dependency injection:

TypeScript
1// src/bridge/replBridge.ts, lines 260-296
2export async function initBridgeCore(
3 params: BridgeCoreParams,
4): Promise<BridgeCoreHandle | null> {
5 const {
6 dir, machineName, branch, gitRepoUrl, title,
7 baseUrl, sessionIngressUrl, workerType,
8 getAccessToken, createSession, archiveSession,
9 toSDKMessages, onAuth401,
10 getPollIntervalConfig, initialHistoryCap,
11 initialMessages, previouslyFlushedUUIDs,
12 onInboundMessage, onPermissionResponse,
13 onInterrupt, onSetModel, onSetPermissionMode,
14 onStateChange, onUserMessage,
15 perpetual, initialSSESequenceNum,
16 } = params

The initialization flow involves multiple steps:

  1. Check crash recovery pointer: Read the bridgePointer file; if a prior session state exists and we're in perpetual mode, attempt recovery.
  2. Register environment: Call registerBridgeEnvironment to register on the server.
  3. Create or recover session: Perpetual mode attempts reconnectSession; otherwise creates a new session.
  4. Write crash recovery pointer: Save current state for recovery after kill -9.
  5. Start poll loop: Wait for the server to dispatch work items (user sends a message in the Web UI).

Environment Reconstruction Strategy

When polling returns 404 (environment reclaimed by the server), the system initiates an environment reconstruction flow, attempting two strategies:

TypeScript
1// src/bridge/replBridge.ts, lines 605-800
2async function doReconnect(): Promise<boolean> {
3 environmentRecreations++
4 v2Generation++ // Invalidate any in-flight v2 handshakes
5
6 // Strategy 1: In-place reconnection
7 // Re-register with the original environmentId; if the server returns the same ID,
8 // reconnectSession re-queues the existing session
9 bridgeConfig.reuseEnvironmentId = requestedEnvId
10 const reg = await api.registerBridgeEnvironment(bridgeConfig)
11 environmentId = reg.environment_id
12
13 if (await tryReconnectInPlace(requestedEnvId, currentSessionId)) {
14 return true // Session URL unchanged, seamless for the user
15 }
16
17 // Strategy 2: Brand new session
18 // Archive the old session, create a new one on the newly registered environment
19 await archiveSession(currentSessionId)
20 const newSessionId = await createSession({ environmentId, ... })
21 currentSessionId = newSessionId
22 // Reset SSE sequence number — new session's event stream starts at 1
23 lastTransportSequenceNum = 0
24 return true
25}

Strategy 1 is "seamless recovery" — the URL the user sees on their phone doesn't change, session state (including previouslyFlushedUUIDs) is preserved, and history messages won't be resent. Strategy 2 is "degraded recovery" — the old session is archived, a new one is created, but context may be lost.

Concurrent reconstruction is guarded by a promise:

TypeScript
1// src/bridge/replBridge.ts, lines 605-615
2async function reconnectEnvironmentWithSession(): Promise<boolean> {
3 if (reconnectPromise) {
4 return reconnectPromise // Share the same reconnection attempt
5 }
6 reconnectPromise = doReconnect()
7 try {
8 return await reconnectPromise
9 } finally {
10 reconnectPromise = null
11 }
12}

Bidirectional Message Sync

The REPL Bridge message flow is illustrated below:

sequenceDiagram
    participant User as User (Terminal/Web)
    participant REPL as REPL Loop
    participant Bridge as replBridge
    participant Transport as WebSocket/SSE
    participant Server as Session Ingress

    Note over User,Server: Outbound path: REPL -> Web UI
    REPL->>Bridge: writeMessages(messages)
    Bridge->>Bridge: Filter isEligibleBridgeMessage
    Bridge->>Bridge: Convert toSDKMessages
    Bridge->>Bridge: Filter previouslyFlushedUUIDs
    Bridge->>Transport: transport.write(sdkMsg)
    Transport->>Server: WebSocket/SSE POST

    Note over User,Server: Inbound path: Web UI -> REPL
    Server->>Transport: WebSocket message
    Transport->>Bridge: handleIngressMessage
    Bridge->>Bridge: Echo filter (BoundedUUIDSet)
    Bridge->>Bridge: Type check + routing
    Bridge->>REPL: onInboundMessage(sdkMsg)
    REPL->>REPL: Execute user request

    Note over User,Server: Control path
    Server->>Transport: control_request (interrupt/set_model)
    Transport->>Bridge: handleServerControlRequest
    Bridge->>REPL: onInterrupt() / onSetModel()
    Bridge->>Transport: control_response

Inbound Attachments: Files Flowing from Web to CLI

When a user uploads files or images on claude.ai, those attachments need to be delivered to the local CLI process. inboundAttachments.ts implements this flow:

TypeScript
1// src/bridge/inboundAttachments.ts, lines 68-117
2async function resolveOne(att: InboundAttachment): Promise<string | undefined> {
3 const token = getBridgeAccessToken()
4 if (!token) return undefined
5
6 let data: Buffer
7 try {
8 const url = `${getBridgeBaseUrl()}/api/oauth/files/` +
9 `${encodeURIComponent(att.file_uuid)}/content`
10 const response = await axios.get(url, {
11 headers: { Authorization: `Bearer ${token}` },
12 responseType: 'arraybuffer',
13 timeout: DOWNLOAD_TIMEOUT_MS, // 30 seconds
14 validateStatus: () => true,
15 })
16 if (response.status !== 200) return undefined
17 data = Buffer.from(response.data)
18 } catch (e) {
19 return undefined
20 }
21
22 // UUID prefix ensures no filename collisions
23 const safeName = sanitizeFileName(att.file_name)
24 const prefix = (att.file_uuid.slice(0, 8) || randomUUID().slice(0, 8))
25 .replace(/[^a-zA-Z0-9_-]/g, '_')
26 const dir = uploadsDir() // ~/.claude/uploads/{sessionId}/
27 const outPath = join(dir, `${prefix}-${safeName}`)
28
29 await mkdir(dir, { recursive: true })
30 await writeFile(outPath, data)
31 return outPath
32}

The flow is:

  1. The Web composer uploads the file to the Anthropic server, obtaining a file_uuid
  2. The user message carries a file_attachments array
  3. The CLI receives the inbound message and downloads the file from /api/oauth/files/{uuid}/content using the OAuth token
  4. The file is written to the ~/.claude/uploads/{sessionId}/ directory
  5. An @"filepath" reference prefix is added to the message text

prependPathRefs specifically handles multi-block content — references are added to the last text block, because processUserInputBase reads inputString from the end of processedBlocks:

TypeScript
1// src/bridge/inboundAttachments.ts, lines 142-161
2export function prependPathRefs(
3 content: string | Array<ContentBlockParam>,
4 prefix: string,
5): string | Array<ContentBlockParam> {
6 if (!prefix) return content
7 if (typeof content === 'string') return prefix + content
8 // Find the last text block
9 const i = content.findLastIndex(b => b.type === 'text')
10 if (i !== -1) {
11 const b = content[i]!
12 if (b.type === 'text') {
13 return [
14 ...content.slice(0, i),
15 { ...b, text: prefix + b.text },
16 ...content.slice(i + 1),
17 ]
18 }
19 }
20 return [...content, { type: 'text', text: prefix.trimEnd() }]
21}

This is an elegant fault-tolerant design — filenames use sanitizeFileName to prevent path traversal attacks, paths are wrapped in quotes to prevent parsing errors from spaces (@"path" rather than @path), and all network and IO operations are best-effort, with failures only skipping the attachment rather than interrupting message processing.

BRIDGE_MODE Feature Flag

The Bridge system's entry point is controlled by a compile-time feature flag. bridgeEnabled.ts defines multi-layered gating:

TypeScript
1// src/bridge/bridgeEnabled.ts, lines 28-36
2export function isBridgeEnabled(): boolean {
3 return feature('BRIDGE_MODE')
4 ? isClaudeAISubscriber() &&
5 getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_bridge', false)
6 : false
7}

Here feature('BRIDGE_MODE') is a compile-time constant — in external builds, the true branch of the entire ternary expression is removed by dead-code elimination, including the GrowthBook string literals. This ensures:

  • External builds: Bridge code is completely absent
  • Internal builds: Two runtime conditions must be met
    • isClaudeAISubscriber() — excludes Bedrock/Vertex/Foundry and API key users
    • GrowthBook tengu_ccr_bridge gate — gradual rollout

The blocking version isBridgeEnabledBlocking is used for entry-point gating (the claude remote-control command), while the non-blocking version is used for UI rendering (whether the sidebar shows the Remote Control button).

More advanced gating also includes version checks:

TypeScript
1// src/bridge/bridgeEnabled.ts, lines 160-173
2export function checkBridgeMinVersion(): string | null {
3 if (feature('BRIDGE_MODE')) {
4 const config = getDynamicConfig_CACHED_MAY_BE_STALE<{
5 minVersion: string
6 }>('tengu_bridge_min_version', { minVersion: '0.0.0' })
7 if (config.minVersion && lt(MACRO.VERSION, config.minVersion)) {
8 return `Your version of Claude Code (${MACRO.VERSION}) is too old...`
9 }
10 }
11 return null
12}

This allows the operations team to force users to update without releasing a new version — simply raise tengu_bridge_min_version in GrowthBook.

Perpetual Mode

In normal mode, Bridge sessions end when the process exits. Perpetual mode allows sessions to survive across processes — after the CLI exits and restarts, it can resume the same Web session.

The implementation relies on the bridgePointer — a state pointer written to disk:

TypeScript
1// src/bridge/replBridge.ts, lines 302-312
2// Perpetual mode: read crash recovery pointer
3const rawPrior = perpetual ? await readBridgePointer(dir) : null
4const prior = rawPrior?.source === 'repl' ? rawPrior : null

The pointer contains sessionId, environmentId, and source (distinguishing between REPL and standalone). When the CLI restarts:

  1. Read the pointer file
  2. Register the environment with reuseEnvironmentId (idempotent operation)
  3. If registration returns the same environmentId, call reconnectSession to re-queue
  4. If the environment has expired (returns a different ID), degrade to new session creation

A critical detail: when recovering sessions in perpetual mode, initialMessages are marked as already sent (added to previouslyFlushedUUIDs) to prevent duplicate messages from causing the server to disconnect the WebSocket. At the same time, lastTransportSequenceNum is restored from the previously saved value, letting the SSE connection resume from the breakpoint rather than replaying the full history.

SpawnMode: Multi-Session Management Strategy

The standalone Bridge supports three spawn modes, controlled by BridgeConfig.spawnMode:

TypeScript
1// src/bridge/types.ts, lines 64-69
2export type SpawnMode = 'single-session' | 'worktree' | 'same-dir'
ModeBehaviorUse Case
single-sessionBridge exits after the session endsDefault behavior, claude remote-control
worktreeCreates an independent git worktree for each sessionConcurrent multi-session with isolation
same-dirAll sessions share the working directoryMulti-session without file isolation needed

worktree mode calls createAgentWorktree at spawn time:

TypeScript
1// src/bridge/bridgeMain.ts, lines 977-993
2if (spawnModeAtDecision === 'worktree' &&
3 (initialSessionId === undefined ||
4 !sameSessionId(sessionId, initialSessionId))) {
5 const wt = await createAgentWorktree(
6 `bridge-${safeFilenameId(sessionId)}`)
7 sessionWorktrees.set(sessionId, {
8 worktreePath: wt.worktreePath,
9 worktreeBranch: wt.worktreeBranch,
10 gitRoot: wt.gitRoot,
11 hookBased: wt.hookBased,
12 })
13 sessionDir = wt.worktreePath
14}

After a session ends, onSessionDone automatically cleans up the worktree:

TypeScript
1// src/bridge/bridgeMain.ts, lines 537-551
2const wt = sessionWorktrees.get(sessionId)
3if (wt) {
4 sessionWorktrees.delete(sessionId)
5 trackCleanup(
6 removeAgentWorktree(
7 wt.worktreePath, wt.worktreeBranch, wt.gitRoot, wt.hookBased
8 ).catch((err) =>
9 logger.logVerbose(`Failed to remove worktree: ${errorMessage(err)}`)
10 ),
11 )
12}

Note the use of trackCleanup — all cleanup operation Promises are tracked, and the shutdown sequence awaits their completion before calling process.exit(), preventing orphaned worktrees from being left behind.

System Sleep Detection

Long-running Bridge instances need to handle system sleep/wake events. When a laptop lid is closed, setTimeout and setInterval timers pause, and waking up may trigger a flood of backlogged callbacks. Bridge uses a simple yet effective detection method:

TypeScript
1// src/bridge/bridgeMain.ts, lines 107-109
2function pollSleepDetectionThresholdMs(backoff: BackoffConfig): number {
3 return backoff.connCapMs * 2 // 2x connection backoff cap
4}

If the actual interval between two polls exceeds connCapMs * 2 (default 4 minutes), the system determines that a sleep occurred. At that point, error counters are reset — because the preceding timeouts weren't real network errors but artifacts of the system sleeping.

Design Summary

The Bridge system's architecture embodies several core design principles:

1. Dependency injection and startup isolation. initBridgeCore doesn't import commands.ts, config.ts, or any React components. All these dependencies are passed in as parameters. This allows the Agent SDK and Daemon to reuse the core logic without pulling in the REPL's full dependency tree (~1300 modules).

2. Best-effort degradation. Attachment download failures don't block message processing. Environment reconstruction failures don't crash the process. Token refresh failures have bounded retries. Every operation has a clear degradation path.

3. Idempotency and deduplication. Environment registration is idempotent (reuseEnvironmentId). Message sending is deduplicated via BoundedUUIDSet. Work acknowledgment (ack) failures don't lose work — the server will redeliver. completedWorkIds prevents already-completed work from being reprocessed.

4. Compile-time elimination. The ternary expression pattern with feature('BRIDGE_MODE') ensures external builds contain no Bridge code — not even the string literals of GrowthBook flags.