@wrongstack/webui 0.155.0 → 0.250.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.
@@ -1,7 +1,22 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/server/index.ts
2
- import { expectDefined as expectDefined2 } from "@wrongstack/core";
3
- import * as fs4 from "fs/promises";
4
- import * as path4 from "path";
9
+ import { expectDefined as expectDefined2, GlobalMailbox as GlobalMailbox2, projectSlug, getSessionRegistry, AgentStatusTracker } from "@wrongstack/core";
10
+ import { makeMailboxTool, makeMailSendTool, makeMailInboxTool, mailboxSessionTag } from "@wrongstack/core";
11
+ import {
12
+ BrainMonitor,
13
+ DefaultBrainArbiter,
14
+ ObservableBrainArbiter,
15
+ createAutonomyBrain,
16
+ createTieredBrainArbiter
17
+ } from "@wrongstack/core";
18
+ import * as fs6 from "fs/promises";
19
+ import * as path8 from "path";
5
20
 
6
21
  // src/server/http-server.ts
7
22
  import * as fs from "fs/promises";
@@ -27,7 +42,7 @@ function injectWsPort(html, wsPort) {
27
42
  ${html}`;
28
43
  }
29
44
  function buildCspHeader(wsPort) {
30
- return `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:${wsPort} wss://127.0.0.1:${wsPort} ws://[::1]:${wsPort} wss://[::1]:${wsPort}; img-src 'self' data:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'`;
45
+ return `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:${wsPort} wss://127.0.0.1:${wsPort} ws://[::1]:${wsPort} wss://[::1]:${wsPort}; img-src 'self' data:; font-src 'self' data:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'`;
31
46
  }
32
47
  function isInsideDist(candidate, distDir) {
33
48
  const root = path.resolve(distDir);
@@ -41,6 +56,15 @@ function createHttpServer(opts) {
41
56
  return http.createServer(async (req, res) => {
42
57
  try {
43
58
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
59
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
60
+ await handleApiSessions(res, opts.globalRoot);
61
+ return;
62
+ }
63
+ const agentsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/agents$/);
64
+ if (agentsMatch && req.method === "GET") {
65
+ await handleApiSessionAgents(res, opts.globalRoot, agentsMatch[1]);
66
+ return;
67
+ }
44
68
  let filePath;
45
69
  if (url.pathname === "/" || url.pathname === "") {
46
70
  filePath = path.join(distDir, "index.html");
@@ -97,6 +121,84 @@ function createHttpServer(opts) {
97
121
  }
98
122
  });
99
123
  }
124
+ async function handleApiSessions(res, globalRoot) {
125
+ if (!globalRoot) {
126
+ res.writeHead(500, { "Content-Type": "application/json" });
127
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
128
+ return;
129
+ }
130
+ try {
131
+ const { SessionRegistry } = await import("@wrongstack/core");
132
+ const registry = new SessionRegistry(globalRoot);
133
+ const sessions = await registry.list();
134
+ const result = sessions.map((s) => ({
135
+ sessionId: s.sessionId,
136
+ projectSlug: s.projectSlug,
137
+ projectName: s.projectName,
138
+ projectRoot: s.projectRoot,
139
+ workingDir: s.workingDir,
140
+ status: s.status,
141
+ pid: s.pid,
142
+ startedAt: s.startedAt,
143
+ lastHeartbeatAt: s.lastHeartbeatAt,
144
+ agentCount: s.agentCount,
145
+ agents: s.agents.map((a) => ({
146
+ id: a.id,
147
+ name: a.name,
148
+ status: a.status,
149
+ currentTool: a.currentTool,
150
+ iterations: a.iterations,
151
+ toolCalls: a.toolCalls,
152
+ lastActivityAt: a.lastActivityAt
153
+ }))
154
+ }));
155
+ res.writeHead(200, { "Content-Type": "application/json" });
156
+ res.end(JSON.stringify(result));
157
+ } catch (err) {
158
+ res.writeHead(500, { "Content-Type": "application/json" });
159
+ res.end(JSON.stringify({ error: String(err) }));
160
+ }
161
+ }
162
+ async function handleApiSessionAgents(res, globalRoot, sessionId) {
163
+ if (!globalRoot) {
164
+ res.writeHead(500, { "Content-Type": "application/json" });
165
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
166
+ return;
167
+ }
168
+ try {
169
+ const { SessionRegistry } = await import("@wrongstack/core");
170
+ const registry = new SessionRegistry(globalRoot);
171
+ const entry = await registry.get(sessionId);
172
+ if (!entry) {
173
+ res.writeHead(404, { "Content-Type": "application/json" });
174
+ res.end(JSON.stringify({ error: "Session not found" }));
175
+ return;
176
+ }
177
+ res.writeHead(200, { "Content-Type": "application/json" });
178
+ res.end(JSON.stringify({
179
+ sessionId: entry.sessionId,
180
+ projectName: entry.projectName,
181
+ status: entry.status,
182
+ agents: entry.agents.map((a) => ({
183
+ id: a.id,
184
+ name: a.name,
185
+ status: a.status,
186
+ currentTool: a.currentTool,
187
+ iterations: a.iterations,
188
+ toolCalls: a.toolCalls,
189
+ lastActivityAt: a.lastActivityAt
190
+ }))
191
+ }));
192
+ } catch (err) {
193
+ res.writeHead(500, { "Content-Type": "application/json" });
194
+ res.end(JSON.stringify({ error: String(err) }));
195
+ }
196
+ }
197
+
198
+ // src/server/file-handlers.ts
199
+ import * as fs2 from "fs/promises";
200
+ import * as path2 from "path";
201
+ import { atomicWrite } from "@wrongstack/core";
100
202
 
101
203
  // src/server/file-picker.ts
102
204
  var SKIP_DIRS = /* @__PURE__ */ new Set([
@@ -146,6 +248,193 @@ function rankFiles(paths, query, limit) {
146
248
  return scored.slice(0, limit).map((s) => s.path);
147
249
  }
148
250
 
251
+ // src/server/ws-utils.ts
252
+ import { randomBytes } from "crypto";
253
+ import { WebSocket } from "ws";
254
+ function send(ws, msg) {
255
+ if (ws.readyState === WebSocket.OPEN) {
256
+ ws.send(JSON.stringify(msg));
257
+ }
258
+ }
259
+ function broadcast(clients, msg) {
260
+ const data = JSON.stringify(msg);
261
+ for (const [ws] of clients) {
262
+ if (ws.readyState === WebSocket.OPEN) {
263
+ try {
264
+ ws.send(data);
265
+ } catch {
266
+ }
267
+ }
268
+ }
269
+ }
270
+ function sendResult(ws, success, message) {
271
+ send(ws, { type: "key.operation_result", payload: { success, message } });
272
+ }
273
+ function errMessage(err) {
274
+ return err instanceof Error ? err.message : String(err);
275
+ }
276
+ function generateAuthToken() {
277
+ return randomBytes(16).toString("hex");
278
+ }
279
+
280
+ // src/server/file-handlers.ts
281
+ async function handleFilesTree(ws, msg, projectRoot) {
282
+ const payload = msg.payload;
283
+ const rawPath = payload?.path?.trim();
284
+ const treeRoot = rawPath && rawPath !== "." ? path2.resolve(projectRoot, rawPath) : projectRoot;
285
+ if (!treeRoot.startsWith(projectRoot + path2.sep) && treeRoot !== projectRoot) {
286
+ send(ws, {
287
+ type: "files.tree",
288
+ payload: { root: projectRoot, tree: [], error: "Path outside project root" }
289
+ });
290
+ return;
291
+ }
292
+ const pathPrefix = treeRoot === projectRoot ? "" : (path2.relative(projectRoot, treeRoot) + "/").replace(/\\/g, "/");
293
+ async function buildTree(dir, rel, depth) {
294
+ if (depth > 10) return [];
295
+ let entries = [];
296
+ try {
297
+ entries = await fs2.readdir(dir, { withFileTypes: true });
298
+ } catch {
299
+ return [];
300
+ }
301
+ entries.sort((a, b) => {
302
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
303
+ return a.name.localeCompare(b.name);
304
+ });
305
+ const nodes = [];
306
+ for (const e of entries) {
307
+ if (isHiddenEntry(e.name)) continue;
308
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
309
+ const childAbs = path2.join(dir, e.name);
310
+ const childPath = pathPrefix + childRel;
311
+ if (e.isDirectory()) {
312
+ if (SKIP_DIRS.has(e.name)) continue;
313
+ const children = await buildTree(childAbs, childRel, depth + 1);
314
+ nodes.push({ name: e.name, path: childPath, type: "directory", children });
315
+ } else if (e.isFile()) {
316
+ nodes.push({ name: e.name, path: childPath, type: "file" });
317
+ }
318
+ }
319
+ return nodes;
320
+ }
321
+ try {
322
+ const tree = await buildTree(treeRoot, "", 0);
323
+ const rootLabel = treeRoot === projectRoot ? projectRoot : path2.relative(projectRoot, treeRoot) || ".";
324
+ send(ws, { type: "files.tree", payload: { root: rootLabel, tree } });
325
+ } catch (err) {
326
+ const rootLabel = treeRoot === projectRoot ? projectRoot : path2.relative(projectRoot, treeRoot) || ".";
327
+ send(ws, {
328
+ type: "files.tree",
329
+ payload: { root: rootLabel, tree: [], error: errMessage(err) }
330
+ });
331
+ }
332
+ }
333
+ async function handleFilesRead(ws, msg, projectRoot) {
334
+ const { filePath } = msg.payload;
335
+ const resolved = path2.resolve(projectRoot, filePath);
336
+ if (!resolved.startsWith(projectRoot + path2.sep) && resolved !== projectRoot) {
337
+ send(ws, { type: "files.read", payload: { filePath, content: "", error: "Forbidden" } });
338
+ return;
339
+ }
340
+ try {
341
+ const content = await fs2.readFile(resolved, "utf8");
342
+ send(ws, { type: "files.read", payload: { filePath, content } });
343
+ } catch (err) {
344
+ send(ws, {
345
+ type: "files.read",
346
+ payload: { filePath, content: "", error: errMessage(err) }
347
+ });
348
+ }
349
+ }
350
+ async function handleFilesWrite(ws, msg, projectRoot) {
351
+ const { filePath, content } = msg.payload;
352
+ const resolved = path2.resolve(projectRoot, filePath);
353
+ if (!resolved.startsWith(projectRoot + path2.sep) && resolved !== projectRoot) {
354
+ send(ws, { type: "files.written", payload: { filePath, success: false, error: "Forbidden" } });
355
+ return;
356
+ }
357
+ try {
358
+ await atomicWrite(resolved, content);
359
+ send(ws, { type: "files.written", payload: { filePath, success: true } });
360
+ } catch (err) {
361
+ send(ws, {
362
+ type: "files.written",
363
+ payload: { filePath, success: false, error: errMessage(err) }
364
+ });
365
+ }
366
+ }
367
+ async function handleFilesList(ws, msg, projectRoot) {
368
+ const payload = msg.payload ?? {};
369
+ const limit = payload.limit ?? 50;
370
+ const listRoot = payload.path ? path2.resolve(projectRoot, payload.path) : projectRoot;
371
+ if (!listRoot.startsWith(projectRoot + path2.sep) && listRoot !== projectRoot) {
372
+ send(ws, { type: "files.list", payload: { files: [] } });
373
+ return;
374
+ }
375
+ const results = [];
376
+ async function walk(dir, rel, depth) {
377
+ if (depth > 8 || results.length >= 600) return;
378
+ let entries = [];
379
+ try {
380
+ entries = await fs2.readdir(dir, { withFileTypes: true });
381
+ } catch {
382
+ return;
383
+ }
384
+ for (const e of entries) {
385
+ if (results.length >= 600) return;
386
+ if (isHiddenEntry(e.name)) continue;
387
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
388
+ if (e.isDirectory()) {
389
+ if (SKIP_DIRS.has(e.name)) continue;
390
+ await walk(path2.join(dir, e.name), childRel, depth + 1);
391
+ } else if (e.isFile()) {
392
+ results.push(childRel);
393
+ }
394
+ }
395
+ }
396
+ await walk(listRoot, "", 0);
397
+ send(ws, {
398
+ type: "files.list",
399
+ payload: { files: rankFiles(results, payload.query ?? "", limit) }
400
+ });
401
+ }
402
+
403
+ // src/server/memory-handlers.ts
404
+ async function handleMemoryList(ws, memoryStore) {
405
+ try {
406
+ const text = await memoryStore.readAll();
407
+ send(ws, { type: "memory.list", payload: { text } });
408
+ } catch (err) {
409
+ send(ws, {
410
+ type: "memory.list",
411
+ payload: { text: "", error: errMessage(err) }
412
+ });
413
+ }
414
+ }
415
+ async function handleMemoryRemember(ws, msg, memoryStore) {
416
+ const { text, scope } = msg.payload;
417
+ try {
418
+ await memoryStore.remember(text, scope ?? "project-memory");
419
+ sendResult(ws, true, "Saved to memory");
420
+ } catch (err) {
421
+ sendResult(ws, false, errMessage(err));
422
+ }
423
+ }
424
+ async function handleMemoryForget(ws, msg, memoryStore) {
425
+ const { text, scope } = msg.payload;
426
+ try {
427
+ const removed = await memoryStore.forget(text, scope ?? "project-memory");
428
+ sendResult(
429
+ ws,
430
+ removed > 0,
431
+ removed > 0 ? `Removed ${removed} entr${removed === 1 ? "y" : "ies"}` : "No matching entries"
432
+ );
433
+ } catch (err) {
434
+ sendResult(ws, false, errMessage(err));
435
+ }
436
+ }
437
+
149
438
  // src/server/index.ts
150
439
  import {
151
440
  Agent,
@@ -169,16 +458,20 @@ import {
169
458
  ProviderRegistry,
170
459
  TOKENS as TOKENS2,
171
460
  ToolRegistry,
172
- atomicWrite as atomicWrite3,
461
+ atomicWrite as atomicWrite5,
173
462
  createDefaultPipelines,
463
+ createSessionEventBridge,
464
+ resolveSessionLoggingConfig,
174
465
  DEFAULT_CONTEXT_WINDOW_MODE_ID,
175
466
  DEFAULT_SESSION_PRUNE_DAYS,
176
467
  DEFAULT_TOOLS_CONFIG,
177
- listContextWindowModes,
178
468
  repairToolUseAdjacency,
179
- resolveContextWindowPolicy
469
+ resolveContextWindowPolicy,
470
+ enhanceUserPrompt,
471
+ recentTextTurns
180
472
  } from "@wrongstack/core";
181
473
  import { ToolExecutor } from "@wrongstack/core/execution";
474
+ import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets as encryptConfigSecrets2 } from "@wrongstack/core/security";
182
475
  import { buildProviderFactoriesFromRegistry, makeProviderFromConfig } from "@wrongstack/providers";
183
476
  import { builtinToolsPack, forgetTool, rememberTool, searchMemoryTool, relatedMemoryTool } from "@wrongstack/tools";
184
477
  import { WebSocketServer } from "ws";
@@ -308,7 +601,7 @@ import {
308
601
  } from "@wrongstack/core";
309
602
  function isGitRepo(cwd) {
310
603
  try {
311
- const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, encoding: "utf8" });
604
+ const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, encoding: "utf8", windowsHide: true });
312
605
  return r.status === 0 && r.stdout.trim() === "true";
313
606
  } catch {
314
607
  return false;
@@ -1422,19 +1715,112 @@ var WorktreeWebSocketHandler = class {
1422
1715
  }
1423
1716
  };
