init claude-code
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type {
|
||||
SDKControlCancelRequest,
|
||||
SDKControlPermissionRequest,
|
||||
SDKControlRequest,
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import {
|
||||
type RemoteMessageContent,
|
||||
sendEventToRemoteSession,
|
||||
} from '../utils/teleport/api.js'
|
||||
import {
|
||||
SessionsWebSocket,
|
||||
type SessionsWebSocketCallbacks,
|
||||
} from './SessionsWebSocket.js'
|
||||
|
||||
/**
|
||||
* Type guard to check if a message is an SDKMessage (not a control message)
|
||||
*/
|
||||
function isSDKMessage(
|
||||
message:
|
||||
| SDKMessage
|
||||
| SDKControlRequest
|
||||
| SDKControlResponse
|
||||
| SDKControlCancelRequest,
|
||||
): message is SDKMessage {
|
||||
return (
|
||||
message.type !== 'control_request' &&
|
||||
message.type !== 'control_response' &&
|
||||
message.type !== 'control_cancel_request'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple permission response for remote sessions.
|
||||
* This is a simplified version of PermissionResult for CCR communication.
|
||||
*/
|
||||
export type RemotePermissionResponse =
|
||||
| {
|
||||
behavior: 'allow'
|
||||
updatedInput: Record<string, unknown>
|
||||
}
|
||||
| {
|
||||
behavior: 'deny'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type RemoteSessionConfig = {
|
||||
sessionId: string
|
||||
getAccessToken: () => string
|
||||
orgUuid: string
|
||||
/** True if session was created with an initial prompt that's being processed */
|
||||
hasInitialPrompt?: boolean
|
||||
/**
|
||||
* When true, this client is a pure viewer. Ctrl+C/Escape do NOT send
|
||||
* interrupt to the remote agent; 60s reconnect timeout is disabled;
|
||||
* session title is never updated. Used by `claude assistant`.
|
||||
*/
|
||||
viewerOnly?: boolean
|
||||
}
|
||||
|
||||
export type RemoteSessionCallbacks = {
|
||||
/** Called when an SDKMessage is received from the session */
|
||||
onMessage: (message: SDKMessage) => void
|
||||
/** Called when a permission request is received from CCR */
|
||||
onPermissionRequest: (
|
||||
request: SDKControlPermissionRequest,
|
||||
requestId: string,
|
||||
) => void
|
||||
/** Called when the server cancels a pending permission request */
|
||||
onPermissionCancelled?: (
|
||||
requestId: string,
|
||||
toolUseId: string | undefined,
|
||||
) => void
|
||||
/** Called when connection is established */
|
||||
onConnected?: () => void
|
||||
/** Called when connection is lost and cannot be restored */
|
||||
onDisconnected?: () => void
|
||||
/** Called on transient WS drop while reconnect backoff is in progress */
|
||||
onReconnecting?: () => void
|
||||
/** Called on error */
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages a remote CCR session.
|
||||
*
|
||||
* Coordinates:
|
||||
* - WebSocket subscription for receiving messages from CCR
|
||||
* - HTTP POST for sending user messages to CCR
|
||||
* - Permission request/response flow
|
||||
*/
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from WebSocket
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle control requests from CCR (e.g., permission requests)
|
||||
*/
|
||||
private handleControlRequest(request: SDKControlRequest): void {
|
||||
const { request_id, request: inner } = request
|
||||
|
||||
if (inner.subtype === 'can_use_tool') {
|
||||
logForDebugging(
|
||||
`[RemoteSessionManager] Permission request for tool: ${inner.tool_name}`,
|
||||
)
|
||||
this.pendingPermissionRequests.set(request_id, inner)
|
||||
this.callbacks.onPermissionRequest(inner, request_id)
|
||||
} else {
|
||||
// Send an error response for unrecognized subtypes so the server
|
||||
// doesn't hang waiting for a reply that never comes.
|
||||
logForDebugging(
|
||||
`[RemoteSessionManager] Unsupported control request subtype: ${inner.subtype}`,
|
||||
)
|
||||
const response: SDKControlResponse = {
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: 'error',
|
||||
request_id,
|
||||
error: `Unsupported control request subtype: ${inner.subtype}`,
|
||||
},
|
||||
}
|
||||
this.websocket?.sendControlResponse(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a user message to the remote session via HTTP POST
|
||||
*/
|
||||
async sendMessage(
|
||||
content: RemoteMessageContent,
|
||||
opts?: { uuid?: string },
|
||||
): Promise<boolean> {
|
||||
logForDebugging(
|
||||
`[RemoteSessionManager] Sending message to session ${this.config.sessionId}`,
|
||||
)
|
||||
|
||||
const success = await sendEventToRemoteSession(
|
||||
this.config.sessionId,
|
||||
content,
|
||||
opts,
|
||||
)
|
||||
|
||||
if (!success) {
|
||||
logError(
|
||||
new Error(
|
||||
`[RemoteSessionManager] Failed to send message to session ${this.config.sessionId}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to a permission request from CCR
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected to the remote session
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.websocket?.isConnected() ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an interrupt signal to cancel the current request on the remote session
|
||||
*/
|
||||
cancelSession(): void {
|
||||
logForDebugging('[RemoteSessionManager] Sending interrupt signal')
|
||||
this.websocket?.sendControlRequest({ subtype: 'interrupt' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the session ID
|
||||
*/
|
||||
getSessionId(): string {
|
||||
return this.config.sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the remote session
|
||||
*/
|
||||
disconnect(): void {
|
||||
logForDebugging('[RemoteSessionManager] Disconnecting')
|
||||
this.websocket?.close()
|
||||
this.websocket = null
|
||||
this.pendingPermissionRequests.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reconnect the WebSocket.
|
||||
* Useful when the subscription becomes stale after container shutdown.
|
||||
*/
|
||||
reconnect(): void {
|
||||
logForDebugging('[RemoteSessionManager] Reconnecting WebSocket')
|
||||
this.websocket?.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a remote session config from OAuth tokens
|
||||
*/
|
||||
export function createRemoteSessionConfig(
|
||||
sessionId: string,
|
||||
getAccessToken: () => string,
|
||||
orgUuid: string,
|
||||
hasInitialPrompt = false,
|
||||
viewerOnly = false,
|
||||
): RemoteSessionConfig {
|
||||
return {
|
||||
sessionId,
|
||||
getAccessToken,
|
||||
orgUuid,
|
||||
hasInitialPrompt,
|
||||
viewerOnly,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
||||
import type {
|
||||
SDKControlCancelRequest,
|
||||
SDKControlRequest,
|
||||
SDKControlRequestInner,
|
||||
SDKControlResponse,
|
||||
} from '../entrypoints/sdk/controlTypes.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { errorMessage } from '../utils/errors.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { getWebSocketTLSOptions } from '../utils/mtls.js'
|
||||
import { getWebSocketProxyAgent, getWebSocketProxyUrl } from '../utils/proxy.js'
|
||||
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
||||
|
||||
const RECONNECT_DELAY_MS = 2000
|
||||
const MAX_RECONNECT_ATTEMPTS = 5
|
||||
const PING_INTERVAL_MS = 30000
|
||||
|
||||
/**
|
||||
* Maximum retries for 4001 (session not found). During compaction the
|
||||
* server may briefly consider the session stale; a short retry window
|
||||
* lets the client recover without giving up permanently.
|
||||
*/
|
||||
const MAX_SESSION_NOT_FOUND_RETRIES = 3
|
||||
|
||||
/**
|
||||
* WebSocket close codes that indicate a permanent server-side rejection.
|
||||
* The client stops reconnecting immediately.
|
||||
* Note: 4001 (session not found) is handled separately with limited
|
||||
* retries since it can be transient during compaction.
|
||||
*/
|
||||
const PERMANENT_CLOSE_CODES = new Set([
|
||||
4003, // unauthorized
|
||||
])
|
||||
|
||||
type WebSocketState = 'connecting' | 'connected' | 'closed'
|
||||
|
||||
type SessionsMessage =
|
||||
| SDKMessage
|
||||
| SDKControlRequest
|
||||
| SDKControlResponse
|
||||
| SDKControlCancelRequest
|
||||
|
||||
function isSessionsMessage(value: unknown): value is SessionsMessage {
|
||||
if (typeof value !== 'object' || value === null || !('type' in value)) {
|
||||
return false
|
||||
}
|
||||
// Accept any message with a string `type` field. Downstream handlers
|
||||
// (sdkMessageAdapter, RemoteSessionManager) decide what to do with
|
||||
// unknown types. A hardcoded allowlist here would silently drop new
|
||||
// message types the backend starts sending before the client is updated.
|
||||
return typeof value.type === 'string'
|
||||
}
|
||||
|
||||
export type SessionsWebSocketCallbacks = {
|
||||
onMessage: (message: SessionsMessage) => void
|
||||
onClose?: () => void
|
||||
onError?: (error: Error) => void
|
||||
onConnected?: () => void
|
||||
/** Fired when a transient close is detected and a reconnect is scheduled.
|
||||
* onClose fires only for permanent close (server ended / attempts exhausted). */
|
||||
onReconnecting?: () => void
|
||||
}
|
||||
|
||||
// Common interface between globalThis.WebSocket and ws.WebSocket
|
||||
type WebSocketLike = {
|
||||
close(): void
|
||||
send(data: string): void
|
||||
ping?(): void // Bun & ws both support this
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket client for connecting to CCR sessions via /v1/sessions/ws/{id}/subscribe
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Connect to wss://api.anthropic.com/v1/sessions/ws/{sessionId}/subscribe?organization_uuid=...
|
||||
* 2. Send auth message: { type: 'auth', credential: { type: 'oauth', token: '...' } }
|
||||
* 3. Receive SDKMessage stream from the session
|
||||
*/
|
||||
export class SessionsWebSocket {
|
||||
private ws: WebSocketLike | null = null
|
||||
private state: WebSocketState = 'closed'
|
||||
private reconnectAttempts = 0
|
||||
private sessionNotFoundRetries = 0
|
||||
private pingInterval: NodeJS.Timeout | null = null
|
||||
private reconnectTimer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(
|
||||
private readonly sessionId: string,
|
||||
private readonly orgUuid: string,
|
||||
private readonly getAccessToken: () => string,
|
||||
private readonly callbacks: SessionsWebSocketCallbacks,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Connect to the sessions WebSocket endpoint
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.state === 'connecting') {
|
||||
logForDebugging('[SessionsWebSocket] Already connecting')
|
||||
return
|
||||
}
|
||||
|
||||
this.state = 'connecting'
|
||||
|
||||
const baseUrl = getOauthConfig().BASE_API_URL.replace('https://', 'wss://')
|
||||
const url = `${baseUrl}/v1/sessions/ws/${this.sessionId}/subscribe?organization_uuid=${this.orgUuid}`
|
||||
|
||||
logForDebugging(`[SessionsWebSocket] Connecting to ${url}`)
|
||||
|
||||
// Get fresh token for each connection attempt
|
||||
const accessToken = this.getAccessToken()
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'anthropic-version': '2023-06-01',
|
||||
}
|
||||
|
||||
if (typeof Bun !== 'undefined') {
|
||||
// Bun's WebSocket supports headers/proxy options but the DOM typings don't
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||
const ws = new globalThis.WebSocket(url, {
|
||||
headers,
|
||||
proxy: getWebSocketProxyUrl(url),
|
||||
tls: getWebSocketTLSOptions() || undefined,
|
||||
} as unknown as string[])
|
||||
this.ws = ws
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
logForDebugging(
|
||||
'[SessionsWebSocket] Connection opened, authenticated via headers',
|
||||
)
|
||||
this.state = 'connected'
|
||||
this.reconnectAttempts = 0
|
||||
this.sessionNotFoundRetries = 0
|
||||
this.startPingInterval()
|
||||
this.callbacks.onConnected?.()
|
||||
})
|
||||
|
||||
ws.addEventListener('message', (event: MessageEvent) => {
|
||||
const data =
|
||||
typeof event.data === 'string' ? event.data : String(event.data)
|
||||
this.handleMessage(data)
|
||||
})
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
const err = new Error('[SessionsWebSocket] WebSocket error')
|
||||
logError(err)
|
||||
this.callbacks.onError?.(err)
|
||||
})
|
||||
|
||||
// eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins
|
||||
ws.addEventListener('close', (event: CloseEvent) => {
|
||||
logForDebugging(
|
||||
`[SessionsWebSocket] Closed: code=${event.code} reason=${event.reason}`,
|
||||
)
|
||||
this.handleClose(event.code)
|
||||
})
|
||||
|
||||
ws.addEventListener('pong', () => {
|
||||
logForDebugging('[SessionsWebSocket] Pong received')
|
||||
})
|
||||
} else {
|
||||
const { default: WS } = await import('ws')
|
||||
const ws = new WS(url, {
|
||||
headers,
|
||||
agent: getWebSocketProxyAgent(url),
|
||||
...getWebSocketTLSOptions(),
|
||||
})
|
||||
this.ws = ws
|
||||
|
||||
ws.on('open', () => {
|
||||
logForDebugging(
|
||||
'[SessionsWebSocket] Connection opened, authenticated via headers',
|
||||
)
|
||||
// Auth is handled via headers, so we're immediately connected
|
||||
this.state = 'connected'
|
||||
this.reconnectAttempts = 0
|
||||
this.sessionNotFoundRetries = 0
|
||||
this.startPingInterval()
|
||||
this.callbacks.onConnected?.()
|
||||
})
|
||||
|
||||
ws.on('message', (data: Buffer) => {
|
||||
this.handleMessage(data.toString())
|
||||
})
|
||||
|
||||
ws.on('error', (err: Error) => {
|
||||
logError(new Error(`[SessionsWebSocket] Error: ${err.message}`))
|
||||
this.callbacks.onError?.(err)
|
||||
})
|
||||
|
||||
ws.on('close', (code: number, reason: Buffer) => {
|
||||
logForDebugging(
|
||||
`[SessionsWebSocket] Closed: code=${code} reason=${reason.toString()}`,
|
||||
)
|
||||
this.handleClose(code)
|
||||
})
|
||||
|
||||
ws.on('pong', () => {
|
||||
logForDebugging('[SessionsWebSocket] Pong received')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming WebSocket message
|
||||
*/
|
||||
private handleMessage(data: string): void {
|
||||
try {
|
||||
const message: unknown = jsonParse(data)
|
||||
|
||||
// Forward SDK messages to callback
|
||||
if (isSessionsMessage(message)) {
|
||||
this.callbacks.onMessage(message)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`[SessionsWebSocket] Ignoring message type: ${typeof message === 'object' && message !== null && 'type' in message ? String(message.type) : 'unknown'}`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logError(
|
||||
new Error(
|
||||
`[SessionsWebSocket] Failed to parse message: ${errorMessage(error)}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle WebSocket close
|
||||
*/
|
||||
private handleClose(closeCode: number): void {
|
||||
this.stopPingInterval()
|
||||
|
||||
if (this.state === 'closed') {
|
||||
return
|
||||
}
|
||||
|
||||
this.ws = null
|
||||
|
||||
const previousState = this.state
|
||||
this.state = 'closed'
|
||||
|
||||
// Permanent codes: stop reconnecting — server has definitively ended the session
|
||||
if (PERMANENT_CLOSE_CODES.has(closeCode)) {
|
||||
logForDebugging(
|
||||
`[SessionsWebSocket] Permanent close code ${closeCode}, not reconnecting`,
|
||||
)
|
||||
this.callbacks.onClose?.()
|
||||
return
|
||||
}
|
||||
|
||||
// 4001 (session not found) can be transient during compaction: the
|
||||
// server may briefly consider the session stale while the CLI worker
|
||||
// is busy with the compaction API call and not emitting events.
|
||||
if (closeCode === 4001) {
|
||||
this.sessionNotFoundRetries++
|
||||
if (this.sessionNotFoundRetries > MAX_SESSION_NOT_FOUND_RETRIES) {
|
||||
logForDebugging(
|
||||
`[SessionsWebSocket] 4001 retry budget exhausted (${MAX_SESSION_NOT_FOUND_RETRIES}), not reconnecting`,
|
||||
)
|
||||
this.callbacks.onClose?.()
|
||||
return
|
||||
}
|
||||
this.scheduleReconnect(
|
||||
RECONNECT_DELAY_MS * this.sessionNotFoundRetries,
|
||||
`4001 attempt ${this.sessionNotFoundRetries}/${MAX_SESSION_NOT_FOUND_RETRIES}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt reconnection if we were connected
|
||||
if (
|
||||
previousState === 'connected' &&
|
||||
this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS
|
||||
) {
|
||||
this.reconnectAttempts++
|
||||
this.scheduleReconnect(
|
||||
RECONNECT_DELAY_MS,
|
||||
`attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`,
|
||||
)
|
||||
} else {
|
||||
logForDebugging('[SessionsWebSocket] Not reconnecting')
|
||||
this.callbacks.onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(delay: number, label: string): void {
|
||||
this.callbacks.onReconnecting?.()
|
||||
logForDebugging(
|
||||
`[SessionsWebSocket] Scheduling reconnect (${label}) in ${delay}ms`,
|
||||
)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
void this.connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
private startPingInterval(): void {
|
||||
this.stopPingInterval()
|
||||
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (this.ws && this.state === 'connected') {
|
||||
try {
|
||||
this.ws.ping?.()
|
||||
} catch {
|
||||
// Ignore ping errors, close handler will deal with connection issues
|
||||
}
|
||||
}
|
||||
}, PING_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ping interval
|
||||
*/
|
||||
private stopPingInterval(): void {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval)
|
||||
this.pingInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a control response back to the session
|
||||
*/
|
||||
sendControlResponse(response: SDKControlResponse): void {
|
||||
if (!this.ws || this.state !== 'connected') {
|
||||
logError(new Error('[SessionsWebSocket] Cannot send: not connected'))
|
||||
return
|
||||
}
|
||||
|
||||
logForDebugging('[SessionsWebSocket] Sending control response')
|
||||
this.ws.send(jsonStringify(response))
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a control request to the session (e.g., interrupt)
|
||||
*/
|
||||
sendControlRequest(request: SDKControlRequestInner): void {
|
||||
if (!this.ws || this.state !== 'connected') {
|
||||
logError(new Error('[SessionsWebSocket] Cannot send: not connected'))
|
||||
return
|
||||
}
|
||||
|
||||
const controlRequest: SDKControlRequest = {
|
||||
type: 'control_request',
|
||||
request_id: randomUUID(),
|
||||
request,
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`[SessionsWebSocket] Sending control request: ${request.subtype}`,
|
||||
)
|
||||
this.ws.send(jsonStringify(controlRequest))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.state === 'connected'
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the WebSocket connection
|
||||
*/
|
||||
close(): void {
|
||||
logForDebugging('[SessionsWebSocket] Closing connection')
|
||||
this.state = 'closed'
|
||||
this.stopPingInterval()
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
// Null out event handlers to prevent race conditions during reconnect.
|
||||
// Under Bun (native WebSocket), onX handlers are the clean way to detach.
|
||||
// Under Node (ws package), the listeners were attached with .on() in connect(),
|
||||
// but since we're about to close and null out this.ws, no cleanup is needed.
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reconnect - closes existing connection and establishes a new one.
|
||||
* Useful when the subscription becomes stale (e.g., after container shutdown).
|
||||
*/
|
||||
reconnect(): void {
|
||||
logForDebugging('[SessionsWebSocket] Force reconnecting')
|
||||
this.reconnectAttempts = 0
|
||||
this.sessionNotFoundRetries = 0
|
||||
this.close()
|
||||
// Small delay before reconnecting (stored in reconnectTimer so it can be cancelled)
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
void this.connect()
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { SDKControlPermissionRequest } from '../entrypoints/sdk/controlTypes.js'
|
||||
import type { Tool } from '../Tool.js'
|
||||
import type { AssistantMessage } from '../types/message.js'
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
|
||||
/**
|
||||
* Create a synthetic AssistantMessage for remote permission requests.
|
||||
* The ToolUseConfirm type requires an AssistantMessage, but in remote mode
|
||||
* we don't have a real one — the tool use runs on the CCR container.
|
||||
*/
|
||||
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,
|
||||
},
|
||||
],
|
||||
model: '',
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
container: null,
|
||||
context_management: null,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
} as AssistantMessage['message'],
|
||||
requestId: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal Tool stub for tools that aren't loaded locally.
|
||||
* This happens when the remote CCR has tools (e.g., MCP tools) that the
|
||||
* local CLI doesn't know about. The stub routes to FallbackPermissionRequest.
|
||||
*/
|
||||
export function createToolStub(toolName: string): Tool {
|
||||
return {
|
||||
name: toolName,
|
||||
inputSchema: {} as Tool['inputSchema'],
|
||||
isEnabled: () => true,
|
||||
userFacingName: () => toolName,
|
||||
renderToolUseMessage: (input: Record<string, unknown>) => {
|
||||
const entries = Object.entries(input)
|
||||
if (entries.length === 0) return ''
|
||||
return entries
|
||||
.slice(0, 3)
|
||||
.map(([key, value]) => {
|
||||
const valueStr =
|
||||
typeof value === 'string' ? value : jsonStringify(value)
|
||||
return `${key}: ${valueStr}`
|
||||
})
|
||||
.join(', ')
|
||||
},
|
||||
call: async () => ({ data: '' }),
|
||||
description: async () => '',
|
||||
prompt: () => '',
|
||||
isReadOnly: () => false,
|
||||
isMcp: false,
|
||||
needsPermissions: () => true,
|
||||
} as unknown as Tool
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import type {
|
||||
SDKAssistantMessage,
|
||||
SDKCompactBoundaryMessage,
|
||||
SDKMessage,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKResultMessage,
|
||||
SDKStatusMessage,
|
||||
SDKSystemMessage,
|
||||
SDKToolProgressMessage,
|
||||
} from '../entrypoints/agentSdkTypes.js'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Message,
|
||||
StreamEvent,
|
||||
SystemMessage,
|
||||
} from '../types/message.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { fromSDKCompactMetadata } from '../utils/messages/mappers.js'
|
||||
import { createUserMessage } from '../utils/messages.js'
|
||||
|
||||
/**
|
||||
* Converts SDKMessage from CCR to REPL Message types.
|
||||
*
|
||||
* The CCR backend sends SDK-format messages via WebSocket. The REPL expects
|
||||
* internal Message types for rendering. This adapter bridges the two.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert an SDKAssistantMessage to an AssistantMessage
|
||||
*/
|
||||
function convertAssistantMessage(msg: SDKAssistantMessage): AssistantMessage {
|
||||
return {
|
||||
type: 'assistant',
|
||||
message: msg.message,
|
||||
uuid: msg.uuid,
|
||||
requestId: undefined,
|
||||
timestamp: new Date().toISOString(),
|
||||
error: msg.error,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKPartialAssistantMessage (streaming) to a StreamEvent
|
||||
*/
|
||||
function convertStreamEvent(msg: SDKPartialAssistantMessage): StreamEvent {
|
||||
return {
|
||||
type: 'stream_event',
|
||||
event: msg.event,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKResultMessage to a SystemMessage
|
||||
*/
|
||||
function convertResultMessage(msg: SDKResultMessage): SystemMessage {
|
||||
const isError = msg.subtype !== 'success'
|
||||
const content = isError
|
||||
? msg.errors?.join(', ') || 'Unknown error'
|
||||
: 'Session completed successfully'
|
||||
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'informational',
|
||||
content,
|
||||
level: isError ? 'warning' : 'info',
|
||||
uuid: msg.uuid,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKSystemMessage (init) to a SystemMessage
|
||||
*/
|
||||
function convertInitMessage(msg: SDKSystemMessage): SystemMessage {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'informational',
|
||||
content: `Remote session initialized (model: ${msg.model})`,
|
||||
level: 'info',
|
||||
uuid: msg.uuid,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKStatusMessage to a SystemMessage
|
||||
*/
|
||||
function convertStatusMessage(msg: SDKStatusMessage): SystemMessage | null {
|
||||
if (!msg.status) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'informational',
|
||||
content:
|
||||
msg.status === 'compacting'
|
||||
? 'Compacting conversation…'
|
||||
: `Status: ${msg.status}`,
|
||||
level: 'info',
|
||||
uuid: msg.uuid,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKToolProgressMessage to a SystemMessage.
|
||||
* We use a system message instead of ProgressMessage since the Progress type
|
||||
* is a complex union that requires tool-specific data we don't have from CCR.
|
||||
*/
|
||||
function convertToolProgressMessage(
|
||||
msg: SDKToolProgressMessage,
|
||||
): SystemMessage {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'informational',
|
||||
content: `Tool ${msg.tool_name} running for ${msg.elapsed_time_seconds}s…`,
|
||||
level: 'info',
|
||||
uuid: msg.uuid,
|
||||
timestamp: new Date().toISOString(),
|
||||
toolUseID: msg.tool_use_id,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKCompactBoundaryMessage to a SystemMessage
|
||||
*/
|
||||
function convertCompactBoundaryMessage(
|
||||
msg: SDKCompactBoundaryMessage,
|
||||
): SystemMessage {
|
||||
return {
|
||||
type: 'system',
|
||||
subtype: 'compact_boundary',
|
||||
content: 'Conversation compacted',
|
||||
level: 'info',
|
||||
uuid: msg.uuid,
|
||||
timestamp: new Date().toISOString(),
|
||||
compactMetadata: fromSDKCompactMetadata(msg.compact_metadata),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of converting an SDKMessage
|
||||
*/
|
||||
export type ConvertedMessage =
|
||||
| { type: 'message'; message: Message }
|
||||
| { type: 'stream_event'; event: StreamEvent }
|
||||
| { type: 'ignored' }
|
||||
|
||||
type ConvertOptions = {
|
||||
/** Convert user messages containing tool_result content blocks into UserMessages.
|
||||
* Used by direct connect mode where tool results come from the remote server
|
||||
* and need to be rendered locally. CCR mode ignores user messages since they
|
||||
* are handled differently. */
|
||||
convertToolResults?: boolean
|
||||
/**
|
||||
* Convert user text messages into UserMessages for display. Used when
|
||||
* converting historical events where user-typed messages need to be shown.
|
||||
* In live WS mode these are already added locally by the REPL so they're
|
||||
* ignored by default.
|
||||
*/
|
||||
convertUserTextMessages?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKMessage to REPL message format
|
||||
*/
|
||||
export function convertSDKMessage(
|
||||
msg: SDKMessage,
|
||||
opts?: ConvertOptions,
|
||||
): ConvertedMessage {
|
||||
switch (msg.type) {
|
||||
case 'assistant':
|
||||
return { type: 'message', message: convertAssistantMessage(msg) }
|
||||
|
||||
case 'user': {
|
||||
const content = msg.message?.content
|
||||
// Tool result messages from the remote server need to be converted so
|
||||
// they render and collapse like local tool results. Detect via content
|
||||
// shape (tool_result blocks) — parent_tool_use_id is NOT reliable: the
|
||||
// agent-side normalizeMessage() hardcodes it to null for top-level
|
||||
// tool results, so it can't distinguish tool results from prompt echoes.
|
||||
const isToolResult =
|
||||
Array.isArray(content) && content.some(b => b.type === 'tool_result')
|
||||
if (opts?.convertToolResults && isToolResult) {
|
||||
return {
|
||||
type: 'message',
|
||||
message: createUserMessage({
|
||||
content,
|
||||
toolUseResult: msg.tool_use_result,
|
||||
uuid: msg.uuid,
|
||||
timestamp: msg.timestamp,
|
||||
}),
|
||||
}
|
||||
}
|
||||
// When converting historical events, user-typed messages need to be
|
||||
// rendered (they weren't added locally by the REPL). Skip tool_results
|
||||
// here — already handled above.
|
||||
if (opts?.convertUserTextMessages && !isToolResult) {
|
||||
if (typeof content === 'string' || Array.isArray(content)) {
|
||||
return {
|
||||
type: 'message',
|
||||
message: createUserMessage({
|
||||
content,
|
||||
toolUseResult: msg.tool_use_result,
|
||||
uuid: msg.uuid,
|
||||
timestamp: msg.timestamp,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
// User-typed messages (string content) are already added locally by REPL.
|
||||
// In CCR mode, all user messages are ignored (tool results handled differently).
|
||||
return { type: 'ignored' }
|
||||
}
|
||||
|
||||
case 'stream_event':
|
||||
return { type: 'stream_event', event: convertStreamEvent(msg) }
|
||||
|
||||
case 'result':
|
||||
// Only show result messages for errors. Success results are noise
|
||||
// in multi-turn sessions (isLoading=false is sufficient signal).
|
||||
if (msg.subtype !== 'success') {
|
||||
return { type: 'message', message: convertResultMessage(msg) }
|
||||
}
|
||||
return { type: 'ignored' }
|
||||
|
||||
case 'system':
|
||||
if (msg.subtype === 'init') {
|
||||
return { type: 'message', message: convertInitMessage(msg) }
|
||||
}
|
||||
if (msg.subtype === 'status') {
|
||||
const statusMsg = convertStatusMessage(msg)
|
||||
return statusMsg
|
||||
? { type: 'message', message: statusMsg }
|
||||
: { type: 'ignored' }
|
||||
}
|
||||
if (msg.subtype === 'compact_boundary') {
|
||||
return {
|
||||
type: 'message',
|
||||
message: convertCompactBoundaryMessage(msg),
|
||||
}
|
||||
}
|
||||
// hook_response and other subtypes
|
||||
logForDebugging(
|
||||
`[sdkMessageAdapter] Ignoring system message subtype: ${msg.subtype}`,
|
||||
)
|
||||
return { type: 'ignored' }
|
||||
|
||||
case 'tool_progress':
|
||||
return { type: 'message', message: convertToolProgressMessage(msg) }
|
||||
|
||||
case 'auth_status':
|
||||
// Auth status is handled separately, not converted to a display message
|
||||
logForDebugging('[sdkMessageAdapter] Ignoring auth_status message')
|
||||
return { type: 'ignored' }
|
||||
|
||||
case 'tool_use_summary':
|
||||
// Tool use summaries are SDK-only events, not displayed in REPL
|
||||
logForDebugging('[sdkMessageAdapter] Ignoring tool_use_summary message')
|
||||
return { type: 'ignored' }
|
||||
|
||||
case 'rate_limit_event':
|
||||
// Rate limit events are SDK-only events, not displayed in REPL
|
||||
logForDebugging('[sdkMessageAdapter] Ignoring rate_limit_event message')
|
||||
return { type: 'ignored' }
|
||||
|
||||
default: {
|
||||
// Gracefully ignore unknown message types. The backend may send new
|
||||
// types before the client is updated; logging helps with debugging
|
||||
// without crashing or losing the session.
|
||||
logForDebugging(
|
||||
`[sdkMessageAdapter] Unknown message type: ${(msg as { type: string }).type}`,
|
||||
)
|
||||
return { type: 'ignored' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SDKMessage indicates the session has ended
|
||||
*/
|
||||
export function isSessionEndMessage(msg: SDKMessage): boolean {
|
||||
return msg.type === 'result'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SDKResultMessage indicates success
|
||||
*/
|
||||
export function isSuccessResult(msg: SDKResultMessage): boolean {
|
||||
return msg.subtype === 'success'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the result text from a successful SDKResultMessage
|
||||
*/
|
||||
export function getResultText(msg: SDKResultMessage): string | null {
|
||||
if (msg.subtype === 'success') {
|
||||
return msg.result
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user