claude-code.sharosoo.com
레슨 20 · 전송 아키텍처

브리지와 리모트 컨트롤

Claude Code가 로컬 CLI 세션을 양방향 클라우드 연결 환경으로 바꾸는 방식을 살펴봅니다. 2단계 Environments API부터 env-less CCR v2 경로, SSE/WebSocket 하이브리드, 권한 브리징, 그리고 claude remote-control 명령까지 다룹니다.

아키텍처 개요

Remote Control은 로컬 Claude Code REPL, 또는 헤드리스 브리지 서버를 claude.ai 웹 프런트엔드에 연결합니다. 이 연결은 뚜렷하게 두 방향으로 나뉩니다.

  • Outbound (로컬 → 클라우드): Claude의 메시지, 도구 활동, 결과 이벤트가 CCR (Cloud Code Runner) 세션으로 스트리밍되어 claude.ai가 이를 실시간으로 렌더링할 수 있게 합니다.
  • Inbound (클라우드 → 로컬): claude.ai에서 입력한 사용자 프롬프트, 권한 승인 또는 거부 결정, 인터럽트 신호, 제어 메시지가 실행 중인 Claude 프로세스로 되돌아옵니다.
┌──────────────────────────────┐ │ Claude Code REPL / 브리지 │ └────────────┬─────────────────┘ │ OAuth / JWT 인증 ┌──────────────────────────────┐ │ Environments API (v1 전용) │ ← env 등록, 작업 폴링, heartbeat └────────────┬─────────────────┘ │ WorkSecret (base64url JWT) ┌──────────────────────────────┐ │ Session-Ingress / CCR 계층 │ ← session_id, session_ingress_token │ ┌─────────┐ ┌────────────┐ │ │ │ SSE 입력│ │ HTTP POST │ │ ← v2: SSE 읽기 + CCRClient 쓰기 │ │ WS 입출력│ │ 출력(v1) │ │ ← v1: HybridTransport WS+POST │ └─────────┘ └────────────┘ │ └──────────────────────────────┘ │ WS 구독 ┌──────────────────────────────┐ │ claude.ai 웹 앱 │ /v1/sessions/ws/{id}/subscribe └──────────────────────────────┘

인증 경계는 매우 중요합니다. 모든 브리지 API 호출은 사용자의 claude.ai OAuth 토큰을 사용합니다. 여기에 더해 CCR worker 엔드포인트는 session_idrole=worker 클레임을 담은 짧은 수명의 JWT를 검증합니다. OAuth 토큰만으로는 그 엔드포인트를 통과할 수 없습니다.

Bridge v1 vs v2, env 기반 vs env-less

브리지 시스템의 핵심 분기점은 세션이 Environments API를 통해 중개되는지(v1), 아니면 새 worker JWT를 들고 CCR에 직접 연결되는지(v2, "env-less")입니다.

v1, env 기반 (initBridgeCore)

  • POST /v1/environments로 environment를 등록
  • GET /v1/environments/{id}/work로 작업을 폴링
  • WorkSecret (base64url JSON)을 디코드해 세션 JWT를 획득
  • work item에 대해 acknowledge, heartbeat, stop 처리
  • 영구 모드 지원 (bridge-pointer.json을 통한 크래시 복구)
  • 멀티 세션 spawn 지원 (--spawn, worktree 모드)
  • 전송 방식: HybridTransport (WebSocket 읽기 + HTTP POST 쓰기)
  • 사용처: REPL, daemon, claude remote-control 서버 모드

v2, env-less (initEnvLessBridgeCore)

  • Environments API 없음, register / poll / ack / heartbeat 단계를 건너뜀
  • POST /v1/code/sessions → session ID
  • POST /v1/code/sessions/{id}/bridge → worker JWT + epoch
  • /bridge 호출 자체가 worker 등록 역할을 함
  • 전송 방식: SSETransport (읽기) + CCRClient (쓰기)
  • tengu_bridge_repl_v2 GrowthBook 플래그로 제어됨
  • REPL 전용, daemon/print는 계속 v1 사용
  • 토큰 갱신 시 /bridge를 다시 호출함 (새 JWT + 새 epoch)
WorkSecret 디코드, v1 핸드셰이크 토큰 안에는 무엇이 들어 있을까

Environments API가 작업을 전달할 때는 불투명한 secret 필드를 함께 붙입니다. 이것은 base64url로 인코딩된 JSON 블롭입니다. bridge/workSecret.tsdecodeWorkSecret()가 이를 풀어냅니다.

export function decodeWorkSecret(secret: string): WorkSecret {
  const json = Buffer.from(secret, 'base64url').toString('utf-8')
  const parsed: unknown = jsonParse(json)
  // version === 1, session_ingress_token, api_base_url을 검증
  return parsed as WorkSecret
}

