// Task 183 — incremental JSONL tail for specialist (database-operator)
// recorder spawns. Posts two log-line shapes to /api/admin/log-ingest so a
// grep against server.log answers, for any sessionId, whether the model
// received its tools.
//
//   Line 1 — `[recorder-session-init] sessionId=<id> specialist=<name>
//             toolsExposedCount=<n> toolsExposed=<csv> toolsArgvCount=<n>
//             toolsArgvMissingFromExposed=<csv|—> source=<source>`
//     Emitted once per registered session. `source=` names the data path:
//       - `system.init` / `command_permissions` / `deferred_tools_delta`:
//         the SDK wrote an init record to the JSONL and we matched it.
//       - `headless-argv-fallback`: the assistant's first record landed
//         without any JSONL init record matching. Task 185 confirmed (Pi
//         evidence, 2026-05-20) that headless `claude --verbose` PTY mode
//         simply does not write a tool-list record to JSONL; the bridge-
//         attached `attachment.deferred_tools_delta` only appears under
//         `--remote-control`, and it carries Claude Code built-ins, not
//         MCP server tools. In headless mode the authoritative tool set
//         is the spawner's `--allowed-tools` argv list (Task 178's
//         `[pty-spawn-tool-inventory]` independently verifies that every
//         argv name is published by the MCP server), so we emit Line 1
//         with argvTools as the exposed set and tag the source.
//
//   Line 2 — `[recorder-turn] sessionId=<id> turnIndex=<n> stopReason=<r>
//             textBytes=<n> text=<full json-escaped>
//             toolUseNames=<csv|—> containsToolCallText=true|false`
//     Emitted once per newly-appended `assistant` record. Records with the
//     same `message.id` collapse to one line (only the first sighting
//     emits). Records that contain only `thinking` blocks emit nothing.
//     `text=` is the full assistant text, json-escaped — Task 213 dropped
//     the 240-char head cap so a recorder relapse that emits
//     `<tool_call>{…}</tool_call>` as text is observable from server.log
//     without downloading the jsonl. `containsToolCallText=true` flags any
//     assistant text containing `<tool_call>`, `<function_calls>`, or
//     `</invoke>` — the recorder model copying tool-call shape into prose.
//
// The cursor is append-only: `cursorOffset` is the byte position past the
// last `\n` we successfully parsed; the trailing fragment lives in
// `partialLine` until a future read completes it. Without this, a JSONL
// append landing mid-record produces a parse error and silently drops the
// record — exactly the failure mode this task closes.
import { existsSync, openSync, closeSync, readSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
const HEAD_READ_CHUNK = 64 * 1024;
// Task 213 — regex against assistant text. Matches `<tool_call>`,
// `<function_calls>`, and `</invoke>` — three shapes the recorder model
// emitted as prose in sessions d2aaa85e / 481799d1 (and similar evidence
// in 596a42bc, c3539844, 4d4775bd, bad79fb9, 06ac2eb3, 6828bae0,
// c4c10cf3, 5cb8fd16) instead of real `tool_use` blocks. `\b` against the
// `>` boundary keeps the match precise.
const TOOL_CALL_TEXT_PATTERN = /<tool_call\b|<function_calls\b|<\/invoke\b/;
/** Task 571 — tools the admin can legitimately call inline without delegating.
 *  Reads + the lightweight in-context primitives the doctrine names as
 *  exceptions. Everything else (any other built-in tool, any MCP tool other
 *  than a memory-search read) classifies as "direct" work the admin should
 *  have delegated, which is the load-bearing signal the
 *  `[admin-delegation-ratio]` line watches. The pattern check covers MCP
 *  search/lookup tools whose short name starts `memory-search` or contains
 *  `-search` / `-read` / `-get` so an admin reconnaissance turn does not
 *  inflate the direct count. */
const ADMIN_READ_TOOLS = new Set([
    'Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'TodoRead', 'TodoWrite',
]);
function isReadOnlyTool(name) {
    if (ADMIN_READ_TOOLS.has(name))
        return true;
    // MCP tools — names follow `mcp__<plugin>__<short>`. Treat anything whose
    // short name signals lookup/read as a read for ratio purposes.
    const short = name.startsWith('mcp__') ? name.split('__').slice(2).join('__') : name;
    return /^memory-search|^memory-read|-search$|-read$|-get$|-list$/.test(short);
}
/** Task 571 — classify an admin turn for the delegation-ratio counter.
 *  Agent dispatch always wins; otherwise any non-read tool use is direct. */
export function classifyAdminTurn(toolUseNames) {
    if (toolUseNames.length === 0)
        return null;
    if (toolUseNames.includes('Agent'))
        return 'delegated';
    for (const t of toolUseNames) {
        if (!isReadOnlyTool(t))
            return 'direct';
    }
    return null;
}
/** Production implementation of LogLinePoster. POSTs to the brand UI's
 *  loopback log-ingest route. Fire-and-forget; failure is logged via the
 *  manager's own logger and otherwise swallowed. */
export function postLogLineViaFetch(opts) {
    const url = `http://127.0.0.1:${opts.uiPort}/api/admin/log-ingest`;
    return (tag, level, line) => {
        void fetch(url, {
            method: 'POST',
            headers: { 'content-type': 'application/json' },
            body: JSON.stringify({ tag, level, line }),
        }).catch((err) => {
            const msg = err instanceof Error ? err.message : String(err);
            opts.logger(`[jsonl-tail] log-ingest-post-failed tag=${tag} err=${JSON.stringify(msg)}`);
        });
    };
}
/** Read a complete JSON record line. Returns the parsed object, or null
 *  if the line is empty / malformed (silently skipped — JSONL grew before
 *  the writer flushed). */
function parseJsonl(line) {
    const trimmed = line.trim();
    if (trimmed.length === 0)
        return null;
    try {
        const obj = JSON.parse(trimmed);
        if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
            return obj;
        }
        return null;
    }
    catch {
        return null;
    }
}
/** Probe one JSONL record for an init candidate. Returns the matched
 *  tool list when found, else null. Three probe shapes in priority order:
 *  `system.init`, `attachment.command_permissions`, then
 *  `attachment.deferred_tools_delta`. Each probe is intentionally narrow:
 *  it must see the exact shape that a real CLI session writes, otherwise
 *  downstream fallbacks get their chance. Headless `claude --verbose`
 *  PTY mode writes none of these — Task 185's argv fallback in
 *  `emitInitFallbackArgv` handles that path. */
