// Task 500 — per-account `claude rc` daemon supervisor.
//
// One persistent `claude rc --spawn same-dir` per account registers the
// device as a Remote Control target in claude.ai/code. The composer then
// creates / resumes / stops / renames / archives / deletes sessions there,
// with model + permission-mode applied at inception. maxy no longer spawns
// admin interactive sessions per-request; the daemon is the single owner.
//
// Wiring this daemon needs three things, each at a different surface:
//
//   1. settings.json "model"  (CLAUDE_CONFIG_DIR/settings.json) — the
//      inception-model lever. account.json.adminModel is written here so a
//      composer-spawned session starts on the configured model. Only the
//      `model` key is owned; every other key (hooks, permissions, env the
//      operator or claude wrote) is preserved by a read-merge-write.
//
//   2. daemon process env  (--setenv via the systemd-run scope) — composer
//      sessions are children of the daemon and inherit its env, so the admin
//      pin-identity-gate.sh hook (installed in
//      CLAUDE_CONFIG_DIR/settings.json) fires for them. They read
//      MAXY_SESSION_ROLE / MAXY_UI_INTERNAL_PORT / ACCOUNT_ID from this env.
//      These are NOT written into the shared settings.json — that file is
//      read by maxy-spawned public/specialist sessions too, and a
//      MAXY_SESSION_ROLE=admin there would break their role gating.
//
//   3. project .mcp.json  (spawnCwd/.mcp.json) — composer sessions load the
//      admin MCP server set from here with ACCOUNT_ID + Neo4j baked into each
//      server's env block. Confirmed on device (2026-05-28): ACCOUNT_ID via
//      the .mcp.json env block is sufficient for the work MCP to resolve.
//
// Supervision has two branches (mirrors the reconnect-loop incident class):
//   - temporary: the daemon exits → restart with exponential backoff. A run
//     that stayed up past `stableRunMs` resets the backoff and the failure
//     counter (a healthy daemon that is later killed restarts immediately).
//   - permanent: `maxFastFailures` consecutive exits inside `stableRunMs`
//     means the daemon cannot stay up. Stop retrying and emit
//     `rc-daemon permanently-failed` — the composer will show
//     "Set up Remote Control", which is the operator-visible remediation.
import { join, dirname } from 'node:path';
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
import { createConnection } from 'node:net';
import { randomUUID } from 'node:crypto';
import { emitRcLife } from './rc-life.js';
const TAG = '[claude-session-manager]';
const BACKOFF_BASE_MS = 1_000;
const BACKOFF_MAX_MS = 60_000;
const STABLE_RUN_MS = 60_000;
const MAX_FAST_FAILURES = 8;
// Task 627 — aggregate concurrent-session cap for the rc-daemon. The
// `claude rc` default is 32 (`claude rc --help`: `--capacity <N>  Max
// concurrent sessions in worktree or same-dir mode (default: 32)`); the
// operator-observed `1 of 32` was that default, not a Maxy choice. 10 is a
// conservative starting ceiling pending the pty-census evidence (see
// pty-census.ts); revising it is a one-line change here.
export const RC_DAEMON_CAPACITY = 10;
// Task 530 — "fast exit" threshold for capturing the child's own output.
// Comfortably covers the `ranMs=6` class (the unknown cause Task 531 will
// classify) and the `ranMs=207-381` Neo4j-not-ready class that Task 523
// already gates against. Well below STABLE_RUN_MS so a healthy daemon
// ending after minutes of uptime does not trigger the capture log.
const FAST_EXIT_THRESHOLD_MS = 2_000;
// Ring-buffer cap for the child's PTY output. Sized to fit a multi-line
// stack trace or a single-instance/lock error message without unbounded
// growth on a chatty boot sequence; the tail wins because the message that
// precedes exit is the diagnostic surface.
const FAST_EXIT_OUTPUT_MAX_BYTES = 4_096;
const NEO4J_READY_POLL_INTERVAL_MS = 2_000;
const NEO4J_READY_TIMEOUT_MS = 300_000;
const NEO4J_READY_CONNECT_TIMEOUT_MS = 5_000;
const NEO4J_READY_LOG_EVERY_MS = 10_000;
/** Parse the bolt host:port from a Neo4j URI (`bolt://`, `neo4j://`,
 *  `bolt+s://`, …). Returns null when no explicit `host:port` is present —
 *  the caller then skips the readiness gate rather than probing a guessed
 *  port, which on a non-default brand could falsely pass against another
 *  brand's Neo4j (brand isolation is absolute). */
