← Files Compound EngineeringARCHIVED FILE

skills/ce-prototype/scripts/light-webserver.js

40.9 KB · Oct 2, 2026 · 00:33 UTC

↓ Download file

#!/usr/bin/env node
import { createHash, randomUUID } from "node:crypto"
import { execFileSync, spawn } from "node:child_process"
import fs from "node:fs"
import http from "node:http"
import path from "node:path"
import { fileURLToPath } from "node:url"

const scriptPath = fileURLToPath(import.meta.url)
const assetsDir = path.join(path.dirname(scriptPath), "..", "assets")
const DEFAULT_HOST = "127.0.0.1"
const DEFAULT_URL_HOST = "localhost"
const IDLE_TIMEOUT_MS = Number(process.env.CE_LIGHT_WEB_IDLE_TIMEOUT_MS) || 30 * 60 * 1000
const LIFECYCLE_CHECK_MS = Number(process.env.CE_LIGHT_WEB_LIFECYCLE_CHECK_MS) || 60 * 1000
const WAIT_TIMEOUT_MS = Number(process.env.CE_LIGHT_WEB_WAIT_TIMEOUT_MS) || 30 * 1000
const SSE_GRACE_MS = Number(process.env.CE_LIGHT_WEB_SSE_GRACE_MS) || 5000
const BODY_LIMIT = 64 * 1024
// Reserved URL namespace for the overlay, so a screen's own /annotate.js or
// /annotate.css under screens/ is never shadowed.
const OVERLAY_PREFIX = "/__ce-annotate"
const OVERLAY_FILES = {
  [`${OVERLAY_PREFIX}/annotate.js`]: "annotate.js",
  [`${OVERLAY_PREFIX}/annotate.css`]: "annotate.css",
}
// A Host header is reflected into the served document only in this shape.
const HOST_HEADER = /^[A-Za-z0-9.\-]+(:\d{1,5})?$|^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/

function usage() {
  return [
    "Usage:",
    "  node light-webserver.js start --root <dir> [--host 127.0.0.1] [--port 0] [--foreground] [--owner-pid <pid>] [--annotate]",
    "  node light-webserver.js stop --root <dir>",
    "  node light-webserver.js status --root <dir>",
    "  node light-webserver.js wait --root <dir>",
  ].join("\n")
}

function parseArgs(argv) {
  const command = argv[2]
  const options = {
    command,
    host: DEFAULT_HOST,
    port: 0,
    foreground: false,
    annotate: false,
  }

  for (let i = 3; i < argv.length; i++) {
    const arg = argv[i]
    if (arg === "--root") {
      options.root = argv[++i]
    } else if (arg === "--host") {
      options.host = argv[++i]
    } else if (arg === "--port") {
      options.port = Number(argv[++i])
    } else if (arg === "--foreground") {
      options.foreground = true
    } else if (arg === "--owner-pid") {
      options.ownerPid = Number(argv[++i])
    } else if (arg === "--annotate") {
      options.annotate = true
    } else {
      throw new Error(`Unknown argument: ${arg}`)
    }
  }

  if (!["start", "serve", "stop", "status", "wait"].includes(command)) {
    throw new Error(usage())
  }
  if (!options.root) {
    throw new Error("--root is required")
  }
  if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
    throw new Error("--port must be an integer from 0 to 65535")
  }
  if (options.ownerPid !== undefined && (!Number.isInteger(options.ownerPid) || options.ownerPid <= 1)) {
    throw new Error("--owner-pid must be an integer greater than 1")
  }

  options.root = path.resolve(options.root)
  options.screensDir = path.join(options.root, "screens")
  options.stateDir = path.join(options.root, "state")
  options.pidFile = path.join(options.stateDir, "server.pid")
  options.infoFile = path.join(options.stateDir, "display-info.json")
  options.logFile = path.join(options.stateDir, "server.log")
  return options
}

function ensureDirs(options) {
  fs.mkdirSync(options.screensDir, { recursive: true })
  fs.mkdirSync(options.stateDir, { recursive: true })
}

function jsonOut(value) {
  process.stdout.write(`${JSON.stringify(value)}\n`)
}

function readJson(filePath) {
  return JSON.parse(fs.readFileSync(filePath, "utf8"))
}

function processAlive(pid) {
  if (!pid || !Number.isInteger(pid)) return false
  try {
    process.kill(pid, 0)
    return true
  } catch (error) {
    return error?.code === "EPERM"
  }
}

function processArgs(pid) {
  try {
    return execFileSync("ps", ["-p", String(pid), "-o", "args="], {
      encoding: "utf8",
      stdio: ["ignore", "pipe", "ignore"],
    }).trim()
  } catch {
    return null
  }
}

function ownsServerProcess(options, pid) {
  const args = processArgs(pid)
  // Process-command inspection is best-effort; when unavailable, fall back to
  // PID-file behavior so stop still works on platforms without a compatible ps.
  if (args === null) return true
  if (!args.includes(scriptPath) || !args.includes(options.root)) return false
  const tokens = args.split(/\s+/)
  // Detached start spawns `serve`; `--foreground` keeps `start` in-process.
  return tokens.includes("serve") || tokens.includes("start")
}