export function probeInitTools(rec) {
    // (1) system.subtype=init with tools[] — the headless SDK init record.
    if (rec.type === 'system' && rec.subtype === 'init') {
        const tools = rec.tools;
        if (Array.isArray(tools)) {
            const named = tools.filter((t) => typeof t === 'string');
            if (named.length > 0)
                return { source: 'system.init', tools: named };
        }
    }
    // (2) attachment.command_permissions.allowedTools — the literal
    //     `--allowed-tools` value reflected back.
    const att = rec.attachment;
    if (att && typeof att === 'object' && !Array.isArray(att)) {
        const a = att;
        if (a.type === 'command_permissions') {
            const allowed = a.allowedTools;
            if (Array.isArray(allowed)) {
                const named = allowed.filter((t) => typeof t === 'string');
                if (named.length > 0)
                    return { source: 'command_permissions', tools: named };
            }
        }
        // (3) attachment.deferred_tools_delta.addedNames — fallback for CLI
        //     versions where command_permissions is empty but deferred names
        //     carry the resolved set.
        if (a.type === 'deferred_tools_delta') {
            const added = a.addedNames;
            if (Array.isArray(added)) {
                const named = added.filter((t) => typeof t === 'string');
                if (named.length > 0)
                    return { source: 'deferred_tools_delta', tools: named };
            }
        }
    }
    return null;
}
/** JSON-escape a string for inclusion in a log line. Renders control
 *  characters as `\uXXXX`, quotes the result. Callers strip the quotes
 *  if they want; this module keeps them so the boundaries of
 *  `text=...` are explicit on screen. */
