"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
// Reject native Windows — WSL is fine (reports 'linux')
if (process.platform === 'win32') {
    console.error('ccweb does not support native Windows. Please use WSL2 instead.');
    process.exit(1);
}
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const http = __importStar(require("http"));
const path = __importStar(require("path"));
const fs = __importStar(require("fs"));
const WebSocket = __importStar(require("ws"));
const config_1 = require("./config");
const auth_1 = require("./auth");
const terminal_manager_1 = require("./terminal-manager");
const terminal_paste_1 = require("./terminal-paste");
const auth_2 = __importDefault(require("./routes/auth"));
const projects_1 = __importDefault(require("./routes/projects"));
const filesystem_1 = __importDefault(require("./routes/filesystem"));
const shortcuts_1 = __importDefault(require("./routes/shortcuts"));
const agent_prompts_1 = __importDefault(require("./routes/agent-prompts"));
const memory_prompts_1 = __importDefault(require("./routes/memory-prompts"));
const tracks_1 = require("./routes/tracks");
const track_flows_1 = require("./routes/track-flows");
const global_tracks_1 = __importDefault(require("./routes/global-tracks"));
const track_flow_ws_1 = require("./track-flow-ws");
const track_flow_1 = require("./track-flow");
const update_1 = __importDefault(require("./routes/update"));
const user_prefs_1 = __importDefault(require("./routes/user-prefs"));
const skillhub_1 = __importDefault(require("./routes/skillhub"));
const session_manager_1 = require("./session-manager");
const logger_1 = require("./logger");
const request_log_1 = require("./middleware/request-log");
const hooks_1 = __importStar(require("./routes/hooks"));
const approval_1 = __importDefault(require("./routes/approval"));
const approval_manager_1 = require("./approval-manager");
const cli_prompt_detector_1 = require("./cli-prompt-detector");
const notify_1 = __importDefault(require("./routes/notify"));
const notify_service_1 = require("./notify-service");
const git_1 = __importDefault(require("./routes/git"));
const claude_1 = __importDefault(require("./routes/claude"));
const hooks_manager_1 = require("./hooks-manager");
const plugin_manager_1 = require("./plugin-manager");
const plugins_1 = __importDefault(require("./routes/plugins"));
const plugin_bridge_1 = __importDefault(require("./routes/plugin-bridge"));
const sync_1 = __importDefault(require("./routes/sync"));
const browser_proxy_1 = __importDefault(require("./routes/browser-proxy"));
const browser_chrome_1 = __importDefault(require("./routes/browser-chrome"));
const session_manager_2 = require("./browser-chrome/session-manager");
const screencast_1 = require("./browser-chrome/screencast");
const input_forwarder_1 = require("./browser-chrome/input-forwarder");
const sync_scheduler_1 = require("./sync-scheduler");
const chat_backup_1 = require("./chat-backup");
const sync_service_1 = require("./sync-service");
const os = __importStar(require("os"));
// Port file path: always ~/.ccweb/port (fixed path for hook shell commands)
const PORT_FILE = path.join(os.homedir(), '.ccweb', 'port');
const hooksManager = new hooks_manager_1.HooksManager(PORT_FILE);
// modLogger returns a lazy Proxy; safe to call at module top. Every log.*
// site below resolves the real child logger on first use (after initLogger).
const log = (0, logger_1.modLogger)('server');
// NOTE: a handful of BOOTSTRAP-PHASE console.* remain in this file —
//   - L3  (Windows reject): runs before any import
//   - L60 (migrateProjectConfigs): runs at module load, before logger init
//   - L699 (bootstrap catch): fires only if logger init itself failed
// They are intentionally retained. Daemon stdout/stderr → ~/.ccweb/ccweb.log
// is the bootstrap/crash fallback (plan §11). All POST-init console.* have
// been migrated to structured logging.
(0, config_1.initDataDirs)();
plugin_manager_1.pluginManager.installBundled();
plugin_manager_1.pluginManager.loadAll();
migrateProjectConfigs();
(0, track_flow_1.cleanupStaleCwdFiles)((0, config_1.getProjects)().map((p) => p.folderPath));
/** Backfill .ccweb/project.json for projects created before this feature existed */
function migrateProjectConfigs() {
    for (const project of (0, config_1.getProjects)()) {
        try {
            if (!(0, config_1.readProjectConfig)(project.folderPath)) {
                (0, config_1.writeProjectConfig)(project.folderPath, project);
                console.log(`[Migration] Wrote .ccweb/project.json for "${project.name}"`);
            }
        }
        catch (err) {
            console.error(`[Migration] Failed for "${project.name}":`, err);
        }
    }
}
const app = (0, express_1.default)();
const PORT = parseInt(process.env.CCWEB_PORT || '3001', 10);
const ACCESS_MODE = (process.env.CCWEB_ACCESS_MODE || 'local');
const LISTEN_HOST = ACCESS_MODE === 'local' ? '127.0.0.1' : '0.0.0.0';
/** Check if an IP address belongs to a private network range */
function isPrivateIP(ip) {
    // Normalize IPv4-mapped IPv6 (::ffff:x.x.x.x)
    let addr = ip;
    if (addr.startsWith('::ffff:'))
        addr = addr.slice(7);
    // IPv6 loopback
    if (addr === '::1')
        return true;
    // IPv6 link-local
    if (addr.toLowerCase().startsWith('fe80:'))
        return true;
    // IPv4 checks
    const parts = addr.split('.').map(Number);
    if (parts.length !== 4 || parts.some(isNaN))
        return false;
    // 127.0.0.0/8
    if (parts[0] === 127)
        return true;
    // 10.0.0.0/8
    if (parts[0] === 10)
        return true;
    // 172.16.0.0/12
    if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
        return true;
    // 192.168.0.0/16
    if (parts[0] === 192 && parts[1] === 168)
        return true;
    return false;
}
/** LAN mode IP filter middleware — reject non-private IPs */
if (ACCESS_MODE === 'lan') {
    app.use((req, res, next) => {
        const ip = req.ip || req.socket.remoteAddress || '';
        if (isPrivateIP(ip))
            return next();
        res.status(403).json({ error: 'Access denied: LAN only' });
    });
}
// Security headers
app.use((_req, res, next) => {
    res.setHeader('X-Content-Type-Options', 'nosniff');
    res.setHeader('X-Frame-Options', 'SAMEORIGIN');
    res.setHeader('X-XSS-Protection', '1; mode=block');
    res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; font-src 'self' data:; frame-src 'self'");
    next();
});
app.use((0, cors_1.default)({
    origin: (origin, callback) => {
        // Allow requests with no origin (same-origin, curl, Electron, etc.)
        if (!origin)
            return callback(null, true);
        // In lan/public mode, allow all origins
        if (ACCESS_MODE !== 'local')
            return callback(null, true);
        // Local mode: only allow localhost origins
        try {
            const u = new URL(origin);
            if (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1') {
                return callback(null, true);
            }
        }
        catch { /* invalid origin */ }
        callback(new Error('Not allowed by CORS'));
    },
    credentials: true,
}));
app.use(express_1.default.json({
    // Stash raw body for all POSTs so HMAC routes can verify against exact bytes.
    // Cost is one Buffer.from per request body (small, bounded by body size).
    verify: (req, _res, buf) => {
        if (req.method === 'POST') {
            req.rawBody = Buffer.from(buf);
        }
    },
}));
// Structured request logging + reqId injection into AsyncLocalStorage.
// Runs AFTER json parser (so req.rawBody is set if needed) and BEFORE any
// auth/router — downstream handlers auto-inherit the reqId via ALS.
app.use(request_log_1.requestLog);
app.use('/api/auth', auth_2.default);
app.use('/api/hooks', hooks_1.default);
// Approval router mounts at /api; hook-facing route is loopback+HMAC gated internally,
// user-facing routes check req.user manually.
if (app.get('trust proxy')) {
    console.warn('[approval] trust proxy is set — hook loopback check may accept spoofed IPs. Disable trust proxy or bind backend to 127.0.0.1.');
}
app.use('/api', approval_1.default);
app.use('/api/projects', auth_1.authMiddleware, projects_1.default);
app.use('/api/filesystem', auth_1.authMiddleware, filesystem_1.default);
app.use('/api/shortcuts', auth_1.authMiddleware, shortcuts_1.default);
app.use('/api/prompts', auth_1.authMiddleware, agent_prompts_1.default);
app.use('/api/memory', auth_1.authMiddleware, memory_prompts_1.default);
app.use('/api/update', auth_1.authMiddleware, update_1.default);
app.use('/api/user-prefs', auth_1.authMiddleware, user_prefs_1.default);
app.use('/api/skillhub', auth_1.authMiddleware, skillhub_1.default);
app.use('/api/notify', auth_1.authMiddleware, notify_1.default);
app.use('/api/projects', auth_1.authMiddleware, git_1.default);
app.use('/api/claude', auth_1.authMiddleware, claude_1.default);
app.use('/api/tool', auth_1.authMiddleware, claude_1.default);
app.use('/api/plugins', auth_1.authMiddleware, plugins_1.default);
app.use('/api/plugin-bridge', auth_1.authMiddleware, plugin_bridge_1.default);
app.use('/api/sync', auth_1.authMiddleware, sync_1.default);
// NOT wrapped in authMiddleware — browser-proxy.ts handles its own
// auth (explicit Bearer admin for /_session, cookie for proxy GETs) so
// localhost auto-auth cannot blind-trigger SSRF via CSRF.
app.use('/api/browser-proxy', browser_proxy_1.default);
// Same pattern: browser-chrome.ts uses requireBearerAdmin internally so
// localhost auto-auth cannot CSRF chromium session creation.
app.use('/api/browser-chrome', browser_chrome_1.default);
// Serve plugin SDK: /plugin-sdk/ccweb-plugin-sdk.js
app.use('/plugin-sdk', express_1.default.static(path.join(__dirname, '../../plugin-sdk')));
// Serve plugin frontend files: /plugins/:id/* → ~/.ccweb/plugins/:id/frontend/*
app.use('/plugins/:id', (req, res, next) => {
    const frontendDir = plugin_manager_1.pluginManager.getFrontendDir(req.params.id);
    if (!frontendDir)
        return res.status(404).json({ error: 'Plugin not found' });
    express_1.default.static(frontendDir)(req, res, next);
});
// Mount plugin backend routers: /api/plugins/:id/* → plugin's Express Router
for (const plugin of plugin_manager_1.pluginManager.getAll()) {
    if (plugin.router) {
        app.use(`/api/plugins/${plugin.manifest.id}`, auth_1.authMiddleware, plugin.router);
    }
}
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
// Serve built frontend (production / Electron)
const frontendDist = path.join(__dirname, '../../frontend/dist');
if (fs.existsSync(frontendDist)) {
    app.use(express_1.default.static(frontendDist));
    app.get('*', (_req, res, next) => {
        if (_req.path.startsWith('/api/') || _req.path.startsWith('/ws/') || _req.path.startsWith('/plugins/') || _req.path.startsWith('/plugin-sdk/'))
            return next();
        res.sendFile(path.join(frontendDist, 'index.html'));
    });
}
const server = http.createServer(app);
const wss = new WebSocket.Server({ noServer: true, maxPayload: 1024 * 1024 }); // 1MB max message
// projectId → connected WebSocket clients (all in terminal mode)
const projectClients = new Map();
// Paste body / CR split + per-project serial queue lives in terminal-paste.ts —
// chat send + v3 track-flow runtime（routes/_flow-injector.ts）共享同一条路径。
// v1 任务流系统（backend/src/flows/）已在 v-h 删除。
/**
 * Bounded WS send. Skips closed sockets, enforces a per-socket bufferedAmount
 * ceiling so a slow/stuck client cannot accumulate unbounded backlog. When the
 * ceiling is hit we terminate the socket — preferable to silently growing the
 * Node heap. Replaces direct `ws.send` calls per CLAUDE.md red line ("WS send
 * 必须经队列, 不直接 ws.send").
 *
 * Threshold (8 MiB) is generous for normal terminal data + chat blocks; only
 * pathological clients hit it. A real queue with drop policy would be more
 * sophisticated but this single guard already closes the unbounded-memory hole.
 *
 * Subscribe grace: when a client subscribes, we push the entire scrollback
 * (up to SCROLLBACK_MAX_CHARS = 5 MB raw, ~25-30 MB after JSON.stringify
 * escaping). That single send pushes bufferedAmount well above the 8 MiB
 * limit instantly, so the very next safeSend (terminal_subscribed reply,
 * a follow-up broadcast etc.) would self-terminate the WS — symptom: client
 * sees a black terminal with no error. To avoid that, the subscribe handler
 * stamps `__subscribeAt` on the socket; for ~30 s afterwards safeSend uses
 * a much higher threshold so TCP can drain the burst, then falls back to
 * the strict 8 MiB. Stamp is also set on broadcast handlers and similar
 * places that may follow with a backlog.
 */
