import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import type {
  HookInput,
  Options,
  PostToolUseHookInput,
  PreToolUseHookInput,
  SDKAssistantMessage,
  SDKPartialAssistantMessage,
  SDKResultMessage,
  SDKSystemMessage,
  SDKToolProgressMessage,
  SDKToolUseSummaryMessage,
  SDKUserMessage,
  SDKUserMessageReplay,
} from "@anthropic-ai/claude-agent-sdk";
import { query } from "@anthropic-ai/claude-agent-sdk";
import type {
  ContentBlockParam,
  ImageBlockParam,
} from "@anthropic-ai/sdk/resources/messages/messages.mjs";
import { claudeRegistry } from "#agents/claude-registry";
import { deepMapStrings } from "#agents/deep-map";
import {
  hostedClaudeCodeExecutablePath,
  hostedMcpServers,
  redactHostedSecrets,
  referencesHostedSecretStorage,
  substituteHostedSecrets,
} from "#agents/execution-host";
import { shouldSubstituteVaultToolInput } from "#agents/vault-tool-policy";
import { extractFileEvents } from "#media/file-events";
import { errMsg } from "#platform/error";
import { logger } from "#platform/logger";
import { claudeRequestContextTokens } from "#runtime/context-warning";
import type { AgentInputAttachment, AgentQueryOptions, EffortLevel, UnifiedEvent } from "#types";

const CLAUDE_DEFAULT_DISALLOWED_TOOLS = [
  "AskUserQuestion",
  "Workflow",
  "TodoWrite",
  "TaskCreate",
  "TaskUpdate",
  "TaskList",
  "TaskGet",
  // Monitor spawns a long-running background watcher tied to the CLI's own
  // interactive session/notification loop, which our headless spawned turns
  // don't have — it doesn't reliably deliver events back into a Negotium
  // topic. Use the background-bash MCP tools (run/watch) instead.
  "Monitor",
  // Native scheduling/cron tools duplicate Negotium's own runtime MCP surface
  // (`mcp__runtime__schedule_self`, the `cron-manager` server) and bypass its
  // persistence/host wiring if used directly.
  "ScheduleWakeup",
  "CronCreate",
  "CronList",
  "CronDelete",
  // Claude Code's cloud Routines surface is another scheduler. Keep scheduled
  // work in Negotium so it remains attached to the canonical topic and host.
  "RemoteTrigger",
] as const;

const CLAUDE_NATIVE_AGENT_TOOLS = [
  "Task",
  "Agent",
  "TaskOutput",
  "TaskStop",
  // Claude Code 2.1.224 added cross-session discovery and messaging. Those
  // sessions do not share Negotium's topic identity, authorization, inbox, or
  // transcript, so allowing them alongside session-comm makes "send/tell"
  // ambiguous and can route messages outside the canonical runtime.
  "ListAgents",
  "SendMessage",
  // Older agent-team builds exposed explicit lifecycle tools. Keep the names
  // denied for compatibility even though current builds launch named
  // teammates through Agent.
  "TeamCreate",
  "TeamDelete",
] as const;

export function claudeBuiltInTools(
  opts: Pick<AgentQueryOptions, "toolPolicy">,
): string[] | undefined {
  return opts.toolPolicy ? [] : undefined;
}

export function substituteClaudeToolInput(
  userId: string,
  toolName: string,
  input: unknown,
): unknown {
  if (!shouldSubstituteVaultToolInput(toolName)) return input;
  return deepMapStrings(input, (value) => substituteHostedSecrets(userId, value));
}

export function buildClaudeDisallowedTools(
  extra: readonly string[] | undefined = undefined,
): string[] {
  return [
    ...new Set([
      ...CLAUDE_DEFAULT_DISALLOWED_TOOLS,
      ...CLAUDE_NATIVE_AGENT_TOOLS,
      ...(extra ?? []),
    ]),
  ];
}

/**
 * Narrow our shared `EffortLevel` to the subset claude-agent-sdk accepts.
 * Claude SDK rejects "minimal" (codex-only); we drop it silently rather than
 * coercing, matching our policy for unrecognized model strings.
 */