1424
1717
 
1718
+ // src/server/mailbox-handlers.ts
1719
+ import * as path3 from "path";
1720
+ import { GlobalMailbox } from "@wrongstack/core";
1721
+ function resolveProjectDir(projectRoot, globalRoot) {
1722
+ const { createHash } = __require("crypto");
1723
+ const hash = createHash("sha256").update(path3.resolve(projectRoot)).digest("hex").slice(0, 6);
1724
+ const slug = path3.basename(projectRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40) || "project";
1725
+ return path3.join(globalRoot, "projects", `${slug}-${hash}`);
1726
+ }
1727
+ async function handleMailboxMessages(ws, deps, payload) {
1728
+ try {
1729
+ const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
1730
+ const mb = new GlobalMailbox(dir);
1731
+ const messages = await mb.query({
1732
+ limit: payload?.limit ?? 30,
1733
+ to: payload?.agentId,
1734
+ unreadBy: payload?.unreadOnly ? payload.agentId : void 0
1735
+ });
1736
+ send(ws, {
1737
+ type: "mailbox.messages",
1738
+ payload: {
1739
+ messages: messages.map((m) => ({
1740
+ id: m.id,
1741
+ from: m.from,
1742
+ to: m.to,
1743
+ type: m.type,
1744
+ subject: m.subject,
1745
+ body: m.body,
1746
+ priority: m.priority,
1747
+ readBy: m.readBy,
1748
+ readByCount: Object.keys(m.readBy).length,
1749
+ completed: m.completed,
1750
+ completedBy: m.completedBy,
1751
+ outcome: m.outcome,
1752
+ timestamp: m.timestamp,
1753
+ replyTo: m.replyTo,
1754
+ senderSessionId: m.senderSessionId
1755
+ }))
1756
+ }
1757
+ });
1758
+ } catch (err) {
1759
+ send(ws, { type: "mailbox.messages", payload: { messages: [], error: errMessage(err) } });
1760
+ }
1761
+ }
1762
+ async function handleMailboxAgents(ws, deps, payload) {
1763
+ try {
1764
+ const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
1765
+ const mb = new GlobalMailbox(dir);
1766
+ const agents = payload?.onlineOnly ? await mb.getOnlineAgents() : await mb.getAgentStatuses();
1767
+ send(ws, {
1768
+ type: "mailbox.agents",
1769
+ payload: {
1770
+ agents: agents.map((a) => ({
1771
+ agentId: a.agentId,
1772
+ name: a.name,
1773
+ role: a.role,
1774
+ sessionId: a.sessionId,
1775
+ status: a.status,
1776
+ currentTool: a.currentTool,
1777
+ currentTask: a.currentTask,
1778
+ iterations: a.iterations,
1779
+ toolCalls: a.toolCalls,
1780
+ lastSeenAt: a.lastSeenAt,
1781
+ online: a.online,
1782
+ pid: a.pid,
1783
+ source: a.source
1784
+ }))
1785
+ }
1786
+ });
1787
+ } catch (err) {
1788
+ send(ws, { type: "mailbox.agents", payload: { agents: [], error: errMessage(err) } });
1789
+ }
1790
+ }
1791
+ async function handleMailboxClear(ws, deps) {
1792
+ try {
1793
+ const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
1794
+ const mb = new GlobalMailbox(dir);
1795
+ await mb.clearAll();
1796
+ send(ws, { type: "mailbox.cleared", payload: {} });
1797
+ } catch (err) {
1798
+ send(ws, { type: "mailbox.cleared", payload: { error: errMessage(err) } });
1799
+ }
1800
+ }
1801
+
1425
1802
  // src/server/ws-auth.ts
1426
- import { Buffer } from "buffer";
1803
+ import { Buffer as Buffer2 } from "buffer";
1427
1804
  import { timingSafeEqual } from "crypto";
1428
1805
  function isLoopbackHostname(hostname) {
1429
1806
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
1430
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
+ }
1431
1817
  function isLoopbackBind(wsHost) {
1432
1818
  return wsHost === "127.0.0.1" || wsHost === "::1" || wsHost === "localhost";
1433
1819
  }
1434
1820
  function tokenMatches(provided, expected) {
1435
1821
  if (!provided) return false;
1436
- const a = Buffer.from(provided);
1437
- const b = Buffer.from(expected);
1822
+ const a = Buffer2.from(provided);
1823
+ const b = Buffer2.from(expected);
1438
1824
  if (a.length !== b.length) return false;
1439
1825
  return timingSafeEqual(a, b);
1440
1826
  }
@@ -1466,7 +1852,12 @@ function verifyClient(input) {
1466
1852
  }