function resolveOwnerPid() {
  const parentPid = process.ppid
  if (!parentPid || parentPid <= 1) return null
  try {
    const grandparent = Number(execFileSync("ps", ["-o", "ppid=", "-p", String(parentPid)], {
      encoding: "utf8",
      stdio: ["ignore", "pipe", "ignore"],
    }).trim())
    if (Number.isInteger(grandparent) && grandparent > 1) return grandparent
  } catch {
    // Fall back to the direct parent when grandparent lookup is unavailable.
  }
  return parentPid
}

function readPid(options) {
  if (!fs.existsSync(options.pidFile)) return null
  const pid = Number(fs.readFileSync(options.pidFile, "utf8").trim())
  return Number.isInteger(pid) ? pid : null
}

function getRunningInfo(options) {
  const pid = readPid(options)
  if (!processAlive(pid)) return null
  if (!ownsServerProcess(options, pid)) return null
  if (!fs.existsSync(options.infoFile)) return null
  try {
    return readJson(options.infoFile)
  } catch {
    return null
  }
}

function sessionHasEnded(options) {
  try {
    return Boolean(readJson(options.infoFile).session_ended)
  } catch {
    return false
  }
}

function exitSessionEnded() {
  process.exitCode = 1
  jsonOut({ status: "session-ended" })
}

// Containment has to survive symlinks: path.resolve is lexical, so a link
// inside the run directory would otherwise be followed straight out of it.
// Every route that reads a file goes through this — the screen route and the
// asset route drifting apart is what left one of them unguarded before.
function containedRealPath(rootDir, candidate) {
  let root
  let real
  try {
    root = fs.realpathSync(rootDir)
    real = fs.realpathSync(candidate)
  } catch {
    return null
  }
  if (real !== root && !real.startsWith(root + path.sep)) return null
  return real
}

function newestScreen(options) {
  if (!fs.existsSync(options.screensDir)) return null
  const files = fs.readdirSync(options.screensDir)
    .filter((file) => file.endsWith(".html"))
    .map((file) => containedRealPath(options.screensDir, path.join(options.screensDir, file)))
    .filter(Boolean)
    .map((filePath) => ({ filePath, mtimeMs: fs.statSync(filePath).mtimeMs }))
    .sort((a, b) => b.mtimeMs - a.mtimeMs)
  return files[0]?.filePath ?? null
}

function isFullDocument(html) {
  // After ignorable prologue (whitespace, HTML comments), a doctype or <html>
  // is a complete document; anything else is a fragment.
  let text = html
  for (;;) {
    text = text.trimStart()
    if (!text.startsWith("<!--")) break
    const end = text.indexOf("-->")
    if (end === -1) return false
    text = text.slice(end + 3)
  }
  const trimmed = text.toLowerCase()
  return trimmed.startsWith("<!doctype") || trimmed.startsWith("<html")
}

function screenVersion(options) {
  const screen = newestScreen(options)
  if (!screen) return { screen: null, mtimeMs: 0 }
  return {
    screen: path.basename(screen),
    mtimeMs: fs.statSync(screen).mtimeMs,
  }
}

function versionKey(version) {
  return `${version?.screen ?? ""}:${version?.mtimeMs ?? 0}`
}

// Annotate mode reloads the explorer's page on any change under screens/,
// including a stylesheet or script the newest screen links, so the change key
// covers every regular file there, not only the newest screen.
function screensChangeKey(options) {
  const hash = createHash("sha1")
  const walk = (dir, prefix) => {
    let entries
    try {
      entries = fs.readdirSync(dir, { withFileTypes: true })
    } catch {
      return
    }
    for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
      const rel = `${prefix}${entry.name}`
      if (entry.isDirectory()) {
        walk(path.join(dir, entry.name), `${rel}/`)
        continue
      }
      if (!entry.isFile()) continue
      try {
        const stat = fs.statSync(path.join(dir, entry.name))
        hash.update(`${rel}:${stat.mtimeMs}:${stat.size}\n`)
      } catch {
        // Removed between readdir and stat: the next tick sees the settled tree.
      }
    }
  }
  walk(options.screensDir, "")
  return hash.digest("hex")
}

const WAITING_HTML = "<h1>Waiting for a page...</h1><p>The agent will update this page when a screen is ready.</p>"
const NO_STORE = { "Cache-Control": "no-store" }

function refreshScript(options) {
  const initialVersion = JSON.stringify(screenVersion(options))
  return `<script>
(function(){
  var currentVersion = ${initialVersion};
  function key(version) {
    return String(version && version.screen) + ":" + String(version && version.mtimeMs);
  }
  async function checkForVisualProbeUpdate() {
    try {
      var response = await fetch("/version", { cache: "no-store" });
      if (!response.ok) return;
      var nextVersion = await response.json();
      if (key(nextVersion) !== key(currentVersion)) {
        window.location.reload();
      }
    } catch (error) {
      // Keep the current sketch visible if the transient version check fails.
    }
  }
  setInterval(checkForVisualProbeUpdate, 1000);
})();
</script>`
}