function jsonEscape(s) {
    return JSON.stringify(s);
}
function summarizeAssistantRecord(rec) {
    if (rec.type !== 'assistant')
        return null;
    const msg = rec.message;
    if (!msg || typeof msg !== 'object' || Array.isArray(msg))
        return null;
    const m = msg;
    const msgId = typeof m.id === 'string' ? m.id : null;
    const stopReason = typeof m.stop_reason === 'string' ? m.stop_reason : '';
    const content = m.content;
    let text = '';
    const toolUseNames = [];
    let sawThinking = false;
    if (Array.isArray(content)) {
        for (const block of content) {
            if (!block || typeof block !== 'object')
                continue;
            const b = block;
            if (b.type === 'text' && typeof b.text === 'string') {
                text += b.text;
            }
            else if (b.type === 'tool_use' && typeof b.name === 'string') {
                toolUseNames.push(b.name);
            }
            else if (b.type === 'thinking') {
                sawThinking = true;
            }
        }
    }
    else if (typeof content === 'string') {
        text = content;
    }
    const isThinkingOnly = sawThinking && text.length === 0 && toolUseNames.length === 0;
    return { msgId, text, stopReason, toolUseNames, isThinkingOnly };
}
/** Extract the text of the first user message from a `type=user` record.
 *  Returns null when the record is not a usable user message (no `message`
 *  object, no text content, or content is exclusively `tool_result` blocks
 *  from a parent subagent — those land on later turns and should not be
 *  mistaken for the brief). */
