/**
 * Stream adapter: bridges the Claude Agent SDK into SF's streamSimple contract.
 *
 * The SDK runs the full agentic loop (multi-turn, tool execution, compaction)
 * in one call. This adapter translates the SDK's streaming output into
 * AssistantMessageEvents for TUI rendering, then strips tool-call blocks from
 * the final AssistantMessage so SF's agent loop doesn't try to dispatch them.
 */
import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { EventStream } from "@singularity-forge/ai";
import { showInterviewRound } from "../shared/tui.js";
import {
	mapUsage,
	PartialMessageBuilder,
	ZERO_USAGE,
} from "./partial-builder.js";

const OTHER_OPTION_LABEL = "None of the above";
const SENSITIVE_FIELD_PATTERN =
	/(password|passphrase|secret|token|api[_\s-]*key|private[_\s-]*key|credential)/i;
// ---------------------------------------------------------------------------
// Stream factory
// ---------------------------------------------------------------------------
/**
 * Construct an AssistantMessageEventStream using EventStream directly.
 * (The class itself is only re-exported as a type from the @singularity-forge/ai barrel.)
 */
function createAssistantStream() {
	return new EventStream(
		(event) => event.type === "done" || event.type === "error",
		(event) => {
			if (event.type === "done") return event.message;
			if (event.type === "error") return event.error;
			throw new Error("Unexpected event type for final result");
		},
	);
}
export function getResultErrorMessage(result) {
	if (
		"errors" in result &&
		Array.isArray(result.errors) &&
		result.errors.length > 0
	) {
		return result.errors.join("; ");
	}
	if (
		"result" in result &&
		typeof result.result === "string" &&
		result.result.trim().length > 0
	) {
		return result.result.trim();
	}
	return result.subtype === "success"
		? "claude_code_request_failed"
		: result.subtype;
}
// ---------------------------------------------------------------------------
// Claude binary resolution
// ---------------------------------------------------------------------------
let cachedClaudePath = null;
export function getClaudeLookupCommand(platform = process.platform) {
	return platform === "win32" ? "where claude" : "which claude";
}
export function parseClaudeLookupOutput(output) {
	return output.toString().trim().split(/\r?\n/)[0] ?? "";
}
/**
 * Resolve the path to the system-installed `claude` binary.
 * The SDK defaults to a bundled cli.js which doesn't exist when
 * installed as a library — we need to point it at the real CLI.
 */
function getClaudePath() {
	if (cachedClaudePath) return cachedClaudePath;
	try {
		cachedClaudePath = parseClaudeLookupOutput(
			execSync(getClaudeLookupCommand(), { timeout: 5_000, stdio: "pipe" }),
		);
	} catch {
		cachedClaudePath = "claude"; // fall back to PATH resolution
	}
	return cachedClaudePath;
}
// ---------------------------------------------------------------------------
// Prompt construction
// ---------------------------------------------------------------------------
/**
 * Extract text content from a single message regardless of content shape.
 */
function extractMessageText(msg) {
	if (typeof msg.content === "string") return msg.content;
	if (Array.isArray(msg.content)) {
		const textParts = msg.content
			.filter((part) => part.type === "text")
			.map((part) => part.text ?? part.thinking ?? "");
		if (textParts.length > 0) return textParts.join("\n");
	}
	return "";
}
/**
 * Build a full conversational prompt from SF's context messages.
 *
 * Previous behaviour sent only the last user message, making every SDK
 * call effectively stateless. This version serialises the complete
 * conversation history (system prompt + all user/assistant turns) so
 * Claude Code has full context for multi-turn continuity.
 *
 * History is wrapped in XML-tag structure rather than `[User]`/`[Assistant]`
 * bracket headers. Bracket headers read to the model as an in-context
 * demonstration of how turns are delimited, causing it to fabricate fake
 * user turns in its own output. XML tags read as document structure and
 * don't get mirrored in free text.
 */
export function buildPromptFromContext(context) {
	const hasContent =
		Boolean(context.systemPrompt) ||
		context.messages.some((m) => extractMessageText(m));
	if (!hasContent) return "";
	const parts = [
		"Respond only to the final user message below. " +
			"Do not emit <user_message>, <assistant_message>, or <prior_system_context> tags in your response.",
	];
	if (context.systemPrompt) {
		parts.push(
			`<prior_system_context>\n${context.systemPrompt}\n</prior_system_context>`,
		);
	}
	const turns = [];
	for (const msg of context.messages) {
		const text = extractMessageText(msg);
		if (!text) continue;
		const tag =
			msg.role === "user"
				? "user_message"
				: msg.role === "assistant"
					? "assistant_message"
					: "system_message";
		turns.push(`<${tag}>\n${text}\n</${tag}>`);
	}
	if (turns.length > 0) {
		parts.push(
			`<conversation_history>\n${turns.join("\n")}\n</conversation_history>`,
		);
	}
	return parts.join("\n\n");
}
function stripDataUriPrefix(value) {
	const commaIndex = value.indexOf(",");
	if (value.startsWith("data:") && commaIndex !== -1) {
		return value.slice(commaIndex + 1);
	}
	return value;
}
function inferMimeTypeFromDataUri(value) {
	const match = /^data:([^;,]+);base64,/.exec(value);
	return match?.[1] ?? null;
}
export function extractImageBlocksFromContext(context) {
	const imageBlocks = [];
	for (const msg of context.messages) {
		if (msg.role !== "user" || !Array.isArray(msg.content)) continue;
		for (const part of msg.content) {
			if (!part || typeof part !== "object") continue;
			const block = part;
			if (block.type !== "image" || typeof block.data !== "string") continue;
			const mimeType =
				typeof block.mimeType === "string" && block.mimeType.length > 0
					? block.mimeType
					: inferMimeTypeFromDataUri(block.data);
			if (!mimeType) continue;
			imageBlocks.push({
				type: "image",
				source: {
					type: "base64",
					media_type: mimeType,
					data: stripDataUriPrefix(block.data),
				},
			});
		}
	}
	return imageBlocks;
}
export function buildSdkQueryPrompt(
	context,
	textPrompt = buildPromptFromContext(context),
) {
	const imageBlocks = extractImageBlocksFromContext(context);
	if (imageBlocks.length === 0) {
		return textPrompt;
	}
	const content = [...imageBlocks];
	if (textPrompt) {
		content.push({ type: "text", text: textPrompt });
	}
	const sdkMessage = {
		type: "user",
		message: { role: "user", content },
		parent_tool_use_id: null,
	};
	return (async function* () {
		yield sdkMessage;
	})();
}
// ---------------------------------------------------------------------------
// Error helper
// ---------------------------------------------------------------------------
function makeErrorMessage(model, errorMsg) {
	return {
		role: "assistant",
		content: [{ type: "text", text: `Claude Code error: ${errorMsg}` }],
		api: "anthropic-messages",
		provider: "claude-code",
		model,
		usage: { ...ZERO_USAGE },
		stopReason: "error",
		errorMessage: errorMsg,
		timestamp: Date.now(),
	};
}
/**
 * Generator exhaustion without a terminal result means the SDK stream was
 * interrupted mid-turn. Surface it as an error so downstream recovery logic
 * can classify and retry it instead of treating it as a clean completion.
 */