type WorkSecret = {
  version: 1
  session_ingress_token: string   // CCR worker 엔드포인트용 JWT
  api_base_url: string
  sources: Array<{ type: string; git_info?: ... }>
  auth: Array<{ type: string; token: string }>
  use_code_sessions?: boolean    // 서버가 결정하는 CCR v2 선택자
}

session_ingress_token은 worker 계층 작업을 승인하는 짧은 수명의 JWT입니다. 이 자리에 OAuth 토큰을 대신 쓸 수는 없습니다. CCR은 JWT의 session_idrole=worker 클레임을 직접 검증합니다.

initReplBridge 안의 게이트 로직

v1/v2 분기는 모든 인증 검사를 통과한 뒤 bridge/initReplBridge.ts에서 결정됩니다.

// tengu_bridge_repl_v2는 REPL에 env-less (v2) 경로를 활성화한다.
// perpetual=true이면 v1로 되돌아간다. bridge-pointer는 아직 v2에 연결되지 않았다.
if (isEnvLessBridgeEnabled() && !perpetual) {
  const versionError = await checkEnvLessBridgeMinVersion()
  if (versionError) {
    onStateChange?.('failed', 'run `claude update` to upgrade')
    return null
  }
  const { initEnvLessBridgeCore } = await import('./remoteBridgeCore.js')
  return initEnvLessBridgeCore({ baseUrl, orgUUID, title, ... })
}

// v1 경로: env 기반 register/poll/ack/heartbeat
return initBridgeCore({ dir, machineName, branch, gitRepoUrl, ... })

버전 하한은 서로 독립적입니다. tengu_bridge_min_version은 v1을, tengu_bridge_repl_v2_config.min_version은 v2를 제어합니다. 둘 다 GrowthBook 동적 설정이므로, 운영팀은 하한을 0.0.0으로 낮춰 롤백할 수 있습니다.

세션 ID 호환 계층, cse_* vs session_*

CCR v2 호환 계층은 ID를 둘로 나눕니다. 인프라 엔드포인트는 cse_* ID를 내주고, claude.ai 프런트엔드는 session_* 경로를 사용합니다. 둘은 접두사만 다를 뿐 같은 UUID입니다.

// bridge/sessionIdCompat.ts
export function toCompatSessionId(id: string): string {
  if (!id.startsWith('cse_')) return id
  if (_isCseShimEnabled && !_isCseShimEnabled()) return id
  return 'session_' + id.slice('cse_'.length)
}

export function toInfraSessionId(id: string): string {
  if (!id.startsWith('session_')) return id
  return 'cse_' + id.slice('session_'.length)
}

// sameSessionId()는 접두사를 무시해서, poll 루프가
// 호환 게이트가 켜졌을 때 자기 세션을 '외부 세션'으로 거부하지 않게 한다
export function sameSessionId(a: string, b: string): boolean {
  const aBody = a.slice(a.lastIndexOf('_') + 1)
  const bBody = b.slice(b.lastIndexOf('_') + 1)
  return aBody.length >= 4 && aBody === bBody
}

isCseShimEnabled 킬 스위치는 setCseShimGate()로 주입되며, Agent SDK 번들에 GrowthBook을 import하지 않기 위한 장치입니다.

전송 계층, WebSocket, SSE, Hybrid

전송 추상화는 bridge/replBridgeTransport.ts에 있습니다. 여기서는 하나의 ReplBridgeTransport 인터페이스와 두 개의 팩터리 함수를 정의합니다. 하나는 v1용, 다른 하나는 v2용입니다. 그래서 나머지 브리지 코드는 아래에서 어떤 프로토콜이 쓰이는지 알 필요가 없습니다.

ReplBridgeTransport 인터페이스

export type ReplBridgeTransport = {
  write(message: StdoutMessage): Promise<void>
  writeBatch(messages: StdoutMessage[]): Promise<void>
  close(): void
  isConnectedStatus(): boolean
  getStateLabel(): string
  setOnData(cb: (data: string) => void): void
  setOnClose(cb: (closeCode?: number) => void): void
  setOnConnect(cb: () => void): void
  connect(): void
  getLastSequenceNum(): number  // v1은 항상 0을 반환
  readonly droppedBatchCount: number
  reportState(state: SessionState): void    // v2 전용, v1에서는 no-op
  reportMetadata(m: Record<string, unknown>): void  // v2 전용
  reportDelivery(id: string, s: 'processing'|'processed'): void
  flush(): Promise<void>  // v2는 큐를 비우고, v1은 즉시 resolve
}
v1 전송, HybridTransport 어댑터

