@wrongstack/webui 0.250.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
  }
@@ -3296,6 +3428,14 @@ async function startWebUI(opts = {}) {
3296
3428
  try {
3297
3429
  const m = await modelsRegistry.getModel(config.provider, config.model);
3298
3430
  maxContext = m?.capabilities?.maxContext ?? 0;
3431
+ if (!maxContext) {
3432
+ try {
3433
+ const provider2 = await modelsRegistry.getProvider(config.provider);
3434
+ const rawModel = provider2?.models.find((mod) => mod.id === config.model);
3435
+ maxContext = rawModel?.limit?.context ?? 0;
3436
+ } catch {
3437
+ }
3438
+ }
3299
3439
  const rates = getCostRates(m);
3300
3440
  inputCost = rates.input;
3301
3441
  outputCost = rates.output;
@@ -3310,12 +3450,11 @@ async function startWebUI(opts = {}) {
3310
3450
  inputCost,
3311
3451
  outputCost,
3312
3452
  cacheReadCost,
3313
- projectName: path8.basename(projectRoot) || projectRoot,
3453
+ projectName: path9.basename(projectRoot) || projectRoot,
3314
3454
  projectRoot,
3315
3455
  cwd: workingDir,
3316
3456
  mode: modeId,
3317
- contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID),
3318
- wsToken
3457
+ contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID)
3319
3458
  };
3320
3459
  }
3321
3460
  const wsToken = generateAuthToken();
@@ -3325,6 +3464,11 @@ async function startWebUI(opts = {}) {
3325
3464
  url: info.req.url ?? "",
3326
3465
  hostHeader: info.req.headers.host,
3327
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,
3328
3472
  wsHost,
3329
3473
  expectedToken: wsToken
3330
3474
  });
@@ -3349,6 +3493,14 @@ async function startWebUI(opts = {}) {
3349
3493
  payload: { cwd: newDir, projectRoot }
3350
3494
  });
3351
3495
  });
3496
+ let eternalSubscription = null;
3497
+ if (opts.subscribeEternalIteration) {
3498
+ eternalSubscription = createEternalSubscription(
3499
+ opts.subscribeEternalIteration,
3500
+ broadcast,
3501
+ () => clients
3502
+ );
3503
+ }
3352
3504
  const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
3353
3505
  const RATE_LIMIT_WINDOW_MS = 6e4;
3354
3506
  const rateLimits = /* @__PURE__ */ new Map();
@@ -3425,8 +3577,8 @@ async function startWebUI(opts = {}) {
3425
3577
  clients.delete(ws);
3426
3578
  rateLimits.delete(String(ws));
3427
3579
  if (pendingConfirms.size > 0) {
3428
- for (const [id, resolve5] of pendingConfirms) {
3429
- resolve5("no");
3580
+ for (const [id, resolve6] of pendingConfirms) {
3581
+ resolve6("no");
3430
3582
  pendingConfirms.delete(id);
3431
3583
  }
3432
3584
  }
@@ -3490,33 +3642,33 @@ async function startWebUI(opts = {}) {
3490
3642
  });
3491
3643
  }
3492
3644
  async function touchProjectEntry(root, workDir) {
3493
- const resolved = path8.resolve(root);
3645
+ const resolved = path9.resolve(root);
3494
3646
  const manifest = await loadManifest(globalConfigPath);
3495
3647
  const now = (/* @__PURE__ */ new Date()).toISOString();
3496
- const existing = manifest.projects.find((p) => path8.resolve(p.root) === resolved);
3648
+ const existing = manifest.projects.find((p) => path9.resolve(p.root) === resolved);
3497
3649
  if (existing) {
3498
3650
  existing.lastSeen = now;
3499
- if (workDir) existing.lastWorkingDir = path8.resolve(workDir);
3651
+ if (workDir) existing.lastWorkingDir = path9.resolve(workDir);
3500
3652
  } else {
3501
3653
  manifest.projects.push({
3502
- name: path8.basename(resolved),
3654
+ name: path9.basename(resolved),
3503
3655
  root: resolved,
3504
3656
  slug: generateProjectSlug(resolved),
3505
3657
  createdAt: now,
3506
3658
  lastSeen: now,
3507
- lastWorkingDir: workDir ? path8.resolve(workDir) : void 0
3659
+ lastWorkingDir: workDir ? path9.resolve(workDir) : void 0
3508
3660
  });
3509
3661
  }
3510
3662
  await saveManifest(manifest, globalConfigPath);
3511
3663
  await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
3512
3664
  }
3513
3665
  function projectsJsonPath(globalConfigPath2) {
3514
- const base = path8.dirname(globalConfigPath2);
3515
- return path8.join(base, "projects.json");
3666
+ const base = path9.dirname(globalConfigPath2);
3667
+ return path9.join(base, "projects.json");
3516
3668
  }