export function makeStreamExhaustedErrorMessage(model, lastTextContent) {
	const errorMsg = "stream_exhausted_without_result";
	const message = makeErrorMessage(model, errorMsg);
	if (lastTextContent) {
		message.content = [{ type: "text", text: lastTextContent }];
	}
	return message;
}
function readElicitationChoices(options) {
	if (!Array.isArray(options)) return [];
	return options
		.map((option) =>
			typeof option?.const === "string"
				? option.const
				: typeof option?.title === "string"
					? option.title
					: "",
		)
		.filter((option) => option.length > 0);
}
export function parseAskUserQuestionsElicitation(request) {
	if (request.mode && request.mode !== "form") return null;
	const properties = request.requestedSchema?.properties;
	if (!properties || typeof properties !== "object") return null;
	const questions = [];
	for (const [fieldId, rawField] of Object.entries(properties)) {
		if (fieldId.endsWith("__note")) continue;
		if (!rawField || typeof rawField !== "object") return null;
		const header =
			typeof rawField.title === "string" && rawField.title.length > 0
				? rawField.title
				: fieldId;
		const question =
			typeof rawField.description === "string" ? rawField.description : "";
		if (rawField.type === "array") {
			const options = readElicitationChoices(rawField.items?.anyOf).map(
				(label) => ({ label, description: "" }),
			);
			if (options.length === 0) return null;
			questions.push({
				id: fieldId,
				header,
				question,
				options,
				allowMultiple: true,
			});
			continue;
		}
		if (rawField.type === "string") {
			const noteFieldId = Object.hasOwn(properties, `${fieldId}__note`)
				? `${fieldId}__note`
				: undefined;
			const options = readElicitationChoices(rawField.oneOf)
				.filter((label) => label !== OTHER_OPTION_LABEL)
				.map((label) => ({ label, description: "" }));
			if (options.length === 0) return null;
			questions.push({
				id: fieldId,
				header,
				question,
				options,
				noteFieldId,
			});
			continue;
		}
		return null;
	}
	return questions.length > 0 ? questions : null;
}
function isSecureElicitationField(requestMessage, fieldId, field) {
	if (field.format === "password") return true;
	if (field.writeOnly === true) return true;
	const rawField = field;
	if (rawField.sensitive === true || rawField["x-sensitive"] === true)
		return true;
	const haystack = [
		requestMessage,
		fieldId.replace(/[_-]+/g, " "),
		typeof field.title === "string" ? field.title : "",
		typeof field.description === "string" ? field.description : "",
	]
		.join(" ")
		.toLowerCase();
	return SENSITIVE_FIELD_PATTERN.test(haystack);
}
export function parseTextInputElicitation(request) {
	if (request.mode && request.mode !== "form") return null;
	const schema = request.requestedSchema;
	const fieldsSource =
		schema?.properties && typeof schema.properties === "object"
			? schema.properties
			: schema?.keys && typeof schema.keys === "object"
				? schema.keys
				: undefined;
	if (!fieldsSource) return null;
	const requiredSet = new Set(
		Array.isArray(request.requestedSchema?.required)
			? request.requestedSchema.required.filter(
					(value) => typeof value === "string",
				)
			: [],
	);
	const fields = [];
	for (const [fieldId, field] of Object.entries(fieldsSource)) {
		if (!field || typeof field !== "object") continue;
		if (field.type !== "string") continue;
		if (Array.isArray(field.oneOf) && field.oneOf.length > 0) continue;
		fields.push({
			id: fieldId,
			title:
				typeof field.title === "string" && field.title.length > 0
					? field.title
					: fieldId,
			description:
				typeof field.description === "string" ? field.description : "",
			required: requiredSet.has(fieldId),
			secure: isSecureElicitationField(request.message, fieldId, field),
		});
	}
	return fields.length > 0 ? fields : null;
}
export function roundResultToElicitationContent(questions, result) {
	const content = {};
	for (const question of questions) {
		const answer = result.answers[question.id];
		if (!answer) continue;
		if (question.allowMultiple) {
			const selected = Array.isArray(answer.selected)
				? answer.selected
				: [answer.selected];
			content[question.id] = selected;
			continue;
		}
		const selected = Array.isArray(answer.selected)
			? (answer.selected[0] ?? "")
			: answer.selected;
		content[question.id] = selected;
		if (
			question.noteFieldId &&
			selected === OTHER_OPTION_LABEL &&
			answer.notes.trim().length > 0
		) {
			content[question.noteFieldId] = answer.notes.trim();
		}
	}
	return content;
}
function buildElicitationPromptTitle(request, question) {
	const parts = [
		request.serverName ? `[${request.serverName}]` : "",
		question.header,
		question.question,
	].filter((part) => part && part.trim().length > 0);
	return parts.join("\n\n");
}
async function promptElicitationWithDialogs(request, questions, ui, signal) {
	const content = {};
	for (const question of questions) {
		const title = buildElicitationPromptTitle(request, question);
		if (question.allowMultiple) {
			const selected = await ui.select(
				title,
				question.options.map((option) => option.label),
				{
					allowMultiple: true,
					signal,
				},
			);
			if (Array.isArray(selected)) {
				if (selected.length === 0) return { action: "cancel" };
				content[question.id] = selected;
				continue;
			}
			if (typeof selected === "string" && selected.length > 0) {
				content[question.id] = [selected];
				continue;
			}
			return { action: "cancel" };
		}
		const selected = await ui.select(
			title,
			[...question.options.map((option) => option.label), OTHER_OPTION_LABEL],
			{ signal },
		);
		if (typeof selected !== "string" || selected.length === 0) {
			return { action: "cancel" };
		}
		content[question.id] = selected;
		if (question.noteFieldId && selected === OTHER_OPTION_LABEL) {
			const note = await ui.input(
				`${question.header} note`,
				"Explain your answer",
				{ signal },
			);
			if (note === undefined) return { action: "cancel" };
			if (note.trim().length > 0) {
				content[question.noteFieldId] = note.trim();
			}
		}
	}
	return { action: "accept", content };
}
function buildTextInputPromptTitle(request, field) {
	const parts = [
		request.serverName ? `[${request.serverName}]` : "",
		field.title,
		field.description,
	].filter((part) => typeof part === "string" && part.trim().length > 0);
	return parts.join("\n\n");
}
function buildTextInputPlaceholder(field) {
	const desc = field.description.trim();
	if (!desc) return field.required ? "Required" : "Leave empty to skip";
	const formatLine = desc
		.split(/\r?\n/)
		.map((line) => line.trim())
		.find((line) => /^format:/i.test(line));
	if (!formatLine) return field.required ? "Required" : "Leave empty to skip";
	const hint = formatLine.replace(/^format:\s*/i, "").trim();
	return hint.length > 0
		? hint
		: field.required
			? "Required"
			: "Leave empty to skip";
}
async function promptTextInputElicitation(request, fields, ui, signal) {
	const content = {};
	for (const field of fields) {
		const value = await ui.input(
			buildTextInputPromptTitle(request, field),
			buildTextInputPlaceholder(field),
			{ signal, ...(field.secure ? { secure: true } : {}) },
		);
		if (value === undefined) {
			return { action: "cancel" };
		}
		content[field.id] = value;
	}
	return { action: "accept", content };
}
/**
 * Known CLI tools where the subcommand verb changes the risk profile.
 * Value = number of subcommand tokens (beyond the executable) to capture
 * in the "Always Allow" permission pattern.
 *
 * `git push` and `git log` are very different → depth 1 → `Bash(git push:*)`
 * `gh pr create` and `gh pr list` differ at depth 2 → `Bash(gh pr create:*)`
 * `ping` is always safe → not listed → `Bash(ping:*)`
 */
