Ctrl K
ComunidadGuías…

src/server/engine/claude-code.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { query } from '@anthropic-ai/claude-agent-sdk';
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { signInternalToken } from '../auth.ts';
import { buildHooks } from '../hooks.ts';
import { offloadEnv } from '../offload.ts';
import { listSkills } from '../skills.ts';
import { loadAgents } from '../agents.ts';
import { registerProactiveMessage } from '../slack/sessions.ts';
import { registerPoll } from '../polls.ts';
import { getSession, setSessionModel, getSessionModel, setClaudeResume, type ConvMessage } from '../sessions.ts';
import {
  isResumeEnabled, decideClaudeTurn, claudeConfigDir, findClaudeTranscript, shortHash, sectionHashes, speakerKey,
  sectionsAfterSubmit, isResumeFailure, conversationSummaryKey, buildContextDelta, buildResumePrompt, renderConvMessage,
  type TurnPath, type ClaudeResumeState,
} from './claude-resume.ts';
import { DEFAULT_MODEL } from '../directives.ts';
import { claudeCodeModels } from '../../shared/models';
import { resolveModelSwitch, MODEL_ALIASES } from '../model-aliases.ts';
import type { WsEvent, AskQuestion, QuestionAnswers, QuestionHandler } from '../claude.ts';
import type { AgentEngine, EngineStreamOpts, EngineModel } from './types.ts';
import { getPromptSuffix } from '../prompt-suffix.ts';
import { APP_ROOT } from '../paths.ts';
import { writeMcpConfigFile } from './mcp-config-file.ts';
import { claudeUsageFor } from '../claude-usage.ts';
import { claudeAccountDir, applyClaudeAccount, applyForcedSubscription, claudeAccountRef, type ClaudeAccountRef } from '../claude-account.ts';
import { buildAgentEnv, builtinTools, filterMcpServers, allowsEscalate, allowsInternalToken, allowsShare, SHARE_TOOL_ID, SENSITIVE_PATH_PATTERNS } from '../security/enforce.ts';
import { escalateMcpServer } from '../security/escalate.ts';
import { shareMcpServer } from '../share-file.ts';
import { jobsMcpServer, JOBS_TOOL_IDS } from '../jobs-mcp.ts';
const IMMUTABLE_SYSTEM_PROMPT = readFileSync(path.resolve(import.meta.dirname, '../../../defaults/system-prompt.md'), 'utf-8');
const DEFAULT_USER_PROMPT = `You are a helpful assistant with access to MCP tools.`;
const DEFAULT_ALLOWED_TOOLS = ['Read', 'Edit', 'Bash', 'WebSearch', 'Glob', 'LS', 'ToolSearch'];
const BG_TASK_MAX_WAIT_MS = 15 * 60_000;
const BG_HEARTBEAT_MS = 60_000;
/** How long to wait for the CLI's follow-up turn on a bg task that finished before the turn's result. */
const BG_FOLLOWUP_GRACE_MS = 5_000;
const HISTORY_LIMIT = 50;

const NO_INTERACTIVE_ANSWER = 'No interactive channel is available to answer right now. Use your best judgement to proceed, and surface these options to the user in your reply so they can redirect if needed.';

const SENSITIVE_BASH_PATTERNS = [
  /\.env\b/i, /\bprintenv\b/i, /\b(env|set)\s*\|/i, /\bsecrets?\//i,
  /credentials/i, /service.account/i, /\.(pem|key)\b/i,
  /\$[A-Z_]*(KEY|SECRET|TOKEN|PASSWORD)\b/i, /process\.env/i,
];
const DESTRUCTIVE_DATA_PATTERNS = [
  /\b(rm|rmdir|unlink|mv)\b.*\bdata\/(conversations?|sessions?|schedules?)\b/i,
  /\b(rm|rmdir|unlink|mv)\b.*\b(whitelist\.json|api-keys\.json|agent-config\.json)\b/i,
  /\bfind\b.*\bdata\/(conversations?|sessions?|schedules?).*(-delete|-exec\s+rm)\b/i,
  />\s*data\/(conversations?|sessions?|schedules?)\//i,
];

/** Is `id` a model this engine can actually run? Anything else — notably a foreign id like
 *  `cursor/composer-2.5` or the bare `composer-2.5` a schedule pinned for another engine — used to be
 *  forwarded straight to the Anthropic SDK, billing Anthropic for a model the caller never asked it
 *  for. Shape-based rather than an exact list so dated ids (`claude-sonnet-4-5-20250929`) still pass.
 *  An explicit `anthropic/` prefix is this engine's own provider and is stripped first. */
function isOwnModel(id: string): boolean {
  const bare = id.startsWith('anthropic/') ? id.slice('anthropic/'.length) : id;
  return bare.startsWith('claude-') || bare.toLowerCase() in MODEL_ALIASES;
}

type DenyResult = { behavior: 'deny'; message: string };

function checkSensitiveAccess(toolName: string, input: Record<string, unknown>): DenyResult | null {
  const filePath = (input.file_path ?? input.path ?? '') as string;
  if ((toolName === 'Read' || toolName === 'Edit' || toolName === 'Write') && filePath) {
    if (SENSITIVE_PATH_PATTERNS.some(p => p.test(filePath))) {
      console.log(`[security] Blocked ${toolName} on sensitive file: ${filePath}`);
      return { behavior: 'deny', message: 'Access to sensitive files (.env, secrets, credentials) is blocked.' };
    }
  }
  if (toolName === 'Bash') {
    const cmd = (input.command ?? '') as string;
    if (SENSITIVE_BASH_PATTERNS.some(p => p.test(cmd))) {
      console.log(`[security] Blocked Bash command targeting sensitive data: ${cmd.slice(0, 80)}`);
      return { behavior: 'deny', message: 'Commands accessing sensitive files or environment secrets are blocked.' };
    }
  }
  return null;
}

function isDestructiveDataOp(toolName: string, input: Record<string, unknown>): boolean {
  if (toolName !== 'Bash') return false;
  return DESTRUCTIVE_DATA_PATTERNS.some(p => p.test((input.command ?? '') as string));
}

function buildHistoryPrompt(conv: ConvMessage[], contextBlock: string, userPrompt: string): string {
  if (!conv.length) {
    return contextBlock ? `${contextBlock}\n\n${userPrompt}` : userPrompt;
  }
  const summaryIdx = conv.findLastIndex((m) => m.blocks.some((b) => b.type === 'summary'));
  let summary: string | null = null;
  let recent: ConvMessage[];
  if (summaryIdx >= 0) {
    summary = (conv[summaryIdx].blocks.find((b) => b.type === 'summary') as any)?.text ?? null;
    recent = conv.slice(summaryIdx + 1).slice(-HISTORY_LIMIT);
  } else {
    recent = conv.slice(-HISTORY_LIMIT);
  }

  const parts: string[] = [];
  if (summary) parts.push(`<conversation_summary>\n${summary}\n</conversation_summary>`);
  for (const m of recent) {
    const line = renderConvMessage(m);
    if (line) parts.push(line);
  }

  if (parts.length) {
    const prefix = contextBlock ? `${contextBlock}\n\n` : '';
    return `${prefix}<conversation_history>\n${parts.join('\n\n')}\n</conversation_history>\n\nUser: ${userPrompt}`;
  }
  return contextBlock ? `${contextBlock}\n\n${userPrompt}` : userPrompt;
}