function extractFirstUserText(rec) {
    const msg = rec.message;
    if (!msg || typeof msg !== 'object' || Array.isArray(msg))
        return null;
    const m = msg;
    const content = m.content;
    if (typeof content === 'string') {
        return content;
    }
    if (Array.isArray(content)) {
        let text = '';
        for (const block of content) {
            if (!block || typeof block !== 'object')
                continue;
            const b = block;
            if (b.type === 'text' && typeof b.text === 'string') {
                text += b.text;
            }
        }
        return text.length > 0 ? text : null;
    }
    return null;
}
export function classifyOutcome(args) {
    if (args.toolUseNames.length > 0)
        return 'done';
    if (args.containsToolCallText)
        return 'stalled';
    if (args.stopReason === 'end_turn') {
        return args.textBytes > 0 ? 'stalled' : 'refused';
    }
    return null;
}
export function createJsonlTail(deps) {
    const sessions = new Map();
    function emitInitFound(state, source, exposed) {
        const exposedSet = new Set(exposed);
        const missing = [];
        for (const t of state.argvTools) {
            if (!exposedSet.has(t))
                missing.push(t);
        }
        missing.sort();
        const line = `sessionId=${state.sessionId} specialist=${state.specialist}` +
            ` toolsExposedCount=${exposed.length} toolsExposed=${exposed.join(',')}` +
            ` toolsArgvCount=${state.argvTools.size}` +
            ` toolsArgvMissingFromExposed=${missing.length === 0 ? '—' : missing.join(',')}` +
            ` source=${source}`;
        deps.postLogLine('recorder-session-init', 'info', line);
        state.initEmitted = true;
    }
    function emitInitFallbackArgv(state) {
        // Task 185 — headless `claude --verbose` PTY mode does NOT write a
        // tool-list record to JSONL (verified on Pi 2026-05-20 across every
        // recorder JSONL: no `system.subtype=init`, no
        // `attachment.command_permissions`, no `attachment.deferred_tools_delta`
        // with MCP names). The earlier `error=init-record-not-found` emission
        // suggested we should keep probing for a record shape that does not
        // exist in this mode. Replace it with a positive Line 1 that uses the
        // spawner's `--allowed-tools` argv set as the exposed set — the
        // authoritative tool list for headless spawns, independently verified
        // by Task 178's `[pty-spawn-tool-inventory]`. The `source=` tag tells
        // a future grep that this Line 1 was sourced from argv, not from the
        // JSONL's init record.
        const exposed = Array.from(state.argvTools).sort();
        const line = `sessionId=${state.sessionId} specialist=${state.specialist}` +
            ` toolsExposedCount=${exposed.length} toolsExposed=${exposed.join(',')}` +
            ` toolsArgvCount=${state.argvTools.size}` +
            ` toolsArgvMissingFromExposed=—` +
            ` source=headless-argv-fallback`;
        deps.postLogLine('recorder-session-init', 'info', line);
        state.initEmitted = true;
    }
    function emitTurn(state, summary) {
        const idx = state.nextTurnIndex;
        state.nextTurnIndex += 1;
        const textBytes = Buffer.byteLength(summary.text, 'utf8');
        const namesField = summary.toolUseNames.length === 0 ? '—' : summary.toolUseNames.join(',');
        const containsToolCallText = TOOL_CALL_TEXT_PATTERN.test(summary.text);
        const line = `sessionId=${state.sessionId} turnIndex=${idx}` +
            ` stopReason=${summary.stopReason || '—'}` +
            ` textBytes=${textBytes}` +
            ` text=${jsonEscape(summary.text)}` +
            ` toolUseNames=${namesField}` +
            ` containsToolCallText=${containsToolCallText}`;
        deps.postLogLine('recorder-turn', 'info', line);
        // Task 565 observability item 3 — per-turn outcome census. Adjacent to
        // the recorder-turn line so a `[specialist-outcome]` grep across server.log
        // gives a brief-failure histogram without parsing the recorder text.
        const outcome = classifyOutcome({
            stopReason: summary.stopReason,
            toolUseNames: summary.toolUseNames,
            textBytes,
            containsToolCallText,
        });
        if (outcome !== null) {
            deps.postLogLine('specialist-outcome', 'info', `sessionId=${state.sessionId} specialist=${state.specialist}` +
                ` turnIndex=${idx} brief=${state.briefToken} outcome=${outcome}`);
        }
        // Task 571 — delegation-ratio counter for admin sessions. Emitted in
        // one batch at unregister time; per-turn classification feeds that.
        if (state.specialist === 'admin') {
            const cls = classifyAdminTurn(summary.toolUseNames);
            if (cls === 'delegated')
                state.adminDelegatedTurns += 1;
            else if (cls === 'direct')
                state.adminDirectTurns += 1;
        }
        // Task 215 — fire the first-turn callback exactly once. The flag
        // flip and reference clear happen BEFORE invoking the callback so a
        // thrown callback can't leave `firstTurnEmitted=false` and re-fire
        // on the next record. Errors from the callback are caught and
        // logged but never propagate — the `recorder-turn` line above is
        // the contract, and the callback is a downstream observer.
        if (!state.firstTurnEmitted) {
            state.firstTurnEmitted = true;
            const cb = state.onFirstTurn;
            state.onFirstTurn = null;
            if (cb !== null) {
                try {
                    cb();
                }
                catch (err) {
                    deps.logger(`[jsonl-tail] first-turn-callback-threw sessionId=${state.sessionId} err=${err instanceof Error ? err.message : String(err)}`);
                }
            }
        }
    }
    function processRecord(state, rec) {
        // Probe init candidates against EVERY record until init emits — the
        // CLI may write a `system.init` after several `attachment` records,
        // and we'd rather wait one extra line than emit a fallback prematurely.
        if (!state.initEmitted) {
            const probed = probeInitTools(rec);
            if (probed !== null) {
                emitInitFound(state, probed.source, probed.tools);
            }
        }
        // Task 565 observability item 3 — capture the brief token from the
        // first `user` text record. The token is the first 16 hex chars of the
        // sha256 of the user text; stable across re-runs of the same brief so
        // a recurring `outcome=stalled` on one brief is greppable.
        if (state.briefToken === '—' && rec.type === 'user') {
            const brief = extractFirstUserText(rec);
            if (brief !== null && brief.length > 0) {
                state.briefToken = createHash('sha256').update(brief, 'utf8').digest('hex').slice(0, 16);
            }
        }
        // Assistant records — collapse on msg.id, skip thinking-only.
        const summary = summarizeAssistantRecord(rec);
        if (summary === null)
            return;
        if (summary.isThinkingOnly)
            return;
        // Fall back to argv as the exposed set once an assistant turn lands
        // without any JSONL init candidate having matched. See
        // `emitInitFallbackArgv` for the architectural rationale.
        if (!state.initEmitted) {
            emitInitFallbackArgv(state);
        }
        if (summary.msgId !== null) {
            if (state.emittedMsgIds.has(summary.msgId))
                return;
            state.emittedMsgIds.add(summary.msgId);
        }
        emitTurn(state, summary);
    }
    function readAppended(state, jsonlPath) {
        let stats;
        try {
            stats = statSync(jsonlPath);
        }
        catch {
            return;
        }
        if (!stats.isFile())
            return;
        const size = stats.size;
        if (size <= state.cursorOffset) {
            // File shrank — likely log rotation or deletion. Reset cursor;
            // next modify event will read from 0.
            if (size < state.cursorOffset) {
                state.cursorOffset = 0;
                state.partialLine = '';
            }
            return;
        }
        let fd;
        try {
            fd = openSync(jsonlPath, 'r');
        }
        catch (err) {
            deps.logger(`[jsonl-tail] open-failed sessionId=${state.sessionId} path=${jsonlPath} err=${err instanceof Error ? err.message : String(err)}`);
            return;
        }
        try {
            let offset = state.cursorOffset;
            let buffer = state.partialLine;
            const chunk = Buffer.alloc(HEAD_READ_CHUNK);
            while (offset < size) {
                const want = Math.min(HEAD_READ_CHUNK, size - offset);
                const n = readSync(fd, chunk, 0, want, offset);
                if (n <= 0)
                    break;
                buffer += chunk.toString('utf8', 0, n);
                offset += n;
            }
            // Split into complete lines; the trailing fragment (after the last
            // `\n`) lives in `partialLine` until a future read completes it.
            const lastNl = buffer.lastIndexOf('\n');
            const completeBlock = lastNl === -1 ? '' : buffer.slice(0, lastNl);
            const remainder = lastNl === -1 ? buffer : buffer.slice(lastNl + 1);
            state.cursorOffset = offset;
            state.partialLine = remainder;
            if (completeBlock.length === 0)
                return;
            for (const raw of completeBlock.split('\n')) {
                const rec = parseJsonl(raw);
                if (rec === null)
                    continue;
                processRecord(state, rec);
            }
        }
        finally {
            try {
                closeSync(fd);
            }
            catch { /* ignore */ }
        }
    }
    return {
        register(sessionId, args) {
            if (sessions.has(sessionId))
                return;
            const argvTools = new Set([...args.argvTools].filter((t) => typeof t === 'string' && t.length > 0));
            sessions.set(sessionId, {
                sessionId,
                specialist: args.specialist,
                argvTools,
                cursorOffset: 0,
                partialLine: '',
                initEmitted: false,
                emittedMsgIds: new Set(),
                nextTurnIndex: 0,
                onFirstTurn: args.onFirstTurn ?? null,
                firstTurnEmitted: false,
                briefToken: '—',
                adminDelegatedTurns: 0,
                adminDirectTurns: 0,
            });
            deps.logger(`[jsonl-tail] register sessionId=${sessionId} specialist=${args.specialist} argvTools=${argvTools.size}`);
        },
        onJsonlModify(sessionId, jsonlPath) {
            const state = sessions.get(sessionId);
            if (!state)
                return;
            if (!existsSync(jsonlPath))
                return;
            readAppended(state, jsonlPath);
        },
        unregister(sessionId) {
            const state = sessions.get(sessionId);
            if (!state)
                return;
            // Task 571 — emit the delegation-ratio counter for admin sessions
            // before clearing state. Always emit (even when both counters are 0)
            // so a zero-activity admin session still produces a line for the
            // operator-side audit (`grep '\[admin-delegation-ratio\]'`).
            if (state.specialist === 'admin') {
                deps.postLogLine('admin-delegation-ratio', 'info', `sessionId=${state.sessionId}` +
                    ` direct=${state.adminDirectTurns}` +
                    ` delegated=${state.adminDelegatedTurns}`);
            }
            sessions.delete(sessionId);
            deps.logger(`[jsonl-tail] unregister sessionId=${sessionId}`);
        },
        __peek(sessionId) {
            const s = sessions.get(sessionId);
            if (!s)
                return null;
            return {
                cursorOffset: s.cursorOffset,
                initEmitted: s.initEmitted,
                nextTurnIndex: s.nextTurnIndex,
            };
        },
    };
}
//# sourceMappingURL=jsonl-tail.js.map