@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.
@@ -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");
@@ -1699,6 +1783,37 @@ function errMessage(err) {
1699
1783
  function generateAuthToken() {
1700
1784
  return randomBytes(16).toString("hex");
1701
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
+ }
1702
1817
 
1703
1818
  // src/server/file-handlers.ts
1704
1819
  async function resolveFileInsideProject(projectRoot, filePath) {
@@ -3030,6 +3145,13 @@ import {
3030
3145
  PhaseStore,
3031
3146
  WorktreeManager
3032
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
+ }
3033
3155
  function isGitRepo(cwd) {
3034
3156
  try {
3035
3157
  const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, encoding: "utf8", windowsHide: true });
@@ -3038,6 +3160,19 @@ function isGitRepo(cwd) {
3038
3160
  return false;
3039
3161
  }
3040
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
+ }
3041
3176
  var AutoPhaseWebSocketHandler = class {
3042
3177
  constructor(agent, context, logger, storeDir, events, projectRoot) {
3043
3178
  this.agent = agent;
@@ -3057,10 +3192,17 @@ var AutoPhaseWebSocketHandler = class {
3057
3192
  store;
3058
3193
  clients = /* @__PURE__ */ new Set();
3059
3194
  broadcastInterval = null;
3060
- /** 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. */
3061
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;
3062
3201
  /** Optional per-phase git-worktree isolation (lazily created at start). */
3063
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;
3064
3206
  /** Per-run worker identities so the board can show "who is on what". */
3065
3207
  usedNicknames = /* @__PURE__ */ new Set();
3066
3208
  addClient(ws) {
@@ -3084,11 +3226,13 @@ var AutoPhaseWebSocketHandler = class {
3084
3226
  this.broadcast({ type: "autophase.resumed", payload: {} });
3085
3227
  break;
3086
3228
  case "autophase.stop":
3087
- this.abort?.abort();
3088
- this.orchestrator?.stop();
3089
- this.stopBroadcast();
3090
- if (this.graph) void this.store.save(this.graph);
3091
- 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();
3092
3236
  break;
3093
3237
  case "autophase.status":
3094
3238
  this.broadcastState();
@@ -3165,17 +3309,27 @@ var AutoPhaseWebSocketHandler = class {
3165
3309
  }
3166
3310
  }
3167
3311
  async handleStart(payload) {
3168
- const title = payload?.goal || payload?.title || "Untitled Project";
3312
+ const goal = payload?.goal || payload?.title || "Untitled Project";
3313
+ const title = deriveTitle(goal);
3169
3314
  const autonomous = payload?.autonomous ?? true;
3170
- 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
+ }
3171
3322
  this.logger.info(`[AutoPhase] Starting: ${title}`);
3172
- const graph = await new PhaseGraphBuilder({ title, phases, autonomous }).build();
3323
+ const graph = await new PhaseGraphBuilder({ title, description: goal, phases, autonomous }).build();
3173
3324
  this.graph = graph;
3174
- this.abort = new AbortController();
3175
3325
  await this.store.save(graph);
3176
- 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)) {
3177
3328
  this.worktrees = new WorktreeManager({ projectRoot: this.projectRoot, events: this.events });
3178
3329
  }
3330
+ if (this.worktrees) {
3331
+ this.runBase = await this.worktrees.currentBase();
3332
+ }
3179
3333
  this.orchestrator = new PhaseOrchestrator({
3180
3334
  graph,
3181
3335
  ctx: {
@@ -3222,6 +3376,62 @@ var AutoPhaseWebSocketHandler = class {
3222
3376
  this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
3223
3377
  });
3224
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
+ }
3225
3435
  /** Generic fallback phases when the LLM planner produces nothing usable. */
3226
3436
  defaultPhases() {
3227
3437
  return [
@@ -3232,13 +3442,18 @@ var AutoPhaseWebSocketHandler = class {
3232
3442
  { name: "Deployment", description: "Deploy to production", priority: "medium", estimateHours: 2, parallelizable: false }
3233
3443
  ];
3234
3444
  }
3235
- /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure. */
3236
- 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) {
3237
3450
  try {
3238
3451
  const planner = new AutoPhasePlanner({
3239
3452
  goal,
3240
3453
  runOnce: async (prompt) => {
3241
- 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
+ });
3242
3457
  return result.status === "done" ? result.finalText ?? "" : "";
3243
3458
  }
3244
3459
  });
@@ -3373,6 +3588,10 @@ Type: ${task.type}`;
3373
3588
  const lastError = lastFailed ? `${lastFailed.name}: ${lastFailed.metadata?.integrationError ?? "phase failed"}` : null;
3374
3589
  return {
3375
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,
3376
3595
  phases: phaseItems,
3377
3596
  tasks: taskItems,
3378
3597
  activePhaseId: currentActiveId,
@@ -3683,6 +3902,12 @@ var SddBoardWebSocketHandler = class {
3683
3902
  };
3684
3903
 
3685
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
+ }
3686
3911
  var SddWizardWebSocketHandler = class {
3687
3912
  constructor(deps2) {
3688
3913
  this.deps = deps2;
@@ -3721,7 +3946,8 @@ var SddWizardWebSocketHandler = class {
3721
3946
  parallelSlots: msg.payload?.parallelSlots,
3722
3947
  defaultModel: msg.payload?.model,
3723
3948
  defaultProvider: msg.payload?.provider,
3724
- 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
3725
3951
  });
3726
3952
  break;
3727
3953
  }
@@ -3741,7 +3967,7 @@ var SddWizardWebSocketHandler = class {
3741
3967
  }
3742
3968
  if (this.busy) return;
3743
3969
  this.driver = this.deps.makeDriver();
3744
- const prompt = this.driver.start(goal);
3970
+ const prompt = this.driver.start(deriveTitle2(goal), goal);
3745
3971
  await this.runTurn(prompt);
3746
3972
  }
3747
3973
  async onMessage(text) {
@@ -3826,6 +4052,7 @@ import {
3826
4052
  TaskGraphStore as TaskGraphStore2,
3827
4053
  WorktreeManager as WorktreeManager2
3828
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";
3829
4056
  function buildSddWizardDeps(opts) {
3830
4057
  const registry = new SddRunRegistry();
3831
4058
  let isolatedSeq = 0;
@@ -3834,11 +4061,11 @@ function buildSddWizardDeps(opts) {
3834
4061
  id: `sdd-${name2.toLowerCase().replace(/\s+/g, "-")}-${isolatedSeq++}`,
3835
4062
  role: "executor",
3836
4063
  name: name2,
3837
- disabledTools: ["delegate"],
4064
+ disabledTools: ["delegate", "write", "edit", "patch", "bash", "exec"],
3838
4065
  allowedCapabilities: ["fs.read", "net.outbound"]
3839
4066
  });
3840
4067
  try {
3841
- const res = await result.agent.run([{ type: "text", text: prompt }]);
4068
+ const res = await result.agent.run([{ type: "text", text: PLANNING_ONLY_GUARD + prompt }]);
3842
4069
  return res.finalText ?? "";
3843
4070
  } finally {
3844
4071
  await result.dispose?.();
@@ -3851,14 +4078,15 @@ function buildSddWizardDeps(opts) {
3851
4078
  sessionPath: path7.join(opts.paths.projectDir, "sdd-wizard-session.json")
3852
4079
  }),
3853
4080
  runInterviewTurn: (prompt) => runIsolatedTurn(prompt, "Spec Architect"),
3854
- startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels }) => {
4081
+ startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels, worktrees: useWorktrees }) => {
3855
4082
  const graph = driver.getGraph();
3856
4083
  const tracker = driver.getTracker();
3857
4084
  if (!graph || !tracker) {
3858
4085
  throw new Error("No task graph to run \u2014 finish the interview first.");
3859
4086
  }
4087
+ const worktreesEnabled = useWorktrees ?? process.env["WRONGSTACK_SDD_WORKTREES"] !== "0";
3860
4088
  let worktrees;
3861
- if (process.env["WRONGSTACK_SDD_WORKTREES"] !== "0") {
4089
+ if (worktreesEnabled) {
3862
4090
  const inGit = spawnSync2("git", ["rev-parse", "--is-inside-work-tree"], {
3863
4091
  cwd: opts.projectRoot,
3864
4092
  encoding: "utf8",
@@ -7775,8 +8003,11 @@ async function handleGoalGet(projectRoot, broadcast2) {
7775
8003
  async function startWebUI(opts = {}) {
7776
8004
  ensureSessionShell();
7777
8005
  const requestedWsPort = opts.wsPort ?? 3457;
7778
- const wsHost = opts.wsHost ?? "127.0.0.1";
7779
- 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");
7780
8011
  const strictPort = process.env["WEBUI_STRICT_PORT"] === "1" || process.env["WEBUI_STRICT_PORT"] === "true";
7781
8012
  let wsPort = requestedWsPort;
7782
8013
  let httpPort = requestedHttpPort;
@@ -8571,8 +8802,16 @@ async function startWebUI(opts = {}) {
8571
8802
  contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID2)
8572
8803
  };
8573
8804
  }
8574
- const wsToken = generateAuthToken();
8575
- 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));
8576
8815
  const verifyClient2 = (info) => verifyClient({
8577
8816
  origin: info.origin,
8578
8817
  url: info.req.url ?? "",
@@ -8584,7 +8823,10 @@ async function startWebUI(opts = {}) {
8584
8823
  // exposure class.
8585
8824
  cookieHeader: info.req.headers.cookie,
8586
8825
  wsHost,
8587
- expectedToken: wsToken
8826
+ expectedToken: wsToken,
8827
+ requireToken,
8828
+ allowedHostnames: publicHostnames,
8829
+ allowBrowserUrlToken: Boolean(publicWsUrl)
8588
8830
  });
8589
8831
  const WS_MAX_PAYLOAD = 8 * 1024 * 1024;
8590
8832
  const wssPrimary = new WebSocketServer({
@@ -9524,8 +9766,10 @@ async function startWebUI(opts = {}) {
9524
9766
  host: wsHost,
9525
9767
  distDir: path16.resolve(import.meta.dirname, "../../dist"),
9526
9768
  wsPort,
9769
+ publicWsUrl,
9527
9770
  globalRoot: wpaths.globalRoot,
9528
9771
  apiToken: wsToken,
9772
+ requireToken,
9529
9773
  watcherMetrics,
9530
9774
  onFleetPing: () => {
9531
9775
  void fleetBroadcast?.();
@@ -9533,7 +9777,12 @@ async function startWebUI(opts = {}) {
9533
9777
  });
9534
9778
  const registryBaseDir = path16.dirname(globalConfigPath);
9535
9779
  httpServer.listen(httpPort, wsHost, () => {
9536
- const openUrl = `http://${wsHost}:${httpPort}`;
9780
+ const openUrl = buildWebUIAccessUrl({
9781
+ host: wsHost,
9782
+ port: httpPort,
9783
+ token: wsToken,
9784
+ publicUrl
9785
+ });
9537
9786
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
9538
9787
  if (opts.open) openBrowser(openUrl);
9539
9788
  void registerInstance(
@@ -9545,7 +9794,7 @@ async function startWebUI(opts = {}) {
9545
9794
  projectRoot,
9546
9795
  projectName: path16.basename(projectRoot) || projectRoot,
9547
9796
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9548
- url: `http://${wsHost}:${httpPort}`
9797
+ url: buildWebUIAccessUrl({ host: wsHost, port: httpPort, publicUrl })
9549
9798
  },
9550
9799
  registryBaseDir
9551
9800
  ).catch((err) => console.warn(JSON.stringify({
@@ -9595,6 +9844,7 @@ export {
9595
9844
  browserOpenCommand,
9596
9845
  buildCspHeader,
9597
9846
  buildSddWizardDeps,
9847
+ buildWebUIAccessUrl,
9598
9848
  createCustomModeStore,
9599
9849
  createEternalSubscription,
9600
9850
  createHttpServer,
@@ -9602,6 +9852,7 @@ export {
9602
9852
  createToolLspCompletionSource,
9603
9853
  defaultBaseDir,
9604
9854
  deleteKey,
9855
+ envFlag,
9605
9856
  errMessage,
9606
9857
  estimateTokens,
9607
9858
  extractToken,
@@ -9637,6 +9888,7 @@ export {
9637
9888
  handleSkillsInstall,
9638
9889
  handleSkillsUninstall,
9639
9890
  handleSkillsUpdate,
9891
+ hostForBrowserUrl,
9640
9892
  hostHeaderOk,
9641
9893
  injectWsPort,
9642
9894
  isLoopbackBind,
@@ -9652,6 +9904,7 @@ export {
9652
9904
  registerInstance,
9653
9905
  registryPath,
9654
9906
  removeProvider,
9907
+ resolveAuthToken,
9655
9908
  saveProviders,
9656
9909
  send,
9657
9910
  sendResult2 as sendResult,