import type { BrowserWindow } from 'electron'
import { app } from 'electron'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'

import { createLogger } from './logger'
import { asObject, asString } from './codex-utils'
import { notificationService } from './notification-service'
import { loadClaudeSDK } from './claude-sdk-loader'
import { maybeWithClaudeProjectMemory } from './claude-project-memory-loader'
import type {
  AgentSdkCapabilities,
  AgentSdkImplementer,
  PromptOptions
} from './agent-runtime-types'
import type { AgentRuntimeAdapter } from './agent-runtime-types'
import { CLAUDE_CODE_CAPABILITIES } from './agent-runtime-types'
import type { DatabaseService } from '../db/database'
import {
  readClaudeGoalStatus,
  readClaudeTranscript,
  translateEntry
} from './claude-transcript-reader'
import { generateSessionTitle } from './claude-session-title'
import { autoRenameWorktreeBranch } from './git-service'
import { getEventBus } from '../../server/event-bus'
import { Options, PermissionMode } from '@anthropic-ai/claude-agent-sdk'
import type { SDKControlGetContextUsageResponse } from '@anthropic-ai/claude-agent-sdk'
import { CommandFilterService, type CommandFilterSettings } from './command-filter-service'
import { createLspMcpServerConfig, LspService } from './lsp'
import { createXuanpuToolsMcpServerConfig } from './token-saver/xuanpu-tools-mcp'
import { isTokenSaverEnabled } from '../field/privacy'
import { XUANPU_SYSTEM_CONTEXT } from './xuanpu-system-context'
import { APP_SETTINGS_DB_KEY } from '@shared/types/settings'
import { getActiveAppHomeDir } from '@shared/app-identity'
import { beginSessionRun, emitAgentEvent } from '@shared/lib/normalize-agent-event'
import type { AgentSessionGoalState } from '@shared/types/agent-protocol'
import { resolveRuntimeModelId } from '@shared/usage/models'
import { calculateUsageCost, resolvePricingModelKey } from '@shared/usage/pricing'

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

const CLAUDE_EFFORT_VARIANTS = { low: {}, medium: {}, high: {} }
const CLAUDE_OPUS_EFFORT_VARIANTS = { low: {}, medium: {}, high: {}, max: {} }

const CLAUDE_MODELS = [
  {
    id: 'opus',
    name: 'Opus 4.7',
    limit: { context: 200000, output: 32000 },
    variants: CLAUDE_OPUS_EFFORT_VARIANTS,
    defaultVariant: 'high'
  },
  {
    id: 'sonnet',
    name: 'Sonnet 4.6',
    limit: { context: 200000, output: 16000 },
    variants: CLAUDE_EFFORT_VARIANTS,
    defaultVariant: 'high'
  },
  {
    id: 'haiku',
    name: 'Haiku 4.5',
    limit: { context: 200000, output: 8192 },
    variants: CLAUDE_EFFORT_VARIANTS,
    defaultVariant: 'high'
  }
]

function asNumberOrNull(value: unknown): number | null | undefined {
  if (value === null) return null
  return typeof value === 'number' && Number.isFinite(value) ? value : undefined
}

function splitClaudeGoalObjective(rawObjective: string): {
  objective: string
  successCriteria?: string
} {
  const trimmed = rawObjective.trim()
  const markerMatch = trimmed.match(/\n\s*Success criteria:\s*\n?/i)
  if (markerMatch?.index != null) {
    const objective = trimmed.slice(0, markerMatch.index).trim()
    const successCriteria = trimmed.slice(markerMatch.index + markerMatch[0].length).trim()
    return {
      objective: objective || trimmed,
      ...(successCriteria ? { successCriteria } : {})
    }
  }

  const pipeMarkerMatch = trimmed.match(/\s*\|\s*Success criteria:\s*\|\s*/i)
  if (pipeMarkerMatch?.index != null) {
    const objective = trimmed.slice(0, pipeMarkerMatch.index).trim()
    const successCriteria = trimmed.slice(pipeMarkerMatch.index + pipeMarkerMatch[0].length).trim()
    return {
      objective: objective || trimmed,
      ...(successCriteria ? { successCriteria } : {})
    }
  }

  return { objective: trimmed }
}

function formatClaudeGoalCommandArgument(goalObjective: string): string {
  return goalObjective
    .split(/\r?\n/)
    .map((line) => line.trim())
    .filter(Boolean)
    .join(' | ')
}

function appendClaudeGoalFallbackSystemPrompt(
  current: string | undefined,
  goalObjective: string
): string {
  const goalBlock = [
    '[Xuanpu Goal]',
    'The user has set a session goal for this turn. Treat it as the target outcome and use the success criteria to decide when the task is complete.',
    '',
    goalObjective.trim()
  ].join('\n')
  return current?.trim() ? `${current.trim()}\n\n${goalBlock}` : goalBlock
}

function readStringField(
  record: Record<string, unknown> | undefined,
  keys: string[]
): string | undefined {
  if (!record) return undefined
  for (const key of keys) {
    const value = asString(record[key])
    if (value?.trim()) return value.trim()
  }
  return undefined
}

function normalizeClaudeGoalStatus(
  rawStatus: string | undefined,
  messageKind: string | undefined,
  goalMet?: unknown
): string {
  const status = rawStatus?.trim().toLowerCase()
  if (goalMet === true || messageKind?.includes('achieved')) return 'completed'
  if (!status) return 'active'
  if (status === 'goal_met' || status === 'achieved' || status === 'complete') return 'completed'
  if (status === 'goal_set') return 'active'
  return status
}

function isClaudeGoalClearedStatus(status: string | undefined): boolean {
  const normalized = status?.trim().toLowerCase()
  return (
    normalized === 'cleared' ||
    normalized === 'clear' ||
    normalized === 'none' ||
    normalized === 'no_goal' ||
    normalized === 'unset'
  )
}

export interface ClaudeQuery {
  interrupt(): Promise<void>
  close(): void
  return?(value?: void): Promise<IteratorResult<unknown, void>>
  next(...args: unknown[]): Promise<IteratorResult<unknown, void>>
  [Symbol.asyncIterator](): AsyncGenerator<unknown, void>
  getContextUsage?(): Promise<SDKControlGetContextUsageResponse>
  rewindFiles?: (
    userMessageId: string,
    options?: { dryRun?: boolean }
  ) => Promise<void | {
    canRewind: boolean
    error?: string
    filesChanged?: string[]
    insertions?: number
    deletions?: number
  }>
  supportedCommands?: () => Promise<
    Array<{ name: string; description: string; argumentHint: string }>
  >
}

interface RewindFilesResult {
  canRewind?: boolean
  error?: string
  filesChanged?: string[]
  insertions?: number
  deletions?: number
}

export interface PendingQuestionState {
  requestId: string
  questions: { question: string; header: string }[]
  resolve: (response: { answers: string[][]; rejected?: boolean }) => void
}

export interface PendingPlanApprovalState {
  requestId: string
  resolve: (response: { approved: boolean; feedback?: string }) => void
}

/**
 * Partial assistant message being accumulated from SDK stream_event deltas.
 *
 * The Claude Agent SDK only emits a complete `assistant` SDK message AFTER a
 * full content turn finishes — on abort (user-triggered stop, network drop,
 * timeout) the complete message never arrives and `session.messages` is left
 * without the in-flight content. We rebuild a best-effort copy here so that
 * abort() can flush it to the DB and the renderer's timeline keeps the
 * partial turn after the run ends.
 *
 * Lifecycle:
 *   stream_event `message_start` → initialize fresh draft
 *   stream_event `content_block_*` → append text / reasoning / tool_use parts
 *   SDK `assistant` message received → draft cleared (authoritative)
 *   abort() with non-null draft → materialise into session.messages
 */
/**
 * Internal lifecycle state per in-flight Claude SDK tool_use block.
 * NOTE: emitted ToolPart conforms to `ToolStatus` from
 * `@shared/types/agent-protocol` (pending|running|completed|error|cancelled).
 * The internal `'success'` value here is mapped to `'completed'` at the
 * emit boundary by the timeline-mappers / store code path.
 */
interface InFlightToolDraft {
  id: string
  name: string
  blockIdx: number
  inputJson: string
  input?: unknown
  status: 'pending' | 'running' | 'success' | 'error'
  startTime: number
  endTime?: number
}

interface InFlightAssistantDraft {
  // Timestamp of the first delta — used as message.timestamp.
  timestamp: string
  // Running per-content-block accumulators keyed by block index so the order
  // matches what the SDK streams. Mixed types: text / thinking / tool.
  blocks: Map<number, InFlightDraftBlock>
  // Monotonic order of block insertion for final part serialisation.
  blockOrder: number[]
}

type InFlightDraftBlock =
  | { type: 'text'; text: string; timestamp: string }
  | { type: 'reasoning'; text: string; timestamp: string }
  | { type: 'tool_use'; tool: InFlightToolDraft }

export interface ClaudeSessionState {
  claudeSessionId: string
  hiveSessionId: string
  worktreePath: string
  abortController: AbortController | null
  checkpointCounter: number
  checkpoints: Map<string, number>
  query: ClaudeQuery | null
  /** Last completed query reference for rewindFiles access */
  lastQuery: ClaudeQuery | null
  materialized: boolean
  messages: unknown[]
  /** Maps tool_use IDs to their tool names for lookup on tool_result completion */
  toolNames: Map<string, string>
  /** Pending AskUserQuestion awaiting user response */
  pendingQuestion: PendingQuestionState | null
  /** Pending ExitPlanMode awaiting user approval/rejection */
  pendingPlanApproval: PendingPlanApprovalState | null
  /** Current revert boundary message ID (hive-side), set by undo */
  revertMessageID: string | null
  /** Last Goal state mirrored into Xuanpu's sticky Goal card. */
  currentGoal: AgentSessionGoalState | null
  /** SDK UUID of the reverted checkpoint, used for boundary lookups in subsequent undos */
  revertCheckpointUuid: string | null
  /** Diff string from last rewindFiles result */
  revertDiff: string | null
  /** Signals next prompt() should use forkSession: true to branch from the undo point */
  pendingFork: boolean
  /** SDK assistant UUID to pass as resumeSessionAt on the forked prompt.
   *  Ensures the fork's context excludes undone messages and throwaway entries. */
  pendingResumeSessionAt: string | null
  /** Title generation was skipped for the first prompt (e.g. /using-superpowers);
   *  fire on the next real user message instead. */
  titleDeferred: boolean
  /** Accumulated stderr output from the Claude Code process for the current prompt */
  stderrBuffer: string[]
  /**
   * In-flight assistant message being reconstructed from stream_event deltas.
   * Null outside of an assistant turn. See InFlightAssistantDraft docs.
   */
  inFlightDraft: InFlightAssistantDraft | null
}

export class ClaudeCodeImplementer implements AgentSdkImplementer, AgentRuntimeAdapter {
  readonly id = 'claude-code' as const
  readonly capabilities: AgentSdkCapabilities = CLAUDE_CODE_CAPABILITIES

  private mainWindow: BrowserWindow | null = null
  private dbService: DatabaseService | null = null
  private claudeBinaryPath: string | null = null
  private sessions = new Map<string, ClaudeSessionState>()
  private selectedModel: string = 'sonnet'
  private selectedVariant: string | undefined
  /** Tracks in-flight tool_use content blocks for input_json_delta accumulation.
   *  Keyed by hiveSessionId → Map<blockIndex, { id, name, inputJson }>. */
  private activeToolBlocks = new Map<
    string,
    Map<number, { id: string; name: string; inputJson: string }>
  >()
  /** Maps pending question requestIds to session keys for IPC routing */
  private pendingQuestionSessions = new Map<string, string>()
  /** Maps pending plan approval requestIds to session keys for IPC routing */
  private pendingPlanSessions = new Map<string, string>()
  /** Caches slash commands per worktree path, populated from SDK init messages */
  private cachedSlashCommands = new Map<
    string,
    Array<{ name: string; description: string; argumentHint: string }>
  >()
  /** LSP services keyed by worktree path — shared across sessions on the same worktree */
  private lspServices = new Map<string, LspService>()
  /** Command filter service for evaluating tool use permissions */
  private commandFilterService = new CommandFilterService()
  /** Whether the Claude CLI supports the --thinking flag.
   *  Set to false on first "unknown option '--thinking'" stderr and never re-enabled. */
  private thinkingSupported = true
  /** Maps pending command approval requestIds to their resolution callbacks */
  private pendingApprovals = new Map<
    string,
    {
      resolve: (response: {
        approved: boolean
        remember?: 'allow' | 'block'
        pattern?: string
        patterns?: string[]
      }) => void
      toolName: string
      input: Record<string, unknown>
      commandStr: string
      hiveSessionId: string
    }
  >()

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

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

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

  setClaudeBinaryPath(path: string | null): void {
    this.claudeBinaryPath = path
  }

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

  async connect(worktreePath: string, hiveSessionId: string): Promise<{ sessionId: string }> {
    const placeholderId = `pending::${randomUUID()}`

    const key = this.getSessionKey(worktreePath, placeholderId)
    const state: ClaudeSessionState = {
      claudeSessionId: placeholderId,
      hiveSessionId,
      worktreePath,
      abortController: new AbortController(),
      checkpointCounter: 0,
      checkpoints: new Map(),
      query: null,
      lastQuery: null,
      materialized: false,
      messages: [],
      toolNames: new Map(),
      pendingQuestion: null,
      pendingPlanApproval: null,
      revertMessageID: null,
      currentGoal: null,
      revertCheckpointUuid: null,
      revertDiff: null,
      pendingFork: false,
      pendingResumeSessionAt: null,
      titleDeferred: false,
      stderrBuffer: [],
      inFlightDraft: null
    }
    this.sessions.set(key, state)

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

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

    const existing = this.sessions.get(key)
    if (existing) {
      existing.hiveSessionId = hiveSessionId
      const sessionStatus = existing.query ? 'busy' : 'idle'
      log.info('Reconnect: session already registered, updated hiveSessionId', {
        worktreePath,
        agentSessionId,
        hiveSessionId,
        sessionStatus
      })
      if (!existing.query) {
        this.emitClaudeGoalFromTranscript(existing).catch((err) => {
          log.debug('Reconnect: goal status sync failed', {
            hiveSessionId,
            error: err instanceof Error ? err.message : String(err)
          })
        })
      }
      return { success: true, sessionStatus, revertMessageID: existing.revertMessageID ?? null }
    }

    // Sessions with pending:: prefix were never materialized — they were created
    // by connect() but never sent a prompt before the app restarted. Setting
    // materialized=false ensures prompt() won't pass the fake ID as `resume`,
    // which would cause the Claude Code process to exit with code 1.
    const isPending = agentSessionId.startsWith('pending::')

    const state: ClaudeSessionState = {
      claudeSessionId: agentSessionId,
      hiveSessionId,
      worktreePath,
      abortController: new AbortController(),
      checkpointCounter: 0,
      checkpoints: new Map(),
      query: null,
      lastQuery: null,
      materialized: !isPending,
      messages: [],
      toolNames: new Map(),
      pendingQuestion: null,
      pendingPlanApproval: null,
      revertMessageID: null,
      currentGoal: null,
      revertCheckpointUuid: null,
      revertDiff: null,
      pendingFork: false,
      pendingResumeSessionAt: null,
      titleDeferred: false,
      stderrBuffer: [],
      inFlightDraft: null
    }
    this.sessions.set(key, state)

    // Hydrate in-memory messages from DB so getMessages() and timeline
    // work immediately without waiting for the first prompt() call.
    if (this.dbService) {
      try {
        const dbRows = this.dbService.getSessionMessages(hiveSessionId)
        if (dbRows.length > 0) {
          const hydrated: unknown[] = []
          for (const row of dbRows) {
            if (row.opencode_message_json) {
              try {
                hydrated.push(JSON.parse(row.opencode_message_json))
              } catch {
                // Corrupted JSON row — skip
              }
            }
          }
          if (hydrated.length > 0) {
            state.messages = hydrated
            log.info('Reconnect: hydrated messages from DB', {
              hiveSessionId,
              count: hydrated.length
            })
          }
        }
      } catch (err) {
        log.warn('Reconnect: failed to hydrate from DB', {
          hiveSessionId,
          error: err instanceof Error ? err.message : String(err)
        })
      }
    }

    log.info('Reconnected (restored from DB)', { worktreePath, agentSessionId, hiveSessionId })
    this.emitClaudeGoalFromTranscript(state).catch((err) => {
      log.debug('Reconnect: goal status sync failed', {
        hiveSessionId,
        error: err instanceof Error ? err.message : String(err)
      })
    })
    return { success: true, sessionStatus: 'idle', revertMessageID: null }
  }

