@wrongstack/webui 0.260.0 → 0.265.1

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,14 +1,180 @@
1
1
  #!/usr/bin/env node
2
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
- }) : x)(function(x) {
5
- if (typeof require !== "undefined") return require.apply(this, arguments);
6
- throw Error('Dynamic require of "' + x + '" is not supported');
7
- });
2
+ // src/server/index.ts
3
+ import { expectDefined as expectDefined2, GlobalMailbox as GlobalMailbox2, projectSlug, getSessionRegistry, AgentStatusTracker, FleetNotifier } from "@wrongstack/core";
4
+
5
+ // src/server/handlers/worklist-handlers.ts
6
+ function sendResult(ws, ctx, ok, message) {
7
+ ctx.send(ws, { type: ok ? "ok" : "error", message });
8
+ }
9
+ function handleTodosGet(ctx, ws) {
10
+ ctx.send(ws, { type: "todos.updated", payload: { todos: ctx.context.todos } });
11
+ }
12
+ function handleTodosClear(ctx, ws) {
13
+ ctx.replaceTodos?.([]);
14
+ ctx.broadcast({ type: "todos.cleared" });
15
+ sendResult(ws, ctx, true, "Todo board cleared.");
16
+ }
17
+ function handleTodosRemove(ctx, ws, payload) {
18
+ if (!payload || payload.id === void 0 && payload.index === void 0) {
19
+ sendResult(ws, ctx, false, "todos.remove requires id or index.");
20
+ return;
21
+ }
22
+ const next = payload.id !== void 0 ? ctx.context.todos.filter((t) => t.id !== payload.id) : ctx.context.todos.filter((_, i) => i !== payload.index);
23
+ ctx.replaceTodos?.(next);
24
+ ctx.broadcast({ type: "todos.updated", payload: { todos: next } });
25
+ sendResult(ws, ctx, true, "Todo item removed.");
26
+ }
27
+ function handleTodoUpdate(ctx, ws, payload) {
28
+ const todo = ctx.context.todos.find((t) => t.id === payload.id);
29
+ if (!todo) {
30
+ sendResult(ws, ctx, false, `No todo with id "${payload.id}".`);
31
+ return;
32
+ }
33
+ const next = ctx.context.todos.map(
34
+ (t) => t.id === payload.id ? { ...t, ...payload.status !== void 0 && { status: payload.status }, ...payload.activeForm !== void 0 && { activeForm: payload.activeForm } } : t
35
+ );
36
+ ctx.replaceTodos?.(next);
37
+ ctx.broadcast({ type: "todos.updated", payload: { todos: next } });
38
+ sendResult(ws, ctx, true, `Todo "${todo.content}" updated.`);
39
+ }
40
+ async function handleTasksGet(ctx, ws) {
41
+ const taskPath = ctx.context.meta["task.path"];
42
+ if (typeof taskPath === "string" && taskPath) {
43
+ try {
44
+ const { loadTasks } = await import("@wrongstack/core");
45
+ const file = await loadTasks(taskPath);
46
+ ctx.send(ws, { type: "tasks.updated", payload: { tasks: file?.tasks ?? [] } });
47
+ } catch {
48
+ ctx.send(ws, { type: "tasks.updated", payload: { tasks: [] } });
49
+ }
50
+ } else {
51
+ ctx.send(ws, {
52
+ type: "tasks.updated",
53
+ payload: { tasks: [], error: "Task storage not configured." }
54
+ });
55
+ }
56
+ }
57
+ async function handleTaskUpdate(ctx, ws, payload) {
58
+ const taskPath = ctx.context.meta["task.path"];
59
+ if (typeof taskPath !== "string" || !taskPath) {
60
+ sendResult(ws, ctx, false, "Task storage is not configured for this session.");
61
+ return;
62
+ }
63
+ try {
64
+ const { loadTasks, saveTasks } = await import("@wrongstack/core");
65
+ const file = await loadTasks(taskPath);
66
+ if (!file) {
67
+ sendResult(ws, ctx, false, "No task file found.");
68
+ return;
69
+ }
70
+ const idx = file.tasks.findIndex((t) => t.id === payload.id);
71
+ if (idx === -1) {
72
+ sendResult(ws, ctx, false, `Task "${payload.id}" not found.`);
73
+ return;
74
+ }
75
+ file.tasks[idx] = { ...file.tasks[idx], status: payload.status };
76
+ await saveTasks(taskPath, file);
77
+ ctx.broadcast({ type: "tasks.updated", payload: { tasks: file.tasks } });
78
+ sendResult(ws, ctx, true, `Task "${payload.id}" marked ${payload.status}.`);
79
+ } catch (err) {
80
+ sendResult(ws, ctx, false, String(err));
81
+ }
82
+ }
83
+ async function handlePlanGet(ctx, ws) {
84
+ const planPath = ctx.context.meta["plan.path"];
85
+ const sessionId = ctx.context.session?.id ?? "";
86
+ if (typeof planPath === "string" && planPath) {
87
+ try {
88
+ const { loadPlan } = await import("@wrongstack/core");
89
+ const plan = await loadPlan(planPath);
90
+ ctx.send(ws, {
91
+ type: "plan.updated",
92
+ payload: {
93
+ plan: plan ?? {
94
+ version: 1,
95
+ sessionId,
96
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
97
+ items: []
98
+ }
99
+ }
100
+ });
101
+ } catch {
102
+ ctx.send(ws, {
103
+ type: "plan.updated",
104
+ payload: {
105
+ plan: {
106
+ version: 1,
107
+ sessionId,
108
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
109
+ items: []
110
+ }
111
+ }
112
+ });
113
+ }
114
+ } else {
115
+ ctx.send(ws, {
116
+ type: "plan.updated",
117
+ payload: { plan: null, error: "Plan storage is not configured for this session." }
118
+ });
119
+ }
120
+ }
121
+ async function handlePlanTemplateUse(ctx, ws, template) {
122
+ const planPath = ctx.context.meta["plan.path"];
123
+ const sessionId = ctx.context.session?.id ?? "";
124
+ if (typeof planPath !== "string" || !planPath) {
125
+ sendResult(ws, ctx, false, "Plan storage is not configured for this session.");
126
+ return;
127
+ }
128
+ try {
129
+ const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
130
+ const tpl = getPlanTemplate(template);
131
+ if (!tpl) {
132
+ sendResult(ws, ctx, false, `Unknown template "${template}".`);
133
+ return;
134
+ }
135
+ let plan = await loadPlan(planPath) ?? emptyPlan(sessionId);
136
+ for (const item of tpl.items) {
137
+ ({ plan } = addPlanItem(plan, item.title, item.details));
138
+ }
139
+ await savePlan(planPath, plan);
140
+ sendResult(ws, ctx, true, `Applied template "${tpl.name}" \u2014 ${tpl.items.length} items added.`);
141
+ ctx.broadcast({ type: "plan.updated", payload: { plan } });
142
+ } catch (err) {
143
+ sendResult(ws, ctx, false, String(err));
144
+ }
145
+ }
146
+ async function handlePlanItemUpdate(ctx, ws, payload) {
147
+ const planPath = ctx.context.meta["plan.path"];
148
+ const sessionId = ctx.context.session?.id ?? "";
149
+ if (typeof planPath !== "string" || !planPath) {
150
+ sendResult(ws, ctx, false, "Plan storage is not configured for this session.");
151
+ return;
152
+ }
153
+ try {
154
+ const { loadPlan, savePlan, mutatePlan, setPlanItemStatus } = await import("@wrongstack/core");
155
+ let changed = false;
156
+ const plan = await mutatePlan(planPath, sessionId, async (p) => {
157
+ const before = p.updatedAt;
158
+ const updated = setPlanItemStatus(p, payload.target, payload.status);
159
+ changed = updated.updatedAt !== before;
160
+ return updated;
161
+ });
162
+ if (!changed) {
163
+ sendResult(ws, ctx, false, `No plan item matched "${payload.target}".`);
164
+ return;
165
+ }
166
+ sendResult(ws, ctx, true, `Plan item status updated to "${payload.status}".`);
167
+ ctx.broadcast({ type: "plan.updated", payload: { plan } });
168
+ } catch (err) {
169
+ sendResult(ws, ctx, false, String(err));
170
+ }
171
+ }
8
172
 
9
173
  // src/server/index.ts
10
- import { expectDefined as expectDefined2, GlobalMailbox as GlobalMailbox2, projectSlug, getSessionRegistry, AgentStatusTracker } from "@wrongstack/core";
11
174
  import { makeMailboxTool, makeMailSendTool, makeMailInboxTool, mailboxSessionTag } from "@wrongstack/core";
175
+ import { toErrorMessage as toErrorMessage5, wstackGlobalRoot as wstackGlobalRoot2, projectHash, resolveWstackPaths } from "@wrongstack/core/utils";
176
+ import { SkillInstaller } from "@wrongstack/core/skills";
177
+ import JSZip2 from "jszip";
12
178
  import {
13
179
  BrainMonitor,
14
180
  DefaultBrainArbiter,
@@ -16,8 +182,8 @@ import {
16
182
  createAutonomyBrain,
17
183
  createTieredBrainArbiter
18
184
  } from "@wrongstack/core";
19
- import * as fs7 from "fs/promises";
20
- import * as path9 from "path";
185
+ import * as fs10 from "fs/promises";
186
+ import * as path10 from "path";
21
187
 
22
188
  // src/server/http-server.ts
23
189
  import * as fs from "fs/promises";
@@ -25,7 +191,7 @@ import * as http from "http";
25
191
  import * as path from "path";
26
192
 
27
193
  // src/server/ws-auth.ts
28
- import { Buffer as Buffer2 } from "buffer";
194
+ import { Buffer } from "buffer";
29
195
  import { timingSafeEqual } from "crypto";
30
196
  function isLoopbackHostname(hostname) {
31
197
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
@@ -44,8 +210,8 @@ function isLoopbackBind(wsHost) {
44
210
  }
45
211
  function tokenMatches(provided, expected) {
46
212
  if (!provided) return false;
47
- const a = Buffer2.from(provided);
48
- const b = Buffer2.from(expected);
213
+ const a = Buffer.from(provided);
214
+ const b = Buffer.from(expected);
49
215
  if (a.length !== b.length) return false;
50
216
  return timingSafeEqual(a, b);
51
217
  }
@@ -136,6 +302,13 @@ function isInsideDist(candidate, distDir) {
136
302
  const resolved = path.resolve(candidate);
137
303
  return resolved === root || resolved.startsWith(root + path.sep);
138
304
  }
305
+ function decodeSessionId(segment) {
306
+ try {
307
+ return decodeURIComponent(segment);
308
+ } catch {
309
+ return segment;
310
+ }
311
+ }
139
312
  function createHttpServer(opts) {
140
313
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
141
314
  const distDir = path.resolve(opts.distDir);
@@ -161,6 +334,22 @@ function createHttpServer(opts) {
161
334
  res.end("ok");
162
335
  return;
163
336
  }
337
+ if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
338
+ const headerToken = req.headers["x-ws-token"];
339
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
340
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
341
+ res.writeHead(401, { "Content-Type": "application/json" });
342
+ res.end(JSON.stringify({ error: "Unauthorized" }));
343
+ return;
344
+ }
345
+ try {
346
+ opts.onFleetPing?.();
347
+ } catch {
348
+ }
349
+ res.writeHead(204);
350
+ res.end();
351
+ return;
352
+ }
164
353
  if (url.pathname === "/api/sessions" && req.method === "GET") {
165
354
  const headerToken = req.headers["x-ws-token"];
166
355
  const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
@@ -181,7 +370,89 @@ function createHttpServer(opts) {
181
370
  res.end(JSON.stringify({ error: "Unauthorized" }));
182
371
  return;
183
372
  }
184
- await handleApiSessionAgents(res, opts.globalRoot, agentsMatch[1]);
373
+ await handleApiSessionAgents(res, opts.globalRoot, decodeSessionId(agentsMatch[1]));
374
+ return;
375
+ }
376
+ const eventsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
377
+ if (eventsMatch && req.method === "GET") {
378
+ const headerToken = req.headers["x-ws-token"];
379
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
380
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
381
+ res.writeHead(401, { "Content-Type": "application/json" });
382
+ res.end(JSON.stringify({ error: "Unauthorized" }));
383
+ return;
384
+ }
385
+ const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10);
386
+ const limit = Math.min(500, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 200));
387
+ await handleApiSessionEvents(res, opts.globalRoot, decodeSessionId(eventsMatch[1]), limit);
388
+ return;
389
+ }
390
+ const msgMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/message$/);
391
+ if (msgMatch && req.method === "POST") {
392
+ const headerToken = req.headers["x-ws-token"];
393
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
394
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
395
+ res.writeHead(401, { "Content-Type": "application/json" });
396
+ res.end(JSON.stringify({ error: "Unauthorized" }));
397
+ return;
398
+ }
399
+ await handleApiSessionMessage(res, req, opts.globalRoot, decodeSessionId(msgMatch[1]));
400
+ return;
401
+ }
402
+ const mailboxMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/mailbox$/);
403
+ if (mailboxMatch && req.method === "GET") {
404
+ const headerToken = req.headers["x-ws-token"];
405
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
406
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
407
+ res.writeHead(401, { "Content-Type": "application/json" });
408
+ res.end(JSON.stringify({ error: "Unauthorized" }));
409
+ return;
410
+ }
411
+ await handleApiSessionMailbox(res, opts.globalRoot, decodeSessionId(mailboxMatch[1]));
412
+ return;
413
+ }
414
+ const interruptMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/interrupt$/);
415
+ if (interruptMatch && req.method === "POST") {
416
+ const headerToken = req.headers["x-ws-token"];
417
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
418
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
419
+ res.writeHead(401, { "Content-Type": "application/json" });
420
+ res.end(JSON.stringify({ error: "Unauthorized" }));
421
+ return;
422
+ }
423
+ await handleApiSessionInterrupt(
424
+ res,
425
+ req,
426
+ opts.globalRoot,
427
+ decodeSessionId(interruptMatch[1])
428
+ );
429
+ return;
430
+ }
431
+ if (url.pathname === "/api/fleet/broadcast" && req.method === "POST") {
432
+ const headerToken = req.headers["x-ws-token"];
433
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
434
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
435
+ res.writeHead(401, { "Content-Type": "application/json" });
436
+ res.end(JSON.stringify({ error: "Unauthorized" }));
437
+ return;
438
+ }
439
+ await handleApiFleetBroadcast(res, req, opts.globalRoot);
440
+ return;
441
+ }
442
+ if (url.pathname === "/debug/watcher-metrics" && req.method === "GET") {
443
+ if (opts.watcherMetrics) {
444
+ const avgDelay = opts.watcherMetrics.broadcastsSent > 0 ? opts.watcherMetrics.totalDebounceDelayMs / opts.watcherMetrics.broadcastsSent : 0;
445
+ const response = {
446
+ ...opts.watcherMetrics,
447
+ averageDebounceDelayMs: avgDelay,
448
+ timestamp: Date.now()
449
+ };
450
+ res.writeHead(200, { "Content-Type": "application/json" });
451
+ res.end(JSON.stringify(response));
452
+ } else {
453
+ res.writeHead(503, { "Content-Type": "application/json" });
454
+ res.end(JSON.stringify({ error: "File watcher metrics not available" }));
455
+ }
185
456
  return;
186
457
  }
187
458
  let filePath;
@@ -313,6 +584,324 @@ async function handleApiSessionAgents(res, globalRoot, sessionId) {
313
584
  res.end(JSON.stringify({ error: String(err) }));
314
585
  }
315
586
  }