/**
 * Log prompt-cache effectiveness from the SDK result `usage`. The hit rate is
 * cache_read / (cache_read + cache_creation + uncached input) — a low rate over
 * many turns points to a silent prefix invalidator or sessions spread past the
 * 5-min cache TTL. `path` says how the prompt was built — `fresh` (history re-sent in one message),
 * `resume` (SDK session resumed, only the new message sent) or `fallback:<reason>` (resume enabled but a
 * fresh query ran) — so journal lines can be A/B-compared per path.
 */
function logCacheUsage(usage: any, model: string, path: TurnPath): void {
  if (!usage) return;
  const read = usage.cache_read_input_tokens ?? 0;
  const created = usage.cache_creation_input_tokens ?? 0;
  const fresh = usage.input_tokens ?? 0;
  const totalIn = read + created + fresh;
  if (totalIn === 0) return;
  const hitRate = ((read / totalIn) * 100).toFixed(1);
  console.log(`[claude] Cache: hit=${hitRate}% read=${read} write=${created} uncached=${fresh} out=${usage.output_tokens ?? 0} model=${model} path=${path}`);
}

/** How one query attempt is built. `persist` is set when resume is enabled: after a query that produced
 *  output, the CC session id + these facts are saved so the next turn can resume. */
interface RunPlan {
  path: TurnPath;
  prompt: string;
  accountDir: string | null;
  resumeId?: string;
  persist?: Omit<ClaudeResumeState, 'claudeSessionId' | 'model' | 'interruptedBy'>;
  /** Resume only: section hashes to store once the prompt is submitted (see sectionsAfterSubmit). */
  submitSections?: Record<string, string>;
}

/** Exit promises of CLI processes still running, per shraga session. A turn that took over a session (external
 *  steer) can start while the aborted run's CLI is still exiting; resuming then would put two writers on one
 *  transcript. */
const liveCli = new Map<string, Set<Promise<void>>>();

function trackCliExit(sessionId: string | undefined, child: { once: (event: string, fn: () => void) => unknown }): void {
  if (!sessionId) return;
  const set = liveCli.get(sessionId) ?? new Set();
  liveCli.set(sessionId, set);
  const exited = new Promise<void>((resolve) => { child.once('exit', resolve); child.once('error', resolve); });
  set.add(exited);
  void exited.then(() => { set.delete(exited); if (!set.size && liveCli.get(sessionId) === set) liveCli.delete(sessionId); });
}

/** true once every CLI process of the session has exited, false if one is still alive after `ms`. */
async function cliExited(sessionId: string, ms: number): Promise<boolean> {
  const set = liveCli.get(sessionId);
  if (!set?.size) return true;
  let timer: ReturnType<typeof setTimeout> | undefined;
  const timeout = new Promise<false>((r) => { timer = setTimeout(() => r(false), ms); });
  try { return await Promise.race([Promise.all(set).then(() => true), timeout]); } finally { clearTimeout(timer); }
}

/** Events that prove a resumed query actually runs; until one arrives the attempt can still be retried fresh unseen. */
const LIVE_EVENTS = new Set<WsEvent['type']>(['text_delta', 'thinking_delta', 'tool_use', 'tool_use_input', 'tool_result', 'tool_result_image', 'done']);

/** SDK spawn hook: same call the SDK would make, plus `detached` (own process group). See the
 *  call site for why. Shape mirrors the SDK's own spawnLocalProcess return. */
function spawnDetached(cfg: { command: string; args: string[]; cwd?: string; env: Record<string, string | undefined>; signal?: AbortSignal }) {
  const child = spawn(cfg.command, cfg.args, {
    cwd: cfg.cwd,
    env: cfg.env,
    signal: cfg.signal,
    // Keep SDK debug output visible when it is asked for; otherwise the SDK's own default.
    stdio: ['pipe', 'pipe', cfg.env.DEBUG_CLAUDE_AGENT_SDK ? 'inherit' : 'ignore'],
    detached: true,
    windowsHide: true,
  });
  return {
    stdin: child.stdin,
    stdout: child.stdout,
    get killed() { return child.killed; },
    get exitCode() { return child.exitCode; },
    kill: child.kill.bind(child),
    on: child.on.bind(child),
    once: child.once.bind(child),
    off: child.off.bind(child),
  };
}

const INLINE_MIMES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']);

async function* buildAttachmentPrompt(text: string, attachments: { path: string; name: string; mimeType: string }[], sessionId: string): AsyncIterable<any> {
  const content: any[] = [];
  const fileRefs: string[] = [];
  const audioRefs: string[] = [];
  const inlined: string[] = [];
  for (const att of attachments) {
    if (INLINE_MIMES.has(att.mimeType)) {
      try {
        const buf = readFileSync(att.path);
        const blockType = att.mimeType === 'application/pdf' ? 'document' : 'image';
        content.push({ type: blockType, source: { type: 'base64', media_type: att.mimeType, data: buf.toString('base64') } });
        inlined.push(`${att.name} (${att.path})`);
      } catch (err) {
        console.error(`[claude] Failed to read attachment ${att.path}:`, err);
        fileRefs.push(`${att.name} (at ${att.path} — failed to read)`);
      }
    } else if (att.mimeType.startsWith('audio/')) {
      // The model can't ingest audio directly — pass the path and route to mcp-audio.
      audioRefs.push(`${att.name} (at ${att.path})`);
    } else {
      fileRefs.push(`${att.name} (at ${att.path})`);
    }
  }
  if (audioRefs.length > 0) text += `\n\n[Audio attached — transcribe with the mcp-audio tool (post_audio_transcribe { file }) before answering]: ${audioRefs.join(', ')}`;
  if (fileRefs.length > 0) text += `\n\n[Attached files — use Read tool to access]: ${fileRefs.join(', ')}`;
  // Say IN THE TEXT that the inlined blocks exist. `text` carries the whole conversation history, so
  // the image blocks sit tens of thousands of tokens above the actual question and the model has
  // answered "I don't see any images attached" to a message that had four — the transcript proves the
  // blocks were sent. This line makes the prompt agree with its own content, and names the on-disk
  // paths so the model can Read them if it still can't see a block.
  if (inlined.length > 0) text += `\n\n[${inlined.length} file(s) are attached to THIS message and included above as image/document blocks — look at them: ${inlined.join(', ')}]`;
  content.push({ type: 'text', text });
  yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionId };
}

