// Filesystem watcher — disk is the source of truth (Task 258).
//
// Two directories are watched continuously:
//
//   ${CLAUDE_CONFIG_DIR}/sessions/<pid>.json   — written by Claude Code at PTY
//     launch. Carries sessionId, bridgeSessionId, agent, status, cwd,
//     updatedAt directly. Created at process init, refreshed on status/agent
//     change, deleted when the process exits cleanly.
//
//   ${CLAUDE_CONFIG_DIR}/projects/<slug>/<sessionId>.jsonl — written on the
//     first operator turn; persists after process exit as the audit
//     transcript.
//
// Row identity rule:
//   - A top-level JSONL file at `<projectsDir>/<sid>.jsonl` is a row whose
//     `sessionId` IS the basename. Always.
//   - A PID file at `<sessionsDir>/<pid>.json` decorates a row: the row
//     becomes 'live' when the PID file's `sessionId` field matches a
//     top-level JSONL basename, OR when the PID file's `sessionId` field
//     has no matching JSONL yet (fresh /spawn pre-first-turn).
//   - The fs-watcher carries no path-anchored maps and no merge state
//     machine. After Task 258, paths are derived; the watcher reads disk.
//
// Boot sequence is sync-then-arm: bootSeed() reads both directories
// synchronously and rebuilds the in-memory cache before arm() turns on the
// fs.watch handlers. Without this ordering, watch events fire before the
// seed completes and either land before any subscriber attaches (silent
// drop) or race with seed inserts (double-emit). The deterministic order
// also lets the manager survive restarts mid-session — a long-lived PTY's
// PID file is already on disk when the manager comes back up.
//
// Per-file 200 ms debounce coalesces the burst of MODIFY events Claude Code
// emits when the PID file is rewritten (write-temp-rename is two events
// from fs.watch's perspective on macOS, more on Linux), and folds the
// CREATE→MODIFY pair an editor-style writer can emit.
import { existsSync, mkdirSync, openSync, closeSync, readSync, readFileSync, readdirSync, statSync, watch } from 'node:fs';
import { dirname, join } from 'node:path';
import { readSidecar } from './session-sidecar.js';
import { defaultProcessSignals, defaultSystemctlRunner, getScopeMainPid, listSurvivingScopes, stopScopeUnit, } from './systemd-scope.js';
const DEBOUNCE_MS = 200;
// First `agent-setting` record sits near the JSONL head (Claude Code writes
// it after env+meta, before the first user turn). 32 KiB is enough to cover
// the prologue with margin.
const JSONL_HEAD_READ_BYTES = 32 * 1024;
/** Scan the first JSONL_HEAD_READ_BYTES of a transcript for the first
 *  `{"type":"agent-setting","agentSetting":"<name>"}` record. Returns the
 *  agent name, or null when no record is found (operator-driven session)
 *  or the file is unreadable. */