// Ahead of the authored document: deferred overlay. Its URL is absolute on
// the request's own origin, so a screen's <base href> cannot redirect it.
// data-ce-page is the path this response served, so a later History API
// rewrite is not the screen.
function htmlAttr(value) {
  return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;")
}

function encodePagePath(page) {
  if (typeof page !== "string" || !page.startsWith("/") || page === "/") return "/"
  return `/${page.slice(1).split("/").map((segment) => {
    try {
      return encodeURIComponent(decodeURIComponent(segment))
    } catch {
      return encodeURIComponent(segment)
    }
  }).join("/")}`
}

function annotateBoot(origin, page = "/") {
  const servedPage = encodePagePath(typeof page === "string" && page.startsWith("/") ? page : "/")
  return `<script defer src="${origin}${OVERLAY_PREFIX}/annotate.js" data-ce-page="${htmlAttr(servedPage)}"></script>`
}

function wrapFragment(options, content) {
  return `<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>CE local web</title>
  <style>
    body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #f7f7f8; color: #1f2328; }
    header { padding: 10px 18px; border-bottom: 1px solid #d8dee4; background: #fff; color: #57606a; font-size: 13px; }
    main { padding: 24px; }
  </style>
</head>
<body>
  <header>CE local web - newest screen, reloads on change</header>
  <main>${content}</main>
  ${refreshScript(options)}
</body>
</html>`
}

function wrapAnnotateFragment(content, boot) {
  return `<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>CE local web</title>
  ${boot}
  <style>
    body { margin: 0; font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif; background: #f7f7f8; color: #1f2328; }
    header { padding: 10px 18px; border-bottom: 1px solid #d8dee4; background: #fff; color: #57606a; font-size: 13px; }
    main { padding: 24px; }
  </style>
</head>
<body>
  <header>CE local web - newest screen</header>
  <main>${content}</main>
</body>
</html>`
}

function injectRefresh(options, html) {
  if (html.includes("</body>")) {
    return html.replace("</body>", `${refreshScript(options)}\n</body>`)
  }
  return `${html}\n${refreshScript(options)}`
}

// The document the annotate client sees. A full document is served unchanged
// behind our doctype and the boot script: the parser opens html/head for the
// script, then merges the authored <html> attributes, processes the authored
// head children in head, ignores the second doctype and <head> start tag, and
// creates <body> with its attributes. Nothing in the authored text is located
// or rewritten, so a "</body>" in a script string or comment cannot mislead it.
function annotateScreen(html, origin, page = "/") {
  const boot = annotateBoot(origin, page)
  const text = html.replace(/^\uFEFF/, "")
  if (!isFullDocument(text)) return wrapAnnotateFragment(text, boot)
  return `<!doctype html>\n${boot}\n${text}`
}

function annotateDocument(options, origin) {
  const screen = newestScreen(options)
  if (!screen) return wrapAnnotateFragment(WAITING_HTML, annotateBoot(origin))
  return annotateScreen(fs.readFileSync(screen, "utf8"), origin, pageForScreen(options, screen))
}

function renderPage(options, origin) {
  if (options.annotate) return annotateDocument(options, origin)
  const screen = newestScreen(options)
  if (!screen) return wrapFragment(options, WAITING_HTML)
  const html = fs.readFileSync(screen, "utf8")
  return isFullDocument(html) ? injectRefresh(options, html) : wrapFragment(options, html)
}

function cookieValue(req, name) {
  const header = req.headers.cookie
  if (typeof header !== "string") return null
  for (const part of header.split(";")) {
    const eq = part.indexOf("=")
    if (eq === -1) continue
    if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim()
  }
  return null
}

// Every credential the request presents. A prototype may use ?token= for its
// own purposes, so no single source may shadow another: the gate accepts the
// request when any of these matches.
function requestCredentials(req, cookieName) {
  const url = new URL(req.url, "http://127.0.0.1")
  const auth = req.headers.authorization
  return [
    url.searchParams.get("token"),
    req.headers["x-session-token"],
    typeof auth === "string" && auth.startsWith("Bearer ") ? auth.slice(7) : null,
    cookieName ? cookieValue(req, cookieName) : null,
  ].filter((value) => typeof value === "string" && value)
}

function tokenMatches(candidate, expected) {
  return typeof candidate === "string" && candidate === expected
}

function sendJson(res, status, value) {
  res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" })
  res.end(`${JSON.stringify(value)}\n`)
}

function readBody(req, limit = BODY_LIMIT) {
  return new Promise((resolve, reject) => {
    const chunks = []
    let size = 0
    req.on("data", (chunk) => {
      size += chunk.length
      if (size > limit) {
        req.destroy()
        reject(new Error("payload too large"))
        return
      }
      chunks.push(chunk)
    })
    req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
    req.on("error", reject)
  })
}

// Screens-relative URL path for a file we just served, so the overlay names
// that file rather than "/". "/" would re-resolve to newestScreen at POST
// time, and a newer sibling would steal the pin.
function pageForScreen(options, filePath) {
  let root
  try {
    root = fs.realpathSync(options.screensDir)
  } catch {
    return "/"
  }
  const relative = path.relative(root, filePath).split(path.sep).join("/")
  if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) return "/"
  return `/${relative}`
}