const WS_BACKPRESSURE_LIMIT_BYTES = 8 * 1024 * 1024;
const WS_SUBSCRIBE_GRACE_MS = 30 * 1000;
const WS_SUBSCRIBE_GRACE_LIMIT_BYTES = 128 * 1024 * 1024;
function safeSend(ws, payload) {
    if (ws.readyState !== WebSocket.WebSocket.OPEN)
        return false;
    const subAt = ws.__subscribeAt;
    const inGrace = subAt !== undefined && Date.now() - subAt < WS_SUBSCRIBE_GRACE_MS;
    const limit = inGrace ? WS_SUBSCRIBE_GRACE_LIMIT_BYTES : WS_BACKPRESSURE_LIMIT_BYTES;
    if (ws.bufferedAmount > limit) {
        try {
            ws.terminate();
        }
        catch { /**/ }
        return false;
    }
    try {
        ws.send(payload);
        return true;
    }
    catch {
        return false;
    }
}
function broadcast(projectId, rawData) {
    const clients = projectClients.get(projectId);
    if (!clients)
        return;
    const payload = JSON.stringify({ type: 'terminal_data', data: rawData });
    for (const client of clients)
        safeSend(client, payload);
}
function broadcastJson(projectId, message) {
    const clients = projectClients.get(projectId);
    if (!clients)
        return;
    const payload = JSON.stringify(message);
    for (const client of clients)
        safeSend(client, payload);
}
/**
 * Broadcast a JSON message ONLY to non-view-only project clients.
 *
 * Track events (track_status_change / track_ask_user / track_run_complete)
 * embed LLM outputs (result) and runtime error details that may contain
 * sensitive content. Mirrors the approval-event policy at index.ts:357
 * which already withholds approval events from `__readOnly` clients.
 */