587
+ function blocksToText(content) {
588
+ if (typeof content === "string") return content;
589
+ if (Array.isArray(content)) {
590
+ return content.filter(
591
+ (b) => !!b && typeof b === "object" && b.type === "text" && typeof b.text === "string"
592
+ ).map((b) => b.text).join("\n");
593
+ }
594
+ return "";
595
+ }
596
+ function clip(s, n = 600) {
597
+ return s.length > n ? `${s.slice(0, n)}\u2026` : s;
598
+ }
599
+ function asString(v) {
600
+ if (typeof v === "string") return v;
601
+ try {
602
+ return JSON.stringify(v);
603
+ } catch {
604
+ return String(v);
605
+ }
606
+ }
607
+ function mapWatchEntry(ev) {
608
+ const ts = typeof ev["ts"] === "string" ? ev["ts"] : "";
609
+ switch (ev["type"]) {
610
+ case "user_input":
611
+ return { ts, role: "user", text: clip(blocksToText(ev["content"])) };
612
+ case "llm_response": {
613
+ const text = blocksToText(ev["content"]);
614
+ return text.trim() ? { ts, role: "assistant", text: clip(text) } : null;
615
+ }
616
+ case "tool_use":
617
+ case "tool_call_start": {
618
+ const input = ev["input"] ?? ev["args"];
619
+ const preview = input !== void 0 && input !== null ? clip(asString(input), 160) : "";
620
+ return { ts, role: "tool", tool: String(ev["name"] ?? "tool"), text: preview };
621
+ }
622
+ case "tool_result": {
623
+ if (ev["isError"]) return { ts, role: "error", text: clip(asString(ev["content"])) };
624
+ const out = asString(ev["content"]).trim();
625
+ return out ? { ts, role: "tool", tool: "\u21B3 result", text: clip(out, 240) } : null;
626
+ }
627
+ case "error":
628
+ case "provider_error":
629
+ return { ts, role: "error", text: clip(String(ev["message"] ?? "error")) };
630
+ case "agent_spawned":
631
+ return { ts, role: "system", text: `spawned ${String(ev["role"] ?? "agent")}` };
632
+ case "task_completed":
633
+ return { ts, role: "system", text: `task done: ${String(ev["title"] ?? "")}` };
634
+ case "task_failed":
635
+ return { ts, role: "system", text: `task failed: ${String(ev["title"] ?? "")}` };
636
+ default:
637
+ return null;
638
+ }
639
+ }
640
+ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
641
+ if (!globalRoot) {
642
+ res.writeHead(500, { "Content-Type": "application/json" });
643
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
644
+ return;
645
+ }
646
+ try {
647
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
648
+ const registry = new SessionRegistry(globalRoot);
649
+ const entry = await registry.get(sessionId);
650
+ if (!entry) {
651
+ res.writeHead(404, { "Content-Type": "application/json" });
652
+ res.end(JSON.stringify({ error: "Session not found" }));
653
+ return;
654
+ }
655
+ const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
656
+ const store = new DefaultSessionStore3({ dir: paths.projectSessions });
657
+ const reader = new DefaultSessionReader2({ store });
658
+ const all = [];
659
+ for await (const ev of reader.replay(sessionId)) {
660
+ const mapped = mapWatchEntry(ev);
661
+ if (mapped) all.push(mapped);
662
+ }
663
+ const tail = all.slice(-limit);
664
+ res.writeHead(200, { "Content-Type": "application/json" });
665
+ res.end(
666
+ JSON.stringify({
667
+ sessionId,
668
+ status: entry.status,
669
+ clientType: entry.clientType,
670
+ projectName: entry.projectName,
671
+ total: all.length,
672
+ entries: tail
673
+ })
674
+ );
675
+ } catch (err) {
676
+ res.writeHead(500, { "Content-Type": "application/json" });
677
+ res.end(JSON.stringify({ error: String(err) }));
678
+ }
679
+ }
680
+ function readJsonBody(req) {
681
+ return new Promise((resolve5, reject) => {
682
+ let data = "";
683
+ req.on("data", (chunk) => {
684
+ data += chunk;
685
+ if (data.length > 64e3) {
686
+ reject(new Error("Request body too large"));
687
+ req.destroy();
688
+ }
689
+ });
690
+ req.on("end", () => {
691
+ try {
692
+ resolve5(data ? JSON.parse(data) : {});
693
+ } catch (err) {
694
+ reject(err instanceof Error ? err : new Error(String(err)));
695
+ }
696
+ });
697
+ req.on("error", reject);
698
+ });
699
+ }
700
+ async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
701
+ if (!globalRoot) {
702
+ res.writeHead(500, { "Content-Type": "application/json" });
703
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
704
+ return;
705
+ }
706
+ let body;
707
+ try {
708
+ body = await readJsonBody(req);
709
+ } catch {
710
+ res.writeHead(400, { "Content-Type": "application/json" });
711
+ res.end(JSON.stringify({ error: "Invalid request body" }));
712
+ return;
713
+ }
714
+ const text = typeof body["text"] === "string" ? body["text"].trim() : "";
715
+ if (!text) {
716
+ res.writeHead(400, { "Content-Type": "application/json" });
717
+ res.end(JSON.stringify({ error: "text is required" }));
718
+ return;
719
+ }
720
+ const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
721
+ const ALLOWED = /* @__PURE__ */ new Set(["steer", "ask", "assign", "note", "btw"]);
722
+ const rawType = typeof body["type"] === "string" ? body["type"] : "steer";
723
+ const type = ALLOWED.has(rawType) ? rawType : "steer";
724
+ const rawPriority = typeof body["priority"] === "string" ? body["priority"] : "";
725
+ const priority = ["low", "normal", "high"].includes(rawPriority) ? rawPriority : "high";
726
+ const subject = typeof body["subject"] === "string" && body["subject"].trim() ? body["subject"].trim() : "Message from Fleet HQ";
727
+ try {
728
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
729
+ const registry = new SessionRegistry(globalRoot);
730
+ const entry = await registry.get(sessionId);
731
+ if (!entry) {
732
+ res.writeHead(404, { "Content-Type": "application/json" });
733
+ res.end(JSON.stringify({ error: "Session not found" }));
734
+ return;
735
+ }
736
+ const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
737
+ const mailbox = new GlobalMailbox3(paths.projectDir);
738
+ const to = `leader@${mailboxSessionTag2(sessionId)}`;
739
+ const sent = await mailbox.send({ from, to, type, subject, body: text, priority });
740
+ res.writeHead(200, { "Content-Type": "application/json" });
741
+ res.end(JSON.stringify({ ok: true, id: sent.id, to, type, delivered: entry.status }));
742
+ } catch (err) {
743
+ res.writeHead(500, { "Content-Type": "application/json" });
744
+ res.end(JSON.stringify({ error: String(err) }));
745
+ }
746
+ }
747
+ async function handleApiSessionMailbox(res, globalRoot, sessionId) {
748
+ if (!globalRoot) {
749
+ res.writeHead(500, { "Content-Type": "application/json" });
750
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
751
+ return;
752
+ }
753
+ try {
754
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
755
+ const registry = new SessionRegistry(globalRoot);
756
+ const entry = await registry.get(sessionId);
757
+ if (!entry) {
758
+ res.writeHead(404, { "Content-Type": "application/json" });
759
+ res.end(JSON.stringify({ error: "Session not found" }));
760
+ return;
761
+ }
762
+ const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
763
+ const mailbox = new GlobalMailbox3(paths.projectDir);
764
+ const leaderAddr = `leader@${mailboxSessionTag2(sessionId)}`;
765
+ const [inbound, outbound] = await Promise.all([
766
+ mailbox.query({ to: leaderAddr, limit: 50 }),
767
+ mailbox.query({ from: leaderAddr, limit: 50 })
768
+ ]);
769
+ const seen = /* @__PURE__ */ new Set();
770
+ const thread = [...inbound, ...outbound].filter((m) => {
771
+ if (seen.has(m.id)) return false;
772
+ seen.add(m.id);
773
+ return true;
774
+ }).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)).map((m) => ({
775
+ id: m.id,
776
+ from: m.from,
777
+ to: m.to,
778
+ type: m.type,
779
+ subject: m.subject,
780
+ body: m.body,
781
+ priority: m.priority,
782
+ // Whether the leader has read it, and when.
783
+ readByLeader: m.readBy?.[leaderAddr] ?? null,
784
+ readByCount: Object.keys(m.readBy ?? {}).length,
785
+ completed: m.completed,
786
+ outcome: m.outcome ?? null,
787
+ timestamp: m.timestamp,
788
+ replyTo: m.replyTo ?? null,
789
+ fromLeader: m.from === leaderAddr
790
+ }));
791
+ res.writeHead(200, { "Content-Type": "application/json" });
792
+ res.end(JSON.stringify({ sessionId, leader: leaderAddr, status: entry.status, thread }));
793
+ } catch (err) {
794
+ res.writeHead(500, { "Content-Type": "application/json" });
795
+ res.end(JSON.stringify({ error: String(err) }));
796
+ }
797
+ }
798
+ async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
799
+ if (!globalRoot) {
800
+ res.writeHead(500, { "Content-Type": "application/json" });
801
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
802
+ return;
803
+ }
804
+ let body = {};
805
+ try {
806
+ body = await readJsonBody(req);
807
+ } catch {
808
+ }
809
+ const reason = typeof body["reason"] === "string" && body["reason"].trim() ? body["reason"].trim() : "Operator requested stop from Fleet HQ";
810
+ const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
811
+ try {
812
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
813
+ const registry = new SessionRegistry(globalRoot);
814
+ const entry = await registry.get(sessionId);
815
+ if (!entry) {
816
+ res.writeHead(404, { "Content-Type": "application/json" });
817
+ res.end(JSON.stringify({ error: "Session not found" }));
818
+ return;
819
+ }
820
+ const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
821
+ const mailbox = new GlobalMailbox3(paths.projectDir);
822
+ const to = `leader@${mailboxSessionTag2(sessionId)}`;
823
+ const sent = await mailbox.send({
824
+ from,
825
+ to,
826
+ type: "control",
827
+ subject: "interrupt",
828
+ body: reason,
829
+ priority: "high"
830
+ });
831
+ res.writeHead(200, { "Content-Type": "application/json" });
832
+ res.end(JSON.stringify({ ok: true, id: sent.id, to, delivered: entry.status }));
833
+ } catch (err) {
834
+ res.writeHead(500, { "Content-Type": "application/json" });
835
+ res.end(JSON.stringify({ error: String(err) }));
836
+ }
837
+ }
838
+ async function handleApiFleetBroadcast(res, req, globalRoot) {
839
+ if (!globalRoot) {
840
+ res.writeHead(500, { "Content-Type": "application/json" });
841
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
842
+ return;
843
+ }
844
+ let body;
845
+ try {
846
+ body = await readJsonBody(req);
847
+ } catch {
848
+ res.writeHead(400, { "Content-Type": "application/json" });
849
+ res.end(JSON.stringify({ error: "Invalid request body" }));
850
+ return;
851
+ }
852
+ const text = typeof body["text"] === "string" ? body["text"].trim() : "";
853
+ if (!text) {
854
+ res.writeHead(400, { "Content-Type": "application/json" });
855
+ res.end(JSON.stringify({ error: "text is required" }));
856
+ return;
857
+ }
858
+ const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
859
+ try {
860
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
861
+ const registry = new SessionRegistry(globalRoot);
862
+ const all = await registry.list();
863
+ const mySlug = all.find((s) => s.pid === process.pid)?.projectSlug;
864
+ const targets = all.filter((s) => s.status !== "stale").filter((s) => mySlug ? s.projectSlug === mySlug : true);
865
+ if (targets.length === 0) {
866
+ res.writeHead(200, { "Content-Type": "application/json" });
867
+ res.end(JSON.stringify({ ok: true, delivered: 0 }));
868
+ return;
869
+ }
870
+ const mbByDir = /* @__PURE__ */ new Map();
871
+ const mailboxFor = (projectRoot) => {
872
+ const dir = resolveWstackPaths2({ projectRoot, globalRoot }).projectDir;
873
+ let mb = mbByDir.get(dir);
874
+ if (!mb) {
875
+ mb = new GlobalMailbox3(dir);
876
+ mbByDir.set(dir, mb);
877
+ }
878
+ return mb;
879
+ };
880
+ let delivered = 0;
881
+ await Promise.all(
882
+ targets.map(async (s) => {
883
+ try {
884
+ const mb = mailboxFor(s.projectRoot);
885
+ await mb.send({
886
+ from,
887
+ to: `leader@${mailboxSessionTag2(s.sessionId)}`,
888
+ type: "steer",
889
+ subject: "Broadcast from Fleet HQ",
890
+ body: text,
891
+ priority: "high"
892
+ });
893
+ delivered++;
894
+ } catch {
895
+ }
896
+ })
897
+ );
898
+ res.writeHead(200, { "Content-Type": "application/json" });
899
+ res.end(JSON.stringify({ ok: true, delivered, targets: targets.length }));
900
+ } catch (err) {
901
+ res.writeHead(500, { "Content-Type": "application/json" });
902
+ res.end(JSON.stringify({ error: String(err) }));
903
+ }
904
+ }
316
905
 
317
906
  // src/server/file-handlers.ts
318
907
  import * as fs2 from "fs/promises";
@@ -386,7 +975,7 @@ function broadcast(clients, msg) {
386
975
  }
387
976
  }
388
977
  }
