import type { BrowserWindow } from 'electron'

import type { AgentSdkCapabilities, AgentSdkImplementer, PromptOptions } from './agent-runtime-types'
import type { AgentRuntimeAdapter } from './agent-runtime-types'
import { CODEX_CAPABILITIES } from './agent-runtime-types'
import {
  getAvailableCodexModels,
  getCodexModelInfo,
  CODEX_DEFAULT_MODEL,
  resolveCodexModelSlug
} from './codex-models'
import { createLogger } from './logger'
import { CodexAppServerManager, type CodexManagerEvent } from './codex-app-server-manager'
import { mapCodexManagerEventToActivity } from './codex-activity-mapper'
import {
  mapCodexEventToStreamEvents,
  contentStreamKindFromMethod,
  createCodexMapperState,
  type CodexMapperState
} from './codex-event-mapper'
import { ensureCodexAppServerLaunchSpec } from './codex-binary-resolver'
import { asNumber, asObject, asString, toDebugSnapshot } from './codex-utils'
import { generateCodexSessionTitle } from './codex-session-title'
import type { DatabaseService } from '../db/database'
import { autoRenameWorktreeBranch } from './git-service'
import { beginSessionRun, emitAgentEvent } from '@shared/lib/normalize-agent-event'
import { notificationService } from './notification-service'

const log = createLogger({ component: 'CodexImplementer' })

// ── Session state ─────────────────────────────────────────────────

export interface CodexSessionState {
  threadId: string
  hiveSessionId: string
  worktreePath: string
  status: 'connecting' | 'ready' | 'running' | 'error' | 'closed'
  messages: unknown[]
  liveAssistantDraft?: CodexLiveAssistantDraft | null
  revertMessageID: string | null
  revertDiff: string | null
  titleGenerated: boolean
  titleGenerationStarted: boolean
  /**
   * Per-session mapper state. Codex commandExecution streams stdout via
   * outputDelta chunks; the mapper aggregates them into state.output here so
   * the renderer's last-write-wins merge produces the full output, not just
   * the latest delta.
   */
  mapperState: CodexMapperState
  /**
   * Per-turn array of wall-clock timestamps captured from `item/started`
   * events as they stream in. Position-indexed: the N-th `item/started`
   * we see for a turn lands at index N. parseThreadSnapshot uses the SAME
   * positional index to assign timestamps because codex's thread/read
   * response renumbers item ids (`item-1`, `item-2`, …), so an id-keyed
   * lookup would miss every time. Position is the only reliable bridge
   * between streaming and the snapshot.
   */
  itemTimestampsByTurn: Map<string, string[]>
}

interface CodexLiveToolPart {
  type: 'tool'
  callID: string
  tool: string
  state: {
    status: 'running' | 'completed' | 'error'
    input?: unknown
    output?: unknown
    error?: unknown
  }
}

type CodexLiveDraftPart =
  | { type: 'text'; text: string; timestamp: string }
  | { type: 'reasoning'; text: string; timestamp: string }
  | CodexLiveToolPart

interface CodexLiveAssistantDraft {
  id: string
  timestamp: string
  parts: CodexLiveDraftPart[]
  toolIndexById: Map<string, number>
}

// ── Pending HITL entry (shared by questions and approvals) ────────

interface PendingHitlEntry {
  threadId: string
  hiveSessionId: string
  worktreePath: string
  turnId?: string
  /** Snapshot of the questions[] payload so we can persist it on resolution. */
  questions?: unknown[]
}

interface CodexPermissionRequest {
  id: string
  sessionID: string
  permission: string
  patterns: string[]
  metadata: Record<string, unknown>
  always: string[]
}

/**
 * Extracts the markdown content from a `<proposed_plan>` XML block.
 * Returns the inner content trimmed, or null if no block is found.
 */
function extractProposedPlanMarkdown(text: string): string | null {
  const match = text.match(/<proposed_plan>\s*([\s\S]*?)\s*<\/proposed_plan>/i)
  return match ? (match[1]?.trim() ?? null) : null
}

function buildCodexGoalObjective(promptText: string, successCriteria?: string): string {
  const objective = promptText.trim()
  const criteria = successCriteria?.trim() ?? ''

  if (!objective) {
    return criteria ? `Success criteria:\n${criteria}` : ''
  }

  if (!criteria) {
    return objective
  }

  return `${objective}\n\nSuccess criteria:\n${criteria}`
}

function isCodexGoalUnavailableError(error: unknown): boolean {
  const message = error instanceof Error ? error.message : String(error)
  const lowered = message.toLowerCase()

  if (lowered.includes('goals feature is disabled')) return true
  if (!lowered.includes('thread/goal/set') && !lowered.includes('setthreadgoal')) return false

  return (
    lowered.includes('method not found') ||
    lowered.includes('unknown method') ||
    lowered.includes('no such method') ||
    lowered.includes('not supported') ||
    lowered.includes('unsupported') ||
    lowered.includes('feature is disabled')
  )
}

function appendCodexGoalFallbackPrompt(promptText: string, goalObjective: string): string {
  const prompt = promptText.trimEnd()
  const objective = goalObjective.trim()

  if (!objective) return prompt

  if (objective === promptText.trim()) {
    return `${prompt}\n\n[Xuanpu Goal]\nTreat the user message above as the objective and completion contract for this turn.`
  }

  return `${prompt}\n\n[Xuanpu Goal]\nTreat this goal and any success criteria as the completion contract for this turn:\n${objective}`
}

// ── Immediate title helpers ────────────────────────────────────────────────

const IMMEDIATE_TITLE_LENGTH = 50

function truncateForImmediateTitle(text: string): string {
  const trimmed = text.trim().split(/\r?\n/, 1)[0]?.trim() ?? ''
  if (!trimmed) return ''
  if (trimmed.length <= IMMEDIATE_TITLE_LENGTH) return trimmed
  return trimmed.slice(0, IMMEDIATE_TITLE_LENGTH - 3) + '...'
}

export function normalizeCodexMessageTimestamps<T extends { created_at: string }>(rows: T[]): T[] {
  let lastTimestampMs = Number.NEGATIVE_INFINITY

  return rows.map((row) => {
    const parsed = Date.parse(row.created_at)
    const baseTimestampMs = Number.isFinite(parsed) ? parsed : Date.now()
    const nextTimestampMs =
      baseTimestampMs > lastTimestampMs ? baseTimestampMs : lastTimestampMs + 1
    lastTimestampMs = nextTimestampMs

    return {
      ...row,
      created_at: new Date(nextTimestampMs).toISOString()
    }
  })
}

export class CodexImplementer implements AgentSdkImplementer, AgentRuntimeAdapter {
  readonly id = 'codex' as const
  readonly capabilities: AgentSdkCapabilities = CODEX_CAPABILITIES

  private mainWindow: BrowserWindow | null = null
  private dbService: DatabaseService | null = null
  private selectedModel: string = CODEX_DEFAULT_MODEL
  private selectedVariant: string | undefined
  private manager: CodexAppServerManager = new CodexAppServerManager()
  private sessions = new Map<string, CodexSessionState>()
  private pendingQuestions = new Map<string, PendingHitlEntry>()
  private pendingApprovalSessions = new Map<string, PendingHitlEntry>()

  // ── Window binding ───────────────────────────────────────────────

  setMainWindow(window: BrowserWindow): void {
    this.mainWindow = window
  }

  setDatabaseService(db: DatabaseService): void {
    this.dbService = db
  }

  private async resolveLaunchSpec() {
    return ensureCodexAppServerLaunchSpec()
  }

  private maybeNotifyPendingUserFeedback(
    hiveSessionId: string,
    kind: 'question' | 'approval'
  ): void {
    try {
      if (!this.mainWindow || this.mainWindow.isDestroyed() || this.mainWindow.isFocused()) {
        return
      }

      if (!this.dbService) return

      const session = this.dbService.getSession(hiveSessionId)
      if (!session) return

      const project = this.dbService.getProject(session.project_id)
      if (!project) return

      notificationService.showPendingUserFeedback(
        {
          projectName: project.name,
          sessionName: session.name || 'Untitled',
          projectId: session.project_id,
          worktreeId: session.worktree_id || '',
          sessionId: hiveSessionId
        },
        kind
      )
    } catch (error) {
      log.warn('Failed to show pending user feedback notification', {
        hiveSessionId,
        kind,
        error
      })
    }
  }

  // ── Manager event listener (handles approval/question routing) ──

  private managerListenerAttached = false

  private attachManagerListener(): void {
    if (this.managerListenerAttached) return
    this.managerListenerAttached = true

    this.manager.on('event', (event: CodexManagerEvent) => {
      this.handleManagerEvent(event)
    })
  }

