import { type ChildProcess, execFile, execSync, type SpawnOptions, spawn, } from "node:child_process"; import { randomBytes } from "node:crypto"; import { existsSync, readFileSync, readlinkSync, unlinkSync, writeFileSync, } from "node:fs"; import { request as httpRequest } from "node:http"; import { createServer } from "node:net"; import { join, resolve } from "node:path"; import { appRoot, webPidFilePath as defaultWebPidFilePath, } from "./app-paths.js"; const DEFAULT_HOST = "127.0.0.1"; const DEFAULT_PACKAGE_ROOT = resolve(import.meta.dirname, ".."); /** Open a URL in the user's default browser. */ function openBrowser(url: string): void { if (process.platform === "win32") { // PowerShell's Start-Process handles URLs with '&' safely; cmd /c start does not. execFile( "powershell", ["-c", `Start-Process '${url.replace(/'/g, "''")}'`], { windowsHide: true }, () => {}, ); } else { const cmd = process.platform === "darwin" ? "open" : "xdg-open"; execFile(cmd, [url], () => {}); } } type WritableLike = Pick; type ResourceBootstrapLike = { initResources: (agentDir: string) => void; }; type SpawnedChildLike = Pick; export interface WebModeLaunchOptions { cwd: string; projectSessionsDir: string; agentDir: string; packageRoot?: string; host?: string; port?: number; /** Additional allowed origins for CORS (forwarded as SF_WEB_ALLOWED_ORIGINS). */ allowedOrigins?: string[]; } export interface ResolvedWebHostBootstrap { ok: true; kind: "packaged-standalone" | "source-dev"; packageRoot: string; hostRoot: string; entryPath: string; } export interface UnresolvedWebHostBootstrap { ok: false; packageRoot: string; reason: string; candidates: string[]; } export type WebHostBootstrap = | ResolvedWebHostBootstrap | UnresolvedWebHostBootstrap; export interface WebModeLaunchSuccess { mode: "web"; ok: true; cwd: string; projectSessionsDir: string; host: string; port: number; url: string; hostKind: ResolvedWebHostBootstrap["kind"]; hostPath: string; hostRoot: string; } export interface WebModeLaunchFailure { mode: "web"; ok: false; cwd: string; projectSessionsDir: string; host: string; port: number | null; url: string | null; hostKind: ResolvedWebHostBootstrap["kind"] | "unresolved"; hostPath: string | null; hostRoot: string | null; failureReason: string; candidates?: string[]; } export type WebModeLaunchStatus = WebModeLaunchSuccess | WebModeLaunchFailure; export interface WebModeDeps { existsSync?: (path: string) => boolean; initResources?: (agentDir: string) => void; resolvePort?: (host: string) => Promise; spawn?: ( command: string, args: readonly string[], options: SpawnOptions, ) => SpawnedChildLike; waitForBootReady?: (url: string) => Promise; openBrowser?: (url: string) => void; stderr?: WritableLike; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; execPath?: string; pidFilePath?: string; writePidFile?: (path: string, pid: number) => void; readPidFile?: (path: string) => number | null; deletePidFile?: (path: string) => void; /** Path to the multi-instance registry JSON (for testing). */ registryPath?: string; } export interface WebModeStopResult { ok: boolean; reason?: string; /** How many instances were stopped (relevant for --all) */ stoppedCount?: number; } // ─── Instance Registry ────────────────────────────────────────────────────── export interface WebInstanceEntry { pid: number; port: number; url: string; cwd: string; startedAt: string; } export type WebInstanceRegistry = Record; const WEB_INSTANCES_PATH = join(appRoot, "web-instances.json"); export function readInstanceRegistry( registryPath = WEB_INSTANCES_PATH, ): WebInstanceRegistry { try { return JSON.parse( readFileSync(registryPath, "utf8"), ) as WebInstanceRegistry; } catch { return {}; } } export function writeInstanceRegistry( registry: WebInstanceRegistry, registryPath = WEB_INSTANCES_PATH, ): void { writeFileSync(registryPath, JSON.stringify(registry, null, 2), "utf8"); } export function registerInstance( cwd: string, entry: Omit, registryPath = WEB_INSTANCES_PATH, ): void { const registry = readInstanceRegistry(registryPath); registry[resolve(cwd)] = { ...entry, cwd: resolve(cwd), startedAt: new Date().toISOString(), }; writeInstanceRegistry(registry, registryPath); } export function unregisterInstance( cwd: string, registryPath = WEB_INSTANCES_PATH, ): void { const registry = readInstanceRegistry(registryPath); delete registry[resolve(cwd)]; writeInstanceRegistry(registry, registryPath); } function killPid(pid: number): "killed" | "already-dead" | { error: string } { try { process.kill(pid, "SIGTERM"); return "killed"; } catch (error) { const isAlreadyDead = error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ESRCH"; if (isAlreadyDead) return "already-dead"; return { error: error instanceof Error ? error.message : String(error) }; } } export function writePidFile(filePath: string, pid: number): void { writeFileSync(filePath, String(pid), "utf8"); } export function readPidFile(filePath: string): number | null { try { const content = readFileSync(filePath, "utf8").trim(); const pid = parseInt(content, 10); return Number.isFinite(pid) && pid > 0 ? pid : null; } catch { return null; } } export function deletePidFile(filePath: string): void { try { unlinkSync(filePath); } catch { // Non-fatal — file may already be gone } } export interface WebModeStopOptions { /** Stop instance for a specific project path */ projectCwd?: string; /** Stop all running instances */ all?: boolean; } export function stopWebMode( deps: Pick< WebModeDeps, "pidFilePath" | "readPidFile" | "deletePidFile" | "stderr" > = {}, options: WebModeStopOptions = {}, ): WebModeStopResult { const stderr = deps.stderr ?? process.stderr; // ── Stop all instances ────────────────────────────────────────────── if (options.all) { const registry = readInstanceRegistry(); const entries = Object.entries(registry); if (entries.length === 0) { // Fall back to legacy PID file return stopLegacyPidFile(deps); } let stopped = 0; for (const [cwd, entry] of entries) { const result = killPid(entry.pid); if (result === "killed") { stderr.write( `[forge] Stopped web server for ${cwd} (pid=${entry.pid})\n`, ); stopped++; } else if (result === "already-dead") { stderr.write( `[forge] Web server for ${cwd} was already stopped (pid=${entry.pid})\n`, ); stopped++; } else { stderr.write( `[forge] Failed to stop web server for ${cwd}: ${result.error}\n`, ); } unregisterInstance(cwd); } // Also clean up legacy PID file const deletePid = deps.deletePidFile ?? deletePidFile; const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath; deletePid(pidFilePath); stderr.write( `[forge] Stopped ${stopped} instance${stopped === 1 ? "" : "s"}.\n`, ); // Also reap orphaned next-server processes (sf-mooe4m5k-6fm7z9) const orphanResult = reapOrphanedNextServerProcesses(stderr); if (orphanResult.reaped > 0) { stderr.write( `[forge] Reaped ${orphanResult.reaped} orphaned next-server process${orphanResult.reaped === 1 ? "" : "es"}.\n`, ); } return { ok: true, stoppedCount: stopped }; } // ── Stop specific project ────────────────────────────────────────── if (options.projectCwd) { const resolvedCwd = resolve(options.projectCwd); const registry = readInstanceRegistry(); const entry = registry[resolvedCwd]; if (!entry) { stderr.write(`[forge] No web server running for ${resolvedCwd}\n`); return { ok: false, reason: "not-found" }; } const result = killPid(entry.pid); unregisterInstance(resolvedCwd); if (result === "killed") { stderr.write( `[forge] Stopped web server for ${resolvedCwd} (pid=${entry.pid})\n`, ); return { ok: true, stoppedCount: 1 }; } else if (result === "already-dead") { stderr.write( `[forge] Web server for ${resolvedCwd} was already stopped — cleared stale entry.\n`, ); return { ok: true, stoppedCount: 1 }; } else { stderr.write( `[forge] Failed to stop web server for ${resolvedCwd}: ${result.error}\n`, ); return { ok: false, reason: result.error }; } } // ── Default: stop via legacy PID file (backward compat) ───────────── return stopLegacyPidFile(deps); } function stopLegacyPidFile( deps: Pick< WebModeDeps, "pidFilePath" | "readPidFile" | "deletePidFile" | "stderr" >, ): WebModeStopResult { const stderr = deps.stderr ?? process.stderr; const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath; const readPid = deps.readPidFile ?? readPidFile; const deletePid = deps.deletePidFile ?? deletePidFile; const pid = readPid(pidFilePath); if (pid === null) { stderr.write(`[forge] Web server is not running (no PID file found)\n`); return { ok: false, reason: "no-pid-file" }; } stderr.write(`[forge] Stopping web server (pid=${pid})…\n`); const result = killPid(pid); deletePid(pidFilePath); if (result === "killed") { stderr.write(`[forge] Web server stopped.\n`); return { ok: true }; } else if (result === "already-dead") { stderr.write( `[forge] Web server was already stopped — cleared stale PID file.\n`, ); return { ok: true }; } else { stderr.write(`[forge] Failed to stop web server: ${result.error}\n`); return { ok: false, reason: result.error }; } } async function loadResourceBootstrap(): Promise { const mod = await import("./resource-loader.js"); return { initResources: mod.initResources, }; } export function resolveWebHostBootstrap( options: { packageRoot?: string; existsSync?: (path: string) => boolean; } = {}, ): WebHostBootstrap { const packageRoot = options.packageRoot ?? DEFAULT_PACKAGE_ROOT; const checkExists = options.existsSync ?? existsSync; const packagedStandaloneServer = join( packageRoot, "dist", "web", "standalone", "server.js", ); if (checkExists(packagedStandaloneServer)) { return { ok: true, kind: "packaged-standalone", packageRoot, hostRoot: join(packageRoot, "dist", "web", "standalone"), entryPath: packagedStandaloneServer, }; } const sourceWebRoot = join(packageRoot, "web"); const sourceManifest = join(sourceWebRoot, "package.json"); if (checkExists(sourceManifest)) { return { ok: true, kind: "source-dev", packageRoot, hostRoot: sourceWebRoot, entryPath: sourceManifest, }; } return { ok: false, packageRoot, reason: "host bootstrap not found", candidates: [packagedStandaloneServer, sourceManifest], }; } export async function reserveWebPort(host = DEFAULT_HOST): Promise { return await new Promise((resolvePort, reject) => { const server = createServer(); server.unref(); server.once("error", reject); server.listen(0, host, () => { const address = server.address(); if (!address || typeof address === "string") { server.close(() => reject(new Error("failed to determine reserved web port")), ); return; } server.close((error) => { if (error) { reject(error); return; } resolvePort(address.port); }); }); }); } function getSpawnCommandForSourceHost(platform: NodeJS.Platform): string { return platform === "win32" ? "npm.cmd" : "npm"; } function needsWindowsShell( command: string, platform: NodeJS.Platform, ): boolean { return platform === "win32" && /\.(cmd|bat)$/i.test(command); } function formatLaunchStatus(status: WebModeLaunchStatus): string { if (status.ok) { return `[forge] Web mode startup: status=started cwd=${status.cwd} port=${status.port} host=${status.hostPath} kind=${status.hostKind} url=${status.url}\n`; } return `[forge] Web mode startup: status=failed cwd=${status.cwd} port=${status.port ?? "n/a"} host=${status.hostPath ?? "unresolved"} kind=${status.hostKind} reason=${(status as any).failureReason}\n`; } function emitLaunchStatus( stderr: WritableLike, status: WebModeLaunchStatus, ): void { stderr.write(formatLaunchStatus(status)); } function buildSpawnSpec( resolution: ResolvedWebHostBootstrap, host: string, port: number, platform: NodeJS.Platform, execPath: string, ): { command: string; args: string[]; cwd: string } { if (resolution.kind === "packaged-standalone") { return { command: execPath, args: [resolution.entryPath], cwd: resolution.hostRoot, }; } return { command: getSpawnCommandForSourceHost(platform), args: ["run", "dev", "--", "--hostname", host, "--port", String(port)], cwd: resolution.hostRoot, }; } async function spawnDetachedProcess( spawnCommand: ( command: string, args: readonly string[], options: SpawnOptions, ) => SpawnedChildLike, command: string, args: string[], options: SpawnOptions, ): Promise< { ok: true; child: SpawnedChildLike } | { ok: false; error: unknown } > { return await new Promise((resolve) => { try { const child = spawnCommand(command, args, options); let settled = false; const finish = ( result: | { ok: true; child: SpawnedChildLike } | { ok: false; error: unknown }, ) => { if (settled) return; settled = true; resolve(result); }; child.once?.("error", (error) => finish({ ok: false, error })); setImmediate(() => finish({ ok: true, child })); } catch (error) { resolve({ ok: false, error }); } }); } async function requestLocalJson( url: string, timeoutMs: number, authToken?: string, ): Promise<{ statusCode: number; body: string }> { return await new Promise((resolve, reject) => { const headers: Record = { Accept: "application/json", // Keep launch readiness on the cheapest uncompressed path. The // packaged host can spend noticeable time compressing the large boot // snapshot, which adds avoidable startup jitter for a local health // check that only needs the JSON payload itself. "Accept-Encoding": "identity", }; if (authToken) { headers["Authorization"] = `Bearer ${authToken}`; } const request = httpRequest( url, { method: "GET", headers, }, (response) => { const statusCode = response.statusCode ?? 0; let body = ""; response.setEncoding("utf8"); response.on("data", (chunk) => { body += chunk; }); response.on("end", () => resolve({ statusCode, body })); }, ); request.setTimeout(timeoutMs, () => { request.destroy(new Error(`request timed out after ${timeoutMs}ms`)); }); request.once("error", reject); request.end(); }); } async function waitForBootReady( url: string, timeoutMs = 180_000, stderr?: WritableLike, authToken?: string, ): Promise { const deadline = Date.now() + timeoutMs; const startedAt = Date.now(); let lastError: string | null = null; let lastBody: string | null = null; let hostUp = false; let consecutive5xx = 0; const MAX_CONSECUTIVE_5XX = 3; // Print a progress dot every N ms while waiting so the terminal isn't silent const TICKER_INTERVAL_MS = 5_000; let lastTickAt = startedAt; const elapsed = () => `${Math.round((Date.now() - startedAt) / 1000)}s`; while (Date.now() < deadline) { try { // Give the packaged host enough time to finish a cold /api/boot render. const response = await requestLocalJson( `${url}/api/boot`, 45_000, authToken, ); if (response.statusCode >= 200 && response.statusCode < 300) { if (!hostUp) { hostUp = true; stderr?.write(`[forge] Web host ready.\n`); } consecutive5xx = 0; // Host responded successfully — it's ready for the browser return; } else if (response.statusCode >= 500) { consecutive5xx++; lastError = `http ${response.statusCode}`; lastBody = response.body || null; if (consecutive5xx >= MAX_CONSECUTIVE_5XX) { const detail = lastBody ? `: ${lastBody.slice(0, 500)}` : ""; throw new Error( `boot route returned ${MAX_CONSECUTIVE_5XX} consecutive 5xx responses (last: ${response.statusCode})${detail}`, ); } } else { consecutive5xx = 0; lastError = `http ${response.statusCode}`; } } catch (error) { if ( error instanceof Error && error.message.startsWith("boot route returned") ) { throw error; } // Connection refused, timeout, etc. — transient during cold start consecutive5xx = 0; lastError = error instanceof Error ? error.message : String(error); } // Emit a heartbeat line every TICKER_INTERVAL_MS to show we're alive const now = Date.now(); if (now - lastTickAt >= TICKER_INTERVAL_MS) { lastTickAt = now; if (hostUp) { stderr?.write(`[forge] Still waiting… (${elapsed()})\n`); } else { stderr?.write(`[forge] Waiting for web host… (${elapsed()})\n`); } } await new Promise((resolve) => setTimeout(resolve, 250)); } throw new Error(lastError ?? "timed out waiting for boot readiness"); } /** * If a previous web server instance is registered for the same `cwd`, attempt * to kill it and remove its registry entry so the new launch can bind the port * cleanly. This handles the "orphan process" scenario where a prior `sf --web` * was terminated without clean shutdown (e.g. terminal closed). */ function cleanupStaleInstance( cwd: string, stderr: WritableLike, registryPath?: string, ): void { const registry = readInstanceRegistry(registryPath); const key = resolve(cwd); const stale = registry[key]; if (!stale) return; stderr.write( `[forge] Cleaning up stale web server for ${key} (pid=${stale.pid}, port=${stale.port})…\n`, ); const result = killPid(stale.pid); if (result === "killed") { stderr.write(`[forge] Killed stale web server (pid=${stale.pid}).\n`); } else if (result === "already-dead") { stderr.write( `[forge] Stale web server was already stopped (pid=${stale.pid}) — clearing entry.\n`, ); } else { stderr.write( `[forge] Could not kill stale web server (pid=${stale.pid}): ${result.error}\n`, ); } unregisterInstance(cwd, registryPath); } /** * Detect and reap orphaned next-server processes that outlived their parent * web host. These orphans have cwd under dist/web/standalone (or a deleted * variant) and parent PID 1 (init). They are created when the web host process * exits without cleanly terminating its next-server child. * * Purpose: prevent stale next-server processes from accumulating and holding * ports or consuming resources after sf web stop or host replacement. * Consumer: launchWebMode before binding a new port, and stopWebMode --all. * * AC2 from sf-mooe4m5k-6fm7z9: Orphaned next-server processes with cwd under * dist/web/standalone are detected and reaped on next web launch for the same repo. */ export function reapOrphanedNextServerProcesses( stderr: WritableLike, packageRoot = DEFAULT_PACKAGE_ROOT, deps: { execSync?: typeof execSync; readlinkSync?: typeof readlinkSync; kill?: typeof process.kill; platform?: NodeJS.Platform; } = {}, ): { reaped: number; errors: string[] } { const errors: string[] = []; let reaped = 0; const platform = deps.platform ?? process.platform; if (platform === "win32") { // Windows orphan detection not implemented; rely on port-kill fallback return { reaped: 0, errors: [] }; } try { // Find next-server processes with cwd matching our standalone host path const standalonePath = resolve(packageRoot, "dist", "web", "standalone"); // Use ps to find node processes with next-server in their command line const psOutput = (deps.execSync ?? execSync)( "ps -eo pid,ppid,cmd,comm --no-headers", { encoding: "utf8", timeout: 5000 }, ) as string; const lines = psOutput.split("\n").filter((line) => line.trim()); for (const line of lines) { const parts = line.trim().split(/\s+/); if (parts.length < 4) continue; const pidStr = parts[0]; const ppidStr = parts[1]; const cmd = parts.slice(2).join(" "); const pid = Number.parseInt(pidStr, 10); const ppid = Number.parseInt(ppidStr, 10); if (!Number.isFinite(pid) || pid <= 1) continue; // Look for next-server in command line if (!cmd.includes("next-server") && !cmd.includes("server.js")) continue; // Check if the process cwd matches our standalone path (or deleted variant) let cwd: string | null = null; try { cwd = (deps.readlinkSync ?? readlinkSync)(`/proc/${pid}/cwd`); } catch { // Process may have exited between ps and readlink continue; } if ( cwd && (cwd.startsWith(standalonePath) || cwd.includes("standalone (deleted)")) ) { // Orphan: parent is init (ppid=1) or the parent is dead const isOrphan = ppid === 1; if (isOrphan) { try { (deps.kill ?? process.kill)(pid, "SIGTERM"); reaped++; stderr.write( `[forge] Reaped orphaned next-server (pid=${pid}, cwd=${cwd})\n`, ); } catch (killErr) { const msg = killErr instanceof Error ? killErr.message : String(killErr); errors.push(`pid=${pid}: ${msg}`); } } } } } catch (execErr) { const msg = execErr instanceof Error ? execErr.message : String(execErr); errors.push(`ps exec failed: ${msg}`); } return { reaped, errors }; } export async function launchWebMode( options: WebModeLaunchOptions, deps: WebModeDeps = {}, ): Promise { const stderr = deps.stderr ?? process.stderr; const host = options.host ?? DEFAULT_HOST; const resolution = resolveWebHostBootstrap({ packageRoot: options.packageRoot, existsSync: deps.existsSync, }); if (!resolution.ok) { const failure: WebModeLaunchFailure = { mode: "web", ok: false, cwd: options.cwd, projectSessionsDir: options.projectSessionsDir, host, port: null, url: null, hostKind: "unresolved", hostPath: null, hostRoot: null, failureReason: `${resolution.reason}; checked=${resolution.candidates.join(",")}`, candidates: resolution.candidates, }; emitLaunchStatus(stderr, failure); return failure; } stderr.write(`[forge] Starting web mode…\n`); // Kill any stale server instance for this project before reserving a port. // This prevents EADDRINUSE when the previous `sf --web` was terminated // without a clean shutdown (e.g. terminal closed, crash). cleanupStaleInstance(options.cwd, stderr, deps.registryPath); // Also reap orphaned next-server processes from prior unclean shutdowns // (sf-mooe4m5k-6fm7z9): orphaned next-server processes with cwd under // dist/web/standalone are detected and reaped on next web launch. const orphanResult = reapOrphanedNextServerProcesses( stderr, options.packageRoot, ); if (orphanResult.reaped > 0) { stderr.write( `[forge] Reaped ${orphanResult.reaped} orphaned next-server process${orphanResult.reaped === 1 ? "" : "es"} before launch.\n`, ); } const port = options.port ?? (await (deps.resolvePort ?? reserveWebPort)(host)); const authToken = randomBytes(32).toString("hex"); const url = `http://${host}:${port}`; const env = { ...(deps.env ?? process.env), HOSTNAME: host, PORT: String(port), SF_WEB_HOST: host, SF_WEB_PORT: String(port), SF_WEB_AUTH_TOKEN: authToken, SF_WEB_PROJECT_CWD: options.cwd, SF_WEB_PROJECT_SESSIONS_DIR: options.projectSessionsDir, SF_WEB_PACKAGE_ROOT: resolution.packageRoot, SF_WEB_HOST_KIND: resolution.kind, ...(resolution.kind === "source-dev" ? { NEXT_PUBLIC_SF_DEV: "1" } : {}), ...(options.allowedOrigins?.length ? { SF_WEB_ALLOWED_ORIGINS: options.allowedOrigins.join(",") } : {}), }; try { stderr.write(`[forge] Initialising resources…\n`); const bootstrap = deps.initResources ? { initResources: deps.initResources } : await loadResourceBootstrap(); bootstrap.initResources(options.agentDir); } catch (error) { const failure: WebModeLaunchFailure = { mode: "web", ok: false, cwd: options.cwd, projectSessionsDir: options.projectSessionsDir, host, port, url, hostKind: resolution.kind, hostPath: resolution.entryPath, hostRoot: resolution.hostRoot, failureReason: `bootstrap:${error instanceof Error ? error.message : String(error)}`, }; emitLaunchStatus(stderr, failure); return failure; } const spawnSpec = buildSpawnSpec( resolution, host, port, deps.platform ?? process.platform, deps.execPath ?? process.execPath, ); stderr.write(`[forge] Launching web host on port ${port}…\n`); const spawnResult = await spawnDetachedProcess( deps.spawn ?? ((command, args, spawnOptions) => spawn(command, args, spawnOptions)), spawnSpec.command, spawnSpec.args, { cwd: spawnSpec.cwd, detached: true, stdio: "ignore", windowsHide: true, shell: needsWindowsShell( spawnSpec.command, deps.platform ?? process.platform, ), env, }, ); if (!spawnResult.ok) { const failure: WebModeLaunchFailure = { mode: "web", ok: false, cwd: options.cwd, projectSessionsDir: options.projectSessionsDir, host, port, url, hostKind: resolution.kind, hostPath: resolution.entryPath, hostRoot: resolution.hostRoot, failureReason: `launch:${spawnResult.error instanceof Error ? spawnResult.error.message : String(spawnResult.error)}`, }; emitLaunchStatus(stderr, failure); return failure; } try { const bootReadyFn = deps.waitForBootReady ?? ((u: string) => waitForBootReady(u, 180_000, stderr, authToken)); await bootReadyFn(url); } catch (error) { const failure: WebModeLaunchFailure = { mode: "web", ok: false, cwd: options.cwd, projectSessionsDir: options.projectSessionsDir, host, port, url, hostKind: resolution.kind, hostPath: resolution.entryPath, hostRoot: resolution.hostRoot, failureReason: `boot-ready:${error instanceof Error ? error.message : String(error)}`, }; emitLaunchStatus(stderr, failure); return failure; } try { spawnResult.child.unref?.(); const pid = spawnResult.child.pid; if (pid !== undefined) { const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath; (deps.writePidFile ?? writePidFile)(pidFilePath, pid); // Register in multi-instance registry registerInstance(options.cwd, { pid, port, url }, deps.registryPath); } const authenticatedUrl = `${url}/#token=${authToken}`; try { (deps.openBrowser ?? openBrowser)(authenticatedUrl); } catch (browserError) { stderr.write( `[forge] Could not open browser: ${browserError instanceof Error ? browserError.message : String(browserError)}\n`, ); } } catch (error) { const failure: WebModeLaunchFailure = { mode: "web", ok: false, cwd: options.cwd, projectSessionsDir: options.projectSessionsDir, host, port, url, hostKind: resolution.kind, hostPath: resolution.entryPath, hostRoot: resolution.hostRoot, failureReason: `browser-open:${error instanceof Error ? error.message : String(error)}`, }; emitLaunchStatus(stderr, failure); return failure; } const authenticatedUrl = `${url}/#token=${authToken}`; const success: WebModeLaunchSuccess = { mode: "web", ok: true, cwd: options.cwd, projectSessionsDir: options.projectSessionsDir, host, port, url, hostKind: resolution.kind, hostPath: resolution.entryPath, hostRoot: resolution.hostRoot, }; stderr.write(`[forge] Ready → ${authenticatedUrl}\n`); emitLaunchStatus(stderr, success); return success; }