async function* buildTextPrompt(text: string, sessionId: string): AsyncIterable<any> {
  yield { type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: sessionId };
}

async function* buildLegacyImagePrompt(text: string, images: string[], sessionId: string): AsyncIterable<any> {
  const content: any[] = images.map((dataUrl) => {
    const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
    if (!match) return null;
    const blockType = match[1] === 'application/pdf' ? 'document' : 'image';
    return { type: blockType, source: { type: 'base64', media_type: match[1], data: match[2] } };
  }).filter(Boolean);
  content.push({ type: 'text', text });
  yield { type: 'user', message: { role: 'user', content }, parent_tool_use_id: null, session_id: sessionId };
}

export class ClaudeCodeEngine implements AgentEngine {
  readonly name = 'claude-code';
  readonly enforcesProfile = true;

  getModels(): EngineModel[] {
    return claudeCodeModels();
  }

  /** How long a resume-enabled turn waits for an earlier CLI process on the session to exit before going fresh. */
  static cliExitWaitMs = 5_000;

  async *stream(opts: EngineStreamOpts): AsyncGenerator<WsEvent> {
    let cliAlive = false;
    if (opts.sessionId && isResumeEnabled(opts.directives, opts.config) && !(await cliExited(opts.sessionId, ClaudeCodeEngine.cliExitWaitMs))) {
      cliAlive = true;
      console.warn(`[claude] An earlier CLI process on session=${opts.sessionId} is still running after ${ClaudeCodeEngine.cliExitWaitMs}ms — not resuming its transcript`);
    }
    const plan = this.plan(opts, cliAlive);
    if (plan.path !== 'resume') { yield* this.run(opts, plan); return; }
    // Hold non-output events (init's model_resolved / switch notice) until the resumed query proves it
    // runs, so a failed resume can be retried fresh with nothing duplicated on the user's side.
    const attempt = this.run(opts, plan);
    const held: WsEvent[] = [];
    let live = false;
    let r: IteratorResult<WsEvent, 'resume-failed' | void>;
    try {
      while (!(r = await attempt.next()).done) {
        if (!live && !LIVE_EVENTS.has(r.value.type)) { held.push(r.value); continue; }
        if (!live) { live = true; yield* held; }
        yield r.value;
      }
    } finally {
      await attempt.return(undefined);
    }
    if (r.value !== 'resume-failed') { if (!live) yield* held; return; }
    console.warn(`[claude] Resume of ${plan.resumeId} failed before any output — retrying fresh in the same turn (session=${opts.sessionId})`);
    setClaudeResume(opts.sessionId, undefined);
    yield* this.run(opts, this.freshPlan(opts, plan.accountDir, 'fallback:resume-failed', plan.persist));
  }

  private freshPlan(opts: EngineStreamOpts, accountDir: string | null, path: TurnPath, persist?: RunPlan['persist']): RunPlan {
    return {
      path, accountDir,
      prompt: buildHistoryPrompt(opts.conversation, opts.contextBlock, opts.prompt),
      ...(persist ? { persist: { ...persist, sections: sectionHashes(opts.contextSections ?? { context: opts.contextBlock }), startedAt: Date.now() } } : {}),
    };
  }

  /** Resume vs fresh, and why — see claude-resume.ts. */
  private plan(opts: EngineStreamOpts, cliAlive: boolean): RunPlan {
    const accountDir = claudeAccountDir(opts.userEmail);
    if (!isResumeEnabled(opts.directives, opts.config)) return this.freshPlan(opts, accountDir, 'fresh');
    const configDir = claudeConfigDir(accountDir);
    const configDirHash = shortHash(configDir);
    const state = opts.sessionId ? getSession(opts.sessionId)?.claudeResume : undefined;
    const speaker = speakerKey(opts.uid, opts.userEmail);
    const persist = { configDirHash, speaker, markId: opts.conversation.at(-1)?.id, summaryKey: conversationSummaryKey(opts.conversation), sections: {}, startedAt: Date.now() };
    const decision = decideClaudeTurn({
      enabled: true, state, conversation: opts.conversation, configDirHash, speaker, cliAlive, conversationReset: opts.conversationReset,
      hasTranscript: (id) => !!findClaudeTranscript(id, configDir),
    });
    if (decision.path !== 'resume' || !state) return this.freshPlan(opts, accountDir, decision.path, persist);
    const sections = opts.contextSections ?? { context: opts.contextBlock };
    const hashes = sectionHashes(sections);
    return {
      path: 'resume', accountDir, resumeId: state.claudeSessionId,
      prompt: buildResumePrompt(opts.prompt, decision.unseen, buildContextDelta(state.sections, sections)),
      persist: { ...persist, sections: hashes, startedAt: state.startedAt },
      submitSections: sectionsAfterSubmit(state.sections, hashes),
    };
  }

