@wrongstack/webui 0.255.0 → 0.256.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.
@@ -16,13 +16,99 @@ import {
16
16
  createAutonomyBrain,
17
17
  createTieredBrainArbiter
18
18
  } from "@wrongstack/core";
19
- import * as fs6 from "fs/promises";
20
- import * as path8 from "path";
19
+ import * as fs7 from "fs/promises";
20
+ import * as path9 from "path";
21
21
 
22
22
  // src/server/http-server.ts
23
23
  import * as fs from "fs/promises";
24
24
  import * as http from "http";
25
25
  import * as path from "path";
26
+
27
+ // src/server/ws-auth.ts
28
+ import { Buffer as Buffer2 } from "buffer";
29
+ import { timingSafeEqual } from "crypto";
30
+ function isLoopbackHostname(hostname) {
31
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
32
+ }
33
+ function isTrustedLoopbackOrigin(origin) {
34
+ try {
35
+ const url = new URL(origin);
36
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
37
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1";
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+ function isLoopbackBind(wsHost) {
43
+ return wsHost === "127.0.0.1" || wsHost === "::1" || wsHost === "localhost";
44
+ }
45
+ function tokenMatches(provided, expected) {
46
+ if (!provided) return false;
47
+ const a = Buffer2.from(provided);
48
+ const b = Buffer2.from(expected);
49
+ if (a.length !== b.length) return false;
50
+ return timingSafeEqual(a, b);
51
+ }
52
+ function extractToken(url) {
53
+ const match = url.match(/[?&]token=([^&]+)/);
54
+ return match ? match[1] : void 0;
55
+ }
56
+ function extractTokenFromCookie(cookieHeader) {
57
+ if (!cookieHeader) return void 0;
58
+ const raw = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader;
59
+ for (const part of raw.split(";")) {
60
+ const eq = part.indexOf("=");
61
+ if (eq < 0) continue;
62
+ const name = part.slice(0, eq).trim();
63
+ if (name === "ws_token") {
64
+ try {
65
+ return decodeURIComponent(part.slice(eq + 1).trim());
66
+ } catch {
67
+ return part.slice(eq + 1).trim();
68
+ }
69
+ }
70
+ }
71
+ return void 0;
72
+ }
73
+ function hostHeaderOk(input) {
74
+ if (!isLoopbackBind(input.wsHost)) return true;
75
+ const hostHeader = (input.hostHeader ?? "").trim();
76
+ if (!hostHeader) return false;
77
+ let hostname;
78
+ try {
79
+ hostname = new URL(`http://${hostHeader}`).hostname;
80
+ } catch {
81
+ return false;
82
+ }
83
+ return isLoopbackHostname(hostname);
84
+ }
85
+ function verifyClient(input) {
86
+ const { origin, url, hostHeader, remoteAddress, cookieHeader, wsHost, expectedToken } = input;
87
+ const urlToken = extractToken(url ?? "");
88
+ const cookieToken = extractTokenFromCookie(cookieHeader);
89
+ const tokenOk = tokenMatches(urlToken, expectedToken) || tokenMatches(cookieToken, expectedToken);
90
+ if (!hostHeaderOk({ hostHeader, wsHost })) return false;
91
+ if (!origin) {
92
+ const remoteIp = remoteAddress ?? "";
93
+ const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
94
+ if (!isRemoteLoopback && wsHost === "0.0.0.0") return false;
95
+ return tokenOk || isLoopbackBind(wsHost);
96
+ }
97
+ try {
98
+ const { hostname } = new URL(origin);
99
+ if (isLoopbackHostname(hostname)) {
100
+ if (wsHost === "0.0.0.0" && !isTrustedLoopbackOrigin(origin)) {
101
+ return false;
102
+ }
103
+ return true;
104
+ }
105
+ return tokenOk;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ // src/server/http-server.ts
26
112
  var MIME_TYPES = {
27
113
  ".html": "text/html",
28
114
  ".js": "application/javascript",
@@ -54,15 +140,47 @@ function createHttpServer(opts) {
54
140
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
55
141
  const distDir = path.resolve(opts.distDir);
56
142
  const wsPort = opts.wsPort;
143
+ const requireApiToken = !isLoopbackBind(opts.host) && Boolean(opts.apiToken);
57
144
  return http.createServer(async (req, res) => {
58
145
  try {
59
146
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
147
+ if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
148
+ const provided = url.searchParams.get("token") ?? req.headers["x-ws-token"];
149
+ if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
150
+ res.writeHead(401, { "Content-Type": "text/plain" });
151
+ res.end("Unauthorized");
152
+ return;
153
+ }
154
+ res.writeHead(200, {
155
+ "Content-Type": "text/plain",
156
+ "Set-Cookie": `ws_token=${encodeURIComponent(opts.apiToken)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`,
157
+ // Belt-and-braces: tell any caches the cookie response itself
158
+ // is sensitive.
159
+ "Cache-Control": "no-store"
160
+ });
161
+ res.end("ok");
162
+ return;
163
+ }
60
164
  if (url.pathname === "/api/sessions" && req.method === "GET") {
165
+ const headerToken = req.headers["x-ws-token"];
166
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
167
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
168
+ res.writeHead(401, { "Content-Type": "application/json" });
169
+ res.end(JSON.stringify({ error: "Unauthorized" }));
170
+ return;
171
+ }
61
172
  await handleApiSessions(res, opts.globalRoot);
62
173
  return;
63
174
  }
64
175
  const agentsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/agents$/);
65
176
  if (agentsMatch && req.method === "GET") {
177
+ const headerToken = req.headers["x-ws-token"];
178
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
179
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
180
+ res.writeHead(401, { "Content-Type": "application/json" });
181
+ res.end(JSON.stringify({ error: "Unauthorized" }));
182
+ return;
183
+ }
66
184
  await handleApiSessionAgents(res, opts.globalRoot, agentsMatch[1]);
67
185
  return;
68
186
  }
@@ -1800,71 +1918,6 @@ async function handleMailboxClear(ws, deps) {
1800
1918
  }
1801
1919
  }
1802
1920
 
1803
- // src/server/ws-auth.ts
1804
- import { Buffer as Buffer2 } from "buffer";
1805
- import { timingSafeEqual } from "crypto";
1806
- function isLoopbackHostname(hostname) {
1807
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
1808
- }
1809
- function isTrustedLoopbackOrigin(origin) {
1810
- try {
1811
- const url = new URL(origin);
1812
- if (url.protocol !== "http:" && url.protocol !== "https:") return false;
1813
- return url.hostname === "localhost" || url.hostname === "127.0.0.1";
1814
- } catch {
1815
- return false;
1816
- }
1817
- }
1818
- function isLoopbackBind(wsHost) {
1819
- return wsHost === "127.0.0.1" || wsHost === "::1" || wsHost === "localhost";
1820
- }
1821
- function tokenMatches(provided, expected) {
1822
- if (!provided) return false;
1823
- const a = Buffer2.from(provided);
1824
- const b = Buffer2.from(expected);
1825
- if (a.length !== b.length) return false;
1826
- return timingSafeEqual(a, b);
1827
- }
1828
- function extractToken(url) {
1829
- const match = url.match(/[?&]token=([^&]+)/);
1830
- return match ? match[1] : void 0;
1831
- }
1832
- function hostHeaderOk(input) {
1833
- if (!isLoopbackBind(input.wsHost)) return true;
1834
- const hostHeader = (input.hostHeader ?? "").trim();
1835
- if (!hostHeader) return false;
1836
- let hostname;
1837
- try {
1838
- hostname = new URL(`http://${hostHeader}`).hostname;
1839
- } catch {
1840
- return false;
1841
- }
1842
- return isLoopbackHostname(hostname);
1843
- }
1844
- function verifyClient(input) {
1845
- const { origin, url, hostHeader, remoteAddress, wsHost, expectedToken } = input;
1846
- const tokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
1847
- if (!hostHeaderOk({ hostHeader, wsHost })) return false;
1848
- if (!origin) {
1849
- const remoteIp = remoteAddress ?? "";
1850
- const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
1851
- if (!isRemoteLoopback && wsHost === "0.0.0.0") return false;
1852
- return tokenOk || isLoopbackBind(wsHost);
1853
- }
1854
- try {
1855
- const { hostname } = new URL(origin);
1856
- if (isLoopbackHostname(hostname)) {
1857
- if (wsHost === "0.0.0.0" && !isTrustedLoopbackOrigin(origin)) {
1858
- return false;
1859
- }
1860
- return true;
1861
- }
1862
- return tokenOk;
1863
- } catch {
1864
- return false;
1865
- }
1866
- }
1867
-
1868
1921
  // src/server/lifecycle.ts
1869
1922
  function createShutdown(res) {
1870
1923
  const log = res.log ?? ((m) => console.log(m));
@@ -1982,16 +2035,16 @@ function formatInstances(instances) {
1982
2035
  // src/server/port-utils.ts
1983
2036
  import * as net from "net";
1984
2037
  function isPortFree(host, port) {
1985
- return new Promise((resolve5) => {
2038
+ return new Promise((resolve6) => {
1986
2039
  const srv = net.createServer();
1987
- srv.once("error", () => resolve5(false));
2040
+ srv.once("error", () => resolve6(false));
1988
2041
  srv.once("listening", () => {
1989
- srv.close(() => resolve5(true));
2042
+ srv.close(() => resolve6(true));
1990
2043
  });
1991
2044
  try {
1992
2045
  srv.listen(port, host);
1993
2046
  } catch {
1994
- resolve5(false);
2047
+ resolve6(false);
1995
2048
  }
1996
2049
  });
1997
2050
  }
@@ -2722,6 +2775,79 @@ function estimateContextBreakdown(input) {
2722
2775
  };
2723
2776
  }
2724
2777
 
2778
+ // src/server/eternal-iteration-broadcast.ts
2779
+ function createEternalSubscription(subscribe, broadcast2, clientsRef) {
2780
+ let disposed = false;
2781
+ const dispose = subscribe((entry) => {
2782
+ if (disposed) return;
2783
+ broadcast2(clientsRef(), {
2784
+ type: "eternal.iteration",
2785
+ payload: { entry }
2786
+ });
2787
+ });
2788
+ return {
2789
+ dispose() {
2790
+ if (disposed) return;
2791
+ disposed = true;
2792
+ dispose();
2793
+ }
2794
+ };
2795
+ }
2796
+
2797
+ // src/server/shell-open.ts
2798
+ import * as fs6 from "fs/promises";
2799
+ import * as path8 from "path";
2800
+ import { spawn as spawn2 } from "child_process";
2801
+ var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
2802
+ async function handleShellOpen(req, logger) {
2803
+ try {
2804
+ const resolved = path8.resolve(req.path);
2805
+ await fs6.access(resolved);
2806
+ if (METACHAR_REGEX.test(resolved)) {
2807
+ return { success: false, message: "Path contains unsupported characters." };
2808
+ }
2809
+ const platform = process.platform;
2810
+ const launch = (cmd, args, onError) => {
2811
+ const child = spawn2(cmd, args, {
2812
+ detached: true,
2813
+ stdio: "ignore",
2814
+ windowsHide: true
2815
+ });
2816
+ child.on("error", (err) => {
2817
+ logger.warn(`shell.open spawn failed: ${err.message}`);
2818
+ onError?.();
2819
+ });
2820
+ child.unref();
2821
+ };
2822
+ if (req.target === "file-manager") {
2823
+ if (platform === "win32") launch("explorer", [resolved]);
2824
+ else if (platform === "darwin") launch("open", [resolved]);
2825
+ else launch("xdg-open", [resolved]);
2826
+ } else if (req.target === "terminal") {
2827
+ if (platform === "win32") {
2828
+ launch("cmd", ["/c", "start", "cmd", "/k", "cd", "/d", resolved]);
2829
+ } else if (platform === "darwin") {
2830
+ launch("open", ["-a", "Terminal", resolved]);
2831
+ } else {
2832
+ launch(
2833
+ "x-terminal-emulator",
2834
+ [`--working-directory=${resolved}`],
2835
+ () => launch(
2836
+ "gnome-terminal",
2837
+ [`--working-directory=${resolved}`],
2838
+ () => launch("xterm", ["-e", `cd '${resolved}' && ${process.env["SHELL"] ?? "sh"}`])
2839
+ )
2840
+ );
2841
+ }
2842
+ } else {
2843
+ return { success: false, message: `Unknown shell.open target: ${String(req.target)}` };
2844
+ }
2845
+ return { success: true, message: `Opened ${req.target} at ${resolved}` };
2846
+ } catch (err) {
2847
+ return { success: false, message: err instanceof Error ? err.message : String(err) };
2848
+ }
2849
+ }
2850
+
2725
2851
  // src/server/index.ts
2726
2852
  async function startWebUI(opts = {}) {
2727
2853
  const requestedWsPort = opts.wsPort ?? 3457;
@@ -2756,7 +2882,8 @@ async function startWebUI(opts = {}) {
2756
2882
  }
2757
2883
  console.log("[WebUI] Starting backend services...");
2758
2884
  const boot = await bootConfig();
2759
- const { config: baseConfig, vault, globalConfigPath, wpaths, logger } = boot;
2885
+ const { config: baseConfig, globalConfigPath, wpaths, logger } = boot;
2886
+ const vault = opts.services?.vault ?? boot.vault;
2760
2887
  let config = baseConfig;
2761
2888
  let projectRoot = boot.projectRoot;
2762
2889
  let workingDir = projectRoot;
@@ -2768,12 +2895,12 @@ async function startWebUI(opts = {}) {
2768
2895
  console.log("[WebUI] No active provider \u2014 auto-selected:", firstKey);
2769
2896
  }
2770
2897
  const needsProvider = !config.provider || !config.model;
2771
- const modelsRegistry = new DefaultModelsRegistry({
2898
+ const modelsRegistry = opts.services?.modelsRegistry ?? new DefaultModelsRegistry({
2772
2899
  cacheFile: wpaths.modelsCache,
2773
2900
  ttlSeconds: 24 * 3600
2774
2901
  });
2775
2902
  const container = createDefaultContainer({ config, wpaths, logger, modelsRegistry });
2776
- const configStore = container.resolve(TOKENS2.ConfigStore);
2903
+ const configStore = opts.services?.configStore ?? container.resolve(TOKENS2.ConfigStore);
2777
2904
  const providerRegistry = new ProviderRegistry();
2778
2905
  try {
2779
2906
  const factories = await buildProviderFactoriesFromRegistry({
@@ -2790,8 +2917,11 @@ async function startWebUI(opts = {}) {
2790
2917
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2791
2918
  }));
2792
2919
  }
2793
- const toolRegistry = new ToolRegistry();
2794
- toolRegistry.registerAllOrThrow([...builtinToolsPack.tools ?? []], builtinToolsPack.name);
2920
+ const toolRegistry = opts.services?.toolRegistry ?? (() => {
2921
+ const r = new ToolRegistry();
2922
+ r.registerAllOrThrow([...builtinToolsPack.tools ?? []], builtinToolsPack.name);
2923
+ return r;
2924
+ })();
2795
2925
  const memoryStore = new DefaultMemoryStore2({ paths: wpaths });
2796
2926
  if (config.features.memory) {
2797
2927
  toolRegistry.register(rememberTool(memoryStore));
@@ -2799,16 +2929,18 @@ async function startWebUI(opts = {}) {
2799
2929
  toolRegistry.register(searchMemoryTool(memoryStore));
2800
2930
  toolRegistry.register(relatedMemoryTool(memoryStore));
2801
2931
  }
2802
- const events = new EventBus();
2932
+ const events = opts.services?.events ?? new EventBus();
2803
2933
  events.setLogger(logger);
2804
2934
  toolRegistry.register(makeMailboxTool({ projectDir: wpaths.projectDir, events }));
2805
2935
  toolRegistry.register(makeMailSendTool({ projectDir: wpaths.projectDir, events }));
2806
2936
  toolRegistry.register(makeMailInboxTool({ projectDir: wpaths.projectDir, events }));
2807
2937
  console.log("[WebUI] Tool registry loaded:", toolRegistry.list().length, "tools");
2808
- let sessionStore = new DefaultSessionStore2({ dir: wpaths.projectSessions });
2809
- sessionStore.prune(DEFAULT_SESSION_PRUNE_DAYS).then((count) => {
2810
- if (count > 0) logger.info(`Pruned ${count} old session${count === 1 ? "" : "s"}.`);
2811
- }).catch(() => void 0);
2938
+ let sessionStore = opts.services?.session ?? new DefaultSessionStore2({ dir: wpaths.projectSessions });
2939
+ if (!opts.services?.session) {
2940
+ sessionStore.prune(DEFAULT_SESSION_PRUNE_DAYS).then((count) => {
2941
+ if (count > 0) logger.info(`Pruned ${count} old session${count === 1 ? "" : "s"}.`);
2942
+ }).catch(() => void 0);
2943
+ }
2812
2944
  const sessionReader = new DefaultSessionReader({ store: sessionStore });
2813
2945
  const annotationsStore = new AnnotationsStore({ dir: wpaths.projectSessions });
2814
2946
  let session = await sessionStore.create({
@@ -2830,7 +2962,7 @@ async function startWebUI(opts = {}) {
2830
2962
  sessionId: session.id,
2831
2963
  projectSlug: wpaths.projectSlug,
2832
2964
  projectRoot,
2833
- projectName: path8.basename(projectRoot),
2965
+ projectName: path9.basename(projectRoot),
2834
2966
  workingDir,
2835
2967
  pid: process.pid,
2836
2968
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -3025,7 +3157,7 @@ async function startWebUI(opts = {}) {
3025
3157
  const write = async () => {
3026
3158
  let raw;
3027
3159
  try {
3028
- raw = await fs6.readFile(globalConfigPath, "utf8");
3160
+ raw = await fs7.readFile(globalConfigPath, "utf8");
3029
3161
  } catch {
3030
3162
  raw = "{}";
3031
3163
  }
@@ -3318,12 +3450,11 @@ async function startWebUI(opts = {}) {
3318
3450
  inputCost,
3319
3451
  outputCost,
3320
3452
  cacheReadCost,
3321
- projectName: path8.basename(projectRoot) || projectRoot,
3453
+ projectName: path9.basename(projectRoot) || projectRoot,
3322
3454
  projectRoot,
3323
3455
  cwd: workingDir,
3324
3456
  mode: modeId,
3325
- contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID),
3326
- wsToken
3457
+ contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID)
3327
3458
  };
3328
3459
  }
3329
3460
  const wsToken = generateAuthToken();
@@ -3333,6 +3464,11 @@ async function startWebUI(opts = {}) {
3333
3464
  url: info.req.url ?? "",
3334
3465
  hostHeader: info.req.headers.host,
3335
3466
  remoteAddress: info.req.socket.remoteAddress,
3467
+ // C-2 fix: accept the token via the HttpOnly cookie set by
3468
+ // `/ws-auth` (preferred) OR the URL query param (non-browser
3469
+ // fallback). The cookie path closes the C-598 query-string
3470
+ // exposure class.
3471
+ cookieHeader: info.req.headers.cookie,
3336
3472
  wsHost,
3337
3473
  expectedToken: wsToken
3338
3474
  });
@@ -3357,6 +3493,14 @@ async function startWebUI(opts = {}) {
3357
3493
  payload: { cwd: newDir, projectRoot }
3358
3494
  });
3359
3495
  });
3496
+ let eternalSubscription = null;
3497
+ if (opts.subscribeEternalIteration) {
3498
+ eternalSubscription = createEternalSubscription(
3499
+ opts.subscribeEternalIteration,
3500
+ broadcast,
3501
+ () => clients
3502
+ );
3503
+ }
3360
3504
  const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
3361
3505
  const RATE_LIMIT_WINDOW_MS = 6e4;
3362
3506
  const rateLimits = /* @__PURE__ */ new Map();
@@ -3433,8 +3577,8 @@ async function startWebUI(opts = {}) {
3433
3577
  clients.delete(ws);
3434
3578
  rateLimits.delete(String(ws));
3435
3579
  if (pendingConfirms.size > 0) {
3436
- for (const [id, resolve5] of pendingConfirms) {
3437
- resolve5("no");
3580
+ for (const [id, resolve6] of pendingConfirms) {
3581
+ resolve6("no");
3438
3582
  pendingConfirms.delete(id);
3439
3583
  }
3440
3584
  }
@@ -3498,33 +3642,33 @@ async function startWebUI(opts = {}) {
3498
3642
  });
3499
3643
  }
3500
3644
  async function touchProjectEntry(root, workDir) {
3501
- const resolved = path8.resolve(root);
3645
+ const resolved = path9.resolve(root);
3502
3646
  const manifest = await loadManifest(globalConfigPath);
3503
3647
  const now = (/* @__PURE__ */ new Date()).toISOString();
3504
- const existing = manifest.projects.find((p) => path8.resolve(p.root) === resolved);
3648
+ const existing = manifest.projects.find((p) => path9.resolve(p.root) === resolved);
3505
3649
  if (existing) {
3506
3650
  existing.lastSeen = now;
3507
- if (workDir) existing.lastWorkingDir = path8.resolve(workDir);
3651
+ if (workDir) existing.lastWorkingDir = path9.resolve(workDir);
3508
3652
  } else {
3509
3653
  manifest.projects.push({
3510
- name: path8.basename(resolved),
3654
+ name: path9.basename(resolved),
3511
3655
  root: resolved,
3512
3656
  slug: generateProjectSlug(resolved),
3513
3657
  createdAt: now,
3514
3658
  lastSeen: now,
3515
- lastWorkingDir: workDir ? path8.resolve(workDir) : void 0
3659
+ lastWorkingDir: workDir ? path9.resolve(workDir) : void 0
3516
3660
  });
3517
3661
  }
3518
3662
  await saveManifest(manifest, globalConfigPath);
3519
3663
  await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
3520
3664
  }
3521
3665
  function projectsJsonPath(globalConfigPath2) {
3522
- const base = path8.dirname(globalConfigPath2);
3523
- return path8.join(base, "projects.json");
3666
+ const base = path9.dirname(globalConfigPath2);
3667
+ return path9.join(base, "projects.json");
3524
3668
  }
3525
3669
  async function loadManifest(globalConfigPath2) {
3526
3670
  try {
3527
- const raw = await fs6.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3671
+ const raw = await fs7.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3528
3672
  const parsed = JSON.parse(raw);
3529
3673
  return { projects: parsed.projects ?? [] };
3530
3674
  } catch {
@@ -3533,16 +3677,16 @@ async function startWebUI(opts = {}) {
3533
3677
  }
3534
3678
  async function saveManifest(manifest, globalConfigPath2) {
3535
3679
  const file = projectsJsonPath(globalConfigPath2);
3536
- await fs6.mkdir(path8.dirname(file), { recursive: true });
3537
- await fs6.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
3680
+ await fs7.mkdir(path9.dirname(file), { recursive: true });
3681
+ await fs7.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
3538
3682
  }
3539
3683
  function generateProjectSlug(rootPath) {
3540
3684
  return projectSlug(rootPath);
3541
3685
  }
3542
3686
  async function ensureProjectDataDir(slug, globalConfigPath2) {
3543
- const base = path8.dirname(globalConfigPath2);
3544
- const dir = path8.join(base, "projects", slug);
3545
- await fs6.mkdir(dir, { recursive: true });
3687
+ const base = path9.dirname(globalConfigPath2);
3688
+ const dir = path9.join(base, "projects", slug);
3689
+ await fs7.mkdir(dir, { recursive: true });
3546
3690
  return dir;
3547
3691
  }
3548
3692
  async function handleMessage(ws, _client, msg) {
@@ -3604,10 +3748,10 @@ async function startWebUI(opts = {}) {
3604
3748
  }
3605
3749
  case "tool.confirm_result": {
3606
3750
  const { id, decision } = msg.payload;
3607
- const resolve5 = pendingConfirms.get(id);
3608
- if (resolve5) {
3751
+ const resolve6 = pendingConfirms.get(id);
3752
+ if (resolve6) {
3609
3753
  pendingConfirms.delete(id);
3610
- resolve5(decision);
3754
+ resolve6(decision);
3611
3755
  }
3612
3756
  break;
3613
3757
  }
@@ -3881,7 +4025,7 @@ async function startWebUI(opts = {}) {
3881
4025
  updateAutoCompactionMaxContext?.(newProv);
3882
4026
  try {
3883
4027
  configWriteLock = configWriteLock.then(async () => {
3884
- const raw = await fs6.readFile(globalConfigPath, "utf8");
4028
+ const raw = await fs7.readFile(globalConfigPath, "utf8");
3885
4029
  const parsed = JSON.parse(raw);
3886
4030
  parsed.provider = newProvider;
3887
4031
  parsed.model = newModel;
@@ -4427,8 +4571,8 @@ async function startWebUI(opts = {}) {
4427
4571
  }
4428
4572
  case "goal.get": {
4429
4573
  try {
4430
- const goalPath = path8.join(projectRoot, ".wrongstack", "goal.json");
4431
- const raw = await fs6.readFile(goalPath, "utf8");
4574
+ const goalPath = path9.join(projectRoot, ".wrongstack", "goal.json");
4575
+ const raw = await fs7.readFile(goalPath, "utf8");
4432
4576
  const goal = JSON.parse(raw);
4433
4577
  broadcast(clients, { type: "goal.updated", payload: goal });
4434
4578
  } catch {
@@ -4488,7 +4632,7 @@ async function startWebUI(opts = {}) {
4488
4632
  try {
4489
4633
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4490
4634
  const rewinder = new DefaultSessionRewinder(
4491
- path8.join(projectRoot, ".wrongstack", "sessions"),
4635
+ path9.join(projectRoot, ".wrongstack", "sessions"),
4492
4636
  projectRoot
4493
4637
  );
4494
4638
  const checkpoints = await rewinder.listCheckpoints(session.id);
@@ -4509,7 +4653,7 @@ async function startWebUI(opts = {}) {
4509
4653
  try {
4510
4654
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4511
4655
  const rewinder = new DefaultSessionRewinder(
4512
- path8.join(projectRoot, ".wrongstack", "sessions"),
4656
+ path9.join(projectRoot, ".wrongstack", "sessions"),
4513
4657
  projectRoot
4514
4658
  );
4515
4659
  await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
@@ -4543,9 +4687,9 @@ async function startWebUI(opts = {}) {
4543
4687
  case "projects.add": {
4544
4688
  const { root: addRoot, name: displayName } = msg.payload;
4545
4689
  try {
4546
- const resolved = path8.resolve(addRoot);
4547
- await fs6.access(resolved);
4548
- const stat2 = await fs6.stat(resolved);
4690
+ const resolved = path9.resolve(addRoot);
4691
+ await fs7.access(resolved);
4692
+ const stat2 = await fs7.stat(resolved);
4549
4693
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4550
4694
  const manifest = await loadManifest(globalConfigPath);
4551
4695
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -4561,7 +4705,7 @@ async function startWebUI(opts = {}) {
4561
4705
  });
4562
4706
  break;
4563
4707
  }
4564
- const name = displayName?.trim() || path8.basename(resolved);
4708
+ const name = displayName?.trim() || path9.basename(resolved);
4565
4709
  const slug = generateProjectSlug(resolved);
4566
4710
  await ensureProjectDataDir(slug, globalConfigPath);
4567
4711
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -4580,7 +4724,7 @@ async function startWebUI(opts = {}) {
4580
4724
  send(ws, {
4581
4725
  type: "projects.added",
4582
4726
  payload: {
4583
- name: path8.basename(addRoot),
4727
+ name: path9.basename(addRoot),
4584
4728
  root: addRoot,
4585
4729
  slug: "",
4586
4730
  message: errMessage(err)
@@ -4592,17 +4736,17 @@ async function startWebUI(opts = {}) {
4592
4736
  case "projects.select": {
4593
4737
  const { root: selRoot, name: selName } = msg.payload;
4594
4738
  try {
4595
- const resolved = path8.resolve(selRoot);
4739
+ const resolved = path9.resolve(selRoot);
4596
4740
  try {
4597
- await fs6.access(resolved);
4598
- const stat2 = await fs6.stat(resolved);
4741
+ await fs7.access(resolved);
4742
+ const stat2 = await fs7.stat(resolved);
4599
4743
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4600
4744
  } catch (err) {
4601
4745
  send(ws, {
4602
4746
  type: "projects.selected",
4603
4747
  payload: {
4604
4748
  root: selRoot,
4605
- name: selName || path8.basename(selRoot),
4749
+ name: selName || path9.basename(selRoot),
4606
4750
  message: `Cannot switch: ${errMessage(err)}`
4607
4751
  }
4608
4752
  });
@@ -4614,7 +4758,7 @@ async function startWebUI(opts = {}) {
4614
4758
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
4615
4759
  entry.lastWorkingDir = resolved;
4616
4760
  } else {
4617
- const name = selName?.trim() || path8.basename(resolved);
4761
+ const name = selName?.trim() || path9.basename(resolved);
4618
4762
  const slug = generateProjectSlug(resolved);
4619
4763
  manifest.projects.push({
4620
4764
  name,
@@ -4635,13 +4779,33 @@ async function startWebUI(opts = {}) {
4635
4779
  workingDir = resolved;
4636
4780
  context.cwd = workingDir;
4637
4781
  context.projectRoot = projectRoot;
4638
- const newSessionsDir = path8.join(
4639
- path8.dirname(globalConfigPath),
4782
+ const switchSlug = entry?.slug ?? generateProjectSlug(resolved);
4783
+ try {
4784
+ const switchMode = modeId === "default" ? void 0 : await modeStore.getMode(modeId);
4785
+ const switchBuilder = new DefaultSystemPromptBuilder2({
4786
+ memoryStore,
4787
+ skillLoader,
4788
+ modeStore,
4789
+ modeId,
4790
+ modePrompt: switchMode?.prompt ?? "",
4791
+ modelCapabilities
4792
+ });
4793
+ context.systemPrompt = await switchBuilder.build({
4794
+ cwd: workingDir,
4795
+ projectRoot,
4796
+ tools: toolRegistry.list(),
4797
+ provider: config.provider,
4798
+ model: config.model
4799
+ });
4800
+ } catch {
4801
+ }
4802
+ const newSessionsDir = path9.join(
4803
+ path9.dirname(globalConfigPath),
4640
4804
  "projects",
4641
- entry?.slug ?? generateProjectSlug(resolved),
4805
+ switchSlug,
4642
4806
  "sessions"
4643
4807
  );
4644
- await fs6.mkdir(newSessionsDir, { recursive: true });
4808
+ await fs7.mkdir(newSessionsDir, { recursive: true });
4645
4809
  const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
4646
4810
  const oldSessionId = session.id;
4647
4811
  try {
@@ -4667,12 +4831,25 @@ async function startWebUI(opts = {}) {
4667
4831
  context.fileMtimes.clear();
4668
4832
  tokenCounter.reset();
4669
4833
  sessionStartedAt = Date.now();
4834
+ try {
4835
+ const registry = getSessionRegistry(wpaths.globalRoot);
4836
+ await registry.register({
4837
+ sessionId: session.id,
4838
+ projectSlug: switchSlug,
4839
+ projectRoot,
4840
+ projectName: path9.basename(projectRoot),
4841
+ workingDir,
4842
+ pid: process.pid,
4843
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
4844
+ });
4845
+ } catch {
4846
+ }
4670
4847
  send(ws, {
4671
4848
  type: "projects.selected",
4672
4849
  payload: {
4673
4850
  root: resolved,
4674
- name: selName || path8.basename(resolved),
4675
- message: `Switched to ${selName || path8.basename(resolved)}`
4851
+ name: selName || path9.basename(resolved),
4852
+ message: `Switched to ${selName || path9.basename(resolved)}`
4676
4853
  }
4677
4854
  });
4678
4855
  broadcast(clients, {
@@ -4695,7 +4872,7 @@ async function startWebUI(opts = {}) {
4695
4872
  type: "projects.selected",
4696
4873
  payload: {
4697
4874
  root: selRoot,
4698
- name: selName || path8.basename(selRoot),
4875
+ name: selName || path9.basename(selRoot),
4699
4876
  message: errMessage(err)
4700
4877
  }
4701
4878
  });
@@ -4706,14 +4883,14 @@ async function startWebUI(opts = {}) {
4706
4883
  case "working_dir.set": {
4707
4884
  const { path: newPath } = msg.payload;
4708
4885
  try {
4709
- const resolved = path8.resolve(projectRoot, newPath);
4710
- if (!resolved.startsWith(projectRoot + path8.sep) && resolved !== projectRoot) {
4886
+ const resolved = path9.resolve(projectRoot, newPath);
4887
+ if (!resolved.startsWith(projectRoot + path9.sep) && resolved !== projectRoot) {
4711
4888
  sendResult(ws, false, `Path must stay inside the project root: ${projectRoot}`);
4712
4889
  break;
4713
4890
  }
4714
4891
  try {
4715
- await fs6.access(resolved);
4716
- const stat2 = await fs6.stat(resolved);
4892
+ await fs7.access(resolved);
4893
+ const stat2 = await fs7.stat(resolved);
4717
4894
  if (!stat2.isDirectory()) throw new Error("Not a directory");
4718
4895
  } catch {
4719
4896
  sendResult(ws, false, `Directory not found or not accessible: ${resolved}`);
@@ -4733,58 +4910,30 @@ async function startWebUI(opts = {}) {
4733
4910
  }
4734
4911
  // ── Shell open — spawn terminal or file manager at a path ─────────
4735
4912
  case "shell.open": {
4736
- const { path: targetPath, target } = msg.payload;
4737
- try {
4738
- const resolved = path8.resolve(targetPath);
4739
- await fs6.access(resolved);
4740
- const { exec } = await import("child_process");
4741
- const platform = process.platform;
4742
- let cmd;
4743
- if (target === "file-manager") {
4744
- if (platform === "win32") {
4745
- cmd = `explorer "${resolved}"`;
4746
- } else if (platform === "darwin") {
4747
- cmd = `open "${resolved}"`;
4748
- } else {
4749
- cmd = `xdg-open "${resolved}"`;
4750
- }
4751
- } else {
4752
- if (platform === "win32") {
4753
- cmd = `start cmd /k cd /d "${resolved}"`;
4754
- } else if (platform === "darwin") {
4755
- cmd = `open -a Terminal "${resolved}"`;
4756
- } else {
4757
- cmd = `x-terminal-emulator --working-directory="${resolved}" 2>/dev/null || gnome-terminal --working-directory="${resolved}" 2>/dev/null || xterm -e "cd '${resolved}' && $SHELL"`;
4758
- }
4759
- }
4760
- exec(cmd, { timeout: 5e3 }, (err) => {
4761
- if (err) {
4762
- logger.warn(`shell.open failed: ${err.message}`);
4763
- }
4764
- });
4765
- sendResult(ws, true, `Opened ${target} at ${resolved}`);
4766
- } catch (err) {
4767
- sendResult(ws, false, errMessage(err));
4768
- }
4913
+ const result = await handleShellOpen(
4914
+ msg.payload,
4915
+ logger
4916
+ );
4917
+ sendResult(ws, result.success, result.message);
4769
4918
  break;
4770
4919
  }
4771
4920
  // ── Mailbox operations — project-level inter-agent messaging ────
4772
4921
  case "mailbox.messages":
4773
4922
  return handleMailboxMessages(
4774
4923
  ws,
4775
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
4924
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
4776
4925
  msg.payload
4777
4926
  );
4778
4927
  case "mailbox.agents":
4779
4928
  return handleMailboxAgents(
4780
4929
  ws,
4781
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
4930
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
4782
4931
  msg.payload
4783
4932
  );
4784
4933
  case "mailbox.clear":
4785
4934
  return handleMailboxClear(
4786
4935
  ws,
4787
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) }
4936
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) }
4788
4937
  );
4789
4938
  // ── Brain — status, autonomy ceiling, direct decision support ───
4790
4939
  case "brain.status":
@@ -4852,10 +5001,12 @@ async function startWebUI(opts = {}) {
4852
5001
  });
4853
5002
  const httpServer = createHttpServer({
4854
5003
  host: wsHost,
4855
- distDir: path8.resolve(import.meta.dirname, "../../dist"),
4856
- wsPort
5004
+ distDir: path9.resolve(import.meta.dirname, "../../dist"),
5005
+ wsPort,
5006
+ globalRoot: wpaths.globalRoot,
5007
+ apiToken: wsToken
4857
5008
  });
4858
- const registryBaseDir = path8.dirname(globalConfigPath);
5009
+ const registryBaseDir = path9.dirname(globalConfigPath);
4859
5010
  httpServer.listen(httpPort, wsHost, () => {
4860
5011
  const openUrl = `http://${wsHost}:${httpPort}`;
4861
5012
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
@@ -4867,7 +5018,7 @@ async function startWebUI(opts = {}) {
4867
5018
  wsPort,
4868
5019
  host: wsHost,
4869
5020
  projectRoot,
4870
- projectName: path8.basename(projectRoot) || projectRoot,
5021
+ projectName: path9.basename(projectRoot) || projectRoot,
4871
5022
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4872
5023
  url: `http://${wsHost}:${httpPort}`
4873
5024
  },
@@ -4894,6 +5045,10 @@ async function startWebUI(opts = {}) {
4894
5045
  // reality. Crash exits are healed by the next register()/list() prune pass.
4895
5046
  onShutdown: () => {
4896
5047
  brainMonitor.stop();
5048
+ if (eternalSubscription) {
5049
+ eternalSubscription.dispose();
5050
+ eternalSubscription = null;
5051
+ }
4897
5052
  return unregisterInstance(process.pid, registryBaseDir);
4898
5053
  }
4899
5054
  });