const SUBCOMMAND_DEPTH = {
	git: 1,
	gh: 2,
	npm: 1,
	npx: 1,
	yarn: 1,
	pnpm: 1,
	docker: 1,
	kubectl: 1,
	aws: 2,
	az: 2,
	gcloud: 2,
	cargo: 1,
	pip: 1,
	pip3: 1,
	brew: 1,
	terraform: 1,
	helm: 1,
	dotnet: 1,
};
/** Command wrappers to skip when extracting the base executable. */
const CMD_PASSTHROUGH = new Set(["sudo", "env", "command"]);
/**
 * Build a smart permission pattern for Bash "Always Allow".
 *
 * Simple commands → `Bash(ping:*)` (any args are fine)
 * Subcommand-sensitive CLIs → `Bash(git push:*)` (verb is captured, args wildcarded)
 */
export function buildBashPermissionPattern(command) {
	// When the command is a chain like "cd /foo && gh pr list", extract the
	// last segment — `cd` is just setup, the meaningful operation is what follows.
	const segments = command.split(/\s*(?:&&|\|\||;)\s*/);
	// Skip leading `cd` (directory setup) and trailing error suppressors
	// like `|| true`, `|| :`, `|| echo ...`.  The meaningful command is
	// the first segment that is *neither* of those.
	const SETUP_RE = /^\s*cd\s/;
	const SUPPRESSOR_RE = /^\s*(?:true|:|echo\b)/;
	let meaningful;
	if (segments.length > 1) {
		// Strip suppressors, then strip cd prefixes; take the *last* remaining
		// segment — that's the meaningful command.
		const trimmed = segments.filter((s) => !SUPPRESSOR_RE.test(s));
		const core = trimmed.filter((s) => !SETUP_RE.test(s));
		meaningful =
			core.length > 0 ? core[core.length - 1] : trimmed[trimmed.length - 1];
	}
	meaningful = meaningful || segments[0] || command;
	const rawTokens = meaningful.trim().split(/\s+/);
	// Skip sudo/env wrappers and leading VAR=val assignments
	let idx = 0;
	while (idx < rawTokens.length) {
		if (CMD_PASSTHROUGH.has(rawTokens[idx])) {
			idx++;
			continue;
		}
		if (/^[A-Za-z_]\w*=/.test(rawTokens[idx])) {
			idx++;
			continue;
		}
		break;
	}
	const tokens = rawTokens.slice(idx).filter(Boolean);
	if (tokens.length === 0) return "Bash(*)";
	// Strip path and .exe from executable name
	const base = tokens[0].replace(/^.*[\\/]/, "").replace(/\.exe$/i, "");
	const depth = SUBCOMMAND_DEPTH[base];
	if (depth !== undefined) {
		// Capture base + N subcommand tokens: "gh pr list" → Bash(gh pr list:*)
		const significant = [base, ...tokens.slice(1, 1 + depth)].join(" ");
		return `Bash(${significant}:*)`;
	}
	// Simple command — any args are fine: "ping" → Bash(ping:*)
	return `Bash(${base}:*)`;
}
/**
 * Build the list of granularity options presented after a user chooses
 * "Always Allow" for a Bash command.
 *
 * Rather than assuming the user wants the default smart pattern, the UI
 * shows every meaningful prefix so the user explicitly picks the scope:
 *
 *   "gh pr list --limit 5" → [
 *     "Bash(gh:*)",         // allow any gh command
 *     "Bash(gh pr:*)",      // allow any gh pr subcommand
 *     "Bash(gh pr list:*)", // allow just this verb
 *   ]
 *
 * Flags (tokens starting with `-`) terminate the subcommand chain — they
 * are call-site arguments, not stable verbs. Subcommand depth is capped
 * at 3 to keep the menu short (max 4 options).
 *
 * Returns a single-entry list when there is no meaningful subcommand to
 * choose from (e.g. `ls -la`). Callers can skip the second dialog in
 * that case.
 */