// The screens/-relative HTML file the annotated page resolves to, or null.
// "/" (also a missing page, from an older overlay) is the newest screen at
// that moment; any other path must name an HTML file under screens/, through
// the same containment as the route that served it. This is the file the
// agent edits, so it is resolved here rather than trusted from the client.
function screenForPage(options, page = "/") {
  if (typeof page !== "string" || !page.startsWith("/")) return null
  let filePath
  if (page === "/") {
    filePath = newestScreen(options)
  } else {
    let name
    try {
      name = decodeURIComponent(page)
    } catch {
      return null
    }
    filePath = containedRealPath(options.screensDir, path.resolve(options.screensDir, name.replace(/^\/+/, "")))
    if (!filePath || contentType(filePath) !== CONTENT_TYPES[".html"]) return null
    try {
      if (!fs.statSync(filePath).isFile()) return null
    } catch {
      return null
    }
  }
  if (!filePath) return null
  let root
  try {
    root = fs.realpathSync(options.screensDir)
  } catch {
    return null
  }
  return path.relative(root, filePath).split(path.sep).join("/")
}

function parseAnnotation(raw, options) {
  if (!raw || !raw.trim()) return null
  let body
  try {
    body = JSON.parse(raw)
  } catch {
    return null
  }
  if (!body || typeof body !== "object" || Array.isArray(body)) return null
  const comment = typeof body.comment === "string" ? body.comment.trim() : ""
  const selector = typeof body.selector === "string" ? body.selector.trim() : ""
  if (!comment || !selector) return null
  const screen = screenForPage(options, body.page)
  if (!screen) return null
  const textSnippet = typeof body.textSnippet === "string" ? body.textSnippet : null
  const rect = body.rect && typeof body.rect === "object" && !Array.isArray(body.rect) ? body.rect : null
  return {
    id: randomUUID(),
    screen,
    comment,
    selector,
    textSnippet,
    rect,
  }
}

// Whether a request for an HTML file is the browser navigating to it, as
// opposed to a script fetching it. Fetch metadata decides when the browser
// sends it (frames stay raw: only a top-level document is a screen); without
// it, a request that accepts HTML and states no fetch mode is a navigation.
function isDocumentNavigation(req) {
  const dest = req.headers["sec-fetch-dest"]
  if (dest) return dest === "document"
  return !req.headers["sec-fetch-mode"] && /\btext\/html\b/.test(req.headers.accept || "")
}

// The regular file under rootDir that the request names, or null after the
// error response has been written.
function resolveContainedFile(rootDir, req, res) {
  let name
  try {
    // A malformed percent-escape throws URIError; without this the throw is
    // uncaught in the request handler and takes the whole server down.
    name = decodeURIComponent(req.url.split("?")[0].split("#")[0])
  } catch {
    res.writeHead(400)
    res.end("Bad request")
    return null
  }
  name = name.replace(/^\/+/, "")
  // Serve nested paths so a screen can keep the asset layout it was copied
  // from, but never resolve outside the run's screens directory.
  const filePath = containedRealPath(rootDir, path.resolve(rootDir, name))
  if (!filePath) {
    res.writeHead(404)
    res.end("Not found")
    return null
  }
  let stat
  try {
    stat = fs.statSync(filePath)
  } catch {
    res.writeHead(404)
    res.end("Not found")
    return null
  }
  // `/files/%2e` resolves to the screens directory itself, which passes an
  // existence check and then throws EISDIR on read — uncaught, killing the server.
  if (!stat.isFile()) {
    res.writeHead(404)
    res.end("Not found")
    return null
  }
  return filePath
}

function sendFile(filePath, res, headers = {}) {
  res.writeHead(200, { "Content-Type": contentType(filePath), ...headers })
  res.end(fs.readFileSync(filePath))
}

function safeFileResponse(rootDir, req, res, headers = {}) {
  const filePath = resolveContainedFile(rootDir, req, res)
  if (filePath) sendFile(filePath, res, headers)
}

// A prototype recreated from a real product brings whatever that product uses,
// so this covers the ordinary web asset set rather than an allowlist that has
// to grow every time a screen references a new kind of file.
const CONTENT_TYPES = {
  ".html": "text/html; charset=utf-8",
  ".css": "text/css; charset=utf-8",
  ".js": "text/javascript; charset=utf-8",
  ".mjs": "text/javascript; charset=utf-8",
  ".json": "application/json; charset=utf-8",
  ".txt": "text/plain; charset=utf-8",
  ".svg": "image/svg+xml",
  ".png": "image/png",
  ".jpg": "image/jpeg",
  ".jpeg": "image/jpeg",
  ".gif": "image/gif",
  ".webp": "image/webp",
  ".avif": "image/avif",
  ".ico": "image/x-icon",
  ".woff": "font/woff",
  ".woff2": "font/woff2",
  ".ttf": "font/ttf",
  ".otf": "font/otf",
  ".mp4": "video/mp4",
  ".webm": "video/webm",
  ".mp3": "audio/mpeg",
  ".wasm": "application/wasm",
}

function contentType(filePath) {
  return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream"
}

