import { serve } from '@hono/node-server';
import { spawn as ptySpawn } from 'node-pty';
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { basename, dirname, join } from 'node:path';
import { filterBrandForeignAgentDirs, readBrandShipsPremiumBundles, } from './brand-foreign-filter.js';
import { loadConfig } from './config.js';
import { buildSystemdRunArgv, defaultSystemctlRunner, reapOrphanNonPtyScopes, scopeUnitName, } from './systemd-scope.js';
import { buildHttpApp } from './http-server.js';
import { forEachLivePty, livePtyCount, extractServerNameFromQualifiedTool, runPostArchiveSweep } from './pty-spawner.js';
import { loadToolSurface, ToolSurfaceError } from './tool-surface.js';
import { createSpawnRateLimiter } from './spawn-rate-limiter.js';
import { createAuditRegistry } from './public-tool-audit.js';
import { createFsWatcher } from './fs-watcher.js';
import { createJsonlTail, postLogLineViaFetch } from './jsonl-tail.js';
import { createPermissionModeTail } from './permission-mode-tail.js';
import { createReaper } from './reaper.js';
import { createIdentityAudit } from './admin-identity-audit.js';
import { createReachabilityAudit } from './public-agent-reachability.js';
import { createPtyCensus } from './pty-census.js';
import { projectsDirForCwd, sessionsDir } from './jsonl-path.js';
import { runSystemPromptSelfTest, runIdentityDriftAssertion, runIdentityForbiddenTokenCheck } from './system-prompt.js';
import { assertSpecialistDrift } from './specialist-drift.js';
import { UserTitleStore } from './user-title-store.js';
import { startRcDaemon } from './rc-daemon.js';
const TAG = '[claude-session-manager]';
const log = (line) => console.log(`${TAG} ${line}`);
/** Task 173 — brand.json is stamped at `${platformRoot}/config/brand.json`
 *  by the bundler (`packages/create-maxy-code/scripts/bundle.js`). Read the
 *  `plugins.excluded` array (zero or more plugin directory names the brand
 *  drops from the bundle) and return it as a set. Absence of the file —
 *  or any field shape mismatch — yields an empty set and a one-line
 *  warning; the manager continues with strict-boot semantics. The brand
 *  filter only relaxes drift for namespaces the brand explicitly excludes,
 *  so a missing config never silently widens the strip surface. */
