// Observe (do not predict) the JSONL file Claude Code writes for a session.
//
// the manager used to derive `jsonlPath` from the captured
// `/remote-control` URL at spawn time. That id is the *bridge* sessionId
// (the suffix after `session_` in the URL), which is NOT the same as the
// *intrinsic* sessionId that Claude Code uses for the JSONL filename.
// `GET /list` then merged the live store (keyed by bridge id) with the
// on-disk enumerator (keyed by intrinsic id) and the dedup never matched,
// producing one phantom row per spawn.
//
// The fix: never predict. Watch the project dir; when a new JSONL appears,
// read its head bytes and bind it to the spawn whose URL bridge-suffix
// appears verbatim somewhere in those bytes. Claude Code writes a
// `bridgeSessionId=cse_<suffix>` record in the JSONL head; the suffix is
// also the part after `session_` in the URL we captured. Substring match
// over the head bytes is the deterministic link, schema-agnostic and
// concurrency-safe — two spawns never share a random URL suffix.
//
// One shared `fs.watch` on the project dir. Each spawn calls `observe()`
// to register a pending observation; the call does a one-shot scan first
// (closes the snapshot-vs-watch race) and otherwise resolves on the next
// `rename`/`change` event whose head bytes match. PTY exit before the
// JSONL appears calls `cancel()` to drop the pending registration.
import { existsSync, mkdirSync, openSync, closeSync, readSync, readdirSync, statSync, watch } from 'node:fs';
import { join } from 'node:path';
const HEAD_READ_BYTES = 4 * 1024;
// Cap on the head-preview substring rendered in `*-no-match` log lines so
// a 4 KiB head doesn't spam a multi-line log row. 200 chars is the first
// line or two of a JSONL plus enough of the next record to spot a wrong
// bridge suffix at a glance.
const HEAD_PREVIEW_CHARS = 200;
/** Read the first HEAD_READ_BYTES of a file. Returns empty string on any
 *  IO error (ENOENT, EACCES, file truncated mid-read). The caller treats
 *  empty as "no match yet" and keeps the pending observation registered
 *  so a subsequent `change` event re-reads after Claude finishes writing. */
function readHead(path, logger) {
    let fd = null;
    try {
        fd = openSync(path, 'r');
        const stat = statSync(path);
        const want = Math.min(stat.size, HEAD_READ_BYTES);
        if (want === 0)
            return '';
        const buf = Buffer.alloc(want);
        const got = readSync(fd, buf, 0, want, 0);
        return buf.toString('utf8', 0, got);
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        logger(`jsonl-observer head-read-failed path=${path} message=${msg}`);
        return '';
    }
    finally {
        if (fd !== null) {
            try {
                closeSync(fd);
            }
            catch { /* ignore */ }
        }
    }
}
/** Try to bind the given JSONL to any pending observation. Returns the
 *  observation that matched (already resolved) and the bytes read. */
function tryBindFile(jsonlPath, pendings, logger) {
    if (pendings.size === 0)
        return { bound: null, head: '' };
    const head = readHead(jsonlPath, logger);
    if (!head)
        return { bound: null, head: '' };
    for (const p of pendings) {
        if (p.settled)
            continue;
        if (head.includes(p.bridgeSuffix)) {
            const claudeSessionId = jsonlPath.endsWith('.jsonl')
                ? jsonlPath.slice(jsonlPath.lastIndexOf('/') + 1, -'.jsonl'.length)
                : jsonlPath.slice(jsonlPath.lastIndexOf('/') + 1);
            p.settled = true;
            p.resolve({ jsonlPath, claudeSessionId });
            pendings.delete(p);
            return { bound: p, head };
        }
    }
    return { bound: null, head };
}
/** Build the head-preview field used in `*-no-match` log lines: cap at
 *  HEAD_PREVIEW_CHARS, replace control chars so a single log line stays
 *  one line, and JSON-quote so grep on `head-preview="…"` is reliable. */