export function buildBashPermissionPatternOptions(command) {
	const segments = command.split(/\s*(?:&&|\|\||;)\s*/);
	const SETUP_RE = /^\s*cd\s/;
	const SUPPRESSOR_RE = /^\s*(?:true|:|echo\b)/;
	let meaningful;
	if (segments.length > 1) {
		const trimmed = segments.filter((s) => !SUPPRESSOR_RE.test(s));
		const core = trimmed.filter((s) => !SETUP_RE.test(s));
		meaningful =
			core.length > 0 ? core[core.length - 1] : trimmed[trimmed.length - 1];
	}
	meaningful = meaningful || segments[0] || command;
	const rawTokens = meaningful.trim().split(/\s+/);
	let idx = 0;
	while (idx < rawTokens.length) {
		if (CMD_PASSTHROUGH.has(rawTokens[idx])) {
			idx++;
			continue;
		}
		if (/^[A-Za-z_]\w*=/.test(rawTokens[idx])) {
			idx++;
			continue;
		}
		break;
	}
	const tokens = rawTokens.slice(idx).filter(Boolean);
	if (tokens.length === 0) return ["Bash(*)"];
	const base = tokens[0].replace(/^.*[\\/]/, "").replace(/\.exe$/i, "");
	// Collect up to 3 subcommand tokens, stopping at the first flag.
	const subTokens = [];
	for (let i = 1; i < tokens.length; i++) {
		const t = tokens[i];
		if (t.startsWith("-")) break;
		subTokens.push(t);
		if (subTokens.length >= 3) break;
	}
	const patterns = [`Bash(${base}:*)`];
	for (let i = 1; i <= subTokens.length; i++) {
		patterns.push(`Bash(${[base, ...subTokens.slice(0, i)].join(" ")}:*)`);
	}
	return patterns;
}
/**
 * Read Bash allow-rule patterns from project and user settings files.
 *
 * Returns the ruleContent portion (e.g. `"gh pr list:*"`) for each
 * `Bash(...)` entry found in `permissions.allow`.
 */
function readBashAllowRulesFromSettings() {
	const rules = [];
	const paths = [
		join(process.cwd(), ".claude", "settings.local.json"),
		join(process.cwd(), ".claude", "settings.json"),
	];
	try {
		paths.push(join(homedir(), ".claude", "settings.json"));
	} catch {
		// homedir() can throw on some platforms
	}
	for (const settingsPath of paths) {
		try {
			if (!existsSync(settingsPath)) continue;
			const raw = JSON.parse(readFileSync(settingsPath, "utf8"));
			const allow = raw?.permissions?.allow;
			if (!Array.isArray(allow)) continue;
			for (const entry of allow) {
				if (typeof entry !== "string") continue;
				const m = /^Bash\((.+)\)$/.exec(entry);
				if (m) rules.push(m[1]);
			}
		} catch {
			// Ignore malformed settings files
		}
	}
	return rules;
}
/**
 * Check if a Bash compound command matches saved allow rules after
 * extracting the meaningful segment.
 *
 * The SDK's built-in matcher refuses to match prefix rules against
 * compound commands (e.g. `cd /path && gh pr list`). Claude Code
 * routinely prepends `cd <cwd> &&` to commands, causing saved rules
 * to never match on re-invocation. This function strips safe leading
 * segments (only `cd` commands) and checks the remaining operation
 * against saved rules.
 *
 * For compound commands, returns true only when all leading segments
 * are `cd` commands and the final segment matches a saved rule.
 * For simple (single-segment) commands, checks directly against saved
 * rules — this covers the case where a rule was added mid-session and
 * the SDK's in-memory cache is stale.
 */
export function bashCommandMatchesSavedRules(command) {
	const segments = command.split(/\s*(?:&&|\|\||;)\s*/).filter(Boolean);
	if (segments.length === 0) return false;
	let meaningful;
	if (segments.length === 1) {
		meaningful = segments[0].trim();
	} else {
		// Strip trailing error suppressors (|| true, || :, || echo ...)
		// and leading cd segments.  The first remaining segment is the
		// meaningful command.  All other non-cd, non-suppressor segments
		// must be absent — otherwise we can't safely auto-approve.
		const SETUP_RE = /^cd\s/;
		const SUPPRESSOR_RE = /^\s*(?:true|:|echo\b)/;
		const trimmed = segments.filter((s) => !SUPPRESSOR_RE.test(s.trim()));
		const core = trimmed.filter((s) => !SETUP_RE.test(s.trim()));
		if (core.length !== 1) return false; // ambiguous — multiple real commands
		meaningful = core[0].trim();
	}
	if (!meaningful) return false;
	const rules = readBashAllowRulesFromSettings();
	if (rules.length === 0) return false;
	for (const rule of rules) {
		const prefixMatch = /^(.+):\*$/.exec(rule);
		if (prefixMatch) {
			const prefix = prefixMatch[1];
			if (meaningful === prefix || meaningful.startsWith(prefix + " ")) {
				return true;
			}
			continue;
		}
		// Exact match
		if (meaningful === rule) return true;
	}
	return false;
}
/** Format the tool input into a human-readable summary for the permission prompt. */
function formatToolInput(toolName, input) {
	// Bash — show the command
	if (input.command && typeof input.command === "string") {
		const cmd =
			input.command.length > 300
				? input.command.slice(0, 300) + "…"
				: input.command;
		return cmd;
	}
	// File-oriented tools — show path
	if (input.file_path && typeof input.file_path === "string") {
		return `${toolName}: ${input.file_path}`;
	}
	// Generic fallback — compact JSON, truncated
	const json = JSON.stringify(input);
	if (json.length <= 200) return json;
	return json.slice(0, 200) + "…";
}
/**
 * Create a canUseTool handler that routes SDK permission requests through the
 * extension UI's select dialog, or auto-approves when no UI is available.
 *
 * Presents three options:
 * - **Allow** — approve this one invocation
 * - **Always Allow** — approve and pass `suggestions` back as `updatedPermissions`
 *   so the SDK remembers the choice for the rest of the session
 * - **Deny** — reject the invocation
 *
 * Follows the same pattern as {@link createClaudeCodeElicitationHandler}:
 * takes an optional UI context and returns the callback or undefined.
 *
 * When UI is unavailable (headless / autonomous sub-agents), returns a handler
 * that always approves — replacing the old SF_AUTO_MODE → bypassPermissions
 * workaround.
 */