  private async *run(opts: EngineStreamOpts, plan: RunPlan): AsyncGenerator<WsEvent, 'resume-failed' | void> {
    const { config, directives } = opts;
    const cwd = APP_ROOT;

    const fullPrompt = plan.prompt;
    const permMode = opts.onPermissionRequest ? 'default' : (config.permissionMode ?? 'acceptEdits');

    // SECURITY_ENFORCE: the effective profile at spawn decides env/tools/MCPs; the per-call gate re-reads it (enforce.ts).
    const guard = opts.security;
    const eff = guard?.current();
    const sdkEnv: Record<string, string> = eff ? buildAgentEnv(process.env, eff.profile) : {};
    if (!eff) for (const [k, v] of Object.entries(process.env)) {
      if (v !== undefined) sdkEnv[k] = v;
    }
    // Injected for the agent's own tools/scripts. Each is written under both the canonical
    // `SHRAGA_*` name and the legacy `UNCLAW_*` one — deployed workspaces still contain scripts
    // and extensions that read the legacy names, and we don't control those callers.
    sdkEnv.SHRAGA_USER_UID = sdkEnv.UNCLAW_USER_UID = opts.uid;
    if (opts.userEmail) sdkEnv.SHRAGA_USER_EMAIL = sdkEnv.UNCLAW_USER_EMAIL = opts.userEmail;
    sdkEnv.SHRAGA_SESSION_ID = sdkEnv.UNCLAW_SESSION_ID = opts.sessionId ?? '';
    Object.assign(sdkEnv, offloadEnv());
    // No CLAUDE.md, ever. settingSources:['project'] does NOT stop it: CC walks UP from cwd and loads
    // every ancestor's CLAUDE.md / .claude/CLAUDE.md as a "Project" file — with the app under $HOME
    // that is the host's ~/.claude/CLAUDE.md (an operator's personal dev rules: "plan first, let me
    // review"), which made the agent and its sub-agents stop for approval (feedox, 2026-10-25). The
    // agent's instructions are ours (system prompt + DATA_DIR/skills); a repo CLAUDE.md is dev docs.
    sdkEnv.CLAUDE_CODE_DISABLE_CLAUDE_MDS = '1';
    // Per-command wall-clock cap for the SDK's Bash tool. Without it a single unbounded command
    // (e.g. `curl` with no `-m` against an SSE endpoint that streams nothing) eats the whole turn and
    // the agent answers with "reads were interrupted". The model still sees `[exit 124]` + partial
    // output and can adapt, and may ask for up to BASH_MAX_TIMEOUT_MS on a legitimately slow command.
    // `??=` is not enough: the SDK treats a set-but-empty var as absent and silently falls back to
    // its own 120s, so a blank host value must take our default too — only a real value wins.
    const setIfBlank = (key: string, value: string) => { if (!sdkEnv[key]?.trim()) sdkEnv[key] = value; };
    setIfBlank('BASH_DEFAULT_TIMEOUT_MS', process.env.AGENT_SHELL_TIMEOUT_MS?.trim() || '60000');
    setIfBlank('BASH_MAX_TIMEOUT_MS', process.env.AGENT_SHELL_MAX_TIMEOUT_MS?.trim() || '600000');
    // The subscription login's claude.ai connectors (Gmail/Calendar/… and this agent's own MCP) load
    // regardless of settingSources; headless they are auth stubs or self-recursion. Our MCPs come from mcp-config.
    setIfBlank('ENABLE_CLAUDEAI_MCP_SERVERS', 'false');
    // Per-user subscription (workspace/users/<contactId>/.claude): run on that login, never the box's credentials.
    const accountDir = plan.accountDir;
    if (accountDir) applyClaudeAccount(sdkEnv, accountDir);
    else applyForcedSubscription(sdkEnv);
    // Which login this run is on (email/plan, never a token) — read lazily from the login's local
    // files, only once init proves the run is on a subscription login (see init below).
    let accountRefP: Promise<ClaudeAccountRef | undefined> | undefined;
    const accountRef = () => (accountRefP ??= claudeAccountRef(accountDir));
    let runAccount: ClaudeAccountRef | undefined;
    let sawInit = false;
    // Did THIS attempt produce model output? Gates both the resume-failure retry and saving the session id
    // (a resume that died on "No conversation found" can report a session id it never wrote a turn to).
    let sawOutput = false;
    let initModel: string | undefined;
    sdkEnv.INTERNAL_API_TOKEN = signInternalToken(opts.uid, opts.userEmail || 'unknown');

    if (eff && !allowsInternalToken(eff.profile)) delete sdkEnv.INTERNAL_API_TOKEN;

    const baseAllowed = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
    const withToolSearch = baseAllowed.includes('ToolSearch') ? baseAllowed : [...baseAllowed, 'ToolSearch'];
    // Enforced: built-in tool AVAILABILITY is the profile's (tools outside it are not in the model's context), and
    // auto-approval never widens it.
    const available = eff ? builtinTools(eff.profile) : 'all';
    // Same gate that mounts the jobs server below — the hooks steer to job_start only where it exists.
    const durableJobs = !!opts.sessionId && (available === 'all' || available.includes('Bash'));
    const allowedTools = available === 'all' ? withToolSearch : withToolSearch.filter((t) => available.includes(t));
    const maxTurns = directives.turns ?? config.maxTurns ?? 50;

    const options: Record<string, unknown> = {
      tools: available === 'all' ? { type: 'preset', preset: 'claude_code' } : available,
      env: sdkEnv,
      allowedTools,
      cwd,
      // Omitted = ALL sources, so the host's ~/.claude (CLAUDE.md, MCP instructions, hooks) rode into every
      // query — ~24k tokens re-written to cache per fresh turn on a dev box. Keep 'project': the deployment
      // ships its own .claude/skills (e.g. shraga-circles' video-ad-pipeline) that the agent relies on.
      settingSources: ['project'],
      permissionMode: permMode === 'bypassPermissions' ? 'acceptEdits' : permMode,
      maxTurns,
      includePartialMessages: true,
      // The SDK `skills` option is a context filter over skills the SDK DISCOVERS on disk
      // (.claude/skills / settingSources / plugins) — NOT our DATA_DIR/skills. We never point the
      // SDK at that dir, so these names match nothing: the built-in `Skill` tool turns on but can
      // resolve zero of them → every Skill{name} returns "Unknown skill". Our real skill path is
      // trigger-injection + the <available-skills> index (which tells the model to `Read` them).
      // Disable the phantom tool so the model doesn't waste a turn (and then fly blind) on it.
      skills: listSkills(),
      disallowedTools: ['Skill'],
      agents: loadAgents(),
      // Enforced: the profile gate runs FIRST on every tool call — hooks fire even for auto-approved `allowedTools`,
      // which never reach canUseTool.
      hooks: ((h) => (guard ? { ...h, PreToolUse: [{ hooks: [guard.hook()] }, ...(h.PreToolUse ?? [])] } : h))(buildHooks({ exemptBashCommand: opts.foregroundBashCommand, durableJobs })),
    };

    const userHandler = opts.onPermissionRequest;
    const destructiveHandler = opts.onDestructiveApproval;
    const questionHandler = opts.onUserQuestion;
    options['canUseTool'] = async (toolName: string, input: Record<string, unknown>) => {
      // Enforced: TurnGuard owns the file-path deny (tightened list); the legacy path list stays flag-OFF behavior only.
      const denied = guard && toolName !== 'Bash' ? null : checkSensitiveAccess(toolName, input);
      if (denied) return denied;
      // Enforced: the profile gate (re-reads the session floor) runs before ANY handler below, so an
      // `onPermissionRequest: allow` call site can never override a profile deny.
      if (guard) {
        const gate = guard.check(toolName, input, cwd);
        if (!gate.allow) return { behavior: 'deny' as const, message: gate.message };
      }
      if (toolName === 'AskUserQuestion') {
        const questions = (input.questions ?? []) as AskQuestion[];
        const answers = questionHandler
          ? await questionHandler(crypto.randomUUID(), questions).catch((err) => {
              console.error(`[claude] onUserQuestion failed:`, (err as Error)?.message);
              return null;
            })
          : null;
        if (answers && Object.keys(answers).length) {
          return { behavior: 'allow' as const, updatedInput: { ...input, questions, answers } };
        }
        const sentinel: QuestionAnswers = {};
        for (const q of questions) sentinel[q.question] = NO_INTERACTIVE_ANSWER;
        return { behavior: 'allow' as const, updatedInput: { ...input, questions, answers: sentinel } };
      }
      if (isDestructiveDataOp(toolName, input)) {
        const cmd = ((input.command ?? '') as string).slice(0, 100);
        console.log(`[security] Destructive data op requires approval: ${cmd}`);
        if (destructiveHandler) {
          const id = crypto.randomUUID();
          const result = await destructiveHandler(id, toolName, input);
          if (result.allow) return { behavior: 'allow' as const, updatedInput: input };
          return { behavior: 'deny' as const, message: 'User denied this destructive action' };
        }
        return { behavior: 'deny' as const, message: 'Destructive data operations require interactive approval.' };
      }
      if (userHandler) {
        const id = crypto.randomUUID();
        const result = await userHandler(id, toolName, input);
        if (result.allow) return { behavior: 'allow' as const, updatedInput: input };
        return { behavior: 'deny' as const, message: 'User denied this action' };
      }
      return { behavior: 'allow' as const, updatedInput: input };
    };

    // Always pass an explicit model — left unset, the CLI applies its own default
    // (observed: Opus 4.7), not what the UI's "Default" label promises.
    const requestedModel = directives.model || config.model || DEFAULT_MODEL;
    // Refuse a model that isn't ours instead of posting it to Anthropic. Substituting our default
    // silently would be the same billing lie in a different costume, so this ends the turn.
    if (!isOwnModel(requestedModel)) {
      const message =
        `Model "${requestedModel}" does not belong to the ${this.name} engine, so this run was stopped ` +
        `rather than billed to Anthropic under another provider's model name. Pick a Claude model, or ` +
        `request the engine that owns it (e.g. \`[engine:cursor,model:${requestedModel}]\`) and make sure ` +
        `that engine is registered on this server.`;
      console.error(`[claude] ${message}`);
      yield { type: 'error', message };
      return;
    }
    options['model'] = requestedModel;
    const thinkingMode = directives.thinking ?? config.thinking;
    if (thinkingMode) options['thinking'] = thinkingMode === 'enabled' ? { type: 'enabled' } : { type: thinkingMode };
    const effort = directives.effort ?? config.effort;
    if (effort) options['effort'] = effort;

    const userPrompt = config.systemPrompt || DEFAULT_USER_PROMPT;
    // Optional add-ons may append a system-prompt suffix decided off the opaque turn hints (add-on-owned
    // text). With nothing registered (CE's own state) this is '' and the prompt is byte-identical to before.
    const addonSuffix = getPromptSuffix(opts.turnHints);
    options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${addonSuffix ? `\n\n${addonSuffix}` : ''}`;
    if (opts.abortController) options['abortController'] = opts.abortController;
    if (plan.resumeId) options['resume'] = plan.resumeId;
    // Spawn the CLI in its OWN process group. The service manager signals the whole JOB on restart
    // (`launchctl kickstart -k`, `systemctl restart`), so an inherited process group means the child
    // dies instantly with SIGTERM — surfacing mid-reply as `exited with code 143` and making the
    // server's 90s drain (gracefulShutdown) protect nothing: it only ever waited for a turn that was
    // already dead. Detached, the signal reaches the server alone and the drain can finish the turn.
    // Teardown is unaffected: the SDK still kills the child on abort/close and on process exit.
    options['spawnClaudeCodeProcess'] = (cfg: Parameters<typeof spawnDetached>[0]) => {
      const child = spawnDetached(cfg);
      trackCliExit(opts.sessionId, child);
      return child;
    };

    // Passed as a FILE, never as `options.mcpServers` — the SDK would put the whole config (every MCP
    // server's credentials) on the CLI's argv, where `ps` / `/proc` / journald expose it. See
    // writeMcpConfigFile. Setting both would re-add the argv copy, so it's one or the other.
    let mcpConfigFile: { path: string; cleanup: () => void } | undefined;
    // Enforced: servers outside profile.mcps never reach the file, so the CLI never starts them.
    const mcpServers = eff ? filterMcpServers(opts.mcpServers, eff.profile) : opts.mcpServers;
    if (mcpServers && Object.keys(mcpServers).length > 0) {
      mcpConfigFile = writeMcpConfigFile(mcpServers);
      options['extraArgs'] = { ...(options['extraArgs'] as Record<string, string> | undefined), 'mcp-config': mcpConfigFile.path };
    }
    // `escalate` is an in-process SDK server (no credentials): `options.mcpServers` hands the CLI only its name.
    if (guard && eff && allowsEscalate(eff.profile)) {
      const { principal } = guard.options;
      const lastUser = opts.conversation.findLast((m) => m.role === 'user');
      const excerpt = lastUser?.blocks.map((b) => (b.type === 'text' ? b.text : '')).filter(Boolean).join('\n') || opts.prompt;
      const server = escalateMcpServer({
        principal, role: () => guard.current().role, sessionId: opts.sessionId, excerpt,
        channel: [principal.kind, principal.attrs.lane, opts.context?.source].filter(Boolean).join(':'),
      });
      options['mcpServers'] = { [server.name]: server };
    }
    // `share_file` (in-process, like escalate): full-access turns only — flag OFF every turn is full.
    if (!eff || allowsShare(eff.profile)) {
      const server = shareMcpServer();
      options['mcpServers'] = { ...(options['mcpServers'] as object | undefined), [server.name]: server };
      options['allowedTools'] = [...allowedTools, SHARE_TOOL_ID];
    }
    // Durable background jobs (in-process, like share_file): server-owned children that outlive the
    // turn, the CLI process, and the bg-wait budget, with wake-on-exit (background-jobs.ts). Only
    // where the profile grants Bash — job_start is arbitrary shell — and only for a real session id
    // (wake reports need a session to land in).
    if (durableJobs) {
      // The job store scrubs credentials from children, but here the turn's own Bash already sees this
      // env (profile-filtered by buildAgentEnv). Scrubbing it made job_start strictly weaker than Bash, and
      // the hook steers long scripts INTO job_start: a nested agent died "No provider key found" (2026-09-24),
      // AGF Post-Daily lost MIXPANEL_API_SECRET_AGF/GOOGLE_SERVICE_ACCOUNT (2026-10-20). Hand the job the
      // turn's env, minus PORT (the server's own; a job honouring it would shadow us).
      const jobEnv = Object.fromEntries(Object.entries(sdkEnv).filter(([k, v]) => v && k !== 'PORT'));
      const server = jobsMcpServer({ sessionId: opts.sessionId!, uid: opts.uid, userEmail: opts.userEmail, cwd, env: jobEnv });
      options['mcpServers'] = { ...(options['mcpServers'] as object | undefined), [server.name]: server };
      options['allowedTools'] = [...((options['allowedTools'] as string[] | undefined) ?? allowedTools), ...JOBS_TOOL_IDS];
    }

    const mcpNames = mcpServers ? Object.keys(mcpServers) : [];
    const activeModel = (options['model'] as string) || 'default';
    const directivesTag = Object.keys(directives).length ? ` directives=${JSON.stringify(directives)}` : '';
    console.log(`[claude] Starting query user=${opts.uid} session=${opts.sessionId ?? 'new'} model=${activeModel} perms=${config.permissionMode} mcps=[${mcpNames.join(',')}]${directivesTag} cwd=${cwd}`);
    const startTime = Date.now();
    const elapsed = () => `${Date.now() - startTime}ms`;

    const sessionKey = opts.sessionId ?? crypto.randomUUID();
    const hasAttachments = opts.attachments && opts.attachments.length > 0;
    const hasLegacyImages = opts.images && opts.images.length > 0;
    const source = hasAttachments
      ? buildAttachmentPrompt(fullPrompt, opts.attachments!, sessionKey)
      : hasLegacyImages
        ? buildLegacyImagePrompt(fullPrompt, opts.images!, sessionKey)
        : buildTextPrompt(fullPrompt, sessionKey);
    // Streaming input, held open until the turn is over. A string prompt runs the CLI one-shot: it exits after
    // the first result and kills `run_in_background` jobs ~5s later. Open, the job keeps running and its
    // task_notification gets the model a follow-up turn, as interactive Claude Code does. The uuid is echoed on
    // the result that answers THIS prompt (`user_message_uuids`), telling it apart from a resumed session's stale one.
    const promptUuid = crypto.randomUUID();
    let closeInput!: () => void;
    const inputClosed = new Promise<void>((r) => { closeInput = r; });
    const prompt = (async function* () {
      for await (const msg of source) yield { ...msg, uuid: promptUuid };
      await inputClosed;
    })();

    const q = query({ prompt, options: options as any });
    // The CLI writes this prompt to the transcript before any output: from here a cancel can't un-deliver it.
    if (plan.submitSections && opts.sessionId) setClaudeResume(opts.sessionId, undefined, { sections: plan.submitSections });

    let lastSessionId = '';
    let messageCount = 0;
    let textDeltaCount = 0;
    let turnCount = 0;
    const pendingToolUses = new Map<string, { tool: string; input: any }>();
    const streamedToolUseIds = new Set<string>();
    const streamingToolInputsByIndex = new Map<number, { id: string; name: string; inputJson: string }>();
    const outstandingTasks = new Set<string>();
    const foregroundTasks = new Set<string>();

    const iter = q[Symbol.asyncIterator]();
    let pendingNext: Promise<IteratorResult<any>> | null = null;
    let waitingForBg = false;
    // Grace hold: no task outstanding, but one finished mid-turn and the CLI may still run its follow-up turn.
    let graceStop: string | null = null;
    let notifiedBeforeAnswer = false;
    // Has the result answering THIS prompt arrived? Later results are the model's follow-ups on bg-task completions.
    let answered = false;
    let turnBreak = false;
    let bgTimer: Promise<'__bgtimeout'> | null = null;
    let bgTimerHandle: ReturnType<typeof setTimeout> | null = null;
    let bgHeartbeat: ReturnType<typeof setInterval> | null = null;
    const clearBgTimer = () => { if (bgTimerHandle) { clearTimeout(bgTimerHandle); bgTimerHandle = null; } bgTimer = null; if (bgHeartbeat) { clearInterval(bgHeartbeat); bgHeartbeat = null; } };

    try {
      while (true) {
        if (!pendingNext) pendingNext = iter.next();
        let res: IteratorResult<any>;
        if (waitingForBg) {
          if (!bgTimer) bgTimer = new Promise((r) => { bgTimerHandle = setTimeout(() => r('__bgtimeout'), graceStop ? BG_FOLLOWUP_GRACE_MS : BG_TASK_MAX_WAIT_MS); });
          const raced = await Promise.race([pendingNext, bgTimer]);
          if (raced === '__bgtimeout' && graceStop) {
            console.log(`[claude] No follow-up turn within grace — ending (${elapsed()})`);
            yield { type: 'done', sessionId: lastSessionId, stopReason: graceStop };
            return;
          }
          // The follow-up turn started: it gets the full bg wait, not the grace window. CLI housekeeping
          // (status/rate-limit events) is not a turn — letting it cancel the grace held every such turn for 15 min.
          const v = raced === '__bgtimeout' ? undefined : (raced as IteratorResult<any>).value;
          const housekeeping = v?.type === 'rate_limit_event' || (v?.type === 'system' && !String(v.subtype).startsWith('task_'));
          if (graceStop && !housekeeping) { graceStop = null; clearBgTimer(); }
          if (raced === '__bgtimeout') {
            console.warn(`[claude] Background-task wait timed out (${elapsed()})`);
            yield { type: 'done', sessionId: lastSessionId, stopReason: 'end_turn' };
            return;
          }
          res = raced as IteratorResult<any>;
        } else {
          res = await pendingNext;
        }
        pendingNext = null;
        if (res.done) break;
        const m = res.value as any;
        messageCount++;

        if (m.session_id && !lastSessionId) {
          lastSessionId = m.session_id;
          console.log(`[claude] Got session_id=${lastSessionId} at msg #${messageCount} (${elapsed()})`);
        }