function broadcastJsonNonReadOnly(projectId, message) {
    const clients = projectClients.get(projectId);
    if (!clients)
        return;
    const payload = JSON.stringify(message);
    for (const client of clients) {
        if (client.__readOnly)
            continue;
        safeSend(client, payload);
    }
}
// ── Track subsystem (CRUD only, M0) ───────────────────────────────────────
app.use('/api/projects', auth_1.authMiddleware, (0, tracks_1.buildTracksRouter)());
app.use('/api/global/tracks', auth_1.authMiddleware, global_tracks_1.default);
// ── Track-flow subsystem (M1 CRUD + M2 runtime) ──────────────────────────
app.use('/api/projects', auth_1.authMiddleware, (0, track_flows_1.buildTrackFlowsRouter)());
(0, track_flow_ws_1.setBroadcast)(broadcastJsonNonReadOnly);
// Approval events leak tool inputs (command strings, file paths). Withhold from view-only clients.
approval_manager_1.approvalManager.subscribe((evt) => {
    if (!('projectId' in evt))
        return;
    const clients = projectClients.get(evt.projectId);
    if (!clients)
        return;
    const payload = JSON.stringify(evt);
    for (const client of clients) {
        if (client.__readOnly)
            continue;
        safeSend(client, payload);
    }
});
// CLI interactive prompt detection (Claude --continue resume menu, etc.).
// Carries no sensitive tool input; the event body is just a stable kind +
// generic label. Broadcast to all project clients including view-only —
// they should also see "切到终端" hints, since they may still want to know
// the project is blocked waiting for input.
cli_prompt_detector_1.cliPromptDetector.on('event', (evt) => {
    broadcastJson(evt.projectId, evt);
});
function isLocalWs(req) {
    const ip = req.socket.remoteAddress || '';
    return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
}
// ── Dashboard WebSocket clients (activity push) ─────────────────────────────
const dashboardClients = new Set();
const SEMANTIC_STALE_MS = 30000;
/**
 * Per-user visibility check used by every dashboard WS broadcast. Without it,
 * any connected user sees activity / semantic / project_stopped events for
 * every other user's projects (cross-user information leak). __username is
 * tagged on the socket at auth time (localhost path → admin; jwt path → user).
 */