createV1ReplTransport()HybridTransport를 감싸는 얇은 패스스루 래퍼입니다. HybridTransport는 인바운드 메시지를 위해 Session-Ingress로 WebSocket을 열고, 아웃바운드는 HTTP POST를 사용합니다. v1은 SSE 시퀀스 번호를 쓰지 않습니다. 재생은 서버 측 커서가 처리합니다.

export function createV1ReplTransport(
  hybrid: HybridTransport,
): ReplBridgeTransport {
  return {
    write: msg => hybrid.write(msg),
    writeBatch: msgs => hybrid.writeBatch(msgs),
    close: () => hybrid.close(),
    isConnectedStatus: () => hybrid.isConnectedStatus(),
    getLastSequenceNum: () => 0,     // WS 재생은 SSE 시퀀스 번호와 다르다
    reportState: () => {},              // no-op
    reportMetadata: () => {},
    reportDelivery: () => {},
    flush: () => Promise.resolve(),     // 각 POST는 write마다 await된다
    // ... 그 밖의 패스스루들
  }
}
v2 전송, SSETransport + CCRClient

v2 전송은 비대칭입니다. 읽기는 SSE (Server-Sent Events)로 들어오고, 쓰기CCRClient를 통해 SerialBatchEventUploader/worker/events로 POST합니다. 이 분리는 의도된 설계입니다. 인바운드 SSE 스트림이 잠시 멈춰도 CCRClient의 heartbeat와 쓰기 경로는 살아 있을 수 있습니다.

export async function createV2ReplTransport(opts: {
  sessionUrl: string
  ingressToken: string
  sessionId: string
  initialSequenceNum?: number  // 재연결 시 이 SSE 시퀀스부터 재개
  epoch?: number               // /bridge가 제공했다면 registerWorker 생략
  getAuthToken?: () => string | undefined  // 멀티 세션에서도 안전
  outboundOnly?: boolean       // SSE 읽기 생략 (미러 모드)
}): Promise<ReplBridgeTransport> {

  // registerWorker는 CCRClient에 필요한 worker_epoch를 반환한다
  const epoch = opts.epoch ?? (await registerWorker(sessionUrl, ingressToken))

  const sse = new SSETransport(sseUrl, {}, sessionId, ...)
  const ccr = new CCRClient(sse, new URL(sessionUrl), {
    getAuthHeaders,
    onEpochMismatch: () => {
      // 서버의 409 응답: 다른 worker가 우리 epoch를 대체했다
      ccr.close(); sse.close()
      onCloseCb?.(4090)  // poll 루프 복구 코드
      throw new Error('epoch superseded')
    },
  })
  // daemon 재시작 때 유령 프롬프트가 쏟아지는 일을 막으려면
  // 'received'와 함께 'processed'도 즉시 ACK해야 한다 (CC-1263)
  sse.setOnEvent(event => {
    ccr.reportDelivery(event.event_id, 'received')
    ccr.reportDelivery(event.event_id, 'processed')
  })
  return { write: msg => ccr.writeEvent(msg), ... }
}
Epoch 불일치 (409) 서버가 같은 세션에 두 번째 worker가 등록되었다고 감지하면, 예를 들어 브리지가 재시작된 경우, 이전 worker의 다음 heartbeat나 write에 409를 보냅니다. 기존 transport는 스스로 닫히고(code 4090), poll 루프는 새 epoch가 붙은 fresh work를 다시 집어 옵니다.
FlushGate, 이력 메시지와 실시간 메시지가 섞이는 것 방지

브리지 세션이 시작되면 과거 대화를 POST로 한 번에 flush합니다. 이때 새 메시지가 들어오면 서버에서는 이력과 뒤섞일 수 있습니다. FlushGate는 flush가 끝날 때까지 그것들을 큐에 넣어 둡니다.

class FlushGate<T> {
  start(): void            // flush 진행 중으로 표시, enqueue()가 큐잉을 시작
  end(): T[]               // flush 완료, 비워 낼 큐 항목들을 반환
  enqueue(...items: T[]): boolean  // active면 true(큐에 저장), 아니면 false(그대로 통과)
  drop(): number           // 큐를 폐기 (transport가 영구적으로 닫힘)
  deactivate(): void       // transport가 교체됨, 새 transport가 이를 비운다
}

deactivate()는 transport가 교체될 때 호출됩니다. 예를 들어 env loss 뒤 재연결되는 경우입니다. 큐에 쌓인 항목은 새 transport의 end() 호출까지 보존됩니다.