async function start(options) {
  ensureDirs(options)
  options.ownerPid = options.ownerPid ?? resolveOwnerPid()
  const running = getRunningInfo(options)
  if (running && Boolean(running.annotate) === options.annotate && !running.session_ended) {
    jsonOut({ ...running, status: "running" })
    return
  }
  // A server in the other mode cannot serve this start: a default server has
  // no token for wait, and an annotate server would gate a default preview.
  if (running) await stopServer(options)

  fs.rmSync(options.pidFile, { force: true })
  fs.rmSync(options.infoFile, { force: true })

  if (options.foreground) {
    await serve(options)
    return
  }

  const logFd = fs.openSync(options.logFile, "a")
  const child = spawn(process.execPath, [
    scriptPath,
    "serve",
    "--root",
    options.root,
    "--host",
    options.host,
    "--port",
    String(options.port),
    ...(options.ownerPid ? ["--owner-pid", String(options.ownerPid)] : []),
    ...(options.annotate ? ["--annotate"] : []),
  ], {
    detached: true,
    stdio: ["ignore", logFd, logFd],
  })
  child.unref()
  fs.closeSync(logFd)

  const started = await waitForInfo(options, child.pid)
  if (!started) {
    throw new Error(`Server failed to start. See ${options.logFile}`)
  }
  jsonOut({ ...started, status: "started" })
}

async function waitForInfo(options, pid) {
  for (let i = 0; i < 100; i++) {
    if (fs.existsSync(options.infoFile)) {
      try {
        return readJson(options.infoFile)
      } catch {
        // Truncated write; keep polling.
      }
    }
    if (pid && !processAlive(pid)) return null
    await new Promise((resolve) => setTimeout(resolve, 50))
  }
  return null
}

// The address a local client uses to reach the bound interface: a wildcard
// bind is reached through its loopback, anything else through itself.
function localAddressFor(host) {
  if (!host || host === "0.0.0.0") return DEFAULT_HOST
  if (host === "::" || host === "[::]") return "[::1]"
  return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host
}

async function wait(options) {
  process.stdout.on("error", (error) => {
    console.error(error.message)
    process.exitCode = 2
  })
  const info = getRunningInfo(options)
  if (!info?.port) {
    // Idle/owner shutdown records session_ended and exits; wait must still
    // report that terminal status rather than "not running".
    if (sessionHasEnded(options)) return exitSessionEnded()
    console.error("Server is not running")
    process.exit(2)
  }
  if (!info.token) {
    console.error("Annotation is not enabled for this server")
    process.exit(2)
  }

  const url = `http://${localAddressFor(info.host)}:${info.port}/wait?token=${encodeURIComponent(info.token)}`
  while (true) {
    let response
    try {
      response = await fetch(url)
    } catch {
      if (sessionHasEnded(options)) return exitSessionEnded()
      process.exit(2)
    }
    if (response.status === 200 || response.status === 410) {
      const text = await response.text()
      // Let pending stdout writes drain instead of truncating a piped batch.
      process.exitCode = response.status === 200 ? 0 : 1
      process.stdout.write(text.endsWith("\n") ? text : `${text}\n`)
      return
    }
    if (response.status === 204) continue
    process.exit(2)
  }
}