type ClaudeEffort = Exclude<EffortLevel, "minimal">;
function toClaudeEffort(e: EffortLevel | undefined): ClaudeEffort | undefined {
  if (!e || !claudeRegistry.validateEffort(e)) return undefined;
  return e as ClaudeEffort;
}

type ClaudeImageMimeType = "image/jpeg" | "image/png" | "image/gif" | "image/webp";

const CLAUDE_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const CLAUDE_IMAGE_MIME_TYPES = new Set<string>([
  "image/jpeg",
  "image/png",
  "image/gif",
  "image/webp",
]);

function isClaudeImageAttachment(
  attachment: AgentInputAttachment,
): attachment is AgentInputAttachment & { mimeType: ClaudeImageMimeType } {
  return attachment.type === "image" && CLAUDE_IMAGE_MIME_TYPES.has(attachment.mimeType);
}

async function* singleUserMessage(message: SDKUserMessage): AsyncGenerator<SDKUserMessage> {
  yield message;
}

function buildClaudePrompt(opts: AgentQueryOptions): string | AsyncIterable<SDKUserMessage> {
  const attachments = opts.attachments ?? [];
  if (attachments.length === 0) return opts.prompt;

  const imageBlocks: ImageBlockParam[] = [];
  for (const attachment of attachments) {
    if (!isClaudeImageAttachment(attachment)) continue;
    if (attachment.sizeBytes > CLAUDE_IMAGE_MAX_BYTES) {
      logger.info(
        {
          path: attachment.path,
          mimeType: attachment.mimeType,
          sizeBytes: attachment.sizeBytes,
          maxBytes: CLAUDE_IMAGE_MAX_BYTES,
        },
        "claudeProvider: skipping oversized image attachment content block",
      );
      continue;
    }

    try {
      imageBlocks.push({
        type: "image",
        source: {
          type: "base64",
          media_type: attachment.mimeType,
          data: readFileSync(attachment.path).toString("base64"),
        },
      });
    } catch (err) {
      logger.warn(
        {
          path: attachment.path,
          mimeType: attachment.mimeType,
          err,
        },
        "claudeProvider: failed to read image attachment for content block",
      );
    }
  }

  if (imageBlocks.length === 0) return opts.prompt;

  const content: ContentBlockParam[] = [{ type: "text", text: opts.prompt }, ...imageBlocks];
  return singleUserMessage({
    type: "user",
    message: {
      role: "user",
      content,
    },
    parent_tool_use_id: null,
  });
}

// --- Stream event sub-types (from @anthropic-ai/sdk, not directly importable) ---
interface ContentBlockDelta {
  type: "content_block_delta";
  delta: { type: string; partial_json?: string; text?: string };
}
type StreamEvent = ContentBlockDelta | { type: string };

// --- Assistant message content block types (from BetaMessage.content) ---
interface TextBlock {
  type: "text";
  text: string;
}
interface ToolUseBlock {
  type: "tool_use";
  id: string;
  name: string;
  input: Record<string, unknown>;
}
interface ToolResultBlock {
  type: "tool_result";
  tool_use_id: string;
  content: string | unknown;
  is_error?: boolean;
}
type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock | { type: string };

// --- Process tree kill on abort ---
//
// The Claude Agent SDK spawns a `claude` CLI subprocess, which in turn spawns
// MCP servers (playwright-mcp, background workers, …) which themselves spawn
// browsers / interpreters. The SDK's default abort path only signals the
// direct child, leaving grandchildren orphaned — over time they accumulate as
// zombies, holding ports (Playwright DevTools 9222) and leaking memory
// (Chrome ~hundreds of MB per orphan). Single-user Mac deployments notice
// this within a day of repeated /abort.
//
// We override `spawnClaudeCodeProcess` to:
//   1. spawn with `detached: true` → child becomes the leader of a new
//      process group with the same pgid as its pid;
//   2. on abort, send the signal to `-pid` (the whole group) instead of the
//      single pid, so all transitive descendants receive it;
//   3. escalate to SIGKILL after a 2.5s grace if SIGTERM didn't take.