export function createClaudeCodeCanUseToolHandler(ui) {
	if (!ui) return undefined;
	return async (toolName, _input, options) => {
		// Abort early if the signal is already fired
		if (options.signal.aborted) {
			return {
				behavior: "deny",
				message: "Aborted",
				toolUseID: options.toolUseID,
			};
		}
		// For Bash compound commands (e.g. "cd /path && gh pr list"),
		// check if the meaningful operation matches a saved allow rule.
		// The SDK's built-in matcher rejects prefix rules for compound
		// commands, but cd-prefixed commands are routine and the actual
		// operation is already approved.
		if (toolName === "Bash" && typeof _input.command === "string") {
			if (bashCommandMatchesSavedRules(_input.command)) {
				return {
					behavior: "allow",
					updatedInput: _input,
					toolUseID: options.toolUseID,
				};
			}
		}
		const inputSummary = formatToolInput(toolName, _input);
		const title = options.title || `Allow Claude Code to use: ${toolName}?`;
		const body = [options.description, inputSummary].filter(Boolean).join("\n");
		// The 2nd menu (level picker) lets the user choose the exact pattern,
		// so the 1st menu just shows "Always Allow" without a command suffix.
		const alwaysAllowLabel = "Always Allow";
		try {
			const choice = await ui.select(
				`${title}\n${body}`,
				["Allow", alwaysAllowLabel, "Deny"],
				{ signal: options.signal },
			);
			if (options.signal.aborted) {
				return {
					behavior: "deny",
					message: "Aborted",
					toolUseID: options.toolUseID,
				};
			}
			if (choice === alwaysAllowLabel) {
				// Pass the SDK's own suggestions back as updatedPermissions so
				// it knows how to persist them (PermissionUpdate[] shape).
				// For Bash, patch the ruleContent with the user-chosen
				// granularity pattern (e.g. "gh", "gh pr", "gh pr list") so
				// the saved rule matches the scope the user actually wants.
				let perms = options.suggestions;
				let notifyLabel;
				if (toolName === "Bash" && typeof _input.command === "string") {
					// Present every meaningful prefix so the user picks the
					// scope explicitly rather than getting a blanket match.
					const patternOptions = buildBashPermissionPatternOptions(
						_input.command,
					);
					let chosenPattern;
					if (patternOptions.length <= 1) {
						// No subcommand choice to make (e.g. "ls -la") — use
						// the single available pattern directly.
						chosenPattern =
							patternOptions[0] ?? buildBashPermissionPattern(_input.command);
					} else {
						const levelChoiceRaw = await ui.select(
							"Save permission at which level?",
							patternOptions,
							{ signal: options.signal },
						);
						if (options.signal.aborted) {
							return {
								behavior: "deny",
								message: "Aborted",
								toolUseID: options.toolUseID,
							};
						}
						const levelChoice = Array.isArray(levelChoiceRaw)
							? levelChoiceRaw[0]
							: levelChoiceRaw;
						if (!levelChoice || !patternOptions.includes(levelChoice)) {
							// User dismissed the level picker — cancel the
							// tool use. Falling back to a one-time allow
							// here would leave the spawned agent running
							// with no clear signal that the user bailed.
							return {
								behavior: "deny",
								message: "User cancelled permission selection",
								toolUseID: options.toolUseID,
							};
						}
						chosenPattern = levelChoice;
					}
					notifyLabel = chosenPattern;
					// Extract the ruleContent portion from "Bash(gh pr list:*)" → "gh pr list:*"
					const ruleContent = chosenPattern
						.replace(/^Bash\(/, "")
						.replace(/\)$/, "");
					if (perms && Array.isArray(perms) && perms.length > 0) {
						// Clone suggestions and patch ruleContent on any Bash addRules entry
						perms = perms.map((s) => {
							if (s.type === "addRules" && Array.isArray(s.rules)) {
								return {
									...s,
									rules: s.rules.map((r) =>
										r.toolName === "Bash" ? { ...r, ruleContent } : r,
									),
								};
							}
							return s;
						});
					} else {
						// No suggestions from SDK — build a proper PermissionUpdate
						perms = [
							{
								type: "addRules",
								rules: [{ toolName: "Bash", ruleContent }],
								behavior: "allow",
								destination: "localSettings",
							},
						];
					}
				} else if (!perms || (Array.isArray(perms) && perms.length === 0)) {
					// Non-Bash tool with no SDK-supplied suggestions. Without a
					// fallback rule the SDK would return `behavior: "allow"`
					// with no `updatedPermissions`, so "Always Allow" silently
					// fails to persist for tools whose input varies per call
					// (e.g. AskUserQuestion with different `questions` payloads).
					// A bare `{ toolName }` rule matches any input.
					perms = [
						{
							type: "addRules",
							rules: [{ toolName }],
							behavior: "allow",
							destination: "localSettings",
						},
					];
					notifyLabel = toolName;
				}
				// Notify with the resolved pattern (label already previewed it)
				if (notifyLabel) {
					ui.notify(`Saved: ${notifyLabel}`, "info");
				}
				return {
					behavior: "allow",
					updatedInput: _input,
					toolUseID: options.toolUseID,
					...(perms ? { updatedPermissions: perms } : {}),
				};
			}
			if (choice === "Allow") {
				return {
					behavior: "allow",
					updatedInput: _input,
					toolUseID: options.toolUseID,
				};
			}
			return {
				behavior: "deny",
				message: "User denied",
				toolUseID: options.toolUseID,
			};
		} catch {
			return {
				behavior: "deny",
				message: "Aborted",
				toolUseID: options.toolUseID,
			};
		}
	};
}
// ---------------------------------------------------------------------------
// Elicitation handler
// ---------------------------------------------------------------------------
/** Create an SDK elicitation handler that routes requests through the extension UI dialogs, or undefined if no UI is available. */
export function createClaudeCodeElicitationHandler(ui) {
	if (!ui) return undefined;
	return async (request, { signal }) => {
		if (request.mode === "url") {
			return { action: "decline" };
		}
		const questions = parseAskUserQuestionsElicitation(request);
		if (questions) {
			const interviewResult = await showInterviewRound(
				questions,
				{ signal },
				{
					ui,
				},
			).catch(() => undefined);
			if (interviewResult && Object.keys(interviewResult.answers).length > 0) {
				return {
					action: "accept",
					content: roundResultToElicitationContent(questions, interviewResult),
				};
			}
			return promptElicitationWithDialogs(request, questions, ui, signal);
		}
		const textFields = parseTextInputElicitation(request);
		if (textFields) {
			return promptTextInputElicitation(request, textFields, ui, signal);
		}
		return { action: "decline" };
	};
}
/**
 * Aborted by the caller's AbortSignal — distinct from exhaustion. SF's
 * agent loop keys off `stopReason === "aborted"` to treat this as a clean
 * user cancel instead of a retry-eligible provider failure.
 */