function userCanSeeProject(username, projectId) {
    if (!username)
        return false;
    if ((0, config_1.isAdminUser)(username))
        return true; // admin (incl. localhost preauth) sees all
    const project = (0, config_1.getProject)(projectId);
    if (!project)
        return false;
    if ((0, config_1.isProjectOwner)(project, username))
        return true;
    return project.shares?.some((s) => s.username === username) ?? false;
}
function sendToDashboardClientsForProject(projectId, payload) {
    for (const client of dashboardClients) {
        const clientUser = client.__username;
        if (!userCanSeeProject(clientUser, projectId))
            continue;
        safeSend(client, payload);
    }
}
function broadcastDashboardActivity(projectId, lastActivityAt) {
    if (dashboardClients.size === 0)
        return;
    const semantic = session_manager_1.sessionManager.getSemanticStatus(projectId);
    const stale = semantic && Date.now() - semantic.updatedAt > SEMANTIC_STALE_MS;
    const now = Date.now();
    const payload = JSON.stringify({
        type: 'activity_update',
        projectId,
        lastActivityAt,
        active: now - lastActivityAt < 3000, // server-side determination, no clock skew
        status: terminal_manager_1.terminalManager.getProjectStatus(projectId),
        semantic: semantic && !stale ? semantic : undefined,
    });
    sendToDashboardClientsForProject(projectId, payload);
}
function broadcastDashboardSemantic(projectId, status) {
    if (dashboardClients.size === 0)
        return;
    // When status is non-null, LLM is currently active (hook just fired).
    // When status is null (Stop hook), fall back to PTY timestamp.
    const lastActivityAt = status ? Date.now() : (terminal_manager_1.terminalManager.getLastActivityAt(projectId) ?? 0);
    const now = Date.now();
    const payload = JSON.stringify({
        type: 'activity_update',
        projectId,
        lastActivityAt,
        active: !!status || (now - lastActivityAt < 3000),
        status: terminal_manager_1.terminalManager.getProjectStatus(projectId),
        semantic: status ?? undefined,
    });
    sendToDashboardClientsForProject(projectId, payload);
}
function buildSemanticSnapshot(projectId) {
    const semantic = session_manager_1.sessionManager.getSemanticStatus(projectId);
    const fresh = semantic && Date.now() - semantic.updatedAt <= SEMANTIC_STALE_MS;
    return { active: !!(semantic && fresh), semantic: fresh ? semantic : undefined };
}
function broadcastProjectSemantic(projectId, status) {
    const clients = projectClients.get(projectId);
    if (!clients || clients.size === 0)
        return;
    const payload = JSON.stringify({ type: 'semantic_update', active: !!status, semantic: status ?? undefined });
    for (const client of clients)
        safeSend(client, payload);
}
/**
 * PTY-driven `semantic_update` push. Emits `{active: true, semantic?:...}`
 * whenever the PTY saw output in the last 3s, regardless of whether the
 * session-manager's semantic classifier has categorized it yet.
 *
 * Symmetric with `broadcastDashboardActivity`, which does the same for the
 * dashboard WS. Without this, the chat overlay's "LLM is active but phase
 * unknown" code path (dots-only typing indicator) never fires — the
 * project WS only sent `semantic_update` when the classifier produced a
 * phase, never from raw PTY activity.
 */
function broadcastProjectActivity(projectId, lastActivityAt) {
    const clients = projectClients.get(projectId);
    if (!clients || clients.size === 0)
        return;
    const semantic = session_manager_1.sessionManager.getSemanticStatus(projectId);
    const fresh = semantic && Date.now() - semantic.updatedAt <= SEMANTIC_STALE_MS;
    const payload = JSON.stringify({
        type: 'semantic_update',
        active: Date.now() - lastActivityAt < 3000,
        semantic: fresh ? semantic : undefined,
    });
    for (const client of clients)
        safeSend(client, payload);
}
/**
 * When PTY / semantic activity stops, the project WS needs an explicit
 * `active: false` push — otherwise the chat overlay's typing-indicator
 * bubble stays on forever (React clears it on `!u.active` only).
 *
 * Dashboard doesn't need this because its frontend runs a 3s interval to
 * expire stale `active` entries locally; the project WS frontend has no
 * such safety net. So we schedule a single per-project idle timer that
 * fires 3.5s after the last activity / semantic event and broadcasts
 * `{active: false, semantic: undefined}`. Any new event resets the timer.
 */
