#!/usr/bin/env node
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 DEFAULT_HOST = "127.0.0.1"
const DEFAULT_URL_HOST = "localhost"
const IDLE_TIMEOUT_MS = Number(process.env.CE_VISUAL_PROBE_IDLE_TIMEOUT_MS) || 30 * 60 * 1000
const LIFECYCLE_CHECK_MS = Number(process.env.CE_VISUAL_PROBE_LIFECYCLE_CHECK_MS) || 60 * 1000
function usage() {
return [
"Usage:",
" node visual-probe-server.js start --root
[--host 127.0.0.1] [--port 0] [--foreground] [--owner-pid ]",
" node visual-probe-server.js stop --root ",
" node visual-probe-server.js status --root ",
].join("\n")
}
function parseArgs(argv) {
const command = argv[2]
const options = {
command,
host: DEFAULT_HOST,
port: 0,
foreground: 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 {
throw new Error(`Unknown argument: ${arg}`)
}
}
if (!["start", "serve", "stop", "status"].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
return args.includes(scriptPath) && args.includes("serve") && args.includes(options.root)
}
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
return readJson(options.infoFile)
}
function newestScreen(options) {
if (!fs.existsSync(options.screensDir)) return null
const files = fs.readdirSync(options.screensDir)
.filter((file) => file.endsWith(".html"))
.map((file) => {
const filePath = path.join(options.screensDir, file)
return { filePath, mtimeMs: fs.statSync(filePath).mtimeMs }
})
.sort((a, b) => b.mtimeMs - a.mtimeMs)
return files[0]?.filePath ?? null
}
function isFullDocument(html) {
const trimmed = html.trimStart().toLowerCase()
return trimmed.startsWith("
(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);
})();
`
}
function wrapFragment(options, content) {
return `
CE Brainstorm Visual Probe
CE Brainstorm Visual Probe - directional sketch, reply in chat
${content}
${refreshScript(options)}
`
}
function injectRefresh(options, html) {
if (html.includes("