import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import type { SessionBootstrapResult } from '@/agent/sessionFactory'; import type { ApiSessionClient } from '@/api/apiSession'; import { logger } from '@/ui/logger'; import { listSlashCommands } from '@/modules/common/slashCommands'; import { normalizeCodexModel } from '@/modules/common/codexModels'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; import { ImplementCodexPlanRequestSchema, type ImplementCodexPlanResult } from '@hapi/protocol/apiTypes'; import { CodexAppServerClient, isIndeterminateError } from '../codexAppServerClient'; import { buildHapiMcpBridge, type HapiMcpBridge } from '../utils/buildHapiMcpBridge'; import { buildUserInputFromMessage } from '../utils/appServerConfig'; import { resolveCodexPermissionModeConfig } from '../utils/permissionModeConfig'; import { resolveCodexSlashCommand } from '../utils/slashCommands'; import { parseReasoningEffortValue } from '../utils/reasoningEffort'; import { SharedCodexPermissions } from './permissions'; import { SharedCodexQueue } from './queue'; import { SharedCodexProjection, inputText } from './projection'; import { getCodexSystemPrompt } from '../utils/systemPrompt'; import { record, string } from './gateway'; import { initializeSharedClient, type SharedLaunchOptions } from './launch'; import { inheritedSandbox, settingsMatch } from './settings'; import { planImplementationMessageId, planProposalForItem, planProposalForTurn } from './plan'; type RuntimeSettings = NonNullable[2]>; export type RootHost = { directory: string; generation: string; endpoint: string; token?: string; settingsFor(threadId: string): Record | undefined; create(method: 'thread/start' | 'thread/fork', params: Record, parent?: SharedCodexRoot): Promise; end(root: SharedCodexRoot, nativeArchive?: boolean): Promise; }; const SettingsSchema = z.object({ permissionMode: z.enum(['default', 'read-only', 'autoReview', 'yolo']).optional(), model: z.string().min(1).optional(), modelReasoningEffort: z.string().nullable().optional(), collaborationMode: z.enum(['default', 'plan']).optional(), serviceTier: z.enum(['fast', 'standard']).nullable().optional(), personality: z.enum(['friendly', 'pragmatic', 'none']).optional() }); /** One immutable HAPI identity + independent subscription for one root. */ export class SharedCodexRoot { readonly client: CodexAppServerClient; readonly session: ApiSessionClient; bridge!: HapiMcpBridge; threadId = ''; private permissions!: SharedCodexPermissions; private queue!: SharedCodexQueue; private projection!: SharedCodexProjection; private readonly children = new Map(); private readonly ancestry = new Map(); private heartbeat?: ReturnType; private work: Promise = Promise.resolve(); private notifications = Promise.resolve(); private currentTurn: string | undefined; // Ephemeral observations only: dequeue/compaction never prove acceptance. private readonly dequeuedInputs = new Set(); private deliveryTurn?: string; private compactingTurn?: string; private publishedCompactionIds?: string; private latestTurn?: { id: string; status: string; planId?: string }; private publishedPlanId: string | null | undefined; private submittingPlan = false; private steeringActive: boolean | undefined; private turnRevision = 0; private settingsRevision = 0; private refreshing?: Promise; private interrupted = false; private closed = false; private stopping = false; private closing?: Promise; private readonly controls = new Set>(); private reconnecting = false; private settings: RuntimeSettings = {}; private settingsNative: Record = {}; private settingsNotification?: Record; private readonly settingsListeners = new Set<() => void>(); private ready!: () => void; private readonly bound = new Promise(resolve => { this.ready = resolve; }); constructor(readonly bootstrap: SessionBootstrapResult, private readonly host: RootHost) { this.session = bootstrap.session; this.client = new CodexAppServerClient({ endpoint: host.endpoint, token: host.token, cwd: bootstrap.workingDirectory }); this.client.setNotificationHandler((method, params) => { if (method === 'serverRequest/resolved') { this.permissions?.resolved(string(record(params).threadId) ?? '', record(params).requestId); return; } if (method === 'thread/settings/updated' && record(params).threadId === this.threadId) this.acceptSettings(record(record(params).threadSettings)); if (record(params).threadId === this.threadId) { if (method === 'turn/started') { this.turnRevision++; this.currentTurn = string(record(record(params).turn).id); this.interrupted = false; if (this.currentTurn) this.latestTurn = { id: this.currentTurn, status: 'inProgress' }; this.publishSteering(); } if (method === 'turn/completed' && (!this.currentTurn || record(record(params).turn).id === this.currentTurn)) { this.turnRevision++; this.currentTurn = undefined; this.interrupted = record(record(params).turn).status === 'interrupted'; this.publishSteering(); } const p = record(params); if (method === 'item/completed' && this.latestTurn && this.latestTurn.id === p.turnId) { const planId = planProposalForItem(this.threadId, this.latestTurn.id, p.item); if (planId) { this.turnRevision++; this.latestTurn.planId = planId; } } if (method === 'turn/completed') { const turn = record(p.turn); const id = string(turn.id); if (id && (!this.latestTurn || this.latestTurn.id === id)) { this.latestTurn = { id, status: string(turn.status) ?? 'unknown', planId: planProposalForTurn(this.threadId, turn) ?? this.latestTurn?.planId }; } } this.publishPlan(); } const receivedAt = Date.now(); const modelAtReceipt = record(params).threadId === this.threadId ? this.settings.model ?? undefined : undefined; this.notifications = this.notifications.then(() => this.notification(method, params, modelAtReceipt, receivedAt)).catch(error => logger.debug('[Codex shared] projection', error)); }); this.client.setTransportAbandonedHandler(() => { void this.reconnect(); }); this.session.onUserMessage((message, localId) => { this.work = this.work.catch(() => {}).then(async () => { await this.bound; if (this.closed || this.stopping) return; const id = localId ?? randomUUID(); const text = formatMessageWithAttachments(message.content.text, message.content.attachments); const resolved = text.trim().startsWith('/') ? await this.queue.command(id, () => this.command(text)) : text; if (resolved === null) { this.session.emitMessagesConsumed([id], { clearQueuedThinkingGrace: true }); return; } await this.queue.enqueue(id, buildUserInputFromMessage(resolved), this.interrupted); }).catch(error => this.notice(`Message not confirmed: ${error instanceof Error ? error.message : error}. Inspect the queue before retrying.`)); }); this.session.onCancelQueuedMessage(async id => { await this.bound; return this.stopping ? 'indeterminate' : this.queue.cancel(id); }); // Absence from history is NOT proof an uncertain mutation failed. this.session.onRetryQueuedMessage(async id => { await this.bound; if (this.stopping) return false; await this.refresh(); return ['rejected', 'canceled', 'released'].includes(this.queue.state(id) ?? ''); }); this.session.onReconnect(() => { if (!this.threadId || this.closed || this.stopping) return; // An emitted socket packet is not a durable hub acknowledgement. // Replay final native history with stable IDs after every reconnect. this.projection?.reset(); this.steeringActive = undefined; this.publishedPlanId = undefined; this.publishedCompactionIds = undefined; void this.refresh().then(() => this.refreshChildren(false)).then(() => this.queue.replay()) .catch(error => logger.debug('[Codex shared] hub resync', error)); }); } async prepare(): Promise { // Descendants share this bridge. The projection applies successful // root title calls so a child cannot rename the parent session. this.bridge = await buildHapiMcpBridge(this.session, { exportSessionEnv: false, emitTitleSummary: false, skillLookup: { workingDirectory: this.bootstrap.workingDirectory, flavor: 'codex' } }); await initializeSharedClient(this.client); this.permissions = new SharedCodexPermissions(this.session, this.client, this.host.generation); this.client.setServerRequestHandler(request => { void this.receiveRequest(request); }); } private async receiveRequest(request: { id: string | number; method: string; params: unknown }): Promise { await this.bound; if (this.closed || this.stopping) return; const threadId = string(record(request.params).threadId); // App-server automatically subscribes every initialized connection to // newly created threads, including siblings. Never answer their calls. if (threadId && await this.ownsThread(threadId)) this.permissions.receive(request); } private async ownsThread(threadId: string): Promise { const visited = new Set(); let candidate: string | null = threadId; while (candidate && !visited.has(candidate)) { if (candidate === this.threadId) return true; visited.add(candidate); if (!this.ancestry.has(candidate)) { try { const thread = record(record(await this.client.request('thread/read', { threadId: candidate, includeTurns: false })).thread); this.ancestry.set(candidate, string(thread.parentThreadId) ?? null); } catch { return false; } } candidate = this.ancestry.get(candidate) ?? null; } return false; } config(params: Record): Record { return { ...params, cwd: params.cwd ?? this.bootstrap.workingDirectory, developerInstructions: params.developerInstructions ?? getCodexSystemPrompt(), config: { ...record(params.config), 'mcp_servers.hapi': this.bridge.mcpServers.hapi, 'shell_environment_policy.set.HAPI_SESSION_ID': this.session.sessionId } }; } async bind(threadId: string, response: Record, subscribe: boolean): Promise { if (this.threadId && this.threadId !== threadId) throw new Error('Cannot retarget a shared HAPI session'); this.threadId = threadId; this.queue = new SharedCodexQueue(this.client, threadId, join(this.host.directory, `${this.session.sessionId}.queue.json`), (ids, steered) => { this.session.emitMessagesConsumed(ids, { steered }); for (const id of ids) this.dequeuedInputs.delete(id); this.publishCompaction(); }, ids => this.session.emitSteerIndeterminate(ids), (id, input) => this.session.syncNativeQueuedMessage(id, input === null ? null : inputText(input)), ids => this.session.setSteerDeliveryState(ids, 'queued')); await this.queue.load(); this.projection = new SharedCodexProjection(this.session, threadId, id => this.queue.committed(id)); this.session.updateMetadata(metadata => ({ ...metadata, codexSessionId: threadId, capabilities: { ...metadata.capabilities, concurrentClients: true, terminal: true, // In-place rewind needs a native + hub commit barrier. Do not // advertise a destructive operation we cannot make atomic yet. conversationHistory: { forkCurrent: true, forkAtMessage: true, rewindToMessage: false } } })); this.session.updateAgentState(state => ({ ...state, controlledByUser: false, startingMode: undefined, codexPlanProposalId: null, codexCompactionPendingMessageIds: [], requests: {}, completedRequests: { ...state.completedRequests, ...Object.fromEntries(Object.entries(state.requests ?? {}).map(([id, request]) => [id, { ...request, completedAt: Date.now(), status: 'canceled' as const }])) } })); if (subscribe) response = record(await this.client.request('thread/resume', { threadId })); this.acceptSettings(response); this.acceptSettings(this.host.settingsFor(threadId) ?? {}); await this.projection.history(response.thread); await this.refresh(); await this.refreshChildren(true); } async activate(options: SharedLaunchOptions = {}): Promise { // Restored input cannot run before the cold-resume settings are applied. await this.initialSettings(options); if (this.stopping) throw new Error('Codex execution is stopping'); this.registerControls(); this.ready(); this.heartbeat = setInterval(() => this.alive(), 2_000); this.alive(); this.session.emitSessionReady(); } private publishSteering(): void { this.publishCompaction(); const active = Boolean(this.currentTurn) && !this.stopping && !this.closed && !this.reconnecting && this.client.isInitialized(); if (this.steeringActive === active) return; this.steeringActive = active; this.session.updateAgentState(state => ({ ...state, steeringActive: active })); } private publishCompaction(): void { const active = this.compactingTurn && this.compactingTurn === this.currentTurn && !this.closed && !this.stopping && !this.reconnecting && this.client.isInitialized(); const ids = active ? [...this.dequeuedInputs].filter(id => this.queue?.state(id) === 'unknown').sort() : []; const key = JSON.stringify(ids); if (key === this.publishedCompactionIds) return; this.publishedCompactionIds = key; this.session.updateAgentState(state => ({ ...state, codexCompactionPendingMessageIds: ids })); } private alive(): void { this.publishSteering(); this.publishPlan(); if (!this.closed) this.session.keepAlive(Boolean(this.currentTurn), undefined, this.settings); } private availablePlanId(): string | null { if (this.currentTurn || this.latestTurn?.status !== 'completed' || this.settings.collaborationMode !== 'plan' || this.stopping || this.closed || this.reconnecting || this.submittingPlan || !this.client.isInitialized()) return null; const id = this.latestTurn.planId; // An uncertain/accepted submission must never become a fresh Execute button after reconnect. return id && !this.queue?.owns(planImplementationMessageId(id)) ? id : null; } private publishPlan(): void { const id = this.availablePlanId(); if (id === this.publishedPlanId) return; this.publishedPlanId = id; this.session.updateAgentState(state => ({ ...state, codexPlanProposalId: id })); } acceptSettings(value: Record): void { if (typeof value.model !== 'string') return; this.settingsRevision++; if ('collaborationMode' in value && 'sandboxPolicy' in value) this.settingsNotification = value; this.settingsNative = { ...this.settingsNative, ...value }; const sandbox = record(value.sandboxPolicy ?? value.sandbox).type; this.settings = { ...this.settings, model: value.model, modelReasoningEffort: string(value.effort ?? value.reasoningEffort) ?? null, ...(sandbox ? { permissionMode: sandbox === 'dangerFullAccess' ? 'yolo' : sandbox === 'readOnly' ? 'read-only' : this.settingsNative.approvalsReviewer === 'auto_review' ? 'autoReview' : 'default' } : {}), ...('serviceTier' in value ? { serviceTier: value.serviceTier === 'priority' ? 'fast' : 'standard' } : {}), ...('collaborationMode' in value ? { collaborationMode: record(value.collaborationMode).mode === 'plan' ? 'plan' as const : 'default' as const } : {}) }; for (const listener of this.settingsListeners) listener(); this.publishPlan(); } private async notification(method: string, params: unknown, modelAtReceipt?: string, receivedAt = Date.now()): Promise { if (!this.threadId || this.closed) return; const p = record(params); if (method === 'thread/started') { const thread = record(p.thread); const id = string(thread.id); if (id) this.ancestry.set(id, string(thread.parentThreadId) ?? null); } const eventThread = string(p.threadId) ?? string(record(p.thread).id); if (eventThread && eventThread !== this.threadId) { if (!await this.ownsThread(eventThread)) return; let projection = this.children.get(eventThread); if (!projection) { projection = new SharedCodexProjection(this.session, eventThread, async () => {}, this.threadId); this.children.set(eventThread, projection); } await projection.notification(method, params); return; } if (method === 'turn/completed') this.session.sendSessionEvent({ type: 'ready' }); if (method === 'thread/name/updated' && (typeof p.threadName === 'string' || p.threadName === null)) { const name = p.threadName ?? undefined; this.session.updateMetadata(metadata => ({ ...metadata, name })); } if (method === 'thread/archived') { await this.host.end(this, false); return; } // Process these in notification order: queue removal can precede turn/started. // Older unknown input must not acquire a later turn's compaction status. if (method === 'turn/started') { const id = string(record(p.turn).id); if (this.deliveryTurn && this.deliveryTurn !== id) this.dequeuedInputs.clear(); this.deliveryTurn = id; this.compactingTurn = undefined; } if (method === 'turn/completed' && record(p.turn).id === this.deliveryTurn) { this.dequeuedInputs.clear(); this.deliveryTurn = undefined; this.compactingTurn = undefined; } if (p.turnId === this.currentTurn && record(p.item).type === 'contextCompaction') { if (method === 'item/started') this.compactingTurn = string(p.turnId); if (method === 'item/completed') { this.compactingTurn = undefined; this.dequeuedInputs.clear(); } } if (method === 'thread/queue/changed') { for (const id of await this.queue.reconcile()) this.dequeuedInputs.add(id); } await this.projection.notification(method, params, modelAtReceipt, receivedAt); this.publishCompaction(); this.alive(); } async readThread(threadId = this.threadId): Promise> { let thread = record(record(await this.client.request('thread/read', { threadId, includeTurns: false })).thread); if (thread.historyMode === 'paginated') { const turns: unknown[] = []; let cursor: string | undefined; do { const page = record(await this.client.request('thread/turns/list', { threadId, cursor, sortDirection: 'asc', itemsView: 'full' })); if (!Array.isArray(page.data)) throw new Error('Invalid Codex history'); turns.push(...page.data); cursor = string(page.nextCursor); } while (cursor); thread.turns = turns; } else thread = record(record(await this.client.request('thread/read', { threadId, includeTurns: true })).thread); return thread; } refresh(): Promise { return this.refreshing ??= this.refreshNow().finally(() => { this.refreshing = undefined; }); } private async refreshNow(): Promise { if (!this.threadId || this.closed || !this.client.isInitialized()) return; const revision = this.turnRevision; const thread = await this.readThread(); const turns = Array.isArray(thread.turns) ? thread.turns.map(record) : []; if (revision === this.turnRevision) { this.currentTurn = string(turns.find(turn => turn.status === 'inProgress')?.id); this.interrupted = turns.at(-1)?.status === 'interrupted'; const last = turns.at(-1); const id = string(last?.id); this.latestTurn = id && last ? { id, status: string(last.status) ?? 'unknown', planId: planProposalForTurn(this.threadId, last) } : undefined; } await this.projection.history(thread); await this.queue.reconcile(); this.alive(); } private async refreshChildren(subscribe: boolean): Promise { let cursor: string | undefined; do { const page = record(await this.client.request('thread/list', { ancestorThreadId: this.threadId, cursor, sourceKinds: ['subAgent', 'subAgentReview', 'subAgentCompact', 'subAgentThreadSpawn', 'subAgentOther'] })); for (const value of Array.isArray(page.data) ? page.data : []) { const thread = record(value); const id = string(thread.id); if (id && id !== this.threadId) this.ancestry.set(id, string(thread.parentThreadId) ?? null); if (id && id !== this.threadId && await this.ownsThread(id) && !this.children.has(id)) { this.children.set(id, new SharedCodexProjection(this.session, id, async () => {}, this.threadId)); } } cursor = string(page.nextCursor); } while (cursor); const loaded = new Set(); cursor = undefined; if (subscribe && this.children.size) do { const page = record(await this.client.request('thread/loaded/list', { cursor })); for (const id of Array.isArray(page.data) ? page.data : []) if (typeof id === 'string') loaded.add(id); cursor = string(page.nextCursor); } while (cursor); for (const [id, projection] of this.children) { // Replaying a completed/unloaded child must not start its engine. try { if (subscribe && loaded.has(id)) await this.client.request('thread/resume', { threadId: id }); projection.reset(); await projection.history(await this.readThread(id)); } catch (error) { logger.debug('[Codex shared] child history unavailable', { id, error }); } } } nativeQueueDeleted(nativeId: string): Promise { return this.queue.deleted(nativeId); } replaySettings(): Array<{ method: string; params: unknown }> { return this.settingsNotification ? [{ method: 'thread/settings/updated', params: { threadId: this.threadId, threadSettings: this.settingsNotification } }] : []; } private async reconnect(): Promise { if (this.closed || this.stopping || this.reconnecting || !this.threadId) return; this.reconnecting = true; this.dequeuedInputs.clear(); this.deliveryTurn = undefined; this.compactingTurn = undefined; this.publishSteering(); this.publishPlan(); this.permissions.close(); try { while (!this.closed && !this.stopping) { try { await initializeSharedClient(this.client); if (this.stopping) return; this.permissions = new SharedCodexPermissions(this.session, this.client, `${this.host.generation}:${randomUUID()}`); this.client.setServerRequestHandler(request => { void this.receiveRequest(request); }); const settingsRevision = this.settingsRevision; const response = record(await this.client.request('thread/resume', { threadId: this.threadId })); const observedSettings = this.settingsRevision !== settingsRevision; this.acceptSettings(response); if (!observedSettings) this.acceptSettings(this.host.settingsFor(this.threadId) ?? {}); this.projection.reset(); await this.refresh(); this.queue.replay(); await this.refreshChildren(true); return; } catch (error) { this.permissions.close(); logger.debug('[Codex shared] reconnect', error); await new Promise(resolve => setTimeout(resolve, 1_000)); } } } finally { this.reconnecting = false; this.publishSteering(); this.publishPlan(); } } async applySettings(raw: unknown): Promise<{ applied: RuntimeSettings }> { const config = SettingsSchema.parse(raw); if (Object.keys(config).length === 0) return { applied: this.settings }; if (typeof config.modelReasoningEffort === 'string') config.modelReasoningEffort = parseReasoningEffortValue(config.modelReasoningEffort); if (config.modelReasoningEffort === null) { // Native public null means "leave effort alone", not reset. Resolve // the selected model's real default instead of publishing a false reset. let cursor: string | undefined; do { const page = await this.client.listModels({ cursor, includeHidden: true }); const model = page.data?.find(model => (model.model ?? model.id) === (config.model ?? this.settingsNative.model)); if (model?.defaultReasoningEffort) { config.modelReasoningEffort = model.defaultReasoningEffort; break; } cursor = page.nextCursor ?? undefined; } while (cursor); if (config.modelReasoningEffort === null) throw new Error('Codex did not report a default reasoning effort; choose an explicit effort'); } const revision = this.settingsRevision; const permission = config.permissionMode && resolveCodexPermissionModeConfig(config.permissionMode); const params = { threadId: this.threadId, ...(config.model ? { model: config.model } : {}), ...(config.personality ? { personality: config.personality } : {}), ...(config.modelReasoningEffort !== undefined ? { effort: config.modelReasoningEffort } : {}), ...(config.serviceTier !== undefined ? { serviceTier: config.serviceTier === 'fast' ? 'priority' : null } : {}), ...(permission ? { approvalPolicy: permission.approvalPolicy, sandboxPolicy: permission.sandboxPolicy, approvalsReviewer: config.permissionMode === 'autoReview' ? 'auto_review' : 'user' } : {}), ...(config.collaborationMode ? { collaborationMode: { mode: config.collaborationMode, settings: { model: config.model ?? this.settings.model, reasoning_effort: config.modelReasoningEffort ?? this.settings.modelReasoningEffort, developer_instructions: null } } } : {}) }; let changed!: () => void; let timer: ReturnType | undefined; const accepted = new Promise((resolve, reject) => { changed = () => { if (this.settingsRevision > revision && settingsMatch(this.settingsNative, params)) resolve(); }; this.settingsListeners.add(changed); timer = setTimeout(() => reject(new Error('Native settings update is not confirmed; reconnect before retrying')), 15_000); }); void accepted.catch(() => {}); try { await this.client.request('thread/settings/update', params); // The RPC only acknowledges queueing the settings operation. The // native notification, not our submitted candidate, establishes state. await accepted; this.alive(); return { applied: this.settings }; } finally { clearTimeout(timer); this.settingsListeners.delete(changed); } } async initialSettings(options: SharedLaunchOptions): Promise { if (options.collaborationMode) await this.applySettings({ collaborationMode: options.collaborationMode }); } private async implementPlan(planId: string): Promise { const unavailable = (): ImplementCodexPlanResult => ({ ok: false, code: 'unavailable', error: 'Codex is disconnected or stopping. Reconnect before implementing the plan.' }); const stale = (): ImplementCodexPlanResult => ({ ok: false, code: 'stale_plan', error: 'This plan is no longer actionable. Refresh the conversation.' }); const localId = planImplementationMessageId(planId); if (this.closed || this.stopping || this.reconnecting || !this.client.isInitialized()) return unavailable(); try { // A reconnect snapshot may have started before this action arrived. await this.refreshing; await this.refresh(); const prior = this.queue.state(localId); if (prior === 'queued' || prior === 'consumed') return { ok: true }; if (prior) return { ok: false, code: 'indeterminate', error: 'This implementation was already submitted. Check the queue and conversation before sending again.' }; const before = await this.queue.list(); if (this.availablePlanId() !== planId || before.length) return stale(); const revision = this.turnRevision; this.submittingPlan = true; this.publishPlan(); await this.applySettings({ collaborationMode: 'default' }); const queued = await this.queue.list(); if (this.closed || this.stopping || this.reconnecting || !this.client.isInitialized()) return unavailable(); if (this.turnRevision !== revision || this.currentTurn || this.latestTurn?.planId !== planId || this.settings.collaborationMode !== 'default' || queued.length) return stale(); await this.queue.enqueue(localId, buildUserInputFromMessage('Implement the plan.')); return { ok: true }; } catch (error) { const detail = error instanceof Error ? error.message : String(error); return { ok: false, code: isIndeterminateError(error) || this.queue.state(localId) === 'unknown' ? 'indeterminate' : 'failed', error: `Plan implementation was not confirmed: ${detail}. Check the mode, queue and conversation before retrying.` }; } finally { this.submittingPlan = false; this.publishPlan(); } } private registerControls(): void { const rpc = { registerHandler: (method: string, handler: (raw: unknown) => Promise) => { this.session.rpcHandlerManager.registerHandler(method, async (raw: unknown) => { if (this.stopping) throw new Error('Codex execution is stopping; resume when inactive'); const pending = handler(raw); this.controls.add(pending); try { return await pending; } finally { this.controls.delete(pending); } }); } }; rpc.registerHandler(RPC_METHODS.ListCodexModels, async raw => { const models = []; let cursor: string | undefined; do { const response = await this.client.listModels({ includeHidden: record(raw).includeHidden === true, cursor }); models.push(...(response.data ?? []).map(normalizeCodexModel).filter(model => model !== null)); cursor = response.nextCursor ?? undefined; } while (cursor); return { success: true, models }; }); rpc.registerHandler(RPC_METHODS.Switch, async () => { throw new Error('control_mode_not_applicable'); }); rpc.registerHandler(RPC_METHODS.HandoffLocal, async () => { throw new Error('control_mode_not_applicable'); }); rpc.registerHandler(RPC_METHODS.Abort, async () => { const turnId = this.currentTurn; if (turnId) await this.client.request('turn/interrupt', { threadId: this.threadId, turnId }); }); rpc.registerHandler(RPC_METHODS.KillSession, async () => { await this.host.end(this); return { success: true }; }); // #1911 C2 / AC6: Codex owns KillSession here and never calls // registerKillSessionHandler — still must EXIT on hub-archived metadata // (KillSession unreachable / map-miss). Latch covers bootstrap write // before bind/activate registers controls; B2 refuses tree-kill of the // shared wrapper so this subscribe is load-bearing. const exitFromHubArchive = () => { void this.host.end(this).catch(error => logger.debug('[CODEX] hub-archived end failed:', error)); }; if (this.session.hubArchived) { exitFromHubArchive(); } this.session.on('hub-archived', exitFromHubArchive); rpc.registerHandler(RPC_METHODS.SetSessionConfig, raw => this.applySettings(raw)); rpc.registerHandler(RPC_METHODS.ImplementCodexPlan, raw => { const { planId } = ImplementCodexPlanRequestSchema.parse(raw); const work = this.work.catch(() => {}).then(() => this.implementPlan(planId)); this.work = work; return work; }); rpc.registerHandler(RPC_METHODS.SteerQueuedMessage, async raw => { const { localId } = z.object({ localId: z.string().min(1) }).parse(raw); const expectedTurnId = this.currentTurn; if (!expectedTurnId) return { steered: false, error: 'No active turn' }; return this.queue.steer(localId, expectedTurnId); }); rpc.registerHandler(RPC_METHODS.ClearConversation, async () => { const child = await this.newConversation(); return { sessionId: child.session.sessionId }; }); rpc.registerHandler(RPC_METHODS.ForkConversation, async raw => { const { messageLocalId } = z.object({ messageLocalId: z.string().optional() }).parse(raw); if (this.currentTurn && !messageLocalId) throw new Error('Session is busy'); const beforeTurnId = messageLocalId ? this.projection.turnFor(messageLocalId) : undefined; if (messageLocalId && !beforeTurnId) throw new Error('No native history point for this message'); if (messageLocalId) { const thread = await this.assertBoundary(messageLocalId, beforeTurnId!); const turns = (Array.isArray(thread.turns) ? thread.turns : []).map(record); const index = turns.findIndex(turn => turn.id === beforeTurnId); if (index < 0 || turns.slice(0, index + 1).some(turn => !['completed', 'interrupted', 'failed'].includes(string(turn.status) ?? ''))) { throw new Error('Historical fork requires a completed turn and history prefix'); } } const child = await this.host.create('thread/fork', { ...this.freshParams(), threadId: this.threadId, ...(beforeTurnId ? { beforeTurnId } : {}) }, this); await child.initialSettings({ collaborationMode: this.settings.collaborationMode }); return { nativeSessionId: child.threadId, sessionId: child.session.sessionId }; }); rpc.registerHandler(RPC_METHODS.RewindConversation, async () => { throw new Error('In-place rewind is not supported for concurrent Codex clients; fork at the message instead'); }); } private async assertBoundary(localId: string, turnId: string): Promise> { const thread = await this.readThread(); const turn = (Array.isArray(thread.turns) ? thread.turns.map(record) : []).find(turn => turn.id === turnId); const first = (Array.isArray(turn?.items) ? turn.items.map(record) : []).find(item => item.type === 'userMessage'); const firstId = string(first?.clientId ?? first?.clientUserMessageId) ?? (first?.id ? `codex:${this.threadId}:user:${first.id}` : undefined); if (firstId !== localId) throw new Error('Cannot cut inside a steered native turn. Select its first message.'); return thread; } freshParams(): Record { const sandbox = inheritedSandbox(this.settingsNative); return { ...sandbox, cwd: this.settingsNative.cwd ?? this.bootstrap.workingDirectory, model: this.settingsNative.model, modelProvider: this.settingsNative.modelProvider, approvalPolicy: this.settingsNative.approvalPolicy, serviceTier: this.settingsNative.serviceTier, approvalsReviewer: this.settingsNative.approvalsReviewer, personality: this.settingsNative.personality, developerInstructions: getCodexSystemPrompt(), config: { ...record(sandbox.config), model_reasoning_effort: this.settings.modelReasoningEffort ?? undefined } }; } private notice(message: string): void { this.session.sendSessionEvent({ type: 'message', message }); } private async newConversation(): Promise { const child = await this.host.create('thread/start', this.freshParams()); await child.initialSettings({ collaborationMode: this.settings.collaborationMode }); return child; } private async command(text: string): Promise { const command = text.trim(); if (command === '/clear' || command === '/new') { const child = await this.newConversation(); this.notice(`New conversation: /sessions/${child.session.sessionId}`); return null; } if (command === '/compact') { await this.client.request('thread/compact/start', { threadId: this.threadId }); return null; } if (command === '/exit') { this.notice('/exit closes a native terminal, not this Web view. Use End session to archive this conversation.'); return null; } if (!command.startsWith('/')) return text; const slash = resolveCodexSlashCommand(text, { commands: await listSlashCommands('codex', this.bootstrap.workingDirectory), permissionMode: this.settings.permissionMode === 'yolo' || this.settings.permissionMode === 'read-only' || this.settings.permissionMode === 'autoReview' ? this.settings.permissionMode : 'default', collaborationMode: this.settings.collaborationMode ?? 'default', model: this.settings.model ?? undefined, modelReasoningEffort: parseReasoningEffortValue(this.settings.modelReasoningEffort), serviceTier: this.settings.serviceTier }); if (slash.kind === 'passthrough') return text; if (slash.kind === 'goal') { const params = { threadId: this.threadId }; const response = slash.action === 'show' ? await this.client.request('thread/goal/get', params) : slash.action === 'clear' ? await this.client.request('thread/goal/clear', params) : await this.client.request('thread/goal/set', { ...params, ...(slash.action === 'set' ? { objective: slash.objective } : { status: slash.action === 'pause' ? 'paused' : 'active' }) }); this.notice(JSON.stringify(response)); return null; } if (slash.updates?.proactiveMultiAgent !== undefined) throw new Error('This Codex version uses Ultra reasoning effort instead of a multi-agent toggle'); if (slash.updates?.model === null) throw new Error('Choose an explicit model in a shared thread'); if (slash.updates) await this.applySettings(slash.updates); if (slash.message) this.notice(slash.message); return slash.kind === 'replace' ? slash.text : null; } stopAccepting(): void { this.stopping = true; this.ready(); this.publishSteering(); this.publishPlan(); this.session.onReconnect(null); this.client.setTransportAbandonedHandler(null); } async suspend(): Promise { this.stopAccepting(); await this.work.catch(() => {}); await Promise.allSettled([...this.controls]); await this.queue?.suspend(); // Capture any dequeue/turn that won the race with native deletion. if (this.threadId && this.client.isInitialized()) await this.refresh(); } close(archived: boolean): Promise { return this.closing ??= (async () => { this.stopAccepting(); this.closed = true; clearInterval(this.heartbeat); this.permissions?.close(); await this.client.disconnect(); await this.notifications; await this.queue?.flush(); this.bridge?.server.stop(); if (archived) this.session.updateMetadata(metadata => ({ ...metadata, lifecycleState: 'archived', lifecycleStateSince: Date.now() })); // Inactive is resumable; uploads referenced by pending input must survive. this.session.sendSessionDeath(undefined, { preserveUploads: !archived }); await this.session.flush(); this.session.close(); })(); } }