init claude-code

This commit is contained in:
2026-04-01 17:32:37 +02:00
commit 73b208c009
1902 changed files with 513237 additions and 0 deletions
+343
View File
@@ -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,
}
}
+404
View File
@@ -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)
}
}
+78
View File
@@ -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
}
+302
View File
@@ -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
}