const projectIdleTimers = new Map();
const PROJECT_IDLE_MS = 3500;
function broadcastProjectIdle(projectId) {
    const clients = projectClients.get(projectId);
    if (!clients || clients.size === 0)
        return;
    const payload = JSON.stringify({ type: 'semantic_update', active: false, semantic: undefined });
    for (const client of clients)
        safeSend(client, payload);
}
function armProjectIdleTimer(projectId) {
    const prev = projectIdleTimers.get(projectId);
    if (prev)
        clearTimeout(prev);
    const t = setTimeout(() => {
        projectIdleTimers.delete(projectId);
        broadcastProjectIdle(projectId);
    }, PROJECT_IDLE_MS);
    projectIdleTimers.set(projectId, t);
}
function initProjectTerminal(project, projectId) {
    const fn = (data) => broadcast(projectId, data);
    // Always pass continueSession=true: if terminal already exists getOrCreate returns early
    // (flag is ignored); if project is stopped and user navigates to it, --continue is correct.
    // Brand-new projects are pre-started in POST /api/projects before any WS connects, so
    // their terminal already exists and this flag is never consumed for truly new projects.
    terminal_manager_1.terminalManager.getOrCreate(project, fn, true);
    terminal_manager_1.terminalManager.updateBroadcast(projectId, fn);
}
function sendActivitySnapshot(ws) {
    const username = ws.__username;
    const allActivity = terminal_manager_1.terminalManager.getAllActivity();
    const allSemantic = session_manager_1.sessionManager.getAllSemanticStatus();
    // Include all running/restarting projects, even those with no PTY output yet
    const allRunningIds = new Set([...Object.keys(allActivity), ...terminal_manager_1.terminalManager.getAllRunningIds()]);
    const now = Date.now();
    for (const id of allRunningIds) {
        // Per-user filter: never leak other users' project state on initial snapshot.
        if (!userCanSeeProject(username, id))
            continue;
        const lastActivityAt = allActivity[id] ?? 0;
        const semantic = allSemantic[id];
        const stale = semantic && now - semantic.updatedAt > SEMANTIC_STALE_MS;
        safeSend(ws, JSON.stringify({
            type: 'activity_update',
            projectId: id,
            lastActivityAt,
            active: (semantic && !stale) ? true : (lastActivityAt > 0 && now - lastActivityAt < 3000),
            status: terminal_manager_1.terminalManager.getProjectStatus(id),
            semantic: semantic && !stale ? semantic : undefined,
        }));
    }
}
// Wire up events from terminal-manager and session-manager
terminal_manager_1.terminalManager.on('activity', ({ projectId, lastActivityAt }) => {
    broadcastDashboardActivity(projectId, lastActivityAt);
    // Also push to project WS so the chat overlay's typing indicator
    // fires on raw PTY activity (not just when the semantic classifier
    // has a phase). See broadcastProjectActivity comment.
    broadcastProjectActivity(projectId, lastActivityAt);
    // Arm the 3.5s idle timer — if no further activity arrives it will
    // broadcast `active: false` so the overlay's bubble clears.
    armProjectIdleTimer(projectId);
});
session_manager_1.sessionManager.on('semantic', ({ projectId, status }) => {
    broadcastDashboardSemantic(projectId, status);
    broadcastProjectSemantic(projectId, status);
    // If classifier pushed an active phase (status != null) arm idle clear
    // in case PTY immediately goes quiet after this event. When status is
    // null, broadcastProjectSemantic already sent `active: false` —
    // arming another timer would be harmless but redundant.
    if (status)
        armProjectIdleTimer(projectId);
});
terminal_manager_1.terminalManager.on('cli-tool-switched', ({ projectId, cliTool }) => {
    // Ask each connected browser to clear its xterm scrollback so the new CLI
    // starts on a clean screen instead of stacking on top of the old session's
    // banner. Server-side scrollback is already empty (new PTY instance).
    const payload = JSON.stringify({ type: 'terminal_reset', cliTool });
    const clients = projectClients.get(projectId);
    if (clients)
        for (const client of clients)
            safeSend(client, payload);
});
notify_service_1.notifyService.on('stopped', ({ projectId, projectName }) => {
    const msg = JSON.stringify({ type: 'project_stopped', projectId, projectName });
    // Per-user filter on dashboard side (project clients are already per-project + ownership-gated at subscribe time).
    sendToDashboardClientsForProject(projectId, msg);
    const clients = projectClients.get(projectId);
    if (clients)
        for (const client of clients)
            safeSend(client, msg);
});
// ── Sync progress bridge ────────────────────────────────────────────────────
// Forward rsync-driven start/progress/done events to both the per-project WS
// (so ProjectHeader's button can render) and the dashboard WS (so the
// SettingsPage batch-sync view can render). Payload type prefix `sync.` avoids
// collisions with existing message types.
//
// Dashboard WS is user-partitioned here: `currentFile` can contain sensitive
// absolute paths, so only the sync's owner receives the event. The project WS
// bucket is already per-project and further gated by ownership at subscribe
// time, so no additional filter needed there. __username is set at auth time
// in both the localhost and token-auth paths of the dashboard WS handler.
sync_service_1.syncEvents.on('event', (evt) => {
    const payload = JSON.stringify({ type: `sync.${evt.kind}`, ...evt });
    const projectBucket = projectClients.get(evt.projectId);
    if (projectBucket)
        for (const client of projectBucket)
            safeSend(client, payload);
    for (const client of dashboardClients) {
        const clientUser = client.__username;
        if (clientUser !== evt.username)
            continue;
        safeSend(client, payload);
    }
});
wss.on('connection', (ws, req) => {
    const parsedUrl = new URL(req.url || '', 'http://localhost');
    // ── Dashboard WebSocket (/ws/dashboard) ────────────────────────────────────
    if (parsedUrl.pathname === '/ws/dashboard') {
        const localConnection = isLocalWs(req);
        let authenticated = localConnection;
        if (localConnection) {
            // Localhost pre-auth ≡ admin. Tag the socket so the sync-events bridge
            // can filter cross-user events (see sync bridge above).
            ws.__username = (0, config_1.getAdminUsername)() ?? '__local_admin__';
            // Same subscribe-grace stamp as project WS — sendActivitySnapshot loops
            // every running project and emits one safeSend per id; on a daemon with
            // many running projects + large semantic payloads this burst could trip
            // the 8 MiB ceiling on follow-up sends.
            ws.__subscribeAt = Date.now();
            sendActivitySnapshot(ws);
            dashboardClients.add(ws);
        }
        ws.on('message', (rawMsg) => {
            try {
                const parsed = JSON.parse(rawMsg.toString());
                if (!authenticated && parsed.type === 'auth' && parsed.token) {
                    const user = (0, auth_1.verifyToken)(parsed.token);
                    if (user) {
                        authenticated = true;
                        ws.__username = user.username;
                        ws.__subscribeAt = Date.now();
                        sendActivitySnapshot(ws);
                        dashboardClients.add(ws);
                    }
                    else {
                        ws.close(1008, 'Invalid token');
                    }
                }
            }
            catch { /**/ }
        });
        ws.on('close', () => {
            dashboardClients.delete(ws);
        });
        return;
    }
    // ── Browser-chrome WebSocket (/ws/browser-chrome/:sid?token=...) ──────────
    const bcMatch = parsedUrl.pathname?.match(/^\/ws\/browser-chrome\/([a-f0-9-]+)$/);
    if (bcMatch) {
        const sid = bcMatch[1];
        const token = parsedUrl.searchParams.get('token');
        const claim = token ? (0, session_manager_2.verifySessionToken)(token) : null;
        if (!claim || claim.sid !== sid) {
            ws.close(1008, 'Invalid token');
            return;
        }
        const session = session_manager_2.browserChromeSessions.get(sid, claim.username);
        if (!session) {
            ws.close(1008, 'Session not found');
            return;
        }
        let stop = null;
        (0, screencast_1.startScreencast)(session, ws).then(s => { stop = s; }).catch(err => {
            log.warn({ err, sid }, 'screencast start failed');
            try {
                ws.close(1011, 'Screencast start failed');
            }
            catch { }
        });
        ws.on('message', (raw) => {
            let msg;
            try {
                msg = JSON.parse(raw.toString());
            }
            catch {
                return;
            }
            (0, input_forwarder_1.handleInput)(session, msg).catch(err => {
                log.warn({ err, sid, type: msg.type }, 'input handle failed');
            });
        });
        ws.on('close', () => {
            if (stop)
                stop().catch(() => { });
            // Do NOT destroy the session on WS close — let the user reconnect
            // quickly. Idle sweep in SessionManager will reap after 5 min.
        });
        return;
    }
    // ── Project WebSocket (/ws/projects/:id) ───────────────────────────────────
    const match = parsedUrl.pathname?.match(/^\/ws\/projects\/([a-zA-Z0-9_-]+)$/);
    if (!match) {
        ws.close(1008, 'Invalid path');
        return;
    }
    const projectId = match[1];
    const localConnection = isLocalWs(req);
    let authenticated = localConnection; // localhost = pre-authenticated
    let wsReadOnly = false; // true for view-only shared projects
    const chatListener = (msg) => {
        safeSend(ws, JSON.stringify({ type: 'chat_message', ...msg }));
    };
    const authTimeout = localConnection ? null : setTimeout(() => {
        if (!authenticated)
            ws.close(1008, 'Authentication timeout');
    }, 10000);
    // For local connections, set up project immediately
    if (localConnection) {
        const project = (0, config_1.getProject)(projectId);
        if (!project) {
            safeSend(ws, JSON.stringify({ type: 'error', message: 'Project not found' }));
            ws.close(1008, 'Project not found');
            return;
        }
        initProjectTerminal(project, projectId);
        safeSend(ws, JSON.stringify({ type: 'connected', projectId }));
        safeSend(ws, JSON.stringify({ type: 'status', status: project.status }));
    }
    ws.on('message', (rawMsg) => {
        try {
            let parsed;
            try {
                parsed = JSON.parse(rawMsg.toString());
            }
            catch {
                return;
            }
            // ── Auth handshake (skipped for localhost) ─────────────────────────────────
            if (!authenticated) {
                if (parsed.type !== 'auth' || !parsed.token) {
                    ws.close(1008, 'Authentication required');
                    return;
                }
                const tokenUser = (0, auth_1.verifyToken)(parsed.token);
                if (!tokenUser) {
                    ws.close(1008, 'Invalid token');
                    return;
                }
                if (authTimeout)
                    clearTimeout(authTimeout);
                authenticated = true;
                const project = (0, config_1.getProject)(projectId);
                if (!project) {
                    safeSend(ws, JSON.stringify({ type: 'error', message: 'Project not found' }));
                    ws.close(1008, 'Project not found');
                    return;
                }
                // Check access: owner, admin for legacy, or shared user
                const wsUsername = tokenUser.username;
                if (!(0, config_1.isProjectOwner)(project, wsUsername)) {
                    const share = project.shares?.find((s) => s.username === wsUsername);
                    if (!share) {
                        safeSend(ws, JSON.stringify({ type: 'error', message: 'Access denied' }));
                        ws.close(1008, 'Access denied');
                        return;
                    }
                    if (share.permission === 'view')
                        wsReadOnly = true;
                }
                ws.__readOnly = wsReadOnly;
                initProjectTerminal(project, projectId);
                safeSend(ws, JSON.stringify({ type: 'connected', projectId, readOnly: wsReadOnly }));
                safeSend(ws, JSON.stringify({ type: 'status', status: project.status }));
                return;
            }
            // For local connections, skip the auth message if sent anyway
            if (parsed.type === 'auth')
                return;
            // ── Authenticated messages ────────────────────────────────────────────────
            switch (parsed.type) {
                case 'terminal_subscribe':
                    // Resize PTY to browser dimensions before replaying scrollback
                    if (typeof parsed.cols === 'number' && typeof parsed.rows === 'number') {
                        terminal_manager_1.terminalManager.resize(projectId, parsed.cols, parsed.rows);
                    }
                    // Mark this socket as in subscribe-grace BEFORE the scrollback push.
                    // Without the stamp, the next safeSend (terminal_subscribed reply)
                    // sees bufferedAmount > 8 MiB from the scrollback we just queued and
                    // ws.terminate()'s the connection. See safeSend comment block.
                    ws.__subscribeAt = Date.now();
                    // Replay history so reconnecting clients see prior output
                    {
                        const scrollback = terminal_manager_1.terminalManager.getScrollback(projectId);
                        if (scrollback)
                            safeSend(ws, JSON.stringify({ type: 'terminal_data', data: scrollback }));
                    }
                    // Register as a live client
                    if (!projectClients.has(projectId))
                        projectClients.set(projectId, new Set());
                    projectClients.get(projectId).add(ws);
                    safeSend(ws, JSON.stringify({ type: 'terminal_subscribed' }));
                    // Send initial context data if available
                    {
                        const ctxData = (0, hooks_1.getContextData)(projectId);
                        if (ctxData)
                            safeSend(ws, JSON.stringify({ type: 'context_update', ...ctxData }));
                    }
                    {
                        const snap = buildSemanticSnapshot(projectId);
                        safeSend(ws, JSON.stringify({ type: 'semantic_update', ...snap }));
                    }
                    break;
                case 'terminal_input':
                    if (wsReadOnly)
                        break; // view-only users cannot send input
                    if (typeof parsed.data === 'string')
                        (0, terminal_paste_1.writeTerminalInputSplit)(projectId, parsed.data);
                    break;
                case 'terminal_resize':
                    if (typeof parsed.cols === 'number' && typeof parsed.rows === 'number') {
                        terminal_manager_1.terminalManager.resize(projectId, parsed.cols, parsed.rows);
                    }
                    break;
                case 'chat_subscribe':
                    // Replay existing chat history so reconnecting/switching clients see prior messages.
                    // New clients (v-o+) pass `replay: N` (typically 50) and pair this with an
                    // HTTP /chat-history pull; the id-based dedup on the frontend handles overlap.
                    // Old clients (no replay field) default to MAX_SAFE_INTEGER = full file,
                    // preserving existing behavior during rolling upgrades.
                    //
                    // Same subscribe-grace stamp as terminal_subscribe — full-history
                    // replay can total many MiB across multiple chat_message frames and
                    // would otherwise self-terminate via safeSend's bufferedAmount gate.
                    ws.__subscribeAt = Date.now();
                    {
                        const replayLimit = typeof parsed.replay === 'number' ? parsed.replay : Number.MAX_SAFE_INTEGER;
                        if (replayLimit > 0) {
                            const history = session_manager_1.sessionManager.getChatHistory(projectId);
                            const slice = replayLimit >= history.length ? history : history.slice(-replayLimit);
                            for (const block of slice) {
                                safeSend(ws, JSON.stringify({ type: 'chat_message', ...block }));
                            }
                        }
                    }
                    session_manager_1.sessionManager.registerChatListener(projectId, chatListener);
                    // Mobile/monitor clients never send `terminal_subscribe` — without this
                    // they'd never join `projectClients` (missing real-time context_update
                    // and approval broadcasts) and never receive the initial context snapshot.
                    if (!projectClients.has(projectId))
                        projectClients.set(projectId, new Set());
                    projectClients.get(projectId).add(ws);
                    {
                        const ctxData = (0, hooks_1.getContextData)(projectId);
                        if (ctxData)
                            safeSend(ws, JSON.stringify({ type: 'context_update', ...ctxData }));
                    }
                    {
                        const snap = buildSemanticSnapshot(projectId);
                        safeSend(ws, JSON.stringify({ type: 'semantic_update', ...snap }));
                    }
                    break;
            }
        }
        catch (err) {
            log.error({ err, projectId, mod: 'ws' }, 'ws message handling error');
            safeSend(ws, JSON.stringify({ type: 'error', message: 'Internal server error' }));
        }
    });
    ws.on('close', () => {
        if (authTimeout)
            clearTimeout(authTimeout);
        const clients = projectClients.get(projectId);
        if (clients) {
            clients.delete(ws);
            if (clients.size === 0)
                projectClients.delete(projectId);
        }
        session_manager_1.sessionManager.unregisterChatListener(projectId, chatListener);
    });
    ws.on('error', (err) => log.error({ err, projectId, mod: 'ws' }, 'ws connection error'));
});
server.on('upgrade', (req, socket, head) => {
    const pathname = new URL(req.url || '', 'http://localhost').pathname;
    if (!pathname.startsWith('/ws/')) {
        socket.destroy();
        return;
    }
    // Validate WebSocket Origin — local mode only allows localhost origins
    if (ACCESS_MODE === 'local') {
        const origin = req.headers.origin;
        if (origin) {
            try {
                const u = new URL(origin);
                if (u.hostname !== 'localhost' && u.hostname !== '127.0.0.1' && u.hostname !== '::1') {
                    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
                    socket.destroy();
                    return;
                }
            }
            catch {
                socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
                socket.destroy();
                return;
            }
        }
    }
    // LAN mode: reject non-private IPs on WebSocket upgrade
    if (ACCESS_MODE === 'lan') {
        const ip = req.socket.remoteAddress || '';
        if (!isPrivateIP(ip)) {
            socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
            socket.destroy();
            return;
        }
    }
    wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});
