/** * Minimal Codex app-server 0.149.1 protocol adapter. The shared JSON-RPC * transport owns framing and request correlation; this module owns only the * product methods, current thread/turn association, unattended approval * responses, and terminal-answer selection. * * @module @deepseek-ai/dsh-subagent-codex/wire */ import type { Readable, Writable } from 'node:stream' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentResult } from '@deepseek-ai/dsh-subagent' import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' import type { CodexPermissionMode } from './run.ts' type JsonObject = Record /** Product facts owned by the Codex wire after publication. */ export interface CodexWireFailureFacts { readonly stage: 'turn-start' | 'turn' readonly category: | 'limit' | 'access-policy' | 'service' | 'transport' | 'product-error' | 'invalid-result' | 'unknown' readonly httpStatus?: number | undefined } const THREAD_PERMISSION_PARAMS: Readonly> = { never: { approvalPolicy: 'never' }, 'approve-for-me': { approvalPolicy: 'on-request', approvalsReviewer: 'auto_review', sandbox: 'workspace-write', }, 'dangerously-bypass-approvals-and-sandbox': { approvalPolicy: 'never', sandbox: 'danger-full-access', }, } function object(value: unknown, label: string): JsonObject { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`subagent-codex: app-server returned invalid ${label}`) } return value as JsonObject } function string(value: unknown, label: string): string { if (typeof value !== 'string' || value.length === 0) { throw new Error(`subagent-codex: app-server returned invalid ${label}`) } return value } function unattendedDecision(params: JsonObject): 'cancel' | 'decline' { const available = params.availableDecisions if (available === undefined || available === null) return 'decline' if (Array.isArray(available)) { if (available.includes('cancel')) return 'cancel' if (available.includes('decline')) return 'decline' } throw new Error('subagent-codex: app-server offered no unattended approval decision') } function numericHttpStatus(value: unknown): number | undefined { return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 65_535 ? value : undefined } interface ParsedFailureInfo { readonly category: CodexWireFailureFacts['category'] readonly httpStatus?: number | undefined readonly maxTokens?: true readonly sandboxFailure?: true } function objectFailureInfo(value: JsonObject): ParsedFailureInfo { const keys = Object.keys(value) const category = keys[0] if (keys.length !== 1 || category === undefined) { return { category: 'unknown' } } const detail = value[category] if (detail === null || typeof detail !== 'object' || Array.isArray(detail)) { return { category: 'unknown' } } const fields = detail as JsonObject switch (category) { case 'httpConnectionFailed': case 'responseStreamConnectionFailed': case 'responseStreamDisconnected': case 'responseTooManyFailedAttempts': { const httpStatus = numericHttpStatus(fields.httpStatusCode) return httpStatus === undefined ? { category: 'transport' } : { category: 'transport', httpStatus } } case 'activeTurnNotSteerable': return { category: 'product-error' } default: return { category: 'unknown' } } } function failureInfo(turn: JsonObject): ParsedFailureInfo { if (turn.status !== 'failed') return { category: 'unknown' } const error = turn.error if (error === null || typeof error !== 'object' || Array.isArray(error)) { return { category: 'unknown' } } const info = (error as JsonObject).codexErrorInfo if (typeof info === 'string') { switch (info) { case 'contextWindowExceeded': return { category: 'limit', maxTokens: true } case 'sessionBudgetExceeded': case 'usageLimitExceeded': return { category: 'limit' } case 'serverOverloaded': case 'internalServerError': return { category: 'service' } case 'cyberPolicy': case 'misalignmentPolicyViolation': case 'unauthorized': return { category: 'access-policy' } case 'badRequest': case 'threadRollbackFailed': case 'other': return { category: 'product-error' } case 'sandboxError': return { category: 'access-policy', sandboxFailure: true } default: return { category: 'unknown' } } } return info !== null && typeof info === 'object' && !Array.isArray(info) ? objectFailureInfo(info as JsonObject) : { category: 'unknown' } } function unattendedDiagnostic( mode: CodexPermissionMode, request: 'command approval' | 'file approval' | 'permission grant' | 'user input' | 'MCP elicitation' | 'command execution' | 'file change' | 'sandbox execution', decision: 'cancelled' | 'declined' | 'denied' | 'empty response' | 'failed', reason: string, ): string { return `Codex unattended decision (mode: ${mode}; request: ${request}; decision: ${decision}): ${reason}` } function thrown(value: unknown): Error { /* v8 ignore next -- typed protocol and stream failures reject with Error. */ return value instanceof Error ? value : new Error(String(value)) } function abortError(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason : new Error(`subagent-codex: app-server request aborted: ${String(signal.reason)}`) } async function raceAbort(pending: Promise, signal: AbortSignal): Promise { if (signal.aborted) { void pending.catch(() => {}) throw abortError(signal) } let rejectAbort!: (error: Error) => void const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) const onAbort = (): void => { rejectAbort(abortError(signal)) } signal.addEventListener('abort', onAbort, { once: true }) try { return await Promise.race([pending, aborted]) } finally { signal.removeEventListener('abort', onAbort) } } /** * One app-server connection and its single ephemeral thread/turn. * * The class deliberately exposes no generic request surface. Supporting * another product method must first become part of the provider contract. */ export class CodexAppServerWire { private readonly transport: JsonRpcLineTransport private readonly fatal = Promise.withResolvers() private threadId: string | undefined private turnId: string | undefined private pendingTurnId: string | undefined private turnCompleted: PromiseWithResolvers<{ readonly params: JsonObject readonly order: number }> | undefined private readonly earlyTurnNotifications: Array<{ readonly method: string readonly params: JsonObject readonly order: number }> = [] private lastFinalAnswer: string | undefined private lastUnphasedAnswer: string | undefined private diagnostic: string | undefined private failure: CodexWireFailureFacts | undefined private diagnosticOrder = 0 private observationOrder = 0 private pendingDiagnostic: { readonly order: number readonly request: Parameters[1] readonly decision: Parameters[2] readonly reason: string } | undefined private inputEnded = false private terminalObserved = false private closed = false constructor( private readonly input: Readable, output: Writable, private readonly permissionMode: CodexPermissionMode, private readonly model?: string, ) { this.transport = new JsonRpcLineTransport(input, output) // Fatal protocol state can arrive after the current guarded operation has // already settled. Keep the shared rejection observed without inserting // another promise-adoption hop into active races. void this.fatal.promise.catch(() => {}) this.transport.onRequest((method, params) => this.handleServerRequest(method, params)) this.transport.onNotification((method, params) => { try { this.handleNotification(method, params) } catch (error: unknown) { this.fail(thrown(error)) } }) this.input.on('error', this.onInputError) this.input.on('end', this.onInputEnd) // Pipe errors can race protocol closure and process teardown. Retain both // error listeners for the lifetime of their per-run streams so no late // EPIPE or read failure becomes an unhandled EventEmitter error. output.on('error', this.onOutputError) } /** Start reading app-server frames. */ start(): void { this.transport.start() } /** * Whether protocol output ended before a terminal turn notification. * @returns `true` only for an early protocol close without a terminal turn. */ endedBeforeTerminal(): boolean { return this.inputEnded && !this.terminalObserved } /** * Perform the required app-server initialize/initialized handshake. * @param signal - unpublished-start cancellation. */ async initialize(signal: AbortSignal): Promise { object(await this.guarded(this.transport.request('initialize', { clientInfo: { name: 'deepseek-harness', title: 'DeepSeek Harness', version: '0.0.1', }, capabilities: { experimentalApi: false, requestAttestation: false, }, }, signal), signal), 'initialize response') this.transport.notify('initialized') await this.guarded(this.transport.flush(), signal) } /** * Create the run's private ephemeral thread and retain its identity. * @param cwd - parent Session workspace. * @param signal - unpublished-start cancellation. */ async startThread(cwd: string, signal: AbortSignal): Promise { const response = object(await this.guarded(this.transport.request('thread/start', { cwd, ephemeral: true, ...this.model === undefined ? {} : { model: this.model }, ...THREAD_PERMISSION_PARAMS[this.permissionMode], }, signal), signal), 'thread/start response') const thread = object(response.thread, 'thread/start thread') const id = string(thread.id, 'thread/start thread id') if (thread.ephemeral !== true) { throw new Error('subagent-codex: app-server did not create an ephemeral thread') } this.threadId = id } /** * Submit the one text-only task and wait for this thread/turn's authoritative * terminal notification. * @param texts - already validated task text blocks. * @param signal - local cancellation for the published run. * @returns the shared subagent result. */ async runTurn( texts: readonly string[], signal: AbortSignal, ): Promise { const completion = Promise.withResolvers<{ readonly params: JsonObject readonly order: number }>() this.turnCompleted = completion const threadId = this.threadId as string try { const response = object(await this.guarded(this.transport.request('turn/start', { threadId, input: texts.map(text => ({ type: 'text', text, text_elements: [] })), }, signal), signal), 'turn/start response') const turn = object(response.turn, 'turn/start turn') this.commitTurnId(string(turn.id, 'turn/start turn id')) } catch (error: unknown) { this.recordFailure({ stage: 'turn-start', category: 'unknown' }) throw error } let completed: { readonly params: JsonObject readonly order: number } let terminal: JsonObject try { completed = await this.guarded(completion.promise, signal) terminal = object(completed.params.turn, 'turn/completed turn') } catch (error: unknown) { this.recordFailure({ stage: 'turn', category: 'unknown' }) throw error } const status = terminal.status if (status !== 'completed') { const parsed = failureInfo(terminal) this.recordFailure(parsed.httpStatus === undefined ? { stage: 'turn', category: parsed.category } : { stage: 'turn', category: parsed.category, httpStatus: parsed.httpStatus, }) if (parsed.sandboxFailure) { this.recordDiagnostic( 'sandbox execution', 'failed', 'Codex reported a sandbox failure', completed.order, ) } if (parsed.maxTokens) { return { output: this.collectOutput(), stopReason: 'max-tokens' } } const detail = status === 'failed' ? `: ${parsed.category}` : '' throw new Error(`subagent-codex: Codex turn ended with status ${String(status)}${detail}`) } const output = this.collectOutput() if (output.length === 0) { this.recordFailure({ stage: 'turn', category: 'invalid-result' }) throw new Error('subagent-codex: Codex completed without a final answer') } return { output, stopReason: 'completed' } } /** * Best-effort remote cancellation. Local settlement and process teardown * remain authoritative when the child no longer accepts protocol requests. */ interrupt(): void { if (this.threadId === undefined || this.turnId === undefined || this.closed) return void this.transport.request('turn/interrupt', { threadId: this.threadId, turnId: this.turnId, }).catch(() => {}) } /** * The best non-commentary answer observed so far, preserving exact bytes. * @returns the selected final or nullable-phase text block, if any. */ collectOutput(): ContentBlock[] { const selected = this.lastFinalAnswer ?? this.lastUnphasedAnswer return selected !== undefined && selected.trim().length > 0 ? [{ type: 'text', text: selected }] : [] } /** * The latest safe unattended permission fact observed for this run. * @returns provider-authored diagnostic text, when one was observed. */ collectDiagnostic(): string | undefined { return this.diagnostic } /** * The structured failure fact observed for this published turn. * Call only after a non-completed return or rejection from {@link runTurn}. * @returns the fixed stage/category pair and optional HTTP status. */ collectFailure(): CodexWireFailureFacts { return this.failure as CodexWireFailureFacts } /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ close(): void { if (this.closed) return this.closed = true this.input.off('end', this.onInputEnd) this.transport.close() } private async guarded(pending: Promise, signal: AbortSignal): Promise { const withFatal = Promise.race([this.fatal.promise, pending]) return raceAbort(withFatal, signal) } private fail(error: Error): void { this.fatal.reject(error) } private readonly onInputError = (error: Error): void => { this.fail(error) } private readonly onOutputError = (error: Error): void => { this.fail(error) } private readonly onInputEnd = (): void => { this.inputEnded = true this.fail(new Error('subagent-codex: app-server protocol stream closed')) } private observePendingTurnId(id: string): void { if (this.turnCompleted === undefined) { throw new Error('subagent-codex: app-server referenced a turn before turn/start') } if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) { throw new Error('subagent-codex: app-server referenced conflicting turns') } this.pendingTurnId = id } private commitTurnId(id: string): void { if (this.pendingTurnId !== undefined && this.pendingTurnId !== id) { throw new Error('subagent-codex: turn/start response did not match the active turn') } this.turnId = id const pendingDiagnostic = this.pendingDiagnostic this.pendingDiagnostic = undefined if (pendingDiagnostic !== undefined) { this.recordDiagnostic( pendingDiagnostic.request, pendingDiagnostic.decision, pendingDiagnostic.reason, pendingDiagnostic.order, ) } const notifications = this.earlyTurnNotifications.splice(0) for (const notification of notifications) { this.handleNotification( notification.method, notification.params, notification.order, ) } } /** * Validate the request's thread and turn association. * @returns `true` when the matching turn is still provisional, so the caller * defers its diagnostic until `commitTurnId()`. */ private validateRunIds( params: JsonObject, nullableTurn = false, ): boolean { if (params.threadId !== this.threadId) { throw new Error('subagent-codex: app-server request referenced another thread') } if (nullableTurn && params.turnId === null) return false const id = string(params.turnId, 'server request turn id') if (this.turnId === undefined) { this.observePendingTurnId(id) return true } if (id !== this.turnId) { throw new Error('subagent-codex: app-server request referenced another turn') } return false } private recordRequestDiagnostic( provisional: boolean, request: Parameters[1], decision: Parameters[2], reason: string, ): void { const order = this.nextObservationOrder() if (provisional) { this.pendingDiagnostic = { order, request, decision, reason, } return } this.recordDiagnostic(request, decision, reason, order) } private recordDiagnostic( request: Parameters[1], decision: Parameters[2], reason: string, order = this.nextObservationOrder(), ): void { if (order < this.diagnosticOrder) return this.diagnosticOrder = order this.diagnostic = unattendedDiagnostic( this.permissionMode, request, decision, reason, ) } private recordFailure(facts: CodexWireFailureFacts): void { this.failure = facts } private nextObservationOrder(): number { this.observationOrder += 1 return this.observationOrder } private recordDeclinedItem(item: JsonObject, order?: number): boolean { if (item.type === 'commandExecution' && item.status === 'declined') { this.recordDiagnostic( 'command execution', 'declined', 'Codex declined the command under the selected permission mode', order, ) return true } if (item.type === 'fileChange' && item.status === 'declined') { this.recordDiagnostic( 'file change', 'declined', 'Codex declined the file change under the selected permission mode', order, ) return true } return false } private handleServerRequest(method: string, params: JsonObject): Promise { try { switch (method) { case 'item/commandExecution/requestApproval': { const provisional = this.validateRunIds(params) const decision = unattendedDecision(params) this.recordRequestDiagnostic( provisional, 'command approval', decision === 'cancel' ? 'cancelled' : 'declined', 'the provider does not grant interactive approval', ) return Promise.resolve({ decision }) } case 'item/fileChange/requestApproval': { const provisional = this.validateRunIds(params) const decision = unattendedDecision(params) this.recordRequestDiagnostic( provisional, 'file approval', decision === 'cancel' ? 'cancelled' : 'declined', 'the provider does not grant interactive approval', ) return Promise.resolve({ decision }) } case 'item/permissions/requestApproval': this.recordRequestDiagnostic( this.validateRunIds(params), 'permission grant', 'denied', 'the provider grants no additional turn permissions', ) return Promise.resolve({ permissions: {}, scope: 'turn' }) case 'item/tool/requestUserInput': this.recordRequestDiagnostic( this.validateRunIds(params), 'user input', 'empty response', 'the provider does not collect interactive answers', ) return Promise.resolve({ answers: {} }) case 'mcpServer/elicitation/request': this.recordRequestDiagnostic( this.validateRunIds(params, true), 'MCP elicitation', 'declined', 'the provider does not collect interactive MCP input', ) return Promise.resolve({ action: 'decline', content: null, _meta: null }) default: throw new Error(`subagent-codex: unsupported app-server request ${JSON.stringify(method)}`) } } catch (error: unknown) { const normalized = thrown(error) this.fail(normalized) return Promise.reject(normalized) } } private handleNotification( method: string, params: JsonObject, order?: number, ): void { if (method === 'turn/started') { const threadId = string(params.threadId, 'turn/started thread id') if (threadId !== this.threadId) return const turn = object(params.turn, 'turn/started turn') if (this.turnCompleted !== undefined && this.turnId === undefined) { this.observePendingTurnId(string(turn.id, 'turn/started turn id')) } return } if (method === 'item/completed') { const threadId = string(params.threadId, 'item/completed thread id') if (threadId !== this.threadId) return const id = string(params.turnId, 'item/completed turn id') if (this.turnId === undefined) { if (this.turnCompleted !== undefined) { this.observePendingTurnId(id) this.earlyTurnNotifications.push({ method, params, order: this.nextObservationOrder(), }) } return } if (id !== this.turnId) return const item = object(params.item, 'item/completed item') if (this.recordDeclinedItem(item, order)) return if (item.type !== 'agentMessage') return const text = typeof item.text === 'string' ? item.text : (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })() if (item.phase === 'final_answer') { this.lastFinalAnswer = text } else if (item.phase === null) { this.lastUnphasedAnswer = text } else if (item.phase !== 'commentary') { throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`) } return } if (method !== 'turn/completed') return const threadId = string(params.threadId, 'turn/completed thread id') if (threadId !== this.threadId) return const turn = object(params.turn, 'turn/completed turn') const id = string(turn.id, 'turn/completed turn id') const turnCompleted = this.turnCompleted if (turnCompleted === undefined) return if (this.turnId === undefined) { this.observePendingTurnId(id) this.earlyTurnNotifications.push({ method, params, order: this.nextObservationOrder(), }) return } if (id !== this.turnId) return this.terminalObserved = true if (!['completed', 'interrupted', 'failed'].includes(String(turn.status))) { throw new Error(`subagent-codex: app-server returned invalid terminal turn status ${String(turn.status)}`) } turnCompleted.resolve({ params, order: order ?? this.nextObservationOrder(), }) } }