@wrongstack/webui 0.273.1 → 0.275.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.
@@ -171,9 +171,12 @@ var BOOLEAN_PREF_KEYS = /* @__PURE__ */ new Set([
171
171
  "reasoningPreserve",
172
172
  "hqEnabled",
173
173
  "hqRawContent",
174
- "fallbackAuto"
174
+ "fallbackAuto",
175
+ "favoriteModelsOnly"
175
176
  ]);
176
- var STRING_ARRAY_PREF_KEYS = /* @__PURE__ */ new Set(["fallbackModels"]);
177
+ var STRING_ARRAY_PREF_KEYS = /* @__PURE__ */ new Set(["fallbackModels", "favoriteModels"]);
178
+ var STRING_ARRAY_RECORD_PREF_KEYS = /* @__PURE__ */ new Set(["fallbackProfiles"]);
179
+ var MODEL_MATRIX_PREF_KEYS = /* @__PURE__ */ new Set(["modelMatrix"]);
177
180
  var NUMBER_PREF_KEYS = /* @__PURE__ */ new Set([
178
181
  "autonomyDelayMs",
179
182
  "autoProceedMaxIterations",
@@ -208,6 +211,33 @@ function validatePreferenceValue(key, value) {
208
211
  if (STRING_ARRAY_PREF_KEYS.has(key)) {
209
212
  return Array.isArray(value) && value.every((v) => typeof v === "string") ? null : `prefs.update payload.${key} must be an array of strings`;
210
213
  }
214
+ if (STRING_ARRAY_RECORD_PREF_KEYS.has(key)) {
215
+ return isRecord(value) && Object.values(value).every(
216
+ (v) => Array.isArray(v) && v.every((item) => typeof item === "string")
217
+ ) ? null : `prefs.update payload.${key} must be an object of string arrays`;
218
+ }
219
+ if (MODEL_MATRIX_PREF_KEYS.has(key)) {
220
+ if (!isRecord(value)) return `prefs.update payload.${key} must be an object`;
221
+ for (const entry of Object.values(value)) {
222
+ if (!isRecord(entry)) return `prefs.update payload.${key} entries must be objects`;
223
+ const provider = entry["provider"];
224
+ const model = entry["model"];
225
+ const fallbackProfile = entry["fallbackProfile"];
226
+ if (provider !== void 0 && typeof provider !== "string") {
227
+ return `prefs.update payload.${key}.provider must be a string when provided`;
228
+ }
229
+ if (model !== void 0 && typeof model !== "string") {
230
+ return `prefs.update payload.${key}.model must be a string when provided`;
231
+ }
232
+ if (fallbackProfile !== void 0 && typeof fallbackProfile !== "string") {
233
+ return `prefs.update payload.${key}.fallbackProfile must be a string when provided`;
234
+ }
235
+ if (model === void 0 && fallbackProfile === void 0) {
236
+ return `prefs.update payload.${key} entries require model or fallbackProfile`;
237
+ }
238
+ }
239
+ return null;
240
+ }
211
241
  const allowed = ENUM_PREF_KEYS[key];
212
242
  if (allowed) {
213
243
  return typeof value === "string" && allowed.has(value) ? null : `prefs.update payload.${key} must be one of: ${Array.from(allowed).join(", ")}`;
@@ -436,8 +466,8 @@ function validateShellOpenPayload(payload) {
436
466
  if (!isRecord(payload)) {
437
467
  return { ok: false, message: "shell.open payload must be an object with string path" };
438
468
  }
439
- const path17 = payload["path"];
440
- if (typeof path17 !== "string" || path17.trim().length === 0) {
469
+ const path18 = payload["path"];
470
+ if (typeof path18 !== "string" || path18.trim().length === 0) {
441
471
  return { ok: false, message: "shell.open payload.path must be a non-empty string" };
442
472
  }
443
473
  const target = payload["target"];
@@ -447,20 +477,20 @@ function validateShellOpenPayload(payload) {
447
477
  message: 'shell.open payload.target must be "file" or "terminal" when provided'
448
478
  };
449
479
  }
450
- return { ok: true, value: { path: path17, target } };
480
+ return { ok: true, value: { path: path18, target } };
451
481
  }
452
482
  function validateGitDiffPayload(payload) {
453
483
  if (!isRecord(payload)) {
454
484
  return { ok: false, message: "git.diff payload must be an object" };
455
485
  }
456
- const path17 = payload["path"];
457
- if (path17 === void 0 || path17 === null) {
486
+ const path18 = payload["path"];
487
+ if (path18 === void 0 || path18 === null) {
458
488
  return { ok: true, value: { path: "" } };
459
489
  }
460
- if (typeof path17 !== "string") {
490
+ if (typeof path18 !== "string") {
461
491
  return { ok: false, message: "git.diff payload.path must be a string when provided" };
462
492
  }
463
- return { ok: true, value: { path: path17 } };
493
+ return { ok: true, value: { path: path18 } };
464
494
  }
465
495
  function validateProjectsAddPayload(payload) {
466
496
  if (!isRecord(payload)) {
@@ -710,8 +740,57 @@ async function handleWorklistMessage(ctx, ws, msg) {
710
740
 
711
741
  // src/server/index.ts
712
742
  import { makeMailboxTool, makeMailSendTool, makeMailInboxTool, mailboxSessionTag } from "@wrongstack/core";
713
- import { toErrorMessage as toErrorMessage6, wstackGlobalRoot as wstackGlobalRoot2, projectHash } from "@wrongstack/core/utils";
743
+ import { toErrorMessage as toErrorMessage6, wstackGlobalRoot as wstackGlobalRoot3, projectHash } from "@wrongstack/core/utils";
714
744
  import { SkillInstaller } from "@wrongstack/core/skills";
745
+
746
+ // src/server/discover-mailbox-bridge.ts
747
+ import { resolveProjectDir, wstackGlobalRoot } from "@wrongstack/core";
748
+ import { readLiveLock } from "@wrongstack/core/coordination";
749
+ async function discoverMailboxBridgeForWebui(params) {
750
+ const mode = params.config?.features?.mailboxBridge ?? "auto";
751
+ if (mode === "off") return;
752
+ const projectDir = resolveProjectDir(params.projectRoot, wstackGlobalRoot());
753
+ const result = await readLiveLock(projectDir);
754
+ switch (result.kind) {
755
+ case "live": {
756
+ params.logger.debug("webui joined existing mailbox bridge", {
757
+ url: result.lock.url,
758
+ lockPath: projectDir
759
+ });
760
+ params.ctx.meta["mailboxBridge"] = {
761
+ url: result.lock.url,
762
+ token: result.lock.token,
763
+ lockPath: projectDir,
764
+ childPid: null,
765
+ source: "joined"
766
+ };
767
+ break;
768
+ }
769
+ case "probe-failed": {
770
+ params.logger.warn(
771
+ "mailbox bridge present but /healthz unreachable; webui will start without external-agent connectivity",
772
+ { url: result.lock.url, lockPath: projectDir }
773
+ );
774
+ params.ctx.meta["mailboxBridge"] = {
775
+ url: result.lock.url,
776
+ token: result.lock.token,
777
+ lockPath: projectDir,
778
+ childPid: null,
779
+ source: "unhealthy"
780
+ };
781
+ break;
782
+ }
783
+ case "absent": {
784
+ params.logger.info(
785
+ "no mailbox bridge running; webui will start without external-agent connectivity. Run `wstack mailbox serve` or a CLI surface to bring one up.",
786
+ { projectDir }
787
+ );
788
+ break;
789
+ }
790
+ }
791
+ }
792
+
793
+ // src/server/index.ts
715
794
  import {
716
795
  BrainMonitor,
717
796
  DefaultBrainArbiter,
@@ -719,8 +798,9 @@ import {
719
798
  createAutonomyBrain,
720
799
  createTieredBrainArbiter
721
800
  } from "@wrongstack/core";
722
- import * as fs13 from "fs/promises";
723
- import * as path16 from "path";
801
+ import * as fs14 from "fs/promises";
802
+ import * as path17 from "path";
803
+ import { createRequire as createRequire2 } from "module";
724
804
 
725
805
  // src/server/http-server.ts
726
806
  import * as fs from "fs/promises";
@@ -897,7 +977,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
897
977
  return;
898
978
  }
899
979
  try {
900
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
980
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths4, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
901
981
  const registry = new SessionRegistry(globalRoot);
902
982
  const entry = await registry.get(sessionId);
903
983
  if (!entry) {
@@ -905,7 +985,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
905
985
  res.end(JSON.stringify({ error: "Session not found" }));
906
986
  return;
907
987
  }
908
- const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
988
+ const paths = resolveWstackPaths4({ projectRoot: entry.projectRoot, globalRoot });
909
989
  const store = new DefaultSessionStore3({ dir: paths.projectSessions });
910
990
  const reader = new DefaultSessionReader2({ store });
911
991
  const rawEntries = [];
@@ -932,7 +1012,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
932
1012
  }
933
1013
  }
934
1014
  function readJsonBody(req) {
935
- return new Promise((resolve9, reject) => {
1015
+ return new Promise((resolve10, reject) => {
936
1016
  let data = "";
937
1017
  req.on("data", (chunk) => {
938
1018
  data += chunk;
@@ -943,7 +1023,7 @@ function readJsonBody(req) {
943
1023
  });
944
1024
  req.on("end", () => {
945
1025
  try {
946
- resolve9(data ? JSON.parse(data) : {});
1026
+ resolve10(data ? JSON.parse(data) : {});
947
1027
  } catch (err) {
948
1028
  reject(err instanceof Error ? err : new Error(String(err)));
949
1029
  }
@@ -979,7 +1059,7 @@ async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
979
1059
  const priority = ["low", "normal", "high"].includes(rawPriority) ? rawPriority : "high";
980
1060
  const subject = typeof body["subject"] === "string" && body["subject"].trim() ? body["subject"].trim() : "Message from Fleet HQ";
981
1061
  try {
982
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1062
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths4, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
983
1063
  const registry = new SessionRegistry(globalRoot);
984
1064
  const entry = await registry.get(sessionId);
985
1065
  if (!entry) {
@@ -987,7 +1067,7 @@ async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
987
1067
  res.end(JSON.stringify({ error: "Session not found" }));
988
1068
  return;
989
1069
  }
990
- const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
1070
+ const paths = resolveWstackPaths4({ projectRoot: entry.projectRoot, globalRoot });
991
1071
  const mailbox = new GlobalMailbox3(paths.projectDir);
992
1072
  const to = `leader@${mailboxSessionTag2(sessionId)}`;
993
1073
  const sent = await mailbox.send({ from, to, type, subject, body: text, priority });
@@ -1005,7 +1085,7 @@ async function handleApiSessionMailbox(res, globalRoot, sessionId) {
1005
1085
  return;
1006
1086
  }
1007
1087
  try {
1008
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1088
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths4, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1009
1089
  const registry = new SessionRegistry(globalRoot);
1010
1090
  const entry = await registry.get(sessionId);
1011
1091
  if (!entry) {
@@ -1013,7 +1093,7 @@ async function handleApiSessionMailbox(res, globalRoot, sessionId) {
1013
1093
  res.end(JSON.stringify({ error: "Session not found" }));
1014
1094
  return;
1015
1095
  }
1016
- const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
1096
+ const paths = resolveWstackPaths4({ projectRoot: entry.projectRoot, globalRoot });
1017
1097
  const mailbox = new GlobalMailbox3(paths.projectDir);
1018
1098
  const leaderAddr = `leader@${mailboxSessionTag2(sessionId)}`;
1019
1099
  const [inbound, outbound] = await Promise.all([
@@ -1063,7 +1143,7 @@ async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
1063
1143
  const reason = typeof body["reason"] === "string" && body["reason"].trim() ? body["reason"].trim() : "Operator requested stop from Fleet HQ";
1064
1144
  const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
1065
1145
  try {
1066
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1146
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths4, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1067
1147
  const registry = new SessionRegistry(globalRoot);
1068
1148
  const entry = await registry.get(sessionId);
1069
1149
  if (!entry) {
@@ -1071,7 +1151,7 @@ async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
1071
1151
  res.end(JSON.stringify({ error: "Session not found" }));
1072
1152
  return;
1073
1153
  }
1074
- const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
1154
+ const paths = resolveWstackPaths4({ projectRoot: entry.projectRoot, globalRoot });
1075
1155
  const mailbox = new GlobalMailbox3(paths.projectDir);
1076
1156
  const to = `leader@${mailboxSessionTag2(sessionId)}`;
1077
1157
  const sent = await mailbox.send({
@@ -1111,7 +1191,7 @@ async function handleApiFleetBroadcast(res, req, globalRoot) {
1111
1191
  }
1112
1192
  const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
1113
1193
  try {
1114
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1194
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths4, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1115
1195
  const registry = new SessionRegistry(globalRoot);
1116
1196
  const all = await registry.list();
1117
1197
  const mySlug = all.find((s) => s.pid === process.pid)?.projectSlug;
@@ -1123,7 +1203,7 @@ async function handleApiFleetBroadcast(res, req, globalRoot) {
1123
1203
  }
1124
1204
  const mbByDir = /* @__PURE__ */ new Map();
1125
1205
  const mailboxFor = (projectRoot) => {
1126
- const dir = resolveWstackPaths2({ projectRoot, globalRoot }).projectDir;
1206
+ const dir = resolveWstackPaths4({ projectRoot, globalRoot }).projectDir;
1127
1207
  let mb = mbByDir.get(dir);
1128
1208
  if (!mb) {
1129
1209
  mb = new GlobalMailbox3(dir);
@@ -1167,7 +1247,7 @@ function isTrustedLoopbackOrigin(origin) {
1167
1247
  try {
1168
1248
  const url = new URL(origin);
1169
1249
  if (url.protocol !== "http:" && url.protocol !== "https:") return false;
1170
- return url.hostname === "localhost" || url.hostname === "127.0.0.1";
1250
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
1171
1251
  } catch {
1172
1252
  return false;
1173
1253
  }
@@ -1178,6 +1258,14 @@ function isLoopbackBind(wsHost) {
1178
1258
  function isWildcardBind(wsHost) {
1179
1259
  return wsHost === "0.0.0.0" || wsHost === "::" || wsHost === "[::]";
1180
1260
  }
1261
+ function normalizeHostname(hostname) {
1262
+ const h = hostname.trim().toLowerCase();
1263
+ return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
1264
+ }
1265
+ function allowedHostname(hostname, allowedHostnames) {
1266
+ const normalized = normalizeHostname(hostname);
1267
+ return (allowedHostnames ?? []).some((candidate) => normalizeHostname(candidate) === normalized);
1268
+ }
1181
1269
  function tokenMatches(provided, expected) {
1182
1270
  if (!provided) return false;
1183
1271
  const a = Buffer.from(provided);
@@ -1216,28 +1304,37 @@ function hostHeaderOk(input) {
1216
1304
  } catch {
1217
1305
  return false;
1218
1306
  }
1219
- return isLoopbackHostname(hostname);
1307
+ return isLoopbackHostname(hostname) || allowedHostname(hostname, input.allowedHostnames);
1220
1308
  }
1221
1309
  function verifyClient(input) {
1222
- const { origin, url, hostHeader, remoteAddress, cookieHeader, wsHost, expectedToken } = input;
1310
+ const {
1311
+ origin,
1312
+ url,
1313
+ hostHeader,
1314
+ remoteAddress,
1315
+ cookieHeader,
1316
+ wsHost,
1317
+ expectedToken,
1318
+ requireToken,
1319
+ allowedHostnames,
1320
+ allowBrowserUrlToken
1321
+ } = input;
1223
1322
  const urlTokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
1224
1323
  const cookieTokenOk = tokenMatches(extractTokenFromCookie(cookieHeader), expectedToken);
1225
- if (!hostHeaderOk({ hostHeader, wsHost })) return false;
1324
+ if (!hostHeaderOk({ hostHeader, wsHost, allowedHostnames })) return false;
1226
1325
  if (!origin) {
1227
1326
  const remoteIp = remoteAddress ?? "";
1228
1327
  const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
1229
1328
  if (!isRemoteLoopback && isWildcardBind(wsHost)) return false;
1230
- return urlTokenOk || cookieTokenOk || isLoopbackBind(wsHost);
1329
+ return urlTokenOk || cookieTokenOk || isLoopbackBind(wsHost) && !requireToken;
1231
1330
  }
1232
1331
  try {
1233
- const { hostname } = new URL(origin);
1234
- if (isLoopbackHostname(hostname)) {
1235
- if (isWildcardBind(wsHost) && !isTrustedLoopbackOrigin(origin)) {
1236
- return false;
1237
- }
1238
- return true;
1332
+ const { hostname: originHostname } = new URL(origin);
1333
+ if (isLoopbackHostname(originHostname)) {
1334
+ if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
1335
+ return isTrustedLoopbackOrigin(origin);
1239
1336
  }
1240
- return cookieTokenOk;
1337
+ return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
1241
1338
  } catch {
1242
1339
  return false;
1243
1340
  }
@@ -1263,8 +1360,69 @@ function injectWsPort(html, wsPort) {
1263
1360
  return `${tag}
1264
1361
  ${html}`;
1265
1362
  }
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'`;
1363
+ function escapeHtmlAttr(value) {
1364
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1365
+ }
1366
+ function injectWsConfig(html, opts) {
1367
+ let out = injectWsPort(html, opts.wsPort);
1368
+ if (!opts.publicWsUrl || out.includes('name="wrongstack-ws-url"')) return out;
1369
+ const tag = `<meta name="wrongstack-ws-url" content="${escapeHtmlAttr(opts.publicWsUrl)}" />`;
1370
+ if (out.includes("</head>")) {
1371
+ return out.replace("</head>", ` ${tag}
1372
+ </head>`);
1373
+ }
1374
+ return `${tag}
1375
+ ${out}`;
1376
+ }
1377
+ function firstHeader(value) {
1378
+ return Array.isArray(value) ? value[0] : value;
1379
+ }
1380
+ function wsTokenCookie(token) {
1381
+ return `ws_token=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
1382
+ }
1383
+ function requestToken(req, url) {
1384
+ return url.searchParams.get("token") ?? firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
1385
+ }
1386
+ function requestHostForCsp(hostHeader) {
1387
+ const raw = firstHeader(hostHeader)?.trim();
1388
+ if (!raw) return void 0;
1389
+ try {
1390
+ return new URL(`http://${raw}`).hostname;
1391
+ } catch {
1392
+ return void 0;
1393
+ }
1394
+ }
1395
+ function formatCspHostname(hostname) {
1396
+ return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
1397
+ }
1398
+ function cspSourceFromUrl(rawUrl) {
1399
+ try {
1400
+ const url = new URL(rawUrl);
1401
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") return void 0;
1402
+ return `${url.protocol}//${formatCspHostname(url.hostname)}${url.port ? `:${url.port}` : ""}`;
1403
+ } catch {
1404
+ return void 0;
1405
+ }
1406
+ }
1407
+ var ALLOWED_INLINE_SCRIPT_HASHES = [
1408
+ "'sha256-6PXDy0zrpXa6mvYOl11bZ8nubNUL7ushPUhGDZtaexg='",
1409
+ "'sha256-6sIdwbEBx7jj0drqSHHm7MqvmoYD3CQ4lp8Zp8blcb0='"
1410
+ ];
1411
+ function buildCspHeader(wsPort, requestHost, publicWsUrl) {
1412
+ const connect = /* @__PURE__ */ new Set([
1413
+ "'self'",
1414
+ `ws://127.0.0.1:${wsPort}`,
1415
+ `wss://127.0.0.1:${wsPort}`
1416
+ ]);
1417
+ if (requestHost && requestHost !== "127.0.0.1" && requestHost !== "::1" && requestHost !== "[::1]") {
1418
+ const host = formatCspHostname(requestHost);
1419
+ connect.add(`ws://${host}:${wsPort}`);
1420
+ connect.add(`wss://${host}:${wsPort}`);
1421
+ }
1422
+ const publicWsSource = publicWsUrl ? cspSourceFromUrl(publicWsUrl) : void 0;
1423
+ if (publicWsSource) connect.add(publicWsSource);
1424
+ const scriptSrc = ["'self'", ...ALLOWED_INLINE_SCRIPT_HASHES].join(" ");
1425
+ 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
1426
  }
1269
1427
  function isInsideDist(candidate, distDir) {
1270
1428
  const root = path.resolve(distDir);
@@ -1282,12 +1440,15 @@ function createHttpServer(opts) {
1282
1440
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
1283
1441
  const distDir = path.resolve(opts.distDir);
1284
1442
  const wsPort = opts.wsPort;
1285
- const requireApiToken = !isLoopbackBind(opts.host) && Boolean(opts.apiToken);
1443
+ const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
1286
1444
  return http.createServer(async (req, res) => {
1287
1445
  try {
1288
1446
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
1447
+ const providedAccessToken = requestToken(req, url);
1448
+ const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
1449
+ const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
1289
1450
  if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
1290
- const provided = url.searchParams.get("token") ?? req.headers["x-ws-token"];
1451
+ const provided = requestToken(req, url);
1291
1452
  if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
1292
1453
  res.writeHead(401, { "Content-Type": "text/plain" });
1293
1454
  res.end("Unauthorized");
@@ -1295,7 +1456,7 @@ function createHttpServer(opts) {
1295
1456
  }
1296
1457
  res.writeHead(200, {
1297
1458
  "Content-Type": "text/plain",
1298
- "Set-Cookie": `ws_token=${encodeURIComponent(opts.apiToken)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`,
1459
+ "Set-Cookie": wsTokenCookie(opts.apiToken),
1299
1460
  // Belt-and-braces: tell any caches the cookie response itself
1300
1461
  // is sensitive.
1301
1462
  "Cache-Control": "no-store"
@@ -1303,10 +1464,20 @@ function createHttpServer(opts) {
1303
1464
  res.end("ok");
1304
1465
  return;
1305
1466
  }
1467
+ if (requireAccessToken && !accessTokenOk) {
1468
+ res.writeHead(401, {
1469
+ "Content-Type": "text/plain",
1470
+ "Cache-Control": "no-store"
1471
+ });
1472
+ res.end("Unauthorized");
1473
+ return;
1474
+ }
1475
+ if (shouldSetAuthCookie && opts.apiToken) {
1476
+ res.setHeader("Set-Cookie", wsTokenCookie(opts.apiToken));
1477
+ res.setHeader("Cache-Control", "no-store");
1478
+ }
1306
1479
  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 ?? "")) {
1480
+ if (requireAccessToken && !accessTokenOk) {
1310
1481
  res.writeHead(401, { "Content-Type": "application/json" });
1311
1482
  res.end(JSON.stringify({ error: "Unauthorized" }));
1312
1483
  return;
@@ -1320,9 +1491,7 @@ function createHttpServer(opts) {
1320
1491
  return;
1321
1492
  }
1322
1493
  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 ?? "")) {
1494
+ if (requireAccessToken && !accessTokenOk) {
1326
1495
  res.writeHead(401, { "Content-Type": "application/json" });
1327
1496
  res.end(JSON.stringify({ error: "Unauthorized" }));
1328
1497
  return;
@@ -1332,9 +1501,7 @@ function createHttpServer(opts) {
1332
1501
  }
1333
1502
  const agentsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/agents$/);
1334
1503
  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 ?? "")) {
1504
+ if (requireAccessToken && !accessTokenOk) {
1338
1505
  res.writeHead(401, { "Content-Type": "application/json" });
1339
1506
  res.end(JSON.stringify({ error: "Unauthorized" }));
1340
1507
  return;
@@ -1344,9 +1511,7 @@ function createHttpServer(opts) {
1344
1511
  }
1345
1512
  const eventsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
1346
1513
  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 ?? "")) {
1514
+ if (requireAccessToken && !accessTokenOk) {
1350
1515
  res.writeHead(401, { "Content-Type": "application/json" });
1351
1516
  res.end(JSON.stringify({ error: "Unauthorized" }));
1352
1517
  return;
@@ -1358,9 +1523,7 @@ function createHttpServer(opts) {
1358
1523
  }
1359
1524
  const msgMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/message$/);
1360
1525
  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 ?? "")) {
1526
+ if (requireAccessToken && !accessTokenOk) {
1364
1527
  res.writeHead(401, { "Content-Type": "application/json" });
1365
1528
  res.end(JSON.stringify({ error: "Unauthorized" }));
1366
1529
  return;
@@ -1370,9 +1533,7 @@ function createHttpServer(opts) {
1370
1533
  }
1371
1534
  const mailboxMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/mailbox$/);
1372
1535
  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 ?? "")) {
1536
+ if (requireAccessToken && !accessTokenOk) {
1376
1537
  res.writeHead(401, { "Content-Type": "application/json" });
1377
1538
  res.end(JSON.stringify({ error: "Unauthorized" }));
1378
1539
  return;
@@ -1382,9 +1543,7 @@ function createHttpServer(opts) {
1382
1543
  }
1383
1544
  const interruptMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/interrupt$/);
1384
1545
  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 ?? "")) {
1546
+ if (requireAccessToken && !accessTokenOk) {
1388
1547
  res.writeHead(401, { "Content-Type": "application/json" });
1389
1548
  res.end(JSON.stringify({ error: "Unauthorized" }));
1390
1549
  return;
@@ -1398,9 +1557,7 @@ function createHttpServer(opts) {
1398
1557
  return;
1399
1558
  }
1400
1559
  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 ?? "")) {
1560
+ if (requireAccessToken && !accessTokenOk) {
1404
1561
  res.writeHead(401, { "Content-Type": "application/json" });
1405
1562
  res.end(JSON.stringify({ error: "Unauthorized" }));
1406
1563
  return;
@@ -1447,11 +1604,14 @@ function createHttpServer(opts) {
1447
1604
  res.setHeader("X-Frame-Options", "DENY");
1448
1605
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
1449
1606
  if (ext === ".html") {
1450
- res.setHeader("Cache-Control", "no-cache");
1451
- res.setHeader("Content-Security-Policy", buildCspHeader(wsPort));
1607
+ if (!shouldSetAuthCookie) res.setHeader("Cache-Control", "no-cache");
1608
+ res.setHeader(
1609
+ "Content-Security-Policy",
1610
+ buildCspHeader(wsPort, requestHostForCsp(req.headers.host), opts.publicWsUrl)
1611
+ );
1452
1612
  const html = await fs.readFile(resolvedPath, "utf8");
1453
1613
  res.writeHead(200);
1454
- res.end(injectWsPort(html, wsPort));
1614
+ res.end(injectWsConfig(html, { wsPort, publicWsUrl: opts.publicWsUrl }));
1455
1615
  return;
1456
1616
  }
1457
1617
  const fileContent = await fs.readFile(resolvedPath);
@@ -1466,9 +1626,13 @@ function createHttpServer(opts) {
1466
1626
  "X-Content-Type-Options": "nosniff",
1467
1627
  "X-Frame-Options": "DENY",
1468
1628
  "Referrer-Policy": "strict-origin-when-cross-origin",
1469
- "Content-Security-Policy": buildCspHeader(wsPort)
1629
+ "Content-Security-Policy": buildCspHeader(
1630
+ wsPort,
1631
+ requestHostForCsp(req.headers.host),
1632
+ opts.publicWsUrl
1633
+ )
1470
1634
  });
1471
- res.end(injectWsPort(html, wsPort));
1635
+ res.end(injectWsConfig(html, { wsPort, publicWsUrl: opts.publicWsUrl }));
1472
1636
  } catch {
1473
1637
  res.writeHead(404);
1474
1638
  res.end("Not found");
@@ -1700,6 +1864,37 @@ function errMessage(err) {
1700
1864
  function generateAuthToken() {
1701
1865
  return randomBytes(16).toString("hex");
1702
1866
  }
1867
+ function resolveAuthToken(explicit) {
1868
+ const configured = explicit?.trim() || process.env["WEBUI_TOKEN"]?.trim() || process.env["WEBUI_AUTH_TOKEN"]?.trim();
1869
+ return configured || generateAuthToken();
1870
+ }
1871
+ function hostForBrowserUrl(bindHost) {
1872
+ if (bindHost === "0.0.0.0") return "127.0.0.1";
1873
+ if (bindHost === "::" || bindHost === "[::]") return "[::1]";
1874
+ if (bindHost.includes(":") && !bindHost.startsWith("[")) return `[${bindHost}]`;
1875
+ return bindHost;
1876
+ }
1877
+ function buildWebUIAccessUrl(opts) {
1878
+ const protocol = opts.protocol ?? "http";
1879
+ const base = opts.publicUrl?.trim() || `${protocol}://${hostForBrowserUrl(opts.host)}:${opts.port}`;
1880
+ if (!opts.token) return base;
1881
+ try {
1882
+ const url = new URL(base);
1883
+ url.searchParams.set("token", opts.token);
1884
+ const rendered = url.toString();
1885
+ const afterOrigin = base.slice(url.origin.length);
1886
+ if (url.pathname === "/" && !afterOrigin.startsWith("/")) {
1887
+ return `${url.origin}${url.search}${url.hash}`;
1888
+ }
1889
+ return rendered;
1890
+ } catch {
1891
+ return `${base}${base.includes("?") ? "&" : "?"}token=${encodeURIComponent(opts.token)}`;
1892
+ }
1893
+ }
1894
+ function envFlag(name2) {
1895
+ const value = process.env[name2]?.trim().toLowerCase();
1896
+ return value === "1" || value === "true" || value === "yes" || value === "on";
1897
+ }
1703
1898
 
1704
1899
  // src/server/file-handlers.ts
1705
1900
  async function resolveFileInsideProject(projectRoot, filePath) {
@@ -1744,6 +1939,16 @@ async function realpathAllowMissing(p) {
1744
1939
  }
1745
1940
  }
1746
1941
  }
1942
+ function validatedPayload(msg, label) {
1943
+ if (msg == null || typeof msg !== "object") {
1944
+ throw new TypeError(`Expected object for ${label}, got ${msg}`);
1945
+ }
1946
+ const payload = msg.payload;
1947
+ if (payload == null || typeof payload !== "object") {
1948
+ throw new TypeError(`Expected payload object for ${label}, got ${payload}`);
1949
+ }
1950
+ return payload;
1951
+ }
1747
1952
  async function handleFilesTree(ws, msg, projectRoot) {
1748
1953
  const payload = msg.payload;
1749
1954
  const rawPath = payload?.path?.trim();
@@ -1814,7 +2019,13 @@ async function handleFilesTree(ws, msg, projectRoot) {
1814
2019
  }
1815
2020
  }
1816
2021
  async function handleFilesRead(ws, msg, projectRoot) {
1817
- const { filePath } = msg.payload;
2022
+ let filePath;
2023
+ try {
2024
+ ({ filePath } = validatedPayload(msg, "files.read"));
2025
+ } catch {
2026
+ send(ws, { type: "files.read", payload: { filePath: "", content: "", error: "Malformed request" } });
2027
+ return;
2028
+ }
1818
2029
  let realResolved;
1819
2030
  try {
1820
2031
  realResolved = await resolveFileInsideProject(projectRoot, filePath);
@@ -1833,7 +2044,14 @@ async function handleFilesRead(ws, msg, projectRoot) {
1833
2044
  }
1834
2045
  }
1835
2046
  async function handleFilesWrite(ws, msg, projectRoot, opts = {}) {
1836
- const { filePath, content } = msg.payload;
2047
+ let filePath;
2048
+ let content;
2049
+ try {
2050
+ ({ filePath, content } = validatedPayload(msg, "files.write"));
2051
+ } catch {
2052
+ send(ws, { type: "files.written", payload: { filePath: "", success: false, error: "Malformed request" } });
2053
+ return;
2054
+ }
1837
2055
  let realResolved;
1838
2056
  try {
1839
2057
  realResolved = await resolveFileInsideProject(projectRoot, filePath);
@@ -2671,7 +2889,7 @@ import { promises as fs5 } from "fs";
2671
2889
  import path6 from "path";
2672
2890
  import JSZip from "jszip";
2673
2891
  import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
2674
- import { wstackGlobalRoot } from "@wrongstack/core/utils";
2892
+ import { wstackGlobalRoot as wstackGlobalRoot2 } from "@wrongstack/core/utils";
2675
2893
  async function handleSkillsList(ws, ctx) {
2676
2894
  if (!ctx.skillLoader) {
2677
2895
  send(ws, { type: "skills.list", payload: { skills: [], enabled: false } });
@@ -2841,7 +3059,7 @@ async function handleSkillsCreate(ws, ctx, msg) {
2841
3059
  }
2842
3060
  const createPayload = parsed.value;
2843
3061
  try {
2844
- const targetDir = createPayload.scope === "global" ? path6.join(wstackGlobalRoot(), "skills", createPayload.name.trim()) : path6.join(ctx.projectRoot, ".wrongstack", "skills", createPayload.name.trim());
3062
+ const targetDir = createPayload.scope === "global" ? path6.join(wstackGlobalRoot2(), "skills", createPayload.name.trim()) : path6.join(ctx.projectRoot, ".wrongstack", "skills", createPayload.name.trim());
2845
3063
  try {
2846
3064
  await fs5.access(targetDir);
2847
3065
  send(ws, { type: "skills.created", payload: { success: false, error: `Skill "${createPayload.name}" already exists` } });
@@ -2961,6 +3179,416 @@ async function handleSkillsExport(ws, ctx) {
2961
3179
  }
2962
3180
  }
2963
3181
 
3182
+ // src/server/prompts-handlers.ts
3183
+ function parseVariablesPayload(raw) {
3184
+ if (!Array.isArray(raw)) return void 0;
3185
+ const out = [];
3186
+ for (const item of raw) {
3187
+ if (!item || typeof item !== "object") continue;
3188
+ const o = item;
3189
+ if (typeof o["name"] !== "string" || !o["name"].trim()) continue;
3190
+ const enumVals = Array.isArray(o["enum"]) && o["enum"].every((x) => typeof x === "string") ? o["enum"].map((s) => s.trim()).filter(Boolean) : void 0;
3191
+ const v = { name: o["name"].trim() };
3192
+ if (typeof o["description"] === "string" && o["description"].trim()) {
3193
+ v.description = o["description"].trim();
3194
+ }
3195
+ if (o["required"] === true) v.required = true;
3196
+ if (o["multiline"] === true) v.multiline = true;
3197
+ if (enumVals && enumVals.length > 0) v.enum = enumVals;
3198
+ out.push(v);
3199
+ }
3200
+ return out.length > 0 ? out : void 0;
3201
+ }
3202
+ function toMeta(e) {
3203
+ return {
3204
+ id: e.id,
3205
+ slug: e.slug,
3206
+ title: e.title,
3207
+ description: e.description,
3208
+ category: e.category,
3209
+ tags: e.tags,
3210
+ source: e.source,
3211
+ favorite: e.favorite,
3212
+ variables: e.variables ?? []
3213
+ };
3214
+ }
3215
+ async function handlePromptsList(ws, ctx) {
3216
+ if (!ctx.promptLoader) {
3217
+ send(ws, { type: "prompts.list", payload: { enabled: false, prompts: [], categories: [] } });
3218
+ return;
3219
+ }
3220
+ try {
3221
+ const [all, categories] = await Promise.all([
3222
+ ctx.promptLoader.list(),
3223
+ ctx.promptLoader.categories()
3224
+ ]);
3225
+ send(ws, {
3226
+ type: "prompts.list",
3227
+ payload: { enabled: true, prompts: all.map(toMeta), categories }
3228
+ });
3229
+ } catch (err) {
3230
+ send(ws, {
3231
+ type: "prompts.list",
3232
+ payload: { enabled: true, prompts: [], categories: [], error: errMessage(err) }
3233
+ });
3234
+ }
3235
+ }
3236
+ async function handlePromptsSearch(ws, ctx, msg) {
3237
+ if (!ctx.promptLoader) {
3238
+ send(ws, { type: "prompts.search", payload: { enabled: false, prompts: [] } });
3239
+ return;
3240
+ }
3241
+ const payload = msg.payload ?? {};
3242
+ try {
3243
+ const results = await ctx.promptLoader.search(payload.query ?? "", {
3244
+ ...payload.category ? { category: payload.category } : {},
3245
+ limit: 50
3246
+ });
3247
+ send(ws, { type: "prompts.search", payload: { enabled: true, prompts: results.map(toMeta) } });
3248
+ } catch (err) {
3249
+ send(ws, {
3250
+ type: "prompts.search",
3251
+ payload: { enabled: true, prompts: [], error: errMessage(err) }
3252
+ });
3253
+ }
3254
+ }
3255
+ async function handlePromptsContent(ws, ctx, msg) {
3256
+ const slug = msg.payload?.slug;
3257
+ if (!ctx.promptLoader || !slug) {
3258
+ send(ws, {
3259
+ type: "prompts.content",
3260
+ payload: { slug: slug ?? "", found: false, content: "", variables: [] }
3261
+ });
3262
+ return;
3263
+ }
3264
+ try {
3265
+ const entry = await ctx.promptLoader.find(slug);
3266
+ if (!entry) {
3267
+ send(ws, {
3268
+ type: "prompts.content",
3269
+ payload: { slug, found: false, content: "", variables: [] }
3270
+ });
3271
+ return;
3272
+ }
3273
+ send(ws, {
3274
+ type: "prompts.content",
3275
+ payload: {
3276
+ slug: entry.slug,
3277
+ found: true,
3278
+ title: entry.title,
3279
+ content: entry.content,
3280
+ variables: entry.variables ?? [],
3281
+ category: entry.category,
3282
+ source: entry.source
3283
+ }
3284
+ });
3285
+ } catch (err) {
3286
+ send(ws, {
3287
+ type: "prompts.content",
3288
+ payload: { slug, found: false, content: "", variables: [], error: errMessage(err) }
3289
+ });
3290
+ }
3291
+ }
3292
+ async function handlePromptsFavorite(ws, ctx, msg) {
3293
+ const payload = msg.payload;
3294
+ if (!ctx.promptLoader || !payload?.slug) {
3295
+ send(ws, {
3296
+ type: "prompts.favorite",
3297
+ payload: { success: false, error: "Prompt library unavailable" }
3298
+ });
3299
+ return;
3300
+ }
3301
+ try {
3302
+ const updated = await ctx.promptLoader.setFavorite(payload.slug, payload.favorite !== false);
3303
+ if (!updated) {
3304
+ send(ws, {
3305
+ type: "prompts.favorite",
3306
+ payload: { success: false, error: "Prompt not found" }
3307
+ });
3308
+ return;
3309
+ }
3310
+ send(ws, {
3311
+ type: "prompts.favorite",
3312
+ payload: { success: true, slug: updated.slug, favorite: updated.favorite }
3313
+ });
3314
+ } catch (err) {
3315
+ send(ws, { type: "prompts.favorite", payload: { success: false, error: errMessage(err) } });
3316
+ }
3317
+ }
3318
+ async function handlePromptsCreate(ws, ctx, msg) {
3319
+ const p = msg.payload;
3320
+ if (!ctx.promptLoader || !p) {
3321
+ send(ws, {
3322
+ type: "prompts.created",
3323
+ payload: { success: false, error: "Prompt library unavailable" }
3324
+ });
3325
+ return;
3326
+ }
3327
+ const title = typeof p["title"] === "string" ? p["title"].trim() : "";
3328
+ const content = typeof p["content"] === "string" ? p["content"] : "";
3329
+ if (!title || !content) {
3330
+ send(ws, {
3331
+ type: "prompts.created",
3332
+ payload: { success: false, error: "Title and content are required" }
3333
+ });
3334
+ return;
3335
+ }
3336
+ try {
3337
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3338
+ const tags = Array.isArray(p["tags"]) ? p["tags"].filter((t) => typeof t === "string") : [];
3339
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "prompt";
3340
+ const variables = parseVariablesPayload(p["variables"]);
3341
+ const entry = {
3342
+ id: slug,
3343
+ slug,
3344
+ title,
3345
+ description: typeof p["description"] === "string" ? p["description"] : "",
3346
+ content,
3347
+ category: typeof p["category"] === "string" && p["category"] ? p["category"] : "uncategorized",
3348
+ tags,
3349
+ source: "user",
3350
+ favorite: false,
3351
+ ...variables ? { variables } : {},
3352
+ createdAt: now,
3353
+ updatedAt: now
3354
+ };
3355
+ await ctx.promptLoader.save(entry);
3356
+ send(ws, { type: "prompts.created", payload: { success: true, slug } });
3357
+ } catch (err) {
3358
+ send(ws, { type: "prompts.created", payload: { success: false, error: errMessage(err) } });
3359
+ }
3360
+ }
3361
+ async function handlePromptsUsed(ws, ctx, msg) {
3362
+ const slug = msg.payload?.slug;
3363
+ if (!ctx.promptUsage || !slug) {
3364
+ send(ws, { type: "prompts.used", payload: { success: false } });
3365
+ return;
3366
+ }
3367
+ try {
3368
+ await ctx.promptUsage.record(slug);
3369
+ send(ws, { type: "prompts.used", payload: { success: true, slug } });
3370
+ } catch {
3371
+ send(ws, { type: "prompts.used", payload: { success: false } });
3372
+ }
3373
+ }
3374
+ async function handlePromptsRecent(ws, ctx) {
3375
+ if (!ctx.promptUsage) {
3376
+ send(ws, { type: "prompts.recent", payload: { slugs: [] } });
3377
+ return;
3378
+ }
3379
+ try {
3380
+ const recent = await ctx.promptUsage.recent(50);
3381
+ send(ws, { type: "prompts.recent", payload: { slugs: recent.map((r) => r.slug) } });
3382
+ } catch (err) {
3383
+ send(ws, { type: "prompts.recent", payload: { slugs: [], error: errMessage(err) } });
3384
+ }
3385
+ }
3386
+
3387
+ // src/server/design-handlers.ts
3388
+ import * as fs6 from "fs/promises";
3389
+ import * as path7 from "path";
3390
+ import {
3391
+ applyTokenOverrides,
3392
+ getDesignKitLoader,
3393
+ getDesignState,
3394
+ isDesignStack,
3395
+ loadActiveKit,
3396
+ materializeTokens,
3397
+ recordKitChoice,
3398
+ recordOverrides,
3399
+ runDesignVerify,
3400
+ setActiveKit,
3401
+ setDesignOverrides
3402
+ } from "@wrongstack/core";
3403
+ function readOverrides(value) {
3404
+ const out = {};
3405
+ if (value && typeof value === "object") {
3406
+ for (const [k, v] of Object.entries(value)) {
3407
+ if (typeof v === "string") out[k] = v;
3408
+ }
3409
+ }
3410
+ return out;
3411
+ }
3412
+ var FOUNDATIONS_ID = "_foundations";
3413
+ async function buildListPayload(ctx) {
3414
+ const loader = getDesignKitLoader(ctx.projectRoot);
3415
+ const manifests = (await loader.list()).filter((k) => k.id !== FOUNDATIONS_ID);
3416
+ const kits = [];
3417
+ for (const m of manifests) {
3418
+ const tokens = await loader.readTokens(m.id);
3419
+ kits.push({
3420
+ id: m.id,
3421
+ name: m.name,
3422
+ aesthetic: m.aesthetic,
3423
+ bestFor: m.bestFor,
3424
+ stacks: m.stacks,
3425
+ tags: m.tags,
3426
+ light: tokens?.light ?? {},
3427
+ dark: tokens?.dark ?? {}
3428
+ });
3429
+ }
3430
+ const state = ctx.agentMeta ? getDesignState(ctx.agentMeta) : void 0;
3431
+ const persisted = await loadActiveKit(ctx.projectRoot).catch(() => void 0);
3432
+ return {
3433
+ kits,
3434
+ activeKit: state?.activeKit ?? persisted?.kit ?? null,
3435
+ stack: state?.stack ?? persisted?.stack ?? null,
3436
+ overrides: state?.overrides ?? persisted?.overrides ?? {}
3437
+ };
3438
+ }
3439
+ async function handleDesignList(ws, ctx) {
3440
+ try {
3441
+ send(ws, { type: "design.list", payload: await buildListPayload(ctx) });
3442
+ } catch (err) {
3443
+ send(ws, {
3444
+ type: "design.list",
3445
+ payload: { kits: [], activeKit: null, stack: null, error: String(err) }
3446
+ });
3447
+ }
3448
+ }
3449
+ async function handleDesignState(ws, ctx) {
3450
+ const state = ctx.agentMeta ? getDesignState(ctx.agentMeta) : void 0;
3451
+ send(ws, {
3452
+ type: "design.state",
3453
+ payload: {
3454
+ activeKit: state?.activeKit ?? null,
3455
+ stack: state?.stack ?? null,
3456
+ overrides: state?.overrides ?? {}
3457
+ }
3458
+ });
3459
+ }
3460
+ async function handleDesignUse(ws, ctx, msg) {
3461
+ const payload = msg.payload ?? {};
3462
+ const kitId = typeof payload.kit === "string" ? payload.kit.trim() : "";
3463
+ if (!kitId) {
3464
+ send(ws, { type: "design.use", payload: { ok: false, error: "No kit id provided" } });
3465
+ return;
3466
+ }
3467
+ try {
3468
+ const loader = getDesignKitLoader(ctx.projectRoot);
3469
+ const kit = await loader.find(kitId);
3470
+ if (!kit) {
3471
+ send(ws, { type: "design.use", payload: { ok: false, kit: kitId, error: "Kit not found" } });
3472
+ return;
3473
+ }
3474
+ const stackArg = typeof payload.stack === "string" ? payload.stack : void 0;
3475
+ const stack = stackArg && isDesignStack(stackArg) ? stackArg : kit.stacks[0] ?? "web";
3476
+ const persisted = await loadActiveKit(ctx.projectRoot).catch(() => void 0);
3477
+ const keep = persisted?.kit === kit.id ? persisted.overrides ?? {} : {};
3478
+ const overrides = { ...keep, ...readOverrides(payload.overrides) };
3479
+ if (ctx.agentMeta) setActiveKit(ctx.agentMeta, kit.id, stack, overrides);
3480
+ await recordKitChoice(
3481
+ ctx.projectRoot,
3482
+ kit.id,
3483
+ stack,
3484
+ "webui",
3485
+ (/* @__PURE__ */ new Date()).toISOString(),
3486
+ Object.keys(overrides).length ? overrides : void 0
3487
+ );
3488
+ const body = await loader.readBody(kit.id, stack);
3489
+ const rawTokens = await loader.readTokens(kit.id);
3490
+ const tokens = rawTokens ? applyTokenOverrides(rawTokens, overrides) : rawTokens;
3491
+ send(ws, {
3492
+ type: "design.use",
3493
+ payload: {
3494
+ ok: true,
3495
+ kit: kit.id,
3496
+ name: kit.name,
3497
+ aesthetic: kit.aesthetic,
3498
+ stack,
3499
+ body,
3500
+ overrides,
3501
+ light: tokens?.light ?? {},
3502
+ dark: tokens?.dark ?? {}
3503
+ }
3504
+ });
3505
+ } catch (err) {
3506
+ send(ws, { type: "design.use", payload: { ok: false, kit: kitId, error: String(err) } });
3507
+ }
3508
+ }
3509
+ async function handleDesignSet(ws, ctx, msg) {
3510
+ const patch = readOverrides(msg.payload?.overrides);
3511
+ if (Object.keys(patch).length === 0) {
3512
+ send(ws, { type: "design.set", payload: { ok: false, error: "No overrides provided" } });
3513
+ return;
3514
+ }
3515
+ try {
3516
+ const merged = await recordOverrides(ctx.projectRoot, patch, (/* @__PURE__ */ new Date()).toISOString());
3517
+ if (!merged) {
3518
+ send(ws, { type: "design.set", payload: { ok: false, error: "No active kit" } });
3519
+ return;
3520
+ }
3521
+ if (ctx.agentMeta) setDesignOverrides(ctx.agentMeta, merged);
3522
+ send(ws, { type: "design.set", payload: { ok: true, overrides: merged } });
3523
+ } catch (err) {
3524
+ send(ws, { type: "design.set", payload: { ok: false, error: String(err) } });
3525
+ }
3526
+ }
3527
+ async function handleDesignMaterialize(ws, ctx, msg) {
3528
+ const payload = msg.payload ?? {};
3529
+ try {
3530
+ const active = await loadActiveKit(ctx.projectRoot);
3531
+ if (!active) {
3532
+ send(ws, { type: "design.materialize", payload: { ok: false, error: "No active kit" } });
3533
+ return;
3534
+ }
3535
+ const loader = getDesignKitLoader(ctx.projectRoot);
3536
+ const stackArg = typeof payload.stack === "string" ? payload.stack : void 0;
3537
+ const stack = stackArg && isDesignStack(stackArg) ? stackArg : active.stack && isDesignStack(active.stack) ? active.stack : "web";
3538
+ const raw = await loader.readTokens(active.kit);
3539
+ if (!raw) {
3540
+ send(ws, { type: "design.materialize", payload: { ok: false, error: "Kit has no tokens" } });
3541
+ return;
3542
+ }
3543
+ const tokens = applyTokenOverrides(raw, active.overrides);
3544
+ const result = materializeTokens({
3545
+ tokens,
3546
+ stack,
3547
+ kitId: active.kit,
3548
+ outPath: typeof payload.out === "string" ? payload.out : void 0
3549
+ });
3550
+ const abs = path7.join(ctx.projectRoot, result.path);
3551
+ await fs6.mkdir(path7.dirname(abs), { recursive: true });
3552
+ await fs6.writeFile(abs, result.content);
3553
+ send(ws, {
3554
+ type: "design.materialize",
3555
+ payload: { ok: true, path: result.path, format: result.format, stack }
3556
+ });
3557
+ } catch (err) {
3558
+ send(ws, { type: "design.materialize", payload: { ok: false, error: String(err) } });
3559
+ }
3560
+ }
3561
+ async function handleDesignVerify(ws, ctx) {
3562
+ try {
3563
+ const active = await loadActiveKit(ctx.projectRoot);
3564
+ if (!active) {
3565
+ send(ws, { type: "design.verify", payload: { ok: false, error: "No active kit" } });
3566
+ return;
3567
+ }
3568
+ const loader = getDesignKitLoader(ctx.projectRoot);
3569
+ const raw = await loader.readTokens(active.kit);
3570
+ if (!raw) {
3571
+ send(ws, { type: "design.verify", payload: { ok: false, error: "Kit has no tokens" } });
3572
+ return;
3573
+ }
3574
+ const tokens = applyTokenOverrides(raw, active.overrides);
3575
+ const report = await runDesignVerify(ctx.projectRoot, tokens);
3576
+ send(ws, {
3577
+ type: "design.verify",
3578
+ payload: {
3579
+ ok: true,
3580
+ kit: active.kit,
3581
+ filesScanned: report.filesScanned,
3582
+ score: report.score,
3583
+ violations: report.violations.slice(0, 50),
3584
+ violationCount: report.violations.length
3585
+ }
3586
+ });
3587
+ } catch (err) {
3588
+ send(ws, { type: "design.verify", payload: { ok: false, error: String(err) } });
3589
+ }
3590
+ }
3591
+
2964
3592
  // src/server/index.ts
2965
3593
  import {
2966
3594
  Agent,
@@ -2972,6 +3600,8 @@ import {
2972
3600
  DefaultSessionReader,
2973
3601
  DefaultSessionStore as DefaultSessionStore2,
2974
3602
  DefaultSkillLoader,
3603
+ DefaultPromptLoader,
3604
+ PromptUsageStore,
2975
3605
  DefaultSystemPromptBuilder as DefaultSystemPromptBuilder3,
2976
3606
  DefaultTokenCounter,
2977
3607
  AnnotationsStore,
@@ -2986,17 +3616,20 @@ import {
2986
3616
  ToolRegistry,
2987
3617
  atomicWrite as atomicWrite6,
2988
3618
  createDefaultPipelines,
3619
+ installDesignStudioMiddleware,
2989
3620
  createSessionEventBridge,
2990
3621
  resolveSessionLoggingConfig,
2991
3622
  DEFAULT_CONTEXT_WINDOW_MODE_ID as DEFAULT_CONTEXT_WINDOW_MODE_ID2,
2992
3623
  DEFAULT_SESSION_PRUNE_DAYS,
2993
3624
  DEFAULT_TOOLS_CONFIG,
2994
3625
  applyToolDescriptionModes,
3626
+ applyToolResultRenderModes,
2995
3627
  resolveContextWindowPolicy as resolveContextWindowPolicy2,
2996
3628
  enhanceUserPrompt,
2997
3629
  gatedEnhancerReasoning,
2998
3630
  recentTextTurns,
2999
- resolveProviderModelList
3631
+ resolveProviderModelList,
3632
+ cleanupStaleSddWorktrees as cleanupStaleSddWorktrees3
3000
3633
  } from "@wrongstack/core";
3001
3634
  import { ToolExecutor } from "@wrongstack/core/execution";
3002
3635
  import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets as encryptConfigSecrets2 } from "@wrongstack/core/security";
@@ -3031,6 +3664,13 @@ import {
3031
3664
  PhaseStore,
3032
3665
  WorktreeManager
3033
3666
  } from "@wrongstack/core";
3667
+ function deriveTitle(goal) {
3668
+ const firstLine = goal.split("\n").map((l) => l.trim()).find(Boolean);
3669
+ if (!firstLine) return "AutoPhase";
3670
+ const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
3671
+ const trimmed = sentence.length <= 64 ? sentence : `${sentence.slice(0, 63).trimEnd()}\u2026`;
3672
+ return trimmed || "AutoPhase";
3673
+ }
3034
3674
  function isGitRepo(cwd) {
3035
3675
  try {
3036
3676
  const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, encoding: "utf8", windowsHide: true });
@@ -3039,6 +3679,19 @@ function isGitRepo(cwd) {
3039
3679
  return false;
3040
3680
  }
3041
3681
  }
3682
+ function commitsSince(cwd, baseSha, branch) {
3683
+ try {
3684
+ const r = spawnSync("git", ["log", "--reverse", "--format=%H", `${baseSha}..${branch}`], {
3685
+ cwd,
3686
+ encoding: "utf8",
3687
+ windowsHide: true
3688
+ });
3689
+ if (r.status !== 0) return [];
3690
+ return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
3691
+ } catch {
3692
+ return [];
3693
+ }
3694
+ }
3042
3695
  var AutoPhaseWebSocketHandler = class {
3043
3696
  constructor(agent, context, logger, storeDir, events, projectRoot) {
3044
3697
  this.agent = agent;
@@ -3058,10 +3711,17 @@ var AutoPhaseWebSocketHandler = class {
3058
3711
  store;
3059
3712
  clients = /* @__PURE__ */ new Set();
3060
3713
  broadcastInterval = null;
3061
- /** Aborts in-flight task agents when the run is stopped. */
3714
+ /** Aborts in-flight task agents AND the planning turn when the run is stopped. */
3062
3715
  abort = null;
3716
+ /** Set the instant a stop/clear/revert is requested, so a planning turn that
3717
+ * resolves afterwards never launches the orchestrator (the abort alone can't
3718
+ * cover the window between the LLM call resolving and the orchestrator start). */
3719
+ stopping = false;
3063
3720
  /** Optional per-phase git-worktree isolation (lazily created at start). */
3064
3721
  worktrees = null;
3722
+ /** Base branch + tip SHA captured at run start so a revert can git-revert the
3723
+ * run's squash commits (history-preserving) instead of a destructive reset. */
3724
+ runBase = null;
3065
3725
  /** Per-run worker identities so the board can show "who is on what". */
3066
3726
  usedNicknames = /* @__PURE__ */ new Set();
3067
3727
  addClient(ws) {
@@ -3085,11 +3745,13 @@ var AutoPhaseWebSocketHandler = class {
3085
3745
  this.broadcast({ type: "autophase.resumed", payload: {} });
3086
3746
  break;
3087
3747
  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: {} });
3748
+ await this.handleStop();
3749
+ break;
3750
+ case "autophase.clear":
3751
+ await this.handleClear();
3752
+ break;
3753
+ case "autophase.revert":
3754
+ await this.handleRevert();
3093
3755
  break;
3094
3756
  case "autophase.status":
3095
3757
  this.broadcastState();
@@ -3166,17 +3828,27 @@ var AutoPhaseWebSocketHandler = class {
3166
3828
  }
3167
3829
  }
3168
3830
  async handleStart(payload) {
3169
- const title = payload?.goal || payload?.title || "Untitled Project";
3831
+ const goal = payload?.goal || payload?.title || "Untitled Project";
3832
+ const title = deriveTitle(goal);
3170
3833
  const autonomous = payload?.autonomous ?? true;
3171
- const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(title);
3834
+ this.abort = new AbortController();
3835
+ this.stopping = false;
3836
+ const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(goal, this.abort.signal);
3837
+ if (this.stopping || this.abort.signal.aborted) {
3838
+ this.broadcast({ type: "autophase.stopped", payload: { title } });
3839
+ return;
3840
+ }
3172
3841
  this.logger.info(`[AutoPhase] Starting: ${title}`);
3173
- const graph = await new PhaseGraphBuilder({ title, phases, autonomous }).build();
3842
+ const graph = await new PhaseGraphBuilder({ title, description: goal, phases, autonomous }).build();
3174
3843
  this.graph = graph;
3175
- this.abort = new AbortController();
3176
3844
  await this.store.save(graph);
3177
- if (!this.worktrees && this.events && this.projectRoot && process.env["WRONGSTACK_AUTOPHASE_WORKTREES"] !== "0" && isGitRepo(this.projectRoot)) {
3845
+ const useWorktrees = payload?.worktrees ?? process.env["WRONGSTACK_AUTOPHASE_WORKTREES"] !== "0";
3846
+ if (!this.worktrees && this.events && this.projectRoot && useWorktrees && isGitRepo(this.projectRoot)) {
3178
3847
  this.worktrees = new WorktreeManager({ projectRoot: this.projectRoot, events: this.events });
3179
3848
  }
3849
+ if (this.worktrees) {
3850
+ this.runBase = await this.worktrees.currentBase();
3851
+ }
3180
3852
  this.orchestrator = new PhaseOrchestrator({
3181
3853
  graph,
3182
3854
  ctx: {
@@ -3223,6 +3895,62 @@ var AutoPhaseWebSocketHandler = class {
3223
3895
  this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
3224
3896
  });
3225
3897
  }
3898
+ /**
3899
+ * Halt the run NOW — at any phase. Sets `stopping` (so a planning turn that
3900
+ * resolves afterwards bails), aborts in-flight agents, stops the orchestrator
3901
+ * tick, and ends the live broadcast. The board is kept for review; use
3902
+ * `autophase.clear` to reset or `autophase.revert` to undo the changes.
3903
+ */
3904
+ async handleStop() {
3905
+ this.stopping = true;
3906
+ this.abort?.abort();
3907
+ this.orchestrator?.stop();
3908
+ this.stopBroadcast();
3909
+ if (this.graph) await this.store.save(this.graph).catch(() => void 0);
3910
+ this.broadcast({ type: "autophase.stopped", payload: { title: this.graph?.title } });
3911
+ }
3912
+ /**
3913
+ * Stop + wipe: tear down phase worktrees and reset to an empty board so the UI
3914
+ * returns to the start screen ("new one"). Does NOT touch already-merged commits
3915
+ * on the base branch — that is `autophase.revert`.
3916
+ */
3917
+ async handleClear() {
3918
+ await this.handleStop();
3919
+ if (this.worktrees) await this.worktrees.cleanupAllManaged().catch(() => void 0);
3920
+ this.orchestrator = null;
3921
+ this.graph = null;
3922
+ this.runBase = null;
3923
+ this.usedNicknames.clear();
3924
+ this.broadcast({ type: "autophase.cleared", payload: {} });
3925
+ this.broadcast({ type: "autophase.state", payload: this.buildState() });
3926
+ }
3927
+ /**
3928
+ * Stop + undo: remove phase worktrees, then history-preservingly `git revert`
3929
+ * every commit this run landed on the base branch (captured `runBase`..HEAD),
3930
+ * then reset to an empty board. Refuses (reports a reason) on a dirty tree or a
3931
+ * conflicting revert rather than leaving the tree half-reverted.
3932
+ */
3933
+ async handleRevert() {
3934
+ await this.handleStop();
3935
+ if (!this.worktrees || !this.runBase || !this.projectRoot) {
3936
+ this.broadcast({
3937
+ type: "autophase.reverted",
3938
+ payload: { ok: false, reverted: 0, reason: "no git baseline was captured for this run" }
3939
+ });
3940
+ return;
3941
+ }
3942
+ await this.worktrees.cleanupAllManaged().catch(() => void 0);
3943
+ const shas = commitsSince(this.projectRoot, this.runBase.sha, this.runBase.branch);
3944
+ const res = await this.worktrees.revertCommits(this.runBase.branch, shas);
3945
+ this.broadcast({ type: "autophase.reverted", payload: res });
3946
+ if (res.ok) {
3947
+ this.orchestrator = null;
3948
+ this.graph = null;
3949
+ this.runBase = null;
3950
+ this.broadcast({ type: "autophase.cleared", payload: {} });
3951
+ this.broadcast({ type: "autophase.state", payload: this.buildState() });
3952
+ }
3953
+ }
3226
3954
  /** Generic fallback phases when the LLM planner produces nothing usable. */
3227
3955
  defaultPhases() {
3228
3956
  return [
@@ -3233,13 +3961,18 @@ var AutoPhaseWebSocketHandler = class {
3233
3961
  { name: "Deployment", description: "Deploy to production", priority: "medium", estimateHours: 2, parallelizable: false }
3234
3962
  ];
3235
3963
  }
3236
- /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure. */
3237
- async planPhases(goal) {
3964
+ /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure.
3965
+ * The caller passes the run's abort signal so a stop during planning cancels
3966
+ * the LLM turn (the previous fresh, never-aborted controller made planning
3967
+ * uninterruptible). */
3968
+ async planPhases(goal, signal) {
3238
3969
  try {
3239
3970
  const planner = new AutoPhasePlanner({
3240
3971
  goal,
3241
3972
  runOnce: async (prompt) => {
3242
- const result = await this.agent.run(prompt, { signal: new AbortController().signal });
3973
+ const result = await this.agent.run(prompt, {
3974
+ signal: signal ?? new AbortController().signal
3975
+ });
3243
3976
  return result.status === "done" ? result.finalText ?? "" : "";
3244
3977
  }
3245
3978
  });
@@ -3374,6 +4107,10 @@ Type: ${task.type}`;
3374
4107
  const lastError = lastFailed ? `${lastFailed.name}: ${lastFailed.metadata?.integrationError ?? "phase failed"}` : null;
3375
4108
  return {
3376
4109
  title: this.graph.title,
4110
+ // Full operator prompt, shown verbatim in a dedicated goal block (the
4111
+ // title is only a short derived heading). Fall back to the title for
4112
+ // legacy boards saved before the title/goal split.
4113
+ goal: this.graph.description || this.graph.title,
3377
4114
  phases: phaseItems,
3378
4115
  tasks: taskItems,
3379
4116
  activePhaseId: currentActiveId,
@@ -3575,7 +4312,7 @@ var SpecsWebSocketHandler = class {
3575
4312
  };
3576
4313
 
3577
4314
  // src/server/sdd-board-ws-handler.ts
3578
- import { SddBoardStore } from "@wrongstack/core";
4315
+ import { applySddLifecycle, SddBoardStore } from "@wrongstack/core";
3579
4316
  var CONTROL_TYPES = /* @__PURE__ */ new Set([
3580
4317
  "pause",
3581
4318
  "resume",
@@ -3589,19 +4326,19 @@ var CONTROL_TYPES = /* @__PURE__ */ new Set([
3589
4326
  "set_task_verification",
3590
4327
  "cancel_task",
3591
4328
  "delete_task",
3592
- "split_task",
3593
- // Lifecycle (pair with a prior `stop`): sweep worktrees / revert merged commits.
3594
- "cleanup_worktrees",
3595
- "rollback"
4329
+ "split_task"
3596
4330
  ]);
4331
+ var LIFECYCLE_TYPES = /* @__PURE__ */ new Set(["cleanup_worktrees", "rollback", "destroy"]);
3597
4332
  var SddBoardWebSocketHandler = class {
3598
4333
  store;
3599
4334
  clients = /* @__PURE__ */ new Set();
4335
+ lifecycle;
3600
4336
  latest = null;
3601
4337
  poll = null;
3602
4338
  unsub = null;
3603
- constructor(boardsDir, events) {
4339
+ constructor(boardsDir, events, lifecycle) {
3604
4340
  this.store = new SddBoardStore({ baseDir: boardsDir });
4341
+ this.lifecycle = lifecycle;
3605
4342
  if (events) {
3606
4343
  const handler = (e) => {
3607
4344
  this.latest = e.snapshot;
@@ -3630,6 +4367,10 @@ var SddBoardWebSocketHandler = class {
3630
4367
  return;
3631
4368
  }
3632
4369
  const action = msg.type.replace(/^sdd\.board\./, "");
4370
+ if (LIFECYCLE_TYPES.has(action)) {
4371
+ await this.applyLifecycle(action, msg.payload);
4372
+ return;
4373
+ }
3633
4374
  if (CONTROL_TYPES.has(action)) {
3634
4375
  const runId = msg.payload?.runId ?? this.latest?.runId ?? (await this.store.list())[0]?.runId;
3635
4376
  if (runId) {
@@ -3641,6 +4382,40 @@ var SddBoardWebSocketHandler = class {
3641
4382
  }
3642
4383
  }
3643
4384
  }
4385
+ /**
4386
+ * Apply a cleanup/rollback/destroy from disk and broadcast a structured
4387
+ * `sdd.board.lifecycle_result`. Refuses (no-op) while a run is still active —
4388
+ * the user must stop it first; the UI gates the buttons on `!active` and the
4389
+ * Destroy flow auto-stops then waits before sending `destroy`.
4390
+ */
4391
+ async applyLifecycle(op, payload) {
4392
+ if (!this.lifecycle) {
4393
+ this.broadcast({
4394
+ type: "sdd.board.lifecycle_result",
4395
+ payload: { op, ok: false, reason: "Lifecycle operations are not available in this session." }
4396
+ });
4397
+ return;
4398
+ }
4399
+ if (this.latest && (this.latest.status === "running" || this.latest.status === "paused")) {
4400
+ this.broadcast({
4401
+ type: "sdd.board.lifecycle_result",
4402
+ payload: { op, ok: false, reason: "Stop the run first, then retry." }
4403
+ });
4404
+ return;
4405
+ }
4406
+ const runId = payload?.runId ?? this.latest?.runId;
4407
+ const result = await applySddLifecycle(op, {
4408
+ projectRoot: this.lifecycle.projectRoot,
4409
+ paths: this.lifecycle.paths,
4410
+ runId,
4411
+ revertMerged: payload?.revertMerged === true
4412
+ });
4413
+ this.broadcast({ type: "sdd.board.lifecycle_result", payload: result });
4414
+ if (op === "destroy" && result.ok) {
4415
+ this.latest = null;
4416
+ this.broadcast({ type: "sdd.board.snapshot", payload: null });
4417
+ }
4418
+ }
3644
4419
  dispose() {
3645
4420
  if (this.poll) clearInterval(this.poll);
3646
4421
  this.unsub?.();
@@ -3684,6 +4459,12 @@ var SddBoardWebSocketHandler = class {
3684
4459
  };
3685
4460
 
3686
4461
  // src/server/sdd-wizard-ws-handler.ts
4462
+ function deriveTitle2(goal) {
4463
+ const firstLine = goal.split("\n").map((l) => l.trim()).find(Boolean);
4464
+ if (!firstLine) return "New SDD Project";
4465
+ const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
4466
+ return sentence.length <= 64 ? sentence : `${sentence.slice(0, 63).trimEnd()}\u2026`;
4467
+ }
3687
4468
  var SddWizardWebSocketHandler = class {
3688
4469
  constructor(deps2) {
3689
4470
  this.deps = deps2;
@@ -3722,7 +4503,8 @@ var SddWizardWebSocketHandler = class {
3722
4503
  parallelSlots: msg.payload?.parallelSlots,
3723
4504
  defaultModel: msg.payload?.model,
3724
4505
  defaultProvider: msg.payload?.provider,
3725
- fallbackModels: Array.isArray(msg.payload?.fallbackModels) ? msg.payload?.fallbackModels : void 0
4506
+ fallbackModels: Array.isArray(msg.payload?.fallbackModels) ? msg.payload?.fallbackModels : void 0,
4507
+ worktrees: typeof msg.payload?.worktrees === "boolean" ? msg.payload.worktrees : void 0
3726
4508
  });
3727
4509
  break;
3728
4510
  }
@@ -3742,7 +4524,7 @@ var SddWizardWebSocketHandler = class {
3742
4524
  }
3743
4525
  if (this.busy) return;
3744
4526
  this.driver = this.deps.makeDriver();
3745
- const prompt = this.driver.start(goal);
4527
+ const prompt = this.driver.start(deriveTitle2(goal), goal);
3746
4528
  await this.runTurn(prompt);
3747
4529
  }
3748
4530
  async onMessage(text) {
@@ -3813,9 +4595,10 @@ var SddWizardWebSocketHandler = class {
3813
4595
  };
3814
4596
 
3815
4597
  // src/server/sdd-wizard-wiring.ts
3816
- import * as path7 from "path";
4598
+ import * as path8 from "path";
3817
4599
  import { spawnSync as spawnSync2 } from "child_process";
3818
4600
  import {
4601
+ cleanupStaleSddWorktrees,
3819
4602
  makeCommandVerifier,
3820
4603
  makeLlmSubtaskGenerator,
3821
4604
  SddBoardStore as SddBoardStore2,
@@ -3827,6 +4610,7 @@ import {
3827
4610
  TaskGraphStore as TaskGraphStore2,
3828
4611
  WorktreeManager as WorktreeManager2
3829
4612
  } from "@wrongstack/core";
4613
+ 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
4614
  function buildSddWizardDeps(opts) {
3831
4615
  const registry = new SddRunRegistry();
3832
4616
  let isolatedSeq = 0;
@@ -3835,11 +4619,11 @@ function buildSddWizardDeps(opts) {
3835
4619
  id: `sdd-${name2.toLowerCase().replace(/\s+/g, "-")}-${isolatedSeq++}`,
3836
4620
  role: "executor",
3837
4621
  name: name2,
3838
- disabledTools: ["delegate"],
4622
+ disabledTools: ["delegate", "write", "edit", "patch", "bash", "exec"],
3839
4623
  allowedCapabilities: ["fs.read", "net.outbound"]
3840
4624
  });
3841
4625
  try {
3842
- const res = await result.agent.run([{ type: "text", text: prompt }]);
4626
+ const res = await result.agent.run([{ type: "text", text: PLANNING_ONLY_GUARD + prompt }]);
3843
4627
  return res.finalText ?? "";
3844
4628
  } finally {
3845
4629
  await result.dispose?.();
@@ -3849,23 +4633,30 @@ function buildSddWizardDeps(opts) {
3849
4633
  makeDriver: () => new SddInterviewDriver({
3850
4634
  specStore: new SpecStore2({ baseDir: opts.paths.projectSpecs }),
3851
4635
  graphStore: new TaskGraphStore2({ baseDir: opts.paths.projectTaskGraphs }),
3852
- sessionPath: path7.join(opts.paths.projectDir, "sdd-wizard-session.json")
4636
+ sessionPath: path8.join(opts.paths.projectDir, "sdd-wizard-session.json")
3853
4637
  }),
3854
4638
  runInterviewTurn: (prompt) => runIsolatedTurn(prompt, "Spec Architect"),
3855
- startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels }) => {
4639
+ startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels, worktrees: useWorktrees }) => {
3856
4640
  const graph = driver.getGraph();
3857
4641
  const tracker = driver.getTracker();
3858
4642
  if (!graph || !tracker) {
3859
4643
  throw new Error("No task graph to run \u2014 finish the interview first.");
3860
4644
  }
4645
+ const worktreesEnabled = useWorktrees ?? process.env["WRONGSTACK_SDD_WORKTREES"] !== "0";
3861
4646
  let worktrees;
3862
- if (process.env["WRONGSTACK_SDD_WORKTREES"] !== "0") {
4647
+ if (worktreesEnabled) {
3863
4648
  const inGit = spawnSync2("git", ["rev-parse", "--is-inside-work-tree"], {
3864
4649
  cwd: opts.projectRoot,
3865
4650
  encoding: "utf8",
3866
4651
  windowsHide: true
3867
4652
  }).stdout?.trim() === "true";
3868
- if (inGit) worktrees = new WorktreeManager2({ projectRoot: opts.projectRoot, events: opts.events });
4653
+ if (inGit) {
4654
+ await cleanupStaleSddWorktrees({
4655
+ projectRoot: opts.projectRoot,
4656
+ boardsDir: opts.paths.projectSddBoards
4657
+ }).catch(() => void 0);
4658
+ worktrees = new WorktreeManager2({ projectRoot: opts.projectRoot, events: opts.events });
4659
+ }
3869
4660
  }
3870
4661
  const boardStore = new SddBoardStore2({ baseDir: opts.paths.projectSddBoards });
3871
4662
  const verifyTask = makeCommandVerifier();
@@ -4644,16 +5435,16 @@ var CollaborationWebSocketHandler = class {
4644
5435
  };
4645
5436
 
4646
5437
  // src/server/projects-manifest.ts
4647
- import * as fs6 from "fs/promises";
4648
- import * as path8 from "path";
5438
+ import * as fs7 from "fs/promises";
5439
+ import * as path9 from "path";
4649
5440
  import { projectSlug } from "@wrongstack/core";
4650
5441
  function projectsJsonPath(globalConfigPath) {
4651
- const base = path8.dirname(globalConfigPath);
4652
- return path8.join(base, "projects.json");
5442
+ const base = path9.dirname(globalConfigPath);
5443
+ return path9.join(base, "projects.json");
4653
5444
  }
4654
5445
  async function loadManifest(globalConfigPath) {
4655
5446
  try {
4656
- const raw = await fs6.readFile(projectsJsonPath(globalConfigPath), "utf8");
5447
+ const raw = await fs7.readFile(projectsJsonPath(globalConfigPath), "utf8");
4657
5448
  const parsed = JSON.parse(raw);
4658
5449
  return { projects: parsed.projects ?? [] };
4659
5450
  } catch {
@@ -4662,16 +5453,16 @@ async function loadManifest(globalConfigPath) {
4662
5453
  }
4663
5454
  async function saveManifest(manifest, globalConfigPath) {
4664
5455
  const file = projectsJsonPath(globalConfigPath);
4665
- await fs6.mkdir(path8.dirname(file), { recursive: true });
4666
- await fs6.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
5456
+ await fs7.mkdir(path9.dirname(file), { recursive: true });
5457
+ await fs7.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
4667
5458
  }
4668
5459
  function generateProjectSlug(rootPath) {
4669
5460
  return projectSlug(rootPath);
4670
5461
  }
4671
5462
  async function ensureProjectDataDir(slug, globalConfigPath) {
4672
- const base = path8.dirname(globalConfigPath);
4673
- const dir = path8.join(base, "projects", slug);
4674
- await fs6.mkdir(dir, { recursive: true });
5463
+ const base = path9.dirname(globalConfigPath);
5464
+ const dir = path9.join(base, "projects", slug);
5465
+ await fs7.mkdir(dir, { recursive: true });
4675
5466
  return dir;
4676
5467
  }
4677
5468
 
@@ -4833,16 +5624,22 @@ function clampDim(value, fallback) {
4833
5624
  }
4834
5625
 
4835
5626
  // src/server/worktree-ws-handler.ts
5627
+ import { join as join6, resolve as resolve6, sep as sep4 } from "path";
5628
+ import { cleanupStaleSddWorktrees as cleanupStaleSddWorktrees2, WorktreeManager as WorktreeManager3 } from "@wrongstack/core";
4836
5629
  import { toErrorMessage as toErrorMessage4 } from "@wrongstack/core/utils";
4837
5630
  var MAX_ACTIVITY = 6;
5631
+ var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["allocating", "active", "committing", "merging"]);
5632
+ var MANAGED_BRANCH_RE = /^wstack\/ap\/[A-Za-z0-9._/-]+$/;
4838
5633
  var WorktreeWebSocketHandler = class {
4839
- constructor(events, logger) {
5634
+ constructor(events, logger, management) {
4840
5635
  this.events = events;
4841
5636
  this.logger = logger;
5637
+ this.management = management;
4842
5638
  this.subscribe();
4843
5639
  }
4844
5640
  events;
4845
5641
  logger;
5642
+ management;
4846
5643
  clients = /* @__PURE__ */ new Set();
4847
5644
  handles = /* @__PURE__ */ new Map();
4848
5645
  baseBranch = "";
@@ -4853,12 +5650,197 @@ var WorktreeWebSocketHandler = class {
4853
5650
  ws.on("close", () => this.clients.delete(ws));
4854
5651
  ws.on("error", () => this.clients.delete(ws));
4855
5652
  this.send(ws, this.stateMessage());
5653
+ void this.scanAndBroadcast();
5654
+ }
5655
+ /** Handle worktree-panel control messages (scan / clean / per-row ops). */
5656
+ async handleMessage(msg) {
5657
+ if (msg.type === "worktree.scan") {
5658
+ await this.scanAndBroadcast();
5659
+ return true;
5660
+ }
5661
+ if (msg.type === "worktree.cleanup") {
5662
+ await this.cleanupOrphans();
5663
+ return true;
5664
+ }
5665
+ if (msg.type === "worktree.remove") {
5666
+ await this.removeOne(msg.payload?.["dir"], msg.payload?.["branch"]);
5667
+ return true;
5668
+ }
5669
+ if (msg.type === "worktree.merge") {
5670
+ await this.mergeBranch(msg.payload?.["branch"]);
5671
+ return true;
5672
+ }
5673
+ if (msg.type === "worktree.diff") {
5674
+ await this.diffOne(msg.payload?.["dir"], msg.payload?.["baseBranch"]);
5675
+ return true;
5676
+ }
5677
+ return false;
4856
5678
  }
4857
5679
  dispose() {
4858
5680
  for (const off of this.offs) off();
4859
5681
  this.offs.length = 0;
4860
5682
  this.stopBroadcast();
4861
5683
  }
5684
+ // ── orphan management ─────────────────────────────────────────────────────
5685
+ /** Absolute managed-worktrees root for this project. */
5686
+ worktreesRoot() {
5687
+ return resolve6(join6(this.management.projectRoot, ".wrongstack", "worktrees"));
5688
+ }
5689
+ /** True iff `dir` resolves strictly inside the managed worktrees root. */
5690
+ underRoot(dir) {
5691
+ const abs = resolve6(dir);
5692
+ const root = this.worktreesRoot();
5693
+ return abs !== root && abs.startsWith(root + sep4);
5694
+ }
5695
+ /** Branches of worktrees a live in-session run currently owns. */
5696
+ liveActiveBranches() {
5697
+ const live = /* @__PURE__ */ new Set();
5698
+ for (const h of this.handles.values()) {
5699
+ if (ACTIVE_STATUSES.has(h.status) && h.branch) live.add(h.branch);
5700
+ }
5701
+ return live;
5702
+ }
5703
+ /**
5704
+ * Scan the disk for managed worktrees/branches NOT owned by a live in-session
5705
+ * run and broadcast them as orphans, with whether it is safe to clean now.
5706
+ * No-op (empty inventory) when management deps were not wired.
5707
+ */
5708
+ async scanAndBroadcast() {
5709
+ if (!this.management) {
5710
+ this.broadcast({ type: "worktree.orphans", payload: { orphans: [], canClean: false } });
5711
+ return;
5712
+ }
5713
+ try {
5714
+ const wt = new WorktreeManager3({ projectRoot: this.management.projectRoot });
5715
+ const { worktrees, branches } = await wt.listManaged();
5716
+ const live = this.liveActiveBranches();
5717
+ const orphans = [];
5718
+ const seenBranches = /* @__PURE__ */ new Set();
5719
+ for (const w of worktrees) {
5720
+ if (w.branch && live.has(w.branch)) continue;
5721
+ if (w.branch) seenBranches.add(w.branch);
5722
+ orphans.push({ kind: "worktree", dir: w.dir, branch: w.branch });
5723
+ }
5724
+ for (const b of branches) {
5725
+ if (live.has(b) || seenBranches.has(b)) continue;
5726
+ orphans.push({ kind: "branch", branch: b });
5727
+ }
5728
+ const canClean = this.liveActiveBranches().size === 0;
5729
+ this.broadcast({
5730
+ type: "worktree.orphans",
5731
+ payload: {
5732
+ orphans,
5733
+ canClean,
5734
+ reason: canClean ? void 0 : "a run is live in this session"
5735
+ }
5736
+ });
5737
+ } catch (err) {
5738
+ this.logger.debug?.(`worktree orphan scan failed: ${toErrorMessage4(err)}`);
5739
+ this.broadcast({ type: "worktree.orphans", payload: { orphans: [], canClean: false } });
5740
+ }
5741
+ }
5742
+ /**
5743
+ * Force-remove every orphaned worktree + branch. Refused while a run is live —
5744
+ * in this session (active handles) OR another process (the SDD board liveness
5745
+ * guard inside cleanupStaleSddWorktrees). Best-effort; reports the outcome.
5746
+ */
5747
+ async cleanupOrphans() {
5748
+ if (!this.management) {
5749
+ this.broadcast({
5750
+ type: "worktree.cleanup_result",
5751
+ payload: { ok: false, removed: 0, reason: "cleanup is not available in this session" }
5752
+ });
5753
+ return;
5754
+ }
5755
+ if (this.liveActiveBranches().size > 0) {
5756
+ this.broadcast({
5757
+ type: "worktree.cleanup_result",
5758
+ payload: { ok: false, removed: 0, reason: "a run is live in this session \u2014 stop it first" }
5759
+ });
5760
+ return;
5761
+ }
5762
+ const res = await cleanupStaleSddWorktrees2({
5763
+ projectRoot: this.management.projectRoot,
5764
+ boardsDir: this.management.boardsDir
5765
+ });
5766
+ if (res.skippedReason) {
5767
+ this.broadcast({
5768
+ type: "worktree.cleanup_result",
5769
+ payload: { ok: false, removed: 0, reason: res.skippedReason }
5770
+ });
5771
+ await this.scanAndBroadcast();
5772
+ return;
5773
+ }
5774
+ for (const [id, h] of [...this.handles]) {
5775
+ if (!ACTIVE_STATUSES.has(h.status)) this.handles.delete(id);
5776
+ }
5777
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: true, removed: res.removed } });
5778
+ this.broadcastState();
5779
+ await this.scanAndBroadcast();
5780
+ }
5781
+ /** Remove/discard ONE worktree + branch. Refused while a live run owns it. */
5782
+ async removeOne(dir, branch) {
5783
+ if (!this.management || !dir && !branch) {
5784
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: false, removed: 0, reason: "nothing to remove" } });
5785
+ return;
5786
+ }
5787
+ if (branch && !MANAGED_BRANCH_RE.test(branch)) {
5788
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: false, removed: 0, reason: "not a managed worktree branch" } });
5789
+ return;
5790
+ }
5791
+ if (dir && !this.underRoot(dir)) {
5792
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: false, removed: 0, reason: "path is outside the managed worktrees root" } });
5793
+ return;
5794
+ }
5795
+ if (branch && this.liveActiveBranches().has(branch)) {
5796
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: false, removed: 0, reason: "a run is live on this worktree \u2014 stop it first" } });
5797
+ return;
5798
+ }
5799
+ let removed = false;
5800
+ if (dir) {
5801
+ const wt = new WorktreeManager3({ projectRoot: this.management.projectRoot });
5802
+ ({ removed } = await wt.removeOne(dir, branch));
5803
+ }
5804
+ for (const [id, h] of [...this.handles]) {
5805
+ if (branch && h.branch === branch || dir && h.handleId && dir.endsWith(h.handleId)) this.handles.delete(id);
5806
+ }
5807
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: removed, removed: removed ? 1 : 0, reason: removed ? void 0 : "remove failed (not a managed worktree?)" } });
5808
+ this.broadcastState();
5809
+ await this.scanAndBroadcast();
5810
+ }
5811
+ /** Squash-merge ONE branch into base. Refused while a live run owns it. */
5812
+ async mergeBranch(branch) {
5813
+ if (!this.management || !branch) {
5814
+ this.broadcast({ type: "worktree.merge_result", payload: { ok: false, branch: branch ?? "", reason: "no branch" } });
5815
+ return;
5816
+ }
5817
+ if (!MANAGED_BRANCH_RE.test(branch)) {
5818
+ this.broadcast({ type: "worktree.merge_result", payload: { ok: false, branch, reason: "not a managed worktree branch" } });
5819
+ return;
5820
+ }
5821
+ if (this.liveActiveBranches().has(branch)) {
5822
+ this.broadcast({ type: "worktree.merge_result", payload: { ok: false, branch, reason: "a run is live on this worktree \u2014 stop it first" } });
5823
+ return;
5824
+ }
5825
+ const wt = new WorktreeManager3({ projectRoot: this.management.projectRoot });
5826
+ const res = await wt.mergeBranch(branch);
5827
+ this.broadcast({
5828
+ type: "worktree.merge_result",
5829
+ payload: { ok: res.ok, branch, conflict: res.conflict, conflictFiles: res.conflictFiles, reason: res.reason }
5830
+ });
5831
+ await this.scanAndBroadcast();
5832
+ }
5833
+ /** Compact change summary for one worktree checkout. */
5834
+ async diffOne(dir, baseBranch) {
5835
+ if (!this.management || !dir || !this.underRoot(dir)) {
5836
+ this.broadcast({ type: "worktree.diff_result", payload: { dir: dir ?? "", summary: null } });
5837
+ return;
5838
+ }
5839
+ const base = baseBranch && MANAGED_BRANCH_RE.test(baseBranch) ? baseBranch : void 0;
5840
+ const wt = new WorktreeManager3({ projectRoot: this.management.projectRoot });
5841
+ const summary = await wt.diffSummary(resolve6(dir), base);
5842
+ this.broadcast({ type: "worktree.diff_result", payload: { dir, summary } });
5843
+ }
4862
5844
  // ── internals ───────────────────────────────────────────────────────────
4863
5845
  subscribe() {
4864
5846
  const on = this.events.on.bind(this.events);
@@ -4870,6 +5852,7 @@ var WorktreeWebSocketHandler = class {
4870
5852
  handleId: e.handleId,
4871
5853
  ownerId: e.ownerId,
4872
5854
  ownerLabel: e.ownerLabel,
5855
+ dir: e.dir,
4873
5856
  branch: e.branch,
4874
5857
  baseBranch: e.baseBranch,
4875
5858
  status: "active",
@@ -4972,10 +5955,10 @@ var WorktreeWebSocketHandler = class {
4972
5955
  };
4973
5956
 
4974
5957
  // src/server/mailbox-handlers.ts
4975
- import { GlobalMailbox, resolveProjectDir } from "@wrongstack/core";
5958
+ import { GlobalMailbox, resolveProjectDir as resolveProjectDir2 } from "@wrongstack/core";
4976
5959
  async function handleMailboxMessages(ws, deps2, payload) {
4977
5960
  try {
4978
- const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
5961
+ const dir = resolveProjectDir2(deps2.projectRoot, deps2.globalRoot);
4979
5962
  const mb = new GlobalMailbox(dir);
4980
5963
  const messages = await mb.query({
4981
5964
  limit: payload?.limit ?? 30,
@@ -5011,7 +5994,7 @@ async function handleMailboxMessages(ws, deps2, payload) {
5011
5994
  }
5012
5995
  async function handleMailboxAgents(ws, deps2, payload) {
5013
5996
  try {
5014
- const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
5997
+ const dir = resolveProjectDir2(deps2.projectRoot, deps2.globalRoot);
5015
5998
  const mb = new GlobalMailbox(dir);
5016
5999
  const agents = payload?.onlineOnly ? await mb.getOnlineAgents() : await mb.getAgentStatuses();
5017
6000
  send(ws, {
@@ -5040,7 +6023,7 @@ async function handleMailboxAgents(ws, deps2, payload) {
5040
6023
  }
5041
6024
  async function handleMailboxClear(ws, deps2) {
5042
6025
  try {
5043
- const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
6026
+ const dir = resolveProjectDir2(deps2.projectRoot, deps2.globalRoot);
5044
6027
  const mb = new GlobalMailbox(dir);
5045
6028
  await mb.clearAll();
5046
6029
  send(ws, { type: "mailbox.cleared", payload: {} });
@@ -5050,7 +6033,7 @@ async function handleMailboxClear(ws, deps2) {
5050
6033
  }
5051
6034
  async function handleMailboxPurge(ws, deps2, opts) {
5052
6035
  try {
5053
- const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
6036
+ const dir = resolveProjectDir2(deps2.projectRoot, deps2.globalRoot);
5054
6037
  const mb = new GlobalMailbox(dir);
5055
6038
  const result = await mb.purgeStale(opts);
5056
6039
  send(ws, { type: "mailbox.purged", payload: result });
@@ -5097,14 +6080,14 @@ function registerShutdownHandlers(res) {
5097
6080
 
5098
6081
  // src/server/instance-registry.ts
5099
6082
  import * as os from "os";
5100
- import * as path9 from "path";
5101
- import * as fs7 from "fs/promises";
6083
+ import * as path10 from "path";
6084
+ import * as fs8 from "fs/promises";
5102
6085
  import { atomicWrite as atomicWrite3 } from "@wrongstack/core";
5103
6086
  function defaultBaseDir() {
5104
- return path9.join(os.homedir(), ".wrongstack");
6087
+ return path10.join(os.homedir(), ".wrongstack");
5105
6088
  }
5106
6089
  function registryPath(baseDir = defaultBaseDir()) {
5107
- return path9.join(baseDir, "webui-instances.json");
6090
+ return path10.join(baseDir, "webui-instances.json");
5108
6091
  }
5109
6092
  function isPidAlive(pid) {
5110
6093
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -5117,7 +6100,7 @@ function isPidAlive(pid) {
5117
6100
  }
5118
6101
  async function load(file) {
5119
6102
  try {
5120
- const raw = await fs7.readFile(file, "utf8");
6103
+ const raw = await fs8.readFile(file, "utf8");
5121
6104
  const parsed = JSON.parse(raw);
5122
6105
  if (parsed?.version === 1 && Array.isArray(parsed.instances)) {
5123
6106
  return parsed;
@@ -5176,16 +6159,16 @@ function formatInstances(instances) {
5176
6159
  // src/server/port-utils.ts
5177
6160
  import * as net from "net";
5178
6161
  function isPortFree(host, port) {
5179
- return new Promise((resolve9) => {
6162
+ return new Promise((resolve10) => {
5180
6163
  const srv = net.createServer();
5181
- srv.once("error", () => resolve9(false));
6164
+ srv.once("error", () => resolve10(false));
5182
6165
  srv.once("listening", () => {
5183
- srv.close(() => resolve9(true));
6166
+ srv.close(() => resolve10(true));
5184
6167
  });
5185
6168
  try {
5186
6169
  srv.listen(port, host);
5187
6170
  } catch {
5188
- resolve9(false);
6171
+ resolve10(false);
5189
6172
  }
5190
6173
  });
5191
6174
  }
@@ -5266,15 +6249,15 @@ import { DefaultSecretScrubber } from "@wrongstack/core";
5266
6249
  import { probeLocalLlm } from "@wrongstack/runtime/probe";
5267
6250
 
5268
6251
  // src/server/provider-config-io.ts
5269
- import * as fs8 from "fs/promises";
5270
- import * as path10 from "path";
6252
+ import * as fs9 from "fs/promises";
6253
+ import * as path11 from "path";
5271
6254
  import { atomicWrite as atomicWrite4 } from "@wrongstack/core";
5272
6255
  import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/security";
5273
6256
  import { DefaultSecretVault } from "@wrongstack/core";
5274
6257
  async function loadSavedProviders(configPath, vault) {
5275
6258
  let raw;
5276
6259
  try {
5277
- raw = await fs8.readFile(configPath, "utf8");
6260
+ raw = await fs9.readFile(configPath, "utf8");
5278
6261
  } catch {
5279
6262
  return {};
5280
6263
  }
@@ -5291,7 +6274,7 @@ async function saveProviders(configPath, vault, providers) {
5291
6274
  let raw;
5292
6275
  let fileExists = true;
5293
6276
  try {
5294
- raw = await fs8.readFile(configPath, "utf8");
6277
+ raw = await fs9.readFile(configPath, "utf8");
5295
6278
  } catch (err) {
5296
6279
  if (err.code !== "ENOENT") {
5297
6280
  throw new Error(
@@ -5629,7 +6612,8 @@ function createProviderHandlers(deps2) {
5629
6612
 
5630
6613
  // src/server/mode-handlers.ts
5631
6614
  import {
5632
- DefaultSystemPromptBuilder
6615
+ DefaultSystemPromptBuilder,
6616
+ resolveWstackPaths
5633
6617
  } from "@wrongstack/core";
5634
6618
  function createModeHandlers(ctx) {
5635
6619
  return {
@@ -5677,13 +6661,18 @@ function createModeHandlers(ctx) {
5677
6661
  }
5678
6662
  ctx.setModeId(id);
5679
6663
  const modePrompt = id === "default" ? "" : (await ctx.modeStore.getMode(id))?.prompt ?? "";
6664
+ const paths = resolveWstackPaths({ projectRoot: ctx.projectRoot, globalRoot: ctx.globalRoot });
5680
6665
  const freshBuilder = new DefaultSystemPromptBuilder({
5681
6666
  memoryStore: ctx.memoryStore,
5682
6667
  skillLoader: ctx.skillLoader,
5683
6668
  modeStore: ctx.modeStore,
5684
6669
  modeId: id,
5685
6670
  modePrompt,
5686
- modelCapabilities: ctx.modelCapabilities
6671
+ modelCapabilities: ctx.modelCapabilities,
6672
+ instructionPaths: {
6673
+ globalDir: paths.globalInstructions,
6674
+ projectDir: paths.inProjectInstructions
6675
+ }
5687
6676
  });
5688
6677
  ctx.context.systemPrompt = await freshBuilder.build({
5689
6678
  cwd: ctx.projectRoot,
@@ -5705,12 +6694,13 @@ function createModeHandlers(ctx) {
5705
6694
  }
5706
6695
 
5707
6696
  // src/server/project-handlers.ts
5708
- import * as fs9 from "fs/promises";
5709
- import * as path11 from "path";
6697
+ import * as fs10 from "fs/promises";
6698
+ import * as path12 from "path";
5710
6699
  import {
5711
6700
  DefaultSessionStore,
5712
6701
  DefaultSystemPromptBuilder as DefaultSystemPromptBuilder2,
5713
- getSessionRegistry
6702
+ getSessionRegistry,
6703
+ resolveWstackPaths as resolveWstackPaths2
5714
6704
  } from "@wrongstack/core";
5715
6705
  function createProjectHandlers(ctx) {
5716
6706
  return {
@@ -5733,9 +6723,9 @@ function createProjectHandlers(ctx) {
5733
6723
  }
5734
6724
  const { root: addRoot, name: displayName } = parsed.value;
5735
6725
  try {
5736
- const resolved = path11.resolve(addRoot);
5737
- await fs9.access(resolved);
5738
- const stat3 = await fs9.stat(resolved);
6726
+ const resolved = path12.resolve(addRoot);
6727
+ await fs10.access(resolved);
6728
+ const stat3 = await fs10.stat(resolved);
5739
6729
  if (!stat3.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
5740
6730
  const manifest = await loadManifest(ctx.globalConfigPath);
5741
6731
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -5751,7 +6741,7 @@ function createProjectHandlers(ctx) {
5751
6741
  });
5752
6742
  return;
5753
6743
  }
5754
- const name2 = displayName?.trim() || path11.basename(resolved);
6744
+ const name2 = displayName?.trim() || path12.basename(resolved);
5755
6745
  const slug = generateProjectSlug(resolved);
5756
6746
  await ensureProjectDataDir(slug, ctx.globalConfigPath);
5757
6747
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -5764,7 +6754,7 @@ function createProjectHandlers(ctx) {
5764
6754
  } catch (err) {
5765
6755
  send(ws, {
5766
6756
  type: "projects.added",
5767
- payload: { name: path11.basename(addRoot), root: addRoot, slug: "", message: errMessage(err) }
6757
+ payload: { name: path12.basename(addRoot), root: addRoot, slug: "", message: errMessage(err) }
5768
6758
  });
5769
6759
  }
5770
6760
  },
@@ -5779,17 +6769,17 @@ function createProjectHandlers(ctx) {
5779
6769
  }
5780
6770
  const { root: selRoot, name: selName } = parsed.value;
5781
6771
  try {
5782
- const resolved = path11.resolve(selRoot);
6772
+ const resolved = path12.resolve(selRoot);
5783
6773
  try {
5784
- await fs9.access(resolved);
5785
- const stat3 = await fs9.stat(resolved);
6774
+ await fs10.access(resolved);
6775
+ const stat3 = await fs10.stat(resolved);
5786
6776
  if (!stat3.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
5787
6777
  } catch (err) {
5788
6778
  send(ws, {
5789
6779
  type: "projects.selected",
5790
6780
  payload: {
5791
6781
  root: selRoot,
5792
- name: selName || path11.basename(selRoot),
6782
+ name: selName || path12.basename(selRoot),
5793
6783
  message: `Cannot switch: ${errMessage(err)}`
5794
6784
  }
5795
6785
  });
@@ -5801,7 +6791,7 @@ function createProjectHandlers(ctx) {
5801
6791
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
5802
6792
  entry.lastWorkingDir = resolved;
5803
6793
  } else {
5804
- const name2 = selName?.trim() || path11.basename(resolved);
6794
+ const name2 = selName?.trim() || path12.basename(resolved);
5805
6795
  const slug = generateProjectSlug(resolved);
5806
6796
  manifest.projects.push({
5807
6797
  name: name2,
@@ -5823,13 +6813,21 @@ function createProjectHandlers(ctx) {
5823
6813
  try {
5824
6814
  const modeId = ctx.getModeId();
5825
6815
  const switchMode = modeId === "default" ? void 0 : await ctx.modeStore.getMode(modeId);
6816
+ const switchPaths = resolveWstackPaths2({
6817
+ projectRoot: resolved,
6818
+ globalRoot: ctx.wpaths.globalRoot
6819
+ });
5826
6820
  const switchBuilder = new DefaultSystemPromptBuilder2({
5827
6821
  memoryStore: ctx.memoryStore,
5828
6822
  skillLoader: ctx.skillLoader,
5829
6823
  modeStore: ctx.modeStore,
5830
6824
  modeId,
5831
6825
  modePrompt: switchMode?.prompt ?? "",
5832
- modelCapabilities: ctx.modelCapabilities
6826
+ modelCapabilities: ctx.modelCapabilities,
6827
+ instructionPaths: {
6828
+ globalDir: switchPaths.globalInstructions,
6829
+ projectDir: switchPaths.inProjectInstructions
6830
+ }
5833
6831
  });
5834
6832
  ctx.context.systemPrompt = await switchBuilder.build({
5835
6833
  cwd: resolved,
@@ -5840,13 +6838,13 @@ function createProjectHandlers(ctx) {
5840
6838
  });
5841
6839
  } catch {
5842
6840
  }
5843
- const newSessionsDir = path11.join(
5844
- path11.dirname(ctx.globalConfigPath),
6841
+ const newSessionsDir = path12.join(
6842
+ path12.dirname(ctx.globalConfigPath),
5845
6843
  "projects",
5846
6844
  switchSlug,
5847
6845
  "sessions"
5848
6846
  );
5849
- await fs9.mkdir(newSessionsDir, { recursive: true });
6847
+ await fs10.mkdir(newSessionsDir, { recursive: true });
5850
6848
  const newSessionStore = new DefaultSessionStore({ dir: newSessionsDir });
5851
6849
  const oldSession = ctx.getSession();
5852
6850
  const oldSessionId = oldSession.id;
@@ -5880,7 +6878,7 @@ function createProjectHandlers(ctx) {
5880
6878
  sessionId: newSession.id,
5881
6879
  projectSlug: switchSlug,
5882
6880
  projectRoot: resolved,
5883
- projectName: path11.basename(resolved),
6881
+ projectName: path12.basename(resolved),
5884
6882
  workingDir: resolved,
5885
6883
  clientType: "webui",
5886
6884
  pid: process.pid,
@@ -5892,8 +6890,8 @@ function createProjectHandlers(ctx) {
5892
6890
  type: "projects.selected",
5893
6891
  payload: {
5894
6892
  root: resolved,
5895
- name: selName || path11.basename(resolved),
5896
- message: `Switched to ${selName || path11.basename(resolved)}`
6893
+ name: selName || path12.basename(resolved),
6894
+ message: `Switched to ${selName || path12.basename(resolved)}`
5897
6895
  }
5898
6896
  });
5899
6897
  broadcast(ctx.clients, {
@@ -5913,7 +6911,7 @@ function createProjectHandlers(ctx) {
5913
6911
  type: "projects.selected",
5914
6912
  payload: {
5915
6913
  root: selRoot,
5916
- name: selName || path11.basename(selRoot),
6914
+ name: selName || path12.basename(selRoot),
5917
6915
  message: errMessage(err)
5918
6916
  }
5919
6917
  });
@@ -5944,7 +6942,7 @@ function createProjectHandlers(ctx) {
5944
6942
  }
5945
6943
 
5946
6944
  // src/server/session-handlers.ts
5947
- import * as path12 from "path";
6945
+ import * as path13 from "path";
5948
6946
  import {
5949
6947
  DEFAULT_CONTEXT_WINDOW_MODE_ID,
5950
6948
  repairToolUseAdjacency,
@@ -6286,7 +7284,7 @@ function createSessionHandlers(ctx) {
6286
7284
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
6287
7285
  const projectRoot = ctx.getProjectRoot();
6288
7286
  const rewinder = new DefaultSessionRewinder(
6289
- path12.join(projectRoot, ".wrongstack", "sessions"),
7287
+ path13.join(projectRoot, ".wrongstack", "sessions"),
6290
7288
  projectRoot
6291
7289
  );
6292
7290
  const checkpoints = await rewinder.listCheckpoints(ctx.getSession().id);
@@ -6301,7 +7299,7 @@ function createSessionHandlers(ctx) {
6301
7299
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
6302
7300
  const projectRoot = ctx.getProjectRoot();
6303
7301
  const rewinder = new DefaultSessionRewinder(
6304
- path12.join(projectRoot, ".wrongstack", "sessions"),
7302
+ path13.join(projectRoot, ".wrongstack", "sessions"),
6305
7303
  projectRoot
6306
7304
  );
6307
7305
  await rewinder.rewindToCheckpoint(ctx.getSession().id, checkpointIndex);
@@ -6677,9 +7675,9 @@ async function handleSddBoardRoute(_ws, msg, handlers) {
6677
7675
  }
6678
7676
 
6679
7677
  // src/server/setup-events.ts
6680
- import * as fs10 from "fs/promises";
7678
+ import * as fs11 from "fs/promises";
6681
7679
  import { watch as fsWatch } from "fs";
6682
- import * as path13 from "path";
7680
+ import * as path14 from "path";
6683
7681
  function setupEvents(deps2) {
6684
7682
  const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge, wpaths, watcherMetrics, onFleetBroadcaster } = deps2;
6685
7683
  const disposers = [];
@@ -6768,6 +7766,22 @@ function setupEvents(deps2) {
6768
7766
  }).catch(() => {
6769
7767
  });
6770
7768
  broadcast2(clients, { type: "todos.updated", payload: { todos: [...context.todos] } });
7769
+ const sideEffects = context.sideEffects ?? [];
7770
+ if (sideEffects.length > 0) {
7771
+ broadcast2(clients, {
7772
+ type: "side_effects",
7773
+ payload: {
7774
+ sideEffects: sideEffects.slice(-50).map((se) => ({
7775
+ toolUseId: se.toolUseId,
7776
+ toolName: se.toolName,
7777
+ ts: se.ts,
7778
+ input: se.input,
7779
+ outcome: se.outcome,
7780
+ risk: se.risk
7781
+ }))
7782
+ }
7783
+ });
7784
+ }
6771
7785
  if (e.name === "task" || e.name === "plan" || e.name === "todo") {
6772
7786
  void (async () => {
6773
7787
  try {
@@ -7146,16 +8160,16 @@ function setupEvents(deps2) {
7146
8160
  if (wpaths?.projectStatus) {
7147
8161
  try {
7148
8162
  const statusFile = wpaths.projectStatus(e.projectHash);
7149
- const dir = path13.dirname(statusFile);
7150
- await fs10.mkdir(dir, { recursive: true });
7151
- await fs10.writeFile(statusFile, JSON.stringify(e, null, 2), "utf-8");
8163
+ const dir = path14.dirname(statusFile);
8164
+ await fs11.mkdir(dir, { recursive: true });
8165
+ await fs11.writeFile(statusFile, JSON.stringify(e, null, 2), "utf-8");
7152
8166
  } catch (err) {
7153
8167
  console.error("[setup-events] Failed to write status.json:", err);
7154
8168
  }
7155
8169
  }
7156
8170
  });
7157
8171
  if (wpaths?.projectStatus && wpaths.configDir) {
7158
- const projectsDir = path13.join(wpaths.configDir, "projects");
8172
+ const projectsDir = path14.join(wpaths.configDir, "projects");
7159
8173
  const knownProjectHashes = /* @__PURE__ */ new Set();
7160
8174
  const debounceTimers = /* @__PURE__ */ new Map();
7161
8175
  const DEBOUNCE_MS = 150;
@@ -7218,20 +8232,20 @@ function setupEvents(deps2) {
7218
8232
  let watcher;
7219
8233
  const startWatcher = async () => {
7220
8234
  try {
7221
- await fs10.mkdir(projectsDir, { recursive: true });
8235
+ await fs11.mkdir(projectsDir, { recursive: true });
7222
8236
  watcher = fsWatch(projectsDir, { persistent: true, recursive: true }, async (eventType, filename) => {
7223
8237
  if (eventType === "change") {
7224
8238
  if (filename == null) return;
7225
8239
  if (watcherMetrics) watcherMetrics.fileChangesDetected++;
7226
- const targetFile = path13.join(projectsDir, String(filename));
8240
+ const targetFile = path14.join(projectsDir, String(filename));
7227
8241
  if (targetFile.endsWith("status.json")) {
7228
- const projectHash2 = path13.basename(path13.dirname(targetFile));
8242
+ const projectHash2 = path14.basename(path14.dirname(targetFile));
7229
8243
  if (knownProjectHashes.size > 0 && !knownProjectHashes.has(projectHash2)) {
7230
8244
  return;
7231
8245
  }
7232
8246
  if (watcherMetrics) watcherMetrics.filesProcessed++;
7233
8247
  try {
7234
- const content = await fs10.readFile(targetFile, "utf-8");
8248
+ const content = await fs11.readFile(targetFile, "utf-8");
7235
8249
  const statusData = JSON.parse(content);
7236
8250
  if (statusData.projectHash) {
7237
8251
  const hash = String(statusData.projectHash);
@@ -7283,7 +8297,7 @@ function setupEvents(deps2) {
7283
8297
  }
7284
8298
  });
7285
8299
  }
7286
- const globalRoot = globalConfigPath ? path13.dirname(globalConfigPath) : void 0;
8300
+ const globalRoot = globalConfigPath ? path14.dirname(globalConfigPath) : void 0;
7287
8301
  if (globalRoot) {
7288
8302
  const broadcastSessions = async () => {
7289
8303
  try {
@@ -7356,11 +8370,11 @@ function setupEvents(deps2) {
7356
8370
 
7357
8371
  // src/server/custom-context-modes.ts
7358
8372
  import { listContextWindowModes, atomicWrite as atomicWrite5 } from "@wrongstack/core";
7359
- import * as fs11 from "fs/promises";
7360
- import * as path14 from "path";
8373
+ import * as fs12 from "fs/promises";
8374
+ import * as path15 from "path";
7361
8375
  var STORE_FILENAME = "custom-context-modes.json";
7362
8376
  function storePath(wrongstackDir) {
7363
- return path14.join(wrongstackDir, STORE_FILENAME);
8377
+ return path15.join(wrongstackDir, STORE_FILENAME);
7364
8378
  }
7365
8379
  var BUILTIN_IDS = /* @__PURE__ */ new Set(["balanced", "frugal", "deep", "archival"]);
7366
8380
  function createCustomModeStore(wrongstackDir) {
@@ -7368,7 +8382,7 @@ function createCustomModeStore(wrongstackDir) {
7368
8382
  const load2 = async () => {
7369
8383
  modes.clear();
7370
8384
  try {
7371
- const raw = await fs11.readFile(storePath(wrongstackDir), "utf8");
8385
+ const raw = await fs12.readFile(storePath(wrongstackDir), "utf8");
7372
8386
  const parsed = JSON.parse(raw);
7373
8387
  if (Array.isArray(parsed.modes)) {
7374
8388
  for (const m of parsed.modes) {
@@ -7414,7 +8428,8 @@ function createCustomModeStore(wrongstackDir) {
7414
8428
  custom: true
7415
8429
  };
7416
8430
  modes.set(mode.id, entry);
7417
- void save2();
8431
+ void save2().catch(() => {
8432
+ });
7418
8433
  return { ok: true };
7419
8434
  };
7420
8435
  const update = (id, patch) => {
@@ -7440,7 +8455,8 @@ function createCustomModeStore(wrongstackDir) {
7440
8455
  if (patch.targetLoad !== void 0) next.targetLoad = patch.targetLoad;
7441
8456
  if (patch.aggressiveOn !== void 0) next.aggressiveOn = patch.aggressiveOn;
7442
8457
  modes.set(id, next);
7443
- void save2();
8458
+ void save2().catch(() => {
8459
+ });
7444
8460
  return { ok: true };
7445
8461
  };
7446
8462
  const remove = (id) => {
@@ -7450,7 +8466,8 @@ function createCustomModeStore(wrongstackDir) {
7450
8466
  if (!modes.delete(id)) {
7451
8467
  return { ok: false, error: `Mode "${id}" not found` };
7452
8468
  }
7453
- void save2();
8469
+ void save2().catch(() => {
8470
+ });
7454
8471
  return { ok: true };
7455
8472
  };
7456
8473
  const list = () => {
@@ -7491,14 +8508,17 @@ function createEternalSubscription(subscribe, broadcast2, clientsRef) {
7491
8508
  }
7492
8509
 
7493
8510
  // src/server/shell-open.ts
7494
- import * as fs12 from "fs/promises";
7495
- import * as path15 from "path";
8511
+ import * as fs13 from "fs/promises";
8512
+ import * as path16 from "path";
7496
8513
  import { spawn as spawn2 } from "child_process";
7497
- var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
8514
+ var METACHAR_REGEX = /[&|<>^"'`'\n\r]/;
8515
+ function shellQuote(s) {
8516
+ return s.replaceAll("'", `'"'"'`);
8517
+ }
7498
8518
  async function handleShellOpen(req, logger) {
7499
8519
  try {
7500
- const resolved = path15.resolve(req.path);
7501
- await fs12.access(resolved);
8520
+ const resolved = path16.resolve(req.path);
8521
+ await fs13.access(resolved);
7502
8522
  if (METACHAR_REGEX.test(resolved)) {
7503
8523
  return { success: false, message: "Path contains unsupported characters." };
7504
8524
  }
@@ -7531,7 +8551,11 @@ async function handleShellOpen(req, logger) {
7531
8551
  () => launch(
7532
8552
  "gnome-terminal",
7533
8553
  [`--working-directory=${resolved}`],
7534
- () => launch("xterm", ["-e", `cd '${resolved}' && ${process.env["SHELL"] ?? "sh"}`])
8554
+ () => (
8555
+ // Pass argv array so sh -c sees a literal string, not an interpolated one.
8556
+ // shellQuote() guards against paths that somehow slipped the METACHAR_REGEX.
8557
+ launch("xterm", ["-e", "sh", "-c", `cd ${shellQuote(resolved)} && ${process.env["SHELL"] ?? "sh"}`])
8558
+ )
7535
8559
  )
7536
8560
  );
7537
8561
  }
@@ -7550,9 +8574,9 @@ async function handleGitInfo(ws, projectRoot) {
7550
8574
  const cwd = projectRoot || void 0;
7551
8575
  try {
7552
8576
  const { execFile: ef } = await import("child_process");
7553
- const git = (args) => new Promise((resolve9) => {
8577
+ const git = (args) => new Promise((resolve10) => {
7554
8578
  ef("git", args, { cwd, timeout: 3e3 }, (err, stdout) => {
7555
- resolve9(err ? "" : stdout.trim());
8579
+ resolve10(err ? "" : stdout.trim());
7556
8580
  });
7557
8581
  });
7558
8582
  const [branchRaw, diffRaw, statusRaw, upstreamRaw] = await Promise.all([
@@ -7578,12 +8602,12 @@ async function handleGitInfo(ws, projectRoot) {
7578
8602
  function makeGit(cwd) {
7579
8603
  return async (args) => {
7580
8604
  const { execFile: ef } = await import("child_process");
7581
- return new Promise((resolve9) => {
8605
+ return new Promise((resolve10) => {
7582
8606
  ef(
7583
8607
  "git",
7584
8608
  args,
7585
8609
  { cwd, timeout: 5e3, maxBuffer: 1024 * 1024 * 16 },
7586
- (err, stdout) => resolve9(err ? "" : stdout)
8610
+ (err, stdout) => resolve10(err ? "" : stdout)
7587
8611
  );
7588
8612
  });
7589
8613
  };
@@ -7607,15 +8631,15 @@ async function handleGitChanges(ws, projectRoot) {
7607
8631
  if (!m) continue;
7608
8632
  const added = m[1] === "-" ? 0 : Number(m[1]);
7609
8633
  const deleted = m[2] === "-" ? 0 : Number(m[2]);
7610
- let path17 = m[3] ?? "";
7611
- if (path17 === "") {
8634
+ let path18 = m[3] ?? "";
8635
+ if (path18 === "") {
7612
8636
  i += 1;
7613
- path17 = parts[i + 1] ?? parts[i] ?? "";
8637
+ path18 = parts[i + 1] ?? parts[i] ?? "";
7614
8638
  i += 1;
7615
8639
  }
7616
- if (!path17) continue;
7617
- const prev = counts.get(path17) ?? { added: 0, deleted: 0 };
7618
- counts.set(path17, { added: prev.added + added, deleted: prev.deleted + deleted });
8640
+ if (!path18) continue;
8641
+ const prev = counts.get(path18) ?? { added: 0, deleted: 0 };
8642
+ counts.set(path18, { added: prev.added + added, deleted: prev.deleted + deleted });
7619
8643
  }
7620
8644
  };
7621
8645
  parseNumstat(unstagedNumstat);
@@ -7627,7 +8651,7 @@ async function handleGitChanges(ws, projectRoot) {
7627
8651
  if (!rec || rec.length < 3) continue;
7628
8652
  const x = rec[0] ?? " ";
7629
8653
  const y = rec[1] ?? " ";
7630
- const path17 = rec.slice(3);
8654
+ const path18 = rec.slice(3);
7631
8655
  const isRename = x === "R" || x === "C" || y === "R" || y === "C";
7632
8656
  if (isRename) i += 1;
7633
8657
  let status;
@@ -7639,13 +8663,13 @@ async function handleGitChanges(ws, projectRoot) {
7639
8663
  else if (x === "D" || y === "D") status = "D";
7640
8664
  else status = "M";
7641
8665
  const staged = x !== " " && x !== "?";
7642
- let added = counts.get(path17)?.added ?? 0;
7643
- let deleted = counts.get(path17)?.deleted ?? 0;
8666
+ let added = counts.get(path18)?.added ?? 0;
8667
+ let deleted = counts.get(path18)?.deleted ?? 0;
7644
8668
  if (status === "?") {
7645
8669
  added = 0;
7646
8670
  deleted = 0;
7647
8671
  }
7648
- files.push({ path: path17, status, added, deleted, staged });
8672
+ files.push({ path: path18, status, added, deleted, staged });
7649
8673
  }
7650
8674
  send(ws, { type: "git.changes", payload: { files } });
7651
8675
  } catch (err) {
@@ -7656,21 +8680,21 @@ async function handleGitChanges(ws, projectRoot) {
7656
8680
  }
7657
8681
  }
7658
8682
  var MAX_DIFF_BYTES = 2 * 1024 * 1024;
7659
- async function handleGitDiff(ws, projectRoot, path17) {
8683
+ async function handleGitDiff(ws, projectRoot, path18) {
7660
8684
  const cwd = projectRoot || void 0;
7661
- const reply = (extra) => send(ws, { type: "git.diff", payload: { path: path17, ...extra } });
7662
- if (!path17 || path17.includes("\0") || path17.includes("..") || nodePath.isAbsolute(path17)) {
8685
+ const reply = (extra) => send(ws, { type: "git.diff", payload: { path: path18, ...extra } });
8686
+ if (!path18 || path18.includes("\0") || path18.includes("..") || nodePath.isAbsolute(path18)) {
7663
8687
  reply({ oldText: "", newText: "", error: "invalid path" });
7664
8688
  return;
7665
8689
  }
7666
8690
  try {
7667
8691
  const git = makeGit(cwd);
7668
8692
  const { readFile: readFile9 } = await import("fs/promises");
7669
- const { join: join12 } = await import("path");
7670
- const oldText = await git(["show", `HEAD:${path17}`]);
8693
+ const { join: join14 } = await import("path");
8694
+ const oldText = await git(["show", `HEAD:${path18}`]);
7671
8695
  let newText = "";
7672
8696
  try {
7673
- const abs = cwd ? join12(cwd, path17) : path17;
8697
+ const abs = cwd ? join14(cwd, path18) : path18;
7674
8698
  const buf = await readFile9(abs);
7675
8699
  if (buf.includes(0)) {
7676
8700
  reply({ oldText: "", newText: "", binary: true });
@@ -7751,10 +8775,10 @@ async function handleProcessKillAll(ws) {
7751
8775
  }
7752
8776
 
7753
8777
  // src/server/goal-handlers.ts
7754
- import { resolveWstackPaths } from "@wrongstack/core/utils";
8778
+ import { resolveWstackPaths as resolveWstackPaths3 } from "@wrongstack/core/utils";
7755
8779
  async function handleGoalGet(projectRoot, broadcast2) {
7756
8780
  try {
7757
- const goalPath = resolveWstackPaths({ projectRoot }).projectGoal;
8781
+ const goalPath = resolveWstackPaths3({ projectRoot }).projectGoal;
7758
8782
  const { readFile: readFile9 } = await import("fs/promises");
7759
8783
  const raw = await readFile9(goalPath, "utf8");
7760
8784
  const goal = JSON.parse(raw);
@@ -7768,8 +8792,11 @@ async function handleGoalGet(projectRoot, broadcast2) {
7768
8792
  async function startWebUI(opts = {}) {
7769
8793
  ensureSessionShell();
7770
8794
  const requestedWsPort = opts.wsPort ?? 3457;
7771
- const wsHost = opts.wsHost ?? "127.0.0.1";
7772
- const requestedHttpPort = Number.parseInt(process.env["PORT"] ?? "3456", 10);
8795
+ const wsHost = opts.wsHost ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
8796
+ const requestedHttpPort = opts.httpPort ?? opts.webuiPort ?? opts.port ?? Number.parseInt(process.env["WEBUI_PORT"] ?? process.env["PORT"] ?? "3456", 10);
8797
+ const publicUrl = opts.publicUrl ?? process.env["WEBUI_PUBLIC_URL"];
8798
+ const publicWsUrl = opts.publicWsUrl ?? process.env["WEBUI_PUBLIC_WS_URL"];
8799
+ const requireToken = opts.requireToken ?? envFlag("WEBUI_REQUIRE_TOKEN");
7773
8800
  const strictPort = process.env["WEBUI_STRICT_PORT"] === "1" || process.env["WEBUI_STRICT_PORT"] === "true";
7774
8801
  let wsPort = requestedWsPort;
7775
8802
  let httpPort = requestedHttpPort;
@@ -7809,7 +8836,7 @@ async function startWebUI(opts = {}) {
7809
8836
  const write = async () => {
7810
8837
  let raw;
7811
8838
  try {
7812
- raw = await fs13.readFile(globalConfigPath, "utf8");
8839
+ raw = await fs14.readFile(globalConfigPath, "utf8");
7813
8840
  } catch {
7814
8841
  raw = "{}";
7815
8842
  }
@@ -7883,6 +8910,7 @@ async function startWebUI(opts = {}) {
7883
8910
  toolRegistry.register(makeMailSendTool({ projectDir: wpaths.projectDir, events }));
7884
8911
  toolRegistry.register(makeMailInboxTool({ projectDir: wpaths.projectDir, events }));
7885
8912
  applyToolDescriptionModes(toolRegistry, config.tools?.descriptionMode);
8913
+ applyToolResultRenderModes(toolRegistry, config.tools?.resultRenderMode);
7886
8914
  configureExecPolicy(config.tools?.exec ?? {});
7887
8915
  console.log("[WebUI] Tool registry loaded:", toolRegistry.list().length, "tools");
7888
8916
  const mcpRegistry = new MCPRegistry({
@@ -7927,7 +8955,7 @@ async function startWebUI(opts = {}) {
7927
8955
  sessionId: session.id,
7928
8956
  projectSlug: wpaths.projectSlug,
7929
8957
  projectRoot,
7930
- projectName: path16.basename(projectRoot),
8958
+ projectName: path17.basename(projectRoot),
7931
8959
  workingDir,
7932
8960
  clientType: "webui",
7933
8961
  pid: process.pid,
@@ -7947,7 +8975,7 @@ async function startWebUI(opts = {}) {
7947
8975
  const hqTelemetry = createHqPublisherFromEnv({
7948
8976
  clientKind: "webui",
7949
8977
  projectRoot,
7950
- projectName: path16.basename(projectRoot),
8978
+ projectName: path17.basename(projectRoot),
7951
8979
  appConfig: config,
7952
8980
  socketFactory: (url) => new WebSocket2(url)
7953
8981
  });
@@ -7959,7 +8987,7 @@ async function startWebUI(opts = {}) {
7959
8987
  events,
7960
8988
  sessionId: session.id,
7961
8989
  projectRoot,
7962
- projectName: path16.basename(projectRoot),
8990
+ projectName: path17.basename(projectRoot),
7963
8991
  globalRoot: wpaths.globalRoot,
7964
8992
  initialAgents: statusTracker?.getAgents(),
7965
8993
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -8015,19 +9043,39 @@ async function startWebUI(opts = {}) {
8015
9043
  };
8016
9044
  const skillLoader = config.features.skills ? new DefaultSkillLoader({ paths: wpaths }) : void 0;
8017
9045
  const skillInstaller = config.features.skills ? new SkillInstaller({
8018
- manifestPath: path16.join(wstackGlobalRoot2(), "installed-skills.json"),
8019
- projectSkillsDir: path16.join(projectRoot, ".wrongstack", "skills"),
8020
- globalSkillsDir: path16.join(wstackGlobalRoot2(), "skills"),
9046
+ manifestPath: path17.join(wstackGlobalRoot3(), "installed-skills.json"),
9047
+ projectSkillsDir: path17.join(projectRoot, ".wrongstack", "skills"),
9048
+ globalSkillsDir: path17.join(wstackGlobalRoot3(), "skills"),
8021
9049
  projectHash: projectHash(projectRoot),
8022
9050
  skillLoader
8023
9051
  }) : void 0;
9052
+ const promptsEnabled = config.features.prompts !== false;
9053
+ const bundledPromptsDir = promptsEnabled ? (() => {
9054
+ try {
9055
+ const req = createRequire2(import.meta.url);
9056
+ return path17.join(
9057
+ path17.dirname(req.resolve("@wrongstack/core/package.json")),
9058
+ "data",
9059
+ "prompts"
9060
+ );
9061
+ } catch {
9062
+ return void 0;
9063
+ }
9064
+ })() : void 0;
9065
+ const promptLoader = promptsEnabled ? new DefaultPromptLoader({ paths: wpaths, bundledDir: bundledPromptsDir }) : void 0;
9066
+ const promptUsage = new PromptUsageStore(wpaths.promptUsage);
9067
+ const promptsCtx = { promptLoader, promptUsage };
8024
9068
  const systemPromptBuilder = new DefaultSystemPromptBuilder3({
8025
9069
  memoryStore,
8026
9070
  skillLoader,
8027
9071
  modeStore,
8028
9072
  modeId,
8029
9073
  modePrompt,
8030
- modelCapabilities: () => modelCapabilitiesRef.current
9074
+ modelCapabilities: () => modelCapabilitiesRef.current,
9075
+ instructionPaths: {
9076
+ globalDir: wpaths.globalInstructions,
9077
+ projectDir: wpaths.inProjectInstructions
9078
+ }
8031
9079
  });
8032
9080
  let onlineAgents = [];
8033
9081
  try {
@@ -8122,6 +9170,10 @@ async function startWebUI(opts = {}) {
8122
9170
  context.meta["enhanceLanguage"] = autonomyCfg["enhanceLanguage"] ?? "original";
8123
9171
  context.meta["nextPrediction"] = config.nextPrediction ?? false;
8124
9172
  context.meta["fallbackModels"] = config.fallbackModels ?? [];
9173
+ context.meta["fallbackProfiles"] = config.fallbackProfiles ?? {};
9174
+ context.meta["favoriteModels"] = config.favoriteModels ?? [];
9175
+ context.meta["favoriteModelsOnly"] = config.favoriteModelsOnly === true;
9176
+ context.meta["modelMatrix"] = config.modelMatrix ?? {};
8125
9177
  context.meta["fallbackAuto"] = config.fallbackAuto !== false;
8126
9178
  context.meta["featureMcp"] = config.features.mcp !== false;
8127
9179
  context.meta["featurePlugins"] = config.features.plugins !== false;
@@ -8134,6 +9186,20 @@ async function startWebUI(opts = {}) {
8134
9186
  context.meta["logLevel"] = config.log?.level ?? "info";
8135
9187
  context.meta["auditLevel"] = config.session?.auditLevel ?? "standard";
8136
9188
  context.meta["maxIterations"] = config.tools?.maxIterations ?? 500;
9189
+ context.meta["contextMode"] = config.context?.mode ?? "balanced";
9190
+ {
9191
+ const tsm = config.features?.tokenSavingMode;
9192
+ context.meta["tokenSavingTier"] = typeof tsm === "string" ? tsm : tsm ? "medium" : "off";
9193
+ }
9194
+ context.meta["maxConcurrent"] = typeof config.maxConcurrent === "number" ? config.maxConcurrent : 10;
9195
+ context.meta["titleAnimation"] = autonomyCfg["terminalTitleAnimation"] !== false;
9196
+ {
9197
+ const mr = config.modelRuntime ?? {};
9198
+ context.meta["reasoningMode"] = mr.reasoning?.mode ?? "auto";
9199
+ context.meta["reasoningEffort"] = mr.reasoning?.effort ?? "high";
9200
+ context.meta["reasoningPreserve"] = mr.reasoning?.preserve === true;
9201
+ context.meta["cacheTtl"] = mr.cache?.ttl ?? "default";
9202
+ }
8137
9203
  const hqConfig = config.hq;
8138
9204
  context.meta["hqEnabled"] = hqConfig?.enabled === true;
8139
9205
  context.meta["hqUrl"] = hqConfig?.url ?? "";
@@ -8167,6 +9233,10 @@ async function startWebUI(opts = {}) {
8167
9233
  "indexOnStart",
8168
9234
  "contextAutoCompact",
8169
9235
  "contextStrategy",
9236
+ "contextMode",
9237
+ "tokenSavingTier",
9238
+ "maxConcurrent",
9239
+ "titleAnimation",
8170
9240
  "logLevel",
8171
9241
  "auditLevel",
8172
9242
  "hqEnabled",
@@ -8182,6 +9252,10 @@ async function startWebUI(opts = {}) {
8182
9252
  "reasoningPreserve",
8183
9253
  "cacheTtl",
8184
9254
  "fallbackModels",
9255
+ "fallbackProfiles",
9256
+ "favoriteModels",
9257
+ "favoriteModelsOnly",
9258
+ "modelMatrix",
8185
9259
  "fallbackAuto"
8186
9260
  ];
8187
9261
  const prefSnapshot = () => {
@@ -8214,6 +9288,15 @@ async function startWebUI(opts = {}) {
8214
9288
  if (autonomyTouched) decrypted.autonomy = autonomyCfg;
8215
9289
  if (typeof payload["nextPrediction"] === "boolean") decrypted.nextPrediction = payload["nextPrediction"];
8216
9290
  if (Array.isArray(payload["fallbackModels"])) decrypted.fallbackModels = payload["fallbackModels"];
9291
+ if (payload["fallbackProfiles"] && typeof payload["fallbackProfiles"] === "object" && !Array.isArray(payload["fallbackProfiles"])) {
9292
+ decrypted.fallbackProfiles = payload["fallbackProfiles"];
9293
+ }
9294
+ if (Array.isArray(payload["favoriteModels"])) decrypted.favoriteModels = payload["favoriteModels"];
9295
+ if (typeof payload["favoriteModelsOnly"] === "boolean")
9296
+ decrypted.favoriteModelsOnly = payload["favoriteModelsOnly"];
9297
+ if (payload["modelMatrix"] && typeof payload["modelMatrix"] === "object" && !Array.isArray(payload["modelMatrix"])) {
9298
+ decrypted.modelMatrix = payload["modelMatrix"];
9299
+ }
8217
9300
  if (typeof payload["fallbackAuto"] === "boolean") decrypted.fallbackAuto = payload["fallbackAuto"];
8218
9301
  const FEATURE_MAP = {
8219
9302
  featureMcp: "mcp",
@@ -8229,12 +9312,26 @@ async function startWebUI(opts = {}) {
8229
9312
  decrypted.features = feats;
8230
9313
  }
8231
9314
  }
8232
- if (typeof payload["contextAutoCompact"] === "boolean" || typeof payload["contextStrategy"] === "string") {
9315
+ if (typeof payload["contextAutoCompact"] === "boolean" || typeof payload["contextStrategy"] === "string" || typeof payload["contextMode"] === "string") {
8233
9316
  const ctxCfg = decrypted.context ?? {};
8234
9317
  if (typeof payload["contextAutoCompact"] === "boolean") ctxCfg.autoCompact = payload["contextAutoCompact"];
8235
9318
  if (typeof payload["contextStrategy"] === "string") ctxCfg.strategy = payload["contextStrategy"];
9319
+ if (typeof payload["contextMode"] === "string") ctxCfg.mode = payload["contextMode"];
8236
9320
  decrypted.context = ctxCfg;
8237
9321
  }
9322
+ if (typeof payload["tokenSavingTier"] === "string") {
9323
+ const featsCfg = decrypted.features ?? {};
9324
+ featsCfg.tokenSavingMode = payload["tokenSavingTier"];
9325
+ decrypted.features = featsCfg;
9326
+ }
9327
+ if (typeof payload["maxConcurrent"] === "number") {
9328
+ decrypted.maxConcurrent = payload["maxConcurrent"];
9329
+ }
9330
+ if (typeof payload["titleAnimation"] === "boolean") {
9331
+ const autoCfg = decrypted.autonomy ?? {};
9332
+ autoCfg.terminalTitleAnimation = payload["titleAnimation"];
9333
+ decrypted.autonomy = autoCfg;
9334
+ }
8238
9335
  if (typeof payload["logLevel"] === "string") {
8239
9336
  const logCfg = decrypted.log ?? {};
8240
9337
  logCfg.level = payload["logLevel"];
@@ -8305,6 +9402,7 @@ async function startWebUI(opts = {}) {
8305
9402
  const collabInject = collabInjectMiddleware(collabBus, { logger });
8306
9403
  Object.defineProperty(collabInject, "name", { value: "collab-inject" });
8307
9404
  pipelines.toolCall.prepend(collabInject);
9405
+ installDesignStudioMiddleware({ pipelines, ctx: context });
8308
9406
  const codebaseIndexing = setupWebUICodebaseIndexing({
8309
9407
  config,
8310
9408
  context,
@@ -8400,6 +9498,17 @@ async function startWebUI(opts = {}) {
8400
9498
  perIterationOutputCapBytes: config.tools?.perIterationOutputCapBytes ?? DEFAULT_TOOLS_CONFIG.perIterationOutputCapBytes,
8401
9499
  tracer: void 0
8402
9500
  });
9501
+ const webuiLogger = container.resolve(TOKENS.Logger);
9502
+ void discoverMailboxBridgeForWebui({
9503
+ projectRoot,
9504
+ config,
9505
+ logger: webuiLogger,
9506
+ ctx: context
9507
+ }).catch((err) => {
9508
+ webuiLogger.warn("mailbox bridge discovery threw on webui boot", {
9509
+ err: err instanceof Error ? err.message : String(err)
9510
+ });
9511
+ });
8403
9512
  const agent = new Agent({
8404
9513
  container,
8405
9514
  tools: toolRegistry,
@@ -8496,7 +9605,18 @@ async function startWebUI(opts = {}) {
8496
9605
  projectRoot
8497
9606
  );
8498
9607
  const specsHandler = new SpecsWebSocketHandler(wpaths.projectSpecs, wpaths.projectTaskGraphs);
8499
- const sddBoardHandler = new SddBoardWebSocketHandler(wpaths.projectSddBoards);
9608
+ const sddBoardHandler = new SddBoardWebSocketHandler(wpaths.projectSddBoards, void 0, {
9609
+ projectRoot,
9610
+ paths: {
9611
+ projectSpecs: wpaths.projectSpecs,
9612
+ projectTaskGraphs: wpaths.projectTaskGraphs,
9613
+ projectSddSession: wpaths.projectSddSession,
9614
+ projectSddBoards: wpaths.projectSddBoards
9615
+ }
9616
+ });
9617
+ void cleanupStaleSddWorktrees3({ projectRoot, boardsDir: wpaths.projectSddBoards }).catch(
9618
+ () => void 0
9619
+ );
8500
9620
  const sddWizardHandler = new SddWizardWebSocketHandler(
8501
9621
  buildSddWizardDeps({
8502
9622
  agent,
@@ -8518,7 +9638,10 @@ async function startWebUI(opts = {}) {
8518
9638
  }
8519
9639
  })
8520
9640
  );
8521
- const worktreeHandler = new WorktreeWebSocketHandler(events, logger);
9641
+ const worktreeHandler = new WorktreeWebSocketHandler(events, logger, {
9642
+ projectRoot,
9643
+ boardsDir: wpaths.projectSddBoards
9644
+ });
8522
9645
  const terminalHandler = new TerminalWebSocketHandler(() => workingDir, logger);
8523
9646
  const collabHandler = new CollaborationWebSocketHandler(
8524
9647
  events,
@@ -8557,15 +9680,23 @@ async function startWebUI(opts = {}) {
8557
9680
  inputCost,
8558
9681
  outputCost,
8559
9682
  cacheReadCost,
8560
- projectName: path16.basename(projectRoot) || projectRoot,
9683
+ projectName: path17.basename(projectRoot) || projectRoot,
8561
9684
  projectRoot,
8562
9685
  cwd: workingDir,
8563
9686
  mode: modeId,
8564
9687
  contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID2)
8565
9688
  };
8566
9689
  }
8567
- const wsToken = generateAuthToken();
8568
- console.log("[WebUI] WS auth token generated (redacted from logs)");
9690
+ const wsToken = resolveAuthToken(opts.accessToken);
9691
+ console.log("[WebUI] WS auth token ready");
9692
+ const publicHostnames = [publicUrl, publicWsUrl].map((value) => {
9693
+ if (!value) return void 0;
9694
+ try {
9695
+ return new URL(value).hostname;
9696
+ } catch {
9697
+ return void 0;
9698
+ }
9699
+ }).filter((value) => Boolean(value));
8569
9700
  const verifyClient2 = (info) => verifyClient({
8570
9701
  origin: info.origin,
8571
9702
  url: info.req.url ?? "",
@@ -8577,7 +9708,10 @@ async function startWebUI(opts = {}) {
8577
9708
  // exposure class.
8578
9709
  cookieHeader: info.req.headers.cookie,
8579
9710
  wsHost,
8580
- expectedToken: wsToken
9711
+ expectedToken: wsToken,
9712
+ requireToken,
9713
+ allowedHostnames: publicHostnames,
9714
+ allowBrowserUrlToken: Boolean(publicWsUrl)
8581
9715
  });
8582
9716
  const WS_MAX_PAYLOAD = 8 * 1024 * 1024;
8583
9717
  const wssPrimary = new WebSocketServer({
@@ -8695,8 +9829,8 @@ async function startWebUI(opts = {}) {
8695
9829
  clients.delete(ws);
8696
9830
  if (closing) rateLimits.delete(closing.connId);
8697
9831
  if (pendingConfirms.size > 0) {
8698
- for (const [id, resolve9] of pendingConfirms) {
8699
- resolve9("no");
9832
+ for (const [id, resolve10] of pendingConfirms) {
9833
+ resolve10("no");
8700
9834
  pendingConfirms.delete(id);
8701
9835
  }
8702
9836
  }
@@ -8776,21 +9910,21 @@ async function startWebUI(opts = {}) {
8776
9910
  });
8777
9911
  }
8778
9912
  async function touchProjectEntry(root, workDir) {
8779
- const resolved = path16.resolve(root);
9913
+ const resolved = path17.resolve(root);
8780
9914
  const manifest = await loadManifest(globalConfigPath);
8781
9915
  const now = (/* @__PURE__ */ new Date()).toISOString();
8782
- const existing = manifest.projects.find((p) => path16.resolve(p.root) === resolved);
9916
+ const existing = manifest.projects.find((p) => path17.resolve(p.root) === resolved);
8783
9917
  if (existing) {
8784
9918
  existing.lastSeen = now;
8785
- if (workDir) existing.lastWorkingDir = path16.resolve(workDir);
9919
+ if (workDir) existing.lastWorkingDir = path17.resolve(workDir);
8786
9920
  } else {
8787
9921
  manifest.projects.push({
8788
- name: path16.basename(resolved),
9922
+ name: path17.basename(resolved),
8789
9923
  root: resolved,
8790
9924
  slug: generateProjectSlug(resolved),
8791
9925
  createdAt: now,
8792
9926
  lastSeen: now,
8793
- lastWorkingDir: workDir ? path16.resolve(workDir) : void 0
9927
+ lastWorkingDir: workDir ? path17.resolve(workDir) : void 0
8794
9928
  });
8795
9929
  }
8796
9930
  await saveManifest(manifest, globalConfigPath);
@@ -8835,6 +9969,8 @@ async function startWebUI(opts = {}) {
8835
9969
  if (await handleSpecsRoute(ws, msg, specsRoutes)) return;
8836
9970
  if (await handleSddBoardRoute(ws, msg, sddBoardRoutes)) return;
8837
9971
  if (await handleSddWizardRoute(ws, msg, sddWizardRoutes)) return;
9972
+ if (msg.type.startsWith("worktree.") && await worktreeHandler.handleMessage(msg))
9973
+ return;
8838
9974
  switch (msg.type) {
8839
9975
  // Collaboration messages short-circuit the user/agent flow.
8840
9976
  // They don't touch runLock, the agent loop, or the message queue —
@@ -8905,10 +10041,10 @@ async function startWebUI(opts = {}) {
8905
10041
  }
8906
10042
  case "tool.confirm_result": {
8907
10043
  const { id, decision } = msg.payload;
8908
- const resolve9 = pendingConfirms.get(id);
8909
- if (resolve9) {
10044
+ const resolve10 = pendingConfirms.get(id);
10045
+ if (resolve10) {
8910
10046
  pendingConfirms.delete(id);
8911
- resolve9(decision);
10047
+ resolve10(decision);
8912
10048
  }
8913
10049
  break;
8914
10050
  }
@@ -8992,6 +10128,48 @@ async function startWebUI(opts = {}) {
8992
10128
  case "skills.export":
8993
10129
  await handleSkillsExport(ws, { skillLoader, skillInstaller, projectRoot });
8994
10130
  break;
10131
+ // Prompt library — shared handlers (prompts-handlers.ts).
10132
+ case "prompts.list":
10133
+ await handlePromptsList(ws, promptsCtx);
10134
+ break;
10135
+ case "prompts.search":
10136
+ await handlePromptsSearch(ws, promptsCtx, msg);
10137
+ break;
10138
+ case "prompts.content":
10139
+ await handlePromptsContent(ws, promptsCtx, msg);
10140
+ break;
10141
+ case "prompts.favorite":
10142
+ await handlePromptsFavorite(ws, promptsCtx, msg);
10143
+ break;
10144
+ case "prompts.create":
10145
+ await handlePromptsCreate(ws, promptsCtx, msg);
10146
+ break;
10147
+ case "prompts.used":
10148
+ await handlePromptsUsed(ws, promptsCtx, msg);
10149
+ break;
10150
+ case "prompts.recent":
10151
+ await handlePromptsRecent(ws, promptsCtx);
10152
+ break;
10153
+ // Design Studio — shared handlers (design-handlers.ts). agentMeta is the
10154
+ // live context so design.use pins the active kit for the next turn.
10155
+ case "design.list":
10156
+ await handleDesignList(ws, { projectRoot, agentMeta: context });
10157
+ break;
10158
+ case "design.use":
10159
+ await handleDesignUse(ws, { projectRoot, agentMeta: context }, msg);
10160
+ break;
10161
+ case "design.state":
10162
+ await handleDesignState(ws, { projectRoot, agentMeta: context });
10163
+ break;
10164
+ case "design.set":
10165
+ await handleDesignSet(ws, { projectRoot, agentMeta: context }, msg);
10166
+ break;
10167
+ case "design.materialize":
10168
+ await handleDesignMaterialize(ws, { projectRoot, agentMeta: context }, msg);
10169
+ break;
10170
+ case "design.verify":
10171
+ await handleDesignVerify(ws, { projectRoot, agentMeta: context });
10172
+ break;
8995
10173
  case "diag.get": {
8996
10174
  const usage = tokenCounter.total();
8997
10175
  send(ws, {
@@ -9072,11 +10250,29 @@ async function startWebUI(opts = {}) {
9072
10250
  messages: context.messages.length,
9073
10251
  readFiles: context.readFiles.size,
9074
10252
  tools: toolRegistry.list().length,
10253
+ sideEffectCount: context.sideEffects?.length ?? 0,
9075
10254
  elapsedMs: Date.now() - sessionStartedAt
9076
10255
  }
9077
10256
  });
9078
10257
  break;
9079
10258
  }
10259
+ case "side_effects.list": {
10260
+ const sideEffects = context.sideEffects ?? [];
10261
+ send(ws, {
10262
+ type: "side_effects",
10263
+ payload: {
10264
+ sideEffects: sideEffects.slice(-50).map((se) => ({
10265
+ toolUseId: se.toolUseId,
10266
+ toolName: se.toolName,
10267
+ ts: se.ts,
10268
+ input: se.input,
10269
+ outcome: se.outcome,
10270
+ risk: se.risk
10271
+ }))
10272
+ }
10273
+ });
10274
+ break;
10275
+ }
9080
10276
  case "process.list": {
9081
10277
  await handleProcessList(ws);
9082
10278
  break;
@@ -9331,6 +10527,7 @@ async function startWebUI(opts = {}) {
9331
10527
  toolRegistry,
9332
10528
  config,
9333
10529
  projectRoot,
10530
+ globalRoot: wpaths.globalRoot,
9334
10531
  clients,
9335
10532
  setModeId: (id) => {
9336
10533
  modeId = id;
@@ -9367,6 +10564,16 @@ async function startWebUI(opts = {}) {
9367
10564
  config.features.modelsRegistry = payload["featureModelsRegistry"];
9368
10565
  if (Array.isArray(payload["fallbackModels"]))
9369
10566
  config.fallbackModels = payload["fallbackModels"];
10567
+ if (payload["fallbackProfiles"] && typeof payload["fallbackProfiles"] === "object" && !Array.isArray(payload["fallbackProfiles"])) {
10568
+ config.fallbackProfiles = payload["fallbackProfiles"];
10569
+ }
10570
+ if (Array.isArray(payload["favoriteModels"]))
10571
+ config.favoriteModels = payload["favoriteModels"];
10572
+ if (typeof payload["favoriteModelsOnly"] === "boolean")
10573
+ config.favoriteModelsOnly = payload["favoriteModelsOnly"];
10574
+ if (payload["modelMatrix"] && typeof payload["modelMatrix"] === "object" && !Array.isArray(payload["modelMatrix"])) {
10575
+ config.modelMatrix = payload["modelMatrix"];
10576
+ }
9370
10577
  if (typeof payload["fallbackAuto"] === "boolean")
9371
10578
  config.fallbackAuto = payload["fallbackAuto"];
9372
10579
  if (typeof payload["contextAutoCompact"] === "boolean") {
@@ -9418,7 +10625,7 @@ async function startWebUI(opts = {}) {
9418
10625
  sendResult2(ws, false, parsed.message);
9419
10626
  return;
9420
10627
  }
9421
- return handleMailboxMessages(ws, { projectRoot, globalRoot: path16.dirname(globalConfigPath) }, parsed.value);
10628
+ return handleMailboxMessages(ws, { projectRoot, globalRoot: path17.dirname(globalConfigPath) }, parsed.value);
9422
10629
  },
9423
10630
  agents: (ws, msg) => {
9424
10631
  const parsed = validateMailboxAgentsPayload(msg.payload);
@@ -9426,16 +10633,16 @@ async function startWebUI(opts = {}) {
9426
10633
  sendResult2(ws, false, parsed.message);
9427
10634
  return;
9428
10635
  }
9429
- return handleMailboxAgents(ws, { projectRoot, globalRoot: path16.dirname(globalConfigPath) }, parsed.value);
10636
+ return handleMailboxAgents(ws, { projectRoot, globalRoot: path17.dirname(globalConfigPath) }, parsed.value);
9430
10637
  },
9431
- clear: (ws) => handleMailboxClear(ws, { projectRoot, globalRoot: path16.dirname(globalConfigPath) }),
10638
+ clear: (ws) => handleMailboxClear(ws, { projectRoot, globalRoot: path17.dirname(globalConfigPath) }),
9432
10639
  purge: (ws, msg) => {
9433
10640
  const parsed = validateMailboxPurgePayload(msg.payload);
9434
10641
  if (!parsed.ok) {
9435
10642
  sendResult2(ws, false, parsed.message);
9436
10643
  return;
9437
10644
  }
9438
- return handleMailboxPurge(ws, { projectRoot, globalRoot: path16.dirname(globalConfigPath) }, parsed.value);
10645
+ return handleMailboxPurge(ws, { projectRoot, globalRoot: path17.dirname(globalConfigPath) }, parsed.value);
9439
10646
  }
9440
10647
  };
9441
10648
  mcpRoutes = {
@@ -9515,18 +10722,25 @@ async function startWebUI(opts = {}) {
9515
10722
  };
9516
10723
  const httpServer = createHttpServer({
9517
10724
  host: wsHost,
9518
- distDir: path16.resolve(import.meta.dirname, "../../dist"),
10725
+ distDir: path17.resolve(import.meta.dirname, "../../dist"),
9519
10726
  wsPort,
10727
+ publicWsUrl,
9520
10728
  globalRoot: wpaths.globalRoot,
9521
10729
  apiToken: wsToken,
10730
+ requireToken,
9522
10731
  watcherMetrics,
9523
10732
  onFleetPing: () => {
9524
10733
  void fleetBroadcast?.();
9525
10734
  }
9526
10735
  });
9527
- const registryBaseDir = path16.dirname(globalConfigPath);
10736
+ const registryBaseDir = path17.dirname(globalConfigPath);
9528
10737
  httpServer.listen(httpPort, wsHost, () => {
9529
- const openUrl = `http://${wsHost}:${httpPort}`;
10738
+ const openUrl = buildWebUIAccessUrl({
10739
+ host: wsHost,
10740
+ port: httpPort,
10741
+ token: wsToken,
10742
+ publicUrl
10743
+ });
9530
10744
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
9531
10745
  if (opts.open) openBrowser(openUrl);
9532
10746
  void registerInstance(
@@ -9536,9 +10750,9 @@ async function startWebUI(opts = {}) {
9536
10750
  wsPort,
9537
10751
  host: wsHost,
9538
10752
  projectRoot,
9539
- projectName: path16.basename(projectRoot) || projectRoot,
10753
+ projectName: path17.basename(projectRoot) || projectRoot,
9540
10754
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9541
- url: `http://${wsHost}:${httpPort}`
10755
+ url: buildWebUIAccessUrl({ host: wsHost, port: httpPort, publicUrl })
9542
10756
  },
9543
10757
  registryBaseDir
9544
10758
  ).catch((err) => console.warn(JSON.stringify({
@@ -9580,7 +10794,55 @@ async function startWebUI(opts = {}) {
9580
10794
 
9581
10795
  // src/server/entry.ts
9582
10796
  var argv = process.argv.slice(2);
9583
- if (argv.includes("--list") || argv.includes("-l") || argv[0] === "ls") {
10797
+ function readArg(names) {
10798
+ for (let i = 0; i < argv.length; i++) {
10799
+ const current = argv[i];
10800
+ if (!current) continue;
10801
+ for (const name2 of names) {
10802
+ if (current === name2) {
10803
+ const next = argv[i + 1];
10804
+ if (!next || next.startsWith("-")) {
10805
+ throw new Error(`${name2} requires a value`);
10806
+ }
10807
+ return next;
10808
+ }
10809
+ if (current.startsWith(`${name2}=`)) return current.slice(name2.length + 1);
10810
+ }
10811
+ }
10812
+ return void 0;
10813
+ }
10814
+ function parsePort(value, fallback, label) {
10815
+ if (value === void 0) return fallback;
10816
+ const parsed = Number.parseInt(value, 10);
10817
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
10818
+ throw new Error(`${label} must be a port between 1 and 65535`);
10819
+ }
10820
+ return parsed;
10821
+ }
10822
+ function envFlag2(name2) {
10823
+ const value = process.env[name2]?.trim().toLowerCase();
10824
+ return value === "1" || value === "true" || value === "yes" || value === "on";
10825
+ }
10826
+ function printHelp() {
10827
+ console.log(`Usage: wstackui [options]
10828
+
10829
+ Options:
10830
+ --host <host> Bind host/interface (default: 127.0.0.1)
10831
+ --port <port> HTTP frontend port (default: 3456)
10832
+ --ws-port <port> WebSocket backend port (default: 3457)
10833
+ --token <token> Fixed access token/password (default: random per process)
10834
+ --public-url <url> Browser-facing HTTP URL for tunnels/proxies
10835
+ --public-ws-url <url> Browser-facing ws:// or wss:// URL for tunnels/proxies
10836
+ --require-token Require token/password even on loopback binds
10837
+ --open, -o Open the browser after startup
10838
+ --list, -l, ls List running WebUI instances
10839
+ --help, -h Show this help
10840
+ `);
10841
+ }
10842
+ if (argv.includes("--help") || argv.includes("-h")) {
10843
+ printHelp();
10844
+ process.exit(0);
10845
+ } else if (argv.includes("--list") || argv.includes("-l") || argv[0] === "ls") {
9584
10846
  listInstances().then((instances) => {
9585
10847
  console.log(formatInstances(instances));
9586
10848
  process.exit(0);
@@ -9594,11 +10856,40 @@ if (argv.includes("--list") || argv.includes("-l") || argv[0] === "ls") {
9594
10856
  process.exit(1);
9595
10857
  });
9596
10858
  } else {
9597
- const wsPort = Number.parseInt(process.env["WS_PORT"] ?? "3457", 10);
9598
- const wsHost = process.env["WS_HOST"] ?? "127.0.0.1";
10859
+ let wsPort;
10860
+ let httpPort;
10861
+ let wsHost;
10862
+ let accessToken;
10863
+ let publicUrl;
10864
+ let publicWsUrl;
10865
+ try {
10866
+ wsHost = readArg(["--host"]) ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
10867
+ httpPort = parsePort(
10868
+ readArg(["--port", "--http-port"]) ?? process.env["WEBUI_PORT"] ?? process.env["PORT"],
10869
+ 3456,
10870
+ "--port"
10871
+ );
10872
+ wsPort = parsePort(readArg(["--ws-port"]) ?? process.env["WS_PORT"], 3457, "--ws-port");
10873
+ accessToken = readArg(["--token", "--auth-token"]) ?? process.env["WEBUI_TOKEN"] ?? process.env["WEBUI_AUTH_TOKEN"];
10874
+ publicUrl = readArg(["--public-url"]) ?? process.env["WEBUI_PUBLIC_URL"];
10875
+ publicWsUrl = readArg(["--public-ws-url"]) ?? process.env["WEBUI_PUBLIC_WS_URL"];
10876
+ } catch (err) {
10877
+ console.error(err instanceof Error ? err.message : String(err));
10878
+ process.exit(1);
10879
+ }
9599
10880
  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) => {
10881
+ const requireToken = argv.includes("--require-token") || envFlag2("WEBUI_REQUIRE_TOKEN");
10882
+ console.log(`[WebUI] Starting standalone server on ${wsHost} (http:${httpPort}, ws:${wsPort})...`);
10883
+ startWebUI({
10884
+ wsPort,
10885
+ wsHost,
10886
+ httpPort,
10887
+ accessToken,
10888
+ publicUrl,
10889
+ publicWsUrl,
10890
+ requireToken,
10891
+ open
10892
+ }).catch((err) => {
9602
10893
  console.error(JSON.stringify({
9603
10894
  level: "fatal",
9604
10895
  event: "webui.startup_failed",