재연결을 가로지르는 SSE 시퀀스 번호 v2에서는 getLastSequenceNum()가 SSE 하이워터 마크를 반환합니다. transport가 교체되면, 예를 들어 epoch mismatch, 401, SSE drop 상황에서, 새 SSETransportinitialSequenceNum과 함께 만들어집니다. 그래서 from_sequence_num을 보내고 서버는 전체 이력을 다시 보내지 않고 이어서 재개할 수 있습니다. v1이 항상 0을 반환하는 이유는 WS 재생이 서버 측 커서 기반이기 때문입니다.

권한 브리지

Remote Control은 control_request / control_response 프로토콜을 통해 도구 사용 권한 프롬프트를 노출합니다. Claude가 잠재적으로 위험한 도구를 실행하려 하면, 보통 REPL은 로컬 사용자에게 묻습니다. Remote Control 모드에서는 그 질문이 브리지를 거쳐 claude.ai까지 전달됩니다.

제어 메시지 타입

  • control_request, 브리지가 claude.ai에 "이 도구를 실행해도 될까?"라고 묻는 메시지
  • control_response, claude.ai가 allow/deny로 응답하고 필요하면 입력도 갱신함
  • control_cancel_request, 서버가 대기 중인 프롬프트를 취소함, 예를 들면 세션 종료 시
권한 응답 타입과 type guard
// bridge/bridgePermissionCallbacks.ts
type BridgePermissionResponse = {
  behavior: 'allow' | 'deny'
  updatedInput?: Record<string, unknown>
  updatedPermissions?: PermissionUpdate[]
  message?: string
}

function isBridgePermissionResponse(value: unknown): value is BridgePermissionResponse {
  if (!value || typeof value !== 'object') return false
  return (
    'behavior' in value &&
    (value.behavior === 'allow' || value.behavior === 'deny')
  )
}
RemoteSessionManager, 권한 요청과 응답 흐름

remote/RemoteSessionManager.ts는 로컬 CLI가 바라보고 있는 CCR 호스팅 세션의 클라이언트 측을 조정합니다. WebSocket을 통해 CCR에서 권한 요청을 받고, 사용자가 응답할 때까지 그것을 붙잡아 둡니다.

class RemoteSessionManager {
  private pendingPermissionRequests = new Map<string, SDKControlPermissionRequest>()

  private handleControlRequest(req: SDKControlRequest): void {
    const { request_id, request: inner } = req
    if (inner.subtype === 'can_use_tool') {
      this.pendingPermissionRequests.set(request_id, inner)
      this.callbacks.onPermissionRequest(inner, request_id)
    } else {
      // 지원하지 않는 subtype이면 서버가 멈추지 않도록 즉시 에러 응답을 보낸다
      this.websocket?.sendControlResponse({ type: 'control_response', response: {
        subtype: 'error', request_id, error: 'Unsupported subtype'
      }})
    }
  }

  respondToPermissionRequest(requestId: string, result: RemotePermissionResponse): void {
    this.pendingPermissionRequests.delete(requestId)
    this.websocket?.sendControlResponse({
      type: 'control_response',
      response: {
        subtype: 'success', request_id: requestId,
        response: {
          behavior: result.behavior,
          ...(result.behavior === 'allow'
            ? { updatedInput: result.updatedInput }
            : { message: result.message }),
        },
      },
    })
  }
}
리모트 권한 프롬프트를 위한 synthetic AssistantMessage

REPL의 권한 UI는 도구 사용 블록이 들어 있는 실제 AssistantMessage를 기대합니다. 그런데 권한 요청이 리모트 CCR 컨테이너에서 오면 로컬에는 그런 메시지가 없습니다. remote/remotePermissionBridge.ts가 이를 만들어 냅니다.

export function createSyntheticAssistantMessage(
  request: SDKControlPermissionRequest,
  requestId: string,
): AssistantMessage {
  return {
    type: 'assistant',
    uuid: randomUUID(),
    message: {
      id: `remote-${requestId}`,
      type: 'message',
      role: 'assistant',
      content: [{
        type: 'tool_use',
        id: request.tool_use_id,
        name: request.tool_name,
        input: request.input,
      }],
      // usage가 0인 스텁 필드들 ...
    } as AssistantMessage['message'],
    requestId: undefined,
    timestamp: new Date().toISOString(),
  }
}

// 로컬에서 알지 못하는 도구를 위한 처리, 예: 리모트 컨테이너의 MCP 도구
export function createToolStub(toolName: string): Tool {
  return {
    name: toolName,
    isEnabled: () => true,
    needsPermissions: () => true,
    // 표시용으로 처음 3개의 input key:value 쌍을 렌더링한다
    call: async () => ({ data: '' }),
  } as unknown as Tool
}
독립형 브리지의 권한 흐름 (bridgeMain.ts) claude remote-control 서버 모드에서는 자식 Claude 프로세스에서 온 권한 요청이 sessionRunner.ts를 통해 가로채집니다. 그런 다음 api.sendPermissionResponseEvent()로 서버에 전달되고, 그 응답이 다시 자식 프로세스의 stdin으로 써집니다.

