/**
 * src/server.ts (T14 + T-server-wire) — HTTP + WebSocket wiring layer.
 *
 * Responsibilities (ARCHITECTURE §3.6): static hosting; WS upgrade gate (wrong
 * path → destroy; bad Origin → 401, L3); WS connection (first frame must be
 * 'attach', M4 spawn-failure → exit(-1)+close); message routing; close → detach
 * (never kills PTY, invariant #2); SIGINT/SIGTERM → shutdown. M5: every ws.send
 * guarded by readyState === WS_OPEN. L5: maxPayload cap. wsPath from config (#8).
 *
 * v0.7 (T-server-wire) adds out-of-band side-channel routes — the terminal
 * stream stays a byte-shuttle. A1 push (/push/*, /hook/decision + C1 hold gate),
 * A4 timeline (/…/events), A5 reaper sweepStuck, B1 diff (/projects/diff), B2
 * statusLine (/hook/status), B3 worktree (/projects/worktree), B4 approve mode,
 * GET /config/ui. State-changing routes carry requireAllowedOrigin (CSRF) and
 * per-IP rate limits; loopback-only ingest keeps isLoopback.
 *
 * Style: immutable data, explicit error handling, no silent swallowing.
 */

import { createServer } from 'node:http'
import net from 'node:net'
import { URL } from 'node:url'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomUUID } from 'node:crypto'
import { stat } from 'node:fs/promises'
import { readFileSync } from 'node:fs'

import express from 'express'
import type { NextFunction, Request, Response } from 'express'
import { WebSocketServer } from 'ws'
import type { WebSocket as WsWebSocket } from 'ws'
import type { IncomingMessage } from 'node:http'

import { loadConfig } from './config.js'
import { parseClientMessage, serialize, SESSION_ID_RE } from './protocol.js'
import { isOriginAllowed } from './http/origin.js'
import {
  buildSetCookie,
  constantTimeEqual,
  cookieIsAuthed,
  isAuthEnabled,
  isHttpsRequest,
} from './http/auth.js'
import { parseHookEvent } from './http/hook.js'
import { deriveApprovalPreview } from './http/approval-preview.js'
import { listSessions } from './http/history.js'
import { buildProjects, buildProjectDetail, buildWorktreeState } from './http/projects.js'
import { openInEditor, openFileInEditor } from './http/editor.js'
import { getDiff, isPlausibleRev } from './http/diff.js'
import { getGitLog } from './http/git-log.js'
import { buildDigest, clampSince } from './http/digest.js'
import { getPrStatus } from './http/gh.js'
import { parseStatusLine } from './http/statusline.js'
import { createWorktree, removeWorktree, pruneWorktrees } from './http/worktrees.js'
import { groupSessionsByRepo } from './http/session-groups.js'
import {
  stageFiles,
  commit as gitCommit,
  push as gitPush,
  fetch as gitFetch,
} from './http/git-ops.js'
import { createSessionManager } from './session/manager.js'
import { detachWs, writeInput, setClientDims } from './session/session.js'
import { loadSubscriptionStore } from './push/subscription-store.js'
import { loadPrefsStore } from './http/prefs-store.js'
import { createPushService } from './push/push-service.js'
import { combineNotifyServices, initApns, normalizeApnsToken } from './push/apns.js'
import { initFcm, normalizeFcmToken } from './push/fcm.js'
import type {
  ApprovalPreview,
  Config,
  NotifyService,
  PermissionGate,
  PermissionMode,
  PushSubscriptionRecord,
  UiConfig,
} from './types.js'
import { WS_OPEN } from './types.js'

// ── defaults ──────────────────────────────────────────────────────────────────

const DEFAULT_COLS = 80
const DEFAULT_ROWS = 24

// Width of the leaky-bucket window for per-connection WS rate limiting (10).
const RATE_WINDOW_MS = 1000

// Max chars of a user-controlled string written to a log line (M2 log injection).
const LOG_FIELD_MAX = 200

// Per-IP sliding-window rate limits for the new state-changing routes (SEC-H9,
// review #9). Fixed security policy — not user config.
const RATE_LIMIT_WINDOW_MS = 60_000
const DECISION_RATE_MAX = 10 // POST /hook/decision ≤ 10/min/IP
const SUBSCRIBE_RATE_MAX = 5 // POST/DELETE /push/subscribe ≤ 5/min/IP
const QUEUE_RATE_MAX = 20 // W2: POST/DELETE /live-sessions/:id/queue ≤ 20/min/IP
// A: GET /orphan-sessions/:id/preview ≤ 240/min/IP — one tmux capture-pane each,
// and a full cleanup grid refreshes every visible card at once.
const ORPHAN_PREVIEW_RATE_MAX = 240
const GIT_WRITE_RATE_MAX = 30 // W4: POST /projects/git/{stage,commit} ≤ 30/min/IP
const GIT_PUSH_RATE_MAX = 6 // W4: POST /projects/git/push ≤ 6/min/IP (network-bound)
const AUTH_RATE_MAX = 10 // w5-access-token: POST /auth + GET /?token= ≤ 10/min/IP (brute-force guard)

/** Loopback-only Claude Code hook ingest paths the auth gate lets through from a
 *  loopback peer with no cookie (the token is about REMOTE access; hooks run on
 *  the host). Each handler independently re-checks isLoopback (defense in depth). */
const LOOPBACK_INGEST_PATHS: ReadonlySet<string> = new Set([
  '/hook',
  '/hook/permission',
  '/hook/status',
])

/** The four permission modes the WS approve relay accepts (B4); else undefined. */
const PERMISSION_MODES: ReadonlySet<string> = new Set<PermissionMode>([
  'default',
  'acceptEdits',
  'plan',
  'auto',
])

/** Decision JSON a PermissionRequest hook writes to stdout (H3). `mode` (B4)
 *  carries the updated permission mode back to Claude on a plan-gate approval. */
function permDecision(behavior: 'allow' | 'deny', mode?: PermissionMode): unknown {
  const decision: Record<string, unknown> = { behavior }
  if (mode !== undefined) decision['mode'] = mode
  return { hookSpecificOutput: { hookEventName: 'PermissionRequest', decision } }
}

/** Whitelist-validate an optional `approve.mode` from a raw WS frame (B4). The
 *  shared browser-safe protocol parser cannot surface it (must stay DOM-free),
 *  so the WS wiring layer validates it here against PermissionMode. Never throws. */
function parseApproveMode(raw: string): PermissionMode | undefined {
  try {
    const obj = JSON.parse(raw) as Record<string, unknown>
    const m = obj?.['mode']
    return typeof m === 'string' && PERMISSION_MODES.has(m) ? (m as PermissionMode) : undefined
  } catch {
    return undefined
  }
}

/** Per-IP sliding-window limiter (in-memory). true (and records) when under the
 *  cap for the window, false when over. Immutable per-IP arrays (filter → new). */
function createRateLimiter(maxPerWindow: number, windowMs: number): (ip: string, now: number) => boolean {
  const hits = new Map<string, number[]>()
  return (ip, now): boolean => {
    const recent = (hits.get(ip) ?? []).filter((t) => now - t < windowMs)
    if (recent.length >= maxPerWindow) {
      hits.set(ip, recent)
      return false
    }
    hits.set(ip, [...recent, now])
    return true
  }
}

/** Three-prong read-only diff path check (SEC-H7): absolute + dir + has .git. */
async function isValidGitDir(target: string): Promise<boolean> {
  if (typeof target !== 'string' || !path.isAbsolute(target)) return false
  try {
    const s = await stat(target)
    if (!s.isDirectory()) return false
    await stat(path.join(target, '.git'))
    return true
  } catch {
    return false
  }
}

/** Build a PushSubscriptionRecord from an untrusted POST body, or null. */
function toSubscriptionRecord(body: Record<string, unknown>): PushSubscriptionRecord | null {
  const endpoint = body['endpoint']
  const keys = body['keys']
  if (typeof endpoint !== 'string' || endpoint.length === 0) return null
  if (keys === null || typeof keys !== 'object') return null
  const k = keys as Record<string, unknown>
  if (typeof k['p256dh'] !== 'string' || typeof k['auth'] !== 'string') return null
  return { endpoint, keys: { p256dh: k['p256dh'], auth: k['auth'] }, createdAt: Date.now() }
}

/**
 * True for loopback peers (hooks always run on the host). Accepts the whole
 * 127.0.0.0/8 range and IPv4-mapped IPv6 forms, not just 127.0.0.1, so a peer
 * address behind a local proxy / alternate loopback alias still passes.
 */
