| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703 |
- /**
- * 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<string, unknown>
- /** 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<Record<CodexPermissionMode, JsonObject>> = {
- 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<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
- if (signal.aborted) {
- void pending.catch(() => {})
- throw abortError(signal)
- }
- let rejectAbort!: (error: Error) => void
- const aborted = new Promise<never>((_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<never>()
- 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<typeof unattendedDiagnostic>[1]
- readonly decision: Parameters<typeof unattendedDiagnostic>[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<void> {
- 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<void> {
- 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<SubagentResult> {
- 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<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
- 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<typeof unattendedDiagnostic>[1],
- decision: Parameters<typeof unattendedDiagnostic>[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<typeof unattendedDiagnostic>[1],
- decision: Parameters<typeof unattendedDiagnostic>[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<unknown> {
- 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(),
- })
- }
- }
|