리모트 컨트롤 명령, /remote-control

/remote-control 슬래시 명령은 commands/bridge/bridge.tsx에 있습니다. 이것은 REPL의 Ink 터미널 UI 안에서 렌더링되는 React 컴포넌트입니다.

무엇을 하는가

  • 이미 브리지가 연결되어 있는지 확인하고, 연결되어 있다면 세션 URL과 QR 코드 옵션이 포함된 연결 해제 대화상자를 보여 줍니다.
  • 연결되지 않은 상태라면 사전 점검(checkBridgePrerequisites)을 실행한 뒤 AppStatereplBridgeEnabled: true를 설정합니다.
  • REPL.tsxuseReplBridgereplBridgeEnabled를 감시하다가 initReplBridge()를 호출합니다.
  • name 인자(/remote-control my-session)는 명시적인 세션 제목을 지정합니다.
BridgeToggle 컴포넌트 로직 (컴파일된 React)
// commands/bridge/bridge.tsx (compiled)
function BridgeToggle({ onDone, name }) {
  const replBridgeConnected = useAppState(s => s.replBridgeConnected)
  const replBridgeEnabled   = useAppState(s => s.replBridgeEnabled)
  const [showDisconnectDialog, setShow] = useState(false)

  useEffect(() => {
    if ((replBridgeConnected || replBridgeEnabled) && !replBridgeOutboundOnly) {
      setShow(true)  // 이미 연결되어 있음, 대화상자를 표시
      return
    }
    (async () => {
      const error = await checkBridgePrerequisites()
      if (error) { onDone(error, { display: 'system' }); return }
      setAppState(prev => ({
        ...prev,
        replBridgeEnabled: true,
        replBridgeExplicit: true,
        replBridgeOutboundOnly: false,
        replBridgeInitialName: name,
      }))
      onDone('Remote Control connecting…', { display: 'system' })
    })()
  }, [])  // 마운트 시 한 번만 실행
}
권한 게이트, 누가 Remote Control을 쓸 수 있는가

initReplBridge가 진행되기 전에 다섯 가지 검사가 모두 통과해야 합니다.

  1. 런타임 게이트: isBridgeEnabledBlocking(), tengu_ccr_bridge GrowthBook 플래그와 claude.ai OAuth 구독이 모두 필요합니다. Bedrock/Vertex/API-key 인증은 허용되지 않습니다.
  2. OAuth 토큰 존재: getBridgeAccessToken()이 값을 반환해야 합니다.
  3. 조직 정책: isPolicyAllowed('allow_remote_control'), 엔터프라이즈 관리자는 조직 전체 구성원의 RC를 끌 수 있습니다.
  4. 토큰 신선도: 선제 갱신을 수행하고, 만료됐고 갱신도 불가능하다면 건너뜁니다. 확정적인 401 루프를 피하기 위함입니다.
  5. 최소 버전: v1은 checkBridgeMinVersion(), v2는 checkEnvLessBridgeMinVersion()으로 검사합니다. 운영팀은 전체 배포군에 강제 업그레이드를 걸 수 있습니다.

어느 하나라도 실패하면 onStateChange?.('failed', reason)가 호출되고 함수는 null을 반환합니다.

CCR 미러 모드 (outboundOnly)

isCcrMirrorEnabled()가 true이면, 즉 환경 변수 CLAUDE_CODE_CCR_MIRROR 또는 GrowthBook 플래그가 켜져 있으면, 모든 로컬 세션이 아웃바운드 전용 브리지로 시작됩니다. SSE 읽기 스트림은 건너뛰고, 브리지는 이벤트를 claude.ai로만 스트리밍하며 인바운드 프롬프트는 받지 않습니다. 이 세션은 claude.ai 세션 목록에서 읽기 전용 보기로 나타납니다.

세션 제목 생성 방식 제목은 두 단계로 정해집니다. count-1에서는 빠른 플레이스홀더를 씁니다. 첫 사용자 메시지의 첫 문장을 50자까지 잘라 사용합니다. count-3에서는 전체 대화 텍스트를 바탕으로 Haiku(generateSessionTitle)를 돌립니다. /remote-control <name>이나 /rename으로 지정한 명시적 제목은 자동으로 덮어쓰지 않습니다.

CCR 통합, Cloud Code Runner