export function makeAbortedMessage(model, lastTextContent) {
	const message = {
		role: "assistant",
		content: lastTextContent
			? [{ type: "text", text: lastTextContent }]
			: [{ type: "text", text: "Claude Code stream aborted by caller" }],
		api: "anthropic-messages",
		provider: "claude-code",
		model,
		usage: { ...ZERO_USAGE },
		stopReason: "aborted",
		timestamp: Date.now(),
	};
	return message;
}
// ---------------------------------------------------------------------------
// SDK options builder
// ---------------------------------------------------------------------------
/**
 * Resolve the Claude Code permission mode for the current run.
 *
 * SF subagents run underneath a host Claude Code session the user has
 * already consented to, and their work spans the full direct runtime toolset.
 * Defaulting the inner SDK to
 * `bypassPermissions` avoids per-tool approval prompts that offer no
 * meaningful safety beyond what the host session and the subagent prompts
 * already enforce. `SF_CLAUDE_CODE_PERMISSION_MODE` lets security-conscious
 * users opt into a stricter mode (`acceptEdits`, `default`, `plan`).
 *
 * Tradeoff: bypass means a prompt-injection payload read from an untrusted
 * file could trigger tool calls without a second gate. Accepted for SF
 * because the workflow is explicit user intent and the alternative
 * (#4099) is continuous approval fatigue that blocks real work.
 */
export async function resolveClaudePermissionMode(env = process.env) {
	const override = env.SF_CLAUDE_CODE_PERMISSION_MODE?.trim();
	if (
		override === "bypassPermissions" ||
		override === "acceptEdits" ||
		override === "default" ||
		override === "plan"
	) {
		return override;
	}
	return "bypassPermissions";
}
// NOTE: These helpers intentionally mirror @singularity-forge/ai anthropic-shared
// behavior so this extension remains typecheck-stable even when the published
// @singularity-forge/ai barrel lags behind monorepo source exports.
function modelSupportsAdaptiveThinking(modelId) {
	return (
		modelId.includes("opus-4-6") ||
		modelId.includes("opus-4.6") ||
		modelId.includes("opus-4-7") ||
		modelId.includes("opus-4.7") ||
		modelId.includes("sonnet-4-6") ||
		modelId.includes("sonnet-4.6") ||
		modelId.includes("sonnet-4-7") ||
		modelId.includes("sonnet-4.7") ||
		modelId.includes("haiku-4-5") ||
		modelId.includes("haiku-4.5")
	);
}
function mapThinkingLevelToAnthropicEffort(level, modelId) {
	switch (level) {
		case "minimal":
		case "low":
			return "low";
		case "medium":
			return "medium";
		case "high":
			return "high";
		case "xhigh":
			if (modelId.includes("opus-4-7") || modelId.includes("opus-4.7"))
				return "xhigh";
			if (modelId.includes("opus-4-6") || modelId.includes("opus-4.6"))
				return "max";
			return "high";
		default:
			return "high";
	}
}
/**
 * Build the options object passed to the Claude Agent SDK's `query()` call.
 *
 * Extracted for testability — callers can verify session persistence,
 * beta flags, and other configuration without mocking the full SDK.
 *
 * `permissionMode` / `allowDangerouslySkipPermissions` are resolved through
 * {@link resolveClaudePermissionMode} so interactive runs don't silently
 * bypass the SDK's permission gate. Callers that want the old always-bypass
 * behaviour pass `permissionMode: "bypassPermissions"` explicitly.
 */
