@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.
@@ -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
  }
@@ -3325,12 +3457,11 @@ async function startWebUI(opts = {}) {
3325
3457
  inputCost,
3326
3458
  outputCost,
3327
3459
  cacheReadCost,
3328
- projectName: path8.basename(projectRoot) || projectRoot,
3460
+ projectName: path9.basename(projectRoot) || projectRoot,
3329
3461
  projectRoot,
3330
3462
  cwd: workingDir,
3331
3463
  mode: modeId,
3332
- contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID),
3333
- wsToken
3464
+ contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID)
3334
3465
  };
3335
3466
  }
3336
3467
  const wsToken = generateAuthToken();
@@ -3340,6 +3471,11 @@ async function startWebUI(opts = {}) {
3340
3471
  url: info.req.url ?? "",
3341
3472
  hostHeader: info.req.headers.host,
3342
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,
3343
3479
  wsHost,
3344
3480
  expectedToken: wsToken
3345
3481
  });
@@ -3364,6 +3500,14 @@ async function startWebUI(opts = {}) {
3364
3500
  payload: { cwd: newDir, projectRoot }
3365
3501
  });
3366
3502
  });
3503
+ let eternalSubscription = null;
3504
+ if (opts.subscribeEternalIteration) {
3505
+ eternalSubscription = createEternalSubscription(
3506
+ opts.subscribeEternalIteration,
3507
+ broadcast,
3508
+ () => clients
3509
+ );
3510
+ }
3367
3511
  const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
3368
3512
  const RATE_LIMIT_WINDOW_MS = 6e4;
3369
3513
  const rateLimits = /* @__PURE__ */ new Map();
@@ -3440,8 +3584,8 @@ async function startWebUI(opts = {}) {
3440
3584
  clients.delete(ws);
3441
3585
  rateLimits.delete(String(ws));
3442
3586
  if (pendingConfirms.size > 0) {
3443
- for (const [id, resolve5] of pendingConfirms) {
3444
- resolve5("no");
3587
+ for (const [id, resolve6] of pendingConfirms) {
3588
+ resolve6("no");
3445
3589
  pendingConfirms.delete(id);
3446
3590
  }
3447
3591
  }
@@ -3505,33 +3649,33 @@ async function startWebUI(opts = {}) {
3505
3649
  });
3506
3650
  }
3507
3651
  async function touchProjectEntry(root, workDir) {
3508
- const resolved = path8.resolve(root);
3652
+ const resolved = path9.resolve(root);
3509
3653
  const manifest = await loadManifest(globalConfigPath);
3510
3654
  const now = (/* @__PURE__ */ new Date()).toISOString();
3511
- const existing = manifest.projects.find((p) => path8.resolve(p.root) === resolved);
3655
+ const existing = manifest.projects.find((p) => path9.resolve(p.root) === resolved);
3512
3656
  if (existing) {
3513
3657
  existing.lastSeen = now;
3514
- if (workDir) existing.lastWorkingDir = path8.resolve(workDir);
3658
+ if (workDir) existing.lastWorkingDir = path9.resolve(workDir);
3515
3659
  } else {
3516
3660
  manifest.projects.push({
3517
- name: path8.basename(resolved),
3661
+ name: path9.basename(resolved),
3518
3662
  root: resolved,
3519
3663
  slug: generateProjectSlug(resolved),
3520
3664
  createdAt: now,
3521
3665
  lastSeen: now,
3522
- lastWorkingDir: workDir ? path8.resolve(workDir) : void 0
3666
+ lastWorkingDir: workDir ? path9.resolve(workDir) : void 0
3523
3667
  });
3524
3668
  }
3525
3669
  await saveManifest(manifest, globalConfigPath);
3526
3670
  await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
3527
3671
  }
3528
3672
  function projectsJsonPath(globalConfigPath2) {
3529
- const base = path8.dirname(globalConfigPath2);
3530
- return path8.join(base, "projects.json");
3673
+ const base = path9.dirname(globalConfigPath2);
3674
+ return path9.join(base, "projects.json");
3531
3675
  }
