import { randomUUID } from 'node:crypto' import React from 'react' import { logger } from '@/ui/logger' import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge' import { convertAgentMessage } from '@/agent/messageConverter' import type { AgentMessage, McpServerStdio, PromptContent } from '@/agent/types' import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase' import { GrokDisplay } from '@/ui/ink/GrokDisplay' import type { GrokSession } from './session' import type { PermissionMode } from './types' import { createGrokBackend, formatGrokError, isGrokBuildAuxiliaryQuotaError } from './utils/grokBackend' import { GrokPermissionHandler } from './utils/permissionHandler' import { RPC_METHODS } from '@hapi/protocol/rpcMethods' import { GROK_TITLE_INSTRUCTION } from './utils/systemPrompt' import { GrokConversationHistory } from './conversationHistory' import { isObject } from '@hapi/protocol' const PLAN_MODE_INSTRUCTION = 'Work in plan-only mode. Analyze and propose a plan, but do not execute commands or modify files.' type GrokRemoteLauncherOptions = { model?: string effort?: string onModelRollback?: (model: string | null) => void onEffortRollback?: (effort: string | null) => void onPermissionModeRollback?: (mode: PermissionMode) => void onConfigDiscovered?: (config: { model: string | null; effort: string | null }) => void } class GrokRemoteLauncher extends RemoteLauncherBase { private backend: ReturnType | null = null private permissionHandler: GrokPermissionHandler | null = null private happyServer: { stop: () => void } | null = null private abortController = new AbortController() private displayPermissionMode: PermissionMode | null = null private readonly lastDisplayedToolCall = new Map() private currentBackendModel: string | null = null private defaultBackendModel: string | null = null private currentBackendEffort: string | null = null private defaultBackendEffort: string | null = null private currentBackendPermissionMode: 'default' | 'auto' | null = null private instructionsSent = false private readonly conversationHistory = new GrokConversationHistory(() => this.backend) constructor( private readonly session: GrokSession, private readonly opts: GrokRemoteLauncherOptions ) { super(process.env.DEBUG ? session.logPath : undefined) } public async launch(): Promise { return this.start({ onExit: () => this.handleExitFromUi(), onSwitchToLocal: () => this.handleSwitchFromUi() }) } protected createDisplay(context: RemoteLauncherDisplayContext): React.ReactElement { return React.createElement(GrokDisplay, context) } protected async runMainLoop(): Promise { const session = this.session const { server, mcpServers } = await buildHapiMcpBridge(session.client, { skillLookup: { workingDirectory: session.path, flavor: 'grok' } }) this.happyServer = server const backend = createGrokBackend({ cwd: session.path, ...this.opts }) this.backend = backend backend.setSessionInfoUpdateListener(({ title }) => { if (typeof title !== 'string') return session.client.sendClaudeSessionMessage({ type: 'summary', summary: title, leafUuid: randomUUID() }) }) backend.onStderrError((error) => { const activeModel = this.currentBackendModel ?? this.opts.model ?? null if (isGrokBuildAuxiliaryQuotaError(`${error.message}\n${error.raw}`, activeModel)) { logger.debug('[grok-remote] Suppressed non-fatal grok-build session-title quota error', { activeModel }) return } logger.debug('[grok-remote] stderr error', error) const message = formatGrokError(error.message) session.sendSessionEvent({ type: 'message', message }) this.messageBuffer.addMessage(message, 'status') }) await backend.initialize() const acpMcpServers = toAcpMcpServers(mcpServers) // Fork children must load the exact native id hub forked. Falling back to // newSession() would leave hydrated HAPI history without matching model context. const strictForkResume = session.client.getMetadata()?.forkedFrom != null let acpSessionId: string try { if (session.sessionId) { try { acpSessionId = await backend.loadSession({ sessionId: session.sessionId, cwd: session.path, mcpServers: acpMcpServers }) } catch (error) { if (strictForkResume) { throw error } logger.warn('[grok-remote] resume failed, starting new session', error) session.sendSessionEvent({ type: 'message', message: 'Grok resume failed; starting a new session.' }) acpSessionId = await backend.newSession({ cwd: session.path, mcpServers: acpMcpServers }) } } else { acpSessionId = await backend.newSession({ cwd: session.path, mcpServers: acpMcpServers }) } } catch (error) { const message = formatGrokError(error) session.sendSessionEvent({ type: 'message', message }) throw new Error(message, { cause: error }) } session.registerExistingNativeSession(acpSessionId) this.conversationHistory.setSession(acpSessionId, session.path) this.conversationHistory.setPublishCapabilities(async () => { const conversationHistory = this.conversationHistory.getCapabilitiesForMetadata()?.conversationHistory try { session.client.updateMetadata((metadata) => { const capabilities = { ...metadata?.capabilities } delete capabilities.conversationHistory if (conversationHistory) { capabilities.conversationHistory = conversationHistory } return { ...metadata, path: metadata?.path ?? session.path, host: metadata?.host ?? 'unknown', capabilities, conversationHistoryPoints: { ...metadata?.conversationHistoryPoints, ...this.conversationHistory.getHistoryPoints() }, conversationHistoryIndexes: { ...metadata?.conversationHistoryIndexes, ...this.conversationHistory.getHistoryIndexes() } } }) } catch { // best-effort; tests and transient hub disconnects must not crash the loop } }) this.conversationHistory.restorePromptIndexes( typeof session.client.getMetadata === 'function' ? session.client.getMetadata()?.conversationHistoryIndexes : undefined ) session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ForkConversation, async (payload: unknown) => { const messageLocalId = isObject(payload) && typeof payload.messageLocalId === 'string' ? payload.messageLocalId : undefined return await this.conversationHistory.fork(messageLocalId) }) session.client.rpcHandlerManager.registerHandler(RPC_METHODS.RewindConversation, async (payload: unknown) => { if (!isObject(payload) || typeof payload.messageLocalId !== 'string') { throw new Error('messageLocalId is required') } return await this.conversationHistory.rewind(payload.messageLocalId) }) void this.conversationHistory.probeCapabilities().catch(() => {}) const modelMetadata = backend.getSessionModelsMetadata(acpSessionId) const effortMetadata = backend.getThoughtLevelConfigOption(acpSessionId) this.currentBackendModel = modelMetadata?.currentModelId ?? this.opts.model ?? null this.defaultBackendModel = this.currentBackendModel this.currentBackendEffort = effortMetadata?.currentValue ?? this.opts.effort ?? null this.defaultBackendEffort = this.currentBackendEffort this.opts.onConfigDiscovered?.({ model: this.currentBackendModel, effort: this.currentBackendEffort }) session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListGrokModels, async () => { const metadata = backend.getSessionModelsMetadata(acpSessionId) if (!metadata) return { success: false, error: 'Grok model metadata is not available' } return { success: true, availableModels: metadata.availableModels, currentModelId: metadata.currentModelId, autoPermissionModeSupported: backend.hasAvailableCommand(acpSessionId, 'auto') } }) session.client.rpcHandlerManager.registerHandler(RPC_METHODS.ListGrokReasoningEffortOptions, async () => { const metadata = backend.getThoughtLevelConfigOption(acpSessionId) if (!metadata) return { success: false, error: 'Grok effort metadata is not available' } return { success: true, options: metadata.options, currentValue: metadata.currentValue ?? null } }) this.permissionHandler = new GrokPermissionHandler( session.client, backend, () => session.getPermissionMode() as PermissionMode | undefined ) this.applyDisplayMode(session.getPermissionMode() as PermissionMode | undefined) this.messageBuffer.addMessage(`[MODEL:${this.currentBackendModel ?? 'default'}]`, 'system') this.setupAbortHandlers(session.client.rpcHandlerManager, { onAbort: () => this.handleAbort(), onSwitch: () => this.handleSwitchRequest() }) while (!this.shouldExit) { const waitSignal = this.abortController.signal const batch = await session.queue.waitForMessagesAndGetAsString(waitSignal) if (!batch) { if (waitSignal.aborted && !this.shouldExit) continue break } // collectBatch already emitted messages-consumed; hub idle checks // see an empty queue. Hold the history busy flag across the whole // turn setup (model/permission sync, rewind-points, prompt). this.conversationHistory.setBusy(true) session.onThinkingChange(true) try { const requestedModel = batch.mode.model === null ? this.defaultBackendModel : batch.mode.model if (requestedModel && requestedModel !== this.currentBackendModel) { try { await backend.setModel(acpSessionId, requestedModel, { flavor: 'grok' }) this.currentBackendModel = requestedModel batch.mode.model = requestedModel } catch (error) { logger.warn('[grok-remote] Inline model switch failed', error) this.rollbackModel(batch, this.currentBackendModel) session.sendSessionEvent({ type: 'message', message: `Failed to switch Grok model to ${requestedModel}.` }) } } const requestedEffort = batch.mode.effort === null ? this.defaultBackendEffort : batch.mode.effort if (requestedEffort && requestedEffort !== this.currentBackendEffort) { try { await backend.setMode(acpSessionId, requestedEffort) this.currentBackendEffort = requestedEffort batch.mode.effort = requestedEffort } catch (error) { logger.warn('[grok-remote] Inline effort switch failed', error) this.rollbackEffort(batch, this.currentBackendEffort) session.sendSessionEvent({ type: 'message', message: `Failed to switch Grok effort to ${requestedEffort}.` }) } } const requestedPermissionMode = batch.mode.permissionMode try { await this.syncBackendPermissionMode(acpSessionId, requestedPermissionMode) } catch (error) { logger.warn('[grok-remote] Inline permission mode switch failed', error) const fallbackMode: PermissionMode = this.currentBackendPermissionMode ?? 'default' this.rollbackPermissionMode(batch, fallbackMode) session.sendSessionEvent({ type: 'message', message: requestedPermissionMode === 'auto' ? 'Grok Auto permission mode is not enabled for this account or CLI build; using Default.' : `Failed to switch Grok permission mode to ${requestedPermissionMode}.` }) } this.applyDisplayMode(batch.mode.permissionMode) this.messageBuffer.addMessage(batch.message, 'user') const isSlashCommand = batch.message.trimStart().startsWith('/') let text = batch.mode.permissionMode === 'plan' && !isSlashCommand ? `${PLAN_MODE_INSTRUCTION}\n\n${batch.message}` : batch.message if (!this.instructionsSent && !isSlashCommand) { text = `${GROK_TITLE_INSTRUCTION}\n\n${text}` this.instructionsSent = true } const promptContent: PromptContent[] = [{ type: 'text', text }] const localId = batch.items ?.map((item) => item.localId) .find((id): id is string => typeof id === 'string' && id.length > 0) // Official prompt index: count rewind points before the prompt; the new // point lands at that index after a successful turn. let nextPromptIndex: number | null = null try { const points = await backend.sendExtensionRequest<{ points?: unknown[] } | unknown[]>( '_x.ai/rewind/points', { sessionId: acpSessionId } ) const list = Array.isArray(points) ? points : (isObject(points) && Array.isArray(points.points) ? points.points : null) if (list) nextPromptIndex = list.length } catch { nextPromptIndex = null } try { await backend.prompt(acpSessionId, promptContent, (message: AgentMessage) => { this.handleAgentMessage(message) }) if (localId && nextPromptIndex != null) { this.conversationHistory.rememberPromptIndex(localId, nextPromptIndex) session.client.updateMetadata((metadata) => ({ ...metadata, path: metadata?.path ?? session.path, host: metadata?.host ?? 'unknown', conversationHistoryPoints: { ...metadata?.conversationHistoryPoints, [localId]: true as const }, conversationHistoryIndexes: { ...metadata?.conversationHistoryIndexes, [localId]: nextPromptIndex } })) } } catch (error) { const message = formatGrokError(error) logger.warn('[grok-remote] prompt failed', error) session.sendSessionEvent({ type: 'message', message: `Grok prompt failed: ${message}` }) this.messageBuffer.addMessage(`Grok prompt failed: ${message}`, 'status') } } finally { this.conversationHistory.setBusy(false) session.onThinkingChange(false) await this.permissionHandler?.cancelAll('Prompt finished') if (session.queue.size() === 0 && !this.shouldExit) { session.sendSessionEvent({ type: 'ready' }) } } } } protected async cleanup(): Promise { this.clearAbortHandlers(this.session.client.rpcHandlerManager) if (this.permissionHandler) { await this.permissionHandler.cancelAll('Session ended') this.permissionHandler = null } if (this.backend) { await this.backend.disconnect() this.backend = null } if (this.happyServer) { this.happyServer.stop() this.happyServer = null } } private handleAgentMessage(message: AgentMessage): void { const converted = convertAgentMessage(message) if (converted) this.session.sendAgentMessage(converted) switch (message.type) { case 'text': this.messageBuffer.addMessage(message.text, 'assistant') break case 'reasoning': if (!message.live) { this.messageBuffer.addMessage(`[Thinking] ${message.text.substring(0, 100)}...`, 'system') } break case 'tool_call': { const previous = this.lastDisplayedToolCall.get(message.id) if (previous !== message.name) { this.lastDisplayedToolCall.set(message.id, message.name) this.messageBuffer.addMessage(`Tool call: ${message.name}`, 'tool') } break } case 'tool_result': this.messageBuffer.addMessage('Tool result received', 'result') break case 'plan': this.messageBuffer.addMessage('Plan updated', 'status') break case 'error': this.messageBuffer.addMessage(message.message, 'status') break case 'turn_complete': this.messageBuffer.addMessage('Turn complete', 'status') break case 'usage': break default: { const exhaustive: never = message return exhaustive } } } private applyDisplayMode(permissionMode: PermissionMode | undefined): void { if (permissionMode && permissionMode !== this.displayPermissionMode) { this.displayPermissionMode = permissionMode this.messageBuffer.addMessage(`[MODE:${permissionMode}]`, 'system') } } private rollbackModel(batch: { mode: { model?: string | null } }, model: string | null): void { batch.mode.model = model this.session.setModel(model) this.session.pushKeepAlive() this.opts.onModelRollback?.(model) } private rollbackEffort(batch: { mode: { effort?: string | null } }, effort: string | null): void { batch.mode.effort = effort this.session.setEffort(effort) this.session.pushKeepAlive() this.opts.onEffortRollback?.(effort) } private rollbackPermissionMode( batch: { mode: { permissionMode: PermissionMode } }, permissionMode: PermissionMode ): void { batch.mode.permissionMode = permissionMode this.session.setPermissionMode(permissionMode) this.session.pushKeepAlive() this.opts.onPermissionModeRollback?.(permissionMode) } private async syncBackendPermissionMode( sessionId: string, permissionMode: PermissionMode ): Promise { if (!this.backend) return if (permissionMode === 'auto') { if (this.currentBackendPermissionMode === 'auto') return if (!this.backend.hasAvailableCommand(sessionId, 'auto')) { await this.setBackendDefaultPermissionMode(sessionId) throw new Error('Grok did not advertise the /auto command') } await this.backend.prompt( sessionId, [{ type: 'text', text: '/auto' }], () => undefined ) this.currentBackendPermissionMode = 'auto' return } await this.setBackendDefaultPermissionMode(sessionId) } private async setBackendDefaultPermissionMode(sessionId: string): Promise { if (!this.backend || this.currentBackendPermissionMode === 'default') return await this.backend.prompt( sessionId, [{ type: 'text', text: '/always-approve off' }], () => undefined ) this.currentBackendPermissionMode = 'default' } private async handleAbort(): Promise { if (this.backend && this.session.sessionId) { await this.backend.cancelPrompt(this.session.sessionId) } await this.permissionHandler?.cancelAll('User aborted') this.session.queue.reset() this.session.onThinkingChange(false) this.abortController.abort() this.abortController = new AbortController() this.messageBuffer.addMessage('Turn aborted', 'status') } private async handleExitFromUi(): Promise { await this.requestExit('exit', () => this.handleAbort()) } private async handleSwitchFromUi(): Promise { await this.requestExit('switch', () => this.handleAbort()) } private async handleSwitchRequest(): Promise { await this.requestExit('switch', () => this.handleAbort()) } } function toAcpMcpServers(config: Record): McpServerStdio[] { return Object.entries(config).map(([name, entry]) => ({ name, command: entry.command, args: entry.args, env: [] })) } export async function grokRemoteLauncher( session: GrokSession, opts: GrokRemoteLauncherOptions ): Promise<'switch' | 'exit'> { return new GrokRemoteLauncher(session, opts).launch() }