@wrongstack/webui 0.273.1 → 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.
@@ -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");
@@ -1700,6 +1784,37 @@ function errMessage(err) {
1700
1784
  function generateAuthToken() {
1701
1785
  return randomBytes(16).toString("hex");
1702
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
+ }
1703
1818
 
1704
1819
  // src/server/file-handlers.ts
1705
1820
  async function resolveFileInsideProject(projectRoot, filePath) {
@@ -3031,6 +3146,13 @@ import {
3031
3146
  PhaseStore,
3032
3147
  WorktreeManager
3033
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
+ }
3034
3156
  function isGitRepo(cwd) {
3035
3157
  try {
3036
3158
  const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, encoding: "utf8", windowsHide: true });
@@ -3039,6 +3161,19 @@ function isGitRepo(cwd) {
3039
3161
  return false;
3040
3162
  }
3041
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
+ }
3042
3177
  var AutoPhaseWebSocketHandler = class {
3043
3178
  constructor(agent, context, logger, storeDir, events, projectRoot) {
3044
3179
  this.agent = agent;
@@ -3058,10 +3193,17 @@ var AutoPhaseWebSocketHandler = class {
3058
3193
  store;
3059
3194
  clients = /* @__PURE__ */ new Set();
3060
3195
  broadcastInterval = null;
3061
- /** 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. */
3062
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;
3063
3202
  /** Optional per-phase git-worktree isolation (lazily created at start). */
3064
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;
3065
3207
  /** Per-run worker identities so the board can show "who is on what". */
3066
3208
  usedNicknames = /* @__PURE__ */ new Set();
3067
3209
  addClient(ws) {
@@ -3085,11 +3227,13 @@ var AutoPhaseWebSocketHandler = class {
3085
3227
  this.broadcast({ type: "autophase.resumed", payload: {} });
3086
3228
  break;
3087
3229
  case "autophase.stop":
3088
- this.abort?.abort();
3089
- this.orchestrator?.stop();
3090
- this.stopBroadcast();
3091
- if (this.graph) void this.store.save(this.graph);
3092
- 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();
3093
3237
  break;
3094
3238
  case "autophase.status":
3095
3239
  this.broadcastState();
@@ -3166,17 +3310,27 @@ var AutoPhaseWebSocketHandler = class {
3166
3310
  }
3167
3311
  }
3168
3312
  async handleStart(payload) {
3169
- const title = payload?.goal || payload?.title || "Untitled Project";
3313
+ const goal = payload?.goal || payload?.title || "Untitled Project";
3314
+ const title = deriveTitle(goal);
3170
3315
  const autonomous = payload?.autonomous ?? true;
3171
- 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
+ }
3172
3323
  this.logger.info(`[AutoPhase] Starting: ${title}`);
3173
- const graph = await new PhaseGraphBuilder({ title, phases, autonomous }).build();
3324
+ const graph = await new PhaseGraphBuilder({ title, description: goal, phases, autonomous }).build();
3174
3325
  this.graph = graph;
3175
- this.abort = new AbortController();
3176
3326
  await this.store.save(graph);
3177
- 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)) {
3178
3329
  this.worktrees = new WorktreeManager({ projectRoot: this.projectRoot, events: this.events });
3179
3330
  }
3331
+ if (this.worktrees) {
3332
+ this.runBase = await this.worktrees.currentBase();
3333
+ }
3180
3334
  this.orchestrator = new PhaseOrchestrator({
3181
3335
  graph,
3182
3336
  ctx: {
@@ -3223,6 +3377,62 @@ var AutoPhaseWebSocketHandler = class {
3223
3377
  this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
3224
3378
  });
3225
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
+ }
3226
3436
  /** Generic fallback phases when the LLM planner produces nothing usable. */
3227
3437
  defaultPhases() {
3228
3438
  return [
@@ -3233,13 +3443,18 @@ var AutoPhaseWebSocketHandler = class {
3233
3443
  { name: "Deployment", description: "Deploy to production", priority: "medium", estimateHours: 2, parallelizable: false }
3234
3444
  ];
3235
3445
  }
3236
- /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure. */
3237
- 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) {
3238
3451
  try {
3239
3452
  const planner = new AutoPhasePlanner({
3240
3453
  goal,
3241
3454
  runOnce: async (prompt) => {
3242
- 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
+ });
3243
3458
  return result.status === "done" ? result.finalText ?? "" : "";
3244
3459
  }
3245
3460
  });
@@ -3374,6 +3589,10 @@ Type: ${task.type}`;
3374
3589
  const lastError = lastFailed ? `${lastFailed.name}: ${lastFailed.metadata?.integrationError ?? "phase failed"}` : null;
3375
3590
  return {
3376
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,
3377
3596
  phases: phaseItems,
3378
3597
  tasks: taskItems,
3379
3598
  activePhaseId: currentActiveId,
@@ -3684,6 +3903,12 @@ var SddBoardWebSocketHandler = class {
3684
3903
  };
3685
3904
 
3686
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
+ }
3687
3912
  var SddWizardWebSocketHandler = class {
3688
3913
  constructor(deps2) {
3689
3914
  this.deps = deps2;
@@ -3722,7 +3947,8 @@ var SddWizardWebSocketHandler = class {
3722
3947
  parallelSlots: msg.payload?.parallelSlots,
3723
3948
  defaultModel: msg.payload?.model,
3724
3949
  defaultProvider: msg.payload?.provider,
3725
- 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
3726
3952
  });
3727
3953
  break;
3728
3954
  }
@@ -3742,7 +3968,7 @@ var SddWizardWebSocketHandler = class {
3742
3968
  }
3743
3969
  if (this.busy) return;
3744
3970
  this.driver = this.deps.makeDriver();
3745
- const prompt = this.driver.start(goal);
3971
+ const prompt = this.driver.start(deriveTitle2(goal), goal);
3746
3972
  await this.runTurn(prompt);
3747
3973
  }
3748
3974
  async onMessage(text) {
@@ -3827,6 +4053,7 @@ import {
3827
4053
  TaskGraphStore as TaskGraphStore2,
3828
4054
  WorktreeManager as WorktreeManager2
3829
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";
3830
4057
  function buildSddWizardDeps(opts) {
3831
4058
  const registry = new SddRunRegistry();
3832
4059
  let isolatedSeq = 0;
@@ -3835,11 +4062,11 @@ function buildSddWizardDeps(opts) {
3835
4062
  id: `sdd-${name2.toLowerCase().replace(/\s+/g, "-")}-${isolatedSeq++}`,
3836
4063
  role: "executor",
3837
4064
  name: name2,
3838
- disabledTools: ["delegate"],
4065
+ disabledTools: ["delegate", "write", "edit", "patch", "bash", "exec"],
3839
4066
  allowedCapabilities: ["fs.read", "net.outbound"]
3840
4067
  });
3841
4068
  try {
3842
- const res = await result.agent.run([{ type: "text", text: prompt }]);
4069
+ const res = await result.agent.run([{ type: "text", text: PLANNING_ONLY_GUARD + prompt }]);
3843
4070
  return res.finalText ?? "";
3844
4071
  } finally {
3845
4072
  await result.dispose?.();
@@ -3852,14 +4079,15 @@ function buildSddWizardDeps(opts) {
3852
4079
  sessionPath: path7.join(opts.paths.projectDir, "sdd-wizard-session.json")
3853
4080
  }),
3854
4081
  runInterviewTurn: (prompt) => runIsolatedTurn(prompt, "Spec Architect"),
3855
- startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels }) => {
4082
+ startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels, worktrees: useWorktrees }) => {
3856
4083
  const graph = driver.getGraph();
3857
4084
  const tracker = driver.getTracker();
3858
4085
  if (!graph || !tracker) {
3859
4086
  throw new Error("No task graph to run \u2014 finish the interview first.");
3860
4087
  }
4088
+ const worktreesEnabled = useWorktrees ?? process.env["WRONGSTACK_SDD_WORKTREES"] !== "0";
3861
4089
  let worktrees;
3862
- if (process.env["WRONGSTACK_SDD_WORKTREES"] !== "0") {
4090
+ if (worktreesEnabled) {
3863
4091
  const inGit = spawnSync2("git", ["rev-parse", "--is-inside-work-tree"], {
3864
4092
  cwd: opts.projectRoot,
3865
4093
  encoding: "utf8",
@@ -7768,8 +7996,11 @@ async function handleGoalGet(projectRoot, broadcast2) {
7768
7996
  async function startWebUI(opts = {}) {
7769
7997
  ensureSessionShell();
7770
7998
  const requestedWsPort = opts.wsPort ?? 3457;
7771
- const wsHost = opts.wsHost ?? "127.0.0.1";
7772
- 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");
7773
8004
  const strictPort = process.env["WEBUI_STRICT_PORT"] === "1" || process.env["WEBUI_STRICT_PORT"] === "true";
7774
8005
  let wsPort = requestedWsPort;
7775
8006
  let httpPort = requestedHttpPort;
@@ -8564,8 +8795,16 @@ async function startWebUI(opts = {}) {
8564
8795
  contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID2)
8565
8796
  };
8566
8797
  }
8567
- const wsToken = generateAuthToken();
8568
- 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));
8569
8808
  const verifyClient2 = (info) => verifyClient({
8570
8809
  origin: info.origin,
8571
8810
  url: info.req.url ?? "",
@@ -8577,7 +8816,10 @@ async function startWebUI(opts = {}) {
8577
8816
  // exposure class.
8578
8817
  cookieHeader: info.req.headers.cookie,
8579
8818
  wsHost,
8580
- expectedToken: wsToken
8819
+ expectedToken: wsToken,
8820
+ requireToken,
8821
+ allowedHostnames: publicHostnames,
8822
+ allowBrowserUrlToken: Boolean(publicWsUrl)
8581
8823
  });
8582
8824
  const WS_MAX_PAYLOAD = 8 * 1024 * 1024;
8583
8825
  const wssPrimary = new WebSocketServer({
@@ -9517,8 +9759,10 @@ async function startWebUI(opts = {}) {
9517
9759
  host: wsHost,
9518
9760
  distDir: path16.resolve(import.meta.dirname, "../../dist"),
9519
9761
  wsPort,
9762
+ publicWsUrl,
9520
9763
  globalRoot: wpaths.globalRoot,
9521
9764
  apiToken: wsToken,
9765
+ requireToken,
9522
9766
  watcherMetrics,
9523
9767
  onFleetPing: () => {
9524
9768
  void fleetBroadcast?.();
@@ -9526,7 +9770,12 @@ async function startWebUI(opts = {}) {
9526
9770
  });
9527
9771
  const registryBaseDir = path16.dirname(globalConfigPath);
9528
9772
  httpServer.listen(httpPort, wsHost, () => {
9529
- const openUrl = `http://${wsHost}:${httpPort}`;
9773
+ const openUrl = buildWebUIAccessUrl({
9774
+ host: wsHost,
9775
+ port: httpPort,
9776
+ token: wsToken,
9777
+ publicUrl
9778
+ });
9530
9779
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
9531
9780
  if (opts.open) openBrowser(openUrl);
9532
9781
  void registerInstance(
@@ -9538,7 +9787,7 @@ async function startWebUI(opts = {}) {
9538
9787
  projectRoot,
9539
9788
  projectName: path16.basename(projectRoot) || projectRoot,
9540
9789
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9541
- url: `http://${wsHost}:${httpPort}`
9790
+ url: buildWebUIAccessUrl({ host: wsHost, port: httpPort, publicUrl })
9542
9791
  },
9543
9792
  registryBaseDir
9544
9793
  ).catch((err) => console.warn(JSON.stringify({
@@ -9580,7 +9829,55 @@ async function startWebUI(opts = {}) {
9580
9829
 
9581
9830
  // src/server/entry.ts
9582
9831
  var argv = process.argv.slice(2);
9583
- 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") {
9584
9881
  listInstances().then((instances) => {
9585
9882
  console.log(formatInstances(instances));
9586
9883
  process.exit(0);
@@ -9594,11 +9891,40 @@ if (argv.includes("--list") || argv.includes("-l") || argv[0] === "ls") {
9594
9891
  process.exit(1);
9595
9892
  });
9596
9893
  } else {
9597
- const wsPort = Number.parseInt(process.env["WS_PORT"] ?? "3457", 10);
9598
- 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
+ }
9599
9915
  const open = argv.includes("--open") || argv.includes("-o") || process.env["WEBUI_OPEN"] === "1";
9600
- console.log(`[WebUI] Starting standalone server on ${wsHost}:${wsPort}...`);
9601
- 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) => {
9602
9928
  console.error(JSON.stringify({
9603
9929
  level: "fatal",
9604
9930
  event: "webui.startup_failed",