type SpawnClaudeCodeProcess = NonNullable<Options["spawnClaudeCodeProcess"]>;
type SpawnClaudeCodeOptions = Parameters<SpawnClaudeCodeProcess>[0];
type SpawnClaudeCodeResult = ReturnType<SpawnClaudeCodeProcess>;

const CLAUDE_ABORT_SIGKILL_DELAY_MS = 2500;

type ClaudeProcessExit = { code: number | null; signal: NodeJS.Signals | null };

// The Agent SDK can keep its stdout reader open for several minutes after its
// CLI child has gone away (CLAUDE_CODE_STREAM_CLOSE_TIMEOUT defaults to five
// minutes in our host environment).  Keep a per-query hook so the provider
// can abort that stuck reader and emit a normal terminal error instead of
// leaving the room lease occupied indefinitely.
const claudeProcessExitListeners = new WeakMap<
  AbortSignal,
  Set<(exit: ClaudeProcessExit) => void>
>();

function watchClaudeProcessExit(signal: AbortSignal): {
  exited: Promise<ClaudeProcessExit>;
  dispose: () => void;
} {
  let listener: ((exit: ClaudeProcessExit) => void) | undefined;
  const exited = new Promise<ClaudeProcessExit>((resolve) => {
    listener = resolve;
    const listeners = claudeProcessExitListeners.get(signal) ?? new Set();
    listeners.add(listener);
    claudeProcessExitListeners.set(signal, listeners);
  });
  return {
    exited,
    dispose: () => {
      if (!listener) return;
      const listeners = claudeProcessExitListeners.get(signal);
      listeners?.delete(listener);
      if (listeners?.size === 0) claudeProcessExitListeners.delete(signal);
      listener = undefined;
    },
  };
}

function notifyClaudeProcessExit(signal: AbortSignal, exit: ClaudeProcessExit): void {
  const listeners = claudeProcessExitListeners.get(signal);
  if (!listeners) return;
  claudeProcessExitListeners.delete(signal);
  for (const listener of listeners) listener(exit);
}

function signalProcessTree(pid: number, signal: NodeJS.Signals): boolean {
  // Negative pid = "signal every process in this process group". Falls back
  // to single-pid signaling if the group call fails (e.g. on platforms where
  // `detached` didn't materialize a new group).
  try {
    process.kill(-pid, signal);
    return true;
  } catch (groupErr) {
    try {
      process.kill(pid, signal);
      return true;
    } catch (pidErr) {
      logger.debug({ groupErr, pidErr, pid, signal }, "Failed to signal Claude process tree");
      return false;
    }
  }
}