1467
1853
  try {
1468
1854
  const { hostname } = new URL(origin);
1469
- if (isLoopbackHostname(hostname)) return true;
1855
+ if (isLoopbackHostname(hostname)) {
1856
+ if (wsHost === "0.0.0.0" && !isTrustedLoopbackOrigin(origin)) {
1857
+ return false;
1858
+ }
1859
+ return true;
1860
+ }
1470
1861
  return tokenOk;
1471
1862
  } catch {
1472
1863
  return false;
@@ -1511,14 +1902,14 @@ function registerShutdownHandlers(res) {
1511
1902
 
1512
1903
  // src/server/instance-registry.ts
1513
1904
  import * as os from "os";
1514
- import * as path2 from "path";
1515
- import * as fs2 from "fs/promises";
1516
- import { atomicWrite } from "@wrongstack/core";
1905
+ import * as path4 from "path";
1906
+ import * as fs3 from "fs/promises";
1907
+ import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
1517
1908
  function defaultBaseDir() {
1518
- return path2.join(os.homedir(), ".wrongstack");
1909
+ return path4.join(os.homedir(), ".wrongstack");
1519
1910
  }
1520
1911
  function registryPath(baseDir = defaultBaseDir()) {
1521
- return path2.join(baseDir, "webui-instances.json");
1912
+ return path4.join(baseDir, "webui-instances.json");
1522
1913
  }
1523
1914
  function isPidAlive(pid) {
1524
1915
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -1531,7 +1922,7 @@ function isPidAlive(pid) {
1531
1922
  }
1532
1923
  async function load(file) {
1533
1924
  try {
1534
- const raw = await fs2.readFile(file, "utf8");
1925
+ const raw = await fs3.readFile(file, "utf8");
1535
1926
  const parsed = JSON.parse(raw);
1536
1927
  if (parsed?.version === 1 && Array.isArray(parsed.instances)) {
1537
1928
  return parsed;
@@ -1541,7 +1932,7 @@ async function load(file) {
1541
1932
  return { version: 1, instances: [] };
1542
1933
  }
1543
1934
  async function save(file, instances) {
1544
- await atomicWrite(file, `${JSON.stringify({ version: 1, instances }, null, 2)}
1935
+ await atomicWrite2(file, `${JSON.stringify({ version: 1, instances }, null, 2)}
1545
1936
  `, {
1546
1937
  mode: 384
1547
1938
  });
@@ -1590,16 +1981,16 @@ function formatInstances(instances) {
1590
1981
  // src/server/port-utils.ts
1591
1982
  import * as net from "net";
1592
1983
  function isPortFree(host, port) {
1593
- return new Promise((resolve3) => {
1984
+ return new Promise((resolve5) => {
1594
1985
  const srv = net.createServer();
1595
- srv.once("error", () => resolve3(false));
1986
+ srv.once("error", () => resolve5(false));
1596
1987
  srv.once("listening", () => {
1597
- srv.close(() => resolve3(true));
1988
+ srv.close(() => resolve5(true));
1598
1989
  });
1599
1990
  try {
1600
1991
  srv.listen(port, host);
1601
1992
  } catch {
1602
- resolve3(false);
1993
+ resolve5(false);
1603
1994
  }
1604
1995
  });
1605
1996
  }
@@ -1633,7 +2024,7 @@ function browserOpenCommand(url, platform = process.platform) {
1633
2024
  function openBrowser(url, platform = process.platform) {
1634
2025
  try {
1635
2026
  const { command, args } = browserOpenCommand(url, platform);
1636
- const child = spawn(command, args, { stdio: "ignore", detached: true });
2027
+ const child = spawn(command, args, { stdio: "ignore", detached: true, windowsHide: true });
1637
2028
  child.on("error", () => {
1638
2029
  });
1639
2030
  child.unref();
@@ -1675,15 +2066,15 @@ function computeUsageCost(usage, rates) {
1675
2066
  }
1676
2067
 
1677
2068
  // src/server/provider-config-io.ts
1678
- import * as fs3 from "fs/promises";
1679
- import * as path3 from "path";
1680
- import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
2069
+ import * as fs4 from "fs/promises";
2070
+ import * as path5 from "path";
2071
+ import { atomicWrite as atomicWrite3 } from "@wrongstack/core";
1681
2072
  import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/security";
1682
2073
  import { DefaultSecretVault } from "@wrongstack/core";
1683
2074
  async function loadSavedProviders(configPath, vault) {
1684
2075
  let raw;
1685
2076
  try {
1686
- raw = await fs3.readFile(configPath, "utf8");
2077
+ raw = await fs4.readFile(configPath, "utf8");
1687
2078
  } catch {
1688
2079
  return {};
1689
2080
  }
@@ -1700,7 +2091,7 @@ async function saveProviders(configPath, vault, providers) {
1700
2091
  let raw;
1701
2092
  let fileExists = true;
1702
2093
  try {
1703
- raw = await fs3.readFile(configPath, "utf8");
2094
+ raw = await fs4.readFile(configPath, "utf8");
1704
2095
  } catch (err) {
1705
2096
  if (err.code !== "ENOENT") {
1706
2097
  throw new Error(
@@ -1725,10 +2116,10 @@ async function saveProviders(configPath, vault, providers) {
1725
2116
  }
1726
2117
  parsed.providers = providers;
1727
2118
  const encrypted = encryptConfigSecrets(parsed, vault);
1728
- await atomicWrite2(configPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
2119
+ await atomicWrite3(configPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
1729
2120
  }
1730
2121
  function createProviderConfigIO(configPath) {
1731
- const keyFile = path3.join(path3.dirname(configPath), ".key");
2122
+ const keyFile = path5.join(path5.dirname(configPath), ".key");
1732
2123
  const vault = new DefaultSecretVault({ keyFile });
1733
2124
  return {
1734
2125
  load: () => loadSavedProviders(configPath, vault),
@@ -1832,38 +2223,9 @@ function removeProvider(providers, providerId) {
1832
2223
  return { ok: true, message: `Provider "${providerId}" removed` };
1833
2224
  }
1834
2225
 
1835
- // src/server/ws-utils.ts
1836
- import { randomBytes } from "crypto";
1837
- import { WebSocket } from "ws";
1838
- function send(ws, msg) {
1839
- if (ws.readyState === WebSocket.OPEN) {
1840
- ws.send(JSON.stringify(msg));
1841
- }
1842
- }
1843
- function broadcast(clients, msg) {
1844
- const data = JSON.stringify(msg);
1845
- for (const [ws] of clients) {
1846
- if (ws.readyState === WebSocket.OPEN) {
1847
- try {
1848
- ws.send(data);
1849
- } catch {
1850
- }
1851
- }
1852
- }
1853
- }
1854
- function sendResult(ws, success, message) {
1855
- send(ws, { type: "key.operation_result", payload: { success, message } });
1856
- }
1857
- function errMessage(err) {
1858
- return err instanceof Error ? err.message : String(err);
1859
- }
1860
- function generateAuthToken() {
1861
- return randomBytes(16).toString("hex");
1862
- }
1863
-
1864
2226
  // src/server/provider-handlers.ts
1865
2227
  function createProviderHandlers(deps) {
1866
- const { globalConfigPath, vault } = deps;
2228
+ const { globalConfigPath, vault, broadcast: broadcast2, clients } = deps;
1867
2229
  let configWriteLock = deps.getConfigWriteLock();
1868
2230
  async function loadConfigProviders() {
1869
2231
  return loadSavedProviders(globalConfigPath, vault);
@@ -1871,7 +2233,12 @@ function createProviderHandlers(deps) {
1871
2233
  async function saveConfigProviders(providers) {
1872
2234
  const next = configWriteLock.then(() => saveProviders(globalConfigPath, vault, providers)).catch((err) => {
1873
2235
  const msg = err instanceof Error ? err.message : String(err);
1874
- console.error(`[ProviderHandlers] saveProviders failed: ${msg}`);
2236
+ console.error(JSON.stringify({
2237
+ level: "error",
2238
+ event: "webui.provider_save_failed",
2239
+ message: msg,
2240
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2241
+ }));
1875
2242
  });
1876
2243
  configWriteLock = next;
1877
2244
  deps.setConfigWriteLock(next);
@@ -1913,6 +2280,28 @@ function createProviderHandlers(deps) {
1913
2280
  const result = addProvider(providers, payload, (/* @__PURE__ */ new Date()).toISOString());
1914
2281
  if (result.ok) await saveConfigProviders(providers);
1915
2282
  sendResult(ws, result.ok, result.message);
2283
+ if (result.ok) {
2284
+ console.log(`[WebUI] Provider "${payload.id}" added via provider.add`);
2285
+ broadcast2(clients, {
2286
+ type: "providers.saved",
2287
+ payload: {
2288
+ providers: Object.entries(providers).map(([id, cfg]) => {
2289
+ const keys = normalizeKeys(cfg);
2290
+ return {
2291
+ id,
2292
+ family: cfg.family ?? id,
2293
+ baseUrl: cfg.baseUrl,
2294
+ apiKeys: keys.map((k) => ({
2295
+ label: k.label,
2296
+ maskedKey: maskedKey(k.apiKey),
2297
+ isActive: k.label === cfg.activeKey,
2298
+ createdAt: k.createdAt
2299
+ }))
2300
+ };
2301
+ })
2302
+ }
2303
+ });
2304
+ }
1916
2305
  } catch (err) {
1917
2306
  sendResult(ws, false, errMessage(err));
1918
2307
  }
@@ -1931,12 +2320,14 @@ function createProviderHandlers(deps) {
1931
2320
  }
1932
2321
 
1933
2322
  // src/server/setup-events.ts
2323
+ import * as path6 from "path";
1934
2324
  function setupEvents(deps) {
1935
- const { events, broadcast: broadcast2, clients, config, context, pendingConfirms } = deps;
2325
+ const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge } = deps;
1936
2326
  events.on("iteration.started", (e) => {
2327
+ const maxIt = typeof context.meta["maxIterations"] === "number" ? context.meta["maxIterations"] : config.tools?.maxIterations ?? 100;
1937
2328
  broadcast2(clients, {
1938
2329
  type: "iteration.started",
1939
- payload: { index: e.index, maxIterations: config.tools?.maxIterations ?? 100 }
2330
+ payload: { index: e.index, maxIterations: maxIt }
1940
2331
  });
1941
2332
  });
1942
2333
  events.on("provider.text_delta", (e) => {
@@ -1950,19 +2341,70 @@ function setupEvents(deps) {
1950
2341
  type: "tool.started",
1951
2342
  payload: { id: e.id, name: e.name, input: e.input, messageId: `tool_${e.id}` }
1952
2343
  });
2344
+ sessionBridge?.append({
2345
+ type: "tool_call_start",
2346
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2347
+ name: e.name,
2348
+ id: e.id,
2349
+ input: e.input
2350
+ }).catch(() => {
2351
+ });
1953
2352
  });
1954
2353
  events.on("tool.progress", (e) => {
1955
2354
  broadcast2(clients, {
1956
2355
  type: "tool.progress",
1957
2356
  payload: { id: e.id, name: e.name, eventType: e.event.type, text: e.event.text }
1958
2357
  });
2358
+ sessionBridge?.append({
2359
+ type: "tool_progress",
2360
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2361
+ name: e.name,
2362
+ id: e.id,
2363
+ event: { type: e.event.type, text: e.event.text, data: e.event.data }
2364
+ }).catch(() => {
2365
+ });
1959
2366
  });
1960
2367
  events.on("tool.executed", (e) => {
1961
2368
  broadcast2(clients, {
1962
2369
  type: "tool.executed",
1963
2370
  payload: { id: e.id, name: e.name, durationMs: e.durationMs, ok: e.ok, input: e.input, output: e.output }
1964
2371
  });
2372
+ sessionBridge?.append({
2373
+ type: "tool_call_end",
2374
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2375
+ name: e.name,
2376
+ id: e.id ?? "",
2377
+ durationMs: e.durationMs,
2378
+ outputSize: e.outputBytes ?? 0,
2379
+ ok: e.ok,
2380
+ outputBytes: e.outputBytes,
2381
+ outputTokens: e.outputTokens,
2382
+ outputLines: e.outputLines
2383
+ }).catch(() => {
2384
+ });
1965
2385
  broadcast2(clients, { type: "todos.updated", payload: { todos: [...context.todos] } });
2386
+ if (e.name === "task" || e.name === "plan" || e.name === "todo") {
2387
+ void (async () => {
2388
+ try {
2389
+ const taskPath = context.meta["task.path"];
2390
+ if (typeof taskPath === "string" && taskPath) {
2391
+ const { loadTasks } = await import("@wrongstack/core");
2392
+ const file = await loadTasks(taskPath);
2393
+ broadcast2(clients, { type: "tasks.updated", payload: { tasks: file?.tasks ?? [] } });
2394
+ }
2395
+ } catch {
2396
+ }
2397
+ try {
2398
+ const planPath = context.meta["plan.path"];
2399
+ if (typeof planPath === "string" && planPath) {
2400
+ const { loadPlan } = await import("@wrongstack/core");
2401
+ const plan = await loadPlan(planPath);
2402
+ broadcast2(clients, { type: "plan.updated", payload: { plan: plan ?? { version: 1, sessionId: context.session?.id ?? "", updatedAt: (/* @__PURE__ */ new Date()).toISOString(), items: [] } } });
2403
+ }
2404
+ } catch {
2405
+ }
2406
+ })();
2407
+ }
1966
2408
  });
1967
2409
  events.on("provider.response", (e) => {
1968
2410
  broadcast2(clients, { type: "provider.response", payload: { usage: e.usage, stopReason: e.stopReason, messageId: "current" } });
@@ -1977,8 +2419,44 @@ function setupEvents(deps) {
1977
2419
  });
1978
2420
  events.on("error", (e) => {
1979
2421
  broadcast2(clients, { type: "error", payload: { phase: e.phase, message: e.err instanceof Error ? e.err.message : String(e.err) } });
2422
+ sessionBridge?.append({
2423
+ type: "error",
2424
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2425
+ message: e.err instanceof Error ? e.err.message : String(e.err),
2426
+ phase: e.phase
2427
+ }).catch(() => {
2428
+ });
2429
+ });
2430
+ events.on("provider.retry", (e) => {
2431
+ sessionBridge?.append({
2432
+ type: "provider_retry",
2433
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2434
+ providerId: e.providerId,
2435
+ attempt: e.attempt,
2436
+ delayMs: e.delayMs,
2437
+ status: e.status,
2438
+ description: e.description
2439
+ }).catch(() => {
2440
+ });
2441
+ });
2442
+ events.on("provider.error", (e) => {
2443
+ sessionBridge?.append({
2444
+ type: "provider_error",
2445
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2446
+ providerId: e.providerId,
2447
+ status: e.status,
2448
+ description: e.description,
2449
+ retryable: e.retryable
2450
+ }).catch(() => {
2451
+ });
2452
+ });
2453
+ events.onPattern("mailbox.received", (_e, payload) => {
2454
+ broadcast2(clients, { type: "mailbox.received", payload });
1980
2455
  });
1981
- const forwardSubagent = (kind, payload) => broadcast2(clients, { type: "subagent.event", payload: { kind, ...payload } });
2456
+ events.onPattern("mailbox.agent_registered", (_e, payload) => {
2457
+ broadcast2(clients, { type: "mailbox.agent_registered", payload });
2458
+ });
2459
+ const forwardSubagent = (kind, payload) => broadcast2(clients, { type: "subagent.event", payload: { kind, sessionId: context.session.id, ...payload } });
1982
2460
  events.on("subagent.spawned", (e) => forwardSubagent("spawned", { subagentId: e.subagentId, taskId: e.taskId, name: e.name, provider: e.provider, model: e.model, description: e.description }));
1983
2461
  events.on("subagent.task_started", (e) => forwardSubagent("task_started", { subagentId: e.subagentId, taskId: e.taskId, description: e.description }));
1984
2462
  events.on("subagent.tool_executed", (e) => forwardSubagent("tool_executed", { subagentId: e.subagentId, toolName: e.name, durationMs: e.durationMs, ok: e.ok }));
@@ -1986,23 +2464,229 @@ function setupEvents(deps) {
1986
2464
  events.on("subagent.budget_extended", (e) => forwardSubagent("budget_extended", { subagentId: e.subagentId, totalExtensions: e.totalExtensions }));
1987
2465
  events.on("subagent.ctx_pct", (e) => forwardSubagent("ctx_pct", { subagentId: e.subagentId, load: e.load, tokens: e.tokens, maxContext: e.maxContext }));
1988
2466
  events.on("subagent.task_completed", (e) => forwardSubagent("task_completed", { subagentId: e.subagentId, status: e.status, iterations: e.iterations, toolCalls: e.toolCalls, finalText: e.finalText, error: e.error ? { kind: e.error.kind, message: e.error.message } : void 0 }));
2467
+ let leaderSpawned = false;
2468
+ events.on("iteration.started", () => {
2469
+ if (!leaderSpawned) {
2470
+ leaderSpawned = true;
2471
+ const provider = context.provider?.id ?? "unknown";
2472
+ forwardSubagent("spawned", {
2473
+ subagentId: "leader",
2474
+ name: "LEADER",
2475
+ provider,
2476
+ model: context.model,
2477
+ description: `Main agent session (${context.session.id})`
2478
+ });
2479
+ }
2480
+ });
2481
+ events.on("tool.executed", (e) => {
2482
+ forwardSubagent("tool_executed", {
2483
+ subagentId: "leader",
2484
+ toolName: e.name,
2485
+ durationMs: e.durationMs,
2486
+ ok: e.ok
2487
+ });
2488
+ });
2489
+ events.on("provider.response", (e) => {
2490
+ if (e.usage?.input != null) {
2491
+ const maxCtx = context.provider.capabilities.maxContext;
2492
+ const pct = maxCtx > 0 ? e.usage.input / maxCtx : 0;
2493
+ const costUsd = context.tokenCounter.estimateCost().total;
2494
+ forwardSubagent("ctx_pct", {
2495
+ subagentId: "leader",
2496
+ load: pct,
2497
+ tokens: e.usage.input,
2498
+ maxContext: maxCtx,
2499
+ costUsd
2500
+ });
2501
+ }
2502
+ });
2503
+ events.on("iteration.completed", () => {
2504
+ if (!leaderSpawned) {
2505
+ leaderSpawned = true;
2506
+ const provider = context.provider?.id ?? "unknown";
2507
+ forwardSubagent("spawned", {
2508
+ subagentId: "leader",
2509
+ name: "LEADER",
2510
+ provider,
2511
+ model: context.model,
2512
+ description: `Main agent session (${context.session.id})`
2513
+ });
2514
+ }
2515
+ });
2516
+ events.onPattern("mailbox.*", (eventName, payload) => {
2517
+ broadcast2(clients, { type: "mailbox.event", payload: { event: eventName, ...payload } });
2518
+ });
2519
+ events.onPattern("brain.*", (eventName, payload) => {
2520
+ broadcast2(clients, { type: "brain.event", payload: { event: eventName, ...payload } });
2521
+ });
2522
+ const globalRoot = globalConfigPath ? path6.dirname(globalConfigPath) : void 0;
2523
+ if (globalRoot) {
2524
+ const statusInterval = setInterval(async () => {
2525
+ try {
2526
+ const { SessionRegistry } = await import("@wrongstack/core");
2527
+ const registry = new SessionRegistry(globalRoot);
2528
+ const sessions = await registry.list();
2529
+ const live = sessions.filter((s) => s.status !== "stale").map((s) => ({
2530
+ sessionId: s.sessionId,
2531
+ projectName: s.projectName,
2532
+ projectSlug: s.projectSlug,
2533
+ projectRoot: s.projectRoot,
2534
+ workingDir: s.workingDir,
2535
+ gitBranch: s.gitBranch,
2536
+ status: s.status,
2537
+ pid: s.pid,
2538
+ startedAt: s.startedAt,
2539
+ agentCount: s.agentCount,
2540
+ agents: (s.agents ?? []).map((a) => ({
2541
+ id: a.id,
2542
+ name: a.name,
2543
+ status: a.status,
2544
+ currentTool: a.currentTool,
2545
+ iterations: a.iterations,
2546
+ toolCalls: a.toolCalls,
2547
+ lastActivityAt: a.lastActivityAt
2548
+ }))
2549
+ }));
2550
+ broadcast2(clients, { type: "sessions.status_update", payload: { sessions: live } });
2551
+ } catch {
2552
+ }
2553
+ }, 5e3);
2554
+ if (statusInterval.unref) statusInterval.unref();
2555
+ }
1989
2556
  }
1990
2557
 
1991
- // src/server/token-estimator.ts
1992
- function estimateTokens(s) {
1993
- return Math.ceil(s.length / 4);
1994
- }
1995
- function stringifyContent(c) {
1996
- if (typeof c === "string") return c;
1997
- try {
1998
- return JSON.stringify(c);
1999
- } catch {
2000
- return String(c);
2001
- }
2558
+ // src/server/custom-context-modes.ts
2559
+ import { listContextWindowModes, atomicWrite as atomicWrite4 } from "@wrongstack/core";
2560
+ import * as fs5 from "fs/promises";
2561
+ import * as path7 from "path";
2562
+ var STORE_FILENAME = "custom-context-modes.json";
2563
+ function storePath(wrongstackDir) {
2564
+ return path7.join(wrongstackDir, STORE_FILENAME);
2002
2565
  }
2003
- function messageTokens(content) {
2004
- if (typeof content === "string") return estimateTokens(content);
2005
- if (!Array.isArray(content)) return 0;
2566
+ var BUILTIN_IDS = /* @__PURE__ */ new Set(["balanced", "frugal", "deep", "archival"]);
2567
+ function createCustomModeStore(wrongstackDir) {
2568
+ const modes = /* @__PURE__ */ new Map();
2569
+ const load2 = async () => {
2570
+ modes.clear();
2571
+ try {
2572
+ const raw = await fs5.readFile(storePath(wrongstackDir), "utf8");
2573
+ const parsed = JSON.parse(raw);
2574
+ if (Array.isArray(parsed.modes)) {
2575
+ for (const m of parsed.modes) {
2576
+ if (m.id && !BUILTIN_IDS.has(m.id)) {
2577
+ modes.set(m.id, { ...m, custom: true });
2578
+ }
2579
+ }
2580
+ }
2581
+ } catch {
2582
+ }
2583
+ };
2584
+ const save2 = async () => {
2585
+ const arr = [...modes.values()];
2586
+ const json = JSON.stringify({ modes: arr }, null, 2);
2587
+ await atomicWrite4(storePath(wrongstackDir), json);
2588
+ };
2589
+ const create = (mode) => {
2590
+ if (!mode.id || typeof mode.id !== "string") {
2591
+ return { ok: false, error: "id is required" };
2592
+ }
2593
+ if (BUILTIN_IDS.has(mode.id)) {
2594
+ return { ok: false, error: `Cannot override built-in mode "${mode.id}"` };
2595
+ }
2596
+ if (modes.has(mode.id)) {
2597
+ return { ok: false, error: `Mode "${mode.id}" already exists` };
2598
+ }
2599
+ if (!mode.name) {
2600
+ return { ok: false, error: "name is required" };
2601
+ }
2602
+ const entry = {
2603
+ id: mode.id,
2604
+ name: mode.name,
2605
+ description: mode.description || "",
2606
+ thresholds: {
2607
+ warn: mode.thresholds?.warn ?? 0.6,
2608
+ soft: mode.thresholds?.soft ?? 0.75,
2609
+ hard: mode.thresholds?.hard ?? 0.9
2610
+ },
2611
+ aggressiveOn: mode.aggressiveOn || "soft",
2612
+ preserveK: mode.preserveK ?? 10,
2613
+ eliseThreshold: mode.eliseThreshold ?? 2e3,
2614
+ targetLoad: mode.targetLoad ?? 0.65,
2615
+ custom: true
2616
+ };
2617
+ modes.set(mode.id, entry);
2618
+ void save2();
2619
+ return { ok: true };
2620
+ };
2621
+ const update = (id, patch) => {
2622
+ if (BUILTIN_IDS.has(id)) {
2623
+ return { ok: false, error: `Cannot modify built-in mode "${id}"` };
2624
+ }
2625
+ const existing = modes.get(id);
2626
+ if (!existing) {
2627
+ return { ok: false, error: `Mode "${id}" not found` };
2628
+ }
2629
+ const next = { ...existing };
2630
+ if (patch.name !== void 0) next.name = patch.name;
2631
+ if (patch.description !== void 0) next.description = patch.description;
2632
+ if (patch.thresholds) {
2633
+ next.thresholds = {
2634
+ warn: patch.thresholds.warn ?? existing.thresholds.warn,
2635
+ soft: patch.thresholds.soft ?? existing.thresholds.soft,
2636
+ hard: patch.thresholds.hard ?? existing.thresholds.hard
2637
+ };
2638
+ }
2639
+ if (patch.preserveK !== void 0) next.preserveK = patch.preserveK;
2640
+ if (patch.eliseThreshold !== void 0) next.eliseThreshold = patch.eliseThreshold;
2641
+ if (patch.targetLoad !== void 0) next.targetLoad = patch.targetLoad;
2642
+ if (patch.aggressiveOn !== void 0) next.aggressiveOn = patch.aggressiveOn;
2643
+ modes.set(id, next);
2644
+ void save2();
2645
+ return { ok: true };
2646
+ };
2647
+ const remove = (id) => {
2648
+ if (BUILTIN_IDS.has(id)) {
2649
+ return { ok: false, error: `Cannot delete built-in mode "${id}"` };
2650
+ }
2651
+ if (!modes.delete(id)) {
2652
+ return { ok: false, error: `Mode "${id}" not found` };
2653
+ }
2654
+ void save2();
2655
+ return { ok: true };
2656
+ };
2657
+ const list = () => {
2658
+ const builtins = listContextWindowModes().map((m) => ({
2659
+ id: m.id,
2660
+ name: m.name,
2661
+ description: m.description,
2662
+ thresholds: { ...m.thresholds },
2663
+ aggressiveOn: m.aggressiveOn,
2664
+ preserveK: m.preserveK,
2665
+ eliseThreshold: m.eliseThreshold,
2666
+ targetLoad: m.targetLoad,
2667
+ custom: false
2668
+ }));
2669
+ const custom = [...modes.values()];
2670
+ return [...builtins, ...custom];
2671
+ };
2672
+ return { modes, load: load2, save: save2, create, update, remove, list };
2673
+ }
2674
+
2675
+ // src/server/token-estimator.ts
2676
+ function estimateTokens(s) {
2677
+ return Math.ceil(s.length / 4);
2678
+ }
2679
+ function stringifyContent(c) {
2680
+ if (typeof c === "string") return c;
2681
+ try {
2682
+ return JSON.stringify(c);
2683
+ } catch {
2684
+ return String(c);
2685
+ }
2686
+ }
2687
+ function messageTokens(content) {
2688
+ if (typeof content === "string") return estimateTokens(content);
2689
+ if (!Array.isArray(content)) return 0;
2006
2690
  let tk = 0;
2007
2691
  for (const b of content) {
2008
2692
  if (b.type === "text") tk += estimateTokens(b.text ?? "");
@@ -2057,16 +2741,32 @@ async function startWebUI(opts = {}) {
2057
2741
  httpPort = await findFreePort(wsHost, requestedHttpPort);
2058
2742
  wsPort = await findFreePort(wsHost, requestedWsPort, { exclude: /* @__PURE__ */ new Set([httpPort]) });
2059
2743
  if (httpPort !== requestedHttpPort) {
2060
- console.warn(`[WebUI] HTTP port ${requestedHttpPort} in use \u2192 using ${httpPort}`);
2744
+ console.warn(JSON.stringify({
2745
+ level: "warn",
2746
+ event: "webui.port_reassigned",
2747
+ protocol: "HTTP",
2748
+ requested: requestedHttpPort,
2749
+ assigned: httpPort,
2750
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2751
+ }));
2061
2752
  }
2062
2753
  if (wsPort !== requestedWsPort) {
2063
- console.warn(`[WebUI] WS port ${requestedWsPort} in use \u2192 using ${wsPort}`);
2754
+ console.warn(JSON.stringify({
2755
+ level: "warn",
2756
+ event: "webui.port_reassigned",
2757
+ protocol: "WS",
2758
+ requested: requestedWsPort,
2759
+ assigned: wsPort,
2760
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2761
+ }));
2064
2762
  }
2065
2763
  }
2066
2764
  console.log("[WebUI] Starting backend services...");
2067
2765
  const boot = await bootConfig();
2068
- const { config: baseConfig, vault, globalConfigPath, projectRoot, wpaths, logger } = boot;
2766
+ const { config: baseConfig, vault, globalConfigPath, wpaths, logger } = boot;
2069
2767
  let config = baseConfig;
2768
+ let projectRoot = boot.projectRoot;
2769
+ let workingDir = projectRoot;
2070
2770
  let configWriteLock = Promise.resolve();
2071
2771
  console.log("[WebUI] Config loaded:", config.provider ?? "(none)", "/", config.model ?? "(none)");
2072
2772
  if (!config.provider && config.providers && typeof config.providers === "object" && config.providers !== null && !Array.isArray(config.providers) && Object.keys(config.providers).length > 0) {
@@ -2090,7 +2790,12 @@ async function startWebUI(opts = {}) {
2090
2790
  for (const f of factories) providerRegistry.register(f);
2091
2791
  console.log("[WebUI] Provider registry loaded:", providerRegistry.list().length, "providers");
2092
2792
  } catch (err) {
2093
- console.warn("[WebUI] Failed to load provider registry:", err);
2793
+ console.warn(JSON.stringify({
2794
+ level: "warn",
2795
+ event: "webui.provider_registry_load_failed",
2796
+ message: err instanceof Error ? err.message : String(err),
2797
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2798
+ }));
2094
2799
  }
2095
2800
  const toolRegistry = new ToolRegistry();
2096
2801
  toolRegistry.registerAllOrThrow([...builtinToolsPack.tools ?? []], builtinToolsPack.name);
@@ -2101,10 +2806,13 @@ async function startWebUI(opts = {}) {
2101
2806
  toolRegistry.register(searchMemoryTool(memoryStore));
2102
2807
  toolRegistry.register(relatedMemoryTool(memoryStore));
2103
2808
  }
2104
- console.log("[WebUI] Tool registry loaded:", toolRegistry.list().length, "tools");
2105
2809
  const events = new EventBus();
2106
2810
  events.setLogger(logger);
2107
- const sessionStore = new DefaultSessionStore2({ dir: wpaths.projectSessions });
2811
+ toolRegistry.register(makeMailboxTool({ projectDir: wpaths.projectDir, events }));
2812
+ toolRegistry.register(makeMailSendTool({ projectDir: wpaths.projectDir, events }));
2813
+ toolRegistry.register(makeMailInboxTool({ projectDir: wpaths.projectDir, events }));
2814
+ console.log("[WebUI] Tool registry loaded:", toolRegistry.list().length, "tools");
2815
+ let sessionStore = new DefaultSessionStore2({ dir: wpaths.projectSessions });
2108
2816
  sessionStore.prune(DEFAULT_SESSION_PRUNE_DAYS).then((count) => {
2109
2817
  if (count > 0) logger.info(`Pruned ${count} old session${count === 1 ? "" : "s"}.`);
2110
2818
  }).catch(() => void 0);
@@ -2118,6 +2826,42 @@ async function startWebUI(opts = {}) {
2118
2826
  });
2119
2827
  let sessionStartedAt = Date.now();
2120
2828
  console.log("[WebUI] Session created:", session.id);
2829
+ try {
2830
+ await touchProjectEntry(projectRoot, workingDir);
2831
+ } catch {
2832
+ }
2833
+ let statusTracker;
2834
+ try {
2835
+ const registry = getSessionRegistry(wpaths.globalRoot);
2836
+ await registry.register({
2837
+ sessionId: session.id,
2838
+ projectSlug: wpaths.projectSlug,
2839
+ projectRoot,
2840
+ projectName: path8.basename(projectRoot),
2841
+ workingDir,
2842
+ pid: process.pid,
2843
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
2844
+ });
2845
+ statusTracker = new AgentStatusTracker({ events, registry });
2846
+ statusTracker.start();
2847
+ const stopTracking = async () => {
2848
+ try {
2849
+ await registry.markClosing();
2850
+ statusTracker?.stop();
2851
+ } catch {
2852
+ }
2853
+ };
2854
+ process.once("beforeExit", () => {
2855
+ void stopTracking();
2856
+ });
2857
+ process.once("SIGINT", () => {
2858
+ void stopTracking();
2859
+ });
2860
+ process.once("SIGTERM", () => {
2861
+ void stopTracking();
2862
+ });
2863
+ } catch {
2864
+ }
2121
2865
  const tokenCounter = new DefaultTokenCounter2({
2122
2866
  registry: modelsRegistry,
2123
2867
  providerId: config.provider
@@ -2126,6 +2870,13 @@ async function startWebUI(opts = {}) {
2126
2870
  const activeMode = await modeStore.getActiveMode();
2127
2871
  let modeId = activeMode?.id ?? "default";
2128
2872
  const modePrompt = activeMode?.prompt ?? "";
2873
+ const customModeStore = createCustomModeStore(wpaths.configDir);
2874
+ await customModeStore.load();
2875
+ console.log(
2876
+ "[WebUI] Custom context modes loaded:",
2877
+ customModeStore.list().filter((m) => m.custom).length,
2878
+ "custom"
2879
+ );
2129
2880
  const resolvedModel = await modelsRegistry.getModel(config.provider, config.model);
2130
2881
  const modelCapabilities = resolvedModel?.capabilities ? {
2131
2882
  maxContextTokens: resolvedModel.capabilities.maxContext,
@@ -2142,12 +2893,19 @@ async function startWebUI(opts = {}) {
2142
2893
  modePrompt,
2143
2894
  modelCapabilities
2144
2895
  });
2896
+ let onlineAgents = [];
2897
+ try {
2898
+ const systemMailbox = new GlobalMailbox2(wpaths.projectDir);
2899
+ onlineAgents = await systemMailbox.getAgentStatuses();
2900
+ } catch {
2901
+ }
2145
2902
  const systemPrompt = await systemPromptBuilder.build({
2146
2903
  cwd: projectRoot,
2147
2904
  projectRoot,
2148
2905
  tools: toolRegistry.list(),
2149
2906
  provider: config.provider,
2150
- model: config.model
2907
+ model: config.model,
2908
+ onlineAgents
2151
2909
  });
2152
2910
  let provider;
2153
2911
  if (!needsProvider) {
@@ -2164,7 +2922,12 @@ async function startWebUI(opts = {}) {
2164
2922
  provider = makeProviderFromConfig(config.provider, cfgWithType);
2165
2923
  }
2166
2924
  } catch (err) {
2167
- console.error("[WebUI] Failed to create provider:", err);
2925
+ console.error(JSON.stringify({
2926
+ level: "error",
2927
+ event: "webui.provider_create_failed",
2928
+ message: err instanceof Error ? err.message : String(err),
2929
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2930
+ }));
2168
2931
  throw err;
2169
2932
  }
2170
2933
  } else {
@@ -2181,7 +2944,12 @@ async function startWebUI(opts = {}) {
2181
2944
  });
2182
2945
  console.log("[WebUI] Using saved provider:", firstKey);
2183
2946
  } catch (err) {
2184
- console.error("[WebUI] Could not create provider stub:", err);
2947
+ console.error(JSON.stringify({
2948
+ level: "error",
2949
+ event: "webui.provider_stub_create_failed",
2950
+ message: err instanceof Error ? err.message : String(err),
2951
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2952
+ }));
2185
2953
  throw err;
2186
2954
  }
2187
2955
  } else {
@@ -2196,13 +2964,160 @@ async function startWebUI(opts = {}) {
2196
2964
  session,
2197
2965
  signal: new AbortController().signal,
2198
2966
  tokenCounter,
2199
- cwd: projectRoot,
2967
+ cwd: workingDir,
2200
2968
  projectRoot,
2201
2969
  model: config.model
2202
2970
  });
2203
2971
  const initialContextPolicy = resolveContextWindowPolicy(config.context);
2204
2972
  context.meta["contextWindowMode"] = initialContextPolicy.id;
2205
2973
  context.meta["contextWindowPolicy"] = initialContextPolicy;
2974
+ {
2975
+ const autonomyCfg = config.autonomy ?? {};
2976
+ const rawMode = autonomyCfg["defaultMode"];
2977
+ context.meta["autonomy"] = rawMode === "suggest" || rawMode === "auto" ? rawMode : "off";
2978
+ context.meta["autonomyDelayMs"] = autonomyCfg["autoProceedDelayMs"] ?? 45e3;
2979
+ context.meta["autoProceedMaxIterations"] = autonomyCfg["autoProceedMaxIterations"] ?? 50;
2980
+ context.meta["yolo"] = autonomyCfg["yolo"] ?? config.yolo ?? false;
2981
+ context.meta["chime"] = autonomyCfg["chime"] ?? false;
2982
+ context.meta["confirmExit"] = autonomyCfg["confirmExit"] !== false;
2983
+ context.meta["streamFleet"] = autonomyCfg["streamFleet"] !== false;
2984
+ context.meta["enhanceEnabled"] = autonomyCfg["enhance"] ?? true;
2985
+ context.meta["enhanceDelayMs"] = autonomyCfg["enhanceDelayMs"] ?? 6e4;
2986
+ context.meta["enhanceLanguage"] = autonomyCfg["enhanceLanguage"] ?? "original";
2987
+ context.meta["nextPrediction"] = config.nextPrediction ?? false;
2988
+ context.meta["featureMcp"] = config.features.mcp !== false;
2989
+ context.meta["featurePlugins"] = config.features.plugins !== false;
2990
+ context.meta["featureMemory"] = config.features.memory !== false;
2991
+ context.meta["featureSkills"] = config.features.skills !== false;
2992
+ context.meta["featureModelsRegistry"] = config.features.modelsRegistry !== false;
2993
+ context.meta["indexOnStart"] = config.indexing?.onSessionStart !== false;
2994
+ context.meta["contextAutoCompact"] = config.context?.autoCompact !== false;
2995
+ context.meta["contextStrategy"] = config.context?.strategy ?? "hybrid";
2996
+ context.meta["logLevel"] = config.log?.level ?? "info";
2997
+ context.meta["auditLevel"] = config.session?.auditLevel ?? "standard";
2998
+ context.meta["maxIterations"] = config.tools?.maxIterations ?? 500;
2999
+ }
3000
+ const PREF_KEYS = [
3001
+ "autonomy",
3002
+ "autonomyDelayMs",
3003
+ "autoProceedMaxIterations",
3004
+ "yolo",
3005
+ "maxIterations",
3006
+ "chime",
3007
+ "confirmExit",
3008
+ "streamFleet",
3009
+ "nextPrediction",
3010
+ "enhanceEnabled",
3011
+ "enhanceDelayMs",
3012
+ "enhanceLanguage",
3013
+ "featureMcp",
3014
+ "featurePlugins",
3015
+ "featureMemory",
3016
+ "featureSkills",
3017
+ "featureModelsRegistry",
3018
+ "indexOnStart",
3019
+ "contextAutoCompact",
3020
+ "contextStrategy",
3021
+ "logLevel",
3022
+ "auditLevel"
3023
+ ];
3024
+ const prefSnapshot = () => {
3025
+ const snapshot = {};
3026
+ for (const k of PREF_KEYS) {
3027
+ if (k in context.meta) snapshot[k] = context.meta[k];
3028
+ }
3029
+ return snapshot;
3030
+ };
3031
+ const persistPrefsToConfig = async (payload) => {
3032
+ const write = async () => {
3033
+ let raw;
3034
+ try {
3035
+ raw = await fs6.readFile(globalConfigPath, "utf8");
3036
+ } catch {
3037
+ raw = "{}";
3038
+ }
3039
+ let parsed;
3040
+ try {
3041
+ parsed = JSON.parse(raw);
3042
+ } catch {
3043
+ logger.warn(`prefs: refusing to overwrite corrupt config at ${globalConfigPath}`);
3044
+ return;
3045
+ }
3046
+ const decrypted = decryptConfigSecrets2(parsed, vault);
3047
+ const autonomyCfg = decrypted.autonomy ?? {};
3048
+ let autonomyTouched = false;
3049
+ const setAutonomy = (key, val) => {
3050
+ autonomyCfg[key] = val;
3051
+ autonomyTouched = true;
3052
+ };
3053
+ if (typeof payload["autonomy"] === "string" && ["off", "suggest", "auto"].includes(payload["autonomy"])) {
3054
+ setAutonomy("defaultMode", payload["autonomy"]);
3055
+ }
3056
+ if (typeof payload["autonomyDelayMs"] === "number") setAutonomy("autoProceedDelayMs", payload["autonomyDelayMs"]);
3057
+ if (typeof payload["autoProceedMaxIterations"] === "number") setAutonomy("autoProceedMaxIterations", payload["autoProceedMaxIterations"]);
3058
+ if (typeof payload["yolo"] === "boolean") setAutonomy("yolo", payload["yolo"]);
3059
+ if (typeof payload["chime"] === "boolean") setAutonomy("chime", payload["chime"]);
3060
+ if (typeof payload["confirmExit"] === "boolean") setAutonomy("confirmExit", payload["confirmExit"]);
3061
+ if (typeof payload["streamFleet"] === "boolean") setAutonomy("streamFleet", payload["streamFleet"]);
3062
+ if (typeof payload["enhanceEnabled"] === "boolean") setAutonomy("enhance", payload["enhanceEnabled"]);
3063
+ if (typeof payload["enhanceDelayMs"] === "number") setAutonomy("enhanceDelayMs", payload["enhanceDelayMs"]);
3064
+ if (typeof payload["enhanceLanguage"] === "string") setAutonomy("enhanceLanguage", payload["enhanceLanguage"]);
3065
+ if (autonomyTouched) decrypted.autonomy = autonomyCfg;
3066
+ if (typeof payload["nextPrediction"] === "boolean") decrypted.nextPrediction = payload["nextPrediction"];
3067
+ const FEATURE_MAP = {
3068
+ featureMcp: "mcp",
3069
+ featurePlugins: "plugins",
3070
+ featureMemory: "memory",
3071
+ featureSkills: "skills",
3072
+ featureModelsRegistry: "modelsRegistry"
3073
+ };
3074
+ for (const [prefKey, cfgKey] of Object.entries(FEATURE_MAP)) {
3075
+ if (typeof payload[prefKey] === "boolean") {
3076
+ const feats = decrypted.features ?? {};
3077
+ feats[cfgKey] = payload[prefKey];
3078
+ decrypted.features = feats;
3079
+ }
3080
+ }
3081
+ if (typeof payload["contextAutoCompact"] === "boolean" || typeof payload["contextStrategy"] === "string") {
3082
+ const ctxCfg = decrypted.context ?? {};
3083
+ if (typeof payload["contextAutoCompact"] === "boolean") ctxCfg.autoCompact = payload["contextAutoCompact"];
3084
+ if (typeof payload["contextStrategy"] === "string") ctxCfg.strategy = payload["contextStrategy"];
3085
+ decrypted.context = ctxCfg;
3086
+ }
3087
+ if (typeof payload["logLevel"] === "string") {
3088
+ const logCfg = decrypted.log ?? {};
3089
+ logCfg.level = payload["logLevel"];
3090
+ decrypted.log = logCfg;
3091
+ }
3092
+ if (typeof payload["auditLevel"] === "string") {
3093
+ const sessionCfg = decrypted.session ?? {};
3094
+ sessionCfg.auditLevel = payload["auditLevel"];
3095
+ decrypted.session = sessionCfg;
3096
+ }
3097
+ if (typeof payload["indexOnStart"] === "boolean") {
3098
+ const indexingCfg = decrypted.indexing ?? {};
3099
+ indexingCfg.onSessionStart = payload["indexOnStart"];
3100
+ decrypted.indexing = indexingCfg;
3101
+ }
3102
+ if (typeof payload["maxIterations"] === "number") {
3103
+ const toolsCfg = decrypted.tools ?? {};
3104
+ toolsCfg.maxIterations = payload["maxIterations"];
3105
+ decrypted.tools = toolsCfg;
3106
+ }
3107
+ const encrypted = encryptConfigSecrets2(decrypted, vault);
3108
+ await atomicWrite5(globalConfigPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
3109
+ };
3110
+ const next = configWriteLock.then(write);
3111
+ configWriteLock = next.then(
3112
+ () => void 0,
3113
+ () => void 0
3114
+ );
3115
+ try {
3116
+ await next;
3117
+ } catch (err) {
3118
+ logger.warn(`prefs: failed to persist to config: ${errMessage(err)}`);
3119
+ }
3120
+ };
2206
3121
  const pipelines = createDefaultPipelines();
2207
3122
  const collabBus = new CollaborationBus();
2208
3123
  const collabPause = collabPauseMiddleware(collabBus, { logger });
@@ -2266,8 +3181,9 @@ async function startWebUI(opts = {}) {
2266
3181
  }
2267
3182
  const secretScrubber = container.resolve(TOKENS2.SecretScrubber);
2268
3183
  const renderer = container.has(TOKENS2.Renderer) ? container.resolve(TOKENS2.Renderer) : void 0;
3184
+ const permissionPolicy = container.resolve(TOKENS2.PermissionPolicy);
2269
3185
  const toolExecutor = new ToolExecutor(toolRegistry, {
2270
- permissionPolicy: container.resolve(TOKENS2.PermissionPolicy),
3186
+ permissionPolicy,
2271
3187
  secretScrubber,
2272
3188
  renderer,
2273
3189
  events,
@@ -2291,6 +3207,78 @@ async function startWebUI(opts = {}) {
2291
3207
  toolExecutor
2292
3208
  });
2293
3209
  console.log("[WebUI] Agent initialized");
3210
+ const brainSettings = { maxAutoRisk: "medium" };
3211
+ const autonomousBrain = {
3212
+ decide: (request) => createAutonomyBrain({
3213
+ provider,
3214
+ model: context.model,
3215
+ maxAutoRisk: "all"
3216
+ // the tiered ceiling gates risk — keep inner permissive
3217
+ }).decide(request)
3218
+ };
3219
+ const brain = new ObservableBrainArbiter(
3220
+ createTieredBrainArbiter({
3221
+ policy: new DefaultBrainArbiter(),
3222
+ autonomous: autonomousBrain,
3223
+ getMaxAutoRisk: () => brainSettings.maxAutoRisk
3224
+ }),
3225
+ events
3226
+ );
3227
+ container.bind(TOKENS2.BrainArbiter, () => brain);
3228
+ const brainMailbox = new GlobalMailbox2(wpaths.projectDir, events);
3229
+ const brainMonitor = new BrainMonitor({
3230
+ events,
3231
+ brain,
3232
+ intervene: async ({ subject, body }) => {
3233
+ const tag = mailboxSessionTag(session.id);
3234
+ await brainMailbox.send({
3235
+ from: `brain@${tag}`,
3236
+ to: `leader@${tag}`,
3237
+ type: "steer",
3238
+ subject,
3239
+ body,
3240
+ priority: "high"
3241
+ });
3242
+ }
3243
+ });
3244
+ brainMonitor.start();
3245
+ console.log("[WebUI] Brain initialized (tiered policy \u2192 LLM, monitor active)");
3246
+ const brainLog = [];
3247
+ const pushBrainLog = (entry) => {
3248
+ brainLog.push(entry);
3249
+ if (brainLog.length > 20) brainLog.shift();
3250
+ };
3251
+ events.on(
3252
+ "brain.decision_answered",
3253
+ (e) => pushBrainLog({
3254
+ at: e.at,
3255
+ kind: "answered",
3256
+ question: e.request.question,
3257
+ outcome: e.decision.type === "answer" ? e.decision.optionId ?? e.decision.text : ""
3258
+ })
3259
+ );
3260
+ events.on(
3261
+ "brain.decision_ask_human",
3262
+ (e) => pushBrainLog({ at: e.at, kind: "ask_human", question: e.request.question, outcome: "needs human judgement" })
3263
+ );
3264
+ events.on(
3265
+ "brain.decision_denied",
3266
+ (e) => pushBrainLog({
3267
+ at: e.at,
3268
+ kind: "denied",
3269
+ question: e.request.question,
3270
+ outcome: e.decision.type === "deny" ? e.decision.reason : ""
3271
+ })
3272
+ );
3273
+ events.on(
3274
+ "brain.intervention",
3275
+ (e) => pushBrainLog({
3276
+ at: e.at,
3277
+ kind: "intervention",
3278
+ question: e.request.question,
3279
+ outcome: e.intervened ? "steered the agent" : "observed (no action)"
3280
+ })
3281
+ );
2294
3282
  const autoPhaseHandler = new AutoPhaseWebSocketHandler(
2295
3283
  agent,
2296
3284
  context,
@@ -2329,15 +3317,16 @@ async function startWebUI(opts = {}) {
2329
3317
  inputCost,
2330
3318
  outputCost,
2331
3319
  cacheReadCost,
2332
- projectName: path4.basename(projectRoot) || projectRoot,
2333
- cwd: projectRoot,
3320
+ projectName: path8.basename(projectRoot) || projectRoot,
3321
+ projectRoot,
3322
+ cwd: workingDir,
2334
3323
  mode: modeId,
2335
3324
  contextMode: String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID),
2336
3325
  wsToken
2337
3326
  };
2338
3327
  }
2339
3328
  const wsToken = generateAuthToken();
2340
- console.log(`[WebUI] WS auth token: ${wsToken.slice(0, 4)}\u2026${wsToken.slice(-4)} (masked)`);
3329
+ console.log("[WebUI] WS auth token generated (redacted from logs)");
2341
3330
  const verifyClient2 = (info) => verifyClient({
2342
3331
  origin: info.origin,
2343
3332
  url: info.req.url ?? "",
@@ -2360,6 +3349,13 @@ async function startWebUI(opts = {}) {
2360
3349
  maxPayload: WS_MAX_PAYLOAD
2361
3350
  }) : null;
2362
3351
  const clients = /* @__PURE__ */ new Map();
3352
+ context.onWorkingDirChanged((newDir) => {
3353
+ workingDir = newDir;
3354
+ broadcast(clients, {
3355
+ type: "working_dir.changed",
3356
+ payload: { cwd: newDir, projectRoot }
3357
+ });
3358
+ });
2363
3359
  const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
2364
3360
  const RATE_LIMIT_WINDOW_MS = 6e4;
2365
3361
  const rateLimits = /* @__PURE__ */ new Map();
@@ -2384,9 +3380,15 @@ async function startWebUI(opts = {}) {
2384
3380
  const handleConnection = (ws) => {
2385
3381
  const client = { ws, sessionId: session.id, connectedAt: Date.now() };
2386
3382
  clients.set(ws, client);
2387
- console.log("[WebUI] Client connected, total:", clients.size);
2388
3383
  void sessionStartPayload().then((payload) => {
2389
3384
  send(ws, { type: "session.start", payload });
3385
+ }).catch((err) => {
3386
+ console.warn(JSON.stringify({
3387
+ level: "warn",
3388
+ event: "webui.session_start_payload_failed",
3389
+ message: err instanceof Error ? err.message : String(err),
3390
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3391
+ }));
2390
3392
  });
2391
3393
  autoPhaseHandler.addClient(ws);
2392
3394
  worktreeHandler.addClient(ws);
@@ -2418,47 +3420,130 @@ async function startWebUI(opts = {}) {
2418
3420
  await handleMessage(ws, client, rawObj);
2419
3421
  }
2420
3422
  } catch (err) {
2421
- console.error("[WebUI] Failed to parse message", err);
3423
+ console.error(JSON.stringify({
3424
+ level: "error",
3425
+ event: "webui.ws_message_parse_failed",
3426
+ message: err instanceof Error ? err.message : String(err),
3427
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3428
+ }));
2422
3429
  }
2423
3430
  });
2424
3431
  ws.on("close", () => {
2425
3432
  clients.delete(ws);
2426
3433
  rateLimits.delete(String(ws));
2427
- console.log("[WebUI] Client disconnected, total:", clients.size);
2428
3434
  if (pendingConfirms.size > 0) {
2429
- for (const [id, resolve3] of pendingConfirms) {
2430
- resolve3("no");
3435
+ for (const [id, resolve5] of pendingConfirms) {
3436
+ resolve5("no");
2431
3437
  pendingConfirms.delete(id);
2432
3438
  }
2433
3439
  }
2434
3440
  });
2435
3441
  ws.on("error", (err) => {
2436
- console.warn("[WebUI] Client socket error:", err.message);
3442
+ console.warn(JSON.stringify({
3443
+ level: "warn",
3444
+ event: "webui.client_socket_error",
3445
+ message: err.message,
3446
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3447
+ }));
2437
3448
  });
2438
3449
  };
3450
+ const sessionLogging = resolveSessionLoggingConfig(
3451
+ config
3452
+ );
3453
+ const sessionBridge = createSessionEventBridge(
3454
+ () => context.session ?? session,
3455
+ sessionLogging.auditLevel,
3456
+ { sampling: sessionLogging.sampling }
3457
+ );
2439
3458
  let eventsArmed = false;
2440
3459
  const armOnce = (label) => {
2441
3460
  if (eventsArmed) return;
2442
3461
  eventsArmed = true;
2443
3462
  console.log(`[WebUI] Backend ready (${label})`);
2444
- setupEvents({ events, broadcast, clients, config, context, pendingConfirms });
3463
+ setupEvents({ events, broadcast, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge });
2445
3464
  };
2446
3465
  wssPrimary.on("listening", () => armOnce(`${wsHost}:${wsPort}`));
2447
3466
  wssPrimary.on("connection", handleConnection);
2448
3467
  wssPrimary.on("error", (err) => {
2449
- console.error(`[WebUI] Primary WS server error (${wsHost}):`, err);
3468
+ console.error(JSON.stringify({
3469
+ level: "error",
3470
+ event: "webui.ws_server_error",
3471
+ host: wsHost,
3472
+ message: err instanceof Error ? err.message : String(err),
3473
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3474
+ }));
2450
3475
  });
2451
3476
  if (wssSecondary) {
2452
3477
  wssSecondary.on("listening", () => armOnce(`::1:${wsPort}`));
2453
3478
  wssSecondary.on("connection", handleConnection);
2454
3479
  wssSecondary.on("error", (err) => {
2455
3480
  if (err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL") {
2456
- console.warn("[WebUI] IPv6 loopback not available, v4-only:", err.code);
3481
+ console.warn(JSON.stringify({
3482
+ level: "warn",
3483
+ event: "webui.ipv6_unavailable",
3484
+ code: err.code,
3485
+ message: err.message,
3486
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3487
+ }));
2457
3488
  } else {
2458
- console.error("[WebUI] Secondary WS server error (::1):", err);
3489
+ console.error(JSON.stringify({
3490
+ level: "error",
3491
+ event: "webui.ws_server_error",
3492
+ host: "::1",
3493
+ message: err.message,
3494
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3495
+ }));
2459
3496
  }
2460
3497
  });
2461
3498
  }
3499
+ async function touchProjectEntry(root, workDir) {
3500
+ const resolved = path8.resolve(root);
3501
+ const manifest = await loadManifest(globalConfigPath);
3502
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3503
+ const existing = manifest.projects.find((p) => path8.resolve(p.root) === resolved);
3504
+ if (existing) {
3505
+ existing.lastSeen = now;
3506
+ if (workDir) existing.lastWorkingDir = path8.resolve(workDir);
3507
+ } else {
3508
+ manifest.projects.push({
3509
+ name: path8.basename(resolved),
3510
+ root: resolved,
3511
+ slug: generateProjectSlug(resolved),
3512
+ createdAt: now,
3513
+ lastSeen: now,
3514
+ lastWorkingDir: workDir ? path8.resolve(workDir) : void 0
3515
+ });
3516
+ }
3517
+ await saveManifest(manifest, globalConfigPath);
3518
+ await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
3519
+ }
3520
+ function projectsJsonPath(globalConfigPath2) {
3521
+ const base = path8.dirname(globalConfigPath2);
3522
+ return path8.join(base, "projects.json");
3523
+ }
3524
+ async function loadManifest(globalConfigPath2) {
3525
+ try {
3526
+ const raw = await fs6.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3527
+ const parsed = JSON.parse(raw);
3528
+ return { projects: parsed.projects ?? [] };
3529
+ } catch {
3530
+ return { projects: [] };
3531
+ }
3532
+ }
3533
+ async function saveManifest(manifest, globalConfigPath2) {
3534
+ const file = projectsJsonPath(globalConfigPath2);
3535
+ await fs6.mkdir(path8.dirname(file), { recursive: true });
3536
+ await fs6.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
3537
+ }
3538
+ function generateProjectSlug(rootPath) {
3539
+ return projectSlug(rootPath);
3540
+ }
3541
+ 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 });
3545
+ return dir;
3546
+ }
2462
3547
  async function handleMessage(ws, _client, msg) {
2463
3548
  switch (msg.type) {
2464
3549
  // Collaboration messages short-circuit the user/agent flow.
@@ -2486,7 +3571,8 @@ async function startWebUI(opts = {}) {
2486
3571
  runLock = new AbortController();
2487
3572
  const thisRun = runLock;
2488
3573
  try {
2489
- const result = await agent.run(content, { signal: thisRun.signal });
3574
+ const maxIt = typeof context.meta["maxIterations"] === "number" ? context.meta["maxIterations"] : void 0;
3575
+ const result = await agent.run(content, { signal: thisRun.signal, maxIterations: maxIt });
2490
3576
  send(ws, {
2491
3577
  type: "run.result",
2492
3578
  payload: {
@@ -2517,10 +3603,10 @@ async function startWebUI(opts = {}) {
2517
3603
  }
2518
3604
  case "tool.confirm_result": {
2519
3605
  const { id, decision } = msg.payload;
2520
- const resolve3 = pendingConfirms.get(id);
2521
- if (resolve3) {
3606
+ const resolve5 = pendingConfirms.get(id);
3607
+ if (resolve5) {
2522
3608
  pendingConfirms.delete(id);
2523
- resolve3(decision);
3609
+ resolve5(decision);
2524
3610
  }
2525
3611
  break;
2526
3612
  }
@@ -2532,6 +3618,15 @@ async function startWebUI(opts = {}) {
2532
3618
  send(ws, { type: "pong", payload: {} });
2533
3619
  break;
2534
3620
  case "session.new": {
3621
+ try {
3622
+ await session.append({
3623
+ type: "session_end",
3624
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
3625
+ usage: tokenCounter.total()
3626
+ });
3627
+ await session.close();
3628
+ } catch {
3629
+ }
2535
3630
  session = await sessionStore.create({
2536
3631
  id: "",
2537
3632
  title: "",
@@ -2625,29 +3720,35 @@ async function startWebUI(opts = {}) {
2625
3720
  }
2626
3721
  case "context.modes.list": {
2627
3722
  const active = String(context.meta["contextWindowMode"] ?? DEFAULT_CONTEXT_WINDOW_MODE_ID);
3723
+ const allModes = customModeStore.list().map((m) => ({
3724
+ id: m.id,
3725
+ name: m.name,
3726
+ description: m.description,
3727
+ isActive: m.id === active,
3728
+ thresholds: m.thresholds,
3729
+ preserveK: m.preserveK,
3730
+ eliseThreshold: m.eliseThreshold,
3731
+ custom: m.custom === true
3732
+ }));
2628
3733
  send(ws, {
2629
3734
  type: "context.modes.list",
2630
- payload: {
2631
- activeId: active,
2632
- modes: listContextWindowModes().map((m) => ({
2633
- id: m.id,
2634
- name: m.name,
2635
- description: m.description,
2636
- isActive: m.id === active,
2637
- thresholds: m.thresholds,
2638
- preserveK: m.preserveK,
2639
- eliseThreshold: m.eliseThreshold
2640
- }))
2641
- }
3735
+ payload: { activeId: active, modes: allModes }
2642
3736
  });
2643
3737
  break;
2644
3738
  }
2645
3739
  case "context.mode.switch": {
2646
3740
  const { id } = msg.payload;
2647
- const policy = resolveContextWindowPolicy({}, id);
3741
+ let policy = resolveContextWindowPolicy({}, id);
2648
3742
  if (policy.id !== id) {
2649
- sendResult(ws, false, `Unknown context mode "${id}"`);
2650
- break;
3743
+ const customModes = customModeStore.list().filter(
3744
+ (m) => m.custom === true
3745
+ );
3746
+ const custom = customModes.find((m) => m.id === id);
3747
+ if (!custom) {
3748
+ sendResult(ws, false, `Unknown context mode "${id}"`);
3749
+ break;
3750
+ }
3751
+ policy = custom;
2651
3752
  }
2652
3753
  context.meta["contextWindowMode"] = policy.id;
2653
3754
  context.meta["contextWindowPolicy"] = policy;
@@ -2658,6 +3759,48 @@ async function startWebUI(opts = {}) {
2658
3759
  });
2659
3760
  break;
2660
3761
  }
3762
+ case "context.mode.create": {
3763
+ const payload = msg.payload;
3764
+ const result = customModeStore.create({
3765
+ id: payload.id,
3766
+ name: payload.name,
3767
+ description: payload.description,
3768
+ thresholds: payload.thresholds,
3769
+ preserveK: payload.preserveK,
3770
+ eliseThreshold: payload.eliseThreshold,
3771
+ custom: true,
3772
+ aggressiveOn: "soft",
3773
+ targetLoad: 0.65
3774
+ });
3775
+ sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
3776
+ break;
3777
+ }
3778
+ case "context.mode.update": {
3779
+ const payload = msg.payload;
3780
+ const result = customModeStore.update(payload.id, {
3781
+ name: payload.name,
3782
+ description: payload.description,
3783
+ thresholds: payload.thresholds ? {
3784
+ warn: payload.thresholds.warn ?? 0.6,
3785
+ soft: payload.thresholds.soft ?? 0.75,
3786
+ hard: payload.thresholds.hard ?? 0.9
3787
+ } : void 0,
3788
+ preserveK: payload.preserveK,
3789
+ eliseThreshold: payload.eliseThreshold
3790
+ });
3791
+ sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
3792
+ break;
3793
+ }
3794
+ case "context.mode.delete": {
3795
+ const { id } = msg.payload;
3796
+ if (String(context.meta["contextWindowMode"] ?? "") === id) {
3797
+ context.meta["contextWindowMode"] = DEFAULT_CONTEXT_WINDOW_MODE_ID;
3798
+ context.meta["contextWindowPolicy"] = resolveContextWindowPolicy({}, DEFAULT_CONTEXT_WINDOW_MODE_ID);
3799
+ }
3800
+ const result = customModeStore.remove(id);
3801
+ sendResult(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
3802
+ break;
3803
+ }
2661
3804
  case "providers.list": {
2662
3805
  const providers = await modelsRegistry.listProviders();
2663
3806
  const savedIds = new Set(Object.keys(config.providers ?? {}));
@@ -2737,15 +3880,20 @@ async function startWebUI(opts = {}) {
2737
3880
  updateAutoCompactionMaxContext?.(newProv);
2738
3881
  try {
2739
3882
  configWriteLock = configWriteLock.then(async () => {
2740
- const raw = await fs4.readFile(globalConfigPath, "utf8");
3883
+ const raw = await fs6.readFile(globalConfigPath, "utf8");
2741
3884
  const parsed = JSON.parse(raw);
2742
3885
  parsed.provider = newProvider;
2743
3886
  parsed.model = newModel;
2744
- await atomicWrite3(globalConfigPath, JSON.stringify(parsed, null, 2));
3887
+ await atomicWrite5(globalConfigPath, JSON.stringify(parsed, null, 2));
2745
3888
  });
2746
3889
  await configWriteLock;
2747
3890
  } catch (err) {
2748
- console.warn("[WebUI] Failed to save config:", err);
3891
+ console.warn(JSON.stringify({
3892
+ level: "warn",
3893
+ event: "webui.config_save_failed",
3894
+ message: err instanceof Error ? err.message : String(err),
3895
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3896
+ }));
2749
3897
  }
2750
3898
  send(ws, {
2751
3899
  type: "key.operation_result",
@@ -2764,6 +3912,57 @@ async function startWebUI(opts = {}) {
2764
3912
  broadcast(clients, { type: "session.start", payload: await sessionStartPayload() });
2765
3913
  break;
2766
3914
  }
3915
+ case "model.refine": {
3916
+ const { text } = msg.payload;
3917
+ if (!text?.trim()) {
3918
+ send(ws, {
3919
+ type: "model.refine_result",
3920
+ payload: { refined: "", english: "", error: "Empty text" }
3921
+ });
3922
+ break;
3923
+ }
3924
+ try {
3925
+ const history = recentTextTurns(context.messages);
3926
+ const result = await enhanceUserPrompt({
3927
+ provider: context.provider,
3928
+ model: context.model,
3929
+ text,
3930
+ history,
3931
+ timeoutMs: 9e4,
3932
+ onError: (reason) => {
3933
+ console.warn(JSON.stringify({
3934
+ level: "warn",
3935
+ event: "model.refine_failed",
3936
+ reason,
3937
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3938
+ }));
3939
+ }
3940
+ });
3941
+ if (result) {
3942
+ send(ws, {
3943
+ type: "model.refine_result",
3944
+ payload: { refined: result.refined, english: result.english }
3945
+ });
3946
+ } else {
3947
+ send(ws, {
3948
+ type: "model.refine_result",
3949
+ payload: { refined: text, english: text, error: "Refinement returned no result" }
3950
+ });
3951
+ }
3952
+ } catch (err) {
3953
+ console.error(JSON.stringify({
3954
+ level: "error",
3955
+ event: "model.refine.error",
3956
+ error: errMessage(err),
3957
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
3958
+ }));
3959
+ send(ws, {
3960
+ type: "model.refine_result",
3961
+ payload: { refined: text, english: text, error: errMessage(err) }
3962
+ });
3963
+ }
3964
+ break;
3965
+ }
2767
3966
  case "key.add":
2768
3967
  case "key.update": {
2769
3968
  const { providerId, label, apiKey } = msg.payload;
@@ -2839,6 +4038,11 @@ async function startWebUI(opts = {}) {
2839
4038
  }
2840
4039
  const resumed = await sessionStore.resume(id);
2841
4040
  try {
4041
+ await session.append({
4042
+ type: "session_end",
4043
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
4044
+ usage: tokenCounter.total()
4045
+ });
2842
4046
  await session.close();
2843
4047
  } catch {
2844
4048
  }
@@ -2882,42 +4086,13 @@ async function startWebUI(opts = {}) {
2882
4086
  send(ws, { type: "tools.list", payload: { tools: list } });
2883
4087
  break;
2884
4088
  }
2885
- case "memory.list": {
2886
- try {
2887
- const text = await memoryStore.readAll();
2888
- send(ws, { type: "memory.list", payload: { text } });
2889
- } catch (err) {
2890
- send(ws, {
2891
- type: "memory.list",
2892
- payload: { text: "", error: errMessage(err) }
2893
- });
2894
- }
2895
- break;
2896
- }
2897
- case "memory.remember": {
2898
- const { text, scope } = msg.payload;
2899
- try {
2900
- await memoryStore.remember(text, scope ?? "project-memory");
2901
- sendResult(ws, true, "Saved to memory");
2902
- } catch (err) {
2903
- sendResult(ws, false, errMessage(err));
2904
- }
2905
- break;
2906
- }
2907
- case "memory.forget": {
2908
- const { text, scope } = msg.payload;
2909
- try {
2910
- const removed = await memoryStore.forget(text, scope ?? "project-memory");
2911
- sendResult(
2912
- ws,
2913
- removed > 0,
2914
- removed > 0 ? `Removed ${removed} entr${removed === 1 ? "y" : "ies"}` : "No matching entries"
2915
- );
2916
- } catch (err) {
2917
- sendResult(ws, false, errMessage(err));
2918
- }
2919
- break;
2920
- }
4089
+ // ── Memory operations — delegated to shared handlers (memory-handlers.ts) ──
4090
+ case "memory.list":
4091
+ return handleMemoryList(ws, memoryStore);
4092
+ case "memory.remember":
4093
+ return handleMemoryRemember(ws, msg, memoryStore);
4094
+ case "memory.forget":
4095
+ return handleMemoryForget(ws, msg, memoryStore);
2921
4096
  case "skills.list": {
2922
4097
  if (!skillLoader) {
2923
4098
  send(ws, { type: "skills.list", payload: { skills: [], enabled: false } });
@@ -3017,6 +4192,24 @@ async function startWebUI(opts = {}) {
3017
4192
  broadcast(clients, { type: "todos.updated", payload: { todos: next } });
3018
4193
  break;
3019
4194
  }
4195
+ case "tasks.get": {
4196
+ const taskPath = context.meta["task.path"];
4197
+ if (typeof taskPath === "string" && taskPath) {
4198
+ try {
4199
+ const { loadTasks } = await import("@wrongstack/core");
4200
+ const file = await loadTasks(taskPath);
4201
+ send(ws, {
4202
+ type: "tasks.updated",
4203
+ payload: { tasks: file?.tasks ?? [] }
4204
+ });
4205
+ } catch {
4206
+ send(ws, { type: "tasks.updated", payload: { tasks: [] } });
4207
+ }
4208
+ } else {
4209
+ send(ws, { type: "tasks.updated", payload: { tasks: [], error: "Task storage not configured." } });
4210
+ }
4211
+ break;
4212
+ }
3020
4213
  case "plan.get": {
3021
4214
  const planPath = context.meta["plan.path"];
3022
4215
  if (typeof planPath === "string" && planPath) {
@@ -3084,37 +4277,18 @@ async function startWebUI(opts = {}) {
3084
4277
  }
3085
4278
  break;
3086
4279
  }
3087
- case "files.list": {
3088
- const payload = msg.payload ?? {};
3089
- const limit = payload.limit ?? 50;
3090
- const results = [];
3091
- async function walk(dir, rel, depth) {
3092
- if (depth > 8 || results.length >= 600) return;
3093
- let entries = [];
3094
- try {
3095
- entries = await fs4.readdir(dir, { withFileTypes: true });
3096
- } catch {
3097
- return;
3098
- }
3099
- for (const e of entries) {
3100
- if (results.length >= 600) return;
3101
- if (isHiddenEntry(e.name)) continue;
3102
- const childRel = rel ? `${rel}/${e.name}` : e.name;
3103
- if (e.isDirectory()) {
3104
- if (SKIP_DIRS.has(e.name)) continue;
3105
- await walk(path4.join(dir, e.name), childRel, depth + 1);
3106
- } else if (e.isFile()) {
3107
- results.push(childRel);
3108
- }
3109
- }
3110
- }
3111
- await walk(projectRoot, "", 0);
3112
- send(ws, {
3113
- type: "files.list",
3114
- payload: { files: rankFiles(results, payload.query ?? "", limit) }
3115
- });
3116
- break;
3117
- }
4280
+ // ── File operations — delegated to shared handlers (file-handlers.ts) ──
4281
+ // These handlers are also used by the CLI's webui-server.ts. When
4282
+ // adding or modifying file-operation WebSocket messages, update
4283
+ // file-handlers.ts NOT these case blocks individually.
4284
+ case "files.list":
4285
+ return handleFilesList(ws, msg, projectRoot);
4286
+ case "files.tree":
4287
+ return handleFilesTree(ws, msg, projectRoot);
4288
+ case "files.read":
4289
+ return handleFilesRead(ws, msg, projectRoot);
4290
+ case "files.write":
4291
+ return handleFilesWrite(ws, msg, projectRoot);
3118
4292
  case "modes.list": {
3119
4293
  try {
3120
4294
  const modes = await modeStore.listModes();
@@ -3252,8 +4426,8 @@ async function startWebUI(opts = {}) {
3252
4426
  }
3253
4427
  case "goal.get": {
3254
4428
  try {
3255
- const goalPath = path4.join(projectRoot, ".wrongstack", "goal.json");
3256
- const raw = await fs4.readFile(goalPath, "utf8");
4429
+ const goalPath = path8.join(projectRoot, ".wrongstack", "goal.json");
4430
+ const raw = await fs6.readFile(goalPath, "utf8");
3257
4431
  const goal = JSON.parse(raw);
3258
4432
  broadcast(clients, { type: "goal.updated", payload: goal });
3259
4433
  } catch {
@@ -3265,13 +4439,55 @@ async function startWebUI(opts = {}) {
3265
4439
  const { mode } = msg.payload;
3266
4440
  context.meta["autonomy"] = mode;
3267
4441
  sendResult(ws, true, `Autonomy mode set to "${mode}"`);
4442
+ broadcast(clients, { type: "prefs.updated", payload: { autonomy: mode } });
4443
+ void persistPrefsToConfig({ autonomy: mode });
4444
+ break;
4445
+ }
4446
+ case "prefs.update": {
4447
+ const payload = msg.payload;
4448
+ for (const [key, val] of Object.entries(payload)) {
4449
+ context.meta[key] = val;
4450
+ }
4451
+ void persistPrefsToConfig(payload);
4452
+ if (typeof payload["yolo"] === "boolean") {
4453
+ permissionPolicy.setYolo?.(payload["yolo"]);
4454
+ }
4455
+ if (typeof payload["featureMcp"] === "boolean")
4456
+ config.features.mcp = payload["featureMcp"];
4457
+ if (typeof payload["featurePlugins"] === "boolean")
4458
+ config.features.plugins = payload["featurePlugins"];
4459
+ if (typeof payload["featureMemory"] === "boolean")
4460
+ config.features.memory = payload["featureMemory"];
4461
+ if (typeof payload["featureSkills"] === "boolean")
4462
+ config.features.skills = payload["featureSkills"];
4463
+ if (typeof payload["featureModelsRegistry"] === "boolean")
4464
+ config.features.modelsRegistry = payload["featureModelsRegistry"];
4465
+ if (typeof payload["contextAutoCompact"] === "boolean") {
4466
+ if (payload["contextAutoCompact"] && autoCompactor) {
4467
+ pipelines.contextWindow.remove("AutoCompaction", { optional: true });
4468
+ pipelines.contextWindow.use({ name: "AutoCompaction", handler: autoCompactor.handler() });
4469
+ } else {
4470
+ pipelines.contextWindow.remove("AutoCompaction", { optional: true });
4471
+ }
4472
+ }
4473
+ if (typeof payload["logLevel"] === "string") {
4474
+ const valid = ["debug", "info", "warn", "error"];
4475
+ if (valid.includes(payload["logLevel"])) {
4476
+ logger.level = payload["logLevel"];
4477
+ }
4478
+ }
4479
+ broadcast(clients, { type: "prefs.updated", payload: prefSnapshot() });
4480
+ break;
4481
+ }
4482
+ case "prefs.get": {
4483
+ send(ws, { type: "prefs.updated", payload: prefSnapshot() });
3268
4484
  break;
3269
4485
  }
3270
4486
  case "session.checkpoints": {
3271
4487
  try {
3272
4488
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
3273
4489
  const rewinder = new DefaultSessionRewinder(
3274
- path4.join(projectRoot, ".wrongstack", "sessions"),
4490
+ path8.join(projectRoot, ".wrongstack", "sessions"),
3275
4491
  projectRoot
3276
4492
  );
3277
4493
  const checkpoints = await rewinder.listCheckpoints(session.id);
@@ -3292,7 +4508,7 @@ async function startWebUI(opts = {}) {
3292
4508
  try {
3293
4509
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
3294
4510
  const rewinder = new DefaultSessionRewinder(
3295
- path4.join(projectRoot, ".wrongstack", "sessions"),
4511
+ path8.join(projectRoot, ".wrongstack", "sessions"),
3296
4512
  projectRoot
3297
4513
  );
3298
4514
  await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
@@ -3307,6 +4523,309 @@ async function startWebUI(opts = {}) {
3307
4523
  }
3308
4524
  break;
3309
4525
  }
4526
+ // ── Project management ────────────────────────────────────────────
4527
+ case "projects.list": {
4528
+ try {
4529
+ const manifest = await loadManifest(globalConfigPath);
4530
+ send(ws, {
4531
+ type: "projects.list",
4532
+ payload: { projects: manifest.projects }
4533
+ });
4534
+ } catch (err) {
4535
+ send(ws, {
4536
+ type: "projects.list",
4537
+ payload: { projects: [], error: errMessage(err) }
4538
+ });
4539
+ }
4540
+ break;
4541
+ }
4542
+ case "projects.add": {
4543
+ const { root: addRoot, name: displayName } = msg.payload;
4544
+ try {
4545
+ const resolved = path8.resolve(addRoot);
4546
+ await fs6.access(resolved);
4547
+ const stat2 = await fs6.stat(resolved);
4548
+ if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4549
+ const manifest = await loadManifest(globalConfigPath);
4550
+ const existing = manifest.projects.find((p) => p.root === resolved);
4551
+ if (existing) {
4552
+ send(ws, {
4553
+ type: "projects.added",
4554
+ payload: {
4555
+ name: existing.name,
4556
+ root: existing.root,
4557
+ slug: existing.slug,
4558
+ message: `Already registered as "${existing.name}"`
4559
+ }
4560
+ });
4561
+ break;
4562
+ }
4563
+ const name = displayName?.trim() || path8.basename(resolved);
4564
+ const slug = generateProjectSlug(resolved);
4565
+ await ensureProjectDataDir(slug, globalConfigPath);
4566
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4567
+ manifest.projects.push({ name, root: resolved, slug, lastSeen: now, createdAt: now });
4568
+ await saveManifest(manifest, globalConfigPath);
4569
+ send(ws, {
4570
+ type: "projects.added",
4571
+ payload: {
4572
+ name,
4573
+ root: resolved,
4574
+ slug,
4575
+ message: `Registered project "${name}"`
4576
+ }
4577
+ });
4578
+ } catch (err) {
4579
+ send(ws, {
4580
+ type: "projects.added",
4581
+ payload: {
4582
+ name: path8.basename(addRoot),
4583
+ root: addRoot,
4584
+ slug: "",
4585
+ message: errMessage(err)
4586
+ }
4587
+ });
4588
+ }
4589
+ break;
4590
+ }
4591
+ case "projects.select": {
4592
+ const { root: selRoot, name: selName } = msg.payload;
4593
+ try {
4594
+ const resolved = path8.resolve(selRoot);
4595
+ try {
4596
+ await fs6.access(resolved);
4597
+ const stat2 = await fs6.stat(resolved);
4598
+ if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4599
+ } catch (err) {
4600
+ send(ws, {
4601
+ type: "projects.selected",
4602
+ payload: {
4603
+ root: selRoot,
4604
+ name: selName || path8.basename(selRoot),
4605
+ message: `Cannot switch: ${errMessage(err)}`
4606
+ }
4607
+ });
4608
+ break;
4609
+ }
4610
+ const manifest = await loadManifest(globalConfigPath);
4611
+ const entry = manifest.projects.find((p) => p.root === resolved);
4612
+ if (entry) {
4613
+ entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
4614
+ entry.lastWorkingDir = resolved;
4615
+ } else {
4616
+ const name = selName?.trim() || path8.basename(resolved);
4617
+ const slug = generateProjectSlug(resolved);
4618
+ manifest.projects.push({
4619
+ name,
4620
+ root: resolved,
4621
+ slug,
4622
+ lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
4623
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
4624
+ lastWorkingDir: resolved
4625
+ });
4626
+ await ensureProjectDataDir(slug, globalConfigPath);
4627
+ }
4628
+ await saveManifest(manifest, globalConfigPath);
4629
+ if (runLock) {
4630
+ runLock.abort();
4631
+ runLock = null;
4632
+ }
4633
+ projectRoot = resolved;
4634
+ workingDir = resolved;
4635
+ context.cwd = workingDir;
4636
+ context.projectRoot = projectRoot;
4637
+ const newSessionsDir = path8.join(
4638
+ path8.dirname(globalConfigPath),
4639
+ "projects",
4640
+ entry?.slug ?? generateProjectSlug(resolved),
4641
+ "sessions"
4642
+ );
4643
+ await fs6.mkdir(newSessionsDir, { recursive: true });
4644
+ const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
4645
+ const oldSessionId = session.id;
4646
+ try {
4647
+ await session.append({
4648
+ type: "session_end",
4649
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
4650
+ usage: tokenCounter.total()
4651
+ });
4652
+ await session.close();
4653
+ } catch {
4654
+ }
4655
+ sessionStore = newSessionStore;
4656
+ session = await sessionStore.create({
4657
+ id: "",
4658
+ title: "",
4659
+ model: config.model,
4660
+ provider: config.provider
4661
+ });
4662
+ context.session = session;
4663
+ context.state.replaceMessages([]);
4664
+ context.state.replaceTodos([]);
4665
+ context.readFiles.clear();
4666
+ context.fileMtimes.clear();
4667
+ tokenCounter.reset();
4668
+ sessionStartedAt = Date.now();
4669
+ send(ws, {
4670
+ type: "projects.selected",
4671
+ payload: {
4672
+ root: resolved,
4673
+ name: selName || path8.basename(resolved),
4674
+ message: `Switched to ${selName || path8.basename(resolved)}`
4675
+ }
4676
+ });
4677
+ broadcast(clients, {
4678
+ type: "subagent.event",
4679
+ payload: {
4680
+ kind: "session_stopped",
4681
+ sessionId: oldSessionId
4682
+ }
4683
+ });
4684
+ broadcast(clients, {
4685
+ type: "session.start",
4686
+ payload: {
4687
+ ...await sessionStartPayload(),
4688
+ reset: true,
4689
+ clearedSessionId: oldSessionId
4690
+ }
4691
+ });
4692
+ } catch (err) {
4693
+ send(ws, {
4694
+ type: "projects.selected",
4695
+ payload: {
4696
+ root: selRoot,
4697
+ name: selName || path8.basename(selRoot),
4698
+ message: errMessage(err)
4699
+ }
4700
+ });
4701
+ }
4702
+ break;
4703
+ }
4704
+ // ── Working directory (within current project) ───────────────────
4705
+ case "working_dir.set": {
4706
+ const { path: newPath } = msg.payload;
4707
+ try {
4708
+ const resolved = path8.resolve(projectRoot, newPath);
4709
+ if (!resolved.startsWith(projectRoot + path8.sep) && resolved !== projectRoot) {
4710
+ sendResult(ws, false, `Path must stay inside the project root: ${projectRoot}`);
4711
+ break;
4712
+ }
4713
+ try {
4714
+ await fs6.access(resolved);
4715
+ const stat2 = await fs6.stat(resolved);
4716
+ if (!stat2.isDirectory()) throw new Error("Not a directory");
4717
+ } catch {
4718
+ sendResult(ws, false, `Directory not found or not accessible: ${resolved}`);
4719
+ break;
4720
+ }
4721
+ workingDir = resolved;
4722
+ context.cwd = resolved;
4723
+ broadcast(clients, {
4724
+ type: "working_dir.changed",
4725
+ payload: { cwd: resolved, projectRoot }
4726
+ });
4727
+ sendResult(ws, true, `Working directory set to ${resolved}`);
4728
+ } catch (err) {
4729
+ sendResult(ws, false, errMessage(err));
4730
+ }
4731
+ break;
4732
+ }
4733
+ // ── Shell open — spawn terminal or file manager at a path ─────────
4734
+ 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
+ }
4768
+ break;
4769
+ }
4770
+ // ── Mailbox operations — project-level inter-agent messaging ────
4771
+ case "mailbox.messages":
4772
+ return handleMailboxMessages(
4773
+ ws,
4774
+ { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
4775
+ msg.payload
4776
+ );
4777
+ case "mailbox.agents":
4778
+ return handleMailboxAgents(
4779
+ ws,
4780
+ { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
4781
+ msg.payload
4782
+ );
4783
+ case "mailbox.clear":
4784
+ return handleMailboxClear(
4785
+ ws,
4786
+ { projectRoot, globalRoot: path8.dirname(globalConfigPath) }
4787
+ );
4788
+ // ── Brain — status, autonomy ceiling, direct decision support ───
4789
+ case "brain.status":
4790
+ send(ws, {
4791
+ type: "brain.status",
4792
+ payload: { maxAutoRisk: brainSettings.maxAutoRisk, log: brainLog }
4793
+ });
4794
+ break;
4795
+ case "brain.risk": {
4796
+ const level = msg.payload?.level ?? "";
4797
+ const valid = ["off", "low", "medium", "high", "all"];
4798
+ if (!valid.includes(level)) {
4799
+ sendResult(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
4800
+ break;
4801
+ }
4802
+ brainSettings.maxAutoRisk = level;
4803
+ send(ws, {
4804
+ type: "brain.status",
4805
+ payload: { maxAutoRisk: brainSettings.maxAutoRisk, log: brainLog }
4806
+ });
4807
+ break;
4808
+ }
4809
+ case "brain.ask": {
4810
+ const question = msg.payload?.question?.trim();
4811
+ if (!question) {
4812
+ sendResult(ws, false, "Usage: /brain ask <question>");
4813
+ break;
4814
+ }
4815
+ try {
4816
+ const decision = await brain.decide({
4817
+ id: `brain-ask-${Date.now().toString(36)}`,
4818
+ source: "user",
4819
+ question,
4820
+ risk: "medium",
4821
+ fallback: "ask_human"
4822
+ });
4823
+ send(ws, { type: "brain.answer", payload: { question, decision } });
4824
+ } catch (err) {
4825
+ sendResult(ws, false, `Brain consultation failed: ${errMessage(err)}`);
4826
+ }
4827
+ break;
4828
+ }
3310
4829
  default:
3311
4830
  if (msg.type.startsWith("autophase.")) {
3312
4831
  await autoPhaseHandler.handleMessage(
@@ -3326,14 +4845,16 @@ async function startWebUI(opts = {}) {
3326
4845
  getConfigWriteLock: () => configWriteLock,
3327
4846
  setConfigWriteLock: (p) => {
3328
4847
  configWriteLock = p;
3329
- }
4848
+ },
4849
+ broadcast,
4850
+ clients
3330
4851
  });
3331
4852
  const httpServer = createHttpServer({
3332
4853
  host: wsHost,
3333
- distDir: path4.resolve(import.meta.dirname, "../../dist"),
4854
+ distDir: path8.resolve(import.meta.dirname, "../../dist"),
3334
4855
  wsPort
3335
4856
  });
3336
- const registryBaseDir = path4.dirname(globalConfigPath);
4857
+ const registryBaseDir = path8.dirname(globalConfigPath);
3337
4858
  httpServer.listen(httpPort, wsHost, () => {
3338
4859
  const openUrl = `http://${wsHost}:${httpPort}`;
3339
4860
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
@@ -3345,12 +4866,17 @@ async function startWebUI(opts = {}) {
3345
4866
  wsPort,
3346
4867
  host: wsHost,
3347
4868
  projectRoot,
3348
- projectName: path4.basename(projectRoot) || projectRoot,
4869
+ projectName: path8.basename(projectRoot) || projectRoot,
3349
4870
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3350
4871
  url: `http://${wsHost}:${httpPort}`
3351
4872
  },
3352
4873
  registryBaseDir
3353
- ).catch((err) => console.warn("[WebUI] Could not record instance:", errMessage(err)));
4874
+ ).catch((err) => console.warn(JSON.stringify({
4875
+ level: "warn",
4876
+ event: "webui.instance_record_failed",
4877
+ message: errMessage(err),
4878
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4879
+ })));
3354
4880
  });
3355
4881
  registerShutdownHandlers({
3356
4882
  flushSession: async () => {
@@ -3365,14 +4891,19 @@ async function startWebUI(opts = {}) {
3365
4891
  servers: [httpServer, wssPrimary, wssSecondary],
3366
4892
  // Drop this instance from the registry on a clean exit so the file reflects
3367
4893
  // reality. Crash exits are healed by the next register()/list() prune pass.
3368
- onShutdown: () => unregisterInstance(process.pid, registryBaseDir)
4894
+ onShutdown: () => {
4895
+ brainMonitor.stop();
4896
+ return unregisterInstance(process.pid, registryBaseDir);
4897
+ }
3369
4898
  });
3370
4899
  }
3371
4900
  export {
4901
+ AutoPhaseWebSocketHandler,
3372
4902
  addProvider,
3373
4903
  broadcast,
3374
4904
  browserOpenCommand,
3375
4905
  buildCspHeader,
4906
+ createCustomModeStore,
3376
4907
  createHttpServer,
3377
4908
  createProviderConfigIO,
3378
4909
  defaultBaseDir,
@@ -3382,6 +4913,13 @@ export {
3382
4913
  findFreePort,
3383
4914
  formatInstances,
3384
4915
  generateAuthToken,
4916
+ handleFilesList,
4917
+ handleFilesRead,
4918
+ handleFilesTree,
4919
+ handleFilesWrite,
4920
+ handleMemoryForget,
4921
+ handleMemoryList,
4922
+ handleMemoryRemember,
3385
4923
  hostHeaderOk,
3386
4924
  injectWsPort,
3387
4925
  isLoopbackBind,