389
- function sendResult(ws, success, message) {
978
+ function sendResult2(ws, success, message) {
390
979
  send(ws, { type: "key.operation_result", payload: { success, message } });
391
980
  }
392
981
  function errMessage(err) {
@@ -535,23 +1124,265 @@ async function handleMemoryRemember(ws, msg, memoryStore) {
535
1124
  const { text, scope } = msg.payload;
536
1125
  try {
537
1126
  await memoryStore.remember(text, scope ?? "project-memory");
538
- sendResult(ws, true, "Saved to memory");
1127
+ sendResult2(ws, true, "Saved to memory");
539
1128
  } catch (err) {
540
- sendResult(ws, false, errMessage(err));
1129
+ sendResult2(ws, false, errMessage(err));
541
1130
  }
542
1131
  }
543
1132
  async function handleMemoryForget(ws, msg, memoryStore) {
544
1133
  const { text, scope } = msg.payload;
545
1134
  try {
546
1135
  const removed = await memoryStore.forget(text, scope ?? "project-memory");
547
- sendResult(
1136
+ sendResult2(
548
1137
  ws,
549
1138
  removed > 0,
550
1139
  removed > 0 ? `Removed ${removed} entr${removed === 1 ? "y" : "ies"}` : "No matching entries"
551
1140
  );
552
1141
  } catch (err) {
553
- sendResult(ws, false, errMessage(err));
1142
+ sendResult2(ws, false, errMessage(err));
1143
+ }
1144
+ }
1145
+
1146
+ // src/server/mcp-handlers.ts
1147
+ import * as fs3 from "fs/promises";
1148
+ import * as path3 from "path";
1149
+ function isMcpServerRecord(val) {
1150
+ if (typeof val !== "object" || val === null) return false;
1151
+ return true;
1152
+ }
1153
+ function projectServer(name, cfg, _status = "stopped", tools = []) {
1154
+ return {
1155
+ name,
1156
+ transport: cfg.transport,
1157
+ status: _status,
1158
+ enabled: cfg.enabled ?? true,
1159
+ description: cfg.description,
1160
+ tools
1161
+ };
1162
+ }
1163
+ async function readConfig(configPath) {
1164
+ try {
1165
+ const content = await fs3.readFile(configPath, "utf-8");
1166
+ return JSON.parse(content);
1167
+ } catch {
1168
+ return {};
1169
+ }
1170
+ }
1171
+ async function writeConfig(configPath, cfg) {
1172
+ const dir = path3.dirname(configPath);
1173
+ await fs3.mkdir(dir, { recursive: true });
1174
+ await fs3.writeFile(configPath, JSON.stringify(cfg, null, 2), "utf-8");
1175
+ }
1176
+ async function getMcpServers(config, globalConfigPath) {
1177
+ const servers = [];
1178
+ const configured = isMcpServerRecord(config.mcpServers) ? config.mcpServers : {};
1179
+ for (const [name, cfg] of Object.entries(configured)) {
1180
+ servers.push(projectServer(name, cfg));
1181
+ }
1182
+ return servers;
1183
+ }
1184
+ function getRegistryStates(mcpRegistry) {
1185
+ const states = /* @__PURE__ */ new Map();
1186
+ if (!mcpRegistry?.list) return states;
1187
+ try {
1188
+ const list = mcpRegistry.list();
1189
+ for (const item of list) {
1190
+ states.set(item.name, { state: item.state, toolCount: item.toolCount });
1191
+ }
1192
+ } catch {
1193
+ }
1194
+ return states;
1195
+ }
1196
+ async function handleMcpList(ws, _msg, config, _globalConfigPath, mcpRegistry) {
1197
+ const servers = await getMcpServers(config, _globalConfigPath);
1198
+ const registryStates = getRegistryStates(mcpRegistry);
1199
+ for (const server of servers) {
1200
+ const registryState = registryStates.get(server.name);
1201
+ if (registryState) {
1202
+ server.status = registryState.state;
1203
+ server.tools = Array.from({ length: registryState.toolCount }, (_, i) => `tool-${i + 1}`);
1204
+ }
1205
+ }
1206
+ send(ws, { type: "mcp.list", payload: { servers } });
1207
+ }
1208
+ async function handleMcpAdd(ws, msg, config, globalConfigPath, mcpRegistry) {
1209
+ const payload = msg.payload;
1210
+ if (!payload.name) {
1211
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1212
+ return;
1213
+ }
1214
+ try {
1215
+ const diskConfig = await readConfig(globalConfigPath);
1216
+ const mcpServers = isMcpServerRecord(diskConfig.mcpServers) ? diskConfig.mcpServers : {};
1217
+ if (mcpServers[payload.name]) {
1218
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Server "${payload.name}" already exists` } });
1219
+ return;
1220
+ }
1221
+ mcpServers[payload.name] = {
1222
+ transport: payload.transport,
1223
+ description: payload.description,
1224
+ enabled: payload.enabled ?? true,
1225
+ command: payload.command,
1226
+ args: payload.args,
1227
+ env: payload.env,
1228
+ allowedTools: payload.allowedTools
1229
+ };
1230
+ diskConfig.mcpServers = mcpServers;
1231
+ await writeConfig(globalConfigPath, diskConfig);
1232
+ const newServer = projectServer(payload.name, mcpServers[payload.name]);
1233
+ send(ws, { type: "mcp.server.added", payload: { server: newServer } });
1234
+ if (mcpRegistry && (payload.enabled ?? true)) {
1235
+ const serverConfig = mcpServers[payload.name];
1236
+ try {
1237
+ await mcpRegistry.start({
1238
+ name: payload.name,
1239
+ transport: payload.transport,
1240
+ command: payload.command,
1241
+ args: payload.args,
1242
+ env: payload.env,
1243
+ allowedTools: payload.allowedTools,
1244
+ enabled: true
1245
+ });
1246
+ } catch (err) {
1247
+ send(ws, { type: "mcp.server.error", payload: { name: payload.name, error: String(err) } });
1248
+ }
1249
+ }
1250
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" added` } });
1251
+ } catch (err) {
1252
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to add server: ${err}` } });
1253
+ }
1254
+ }
1255
+ async function handleMcpRemove(ws, msg, _config, globalConfigPath, mcpRegistry) {
1256
+ const payload = msg.payload;
1257
+ if (!payload.name) {
1258
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1259
+ return;
1260
+ }
1261
+ try {
1262
+ if (mcpRegistry) {
1263
+ try {
1264
+ await mcpRegistry.stop(payload.name);
1265
+ } catch {
1266
+ }
1267
+ }
1268
+ const diskConfig = await readConfig(globalConfigPath);
1269
+ const mcpServers = isMcpServerRecord(diskConfig.mcpServers) ? diskConfig.mcpServers : {};
1270
+ if (!mcpServers[payload.name]) {
1271
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Server "${payload.name}" not found` } });
1272
+ return;
1273
+ }
1274
+ delete mcpServers[payload.name];
1275
+ diskConfig.mcpServers = mcpServers;
1276
+ await writeConfig(globalConfigPath, diskConfig);
1277
+ send(ws, { type: "mcp.server.removed", payload: { name: payload.name } });
1278
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" removed` } });
1279
+ } catch (err) {
1280
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to remove server: ${err}` } });
1281
+ }
1282
+ }
1283
+ async function handleMcpUpdate(ws, msg, _config, globalConfigPath) {
1284
+ const payload = msg.payload;
1285
+ if (!payload.name) {
1286
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1287
+ return;
1288
+ }
1289
+ try {
1290
+ const diskConfig = await readConfig(globalConfigPath);
1291
+ const mcpServers = isMcpServerRecord(diskConfig.mcpServers) ? diskConfig.mcpServers : {};
1292
+ if (!mcpServers[payload.name]) {
1293
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Server "${payload.name}" not found` } });
1294
+ return;
1295
+ }
1296
+ const existing = mcpServers[payload.name];
1297
+ mcpServers[payload.name] = {
1298
+ transport: payload.transport ?? existing.transport,
1299
+ description: payload.description ?? existing.description,
1300
+ enabled: payload.enabled ?? existing.enabled,
1301
+ command: payload.command ?? existing.command,
1302
+ args: payload.args ?? existing.args,
1303
+ env: payload.env ?? existing.env,
1304
+ allowedTools: payload.allowedTools ?? existing.allowedTools
1305
+ };
1306
+ diskConfig.mcpServers = mcpServers;
1307
+ await writeConfig(globalConfigPath, diskConfig);
1308
+ const updatedServer = projectServer(payload.name, mcpServers[payload.name]);
1309
+ send(ws, { type: "mcp.server.updated", payload: { server: updatedServer } });
1310
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" updated` } });
1311
+ } catch (err) {
1312
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to update server: ${err}` } });
1313
+ }
1314
+ }
1315
+ async function handleMcpWake(ws, msg, _config, _globalConfigPath, mcpRegistry) {
1316
+ const payload = msg.payload;
1317
+ if (!payload.name) {
1318
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1319
+ return;
1320
+ }
1321
+ if (!mcpRegistry) {
1322
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "MCP registry not available" } });
1323
+ return;
1324
+ }
1325
+ try {
1326
+ send(ws, { type: "mcp.server.waking", payload: { name: payload.name } });
1327
+ await mcpRegistry.restart(payload.name);
1328
+ send(ws, { type: "mcp.server.connected", payload: { name: payload.name } });
1329
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" restarted` } });
1330
+ } catch (err) {
1331
+ send(ws, { type: "mcp.server.error", payload: { name: payload.name, error: String(err) } });
1332
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to restart "${payload.name}": ${err}` } });
1333
+ }
1334
+ }
1335
+ async function handleMcpSleep(ws, msg, _config, _globalConfigPath, mcpRegistry) {
1336
+ const payload = msg.payload;
1337
+ if (!payload.name) {
1338
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1339
+ return;
1340
+ }
1341
+ if (!mcpRegistry) {
1342
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "MCP registry not available" } });
1343
+ return;
1344
+ }
1345
+ try {
1346
+ await mcpRegistry.stop(payload.name);
1347
+ send(ws, { type: "mcp.server.sleeping", payload: { name: payload.name } });
1348
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" stopped` } });
1349
+ } catch (err) {
1350
+ send(ws, { type: "mcp.server.error", payload: { name: payload.name, error: String(err) } });
1351
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to stop "${payload.name}": ${err}` } });
1352
+ }
1353
+ }
1354
+ async function handleMcpDiscover(ws, msg, _config, _globalConfigPath, _mcpRegistry) {
1355
+ const payload = msg.payload;
1356
+ if (!payload.name) {
1357
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1358
+ return;
1359
+ }
1360
+ send(ws, { type: "mcp.server.discovered", payload: { name: payload.name, tools: [] } });
1361
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" tools were discovered on connect` } });
1362
+ }
1363
+ async function handleMcpEnable(ws, msg, _config, _globalConfigPath) {
1364
+ const payload = msg.payload;
1365
+ if (!payload.name) {
1366
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1367
+ return;
1368
+ }
1369
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Enable command sent for "${payload.name}"` } });
1370
+ }
1371
+ async function handleMcpDisable(ws, msg, _config, _globalConfigPath) {
1372
+ const payload = msg.payload;
1373
+ if (!payload.name) {
1374
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1375
+ return;
554
1376
  }
1377
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Disable command sent for "${payload.name}"` } });
1378
+ }
1379
+ async function handleMcpRestart(ws, msg, _config, _globalConfigPath) {
1380
+ const payload = msg.payload;
1381
+ if (!payload.name) {
1382
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1383
+ return;
1384
+ }
1385
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Restart command sent for "${payload.name}"` } });
555
1386
  }
556
1387
 
557
1388
  // src/server/index.ts
@@ -711,6 +1542,7 @@ function patchConfig(config, updates) {
711
1542
 
712
1543
  // src/server/autophase-ws-handler.ts
713
1544
  import { spawnSync } from "child_process";
1545
+ import { toErrorMessage } from "@wrongstack/core/utils";
714
1546
  import {
715
1547
  AutoPhasePlanner,
716
1548
  PhaseGraphBuilder,
@@ -880,7 +1712,7 @@ var AutoPhaseWebSocketHandler = class {
880
1712
  );
881
1713
  this.broadcastState();
882
1714
  }).catch((err) => {
883
- this.logger.error(`[AutoPhase] Aborted: ${err instanceof Error ? err.message : String(err)}`);
1715
+ this.logger.error(`[AutoPhase] Aborted: ${toErrorMessage(err)}`);
884
1716
  this.stopBroadcast();
885
1717
  this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
886
1718
  });
@@ -913,7 +1745,7 @@ var AutoPhaseWebSocketHandler = class {
913
1745
  }
914
1746
  this.logger.info(`[AutoPhase] Planner produced no phases; using defaults for: ${goal}`);
915
1747
  } catch (err) {
916
- this.logger.error(`[AutoPhase] Planning failed, using defaults: ${err instanceof Error ? err.message : String(err)}`);
1748
+ this.logger.error(`[AutoPhase] Planning failed, using defaults: ${toErrorMessage(err)}`);
917
1749
  }
918
1750
  return this.defaultPhases();
919
1751
  }
@@ -1040,6 +1872,7 @@ Type: ${task.type}`;
1040
1872
 
1041
1873
  // src/server/collaboration-ws-handler.ts
1042
1874
  import { randomUUID } from "crypto";
1875
+ import { toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils";
1043
1876
  var REPLAY_LIMIT = 50;
1044
1877
  var PAUSE_TIMEOUT_MS = 6e4;
1045
1878
  var CollaborationWebSocketHandler = class {
@@ -1167,7 +2000,7 @@ var CollaborationWebSocketHandler = class {
1167
2000
  if (this.reader) {
1168
2001
  this.replayHistory(ws, sessionId).catch((err) => {
1169
2002
  this.logger.debug?.(
1170
- `collab: replay failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`
2003
+ `collab: replay failed for ${sessionId}: ${toErrorMessage2(err)}`
1171
2004
  );
1172
2005
  });
1173
2006
  }
@@ -1277,7 +2110,7 @@ var CollaborationWebSocketHandler = class {
1277
2110
  this.send(
1278
2111
  ws,
1279
2112
  this.errorMessage(
1280
- `annotation rejected: ${err instanceof Error ? err.message : String(err)}`
2113
+ `annotation rejected: ${toErrorMessage2(err)}`
1281
2114
  )
1282
2115
  );
1283
2116
  }
@@ -1344,7 +2177,7 @@ var CollaborationWebSocketHandler = class {
1344
2177
  this.send(
1345
2178
  ws,
1346
2179
  this.errorMessage(
1347
- `resolve failed: ${err instanceof Error ? err.message : String(err)}`
2180
+ `resolve failed: ${toErrorMessage2(err)}`
1348
2181
  )
1349
2182
  );
1350
2183
  }
@@ -1392,7 +2225,7 @@ var CollaborationWebSocketHandler = class {
1392
2225
  if (p.ws.readyState === 1) p.ws.send(data);
1393
2226
  } catch (err) {
1394
2227
  this.logger.debug?.(
1395
- `collab broadcast failed: ${err instanceof Error ? err.message : String(err)}`
2228
+ `collab broadcast failed: ${toErrorMessage2(err)}`
1396
2229
  );
1397
2230
  }
1398
2231
  }
@@ -1419,7 +2252,7 @@ var CollaborationWebSocketHandler = class {
1419
2252
  }
1420
2253
  } catch (err) {
1421
2254
  this.logger.debug?.(
1422
- `collab: session reader rejected ${sessionId}: ${err instanceof Error ? err.message : String(err)}`
2255
+ `collab: session reader rejected ${sessionId}: ${toErrorMessage2(err)}`
1423
2256
  );
1424
2257
  return;
1425
2258
  }
@@ -1500,7 +2333,7 @@ var CollaborationWebSocketHandler = class {
1500
2333
  if (p.ws.readyState === 1) p.ws.send(data);
1501
2334
  } catch (err) {
1502
2335
  this.logger.debug?.(
1503
- `collab broadcast failed: ${err instanceof Error ? err.message : String(err)}`
2336
+ `collab broadcast failed: ${toErrorMessage2(err)}`
1504
2337
  );
1505
2338
  }
1506
2339
  }
@@ -1697,6 +2530,7 @@ var CollaborationWebSocketHandler = class {
1697
2530
  };
1698
2531
 
1699
2532
  // src/server/worktree-ws-handler.ts
2533
+ import { toErrorMessage as toErrorMessage3 } from "@wrongstack/core/utils";
1700
2534
  var MAX_ACTIVITY = 6;
1701
2535
  var WorktreeWebSocketHandler = class {
1702
2536
  constructor(events, logger) {
@@ -1822,7 +2656,7 @@ var WorktreeWebSocketHandler = class {
1822
2656
  try {
1823
2657
  if (ws.readyState === 1) ws.send(data);
1824
2658
  } catch (err) {
1825
- this.logger.debug?.(`worktree broadcast failed: ${err instanceof Error ? err.message : String(err)}`);
2659
+ this.logger.debug?.(`worktree broadcast failed: ${toErrorMessage3(err)}`);
1826
2660
  }
1827
2661
  }
1828
2662
  }
@@ -1835,22 +2669,14 @@ var WorktreeWebSocketHandler = class {
1835
2669
  };
1836
2670
 
1837
2671
  // src/server/mailbox-handlers.ts
1838
- import * as path3 from "path";
1839
- import { GlobalMailbox } from "@wrongstack/core";
1840
- function resolveProjectDir(projectRoot, globalRoot) {
1841
- const { createHash } = __require("crypto");
1842
- const hash = createHash("sha256").update(path3.resolve(projectRoot)).digest("hex").slice(0, 6);
1843
- const slug = path3.basename(projectRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40) || "project";
1844
- return path3.join(globalRoot, "projects", `${slug}-${hash}`);
1845
- }
2672
+ import { GlobalMailbox, resolveProjectDir } from "@wrongstack/core";
1846
2673
  async function handleMailboxMessages(ws, deps, payload) {
1847
2674
  try {
1848
2675
  const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
1849
2676
  const mb = new GlobalMailbox(dir);
1850
2677
  const messages = await mb.query({
1851
2678
  limit: payload?.limit ?? 30,
1852
- to: payload?.agentId,
1853
- unreadBy: payload?.unreadOnly ? payload.agentId : void 0
2679
+ incompleteOnly: payload?.incompleteOnly ?? false
1854
2680
  });
1855
2681
  send(ws, {
1856
2682
  type: "mailbox.messages",
@@ -1867,10 +2693,12 @@ async function handleMailboxMessages(ws, deps, payload) {
1867
2693
  readByCount: Object.keys(m.readBy).length,
1868
2694
  completed: m.completed,
1869
2695
  completedBy: m.completedBy,
2696
+ completedAt: m.completedAt,
1870
2697
  outcome: m.outcome,
1871
2698
  timestamp: m.timestamp,
1872
2699
  replyTo: m.replyTo,
1873
- senderSessionId: m.senderSessionId
2700
+ senderSessionId: m.senderSessionId,
2701
+ taskContext: m.taskContext
1874
2702
  }))
1875
2703
  }
1876
2704
  });
@@ -1917,6 +2745,16 @@ async function handleMailboxClear(ws, deps) {
1917
2745
  send(ws, { type: "mailbox.cleared", payload: { error: errMessage(err) } });
1918
2746
  }
1919
2747
  }
2748
+ async function handleMailboxPurge(ws, deps, opts) {
2749
+ try {
2750
+ const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
2751
+ const mb = new GlobalMailbox(dir);
2752
+ const result = await mb.purgeStale(opts);
2753
+ send(ws, { type: "mailbox.purged", payload: result });
2754
+ } catch (err) {
2755
+ send(ws, { type: "mailbox.purged", payload: { error: errMessage(err) } });
2756
+ }
2757
+ }
1920
2758
 
1921
2759
  // src/server/lifecycle.ts