        if (m.type === 'system' && m.subtype === 'init') {
          // Which credentials the SDK actually resolved (env key vs claude.ai login) — a process-global
          // fact surfaced per-run so logs (incl. headless/scheduler) answer "subscription or API key?".
          // Runtime `apiKeySource` is the RESOLVED source string (verified against the SDK, not its .d.ts
          // enum): a named env var ("ANTHROPIC_API_KEY") ⇒ API key; "none"/"oauth" ⇒ stored login ⇒ sub.
          const src = m.apiKeySource as string | undefined;
          const authSource: 'subscription' | 'api-key' | undefined =
            src == null ? undefined : src === 'none' || src === 'oauth' ? 'subscription' : 'api-key';
          if (authSource) console.log(`[claude] Auth: ${authSource} (apiKeySource=${src}) account=${accountDir ? opts.userEmail!.trim().toLowerCase() : 'default'}`);
          sawInit = true;
          // A routed dir is a login by construction (applyClaudeAccount strips every key); the box
          // default only counts when the SDK says it resolved a login, not an API key.
          if ((authSource ?? (accountDir ? 'subscription' : undefined)) === 'subscription') runAccount = await accountRef();
          if (m.model) {
            initModel = m.model;
            console.log(`[claude] Init model=${m.model}${m.model !== options['model'] ? ` (requested ${options['model']})` : ''}`);
            // If the user explicitly asked to switch models via a [directive], announce the change
            // inline so the response confirms the switch took effect.
            const sw = resolveModelSwitch({
              requested: opts.directives.model ? m.model : undefined,
              current: m.model,
              prior: opts.sessionId ? getSessionModel(opts.sessionId) : undefined,
            });
            if (sw.notice) yield { type: 'text_delta', text: sw.notice };
            if (opts.sessionId) setSessionModel(opts.sessionId, m.model, this.name, runAccount);
            // Live ground-truth so the header pill confirms the actually-resolved model mid-turn
            // (catches inline overrides like [opus] and silent rate-limit fallbacks) instead of
            // only updating on session reload.
            yield { type: 'model_resolved', sessionId: opts.sessionId ?? '', model: m.model, engine: this.name, ...(runAccount ? { account: runAccount } : {}) };
          }
          const servers = m.mcp_servers;
          if (Array.isArray(servers) && servers.length > 0) {
            console.log(`[claude] MCP: ${servers.map((s: any) => `${s.name}:${s.status}`).join(', ')}`);
          }
          continue;
        }