3517
3669
  async function loadManifest(globalConfigPath2) {
3518
3670
  try {
3519
- const raw = await fs6.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3671
+ const raw = await fs7.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3520
3672
  const parsed = JSON.parse(raw);
3521
3673
  return { projects: parsed.projects ?? [] };
3522
3674
  } catch {
@@ -3525,16 +3677,16 @@ async function startWebUI(opts = {}) {
3525
3677
  }
3526
3678
  async function saveManifest(manifest, globalConfigPath2) {
3527
3679
  const file = projectsJsonPath(globalConfigPath2);
3528
- await fs6.mkdir(path8.dirname(file), { recursive: true });
3529
- 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");
3530
3682
  }
3531
3683
  function generateProjectSlug(rootPath) {
3532
3684
  return projectSlug(rootPath);
3533
3685
  }
3534
3686
  async function ensureProjectDataDir(slug, globalConfigPath2) {
3535
- const base = path8.dirname(globalConfigPath2);
3536
- const dir = path8.join(base, "projects", slug);
3537
- 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 });
3538
3690
  return dir;
3539
3691
  }
3540
3692
  async function handleMessage(ws, _client, msg) {
@@ -3596,10 +3748,10 @@ async function startWebUI(opts = {}) {
3596
3748
  }
3597
3749
  case "tool.confirm_result": {
3598
3750
  const { id, decision } = msg.payload;
3599
- const resolve5 = pendingConfirms.get(id);
3600
- if (resolve5) {
3751
+ const resolve6 = pendingConfirms.get(id);
3752
+ if (resolve6) {
3601
3753
  pendingConfirms.delete(id);
3602
- resolve5(decision);
3754
+ resolve6(decision);
3603
3755
  }
3604
3756
  break;
3605
3757
  }
@@ -3873,7 +4025,7 @@ async function startWebUI(opts = {}) {
3873
4025
  updateAutoCompactionMaxContext?.(newProv);
3874
4026
  try {
3875
4027
  configWriteLock = configWriteLock.then(async () => {
3876
- const raw = await fs6.readFile(globalConfigPath, "utf8");
4028
+ const raw = await fs7.readFile(globalConfigPath, "utf8");
3877
4029
  const parsed = JSON.parse(raw);
3878
4030
  parsed.provider = newProvider;
3879
4031
  parsed.model = newModel;
@@ -4419,8 +4571,8 @@ async function startWebUI(opts = {}) {
4419
4571
  }
4420
4572
  case "goal.get": {
4421
4573
  try {
4422
- const goalPath = path8.join(projectRoot, ".wrongstack", "goal.json");
4423
- const raw = await fs6.readFile(goalPath, "utf8");
4574
+ const goalPath = path9.join(projectRoot, ".wrongstack", "goal.json");
4575
+ const raw = await fs7.readFile(goalPath, "utf8");
4424
4576
  const goal = JSON.parse(raw);
4425
4577
  broadcast(clients, { type: "goal.updated", payload: goal });
4426
4578
  } catch {
@@ -4480,7 +4632,7 @@ async function startWebUI(opts = {}) {
4480
4632
  try {
4481
4633
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4482
4634
  const rewinder = new DefaultSessionRewinder(
4483
- path8.join(projectRoot, ".wrongstack", "sessions"),
4635
+ path9.join(projectRoot, ".wrongstack", "sessions"),
4484
4636
  projectRoot
4485
4637
  );
4486
4638
  const checkpoints = await rewinder.listCheckpoints(session.id);
@@ -4501,7 +4653,7 @@ async function startWebUI(opts = {}) {
4501
4653
  try {
4502
4654
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4503
4655
  const rewinder = new DefaultSessionRewinder(
4504
- path8.join(projectRoot, ".wrongstack", "sessions"),
4656
+ path9.join(projectRoot, ".wrongstack", "sessions"),
4505
4657
  projectRoot
4506
4658
  );
4507
4659
  await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
@@ -4535,9 +4687,9 @@ async function startWebUI(opts = {}) {
4535
4687
  case "projects.add": {
4536
4688
  const { root: addRoot, name: displayName } = msg.payload;
4537
4689
  try {
4538
- const resolved = path8.resolve(addRoot);
4539
- await fs6.access(resolved);
4540
- const stat2 = await fs6.stat(resolved);
4690
+ const resolved = path9.resolve(addRoot);
4691
+ await fs7.access(resolved);
4692
+ const stat2 = await fs7.stat(resolved);
4541
4693
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4542
4694
  const manifest = await loadManifest(globalConfigPath);
4543
4695
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -4553,7 +4705,7 @@ async function startWebUI(opts = {}) {
4553
4705
  });
4554
4706
  break;
4555
4707
  }
4556
- const name = displayName?.trim() || path8.basename(resolved);
4708
+ const name = displayName?.trim() || path9.basename(resolved);
4557
4709
  const slug = generateProjectSlug(resolved);
4558
4710
  await ensureProjectDataDir(slug, globalConfigPath);
4559
4711
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -4572,7 +4724,7 @@ async function startWebUI(opts = {}) {
4572
4724
  send(ws, {
4573
4725
  type: "projects.added",
4574
4726
  payload: {
4575
- name: path8.basename(addRoot),
4727
+ name: path9.basename(addRoot),
4576
4728
  root: addRoot,
4577
4729
  slug: "",
4578
4730
  message: errMessage(err)
@@ -4584,17 +4736,17 @@ async function startWebUI(opts = {}) {
4584
4736
  case "projects.select": {
4585
4737
  const { root: selRoot, name: selName } = msg.payload;
4586
4738
  try {
4587
- const resolved = path8.resolve(selRoot);
4739
+ const resolved = path9.resolve(selRoot);
4588
4740
  try {
4589
- await fs6.access(resolved);
4590
- const stat2 = await fs6.stat(resolved);
4741
+ await fs7.access(resolved);
4742
+ const stat2 = await fs7.stat(resolved);
4591
4743
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4592
4744
  } catch (err) {
4593
4745
  send(ws, {
4594
4746
  type: "projects.selected",
4595
4747
  payload: {
4596
4748
  root: selRoot,
4597
- name: selName || path8.basename(selRoot),
4749
+ name: selName || path9.basename(selRoot),
4598
4750
  message: `Cannot switch: ${errMessage(err)}`
4599
4751
  }
4600
4752
  });
@@ -4606,7 +4758,7 @@ async function startWebUI(opts = {}) {
4606
4758
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
4607
4759
  entry.lastWorkingDir = resolved;
4608
4760
  } else {
4609
- const name = selName?.trim() || path8.basename(resolved);
4761
+ const name = selName?.trim() || path9.basename(resolved);
4610
4762
  const slug = generateProjectSlug(resolved);
4611
4763
  manifest.projects.push({
4612
4764
  name,
@@ -4627,13 +4779,33 @@ async function startWebUI(opts = {}) {
4627
4779
  workingDir = resolved;
4628
4780
  context.cwd = workingDir;
4629
4781
  context.projectRoot = projectRoot;
4630
- const newSessionsDir = path8.join(
4631
- 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),
4632
4804
  "projects",
4633
- entry?.slug ?? generateProjectSlug(resolved),
4805
+ switchSlug,
4634
4806
  "sessions"
4635
4807
  );
4636
- await fs6.mkdir(newSessionsDir, { recursive: true });
4808
+ await fs7.mkdir(newSessionsDir, { recursive: true });
4637
4809
  const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
4638
4810
  const oldSessionId = session.id;
4639
4811
  try {
@@ -4659,12 +4831,25 @@ async function startWebUI(opts = {}) {
4659
4831
  context.fileMtimes.clear();
4660
4832
  tokenCounter.reset();
4661
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
+ }
4662
4847
  send(ws, {
4663
4848
  type: "projects.selected",
4664
4849
  payload: {
4665
4850
  root: resolved,
4666
- name: selName || path8.basename(resolved),
4667
- message: `Switched to ${selName || path8.basename(resolved)}`
4851
+ name: selName || path9.basename(resolved),
4852
+ message: `Switched to ${selName || path9.basename(resolved)}`
4668
4853
  }
4669
4854
  });
4670
4855
  broadcast(clients, {
@@ -4687,7 +4872,7 @@ async function startWebUI(opts = {}) {
4687
4872
  type: "projects.selected",
4688
4873
  payload: {
4689
4874
  root: selRoot,
4690
- name: selName || path8.basename(selRoot),
4875
+ name: selName || path9.basename(selRoot),
4691
4876
  message: errMessage(err)
4692
4877
  }
4693
4878
  });
@@ -4698,14 +4883,14 @@ async function startWebUI(opts = {}) {
4698
4883
  case "working_dir.set": {
4699
4884
  const { path: newPath } = msg.payload;
4700
4885
  try {
4701
- const resolved = path8.resolve(projectRoot, newPath);
4702
- if (!resolved.startsWith(projectRoot + path8.sep) && resolved !== projectRoot) {
4886
+ const resolved = path9.resolve(projectRoot, newPath);
4887
+ if (!resolved.startsWith(projectRoot + path9.sep) && resolved !== projectRoot) {
4703
4888
  sendResult(ws, false, `Path must stay inside the project root: ${projectRoot}`);
4704
4889
  break;
4705
4890
  }
4706
4891
  try {
4707
- await fs6.access(resolved);
4708
- const stat2 = await fs6.stat(resolved);
4892
+ await fs7.access(resolved);
4893
+ const stat2 = await fs7.stat(resolved);
4709
4894
  if (!stat2.isDirectory()) throw new Error("Not a directory");
4710
4895
  } catch {
4711
4896
  sendResult(ws, false, `Directory not found or not accessible: ${resolved}`);
@@ -4725,58 +4910,30 @@ async function startWebUI(opts = {}) {
4725
4910
  }
4726
4911
  // ── Shell open — spawn terminal or file manager at a path ─────────
4727
4912
  case "shell.open": {
4728
- const { path: targetPath, target } = msg.payload;
4729
- try {
4730
- const resolved = path8.resolve(targetPath);
4731
- await fs6.access(resolved);
4732
- const { exec } = await import("child_process");
4733
- const platform = process.platform;
4734
- let cmd;
4735
- if (target === "file-manager") {
4736
- if (platform === "win32") {
4737
- cmd = `explorer "${resolved}"`;
4738
- } else if (platform === "darwin") {
4739
- cmd = `open "${resolved}"`;
4740
- } else {
4741
- cmd = `xdg-open "${resolved}"`;
4742
- }
4743
- } else {
4744
- if (platform === "win32") {
4745
- cmd = `start cmd /k cd /d "${resolved}"`;
4746
- } else if (platform === "darwin") {
4747
- cmd = `open -a Terminal "${resolved}"`;
4748
- } else {
4749
- cmd = `x-terminal-emulator --working-directory="${resolved}" 2>/dev/null || gnome-terminal --working-directory="${resolved}" 2>/dev/null || xterm -e "cd '${resolved}' && $SHELL"`;
4750
- }
4751
- }
4752
- exec(cmd, { timeout: 5e3 }, (err) => {
4753
- if (err) {
4754
- logger.warn(`shell.open failed: ${err.message}`);
4755
- }
4756
- });
4757
- sendResult(ws, true, `Opened ${target} at ${resolved}`);
4758
- } catch (err) {
4759
- sendResult(ws, false, errMessage(err));
4760
- }
4913
+ const result = await handleShellOpen(
4914
+ msg.payload,
4915
+ logger
4916
+ );
4917
+ sendResult(ws, result.success, result.message);
4761
4918
  break;
4762
4919
  }
4763
4920
  // ── Mailbox operations — project-level inter-agent messaging ────
4764
4921
  case "mailbox.messages":
4765
4922
  return handleMailboxMessages(
4766
4923
  ws,
4767
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
4924
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
4768
4925
  msg.payload
4769
4926
  );
4770
4927
  case "mailbox.agents":
4771
4928
  return handleMailboxAgents(
4772
4929
  ws,
4773
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
4930
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
4774
4931
  msg.payload
4775
4932
  );
4776
4933
  case "mailbox.clear":
4777
4934
  return handleMailboxClear(
4778
4935
  ws,
4779
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) }
4936
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) }
4780
4937
  );
4781
4938
  // ── Brain — status, autonomy ceiling, direct decision support ───
4782
4939
  case "brain.status":
@@ -4844,10 +5001,12 @@ async function startWebUI(opts = {}) {
4844
5001
  });
4845
5002
  const httpServer = createHttpServer({
4846
5003
  host: wsHost,
4847
- distDir: path8.resolve(import.meta.dirname, "../../dist"),
4848
- wsPort
5004
+ distDir: path9.resolve(import.meta.dirname, "../../dist"),
5005
+ wsPort,
5006
+ globalRoot: wpaths.globalRoot,
5007
+ apiToken: wsToken
4849
5008
  });
4850
- const registryBaseDir = path8.dirname(globalConfigPath);
5009
+ const registryBaseDir = path9.dirname(globalConfigPath);
4851
5010
  httpServer.listen(httpPort, wsHost, () => {
4852
5011
  const openUrl = `http://${wsHost}:${httpPort}`;
4853
5012
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
@@ -4859,7 +5018,7 @@ async function startWebUI(opts = {}) {
4859
5018
  wsPort,
4860
5019
  host: wsHost,
4861
5020
  projectRoot,
4862
- projectName: path8.basename(projectRoot) || projectRoot,
5021
+ projectName: path9.basename(projectRoot) || projectRoot,
4863
5022
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4864
5023
  url: `http://${wsHost}:${httpPort}`
4865
5024
  },
@@ -4886,6 +5045,10 @@ async function startWebUI(opts = {}) {
4886
5045
  // reality. Crash exits are healed by the next register()/list() prune pass.
4887
5046
  onShutdown: () => {
4888
5047
  brainMonitor.stop();
5048
+ if (eternalSubscription) {
5049
+ eternalSubscription.dispose();
5050
+ eternalSubscription = null;
5051
+ }
4889
5052
  return unregisterInstance(process.pid, registryBaseDir);
4890
5053
  }
4891
5054
  });