  private getOrCreateLspService(worktreePath: string): LspService {
    const existing = this.lspServices.get(worktreePath)
    if (existing) return existing
    const service = new LspService(worktreePath)
    this.lspServices.set(worktreePath, service)
    return service
  }

  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
    }

    // Persist messages before removing from memory to prevent data loss
    if (session.messages.length > 0) {
      this.persistMessagesToDB(session)
    }

    if (session.query) {
      try {
        session.query.close()
      } catch {
        log.warn('Disconnect: query.close() threw, ignoring', { worktreePath, agentSessionId })
      }
      session.query = null
    }

    if (session.abortController) {
      session.abortController.abort()
    }

    this.sessions.delete(key)
    // Clear cached slash commands so a fresh session doesn't show stale entries
    this.cachedSlashCommands.delete(worktreePath)

    // Shut down LSP service if no remaining sessions use this worktree
    const stillUsed = [...this.sessions.values()].some((s) => s.worktreePath === worktreePath)
    if (!stillUsed) {
      const lsp = this.lspServices.get(worktreePath)
      if (lsp) {
        await lsp.shutdown()
        this.lspServices.delete(worktreePath)
        log.info('LSP service shut down (no remaining sessions)', { worktreePath })
      }
    }

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

  async cleanup(): Promise<void> {
    log.info('Cleaning up all Claude Code sessions', { count: this.sessions.size })
    for (const [key, session] of this.sessions) {
      // Persist messages before clearing to prevent data loss on app exit
      if (session.messages.length > 0) {
        this.persistMessagesToDB(session)
      }
      if (session.query) {
        try {
          session.query.close()
        } catch {
          log.warn('Cleanup: query.close() threw, ignoring', { key })
        }
        session.query = null
      }
      if (session.abortController) {
        log.debug('Aborting session', { key })
        session.abortController.abort()
      }
    }
    this.sessions.clear()
    this.cachedSlashCommands.clear()

    // Shut down all LSP services
    for (const lsp of this.lspServices.values()) {
      try {
        await lsp.shutdown()
      } catch {
        log.warn('Cleanup: LSP service shutdown threw, ignoring')
      }
    }
    this.lspServices.clear()
  }

  // ── 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 },
    promptOptions?: PromptOptions
  ): Promise<void> {
    const session = this.getSession(worktreePath, agentSessionId)
    if (!session) {
      throw new Error(`Prompt failed: session not found for ${worktreePath} / ${agentSessionId}`)
    }

    beginSessionRun(session.hiveSessionId)

    // Clear revert boundary — a new prompt invalidates prior undo state
    session.revertMessageID = null
    session.revertCheckpointUuid = null
    session.revertDiff = null

    // Reset stderr buffer so it only captures output from this prompt
    session.stderrBuffer = []

    this.emitStatus(session.hiveSessionId, 'busy')
    log.info('Prompt: starting', {
      worktreePath,
      agentSessionId,
      hiveSessionId: session.hiveSessionId,
      materialized: session.materialized,
      claudeSessionId: session.claudeSessionId
    })

    try {
      const sdk = await loadClaudeSDK()
      log.info('Prompt: SDK loaded')

      // Build prompt: use structured content blocks when file attachments are present,
      // otherwise use a plain string (preserving the existing fast path).
      const hasFiles = this.hasFileAttachments(message)

      let prompt: string
      let contentBlocks: Array<Record<string, unknown>> | null = null

      if (typeof message === 'string') {
        prompt = message
      } else if (!hasFiles) {
        // No file attachments — flatten to string (existing behavior)
        prompt = message
          .filter((part) => part.type === 'text')
          .map((part) => (part as { type: 'text'; text: string }).text)
          .join('\n')
      } else {
        // Has file attachments — build structured content blocks for the SDK
        contentBlocks = this.buildAnthropicContentBlocks(message)
        // Also build a text-only prompt string for logging and the synthetic user message
        prompt = message
          .map((part) =>
            part.type === 'text' ? part.text : `[attachment: ${part.filename ?? 'file'}]`
          )
          .join('\n')
      }

      log.info('Prompt: constructed', {
        promptLength: prompt.length,
        promptPreview: prompt.slice(0, 100)
      })

      // After undo, the fork creates a new session branch.  Clear the
      // in-memory messages so the new branch starts fresh — the SDK will
      // stream the forked conversation's messages which we'll capture.
      if (session.pendingFork) {
        session.messages = []
        log.debug('Cleared session messages for pending fork')
      } else if (session.messages.length === 0) {
        // Hydrate in-memory messages from the transcript so that
        // getMessages() returns the full history, not just this turn.
        const existing = await readClaudeTranscript(session.worktreePath, session.claudeSessionId)
        session.messages.push(...existing)
        log.debug('Hydrated session messages from transcript', {
          count: existing.length
        })
      }

      // Inject a synthetic user message into session.messages so that
      // getMessages() returns it alongside the assistant response.
      // The SDK does NOT emit a `user` type event — without this,
      // loadMessages() on idle would replace state with only the assistant
      // message, causing the user's message to vanish.
      const syntheticTimestamp = new Date().toISOString()
      const syntheticParts: Array<Record<string, unknown>> = []

      if (typeof message === 'string' || !hasFiles) {
        syntheticParts.push({ type: 'text', text: prompt, timestamp: syntheticTimestamp })
      } else {
        // Include both text and file parts so the renderer can display attachments
        for (const part of message) {
          if (part.type === 'text') {
            syntheticParts.push({ type: 'text', text: part.text, timestamp: syntheticTimestamp })
          } else {
            syntheticParts.push({
              type: 'file',
              mime: part.mime,
              url: part.url,
              filename: part.filename,
              timestamp: syntheticTimestamp
            })
          }
        }
      }

      session.messages.push({
        id: `user-${randomUUID()}`,
        role: 'user',
        timestamp: syntheticTimestamp,
        content: prompt,
        parts: syntheticParts,
        // Marker for the SDK-echo dedup loop below. Without this the dedup
        // falls back to content-string equality which fails whenever
        // attachments are present: the synthetic content includes
        // `[attachment: name]` placeholders, while the SDK echo only carries
        // text blocks (extractTextFromContent skips file blocks). Mismatched
        // strings → no dedup → DOUBLE row in session_messages on every prompt
        // that has files attached.
        _pendingSdkEcho: true
      })

      // Fresh AbortController for this prompt turn
      session.abortController = new AbortController()

      // Determine permission mode from DB session mode
      // 'plan' mode uses SDK-native plan mode (ExitPlanMode blocking tool)
      // 'build' mode uses 'default' so canUseTool handles AskUserQuestion
      let sdkPermissionMode: PermissionMode = 'default'
      if (this.dbService) {
        try {
          const dbSession = this.dbService.getSession(session.hiveSessionId)
          if (dbSession?.mode === 'plan') {
            sdkPermissionMode = 'plan'
          }
        } catch {
          // Fall through to default mode
        }
      }

      // Resolve effort level from variant selection (default per model)
      const resolvedModel = modelOverride?.modelID ?? this.selectedModel
      const modelDef = CLAUDE_MODELS.find((m) => m.id === resolvedModel)
      const effortLevel = (modelOverride?.variant ??
        this.selectedVariant ??
        modelDef?.defaultVariant ??
        'high') as Options['effort']

      // When the session runs inside a worktree, also expose the parent
      // project's root as an additional directory so that project-scope
      // skills (`<projectPath>/.claude/skills/`), CLAUDE.md, and other
      // `.claude/*` resources installed at the project level are picked up.
      // The Agent SDK only walks `.claude/skills/` under cwd + each
      // additionalDirectory, never sideways into sibling worktree paths.
      const additionalDirs: string[] = []
      if (this.dbService) {
        try {
          const wt = this.dbService.getWorktreeByPath(session.worktreePath)
          if (wt) {
            const proj = this.dbService.getProject(wt.project_id)
            if (proj?.path && proj.path !== session.worktreePath) {
              additionalDirs.push(proj.path)
            }
          }
        } catch {
          // Best-effort; missing parent project just means no extra dirs.
        }
      }

      // Build SDK query options
      let options: Options = {
        cwd: session.worktreePath,
        permissionMode: sdkPermissionMode,
        abortController: session.abortController,
        ...(this.thinkingSupported ? { maxThinkingTokens: 31999 } : {}),
        model: resolvedModel,
        includePartialMessages: true,
        enableFileCheckpointing: true,
        settingSources: ['user', 'project', 'local'],
        extraArgs: { 'replay-user-messages': null },
        // Append Xuanpu's runtime context to the SDK system prompt. This
        // tells the model that Xuanpu's [Field Context]/[User Message] envelope
        // is informational, not contractual — preventing "No response
        // requested." silent-exits when the SDK injects bare boilerplate
        // (e.g. "Continue from where you left off." after an interrupted turn).
        // See src/main/services/xuanpu-system-context.ts for the full rationale.
        appendSystemPrompt: XUANPU_SYSTEM_CONTEXT,
        ...(additionalDirs.length > 0 ? { additionalDirectories: additionalDirs } : {}),
        ...(this.thinkingSupported ? { thinking: { type: 'adaptive' } as const } : {}),
        effort: effortLevel,
        debugFile: join(getActiveAppHomeDir(app.getPath('home')), 'logs', 'claude-debug.log'),
        env: {
          ...process.env,
          CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1'
        },
        stderr: (data: string) => {
          session.stderrBuffer.push(data)
          log.warn('Claude Code stderr', {
            worktreePath,
            agentSessionId,
            stderr: data.trim()
          })
        },
        canUseTool: this.createCanUseToolCallback(session),
        ...(this.claudeBinaryPath ? { pathToClaudeCodeExecutable: this.claudeBinaryPath } : {})
      }

      // Attach LSP MCP server so Claude can query language servers (best-effort)
      try {
        const lspService = this.getOrCreateLspService(session.worktreePath)
        const lspMcpServer = await createLspMcpServerConfig(lspService)
        options.mcpServers = { ...options.mcpServers, 'hive-lsp': lspMcpServer }
        options.allowedTools = [...(options.allowedTools ?? []), 'mcp__hive-lsp__lsp']
      } catch (err) {
        log.warn('Failed to attach LSP MCP server, continuing without LSP', {
          worktreePath: session.worktreePath,
          error: err instanceof Error ? err.message : String(err)
        })
      }

      // Token Saver (v1.5.0): in-process MCP `bash` tool that compresses
      // verbose output before it reaches the API and archives the original
      // locally. When enabled we suppress the built-in Bash so the agent only
      // sees ours. Best-effort: failure to attach falls through to native Bash.
      let tokenSaverEnabled = false
      try {
        tokenSaverEnabled = isTokenSaverEnabled()
      } catch (err) {
        log.warn('Token Saver: failed to read setting, falling back to native Bash', {
          hiveSessionId: session.hiveSessionId,
          error: err instanceof Error ? err.message : String(err)
        })
      }

      if (tokenSaverEnabled) {
        try {
          const tokenSaverServer = await createXuanpuToolsMcpServerConfig({
            sessionId: session.hiveSessionId,
            defaultCwd: session.worktreePath,
            logger: { warn: (msg, meta) => log.warn(msg, meta) }
          })
          options.mcpServers = { ...options.mcpServers, xuanpu: tokenSaverServer }
          options.allowedTools = [...(options.allowedTools ?? []), 'mcp__xuanpu__bash']
          options.disallowedTools = [...(options.disallowedTools ?? []), 'Bash']
          log.info('Token Saver: attached xuanpu MCP, disallowed built-in Bash', {
            hiveSessionId: session.hiveSessionId
          })
        } catch (err) {
          log.warn('Token Saver: failed to attach MCP, falling back to native Bash', {
            hiveSessionId: session.hiveSessionId,
            error: err instanceof Error ? err.message : String(err)
          })
        }
      }

      options = await maybeWithClaudeProjectMemory(options)

      // If session is materialized (has real SDK ID), add resume
      if (session.materialized) {
        options.resume = session.claudeSessionId
      }

      // After an undo, fork the conversation so the SDK creates a new branch.
      // forkSession: true tells the SDK to branch from the resume point,
      // returning a new session ID.
      // resumeSessionAt tells the SDK to load history only up to a specific
      // assistant message — this ensures the fork's context excludes both
      // the undone messages and any throwaway entries from rewindWithResumedQuery().
      if (session.pendingFork && session.materialized) {
        options.forkSession = true
        if (session.pendingResumeSessionAt) {
          options.resumeSessionAt = session.pendingResumeSessionAt
          session.pendingResumeSessionAt = null
        }
      }

      const requestedGoalObjective =
        promptOptions?.goalMode === true ? promptOptions.goalObjective?.trim() || prompt.trim() : ''
      const wasMaterializedBeforeGoal = session.materialized
      if (requestedGoalObjective) {
        const nativeGoalObserved = await this.setClaudeGoalBeforePrompt(
          sdk,
          session,
          requestedGoalObjective,
          options
        )
        if (!nativeGoalObserved) {
          options.appendSystemPrompt = appendClaudeGoalFallbackSystemPrompt(
            options.appendSystemPrompt,
            requestedGoalObjective
          )
        }
        if (!options.forkSession && session.materialized && !options.resume) {
          options.resume = session.claudeSessionId
        }
        if (session.abortController?.signal.aborted) {
          return
        }
      }

      const materializedByGoalCommand =
        requestedGoalObjective.length > 0 && !wasMaterializedBeforeGoal && session.materialized
      if (materializedByGoalCommand && prompt.trimStart().startsWith('/using-superpowers')) {
        session.titleDeferred = true
        log.info('Prompt: deferring title generation after goal command', {
          hiveSessionId: session.hiveSessionId
        })
      } else if (
        (materializedByGoalCommand || session.titleDeferred) &&
        session.materialized &&
        !prompt.trimStart().startsWith('/using-superpowers')
      ) {
        session.titleDeferred = false
        this.handleTitleGeneration(session, prompt).catch(() => {})
        log.info('Prompt: firing title generation', {
          hiveSessionId: session.hiveSessionId,
          promptPreview: prompt.slice(0, 80),
          reason: materializedByGoalCommand ? 'goal-command-materialized' : 'deferred'
        })
      }

      log.info('Prompt: calling sdk.query()', {
        model: options.model,
        effort: options.effort,
        resume: !!options.resume,
        forkSession: !!options.forkSession,
        resumeSessionAt: options.resumeSessionAt ?? null,
        cwd: options.cwd,
        hasFileAttachments: !!contentBlocks
      })

      // When file attachments are present, use AsyncIterable<SDKUserMessage> prompt path
      // with proper Anthropic content blocks (base64 images/documents);
      // otherwise use the plain string path (preserves all existing behavior).
      const sdkPrompt = contentBlocks
        ? this.createUserMessageIterable(contentBlocks, session.claudeSessionId)
        : prompt

      const queryData = sdk.query({ prompt: sdkPrompt, options }) as AsyncIterable<
        Record<string, unknown>
      >
      session.pendingFork = false
      session.query = queryData as unknown as ClaudeQuery

      // Capture whether this prompt was an explicit fork request (undo+resend).
      // Used to send an authoritative wasFork flag in session.materialized so the
      // renderer doesn't have to guess based on the old session ID format.
      const wasForkRequest = !!options.forkSession

      log.info('Prompt: entering async iteration loop')

      let messageIndex = 0

      for await (const sdkMessage of queryData) {
        // Break if aborted
        if (session.abortController?.signal.aborted) {
          log.info('Prompt: aborted, breaking loop')
          break
        }

        const msgType = sdkMessage.type as string

        // stream_event messages fire per-token — log at debug to avoid spam
        if (msgType === 'stream_event') {
          this.updateInFlightDraft(session, sdkMessage)
          this.emitSdkMessage(session.hiveSessionId, sdkMessage, messageIndex, session.toolNames)
          continue // No materialization/accumulation needed for partials
        }

        if (msgType === 'rate_limit_event') {
          log.info('Prompt: rate_limit_event received', {
            fullMessage: sdkMessage
          })
        }

        log.info('Prompt: received SDK message', {
          type: msgType,
          index: messageIndex,
          hasSessionId: !!sdkMessage.session_id,
          hasContent: !!sdkMessage.content,
          keys: Object.keys(sdkMessage).join(',')
        })

        // Log init messages (includes MCP server connection status) and cache slash commands
        // SDK sends init as { type: 'system', subtype: 'init' }
        const msgSubtype = (sdkMessage as Record<string, unknown>).subtype as string | undefined

        // Detect compaction start via system status message.
        // Fires BEFORE compact_boundary, so the renderer can show a loading
        // indicator ("正在压缩上下文窗口...") immediately.
        if (msgType === 'system' && msgSubtype === 'status') {
          const status = (sdkMessage as Record<string, unknown>).status as string | undefined
          if (status === 'compacting') {
            log.info('Compaction started detected via system status message')
            emitAgentEvent(this.mainWindow, {
              type: 'session.compaction_started',
              sessionId: session.hiveSessionId,
              data: {}
            })
          }
        }

        if (msgType === 'system' && msgSubtype === 'init') {
          const initMsg = sdkMessage as Record<string, unknown>
          log.info('Prompt: init message received', {
            mcpServers: initMsg.mcp_servers,
            model: initMsg.model,
            slashCommands: initMsg.slash_commands
          })

          // Phase 1: Cache command names from init message as minimal entries
          const initSlashCommandNames = initMsg.slash_commands as string[] | undefined
          if (initSlashCommandNames && Array.isArray(initSlashCommandNames)) {
            const minimal = initSlashCommandNames
              .filter((name): name is string => typeof name === 'string')
              .map((name) => ({
                name,
                description: '',
                argumentHint: ''
              }))
            this.cachedSlashCommands.set(worktreePath, minimal)
            this.persistCommandsToDb(worktreePath)
            log.info('Prompt: cached slash command names from init', {
              count: minimal.length,
              names: minimal.map((c) => c.name)
            })
          }

          // Phase 2: Enrich with full metadata via supportedCommands() (fire-and-forget)
          if (session.query?.supportedCommands) {
            session.query
              .supportedCommands()
              .then((cmds) => {
                if (cmds?.length) {
                  this.cachedSlashCommands.set(worktreePath, cmds)
                  this.persistCommandsToDb(worktreePath)
                  log.info('Prompt: cached full slash commands from supportedCommands()', {
                    count: cmds.length,
                    names: cmds.map((c) => c.name)
                  })
                }
              })
              .catch((err) => {
                log.warn('Prompt: supportedCommands() failed, using init names', {
                  error: err instanceof Error ? err.message : String(err)
                })
              })
          }

          // Notify renderer that commands are now available for fetching
          emitAgentEvent(this.mainWindow, {
            type: 'session.commands_available',
            sessionId: session.hiveSessionId,
            data: {}
          })

          // Send Claude model limits so the renderer can populate contextStore
          emitAgentEvent(this.mainWindow, {
            type: 'session.model_limits',
            sessionId: session.hiveSessionId,
            data: {
              models: CLAUDE_MODELS.map((m) => ({
                modelID: m.id,
                providerID: 'anthropic',
                contextLimit: m.limit.context
              }))
            }
          })

          continue
        }

        // Materialize pending:: to real SDK session ID from first message,
        // or capture a new session ID after a fork (forkSession: true returns a new ID)
        const sdkSessionId = sdkMessage.session_id as string | undefined
        const materialization = this.materializeSessionFromSdkMessage(
          session,
          worktreePath,
          sdkSessionId,
          wasForkRequest
        )
        if (materialization?.wasPending) {
          if (prompt.trimStart().startsWith('/using-superpowers')) {
            session.titleDeferred = true
            log.info('Prompt: deferring title generation (superpowers hook)', {
              hiveSessionId: session.hiveSessionId
            })
          } else {
            this.handleTitleGeneration(session, prompt).catch(() => {
              // Swallowed — handleTitleGeneration already logs internally
            })
          }
        }

        if (this.emitClaudeGoalFromSdkMessage(session, sdkMessage)) {
          messageIndex++
          continue
        }

        // Accumulate translated messages in-memory for getMessages()
        if (msgType === 'user' || msgType === 'assistant') {
          const sdkMsg = sdkMessage as Record<string, unknown>

          // Skip compaction summary — a synthetic user message containing the
          // conversation summary after context compaction. Should not appear in UI.
          if (sdkMsg.isCompactSummary === true) continue

          const msgContent = (
            sdkMsg.message as { content?: { type: string; [key: string]: unknown }[] } | undefined
          )?.content

          // Skip compaction-related user messages — content-based fallback.
          // Covers both: (1) context-continuation summary from CLI after context
          // exhaustion, and (2) compaction summary injected by SDK after context
          // window compression. Neither should appear in the UI timeline.
          // Also skip skill prompt injection messages (expanded skill file content).
          // Also skip the SDK's synthetic "Continue from where you left off." marker
          // injected by aA8/print.ts when resuming an interrupted_turn or a deferred
          // tool use. It carries isMeta:true upstream, but the flag is unreliable
          // across SDK serialization, so we match the canonical text instead.
          if (msgType === 'user') {
            // Honor explicit isMeta marker when SDK propagates it
            if ((sdkMsg as Record<string, unknown>).isMeta === true) {
              log.info('Skipping SDK meta user message', {
                uuid: typeof sdkMsg.uuid === 'string' ? sdkMsg.uuid.slice(0, 12) : undefined
              })
              continue
            }
            let textContent = ''
            if (Array.isArray(msgContent)) {
              textContent = msgContent
                .filter((b) => b.type === 'text' && typeof b.text === 'string')
                .map((b) => b.text as string)
                .join('')
            } else if (typeof msgContent === 'string') {
              textContent = msgContent
            } else if (typeof (sdkMsg as Record<string, unknown>).content === 'string') {
              // Some SDK versions put content directly on the message, not nested
              textContent = (sdkMsg as Record<string, unknown>).content as string
            }
            const trimmed = textContent.trimStart()
            if (
              trimmed.startsWith('This session is being continued from a previous conversation') ||
              trimmed.startsWith('Here is a summary of the conversation so far') ||
              trimmed.startsWith('Base directory for this skill:') ||
              trimmed.startsWith('<local-command-stdout>') ||
              trimmed.startsWith('<system-reminder>') ||
              trimmed === 'Continue from where you left off.'
            ) {
              log.info('Skipping SDK boilerplate user message', {
                preview: trimmed.slice(0, 60)
              })
              continue
            }
          }

          const contentBlockTypes = Array.isArray(msgContent) ? msgContent.map((b) => b.type) : []
          const isToolResultOnly =
            msgType === 'user' &&
            contentBlockTypes.length > 0 &&
            contentBlockTypes.every((t) => t === 'tool_result')

          // Capture checkpoints only from actual user prompts on the
          // main conversation thread (not subagent messages).
          // tool_result-only user messages carry UUIDs but do not represent
          // stable rewind points for file checkpointing.
          // Subagent messages have parent_tool_use_id set — their UUIDs live
          // in a separate JSONL file and are not valid fork/resume points.
          const isSubagentMessage = !!(sdkMessage as Record<string, unknown>).parent_tool_use_id
          if (msgType === 'user' && sdkMessage.uuid && !isToolResultOnly && !isSubagentMessage) {
            const checkpointUuid = sdkMessage.uuid as string
            const isFirstSeenCheckpoint = !session.checkpoints.has(checkpointUuid)
            if (isFirstSeenCheckpoint) {
              session.checkpointCounter += 1
              session.checkpoints.set(checkpointUuid, session.checkpointCounter)
              log.info('Checkpoint captured', {
                uuid: checkpointUuid,
                counter: session.checkpointCounter,
                totalCheckpoints: session.checkpoints.size
              })
            }
          } else if (msgType === 'user' && sdkMessage.uuid && isSubagentMessage) {
            log.debug('Skipping subagent user message for checkpoint', {
              uuid: (sdkMessage.uuid as string).slice(0, 12),
              parentToolUseId: (sdkMessage as Record<string, unknown>).parent_tool_use_id
            })
          }

          log.info('TOOL_LIFECYCLE: accumulate message', {
            hiveSessionId: session.hiveSessionId,
            msgType,
            contentBlockTypes,
            isToolResultOnly,
            isSubagentMessage
          })

          // Skip subagent messages from accumulation — they belong to the child
          // session's transcript and should not appear in the main conversation.
          // The renderer routes subagent content into SubtaskCards via childSessionId
          // on stream events; the in-memory cache must not include them either.
          if (isSubagentMessage) {
            log.debug('Skipping subagent message from session.messages accumulation', {
              msgType,
              parentToolUseId: (sdkMessage as Record<string, unknown>).parent_tool_use_id
            })
          } else if (isToolResultOnly) {
            // Instead of creating an empty user message, merge tool_result
            // output/error into the preceding assistant message's tool_use parts.
            const toolResults = msgContent as {
              type: string
              tool_use_id?: string
              is_error?: boolean
              content?: string | { type: string; text?: string }[]
            }[]
            // Find the last assistant message
            const lastAssistant = [...session.messages]
              .reverse()
              .find((m) => (m as Record<string, unknown>).role === 'assistant') as
              | Record<string, unknown>
              | undefined
            if (lastAssistant) {
              const parts = lastAssistant.parts as Record<string, unknown>[] | undefined
              if (Array.isArray(parts)) {
                for (const tr of toolResults) {
                  if (tr.type !== 'tool_result' || !tr.tool_use_id) continue
                  let output: string | undefined
                  if (typeof tr.content === 'string') {
                    output = tr.content
                  } else if (Array.isArray(tr.content)) {
                    output = tr.content
                      .filter((c) => c.type === 'text')
                      .map((c) => c.text ?? '')
                      .join('\n')
                  }
                  const toolPart = parts.find(
                    (p) =>
                      p.type === 'tool_use' &&
                      (p.toolUse as Record<string, unknown> | undefined)?.id === tr.tool_use_id
                  )
                  if (toolPart) {
                    const tu = toolPart.toolUse as Record<string, unknown>
                    tu.output = output
                    tu.error = tr.is_error ? output : undefined
                    tu.status = tr.is_error ? 'error' : 'success'
                    log.info('TOOL_LIFECYCLE: merged tool_result into assistant tool_use', {
                      toolId: tr.tool_use_id,
                      isError: !!tr.is_error,
                      hasOutput: !!output
                    })

                    // Phase 21.5: emit agent.* field event when a tool_use
                    // completes. Wrapped in try/catch — instrumentation
                    // failure must never break the merge.
                    try {
                      this.emitAgentToolField(session, tu, tr, output)
                    } catch (err) {
                      log.debug('Phase 21.5 emit failed; continuing', {
                        error: err instanceof Error ? err.message : String(err)
                      })
                    }
                  }
                }
              }
            }
          } else {
            const translated = translateEntry(
              {
                type: msgType,
                uuid: sdkMsg.uuid as string | undefined,
                timestamp: new Date().toISOString(),
                message: sdkMsg.message as
                  | {
                      role?: string
                      content?: { type: string; [key: string]: unknown }[] | string
                    }
                  | undefined,
                isSidechain: false
              },
              session.messages.length
            )
            if (translated) {
              const isUserMessage = msgType === 'user'
              const translatedContent = (translated as Record<string, unknown>).content
              let optimisticIndex = -1

              if (isUserMessage) {
                // Prefer flag-based dedup: the synthetic optimistic user
                // message we pushed at prompt() time carries `_pendingSdkEcho`.
                // Match the FIRST such pending entry (oldest first) to handle
                // queued prompts in order. Falls back to legacy content-string
                // equality when no flag entry is found (covers cold reloads
                // where session.messages was hydrated from transcript).
                for (let i = 0; i < session.messages.length; i++) {
                  const candidate = session.messages[i] as Record<string, unknown>
                  if (candidate.role === 'user' && candidate._pendingSdkEcho === true) {
                    optimisticIndex = i
                    break
                  }
                }
                if (optimisticIndex < 0 && typeof translatedContent === 'string') {
                  for (let i = session.messages.length - 1; i >= 0; i--) {
                    const candidate = session.messages[i] as Record<string, unknown>
                    if (
                      candidate.role === 'user' &&
                      typeof candidate.id === 'string' &&
                      candidate.id.startsWith('user-') &&
                      typeof candidate.content === 'string' &&
                      candidate.content === translatedContent
                    ) {
                      optimisticIndex = i
                      break
                    }
                  }
                }
              }

              if (optimisticIndex >= 0) {
                const previous = session.messages[optimisticIndex] as Record<string, unknown>
                const previousTimestamp = asString(previous.timestamp)
                // Preserve the optimistic user message's original timestamp.
                // The SDK echoes the user message back AFTER the assistant has
                // already streamed, so using the echo's timestamp would push
                // the user bubble below the assistant reply once the timeline
                // is reloaded from the DB (rows are sorted by created_at ASC).
                session.messages[optimisticIndex] = previousTimestamp
                  ? { ...translated, timestamp: previousTimestamp }
                  : translated
              } else {
                session.messages.push(translated)
              }
            }
          }
        }

        // Persist compaction boundary as a synthetic assistant message so it
        // survives the getMessages() → finalizeResponse() reload cycle.
        // Without this, the CompactionPill would disappear when the renderer
        // refreshes messages from the in-memory cache on stream completion.
        let compactBoundaryDetected = false

        if (msgType === 'system') {
          const subtype = (sdkMessage as Record<string, unknown>).subtype as string | undefined
          if (subtype === 'compact_boundary') {
            compactBoundaryDetected = true
            const meta = (sdkMessage as Record<string, unknown>).compact_metadata as
              | Record<string, unknown>
              | undefined
            session.messages.push({
              id: `compaction-${randomUUID()}`,
              role: 'assistant',
              timestamp: new Date().toISOString(),
              content: '',
              parts: [
                {
                  type: 'compaction',
                  auto: meta?.trigger === 'auto'
                }
              ]
            })
            log.info('Compaction boundary persisted to session.messages', {
              hiveSessionId: session.hiveSessionId,
              trigger: meta?.trigger,
              totalMessages: session.messages.length
            })
          }
        }

        // Emit normalized event
        this.updateInFlightDraft(session, sdkMessage)
        this.emitSdkMessage(session.hiveSessionId, sdkMessage, messageIndex, session.toolNames)
        if (compactBoundaryDetected) {
          await this.emitClaudeContextUsageSnapshot(session)
          emitAgentEvent(this.mainWindow, {
            type: 'session.context_compacted',
            sessionId: session.hiveSessionId,
            data: {}
          })
        }
        messageIndex++
      }

      log.info('Prompt: async iteration loop finished', {
        totalMessages: messageIndex,
        aborted: session.abortController?.signal.aborted ?? false
      })
      await this.emitClaudeGoalFromTranscript(session)
      await this.reconcileSessionMessagesFromTranscript(session)
      this.persistMessagesToDB(session)
    } catch (error) {
      const errorMessage = error instanceof Error ? error.message : String(error)
      const stderrOutput = session.stderrBuffer.join('').trim() || undefined

      // Detect old Claude CLI that doesn't support --thinking flag.
      // Disable thinking, warn the user, and transparently retry the prompt.
      if (this.thinkingSupported && stderrOutput?.includes("unknown option '--thinking'")) {
        this.thinkingSupported = false
        log.warn('Claude CLI does not support --thinking, disabling and retrying', {
          worktreePath,
          agentSessionId,
          stderr: stderrOutput
        })
        emitAgentEvent(this.mainWindow, {
          type: 'session.warning',
          sessionId: session.hiveSessionId,
          data: {
            message:
              'Claude Code CLI 版本过旧，不支持扩展思考（extended thinking）。' +
              '已自动回退，但建议升级：运行 claude update 或 npm install -g @anthropic-ai/claude-code'
          }
        })
        // Reset session state and retry without --thinking
        session.query = null
        session.stderrBuffer = []
        session.abortController = new AbortController()
        return this.prompt(worktreePath, agentSessionId, message, modelOverride, promptOptions)
      }

      // Capture any extra properties the SDK may attach to the error
      const errorExtras: Record<string, unknown> = {}
      if (error && typeof error === 'object') {
        for (const key of Object.getOwnPropertyNames(error)) {
          if (!['name', 'message', 'stack'].includes(key)) {
            errorExtras[key] = (error as Record<string, unknown>)[key]
          }
        }
      }

      log.error(
        'Prompt streaming error',
        error instanceof Error ? error : new Error(errorMessage),
        {
          worktreePath,
          agentSessionId,
          error: errorMessage,
          stderr: stderrOutput,
          ...(Object.keys(errorExtras).length > 0 ? { errorExtras } : {})
        }
      )

      emitAgentEvent(this.mainWindow, {
        type: 'session.error',
        sessionId: session.hiveSessionId,
        data: { error: errorMessage, stderr: stderrOutput }
      })
      // idle is emitted by the finally block after persist
    } finally {
      // Always persist messages — whether the loop completed normally,
      // was aborted, or threw an error. This prevents message loss.
      // If the loop exited without the SDK emitting the authoritative
      // `assistant` message (network drop, uncaught error, manual break),
      // flush whatever we accumulated from stream_events.
      const abortedFlag = session.abortController?.signal.aborted === true
      if (session.inFlightDraft) {
        this.flushInFlightDraft(session, abortedFlag)
      }
      this.persistMessagesToDB(session)
      session.lastQuery = session.query
      session.query = null
      // Guarantee idle status is emitted after persist completes.
      // try/catch paths emit idle before finally, but abort() relies on
      // finally to emit idle (to avoid racing with persist).
      this.emitStatus(session.hiveSessionId, 'idle')
    }
  }

  private materializeSessionFromSdkMessage(
    session: ClaudeSessionState,
    worktreePath: string,
    sdkSessionId: string | undefined,
    wasForkRequest: boolean
  ): { wasPending: boolean; sdkSessionId: string } | null {
    if (!sdkSessionId) return null
    if (
      !session.claudeSessionId.startsWith('pending::') &&
      sdkSessionId === session.claudeSessionId
    ) {
      return null
    }

    const wasPending = session.claudeSessionId.startsWith('pending::')
    const oldKey = this.getSessionKey(worktreePath, session.claudeSessionId)
    session.claudeSessionId = sdkSessionId
    session.materialized = true
    this.sessions.delete(oldKey)
    const newKey = this.getSessionKey(worktreePath, sdkSessionId)
    this.sessions.set(newKey, session)
    log.info(wasPending ? 'Materialized session ID' : 'Forked session ID', {
      oldKey,
      newKey
    })

    if (!wasPending) {
      session.checkpoints = new Map()
      session.checkpointCounter = 0
      log.info('Reset checkpoints for forked session', {
        hiveSessionId: session.hiveSessionId,
        newSessionId: sdkSessionId
      })
    }

    if (this.dbService) {
      try {
        this.dbService.updateSession(session.hiveSessionId, {
          opencode_session_id: sdkSessionId
        })
        log.info('Updated DB opencode_session_id', {
          hiveSessionId: session.hiveSessionId,
          newAgentSessionId: sdkSessionId
        })
      } catch (err) {
        const error = err instanceof Error ? err : new Error(String(err))
        log.error('Failed to update opencode_session_id in DB', error, {
          hiveSessionId: session.hiveSessionId
        })
      }
    }

    emitAgentEvent(this.mainWindow, {
      type: 'session.materialized',
      sessionId: session.hiveSessionId,
      data: { newSessionId: sdkSessionId, wasFork: !wasPending && wasForkRequest }
    })

    return { wasPending, sdkSessionId }
  }

  private async setClaudeGoalBeforePrompt(
    sdk: Awaited<ReturnType<typeof loadClaudeSDK>>,
    session: ClaudeSessionState,
    goalObjective: string,
    options: Options
  ): Promise<boolean> {
    const objective = goalObjective.trim()
    if (!objective) return false

    this.emitClaudeGoalUpdated(session, objective, 'active')

    if (options.forkSession) {
      log.warn('Claude Goal: skipping native /goal during forked prompt; using Xuanpu mirror', {
        hiveSessionId: session.hiveSessionId
      })
      return false
    }

    const commandArgument = formatClaudeGoalCommandArgument(objective)
    if (!commandArgument) return false

    const goalOptionsBase: Options = { ...options }
    delete goalOptionsBase.forkSession
    delete goalOptionsBase.resumeSessionAt
    const goalOptions: Options = {
      ...goalOptionsBase,
      ...(session.materialized ? { resume: session.claudeSessionId } : {})
    }

    let observedNativeGoal = false
    let goalQuery: ClaudeQuery | null = null
    try {
      const queryData = sdk.query({
        prompt: `/goal ${commandArgument}`,
        options: goalOptions
      }) as AsyncIterable<Record<string, unknown>>
      goalQuery = queryData as unknown as ClaudeQuery
      session.query = goalQuery

      for await (const sdkMessage of queryData) {
        if (session.abortController?.signal.aborted) break

        const msgType = asString(sdkMessage.type)
        const msgSubtype = asString(sdkMessage.subtype)
        const sdkSessionId = asString(sdkMessage.session_id)
        this.materializeSessionFromSdkMessage(session, session.worktreePath, sdkSessionId, false)

        if (msgType === 'system' && msgSubtype === 'init') {
          this.cacheClaudeInitSlashCommands(session, session.worktreePath, sdkMessage)
          continue
        }

        observedNativeGoal =
          this.emitClaudeGoalFromSdkMessage(session, sdkMessage, objective) || observedNativeGoal
      }
      observedNativeGoal = (await this.emitClaudeGoalFromTranscript(session)) || observedNativeGoal
    } catch (err) {
      log.warn('Claude Goal: native /goal command failed, using Xuanpu fallback', {
        hiveSessionId: session.hiveSessionId,
        error: err instanceof Error ? err.message : String(err)
      })
      return false
    } finally {
      if (goalQuery) {
        session.lastQuery = goalQuery
      }
      session.query = null
    }

    return observedNativeGoal && session.materialized
  }

  private async emitClaudeGoalFromTranscript(session: ClaudeSessionState): Promise<boolean> {
    if (!session.materialized || session.claudeSessionId.startsWith('pending::')) return false

    const status = await readClaudeGoalStatus(session.worktreePath, session.claudeSessionId)
    if (!status) return false

    const objective = status.condition ?? session.currentGoal?.objective
    if (!objective) return false

    this.emitClaudeGoalUpdated(session, objective, status.met ? 'completed' : 'active')
    log.info('Claude Goal: mirrored transcript goal_status', {
      hiveSessionId: session.hiveSessionId,
      met: status.met,
      sentinel: status.sentinel,
      condition: status.condition ?? null
    })
    return true
  }

  private cacheClaudeInitSlashCommands(
    session: ClaudeSessionState,
    worktreePath: string,
    initMsg: Record<string, unknown>
  ): void {
    const initSlashCommandNames = initMsg.slash_commands as string[] | undefined
    if (initSlashCommandNames && Array.isArray(initSlashCommandNames)) {
      const minimal = initSlashCommandNames
        .filter((name): name is string => typeof name === 'string')
        .map((name) => ({
          name,
          description: '',
          argumentHint: ''
        }))
      this.cachedSlashCommands.set(worktreePath, minimal)
      this.persistCommandsToDb(worktreePath)
      log.info('Prompt: cached slash command names from init', {
        count: minimal.length,
        names: minimal.map((c) => c.name)
      })
    }

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

    emitAgentEvent(this.mainWindow, {
      type: 'session.model_limits',
      sessionId: session.hiveSessionId,
      data: {
        models: CLAUDE_MODELS.map((m) => ({
          modelID: m.id,
          providerID: 'anthropic',
          contextLimit: m.limit.context
        }))
      }
    })
  }

  private emitClaudeGoalFromSdkMessage(
    session: ClaudeSessionState,
    sdkMessage: Record<string, unknown>,
    fallbackObjective?: string
  ): boolean {
    const msgType = asString(sdkMessage.type)
    const msgSubtype = asString(sdkMessage.subtype)
    const kind = [msgType, msgSubtype].filter(Boolean).join(':').toLowerCase()

    if (msgType === 'system' && msgSubtype === 'local_command_output') {
      const content = asString(sdkMessage.content)?.trim() ?? ''
      if (!content) return false

      if (/no goal set/i.test(content)) {
        this.emitClaudeGoalCleared(session)
        return true
      }

      if (/goal achieved/i.test(content)) {
        const objective = session.currentGoal?.objective ?? fallbackObjective
        if (!objective) return false
        this.emitClaudeGoalUpdated(session, objective, 'completed')
        return true
      }

      if (/goal active|goal set/i.test(content)) {
        const objective = fallbackObjective ?? session.currentGoal?.objective
        if (!objective) return false
        this.emitClaudeGoalUpdated(session, objective, 'active')
        return true
      }

      if (/only available|can't run|unknown command/i.test(content)) {
        log.warn('Claude Goal: /goal command output did not confirm native support', {
          hiveSessionId: session.hiveSessionId,
          content
        })
      }
      return false
    }

    const goalRecord =
      asObject(sdkMessage.goal) ??
      asObject(sdkMessage.active_goal) ??
      asObject(sdkMessage.activeGoal)
    const hasGoalSignal =
      kind.includes('goal') ||
      !!goalRecord ||
      typeof sdkMessage.goal === 'string' ||
      typeof sdkMessage.active_goal === 'string' ||
      typeof sdkMessage.activeGoal === 'string'

    if (!hasGoalSignal) return false

    const rawStatus =
      readStringField(goalRecord, ['status', 'state']) ??
      readStringField(sdkMessage, ['status', 'goal_status', 'state'])
    const status = normalizeClaudeGoalStatus(rawStatus, kind, sdkMessage.goal_met)
    if (isClaudeGoalClearedStatus(status) || kind.includes('goal_clear')) {
      this.emitClaudeGoalCleared(session)
      return true
    }

    const rawObjective =
      readStringField(goalRecord, ['objective', 'goal', 'condition', 'text', 'prompt']) ??
      readStringField(sdkMessage, ['objective', 'goal', 'condition', 'active_goal', 'activeGoal'])
    const objective = rawObjective ?? fallbackObjective ?? session.currentGoal?.objective
    if (!objective) return false

    this.emitClaudeGoalUpdated(session, objective, status, goalRecord ?? sdkMessage)
    return true
  }

  private emitClaudeGoalUpdated(
    session: ClaudeSessionState,
    rawObjective: string,
    status: string,
    metadata?: Record<string, unknown>
  ): void {
    const { objective, successCriteria } = splitClaudeGoalObjective(rawObjective)
    if (!objective) return

    const goal: AgentSessionGoalState = {
      objective,
      ...(successCriteria ? { successCriteria } : {}),
      status
    }

    const tokenBudget = asNumberOrNull(metadata?.tokenBudget ?? metadata?.token_budget)
    if (tokenBudget !== undefined) goal.tokenBudget = tokenBudget
    const tokensUsed = asNumberOrNull(metadata?.tokensUsed ?? metadata?.tokens_used)
    if (tokensUsed !== undefined) goal.tokensUsed = tokensUsed
    const timeUsedSeconds = asNumberOrNull(metadata?.timeUsedSeconds ?? metadata?.time_used_seconds)
    if (timeUsedSeconds !== undefined) goal.timeUsedSeconds = timeUsedSeconds
    const createdAt = asNumberOrNull(metadata?.createdAt ?? metadata?.created_at)
    if (createdAt !== undefined) goal.createdAt = createdAt
    const updatedAt = asNumberOrNull(metadata?.updatedAt ?? metadata?.updated_at)
    if (updatedAt !== undefined) goal.updatedAt = updatedAt

    session.currentGoal = goal
    emitAgentEvent(this.mainWindow, {
      type: 'session.goal_updated',
      sessionId: session.hiveSessionId,
      runtimeId: 'claude-code',
      data: {
        goal,
        status: goal.status,
        source: 'claude-code'
      }
    })
  }

  private emitClaudeGoalCleared(session: ClaudeSessionState): void {
    session.currentGoal = null
    emitAgentEvent(this.mainWindow, {
      type: 'session.goal_cleared',
      sessionId: session.hiveSessionId,
      runtimeId: 'claude-code',
      data: {
        goal: null,
        status: 'cleared',
        source: 'claude-code'
      }
    })
  }

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

    if (session.abortController) {
      session.abortController.abort()
    }

    if (session.query) {
      try {
        await session.query.interrupt()
      } catch {
        log.warn('Abort: query.interrupt() threw, ignoring', { worktreePath, agentSessionId })
      }
    }

    // The SDK's `for await (const ... of query)` iterator does not reliably
    // return after `interrupt()` — the next `await` can hang indefinitely
    // waiting for an SSE chunk that never arrives. That means `prompt().finally`
    // may never run, and without explicit finalisation here the UI stays stuck:
    // the Stop button remains red, in-flight tool_use cards keep showing
    // "Running...", and any partial assistant content accumulated from
    // stream_events is lost (the SDK never emits its authoritative `assistant`
    // message for the aborted turn). Drive the UI back to idle ourselves and
    // flush the in-flight draft so the partial turn stays in the transcript.
    try {
      this.flushInFlightDraft(session, true)
      this.markRunningToolsAborted(session)
      this.persistMessagesToDB(session)
    } catch (err) {
      log.warn('Abort: failed to finalize session state', {
        worktreePath,
        agentSessionId,
        error: err instanceof Error ? err.message : String(err)
      })
    }

    session.query = null
    this.emitStatus(session.hiveSessionId, 'idle')
    return true
  }

  /**
   * Walk session.messages and transition every in-flight tool_use to an
   * aborted terminal state, emitting message.part.updated so the renderer's
   * streaming mirror flips the card out of "Running..." immediately.
   */
  private markRunningToolsAborted(session: ClaudeSessionState): void {
    const hiveSessionId = session.hiveSessionId
    const now = Date.now()

    for (const message of session.messages) {
      const record = message as Record<string, unknown>
      if (record.role !== 'assistant') continue
      const parts = record.parts as Array<Record<string, unknown>> | undefined
      if (!Array.isArray(parts)) continue

      for (const part of parts) {
        if (part.type !== 'tool_use') continue
        const toolUse = part.toolUse as Record<string, unknown> | undefined
        if (!toolUse) continue
        const status = toolUse.status as string | undefined
        if (status !== 'running' && status !== 'pending') continue

        toolUse.status = 'error'
        toolUse.error = (toolUse.error as string | undefined) ?? 'Aborted by user'
        toolUse.endTime = (toolUse.endTime as number | undefined) ?? now

        emitAgentEvent(this.mainWindow, {
          type: 'message.part.updated',
          sessionId: hiveSessionId,
          data: {
            part: {
              type: 'tool',
              callID: (toolUse.id as string) ?? '',
              tool: (toolUse.name as string) ?? '',
              state: {
                status: 'error',
                error: toolUse.error,
                time: { end: now }
              }
            }
          }
        })
      }
    }
  }

  /** Persist in-memory messages to the session_messages table so that
   *  getSessionTimeline() can return them. Follows the same pattern as
   *  Codex's persistCanonicalMessages(). */
  private persistMessagesToDB(session: ClaudeSessionState): void {
    if (!this.dbService) return
    if (session.messages.length === 0) 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') 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 || asString(record.content) || '',
            opencode_message_id: asString(record.id) ?? null,
            opencode_message_json: JSON.stringify(message),
            opencode_parts_json: JSON.stringify(parts),
            created_at: timestamp
          }
        ]
      })

      if (rows.length === 0) return

      this.dbService.replaceSessionMessages(session.hiveSessionId, rows)
      log.debug('Persisted Claude Code messages to DB', {
        hiveSessionId: session.hiveSessionId,
        rowCount: rows.length
      })
    } catch (error) {
      log.warn('Failed to persist Claude Code messages', {
        hiveSessionId: session.hiveSessionId,
        error: error instanceof Error ? error.message : String(error)
      })
    }
  }

  private async reconcileSessionMessagesFromTranscript(session: ClaudeSessionState): Promise<void> {
    if (!session.materialized) return
    if (!session.claudeSessionId || session.claudeSessionId.startsWith('pending::')) return
    if (session.messages.length === 0) return

    try {
      const transcriptMessages = await readClaudeTranscript(
        session.worktreePath,
        session.claudeSessionId
      )
      if (!Array.isArray(transcriptMessages) || transcriptMessages.length === 0) return

      const transcriptAssistants = new Map<string, Record<string, unknown>>()
      for (const message of transcriptMessages) {
        const record = asObject(message)
        if (!record || asString(record.role) !== 'assistant') continue

        const id = asString(record.id)
        if (!id) continue
        transcriptAssistants.set(id, record)
      }

      if (transcriptAssistants.size === 0) return

      let updatedCount = 0
      session.messages = session.messages.map((message) => {
        const record = asObject(message)
        if (!record || asString(record.role) !== 'assistant') return message

        const id = asString(record.id)
        if (!id) return message

        const transcriptRecord = transcriptAssistants.get(id)
        if (!transcriptRecord) return message

        const usageChanged =
          JSON.stringify(record.usage ?? null) !== JSON.stringify(transcriptRecord.usage ?? null)
        const modelChanged =
          JSON.stringify(record.model ?? null) !== JSON.stringify(transcriptRecord.model ?? null)
        const costChanged = (record.cost ?? null) !== (transcriptRecord.cost ?? null)
        const contentChanged = (record.content ?? null) !== (transcriptRecord.content ?? null)
        const partsChanged =
          JSON.stringify(record.parts ?? null) !== JSON.stringify(transcriptRecord.parts ?? null)

        if (!usageChanged && !modelChanged && !costChanged && !contentChanged && !partsChanged) {
          return message
        }

        updatedCount += 1
        return {
          ...record,
          ...transcriptRecord
        }
      })

      if (updatedCount > 0) {
        log.info('Reconciled Claude session messages from transcript', {
          hiveSessionId: session.hiveSessionId,
          updatedCount
        })
      }
    } catch (error) {
      log.warn('Failed to reconcile Claude transcript into session messages', {
        hiveSessionId: session.hiveSessionId,
        error: error instanceof Error ? error.message : String(error)
      })
    }
  }

  /** In-flight transcript reads keyed by agentSessionId — deduplicates concurrent calls */
  private pendingTranscriptReads = new Map<string, Promise<unknown[]>>()

  async getMessages(worktreePath: string, agentSessionId: string): Promise<unknown[]> {
    // First: check in-memory cache
    const session = this.getSession(worktreePath, agentSessionId)
    if (session && session.messages.length > 0) {
      log.info('TOOL_LIFECYCLE: getMessages returning in-memory', {
        agentSessionId,
        count: session.messages.length
      })
      // Ensure DB is in sync — callers like getTimeline read from DB,
      // so an in-memory-only state would cause empty timeline on refresh.
      this.persistMessagesToDB(session)
      return session.messages
    }

    // Deduplicate: if a transcript read is already in-flight for this session,
    // wait for the same Promise instead of starting a second file read.
    const pending = this.pendingTranscriptReads.get(agentSessionId)
    if (pending) {
      log.info('getMessages: joining in-flight transcript read', { agentSessionId })
      return pending
    }

    log.info('getMessages: no in-memory messages, falling back to transcript', {
      agentSessionId,
      sessionFound: !!session
    })

    // Fallback: read from JSONL transcript on disk
    const readPromise = readClaudeTranscript(worktreePath, agentSessionId).then((transcript) => {
      this.pendingTranscriptReads.delete(agentSessionId)
      // Warm the in-memory cache so future calls (and prompt() hydration)
      // don't re-read from disk and always have the latest state.
      if (transcript.length > 0) {
        const currentSession = this.getSession(worktreePath, agentSessionId)
        if (currentSession) {
          currentSession.messages = [...transcript]
          this.persistMessagesToDB(currentSession)
          log.info('getMessages: warmed in-memory cache from transcript', {
            agentSessionId,
            count: transcript.length
          })
        }
      }
      return transcript
    })

    this.pendingTranscriptReads.set(agentSessionId, readPromise)
    return readPromise
  }

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

  async getAvailableModels(): Promise<unknown> {
    return [
      {
        id: 'claude-code',
        name: 'Claude Code',
        models: Object.fromEntries(
          CLAUDE_MODELS.map((m) => [
            m.id,
            { id: m.id, name: m.name, limit: m.limit, variants: m.variants }
          ])
        )
      }
    ]
  }

  async getModelInfo(
    _worktreePath: string,
    modelId: string
  ): Promise<{
    id: string
    name: string
    limit: { context: number; input?: number; output: number }
  } | null> {
    const model = CLAUDE_MODELS.find((m) => m.id === modelId)
    if (!model) return null
    return { id: model.id, name: model.name, limit: model.limit }
  }

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

  clearSelectedModel(): void {
    this.selectedModel = undefined
    this.selectedVariant = undefined
    log.info('Selected model cleared')
  }

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

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

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

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

    const session = this.sessions.get(sessionKey)
    if (!session?.pendingQuestion || session.pendingQuestion.requestId !== requestId) {
      throw new Error(`Session pending question mismatch for requestId: ${requestId}`)
    }

    log.info('questionReply: resolving pending question', {
      requestId,
      hiveSessionId: session.hiveSessionId,
      answerCount: answers.length
    })

    // Resolve the blocked canUseTool Promise with the user's answers
    session.pendingQuestion.resolve({ answers })

    // Emit question.replied so the renderer removes the QuestionPrompt
    emitAgentEvent(this.mainWindow, {
      type: 'question.replied',
      sessionId: session.hiveSessionId,
      data: { requestId, id: requestId }
    })
  }

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

    const session = this.sessions.get(sessionKey)
    if (!session?.pendingQuestion || session.pendingQuestion.requestId !== requestId) {
      throw new Error(`Session pending question mismatch for requestId: ${requestId}`)
    }

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

    // Resolve the blocked canUseTool Promise with rejection
    session.pendingQuestion.resolve({ answers: [], rejected: true })

    // Emit question.rejected so the renderer removes the QuestionPrompt
    emitAgentEvent(this.mainWindow, {
      type: 'question.rejected',
      sessionId: session.hiveSessionId,
      data: { requestId, id: requestId }
    })
  }

  async permissionReply(
    requestId: string,
    _decision: 'once' | 'always' | 'reject',
    _worktreePath?: string
  ): Promise<void> {
    // Claude Code handles permissions inline via the canUseTool callback
    // during prompt() streaming. There are no separate pending permission
    // requests to reply to — canUseTool auto-allows all non-question tools.
    log.warn('permissionReply: no-op for Claude Code (permissions handled via canUseTool)', {
      requestId
    })
  }

  async permissionList(_worktreePath?: string): Promise<unknown[]> {
    // Claude Code handles permissions inline via canUseTool during prompt()
    // streaming. There is no separate permission queue to list.
    return []
  }

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

  // ── Plan approval ───────────────────────────────────────────────

  /** Check if a plan requestId belongs to this implementer */
  hasPendingPlan(requestId: string): boolean {
    return this.pendingPlanSessions.has(requestId)
  }

  /** Check if a session (by hiveSessionId) has a pending plan */
  hasPendingPlanForSession(hiveSessionId: string): boolean {
    for (const session of this.sessions.values()) {
      if (session.hiveSessionId === hiveSessionId && session.pendingPlanApproval) {
        return true
      }
    }
    return false
  }

  /** Find a session state by hiveSessionId */
  findSessionByHiveId(hiveSessionId: string): ClaudeSessionState | undefined {
    for (const session of this.sessions.values()) {
      if (session.hiveSessionId === hiveSessionId) {
        return session
      }
    }
    return undefined
  }

  /** Find a session state by pending plan requestId */
  private findSessionByPendingPlanRequestId(requestId: string): ClaudeSessionState | undefined {
    const sessionKey = this.pendingPlanSessions.get(requestId)
    if (!sessionKey) return undefined
    return this.sessions.get(sessionKey)
  }

  private resolveClaudeContextModel(model: string | undefined): string | null {
    if (!model) return null

    return (
      resolveRuntimeModelId(model, 'anthropic') ??
      resolvePricingModelKey(model, 'claude-code') ??
      null
    )
  }

  private async emitClaudeContextUsageSnapshot(session: ClaudeSessionState): Promise<boolean> {
    const query = session.query
    if (!query?.getContextUsage) {
      log.debug('Claude context refresh unavailable on current query', {
        hiveSessionId: session.hiveSessionId
      })
      return false
    }

    try {
      const usage = await query.getContextUsage()
      const modelID = this.resolveClaudeContextModel(usage.model)

      emitAgentEvent(this.mainWindow, {
        type: 'session.context_usage',
        sessionId: session.hiveSessionId,
        data: {
          tokens: {
            input: 0,
            output: 0
          },
          ...(modelID
            ? {
                model: {
                  providerID: 'anthropic',
                  modelID
                }
              }
            : {}),
          contextWindow: usage.maxTokens,
          breakdown: {
            usedTokens: usage.totalTokens,
            maxTokens: usage.maxTokens,
            ...(typeof usage.rawMaxTokens === 'number' ? { rawMaxTokens: usage.rawMaxTokens } : {}),
            percentage: usage.percentage,
            ...(Array.isArray(usage.categories) && usage.categories.length > 0
              ? {
                  categories: usage.categories.map((category) => ({
                    name: category.name,
                    tokens: category.tokens,
                    color: category.color,
                    ...(category.isDeferred === true ? { isDeferred: true } : {})
                  }))
                }
              : {})
          }
        }
      })

      log.info('Emitted live Claude context usage snapshot', {
        hiveSessionId: session.hiveSessionId,
        usedTokens: usage.totalTokens,
        maxTokens: usage.maxTokens,
        percentage: usage.percentage,
        modelID
      })

      return true
    } catch (error) {
      log.warn('Failed to refresh Claude context usage after compaction', {
        hiveSessionId: session.hiveSessionId,
        error: error instanceof Error ? error.message : String(error)
      })
      return false
    }
  }

  /** Approve a pending plan — unblocks the SDK to continue implementation */
  async planApprove(
    _worktreePath: string,
    hiveSessionId: string,
    requestId?: string
  ): Promise<void> {
    // Prefer requestId routing (stable and specific) when available.
    // Fall back to hiveSessionId scan for compatibility.
    let session: ClaudeSessionState | undefined = requestId
      ? this.findSessionByPendingPlanRequestId(requestId)
      : undefined

    if (!session) {
      for (const s of this.sessions.values()) {
        if (s.hiveSessionId === hiveSessionId && s.pendingPlanApproval) {
          session = s
          break
        }
      }
    }

    if (!session || !session.pendingPlanApproval) {
      throw new Error(`planApprove: no pending plan for session: ${hiveSessionId}`)
    }

    log.info('planApprove: approving plan', {
      hiveSessionId,
      requestId: session.pendingPlanApproval.requestId
    })

    const resolvedRequestId = session.pendingPlanApproval.requestId

    // Resolve the blocked canUseTool Promise with approval
    session.pendingPlanApproval.resolve({ approved: true })
    session.pendingPlanApproval = null
    this.pendingPlanSessions.delete(resolvedRequestId)

    // Emit plan.resolved so the renderer clears the plan UI
    emitAgentEvent(this.mainWindow, {
      type: 'plan.resolved',
      sessionId: hiveSessionId,
      data: { approved: true }
    })
  }

  /** Reject a pending plan with user feedback — Claude will revise */
  async planReject(
    _worktreePath: string,
    hiveSessionId: string,
    feedback?: string,
    requestId?: string
  ): Promise<void> {
    // Prefer requestId routing (stable and specific) when available.
    let session: ClaudeSessionState | undefined = requestId
      ? this.findSessionByPendingPlanRequestId(requestId)
      : undefined

    if (!session) {
      for (const s of this.sessions.values()) {
        if (s.hiveSessionId === hiveSessionId && s.pendingPlanApproval) {
          session = s
          break
        }
      }
    }

    if (!session || !session.pendingPlanApproval) {
      throw new Error(`planReject: no pending plan for session: ${hiveSessionId}`)
    }

    log.info('planReject: rejecting plan with feedback', {
      hiveSessionId,
      requestId: session.pendingPlanApproval.requestId,
      hasFeedback: !!feedback
    })

    const resolvedRequestId = session.pendingPlanApproval.requestId

    // Resolve the blocked canUseTool Promise with rejection + feedback
    session.pendingPlanApproval.resolve({ approved: false, feedback })
    session.pendingPlanApproval = null
    this.pendingPlanSessions.delete(resolvedRequestId)

    // Emit plan.resolved so the renderer clears the plan UI
    emitAgentEvent(this.mainWindow, {
      type: 'plan.resolved',
      sessionId: hiveSessionId,
      data: { approved: false, feedback }
    })
  }

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

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

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

    // Find the current revert boundary's checkpoint index (if any).
    // Use revertCheckpointUuid (SDK UUID) directly against checkpoints map
    // to avoid roundabout hive-side ID lookups that can fail when IDs diverge.
    const currentBoundaryCheckpointIdx = session.revertCheckpointUuid
      ? (session.checkpoints.get(session.revertCheckpointUuid) ?? null)
      : null

    // Walk checkpoints in reverse order (by checkpoint counter, descending)
    // to find the last user message UUID BEFORE the current revert boundary.
    // After finding the undo target, also locate the checkpoint immediately
    // before it — used as context for the fork boundary on the next prompt().
    const sortedCheckpoints = [...session.checkpoints.entries()].sort(
      ([, idxA], [, idxB]) => idxB - idxA
    )

    let targetUuid: string | null = null
    let targetCheckpointIdx: number | null = null
    let targetMessage: Record<string, unknown> | undefined
    let previousCheckpointUuid: string | null = null
    let foundTarget = false
    for (const [uuid, msgIndex] of sortedCheckpoints) {
      // Skip checkpoints at or after the current boundary
      if (currentBoundaryCheckpointIdx !== null && msgIndex >= currentBoundaryCheckpointIdx) {
        continue
      }

      // Ignore UUIDs that don't map to a real rendered user message.
      // This filters out tool_result-only user messages that can carry UUIDs
      // but are not reliable file rewind checkpoints.
      const candidateMessage = this.findMessageByUuid(session, uuid)
      if (!candidateMessage || candidateMessage.role !== 'user') {
        continue
      }

      if (!foundTarget) {
        targetUuid = uuid
        targetCheckpointIdx = msgIndex
        targetMessage = candidateMessage
        foundTarget = true
      } else {
        // This is the checkpoint BEFORE the target — the one we keep.
        previousCheckpointUuid = uuid
        break
      }
    }

    if (!targetUuid || targetCheckpointIdx === null || !targetMessage) {
      throw new Error('Nothing to undo')
    }

    let rawRewindResult: void | RewindFilesResult | undefined
    let usedConversationOnlyFallback = false

    try {
      // During an active stream, rewind on the live query transport.
      if (session.query) {
        if (!session.query.rewindFiles) {
          throw new Error('Cannot undo: SDK query does not support rewindFiles')
        }
        rawRewindResult = await session.query.rewindFiles(targetUuid)
      } else {
        // After stream completion, the previous transport is closed.
        // Resume with an empty prompt and rewind on the new query.
        rawRewindResult = await this.rewindWithResumedQuery(session, targetUuid)
      }
    } catch (error) {
      if (!this.isNoFileCheckpointFoundError(error)) {
        throw error
      }

      // Fallback path: conversation-only rewind boundary.
      // We cannot restore files for this UUID, but we can still continue the
      // session from this checkpoint on the next prompt.
      usedConversationOnlyFallback = true
      log.info('Undo: falling back to conversation-only checkpoint boundary', {
        worktreePath,
        agentSessionId,
        targetUuid,
        previousCheckpointUuid
      })
    }

    const rewindResult = usedConversationOnlyFallback
      ? null
      : this.normalizeRewindResult(rawRewindResult)
    if (rewindResult?.canRewind === false) {
      throw new Error(rewindResult.error ?? 'Cannot rewind to this point')
    }

    // Build a diff summary string
    const diffParts: string[] = []
    if (rewindResult?.filesChanged && rewindResult.filesChanged.length > 0) {
      diffParts.push(`${rewindResult.filesChanged.length} file(s) changed`)
    }
    if (rewindResult?.insertions) {
      diffParts.push(`+${rewindResult.insertions}`)
    }
    if (rewindResult?.deletions) {
      diffParts.push(`-${rewindResult.deletions}`)
    }
    const revertDiff = diffParts.length > 0 ? diffParts.join(', ') : null

    const restoredPrompt = this.extractPromptFromMessage(targetMessage)

    // Use the hive-side message ID, or fall back to the SDK UUID
    const revertMessageID = (targetMessage?.id as string) ?? targetUuid

    // Update session revert state
    session.revertMessageID = revertMessageID
    session.revertCheckpointUuid = targetUuid
    session.revertDiff = revertDiff

    // Don't splice in-memory messages or delete checkpoints during undo.
    // The SDK's JSONL is append-only with parentUuid branching; the fork
    // on next prompt() creates a new branch and the messages array will be
    // cleared at that point.  Leaving messages intact lets getMessages()
    // continue to work (the renderer's visibleMessages filter handles
    // hiding the reverted tail via revertMessageID).

    // Signal the next prompt() to use forkSession: true + resumeSessionAt.
    //
    // forkSession tells the SDK to create a new conversation branch.
    // resumeSessionAt tells the SDK to load history only up to a specific
    // assistant message, so the fork's context excludes both the undone
    // messages AND the throwaway entries from rewindWithResumedQuery().
    //
    // We need the assistant message UUID immediately preceding the target
    // user message — that's the last "good" assistant response.
    //
    // When undoing the very first prompt (no previous checkpoint exists),
    // we de-materialize the session so the next prompt() starts a completely
    // fresh SDK conversation instead of resuming the old one.  No fork is
    // needed in that case since there's nothing to fork from.
    if (previousCheckpointUuid) {
      session.pendingFork = true

      // Find the assistant UUID preceding the undo target in session.messages.
      // Walk backward from the target to find the nearest assistant message.
      const targetMsgIndex = session.messages.findIndex(
        (m) => (m as Record<string, unknown>).id === targetUuid
      )
      let precedingAssistantUuid: string | null = null
      if (targetMsgIndex > 0) {
        for (let i = targetMsgIndex - 1; i >= 0; i--) {
          const msg = session.messages[i] as Record<string, unknown>
          if (
            msg.role === 'assistant' &&
            typeof msg.id === 'string' &&
            !msg.id.startsWith('user-')
          ) {
            precedingAssistantUuid = msg.id
            break
          }
        }
      }
      session.pendingResumeSessionAt = precedingAssistantUuid
      log.info('Undo: set pendingResumeSessionAt', {
        precedingAssistantUuid,
        targetUuid,
        targetMsgIndex
      })
    } else {
      // Undoing the very first prompt — nothing before it to resume to.
      // Clear materialisation so the next prompt() starts a fresh session.
      session.pendingFork = false
      session.pendingResumeSessionAt = null
      session.materialized = false
      log.info('Undo: de-materialized session (undoing first prompt)', {
        oldClaudeSessionId: session.claudeSessionId
      })
    }

    log.info('Undo: rewindFiles succeeded', {
      worktreePath,
      agentSessionId,
      targetUuid,
      targetCheckpointIdx,
      revertMessageID,
      pendingFork: session.pendingFork,
      previousCheckpointUuid,
      messagesRemaining: session.messages.length,
      checkpointsRemaining: session.checkpoints.size,
      filesChanged: rewindResult?.filesChanged?.length ?? 0
    })

    return { revertMessageID, restoredPrompt, revertDiff }
  }

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

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

  private toHiveCommandFormat(cmd: { name: string; description: string; argumentHint: string }) {
    return {
      name: cmd.name,
      description: cmd.description || undefined,
      template: `/${cmd.name}${cmd.argumentHint ? ' ' + cmd.argumentHint : ''}`
    }
  }

  async listCommands(worktreePath: string): Promise<unknown[]> {
    // 1. Check in-memory cache (populated after SDK init in current session)
    const cached = this.cachedSlashCommands.get(worktreePath)
    if (cached?.length) {
      const commands = cached.map((cmd) => this.toHiveCommandFormat(cmd))
      // Sync DB so removed commands are pruned from the persisted cache
      this.persistCommandsToDb(worktreePath, commands)
      return commands
    }

    // 2. Fallback: load from DB (persisted from a previous session)
    if (this.dbService) {
      try {
        const dbKey = `slash_commands:${worktreePath}`
        const json = this.dbService.getSetting(dbKey)
        if (json) {
          const commands = JSON.parse(json) as unknown[]
          if (Array.isArray(commands) && commands.length > 0) {
            log.info('listCommands: loaded from DB cache', {
              worktreePath,
              count: commands.length
            })
            return commands
          }
        }
      } catch (err) {
        log.warn('listCommands: DB cache read failed', {
          error: err instanceof Error ? err.message : String(err)
        })
      }
    }

    log.info('listCommands: no cached commands', { worktreePath })
    return []
  }

  private persistCommandsToDb(worktreePath: string, commands?: unknown[]): void {
    if (!this.dbService) return

    // If no pre-mapped commands provided, map from the in-memory cache
    if (!commands) {
      const cached = this.cachedSlashCommands.get(worktreePath)
      if (!cached?.length) return
      commands = cached.map((cmd) => this.toHiveCommandFormat(cmd))
    }

    try {
      const dbKey = `slash_commands:${worktreePath}`
      this.dbService.setSetting(dbKey, JSON.stringify(commands))
      log.info('persistCommandsToDb: saved to DB', {
        worktreePath,
        count: commands.length
      })
    } catch (err) {
      log.warn('persistCommandsToDb: failed', {
        error: err instanceof Error ? err.message : String(err)
      })
    }
  }

  async sendCommand(
    worktreePath: string,
    agentSessionId: string,
    command: string,
    args?: string
  ): Promise<void> {
    // Translate slash command into a prompt message. The Claude CLI
    // subprocess parses slash command prefixes from prompt text.
    const prompt = args ? `/${command} ${args}` : `/${command}`
    log.info('sendCommand: dispatching as prompt', {
      worktreePath,
      agentSessionId,
      command,
      hasArgs: !!args
    })
    await this.prompt(worktreePath, agentSessionId, prompt)
  }

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

  async renameSession(_worktreePath: string, agentSessionId: string, name: string): Promise<void> {
    // The Claude SDK has no session rename API. Session titles are stored
    // in Hive's local DB only. Find the session and update via dbService.
    if (!this.dbService) {
      log.warn('renameSession: no dbService available', { agentSessionId })
      return
    }

    // Find the hive session ID from our session map
    let hiveSessionId: string | null = null
    for (const session of this.sessions.values()) {
      if (session.claudeSessionId === agentSessionId) {
        hiveSessionId = session.hiveSessionId
        break
      }
    }

    if (!hiveSessionId) {
      log.warn('renameSession: session not found in active map', { agentSessionId })
      return
    }

    try {
      this.dbService.updateSession(hiveSessionId, { name })
      log.info('renameSession: updated title in DB', { hiveSessionId, name })
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err))
      log.error('renameSession: failed to update title', error, { hiveSessionId })
    }
  }

  // ── Title generation ────────────────────────────────────────────

  /**
   * Asynchronously generate a session title via Agent SDK with Haiku.
   * On success: updates DB, notifies renderer, and auto-renames branch if applicable.
   * Fire-and-forget — errors are logged and swallowed.
   */
  private async handleTitleGeneration(
    session: ClaudeSessionState,
    userMessage: string
  ): Promise<void> {
    try {
      const title = await generateSessionTitle(userMessage, this.claudeBinaryPath)
      if (!title) return

      // 1. Update session name in DB
      if (this.dbService) {
        this.dbService.updateSession(session.hiveSessionId, { name: title })
        log.info('handleTitleGeneration: updated DB', {
          hiveSessionId: session.hiveSessionId,
          title
        })
      }

      // 2. Notify renderer with session.updated event (same format as OpenCode)
      // The renderer's SessionView.tsx and useOpenCodeGlobalListener.ts both
      // read: event.data?.info?.title || event.data?.title
      emitAgentEvent(this.mainWindow, {
        type: 'session.updated',
        sessionId: session.hiveSessionId,
        data: {
          title,
          info: { title }
        }
      })

      // 3. Auto-rename branch for the session's direct worktree
      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: title,
            db: this.dbService
          })
          if (result.renamed) {
            this.sendToRenderer('worktree:branchRenamed', {
              worktreeId: worktree.id,
              newBranch: result.newBranch
            })
            log.info('handleTitleGeneration: auto-renamed branch', {
              oldBranch: worktree.branch_name,
              newBranch: result.newBranch
            })
          } else if (result.error) {
            log.warn('handleTitleGeneration: rename failed', { error: result.error })
          }
        } catch (err) {
          if (this.dbService) {
            this.dbService.updateWorktree(worktree.id, { branch_renamed: 1 })
          }
          log.warn('handleTitleGeneration: branch rename error', { err })
        }
      }

      // 4. Auto-rename branches for all connection member worktrees
      if (this.dbService) {
        const dbSession = this.dbService.getSession(session.hiveSessionId)
        if (dbSession?.connection_id) {
          const connection = this.dbService.getConnection(dbSession.connection_id)
          if (connection) {
            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: title,
                  db: this.dbService
                })
                if (result.renamed) {
                  this.sendToRenderer('worktree:branchRenamed', {
                    worktreeId: memberWorktree.id,
                    newBranch: result.newBranch
                  })
                  log.info('handleTitleGeneration: auto-renamed connection member', {
                    connectionId: dbSession.connection_id,
                    worktreeId: memberWorktree.id,
                    oldBranch: memberWorktree.branch_name,
                    newBranch: result.newBranch
                  })
                } else if (result.error) {
                  log.warn('handleTitleGeneration: connection member rename failed', {
                    connectionId: dbSession.connection_id,
                    worktreeId: memberWorktree.id,
                    error: result.error
                  })
                }
              } catch (err) {
                log.warn('handleTitleGeneration: connection member rename error', {
                  worktreeId: member.worktree_id,
                  err
                })
              }
            }
          }
        }
      }
    } catch (err) {
      log.warn('handleTitleGeneration: unexpected error', {
        error: err instanceof Error ? err.message : String(err)
      })
    }
  }

  // ── Internal helpers ─────────────────────────────────────────────

  /**
   * Handles ExitPlanMode tool invocation from the SDK.
   * Blocks execution with a Promise that waits for user approval/rejection
   * via the plan approval IPC handlers. If approved, allows the tool to proceed
   * (SDK exits plan mode and continues with implementation). If rejected,
   * denies the tool with feedback so Claude revises the plan.
   */
  private async handleExitPlanMode(
    session: ClaudeSessionState,
    input: Record<string, unknown>,
    options: { signal: AbortSignal; toolUseID: string; [key: string]: unknown }
  ): Promise<
    | { behavior: 'allow'; updatedInput?: Record<string, unknown> }
    | { behavior: 'deny'; message: string }
  > {
    const requestId = `plan-${Date.now()}-${randomUUID().slice(0, 8)}`
    const toolUseID = options.toolUseID

    log.info('canUseTool: ExitPlanMode intercepted', {
      hiveSessionId: session.hiveSessionId,
      requestId,
      toolUseID,
      inputKeys: Object.keys(input).join(',')
    })

    // Track this pending plan for IPC routing
    const sessionKey = this.getSessionKey(session.worktreePath, session.claudeSessionId)
    this.pendingPlanSessions.set(requestId, sessionKey)

    // Resolve plan content: prefer input.plan from SDK, else read the plan file from disk.
    // ExitPlanMode doesn't include plan content as a parameter — the SDK writes it to a
    // .plan / .claude/plans/*.md file, so we must read the latest version from disk.
    let planContent =
      typeof input.plan === 'string'
        ? input.plan
        : input.plan !== undefined
          ? JSON.stringify(input.plan, null, 2)
          : ''

    if (!planContent) {
      planContent = await this.readPlanFileContent(session.worktreePath)
    }

    // Block execution with a Promise that waits for user response
    const userResponse = await new Promise<{ approved: boolean; feedback?: string }>((resolve) => {
      session.pendingPlanApproval = { requestId, resolve }

      // Emit plan.ready event to renderer with plan content read from disk
      // The renderer reads data.id, data.plan, data.toolUseID
      emitAgentEvent(this.mainWindow, {
        type: 'plan.ready',
        sessionId: session.hiveSessionId,
        data: {
          id: requestId,
          plan: planContent,
          toolUseID
        }
      })

      log.info('canUseTool: emitted plan.ready, waiting for approval', {
        requestId,
        hiveSessionId: session.hiveSessionId,
        hasPlanContent: planContent.length > 0
      })

      // If the session is aborted while waiting, auto-reject and notify renderer
      const onAbort = (): void => {
        if (session.pendingPlanApproval?.requestId === requestId) {
          log.info('canUseTool: session aborted while plan pending, auto-rejecting', { requestId })
          session.pendingPlanApproval = null
          this.pendingPlanSessions.delete(requestId)
          // Notify renderer to clear stale pending plan UI
          emitAgentEvent(this.mainWindow, {
            type: 'plan.resolved',
            sessionId: session.hiveSessionId,
            data: { approved: false, aborted: true }
          })
          resolve({ approved: false })
        }
      }
      options.signal.addEventListener('abort', onAbort, { once: true })
    })

    // Clean up tracking state
    session.pendingPlanApproval = null
    this.pendingPlanSessions.delete(requestId)

    if (userResponse.approved) {
      log.info('canUseTool: ExitPlanMode approved by user', { requestId })
      return { behavior: 'allow' as const, updatedInput: input }
    }

    log.info('canUseTool: ExitPlanMode rejected by user', {
      requestId,
      hasFeedback: !!userResponse.feedback
    })
    return {
      behavior: 'deny' as const,
      message: userResponse.feedback || 'The user rejected the plan. Please revise.'
    }
  }

  /**
   * Reads the most recently modified plan file from the project's .claude/plans/ directory
   * or a .plan file in the project root. Returns empty string if no plan file is found.
   */
  private async readPlanFileContent(worktreePath: string): Promise<string> {
    const { readFile, readdir, stat } = await import('node:fs/promises')
    const candidates: { path: string; mtime: number }[] = []

    // 1. Check .claude/plans/*.md (Claude convention)
    const plansDir = join(worktreePath, '.claude', 'plans')
    try {
      const entries = await readdir(plansDir)
      for (const file of entries.filter((f) => f.endsWith('.md'))) {
        const filePath = join(plansDir, file)
        try {
          const s = await stat(filePath)
          if (s.isFile()) candidates.push({ path: filePath, mtime: s.mtimeMs })
        } catch {
          // skip unreadable files
        }
      }
    } catch {
      // .claude/plans/ doesn't exist — not an error
    }

    // 2. Check .plan in project root
    const rootPlan = join(worktreePath, '.plan')
    try {
      const s = await stat(rootPlan)
      if (s.isFile()) candidates.push({ path: rootPlan, mtime: s.mtimeMs })
    } catch {
      // no .plan file — not an error
    }

    if (candidates.length === 0) return ''

    // Pick the most recently modified file
    candidates.sort((a, b) => b.mtime - a.mtime)
    const chosen = candidates[0]

    try {
      const content = await readFile(chosen.path, 'utf-8')
      log.info('readPlanFileContent: read plan from disk', {
        path: chosen.path,
        length: content.length,
        candidateCount: candidates.length
      })
      return content
    } catch (err) {
      log.warn('readPlanFileContent: failed to read plan file', {
        path: chosen.path,
        error: err instanceof Error ? err.message : String(err)
      })
      return ''
    }
  }

  /**
   * Creates a canUseTool callback for the Claude Agent SDK.
   * Intercepts AskUserQuestion and ExitPlanMode to block execution and wait
   * for user input, translating between the SDK format and Hive's event format.
   */
  private createCanUseToolCallback(
    session: ClaudeSessionState
  ): (
    toolName: string,
    input: Record<string, unknown>,
    options: { signal: AbortSignal; toolUseID: string; [key: string]: unknown }
  ) => Promise<
    | { behavior: 'allow'; updatedInput?: Record<string, unknown> }
    | { behavior: 'deny'; message: string }
  > {
    return async (toolName, input, options) => {
      // Handle ExitPlanMode — blocks until user approves or rejects the plan
      if (toolName === 'ExitPlanMode') {
        return this.handleExitPlanMode(session, input, options)
      }

      // Handle AskUserQuestion (intercept and block for user response)
      if (toolName === 'AskUserQuestion') {
        // Continue to existing AskUserQuestion handling below...
      } else {
        // For all other tools, evaluate against command filter
        const commandStr = this.commandFilterService.formatCommandString(toolName, input)
        log.info('SECURITY CHECK: Evaluating tool use', {
          toolName,
          commandStr,
          sessionId: session.hiveSessionId
        })

        const settings = await this.getCommandFilterSettings()
        const action = this.commandFilterService.evaluateToolUse(toolName, input, settings)

        log.info('SECURITY CHECK: Evaluation result', {
          toolName,
          commandStr,
          action,
          enabled: settings.enabled,
          defaultBehavior: settings.defaultBehavior,
          sessionId: session.hiveSessionId
        })

        if (action === 'allow') {
          log.info('SECURITY CHECK: Tool allowed', { toolName, commandStr })
          return { behavior: 'allow' as const, updatedInput: input }
        }

        if (action === 'block') {
          log.info('SECURITY CHECK: Tool blocked by command filter', {
            toolName,
            commandStr,
            sessionId: session.hiveSessionId
          })
          return {
            behavior: 'deny' as const,
            message: `Command blocked by security policy: ${commandStr}`
          }
        }

        // action === 'ask' - show approval prompt
        log.info('SECURITY CHECK: Requesting user approval', {
          toolName,
          commandStr,
          sessionId: session.hiveSessionId
        })
        return this.handleCommandApproval(session, toolName, input, options)
      }

      // Continue with AskUserQuestion handling (existing code below)...

      const requestId = `askuser-${Date.now()}-${randomUUID().slice(0, 8)}`
      const toolUseID = options.toolUseID

      log.info('canUseTool: AskUserQuestion intercepted', {
        hiveSessionId: session.hiveSessionId,
        requestId,
        toolUseID,
        questionCount: (input.questions as unknown[])?.length ?? 0
      })

      // Translate SDK AskUserQuestionInput to Hive QuestionRequest format
      const sdkQuestions = input.questions as Array<{
        question: string
        header: string
        options: Array<{ label: string; description: string }>
        multiSelect?: boolean
      }>

      const questionRequest = {
        id: requestId,
        sessionID: session.hiveSessionId,
        questions: sdkQuestions.map((q) => ({
          question: q.question,
          header: q.header,
          options: q.options,
          multiple: q.multiSelect ?? false,
          custom: true // SDK says "Other" option is auto-provided by client
        })),
        tool: { messageID: `msg-${Date.now()}`, callID: toolUseID }
      }

      // Track this pending question for IPC routing
      const sessionKey = this.getSessionKey(session.worktreePath, session.claudeSessionId)
      this.pendingQuestionSessions.set(requestId, sessionKey)

      // Block execution with a Promise that waits for user response
      const userResponse = await new Promise<{ answers: string[][]; rejected?: boolean }>(
        (resolve) => {
          session.pendingQuestion = {
            requestId,
            questions: sdkQuestions.map((q) => ({ question: q.question, header: q.header })),
            resolve
          }

          // Emit question.asked event to renderer (matches OpenCode event format)
          emitAgentEvent(this.mainWindow, {
            type: 'question.asked',
            sessionId: session.hiveSessionId,
            data: questionRequest
          })
          this.maybeNotifyPendingUserFeedback(session.hiveSessionId, 'question')

          log.info('canUseTool: emitted question.asked, waiting for response', {
            requestId,
            hiveSessionId: session.hiveSessionId
          })

          // If the session is aborted while waiting, auto-reject
          const onAbort = (): void => {
            if (session.pendingQuestion?.requestId === requestId) {
              log.info('canUseTool: session aborted while question pending, auto-rejecting', {
                requestId
              })
              session.pendingQuestion = null
              this.pendingQuestionSessions.delete(requestId)
              resolve({ answers: [], rejected: true })
            }
          }
          options.signal.addEventListener('abort', onAbort, { once: true })
        }
      )

      // Clean up tracking state
      session.pendingQuestion = null
      this.pendingQuestionSessions.delete(requestId)

      if (userResponse.rejected) {
        log.info('canUseTool: AskUserQuestion rejected by user', { requestId })
        return {
          behavior: 'deny' as const,
          message: 'The user dismissed the question without answering.'
        }
      }

      // Translate Hive string[][] answers back to SDK Record<string, string> format
      // The SDK expects { answers: { "question text": "selected label(s)" } }
      const sdkAnswers: Record<string, string> = {}
      sdkQuestions.forEach((q, i) => {
        const selected = userResponse.answers[i] || []
        sdkAnswers[q.question] = selected.join(', ')
      })

      log.info('canUseTool: AskUserQuestion answered', {
        requestId,
        answerCount: Object.keys(sdkAnswers).length
      })

      return {
        behavior: 'allow' as const,
        updatedInput: { ...input, answers: sdkAnswers }
      }
    }
  }

  /**
   * Generate a safer pattern suggestion for allowlisting
   * Avoids overly broad patterns like "git *" which would allow all git commands
   */
  private generateSaferPatternSuggestion(commandStr: string): string {
    const parts = commandStr.split(' ')
    const command = parts[0]
    const subcommand = parts[1]
    const flag = parts[2]

    // For certain safe commands, we can suggest broader patterns
    const safeForBroadPattern = ['kubectl', 'npm', 'pnpm', 'yarn', 'ls', 'cat', 'echo', 'pwd']
    if (safeForBroadPattern.includes(command)) {
      return `${command} *`
    }

    // Git commands need special handling - be very specific
    if (command === 'git') {
      if (!subcommand) {
        return commandStr // Just "git" alone - use exact
      }

      // Safe git subcommands that can have broad patterns
      const safeGitSubcommands = ['status', 'log', 'diff', 'show', 'branch', 'remote', 'fetch']
      if (safeGitSubcommands.includes(subcommand)) {
        return `git ${subcommand} *`
      }

      // For git commit, be very specific about the flags
      if (subcommand === 'commit') {
        if (flag === '-m' || flag === '--message') {
          return `git commit -m *` // Only allow commit with message flag
        }
        // For other commit variations (--amend, --no-verify, etc), use exact command
        // This prevents dangerous operations
        return commandStr
      }

      // For git push/pull, include the operation but not force flags
      if (subcommand === 'push' || subcommand === 'pull') {
        if (flag === '--force' || flag === '-f') {
          return commandStr // Don't suggest pattern for force push
        }
        return `git ${subcommand} *`
      }

      // For git checkout/reset, be very careful
      if (subcommand === 'checkout' || subcommand === 'reset') {
        return commandStr // Always use exact for these dangerous operations
      }

      // For other git subcommands, use subcommand + first flag if present
      if (flag) {
        return `git ${subcommand} ${flag} *`
      }
      return `git ${subcommand} *`
    }

    // For other potentially dangerous commands, be specific
    const dangerousCommands = ['rm', 'mv', 'cp', 'chmod', 'chown', 'sudo', 'docker']
    if (dangerousCommands.includes(command)) {
      // For rm, never suggest patterns with -rf
      if (command === 'rm' && (parts.includes('-rf') || parts.includes('-fr'))) {
        return commandStr // Use exact command for dangerous rm operations
      }

      if (subcommand) {
        return `${command} ${subcommand} *`
      }
      return commandStr // Use exact if no subcommand
    }

    // For other commands, suggest the exact command
    // This is the safest default
    return commandStr
  }

  /**
   * Handle command approval flow for tool uses requiring user permission
   * Blocks execution until user approves or denies, optionally adding to allowlist/blocklist
   */
  private async handleCommandApproval(
    session: ClaudeSessionState,
    toolName: string,
    input: Record<string, unknown>,
    options: { signal: AbortSignal; toolUseID: string }
  ): Promise<
    | { behavior: 'allow'; updatedInput?: Record<string, unknown> }
    | { behavior: 'deny'; message: string }
  > {
    const requestId = `approval-${Date.now()}-${randomUUID().slice(0, 8)}`
    const commandStr = this.commandFilterService.formatCommandString(toolName, input)

    log.info('APPROVAL FLOW: Starting approval request', {
      requestId,
      toolName,
      commandStr,
      hiveSessionId: session.hiveSessionId
    })

    const patternSuggestions = this.commandFilterService.generatePatternSuggestions(toolName, input)
    const subCommandPatterns = this.commandFilterService.generateSubCommandSuggestions(
      toolName,
      input
    )

    const approvalRequest = {
      id: requestId,
      sessionID: session.hiveSessionId,
      toolName,
      commandStr,
      input,
      patternSuggestions,
      subCommandPatterns: subCommandPatterns ?? undefined,
      tool: { messageID: `msg-${Date.now()}`, callID: options.toolUseID }
    }

    // Emit command.approval_needed event to renderer
    log.info('APPROVAL FLOW: Sending event to renderer', {
      requestId,
      sessionId: session.hiveSessionId
    })
    emitAgentEvent(this.mainWindow, {
      type: 'command.approval_needed',
      sessionId: session.hiveSessionId,
      data: approvalRequest
    })
    this.maybeNotifyPendingUserFeedback(session.hiveSessionId, 'approval')

    // Log timestamp for debugging approval dialog issues
    log.info('APPROVAL FLOW: Waiting for user response', {
      requestId,
      commandStr,
      waitStartTime: new Date().toISOString()
    })

    // Block execution with a Promise that waits for user response
    // 60-second timeout: if approval dialog doesn't appear or user doesn't respond, auto-deny
    const APPROVAL_TIMEOUT_MS = 60_000
    const userResponse = await new Promise<{
      approved: boolean
      remember?: 'allow' | 'block'
      pattern?: string
      patterns?: string[]
      timeout?: boolean
    }>((resolve) => {
      // Timeout handler — auto-deny after 60 seconds
      const timeoutHandle = setTimeout(() => {
        const pending = this.pendingApprovals.get(requestId)
        if (pending) {
          log.warn('APPROVAL FLOW: Timed out waiting for user response', {
            requestId,
            commandStr,
            timeoutMs: APPROVAL_TIMEOUT_MS
          })

          // Notify renderer about the problem so user sees actionable feedback
          emitAgentEvent(this.mainWindow, {
            type: 'command.approval_problem',
            sessionId: session.hiveSessionId,
            data: {
              requestId,
              commandStr,
              reason: 'timeout',
              suggestion:
                'Approval dialog timed out. Add a matching pattern in Settings > Security, or disable command filtering temporarily.'
            }
          })

          // Clear approval UI
          emitAgentEvent(this.mainWindow, {
            type: 'command.approval_replied',
            sessionId: session.hiveSessionId,
            data: { requestId, id: requestId, approved: false }
          })

          this.pendingApprovals.delete(requestId)
          resolve({ approved: false, timeout: true })
        }
      }, APPROVAL_TIMEOUT_MS)

      this.pendingApprovals.set(requestId, {
        resolve: (response) => {
          clearTimeout(timeoutHandle)
          log.info('APPROVAL FLOW: User response received', {
            requestId,
            approved: response.approved
          })
          resolve(response)
        },
        toolName,
        input,
        commandStr,
        hiveSessionId: session.hiveSessionId
      })

      // If the session is aborted while waiting, auto-deny
      const onAbort = (): void => {
        clearTimeout(timeoutHandle)
        const pending = this.pendingApprovals.get(requestId)
        if (pending) {
          log.info('APPROVAL FLOW: Session aborted while approval pending, auto-denying', {
            requestId
          })

          // Send command.approval_replied event to clear the UI state
          emitAgentEvent(this.mainWindow, {
            type: 'command.approval_replied',
            sessionId: pending.hiveSessionId,
            data: { requestId, id: requestId, approved: false }
          })

          this.pendingApprovals.delete(requestId)
          resolve({ approved: false })
        }
      }
      options.signal.addEventListener('abort', onAbort, { once: true })
    })

    // Clean up tracking state
    this.pendingApprovals.delete(requestId)

    // Handle "remember" choice - update settings with user-selected pattern(s)
    if (userResponse.remember) {
      if (userResponse.patterns && userResponse.patterns.length > 0) {
        // Multi-pattern (per sub-command "Allow always")
        for (const p of userResponse.patterns) {
          await this.updateCommandFilter(p, userResponse.remember)
        }
      } else {
        // Single pattern or fallback to full command string
        const patternToSave = userResponse.pattern || commandStr
        await this.updateCommandFilter(patternToSave, userResponse.remember)
      }
    }

    if (!userResponse.approved) {
      log.info('APPROVAL FLOW: User denied command', { requestId, commandStr })
      return {
        behavior: 'deny' as const,
        message: `Command rejected by user: ${commandStr}`
      }
    }

    log.info('APPROVAL FLOW: User approved command', { requestId, commandStr })
    return { behavior: 'allow' as const, updatedInput: input }
  }

  /**
   * Load command filter settings from database
   */
  private async getCommandFilterSettings(): Promise<CommandFilterSettings> {
    if (!this.dbService) {
      log.warn('getCommandFilterSettings: no database service available, using defaults')
      return {
        allowlist: [],
        blocklist: [],
        defaultBehavior: 'ask',
        enabled: true
      }
    }

    try {
      const settingsJson = this.dbService.getSetting(APP_SETTINGS_DB_KEY)
      if (!settingsJson) {
        log.warn('getCommandFilterSettings: no app_settings found in DB, using defaults')
        return {
          allowlist: [],
          blocklist: [],
          defaultBehavior: 'ask',
          enabled: true
        }
      }

      const settings = JSON.parse(settingsJson)
      // Deep-merge so new fields (e.g. `enabled`) always have defaults even for
      // users whose saved settings pre-date those fields being added.
      const filterSettings: CommandFilterSettings = {
        allowlist: [],
        blocklist: [],
        defaultBehavior: 'ask',
        enabled: false,
        ...(settings.commandFilter || {})
      }

      log.info('getCommandFilterSettings: loaded from DB', {
        enabled: filterSettings.enabled,
        allowlistCount: filterSettings.allowlist?.length,
        blocklistCount: filterSettings.blocklist?.length,
        allowlist: filterSettings.allowlist,
        blocklist: filterSettings.blocklist,
        defaultBehavior: filterSettings.defaultBehavior
      })

      return filterSettings
    } catch (error) {
      log.error('getCommandFilterSettings: failed to load settings', { error })
      return {
        allowlist: [],
        blocklist: [],
        defaultBehavior: 'ask',
        enabled: true
      }
    }
  }

  /**
   * Update command filter settings by adding a pattern to allowlist or blocklist
   */
  private async updateCommandFilter(pattern: string, action: 'allow' | 'block'): Promise<void> {
    if (!this.dbService) {
      log.error('updateCommandFilter: no database service available')
      return
    }

    try {
      const settingsJson = this.dbService.getSetting(APP_SETTINGS_DB_KEY) || '{}'
      const settings = JSON.parse(settingsJson)

      if (!settings.commandFilter) {
        settings.commandFilter = {
          allowlist: [],
          blocklist: [],
          defaultBehavior: 'ask',
          enabled: true
        }
      }

      const list =
        action === 'allow' ? settings.commandFilter.allowlist : settings.commandFilter.blocklist

      if (!list.includes(pattern)) {
        list.push(pattern)
        this.dbService.setSetting(APP_SETTINGS_DB_KEY, JSON.stringify(settings))

        log.info('updateCommandFilter: added pattern', {
          pattern,
          action,
          updatedAllowlist: settings.commandFilter.allowlist,
          updatedBlocklist: settings.commandFilter.blocklist
        })

        // Notify renderer to update settings store
        this.sendToRenderer('settings:updated', {
          commandFilter: settings.commandFilter
        })
      } else {
        log.info('updateCommandFilter: pattern already exists', {
          pattern,
          action,
          currentAllowlist: settings.commandFilter.allowlist,
          currentBlocklist: settings.commandFilter.blocklist
        })
      }
    } catch (error) {
      log.error('updateCommandFilter: failed to update settings', { error })
    }
  }

  /**
   * Handle approval reply from IPC (called by agent-handlers.ts)
   */
  handleApprovalReply(
    requestId: string,
    approved: boolean,
    remember?: 'allow' | 'block',
    pattern?: string,
    patterns?: string[]
  ): void {
    const pendingApproval = this.pendingApprovals.get(requestId)
    if (!pendingApproval) {
      log.warn('handleApprovalReply: no pending approval found', { requestId })
      return
    }

    log.info('handleApprovalReply: user responded', {
      requestId,
      approved,
      remember,
      pattern,
      patterns
    })

    // Send command.approval_replied event to renderer so it can clear the pending approval
    // We know which session this approval belongs to from the stored data
    emitAgentEvent(this.mainWindow, {
      type: 'command.approval_replied',
      sessionId: pendingApproval.hiveSessionId,
      data: { requestId, id: requestId, approved }
    })

    pendingApproval.resolve({ approved, remember, pattern, patterns })
  }

  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
    })

    if (status === 'idle') {
      this.maybeNotifySessionComplete(hiveSessionId)
    }
  }

  /**
   * Show a native notification when a session completes while the app window is unfocused
   */
  private maybeNotifySessionComplete(hiveSessionId: string): void {
    try {
      if (!this.mainWindow || this.mainWindow.isDestroyed() || this.mainWindow.isFocused()) {
        return
      }

      if (!this.dbService) return

      const session = this.dbService.getSession(hiveSessionId)
      if (!session) {
        log.warn('Cannot notify: session not found', { hiveSessionId })
        return
      }

      const project = this.dbService.getProject(session.project_id)
      if (!project) {
        log.warn('Cannot notify: project not found', { projectId: session.project_id })
        return
      }

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

  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
      })
    }
  }

  /**
   * Accumulate partial assistant content from SDK stream_events into
   * `session.inFlightDraft`. Also detects SDK message boundaries to start /
   * clear the draft. See InFlightAssistantDraft for the rationale.
   */
  private updateInFlightDraft(session: ClaudeSessionState, msg: Record<string, unknown>): void {
    const msgType = msg.type as string

    if (msgType === 'stream_event') {
      const rawEvent = msg.event as Record<string, unknown> | undefined
      if (!rawEvent) return

      // Sub-agent / child-session events are not folded into the parent
      // assistant draft — the renderer tracks them in childParts instead, and
      // they shouldn't leak into the parent transcript on abort.
      if (msg.parent_tool_use_id) return

      const eventType = rawEvent.type as string

      if (eventType === 'message_start') {
        session.inFlightDraft = {
          timestamp: new Date().toISOString(),
          blocks: new Map(),
          blockOrder: []
        }
        return
      }

      if (!session.inFlightDraft) {
        // If the first delta arrives before message_start (shouldn't happen
        // but be defensive), initialise lazily.
        session.inFlightDraft = {
          timestamp: new Date().toISOString(),
          blocks: new Map(),
          blockOrder: []
        }
      }

      const draft = session.inFlightDraft
      const blockIdx = rawEvent.index as number | undefined

      if (eventType === 'content_block_start') {
        const contentBlock = rawEvent.content_block as Record<string, unknown> | undefined
        if (!contentBlock || blockIdx === undefined) return
        const blockType = contentBlock.type as string

        if (blockType === 'tool_use') {
          const tool: InFlightToolDraft = {
            id: (contentBlock.id as string) ?? `tool-${Date.now()}`,
            name: (contentBlock.name as string) ?? 'unknown',
            blockIdx,
            inputJson: '',
            status: 'running',
            startTime: Date.now()
          }
          draft.blocks.set(blockIdx, { type: 'tool_use', tool })
          draft.blockOrder.push(blockIdx)
        } else if (blockType === 'thinking') {
          draft.blocks.set(blockIdx, {
            type: 'reasoning',
            text: '',
            timestamp: draft.timestamp
          })
          draft.blockOrder.push(blockIdx)
        } else if (blockType === 'text') {
          draft.blocks.set(blockIdx, {
            type: 'text',
            text: '',
            timestamp: draft.timestamp
          })
          draft.blockOrder.push(blockIdx)
        }
        return
      }

      if (eventType === 'content_block_delta') {
        if (blockIdx === undefined) return
        const delta = rawEvent.delta as Record<string, unknown> | undefined
        if (!delta) return
        const deltaType = delta.type as string
        const existing = draft.blocks.get(blockIdx)

        if (deltaType === 'text_delta') {
          const text = (delta.text as string) ?? ''
          if (existing?.type === 'text') {
            existing.text += text
          } else {
            draft.blocks.set(blockIdx, { type: 'text', text, timestamp: draft.timestamp })
            draft.blockOrder.push(blockIdx)
          }
        } else if (deltaType === 'thinking_delta') {
          const text = (delta.thinking as string) ?? ''
          if (existing?.type === 'reasoning') {
            existing.text += text
          } else {
            draft.blocks.set(blockIdx, { type: 'reasoning', text, timestamp: draft.timestamp })
            draft.blockOrder.push(blockIdx)
          }
        } else if (deltaType === 'input_json_delta') {
          const partial = (delta.partial_json as string) ?? ''
          if (existing?.type === 'tool_use' && partial) {
            existing.tool.inputJson += partial
          }
        }
        return
      }

      if (eventType === 'content_block_stop') {
        if (blockIdx === undefined) return
        const block = draft.blocks.get(blockIdx)
        if (block?.type === 'tool_use' && block.tool.inputJson && block.tool.input === undefined) {
          try {
            block.tool.input = JSON.parse(block.tool.inputJson)
          } catch {
            block.tool.input = block.tool.inputJson
          }
        }
        return
      }

      // message_delta / message_stop / others — nothing to do here; the
      // authoritative `assistant` SDK message (if it arrives) will clear the
      // draft below.
      return
    }

    // Authoritative assistant message → draft superseded.
    if (msgType === 'assistant' && !msg.parent_tool_use_id) {
      session.inFlightDraft = null
    }
  }

  /**
   * If an assistant turn was in-flight when the prompt loop terminated (abort,
   * error, network drop), materialise the draft into session.messages so it
   * persists to the DB and shows up in the rendered timeline.
   */
  private flushInFlightDraft(session: ClaudeSessionState, aborted: boolean): void {
    const draft = session.inFlightDraft
    if (!draft) return
    session.inFlightDraft = null

    // Convert blocks in arrival order into the renderer's part shape.
    const parts: Record<string, unknown>[] = []
    const seen = new Set<number>()
    for (const idx of draft.blockOrder) {
      if (seen.has(idx)) continue
      seen.add(idx)
      const block = draft.blocks.get(idx)
      if (!block) continue

      if (block.type === 'text') {
        if (!block.text) continue
        parts.push({ type: 'text', text: block.text, timestamp: block.timestamp })
      } else if (block.type === 'reasoning') {
        if (!block.text) continue
        parts.push({ type: 'reasoning', text: block.text, timestamp: block.timestamp })
      } else if (block.type === 'tool_use') {
        const tool = block.tool
        // Mark tools that never saw a tool_result as terminal on abort.
        const finalStatus: InFlightToolDraft['status'] =
          aborted && (tool.status === 'pending' || tool.status === 'running')
            ? 'error'
            : tool.status
        parts.push({
          type: 'tool_use',
          toolUse: {
            id: tool.id,
            name: tool.name,
            input: tool.input ?? {},
            status: finalStatus,
            startTime: tool.startTime,
            endTime: tool.endTime ?? (aborted ? Date.now() : undefined),
            error: finalStatus === 'error' ? 'Aborted by user' : undefined
          }
        })
      }
    }

    if (parts.length === 0) return

    const textContent = parts
      .filter((p) => p.type === 'text' || p.type === 'reasoning')
      .map((p) => (p as Record<string, unknown>).text as string)
      .join('')

    session.messages.push({
      id: `draft-${randomUUID()}`,
      role: 'assistant',
      timestamp: draft.timestamp,
      content: textContent,
      parts,
      aborted: aborted || undefined
    })

    log.info('Flushed in-flight draft to session.messages', {
      hiveSessionId: session.hiveSessionId,
      aborted,
      partCount: parts.length,
      textLength: textContent.length
    })
  }

  private emitSdkMessage(
    hiveSessionId: string,
    msg: Record<string, unknown>,
    messageIndex: number,
    toolNames?: Map<string, string>
  ): void {
    const msgType = msg.type as string
    const childSessionId = (msg.parent_tool_use_id as string) || undefined

    // SDK messages nest content under `msg.message.content` for assistant/user,
    // and under `msg.result` for result messages (NOT top-level `msg.content`).
    const innerMessage = msg.message as Record<string, unknown> | undefined
    const innerContent = innerMessage?.content as unknown[] | undefined

    switch (msgType) {
      // ── Token-level streaming events (includePartialMessages: true) ──
      case 'stream_event': {
        const rawEvent = msg.event as Record<string, unknown> | undefined
        if (!rawEvent) break

        const eventType = rawEvent.type as string

        switch (eventType) {
          case 'content_block_delta': {
            const delta = rawEvent.delta as Record<string, unknown> | undefined
            if (!delta) break
            const deltaType = delta.type as string

            if (deltaType === 'text_delta') {
              const text = delta.text as string
              emitAgentEvent(this.mainWindow, {
                type: 'message.part.updated',
                sessionId: hiveSessionId,
                childSessionId,
                data: {
                  part: { type: 'text', text },
                  delta: text
                }
              })
            } else if (deltaType === 'thinking_delta') {
              const thinking = delta.thinking as string
              emitAgentEvent(this.mainWindow, {
                type: 'message.part.updated',
                sessionId: hiveSessionId,
                childSessionId,
                data: {
                  part: { type: 'reasoning', text: thinking },
                  delta: thinking
                }
              })
            } else if (deltaType === 'input_json_delta') {
              // Tool input arrives as incremental JSON chunks.
              // Accumulate in the active tool block tracker so we can
              // emit a tool update with input once the block stops.
              const partialJson = delta.partial_json as string
              if (partialJson && this.activeToolBlocks.has(hiveSessionId)) {
                const tools = this.activeToolBlocks.get(hiveSessionId)!
                const blockIdx = rawEvent.index as number | undefined
                if (blockIdx !== undefined && tools.has(blockIdx)) {
                  const tool = tools.get(blockIdx)!
                  tool.inputJson += partialJson
                }
              }
            }
            break
          }
          case 'content_block_start': {
            const contentBlock = rawEvent.content_block as Record<string, unknown> | undefined
            if (!contentBlock) break
            const blockType = contentBlock.type as string
            const blockIdx = rawEvent.index as number | undefined

            if (blockType === 'tool_use') {
              const toolId = contentBlock.id as string
              const toolName = contentBlock.name as string
              log.info('TOOL_LIFECYCLE: content_block_start', {
                hiveSessionId,
                toolId,
                toolName,
                blockIdx
              })
              // Remember tool name for later lookup on tool_result
              if (toolNames) {
                toolNames.set(toolId, toolName)
              }
              // Track active tool block for input_json_delta accumulation
              if (!this.activeToolBlocks.has(hiveSessionId)) {
                this.activeToolBlocks.set(hiveSessionId, new Map())
              }
              if (blockIdx !== undefined) {
                this.activeToolBlocks.get(hiveSessionId)!.set(blockIdx, {
                  id: toolId,
                  name: toolName,
                  inputJson: ''
                })
              }
              emitAgentEvent(this.mainWindow, {
                type: 'message.part.updated',
                sessionId: hiveSessionId,
                childSessionId,
                data: {
                  part: {
                    type: 'tool',
                    callID: toolId,
                    tool: toolName,
                    state: { status: 'running', input: undefined }
                  }
                }
              })
            } else if (blockType === 'thinking') {
              // Start of a thinking/reasoning block — the actual content
              // arrives via content_block_delta thinking_delta events.
              // Nothing to emit here; the first delta creates the part.
            }
            break
          }
          case 'content_block_stop': {
            const blockIdx = rawEvent.index as number | undefined
            if (blockIdx !== undefined && this.activeToolBlocks.has(hiveSessionId)) {
              const tools = this.activeToolBlocks.get(hiveSessionId)!
              const tool = tools.get(blockIdx)
              if (tool) {
                log.info('TOOL_LIFECYCLE: content_block_stop', {
                  hiveSessionId,
                  toolId: tool.id,
                  toolName: tool.name,
                  hasInput: !!tool.inputJson,
                  inputLength: tool.inputJson.length
                })
                // Emit tool with accumulated input now that the block is complete
                let parsedInput: unknown = undefined
                if (tool.inputJson) {
                  try {
                    parsedInput = JSON.parse(tool.inputJson)
                  } catch {
                    parsedInput = tool.inputJson
                  }
                }
                emitAgentEvent(this.mainWindow, {
                  type: 'message.part.updated',
                  sessionId: hiveSessionId,
                  childSessionId,
                  data: {
                    part: {
                      type: 'tool',
                      callID: tool.id,
                      tool: tool.name,
                      state: { status: 'running', input: parsedInput }
                    }
                  }
                })
                tools.delete(blockIdx)
              }
              if (tools.size === 0) {
                this.activeToolBlocks.delete(hiveSessionId)
              }
            }
            break
          }
          default: {
            // message_start, message_delta, message_stop — no action needed
            break
          }
        }
        break
      }

      // ── Complete assistant message (arrives AFTER all stream_events) ──
      // With includePartialMessages the renderer already accumulated text/tools
      // via stream_event deltas.  Emit as message.updated for metadata/usage only.
      case 'assistant': {
        const usage = innerMessage?.usage as Record<string, unknown> | undefined
        const modelStr = typeof innerMessage?.model === 'string' ? innerMessage.model : undefined
        const visibleContentBlocks = Array.isArray(innerContent)
          ? innerContent.filter((block) => {
              const type = (block as Record<string, unknown>)?.type as string | undefined
              return type !== 'thinking'
            })
          : []
        const requestId =
          typeof msg.requestId === 'string'
            ? msg.requestId
            : typeof msg.request_id === 'string'
              ? (msg.request_id as string)
              : undefined
        const messageId =
          typeof msg.uuid === 'string'
            ? msg.uuid
            : typeof innerMessage?.id === 'string'
              ? innerMessage.id
              : undefined

        // Compute per-turn cost from usage + model so the renderer can display it.
        // Claude Code SDK result messages don't always carry total_cost_usd.
        let turnCost: number | undefined
        if (usage && modelStr && visibleContentBlocks.length > 0) {
          try {
            const pricingKey = resolvePricingModelKey(modelStr, 'claude-code')
            turnCost = calculateUsageCost(
              pricingKey,
              {
                input: typeof usage.input_tokens === 'number' ? usage.input_tokens : 0,
                output: typeof usage.output_tokens === 'number' ? usage.output_tokens : 0,
                cacheWrite:
                  typeof usage.cache_creation_input_tokens === 'number'
                    ? usage.cache_creation_input_tokens
                    : 0,
                cacheRead:
                  typeof usage.cache_read_input_tokens === 'number'
                    ? usage.cache_read_input_tokens
                    : 0
              },
              'claude-code'
            )
          } catch {
            // Pricing data unavailable — skip cost
          }
        }

        log.info('emitSdkMessage: assistant (complete)', {
          hiveSessionId,
          messageIndex,
          contentBlocks: Array.isArray(innerContent) ? innerContent.length : 0,
          hasUsage: !!usage,
          turnCost
        })
        emitAgentEvent(this.mainWindow, {
          type: 'message.updated',
          sessionId: hiveSessionId,
          childSessionId,
          data: {
            ...(messageId ? { id: messageId } : {}),
            ...(requestId ? { requestId } : {}),
            role: 'assistant',
            messageIndex,
            info: {
              ...(requestId ? { requestId } : {}),
              time: { completed: new Date().toISOString() },
              usage: usage
                ? {
                    input: usage.input_tokens,
                    output: usage.output_tokens,
                    cacheRead: usage.cache_read_input_tokens,
                    cacheCreation: usage.cache_creation_input_tokens
                  }
                : undefined,
              model: innerMessage?.model,
              ...(turnCost !== undefined ? { cost: turnCost } : {})
            }
          }
        })

        // Also emit tool result status updates.  When the complete assistant
        // message arrives, user-type messages with tool_result content follow.
        // But the tool_use blocks inside the assistant message carry the final
        // input which the renderer needs for tool cards.
        if (Array.isArray(innerContent)) {
          for (const block of innerContent) {
            const b = block as Record<string, unknown>
            if (b.type === 'tool_use') {
              emitAgentEvent(this.mainWindow, {
                type: 'message.part.updated',
                sessionId: hiveSessionId,
                childSessionId,
                data: {
                  part: {
                    type: 'tool',
                    callID: b.id as string,
                    tool: b.name as string,
                    state: { status: 'running', input: b.input }
                  }
                }
              })
            }
          }
        }

        // Persist messages to DB after each assistant turn completes.
        // This ensures data survives app crashes — at most one in-flight turn is lost.
        if (!childSessionId) {
          const session = this.findSessionByHiveId(hiveSessionId)
          if (session && session.messages.length > 0) {
            this.persistMessagesToDB(session)
          }
        }
        break
      }

      case 'result': {
        // Result content is in msg.result (array of content blocks or text)
        const resultContent = msg.result as unknown[] | unknown
        const resultArray = Array.isArray(resultContent) ? resultContent : undefined
        const requestId =
          typeof msg.requestId === 'string'
            ? msg.requestId
            : typeof msg.request_id === 'string'
              ? (msg.request_id as string)
              : undefined
        log.info('emitSdkMessage: result', {
          hiveSessionId,
          messageIndex,
          isError: msg.is_error,
          resultType: typeof resultContent,
          isArray: Array.isArray(resultContent),
          contentLength: resultArray?.length ?? 0
        })

        // NOTE: Previously this emitted the result text as message.part.updated,
        // but that duplicated text already streamed via stream_event deltas.
        // Removed to fix duplicate message display.

        emitAgentEvent(this.mainWindow, {
          type: 'message.updated',
          sessionId: hiveSessionId,
          childSessionId,
          data: {
            ...(requestId ? { requestId } : {}),
            role: 'assistant',
            content: resultArray ?? resultContent,
            isError: msg.is_error ?? false,
            messageIndex,
            // Include cost and modelUsage (for context limit detection) but
            // NOT per-turn cost or per-token usage — the `assistant` event is
            // the authoritative per-turn source. Re-counting the same turn on
            // `result` causes duplicate session cost accumulation.
            info: {
              time: { completed: new Date().toISOString() },
              ...(requestId ? { requestId } : {}),
              modelUsage: msg.modelUsage
            }
          }
        })
        break
      }

      case 'user': {
        // User messages are echoes from the SDK; the renderer already has
        // the user message locally.  However we still emit them so the
        // renderer can track tool_result content for tool card completion.
        if (Array.isArray(innerContent)) {
          for (const block of innerContent) {
            const b = block as Record<string, unknown>
            if (b.type === 'tool_result') {
              const toolId = b.tool_use_id as string
              const isError = b.is_error as boolean | undefined
              log.info('TOOL_LIFECYCLE: tool_result received', {
                hiveSessionId,
                toolId,
                isError: !!isError
              })
              // Extract text content from tool result
              let output: string | undefined
              if (typeof b.content === 'string') {
                output = b.content
              } else if (Array.isArray(b.content)) {
                output = (b.content as Record<string, unknown>[])
                  .filter((c) => c.type === 'text')
                  .map((c) => c.text as string)
                  .join('\n')
              }
              log.info('TOOL_LIFECYCLE: emitting tool_result to renderer', {
                hiveSessionId,
                toolId,
                isError: !!isError,
                hasOutput: !!output,
                outputLength: output?.length ?? 0
              })
              emitAgentEvent(this.mainWindow, {
                type: 'message.part.updated',
                sessionId: hiveSessionId,
                childSessionId,
                data: {
                  part: {
                    type: 'tool',
                    callID: toolId,
                    tool: toolNames?.get(toolId) ?? '',
                    state: {
                      status: isError ? 'error' : 'completed',
                      output: output,
                      error: isError ? output : undefined
                    }
                  }
                }
              })
            }
          }
        }
        break
      }

      // ── System messages (compaction, status) ──
      case 'system': {
        const subtype = msg.subtype as string | undefined
        if (subtype === 'compact_boundary') {
          const meta = msg.compact_metadata as Record<string, unknown> | undefined
          emitAgentEvent(this.mainWindow, {
            type: 'message.part.updated',
            sessionId: hiveSessionId,
            childSessionId,
            data: {
              part: {
                type: 'compaction',
                auto: meta?.trigger === 'auto'
              }
            }
          })
        }
        break
      }

      // ── Tool progress heartbeats ──
      case 'tool_progress': {
        const toolId = msg.tool_use_id as string
        const toolName = msg.tool_name as string
        emitAgentEvent(this.mainWindow, {
          type: 'message.part.updated',
          sessionId: hiveSessionId,
          childSessionId,
          data: {
            part: {
              type: 'tool',
              callID: toolId,
              tool: toolName,
              state: { status: 'running' }
            }
          }
        })
        break
      }

      case 'tool_use': {
        log.info('emitSdkMessage: tool_use', { hiveSessionId, messageIndex })
        emitAgentEvent(this.mainWindow, {
          type: 'message.part.updated',
          sessionId: hiveSessionId,
          childSessionId,
          data: {
            part: {
              type: 'tool',
              callID: ((msg as Record<string, unknown>).id as string) || `tool-${Date.now()}`,
              tool: ((msg as Record<string, unknown>).name as string) || 'unknown',
              state: { status: 'running', input: (msg as Record<string, unknown>).input }
            }
          }
        })
        break
      }

      default: {
        log.warn('emitSdkMessage: unhandled type', {
          type: msgType,
          messageIndex,
          keys: Object.keys(msg).join(',')
        })
      }
    }
  }

  private normalizeRewindResult(result: void | RewindFilesResult): RewindFilesResult | null {
    if (!result || typeof result !== 'object') return null
    return result
  }

  private isNoFileCheckpointFoundError(error: unknown): boolean {
    const message = error instanceof Error ? error.message : String(error)
    return message.toLowerCase().includes('no file checkpoint found')
  }

  /**
   * Resume the session solely to get access to `rewindFiles()`, then
   * immediately close the query.
   *
   * This follows the official SDK file-checkpointing pattern: resume
   * with a lightweight prompt, iterate one message to establish the
   * transport, call rewindFiles(), then close.
   *
   * We use `prompt: '.'` because the SDK documented pattern uses an
   * empty string, but the API rejects both empty and whitespace-only
   * text blocks.  A single dot is the minimal non-whitespace prompt.
   * See: https://github.com/anthropics/claude-agent-sdk-typescript/issues
   *
   * The throwaway entries this writes to the JSONL don't matter — the
   * next prompt uses `forkSession: true` + `resumeSessionAt` to branch
   * from the correct point, so the throwaway entries are excluded from
   * the forked session's context entirely.
   *
   * `maxTurns: 1` prevents the assistant from doing real work.
   */
  private async rewindWithResumedQuery(
    session: ClaudeSessionState,
    targetUuid: string
  ): Promise<void | RewindFilesResult> {
    const sdk = await loadClaudeSDK()

    const rewindQueryRaw = sdk.query({
      prompt: '.',
      options: {
        cwd: session.worktreePath,
        resume: session.claudeSessionId,
        enableFileCheckpointing: true,
        maxTurns: 1,
        env: {
          ...process.env,
          CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: '1'
        },
        ...(this.claudeBinaryPath ? { pathToClaudeCodeExecutable: this.claudeBinaryPath } : {})
      }
    })

    if (
      !rewindQueryRaw ||
      typeof (rewindQueryRaw as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] !==
        'function'
    ) {
      throw new Error('Cannot undo: failed to resume session for rewinding')
    }

    const rewindQuery = rewindQueryRaw as AsyncIterable<Record<string, unknown>>
    const queryObj = rewindQueryRaw as unknown as ClaudeQuery
    if (!queryObj.rewindFiles) {
      throw new Error('Cannot undo: SDK query does not support rewindFiles')
    }

    let result: void | RewindFilesResult | undefined
    let gotMessage = false
    try {
      for await (const message of rewindQuery) {
        void message
        gotMessage = true
        result = await queryObj.rewindFiles(targetUuid)
        break
      }
    } finally {
      // Forcefully terminate the throwaway query subprocess
      if (queryObj.close) {
        queryObj.close()
      } else if (queryObj.return) {
        await queryObj.return().catch(() => {})
      }
    }

    if (!gotMessage) {
      throw new Error('Cannot undo: failed to resume session for rewinding')
    }

    return result
  }

  /**
   * Extract prompt text from a session message's parts array or content string.
   */
  private extractPromptFromMessage(msg: Record<string, unknown> | undefined): string {
    if (!msg) return ''

    // Try parts array first
    const parts = msg.parts as Array<Record<string, unknown>> | undefined
    if (Array.isArray(parts)) {
      const textParts = parts
        .filter((p) => p.type === 'text' && typeof p.text === 'string')
        .map((p) => p.text as string)
      if (textParts.length > 0) return textParts.join('\n')
    }

    // Fall back to content string
    if (typeof msg.content === 'string') return msg.content

    return ''
  }

  /**
   * Find a message in session.messages by UUID (checking the `id` field).
   * Returns the message object or undefined if not found.
   */
  private findMessageByUuid(
    session: ClaudeSessionState,
    uuid: string
  ): Record<string, unknown> | undefined {
    for (const msg of session.messages) {
      const m = msg as Record<string, unknown>
      if (m.id === uuid) return m
    }
    return undefined
  }

  protected getSessionKey(worktreePath: string, claudeSessionId: string): string {
    return `${worktreePath}::${claudeSessionId}`
  }

  protected getSession(
    worktreePath: string,
    claudeSessionId: string
  ): ClaudeSessionState | undefined {
    return this.sessions.get(this.getSessionKey(worktreePath, claudeSessionId))
  }

  /**
   * Reverse-lookup helper: given a Hive session id (the renderer/UI's stable
   * id, distinct from the Claude SDK's per-fork id), find the live in-memory
   * session state and return the routing tuple consumers need to call
   * `prompt(worktreePath, agentSessionId, ...)`. Used by the Hub bridge so a
   * mobile client doesn't have to know the SDK id (which can rotate when the
   * session forks). Returns null if no live session is currently materialized
   * for this hive id (e.g. the desktop hasn't opened the session yet).
   */
  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.claudeSessionId
        }
      }
    }
    return null
  }

  protected sendToRenderer(channel: string, data: unknown): void {
    if (this.mainWindow && !this.mainWindow.isDestroyed()) {
      this.mainWindow.webContents.send(channel, data)
    } else {
      // Headless mode — no renderer window. Events still reach mobile
      // clients via the EventBus emit below, so this is expected.
      log.debug('sendToRenderer: no window (headless)')
    }
    try {
      const bus = getEventBus()
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      if (channel === 'agent:stream') bus.emit('agent:stream', data as any)
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      else if (channel === 'worktree:branchRenamed') bus.emit('worktree:branchRenamed', data as any)
    } catch {
      // EventBus not available
    }
  }

  // ── File attachment helpers ──────────────────────────────────────

  /**
   * Returns true if the message array contains at least one file part (image/PDF).
   */
  private hasFileAttachments(
    message:
      | string
      | Array<
          | { type: 'text'; text: string }
          | { type: 'file'; mime: string; url: string; filename?: string }
        >
  ): boolean {
    if (typeof message === 'string') return false
    return message.some((part) => part.type === 'file')
  }

  /**
   * Parse a data URL into its media type and raw base64 data.
   * Input format: "data:<mime>;base64,<data>"
   * Returns { mediaType, data } or null if the URL is not a valid data URL.
   */
  private parseDataUrl(url: string): { mediaType: string; data: string } | null {
    const match = url.match(/^data:([^;]+);base64,(.+)$/)
    if (!match) return null
    return { mediaType: match[1], data: match[2] }
  }

  /**
   * Convert Hive message parts into Anthropic API content blocks.
   * - Text parts → { type: 'text', text }
   * - Image files (image/*) → { type: 'image', source: { type: 'base64', media_type, data } }
   * - PDF files (application/pdf) → { type: 'document', source: { type: 'base64', media_type, data } }
   * - Unrecognized file types → text placeholder fallback
   */
  private buildAnthropicContentBlocks(
    message: Array<
      | { type: 'text'; text: string }
      | { type: 'file'; mime: string; url: string; filename?: string }
    >
  ): Array<Record<string, unknown>> {
    const blocks: Array<Record<string, unknown>> = []

    for (const part of message) {
      if (part.type === 'text') {
        if (part.text.trim()) {
          blocks.push({ type: 'text', text: part.text })
        }
        continue
      }

      // part.type === 'file'
      const parsed = this.parseDataUrl(part.url)
      if (!parsed) {
        // Fallback for non-data URLs: include as text placeholder
        blocks.push({ type: 'text', text: `[file: ${part.filename ?? part.url}]` })
        continue
      }

      const { mediaType, data } = parsed

      if (mediaType.startsWith('image/')) {
        blocks.push({
          type: 'image',
          source: {
            type: 'base64',
            media_type: mediaType,
            data
          }
        })
      } else if (mediaType === 'application/pdf') {
        blocks.push({
          type: 'document',
          source: {
            type: 'base64',
            media_type: mediaType,
            data
          }
        })
      } else {
        // Unsupported file type fallback
        blocks.push({ type: 'text', text: `[file: ${part.filename ?? 'attachment'}]` })
      }
    }

    return blocks
  }

  /**
   * Create an AsyncIterable that yields a single SDKUserMessage with the given content blocks.
   * Used when the prompt contains file attachments that need structured content blocks
   * instead of a plain text string.
   */
  private createUserMessageIterable(
    contentBlocks: Array<Record<string, unknown>>,
    sessionId: string
  ): AsyncIterable<Record<string, unknown>> {
    const message = {
      type: 'user' as const,
      message: {
        role: 'user' as const,
        content: contentBlocks
      },
      parent_tool_use_id: null,
      session_id: sessionId
    }

    return {
      [Symbol.asyncIterator]() {
        let done = false
        return {
          async next() {
            if (done) return { value: undefined, done: true as const }
            done = true
            return { value: message, done: false as const }
          }
        }
      }
    }
  }

  /**
   * Phase 21.5: emit an agent.* field event when a tool_use → tool_result
   * cycle completes. The shape of `tu` (toolUse) is the merged in-flight
   * draft: `{ id, name, input, output, error, status, startTime, ... }`.
   *
   * Sub-agent calls are already filtered upstream (isSubagentMessage check
   * in the tool_result merge block).
   */
  private emitAgentToolField(
    session: ClaudeSessionState,
    tu: Record<string, unknown>,
    tr: { tool_use_id?: string; is_error?: boolean },
    output: string | undefined
  ): void {
    if (!this.dbService) return
    const worktreeRow = this.dbService.getWorktreeByPath(session.worktreePath)
    if (!worktreeRow) return

    const toolName = (tu.name as string) ?? 'unknown'
    const toolUseId = (tu.id as string) ?? tr.tool_use_id ?? ''
    const input = (tu.input as Record<string, unknown> | undefined) ?? {}
    const startTime = (tu.startTime as number | undefined) ?? null
    const durationMs = startTime ? Date.now() - startTime : undefined

    // Lazy import to keep the module-level dep graph small.
    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,
        // Sub-agent already filtered at the message level; stays null.
        parentToolUseId: null,
        input,
        output: {
          text: tr.is_error ? undefined : output,
          error: tr.is_error ? output : undefined,
          // TODO Phase 21.5+: parse structured exit_code from Bash
          // tool_result when the SDK exposes it. For now we use is_error
          // as a 0/1 signal.
          exitCode: tr.is_error ? 1 : undefined,
          durationMs
        }
      })
    })
  }
}