export function spawnClaudeCodeProcessWithTreeKill(
  options: SpawnClaudeCodeOptions,
): SpawnClaudeCodeResult {
  const child = spawn(options.command, options.args, {
    cwd: options.cwd,
    detached: true,
    env: options.env as NodeJS.ProcessEnv,
    stdio: ["pipe", "pipe", "inherit"],
    // Windows gives every console child its own window unless told otherwise,
    // so without this each turn blinks a black box over whatever the user is
    // looking at. Ignored on POSIX.
    windowsHide: true,
  });
  if (!child.stdin || !child.stdout) {
    child.kill("SIGKILL");
    throw new Error("Failed to spawn Claude Code process with piped stdin/stdout");
  }

  let exited = false;
  let killTimer: NodeJS.Timeout | undefined;
  const clearKillTimer = () => {
    if (killTimer) clearTimeout(killTimer);
    killTimer = undefined;
  };

  const kill = (signal: NodeJS.Signals): boolean => {
    if (!child.pid) return child.kill(signal);
    const ok = signalProcessTree(child.pid, signal);
    if (signal !== "SIGKILL") {
      // Soft signal: schedule a SIGKILL fallback so a stuck process tree
      // can't survive abort indefinitely.
      clearKillTimer();
      killTimer = setTimeout(() => {
        if (!exited && child.pid) {
          logger.warn({ pid: child.pid }, "Claude process tree still alive after SIGTERM; SIGKILL");
          signalProcessTree(child.pid, "SIGKILL");
        }
      }, CLAUDE_ABORT_SIGKILL_DELAY_MS);
      killTimer.unref?.();
    }
    return ok;
  };

  const onAbort = () => {
    logger.warn({ pid: child.pid, command: options.command }, "Aborting Claude Code process tree");
    kill("SIGTERM");
  };

  if (options.signal.aborted) onAbort();
  else options.signal.addEventListener("abort", onAbort, { once: true });

  child.once("exit", (code, signal) => {
    exited = true;
    clearKillTimer();
    options.signal.removeEventListener("abort", onAbort);
    logger.debug({ pid: child.pid, code, signal }, "Claude Code process exited");
    notifyClaudeProcessExit(options.signal, { code, signal });
  });

  // Consume the child's 'error' event. Without a listener, a spawn-level error
  // propagates into the SDK's readline-over-stdout teardown and crashes the
  // whole process with "this.input.pause is not a function" (node:readline
  // close→pause on a stream that never initialized). Handling it here keeps the
  // failure local and surfaces a clean diagnostic.
  child.once("error", (err) => {
    exited = true;
    clearKillTimer();
    options.signal.removeEventListener("abort", onAbort);
    logger.error(
      {
        pid: child.pid,
        command: options.command,
        err: err instanceof Error ? err.message : String(err),
      },
      "Claude Code process error event",
    );
    notifyClaudeProcessExit(options.signal, { code: null, signal: null });
  });

  return {
    stdin: child.stdin,
    stdout: child.stdout,
    get killed() {
      return child.killed || exited;
    },
    get exitCode() {
      return child.exitCode;
    },
    kill,
    on(event, listener) {
      child.on(event, listener);
    },
    once(event, listener) {
      child.once(event, listener);
    },
    off(event, listener) {
      child.off(event, listener);
    },
  };
}

/**
 * Recognize the multiple shapes Node + WHATWG fetch use when a request is
 * cancelled via `AbortController`. Mirrors `isAbortError` in maestro/provider
 * — both files keep their own copy so neither has to import the other (the
 * agent providers are deliberately siblings, no shared error helpers).
 */
function isAbortError(err: unknown): boolean {
  if (!err || typeof err !== "object") return false;
  const e = err as { name?: unknown; code?: unknown };
  if (e.name === "AbortError") return true;
  if (e.code === 20 || e.code === "ABORT_ERR") return true;
  return false;
}

/**
 * Core Claude provider.
 * Wraps the @anthropic-ai/claude-agent-sdk into a UnifiedEvent stream so any
 * caller can consume it identically to other providers such as codexProvider.
 * The dispatch entry point is `runAgent` in agents/index.ts.
 *
 * Failure handling: any throw escaping the SDK iteration (missing Claude CLI
 * binary, OAuth not configured, transient network) is caught and yielded as
 * a `{type:"error"}` UnifiedEvent so the dispatcher never sees a synthetic
 * crash and event-processor's `error` case classifies it with the right
 * provider name. Matches the graceful pattern maestroProvider already uses.
 * Abort signals are swallowed silently (the user moved on) so they don't
 * surface as ghost error messages.
 */