function readBrandExcludedPlugins(platformRoot, logger) {
    const path = join(platformRoot, 'config', 'brand.json');
    let raw;
    try {
        raw = readFileSync(path, 'utf-8');
    }
    catch {
        logger(`brand-config-missing path=${path} excluded-plugins=none`);
        return new Set();
    }
    let parsed;
    try {
        parsed = JSON.parse(raw);
    }
    catch (err) {
        logger(`brand-config-malformed path=${path} err=${err instanceof Error ? err.message : String(err)} excluded-plugins=none`);
        return new Set();
    }
    if (!parsed || typeof parsed !== 'object') {
        logger(`brand-config-malformed path=${path} reason=not-object excluded-plugins=none`);
        return new Set();
    }
    const plugins = parsed.plugins;
    if (!plugins || typeof plugins !== 'object') {
        return new Set();
    }
    const excluded = plugins.excluded;
    if (!Array.isArray(excluded)) {
        return new Set();
    }
    const set = new Set();
    for (const v of excluded) {
        if (typeof v === 'string' && v.length > 0)
            set.add(v);
    }
    logger(`brand-config-loaded path=${path} excluded-plugins=${set.size === 0 ? 'none' : Array.from(set).sort().join(',')}`);
    return set;
}
// Task 250 — every claude PTY is launched via `systemd-run --user --scope`
// so the scope unit's cgroup owns claude, not the manager unit's cgroup.
// `cmd` (always `<claudeBin>`) and `args` (claude's own argv) come from
// pty-spawner.spawnClaudeSession; this adapter wraps them in the
// systemd-run argv (see systemd-scope.ts) and hands the wrapped argv to
// node-pty. The env block becomes one `--setenv=KEY=VAL` per key (claude
// inherits a clean, explicit env from systemd inside the scope); the env
// passed to node-pty is the manager's own process.env, which carries
// XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS for systemd-run itself to
// reach user systemd. node-pty's pty.pid is preserved across the chain
// of execs (systemd-run → sh → claude) because the sh wrapper uses
// `exec "$@"`, so pty.pid == claude.pid and the existing PID-file
// matching contract in fs-watcher.waitForPid is unchanged.
function spawnPtyAdapter(cmd, args, env, cwd, unitToken) {
    // Filter out any undefined env values — Record<string, string> is the
    // helper's contract and undefined would yield `--setenv=KEY=undefined`.
    const cleanEnv = {};
    for (const [k, v] of Object.entries(env)) {
        if (typeof v === 'string')
            cleanEnv[k] = v;
    }
    const wrapped = buildSystemdRunArgv({
        unitToken,
        claudeBin: cmd,
        claudeArgs: args,
        env: cleanEnv,
        cwd,
    });
    const child = ptySpawn(wrapped.cmd, wrapped.args, {
        name: 'xterm-256color',
        cols: 120,
        rows: 40,
        cwd,
        // node-pty's env is for systemd-run itself, not for claude. Pass the
        // manager's process.env so XDG_RUNTIME_DIR + DBUS_SESSION_BUS_ADDRESS
        // are available for systemd-run to reach the user manager. claude's
        // env block lands via --setenv= flags inside `wrapped.args`.
        env: process.env,
    });
    return {
        pid: child.pid,
        onData: (cb) => child.onData(cb),
        onExit: (cb) => child.onExit((e) => cb({ exitCode: e.exitCode, signal: e.signal })),
        write: (data) => child.write(data),
        kill: (signal) => child.kill(signal),
        // Task 170 — UnixTerminal.destroy is the only path that synchronously
        // releases the master fd by destroying the internal tty socket.
        // node-pty's IPty typings omit it; the cast is the narrowest escape.
        destroy: () => child.destroy(),
    };
}
async function main() {
    const config = loadConfig();
    log(`boot port=${config.port} persistDir=${config.persistDir} spawnCwd=${config.spawnCwd}`);
    log(`host hostname=${config.host.hostname} lanIPv4=${config.host.lanIPv4 ?? 'unresolved'} adminUrl=${config.host.adminUrl} tunnelUrl=${config.host.tunnelUrl ?? 'none'}` +
        (config.host.lanIPv4Diagnostic ? ` diagnostic=${JSON.stringify(config.host.lanIPv4Diagnostic)}` : ''));
    // per-spawn IDENTITY.md + SOUL.md reads happen against this dir.
    // Absence here is fatal in loadConfig; reaching this log line means
    // resolution succeeded and the brand persona will inject on every spawn.
    log(`boot accountDir=${config.accountDir}`);
    // load the tool surface once at boot. Refuse to start if any
    // PLUGIN.md tool entry lacks a publicAllowlist field (or has a malformed
    // one). The HTTP listener is only bound after this returns successfully,
    // so a malformed manifest never silently produces public spawns.
    const platformRoot = process.env.PLATFORM_ROOT ?? process.env.MAXY_PLATFORM_ROOT;
    if (!platformRoot) {
        log('boot-failed reason=platform-root-missing detail="PLATFORM_ROOT env var not set; cannot locate plugins/"');
        process.exit(1);
    }
    const pluginsRoot = join(platformRoot, 'plugins');
    // premium-plugins is a sibling of platform/ in the repo and ships under the
    // same root in the installed payload. Pass both so loadToolSurface returns
    // a merged surface; the premium tree is optional in non-default brands.
    const premiumPluginsRoot = join(dirname(platformRoot), 'premium-plugins');
    const pluginRoots = existsSync(premiumPluginsRoot) ? [pluginsRoot, premiumPluginsRoot] : [pluginsRoot];
    let toolSurface;
    try {
        toolSurface = loadToolSurface(pluginRoots);
    }
    catch (err) {
        if (err instanceof ToolSurfaceError) {
            log(`boot-failed reason=${err.reason} tool=${err.detail}`);
        }
        else {
            log(`boot-failed reason=tool-surface-load detail=${err instanceof Error ? err.message : String(err)}`);
        }
        process.exit(1);
    }
    log(`tool-surface admin=${toolSurface.admin.size} public=${toolSurface.public.size} all=${toolSurface.all.size} mcpServers=${toolSurface.mcpServers.size}`);
    // Sanity ceiling — not a cap. Task 164 set the admin surface to a
    // routing-only residual; if a future PLUGIN.md edit floods the admin
    // allowlist we want one log line above the operator's eye-line rather
    // than a silent return-to-2026.
    if (toolSurface.admin.size > 50) {
        log(`tool-surface-admin-too-wide count=${toolSurface.admin.size}`);
    }
    // Boot gate — every admin-allowlist tool `mcp__<plugin>__*` must have a
    // server descriptor under the same plugin key. The reverse failure mode
    // (server registered with no allowlisted tool) is harmless — the LLM never
    // sees that surface — so it is not checked. Both directions are surfaced in
    // mcp-config-allowlist-coverage for the operator.
    let adminRegistered = 0;
    for (const t of toolSurface.admin) {
        // Admin tools carry the long canonical prefix mcp__plugin_<dir>_<dir>__<tool>
        // (Task 502). extractServerNameFromQualifiedTool maps both long and short
        // shapes back to the plugin-dir key mcpServers is keyed on; the old inline
        // /^mcp__(.+?)__/ extracted `plugin_<dir>_<dir>` and never matched a key.
        const plugin = extractServerNameFromQualifiedTool(t);
        if (!plugin)
            continue;
        if (!toolSurface.mcpServers.has(plugin)) {
            log(`boot-failed reason=mcp-allowlist-without-server plugin=${plugin} tool=${t}`);
            process.exit(1);
        }
        adminRegistered += 1;
    }
    log(`mcp-config-allowlist-coverage admin-tools=${toolSurface.admin.size} admin-registered=${adminRegistered}`);
    // Bundled subagent directories — walked once at boot. The walk covers
    // `<platform>/plugins/*/agents/` and `<premium-plugins>/*/agents/` for both
    // roles; per-account specialists are layered per-spawn (admin only).
    //
    // Task 343 — premium-plugins/<bundle>/agents/ dirs whose bundle name is
    // not in brand.json#shipsPremiumBundles are brand-foreign and skipped.
    // A real-agent specialist file on a maxy-code install otherwise refuses
    // boot the moment its tools reference any namespace not in the maxy-code
    // tool surface. The filter is premium-only — platform/plugins/ is always
    // walked.
    const premiumMode = readBrandShipsPremiumBundles(platformRoot, log);
    const rawCandidates = [];
    for (const root of pluginRoots) {
        let entries;
        try {
            entries = readdirSync(root);
        }
        catch {
            continue;
        }
        const isPremium = root === premiumPluginsRoot;
        for (const name of entries.sort()) {
            const candidatePath = join(root, name, 'agents');
            try {
                if (!statSync(candidatePath).isDirectory())
                    continue;
            }
            catch {
                continue;
            }
            rawCandidates.push({ path: candidatePath, bundle: name, premium: isPremium });
        }
    }
    const { kept, skipped } = filterBrandForeignAgentDirs(rawCandidates, premiumMode);
    for (const s of skipped) {
        log(`agents-dir-skipped path=${s.path} reason=brand-foreign-bundle bundle=${s.bundle}`);
    }
    // Visibility for the failure mode where the brand ships a premium tree
    // but shipsPremiumBundles resolves to 'none' (missing field, false, or
    // malformed JSON) — every premium agent dir would be filtered silently.
    // One warning makes the misconfig grep-able.
    const premiumCandidates = rawCandidates.filter((c) => c.premium);
    if (premiumCandidates.length > 0 &&
        premiumMode.mode === 'none' &&
        skipped.length === premiumCandidates.length) {
        log(`brand-foreign-skip-all path=${premiumPluginsRoot} reason=shipsPremiumBundles-missing-or-empty premium-dirs-skipped=${premiumCandidates.length}`);
    }
    // Bundled subagents are available to both roles. Per-role gating is an
    // account-level concern (per-account specialists dir) and is added at
    // spawn time, not here.
    const agentsDirs = { admin: [], public: [] };
    for (const c of kept) {
        agentsDirs.admin.push(c.path);
        agentsDirs.public.push(c.path);
        log(`agents-dir-bundled path=${c.path}`);
    }
    // Boot-time specialist-drift assertion. Each specialist `.md` declares
    // its tool surface as a comma-separated frontmatter line; a name that
    // does not resolve to a known plugin tool, a CC-native tool, or an
    // MCP-bridge namespace is silent "tool not found" at the first use. We
    // catch the gap here instead of months later.
    //
    // Task 173 — brand-excluded plugins. brand.json's `plugins.excluded`
    // names plugin directories the bundle does NOT ship for this brand
    // (e.g. realagent-code excludes `telegram`). Tool names referencing an
    // excluded plugin's namespace are stripped from the specialist's
    // allowlist at spawn time rather than refusing boot. Tool names
    // referencing a namespace that no plugin produces — and which the
    // brand does NOT list as excluded — remain a refuse-to-boot defect.
    const excludedPlugins = readBrandExcludedPlugins(platformRoot, log);
    const driftAgentDirs = [
        join(platformRoot, 'templates', 'specialists', 'agents'),
        ...agentsDirs.admin,
        join(config.accountDir, 'specialists', 'agents'),
    ];
    const specialistDrift = assertSpecialistDrift(driftAgentDirs, toolSurface, excludedPlugins);
    if (!specialistDrift.ok) {
        for (const d of specialistDrift.defects) {
            log(`boot-failed reason=specialist-tool-drift specialist=${d.specialistName} tool=${d.tool} drift=${d.reason} path=${d.specialistPath}`);
            // Task 343 — mirror to journald with structured fields so
            // `journalctl --user -u <brand>-claude-session-manager.service` can
            // filter by `specialist=`, `tool=`, `drift_reason=`, `agent_path=`.
            // systemd-cat absence (e.g. macOS dev) is non-fatal — the stdout line
            // above is the primary surface; the structured emit is auxiliary.
            try {
                spawnSync('systemd-cat', ['-t', 'maxy-csm', '-p', 'err'], {
                    input: `boot-failed reason=specialist-tool-drift specialist=${d.specialistName} tool=${d.tool} drift_reason=${d.reason} agent_path=${d.specialistPath}\n`,
                    stdio: ['pipe', 'ignore', 'ignore'],
                    timeout: 1_000,
                });
            }
            catch { /* journald mirror is best-effort */ }
        }
        process.exit(1);
    }
    // brand-excluded defects landed in stripList: one line per specialist
    // names the affected plugin and tool list so an operator who reads
    // server.log sees the brand filter doing work without needing to
    // cross-reference brand.json against the template.
    for (const [specialistName, stripped] of specialistDrift.stripList) {
        const byPlugin = new Map();
        for (const tool of stripped) {
            const plugin = extractServerNameFromQualifiedTool(tool) ?? 'unknown';
            const list = byPlugin.get(plugin) ?? [];
            list.push(tool);
            byPlugin.set(plugin, list);
        }
        for (const [plugin, tools] of byPlugin) {
            log(`specialist-tool-strip specialist=${specialistName} plugin=${plugin} tools=${tools.sort().join(',')} reason=brand-excluded`);
        }
    }
    log(`startup-self-test specialist-tool-drift=ok inspected=${specialistDrift.inspected} stripped-specialists=${specialistDrift.stripList.size}`);
    // Task 565 observability item 1 — standing per-specialist surface log.
    // One line per specialist names the sorted tool set the manager resolved
    // from its `.md` frontmatter. A payload-twin or bundle drift that drops
    // a tool (e.g. Read/Edit/Write missing from a producer) is grep-visible
    // from server.log without re-running a failing brief.
    for (const [specialistName, tools] of [...specialistDrift.surfaces].sort(([a], [b]) => a.localeCompare(b))) {
        log(`specialist-surface specialist=${specialistName} tools=${[...tools].join(',')}`);
    }
    const accountId = basename(config.accountDir);
    const logDir = process.env.LOG_DIR ?? join(config.persistDir, 'logs');
    // Task 165 — the manager process inherits CLAUDE_CONFIG_DIR from its
    // systemd unit. pty-spawner reads specialist agent files from
    // `<claudeConfigDir>/agents/<name>.md` when `--agent` is set. Absence is
    // a boot-time refusal: without it, every specialist spawn would silently
    // run as the admin agent on admin IDENTITY/SOUL scaffolding.
    const claudeConfigDirResolved = process.env.CLAUDE_CONFIG_DIR ?? '';
    if (!claudeConfigDirResolved) {
        log('boot-failed reason=claude-config-dir-missing env=CLAUDE_CONFIG_DIR');
        process.exit(1);
    }
    log(`claude-config-dir resolved=${claudeConfigDirResolved}`);
    // Task 207 — Neo4j connection is now stamped on specialist spawn env
    // explicitly (previously it rode the per-spawn `.mcp.json` env field,
    // which this task drops). The brand-scoped URI is mandatory; absence
    // means the brand was installed without the EnvironmentFile=-<persist>/.env
    // line that the installer writes, and no recorder spawn will ever produce
    // a tool_use turn against the live graph. Loud-fail at boot replaces the
    // silent regression where the recorder emitted prose `<tool_call>` for
    // weeks (investigation 2026-05-20-database-operator-graph-write-binding-defect).
    const brandedNeo4jUri = process.env.NEO4J_URI ?? '';
    if (!brandedNeo4jUri) {
        log('boot-failed reason=neo4j-uri-missing env=NEO4J_URI');
        process.exit(1);
    }
    let brandedNeo4jPassword = process.env.NEO4J_PASSWORD ?? '';
    if (!brandedNeo4jPassword) {
        const passwordFile = join(platformRoot, 'config', '.neo4j-password');
        try {
            brandedNeo4jPassword = readFileSync(passwordFile, 'utf-8').trim();
        }
        catch (err) {
            const detail = err instanceof Error ? err.message : String(err);
            log(`boot-failed reason=neo4j-password-unresolved env=NEO4J_PASSWORD file=${passwordFile} err=${detail}`);
            process.exit(1);
        }
    }
    if (!brandedNeo4jPassword) {
        log('boot-failed reason=neo4j-password-empty env=NEO4J_PASSWORD');
        process.exit(1);
    }
    log(`neo4j-env resolved uri=${brandedNeo4jUri} password-bytes=${brandedNeo4jPassword.length}`);
    // Read users.json so USER_ID reaches rc-daemon and /rc-spawn children.
    // Absent on pre-onboarding installs — tolerated, daemon still starts with
    // USER_ID=''. Path is <brand-dir>/users.json (one level above CLAUDE_CONFIG_DIR).
    let adminUserId = '';
    const usersJsonPath = join(dirname(claudeConfigDirResolved), 'users.json');
    try {
        const raw = readFileSync(usersJsonPath, 'utf-8').trim();
        const parsed = JSON.parse(raw);
        if (Array.isArray(parsed)) {
            const first = parsed[0];
            const uid = first && typeof first === 'object' ? first.userId : undefined;
            adminUserId = typeof uid === 'string' ? uid : '';
            log(`[claude-session-manager] rc-child-env userId-present=${adminUserId.length > 0 ? 'yes' : 'no'} source=users.json`);
        }
        else {
            log(`[claude-session-manager] rc-child-env userId-present=no source=malformed`);
        }
    }
    catch {
        log(`[claude-session-manager] rc-child-env userId-present=no source=absent`);
    }
    // Boot-time drift catcher: render the prompt block with synthetic inputs
    // that exercise every optional sentinel. If a future refactor drops the
    // `<about-owner>` / `<dormant-plugins>` emit, the manager refuses to start
    // rather than producing spawns that silently strand the operator-visible
    // signal IDENTITY.md prose promises.
    const selfTest = runSystemPromptSelfTest();
    if (!selfTest.ok) {
        log(`startup-self-test system-prompt-sentinels=fail missing=${selfTest.missing}`);
        process.exit(1);
    }
    log('startup-self-test system-prompt-sentinels=ok');
    // Boot-time drift assertion: every sentinel IDENTITY.md names in prose must
    // be one the composer would emit. A refactor that drops a sentinel — or an
    // IDENTITY edit that names a new one — surfaces here, not at runtime when an
    // operator hits the gap. Read the admin IDENTITY.md (the prose authority);
    // a missing or unreadable file is itself a refusal-to-boot condition.
    const adminIdentityPath = join(config.accountDir, 'agents', 'admin', 'IDENTITY.md');
    let adminIdentity;
    try {
        adminIdentity = readFileSync(adminIdentityPath, 'utf-8');
    }
    catch (err) {
        log(`boot-failed reason=identity-drift-read path=${adminIdentityPath} err=${err instanceof Error ? err.message : String(err)}`);
        process.exit(1);
    }
    const drift = runIdentityDriftAssertion(adminIdentity);
    if (!drift.ok) {
        log(`startup-self-test identity-drift=fail missing-sentinel=${drift.missing} checked=${drift.sentinelsChecked.join(',')}`);
        process.exit(1);
    }
    log(`startup-self-test identity-drift=ok checked=${drift.sentinelsChecked.join(',') || 'none'}`);
    // Task 437: structural de-leak gate. The admin IDENTITY.md must not carry
    // protocol-and-tool narration (tool names, task numbers, file paths, node
    // labels, hook lifecycle names, counter tokens, angle-bracket sentinels,
    // backtick code-spans) because the model treats whatever it reads in the
    // identity surface as its own voice. A regenerated per-account copy that
    // drifts off the template fails the manager boot rather than reaching an
    // operator turn.
    const forbidden = runIdentityForbiddenTokenCheck(adminIdentity);
    if (!forbidden.ok) {
        const summary = forbidden.violations
            .slice(0, 8)
            .map((v) => `${v.class}=${v.token}`)
            .join(' ');
        log(`startup-self-test identity-forbidden-tokens=fail count=${forbidden.violations.length} first=${summary}`);
        process.exit(1);
    }
    log('startup-self-test identity-forbidden-tokens=ok');
    // per-public-sender spawn throttle + per-call audit watcher.
    const publicSpawnLimiter = createSpawnRateLimiter(config.publicSpawnLimits);
    const publicToolAudit = createAuditRegistry({ logger: log });
    log(`public-spawn-limits perChannelCap=${config.publicSpawnLimits.perChannel.whatsapp.cap}` +
        ` perChannelWindowMs=${config.publicSpawnLimits.perChannel.whatsapp.windowMs}` +
        ` globalCap=${config.publicSpawnLimits.global.cap}` +
        ` globalWindowMs=${config.publicSpawnLimits.global.windowMs}`);
    // Filesystem watcher — single source of truth for the row index.
    // Watches the two dirs Claude Code writes (sessions/<pid>.json and
    // projects/<slug>/<sid>.jsonl), unifies them into one in-memory index
    // keyed on the intrinsic sessionId. PID files give live status; JSONL
    // gives archived transcripts. Boot-seed runs synchronously before
    // arming the fs.watch handlers so a restart mid-session re-binds to
    // PTYs still alive on disk.
    const watcher = createFsWatcher({
        sessionsDir: sessionsDir(),
        projectsDir: projectsDirForCwd(config.spawnCwd),
        logger: log,
        // Task 250 — bootSeed uses systemctl to reconcile surviving
        // claude-session-*.scope units across a manager restart.
        runSystemctl: defaultSystemctlRunner,
    });
    watcher.bootSeed();
    watcher.arm();
    // Idle-recorder reaper: every 30s (and on each PID-file event) walks
    // watcher rows and SIGTERMs specialist rows stuck status:idle with no
    // JSONL past TTL. Single source of pty leak the Stop hook can produce
    // when a recorder errors-without-exit.
    const reaper = createReaper({
        watcher,
        spawnCwd: config.spawnCwd,
        recorderIdleTtlMs: config.recorderIdleTtlMs,
        killGraceMs: config.killGraceMs,
        intervalMs: 30_000,
        logger: log,
        runSystemctl: defaultSystemctlRunner,
    });
    reaper.start();
    log(`pty-cap specialistCap=${config.specialistCap} operatorReserve=${config.operatorReserve} totalPtyCap=${config.totalPtyCap === Number.POSITIVE_INFINITY ? 'unbounded' : config.totalPtyCap} recorderIdleTtlMs=${config.recorderIdleTtlMs}`);
    // Task 619 — standing audit for the native-CC admin PIN identity gate. Every
    // 60s, reconciles live native-operator sessions (watcher rows with null role)
    // that reached real tool use against the per-session auth-state files the gate
    // hook writes. A session that acted without an authenticated userId emits
    // `[admin-identity] op=audit-orphan` — the no-event signal a silent gate
    // bypass would otherwise leave invisible. State dir matches the hook's
    // resolution (<accountDir>/state/admin-identity).
    const identityAudit = createIdentityAudit({
        watcher,
        spawnCwd: config.spawnCwd,
        stateDir: join(config.spawnCwd, 'state', 'admin-identity'),
        intervalMs: 60_000,
        logger: log,
    });
    identityAudit.start();
    // Task 623 — standing public-agent reachability audit. Every 60s, walks
    // agents/<slug>/ with status=active and emits a reachability verdict per
    // agent — the no-event detector for an active agent that is shadowed or
    // otherwise unrouted. Names the exact slug and cause without a visitor
    // request.
    const reachabilityAudit = createReachabilityAudit({
        accountDir: config.accountDir,
        intervalMs: 60_000,
        logger: log,
    });
    reachabilityAudit.start();
    // Task 627 — standing PTY+RAM census. Independent of spawn/exit events:
    // every 60s logs the live-PTY count against per-process RSS and system
    // RAM/fd so the sustainable ceiling can be read from logs alone, and a RAM
    // approach-to-ceiling (which emits no event and does not reproduce on
    // demand) is caught by this independent periodic audit rather than a log
    // hung on a spawn. The capacity field is sourced from RC_DAEMON_CAPACITY.
    const ptyCensus = createPtyCensus({ intervalMs: 60_000, logger: log });
    ptyCensus.start();
    // Task 183 — recorder JSONL tail. Posts [recorder-session-init] and
    // [recorder-turn] lines for every database-operator specialist spawn,
    // routed through the platform UI's /api/admin/log-ingest loopback
    // endpoint so the lines land on server.log next to the existing
    // [pty-spawn-tool-inventory] line. Absence of MAXY_UI_INTERNAL_PORT
    // is a warn (not a boot refusal) — older brand deployments may not
    // stamp the port yet, in which case the tail still runs but its lines
    // never reach server.log. Operator spawns are not registered.
    const uiPortForTail = process.env.MAXY_UI_INTERNAL_PORT ?? '';
    if (uiPortForTail.length === 0) {
        log('jsonl-tail-config warn=MAXY_UI_INTERNAL_PORT-missing recorder-evidence=will-not-reach-server-log');
    }
    const jsonlTail = createJsonlTail({
        postLogLine: postLogLineViaFetch({ uiPort: uiPortForTail || '0', logger: log }),
        logger: log,
    });
    // Task 278 — derives effectivePermissionMode from the per-session
    // auto-mode debug capture. http-server's jsonl-event subscriber drives it.
    const permissionModeTail = createPermissionModeTail({ logger: log });
    // Task 253 — operator-stamped session titles. File lives at
    // <accountDir>/session-titles.json. Boot reads it into memory; rename
    // route writes; cleanup-on-purge prevents orphan accretion. Boot-resilient
    // (any read/parse failure is logged and the store starts empty).
    const userTitleStore = new UserTitleStore(join(config.accountDir, 'session-titles.json'), log);
    userTitleStore.loadFromDisk();
    log(`user-title-store ready entries=${userTitleStore.size()} path=${join(config.accountDir, 'session-titles.json')}`);
    const app = buildHttpApp({
        watcher,
        spawnCwd: config.spawnCwd,
        claudeBin: config.claudeBin,
        pidFileTimeoutMs: config.urlCaptureTimeoutMs,
        urlCaptureTimeoutMs: config.urlCaptureTimeoutMs,
        killGraceMs: config.killGraceMs,
        jsonlReadMaxBytes: config.jsonlReadMaxBytes,
        logger: log,
        spawnPty: (cmd, args, env, unitToken) => spawnPtyAdapter(cmd, args, env, config.spawnCwd, unitToken),
        runSystemctl: defaultSystemctlRunner,
        host: config.host,
        accountDir: config.accountDir,
        accountId,
        userId: adminUserId,
        platformRoot,
        logDir,
        agentsDirs,
        perAccountSpecialistsDir: join(config.accountDir, 'specialists', 'agents'),
        claudeConfigDir: claudeConfigDirResolved,
        toolSurface,
        publicSpawnLimiter,
        publicToolAudit,
        specialistCap: config.specialistCap,
        operatorReserve: config.operatorReserve,
        totalPtyCap: config.totalPtyCap,
        specialistToolStripList: specialistDrift.stripList,
        brandedNeo4jUri,
        brandedNeo4jPassword,
        jsonlTail,
        permissionModeTail,
        userTitleStore,
    });
    // Task 554 — sweep orphan non-PTY scopes (rc-daemon-*, rc-resume-*) left
    // by the prior MainPID-based activation gate, which threw on every spawn
    // and never stopped the partially-registered scope. Awaited so the rc
    // daemon's first spawn and the HTTP /rc-spawn route don't observe an
    // orphan in `list-units` mid-reap. Idempotent: post-rollout the list is
    // empty and this is a single `systemctl list-units` call.
    await reapOrphanNonPtyScopes(defaultSystemctlRunner, log);
    serve({ fetch: app.fetch, port: config.port, hostname: '127.0.0.1' }, (info) => {
        log(`listening address=127.0.0.1:${info.port}`);
        log(`[spawn-routes] live=[rc-spawn,public-spawn]`);
    });
    // Task 500 — per-account `claude rc` daemon. Started after the watcher is
    // armed so its composer-spawned sessions' PID files are observed live, and
    // after the listener is up so the recorder's loopback POST has a target.
    // The daemon is manager-owned (single owner) — its sessions surface in the
    // fs-watcher with agent=null, so the reaper never touches them.
    const rcDaemon = startRcDaemon({
        claudeBin: config.claudeBin,
        spawnCwd: config.spawnCwd,
        accountDir: config.accountDir,
        accountId,
        userId: adminUserId,
        brandProductName: config.brandProductName,
        claudeConfigDir: claudeConfigDirResolved,
        platformRoot,
        logDir,
        brandedNeo4jUri,
        brandedNeo4jPassword,
        uiPort: uiPortForTail,
        toolSurface,
        logger: log,
        // Task 573 — daemon spawns through node-pty + systemd-scope (shares
        // the same adapter the /rc-spawn route uses). Exit detection comes
        // from pty.onExit; fast-exit output from a bounded onData ring buffer;
        // success signal from the on-disk pid file via watcher.waitForPid.
        spawnPty: (cmd, args, env, unitToken) => spawnPtyAdapter(cmd, args, env, config.spawnCwd, unitToken),
        waitForPid: (pid, timeoutMs) => watcher.waitForPid(pid, timeoutMs).then((r) => ({ pidFilePath: r.pidFilePath ?? null })),
        pidFileTimeoutMs: config.urlCaptureTimeoutMs,
    });
    // Task 558 — independent post-archive fd sweep. Runs on a fixed
    // interval (60 s), decoupled from spawn/archive request traffic so a
    // master-fd leak surfaces within one sweep interval even with zero
    // user activity. The `runPostArchiveSweep` helper owns the
    // `livePtys` walk; this closure supplies the watcher-derived row
    // state (archived/live) and the archive timestamp. `unref()` keeps
    // the timer from blocking process exit.
    const POST_ARCHIVE_SWEEP_INTERVAL_MS = 60_000;
    const ORPHAN_MASTER_ESCALATE_MS = 300_000;
    const postArchiveSweepTimer = setInterval(() => {
        try {
            runPostArchiveSweep({
                logger: log,
                isArchived: (sessionId) => watcher.getBySessionId(sessionId)?.state === 'archived',
                liveSessionIds: () => watcher.all().filter((r) => r.state !== 'archived').map((r) => r.sessionId),
                archivedAtMs: (sessionId) => {
                    const row = watcher.getBySessionId(sessionId);
                    return row && row.state === 'archived' && row.updatedAt > 0 ? row.updatedAt : null;
                },
                ageEscalateMs: ORPHAN_MASTER_ESCALATE_MS,
            });
        }
        catch (err) {
            log(`[fd-audit] op=post-archive-sweep-error err=${err instanceof Error ? err.message : String(err)}`);
        }
    }, POST_ARCHIVE_SWEEP_INTERVAL_MS);
    postArchiveSweepTimer.unref();
    const shutdown = (signal) => {
        log(`shutdown signal=${signal} sessions=${livePtyCount()}`);
        publicToolAudit.drain();
        // Task 573 — single rc-daemon arm; rc-spawn lifetimes are tracked
        // alongside operator/specialist PTYs in livePtys. Logging the daemon
        // alive count answers "did the daemon survive shutdown handling?"
        // from a logged number without re-running anything.
        log(`[manager-shutdown] op=rc-audit source=rc-daemon path=node-pty live=${rcDaemon.alive()}`);
        rcDaemon.stop();
        reaper.stop();
        identityAudit.stop();
        ptyCensus.stop();
        watcher.close();
        clearInterval(postArchiveSweepTimer);
        // Task 250 + 260 — manager shutdown NO LONGER kills the PTYs.
        // Each claude PTY lives in its own `claude-session-<token>.scope`
        // cgroup owned by user systemd; on manager restart, only the
        // manager process dies. The SIGHUP-trap wrap inside the scope
        // (see systemd-scope.ts) keeps claude alive when its PTY master
        // fd closes here. We still release the master fd locally — that's
        // manager-owned state — and walk the module-scoped tracker map
        // (Task 260) instead of the deleted SessionStore.
        //
        // Task 558 — quantify the master-audit at shutdown so the
        // out-of-scope "does manager restart kill on-device live sessions"
        // question is answered by a logged number, not speculation. `held`
        // is the count of trackers in `livePtys` at shutdown entry;
        // `liveSessionsClosed` is the subset whose `master-fd=released`
        // here (i.e. node-pty masters destroyed by this shutdown call).
        let held = 0;
        let liveSessionsClosed = 0;
        forEachLivePty((t) => {
            held++;
            const unit = scopeUnitName(t.scopeUnitToken);
            const releasedBefore = t.fdReleased;
            if (!releasedBefore) {
                try {
                    t.pty.destroy?.();
                    liveSessionsClosed++;
                }
                catch { /* fd may already be gone */ }
            }
            log(`[manager-shutdown] survives unit=${unit} sessionId=${t.sessionId} pid=${t.pid} master-fd=${releasedBefore ? 'closed' : 'released'}`);
        });
        log(`[manager-shutdown] op=master-audit held=${held} liveSessionsClosed=${liveSessionsClosed}`);
        process.exit(0);
    };
    process.on('SIGTERM', () => shutdown('SIGTERM'));
    process.on('SIGINT', () => shutdown('SIGINT'));
}
main().catch((err) => {
    console.error(`${TAG} fatal ${err instanceof Error ? err.stack : String(err)}`);
    process.exit(1);
});
//# sourceMappingURL=index.js.map