Claude Code가 로컬 CLI 세션을 양방향 클라우드 연결 환경으로 바꾸는 방식을 살펴봅니다. 2단계 Environments API부터 env-less CCR v2 경로, SSE/WebSocket 하이브리드, 권한 브리징, 그리고 claude remote-control 명령까지 다룹니다.
Remote Control은 로컬 Claude Code REPL, 또는 헤드리스 브리지 서버를 claude.ai 웹 프런트엔드에 연결합니다. 이 연결은 뚜렷하게 두 방향으로 나뉩니다.
인증 경계는 매우 중요합니다. 모든 브리지 API 호출은 사용자의 claude.ai OAuth 토큰을 사용합니다. 여기에 더해 CCR worker 엔드포인트는 session_id와 role=worker 클레임을 담은 짧은 수명의 JWT를 검증합니다. OAuth 토큰만으로는 그 엔드포인트를 통과할 수 없습니다.
브리지 시스템의 핵심 분기점은 세션이 Environments API를 통해 중개되는지(v1), 아니면 새 worker JWT를 들고 CCR에 직접 연결되는지(v2, "env-less")입니다.
initBridgeCore)POST /v1/environments로 environment를 등록GET /v1/environments/{id}/work로 작업을 폴링bridge-pointer.json을 통한 크래시 복구)--spawn, worktree 모드)HybridTransport (WebSocket 읽기 + HTTP POST 쓰기)claude remote-control 서버 모드initEnvLessBridgeCore)POST /v1/code/sessions → session IDPOST /v1/code/sessions/{id}/bridge → worker JWT + epoch/bridge 호출 자체가 worker 등록 역할을 함SSETransport (읽기) + CCRClient (쓰기)tengu_bridge_repl_v2 GrowthBook 플래그로 제어됨/bridge를 다시 호출함 (새 JWT + 새 epoch)Environments API가 작업을 전달할 때는 불투명한 secret 필드를 함께 붙입니다. 이것은 base64url로 인코딩된 JSON 블롭입니다. bridge/workSecret.ts의 decodeWorkSecret()가 이를 풀어냅니다.
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_id와 role=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으로 낮춰 롤백할 수 있습니다.
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하지 않기 위한 장치입니다.
전송 추상화는 bridge/replBridgeTransport.ts에 있습니다. 여기서는 하나의 ReplBridgeTransport 인터페이스와 두 개의 팩터리 함수를 정의합니다. 하나는 v1용, 다른 하나는 v2용입니다. 그래서 나머지 브리지 코드는 아래에서 어떤 프로토콜이 쓰이는지 알 필요가 없습니다.
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
}
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 전송은 비대칭입니다. 읽기는 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), ... }
}
브리지 세션이 시작되면 과거 대화를 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() 호출까지 보존됩니다.
getLastSequenceNum()가 SSE 하이워터 마크를 반환합니다. transport가 교체되면, 예를 들어 epoch mismatch, 401, SSE drop 상황에서, 새 SSETransport는 initialSequenceNum과 함께 만들어집니다. 그래서 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, 서버가 대기 중인 프롬프트를 취소함, 예를 들면 세션 종료 시// 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')
)
}
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 }),
},
},
})
}
}
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
}
claude remote-control 서버 모드에서는 자식 Claude 프로세스에서 온 권한 요청이 sessionRunner.ts를 통해 가로채집니다. 그런 다음 api.sendPermissionResponseEvent()로 서버에 전달되고, 그 응답이 다시 자식 프로세스의 stdin으로 써집니다.
/remote-control/remote-control 슬래시 명령은 commands/bridge/bridge.tsx에 있습니다. 이것은 REPL의 Ink 터미널 UI 안에서 렌더링되는 React 컴포넌트입니다.
checkBridgePrerequisites)을 실행한 뒤 AppState에 replBridgeEnabled: true를 설정합니다.REPL.tsx의 useReplBridge가 replBridgeEnabled를 감시하다가 initReplBridge()를 호출합니다.name 인자(/remote-control my-session)는 명시적인 세션 제목을 지정합니다.// 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' })
})()
}, []) // 마운트 시 한 번만 실행
}
initReplBridge가 진행되기 전에 다섯 가지 검사가 모두 통과해야 합니다.
isBridgeEnabledBlocking(), tengu_ccr_bridge GrowthBook 플래그와 claude.ai OAuth 구독이 모두 필요합니다. Bedrock/Vertex/API-key 인증은 허용되지 않습니다.getBridgeAccessToken()이 값을 반환해야 합니다.isPolicyAllowed('allow_remote_control'), 엔터프라이즈 관리자는 조직 전체 구성원의 RC를 끌 수 있습니다.checkBridgeMinVersion(), v2는 checkEnvLessBridgeMinVersion()으로 검사합니다. 운영팀은 전체 배포군에 강제 업그레이드를 걸 수 있습니다.어느 하나라도 실패하면 onStateChange?.('failed', reason)가 호출되고 함수는 null을 반환합니다.
outboundOnly)isCcrMirrorEnabled()가 true이면, 즉 환경 변수 CLAUDE_CODE_CCR_MIRROR 또는 GrowthBook 플래그가 켜져 있으면, 모든 로컬 세션이 아웃바운드 전용 브리지로 시작됩니다. SSE 읽기 스트림은 건너뛰고, 브리지는 이벤트를 claude.ai로만 스트리밍하며 인바운드 프롬프트는 받지 않습니다. 이 세션은 claude.ai 세션 목록에서 읽기 전용 보기로 나타납니다.
generateSessionTitle)를 돌립니다. /remote-control <name>이나 /rename으로 지정한 명시적 제목은 자동으로 덮어쓰지 않습니다.
CCR (Cloud Code Runner)은 로컬 CLI 없이 claude.ai에서 요청된 세션을 처리하는 서버 측 실행 환경입니다. 로컬 브리지는 원래 원격에서 만들어진 세션의 출력을 렌더링하고 권한을 처리하기 위해 CCR의 session-ingress 계층에 연결됩니다.
remote/SessionsWebSocket.ts는 wss://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 엔드포인트에는 연결 후 인증 메시지가 따로 필요하지 않습니다.
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, 알 수 없는 타입은 조용히 버림
}
}
convertUserTextMessages: true는 과거 이벤트를 재생할 때만 설정됩니다.
claude remote-control 서버 모드bridge/bridgeMain.ts는 claude remote-control가 지속 실행 서버로 사용할 runBridgeLoop() 함수를 구현합니다. REPL 브리지(단일 세션, 인라인)와 달리 독립형 브리지는 동시에 실행되는 자식 Claude 프로세스 풀을 관리합니다.
single-session (세션 하나만 처리하고 브리지 종료), worktree (각 세션에 격리된 git worktree 제공), same-dir (세션들이 같은 cwd를 공유하므로 서로 덮어쓸 수 있음).capacityWake로 즉시 재개합니다.handle.updateAccessToken()으로 갱신된 OAuth 토큰을 받습니다. v2 세션은 reconnectSession을 호출해 서버가 fresh JWT와 함께 다시 디스패치하도록 합니다. OAuth 토큰은 CCR worker 엔드포인트에서 쓸 수 없기 때문입니다.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
}
활성 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 경로를 거칩니다.
모든 environment 등록에는 metadata.worker_type으로 전송되는 worker_type 문자열이 포함됩니다. 웹 UI는 이것을 사용해 세션 선택기에서 세션을 필터링합니다.
"claude_code", 표준 REPL 세션"claude_code_assistant", assistant 모드 (KAIROS feature flag)"cowork", Desktop Cowork (이 코드베이스가 아니라 claude.ai 데스크톱 앱이 보냄)POST /bridge를 통한 직접 OAuth → worker JWT). REPL이 어느 경로를 택할지는 GrowthBook 플래그 tengu_bridge_repl_v2가 결정합니다.CCRClient가 /worker/events로 POST합니다. v1은 HybridTransport를 통해 양방향 모두 WebSocket을 사용합니다.FlushGate는 이력과 실시간 메시지가 섞이는 것을 막는다: 과거 메시지는 연결 시 하나의 HTTP 배치로 flush되고, 그동안 도착한 실시간 메시지는 큐에 쌓였다가 flush가 끝난 뒤 비워집니다.control_request는 Claude → 서버 → claude.ai로 이동하고, control_response (allow/deny)는 다시 돌아옵니다. 클라이언트가 알지 못하는 도구를 위해 리모트 도구 스텁이 로컬에서 합성됩니다.cse_*를, 호환/클라이언트용 API는 session_*를 사용합니다. sameSessionId()는 UUID 본문만 비교하므로 poll 루프가 자기 세션을 거부하지 않습니다. toCompatSessionId()는 GrowthBook 킬 스위치의 제어를 받습니다.session_id 클레임과 role=worker를 검증합니다. v2 세션의 토큰 갱신은 실행 중인 프로세스에 새 OAuth 토큰을 밀어 넣는 대신 서버 재디스패치(reconnectSession)를 트리거합니다.claude remote-control 서버 모드는 single-session, worktree, same-dir spawn 모드, 설정 가능한 풀 크기, 세션별 24시간 타임아웃 감시기를 바탕으로 멀티 세션 동시 실행을 지원합니다.POST /v1/code/sessions/{id}/bridge는 Environments API 전체 폴링 워크플로를 대신해 무엇을 반환하나요?received만이 아니라 processed까지 즉시 ACK하나요?processed ACK 없이 received만 보내면 서버는 이벤트를 처리 완료로 보지 않고 재큐에 남겨둡니다. daemon이 재시작될 때마다 이 미처리 이벤트가 다시 전달되어 유령 프롬프트가 세션에 쏟아집니다.cse_abc123 세션 ID가 도착했습니다. 이것을 클라이언트용 sessions API (/v1/sessions/{id}/archive)에 맞게 바꾸는 함수는 무엇인가요?toCompatSessionId()는 인프라용 cse_* 접두사를 클라이언트 sessions API가 기대하는 session_* 접두사로 변환합니다. UUID 본문은 그대로 유지되고 접두사만 바뀝니다.FlushGate.deactivate()의 목적은 무엇인가요? drop()과는 어떻게 다른가요?deactivate()는 active 플래그만 지우고 큐 항목은 그대로 둡니다. drop()은 항목까지 버립니다. 이 차이 덕분에 transport가 교체될 때 새 transport가 기존 큐 항목을 이어받아 처리할 수 있습니다.claude remote-control 서버 모드(독립형 브리지)에서 v2 세션의 JWT가 heartbeat 도중 만료되면 어떻게 되나요?api.reconnectSession()으로 서버 재디스패치를 트리거합니다. 실행 중인 프로세스에 직접 토큰을 밀어 넣거나 세션을 죽이지 않고, 서버가 새 JWT와 함께 fresh work를 다음 poll로 전달하게 만듭니다.remote/remotePermissionBridge.ts의 createToolStub(toolName)가 필요한 이유는 무엇인가요?createToolStub()는 이런 알 수 없는 도구 이름에 대해 FallbackPermissionRequest에 연결할 수 있는 최소한의 스텁 객체를 만들어 표시 목적으로 사용합니다.