CCR (Cloud Code Runner)은 로컬 CLI 없이 claude.ai에서 요청된 세션을 처리하는 서버 측 실행 환경입니다. 로컬 브리지는 원래 원격에서 만들어진 세션의 출력을 렌더링하고 권한을 처리하기 위해 CCR의 session-ingress 계층에 연결됩니다.

SessionsWebSocket, CCR 세션 구독

remote/SessionsWebSocket.tswss://api.anthropic.com/v1/sessions/ws/{sessionId}/subscribe에 연결해 활성 CCR 세션의 실시간 이벤트 스트림을 받습니다.

연결 및 재연결 로직
const RECONNECT_DELAY_MS = 2000
const MAX_RECONNECT_ATTEMPTS = 5
const MAX_SESSION_NOT_FOUND_RETRIES = 3  // 4001은 compaction 중 일시적일 수 있다

const PERMANENT_CLOSE_CODES = new Set([
  4003,  // unauthorized, 즉시 중단
])

private handleClose(closeCode: number): void {
  if (PERMANENT_CLOSE_CODES.has(closeCode)) {
    this.callbacks.onClose?.()
    return
  }
  if (closeCode === 4001) {
    // session not found, 선형 backoff로 최대 3번 재시도
    this.sessionNotFoundRetries++
    if (this.sessionNotFoundRetries > MAX_SESSION_NOT_FOUND_RETRIES) {
      this.callbacks.onClose?.()
      return
    }
    this.scheduleReconnect(RECONNECT_DELAY_MS * this.sessionNotFoundRetries, ...)
    return
  }
  if (previousState === 'connected' && this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
    this.reconnectAttempts++
    this.scheduleReconnect(RECONNECT_DELAY_MS, ...)
  }
}

Bun의 네이티브 WebSocket은 헤더로 인증을 전달합니다. Node는 같은 인증 헤더를 쓰는 ws 패키지를 사용합니다. subscribe 엔드포인트에는 연결 후 인증 메시지가 따로 필요하지 않습니다.

SDKMessage 어댑터

CCR은 SDK 형식 메시지(assistant, stream_event, result, system, tool_progress 등)를 보냅니다. remote/sdkMessageAdapter.ts는 이를 로컬 렌더링용 REPL 내부 Message 타입으로 변환합니다.

메시지 변환 표
function convertSDKMessage(msg: SDKMessage, opts?: ConvertOptions): ConvertedMessage {
  switch (msg.type) {
    case 'assistant':
      return { type: 'message', message: convertAssistantMessage(msg) }

    case 'stream_event':
      return { type: 'stream_event', event: convertStreamEvent(msg) }

    case 'result':
      // 에러만 보여 준다. 성공 결과는 멀티턴 대화에서 잡음이 된다
      return msg.subtype !== 'success'
        ? { type: 'message', message: convertResultMessage(msg) }
        : { type: 'ignored' }

    case 'system':
      if (msg.subtype === 'init')            return { type: 'message', message: convertInitMessage(msg) }
      if (msg.subtype === 'status')          return { ... }  // 'compacting'을 배너로 표시
      if (msg.subtype === 'compact_boundary') return { ... }  // compaction 지점을 표시
      return { type: 'ignored' }

    case 'tool_progress':  return { type: 'message', message: convertToolProgressMessage(msg) }
    case 'user':          return { type: 'ignored' }  // REPL이 이미 로컬에 추가했다
    case 'auth_status':   return { type: 'ignored' }
    default:             return { type: 'ignored' }  // forward-compat, 알 수 없는 타입은 조용히 버림
  }
}
user 메시지는 의도적으로 무시된다 라이브 WS 모드에서는 REPL이 사용자가 입력한 메시지를 CCR로 보내기 전에 이미 로컬에 추가해 둡니다. 어댑터가 인바운드 user 메시지까지 변환하면 화면에 두 번 나타나게 됩니다. convertUserTextMessages: true는 과거 이벤트를 재생할 때만 설정됩니다.

독립형 브리지, claude remote-control 서버 모드

bridge/bridgeMain.tsclaude remote-control가 지속 실행 서버로 사용할 runBridgeLoop() 함수를 구현합니다. REPL 브리지(단일 세션, 인라인)와 달리 독립형 브리지는 동시에 실행되는 자식 Claude 프로세스 풀을 관리합니다.