function readAgentSettingFromJsonl(path) {
    let fd;
    try {
        fd = openSync(path, 'r');
    }
    catch {
        return null;
    }
    try {
        const buf = Buffer.alloc(JSONL_HEAD_READ_BYTES);
        const n = readSync(fd, buf, 0, JSONL_HEAD_READ_BYTES, 0);
        const head = buf.toString('utf8', 0, n);
        const newlineIdx = head.lastIndexOf('\n');
        const scannable = newlineIdx === -1 ? head : head.slice(0, newlineIdx);
        for (const line of scannable.split('\n')) {
            if (!line.includes('"agent-setting"'))
                continue;
            try {
                const obj = JSON.parse(line);
                if (obj.type !== 'agent-setting')
                    continue;
                if (typeof obj.agentSetting !== 'string' || obj.agentSetting.length === 0)
                    continue;
                return obj.agentSetting;
            }
            catch {
                // Malformed line — keep scanning.
            }
        }
        return null;
    }
    finally {
        try {
            closeSync(fd);
        }
        catch { /* ignore */ }
    }
}
function parsePidFile(path) {
    let raw;
    try {
        raw = readFileSync(path, 'utf8');
    }
    catch {
        return null;
    }
    if (!raw)
        return null;
    let parsed;
    try {
        parsed = JSON.parse(raw);
    }
    catch {
        return null;
    }
    const sessionId = typeof parsed.sessionId === 'string' ? parsed.sessionId : '';
    const pid = typeof parsed.pid === 'number' ? parsed.pid : null;
    if (!sessionId || pid === null)
        return null;
    const bridgeSessionId = typeof parsed.bridgeSessionId === 'string' ? parsed.bridgeSessionId : null;
    const bridgeSuffix = bridgeSessionId ? bridgeSessionId.replace(/^session_/, '') : null;
    const agent = typeof parsed.agent === 'string' ? parsed.agent : null;
    const cwd = typeof parsed.cwd === 'string' ? parsed.cwd : null;
    const rawStatus = typeof parsed.status === 'string' ? parsed.status : '';
    const status = rawStatus === 'busy' ? 'busy' : 'idle';
    const updatedAt = typeof parsed.updatedAt === 'number'
        ? parsed.updatedAt
        : typeof parsed.startedAt === 'number'
            ? parsed.startedAt
            : 0;
    return { sessionId, pid, bridgeSessionId, bridgeSuffix, agent, cwd, status, updatedAt, pidFilePath: path };
}
export function createFsWatcher(opts) {
    const { sessionsDir, projectsDir, logger } = opts;
    const runSystemctl = opts.runSystemctl ?? defaultSystemctlRunner;
    const processSignals = opts.processSignals ?? defaultProcessSignals;
    const killGraceMs = opts.killGraceMs ?? 5_000;
    // Transient row cache, keyed on sessionId. Built by bootSeed and kept
    // in sync by fs event reconciliation. Not the source of truth — disk is.
    const rowsBySessionId = new Map();
    // Reverse indexes derived from row content. Rebuilt on indexRow/unindexRow.
    const sessionIdByPid = new Map();
    const sessionIdByBridgeSuffix = new Map();
    const subscribers = new Set();
    const pidWaiters = new Map();
    const debounceTimers = new Map();
    let pidWatcher = null;
    let jsonlWatcher = null;
    let archiveWatcher = null;
    let armed = false;
    let seeded = false;
    function emit(event) {
        logger(`[fs-watcher] event=${event.kind} kind=${event.source} sessionId=${event.sessionId} agent=${event.row?.agent ?? 'none'} ` +
            `status=${event.row?.status ?? 'gone'} path=${event.row?.pidFilePath ?? 'derived'}`);
        for (const sub of subscribers) {
            try {
                sub(event);
            }
            catch (err) {
                logger(`[fs-watcher] subscriber-error message=${err instanceof Error ? err.message : String(err)}`);
            }
        }
        // Drain PID waiters when the event carries a pid.
        if (event.kind !== 'delete' && event.row && event.row.pid !== null) {
            const waiter = pidWaiters.get(event.row.pid);
            if (waiter) {
                clearTimeout(waiter.timer);
                pidWaiters.delete(event.row.pid);
                waiter.resolve(event.row);
            }
        }
    }
    function indexRow(row) {
        rowsBySessionId.set(row.sessionId, row);
        if (row.pid !== null)
            sessionIdByPid.set(row.pid, row.sessionId);
        if (row.bridgeSuffix)
            sessionIdByBridgeSuffix.set(row.bridgeSuffix, row.sessionId);
    }
    function unindexRow(row) {
        rowsBySessionId.delete(row.sessionId);
        if (row.pid !== null)
            sessionIdByPid.delete(row.pid);
        if (row.bridgeSuffix)
            sessionIdByBridgeSuffix.delete(row.bridgeSuffix);
    }
    const archiveDir = join(projectsDir, 'archive');
    function jsonlPathFor(sessionId) {
        return join(projectsDir, `${sessionId}.jsonl`);
    }
    function archiveJsonlPathFor(sessionId) {
        return join(archiveDir, `${sessionId}.jsonl`);
    }
    function sidecarPathForLocation(sessionId, location) {
        return location === 'archive'
            ? join(archiveDir, `${sessionId}.meta.json`)
            : join(projectsDir, `${sessionId}.meta.json`);
    }
    /** Read the sidecar that should live next to `<sessionId>.jsonl` for
     *  the given location. Missing or malformed sidecars yield a record
     *  with seven null fields and `hidden: false` — the row is still
     *  indexed; downstream consumers (toPayload, rowToPayload) treat the
     *  null fields as "no spawn metadata captured". The watcher logs every
     *  read via `readSidecar`'s own log lines. */
    function readSidecarForLocation(sessionId, location) {
        const path = sidecarPathForLocation(sessionId, location);
        const result = readSidecar(path, logger);
        return { sidecar: result.sidecar, present: result.outcome === 'ok' };
    }
    function applySidecarToRow(row, sidecar) {
        if (!sidecar) {
            return {
                ...row,
                senderId: null,
                role: null,
                channel: null,
                url: null,
                startedAt: null,
                permissionMode: null,
                effectivePermissionMode: null,
                hidden: false,
                sidecarSpecialist: null,
            };
        }
        return {
            ...row,
            senderId: sidecar.senderId,
            role: sidecar.role,
            channel: sidecar.channel,
            url: sidecar.url,
            startedAt: sidecar.startedAt,
            permissionMode: sidecar.permissionMode,
            effectivePermissionMode: sidecar.effectivePermissionMode,
            hidden: sidecar.hidden,
            sidecarSpecialist: sidecar.specialist,
            // Task 451 — carry the scope token from the sidecar onto the row so
            // boot-seed's reconcileSurvivingScopes can find the row by token,
            // even when the unit token does not equal sessionId (fresh spawns).
            scopeUnitToken: sidecar.scopeUnitToken ?? row.scopeUnitToken,
        };
    }
    /** Find the pid file (if any) whose content names `sessionId`. Used at
     *  bootSeed to correlate jsonls with their live pid files, and on jsonl
     *  events to detect a freshly-created jsonl whose sessionId matches a
     *  pre-first-turn live row. */
    function findPidFileForSessionId(sessionId) {
        try {
            for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
                if (!entry.isFile() || !entry.name.endsWith('.json'))
                    continue;
                const parsed = parsePidFile(join(sessionsDir, entry.name));
                if (parsed && parsed.sessionId === sessionId)
                    return parsed;
            }
        }
        catch {
            // sessionsDir absent — fine.
        }
        return null;
    }
    function buildLiveRow(pidContent) {
        const base = {
            sessionId: pidContent.sessionId,
            bridgeSessionId: pidContent.bridgeSessionId,
            bridgeSuffix: pidContent.bridgeSuffix,
            agent: pidContent.agent,
            cachedAgentSetting: null,
            cwd: pidContent.cwd,
            pid: pidContent.pid,
            pidFilePath: pidContent.pidFilePath,
            state: 'live',
            status: pidContent.status,
            updatedAt: pidContent.updatedAt,
            scopeUnitToken: null,
            senderId: null,
            role: null,
            channel: null,
            url: null,
            startedAt: null,
            permissionMode: null,
            effectivePermissionMode: null,
            hidden: false,
            sidecarSpecialist: null,
        };
        // Live rows always read the sidecar from the top-level projects dir
        // (the JSONL hasn't been archived yet). A pre-first-turn live spawn
        // without a sidecar should never happen post-Task-260 because the
        // pty-spawner writes the sidecar BEFORE the PID-file event resolves
        // — but if it does, the row indexes with null spawn fields and
        // downstream payload builders surface those as null on the wire.
        const { sidecar } = readSidecarForLocation(pidContent.sessionId, 'top');
        return applySidecarToRow(base, sidecar);
    }
    function buildArchivedRow(sessionId, jsonlPath) {
        // Locate the sidecar that belongs next to this JSONL. The path the
        // caller passed names the JSONL's directory; the sidecar shares
        // that directory by construction.
        const location = dirname(jsonlPath) === archiveDir ? 'archive' : 'top';
        const base = {
            sessionId,
            bridgeSessionId: null,
            bridgeSuffix: null,
            agent: null,
            cachedAgentSetting: readAgentSettingFromJsonl(jsonlPath),
            cwd: null,
            pid: null,
            pidFilePath: null,
            state: location === 'archive' ? 'archived' : 'ended',
            status: 'gone',
            updatedAt: (() => {
                try {
                    return statSync(jsonlPath).mtimeMs;
                }
                catch {
                    return 0;
                }
            })(),
            scopeUnitToken: null,
            senderId: null,
            role: null,
            channel: null,
            url: null,
            startedAt: null,
            permissionMode: null,
            effectivePermissionMode: null,
            hidden: false,
            sidecarSpecialist: null,
        };
        const { sidecar } = readSidecarForLocation(sessionId, location);
        return applySidecarToRow(base, sidecar);
    }
    /** Apply the current on-disk state for one PID-file path to the index.
     *  Reads the file if present; on delete, finds the row via pid (from the
     *  filename) since we no longer carry a path-keyed index. */
    function reconcilePidFile(path) {
        const onDisk = existsSync(path);
        if (!onDisk) {
            // pid is encoded in the filename. Look up by pid → sessionId.
            const fileName = path.slice(path.lastIndexOf('/') + 1);
            const pidStr = fileName.replace(/\.json$/, '');
            const pid = /^\d+$/.test(pidStr) ? Number(pidStr) : null;
            if (pid === null)
                return;
            const priorSessionId = sessionIdByPid.get(pid);
            if (!priorSessionId)
                return;
            const prior = rowsBySessionId.get(priorSessionId);
            if (!prior)
                return;
            // Task 260 — the JSONL may have already moved to archive/ if the
            // operator pressed Archive concurrently with the PTY exit. Check
            // both locations; the demotion row stays a single entry, with the
            // sidecar re-read from whichever directory currently holds it.
            const topJsonl = jsonlPathFor(prior.sessionId);
            const archivedJsonl = archiveJsonlPathFor(prior.sessionId);
            const hasTop = existsSync(topJsonl);
            const hasArchived = existsSync(archivedJsonl);
            if (hasTop || hasArchived) {
                // Cache agent identity from JSONL head before the PID file is
                // forgotten. `agent` on a live row comes from the PID file; after
                // demote it's null, so without this cache the UI would lose the
                // specialist label.
                const presentJsonl = hasTop ? topJsonl : archivedJsonl;
                const sidecarLocation = hasTop ? 'top' : 'archive';
                const cachedAgentSetting = prior.cachedAgentSetting
                    ?? readAgentSettingFromJsonl(presentJsonl);
                const { sidecar } = readSidecarForLocation(prior.sessionId, sidecarLocation);
                const updated = applySidecarToRow({
                    ...prior,
                    pid: null,
                    pidFilePath: null,
                    state: hasArchived ? 'archived' : 'ended',
                    status: 'gone',
                    cachedAgentSetting,
                }, sidecar);
                unindexRow(prior);
                indexRow(updated);
                emit({ kind: 'delete', source: 'pid', row: updated, sessionId: updated.sessionId });
                emit({ kind: 'modify', source: 'jsonl', row: updated, sessionId: updated.sessionId });
                return;
            }
            unindexRow(prior);
            emit({ kind: 'delete', source: 'pid', row: null, sessionId: prior.sessionId });
            return;
        }
        const parsed = parsePidFile(path);
        if (!parsed)
            return;
        // If this pid was already mapped to a different sessionId (claude
        // rewrote the file's sessionId mid-life, e.g. Task 186 resume
        // convergence), drop the stale row first. The new content is the
        // authoritative state.
        const stalePriorByPid = sessionIdByPid.get(parsed.pid);
        if (stalePriorByPid && stalePriorByPid !== parsed.sessionId) {
            const stale = rowsBySessionId.get(stalePriorByPid);
            if (stale) {
                unindexRow(stale);
                emit({ kind: 'delete', source: 'pid', row: null, sessionId: stalePriorByPid });
            }
        }
        const prior = rowsBySessionId.get(parsed.sessionId);
        const merged = buildLiveRow(parsed);
        if (prior) {
            // Preserve cachedAgentSetting from the prior row (read once at
            // archive transition or boot-seed; pid file rewrites don't carry it).
            merged.cachedAgentSetting = prior.cachedAgentSetting ?? merged.cachedAgentSetting;
            unindexRow(prior);
            indexRow(merged);
            emit({ kind: 'modify', source: 'pid', row: merged, sessionId: merged.sessionId });
            return;
        }
        indexRow(merged);
        emit({ kind: 'create', source: 'pid', row: merged, sessionId: merged.sessionId });
    }
    // Task 260 — reconciliation is now path-aware: the same sessionId can
    // surface at `<projectsDir>/<sid>.jsonl` (live or ended-but-not-archived)
    // or at `<projectsDir>/archive/<sid>.jsonl` (archived). Row identity is
    // the sessionId; the row's `state` reflects which directory holds the
    // JSONL right now.
    //
    // The load-bearing event in this path is the top↔archive RENAME, which
    // fs.watch emits as a (delete on source, create on destination) pair in
    // either order. Coalescing that pair into ONE `row-updated` event keyed
    // on sessionId is the contract Task 260 promises the UI: an archive
    // never produces a `row-removed` followed by a `row-created` — it
    // produces a single `row-updated` with the new state flag.
    //
    // Implementation: on a delete event for one location, check whether the
    // sibling location still holds the JSONL for the same sessionId. If
    // yes, mutate the row's state (and re-read its sidecar from the new
    // location) and emit a single `modify`. If no, the JSONL really left
    // disk — unindex and emit delete. On a create event, if the row
    // already exists with the matching state, no-op (we already emitted
    // the modify above); otherwise treat as a fresh row.
    function reconcileJsonlFile(path) {
        const fileName = path.slice(path.lastIndexOf('/') + 1);
        if (!fileName.endsWith('.jsonl'))
            return;
        const sessionId = fileName.slice(0, -'.jsonl'.length);
        const location = dirname(path) === archiveDir ? 'archive' : 'top';
        const onDisk = existsSync(path);
        const prior = rowsBySessionId.get(sessionId);
        const siblingPath = location === 'top' ? archiveJsonlPathFor(sessionId) : jsonlPathFor(sessionId);
        const siblingOnDisk = existsSync(siblingPath);
        if (!onDisk) {
            // Event is a delete for THIS location.
            if (!prior)
                return;
            if (siblingOnDisk) {
                // top↔archive rename — coalesce into ONE modify with the new
                // state. The new location reflects where the JSONL lives now.
                // Task 263 — state is derived from (hasPidFile, location), not
                // location alone. An unarchive of a row that never had a PTY
                // must emit 'ended', not 'live'; a live PTY rename (rare) stays
                // 'live' regardless of which directory the JSONL moved to.
                const newLocation = location === 'top' ? 'archive' : 'top';
                const hasPidFile = findPidFileForSessionId(sessionId) !== null;
                const newState = hasPidFile
                    ? 'live'
                    : newLocation === 'top' ? 'ended' : 'archived';
                const { sidecar } = readSidecarForLocation(sessionId, newLocation);
                const updated = applySidecarToRow({ ...prior, state: newState }, sidecar);
                unindexRow(prior);
                indexRow(updated);
                emit({ kind: 'modify', source: 'jsonl', row: updated, sessionId });
                logger(`[fs-watcher] archive-rename sessionId=${sessionId.slice(0, 8)} direction=${location}->${newLocation} new-state=${newState}`);
                return;
            }
            // True delete. If PID file still present at the prior location,
            // row stays live (transient — live PTY may write a new jsonl on
            // the next turn); else drop the row entirely.
            if (prior.pidFilePath && existsSync(prior.pidFilePath)) {
                emit({ kind: 'delete', source: 'jsonl', row: prior, sessionId });
                return;
            }
            unindexRow(prior);
            emit({ kind: 'delete', source: 'jsonl', row: null, sessionId });
            return;
        }
        // Event is a create or modify on THIS location.
        // Task 263 — desired state is derived from (hasPidFile, location).
        // A top-level JSONL without a matching PID file is 'ended', not 'live':
        // the unarchive path moves the JSONL but does not spawn a PTY.
        const hasPidFileForRow = (prior?.pidFilePath && existsSync(prior.pidFilePath))
            || findPidFileForSessionId(sessionId) !== null;
        const desiredState = hasPidFileForRow && location === 'top'
            ? 'live'
            : location === 'top' ? 'ended' : 'archived';
        if (prior) {
            // Row already exists. If the state matches (or the row is live
            // because a PID file is present), this is a content modify or
            // the second event of a rename whose first half already mutated
            // the row to the new state — both cases emit a single modify and
            // refresh the sidecar.
            const { sidecar } = readSidecarForLocation(sessionId, location);
            const updated = applySidecarToRow({ ...prior, state: desiredState }, sidecar);
            // Only unindex+reindex when something changed; otherwise we just
            // re-emit modify so subscribers re-render.
            unindexRow(prior);
            indexRow(updated);
            emit({ kind: 'modify', source: 'jsonl', row: updated, sessionId });
            return;
        }
        // Fresh jsonl at this sessionId. If a pid file already exists with
        // this sessionId (rare but possible — claude wrote its first turn
        // before the pid-file create event was observed), build a live row.
        // Otherwise the row reflects the location: top → live (manager
        // probably just spawned but the PID file hasn't been observed yet,
        // or a manually-restored archive) / archive → archived.
        const matchingPid = findPidFileForSessionId(sessionId);
        if (matchingPid && location === 'top') {
            const row = buildLiveRow(matchingPid);
            indexRow(row);
            emit({ kind: 'create', source: 'jsonl', row, sessionId });
            return;
        }
        const row = buildArchivedRow(sessionId, path);
        indexRow(row);
        emit({ kind: 'create', source: 'jsonl', row, sessionId });
    }
    function schedule(path, reconcile) {
        const prev = debounceTimers.get(path);
        if (prev)
            clearTimeout(prev);
        const t = setTimeout(() => {
            debounceTimers.delete(path);
            try {
                reconcile(path);
            }
            catch (err) {
                logger(`[fs-watcher] reconcile-error path=${path} message=${err instanceof Error ? err.message : String(err)}`);
            }
        }, DEBOUNCE_MS);
        debounceTimers.set(path, t);
    }
    function bootSeed() {
        if (seeded)
            return;
        // Task 260 — walk the projects dir AND its archive subdir so archived
        // rows are first-class members of the row index. Then walk the
        // sessions dir; each pid file's sessionId either matches an
        // already-seeded jsonl row (promote to live) or has no jsonl yet (a
        // pre-first-turn live row, no transcript).
        let topJsonlCount = 0;
        let archiveJsonlCount = 0;
        let sidecarCount = 0;
        let pidCount = 0;
        try {
            for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
                if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
                    continue;
                const sessionId = entry.name.slice(0, -'.jsonl'.length);
                if (!sessionId)
                    continue;
                const jsonlPath = join(projectsDir, entry.name);
                if (!rowsBySessionId.has(sessionId)) {
                    indexRow(buildArchivedRow(sessionId, jsonlPath));
                }
                topJsonlCount += 1;
            }
        }
        catch {
            // projectsDir absent on first boot — Claude Code creates it on first
            // transcript write. Watcher's mkdirSync at arm() avoids fs.watch ENOENT.
        }
        try {
            for (const entry of readdirSync(archiveDir, { withFileTypes: true })) {
                if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
                    continue;
                const sessionId = entry.name.slice(0, -'.jsonl'.length);
                if (!sessionId)
                    continue;
                const jsonlPath = join(archiveDir, entry.name);
                if (!rowsBySessionId.has(sessionId)) {
                    indexRow(buildArchivedRow(sessionId, jsonlPath));
                }
                archiveJsonlCount += 1;
            }
        }
        catch {
            // archive subdir absent — fine. Watcher mkdirs it at arm time.
        }
        try {
            for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
                if (!entry.isFile() || !entry.name.endsWith('.json'))
                    continue;
                const path = join(sessionsDir, entry.name);
                const parsed = parsePidFile(path);
                if (!parsed)
                    continue;
                const prior = rowsBySessionId.get(parsed.sessionId);
                const row = buildLiveRow(parsed);
                if (prior) {
                    // Preserve cached agent setting we already read from the jsonl head.
                    row.cachedAgentSetting = prior.cachedAgentSetting ?? row.cachedAgentSetting;
                    unindexRow(prior);
                }
                indexRow(row);
                pidCount += 1;
            }
        }
        catch {
            // sessionsDir absent — fine. Watcher creates it via mkdir at arm time.
        }
        // Count rows whose sidecar populated `senderId` — the load-bearing
        // signal that a row carries spawn metadata. `sidecarCount <
        // topJsonlCount + archiveJsonlCount` flags pre-Task-260 JSONLs that
        // landed before the sidecar writer existed: they index normally but
        // surface null senderId/role/channel on the wire.
        for (const row of rowsBySessionId.values()) {
            if (row.senderId !== null)
                sidecarCount += 1;
        }
        seeded = true;
        const totalJsonl = topJsonlCount + archiveJsonlCount;
        logger(`[fs-watcher] boot-seed sessions-dir=${pidCount} projects-dir-top=${topJsonlCount} projects-dir-archive=${archiveJsonlCount} sidecars=${sidecarCount} rows=${rowsBySessionId.size} jsonl-total=${totalJsonl}`);
        // Task 250 — second pass: reconcile surviving `claude-session-*.scope`
        // units with the rows just built. A surviving scope without a row is
        // an orphan; a surviving scope with a row reattaches as `detached`.
        reconcileSurvivingScopes();
    }
    /** Task 250 — boot-seed extension. Lists `claude-session-*.scope` units
     *  owned by the user manager; for each, queries MainPID and looks up the
     *  matching PID file in sessionsDir. The PID file's `sessionId` field is
     *  the row key. Outcomes per scope:
     *
     *    - reattached: surviving scope whose MainPID points to a parsed PID
     *      file whose sessionId is in `rowsBySessionId`. The row's
     *      `scopeUnitToken` is set and `status` flipped to `detached`. The
     *      manager has lost the PTY master fd; the row is observation-only
     *      until the operator clicks End (handled separately via the row's
     *      unit token).
     *    - orphaned: surviving scope whose MainPID either (a) doesn't
     *      resolve, (b) has no PID file, or (c) the PID file's sessionId is
     *      unknown. The manager cannot reconstruct what claude is doing; per
     *      the eng-review decision, it issues `systemctl --user stop` on the
     *      orphan rather than leaving the cgroup alive.
     *
     *  Emits `[boot-seed] survived-scopes=N reattached=N orphaned=N
     *  orphaned-stopped=N` even when every count is zero so the operator
     *  can confirm the reconciliation ran. */
    function reconcileSurvivingScopes() {
        const { scopes, result } = listSurvivingScopes(runSystemctl);
        if (result.exitCode !== 0) {
            logger(`[boot-seed] systemctl-list-units-failed exit-code=${result.exitCode} stderr=${JSON.stringify(result.stderr.slice(-200))} survived-scopes=0 reattached=0 orphaned=0 orphaned-stopped=0`);
            return;
        }
        let reattached = 0;
        let orphaned = 0;
        let orphanedStopped = 0;
        for (const scope of scopes) {
            const decision = classifyScope(scope);
            if (decision.match === 'reattach') {
                reattach(scope, decision.row);
                reattached += 1;
                continue;
            }
            orphaned += 1;
            // Task 451 — orphan-stop is fire-and-forget so a single slow scope
            // with a stuck pid does not block the rest of the boot-seed loop.
            // The `[boot-seed] orphan-stop` line lands when the promise resolves;
            // `orphaned-stopped` counts entries whose stop request was issued,
            // not entries whose verify completed (the verify path is hundreds of
            // ms when escalation runs and we don't want to serialise boot on it).
            const pidForStop = decision.match === 'orphan-pid-dead'
                ? null
                : getScopeMainPid(runSystemctl, scope.unitToken);
            orphanedStopped += 1;
            void stopScopeUnit(runSystemctl, scope.unitToken, {
                pid: pidForStop,
                killGraceMs,
                signals: processSignals,
            })
                .then((stop) => {
                logger(`[boot-seed] orphan-stop unit=${scope.unitName} outcome=${stop.outcome} exit-code=${stop.exitCode}`);
            })
                .catch((err) => {
                const msg = err instanceof Error ? err.message : String(err);
                logger(`[boot-seed] orphan-stop-threw unit=${scope.unitName} err=${JSON.stringify(msg)}`);
            });
        }
        logger(`[boot-seed] survived-scopes=${scopes.length} reattached=${reattached} orphaned=${orphaned} orphaned-stopped=${orphanedStopped}`);
    }
    function classifyScope(scope) {
        // Resolve the row two ways:
        //   1. The unit token matches a sessionId directly (resume spawns,
        //      where unitToken === claude's intrinsic sessionId).
        //   2. The unit token matches a sidecar-recorded scopeUnitToken on
        //      some row (fresh spawns, where unitToken is a random UUID
        //      minted at spawn time).
        // Both paths are tried because we cannot tell at this point which
        // type of spawn produced the scope; iterating rows is fine because
        // the inner loop runs once per surviving scope at boot.
        let row = rowsBySessionId.get(scope.unitToken) ?? null;
        if (!row) {
            for (const r of rowsBySessionId.values()) {
                if (r.scopeUnitToken === scope.unitToken) {
                    row = r;
                    break;
                }
            }
        }
        const sessionId = row?.sessionId ?? 'none';
        if (!row) {
            logger(`[boot-seed] scope-classify unit=${scope.unitName} sessionId=${sessionId} match=orphan reason=no-row-on-disk`);
            return { match: 'orphan', reason: 'no-row-on-disk' };
        }
        // Task 451 — only reattach when the row's pid is still alive. Without
        // this check we would mark a row `detached` for a process that has
        // already exited, and the operator's End click would route to the
        // row-scope branch and stop a unit whose pid is long dead.
        if (row.pid === null || !processSignals.isAlive(row.pid)) {
            logger(`[boot-seed] scope-classify unit=${scope.unitName} sessionId=${sessionId.slice(0, 8)} match=orphan-pid-dead reason=pid-already-dead`);
            return { match: 'orphan-pid-dead', reason: 'pid-already-dead' };
        }
        logger(`[boot-seed] scope-classify unit=${scope.unitName} sessionId=${sessionId.slice(0, 8)} match=reattach reason=row-on-disk`);
        return { match: 'reattach', row, reason: 'row-on-disk' };
    }
    function reattach(scope, row) {
        // unindex/indexRow keeps the reverse indexes consistent (sessionId
        // and pid keys are unchanged; the re-index is defensive against
        // future field-change ordering).
        unindexRow(row);
        row.status = 'detached';
        row.scopeUnitToken = scope.unitToken;
        indexRow(row);
        logger(`[boot-seed] reattached sessionId=${row.sessionId} unit=${scope.unitName} pid=${row.pid ?? 'unknown'}`);
    }
    function arm() {
        if (armed)
            return;
        if (!seeded)
            bootSeed();
        try {
            mkdirSync(sessionsDir, { recursive: true });
        }
        catch { /* best effort */ }
        try {
            mkdirSync(projectsDir, { recursive: true });
        }
        catch { /* best effort */ }
        // Task 260 — archive subdir must exist before fs.watch attaches;
        // otherwise the watch handle fails ENOENT and we miss the first
        // archive event of the session.
        try {
            mkdirSync(archiveDir, { recursive: true });
        }
        catch { /* best effort */ }
        try {
            pidWatcher = watch(sessionsDir, { persistent: false }, (_event, filename) => {
                if (!filename || !filename.endsWith('.json'))
                    return;
                const path = join(sessionsDir, filename);
                schedule(path, reconcilePidFile);
            });
            pidWatcher.on('error', (err) => {
                logger(`[fs-watcher] watch-error dir=${sessionsDir} message=${err instanceof Error ? err.message : String(err)}`);
            });
        }
        catch (err) {
            logger(`[fs-watcher] watch-init-failed dir=${sessionsDir} message=${err instanceof Error ? err.message : String(err)}`);
        }
        try {
            jsonlWatcher = watch(projectsDir, { persistent: false }, (_event, filename) => {
                if (!filename || !filename.endsWith('.jsonl'))
                    return;
                const path = join(projectsDir, filename);
                schedule(path, reconcileJsonlFile);
            });
            jsonlWatcher.on('error', (err) => {
                logger(`[fs-watcher] watch-error dir=${projectsDir} message=${err instanceof Error ? err.message : String(err)}`);
            });
        }
        catch (err) {
            logger(`[fs-watcher] watch-init-failed dir=${projectsDir} message=${err instanceof Error ? err.message : String(err)}`);
        }
        // Task 260 — second fs.watch on archive/. Without this the archive
        // route's rename emits a delete on top (handled by jsonlWatcher) but
        // the create on archive is silent, breaking the rename-coalesce that
        // turns the pair into one row-updated event. The watcher's
        // reconcileJsonlFile handles archive paths symmetric to top paths.
        try {
            archiveWatcher = watch(archiveDir, { persistent: false }, (_event, filename) => {
                if (!filename || !filename.endsWith('.jsonl'))
                    return;
                const path = join(archiveDir, filename);
                schedule(path, reconcileJsonlFile);
            });
            archiveWatcher.on('error', (err) => {
                logger(`[fs-watcher] watch-error dir=${archiveDir} message=${err instanceof Error ? err.message : String(err)}`);
            });
        }
        catch (err) {
            logger(`[fs-watcher] watch-init-failed dir=${archiveDir} message=${err instanceof Error ? err.message : String(err)}`);
        }
        armed = true;
        logger(`[fs-watcher] armed sessions-dir=${sessionsDir} projects-dir=${projectsDir} archive-dir=${archiveDir}`);
    }
    function subscribe(sub) {
        subscribers.add(sub);
        return () => { subscribers.delete(sub); };
    }
    function getBySessionId(id) {
        return rowsBySessionId.get(id);
    }
    function getByBridgeSessionId(id) {
        const suffix = id.replace(/^session_/, '');
        const sessionId = sessionIdByBridgeSuffix.get(suffix);
        return sessionId ? rowsBySessionId.get(sessionId) : undefined;
    }
    function getByBridgeSuffix(suffix) {
        const sessionId = sessionIdByBridgeSuffix.get(suffix);
        return sessionId ? rowsBySessionId.get(sessionId) : undefined;
    }
    function getByPid(pid) {
        const sessionId = sessionIdByPid.get(pid);
        return sessionId ? rowsBySessionId.get(sessionId) : undefined;
    }
    function all() {
        return [...rowsBySessionId.values()];
    }
    function waitForPid(pid, timeoutMs) {
        const existing = getByPid(pid);
        if (existing)
            return Promise.resolve(existing);
        return new Promise((resolve, reject) => {
            const timer = setTimeout(() => {
                pidWaiters.delete(pid);
                reject(new Error(`fs-watcher waitForPid timed out pid=${pid} ms=${timeoutMs}`));
            }, timeoutMs);
            pidWaiters.set(pid, { resolve, reject, timer });
        });
    }
    function refreshSidecar(sessionId) {
        const prior = rowsBySessionId.get(sessionId);
        if (!prior)
            return false;
        // Task 260 — find the sidecar wherever it actually lives on disk
        // (top or archive). Row state alone doesn't say: an `archived`
        // state means "no live PTY", but the JSONL/sidecar pair may still
        // be at the top level (e.g. a pre-Task-260 JSONL that has never
        // been archived through the route).
        const topSidecar = sidecarPathForLocation(sessionId, 'top');
        const archiveSidecar = sidecarPathForLocation(sessionId, 'archive');
        const location = existsSync(topSidecar)
            ? 'top'
            : existsSync(archiveSidecar) ? 'archive' : (prior.state === 'archived' ? 'archive' : 'top');
        const { sidecar } = readSidecarForLocation(sessionId, location);
        const updated = applySidecarToRow(prior, sidecar);
        unindexRow(prior);
        indexRow(updated);
        emit({ kind: 'modify', source: 'jsonl', row: updated, sessionId });
        return true;
    }
    function close() {
        if (pidWatcher) {
            pidWatcher.close();
            pidWatcher = null;
        }
        if (jsonlWatcher) {
            jsonlWatcher.close();
            jsonlWatcher = null;
        }
        if (archiveWatcher) {
            archiveWatcher.close();
            archiveWatcher = null;
        }
        for (const t of debounceTimers.values())
            clearTimeout(t);
        debounceTimers.clear();
        for (const w of pidWaiters.values()) {
            clearTimeout(w.timer);
            w.reject(new Error('fs-watcher closed'));
        }
        pidWaiters.clear();
        subscribers.clear();
    }
    return {
        bootSeed,
        arm,
        subscribe,
        getBySessionId,
        getByBridgeSessionId,
        getByBridgeSuffix,
        getByPid,
        all,
        waitForPid,
        refreshSidecar,
        close,
        __internal: { reconcilePidFile, reconcileJsonlFile },
    };
}
//# sourceMappingURL=fs-watcher.js.map