  private handleManagerEvent(event: CodexManagerEvent): void {
    // DEBUG: Log ALL notification events to discover title-related methods
    if (event.kind === 'notification') {
      log.info('DEBUG handleManagerEvent: notification received', {
        method: event.method,
        threadId: event.threadId,
        payloadKeys: event.payload ? Object.keys(event.payload as Record<string, unknown>) : [],
        payloadSnapshot: toDebugSnapshot(event.payload, 500)
      })
    }

    const targetSession = this.findSessionByThreadId(event.threadId)
    if (targetSession) {
      this.persistActivity(targetSession, event)

      // Record per-turn item arrival timestamps so parseThreadSnapshot can
      // assign chronologically-correct timestamps later. Codex's thread/read
      // response renumbers item ids (`item-1`, `item-2`, …) so id-keyed
      // matching always misses; positional matching (turn → ordered list of
      // ts) is the reliable bridge between streaming and snapshot.
      if (
        event.kind === 'notification' &&
        (event.method === 'item/started' || event.method === 'item.started')
      ) {
        const turnId =
          event.turnId ?? asString(asObject(event.payload)?.turnId) ?? null
        if (turnId) {
          const list =
            targetSession.itemTimestampsByTurn.get(turnId) ??
            (targetSession.itemTimestampsByTurn.set(turnId, []),
            targetSession.itemTimestampsByTurn.get(turnId)!)
          list.push(new Date().toISOString())
        }
      }

      // Phase 21.5: emit agent.* field events when a tool item completes.
      // Wrapped in try/catch — instrumentation failure must never affect
      // the main event flow.
      if (event.kind === 'notification' && event.method === 'item/completed') {
        try {
          this.emitAgentToolField(targetSession, event)
        } catch (err) {
          log.debug('Phase 21.5 emit failed; continuing', {
            error: err instanceof Error ? err.message : String(err)
          })
        }
      }
    }

    // Clean up stale pending entries when a session closes
    if (
      event.kind === 'session' &&
      (event.method === 'session/closed' || event.method === 'session/exited')
    ) {
      this.cleanupPendingForThread(event.threadId)
      return
    }

    // Handle thread name updates from the Codex provider (title generation)
    if (event.kind === 'notification' && event.method === 'thread/name/updated') {
      this.handleProviderTitleUpdate(event).catch(() => {})
      return
    }

    // Handle token usage updates from the Codex provider
    if (event.kind === 'notification' && event.method === 'thread/tokenUsage/updated') {
      if (!targetSession) return

      const payload = asObject(event.payload)
      const tokenUsage = asObject(payload?.tokenUsage)
      // Use "last" (per-turn prompt size) not "total" (cumulative).
      // The context window shows how full the current prompt is,
      // not the sum of all tokens consumed across every turn.
      const last = asObject(tokenUsage?.last)
      const contextWindow = asNumber(tokenUsage?.modelContextWindow) ?? 0

      const inputTokens = asNumber(last?.inputTokens) ?? 0
      const cachedInputTokens = asNumber(last?.cachedInputTokens) ?? 0
      const outputTokens = asNumber(last?.outputTokens) ?? 0
      const reasoningTokens = asNumber(last?.reasoningOutputTokens) ?? 0

      const modelID = resolveCodexModelSlug(asString(payload?.model) ?? this.selectedModel)

      emitAgentEvent(this.mainWindow, {
        type: 'session.context_usage',
        sessionId: targetSession.hiveSessionId,
        data: {
          tokens: {
            // inputTokens includes cached; subtract so
            // input + cacheRead = total prompt tokens in store
            input: inputTokens - cachedInputTokens,
            cacheRead: cachedInputTokens,
            cacheWrite: 0,
            output: outputTokens,
            reasoning: reasoningTokens
          },
          model: { providerID: 'codex', modelID },
          contextWindow
        }
      })
      return
    }

    // Handle thread compaction notifications
    if (event.kind === 'notification' && event.method === 'thread/compacted') {
      if (!targetSession) return

      emitAgentEvent(this.mainWindow, {
        type: 'session.context_compacted',
        sessionId: targetSession.hiveSessionId,
        data: {}
      })
      return
    }

    // Only handle request events (approvals + user inputs)
    if (event.kind !== 'request') return

    if (!targetSession) return

    const requestId = event.requestId
    if (!requestId) return

    // Handle approval requests
    if (
      event.method === 'item/commandExecution/requestApproval' ||
      event.method === 'item/fileChange/requestApproval' ||
      event.method === 'item/fileRead/requestApproval'
    ) {
      this.pendingApprovalSessions.set(requestId, {
        threadId: targetSession.threadId,
        hiveSessionId: targetSession.hiveSessionId,
        worktreePath: targetSession.worktreePath,
        turnId:
          event.turnId ??
          this.manager.getSession(targetSession.threadId)?.activeTurnId ??
          undefined
      })

      const payload = asObject(event.payload)
      emitAgentEvent(this.mainWindow, {
        type: 'permission.asked',
        sessionId: targetSession.hiveSessionId,
        data: this.toPermissionRequest(
          requestId,
          targetSession.hiveSessionId,
          event.method,
          payload,
          event.turnId,
          event.itemId
        )
      })
      this.maybeNotifyPendingUserFeedback(targetSession.hiveSessionId, 'approval')
      return
    }

    // Handle user input requests (questions)
    if (event.method === 'item/tool/requestUserInput') {
      this.pendingQuestions.set(requestId, {
        threadId: targetSession.threadId,
        hiveSessionId: targetSession.hiveSessionId,
        worktreePath: targetSession.worktreePath,
        // Same fallback as the durable activity below — codex's
        // requestUserInput JSON-RPC params don't always carry turn.id.
        turnId:
          event.turnId ??
          this.manager.getSession(targetSession.threadId)?.activeTurnId ??
          undefined
      })

      const payload = asObject(event.payload)
      // Codex's request_user_input MCP tool may carry the question in any of
      // several shapes (depending on how the agent invoked it). Normalize so
      // the UI always receives a non-empty `questions` array — otherwise
      // AskUserCard would silently drop the request and the turn would hang
      // forever waiting for a reply that can never arrive.
      let questions = (payload?.questions ?? payload?.items ?? []) as unknown[]
      if (!Array.isArray(questions) || questions.length === 0) {
        const item = asObject(payload?.item)
        const args = asObject(item?.arguments) ?? asObject(payload?.arguments)
        const prompt =
          asString(args?.prompt) ??
          asString(args?.question) ??
          asString(payload?.prompt) ??
          asString(payload?.question) ??
          'Codex requested user input.'
        const optionsRaw = (args?.options ?? args?.choices ?? payload?.options) as unknown
        const options = Array.isArray(optionsRaw)
          ? optionsRaw.map((opt) =>
              typeof opt === 'string'
                ? { label: opt, description: '' }
                : ((opt as { label?: string; description?: string }) ?? { label: '', description: '' })
            )
          : []
        questions = [
          {
            question: prompt,
            header: 'Codex',
            multiple: false,
            options
          }
        ]
        log.info('codex requestUserInput: synthesized fallback question', {
          requestId,
          prompt,
          optionCount: options.length,
          payloadKeys: payload ? Object.keys(payload) : []
        })
      }

      // Snapshot questions on the pending entry so questionReply can include
      // them in the durable record (input.questions).
      const pending = this.pendingQuestions.get(requestId)
      if (pending) pending.questions = questions

      emitAgentEvent(this.mainWindow, {
        type: 'question.asked',
        sessionId: targetSession.hiveSessionId,
        data: {
          requestId,
          id: requestId,
          questions
        }
      })

      // Also surface as a synthetic tool part so AgentTimeline renders the
      // existing AskUserCard (it dispatches on tool name === 'AskUserQuestion').
      // Without this, the question would only appear as a composer placeholder
      // change and the user would have nowhere to click an answer.
      emitAgentEvent(this.mainWindow, {
        type: 'message.part.updated',
        sessionId: targetSession.hiveSessionId,
        data: {
          part: {
            type: 'tool',
            callID: requestId,
            tool: 'AskUserQuestion',
            state: {
              status: 'running',
              input: { questions }
            }
          }
        }
      })

      // Persist a tool.started activity so the durable timeline keeps the
      // AskUserCard around after the turn ends. Without this, the synthetic
      // tool part lives only in the streaming overlay and disappears as
      // soon as the live transcript is sealed.
      if (this.dbService) {
        try {
          this.dbService.upsertSessionActivity({
            id: `${requestId}:asked`,
            session_id: targetSession.hiveSessionId,
            agent_session_id: targetSession.threadId,
            thread_id: targetSession.threadId,
            // Fallback to the session's active turn — codex's
            // item/tool/requestUserInput JSON-RPC params don't always carry
            // turn.id, but without it the synthetic AskUserCard becomes
            // "unanchored" in timeline-mappers and gets pushed to the END of
            // the timeline (rendered after all post-question text). The
            // manager already tracks activeTurnId from turn/started events.
            turn_id:
              event.turnId ??
              this.manager.getSession(targetSession.threadId)?.activeTurnId ??
              null,
            item_id: requestId,
            request_id: requestId,
            kind: 'tool.started',
            tone: 'tool',
            summary: 'AskUserQuestion',
            payload_json: JSON.stringify({
              item: {
                type: 'AskUserQuestion',
                toolName: 'AskUserQuestion',
                id: requestId,
                input: { questions }
              }
            })
          })
        } catch (err) {
          log.warn('codex requestUserInput: failed to persist tool.started', {
            requestId,
            error: err instanceof Error ? err.message : String(err)
          })
        }
      }

      this.maybeNotifyPendingUserFeedback(targetSession.hiveSessionId, 'question')
    }
  }

  private async handleProviderTitleUpdate(event: CodexManagerEvent): Promise<void> {
    const payload = asObject(event.payload)
    log.info('DEBUG handleProviderTitleUpdate: raw payload', {
      payloadKeys: payload ? Object.keys(payload) : [],
      fullPayload: toDebugSnapshot(event.payload, 1000)
    })
    const title = asString(payload?.threadName)
    if (!title) {
      log.warn(
        'DEBUG handleProviderTitleUpdate: threadName field empty/missing, tried payload?.threadName'
      )
      return
    }

    // Find session by threadId
    let targetSession: CodexSessionState | undefined
    for (const session of this.sessions.values()) {
      if (session.threadId === event.threadId) {
        targetSession = session
        break
      }
    }
    if (!targetSession) return

    await this.applyGeneratedTitle(targetSession, title)
  }

  // ── Lifecycle ────────────────────────────────────────────────────

  async connect(worktreePath: string, hiveSessionId: string): Promise<{ sessionId: string }> {
    const resolvedModel = resolveCodexModelSlug(this.selectedModel)
    log.info('Connecting', { worktreePath, hiveSessionId, model: resolvedModel })

    // Ensure the manager event listener is attached for HITL flows
    this.attachManagerListener()

    const providerSession = await this.manager.startSession({
      cwd: worktreePath,
      model: resolvedModel,
      codexLaunchSpec: await this.resolveLaunchSpec()
    })

    const threadId = providerSession.threadId
    if (!threadId) {
      throw new Error('Codex session started but no thread ID was returned.')
    }

    const key = this.getSessionKey(worktreePath, threadId)
    const state: CodexSessionState = {
      threadId,
      hiveSessionId,
      worktreePath,
      status: this.mapProviderStatus(providerSession.status),
      messages: [],
      liveAssistantDraft: null,
      revertMessageID: null,
      revertDiff: null,
      titleGenerated: false,
      titleGenerationStarted: false,
      mapperState: createCodexMapperState(),
      itemTimestampsByTurn: new Map()
    }
    this.sessions.set(key, state)

    // Notify renderer that the session has materialized
    emitAgentEvent(this.mainWindow, {
      type: 'session.materialized',
      sessionId: hiveSessionId,
      data: { newSessionId: threadId, wasFork: false }
    })

    log.info('Connected', { worktreePath, hiveSessionId, threadId })
    return { sessionId: threadId }
  }