export function parseBoltHostPort(uri) {
    const m = uri.match(/^[a-z][a-z0-9+.-]*:\/\/([^/:@]+):(\d+)/i);
    if (!m)
        return null;
    return { host: m[1], port: Number(m[2]) };
}
/** TCP-connect readiness probe: resolves true once the bolt port accepts a
 *  connection, false on error or connect-timeout. The socket is always torn
 *  down. Never rejects. */
function tcpProbe(host, port, timeoutMs) {
    return new Promise((resolve) => {
        let settled = false;
        const sock = createConnection({ host, port });
        const finish = (ok) => {
            if (settled)
                return;
            settled = true;
            try {
                sock.destroy();
            }
            catch { /* already torn down */ }
            resolve(ok);
        };
        sock.setTimeout(timeoutMs);
        sock.once('connect', () => finish(true));
        sock.once('timeout', () => finish(false));
        sock.once('error', () => finish(false));
    });
}
/** Read account.json.adminModel. Returns null when the file is absent,
 *  unparseable, or the field is empty — the daemon still starts (the
 *  composer falls back to claude's own default model) but the
 *  inception-model lever is logged as unset. */
export function readAdminModel(accountDir) {
    try {
        const raw = readFileSync(join(accountDir, 'account.json'), 'utf8');
        const parsed = JSON.parse(raw);
        return typeof parsed.adminModel === 'string' && parsed.adminModel.trim()
            ? parsed.adminModel.trim()
            : null;
    }
    catch {
        return null;
    }
}
/** Task 578 — pre-seed RC consent. `claude rc` prompts
 *  `Enable Remote Control? (y/n)` on first run; the daemon spawn is
 *  headless so the prompt hangs and the supervisor restarts the child
 *  until it permanently-fails. Writing `remoteControlAtStartup: true`
 *  into `$CLAUDE_CONFIG_DIR/.claude.json` is the same mechanism
 *  claude itself uses when the operator answers `y`, so the prompt is
 *  never reached on subsequent spawns either. Read-merge-write owns
 *  only this one key; siblings (`teammateMode`, `hasUsedRemoteControl`,
 *  `remoteControlUpsellSeenCount`, claude's own auth blocks) are
 *  preserved. Atomic write mirrors `mergeSettingsModel`. */
export function ensureRemoteControlConsent(claudeConfigDir, logger) {
    const configPath = join(claudeConfigDir, '.claude.json');
    let config = {};
    try {
        config = JSON.parse(readFileSync(configPath, 'utf8'));
        if (typeof config !== 'object' || config === null || Array.isArray(config)) {
            config = {};
        }
    }
    catch {
        // absent or unparseable — start fresh; we own remoteControlAtStartup + remoteDialogSeen.
    }
    // remoteControlAtStartup: older Claude Code versions; remoteDialogSeen: v2.1.x+
    // (Task 591 — v2.1.159 changed the key that suppresses "Enable Remote Control? (y/n)")
    if (config.remoteControlAtStartup === true && config.remoteDialogSeen === true) {
        logger(`${TAG} rc-daemon consent-already path=${configPath}`);
        return;
    }
    config.remoteControlAtStartup = true;
    config.remoteDialogSeen = true;
    try {
        mkdirSync(dirname(configPath), { recursive: true });
        const tmp = `${configPath}.${process.pid}.tmp`;
        writeFileSync(tmp, JSON.stringify(config, null, 2), 'utf8');
        renameSync(tmp, configPath);
        logger(`${TAG} rc-daemon consent-written path=${configPath}`);
    }
    catch (err) {
        logger(`${TAG} rc-daemon consent-write-failed path=${configPath} err=${err instanceof Error ? err.message : String(err)}`);
    }
}
/** Task 578 — substring scan over a `claude rc` PTY output chunk that
 *  returns true once the daemon has reached its post-bind handshake.
 *  Two markers, both emitted by the daemon's own output after RC
 *  registers: the literal "Capacity:" header, and a capacity line of
 *  the form "<id> · <prefix> · N of M" the composer surface prints
 *  (operator-observed `1 of 32`). The `· N of M` form is preceded by
 *  the `·` separator the composer surface uses; matching on `· N of M`
 *  rejects unrelated prose like "retry 1 of 3" that happens to share
 *  the N-of-M shape. A chunk that contains neither marker is pre-bind
 *  output (boot banner, consent prompt, version line) and the flag
 *  stays false. Pure function so it can be unit-tested without a real
 *  PTY. */