export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<UnifiedEvent> {
  const claudeExecutable = hostedClaudeCodeExecutablePath();
  // The default host returns undefined so the Agent SDK selects its bundled,
  // version-matched Claude Code binary. If a host explicitly overrides the
  // executable, validate it up front and return an actionable error instead
  // of letting process spawn fail mid-stream with an opaque ENOENT.
  if (claudeExecutable && !existsSync(claudeExecutable)) {
    yield {
      type: "error",
      content: `Claude CLI not found at ${claudeExecutable}. Install Claude Code or configure the executable path.`,
    };
    return;
  }

  const cleanEnv = { ...process.env };
  delete cleanEnv.NEGOTIUM_PEER_SESSION_BRIDGE_URL;
  delete cleanEnv.NEGOTIUM_PEER_SESSION_BRIDGE_TOKEN;
  delete cleanEnv.CLAUDECODE;
  cleanEnv.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT ??= "300000";
  // Workflow is a feature flag, not a disallowedTools-addressable tool name.
  // The env var is the version-robust kill switch (works on any CLI the SDK
  // spawns); the `settings` flag layer below covers it a second time.
  cleanEnv.CLAUDE_CODE_DISABLE_WORKFLOWS = "1";

  // Use a private signal for the SDK. A premature CLI exit must wake its
  // reader, but must not look like a user-initiated abort to the turn runner.
  const sdkAbortController = new AbortController();
  const onCallerAbort = () => sdkAbortController.abort();
  if (opts.abortController?.signal.aborted) sdkAbortController.abort();
  else opts.abortController?.signal.addEventListener("abort", onCallerAbort, { once: true });
  const processExitWatch = watchClaudeProcessExit(sdkAbortController.signal);
  let unexpectedProcessExit: ClaudeProcessExit | undefined;
  void processExitWatch.exited.then((exit) => {
    // An exit caused by an explicit stop/supersede is expected. Its caller
    // signal is the authoritative distinction from a CLI crash.
    if (opts.abortController?.signal.aborted) return;
    unexpectedProcessExit = exit;
    sdkAbortController.abort();
  });

  const queryOptions: Options = {
    ...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
    spawnClaudeCodeProcess: spawnClaudeCodeProcessWithTreeKill,
    cwd: opts.cwd,
    permissionMode: "bypassPermissions",
    allowDangerouslySkipPermissions: true,
    includePartialMessages: true,
    env: cleanEnv,
    mcpServers: hostedMcpServers(opts) as Options["mcpServers"],
    ...(claudeBuiltInTools(opts) ? { tools: claudeBuiltInTools(opts) } : {}),
    abortController: sdkAbortController,
    // `AskUserQuestion` is Claude Code's built-in clarification tool, but it
    // expects the SDK CLI to render a TUI prompt and read stdin for the
    // answer — neither exists in this headless Telegram bridge. Without
    // disallowing it, the model would call AskUserQuestion, the SDK would
    // wait for a TUI response that never comes, and the turn would hang
    // until abort. Forbidding the tool nudges the model to ask the same
    // clarification as plain text (see topic-system prompt), which routes
    // through the normal next-user-message path and the user just answers
    // in the topic.
    //
    // Claude Code's private task store, Workflow, and native subagent tools are
    // not Negotium state. They stay disabled even when a caller supplies custom
    // agent definitions: delegation must go through runtime.spawn_subagent and
    // task state must go through the required shared task MCP.
    disallowedTools: buildClaudeDisallowedTools(opts.disallowedTools),
    ...(opts.model ? { model: opts.model } : {}),
    ...(opts.maxBudgetUsd ? { maxBudgetUsd: opts.maxBudgetUsd } : {}),
    ...(opts.agents
      ? {
          agents: Object.fromEntries(
            Object.entries(opts.agents).map(([name, def]) => {
              const { effort, ...rest } = def;
              const narrowed = typeof effort === "string" ? toClaudeEffort(effort) : effort;
              return [name, narrowed !== undefined ? { ...rest, effort: narrowed } : rest];
            }),
          ),
        }
      : {}),
    ...(toClaudeEffort(opts.effort) ? { effort: toClaudeEffort(opts.effort) } : {}),
    hooks: {
      PreToolUse: [
        {
          hooks: [
            async (input: HookInput) => {
              const { tool_name, tool_input } = input as PreToolUseHookInput;
              if (referencesHostedSecretStorage(tool_input)) {
                return {
                  hookSpecificOutput: {
                    hookEventName: "PreToolUse" as const,
                    permissionDecision: "deny" as const,
                    permissionDecisionReason: "Runtime secret storage access is not permitted",
                  },
                };
              }

              // Block built-in run_in_background: the bash runs inside the CLI
              // subprocess and dies when the turn ends (SIGTERM on IPC close).
              // Direct the model to the MCP tool that survives across turns and
              // auto-injects a completion notification via session-inbox.
              if (
                tool_name === "Bash" &&
                (tool_input as Record<string, unknown>)?.run_in_background === true
              ) {
                return {
                  hookSpecificOutput: {
                    hookEventName: "PreToolUse" as const,
                    permissionDecision: "deny" as const,
                    permissionDecisionReason:
                      "run_in_background is not supported in headless mode — the bash process dies when the agent turn ends. Use mcp__background-bash__background_bash_run instead: it survives across turns and automatically injects the result into the session when done.",
                  },
                };
              }

              // Resolve Vault placeholders only for explicitly allowlisted
              // transient execution tools. Messaging and persistence tools
              // must retain {{KEY}} verbatim.
              const userId = opts.vaultUserId ?? opts.userId ?? "";
              const withVault = substituteClaudeToolInput(userId, tool_name, tool_input);
              if (JSON.stringify(withVault) === JSON.stringify(tool_input)) {
                return { continue: true };
              }
              return {
                hookSpecificOutput: {
                  hookEventName: "PreToolUse" as const,
                  updatedInput: withVault as Record<string, unknown>,
                },
              };
            },
          ],
        },
      ],
      PostToolUse: [
        {
          hooks: [
            async (input: HookInput) => {
              const { tool_response } = input as PostToolUseHookInput;
              const userId = opts.vaultUserId ?? opts.userId ?? "";
              const redacted = deepMapStrings(tool_response, (value) =>
                redactHostedSecrets(userId, value),
              );
              if (JSON.stringify(redacted) === JSON.stringify(tool_response)) {
                return { continue: true };
              }
              return {
                hookSpecificOutput: {
                  hookEventName: "PostToolUse" as const,
                  updatedToolOutput: redacted,
                },
              };
            },
          ],
        },
      ],
    },
    settingSources: ["project"] as Options["settingSources"],
    // Disable the multi-agent Workflow feature: a headless backend query has
    // no business fanning out orchestration subagents, and the "ultracode"
    // keyword trigger would let user-authored message text opt into it.
    settings: {
      disableWorkflows: true,
      workflowKeywordTriggerEnabled: false,
    },
    systemPrompt: opts.systemPrompt,
  };

  if (opts.sessionId) {
    queryOptions.resume = opts.sessionId;
  }

  // Wrap the SDK iteration so init failures (missing claude CLI, OAuth not
  // configured, transient network errors before the first event) surface as
  // a normal error UnifiedEvent. Without this, an unconfigured Claude
  // install would crash the dispatcher with a synthetic exception and the
  // user-facing classifyError would still fire — but only via the handler
  // catch path, which means cleanup ordering differed from maestro/codex.
  // Keeping the failure shape consistent across providers simplifies the
  // upstream error code.
  // Thinking heartbeat: long extended-thinking stretches (minutes on fable /
  // high effort) emit no text or tool events, so the room UI goes silent and
  // the user can't tell whether the turn is still alive. The SDK does stream
  // thinking signals (`thinking_delta` content deltas, `system/thinking_tokens`
  // pings during redacted thinking) — digest them into a throttled
  // `tool_progress` heartbeat that the client already renders as a live status
  // chip (its handler also re-asserts the typing indicator).
  const THINKING_BEAT_MS = 5000;
  let thinkingStart: number | null = null;
  let lastThinkingBeat = 0;
  let lastContextTokens: number | undefined;
  let lastContextModel: string | undefined;
  const thinkingBeat = (): { type: "tool_progress"; toolName: string; elapsed: number } | null => {
    const now = Date.now();
    if (thinkingStart === null) {
      thinkingStart = now;
      lastThinkingBeat = now;
      return { type: "tool_progress", toolName: "thinking", elapsed: 0 };
    }
    if (now - lastThinkingBeat < THINKING_BEAT_MS) return null;
    lastThinkingBeat = now;
    return { type: "tool_progress", toolName: "thinking", elapsed: (now - thinkingStart) / 1000 };
  };

  try {
    const prompt = buildClaudePrompt(opts);
    for await (const message of query({
      prompt,
      options: queryOptions,
    })) {
      // --- Thinking heartbeat (see above) ---
      // A thinking "stretch" ends at the first non-thinking signal so elapsed
      // restarts cleanly on the next thinking phase within the same turn.
      if (
        message.type === "system" &&
        (message as { subtype?: string }).subtype === "thinking_tokens"
      ) {
        const beat = thinkingBeat();
        if (beat) yield beat;
        continue;
      }

      // --- Stream events (token-by-token) ---
      // tool_use is NOT emitted from the stream path: the completed assistant
      // message below carries the same block with its input already parsed,
      // and tools only start executing after the message completes — so the
      // stream copy added no timeliness, only a duplicate tool_use per turn
      // in the unified log (doubling `<!-- Tool: ... -->` comments when
      // bridging to another agent).
      if (message.type === "stream_event") {
        const streamMsg = message as SDKPartialAssistantMessage;
        const evt = (streamMsg as { event?: StreamEvent }).event;
        if (!evt) continue;

        if (evt.type === "content_block_delta") {
          const delta = evt as ContentBlockDelta;
          if (delta.delta.type === "thinking_delta") {
            const beat = thinkingBeat();
            if (beat) yield beat;
          } else if (delta.delta.type === "text_delta" && delta.delta.text) {
            // Claude only exposes tool_use blocks on the completed assistant
            // message below. Forwarding text deltas here would place all text
            // before those tools, making the original block order impossible
            // for consumers to reconstruct. The completed message emits text
            // and tool blocks in their canonical order.
            thinkingStart = null;
          } else {
            // Tool-args streaming (partial_json) etc. — thinking stretch over.
            thinkingStart = null;
          }
        }

        continue;
      }

      // Any completed message (assistant text, tool events, results) ends the
      // current thinking stretch.
      thinkingStart = null;

      // --- Tool progress ---
      if (message.type === "tool_progress") {
        const m = message as SDKToolProgressMessage;
        yield {
          type: "tool_progress",
          toolName: m.tool_name,
          elapsed: m.elapsed_time_seconds,
        };
        continue;
      }

      // --- Tool use summary ---
      if (message.type === "tool_use_summary") {
        const m = message as SDKToolUseSummaryMessage;
        yield { type: "tool_use_summary", summary: m.summary };
        continue;
      }

      // --- System init ---
      if (message.type === "system") {
        const m = message as SDKSystemMessage;
        if (m.subtype === "init") {
          yield { type: "session", sessionId: m.session_id };
        } else {
          // Log all other system messages (task_updated, task_progress,
          // task_notification, etc.) so we can observe lifecycle events.
          logger.debug(
            { subtype: m.subtype, msg: m },
            "claudeProvider: system message (unhandled subtype)",
          );
        }
        continue;
      }

      // --- Result (terminal) ---
      // SDK occasionally yields stray messages after a `result`; consuming them
      // surfaces as ghost messages in the topic ("어? 뭐가 더 나오네?"). Close
      // the generator immediately on terminal so the SDK stream is dropped and
      // any trailing payloads can't leak through.
      if (message.type === "result") {
        const m = message as SDKResultMessage;
        if (m.subtype === "success") {
          const contextWindow =
            (lastContextModel ? m.modelUsage?.[lastContextModel]?.contextWindow : undefined) ??
            Object.values(m.modelUsage ?? {})[0]?.contextWindow;
          yield {
            type: "result",
            content: m.result,
            stopReason: m.stop_reason ?? "end_turn",
            usage: m.usage
              ? {
                  inputTokens: m.usage.input_tokens,
                  outputTokens: m.usage.output_tokens,
                  cacheCreationInputTokens: m.usage.cache_creation_input_tokens ?? undefined,
                  cacheReadInputTokens: m.usage.cache_read_input_tokens ?? undefined,
                  costUsd: m.total_cost_usd,
                  ...(lastContextTokens !== undefined && contextWindow
                    ? { contextTokens: lastContextTokens, contextWindow }
                    : {}),
                }
              : undefined,
          };
          yield* extractFileEvents(m.result, "result");
          return;
        }
        // Error result
        const budgetExceeded = m.subtype === "error_max_budget_usd";
        const errorMsg = budgetExceeded
          ? "The job reached its cost limit"
          : m.errors?.join("; ") || "Unknown error";
        yield {
          type: "error",
          content: errorMsg,
          ...(budgetExceeded ? { code: "budget_exceeded" as const } : {}),
          ...(m.usage
            ? {
                usage: {
                  inputTokens: m.usage.input_tokens,
                  outputTokens: m.usage.output_tokens,
                  cacheCreationInputTokens: m.usage.cache_creation_input_tokens ?? undefined,
                  cacheReadInputTokens: m.usage.cache_read_input_tokens ?? undefined,
                  costUsd: m.total_cost_usd,
                },
              }
            : {}),
        };
        return;
      }

      // --- Assistant message ---
      if (message.type === "assistant") {
        const m = message as SDKAssistantMessage;
        const content = (m.message?.content ?? []) as ContentBlock[];
        const usage = m.message?.usage;
        if (usage) {
          lastContextTokens = claudeRequestContextTokens(usage) ?? undefined;
          lastContextModel = m.message.model;
        }

        for (const block of content) {
          if (block.type === "text") {
            const textBlock = block as TextBlock;
            yield { type: "text", content: textBlock.text };
            // Surface mid-turn [FILE:/abs/path] tags right away, matching codex/
            // maestro providers — without this, a model that narrates a file
            // before its next tool call (rather than emitting the tag in the
            // final `result` text) wouldn't trigger send-file at all. The file
            // event handler dedups on normalized path so the same tag appearing
            // again in `result` won't double-send.
            yield* extractFileEvents(textBlock.text, "text");
          } else if (block.type === "tool_use") {
            const tb = block as ToolUseBlock;
            yield {
              type: "tool_use",
              name: tb.name,
              input: tb.input || {},
              toolUseId: tb.id,
            };
          }
        }
        continue;
      }

      // --- User message (tool results ride on user-role messages) ---
      // The Anthropic message protocol puts tool_result blocks in USER
      // messages, never assistant ones — without this branch the unified log
      // records tool calls but no results, losing information codex topics
      // keep (visible when bridging a claude topic to another agent).
      if (message.type === "user") {
        const um = message as SDKUserMessage | SDKUserMessageReplay;
        // Replays re-deliver transcript history on resume; mapping them would
        // duplicate every past tool_result into the unified log.
        if ("isReplay" in um && um.isReplay) continue;
        const content = um.message?.content;
        if (!Array.isArray(content)) continue;
        for (const block of content as ContentBlock[]) {
          if (block.type !== "tool_result") continue;
          const trBlock = block as ToolResultBlock;
          const trContent =
            typeof trBlock.content === "string" ? trBlock.content.slice(0, 200) : "";
          yield {
            type: "tool_result",
            toolUseId: trBlock.tool_use_id || "",
            content: trContent,
            ...(trBlock.is_error ? { isError: true } : {}),
          };
        }
      }
    }
    if (unexpectedProcessExit) {
      const detail = unexpectedProcessExit.signal
        ? `signal ${unexpectedProcessExit.signal}`
        : unexpectedProcessExit.code === null
          ? "before it could start"
          : `exit code ${unexpectedProcessExit.code}`;
      logger.error({ detail }, "claudeProvider: CLI exited before terminal SDK event");
      yield { type: "error", content: `Claude CLI exited unexpectedly (${detail}).` };
    }
  } catch (e) {
    // Abort = user moved on; don't surface as an error message. The handler
    // catches the abort signal separately via abortReason, so swallowing
    // here matches both maestro and codex behavior.
    if (unexpectedProcessExit) {
      const detail = unexpectedProcessExit.signal
        ? `signal ${unexpectedProcessExit.signal}`
        : unexpectedProcessExit.code === null
          ? "before it could start"
          : `exit code ${unexpectedProcessExit.code}`;
      logger.error({ err: e, detail }, "claudeProvider: CLI exited before terminal SDK event");
      yield { type: "error", content: `Claude CLI exited unexpectedly (${detail}).` };
      return;
    }
    if (isAbortError(e) || opts.abortController?.signal.aborted) return;
    logger.error({ err: e }, "claudeProvider: SDK iteration failed");
    yield { type: "error", content: errMsg(e) };
  } finally {
    processExitWatch.dispose();
    opts.abortController?.signal.removeEventListener("abort", onCallerAbort);
  }
}