  async reconnect(
    worktreePath: string,
    agentSessionId: string,
    hiveSessionId: string
  ): Promise<{
    success: boolean
    sessionStatus?: 'idle' | 'busy' | 'retry'
    revertMessageID?: string | null
  }> {
    const key = this.getSessionKey(worktreePath, agentSessionId)

    // If session already exists locally, just update the hiveSessionId
    const existing = this.sessions.get(key)
    if (existing) {
      existing.hiveSessionId = hiveSessionId
      const sessionStatus = this.statusToHive(existing.status)
      log.info('Reconnect: session already registered, updated hiveSessionId', {
        worktreePath,
        agentSessionId,
        hiveSessionId,
        sessionStatus
      })
      return { success: true, sessionStatus, revertMessageID: null }
    }

    // Otherwise, start a new session with thread resume
    try {
      // Ensure the manager event listener is attached so notifications
      // like thread/tokenUsage/updated reach handleManagerEvent.
      this.attachManagerListener()

      const resolvedModel = resolveCodexModelSlug(this.selectedModel)
      const providerSession = await this.manager.startSession({
        cwd: worktreePath,
        model: resolvedModel,
        resumeThreadId: agentSessionId,
        codexLaunchSpec: await this.resolveLaunchSpec()
      })

      const threadId = providerSession.threadId
      if (!threadId) {
        throw new Error('Codex session started but no thread ID was returned.')
      }

      const newKey = this.getSessionKey(worktreePath, threadId)
      const state: CodexSessionState = {
        threadId,
        hiveSessionId,
        worktreePath,
        status: this.mapProviderStatus(providerSession.status),
        messages: [],
        liveAssistantDraft: null,
        revertMessageID: null,
        revertDiff: null,
        titleGenerated: true,
        titleGenerationStarted: true,
        mapperState: createCodexMapperState(),
      itemTimestampsByTurn: new Map()
      }
      this.sessions.set(newKey, state)

      log.info('Reconnected via thread resume', { worktreePath, agentSessionId, threadId })

      // Fire-and-forget: hydrate token usage so the context bar shows
      // accumulated usage from previous turns, not 0/200k.
      this.hydrateTokenUsageFromThread(state).catch(() => {})

      return {
        success: true,
        sessionStatus: this.statusToHive(state.status),
        revertMessageID: null
      }
    } catch (error) {
      log.error('Reconnect failed', error instanceof Error ? error : new Error(String(error)), {
        worktreePath,
        agentSessionId
      })
      return { success: false }
    }
  }

  async disconnect(worktreePath: string, agentSessionId: string): Promise<void> {
    const key = this.getSessionKey(worktreePath, agentSessionId)
    const session = this.sessions.get(key)

    if (!session) {
      log.warn('Disconnect: session not found, ignoring', { worktreePath, agentSessionId })
      return
    }

    // Stop the manager session
    this.manager.stopSession(agentSessionId)

    // Clean up local state
    this.sessions.delete(key)
    this.cleanupPendingForThread(agentSessionId)

    log.info('Disconnected', { worktreePath, agentSessionId })
  }

  async cleanup(): Promise<void> {
    log.info('Cleaning up CodexImplementer state', { sessionCount: this.sessions.size })

    // Stop all manager sessions
    this.manager.stopAll()

    // Clear local state
    this.sessions.clear()
    this.pendingQuestions.clear()
    this.pendingApprovalSessions.clear()
    this.managerListenerAttached = false
    this.mainWindow = null
    this.selectedModel = CODEX_DEFAULT_MODEL
    this.selectedVariant = undefined
  }

  // ── Messaging ────────────────────────────────────────────────────