function isLoopback(ip: string): boolean {
  if (ip === '::1') return true
  // Strip an IPv4-mapped IPv6 prefix (::ffff:127.0.0.1) down to the IPv4 part.
  const v4 = ip.startsWith('::ffff:') ? ip.slice('::ffff:'.length) : ip
  if (net.isIPv4(v4)) {
    return v4.startsWith('127.')
  }
  return false
}

/** Strip control chars and truncate a user-controlled value before logging (M2). */
function sanitizeForLog(value: unknown): string {
  return String(value)
    .slice(0, LOG_FIELD_MAX)
    // eslint-disable-next-line no-control-regex
    .replace(/[\x00-\x1f]/g, '?')
}

// ── helpers ───────────────────────────────────────────────────────────────────

/** Derive __dirname equivalent in ESM. */
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

/** Guard a ws.send so we never call send on a non-OPEN socket (M5). */
function safeSend(ws: WsWebSocket, data: string): void {
  if (ws.readyState === WS_OPEN) {
    ws.send(data)
  }
}

// ── startServer ───────────────────────────────────────────────────────────────

/**
 * Start the HTTP + WS server.
 *
 * Returns an object with a `close()` method that gracefully shuts down
 * both the HTTP server and the session manager.
 */
export function startServer(cfg: Config): { close(): Promise<void> } {
  // A1: persistent push-subscription store + VAPID-signed push sender, injected
  // into the manager (DI, M5) so the manager never imports push-service directly.
  const subStore = loadSubscriptionStore(cfg.pushStorePath, cfg.pushMaxSubs)
  const webPushService = createPushService(cfg, subStore)
  // T-iOS-20: optional APNs sender — enabled only when the APNS_* env group is
  // complete (else null + one log line, never a crash). It fans out beside
  // web-push through the SAME NotifyService seam, so every existing hook-event
  // call site below stays untouched: `pushService` is the combined fan-out.
  const apns = initApns(cfg, process.env as Record<string, string | undefined>)
  // A33: optional FCM sender (Android client) — enabled only when the FCM_* env
  // group is complete (else null + one log line, never a crash). It fans out
  // beside web-push and APNs through the SAME NotifyService seam.
  const fcm = initFcm(cfg, process.env as Record<string, string | undefined>)
  const extraNotifyServices: NotifyService[] = [
    ...(apns !== null ? [apns.service] : []),
    ...(fcm !== null ? [fcm.service] : []),
  ]
  const pushService: NotifyService =
    extraNotifyServices.length === 0
      ? webPushService
      : combineNotifyServices(webPushService, ...extraNotifyServices)
  const manager = createSessionManager(cfg, pushService)

  // v0.6 Projects: cross-device UI prefs (favourites + group collapse-state).
  const prefsStore = loadPrefsStore(cfg.prefsStorePath)

  // Per-IP rate limiters for the new state-changing routes (SEC-H9).
  const decisionLimiter = createRateLimiter(DECISION_RATE_MAX, RATE_LIMIT_WINDOW_MS)
  const subscribeLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)
  const queueLimiter = createRateLimiter(QUEUE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W2
  // A: /orphan-sessions/:id/preview spawns one `tmux capture-pane` per call, and a
  // cleanup grid can ask for many at once — its own bucket, sized for a full grid
  // refresh, so it never eats the queue or git budgets.
  const orphanLimiter = createRateLimiter(ORPHAN_PREVIEW_RATE_MAX, RATE_LIMIT_WINDOW_MS)
  const gitWriteLimiter = createRateLimiter(GIT_WRITE_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 stage/commit
  const gitPushLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // W4 push
  // w6/G2 fetch gets its OWN bucket: it is the panel's refresh button, so a burst
  // of fetches must never eat the budget a real push needs.
  const gitFetchLimiter = createRateLimiter(GIT_PUSH_RATE_MAX, RATE_LIMIT_WINDOW_MS)
  const authLimiter = createRateLimiter(AUTH_RATE_MAX, RATE_LIMIT_WINDOW_MS) // w5-access-token

  // W2: per-session settle timers for the idle-drain. A Stop/SessionEnd hook
  // schedules a debounced drain of one queue entry after cfg.queueSettleMs, so
  // the injected text lands once the prompt is ready. Cleared on shutdown; the
  // fired callback re-checks idle + a stable lastOutputAt cursor before draining.
  const drainTimers = new Map<string, ReturnType<typeof setTimeout>>()

  /**
   * W2: schedule (or reschedule) a single idle-drain for `sessionId`. Debounced
   * per session — each Stop clears any pending timer and restarts the window, so
   * flapping Stop events fire at most one drain. On fire we drain ONE entry only
   * if the session is still idle, unexited, and its lastOutputAt has not moved
   * since scheduling (no output mid-settle = Claude really finished, not
   * mid-render). One entry per idle → natural pacing (the injected prompt makes
   * Claude work again, and its next Stop drains the next entry).
   */
  function scheduleDrain(sessionId: string): void {
    if (!cfg.queueEnabled) return
    const session = manager.get(sessionId)
    if (session === undefined || session.exitedAt !== null) return
    if (session.queue.length === 0) return

    const existing = drainTimers.get(sessionId)
    if (existing !== undefined) clearTimeout(existing)

    const outputCursor = session.lastOutputAt
    const timer = setTimeout(() => {
      drainTimers.delete(sessionId)
      const s = manager.get(sessionId)
      if (s === undefined || s.exitedAt !== null) return
      // Settle guard: new output since scheduling → Claude is still active; skip
      // (a later genuine Stop reschedules). Also require the idle status to hold.
      if (s.lastOutputAt !== outputCursor) return
      if (s.claudeStatus !== 'idle') return
      manager.drainOne(sessionId)
    }, cfg.queueSettleMs)
    // Don't let a pending drain keep the process alive; a stale fire is harmless.
    timer.unref()
    drainTimers.set(sessionId, timer)
  }

  // H3/A1: held PermissionRequest responses, keyed by sessionId. `res` is parked
  // until approve/reject or timeout. `token`+`expiresAt` = the per-decision
  // capability a remote device presents at /hook/decision (SEC-C1/M1); `gate`
  // (B4) lets a late-joining client re-render the right affordance (M3).
  interface PendingApproval {
    res: Response
    timer: ReturnType<typeof setTimeout>
    token: string
    expiresAt: number
    gate: PermissionGate
    /** W1: bounded command/diff preview of the held tool, re-sent to late joiners
     *  exactly like `gate`. Undefined for non-previewable tools (e.g. ExitPlanMode). */
    preview?: ApprovalPreview
  }
  const pendingApprovals = new Map<string, PendingApproval>()

  function resolvePending(sessionId: string, decision: unknown): void {
    const p = pendingApprovals.get(sessionId)
    if (p === undefined) return
    clearTimeout(p.timer)
    pendingApprovals.delete(sessionId)
    p.res.json(decision)
  }

  // ── Express static hosting ────────────────────────────────────────────────
  const app = express()

  // ── Security headers (Sec H2) ─────────────────────────────────────────────
  // Hand-rolled (no helmet) to keep deps minimal. CSP is conservative; the
  // 'unsafe-inline' on style-src is required because xterm.js injects inline
  // styles for the terminal viewport (verified: dropping it breaks rendering).
  app.use((_req, res, next) => {
    res.setHeader('X-Content-Type-Options', 'nosniff')
    res.setHeader('X-Frame-Options', 'DENY')
    res.setHeader('Referrer-Policy', 'no-referrer')
    res.setHeader(
      'Content-Security-Policy',
      "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:",
    )
    next()
  })

  const publicDir = path.join(__dirname, '..', 'public')

  // ── w5-access-token: optional shared-token gate (ADDITIVE — in front of the
  // Origin/CSRF model, never replacing it). Unset WEBTERM_TOKEN ⇒ DISABLED ⇒
  // every request falls straight through `next()` (LAN zero-config unchanged).
  //
  // HONEST BOUNDARY: a bar-raiser, NOT a TLS/Tailscale substitute. On bare ws://
  // the cookie/token travel in cleartext and are replayable by a LAN sniffer;
  // this only meaningfully hardens the relay/tunnel (TLS-terminated) path. See
  // src/http/auth.ts and TECH_DOC §7 ("never port-forward this raw").

  // Startup-cached login page (served by GET /login BEFORE the gate, so it is
  // always reachable while unauthed). Fallback keeps the server alive if the
  // asset is missing rather than crashing at boot.
  const loginHtml = ((): string => {
    try {
      return readFileSync(path.join(publicDir, 'login.html'), 'utf8')
    } catch {
      return '<!doctype html><meta charset="utf-8"><title>Sign in</title><form method="POST" action="/auth"><input type="password" name="token" autofocus><button>Unlock</button></form>'
    }
  })()

  /** True for a top-level browser navigation (wants HTML) vs an XHR/fetch (wants JSON). */
  function acceptsHtml(req: Request): boolean {
    const accept = req.headers['accept']
    return typeof accept === 'string' && accept.includes('text/html')
  }

  /** Redirect target for the `?token=` bootstrap: same path with `token` stripped
   *  so no secret is left in browser history / Referer. */
  function pathWithoutToken(req: Request): string {
    const u = new URL(req.originalUrl, 'http://localhost')
    u.searchParams.delete('token')
    const qs = u.searchParams.toString()
    return u.pathname + (qs ? `?${qs}` : '')
  }

  // GET /login — always reachable. On ?e=1 reveal the error banner (no JS: the
  // CSP is `script-src 'self'`, so the toggle is a server-side class swap).
  app.get('/login', (req, res) => {
    const html =
      req.query['e'] === '1'
        ? loginHtml.replace('ERRSTATE', 'show-error')
        : loginHtml.replace('ERRSTATE', '')
    res.type('html').send(html)
  })

  // POST /auth — always reachable, rate-limited. Accepts urlencoded (native form)
  // AND json (XHR). Valid ⇒ Set-Cookie + 302→/ (form) or 204 (XHR). Invalid ⇒
  // 302→/login?e=1 (form) or 401 (XHR). Over the limit ⇒ 429.
  app.post(
    '/auth',
    express.urlencoded({ extended: false, limit: '1kb' }),
    express.json({ limit: '1kb' }),
    (req, res) => {
      const ip = req.socket.remoteAddress ?? 'unknown'
      if (!authLimiter(ip, Date.now())) {
        res.status(429).end()
        return
      }
      // Disabled ⇒ nothing to authenticate; acknowledge without setting a cookie.
      if (!isAuthEnabled(cfg)) {
        res.status(204).end()
        return
      }
      const body = (req.body ?? {}) as Record<string, unknown>
      const candidate = typeof body['token'] === 'string' ? body['token'] : ''
      const isForm = acceptsHtml(req)
      if (constantTimeEqual(candidate, cfg.webtermToken)) {
        res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) }))
        if (isForm) res.redirect(302, '/')
        else res.status(204).end()
        return
      }
      if (isForm) res.redirect(302, '/login?e=1')
      else res.status(401).json({ error: 'invalid token' })
    },
  )

  // The global auth gate. Runs after the security-headers middleware and BEFORE
  // express.static + every API route below, so ONE central policy point covers
  // the whole app (the origin.ts idiom). Allow-list, in order:
  function authGate(req: Request, res: Response, next: NextFunction): void {
    // 1. Disabled → everything open (zero-config LAN unchanged).
    if (!isAuthEnabled(cfg)) {
      next()
      return
    }
    // 2. Loopback-only Claude Code hook ingest (POST /hook, /hook/permission,
    //    /hook/status) has no cookie and must keep working — the token is about
    //    REMOTE access, not the host's own hook side-channel. DEVIATION from the
    //    plan's blanket `isLoopback → next()`: scoped to these three ingest paths
    //    so the gate still protects every OTHER route even from a loopback peer
    //    (a token-enabled deploy then also gates the local browser — strictly
    //    more secure, and testable from a 127.0.0.1 harness).
    if (LOOPBACK_INGEST_PATHS.has(req.path) && isLoopback(req.socket.remoteAddress ?? '')) {
      next()
      return
    }
    // 3. GET with ?token= bootstrap link → validate (rate-limited), set cookie +
    //    redirect to the same path with the token stripped, else → /login.
    if (req.method === 'GET' && typeof req.query['token'] === 'string' && req.query['token'] !== '') {
      const ip = req.socket.remoteAddress ?? 'unknown'
      if (!authLimiter(ip, Date.now())) {
        res.status(429).end()
        return
      }
      if (constantTimeEqual(req.query['token'], cfg.webtermToken)) {
        res.setHeader('Set-Cookie', buildSetCookie(cfg, { secure: isHttpsRequest(req) }))
        res.redirect(302, pathWithoutToken(req))
      } else {
        res.redirect(302, '/login')
      }
      return
    }
    // 4. Valid cookie → allow.
    if (cookieIsAuthed(cfg, req.headers['cookie'])) {
      next()
      return
    }
    // 5. Unauthed: HTML navigation → login page; XHR/other → 401 JSON.
    if (acceptsHtml(req)) res.redirect(302, '/login')
    else res.status(401).json({ error: 'authentication required' })
  }
  app.use(authGate)

  // Serve the entire public/ directory (including public/build/ esbuild output).
  // Note: `npm run build:web` must be run before `npm start` to populate public/build/.
  app.use(express.static(publicDir))

  // ── Claude Code history (O2) — list past sessions for the resume browser ──
  // SECURITY (Sec H3, accepted risk): this returns Claude session cwds + the
  // first ~120 chars of each first prompt + resumable UUIDs, UNAUTHENTICATED, to
  // any LAN device. That is consistent with the app's threat model — this whole
  // app hands a full shell to anyone who can reach the port (no auth, LAN-only,
  // never public-internet). Deploy behind Tailscale. See TECH_DOC §7.
  app.get('/sessions', async (_req, res) => {
    res.json(await listSessions(50))
  })

  // ── Live sessions (v0.4) — running server sessions, for multi-device discovery.
  // Any LAN device opening the app fetches this to show the host's sessions as tabs.
  app.get('/live-sessions', (_req, res) => {
    res.json(manager.list())
  })

  // ── W5 fan-out board: running sessions clustered by repo (read-only) ──────
  // Pure string grouping over manager.list() (no git/FS work); same threat model
  // as /live-sessions and /digest → no Origin guard. Registered BEFORE the
  // /live-sessions/:id routes so "grouped" is never captured as an :id.
  app.get('/live-sessions/grouped', (_req, res) => {
    res.json(groupSessionsByRepo(manager.list()))
  })

  // ── W3 quick-wins (c): "while you were away" reconnect digest (read-only) ──
  // A pure read-side aggregate over manager.list() + in-memory telemetry/status;
  // no Origin guard (same threat model as /live-sessions). `?since=<epochMs>` is
  // the client's last-seen watermark (bad/absent → 0 = "everything is new").
  app.get('/digest', (req, res) => {
    const since = clampSince(req.query['since'])
    res.json(buildDigest(manager.list(), since))
  })

  // Projects (v0.6 Project Manager) — discovery-only; no Origin guard (read-only, like /live-sessions).
  app.get('/projects', async (_req, res) => {
    try {
      res.json(await buildProjects(cfg, manager.list()))
    } catch (err) {
      console.error('[server] /projects failed:', err instanceof Error ? err.message : String(err))
      res.json([])
    }
  })

  // Projects UI prefs (v0.6) — favourites + group collapse-state, cross-device.
  // GET is read-only (no secrets, just paths/booleans) → no Origin guard, like /projects.
  app.get('/prefs', (_req, res) => {
    res.json(prefsStore.get())
  })
  // PUT replaces the whole prefs blob. State-changing → Origin guard (CSRF, like
  // /open-in-editor). Body is sanitized inside prefsStore.set (never trust it).
  app.put('/prefs', express.json({ limit: '64kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    try {
      prefsStore.set(req.body)
      await prefsStore.persist()
      res.json(prefsStore.get())
    } catch (err) {
      console.error('[server] PUT /prefs failed:', err instanceof Error ? err.message : String(err))
      res.status(500).json({ error: 'failed to save prefs' })
    }
  })

  // Project detail (v0.6) — branch/worktrees + running sessions for one repo.
  // Read-only (git branch/status/worktree-list); same threat model as /projects.
  // ?path must be an absolute existing directory (validated in buildProjectDetail).
  // w6/G7 — one worktree's git state, fetched lazily per row. Read-only, and
  // deliberately narrower than /projects/detail: a row shows branch/sync/dirty,
  // so listing worktrees and reading CLAUDE.md for each of N rows would spend
  // spawns on data nothing renders.
  app.get('/projects/worktree/state', async (req, res) => {
    const target = req.query['path']
    if (typeof target !== 'string' || target === '') {
      res.status(400).json({ error: 'path query parameter is required' })
      return
    }
    try {
      const state = await buildWorktreeState(cfg, target)
      if (state === null) {
        res.status(404).json({ error: 'worktree not found' })
        return
      }
      res.json(state)
    } catch (err) {
      console.error(
        '[server] /projects/worktree/state failed:',
        err instanceof Error ? err.message : String(err),
      )
      res.status(500).json({ error: 'failed to read worktree state' })
    }
  })

  app.get('/projects/detail', async (req, res) => {
    const target = req.query['path']
    if (typeof target !== 'string' || target === '') {
      res.status(400).json({ error: 'path query parameter is required' })
      return
    }
    try {
      const detail = await buildProjectDetail(cfg, target, manager.list())
      if (detail === null) {
        res.status(404).json({ error: 'project not found' })
        return
      }
      res.json(detail)
    } catch (err) {
      console.error('[server] /projects/detail failed:', err instanceof Error ? err.message : String(err))
      res.status(500).json({ error: 'failed to read project detail' })
    }
  })

  // Preview (v0.4 manage page) — the tail of a session's scrollback so the grid
  // can render a live read-only thumbnail of its current screen. No client/attach.
  // ── Orphan tmux sessions (A) — `web_*` tmux sessions this server does NOT
  // track, because they outlived the process that created them. Read-only routes
  // carry no Origin guard (same threat model as /live-sessions); the DELETEs do,
  // and every route re-validates the id via manager, which requires UUID v4 and
  // refuses anything the table already owns.
  //
  // Deliberately a separate path from /live-sessions rather than an extra field on
  // it: an orphan supports none of the live-session operations (no replay, status,
  // telemetry or inject queue), and the Android/iOS clients decode
  // /live-sessions — adding a variant shape there would break them.
  // Rate-limited even though it is read-only: unlike the other read routes this one
  // spawns a subprocess, so an unbounded caller is a spawn loop against the host.
  app.get('/orphan-sessions', async (req, res) => {
    if (!orphanLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).end()
      return
    }
    res.json(await manager.listOrphans())
  })

  // How many the bulk cleanup would kill, so the client can confirm with a real
  // number instead of counting the cards it happens to be showing.
  app.get('/orphan-sessions/count-idle', async (req, res) => {
    if (!orphanLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).end()
      return
    }
    const days = Number(req.query['idleDays'])
    if (!Number.isFinite(days) || days < 1) {
      res.status(400).json({ error: 'idleDays must be a number >= 1' })
      return
    }
    res.json({ count: await manager.countOrphansIdleSince(Date.now() - days * 86_400_000) })
  })

  // Each call spawns a short-lived `tmux capture-pane`, so it gets the same per-IP
  // bucket discipline as the other process-spawning routes.
  app.get('/orphan-sessions/:id/preview', async (req, res) => {
    if (!orphanLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).end()
      return
    }
    const id = req.params.id
    if (!SESSION_ID_RE.test(id)) {
      res.status(400).json({ error: 'invalid session id' })
      return
    }
    const data = await manager.captureOrphan(id)
    if (data === null) {
      res.status(404).end()
      return
    }
    // Same shape as the live preview so the frontend renders both with one path.
    res.json({ id, cols: 0, rows: 0, data })
  })

  // Bulk cleanup — kill UNATTACHED orphans idle longer than `idleDays`. Registered
  // BEFORE /orphan-sessions/:id so it is not captured as an :id. `idleDays` is
  // required and must be >= 1: there is no "delete everything" form of this route.
  app.delete('/orphan-sessions', async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    const days = Number(req.query['idleDays'])
    if (!Number.isFinite(days) || days < 1) {
      res.status(400).json({ error: 'idleDays must be a number >= 1' })
      return
    }
    const killed = await manager.killOrphansIdleSince(Date.now() - days * 86_400_000)
    res.json({ killed: killed.length, ids: killed })
  })

  app.delete('/orphan-sessions/:id', (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    const id = req.params.id
    if (!SESSION_ID_RE.test(id)) {
      res.status(400).json({ error: 'invalid session id' })
      return
    }
    res.status(manager.killOrphan(id) ? 204 : 404).end()
  })

  app.get('/live-sessions/:id/preview', (req, res) => {
    const session = manager.get(req.params.id)
    if (session === undefined) {
      res.status(404).end()
      return
    }
    res.json({
      id: session.meta.id,
      cols: session.pty.cols,
      rows: session.pty.rows,
      data: session.buffer.tail(cfg.previewBytes),
    })
  })

  // ── W2 inject queue: enqueue a follow-up prompt fired on next idle ────────
  // State-changing (causes shell input on idle) → Origin guard (CSRF) + per-IP
  // rate limit. Body: { text: string, appendEnter?: boolean }. Bytes are stored
  // and later injected VERBATIM (byte-shuttle) — bounded in size and count, never
  // parsed as a shell command. `text` + optional trailing \r is capped by
  // queueItemMaxBytes; the queue depth is capped by queueMaxItems.
  app.post('/live-sessions/:id/queue', express.json({ limit: '16kb' }), (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!cfg.queueEnabled) {
      res.status(503).json({ error: 'queue disabled' })
      return
    }
    if (!queueLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).end()
      return
    }
    const id = req.params.id
    if (!SESSION_ID_RE.test(id)) {
      res.status(400).json({ error: 'invalid session id' })
      return
    }
    const body = (req.body ?? {}) as Record<string, unknown>
    const rawText = body['text']
    if (typeof rawText !== 'string' || rawText.length === 0) {
      res.status(400).json({ error: 'text must be a non-empty string' })
      return
    }
    // FE decides Enter (mirrors quick-reply's appendEnter): materialize the exact
    // bytes to inject here so the stored entry is byte-identical to a keystroke.
    const text = body['appendEnter'] === true ? rawText + '\r' : rawText
    if (Buffer.byteLength(text, 'utf8') > cfg.queueItemMaxBytes) {
      res.status(413).json({ error: 'text too large' })
      return
    }
    const result = manager.enqueueFollowup(id, text)
    if (!result.ok) {
      // full → 409 (never silently drop); unknown/exited → 404.
      res.status(result.reason === 'full' ? 409 : 404).json({ error: result.reason })
      return
    }
    res.json({ length: result.length })
  })

  // Read-only queue view (depth + the user's own queued text). No Origin guard —
  // same threat model as GET /live-sessions.
  app.get('/live-sessions/:id/queue', (req, res) => {
    const session = manager.get(req.params.id)
    if (session === undefined) {
      res.status(404).end()
      return
    }
    res.json({ length: session.queue.length, items: [...session.queue] })
  })

  // Cancel all pending entries (escape hatch). State-changing → Origin guard.
  app.delete('/live-sessions/:id/queue', (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!manager.clearQueue(req.params.id)) {
      res.status(404).end()
      return
    }
    res.json({ length: 0 })
  })

  // CSRF guard for the state-changing DELETE routes (Arch 5b / Sec H2): the WS
  // upgrade checks Origin, but plain HTTP routes don't — without this, a foreign
  // page could fire a no-preflight DELETE and Kill-All sessions. Reuse the same
  // Origin whitelist; reject missing/foreign Origin with 403.
  function requireAllowedOrigin(req: IncomingMessage, res: Response): boolean {
    const origin = req.headers['origin'] as string | undefined
    if (!isOriginAllowed(origin, cfg.allowedOrigins)) {
      res.status(403).end()
      return false
    }
    return true
  }

  // Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action.
  app.delete('/live-sessions', (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    const onlyDetached = req.query['detached'] === '1'
    let killed = 0
    for (const s of manager.list()) {
      if (onlyDetached && s.clientCount > 0) continue
      if (manager.killById(s.id)) killed += 1
    }
    res.json({ killed })
  })

  // Kill one session by id — manage page per-row action.
  app.delete('/live-sessions/:id', (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    const ok = manager.killById(req.params.id)
    res.status(ok ? 204 : 404).end()
  })

  // Open a project in the host's desktop editor (v0.6 Projects panel — VS Code
  // logo). State-changing (spawns a GUI process), so it carries the same Origin
  // guard as the DELETE routes. The path is validated + passed via execFile (no
  // shell) in openInEditor / openFileInEditor.
  //
  // W1: two modes on one route. `file` present ⇒ open that FILE at `line` (a
  // clicked terminal path like `src/app.ts:42`); else the original directory mode
  // (`path`). Discriminated by body shape so the Projects panel is unchanged.
  app.post('/open-in-editor', express.json({ limit: '4kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    const body = (req.body ?? {}) as Record<string, unknown>
    const result =
      body['file'] !== undefined
        ? await openFileInEditor(cfg, body['file'], body['line'])
        : await openInEditor(cfg, body['path'])
    if (result.ok) {
      res.status(204).end()
      return
    }
    res.status(result.status).json({ error: result.error })
  })

  // ── Claude Code hook side-channel (H2) ────────────────────────────────────
  // Hooks running inside a spawned shell POST status here (loopback only — the
  // shell runs on the host). sessionId arrives in the X-Webterm-Session header.
  app.post('/hook', express.json({ limit: '64kb' }), (req, res) => {
    if (!isLoopback(req.socket.remoteAddress ?? '')) {
      res.status(403).end()
      return
    }
    const body = (req.body ?? {}) as Record<string, unknown>
    const ev = parseHookEvent(req.header('x-webterm-session'), req.body)
    if (ev === null) {
      res.status(400).end()
      return
    }
    // A4: pass event class + tool name so the manager appends a sanitized
    // timeline entry. Not a held gate → pending/gate stay undefined.
    const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
    manager.handleHookEvent(ev.sessionId, ev.status, ev.detail, undefined, undefined, ev.eventClass, tool)
    // A1: DONE push on terminal-completing events (best-effort; honors NOTIFY_DONE).
    if (ev.eventClass === 'Stop' || ev.eventClass === 'SessionEnd') {
      const session = manager.get(ev.sessionId)
      if (session !== undefined) void pushService.notify(session, 'done')
      // W2: Claude just went idle — schedule a debounced drain of the next queued
      // follow-up (fires after the settle delay iff still idle + output stable).
      scheduleDrain(ev.sessionId)
    }
    res.status(204).end()
  })

  // H3/A1: PermissionRequest hook — held until the client approves/rejects. The
  // hook's curl writes our response (the decision JSON) to stdout for Claude.
  app.post('/hook/permission', express.json({ limit: '64kb' }), (req, res) => {
    if (!isLoopback(req.socket.remoteAddress ?? '')) {
      res.status(403).end()
      return
    }
    const sessionId = req.header('x-webterm-session')
    const body = (req.body ?? {}) as Record<string, unknown>
    const tool = typeof body['tool_name'] === 'string' ? body['tool_name'] : undefined
    const session = typeof sessionId === 'string' ? manager.get(sessionId) : undefined

    if (sessionId === undefined || session === undefined) {
      res.json({}) // no such session → let Claude prompt itself
      return
    }

    // C1 hold gate (SEC-C2): hold if a client is watching OR push is enabled with
    // ≥1 subscription (so "walk away with zero tabs" still triggers lock-screen
    // approval); otherwise fall through to Claude's own prompt.
    const hasWatcher = session.clients.size > 0
    // A web-push subscription OR a registered APNs device token counts as a
    // reachable "walked-away" device (T-iOS-20).
    const hasPushTarget =
      (webPushService.isEnabled() && subStore.list().length > 0) ||
      (apns !== null && apns.store.list().length > 0) ||
      (fcm !== null && fcm.store.list().length > 0)
    if (!hasWatcher && !hasPushTarget) {
      res.json({})
      return
    }

    // B4: an ExitPlanMode request is a 'plan' gate (three-way), else a 'tool' gate.
    const gate: PermissionGate = tool === 'ExitPlanMode' ? 'plan' : 'tool'

    // W1: derive a bounded, sanitized command/diff preview from the untrusted
    // tool_input so the approve/reject bar shows WHAT will run (never blind).
    // Non-previewable tools / malformed input → undefined (name-only bar).
    const preview = deriveApprovalPreview(tool, body['tool_input']) ?? undefined

    resolvePending(sessionId, {}) // clear any stale hold for this session
    const token = randomUUID()
    const expiresAt = Date.now() + cfg.decisionTokenTtlMs
    const timer = setTimeout(() => {
      pendingApprovals.delete(sessionId)
      res.json({}) // timeout → fall back to Claude's interactive prompt
      manager.handleHookEvent(sessionId, 'idle')
    }, cfg.permTimeoutMs)
    pendingApprovals.set(sessionId, {
      res,
      timer,
      token,
      expiresAt,
      gate,
      ...(preview !== undefined ? { preview } : {}),
    })

    // A1: push the lock-screen approval (carrying the capability token) to every
    // subscribed device. Best-effort — failures are logged inside push-service.
    void pushService.notify(session, 'needs-input', token)

    // Show the approve/reject affordance (+ W1 preview) on every attached client.
    manager.handleHookEvent(sessionId, 'waiting', tool, true, gate, undefined, undefined, preview)
  })

  // ── A1 push subscription + lock-screen decision routes ────────────────────

  // Public VAPID key for SW subscription. 503 when push is disabled (graceful).
  app.get('/push/vapid-key', (_req, res) => {
    if (!webPushService.isEnabled() || cfg.vapidPublicKey === undefined) {
      res.status(503).end()
      return
    }
    res.json({ publicKey: cfg.vapidPublicKey })
  })

  // Store a browser PushSubscription (state-changing → Origin guard + rate limit).
  app.post('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).end()
      return
    }
    const record = toSubscriptionRecord((req.body ?? {}) as Record<string, unknown>)
    if (record === null) {
      res.status(400).end()
      return
    }
    try {
      subStore.add(record)
      await subStore.persist()
    } catch {
      res.status(400).end()
      return
    }
    res.status(204).end()
  })

  // Remove a stored subscription (state-changing → Origin guard + rate limit).
  app.delete('/push/subscribe', express.json({ limit: '8kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!subscribeLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).end()
      return
    }
    const endpoint = (req.body ?? {}) as Record<string, unknown>
    const value = endpoint['endpoint']
    if (typeof value !== 'string' || value.length === 0) {
      res.status(400).end()
      return
    }
    subStore.remove(value)
    await subStore.persist()
    res.status(204).end()
  })

  // ── T-iOS-20: APNs device-token registry (iOS client) ─────────────────────
  // Mounted only when the APNS_* env group is complete (disabled ⇒ 404).
  // Frozen wire shape: POST/DELETE /push/apns-token {token: 64–160 hex chars,
  // lowercase-normalized} → 204 idempotent upsert/remove; 400 invalid; Origin
  // guard 403; 5/min/IP 429; body ≤ 8kb — mirrors /push/subscribe above.
  if (apns !== null) {
    const apnsStore = apns.store
    const apnsTokenLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)

    app.post('/push/apns-token', express.json({ limit: '8kb' }), async (req, res) => {
      if (!requireAllowedOrigin(req, res)) return
      if (!apnsTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
        res.status(429).end()
        return
      }
      const token = normalizeApnsToken(((req.body ?? {}) as Record<string, unknown>)['token'])
      if (token === null) {
        res.status(400).end()
        return
      }
      try {
        apnsStore.add({ token, createdAt: Date.now() })
        await apnsStore.persist()
      } catch {
        res.status(400).end()
        return
      }
      res.status(204).end()
    })

    app.delete('/push/apns-token', express.json({ limit: '8kb' }), async (req, res) => {
      if (!requireAllowedOrigin(req, res)) return
      if (!apnsTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
        res.status(429).end()
        return
      }
      const token = normalizeApnsToken(((req.body ?? {}) as Record<string, unknown>)['token'])
      if (token === null) {
        res.status(400).end()
        return
      }
      apnsStore.remove(token) // idempotent: unknown token still 204
      await apnsStore.persist()
      res.status(204).end()
    })
  }

  // ── A33: FCM device-token registry (Android client) ───────────────────────
  // Mounted only when the FCM_* env group is complete (disabled ⇒ 404). Frozen
  // wire shape: POST/DELETE /push/fcm-token {token: loose base64url+':' string,
  // ≤ bound} → 204 idempotent upsert/remove; 400 invalid; Origin guard 403;
  // 5/min/IP 429; body ≤ 8kb — mirrors /push/apns-token above.
  if (fcm !== null) {
    const fcmStore = fcm.store
    const fcmTokenLimiter = createRateLimiter(SUBSCRIBE_RATE_MAX, RATE_LIMIT_WINDOW_MS)

    app.post('/push/fcm-token', express.json({ limit: '8kb' }), async (req, res) => {
      if (!requireAllowedOrigin(req, res)) return
      if (!fcmTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
        res.status(429).end()
        return
      }
      const token = normalizeFcmToken(((req.body ?? {}) as Record<string, unknown>)['token'])
      if (token === null) {
        res.status(400).end()
        return
      }
      try {
        fcmStore.add({ token, createdAt: Date.now() })
        await fcmStore.persist()
      } catch {
        res.status(400).end()
        return
      }
      res.status(204).end()
    })

    app.delete('/push/fcm-token', express.json({ limit: '8kb' }), async (req, res) => {
      if (!requireAllowedOrigin(req, res)) return
      if (!fcmTokenLimiter(req.socket.remoteAddress ?? '', Date.now())) {
        res.status(429).end()
        return
      }
      const token = normalizeFcmToken(((req.body ?? {}) as Record<string, unknown>)['token'])
      if (token === null) {
        res.status(400).end()
        return
      }
      fcmStore.remove(token) // idempotent: unknown token still 204
      await fcmStore.persist()
      res.status(204).end()
    })
  }

  // Remote lock-screen Allow/Deny (SEC-C1): called by a REMOTE device's SW (not
  // loopback) → guard with Origin + a per-decision capability token (stale/
  // mismatch → 403).
  app.post('/hook/decision', express.json({ limit: '4kb' }), (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!decisionLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).end()
      return
    }
    const body = (req.body ?? {}) as Record<string, unknown>
    const sessionId = typeof body['sessionId'] === 'string' ? body['sessionId'] : undefined
    const decision = body['decision']
    const token = typeof body['token'] === 'string' ? body['token'] : undefined
    if (sessionId === undefined || token === undefined || (decision !== 'allow' && decision !== 'deny')) {
      res.status(400).end()
      return
    }
    const pending = pendingApprovals.get(sessionId)
    if (pending === undefined || pending.token !== token || Date.now() > pending.expiresAt) {
      res.status(403).end() // SEC-C1/M1: missing / mismatched / stale token
      return
    }
    resolvePending(sessionId, permDecision(decision))
    manager.handleHookEvent(sessionId, 'working', undefined, false)
    res.status(204).end() // SW does not read the body (review #14)
  })

  // ── A4 timeline (read-only discovery, no Origin guard) ────────────────────
  app.get('/live-sessions/:id/events', (req, res) => {
    if (!cfg.timelineEnabled) {
      res.json([]) // AC-A4.6: capture/serve disabled → empty
      return
    }
    const session = manager.get(req.params.id)
    if (session === undefined) {
      res.status(404).end()
      return
    }
    res.json(session.timeline)
  })

  // ── B1 read-only git diff (no Origin guard; same threat model as /projects) ─
  app.get('/projects/diff', async (req, res) => {
    const target = req.query['path']
    if (typeof target !== 'string' || target === '') {
      res.status(400).json({ error: 'path query parameter is required' })
      return
    }
    if (!(await isValidGitDir(target))) {
      res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
      return
    }
    // FR-B1.9: optional ?base=<rev> — diff HEAD against a base commit-ish. The
    // rev-parse allow-list (in getDiff) is the real defense; isPlausibleRev
    // rejects flag-injection/junk fast with a 400 before any git call.
    const rawBase = req.query['base']
    const base = typeof rawBase === 'string' && rawBase !== '' ? rawBase : undefined
    if (base !== undefined && !isPlausibleRev(base)) {
      res.status(400).json({ error: 'invalid base revision' })
      return
    }
    try {
      const staged = req.query['staged'] === '1'
      res.json(await getDiff(target, { staged, base, cfg }))
    } catch (err) {
      console.error('[server] /projects/diff failed:', err instanceof Error ? err.message : String(err))
      res.status(500).json({ error: 'failed to read diff' })
    }
  })

  // ── W3 quick-wins (d): read-only recent-commit log (no Origin guard) ──────
  // Same three-prong path validation (isValidGitDir, SEC-H7) as /projects/diff.
  // `?n=<int>` is clamped to [1, GIT_LOG_MAX] inside getGitLog; `path` missing →
  // 400, non-git dir → 404, git failure → best-effort empty (getGitLog) → 200.
  app.get('/projects/log', async (req, res) => {
    const target = req.query['path']
    if (typeof target !== 'string' || target === '') {
      res.status(400).json({ error: 'path query parameter is required' })
      return
    }
    if (!(await isValidGitDir(target))) {
      res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
      return
    }
    const rawN = req.query['n']
    const n = typeof rawN === 'string' ? Number.parseInt(rawN, 10) : undefined
    try {
      res.json(await getGitLog(target, { n, timeoutMs: cfg.diffTimeoutMs }))
    } catch (err) {
      console.error('[server] /projects/log failed:', err instanceof Error ? err.message : String(err))
      res.status(500).json({ error: 'failed to read git log' })
    }
  })

  // ── W3 read-only PR + CI status (no Origin guard; same threat model as /projects) ─
  // Out-of-band side-channel: spawns the host's `gh` CLI to read the current
  // branch's PR + statusCheckRollup. Unlike the local git side-channels, gh makes
  // a NETWORK call to GitHub using the host's own gh/GH_TOKEN credential — this
  // route NEVER accepts or forwards a token, only triggers gh's own auth, and
  // GH_ENABLED=0 disables it entirely. Always 200 on a valid git dir: every
  // degrade (gh missing / unauthed / no PR / disabled) lives in the response body
  // (PrStatus.availability), not the HTTP status, so the FE renders one chip.
  app.get('/projects/pr', async (req, res) => {
    const target = req.query['path']
    if (typeof target !== 'string' || target === '') {
      res.status(400).json({ error: 'path query parameter is required' })
      return
    }
    if (!(await isValidGitDir(target))) {
      res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
      return
    }
    try {
      res.json(await getPrStatus(target, cfg))
    } catch (err) {
      console.error('[server] /projects/pr failed:', err instanceof Error ? err.message : String(err))
      res.status(500).json({ error: 'failed to read PR status' })
    }
  })

  // ── B2 statusLine telemetry ingest (loopback only, SEC-H1) ────────────────
  app.post('/hook/status', express.json({ limit: '64kb' }), (req, res) => {
    if (!isLoopback(req.socket.remoteAddress ?? '')) {
      res.status(403).end()
      return
    }
    const telemetry = parseStatusLine(req.body)
    if (telemetry === null) {
      res.status(400).end()
      return
    }
    const sessionId = req.header('x-webterm-session')
    if (typeof sessionId === 'string' && sessionId.length > 0) {
      manager.handleStatusLine(sessionId, telemetry)
    }
    res.status(204).end()
  })

  // ── B3 create a git worktree (the only write-to-disk feature → Origin guard) ─
  app.post('/projects/worktree', express.json({ limit: '4kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!cfg.worktreeEnabled) {
      res.status(403).json({ error: 'Worktree creation is disabled.' })
      return
    }
    const body = (req.body ?? {}) as Record<string, unknown>
    const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
    const branch = typeof body['branch'] === 'string' ? body['branch'] : undefined
    const base = typeof body['base'] === 'string' && body['base'] !== '' ? body['base'] : undefined
    if (repoPath === undefined || branch === undefined) {
      res.status(400).json({ error: 'path and branch are required' })
      return
    }
    // Audit (B3.4): who/what, sanitized + truncated (never raw control chars).
    console.error(`[server] worktree create: branch=${sanitizeForLog(branch)} path=${sanitizeForLog(repoPath)}`)
    const result = await createWorktree(repoPath, branch, {
      base,
      worktreeRoot: cfg.worktreeRoot,
      timeoutMs: cfg.worktreeTimeoutMs,
    })
    if (result.ok) {
      res.status(200).json({ ok: true, path: result.path, branch: result.branch })
      return
    }
    res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to create the worktree.' })
  })

  // ── W4 remove a git worktree (destructive → Origin guard, same as create) ────
  app.delete('/projects/worktree', express.json({ limit: '4kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!cfg.worktreeEnabled) {
      res.status(403).json({ error: 'Worktree management is disabled.' })
      return
    }
    const body = (req.body ?? {}) as Record<string, unknown>
    const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
    const worktreePath = typeof body['worktreePath'] === 'string' ? body['worktreePath'] : undefined
    const force = body['force'] === true
    if (repoPath === undefined || worktreePath === undefined) {
      res.status(400).json({ error: 'path and worktreePath are required' })
      return
    }
    // Audit (destructive): who/what, sanitized + truncated (never raw control chars).
    console.error(
      `[server] worktree remove: path=${sanitizeForLog(repoPath)} worktree=${sanitizeForLog(worktreePath)} force=${force}`,
    )
    const result = await removeWorktree(repoPath, worktreePath, {
      force,
      timeoutMs: cfg.worktreeTimeoutMs,
    })
    if (result.ok) {
      res.status(200).json({ ok: true, path: result.path })
      return
    }
    res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to remove the worktree.' })
  })

  // ── W4 prune stale worktrees (destructive → Origin guard) ────────────────────
  app.post('/projects/worktree/prune', express.json({ limit: '4kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!cfg.worktreeEnabled) {
      res.status(403).json({ error: 'Worktree management is disabled.' })
      return
    }
    const body = (req.body ?? {}) as Record<string, unknown>
    const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
    if (repoPath === undefined) {
      res.status(400).json({ error: 'path is required' })
      return
    }
    console.error(`[server] worktree prune: path=${sanitizeForLog(repoPath)}`)
    const result = await pruneWorktrees(repoPath, { timeoutMs: cfg.worktreeTimeoutMs })
    if (result.ok) {
      res.status(200).json({ ok: true, pruned: result.pruned ?? [] })
      return
    }
    res.status(result.status ?? 500).json({ error: result.error ?? 'Failed to prune worktrees.' })
  })

  // ── W4 git write: stage / commit / push (the HIGHEST-risk channel) ───────────
  // Every route: requireAllowedOrigin (CSRF — this is a WRITE channel, unlike the
  // read-only /projects/diff) → gitOpsEnabled kill-switch (403) → per-IP rate
  // limit (429) → isValidGitDir three-prong (404). The delegates in git-ops.ts
  // re-validate and realpath-CONTAIN every untrusted path/message (defense in
  // depth) and return SAFE, classified errors only (never raw git stderr).

  // Stage / unstage specific files. `stage` (default true) → git add; false →
  // git restore --staged. `files[]` is capped + realpath-contained in git-ops.
  app.post('/projects/git/stage', express.json({ limit: '64kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!cfg.gitOpsEnabled) {
      res.status(403).json({ error: 'Git operations are disabled.' })
      return
    }
    if (!gitWriteLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).json({ error: 'Too many requests.' })
      return
    }
    const body = (req.body ?? {}) as Record<string, unknown>
    const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
    const files = Array.isArray(body['files']) ? (body['files'] as unknown[]) : undefined
    const stage = body['stage'] === undefined ? true : body['stage'] === true
    if (repoPath === undefined || files === undefined) {
      res.status(400).json({ error: 'path and files are required' })
      return
    }
    if (!(await isValidGitDir(repoPath))) {
      res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong
      return
    }
    const result = await stageFiles(repoPath, files, stage, {
      timeoutMs: cfg.gitOpsTimeoutMs,
      maxFiles: cfg.diffMaxFiles,
    })
    if (result.ok) {
      res.status(200).json({ ok: true, staged: result.staged, count: result.count })
      return
    }
    res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
  })

  // Commit the STAGED changes. Message is length-capped + non-empty checked and
  // passed as a single `-m <msg>` argv (never a shell string, never a pathspec).
  app.post('/projects/git/commit', express.json({ limit: '16kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!cfg.gitOpsEnabled) {
      res.status(403).json({ error: 'Git operations are disabled.' })
      return
    }
    if (!gitWriteLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).json({ error: 'Too many requests.' })
      return
    }
    const body = (req.body ?? {}) as Record<string, unknown>
    const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
    const message = typeof body['message'] === 'string' ? body['message'] : ''
    if (repoPath === undefined) {
      res.status(400).json({ error: 'path is required' })
      return
    }
    if (!(await isValidGitDir(repoPath))) {
      res.status(404).json({ error: 'project not found' })
      return
    }
    // Audit (write): who/what, sanitized + truncated (never raw control chars).
    console.error(`[server] git commit: path=${sanitizeForLog(repoPath)}`)
    const result = await gitCommit(repoPath, message, {
      timeoutMs: cfg.gitOpsTimeoutMs,
      maxLen: cfg.commitMsgMaxLen,
    })
    if (result.ok) {
      res.status(200).json({ ok: true, commit: result.commit })
      return
    }
    res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
  })

  // Push the CURRENT branch to its existing upstream, or `-u <sole-remote>
  // <branch>` if none. Remote/branch are derived server-side; never a force-push.
  // Tighter per-IP rate limit than stage/commit (network-bound).
  app.post('/projects/git/push', express.json({ limit: '4kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!cfg.gitOpsEnabled) {
      res.status(403).json({ error: 'Git operations are disabled.' })
      return
    }
    if (!gitPushLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).json({ error: 'Too many requests.' })
      return
    }
    const body = (req.body ?? {}) as Record<string, unknown>
    const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
    if (repoPath === undefined) {
      res.status(400).json({ error: 'path is required' })
      return
    }
    if (!(await isValidGitDir(repoPath))) {
      res.status(404).json({ error: 'project not found' })
      return
    }
    console.error(`[server] git push: path=${sanitizeForLog(repoPath)}`)
    const result = await gitPush(repoPath, { timeoutMs: cfg.gitPushTimeoutMs })
    if (result.ok) {
      res.status(200).json({ ok: true, branch: result.branch, remote: result.remote })
      return
    }
    res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
  })

  // w6/G2 — refresh remote-tracking refs so the panel's `behind` stops being a
  // stale guess. Read-only against the working tree (refs/remotes only, never a
  // pull); the remote is derived server-side inside gitFetch, never from the body.
  // Shares the gitOpsEnabled kill-switch because it is the one route that talks to
  // the network with the host's git credentials.
  app.post('/projects/git/fetch', express.json({ limit: '4kb' }), async (req, res) => {
    if (!requireAllowedOrigin(req, res)) return
    if (!cfg.gitOpsEnabled) {
      res.status(403).json({ error: 'Git operations are disabled.' })
      return
    }
    if (!gitFetchLimiter(req.socket.remoteAddress ?? '', Date.now())) {
      res.status(429).json({ error: 'Too many requests.' })
      return
    }
    const body = (req.body ?? {}) as Record<string, unknown>
    const repoPath = typeof body['path'] === 'string' ? body['path'] : undefined
    if (repoPath === undefined) {
      res.status(400).json({ error: 'path is required' })
      return
    }
    if (!(await isValidGitDir(repoPath))) {
      res.status(404).json({ error: 'project not found' })
      return
    }
    console.error(`[server] git fetch: path=${sanitizeForLog(repoPath)}`)
    const result = await gitFetch(repoPath, { timeoutMs: cfg.gitPushTimeoutMs })
    if (result.ok) {
      res.status(200).json({ ok: true, remote: result.remote, lastFetchMs: result.lastFetchMs })
      return
    }
    res.status(result.status ?? 500).json({ ok: false, error: result.error ?? 'Git operation failed.' })
  })

  // ── GET /config/ui (review #4) — client-readable UI config (read-only) ────
  app.get('/config/ui', (_req, res) => {
    const uiConfig: UiConfig = {
      allowAutoMode: cfg.allowAutoMode,
      // W3(b): expose the cost budget only when set (>0) so the FE can derive
      // cost-overage warn styling; a non-secret number, safe over /config/ui.
      ...(cfg.costBudgetUsd > 0 ? { costBudgetUsd: cfg.costBudgetUsd } : {}),
      // W5 fan-out board: let the FE stepper max mirror MAX_FANOUT_LANES.
      maxFanoutLanes: cfg.maxFanoutLanes,
    }
    res.json(uiConfig)
  })

  // ── HTTP server ───────────────────────────────────────────────────────────
  const httpServer = createServer(app)

  // ── WebSocket server (noServer: true — L3) ────────────────────────────────
  // noServer prevents the wss from auto-attaching its own 'upgrade' listener,
  // which would double-handle the socket before our Origin check runs.
  const wss = new WebSocketServer({
    noServer: true,
    maxPayload: cfg.maxPayloadBytes, // L5: cap single WS frame size
  })

  // ── Idle reaper (+ A5 stuck sweep, H4 — no new timer) ─────────────────────
  const reapTimer = setInterval(() => {
    const now = Date.now()
    manager.reapIdle(now)
    manager.sweepStuck(now)
  }, cfg.reapIntervalMs)
  // Don't let this timer prevent the process from exiting.
  reapTimer.unref()

  // ── HTTP upgrade (single entry point — ARCHITECTURE §3.6, L3) ────────────
  httpServer.on('upgrade', (req: IncomingMessage, socket, head) => {
    // Parse the path. Use a dummy base so URL() accepts a path-only string.
    const { pathname } = new URL(req.url ?? '/', 'http://localhost')

    // 1. Wrong path → destroy
    if (pathname !== cfg.wsPath) {
      socket.destroy()
      return
    }

    // 2. Origin check (CSWSH defence, ARCHITECTURE §3.3 / TECH_DOC §7)
    const origin = req.headers['origin'] as string | undefined
    if (!isOriginAllowed(origin, cfg.allowedOrigins)) {
      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
      socket.destroy()
      return
    }

    // 2b. Access-token gate (w5-access-token) — ADDITIVE, runs AFTER the Origin
    //     check (never weakens it) and BEFORE the handshake. The browser
    //     auto-sends the HttpOnly cookie on the same-origin WS upgrade, so no
    //     frontend change is needed. Disabled (no token) → this is a no-op.
    if (isAuthEnabled(cfg) && !cookieIsAuthed(cfg, req.headers['cookie'])) {
      socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n')
      socket.destroy()
      return
    }

    // 3. Hand off to wss — it completes the handshake and emits 'connection'.
    wss.handleUpgrade(req, socket, head, (ws) => {
      wss.emit('connection', ws, req)
    })
  })

  // ── WS connection handler ─────────────────────────────────────────────────
  wss.on('connection', (ws: WsWebSocket) => {
    // State: session id bound after the first 'attach' frame.
    let boundSessionId: string | null = null

    // Per-connection leaky-bucket rate limit (Sec M3). maxPayload caps frame
    // SIZE; this caps frame FREQUENCY so an input/resize flood can't saturate
    // CPU/IO. Over-limit frames are dropped (NOT a close — avoid killing a
    // legitimate burst); the warn log is throttled to once per window.
    let windowStart = Date.now()
    let msgsThisWindow = 0
    let droppedThisWindow = 0
    let warnedThisWindow = false

    // Per-connection message handler.
    const onMessage = (raw: Buffer | string): void => {
      // ── Rate limit (leaky bucket over a 1s window) ────────────────────────
      const now = Date.now()
      if (now - windowStart >= RATE_WINDOW_MS) {
        windowStart = now
        msgsThisWindow = 0
        droppedThisWindow = 0
        warnedThisWindow = false
      }
      msgsThisWindow += 1
      if (msgsThisWindow > cfg.maxMsgsPerSec) {
        droppedThisWindow += 1
        if (!warnedThisWindow) {
          warnedThisWindow = true
          console.error(
            `[server] rate limit: dropping frames over ${cfg.maxMsgsPerSec}/s on one connection`,
          )
        }
        return
      }

      const text = typeof raw === 'string' ? raw : raw.toString('utf8')
      const result = parseClientMessage(text)

      if (!result.ok) {
        // Discard invalid frames; log without noisy stack traces (avoid noise per spec).
        console.error('[server] invalid client message discarded:', sanitizeForLog(result.error))
        return
      }

      const msg = result.message

      // ── First frame must be 'attach' ──────────────────────────────────────
      if (boundSessionId === null) {
        if (msg.type !== 'attach') {
          // Protocol violation: first message was not attach. Ignore and wait.
          console.error('[server] expected attach as first message, got:', msg.type)
          return
        }

        const dims = {
          cols: DEFAULT_COLS,
          rows: DEFAULT_ROWS,
        }

        let session
        try {
          session = manager.handleAttach(ws, msg.sessionId, dims, Date.now(), msg.cwd)
        } catch (err: unknown) {
          // M4: spawn failure — send exit(-1) and close only this connection.
          const reason =
            err instanceof Error ? err.message : String(err)
          safeSend(ws, serialize({ type: 'exit', code: -1, reason }))
          ws.close()
          return
        }

        boundSessionId = session.meta.id

        // Confirm the attach to the client.
        safeSend(ws, serialize({ type: 'attached', sessionId: session.meta.id }))

        // M3 / AC-A1.3: a late-joining device immediately sees a still-held
        // approval (the manager re-sent telemetry + status; the pending/gate
        // portion lives here because the server owns pendingApprovals).
        const heldApproval = pendingApprovals.get(boundSessionId)
        if (heldApproval !== undefined) {
          // W1: re-send the bounded preview alongside gate so a late-joining
          // device sees WHAT is held, not just that something is. JSON.stringify
          // drops `preview` when undefined (non-previewable tools).
          safeSend(
            ws,
            serialize({
              type: 'status',
              status: 'waiting',
              pending: true,
              gate: heldApproval.gate,
              ...(heldApproval.preview !== undefined ? { preview: heldApproval.preview } : {}),
            }),
          )
        }
        return
      }

      // ── Subsequent frames: route input / resize ───────────────────────────
      const session = manager.get(boundSessionId)
      if (session === undefined) {
        // Session was reaped between messages — nothing to route to.
        return
      }

      if (msg.type === 'input') {
        writeInput(session, msg.data)
      } else if (msg.type === 'resize') {
        // Latest-writer-wins: this device's dims drive the shared PTY size.
        setClientDims(session, ws, msg.cols, msg.rows)
      } else if (msg.type === 'approve') {
        // H3/B4: resolve the held PermissionRequest with allow, carrying the
        // optional permission mode. SEC-M5: downgrade the high-risk 'auto' mode
        // to 'default' unless ALLOW_AUTO_MODE is set (even if the FE hid it).
        let mode = parseApproveMode(text)
        if (mode === 'auto' && !cfg.allowAutoMode) mode = 'default'
        resolvePending(boundSessionId, permDecision('allow', mode))
        manager.handleHookEvent(boundSessionId, 'working', undefined, false)
      } else if (msg.type === 'reject') {
        resolvePending(boundSessionId, permDecision('deny'))
        manager.handleHookEvent(boundSessionId, 'working', undefined, false)
      } else if (msg.type === 'attach') {
        // Re-attach after first bind is not expected; log and ignore.
        console.error('[server] unexpected re-attach on established connection')
      }
    }

    ws.on('message', onMessage)

    // ── WS close ─────────────────────────────────────────────────────────────
    ws.on('close', () => {
      if (boundSessionId === null) return

      const session = manager.get(boundSessionId)
      if (session === undefined) {
        // Session already gone — make sure no held approval dangles.
        resolvePending(boundSessionId, {})
        return
      }

      // Invariant #2: never kill the PTY on WS close — detach THIS client only
      // (the PTY stays alive for other devices and for reconnect).
      detachWs(session, ws, Date.now())

      // H3: only release a held approval when the LAST viewer leaves. With
      // multi-device sharing (v0.4), another device may still be looking at the
      // same pending approval — closing one mirror must not cancel it for them.
      if (session.clients.size === 0) {
        resolvePending(boundSessionId, {})
      }
    })

    // ── WS error ─────────────────────────────────────────────────────────────
    ws.on('error', (err: Error) => {
      // Log and let the 'close' event (which always follows 'error') handle cleanup.
      console.error('[server] ws error:', err.message)
    })
  })

  // ── Graceful shutdown helper ──────────────────────────────────────────────
  function doShutdown(): void {
    clearInterval(reapTimer)
    // W2: cancel any pending idle-drain timers so they can't fire post-shutdown.
    for (const t of drainTimers.values()) clearTimeout(t)
    drainTimers.clear()
    manager.shutdown()
  }

  // ── Signal handlers (SIGINT / SIGTERM) ────────────────────────────────────
  const onSignal = (): void => {
    console.error('[server] shutdown signal received — cleaning up')
    doShutdown()
    httpServer.close(() => process.exit(0))
    httpServer.closeIdleConnections() // drop idle keep-alive (hook) sockets so close() drains
  }

  // ── uncaughtException: log and exit for truly unexpected errors ───────────
  // (spawn failures / send failures are handled above and must NOT reach here)
  const onUncaught = (err: Error): void => {
    console.error('[server] uncaughtException — exiting:', err)
    doShutdown()
    process.exit(1)
  }

  process.on('SIGINT', onSignal)
  process.on('SIGTERM', onSignal)
  process.on('uncaughtException', onUncaught)

  // Remove the process-level listeners this server registered, so repeated
  // startServer()/close() cycles (tests, library use) don't leak handlers.
  function removeProcessListeners(): void {
    process.off('SIGINT', onSignal)
    process.off('SIGTERM', onSignal)
    process.off('uncaughtException', onUncaught)
  }

  // ── Start listening ───────────────────────────────────────────────────────
  httpServer.listen(cfg.port, cfg.bindHost, () => {
    console.error(
      `[server] listening on http://${cfg.bindHost}:${cfg.port}  WS → ${cfg.wsPath}`,
    )
    console.error('[server] note: run `npm run build:web` before connecting a browser')
  })

  // ── Return handle for tests / orchestrator ────────────────────────────────
  return {
    close(): Promise<void> {
      return new Promise<void>((resolve) => {
        removeProcessListeners()
        doShutdown()
        httpServer.close(() => resolve())
        // Drop idle keep-alive sockets (e.g. an undici hook connection) so the
        // close callback can fire instead of waiting for the keep-alive timeout.
        httpServer.closeIdleConnections()
      })
    },
  }
}

// ── Entrypoint (npm start → tsx src/server.ts) ───────────────────────────────
// Detect whether this module is the main entry point in both CJS and ESM.
const isMain =
  // ESM: import.meta.url matches the invoked file URL
  process.argv[1] !== undefined &&
  fileURLToPath(import.meta.url) === path.resolve(process.argv[1])

if (isMain) {
  const cfg = loadConfig(process.env as Record<string, string | undefined>)
  startServer(cfg)
}
