// Loopback-only HTTP API exposed by the manager. The Hono wrapper at
// platform/ui/server/routes/admin/claude-sessions.ts proxies admin requests
// here via fetch.
//
// Routes:
//   POST   /spawn               { senderId, role, channel, tunnelUrl? } -> Session
//   POST   /:id/stop            -> 204 (kill PTY; idempotent on already-dead row)
//                                 404 if no row matches any of the 4 keys
//   DELETE /:id                 -> 204 (rmSync JSONL + subdir)
//                                 409 if PTY still alive
//                                 404 if no transcript on disk
//   POST   /:id/input           { text }
//   GET    /:id/log             -> { jsonlPath, sizeBytes, exists }
//   GET    /:id/log?follow=1    -> streamed file content (current + new)
//   GET    /:id/meta            -> SessionMeta payload
//   POST   /:id/archive         { mode }
//
// Any of the four ids — intrinsic sessionId, bridgeSessionId, bridgeSuffix,
// or pid (numeric) — resolves to the same row via the fs-watcher index.
// Unknown ids return 404; nothing returns a silent 204 against an id the
// manager does not know.
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { existsSync, statSync, createReadStream, watchFile, unwatchFile, rmSync, mkdirSync, renameSync, readFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { buildRcChildEnv } from './rc-daemon.js';
import { spawnClaudeSession, stopSession, writeInputToPty, isLive, onPtyExit, livePtyCount, getPtyTrackerForTests, livePidForSession, priorExitedCountForSession, PERMISSION_MODES, openFdCount, registerRcSpawnPty, } from './pty-spawner.js';
import { readSidecar, updateSidecar, appendBridgeId } from './session-sidecar.js';
import { emitRcLife } from './rc-life.js';
import { readJsonlSession } from './jsonl-enumerator.js';
import { claudeStateRoot, projectSlugForCwd, jsonlPathForSessionId, sidecarPathForSessionId, permissionModeLogPathForSessionId, findExistingJsonlForSessionId, } from './jsonl-path.js';
import { basename, join } from 'node:path';
import { validateUserTitle } from './user-title-store.js';
import { credentialsPathForConfigDir, snapshotAuthForFailureReason } from './auth-snapshot.js';
const ROLES = ['admin', 'public'];
const CHANNELS = ['browser', 'whatsapp', 'telegram', 'webchat', 'email'];
// Task 382 — body.conversationNodeId shape gate. The value is the
// sessionId UUID (v4) minted by createNewAdminConversation in
// platform/ui/app/lib/neo4j-store.ts.
const UUID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
function parseAboutOwner(raw) {
    if (!raw || typeof raw !== 'object')
        return undefined;
    const r = raw;
    if (r.ok === true && typeof r.body === 'string')
        return { ok: true, body: r.body };
    if (r.ok === false && typeof r.reason === 'string' && r.reason.length > 0)
        return { ok: false, reason: r.reason };
    return undefined;
}
function parseDormantPlugins(raw) {
    if (!Array.isArray(raw))
        return undefined;
    const out = [];
    for (const entry of raw) {
        if (!entry || typeof entry !== 'object')
            continue;
        const e = entry;
        if (typeof e.name !== 'string' || e.name.length === 0)
            continue;
        if (typeof e.description !== 'string')
            continue;
        out.push({ name: e.name, description: e.description });
    }
    return out.length > 0 ? out : undefined;
}
function parseNameDescList(raw) {
    if (!Array.isArray(raw))
        return [];
    const out = [];
    for (const entry of raw) {
        if (!entry || typeof entry !== 'object')
            continue;
        const e = entry;
        if (typeof e.name !== 'string' || e.name.length === 0)
            continue;
        const item = { name: e.name };
        if (typeof e.description === 'string' && e.description.length > 0)
            item.description = e.description;
        out.push(item);
    }
    return out;
}
function parseActivePlugins(raw) {
    if (!Array.isArray(raw))
        return undefined;
    const out = [];
    for (const entry of raw) {
        if (!entry || typeof entry !== 'object')
            continue;
        const e = entry;
        if (typeof e.name !== 'string' || e.name.length === 0)
            continue;
        if (typeof e.description !== 'string')
            continue;
        out.push({
            name: e.name,
            description: e.description,
            tools: parseNameDescList(e.tools),
            skills: parseNameDescList(e.skills),
        });
    }
    return out.length > 0 ? out : undefined;
}
function parseSpecialistDomains(raw) {
    if (!Array.isArray(raw))
        return undefined;
    const out = [];
    for (const entry of raw) {
        if (!entry || typeof entry !== 'object')
            continue;
        const e = entry;
        if (typeof e.name !== 'string' || e.name.length === 0)
            continue;
        if (typeof e.description !== 'string')
            continue;
        out.push({ name: e.name, description: e.description, tools: parseNameDescList(e.tools) });
    }
    return out.length > 0 ? out : undefined;
}
// `<id>` may be: intrinsic UUID, bridgeSessionId ("session_<suffix>"),
// bridgeSuffix, or pid (numeric). No `.` or `/` — the character class
// is narrowed so the downstream `jsonlPathForSessionId` composition
// cannot be steered into a sibling extension or a path segment.
export const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
// Task 648 — `/rc-spawn` polls the pid file for the late-written
// bridgeSessionId (the `claude.ai/code/session_<id>` slug) up to this bound.
// Device-measured RC bridge bind latency is 18–30s; the prior 12s bound
// expired first, so the slug was never captured before the response returned
// and the client opened a bare URL. 60s covers the observed worst case.
// Injectable via `HttpDeps.rcSpawnUrlWaitMs` so the timeout path is
// unit-testable without a real 60s wait.
export const RC_SPAWN_URL_WAIT_DEFAULT_MS = 60_000;
const LIST_TAIL_BYTES = 64 * 1024;
function timed(logger, method, path, status, ms) {
    logger(`api method=${method} path=${path} outcome=${status} ms=${ms}`);
}
/** Resolve any of the 4 keys to the canonical (sessionId, row) pair.
 *  Tries: intrinsic sessionId, bridgeSessionId, bridgeSuffix, numeric pid.
 *  Returns undefined when nothing matches. */
function resolveRow(watcher, id) {
    const direct = watcher.getBySessionId(id);
    if (direct)
        return direct;
    const byBridge = watcher.getByBridgeSessionId(id);
    if (byBridge)
        return byBridge;
    const bySuffix = watcher.getByBridgeSuffix(id);
    if (bySuffix)
        return bySuffix;
    if (/^\d+$/.test(id)) {
        const byPid = watcher.getByPid(Number(id));
        if (byPid)
            return byPid;
    }
    return undefined;
}
// Task 147 — auto-archive for the database-operator specialist on
// `end_turn`. The recorder is a Sidebar-equivalent admin spawn whose only
// override is the specialist value. Once its JSONL contains an assistant
// message with `stop_reason === "end_turn"`, stop the PTY and let the
// normal archive path run. No tool-call tally, no recorder-only log
// surfaces — the Sidebar's `pty-spawned … jsonl … end_turn …
// auto-archive` lines cover the lifecycle.
const END_TURN_SIGNATURE = '"stop_reason":"end_turn"';
const AUTO_ARCHIVE_SPECIALIST = 'database-operator';
// One-shot auto-close: once the session's JSONL contains an assistant
// message with `stop_reason === "end_turn"`, stop the PTY so the session
// closes after its single turn. Shared by the public-spawn database-operator
// specialist path (below) and the `/rc-spawn` `closeAfterTurn` jobs (LinkedIn
// ingest, public session-end review). `label` only colours the log line.
function attachEndTurnAutoClose(deps, sessionId, label) {
    let closed = false;
    const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
    function endTurnSeen() {
        try {
            return readFileSync(jsonlPath, 'utf8').includes(END_TURN_SIGNATURE);
        }
        catch {
            return false;
        }
    }
    function tryClose() {
        if (closed)
            return;
        if (!existsSync(jsonlPath))
            return;
        if (!endTurnSeen())
            return;
        closed = true;
        deps.logger(`auto-archive sessionId=${sessionId} ${label} reason=end-turn archivedAt=${new Date().toISOString()}`);
        void stopSession({
            killGraceMs: deps.killGraceMs,
            logger: deps.logger,
            runSystemctl: deps.runSystemctl,
        }, sessionId);
    }
    tryClose();
    const unsubscribe = deps.watcher.subscribe((event) => {
        if (closed)
            return;
        if (event.sessionId !== sessionId)
            return;
        if (event.source !== 'jsonl')
            return;
        tryClose();
    });
    onPtyExit(sessionId, () => unsubscribe());
}
// Task 147 — public-spawn database-operator specialist auto-archive. Unchanged
// behaviour: only the database-operator specialist auto-closes on end_turn.
function attachSpecialistEndTurnAutoArchive(deps, sessionId, specialist) {
    if (specialist !== AUTO_ARCHIVE_SPECIALIST)
        return;
    attachEndTurnAutoClose(deps, sessionId, `specialist=${AUTO_ARCHIVE_SPECIALIST}`);
}
function attachPublicAudit(deps, sessionId, role) {
    if (role !== 'public')
        return;
    const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
    if (!existsSync(jsonlPath))
        return;
    deps.publicToolAudit.attach(sessionId, jsonlPath);
    if (!isLive(sessionId)) {
        deps.publicToolAudit.detach(sessionId);
        return;
    }
    onPtyExit(sessionId, () => {
        deps.publicToolAudit.detach(sessionId);
    });
}
// Task 488 — one-time guard so a manager booted without the UI port logs the
// gap once rather than on every public PTY exit.
let exitNotifyEnvWarned = false;
/** Task 488 — notify the bridge when a role=public PTY exits explicitly
 *  (operator stop, claude crash, manager DELETE). The bridge owns the slice
 *  ringfence the session-end reviewer needs; the manager knows only the
 *  sessionId, so it POSTs that and lets the bridge resolve the rest. Wired at
 *  the same two sites as `attachPublicAudit` (spawn-ready + watcher create) so
 *  it covers every public PTY this manager process owns.
 *
 *  Fire-and-forget: the POST is best-effort. When it is lost (bridge down,
 *  port unset), the bridge idle-reaper is the backstop — it reviews the
 *  session from the archived transcript at its next sweep. The notify only
 *  makes the review prompt and covers the manager-DELETE case where the
 *  transcript is removed before the idle sweep. */
function attachPublicExitNotify(sessionId, role, logger) {
    if (role !== 'public')
        return;
    if (!isLive(sessionId))
        return;
    onPtyExit(sessionId, () => {
        const uiPort = process.env.MAXY_UI_INTERNAL_PORT ?? '';
        if (uiPort.length === 0) {
            if (!exitNotifyEnvWarned) {
                exitNotifyEnvWarned = true;
                logger('[public-session-exit-notify] warn=MAXY_UI_INTERNAL_PORT-missing review-on-exit=will-not-reach-bridge');
            }
            return;
        }
        const url = `http://127.0.0.1:${uiPort}/api/admin/public-session-exit`;
        void fetch(url, {
            method: 'POST',
            headers: { 'content-type': 'application/json' },
            body: JSON.stringify({ sessionId }),
        })
            .then((res) => {
            logger(`[public-session-exit-notify] sessionId=${sessionId.slice(0, 8)} status=${res.status}`);
        })
            .catch((err) => {
            logger(`[public-session-exit-notify] sessionId=${sessionId.slice(0, 8)} error=${err instanceof Error ? err.message : String(err)}`);
        });
    });
}
export function buildHttpApp(deps) {
    const app = new Hono();
    // Task 189 — process-local fan-out from `url-capture` to every connected
    // `/events` client. The pty-spawner mutates `StoredSession.url` in place
    // when the `/remote-control is active · <url>` banner lands; this
    // broadcaster relays the sessionId so each /events handler can push a
    // `row-updated` frame carrying the new URL. The set is mutated by the
    // /events handler on connect/disconnect; the spawner deps below pass
    // `notifyUrlCaptured` into both spawn call sites.
    const urlUpdateListeners = new Set();
    function notifyUrlCaptured(sessionId) {
        for (const listener of urlUpdateListeners)
            listener(sessionId);
    }
    app.__notifyUrlCapturedForTests = notifyUrlCaptured;
    // Task 253 — same fan-out shape for rename. The POST /:sessionId/rename
    // route writes the user-title store and then calls notifyRename; each
    // /events handler then re-emits a `row-updated` frame so every browser
    // tab sees the new label without re-fetching the sidebar route.
    const renameListeners = new Set();
    function notifyRename(sessionId) {
        for (const listener of renameListeners)
            listener(sessionId);
    }
    app.__notifyRenameForTests = notifyRename;
    // Task 550 — bridge ULID → JSONL UUID persistence. The fs-watcher reads
    // each `<sessionsDir>/<pid>.json` and exposes the composer's
    // `bridgeSessionId` on the row. On every pid-source create/modify event
    // where the row carries a non-null bridge suffix, append that suffix to
    // the matching `<projectsDir>/<sessionId>.meta.json` sidecar so a future
    // composer-URL lookup resolves to this row even after the PID file is
    // unlinked at PTY exit. Append-only with duplicate suppression in
    // `appendBridgeId`; legacy rows without a sidecar log `no-sidecar` and
    // skip per the task brief (out of scope for back-fill).
    deps.watcher.subscribe((event) => {
        if (event.source !== 'pid')
            return;
        if (event.kind !== 'create' && event.kind !== 'modify')
            return;
        if (!event.row)
            return;
        const suffix = event.row.bridgeSuffix;
        if (!suffix)
            return;
        const sidecarPath = sidecarPathForSessionId(deps.spawnCwd, event.sessionId);
        const outcome = appendBridgeId(sidecarPath, suffix, deps.logger);
        if (outcome === 'ok') {
            deps.logger(`[sessions-bridge-bind] sessionId=${event.sessionId} bridgeId=${suffix}`);
            deps.watcher.refreshSidecar?.(event.sessionId);
        }
        else if (outcome === 'no-sidecar') {
            deps.logger(`[sessions-bridge-bind] sessionId=${event.sessionId} bridgeId=${suffix} outcome=no-sidecar`);
        }
    });
    // Watcher subscriber: when the JSONL CREATE event fires for a live
    // session, attach the public-audit watcher. The watcher row carries
    // the role from the sidecar (Task 260 — disk is the source of truth),
    // so the audit attachment fires deterministically against rows whose
    // PTY is still alive in this manager process.
    deps.watcher.subscribe((event) => {
        if (event.source !== 'jsonl')
            return;
        if (event.kind !== 'create' && event.kind !== 'modify')
            return;
        if (!event.row)
            return;
        if (!isLive(event.sessionId))
            return;
        attachPublicAudit(deps, event.sessionId, event.row.role);
        attachPublicExitNotify(event.sessionId, event.row.role, deps.logger);
    });
    // Task 183 — dispatch jsonl modify/create events into the recorder
    // tail. The tail filters by registered sessionId (only specialist
    // database-operator spawns are registered), so operator turns pass
    // through without producing recorder-* lines. The tail's append-only
    // cursor ensures repeated modify events for the same JSONL only read
    // newly-appended bytes. Path is derived from sessionId + spawnCwd
    // (Task 258).
    if (deps.jsonlTail) {
        const tail = deps.jsonlTail;
        deps.watcher.subscribe((event) => {
            if (event.source !== 'jsonl')
                return;
            if (event.kind !== 'create' && event.kind !== 'modify')
                return;
            const path = jsonlPathForSessionId(deps.spawnCwd, event.sessionId);
            if (!existsSync(path))
                return;
            tail.onJsonlModify(event.sessionId, path);
        });
    }
    // Task 278 — derive `effectivePermissionMode` from the per-session
    // auto-mode debug capture and persist it on the sidecar. We piggyback
    // on the JSONL CREATE/MODIFY events because the binary writes the
    // `[auto-mode] verifyAutoModeGateAccess` line at startup (before the
    // first JSONL flush) and on every shift-tab transition (which also
    // writes a `permission-mode` JSONL record). Each event re-reads the
    // small debug file and updates the sidecar only when the derived
    // value differs from the persisted one. Non-auto requested spawns
    // skip the file read entirely — pty-spawner does not capture a debug
    // log for those.
    if (deps.permissionModeTail) {
        const tail = deps.permissionModeTail;
        deps.watcher.subscribe((event) => {
            if (event.source !== 'jsonl')
                return;
            if (event.kind !== 'create' && event.kind !== 'modify')
                return;
            if (!event.row)
                return;
            if (event.row.permissionMode !== 'auto')
                return;
            const logPath = permissionModeLogPathForSessionId(deps.spawnCwd, event.sessionId);
            const derived = tail.deriveFromFile(event.sessionId, logPath, event.row.permissionMode);
            if (derived === null)
                return;
            if (derived === event.row.effectivePermissionMode)
                return;
            const sidecarPath = sidecarPathForSessionId(deps.spawnCwd, event.sessionId);
            const wrote = updateSidecar(sidecarPath, { effectivePermissionMode: derived }, deps.logger);
            if (wrote) {
                deps.watcher.refreshSidecar?.(event.sessionId);
                if (derived !== event.row.permissionMode) {
                    deps.logger(`[permission-mode-tail] downgrade-detected sessionId=${event.sessionId.slice(0, 8)} requested=${event.row.permissionMode} effective=${derived}`);
                }
            }
        });
    }
    /** Task 260 — /spawn and /rc-spawn need to return a wire payload
     *  immediately, but at that moment the watcher row may not yet
     *  include the sidecar fields (the JSONL CREATE event hasn't fired
     *  in fresh-spawn tests with synthetic watchers). Build the payload
     *  from the row if present, falling back to a synthetic shape that
     *  reads the sidecar directly. */
    function buildSpawnReturnPayload(sessionId, pid, startedAt, capturedUrl) {
        const row = deps.watcher.getBySessionId(sessionId);
        if (row && row.senderId !== null) {
            return toPayload(row);
        }
        // Read the sidecar directly — the spawn just wrote it.
        const sidecarPath = sidecarPathForSessionId(deps.spawnCwd, sessionId);
        const { sidecar } = readSidecar(sidecarPath, deps.logger);
        const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, sessionId);
        const hasJsonl = existsSync(jsonlPath);
        return {
            sessionId,
            pid,
            url: capturedUrl ?? sidecar?.url ?? null,
            startedAt: sidecar?.startedAt ?? startedAt,
            senderId: sidecar?.senderId ?? '',
            role: sidecar?.role ?? null,
            channel: sidecar?.channel ?? null,
            jsonlPath: hasJsonl ? jsonlPath : null,
            status: 'alive',
            archived: false,
            displayName: null,
            titleSource: null,
            lastMessageAt: null,
            turnCount: 0,
            capped: false,
            sizeBytes: hasJsonl ? statSync(jsonlPath).size : null,
            hidden: sidecar?.hidden ?? false,
            specialist: sidecar?.specialist ?? null,
            model: null,
            permissionMode: sidecar?.permissionMode ?? null,
            effectivePermissionMode: sidecar?.effectivePermissionMode ?? null,
        };
    }
    /** Task 260 — single payload builder. Both live and archived rows
     *  derive every wire field from the watcher row (which itself derived
     *  from the sidecar + JSONL + PID file). The split between
     *  `toLivePayload`/`toArchivedPayload` is gone; the row's `state`
     *  and `pid` decide `status` and the `archived` flag.
     *
     *  The `model` field continues to derive from the JSONL tail (last
     *  assistant turn) rather than the sidecar's spawn-request model —
     *  see [[learnings_writer_reader_charset_drift]] family: the running
     *  model is what the wire's `model` has historically meant, and the
     *  sidecar's `model` is kept as a self-diagnostic record. */
    function toPayload(row) {
        const isLiveState = row.state === 'live';
        const archivedJsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
        const topJsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
        const jsonlPath = isLiveState
            ? topJsonlPath
            : existsSync(archivedJsonlPath) ? archivedJsonlPath : topJsonlPath;
        const hasJsonl = existsSync(jsonlPath);
        let displayName = null;
        let titleSource = null;
        let lastMessageAt = null;
        let turnCount = 0;
        let capped = false;
        let sizeBytes = hasJsonl ? statSync(jsonlPath).size : null;
        let model = null;
        let enrichedStartedAt = null;
        if (hasJsonl) {
            const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
            if (enriched) {
                displayName = enriched.displayName;
                titleSource = enriched.titleSource;
                lastMessageAt = enriched.lastMessageAt;
                turnCount = enriched.turnCount;
                capped = enriched.capped;
                sizeBytes = enriched.sizeBytes;
                enrichedStartedAt = enriched.startedAt;
                model = enriched.model;
            }
        }
        else {
            const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
            if (userTitle) {
                displayName = userTitle;
                titleSource = 'user';
            }
        }
        // Task 624 — standing reconciliation. A LIVE role=public row whose title did
        // not resolve from the user-override tier means the /spawn userTitleStore.set
        // silently failed or was evicted. Fires on the next list read while the
        // session is live, so a missing write is caught without a live repro. Gated
        // to live rows so pre-Task-624 public sessions (no title, forward-only — the
        // designed pre-migration state, not a defect) never emit this signal.
        if (isLiveState && row.role === 'public' && titleSource !== 'user') {
            deps.logger(`[public-title] sessionId=${row.sessionId.slice(0, 8)} unexpected titleSource=${titleSource ?? 'null'}`);
        }
        // startedAt resolution order:
        // 1. JSONL enrichment (the most authoritative — claude's own record)
        // 2. Sidecar (/spawn time)
        // 3. Row mtime fallback (legacy archived rows without sidecar)
        const startedAt = enrichedStartedAt
            ?? row.startedAt
            ?? new Date(row.updatedAt || 0).toISOString();
        // Agent label: PID-file `agent` for live rows; cachedAgentSetting for
        // archived rows (JSONL head). Sidecar's `sidecarSpecialist` is the
        // spawn-time request — fall back to it when both above are null so
        // post-Task-260 rows surface their specialist even if the JSONL
        // doesn't include an agent-setting record.
        const specialist = row.agent ?? row.cachedAgentSetting ?? row.sidecarSpecialist;
        return {
            sessionId: row.sessionId,
            pid: row.pid,
            url: row.url,
            startedAt,
            senderId: row.senderId ?? '',
            role: row.role,
            channel: row.channel,
            jsonlPath: hasJsonl ? jsonlPath : null,
            status: isLiveState ? 'alive' : 'ended',
            archived: row.state === 'archived',
            displayName,
            titleSource,
            lastMessageAt,
            turnCount,
            capped,
            sizeBytes,
            hidden: row.hidden,
            specialist,
            model,
            permissionMode: row.permissionMode,
            effectivePermissionMode: row.effectivePermissionMode,
        };
    }
    app.post('/public-spawn', async (c) => {
        const start = Date.now();
        let body;
        try {
            body = await c.req.json();
        }
        catch {
            deps.logger(`spawn-request body-parse-failed`);
            timed(deps.logger, 'POST', '/public-spawn', 400, Date.now() - start);
            return c.json({ error: 'body-not-json' }, 400);
        }
        const senderId = typeof body.senderId === 'string' ? body.senderId : '';
        // Task 205 — admin operator's userId. Optional; falls through as
        // empty to the pty child env. Validated only for shape (non-empty
        // string); the cookie-auth path resolves the value, the
        // silent-compaction / recorder paths legitimately omit it.
        const userId = typeof body.userId === 'string' && body.userId.length > 0 && body.userId.length <= 128
            ? body.userId
            : undefined;
        const role = typeof body.role === 'string' ? body.role : '';
        const channel = typeof body.channel === 'string' ? body.channel : '';
        const hidden = body.hidden === true;
        const specialist = typeof body.specialist === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.specialist)
            ? body.specialist
            : undefined;
        const model = typeof body.model === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(body.model)
            ? body.model
            : undefined;
        const initialMessage = typeof body.initialMessage === 'string' && body.initialMessage.length > 0
            ? body.initialMessage
            : undefined;
        // Task 346 — agent slug arrives on every spawn from the bridge.
        // Admin spawns send 'admin'; public spawns send the per-account default
        // (chat.ts) or the per-group/per-account/top-level resolver result
        // (whatsapp). Shape gate matches account.ts's SLUG_PATTERN — keeping
        // it conservative refuses a body-controlled traversal segment from
        // reaching the agents-dir read below.
        const agentSlug = typeof body.agentSlug === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(body.agentSlug)
            ? body.agentSlug
            : undefined;
        // Task 382 — sessionId UUID of the active :AdminConversation (or
        // :PublicConversation). Shape-validated as a v4 UUID; anything else is
        // discarded so a body-controlled value cannot reach the env-stamp.
        const conversationNodeId = typeof body.conversationNodeId === 'string' && UUID_PATTERN.test(body.conversationNodeId)
            ? body.conversationNodeId
            : undefined;
        // Task 485 — per-visitor slice ringfence values. sliceToken is shape-gated
        // to UUID v4 (matches the AccessGrant.sliceToken generator); personId is
        // a Neo4j elementId so the length cap is conservative — anything else is
        // discarded so a body-controlled value cannot reach the child env.
        const sliceToken = typeof body.sliceToken === 'string' && UUID_PATTERN.test(body.sliceToken)
            ? body.sliceToken
            : undefined;
        const personId = typeof body.personId === 'string' && body.personId.length > 0 && body.personId.length <= 128
            ? body.personId
            : undefined;
        // Task 624 — maxy-composed public-session title. Validated by the same
        // validateUserTitle the /:sessionId/rename route uses; persisted only for
        // role=public after the sessionId is minted. An invalid/absent name leaves
        // the title to fall through to Claude Code's ai-title — surfaced as
        // title=missing on the spawn log line below, never a silent drop.
        const titleResult = validateUserTitle(body.name);
        const publicTitle = titleResult.ok ? titleResult.title : null;
        const aboutOwnerShape = body.aboutOwner == null ? 'absent' : (typeof body.aboutOwner === 'object' ? 'object' : typeof body.aboutOwner);
        const dormantPluginsShape = Array.isArray(body.dormantPlugins)
            ? `array(${body.dormantPlugins.length})`
            : body.dormantPlugins == null ? 'absent' : typeof body.dormantPlugins;
        const initialMessageBytes = typeof body.initialMessage === 'string' ? Buffer.byteLength(body.initialMessage, 'utf8') : 0;
        deps.logger(`spawn-request senderId=${senderId || 'missing'} userId=${userId ? userId.slice(0, 8) : 'absent'} role=${role || 'missing'} channel=${channel || 'missing'} permissionMode=${typeof body.permissionMode === 'string' ? body.permissionMode : 'default'} channels=${Array.isArray(body.channels) ? body.channels.length : 0} attachmentDir=${typeof body.attachmentDir === 'string' ? 'set' : 'absent'} aboutOwner=${aboutOwnerShape} dormantPlugins=${dormantPluginsShape} tunnelUrl=${typeof body.tunnelUrl === 'string' ? 'set' : (body.tunnelUrl === null ? 'null' : 'absent')} initialMessageBytes=${initialMessageBytes}`);
        if (!senderId || !ROLES.includes(role) || !CHANNELS.includes(channel)) {
            timed(deps.logger, 'POST', '/public-spawn', 400, Date.now() - start);
            return c.json({ error: 'invalid-arguments', detail: { senderId: !!senderId, role, channel } }, 400);
        }
        if (role === 'public') {
            const decision = deps.publicSpawnLimiter.admit(senderId, channel);
            if (!decision.admitted) {
                deps.logger(`spawn-rate-limited senderId=${senderId} channel=${channel} scope=${decision.scope} window=${decision.windowMs} cap=${decision.cap}`);
                timed(deps.logger, 'POST', '/public-spawn', 429, Date.now() - start);
                return c.json({
                    error: 'too-many-spawns',
                    detail: { scope: decision.scope, cap: decision.cap, windowMs: decision.windowMs, channel },
                }, 429);
            }
        }
        const channels = Array.isArray(body.channels)
            ? body.channels.filter((c) => typeof c === 'string' && c.length > 0)
            : undefined;
        const permissionMode = typeof body.permissionMode === 'string' && PERMISSION_MODES.includes(body.permissionMode)
            ? body.permissionMode
            : undefined;
        const attachmentDir = typeof body.attachmentDir === 'string' && body.attachmentDir.startsWith('/')
            ? body.attachmentDir
            : undefined;
        const aboutOwner = parseAboutOwner(body.aboutOwner);
        const dormantPlugins = parseDormantPlugins(body.dormantPlugins);
        const activePlugins = parseActivePlugins(body.activePlugins);
        const specialistDomains = parseSpecialistDomains(body.specialistDomains);
        const tunnelUrlOverride = typeof body.tunnelUrl === 'string'
            ? body.tunnelUrl
            : body.tunnelUrl === null
                ? null
                : undefined;
        const result = await spawnClaudeSession({
            ...deps,
            watcher: deps.watcher,
            onSessionReady: (tracker) => {
                attachPublicAudit(deps, tracker.sessionId, role);
                attachPublicExitNotify(tracker.sessionId, role, deps.logger);
                attachSpecialistEndTurnAutoArchive(deps, tracker.sessionId, tracker.specialist);
            },
            onUrlCaptured: notifyUrlCaptured,
            tunnelUrlOverride,
        }, {
            senderId,
            userId,
            role: role,
            channel: channel,
            channels,
            permissionMode,
            attachmentDir,
            aboutOwner,
            dormantPlugins,
            activePlugins,
            specialistDomains,
            hidden,
            specialist,
            model,
            initialMessage,
            agentSlug,
            conversationNodeId,
            sliceToken,
            personId,
        });
        if (!result.ok) {
            if ('rejected' in result) {
                timed(deps.logger, 'POST', '/public-spawn', 503, Date.now() - start);
                if (result.rejected.kind === 'specialist-cap-reached') {
                    return c.json({ error: 'specialist-cap-reached', currentCount: result.rejected.currentCount, cap: result.rejected.cap }, 503);
                }
                if (result.rejected.kind === 'low-memory') {
                    return c.json({ error: 'low-memory', memAvailableMb: result.rejected.memAvailableMb, thresholdMb: result.rejected.thresholdMb }, 503);
                }
                return c.json({ error: 'operator-slots-reserved', total: result.rejected.total, reserve: result.rejected.reserve, totalCap: result.rejected.totalCap }, 503);
            }
            timed(deps.logger, 'POST', '/public-spawn', 500, Date.now() - start);
            // Task 576 — surface the auth-refresh-failed reason when the
            // creds file says `dead` or `expired`. Without this the operator
            // sees a generic `pid-file-timeout` for what is actually an
            // OAuth-token failure and cannot recover via "Reconnect Claude".
            const authSnap = snapshotAuthForFailureReason(credentialsPathForConfigDir(deps.claudeConfigDir));
            const reason = authSnap === 'dead' || authSnap === 'expired' ? 'auth-refresh-failed' : result.reason;
            if (reason === 'auth-refresh-failed') {
                deps.logger(`[spawn-failed] reason=auth-refresh-failed authStatus=${authSnap} originalReason=${result.reason}`);
            }
            return c.json({ error: 'spawn-failed', reason, stderrTail: result.stderrTail, authStatus: authSnap }, 500);
        }
        timed(deps.logger, 'POST', '/public-spawn', 200, Date.now() - start);
        // Task 624 — persist the maxy title into the user-override tier so it wins
        // over Claude Code's ai-title in both the manager list and the UI sidebar
        // (sidebar-sessions.ts reads the same session-titles.json). role=public only;
        // admin/whatsapp titling is unchanged. The spawn line always carries a title
        // field so a dropped name is visible without reproducing.
        if (role === 'public') {
            if (publicTitle) {
                deps.userTitleStore.set(result.session.sessionId, publicTitle);
                deps.logger(`[spawn] role=public senderId=${senderId} sessionId=${result.session.sessionId.slice(0, 8)} title="${publicTitle}"`);
            }
            else {
                deps.logger(`[spawn] role=public senderId=${senderId} sessionId=${result.session.sessionId.slice(0, 8)} title=missing`);
            }
        }
        const payload = buildSpawnReturnPayload(result.session.sessionId, result.session.pid, result.session.startedAt, result.session.url);
        return c.json(payload, 200);
    });
    // SSE feed of row deltas. One channel per client; manager fans out from
    // a single watcher subscription. On connect: replay current rows as
    // `row-created`, then stream subsequent watcher events as
    // `row-<created|updated|archived|removed>`.
    //
    // Payload shape per event (`data: <json>`):
    //   { sessionId, live, status, agentLabel, cwd, firstPrompt, titleSource,
    //     updatedAt, url, archived }
    // Task 141: `sessionId` carries whichever phase is current (bridge
    // suffix preferred, JSONL basename fallback) — same single-id contract
    // as `/meta`.
    //
    // `live` mirrors the watcher's `state === 'live'`. `status` is the
    // watcher's `idle | busy | gone`. `agentLabel` is the resolution
    // `agent ?? cachedAgentSetting`; the UI maps null → "admin",
    // non-null → the specialist name. `firstPrompt` is the resolved
    // displayName (tail.aiTitle verbatim, else null — Task 146) so the row
    // title is computed once on the server, not by every browser tab.
    // Task 189: `url` is `StoredSession.url` for a live row in the in-memory
    // store, or null otherwise. Stays null forever on headless spawns (they
    // never get `--remote-control`) and on archived rows (the StoredSession
    // is removed on PTY exit). The client uses null to hide the
    // Open-in-new-tab arrow; the SSE re-emit below pushes a fresh frame
    // when url-capture lands so the arrow appears in step.
    app.get('/events', (c) => {
        const start = Date.now();
        const clientIp = c.req.header('x-forwarded-for') ?? 'loopback';
        const replayRows = deps.watcher.all();
        deps.logger(`[admin-events] client-connected ip=${clientIp} seeded-rows=${replayRows.length}`);
        function rowToPayload(row) {
            const agentLabel = row.agent ?? row.cachedAgentSetting ?? null;
            let firstPrompt = null;
            let titleSource = null;
            const jsonlPath = jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
            if (existsSync(jsonlPath)) {
                const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, LIST_TAIL_BYTES, deps.logger, deps.userTitleStore);
                if (enriched) {
                    firstPrompt = enriched.displayName;
                    titleSource = enriched.titleSource;
                }
            }
            else {
                // Pre-jsonl / post-purge: only the user-title store can answer.
                const userTitle = deps.userTitleStore.get(row.sessionId)?.title;
                if (userTitle) {
                    firstPrompt = userTitle;
                    titleSource = 'user';
                }
            }
            return {
                sessionId: row.sessionId,
                live: row.state === 'live',
                status: row.status,
                agentLabel,
                cwd: row.cwd,
                firstPrompt,
                titleSource,
                updatedAt: row.updatedAt,
                // Task 260 — url is on the row from the sidecar; url-capture
                // mutates the sidecar in place and the watcher re-reads on the
                // next event. Earlier this dipped into deps.store for the live-
                // row case and returned null for everything else.
                url: row.url,
                // Task 260 — archive state is on the row directly (the watcher
                // enumerates both top and archive subdirs and folds a top→archive
                // move into a single state change). Earlier this was an extra
                // existsSync against the archive path because archived rows
                // weren't first-class members of the index.
                archived: row.state === 'archived',
            };
        }
        timed(deps.logger, 'GET', '/events', 200, Date.now() - start);
        return stream(c, async (out) => {
            // SSE response headers — Hono's stream() helper sets text/event-stream
            // automatically, but we explicitly disable proxy buffering so the
            // first replay batch arrives before any keep-alive.
            c.header('Content-Type', 'text/event-stream');
            c.header('Cache-Control', 'no-cache, no-transform');
            c.header('X-Accel-Buffering', 'no');
            c.header('Connection', 'keep-alive');
            const connectStart = Date.now();
            let active = true;
            let subscribersCount = 0;
            // Task 272 — paired dedup for the live→archived demote sequence.
            // The watcher fires `delete pid` immediately followed by `modify jsonl`
            // for the same sessionId; the pid-delete branch emits row-archived and
            // we must swallow the trailing modify so the client sees one
            // transition. Pre-Task-272 the dedup matched on row state instead, so
            // every modify-jsonl on a non-live row was dropped — including
            // archive↔top renames on stash composites and sidecar refreshes on
            // ended rows. Window is comfortably wider than the watcher's per-path
            // debounce (~30 ms in production) so back-to-back events on the same
            // sessionId pair correctly without dropping unrelated mutations
            // arriving later in the connection.
            const pendingDedupAt = new Map();
            const DEDUP_WINDOW_MS = 100;
            // Replay phase. Emit a `row-created` for every row in the index so
            // a freshly-connected tab catches up to the manager's view before
            // any subsequent delta arrives.
            for (const row of replayRows) {
                if (!active)
                    break;
                const payload = rowToPayload(row);
                await out.write(`event: row-created\ndata: ${JSON.stringify(payload)}\n\n`);
            }
            // Delta phase. Subscribe to the watcher and map watcher events to
            // the four SSE event kinds. Mapping:
            //   create  → row-created
            //   modify  → row-updated   (state may have flipped — see archive)
            //   delete  → three real cases:
            //     pid    + row present  → row-archived (PID gone, JSONL survives)
            //     pid    + row null     → row-removed  (PID gone, no JSONL ever existed)
            //     jsonl  + row null     → row-removed  (JSONL deleted, row unindexed)
            const unsubscribe = deps.watcher.subscribe((event) => {
                if (!active)
                    return;
                // The watcher emits a synthetic `kind=modify source=jsonl` pair
                // immediately after `kind=delete source=pid` when a row demotes
                // from live → archived. Treat that pair as one `row-archived`
                // event so the UI sees a single transition rather than a delete
                // followed by an unrelated update.
                if (event.kind === 'delete' && event.source === 'pid' && event.row) {
                    const payload = rowToPayload(event.row);
                    subscribersCount += 1;
                    // Task 272 — arm the paired dedup BEFORE the synchronous emit so
                    // the trailing modify-jsonl on the same sessionId (delivered by
                    // the same subscriber callback, off the same debounce flush) hits
                    // an entry that has not yet expired.
                    pendingDedupAt.set(event.sessionId, Date.now());
                    deps.logger(`[admin-events] emit event=row-archived sessionId=${event.sessionId} subscribers=1`);
                    void out.write(`event: row-archived\ndata: ${JSON.stringify(payload)}\n\n`);
                    return;
                }
                // Recorder case: PID file disappeared before any JSONL was ever
                // written. Row is unindexed at the manager. Without this branch
                // the row falls through `if (!event.row) return` below and the
                // client sidebar keeps showing a session that no longer exists.
                if (event.kind === 'delete' && event.source === 'pid' && !event.row) {
                    subscribersCount += 1;
                    deps.logger(`[admin-events] emit event=row-removed sessionId=${event.sessionId} subscribers=1`);
                    void out.write(`event: row-removed\ndata: ${JSON.stringify({ sessionId: event.sessionId })}\n\n`);
                    return;
                }
                if (event.kind === 'delete' && event.source === 'jsonl' && !event.row) {
                    subscribersCount += 1;
                    deps.logger(`[admin-events] emit event=row-removed sessionId=${event.sessionId} subscribers=1`);
                    void out.write(`event: row-removed\ndata: ${JSON.stringify({ sessionId: event.sessionId })}\n\n`);
                    return;
                }
                // Task 272 — paired dedup for the live→archived demote sequence.
                // Only swallow the modify-jsonl that immediately follows a
                // delete-pid for the SAME sessionId, within the debounce window.
                // The pre-Task-272 predicate (row no longer live AND pidFilePath
                // null) was too broad: it caught archive↔top renames on stash
                // composites and sidecar refreshes on ended rows, leaving the
                // sidebar stuck on the pre-rename state.
                if (event.kind === 'modify' && event.source === 'jsonl' && event.row) {
                    const dedupTs = pendingDedupAt.get(event.sessionId);
                    if (dedupTs !== undefined && Date.now() - dedupTs <= DEDUP_WINDOW_MS) {
                        pendingDedupAt.delete(event.sessionId);
                        deps.logger(`[admin-events] dedup-paired sessionId=${event.sessionId} reason=pid-delete-coalesce`);
                        return;
                    }
                    // Drop any stale pending entry so it cannot dedup an unrelated
                    // future modify on the same sessionId.
                    if (dedupTs !== undefined)
                        pendingDedupAt.delete(event.sessionId);
                }
                if (!event.row)
                    return;
                const sseEvent = event.kind === 'create' ? 'row-created' : 'row-updated';
                const payload = rowToPayload(event.row);
                subscribersCount += 1;
                // Task 272 — the previously-swallowed case (state !== 'live' AND
                // pidFilePath === null with no paired pid-delete in the window)
                // now emits. Tag it with reason=jsonl-modify-non-live so the
                // regression is visible in a single grep against server logs.
                const previouslySwallowed = event.kind === 'modify'
                    && event.source === 'jsonl'
                    && event.row.state !== 'live'
                    && event.row.pidFilePath === null;
                const reasonSuffix = previouslySwallowed ? ' reason=jsonl-modify-non-live' : '';
                deps.logger(`[admin-events] emit event=${sseEvent} sessionId=${event.sessionId} subscribers=1${reasonSuffix}`);
                void out.write(`event: ${sseEvent}\ndata: ${JSON.stringify(payload)}\n\n`);
            });
            // Task 189 / 260 — when `url-capture` fires for a session, the
            // pty-spawner calls `notifyUrlCaptured(sessionId)` after writing
            // the URL to the sidecar. Re-emit the row so the client's hidden
            // Open-in-new-tab arrow appears in step. The row lookup goes
            // through the watcher (canonical row index); rowToPayload then
            // reads `url` from the row (which the watcher just refreshed
            // from the sidecar).
            const urlListener = (sessionId) => {
                if (!active)
                    return;
                const row = deps.watcher.getBySessionId(sessionId);
                if (!row)
                    return;
                const payload = rowToPayload(row);
                subscribersCount += 1;
                deps.logger(`[admin-events] emit event=row-updated sessionId=${sessionId} subscribers=1`);
                void out.write(`event: row-updated\ndata: ${JSON.stringify(payload)}\n\n`);
            };
            urlUpdateListeners.add(urlListener);
            // Task 253 — rename re-emit. The POST /:sessionId/rename handler
            // mutates the user-title store and then calls notifyRename(); each
            // /events handler re-emits the row so every browser tab sees the new
            // label without re-fetching the sidebar route. The row lookup goes through the
            // watcher (canonical row index); rowToPayload then re-reads the user
            // title from `deps.userTitleStore`, picking up the freshly-set value.
            const renameListener = (sessionId) => {
                if (!active)
                    return;
                const row = deps.watcher.getBySessionId(sessionId);
                if (!row)
                    return;
                const payload = rowToPayload(row);
                subscribersCount += 1;
                deps.logger(`[admin-events] emit event=row-updated sessionId=${sessionId} reason=rename subscribers=1`);
                void out.write(`event: row-updated\ndata: ${JSON.stringify(payload)}\n\n`);
            };
            renameListeners.add(renameListener);
            // Keep-alive comment every 25 s. Proxies aggressively close idle
            // text/event-stream connections at 30–60 s; a comment line is
            // ignored by EventSource consumers but resets the proxy clock.
            const keepAlive = setInterval(() => {
                if (!active)
                    return;
                void out.write(`: keep-alive ${Date.now()}\n\n`);
            }, 25_000);
            out.onAbort(() => {
                active = false;
                clearInterval(keepAlive);
                unsubscribe();
                urlUpdateListeners.delete(urlListener);
                renameListeners.delete(renameListener);
                const durationMs = Date.now() - connectStart;
                deps.logger(`[admin-events] client-disconnected ip=${clientIp} duration-ms=${durationMs} emits=${subscribersCount}`);
            });
            // Park the stream open until the client aborts. Without this the
            // handler returns and Hono closes the response.
            while (active) {
                await new Promise((resolve) => setTimeout(resolve, 1_000));
            }
        });
    });
    app.get('/:sessionId/meta', (c) => {
        const start = Date.now();
        const id = c.req.param('sessionId');
        if (!SESSION_ID_PATTERN.test(id)) {
            timed(deps.logger, 'GET', `/${id}/meta`, 400, Date.now() - start);
            return c.json({ error: 'invalid-session-id' }, 400);
        }
        const row = resolveRow(deps.watcher, id);
        if (!row) {
            timed(deps.logger, 'GET', `/${id}/meta`, 404, Date.now() - start);
            return c.json({ error: 'session-not-found' }, 404);
        }
        // Task 260 — single row source. /meta uses the deeper enumerator
        // limit (jsonlReadMaxBytes) for richer turn/sizeBytes data than
        // the /events SSE rowToPayload's 64 KB tail; otherwise the payload
        // shape matches what /events emits.
        let payload = toPayload(row);
        const candidatePath = row.state === 'archived'
            ? jsonlPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true })
            : jsonlPathForSessionId(deps.spawnCwd, row.sessionId);
        if (existsSync(candidatePath)) {
            const enriched = readJsonlSession(deps.spawnCwd, row.sessionId, deps.jsonlReadMaxBytes, deps.logger, deps.userTitleStore);
            if (enriched) {
                payload = {
                    ...payload,
                    lastMessageAt: enriched.lastMessageAt,
                    turnCount: enriched.turnCount,
                    capped: enriched.capped,
                    sizeBytes: enriched.sizeBytes,
                    model: enriched.model,
                    displayName: enriched.displayName,
                    titleSource: enriched.titleSource,
                };
            }
        }
        deps.logger(`meta sessionId=${row.sessionId.slice(0, 8)} fields=${Object.keys(payload).length} ms=${Date.now() - start}`);
        timed(deps.logger, 'GET', `/${id}/meta`, 200, Date.now() - start);
        return c.json(payload, 200);
    });
    app.post('/:sessionId/archive', async (c) => {
        const start = Date.now();
        const id = c.req.param('sessionId');
        if (!SESSION_ID_PATTERN.test(id)) {
            timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
            return c.json({ error: 'invalid-session-id' }, 400);
        }
        let body;
        try {
            body = await c.req.json();
        }
        catch {
            timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
            return c.json({ error: 'body-not-json' }, 400);
        }
        const mode = body.mode;
        if (mode !== 'archive' && mode !== 'unarchive') {
            timed(deps.logger, 'POST', `/${id}/archive`, 400, Date.now() - start);
            return c.json({ error: 'invalid-mode', detail: { expected: ['archive', 'unarchive'] } }, 400);
        }
        const row = resolveRow(deps.watcher, id);
        if (!row) {
            timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
            return c.json({ error: 'session-not-found' }, 404);
        }
        const slug = projectSlugForCwd(deps.spawnCwd);
        const slugDir = join(claudeStateRoot(), 'projects', slug);
        const archiveDir = join(slugDir, 'archive');
        const fileName = `${row.sessionId}.jsonl`;
        // Task 260 — refuse archive whenever an alive PTY exists for this
        // row in the manager's tracker map. Earlier this walked every
        // store entry to find one whose derived path equaled the candidate
        // — that loop was redundant because the JSONL basename IS the
        // sessionId, so the only relevant check is "is this sessionId
        // tracker alive?". `isLive` answers in O(1).
        if (mode === 'archive' && isLive(row.sessionId)) {
            deps.logger(`archive-refused sessionId=${row.sessionId.slice(0, 8)} reason=pty-alive`);
            timed(deps.logger, 'POST', `/${id}/archive`, 409, Date.now() - start);
            return c.json({ error: 'pty-still-alive', detail: 'end the session before archiving' }, 409);
        }
        const fromJsonl = mode === 'archive' ? join(slugDir, fileName) : join(archiveDir, fileName);
        const toJsonl = mode === 'archive' ? join(archiveDir, fileName) : join(slugDir, fileName);
        if (!existsSync(fromJsonl)) {
            timed(deps.logger, 'POST', `/${id}/archive`, 404, Date.now() - start);
            return c.json({ error: 'session-not-found' }, 404);
        }
        try {
            if (mode === 'archive')
                mkdirSync(archiveDir, { recursive: true });
            renameSync(fromJsonl, toJsonl);
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            deps.logger(`archive-failed sessionId=${row.sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
            timed(deps.logger, 'POST', `/${id}/archive`, 500, Date.now() - start);
            return c.json({ error: 'archive-failed', detail: msg }, 500);
        }
        // Task 260 — the sidecar travels with the JSONL. Best-effort: an
        // orphan sidecar at the source path is self-healing (the next
        // bootSeed indexes the row with null fields and the operator
        // re-archives), but the orphan is itself a regression signal the
        // log line surfaces.
        let sidecarMoved = false;
        try {
            const fromSidecar = mode === 'archive'
                ? sidecarPathForSessionId(deps.spawnCwd, row.sessionId)
                : sidecarPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true });
            const toSidecar = mode === 'archive'
                ? sidecarPathForSessionId(deps.spawnCwd, row.sessionId, { archived: true })
                : sidecarPathForSessionId(deps.spawnCwd, row.sessionId);
            if (existsSync(fromSidecar)) {
                renameSync(fromSidecar, toSidecar);
                sidecarMoved = true;
            }
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            deps.logger(`archive-sidecar-move-failed sessionId=${row.sessionId.slice(0, 8)} mode=${mode} message=${msg}`);
        }
        deps.logger(`[archive] sessionId=${row.sessionId.slice(0, 8)} mode=${mode} from=${basename(fromJsonl)} to=${join(mode === 'archive' ? 'archive' : '.', basename(toJsonl))} sidecar-moved=${sidecarMoved}`);
        timed(deps.logger, 'POST', `/${id}/archive`, 200, Date.now() - start);
        return c.json({ ok: true, mode }, 200);
    });
    // Task 253 — POST /:sessionId/rename { title }
    //   - 200 + body on accept (carries the persisted title)
    //   - 400 on validator reject (empty / too-long / control-chars)
    //   - 404 when no row matches any of the four ids
    // After accepting, the user-title store persists and notifyRename()
    // fans a row-updated frame to every connected /events client so the
    // sidebar refreshes without a sidebar-route re-fetch.
    app.post('/:sessionId/rename', async (c) => {
        const start = Date.now();
        const id = c.req.param('sessionId');
        if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
            timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
            return c.json({ error: 'invalid-session-id' }, 400);
        }
        let body;
        try {
            body = await c.req.json();
        }
        catch {
            timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
            return c.json({ error: 'invalid-json-body' }, 400);
        }
        const row = resolveRow(deps.watcher, id);
        // Task 260 — rename resolves via the watcher row index. The live
        // tracker is no longer a secondary lookup surface — the watcher's
        // PID-file event indexes every live session, so a sessionId that
        // names no row also names no live PTY.
        const sessionId = row?.sessionId;
        if (!sessionId) {
            timed(deps.logger, 'POST', `/${id}/rename`, 404, Date.now() - start);
            return c.json({ error: 'session-not-found' }, 404);
        }
        const result = validateUserTitle(body.title);
        if (!result.ok) {
            deps.logger(`rename-rejected sessionId=${sessionId.slice(0, 8)} reason=${result.reason}`);
            timed(deps.logger, 'POST', `/${id}/rename`, 400, Date.now() - start);
            return c.json({ error: 'invalid-title', reason: result.reason }, 400);
        }
        const entry = deps.userTitleStore.set(sessionId, result.title);
        // Title content is never logged — operator names may contain customer
        // data. Byte count is enough for forensic recovery of bulk-rename surges.
        const titleBytes = Buffer.byteLength(entry.title, 'utf-8');
        deps.logger(`rename sessionId=${sessionId.slice(0, 8)} title-bytes=${titleBytes} source=manager-route`);
        notifyRename(sessionId);
        timed(deps.logger, 'POST', `/${id}/rename`, 200, Date.now() - start);
        return c.json({ ok: true, sessionId, titleSource: 'user', renamedAt: entry.renamedAt }, 200);
    });
    // SIGTERM-only stop. Idempotent on already-dead PTYs. JSONL untouched.
    app.post('/:sessionId/stop', async (c) => {
        const start = Date.now();
        const id = c.req.param('sessionId');
        if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
            timed(deps.logger, 'POST', `/${id}/stop`, 400, Date.now() - start);
            return c.json({ error: 'invalid-session-id' }, 400);
        }
        const row = resolveRow(deps.watcher, id);
        if (!row && !isLive(id)) {
            timed(deps.logger, 'POST', `/${id}/stop`, 404, Date.now() - start);
            return c.json({ error: 'session-not-found' }, 404);
        }
        const sessionId = row?.sessionId ?? id;
        // Task 455 — snapshot the live pid and prior-exited-count BEFORE
        // stopSession runs (the call mutates module state). When prior history
        // exists, emit a single observability line naming the pid the lookup
        // resolved to; the existing `[stop-session] systemd-scope-stop` line
        // still fires from inside stopSession. The new line is gated on count
        // > 0 so the happy-path log shape stays unchanged.
        const liveBeforeStop = livePidForSession(sessionId);
        const priorExitedCount = priorExitedCountForSession(sessionId);
        if (priorExitedCount > 0) {
            deps.logger(`[stop-session] resolved sessionId=${sessionId.slice(0, 8)} pid=${liveBeforeStop ?? 'none'} prior-exited-count=${priorExitedCount}`);
        }
        const outcome = await stopSession({
            killGraceMs: deps.killGraceMs,
            logger: deps.logger,
            runSystemctl: deps.runSystemctl,
            // Task 427 — when the row was reattached after a manager restart,
            // `livePtys` has no entry but `row.scopeUnitToken` is set; pass it
            // so stopSession can take the row-scope fallback path.
            rowScopeUnitToken: row?.scopeUnitToken ?? null,
            // Task 451 — paired with rowScopeUnitToken so the row-scope branch
            // can post-flight verify the kill via kill(pid, 0).
            rowPid: row?.pid ?? null,
        }, sessionId);
        // Task 427 — one unified log line per /stop attempt that names the
        // path taken. Detail lines from each branch keep their own per-branch
        // formatting (Task 250 contract); this is the single audit line the
        // operator and tests can grep against to confirm which branch ran.
        deps.logger(`[stop-session] path=${outcome.path} outcome=${outcome.scopeOutcome} sessionId=${sessionId.slice(0, 8)} unit=${row?.scopeUnitToken ?? 'none'} pid=${row?.pid ?? 'unknown'}`);
        // Task 250 — surface scope-stop failures as 500 so the operator sees
        // the End-click fail when systemd cannot stop the unit. The liveptys
        // happy path keeps its original 204 response (Task 250 contract).
        if (outcome.scopeOutcome === 'error') {
            timed(deps.logger, 'POST', `/${sessionId}/stop`, 500, Date.now() - start);
            return c.json({
                error: 'scope-stop-failed',
                path: outcome.path,
                exitCode: outcome.exitCode,
                detail: 'systemctl --user stop returned a non-zero exit code for the session\'s scope unit',
            }, 500);
        }
        // Task 451 — `failed-pid-survives` means the manager escalated SIGTERM
        // then SIGKILL and the pid is STILL alive. Surface as 500 so the End
        // click does not falsely succeed; the operator needs to know termination
        // did not happen.
        if (outcome.scopeOutcome === 'failed-pid-survives') {
            timed(deps.logger, 'POST', `/${sessionId}/stop`, 500, Date.now() - start);
            return c.json({
                error: 'pid-survived-sigkill',
                path: outcome.path,
                exitCode: outcome.exitCode,
                detail: 'systemctl stop returned 0 but the claude pid resisted SIGTERM and SIGKILL; manual intervention required',
            }, 500);
        }
        // Task 427 — the row-scope branch returns a 200 JSON body so the UI
        // can distinguish "the manager actually stopped a reattached PTY" from
        // the original single-process happy path's 204. The boot-seed reattach
        // case is genuine work — systemctl stopped a surviving scope unit —
        // and the operator's log surface should reflect that distinction.
        //
        // Task 455 — the `no-store-entry` branch falls through to 204. There
        // was no live pid anywhere (no tracker, no row scope token), so the
        // request is idempotent "already stopped"; nothing was signalled. The
        // UI logger sees the same 204 it does for the live happy path, which
        // is correct — both cases describe "nothing live remains".
        if (outcome.path === 'row-scope') {
            timed(deps.logger, 'POST', `/${sessionId}/stop`, 200, Date.now() - start);
            return c.json({ ok: true, path: outcome.path, outcome: outcome.scopeOutcome }, 200);
        }
        timed(deps.logger, 'POST', `/${sessionId}/stop`, 204, Date.now() - start);
        return c.body(null, 204);
    });
    // Hard-remove transcript. 409 if PTY is still alive (stop first).
    app.delete('/:sessionId', async (c) => {
        const start = Date.now();
        const id = c.req.param('sessionId');
        if (!SESSION_ID_PATTERN.test(id) && !/^\d+$/.test(id)) {
            timed(deps.logger, 'DELETE', `/${id}`, 400, Date.now() - start);
            return c.json({ error: 'invalid-session-id' }, 400);
        }
        const row = resolveRow(deps.watcher, id);
        const sessionId = row?.sessionId ?? id;
        if (isLive(sessionId)) {
            deps.logger(`delete-rejected sessionId=${sessionId.slice(0, 8)} reason=pty-still-alive`);
            timed(deps.logger, 'DELETE', `/${id}`, 409, Date.now() - start);
            return c.json({ error: 'pty-still-alive', detail: 'stop the session before deleting' }, 409);
        }
        // Task 260 — purge from BOTH locations: a row may have its JSONL
        // at the top level (live or just-ended) or under archive/ (archived).
        const topJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
        const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
        const topSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId);
        const archivedSidecar = sidecarPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
        const hasTop = existsSync(topJsonl);
        const hasArchived = existsSync(archivedJsonl);
        if (!row || (!hasTop && !hasArchived)) {
            timed(deps.logger, 'DELETE', `/${id}`, 404, Date.now() - start);
            return c.json({ error: 'session-not-found' }, 404);
        }
        const subdir = join(claudeStateRoot(), 'projects', projectSlugForCwd(deps.spawnCwd), sessionId);
        let bytes = 0;
        try {
            bytes = hasTop ? statSync(topJsonl).size : hasArchived ? statSync(archivedJsonl).size : 0;
        }
        catch {
            // size is informational
        }
        try {
            if (hasTop)
                rmSync(topJsonl);
            if (hasArchived)
                rmSync(archivedJsonl);
            if (existsSync(topSidecar))
                rmSync(topSidecar);
            if (existsSync(archivedSidecar))
                rmSync(archivedSidecar);
            if (existsSync(subdir))
                rmSync(subdir, { recursive: true, force: true });
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            deps.logger(`delete-failed sessionId=${sessionId.slice(0, 8)} message=${msg}`);
            timed(deps.logger, 'DELETE', `/${id}`, 500, Date.now() - start);
            return c.json({ error: 'delete-failed', detail: msg }, 500);
        }
        // Task 253 — drop the user-title entry for the purged session so the
        // session-titles.json doesn't accrete orphans. Best-effort; the store
        // logs its own failure and reads still serve from the in-memory map
        // until next boot.
        deps.userTitleStore.remove(sessionId);
        deps.logger(`purge sessionId=${sessionId.slice(0, 8)} bytes=${bytes}`);
        timed(deps.logger, 'DELETE', `/${id}`, 204, Date.now() - start);
        return c.body(null, 204);
    });
    app.post('/:sessionId/input', async (c) => {
        const start = Date.now();
        const id = c.req.param('sessionId');
        const row = resolveRow(deps.watcher, id);
        const sessionId = row?.sessionId ?? id;
        let body;
        try {
            body = await c.req.json();
        }
        catch {
            timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
            return c.json({ error: 'body-not-json' }, 400);
        }
        if (typeof body.text !== 'string') {
            timed(deps.logger, 'POST', `/${id}/input`, 400, Date.now() - start);
            return c.json({ error: 'text-required' }, 400);
        }
        const channel = row?.channel ?? null;
        const result = writeInputToPty(sessionId, body.text, deps.logger, channel);
        if (!result.ok) {
            if (result.error === 'not-found') {
                timed(deps.logger, 'POST', `/${id}/input`, 404, Date.now() - start);
                return c.json({ error: 'session-not-found' }, 404);
            }
            if (result.error === 'exited') {
                timed(deps.logger, 'POST', `/${id}/input`, 410, Date.now() - start);
                return c.json({ error: 'session-exited' }, 410);
            }
            timed(deps.logger, 'POST', `/${id}/input`, 500, Date.now() - start);
            return c.json({ error: 'pty-write-failed', detail: result.detail }, 500);
        }
        timed(deps.logger, 'POST', `/${id}/input`, 200, Date.now() - start);
        return c.json({ ok: true, bytes: result.bytes }, 200);
    });
    app.get('/:sessionId/log', (c) => {
        const start = Date.now();
        const id = c.req.param('sessionId');
        const row = resolveRow(deps.watcher, id);
        const sessionId = row?.sessionId ?? id;
        const live = isLive(sessionId);
        // Task 260 — /log reads from whichever location holds the JSONL
        // right now: top level for live + recently-ended rows, archive
        // subdir for archived rows. 404 when there's no row AND no live
        // PTY for this id; 202 pending when a live PTY hasn't flushed
        // its first turn yet (no JSONL on disk in either location).
        if (!live && !row) {
            timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
            return c.json({ error: 'session-not-found' }, 404);
        }
        const topJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId);
        const archivedJsonl = jsonlPathForSessionId(deps.spawnCwd, sessionId, { archived: true });
        const jsonlPath = existsSync(topJsonl) ? topJsonl : archivedJsonl;
        if (live && !existsSync(topJsonl) && !existsSync(archivedJsonl)) {
            timed(deps.logger, 'GET', `/${id}/log`, 202, Date.now() - start);
            return c.json({ jsonlPath: null, exists: false, sizeBytes: 0, pending: true }, 202);
        }
        if (!existsSync(jsonlPath)) {
            timed(deps.logger, 'GET', `/${id}/log`, 404, Date.now() - start);
            return c.json({ error: 'session-not-found' }, 404);
        }
        const download = c.req.query('download') === '1';
        const follow = c.req.query('follow') === '1';
        // Task 631 — `&boundary=1` (channel resume-suppressing follower only) makes
        // the follow stream emit a single head→tail boundary sentinel line after
        // the existing JSONL, before tailing, so the follower can discard pre-attach
        // replay. The in-browser viewer omits it and streams from offset 0 as before.
        const boundary = c.req.query('boundary') === '1';
        // Task 197 — `?download=1` short-circuits before the JSON-metadata branch
        // so the browser receives the raw NDJSON bytes with attachment-disposition
        // headers. The non-follow JSON branch's "file gone, sizeBytes:0" answer is
        // correct for metadata but would silently save an empty file to the
        // operator's Downloads folder; refuse with a loud 404 instead.
        if (download) {
            if (!existsSync(jsonlPath)) {
                timed(deps.logger, 'GET', `/${id}/log?download=1`, 404, Date.now() - start);
                return c.json({ error: 'jsonl-missing-on-disk' }, 404);
            }
            const sizeBytes = statSync(jsonlPath).size;
            // RFC 6266 quoted-string. The sessionId arrives URL-decoded from the
            // route param; replace any character outside the safe charset so a
            // quote or newline cannot terminate the header early.
            const safeName = sessionId.replace(/[^A-Za-z0-9._-]/g, '_');
            c.header('Content-Type', 'application/x-ndjson');
            c.header('Content-Disposition', `attachment; filename="${safeName}.jsonl"`);
            c.header('Cache-Control', 'no-store');
            timed(deps.logger, 'GET', `/${id}/log?download=1`, 200, Date.now() - start);
            return stream(c, async (out) => {
                const reader = createReadStream(jsonlPath);
                for await (const chunk of reader) {
                    await out.write(chunk);
                }
                deps.logger(`log-download sessionId=${sessionId} bytes=${sizeBytes} ms=${Date.now() - start}`);
            });
        }
        if (!follow) {
            const exists = existsSync(jsonlPath);
            const sizeBytes = exists ? statSync(jsonlPath).size : 0;
            timed(deps.logger, 'GET', `/${id}/log`, 200, Date.now() - start);
            return c.json({ jsonlPath, exists, sizeBytes }, 200);
        }
        timed(deps.logger, 'GET', `/${id}/log?follow=1`, 200, Date.now() - start);
        // Task 198 — open/close lifecycle lines bracket every follow stream so
        // the in-browser viewer's session is observable on the manager side.
        // `linesStreamed` counts `\n` bytes written across initial-read + tail, so
        // the value matches the operator's `wc -l` mental model. `closeReason`
        // defaults to `end` (unreachable today; reserved for a future natural-EOF
        // path such as file-deleted-mid-stream) and flips to `aborted` whenever
        // `out.onAbort` fires — that single hook covers both browser-initiated
        // abort and network disconnect, which the server cannot distinguish.
        const followStreamStart = Date.now();
        return stream(c, async (out) => {
            let position = 0;
            let linesStreamed = 0;
            let closeReason = 'end';
            const countNewlines = (buf) => {
                let n = 0;
                for (let i = 0; i < buf.length; i++)
                    if (buf[i] === 0x0a)
                        n++;
                return n;
            };
            if (existsSync(jsonlPath)) {
                const initialSize = statSync(jsonlPath).size;
                const head = createReadStream(jsonlPath, { start: 0, end: Math.max(0, initialSize - 1) });
                for await (const chunk of head) {
                    const buf = chunk;
                    linesStreamed += countNewlines(buf);
                    await out.write(buf);
                }
                position = initialSize;
            }
            // Task 631 — mark the head→tail boundary. Everything written above is the
            // pre-attach JSONL (full prior conversation on a resumed session, a
            // partial in-flight turn or nothing on a cold spawn); the sentinel tells
            // the channel follower the live tail starts here. Not counted in
            // `linesStreamed` — it is not a file line, and the viewer never sets
            // `boundary`.
            if (boundary) {
                await out.write(Buffer.from(JSON.stringify({ type: '__channel_resume_boundary__' }) + '\n'));
            }
            // Task 260 — prefer the row's PID (watcher-derived) but fall back
            // to the live tracker if the row hasn't been populated yet (the
            // pre-first-turn window during which the watcher's PID-file event
            // has not fired against the row builder).
            const followPid = row?.pid ?? getPtyTrackerForTests(sessionId)?.pid ?? null;
            deps.logger(`log-follow-open sessionId=${sessionId} initialBytes=${position} pid=${followPid ?? 'null'}`);
            let active = true;
            out.onAbort(() => {
                active = false;
                closeReason = 'aborted';
                unwatchFile(jsonlPath);
            });
            const listener = () => {
                if (!active)
                    return;
                if (!existsSync(jsonlPath))
                    return;
                const newSize = statSync(jsonlPath).size;
                if (newSize <= position)
                    return;
                const tail = createReadStream(jsonlPath, { start: position, end: newSize - 1 });
                position = newSize;
                tail.on('data', (chunk) => {
                    if (active) {
                        const buf = chunk;
                        linesStreamed += countNewlines(buf);
                        void out.write(buf);
                    }
                });
            };
            watchFile(jsonlPath, { interval: 250 }, listener);
            while (active) {
                await new Promise((resolve) => setTimeout(resolve, 1_000));
            }
            deps.logger(`log-follow-close sessionId=${sessionId} reason=${closeReason} linesStreamed=${linesStreamed} ms=${Date.now() - followStreamStart}`);
        });
    });
    app.get('/healthz', (c) => c.json({ ok: true, sessions: livePtyCount() }));
    // Task 543 — fire-and-forget `claude --remote-control [name] [--session-id <sid>]`
    // spawn for the admin sessions pane. The new PTY registers itself in
    // claude.ai/code as its own Remote Control entry; reconciliation with the
    // rc-daemon is out of scope. The PID lands in
    // `<CLAUDE_CONFIG_DIR>/sessions/<pid>.json` via claude's own bootstrap,
    // which is how the sidebar list keeps `live` accurate and how the delete
    // route later finds the PID to SIGTERM.
    //
    // Task 573 — single spawn path: node-pty wrapped in `systemd-run --user
    // --scope`. Success signal is the on-disk PID file (logged as
    // `op=pidfile-present`); the prior A/B `script-scope` arm and its
    // non-PTY scope primitive are gone. The manager owns
    // one master fd per live rc-spawn child; lifecycle observability emits
    // `[rc-spawn]` and `[rc-life]` lines tagged with the `unitToken`
    // covering request → argv → pty-spawned → child-output → pidfile-present
    // (success) / early-exit (failure) → exit.
    app.post('/rc-spawn', async (c) => {
        const start = Date.now();
        let body;
        try {
            body = await c.req.json();
        }
        catch {
            body = {};
        }
        const sessionId = typeof body.sessionId === 'string' && body.sessionId.length > 0 ? body.sessionId : null;
        const name = typeof body.name === 'string' && body.name.length > 0 ? body.name : null;
        // Task 626 — per-visitor slice ringfence for the public session-end review
        // rc session. Shape-gated identically to /public-spawn so a body-controlled
        // value cannot reach the child env. Absent on channel-admin / LinkedIn.
        const sliceToken = typeof body.sliceToken === 'string' && UUID_PATTERN.test(body.sliceToken) ? body.sliceToken : undefined;
        const personId = typeof body.personId === 'string' && body.personId.length > 0 && body.personId.length <= 128
            ? body.personId
            : undefined;
        // Task 626 — one-shot admin jobs (LinkedIn ingest, public session-end
        // review) carry their prompt as the session's first turn, the same
        // trailing-positional mechanism /public-spawn uses, and close after that
        // one assistant turn. `closeAfterTurn` only takes effect with a sessionId
        // so the JSONL watcher has a path to follow.
        const initialMessage = typeof body.initialMessage === 'string' && body.initialMessage.length > 0 ? body.initialMessage : null;
        const closeAfterTurn = body.closeAfterTurn === true && sessionId !== null;
        if (sessionId !== null && !UUID_PATTERN.test(sessionId)) {
            timed(deps.logger, 'POST', '/rc-spawn', 400, Date.now() - start);
            return c.json({ error: 'sessionId must be a v4 UUID' }, 400);
        }
        // Task 547 — when a JSONL already exists for the requested sessionId,
        // resume the existing conversation instead of forking a fresh one
        // under the same id. `--session-id` is claude's fresh-spawn flag;
        // `--resume` is the continue-this-conversation flag.
        const existingJsonlPath = sessionId ? findExistingJsonlForSessionId(deps.claudeConfigDir, sessionId) : null;
        const mode = existingJsonlPath ? 'resume' : 'fresh';
        const argv = ['--remote-control'];
        if (name)
            argv.push(name);
        if (sessionId)
            argv.push(mode === 'resume' ? '--resume' : '--session-id', sessionId);
        // Task 592 — per-spawn bypass-permissions waiver. The brand settings.json
        // carries `defaultMode: bypassPermissions` (Task 583); without this flag
        // the first-time "WARNING: … 1. No, exit  2. Yes, I accept" disclaimer
        // hangs the spawn and times out waitForPid. Both documented skip keys are
        // waived so the disclaimer is suppressed whatever mode the session resolves
        // to. Per Task 574 doctrine the waiver is per-spawn (never written into
        // settings.json / .claude.json, which would permanently waive it).
        argv.push('--settings', JSON.stringify({ skipDangerousModePermissionPrompt: true, skipAutoPermissionPrompt: true }));
        // Task 626 — the initial message is the trailing positional, processed as
        // the session's first user turn at PTY startup. One-shot jobs omit `name`
        // so the prompt is the sole positional.
        if (initialMessage)
            argv.push(initialMessage);
        // Task 606 — thread the validated per-session sessionId (fresh and resume
        // share the one var) so session-linked writes resolve SESSION_ID; logDir
        // so the child's MCP stderr-tee writes its readable per-server log.
        const env = buildRcChildEnv({
            accountId: deps.accountId,
            userId: deps.userId,
            sessionId: sessionId ?? undefined,
            uiPort: process.env.MAXY_UI_INTERNAL_PORT ?? '',
            platformRoot: deps.platformRoot,
            claudeConfigDir: deps.claudeConfigDir,
            logDir: deps.logDir,
            brandedNeo4jUri: process.env.NEO4J_URI ?? '',
            brandedNeo4jPassword: process.env.NEO4J_PASSWORD ?? '',
            sliceToken,
            personId,
        });
        // Task 606 — standing startup signal: confirms per-spawn whether the
        // child env carries a session id. `mode=new` when none was requested
        // (CLI fresh-spawn), else the resolved fresh/resume.
        const childEnvMode = sessionId === null ? 'new' : mode;
        deps.logger(`[claude-session-manager] rc-child-env sessionId-present=${sessionId ? 'yes' : 'no'} mode=${childEnvMode}`);
        const unitToken = `rc-resume-${randomUUID()}`;
        const sessionIdForLog = sessionId ? sessionId.slice(0, 8) : 'new';
        const rcLifeMode = mode === 'resume' ? 'resume' : 'new';
        // Step 1 — request received, before any spawn work. The argv-resolved
        // line (step 2) emits next, so a 500 between them isolates resolution
        // failures from spawn failures.
        deps.logger(`[rc-spawn] op=request unitToken=${unitToken} sessionId=${sessionIdForLog} name=${name ?? 'none'} mode=${mode} jsonl=${existingJsonlPath ?? 'none'} initialMessage=${initialMessage ? Buffer.byteLength(initialMessage, 'utf8') + 'b' : 'none'} closeAfterTurn=${closeAfterTurn}`);
        // Cross-arm schema mirror so `grep '[rc-life]'` covers rc-spawn and
        // rc-daemon under one shape.
        emitRcLife(deps.logger, 'request', 'rc-spawn', 'node-pty', rcLifeMode, unitToken, {
            sessionId: sessionId ?? 'new',
            name: name ?? 'none',
            existingJsonl: existingJsonlPath ?? 'none',
        });
        // Step 2 — the exact claude argv (the inner command). The systemd-run
        // wrap is composed by the spawnPty adapter; logging the inner argv
        // here is enough for an operator to reproduce by hand. The wrapped
        // argv is fully reconstructible from this line + the spawn adapter
        // source (Task 250 / 556).
        deps.logger(`[rc-spawn] op=argv unitToken=${unitToken} cwd=${deps.spawnCwd} argv=${JSON.stringify([deps.claudeBin, ...argv])}`);
        emitRcLife(deps.logger, 'argv', 'rc-spawn', 'node-pty', rcLifeMode, unitToken, {
            cwd: deps.spawnCwd,
            argv: JSON.stringify([deps.claudeBin, ...argv]),
        });
        let pty;
        try {
            pty = deps.spawnPty(deps.claudeBin, argv, env, unitToken);
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            deps.logger(`[sessions-rc-resume] spawn-failed sessionId=${sessionId ?? 'new'} name=${name ?? 'none'} err=${msg}`);
            deps.logger(`[rc-spawn] op=spawn-failed unitToken=${unitToken} err=${JSON.stringify(msg)}`);
            timed(deps.logger, 'POST', '/rc-spawn', 500, Date.now() - start);
            // Task 576 — same auth-aware reason as /spawn so the resume path
            // surfaces auth death too, not only spawn-time failures.
            const authSnap = snapshotAuthForFailureReason(credentialsPathForConfigDir(deps.claudeConfigDir));
            const reason = authSnap === 'dead' || authSnap === 'expired' ? 'auth-refresh-failed' : msg;
            if (reason === 'auth-refresh-failed') {
                deps.logger(`[rc-spawn] op=auth-refresh-failed authStatus=${authSnap} unitToken=${unitToken}`);
            }
            // Task 648 — tie the 500 to the same resume-response decision line the
            // success/timeout paths emit, so one grep covers every spawn outcome.
            deps.logger(`[rc-spawn] op=resume-response sessionId=${sessionId ?? 'none'} slug=none outcome=spawn-failed reason=${JSON.stringify(reason)}`);
            return c.json({ error: msg, reason, authStatus: authSnap }, 500);
        }
        // Step 3 — PTY spawned. fd baseline at this instant; the fd-release
        // line later in the lifecycle is read relative to this value.
        deps.logger(`[rc-spawn] op=pty-spawned unitToken=${unitToken} pid=${pty.pid} openFds=${openFdCount()}`);
        emitRcLife(deps.logger, 'spawned', 'rc-spawn', 'node-pty', rcLifeMode, unitToken, {
            pid: pty.pid,
            managerOpenFds: openFdCount(),
        });
        // Tracker registration — closes the leak gap the Task 552 path opened.
        // For fresh spawns without a caller-supplied sessionId we register the
        // tracker under the unitToken so the entry has a stable key; the
        // operator-visible sessionId only becomes known when claude writes its
        // pid file. Both keys are equally good for fd-ownership purposes.
        const trackerKey = sessionId ?? `rc-${unitToken}`;
        const tracker = registerRcSpawnPty({ logger: deps.logger }, trackerKey, pty, unitToken);
        // Task 626 — one-shot job (LinkedIn ingest, public session-end review):
        // stop the PTY once its single assistant turn lands `end_turn`. closeAfterTurn
        // implies a sessionId (validated above), so trackerKey === sessionId and the
        // JSONL watcher follows the same id claude writes under.
        if (closeAfterTurn && sessionId)
            attachEndTurnAutoClose(deps, sessionId, 'closeAfterTurn=true');
        // Step 4 — first child output. Accumulate up to 1 KB or until 500 ms
        // after the first byte arrives, then emit one line. The single log
        // that ends the 5-occasion blindness: claude's own words land here
        // — `Remote Control` on success, `No deferred tool marker` /
        // `Unknown assignment` on failure.
        let outputBuf = '';
        let outputEmitted = false;
        let outputTimer = null;
        const emitChildOutput = () => {
            if (outputEmitted)
                return;
            outputEmitted = true;
            if (outputTimer) {
                clearTimeout(outputTimer);
                outputTimer = null;
            }
            deps.logger(`[rc-spawn] op=child-output unitToken=${unitToken} pid=${pty.pid} head=${JSON.stringify(outputBuf)}`);
        };
        pty.onData((data) => {
            if (outputEmitted)
                return;
            const remaining = 1024 - outputBuf.length;
            outputBuf += remaining >= data.length ? data : data.slice(0, remaining);
            if (outputBuf.length >= 1024)
                emitChildOutput();
            else if (!outputTimer)
                outputTimer = setTimeout(emitChildOutput, 500);
        });
        // Step 5/10 — exit observation. `pidFileSeen` discriminates `early-exit`
        // (no pid file ever appeared — the no-TTY death signature) from `exit`
        // (clean termination after a normal run). `handlePtyNaturalExit` (wired
        // by `registerRcSpawnPty`) handles the tracker side — fd release +
        // tracker delete + the `kill … reason=process-exited` log.
        let pidFileSeen = false;
        pty.onExit((e) => {
            const ranMs = Date.now() - start;
            if (!pidFileSeen) {
                deps.logger(`[rc-spawn] op=early-exit unitToken=${unitToken} pid=${pty.pid} ranMs=${ranMs} exitCode=${e.exitCode} signal=${e.signal ?? 'none'}`);
                emitRcLife(deps.logger, 'early-exit', 'rc-spawn', 'node-pty', rcLifeMode, unitToken, {
                    pid: pty.pid,
                    ranMs,
                    exitCode: e.exitCode ?? 'null',
                    signal: e.signal ?? 'none',
                });
            }
            deps.logger(`[rc-spawn] op=exit unitToken=${unitToken} pid=${pty.pid} ranMs=${ranMs}`);
            emitRcLife(deps.logger, 'exit', 'rc-spawn', 'node-pty', rcLifeMode, unitToken, {
                pid: pty.pid,
                lifetimeMs: ranMs,
                exitCode: e.exitCode ?? 'null',
                signal: e.signal ?? 'none',
            });
            // Flush any buffered child output that never reached 1 KB nor the
            // 500 ms idle deadline before the child exited.
            if (!outputEmitted)
                emitChildOutput();
        });
        // Task 558 — session-ready is terminal for the spawn path. The PTY
        // master IS the live session, not a reclaimable spawn artifact:
        // claude operates on the slave, and closing the master hangs up the
        // slave (Task 557 evidence: claude exits ~0.8s after `op=fd-release
        // trigger=session-ready`). The master must stay open for the slave
        // to function. The only valid release points are the explicit
        // operator teardown — `/stop` → `stopSession` → `releasePtyMasterFd`
        // — and the natural-exit path inside `pty.onExit →
        // handlePtyNaturalExit`. The tracker remains in `livePtys` for the
        // session's lifetime.
        //
        // Task 592 — capture the promise once so the fire-and-forget logging
        // and the bounded bridgeSessionId await share the same resolution.
        const RC_SPAWN_URL_WAIT_MS = deps.rcSpawnUrlWaitMs ?? RC_SPAWN_URL_WAIT_DEFAULT_MS;
        const waitPidPromise = deps.watcher.waitForPid(pty.pid, deps.pidFileTimeoutMs);
        // Fire-and-forget: unchanged logging contract.
        void waitPidPromise
            .then((row) => {
            pidFileSeen = true;
            const ageMs = Date.now() - start;
            // Task 573 — on-disk pid file presence is the success evidence.
            // Replaces the prior op=alive / op=session-ready pair.
            deps.logger(`[rc-spawn] op=pidfile-present unitToken=${unitToken} pid=${pty.pid} path=${row.pidFilePath ?? 'unknown'} ageMs=${ageMs} bridgeId=${row.bridgeSessionId ?? 'unknown'}`);
            emitRcLife(deps.logger, 'pidfile-present', 'rc-spawn', 'node-pty', rcLifeMode, unitToken, {
                pid: pty.pid,
                path: row.pidFilePath ?? 'unknown',
                ageMs,
                bridgeId: row.bridgeSessionId ?? 'unknown',
            });
        })
            .catch((err) => {
            deps.logger(`[rc-spawn] op=wait-pid-failed unitToken=${unitToken} pid=${pty.pid} err=${err instanceof Error ? err.message : String(err)}`);
            emitRcLife(deps.logger, 'wait-pid-failed', 'rc-spawn', 'node-pty', rcLifeMode, unitToken, {
                pid: pty.pid,
                err: JSON.stringify(err instanceof Error ? err.message : String(err)),
            });
        });
        // Task 592 — bounded await: surface bridgeSessionId in the response
        // so the client can open the exact session URL. bridgeSessionId lands
        // in TWO steps (device-measured): the pid file first appears at ~1 s
        // carrying {pid, sessionId} but NO bridgeSessionId, then the RC bridge
        // writes bridgeSessionId into the same file ~0.6 s later when it
        // registers with claude.ai/code. `waitForPid` resolves on first
        // appearance, so its row's bridge is almost always null — after it
        // resolves, re-read the pid file until the bridge lands or the bound
        // elapses. On timeout (slow/failed bind) bridgeSessionId stays null and
        // the client falls back to the bare claude.ai/code URL.
        const deadline = start + RC_SPAWN_URL_WAIT_MS;
        let bridgeSessionId = null;
        let pidFilePath = null;
        const timedPidRow = await Promise.race([
            waitPidPromise.then((r) => r).catch(() => null),
            new Promise((resolve) => setTimeout(() => resolve(null), RC_SPAWN_URL_WAIT_MS)),
        ]);
        if (timedPidRow) {
            bridgeSessionId = timedPidRow.bridgeSessionId ?? null;
            pidFilePath = timedPidRow.pidFilePath ?? null;
        }
        // Poll the pid file for the late-written bridgeSessionId.
        while (!bridgeSessionId && pidFilePath && Date.now() < deadline) {
            await new Promise((r) => setTimeout(r, 200));
            try {
                const parsed = JSON.parse(readFileSync(pidFilePath, 'utf8'));
                if (typeof parsed.bridgeSessionId === 'string' && parsed.bridgeSessionId.length > 0) {
                    bridgeSessionId = parsed.bridgeSessionId;
                }
            }
            catch {
                // file mid-write or transiently unreadable — retry until the deadline
            }
        }
        // Task 648 — the bind decision drives what the client renders: a slug
        // navigates the opened tab to the exact session URL; a null slug after
        // the full wait is a bind-timeout the client surfaces as an error modal
        // (never a bare claude.ai/code tab). `outcome` and `reason` are threaded
        // through the proxy unchanged so the client decides modal-vs-navigate
        // from one server-supplied value.
        const waitedMs = Date.now() - start;
        const outcome = bridgeSessionId ? 'bound' : 'timeout';
        const reason = outcome === 'bound' ? null : 'pid-bind-timeout';
        if (outcome === 'bound') {
            deps.logger(`[rc-spawn] op=resume-bound sessionId=${sessionId ?? 'none'} slug=${bridgeSessionId} ms=${waitedMs}`);
        }
        else {
            deps.logger(`[rc-spawn] op=resume-timeout sessionId=${sessionId ?? 'none'} waitedMs=${RC_SPAWN_URL_WAIT_MS} slug=none`);
        }
        deps.logger(`[rc-spawn] op=resume-response sessionId=${sessionId ?? 'none'} slug=${bridgeSessionId ?? 'none'} outcome=${outcome} reason=${reason ?? 'none'}`);
        // Every rc-spawn child runs inside a `systemd-run --user --scope`
        // unit driven by node-pty; the operator-visible spawnMethod marker
        // is kept stable so server.log greps survive the Task 573 cleanup.
        deps.logger(`[sessions-rc-resume] mode=${mode} sessionId=${sessionId ?? 'none'} jsonlPath=${existingJsonlPath ?? 'none'} name=${name ?? 'none'} spawnedPid=${pty.pid} bridgeId=${bridgeSessionId ?? 'pending'} spawnMethod=systemd-scope env.CLAUDE_CONFIG_DIR=${deps.claudeConfigDir}`);
        timed(deps.logger, 'POST', '/rc-spawn', 200, Date.now() - start);
        // `slug` is the Task 648 canonical field the client navigates by;
        // `bridgeSessionId` is the same value, retained so pre-648 consumers and
        // tests keep resolving. They never diverge — `slug` IS `bridgeSessionId`.
        return c.json({ spawnedPid: pty.pid, sessionId: sessionId ?? null, bridgeSessionId, slug: bridgeSessionId, outcome, reason });
    });
    return app;
}
//# sourceMappingURL=http-server.js.map