async function serve(options) {
  ensureDirs(options)

  const sessionToken = options.annotate ? randomUUID() : null
  const overlaySession = options.annotate ? randomUUID() : ""
  let cookieName = null
  const heldQueue = []
  const annotationQueue = []
  // id -> held | queued | working | done, in POST order. The overlay's pin
  // status follows this, never the screen changes an annotation happens to cause.
  const annotationStates = new Map()
  const waiters = []
  const sseClients = new Set()
  let sessionEnded = false
  let publishedInfo = null
  let sawSseClient = false
  let sseGraceTimer = null
  const pendingDocuments = new Map()
  let lastBroadcastKey = options.annotate ? screensChangeKey(options) : null
  let lastActivity = Date.now()
  const touch = () => {
    lastActivity = Date.now()
  }

  function endSession() {
    if (sessionEnded) return
    sessionEnded = true
    if (publishedInfo && options.infoFile) {
      publishedInfo = { ...publishedInfo, session_ended: true }
      try {
        fs.writeFileSync(options.infoFile, `${JSON.stringify(publishedInfo, null, 2)}\n`)
      } catch {
        // Reuse without this flag would report a live session that cannot wait.
      }
    }
    for (const id of [...pendingDocuments.keys()]) forgetPendingDocument(id)
    if (sseGraceTimer) {
      clearTimeout(sseGraceTimer)
      sseGraceTimer = null
    }
    for (const [id, state] of annotationStates) {
      if (state === "working") annotationStates.set(id, "done")
    }
    flushHeld()
    broadcastAnnotations()
    fulfillWaiters()
    const body = `${JSON.stringify({ status: "session-ended" })}\n`
    const draining = []
    while (waiters.length > 0) {
      const parked = waiters.shift()
      clearTimeout(parked.timer)
      if (!parked.res.writableEnded) {
        parked.res.writeHead(410, { "Content-Type": "application/json; charset=utf-8" })
        draining.push(new Promise((resolve) => parked.res.end(body, resolve)))
      }
    }
    for (const client of sseClients) {
      if (!client.writableEnded) {
        client.write("event: session-ended\ndata: {}\n\n")
        draining.push(new Promise((resolve) => client.end(resolve)))
      }
    }
    sseClients.clear()
    return Promise.all(draining)
  }

  function annotationsPayload() {
    return JSON.stringify(Object.fromEntries(annotationStates))
  }

  function broadcastAnnotations() {
    const frame = `event: annotations\ndata: ${annotationsPayload()}\n\n`
    for (const client of sseClients) {
      if (!client.writableEnded) client.write(frame)
    }
  }

  // The agent asking for the next annotation is the completion signal for the
  // one it was serving; the wait CLI only re-enters after a 204 while idle.
  function completeWorking() {
    let changed = false
    for (const [id, state] of annotationStates) {
      if (state === "working") {
        annotationStates.set(id, "done")
        changed = true
      }
    }
    if (changed) broadcastAnnotations()
  }

  function flushHeld() {
    if (heldQueue.length === 0) return
    for (const item of heldQueue) {
      annotationQueue.push(item)
      if (annotationStates.get(item.id) === "held") annotationStates.set(item.id, "queued")
    }
    heldQueue.length = 0
  }

  function serveBatch(res, items) {
    for (const item of items) annotationStates.set(item.id, "working")
    res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" })
    res.end(`${JSON.stringify(items)}\n`)
    broadcastAnnotations()
  }

  function fulfillWaiters() {
    while (waiters.length > 0 && annotationQueue.length > 0) {
      const parked = waiters.shift()
      clearTimeout(parked.timer)
      if (parked.res.writableEnded) continue
      serveBatch(parked.res, annotationQueue.splice(0, annotationQueue.length))
    }
  }

  // The client reloads on this event; the browser then owns every
  // reconciliation (head, html/body attributes, linked assets, scripts).
  function broadcastScreenChange(key) {
    lastBroadcastKey = key
    const payload = JSON.stringify({ version: key })
    for (const client of sseClients) {
      if (!client.writableEnded) {
        client.write(`event: screen-changed\ndata: ${payload}\n\n`)
      }
    }
  }

  function broadcastIfChanged() {
    const key = screensChangeKey(options)
    if (key !== lastBroadcastKey) broadcastScreenChange(key)
  }

  function authorized(req) {
    return requestCredentials(req, cookieName).some((candidate) => tokenMatches(candidate, sessionToken))
  }

  function requireAnnotateToken(req, res) {
    if (authorized(req)) return true
    sendJson(res, 401, { error: "unauthorized" })
    return false
  }

  function requireLiveAnnotate(req, res) {
    if (!requireAnnotateToken(req, res)) return false
    if (sessionEnded) {
      sendJson(res, 410, { status: "session-ended" })
      return false
    }
    return true
  }

  function requestOrigin(req) {
    const host = req.headers.host
    if (typeof host === "string" && HOST_HEADER.test(host)) return `http://${host}`
    return `http://${DEFAULT_URL_HOST}:${server.address().port}`
  }

  function stampOverlayDocument(html, documentId) {
    const marker = `${OVERLAY_PREFIX}/annotate.js"`
    const at = html.indexOf(marker)
    if (at === -1) return html
    const after = at + marker.length
    return `${html.slice(0, after)} data-ce-session="${htmlAttr(overlaySession)}" data-ce-document="${htmlAttr(documentId)}"${html.slice(after)}`
  }

  function forgetPendingDocument(id) {
    return pendingDocuments.delete(id)
  }

  function abandonPendingDocument(id) {
    if (forgetPendingDocument(id) && sseClients.size === 0 && sawSseClient && !sessionEnded) {
      armSseGrace()
    }
  }

  function unbindPendingFromSocket(socket, exceptId) {
    if (!socket) return
    for (const [id, entry] of pendingDocuments) {
      if (entry.socket !== socket || id === exceptId) continue
      entry.socket = null
    }
  }

  function retainPendingDocument(id, socket) {
    unbindPendingFromSocket(socket, id)
    pendingDocuments.set(id, { socket: socket || null })
  }

  // Every document that carries the overlay is served the same way.
  // `renderedKey` is captured with `html`; scanning screens/ here races a rewrite.
  function serveAnnotateDocument(req, res, html, renderedKey) {
    // A page being served is a tab loading, not the last tab closing. The
    // overlay is deferred and may sit behind parser-blocking work, so cancel
    // the reconnect grace until that document's /events connects; ending on
    // the short elapsed timeout would kill a still-loading tab. The old
    // stream's close can arrive after this response; it must not start grace
    // while any replacement is still pending. /events names the document it
    // completes, so another tab's reconnect cannot consume this pending load.
    // The handshake dies when this document can no longer open /events: the
    // request aborted before the body was delivered, /events completed it, or
    // the session ended. A completed response is not that; the overlay may
    // connect on a new connection. Keep-alive reuse is not replacement:
    // another document on the same socket leaves this pending in place.
    // A script fetching the page is not a tab loading, so it does not create
    // a handshake.
    const pending = randomUUID()
    if (isDocumentNavigation(req)) {
      retainPendingDocument(pending, req.socket)
      if (sseGraceTimer) {
        clearTimeout(sseGraceTimer)
        sseGraceTimer = null
      }
      req.on("close", () => {
        if (res.writableEnded) return
        abandonPendingDocument(pending)
      })
    }
    // Sync the change key to what this page will render, so a stream that
    // connects right after load does not reload the same screen. Any
    // already-open stream still receives the change.
    if (renderedKey !== lastBroadcastKey) broadcastScreenChange(renderedKey)
    const headers = {
      "Content-Type": CONTENT_TYPES[".html"],
      ...NO_STORE,
      "Referrer-Policy": "no-referrer",
    }
    if (cookieName && sessionToken) {
      headers["Set-Cookie"] = `${cookieName}=${sessionToken}; HttpOnly; SameSite=Strict; Path=/`
    }
    res.writeHead(200, headers)
    res.end(stampOverlayDocument(html, pending))
  }

  function armSseGrace() {
    if (pendingDocuments.size > 0 || sessionEnded) return
    if (sseGraceTimer) clearTimeout(sseGraceTimer)
    sseGraceTimer = setTimeout(() => {
      if (sseClients.size === 0 && pendingDocuments.size === 0) endSession()
    }, SSE_GRACE_MS)
    sseGraceTimer.unref()
  }

  async function handleRequest(req, res) {
    const urlPath = req.url.split("?")[0].split("#")[0]

    if (req.method === "GET" && urlPath === "/version") {
      unbindPendingFromSocket(req.socket)
      res.writeHead(200, {
        "Content-Type": "application/json; charset=utf-8",
        "Cache-Control": "no-store",
      })
      res.end(`${JSON.stringify(screenVersion(options))}\n`)
      return
    }

    if (options.annotate) {
      if (req.method === "GET" && urlPath === "/wait") {
        unbindPendingFromSocket(req.socket)
        if (!requireAnnotateToken(req, res)) return
        broadcastIfChanged()
        completeWorking()
        if (annotationQueue.length > 0) {
          serveBatch(res, annotationQueue.splice(0, annotationQueue.length))
          return
        }
        if (sessionEnded) {
          sendJson(res, 410, { status: "session-ended" })
          return
        }
        const parked = { res, timer: null }
        parked.timer = setTimeout(() => {
          const index = waiters.indexOf(parked)
          if (index !== -1) waiters.splice(index, 1)
          if (!res.writableEnded) {
            res.writeHead(204)
            res.end()
          }
        }, WAIT_TIMEOUT_MS)
        waiters.push(parked)
        req.on("close", () => {
          clearTimeout(parked.timer)
          const index = waiters.indexOf(parked)
          if (index !== -1) waiters.splice(index, 1)
        })
        return
      }

      if (req.method === "POST" && urlPath === "/annotation") {
        unbindPendingFromSocket(req.socket)
        if (!requireLiveAnnotate(req, res)) return
        let raw
        try {
          raw = await readBody(req)
        } catch {
          sendJson(res, 400, { error: "invalid annotation" })
          return
        }
        // The session can end while the body is still arriving.
        if (sessionEnded) {
          sendJson(res, 410, { status: "session-ended" })
          return
        }
        const record = parseAnnotation(raw, options)
        if (!record) {
          sendJson(res, 400, { error: "invalid annotation" })
          return
        }
        heldQueue.push(record)
        annotationStates.set(record.id, "held")
        touch()
        broadcastAnnotations()
        sendJson(res, 200, { ok: true, id: record.id })
        return
      }

      if (req.method === "POST" && urlPath === "/session/flush") {
        unbindPendingFromSocket(req.socket)
        if (!requireLiveAnnotate(req, res)) return
        flushHeld()
        broadcastAnnotations()
        fulfillWaiters()
        sendJson(res, 200, { ok: true })
        return
      }

      if (req.method === "POST" && urlPath === "/session/end") {
        unbindPendingFromSocket(req.socket)
        if (!requireAnnotateToken(req, res)) return
        endSession()
        sendJson(res, 200, { status: "session-ended" })
        return
      }

      if (req.method === "GET" && urlPath === "/events") {
        if (!requireLiveAnnotate(req, res)) return
        res.writeHead(200, {
          "Content-Type": "text/event-stream",
          "Cache-Control": "no-cache",
          Connection: "keep-alive",
        })
        res.write(":ok\n\n")
        // A client that just reloaded reconciles its pins from this frame.
        res.write(`event: annotations\ndata: ${annotationsPayload()}\n\n`)
        sawSseClient = true
        const documentId = new URL(req.url, "http://127.0.0.1").searchParams.get("document")
        if (documentId) forgetPendingDocument(documentId)
        unbindPendingFromSocket(req.socket, documentId)
        if (sseGraceTimer) {
          clearTimeout(sseGraceTimer)
          sseGraceTimer = null
        }
        sseClients.add(res)
        req.on("close", () => {
          sseClients.delete(res)
          if (sseClients.size === 0 && sawSseClient && !sessionEnded) armSseGrace()
        })
        return
      }

      if (req.method === "GET" && OVERLAY_FILES[urlPath]) {
        safeFileResponse(assetsDir, { url: `/${OVERLAY_FILES[urlPath]}` }, res, NO_STORE)
        return
      }

      if (req.method === "GET" && urlPath === "/") {
        touch()
        if (isDocumentNavigation(req)) {
          const renderedKey = screensChangeKey(options)
          serveAnnotateDocument(req, res, renderPage(options, requestOrigin(req)), renderedKey)
          return
        }
        const screen = newestScreen(options)
        if (screen) {
          sendFile(screen, res, NO_STORE)
          return
        }
        res.writeHead(200, { "Content-Type": CONTENT_TYPES[".html"], ...NO_STORE })
        res.end(WAITING_HTML)
        return
      }

      // A linked page under screens/ is a screen too: navigated to, it carries
      // the same overlay and stream, or the session would end at the first
      // navigation. It stays ungated like every other screen file. Fetched by
      // a script, the same file is a partial and is served raw.
      if (req.method === "GET") {
        touch()
        const filePath = resolveContainedFile(options.screensDir, req, res)
        if (!filePath) return
        if (contentType(filePath) === CONTENT_TYPES[".html"] && isDocumentNavigation(req)) {
          const renderedKey = screensChangeKey(options)
          serveAnnotateDocument(req, res, annotateScreen(fs.readFileSync(filePath, "utf8"), requestOrigin(req), urlPath), renderedKey)
          return
        }
        // A reload must pick up a revised stylesheet or script whose URL did
        // not change; a cached copy would show the old screen.
        sendFile(filePath, res, NO_STORE)
        return
      }
    }

    if (req.method === "GET" && urlPath === "/") {
      touch()
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
      res.end(renderPage(options))
      return
    }
    if (req.method === "GET") {
      touch()
      safeFileResponse(options.screensDir, req, res)
      return
    }
    res.writeHead(404)
    res.end("Not found")
  }

  const server = http.createServer((req, res) => {
    Promise.resolve(handleRequest(req, res)).catch(() => {
      if (!res.headersSent) {
        res.writeHead(500)
        res.end("Internal error")
      }
    })
  })

  server.listen(options.port, options.host, () => {
    const address = server.address()
    const port = typeof address === "object" && address ? address.port : options.port
    cookieName = `ce-light-web-${port}`
    const baseUrl = `http://${DEFAULT_URL_HOST}:${port}`
    const info = {
      status: "running",
      root: options.root,
      host: options.host,
      port,
      url: baseUrl,
      screen_dir: options.screensDir,
      state_dir: options.stateDir,
      pid: process.pid,
      owner_pid: options.ownerPid ?? null,
      ...(sessionToken ? { token: sessionToken, annotate: true } : {}),
    }
    publishedInfo = info
    fs.writeFileSync(options.pidFile, `${process.pid}\n`)
    fs.writeFileSync(options.infoFile, `${JSON.stringify(info, null, 2)}\n`)
    console.log(JSON.stringify(info))
  })

  // An open change stream or parked wait is an active connection, and
  // server.close waits for those forever; end the session so they drain.
  // CLI `stop` sends SIGTERM; without this handler the process exits before
  // waiters receive session-ended.
  function shutdown() {
    Promise.resolve(endSession()).finally(() => {
      server.close(() => process.exit(0))
      server.closeAllConnections()
    })
  }
  process.on("SIGTERM", shutdown)
  process.on("SIGINT", shutdown)

  const idleTimer = setInterval(() => {
    if (options.ownerPid && !processAlive(options.ownerPid)) {
      shutdown()
    } else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) {
      shutdown()
    }
  }, LIFECYCLE_CHECK_MS)
  idleTimer.unref()

  if (options.annotate) {
    const changeTimer = setInterval(() => {
      if (sseClients.size > 0) broadcastIfChanged()
    }, 250)
    changeTimer.unref()
  }
}

