import chalk from 'chalk';
import { ALL_TOOLS, getToolByName } from './tools/index.js';
import { streamChat, resetClient } from './api.js';
import { checkPermission } from './permissions.js';
import { buildSystemPrompt } from './system-prompt.js';
import { runHooks } from './hooks.js';
import { scanToolCall, printSecurityWarning } from './security.js';
import { trackUsage } from './cost-tracker.js';
import { shouldCompact, compactMessages, quickCompact, DEFAULT_COMPACTION } from './compaction.js';
import { theme, sym, printToolRun, printToolResult, printThinkingOpen, printThinkingText, printThinkingClose, printCost, printApiError, formatDuration, categorizeApiError } from './theme.js';
import { isVoiceEnabled, getTtsConfig, getAccessibilityConfig, speakAssistantResponse, speak, speakUserEcho, } from './voice.js';
import { isLikelyDestructive, describeDestructive, countWords, summarize } from './accessibility.js';
import { audioCue } from './audio.js';
import { setStatus } from './status.js';
import { collapseCompletedTurns } from './turn-context.js';
import * as liveQueue from './live-queue.js';
import { emit as dbgEmit } from './debug.js';
// Per-session set: once we've told the user "this model didn't emit
// reasoning tokens" we don't repeat it on every turn. Cleared per process,
// not persisted — restart, see hint again. Keyed by sessionId so different
// sessions get fresh hints.
const _thinkingHintShownForSession = new Set();
/**
 * Count how many NON-OVERLAPPING occurrences of the last `windowSize`
 * characters of `fullText` appear in the full string. Bails as soon as
 * `threshold` is reached so the cost is O(threshold * windowSize) in
 * the common stuck-model case rather than O(fullText.length).
 *
 * Non-overlapping by stepping the search index by `windowSize` after
 * each match. Overlapping counts cause false positives on self-similar
 * text (markdown tables, repeated import lines, ASCII art) — the audit
 * found that `idx++` made a single copy match 3+ times when there were
 * only two genuine repeats.
 *
 * Exported so the loop-detector behavior can be unit-tested without
 * driving the full streamChat loop.
 */
export function countTailRepetitions(fullText, windowSize, threshold) {
    if (fullText.length < windowSize * threshold)
        return 0;
    const window = fullText.slice(-windowSize);
    let count = 0;
    let idx = 0;
    while ((idx = fullText.indexOf(window, idx)) !== -1) {
        count++;
        if (count >= threshold)
            break;
        idx += windowSize;
    }
    return count;
}
function startInputSuppression(screenReader = false) {
    const stdin = process.stdin;
    if (!stdin.isTTY) {
        return {
            pause: () => { },
            resume: () => { },
            drainQueuedInput: () => '',
            onSteer: () => { },
            restore: () => { },
        };
    }
    const wasRaw = stdin.isRaw;
    // Live queue display — bottom-anchored input box that shows what the
    // user has typed into the queue. Activate immediately so the box is
    // present from the start of streaming, even when empty. Skip entirely
    // in screen-reader mode: NVDA / JAWS read every cursor move as fresh
    // content, which makes a live-updating widget far worse than silent.
    const liveBoxActive = !screenReader && liveQueue.activate();
    // Steer (Ctrl+G mid-stream cancel). Caller registers a handler via
    // onSteer(); we invoke it when 0x07 (BEL / Ctrl+G) arrives during
    // suppression. The handler is responsible for aborting the active
    // stream — we just route the signal.
    let steerHandler = null;
    // Snapshot non-tagged keypress listeners. These are the ones we toggle
    // on suppress/unsuppress; the tagged hotkey listener (F1–F10) stays
    // attached unconditionally so status keys work during streaming and
    // tool execution alike.
    const allKeypressListeners = stdin.listeners('keypress').slice();
    const togglableListeners = allKeypressListeners.filter((l) => !l.__grawkusHotkey__);
    let detached = false;
    function suppress() {
        if (detached)
            return;
        for (const l of togglableListeners)
            stdin.removeListener('keypress', l);
        detached = true;
    }
    function unsuppress() {
        if (!detached)
            return;
        for (const l of togglableListeners)
            stdin.on('keypress', l);
        detached = false;
    }
    // Queued-input buffer (Codex audit "queued_user_messages"). Instead of
    // dropping typed chars during streaming, collect printable ones so we
    // can pre-fill them into the NEXT prompt when the chain ends. The user
    // can keep typing their next request while the current one's still
    // working — it appears at the prompt ready to send or edit.
    //
    // Heuristic to avoid garbage from terminal escapes: only collect
    // printable ASCII (0x20-0x7E) and Enter (0x0D). Drop everything else
    // — including arrow keys, backspace, Ctrl combos — because we can't
    // tell where a multi-byte escape starts vs ends without a full state
    // machine. Backspace within the queued buffer is the main loss; users
    // can clean up at the prompt anyway.
    const queued = [];
    const dataHandler = (chunk) => {
        if (chunk[0] === 0x03) {
            try {
                stdin.setRawMode(false);
            }
            catch { /* noop */ }
            liveQueue.deactivate();
            process.exit(0);
        }
        // Ctrl+G (BEL, 0x07) → Steer trigger. Fires the registered handler
        // which aborts the active stream + uses the queued buffer as the
        // next user turn. Doesn't append the 0x07 itself to the queue.
        if (chunk[0] === 0x07 && detached) {
            if (steerHandler) {
                try {
                    steerHandler();
                }
                catch { /* never break input on a steer error */ }
            }
            return;
        }
        // Esc (0x1B) → Steer trigger, but only for a BARE Esc (chunk of
        // exactly one byte). Multi-byte chunks starting with 0x1B are ANSI
        // escape sequences for arrow keys / function keys / Alt+letter, and
        // arrive in raw mode as one contiguous chunk on every supported
        // terminal (xterm, iTerm, Windows Terminal, Alacritty, Kitty). The
        // length heuristic distinguishes them without a 50ms debounce.
        //
        // Esc and Ctrl+G are now aliases — Esc matches Claude Code + Codex
        // muscle memory, Ctrl+G is kept for existing users.
        if (chunk[0] === 0x1B && chunk.length === 1 && detached) {
            if (steerHandler) {
                try {
                    steerHandler();
                }
                catch { /* never break input on a steer error */ }
            }
            return;
        }
        if (!detached)
            return; // only collect while we're suppressing
        // Drop chunks that look like escape sequences (start with 0x1B)
        // — those are arrow keys, function keys, etc. Already handled by
        // the keypress emitter for tagged hotkeys; for us they're garbage.
        if (chunk[0] === 0x1B)
            return;
        let mutated = false;
        for (const byte of chunk) {
            // Backspace (0x08) or DEL (0x7F) → erase last char from queue
            if (byte === 0x08 || byte === 0x7F) {
                if (queued.length > 0) {
                    queued.pop();
                    mutated = true;
                }
                continue;
            }
            // Printable ASCII or CR/LF → append
            if ((byte >= 0x20 && byte < 0x7F) || byte === 0x0A || byte === 0x0D) {
                queued.push(byte);
                mutated = true;
            }
        }
        // Cap to avoid runaway accumulation if the user holds down a key
        if (queued.length > 4096)
            queued.splice(0, queued.length - 4096);
        // Refresh the live box with current buffer contents so the user
        // sees their typing in real time. Done lazily — only when mutated
        // — to avoid drawing on every random byte.
        if (mutated && liveBoxActive) {
            liveQueue.update(Buffer.from(queued).toString('utf-8').replace(/\r\n?/g, '\n'));
        }
    };
    try {
        stdin.setRawMode(true);
    }
    catch { /* noop */ }
    stdin.on('data', dataHandler);
    stdin.resume();
    // Start suppressed — typing during model streaming is the default-block case
    suppress();
    return {
        pause: unsuppress, // pause suppression = allow typing (for permission prompts)
        resume: suppress, // resume suppression = block typing again
        drainQueuedInput: () => {
            const text = Buffer.from(queued).toString('utf-8');
            queued.length = 0;
            // Normalize CR-only or CR-LF to LF, strip trailing whitespace
            return text.replace(/\r\n?/g, '\n').replace(/\n+$/, '');
        },
        onSteer: (handler) => {
            steerHandler = handler;
        },
        restore: () => {
            unsuppress(); // ensure listeners are back before we leave
            stdin.removeListener('data', dataHandler);
            try {
                stdin.setRawMode(wasRaw);
            }
            catch { /* noop */ }
            // Tear down the live queue box + restore default scroll region.
            // Idempotent; safe to call even if activation was skipped.
            liveQueue.deactivate();
        },
    };
}
/**
 * Validate tool arguments against the tool's JSON schema
 */