  async prompt(
    worktreePath: string,
    agentSessionId: string,
    message:
      | string
      | Array<
          | { type: 'text'; text: string }
          | { type: 'file'; mime: string; url: string; filename?: string }
        >,
    modelOverride?: { providerID: string; modelID: string; variant?: string },
    options?: PromptOptions
  ): Promise<void> {
    const key = this.getSessionKey(worktreePath, agentSessionId)
    const session = this.sessions.get(key)
    if (!session) {
      throw new Error(`Prompt failed: session not found for ${worktreePath} / ${agentSessionId}`)
    }

    beginSessionRun(session.hiveSessionId)

    // Extract text from message
    let text: string
    if (typeof message === 'string') {
      text = message
    } else {
      text = message
        .filter((part) => part.type === 'text')
        .map((part) => (part as { type: 'text'; text: string }).text)
        .join('\n')
    }

    if (!text.trim()) {
      log.warn('Prompt: empty text, ignoring', { worktreePath, agentSessionId })
      return
    }

    // Immediate title: set truncated first message as title for instant UX feedback
    const isFirstMessage = session.messages.length === 0 && !session.titleGenerated
    if (isFirstMessage) {
      session.titleGenerated = true
      const immediateTitle = truncateForImmediateTitle(text)
      if (immediateTitle && this.dbService) {
        this.dbService.updateSession(session.hiveSessionId, { name: immediateTitle })
        emitAgentEvent(this.mainWindow, {
          type: 'session.updated',
          sessionId: session.hiveSessionId,
          data: { title: immediateTitle, info: { title: immediateTitle } }
        })
        log.info('Prompt: set immediate title', {
          hiveSessionId: session.hiveSessionId,
          immediateTitle
        })
      }
    }

    if (!session.titleGenerationStarted) {
      session.titleGenerationStarted = true
      this.handleTitleGeneration(session, text).catch(() => {})
    }

    // Inject synthetic user message so getMessages() returns it
    const syntheticTimestamp = new Date().toISOString()
    session.messages.push({
      role: 'user',
      parts: [{ type: 'text', text, timestamp: syntheticTimestamp }],
      timestamp: syntheticTimestamp
    })
    this.persistCanonicalMessages(session)
    this.resetLiveAssistantDraft(session)

    // Emit busy status
    session.status = 'running'
    this.emitStatus(session.hiveSessionId, 'busy')

    log.info('Prompt: starting', {
      worktreePath,
      agentSessionId,
      hiveSessionId: session.hiveSessionId,
      textLength: text.length
    })

    // Set up event listener for streaming
    let interactionMode: 'default' | 'plan' = 'default'
    let assistantText = ''
    let reasoningText = ''
    let pendingPlanText: string | null = null
    let turnCompleted = false
    let turnFailed = false
    let completedTurnId: string | undefined
    // Track whether a question was raised in this turn. When the agent uses
    // request_user_input (a Plan-mode tool) inside an otherwise-Plan turn,
    // the assistant's final text is its REPLY to the user's answer, not a
    // proposed plan. Re-emitting plan.ready in that case turns every Q&A
    // into a fresh "Requires Approval" card. Suppress emission whenever the
    // turn raised a question.
    let userInputAskedInTurn = false

    const handleEvent = (event: CodexManagerEvent) => {
      // Only handle events for this thread
      if (event.threadId !== session.threadId) return

      const streamEvents = mapCodexEventToStreamEvents(
        event,
        session.hiveSessionId,
        session.mapperState
      )
      for (const streamEvent of streamEvents) {
        if (
          event.method === 'turn/completed' &&
          streamEvent.type === 'session.status' &&
          streamEvent.statusPayload?.type === 'idle'
        ) {
          continue
        }
        emitAgentEvent(this.mainWindow, streamEvent)
        this.updateLiveAssistantDraftFromStreamEvent(session, streamEvent)
      }

      // Accumulate text for message history
      const streamKind = contentStreamKindFromMethod(event.method)
      if (streamKind) {
        const payload = event.payload as Record<string, unknown> | undefined
        const deltaText =
          event.textDelta ??
          asString(asObject(payload)?.delta) ??
          asString(asObject(payload)?.text) ??
          ''

        if (streamKind === 'reasoning' || streamKind === 'reasoning_summary') {
          reasoningText += deltaText
        } else {
          assistantText += deltaText
        }
      }

      if (interactionMode === 'plan') {
        // Only extract plan from streaming events when <proposed_plan> XML
        // tags are present — the tag-based extraction is reliable.  Without
        // tags these events carry only the LAST message fragment, so we let
        // the post-turn fallback use the full accumulated assistantText.
        if (event.method === 'codex/event/task_complete') {
          const payload = asObject(event.payload)
          const msg = asObject(payload?.msg)
          const planText = asString(msg?.last_agent_message)
          if (planText) {
            const extracted = extractProposedPlanMarkdown(planText)
            if (extracted) pendingPlanText = extracted
          }
        }

        if (event.method === 'item/completed') {
          const payload = asObject(event.payload)
          const item = asObject(payload?.item)
          const itemType = asString(item?.type)?.toLowerCase()
          const planText = asString(item?.text)
          if (itemType === 'agentmessage' && planText) {
            const extracted = extractProposedPlanMarkdown(planText)
            if (extracted) pendingPlanText = extracted
          }
        }
      }

      // Detect turn completion and whether it failed
      if (event.method === 'turn/completed') {
        turnCompleted = true
        const payload = event.payload as Record<string, unknown> | undefined
        const turnObj = payload?.turn as Record<string, unknown> | undefined
        completedTurnId =
          event.turnId ?? (typeof turnObj?.id === 'string' ? (turnObj.id as string) : undefined)
        const status = (turnObj?.status as string) ?? (payload?.state as string)
        if (status === 'failed') {
          turnFailed = true
        }
      }

      // Track if request_user_input fires anywhere in this turn so we know
      // the assistant's final text is a reply to a question, not a plan.
      if (event.method === 'item/tool/requestUserInput') {
        userInputAskedInTurn = true
      }
    }

    this.manager.on('event', handleEvent)

    try {
      const model = resolveCodexModelSlug(modelOverride?.modelID ?? this.selectedModel)

      // Determine interaction mode from DB session mode (same pattern as claude-code-implementer)
      if (this.dbService) {
        try {
          const dbSession = this.dbService.getSession(session.hiveSessionId)
          if (dbSession?.mode === 'plan') {
            interactionMode = 'plan'
          }
        } catch {
          // Fall through to default mode
        }
      }

      let turnText = text

      if (options?.goalMode) {
        const objective =
          options.goalObjective?.trim() || buildCodexGoalObjective(text, options.successCriteria)
        if (!objective) {
          throw new Error('Codex goal mode requires a non-empty objective')
        }

        try {
          await this.manager.setThreadGoal(session.threadId, {
            objective,
            status: 'active',
            tokenBudget: null
          })
        } catch (goalError) {
          if (!isCodexGoalUnavailableError(goalError)) {
            throw goalError
          }

          const errorMessage = goalError instanceof Error ? goalError.message : String(goalError)
          turnText = appendCodexGoalFallbackPrompt(text, objective)
          log.warn('Codex native goal unavailable; falling back to prompt goal instructions', {
            worktreePath,
            agentSessionId,
            error: errorMessage
          })
        }
      }

      await this.manager.sendTurn(session.threadId, {
        text: turnText,
        model,
        ...(options?.codexFastMode ? { serviceTier: 'fast' } : {}),
        interactionMode
      })

      // Wait for turn completion (the sendTurn starts the turn, but
      // events stream asynchronously via the manager's event emitter)
      await this.waitForTurnCompletion(session, () => turnCompleted)

      // Read canonical thread for properly separated messages
      try {
        const threadSnapshot = await this.manager.readThread(session.threadId)
        const parsed = this.parseThreadSnapshot(threadSnapshot, session.itemTimestampsByTurn)
        if (parsed.length > 0) {
          session.messages = parsed
        }
        this.persistCanonicalMessages(session)
        session.liveAssistantDraft = null
      } catch (readError) {
        log.warn('prompt: readThread after turn failed, falling back to accumulated text', {
          agentSessionId,
          error: readError instanceof Error ? readError.message : String(readError)
        })
        // Fallback: use accumulated text as single message
        const assistantParts: unknown[] = []
        if (assistantText) {
          assistantParts.push({
            type: 'text',
            text: assistantText,
            timestamp: new Date().toISOString()
          })
        }
        if (reasoningText) {
          assistantParts.push({
            type: 'reasoning',
            text: reasoningText,
            timestamp: new Date().toISOString()
          })
        }
        if (assistantParts.length > 0) {
          session.messages.push({
            role: 'assistant',
            parts: assistantParts,
            timestamp: new Date().toISOString()
          })
        }
        this.persistCanonicalMessages(session)
        session.liveAssistantDraft = null
      }

      // If no plan was detected from streaming events, extract from the parsed
      // thread snapshot.  session.messages has properly separated messages
      // (unlike assistantText which concatenates all deltas without separators).
      // Use the last assistant text message — in plan mode that's the plan.
      if (interactionMode === 'plan' && !pendingPlanText) {
        const msgs = session.messages as Array<Record<string, unknown>>
        for (let i = msgs.length - 1; i >= 0; i--) {
          if (msgs[i]?.role !== 'assistant') continue
          const parts = msgs[i].parts as Array<Record<string, unknown>> | undefined
          if (!Array.isArray(parts)) continue
          for (let j = parts.length - 1; j >= 0; j--) {
            if (parts[j]?.type === 'text' && typeof parts[j]?.text === 'string') {
              const text = parts[j].text as string
              const extracted = extractProposedPlanMarkdown(text)
              pendingPlanText = extracted ?? text
              break
            }
          }
          if (pendingPlanText) break
        }

        // Ultimate fallback: accumulated streaming text (lossy but better than nothing)
        if (!pendingPlanText && assistantText) {
          const extracted = extractProposedPlanMarkdown(assistantText)
          pendingPlanText = extracted ?? assistantText
        }
      }

      if (interactionMode === 'plan' && pendingPlanText && !userInputAskedInTurn) {
        const toolUseID = `codex-exitplan-${session.threadId}-${Date.now()}`
        // Per-plan requestId — including the turn id (or timestamp fallback)
        // so the same session can produce multiple plans without their
        // request_ids colliding. Previously this was just
        // `codex-plan:${session.threadId}`, which meant every subsequent
        // plan reused the requestId of the first plan; once that first plan
        // had a `plan.resolved` activity persisted, every later plan in the
        // same session got `resolvedRequestIds.has(reqId) === true` in
        // parsePlanPartFromActivity → status flipped to 'success' → the
        // brand-new plan card showed "Approved" while the FAB was still
        // waiting for the user to act on the new plan.
        const requestId = `codex-plan:${session.threadId}:${completedTurnId ?? Date.now()}`
        this.persistSyntheticActivity(session, {
          id: requestId,
          kind: 'plan.ready',
          tone: 'info',
          summary: 'Plan ready',
          requestId,
          turnId: completedTurnId,
          payload: { plan: pendingPlanText, toolUseID }
        })
        emitAgentEvent(this.mainWindow, {
          type: 'plan.ready',
          sessionId: session.hiveSessionId,
          data: {
            id: requestId,
            requestId,
            plan: pendingPlanText,
            toolUseID
          }
        })
      }

      session.status = turnFailed ? 'error' : 'ready'
      this.emitStatus(session.hiveSessionId, 'idle')

      log.info('Prompt: completed', {
        worktreePath,
        agentSessionId,
        assistantTextLength: assistantText.length,
        reasoningTextLength: reasoningText.length
      })
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error)
      log.error(
        'Prompt streaming error',
        error instanceof Error ? error : new Error(errorMessage),
        { worktreePath, agentSessionId, error: errorMessage }
      )

      session.status = 'error'
      session.liveAssistantDraft = null
      emitAgentEvent(this.mainWindow, {
        type: 'session.error',
        sessionId: session.hiveSessionId,
        data: { error: errorMessage }
      })
      this.emitStatus(session.hiveSessionId, 'idle')
    } finally {
      this.manager.removeListener('event', handleEvent)
    }
  }

  async steer(
    worktreePath: string,
    agentSessionId: string,
    message:
      | string
      | Array<
          | { type: 'text'; text: string }
          | { type: 'file'; mime: string; url: string; filename?: string }
        >,
    _modelOverride?: { providerID: string; modelID: string; variant?: string },
    _options?: PromptOptions
  ): Promise<void> {
    const key = this.getSessionKey(worktreePath, agentSessionId)
    const session = this.sessions.get(key)
    if (!session) {
      throw new Error(`Steer failed: session not found for ${worktreePath} / ${agentSessionId}`)
    }

    const hasAttachments = Array.isArray(message) && message.some((part) => part.type !== 'text')
    if (hasAttachments) {
      throw new Error('Steer only supports text messages')
    }

    const text =
      typeof message === 'string'
        ? message
        : message
            .filter((part) => part.type === 'text')
            .map((part) => part.text)
            .join('\n')

    if (!text.trim()) {
      throw new Error('Steer message cannot be empty')
    }

    const managerSession = this.manager.getSession(session.threadId)
    const activeTurnId = managerSession?.activeTurnId
    if (!activeTurnId) {
      throw new Error('Steer is unavailable because there is no active Codex turn')
    }

    await this.manager.steerTurn(session.threadId, { text }, activeTurnId)

    const syntheticTimestamp = new Date().toISOString()
    session.messages.push({
      role: 'user',
      steered: true,
      parts: [{ type: 'text', text, timestamp: syntheticTimestamp }],
      timestamp: syntheticTimestamp
    })
    this.persistCanonicalMessages(session)
  }

  async abort(worktreePath: string, agentSessionId: string): Promise<boolean> {
    const key = this.getSessionKey(worktreePath, agentSessionId)
    const session = this.sessions.get(key)
    if (!session) {
      log.warn('Abort: session not found', { worktreePath, agentSessionId })
      return false
    }

    try {
      await this.manager.interruptTurn(session.threadId)
    } catch (error) {
      log.warn('Abort: interruptTurn failed, continuing cleanup', {
        worktreePath,
        agentSessionId,
        error: error instanceof Error ? error.message : String(error)
      })
    }

    session.status = 'ready'
    session.liveAssistantDraft = null
    this.emitStatus(session.hiveSessionId, 'idle')
    return true
  }

  async getMessages(worktreePath: string, agentSessionId: string): Promise<unknown[]> {
    const key = this.getSessionKey(worktreePath, agentSessionId)
    let session = this.sessions.get(key)
    if (!session) {
      const recoveredSession = await this.recoverSessionForRead(worktreePath, agentSessionId)
      session = recoveredSession ?? undefined
    }

    if (!session) {
      log.warn('getMessages: session not found', { worktreePath, agentSessionId })
      return []
    }

    // Return in-memory messages if available
    if (session.messages.length > 0) {
      const liveDraftMessage =
        session.status === 'running' ? this.cloneLiveAssistantDraftMessage(session) : null
      return liveDraftMessage ? [...session.messages, liveDraftMessage] : [...session.messages]
    }

    if (session.status === 'running') {
      const liveDraftMessage = this.cloneLiveAssistantDraftMessage(session)
      if (liveDraftMessage) {
        return [liveDraftMessage]
      }
    }

    if (this.dbService) {
      try {
        const persistedMessages = this.dbService.getSessionMessages(session.hiveSessionId)
        if (persistedMessages.length > 0) {
          const parsed = persistedMessages.flatMap((message) => {
            if (!message.opencode_message_json) return []
            try {
              return [JSON.parse(message.opencode_message_json)]
            } catch {
              return []
            }
          })
          if (parsed.length > 0) {
            session.messages = parsed
            return [...parsed]
          }
        }
      } catch (error) {
        log.warn('getMessages: failed to load persisted Codex messages', {
          agentSessionId,
          error: error instanceof Error ? error.message : String(error)
        })
      }
    }

    // Fallback: try reading from thread via the server
    if (session.status !== 'closed') {
      try {
        const threadSnapshot = await this.manager.readThread(session.threadId)
        const parsed = this.parseThreadSnapshot(threadSnapshot, session.itemTimestampsByTurn)
        if (parsed.length > 0) {
          session.messages = parsed
          this.persistCanonicalMessages(session)
          log.info('getMessages: warmed in-memory cache from thread/read', {
            agentSessionId,
            count: parsed.length
          })
          return [...parsed]
        }
      } catch (error) {
        log.warn('getMessages: readThread fallback failed', {
          agentSessionId,
          error: error instanceof Error ? error.message : String(error)
        })
      }
    }

    return []
  }

  // ── Models ───────────────────────────────────────────────────────

  async getAvailableModels(): Promise<unknown> {
    return getAvailableCodexModels()
  }

  async getModelInfo(
    _worktreePath: string,
    modelId: string
  ): Promise<{
    id: string
    name: string
    limit: { context: number; input?: number; output: number }
  } | null> {
    return getCodexModelInfo(modelId)
  }

  setSelectedModel(model: { providerID: string; modelID: string; variant?: string }): void {
    this.selectedModel = resolveCodexModelSlug(model.modelID)
    this.selectedVariant = model.variant
    log.info('Selected model set', {
      raw: model.modelID,
      resolved: this.selectedModel,
      variant: model.variant
    })
  }

  clearSelectedModel(): void {
    this.selectedModel = CODEX_DEFAULT_MODEL
    this.selectedVariant = undefined
    log.info('Selected model cleared, reset to default', { model: this.selectedModel })
  }

  // ── Session info ─────────────────────────────────────────────────

  async getSessionInfo(
    worktreePath: string,
    agentSessionId: string
  ): Promise<{
    revertMessageID: string | null
    revertDiff: string | null
  }> {
    const sessionKey = this.getSessionKey(worktreePath, agentSessionId)
    const session = this.sessions.get(sessionKey)
    return {
      revertMessageID: session?.revertMessageID ?? null,
      revertDiff: session?.revertDiff ?? null
    }
  }

  // ── Human-in-the-loop ────────────────────────────────────────────

  async questionReply(
    requestId: string,
    answers: string[][],
    _worktreePath?: string
  ): Promise<void> {
    const pending = this.pendingQuestions.get(requestId)
    if (!pending) {
      throw new Error(`No pending question found for requestId: ${requestId}`)
    }

    // Convert string[][] answers to the format Codex expects
    const codexAnswers = answers.map(([id, answer]) => ({
      id: id ?? requestId,
      answer: answer ?? ''
    }))

    log.info('questionReply: responding to pending question', {
      requestId,
      hiveSessionId: pending.hiveSessionId,
      answerCount: codexAnswers.length
    })

    this.manager.respondToUserInput(pending.threadId, requestId, codexAnswers)
    this.pendingQuestions.delete(requestId)
    const session = this.findSessionByThreadId(pending.threadId)
    if (session) {
      this.persistSyntheticActivity(session, {
        id: `${requestId}:resolved`,
        kind: 'user-input.resolved',
        tone: 'approval',
        summary: 'User input answered',
        requestId,
        turnId: pending.turnId,
        payload: { answers: codexAnswers }
      })
    }

    emitAgentEvent(this.mainWindow, {
      type: 'question.replied',
      sessionId: pending.hiveSessionId,
      data: { requestId, id: requestId }
    })

    // Flip the synthetic AskUserQuestion tool part to completed so the
    // AgentTimeline card transitions from "Waiting for reply" → "Answered".
    const answerSummary = codexAnswers
      .map((a) => a.answer)
      .filter((s) => s.length > 0)
      .join('\n')
    emitAgentEvent(this.mainWindow, {
      type: 'message.part.updated',
      sessionId: pending.hiveSessionId,
      data: {
        part: {
          type: 'tool',
          callID: requestId,
          tool: 'AskUserQuestion',
          state: {
            status: 'completed',
            output: answerSummary
          }
        }
      }
    })

    // Persist tool.completed so the answered question card survives turn
    // sealing and remains in the durable timeline. Pairs with the
    // tool.started activity written when the question was raised.
    if (this.dbService) {
      try {
        this.dbService.upsertSessionActivity({
          id: `${requestId}:answered`,
          session_id: pending.hiveSessionId,
          agent_session_id: pending.threadId,
          thread_id: pending.threadId,
          turn_id: pending.turnId ?? null,
          item_id: requestId,
          request_id: requestId,
          kind: 'tool.completed',
          tone: 'tool',
          summary: 'AskUserQuestion',
          payload_json: JSON.stringify({
            item: {
              type: 'AskUserQuestion',
              toolName: 'AskUserQuestion',
              id: requestId,
              input: { questions: pending.questions ?? [] },
              output: answerSummary
            }
          })
        })
      } catch (err) {
        log.warn('codex questionReply: failed to persist tool.completed', {
          requestId,
          error: err instanceof Error ? err.message : String(err)
        })
      }
    }
  }

  async questionReject(requestId: string, _worktreePath?: string): Promise<void> {
    const pending = this.pendingQuestions.get(requestId)
    if (!pending) {
      throw new Error(`No pending question found for requestId: ${requestId}`)
    }

    log.info('questionReject: rejecting pending question', {
      requestId,
      hiveSessionId: pending.hiveSessionId
    })

    this.manager.rejectUserInput(pending.threadId, requestId)
    this.pendingQuestions.delete(requestId)
    const session = this.findSessionByThreadId(pending.threadId)
    if (session) {
      this.persistSyntheticActivity(session, {
        id: `${requestId}:resolved`,
        kind: 'user-input.resolved',
        tone: 'approval',
        summary: 'User input dismissed',
        requestId,
        turnId: pending.turnId,
        payload: { dismissed: true }
      })
    }

    emitAgentEvent(this.mainWindow, {
      type: 'question.rejected',
      sessionId: pending.hiveSessionId,
      data: { requestId, id: requestId }
    })

    // Flip the synthetic AskUserQuestion tool part to cancelled.
    emitAgentEvent(this.mainWindow, {
      type: 'message.part.updated',
      sessionId: pending.hiveSessionId,
      data: {
        part: {
          type: 'tool',
          callID: requestId,
          tool: 'AskUserQuestion',
          state: { status: 'cancelled' }
        }
      }
    })
  }

  async permissionReply(
    requestId: string,
    decision: 'once' | 'always' | 'reject',
    _worktreePath?: string
  ): Promise<void> {
    const pending = this.pendingApprovalSessions.get(requestId)
    if (!pending) {
      throw new Error(`No pending approval found for requestId: ${requestId}`)
    }

    log.info('permissionReply: responding to pending approval', {
      requestId,
      hiveSessionId: pending.hiveSessionId,
      decision
    })

    this.manager.respondToApproval(pending.threadId, requestId, decision)
    this.pendingApprovalSessions.delete(requestId)
    const session = this.findSessionByThreadId(pending.threadId)
    if (session) {
      this.persistSyntheticActivity(session, {
        id: `${requestId}:resolved`,
        kind: 'approval.resolved',
        tone: 'approval',
        summary: 'Approval resolved',
        requestId,
        turnId: pending.turnId,
        payload: { decision }
      })
    }

    emitAgentEvent(this.mainWindow, {
      type: 'permission.replied',
      sessionId: pending.hiveSessionId,
      data: { requestId, id: requestId, decision }
    })
  }

  async permissionList(_worktreePath?: string): Promise<unknown[]> {
    // Aggregate pending approvals across all sessions
    const result: unknown[] = []
    for (const session of this.sessions.values()) {
      const approvals = this.manager.getPendingApprovals(session.threadId)
      for (const approval of approvals) {
        const payload = asObject(approval.payload)
        result.push({
          ...this.toPermissionRequest(
            approval.requestId,
            session.hiveSessionId,
            approval.method,
            payload,
            approval.turnId,
            approval.itemId
          )
        })
      }
    }
    return result
  }

  private toPermissionRequest(
    requestId: string,
    hiveSessionId: string,
    method: string,
    payload: Record<string, unknown> | undefined,
    turnId?: string,
    itemId?: string
  ): CodexPermissionRequest {
    const permission = this.permissionFromApprovalMethod(method)
    const patterns = this.patternsFromApprovalPayload(method, payload)

    return {
      id: requestId,
      sessionID: hiveSessionId,
      permission,
      patterns,
      metadata: {
        method,
        ...(payload ? { payload } : {}),
        ...(turnId ? { turnId } : {}),
        ...(itemId ? { itemId } : {})
      },
      always: []
    }
  }

  private permissionFromApprovalMethod(method: string): string {
    switch (method) {
      case 'item/commandExecution/requestApproval':
        return 'bash'
      case 'item/fileRead/requestApproval':
        return 'read'
      case 'item/fileChange/requestApproval':
        return 'edit'
      default:
        return 'unknown'
    }
  }

  private patternsFromApprovalPayload(
    method: string,
    payload: Record<string, unknown> | undefined
  ): string[] {
    if (!payload) return []

    if (method === 'item/commandExecution/requestApproval') {
      const command = asString(payload.command)
      return command ? [command] : []
    }

    const filePath =
      asString(payload.path) ?? asString(payload.filePath) ?? asString(payload.target)
    return filePath ? [filePath] : []
  }

  /** Check if a question requestId belongs to this implementer */
  hasPendingQuestion(requestId: string): boolean {
    return this.pendingQuestions.has(requestId)
  }

  /** Check if a permission requestId belongs to this implementer */
  hasPendingApproval(requestId: string): boolean {
    return this.pendingApprovalSessions.has(requestId)
  }

  // ── Undo/Redo ────────────────────────────────────────────────────

  async undo(
    worktreePath: string,
    agentSessionId: string,
    _hiveSessionId: string
  ): Promise<{ revertMessageID: string; restoredPrompt: string; revertDiff: string | null }> {
    const sessionKey = this.getSessionKey(worktreePath, agentSessionId)
    const session = this.sessions.get(sessionKey)
    if (!session) {
      throw new Error(`Undo failed: session not found for ${worktreePath} / ${agentSessionId}`)
    }

    if (session.messages.length === 0) {
      throw new Error('Nothing to undo')
    }

    // Rollback 1 turn via the Codex server
    const snapshot = await this.manager.rollbackThread(session.threadId, 1)

    // Try to extract the last user prompt from in-memory messages
    const restoredPrompt = this.extractLastUserPrompt(session)

    // Pop the last exchange (assistant + user) from in-memory messages
    // Find the last user message boundary
    const revertMessageID = this.popLastExchange(session)

    // Store revert state
    session.revertMessageID = revertMessageID
    session.revertDiff = null

    // Emit session info update to renderer
    emitAgentEvent(this.mainWindow, {
      type: 'session.updated',
      sessionId: session.hiveSessionId,
      data: { revertMessageID }
    })

    log.info('Undo completed', {
      worktreePath,
      agentSessionId,
      revertMessageID,
      restoredPrompt: restoredPrompt.slice(0, 50),
      snapshotReceived: !!snapshot
    })

    return { revertMessageID, restoredPrompt, revertDiff: null }
  }

  async redo(
    _worktreePath: string,
    _agentSessionId: string,
    _hiveSessionId: string
  ): Promise<unknown> {
    throw new Error('Redo is not supported for Codex sessions')
  }

  // ── Commands ─────────────────────────────────────────────────────

  async listCommands(_worktreePath: string): Promise<unknown[]> {
    throw new Error('CodexImplementer.listCommands() not yet implemented')
  }

  async sendCommand(
    _worktreePath: string,
    _agentSessionId: string,
    _command: string,
    _args?: string
  ): Promise<void> {
    throw new Error('CodexImplementer.sendCommand() not yet implemented')
  }

  // ── Session management ───────────────────────────────────────────

  async renameSession(_worktreePath: string, agentSessionId: string, name: string): Promise<void> {
    // Codex has no server-side rename — just update Hive's local DB
    if (!this.dbService) {
      log.warn('renameSession: no dbService available', { agentSessionId })
      return
    }

    // Find hive session by matching agentSessionId (threadId)
    const sessionKey = this.findSessionKeyByAgentId(agentSessionId)
    if (sessionKey) {
      const session = this.sessions.get(sessionKey)
      if (session?.hiveSessionId) {
        try {
          this.dbService.updateSession(session.hiveSessionId, { name })
          log.info('renameSession: updated title in DB', {
            hiveSessionId: session.hiveSessionId,
            name
          })
        } catch (err) {
          const error = err instanceof Error ? err : new Error(String(err))
          log.error('renameSession: failed to update title', error, {
            hiveSessionId: session.hiveSessionId
          })
        }
      }
    } else {
      log.warn('renameSession: session not found in active map', { agentSessionId })
    }
  }

  // ── Internal helpers (exposed for testing) ───────────────────────

  /** @internal */
  getSelectedModel(): string {
    return this.selectedModel
  }

  /** @internal */
  getSelectedVariant(): string | undefined {
    return this.selectedVariant
  }

  /** @internal */
  getMainWindow(): BrowserWindow | null {
    return this.mainWindow
  }

  /** @internal */
  getManager(): CodexAppServerManager {
    return this.manager
  }

  /** @internal */
  getSessions(): Map<string, CodexSessionState> {
    return this.sessions
  }

  /** @internal */
  getPendingQuestions(): Map<string, PendingHitlEntry> {
    return this.pendingQuestions
  }

  /** @internal */
  getPendingApprovalSessions(): Map<string, PendingHitlEntry> {
    return this.pendingApprovalSessions
  }

  // ── Private helpers ──────────────────────────────────────────────

  private cleanupPendingForThread(threadId: string): void {
    for (const [reqId, entry] of this.pendingQuestions.entries()) {
      if (entry.threadId === threadId) {
        this.pendingQuestions.delete(reqId)
      }
    }
    for (const [reqId, entry] of this.pendingApprovalSessions.entries()) {
      if (entry.threadId === threadId) {
        this.pendingApprovalSessions.delete(reqId)
      }
    }
  }

  /**
   * Hydrate token usage on reconnect by reading the session JSONL file.
   *
   * thread/read does NOT include tokenUsage data, but the JSONL session
   * file contains event_msg entries with type "token_count" that carry
   * full cumulative token data.  We read the file, find the LAST
   * token_count event, and emit a session.context_usage event.
   */
  private async hydrateTokenUsageFromThread(session: CodexSessionState): Promise<void> {
    try {
      // 1. Get the JSONL path from thread/read
      const snapshot = await this.manager.readThread(session.threadId)
      const obj = asObject(snapshot)
      const threadObj = asObject(obj?.thread) ?? obj
      const jsonlPath = asString(threadObj?.path)
      if (!jsonlPath) {
        log.debug('hydrateTokenUsage: no path in thread/read response')
        return
      }

      // 2. Read the JSONL file and find the last token_count event
      const { readFile } = await import('node:fs/promises')
      const content = await readFile(jsonlPath, 'utf-8')
      const lines = content.split('\n').filter((l) => l.trim())

      let lastTokenCount: Record<string, unknown> | undefined
      for (let i = lines.length - 1; i >= 0; i--) {
        try {
          const entry = JSON.parse(lines[i]) as Record<string, unknown>
          const msg = asObject(entry.msg)
          if (msg?.type === 'token_count') {
            lastTokenCount = asObject(msg.info)
            break
          }
        } catch {
          continue
        }
      }

      if (!lastTokenCount) {
        log.debug('hydrateTokenUsage: no token_count in JSONL')
        return
      }

      // 3. Extract token data (snake_case fields from JSONL)
      // Use last_token_usage (per-turn prompt size), not total_token_usage
      // (cumulative). The context bar shows current prompt fill, not
      // lifetime consumption.
      const lastUsage = asObject(lastTokenCount.last_token_usage)
      if (!lastUsage) return

      const inputTokens = asNumber(lastUsage.input_tokens) ?? 0
      const cachedInputTokens = asNumber(lastUsage.cached_input_tokens) ?? 0
      const outputTokens = asNumber(lastUsage.output_tokens) ?? 0
      const reasoningTokens = asNumber(lastUsage.reasoning_output_tokens) ?? 0
      const contextWindow = asNumber(lastTokenCount.model_context_window) ?? 0

      if (inputTokens === 0 && outputTokens === 0) return

      const modelID = resolveCodexModelSlug(this.selectedModel)

      emitAgentEvent(this.mainWindow, {
        type: 'session.context_usage',
        sessionId: session.hiveSessionId,
        data: {
          tokens: {
            input: inputTokens - cachedInputTokens,
            cacheRead: cachedInputTokens,
            cacheWrite: 0,
            output: outputTokens,
            reasoning: reasoningTokens
          },
          model: { providerID: 'codex', modelID },
          contextWindow
        }
      })

      log.info('hydrateTokenUsage: emitted context_usage from JSONL', {
        hiveSessionId: session.hiveSessionId,
        inputTokens,
        contextWindow,
        modelID
      })
    } catch (error) {
      log.debug('hydrateTokenUsage: failed', {
        hiveSessionId: session.hiveSessionId,
        error: error instanceof Error ? error.message : String(error)
      })
    }
  }

  private findSessionByThreadId(threadId: string): CodexSessionState | undefined {
    for (const session of this.sessions.values()) {
      if (session.threadId === threadId) {
        return session
      }
    }
    return undefined
  }

  private getSessionKey(worktreePath: string, agentSessionId: string): string {
    return `${worktreePath}::${agentSessionId}`
  }

  private sendToRenderer(channel: string, data: unknown): void {
    if (this.mainWindow && !this.mainWindow.isDestroyed()) {
      this.mainWindow.webContents.send(channel, data)
    } else {
      log.debug('sendToRenderer: no window (headless)')
    }
  }

  private persistActivity(session: CodexSessionState, event: CodexManagerEvent): void {
    if (!this.dbService) return

    const activity = mapCodexManagerEventToActivity(session.hiveSessionId, session.threadId, event)
    if (!activity) return

    try {
      this.dbService.upsertSessionActivity(activity)
    } catch (error) {
      log.warn('Failed to persist Codex activity', {
        hiveSessionId: session.hiveSessionId,
        method: event.method,
        error: error instanceof Error ? error.message : String(error)
      })
    }
  }

  private persistSyntheticActivity(
    session: CodexSessionState,
    params: {
      id: string
      kind:
        | 'approval.resolved'
        | 'user-input.resolved'
        | 'plan.ready'
        | 'plan.resolved'
        | 'session.error'
        | 'session.info'
      tone: 'approval' | 'info' | 'error'
      summary: string
      requestId?: string
      turnId?: string
      payload?: unknown
    }
  ): void {
    if (!this.dbService) return

    try {
      this.dbService.upsertSessionActivity({
        id: params.id,
        session_id: session.hiveSessionId,
        agent_session_id: session.threadId,
        thread_id: session.threadId,
        turn_id: params.turnId ?? null,
        request_id: params.requestId ?? null,
        kind: params.kind,
        tone: params.tone,
        summary: params.summary,
        payload_json: params.payload ? JSON.stringify(params.payload) : null
      })
    } catch (error) {
      log.warn('Failed to persist synthetic Codex activity', {
        hiveSessionId: session.hiveSessionId,
        kind: params.kind,
        error: error instanceof Error ? error.message : String(error)
      })
    }
  }

  private persistCanonicalMessages(session: CodexSessionState): void {
    if (!this.dbService) return

    try {
      const rows = session.messages.flatMap((message) => {
        const record = asObject(message)
        if (!record) return []

        const role = asString(record.role)
        const timestamp = asString(record.timestamp) ?? new Date().toISOString()
        if (role !== 'user' && role !== 'assistant' && role !== 'system') return []

        const parts = Array.isArray(record.parts) ? record.parts : []
        const textContent = parts
          .map((part) => asObject(part))
          .filter((part) => part?.type === 'text' || part?.type === 'reasoning')
          .map((part) => asString(part?.text) ?? '')
          .join('')

        return [
          {
            session_id: session.hiveSessionId,
            role,
            content: textContent,
            opencode_message_id: asString(record.id) ?? null,
            opencode_message_json: JSON.stringify(message),
            opencode_parts_json: JSON.stringify(parts),
            created_at: timestamp
          }
        ]
      })

      this.dbService.replaceSessionMessages(
        session.hiveSessionId,
        normalizeCodexMessageTimestamps(rows)
      )
    } catch (error) {
      log.warn('Failed to persist Codex canonical messages', {
        hiveSessionId: session.hiveSessionId,
        error: error instanceof Error ? error.message : String(error)
      })
    }
  }

  private mapProviderStatus(
    status: 'connecting' | 'ready' | 'running' | 'error' | 'closed'
  ): CodexSessionState['status'] {
    return status
  }

  private statusToHive(status: CodexSessionState['status']): 'idle' | 'busy' | 'retry' {
    if (status === 'running') return 'busy'
    return 'idle'
  }

  private emitStatus(
    hiveSessionId: string,
    status: 'idle' | 'busy' | 'retry',
    extra?: { attempt?: number; message?: string; next?: number }
  ): void {
    const statusPayload = { type: status, ...extra }
    emitAgentEvent(this.mainWindow, {
      type: 'session.status',
      sessionId: hiveSessionId,
      data: { status: statusPayload },
      statusPayload
    })
  }

  private resetLiveAssistantDraft(session: CodexSessionState): void {
    session.liveAssistantDraft = {
      id: `codex-live-${session.threadId}`,
      timestamp: new Date().toISOString(),
      parts: [],
      toolIndexById: new Map()
    }
  }

  private ensureLiveAssistantDraft(session: CodexSessionState): CodexLiveAssistantDraft {
    if (!session.liveAssistantDraft) {
      this.resetLiveAssistantDraft(session)
    }
    return session.liveAssistantDraft!
  }

  private appendLiveAssistantText(
    session: CodexSessionState,
    kind: 'text' | 'reasoning',
    text: string
  ): void {
    if (!text) return

    const draft = this.ensureLiveAssistantDraft(session)
    const lastPart = draft.parts[draft.parts.length - 1]
    const timestamp = new Date().toISOString()

    if (lastPart && lastPart.type === kind) {
      lastPart.text += text
      return
    }

    draft.parts.push({ type: kind, text, timestamp })
  }

  private upsertLiveAssistantTool(
    session: CodexSessionState,
    tool: {
      callID: string
      tool: string
      state: {
        status: 'running' | 'completed' | 'error'
        input?: unknown
        output?: unknown
        error?: unknown
      }
    }
  ): void {
    if (!tool.callID) return

    const draft = this.ensureLiveAssistantDraft(session)
    const existingIndex = draft.toolIndexById.get(tool.callID)

    if (existingIndex !== undefined) {
      const existing = draft.parts[existingIndex]
      if (existing && existing.type === 'tool') {
        existing.tool = tool.tool || existing.tool
        existing.state = {
          ...existing.state,
          ...tool.state,
          ...(tool.state.input === undefined ? { input: existing.state.input } : {}),
          ...(tool.state.output === undefined ? { output: existing.state.output } : {}),
          ...(tool.state.error === undefined ? { error: existing.state.error } : {})
        }
      }
      return
    }

    draft.toolIndexById.set(tool.callID, draft.parts.length)
    draft.parts.push({
      type: 'tool',
      callID: tool.callID,
      tool: tool.tool,
      state: tool.state
    })
  }

  private updateLiveAssistantDraftFromStreamEvent(
    session: CodexSessionState,
    streamEvent: { type?: string; data?: unknown }
  ): void {
    if (streamEvent.type !== 'message.part.updated') return

    const data = asObject(streamEvent.data)
    const part = asObject(data?.part)
    if (!part) return

    const partType = asString(part.type)
    if (partType === 'text') {
      const delta = asString(data?.delta) ?? asString(part.text) ?? ''
      this.appendLiveAssistantText(session, 'text', delta)
      return
    }

    if (partType === 'reasoning') {
      const delta = asString(data?.delta) ?? asString(part.text) ?? ''
      this.appendLiveAssistantText(session, 'reasoning', delta)
      return
    }

    if (partType === 'tool') {
      const state = asObject(part.state)
      const statusValue = asString(state?.status)
      const status =
        statusValue === 'completed' || statusValue === 'error' ? statusValue : 'running'

      this.upsertLiveAssistantTool(session, {
        callID: asString(part.callID) ?? asString(part.id) ?? '',
        tool: asString(part.tool) ?? 'unknown',
        state: {
          status,
          ...(state?.input !== undefined ? { input: state.input } : {}),
          ...(state?.output !== undefined ? { output: state.output } : {}),
          ...(state?.error !== undefined ? { error: state.error } : {})
        }
      })
    }
  }

  private cloneLiveAssistantDraftMessage(session: CodexSessionState): unknown | null {
    const draft = session.liveAssistantDraft
    if (!draft || draft.parts.length === 0) return null

    return {
      id: draft.id,
      role: 'assistant',
      parts: draft.parts.map((part) => {
        if (part.type === 'text' || part.type === 'reasoning') {
          return { ...part }
        }

        return {
          type: 'tool',
          callID: part.callID,
          tool: part.tool,
          state: { ...part.state }
        }
      }),
      timestamp: draft.timestamp
    }
  }

  private waitForTurnCompletion(
    session: CodexSessionState,
    isComplete: () => boolean,
    timeoutMs = 300_000
  ): Promise<void> {
    if (isComplete()) return Promise.resolve()

    return new Promise<void>((resolve, reject) => {
      let remainingMs = timeoutMs
      let timerStartedAt = Date.now()
      let timer: ReturnType<typeof setTimeout> | null = null

      const hasPendingHitlForThread = (): boolean => {
        for (const entry of this.pendingQuestions.values()) {
          if (entry.threadId === session.threadId) return true
        }
        for (const entry of this.pendingApprovalSessions.values()) {
          if (entry.threadId === session.threadId) return true
        }
        return false
      }

      const clearTimer = () => {
        if (timer) {
          clearTimeout(timer)
          timer = null
        }
      }

      const startTimer = () => {
        clearTimer()
        timerStartedAt = Date.now()
        timer = setTimeout(() => {
          cleanup()
          reject(new Error('Turn timed out'))
        }, remainingMs)
      }

      const pauseTimer = () => {
        if (!timer) return
        clearTimeout(timer)
        timer = null
        const elapsed = Date.now() - timerStartedAt
        remainingMs = Math.max(0, remainingMs - elapsed)
      }

      const checkEvent = (event: CodexManagerEvent) => {
        if (event.threadId !== session.threadId) return

        if (event.method === 'turn/completed') {
          cleanup()
          resolve()
          return
        }

        // Only reject on truly fatal errors — not stderr warnings.
        // The Codex app-server may output benign stderr content (warnings,
        // progress info, non-standard log formats) that should not abort
        // the turn. Only process crashes and session exits are fatal.
        const isFatalError =
          event.method === 'process/error' ||
          event.method === 'session/exited' ||
          event.method === 'session/closed'

        if (isFatalError) {
          cleanup()
          reject(new Error(event.message ?? 'Codex process error'))
          return
        }

        const isErrorStateChange =
          (event.method === 'session.state.changed' || event.method === 'session/state/changed') &&
          (event.payload as Record<string, unknown> | undefined)?.state === 'error'

        if (isErrorStateChange) {
          const payload = event.payload as Record<string, unknown>
          const reason =
            (payload?.reason as string) ??
            (payload?.error as string) ??
            event.message ??
            'Session entered error state'
          cleanup()
          reject(new Error(reason))
        }

        if (event.kind === 'request') {
          pauseTimer()
          return
        }

        const hitlStateChanged =
          event.method === 'item/tool/requestUserInput/answered' ||
          event.method === 'approval/responded' ||
          event.method === 'userInput/rejected' ||
          event.method === 'session/closed' ||
          event.method === 'session/exited'

        if (hitlStateChanged && !hasPendingHitlForThread() && remainingMs > 0) {
          startTimer()
        }
      }

      const cleanup = () => {
        clearTimer()
        this.manager.removeListener('event', checkEvent)
      }

      this.manager.on('event', checkEvent)
      if (!hasPendingHitlForThread()) {
        startTimer()
      }

      // Check again in case it completed between the start and listener setup
      if (isComplete()) {
        cleanup()
        resolve()
      }
    })
  }

  /** Find a session key by its agentSessionId (threadId) */
  private findSessionKeyByAgentId(agentSessionId: string): string | null {
    for (const [key, session] of this.sessions.entries()) {
      if (session.threadId === agentSessionId) {
        return key
      }
    }
    return null
  }

  /** Extract the last user prompt text from in-memory messages */
  private extractLastUserPrompt(session: CodexSessionState): string {
    for (let i = session.messages.length - 1; i >= 0; i--) {
      const msg = asObject(session.messages[i])
      if (msg?.role === 'user') {
        const parts = msg.parts as unknown[] | undefined
        if (Array.isArray(parts)) {
          for (const part of parts) {
            const partObj = asObject(part)
            if (partObj?.type === 'text' && typeof partObj.text === 'string') {
              return partObj.text
            }
          }
        }
      }
    }
    return ''
  }

  /**
   * Pop the last user+assistant exchange from in-memory messages.
   * Returns the ID/timestamp of the new last message (the revert boundary),
   * or a synthetic boundary ID if no messages remain.
   */
  private popLastExchange(session: CodexSessionState): string {
    // Remove trailing assistant message(s)
    while (session.messages.length > 0) {
      const last = asObject(session.messages[session.messages.length - 1])
      if (last?.role === 'assistant') {
        session.messages.pop()
      } else {
        break
      }
    }

    // Remove the trailing user message
    if (session.messages.length > 0) {
      const last = asObject(session.messages[session.messages.length - 1])
      if (last?.role === 'user') {
        session.messages.pop()
      }
    }

    // Return the ID of what's now the last message, or a synthetic boundary
    if (session.messages.length > 0) {
      const last = asObject(session.messages[session.messages.length - 1])
      return asString(last?.id) ?? asString(last?.timestamp) ?? `revert-${session.messages.length}`
    }

    return 'revert-0'
  }

  private async recoverSessionForRead(
    worktreePath: string,
    agentSessionId: string
  ): Promise<CodexSessionState | null> {
    if (!this.dbService) {
      return null
    }

    const persistedSession = this.dbService.getSessionByOpenCodeSessionId(agentSessionId)
    if (!persistedSession || persistedSession.agent_sdk !== 'codex') {
      return null
    }

    try {
      const providerSession = await this.manager.startSession({
        cwd: worktreePath,
        model: resolveCodexModelSlug(persistedSession.model_id ?? this.selectedModel),
        resumeThreadId: agentSessionId,
        codexLaunchSpec: await this.resolveLaunchSpec()
      })

      const threadId = providerSession.threadId
      if (!threadId) {
        throw new Error('Codex session resumed for read but no thread ID was returned.')
      }

      const recovered: CodexSessionState = {
        threadId,
        hiveSessionId: persistedSession.id,
        worktreePath,
        status: this.mapProviderStatus(providerSession.status),
        messages: [],
        liveAssistantDraft: null,
        revertMessageID: null,
        revertDiff: null,
        titleGenerated: true,
        titleGenerationStarted: true,
        mapperState: createCodexMapperState(),
      itemTimestampsByTurn: new Map()
      }

      this.sessions.set(this.getSessionKey(worktreePath, threadId), recovered)

      log.info('Recovered persisted Codex session for transcript read', {
        worktreePath,
        agentSessionId,
        threadId,
        hiveSessionId: persistedSession.id
      })

      return recovered
    } catch (error) {
      log.warn('Failed to recover persisted Codex session for transcript read', {
        worktreePath,
        agentSessionId,
        error: error instanceof Error ? error.message : String(error)
      })
      return null
    }
  }

  private async handleTitleGeneration(
    session: CodexSessionState,
    userMessage: string
  ): Promise<void> {
    try {
      const title = await generateCodexSessionTitle(userMessage, session.worktreePath)
      if (!title) return
      await this.applyGeneratedTitle(session, title)
    } catch (err) {
      log.warn('handleTitleGeneration: failed', {
        hiveSessionId: session.hiveSessionId,
        error: err instanceof Error ? err.message : String(err)
      })
    }
  }

  private async applyGeneratedTitle(session: CodexSessionState, title: string): Promise<void> {
    const trimmedTitle = title.trim()
    if (!trimmedTitle) return

    let currentTitle: string | null = null
    if (this.dbService) {
      try {
        currentTitle = this.dbService.getSession(session.hiveSessionId)?.name ?? null
      } catch {
        currentTitle = null
      }
    }

    const titleChanged = currentTitle !== trimmedTitle

    if (this.dbService && titleChanged) {
      this.dbService.updateSession(session.hiveSessionId, { name: trimmedTitle })
      log.info('applyGeneratedTitle: updated DB', {
        hiveSessionId: session.hiveSessionId,
        title: trimmedTitle
      })
    }

    if (titleChanged) {
      emitAgentEvent(this.mainWindow, {
        type: 'session.updated',
        sessionId: session.hiveSessionId,
        data: { title: trimmedTitle, info: { title: trimmedTitle } }
      })
    } else {
      log.debug('applyGeneratedTitle: title unchanged, skipping session rename event', {
        hiveSessionId: session.hiveSessionId,
        title: trimmedTitle
      })
    }

    if (!this.dbService) return
    const worktree = this.dbService.getWorktreeBySessionId(session.hiveSessionId)
    if (worktree && !worktree.branch_renamed) {
      try {
        const result = await autoRenameWorktreeBranch({
          worktreeId: worktree.id,
          worktreePath: worktree.path,
          currentBranchName: worktree.branch_name,
          sessionTitle: trimmedTitle,
          db: this.dbService
        })
        if (result.renamed) {
          this.sendToRenderer('worktree:branchRenamed', {
            worktreeId: worktree.id,
            newBranch: result.newBranch
          })
          log.info('applyGeneratedTitle: auto-renamed branch', {
            oldBranch: worktree.branch_name,
            newBranch: result.newBranch
          })
        } else if (result.error) {
          log.warn('applyGeneratedTitle: rename failed', { error: result.error })
        }
      } catch (err) {
        this.dbService.updateWorktree(worktree.id, { branch_renamed: 1 })
        log.warn('applyGeneratedTitle: branch rename error', { err })
      }
    }

    const dbSession = this.dbService.getSession(session.hiveSessionId)
    if (!dbSession?.connection_id) return

    const connection = this.dbService.getConnection(dbSession.connection_id)
    if (!connection) return

    for (const member of connection.members) {
      if (worktree && member.worktree_id === worktree.id) continue
      try {
        const memberWorktree = this.dbService.getWorktree(member.worktree_id)
        if (!memberWorktree || memberWorktree.branch_renamed) continue

        const result = await autoRenameWorktreeBranch({
          worktreeId: memberWorktree.id,
          worktreePath: memberWorktree.path,
          currentBranchName: memberWorktree.branch_name,
          sessionTitle: trimmedTitle,
          db: this.dbService
        })
        if (result.renamed) {
          this.sendToRenderer('worktree:branchRenamed', {
            worktreeId: memberWorktree.id,
            newBranch: result.newBranch
          })
          log.info('applyGeneratedTitle: auto-renamed connection member', {
            connectionId: dbSession.connection_id,
            worktreeId: memberWorktree.id,
            oldBranch: memberWorktree.branch_name,
            newBranch: result.newBranch
          })
        } else if (result.error) {
          log.warn('applyGeneratedTitle: connection member rename failed', {
            connectionId: dbSession.connection_id,
            worktreeId: memberWorktree.id,
            error: result.error
          })
        }
      } catch (err) {
        log.warn('applyGeneratedTitle: connection member rename error', {
          worktreeId: member.worktree_id,
          err
        })
      }
    }
  }

  /** Parse a thread/read snapshot into a message array for getMessages() */
  private parseThreadSnapshot(
    snapshot: unknown,
    itemTimestampsByTurn?: Map<string, string[]>
  ): unknown[] {
    const obj = asObject(snapshot)
    if (!obj) return []

    const threadObj = asObject(obj.thread) ?? obj
    const turns = threadObj.turns as unknown[] | undefined
    if (!Array.isArray(turns)) return []

    const messages: Array<{ message: unknown; sortTime: number; order: number }> = []
    let order = 0
    const pushMessage = (message: unknown, timestamp: string | null | undefined): void => {
      const parsedTimestamp = timestamp ? Date.parse(timestamp) : Number.NaN
      messages.push({
        message,
        sortTime: Number.isFinite(parsedTimestamp) ? parsedTimestamp : Number.MAX_SAFE_INTEGER,
        order: order++
      })
    }

    for (const turn of turns) {
      const turnObj = asObject(turn)
      if (!turnObj) continue

      const turnId = asString(turnObj.id)
      // Codex turns expose `startedAt` as Unix epoch seconds. Older code looked
      // at `createdAt` / `updatedAt`, so items often fell back to read time and
      // sorted after synthetic activity emitted during the same turn.
      const startedAtSec = asNumber(turnObj.startedAt)
      const turnStartIso = startedAtSec
        ? new Date(startedAtSec * 1000).toISOString()
        : undefined
      const turnTimestamp =
        turnStartIso ?? asString(turnObj.createdAt) ?? asString(turnObj.updatedAt)
      const items = turnObj.items as unknown[] | undefined
      if (Array.isArray(items) && items.length > 0) {
        let assistantItemOrdinal = 0
        let userItemOrdinal = 0

        const makeUserMessageId = (itemId?: string): string | undefined => {
          if (!turnId) return itemId
          if (userItemOrdinal === 0) {
            userItemOrdinal += 1
            return `${turnId}:user`
          }
          const suffix = itemId ?? `item-${userItemOrdinal + 1}`
          userItemOrdinal += 1
          return `${turnId}:user:${suffix}`
        }

        const makeAssistantMessageId = (itemId?: string): string | undefined => {
          if (!turnId) return itemId
          if (assistantItemOrdinal === 0) {
            assistantItemOrdinal += 1
            return `${turnId}:assistant`
          }
          const suffix = itemId ?? `item-${assistantItemOrdinal + 1}`
          assistantItemOrdinal += 1
          return `${turnId}:assistant:${suffix}`
        }

        // Resolve per-turn streaming timestamps. We match by POSITION within
        // the turn because codex's thread/read renumbers item ids. Falls back
        // to turnTimestamp (turn startedAt) when the streaming list is too
        // short — happens for past turns we never streamed.
        const turnPositionalTimestamps =
          turnId ? (itemTimestampsByTurn?.get(turnId) ?? []) : []
        let itemPositionInTurn = 0

        for (const item of items) {
          const itemObj = asObject(item)
          if (!itemObj) continue

          const itemType = asString(itemObj.type)
          const itemId = asString(itemObj.id)
          const positionalTs = turnPositionalTimestamps[itemPositionInTurn]
          const itemTimestamp =
            positionalTs ?? turnTimestamp ?? new Date().toISOString()
          itemPositionInTurn += 1

          if (itemType === 'userMessage') {
            const content = itemObj.content as unknown[] | undefined
            const textParts: unknown[] = []

            if (Array.isArray(content)) {
              for (const entry of content) {
                const entryObj = asObject(entry)
                if (entryObj?.type === 'text' && typeof entryObj.text === 'string') {
                  textParts.push({
                    type: 'text',
                    text: entryObj.text,
                    timestamp: itemTimestamp
                  })
                }
              }
            }

            if (textParts.length > 0) {
              const messageId = makeUserMessageId(itemId)
              pushMessage(
                {
                  ...(messageId ? { id: messageId } : {}),
                  role: 'user',
                  parts: textParts,
                  timestamp: itemTimestamp
                },
                itemTimestamp
              )
            }
            continue
          }

          if (itemType === 'agentMessage' || itemType === 'plan') {
            const text = asString(itemObj.text)
            if (text) {
              const messageId = makeAssistantMessageId(itemId)
              pushMessage(
                {
                  ...(messageId ? { id: messageId } : {}),
                  role: 'assistant',
                  parts: [
                    {
                      type: 'text',
                      text,
                      timestamp: itemTimestamp
                    }
                  ],
                  timestamp: itemTimestamp
                },
                itemTimestamp
              )
            }
            continue
          }

          if (itemType === 'reasoning') {
            const summary = Array.isArray(itemObj.summary)
              ? itemObj.summary.filter((entry): entry is string => typeof entry === 'string')
              : []
            const content = Array.isArray(itemObj.content)
              ? itemObj.content.filter((entry): entry is string => typeof entry === 'string')
              : []
            const reasoningText = [...summary, ...content].join('\n').trim()

            if (reasoningText) {
              const messageId = makeAssistantMessageId(itemId)
              pushMessage(
                {
                  ...(messageId ? { id: messageId } : {}),
                  role: 'assistant',
                  parts: [
                    {
                      type: 'reasoning',
                      text: reasoningText,
                      timestamp: itemTimestamp
                    }
                  ],
                  timestamp: itemTimestamp
                },
                itemTimestamp
              )
            }
          }
        }

        continue
      }

      // Extract user input
      const input = turnObj.input as unknown[] | undefined
      if (Array.isArray(input)) {
        const textParts: unknown[] = []
        for (const item of input) {
          const itemObj = asObject(item)
          if (itemObj?.type === 'text' && typeof itemObj.text === 'string') {
            textParts.push({
              type: 'text',
              text: itemObj.text,
              timestamp: asString(turnObj.createdAt) ?? new Date().toISOString()
            })
          }
        }
        if (textParts.length > 0) {
          const timestamp = asString(turnObj.createdAt) ?? new Date().toISOString()
          pushMessage(
            {
              ...(turnId ? { id: `${turnId}:user` } : {}),
              role: 'user',
              parts: textParts,
              timestamp
            },
            timestamp
          )
        }
      }

      // Extract assistant output
      const output = turnObj.output as unknown[] | undefined
      const outputText = asString(turnObj.outputText)
      if (outputText) {
        const timestamp = asString(turnObj.updatedAt) ?? new Date().toISOString()
        pushMessage(
          {
            ...(turnId ? { id: `${turnId}:assistant` } : {}),
            role: 'assistant',
            parts: [
              {
                type: 'text',
                text: outputText,
                timestamp
              }
            ],
            timestamp
          },
          timestamp
        )
      } else if (Array.isArray(output)) {
        const assistantParts: unknown[] = []
        for (const item of output) {
          const itemObj = asObject(item)
          if (!itemObj) continue
          if (itemObj.type === 'text' && typeof itemObj.text === 'string') {
            assistantParts.push({
              type: 'text',
              text: itemObj.text,
              timestamp: asString(turnObj.updatedAt) ?? new Date().toISOString()
            })
          }
        }
        if (assistantParts.length > 0) {
          const timestamp = asString(turnObj.updatedAt) ?? new Date().toISOString()
          pushMessage(
            {
              ...(turnId ? { id: `${turnId}:assistant` } : {}),
              role: 'assistant',
              parts: assistantParts,
              timestamp
            },
            timestamp
          )
        }
      }
    }

    return messages
      .sort((a, b) => {
        if (a.sortTime !== b.sortTime) return a.sortTime - b.sortTime
        return a.order - b.order
      })
      .map((entry) => entry.message)
  }

  /**
   * Phase 21.5: emit an agent.* field event when a Codex tool item completes.
   * Called from handleManagerEvent on `item/completed` notifications.
   *
   * Codex tool lifecycle items are `commandExecution` (Bash) and `fileChange`
   * (apply_patch); anything else is ignored by `isToolLifecycleItem`.
   */
  private emitAgentToolField(
    session: CodexSessionState,
    event: CodexManagerEvent
  ): void {
    if (!this.dbService) return

    const payload = asObject(event.payload)
    const item = asObject(payload?.item)
    if (!item) return

    const itemType = asString(item?.type)?.toLowerCase()
    // Only emit for the tool lifecycle itemTypes Codex uses. Non-tool items
    // (agentMessage / reasoning / plan) are ignored.
    if (itemType !== 'commandexecution' && itemType !== 'filechange') return

    const toolName =
      asString(item?.toolName) ??
      asString(item?.name) ??
      // Fall back to a canonical name derived from the itemType so the
      // router in emit-agent-tool.ts knows how to categorize it.
      (itemType === 'commandexecution' ? 'exec_command' : 'apply_patch')

    const toolUseId =
      asString(item?.id) ?? asString(event.itemId) ?? asString(payload?.itemId) ?? ''
    if (!toolUseId) return

    const input = (item?.input as Record<string, unknown> | undefined) ?? {}
    const status = asString(item?.status)
    const isError = status === 'failed'
    const outputText = asString(item?.output) ?? asString(item?.aggregatedOutput) ?? undefined
    const exitCode = asNumber(item?.exitCode) ?? (isError ? 1 : undefined)
    const durationMs = asNumber(item?.durationMs) ?? undefined

    const worktreeRow = this.dbService.getWorktreeByPath(session.worktreePath)
    if (!worktreeRow) return

    void import('../field/emit-agent-tool').then(({ emitAgentToolEvent }) => {
      emitAgentToolEvent({
        worktreeId: worktreeRow.id,
        projectId: worktreeRow.project_id ?? null,
        sessionId: session.hiveSessionId,
        worktreePath: session.worktreePath,
        toolName,
        toolUseId,
        // Codex does not surface sub-agent nesting today; V1 stays null.
        parentToolUseId: null,
        input,
        output: {
          text: isError ? undefined : outputText,
          error: isError ? outputText : undefined,
          exitCode,
          durationMs
        }
      })
    })
  }

  /**
   * Reverse-lookup helper used by the Hub bridge: given a hive session id,
   * find the live in-memory codex session and return the routing tuple.
   * Mirrors `ClaudeCodeImplementer.findRoutingByHive` so the controller can
   * iterate runtimes uniformly. Returns null when the session isn't loaded
   * in this process — the controller then falls back to a DB-driven
   * lazy-materialize via `reconnect()`.
   */
  findRoutingByHive(
    hiveSessionId: string
  ): { worktreePath: string; agentSessionId: string } | null {
    for (const session of this.sessions.values()) {
      if (session.hiveSessionId === hiveSessionId) {
        return {
          worktreePath: session.worktreePath,
          agentSessionId: session.threadId
        }
      }
    }
    return null
  }
}
