@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.
@@ -15,13 +15,99 @@ import {
15
15
  createAutonomyBrain,
16
16
  createTieredBrainArbiter
17
17
  } from "@wrongstack/core";
18
- import * as fs6 from "fs/promises";
19
- import * as path8 from "path";
18
+ import * as fs7 from "fs/promises";
19
+ import * as path9 from "path";
20
20
 
21
21
  // src/server/http-server.ts
22
22
  import * as fs from "fs/promises";
23
23
  import * as http from "http";
24
24
  import * as path from "path";
25
+
26
+ // src/server/ws-auth.ts
27
+ import { Buffer as Buffer2 } from "buffer";
28
+ import { timingSafeEqual } from "crypto";
29
+ function isLoopbackHostname(hostname) {
30
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
31
+ }
32
+ function isTrustedLoopbackOrigin(origin) {
33
+ try {
34
+ const url = new URL(origin);
35
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
36
+ return url.hostname === "localhost" || url.hostname === "127.0.0.1";
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+ function isLoopbackBind(wsHost) {
42
+ return wsHost === "127.0.0.1" || wsHost === "::1" || wsHost === "localhost";
43
+ }
44
+ function tokenMatches(provided, expected) {
45
+ if (!provided) return false;
46
+ const a = Buffer2.from(provided);
47
+ const b = Buffer2.from(expected);
48
+ if (a.length !== b.length) return false;
49
+ return timingSafeEqual(a, b);
50
+ }
51
+ function extractToken(url) {
52
+ const match = url.match(/[?&]token=([^&]+)/);
53
+ return match ? match[1] : void 0;
54
+ }
55
+ function extractTokenFromCookie(cookieHeader) {
56
+ if (!cookieHeader) return void 0;
57
+ const raw = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader;
58
+ for (const part of raw.split(";")) {
59
+ const eq = part.indexOf("=");
60
+ if (eq < 0) continue;
61
+ const name = part.slice(0, eq).trim();
62
+ if (name === "ws_token") {
63
+ try {
64
+ return decodeURIComponent(part.slice(eq + 1).trim());
65
+ } catch {
66
+ return part.slice(eq + 1).trim();
67
+ }
68
+ }
69
+ }
70
+ return void 0;
71
+ }
72
+ function hostHeaderOk(input) {
73
+ if (!isLoopbackBind(input.wsHost)) return true;
74
+ const hostHeader = (input.hostHeader ?? "").trim();
75
+ if (!hostHeader) return false;
76
+ let hostname;
77
+ try {
78
+ hostname = new URL(`http://${hostHeader}`).hostname;
79
+ } catch {
80
+ return false;
81
+ }
82
+ return isLoopbackHostname(hostname);
83
+ }
84
+ function verifyClient(input) {
85
+ const { origin, url, hostHeader, remoteAddress, cookieHeader, wsHost, expectedToken } = input;
86
+ const urlToken = extractToken(url ?? "");
87
+ const cookieToken = extractTokenFromCookie(cookieHeader);
88
+ const tokenOk = tokenMatches(urlToken, expectedToken) || tokenMatches(cookieToken, expectedToken);
89
+ if (!hostHeaderOk({ hostHeader, wsHost })) return false;
90
+ if (!origin) {
91
+ const remoteIp = remoteAddress ?? "";
92
+ const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
93
+ if (!isRemoteLoopback && wsHost === "0.0.0.0") return false;
94
+ return tokenOk || isLoopbackBind(wsHost);
95
+ }
96
+ try {
97
+ const { hostname } = new URL(origin);
98
+ if (isLoopbackHostname(hostname)) {
99
+ if (wsHost === "0.0.0.0" && !isTrustedLoopbackOrigin(origin)) {
100
+ return false;
101
+ }
102
+ return true;
103
+ }
104
+ return tokenOk;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ // src/server/http-server.ts
25
111
  var MIME_TYPES = {
26
112
  ".html": "text/html",
27
113
  ".js": "application/javascript",
@@ -53,15 +139,47 @@ function createHttpServer(opts) {
53
139
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
54
140
  const distDir = path.resolve(opts.distDir);
55
141
  const wsPort = opts.wsPort;
142
+ const requireApiToken = !isLoopbackBind(opts.host) && Boolean(opts.apiToken);
56
143
  return http.createServer(async (req, res) => {
57
144
  try {
58
145
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
146
+ if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
147
+ const provided = url.searchParams.get("token") ?? req.headers["x-ws-token"];
148
+ if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
149
+ res.writeHead(401, { "Content-Type": "text/plain" });
150
+ res.end("Unauthorized");
151
+ return;
152
+ }
153
+ res.writeHead(200, {
154
+ "Content-Type": "text/plain",
155
+ "Set-Cookie": `ws_token=${encodeURIComponent(opts.apiToken)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`,
156
+ // Belt-and-braces: tell any caches the cookie response itself
157
+ // is sensitive.
158
+ "Cache-Control": "no-store"
159
+ });
160
+ res.end("ok");
161
+ return;
162
+ }
59
163
  if (url.pathname === "/api/sessions" && req.method === "GET") {
164
+ const headerToken = req.headers["x-ws-token"];
165
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
166
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
167
+ res.writeHead(401, { "Content-Type": "application/json" });
168
+ res.end(JSON.stringify({ error: "Unauthorized" }));
169
+ return;
170
+ }
60
171
  await handleApiSessions(res, opts.globalRoot);
61
172
  return;
62
173
  }
63
174
  const agentsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/agents$/);
64
175
  if (agentsMatch && req.method === "GET") {
176
+ const headerToken = req.headers["x-ws-token"];
177
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
178
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
179
+ res.writeHead(401, { "Content-Type": "application/json" });
180
+ res.end(JSON.stringify({ error: "Unauthorized" }));
181
+ return;
182
+ }
65
183
  await handleApiSessionAgents(res, opts.globalRoot, agentsMatch[1]);
66
184
  return;
67
185
  }
@@ -1799,71 +1917,6 @@ async function handleMailboxClear(ws, deps) {
1799
1917
  }
1800
1918
  }
1801
1919
 
1802
- // src/server/ws-auth.ts
1803
- import { Buffer as Buffer2 } from "buffer";
1804
- import { timingSafeEqual } from "crypto";
1805
- function isLoopbackHostname(hostname) {
1806
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
1807
- }
1808
- function isTrustedLoopbackOrigin(origin) {
1809
- try {
1810
- const url = new URL(origin);
1811
- if (url.protocol !== "http:" && url.protocol !== "https:") return false;
1812
- return url.hostname === "localhost" || url.hostname === "127.0.0.1";
1813
- } catch {
1814
- return false;
1815
- }
1816
- }
1817
- function isLoopbackBind(wsHost) {
1818
- return wsHost === "127.0.0.1" || wsHost === "::1" || wsHost === "localhost";
1819
- }
1820
- function tokenMatches(provided, expected) {
1821
- if (!provided) return false;
1822
- const a = Buffer2.from(provided);
1823
- const b = Buffer2.from(expected);
1824
- if (a.length !== b.length) return false;
1825
- return timingSafeEqual(a, b);
1826
- }
1827
- function extractToken(url) {
1828
- const match = url.match(/[?&]token=([^&]+)/);
1829
- return match ? match[1] : void 0;
1830
- }
1831
- function hostHeaderOk(input) {
1832
- if (!isLoopbackBind(input.wsHost)) return true;
1833
- const hostHeader = (input.hostHeader ?? "").trim();
1834
- if (!hostHeader) return false;
1835
- let hostname;
1836
- try {
1837
- hostname = new URL(`http://${hostHeader}`).hostname;
1838
- } catch {
1839
- return false;
1840
- }
1841
- return isLoopbackHostname(hostname);
1842
- }
1843
- function verifyClient(input) {
1844
- const { origin, url, hostHeader, remoteAddress, wsHost, expectedToken } = input;
1845
- const tokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
1846
- if (!hostHeaderOk({ hostHeader, wsHost })) return false;
1847
- if (!origin) {
1848
- const remoteIp = remoteAddress ?? "";
1849
- const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
1850
- if (!isRemoteLoopback && wsHost === "0.0.0.0") return false;
1851
- return tokenOk || isLoopbackBind(wsHost);
1852
- }
1853
- try {
1854
- const { hostname } = new URL(origin);
1855
- if (isLoopbackHostname(hostname)) {
1856
- if (wsHost === "0.0.0.0" && !isTrustedLoopbackOrigin(origin)) {
1857
- return false;
1858
- }
1859
- return true;
1860
- }
1861
- return tokenOk;
1862
- } catch {
1863
- return false;
1864
- }
1865
- }
1866
-
1867
1920
  // src/server/lifecycle.ts
1868
1921
  function createShutdown(res) {
1869
1922
  const log = res.log ?? ((m) => console.log(m));
@@ -1981,16 +2034,16 @@ function formatInstances(instances) {
1981
2034
  // src/server/port-utils.ts
1982
2035
  import * as net from "net";
1983
2036
  function isPortFree(host, port) {
1984
- return new Promise((resolve5) => {
2037
+ return new Promise((resolve6) => {
1985
2038
  const srv = net.createServer();
1986
- srv.once("error", () => resolve5(false));
2039
+ srv.once("error", () => resolve6(false));
1987
2040
  srv.once("listening", () => {
1988
- srv.close(() => resolve5(true));
2041
+ srv.close(() => resolve6(true));
1989
2042
  });
1990
2043
  try {
1991
2044
  srv.listen(port, host);
1992
2045
  } catch {
1993
- resolve5(false);
2046
+ resolve6(false);
1994
2047
  }
1995
2048
  });
1996
2049
  }
@@ -2729,6 +2782,79 @@ function estimateContextBreakdown(input) {
2729
2782
  };
2730
2783
  }
2731
2784
 
2785
+ // src/server/eternal-iteration-broadcast.ts
2786
+ function createEternalSubscription(subscribe, broadcast2, clientsRef) {
2787
+ let disposed = false;
2788
+ const dispose = subscribe((entry) => {
2789
+ if (disposed) return;
2790
+ broadcast2(clientsRef(), {
2791
+ type: "eternal.iteration",
2792
+ payload: { entry }
2793
+ });
2794
+ });
2795
+ return {
2796
+ dispose() {
2797
+ if (disposed) return;
2798
+ disposed = true;
2799
+ dispose();
2800
+ }
2801
+ };
2802
+ }
2803
+
2804
+ // src/server/shell-open.ts
2805
+ import * as fs6 from "fs/promises";
2806
+ import * as path8 from "path";
2807
+ import { spawn as spawn2 } from "child_process";
2808
+ var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
2809
+ async function handleShellOpen(req, logger) {
2810
+ try {
2811
+ const resolved = path8.resolve(req.path);
2812
+ await fs6.access(resolved);
2813
+ if (METACHAR_REGEX.test(resolved)) {
2814
+ return { success: false, message: "Path contains unsupported characters." };
2815
+ }
2816
+ const platform = process.platform;
2817
+ const launch = (cmd, args, onError) => {
2818
+ const child = spawn2(cmd, args, {
2819
+ detached: true,
2820
+ stdio: "ignore",
2821
+ windowsHide: true
2822
+ });
2823
+ child.on("error", (err) => {
2824
+ logger.warn(`shell.open spawn failed: ${err.message}`);
2825
+ onError?.();
2826
+ });
2827
+ child.unref();
2828
+ };
2829
+ if (req.target === "file-manager") {
2830
+ if (platform === "win32") launch("explorer", [resolved]);
2831
+ else if (platform === "darwin") launch("open", [resolved]);
2832
+ else launch("xdg-open", [resolved]);
2833
+ } else if (req.target === "terminal") {
2834
+ if (platform === "win32") {
2835
+ launch("cmd", ["/c", "start", "cmd", "/k", "cd", "/d", resolved]);
2836
+ } else if (platform === "darwin") {
2837
+ launch("open", ["-a", "Terminal", resolved]);
2838
+ } else {
2839
+ launch(
2840
+ "x-terminal-emulator",
2841
+ [`--working-directory=${resolved}`],
2842
+ () => launch(
2843
+ "gnome-terminal",
2844
+ [`--working-directory=${resolved}`],
2845
+ () => launch("xterm", ["-e", `cd '${resolved}' && ${process.env["SHELL"] ?? "sh"}`])
2846
+ )
2847
+ );
2848
+ }
2849
+ } else {
2850
+ return { success: false, message: `Unknown shell.open target: ${String(req.target)}` };
2851
+ }
2852
+ return { success: true, message: `Opened ${req.target} at ${resolved}` };
2853
+ } catch (err) {
2854
+ return { success: false, message: err instanceof Error ? err.message : String(err) };
2855
+ }
2856
+ }
2857
+
2732
2858
  // src/server/index.ts
2733
2859
  async function startWebUI(opts = {}) {
2734
2860
  const requestedWsPort = opts.wsPort ?? 3457;
@@ -2763,7 +2889,8 @@ async function startWebUI(opts = {}) {
2763
2889
  }
2764
2890
  console.log("[WebUI] Starting backend services...");
2765
2891
  const boot = await bootConfig();
2766
- const { config: baseConfig, vault, globalConfigPath, wpaths, logger } = boot;
2892
+ const { config: baseConfig, globalConfigPath, wpaths, logger } = boot;
2893
+ const vault = opts.services?.vault ?? boot.vault;
2767
2894
  let config = baseConfig;
2768
2895
  let projectRoot = boot.projectRoot;
2769
2896
  let workingDir = projectRoot;
@@ -2775,12 +2902,12 @@ async function startWebUI(opts = {}) {
2775
2902
  console.log("[WebUI] No active provider \u2014 auto-selected:", firstKey);
2776
2903
  }
2777
2904
  const needsProvider = !config.provider || !config.model;
2778
- const modelsRegistry = new DefaultModelsRegistry({
2905
+ const modelsRegistry = opts.services?.modelsRegistry ?? new DefaultModelsRegistry({
2779
2906
  cacheFile: wpaths.modelsCache,
2780
2907
  ttlSeconds: 24 * 3600
2781
2908
  });
2782
2909
  const container = createDefaultContainer({ config, wpaths, logger, modelsRegistry });
2783
- const configStore = container.resolve(TOKENS2.ConfigStore);
2910
+ const configStore = opts.services?.configStore ?? container.resolve(TOKENS2.ConfigStore);
2784
2911
  const providerRegistry = new ProviderRegistry();
2785
2912
  try {
2786
2913
  const factories = await buildProviderFactoriesFromRegistry({
@@ -2797,8 +2924,11 @@ async function startWebUI(opts = {}) {
2797
2924
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2798
2925
  }));
2799
2926
  }
2800
- const toolRegistry = new ToolRegistry();
2801
- toolRegistry.registerAllOrThrow([...builtinToolsPack.tools ?? []], builtinToolsPack.name);
2927
+ const toolRegistry = opts.services?.toolRegistry ?? (() => {
2928
+ const r = new ToolRegistry();
2929
+ r.registerAllOrThrow([...builtinToolsPack.tools ?? []], builtinToolsPack.name);
2930
+ return r;
2931
+ })();
2802
2932
  const memoryStore = new DefaultMemoryStore2({ paths: wpaths });
2803
2933
  if (config.features.memory) {
2804
2934
  toolRegistry.register(rememberTool(memoryStore));
@@ -2806,16 +2936,18 @@ async function startWebUI(opts = {}) {
2806
2936
  toolRegistry.register(searchMemoryTool(memoryStore));
2807
2937
  toolRegistry.register(relatedMemoryTool(memoryStore));
2808
2938
  }
2809
- const events = new EventBus();
2939
+ const events = opts.services?.events ?? new EventBus();
2810
2940
  events.setLogger(logger);
2811
2941
  toolRegistry.register(makeMailboxTool({ projectDir: wpaths.projectDir, events }));
2812
2942
  toolRegistry.register(makeMailSendTool({ projectDir: wpaths.projectDir, events }));
2813
2943
  toolRegistry.register(makeMailInboxTool({ projectDir: wpaths.projectDir, events }));
2814
2944
  console.log("[WebUI] Tool registry loaded:", toolRegistry.list().length, "tools");
2815
- let sessionStore = new DefaultSessionStore2({ dir: wpaths.projectSessions });
2816
- sessionStore.prune(DEFAULT_SESSION_PRUNE_DAYS).then((count) => {
2817
- if (count > 0) logger.info(`Pruned ${count} old session${count === 1 ? "" : "s"}.`);
2818
- }).catch(() => void 0);
2945
+ let sessionStore = opts.services?.session ?? new DefaultSessionStore2({ dir: wpaths.projectSessions });
2946
+ if (!opts.services?.session) {
2947
+ sessionStore.prune(DEFAULT_SESSION_PRUNE_DAYS).then((count) => {
2948
+ if (count > 0) logger.info(`Pruned ${count} old session${count === 1 ? "" : "s"}.`);
2949
+ }).catch(() => void 0);
2950
+ }
2819
2951
  const sessionReader = new DefaultSessionReader({ store: sessionStore });
2820
2952
  const annotationsStore = new AnnotationsStore({ dir: wpaths.projectSessions });
2821
2953
  let session = await sessionStore.create({
@@ -2837,7 +2969,7 @@ async function startWebUI(opts = {}) {
2837
2969
  sessionId: session.id,
2838
2970
  projectSlug: wpaths.projectSlug,
2839
2971
  projectRoot,
2840
- projectName: path8.basename(projectRoot),
2972
+ projectName: path9.basename(projectRoot),
2841
2973
  workingDir,
2842
2974
  pid: process.pid,
2843
2975
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -3032,7 +3164,7 @@ async function startWebUI(opts = {}) {
3032
3164
  const write = async () => {
3033
3165
  let raw;
3034
3166
  try {
3035
- raw = await fs6.readFile(globalConfigPath, "utf8");
3167
+ raw = await fs7.readFile(globalConfigPath, "utf8");
3036
3168
  } catch {
3037
3169
  raw = "{}";
3038
3170
  }
@@ -3303,6 +3435,14 @@ async function startWebUI(opts = {}) {
3303
3435
  try {
3304
3436
  const m = await modelsRegistry.getModel(config.provider, config.model);
3305
3437
  maxContext = m?.capabilities?.maxContext ?? 0;
3438
+ if (!maxContext) {
3439
+ try {
3440
+ const provider2 = await modelsRegistry.getProvider(config.provider);
3441
+ const rawModel = provider2?.models.find((mod) => mod.id === config.model);
3442
+ maxContext = rawModel?.limit?.context ?? 0;
3443
+ } catch {
3444
+ }
3445
+ }
3306
3446
  const rates = getCostRates(m);
3307
3447
  inputCost = rates.input;
3308
3448
  outputCost = rates.output;
@@ -3317,12 +3457,11 @@ async function startWebUI(opts = {}) {
3317
3457
  inputCost,
3318
3458
  outputCost,
3319
3459
  cacheReadCost,
3320
- projectName: path8.basename(projectRoot) || projectRoot,
3460
+ projectName: path9.basename(projectRoot) || projectRoot,
3321
3461
  projectRoot,
3322
3462
  cwd: workingDir,
3323
3463
  mode: modeId,
3324
- contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID),
3325
- wsToken
3464
+ contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID)
3326
3465
  };
3327
3466
  }
3328
3467
  const wsToken = generateAuthToken();
@@ -3332,6 +3471,11 @@ async function startWebUI(opts = {}) {
3332
3471
  url: info.req.url ?? "",
3333
3472
  hostHeader: info.req.headers.host,
3334
3473
  remoteAddress: info.req.socket.remoteAddress,
3474
+ // C-2 fix: accept the token via the HttpOnly cookie set by
3475
+ // `/ws-auth` (preferred) OR the URL query param (non-browser
3476
+ // fallback). The cookie path closes the C-598 query-string
3477
+ // exposure class.
3478
+ cookieHeader: info.req.headers.cookie,
3335
3479
  wsHost,
3336
3480
  expectedToken: wsToken
3337
3481
  });
@@ -3356,6 +3500,14 @@ async function startWebUI(opts = {}) {
3356
3500
  payload: { cwd: newDir, projectRoot }
3357
3501
  });
3358
3502
  });
3503
+ let eternalSubscription = null;
3504
+ if (opts.subscribeEternalIteration) {
3505
+ eternalSubscription = createEternalSubscription(
3506
+ opts.subscribeEternalIteration,
3507
+ broadcast,
3508
+ () => clients
3509
+ );
3510
+ }
3359
3511
  const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
3360
3512
  const RATE_LIMIT_WINDOW_MS = 6e4;
3361
3513
  const rateLimits = /* @__PURE__ */ new Map();
@@ -3432,8 +3584,8 @@ async function startWebUI(opts = {}) {
3432
3584
  clients.delete(ws);
3433
3585
  rateLimits.delete(String(ws));
3434
3586
  if (pendingConfirms.size > 0) {
3435
- for (const [id, resolve5] of pendingConfirms) {
3436
- resolve5("no");
3587
+ for (const [id, resolve6] of pendingConfirms) {
3588
+ resolve6("no");
3437
3589
  pendingConfirms.delete(id);
3438
3590
  }
3439
3591
  }
@@ -3497,33 +3649,33 @@ async function startWebUI(opts = {}) {
3497
3649
  });
3498
3650
  }
3499
3651
  async function touchProjectEntry(root, workDir) {
3500
- const resolved = path8.resolve(root);
3652
+ const resolved = path9.resolve(root);
3501
3653
  const manifest = await loadManifest(globalConfigPath);
3502
3654
  const now = (/* @__PURE__ */ new Date()).toISOString();
3503
- const existing = manifest.projects.find((p) => path8.resolve(p.root) === resolved);
3655
+ const existing = manifest.projects.find((p) => path9.resolve(p.root) === resolved);
3504
3656
  if (existing) {
3505
3657
  existing.lastSeen = now;
3506
- if (workDir) existing.lastWorkingDir = path8.resolve(workDir);
3658
+ if (workDir) existing.lastWorkingDir = path9.resolve(workDir);
3507
3659
  } else {
3508
3660
  manifest.projects.push({
3509
- name: path8.basename(resolved),
3661
+ name: path9.basename(resolved),
3510
3662
  root: resolved,
3511
3663
  slug: generateProjectSlug(resolved),
3512
3664
  createdAt: now,
3513
3665
  lastSeen: now,
3514
- lastWorkingDir: workDir ? path8.resolve(workDir) : void 0
3666
+ lastWorkingDir: workDir ? path9.resolve(workDir) : void 0
3515
3667
  });
3516
3668
  }
3517
3669
  await saveManifest(manifest, globalConfigPath);
3518
3670
  await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
3519
3671
  }
3520
3672
  function projectsJsonPath(globalConfigPath2) {
3521
- const base = path8.dirname(globalConfigPath2);
3522
- return path8.join(base, "projects.json");
3673
+ const base = path9.dirname(globalConfigPath2);
3674
+ return path9.join(base, "projects.json");
3523
3675
  }
3524
3676
  async function loadManifest(globalConfigPath2) {
3525
3677
  try {
3526
- const raw = await fs6.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3678
+ const raw = await fs7.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3527
3679
  const parsed = JSON.parse(raw);
3528
3680
  return { projects: parsed.projects ?? [] };
3529
3681
  } catch {
@@ -3532,16 +3684,16 @@ async function startWebUI(opts = {}) {
3532
3684
  }
3533
3685
  async function saveManifest(manifest, globalConfigPath2) {
3534
3686
  const file = projectsJsonPath(globalConfigPath2);
3535
- await fs6.mkdir(path8.dirname(file), { recursive: true });
3536
- await fs6.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
3687
+ await fs7.mkdir(path9.dirname(file), { recursive: true });
3688
+ await fs7.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
3537
3689
  }
3538
3690
  function generateProjectSlug(rootPath) {
3539
3691
  return projectSlug(rootPath);
3540
3692
  }
3541
3693
  async function ensureProjectDataDir(slug, globalConfigPath2) {
3542
- const base = path8.dirname(globalConfigPath2);
3543
- const dir = path8.join(base, "projects", slug);
3544
- await fs6.mkdir(dir, { recursive: true });
3694
+ const base = path9.dirname(globalConfigPath2);
3695
+ const dir = path9.join(base, "projects", slug);
3696
+ await fs7.mkdir(dir, { recursive: true });
3545
3697
  return dir;
3546
3698
  }
3547
3699
  async function handleMessage(ws, _client, msg) {
@@ -3603,10 +3755,10 @@ async function startWebUI(opts = {}) {
3603
3755
  }
3604
3756
  case "tool.confirm_result": {
3605
3757
  const { id, decision } = msg.payload;
3606
- const resolve5 = pendingConfirms.get(id);
3607
- if (resolve5) {
3758
+ const resolve6 = pendingConfirms.get(id);
3759
+ if (resolve6) {
3608
3760
  pendingConfirms.delete(id);
3609
- resolve5(decision);
3761
+ resolve6(decision);
3610
3762
  }
3611
3763
  break;
3612
3764
  }
@@ -3880,7 +4032,7 @@ async function startWebUI(opts = {}) {
3880
4032
  updateAutoCompactionMaxContext?.(newProv);
3881
4033
  try {
3882
4034
  configWriteLock = configWriteLock.then(async () => {
3883
- const raw = await fs6.readFile(globalConfigPath, "utf8");
4035
+ const raw = await fs7.readFile(globalConfigPath, "utf8");
3884
4036
  const parsed = JSON.parse(raw);
3885
4037
  parsed.provider = newProvider;
3886
4038
  parsed.model = newModel;
@@ -4426,8 +4578,8 @@ async function startWebUI(opts = {}) {
4426
4578
  }
4427
4579
  case "goal.get": {
4428
4580
  try {
4429
- const goalPath = path8.join(projectRoot, ".wrongstack", "goal.json");
4430
- const raw = await fs6.readFile(goalPath, "utf8");
4581
+ const goalPath = path9.join(projectRoot, ".wrongstack", "goal.json");
4582
+ const raw = await fs7.readFile(goalPath, "utf8");
4431
4583
  const goal = JSON.parse(raw);
4432
4584
  broadcast(clients, { type: "goal.updated", payload: goal });
4433
4585
  } catch {
@@ -4487,7 +4639,7 @@ async function startWebUI(opts = {}) {
4487
4639
  try {
4488
4640
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4489
4641
  const rewinder = new DefaultSessionRewinder(
4490
- path8.join(projectRoot, ".wrongstack", "sessions"),
4642
+ path9.join(projectRoot, ".wrongstack", "sessions"),
4491
4643
  projectRoot
4492
4644
  );
4493
4645
  const checkpoints = await rewinder.listCheckpoints(session.id);
@@ -4508,7 +4660,7 @@ async function startWebUI(opts = {}) {
4508
4660
  try {
4509
4661
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4510
4662
  const rewinder = new DefaultSessionRewinder(
4511
- path8.join(projectRoot, ".wrongstack", "sessions"),
4663
+ path9.join(projectRoot, ".wrongstack", "sessions"),
4512
4664
  projectRoot
4513
4665
  );
4514
4666
  await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
@@ -4542,9 +4694,9 @@ async function startWebUI(opts = {}) {
4542
4694
  case "projects.add": {
4543
4695
  const { root: addRoot, name: displayName } = msg.payload;
4544
4696
  try {
4545
- const resolved = path8.resolve(addRoot);
4546
- await fs6.access(resolved);
4547
- const stat2 = await fs6.stat(resolved);
4697
+ const resolved = path9.resolve(addRoot);
4698
+ await fs7.access(resolved);
4699
+ const stat2 = await fs7.stat(resolved);
4548
4700
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4549
4701
  const manifest = await loadManifest(globalConfigPath);
4550
4702
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -4560,7 +4712,7 @@ async function startWebUI(opts = {}) {
4560
4712
  });
4561
4713
  break;
4562
4714
  }
4563
- const name = displayName?.trim() || path8.basename(resolved);
4715
+ const name = displayName?.trim() || path9.basename(resolved);
4564
4716
  const slug = generateProjectSlug(resolved);
4565
4717
  await ensureProjectDataDir(slug, globalConfigPath);
4566
4718
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -4579,7 +4731,7 @@ async function startWebUI(opts = {}) {
4579
4731
  send(ws, {
4580
4732
  type: "projects.added",
4581
4733
  payload: {
4582
- name: path8.basename(addRoot),
4734
+ name: path9.basename(addRoot),
4583
4735
  root: addRoot,
4584
4736
  slug: "",
4585
4737
  message: errMessage(err)
@@ -4591,17 +4743,17 @@ async function startWebUI(opts = {}) {
4591
4743
  case "projects.select": {
4592
4744
  const { root: selRoot, name: selName } = msg.payload;
4593
4745
  try {
4594
- const resolved = path8.resolve(selRoot);
4746
+ const resolved = path9.resolve(selRoot);
4595
4747
  try {
4596
- await fs6.access(resolved);
4597
- const stat2 = await fs6.stat(resolved);
4748
+ await fs7.access(resolved);
4749
+ const stat2 = await fs7.stat(resolved);
4598
4750
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4599
4751
  } catch (err) {
4600
4752
  send(ws, {
4601
4753
  type: "projects.selected",
4602
4754
  payload: {
4603
4755
  root: selRoot,
4604
- name: selName || path8.basename(selRoot),
4756
+ name: selName || path9.basename(selRoot),
4605
4757
  message: `Cannot switch: ${errMessage(err)}`
4606
4758
  }
4607
4759
  });
@@ -4613,7 +4765,7 @@ async function startWebUI(opts = {}) {
4613
4765
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
4614
4766
  entry.lastWorkingDir = resolved;
4615
4767
  } else {
4616
- const name = selName?.trim() || path8.basename(resolved);
4768
+ const name = selName?.trim() || path9.basename(resolved);
4617
4769
  const slug = generateProjectSlug(resolved);
4618
4770
  manifest.projects.push({
4619
4771
  name,
@@ -4634,13 +4786,33 @@ async function startWebUI(opts = {}) {
4634
4786
  workingDir = resolved;
4635
4787
  context.cwd = workingDir;
4636
4788
  context.projectRoot = projectRoot;
4637
- const newSessionsDir = path8.join(
4638
- path8.dirname(globalConfigPath),
4789
+ const switchSlug = entry?.slug ?? generateProjectSlug(resolved);
4790
+ try {
4791
+ const switchMode = modeId === "default" ? void 0 : await modeStore.getMode(modeId);
4792
+ const switchBuilder = new DefaultSystemPromptBuilder2({
4793
+ memoryStore,
4794
+ skillLoader,
4795
+ modeStore,
4796
+ modeId,
4797
+ modePrompt: switchMode?.prompt ?? "",
4798
+ modelCapabilities
4799
+ });
4800
+ context.systemPrompt = await switchBuilder.build({
4801
+ cwd: workingDir,
4802
+ projectRoot,
4803
+ tools: toolRegistry.list(),
4804
+ provider: config.provider,
4805
+ model: config.model
4806
+ });
4807
+ } catch {
4808
+ }
4809
+ const newSessionsDir = path9.join(
4810
+ path9.dirname(globalConfigPath),
4639
4811
  "projects",
4640
- entry?.slug ?? generateProjectSlug(resolved),
4812
+ switchSlug,
4641
4813
  "sessions"
4642
4814
  );
4643
- await fs6.mkdir(newSessionsDir, { recursive: true });
4815
+ await fs7.mkdir(newSessionsDir, { recursive: true });
4644
4816
  const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
4645
4817
  const oldSessionId = session.id;
4646
4818
  try {
@@ -4666,12 +4838,25 @@ async function startWebUI(opts = {}) {
4666
4838
  context.fileMtimes.clear();
4667
4839
  tokenCounter.reset();
4668
4840
  sessionStartedAt = Date.now();
4841
+ try {
4842
+ const registry = getSessionRegistry(wpaths.globalRoot);
4843
+ await registry.register({
4844
+ sessionId: session.id,
4845
+ projectSlug: switchSlug,
4846
+ projectRoot,
4847
+ projectName: path9.basename(projectRoot),
4848
+ workingDir,
4849
+ pid: process.pid,
4850
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
4851
+ });
4852
+ } catch {
4853
+ }
4669
4854
  send(ws, {
4670
4855
  type: "projects.selected",
4671
4856
  payload: {
4672
4857
  root: resolved,
4673
- name: selName || path8.basename(resolved),
4674
- message: `Switched to ${selName || path8.basename(resolved)}`
4858
+ name: selName || path9.basename(resolved),
4859
+ message: `Switched to ${selName || path9.basename(resolved)}`
4675
4860
  }
4676
4861
  });
4677
4862
  broadcast(clients, {
@@ -4694,7 +4879,7 @@ async function startWebUI(opts = {}) {
4694
4879
  type: "projects.selected",
4695
4880
  payload: {
4696
4881
  root: selRoot,
4697
- name: selName || path8.basename(selRoot),
4882
+ name: selName || path9.basename(selRoot),
4698
4883
  message: errMessage(err)
4699
4884
  }
4700
4885
  });
@@ -4705,14 +4890,14 @@ async function startWebUI(opts = {}) {
4705
4890
  case "working_dir.set": {
4706
4891
  const { path: newPath } = msg.payload;
4707
4892
  try {
4708
- const resolved = path8.resolve(projectRoot, newPath);
4709
- if (!resolved.startsWith(projectRoot + path8.sep) && resolved !== projectRoot) {
4893
+ const resolved = path9.resolve(projectRoot, newPath);
4894
+ if (!resolved.startsWith(projectRoot + path9.sep) && resolved !== projectRoot) {
4710
4895
  sendResult(ws, false, `Path must stay inside the project root: ${projectRoot}`);
4711
4896
  break;
4712
4897
  }
4713
4898
  try {
4714
- await fs6.access(resolved);
4715
- const stat2 = await fs6.stat(resolved);
4899
+ await fs7.access(resolved);
4900
+ const stat2 = await fs7.stat(resolved);
4716
4901
  if (!stat2.isDirectory()) throw new Error("Not a directory");
4717
4902
  } catch {
4718
4903
  sendResult(ws, false, `Directory not found or not accessible: ${resolved}`);
@@ -4732,58 +4917,30 @@ async function startWebUI(opts = {}) {
4732
4917
  }
4733
4918
  // ── Shell open — spawn terminal or file manager at a path ─────────
4734
4919
  case "shell.open": {
4735
- const { path: targetPath, target } = msg.payload;
4736
- try {
4737
- const resolved = path8.resolve(targetPath);
4738
- await fs6.access(resolved);
4739
- const { exec } = await import("child_process");
4740
- const platform = process.platform;
4741
- let cmd;
4742
- if (target === "file-manager") {
4743
- if (platform === "win32") {
4744
- cmd = `explorer "${resolved}"`;
4745
- } else if (platform === "darwin") {
4746
- cmd = `open "${resolved}"`;
4747
- } else {
4748
- cmd = `xdg-open "${resolved}"`;
4749
- }
4750
- } else {
4751
- if (platform === "win32") {
4752
- cmd = `start cmd /k cd /d "${resolved}"`;
4753
- } else if (platform === "darwin") {
4754
- cmd = `open -a Terminal "${resolved}"`;
4755
- } else {
4756
- cmd = `x-terminal-emulator --working-directory="${resolved}" 2>/dev/null || gnome-terminal --working-directory="${resolved}" 2>/dev/null || xterm -e "cd '${resolved}' && $SHELL"`;
4757
- }
4758
- }
4759
- exec(cmd, { timeout: 5e3 }, (err) => {
4760
- if (err) {
4761
- logger.warn(`shell.open failed: ${err.message}`);
4762
- }
4763
- });
4764
- sendResult(ws, true, `Opened ${target} at ${resolved}`);
4765
- } catch (err) {
4766
- sendResult(ws, false, errMessage(err));
4767
- }
4920
+ const result = await handleShellOpen(
4921
+ msg.payload,
4922
+ logger
4923
+ );
4924
+ sendResult(ws, result.success, result.message);
4768
4925
  break;
4769
4926
  }
4770
4927
  // ── Mailbox operations — project-level inter-agent messaging ────
4771
4928
  case "mailbox.messages":
4772
4929
  return handleMailboxMessages(
4773
4930
  ws,
4774
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
4931
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
4775
4932
  msg.payload
4776
4933
  );
4777
4934
  case "mailbox.agents":
4778
4935
  return handleMailboxAgents(
4779
4936
  ws,
4780
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
4937
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
4781
4938
  msg.payload
4782
4939
  );
4783
4940
  case "mailbox.clear":
4784
4941
  return handleMailboxClear(
4785
4942
  ws,
4786
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) }
4943
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) }
4787
4944
  );
4788
4945
  // ── Brain — status, autonomy ceiling, direct decision support ───
4789
4946
  case "brain.status":
@@ -4851,10 +5008,12 @@ async function startWebUI(opts = {}) {
4851
5008
  });
4852
5009
  const httpServer = createHttpServer({
4853
5010
  host: wsHost,
4854
- distDir: path8.resolve(import.meta.dirname, "../../dist"),
4855
- wsPort
5011
+ distDir: path9.resolve(import.meta.dirname, "../../dist"),
5012
+ wsPort,
5013
+ globalRoot: wpaths.globalRoot,
5014
+ apiToken: wsToken
4856
5015
  });
4857
- const registryBaseDir = path8.dirname(globalConfigPath);
5016
+ const registryBaseDir = path9.dirname(globalConfigPath);
4858
5017
  httpServer.listen(httpPort, wsHost, () => {
4859
5018
  const openUrl = `http://${wsHost}:${httpPort}`;
4860
5019
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
@@ -4866,7 +5025,7 @@ async function startWebUI(opts = {}) {
4866
5025
  wsPort,
4867
5026
  host: wsHost,
4868
5027
  projectRoot,
4869
- projectName: path8.basename(projectRoot) || projectRoot,
5028
+ projectName: path9.basename(projectRoot) || projectRoot,
4870
5029
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4871
5030
  url: `http://${wsHost}:${httpPort}`
4872
5031
  },
@@ -4893,6 +5052,10 @@ async function startWebUI(opts = {}) {
4893
5052
  // reality. Crash exits are healed by the next register()/list() prune pass.
4894
5053
  onShutdown: () => {
4895
5054
  brainMonitor.stop();
5055
+ if (eternalSubscription) {
5056
+ eternalSubscription.dispose();
5057
+ eternalSubscription = null;
5058
+ }
4896
5059
  return unregisterInstance(process.pid, registryBaseDir);
4897
5060
  }
4898
5061
  });
@@ -4904,11 +5067,13 @@ export {
4904
5067
  browserOpenCommand,
4905
5068
  buildCspHeader,
4906
5069
  createCustomModeStore,
5070
+ createEternalSubscription,
4907
5071
  createHttpServer,
4908
5072
  createProviderConfigIO,
4909
5073
  defaultBaseDir,
4910
5074
  deleteKey,
4911
5075
  errMessage,
5076
+ estimateTokens,
4912
5077
  extractToken,
4913
5078
  findFreePort,
4914
5079
  formatInstances,
@@ -4920,6 +5085,7 @@ export {
4920
5085
  handleMemoryForget,
4921
5086
  handleMemoryList,
4922
5087
  handleMemoryRemember,
5088
+ handleShellOpen,
4923
5089
  hostHeaderOk,
4924
5090
  injectWsPort,
4925
5091
  isLoopbackBind,
@@ -4928,6 +5094,8 @@ export {
4928
5094
  listInstances,
4929
5095
  loadSavedProviders,
4930
5096
  maskedKey,
5097
+ messagePreview,
5098
+ messageTokens,
4931
5099
  normalizeKeys,
4932
5100
  openBrowser,
4933
5101
  registerInstance,
@@ -4938,6 +5106,7 @@ export {
4938
5106
  sendResult,
4939
5107
  setActiveKey,
4940
5108
  startWebUI,
5109
+ stringifyContent,
4941
5110
  tokenMatches,
4942
5111
  unregisterInstance,
4943
5112
  upsertKey,