export function buildSdkOptions(
	modelId,
	_prompt,
	overrides,
	extraOptions = {},
) {
	const { reasoning: requestedReasoning, ...sdkExtraOptions } = extraOptions;
	// "auto" → let Claude's adaptive thinking pick effort itself (no explicit level)
	const reasoning =
		requestedReasoning === "auto" ? undefined : requestedReasoning;
	const autoReasoning = requestedReasoning === "auto";
	const permissionMode = overrides?.permissionMode ?? "bypassPermissions";
	const disallowedTools = ["AskUserQuestion"];
	// Pre-authorize the safe built-ins. `acceptEdits` mode (the interactive
	// default) only auto-approves file edits, so read-only inspection still
	// needs explicit allow-listing for autonomous SF actions (#4099).
	const allowedTools = [
		"Read",
		"Write",
		"Edit",
		"Glob",
		"Grep",
		"Bash(ls:*)",
		"Bash(pwd)",
	];
	const supportsAdaptive = modelSupportsAdaptiveThinking(modelId);
	const effort =
		reasoning && supportsAdaptive
			? mapThinkingLevelToAnthropicEffort(reasoning, modelId)
			: undefined;
	// Bug B: SDK requires thinking:{type:"adaptive"} alongside effort for adaptive thinking to activate.
	// Bug C: SDK requires thinking:{type:"disabled"} to actually stop adaptive thinking when reasoning is off;
	//        omitting the field leaves the SDK in its adaptive default (or persisted session state).
	// "auto": request adaptive thinking with no explicit effort (SDK picks).
	const thinkingConfig = supportsAdaptive
		? effort || autoReasoning
			? { thinking: { type: "adaptive" } }
			: { thinking: { type: "disabled" } }
		: undefined;
	return {
		pathToClaudeCodeExecutable: getClaudePath(),
		model: modelId,
		includePartialMessages: true,
		persistSession: true,
		cwd: process.cwd(),
		permissionMode,
		allowDangerouslySkipPermissions: permissionMode === "bypassPermissions",
		settingSources: ["project"],
		systemPrompt: { type: "preset", preset: "claude_code" },
		disallowedTools,
		...(allowedTools.length > 0 ? { allowedTools } : {}),
		betas:
			modelId.includes("sonnet") ||
			modelId.includes("opus-4-7") ||
			modelId.includes("opus-4.7")
				? ["context-1m-2025-08-07"]
				: [],
		...(thinkingConfig ?? {}),
		...(effort ? { effort } : {}),
		...sdkExtraOptions,
	};
}
function normalizeToolResultContent(content) {
	if (typeof content === "string") {
		return [{ type: "text", text: content }];
	}
	if (!Array.isArray(content)) {
		if (content == null) return [{ type: "text", text: "" }];
		return [{ type: "text", text: JSON.stringify(content) }];
	}
	const blocks = [];
	for (const item of content) {
		if (typeof item === "string") {
			blocks.push({ type: "text", text: item });
			continue;
		}
		if (!item || typeof item !== "object") {
			blocks.push({ type: "text", text: String(item) });
			continue;
		}
		const block = item;
		if (block.type === "text") {
			blocks.push({
				type: "text",
				text: typeof block.text === "string" ? block.text : "",
			});
			continue;
		}
		if (
			block.type === "image" &&
			typeof block.data === "string" &&
			typeof block.mimeType === "string"
		) {
			blocks.push({
				type: "image",
				data: block.data,
				mimeType: block.mimeType,
			});
			continue;
		}
		blocks.push({ type: "text", text: JSON.stringify(block) });
	}
	return blocks.length > 0 ? blocks : [{ type: "text", text: "" }];
}
export function extractToolResultsFromSdkUserMessage(message) {
	const extracted = [];
	const seen = new Set();
	const rawMessage = message.message;
	const content = Array.isArray(rawMessage?.content) ? rawMessage.content : [];
	for (const item of content) {
		if (!item || typeof item !== "object") continue;
		const block = item;
		const type = typeof block.type === "string" ? block.type : "";
		if (type !== "tool_result" && type !== "mcp_tool_result") continue;
		const toolUseId =
			typeof block.tool_use_id === "string" ? block.tool_use_id : "";
		if (!toolUseId || seen.has(toolUseId)) continue;
		seen.add(toolUseId);
		extracted.push({
			toolUseId,
			result: {
				content: normalizeToolResultContent(block.content),
				details: {},
				isError: block.is_error === true,
			},
		});
	}
	if (extracted.length === 0) {
		const fallback = message.tool_use_result;
		if (fallback && typeof fallback === "object") {
			const toolResult = fallback;
			const toolUseId =
				typeof toolResult.tool_use_id === "string"
					? toolResult.tool_use_id
					: "";
			if (toolUseId) {
				extracted.push({
					toolUseId,
					result: {
						content: normalizeToolResultContent(toolResult.content),
						details: {},
						isError: toolResult.is_error === true,
					},
				});
			}
		}
	}
	return extracted;
}
function attachExternalResultsToToolBlocks(toolBlocks, toolResultsById) {
	for (const block of toolBlocks) {
		if (block.type !== "toolCall" && block.type !== "serverToolUse") continue;
		const externalResult = toolResultsById.get(block.id);
		if (!externalResult) continue;
		block.externalResult = externalResult;
	}
}
/**
 * Merge tool-call blocks from the active partial-message builder into the
 * running list of intermediate tool calls, preserving order and de-duping
 * by tool-call id. Exposed for testing the F3 fix (final-turn tool calls
 * dropped when `result` arrives without a preceding synthetic `user`).
 */
export function mergePendingToolCalls(intermediate, pending) {
	const alreadyIncluded = new Set();
	for (const block of intermediate) {
		if (block.type === "toolCall") alreadyIncluded.add(block.id);
	}
	for (const block of pending) {
		if (block.type !== "toolCall") continue;
		if (alreadyIncluded.has(block.id)) continue;
		alreadyIncluded.add(block.id);
		intermediate.push(block);
	}
	return intermediate;
}
// ---------------------------------------------------------------------------
// streamSimple implementation
// ---------------------------------------------------------------------------
/**
 * SF streamSimple function that delegates to the Claude Agent SDK.
 *
 * Emits AssistantMessageEvent deltas for real-time TUI rendering
 * (thinking, text, tool calls). The final AssistantMessage has tool-call
 * blocks stripped so the agent loop ends the turn without local dispatch.
 */