        if (m.type === 'system' && m.subtype === 'task_started') {
          // The CLI also reports a FOREGROUND Bash (>~2s) as a task. Its result is already in the turn, so it gets
          // no follow-up — counting it armed the follow-up hold and kept every such turn open for 15 min.
          if (m.is_backgrounded === false) { foregroundTasks.add(m.task_id); continue; }
          outstandingTasks.add(m.task_id);
          console.log(`[claude] Background task started: ${m.task_id} (${outstandingTasks.size} pending) (${elapsed()})`);
          continue;
        }
        if (m.type === 'system' && m.subtype === 'task_notification') {
          if (foregroundTasks.delete(m.task_id)) continue;
          outstandingTasks.delete(m.task_id);
          if (!answered) notifiedBeforeAnswer = true;
          console.log(`[claude] Background task ${m.status}: ${m.task_id} (${outstandingTasks.size} pending) (${elapsed()})`);
          // Not the end of the turn: the CLI now runs a follow-up turn on the result, whose own result ends it
          // (still bounded by the bg timer).
          continue;
        }

        if (m.type === 'system') continue;
        // Free, always-current limit status from the agent's own inference calls — the only signal
        // that still works while the usage endpoint is throttling this account.
        if (m.type === 'rate_limit_event') { claudeUsageFor(accountDir).observeRateLimit(m.rate_limit_info); continue; }