핵심 개념

  • SpawnMode: single-session (세션 하나만 처리하고 브리지 종료), worktree (각 세션에 격리된 git worktree 제공), same-dir (세션들이 같은 cwd를 공유하므로 서로 덮어쓸 수 있음).
  • maxSessions: 설정 가능한 풀 크기(기본값 32). 브리지는 용량이 꽉 차면 폴링을 멈추고, 세션이 끝나면 capacityWake로 즉시 재개합니다.
  • 토큰 갱신: v1 세션은 handle.updateAccessToken()으로 갱신된 OAuth 토큰을 받습니다. v2 세션은 reconnectSession을 호출해 서버가 fresh JWT와 함께 다시 디스패치하도록 합니다. OAuth 토큰은 CCR worker 엔드포인트에서 쓸 수 없기 때문입니다.
폴링 루프 backoff 및 재연결 전략
const DEFAULT_BACKOFF: BackoffConfig = {
  connInitialMs:    2_000,
  connCapMs:      120_000,  // 2분
  connGiveUpMs:   600_000,  // 10분
  generalInitialMs:   500,
  generalCapMs:    30_000,
  generalGiveUpMs:600_000,
}

// Sleep 감지: poll tick이 cap보다 2배 넘게 늦어지면
// 기기가 잠들었다고 보고 에러 예산을 초기화한 뒤 즉시 재연결한다
function pollSleepDetectionThresholdMs(b: BackoffConfig): number {
  return b.connCapMs * 2  // 240_000ms, 최대 backoff cap보다 큼
}

연결 에러와 일반 poll 에러는 서로 독립된 backoff 예산을 가집니다. 연결 에러(등록/WebSocket 실패)는 10분에서 포기합니다. 일반 에러(work poll의 HTTP 500)도 10분에서 포기합니다. 세션 생존 여부의 최종 권한은 서버에 있습니다.

자식 프로세스 생성 및 세션 추적
// bridgeMain.ts, activeSessions 맵이 실행 중인 모든 세션을 추적한다
const activeSessions = new Map<string, SessionHandle>()
const sessionStartTimes = new Map<string, number>()
const sessionIngressTokens = new Map<string, string>()
const sessionTimers = new Map<string, ReturnType<typeof setTimeout>>()

// 세션별 타임아웃 감시기 (기본값 24시간)
const DEFAULT_SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000

// SessionHandle은 kill()/forceKill(), writeStdin(), activities ring buffer를 노출한다
type SessionHandle = {
  sessionId: string
  done: Promise<SessionDoneStatus>
  kill(): void
  forceKill(): void
  activities: SessionActivity[]      // ring buffer (최근 10개)
  currentActivity: SessionActivity | null
  accessToken: string
  lastStderr: string[]              // ring buffer (최근 10줄)
  writeStdin(data: string): void
  updateAccessToken(token: string): void
}
Heartbeat와 JWT 만료 복구

활성 work item은 GrowthBook에서 설정한 간격으로 heartbeat를 보냅니다. heartbeat는 OAuth가 아니라 session ingress JWT를 사용하며, 이는 SessionIngressAuth를 통한 가벼운 DB 없는 JWT 검증입니다. 401/403이 나와 JWT가 만료되면 브리지는 reconnectSession을 호출해 작업을 다시 큐에 넣고, 다음 poll이 fresh credential을 전달하게 만듭니다.

async function heartbeatActiveWorkItems() {
  for (const [sessionId] of activeSessions) {
    const ingressToken = sessionIngressTokens.get(sessionId)
    try {
      await api.heartbeatWork(environmentId, workId, ingressToken)
    } catch (err) {
      if (err.status === 401 || err.status === 403) {
        // JWT 만료, 다음 poll이 fresh token을 주도록 다시 디스패치한다
        await api.reconnectSession(environmentId, sessionId)
      }
    }
  }
}

선제 토큰 갱신 스케줄러도 만료 5분 전에 동작합니다. v1 세션은 새 OAuth 토큰을 직접 받고, v2 세션은 CCR worker 엔드포인트가 OAuth 토큰을 거부하므로 reconnectSession 경로를 거칩니다.

BridgeWorkerType

모든 environment 등록에는 metadata.worker_type으로 전송되는 worker_type 문자열이 포함됩니다. 웹 UI는 이것을 사용해 세션 선택기에서 세션을 필터링합니다.

  • "claude_code", 표준 REPL 세션
  • "claude_code_assistant", assistant 모드 (KAIROS feature flag)
  • "cowork", Desktop Cowork (이 코드베이스가 아니라 claude.ai 데스크톱 앱이 보냄)