function tryListen(port, maxAttempts = 20) {
    server.once('error', (err) => {
        if (err.code === 'EADDRINUSE' && maxAttempts > 1) {
            log.info({ port, nextPort: port + 1 }, 'port in use, trying next');
            tryListen(port + 1, maxAttempts - 1);
        }
        else {
            log.error({ err, port }, 'listen failed');
            process.exit(1);
        }
    });
    server.listen(port, LISTEN_HOST, () => {
        log.info({ host: LISTEN_HOST, port, accessMode: ACCESS_MODE }, 'server listening');
        // Notify parent (Electron) of the actual port via IPC
        if (process.send) {
            process.send({ type: 'server-port', port });
        }
        // Write port file so hook commands can discover the current port
        try {
            const ccwebDir = path.join(os.homedir(), '.ccweb');
            if (!fs.existsSync(ccwebDir))
                fs.mkdirSync(ccwebDir, { recursive: true });
            fs.writeFileSync(PORT_FILE, String(port), 'utf-8');
        }
        catch (err) {
            log.error({ err, portFile: PORT_FILE }, 'failed to write port file');
        }
        hooksManager.install();
        (0, sync_scheduler_1.startSyncScheduler)();
        (0, chat_backup_1.startBackupScheduler)();
        terminal_manager_1.terminalManager.resumeAll();
        // Wire up context broadcast to project WS clients
        (0, hooks_1.setBroadcastContextUpdate)((projectId, data) => {
            const clients = projectClients.get(projectId);
            if (!clients)
                return;
            const payload = JSON.stringify({ type: 'context_update', ...data });
            for (const client of clients)
                safeSend(client, payload);
        });
    });
}
// Bootstrap: initialize structured logger BEFORE listening so the very first
// event on disk is our own "daemon starting" line. Fatal handlers installed
// here call sonic-boom's flushSync() on the pino-roll stream to guarantee
// uncaughtException stack traces hit disk before exit — the async buffer
// would otherwise swallow them. (pino v10 removed pino.final, so we flush
// the destination directly; see logger.ts installFatalHandlers.)
(async () => {
    await (0, logger_1.initLogger)();
    (0, logger_1.installFatalHandlers)();
    log.info({ accessMode: ACCESS_MODE, requestedPort: PORT, host: LISTEN_HOST }, 'daemon starting');
    tryListen(PORT);
})().catch((err) => {
    // If logger init itself fails, fall back to console before exiting.
    // This is the ONLY console.error acceptable post-init.
    console.error('[Bootstrap] failed to initialize logger:', err);
    process.exit(1);
});
// Graceful shutdown
let updateMode = false;
// SIGUSR2 = update mode: kill PTYs but keep project status as 'running'
// so resumeAll() restarts them with --continue after update
process.on('SIGUSR2', () => {
    updateMode = true;
    shutdown();
});
function shutdown() {
    log.info({ updateMode }, 'shutdown begin');
    hooksManager.uninstall();
    try {
        fs.unlinkSync(PORT_FILE);
    }
    catch { /* already gone */ }
    for (const project of (0, config_1.getProjects)()) {
        if (terminal_manager_1.terminalManager.hasTerminal(project.id)) {
            if (updateMode) {
                terminal_manager_1.terminalManager.killForUpdate(project.id);
            }
            else {
                terminal_manager_1.terminalManager.stop(project.id);
            }
        }
    }
    // Close WebSocket connections
    wss.clients.forEach((ws) => ws.close(1001, 'Server shutting down'));
    // Kill all headless chromium instances — daemon-side babysitter; without
    // this, child chrome processes survive daemon SIGTERM (zombie processes).
    session_manager_2.browserChromeSessions.destroyAll().catch((err) => log.warn({ err }, 'browser-chrome destroyAll failed'));
    server.close(async () => {
        log.info('shutdown closed');
        await (0, logger_1.flushLogger)();
        process.exit(0);
    });
    // Force exit after 5s (still flush synchronously if possible)
    setTimeout(async () => {
        log.warn('shutdown forced after 5s');
        await (0, logger_1.flushLogger)();
        process.exit(1);
    }, 5000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
exports.default = app;
//# sourceMappingURL=index.js.map