        if (m.type === 'result') {
          // A resumed session first replays a pending task_notification as its own zero-turn result, before
          // our prompt runs — ending on it drops the user's message. Skip any result that is not ours until
          // ours arrives (older CLIs don't echo the uuid: then only a zero-turn result counts as stale).
          const echoed = m.user_message_uuids ?? (m.user_message_uuid ? [m.user_message_uuid] : undefined);
          if (!answered && !m.is_error && (echoed ? !echoed.includes(promptUuid) : !m.num_turns)) {
            notifiedBeforeAnswer = false; // that notification belonged to the stale turn
            console.log(`[claude] Skipping result of an earlier turn: turns=${m.num_turns ?? 0} result_index=${m.result_index ?? '?'} (${elapsed()})`);
            continue;
          }
          answered = true;
          lastSessionId = m.session_id || lastSessionId;
          const sdkTurns = m.num_turns ?? 0;
          const raw = m.subtype ?? 'unknown';
          const sub = raw === 'error_max_turns' || (raw === 'end_turn' && sdkTurns >= maxTurns) ? 'max_turns_reached' : raw;
          console.log(`[claude] Result: subtype=${m.subtype}→${sub} session=${lastSessionId} turns=${sdkTurns}/${maxTurns} cost=$${m.total_cost_usd?.toFixed(4) ?? '?'} msgs=${messageCount} deltas=${textDeltaCount} (${elapsed()})`);
          logCacheUsage(m.usage, activeModel, plan.path);
          if (outstandingTasks.size > 0) {
            if (!waitingForBg) {
              waitingForBg = true; clearBgTimer();
              console.log(`[claude] Holding stream for ${outstandingTasks.size} bg tasks (${elapsed()})`);
              // The hold is invisible to the user (their next message queues behind it), so beat
              // every minute — otherwise a stuck task reads as a dead agent with nothing in the log.
              bgHeartbeat = setInterval(() => console.log(`[claude] Still holding for ${outstandingTasks.size} bg task(s): ${[...outstandingTasks].join(',')} (${elapsed()})`), BG_HEARTBEAT_MS);
              bgHeartbeat.unref?.();
            }
            turnBreak = textDeltaCount > 0;
            continue;
          }
          // A task that finished BEFORE this result is already out of the set, yet the CLI queues a follow-up
          // turn on its notification — ending here drops it (the model said "will report" and never did).
          const firstResult = !waitingForBg && !graceStop;
          if (firstResult && (m.queued_turn_count > 0 || notifiedBeforeAnswer) && !m.is_error) {
            waitingForBg = true; clearBgTimer(); graceStop = sub;
            console.log(`[claude] Holding for follow-up turn on a bg task that finished mid-turn (queued=${m.queued_turn_count ?? '?'}) (${elapsed()})`);
            turnBreak = textDeltaCount > 0;
            continue;
          }
          // The SDK reports `subtype: 'success'` even when the API call failed (e.g. an org spend
          // cap → HTTP 400) — the only tells are `is_error` / `api_error_status`. Trusting the
          // subtype makes an all-turns-failing agent look healthy: schedules store lastRun 'ok',
          // the failure notifier stays silent, and Slack simply gets nothing. Surface it as an
          // error so the run fails loudly.
          // `error_max_turns` also sets is_error but is a benign stop reason we already model.
          if ((m.is_error || m.api_error_status) && sub !== 'max_turns_reached') {
            const detail = typeof m.result === 'string' && m.result ? m.result : `api_error_status=${m.api_error_status ?? '?'}`;
            // The CLI's reason ("No conversation found with session ID …") rides in `errors`, not `result`.
            const reasons = Array.isArray(m.errors) && m.errors.length ? m.errors.join('; ') : detail;
            if (plan.resumeId && !sawOutput && isResumeFailure(reasons)) {
              console.error(`[claude] Resume error result (subtype=${raw}) — ${reasons}`);
              return 'resume-failed';
            }
            // A usage/rate-limit failure is about ONE login's quota — name it, or a routed user's own
            // exhausted Pro plan reads as the shared agent subscription being out.
            const limitHit = m.api_error_status === 429 || /\b(usage|rate.?limit(ed)?|hit your limit)\b/i.test(detail);
            const account = limitHit ? runAccount ?? (!sawInit && accountDir ? await accountRef() : undefined) : undefined;
            console.error(`[claude] API error result — ${detail}`);
            yield { type: 'error', message: `Claude API error${account ? ` (${account.email})` : ''}: ${detail}` };
            return;
          }
          if (textDeltaCount === 0) {
            console.warn(`[claude] Empty response — result keys: ${Object.keys(m).join(',')}`);
          }
          yield { type: 'done', sessionId: lastSessionId, stopReason: sub };
          return;
        }