async function stopServer(options) {
  const pid = readPid(options)
  if (processAlive(pid) && ownsServerProcess(options, pid)) {
    process.kill(pid)
    for (let i = 0; i < 20; i++) {
      if (!processAlive(pid)) break
      await new Promise((resolve) => setTimeout(resolve, 100))
    }
    if (processAlive(pid)) {
      try {
        process.kill(pid, "SIGKILL")
      } catch {
        // Process may have exited between the liveness check and kill.
      }
    }
  }
  fs.rmSync(options.pidFile, { force: true })
}

async function stop(options) {
  await stopServer(options)
  jsonOut({ status: "stopped", root: options.root })
}

function status(options) {
  const info = getRunningInfo(options)
  if (!info) {
    jsonOut({ status: "stopped", root: options.root })
    return
  }
  jsonOut({ ...info, status: "running" })
}

async function main() {
  let command
  try {
    const options = parseArgs(process.argv)
    command = options.command
    if (options.command === "start") await start(options)
    else if (options.command === "serve") await serve(options)
    else if (options.command === "stop") await stop(options)
    else if (options.command === "status") status(options)
    else if (options.command === "wait") await wait(options)
  } catch (error) {
    console.error(error.message)
    // Wait reserves exit 1 for session-ended; any other failure is exit 2.
    process.exit((command ?? process.argv[2]) === "wait" ? 2 : 1)
  }
}

await main()

SHA-256: 4146d6a18f4261b6b78fdd4cc65a67dad4c34ec9c986664a54c42a985d55eb6e