1922
2760
  function createShutdown(res) {
@@ -1957,7 +2795,7 @@ function registerShutdownHandlers(res) {
1957
2795
  // src/server/instance-registry.ts
1958
2796
  import * as os from "os";
1959
2797
  import * as path4 from "path";
1960
- import * as fs3 from "fs/promises";
2798
+ import * as fs4 from "fs/promises";
1961
2799
  import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
1962
2800
  function defaultBaseDir() {
1963
2801
  return path4.join(os.homedir(), ".wrongstack");
@@ -1976,7 +2814,7 @@ function isPidAlive(pid) {
1976
2814
  }
1977
2815
  async function load(file) {
1978
2816
  try {
1979
- const raw = await fs3.readFile(file, "utf8");
2817
+ const raw = await fs4.readFile(file, "utf8");
1980
2818
  const parsed = JSON.parse(raw);
1981
2819
  if (parsed?.version === 1 && Array.isArray(parsed.instances)) {
1982
2820
  return parsed;
@@ -2035,16 +2873,16 @@ function formatInstances(instances) {
2035
2873
  // src/server/port-utils.ts
2036
2874
  import * as net from "net";
2037
2875
  function isPortFree(host, port) {
2038
- return new Promise((resolve6) => {
2876
+ return new Promise((resolve5) => {
2039
2877
  const srv = net.createServer();
2040
- srv.once("error", () => resolve6(false));
2878
+ srv.once("error", () => resolve5(false));
2041
2879
  srv.once("listening", () => {
2042
- srv.close(() => resolve6(true));
2880
+ srv.close(() => resolve5(true));
2043
2881
  });
2044
2882
  try {
2045
2883
  srv.listen(port, host);
2046
2884
  } catch {
2047
- resolve6(false);
2885
+ resolve5(false);
2048
2886
  }
2049
2887
  });
2050
2888
  }
@@ -2119,8 +2957,12 @@ function computeUsageCost(usage, rates) {
2119
2957
  return (usage.input * rates.input + usage.output * rates.output + (usage.cacheRead ?? 0) * rates.cacheRead) / 1e6;
2120
2958
  }
2121
2959
 
2960
+ // src/server/provider-handlers.ts
2961
+ import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/core";
2962
+ import { probeLocalLlm } from "@wrongstack/runtime/probe";
2963
+
2122
2964
  // src/server/provider-config-io.ts
2123
- import * as fs4 from "fs/promises";
2965
+ import * as fs5 from "fs/promises";
2124
2966
  import * as path5 from "path";
2125
2967
  import { atomicWrite as atomicWrite3 } from "@wrongstack/core";
2126
2968
  import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/security";
@@ -2128,7 +2970,7 @@ import { DefaultSecretVault } from "@wrongstack/core";
2128
2970
  async function loadSavedProviders(configPath, vault) {
2129
2971
  let raw;
2130
2972
  try {
2131
- raw = await fs4.readFile(configPath, "utf8");
2973
+ raw = await fs5.readFile(configPath, "utf8");
2132
2974
  } catch {
2133
2975
  return {};
2134
2976
  }
@@ -2145,7 +2987,7 @@ async function saveProviders(configPath, vault, providers) {
2145
2987
  let raw;
2146
2988
  let fileExists = true;
2147
2989
  try {
2148
- raw = await fs4.readFile(configPath, "utf8");
2990
+ raw = await fs5.readFile(configPath, "utf8");
2149
2991
  } catch (err) {
2150
2992
  if (err.code !== "ENOENT") {
2151
2993
  throw new Error(
@@ -2173,6 +3015,9 @@ async function saveProviders(configPath, vault, providers) {
2173
3015
  await atomicWrite3(configPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
2174
3016
  }
2175
3017
 
3018
+ // src/server/provider-handlers.ts
3019
+ import { toErrorMessage as toErrorMessage4 } from "@wrongstack/core/utils";
3020
+
2176
3021
  // src/server/provider-keys.ts
2177
3022
  import { expectDefined } from "@wrongstack/core";
2178
3023
  function normalizeKeys(cfg) {
@@ -2193,7 +3038,7 @@ function writeKeysBack(cfg, keys) {
2193
3038
  }
2194
3039
  cfg.apiKeys = keys;
2195
3040
  const active = keys.find((k) => k.label === cfg.activeKey) ?? expectDefined(keys[0]);
2196
- cfg.apiKey = active.apiKey;
3041
+ delete cfg.apiKey;
2197
3042
  if (!cfg.activeKey || !keys.some((k) => k.label === cfg.activeKey)) {
2198
3043
  cfg.activeKey = active.label;
2199
3044
  }
@@ -2270,6 +3115,28 @@ function removeProvider(providers, providerId) {
2270
3115
  }
2271
3116
 
2272
3117
  // src/server/provider-handlers.ts
3118
+ function projectSavedProviders(providers) {
3119
+ return Object.entries(providers).map(([id, cfg]) => {
3120
+ const keys = normalizeKeys(cfg);
3121
+ const models = cfg.models;
3122
+ const view = {
3123
+ id,
3124
+ family: cfg.family ?? id,
3125
+ baseUrl: cfg.baseUrl,
3126
+ models,
3127
+ apiKeys: keys.map((k) => ({
3128
+ label: k.label,
3129
+ maskedKey: maskedKey(k.apiKey),
3130
+ isActive: k.label === cfg.activeKey,
3131
+ createdAt: k.createdAt
3132
+ }))
3133
+ };
3134
+ const picked = models && models.length > 0 ? models[0] : void 0;
3135
+ if (picked !== void 0) view.pickedModelId = picked;
3136
+ return view;
3137
+ });
3138
+ }
3139
+ var probeScrubber = new DefaultSecretScrubber2();
2273
3140
  function createProviderHandlers(deps) {
2274
3141
  const { globalConfigPath, vault, broadcast: broadcast2, clients } = deps;
2275
3142
  let configWriteLock = deps.getConfigWriteLock();
@@ -2278,7 +3145,7 @@ function createProviderHandlers(deps) {
2278
3145
  }
2279
3146
  async function saveConfigProviders(providers) {
2280
3147
  const next = configWriteLock.then(() => saveProviders(globalConfigPath, vault, providers)).catch((err) => {
2281
- const msg = err instanceof Error ? err.message : String(err);
3148
+ const msg = toErrorMessage4(err);
2282
3149
  console.error(JSON.stringify({
2283
3150
  level: "error",
2284
3151
  event: "webui.provider_save_failed",
@@ -2295,9 +3162,9 @@ function createProviderHandlers(deps) {
2295
3162
  const providers = await loadConfigProviders();
2296
3163
  const result = upsertKey(providers, providerId, label, apiKey, (/* @__PURE__ */ new Date()).toISOString());
2297
3164
  if (result.ok) await saveConfigProviders(providers);
2298
- sendResult(ws, result.ok, result.message);
3165
+ sendResult2(ws, result.ok, result.message);
2299
3166
  } catch (err) {
2300
- sendResult(ws, false, errMessage(err));
3167
+ sendResult2(ws, false, errMessage(err));
2301
3168
  }
2302
3169
  }
2303
3170
  async function handleKeyDelete(ws, providerId, label) {
@@ -2305,9 +3172,9 @@ function createProviderHandlers(deps) {
2305
3172
  const providers = await loadConfigProviders();
2306
3173
  const result = deleteKey(providers, providerId, label);
2307
3174
  if (result.ok) await saveConfigProviders(providers);
2308
- sendResult(ws, result.ok, result.message);
3175
+ sendResult2(ws, result.ok, result.message);
2309
3176
  } catch (err) {
2310
- sendResult(ws, false, errMessage(err));
3177
+ sendResult2(ws, false, errMessage(err));
2311
3178
  }
2312
3179
  }
2313
3180
  async function handleKeySetActive(ws, providerId, label) {
@@ -2315,9 +3182,9 @@ function createProviderHandlers(deps) {
2315
3182
  const providers = await loadConfigProviders();
2316
3183
  const result = setActiveKey(providers, providerId, label);
2317
3184
  if (result.ok) await saveConfigProviders(providers);
2318
- sendResult(ws, result.ok, result.message);
3185
+ sendResult2(ws, result.ok, result.message);
2319
3186
  } catch (err) {
2320
- sendResult(ws, false, errMessage(err));
3187
+ sendResult2(ws, false, errMessage(err));
2321
3188
  }
2322
3189
  }
2323
3190
  async function handleProviderAdd(ws, payload) {
@@ -2325,31 +3192,13 @@ function createProviderHandlers(deps) {
2325
3192
  const providers = await loadConfigProviders();
2326
3193
  const result = addProvider(providers, payload, (/* @__PURE__ */ new Date()).toISOString());
2327
3194
  if (result.ok) await saveConfigProviders(providers);
2328
- sendResult(ws, result.ok, result.message);
3195
+ sendResult2(ws, result.ok, result.message);
2329
3196
  if (result.ok) {
2330
3197
  console.log(`[WebUI] Provider "${payload.id}" added via provider.add`);
2331
- broadcast2(clients, {
2332
- type: "providers.saved",
2333
- payload: {
2334
- providers: Object.entries(providers).map(([id, cfg]) => {
2335
- const keys = normalizeKeys(cfg);
2336
- return {
2337
- id,
2338
- family: cfg.family ?? id,
2339
- baseUrl: cfg.baseUrl,
2340
- apiKeys: keys.map((k) => ({
2341
- label: k.label,
2342
- maskedKey: maskedKey(k.apiKey),
2343
- isActive: k.label === cfg.activeKey,
2344
- createdAt: k.createdAt
2345
- }))
2346
- };
2347
- })
2348
- }
2349
- });
3198
+ broadcastSaved(providers);
2350
3199
  }
2351
3200
  } catch (err) {
2352
- sendResult(ws, false, errMessage(err));
3201
+ sendResult2(ws, false, errMessage(err));
2353
3202
  }
2354
3203
  }
2355
3204
  async function handleProviderRemove(ws, providerId) {
@@ -2357,18 +3206,116 @@ function createProviderHandlers(deps) {
2357
3206
  const providers = await loadConfigProviders();
2358
3207
  const result = removeProvider(providers, providerId);
2359
3208
  if (result.ok) await saveConfigProviders(providers);
2360
- sendResult(ws, result.ok, result.message);
3209
+ sendResult2(ws, result.ok, result.message);
3210
+ } catch (err) {
3211
+ sendResult2(ws, false, errMessage(err));
3212
+ }
3213
+ }
3214
+ function broadcastSaved(providers) {
3215
+ broadcast2(clients, {
3216
+ type: "providers.saved",
3217
+ payload: { providers: projectSavedProviders(providers) }
3218
+ });
3219
+ }
3220
+ async function handleProviderClearModels(ws, providerId) {
3221
+ try {
3222
+ const providers = await loadConfigProviders();
3223
+ const cfg = providers[providerId];
3224
+ if (!cfg) {
3225
+ sendResult2(ws, false, `Unknown provider "${providerId}"`);
3226
+ return;
3227
+ }
3228
+ delete cfg.models;
3229
+ await saveConfigProviders(providers);
3230
+ sendResult2(ws, true, `Cleared model allowlist for ${providerId}`);
3231
+ broadcastSaved(providers);
3232
+ } catch (err) {
3233
+ sendResult2(ws, false, errMessage(err));
3234
+ }
3235
+ }
3236
+ async function handleProviderUndoClear(ws, providerId, previousModels) {
3237
+ try {
3238
+ const providers = await loadConfigProviders();
3239
+ const cfg = providers[providerId];
3240
+ if (!cfg) {
3241
+ sendResult2(ws, false, `Unknown provider "${providerId}"`);
3242
+ return;
3243
+ }
3244
+ cfg.models = [...previousModels];
3245
+ await saveConfigProviders(providers);
3246
+ sendResult2(ws, true, `Restored ${previousModels.length} model(s) for ${providerId}`);
3247
+ broadcastSaved(providers);
3248
+ } catch (err) {
3249
+ sendResult2(ws, false, errMessage(err));
3250
+ }
3251
+ }
3252
+ async function handleProviderUpdate(ws, payload) {
3253
+ try {
3254
+ const providers = await loadConfigProviders();
3255
+ const cfg = providers[payload.id];
3256
+ if (!cfg) {
3257
+ sendResult2(ws, false, `Unknown provider "${payload.id}"`);
3258
+ return;
3259
+ }
3260
+ if (payload.family !== void 0) cfg.family = payload.family;
3261
+ if (payload.baseUrl !== void 0) cfg.baseUrl = payload.baseUrl;
3262
+ if (payload.envVars !== void 0) cfg.envVars = payload.envVars;
3263
+ if (payload.models !== void 0) cfg.models = payload.models;
3264
+ await saveConfigProviders(providers);
3265
+ sendResult2(ws, true, `Updated ${payload.id}`);
3266
+ broadcastSaved(providers);
3267
+ } catch (err) {
3268
+ sendResult2(ws, false, errMessage(err));
3269
+ }
3270
+ }
3271
+ async function handleProviderProbe(ws, providerId, timeoutMs) {
3272
+ const reply = (payload) => send(ws, { type: "provider.probe", payload: { providerId, ...payload } });
3273
+ try {
3274
+ const providers = await loadConfigProviders();
3275
+ const cfg = providers[providerId];
3276
+ if (!cfg) {
3277
+ reply({ ok: false, status: "no_provider" });
3278
+ return;
3279
+ }
3280
+ if (!cfg.baseUrl) {
3281
+ reply({ ok: false, status: "no_base_url" });
3282
+ return;
3283
+ }
3284
+ const keys = normalizeKeys(cfg);
3285
+ const active = keys.find((k) => k.label === cfg.activeKey) ?? keys[0];
3286
+ const result = await probeLocalLlm({
3287
+ baseUrl: cfg.baseUrl,
3288
+ apiKey: active?.apiKey,
3289
+ noAuth: false,
3290
+ scrubber: probeScrubber,
3291
+ ...timeoutMs !== void 0 ? { timeoutMs } : {}
3292
+ });
3293
+ reply(result);
2361
3294
  } catch (err) {
2362
- sendResult(ws, false, errMessage(err));
3295
+ reply({ ok: false, status: "unreachable", detail: errMessage(err) });
2363
3296
  }
2364
3297
  }
2365
- return { handleKeyUpsert, handleKeyDelete, handleKeySetActive, handleProviderAdd, handleProviderRemove, loadConfigProviders };
3298
+ return {
3299
+ handleKeyUpsert,
3300
+ handleKeyDelete,
3301
+ handleKeySetActive,
3302
+ handleProviderAdd,
3303
+ handleProviderRemove,
3304
+ handleProviderClearModels,
3305
+ handleProviderUndoClear,
3306
+ handleProviderUpdate,
3307
+ handleProviderProbe,
3308
+ loadConfigProviders
3309
+ };
2366
3310
  }
2367
3311
 
2368
3312
  // src/server/setup-events.ts
3313
+ import * as fs6 from "fs/promises";
3314
+ import { watch as fsWatch } from "fs";
2369
3315
  import * as path6 from "path";
2370
3316
  function setupEvents(deps) {
2371
- const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge } = deps;
3317
+ const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge, wpaths, watcherMetrics, onFleetBroadcaster } = deps;
3318
+ const disposers = [];
2372
3319
  events.on("iteration.started", (e) => {
2373
3320
  const maxIt = typeof context.meta["maxIterations"] === "number" ? context.meta["maxIterations"] : config.tools?.maxIterations ?? 100;
2374
3321
  broadcast2(clients, {
@@ -2399,7 +3346,11 @@ function setupEvents(deps) {
2399
3346
  events.on("tool.progress", (e) => {
2400
3347
  broadcast2(clients, {
2401
3348
  type: "tool.progress",
2402
- payload: { id: e.id, name: e.name, eventType: e.event.type, text: e.event.text }
3349
+ // Nested `event` shape the client handler reads `payload.event?.text`
3350
+ // and early-returns on a falsy text, so a flat { eventType, text } payload
3351
+ // makes live tool progress (bash streaming, partial_output, warnings)
3352
+ // never render. Must match WSToolProgress and the CLI server.
3353
+ payload: { id: e.id, name: e.name, event: { type: e.event.type, text: e.event.text, data: e.event.data } }
2403
3354
  });
2404
3355
  sessionBridge?.append({
2405
3356
  type: "tool_progress",
@@ -2565,20 +3516,165 @@ function setupEvents(deps) {
2565
3516
  events.onPattern("brain.*", (eventName, payload) => {
2566
3517
  broadcast2(clients, { type: "brain.event", payload: { event: eventName, ...payload } });
2567
3518
  });
3519
+ events.on("client.status", async (e) => {
3520
+ broadcast2(clients, { type: "client.status_update", payload: e });
3521
+ if (wpaths?.projectStatus) {
3522
+ try {
3523
+ const statusFile = wpaths.projectStatus(e.projectHash);
3524
+ const dir = path6.dirname(statusFile);
3525
+ await fs6.mkdir(dir, { recursive: true });
3526
+ await fs6.writeFile(statusFile, JSON.stringify(e, null, 2), "utf-8");
3527
+ } catch (err) {
3528
+ console.error("[setup-events] Failed to write status.json:", err);
3529
+ }
3530
+ }
3531
+ });
3532
+ if (wpaths?.projectStatus && wpaths.configDir) {
3533
+ const projectsDir = path6.join(wpaths.configDir, "projects");
3534
+ const knownProjectHashes = /* @__PURE__ */ new Set();
3535
+ const debounceTimers = /* @__PURE__ */ new Map();
3536
+ const DEBOUNCE_MS = 150;
3537
+ const pendingStatuses = /* @__PURE__ */ new Map();
3538
+ if (watcherMetrics) {
3539
+ watcherMetrics.fileChangesDetected = 0;
3540
+ watcherMetrics.filesProcessed = 0;
3541
+ watcherMetrics.broadcastsSent = 0;
3542
+ watcherMetrics.debounceResets = 0;
3543
+ watcherMetrics.totalDebounceDelayMs = 0;
3544
+ watcherMetrics.activeProjects = 0;
3545
+ watcherMetrics.averageDebounceDelayMs = 0;
3546
+ watcherMetrics.watcherActive = true;
3547
+ }
3548
+ const getAverageDebounceDelay = () => {
3549
+ if (!watcherMetrics || watcherMetrics.broadcastsSent === 0) return 0;
3550
+ return watcherMetrics.totalDebounceDelayMs / watcherMetrics.broadcastsSent;
3551
+ };
3552
+ const logWatcherMetrics = () => {
3553
+ if (!watcherMetrics) return;
3554
+ watcherMetrics.averageDebounceDelayMs = getAverageDebounceDelay();
3555
+ console.log(
3556
+ `[setup-events] File watcher stats: ${watcherMetrics.broadcastsSent} broadcasts, ${watcherMetrics.fileChangesDetected} file changes, ${watcherMetrics.debounceResets} debounce resets, avg delay: ${watcherMetrics.averageDebounceDelayMs.toFixed(1)}ms, ${watcherMetrics.activeProjects} active projects`
3557
+ );
3558
+ };
3559
+ const metricsInterval = setInterval(logWatcherMetrics, 6e4);
3560
+ const broadcastStatus = (projectHash2, statusData, actualDelayMs) => {
3561
+ broadcast2(clients, { type: "client.status_update", payload: statusData });
3562
+ if (watcherMetrics) {
3563
+ watcherMetrics.broadcastsSent++;
3564
+ watcherMetrics.totalDebounceDelayMs += actualDelayMs;
3565
+ watcherMetrics.averageDebounceDelayMs = getAverageDebounceDelay();
3566
+ }
3567
+ };
3568
+ const scheduleBroadcast = (projectHash2, statusData) => {
3569
+ const now = Date.now();
3570
+ const existing = pendingStatuses.get(projectHash2);
3571
+ if (existing && watcherMetrics) {
3572
+ watcherMetrics.debounceResets++;
3573
+ }
3574
+ pendingStatuses.set(projectHash2, {
3575
+ data: statusData,
3576
+ firstWriteAt: existing ? existing.firstWriteAt : now
3577
+ });
3578
+ const existingTimer = debounceTimers.get(projectHash2);
3579
+ if (existingTimer) {
3580
+ clearTimeout(existingTimer);
3581
+ }
3582
+ const timer = setTimeout(() => {
3583
+ debounceTimers.delete(projectHash2);
3584
+ const pending = pendingStatuses.get(projectHash2);
3585
+ if (pending) {
3586
+ const actualDelay = Date.now() - pending.firstWriteAt;
3587
+ broadcastStatus(projectHash2, pending.data, actualDelay);
3588
+ pendingStatuses.delete(projectHash2);
3589
+ }
3590
+ }, DEBOUNCE_MS);
3591
+ debounceTimers.set(projectHash2, timer);
3592
+ };
3593
+ let watcher;
3594
+ const startWatcher = async () => {
3595
+ try {
3596
+ await fs6.mkdir(projectsDir, { recursive: true });
3597
+ watcher = fsWatch(projectsDir, { persistent: true, recursive: true }, async (eventType, filename) => {
3598
+ if (eventType === "change") {
3599
+ if (filename == null) return;
3600
+ if (watcherMetrics) watcherMetrics.fileChangesDetected++;
3601
+ const targetFile = path6.join(projectsDir, String(filename));
3602
+ if (targetFile.endsWith("status.json")) {
3603
+ const projectHash2 = path6.basename(path6.dirname(targetFile));
3604
+ if (knownProjectHashes.size > 0 && !knownProjectHashes.has(projectHash2)) {
3605
+ return;
3606
+ }
3607
+ if (watcherMetrics) watcherMetrics.filesProcessed++;
3608
+ try {
3609
+ const content = await fs6.readFile(targetFile, "utf-8");
3610
+ const statusData = JSON.parse(content);
3611
+ if (statusData.projectHash) {
3612
+ const hash = String(statusData.projectHash);
3613
+ if (!knownProjectHashes.has(hash)) {
3614
+ knownProjectHashes.add(hash);
3615
+ if (watcherMetrics) watcherMetrics.activeProjects = knownProjectHashes.size;
3616
+ }
3617
+ }
3618
+ scheduleBroadcast(projectHash2, statusData);
3619
+ } catch {
3620
+ }
3621
+ }
3622
+ }
3623
+ });
3624
+ console.log(`[setup-events] Watching ${projectsDir} for status.json changes (hash-filtered, debounced)`);
3625
+ } catch (err) {
3626
+ console.error("[setup-events] Failed to start status file watcher:", err);
3627
+ }
3628
+ };
3629
+ events.on("client.status", (e) => {
3630
+ if (e.projectHash) {
3631
+ const hash = String(e.projectHash);
3632
+ if (!knownProjectHashes.has(hash)) {
3633
+ knownProjectHashes.add(hash);
3634
+ if (watcherMetrics) watcherMetrics.activeProjects = knownProjectHashes.size;
3635
+ }
3636
+ }
3637
+ });
3638
+ startWatcher();
3639
+ disposers.push(() => {
3640
+ clearInterval(metricsInterval);
3641
+ logWatcherMetrics();
3642
+ if (watcherMetrics) watcherMetrics.watcherActive = false;
3643
+ for (const [projectHash2, pending] of pendingStatuses) {
3644
+ const timer = debounceTimers.get(projectHash2);
3645
+ if (timer) {
3646
+ clearTimeout(timer);
3647
+ broadcastStatus(projectHash2, pending.data, 0);
3648
+ }
3649
+ }
3650
+ for (const timer of debounceTimers.values()) {
3651
+ clearTimeout(timer);
3652
+ }
3653
+ debounceTimers.clear();
3654
+ pendingStatuses.clear();
3655
+ if (watcher) {
3656
+ watcher.close();
3657
+ console.log("[setup-events] Closed status file watcher");
3658
+ }
3659
+ });
3660
+ }
2568
3661
  const globalRoot = globalConfigPath ? path6.dirname(globalConfigPath) : void 0;
2569
3662
  if (globalRoot) {
2570
- const statusInterval = setInterval(async () => {
3663
+ const broadcastSessions = async () => {
2571
3664
  try {
2572
3665
  const { SessionRegistry } = await import("@wrongstack/core");
2573
3666
  const registry = new SessionRegistry(globalRoot);
2574
3667
  const sessions = await registry.list();
2575
- const live = sessions.filter((s) => s.status !== "stale").map((s) => ({
3668
+ const mySlug = sessions.find((s) => s.pid === process.pid)?.projectSlug;
3669
+ const live = sessions.filter((s) => s.status !== "stale").filter((s) => mySlug ? s.projectSlug === mySlug : true).map((s) => ({
2576
3670
  sessionId: s.sessionId,
2577
3671
  projectName: s.projectName,
2578
3672
  projectSlug: s.projectSlug,
2579
3673
  projectRoot: s.projectRoot,
2580
3674
  workingDir: s.workingDir,
2581
3675
  gitBranch: s.gitBranch,
3676
+ // Surface (tui/webui/cli) so Fleet HQ can label each live client node.
3677
+ clientType: s.clientType,
2582
3678
  status: s.status,
2583
3679
  pid: s.pid,
2584
3680
  startedAt: s.startedAt,
@@ -2590,20 +3686,52 @@ function setupEvents(deps) {
2590
3686
  currentTool: a.currentTool,
2591
3687
  iterations: a.iterations,
2592
3688
  toolCalls: a.toolCalls,
3689
+ costUsd: a.costUsd,
3690
+ tokensIn: a.tokensIn,
3691
+ tokensOut: a.tokensOut,
3692
+ ctxPct: a.ctxPct,
3693
+ model: a.model,
3694
+ partialText: a.partialText,
2593
3695
  lastActivityAt: a.lastActivityAt
2594
3696
  }))
2595
3697
  }));
2596
3698
  broadcast2(clients, { type: "sessions.status_update", payload: { sessions: live } });
2597
3699
  } catch {
2598
3700
  }
2599
- }, 5e3);
3701
+ };
3702
+ onFleetBroadcaster?.(broadcastSessions);
3703
+ const statusInterval = setInterval(() => void broadcastSessions(), 5e3);
2600
3704
  if (statusInterval.unref) statusInterval.unref();
3705
+ disposers.push(() => clearInterval(statusInterval));
3706
+ let regDebounce;
3707
+ try {
3708
+ const regWatcher = fsWatch(globalRoot, { persistent: false }, (_event, filename) => {
3709
+ const name = filename ? String(filename) : "";
3710
+ if (!name.startsWith("session-registry.json") || name.endsWith(".lock")) return;
3711
+ if (regDebounce) clearTimeout(regDebounce);
3712
+ regDebounce = setTimeout(() => void broadcastSessions(), 150);
3713
+ });
3714
+ disposers.push(() => {
3715
+ if (regDebounce) clearTimeout(regDebounce);
3716
+ regWatcher.close();
3717
+ });
3718
+ } catch {
3719
+ }
3720
+ void broadcastSessions();
2601
3721
  }
3722
+ return () => {
3723
+ for (const dispose of disposers) {
3724
+ try {
3725
+ dispose();
3726
+ } catch {
3727
+ }
3728
+ }
3729
+ };
2602
3730
  }
2603
3731
 
2604
3732
  // src/server/custom-context-modes.ts
2605
3733
  import { listContextWindowModes, atomicWrite as atomicWrite4 } from "@wrongstack/core";
2606
- import * as fs5 from "fs/promises";
3734
+ import * as fs7 from "fs/promises";
2607
3735
  import * as path7 from "path";
2608
3736
  var STORE_FILENAME = "custom-context-modes.json";
2609
3737
  function storePath(wrongstackDir) {
@@ -2615,7 +3743,7 @@ function createCustomModeStore(wrongstackDir) {
2615
3743
  const load2 = async () => {
2616
3744
  modes.clear();
2617
3745
  try {
2618
- const raw = await fs5.readFile(storePath(wrongstackDir), "utf8");
3746
+ const raw = await fs7.readFile(storePath(wrongstackDir), "utf8");
2619
3747
  const parsed = JSON.parse(raw);
2620
3748
  if (Array.isArray(parsed.modes)) {
2621
3749
  for (const m of parsed.modes) {
@@ -2795,14 +3923,14 @@ function createEternalSubscription(subscribe, broadcast2, clientsRef) {
2795
3923
  }
2796
3924
 
2797
3925
  // src/server/shell-open.ts
2798
- import * as fs6 from "fs/promises";
3926
+ import * as fs8 from "fs/promises";
2799
3927
  import * as path8 from "path";
2800
3928
  import { spawn as spawn2 } from "child_process";
2801
3929
  var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
2802
3930
  async function handleShellOpen(req, logger) {
2803
3931
  try {
2804
3932
  const resolved = path8.resolve(req.path);
2805
- await fs6.access(resolved);
3933
+ await fs8.access(resolved);
2806
3934
  if (METACHAR_REGEX.test(resolved)) {
2807
3935
  return { success: false, message: "Path contains unsupported characters." };
2808
3936
  }
@@ -2848,6 +3976,43 @@ async function handleShellOpen(req, logger) {
2848
3976
  }
2849
3977
  }
2850
3978
 
3979
+ // src/server/git-handlers.ts
3980
+ async function handleGitInfo(ws, projectRoot) {
3981
+ const cwd = projectRoot || void 0;
3982
+ try {
3983
+ const { execFile: ef } = await import("child_process");
3984
+ const git = (args) => new Promise((resolve5) => {
3985
+ ef("git", args, { cwd, timeout: 3e3 }, (err, stdout) => {
3986
+ resolve5(err ? "" : stdout.trim());
3987
+ });
3988
+ });
3989
+ const [branchRaw, diffRaw, statusRaw, upstreamRaw] = await Promise.all([
3990
+ git(["branch", "--show-current"]),
3991
+ git(["diff", "--stat"]),
3992
+ git(["status", "--porcelain"]),
3993
+ git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
3994
+ ]);
3995
+ const branch = branchRaw || "(detached)";
3996
+ const addMatch = /(\d+)\s+insertion/i.exec(diffRaw);
3997
+ const delMatch = /(\d+)\s+deletion/i.exec(diffRaw);
3998
+ const added = addMatch ? Number(addMatch[1]) : 0;
3999
+ const deleted = delMatch ? Number(delMatch[1]) : 0;
4000
+ const untracked = statusRaw.split("\n").filter((l) => l.startsWith("??")).length;
4001
+ const [behindRaw, aheadRaw] = (upstreamRaw || "0 0").split(" ");
4002
+ const behind = Number(behindRaw) || 0;
4003
+ const ahead = Number(aheadRaw) || 0;
4004
+ send(ws, { type: "git.info", payload: { branch, added, deleted, untracked, ahead, behind } });
4005
+ } catch {
4006
+ send(ws, { type: "git.info", payload: { branch: "", added: 0, deleted: 0, untracked: 0, ahead: 0, behind: 0 } });
4007
+ }
4008
+ }
4009
+
4010
+ // src/server/skills-handlers.ts
4011
+ import { promises as fs9 } from "fs";
4012
+ import path9 from "path";
4013
+ import JSZip from "jszip";
4014
+ import { wstackGlobalRoot } from "@wrongstack/core/utils";
4015
+
2851
4016
  // src/server/index.ts
2852
4017
  async function startWebUI(opts = {}) {
2853
4018
  const requestedWsPort = opts.wsPort ?? 3457;
@@ -2913,7 +4078,7 @@ async function startWebUI(opts = {}) {
2913
4078
  console.warn(JSON.stringify({
2914
4079
  level: "warn",
2915
4080
  event: "webui.provider_registry_load_failed",
2916
- message: err instanceof Error ? err.message : String(err),
4081
+ message: toErrorMessage5(err),
2917
4082
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2918
4083
  }));
2919
4084
  }
@@ -2962,15 +4127,22 @@ async function startWebUI(opts = {}) {
2962
4127
  sessionId: session.id,
2963
4128
  projectSlug: wpaths.projectSlug,
2964
4129
  projectRoot,
2965
- projectName: path9.basename(projectRoot),
4130
+ projectName: path10.basename(projectRoot),
2966
4131
  workingDir,
4132
+ clientType: "webui",
2967
4133
  pid: process.pid,
2968
4134
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
2969
4135
  });
2970
- statusTracker = new AgentStatusTracker({ events, registry });
4136
+ const fleetNotifier = new FleetNotifier({
4137
+ baseDir: wpaths.globalRoot,
4138
+ projectRoot,
4139
+ selfPid: process.pid
4140
+ });
4141
+ statusTracker = new AgentStatusTracker({ events, registry, onUpdate: () => fleetNotifier.notify() });
2971
4142
  statusTracker.start();
2972
4143
  const stopTracking = async () => {
2973
4144
  try {
4145
+ fleetNotifier.dispose();
2974
4146
  await registry.markClosing();
2975
4147
  statusTracker?.stop();
2976
4148
  } catch {
@@ -3010,6 +4182,13 @@ async function startWebUI(opts = {}) {
3010
4182
  supportsReasoning: resolvedModel.capabilities.reasoning
3011
4183
  } : void 0;
3012
4184
  const skillLoader = config.features.skills ? new DefaultSkillLoader2({ paths: wpaths }) : void 0;
4185
+ const skillInstaller = config.features.skills ? new SkillInstaller({
4186
+ manifestPath: path10.join(wstackGlobalRoot2(), "installed-skills.json"),
4187
+ projectSkillsDir: path10.join(projectRoot, ".wrongstack", "skills"),
4188
+ globalSkillsDir: path10.join(wstackGlobalRoot2(), "skills"),
4189
+ projectHash: projectHash(projectRoot),
4190
+ skillLoader
4191
+ }) : void 0;
3013
4192
  const systemPromptBuilder = new DefaultSystemPromptBuilder2({
3014
4193
  memoryStore,
3015
4194
  skillLoader,
@@ -3050,7 +4229,7 @@ async function startWebUI(opts = {}) {
3050
4229
  console.error(JSON.stringify({
3051
4230
  level: "error",
3052
4231
  event: "webui.provider_create_failed",
3053
- message: err instanceof Error ? err.message : String(err),
4232
+ message: toErrorMessage5(err),
3054
4233
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3055
4234
  }));
3056
4235
  throw err;
@@ -3072,14 +4251,14 @@ async function startWebUI(opts = {}) {
3072
4251
  console.error(JSON.stringify({
3073
4252
  level: "error",
3074
4253
  event: "webui.provider_stub_create_failed",
3075
- message: err instanceof Error ? err.message : String(err),
4254
+ message: toErrorMessage5(err),
3076
4255
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3077
4256
  }));
3078
4257
  throw err;
3079
4258
  }
3080
4259
  } else {
3081
4260
  throw new Error(
3082
- "No provider configured. Run `wrongstack init` first, or configure via the WebUI."
4261
+ "No provider configured. Run `wrongstack auth` to set up, or configure via the WebUI."
3083
4262
  );
3084
4263
  }
3085
4264
  }
@@ -3121,6 +4300,12 @@ async function startWebUI(opts = {}) {
3121
4300
  context.meta["logLevel"] = config.log?.level ?? "info";
3122
4301
  context.meta["auditLevel"] = config.session?.auditLevel ?? "standard";
3123
4302
  context.meta["maxIterations"] = config.tools?.maxIterations ?? 500;
4303
+ const tgExt = config.extensions?.["telegram"];
4304
+ context.meta["tgConfigured"] = typeof tgExt?.["botToken"] === "string" && tgExt["botToken"].length > 0;
4305
+ context.meta["tgSessionEnd"] = tgExt?.["notifyOnSessionEnd"] === true;
4306
+ context.meta["tgDelegate"] = tgExt?.["notifyOnDelegate"] !== false;
4307
+ const tgMs = tgExt?.["longToolThresholdMs"];
4308
+ context.meta["tgLongToolMs"] = typeof tgMs === "number" ? tgMs : 3e4;
3124
4309
  }
3125
4310
  const PREF_KEYS = [
3126
4311
  "autonomy",
@@ -3144,7 +4329,11 @@ async function startWebUI(opts = {}) {
3144
4329
  "contextAutoCompact",
3145
4330
  "contextStrategy",
3146
4331
  "logLevel",
3147
- "auditLevel"
4332
+ "auditLevel",
4333
+ "tgConfigured",
4334
+ "tgSessionEnd",
4335
+ "tgDelegate",
4336
+ "tgLongToolMs"
3148
4337
  ];
3149
4338
  const prefSnapshot = () => {
3150
4339
  const snapshot = {};
@@ -3157,7 +4346,7 @@ async function startWebUI(opts = {}) {
3157
4346
  const write = async () => {
3158
4347
  let raw;
3159
4348
  try {
3160
- raw = await fs7.readFile(globalConfigPath, "utf8");
4349
+ raw = await fs10.readFile(globalConfigPath, "utf8");
3161
4350
  } catch {
3162
4351
  raw = "{}";
3163
4352
  }
@@ -3229,6 +4418,22 @@ async function startWebUI(opts = {}) {
3229
4418
  toolsCfg.maxIterations = payload["maxIterations"];
3230
4419
  decrypted.tools = toolsCfg;
3231
4420
  }
4421
+ const tgTouched = typeof payload["tgSessionEnd"] === "boolean" || typeof payload["tgDelegate"] === "boolean" || typeof payload["tgLongToolMs"] === "number";
4422
+ if (tgTouched) {
4423
+ const ext = decrypted.extensions ?? {};
4424
+ const tg = ext["telegram"] ?? {};
4425
+ if (typeof payload["tgSessionEnd"] === "boolean") {
4426
+ tg["notifyOnSessionEnd"] = payload["tgSessionEnd"];
4427
+ }
4428
+ if (typeof payload["tgDelegate"] === "boolean") {
4429
+ tg["notifyOnDelegate"] = payload["tgDelegate"];
4430
+ }
4431
+ if (typeof payload["tgLongToolMs"] === "number") {
4432
+ tg["longToolThresholdMs"] = payload["tgLongToolMs"];
4433
+ }
4434
+ ext["telegram"] = tg;
4435
+ decrypted.extensions = ext;
4436
+ }
3232
4437
  const encrypted = encryptConfigSecrets2(decrypted, vault);
3233
4438
  await atomicWrite5(globalConfigPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
3234
4439
  };
@@ -3450,7 +4655,7 @@ async function startWebUI(opts = {}) {
3450
4655
  inputCost,
3451
4656
  outputCost,
3452
4657
  cacheReadCost,
3453
- projectName: path9.basename(projectRoot) || projectRoot,
4658
+ projectName: path10.basename(projectRoot) || projectRoot,
3454
4659
  projectRoot,
3455
4660
  cwd: workingDir,
3456
4661
  mode: modeId,
@@ -3504,10 +4709,11 @@ async function startWebUI(opts = {}) {
3504
4709
  const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
3505
4710
  const RATE_LIMIT_WINDOW_MS = 6e4;
3506
4711
  const rateLimits = /* @__PURE__ */ new Map();
3507
- function checkRateLimit(ws, client) {
4712
+ let connSeq = 0;
4713
+ function checkRateLimit(_ws, client) {
3508
4714
  if (RATE_LIMIT_MESSAGES <= 0) return true;
3509
4715
  const now = Date.now();
3510
- const key = client.sessionId ?? String(ws);
4716
+ const key = client.connId;
3511
4717
  const limit = rateLimits.get(key);
3512
4718
  if (!limit || now > limit.resetAt) {
3513
4719
  rateLimits.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
@@ -3523,7 +4729,12 @@ async function startWebUI(opts = {}) {
3523
4729
  );
3524
4730
  const pendingConfirms = /* @__PURE__ */ new Map();
3525
4731
  const handleConnection = (ws) => {
3526
- const client = { ws, sessionId: session.id, connectedAt: Date.now() };
4732
+ const client = {
4733
+ ws,
4734
+ sessionId: session.id,
4735
+ connectedAt: Date.now(),
4736
+ connId: `c${++connSeq}`
4737
+ };
3527
4738
  clients.set(ws, client);
3528
4739
  void sessionStartPayload().then((payload) => {
3529
4740
  send(ws, { type: "session.start", payload });
@@ -3531,7 +4742,7 @@ async function startWebUI(opts = {}) {
3531
4742
  console.warn(JSON.stringify({
3532
4743
  level: "warn",
3533
4744
  event: "webui.session_start_payload_failed",
3534
- message: err instanceof Error ? err.message : String(err),
4745
+ message: toErrorMessage5(err),
3535
4746
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3536
4747
  }));
3537
4748
  });
@@ -3553,7 +4764,7 @@ async function startWebUI(opts = {}) {
3553
4764
  const rawObj = JSON.parse(data.toString());
3554
4765
  if (typeof rawObj === "object" && rawObj !== null) {
3555
4766
  const obj = rawObj;
3556
- if ("__proto__" in obj || "constructor" in obj || "prototype" in obj) {
4767
+ if (Object.hasOwn(obj, "__proto__") || Object.hasOwn(obj, "constructor") || Object.hasOwn(obj, "prototype")) {
3557
4768
  send(ws, {
3558
4769
  type: "error",
3559
4770
  payload: { phase: "parse", message: "Invalid message object" }
@@ -3568,17 +4779,18 @@ async function startWebUI(opts = {}) {
3568
4779
  console.error(JSON.stringify({
3569
4780
  level: "error",
3570
4781
  event: "webui.ws_message_parse_failed",
3571
- message: err instanceof Error ? err.message : String(err),
4782
+ message: toErrorMessage5(err),
3572
4783
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3573
4784
  }));
3574
4785
  }
3575
4786
  });
3576
4787
  ws.on("close", () => {
4788
+ const closing = clients.get(ws);
3577
4789
  clients.delete(ws);
3578
- rateLimits.delete(String(ws));
4790
+ if (closing) rateLimits.delete(closing.connId);
3579
4791
  if (pendingConfirms.size > 0) {
3580
- for (const [id, resolve6] of pendingConfirms) {
3581
- resolve6("no");
4792
+ for (const [id, resolve5] of pendingConfirms) {
4793
+ resolve5("no");
3582
4794
  pendingConfirms.delete(id);
3583
4795
  }
3584
4796
  }
@@ -3601,11 +4813,27 @@ async function startWebUI(opts = {}) {
3601
4813
  { sampling: sessionLogging.sampling }
3602
4814
  );
3603
4815
  let eventsArmed = false;
4816
+ let disposeEvents = null;
4817
+ let fleetBroadcast = null;
3604
4818
  const armOnce = (label) => {
3605
4819
  if (eventsArmed) return;
3606
4820
  eventsArmed = true;
3607
4821
  console.log(`[WebUI] Backend ready (${label})`);
3608
- setupEvents({ events, broadcast, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge });
4822
+ disposeEvents = setupEvents({
4823
+ events,
4824
+ broadcast,
4825
+ clients,
4826
+ config,
4827
+ context,
4828
+ pendingConfirms,
4829
+ globalConfigPath,
4830
+ sessionBridge,
4831
+ wpaths,
4832
+ watcherMetrics,
4833
+ onFleetBroadcaster: (fn) => {
4834
+ fleetBroadcast = fn;
4835
+ }
4836
+ });
3609
4837
  };
3610
4838
  wssPrimary.on("listening", () => armOnce(`${wsHost}:${wsPort}`));
3611
4839
  wssPrimary.on("connection", handleConnection);
@@ -3614,7 +4842,7 @@ async function startWebUI(opts = {}) {
3614
4842
  level: "error",
3615
4843
  event: "webui.ws_server_error",
3616
4844
  host: wsHost,
3617
- message: err instanceof Error ? err.message : String(err),
4845
+ message: toErrorMessage5(err),
3618
4846
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3619
4847
  }));
3620
4848
  });
@@ -3642,33 +4870,33 @@ async function startWebUI(opts = {}) {
3642
4870
  });
3643
4871
  }
3644
4872
  async function touchProjectEntry(root, workDir) {
3645
- const resolved = path9.resolve(root);
4873
+ const resolved = path10.resolve(root);
3646
4874
  const manifest = await loadManifest(globalConfigPath);
3647
4875
  const now = (/* @__PURE__ */ new Date()).toISOString();
3648
- const existing = manifest.projects.find((p) => path9.resolve(p.root) === resolved);
4876
+ const existing = manifest.projects.find((p) => path10.resolve(p.root) === resolved);
3649
4877
  if (existing) {
3650
4878
  existing.lastSeen = now;
3651
- if (workDir) existing.lastWorkingDir = path9.resolve(workDir);
4879
+ if (workDir) existing.lastWorkingDir = path10.resolve(workDir);
3652
4880
  } else {
3653
4881
  manifest.projects.push({
3654
- name: path9.basename(resolved),
4882
+ name: path10.basename(resolved),
3655
4883
  root: resolved,
3656
4884
  slug: generateProjectSlug(resolved),
3657
4885
  createdAt: now,
3658
4886
  lastSeen: now,
3659
- lastWorkingDir: workDir ? path9.resolve(workDir) : void 0
4887
+ lastWorkingDir: workDir ? path10.resolve(workDir) : void 0
3660
4888
  });
3661
4889
  }
3662
4890
  await saveManifest(manifest, globalConfigPath);
3663
4891
  await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
3664
4892
  }
3665
4893
  function projectsJsonPath(globalConfigPath2) {
3666
- const base = path9.dirname(globalConfigPath2);
3667
- return path9.join(base, "projects.json");
4894
+ const base = path10.dirname(globalConfigPath2);
4895
+ return path10.join(base, "projects.json");
3668
4896
  }
3669
4897
  async function loadManifest(globalConfigPath2) {
3670
4898
  try {
3671
- const raw = await fs7.readFile(projectsJsonPath(globalConfigPath2), "utf8");
4899
+ const raw = await fs10.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3672
4900
  const parsed = JSON.parse(raw);
3673
4901
  return { projects: parsed.projects ?? [] };
3674
4902
  } catch {
@@ -3677,16 +4905,16 @@ async function startWebUI(opts = {}) {
3677
4905
  }
3678
4906
  async function saveManifest(manifest, globalConfigPath2) {
3679
4907
  const file = projectsJsonPath(globalConfigPath2);
3680
- await fs7.mkdir(path9.dirname(file), { recursive: true });
3681
- await fs7.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
4908
+ await fs10.mkdir(path10.dirname(file), { recursive: true });
4909
+ await fs10.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
3682
4910
  }
3683
4911
  function generateProjectSlug(rootPath) {
3684
4912
  return projectSlug(rootPath);
3685
4913
  }
3686
4914
  async function ensureProjectDataDir(slug, globalConfigPath2) {
3687
- const base = path9.dirname(globalConfigPath2);
3688
- const dir = path9.join(base, "projects", slug);
3689
- await fs7.mkdir(dir, { recursive: true });
4915
+ const base = path10.dirname(globalConfigPath2);
4916
+ const dir = path10.join(base, "projects", slug);
4917
+ await fs10.mkdir(dir, { recursive: true });
3690
4918
  return dir;
3691
4919
  }
3692
4920
  async function handleMessage(ws, _client, msg) {
@@ -3697,7 +4925,9 @@ async function startWebUI(opts = {}) {
3697
4925
  case "collab.join":
3698
4926
  case "collab.leave":
3699
4927
  case "collab.annotate":
3700
- case "collab.resolve": {
4928
+ case "collab.resolve":
4929
+ case "collab.request_pause":
4930
+ case "collab.resume": {
3701
4931
  collabHandler.handleMessage(ws, msg);
3702
4932
  return;
3703
4933
  }
@@ -3748,10 +4978,10 @@ async function startWebUI(opts = {}) {
3748
4978
  }
3749
4979
  case "tool.confirm_result": {
3750
4980
  const { id, decision } = msg.payload;
3751
- const resolve6 = pendingConfirms.get(id);
3752
- if (resolve6) {
4981
+ const resolve5 = pendingConfirms.get(id);
4982
+ if (resolve5) {
3753
4983
  pendingConfirms.delete(id);
3754
- resolve6(decision);
4984
+ resolve5(decision);
3755
4985
  }
3756
4986
  break;
3757
4987
  }
@@ -3794,7 +5024,7 @@ async function startWebUI(opts = {}) {
3794
5024
  context.readFiles.clear();
3795
5025
  context.fileMtimes.clear();
3796
5026
  tokenCounter.reset();
3797
- sendResult(ws, true, "Context cleared");
5027
+ sendResult2(ws, true, "Context cleared");
3798
5028
  broadcast(clients, {
3799
5029
  type: "session.start",
3800
5030
  payload: { ...await sessionStartPayload(), reset: true }
@@ -3831,13 +5061,13 @@ async function startWebUI(opts = {}) {
3831
5061
  repaired: report.repaired
3832
5062
  }
3833
5063
  });
3834
- sendResult(
5064
+ sendResult2(
3835
5065
  ws,
3836
5066
  true,
3837
5067
  `Compacted: ${report.before} \u2192 ${report.after} tokens (saved ~${Math.max(0, report.before - report.after)})`
3838
5068
  );
3839
5069
  } catch (err) {
3840
- sendResult(ws, false, errMessage(err));
5070
+ sendResult2(ws, false, errMessage(err));
3841
5071
  }
3842
5072
  break;
3843
5073
  }
@@ -3856,7 +5086,7 @@ async function startWebUI(opts = {}) {
3856
5086
  };
3857
5087
  broadcast(clients, { type: "context.repaired", payload });
3858
5088
  const removed = payload.removedToolUses.length + payload.removedToolResults.length + payload.removedMessages;
3859
- sendResult(
5089
+ sendResult2(
3860
5090
  ws,
3861
5091
  true,
3862
5092
  removed > 0 ? `Context repaired: removed ${removed} orphan protocol item(s)` : "Context repair found no orphan protocol blocks"
@@ -3890,14 +5120,14 @@ async function startWebUI(opts = {}) {
3890
5120
  );
3891
5121
  const custom = customModes.find((m) => m.id === id);
3892
5122
  if (!custom) {
3893
- sendResult(ws, false, `Unknown context mode "${id}"`);
5123
+ sendResult2(ws, false, `Unknown context mode "${id}"`);
3894
5124
  break;
3895
5125
  }
3896
5126
  policy = custom;
3897
5127
  }
3898
5128
  context.meta["contextWindowMode"] = policy.id;
3899
5129
  context.meta["contextWindowPolicy"] = policy;
3900
- sendResult(ws, true, `Context mode switched to ${policy.id}`);
5130
+ sendResult2(ws, true, `Context mode switched to ${policy.id}`);
3901
5131
  broadcast(clients, {
3902
5132
  type: "context.mode.changed",
3903
5133
  payload: { id: policy.id, name: policy.name, policy }
@@ -3917,7 +5147,7 @@ async function startWebUI(opts = {}) {
3917
5147
  aggressiveOn: "soft",
3918
5148
  targetLoad: 0.65
3919
5149
  });
3920
- sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
5150
+ sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
3921
5151
  break;
3922
5152
  }
3923
5153
  case "context.mode.update": {
@@ -3933,7 +5163,7 @@ async function startWebUI(opts = {}) {
3933
5163
  preserveK: payload.preserveK,
3934
5164
  eliseThreshold: payload.eliseThreshold
3935
5165
  });
3936
- sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
5166
+ sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
3937
5167
  break;
3938
5168
  }
3939
5169
  case "context.mode.delete": {
@@ -3943,7 +5173,7 @@ async function startWebUI(opts = {}) {
3943
5173
  context.meta["contextWindowPolicy"] = resolveContextWindowPolicy({}, DEFAULT_CONTEXT_WINDOW_MODE_ID);
3944
5174
  }
3945
5175
  const result = customModeStore.remove(id);
3946
- sendResult(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
5176
+ sendResult2(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
3947
5177
  break;
3948
5178
  }
3949
5179
  case "providers.list": {
@@ -4024,19 +5254,20 @@ async function startWebUI(opts = {}) {
4024
5254
  context.provider = newProv;
4025
5255
  updateAutoCompactionMaxContext?.(newProv);
4026
5256
  try {
4027
- configWriteLock = configWriteLock.then(async () => {
4028
- const raw = await fs7.readFile(globalConfigPath, "utf8");
5257
+ const next = configWriteLock.then(async () => {
5258
+ const raw = await fs10.readFile(globalConfigPath, "utf8");
4029
5259
  const parsed = JSON.parse(raw);
4030
5260
  parsed.provider = newProvider;
4031
5261
  parsed.model = newModel;
4032
5262
  await atomicWrite5(globalConfigPath, JSON.stringify(parsed, null, 2));
4033
5263
  });
4034
- await configWriteLock;
5264
+ configWriteLock = next.then(() => void 0, () => void 0);
5265
+ await next;
4035
5266
  } catch (err) {
4036
5267
  console.warn(JSON.stringify({
4037
5268
  level: "warn",
4038
5269
  event: "webui.config_save_failed",
4039
- message: err instanceof Error ? err.message : String(err),
5270
+ message: toErrorMessage5(err),
4040
5271
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
4041
5272
  }));
4042
5273
  }
@@ -4134,6 +5365,26 @@ async function startWebUI(opts = {}) {
4134
5365
  await providerHandlers.handleProviderRemove(ws, providerId);
4135
5366
  break;
4136
5367
  }
5368
+ case "provider.clear_models": {
5369
+ const { providerId } = msg.payload;
5370
+ await providerHandlers.handleProviderClearModels(ws, providerId);
5371
+ break;
5372
+ }
5373
+ case "provider.undo_clear": {
5374
+ const { providerId, previousModels } = msg.payload;
5375
+ await providerHandlers.handleProviderUndoClear(ws, providerId, previousModels);
5376
+ break;
5377
+ }
5378
+ case "provider.update": {
5379
+ const p = msg.payload;
5380
+ await providerHandlers.handleProviderUpdate(ws, p);
5381
+ break;
5382
+ }
5383
+ case "provider.probe": {
5384
+ const { providerId, timeoutMs } = msg.payload;
5385
+ await providerHandlers.handleProviderProbe(ws, providerId, timeoutMs);
5386
+ break;
5387
+ }
4137
5388
  case "sessions.list": {
4138
5389
  const limit = msg.payload?.limit ?? 50;
4139
5390
  try {
@@ -4164,13 +5415,13 @@ async function startWebUI(opts = {}) {
4164
5415
  const { id } = msg.payload;
4165
5416
  try {
4166
5417
  if (id === session.id) {
4167
- sendResult(ws, false, "Cannot delete the active session");
5418
+ sendResult2(ws, false, "Cannot delete the active session");
4168
5419
  break;
4169
5420
  }
4170
5421
  await sessionStore.delete(id);
4171
- sendResult(ws, true, `Session ${id} deleted`);
5422
+ sendResult2(ws, true, `Session ${id} deleted`);
4172
5423
  } catch (err) {
4173
- sendResult(ws, false, errMessage(err));
5424
+ sendResult2(ws, false, errMessage(err));
4174
5425
  }
4175
5426
  break;
4176
5427
  }
@@ -4178,7 +5429,7 @@ async function startWebUI(opts = {}) {
4178
5429
  const { id } = msg.payload;
4179
5430
  try {
4180
5431
  if (id === session.id) {
4181
- sendResult(ws, false, "Session is already active");
5432
+ sendResult2(ws, false, "Session is already active");
4182
5433
  break;
4183
5434
  }
4184
5435
  const resumed = await sessionStore.resume(id);
@@ -4208,14 +5459,14 @@ async function startWebUI(opts = {}) {
4208
5459
  replayUsage: resumed.data.usage
4209
5460
  }
4210
5461
  });
4211
- sendResult(ws, true, `Resumed session ${id}`);
5462
+ sendResult2(ws, true, `Resumed session ${id}`);
4212
5463
  } catch (err) {
4213
- sendResult(ws, false, errMessage(err));
5464
+ sendResult2(ws, false, errMessage(err));
4214
5465
  }
4215
5466
  break;
4216
5467
  }
4217
5468
  case "session.save": {
4218
- sendResult(ws, true, `Session ${session.id} is auto-saved`);
5469
+ sendResult2(ws, true, `Session ${session.id} is auto-saved`);
4219
5470
  break;
4220
5471
  }
4221
5472
  case "tools.list": {
@@ -4238,6 +5489,27 @@ async function startWebUI(opts = {}) {
4238
5489
  return handleMemoryRemember(ws, msg, memoryStore);
4239
5490
  case "memory.forget":
4240
5491
  return handleMemoryForget(ws, msg, memoryStore);
5492
+ // ── MCP operations — delegated to shared handlers (mcp-handlers.ts) ──
5493
+ case "mcp.list":
5494
+ return handleMcpList(ws, msg, config, globalConfigPath, void 0);
5495
+ case "mcp.add":
5496
+ return handleMcpAdd(ws, msg, config, globalConfigPath, void 0);
5497
+ case "mcp.remove":
5498
+ return handleMcpRemove(ws, msg, config, globalConfigPath, void 0);
5499
+ case "mcp.update":
5500
+ return handleMcpUpdate(ws, msg, config, globalConfigPath);
5501
+ case "mcp.wake":
5502
+ return handleMcpWake(ws, msg, config, globalConfigPath, void 0);
5503
+ case "mcp.sleep":
5504
+ return handleMcpSleep(ws, msg, config, globalConfigPath, void 0);
5505
+ case "mcp.discover":
5506
+ return handleMcpDiscover(ws, msg, config, globalConfigPath);
5507
+ case "mcp.enable":
5508
+ return handleMcpEnable(ws, msg, config, globalConfigPath);
5509
+ case "mcp.disable":
5510
+ return handleMcpDisable(ws, msg, config, globalConfigPath);
5511
+ case "mcp.restart":
5512
+ return handleMcpRestart(ws, msg, config, globalConfigPath);
4241
5513
  case "skills.list": {
4242
5514
  if (!skillLoader) {
4243
5515
  send(ws, { type: "skills.list", payload: { skills: [], enabled: false } });
@@ -4247,6 +5519,18 @@ async function startWebUI(opts = {}) {
4247
5519
  const manifests = await skillLoader.list();
4248
5520
  const entries = await skillLoader.listEntries();
4249
5521
  const byName = new Map(entries.map((e) => [e.name, e]));
5522
+ const sourceUrlsByName = /* @__PURE__ */ new Map();
5523
+ const refsByName = /* @__PURE__ */ new Map();
5524
+ if (skillInstaller) {
5525
+ try {
5526
+ const installed = await skillInstaller.listInstalled();
5527
+ for (const entry of installed) {
5528
+ sourceUrlsByName.set(entry.name, entry.source);
5529
+ refsByName.set(entry.name, entry.ref);
5530
+ }
5531
+ } catch {
5532
+ }
5533
+ }
4250
5534
  send(ws, {
4251
5535
  type: "skills.list",
4252
5536
  payload: {
@@ -4256,6 +5540,8 @@ async function startWebUI(opts = {}) {
4256
5540
  description: m.description,
4257
5541
  version: m.version ?? "",
4258
5542
  source: m.source,
5543
+ sourceUrl: sourceUrlsByName.get(m.name) ?? "",
5544
+ ref: refsByName.get(m.name) ?? "",
4259
5545
  path: m.path,
4260
5546
  trigger: byName.get(m.name)?.trigger ?? "",
4261
5547
  scope: byName.get(m.name)?.scope ?? []
@@ -4274,6 +5560,261 @@ async function startWebUI(opts = {}) {
4274
5560
  }
4275
5561
  break;
4276
5562
  }
5563
+ case "skills.content": {
5564
+ if (!skillLoader) {
5565
+ send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skills not enabled" } });
5566
+ break;
5567
+ }
5568
+ const contentPayload = msg.payload;
5569
+ if (!contentPayload?.name) {
5570
+ send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skill name is required" } });
5571
+ break;
5572
+ }
5573
+ try {
5574
+ const { name, source } = contentPayload;
5575
+ const entries = await skillLoader.listEntries();
5576
+ const entry = entries.find((e) => e.name.toLowerCase() === name.toLowerCase());
5577
+ if (!entry) {
5578
+ send(ws, { type: "skills.content", payload: { name, body: "", path: "", source, relatedFiles: [], references: [], error: `Skill "${name}" not found` } });
5579
+ break;
5580
+ }
5581
+ const body = await skillLoader.readBody(name);
5582
+ const skillDir = path10.dirname(entry.path);
5583
+ let relatedFiles = [];
5584
+ try {
5585
+ const files = await fs10.readdir(skillDir);
5586
+ relatedFiles = files.filter((f) => f !== path10.basename(entry.path)).map((f) => path10.join(skillDir, f));
5587
+ } catch {
5588
+ }
5589
+ const refs = [];
5590
+ for (const e of entries) {
5591
+ if (e.name.toLowerCase() === name.toLowerCase()) continue;
5592
+ try {
5593
+ const content = await skillLoader.readBody(e.name);
5594
+ if (content.toLowerCase().includes(name.toLowerCase())) {
5595
+ refs.push(e.name);
5596
+ }
5597
+ } catch {
5598
+ }
5599
+ }
5600
+ send(ws, { type: "skills.content", payload: { name, body, path: entry.path, source, relatedFiles, references: refs } });
5601
+ } catch (err) {
5602
+ send(ws, { type: "skills.content", payload: { name: contentPayload.name, body: "", path: "", source: contentPayload.source, relatedFiles: [], references: [], error: errMessage(err) } });
5603
+ }
5604
+ break;
5605
+ }
5606
+ case "skills.install": {
5607
+ if (!skillInstaller) {
5608
+ send(ws, { type: "skills.installed", payload: { success: false, error: "Skills not enabled" } });
5609
+ break;
5610
+ }
5611
+ const installPayload = msg.payload;
5612
+ if (!installPayload?.ref?.trim()) {
5613
+ send(ws, { type: "skills.installed", payload: { success: false, error: "Skill reference is required (e.g. owner/repo or https://github.com/owner/repo)" } });
5614
+ break;
5615
+ }
5616
+ try {
5617
+ const results = await skillInstaller.install(installPayload.ref.trim(), { global: installPayload.global });
5618
+ send(ws, {
5619
+ type: "skills.installed",
5620
+ payload: {
5621
+ success: true,
5622
+ results,
5623
+ error: null
5624
+ }
5625
+ });
5626
+ } catch (err) {
5627
+ send(ws, {
5628
+ type: "skills.installed",
5629
+ payload: {
5630
+ success: false,
5631
+ error: errMessage(err)
5632
+ }
5633
+ });
5634
+ }
5635
+ break;
5636
+ }
5637
+ case "skills.uninstall": {
5638
+ if (!skillInstaller) {
5639
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skills not enabled" } });
5640
+ break;
5641
+ }
5642
+ const uninstallPayload = msg.payload;
5643
+ if (!uninstallPayload?.name?.trim()) {
5644
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skill name is required" } });
5645
+ break;
5646
+ }
5647
+ try {
5648
+ await skillInstaller.uninstall(uninstallPayload.name.trim(), { global: uninstallPayload.global });
5649
+ send(ws, { type: "skills.uninstalled", payload: { success: true, error: null } });
5650
+ } catch (err) {
5651
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: errMessage(err) } });
5652
+ }
5653
+ break;
5654
+ }
5655
+ case "skills.update": {
5656
+ if (!skillInstaller) {
5657
+ send(ws, { type: "skills.updated", payload: { success: false, error: "Skills not enabled" } });
5658
+ break;
5659
+ }
5660
+ const updatePayload = msg.payload;
5661
+ try {
5662
+ const result = await skillInstaller.update(updatePayload?.name, { global: updatePayload?.global });
5663
+ send(ws, {
5664
+ type: "skills.updated",
5665
+ payload: {
5666
+ success: true,
5667
+ error: null,
5668
+ updated: result.updated,
5669
+ unchanged: result.unchanged,
5670
+ errors: result.errors
5671
+ }
5672
+ });
5673
+ } catch (err) {
5674
+ send(ws, { type: "skills.updated", payload: { success: false, error: errMessage(err) } });
5675
+ }
5676
+ break;
5677
+ }
5678
+ case "skills.create": {
5679
+ const createPayload = msg.payload;
5680
+ if (!createPayload?.name?.trim()) {
5681
+ send(ws, { type: "skills.created", payload: { success: false, error: "Skill name is required" } });
5682
+ break;
5683
+ }
5684
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(createPayload.name.trim())) {
5685
+ send(ws, { type: "skills.created", payload: { success: false, error: "Skill name must be kebab-case (e.g. my-new-skill)" } });
5686
+ break;
5687
+ }
5688
+ if (!createPayload?.description?.trim()) {
5689
+ send(ws, { type: "skills.created", payload: { success: false, error: "Description/trigger is required" } });
5690
+ break;
5691
+ }
5692
+ try {
5693
+ const targetDir = createPayload.scope === "global" ? path10.join(wstackGlobalRoot2(), "skills", createPayload.name.trim()) : path10.join(projectRoot, ".wrongstack", "skills", createPayload.name.trim());
5694
+ try {
5695
+ await fs10.access(targetDir);
5696
+ send(ws, { type: "skills.created", payload: { success: false, error: `Skill "${createPayload.name}" already exists` } });
5697
+ break;
5698
+ } catch {
5699
+ }
5700
+ await fs10.mkdir(targetDir, { recursive: true });
5701
+ const lines = createPayload.description.trim().split("\n");
5702
+ const firstLine = lines[0].trim();
5703
+ const bodyLines = lines.slice(1).map((l) => l.trim()).filter(Boolean);
5704
+ const descriptionText = firstLine + (bodyLines.length > 0 ? `
5705
+ ${bodyLines.join("\n")}` : "");
5706
+ const trigger = bodyLines.find((l) => l.toLowerCase().startsWith("triggers:")) ?? "";
5707
+ const skillContent = [
5708
+ "---",
5709
+ `name: ${createPayload.name.trim()}`,
5710
+ "description: |",
5711
+ ` ${descriptionText.replace(/\n/g, "\n ")}`,
5712
+ `version: 1.0.0`,
5713
+ "---",
5714
+ "",
5715
+ `# ${createPayload.name.trim().split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}`,
5716
+ "",
5717
+ "## Overview",
5718
+ "",
5719
+ firstLine,
5720
+ "",
5721
+ ...bodyLines.length > 0 ? bodyLines.filter((l) => !l.toLowerCase().startsWith("triggers:")) : [],
5722
+ "",
5723
+ "## Rules",
5724
+ "- TODO: add your first rule",
5725
+ "",
5726
+ "## Patterns",
5727
+ "### Do",
5728
+ "```ts",
5729
+ "// TODO: add a good example",
5730
+ "```",
5731
+ "",
5732
+ "### Don't",
5733
+ "```ts",
5734
+ "// TODO: add a bad example",
5735
+ "```",
5736
+ "",
5737
+ "## Workflow",
5738
+ "1. TODO: describe step one",
5739
+ "2. TODO: describe step two",
5740
+ "",
5741
+ trigger ? `
5742
+ ${trigger}
5743
+ ` : "",
5744
+ "## Skills in scope",
5745
+ "- `bug-hunter` \u2014 for systematic bug detection patterns",
5746
+ "- `output-standards` \u2014 for standardized `<next_steps>` formatting"
5747
+ ].join("\n");
5748
+ await fs10.writeFile(path10.join(targetDir, "SKILL.md"), skillContent, "utf-8");
5749
+ send(ws, {
5750
+ type: "skills.created",
5751
+ payload: {
5752
+ success: true,
5753
+ error: null,
5754
+ skill: { name: createPayload.name.trim(), path: path10.join(targetDir, "SKILL.md"), scope: createPayload.scope }
5755
+ }
5756
+ });
5757
+ } catch (err) {
5758
+ send(ws, { type: "skills.created", payload: { success: false, error: errMessage(err) } });
5759
+ }
5760
+ break;
5761
+ }
5762
+ case "skills.edit": {
5763
+ if (!skillLoader) {
5764
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skills not enabled" } });
5765
+ break;
5766
+ }
5767
+ const editPayload = msg.payload;
5768
+ if (!editPayload?.name?.trim()) {
5769
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skill name is required" } });
5770
+ break;
5771
+ }
5772
+ if (!editPayload?.body) {
5773
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skill body is required" } });
5774
+ break;
5775
+ }
5776
+ try {
5777
+ const entries = await skillLoader.listEntries();
5778
+ const entry = entries.find((e) => e.name.toLowerCase() === editPayload.name.toLowerCase());
5779
+ if (!entry) {
5780
+ send(ws, { type: "skills.edited", payload: { success: false, error: `Skill "${editPayload.name}" not found` } });
5781
+ break;
5782
+ }
5783
+ if (entry.scope.includes("bundled")) {
5784
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Bundled skills cannot be edited" } });
5785
+ break;
5786
+ }
5787
+ await fs10.writeFile(entry.path, editPayload.body, "utf-8");
5788
+ send(ws, { type: "skills.edited", payload: { success: true, error: null } });
5789
+ } catch (err) {
5790
+ send(ws, { type: "skills.edited", payload: { success: false, error: errMessage(err) } });
5791
+ }
5792
+ break;
5793
+ }
5794
+ case "skills.export": {
5795
+ if (!skillLoader) {
5796
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: "Skills not enabled" } });
5797
+ break;
5798
+ }
5799
+ try {
5800
+ const entries = await skillLoader.listEntries();
5801
+ const zip = new JSZip2();
5802
+ for (const entry of entries) {
5803
+ try {
5804
+ const body = await skillLoader.readBody(entry.name);
5805
+ const safeName = entry.name.replace(/\//g, "_");
5806
+ zip.file(`${safeName}/SKILL.md`, body);
5807
+ } catch {
5808
+ }
5809
+ }
5810
+ const zipBuffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
5811
+ const zipBase64 = zipBuffer.toString("base64");
5812
+ send(ws, { type: "skills.exported", payload: { zipBase64, skillCount: entries.length, error: void 0 } });
5813
+ } catch (err) {
5814
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: errMessage(err) } });
5815
+ }
5816
+ break;
5817
+ }
4277
5818
  case "diag.get": {
4278
5819
  const usage = tokenCounter.total();
4279
5820
  send(ws, {
@@ -4301,125 +5842,84 @@ async function startWebUI(opts = {}) {
4301
5842
  break;
4302
5843
  }
4303
5844
  case "todos.get": {
4304
- send(ws, {
4305
- type: "todos.updated",
4306
- payload: { todos: [...context.todos] }
4307
- });
5845
+ const ctx = {
5846
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
5847
+ send: (w, m) => send(w, m),
5848
+ broadcast: (m) => broadcast(clients, m)
5849
+ };
5850
+ handleTodosGet(ctx, ws);
4308
5851
  break;
4309
5852
  }
4310
5853
  case "todos.clear": {
4311
- context.state.replaceTodos([]);
4312
- sendResult(ws, true, "Todos cleared");
4313
- broadcast(clients, { type: "todos.updated", payload: { todos: [] } });
5854
+ const ctx = {
5855
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
5856
+ send: (w, m) => send(w, m),
5857
+ broadcast: (m) => broadcast(clients, m)
5858
+ };
5859
+ handleTodosClear(ctx, ws);
4314
5860
  break;
4315
5861
  }
4316
5862
  case "todos.remove": {
4317
- const payload = msg.payload;
4318
- if (!payload) {
4319
- sendResult(ws, false, "Missing id or index");
4320
- break;
4321
- }
4322
- const { id, index } = payload;
4323
- let targetIdx = -1;
4324
- if (typeof id === "string") {
4325
- targetIdx = context.todos.findIndex((t) => t.id === id);
4326
- } else if (typeof index === "number" && index > 0) {
4327
- targetIdx = index - 1;
4328
- }
4329
- if (targetIdx < 0 || !context.todos[targetIdx]) {
4330
- sendResult(ws, false, "Todo not found");
4331
- break;
4332
- }
4333
- const removed = expectDefined2(context.todos[targetIdx]);
4334
- const next = [...context.todos.slice(0, targetIdx), ...context.todos.slice(targetIdx + 1)];
4335
- context.state.replaceTodos(next);
4336
- sendResult(ws, true, `Removed: ${removed.content}`);
4337
- broadcast(clients, { type: "todos.updated", payload: { todos: next } });
5863
+ const ctx = {
5864
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
5865
+ send: (w, m) => send(w, m),
5866
+ broadcast: (m) => broadcast(clients, m)
5867
+ };
5868
+ handleTodosRemove(ctx, ws, msg.payload);
4338
5869
  break;
4339
5870
  }
4340
5871
  case "tasks.get": {
4341
- const taskPath = context.meta["task.path"];
4342
- if (typeof taskPath === "string" && taskPath) {
4343
- try {
4344
- const { loadTasks } = await import("@wrongstack/core");
4345
- const file = await loadTasks(taskPath);
4346
- send(ws, {
4347
- type: "tasks.updated",
4348
- payload: { tasks: file?.tasks ?? [] }
4349
- });
4350
- } catch {
4351
- send(ws, { type: "tasks.updated", payload: { tasks: [] } });
4352
- }
4353
- } else {
4354
- send(ws, { type: "tasks.updated", payload: { tasks: [], error: "Task storage not configured." } });
4355
- }
5872
+ const ctx = {
5873
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
5874
+ send: (w, m) => send(w, m),
5875
+ broadcast: (m) => broadcast(clients, m)
5876
+ };
5877
+ await handleTasksGet(ctx, ws);
4356
5878
  break;
4357
5879
  }
4358
5880
  case "plan.get": {
4359
- const planPath = context.meta["plan.path"];
4360
- if (typeof planPath === "string" && planPath) {
4361
- try {
4362
- const { loadPlan } = await import("@wrongstack/core");
4363
- const plan = await loadPlan(planPath);
4364
- send(ws, {
4365
- type: "plan.updated",
4366
- payload: {
4367
- plan: plan ?? {
4368
- version: 1,
4369
- sessionId: session.id,
4370
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4371
- items: []
4372
- }
4373
- }
4374
- });
4375
- } catch {
4376
- send(ws, {
4377
- type: "plan.updated",
4378
- payload: {
4379
- plan: {
4380
- version: 1,
4381
- sessionId: session.id,
4382
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4383
- items: []
4384
- }
4385
- }
4386
- });
4387
- }
4388
- } else {
4389
- send(ws, {
4390
- type: "plan.updated",
4391
- payload: { plan: null, error: "Plan storage is not configured for this session." }
4392
- });
4393
- }
5881
+ const ctx = {
5882
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
5883
+ send: (w, m) => send(w, m),
5884
+ broadcast: (m) => broadcast(clients, m)
5885
+ };
5886
+ await handlePlanGet(ctx, ws);
4394
5887
  break;
4395
5888
  }
4396
5889
  case "plan.template_use": {
4397
- const { template } = msg.payload;
4398
- const planPath = context.meta["plan.path"];
4399
- if (typeof planPath !== "string" || !planPath) {
4400
- sendResult(ws, false, "Plan storage is not configured for this session.");
4401
- break;
4402
- }
4403
- try {
4404
- const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
4405
- const tpl = getPlanTemplate(template);
4406
- if (!tpl) {
4407
- sendResult(ws, false, `Unknown template "${template}".`);
4408
- break;
4409
- }
4410
- let plan = await loadPlan(planPath) ?? emptyPlan(session.id);
4411
- for (const item of tpl.items) {
4412
- ({ plan } = addPlanItem(plan, item.title, item.details));
4413
- }
4414
- await savePlan(planPath, plan);
4415
- sendResult(ws, true, `Applied template "${tpl.name}" \u2014 ${tpl.items.length} items added.`);
4416
- broadcast(clients, {
4417
- type: "plan.updated",
4418
- payload: { plan }
4419
- });
4420
- } catch (err) {
4421
- sendResult(ws, false, errMessage(err));
4422
- }
5890
+ const ctx = {
5891
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
5892
+ send: (w, m) => send(w, m),
5893
+ broadcast: (m) => broadcast(clients, m)
5894
+ };
5895
+ await handlePlanTemplateUse(ctx, ws, msg.payload.template);
5896
+ break;
5897
+ }
5898
+ case "todo.update": {
5899
+ const ctx = {
5900
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
5901
+ send: (w, m) => send(w, m),
5902
+ broadcast: (m) => broadcast(clients, m)
5903
+ };
5904
+ handleTodoUpdate(ctx, ws, msg.payload);
5905
+ break;
5906
+ }
5907
+ case "task.update": {
5908
+ const ctx = {
5909
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
5910
+ send: (w, m) => send(w, m),
5911
+ broadcast: (m) => broadcast(clients, m)
5912
+ };
5913
+ await handleTaskUpdate(ctx, ws, msg.payload);
5914
+ break;
5915
+ }
5916
+ case "plan.item.update": {
5917
+ const ctx = {
5918
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
5919
+ send: (w, m) => send(w, m),
5920
+ broadcast: (m) => broadcast(clients, m)
5921
+ };
5922
+ await handlePlanItemUpdate(ctx, ws, msg.payload);
4423
5923
  break;
4424
5924
  }
4425
5925
  // ── File operations — delegated to shared handlers (file-handlers.ts) ──
@@ -4489,13 +5989,13 @@ async function startWebUI(opts = {}) {
4489
5989
  provider: config.provider,
4490
5990
  model: config.model
4491
5991
  });
4492
- sendResult(ws, true, `Switched to mode "${id}"`);
5992
+ sendResult2(ws, true, `Switched to mode "${id}"`);
4493
5993
  broadcast(clients, {
4494
5994
  type: "session.start",
4495
5995
  payload: { ...await sessionStartPayload() }
4496
5996
  });
4497
5997
  } catch (err) {
4498
- sendResult(ws, false, errMessage(err));
5998
+ sendResult2(ws, false, errMessage(err));
4499
5999
  }
4500
6000
  break;
4501
6001
  }
@@ -4549,13 +6049,13 @@ async function startWebUI(opts = {}) {
4549
6049
  const { getProcessRegistry } = await import("@wrongstack/tools");
4550
6050
  const proc = getProcessRegistry().get(pid);
4551
6051
  if (proc?.protected) {
4552
- sendResult(ws, false, `Cannot kill protected process (PID ${pid})`);
6052
+ sendResult2(ws, false, `Cannot kill protected process (PID ${pid})`);
4553
6053
  break;
4554
6054
  }
4555
6055
  getProcessRegistry().kill(pid);
4556
- sendResult(ws, true, `Killed PID ${pid}`);
6056
+ sendResult2(ws, true, `Killed PID ${pid}`);
4557
6057
  } catch (err) {
4558
- sendResult(ws, false, errMessage(err));
6058
+ sendResult2(ws, false, errMessage(err));
4559
6059
  }
4560
6060
  break;
4561
6061
  }
@@ -4563,16 +6063,25 @@ async function startWebUI(opts = {}) {
4563
6063
  try {
4564
6064
  const { getProcessRegistry } = await import("@wrongstack/tools");
4565
6065
  getProcessRegistry().killAll();
4566
- sendResult(ws, true, "All processes killed");
6066
+ sendResult2(ws, true, "All processes killed");
4567
6067
  } catch (err) {
4568
- sendResult(ws, false, errMessage(err));
6068
+ sendResult2(ws, false, errMessage(err));
4569
6069
  }
4570
6070
  break;
4571
6071
  }
6072
+ case "git.info": {
6073
+ await handleGitInfo(ws, projectRoot);
6074
+ break;
6075
+ }
6076
+ case "webui.shutdown": {
6077
+ console.log("[WebUI] Shutdown requested from client");
6078
+ process.kill(process.pid, "SIGINT");
6079
+ break;
6080
+ }
4572
6081
  case "goal.get": {
4573
6082
  try {
4574
- const goalPath = path9.join(projectRoot, ".wrongstack", "goal.json");
4575
- const raw = await fs7.readFile(goalPath, "utf8");
6083
+ const goalPath = resolveWstackPaths({ projectRoot }).projectGoal;
6084
+ const raw = await fs10.readFile(goalPath, "utf8");
4576
6085
  const goal = JSON.parse(raw);
4577
6086
  broadcast(clients, { type: "goal.updated", payload: goal });
4578
6087
  } catch {
@@ -4583,7 +6092,7 @@ async function startWebUI(opts = {}) {
4583
6092
  case "autonomy.switch": {
4584
6093
  const { mode } = msg.payload;
4585
6094
  context.meta["autonomy"] = mode;
4586
- sendResult(ws, true, `Autonomy mode set to "${mode}"`);
6095
+ sendResult2(ws, true, `Autonomy mode set to "${mode}"`);
4587
6096
  broadcast(clients, { type: "prefs.updated", payload: { autonomy: mode } });
4588
6097
  void persistPrefsToConfig({ autonomy: mode });
4589
6098
  break;
@@ -4632,7 +6141,7 @@ async function startWebUI(opts = {}) {
4632
6141
  try {
4633
6142
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4634
6143
  const rewinder = new DefaultSessionRewinder(
4635
- path9.join(projectRoot, ".wrongstack", "sessions"),
6144
+ path10.join(projectRoot, ".wrongstack", "sessions"),
4636
6145
  projectRoot
4637
6146
  );
4638
6147
  const checkpoints = await rewinder.listCheckpoints(session.id);
@@ -4653,18 +6162,18 @@ async function startWebUI(opts = {}) {
4653
6162
  try {
4654
6163
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4655
6164
  const rewinder = new DefaultSessionRewinder(
4656
- path9.join(projectRoot, ".wrongstack", "sessions"),
6165
+ path10.join(projectRoot, ".wrongstack", "sessions"),
4657
6166
  projectRoot
4658
6167
  );
4659
6168
  await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
4660
6169
  await context.session.truncateToCheckpoint(checkpointIndex);
4661
- sendResult(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
6170
+ sendResult2(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
4662
6171
  broadcast(clients, {
4663
6172
  type: "session.start",
4664
6173
  payload: { ...await sessionStartPayload(), reset: true }
4665
6174
  });
4666
6175
  } catch (err) {
4667
- sendResult(ws, false, errMessage(err));
6176
+ sendResult2(ws, false, errMessage(err));
4668
6177
  }
4669
6178
  break;
4670
6179
  }
@@ -4687,9 +6196,9 @@ async function startWebUI(opts = {}) {
4687
6196
  case "projects.add": {
4688
6197
  const { root: addRoot, name: displayName } = msg.payload;
4689
6198
  try {
4690
- const resolved = path9.resolve(addRoot);
4691
- await fs7.access(resolved);
4692
- const stat2 = await fs7.stat(resolved);
6199
+ const resolved = path10.resolve(addRoot);
6200
+ await fs10.access(resolved);
6201
+ const stat2 = await fs10.stat(resolved);
4693
6202
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4694
6203
  const manifest = await loadManifest(globalConfigPath);
4695
6204
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -4705,7 +6214,7 @@ async function startWebUI(opts = {}) {
4705
6214
  });
4706
6215
  break;
4707
6216
  }
4708
- const name = displayName?.trim() || path9.basename(resolved);
6217
+ const name = displayName?.trim() || path10.basename(resolved);
4709
6218
  const slug = generateProjectSlug(resolved);
4710
6219
  await ensureProjectDataDir(slug, globalConfigPath);
4711
6220
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -4724,7 +6233,7 @@ async function startWebUI(opts = {}) {
4724
6233
  send(ws, {
4725
6234
  type: "projects.added",
4726
6235
  payload: {
4727
- name: path9.basename(addRoot),
6236
+ name: path10.basename(addRoot),
4728
6237
  root: addRoot,
4729
6238
  slug: "",
4730
6239
  message: errMessage(err)
@@ -4736,17 +6245,17 @@ async function startWebUI(opts = {}) {
4736
6245
  case "projects.select": {
4737
6246
  const { root: selRoot, name: selName } = msg.payload;
4738
6247
  try {
4739
- const resolved = path9.resolve(selRoot);
6248
+ const resolved = path10.resolve(selRoot);
4740
6249
  try {
4741
- await fs7.access(resolved);
4742
- const stat2 = await fs7.stat(resolved);
6250
+ await fs10.access(resolved);
6251
+ const stat2 = await fs10.stat(resolved);
4743
6252
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4744
6253
  } catch (err) {
4745
6254
  send(ws, {
4746
6255
  type: "projects.selected",
4747
6256
  payload: {
4748
6257
  root: selRoot,
4749
- name: selName || path9.basename(selRoot),
6258
+ name: selName || path10.basename(selRoot),
4750
6259
  message: `Cannot switch: ${errMessage(err)}`
4751
6260
  }
4752
6261
  });
@@ -4758,7 +6267,7 @@ async function startWebUI(opts = {}) {
4758
6267
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
4759
6268
  entry.lastWorkingDir = resolved;
4760
6269
  } else {
4761
- const name = selName?.trim() || path9.basename(resolved);
6270
+ const name = selName?.trim() || path10.basename(resolved);
4762
6271
  const slug = generateProjectSlug(resolved);
4763
6272
  manifest.projects.push({
4764
6273
  name,
@@ -4799,13 +6308,13 @@ async function startWebUI(opts = {}) {
4799
6308
  });
4800
6309
  } catch {
4801
6310
  }
4802
- const newSessionsDir = path9.join(
4803
- path9.dirname(globalConfigPath),
6311
+ const newSessionsDir = path10.join(
6312
+ path10.dirname(globalConfigPath),
4804
6313
  "projects",
4805
6314
  switchSlug,
4806
6315
  "sessions"
4807
6316
  );
4808
- await fs7.mkdir(newSessionsDir, { recursive: true });
6317
+ await fs10.mkdir(newSessionsDir, { recursive: true });
4809
6318
  const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
4810
6319
  const oldSessionId = session.id;
4811
6320
  try {
@@ -4837,8 +6346,9 @@ async function startWebUI(opts = {}) {
4837
6346
  sessionId: session.id,
4838
6347
  projectSlug: switchSlug,
4839
6348
  projectRoot,
4840
- projectName: path9.basename(projectRoot),
6349
+ projectName: path10.basename(projectRoot),
4841
6350
  workingDir,
6351
+ clientType: "webui",
4842
6352
  pid: process.pid,
4843
6353
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
4844
6354
  });
@@ -4848,8 +6358,8 @@ async function startWebUI(opts = {}) {
4848
6358
  type: "projects.selected",
4849
6359
  payload: {
4850
6360
  root: resolved,
4851
- name: selName || path9.basename(resolved),
4852
- message: `Switched to ${selName || path9.basename(resolved)}`
6361
+ name: selName || path10.basename(resolved),
6362
+ message: `Switched to ${selName || path10.basename(resolved)}`
4853
6363
  }
4854
6364
  });
4855
6365
  broadcast(clients, {
@@ -4872,7 +6382,7 @@ async function startWebUI(opts = {}) {
4872
6382
  type: "projects.selected",
4873
6383
  payload: {
4874
6384
  root: selRoot,
4875
- name: selName || path9.basename(selRoot),
6385
+ name: selName || path10.basename(selRoot),
4876
6386
  message: errMessage(err)
4877
6387
  }
4878
6388
  });
@@ -4883,17 +6393,17 @@ async function startWebUI(opts = {}) {
4883
6393
  case "working_dir.set": {
4884
6394
  const { path: newPath } = msg.payload;
4885
6395
  try {
4886
- const resolved = path9.resolve(projectRoot, newPath);
4887
- if (!resolved.startsWith(projectRoot + path9.sep) && resolved !== projectRoot) {
4888
- sendResult(ws, false, `Path must stay inside the project root: ${projectRoot}`);
6396
+ const resolved = path10.resolve(projectRoot, newPath);
6397
+ if (!resolved.startsWith(projectRoot + path10.sep) && resolved !== projectRoot) {
6398
+ sendResult2(ws, false, `Path must stay inside the project root: ${projectRoot}`);
4889
6399
  break;
4890
6400
  }
4891
6401
  try {
4892
- await fs7.access(resolved);
4893
- const stat2 = await fs7.stat(resolved);
6402
+ await fs10.access(resolved);
6403
+ const stat2 = await fs10.stat(resolved);
4894
6404
  if (!stat2.isDirectory()) throw new Error("Not a directory");
4895
6405
  } catch {
4896
- sendResult(ws, false, `Directory not found or not accessible: ${resolved}`);
6406
+ sendResult2(ws, false, `Directory not found or not accessible: ${resolved}`);
4897
6407
  break;
4898
6408
  }
4899
6409
  workingDir = resolved;
@@ -4902,9 +6412,9 @@ async function startWebUI(opts = {}) {
4902
6412
  type: "working_dir.changed",
4903
6413
  payload: { cwd: resolved, projectRoot }
4904
6414
  });
4905
- sendResult(ws, true, `Working directory set to ${resolved}`);
6415
+ sendResult2(ws, true, `Working directory set to ${resolved}`);
4906
6416
  } catch (err) {
4907
- sendResult(ws, false, errMessage(err));
6417
+ sendResult2(ws, false, errMessage(err));
4908
6418
  }
4909
6419
  break;
4910
6420
  }
@@ -4914,26 +6424,32 @@ async function startWebUI(opts = {}) {
4914
6424
  msg.payload,
4915
6425
  logger
4916
6426
  );
4917
- sendResult(ws, result.success, result.message);
6427
+ sendResult2(ws, result.success, result.message);
4918
6428
  break;
4919
6429
  }
4920
6430
  // ── Mailbox operations — project-level inter-agent messaging ────
4921
6431
  case "mailbox.messages":
4922
6432
  return handleMailboxMessages(
4923
6433
  ws,
4924
- { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
6434
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
4925
6435
  msg.payload
4926
6436
  );
4927
6437
  case "mailbox.agents":
4928
6438
  return handleMailboxAgents(
4929
6439
  ws,
4930
- { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
6440
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
4931
6441
  msg.payload
4932
6442
  );
4933
6443
  case "mailbox.clear":
4934
6444
  return handleMailboxClear(
4935
6445
  ws,
4936
- { projectRoot, globalRoot: path9.dirname(globalConfigPath) }
6446
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) }
6447
+ );
6448
+ case "mailbox.purge":
6449
+ return handleMailboxPurge(
6450
+ ws,
6451
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
6452
+ msg.payload
4937
6453
  );
4938
6454
  // ── Brain — status, autonomy ceiling, direct decision support ───
4939
6455
  case "brain.status":
@@ -4946,7 +6462,7 @@ async function startWebUI(opts = {}) {
4946
6462
  const level = msg.payload?.level ?? "";
4947
6463
  const valid = ["off", "low", "medium", "high", "all"];
4948
6464
  if (!valid.includes(level)) {
4949
- sendResult(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
6465
+ sendResult2(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
4950
6466
  break;
4951
6467
  }
4952
6468
  brainSettings.maxAutoRisk = level;
@@ -4959,7 +6475,7 @@ async function startWebUI(opts = {}) {
4959
6475
  case "brain.ask": {
4960
6476
  const question = msg.payload?.question?.trim();
4961
6477
  if (!question) {
4962
- sendResult(ws, false, "Usage: /brain ask <question>");
6478
+ sendResult2(ws, false, "Usage: /brain ask <question>");
4963
6479
  break;
4964
6480
  }
4965
6481
  try {
@@ -4972,7 +6488,7 @@ async function startWebUI(opts = {}) {
4972
6488
  });
4973
6489
  send(ws, { type: "brain.answer", payload: { question, decision } });
4974
6490
  } catch (err) {
4975
- sendResult(ws, false, `Brain consultation failed: ${errMessage(err)}`);
6491
+ sendResult2(ws, false, `Brain consultation failed: ${errMessage(err)}`);
4976
6492
  }
4977
6493
  break;
4978
6494
  }
@@ -4999,14 +6515,28 @@ async function startWebUI(opts = {}) {
4999
6515
  broadcast,
5000
6516
  clients
5001
6517
  });
6518
+ const watcherMetrics = {
6519
+ fileChangesDetected: 0,
6520
+ filesProcessed: 0,
6521
+ broadcastsSent: 0,
6522
+ debounceResets: 0,
6523
+ totalDebounceDelayMs: 0,
6524
+ activeProjects: 0,
6525
+ averageDebounceDelayMs: 0,
6526
+ watcherActive: false
6527
+ };
5002
6528
  const httpServer = createHttpServer({
5003
6529
  host: wsHost,
5004
- distDir: path9.resolve(import.meta.dirname, "../../dist"),
6530
+ distDir: path10.resolve(import.meta.dirname, "../../dist"),
5005
6531
  wsPort,
5006
6532
  globalRoot: wpaths.globalRoot,
5007
- apiToken: wsToken
6533
+ apiToken: wsToken,
6534
+ watcherMetrics,
6535
+ onFleetPing: () => {
6536
+ void fleetBroadcast?.();
6537
+ }
5008
6538
  });
5009
- const registryBaseDir = path9.dirname(globalConfigPath);
6539
+ const registryBaseDir = path10.dirname(globalConfigPath);
5010
6540
  httpServer.listen(httpPort, wsHost, () => {
5011
6541
  const openUrl = `http://${wsHost}:${httpPort}`;
5012
6542
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
@@ -5018,7 +6548,7 @@ async function startWebUI(opts = {}) {
5018
6548
  wsPort,
5019
6549
  host: wsHost,
5020
6550
  projectRoot,
5021
- projectName: path9.basename(projectRoot) || projectRoot,
6551
+ projectName: path10.basename(projectRoot) || projectRoot,
5022
6552
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5023
6553
  url: `http://${wsHost}:${httpPort}`
5024
6554
  },
@@ -5045,6 +6575,10 @@ async function startWebUI(opts = {}) {
5045
6575
  // reality. Crash exits are healed by the next register()/list() prune pass.
5046
6576
  onShutdown: () => {
5047
6577
  brainMonitor.stop();
6578
+ if (disposeEvents) {
6579
+ disposeEvents();
6580
+ disposeEvents = null;
6581
+ }
5048
6582
  if (eternalSubscription) {
5049
6583
  eternalSubscription.dispose();
5050
6584
  eternalSubscription = null;