export function detectRcHandshake(chunk) {
    if (chunk.includes('Capacity:'))
        return true;
    if (/· \d+ of \d+\b/.test(chunk))
        return true;
    return false;
}
/** Read-merge-write settings.json so only the `model` key is owned. Every
 *  other key (hooks, permissions, env, statusLine, …) is preserved verbatim
 *  — the file is shared with maxy-spawned sessions and carries the installed
 *  Stop hook. A missing/unparseable file starts from an empty object. */
export function mergeSettingsModel(claudeConfigDir, adminModel, logger) {
    const settingsPath = join(claudeConfigDir, 'settings.json');
    let settings = {};
    try {
        settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
        if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
            settings = {};
        }
    }
    catch {
        // absent or unparseable — start fresh; we only own the `model` key.
    }
    if (settings.model === adminModel) {
        logger(`${TAG} rc-daemon settings-model already=${adminModel} path=${settingsPath}`);
        return;
    }
    settings.model = adminModel;
    try {
        mkdirSync(dirname(settingsPath), { recursive: true });
        // Atomic write: settings.json is shared with maxy-spawned sessions and
        // carries the installed Stop hook. A torn write (manager killed mid-write)
        // would corrupt it and break every session's hook resolution. tmp + rename
        // is atomic on the same filesystem.
        const tmp = `${settingsPath}.${process.pid}.tmp`;
        writeFileSync(tmp, JSON.stringify(settings, null, 2), 'utf8');
        renameSync(tmp, settingsPath);
        logger(`${TAG} rc-daemon settings-model written model=${adminModel} path=${settingsPath}`);
    }
    catch (err) {
        logger(`${TAG} rc-daemon settings-model-write-failed path=${settingsPath} err=${err instanceof Error ? err.message : String(err)}`);
    }
}
/** Compose the env carried by the rc-daemon child and by Task 543's
 *  sessions-pane spawn route. Exported so the route's HTTP handler can
 *  build a byte-identical env without duplicating the field set. */