        if (m.type === 'stream_event') {
          const event = m.event;
          if (event?.type === 'content_block_delta' && event.delta?.type === 'thinking_delta') {
            sawOutput = true;
            yield { type: 'thinking_delta', text: event.delta.thinking };
          }
          if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
            textDeltaCount++;
            sawOutput = true;
            if (turnBreak) { turnBreak = false; yield { type: 'text_delta', text: '\n\n' }; }
            yield { type: 'text_delta', text: event.delta.text };
          }
          if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
            const { id, name } = event.content_block;
            const idx = event.index ?? -1;
            streamedToolUseIds.add(id);
            streamingToolInputsByIndex.set(idx, { id, name, inputJson: '' });
            yield { type: 'tool_use', tool: name, toolUseId: id, input: {} };
          }
          if (event?.type === 'content_block_delta' && event.delta?.type === 'input_json_delta') {
            const entry = streamingToolInputsByIndex.get(event.index ?? -1);
            if (entry) {
              entry.inputJson += event.delta.partial_json ?? '';
              yield { type: 'tool_use_input', toolUseId: entry.id, input: entry.inputJson };
            }
          }
          continue;
        }

        if (m.type === 'assistant' && Array.isArray(m.message?.content)) {
          turnCount++;
          sawOutput = true;
          for (const block of m.message.content) {
            if (block.type === 'tool_use') {
              pendingToolUses.set(block.id, { tool: block.name, input: block.input });
              if (streamedToolUseIds.has(block.id)) {
                yield { type: 'tool_use_input', toolUseId: block.id, input: block.input };
              } else {
                yield { type: 'tool_use', tool: block.name, toolUseId: block.id, input: block.input };
              }
            }
          }
          streamingToolInputsByIndex.clear();
          continue;
        }

        if (m.type === 'user' && Array.isArray(m.message?.content)) {
          for (const block of m.message.content) {
            if (block.type === 'tool_result') {
              const contentArr = Array.isArray(block.content) ? block.content : [];
              const output = contentArr.length > 0
                ? contentArr.filter((c: any) => c.type === 'text').map((c: any) => c.text ?? '').join('')
                : String(block.content ?? '');
              // `is_error` is the ONLY signal that a tool failed — the text alone is indistinguishable
              // from a successful result. Scheduled `bash` tasks rely on it to notice a non-zero exit.
              yield { type: 'tool_result', toolUseId: String(block.tool_use_id), output, isError: block.is_error === true };

              let foundImage = false;
              for (const c of contentArr) {
                if (c.type === 'image') {
                  foundImage = true;
                  if (c.source?.type === 'base64' && c.source?.data) {
                    yield { type: 'tool_result_image', toolUseId: String(block.tool_use_id), dataUrl: `data:${c.source.media_type || 'image/png'};base64,${c.source.data}` };
                  } else if (c.data && c.mimeType) {
                    yield { type: 'tool_result_image', toolUseId: String(block.tool_use_id), dataUrl: `data:${c.mimeType};base64,${c.data}` };
                  }
                }
              }
              if (!foundImage && output.includes('"dataUrl":"data:')) {
                try {
                  const parsed = JSON.parse(output);
                  if (parsed.dataUrl?.startsWith('data:')) {
                    yield { type: 'tool_result_image', toolUseId: String(block.tool_use_id), dataUrl: parsed.dataUrl };
                  }
                } catch (e) { console.warn('[claude] Failed to parse dataUrl from tool result text', e); }
              }

              const pending = pendingToolUses.get(String(block.tool_use_id));
              if (pending?.tool === 'mcp__mcp-slack-use__post_slack_message') {
                try {
                  const parsed = typeof output === 'string' ? JSON.parse(output) : output;
                  const ts = parsed?.ts || parsed?.preview?.ts;
                  const inp = pending.input as any;
                  const channel = inp?.body?.channel || inp?.channel_id || inp?.channel;
                  const sid = opts.sessionId || lastSessionId;
                  if (ts && channel && sid) {
                    const session = getSession(sid);
                    registerProactiveMessage(channel, ts, sid, session?.title || sid);
                  }
                } catch (err) { console.warn('[claude] Failed to track proactive message:', (err as Error).message); }
              }
              if (pending?.tool === 'mcp__mcp-slack-use__post_slack_poll' || pending?.tool === 'mcp__mcp-slack-use__post_slack_card') {
                try {
                  const parsed = typeof output === 'string' ? JSON.parse(output) : output;
                  const body = ((pending.input as any)?.body ?? {}) as Record<string, any>;
                  const sid = opts.sessionId || lastSessionId;
                  if (parsed?.ok && parsed.pollId && parsed.ts && parsed.channel && sid) {
                    registerPoll({
                      pollId: parsed.pollId, channel: parsed.channel, ts: parsed.ts, title: String(parsed.title ?? body.title ?? ''),
                      options: parsed.options ?? body.options ?? [], kind: parsed.kind ?? 'poll',
                      multi: parsed.multi, targetUser: parsed.targetUser,
                      deadlineMinutes: typeof body.deadline_minutes === 'number' ? body.deadline_minutes : undefined,
                      quorum: typeof body.quorum === 'number' ? body.quorum : undefined,
                      sessionId: sid, uid: opts.uid, userEmail: opts.userEmail,
                      cardBlocks: Array.isArray(parsed.blocks) ? parsed.blocks : undefined,
                    });
                  }
                } catch (err) { console.warn('[claude] Failed to register poll:', (err as Error).message); }
              }
              pendingToolUses.delete(String(block.tool_use_id));
            }
          }
          continue;
        }
      }
    } catch (err: any) {
      if (err.name === 'AbortError' || opts.abortController?.signal.aborted) {
        console.log(`[claude] Stream aborted after ${messageCount} msgs (${elapsed()})`);
        return;
      }
      console.error(`[claude] Error after ${messageCount} msgs (${elapsed()}):`, err.message || err);
      if (plan.resumeId && !sawOutput && isResumeFailure(err.message || String(err))) return 'resume-failed';
      yield { type: 'error', message: err.message || String(err) };
      return;
    } finally {
      clearBgTimer();
      closeInput();
      // The CLI has read the file by now (it loads MCP config at startup); holding it any longer just
      // widens the window in which the credentials sit on disk.
      mcpConfigFile?.cleanup();
      // Save the mapping once this attempt really ran a turn (incl. an aborted one — the transcript holds it).
      if (plan.persist && opts.sessionId && lastSessionId && sawOutput) {
        setClaudeResume(opts.sessionId, { ...plan.persist, claudeSessionId: lastSessionId, ...(initModel ? { model: initModel } : {}) });
        if (plan.resumeId && plan.resumeId !== lastSessionId) console.warn(`[claude] Resume returned a new session id ${lastSessionId} (was ${plan.resumeId})`);
      }
    }

    const inferredReason = turnCount >= maxTurns ? 'max_turns_reached' : 'end_turn';
    console.warn(`[claude] Stream ended without result. session=${lastSessionId || 'none'} msgs=${messageCount} turns=${turnCount}/${maxTurns} (${elapsed()})`);
    if (lastSessionId) {
      yield { type: 'done', sessionId: lastSessionId, stopReason: inferredReason };
    }
  }
}