@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.
@@ -896,7 +896,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
896
896
  return;
897
897
  }
898
898
  try {
899
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, DefaultSessionStore: DefaultSessionStore4, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
899
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
900
900
  const registry = new SessionRegistry(globalRoot);
901
901
  const entry = await registry.get(sessionId);
902
902
  if (!entry) {
@@ -905,7 +905,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
905
905
  return;
906
906
  }
907
907
  const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
908
- const store = new DefaultSessionStore4({ dir: paths.projectSessions });
908
+ const store = new DefaultSessionStore3({ dir: paths.projectSessions });
909
909
  const reader = new DefaultSessionReader2({ store });
910
910
  const rawEntries = [];
911
911
  for await (const ev of reader.replay(sessionId)) {
@@ -1166,7 +1166,7 @@ function isTrustedLoopbackOrigin(origin) {
1166
1166
  try {
1167
1167
  const url = new URL(origin);
1168
1168
  if (url.protocol !== "http:" && url.protocol !== "https:") return false;
1169
- return url.hostname === "localhost" || url.hostname === "127.0.0.1";
1169
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
1170
1170
  } catch {
1171
1171
  return false;
1172
1172
  }
@@ -1177,6 +1177,14 @@ function isLoopbackBind(wsHost) {
1177
1177
  function isWildcardBind(wsHost) {
1178
1178
  return wsHost === "0.0.0.0" || wsHost === "::" || wsHost === "[::]";
1179
1179
  }
1180
+ function normalizeHostname(hostname) {
1181
+ const h = hostname.trim().toLowerCase();
1182
+ return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
1183
+ }
1184
+ function allowedHostname(hostname, allowedHostnames) {
1185
+ const normalized = normalizeHostname(hostname);
1186
+ return (allowedHostnames ?? []).some((candidate) => normalizeHostname(candidate) === normalized);
1187
+ }
1180
1188
  function tokenMatches(provided, expected) {
1181
1189
  if (!provided) return false;
1182
1190
  const a = Buffer.from(provided);
@@ -1215,28 +1223,37 @@ function hostHeaderOk(input) {
1215
1223
  } catch {
1216
1224
  return false;
1217
1225
  }
1218
- return isLoopbackHostname(hostname);
1226
+ return isLoopbackHostname(hostname) || allowedHostname(hostname, input.allowedHostnames);
1219
1227
  }
1220
1228
  function verifyClient(input) {
1221
- const { origin, url, hostHeader, remoteAddress, cookieHeader, wsHost, expectedToken } = input;
1229
+ const {
1230
+ origin,
1231
+ url,
1232
+ hostHeader,
1233
+ remoteAddress,
1234
+ cookieHeader,
1235
+ wsHost,
1236
+ expectedToken,
1237
+ requireToken,
1238
+ allowedHostnames,
1239
+ allowBrowserUrlToken
1240
+ } = input;
1222
1241
  const urlTokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
1223
1242
  const cookieTokenOk = tokenMatches(extractTokenFromCookie(cookieHeader), expectedToken);
1224
- if (!hostHeaderOk({ hostHeader, wsHost })) return false;
1243
+ if (!hostHeaderOk({ hostHeader, wsHost, allowedHostnames })) return false;
1225
1244
  if (!origin) {
1226
1245
  const remoteIp = remoteAddress ?? "";
1227
1246
  const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
1228
1247
  if (!isRemoteLoopback && isWildcardBind(wsHost)) return false;
1229
- return urlTokenOk || cookieTokenOk || isLoopbackBind(wsHost);
1248
+ return urlTokenOk || cookieTokenOk || isLoopbackBind(wsHost) && !requireToken;
1230
1249
  }
1231
1250
  try {
1232
- const { hostname } = new URL(origin);
1233
- if (isLoopbackHostname(hostname)) {
1234
- if (isWildcardBind(wsHost) && !isTrustedLoopbackOrigin(origin)) {
1235
- return false;
1236
- }
1237
- return true;
1251
+ const { hostname: originHostname } = new URL(origin);
1252
+ if (isLoopbackHostname(originHostname)) {
1253
+ if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
1254
+ return isTrustedLoopbackOrigin(origin);
1238
1255
  }
1239
- return cookieTokenOk;
1256
+ return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
1240
1257
  } catch {
1241
1258
  return false;
1242
1259
  }
@@ -1262,8 +1279,69 @@ function injectWsPort(html, wsPort) {
1262
1279
  return `${tag}
1263
1280
  ${html}`;
1264
1281
  }
1265
- function buildCspHeader(wsPort) {
1266
- 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'`;
1282
+ function escapeHtmlAttr(value) {
1283
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1284
+ }
1285
+ function injectWsConfig(html, opts) {
1286
+ let out = injectWsPort(html, opts.wsPort);
1287
+ if (!opts.publicWsUrl || out.includes('name="wrongstack-ws-url"')) return out;
1288
+ const tag = `<meta name="wrongstack-ws-url" content="${escapeHtmlAttr(opts.publicWsUrl)}" />`;
1289
+ if (out.includes("</head>")) {
1290
+ return out.replace("</head>", ` ${tag}
1291
+ </head>`);
1292
+ }
1293
+ return `${tag}
1294
+ ${out}`;
1295
+ }
1296
+ function firstHeader(value) {
1297
+ return Array.isArray(value) ? value[0] : value;
1298
+ }
1299
+ function wsTokenCookie(token) {
1300
+ return `ws_token=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
1301
+ }
1302
+ function requestToken(req, url) {
1303
+ return url.searchParams.get("token") ?? firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
1304
+ }
1305
+ function requestHostForCsp(hostHeader) {
1306
+ const raw = firstHeader(hostHeader)?.trim();
1307
+ if (!raw) return void 0;
1308
+ try {
1309
+ return new URL(`http://${raw}`).hostname;
1310
+ } catch {
1311
+ return void 0;
1312
+ }
1313
+ }
1314
+ function formatCspHostname(hostname) {
1315
+ return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
1316
+ }
1317
+ function cspSourceFromUrl(rawUrl) {
1318
+ try {
1319
+ const url = new URL(rawUrl);
1320
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") return void 0;
1321
+ return `${url.protocol}//${formatCspHostname(url.hostname)}${url.port ? `:${url.port}` : ""}`;
1322
+ } catch {
1323
+ return void 0;
1324
+ }
1325
+ }
1326
+ var ALLOWED_INLINE_SCRIPT_HASHES = [
1327
+ "'sha256-6PXDy0zrpXa6mvYOl11bZ8nubNUL7ushPUhGDZtaexg='",
1328
+ "'sha256-6sIdwbEBx7jj0drqSHHm7MqvmoYD3CQ4lp8Zp8blcb0='"
1329
+ ];
1330
+ function buildCspHeader(wsPort, requestHost, publicWsUrl) {
1331
+ const connect = /* @__PURE__ */ new Set([
1332
+ "'self'",
1333
+ `ws://127.0.0.1:${wsPort}`,
1334
+ `wss://127.0.0.1:${wsPort}`
1335
+ ]);
1336
+ if (requestHost && requestHost !== "127.0.0.1") {
1337
+ const host = formatCspHostname(requestHost);
1338
+ connect.add(`ws://${host}:${wsPort}`);
1339
+ connect.add(`wss://${host}:${wsPort}`);
1340
+ }
1341
+ const publicWsSource = publicWsUrl ? cspSourceFromUrl(publicWsUrl) : void 0;
1342
+ if (publicWsSource) connect.add(publicWsSource);
1343
+ const scriptSrc = ["'self'", ...ALLOWED_INLINE_SCRIPT_HASHES].join(" ");
1344
+ 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'`;
1267
1345
  }
1268
1346
  function isInsideDist(candidate, distDir) {
1269
1347
  const root = path.resolve(distDir);
@@ -1281,12 +1359,15 @@ function createHttpServer(opts) {
1281
1359
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
1282
1360
  const distDir = path.resolve(opts.distDir);
1283
1361
  const wsPort = opts.wsPort;
1284
- const requireApiToken = !isLoopbackBind(opts.host) && Boolean(opts.apiToken);
1362
+ const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
1285
1363
  return http.createServer(async (req, res) => {
1286
1364
  try {
1287
1365
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
1366
+ const providedAccessToken = requestToken(req, url);
1367
+ const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
1368
+ const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
1288
1369
  if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
1289
- const provided = url.searchParams.get("token") ?? req.headers["x-ws-token"];
1370
+ const provided = requestToken(req, url);
1290
1371
  if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
1291
1372
  res.writeHead(401, { "Content-Type": "text/plain" });
1292
1373
  res.end("Unauthorized");
@@ -1294,7 +1375,7 @@ function createHttpServer(opts) {
1294
1375
  }
1295
1376
  res.writeHead(200, {
1296
1377
  "Content-Type": "text/plain",
1297
- "Set-Cookie": `ws_token=${encodeURIComponent(opts.apiToken)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`,
1378
+ "Set-Cookie": wsTokenCookie(opts.apiToken),
1298
1379
  // Belt-and-braces: tell any caches the cookie response itself
1299
1380
  // is sensitive.
1300
1381
  "Cache-Control": "no-store"
@@ -1302,10 +1383,20 @@ function createHttpServer(opts) {
1302
1383
  res.end("ok");
1303
1384
  return;
1304
1385
  }
1386
+ if (requireAccessToken && !accessTokenOk) {
1387
+ res.writeHead(401, {
1388
+ "Content-Type": "text/plain",
1389
+ "Cache-Control": "no-store"
1390
+ });
1391
+ res.end("Unauthorized");
1392
+ return;
1393
+ }
1394
+ if (shouldSetAuthCookie && opts.apiToken) {
1395
+ res.setHeader("Set-Cookie", wsTokenCookie(opts.apiToken));
1396
+ res.setHeader("Cache-Control", "no-store");
1397
+ }
1305
1398
  if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
1306
- const headerToken = req.headers["x-ws-token"];
1307
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1308
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1399
+ if (requireAccessToken && !accessTokenOk) {
1309
1400
  res.writeHead(401, { "Content-Type": "application/json" });
1310
1401
  res.end(JSON.stringify({ error: "Unauthorized" }));
1311
1402
  return;
@@ -1319,9 +1410,7 @@ function createHttpServer(opts) {
1319
1410
  return;
1320
1411
  }
1321
1412
  if (url.pathname === "/api/sessions" && req.method === "GET") {
1322
- const headerToken = req.headers["x-ws-token"];
1323
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1324
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1413
+ if (requireAccessToken && !accessTokenOk) {
1325
1414
  res.writeHead(401, { "Content-Type": "application/json" });
1326
1415
  res.end(JSON.stringify({ error: "Unauthorized" }));
1327
1416
  return;
@@ -1331,9 +1420,7 @@ function createHttpServer(opts) {
1331
1420
  }
1332
1421
  const agentsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/agents$/);
1333
1422
  if (agentsMatch && req.method === "GET") {
1334
- const headerToken = req.headers["x-ws-token"];
1335
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1336
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1423
+ if (requireAccessToken && !accessTokenOk) {
1337
1424
  res.writeHead(401, { "Content-Type": "application/json" });
1338
1425
  res.end(JSON.stringify({ error: "Unauthorized" }));
1339
1426
  return;
@@ -1343,9 +1430,7 @@ function createHttpServer(opts) {
1343
1430
  }
1344
1431
  const eventsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
1345
1432
  if (eventsMatch && req.method === "GET") {
1346
- const headerToken = req.headers["x-ws-token"];
1347
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1348
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1433
+ if (requireAccessToken && !accessTokenOk) {
1349
1434
  res.writeHead(401, { "Content-Type": "application/json" });
1350
1435
  res.end(JSON.stringify({ error: "Unauthorized" }));
1351
1436
  return;
@@ -1357,9 +1442,7 @@ function createHttpServer(opts) {
1357
1442
  }
1358
1443
  const msgMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/message$/);
1359
1444
  if (msgMatch && req.method === "POST") {
1360
- const headerToken = req.headers["x-ws-token"];
1361
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1362
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1445
+ if (requireAccessToken && !accessTokenOk) {
1363
1446
  res.writeHead(401, { "Content-Type": "application/json" });
1364
1447
  res.end(JSON.stringify({ error: "Unauthorized" }));
1365
1448
  return;
@@ -1369,9 +1452,7 @@ function createHttpServer(opts) {
1369
1452
  }
1370
1453
  const mailboxMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/mailbox$/);
1371
1454
  if (mailboxMatch && req.method === "GET") {
1372
- const headerToken = req.headers["x-ws-token"];
1373
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1374
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1455
+ if (requireAccessToken && !accessTokenOk) {
1375
1456
  res.writeHead(401, { "Content-Type": "application/json" });
1376
1457
  res.end(JSON.stringify({ error: "Unauthorized" }));
1377
1458
  return;
@@ -1381,9 +1462,7 @@ function createHttpServer(opts) {
1381
1462
  }
1382
1463
  const interruptMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/interrupt$/);
1383
1464
  if (interruptMatch && req.method === "POST") {
1384
- const headerToken = req.headers["x-ws-token"];
1385
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1386
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1465
+ if (requireAccessToken && !accessTokenOk) {
1387
1466
  res.writeHead(401, { "Content-Type": "application/json" });
1388
1467
  res.end(JSON.stringify({ error: "Unauthorized" }));
1389
1468
  return;
@@ -1397,9 +1476,7 @@ function createHttpServer(opts) {
1397
1476
  return;
1398
1477
  }
1399
1478
  if (url.pathname === "/api/fleet/broadcast" && req.method === "POST") {
1400
- const headerToken = req.headers["x-ws-token"];
1401
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1402
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1479
+ if (requireAccessToken && !accessTokenOk) {
1403
1480
  res.writeHead(401, { "Content-Type": "application/json" });
1404
1481
  res.end(JSON.stringify({ error: "Unauthorized" }));
1405
1482
  return;
@@ -1446,11 +1523,14 @@ function createHttpServer(opts) {
1446
1523
  res.setHeader("X-Frame-Options", "DENY");
1447
1524
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
1448
1525
  if (ext === ".html") {
1449
- res.setHeader("Cache-Control", "no-cache");
1450
- res.setHeader("Content-Security-Policy", buildCspHeader(wsPort));
1526
+ if (!shouldSetAuthCookie) res.setHeader("Cache-Control", "no-cache");
1527
+ res.setHeader(
1528
+ "Content-Security-Policy",
1529
+ buildCspHeader(wsPort, requestHostForCsp(req.headers.host), opts.publicWsUrl)
1530
+ );
1451
1531
  const html = await fs.readFile(resolvedPath, "utf8");
1452
1532
  res.writeHead(200);
1453
- res.end(injectWsPort(html, wsPort));
1533
+ res.end(injectWsConfig(html, { wsPort, publicWsUrl: opts.publicWsUrl }));
1454
1534
  return;
1455
1535
  }
1456
1536
  const fileContent = await fs.readFile(resolvedPath);
@@ -1465,9 +1545,13 @@ function createHttpServer(opts) {
1465
1545
  "X-Content-Type-Options": "nosniff",
1466
1546
  "X-Frame-Options": "DENY",
1467
1547
  "Referrer-Policy": "strict-origin-when-cross-origin",
1468
- "Content-Security-Policy": buildCspHeader(wsPort)
1548
+ "Content-Security-Policy": buildCspHeader(
1549
+ wsPort,
1550
+ requestHostForCsp(req.headers.host),
1551
+ opts.publicWsUrl
1552
+ )
1469
1553
  });
1470
- res.end(injectWsPort(html, wsPort));
1554
+ res.end(injectWsConfig(html, { wsPort, publicWsUrl: opts.publicWsUrl }));
1471
1555
  } catch {
1472
1556
  res.writeHead(404);
1473
1557
  res.end("Not found");
@@ -1591,8 +1675,8 @@ function isInside(root, target) {
1591
1675
  }
1592
1676
 
1593
1677
  // src/server/file-handlers.ts
1594
- import * as fs3 from "fs/promises";
1595
- import * as path3 from "path";
1678
+ import * as fs4 from "fs/promises";
1679
+ import * as path4 from "path";
1596
1680
  import { atomicWrite } from "@wrongstack/core";
1597
1681
 
1598
1682
  // src/server/file-picker.ts
@@ -1643,6 +1727,34 @@ function rankFiles(paths, query, limit) {
1643
1727
  return scored.slice(0, limit).map((s) => s.path);
1644
1728
  }
1645
1729
 
1730
+ // src/server/path-containment.ts
1731
+ import * as fs3 from "fs/promises";
1732
+ import * as path3 from "path";
1733
+ function isPathInside(root, target) {
1734
+ const relative3 = path3.relative(root, target);
1735
+ return relative3 === "" || !relative3.startsWith("..") && !path3.isAbsolute(relative3);
1736
+ }
1737
+ async function resolveWorkingDirInsideProject(projectRoot, inputPath) {
1738
+ const resolved = path3.resolve(projectRoot, inputPath);
1739
+ let stat3;
1740
+ try {
1741
+ stat3 = await fs3.stat(resolved);
1742
+ } catch {
1743
+ throw new Error(`Directory not found or not accessible: ${resolved}`);
1744
+ }
1745
+ if (!stat3.isDirectory()) {
1746
+ throw new Error(`Directory not found or not accessible: ${resolved}`);
1747
+ }
1748
+ const [realProjectRoot, realResolved] = await Promise.all([
1749
+ fs3.realpath(projectRoot),
1750
+ fs3.realpath(resolved)
1751
+ ]);
1752
+ if (!isPathInside(realProjectRoot, realResolved)) {
1753
+ throw new Error(`Path must stay inside the project root: ${projectRoot}`);
1754
+ }
1755
+ return resolved;
1756
+ }
1757
+
1646
1758
  // src/server/ws-utils.ts
1647
1759
  import { randomBytes } from "crypto";
1648
1760
  import { WebSocket } from "ws";
@@ -1671,25 +1783,106 @@ function errMessage(err) {
1671
1783
  function generateAuthToken() {
1672
1784
  return randomBytes(16).toString("hex");
1673
1785
  }
1786
+ function resolveAuthToken(explicit) {
1787
+ const configured = explicit?.trim() || process.env["WEBUI_TOKEN"]?.trim() || process.env["WEBUI_AUTH_TOKEN"]?.trim();
1788
+ return configured || generateAuthToken();
1789
+ }
1790
+ function hostForBrowserUrl(bindHost) {
1791
+ if (bindHost === "0.0.0.0") return "127.0.0.1";
1792
+ if (bindHost === "::" || bindHost === "[::]") return "[::1]";
1793
+ if (bindHost.includes(":") && !bindHost.startsWith("[")) return `[${bindHost}]`;
1794
+ return bindHost;
1795
+ }
1796
+ function buildWebUIAccessUrl(opts) {
1797
+ const protocol = opts.protocol ?? "http";
1798
+ const base = opts.publicUrl?.trim() || `${protocol}://${hostForBrowserUrl(opts.host)}:${opts.port}`;
1799
+ if (!opts.token) return base;
1800
+ try {
1801
+ const url = new URL(base);
1802
+ url.searchParams.set("token", opts.token);
1803
+ const rendered = url.toString();
1804
+ const afterOrigin = base.slice(url.origin.length);
1805
+ if (url.pathname === "/" && !afterOrigin.startsWith("/")) {
1806
+ return `${url.origin}${url.search}${url.hash}`;
1807
+ }
1808
+ return rendered;
1809
+ } catch {
1810
+ return `${base}${base.includes("?") ? "&" : "?"}token=${encodeURIComponent(opts.token)}`;
1811
+ }
1812
+ }
1813
+ function envFlag(name2) {
1814
+ const value = process.env[name2]?.trim().toLowerCase();
1815
+ return value === "1" || value === "true" || value === "yes" || value === "on";
1816
+ }
1674
1817
 
1675
1818
  // src/server/file-handlers.ts
1819
+ async function resolveFileInsideProject(projectRoot, filePath) {
1820
+ const resolved = path4.resolve(projectRoot, filePath);
1821
+ if (!isPathInside(projectRoot, resolved)) {
1822
+ throw new Error("Path outside project root");
1823
+ }
1824
+ const { parent, base } = splitParentAndBase(resolved);
1825
+ const realProjectRoot = await fs4.realpath(projectRoot);
1826
+ const realParent = await realpathAllowMissing(parent);
1827
+ const realFull = path4.join(realParent, base);
1828
+ if (!isPathInside(realProjectRoot, realFull)) {
1829
+ throw new Error("Path outside project root");
1830
+ }
1831
+ return realFull;
1832
+ }
1833
+ function splitParentAndBase(p) {
1834
+ const base = path4.basename(p);
1835
+ const parent = path4.dirname(p);
1836
+ return { parent, base };
1837
+ }
1838
+ async function realpathAllowMissing(p) {
1839
+ try {
1840
+ return await fs4.realpath(p);
1841
+ } catch (err) {
1842
+ if (err.code !== "ENOENT") throw err;
1843
+ }
1844
+ const segments = [];
1845
+ let cursor = p;
1846
+ while (true) {
1847
+ const parent = path4.dirname(cursor);
1848
+ if (parent === cursor) {
1849
+ throw new Error("Path outside project root");
1850
+ }
1851
+ segments.unshift(path4.basename(cursor));
1852
+ try {
1853
+ const realParent = await fs4.realpath(parent);
1854
+ return path4.join(realParent, ...segments);
1855
+ } catch (err) {
1856
+ if (err.code !== "ENOENT") throw err;
1857
+ cursor = parent;
1858
+ }
1859
+ }
1860
+ }
1676
1861
  async function handleFilesTree(ws, msg, projectRoot) {
1677
1862
  const payload = msg.payload;
1678
1863
  const rawPath = payload?.path?.trim();
1679
- const treeRoot = rawPath && rawPath !== "." ? path3.resolve(projectRoot, rawPath) : projectRoot;
1680
- if (!treeRoot.startsWith(projectRoot + path3.sep) && treeRoot !== projectRoot) {
1864
+ let treeRoot;
1865
+ let realProjectRoot;
1866
+ try {
1867
+ if (rawPath && rawPath !== ".") {
1868
+ treeRoot = await resolveWorkingDirInsideProject(projectRoot, rawPath);
1869
+ } else {
1870
+ treeRoot = projectRoot;
1871
+ }
1872
+ realProjectRoot = await fs4.realpath(projectRoot);
1873
+ } catch {
1681
1874
  send(ws, {
1682
1875
  type: "files.tree",
1683
1876
  payload: { root: projectRoot, tree: [], error: "Path outside project root" }
1684
1877
  });
1685
1878
  return;
1686
1879
  }
1687
- const pathPrefix = treeRoot === projectRoot ? "" : (path3.relative(projectRoot, treeRoot) + "/").replace(/\\/g, "/");
1880
+ const pathPrefix = treeRoot === projectRoot ? "" : (path4.relative(projectRoot, treeRoot) + "/").replace(/\\/g, "/");
1688
1881
  async function buildTree(dir, rel, depth) {
1689
1882
  if (depth > 10) return [];
1690
1883
  let entries = [];
1691
1884
  try {
1692
- entries = await fs3.readdir(dir, { withFileTypes: true });
1885
+ entries = await fs4.readdir(dir, { withFileTypes: true });
1693
1886
  } catch {
1694
1887
  return [];
1695
1888
  }
@@ -1701,11 +1894,20 @@ async function handleFilesTree(ws, msg, projectRoot) {
1701
1894
  for (const e of entries) {
1702
1895
  if (isHiddenEntry(e.name)) continue;
1703
1896
  const childRel = rel ? `${rel}/${e.name}` : e.name;
1704
- const childAbs = path3.join(dir, e.name);
1897
+ const childAbs = path4.join(dir, e.name);
1705
1898
  const childPath = pathPrefix + childRel;
1706
1899
  if (e.isDirectory()) {
1707
1900
  if (SKIP_DIRS.has(e.name)) continue;
1708
- const children = await buildTree(childAbs, childRel, depth + 1);
1901
+ let realChild;
1902
+ try {
1903
+ realChild = await fs4.realpath(childAbs);
1904
+ } catch {
1905
+ continue;
1906
+ }
1907
+ if (!isPathInside(realProjectRoot, realChild)) {
1908
+ continue;
1909
+ }
1910
+ const children = await buildTree(realChild, childRel, depth + 1);
1709
1911
  nodes.push({ name: e.name, path: childPath, type: "directory", children });
1710
1912
  } else if (e.isFile()) {
1711
1913
  nodes.push({ name: e.name, path: childPath, type: "file" });
@@ -1715,10 +1917,10 @@ async function handleFilesTree(ws, msg, projectRoot) {
1715
1917
  }
1716
1918
  try {
1717
1919
  const tree = await buildTree(treeRoot, "", 0);
1718
- const rootLabel = treeRoot === projectRoot ? projectRoot : path3.relative(projectRoot, treeRoot) || ".";
1920
+ const rootLabel = treeRoot === projectRoot ? projectRoot : path4.relative(projectRoot, treeRoot) || ".";
1719
1921
  send(ws, { type: "files.tree", payload: { root: rootLabel, tree } });
1720
1922
  } catch (err) {
1721
- const rootLabel = treeRoot === projectRoot ? projectRoot : path3.relative(projectRoot, treeRoot) || ".";
1923
+ const rootLabel = treeRoot === projectRoot ? projectRoot : path4.relative(projectRoot, treeRoot) || ".";
1722
1924
  send(ws, {
1723
1925
  type: "files.tree",
1724
1926
  payload: { root: rootLabel, tree: [], error: errMessage(err) }
@@ -1727,13 +1929,15 @@ async function handleFilesTree(ws, msg, projectRoot) {
1727
1929
  }
1728
1930
  async function handleFilesRead(ws, msg, projectRoot) {
1729
1931
  const { filePath } = msg.payload;
1730
- const resolved = path3.resolve(projectRoot, filePath);
1731
- if (!resolved.startsWith(projectRoot + path3.sep) && resolved !== projectRoot) {
1932
+ let realResolved;
1933
+ try {
1934
+ realResolved = await resolveFileInsideProject(projectRoot, filePath);
1935
+ } catch {
1732
1936
  send(ws, { type: "files.read", payload: { filePath, content: "", error: "Forbidden" } });
1733
1937
  return;
1734
1938
  }
1735
1939
  try {
1736
- const content = await fs3.readFile(resolved, "utf8");
1940
+ const content = await fs4.readFile(realResolved, "utf8");
1737
1941
  send(ws, { type: "files.read", payload: { filePath, content } });
1738
1942
  } catch (err) {
1739
1943
  send(ws, {
@@ -1744,16 +1948,18 @@ async function handleFilesRead(ws, msg, projectRoot) {
1744
1948
  }
1745
1949
  async function handleFilesWrite(ws, msg, projectRoot, opts = {}) {
1746
1950
  const { filePath, content } = msg.payload;
1747
- const resolved = path3.resolve(projectRoot, filePath);
1748
- if (!resolved.startsWith(projectRoot + path3.sep) && resolved !== projectRoot) {
1951
+ let realResolved;
1952
+ try {
1953
+ realResolved = await resolveFileInsideProject(projectRoot, filePath);
1954
+ } catch {
1749
1955
  send(ws, { type: "files.written", payload: { filePath, success: false, error: "Forbidden" } });
1750
1956
  return;
1751
1957
  }
1752
1958
  try {
1753
- await atomicWrite(resolved, content);
1959
+ await atomicWrite(realResolved, content);
1754
1960
  send(ws, { type: "files.written", payload: { filePath, success: true } });
1755
1961
  if (opts.onWritten) {
1756
- void Promise.resolve(opts.onWritten(resolved)).catch(() => void 0);
1962
+ void Promise.resolve(opts.onWritten(realResolved)).catch(() => void 0);
1757
1963
  }
1758
1964
  } catch (err) {
1759
1965
  send(ws, {
@@ -1765,8 +1971,16 @@ async function handleFilesWrite(ws, msg, projectRoot, opts = {}) {
1765
1971
  async function handleFilesList(ws, msg, projectRoot) {
1766
1972
  const payload = msg.payload ?? {};
1767
1973
  const limit = payload.limit ?? 50;
1768
- const listRoot = payload.path ? path3.resolve(projectRoot, payload.path) : projectRoot;
1769
- if (!listRoot.startsWith(projectRoot + path3.sep) && listRoot !== projectRoot) {
1974
+ let listRoot;
1975
+ let realProjectRoot;
1976
+ try {
1977
+ if (payload.path) {
1978
+ listRoot = await resolveWorkingDirInsideProject(projectRoot, payload.path);
1979
+ } else {
1980
+ listRoot = projectRoot;
1981
+ }
1982
+ realProjectRoot = await fs4.realpath(projectRoot);
1983
+ } catch {
1770
1984
  send(ws, { type: "files.list", payload: { files: [] } });
1771
1985
  return;
1772
1986
  }
@@ -1775,7 +1989,7 @@ async function handleFilesList(ws, msg, projectRoot) {
1775
1989
  if (depth > 8 || results.length >= 600) return;
1776
1990
  let entries = [];
1777
1991
  try {
1778
- entries = await fs3.readdir(dir, { withFileTypes: true });
1992
+ entries = await fs4.readdir(dir, { withFileTypes: true });
1779
1993
  } catch {
1780
1994
  return;
1781
1995
  }
@@ -1785,7 +1999,16 @@ async function handleFilesList(ws, msg, projectRoot) {
1785
1999
  const childRel = rel ? `${rel}/${e.name}` : e.name;
1786
2000
  if (e.isDirectory()) {
1787
2001
  if (SKIP_DIRS.has(e.name)) continue;
1788
- await walk(path3.join(dir, e.name), childRel, depth + 1);
2002
+ let realChild;
2003
+ try {
2004
+ realChild = await fs4.realpath(path4.join(dir, e.name));
2005
+ } catch {
2006
+ continue;
2007
+ }
2008
+ if (!isPathInside(realProjectRoot, realChild)) {
2009
+ continue;
2010
+ }
2011
+ await walk(realChild, childRel, depth + 1);
1789
2012
  } else if (e.isFile()) {
1790
2013
  results.push(childRel);
1791
2014
  }
@@ -1799,7 +2022,7 @@ async function handleFilesList(ws, msg, projectRoot) {
1799
2022
  }
1800
2023
 
1801
2024
  // src/server/completion-handlers.ts
1802
- import * as path4 from "path";
2025
+ import * as path5 from "path";
1803
2026
  import { searchCodebaseIndex } from "@wrongstack/tools/codebase-index/index";
1804
2027
  var MAX_PREFIX_CHARS = 12e3;
1805
2028
  var MAX_SUFFIX_CHARS = 4e3;
@@ -1874,8 +2097,8 @@ async function handleCompletionRequest(ws, msg, opts) {
1874
2097
  return;
1875
2098
  }
1876
2099
  const payload = parsed.payload;
1877
- const projectRoot = path4.resolve(opts.projectRoot);
1878
- const resolved = path4.resolve(projectRoot, payload.filePath);
2100
+ const projectRoot = path5.resolve(opts.projectRoot);
2101
+ const resolved = path5.resolve(projectRoot, payload.filePath);
1879
2102
  if (!isInside2(projectRoot, resolved)) {
1880
2103
  send(ws, {
1881
2104
  type: "completion.result",
@@ -2274,7 +2497,7 @@ function buildSearchQuery(linePrefix, filePath) {
2274
2497
  if (memberMatch?.[1]) return memberMatch[1];
2275
2498
  const token = linePrefix.match(/([A-Za-z_$][\w$]*)$/)?.[1];
2276
2499
  if (token && token.length >= 2) return token;
2277
- return path4.basename(filePath, path4.extname(filePath));
2500
+ return path5.basename(filePath, path5.extname(filePath));
2278
2501
  }
2279
2502
  function currentLinePrefix(prefix) {
2280
2503
  const idx = Math.max(prefix.lastIndexOf("\n"), prefix.lastIndexOf("\r"));
@@ -2304,7 +2527,7 @@ function head(value, max) {
2304
2527
  return value.length <= max ? value : value.slice(0, max);
2305
2528
  }
2306
2529
  function isInside2(root, target) {
2307
- return target === root || target.startsWith(root + path4.sep);
2530
+ return target === root || target.startsWith(root + path5.sep);
2308
2531
  }
2309
2532
 
2310
2533
  // src/server/memory-handlers.ts
@@ -2558,8 +2781,8 @@ async function handleMcpDiscover(ws, msg, globalConfigPath, mcpRegistry) {
2558
2781
  }
2559
2782
 
2560
2783
  // src/server/skills-handlers.ts
2561
- import { promises as fs4 } from "fs";
2562
- import path5 from "path";
2784
+ import { promises as fs5 } from "fs";
2785
+ import path6 from "path";
2563
2786
  import JSZip from "jszip";
2564
2787
  import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
2565
2788
  import { wstackGlobalRoot } from "@wrongstack/core/utils";
@@ -2630,19 +2853,19 @@ async function handleSkillsContent(ws, ctx, msg) {
2630
2853
  send(ws, { type: "skills.content", payload: { name: name2, body: "", path: "", source, relatedFiles: [], references: [], error: `Skill "${name2}" not found` } });
2631
2854
  return;
2632
2855
  }
2633
- const body = await fs4.readFile(entry.path, "utf8");
2634
- const skillDir = path5.dirname(entry.path);
2856
+ const body = await fs5.readFile(entry.path, "utf8");
2857
+ const skillDir = path6.dirname(entry.path);
2635
2858
  let relatedFiles = [];
2636
2859
  try {
2637
- const files = await fs4.readdir(skillDir);
2638
- relatedFiles = files.filter((f) => f !== path5.basename(entry.path)).map((f) => path5.join(skillDir, f));
2860
+ const files = await fs5.readdir(skillDir);
2861
+ relatedFiles = files.filter((f) => f !== path6.basename(entry.path)).map((f) => path6.join(skillDir, f));
2639
2862
  } catch {
2640
2863
  }
2641
2864
  const nameLower = name2.toLowerCase();
2642
2865
  const refResults = await Promise.all(
2643
2866
  entries.filter((e) => e.name.toLowerCase() !== nameLower).map(async (e) => {
2644
2867
  try {
2645
- const content = await fs4.readFile(e.path, "utf8");
2868
+ const content = await fs5.readFile(e.path, "utf8");
2646
2869
  return [e.name, content.toLowerCase().includes(nameLower)];
2647
2870
  } catch {
2648
2871
  return [e.name, false];
@@ -2732,14 +2955,14 @@ async function handleSkillsCreate(ws, ctx, msg) {
2732
2955
  }
2733
2956
  const createPayload = parsed.value;
2734
2957
  try {
2735
- const targetDir = createPayload.scope === "global" ? path5.join(wstackGlobalRoot(), "skills", createPayload.name.trim()) : path5.join(ctx.projectRoot, ".wrongstack", "skills", createPayload.name.trim());
2958
+ const targetDir = createPayload.scope === "global" ? path6.join(wstackGlobalRoot(), "skills", createPayload.name.trim()) : path6.join(ctx.projectRoot, ".wrongstack", "skills", createPayload.name.trim());
2736
2959
  try {
2737
- await fs4.access(targetDir);
2960
+ await fs5.access(targetDir);
2738
2961
  send(ws, { type: "skills.created", payload: { success: false, error: `Skill "${createPayload.name}" already exists` } });
2739
2962
  return;
2740
2963
  } catch {
2741
2964
  }
2742
- await fs4.mkdir(targetDir, { recursive: true });
2965
+ await fs5.mkdir(targetDir, { recursive: true });
2743
2966
  const lines = createPayload.description.trim().split("\n");
2744
2967
  const firstLine = lines[0].trim();
2745
2968
  const bodyLines = lines.slice(1).map((l) => l.trim()).filter(Boolean);
@@ -2787,13 +3010,13 @@ ${trigger}
2787
3010
  "- `bug-hunter` \u2014 for systematic bug detection patterns",
2788
3011
  "- `output-standards` \u2014 for standardized `<next_steps>` formatting"
2789
3012
  ].join("\n");
2790
- await atomicWrite2(path5.join(targetDir, "SKILL.md"), skillContent);
3013
+ await atomicWrite2(path6.join(targetDir, "SKILL.md"), skillContent);
2791
3014
  send(ws, {
2792
3015
  type: "skills.created",
2793
3016
  payload: {
2794
3017
  success: true,
2795
3018
  error: null,
2796
- skill: { name: createPayload.name.trim(), path: path5.join(targetDir, "SKILL.md"), scope: createPayload.scope }
3019
+ skill: { name: createPayload.name.trim(), path: path6.join(targetDir, "SKILL.md"), scope: createPayload.scope }
2797
3020
  }
2798
3021
  });
2799
3022
  } catch (err) {
@@ -2857,23 +3080,23 @@ import {
2857
3080
  Agent,
2858
3081
  AutoCompactionMiddleware,
2859
3082
  Context,
2860
- DefaultMemoryStore as DefaultMemoryStore2,
2861
- DefaultModeStore as DefaultModeStore2,
3083
+ DefaultMemoryStore,
3084
+ DefaultModeStore,
2862
3085
  DefaultModelsRegistry,
2863
3086
  DefaultSessionReader,
2864
- DefaultSessionStore as DefaultSessionStore3,
2865
- DefaultSkillLoader as DefaultSkillLoader2,
2866
- DefaultSystemPromptBuilder as DefaultSystemPromptBuilder4,
2867
- DefaultTokenCounter as DefaultTokenCounter2,
3087
+ DefaultSessionStore as DefaultSessionStore2,
3088
+ DefaultSkillLoader,
3089
+ DefaultSystemPromptBuilder as DefaultSystemPromptBuilder3,
3090
+ DefaultTokenCounter,
2868
3091
  AnnotationsStore,
2869
3092
  CollaborationBus,
2870
3093
  collabPauseMiddleware,
2871
3094
  collabInjectMiddleware,
2872
3095
  estimateRequestTokensCalibrated,
2873
3096
  EventBus,
2874
- createStrategyCompactor as createStrategyCompactor2,
3097
+ createStrategyCompactor,
2875
3098
  ProviderRegistry,
2876
- TOKENS as TOKENS2,
3099
+ TOKENS,
2877
3100
  ToolRegistry,
2878
3101
  atomicWrite as atomicWrite6,
2879
3102
  createDefaultPipelines,
@@ -2892,110 +3115,10 @@ import {
2892
3115
  import { ToolExecutor } from "@wrongstack/core/execution";
2893
3116
  import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets as encryptConfigSecrets2 } from "@wrongstack/core/security";
2894
3117
  import { buildProviderFactoriesFromRegistry, makeProviderFromConfig } from "@wrongstack/providers";
2895
- import { builtinToolsPack, forgetTool, rememberTool, searchMemoryTool, relatedMemoryTool } from "@wrongstack/tools";
3118
+ import { builtinToolsPack, configureExecPolicy, ensureSessionShell, forgetTool, rememberTool, searchMemoryTool, relatedMemoryTool } from "@wrongstack/tools";
2896
3119
  import { MCPRegistry } from "@wrongstack/mcp";
2897
3120
  import { WebSocket as WebSocket2, WebSocketServer } from "ws";
2898
-
2899
- // ../runtime/src/container.ts
2900
- import {
2901
- Container,
2902
- DefaultConfigStore,
2903
- DefaultErrorHandler,
2904
- DefaultMemoryStore,
2905
- DefaultModeStore,
2906
- DefaultPermissionPolicy,
2907
- DefaultRetryPolicy,
2908
- DefaultSecretScrubber,
2909
- DefaultSessionStore,
2910
- DefaultSkillLoader,
2911
- DefaultSystemPromptBuilder,
2912
- DefaultTokenCounter,
2913
- createStrategyCompactor,
2914
- buildRecoveryStrategies,
2915
- TOKENS
2916
- } from "@wrongstack/core";
2917
- function createDefaultContainer(opts) {
2918
- const { config, wpaths, logger, modelsRegistry } = opts;
2919
- const container = new Container();
2920
- const configStore = new DefaultConfigStore(config);
2921
- container.bind(TOKENS.ConfigStore, () => configStore);
2922
- container.bind(TOKENS.Logger, () => logger);
2923
- container.bind(TOKENS.SecretScrubber, () => new DefaultSecretScrubber());
2924
- container.bind(TOKENS.RetryPolicy, () => new DefaultRetryPolicy());
2925
- container.bind(
2926
- TOKENS.ErrorHandler,
2927
- () => new DefaultErrorHandler(
2928
- buildRecoveryStrategies({
2929
- compactor: container.resolve(TOKENS.Compactor),
2930
- modelsRegistry,
2931
- getConfig: () => configStore.get()
2932
- })
2933
- )
2934
- );
2935
- container.bind(TOKENS.ModelsRegistry, () => modelsRegistry);
2936
- container.bind(
2937
- TOKENS.TokenCounter,
2938
- () => new DefaultTokenCounter({ registry: modelsRegistry, providerId: config.provider })
2939
- );
2940
- const modeStore = new DefaultModeStore({ directory: wpaths.configDir });
2941
- container.bind(TOKENS.ModeStore, () => modeStore);
2942
- container.bind(
2943
- TOKENS.SessionStore,
2944
- () => new DefaultSessionStore({
2945
- dir: wpaths.projectSessions,
2946
- // Scrub secrets out of persisted user/model turns (F-06). Tool output
2947
- // is already scrubbed by the executor.
2948
- secretScrubber: container.resolve(TOKENS.SecretScrubber)
2949
- })
2950
- );
2951
- const memoryStore = new DefaultMemoryStore({ paths: wpaths, events: opts.events });
2952
- container.bind(TOKENS.MemoryStore, () => memoryStore);
2953
- const skillLoader = new DefaultSkillLoader({ paths: wpaths, bundledDir: opts.bundledSkillsDir });
2954
- container.bind(TOKENS.SkillLoader, () => skillLoader);
2955
- if (opts.systemPrompt) {
2956
- container.bind(
2957
- TOKENS.SystemPromptBuilder,
2958
- () => new DefaultSystemPromptBuilder(opts.systemPrompt)
2959
- );
2960
- }
2961
- container.bind(
2962
- TOKENS.PermissionPolicy,
2963
- () => {
2964
- const policyOptions = {
2965
- trustFile: wpaths.projectTrust,
2966
- yolo: opts.permission?.yolo ?? false,
2967
- yoloDestructive: opts.permission?.yoloDestructive ?? opts.permission?.forceAllYolo ?? false,
2968
- confirmDestructive: opts.permission?.confirmDestructive ?? false
2969
- };
2970
- if (opts.permission?.promptDelegate !== void 0) {
2971
- policyOptions.promptDelegate = opts.permission.promptDelegate;
2972
- }
2973
- return new DefaultPermissionPolicy(policyOptions);
2974
- }
2975
- );
2976
- container.bind(
2977
- TOKENS.Compactor,
2978
- () => (
2979
- // Strategy comes from config.context.strategy: 'hybrid' (default, lossless
2980
- // rules, no LLM), 'intelligent' (LLM summarization), or 'selective'
2981
- // (LLM-driven selection). The LLM strategies resolve their provider from
2982
- // ctx at compact()-time, so binding here (before context.provider exists)
2983
- // is safe. preserveK / eliseThreshold are class-level fallbacks; the active
2984
- // ContextWindowPolicy in ctx.meta normally overrides both at runtime.
2985
- // eliseThreshold is a TOKEN COUNT — a previous value of 0.7 elided
2986
- // essentially every tool_result (anything > 1 token).
2987
- createStrategyCompactor({
2988
- strategy: config.context?.strategy,
2989
- preserveK: opts.compactor?.preserveK ?? 10,
2990
- eliseThreshold: opts.compactor?.eliseThreshold ?? 2e3,
2991
- smart: true,
2992
- summarizerModel: config.context?.summarizerModel,
2993
- llmSelector: config.context?.llmSelector
2994
- })
2995
- )
2996
- );
2997
- return container;
2998
- }
3121
+ import { createDefaultContainer, makeLightSubagentFactory } from "@wrongstack/runtime";
2999
3122
 
3000
3123
  // src/server/boot.ts
3001
3124
  import {
@@ -3022,6 +3145,13 @@ import {
3022
3145
  PhaseStore,
3023
3146
  WorktreeManager
3024
3147
  } from "@wrongstack/core";
3148
+ function deriveTitle(goal) {
3149
+ const firstLine = goal.split("\n").map((l) => l.trim()).find(Boolean);
3150
+ if (!firstLine) return "AutoPhase";
3151
+ const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
3152
+ const trimmed = sentence.length <= 64 ? sentence : `${sentence.slice(0, 63).trimEnd()}\u2026`;
3153
+ return trimmed || "AutoPhase";
3154
+ }
3025
3155
  function isGitRepo(cwd) {
3026
3156
  try {
3027
3157
  const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, encoding: "utf8", windowsHide: true });
@@ -3030,6 +3160,19 @@ function isGitRepo(cwd) {
3030
3160
  return false;
3031
3161
  }
3032
3162
  }
3163
+ function commitsSince(cwd, baseSha, branch) {
3164
+ try {
3165
+ const r = spawnSync("git", ["log", "--reverse", "--format=%H", `${baseSha}..${branch}`], {
3166
+ cwd,
3167
+ encoding: "utf8",
3168
+ windowsHide: true
3169
+ });
3170
+ if (r.status !== 0) return [];
3171
+ return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
3172
+ } catch {
3173
+ return [];
3174
+ }
3175
+ }
3033
3176
  var AutoPhaseWebSocketHandler = class {
3034
3177
  constructor(agent, context, logger, storeDir, events, projectRoot) {
3035
3178
  this.agent = agent;
@@ -3049,10 +3192,17 @@ var AutoPhaseWebSocketHandler = class {
3049
3192
  store;
3050
3193
  clients = /* @__PURE__ */ new Set();
3051
3194
  broadcastInterval = null;
3052
- /** Aborts in-flight task agents when the run is stopped. */
3195
+ /** Aborts in-flight task agents AND the planning turn when the run is stopped. */
3053
3196
  abort = null;
3197
+ /** Set the instant a stop/clear/revert is requested, so a planning turn that
3198
+ * resolves afterwards never launches the orchestrator (the abort alone can't
3199
+ * cover the window between the LLM call resolving and the orchestrator start). */
3200
+ stopping = false;
3054
3201
  /** Optional per-phase git-worktree isolation (lazily created at start). */
3055
3202
  worktrees = null;
3203
+ /** Base branch + tip SHA captured at run start so a revert can git-revert the
3204
+ * run's squash commits (history-preserving) instead of a destructive reset. */
3205
+ runBase = null;
3056
3206
  /** Per-run worker identities so the board can show "who is on what". */
3057
3207
  usedNicknames = /* @__PURE__ */ new Set();
3058
3208
  addClient(ws) {
@@ -3076,11 +3226,13 @@ var AutoPhaseWebSocketHandler = class {
3076
3226
  this.broadcast({ type: "autophase.resumed", payload: {} });
3077
3227
  break;
3078
3228
  case "autophase.stop":
3079
- this.abort?.abort();
3080
- this.orchestrator?.stop();
3081
- this.stopBroadcast();
3082
- if (this.graph) void this.store.save(this.graph);
3083
- this.broadcast({ type: "autophase.stopped", payload: {} });
3229
+ await this.handleStop();
3230
+ break;
3231
+ case "autophase.clear":
3232
+ await this.handleClear();
3233
+ break;
3234
+ case "autophase.revert":
3235
+ await this.handleRevert();
3084
3236
  break;
3085
3237
  case "autophase.status":
3086
3238
  this.broadcastState();
@@ -3157,17 +3309,27 @@ var AutoPhaseWebSocketHandler = class {
3157
3309
  }
3158
3310
  }
3159
3311
  async handleStart(payload) {
3160
- const title = payload?.goal || payload?.title || "Untitled Project";
3312
+ const goal = payload?.goal || payload?.title || "Untitled Project";
3313
+ const title = deriveTitle(goal);
3161
3314
  const autonomous = payload?.autonomous ?? true;
3162
- const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(title);
3315
+ this.abort = new AbortController();
3316
+ this.stopping = false;
3317
+ const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(goal, this.abort.signal);
3318
+ if (this.stopping || this.abort.signal.aborted) {
3319
+ this.broadcast({ type: "autophase.stopped", payload: { title } });
3320
+ return;
3321
+ }
3163
3322
  this.logger.info(`[AutoPhase] Starting: ${title}`);
3164
- const graph = await new PhaseGraphBuilder({ title, phases, autonomous }).build();
3323
+ const graph = await new PhaseGraphBuilder({ title, description: goal, phases, autonomous }).build();
3165
3324
  this.graph = graph;
3166
- this.abort = new AbortController();
3167
3325
  await this.store.save(graph);
3168
- if (!this.worktrees && this.events && this.projectRoot && process.env["WRONGSTACK_AUTOPHASE_WORKTREES"] !== "0" && isGitRepo(this.projectRoot)) {
3326
+ const useWorktrees = payload?.worktrees ?? process.env["WRONGSTACK_AUTOPHASE_WORKTREES"] !== "0";
3327
+ if (!this.worktrees && this.events && this.projectRoot && useWorktrees && isGitRepo(this.projectRoot)) {
3169
3328
  this.worktrees = new WorktreeManager({ projectRoot: this.projectRoot, events: this.events });
3170
3329
  }
3330
+ if (this.worktrees) {
3331
+ this.runBase = await this.worktrees.currentBase();
3332
+ }
3171
3333
  this.orchestrator = new PhaseOrchestrator({
3172
3334
  graph,
3173
3335
  ctx: {
@@ -3214,6 +3376,62 @@ var AutoPhaseWebSocketHandler = class {
3214
3376
  this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
3215
3377
  });
3216
3378
  }
3379
+ /**
3380
+ * Halt the run NOW — at any phase. Sets `stopping` (so a planning turn that
3381
+ * resolves afterwards bails), aborts in-flight agents, stops the orchestrator
3382
+ * tick, and ends the live broadcast. The board is kept for review; use
3383
+ * `autophase.clear` to reset or `autophase.revert` to undo the changes.
3384
+ */
3385
+ async handleStop() {
3386
+ this.stopping = true;
3387
+ this.abort?.abort();
3388
+ this.orchestrator?.stop();
3389
+ this.stopBroadcast();
3390
+ if (this.graph) await this.store.save(this.graph).catch(() => void 0);
3391
+ this.broadcast({ type: "autophase.stopped", payload: { title: this.graph?.title } });
3392
+ }
3393
+ /**
3394
+ * Stop + wipe: tear down phase worktrees and reset to an empty board so the UI
3395
+ * returns to the start screen ("new one"). Does NOT touch already-merged commits
3396
+ * on the base branch — that is `autophase.revert`.
3397
+ */
3398
+ async handleClear() {
3399
+ await this.handleStop();
3400
+ if (this.worktrees) await this.worktrees.cleanupAllManaged().catch(() => void 0);
3401
+ this.orchestrator = null;
3402
+ this.graph = null;
3403
+ this.runBase = null;
3404
+ this.usedNicknames.clear();
3405
+ this.broadcast({ type: "autophase.cleared", payload: {} });
3406
+ this.broadcast({ type: "autophase.state", payload: this.buildState() });
3407
+ }
3408
+ /**
3409
+ * Stop + undo: remove phase worktrees, then history-preservingly `git revert`
3410
+ * every commit this run landed on the base branch (captured `runBase`..HEAD),
3411
+ * then reset to an empty board. Refuses (reports a reason) on a dirty tree or a
3412
+ * conflicting revert rather than leaving the tree half-reverted.
3413
+ */
3414
+ async handleRevert() {
3415
+ await this.handleStop();
3416
+ if (!this.worktrees || !this.runBase || !this.projectRoot) {
3417
+ this.broadcast({
3418
+ type: "autophase.reverted",
3419
+ payload: { ok: false, reverted: 0, reason: "no git baseline was captured for this run" }
3420
+ });
3421
+ return;
3422
+ }
3423
+ await this.worktrees.cleanupAllManaged().catch(() => void 0);
3424
+ const shas = commitsSince(this.projectRoot, this.runBase.sha, this.runBase.branch);
3425
+ const res = await this.worktrees.revertCommits(this.runBase.branch, shas);
3426
+ this.broadcast({ type: "autophase.reverted", payload: res });
3427
+ if (res.ok) {
3428
+ this.orchestrator = null;
3429
+ this.graph = null;
3430
+ this.runBase = null;
3431
+ this.broadcast({ type: "autophase.cleared", payload: {} });
3432
+ this.broadcast({ type: "autophase.state", payload: this.buildState() });
3433
+ }
3434
+ }
3217
3435
  /** Generic fallback phases when the LLM planner produces nothing usable. */
3218
3436
  defaultPhases() {
3219
3437
  return [
@@ -3224,13 +3442,18 @@ var AutoPhaseWebSocketHandler = class {
3224
3442
  { name: "Deployment", description: "Deploy to production", priority: "medium", estimateHours: 2, parallelizable: false }
3225
3443
  ];
3226
3444
  }
3227
- /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure. */
3228
- async planPhases(goal) {
3445
+ /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure.
3446
+ * The caller passes the run's abort signal so a stop during planning cancels
3447
+ * the LLM turn (the previous fresh, never-aborted controller made planning
3448
+ * uninterruptible). */
3449
+ async planPhases(goal, signal) {
3229
3450
  try {
3230
3451
  const planner = new AutoPhasePlanner({
3231
3452
  goal,
3232
3453
  runOnce: async (prompt) => {
3233
- const result = await this.agent.run(prompt, { signal: new AbortController().signal });
3454
+ const result = await this.agent.run(prompt, {
3455
+ signal: signal ?? new AbortController().signal
3456
+ });
3234
3457
  return result.status === "done" ? result.finalText ?? "" : "";
3235
3458
  }
3236
3459
  });
@@ -3356,15 +3579,37 @@ Type: ${task.type}`;
3356
3579
  });
3357
3580
  const taskItems = activePhase ? Array.from(activePhase.taskGraph.nodes.values()).map(mapTask) : [];
3358
3581
  const completedPhases = phases.filter((p) => p.status === "completed").length;
3582
+ const failedPhases = phases.filter((p) => p.status === "failed").length;
3583
+ const failedTasks = phases.reduce(
3584
+ (sum, p) => sum + Array.from(p.taskGraph.nodes.values()).filter((t) => t.status === "failed").length,
3585
+ 0
3586
+ );
3587
+ const lastFailed = phases.filter((p) => p.status === "failed").sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0))[0];
3588
+ const lastError = lastFailed ? `${lastFailed.name}: ${lastFailed.metadata?.integrationError ?? "phase failed"}` : null;
3359
3589
  return {
3360
3590
  title: this.graph.title,
3591
+ // Full operator prompt, shown verbatim in a dedicated goal block (the
3592
+ // title is only a short derived heading). Fall back to the title for
3593
+ // legacy boards saved before the title/goal split.
3594
+ goal: this.graph.description || this.graph.title,
3361
3595
  phases: phaseItems,
3362
3596
  tasks: taskItems,
3363
3597
  activePhaseId: currentActiveId,
3364
3598
  overallPercent: phases.length > 0 ? Math.round(completedPhases / phases.length * 100) : 0,
3365
3599
  autonomous: this.graph.autonomous,
3366
3600
  totalTasks,
3367
- completedTasks
3601
+ completedTasks,
3602
+ // Structured progress + lastError consumed by the autophase store (were
3603
+ // defined client-side but never sent, so they stayed null on the board).
3604
+ progress: {
3605
+ totalPhases: phases.length,
3606
+ completed: completedPhases,
3607
+ failed: failedPhases,
3608
+ totalTasks,
3609
+ completedTasks,
3610
+ failedTasks
3611
+ },
3612
+ lastError
3368
3613
  };
3369
3614
  }
3370
3615
  sendState(client) {
@@ -3657,6 +3902,12 @@ var SddBoardWebSocketHandler = class {
3657
3902
  };
3658
3903
 
3659
3904
  // src/server/sdd-wizard-ws-handler.ts
3905
+ function deriveTitle2(goal) {
3906
+ const firstLine = goal.split("\n").map((l) => l.trim()).find(Boolean);
3907
+ if (!firstLine) return "New SDD Project";
3908
+ const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
3909
+ return sentence.length <= 64 ? sentence : `${sentence.slice(0, 63).trimEnd()}\u2026`;
3910
+ }
3660
3911
  var SddWizardWebSocketHandler = class {
3661
3912
  constructor(deps2) {
3662
3913
  this.deps = deps2;
@@ -3695,7 +3946,8 @@ var SddWizardWebSocketHandler = class {
3695
3946
  parallelSlots: msg.payload?.parallelSlots,
3696
3947
  defaultModel: msg.payload?.model,
3697
3948
  defaultProvider: msg.payload?.provider,
3698
- fallbackModels: Array.isArray(msg.payload?.fallbackModels) ? msg.payload?.fallbackModels : void 0
3949
+ fallbackModels: Array.isArray(msg.payload?.fallbackModels) ? msg.payload?.fallbackModels : void 0,
3950
+ worktrees: typeof msg.payload?.worktrees === "boolean" ? msg.payload.worktrees : void 0
3699
3951
  });
3700
3952
  break;
3701
3953
  }
@@ -3715,7 +3967,7 @@ var SddWizardWebSocketHandler = class {
3715
3967
  }
3716
3968
  if (this.busy) return;
3717
3969
  this.driver = this.deps.makeDriver();
3718
- const prompt = this.driver.start(goal);
3970
+ const prompt = this.driver.start(deriveTitle2(goal), goal);
3719
3971
  await this.runTurn(prompt);
3720
3972
  }
3721
3973
  async onMessage(text) {
@@ -3786,7 +4038,7 @@ var SddWizardWebSocketHandler = class {
3786
4038
  };
3787
4039
 
3788
4040
  // src/server/sdd-wizard-wiring.ts
3789
- import * as path6 from "path";
4041
+ import * as path7 from "path";
3790
4042
  import { spawnSync as spawnSync2 } from "child_process";
3791
4043
  import {
3792
4044
  makeCommandVerifier,
@@ -3800,6 +4052,7 @@ import {
3800
4052
  TaskGraphStore as TaskGraphStore2,
3801
4053
  WorktreeManager as WorktreeManager2
3802
4054
  } from "@wrongstack/core";
4055
+ 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";
3803
4056
  function buildSddWizardDeps(opts) {
3804
4057
  const registry = new SddRunRegistry();
3805
4058
  let isolatedSeq = 0;
@@ -3808,11 +4061,11 @@ function buildSddWizardDeps(opts) {
3808
4061
  id: `sdd-${name2.toLowerCase().replace(/\s+/g, "-")}-${isolatedSeq++}`,
3809
4062
  role: "executor",
3810
4063
  name: name2,
3811
- disabledTools: ["delegate"],
4064
+ disabledTools: ["delegate", "write", "edit", "patch", "bash", "exec"],
3812
4065
  allowedCapabilities: ["fs.read", "net.outbound"]
3813
4066
  });
3814
4067
  try {
3815
- const res = await result.agent.run([{ type: "text", text: prompt }]);
4068
+ const res = await result.agent.run([{ type: "text", text: PLANNING_ONLY_GUARD + prompt }]);
3816
4069
  return res.finalText ?? "";
3817
4070
  } finally {
3818
4071
  await result.dispose?.();
@@ -3822,17 +4075,18 @@ function buildSddWizardDeps(opts) {
3822
4075
  makeDriver: () => new SddInterviewDriver({
3823
4076
  specStore: new SpecStore2({ baseDir: opts.paths.projectSpecs }),
3824
4077
  graphStore: new TaskGraphStore2({ baseDir: opts.paths.projectTaskGraphs }),
3825
- sessionPath: path6.join(opts.paths.projectDir, "sdd-wizard-session.json")
4078
+ sessionPath: path7.join(opts.paths.projectDir, "sdd-wizard-session.json")
3826
4079
  }),
3827
4080
  runInterviewTurn: (prompt) => runIsolatedTurn(prompt, "Spec Architect"),
3828
- startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels }) => {
4081
+ startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels, worktrees: useWorktrees }) => {
3829
4082
  const graph = driver.getGraph();
3830
4083
  const tracker = driver.getTracker();
3831
4084
  if (!graph || !tracker) {
3832
4085
  throw new Error("No task graph to run \u2014 finish the interview first.");
3833
4086
  }
4087
+ const worktreesEnabled = useWorktrees ?? process.env["WRONGSTACK_SDD_WORKTREES"] !== "0";
3834
4088
  let worktrees;
3835
- if (process.env["WRONGSTACK_SDD_WORKTREES"] !== "0") {
4089
+ if (worktreesEnabled) {
3836
4090
  const inGit = spawnSync2("git", ["rev-parse", "--is-inside-work-tree"], {
3837
4091
  cwd: opts.projectRoot,
3838
4092
  encoding: "utf8",
@@ -3891,9 +4145,6 @@ async function handleSddWizardRoute(_ws, msg, handlers) {
3891
4145
  return true;
3892
4146
  }
3893
4147
 
3894
- // src/server/index.ts
3895
- import { makeLightSubagentFactory } from "@wrongstack/runtime";
3896
-
3897
4148
  // src/server/collaboration-ws-handler.ts
3898
4149
  import { randomUUID } from "crypto";
3899
4150
  import { toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils";
@@ -4620,16 +4871,16 @@ var CollaborationWebSocketHandler = class {
4620
4871
  };
4621
4872
 
4622
4873
  // src/server/projects-manifest.ts
4623
- import * as fs5 from "fs/promises";
4624
- import * as path7 from "path";
4874
+ import * as fs6 from "fs/promises";
4875
+ import * as path8 from "path";
4625
4876
  import { projectSlug } from "@wrongstack/core";
4626
4877
  function projectsJsonPath(globalConfigPath) {
4627
- const base = path7.dirname(globalConfigPath);
4628
- return path7.join(base, "projects.json");
4878
+ const base = path8.dirname(globalConfigPath);
4879
+ return path8.join(base, "projects.json");
4629
4880
  }
4630
4881
  async function loadManifest(globalConfigPath) {
4631
4882
  try {
4632
- const raw = await fs5.readFile(projectsJsonPath(globalConfigPath), "utf8");
4883
+ const raw = await fs6.readFile(projectsJsonPath(globalConfigPath), "utf8");
4633
4884
  const parsed = JSON.parse(raw);
4634
4885
  return { projects: parsed.projects ?? [] };
4635
4886
  } catch {
@@ -4638,16 +4889,16 @@ async function loadManifest(globalConfigPath) {
4638
4889
  }
4639
4890
  async function saveManifest(manifest, globalConfigPath) {
4640
4891
  const file = projectsJsonPath(globalConfigPath);
4641
- await fs5.mkdir(path7.dirname(file), { recursive: true });
4642
- await fs5.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
4892
+ await fs6.mkdir(path8.dirname(file), { recursive: true });
4893
+ await fs6.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
4643
4894
  }
4644
4895
  function generateProjectSlug(rootPath) {
4645
4896
  return projectSlug(rootPath);
4646
4897
  }
4647
4898
  async function ensureProjectDataDir(slug, globalConfigPath) {
4648
- const base = path7.dirname(globalConfigPath);
4649
- const dir = path7.join(base, "projects", slug);
4650
- await fs5.mkdir(dir, { recursive: true });
4899
+ const base = path8.dirname(globalConfigPath);
4900
+ const dir = path8.join(base, "projects", slug);
4901
+ await fs6.mkdir(dir, { recursive: true });
4651
4902
  return dir;
4652
4903
  }
4653
4904
 
@@ -5073,14 +5324,14 @@ function registerShutdownHandlers(res) {
5073
5324
 
5074
5325
  // src/server/instance-registry.ts
5075
5326
  import * as os from "os";
5076
- import * as path8 from "path";
5077
- import * as fs6 from "fs/promises";
5327
+ import * as path9 from "path";
5328
+ import * as fs7 from "fs/promises";
5078
5329
  import { atomicWrite as atomicWrite3 } from "@wrongstack/core";
5079
5330
  function defaultBaseDir() {
5080
- return path8.join(os.homedir(), ".wrongstack");
5331
+ return path9.join(os.homedir(), ".wrongstack");
5081
5332
  }
5082
5333
  function registryPath(baseDir = defaultBaseDir()) {
5083
- return path8.join(baseDir, "webui-instances.json");
5334
+ return path9.join(baseDir, "webui-instances.json");
5084
5335
  }
5085
5336
  function isPidAlive(pid) {
5086
5337
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -5093,7 +5344,7 @@ function isPidAlive(pid) {
5093
5344
  }
5094
5345
  async function load(file) {
5095
5346
  try {
5096
- const raw = await fs6.readFile(file, "utf8");
5347
+ const raw = await fs7.readFile(file, "utf8");
5097
5348
  const parsed = JSON.parse(raw);
5098
5349
  if (parsed?.version === 1 && Array.isArray(parsed.instances)) {
5099
5350
  return parsed;
@@ -5238,19 +5489,19 @@ function computeUsageCost(usage, rates) {
5238
5489
  }
5239
5490
 
5240
5491
  // src/server/provider-handlers.ts
5241
- import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/core";
5492
+ import { DefaultSecretScrubber } from "@wrongstack/core";
5242
5493
  import { probeLocalLlm } from "@wrongstack/runtime/probe";
5243
5494
 
5244
5495
  // src/server/provider-config-io.ts
5245
- import * as fs7 from "fs/promises";
5246
- import * as path9 from "path";
5496
+ import * as fs8 from "fs/promises";
5497
+ import * as path10 from "path";
5247
5498
  import { atomicWrite as atomicWrite4 } from "@wrongstack/core";
5248
5499
  import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/security";
5249
5500
  import { DefaultSecretVault } from "@wrongstack/core";
5250
5501
  async function loadSavedProviders(configPath, vault) {
5251
5502
  let raw;
5252
5503
  try {
5253
- raw = await fs7.readFile(configPath, "utf8");
5504
+ raw = await fs8.readFile(configPath, "utf8");
5254
5505
  } catch {
5255
5506
  return {};
5256
5507
  }
@@ -5267,7 +5518,7 @@ async function saveProviders(configPath, vault, providers) {
5267
5518
  let raw;
5268
5519
  let fileExists = true;
5269
5520
  try {
5270
- raw = await fs7.readFile(configPath, "utf8");
5521
+ raw = await fs8.readFile(configPath, "utf8");
5271
5522
  } catch (err) {
5272
5523
  if (err.code !== "ENOENT") {
5273
5524
  throw new Error(
@@ -5295,7 +5546,7 @@ async function saveProviders(configPath, vault, providers) {
5295
5546
  await atomicWrite4(configPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
5296
5547
  }
5297
5548
  function createProviderConfigIO(configPath) {
5298
- const keyFile = path9.join(path9.dirname(configPath), ".key");
5549
+ const keyFile = path10.join(path10.dirname(configPath), ".key");
5299
5550
  const vault = new DefaultSecretVault({ keyFile });
5300
5551
  return {
5301
5552
  load: () => loadSavedProviders(configPath, vault),
@@ -5424,7 +5675,7 @@ function projectSavedProviders(providers) {
5424
5675
  return view;
5425
5676
  });
5426
5677
  }
5427
- var probeScrubber = new DefaultSecretScrubber2();
5678
+ var probeScrubber = new DefaultSecretScrubber();
5428
5679
  function createProviderHandlers(deps2) {
5429
5680
  const { globalConfigPath, vault, broadcast: broadcast2, clients } = deps2;
5430
5681
  let configWriteLock = deps2.getConfigWriteLock();
@@ -5613,7 +5864,7 @@ function createProviderHandlers(deps2) {
5613
5864
 
5614
5865
  // src/server/mode-handlers.ts
5615
5866
  import {
5616
- DefaultSystemPromptBuilder as DefaultSystemPromptBuilder2
5867
+ DefaultSystemPromptBuilder
5617
5868
  } from "@wrongstack/core";
5618
5869
  function createModeHandlers(ctx) {
5619
5870
  return {
@@ -5661,7 +5912,7 @@ function createModeHandlers(ctx) {
5661
5912
  }
5662
5913
  ctx.setModeId(id);
5663
5914
  const modePrompt = id === "default" ? "" : (await ctx.modeStore.getMode(id))?.prompt ?? "";
5664
- const freshBuilder = new DefaultSystemPromptBuilder2({
5915
+ const freshBuilder = new DefaultSystemPromptBuilder({
5665
5916
  memoryStore: ctx.memoryStore,
5666
5917
  skillLoader: ctx.skillLoader,
5667
5918
  modeStore: ctx.modeStore,
@@ -5692,40 +5943,10 @@ function createModeHandlers(ctx) {
5692
5943
  import * as fs9 from "fs/promises";
5693
5944
  import * as path11 from "path";
5694
5945
  import {
5695
- DefaultSessionStore as DefaultSessionStore2,
5696
- DefaultSystemPromptBuilder as DefaultSystemPromptBuilder3,
5946
+ DefaultSessionStore,
5947
+ DefaultSystemPromptBuilder as DefaultSystemPromptBuilder2,
5697
5948
  getSessionRegistry
5698
5949
  } from "@wrongstack/core";
5699
-
5700
- // src/server/path-containment.ts
5701
- import * as fs8 from "fs/promises";
5702
- import * as path10 from "path";
5703
- function isPathInside(root, target) {
5704
- const relative3 = path10.relative(root, target);
5705
- return relative3 === "" || !relative3.startsWith("..") && !path10.isAbsolute(relative3);
5706
- }
5707
- async function resolveWorkingDirInsideProject(projectRoot, inputPath) {
5708
- const resolved = path10.resolve(projectRoot, inputPath);
5709
- let stat3;
5710
- try {
5711
- stat3 = await fs8.stat(resolved);
5712
- } catch {
5713
- throw new Error(`Directory not found or not accessible: ${resolved}`);
5714
- }
5715
- if (!stat3.isDirectory()) {
5716
- throw new Error(`Directory not found or not accessible: ${resolved}`);
5717
- }
5718
- const [realProjectRoot, realResolved] = await Promise.all([
5719
- fs8.realpath(projectRoot),
5720
- fs8.realpath(resolved)
5721
- ]);
5722
- if (!isPathInside(realProjectRoot, realResolved)) {
5723
- throw new Error(`Path must stay inside the project root: ${projectRoot}`);
5724
- }
5725
- return resolved;
5726
- }
5727
-
5728
- // src/server/project-handlers.ts
5729
5950
  function createProjectHandlers(ctx) {
5730
5951
  return {
5731
5952
  listProjects: async (ws) => {
@@ -5837,7 +6058,7 @@ function createProjectHandlers(ctx) {
5837
6058
  try {
5838
6059
  const modeId = ctx.getModeId();
5839
6060
  const switchMode = modeId === "default" ? void 0 : await ctx.modeStore.getMode(modeId);
5840
- const switchBuilder = new DefaultSystemPromptBuilder3({
6061
+ const switchBuilder = new DefaultSystemPromptBuilder2({
5841
6062
  memoryStore: ctx.memoryStore,
5842
6063
  skillLoader: ctx.skillLoader,
5843
6064
  modeStore: ctx.modeStore,
@@ -5861,7 +6082,7 @@ function createProjectHandlers(ctx) {
5861
6082
  "sessions"
5862
6083
  );
5863
6084
  await fs9.mkdir(newSessionsDir, { recursive: true });
5864
- const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
6085
+ const newSessionStore = new DefaultSessionStore({ dir: newSessionsDir });
5865
6086
  const oldSession = ctx.getSession();
5866
6087
  const oldSessionId = oldSession.id;
5867
6088
  try {
@@ -6558,6 +6779,22 @@ async function handleModeRoute(ws, msg, handlers) {
6558
6779
  }
6559
6780
  }
6560
6781
 
6782
+ // src/server/prefs-routes.ts
6783
+ async function handlePrefsRoute(ws, msg, handlers) {
6784
+ switch (msg.type) {
6785
+ case "prefs.get": {
6786
+ await handlers.getPrefs(ws);
6787
+ return true;
6788
+ }
6789
+ case "prefs.update": {
6790
+ await handlers.updatePrefs(ws, msg.payload ?? {});
6791
+ return true;
6792
+ }
6793
+ default:
6794
+ return false;
6795
+ }
6796
+ }
6797
+
6561
6798
  // src/server/shell-git-routes.ts
6562
6799
  async function handleShellGitRoute(ws, msg, handlers) {
6563
6800
  switch (msg.type) {
@@ -6598,6 +6835,44 @@ async function handleMailboxRoute(ws, msg, handlers) {
6598
6835
  }
6599
6836
  }
6600
6837
 
6838
+ // src/server/mcp-routes.ts
6839
+ async function handleMcpRoute(ws, msg, handlers) {
6840
+ switch (msg.type) {
6841
+ case "mcp.list":
6842
+ await handlers.list(ws, msg);
6843
+ return true;
6844
+ case "mcp.add":
6845
+ await handlers.add(ws, msg);
6846
+ return true;
6847
+ case "mcp.update":
6848
+ await handlers.update(ws, msg);
6849
+ return true;
6850
+ case "mcp.remove":
6851
+ await handlers.remove(ws, msg);
6852
+ return true;
6853
+ case "mcp.enable":
6854
+ await handlers.enable(ws, msg);
6855
+ return true;
6856
+ case "mcp.disable":
6857
+ await handlers.disable(ws, msg);
6858
+ return true;
6859
+ case "mcp.sleep":
6860
+ await handlers.sleep(ws, msg);
6861
+ return true;
6862
+ case "mcp.wake":
6863
+ await handlers.wake(ws, msg);
6864
+ return true;
6865
+ case "mcp.restart":
6866
+ await handlers.restart(ws, msg);
6867
+ return true;
6868
+ case "mcp.discover":
6869
+ await handlers.discover(ws, msg);
6870
+ return true;
6871
+ default:
6872
+ return false;
6873
+ }
6874
+ }
6875
+
6601
6876
  // src/server/brain-routes.ts
6602
6877
  async function handleBrainRoute(ws, msg, handlers) {
6603
6878
  switch (msg.type) {
@@ -7069,11 +7344,13 @@ function setupEvents(deps2) {
7069
7344
  events.on("provider.response", (e) => {
7070
7345
  if (e.usage?.input != null) {
7071
7346
  const maxCtx = context.provider.capabilities.maxContext;
7072
- const pct = maxCtx > 0 ? e.usage.input / maxCtx : 0;
7347
+ const rawLoad = maxCtx > 0 ? e.usage.input / maxCtx : 0;
7348
+ const load2 = Math.max(0, Math.min(1, rawLoad));
7073
7349
  const costUsd = context.tokenCounter.estimateCost().total;
7074
7350
  forwardSubagent("ctx_pct", {
7075
7351
  subagentId: "leader",
7076
- load: pct,
7352
+ load: load2,
7353
+ rawLoad,
7077
7354
  tokens: e.usage.input,
7078
7355
  maxContext: maxCtx,
7079
7356
  costUsd
@@ -7724,9 +8001,13 @@ async function handleGoalGet(projectRoot, broadcast2) {
7724
8001
 
7725
8002
  // src/server/index.ts
7726
8003
  async function startWebUI(opts = {}) {
8004
+ ensureSessionShell();
7727
8005
  const requestedWsPort = opts.wsPort ?? 3457;
7728
- const wsHost = opts.wsHost ?? "127.0.0.1";
7729
- const requestedHttpPort = Number.parseInt(process.env["PORT"] ?? "3456", 10);
8006
+ const wsHost = opts.wsHost ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
8007
+ const requestedHttpPort = opts.httpPort ?? opts.webuiPort ?? opts.port ?? Number.parseInt(process.env["WEBUI_PORT"] ?? process.env["PORT"] ?? "3456", 10);
8008
+ const publicUrl = opts.publicUrl ?? process.env["WEBUI_PUBLIC_URL"];
8009
+ const publicWsUrl = opts.publicWsUrl ?? process.env["WEBUI_PUBLIC_WS_URL"];
8010
+ const requireToken = opts.requireToken ?? envFlag("WEBUI_REQUIRE_TOKEN");
7730
8011
  const strictPort = process.env["WEBUI_STRICT_PORT"] === "1" || process.env["WEBUI_STRICT_PORT"] === "true";
7731
8012
  let wsPort = requestedWsPort;
7732
8013
  let httpPort = requestedHttpPort;
@@ -7805,7 +8086,7 @@ async function startWebUI(opts = {}) {
7805
8086
  ttlSeconds: 24 * 3600
7806
8087
  });
7807
8088
  const container = createDefaultContainer({ config, wpaths, logger, modelsRegistry });
7808
- const configStore = opts.services?.configStore ?? container.resolve(TOKENS2.ConfigStore);
8089
+ const configStore = opts.services?.configStore ?? container.resolve(TOKENS.ConfigStore);
7809
8090
  const providerRegistry = new ProviderRegistry();
7810
8091
  try {
7811
8092
  const factories = await buildProviderFactoriesFromRegistry({
@@ -7827,7 +8108,7 @@ async function startWebUI(opts = {}) {
7827
8108
  r.registerAllOrThrow([...builtinToolsPack.tools ?? []], builtinToolsPack.name);
7828
8109
  return r;
7829
8110
  })();
7830
- const memoryStore = new DefaultMemoryStore2({ paths: wpaths });
8111
+ const memoryStore = new DefaultMemoryStore({ paths: wpaths });
7831
8112
  if (config.features.memory) {
7832
8113
  toolRegistry.register(rememberTool(memoryStore));
7833
8114
  toolRegistry.register(forgetTool(memoryStore));
@@ -7840,6 +8121,7 @@ async function startWebUI(opts = {}) {
7840
8121
  toolRegistry.register(makeMailSendTool({ projectDir: wpaths.projectDir, events }));
7841
8122
  toolRegistry.register(makeMailInboxTool({ projectDir: wpaths.projectDir, events }));
7842
8123
  applyToolDescriptionModes(toolRegistry, config.tools?.descriptionMode);
8124
+ configureExecPolicy(config.tools?.exec ?? {});
7843
8125
  console.log("[WebUI] Tool registry loaded:", toolRegistry.list().length, "tools");
7844
8126
  const mcpRegistry = new MCPRegistry({
7845
8127
  toolRegistry,
@@ -7856,7 +8138,7 @@ async function startWebUI(opts = {}) {
7856
8138
  });
7857
8139
  }
7858
8140
  }
7859
- let sessionStore = opts.services?.session ?? new DefaultSessionStore3({ dir: wpaths.projectSessions });
8141
+ let sessionStore = opts.services?.session ?? new DefaultSessionStore2({ dir: wpaths.projectSessions });
7860
8142
  if (!opts.services?.session) {
7861
8143
  sessionStore.prune(DEFAULT_SESSION_PRUNE_DAYS).then((count) => {
7862
8144
  if (count > 0) logger.info(`Pruned ${count} old session${count === 1 ? "" : "s"}.`);
@@ -7944,11 +8226,11 @@ async function startWebUI(opts = {}) {
7944
8226
  });
7945
8227
  } catch {
7946
8228
  }
7947
- const tokenCounter = new DefaultTokenCounter2({
8229
+ const tokenCounter = new DefaultTokenCounter({
7948
8230
  registry: modelsRegistry,
7949
8231
  providerId: config.provider
7950
8232
  });
7951
- const modeStore = new DefaultModeStore2({ directory: wpaths.configDir });
8233
+ const modeStore = new DefaultModeStore({ directory: wpaths.configDir });
7952
8234
  const activeMode = await modeStore.getActiveMode();
7953
8235
  let modeId = activeMode?.id ?? "default";
7954
8236
  const modePrompt = activeMode?.prompt ?? "";
@@ -7969,7 +8251,7 @@ async function startWebUI(opts = {}) {
7969
8251
  const modelCapabilitiesRef = {
7970
8252
  current: modelCapabilities
7971
8253
  };
7972
- const skillLoader = config.features.skills ? new DefaultSkillLoader2({ paths: wpaths }) : void 0;
8254
+ const skillLoader = config.features.skills ? new DefaultSkillLoader({ paths: wpaths }) : void 0;
7973
8255
  const skillInstaller = config.features.skills ? new SkillInstaller({
7974
8256
  manifestPath: path16.join(wstackGlobalRoot2(), "installed-skills.json"),
7975
8257
  projectSkillsDir: path16.join(projectRoot, ".wrongstack", "skills"),
@@ -7977,7 +8259,7 @@ async function startWebUI(opts = {}) {
7977
8259
  projectHash: projectHash(projectRoot),
7978
8260
  skillLoader
7979
8261
  }) : void 0;
7980
- const systemPromptBuilder = new DefaultSystemPromptBuilder4({
8262
+ const systemPromptBuilder = new DefaultSystemPromptBuilder3({
7981
8263
  memoryStore,
7982
8264
  skillLoader,
7983
8265
  modeStore,
@@ -8267,7 +8549,7 @@ async function startWebUI(opts = {}) {
8267
8549
  projectRoot,
8268
8550
  logger
8269
8551
  });
8270
- const compactor = createStrategyCompactor2({
8552
+ const compactor = createStrategyCompactor({
8271
8553
  strategy: config.context?.strategy,
8272
8554
  preserveK: config.context?.preserveK ?? 10,
8273
8555
  eliseThreshold: config.context?.eliseThreshold ?? 2e3,
@@ -8343,9 +8625,9 @@ async function startWebUI(opts = {}) {
8343
8625
  maxContext: newMaxContext
8344
8626
  });
8345
8627
  }
8346
- const secretScrubber = container.resolve(TOKENS2.SecretScrubber);
8347
- const renderer = container.has(TOKENS2.Renderer) ? container.resolve(TOKENS2.Renderer) : void 0;
8348
- const permissionPolicy = container.resolve(TOKENS2.PermissionPolicy);
8628
+ const secretScrubber = container.resolve(TOKENS.SecretScrubber);
8629
+ const renderer = container.has(TOKENS.Renderer) ? container.resolve(TOKENS.Renderer) : void 0;
8630
+ const permissionPolicy = container.resolve(TOKENS.PermissionPolicy);
8349
8631
  const toolExecutor = new ToolExecutor(toolRegistry, {
8350
8632
  permissionPolicy,
8351
8633
  secretScrubber,
@@ -8388,7 +8670,7 @@ async function startWebUI(opts = {}) {
8388
8670
  }),
8389
8671
  events
8390
8672
  );
8391
- container.bind(TOKENS2.BrainArbiter, () => brain);
8673
+ container.bind(TOKENS.BrainArbiter, () => brain);
8392
8674
  const brainMailbox = new GlobalMailbox2(wpaths.projectDir, events);
8393
8675
  const brainMonitor = new BrainMonitor({
8394
8676
  events,
@@ -8520,8 +8802,16 @@ async function startWebUI(opts = {}) {
8520
8802
  contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID2)
8521
8803
  };
8522
8804
  }
8523
- const wsToken = generateAuthToken();
8524
- console.log("[WebUI] WS auth token generated (redacted from logs)");
8805
+ const wsToken = resolveAuthToken(opts.accessToken);
8806
+ console.log("[WebUI] WS auth token ready");
8807
+ const publicHostnames = [publicUrl, publicWsUrl].map((value) => {
8808
+ if (!value) return void 0;
8809
+ try {
8810
+ return new URL(value).hostname;
8811
+ } catch {
8812
+ return void 0;
8813
+ }
8814
+ }).filter((value) => Boolean(value));
8525
8815
  const verifyClient2 = (info) => verifyClient({
8526
8816
  origin: info.origin,
8527
8817
  url: info.req.url ?? "",
@@ -8533,7 +8823,10 @@ async function startWebUI(opts = {}) {
8533
8823
  // exposure class.
8534
8824
  cookieHeader: info.req.headers.cookie,
8535
8825
  wsHost,
8536
- expectedToken: wsToken
8826
+ expectedToken: wsToken,
8827
+ requireToken,
8828
+ allowedHostnames: publicHostnames,
8829
+ allowBrowserUrlToken: Boolean(publicWsUrl)
8537
8830
  });
8538
8831
  const WS_MAX_PAYLOAD = 8 * 1024 * 1024;
8539
8832
  const wssPrimary = new WebSocketServer({
@@ -8768,8 +9061,10 @@ async function startWebUI(opts = {}) {
8768
9061
  let sessionRoutes;
8769
9062
  let projectRoutes;
8770
9063
  let modeRoutes;
9064
+ let prefsRoutes;
8771
9065
  let shellGitRoutes;
8772
9066
  let mailboxRoutes;
9067
+ let mcpRoutes;
8773
9068
  let brainRoutes;
8774
9069
  let autoPhaseRoutes;
8775
9070
  let specsRoutes;
@@ -8780,8 +9075,10 @@ async function startWebUI(opts = {}) {
8780
9075
  if (await handleSessionRoute(ws, msg, sessionRoutes)) return;
8781
9076
  if (await handleProjectRoute(ws, msg, projectRoutes)) return;
8782
9077
  if (await handleModeRoute(ws, msg, modeRoutes)) return;
9078
+ if (await handlePrefsRoute(ws, msg, prefsRoutes)) return;
8783
9079
  if (await handleShellGitRoute(ws, msg, shellGitRoutes)) return;
8784
9080
  if (await handleMailboxRoute(ws, msg, mailboxRoutes)) return;
9081
+ if (await handleMcpRoute(ws, msg, mcpRoutes)) return;
8785
9082
  if (await handleBrainRoute(ws, msg, brainRoutes)) return;
8786
9083
  if (await handleAutoPhaseRoute(ws, msg, autoPhaseRoutes)) return;
8787
9084
  if (await handleSpecsRoute(ws, msg, specsRoutes)) return;
@@ -8892,27 +9189,31 @@ async function startWebUI(opts = {}) {
8892
9189
  case "memory.forget":
8893
9190
  return handleMemoryForget(ws, msg, memoryStore);
8894
9191
  // ── MCP operations — delegated to shared handlers (mcp-handlers.ts),
8895
- // backed by the live MCPRegistry constructed above. ──
9192
+ // backed by the live MCPRegistry constructed above. Routed via
9193
+ // handleMcpRoute (see mcpRoutes = { ... } below). These case arms
9194
+ // are unreachable but left as tripwires for any future regression
9195
+ // where the route chain stops claiming 'mcp.*'. If you see one
9196
+ // fire, fix the dispatch order in the handleMessage chain above.
8896
9197
  case "mcp.list":
8897
- return handleMcpList(ws, msg, globalConfigPath, mcpRegistry);
9198
+ throw new Error("handleMcpRoute did not claim mcp.list \u2014 check chain order");
8898
9199
  case "mcp.add":
8899
- return handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry);
8900
- case "mcp.remove":
8901
- return handleMcpRemove(ws, msg, globalConfigPath, mcpRegistry);
9200
+ throw new Error("handleMcpRoute did not claim mcp.add \u2014 check chain order");
8902
9201
  case "mcp.update":
8903
- return handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry);
8904
- case "mcp.wake":
8905
- return handleMcpWake(ws, msg, globalConfigPath, mcpRegistry);
8906
- case "mcp.sleep":
8907
- return handleMcpSleep(ws, msg, globalConfigPath, mcpRegistry);
8908
- case "mcp.discover":
8909
- return handleMcpDiscover(ws, msg, globalConfigPath, mcpRegistry);
9202
+ throw new Error("handleMcpRoute did not claim mcp.update \u2014 check chain order");
9203
+ case "mcp.remove":
9204
+ throw new Error("handleMcpRoute did not claim mcp.remove \u2014 check chain order");
8910
9205
  case "mcp.enable":
8911
- return handleMcpEnable(ws, msg, globalConfigPath, mcpRegistry);
9206
+ throw new Error("handleMcpRoute did not claim mcp.enable \u2014 check chain order");
8912
9207
  case "mcp.disable":
8913
- return handleMcpDisable(ws, msg, globalConfigPath, mcpRegistry);
9208
+ throw new Error("handleMcpRoute did not claim mcp.disable \u2014 check chain order");
9209
+ case "mcp.sleep":
9210
+ throw new Error("handleMcpRoute did not claim mcp.sleep \u2014 check chain order");
9211
+ case "mcp.wake":
9212
+ throw new Error("handleMcpRoute did not claim mcp.wake \u2014 check chain order");
8914
9213
  case "mcp.restart":
8915
- return handleMcpRestart(ws, msg, globalConfigPath, mcpRegistry);
9214
+ throw new Error("handleMcpRoute did not claim mcp.restart \u2014 check chain order");
9215
+ case "mcp.discover":
9216
+ throw new Error("handleMcpRoute did not claim mcp.discover \u2014 check chain order");
8916
9217
  // Skills — full request→response cycle lives in skills-handlers.ts
8917
9218
  // (shared with the CLI's embedded server). skillsCtx is the closed-over
8918
9219
  // loader/installer/projectRoot the handlers need.
@@ -9060,53 +9361,11 @@ async function startWebUI(opts = {}) {
9060
9361
  break;
9061
9362
  }
9062
9363
  case "prefs.update": {
9063
- const parsed = validatePrefsUpdatePayload(msg.payload);
9064
- if (!parsed.ok) {
9065
- sendResult2(ws, false, parsed.message);
9066
- break;
9067
- }
9068
- const payload = parsed.value.prefs;
9069
- for (const [key, val] of Object.entries(payload)) {
9070
- context.meta[key] = val;
9071
- }
9072
- void persistPrefsToConfig(payload);
9073
- if (typeof payload["yolo"] === "boolean") {
9074
- permissionPolicy.setYolo?.(payload["yolo"]);
9075
- }
9076
- if (typeof payload["featureMcp"] === "boolean")
9077
- config.features.mcp = payload["featureMcp"];
9078
- if (typeof payload["featurePlugins"] === "boolean")
9079
- config.features.plugins = payload["featurePlugins"];
9080
- if (typeof payload["featureMemory"] === "boolean")
9081
- config.features.memory = payload["featureMemory"];
9082
- if (typeof payload["featureSkills"] === "boolean")
9083
- config.features.skills = payload["featureSkills"];
9084
- if (typeof payload["featureModelsRegistry"] === "boolean")
9085
- config.features.modelsRegistry = payload["featureModelsRegistry"];
9086
- if (Array.isArray(payload["fallbackModels"]))
9087
- config.fallbackModels = payload["fallbackModels"];
9088
- if (typeof payload["fallbackAuto"] === "boolean")
9089
- config.fallbackAuto = payload["fallbackAuto"];
9090
- if (typeof payload["contextAutoCompact"] === "boolean") {
9091
- if (payload["contextAutoCompact"] && autoCompactor) {
9092
- pipelines.contextWindow.remove("AutoCompaction", { optional: true });
9093
- pipelines.contextWindow.use({ name: "AutoCompaction", handler: autoCompactor.handler() });
9094
- } else {
9095
- pipelines.contextWindow.remove("AutoCompaction", { optional: true });
9096
- }
9097
- }
9098
- if (typeof payload["logLevel"] === "string") {
9099
- const valid = ["debug", "info", "warn", "error"];
9100
- if (valid.includes(payload["logLevel"])) {
9101
- logger.level = payload["logLevel"];
9102
- }
9103
- }
9104
- broadcast(clients, { type: "prefs.updated", payload: prefSnapshot() });
9105
- break;
9364
+ void ws;
9365
+ throw new Error("handlePrefsRoute did not claim prefs.update \u2014 check chain order");
9106
9366
  }
9107
9367
  case "prefs.get": {
9108
- send(ws, { type: "prefs.updated", payload: prefSnapshot() });
9109
- break;
9368
+ throw new Error("handlePrefsRoute did not claim prefs.get \u2014 check chain order");
9110
9369
  }
9111
9370
  default:
9112
9371
  send(ws, {
@@ -9327,6 +9586,55 @@ async function startWebUI(opts = {}) {
9327
9586
  },
9328
9587
  sessionStartPayload
9329
9588
  });
9589
+ prefsRoutes = {
9590
+ getPrefs: async (ws) => {
9591
+ send(ws, { type: "prefs.updated", payload: prefSnapshot() });
9592
+ },
9593
+ updatePrefs: async (ws, msgPayload) => {
9594
+ const parsed = validatePrefsUpdatePayload(msgPayload);
9595
+ if (!parsed.ok) {
9596
+ sendResult2(ws, false, parsed.message);
9597
+ return;
9598
+ }
9599
+ const payload = parsed.value.prefs;
9600
+ for (const [key, val] of Object.entries(payload)) {
9601
+ context.meta[key] = val;
9602
+ }
9603
+ void persistPrefsToConfig(payload);
9604
+ if (typeof payload["yolo"] === "boolean") {
9605
+ permissionPolicy.setYolo?.(payload["yolo"]);
9606
+ }
9607
+ if (typeof payload["featureMcp"] === "boolean")
9608
+ config.features.mcp = payload["featureMcp"];
9609
+ if (typeof payload["featurePlugins"] === "boolean")
9610
+ config.features.plugins = payload["featurePlugins"];
9611
+ if (typeof payload["featureMemory"] === "boolean")
9612
+ config.features.memory = payload["featureMemory"];
9613
+ if (typeof payload["featureSkills"] === "boolean")
9614
+ config.features.skills = payload["featureSkills"];
9615
+ if (typeof payload["featureModelsRegistry"] === "boolean")
9616
+ config.features.modelsRegistry = payload["featureModelsRegistry"];
9617
+ if (Array.isArray(payload["fallbackModels"]))
9618
+ config.fallbackModels = payload["fallbackModels"];
9619
+ if (typeof payload["fallbackAuto"] === "boolean")
9620
+ config.fallbackAuto = payload["fallbackAuto"];
9621
+ if (typeof payload["contextAutoCompact"] === "boolean") {
9622
+ if (payload["contextAutoCompact"] && autoCompactor) {
9623
+ pipelines.contextWindow.remove("AutoCompaction", { optional: true });
9624
+ pipelines.contextWindow.use({ name: "AutoCompaction", handler: autoCompactor.handler() });
9625
+ } else {
9626
+ pipelines.contextWindow.remove("AutoCompaction", { optional: true });
9627
+ }
9628
+ }
9629
+ if (typeof payload["logLevel"] === "string") {
9630
+ const valid = ["debug", "info", "warn", "error"];
9631
+ if (valid.includes(payload["logLevel"])) {
9632
+ logger.level = payload["logLevel"];
9633
+ }
9634
+ }
9635
+ broadcast(clients, { type: "prefs.updated", payload: prefSnapshot() });
9636
+ }
9637
+ };
9330
9638
  shellGitRoutes = {
9331
9639
  gitInfo: async (ws) => {
9332
9640
  await handleGitInfo(ws, projectRoot);
@@ -9379,6 +9687,18 @@ async function startWebUI(opts = {}) {
9379
9687
  return handleMailboxPurge(ws, { projectRoot, globalRoot: path16.dirname(globalConfigPath) }, parsed.value);
9380
9688
  }
9381
9689
  };
9690
+ mcpRoutes = {
9691
+ list: (ws, msg) => handleMcpList(ws, msg, globalConfigPath, mcpRegistry),
9692
+ add: (ws, msg) => handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry),
9693
+ update: (ws, msg) => handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry),
9694
+ remove: (ws, msg) => handleMcpRemove(ws, msg, globalConfigPath, mcpRegistry),
9695
+ enable: (ws, msg) => handleMcpEnable(ws, msg, globalConfigPath, mcpRegistry),
9696
+ disable: (ws, msg) => handleMcpDisable(ws, msg, globalConfigPath, mcpRegistry),
9697
+ sleep: (ws, msg) => handleMcpSleep(ws, msg, globalConfigPath, mcpRegistry),
9698
+ wake: (ws, msg) => handleMcpWake(ws, msg, globalConfigPath, mcpRegistry),
9699
+ restart: (ws, msg) => handleMcpRestart(ws, msg, globalConfigPath, mcpRegistry),
9700
+ discover: (ws, msg) => handleMcpDiscover(ws, msg, globalConfigPath, mcpRegistry)
9701
+ };
9382
9702
  brainRoutes = {
9383
9703
  status: (ws) => {
9384
9704
  send(ws, {
@@ -9446,8 +9766,10 @@ async function startWebUI(opts = {}) {
9446
9766
  host: wsHost,
9447
9767
  distDir: path16.resolve(import.meta.dirname, "../../dist"),
9448
9768
  wsPort,
9769
+ publicWsUrl,
9449
9770
  globalRoot: wpaths.globalRoot,
9450
9771
  apiToken: wsToken,
9772
+ requireToken,
9451
9773
  watcherMetrics,
9452
9774
  onFleetPing: () => {
9453
9775
  void fleetBroadcast?.();
@@ -9455,7 +9777,12 @@ async function startWebUI(opts = {}) {
9455
9777
  });
9456
9778
  const registryBaseDir = path16.dirname(globalConfigPath);
9457
9779
  httpServer.listen(httpPort, wsHost, () => {
9458
- const openUrl = `http://${wsHost}:${httpPort}`;
9780
+ const openUrl = buildWebUIAccessUrl({
9781
+ host: wsHost,
9782
+ port: httpPort,
9783
+ token: wsToken,
9784
+ publicUrl
9785
+ });
9459
9786
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
9460
9787
  if (opts.open) openBrowser(openUrl);
9461
9788
  void registerInstance(
@@ -9467,7 +9794,7 @@ async function startWebUI(opts = {}) {
9467
9794
  projectRoot,
9468
9795
  projectName: path16.basename(projectRoot) || projectRoot,
9469
9796
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9470
- url: `http://${wsHost}:${httpPort}`
9797
+ url: buildWebUIAccessUrl({ host: wsHost, port: httpPort, publicUrl })
9471
9798
  },
9472
9799
  registryBaseDir
9473
9800
  ).catch((err) => console.warn(JSON.stringify({
@@ -9517,6 +9844,7 @@ export {
9517
9844
  browserOpenCommand,
9518
9845
  buildCspHeader,
9519
9846
  buildSddWizardDeps,
9847
+ buildWebUIAccessUrl,
9520
9848
  createCustomModeStore,
9521
9849
  createEternalSubscription,
9522
9850
  createHttpServer,
@@ -9524,6 +9852,7 @@ export {
9524
9852
  createToolLspCompletionSource,
9525
9853
  defaultBaseDir,
9526
9854
  deleteKey,
9855
+ envFlag,
9527
9856
  errMessage,
9528
9857
  estimateTokens,
9529
9858
  extractToken,
@@ -9559,6 +9888,7 @@ export {
9559
9888
  handleSkillsInstall,
9560
9889
  handleSkillsUninstall,
9561
9890
  handleSkillsUpdate,
9891
+ hostForBrowserUrl,
9562
9892
  hostHeaderOk,
9563
9893
  injectWsPort,
9564
9894
  isLoopbackBind,
@@ -9574,6 +9904,7 @@ export {
9574
9904
  registerInstance,
9575
9905
  registryPath,
9576
9906
  removeProvider,
9907
+ resolveAuthToken,
9577
9908
  saveProviders,
9578
9909
  send,
9579
9910
  sendResult2 as sendResult,