@wrongstack/webui 0.273.0 → 0.274.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -897,7 +897,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
897
897
  return;
898
898
  }
899
899
  try {
900
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, DefaultSessionStore: DefaultSessionStore4, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
900
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
901
901
  const registry = new SessionRegistry(globalRoot);
902
902
  const entry = await registry.get(sessionId);
903
903
  if (!entry) {
@@ -906,7 +906,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
906
906
  return;
907
907
  }
908
908
  const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
909
- const store = new DefaultSessionStore4({ dir: paths.projectSessions });
909
+ const store = new DefaultSessionStore3({ dir: paths.projectSessions });
910
910
  const reader = new DefaultSessionReader2({ store });
911
911
  const rawEntries = [];
912
912
  for await (const ev of reader.replay(sessionId)) {
@@ -1167,7 +1167,7 @@ function isTrustedLoopbackOrigin(origin) {
1167
1167
  try {
1168
1168
  const url = new URL(origin);
1169
1169
  if (url.protocol !== "http:" && url.protocol !== "https:") return false;
1170
- return url.hostname === "localhost" || url.hostname === "127.0.0.1";
1170
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
1171
1171
  } catch {
1172
1172
  return false;
1173
1173
  }
@@ -1178,6 +1178,14 @@ function isLoopbackBind(wsHost) {
1178
1178
  function isWildcardBind(wsHost) {
1179
1179
  return wsHost === "0.0.0.0" || wsHost === "::" || wsHost === "[::]";
1180
1180
  }
1181
+ function normalizeHostname(hostname) {
1182
+ const h = hostname.trim().toLowerCase();
1183
+ return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
1184
+ }
1185
+ function allowedHostname(hostname, allowedHostnames) {
1186
+ const normalized = normalizeHostname(hostname);
1187
+ return (allowedHostnames ?? []).some((candidate) => normalizeHostname(candidate) === normalized);
1188
+ }
1181
1189
  function tokenMatches(provided, expected) {
1182
1190
  if (!provided) return false;
1183
1191
  const a = Buffer.from(provided);
@@ -1216,28 +1224,37 @@ function hostHeaderOk(input) {
1216
1224
  } catch {
1217
1225
  return false;
1218
1226
  }
1219
- return isLoopbackHostname(hostname);
1227
+ return isLoopbackHostname(hostname) || allowedHostname(hostname, input.allowedHostnames);
1220
1228
  }
1221
1229
  function verifyClient(input) {
1222
- const { origin, url, hostHeader, remoteAddress, cookieHeader, wsHost, expectedToken } = input;
1230
+ const {
1231
+ origin,
1232
+ url,
1233
+ hostHeader,
1234
+ remoteAddress,
1235
+ cookieHeader,
1236
+ wsHost,
1237
+ expectedToken,
1238
+ requireToken,
1239
+ allowedHostnames,
1240
+ allowBrowserUrlToken
1241
+ } = input;
1223
1242
  const urlTokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
1224
1243
  const cookieTokenOk = tokenMatches(extractTokenFromCookie(cookieHeader), expectedToken);
1225
- if (!hostHeaderOk({ hostHeader, wsHost })) return false;
1244
+ if (!hostHeaderOk({ hostHeader, wsHost, allowedHostnames })) return false;
1226
1245
  if (!origin) {
1227
1246
  const remoteIp = remoteAddress ?? "";
1228
1247
  const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
1229
1248
  if (!isRemoteLoopback && isWildcardBind(wsHost)) return false;
1230
- return urlTokenOk || cookieTokenOk || isLoopbackBind(wsHost);
1249
+ return urlTokenOk || cookieTokenOk || isLoopbackBind(wsHost) && !requireToken;
1231
1250
  }
1232
1251
  try {
1233
- const { hostname } = new URL(origin);
1234
- if (isLoopbackHostname(hostname)) {
1235
- if (isWildcardBind(wsHost) && !isTrustedLoopbackOrigin(origin)) {
1236
- return false;
1237
- }
1238
- return true;
1252
+ const { hostname: originHostname } = new URL(origin);
1253
+ if (isLoopbackHostname(originHostname)) {
1254
+ if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
1255
+ return isTrustedLoopbackOrigin(origin);
1239
1256
  }
1240
- return cookieTokenOk;
1257
+ return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
1241
1258
  } catch {
1242
1259
  return false;
1243
1260
  }
@@ -1263,8 +1280,69 @@ function injectWsPort(html, wsPort) {
1263
1280
  return `${tag}
1264
1281
  ${html}`;
1265
1282
  }
1266
- function buildCspHeader(wsPort) {
1267
- return `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:${wsPort} wss://127.0.0.1:${wsPort}; img-src 'self' data:; font-src 'self' data:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'`;
1283
+ function escapeHtmlAttr(value) {
1284
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1285
+ }
1286
+ function injectWsConfig(html, opts) {
1287
+ let out = injectWsPort(html, opts.wsPort);
1288
+ if (!opts.publicWsUrl || out.includes('name="wrongstack-ws-url"')) return out;
1289
+ const tag = `<meta name="wrongstack-ws-url" content="${escapeHtmlAttr(opts.publicWsUrl)}" />`;
1290
+ if (out.includes("</head>")) {
1291
+ return out.replace("</head>", ` ${tag}
1292
+ </head>`);
1293
+ }
1294
+ return `${tag}
1295
+ ${out}`;
1296
+ }
1297
+ function firstHeader(value) {
1298
+ return Array.isArray(value) ? value[0] : value;
1299
+ }
1300
+ function wsTokenCookie(token) {
1301
+ return `ws_token=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
1302
+ }
1303
+ function requestToken(req, url) {
1304
+ return url.searchParams.get("token") ?? firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
1305
+ }
1306
+ function requestHostForCsp(hostHeader) {
1307
+ const raw = firstHeader(hostHeader)?.trim();
1308
+ if (!raw) return void 0;
1309
+ try {
1310
+ return new URL(`http://${raw}`).hostname;
1311
+ } catch {
1312
+ return void 0;
1313
+ }
1314
+ }
1315
+ function formatCspHostname(hostname) {
1316
+ return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
1317
+ }
1318
+ function cspSourceFromUrl(rawUrl) {
1319
+ try {
1320
+ const url = new URL(rawUrl);
1321
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") return void 0;
1322
+ return `${url.protocol}//${formatCspHostname(url.hostname)}${url.port ? `:${url.port}` : ""}`;
1323
+ } catch {
1324
+ return void 0;
1325
+ }
1326
+ }
1327
+ var ALLOWED_INLINE_SCRIPT_HASHES = [
1328
+ "'sha256-6PXDy0zrpXa6mvYOl11bZ8nubNUL7ushPUhGDZtaexg='",
1329
+ "'sha256-6sIdwbEBx7jj0drqSHHm7MqvmoYD3CQ4lp8Zp8blcb0='"
1330
+ ];
1331
+ function buildCspHeader(wsPort, requestHost, publicWsUrl) {
1332
+ const connect = /* @__PURE__ */ new Set([
1333
+ "'self'",
1334
+ `ws://127.0.0.1:${wsPort}`,
1335
+ `wss://127.0.0.1:${wsPort}`
1336
+ ]);
1337
+ if (requestHost && requestHost !== "127.0.0.1") {
1338
+ const host = formatCspHostname(requestHost);
1339
+ connect.add(`ws://${host}:${wsPort}`);
1340
+ connect.add(`wss://${host}:${wsPort}`);
1341
+ }
1342
+ const publicWsSource = publicWsUrl ? cspSourceFromUrl(publicWsUrl) : void 0;
1343
+ if (publicWsSource) connect.add(publicWsSource);
1344
+ const scriptSrc = ["'self'", ...ALLOWED_INLINE_SCRIPT_HASHES].join(" ");
1345
+ return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; connect-src ${Array.from(connect).join(" ")}; img-src 'self' data:; font-src 'self' data:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'`;
1268
1346
  }
1269
1347
  function isInsideDist(candidate, distDir) {
1270
1348
  const root = path.resolve(distDir);
@@ -1282,12 +1360,15 @@ function createHttpServer(opts) {
1282
1360
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
1283
1361
  const distDir = path.resolve(opts.distDir);
1284
1362
  const wsPort = opts.wsPort;
1285
- const requireApiToken = !isLoopbackBind(opts.host) && Boolean(opts.apiToken);
1363
+ const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
1286
1364
  return http.createServer(async (req, res) => {
1287
1365
  try {
1288
1366
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
1367
+ const providedAccessToken = requestToken(req, url);
1368
+ const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
1369
+ const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
1289
1370
  if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
1290
- const provided = url.searchParams.get("token") ?? req.headers["x-ws-token"];
1371
+ const provided = requestToken(req, url);
1291
1372
  if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
1292
1373
  res.writeHead(401, { "Content-Type": "text/plain" });
1293
1374
  res.end("Unauthorized");
@@ -1295,7 +1376,7 @@ function createHttpServer(opts) {
1295
1376
  }
1296
1377
  res.writeHead(200, {
1297
1378
  "Content-Type": "text/plain",
1298
- "Set-Cookie": `ws_token=${encodeURIComponent(opts.apiToken)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`,
1379
+ "Set-Cookie": wsTokenCookie(opts.apiToken),
1299
1380
  // Belt-and-braces: tell any caches the cookie response itself
1300
1381
  // is sensitive.
1301
1382
  "Cache-Control": "no-store"
@@ -1303,10 +1384,20 @@ function createHttpServer(opts) {
1303
1384
  res.end("ok");
1304
1385
  return;
1305
1386
  }
1387
+ if (requireAccessToken && !accessTokenOk) {
1388
+ res.writeHead(401, {
1389
+ "Content-Type": "text/plain",
1390
+ "Cache-Control": "no-store"
1391
+ });
1392
+ res.end("Unauthorized");
1393
+ return;
1394
+ }
1395
+ if (shouldSetAuthCookie && opts.apiToken) {
1396
+ res.setHeader("Set-Cookie", wsTokenCookie(opts.apiToken));
1397
+ res.setHeader("Cache-Control", "no-store");
1398
+ }
1306
1399
  if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
1307
- const headerToken = req.headers["x-ws-token"];
1308
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1309
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1400
+ if (requireAccessToken && !accessTokenOk) {
1310
1401
  res.writeHead(401, { "Content-Type": "application/json" });
1311
1402
  res.end(JSON.stringify({ error: "Unauthorized" }));
1312
1403
  return;
@@ -1320,9 +1411,7 @@ function createHttpServer(opts) {
1320
1411
  return;
1321
1412
  }
1322
1413
  if (url.pathname === "/api/sessions" && req.method === "GET") {
1323
- const headerToken = req.headers["x-ws-token"];
1324
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1325
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1414
+ if (requireAccessToken && !accessTokenOk) {
1326
1415
  res.writeHead(401, { "Content-Type": "application/json" });
1327
1416
  res.end(JSON.stringify({ error: "Unauthorized" }));
1328
1417
  return;
@@ -1332,9 +1421,7 @@ function createHttpServer(opts) {
1332
1421
  }
1333
1422
  const agentsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/agents$/);
1334
1423
  if (agentsMatch && req.method === "GET") {
1335
- const headerToken = req.headers["x-ws-token"];
1336
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1337
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1424
+ if (requireAccessToken && !accessTokenOk) {
1338
1425
  res.writeHead(401, { "Content-Type": "application/json" });
1339
1426
  res.end(JSON.stringify({ error: "Unauthorized" }));
1340
1427
  return;
@@ -1344,9 +1431,7 @@ function createHttpServer(opts) {
1344
1431
  }
1345
1432
  const eventsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
1346
1433
  if (eventsMatch && req.method === "GET") {
1347
- const headerToken = req.headers["x-ws-token"];
1348
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1349
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1434
+ if (requireAccessToken && !accessTokenOk) {
1350
1435
  res.writeHead(401, { "Content-Type": "application/json" });
1351
1436
  res.end(JSON.stringify({ error: "Unauthorized" }));
1352
1437
  return;
@@ -1358,9 +1443,7 @@ function createHttpServer(opts) {
1358
1443
  }
1359
1444
  const msgMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/message$/);
1360
1445
  if (msgMatch && req.method === "POST") {
1361
- const headerToken = req.headers["x-ws-token"];
1362
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1363
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1446
+ if (requireAccessToken && !accessTokenOk) {
1364
1447
  res.writeHead(401, { "Content-Type": "application/json" });
1365
1448
  res.end(JSON.stringify({ error: "Unauthorized" }));
1366
1449
  return;
@@ -1370,9 +1453,7 @@ function createHttpServer(opts) {
1370
1453
  }
1371
1454
  const mailboxMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/mailbox$/);
1372
1455
  if (mailboxMatch && req.method === "GET") {
1373
- const headerToken = req.headers["x-ws-token"];
1374
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1375
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1456
+ if (requireAccessToken && !accessTokenOk) {
1376
1457
  res.writeHead(401, { "Content-Type": "application/json" });
1377
1458
  res.end(JSON.stringify({ error: "Unauthorized" }));
1378
1459
  return;
@@ -1382,9 +1463,7 @@ function createHttpServer(opts) {
1382
1463
  }
1383
1464
  const interruptMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/interrupt$/);
1384
1465
  if (interruptMatch && req.method === "POST") {
1385
- const headerToken = req.headers["x-ws-token"];
1386
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1387
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1466
+ if (requireAccessToken && !accessTokenOk) {
1388
1467
  res.writeHead(401, { "Content-Type": "application/json" });
1389
1468
  res.end(JSON.stringify({ error: "Unauthorized" }));
1390
1469
  return;
@@ -1398,9 +1477,7 @@ function createHttpServer(opts) {
1398
1477
  return;
1399
1478
  }
1400
1479
  if (url.pathname === "/api/fleet/broadcast" && req.method === "POST") {
1401
- const headerToken = req.headers["x-ws-token"];
1402
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1403
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1480
+ if (requireAccessToken && !accessTokenOk) {
1404
1481
  res.writeHead(401, { "Content-Type": "application/json" });
1405
1482
  res.end(JSON.stringify({ error: "Unauthorized" }));
1406
1483
  return;
@@ -1447,11 +1524,14 @@ function createHttpServer(opts) {
1447
1524
  res.setHeader("X-Frame-Options", "DENY");
1448
1525
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
1449
1526
  if (ext === ".html") {
1450
- res.setHeader("Cache-Control", "no-cache");
1451
- res.setHeader("Content-Security-Policy", buildCspHeader(wsPort));
1527
+ if (!shouldSetAuthCookie) res.setHeader("Cache-Control", "no-cache");
1528
+ res.setHeader(
1529
+ "Content-Security-Policy",
1530
+ buildCspHeader(wsPort, requestHostForCsp(req.headers.host), opts.publicWsUrl)
1531
+ );
1452
1532
  const html = await fs.readFile(resolvedPath, "utf8");
1453
1533
  res.writeHead(200);
1454
- res.end(injectWsPort(html, wsPort));
1534
+ res.end(injectWsConfig(html, { wsPort, publicWsUrl: opts.publicWsUrl }));
1455
1535
  return;
1456
1536
  }
1457
1537
  const fileContent = await fs.readFile(resolvedPath);
@@ -1466,9 +1546,13 @@ function createHttpServer(opts) {
1466
1546
  "X-Content-Type-Options": "nosniff",
1467
1547
  "X-Frame-Options": "DENY",
1468
1548
  "Referrer-Policy": "strict-origin-when-cross-origin",
1469
- "Content-Security-Policy": buildCspHeader(wsPort)
1549
+ "Content-Security-Policy": buildCspHeader(
1550
+ wsPort,
1551
+ requestHostForCsp(req.headers.host),
1552
+ opts.publicWsUrl
1553
+ )
1470
1554
  });
1471
- res.end(injectWsPort(html, wsPort));
1555
+ res.end(injectWsConfig(html, { wsPort, publicWsUrl: opts.publicWsUrl }));
1472
1556
  } catch {
1473
1557
  res.writeHead(404);
1474
1558
  res.end("Not found");
@@ -1592,8 +1676,8 @@ function isInside(root, target) {
1592
1676
  }
1593
1677
 
1594
1678
  // src/server/file-handlers.ts
1595
- import * as fs3 from "fs/promises";
1596
- import * as path3 from "path";
1679
+ import * as fs4 from "fs/promises";
1680
+ import * as path4 from "path";
1597
1681
  import { atomicWrite } from "@wrongstack/core";
1598
1682
 
1599
1683
  // src/server/file-picker.ts
@@ -1644,6 +1728,34 @@ function rankFiles(paths, query, limit) {
1644
1728
  return scored.slice(0, limit).map((s) => s.path);
1645
1729
  }
1646
1730
 
1731
+ // src/server/path-containment.ts
1732
+ import * as fs3 from "fs/promises";
1733
+ import * as path3 from "path";
1734
+ function isPathInside(root, target) {
1735
+ const relative3 = path3.relative(root, target);
1736
+ return relative3 === "" || !relative3.startsWith("..") && !path3.isAbsolute(relative3);
1737
+ }
1738
+ async function resolveWorkingDirInsideProject(projectRoot, inputPath) {
1739
+ const resolved = path3.resolve(projectRoot, inputPath);
1740
+ let stat3;
1741
+ try {
1742
+ stat3 = await fs3.stat(resolved);
1743
+ } catch {
1744
+ throw new Error(`Directory not found or not accessible: ${resolved}`);
1745
+ }
1746
+ if (!stat3.isDirectory()) {
1747
+ throw new Error(`Directory not found or not accessible: ${resolved}`);
1748
+ }
1749
+ const [realProjectRoot, realResolved] = await Promise.all([
1750
+ fs3.realpath(projectRoot),
1751
+ fs3.realpath(resolved)
1752
+ ]);
1753
+ if (!isPathInside(realProjectRoot, realResolved)) {
1754
+ throw new Error(`Path must stay inside the project root: ${projectRoot}`);
1755
+ }
1756
+ return resolved;
1757
+ }
1758
+
1647
1759
  // src/server/ws-utils.ts
1648
1760
  import { randomBytes } from "crypto";
1649
1761
  import { WebSocket } from "ws";
@@ -1672,25 +1784,106 @@ function errMessage(err) {
1672
1784
  function generateAuthToken() {
1673
1785
  return randomBytes(16).toString("hex");
1674
1786
  }
1787
+ function resolveAuthToken(explicit) {
1788
+ const configured = explicit?.trim() || process.env["WEBUI_TOKEN"]?.trim() || process.env["WEBUI_AUTH_TOKEN"]?.trim();
1789
+ return configured || generateAuthToken();
1790
+ }
1791
+ function hostForBrowserUrl(bindHost) {
1792
+ if (bindHost === "0.0.0.0") return "127.0.0.1";
1793
+ if (bindHost === "::" || bindHost === "[::]") return "[::1]";
1794
+ if (bindHost.includes(":") && !bindHost.startsWith("[")) return `[${bindHost}]`;
1795
+ return bindHost;
1796
+ }
1797
+ function buildWebUIAccessUrl(opts) {
1798
+ const protocol = opts.protocol ?? "http";
1799
+ const base = opts.publicUrl?.trim() || `${protocol}://${hostForBrowserUrl(opts.host)}:${opts.port}`;
1800
+ if (!opts.token) return base;
1801
+ try {
1802
+ const url = new URL(base);
1803
+ url.searchParams.set("token", opts.token);
1804
+ const rendered = url.toString();
1805
+ const afterOrigin = base.slice(url.origin.length);
1806
+ if (url.pathname === "/" && !afterOrigin.startsWith("/")) {
1807
+ return `${url.origin}${url.search}${url.hash}`;
1808
+ }
1809
+ return rendered;
1810
+ } catch {
1811
+ return `${base}${base.includes("?") ? "&" : "?"}token=${encodeURIComponent(opts.token)}`;
1812
+ }
1813
+ }
1814
+ function envFlag(name2) {
1815
+ const value = process.env[name2]?.trim().toLowerCase();
1816
+ return value === "1" || value === "true" || value === "yes" || value === "on";
1817
+ }
1675
1818
 
1676
1819
  // src/server/file-handlers.ts
1820
+ async function resolveFileInsideProject(projectRoot, filePath) {
1821
+ const resolved = path4.resolve(projectRoot, filePath);
1822
+ if (!isPathInside(projectRoot, resolved)) {
1823
+ throw new Error("Path outside project root");
1824
+ }
1825
+ const { parent, base } = splitParentAndBase(resolved);
1826
+ const realProjectRoot = await fs4.realpath(projectRoot);
1827
+ const realParent = await realpathAllowMissing(parent);
1828
+ const realFull = path4.join(realParent, base);
1829
+ if (!isPathInside(realProjectRoot, realFull)) {
1830
+ throw new Error("Path outside project root");
1831
+ }
1832
+ return realFull;
1833
+ }
1834
+ function splitParentAndBase(p) {
1835
+ const base = path4.basename(p);
1836
+ const parent = path4.dirname(p);
1837
+ return { parent, base };
1838
+ }
1839
+ async function realpathAllowMissing(p) {
1840
+ try {
1841
+ return await fs4.realpath(p);
1842
+ } catch (err) {
1843
+ if (err.code !== "ENOENT") throw err;
1844
+ }
1845
+ const segments = [];
1846
+ let cursor = p;
1847
+ while (true) {
1848
+ const parent = path4.dirname(cursor);
1849
+ if (parent === cursor) {
1850
+ throw new Error("Path outside project root");
1851
+ }
1852
+ segments.unshift(path4.basename(cursor));
1853
+ try {
1854
+ const realParent = await fs4.realpath(parent);
1855
+ return path4.join(realParent, ...segments);
1856
+ } catch (err) {
1857
+ if (err.code !== "ENOENT") throw err;
1858
+ cursor = parent;
1859
+ }
1860
+ }
1861
+ }
1677
1862
  async function handleFilesTree(ws, msg, projectRoot) {
1678
1863
  const payload = msg.payload;
1679
1864
  const rawPath = payload?.path?.trim();
1680
- const treeRoot = rawPath && rawPath !== "." ? path3.resolve(projectRoot, rawPath) : projectRoot;
1681
- if (!treeRoot.startsWith(projectRoot + path3.sep) && treeRoot !== projectRoot) {
1865
+ let treeRoot;
1866
+ let realProjectRoot;
1867
+ try {
1868
+ if (rawPath && rawPath !== ".") {
1869
+ treeRoot = await resolveWorkingDirInsideProject(projectRoot, rawPath);
1870
+ } else {
1871
+ treeRoot = projectRoot;
1872
+ }
1873
+ realProjectRoot = await fs4.realpath(projectRoot);
1874
+ } catch {
1682
1875
  send(ws, {
1683
1876
  type: "files.tree",
1684
1877
  payload: { root: projectRoot, tree: [], error: "Path outside project root" }
1685
1878
  });
1686
1879
  return;
1687
1880
  }
1688
- const pathPrefix = treeRoot === projectRoot ? "" : (path3.relative(projectRoot, treeRoot) + "/").replace(/\\/g, "/");
1881
+ const pathPrefix = treeRoot === projectRoot ? "" : (path4.relative(projectRoot, treeRoot) + "/").replace(/\\/g, "/");
1689
1882
  async function buildTree(dir, rel, depth) {
1690
1883
  if (depth > 10) return [];
1691
1884
  let entries = [];
1692
1885
  try {
1693
- entries = await fs3.readdir(dir, { withFileTypes: true });
1886
+ entries = await fs4.readdir(dir, { withFileTypes: true });
1694
1887
  } catch {
1695
1888
  return [];
1696
1889
  }
@@ -1702,11 +1895,20 @@ async function handleFilesTree(ws, msg, projectRoot) {
1702
1895
  for (const e of entries) {
1703
1896
  if (isHiddenEntry(e.name)) continue;
1704
1897
  const childRel = rel ? `${rel}/${e.name}` : e.name;
1705
- const childAbs = path3.join(dir, e.name);
1898
+ const childAbs = path4.join(dir, e.name);
1706
1899
  const childPath = pathPrefix + childRel;
1707
1900
  if (e.isDirectory()) {
1708
1901
  if (SKIP_DIRS.has(e.name)) continue;
1709
- const children = await buildTree(childAbs, childRel, depth + 1);
1902
+ let realChild;
1903
+ try {
1904
+ realChild = await fs4.realpath(childAbs);
1905
+ } catch {
1906
+ continue;
1907
+ }
1908
+ if (!isPathInside(realProjectRoot, realChild)) {
1909
+ continue;
1910
+ }
1911
+ const children = await buildTree(realChild, childRel, depth + 1);
1710
1912
  nodes.push({ name: e.name, path: childPath, type: "directory", children });
1711
1913
  } else if (e.isFile()) {
1712
1914
  nodes.push({ name: e.name, path: childPath, type: "file" });
@@ -1716,10 +1918,10 @@ async function handleFilesTree(ws, msg, projectRoot) {
1716
1918
  }
1717
1919
  try {
1718
1920
  const tree = await buildTree(treeRoot, "", 0);
1719
- const rootLabel = treeRoot === projectRoot ? projectRoot : path3.relative(projectRoot, treeRoot) || ".";
1921
+ const rootLabel = treeRoot === projectRoot ? projectRoot : path4.relative(projectRoot, treeRoot) || ".";
1720
1922
  send(ws, { type: "files.tree", payload: { root: rootLabel, tree } });
1721
1923
  } catch (err) {
1722
- const rootLabel = treeRoot === projectRoot ? projectRoot : path3.relative(projectRoot, treeRoot) || ".";
1924
+ const rootLabel = treeRoot === projectRoot ? projectRoot : path4.relative(projectRoot, treeRoot) || ".";
1723
1925
  send(ws, {
1724
1926
  type: "files.tree",
1725
1927
  payload: { root: rootLabel, tree: [], error: errMessage(err) }
@@ -1728,13 +1930,15 @@ async function handleFilesTree(ws, msg, projectRoot) {
1728
1930
  }
1729
1931
  async function handleFilesRead(ws, msg, projectRoot) {
1730
1932
  const { filePath } = msg.payload;
1731
- const resolved = path3.resolve(projectRoot, filePath);
1732
- if (!resolved.startsWith(projectRoot + path3.sep) && resolved !== projectRoot) {
1933
+ let realResolved;
1934
+ try {
1935
+ realResolved = await resolveFileInsideProject(projectRoot, filePath);
1936
+ } catch {
1733
1937
  send(ws, { type: "files.read", payload: { filePath, content: "", error: "Forbidden" } });
1734
1938
  return;
1735
1939
  }
1736
1940
  try {
1737
- const content = await fs3.readFile(resolved, "utf8");
1941
+ const content = await fs4.readFile(realResolved, "utf8");
1738
1942
  send(ws, { type: "files.read", payload: { filePath, content } });
1739
1943
  } catch (err) {
1740
1944
  send(ws, {
@@ -1745,16 +1949,18 @@ async function handleFilesRead(ws, msg, projectRoot) {
1745
1949
  }
1746
1950
  async function handleFilesWrite(ws, msg, projectRoot, opts = {}) {
1747
1951
  const { filePath, content } = msg.payload;
1748
- const resolved = path3.resolve(projectRoot, filePath);
1749
- if (!resolved.startsWith(projectRoot + path3.sep) && resolved !== projectRoot) {
1952
+ let realResolved;
1953
+ try {
1954
+ realResolved = await resolveFileInsideProject(projectRoot, filePath);
1955
+ } catch {
1750
1956
  send(ws, { type: "files.written", payload: { filePath, success: false, error: "Forbidden" } });
1751
1957
  return;
1752
1958
  }
1753
1959
  try {
1754
- await atomicWrite(resolved, content);
1960
+ await atomicWrite(realResolved, content);
1755
1961
  send(ws, { type: "files.written", payload: { filePath, success: true } });
1756
1962
  if (opts.onWritten) {
1757
- void Promise.resolve(opts.onWritten(resolved)).catch(() => void 0);
1963
+ void Promise.resolve(opts.onWritten(realResolved)).catch(() => void 0);
1758
1964
  }
1759
1965
  } catch (err) {
1760
1966
  send(ws, {
@@ -1766,8 +1972,16 @@ async function handleFilesWrite(ws, msg, projectRoot, opts = {}) {
1766
1972
  async function handleFilesList(ws, msg, projectRoot) {
1767
1973
  const payload = msg.payload ?? {};
1768
1974
  const limit = payload.limit ?? 50;
1769
- const listRoot = payload.path ? path3.resolve(projectRoot, payload.path) : projectRoot;
1770
- if (!listRoot.startsWith(projectRoot + path3.sep) && listRoot !== projectRoot) {
1975
+ let listRoot;
1976
+ let realProjectRoot;
1977
+ try {
1978
+ if (payload.path) {
1979
+ listRoot = await resolveWorkingDirInsideProject(projectRoot, payload.path);
1980
+ } else {
1981
+ listRoot = projectRoot;
1982
+ }
1983
+ realProjectRoot = await fs4.realpath(projectRoot);
1984
+ } catch {
1771
1985
  send(ws, { type: "files.list", payload: { files: [] } });
1772
1986
  return;
1773
1987
  }
@@ -1776,7 +1990,7 @@ async function handleFilesList(ws, msg, projectRoot) {
1776
1990
  if (depth > 8 || results.length >= 600) return;
1777
1991
  let entries = [];
1778
1992
  try {
1779
- entries = await fs3.readdir(dir, { withFileTypes: true });
1993
+ entries = await fs4.readdir(dir, { withFileTypes: true });
1780
1994
  } catch {
1781
1995
  return;
1782
1996
  }
@@ -1786,7 +2000,16 @@ async function handleFilesList(ws, msg, projectRoot) {
1786
2000
  const childRel = rel ? `${rel}/${e.name}` : e.name;
1787
2001
  if (e.isDirectory()) {
1788
2002
  if (SKIP_DIRS.has(e.name)) continue;
1789
- await walk(path3.join(dir, e.name), childRel, depth + 1);
2003
+ let realChild;
2004
+ try {
2005
+ realChild = await fs4.realpath(path4.join(dir, e.name));
2006
+ } catch {
2007
+ continue;
2008
+ }
2009
+ if (!isPathInside(realProjectRoot, realChild)) {
2010
+ continue;
2011
+ }
2012
+ await walk(realChild, childRel, depth + 1);
1790
2013
  } else if (e.isFile()) {
1791
2014
  results.push(childRel);
1792
2015
  }
@@ -1800,7 +2023,7 @@ async function handleFilesList(ws, msg, projectRoot) {
1800
2023
  }
1801
2024
 
1802
2025
  // src/server/completion-handlers.ts
1803
- import * as path4 from "path";
2026
+ import * as path5 from "path";
1804
2027
  import { searchCodebaseIndex } from "@wrongstack/tools/codebase-index/index";
1805
2028
  var MAX_PREFIX_CHARS = 12e3;
1806
2029
  var MAX_SUFFIX_CHARS = 4e3;
@@ -1875,8 +2098,8 @@ async function handleCompletionRequest(ws, msg, opts) {
1875
2098
  return;
1876
2099
  }
1877
2100
  const payload = parsed.payload;
1878
- const projectRoot = path4.resolve(opts.projectRoot);
1879
- const resolved = path4.resolve(projectRoot, payload.filePath);
2101
+ const projectRoot = path5.resolve(opts.projectRoot);
2102
+ const resolved = path5.resolve(projectRoot, payload.filePath);
1880
2103
  if (!isInside2(projectRoot, resolved)) {
1881
2104
  send(ws, {
1882
2105
  type: "completion.result",
@@ -2275,7 +2498,7 @@ function buildSearchQuery(linePrefix, filePath) {
2275
2498
  if (memberMatch?.[1]) return memberMatch[1];
2276
2499
  const token = linePrefix.match(/([A-Za-z_$][\w$]*)$/)?.[1];
2277
2500
  if (token && token.length >= 2) return token;
2278
- return path4.basename(filePath, path4.extname(filePath));
2501
+ return path5.basename(filePath, path5.extname(filePath));
2279
2502
  }
2280
2503
  function currentLinePrefix(prefix) {
2281
2504
  const idx = Math.max(prefix.lastIndexOf("\n"), prefix.lastIndexOf("\r"));
@@ -2305,7 +2528,7 @@ function head(value, max) {
2305
2528
  return value.length <= max ? value : value.slice(0, max);
2306
2529
  }
2307
2530
  function isInside2(root, target) {
2308
- return target === root || target.startsWith(root + path4.sep);
2531
+ return target === root || target.startsWith(root + path5.sep);
2309
2532
  }
2310
2533
 
2311
2534
  // src/server/memory-handlers.ts
@@ -2559,8 +2782,8 @@ async function handleMcpDiscover(ws, msg, globalConfigPath, mcpRegistry) {
2559
2782
  }
2560
2783
 
2561
2784
  // src/server/skills-handlers.ts
2562
- import { promises as fs4 } from "fs";
2563
- import path5 from "path";
2785
+ import { promises as fs5 } from "fs";
2786
+ import path6 from "path";
2564
2787
  import JSZip from "jszip";
2565
2788
  import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
2566
2789
  import { wstackGlobalRoot } from "@wrongstack/core/utils";
@@ -2631,19 +2854,19 @@ async function handleSkillsContent(ws, ctx, msg) {
2631
2854
  send(ws, { type: "skills.content", payload: { name: name2, body: "", path: "", source, relatedFiles: [], references: [], error: `Skill "${name2}" not found` } });
2632
2855
  return;
2633
2856
  }
2634
- const body = await fs4.readFile(entry.path, "utf8");
2635
- const skillDir = path5.dirname(entry.path);
2857
+ const body = await fs5.readFile(entry.path, "utf8");
2858
+ const skillDir = path6.dirname(entry.path);
2636
2859
  let relatedFiles = [];
2637
2860
  try {
2638
- const files = await fs4.readdir(skillDir);
2639
- relatedFiles = files.filter((f) => f !== path5.basename(entry.path)).map((f) => path5.join(skillDir, f));
2861
+ const files = await fs5.readdir(skillDir);
2862
+ relatedFiles = files.filter((f) => f !== path6.basename(entry.path)).map((f) => path6.join(skillDir, f));
2640
2863
  } catch {
2641
2864
  }
2642
2865
  const nameLower = name2.toLowerCase();
2643
2866
  const refResults = await Promise.all(
2644
2867
  entries.filter((e) => e.name.toLowerCase() !== nameLower).map(async (e) => {
2645
2868
  try {
2646
- const content = await fs4.readFile(e.path, "utf8");
2869
+ const content = await fs5.readFile(e.path, "utf8");
2647
2870
  return [e.name, content.toLowerCase().includes(nameLower)];
2648
2871
  } catch {
2649
2872
  return [e.name, false];
@@ -2733,14 +2956,14 @@ async function handleSkillsCreate(ws, ctx, msg) {
2733
2956
  }
2734
2957
  const createPayload = parsed.value;
2735
2958
  try {
2736
- const targetDir = createPayload.scope === "global" ? path5.join(wstackGlobalRoot(), "skills", createPayload.name.trim()) : path5.join(ctx.projectRoot, ".wrongstack", "skills", createPayload.name.trim());
2959
+ const targetDir = createPayload.scope === "global" ? path6.join(wstackGlobalRoot(), "skills", createPayload.name.trim()) : path6.join(ctx.projectRoot, ".wrongstack", "skills", createPayload.name.trim());
2737
2960
  try {
2738
- await fs4.access(targetDir);
2961
+ await fs5.access(targetDir);
2739
2962
  send(ws, { type: "skills.created", payload: { success: false, error: `Skill "${createPayload.name}" already exists` } });
2740
2963
  return;
2741
2964
  } catch {
2742
2965
  }
2743
- await fs4.mkdir(targetDir, { recursive: true });
2966
+ await fs5.mkdir(targetDir, { recursive: true });
2744
2967
  const lines = createPayload.description.trim().split("\n");
2745
2968
  const firstLine = lines[0].trim();
2746
2969
  const bodyLines = lines.slice(1).map((l) => l.trim()).filter(Boolean);
@@ -2788,13 +3011,13 @@ ${trigger}
2788
3011
  "- `bug-hunter` \u2014 for systematic bug detection patterns",
2789
3012
  "- `output-standards` \u2014 for standardized `<next_steps>` formatting"
2790
3013
  ].join("\n");
2791
- await atomicWrite2(path5.join(targetDir, "SKILL.md"), skillContent);
3014
+ await atomicWrite2(path6.join(targetDir, "SKILL.md"), skillContent);
2792
3015
  send(ws, {
2793
3016
  type: "skills.created",
2794
3017
  payload: {
2795
3018
  success: true,
2796
3019
  error: null,
2797
- skill: { name: createPayload.name.trim(), path: path5.join(targetDir, "SKILL.md"), scope: createPayload.scope }
3020
+ skill: { name: createPayload.name.trim(), path: path6.join(targetDir, "SKILL.md"), scope: createPayload.scope }
2798
3021
  }
2799
3022
  });
2800
3023
  } catch (err) {
@@ -2858,23 +3081,23 @@ import {
2858
3081
  Agent,
2859
3082
  AutoCompactionMiddleware,
2860
3083
  Context,
2861
- DefaultMemoryStore as DefaultMemoryStore2,
2862
- DefaultModeStore as DefaultModeStore2,
3084
+ DefaultMemoryStore,
3085
+ DefaultModeStore,
2863
3086
  DefaultModelsRegistry,
2864
3087
  DefaultSessionReader,
2865
- DefaultSessionStore as DefaultSessionStore3,
2866
- DefaultSkillLoader as DefaultSkillLoader2,
2867
- DefaultSystemPromptBuilder as DefaultSystemPromptBuilder4,
2868
- DefaultTokenCounter as DefaultTokenCounter2,
3088
+ DefaultSessionStore as DefaultSessionStore2,
3089
+ DefaultSkillLoader,
3090
+ DefaultSystemPromptBuilder as DefaultSystemPromptBuilder3,
3091
+ DefaultTokenCounter,
2869
3092
  AnnotationsStore,
2870
3093
  CollaborationBus,
2871
3094
  collabPauseMiddleware,
2872
3095
  collabInjectMiddleware,
2873
3096
  estimateRequestTokensCalibrated,
2874
3097
  EventBus,
2875
- createStrategyCompactor as createStrategyCompactor2,
3098
+ createStrategyCompactor,
2876
3099
  ProviderRegistry,
2877
- TOKENS as TOKENS2,
3100
+ TOKENS,
2878
3101
  ToolRegistry,
2879
3102
  atomicWrite as atomicWrite6,
2880
3103
  createDefaultPipelines,
@@ -2893,110 +3116,10 @@ import {
2893
3116
  import { ToolExecutor } from "@wrongstack/core/execution";
2894
3117
  import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets as encryptConfigSecrets2 } from "@wrongstack/core/security";
2895
3118
  import { buildProviderFactoriesFromRegistry, makeProviderFromConfig } from "@wrongstack/providers";
2896
- import { builtinToolsPack, forgetTool, rememberTool, searchMemoryTool, relatedMemoryTool } from "@wrongstack/tools";
3119
+ import { builtinToolsPack, configureExecPolicy, ensureSessionShell, forgetTool, rememberTool, searchMemoryTool, relatedMemoryTool } from "@wrongstack/tools";
2897
3120
  import { MCPRegistry } from "@wrongstack/mcp";
2898
3121
  import { WebSocket as WebSocket2, WebSocketServer } from "ws";
2899
-
2900
- // ../runtime/src/container.ts
2901
- import {
2902
- Container,
2903
- DefaultConfigStore,
2904
- DefaultErrorHandler,
2905
- DefaultMemoryStore,
2906
- DefaultModeStore,
2907
- DefaultPermissionPolicy,
2908
- DefaultRetryPolicy,
2909
- DefaultSecretScrubber,
2910
- DefaultSessionStore,
2911
- DefaultSkillLoader,
2912
- DefaultSystemPromptBuilder,
2913
- DefaultTokenCounter,
2914
- createStrategyCompactor,
2915
- buildRecoveryStrategies,
2916
- TOKENS
2917
- } from "@wrongstack/core";
2918
- function createDefaultContainer(opts) {
2919
- const { config, wpaths, logger, modelsRegistry } = opts;
2920
- const container = new Container();
2921
- const configStore = new DefaultConfigStore(config);
2922
- container.bind(TOKENS.ConfigStore, () => configStore);
2923
- container.bind(TOKENS.Logger, () => logger);
2924
- container.bind(TOKENS.SecretScrubber, () => new DefaultSecretScrubber());
2925
- container.bind(TOKENS.RetryPolicy, () => new DefaultRetryPolicy());
2926
- container.bind(
2927
- TOKENS.ErrorHandler,
2928
- () => new DefaultErrorHandler(
2929
- buildRecoveryStrategies({
2930
- compactor: container.resolve(TOKENS.Compactor),
2931
- modelsRegistry,
2932
- getConfig: () => configStore.get()
2933
- })
2934
- )
2935
- );
2936
- container.bind(TOKENS.ModelsRegistry, () => modelsRegistry);
2937
- container.bind(
2938
- TOKENS.TokenCounter,
2939
- () => new DefaultTokenCounter({ registry: modelsRegistry, providerId: config.provider })
2940
- );
2941
- const modeStore = new DefaultModeStore({ directory: wpaths.configDir });
2942
- container.bind(TOKENS.ModeStore, () => modeStore);
2943
- container.bind(
2944
- TOKENS.SessionStore,
2945
- () => new DefaultSessionStore({
2946
- dir: wpaths.projectSessions,
2947
- // Scrub secrets out of persisted user/model turns (F-06). Tool output
2948
- // is already scrubbed by the executor.
2949
- secretScrubber: container.resolve(TOKENS.SecretScrubber)
2950
- })
2951
- );
2952
- const memoryStore = new DefaultMemoryStore({ paths: wpaths, events: opts.events });
2953
- container.bind(TOKENS.MemoryStore, () => memoryStore);
2954
- const skillLoader = new DefaultSkillLoader({ paths: wpaths, bundledDir: opts.bundledSkillsDir });
2955
- container.bind(TOKENS.SkillLoader, () => skillLoader);
2956
- if (opts.systemPrompt) {
2957
- container.bind(
2958
- TOKENS.SystemPromptBuilder,
2959
- () => new DefaultSystemPromptBuilder(opts.systemPrompt)
2960
- );
2961
- }
2962
- container.bind(
2963
- TOKENS.PermissionPolicy,
2964
- () => {
2965
- const policyOptions = {
2966
- trustFile: wpaths.projectTrust,
2967
- yolo: opts.permission?.yolo ?? false,
2968
- yoloDestructive: opts.permission?.yoloDestructive ?? opts.permission?.forceAllYolo ?? false,
2969
- confirmDestructive: opts.permission?.confirmDestructive ?? false
2970
- };
2971
- if (opts.permission?.promptDelegate !== void 0) {
2972
- policyOptions.promptDelegate = opts.permission.promptDelegate;
2973
- }
2974
- return new DefaultPermissionPolicy(policyOptions);
2975
- }
2976
- );
2977
- container.bind(
2978
- TOKENS.Compactor,
2979
- () => (
2980
- // Strategy comes from config.context.strategy: 'hybrid' (default, lossless
2981
- // rules, no LLM), 'intelligent' (LLM summarization), or 'selective'
2982
- // (LLM-driven selection). The LLM strategies resolve their provider from
2983
- // ctx at compact()-time, so binding here (before context.provider exists)
2984
- // is safe. preserveK / eliseThreshold are class-level fallbacks; the active
2985
- // ContextWindowPolicy in ctx.meta normally overrides both at runtime.
2986
- // eliseThreshold is a TOKEN COUNT — a previous value of 0.7 elided
2987
- // essentially every tool_result (anything > 1 token).
2988
- createStrategyCompactor({
2989
- strategy: config.context?.strategy,
2990
- preserveK: opts.compactor?.preserveK ?? 10,
2991
- eliseThreshold: opts.compactor?.eliseThreshold ?? 2e3,
2992
- smart: true,
2993
- summarizerModel: config.context?.summarizerModel,
2994
- llmSelector: config.context?.llmSelector
2995
- })
2996
- )
2997
- );
2998
- return container;
2999
- }
3122
+ import { createDefaultContainer, makeLightSubagentFactory } from "@wrongstack/runtime";
3000
3123
 
3001
3124
  // src/server/boot.ts
3002
3125
  import {
@@ -3023,6 +3146,13 @@ import {
3023
3146
  PhaseStore,
3024
3147
  WorktreeManager
3025
3148
  } from "@wrongstack/core";
3149
+ function deriveTitle(goal) {
3150
+ const firstLine = goal.split("\n").map((l) => l.trim()).find(Boolean);
3151
+ if (!firstLine) return "AutoPhase";
3152
+ const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
3153
+ const trimmed = sentence.length <= 64 ? sentence : `${sentence.slice(0, 63).trimEnd()}\u2026`;
3154
+ return trimmed || "AutoPhase";
3155
+ }
3026
3156
  function isGitRepo(cwd) {
3027
3157
  try {
3028
3158
  const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, encoding: "utf8", windowsHide: true });
@@ -3031,6 +3161,19 @@ function isGitRepo(cwd) {
3031
3161
  return false;
3032
3162
  }
3033
3163
  }
3164
+ function commitsSince(cwd, baseSha, branch) {
3165
+ try {
3166
+ const r = spawnSync("git", ["log", "--reverse", "--format=%H", `${baseSha}..${branch}`], {
3167
+ cwd,
3168
+ encoding: "utf8",
3169
+ windowsHide: true
3170
+ });
3171
+ if (r.status !== 0) return [];
3172
+ return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
3173
+ } catch {
3174
+ return [];
3175
+ }
3176
+ }
3034
3177
  var AutoPhaseWebSocketHandler = class {
3035
3178
  constructor(agent, context, logger, storeDir, events, projectRoot) {
3036
3179
  this.agent = agent;
@@ -3050,10 +3193,17 @@ var AutoPhaseWebSocketHandler = class {
3050
3193
  store;
3051
3194
  clients = /* @__PURE__ */ new Set();
3052
3195
  broadcastInterval = null;
3053
- /** Aborts in-flight task agents when the run is stopped. */
3196
+ /** Aborts in-flight task agents AND the planning turn when the run is stopped. */
3054
3197
  abort = null;
3198
+ /** Set the instant a stop/clear/revert is requested, so a planning turn that
3199
+ * resolves afterwards never launches the orchestrator (the abort alone can't
3200
+ * cover the window between the LLM call resolving and the orchestrator start). */
3201
+ stopping = false;
3055
3202
  /** Optional per-phase git-worktree isolation (lazily created at start). */
3056
3203
  worktrees = null;
3204
+ /** Base branch + tip SHA captured at run start so a revert can git-revert the
3205
+ * run's squash commits (history-preserving) instead of a destructive reset. */
3206
+ runBase = null;
3057
3207
  /** Per-run worker identities so the board can show "who is on what". */
3058
3208
  usedNicknames = /* @__PURE__ */ new Set();
3059
3209
  addClient(ws) {
@@ -3077,11 +3227,13 @@ var AutoPhaseWebSocketHandler = class {
3077
3227
  this.broadcast({ type: "autophase.resumed", payload: {} });
3078
3228
  break;
3079
3229
  case "autophase.stop":
3080
- this.abort?.abort();
3081
- this.orchestrator?.stop();
3082
- this.stopBroadcast();
3083
- if (this.graph) void this.store.save(this.graph);
3084
- this.broadcast({ type: "autophase.stopped", payload: {} });
3230
+ await this.handleStop();
3231
+ break;
3232
+ case "autophase.clear":
3233
+ await this.handleClear();
3234
+ break;
3235
+ case "autophase.revert":
3236
+ await this.handleRevert();
3085
3237
  break;
3086
3238
  case "autophase.status":
3087
3239
  this.broadcastState();
@@ -3158,17 +3310,27 @@ var AutoPhaseWebSocketHandler = class {
3158
3310
  }
3159
3311
  }
3160
3312
  async handleStart(payload) {
3161
- const title = payload?.goal || payload?.title || "Untitled Project";
3313
+ const goal = payload?.goal || payload?.title || "Untitled Project";
3314
+ const title = deriveTitle(goal);
3162
3315
  const autonomous = payload?.autonomous ?? true;
3163
- const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(title);
3316
+ this.abort = new AbortController();
3317
+ this.stopping = false;
3318
+ const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(goal, this.abort.signal);
3319
+ if (this.stopping || this.abort.signal.aborted) {
3320
+ this.broadcast({ type: "autophase.stopped", payload: { title } });
3321
+ return;
3322
+ }
3164
3323
  this.logger.info(`[AutoPhase] Starting: ${title}`);
3165
- const graph = await new PhaseGraphBuilder({ title, phases, autonomous }).build();
3324
+ const graph = await new PhaseGraphBuilder({ title, description: goal, phases, autonomous }).build();
3166
3325
  this.graph = graph;
3167
- this.abort = new AbortController();
3168
3326
  await this.store.save(graph);
3169
- if (!this.worktrees && this.events && this.projectRoot && process.env["WRONGSTACK_AUTOPHASE_WORKTREES"] !== "0" && isGitRepo(this.projectRoot)) {
3327
+ const useWorktrees = payload?.worktrees ?? process.env["WRONGSTACK_AUTOPHASE_WORKTREES"] !== "0";
3328
+ if (!this.worktrees && this.events && this.projectRoot && useWorktrees && isGitRepo(this.projectRoot)) {
3170
3329
  this.worktrees = new WorktreeManager({ projectRoot: this.projectRoot, events: this.events });
3171
3330
  }
3331
+ if (this.worktrees) {
3332
+ this.runBase = await this.worktrees.currentBase();
3333
+ }
3172
3334
  this.orchestrator = new PhaseOrchestrator({
3173
3335
  graph,
3174
3336
  ctx: {
@@ -3215,6 +3377,62 @@ var AutoPhaseWebSocketHandler = class {
3215
3377
  this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
3216
3378
  });
3217
3379
  }
3380
+ /**
3381
+ * Halt the run NOW — at any phase. Sets `stopping` (so a planning turn that
3382
+ * resolves afterwards bails), aborts in-flight agents, stops the orchestrator
3383
+ * tick, and ends the live broadcast. The board is kept for review; use
3384
+ * `autophase.clear` to reset or `autophase.revert` to undo the changes.
3385
+ */
3386
+ async handleStop() {
3387
+ this.stopping = true;
3388
+ this.abort?.abort();
3389
+ this.orchestrator?.stop();
3390
+ this.stopBroadcast();
3391
+ if (this.graph) await this.store.save(this.graph).catch(() => void 0);
3392
+ this.broadcast({ type: "autophase.stopped", payload: { title: this.graph?.title } });
3393
+ }
3394
+ /**
3395
+ * Stop + wipe: tear down phase worktrees and reset to an empty board so the UI
3396
+ * returns to the start screen ("new one"). Does NOT touch already-merged commits
3397
+ * on the base branch — that is `autophase.revert`.
3398
+ */
3399
+ async handleClear() {
3400
+ await this.handleStop();
3401
+ if (this.worktrees) await this.worktrees.cleanupAllManaged().catch(() => void 0);
3402
+ this.orchestrator = null;
3403
+ this.graph = null;
3404
+ this.runBase = null;
3405
+ this.usedNicknames.clear();
3406
+ this.broadcast({ type: "autophase.cleared", payload: {} });
3407
+ this.broadcast({ type: "autophase.state", payload: this.buildState() });
3408
+ }
3409
+ /**
3410
+ * Stop + undo: remove phase worktrees, then history-preservingly `git revert`
3411
+ * every commit this run landed on the base branch (captured `runBase`..HEAD),
3412
+ * then reset to an empty board. Refuses (reports a reason) on a dirty tree or a
3413
+ * conflicting revert rather than leaving the tree half-reverted.
3414
+ */
3415
+ async handleRevert() {
3416
+ await this.handleStop();
3417
+ if (!this.worktrees || !this.runBase || !this.projectRoot) {
3418
+ this.broadcast({
3419
+ type: "autophase.reverted",
3420
+ payload: { ok: false, reverted: 0, reason: "no git baseline was captured for this run" }
3421
+ });
3422
+ return;
3423
+ }
3424
+ await this.worktrees.cleanupAllManaged().catch(() => void 0);
3425
+ const shas = commitsSince(this.projectRoot, this.runBase.sha, this.runBase.branch);
3426
+ const res = await this.worktrees.revertCommits(this.runBase.branch, shas);
3427
+ this.broadcast({ type: "autophase.reverted", payload: res });
3428
+ if (res.ok) {
3429
+ this.orchestrator = null;
3430
+ this.graph = null;
3431
+ this.runBase = null;
3432
+ this.broadcast({ type: "autophase.cleared", payload: {} });
3433
+ this.broadcast({ type: "autophase.state", payload: this.buildState() });
3434
+ }
3435
+ }
3218
3436
  /** Generic fallback phases when the LLM planner produces nothing usable. */
3219
3437
  defaultPhases() {
3220
3438
  return [
@@ -3225,13 +3443,18 @@ var AutoPhaseWebSocketHandler = class {
3225
3443
  { name: "Deployment", description: "Deploy to production", priority: "medium", estimateHours: 2, parallelizable: false }
3226
3444
  ];
3227
3445
  }
3228
- /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure. */
3229
- async planPhases(goal) {
3446
+ /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure.
3447
+ * The caller passes the run's abort signal so a stop during planning cancels
3448
+ * the LLM turn (the previous fresh, never-aborted controller made planning
3449
+ * uninterruptible). */
3450
+ async planPhases(goal, signal) {
3230
3451
  try {
3231
3452
  const planner = new AutoPhasePlanner({
3232
3453
  goal,
3233
3454
  runOnce: async (prompt) => {
3234
- const result = await this.agent.run(prompt, { signal: new AbortController().signal });
3455
+ const result = await this.agent.run(prompt, {
3456
+ signal: signal ?? new AbortController().signal
3457
+ });
3235
3458
  return result.status === "done" ? result.finalText ?? "" : "";
3236
3459
  }
3237
3460
  });
@@ -3357,15 +3580,37 @@ Type: ${task.type}`;
3357
3580
  });
3358
3581
  const taskItems = activePhase ? Array.from(activePhase.taskGraph.nodes.values()).map(mapTask) : [];
3359
3582
  const completedPhases = phases.filter((p) => p.status === "completed").length;
3583
+ const failedPhases = phases.filter((p) => p.status === "failed").length;
3584
+ const failedTasks = phases.reduce(
3585
+ (sum, p) => sum + Array.from(p.taskGraph.nodes.values()).filter((t) => t.status === "failed").length,
3586
+ 0
3587
+ );
3588
+ const lastFailed = phases.filter((p) => p.status === "failed").sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
3589
+ const lastError = lastFailed ? `${lastFailed.name}: ${lastFailed.metadata?.integrationError ?? "phase failed"}` : null;
3360
3590
  return {
3361
3591
  title: this.graph.title,
3592
+ // Full operator prompt, shown verbatim in a dedicated goal block (the
3593
+ // title is only a short derived heading). Fall back to the title for
3594
+ // legacy boards saved before the title/goal split.
3595
+ goal: this.graph.description || this.graph.title,
3362
3596
  phases: phaseItems,
3363
3597
  tasks: taskItems,
3364
3598
  activePhaseId: currentActiveId,
3365
3599
  overallPercent: phases.length > 0 ? Math.round(completedPhases / phases.length * 100) : 0,
3366
3600
  autonomous: this.graph.autonomous,
3367
3601
  totalTasks,
3368
- completedTasks
3602
+ completedTasks,
3603
+ // Structured progress + lastError consumed by the autophase store (were
3604
+ // defined client-side but never sent, so they stayed null on the board).
3605
+ progress: {
3606
+ totalPhases: phases.length,
3607
+ completed: completedPhases,
3608
+ failed: failedPhases,
3609
+ totalTasks,
3610
+ completedTasks,
3611
+ failedTasks
3612
+ },
3613
+ lastError
3369
3614
  };
3370
3615
  }
3371
3616
  sendState(client) {
@@ -3658,6 +3903,12 @@ var SddBoardWebSocketHandler = class {
3658
3903
  };
3659
3904
 
3660
3905
  // src/server/sdd-wizard-ws-handler.ts
3906
+ function deriveTitle2(goal) {
3907
+ const firstLine = goal.split("\n").map((l) => l.trim()).find(Boolean);
3908
+ if (!firstLine) return "New SDD Project";
3909
+ const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
3910
+ return sentence.length <= 64 ? sentence : `${sentence.slice(0, 63).trimEnd()}\u2026`;
3911
+ }
3661
3912
  var SddWizardWebSocketHandler = class {
3662
3913
  constructor(deps2) {
3663
3914
  this.deps = deps2;
@@ -3696,7 +3947,8 @@ var SddWizardWebSocketHandler = class {
3696
3947
  parallelSlots: msg.payload?.parallelSlots,
3697
3948
  defaultModel: msg.payload?.model,
3698
3949
  defaultProvider: msg.payload?.provider,
3699
- fallbackModels: Array.isArray(msg.payload?.fallbackModels) ? msg.payload?.fallbackModels : void 0
3950
+ fallbackModels: Array.isArray(msg.payload?.fallbackModels) ? msg.payload?.fallbackModels : void 0,
3951
+ worktrees: typeof msg.payload?.worktrees === "boolean" ? msg.payload.worktrees : void 0
3700
3952
  });
3701
3953
  break;
3702
3954
  }
@@ -3716,7 +3968,7 @@ var SddWizardWebSocketHandler = class {
3716
3968
  }
3717
3969
  if (this.busy) return;
3718
3970
  this.driver = this.deps.makeDriver();
3719
- const prompt = this.driver.start(goal);
3971
+ const prompt = this.driver.start(deriveTitle2(goal), goal);
3720
3972
  await this.runTurn(prompt);
3721
3973
  }
3722
3974
  async onMessage(text) {
@@ -3787,7 +4039,7 @@ var SddWizardWebSocketHandler = class {
3787
4039
  };
3788
4040
 
3789
4041
  // src/server/sdd-wizard-wiring.ts
3790
- import * as path6 from "path";
4042
+ import * as path7 from "path";
3791
4043
  import { spawnSync as spawnSync2 } from "child_process";
3792
4044
  import {
3793
4045
  makeCommandVerifier,
@@ -3801,6 +4053,7 @@ import {
3801
4053
  TaskGraphStore as TaskGraphStore2,
3802
4054
  WorktreeManager as WorktreeManager2
3803
4055
  } from "@wrongstack/core";
4056
+ var PLANNING_ONLY_GUARD = "SYSTEM: You are running a PLANNING-ONLY specification interview. Do NOT write, create, or edit any files, and do NOT run shell/terminal commands or use any code-editing tools \u2014 they are disabled here and any attempt will fail and waste the turn. Respond with TEXT ONLY: ask your question, or emit the requested spec / plan / task JSON. All code is written later, automatically, once the plan is approved and the multi-agent run starts.\n\n---\n\n";
3804
4057
  function buildSddWizardDeps(opts) {
3805
4058
  const registry = new SddRunRegistry();
3806
4059
  let isolatedSeq = 0;
@@ -3809,11 +4062,11 @@ function buildSddWizardDeps(opts) {
3809
4062
  id: `sdd-${name2.toLowerCase().replace(/\s+/g, "-")}-${isolatedSeq++}`,
3810
4063
  role: "executor",
3811
4064
  name: name2,
3812
- disabledTools: ["delegate"],
4065
+ disabledTools: ["delegate", "write", "edit", "patch", "bash", "exec"],
3813
4066
  allowedCapabilities: ["fs.read", "net.outbound"]
3814
4067
  });
3815
4068
  try {
3816
- const res = await result.agent.run([{ type: "text", text: prompt }]);
4069
+ const res = await result.agent.run([{ type: "text", text: PLANNING_ONLY_GUARD + prompt }]);
3817
4070
  return res.finalText ?? "";
3818
4071
  } finally {
3819
4072
  await result.dispose?.();
@@ -3823,17 +4076,18 @@ function buildSddWizardDeps(opts) {
3823
4076
  makeDriver: () => new SddInterviewDriver({
3824
4077
  specStore: new SpecStore2({ baseDir: opts.paths.projectSpecs }),
3825
4078
  graphStore: new TaskGraphStore2({ baseDir: opts.paths.projectTaskGraphs }),
3826
- sessionPath: path6.join(opts.paths.projectDir, "sdd-wizard-session.json")
4079
+ sessionPath: path7.join(opts.paths.projectDir, "sdd-wizard-session.json")
3827
4080
  }),
3828
4081
  runInterviewTurn: (prompt) => runIsolatedTurn(prompt, "Spec Architect"),
3829
- startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels }) => {
4082
+ startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels, worktrees: useWorktrees }) => {
3830
4083
  const graph = driver.getGraph();
3831
4084
  const tracker = driver.getTracker();
3832
4085
  if (!graph || !tracker) {
3833
4086
  throw new Error("No task graph to run \u2014 finish the interview first.");
3834
4087
  }
4088
+ const worktreesEnabled = useWorktrees ?? process.env["WRONGSTACK_SDD_WORKTREES"] !== "0";
3835
4089
  let worktrees;
3836
- if (process.env["WRONGSTACK_SDD_WORKTREES"] !== "0") {
4090
+ if (worktreesEnabled) {
3837
4091
  const inGit = spawnSync2("git", ["rev-parse", "--is-inside-work-tree"], {
3838
4092
  cwd: opts.projectRoot,
3839
4093
  encoding: "utf8",
@@ -3892,9 +4146,6 @@ async function handleSddWizardRoute(_ws, msg, handlers) {
3892
4146
  return true;
3893
4147
  }
3894
4148
 
3895
- // src/server/index.ts
3896
- import { makeLightSubagentFactory } from "@wrongstack/runtime";
3897
-
3898
4149
  // src/server/collaboration-ws-handler.ts
3899
4150
  import { randomUUID } from "crypto";
3900
4151
  import { toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils";
@@ -4621,16 +4872,16 @@ var CollaborationWebSocketHandler = class {
4621
4872
  };
4622
4873
 
4623
4874
  // src/server/projects-manifest.ts
4624
- import * as fs5 from "fs/promises";
4625
- import * as path7 from "path";
4875
+ import * as fs6 from "fs/promises";
4876
+ import * as path8 from "path";
4626
4877
  import { projectSlug } from "@wrongstack/core";
4627
4878
  function projectsJsonPath(globalConfigPath) {
4628
- const base = path7.dirname(globalConfigPath);
4629
- return path7.join(base, "projects.json");
4879
+ const base = path8.dirname(globalConfigPath);
4880
+ return path8.join(base, "projects.json");
4630
4881
  }
4631
4882
  async function loadManifest(globalConfigPath) {
4632
4883
  try {
4633
- const raw = await fs5.readFile(projectsJsonPath(globalConfigPath), "utf8");
4884
+ const raw = await fs6.readFile(projectsJsonPath(globalConfigPath), "utf8");
4634
4885
  const parsed = JSON.parse(raw);
4635
4886
  return { projects: parsed.projects ?? [] };
4636
4887
  } catch {
@@ -4639,16 +4890,16 @@ async function loadManifest(globalConfigPath) {
4639
4890
  }
4640
4891
  async function saveManifest(manifest, globalConfigPath) {
4641
4892
  const file = projectsJsonPath(globalConfigPath);
4642
- await fs5.mkdir(path7.dirname(file), { recursive: true });
4643
- await fs5.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
4893
+ await fs6.mkdir(path8.dirname(file), { recursive: true });
4894
+ await fs6.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
4644
4895
  }
4645
4896
  function generateProjectSlug(rootPath) {
4646
4897
  return projectSlug(rootPath);
4647
4898
  }
4648
4899
  async function ensureProjectDataDir(slug, globalConfigPath) {
4649
- const base = path7.dirname(globalConfigPath);
4650
- const dir = path7.join(base, "projects", slug);
4651
- await fs5.mkdir(dir, { recursive: true });
4900
+ const base = path8.dirname(globalConfigPath);
4901
+ const dir = path8.join(base, "projects", slug);
4902
+ await fs6.mkdir(dir, { recursive: true });
4652
4903
  return dir;
4653
4904
  }
4654
4905
 
@@ -5074,14 +5325,14 @@ function registerShutdownHandlers(res) {
5074
5325
 
5075
5326
  // src/server/instance-registry.ts
5076
5327
  import * as os from "os";
5077
- import * as path8 from "path";
5078
- import * as fs6 from "fs/promises";
5328
+ import * as path9 from "path";
5329
+ import * as fs7 from "fs/promises";
5079
5330
  import { atomicWrite as atomicWrite3 } from "@wrongstack/core";
5080
5331
  function defaultBaseDir() {
5081
- return path8.join(os.homedir(), ".wrongstack");
5332
+ return path9.join(os.homedir(), ".wrongstack");
5082
5333
  }
5083
5334
  function registryPath(baseDir = defaultBaseDir()) {
5084
- return path8.join(baseDir, "webui-instances.json");
5335
+ return path9.join(baseDir, "webui-instances.json");
5085
5336
  }
5086
5337
  function isPidAlive(pid) {
5087
5338
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -5094,7 +5345,7 @@ function isPidAlive(pid) {
5094
5345
  }
5095
5346
  async function load(file) {
5096
5347
  try {
5097
- const raw = await fs6.readFile(file, "utf8");
5348
+ const raw = await fs7.readFile(file, "utf8");
5098
5349
  const parsed = JSON.parse(raw);
5099
5350
  if (parsed?.version === 1 && Array.isArray(parsed.instances)) {
5100
5351
  return parsed;
@@ -5239,19 +5490,19 @@ function computeUsageCost(usage, rates) {
5239
5490
  }
5240
5491
 
5241
5492
  // src/server/provider-handlers.ts
5242
- import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/core";
5493
+ import { DefaultSecretScrubber } from "@wrongstack/core";
5243
5494
  import { probeLocalLlm } from "@wrongstack/runtime/probe";
5244
5495
 
5245
5496
  // src/server/provider-config-io.ts
5246
- import * as fs7 from "fs/promises";
5247
- import * as path9 from "path";
5497
+ import * as fs8 from "fs/promises";
5498
+ import * as path10 from "path";
5248
5499
  import { atomicWrite as atomicWrite4 } from "@wrongstack/core";
5249
5500
  import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/security";
5250
5501
  import { DefaultSecretVault } from "@wrongstack/core";
5251
5502
  async function loadSavedProviders(configPath, vault) {
5252
5503
  let raw;
5253
5504
  try {
5254
- raw = await fs7.readFile(configPath, "utf8");
5505
+ raw = await fs8.readFile(configPath, "utf8");
5255
5506
  } catch {
5256
5507
  return {};
5257
5508
  }
@@ -5268,7 +5519,7 @@ async function saveProviders(configPath, vault, providers) {
5268
5519
  let raw;
5269
5520
  let fileExists = true;
5270
5521
  try {
5271
- raw = await fs7.readFile(configPath, "utf8");
5522
+ raw = await fs8.readFile(configPath, "utf8");
5272
5523
  } catch (err) {
5273
5524
  if (err.code !== "ENOENT") {
5274
5525
  throw new Error(
@@ -5417,7 +5668,7 @@ function projectSavedProviders(providers) {
5417
5668
  return view;
5418
5669
  });
5419
5670
  }
5420
- var probeScrubber = new DefaultSecretScrubber2();
5671
+ var probeScrubber = new DefaultSecretScrubber();
5421
5672
  function createProviderHandlers(deps2) {
5422
5673
  const { globalConfigPath, vault, broadcast: broadcast2, clients } = deps2;
5423
5674
  let configWriteLock = deps2.getConfigWriteLock();
@@ -5606,7 +5857,7 @@ function createProviderHandlers(deps2) {
5606
5857
 
5607
5858
  // src/server/mode-handlers.ts
5608
5859
  import {
5609
- DefaultSystemPromptBuilder as DefaultSystemPromptBuilder2
5860
+ DefaultSystemPromptBuilder
5610
5861
  } from "@wrongstack/core";
5611
5862
  function createModeHandlers(ctx) {
5612
5863
  return {
@@ -5654,7 +5905,7 @@ function createModeHandlers(ctx) {
5654
5905
  }
5655
5906
  ctx.setModeId(id);
5656
5907
  const modePrompt = id === "default" ? "" : (await ctx.modeStore.getMode(id))?.prompt ?? "";
5657
- const freshBuilder = new DefaultSystemPromptBuilder2({
5908
+ const freshBuilder = new DefaultSystemPromptBuilder({
5658
5909
  memoryStore: ctx.memoryStore,
5659
5910
  skillLoader: ctx.skillLoader,
5660
5911
  modeStore: ctx.modeStore,
@@ -5685,40 +5936,10 @@ function createModeHandlers(ctx) {
5685
5936
  import * as fs9 from "fs/promises";
5686
5937
  import * as path11 from "path";
5687
5938
  import {
5688
- DefaultSessionStore as DefaultSessionStore2,
5689
- DefaultSystemPromptBuilder as DefaultSystemPromptBuilder3,
5939
+ DefaultSessionStore,
5940
+ DefaultSystemPromptBuilder as DefaultSystemPromptBuilder2,
5690
5941
  getSessionRegistry
5691
5942
  } from "@wrongstack/core";
5692
-
5693
- // src/server/path-containment.ts
5694
- import * as fs8 from "fs/promises";
5695
- import * as path10 from "path";
5696
- function isPathInside(root, target) {
5697
- const relative3 = path10.relative(root, target);
5698
- return relative3 === "" || !relative3.startsWith("..") && !path10.isAbsolute(relative3);
5699
- }
5700
- async function resolveWorkingDirInsideProject(projectRoot, inputPath) {
5701
- const resolved = path10.resolve(projectRoot, inputPath);
5702
- let stat3;
5703
- try {
5704
- stat3 = await fs8.stat(resolved);
5705
- } catch {
5706
- throw new Error(`Directory not found or not accessible: ${resolved}`);
5707
- }
5708
- if (!stat3.isDirectory()) {
5709
- throw new Error(`Directory not found or not accessible: ${resolved}`);
5710
- }
5711
- const [realProjectRoot, realResolved] = await Promise.all([
5712
- fs8.realpath(projectRoot),
5713
- fs8.realpath(resolved)
5714
- ]);
5715
- if (!isPathInside(realProjectRoot, realResolved)) {
5716
- throw new Error(`Path must stay inside the project root: ${projectRoot}`);
5717
- }
5718
- return resolved;
5719
- }
5720
-
5721
- // src/server/project-handlers.ts
5722
5943
  function createProjectHandlers(ctx) {
5723
5944
  return {
5724
5945
  listProjects: async (ws) => {
@@ -5830,7 +6051,7 @@ function createProjectHandlers(ctx) {
5830
6051
  try {
5831
6052
  const modeId = ctx.getModeId();
5832
6053
  const switchMode = modeId === "default" ? void 0 : await ctx.modeStore.getMode(modeId);
5833
- const switchBuilder = new DefaultSystemPromptBuilder3({
6054
+ const switchBuilder = new DefaultSystemPromptBuilder2({
5834
6055
  memoryStore: ctx.memoryStore,
5835
6056
  skillLoader: ctx.skillLoader,
5836
6057
  modeStore: ctx.modeStore,
@@ -5854,7 +6075,7 @@ function createProjectHandlers(ctx) {
5854
6075
  "sessions"
5855
6076
  );
5856
6077
  await fs9.mkdir(newSessionsDir, { recursive: true });
5857
- const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
6078
+ const newSessionStore = new DefaultSessionStore({ dir: newSessionsDir });
5858
6079
  const oldSession = ctx.getSession();
5859
6080
  const oldSessionId = oldSession.id;
5860
6081
  try {
@@ -6551,6 +6772,22 @@ async function handleModeRoute(ws, msg, handlers) {
6551
6772
  }
6552
6773
  }
6553
6774
 
6775
+ // src/server/prefs-routes.ts
6776
+ async function handlePrefsRoute(ws, msg, handlers) {
6777
+ switch (msg.type) {
6778
+ case "prefs.get": {
6779
+ await handlers.getPrefs(ws);
6780
+ return true;
6781
+ }
6782
+ case "prefs.update": {
6783
+ await handlers.updatePrefs(ws, msg.payload ?? {});
6784
+ return true;
6785
+ }
6786
+ default:
6787
+ return false;
6788
+ }
6789
+ }
6790
+
6554
6791
  // src/server/shell-git-routes.ts
6555
6792
  async function handleShellGitRoute(ws, msg, handlers) {
6556
6793
  switch (msg.type) {
@@ -6591,6 +6828,44 @@ async function handleMailboxRoute(ws, msg, handlers) {
6591
6828
  }
6592
6829
  }
6593
6830
 
6831
+ // src/server/mcp-routes.ts
6832
+ async function handleMcpRoute(ws, msg, handlers) {
6833
+ switch (msg.type) {
6834
+ case "mcp.list":
6835
+ await handlers.list(ws, msg);
6836
+ return true;
6837
+ case "mcp.add":
6838
+ await handlers.add(ws, msg);
6839
+ return true;
6840
+ case "mcp.update":
6841
+ await handlers.update(ws, msg);
6842
+ return true;
6843
+ case "mcp.remove":
6844
+ await handlers.remove(ws, msg);
6845
+ return true;
6846
+ case "mcp.enable":
6847
+ await handlers.enable(ws, msg);
6848
+ return true;
6849
+ case "mcp.disable":
6850
+ await handlers.disable(ws, msg);
6851
+ return true;
6852
+ case "mcp.sleep":
6853
+ await handlers.sleep(ws, msg);
6854
+ return true;
6855
+ case "mcp.wake":
6856
+ await handlers.wake(ws, msg);
6857
+ return true;
6858
+ case "mcp.restart":
6859
+ await handlers.restart(ws, msg);
6860
+ return true;
6861
+ case "mcp.discover":
6862
+ await handlers.discover(ws, msg);
6863
+ return true;
6864
+ default:
6865
+ return false;
6866
+ }
6867
+ }
6868
+
6594
6869
  // src/server/brain-routes.ts
6595
6870
  async function handleBrainRoute(ws, msg, handlers) {
6596
6871
  switch (msg.type) {
@@ -7062,11 +7337,13 @@ function setupEvents(deps2) {
7062
7337
  events.on("provider.response", (e) => {
7063
7338
  if (e.usage?.input != null) {
7064
7339
  const maxCtx = context.provider.capabilities.maxContext;
7065
- const pct = maxCtx > 0 ? e.usage.input / maxCtx : 0;
7340
+ const rawLoad = maxCtx > 0 ? e.usage.input / maxCtx : 0;
7341
+ const load2 = Math.max(0, Math.min(1, rawLoad));
7066
7342
  const costUsd = context.tokenCounter.estimateCost().total;
7067
7343
  forwardSubagent("ctx_pct", {
7068
7344
  subagentId: "leader",
7069
- load: pct,
7345
+ load: load2,
7346
+ rawLoad,
7070
7347
  tokens: e.usage.input,
7071
7348
  maxContext: maxCtx,
7072
7349
  costUsd
@@ -7717,9 +7994,13 @@ async function handleGoalGet(projectRoot, broadcast2) {
7717
7994
 
7718
7995
  // src/server/index.ts
7719
7996
  async function startWebUI(opts = {}) {
7997
+ ensureSessionShell();
7720
7998
  const requestedWsPort = opts.wsPort ?? 3457;
7721
- const wsHost = opts.wsHost ?? "127.0.0.1";
7722
- const requestedHttpPort = Number.parseInt(process.env["PORT"] ?? "3456", 10);
7999
+ const wsHost = opts.wsHost ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
8000
+ const requestedHttpPort = opts.httpPort ?? opts.webuiPort ?? opts.port ?? Number.parseInt(process.env["WEBUI_PORT"] ?? process.env["PORT"] ?? "3456", 10);
8001
+ const publicUrl = opts.publicUrl ?? process.env["WEBUI_PUBLIC_URL"];
8002
+ const publicWsUrl = opts.publicWsUrl ?? process.env["WEBUI_PUBLIC_WS_URL"];
8003
+ const requireToken = opts.requireToken ?? envFlag("WEBUI_REQUIRE_TOKEN");
7723
8004
  const strictPort = process.env["WEBUI_STRICT_PORT"] === "1" || process.env["WEBUI_STRICT_PORT"] === "true";
7724
8005
  let wsPort = requestedWsPort;
7725
8006
  let httpPort = requestedHttpPort;
@@ -7798,7 +8079,7 @@ async function startWebUI(opts = {}) {
7798
8079
  ttlSeconds: 24 * 3600
7799
8080
  });
7800
8081
  const container = createDefaultContainer({ config, wpaths, logger, modelsRegistry });
7801
- const configStore = opts.services?.configStore ?? container.resolve(TOKENS2.ConfigStore);
8082
+ const configStore = opts.services?.configStore ?? container.resolve(TOKENS.ConfigStore);
7802
8083
  const providerRegistry = new ProviderRegistry();
7803
8084
  try {
7804
8085
  const factories = await buildProviderFactoriesFromRegistry({
@@ -7820,7 +8101,7 @@ async function startWebUI(opts = {}) {
7820
8101
  r.registerAllOrThrow([...builtinToolsPack.tools ?? []], builtinToolsPack.name);
7821
8102
  return r;
7822
8103
  })();
7823
- const memoryStore = new DefaultMemoryStore2({ paths: wpaths });
8104
+ const memoryStore = new DefaultMemoryStore({ paths: wpaths });
7824
8105
  if (config.features.memory) {
7825
8106
  toolRegistry.register(rememberTool(memoryStore));
7826
8107
  toolRegistry.register(forgetTool(memoryStore));
@@ -7833,6 +8114,7 @@ async function startWebUI(opts = {}) {
7833
8114
  toolRegistry.register(makeMailSendTool({ projectDir: wpaths.projectDir, events }));
7834
8115
  toolRegistry.register(makeMailInboxTool({ projectDir: wpaths.projectDir, events }));
7835
8116
  applyToolDescriptionModes(toolRegistry, config.tools?.descriptionMode);
8117
+ configureExecPolicy(config.tools?.exec ?? {});
7836
8118
  console.log("[WebUI] Tool registry loaded:", toolRegistry.list().length, "tools");
7837
8119
  const mcpRegistry = new MCPRegistry({
7838
8120
  toolRegistry,
@@ -7849,7 +8131,7 @@ async function startWebUI(opts = {}) {
7849
8131
  });
7850
8132
  }
7851
8133
  }
7852
- let sessionStore = opts.services?.session ?? new DefaultSessionStore3({ dir: wpaths.projectSessions });
8134
+ let sessionStore = opts.services?.session ?? new DefaultSessionStore2({ dir: wpaths.projectSessions });
7853
8135
  if (!opts.services?.session) {
7854
8136
  sessionStore.prune(DEFAULT_SESSION_PRUNE_DAYS).then((count) => {
7855
8137
  if (count > 0) logger.info(`Pruned ${count} old session${count === 1 ? "" : "s"}.`);
@@ -7937,11 +8219,11 @@ async function startWebUI(opts = {}) {
7937
8219
  });
7938
8220
  } catch {
7939
8221
  }
7940
- const tokenCounter = new DefaultTokenCounter2({
8222
+ const tokenCounter = new DefaultTokenCounter({
7941
8223
  registry: modelsRegistry,
7942
8224
  providerId: config.provider
7943
8225
  });
7944
- const modeStore = new DefaultModeStore2({ directory: wpaths.configDir });
8226
+ const modeStore = new DefaultModeStore({ directory: wpaths.configDir });
7945
8227
  const activeMode = await modeStore.getActiveMode();
7946
8228
  let modeId = activeMode?.id ?? "default";
7947
8229
  const modePrompt = activeMode?.prompt ?? "";
@@ -7962,7 +8244,7 @@ async function startWebUI(opts = {}) {
7962
8244
  const modelCapabilitiesRef = {
7963
8245
  current: modelCapabilities
7964
8246
  };
7965
- const skillLoader = config.features.skills ? new DefaultSkillLoader2({ paths: wpaths }) : void 0;
8247
+ const skillLoader = config.features.skills ? new DefaultSkillLoader({ paths: wpaths }) : void 0;
7966
8248
  const skillInstaller = config.features.skills ? new SkillInstaller({
7967
8249
  manifestPath: path16.join(wstackGlobalRoot2(), "installed-skills.json"),
7968
8250
  projectSkillsDir: path16.join(projectRoot, ".wrongstack", "skills"),
@@ -7970,7 +8252,7 @@ async function startWebUI(opts = {}) {
7970
8252
  projectHash: projectHash(projectRoot),
7971
8253
  skillLoader
7972
8254
  }) : void 0;
7973
- const systemPromptBuilder = new DefaultSystemPromptBuilder4({
8255
+ const systemPromptBuilder = new DefaultSystemPromptBuilder3({
7974
8256
  memoryStore,
7975
8257
  skillLoader,
7976
8258
  modeStore,
@@ -8260,7 +8542,7 @@ async function startWebUI(opts = {}) {
8260
8542
  projectRoot,
8261
8543
  logger
8262
8544
  });
8263
- const compactor = createStrategyCompactor2({
8545
+ const compactor = createStrategyCompactor({
8264
8546
  strategy: config.context?.strategy,
8265
8547
  preserveK: config.context?.preserveK ?? 10,
8266
8548
  eliseThreshold: config.context?.eliseThreshold ?? 2e3,
@@ -8336,9 +8618,9 @@ async function startWebUI(opts = {}) {
8336
8618
  maxContext: newMaxContext
8337
8619
  });
8338
8620
  }
8339
- const secretScrubber = container.resolve(TOKENS2.SecretScrubber);
8340
- const renderer = container.has(TOKENS2.Renderer) ? container.resolve(TOKENS2.Renderer) : void 0;
8341
- const permissionPolicy = container.resolve(TOKENS2.PermissionPolicy);
8621
+ const secretScrubber = container.resolve(TOKENS.SecretScrubber);
8622
+ const renderer = container.has(TOKENS.Renderer) ? container.resolve(TOKENS.Renderer) : void 0;
8623
+ const permissionPolicy = container.resolve(TOKENS.PermissionPolicy);
8342
8624
  const toolExecutor = new ToolExecutor(toolRegistry, {
8343
8625
  permissionPolicy,
8344
8626
  secretScrubber,
@@ -8381,7 +8663,7 @@ async function startWebUI(opts = {}) {
8381
8663
  }),
8382
8664
  events
8383
8665
  );
8384
- container.bind(TOKENS2.BrainArbiter, () => brain);
8666
+ container.bind(TOKENS.BrainArbiter, () => brain);
8385
8667
  const brainMailbox = new GlobalMailbox2(wpaths.projectDir, events);
8386
8668
  const brainMonitor = new BrainMonitor({
8387
8669
  events,
@@ -8513,8 +8795,16 @@ async function startWebUI(opts = {}) {
8513
8795
  contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID2)
8514
8796
  };
8515
8797
  }
8516
- const wsToken = generateAuthToken();
8517
- console.log("[WebUI] WS auth token generated (redacted from logs)");
8798
+ const wsToken = resolveAuthToken(opts.accessToken);
8799
+ console.log("[WebUI] WS auth token ready");
8800
+ const publicHostnames = [publicUrl, publicWsUrl].map((value) => {
8801
+ if (!value) return void 0;
8802
+ try {
8803
+ return new URL(value).hostname;
8804
+ } catch {
8805
+ return void 0;
8806
+ }
8807
+ }).filter((value) => Boolean(value));
8518
8808
  const verifyClient2 = (info) => verifyClient({
8519
8809
  origin: info.origin,
8520
8810
  url: info.req.url ?? "",
@@ -8526,7 +8816,10 @@ async function startWebUI(opts = {}) {
8526
8816
  // exposure class.
8527
8817
  cookieHeader: info.req.headers.cookie,
8528
8818
  wsHost,
8529
- expectedToken: wsToken
8819
+ expectedToken: wsToken,
8820
+ requireToken,
8821
+ allowedHostnames: publicHostnames,
8822
+ allowBrowserUrlToken: Boolean(publicWsUrl)
8530
8823
  });
8531
8824
  const WS_MAX_PAYLOAD = 8 * 1024 * 1024;
8532
8825
  const wssPrimary = new WebSocketServer({
@@ -8761,8 +9054,10 @@ async function startWebUI(opts = {}) {
8761
9054
  let sessionRoutes;
8762
9055
  let projectRoutes;
8763
9056
  let modeRoutes;
9057
+ let prefsRoutes;
8764
9058
  let shellGitRoutes;
8765
9059
  let mailboxRoutes;
9060
+ let mcpRoutes;
8766
9061
  let brainRoutes;
8767
9062
  let autoPhaseRoutes;
8768
9063
  let specsRoutes;
@@ -8773,8 +9068,10 @@ async function startWebUI(opts = {}) {
8773
9068
  if (await handleSessionRoute(ws, msg, sessionRoutes)) return;
8774
9069
  if (await handleProjectRoute(ws, msg, projectRoutes)) return;
8775
9070
  if (await handleModeRoute(ws, msg, modeRoutes)) return;
9071
+ if (await handlePrefsRoute(ws, msg, prefsRoutes)) return;
8776
9072
  if (await handleShellGitRoute(ws, msg, shellGitRoutes)) return;
8777
9073
  if (await handleMailboxRoute(ws, msg, mailboxRoutes)) return;
9074
+ if (await handleMcpRoute(ws, msg, mcpRoutes)) return;
8778
9075
  if (await handleBrainRoute(ws, msg, brainRoutes)) return;
8779
9076
  if (await handleAutoPhaseRoute(ws, msg, autoPhaseRoutes)) return;
8780
9077
  if (await handleSpecsRoute(ws, msg, specsRoutes)) return;
@@ -8885,27 +9182,31 @@ async function startWebUI(opts = {}) {
8885
9182
  case "memory.forget":
8886
9183
  return handleMemoryForget(ws, msg, memoryStore);
8887
9184
  // ── MCP operations — delegated to shared handlers (mcp-handlers.ts),
8888
- // backed by the live MCPRegistry constructed above. ──
9185
+ // backed by the live MCPRegistry constructed above. Routed via
9186
+ // handleMcpRoute (see mcpRoutes = { ... } below). These case arms
9187
+ // are unreachable but left as tripwires for any future regression
9188
+ // where the route chain stops claiming 'mcp.*'. If you see one
9189
+ // fire, fix the dispatch order in the handleMessage chain above.
8889
9190
  case "mcp.list":
8890
- return handleMcpList(ws, msg, globalConfigPath, mcpRegistry);
9191
+ throw new Error("handleMcpRoute did not claim mcp.list \u2014 check chain order");
8891
9192
  case "mcp.add":
8892
- return handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry);
8893
- case "mcp.remove":
8894
- return handleMcpRemove(ws, msg, globalConfigPath, mcpRegistry);
9193
+ throw new Error("handleMcpRoute did not claim mcp.add \u2014 check chain order");
8895
9194
  case "mcp.update":
8896
- return handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry);
8897
- case "mcp.wake":
8898
- return handleMcpWake(ws, msg, globalConfigPath, mcpRegistry);
8899
- case "mcp.sleep":
8900
- return handleMcpSleep(ws, msg, globalConfigPath, mcpRegistry);
8901
- case "mcp.discover":
8902
- return handleMcpDiscover(ws, msg, globalConfigPath, mcpRegistry);
9195
+ throw new Error("handleMcpRoute did not claim mcp.update \u2014 check chain order");
9196
+ case "mcp.remove":
9197
+ throw new Error("handleMcpRoute did not claim mcp.remove \u2014 check chain order");
8903
9198
  case "mcp.enable":
8904
- return handleMcpEnable(ws, msg, globalConfigPath, mcpRegistry);
9199
+ throw new Error("handleMcpRoute did not claim mcp.enable \u2014 check chain order");
8905
9200
  case "mcp.disable":
8906
- return handleMcpDisable(ws, msg, globalConfigPath, mcpRegistry);
9201
+ throw new Error("handleMcpRoute did not claim mcp.disable \u2014 check chain order");
9202
+ case "mcp.sleep":
9203
+ throw new Error("handleMcpRoute did not claim mcp.sleep \u2014 check chain order");
9204
+ case "mcp.wake":
9205
+ throw new Error("handleMcpRoute did not claim mcp.wake \u2014 check chain order");
8907
9206
  case "mcp.restart":
8908
- return handleMcpRestart(ws, msg, globalConfigPath, mcpRegistry);
9207
+ throw new Error("handleMcpRoute did not claim mcp.restart \u2014 check chain order");
9208
+ case "mcp.discover":
9209
+ throw new Error("handleMcpRoute did not claim mcp.discover \u2014 check chain order");
8909
9210
  // Skills — full request→response cycle lives in skills-handlers.ts
8910
9211
  // (shared with the CLI's embedded server). skillsCtx is the closed-over
8911
9212
  // loader/installer/projectRoot the handlers need.
@@ -9053,53 +9354,11 @@ async function startWebUI(opts = {}) {
9053
9354
  break;
9054
9355
  }
9055
9356
  case "prefs.update": {
9056
- const parsed = validatePrefsUpdatePayload(msg.payload);
9057
- if (!parsed.ok) {
9058
- sendResult2(ws, false, parsed.message);
9059
- break;
9060
- }
9061
- const payload = parsed.value.prefs;
9062
- for (const [key, val] of Object.entries(payload)) {
9063
- context.meta[key] = val;
9064
- }
9065
- void persistPrefsToConfig(payload);
9066
- if (typeof payload["yolo"] === "boolean") {
9067
- permissionPolicy.setYolo?.(payload["yolo"]);
9068
- }
9069
- if (typeof payload["featureMcp"] === "boolean")
9070
- config.features.mcp = payload["featureMcp"];
9071
- if (typeof payload["featurePlugins"] === "boolean")
9072
- config.features.plugins = payload["featurePlugins"];
9073
- if (typeof payload["featureMemory"] === "boolean")
9074
- config.features.memory = payload["featureMemory"];
9075
- if (typeof payload["featureSkills"] === "boolean")
9076
- config.features.skills = payload["featureSkills"];
9077
- if (typeof payload["featureModelsRegistry"] === "boolean")
9078
- config.features.modelsRegistry = payload["featureModelsRegistry"];
9079
- if (Array.isArray(payload["fallbackModels"]))
9080
- config.fallbackModels = payload["fallbackModels"];
9081
- if (typeof payload["fallbackAuto"] === "boolean")
9082
- config.fallbackAuto = payload["fallbackAuto"];
9083
- if (typeof payload["contextAutoCompact"] === "boolean") {
9084
- if (payload["contextAutoCompact"] && autoCompactor) {
9085
- pipelines.contextWindow.remove("AutoCompaction", { optional: true });
9086
- pipelines.contextWindow.use({ name: "AutoCompaction", handler: autoCompactor.handler() });
9087
- } else {
9088
- pipelines.contextWindow.remove("AutoCompaction", { optional: true });
9089
- }
9090
- }
9091
- if (typeof payload["logLevel"] === "string") {
9092
- const valid = ["debug", "info", "warn", "error"];
9093
- if (valid.includes(payload["logLevel"])) {
9094
- logger.level = payload["logLevel"];
9095
- }
9096
- }
9097
- broadcast(clients, { type: "prefs.updated", payload: prefSnapshot() });
9098
- break;
9357
+ void ws;
9358
+ throw new Error("handlePrefsRoute did not claim prefs.update \u2014 check chain order");
9099
9359
  }
9100
9360
  case "prefs.get": {
9101
- send(ws, { type: "prefs.updated", payload: prefSnapshot() });
9102
- break;
9361
+ throw new Error("handlePrefsRoute did not claim prefs.get \u2014 check chain order");
9103
9362
  }
9104
9363
  default:
9105
9364
  send(ws, {
@@ -9320,6 +9579,55 @@ async function startWebUI(opts = {}) {
9320
9579
  },
9321
9580
  sessionStartPayload
9322
9581
  });
9582
+ prefsRoutes = {
9583
+ getPrefs: async (ws) => {
9584
+ send(ws, { type: "prefs.updated", payload: prefSnapshot() });
9585
+ },
9586
+ updatePrefs: async (ws, msgPayload) => {
9587
+ const parsed = validatePrefsUpdatePayload(msgPayload);
9588
+ if (!parsed.ok) {
9589
+ sendResult2(ws, false, parsed.message);
9590
+ return;
9591
+ }
9592
+ const payload = parsed.value.prefs;
9593
+ for (const [key, val] of Object.entries(payload)) {
9594
+ context.meta[key] = val;
9595
+ }
9596
+ void persistPrefsToConfig(payload);
9597
+ if (typeof payload["yolo"] === "boolean") {
9598
+ permissionPolicy.setYolo?.(payload["yolo"]);
9599
+ }
9600
+ if (typeof payload["featureMcp"] === "boolean")
9601
+ config.features.mcp = payload["featureMcp"];
9602
+ if (typeof payload["featurePlugins"] === "boolean")
9603
+ config.features.plugins = payload["featurePlugins"];
9604
+ if (typeof payload["featureMemory"] === "boolean")
9605
+ config.features.memory = payload["featureMemory"];
9606
+ if (typeof payload["featureSkills"] === "boolean")
9607
+ config.features.skills = payload["featureSkills"];
9608
+ if (typeof payload["featureModelsRegistry"] === "boolean")
9609
+ config.features.modelsRegistry = payload["featureModelsRegistry"];
9610
+ if (Array.isArray(payload["fallbackModels"]))
9611
+ config.fallbackModels = payload["fallbackModels"];
9612
+ if (typeof payload["fallbackAuto"] === "boolean")
9613
+ config.fallbackAuto = payload["fallbackAuto"];
9614
+ if (typeof payload["contextAutoCompact"] === "boolean") {
9615
+ if (payload["contextAutoCompact"] && autoCompactor) {
9616
+ pipelines.contextWindow.remove("AutoCompaction", { optional: true });
9617
+ pipelines.contextWindow.use({ name: "AutoCompaction", handler: autoCompactor.handler() });
9618
+ } else {
9619
+ pipelines.contextWindow.remove("AutoCompaction", { optional: true });
9620
+ }
9621
+ }
9622
+ if (typeof payload["logLevel"] === "string") {
9623
+ const valid = ["debug", "info", "warn", "error"];
9624
+ if (valid.includes(payload["logLevel"])) {
9625
+ logger.level = payload["logLevel"];
9626
+ }
9627
+ }
9628
+ broadcast(clients, { type: "prefs.updated", payload: prefSnapshot() });
9629
+ }
9630
+ };
9323
9631
  shellGitRoutes = {
9324
9632
  gitInfo: async (ws) => {
9325
9633
  await handleGitInfo(ws, projectRoot);
@@ -9372,6 +9680,18 @@ async function startWebUI(opts = {}) {
9372
9680
  return handleMailboxPurge(ws, { projectRoot, globalRoot: path16.dirname(globalConfigPath) }, parsed.value);
9373
9681
  }
9374
9682
  };
9683
+ mcpRoutes = {
9684
+ list: (ws, msg) => handleMcpList(ws, msg, globalConfigPath, mcpRegistry),
9685
+ add: (ws, msg) => handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry),
9686
+ update: (ws, msg) => handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry),
9687
+ remove: (ws, msg) => handleMcpRemove(ws, msg, globalConfigPath, mcpRegistry),
9688
+ enable: (ws, msg) => handleMcpEnable(ws, msg, globalConfigPath, mcpRegistry),
9689
+ disable: (ws, msg) => handleMcpDisable(ws, msg, globalConfigPath, mcpRegistry),
9690
+ sleep: (ws, msg) => handleMcpSleep(ws, msg, globalConfigPath, mcpRegistry),
9691
+ wake: (ws, msg) => handleMcpWake(ws, msg, globalConfigPath, mcpRegistry),
9692
+ restart: (ws, msg) => handleMcpRestart(ws, msg, globalConfigPath, mcpRegistry),
9693
+ discover: (ws, msg) => handleMcpDiscover(ws, msg, globalConfigPath, mcpRegistry)
9694
+ };
9375
9695
  brainRoutes = {
9376
9696
  status: (ws) => {
9377
9697
  send(ws, {
@@ -9439,8 +9759,10 @@ async function startWebUI(opts = {}) {
9439
9759
  host: wsHost,
9440
9760
  distDir: path16.resolve(import.meta.dirname, "../../dist"),
9441
9761
  wsPort,
9762
+ publicWsUrl,
9442
9763
  globalRoot: wpaths.globalRoot,
9443
9764
  apiToken: wsToken,
9765
+ requireToken,
9444
9766
  watcherMetrics,
9445
9767
  onFleetPing: () => {
9446
9768
  void fleetBroadcast?.();
@@ -9448,7 +9770,12 @@ async function startWebUI(opts = {}) {
9448
9770
  });
9449
9771
  const registryBaseDir = path16.dirname(globalConfigPath);
9450
9772
  httpServer.listen(httpPort, wsHost, () => {
9451
- const openUrl = `http://${wsHost}:${httpPort}`;
9773
+ const openUrl = buildWebUIAccessUrl({
9774
+ host: wsHost,
9775
+ port: httpPort,
9776
+ token: wsToken,
9777
+ publicUrl
9778
+ });
9452
9779
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
9453
9780
  if (opts.open) openBrowser(openUrl);
9454
9781
  void registerInstance(
@@ -9460,7 +9787,7 @@ async function startWebUI(opts = {}) {
9460
9787
  projectRoot,
9461
9788
  projectName: path16.basename(projectRoot) || projectRoot,
9462
9789
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9463
- url: `http://${wsHost}:${httpPort}`
9790
+ url: buildWebUIAccessUrl({ host: wsHost, port: httpPort, publicUrl })
9464
9791
  },
9465
9792
  registryBaseDir
9466
9793
  ).catch((err) => console.warn(JSON.stringify({
@@ -9502,7 +9829,55 @@ async function startWebUI(opts = {}) {
9502
9829
 
9503
9830
  // src/server/entry.ts
9504
9831
  var argv = process.argv.slice(2);
9505
- if (argv.includes("--list") || argv.includes("-l") || argv[0] === "ls") {
9832
+ function readArg(names) {
9833
+ for (let i = 0; i < argv.length; i++) {
9834
+ const current = argv[i];
9835
+ if (!current) continue;
9836
+ for (const name2 of names) {
9837
+ if (current === name2) {
9838
+ const next = argv[i + 1];
9839
+ if (!next || next.startsWith("-")) {
9840
+ throw new Error(`${name2} requires a value`);
9841
+ }
9842
+ return next;
9843
+ }
9844
+ if (current.startsWith(`${name2}=`)) return current.slice(name2.length + 1);
9845
+ }
9846
+ }
9847
+ return void 0;
9848
+ }
9849
+ function parsePort(value, fallback, label) {
9850
+ if (value === void 0) return fallback;
9851
+ const parsed = Number.parseInt(value, 10);
9852
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
9853
+ throw new Error(`${label} must be a port between 1 and 65535`);
9854
+ }
9855
+ return parsed;
9856
+ }
9857
+ function envFlag2(name2) {
9858
+ const value = process.env[name2]?.trim().toLowerCase();
9859
+ return value === "1" || value === "true" || value === "yes" || value === "on";
9860
+ }
9861
+ function printHelp() {
9862
+ console.log(`Usage: wstackui [options]
9863
+
9864
+ Options:
9865
+ --host <host> Bind host/interface (default: 127.0.0.1)
9866
+ --port <port> HTTP frontend port (default: 3456)
9867
+ --ws-port <port> WebSocket backend port (default: 3457)
9868
+ --token <token> Fixed access token/password (default: random per process)
9869
+ --public-url <url> Browser-facing HTTP URL for tunnels/proxies
9870
+ --public-ws-url <url> Browser-facing ws:// or wss:// URL for tunnels/proxies
9871
+ --require-token Require token/password even on loopback binds
9872
+ --open, -o Open the browser after startup
9873
+ --list, -l, ls List running WebUI instances
9874
+ --help, -h Show this help
9875
+ `);
9876
+ }
9877
+ if (argv.includes("--help") || argv.includes("-h")) {
9878
+ printHelp();
9879
+ process.exit(0);
9880
+ } else if (argv.includes("--list") || argv.includes("-l") || argv[0] === "ls") {
9506
9881
  listInstances().then((instances) => {
9507
9882
  console.log(formatInstances(instances));
9508
9883
  process.exit(0);
@@ -9516,11 +9891,40 @@ if (argv.includes("--list") || argv.includes("-l") || argv[0] === "ls") {
9516
9891
  process.exit(1);
9517
9892
  });
9518
9893
  } else {
9519
- const wsPort = Number.parseInt(process.env["WS_PORT"] ?? "3457", 10);
9520
- const wsHost = process.env["WS_HOST"] ?? "127.0.0.1";
9894
+ let wsPort;
9895
+ let httpPort;
9896
+ let wsHost;
9897
+ let accessToken;
9898
+ let publicUrl;
9899
+ let publicWsUrl;
9900
+ try {
9901
+ wsHost = readArg(["--host"]) ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
9902
+ httpPort = parsePort(
9903
+ readArg(["--port", "--http-port"]) ?? process.env["WEBUI_PORT"] ?? process.env["PORT"],
9904
+ 3456,
9905
+ "--port"
9906
+ );
9907
+ wsPort = parsePort(readArg(["--ws-port"]) ?? process.env["WS_PORT"], 3457, "--ws-port");
9908
+ accessToken = readArg(["--token", "--auth-token"]) ?? process.env["WEBUI_TOKEN"] ?? process.env["WEBUI_AUTH_TOKEN"];
9909
+ publicUrl = readArg(["--public-url"]) ?? process.env["WEBUI_PUBLIC_URL"];
9910
+ publicWsUrl = readArg(["--public-ws-url"]) ?? process.env["WEBUI_PUBLIC_WS_URL"];
9911
+ } catch (err) {
9912
+ console.error(err instanceof Error ? err.message : String(err));
9913
+ process.exit(1);
9914
+ }
9521
9915
  const open = argv.includes("--open") || argv.includes("-o") || process.env["WEBUI_OPEN"] === "1";
9522
- console.log(`[WebUI] Starting standalone server on ${wsHost}:${wsPort}...`);
9523
- startWebUI({ wsPort, wsHost, open }).catch((err) => {
9916
+ const requireToken = argv.includes("--require-token") || envFlag2("WEBUI_REQUIRE_TOKEN");
9917
+ console.log(`[WebUI] Starting standalone server on ${wsHost} (http:${httpPort}, ws:${wsPort})...`);
9918
+ startWebUI({
9919
+ wsPort,
9920
+ wsHost,
9921
+ httpPort,
9922
+ accessToken,
9923
+ publicUrl,
9924
+ publicWsUrl,
9925
+ requireToken,
9926
+ open
9927
+ }).catch((err) => {
9524
9928
  console.error(JSON.stringify({
9525
9929
  level: "fatal",
9526
9930
  event: "webui.startup_failed",