function formatHeadPreview(head) {
    const slice = head.length > HEAD_PREVIEW_CHARS ? head.slice(0, HEAD_PREVIEW_CHARS) : head;
    return JSON.stringify(slice.replace(/[\x00-\x1f]/g, '.'));
}
export function createJsonlObserver(opts) {
    const { projectsDir, logger } = opts;
    // Claude Code only creates this dir on its first spawn; the manager may
    // start before then. mkdir -p so fs.watch has something to bind to.
    mkdirSync(projectsDir, { recursive: true });
    const pendings = new Set();
    let watcher = null;
    function onWatchEvent(eventType, filename) {
        if (!filename || !filename.endsWith('.jsonl'))
            return;
        const full = join(projectsDir, filename);
        if (!existsSync(full))
            return;
        const { bound, head } = tryBindFile(full, pendings, logger);
        if (bound) {
            logger(`jsonl-observer matched event=${eventType} path=${full} bridgeSuffix=${bound.bridgeSuffix}`);
            return;
        }
        // No bind. If there are pending observations and the head is readable
        // but the bridge suffix is missing, log the preview so an operator
        // can see whether claude wrote a JSONL with a different suffix vs
        // wrote nothing at all. Pendings=0 means no one is listening; skip.
        if (pendings.size > 0 && head.length > 0) {
            logger(`jsonl-observer watch-no-match event=${eventType} path=${full} head-bytes=${head.length} head-preview=${formatHeadPreview(head)} pendings=${pendings.size}`);
        }
    }
    try {
        watcher = watch(projectsDir, { persistent: false }, onWatchEvent);
        watcher.on('error', (err) => {
            const msg = err instanceof Error ? err.message : String(err);
            logger(`jsonl-observer watch-error dir=${projectsDir} message=${msg}`);
        });
        logger(`jsonl-observer armed dir=${projectsDir}`);
    }
    catch (err) {
        // mkdir succeeded but watch failed — extremely unlikely. Log loudly;
        // observations registered later will resolve only via the on-arm scan
        // unless the operator restarts the manager.
        const msg = err instanceof Error ? err.message : String(err);
        logger(`jsonl-observer watch-init-failed dir=${projectsDir} message=${msg}`);
    }
    function observe(args) {
        const { bridgeSuffix } = args;
        let resolveFn;
        const promise = new Promise((resolve) => {
            resolveFn = resolve;
        });
        const pending = {
            bridgeSuffix,
            resolve: resolveFn,
            settled: false,
        };
        // On-arm scan — close the race between this call and the fs.watch
        // event firing for a JSONL Claude already wrote. Tracks how many
        // JSONLs were inspected and emits a head-preview line for every
        // unrelated file so an operator can see whether the project dir is
        // empty (claude wrote nothing yet) vs full of unrelated JSONLs
        // (claude wrote one but its head bytes don't carry our suffix).
        pendings.add(pending);
        let scannedHit = false;
        let scannedFiles = 0;
        try {
            for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
                if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
                    continue;
                scannedFiles++;
                const full = join(projectsDir, entry.name);
                const { bound, head } = tryBindFile(full, pendings, logger);
                if (bound === pending) {
                    scannedHit = true;
                    logger(`jsonl-observer on-arm-scan-matched path=${full} bridgeSuffix=${bridgeSuffix}`);
                    break;
                }
                if (head.length > 0) {
                    logger(`jsonl-observer on-arm-scan-no-match path=${full} head-bytes=${head.length} head-preview=${formatHeadPreview(head)} bridgeSuffix=${bridgeSuffix}`);
                }
            }
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            logger(`jsonl-observer on-arm-scan-failed dir=${projectsDir} message=${msg}`);
        }
        if (!scannedHit) {
            logger(`jsonl-observer pending bridgeSuffix=${bridgeSuffix} on-arm-files-scanned=${scannedFiles}`);
        }
        return {
            promise,
            cancel: () => {
                if (pending.settled)
                    return;
                pending.settled = true;
                pendings.delete(pending);
                pending.resolve(null);
                logger(`jsonl-observer cancelled bridgeSuffix=${bridgeSuffix}`);
            },
        };
    }
    function close() {
        if (watcher) {
            watcher.close();
            watcher = null;
        }
        for (const p of pendings) {
            if (p.settled)
                continue;
            p.settled = true;
            p.resolve(null);
        }
        pendings.clear();
    }
    return { observe, close };
}
//# sourceMappingURL=jsonl-observer.js.map