export function buildRcChildEnv(parts) {
    const env = parts.env ?? process.env;
    const built = {
        ...env,
        MAXY_SESSION_ROLE: 'admin',
        MAXY_SPECIALIST: '',
        ACCOUNT_ID: parts.accountId,
        USER_ID: parts.userId ?? '',
        SESSION_ID: parts.sessionId ?? '',
        MAXY_UI_INTERNAL_PORT: parts.uiPort,
        PLATFORM_ROOT: parts.platformRoot,
        CLAUDE_CONFIG_DIR: parts.claudeConfigDir,
        LOG_DIR: parts.logDir,
        NEO4J_URI: parts.brandedNeo4jUri,
        NEO4J_PASSWORD: parts.brandedNeo4jPassword,
        CLAUDE_CODE_DISABLE_AUTO_MEMORY: '1',
    };
    // Task 626 — slice ringfence for the public session-end review rc session.
    // Same three vars spawnClaudeSession set for the reviewer (pty-spawner.ts).
    if (parts.sliceToken && parts.sliceToken.length > 0) {
        built.RECORDER_SLICE_TOKEN = parts.sliceToken;
        built.ACCESS_SLICE_TOKEN = parts.sliceToken;
        built.ALLOWED_SCOPES = 'public';
        if (parts.personId && parts.personId.length > 0)
            built.ACCESS_PERSON_ID = parts.personId;
    }
    return built;
}
function buildDaemonEnv(deps) {
    // Task 606 — no sessionId: the long-lived daemon is not a conversation, so
    // its SESSION_ID stays ''. logDir is carried so daemon-spawned MCP children
    // tee their stderr to the readable per-server log.
    return buildRcChildEnv({
        accountId: deps.accountId,
        userId: deps.userId,
        uiPort: deps.uiPort,
        platformRoot: deps.platformRoot,
        claudeConfigDir: deps.claudeConfigDir,
        brandedNeo4jUri: deps.brandedNeo4jUri,
        brandedNeo4jPassword: deps.brandedNeo4jPassword,
        logDir: deps.logDir,
        env: deps.env,
    });
}
export function startRcDaemon(deps) {
    const now = deps.now ?? (() => Date.now());
    const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
    const adminModel = readAdminModel(deps.accountDir);
    if (adminModel) {
        mergeSettingsModel(deps.claudeConfigDir, adminModel, deps.logger);
    }
    else {
        deps.logger(`${TAG} rc-daemon settings-model unset reason=adminModel-absent accountDir=${deps.accountDir} (composer uses claude default)`);
    }
    // Task 578 — pre-seed RC consent so the headless `claude rc` spawn
    // does not hang at `Enable Remote Control? (y/n)`. Called here for
    // the first spawn; also re-asserted inside spawnOnce so a supervised
    // restart after `.claude.json` is truncated (claude auth re-flow,
    // operator wipe) still has the key. Idempotent — a true value
    // returns early after one read, so the per-restart cost is bounded.
    ensureRemoteControlConsent(deps.claudeConfigDir, deps.logger);
    // Task 502 — the daemon no longer writes a project `.mcp.json`. That file
    // registered the platform servers under the SHORT prefix `mcp__<server>__`,
    // which collided with the installed-plugin channel's long prefix
    // `mcp__plugin_<server>_<server>__` (the plugin channel wins, so only the long
    // name was invocable while instructions named the short one). Composer
    // sessions now take their maxy tool surface solely from the installed plugins
    // (long prefix); account scope + Neo4j ride the daemon child env below, so the
    // plugins' `${ACCOUNT_ID}`/`${NEO4J_*}` blocks resolve without a project file.
    deps.logger(`${TAG} rc-daemon mcp-channel=installed-plugins accountId=${deps.accountId} (no project .mcp.json; long-prefix only)`);
    const daemonEnv = buildDaemonEnv(deps);
    const pidFileTimeoutMs = deps.pidFileTimeoutMs ?? 30_000;
    let stopped = false;
    let current = null;
    let backoffMs = BACKOFF_BASE_MS;
    let consecutiveFastFailures = 0;
    let permanentlyFailed = false;
    const spawnOnce = async () => {
        if (stopped || permanentlyFailed)
            return;
        // Task 578 — re-assert consent before every spawn. Cheap when the key
        // is already true (one read + early return). Covers the case where
        // claude rewrites `.claude.json` mid-life (auth re-flow, operator
        // wipe) and the next supervised restart would otherwise re-hit the
        // headless prompt.
        ensureRemoteControlConsent(deps.claudeConfigDir, deps.logger);
        const startedAt = now();
        let pty;
        // Task 531 — unique unitToken per spawn attempt. A fixed token
        // (`'rc-daemon'`) reused on a fast restart races systemd's cleanup of
        // the prior transient scope: the next `systemd-run --user --scope
        // --unit=claude-session-rc-daemon.scope` fails with "Unit … was
        // already loaded or has a fragment file", the child exits at
        // `ranMs=5-6`, and the supervisor counts it toward
        // MAX_FAST_FAILURES. The `rc-daemon-` prefix is preserved so the
        // boot-seed `claude-session-*.scope` reconciler and operator log
        // greps still recognise the scope as the rc-daemon's.
        const unitToken = `rc-daemon-${randomUUID()}`;
        try {
            pty = deps.spawnPty(deps.claudeBin, 
            // --no-create-session-in-dir: do not pre-create the daemon's in-dir
            // session. The daemon starts at platform boot, ahead of any operator
            // intent, so a pre-created session would sit idle until composer
            // creates a real on-demand session anyway.
            ['rc', '--spawn', 'same-dir', '--no-create-session-in-dir', '--capacity', String(RC_DAEMON_CAPACITY), '--remote-control-session-name-prefix', deps.brandProductName], daemonEnv, unitToken);
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            deps.logger(`${TAG} rc-daemon spawn-failed err=${msg}`);
            emitRcLife(deps.logger, 'spawn-failed', 'rc-daemon', 'node-pty', 'daemon', unitToken, { err: JSON.stringify(msg) });
            scheduleRestart(startedAt);
            return;
        }
        current = pty;
        deps.logger(`${TAG} rc-daemon start accountId=${deps.accountId} cwd=${deps.spawnCwd} pid=${pty.pid} model=${adminModel ?? 'default'} name-prefix=${deps.brandProductName} spawnMethod=node-pty`);
        emitRcLife(deps.logger, 'spawned', 'rc-daemon', 'node-pty', 'daemon', unitToken, {
            pid: pty.pid,
            scopeUnit: `claude-session-${unitToken}.scope`,
        });
        // Ring buffer for the child's own stdout/stderr. Sized to fit a
        // multi-line stack trace or single-instance/lock error without
        // unbounded growth on a chatty boot sequence; the head wins because
        // the bounded buffer truncates once full, capturing the early
        // boot output that precedes a fast exit. Task 578 — the buffer is
        // also dumped on every exit (not only fast exits), so a daemon that
        // hangs late at a future prompt is visible.
        let outputBuf = '';
        // Task 578 — `remoteBound` is a measured value derived from the
        // daemon's own post-bind output (see detectRcHandshake). It is
        // false until a handshake marker is observed and then stays true
        // for the life of this pty. Liveness reads this flag, never a
        // literal.
        let remoteBound = false;
        pty.onData((data) => {
            if (outputBuf.length < FAST_EXIT_OUTPUT_MAX_BYTES) {
                const remaining = FAST_EXIT_OUTPUT_MAX_BYTES - outputBuf.length;
                outputBuf += remaining >= data.length ? data : data.slice(0, remaining);
            }
            if (!remoteBound && detectRcHandshake(data)) {
                remoteBound = true;
                emitRcLife(deps.logger, 'handshake-observed', 'rc-daemon', 'node-pty', 'daemon', unitToken, {
                    ageMs: now() - startedAt,
                });
            }
        });
        // Liveness poll — matches the rc-spawn arm's cadence so the endurance
        // signature is directly comparable. Cleared on exit (below).
        const livenessTimer = setInterval(() => {
            if (current !== pty) {
                clearInterval(livenessTimer);
                return;
            }
            let pidAlive = false;
            try {
                process.kill(pty.pid, 0);
                pidAlive = true;
            }
            catch {
                pidAlive = false;
            }
            emitRcLife(deps.logger, 'liveness', 'rc-daemon', 'node-pty', 'daemon', unitToken, {
                ageMs: now() - startedAt,
                pidAlive,
                remoteBound,
            });
        }, 15_000);
        livenessTimer.unref?.();
        // Task 573 — success signal is the on-disk PID file. `waitForPid`
        // resolves once `claude rc` writes `<CLAUDE_CONFIG_DIR>/sessions/<pid>.json`.
        // The fire-and-forget chain emits `op=pidfile-present` (success) or
        // `op=wait-pid-failed` (timeout). Neither outcome aborts the
        // supervised lifetime; the child may still be running with the file
        // absent (logged failure surfaces it).
        let pidFileSeen = false;
        void deps.waitForPid(pty.pid, pidFileTimeoutMs)
            .then((row) => {
            pidFileSeen = true;
            emitRcLife(deps.logger, 'pidfile-present', 'rc-daemon', 'node-pty', 'daemon', unitToken, {
                pid: pty.pid,
                path: row.pidFilePath ?? 'unknown',
                ageMs: now() - startedAt,
            });
        })
            .catch((err) => {
            emitRcLife(deps.logger, 'wait-pid-failed', 'rc-daemon', 'node-pty', 'daemon', unitToken, {
                pid: pty.pid,
                err: JSON.stringify(err instanceof Error ? err.message : String(err)),
            });
        });
        pty.onExit((e) => {
            if (current === pty)
                current = null;
            clearInterval(livenessTimer);
            const ranMs = now() - startedAt;
            const exitCode = e.exitCode ?? null;
            const signal = e.signal ?? null;
            deps.logger(`${TAG} rc-daemon exit accountId=${deps.accountId} pid=${pty.pid} exitCode=${exitCode ?? 'null'} signal=${signal ?? 'null'} ranMs=${ranMs}`);
            emitRcLife(deps.logger, 'exit', 'rc-daemon', 'node-pty', 'daemon', unitToken, {
                pid: pty.pid,
                lifetimeMs: ranMs,
                exitCode: exitCode ?? 'null',
                signal: signal ?? 'null',
                pidFileSeen,
                remoteBound,
            });
            // Task 578 — dump the captured PTY output on every exit, not only
            // fast exits. A late exit (prompt-hang minutes in, daemon killed
            // by supervisor) previously dropped this buffer, hiding the
            // reason the daemon stopped making progress. Buffer is still
            // bounded by FAST_EXIT_OUTPUT_MAX_BYTES.
            const bytes = outputBuf.length;
            const body = bytes > 0 ? outputBuf : '<no child output captured>';
            const tag = ranMs < FAST_EXIT_THRESHOLD_MS ? 'fast-exit-output' : 'exit-output';
            deps.logger(`${TAG} rc-daemon ${tag} accountId=${deps.accountId} pid=${pty.pid} ranMs=${ranMs} bytes=${bytes} remoteBound=${remoteBound} output<<<\n${body}\n>>>`);
            try {
                pty.destroy?.();
            }
            catch { /* fd may already be gone */ }
            scheduleRestart(startedAt);
        });
    };
    const scheduleRestart = (startedAt) => {
        if (stopped)
            return;
        const ranMs = now() - startedAt;
        if (ranMs >= STABLE_RUN_MS) {
            // Healthy run that ended — treat the next start as fresh.
            consecutiveFastFailures = 0;
            backoffMs = BACKOFF_BASE_MS;
        }
        else {
            consecutiveFastFailures += 1;
        }
        if (consecutiveFastFailures >= MAX_FAST_FAILURES) {
            permanentlyFailed = true;
            deps.logger(`${TAG} rc-daemon permanently-failed accountId=${deps.accountId} consecutiveFastFailures=${consecutiveFastFailures} — composer will show "Set up Remote Control"; investigate claude rc on this device`);
            return;
        }
        const delay = backoffMs;
        deps.logger(`${TAG} rc-daemon restart-scheduled accountId=${deps.accountId} delayMs=${delay} fastFailures=${consecutiveFastFailures}`);
        backoffMs = Math.min(backoffMs * 2, BACKOFF_MAX_MS);
        setTimer(() => { void spawnOnce(); }, delay);
    };
    // Task 523 — gate the eager first spawn on Neo4j readiness. On a host
    // reboot the session-manager (a systemd USER unit) restarts before
    // neo4j.service (a systemd SYSTEM unit) is listening; a user unit cannot
    // order `After=` a system unit, so the ordering fix lives here. Without the
    // gate, the daemon spawned into a not-yet-ready Neo4j dies with exitCode=1
    // and crash-loops (ECONNREFUSED 7687) until Neo4j comes up. The poll removes
    // the race: spawn only once Neo4j accepts a bolt connection. Restart-time
    // gating is out of scope — the existing backoff + permanent-failure cap
    // covers daemon death once it is up; the reboot cold-start is the one
    // documented window (Task 523).
    const pollIntervalMs = deps.neo4jReadyPollIntervalMs ?? NEO4J_READY_POLL_INTERVAL_MS;
    const readyTimeoutMs = deps.neo4jReadyTimeoutMs ?? NEO4J_READY_TIMEOUT_MS;
    const hostPort = parseBoltHostPort(deps.brandedNeo4jUri);
    const probe = deps.checkNeo4jReady ??
        ((uri) => {
            const hp = parseBoltHostPort(uri);
            if (!hp)
                return Promise.resolve(true);
            return tcpProbe(hp.host, hp.port, NEO4J_READY_CONNECT_TIMEOUT_MS);
        });
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
    const waitForNeo4jThenSpawn = async () => {
        // No explicit host:port in the URI → skip the gate rather than probe a
        // guessed port (could falsely pass against another brand's Neo4j).
        if (!hostPort && !deps.checkNeo4jReady) {
            deps.logger(`${TAG} rc-daemon neo4j-readiness-skipped reason=unparseable-uri uri=${deps.brandedNeo4jUri} — spawning without gate`);
            void spawnOnce();
            return;
        }
        const target = hostPort ? `${hostPort.host}:${hostPort.port}` : deps.brandedNeo4jUri;
        const startedWait = now();
        let waited = false;
        let lastLogElapsed = -NEO4J_READY_LOG_EVERY_MS;
        while (!stopped) {
            let ready = false;
            try {
                ready = await probe(deps.brandedNeo4jUri);
            }
            catch {
                // A thrown probe is treated as not-ready — never an unhandled
                // rejection on this detached promise.
                ready = false;
            }
            if (stopped)
                return;
            if (ready) {
                if (waited) {
                    deps.logger(`${TAG} rc-daemon neo4j-ready target=${target} waitedMs=${now() - startedWait} — spawning`);
                }
                void spawnOnce();
                return;
            }
            waited = true;
            const elapsed = now() - startedWait;
            if (elapsed >= readyTimeoutMs) {
                permanentlyFailed = true;
                deps.logger(`${TAG} rc-daemon neo4j-unavailable target=${target} waitedMs=${elapsed} — Neo4j never came up within ${readyTimeoutMs}ms; not spawning. composer will show "Set up Remote Control"; check neo4j.service`);
                return;
            }
            if (elapsed - lastLogElapsed >= NEO4J_READY_LOG_EVERY_MS) {
                deps.logger(`${TAG} rc-daemon waiting-on-neo4j target=${target} elapsedMs=${elapsed} (session-manager started before neo4j is listening — gating spawn)`);
                lastLogElapsed = elapsed;
            }
            await sleep(pollIntervalMs);
        }
    };
    void waitForNeo4jThenSpawn();
    return {
        alive() {
            return current ? 1 : 0;
        },
        stop() {
            stopped = true;
            if (current) {
                // Task 573 — node-pty: send SIGTERM to the claude child and
                // release the master fd. The scope's TimeoutStopSec=5 still
                // caps any in-cgroup escalation systemd performs against the
                // reparented pid when the manager exits; node-pty itself does
                // not orchestrate SIGTERM→SIGKILL.
                try {
                    current.kill('SIGTERM');
                }
                catch { /* already gone */ }
                try {
                    current.destroy?.();
                }
                catch { /* fd may already be gone */ }
                current = null;
            }
            deps.logger(`${TAG} rc-daemon stopped accountId=${deps.accountId}`);
        },
    };
}
//# sourceMappingURL=rc-daemon.js.map