import { EventEmitter } from 'node:events' import { randomUUID } from 'node:crypto' import { io, type Socket } from 'socket.io-client' import axios from 'axios' import type { ZodType } from 'zod' import { logger } from '@/ui/logger' import { backoff } from '@/utils/time' import { apiValidationError } from '@/utils/errorUtils' import { AsyncLock } from '@/utils/lock' import type { RawJSONLines } from '@/claude/types' import { configuration } from '@/configuration' import { AGENT_MESSAGE_PAYLOAD_TYPE } from "@hapi/protocol" import type { ClientToServerEvents, ServerToClientEvents, Update } from '@hapi/protocol' import { TerminalClosePayloadSchema, TerminalOpenPayloadSchema, TerminalResizePayloadSchema, TerminalWritePayloadSchema } from '@hapi/protocol' import type { AgentState, MessageContent, MessageMeta, Metadata, SessionCollaborationMode, Session, SessionModel, SessionPermissionMode, UserMessage } from './types' import { AgentStateSchema, CliMessagesResponseSchema, MetadataSchema, UserMessageSchema } from './types' import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' import { cleanupUploadDir } from '../modules/common/handlers/uploads' import { TerminalManager } from '@/terminal/TerminalManager' import { applyVersionedAck } from './versionedUpdate' /** * XML tags that Claude Code injects as `type:'user'` messages. * These are internal bookkeeping, not text the human actually typed. */ const SYSTEM_INJECTION_PREFIXES = [ '', '', '', '', ] /** * Returns true if a JSONL message should be classified as a user-role message * (i.e., text typed by a real human) rather than an agent-role message. * * Claude Code injects system messages (task notifications, command caveats, …) * into the JSONL log as `type:'user'` entries so the model sees them in * context. All metadata fields (`userType`, `isMeta`, …) are identical to * genuine user messages, so the only reliable signal is the message content * itself: injected messages always start with a well-known XML tag. */ export function isExternalUserMessage(body: RawJSONLines): body is Extract & { message: { content: string } } { if (body.type !== 'user') return false if (typeof body.message.content !== 'string') return false if (body.isSidechain === true) return false if (body.isMeta === true) return false const trimmed = body.message.content.trimStart() for (const prefix of SYSTEM_INJECTION_PREFIXES) { if (trimmed.startsWith(prefix)) return false } return true } export class ApiSessionClient extends EventEmitter { private readonly token: string readonly sessionId: string private metadata: Metadata | null private metadataVersion: number private agentState: AgentState | null private agentStateVersion: number private readonly socket: Socket private pendingMessages: UserMessage[] = [] private pendingMessageCallback: ((message: UserMessage) => void) | null = null private lastSeenMessageSeq: number | null = null private backfillInFlight: Promise | null = null private needsBackfill = false private hasConnectedOnce = false readonly rpcHandlerManager: RpcHandlerManager private readonly terminalManager: TerminalManager private agentStateLock = new AsyncLock() private metadataLock = new AsyncLock() constructor(token: string, session: Session) { super() this.token = token this.sessionId = session.id this.metadata = session.metadata this.metadataVersion = session.metadataVersion this.agentState = session.agentState this.agentStateVersion = session.agentStateVersion this.rpcHandlerManager = new RpcHandlerManager({ scopePrefix: this.sessionId, logger: (msg, data) => logger.debug(msg, data) }) if (this.metadata?.path) { registerCommonHandlers(this.rpcHandlerManager, this.metadata.path) } this.socket = io(`${configuration.apiUrl}/cli`, { auth: { token: this.token, clientType: 'session-scoped' as const, sessionId: this.sessionId }, path: '/socket.io/', reconnection: true, reconnectionAttempts: Infinity, reconnectionDelay: 1000, reconnectionDelayMax: 5000, transports: ['websocket'], autoConnect: false }) this.terminalManager = new TerminalManager({ sessionId: this.sessionId, getSessionPath: () => this.metadata?.path ?? null, onReady: (payload) => this.socket.emit('terminal:ready', payload), onOutput: (payload) => this.socket.emit('terminal:output', payload), onExit: (payload) => this.socket.emit('terminal:exit', payload), onError: (payload) => this.socket.emit('terminal:error', payload) }) this.socket.on('connect', () => { logger.debug('Socket connected successfully') this.rpcHandlerManager.onSocketConnect(this.socket) if (this.hasConnectedOnce) { this.needsBackfill = true } void this.backfillIfNeeded() this.hasConnectedOnce = true this.socket.emit('session-alive', { sid: this.sessionId, time: Date.now(), thinking: false }) }) this.socket.on('rpc-request', async (data: { method: string; params: string }, callback: (response: string) => void) => { callback(await this.rpcHandlerManager.handleRequest(data)) }) this.socket.on('disconnect', (reason) => { logger.debug('[API] Socket disconnected:', reason) this.rpcHandlerManager.onSocketDisconnect() this.terminalManager.closeAll() if (this.hasConnectedOnce) { this.needsBackfill = true } }) this.socket.on('connect_error', (error) => { logger.debug('[API] Socket connection error:', error) this.rpcHandlerManager.onSocketDisconnect() }) this.socket.on('error', (payload) => { logger.debug('[API] Socket error:', payload) }) const handleTerminalEvent = ( schema: ZodType, handler: (payload: T) => void ) => (data: unknown) => { const parsed = schema.safeParse(data) if (!parsed.success) { return } if (parsed.data.sessionId !== this.sessionId) { return } handler(parsed.data) } this.socket.on('terminal:open', handleTerminalEvent(TerminalOpenPayloadSchema, (payload) => { this.terminalManager.create(payload.terminalId, payload.cols, payload.rows) })) this.socket.on('terminal:write', handleTerminalEvent(TerminalWritePayloadSchema, (payload) => { this.terminalManager.write(payload.terminalId, payload.data) })) this.socket.on('terminal:resize', handleTerminalEvent(TerminalResizePayloadSchema, (payload) => { this.terminalManager.resize(payload.terminalId, payload.cols, payload.rows) })) this.socket.on('terminal:close', handleTerminalEvent(TerminalClosePayloadSchema, (payload) => { this.terminalManager.close(payload.terminalId) })) this.socket.on('update', (data: Update) => { try { if (!data.body) return if (data.body.t === 'new-message') { this.handleIncomingMessage(data.body.message) return } if (data.body.t === 'update-session') { if (data.body.metadata && data.body.metadata.version > this.metadataVersion) { const parsed = MetadataSchema.safeParse(data.body.metadata.value) if (parsed.success) { this.metadata = parsed.data } else { logger.debug('[API] Ignoring invalid metadata update', { version: data.body.metadata.version }) } this.metadataVersion = data.body.metadata.version } if (data.body.agentState && data.body.agentState.version > this.agentStateVersion) { const next = data.body.agentState.value if (next == null) { this.agentState = null } else { const parsed = AgentStateSchema.safeParse(next) if (parsed.success) { this.agentState = parsed.data } else { logger.debug('[API] Ignoring invalid agentState update', { version: data.body.agentState.version }) } } this.agentStateVersion = data.body.agentState.version } return } this.emit('message', data.body) } catch (error) { logger.debug('[SOCKET] [UPDATE] [ERROR] Error handling update', { error }) } }) this.socket.connect() } onUserMessage(callback: (data: UserMessage) => void): void { this.pendingMessageCallback = callback while (this.pendingMessages.length > 0) { callback(this.pendingMessages.shift()!) } } private enqueueUserMessage(message: UserMessage): void { if (this.pendingMessageCallback) { this.pendingMessageCallback(message) } else { this.pendingMessages.push(message) } } private handleIncomingMessage(message: { seq?: number; content: unknown }): void { const seq = typeof message.seq === 'number' ? message.seq : null if (seq !== null) { if (this.lastSeenMessageSeq !== null && seq <= this.lastSeenMessageSeq) { return } this.lastSeenMessageSeq = seq } const userResult = UserMessageSchema.safeParse(message.content) if (userResult.success) { this.enqueueUserMessage(userResult.data) return } this.emit('message', message.content) } private async backfillIfNeeded(): Promise { if (!this.needsBackfill) { return } try { await this.backfillMessages() this.needsBackfill = false } catch (error) { logger.debug('[API] Backfill failed', error) this.needsBackfill = true } } private async backfillMessages(): Promise { if (this.backfillInFlight) { await this.backfillInFlight return } const startSeq = this.lastSeenMessageSeq if (startSeq === null) { logger.debug('[API] Skipping backfill because no last-seen message sequence is available') return } const limit = 200 const run = async () => { let cursor = startSeq while (true) { const response = await axios.get( `${configuration.apiUrl}/cli/sessions/${encodeURIComponent(this.sessionId)}/messages`, { params: { afterSeq: cursor, limit }, headers: { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' }, timeout: 15_000 } ) const parsed = CliMessagesResponseSchema.safeParse(response.data) if (!parsed.success) { throw apiValidationError('Invalid /cli/sessions/:id/messages response', response) } const messages = parsed.data.messages if (messages.length === 0) { break } let maxSeq = cursor for (const message of messages) { if (typeof message.seq === 'number') { if (message.seq > maxSeq) { maxSeq = message.seq } } this.handleIncomingMessage(message) } const observedSeq = this.lastSeenMessageSeq ?? maxSeq const nextCursor = Math.max(maxSeq, observedSeq) if (nextCursor <= cursor) { logger.debug('[API] Backfill stopped due to non-advancing cursor', { cursor, maxSeq, observedSeq }) break } cursor = nextCursor if (messages.length < limit) { break } } } this.backfillInFlight = run().finally(() => { this.backfillInFlight = null }) await this.backfillInFlight } sendClaudeSessionMessage(body: RawJSONLines): void { let content: MessageContent if (isExternalUserMessage(body)) { content = { role: 'user', content: { type: 'text', text: body.message.content }, meta: { sentFrom: 'cli' } } } else { content = { role: 'agent', content: { type: 'output', data: body }, meta: { sentFrom: 'cli' } } } this.socket.emit('message', { sid: this.sessionId, message: content }) if (body.type === 'summary' && 'summary' in body && 'leafUuid' in body) { this.updateMetadata((metadata) => ({ ...metadata, summary: { text: body.summary, updatedAt: Date.now() } })) } } sendUserMessage(text: string, meta?: MessageMeta): void { if (!text) { return } const content: MessageContent = { role: 'user', content: { type: 'text', text }, meta: { sentFrom: 'cli', ...(meta ?? {}) } } this.socket.emit('message', { sid: this.sessionId, message: content }) } sendAgentMessage(body: unknown): void { const content = { role: 'agent', content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, data: body }, meta: { sentFrom: 'cli' } } this.socket.emit('message', { sid: this.sessionId, message: content }) } sendSessionEvent(event: { type: 'switch' mode: 'local' | 'remote' } | { type: 'message' message: string } | { type: 'permission-mode-changed' mode: SessionPermissionMode } | { type: 'ready' }, id?: string): void { const content = { role: 'agent', content: { id: id ?? randomUUID(), type: 'event', data: event } } this.socket.emit('message', { sid: this.sessionId, message: content }) } keepAlive( thinking: boolean, mode: 'local' | 'remote', runtime?: { permissionMode?: SessionPermissionMode model?: SessionModel modelReasoningEffort?: string | null effort?: string | null collaborationMode?: SessionCollaborationMode } ): void { this.socket.volatile.emit('session-alive', { sid: this.sessionId, time: Date.now(), thinking, mode, ...(runtime ?? {}) }) } sendSessionDeath(): void { void cleanupUploadDir(this.sessionId) this.socket.emit('session-end', { sid: this.sessionId, time: Date.now() }) } updateMetadata(handler: (metadata: Metadata) => Metadata): void { this.metadataLock.inLock(async () => { await backoff(async () => { const current = this.metadata ?? ({} as Metadata) const updated = handler(current) const answer = await this.socket.emitWithAck('update-metadata', { sid: this.sessionId, expectedVersion: this.metadataVersion, metadata: updated }) as unknown applyVersionedAck(answer, { valueKey: 'metadata', parseValue: (value) => { const parsed = MetadataSchema.safeParse(value) return parsed.success ? parsed.data : null }, applyValue: (value) => { this.metadata = value }, applyVersion: (version) => { this.metadataVersion = version }, logInvalidValue: (context, version) => { const suffix = context === 'success' ? 'ack' : 'version-mismatch ack' logger.debug(`[API] Ignoring invalid metadata value from ${suffix}`, { version }) }, invalidResponseMessage: 'Invalid update-metadata response', errorMessage: 'Metadata update failed', versionMismatchMessage: 'Metadata version mismatch' }) }) }) } updateAgentState(handler: (state: AgentState) => AgentState): void { this.agentStateLock.inLock(async () => { await backoff(async () => { const current = this.agentState ?? ({} as AgentState) const updated = handler(current) const answer = await this.socket.emitWithAck('update-state', { sid: this.sessionId, expectedVersion: this.agentStateVersion, agentState: updated }) as unknown applyVersionedAck(answer, { valueKey: 'agentState', parseValue: (value) => { const parsed = AgentStateSchema.safeParse(value) return parsed.success ? parsed.data : null }, applyValue: (value) => { this.agentState = value }, applyVersion: (version) => { this.agentStateVersion = version }, logInvalidValue: (context, version) => { const suffix = context === 'success' ? 'ack' : 'version-mismatch ack' logger.debug(`[API] Ignoring invalid agentState value from ${suffix}`, { version }) }, invalidResponseMessage: 'Invalid update-state response', errorMessage: 'Agent state update failed', versionMismatchMessage: 'Agent state version mismatch' }) }) }) } private async waitForConnected(timeoutMs: number): Promise { if (this.socket.connected) { return true } this.socket.connect() return await new Promise((resolve) => { let settled = false const cleanup = () => { this.socket.off('connect', onConnect) clearTimeout(timeout) } const onConnect = () => { if (settled) return settled = true cleanup() resolve(true) } const timeout = setTimeout(() => { if (settled) return settled = true cleanup() resolve(false) }, Math.max(0, timeoutMs)) this.socket.on('connect', onConnect) }) } private async drainLock(lock: AsyncLock, timeoutMs: number): Promise { if (timeoutMs <= 0) { return false } return await new Promise((resolve) => { let settled = false let timeout: ReturnType | null = null const finish = (value: boolean) => { if (settled) return settled = true if (timeout) { clearTimeout(timeout) } resolve(value) } timeout = setTimeout(() => finish(false), timeoutMs) lock.inLock(async () => { }) .then(() => finish(true)) .catch(() => finish(false)) }) } async flush(options?: { timeoutMs?: number }): Promise { const deadlineMs = Date.now() + (options?.timeoutMs ?? 5_000) const remainingMs = () => Math.max(0, deadlineMs - Date.now()) await this.drainLock(this.metadataLock, remainingMs()) await this.drainLock(this.agentStateLock, remainingMs()) if (remainingMs() === 0) { return } const connected = await this.waitForConnected(remainingMs()) if (!connected) { return } const pingTimeoutMs = remainingMs() if (pingTimeoutMs === 0) { return } try { await this.socket.timeout(pingTimeoutMs).emitWithAck('ping') } catch { // best effort } } close(): void { this.rpcHandlerManager.onSocketDisconnect() this.terminalManager.closeAll() this.socket.disconnect() } }