Why this matters
Remote session management is the layer that keeps CCR readable over the network. It separates the ordinary SDK stream from permission requests, the cancel path that clears a pending prompt, and the acknowledgments that close the loop so the local side can stay simple.
The lifecycle, from connect to the response roundtrip
RemoteSessionManager opens the websocket first. After that,
handleMessage() sorts each inbound message into one of four buckets:
SDK messages are the ordinary session stream. Control requests carry
permission prompts from CCR. Control cancel requests tell the client to clear
a pending prompt. Control responses are acknowledgments that close the loop.
When CCR asks for permission, respondToPermissionRequest() turns the local
decision into a response and sends it back.
See the appendix for the full field-by-field reference on
RemoteSessionConfig and RemoteSessionCallbacks.
The permission response shape
RemotePermissionResponse is the tiny allow-or-deny reply that goes back to
CCR.
export type RemotePermissionResponse =
| {
behavior: 'allow'
updatedInput: Record<string, unknown>
}
| {
behavior: 'deny'
message: string
}
The session settings
RemoteSessionConfig tells the manager which session to join, which org owns
it, and how to fetch a token. The shape starts with
export type RemoteSessionConfig = {, and the appendix has the full
field-by-field reference.
The callback hooks
RemoteSessionCallbacks tells the manager where to hand remote events back
once they arrive. The shape starts with
export type RemoteSessionCallbacks = {, and the appendix keeps the full
callback-by-callback reference.
The manager itself
RemoteSessionManager is the class that opens the socket, classifies inbound
traffic, and sends permission answers back out.
export class RemoteSessionManager {
private websocket: SessionsWebSocket | null = null
private pendingPermissionRequests: Map<string, SDKControlPermissionRequest> =
new Map()
constructor(
private readonly config: RemoteSessionConfig,
private readonly callbacks: RemoteSessionCallbacks,
) {}
/**
* Connect to the remote session via WebSocket
*/
connect(): void {
logForDebugging(
`[RemoteSessionManager] Connecting to session ${this.config.sessionId}`,
)
const wsCallbacks: SessionsWebSocketCallbacks = {
onMessage: message => this.handleMessage(message),
onConnected: () => {
logForDebugging('[RemoteSessionManager] Connected')
this.callbacks.onConnected?.()
},
onClose: () => {
logForDebugging('[RemoteSessionManager] Disconnected')
this.callbacks.onDisconnected?.()
},
onReconnecting: () => {
logForDebugging('[RemoteSessionManager] Reconnecting')
this.callbacks.onReconnecting?.()
},
onError: error => {
logError(error)
this.callbacks.onError?.(error)
},
}
this.websocket = new SessionsWebSocket(
this.config.sessionId,
this.config.orgUuid,
this.config.getAccessToken,
wsCallbacks,
)
void this.websocket.connect()
}
connect() just wires the websocket and the lifecycle callbacks. The real
routing happens in handleMessage(), where the manager keeps SDK messages,
permission prompts, cancellations, and acknowledgments separate.
private handleMessage(
message:
| SDKMessage
| SDKControlRequest
| SDKControlResponse
| SDKControlCancelRequest,
): void {
// Handle control requests (permission prompts from CCR)
if (message.type === 'control_request') {
this.handleControlRequest(message)
return
}
// Handle control cancel requests (server cancelling a pending permission prompt)
if (message.type === 'control_cancel_request') {
const { request_id } = message
const pendingRequest = this.pendingPermissionRequests.get(request_id)
logForDebugging(
`[RemoteSessionManager] Permission request cancelled: ${request_id}`,
)
this.pendingPermissionRequests.delete(request_id)
this.callbacks.onPermissionCancelled?.(
request_id,
pendingRequest?.tool_use_id,
)
return
}
// Handle control responses (acknowledgments)
if (message.type === 'control_response') {
logForDebugging('[RemoteSessionManager] Received control response')
return
}
// Forward SDK messages to callback (type guard ensures proper narrowing)
if (isSDKMessage(message)) {
this.callbacks.onMessage(message)
}
}
When CCR asks for permission, respondToPermissionRequest() turns the local
allow-or-deny decision into a control_response and sends it back over the
socket.
respondToPermissionRequest(
requestId: string,
result: RemotePermissionResponse,
): void {
const pendingRequest = this.pendingPermissionRequests.get(requestId)
if (!pendingRequest) {
logError(
new Error(
`[RemoteSessionManager] No pending permission request with ID: ${requestId}`,
),
)
return
}
this.pendingPermissionRequests.delete(requestId)
const response: SDKControlResponse = {
type: 'control_response',
response: {
subtype: 'success',
request_id: requestId,
response: {
behavior: result.behavior,
...(result.behavior === 'allow'
? { updatedInput: result.updatedInput }
: { message: result.message }),
},
},
}
logForDebugging(
`[RemoteSessionManager] Sending permission response: ${result.behavior}`,
)
this.websocket?.sendControlResponse(response)
}