@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.
@@ -170,9 +170,12 @@ var BOOLEAN_PREF_KEYS = /* @__PURE__ */ new Set([
170
170
  "reasoningPreserve",
171
171
  "hqEnabled",
172
172
  "hqRawContent",
173
- "fallbackAuto"
173
+ "fallbackAuto",
174
+ "favoriteModelsOnly"
174
175
  ]);
175
- var STRING_ARRAY_PREF_KEYS = /* @__PURE__ */ new Set(["fallbackModels"]);
176
+ var STRING_ARRAY_PREF_KEYS = /* @__PURE__ */ new Set(["fallbackModels", "favoriteModels"]);
177
+ var STRING_ARRAY_RECORD_PREF_KEYS = /* @__PURE__ */ new Set(["fallbackProfiles"]);
178
+ var MODEL_MATRIX_PREF_KEYS = /* @__PURE__ */ new Set(["modelMatrix"]);
176
179
  var NUMBER_PREF_KEYS = /* @__PURE__ */ new Set([
177
180
  "autonomyDelayMs",
178
181
  "autoProceedMaxIterations",
@@ -207,6 +210,33 @@ function validatePreferenceValue(key, value) {
207
210
  if (STRING_ARRAY_PREF_KEYS.has(key)) {
208
211
  return Array.isArray(value) && value.every((v) => typeof v === "string") ? null : `prefs.update payload.${key} must be an array of strings`;
209
212
  }
213
+ if (STRING_ARRAY_RECORD_PREF_KEYS.has(key)) {
214
+ return isRecord(value) && Object.values(value).every(
215
+ (v) => Array.isArray(v) && v.every((item) => typeof item === "string")
216
+ ) ? null : `prefs.update payload.${key} must be an object of string arrays`;
217
+ }
218
+ if (MODEL_MATRIX_PREF_KEYS.has(key)) {
219
+ if (!isRecord(value)) return `prefs.update payload.${key} must be an object`;
220
+ for (const entry of Object.values(value)) {
221
+ if (!isRecord(entry)) return `prefs.update payload.${key} entries must be objects`;
222
+ const provider = entry["provider"];
223
+ const model = entry["model"];
224
+ const fallbackProfile = entry["fallbackProfile"];
225
+ if (provider !== void 0 && typeof provider !== "string") {
226
+ return `prefs.update payload.${key}.provider must be a string when provided`;
227
+ }
228
+ if (model !== void 0 && typeof model !== "string") {
229
+ return `prefs.update payload.${key}.model must be a string when provided`;
230
+ }
231
+ if (fallbackProfile !== void 0 && typeof fallbackProfile !== "string") {
232
+ return `prefs.update payload.${key}.fallbackProfile must be a string when provided`;
233
+ }
234
+ if (model === void 0 && fallbackProfile === void 0) {
235
+ return `prefs.update payload.${key} entries require model or fallbackProfile`;
236
+ }
237
+ }
238
+ return null;
239
+ }
210
240
  const allowed = ENUM_PREF_KEYS[key];
211
241
  if (allowed) {
212
242
  return typeof value === "string" && allowed.has(value) ? null : `prefs.update payload.${key} must be one of: ${Array.from(allowed).join(", ")}`;
@@ -435,8 +465,8 @@ function validateShellOpenPayload(payload) {
435
465
  if (!isRecord(payload)) {
436
466
  return { ok: false, message: "shell.open payload must be an object with string path" };
437
467
  }
438
- const path17 = payload["path"];
439
- if (typeof path17 !== "string" || path17.trim().length === 0) {
468
+ const path18 = payload["path"];
469
+ if (typeof path18 !== "string" || path18.trim().length === 0) {
440
470
  return { ok: false, message: "shell.open payload.path must be a non-empty string" };
441
471
  }
442
472
  const target = payload["target"];
@@ -446,20 +476,20 @@ function validateShellOpenPayload(payload) {
446
476
  message: 'shell.open payload.target must be "file" or "terminal" when provided'
447
477
  };
448
478
  }
449
- return { ok: true, value: { path: path17, target } };
479
+ return { ok: true, value: { path: path18, target } };
450
480
  }
451
481
  function validateGitDiffPayload(payload) {
452
482
  if (!isRecord(payload)) {
453
483
  return { ok: false, message: "git.diff payload must be an object" };
454
484
  }
455
- const path17 = payload["path"];
456
- if (path17 === void 0 || path17 === null) {
485
+ const path18 = payload["path"];
486
+ if (path18 === void 0 || path18 === null) {
457
487
  return { ok: true, value: { path: "" } };
458
488
  }
459
- if (typeof path17 !== "string") {
489
+ if (typeof path18 !== "string") {
460
490
  return { ok: false, message: "git.diff payload.path must be a string when provided" };
461
491
  }
462
- return { ok: true, value: { path: path17 } };
492
+ return { ok: true, value: { path: path18 } };
463
493
  }
464
494
  function validateProjectsAddPayload(payload) {
465
495
  if (!isRecord(payload)) {
@@ -709,8 +739,57 @@ async function handleWorklistMessage(ctx, ws, msg) {
709
739
 
710
740
  // src/server/index.ts
711
741
  import { makeMailboxTool, makeMailSendTool, makeMailInboxTool, mailboxSessionTag } from "@wrongstack/core";
712
- import { toErrorMessage as toErrorMessage6, wstackGlobalRoot as wstackGlobalRoot2, projectHash } from "@wrongstack/core/utils";
742
+ import { toErrorMessage as toErrorMessage6, wstackGlobalRoot as wstackGlobalRoot3, projectHash } from "@wrongstack/core/utils";
713
743
  import { SkillInstaller } from "@wrongstack/core/skills";
744
+
745
+ // src/server/discover-mailbox-bridge.ts
746
+ import { resolveProjectDir, wstackGlobalRoot } from "@wrongstack/core";
747
+ import { readLiveLock } from "@wrongstack/core/coordination";
748
+ async function discoverMailboxBridgeForWebui(params) {
749
+ const mode = params.config?.features?.mailboxBridge ?? "auto";
750
+ if (mode === "off") return;
751
+ const projectDir = resolveProjectDir(params.projectRoot, wstackGlobalRoot());
752
+ const result = await readLiveLock(projectDir);
753
+ switch (result.kind) {
754
+ case "live": {
755
+ params.logger.debug("webui joined existing mailbox bridge", {
756
+ url: result.lock.url,
757
+ lockPath: projectDir
758
+ });
759
+ params.ctx.meta["mailboxBridge"] = {
760
+ url: result.lock.url,
761
+ token: result.lock.token,
762
+ lockPath: projectDir,
763
+ childPid: null,
764
+ source: "joined"
765
+ };
766
+ break;
767
+ }
768
+ case "probe-failed": {
769
+ params.logger.warn(
770
+ "mailbox bridge present but /healthz unreachable; webui will start without external-agent connectivity",
771
+ { url: result.lock.url, lockPath: projectDir }
772
+ );
773
+ params.ctx.meta["mailboxBridge"] = {
774
+ url: result.lock.url,
775
+ token: result.lock.token,
776
+ lockPath: projectDir,
777
+ childPid: null,
778
+ source: "unhealthy"
779
+ };
780
+ break;
781
+ }
782
+ case "absent": {
783
+ params.logger.info(
784
+ "no mailbox bridge running; webui will start without external-agent connectivity. Run `wstack mailbox serve` or a CLI surface to bring one up.",
785
+ { projectDir }
786
+ );
787
+ break;
788
+ }
789
+ }
790
+ }
791
+
792
+ // src/server/index.ts
714
793
  import {
715
794
  BrainMonitor,
716
795
  DefaultBrainArbiter,
@@ -718,8 +797,9 @@ import {
718
797
  createAutonomyBrain,
719
798
  createTieredBrainArbiter
720
799
  } from "@wrongstack/core";
721
- import * as fs13 from "fs/promises";
722
- import * as path16 from "path";
800
+ import * as fs14 from "fs/promises";
801
+ import * as path17 from "path";
802
+ import { createRequire as createRequire2 } from "module";
723
803
 
724
804
  // src/server/http-server.ts
725
805
  import * as fs from "fs/promises";
@@ -896,7 +976,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
896
976
  return;
897
977
  }
898
978
  try {
899
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
979
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths4, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
900
980
  const registry = new SessionRegistry(globalRoot);
901
981
  const entry = await registry.get(sessionId);
902
982
  if (!entry) {
@@ -904,7 +984,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
904
984
  res.end(JSON.stringify({ error: "Session not found" }));
905
985
  return;
906
986
  }
907
- const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
987
+ const paths = resolveWstackPaths4({ projectRoot: entry.projectRoot, globalRoot });
908
988
  const store = new DefaultSessionStore3({ dir: paths.projectSessions });
909
989
  const reader = new DefaultSessionReader2({ store });
910
990
  const rawEntries = [];
@@ -931,7 +1011,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
931
1011
  }
932
1012
  }
933
1013
  function readJsonBody(req) {
934
- return new Promise((resolve9, reject) => {
1014
+ return new Promise((resolve10, reject) => {
935
1015
  let data = "";
936
1016
  req.on("data", (chunk) => {
937
1017
  data += chunk;
@@ -942,7 +1022,7 @@ function readJsonBody(req) {
942
1022
  });
943
1023
  req.on("end", () => {
944
1024
  try {
945
- resolve9(data ? JSON.parse(data) : {});
1025
+ resolve10(data ? JSON.parse(data) : {});
946
1026
  } catch (err) {
947
1027
  reject(err instanceof Error ? err : new Error(String(err)));
948
1028
  }
@@ -978,7 +1058,7 @@ async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
978
1058
  const priority = ["low", "normal", "high"].includes(rawPriority) ? rawPriority : "high";
979
1059
  const subject = typeof body["subject"] === "string" && body["subject"].trim() ? body["subject"].trim() : "Message from Fleet HQ";
980
1060
  try {
981
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1061
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths4, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
982
1062
  const registry = new SessionRegistry(globalRoot);
983
1063
  const entry = await registry.get(sessionId);
984
1064
  if (!entry) {
@@ -986,7 +1066,7 @@ async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
986
1066
  res.end(JSON.stringify({ error: "Session not found" }));
987
1067
  return;
988
1068
  }
989
- const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
1069
+ const paths = resolveWstackPaths4({ projectRoot: entry.projectRoot, globalRoot });
990
1070
  const mailbox = new GlobalMailbox3(paths.projectDir);
991
1071
  const to = `leader@${mailboxSessionTag2(sessionId)}`;
992
1072
  const sent = await mailbox.send({ from, to, type, subject, body: text, priority });
@@ -1004,7 +1084,7 @@ async function handleApiSessionMailbox(res, globalRoot, sessionId) {
1004
1084
  return;
1005
1085
  }
1006
1086
  try {
1007
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1087
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths4, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1008
1088
  const registry = new SessionRegistry(globalRoot);
1009
1089
  const entry = await registry.get(sessionId);
1010
1090
  if (!entry) {
@@ -1012,7 +1092,7 @@ async function handleApiSessionMailbox(res, globalRoot, sessionId) {
1012
1092
  res.end(JSON.stringify({ error: "Session not found" }));
1013
1093
  return;
1014
1094
  }
1015
- const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
1095
+ const paths = resolveWstackPaths4({ projectRoot: entry.projectRoot, globalRoot });
1016
1096
  const mailbox = new GlobalMailbox3(paths.projectDir);
1017
1097
  const leaderAddr = `leader@${mailboxSessionTag2(sessionId)}`;
1018
1098
  const [inbound, outbound] = await Promise.all([
@@ -1062,7 +1142,7 @@ async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
1062
1142
  const reason = typeof body["reason"] === "string" && body["reason"].trim() ? body["reason"].trim() : "Operator requested stop from Fleet HQ";
1063
1143
  const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
1064
1144
  try {
1065
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1145
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths4, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1066
1146
  const registry = new SessionRegistry(globalRoot);
1067
1147
  const entry = await registry.get(sessionId);
1068
1148
  if (!entry) {
@@ -1070,7 +1150,7 @@ async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
1070
1150
  res.end(JSON.stringify({ error: "Session not found" }));
1071
1151
  return;
1072
1152
  }
1073
- const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
1153
+ const paths = resolveWstackPaths4({ projectRoot: entry.projectRoot, globalRoot });
1074
1154
  const mailbox = new GlobalMailbox3(paths.projectDir);
1075
1155
  const to = `leader@${mailboxSessionTag2(sessionId)}`;
1076
1156
  const sent = await mailbox.send({
@@ -1110,7 +1190,7 @@ async function handleApiFleetBroadcast(res, req, globalRoot) {
1110
1190
  }
1111
1191
  const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
1112
1192
  try {
1113
- const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1193
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths4, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
1114
1194
  const registry = new SessionRegistry(globalRoot);
1115
1195
  const all = await registry.list();
1116
1196
  const mySlug = all.find((s) => s.pid === process.pid)?.projectSlug;
@@ -1122,7 +1202,7 @@ async function handleApiFleetBroadcast(res, req, globalRoot) {
1122
1202
  }
1123
1203
  const mbByDir = /* @__PURE__ */ new Map();
1124
1204
  const mailboxFor = (projectRoot) => {
1125
- const dir = resolveWstackPaths2({ projectRoot, globalRoot }).projectDir;
1205
+ const dir = resolveWstackPaths4({ projectRoot, globalRoot }).projectDir;
1126
1206
  let mb = mbByDir.get(dir);
1127
1207
  if (!mb) {
1128
1208
  mb = new GlobalMailbox3(dir);
@@ -1166,7 +1246,7 @@ function isTrustedLoopbackOrigin(origin) {
1166
1246
  try {
1167
1247
  const url = new URL(origin);
1168
1248
  if (url.protocol !== "http:" && url.protocol !== "https:") return false;
1169
- return url.hostname === "localhost" || url.hostname === "127.0.0.1";
1249
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
1170
1250
  } catch {
1171
1251
  return false;
1172
1252
  }
@@ -1177,6 +1257,14 @@ function isLoopbackBind(wsHost) {
1177
1257
  function isWildcardBind(wsHost) {
1178
1258
  return wsHost === "0.0.0.0" || wsHost === "::" || wsHost === "[::]";
1179
1259
  }
1260
+ function normalizeHostname(hostname) {
1261
+ const h = hostname.trim().toLowerCase();
1262
+ return h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h;
1263
+ }
1264
+ function allowedHostname(hostname, allowedHostnames) {
1265
+ const normalized = normalizeHostname(hostname);
1266
+ return (allowedHostnames ?? []).some((candidate) => normalizeHostname(candidate) === normalized);
1267
+ }
1180
1268
  function tokenMatches(provided, expected) {
1181
1269
  if (!provided) return false;
1182
1270
  const a = Buffer.from(provided);
@@ -1215,28 +1303,37 @@ function hostHeaderOk(input) {
1215
1303
  } catch {
1216
1304
  return false;
1217
1305
  }
1218
- return isLoopbackHostname(hostname);
1306
+ return isLoopbackHostname(hostname) || allowedHostname(hostname, input.allowedHostnames);
1219
1307
  }
1220
1308
  function verifyClient(input) {
1221
- const { origin, url, hostHeader, remoteAddress, cookieHeader, wsHost, expectedToken } = input;
1309
+ const {
1310
+ origin,
1311
+ url,
1312
+ hostHeader,
1313
+ remoteAddress,
1314
+ cookieHeader,
1315
+ wsHost,
1316
+ expectedToken,
1317
+ requireToken,
1318
+ allowedHostnames,
1319
+ allowBrowserUrlToken
1320
+ } = input;
1222
1321
  const urlTokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
1223
1322
  const cookieTokenOk = tokenMatches(extractTokenFromCookie(cookieHeader), expectedToken);
1224
- if (!hostHeaderOk({ hostHeader, wsHost })) return false;
1323
+ if (!hostHeaderOk({ hostHeader, wsHost, allowedHostnames })) return false;
1225
1324
  if (!origin) {
1226
1325
  const remoteIp = remoteAddress ?? "";
1227
1326
  const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
1228
1327
  if (!isRemoteLoopback && isWildcardBind(wsHost)) return false;
1229
- return urlTokenOk || cookieTokenOk || isLoopbackBind(wsHost);
1328
+ return urlTokenOk || cookieTokenOk || isLoopbackBind(wsHost) && !requireToken;
1230
1329
  }
1231
1330
  try {
1232
- const { hostname } = new URL(origin);
1233
- if (isLoopbackHostname(hostname)) {
1234
- if (isWildcardBind(wsHost) && !isTrustedLoopbackOrigin(origin)) {
1235
- return false;
1236
- }
1237
- return true;
1331
+ const { hostname: originHostname } = new URL(origin);
1332
+ if (isLoopbackHostname(originHostname)) {
1333
+ if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
1334
+ return isTrustedLoopbackOrigin(origin);
1238
1335
  }
1239
- return cookieTokenOk;
1336
+ return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
1240
1337
  } catch {
1241
1338
  return false;
1242
1339
  }
@@ -1262,8 +1359,69 @@ function injectWsPort(html, wsPort) {
1262
1359
  return `${tag}
1263
1360
  ${html}`;
1264
1361
  }
1265
- function buildCspHeader(wsPort) {
1266
- return `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:${wsPort} wss://127.0.0.1:${wsPort}; img-src 'self' data:; font-src 'self' data:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'`;
1362
+ function escapeHtmlAttr(value) {
1363
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1364
+ }
1365
+ function injectWsConfig(html, opts) {
1366
+ let out = injectWsPort(html, opts.wsPort);
1367
+ if (!opts.publicWsUrl || out.includes('name="wrongstack-ws-url"')) return out;
1368
+ const tag = `<meta name="wrongstack-ws-url" content="${escapeHtmlAttr(opts.publicWsUrl)}" />`;
1369
+ if (out.includes("</head>")) {
1370
+ return out.replace("</head>", ` ${tag}
1371
+ </head>`);
1372
+ }
1373
+ return `${tag}
1374
+ ${out}`;
1375
+ }
1376
+ function firstHeader(value) {
1377
+ return Array.isArray(value) ? value[0] : value;
1378
+ }
1379
+ function wsTokenCookie(token) {
1380
+ return `ws_token=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
1381
+ }
1382
+ function requestToken(req, url) {
1383
+ return url.searchParams.get("token") ?? firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
1384
+ }
1385
+ function requestHostForCsp(hostHeader) {
1386
+ const raw = firstHeader(hostHeader)?.trim();
1387
+ if (!raw) return void 0;
1388
+ try {
1389
+ return new URL(`http://${raw}`).hostname;
1390
+ } catch {
1391
+ return void 0;
1392
+ }
1393
+ }
1394
+ function formatCspHostname(hostname) {
1395
+ return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
1396
+ }
1397
+ function cspSourceFromUrl(rawUrl) {
1398
+ try {
1399
+ const url = new URL(rawUrl);
1400
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") return void 0;
1401
+ return `${url.protocol}//${formatCspHostname(url.hostname)}${url.port ? `:${url.port}` : ""}`;
1402
+ } catch {
1403
+ return void 0;
1404
+ }
1405
+ }
1406
+ var ALLOWED_INLINE_SCRIPT_HASHES = [
1407
+ "'sha256-6PXDy0zrpXa6mvYOl11bZ8nubNUL7ushPUhGDZtaexg='",
1408
+ "'sha256-6sIdwbEBx7jj0drqSHHm7MqvmoYD3CQ4lp8Zp8blcb0='"
1409
+ ];
1410
+ function buildCspHeader(wsPort, requestHost, publicWsUrl) {
1411
+ const connect = /* @__PURE__ */ new Set([
1412
+ "'self'",
1413
+ `ws://127.0.0.1:${wsPort}`,
1414
+ `wss://127.0.0.1:${wsPort}`
1415
+ ]);
1416
+ if (requestHost && requestHost !== "127.0.0.1" && requestHost !== "::1" && requestHost !== "[::1]") {
1417
+ const host = formatCspHostname(requestHost);
1418
+ connect.add(`ws://${host}:${wsPort}`);
1419
+ connect.add(`wss://${host}:${wsPort}`);
1420
+ }
1421
+ const publicWsSource = publicWsUrl ? cspSourceFromUrl(publicWsUrl) : void 0;
1422
+ if (publicWsSource) connect.add(publicWsSource);
1423
+ const scriptSrc = ["'self'", ...ALLOWED_INLINE_SCRIPT_HASHES].join(" ");
1424
+ return `default-src 'self'; script-src ${scriptSrc}; style-src 'self' 'unsafe-inline'; connect-src ${Array.from(connect).join(" ")}; img-src 'self' data:; font-src 'self' data:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'`;
1267
1425
  }
1268
1426
  function isInsideDist(candidate, distDir) {
1269
1427
  const root = path.resolve(distDir);
@@ -1281,12 +1439,15 @@ function createHttpServer(opts) {
1281
1439
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
1282
1440
  const distDir = path.resolve(opts.distDir);
1283
1441
  const wsPort = opts.wsPort;
1284
- const requireApiToken = !isLoopbackBind(opts.host) && Boolean(opts.apiToken);
1442
+ const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
1285
1443
  return http.createServer(async (req, res) => {
1286
1444
  try {
1287
1445
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
1446
+ const providedAccessToken = requestToken(req, url);
1447
+ const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
1448
+ const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
1288
1449
  if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
1289
- const provided = url.searchParams.get("token") ?? req.headers["x-ws-token"];
1450
+ const provided = requestToken(req, url);
1290
1451
  if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
1291
1452
  res.writeHead(401, { "Content-Type": "text/plain" });
1292
1453
  res.end("Unauthorized");
@@ -1294,7 +1455,7 @@ function createHttpServer(opts) {
1294
1455
  }
1295
1456
  res.writeHead(200, {
1296
1457
  "Content-Type": "text/plain",
1297
- "Set-Cookie": `ws_token=${encodeURIComponent(opts.apiToken)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`,
1458
+ "Set-Cookie": wsTokenCookie(opts.apiToken),
1298
1459
  // Belt-and-braces: tell any caches the cookie response itself
1299
1460
  // is sensitive.
1300
1461
  "Cache-Control": "no-store"
@@ -1302,10 +1463,20 @@ function createHttpServer(opts) {
1302
1463
  res.end("ok");
1303
1464
  return;
1304
1465
  }
1466
+ if (requireAccessToken && !accessTokenOk) {
1467
+ res.writeHead(401, {
1468
+ "Content-Type": "text/plain",
1469
+ "Cache-Control": "no-store"
1470
+ });
1471
+ res.end("Unauthorized");
1472
+ return;
1473
+ }
1474
+ if (shouldSetAuthCookie && opts.apiToken) {
1475
+ res.setHeader("Set-Cookie", wsTokenCookie(opts.apiToken));
1476
+ res.setHeader("Cache-Control", "no-store");
1477
+ }
1305
1478
  if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
1306
- const headerToken = req.headers["x-ws-token"];
1307
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1308
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1479
+ if (requireAccessToken && !accessTokenOk) {
1309
1480
  res.writeHead(401, { "Content-Type": "application/json" });
1310
1481
  res.end(JSON.stringify({ error: "Unauthorized" }));
1311
1482
  return;
@@ -1319,9 +1490,7 @@ function createHttpServer(opts) {
1319
1490
  return;
1320
1491
  }
1321
1492
  if (url.pathname === "/api/sessions" && req.method === "GET") {
1322
- const headerToken = req.headers["x-ws-token"];
1323
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1324
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1493
+ if (requireAccessToken && !accessTokenOk) {
1325
1494
  res.writeHead(401, { "Content-Type": "application/json" });
1326
1495
  res.end(JSON.stringify({ error: "Unauthorized" }));
1327
1496
  return;
@@ -1331,9 +1500,7 @@ function createHttpServer(opts) {
1331
1500
  }
1332
1501
  const agentsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/agents$/);
1333
1502
  if (agentsMatch && req.method === "GET") {
1334
- const headerToken = req.headers["x-ws-token"];
1335
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1336
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1503
+ if (requireAccessToken && !accessTokenOk) {
1337
1504
  res.writeHead(401, { "Content-Type": "application/json" });
1338
1505
  res.end(JSON.stringify({ error: "Unauthorized" }));
1339
1506
  return;
@@ -1343,9 +1510,7 @@ function createHttpServer(opts) {
1343
1510
  }
1344
1511
  const eventsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
1345
1512
  if (eventsMatch && req.method === "GET") {
1346
- const headerToken = req.headers["x-ws-token"];
1347
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1348
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1513
+ if (requireAccessToken && !accessTokenOk) {
1349
1514
  res.writeHead(401, { "Content-Type": "application/json" });
1350
1515
  res.end(JSON.stringify({ error: "Unauthorized" }));
1351
1516
  return;
@@ -1357,9 +1522,7 @@ function createHttpServer(opts) {
1357
1522
  }
1358
1523
  const msgMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/message$/);
1359
1524
  if (msgMatch && req.method === "POST") {
1360
- const headerToken = req.headers["x-ws-token"];
1361
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1362
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1525
+ if (requireAccessToken && !accessTokenOk) {
1363
1526
  res.writeHead(401, { "Content-Type": "application/json" });
1364
1527
  res.end(JSON.stringify({ error: "Unauthorized" }));
1365
1528
  return;
@@ -1369,9 +1532,7 @@ function createHttpServer(opts) {
1369
1532
  }
1370
1533
  const mailboxMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/mailbox$/);
1371
1534
  if (mailboxMatch && req.method === "GET") {
1372
- const headerToken = req.headers["x-ws-token"];
1373
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1374
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1535
+ if (requireAccessToken && !accessTokenOk) {
1375
1536
  res.writeHead(401, { "Content-Type": "application/json" });
1376
1537
  res.end(JSON.stringify({ error: "Unauthorized" }));
1377
1538
  return;
@@ -1381,9 +1542,7 @@ function createHttpServer(opts) {
1381
1542
  }
1382
1543
  const interruptMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/interrupt$/);
1383
1544
  if (interruptMatch && req.method === "POST") {
1384
- const headerToken = req.headers["x-ws-token"];
1385
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1386
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1545
+ if (requireAccessToken && !accessTokenOk) {
1387
1546
  res.writeHead(401, { "Content-Type": "application/json" });
1388
1547
  res.end(JSON.stringify({ error: "Unauthorized" }));
1389
1548
  return;
@@ -1397,9 +1556,7 @@ function createHttpServer(opts) {
1397
1556
  return;
1398
1557
  }
1399
1558
  if (url.pathname === "/api/fleet/broadcast" && req.method === "POST") {
1400
- const headerToken = req.headers["x-ws-token"];
1401
- const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
1402
- if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
1559
+ if (requireAccessToken && !accessTokenOk) {
1403
1560
  res.writeHead(401, { "Content-Type": "application/json" });
1404
1561
  res.end(JSON.stringify({ error: "Unauthorized" }));
1405
1562
  return;
@@ -1446,11 +1603,14 @@ function createHttpServer(opts) {
1446
1603
  res.setHeader("X-Frame-Options", "DENY");
1447
1604
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
1448
1605
  if (ext === ".html") {
1449
- res.setHeader("Cache-Control", "no-cache");
1450
- res.setHeader("Content-Security-Policy", buildCspHeader(wsPort));
1606
+ if (!shouldSetAuthCookie) res.setHeader("Cache-Control", "no-cache");
1607
+ res.setHeader(
1608
+ "Content-Security-Policy",
1609
+ buildCspHeader(wsPort, requestHostForCsp(req.headers.host), opts.publicWsUrl)
1610
+ );
1451
1611
  const html = await fs.readFile(resolvedPath, "utf8");
1452
1612
  res.writeHead(200);
1453
- res.end(injectWsPort(html, wsPort));
1613
+ res.end(injectWsConfig(html, { wsPort, publicWsUrl: opts.publicWsUrl }));
1454
1614
  return;
1455
1615
  }
1456
1616
  const fileContent = await fs.readFile(resolvedPath);
@@ -1465,9 +1625,13 @@ function createHttpServer(opts) {
1465
1625
  "X-Content-Type-Options": "nosniff",
1466
1626
  "X-Frame-Options": "DENY",
1467
1627
  "Referrer-Policy": "strict-origin-when-cross-origin",
1468
- "Content-Security-Policy": buildCspHeader(wsPort)
1628
+ "Content-Security-Policy": buildCspHeader(
1629
+ wsPort,
1630
+ requestHostForCsp(req.headers.host),
1631
+ opts.publicWsUrl
1632
+ )
1469
1633
  });
1470
- res.end(injectWsPort(html, wsPort));
1634
+ res.end(injectWsConfig(html, { wsPort, publicWsUrl: opts.publicWsUrl }));
1471
1635
  } catch {
1472
1636
  res.writeHead(404);
1473
1637
  res.end("Not found");
@@ -1699,6 +1863,37 @@ function errMessage(err) {
1699
1863
  function generateAuthToken() {
1700
1864
  return randomBytes(16).toString("hex");
1701
1865
  }
1866
+ function resolveAuthToken(explicit) {
1867
+ const configured = explicit?.trim() || process.env["WEBUI_TOKEN"]?.trim() || process.env["WEBUI_AUTH_TOKEN"]?.trim();
1868
+ return configured || generateAuthToken();
1869
+ }
1870
+ function hostForBrowserUrl(bindHost) {
1871
+ if (bindHost === "0.0.0.0") return "127.0.0.1";
1872
+ if (bindHost === "::" || bindHost === "[::]") return "[::1]";
1873
+ if (bindHost.includes(":") && !bindHost.startsWith("[")) return `[${bindHost}]`;
1874
+ return bindHost;
1875
+ }
1876
+ function buildWebUIAccessUrl(opts) {
1877
+ const protocol = opts.protocol ?? "http";
1878
+ const base = opts.publicUrl?.trim() || `${protocol}://${hostForBrowserUrl(opts.host)}:${opts.port}`;
1879
+ if (!opts.token) return base;
1880
+ try {
1881
+ const url = new URL(base);
1882
+ url.searchParams.set("token", opts.token);
1883
+ const rendered = url.toString();
1884
+ const afterOrigin = base.slice(url.origin.length);
1885
+ if (url.pathname === "/" && !afterOrigin.startsWith("/")) {
1886
+ return `${url.origin}${url.search}${url.hash}`;
1887
+ }
1888
+ return rendered;
1889
+ } catch {
1890
+ return `${base}${base.includes("?") ? "&" : "?"}token=${encodeURIComponent(opts.token)}`;
1891
+ }
1892
+ }
1893
+ function envFlag(name2) {
1894
+ const value = process.env[name2]?.trim().toLowerCase();
1895
+ return value === "1" || value === "true" || value === "yes" || value === "on";
1896
+ }
1702
1897
 
1703
1898
  // src/server/file-handlers.ts
1704
1899
  async function resolveFileInsideProject(projectRoot, filePath) {
@@ -1743,6 +1938,16 @@ async function realpathAllowMissing(p) {
1743
1938
  }
1744
1939
  }
1745
1940
  }
1941
+ function validatedPayload(msg, label) {
1942
+ if (msg == null || typeof msg !== "object") {
1943
+ throw new TypeError(`Expected object for ${label}, got ${msg}`);
1944
+ }
1945
+ const payload = msg.payload;
1946
+ if (payload == null || typeof payload !== "object") {
1947
+ throw new TypeError(`Expected payload object for ${label}, got ${payload}`);
1948
+ }
1949
+ return payload;
1950
+ }
1746
1951
  async function handleFilesTree(ws, msg, projectRoot) {
1747
1952
  const payload = msg.payload;
1748
1953
  const rawPath = payload?.path?.trim();
@@ -1813,7 +2018,13 @@ async function handleFilesTree(ws, msg, projectRoot) {
1813
2018
  }
1814
2019
  }
1815
2020
  async function handleFilesRead(ws, msg, projectRoot) {
1816
- const { filePath } = msg.payload;
2021
+ let filePath;
2022
+ try {
2023
+ ({ filePath } = validatedPayload(msg, "files.read"));
2024
+ } catch {
2025
+ send(ws, { type: "files.read", payload: { filePath: "", content: "", error: "Malformed request" } });
2026
+ return;
2027
+ }
1817
2028
  let realResolved;
1818
2029
  try {
1819
2030
  realResolved = await resolveFileInsideProject(projectRoot, filePath);
@@ -1832,7 +2043,14 @@ async function handleFilesRead(ws, msg, projectRoot) {
1832
2043
  }
1833
2044
  }
1834
2045
  async function handleFilesWrite(ws, msg, projectRoot, opts = {}) {
1835
- const { filePath, content } = msg.payload;
2046
+ let filePath;
2047
+ let content;
2048
+ try {
2049
+ ({ filePath, content } = validatedPayload(msg, "files.write"));
2050
+ } catch {
2051
+ send(ws, { type: "files.written", payload: { filePath: "", success: false, error: "Malformed request" } });
2052
+ return;
2053
+ }
1836
2054
  let realResolved;
1837
2055
  try {
1838
2056
  realResolved = await resolveFileInsideProject(projectRoot, filePath);
@@ -2670,7 +2888,7 @@ import { promises as fs5 } from "fs";
2670
2888
  import path6 from "path";
2671
2889
  import JSZip from "jszip";
2672
2890
  import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
2673
- import { wstackGlobalRoot } from "@wrongstack/core/utils";
2891
+ import { wstackGlobalRoot as wstackGlobalRoot2 } from "@wrongstack/core/utils";
2674
2892
  async function handleSkillsList(ws, ctx) {
2675
2893
  if (!ctx.skillLoader) {
2676
2894
  send(ws, { type: "skills.list", payload: { skills: [], enabled: false } });
@@ -2840,7 +3058,7 @@ async function handleSkillsCreate(ws, ctx, msg) {
2840
3058
  }
2841
3059
  const createPayload = parsed.value;
2842
3060
  try {
2843
- const targetDir = createPayload.scope === "global" ? path6.join(wstackGlobalRoot(), "skills", createPayload.name.trim()) : path6.join(ctx.projectRoot, ".wrongstack", "skills", createPayload.name.trim());
3061
+ const targetDir = createPayload.scope === "global" ? path6.join(wstackGlobalRoot2(), "skills", createPayload.name.trim()) : path6.join(ctx.projectRoot, ".wrongstack", "skills", createPayload.name.trim());
2844
3062
  try {
2845
3063
  await fs5.access(targetDir);
2846
3064
  send(ws, { type: "skills.created", payload: { success: false, error: `Skill "${createPayload.name}" already exists` } });
@@ -2960,6 +3178,416 @@ async function handleSkillsExport(ws, ctx) {
2960
3178
  }
2961
3179
  }
2962
3180
 
3181
+ // src/server/prompts-handlers.ts
3182
+ function parseVariablesPayload(raw) {
3183
+ if (!Array.isArray(raw)) return void 0;
3184
+ const out = [];
3185
+ for (const item of raw) {
3186
+ if (!item || typeof item !== "object") continue;
3187
+ const o = item;
3188
+ if (typeof o["name"] !== "string" || !o["name"].trim()) continue;
3189
+ const enumVals = Array.isArray(o["enum"]) && o["enum"].every((x) => typeof x === "string") ? o["enum"].map((s) => s.trim()).filter(Boolean) : void 0;
3190
+ const v = { name: o["name"].trim() };
3191
+ if (typeof o["description"] === "string" && o["description"].trim()) {
3192
+ v.description = o["description"].trim();
3193
+ }
3194
+ if (o["required"] === true) v.required = true;
3195
+ if (o["multiline"] === true) v.multiline = true;
3196
+ if (enumVals && enumVals.length > 0) v.enum = enumVals;
3197
+ out.push(v);
3198
+ }
3199
+ return out.length > 0 ? out : void 0;
3200
+ }
3201
+ function toMeta(e) {
3202
+ return {
3203
+ id: e.id,
3204
+ slug: e.slug,
3205
+ title: e.title,
3206
+ description: e.description,
3207
+ category: e.category,
3208
+ tags: e.tags,
3209
+ source: e.source,
3210
+ favorite: e.favorite,
3211
+ variables: e.variables ?? []
3212
+ };
3213
+ }
3214
+ async function handlePromptsList(ws, ctx) {
3215
+ if (!ctx.promptLoader) {
3216
+ send(ws, { type: "prompts.list", payload: { enabled: false, prompts: [], categories: [] } });
3217
+ return;
3218
+ }
3219
+ try {
3220
+ const [all, categories] = await Promise.all([
3221
+ ctx.promptLoader.list(),
3222
+ ctx.promptLoader.categories()
3223
+ ]);
3224
+ send(ws, {
3225
+ type: "prompts.list",
3226
+ payload: { enabled: true, prompts: all.map(toMeta), categories }
3227
+ });
3228
+ } catch (err) {
3229
+ send(ws, {
3230
+ type: "prompts.list",
3231
+ payload: { enabled: true, prompts: [], categories: [], error: errMessage(err) }
3232
+ });
3233
+ }
3234
+ }
3235
+ async function handlePromptsSearch(ws, ctx, msg) {
3236
+ if (!ctx.promptLoader) {
3237
+ send(ws, { type: "prompts.search", payload: { enabled: false, prompts: [] } });
3238
+ return;
3239
+ }
3240
+ const payload = msg.payload ?? {};
3241
+ try {
3242
+ const results = await ctx.promptLoader.search(payload.query ?? "", {
3243
+ ...payload.category ? { category: payload.category } : {},
3244
+ limit: 50
3245
+ });
3246
+ send(ws, { type: "prompts.search", payload: { enabled: true, prompts: results.map(toMeta) } });
3247
+ } catch (err) {
3248
+ send(ws, {
3249
+ type: "prompts.search",
3250
+ payload: { enabled: true, prompts: [], error: errMessage(err) }
3251
+ });
3252
+ }
3253
+ }
3254
+ async function handlePromptsContent(ws, ctx, msg) {
3255
+ const slug = msg.payload?.slug;
3256
+ if (!ctx.promptLoader || !slug) {
3257
+ send(ws, {
3258
+ type: "prompts.content",
3259
+ payload: { slug: slug ?? "", found: false, content: "", variables: [] }
3260
+ });
3261
+ return;
3262
+ }
3263
+ try {
3264
+ const entry = await ctx.promptLoader.find(slug);
3265
+ if (!entry) {
3266
+ send(ws, {
3267
+ type: "prompts.content",
3268
+ payload: { slug, found: false, content: "", variables: [] }
3269
+ });
3270
+ return;
3271
+ }
3272
+ send(ws, {
3273
+ type: "prompts.content",
3274
+ payload: {
3275
+ slug: entry.slug,
3276
+ found: true,
3277
+ title: entry.title,
3278
+ content: entry.content,
3279
+ variables: entry.variables ?? [],
3280
+ category: entry.category,
3281
+ source: entry.source
3282
+ }
3283
+ });
3284
+ } catch (err) {
3285
+ send(ws, {
3286
+ type: "prompts.content",
3287
+ payload: { slug, found: false, content: "", variables: [], error: errMessage(err) }
3288
+ });
3289
+ }
3290
+ }
3291
+ async function handlePromptsFavorite(ws, ctx, msg) {
3292
+ const payload = msg.payload;
3293
+ if (!ctx.promptLoader || !payload?.slug) {
3294
+ send(ws, {
3295
+ type: "prompts.favorite",
3296
+ payload: { success: false, error: "Prompt library unavailable" }
3297
+ });
3298
+ return;
3299
+ }
3300
+ try {
3301
+ const updated = await ctx.promptLoader.setFavorite(payload.slug, payload.favorite !== false);
3302
+ if (!updated) {
3303
+ send(ws, {
3304
+ type: "prompts.favorite",
3305
+ payload: { success: false, error: "Prompt not found" }
3306
+ });
3307
+ return;
3308
+ }
3309
+ send(ws, {
3310
+ type: "prompts.favorite",
3311
+ payload: { success: true, slug: updated.slug, favorite: updated.favorite }
3312
+ });
3313
+ } catch (err) {
3314
+ send(ws, { type: "prompts.favorite", payload: { success: false, error: errMessage(err) } });
3315
+ }
3316
+ }
3317
+ async function handlePromptsCreate(ws, ctx, msg) {
3318
+ const p = msg.payload;
3319
+ if (!ctx.promptLoader || !p) {
3320
+ send(ws, {
3321
+ type: "prompts.created",
3322
+ payload: { success: false, error: "Prompt library unavailable" }
3323
+ });
3324
+ return;
3325
+ }
3326
+ const title = typeof p["title"] === "string" ? p["title"].trim() : "";
3327
+ const content = typeof p["content"] === "string" ? p["content"] : "";
3328
+ if (!title || !content) {
3329
+ send(ws, {
3330
+ type: "prompts.created",
3331
+ payload: { success: false, error: "Title and content are required" }
3332
+ });
3333
+ return;
3334
+ }
3335
+ try {
3336
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3337
+ const tags = Array.isArray(p["tags"]) ? p["tags"].filter((t) => typeof t === "string") : [];
3338
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "prompt";
3339
+ const variables = parseVariablesPayload(p["variables"]);
3340
+ const entry = {
3341
+ id: slug,
3342
+ slug,
3343
+ title,
3344
+ description: typeof p["description"] === "string" ? p["description"] : "",
3345
+ content,
3346
+ category: typeof p["category"] === "string" && p["category"] ? p["category"] : "uncategorized",
3347
+ tags,
3348
+ source: "user",
3349
+ favorite: false,
3350
+ ...variables ? { variables } : {},
3351
+ createdAt: now,
3352
+ updatedAt: now
3353
+ };
3354
+ await ctx.promptLoader.save(entry);
3355
+ send(ws, { type: "prompts.created", payload: { success: true, slug } });
3356
+ } catch (err) {
3357
+ send(ws, { type: "prompts.created", payload: { success: false, error: errMessage(err) } });
3358
+ }
3359
+ }
3360
+ async function handlePromptsUsed(ws, ctx, msg) {
3361
+ const slug = msg.payload?.slug;
3362
+ if (!ctx.promptUsage || !slug) {
3363
+ send(ws, { type: "prompts.used", payload: { success: false } });
3364
+ return;
3365
+ }
3366
+ try {
3367
+ await ctx.promptUsage.record(slug);
3368
+ send(ws, { type: "prompts.used", payload: { success: true, slug } });
3369
+ } catch {
3370
+ send(ws, { type: "prompts.used", payload: { success: false } });
3371
+ }
3372
+ }
3373
+ async function handlePromptsRecent(ws, ctx) {
3374
+ if (!ctx.promptUsage) {
3375
+ send(ws, { type: "prompts.recent", payload: { slugs: [] } });
3376
+ return;
3377
+ }
3378
+ try {
3379
+ const recent = await ctx.promptUsage.recent(50);
3380
+ send(ws, { type: "prompts.recent", payload: { slugs: recent.map((r) => r.slug) } });
3381
+ } catch (err) {
3382
+ send(ws, { type: "prompts.recent", payload: { slugs: [], error: errMessage(err) } });
3383
+ }
3384
+ }
3385
+
3386
+ // src/server/design-handlers.ts
3387
+ import * as fs6 from "fs/promises";
3388
+ import * as path7 from "path";
3389
+ import {
3390
+ applyTokenOverrides,
3391
+ getDesignKitLoader,
3392
+ getDesignState,
3393
+ isDesignStack,
3394
+ loadActiveKit,
3395
+ materializeTokens,
3396
+ recordKitChoice,
3397
+ recordOverrides,
3398
+ runDesignVerify,
3399
+ setActiveKit,
3400
+ setDesignOverrides
3401
+ } from "@wrongstack/core";
3402
+ function readOverrides(value) {
3403
+ const out = {};
3404
+ if (value && typeof value === "object") {
3405
+ for (const [k, v] of Object.entries(value)) {
3406
+ if (typeof v === "string") out[k] = v;
3407
+ }
3408
+ }
3409
+ return out;
3410
+ }
3411
+ var FOUNDATIONS_ID = "_foundations";
3412
+ async function buildListPayload(ctx) {
3413
+ const loader = getDesignKitLoader(ctx.projectRoot);
3414
+ const manifests = (await loader.list()).filter((k) => k.id !== FOUNDATIONS_ID);
3415
+ const kits = [];
3416
+ for (const m of manifests) {
3417
+ const tokens = await loader.readTokens(m.id);
3418
+ kits.push({
3419
+ id: m.id,
3420
+ name: m.name,
3421
+ aesthetic: m.aesthetic,
3422
+ bestFor: m.bestFor,
3423
+ stacks: m.stacks,
3424
+ tags: m.tags,
3425
+ light: tokens?.light ?? {},
3426
+ dark: tokens?.dark ?? {}
3427
+ });
3428
+ }
3429
+ const state = ctx.agentMeta ? getDesignState(ctx.agentMeta) : void 0;
3430
+ const persisted = await loadActiveKit(ctx.projectRoot).catch(() => void 0);
3431
+ return {
3432
+ kits,
3433
+ activeKit: state?.activeKit ?? persisted?.kit ?? null,
3434
+ stack: state?.stack ?? persisted?.stack ?? null,
3435
+ overrides: state?.overrides ?? persisted?.overrides ?? {}
3436
+ };
3437
+ }
3438
+ async function handleDesignList(ws, ctx) {
3439
+ try {
3440
+ send(ws, { type: "design.list", payload: await buildListPayload(ctx) });
3441
+ } catch (err) {
3442
+ send(ws, {
3443
+ type: "design.list",
3444
+ payload: { kits: [], activeKit: null, stack: null, error: String(err) }
3445
+ });
3446
+ }
3447
+ }
3448
+ async function handleDesignState(ws, ctx) {
3449
+ const state = ctx.agentMeta ? getDesignState(ctx.agentMeta) : void 0;
3450
+ send(ws, {
3451
+ type: "design.state",
3452
+ payload: {
3453
+ activeKit: state?.activeKit ?? null,
3454
+ stack: state?.stack ?? null,
3455
+ overrides: state?.overrides ?? {}
3456
+ }
3457
+ });
3458
+ }
3459
+ async function handleDesignUse(ws, ctx, msg) {
3460
+ const payload = msg.payload ?? {};
3461
+ const kitId = typeof payload.kit === "string" ? payload.kit.trim() : "";
3462
+ if (!kitId) {
3463
+ send(ws, { type: "design.use", payload: { ok: false, error: "No kit id provided" } });
3464
+ return;
3465
+ }
3466
+ try {
3467
+ const loader = getDesignKitLoader(ctx.projectRoot);
3468
+ const kit = await loader.find(kitId);
3469
+ if (!kit) {
3470
+ send(ws, { type: "design.use", payload: { ok: false, kit: kitId, error: "Kit not found" } });
3471
+ return;
3472
+ }
3473
+ const stackArg = typeof payload.stack === "string" ? payload.stack : void 0;
3474
+ const stack = stackArg && isDesignStack(stackArg) ? stackArg : kit.stacks[0] ?? "web";
3475
+ const persisted = await loadActiveKit(ctx.projectRoot).catch(() => void 0);
3476
+ const keep = persisted?.kit === kit.id ? persisted.overrides ?? {} : {};
3477
+ const overrides = { ...keep, ...readOverrides(payload.overrides) };
3478
+ if (ctx.agentMeta) setActiveKit(ctx.agentMeta, kit.id, stack, overrides);
3479
+ await recordKitChoice(
3480
+ ctx.projectRoot,
3481
+ kit.id,
3482
+ stack,
3483
+ "webui",
3484
+ (/* @__PURE__ */ new Date()).toISOString(),
3485
+ Object.keys(overrides).length ? overrides : void 0
3486
+ );
3487
+ const body = await loader.readBody(kit.id, stack);
3488
+ const rawTokens = await loader.readTokens(kit.id);
3489
+ const tokens = rawTokens ? applyTokenOverrides(rawTokens, overrides) : rawTokens;
3490
+ send(ws, {
3491
+ type: "design.use",
3492
+ payload: {
3493
+ ok: true,
3494
+ kit: kit.id,
3495
+ name: kit.name,
3496
+ aesthetic: kit.aesthetic,
3497
+ stack,
3498
+ body,
3499
+ overrides,
3500
+ light: tokens?.light ?? {},
3501
+ dark: tokens?.dark ?? {}
3502
+ }
3503
+ });
3504
+ } catch (err) {
3505
+ send(ws, { type: "design.use", payload: { ok: false, kit: kitId, error: String(err) } });
3506
+ }
3507
+ }
3508
+ async function handleDesignSet(ws, ctx, msg) {
3509
+ const patch = readOverrides(msg.payload?.overrides);
3510
+ if (Object.keys(patch).length === 0) {
3511
+ send(ws, { type: "design.set", payload: { ok: false, error: "No overrides provided" } });
3512
+ return;
3513
+ }
3514
+ try {
3515
+ const merged = await recordOverrides(ctx.projectRoot, patch, (/* @__PURE__ */ new Date()).toISOString());
3516
+ if (!merged) {
3517
+ send(ws, { type: "design.set", payload: { ok: false, error: "No active kit" } });
3518
+ return;
3519
+ }
3520
+ if (ctx.agentMeta) setDesignOverrides(ctx.agentMeta, merged);
3521
+ send(ws, { type: "design.set", payload: { ok: true, overrides: merged } });
3522
+ } catch (err) {
3523
+ send(ws, { type: "design.set", payload: { ok: false, error: String(err) } });
3524
+ }
3525
+ }
3526
+ async function handleDesignMaterialize(ws, ctx, msg) {
3527
+ const payload = msg.payload ?? {};
3528
+ try {
3529
+ const active = await loadActiveKit(ctx.projectRoot);
3530
+ if (!active) {
3531
+ send(ws, { type: "design.materialize", payload: { ok: false, error: "No active kit" } });
3532
+ return;
3533
+ }
3534
+ const loader = getDesignKitLoader(ctx.projectRoot);
3535
+ const stackArg = typeof payload.stack === "string" ? payload.stack : void 0;
3536
+ const stack = stackArg && isDesignStack(stackArg) ? stackArg : active.stack && isDesignStack(active.stack) ? active.stack : "web";
3537
+ const raw = await loader.readTokens(active.kit);
3538
+ if (!raw) {
3539
+ send(ws, { type: "design.materialize", payload: { ok: false, error: "Kit has no tokens" } });
3540
+ return;
3541
+ }
3542
+ const tokens = applyTokenOverrides(raw, active.overrides);
3543
+ const result = materializeTokens({
3544
+ tokens,
3545
+ stack,
3546
+ kitId: active.kit,
3547
+ outPath: typeof payload.out === "string" ? payload.out : void 0
3548
+ });
3549
+ const abs = path7.join(ctx.projectRoot, result.path);
3550
+ await fs6.mkdir(path7.dirname(abs), { recursive: true });
3551
+ await fs6.writeFile(abs, result.content);
3552
+ send(ws, {
3553
+ type: "design.materialize",
3554
+ payload: { ok: true, path: result.path, format: result.format, stack }
3555
+ });
3556
+ } catch (err) {
3557
+ send(ws, { type: "design.materialize", payload: { ok: false, error: String(err) } });
3558
+ }
3559
+ }
3560
+ async function handleDesignVerify(ws, ctx) {
3561
+ try {
3562
+ const active = await loadActiveKit(ctx.projectRoot);
3563
+ if (!active) {
3564
+ send(ws, { type: "design.verify", payload: { ok: false, error: "No active kit" } });
3565
+ return;
3566
+ }
3567
+ const loader = getDesignKitLoader(ctx.projectRoot);
3568
+ const raw = await loader.readTokens(active.kit);
3569
+ if (!raw) {
3570
+ send(ws, { type: "design.verify", payload: { ok: false, error: "Kit has no tokens" } });
3571
+ return;
3572
+ }
3573
+ const tokens = applyTokenOverrides(raw, active.overrides);
3574
+ const report = await runDesignVerify(ctx.projectRoot, tokens);
3575
+ send(ws, {
3576
+ type: "design.verify",
3577
+ payload: {
3578
+ ok: true,
3579
+ kit: active.kit,
3580
+ filesScanned: report.filesScanned,
3581
+ score: report.score,
3582
+ violations: report.violations.slice(0, 50),
3583
+ violationCount: report.violations.length
3584
+ }
3585
+ });
3586
+ } catch (err) {
3587
+ send(ws, { type: "design.verify", payload: { ok: false, error: String(err) } });
3588
+ }
3589
+ }
3590
+
2963
3591
  // src/server/index.ts
2964
3592
  import {
2965
3593
  Agent,
@@ -2971,6 +3599,8 @@ import {
2971
3599
  DefaultSessionReader,
2972
3600
  DefaultSessionStore as DefaultSessionStore2,
2973
3601
  DefaultSkillLoader,
3602
+ DefaultPromptLoader,
3603
+ PromptUsageStore,
2974
3604
  DefaultSystemPromptBuilder as DefaultSystemPromptBuilder3,
2975
3605
  DefaultTokenCounter,
2976
3606
  AnnotationsStore,
@@ -2985,17 +3615,20 @@ import {
2985
3615
  ToolRegistry,
2986
3616
  atomicWrite as atomicWrite6,
2987
3617
  createDefaultPipelines,
3618
+ installDesignStudioMiddleware,
2988
3619
  createSessionEventBridge,
2989
3620
  resolveSessionLoggingConfig,
2990
3621
  DEFAULT_CONTEXT_WINDOW_MODE_ID as DEFAULT_CONTEXT_WINDOW_MODE_ID2,
2991
3622
  DEFAULT_SESSION_PRUNE_DAYS,
2992
3623
  DEFAULT_TOOLS_CONFIG,
2993
3624
  applyToolDescriptionModes,
3625
+ applyToolResultRenderModes,
2994
3626
  resolveContextWindowPolicy as resolveContextWindowPolicy2,
2995
3627
  enhanceUserPrompt,
2996
3628
  gatedEnhancerReasoning,
2997
3629
  recentTextTurns,
2998
- resolveProviderModelList
3630
+ resolveProviderModelList,
3631
+ cleanupStaleSddWorktrees as cleanupStaleSddWorktrees3
2999
3632
  } from "@wrongstack/core";
3000
3633
  import { ToolExecutor } from "@wrongstack/core/execution";
3001
3634
  import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets as encryptConfigSecrets2 } from "@wrongstack/core/security";
@@ -3030,6 +3663,13 @@ import {
3030
3663
  PhaseStore,
3031
3664
  WorktreeManager
3032
3665
  } from "@wrongstack/core";
3666
+ function deriveTitle(goal) {
3667
+ const firstLine = goal.split("\n").map((l) => l.trim()).find(Boolean);
3668
+ if (!firstLine) return "AutoPhase";
3669
+ const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
3670
+ const trimmed = sentence.length <= 64 ? sentence : `${sentence.slice(0, 63).trimEnd()}\u2026`;
3671
+ return trimmed || "AutoPhase";
3672
+ }
3033
3673
  function isGitRepo(cwd) {
3034
3674
  try {
3035
3675
  const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, encoding: "utf8", windowsHide: true });
@@ -3038,6 +3678,19 @@ function isGitRepo(cwd) {
3038
3678
  return false;
3039
3679
  }
3040
3680
  }
3681
+ function commitsSince(cwd, baseSha, branch) {
3682
+ try {
3683
+ const r = spawnSync("git", ["log", "--reverse", "--format=%H", `${baseSha}..${branch}`], {
3684
+ cwd,
3685
+ encoding: "utf8",
3686
+ windowsHide: true
3687
+ });
3688
+ if (r.status !== 0) return [];
3689
+ return r.stdout.split("\n").map((s) => s.trim()).filter(Boolean);
3690
+ } catch {
3691
+ return [];
3692
+ }
3693
+ }
3041
3694
  var AutoPhaseWebSocketHandler = class {
3042
3695
  constructor(agent, context, logger, storeDir, events, projectRoot) {
3043
3696
  this.agent = agent;
@@ -3057,10 +3710,17 @@ var AutoPhaseWebSocketHandler = class {
3057
3710
  store;
3058
3711
  clients = /* @__PURE__ */ new Set();
3059
3712
  broadcastInterval = null;
3060
- /** Aborts in-flight task agents when the run is stopped. */
3713
+ /** Aborts in-flight task agents AND the planning turn when the run is stopped. */
3061
3714
  abort = null;
3715
+ /** Set the instant a stop/clear/revert is requested, so a planning turn that
3716
+ * resolves afterwards never launches the orchestrator (the abort alone can't
3717
+ * cover the window between the LLM call resolving and the orchestrator start). */
3718
+ stopping = false;
3062
3719
  /** Optional per-phase git-worktree isolation (lazily created at start). */
3063
3720
  worktrees = null;
3721
+ /** Base branch + tip SHA captured at run start so a revert can git-revert the
3722
+ * run's squash commits (history-preserving) instead of a destructive reset. */
3723
+ runBase = null;
3064
3724
  /** Per-run worker identities so the board can show "who is on what". */
3065
3725
  usedNicknames = /* @__PURE__ */ new Set();
3066
3726
  addClient(ws) {
@@ -3084,11 +3744,13 @@ var AutoPhaseWebSocketHandler = class {
3084
3744
  this.broadcast({ type: "autophase.resumed", payload: {} });
3085
3745
  break;
3086
3746
  case "autophase.stop":
3087
- this.abort?.abort();
3088
- this.orchestrator?.stop();
3089
- this.stopBroadcast();
3090
- if (this.graph) void this.store.save(this.graph);
3091
- this.broadcast({ type: "autophase.stopped", payload: {} });
3747
+ await this.handleStop();
3748
+ break;
3749
+ case "autophase.clear":
3750
+ await this.handleClear();
3751
+ break;
3752
+ case "autophase.revert":
3753
+ await this.handleRevert();
3092
3754
  break;
3093
3755
  case "autophase.status":
3094
3756
  this.broadcastState();
@@ -3165,17 +3827,27 @@ var AutoPhaseWebSocketHandler = class {
3165
3827
  }
3166
3828
  }
3167
3829
  async handleStart(payload) {
3168
- const title = payload?.goal || payload?.title || "Untitled Project";
3830
+ const goal = payload?.goal || payload?.title || "Untitled Project";
3831
+ const title = deriveTitle(goal);
3169
3832
  const autonomous = payload?.autonomous ?? true;
3170
- const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(title);
3833
+ this.abort = new AbortController();
3834
+ this.stopping = false;
3835
+ const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(goal, this.abort.signal);
3836
+ if (this.stopping || this.abort.signal.aborted) {
3837
+ this.broadcast({ type: "autophase.stopped", payload: { title } });
3838
+ return;
3839
+ }
3171
3840
  this.logger.info(`[AutoPhase] Starting: ${title}`);
3172
- const graph = await new PhaseGraphBuilder({ title, phases, autonomous }).build();
3841
+ const graph = await new PhaseGraphBuilder({ title, description: goal, phases, autonomous }).build();
3173
3842
  this.graph = graph;
3174
- this.abort = new AbortController();
3175
3843
  await this.store.save(graph);
3176
- if (!this.worktrees && this.events && this.projectRoot && process.env["WRONGSTACK_AUTOPHASE_WORKTREES"] !== "0" && isGitRepo(this.projectRoot)) {
3844
+ const useWorktrees = payload?.worktrees ?? process.env["WRONGSTACK_AUTOPHASE_WORKTREES"] !== "0";
3845
+ if (!this.worktrees && this.events && this.projectRoot && useWorktrees && isGitRepo(this.projectRoot)) {
3177
3846
  this.worktrees = new WorktreeManager({ projectRoot: this.projectRoot, events: this.events });
3178
3847
  }
3848
+ if (this.worktrees) {
3849
+ this.runBase = await this.worktrees.currentBase();
3850
+ }
3179
3851
  this.orchestrator = new PhaseOrchestrator({
3180
3852
  graph,
3181
3853
  ctx: {
@@ -3222,6 +3894,62 @@ var AutoPhaseWebSocketHandler = class {
3222
3894
  this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
3223
3895
  });
3224
3896
  }
3897
+ /**
3898
+ * Halt the run NOW — at any phase. Sets `stopping` (so a planning turn that
3899
+ * resolves afterwards bails), aborts in-flight agents, stops the orchestrator
3900
+ * tick, and ends the live broadcast. The board is kept for review; use
3901
+ * `autophase.clear` to reset or `autophase.revert` to undo the changes.
3902
+ */
3903
+ async handleStop() {
3904
+ this.stopping = true;
3905
+ this.abort?.abort();
3906
+ this.orchestrator?.stop();
3907
+ this.stopBroadcast();
3908
+ if (this.graph) await this.store.save(this.graph).catch(() => void 0);
3909
+ this.broadcast({ type: "autophase.stopped", payload: { title: this.graph?.title } });
3910
+ }
3911
+ /**
3912
+ * Stop + wipe: tear down phase worktrees and reset to an empty board so the UI
3913
+ * returns to the start screen ("new one"). Does NOT touch already-merged commits
3914
+ * on the base branch — that is `autophase.revert`.
3915
+ */
3916
+ async handleClear() {
3917
+ await this.handleStop();
3918
+ if (this.worktrees) await this.worktrees.cleanupAllManaged().catch(() => void 0);
3919
+ this.orchestrator = null;
3920
+ this.graph = null;
3921
+ this.runBase = null;
3922
+ this.usedNicknames.clear();
3923
+ this.broadcast({ type: "autophase.cleared", payload: {} });
3924
+ this.broadcast({ type: "autophase.state", payload: this.buildState() });
3925
+ }
3926
+ /**
3927
+ * Stop + undo: remove phase worktrees, then history-preservingly `git revert`
3928
+ * every commit this run landed on the base branch (captured `runBase`..HEAD),
3929
+ * then reset to an empty board. Refuses (reports a reason) on a dirty tree or a
3930
+ * conflicting revert rather than leaving the tree half-reverted.
3931
+ */
3932
+ async handleRevert() {
3933
+ await this.handleStop();
3934
+ if (!this.worktrees || !this.runBase || !this.projectRoot) {
3935
+ this.broadcast({
3936
+ type: "autophase.reverted",
3937
+ payload: { ok: false, reverted: 0, reason: "no git baseline was captured for this run" }
3938
+ });
3939
+ return;
3940
+ }
3941
+ await this.worktrees.cleanupAllManaged().catch(() => void 0);
3942
+ const shas = commitsSince(this.projectRoot, this.runBase.sha, this.runBase.branch);
3943
+ const res = await this.worktrees.revertCommits(this.runBase.branch, shas);
3944
+ this.broadcast({ type: "autophase.reverted", payload: res });
3945
+ if (res.ok) {
3946
+ this.orchestrator = null;
3947
+ this.graph = null;
3948
+ this.runBase = null;
3949
+ this.broadcast({ type: "autophase.cleared", payload: {} });
3950
+ this.broadcast({ type: "autophase.state", payload: this.buildState() });
3951
+ }
3952
+ }
3225
3953
  /** Generic fallback phases when the LLM planner produces nothing usable. */
3226
3954
  defaultPhases() {
3227
3955
  return [
@@ -3232,13 +3960,18 @@ var AutoPhaseWebSocketHandler = class {
3232
3960
  { name: "Deployment", description: "Deploy to production", priority: "medium", estimateHours: 2, parallelizable: false }
3233
3961
  ];
3234
3962
  }
3235
- /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure. */
3236
- async planPhases(goal) {
3963
+ /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure.
3964
+ * The caller passes the run's abort signal so a stop during planning cancels
3965
+ * the LLM turn (the previous fresh, never-aborted controller made planning
3966
+ * uninterruptible). */
3967
+ async planPhases(goal, signal) {
3237
3968
  try {
3238
3969
  const planner = new AutoPhasePlanner({
3239
3970
  goal,
3240
3971
  runOnce: async (prompt) => {
3241
- const result = await this.agent.run(prompt, { signal: new AbortController().signal });
3972
+ const result = await this.agent.run(prompt, {
3973
+ signal: signal ?? new AbortController().signal
3974
+ });
3242
3975
  return result.status === "done" ? result.finalText ?? "" : "";
3243
3976
  }
3244
3977
  });
@@ -3373,6 +4106,10 @@ Type: ${task.type}`;
3373
4106
  const lastError = lastFailed ? `${lastFailed.name}: ${lastFailed.metadata?.integrationError ?? "phase failed"}` : null;
3374
4107
  return {
3375
4108
  title: this.graph.title,
4109
+ // Full operator prompt, shown verbatim in a dedicated goal block (the
4110
+ // title is only a short derived heading). Fall back to the title for
4111
+ // legacy boards saved before the title/goal split.
4112
+ goal: this.graph.description || this.graph.title,
3376
4113
  phases: phaseItems,
3377
4114
  tasks: taskItems,
3378
4115
  activePhaseId: currentActiveId,
@@ -3574,7 +4311,7 @@ var SpecsWebSocketHandler = class {
3574
4311
  };
3575
4312
 
3576
4313
  // src/server/sdd-board-ws-handler.ts
3577
- import { SddBoardStore } from "@wrongstack/core";
4314
+ import { applySddLifecycle, SddBoardStore } from "@wrongstack/core";
3578
4315
  var CONTROL_TYPES = /* @__PURE__ */ new Set([
3579
4316
  "pause",
3580
4317
  "resume",
@@ -3588,19 +4325,19 @@ var CONTROL_TYPES = /* @__PURE__ */ new Set([
3588
4325
  "set_task_verification",
3589
4326
  "cancel_task",
3590
4327
  "delete_task",
3591
- "split_task",
3592
- // Lifecycle (pair with a prior `stop`): sweep worktrees / revert merged commits.
3593
- "cleanup_worktrees",
3594
- "rollback"
4328
+ "split_task"
3595
4329
  ]);
4330
+ var LIFECYCLE_TYPES = /* @__PURE__ */ new Set(["cleanup_worktrees", "rollback", "destroy"]);
3596
4331
  var SddBoardWebSocketHandler = class {
3597
4332
  store;
3598
4333
  clients = /* @__PURE__ */ new Set();
4334
+ lifecycle;
3599
4335
  latest = null;
3600
4336
  poll = null;
3601
4337
  unsub = null;
3602
- constructor(boardsDir, events) {
4338
+ constructor(boardsDir, events, lifecycle) {
3603
4339
  this.store = new SddBoardStore({ baseDir: boardsDir });
4340
+ this.lifecycle = lifecycle;
3604
4341
  if (events) {
3605
4342
  const handler = (e) => {
3606
4343
  this.latest = e.snapshot;
@@ -3629,6 +4366,10 @@ var SddBoardWebSocketHandler = class {
3629
4366
  return;
3630
4367
  }
3631
4368
  const action = msg.type.replace(/^sdd\.board\./, "");
4369
+ if (LIFECYCLE_TYPES.has(action)) {
4370
+ await this.applyLifecycle(action, msg.payload);
4371
+ return;
4372
+ }
3632
4373
  if (CONTROL_TYPES.has(action)) {
3633
4374
  const runId = msg.payload?.runId ?? this.latest?.runId ?? (await this.store.list())[0]?.runId;
3634
4375
  if (runId) {
@@ -3640,6 +4381,40 @@ var SddBoardWebSocketHandler = class {
3640
4381
  }
3641
4382
  }
3642
4383
  }
4384
+ /**
4385
+ * Apply a cleanup/rollback/destroy from disk and broadcast a structured
4386
+ * `sdd.board.lifecycle_result`. Refuses (no-op) while a run is still active —
4387
+ * the user must stop it first; the UI gates the buttons on `!active` and the
4388
+ * Destroy flow auto-stops then waits before sending `destroy`.
4389
+ */
4390
+ async applyLifecycle(op, payload) {
4391
+ if (!this.lifecycle) {
4392
+ this.broadcast({
4393
+ type: "sdd.board.lifecycle_result",
4394
+ payload: { op, ok: false, reason: "Lifecycle operations are not available in this session." }
4395
+ });
4396
+ return;
4397
+ }
4398
+ if (this.latest && (this.latest.status === "running" || this.latest.status === "paused")) {
4399
+ this.broadcast({
4400
+ type: "sdd.board.lifecycle_result",
4401
+ payload: { op, ok: false, reason: "Stop the run first, then retry." }
4402
+ });
4403
+ return;
4404
+ }
4405
+ const runId = payload?.runId ?? this.latest?.runId;
4406
+ const result = await applySddLifecycle(op, {
4407
+ projectRoot: this.lifecycle.projectRoot,
4408
+ paths: this.lifecycle.paths,
4409
+ runId,
4410
+ revertMerged: payload?.revertMerged === true
4411
+ });
4412
+ this.broadcast({ type: "sdd.board.lifecycle_result", payload: result });
4413
+ if (op === "destroy" && result.ok) {
4414
+ this.latest = null;
4415
+ this.broadcast({ type: "sdd.board.snapshot", payload: null });
4416
+ }
4417
+ }
3643
4418
  dispose() {
3644
4419
  if (this.poll) clearInterval(this.poll);
3645
4420
  this.unsub?.();
@@ -3683,6 +4458,12 @@ var SddBoardWebSocketHandler = class {
3683
4458
  };
3684
4459
 
3685
4460
  // src/server/sdd-wizard-ws-handler.ts
4461
+ function deriveTitle2(goal) {
4462
+ const firstLine = goal.split("\n").map((l) => l.trim()).find(Boolean);
4463
+ if (!firstLine) return "New SDD Project";
4464
+ const sentence = firstLine.split(/(?<=[.!?])\s/)[0] ?? firstLine;
4465
+ return sentence.length <= 64 ? sentence : `${sentence.slice(0, 63).trimEnd()}\u2026`;
4466
+ }
3686
4467
  var SddWizardWebSocketHandler = class {
3687
4468
  constructor(deps2) {
3688
4469
  this.deps = deps2;
@@ -3721,7 +4502,8 @@ var SddWizardWebSocketHandler = class {
3721
4502
  parallelSlots: msg.payload?.parallelSlots,
3722
4503
  defaultModel: msg.payload?.model,
3723
4504
  defaultProvider: msg.payload?.provider,
3724
- fallbackModels: Array.isArray(msg.payload?.fallbackModels) ? msg.payload?.fallbackModels : void 0
4505
+ fallbackModels: Array.isArray(msg.payload?.fallbackModels) ? msg.payload?.fallbackModels : void 0,
4506
+ worktrees: typeof msg.payload?.worktrees === "boolean" ? msg.payload.worktrees : void 0
3725
4507
  });
3726
4508
  break;
3727
4509
  }
@@ -3741,7 +4523,7 @@ var SddWizardWebSocketHandler = class {
3741
4523
  }
3742
4524
  if (this.busy) return;
3743
4525
  this.driver = this.deps.makeDriver();
3744
- const prompt = this.driver.start(goal);
4526
+ const prompt = this.driver.start(deriveTitle2(goal), goal);
3745
4527
  await this.runTurn(prompt);
3746
4528
  }
3747
4529
  async onMessage(text) {
@@ -3812,9 +4594,10 @@ var SddWizardWebSocketHandler = class {
3812
4594
  };
3813
4595
 
3814
4596
  // src/server/sdd-wizard-wiring.ts
3815
- import * as path7 from "path";
4597
+ import * as path8 from "path";
3816
4598
  import { spawnSync as spawnSync2 } from "child_process";
3817
4599
  import {
4600
+ cleanupStaleSddWorktrees,
3818
4601
  makeCommandVerifier,
3819
4602
  makeLlmSubtaskGenerator,
3820
4603
  SddBoardStore as SddBoardStore2,
@@ -3826,6 +4609,7 @@ import {
3826
4609
  TaskGraphStore as TaskGraphStore2,
3827
4610
  WorktreeManager as WorktreeManager2
3828
4611
  } from "@wrongstack/core";
4612
+ var PLANNING_ONLY_GUARD = "SYSTEM: You are running a PLANNING-ONLY specification interview. Do NOT write, create, or edit any files, and do NOT run shell/terminal commands or use any code-editing tools \u2014 they are disabled here and any attempt will fail and waste the turn. Respond with TEXT ONLY: ask your question, or emit the requested spec / plan / task JSON. All code is written later, automatically, once the plan is approved and the multi-agent run starts.\n\n---\n\n";
3829
4613
  function buildSddWizardDeps(opts) {
3830
4614
  const registry = new SddRunRegistry();
3831
4615
  let isolatedSeq = 0;
@@ -3834,11 +4618,11 @@ function buildSddWizardDeps(opts) {
3834
4618
  id: `sdd-${name2.toLowerCase().replace(/\s+/g, "-")}-${isolatedSeq++}`,
3835
4619
  role: "executor",
3836
4620
  name: name2,
3837
- disabledTools: ["delegate"],
4621
+ disabledTools: ["delegate", "write", "edit", "patch", "bash", "exec"],
3838
4622
  allowedCapabilities: ["fs.read", "net.outbound"]
3839
4623
  });
3840
4624
  try {
3841
- const res = await result.agent.run([{ type: "text", text: prompt }]);
4625
+ const res = await result.agent.run([{ type: "text", text: PLANNING_ONLY_GUARD + prompt }]);
3842
4626
  return res.finalText ?? "";
3843
4627
  } finally {
3844
4628
  await result.dispose?.();
@@ -3848,23 +4632,30 @@ function buildSddWizardDeps(opts) {
3848
4632
  makeDriver: () => new SddInterviewDriver({
3849
4633
  specStore: new SpecStore2({ baseDir: opts.paths.projectSpecs }),
3850
4634
  graphStore: new TaskGraphStore2({ baseDir: opts.paths.projectTaskGraphs }),
3851
- sessionPath: path7.join(opts.paths.projectDir, "sdd-wizard-session.json")
4635
+ sessionPath: path8.join(opts.paths.projectDir, "sdd-wizard-session.json")
3852
4636
  }),
3853
4637
  runInterviewTurn: (prompt) => runIsolatedTurn(prompt, "Spec Architect"),
3854
- startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels }) => {
4638
+ startRun: async (driver, { parallelSlots, defaultModel, defaultProvider, fallbackModels, worktrees: useWorktrees }) => {
3855
4639
  const graph = driver.getGraph();
3856
4640
  const tracker = driver.getTracker();
3857
4641
  if (!graph || !tracker) {
3858
4642
  throw new Error("No task graph to run \u2014 finish the interview first.");
3859
4643
  }
4644
+ const worktreesEnabled = useWorktrees ?? process.env["WRONGSTACK_SDD_WORKTREES"] !== "0";
3860
4645
  let worktrees;
3861
- if (process.env["WRONGSTACK_SDD_WORKTREES"] !== "0") {
4646
+ if (worktreesEnabled) {
3862
4647
  const inGit = spawnSync2("git", ["rev-parse", "--is-inside-work-tree"], {
3863
4648
  cwd: opts.projectRoot,
3864
4649
  encoding: "utf8",
3865
4650
  windowsHide: true
3866
4651
  }).stdout?.trim() === "true";
3867
- if (inGit) worktrees = new WorktreeManager2({ projectRoot: opts.projectRoot, events: opts.events });
4652
+ if (inGit) {
4653
+ await cleanupStaleSddWorktrees({
4654
+ projectRoot: opts.projectRoot,
4655
+ boardsDir: opts.paths.projectSddBoards
4656
+ }).catch(() => void 0);
4657
+ worktrees = new WorktreeManager2({ projectRoot: opts.projectRoot, events: opts.events });
4658
+ }
3868
4659
  }
3869
4660
  const boardStore = new SddBoardStore2({ baseDir: opts.paths.projectSddBoards });
3870
4661
  const verifyTask = makeCommandVerifier();
@@ -4643,16 +5434,16 @@ var CollaborationWebSocketHandler = class {
4643
5434
  };
4644
5435
 
4645
5436
  // src/server/projects-manifest.ts
4646
- import * as fs6 from "fs/promises";
4647
- import * as path8 from "path";
5437
+ import * as fs7 from "fs/promises";
5438
+ import * as path9 from "path";
4648
5439
  import { projectSlug } from "@wrongstack/core";
4649
5440
  function projectsJsonPath(globalConfigPath) {
4650
- const base = path8.dirname(globalConfigPath);
4651
- return path8.join(base, "projects.json");
5441
+ const base = path9.dirname(globalConfigPath);
5442
+ return path9.join(base, "projects.json");
4652
5443
  }
4653
5444
  async function loadManifest(globalConfigPath) {
4654
5445
  try {
4655
- const raw = await fs6.readFile(projectsJsonPath(globalConfigPath), "utf8");
5446
+ const raw = await fs7.readFile(projectsJsonPath(globalConfigPath), "utf8");
4656
5447
  const parsed = JSON.parse(raw);
4657
5448
  return { projects: parsed.projects ?? [] };
4658
5449
  } catch {
@@ -4661,16 +5452,16 @@ async function loadManifest(globalConfigPath) {
4661
5452
  }
4662
5453
  async function saveManifest(manifest, globalConfigPath) {
4663
5454
  const file = projectsJsonPath(globalConfigPath);
4664
- await fs6.mkdir(path8.dirname(file), { recursive: true });
4665
- await fs6.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
5455
+ await fs7.mkdir(path9.dirname(file), { recursive: true });
5456
+ await fs7.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
4666
5457
  }
4667
5458
  function generateProjectSlug(rootPath) {
4668
5459
  return projectSlug(rootPath);
4669
5460
  }
4670
5461
  async function ensureProjectDataDir(slug, globalConfigPath) {
4671
- const base = path8.dirname(globalConfigPath);
4672
- const dir = path8.join(base, "projects", slug);
4673
- await fs6.mkdir(dir, { recursive: true });
5462
+ const base = path9.dirname(globalConfigPath);
5463
+ const dir = path9.join(base, "projects", slug);
5464
+ await fs7.mkdir(dir, { recursive: true });
4674
5465
  return dir;
4675
5466
  }
4676
5467
 
@@ -4832,16 +5623,22 @@ function clampDim(value, fallback) {
4832
5623
  }
4833
5624
 
4834
5625
  // src/server/worktree-ws-handler.ts
5626
+ import { join as join6, resolve as resolve6, sep as sep4 } from "path";
5627
+ import { cleanupStaleSddWorktrees as cleanupStaleSddWorktrees2, WorktreeManager as WorktreeManager3 } from "@wrongstack/core";
4835
5628
  import { toErrorMessage as toErrorMessage4 } from "@wrongstack/core/utils";
4836
5629
  var MAX_ACTIVITY = 6;
5630
+ var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["allocating", "active", "committing", "merging"]);
5631
+ var MANAGED_BRANCH_RE = /^wstack\/ap\/[A-Za-z0-9._/-]+$/;
4837
5632
  var WorktreeWebSocketHandler = class {
4838
- constructor(events, logger) {
5633
+ constructor(events, logger, management) {
4839
5634
  this.events = events;
4840
5635
  this.logger = logger;
5636
+ this.management = management;
4841
5637
  this.subscribe();
4842
5638
  }
4843
5639
  events;
4844
5640
  logger;
5641
+ management;
4845
5642
  clients = /* @__PURE__ */ new Set();
4846
5643
  handles = /* @__PURE__ */ new Map();
4847
5644
  baseBranch = "";
@@ -4852,12 +5649,197 @@ var WorktreeWebSocketHandler = class {
4852
5649
  ws.on("close", () => this.clients.delete(ws));
4853
5650
  ws.on("error", () => this.clients.delete(ws));
4854
5651
  this.send(ws, this.stateMessage());
5652
+ void this.scanAndBroadcast();
5653
+ }
5654
+ /** Handle worktree-panel control messages (scan / clean / per-row ops). */
5655
+ async handleMessage(msg) {
5656
+ if (msg.type === "worktree.scan") {
5657
+ await this.scanAndBroadcast();
5658
+ return true;
5659
+ }
5660
+ if (msg.type === "worktree.cleanup") {
5661
+ await this.cleanupOrphans();
5662
+ return true;
5663
+ }
5664
+ if (msg.type === "worktree.remove") {
5665
+ await this.removeOne(msg.payload?.["dir"], msg.payload?.["branch"]);
5666
+ return true;
5667
+ }
5668
+ if (msg.type === "worktree.merge") {
5669
+ await this.mergeBranch(msg.payload?.["branch"]);
5670
+ return true;
5671
+ }
5672
+ if (msg.type === "worktree.diff") {
5673
+ await this.diffOne(msg.payload?.["dir"], msg.payload?.["baseBranch"]);
5674
+ return true;
5675
+ }
5676
+ return false;
4855
5677
  }
4856
5678
  dispose() {
4857
5679
  for (const off of this.offs) off();
4858
5680
  this.offs.length = 0;
4859
5681
  this.stopBroadcast();
4860
5682
  }
5683
+ // ── orphan management ─────────────────────────────────────────────────────
5684
+ /** Absolute managed-worktrees root for this project. */
5685
+ worktreesRoot() {
5686
+ return resolve6(join6(this.management.projectRoot, ".wrongstack", "worktrees"));
5687
+ }
5688
+ /** True iff `dir` resolves strictly inside the managed worktrees root. */
5689
+ underRoot(dir) {
5690
+ const abs = resolve6(dir);
5691
+ const root = this.worktreesRoot();
5692
+ return abs !== root && abs.startsWith(root + sep4);
5693
+ }
5694
+ /** Branches of worktrees a live in-session run currently owns. */
5695
+ liveActiveBranches() {
5696
+ const live = /* @__PURE__ */ new Set();
5697
+ for (const h of this.handles.values()) {
5698
+ if (ACTIVE_STATUSES.has(h.status) && h.branch) live.add(h.branch);
5699
+ }
5700
+ return live;
5701
+ }
5702
+ /**
5703
+ * Scan the disk for managed worktrees/branches NOT owned by a live in-session
5704
+ * run and broadcast them as orphans, with whether it is safe to clean now.
5705
+ * No-op (empty inventory) when management deps were not wired.
5706
+ */
5707
+ async scanAndBroadcast() {
5708
+ if (!this.management) {
5709
+ this.broadcast({ type: "worktree.orphans", payload: { orphans: [], canClean: false } });
5710
+ return;
5711
+ }
5712
+ try {
5713
+ const wt = new WorktreeManager3({ projectRoot: this.management.projectRoot });
5714
+ const { worktrees, branches } = await wt.listManaged();
5715
+ const live = this.liveActiveBranches();
5716
+ const orphans = [];
5717
+ const seenBranches = /* @__PURE__ */ new Set();
5718
+ for (const w of worktrees) {
5719
+ if (w.branch && live.has(w.branch)) continue;
5720
+ if (w.branch) seenBranches.add(w.branch);
5721
+ orphans.push({ kind: "worktree", dir: w.dir, branch: w.branch });
5722
+ }
5723
+ for (const b of branches) {
5724
+ if (live.has(b) || seenBranches.has(b)) continue;
5725
+ orphans.push({ kind: "branch", branch: b });
5726
+ }
5727
+ const canClean = this.liveActiveBranches().size === 0;
5728
+ this.broadcast({
5729
+ type: "worktree.orphans",
5730
+ payload: {
5731
+ orphans,
5732
+ canClean,
5733
+ reason: canClean ? void 0 : "a run is live in this session"
5734
+ }
5735
+ });
5736
+ } catch (err) {
5737
+ this.logger.debug?.(`worktree orphan scan failed: ${toErrorMessage4(err)}`);
5738
+ this.broadcast({ type: "worktree.orphans", payload: { orphans: [], canClean: false } });
5739
+ }
5740
+ }
5741
+ /**
5742
+ * Force-remove every orphaned worktree + branch. Refused while a run is live —
5743
+ * in this session (active handles) OR another process (the SDD board liveness
5744
+ * guard inside cleanupStaleSddWorktrees). Best-effort; reports the outcome.
5745
+ */
5746
+ async cleanupOrphans() {
5747
+ if (!this.management) {
5748
+ this.broadcast({
5749
+ type: "worktree.cleanup_result",
5750
+ payload: { ok: false, removed: 0, reason: "cleanup is not available in this session" }
5751
+ });
5752
+ return;
5753
+ }
5754
+ if (this.liveActiveBranches().size > 0) {
5755
+ this.broadcast({
5756
+ type: "worktree.cleanup_result",
5757
+ payload: { ok: false, removed: 0, reason: "a run is live in this session \u2014 stop it first" }
5758
+ });
5759
+ return;
5760
+ }
5761
+ const res = await cleanupStaleSddWorktrees2({
5762
+ projectRoot: this.management.projectRoot,
5763
+ boardsDir: this.management.boardsDir
5764
+ });
5765
+ if (res.skippedReason) {
5766
+ this.broadcast({
5767
+ type: "worktree.cleanup_result",
5768
+ payload: { ok: false, removed: 0, reason: res.skippedReason }
5769
+ });
5770
+ await this.scanAndBroadcast();
5771
+ return;
5772
+ }
5773
+ for (const [id, h] of [...this.handles]) {
5774
+ if (!ACTIVE_STATUSES.has(h.status)) this.handles.delete(id);
5775
+ }
5776
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: true, removed: res.removed } });
5777
+ this.broadcastState();
5778
+ await this.scanAndBroadcast();
5779
+ }
5780
+ /** Remove/discard ONE worktree + branch. Refused while a live run owns it. */
5781
+ async removeOne(dir, branch) {
5782
+ if (!this.management || !dir && !branch) {
5783
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: false, removed: 0, reason: "nothing to remove" } });
5784
+ return;
5785
+ }
5786
+ if (branch && !MANAGED_BRANCH_RE.test(branch)) {
5787
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: false, removed: 0, reason: "not a managed worktree branch" } });
5788
+ return;
5789
+ }
5790
+ if (dir && !this.underRoot(dir)) {
5791
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: false, removed: 0, reason: "path is outside the managed worktrees root" } });
5792
+ return;
5793
+ }
5794
+ if (branch && this.liveActiveBranches().has(branch)) {
5795
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: false, removed: 0, reason: "a run is live on this worktree \u2014 stop it first" } });
5796
+ return;
5797
+ }
5798
+ let removed = false;
5799
+ if (dir) {
5800
+ const wt = new WorktreeManager3({ projectRoot: this.management.projectRoot });
5801
+ ({ removed } = await wt.removeOne(dir, branch));
5802
+ }
5803
+ for (const [id, h] of [...this.handles]) {
5804
+ if (branch && h.branch === branch || dir && h.handleId && dir.endsWith(h.handleId)) this.handles.delete(id);
5805
+ }
5806
+ this.broadcast({ type: "worktree.cleanup_result", payload: { ok: removed, removed: removed ? 1 : 0, reason: removed ? void 0 : "remove failed (not a managed worktree?)" } });
5807
+ this.broadcastState();
5808
+ await this.scanAndBroadcast();
5809
+ }
5810
+ /** Squash-merge ONE branch into base. Refused while a live run owns it. */
5811
+ async mergeBranch(branch) {
5812
+ if (!this.management || !branch) {
5813
+ this.broadcast({ type: "worktree.merge_result", payload: { ok: false, branch: branch ?? "", reason: "no branch" } });
5814
+ return;
5815
+ }
5816
+ if (!MANAGED_BRANCH_RE.test(branch)) {
5817
+ this.broadcast({ type: "worktree.merge_result", payload: { ok: false, branch, reason: "not a managed worktree branch" } });
5818
+ return;
5819
+ }
5820
+ if (this.liveActiveBranches().has(branch)) {
5821
+ this.broadcast({ type: "worktree.merge_result", payload: { ok: false, branch, reason: "a run is live on this worktree \u2014 stop it first" } });
5822
+ return;
5823
+ }
5824
+ const wt = new WorktreeManager3({ projectRoot: this.management.projectRoot });
5825
+ const res = await wt.mergeBranch(branch);
5826
+ this.broadcast({
5827
+ type: "worktree.merge_result",
5828
+ payload: { ok: res.ok, branch, conflict: res.conflict, conflictFiles: res.conflictFiles, reason: res.reason }
5829
+ });
5830
+ await this.scanAndBroadcast();
5831
+ }
5832
+ /** Compact change summary for one worktree checkout. */
5833
+ async diffOne(dir, baseBranch) {
5834
+ if (!this.management || !dir || !this.underRoot(dir)) {
5835
+ this.broadcast({ type: "worktree.diff_result", payload: { dir: dir ?? "", summary: null } });
5836
+ return;
5837
+ }
5838
+ const base = baseBranch && MANAGED_BRANCH_RE.test(baseBranch) ? baseBranch : void 0;
5839
+ const wt = new WorktreeManager3({ projectRoot: this.management.projectRoot });
5840
+ const summary = await wt.diffSummary(resolve6(dir), base);
5841
+ this.broadcast({ type: "worktree.diff_result", payload: { dir, summary } });
5842
+ }
4861
5843
  // ── internals ───────────────────────────────────────────────────────────
4862
5844
  subscribe() {
4863
5845
  const on = this.events.on.bind(this.events);
@@ -4869,6 +5851,7 @@ var WorktreeWebSocketHandler = class {
4869
5851
  handleId: e.handleId,
4870
5852
  ownerId: e.ownerId,
4871
5853
  ownerLabel: e.ownerLabel,
5854
+ dir: e.dir,
4872
5855
  branch: e.branch,
4873
5856
  baseBranch: e.baseBranch,
4874
5857
  status: "active",
@@ -4971,10 +5954,10 @@ var WorktreeWebSocketHandler = class {
4971
5954
  };
4972
5955
 
4973
5956
  // src/server/mailbox-handlers.ts
4974
- import { GlobalMailbox, resolveProjectDir } from "@wrongstack/core";
5957
+ import { GlobalMailbox, resolveProjectDir as resolveProjectDir2 } from "@wrongstack/core";
4975
5958
  async function handleMailboxMessages(ws, deps2, payload) {
4976
5959
  try {
4977
- const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
5960
+ const dir = resolveProjectDir2(deps2.projectRoot, deps2.globalRoot);
4978
5961
  const mb = new GlobalMailbox(dir);
4979
5962
  const messages = await mb.query({
4980
5963
  limit: payload?.limit ?? 30,
@@ -5010,7 +5993,7 @@ async function handleMailboxMessages(ws, deps2, payload) {
5010
5993
  }
5011
5994
  async function handleMailboxAgents(ws, deps2, payload) {
5012
5995
  try {
5013
- const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
5996
+ const dir = resolveProjectDir2(deps2.projectRoot, deps2.globalRoot);
5014
5997
  const mb = new GlobalMailbox(dir);
5015
5998
  const agents = payload?.onlineOnly ? await mb.getOnlineAgents() : await mb.getAgentStatuses();
5016
5999
  send(ws, {
@@ -5039,7 +6022,7 @@ async function handleMailboxAgents(ws, deps2, payload) {
5039
6022
  }
5040
6023
  async function handleMailboxClear(ws, deps2) {
5041
6024
  try {
5042
- const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
6025
+ const dir = resolveProjectDir2(deps2.projectRoot, deps2.globalRoot);
5043
6026
  const mb = new GlobalMailbox(dir);
5044
6027
  await mb.clearAll();
5045
6028
  send(ws, { type: "mailbox.cleared", payload: {} });
@@ -5049,7 +6032,7 @@ async function handleMailboxClear(ws, deps2) {
5049
6032
  }
5050
6033
  async function handleMailboxPurge(ws, deps2, opts) {
5051
6034
  try {
5052
- const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
6035
+ const dir = resolveProjectDir2(deps2.projectRoot, deps2.globalRoot);
5053
6036
  const mb = new GlobalMailbox(dir);
5054
6037
  const result = await mb.purgeStale(opts);
5055
6038
  send(ws, { type: "mailbox.purged", payload: result });
@@ -5096,14 +6079,14 @@ function registerShutdownHandlers(res) {
5096
6079
 
5097
6080
  // src/server/instance-registry.ts
5098
6081
  import * as os from "os";
5099
- import * as path9 from "path";
5100
- import * as fs7 from "fs/promises";
6082
+ import * as path10 from "path";
6083
+ import * as fs8 from "fs/promises";
5101
6084
  import { atomicWrite as atomicWrite3 } from "@wrongstack/core";
5102
6085
  function defaultBaseDir() {
5103
- return path9.join(os.homedir(), ".wrongstack");
6086
+ return path10.join(os.homedir(), ".wrongstack");
5104
6087
  }
5105
6088
  function registryPath(baseDir = defaultBaseDir()) {
5106
- return path9.join(baseDir, "webui-instances.json");
6089
+ return path10.join(baseDir, "webui-instances.json");
5107
6090
  }
5108
6091
  function isPidAlive(pid) {
5109
6092
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -5116,7 +6099,7 @@ function isPidAlive(pid) {
5116
6099
  }
5117
6100
  async function load(file) {
5118
6101
  try {
5119
- const raw = await fs7.readFile(file, "utf8");
6102
+ const raw = await fs8.readFile(file, "utf8");
5120
6103
  const parsed = JSON.parse(raw);
5121
6104
  if (parsed?.version === 1 && Array.isArray(parsed.instances)) {
5122
6105
  return parsed;
@@ -5175,16 +6158,16 @@ function formatInstances(instances) {
5175
6158
  // src/server/port-utils.ts
5176
6159
  import * as net from "net";
5177
6160
  function isPortFree(host, port) {
5178
- return new Promise((resolve9) => {
6161
+ return new Promise((resolve10) => {
5179
6162
  const srv = net.createServer();
5180
- srv.once("error", () => resolve9(false));
6163
+ srv.once("error", () => resolve10(false));
5181
6164
  srv.once("listening", () => {
5182
- srv.close(() => resolve9(true));
6165
+ srv.close(() => resolve10(true));
5183
6166
  });
5184
6167
  try {
5185
6168
  srv.listen(port, host);
5186
6169
  } catch {
5187
- resolve9(false);
6170
+ resolve10(false);
5188
6171
  }
5189
6172
  });
5190
6173
  }
@@ -5265,15 +6248,15 @@ import { DefaultSecretScrubber } from "@wrongstack/core";
5265
6248
  import { probeLocalLlm } from "@wrongstack/runtime/probe";
5266
6249
 
5267
6250
  // src/server/provider-config-io.ts
5268
- import * as fs8 from "fs/promises";
5269
- import * as path10 from "path";
6251
+ import * as fs9 from "fs/promises";
6252
+ import * as path11 from "path";
5270
6253
  import { atomicWrite as atomicWrite4 } from "@wrongstack/core";
5271
6254
  import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/security";
5272
6255
  import { DefaultSecretVault } from "@wrongstack/core";
5273
6256
  async function loadSavedProviders(configPath, vault) {
5274
6257
  let raw;
5275
6258
  try {
5276
- raw = await fs8.readFile(configPath, "utf8");
6259
+ raw = await fs9.readFile(configPath, "utf8");
5277
6260
  } catch {
5278
6261
  return {};
5279
6262
  }
@@ -5290,7 +6273,7 @@ async function saveProviders(configPath, vault, providers) {
5290
6273
  let raw;
5291
6274
  let fileExists = true;
5292
6275
  try {
5293
- raw = await fs8.readFile(configPath, "utf8");
6276
+ raw = await fs9.readFile(configPath, "utf8");
5294
6277
  } catch (err) {
5295
6278
  if (err.code !== "ENOENT") {
5296
6279
  throw new Error(
@@ -5318,7 +6301,7 @@ async function saveProviders(configPath, vault, providers) {
5318
6301
  await atomicWrite4(configPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
5319
6302
  }
5320
6303
  function createProviderConfigIO(configPath) {
5321
- const keyFile = path10.join(path10.dirname(configPath), ".key");
6304
+ const keyFile = path11.join(path11.dirname(configPath), ".key");
5322
6305
  const vault = new DefaultSecretVault({ keyFile });
5323
6306
  return {
5324
6307
  load: () => loadSavedProviders(configPath, vault),
@@ -5636,7 +6619,8 @@ function createProviderHandlers(deps2) {
5636
6619
 
5637
6620
  // src/server/mode-handlers.ts
5638
6621
  import {
5639
- DefaultSystemPromptBuilder
6622
+ DefaultSystemPromptBuilder,
6623
+ resolveWstackPaths
5640
6624
  } from "@wrongstack/core";
5641
6625
  function createModeHandlers(ctx) {
5642
6626
  return {
@@ -5684,13 +6668,18 @@ function createModeHandlers(ctx) {
5684
6668
  }
5685
6669
  ctx.setModeId(id);
5686
6670
  const modePrompt = id === "default" ? "" : (await ctx.modeStore.getMode(id))?.prompt ?? "";
6671
+ const paths = resolveWstackPaths({ projectRoot: ctx.projectRoot, globalRoot: ctx.globalRoot });
5687
6672
  const freshBuilder = new DefaultSystemPromptBuilder({
5688
6673
  memoryStore: ctx.memoryStore,
5689
6674
  skillLoader: ctx.skillLoader,
5690
6675
  modeStore: ctx.modeStore,
5691
6676
  modeId: id,
5692
6677
  modePrompt,
5693
- modelCapabilities: ctx.modelCapabilities
6678
+ modelCapabilities: ctx.modelCapabilities,
6679
+ instructionPaths: {
6680
+ globalDir: paths.globalInstructions,
6681
+ projectDir: paths.inProjectInstructions
6682
+ }
5694
6683
  });
5695
6684
  ctx.context.systemPrompt = await freshBuilder.build({
5696
6685
  cwd: ctx.projectRoot,
@@ -5712,12 +6701,13 @@ function createModeHandlers(ctx) {
5712
6701
  }
5713
6702
 
5714
6703
  // src/server/project-handlers.ts
5715
- import * as fs9 from "fs/promises";
5716
- import * as path11 from "path";
6704
+ import * as fs10 from "fs/promises";
6705
+ import * as path12 from "path";
5717
6706
  import {
5718
6707
  DefaultSessionStore,
5719
6708
  DefaultSystemPromptBuilder as DefaultSystemPromptBuilder2,
5720
- getSessionRegistry
6709
+ getSessionRegistry,
6710
+ resolveWstackPaths as resolveWstackPaths2
5721
6711
  } from "@wrongstack/core";
5722
6712
  function createProjectHandlers(ctx) {
5723
6713
  return {
@@ -5740,9 +6730,9 @@ function createProjectHandlers(ctx) {
5740
6730
  }
5741
6731
  const { root: addRoot, name: displayName } = parsed.value;
5742
6732
  try {
5743
- const resolved = path11.resolve(addRoot);
5744
- await fs9.access(resolved);
5745
- const stat3 = await fs9.stat(resolved);
6733
+ const resolved = path12.resolve(addRoot);
6734
+ await fs10.access(resolved);
6735
+ const stat3 = await fs10.stat(resolved);
5746
6736
  if (!stat3.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
5747
6737
  const manifest = await loadManifest(ctx.globalConfigPath);
5748
6738
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -5758,7 +6748,7 @@ function createProjectHandlers(ctx) {
5758
6748
  });
5759
6749
  return;
5760
6750
  }
5761
- const name2 = displayName?.trim() || path11.basename(resolved);
6751
+ const name2 = displayName?.trim() || path12.basename(resolved);
5762
6752
  const slug = generateProjectSlug(resolved);
5763
6753
  await ensureProjectDataDir(slug, ctx.globalConfigPath);
5764
6754
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -5771,7 +6761,7 @@ function createProjectHandlers(ctx) {
5771
6761
  } catch (err) {
5772
6762
  send(ws, {
5773
6763
  type: "projects.added",
5774
- payload: { name: path11.basename(addRoot), root: addRoot, slug: "", message: errMessage(err) }
6764
+ payload: { name: path12.basename(addRoot), root: addRoot, slug: "", message: errMessage(err) }
5775
6765
  });
5776
6766
  }
5777
6767
  },
@@ -5786,17 +6776,17 @@ function createProjectHandlers(ctx) {
5786
6776
  }
5787
6777
  const { root: selRoot, name: selName } = parsed.value;
5788
6778
  try {
5789
- const resolved = path11.resolve(selRoot);
6779
+ const resolved = path12.resolve(selRoot);
5790
6780
  try {
5791
- await fs9.access(resolved);
5792
- const stat3 = await fs9.stat(resolved);
6781
+ await fs10.access(resolved);
6782
+ const stat3 = await fs10.stat(resolved);
5793
6783
  if (!stat3.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
5794
6784
  } catch (err) {
5795
6785
  send(ws, {
5796
6786
  type: "projects.selected",
5797
6787
  payload: {
5798
6788
  root: selRoot,
5799
- name: selName || path11.basename(selRoot),
6789
+ name: selName || path12.basename(selRoot),
5800
6790
  message: `Cannot switch: ${errMessage(err)}`
5801
6791
  }
5802
6792
  });
@@ -5808,7 +6798,7 @@ function createProjectHandlers(ctx) {
5808
6798
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
5809
6799
  entry.lastWorkingDir = resolved;
5810
6800
  } else {
5811
- const name2 = selName?.trim() || path11.basename(resolved);
6801
+ const name2 = selName?.trim() || path12.basename(resolved);
5812
6802
  const slug = generateProjectSlug(resolved);
5813
6803
  manifest.projects.push({
5814
6804
  name: name2,
@@ -5830,13 +6820,21 @@ function createProjectHandlers(ctx) {
5830
6820
  try {
5831
6821
  const modeId = ctx.getModeId();
5832
6822
  const switchMode = modeId === "default" ? void 0 : await ctx.modeStore.getMode(modeId);
6823
+ const switchPaths = resolveWstackPaths2({
6824
+ projectRoot: resolved,
6825
+ globalRoot: ctx.wpaths.globalRoot
6826
+ });
5833
6827
  const switchBuilder = new DefaultSystemPromptBuilder2({
5834
6828
  memoryStore: ctx.memoryStore,
5835
6829
  skillLoader: ctx.skillLoader,
5836
6830
  modeStore: ctx.modeStore,
5837
6831
  modeId,
5838
6832
  modePrompt: switchMode?.prompt ?? "",
5839
- modelCapabilities: ctx.modelCapabilities
6833
+ modelCapabilities: ctx.modelCapabilities,
6834
+ instructionPaths: {
6835
+ globalDir: switchPaths.globalInstructions,
6836
+ projectDir: switchPaths.inProjectInstructions
6837
+ }
5840
6838
  });
5841
6839
  ctx.context.systemPrompt = await switchBuilder.build({
5842
6840
  cwd: resolved,
@@ -5847,13 +6845,13 @@ function createProjectHandlers(ctx) {
5847
6845
  });
5848
6846
  } catch {
5849
6847
  }
5850
- const newSessionsDir = path11.join(
5851
- path11.dirname(ctx.globalConfigPath),
6848
+ const newSessionsDir = path12.join(
6849
+ path12.dirname(ctx.globalConfigPath),
5852
6850
  "projects",
5853
6851
  switchSlug,
5854
6852
  "sessions"
5855
6853
  );
5856
- await fs9.mkdir(newSessionsDir, { recursive: true });
6854
+ await fs10.mkdir(newSessionsDir, { recursive: true });
5857
6855
  const newSessionStore = new DefaultSessionStore({ dir: newSessionsDir });
5858
6856
  const oldSession = ctx.getSession();
5859
6857
  const oldSessionId = oldSession.id;
@@ -5887,7 +6885,7 @@ function createProjectHandlers(ctx) {
5887
6885
  sessionId: newSession.id,
5888
6886
  projectSlug: switchSlug,
5889
6887
  projectRoot: resolved,
5890
- projectName: path11.basename(resolved),
6888
+ projectName: path12.basename(resolved),
5891
6889
  workingDir: resolved,
5892
6890
  clientType: "webui",
5893
6891
  pid: process.pid,
@@ -5899,8 +6897,8 @@ function createProjectHandlers(ctx) {
5899
6897
  type: "projects.selected",
5900
6898
  payload: {
5901
6899
  root: resolved,
5902
- name: selName || path11.basename(resolved),
5903
- message: `Switched to ${selName || path11.basename(resolved)}`
6900
+ name: selName || path12.basename(resolved),
6901
+ message: `Switched to ${selName || path12.basename(resolved)}`
5904
6902
  }
5905
6903
  });
5906
6904
  broadcast(ctx.clients, {
@@ -5920,7 +6918,7 @@ function createProjectHandlers(ctx) {
5920
6918
  type: "projects.selected",
5921
6919
  payload: {
5922
6920
  root: selRoot,
5923
- name: selName || path11.basename(selRoot),
6921
+ name: selName || path12.basename(selRoot),
5924
6922
  message: errMessage(err)
5925
6923
  }
5926
6924
  });
@@ -5951,7 +6949,7 @@ function createProjectHandlers(ctx) {
5951
6949
  }
5952
6950
 
5953
6951
  // src/server/session-handlers.ts
5954
- import * as path12 from "path";
6952
+ import * as path13 from "path";
5955
6953
  import {
5956
6954
  DEFAULT_CONTEXT_WINDOW_MODE_ID,
5957
6955
  repairToolUseAdjacency,
@@ -6293,7 +7291,7 @@ function createSessionHandlers(ctx) {
6293
7291
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
6294
7292
  const projectRoot = ctx.getProjectRoot();
6295
7293
  const rewinder = new DefaultSessionRewinder(
6296
- path12.join(projectRoot, ".wrongstack", "sessions"),
7294
+ path13.join(projectRoot, ".wrongstack", "sessions"),
6297
7295
  projectRoot
6298
7296
  );
6299
7297
  const checkpoints = await rewinder.listCheckpoints(ctx.getSession().id);
@@ -6308,7 +7306,7 @@ function createSessionHandlers(ctx) {
6308
7306
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
6309
7307
  const projectRoot = ctx.getProjectRoot();
6310
7308
  const rewinder = new DefaultSessionRewinder(
6311
- path12.join(projectRoot, ".wrongstack", "sessions"),
7309
+ path13.join(projectRoot, ".wrongstack", "sessions"),
6312
7310
  projectRoot
6313
7311
  );
6314
7312
  await rewinder.rewindToCheckpoint(ctx.getSession().id, checkpointIndex);
@@ -6684,9 +7682,9 @@ async function handleSddBoardRoute(_ws, msg, handlers) {
6684
7682
  }
6685
7683
 
6686
7684
  // src/server/setup-events.ts
6687
- import * as fs10 from "fs/promises";
7685
+ import * as fs11 from "fs/promises";
6688
7686
  import { watch as fsWatch } from "fs";
6689
- import * as path13 from "path";
7687
+ import * as path14 from "path";
6690
7688
  function setupEvents(deps2) {
6691
7689
  const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge, wpaths, watcherMetrics, onFleetBroadcaster } = deps2;
6692
7690
  const disposers = [];
@@ -6775,6 +7773,22 @@ function setupEvents(deps2) {
6775
7773
  }).catch(() => {
6776
7774
  });
6777
7775
  broadcast2(clients, { type: "todos.updated", payload: { todos: [...context.todos] } });
7776
+ const sideEffects = context.sideEffects ?? [];
7777
+ if (sideEffects.length > 0) {
7778
+ broadcast2(clients, {
7779
+ type: "side_effects",
7780
+ payload: {
7781
+ sideEffects: sideEffects.slice(-50).map((se) => ({
7782
+ toolUseId: se.toolUseId,
7783
+ toolName: se.toolName,
7784
+ ts: se.ts,
7785
+ input: se.input,
7786
+ outcome: se.outcome,
7787
+ risk: se.risk
7788
+ }))
7789
+ }
7790
+ });
7791
+ }
6778
7792
  if (e.name === "task" || e.name === "plan" || e.name === "todo") {
6779
7793
  void (async () => {
6780
7794
  try {
@@ -7153,16 +8167,16 @@ function setupEvents(deps2) {
7153
8167
  if (wpaths?.projectStatus) {
7154
8168
  try {
7155
8169
  const statusFile = wpaths.projectStatus(e.projectHash);
7156
- const dir = path13.dirname(statusFile);
7157
- await fs10.mkdir(dir, { recursive: true });
7158
- await fs10.writeFile(statusFile, JSON.stringify(e, null, 2), "utf-8");
8170
+ const dir = path14.dirname(statusFile);
8171
+ await fs11.mkdir(dir, { recursive: true });
8172
+ await fs11.writeFile(statusFile, JSON.stringify(e, null, 2), "utf-8");
7159
8173
  } catch (err) {
7160
8174
  console.error("[setup-events] Failed to write status.json:", err);
7161
8175
  }
7162
8176
  }
7163
8177
  });
7164
8178
  if (wpaths?.projectStatus && wpaths.configDir) {
7165
- const projectsDir = path13.join(wpaths.configDir, "projects");
8179
+ const projectsDir = path14.join(wpaths.configDir, "projects");
7166
8180
  const knownProjectHashes = /* @__PURE__ */ new Set();
7167
8181
  const debounceTimers = /* @__PURE__ */ new Map();
7168
8182
  const DEBOUNCE_MS = 150;
@@ -7225,20 +8239,20 @@ function setupEvents(deps2) {
7225
8239
  let watcher;
7226
8240
  const startWatcher = async () => {
7227
8241
  try {
7228
- await fs10.mkdir(projectsDir, { recursive: true });
8242
+ await fs11.mkdir(projectsDir, { recursive: true });
7229
8243
  watcher = fsWatch(projectsDir, { persistent: true, recursive: true }, async (eventType, filename) => {
7230
8244
  if (eventType === "change") {
7231
8245
  if (filename == null) return;
7232
8246
  if (watcherMetrics) watcherMetrics.fileChangesDetected++;
7233
- const targetFile = path13.join(projectsDir, String(filename));
8247
+ const targetFile = path14.join(projectsDir, String(filename));
7234
8248
  if (targetFile.endsWith("status.json")) {
7235
- const projectHash2 = path13.basename(path13.dirname(targetFile));
8249
+ const projectHash2 = path14.basename(path14.dirname(targetFile));
7236
8250
  if (knownProjectHashes.size > 0 && !knownProjectHashes.has(projectHash2)) {
7237
8251
  return;
7238
8252
  }
7239
8253
  if (watcherMetrics) watcherMetrics.filesProcessed++;
7240
8254
  try {
7241
- const content = await fs10.readFile(targetFile, "utf-8");
8255
+ const content = await fs11.readFile(targetFile, "utf-8");
7242
8256
  const statusData = JSON.parse(content);
7243
8257
  if (statusData.projectHash) {
7244
8258
  const hash = String(statusData.projectHash);
@@ -7290,7 +8304,7 @@ function setupEvents(deps2) {
7290
8304
  }
7291
8305
  });
7292
8306
  }
7293
- const globalRoot = globalConfigPath ? path13.dirname(globalConfigPath) : void 0;
8307
+ const globalRoot = globalConfigPath ? path14.dirname(globalConfigPath) : void 0;
7294
8308
  if (globalRoot) {
7295
8309
  const broadcastSessions = async () => {
7296
8310
  try {
@@ -7363,11 +8377,11 @@ function setupEvents(deps2) {
7363
8377
 
7364
8378
  // src/server/custom-context-modes.ts
7365
8379
  import { listContextWindowModes, atomicWrite as atomicWrite5 } from "@wrongstack/core";
7366
- import * as fs11 from "fs/promises";
7367
- import * as path14 from "path";
8380
+ import * as fs12 from "fs/promises";
8381
+ import * as path15 from "path";
7368
8382
  var STORE_FILENAME = "custom-context-modes.json";
7369
8383
  function storePath(wrongstackDir) {
7370
- return path14.join(wrongstackDir, STORE_FILENAME);
8384
+ return path15.join(wrongstackDir, STORE_FILENAME);
7371
8385
  }
7372
8386
  var BUILTIN_IDS = /* @__PURE__ */ new Set(["balanced", "frugal", "deep", "archival"]);
7373
8387
  function createCustomModeStore(wrongstackDir) {
@@ -7375,7 +8389,7 @@ function createCustomModeStore(wrongstackDir) {
7375
8389
  const load2 = async () => {
7376
8390
  modes.clear();
7377
8391
  try {
7378
- const raw = await fs11.readFile(storePath(wrongstackDir), "utf8");
8392
+ const raw = await fs12.readFile(storePath(wrongstackDir), "utf8");
7379
8393
  const parsed = JSON.parse(raw);
7380
8394
  if (Array.isArray(parsed.modes)) {
7381
8395
  for (const m of parsed.modes) {
@@ -7421,7 +8435,8 @@ function createCustomModeStore(wrongstackDir) {
7421
8435
  custom: true
7422
8436
  };
7423
8437
  modes.set(mode.id, entry);
7424
- void save2();
8438
+ void save2().catch(() => {
8439
+ });
7425
8440
  return { ok: true };
7426
8441
  };
7427
8442
  const update = (id, patch) => {
@@ -7447,7 +8462,8 @@ function createCustomModeStore(wrongstackDir) {
7447
8462
  if (patch.targetLoad !== void 0) next.targetLoad = patch.targetLoad;
7448
8463
  if (patch.aggressiveOn !== void 0) next.aggressiveOn = patch.aggressiveOn;
7449
8464
  modes.set(id, next);
7450
- void save2();
8465
+ void save2().catch(() => {
8466
+ });
7451
8467
  return { ok: true };
7452
8468
  };
7453
8469
  const remove = (id) => {
@@ -7457,7 +8473,8 @@ function createCustomModeStore(wrongstackDir) {
7457
8473
  if (!modes.delete(id)) {
7458
8474
  return { ok: false, error: `Mode "${id}" not found` };
7459
8475
  }
7460
- void save2();
8476
+ void save2().catch(() => {
8477
+ });
7461
8478
  return { ok: true };
7462
8479
  };
7463
8480
  const list = () => {
@@ -7498,14 +8515,17 @@ function createEternalSubscription(subscribe, broadcast2, clientsRef) {
7498
8515
  }
7499
8516
 
7500
8517
  // src/server/shell-open.ts
7501
- import * as fs12 from "fs/promises";
7502
- import * as path15 from "path";
8518
+ import * as fs13 from "fs/promises";
8519
+ import * as path16 from "path";
7503
8520
  import { spawn as spawn2 } from "child_process";
7504
- var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
8521
+ var METACHAR_REGEX = /[&|<>^"'`'\n\r]/;
8522
+ function shellQuote(s) {
8523
+ return s.replaceAll("'", `'"'"'`);
8524
+ }
7505
8525
  async function handleShellOpen(req, logger) {
7506
8526
  try {
7507
- const resolved = path15.resolve(req.path);
7508
- await fs12.access(resolved);
8527
+ const resolved = path16.resolve(req.path);
8528
+ await fs13.access(resolved);
7509
8529
  if (METACHAR_REGEX.test(resolved)) {
7510
8530
  return { success: false, message: "Path contains unsupported characters." };
7511
8531
  }
@@ -7538,7 +8558,11 @@ async function handleShellOpen(req, logger) {
7538
8558
  () => launch(
7539
8559
  "gnome-terminal",
7540
8560
  [`--working-directory=${resolved}`],
7541
- () => launch("xterm", ["-e", `cd '${resolved}' && ${process.env["SHELL"] ?? "sh"}`])
8561
+ () => (
8562
+ // Pass argv array so sh -c sees a literal string, not an interpolated one.
8563
+ // shellQuote() guards against paths that somehow slipped the METACHAR_REGEX.
8564
+ launch("xterm", ["-e", "sh", "-c", `cd ${shellQuote(resolved)} && ${process.env["SHELL"] ?? "sh"}`])
8565
+ )
7542
8566
  )
7543
8567
  );
7544
8568
  }
@@ -7557,9 +8581,9 @@ async function handleGitInfo(ws, projectRoot) {
7557
8581
  const cwd = projectRoot || void 0;
7558
8582
  try {
7559
8583
  const { execFile: ef } = await import("child_process");
7560
- const git = (args) => new Promise((resolve9) => {
8584
+ const git = (args) => new Promise((resolve10) => {
7561
8585
  ef("git", args, { cwd, timeout: 3e3 }, (err, stdout) => {
7562
- resolve9(err ? "" : stdout.trim());
8586
+ resolve10(err ? "" : stdout.trim());
7563
8587
  });
7564
8588
  });
7565
8589
  const [branchRaw, diffRaw, statusRaw, upstreamRaw] = await Promise.all([
@@ -7585,12 +8609,12 @@ async function handleGitInfo(ws, projectRoot) {
7585
8609
  function makeGit(cwd) {
7586
8610
  return async (args) => {
7587
8611
  const { execFile: ef } = await import("child_process");
7588
- return new Promise((resolve9) => {
8612
+ return new Promise((resolve10) => {
7589
8613
  ef(
7590
8614
  "git",
7591
8615
  args,
7592
8616
  { cwd, timeout: 5e3, maxBuffer: 1024 * 1024 * 16 },
7593
- (err, stdout) => resolve9(err ? "" : stdout)
8617
+ (err, stdout) => resolve10(err ? "" : stdout)
7594
8618
  );
7595
8619
  });
7596
8620
  };
@@ -7614,15 +8638,15 @@ async function handleGitChanges(ws, projectRoot) {
7614
8638
  if (!m) continue;
7615
8639
  const added = m[1] === "-" ? 0 : Number(m[1]);
7616
8640
  const deleted = m[2] === "-" ? 0 : Number(m[2]);
7617
- let path17 = m[3] ?? "";
7618
- if (path17 === "") {
8641
+ let path18 = m[3] ?? "";
8642
+ if (path18 === "") {
7619
8643
  i += 1;
7620
- path17 = parts[i + 1] ?? parts[i] ?? "";
8644
+ path18 = parts[i + 1] ?? parts[i] ?? "";
7621
8645
  i += 1;
7622
8646
  }
7623
- if (!path17) continue;
7624
- const prev = counts.get(path17) ?? { added: 0, deleted: 0 };
7625
- counts.set(path17, { added: prev.added + added, deleted: prev.deleted + deleted });
8647
+ if (!path18) continue;
8648
+ const prev = counts.get(path18) ?? { added: 0, deleted: 0 };
8649
+ counts.set(path18, { added: prev.added + added, deleted: prev.deleted + deleted });
7626
8650
  }
7627
8651
  };
7628
8652
  parseNumstat(unstagedNumstat);
@@ -7634,7 +8658,7 @@ async function handleGitChanges(ws, projectRoot) {
7634
8658
  if (!rec || rec.length < 3) continue;
7635
8659
  const x = rec[0] ?? " ";
7636
8660
  const y = rec[1] ?? " ";
7637
- const path17 = rec.slice(3);
8661
+ const path18 = rec.slice(3);
7638
8662
  const isRename = x === "R" || x === "C" || y === "R" || y === "C";
7639
8663
  if (isRename) i += 1;
7640
8664
  let status;
@@ -7646,13 +8670,13 @@ async function handleGitChanges(ws, projectRoot) {
7646
8670
  else if (x === "D" || y === "D") status = "D";
7647
8671
  else status = "M";
7648
8672
  const staged = x !== " " && x !== "?";
7649
- let added = counts.get(path17)?.added ?? 0;
7650
- let deleted = counts.get(path17)?.deleted ?? 0;
8673
+ let added = counts.get(path18)?.added ?? 0;
8674
+ let deleted = counts.get(path18)?.deleted ?? 0;
7651
8675
  if (status === "?") {
7652
8676
  added = 0;
7653
8677
  deleted = 0;
7654
8678
  }
7655
- files.push({ path: path17, status, added, deleted, staged });
8679
+ files.push({ path: path18, status, added, deleted, staged });
7656
8680
  }
7657
8681
  send(ws, { type: "git.changes", payload: { files } });
7658
8682
  } catch (err) {
@@ -7663,21 +8687,21 @@ async function handleGitChanges(ws, projectRoot) {
7663
8687
  }
7664
8688
  }
7665
8689
  var MAX_DIFF_BYTES = 2 * 1024 * 1024;
7666
- async function handleGitDiff(ws, projectRoot, path17) {
8690
+ async function handleGitDiff(ws, projectRoot, path18) {
7667
8691
  const cwd = projectRoot || void 0;
7668
- const reply = (extra) => send(ws, { type: "git.diff", payload: { path: path17, ...extra } });
7669
- if (!path17 || path17.includes("\0") || path17.includes("..") || nodePath.isAbsolute(path17)) {
8692
+ const reply = (extra) => send(ws, { type: "git.diff", payload: { path: path18, ...extra } });
8693
+ if (!path18 || path18.includes("\0") || path18.includes("..") || nodePath.isAbsolute(path18)) {
7670
8694
  reply({ oldText: "", newText: "", error: "invalid path" });
7671
8695
  return;
7672
8696
  }
7673
8697
  try {
7674
8698
  const git = makeGit(cwd);
7675
8699
  const { readFile: readFile9 } = await import("fs/promises");
7676
- const { join: join12 } = await import("path");
7677
- const oldText = await git(["show", `HEAD:${path17}`]);
8700
+ const { join: join14 } = await import("path");
8701
+ const oldText = await git(["show", `HEAD:${path18}`]);
7678
8702
  let newText = "";
7679
8703
  try {
7680
- const abs = cwd ? join12(cwd, path17) : path17;
8704
+ const abs = cwd ? join14(cwd, path18) : path18;
7681
8705
  const buf = await readFile9(abs);
7682
8706
  if (buf.includes(0)) {
7683
8707
  reply({ oldText: "", newText: "", binary: true });
@@ -7758,10 +8782,10 @@ async function handleProcessKillAll(ws) {
7758
8782
  }
7759
8783
 
7760
8784
  // src/server/goal-handlers.ts
7761
- import { resolveWstackPaths } from "@wrongstack/core/utils";
8785
+ import { resolveWstackPaths as resolveWstackPaths3 } from "@wrongstack/core/utils";
7762
8786
  async function handleGoalGet(projectRoot, broadcast2) {
7763
8787
  try {
7764
- const goalPath = resolveWstackPaths({ projectRoot }).projectGoal;
8788
+ const goalPath = resolveWstackPaths3({ projectRoot }).projectGoal;
7765
8789
  const { readFile: readFile9 } = await import("fs/promises");
7766
8790
  const raw = await readFile9(goalPath, "utf8");
7767
8791
  const goal = JSON.parse(raw);
@@ -7775,8 +8799,11 @@ async function handleGoalGet(projectRoot, broadcast2) {
7775
8799
  async function startWebUI(opts = {}) {
7776
8800
  ensureSessionShell();
7777
8801
  const requestedWsPort = opts.wsPort ?? 3457;
7778
- const wsHost = opts.wsHost ?? "127.0.0.1";
7779
- const requestedHttpPort = Number.parseInt(process.env["PORT"] ?? "3456", 10);
8802
+ const wsHost = opts.wsHost ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
8803
+ const requestedHttpPort = opts.httpPort ?? opts.webuiPort ?? opts.port ?? Number.parseInt(process.env["WEBUI_PORT"] ?? process.env["PORT"] ?? "3456", 10);
8804
+ const publicUrl = opts.publicUrl ?? process.env["WEBUI_PUBLIC_URL"];
8805
+ const publicWsUrl = opts.publicWsUrl ?? process.env["WEBUI_PUBLIC_WS_URL"];
8806
+ const requireToken = opts.requireToken ?? envFlag("WEBUI_REQUIRE_TOKEN");
7780
8807
  const strictPort = process.env["WEBUI_STRICT_PORT"] === "1" || process.env["WEBUI_STRICT_PORT"] === "true";
7781
8808
  let wsPort = requestedWsPort;
7782
8809
  let httpPort = requestedHttpPort;
@@ -7816,7 +8843,7 @@ async function startWebUI(opts = {}) {
7816
8843
  const write = async () => {
7817
8844
  let raw;
7818
8845
  try {
7819
- raw = await fs13.readFile(globalConfigPath, "utf8");
8846
+ raw = await fs14.readFile(globalConfigPath, "utf8");
7820
8847
  } catch {
7821
8848
  raw = "{}";
7822
8849
  }
@@ -7890,6 +8917,7 @@ async function startWebUI(opts = {}) {
7890
8917
  toolRegistry.register(makeMailSendTool({ projectDir: wpaths.projectDir, events }));
7891
8918
  toolRegistry.register(makeMailInboxTool({ projectDir: wpaths.projectDir, events }));
7892
8919
  applyToolDescriptionModes(toolRegistry, config.tools?.descriptionMode);
8920
+ applyToolResultRenderModes(toolRegistry, config.tools?.resultRenderMode);
7893
8921
  configureExecPolicy(config.tools?.exec ?? {});
7894
8922
  console.log("[WebUI] Tool registry loaded:", toolRegistry.list().length, "tools");
7895
8923
  const mcpRegistry = new MCPRegistry({
@@ -7934,7 +8962,7 @@ async function startWebUI(opts = {}) {
7934
8962
  sessionId: session.id,
7935
8963
  projectSlug: wpaths.projectSlug,
7936
8964
  projectRoot,
7937
- projectName: path16.basename(projectRoot),
8965
+ projectName: path17.basename(projectRoot),
7938
8966
  workingDir,
7939
8967
  clientType: "webui",
7940
8968
  pid: process.pid,
@@ -7954,7 +8982,7 @@ async function startWebUI(opts = {}) {
7954
8982
  const hqTelemetry = createHqPublisherFromEnv({
7955
8983
  clientKind: "webui",
7956
8984
  projectRoot,
7957
- projectName: path16.basename(projectRoot),
8985
+ projectName: path17.basename(projectRoot),
7958
8986
  appConfig: config,
7959
8987
  socketFactory: (url) => new WebSocket2(url)
7960
8988
  });
@@ -7966,7 +8994,7 @@ async function startWebUI(opts = {}) {
7966
8994
  events,
7967
8995
  sessionId: session.id,
7968
8996
  projectRoot,
7969
- projectName: path16.basename(projectRoot),
8997
+ projectName: path17.basename(projectRoot),
7970
8998
  globalRoot: wpaths.globalRoot,
7971
8999
  initialAgents: statusTracker?.getAgents(),
7972
9000
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -8022,19 +9050,39 @@ async function startWebUI(opts = {}) {
8022
9050
  };
8023
9051
  const skillLoader = config.features.skills ? new DefaultSkillLoader({ paths: wpaths }) : void 0;
8024
9052
  const skillInstaller = config.features.skills ? new SkillInstaller({
8025
- manifestPath: path16.join(wstackGlobalRoot2(), "installed-skills.json"),
8026
- projectSkillsDir: path16.join(projectRoot, ".wrongstack", "skills"),
8027
- globalSkillsDir: path16.join(wstackGlobalRoot2(), "skills"),
9053
+ manifestPath: path17.join(wstackGlobalRoot3(), "installed-skills.json"),
9054
+ projectSkillsDir: path17.join(projectRoot, ".wrongstack", "skills"),
9055
+ globalSkillsDir: path17.join(wstackGlobalRoot3(), "skills"),
8028
9056
  projectHash: projectHash(projectRoot),
8029
9057
  skillLoader
8030
9058
  }) : void 0;
9059
+ const promptsEnabled = config.features.prompts !== false;
9060
+ const bundledPromptsDir = promptsEnabled ? (() => {
9061
+ try {
9062
+ const req = createRequire2(import.meta.url);
9063
+ return path17.join(
9064
+ path17.dirname(req.resolve("@wrongstack/core/package.json")),
9065
+ "data",
9066
+ "prompts"
9067
+ );
9068
+ } catch {
9069
+ return void 0;
9070
+ }
9071
+ })() : void 0;
9072
+ const promptLoader = promptsEnabled ? new DefaultPromptLoader({ paths: wpaths, bundledDir: bundledPromptsDir }) : void 0;
9073
+ const promptUsage = new PromptUsageStore(wpaths.promptUsage);
9074
+ const promptsCtx = { promptLoader, promptUsage };
8031
9075
  const systemPromptBuilder = new DefaultSystemPromptBuilder3({
8032
9076
  memoryStore,
8033
9077
  skillLoader,
8034
9078
  modeStore,
8035
9079
  modeId,
8036
9080
  modePrompt,
8037
- modelCapabilities: () => modelCapabilitiesRef.current
9081
+ modelCapabilities: () => modelCapabilitiesRef.current,
9082
+ instructionPaths: {
9083
+ globalDir: wpaths.globalInstructions,
9084
+ projectDir: wpaths.inProjectInstructions
9085
+ }
8038
9086
  });
8039
9087
  let onlineAgents = [];
8040
9088
  try {
@@ -8129,6 +9177,10 @@ async function startWebUI(opts = {}) {
8129
9177
  context.meta["enhanceLanguage"] = autonomyCfg["enhanceLanguage"] ?? "original";
8130
9178
  context.meta["nextPrediction"] = config.nextPrediction ?? false;
8131
9179
  context.meta["fallbackModels"] = config.fallbackModels ?? [];
9180
+ context.meta["fallbackProfiles"] = config.fallbackProfiles ?? {};
9181
+ context.meta["favoriteModels"] = config.favoriteModels ?? [];
9182
+ context.meta["favoriteModelsOnly"] = config.favoriteModelsOnly === true;
9183
+ context.meta["modelMatrix"] = config.modelMatrix ?? {};
8132
9184
  context.meta["fallbackAuto"] = config.fallbackAuto !== false;
8133
9185
  context.meta["featureMcp"] = config.features.mcp !== false;
8134
9186
  context.meta["featurePlugins"] = config.features.plugins !== false;
@@ -8141,6 +9193,20 @@ async function startWebUI(opts = {}) {
8141
9193
  context.meta["logLevel"] = config.log?.level ?? "info";
8142
9194
  context.meta["auditLevel"] = config.session?.auditLevel ?? "standard";
8143
9195
  context.meta["maxIterations"] = config.tools?.maxIterations ?? 500;
9196
+ context.meta["contextMode"] = config.context?.mode ?? "balanced";
9197
+ {
9198
+ const tsm = config.features?.tokenSavingMode;
9199
+ context.meta["tokenSavingTier"] = typeof tsm === "string" ? tsm : tsm ? "medium" : "off";
9200
+ }
9201
+ context.meta["maxConcurrent"] = typeof config.maxConcurrent === "number" ? config.maxConcurrent : 10;
9202
+ context.meta["titleAnimation"] = autonomyCfg["terminalTitleAnimation"] !== false;
9203
+ {
9204
+ const mr = config.modelRuntime ?? {};
9205
+ context.meta["reasoningMode"] = mr.reasoning?.mode ?? "auto";
9206
+ context.meta["reasoningEffort"] = mr.reasoning?.effort ?? "high";
9207
+ context.meta["reasoningPreserve"] = mr.reasoning?.preserve === true;
9208
+ context.meta["cacheTtl"] = mr.cache?.ttl ?? "default";
9209
+ }
8144
9210
  const hqConfig = config.hq;
8145
9211
  context.meta["hqEnabled"] = hqConfig?.enabled === true;
8146
9212
  context.meta["hqUrl"] = hqConfig?.url ?? "";
@@ -8174,6 +9240,10 @@ async function startWebUI(opts = {}) {
8174
9240
  "indexOnStart",
8175
9241
  "contextAutoCompact",
8176
9242
  "contextStrategy",
9243
+ "contextMode",
9244
+ "tokenSavingTier",
9245
+ "maxConcurrent",
9246
+ "titleAnimation",
8177
9247
  "logLevel",
8178
9248
  "auditLevel",
8179
9249
  "hqEnabled",
@@ -8189,6 +9259,10 @@ async function startWebUI(opts = {}) {
8189
9259
  "reasoningPreserve",
8190
9260
  "cacheTtl",
8191
9261
  "fallbackModels",
9262
+ "fallbackProfiles",
9263
+ "favoriteModels",
9264
+ "favoriteModelsOnly",
9265
+ "modelMatrix",
8192
9266
  "fallbackAuto"
8193
9267
  ];
8194
9268
  const prefSnapshot = () => {
@@ -8221,6 +9295,15 @@ async function startWebUI(opts = {}) {
8221
9295
  if (autonomyTouched) decrypted.autonomy = autonomyCfg;
8222
9296
  if (typeof payload["nextPrediction"] === "boolean") decrypted.nextPrediction = payload["nextPrediction"];
8223
9297
  if (Array.isArray(payload["fallbackModels"])) decrypted.fallbackModels = payload["fallbackModels"];
9298
+ if (payload["fallbackProfiles"] && typeof payload["fallbackProfiles"] === "object" && !Array.isArray(payload["fallbackProfiles"])) {
9299
+ decrypted.fallbackProfiles = payload["fallbackProfiles"];
9300
+ }
9301
+ if (Array.isArray(payload["favoriteModels"])) decrypted.favoriteModels = payload["favoriteModels"];
9302
+ if (typeof payload["favoriteModelsOnly"] === "boolean")
9303
+ decrypted.favoriteModelsOnly = payload["favoriteModelsOnly"];
9304
+ if (payload["modelMatrix"] && typeof payload["modelMatrix"] === "object" && !Array.isArray(payload["modelMatrix"])) {
9305
+ decrypted.modelMatrix = payload["modelMatrix"];
9306
+ }
8224
9307
  if (typeof payload["fallbackAuto"] === "boolean") decrypted.fallbackAuto = payload["fallbackAuto"];
8225
9308
  const FEATURE_MAP = {
8226
9309
  featureMcp: "mcp",
@@ -8236,12 +9319,26 @@ async function startWebUI(opts = {}) {
8236
9319
  decrypted.features = feats;
8237
9320
  }
8238
9321
  }
8239
- if (typeof payload["contextAutoCompact"] === "boolean" || typeof payload["contextStrategy"] === "string") {
9322
+ if (typeof payload["contextAutoCompact"] === "boolean" || typeof payload["contextStrategy"] === "string" || typeof payload["contextMode"] === "string") {
8240
9323
  const ctxCfg = decrypted.context ?? {};
8241
9324
  if (typeof payload["contextAutoCompact"] === "boolean") ctxCfg.autoCompact = payload["contextAutoCompact"];
8242
9325
  if (typeof payload["contextStrategy"] === "string") ctxCfg.strategy = payload["contextStrategy"];
9326
+ if (typeof payload["contextMode"] === "string") ctxCfg.mode = payload["contextMode"];
8243
9327
  decrypted.context = ctxCfg;
8244
9328
  }
9329
+ if (typeof payload["tokenSavingTier"] === "string") {
9330
+ const featsCfg = decrypted.features ?? {};
9331
+ featsCfg.tokenSavingMode = payload["tokenSavingTier"];
9332
+ decrypted.features = featsCfg;
9333
+ }
9334
+ if (typeof payload["maxConcurrent"] === "number") {
9335
+ decrypted.maxConcurrent = payload["maxConcurrent"];
9336
+ }
9337
+ if (typeof payload["titleAnimation"] === "boolean") {
9338
+ const autoCfg = decrypted.autonomy ?? {};
9339
+ autoCfg.terminalTitleAnimation = payload["titleAnimation"];
9340
+ decrypted.autonomy = autoCfg;
9341
+ }
8245
9342
  if (typeof payload["logLevel"] === "string") {
8246
9343
  const logCfg = decrypted.log ?? {};
8247
9344
  logCfg.level = payload["logLevel"];
@@ -8312,6 +9409,7 @@ async function startWebUI(opts = {}) {
8312
9409
  const collabInject = collabInjectMiddleware(collabBus, { logger });
8313
9410
  Object.defineProperty(collabInject, "name", { value: "collab-inject" });
8314
9411
  pipelines.toolCall.prepend(collabInject);
9412
+ installDesignStudioMiddleware({ pipelines, ctx: context });
8315
9413
  const codebaseIndexing = setupWebUICodebaseIndexing({
8316
9414
  config,
8317
9415
  context,
@@ -8407,6 +9505,17 @@ async function startWebUI(opts = {}) {
8407
9505
  perIterationOutputCapBytes: config.tools?.perIterationOutputCapBytes ?? DEFAULT_TOOLS_CONFIG.perIterationOutputCapBytes,
8408
9506
  tracer: void 0
8409
9507
  });
9508
+ const webuiLogger = container.resolve(TOKENS.Logger);
9509
+ void discoverMailboxBridgeForWebui({
9510
+ projectRoot,
9511
+ config,
9512
+ logger: webuiLogger,
9513
+ ctx: context
9514
+ }).catch((err) => {
9515
+ webuiLogger.warn("mailbox bridge discovery threw on webui boot", {
9516
+ err: err instanceof Error ? err.message : String(err)
9517
+ });
9518
+ });
8410
9519
  const agent = new Agent({
8411
9520
  container,
8412
9521
  tools: toolRegistry,
@@ -8503,7 +9612,18 @@ async function startWebUI(opts = {}) {
8503
9612
  projectRoot
8504
9613
  );
8505
9614
  const specsHandler = new SpecsWebSocketHandler(wpaths.projectSpecs, wpaths.projectTaskGraphs);
8506
- const sddBoardHandler = new SddBoardWebSocketHandler(wpaths.projectSddBoards);
9615
+ const sddBoardHandler = new SddBoardWebSocketHandler(wpaths.projectSddBoards, void 0, {
9616
+ projectRoot,
9617
+ paths: {
9618
+ projectSpecs: wpaths.projectSpecs,
9619
+ projectTaskGraphs: wpaths.projectTaskGraphs,
9620
+ projectSddSession: wpaths.projectSddSession,
9621
+ projectSddBoards: wpaths.projectSddBoards
9622
+ }
9623
+ });
9624
+ void cleanupStaleSddWorktrees3({ projectRoot, boardsDir: wpaths.projectSddBoards }).catch(
9625
+ () => void 0
9626
+ );
8507
9627
  const sddWizardHandler = new SddWizardWebSocketHandler(
8508
9628
  buildSddWizardDeps({
8509
9629
  agent,
@@ -8525,7 +9645,10 @@ async function startWebUI(opts = {}) {
8525
9645
  }
8526
9646
  })
8527
9647
  );
8528
- const worktreeHandler = new WorktreeWebSocketHandler(events, logger);
9648
+ const worktreeHandler = new WorktreeWebSocketHandler(events, logger, {
9649
+ projectRoot,
9650
+ boardsDir: wpaths.projectSddBoards
9651
+ });
8529
9652
  const terminalHandler = new TerminalWebSocketHandler(() => workingDir, logger);
8530
9653
  const collabHandler = new CollaborationWebSocketHandler(
8531
9654
  events,
@@ -8564,15 +9687,23 @@ async function startWebUI(opts = {}) {
8564
9687
  inputCost,
8565
9688
  outputCost,
8566
9689
  cacheReadCost,
8567
- projectName: path16.basename(projectRoot) || projectRoot,
9690
+ projectName: path17.basename(projectRoot) || projectRoot,
8568
9691
  projectRoot,
8569
9692
  cwd: workingDir,
8570
9693
  mode: modeId,
8571
9694
  contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID2)
8572
9695
  };
8573
9696
  }
8574
- const wsToken = generateAuthToken();
8575
- console.log("[WebUI] WS auth token generated (redacted from logs)");
9697
+ const wsToken = resolveAuthToken(opts.accessToken);
9698
+ console.log("[WebUI] WS auth token ready");
9699
+ const publicHostnames = [publicUrl, publicWsUrl].map((value) => {
9700
+ if (!value) return void 0;
9701
+ try {
9702
+ return new URL(value).hostname;
9703
+ } catch {
9704
+ return void 0;
9705
+ }
9706
+ }).filter((value) => Boolean(value));
8576
9707
  const verifyClient2 = (info) => verifyClient({
8577
9708
  origin: info.origin,
8578
9709
  url: info.req.url ?? "",
@@ -8584,7 +9715,10 @@ async function startWebUI(opts = {}) {
8584
9715
  // exposure class.
8585
9716
  cookieHeader: info.req.headers.cookie,
8586
9717
  wsHost,
8587
- expectedToken: wsToken
9718
+ expectedToken: wsToken,
9719
+ requireToken,
9720
+ allowedHostnames: publicHostnames,
9721
+ allowBrowserUrlToken: Boolean(publicWsUrl)
8588
9722
  });
8589
9723
  const WS_MAX_PAYLOAD = 8 * 1024 * 1024;
8590
9724
  const wssPrimary = new WebSocketServer({
@@ -8702,8 +9836,8 @@ async function startWebUI(opts = {}) {
8702
9836
  clients.delete(ws);
8703
9837
  if (closing) rateLimits.delete(closing.connId);
8704
9838
  if (pendingConfirms.size > 0) {
8705
- for (const [id, resolve9] of pendingConfirms) {
8706
- resolve9("no");
9839
+ for (const [id, resolve10] of pendingConfirms) {
9840
+ resolve10("no");
8707
9841
  pendingConfirms.delete(id);
8708
9842
  }
8709
9843
  }
@@ -8783,21 +9917,21 @@ async function startWebUI(opts = {}) {
8783
9917
  });
8784
9918
  }
8785
9919
  async function touchProjectEntry(root, workDir) {
8786
- const resolved = path16.resolve(root);
9920
+ const resolved = path17.resolve(root);
8787
9921
  const manifest = await loadManifest(globalConfigPath);
8788
9922
  const now = (/* @__PURE__ */ new Date()).toISOString();
8789
- const existing = manifest.projects.find((p) => path16.resolve(p.root) === resolved);
9923
+ const existing = manifest.projects.find((p) => path17.resolve(p.root) === resolved);
8790
9924
  if (existing) {
8791
9925
  existing.lastSeen = now;
8792
- if (workDir) existing.lastWorkingDir = path16.resolve(workDir);
9926
+ if (workDir) existing.lastWorkingDir = path17.resolve(workDir);
8793
9927
  } else {
8794
9928
  manifest.projects.push({
8795
- name: path16.basename(resolved),
9929
+ name: path17.basename(resolved),
8796
9930
  root: resolved,
8797
9931
  slug: generateProjectSlug(resolved),
8798
9932
  createdAt: now,
8799
9933
  lastSeen: now,
8800
- lastWorkingDir: workDir ? path16.resolve(workDir) : void 0
9934
+ lastWorkingDir: workDir ? path17.resolve(workDir) : void 0
8801
9935
  });
8802
9936
  }
8803
9937
  await saveManifest(manifest, globalConfigPath);
@@ -8842,6 +9976,8 @@ async function startWebUI(opts = {}) {
8842
9976
  if (await handleSpecsRoute(ws, msg, specsRoutes)) return;
8843
9977
  if (await handleSddBoardRoute(ws, msg, sddBoardRoutes)) return;
8844
9978
  if (await handleSddWizardRoute(ws, msg, sddWizardRoutes)) return;
9979
+ if (msg.type.startsWith("worktree.") && await worktreeHandler.handleMessage(msg))
9980
+ return;
8845
9981
  switch (msg.type) {
8846
9982
  // Collaboration messages short-circuit the user/agent flow.
8847
9983
  // They don't touch runLock, the agent loop, or the message queue —
@@ -8912,10 +10048,10 @@ async function startWebUI(opts = {}) {
8912
10048
  }
8913
10049
  case "tool.confirm_result": {
8914
10050
  const { id, decision } = msg.payload;
8915
- const resolve9 = pendingConfirms.get(id);
8916
- if (resolve9) {
10051
+ const resolve10 = pendingConfirms.get(id);
10052
+ if (resolve10) {
8917
10053
  pendingConfirms.delete(id);
8918
- resolve9(decision);
10054
+ resolve10(decision);
8919
10055
  }
8920
10056
  break;
8921
10057
  }
@@ -8999,6 +10135,48 @@ async function startWebUI(opts = {}) {
8999
10135
  case "skills.export":
9000
10136
  await handleSkillsExport(ws, { skillLoader, skillInstaller, projectRoot });
9001
10137
  break;
10138
+ // Prompt library — shared handlers (prompts-handlers.ts).
10139
+ case "prompts.list":
10140
+ await handlePromptsList(ws, promptsCtx);
10141
+ break;
10142
+ case "prompts.search":
10143
+ await handlePromptsSearch(ws, promptsCtx, msg);
10144
+ break;
10145
+ case "prompts.content":
10146
+ await handlePromptsContent(ws, promptsCtx, msg);
10147
+ break;
10148
+ case "prompts.favorite":
10149
+ await handlePromptsFavorite(ws, promptsCtx, msg);
10150
+ break;
10151
+ case "prompts.create":
10152
+ await handlePromptsCreate(ws, promptsCtx, msg);
10153
+ break;
10154
+ case "prompts.used":
10155
+ await handlePromptsUsed(ws, promptsCtx, msg);
10156
+ break;
10157
+ case "prompts.recent":
10158
+ await handlePromptsRecent(ws, promptsCtx);
10159
+ break;
10160
+ // Design Studio — shared handlers (design-handlers.ts). agentMeta is the
10161
+ // live context so design.use pins the active kit for the next turn.
10162
+ case "design.list":
10163
+ await handleDesignList(ws, { projectRoot, agentMeta: context });
10164
+ break;
10165
+ case "design.use":
10166
+ await handleDesignUse(ws, { projectRoot, agentMeta: context }, msg);
10167
+ break;
10168
+ case "design.state":
10169
+ await handleDesignState(ws, { projectRoot, agentMeta: context });
10170
+ break;
10171
+ case "design.set":
10172
+ await handleDesignSet(ws, { projectRoot, agentMeta: context }, msg);
10173
+ break;
10174
+ case "design.materialize":
10175
+ await handleDesignMaterialize(ws, { projectRoot, agentMeta: context }, msg);
10176
+ break;
10177
+ case "design.verify":
10178
+ await handleDesignVerify(ws, { projectRoot, agentMeta: context });
10179
+ break;
9002
10180
  case "diag.get": {
9003
10181
  const usage = tokenCounter.total();
9004
10182
  send(ws, {
@@ -9079,11 +10257,29 @@ async function startWebUI(opts = {}) {
9079
10257
  messages: context.messages.length,
9080
10258
  readFiles: context.readFiles.size,
9081
10259
  tools: toolRegistry.list().length,
10260
+ sideEffectCount: context.sideEffects?.length ?? 0,
9082
10261
  elapsedMs: Date.now() - sessionStartedAt
9083
10262
  }
9084
10263
  });
9085
10264
  break;
9086
10265
  }
10266
+ case "side_effects.list": {
10267
+ const sideEffects = context.sideEffects ?? [];
10268
+ send(ws, {
10269
+ type: "side_effects",
10270
+ payload: {
10271
+ sideEffects: sideEffects.slice(-50).map((se) => ({
10272
+ toolUseId: se.toolUseId,
10273
+ toolName: se.toolName,
10274
+ ts: se.ts,
10275
+ input: se.input,
10276
+ outcome: se.outcome,
10277
+ risk: se.risk
10278
+ }))
10279
+ }
10280
+ });
10281
+ break;
10282
+ }
9087
10283
  case "process.list": {
9088
10284
  await handleProcessList(ws);
9089
10285
  break;
@@ -9338,6 +10534,7 @@ async function startWebUI(opts = {}) {
9338
10534
  toolRegistry,
9339
10535
  config,
9340
10536
  projectRoot,
10537
+ globalRoot: wpaths.globalRoot,
9341
10538
  clients,
9342
10539
  setModeId: (id) => {
9343
10540
  modeId = id;
@@ -9374,6 +10571,16 @@ async function startWebUI(opts = {}) {
9374
10571
  config.features.modelsRegistry = payload["featureModelsRegistry"];
9375
10572
  if (Array.isArray(payload["fallbackModels"]))
9376
10573
  config.fallbackModels = payload["fallbackModels"];
10574
+ if (payload["fallbackProfiles"] && typeof payload["fallbackProfiles"] === "object" && !Array.isArray(payload["fallbackProfiles"])) {
10575
+ config.fallbackProfiles = payload["fallbackProfiles"];
10576
+ }
10577
+ if (Array.isArray(payload["favoriteModels"]))
10578
+ config.favoriteModels = payload["favoriteModels"];
10579
+ if (typeof payload["favoriteModelsOnly"] === "boolean")
10580
+ config.favoriteModelsOnly = payload["favoriteModelsOnly"];
10581
+ if (payload["modelMatrix"] && typeof payload["modelMatrix"] === "object" && !Array.isArray(payload["modelMatrix"])) {
10582
+ config.modelMatrix = payload["modelMatrix"];
10583
+ }
9377
10584
  if (typeof payload["fallbackAuto"] === "boolean")
9378
10585
  config.fallbackAuto = payload["fallbackAuto"];
9379
10586
  if (typeof payload["contextAutoCompact"] === "boolean") {
@@ -9425,7 +10632,7 @@ async function startWebUI(opts = {}) {
9425
10632
  sendResult2(ws, false, parsed.message);
9426
10633
  return;
9427
10634
  }
9428
- return handleMailboxMessages(ws, { projectRoot, globalRoot: path16.dirname(globalConfigPath) }, parsed.value);
10635
+ return handleMailboxMessages(ws, { projectRoot, globalRoot: path17.dirname(globalConfigPath) }, parsed.value);
9429
10636
  },
9430
10637
  agents: (ws, msg) => {
9431
10638
  const parsed = validateMailboxAgentsPayload(msg.payload);
@@ -9433,16 +10640,16 @@ async function startWebUI(opts = {}) {
9433
10640
  sendResult2(ws, false, parsed.message);
9434
10641
  return;
9435
10642
  }
9436
- return handleMailboxAgents(ws, { projectRoot, globalRoot: path16.dirname(globalConfigPath) }, parsed.value);
10643
+ return handleMailboxAgents(ws, { projectRoot, globalRoot: path17.dirname(globalConfigPath) }, parsed.value);
9437
10644
  },
9438
- clear: (ws) => handleMailboxClear(ws, { projectRoot, globalRoot: path16.dirname(globalConfigPath) }),
10645
+ clear: (ws) => handleMailboxClear(ws, { projectRoot, globalRoot: path17.dirname(globalConfigPath) }),
9439
10646
  purge: (ws, msg) => {
9440
10647
  const parsed = validateMailboxPurgePayload(msg.payload);
9441
10648
  if (!parsed.ok) {
9442
10649
  sendResult2(ws, false, parsed.message);
9443
10650
  return;
9444
10651
  }
9445
- return handleMailboxPurge(ws, { projectRoot, globalRoot: path16.dirname(globalConfigPath) }, parsed.value);
10652
+ return handleMailboxPurge(ws, { projectRoot, globalRoot: path17.dirname(globalConfigPath) }, parsed.value);
9446
10653
  }
9447
10654
  };
9448
10655
  mcpRoutes = {
@@ -9522,18 +10729,25 @@ async function startWebUI(opts = {}) {
9522
10729
  };
9523
10730
  const httpServer = createHttpServer({
9524
10731
  host: wsHost,
9525
- distDir: path16.resolve(import.meta.dirname, "../../dist"),
10732
+ distDir: path17.resolve(import.meta.dirname, "../../dist"),
9526
10733
  wsPort,
10734
+ publicWsUrl,
9527
10735
  globalRoot: wpaths.globalRoot,
9528
10736
  apiToken: wsToken,
10737
+ requireToken,
9529
10738
  watcherMetrics,
9530
10739
  onFleetPing: () => {
9531
10740
  void fleetBroadcast?.();
9532
10741
  }
9533
10742
  });
9534
- const registryBaseDir = path16.dirname(globalConfigPath);
10743
+ const registryBaseDir = path17.dirname(globalConfigPath);
9535
10744
  httpServer.listen(httpPort, wsHost, () => {
9536
- const openUrl = `http://${wsHost}:${httpPort}`;
10745
+ const openUrl = buildWebUIAccessUrl({
10746
+ host: wsHost,
10747
+ port: httpPort,
10748
+ token: wsToken,
10749
+ publicUrl
10750
+ });
9537
10751
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
9538
10752
  if (opts.open) openBrowser(openUrl);
9539
10753
  void registerInstance(
@@ -9543,9 +10757,9 @@ async function startWebUI(opts = {}) {
9543
10757
  wsPort,
9544
10758
  host: wsHost,
9545
10759
  projectRoot,
9546
- projectName: path16.basename(projectRoot) || projectRoot,
10760
+ projectName: path17.basename(projectRoot) || projectRoot,
9547
10761
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9548
- url: `http://${wsHost}:${httpPort}`
10762
+ url: buildWebUIAccessUrl({ host: wsHost, port: httpPort, publicUrl })
9549
10763
  },
9550
10764
  registryBaseDir
9551
10765
  ).catch((err) => console.warn(JSON.stringify({
@@ -9595,6 +10809,7 @@ export {
9595
10809
  browserOpenCommand,
9596
10810
  buildCspHeader,
9597
10811
  buildSddWizardDeps,
10812
+ buildWebUIAccessUrl,
9598
10813
  createCustomModeStore,
9599
10814
  createEternalSubscription,
9600
10815
  createHttpServer,
@@ -9602,6 +10817,7 @@ export {
9602
10817
  createToolLspCompletionSource,
9603
10818
  defaultBaseDir,
9604
10819
  deleteKey,
10820
+ envFlag,
9605
10821
  errMessage,
9606
10822
  estimateTokens,
9607
10823
  extractToken,
@@ -9609,6 +10825,12 @@ export {
9609
10825
  formatInstances,
9610
10826
  generateAuthToken,
9611
10827
  handleCompletionRequest,
10828
+ handleDesignList,
10829
+ handleDesignMaterialize,
10830
+ handleDesignSet,
10831
+ handleDesignState,
10832
+ handleDesignUse,
10833
+ handleDesignVerify,
9612
10834
  handleFilesList,
9613
10835
  handleFilesRead,
9614
10836
  handleFilesTree,
@@ -9629,6 +10851,13 @@ export {
9629
10851
  handleMemoryForget,
9630
10852
  handleMemoryList,
9631
10853
  handleMemoryRemember,
10854
+ handlePromptsContent,
10855
+ handlePromptsCreate,
10856
+ handlePromptsFavorite,
10857
+ handlePromptsList,
10858
+ handlePromptsRecent,
10859
+ handlePromptsSearch,
10860
+ handlePromptsUsed,
9632
10861
  handleShellOpen,
9633
10862
  handleSkillsContent,
9634
10863
  handleSkillsCreate,
@@ -9637,6 +10866,7 @@ export {
9637
10866
  handleSkillsInstall,
9638
10867
  handleSkillsUninstall,
9639
10868
  handleSkillsUpdate,
10869
+ hostForBrowserUrl,
9640
10870
  hostHeaderOk,
9641
10871
  injectWsPort,
9642
10872
  isLoopbackBind,
@@ -9652,6 +10882,7 @@ export {
9652
10882
  registerInstance,
9653
10883
  registryPath,
9654
10884
  removeProvider,
10885
+ resolveAuthToken,
9655
10886
  saveProviders,
9656
10887
  send,
9657
10888
  sendResult2 as sendResult,