export function streamViaClaudeCode(model, context, options) {
	const stream = createAssistantStream();
	void pumpSdkMessages(model, context, options, stream);
	return stream;
}
async function pumpSdkMessages(model, context, options, stream) {
	const modelId = model.id;
	let builder = null;
	/** Track the last text content seen across all assistant turns for the final message. */
	let lastTextContent = "";
	let lastThinkingContent = "";
	/** Collect tool blocks from intermediate SDK turns for tool execution rendering. */
	const intermediateToolBlocks = [];
	/** Preserve real external tool results from Claude Code's synthetic user messages. */
	const toolResultsById = new Map();
	try {
		// Dynamic import — the SDK is an optional dependency.
		const sdkModule = "@anthropic-ai/claude-agent-sdk";
		const sdk = await import(/* webpackIgnore: true */ sdkModule);
		// Bridge SF's AbortSignal to SDK's AbortController
		const controller = new AbortController();
		if (options?.signal) {
			options.signal.addEventListener("abort", () => controller.abort(), {
				once: true,
			});
		}
		const prompt = buildPromptFromContext(context);
		const queryPrompt = buildSdkQueryPrompt(context, prompt);
		const permissionMode = await resolveClaudePermissionMode();
		const uiContext = options?.extensionUIContext;
		const canUseToolHandler = createClaudeCodeCanUseToolHandler(uiContext);
		// When no UI is available (headless / autonomous), auto-approve all
		// tool requests. This replaces the old bypassPermissions workaround.
		const canUseToolFallback =
			canUseToolHandler ??
			(async (_toolName, _input, opts) => ({
				behavior: "allow",
				toolUseID: opts.toolUseID,
			}));
		const sdkOpts = buildSdkOptions(
			modelId,
			prompt,
			{ permissionMode },
			{
				reasoning: options?.reasoning,
				canUseTool: canUseToolFallback,
				...(uiContext
					? {
							onElicitation: createClaudeCodeElicitationHandler(uiContext),
						}
					: {}),
			},
		);
		const queryResult = sdk.query({
			prompt: queryPrompt,
			options: {
				...sdkOpts,
				abortController: controller,
			},
		});
		// Emit start with an empty partial
		const initialPartial = {
			role: "assistant",
			content: [],
			api: "anthropic-messages",
			provider: "claude-code",
			model: modelId,
			usage: { ...ZERO_USAGE },
			stopReason: "stop",
			timestamp: Date.now(),
		};
		stream.push({ type: "start", partial: initialPartial });
		for await (const msg of queryResult) {
			if (options?.signal?.aborted) {
				// User-initiated cancel — emit an aborted error so the agent
				// loop classifies this as a deliberate stop, not a transient
				// provider failure that should be retried.
				stream.push({
					type: "error",
					reason: "aborted",
					error: makeAbortedMessage(modelId, lastTextContent),
				});
				return;
			}
			switch (msg.type) {
				// -- Init --
				case "system": {
					// Nothing to emit — the stream is already started.
					break;
				}
				// -- Streaming partial messages --
				case "stream_event": {
					const partial = msg;
					const event = partial.event;
					// New assistant turn starts with message_start
					if (event.type === "message_start") {
						builder = new PartialMessageBuilder(
							event.message?.model ?? modelId,
						);
						break;
					}
					if (!builder) break;
					const assistantEvent = builder.handleEvent(event);
					if (assistantEvent) {
						stream.push(assistantEvent);
					}
					break;
				}
				// -- Complete assistant message (non-streaming fallback) --
				case "assistant": {
					const sdkAssistant = msg;
					// Capture text content from complete messages
					for (const block of sdkAssistant.message.content) {
						if (block.type === "text") {
							lastTextContent = block.text;
						} else if (block.type === "thinking") {
							lastThinkingContent = block.thinking;
						}
					}
					break;
				}
				// -- User message (synthetic tool result — signals turn boundary) --
				case "user": {
					// Capture content from the completed turn before resetting
					if (builder) {
						for (const block of builder.message.content) {
							if (block.type === "text" && block.text) {
								lastTextContent = block.text;
							} else if (block.type === "thinking" && block.thinking) {
								lastThinkingContent = block.thinking;
							} else if (
								block.type === "toolCall" ||
								block.type === "serverToolUse"
							) {
								// Collect tool blocks for externalToolExecution rendering
								intermediateToolBlocks.push(block);
							}
						}
					}
					// Extract tool results from the SDK's synthetic user message
					// and attach to corresponding tool call blocks immediately.
					for (const {
						toolUseId,
						result,
					} of extractToolResultsFromSdkUserMessage(msg)) {
						toolResultsById.set(toolUseId, result);
					}
					attachExternalResultsToToolBlocks(
						intermediateToolBlocks,
						toolResultsById,
					);
					// Push a synthetic toolcall_end for each tool call from this turn
					// so the TUI can render tool results in real-time during the SDK
					// session instead of waiting until the entire session completes.
					if (builder) {
						for (const block of builder.message.content) {
							const extResult = block.externalResult;
							if (!extResult) continue;
							const contentIndex = builder.message.content.indexOf(block);
							if (contentIndex < 0) continue;
							// Push synthetic completion events with result attached so the
							// chat-controller can update pending ToolExecutionComponents.
							if (block.type === "toolCall") {
								stream.push({
									type: "toolcall_end",
									contentIndex,
									toolCall: block,
									partial: builder.message,
								});
							} else if (block.type === "serverToolUse") {
								stream.push({
									type: "server_tool_use",
									contentIndex,
									partial: builder.message,
								});
							}
						}
					}
					builder = null;
					break;
				}
				// -- Result (terminal) --
				case "result": {
					const result = msg;
					// Build final message. Include intermediate tool calls so the
					// agent loop's externalToolExecution path emits tool_execution
					// events for proper TUI rendering, followed by the text response.
					const finalContent = [];
					// If the final turn ended without a synthetic user message
					// (e.g. stop_reason: "tool_use" followed directly by result,
					// or a turn with text but no tool execution), the `builder`
					// still holds toolCall blocks that were never pushed into
					// `intermediateToolBlocks`. Fold them in here so they aren't
					// dropped from the final AssistantMessage.
					if (builder) {
						mergePendingToolCalls(
							intermediateToolBlocks,
							builder.message.content,
						);
					}
					// Add tool calls from intermediate turns first (renders above text)
					attachExternalResultsToToolBlocks(
						intermediateToolBlocks,
						toolResultsById,
					);
					finalContent.push(...intermediateToolBlocks);
					// Add text/thinking from the last turn
					if (builder && builder.message.content.length > 0) {
						for (const block of builder.message.content) {
							if (block.type === "text" || block.type === "thinking") {
								finalContent.push(block);
							}
						}
					} else {
						if (lastThinkingContent) {
							finalContent.push({
								type: "thinking",
								thinking: lastThinkingContent,
							});
						}
						if (lastTextContent) {
							finalContent.push({ type: "text", text: lastTextContent });
						}
					}
					// Fallback: use the SDK's result text if we have no content
					if (
						finalContent.length === 0 &&
						result.subtype === "success" &&
						result.result
					) {
						finalContent.push({ type: "text", text: result.result });
					}
					const finalMessage = {
						role: "assistant",
						content: finalContent,
						api: "anthropic-messages",
						provider: "claude-code",
						model: modelId,
						usage: mapUsage(result.usage, result.total_cost_usd),
						stopReason: result.is_error ? "error" : "stop",
						timestamp: Date.now(),
					};
					if (result.is_error) {
						finalMessage.errorMessage = getResultErrorMessage(result);
						stream.push({
							type: "error",
							reason: "error",
							error: finalMessage,
						});
					} else {
						stream.push({
							type: "done",
							reason: "stop",
							message: finalMessage,
						});
					}
					return;
				}
				default:
					break;
			}
		}
		// Generator exhaustion without a terminal result is a stream interruption,
		// not a successful completion. Emitting an error lets SF classify it as a
		// transient provider failure instead of advancing autonomous state.
		const fallback = makeStreamExhaustedErrorMessage(modelId, lastTextContent);
		stream.push({ type: "error", reason: "error", error: fallback });
	} catch (err) {
		const errorMsg = err instanceof Error ? err.message : String(err);
		stream.push({
			type: "error",
			reason: "error",
			error: makeErrorMessage(modelId, errorMsg),
		});
	}
}