핵심 정리

  1. 두 가지 브리지 아키텍처가 공존한다: v1 (poll/ack/heartbeat 루프가 있는 env 기반)과 v2 ("env-less", POST /bridge를 통한 직접 OAuth → worker JWT). REPL이 어느 경로를 택할지는 GrowthBook 플래그 tengu_bridge_repl_v2가 결정합니다.
  2. v2의 전송은 비대칭이다: 인바운드는 SSE를 쓰고, 끊김 없는 재연결을 위해 시퀀스 번호를 사용합니다. 아웃바운드는 CCRClient/worker/events로 POST합니다. v1은 HybridTransport를 통해 양방향 모두 WebSocket을 사용합니다.
  3. FlushGate는 이력과 실시간 메시지가 섞이는 것을 막는다: 과거 메시지는 연결 시 하나의 HTTP 배치로 flush되고, 그동안 도착한 실시간 메시지는 큐에 쌓였다가 flush가 끝난 뒤 비워집니다.
  4. 권한 흐름도 같은 전송 경로 위에서 양방향으로 이동한다: control_request는 Claude → 서버 → claude.ai로 이동하고, control_response (allow/deny)는 다시 돌아옵니다. 클라이언트가 알지 못하는 도구를 위해 리모트 도구 스텁이 로컬에서 합성됩니다.
  5. 세션 ID는 두 가지 모습을 가진다: 인프라 계층은 cse_*를, 호환/클라이언트용 API는 session_*를 사용합니다. sameSessionId()는 UUID 본문만 비교하므로 poll 루프가 자기 세션을 거부하지 않습니다. toCompatSessionId()는 GrowthBook 킬 스위치의 제어를 받습니다.
  6. worker 엔드포인트 인증에는 OAuth가 아니라 JWT가 필요하다: CCR은 JWT의 session_id 클레임과 role=worker를 검증합니다. v2 세션의 토큰 갱신은 실행 중인 프로세스에 새 OAuth 토큰을 밀어 넣는 대신 서버 재디스패치(reconnectSession)를 트리거합니다.
  7. claude remote-control 서버 모드single-session, worktree, same-dir spawn 모드, 설정 가능한 풀 크기, 세션별 24시간 타임아웃 감시기를 바탕으로 멀티 세션 동시 실행을 지원합니다.

퀴즈 — 6문제

Q1. v2 (env-less) 브리지 경로에서 POST /v1/code/sessions/{id}/bridge는 Environments API 전체 폴링 워크플로를 대신해 무엇을 반환하나요?
정답! v2 브리지 엔드포인트는 Environments API 폴링 없이 단 한 번의 HTTP 호출로 worker JWT와 worker epoch를 반환합니다. 이 JWT는 짧은 수명을 가지며 CCR worker 계층 작업을 직접 승인합니다.
Q2. v2 transport는 SSE 이벤트를 받자마자 왜 received만이 아니라 processed까지 즉시 ACK하나요?
정답! processed ACK 없이 received만 보내면 서버는 이벤트를 처리 완료로 보지 않고 재큐에 남겨둡니다. daemon이 재시작될 때마다 이 미처리 이벤트가 다시 전달되어 유령 프롬프트가 세션에 쏟아집니다.
Q3. work poll에서 cse_abc123 세션 ID가 도착했습니다. 이것을 클라이언트용 sessions API (/v1/sessions/{id}/archive)에 맞게 바꾸는 함수는 무엇인가요?
정답! toCompatSessionId()는 인프라용 cse_* 접두사를 클라이언트 sessions API가 기대하는 session_* 접두사로 변환합니다. UUID 본문은 그대로 유지되고 접두사만 바뀝니다.
Q4. FlushGate.deactivate()의 목적은 무엇인가요? drop()과는 어떻게 다른가요?
정답! deactivate()는 active 플래그만 지우고 큐 항목은 그대로 둡니다. drop()은 항목까지 버립니다. 이 차이 덕분에 transport가 교체될 때 새 transport가 기존 큐 항목을 이어받아 처리할 수 있습니다.
Q5. claude remote-control 서버 모드(독립형 브리지)에서 v2 세션의 JWT가 heartbeat 도중 만료되면 어떻게 되나요?
정답! JWT 만료 시 v2 세션은 api.reconnectSession()으로 서버 재디스패치를 트리거합니다. 실행 중인 프로세스에 직접 토큰을 밀어 넣거나 세션을 죽이지 않고, 서버가 새 JWT와 함께 fresh work를 다음 poll로 전달하게 만듭니다.
Q6. remote/remotePermissionBridge.tscreateToolStub(toolName)가 필요한 이유는 무엇인가요?
정답! 리모트 CCR 컨테이너는 로컬 CLI가 알지 못하는 MCP 도구를 실행할 수 있습니다. createToolStub()는 이런 알 수 없는 도구 이름에 대해 FallbackPermissionRequest에 연결할 수 있는 최소한의 스텁 객체를 만들어 표시 목적으로 사용합니다.
0/6