function validateToolArguments(tool, input) {
    const schema = tool.parameters;
    const required = schema.required || [];
    const properties = schema.properties || {};
    // Check required parameters exist
    for (const param of required) {
        if (!(param in input)) {
            return { valid: false, error: `Missing required parameter: ${param}` };
        }
    }
    // Basic type checking for parameters that are specified
    for (const [key, value] of Object.entries(input)) {
        if (key in properties) {
            const propSchema = properties[key] || {};
            const expectedType = propSchema.type;
            if (expectedType === 'string' && typeof value !== 'string') {
                return { valid: false, error: `Parameter ${key} must be a string` };
            }
            if (expectedType === 'number' && typeof value !== 'number') {
                return { valid: false, error: `Parameter ${key} must be a number` };
            }
            if (expectedType === 'boolean' && typeof value !== 'boolean') {
                return { valid: false, error: `Parameter ${key} must be a boolean` };
            }
            if (expectedType === 'object' && typeof value !== 'object') {
                return { valid: false, error: `Parameter ${key} must be an object` };
            }
        }
    }
    return { valid: true };
}
/**
 * Main query loop: sends messages to the API, handles tool calls, loops until done.
 */
export async function runQuery(ctx) {
    // Per-chain turn cap. Default is Infinity — the loop runs until the
    // model stops calling tools, or the user cancels (Esc / Ctrl+G /
    // Shift+F5 / Ctrl+C), or the stream loop detector fires. Set
    // ctx.config.maxTurns to a finite number if you want a hard safety
    // cap (useful for unattended sessions). A previous hard-coded 50
    // cap was cutting off legitimate long scaffolding chains; the
    // stream-loop detector + per-turn cost line + Esc cancel are the
    // real defenses against runaways.
    const maxTurns = typeof ctx.config.maxTurns === 'number' && ctx.config.maxTurns > 0
        ? ctx.config.maxTurns
        : Infinity;
    let turns = 0;
    // Track total wall time for this response chain (user message →
    // assistant ending without a tool call). Printed once when the loop exits.
    const chainStart = Date.now();
    // ── Voice: echo the user's input in the user-echo voice ────
    // Run async without awaiting — we don't want to block the API call on
    // ElevenLabs latency. The echo will play while the model is thinking.
    if (isVoiceEnabled(ctx.config) && getTtsConfig(ctx.config).echoUser) {
        const lastUser = [...ctx.messages].reverse().find((m) => m.role === 'user');
        const text = typeof lastUser?.content === 'string' ? lastUser.content : '';
        if (text)
            speakUserEcho(text, ctx.config).catch(() => { });
    }
    // Track the accumulated assistant text across the whole chain so we can
    // TTS it (or its summary) once the chain ends. We collect text from every
    // assistant turn, but the final TTS pass only fires after the no-tool-call
    // exit so tool descriptions aren't read out.
    let accumulatedAssistantText = '';
    // Auto-fallback: when the primary model returns a cryptic / unknown
    // provider error (common for free experimental models like
    // openrouter/owl-alpha which returns literally "ERROR" or "Provider
    // returned error"), we transparently retry the SAME turn once with the
    // user's configured fallbackModel. After we use it, this latches so we
    // don't bounce back and forth between failing models in a single chain.
    let usedFallbackModel = false;
    // Tracks whether ANY reasoning tokens arrived across the entire chain.
    // Used at chain-end to print a one-time "/thinking is ON but this model
    // doesn't emit reasoning" hint. Hoisted to chain scope (not per-turn)
    // because we only care about "in this whole exchange did we see any
    // reasoning at all".
    let sawAnyThinking = false;
    // Skill-graduation telemetry. The Hermes audit's deterministic rule
    // for "this work was worth remembering" — the model is a bad judge of
    // its own complexity, so the dispatcher counts and decides. Thresholds:
    //   - 5+ tool calls in this chain   → complex task
    //   - any tool errored then recovered → learned-from-failure
    // Only used to inform a chain-end suggestion in hermes mode; we don't
    // auto-create skills (which would burn an extra LLM call). We surface
    // the opportunity with a one-line nudge so the user can /skill-create
    // or /learn if they want.
    const chainStats = {
        toolCallCount: 0,
        sawToolError: false,
        sawToolRecovery: false,
        toolCallErrorCounts: new Map(),
        toolParseFailureStreaks: new Map(),
        toolCallLoopDetected: false,
    };
    // Input suppression spans the entire chain: model streaming AND tool
    // execution. executeToolCalls calls inputGuard.pause()/resume() around
    // permission prompts so rl.question() can still read user input. Final
    // teardown happens in the finally block at the bottom of runQuery so
    // the guard is always cleaned up even if something throws unexpectedly.
    //
    // Pass the screen-reader flag through — when on, suppressInputDuringStream
    // skips the live queue box (NVDA/JAWS would re-read every cursor move
    // as new text, drowning the actual response).
    const isScreenReader = ctx.config.voice?.accessibility?.screenReader === true;
    const inputGuard = startInputSuppression(isScreenReader);
    try {
        // Turn-boundary collapse runs BEFORE compaction. Every completed prior
        // turn becomes [user, "<final text>\n[Completed: used X, Y]"] — the
        // model no longer sees stale tool_calls that it might mistake for
        // pending work (the "I'll handle BOTH requests" / "all THREE requests"
        // bug). The current turn (latest user message forward) is left intact
        // because its tool_calls and tool messages are still in flight.
        ctx.messages = collapseCompletedTurns(ctx.messages);
        // Auto-compact if context is getting large
        if (shouldCompact(ctx.messages, DEFAULT_COMPACTION)) {
            console.log(theme.dim(`  ${sym.running} auto-compacting conversation context...`));
            setStatus({ state: 'compacting' });
            ctx.messages = await compactMessages(ctx.messages, ctx.config);
        }
        else {
            // Quick compact: truncate oversized tool results
            ctx.messages = quickCompact(ctx.messages);
        }
        // Tell the status singleton who we are. This is what F2 ("where am I?")
        // speaks back to the user. Updated once per chain — model/provider/mode
        // can't change mid-chain.
        setStatus({
            model: ctx.config.model,
            provider: ctx.config.provider,
            mode: ctx.mode,
            permissionMode: ctx.config.permissionMode,
        });
        while (turns < maxTurns) {
            turns++;
            // Get the last user message for context-aware system prompt
            const lastUserMsg = ctx.messages.filter((m) => m.role === 'user').pop();
            const userQuery = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : undefined;
            // Build full messages array with system prompt
            const systemPrompt = buildSystemPrompt(ctx.config, ctx.cwd, ctx.mode, userQuery);
            const apiMessages = [
                { role: 'system', content: systemPrompt },
                ...ctx.messages,
            ];
            let fullText = '';
            let toolCalls;
            let hasOutput = false;
            let thinkingActive = false;
            let leadingTrimmed = false; // strip leading whitespace from the model's first text chunk
            let lastCharWasNewline = false; // collapse 3+ consecutive newlines down to 2
            let consecutiveNewlines = 0;
            const turnStart = Date.now();
            // Loop detection state: a stuck model can stream the SAME N-char
            // window of text 50+ times in a single API call (observed in the
            // wild with openrouter/owl-alpha emitting tool-call JSON as text).
            // We periodically check whether the most-recent tail occurs ≥3
            // times in the full stream; if so we abort the API call and warn
            // the user. checkpoint values are deliberately coarse — the cost
            // of one indexOf scan per ~500 chars of stream is negligible, the
            // cost of letting the loop run forever is a hung terminal.
            let nextLoopCheckAt = 800; // first check after 800 chars
            const LOOP_WINDOW = 200;
            const LOOP_THRESHOLD = 3;
            let loopDetected = false;
            function writeStreamText(chunk) {
                // Trim leading whitespace until the first non-whitespace character so
                // the model can't produce big vertical gaps before its real reply.
                let text = chunk;
                if (!leadingTrimmed) {
                    text = text.replace(/^[\s\n]+/, '');
                    if (text.length === 0)
                        return; // entire chunk was leading whitespace
                    leadingTrimmed = true;
                }
                // Collapse runs of 3+ newlines into 2 so the body of the response is
                // dense but still has paragraph breaks where the model intended them.
                let out = '';
                for (const ch of text) {
                    if (ch === '\n') {
                        consecutiveNewlines++;
                        if (consecutiveNewlines <= 2)
                            out += ch;
                    }
                    else {
                        consecutiveNewlines = 0;
                        out += ch;
                    }
                }
                if (out.length === 0)
                    return;
                lastCharWasNewline = out.endsWith('\n');
                process.stdout.write(theme.primary(out));
                fullText += out;
                // ── Loop detection ────────────────────────────────────
                // Once fullText crosses each checkpoint, scan: how many times
                // does the last LOOP_WINDOW chars appear in the full stream?
                // 3+ repeats = stuck. Abort and warn.
                if (!loopDetected && fullText.length >= nextLoopCheckAt && fullText.length > LOOP_WINDOW * LOOP_THRESHOLD) {
                    nextLoopCheckAt = fullText.length + 500;
                    const count = countTailRepetitions(fullText, LOOP_WINDOW, LOOP_THRESHOLD);
                    if (count >= LOOP_THRESHOLD) {
                        loopDetected = true;
                        dbgEmit('info', 'stream.loop-detected', {
                            windowSize: LOOP_WINDOW,
                            occurrences: count,
                            totalChars: fullText.length,
                            tail: fullText.slice(-LOOP_WINDOW),
                        });
                        process.stdout.write('\n');
                        console.log(theme.error(`  ⚠ Stream loop detected — same ${LOOP_WINDOW}-char window has appeared ${count}+ times. Aborting.`));
                        console.log(theme.dim(`    The model is stuck repeating itself. Common causes:`));
                        console.log(theme.dim(`      · model is emitting tool-call JSON as plain text (try /model <other>)`));
                        console.log(theme.dim(`      · context exhausted (try /clear and rephrase)`));
                        console.log(theme.dim(`      · experimental free model (openrouter/owl-alpha is a known offender)`));
                        try {
                            streamAbort.abort();
                        }
                        catch { /* noop */ }
                    }
                }
            }
            // (inputGuard is now lifted to runQuery scope — see above. It spans
            // both streaming and tool execution, with pause/resume around the
            // permission prompts inside executeToolCalls.)
            // We're about to wait on the API; tell the status singleton so a blind
            // user pressing F1 hears "calling claude-sonnet-4, 6 seconds elapsed"
            // instead of a stale "idle".
            setStatus({ state: 'streaming' });
            // Steer support — Ctrl+G during streaming aborts the API call and
            // treats the queued buffer as the next user message (with a marker
            // so the model knows the prior turn was interrupted). The handler
            // is wired through InputGuard.onSteer; we create a fresh
            // AbortController per turn and pass it to streamChat.
            const streamAbort = new AbortController();
            let wasSteered = false;
            inputGuard.onSteer(() => {
                wasSteered = true;
                try {
                    streamAbort.abort();
                }
                catch { /* noop */ }
            });
            // Expose the per-turn abort controller on globalThis so the F-row
            // hotkey listener (src/index.ts) can soft-cancel the current turn
            // without going through Ctrl+C / SIGINT. Cleared in the finally
            // block so a stale controller can't be aborted between turns.
            globalThis.__turnAbortCtl = streamAbort;
            dbgEmit('info', 'turn.start', {
                turn: turns,
                model: ctx.config.model,
                messageCount: ctx.messages.length,
            });
            // Pre-token indicator — print a dim "waiting" line inside the live-
            // queue scroll region so the user sees that something IS happening
            // before the first model token arrives. Without this, a slow or
            // hung model (like openrouter/owl-alpha returning ERROR after a
            // long timeout) leaves nothing visible between the prompt and the
            // queue box, making the whole REPL look frozen.
            //
            // Skipped in screen-reader mode (NVDA / JAWS would announce the
            // line and then announce every subsequent token as "after the
            // waiting line", which is noisier than helpful).
            let firstTokenSeen = false;
            // Note: the outer `isScreenReader` declared at the top of runQuery
            // (line ~340) is in scope here via closure — no need for a second
            // declaration. Previously this re-declared inside the while loop
            // and TypeScript tolerated it as a different block scope, but it
            // was confusing and the audit flagged it as bug-bait.
            //
            // Live spinner on the "waiting for model response…" line — ticks
            // every ~90ms until the first event arrives. {S} placeholder is
            // swapped for the Braille frame each tick. In screen-reader mode
            // we skip the line entirely (the previous behavior); in non-TTY
            // mode startSpinner falls back to painting a static placeholder.
            let waitingSpinner = null;
            if (!isScreenReader) {
                const { startSpinner } = await import('./animations.js');
                waitingSpinner = startSpinner(`  {S} ${chalk.dim('waiting for model response…')}`);
            }
            // 30-second slow-model warning. If no token has arrived by 30s,
            // tell the user explicitly that the model is taking longer than
            // usual and how to bail out. Cleared as soon as ANY event arrives.
            const slowTimer = setTimeout(() => {
                if (!firstTokenSeen) {
                    console.log(chalk.yellow(`  ⏳ model is taking longer than 30s. Shift+F5 cancels, Ctrl+C exits. Often means the model returned no tokens (try /model <other> if this hangs).`));
                }
            }, 30_000);
            try {
                for await (const event of streamChat(ctx.config, apiMessages, ALL_TOOLS, streamAbort.signal)) {
                    // First event of any kind — model is alive. Cancel the slow-
                    // model warning timer; subsequent events are normal streaming.
                    if (!firstTokenSeen) {
                        firstTokenSeen = true;
                        clearTimeout(slowTimer);
                        // Tear down the live "waiting…" spinner so the next print
                        // (thinking header, response text, or tool call) lands on
                        // a fresh line. Clear the spinner row first — stop() leaves
                        // the last frame on screen.
                        if (waitingSpinner) {
                            waitingSpinner.stop();
                            process.stdout.write('\r\x1b[K');
                            waitingSpinner = null;
                        }
                    }
                    if (event.type === 'thinking' && event.content) {
                        sawAnyThinking = true;
                        // showThinking defaults to true; only off when explicitly disabled.
                        if (ctx.config.showThinking !== false) {
                            if (!thinkingActive) {
                                // Await the boot animation before streaming the first
                                // thinking token — the animation paints in-place on the
                                // header row and the streamed text needs to land on the
                                // row below it.
                                await printThinkingOpen();
                                thinkingActive = true;
                            }
                            printThinkingText(event.content);
                        }
                    }
                    else if (event.type === 'text' && event.content) {
                        if (thinkingActive) {
                            // Await the collapse animation so subsequent text streams
                            // onto a fresh row beneath the settled footer.
                            await printThinkingClose();
                            thinkingActive = false;
                        }
                        if (!hasOutput) {
                            hasOutput = true;
                            // First token arrived; promote status so F1 reports "receiving"
                            // rather than the still-waiting "streaming" message.
                            setStatus({ state: 'responding' });
                        }
                        writeStreamText(event.content);
                    }
                    else if (event.type === 'tool_call') {
                        toolCalls = event.toolCalls;
                    }
                    else if (event.type === 'done') {
                        if (event.usage) {
                            const u = event.usage;
                            const { cost, warning } = trackUsage(ctx.sessionId, ctx.config.model, u.prompt, u.completion);
                            // Single newline separator if we just streamed text, then the
                            // compact telemetry line.
                            if (hasOutput && !lastCharWasNewline)
                                process.stdout.write('\n');
                            printCost(u.prompt, u.completion, cost, warning, Date.now() - turnStart);
                        }
                    }
                }
            }
            catch (err) {
                // Stream threw before any token arrived — clear the slow-model
                // timer so its 30s callback doesn't fire after the error is
                // already on stdout (would look like a false positive).
                clearTimeout(slowTimer);
                // Tear down the waiting spinner if it's still ticking — error
                // print below shouldn't trail an animated row.
                if (waitingSpinner) {
                    waitingSpinner.stop();
                    process.stdout.write('\r\x1b[K');
                    waitingSpinner = null;
                }
                const msg = err instanceof Error ? err.message : String(err);
                // Always close the streaming line first so the error doesn't glue to text.
                if (hasOutput && !lastCharWasNewline)
                    process.stdout.write('\n');
                // ── Steer path (graceful — not an error) ──────────────
                // If the user pressed Ctrl+G / Esc during streaming, the
                // AbortController fired and the OpenAI SDK threw something like
                // "Request was aborted". Treat as a controlled end-of-turn:
                // save partial assistant text, push the queued buffer as the
                // next user message, continue the chain.
                //
                // Distinguishing USER steer from LOOP-DETECTOR abort: both fire
                // streamAbort.abort() but the loop detector sets
                // `loopDetected` first. Previously this branch keyed off the
                // SDK's error message ("abort|cancel"), which also matched
                // legitimate provider errors ("operation cancelled by upstream")
                // AND matched the loop-detector abort, causing the wrong path
                // to fire. Now we key strictly off `wasSteered` — the only path
                // that sets it is the steerHandler closure above.
                if (wasSteered) {
                    console.log(theme.warning('  ⮌ steered — taking your queued input as the next turn'));
                    // Save whatever the model managed to emit before the abort.
                    // Previously this skipped the push entirely when fullText was
                    // empty (user steered before any tokens streamed). Result:
                    // the saved session ended up with consecutive user messages
                    // and no assistant alternation — /resume printed user-only
                    // history because that's all the file contained. Now we
                    // ALWAYS push an assistant message, even if it's just a
                    // placeholder, so the conversation structure is preserved
                    // for save + restore.
                    const interruptedText = fullText.trim()
                        ? fullText + '\n[interrupted by user steer]'
                        : '[interrupted by user steer — no response generated]';
                    accumulatedAssistantText += (accumulatedAssistantText ? '\n\n' : '') + interruptedText;
                    ctx.messages.push({ role: 'assistant', content: interruptedText });
                    // Drain the queue and push as the next user turn with a marker.
                    const steerText = inputGuard.drainQueuedInput().trim();
                    if (steerText) {
                        ctx.messages.push({
                            role: 'user',
                            content: `[steer mid-turn] ${steerText}`,
                        });
                        // Don't break — continue the while loop so the new user turn
                        // gets processed in the next iteration. turns-- because the
                        // steer doesn't count against the max-turn budget.
                        turns--;
                        continue;
                    }
                    // No steer text (user hit Ctrl+G but typed nothing) — just end the chain.
                    break;
                }
                // ── Auto-fallback path ─────────────────────────────────
                // Categorize the error. If it's "unknown" (the provider returned a
                // cryptic empty error like "ERROR" or "Provider returned error" that
                // matches no specific pattern) AND we have a fallbackModel configured
                // AND we haven't already used it, swap models and silently retry the
                // same turn. This rescues users from broken free models without them
                // having to manually /clear and /model switch.
                const cat = categorizeApiError(msg, {
                    baseURL: ctx.config.baseURL,
                    provider: ctx.config.provider,
                    model: ctx.config.model,
                });
                const canFallback = cat.category === 'unknown'
                    && ctx.config.fallbackModel
                    && ctx.config.fallbackModel !== ctx.config.model
                    && !usedFallbackModel;
                if (canFallback) {
                    usedFallbackModel = true;
                    const failedModel = ctx.config.model;
                    const fallback = ctx.config.fallbackModel;
                    ctx.config.model = fallback;
                    resetClient();
                    console.log(theme.warning(`  ${sym.warn} ${failedModel} returned a cryptic provider error — retrying once with fallback model ${fallback}.`));
                    console.log(theme.dim('    (configure a different fallback with: /fallback <model-id>)'));
                    turns--; // this retry doesn't burn a turn slot from the max-turns budget
                    continue;
                }
                printApiError(msg, {
                    baseURL: ctx.config.baseURL,
                    provider: ctx.config.provider,
                    model: ctx.config.model,
                });
                // Voice: announce errors aloud for screen-reader users
                if (isVoiceEnabled(ctx.config) && getAccessibilityConfig(ctx.config).announceErrors) {
                    const tts = getTtsConfig(ctx.config);
                    if (tts.apiKey) {
                        // Keep it terse — one short sentence — to avoid burning quota on
                        // long stack traces. The error pretty-printer already showed the
                        // categorized version to the screen-reader.
                        speak(`API error: ${msg.slice(0, 120)}`, ctx.config, { voiceId: tts.assistantVoiceId }).catch(() => { });
                    }
                    if (getAccessibilityConfig(ctx.config).audioCues) {
                        audioCue('error').catch(() => { });
                    }
                }
                ctx.messages.push({ role: 'assistant', content: `[API error: ${msg}]` });
                break;
            }
            if (hasOutput && !lastCharWasNewline) {
                process.stdout.write('\n');
            }
            // Save assistant message
            const assistantMsg = { role: 'assistant', content: fullText || null };
            if (toolCalls && toolCalls.length > 0) {
                assistantMsg.tool_calls = toolCalls;
            }
            ctx.messages.push(assistantMsg);
            // Accumulate visible assistant text for chain-end TTS. We don't TTS
            // mid-chain because the model often emits short bridging sentences
            // between tool calls — speaking each one is noisy and slow.
            if (fullText)
                accumulatedAssistantText += (accumulatedAssistantText ? '\n\n' : '') + fullText;
            // If no tool calls, we're done
            if (!toolCalls || toolCalls.length === 0)
                break;
            // Execute tool calls — executeToolCalls itself flips per-tool state
            // and uses inputGuard.pause()/resume() around each permission prompt
            // so rl.question() can read user input even though suppression is on
            // for the rest of the chain. chainStats is mutated by side effect so
            // we can surface a skill-graduation hint at chain end.
            const toolResults = await executeToolCalls(toolCalls, ctx, inputGuard, chainStats);
            ctx.messages.push(...toolResults);
        }
        // Chain ended; back to idle so F1 reports the correct state.
        setStatus({ state: 'idle' });
        // ── Voice: read the assistant's final response ────────────
        // Off the hot path — fire-and-forget so the next prompt appears
        // immediately. The playback runs in background; F2 pauses, F4 skips.
        if (isVoiceEnabled(ctx.config) && accumulatedAssistantText.trim()) {
            const tts = getTtsConfig(ctx.config);
            if (tts.apiKey) {
                const a = getAccessibilityConfig(ctx.config);
                let toRead = accumulatedAssistantText;
                // If the response is long, abbreviate via cheap heuristic summary so
                // blind users aren't forced to listen to 800 words. They can press
                // F3 (replay) on chunks or ask "give me the full version" verbally.
                const words = countWords(toRead);
                if (words >= a.longResponseThreshold) {
                    toRead = summarize(toRead, a.longResponseThreshold);
                }
                // Register an abort controller + last-chunk + last-full-response
                // globally so the hotkey handler in index.ts can cancel / replay.
                // - __voiceLastChunk drives PGUP "replay last chunk"
                // - __voiceLastFullResponse drives F3 "read full" + F4 "read summary"
                const g = globalThis;
                const ctl = new AbortController();
                g.__voicePlaybackCtl = ctl;
                g.__voiceLastChunk = toRead;
                g.__voiceLastFullResponse = accumulatedAssistantText;
                speakAssistantResponse(toRead, ctx.config, ctl.signal).catch(() => { });
            }
        }
        if (Number.isFinite(maxTurns) && turns >= maxTurns) {
            console.log(theme.warning(`\n  ${sym.warn} reached configured max turns limit (${maxTurns})`));
            console.log(theme.dim(`     remove the cap by deleting maxTurns from ~/.grawkus/config.json`));
        }
        // Chain-elapsed summary. One line per response chain (user msg → assistant
        // ending without a tool call), printed regardless of how many tool-call
        // iterations the chain took. Lets the user see how long that whole
        // exchange took, separate from per-turn cost timings.
        const chainMs = Date.now() - chainStart;
        // Only show if there was meaningful work — multi-second chains. Sub-second
        // chains (slash command rejects, instant returns) don't need a chain line.
        if (chainMs > 1500) {
            console.log(theme.dim(`  chain ${formatDuration(chainMs)} · ${turns} ${turns === 1 ? 'turn' : 'turns'}`));
        }
        // /thinking visibility hint. If the user has thinking enabled but the
        // model didn't emit any reasoning tokens this turn AND we haven't
        // already shown the hint for this session, explain why nothing showed.
        // Most users hit this with general-purpose models (gpt-4o, claude-sonnet
        // without `thinking: enabled`, owl-alpha, etc.) — only reasoning models
        // (DeepSeek-R1, Claude with extended thinking, o1, etc.) actually emit
        // reasoning over the API.
        const showThinking = ctx.config.showThinking !== false;
        if (showThinking && !sawAnyThinking && !_thinkingHintShownForSession.has(ctx.sessionId)) {
            _thinkingHintShownForSession.add(ctx.sessionId);
            console.log(theme.dim(`  [hint] /thinking is ON, but ${ctx.config.model} didn't emit reasoning tokens.`));
            console.log(theme.dim(`         Reasoning models (DeepSeek-R1, o1, Claude with extended thinking) emit them; most general-purpose models don't.`));
            console.log(theme.dim(`         Hide this hint with /thinking (toggles off).`));
        }
        // ── Skill graduation hint (Hermes audit, M2 item 2) ─────────
        // Deterministic "this work was worth remembering" trigger. The model
        // is a bad judge of its own complexity (Hermes audit's exact wording);
        // we count instead. Fires at most once per chain in hermes mode, and
        // only when a clear threshold was crossed:
        //
        //   - 5+ tool calls   → complex multi-step task
        //   - error+recovery  → the agent learned a workaround
        //
        // We don't auto-execute /skill-create (it would burn another LLM call
        // and might extract noise); we surface the opportunity so the user can
        // decide. Outside hermes mode, this is silent — keeps the noise floor
        // low for regular dev/review/debug sessions.
        if (ctx.mode === 'hermes') {
            const complex = chainStats.toolCallCount >= 5;
            const learnedFromFailure = chainStats.sawToolError && chainStats.sawToolRecovery;
            if (complex || learnedFromFailure) {
                const reason = complex && learnedFromFailure
                    ? `${chainStats.toolCallCount} tools, recovered from at least one error`
                    : complex
                        ? `${chainStats.toolCallCount} tools — complex enough that a learned pattern might save time next time`
                        : `recovered from a tool error — the working path is worth banking`;
                console.log(theme.dim(`  [hermes] graduation candidate: ${reason}.`));
                console.log(theme.dim(`           Run /skill-create or /learn to bank it. Skip if it was one-off.`));
            }
        }
    }
    finally {
        // Drain any queued user input typed during streaming. Stash on
        // globalThis for the REPL loop in index.ts to pick up — it'll
        // pre-fill the next prompt or auto-submit if the user pressed
        // Enter mid-stream.
        const queued = inputGuard.drainQueuedInput();
        if (queued.trim()) {
            globalThis.__grawkusQueuedInput = queued;
        }
        inputGuard.restore();
        // Clear the per-turn abort controller pointer so a stale handle
        // can't be aborted between turns by Shift+F5 (soft-cancel).
        globalThis.__turnAbortCtl = null;
    }
}
const TOOL_CALL_LOOP_THRESHOLD = 3;
async function executeToolCalls(toolCalls, ctx, inputGuard, chainStats) {
    const results = [];
    for (const tc of toolCalls) {
        const toolName = tc.function.name;
        const tool = getToolByName(toolName);
        // ── Tool-call loop detection (error-only count) ──────
        // Fingerprint = tool name + raw arguments (truncated). The
        // counter tracks CONSECUTIVE ERRORS for the same fingerprint,
        // NOT total attempts — schema-discovery calls like `tools/list`
        // are routinely called 2-3+ times in a chain and shouldn't
        // trip the safety valve. We pre-check here; the post-execution
        // increment/reset happens further down (after we know whether
        // the call errored or succeeded).
        //
        // The fingerprint variable is hoisted so the post-exec hook
        // below can update the right map entry without recomputing.
        const tcFingerprint = chainStats
            ? `${toolName}::${String(tc.function.arguments ?? '').slice(0, 1000)}`
            : null;
        if (chainStats && tcFingerprint) {
            const errorCount = chainStats.toolCallErrorCounts.get(tcFingerprint) ?? 0;
            if (errorCount >= TOOL_CALL_LOOP_THRESHOLD && !chainStats.toolCallLoopDetected) {
                chainStats.toolCallLoopDetected = true;
                console.log(theme.error(`  ⚠ Tool-call loop detected — ${toolName} with the same arguments has failed ${errorCount} times in a row. Aborting chain.`));
                console.log(theme.dim(`    The model is stuck retrying a call that keeps failing. Common causes:`));
                console.log(theme.dim(`      · same JSON parse / schema error on every retry (read the prior error carefully)`));
                console.log(theme.dim(`      · permission denied + the user not approving (try /perm auto or yolo)`));
                console.log(theme.dim(`      · tool is unavailable in this session (e.g. stitch — needs REPL restart after config)`));
                results.push({
                    role: 'tool',
                    tool_call_id: tc.id,
                    content: `Error: tool-call loop detected — your last ${errorCount} attempts at "${toolName}" ` +
                        `with these arguments have ALL failed. The agent is refusing further identical ` +
                        `retries. Read the prior tool-result error carefully and either (a) change the ` +
                        `arguments substantively, (b) use a different tool to achieve the goal, or ` +
                        `(c) stop and report the issue to the user.`,
                });
                return results; // abort the rest of this turn's tool calls
            }
        }
        // Helpers for the error-count tracker. Called on each error
        // path below (bumps the count for this fingerprint) and after
        // a successful execution (resets to 0 — model recovered).
        const bumpFingerprintError = () => {
            if (chainStats && tcFingerprint) {
                const cur = chainStats.toolCallErrorCounts.get(tcFingerprint) ?? 0;
                chainStats.toolCallErrorCounts.set(tcFingerprint, cur + 1);
            }
        };
        const clearFingerprintError = () => {
            if (chainStats && tcFingerprint) {
                chainStats.toolCallErrorCounts.set(tcFingerprint, 0);
            }
        };
        if (!tool) {
            // Free models routinely hallucinate tool names (web_search_exa,
            // google_search, etc.). Telling the model exactly what IS available lets
            // it self-correct on the next iteration instead of giving up.
            const valid = ALL_TOOLS.map((t) => t.name).join(', ');
            console.log(theme.error(`  ${sym.error} Unknown tool: ${toolName} (valid: ${valid})`));
            results.push({
                role: 'tool',
                tool_call_id: tc.id,
                content: `Error: tool "${toolName}" does not exist. Available tools: ${valid}. ` +
                    `Retry with one of these exact names. Do not invent tool names — if you ` +
                    `need a capability not in this list, work around it using the tools that exist ` +
                    `(e.g. use web_search for discovery, web_fetch for a known URL, bash for shell-only operations).`,
            });
            bumpFingerprintError();
            continue;
        }
        let input;
        try {
            input = JSON.parse(tc.function.arguments);
        }
        catch (parseErr) {
            // The model emitted malformed JSON in the tool_calls arguments
            // field. Without a useful error the model loops with the same
            // broken call (observed in user testing: 3 retries on a
            // write_file with a huge HTML content burned 25K tokens). Give
            // the model concrete diagnostics so it can self-correct on the
            // next iteration:
            //   1. The actual parser error (location + what went wrong)
            //   2. A truncated preview of what it sent
            //   3. Hints about the most common causes
            const rawArgs = String(tc.function.arguments ?? '');
            const errMsg = parseErr instanceof Error ? parseErr.message : String(parseErr);
            const preview = rawArgs.length > 400
                ? rawArgs.slice(0, 200) + '\n…[truncated ' + (rawArgs.length - 400) + ' chars]…\n' + rawArgs.slice(-200)
                : rawArgs;
            console.log(theme.error(`  ${sym.error} Invalid tool arguments for ${toolName} — could not parse as JSON`));
            console.log(theme.dim(`    parser error: ${errMsg}`));
            // Heuristic hints to surface the most common root causes the
            // model usually misses on retry.
            const hints = [];
            if (rawArgs.length > 100_000) {
                hints.push('argument payload is very large (' + rawArgs.length + ' chars) — for large file writes, ' +
                    'prefer apply_patch (which handles large content more reliably) over write_file with the ' +
                    'entire content as a single JSON string');
            }
            if (/[^\\]\n/.test(rawArgs.slice(0, 5000))) {
                hints.push('unescaped newline in a JSON string — every literal newline inside a string value must be \\n, not a raw newline');
            }
            if (/,\s*[}\]]/.test(rawArgs)) {
                hints.push('trailing comma before } or ] — strict JSON forbids this');
            }
            results.push({
                role: 'tool',
                tool_call_id: tc.id,
                content: `Error: could not parse tool arguments as JSON.\n` +
                    `Parser error: ${errMsg}\n` +
                    `What you sent (truncated): ${preview}\n` +
                    (hints.length > 0
                        ? `Likely causes:\n  - ${hints.join('\n  - ')}\n`
                        : '') +
                    `Do NOT retry with the same arguments. Fix the JSON issue and try again, ` +
                    `or use a different approach (e.g. split a huge write into multiple smaller writes).`,
            });
            bumpFingerprintError();
            // ── Per-tool parse-failure streak detector ────────────
            // The per-fingerprint detector misses the case where args
            // differ slightly each retry (e.g. 27KB of HTML regenerated
            // with cosmetic variation between attempts, but every
            // attempt fails JSON.parse at a slightly different byte
            // position). Track streaks per TOOL NAME — if the same
            // tool fails to JSON-parse N times in a row, the issue
            // is the SHAPE of what the model is sending, not the
            // specific args; halt with a stronger directive.
            if (chainStats) {
                const streak = (chainStats.toolParseFailureStreaks.get(toolName) ?? 0) + 1;
                chainStats.toolParseFailureStreaks.set(toolName, streak);
                if (streak >= TOOL_CALL_LOOP_THRESHOLD && !chainStats.toolCallLoopDetected) {
                    chainStats.toolCallLoopDetected = true;
                    console.log(theme.error(`  ⚠ Parse-failure streak — ${toolName} has failed JSON.parse ${streak} times in a row. Aborting chain.`));
                    console.log(theme.dim(`    The model keeps generating malformed JSON for this tool. Likely causes:`));
                    console.log(theme.dim(`      · payload too large for a single tool call (use apply_patch for big file writes,`));
                    console.log(theme.dim(`        split into multiple smaller calls, or write via bash heredoc instead)`));
                    console.log(theme.dim(`      · content with unescaped quotes / newlines / backslashes that break JSON encoding`));
                    // Override the last error result with a stronger
                    // terminal message that the model will read in the next
                    // turn. Don't push an additional result — keep the
                    // tool_call_id chain intact.
                    results[results.length - 1] = {
                        role: 'tool',
                        tool_call_id: tc.id,
                        content: `Error: parse-failure streak detected — your last ${streak} attempts at "${toolName}" ` +
                            `have ALL failed JSON.parse (each with a slightly different byte position). The agent ` +
                            `is refusing further attempts. DO NOT regenerate this tool call. Either:\n` +
                            `  1. Use apply_patch instead (handles large file content via a different envelope)\n` +
                            `  2. Split the work into multiple smaller calls (e.g. write the file in chunks via bash echo + >>)\n` +
                            `  3. Stop and report the issue to the user`,
                    };
                    return results; // abort the rest of this turn's tool calls
                }
            }
            continue;
        }
        // Validate arguments against tool's JSON schema
        const validation = validateToolArguments(tool, input);
        if (!validation.valid) {
            console.log(theme.error(`  ${sym.error} Invalid tool arguments for ${toolName}: ${validation.error}`));
            // Same fail-loud philosophy as the JSON-parse path: tell the
            // model what it actually sent + the validation rule + how to
            // recover. Without "Do NOT retry with the same arguments" the
            // model often retries with structurally-identical input.
            results.push({
                role: 'tool',
                tool_call_id: tc.id,
                content: `Error: tool arguments failed schema validation.\n` +
                    `Validation error: ${validation.error}\n` +
                    `Tool: ${toolName}\n` +
                    `What you sent: ${JSON.stringify(input).slice(0, 400)}\n` +
                    `Do NOT retry with the same arguments. Adjust the arguments to satisfy the schema and try again.`,
            });
            bumpFingerprintError();
            continue;
        }
        // ── Security scan ─────────────────────────────────────
        const secResult = scanToolCall(toolName, input);
        if (secResult.threats.length > 0) {
            printSecurityWarning(secResult);
        }
        if (secResult.blocked) {
            results.push({
                role: 'tool',
                tool_call_id: tc.id,
                content: `BLOCKED by security scanner: ${secResult.threats.join('; ')}`,
            });
            bumpFingerprintError();
            continue;
        }
        // ── Pre-tool hook ─────────────────────────────────────
        const preHook = await runHooks({
            event: 'PreToolUse',
            toolName,
            toolInput: input,
            sessionId: ctx.sessionId,
            cwd: ctx.cwd,
            permissionMode: ctx.config.permissionMode,
        });
        if (!preHook.allowed) {
            dbgEmit('info', 'hook.blocked', {
                tool: toolName,
                reason: preHook.message || 'denied',
            });
            results.push({
                role: 'tool',
                tool_call_id: tc.id,
                content: `Blocked by hook: ${preHook.message || 'denied'}`,
            });
            bumpFingerprintError();
            continue;
        }
        // ── Accessibility: TTS-announce destructive actions ───
        // Plays before the permission prompt so a blind user hears WHAT they're
        // approving in addition to the visual prompt. Only fires for tools
        // flagged destructive AND when voice + askBeforeDestructive are on.
        if (isVoiceEnabled(ctx.config) && getAccessibilityConfig(ctx.config).askBeforeDestructive) {
            if (isLikelyDestructive(toolName, input)) {
                const tts = getTtsConfig(ctx.config);
                if (tts.apiKey) {
                    const blurb = describeDestructive(toolName, input);
                    await speak(blurb, ctx.config, { voiceId: tts.assistantVoiceId }).catch(() => false);
                }
            }
        }
        // ── Permission check ──────────────────────────────────
        // Pause input suppression so rl.question() can read the user's
        // Y/n/always response — without this, readline's keypress listener is
        // detached and the prompt would hang forever. Re-suppress immediately
        // after so any typing during the next tool's execution is blocked.
        inputGuard.pause();
        let allowed;
        try {
            allowed = await checkPermission(tool, input, ctx.config, ctx.rl);
        }
        finally {
            inputGuard.resume();
        }
        if (!allowed) {
            console.log(theme.warning(`  ${sym.warn} Denied: ${toolName}`));
            results.push({
                role: 'tool',
                tool_call_id: tc.id,
                content: 'Permission denied by user.',
            });
            bumpFingerprintError();
            continue;
        }
        // ── Execute ───────────────────────────────────────────
        // printToolRun is now async (animated boot + persistent spinner).
        // Await before kicking off the actual tool — the boot animation
        // is short (~150ms) and the spinner starts ticking immediately
        // after, so the user sees motion the entire time the tool runs.
        await printToolRun(toolName, formatArgs(tool, input));
        // Update status for F1 status hotkey — include a short arg snippet so
        // the speaker can hear "executing bash, list directory, 4 seconds
        // elapsed" instead of just "tool".
        setStatus({ state: 'tool-call', detail: `${toolName}: ${formatArgs(tool, input).slice(0, 60)}` });
        let result;
        if (ctx.config.dryRun) {
            console.log(theme.warning(`  ${sym.pending} [DRY RUN] Would execute: ${toolName}`));
            console.log(theme.warning(`           Arguments: ${JSON.stringify(input).slice(0, 100)}`));
            result = {
                isError: false,
                output: `[DRY RUN] Tool execution skipped`,
            };
        }
        else {
            const startTime = Date.now();
            dbgEmit('debug', 'tool.start', { tool: toolName, input });
            result = await tool.call(input, ctx.cwd);
            const elapsed = Date.now() - startTime;
            dbgEmit('info', 'tool.done', {
                tool: toolName,
                elapsedMs: elapsed,
                isError: !!result.isError,
                outputPreview: String(result.output ?? '').slice(0, 200),
            });
            // Stops the spinner, paints the settle animation, then commits
            // the final ✓/✗ result line. Async — must await so the next
            // tool's run line doesn't overwrite mid-settle.
            await printToolResult(!result.isError, elapsed, result.output);
        }
        // Stash last-tool-call info so the Shift+F3 hotkey (src/index.ts) can
        // re-announce "what did the model just do." Truncate the preview to
        // keep TTS readouts short and avoid pinning huge buffers in memory.
        globalThis.__lastToolCall = {
            name: toolName,
            argsPreview: formatArgs(tool, input).slice(0, 80),
            outputPreview: String(result.output ?? '').slice(0, 160),
            isError: !!result.isError,
        };
        // Loop-detector fingerprint bookkeeping for the EXECUTED path.
        // The tool actually ran; whether it succeeded or returned an
        // error result, we know more than at the pre-checks above.
        // Successful exec → reset error count (the model can repeat
        // legitimate calls like tools/list without tripping the loop).
        // Tool returned an error result → bump (counts as another
        // failure for this fingerprint).
        if (result.isError) {
            bumpFingerprintError();
        }
        else {
            clearFingerprintError();
            // A successful exec for this tool also breaks its
            // parse-failure streak. The model recovered from whatever
            // was generating malformed JSON.
            if (chainStats)
                chainStats.toolParseFailureStreaks.set(toolName, 0);
        }
        // Chain stats — bump the counter on every successful execution path
        // we got here through (denied / blocked tool calls hit `continue`
        // above and don't reach this point). Recovery = success-after-error
        // within the same chain.
        if (chainStats) {
            chainStats.toolCallCount++;
            if (result.isError) {
                chainStats.sawToolError = true;
            }
            else if (chainStats.sawToolError) {
                chainStats.sawToolRecovery = true;
            }
        }
        // ── Post-tool hook ────────────────────────────────────
        await runHooks({
            event: 'PostToolUse',
            toolName,
            toolInput: input,
            // Defensively coerce to string — some tool implementations may
            // return non-string output (Buffer, object, undefined) and the
            // raw .slice would throw, killing the chain after the tool
            // already ran. Matches the same coercion pattern used by the
            // __lastToolCall stash above.
            toolOutput: String(result.output ?? '').slice(0, 1000),
            sessionId: ctx.sessionId,
            cwd: ctx.cwd,
            permissionMode: ctx.config.permissionMode,
        });
        results.push({
            role: 'tool',
            tool_call_id: tc.id,
            content: result.output,
        });
    }
    return results;
}
function formatArgs(tool, input) {
    switch (tool.name) {
        case 'bash':
            return `$ ${input.command}`;
        case 'read_file':
            return String(input.file_path);
        case 'write_file':
            return String(input.file_path);
        case 'edit_file':
            return String(input.file_path);
        case 'grep':
            return `/${input.pattern}/${input.path ? ` in ${input.path}` : ''}`;
        case 'glob':
            return String(input.pattern);
        case 'list_dir':
            return String(input.path || '.');
        case 'web_fetch':
            return String(input.url);
        default:
            return JSON.stringify(input).slice(0, 80);
    }
}
//# sourceMappingURL=query.js.map