3532
3676
  async function loadManifest(globalConfigPath2) {
3533
3677
  try {
3534
- const raw = await fs6.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3678
+ const raw = await fs7.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3535
3679
  const parsed = JSON.parse(raw);
3536
3680
  return { projects: parsed.projects ?? [] };
3537
3681
  } catch {
@@ -3540,16 +3684,16 @@ async function startWebUI(opts = {}) {
3540
3684
  }
3541
3685
  async function saveManifest(manifest, globalConfigPath2) {
3542
3686
  const file = projectsJsonPath(globalConfigPath2);
3543
- await fs6.mkdir(path8.dirname(file), { recursive: true });
3544
- 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");
3545
3689
  }
3546
3690
  function generateProjectSlug(rootPath) {
3547
3691
  return projectSlug(rootPath);
3548
3692
  }
3549
3693
  async function ensureProjectDataDir(slug, globalConfigPath2) {
3550
- const base = path8.dirname(globalConfigPath2);
3551
- const dir = path8.join(base, "projects", slug);
3552
- 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 });
3553
3697
  return dir;
3554
3698
  }
3555
3699
  async function handleMessage(ws, _client, msg) {
@@ -3611,10 +3755,10 @@ async function startWebUI(opts = {}) {
3611
3755
  }
3612
3756
  case "tool.confirm_result": {
3613
3757
  const { id, decision } = msg.payload;
3614
- const resolve5 = pendingConfirms.get(id);
3615
- if (resolve5) {
3758
+ const resolve6 = pendingConfirms.get(id);
3759
+ if (resolve6) {
3616
3760
  pendingConfirms.delete(id);
3617
- resolve5(decision);
3761
+ resolve6(decision);
3618
3762
  }
3619
3763
  break;
3620
3764
  }
@@ -3888,7 +4032,7 @@ async function startWebUI(opts = {}) {
3888
4032
  updateAutoCompactionMaxContext?.(newProv);
3889
4033
  try {
3890
4034
  configWriteLock = configWriteLock.then(async () => {
3891
- const raw = await fs6.readFile(globalConfigPath, "utf8");
4035
+ const raw = await fs7.readFile(globalConfigPath, "utf8");
3892
4036
  const parsed = JSON.parse(raw);
3893
4037
  parsed.provider = newProvider;
3894
4038
  parsed.model = newModel;
@@ -4434,8 +4578,8 @@ async function startWebUI(opts = {}) {
4434
4578
  }
4435
4579
  case "goal.get": {
4436
4580
  try {
4437
- const goalPath = path8.join(projectRoot, ".wrongstack", "goal.json");
4438
- const raw = await fs6.readFile(goalPath, "utf8");
4581
+ const goalPath = path9.join(projectRoot, ".wrongstack", "goal.json");
4582
+ const raw = await fs7.readFile(goalPath, "utf8");
4439
4583
  const goal = JSON.parse(raw);
4440
4584
  broadcast(clients, { type: "goal.updated", payload: goal });
4441
4585
  } catch {
@@ -4495,7 +4639,7 @@ async function startWebUI(opts = {}) {
4495
4639
  try {
4496
4640
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4497
4641
  const rewinder = new DefaultSessionRewinder(
4498
- path8.join(projectRoot, ".wrongstack", "sessions"),
4642
+ path9.join(projectRoot, ".wrongstack", "sessions"),
4499
4643
  projectRoot
4500
4644
  );
4501
4645
  const checkpoints = await rewinder.listCheckpoints(session.id);
@@ -4516,7 +4660,7 @@ async function startWebUI(opts = {}) {
4516
4660
  try {
4517
4661
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4518
4662
  const rewinder = new DefaultSessionRewinder(
4519
- path8.join(projectRoot, ".wrongstack", "sessions"),
4663
+ path9.join(projectRoot, ".wrongstack", "sessions"),
4520
4664
  projectRoot
4521
4665
  );
4522
4666
  await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
@@ -4550,9 +4694,9 @@ async function startWebUI(opts = {}) {
4550
4694
  case "projects.add": {
4551
4695
  const { root: addRoot, name: displayName } = msg.payload;
4552
4696
  try {
4553
- const resolved = path8.resolve(addRoot);
4554
- await fs6.access(resolved);
4555
- const stat2 = await fs6.stat(resolved);
4697
+ const resolved = path9.resolve(addRoot);
4698
+ await fs7.access(resolved);
4699
+ const stat2 = await fs7.stat(resolved);
4556
4700
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4557
4701
  const manifest = await loadManifest(globalConfigPath);
4558
4702
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -4568,7 +4712,7 @@ async function startWebUI(opts = {}) {
4568
4712
  });
4569
4713
  break;
4570
4714
  }
4571
- const name = displayName?.trim() || path8.basename(resolved);
4715
+ const name = displayName?.trim() || path9.basename(resolved);
4572
4716
  const slug = generateProjectSlug(resolved);
4573
4717
  await ensureProjectDataDir(slug, globalConfigPath);
4574
4718
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -4587,7 +4731,7 @@ async function startWebUI(opts = {}) {
4587
4731
  send(ws, {
4588
4732
  type: "projects.added",
4589
4733
  payload: {
4590
- name: path8.basename(addRoot),
4734
+ name: path9.basename(addRoot),
4591
4735
  root: addRoot,
4592
4736
  slug: "",
4593
4737
  message: errMessage(err)
@@ -4599,17 +4743,17 @@ async function startWebUI(opts = {}) {
4599
4743
  case "projects.select": {
4600
4744
  const { root: selRoot, name: selName } = msg.payload;
4601
4745
  try {
4602
- const resolved = path8.resolve(selRoot);
4746
+ const resolved = path9.resolve(selRoot);
4603
4747
  try {
4604
- await fs6.access(resolved);
4605
- const stat2 = await fs6.stat(resolved);
4748
+ await fs7.access(resolved);
4749
+ const stat2 = await fs7.stat(resolved);
4606
4750
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4607
4751
  } catch (err) {
4608
4752
  send(ws, {
4609
4753
  type: "projects.selected",
4610
4754
  payload: {
4611
4755
  root: selRoot,
4612
- name: selName || path8.basename(selRoot),
4756
+ name: selName || path9.basename(selRoot),
4613
4757
  message: `Cannot switch: ${errMessage(err)}`
4614
4758
  }
4615
4759
  });
@@ -4621,7 +4765,7 @@ async function startWebUI(opts = {}) {
4621
4765
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
4622
4766
  entry.lastWorkingDir = resolved;
4623
4767
  } else {
4624
- const name = selName?.trim() || path8.basename(resolved);
4768
+ const name = selName?.trim() || path9.basename(resolved);
4625
4769
  const slug = generateProjectSlug(resolved);
4626
4770
  manifest.projects.push({
4627
4771
  name,
@@ -4642,13 +4786,33 @@ async function startWebUI(opts = {}) {
4642
4786
  workingDir = resolved;
4643
4787
  context.cwd = workingDir;
4644
4788
  context.projectRoot = projectRoot;
4645
- const newSessionsDir = path8.join(
4646
- 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),
4647
4811
  "projects",
4648
- entry?.slug ?? generateProjectSlug(resolved),
4812
+ switchSlug,
4649
4813
  "sessions"
4650
4814
  );
4651
- await fs6.mkdir(newSessionsDir, { recursive: true });
4815
+ await fs7.mkdir(newSessionsDir, { recursive: true });
4652
4816
  const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
4653
4817
  const oldSessionId = session.id;
4654
4818
  try {
@@ -4674,12 +4838,25 @@ async function startWebUI(opts = {}) {
4674
4838
  context.fileMtimes.clear();
4675
4839
  tokenCounter.reset();
4676
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
+ }
4677
4854
  send(ws, {
4678
4855
  type: "projects.selected",
4679
4856
  payload: {
4680
4857
  root: resolved,
4681
- name: selName || path8.basename(resolved),
4682
- message: `Switched to ${selName || path8.basename(resolved)}`
4858
+ name: selName || path9.basename(resolved),
4859
+ message: `Switched to ${selName || path9.basename(resolved)}`
4683
4860
  }
4684
4861
  });
4685
4862
  broadcast(clients, {
@@ -4702,7 +4879,7 @@ async function startWebUI(opts = {}) {
4702
4879
  type: "projects.selected",
4703
4880
  payload: {
4704
4881
  root: selRoot,
4705
- name: selName || path8.basename(selRoot),
4882
+ name: selName || path9.basename(selRoot),
4706
4883
  message: errMessage(err)
4707
4884
  }
4708
4885
  });
@@ -4713,14 +4890,14 @@ async function startWebUI(opts = {}) {
4713
4890
  case "working_dir.set": {
4714
4891
  const { path: newPath } = msg.payload;
4715
4892
  try {
4716
- const resolved = path8.resolve(projectRoot, newPath);
4717
- if (!resolved.startsWith(projectRoot + path8.sep) && resolved !== projectRoot) {
4893
+ const resolved = path9.resolve(projectRoot, newPath);
4894
+ if (!resolved.startsWith(projectRoot + path9.sep) && resolved !== projectRoot) {
4718
4895
  sendResult(ws, false, `Path must stay inside the project root: ${projectRoot}`);
4719
4896
  break;
4720
4897
  }
4721
4898
  try {
4722
- await fs6.access(resolved);
4723
- const stat2 = await fs6.stat(resolved);
4899
+ await fs7.access(resolved);
4900
+ const stat2 = await fs7.stat(resolved);
4724
4901
  if (!stat2.isDirectory()) throw new Error("Not a directory");
4725
4902
  } catch {
4726
4903
  sendResult(ws, false, `Directory not found or not accessible: ${resolved}`);
@@ -4740,58 +4917,30 @@ async function startWebUI(opts = {}) {
4740
4917
  }
4741
4918
  // ── Shell open — spawn terminal or file manager at a path ─────────
4742
4919
  case "shell.open": {
4743
- const { path: targetPath, target } = msg.payload;
4744
- try {
4745
- const resolved = path8.resolve(targetPath);
4746
- await fs6.access(resolved);
4747
- const { exec } = await import("child_process");
4748
- const platform = process.platform;
4749
- let cmd;
4750
- if (target === "file-manager") {
4751
- if (platform === "win32") {
4752
- cmd = `explorer "${resolved}"`;
4753
- } else if (platform === "darwin") {
4754
- cmd = `open "${resolved}"`;
4755
- } else {
4756
- cmd = `xdg-open "${resolved}"`;
4757
- }
4758
- } else {
4759
- if (platform === "win32") {
4760
- cmd = `start cmd /k cd /d "${resolved}"`;
4761
- } else if (platform === "darwin") {
4762
- cmd = `open -a Terminal "${resolved}"`;
4763
- } else {
4764
- cmd = `x-terminal-emulator --working-directory="${resolved}" 2>/dev/null || gnome-terminal --working-directory="${resolved}" 2>/dev/null || xterm -e "cd '${resolved}' && $SHELL"`;
4765
- }
4766
- }
4767
- exec(cmd, { timeout: 5e3 }, (err) => {
4768
- if (err) {
4769
- logger.warn(`shell.open failed: ${err.message}`);
4770
- }
4771
- });
4772
- sendResult(ws, true, `Opened ${target} at ${resolved}`);
4773
- } catch (err) {
4774
- sendResult(ws, false, errMessage(err));
4775
- }
4920
+ const result = await handleShellOpen(
4921
+ msg.payload,
4922
+ logger
4923
+ );
4924
+ sendResult(ws, result.success, result.message);
4776
4925
  break;
4777
4926
  }
4778
4927
  // ── Mailbox operations — project-level inter-agent messaging ────
4779
4928
  case "mailbox.messages":
4780
4929
  return handleMailboxMessages(
4781
4930
  ws,
4782
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
4931
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
4783
4932
  msg.payload
4784
4933
  );
4785
4934
  case "mailbox.agents":
4786
4935
  return handleMailboxAgents(
4787
4936
  ws,
4788
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
4937
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
4789
4938
  msg.payload
4790
4939
  );
4791
4940
  case "mailbox.clear":
4792
4941
  return handleMailboxClear(
4793
4942
  ws,
4794
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) }
4943
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) }
4795
4944
  );
4796
4945
  // ── Brain — status, autonomy ceiling, direct decision support ───
4797
4946
  case "brain.status":
@@ -4859,10 +5008,12 @@ async function startWebUI(opts = {}) {
4859
5008
  });
4860
5009
  const httpServer = createHttpServer({
4861
5010
  host: wsHost,
4862
- distDir: path8.resolve(import.meta.dirname, "../../dist"),
4863
- wsPort
5011
+ distDir: path9.resolve(import.meta.dirname, "../../dist"),
5012
+ wsPort,
5013
+ globalRoot: wpaths.globalRoot,
5014
+ apiToken: wsToken
4864
5015
  });
4865
- const registryBaseDir = path8.dirname(globalConfigPath);
5016
+ const registryBaseDir = path9.dirname(globalConfigPath);
4866
5017
  httpServer.listen(httpPort, wsHost, () => {
4867
5018
  const openUrl = `http://${wsHost}:${httpPort}`;
4868
5019
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
@@ -4874,7 +5025,7 @@ async function startWebUI(opts = {}) {
4874
5025
  wsPort,
4875
5026
  host: wsHost,
4876
5027
  projectRoot,
4877
- projectName: path8.basename(projectRoot) || projectRoot,
5028
+ projectName: path9.basename(projectRoot) || projectRoot,
4878
5029
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
4879
5030
  url: `http://${wsHost}:${httpPort}`
4880
5031
  },
@@ -4901,6 +5052,10 @@ async function startWebUI(opts = {}) {
4901
5052
  // reality. Crash exits are healed by the next register()/list() prune pass.
4902
5053
  onShutdown: () => {
4903
5054
  brainMonitor.stop();
5055
+ if (eternalSubscription) {
5056
+ eternalSubscription.dispose();
5057
+ eternalSubscription = null;
5058
+ }
4904
5059
  return unregisterInstance(process.pid, registryBaseDir);
4905
5060
  }
4906
5061
  });
@@ -4912,11 +5067,13 @@ export {
4912
5067
  browserOpenCommand,
4913
5068
  buildCspHeader,
4914
5069
  createCustomModeStore,
5070
+ createEternalSubscription,
4915
5071
  createHttpServer,
4916
5072
  createProviderConfigIO,
4917
5073
  defaultBaseDir,
4918
5074
  deleteKey,
4919
5075
  errMessage,
5076
+ estimateTokens,
4920
5077
  extractToken,
4921
5078
  findFreePort,
4922
5079
  formatInstances,
@@ -4928,6 +5085,7 @@ export {
4928
5085
  handleMemoryForget,
4929
5086
  handleMemoryList,
4930
5087
  handleMemoryRemember,
5088
+ handleShellOpen,
4931
5089
  hostHeaderOk,
4932
5090
  injectWsPort,
4933
5091
  isLoopbackBind,
@@ -4936,6 +5094,8 @@ export {
4936
5094
  listInstances,
4937
5095
  loadSavedProviders,
4938
5096
  maskedKey,
5097
+ messagePreview,
5098
+ messageTokens,
4939
5099
  normalizeKeys,
4940
5100
  openBrowser,
4941
5101
  registerInstance,
@@ -4946,6 +5106,7 @@ export {
4946
5106
  sendResult,
4947
5107
  setActiveKey,
4948
5108
  startWebUI,
5109
+ stringifyContent,
4949
5110
  tokenMatches,
4950
5111
  unregisterInstance,
4951
5112
  upsertKey,