@wrongstack/webui 0.264.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,8 +1,180 @@
1
1
  #!/usr/bin/env node
2
2
  // src/server/index.ts
3
- import { expectDefined as expectDefined2, GlobalMailbox as GlobalMailbox2, projectSlug, getSessionRegistry, AgentStatusTracker } from "@wrongstack/core";
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
+ }
172
+
173
+ // src/server/index.ts
4
174
  import { makeMailboxTool, makeMailSendTool, makeMailInboxTool, mailboxSessionTag } from "@wrongstack/core";
5
- import { toErrorMessage as toErrorMessage5 } from "@wrongstack/core/utils";
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";
6
178
  import {
7
179
  BrainMonitor,
8
180
  DefaultBrainArbiter,
@@ -10,8 +182,8 @@ import {
10
182
  createAutonomyBrain,
11
183
  createTieredBrainArbiter
12
184
  } from "@wrongstack/core";
13
- import * as fs7 from "fs/promises";
14
- import * as path8 from "path";
185
+ import * as fs10 from "fs/promises";
186
+ import * as path10 from "path";
15
187
 
16
188
  // src/server/http-server.ts
17
189
  import * as fs from "fs/promises";
@@ -130,6 +302,13 @@ function isInsideDist(candidate, distDir) {
130
302
  const resolved = path.resolve(candidate);
131
303
  return resolved === root || resolved.startsWith(root + path.sep);
132
304
  }
305
+ function decodeSessionId(segment) {
306
+ try {
307
+ return decodeURIComponent(segment);
308
+ } catch {
309
+ return segment;
310
+ }
311
+ }
133
312
  function createHttpServer(opts) {
134
313
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
135
314
  const distDir = path.resolve(opts.distDir);
@@ -155,6 +334,22 @@ function createHttpServer(opts) {
155
334
  res.end("ok");
156
335
  return;
157
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
+ }
158
353
  if (url.pathname === "/api/sessions" && req.method === "GET") {
159
354
  const headerToken = req.headers["x-ws-token"];
160
355
  const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
@@ -175,7 +370,89 @@ function createHttpServer(opts) {
175
370
  res.end(JSON.stringify({ error: "Unauthorized" }));
176
371
  return;
177
372
  }
178
- 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
+ }
179
456
  return;
180
457
  }
181
458
  let filePath;
@@ -307,6 +584,324 @@ async function handleApiSessionAgents(res, globalRoot, sessionId) {
307
584
  res.end(JSON.stringify({ error: String(err) }));
308
585
  }
309
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
+ }
310
905
 
311
906
  // src/server/file-handlers.ts
312
907
  import * as fs2 from "fs/promises";
@@ -380,7 +975,7 @@ function broadcast(clients, msg) {
380
975
  }
381
976
  }
382
977
  }
383
- function sendResult(ws, success, message) {
978
+ function sendResult2(ws, success, message) {
384
979
  send(ws, { type: "key.operation_result", payload: { success, message } });
385
980
  }
386
981
  function errMessage(err) {
@@ -529,23 +1124,265 @@ async function handleMemoryRemember(ws, msg, memoryStore) {
529
1124
  const { text, scope } = msg.payload;
530
1125
  try {
531
1126
  await memoryStore.remember(text, scope ?? "project-memory");
532
- sendResult(ws, true, "Saved to memory");
1127
+ sendResult2(ws, true, "Saved to memory");
533
1128
  } catch (err) {
534
- sendResult(ws, false, errMessage(err));
1129
+ sendResult2(ws, false, errMessage(err));
535
1130
  }
536
1131
  }
537
1132
  async function handleMemoryForget(ws, msg, memoryStore) {
538
1133
  const { text, scope } = msg.payload;
539
1134
  try {
540
1135
  const removed = await memoryStore.forget(text, scope ?? "project-memory");
541
- sendResult(
1136
+ sendResult2(
542
1137
  ws,
543
1138
  removed > 0,
544
1139
  removed > 0 ? `Removed ${removed} entr${removed === 1 ? "y" : "ies"}` : "No matching entries"
545
1140
  );
546
1141
  } catch (err) {
547
- 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;
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;
548
1384
  }
1385
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Restart command sent for "${payload.name}"` } });
549
1386
  }
550
1387
 
551
1388
  // src/server/index.ts
@@ -1957,14 +2794,14 @@ function registerShutdownHandlers(res) {
1957
2794
 
1958
2795
  // src/server/instance-registry.ts
1959
2796
  import * as os from "os";
1960
- import * as path3 from "path";
1961
- import * as fs3 from "fs/promises";
2797
+ import * as path4 from "path";
2798
+ import * as fs4 from "fs/promises";
1962
2799
  import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
1963
2800
  function defaultBaseDir() {
1964
- return path3.join(os.homedir(), ".wrongstack");
2801
+ return path4.join(os.homedir(), ".wrongstack");
1965
2802
  }
1966
2803
  function registryPath(baseDir = defaultBaseDir()) {
1967
- return path3.join(baseDir, "webui-instances.json");
2804
+ return path4.join(baseDir, "webui-instances.json");
1968
2805
  }
1969
2806
  function isPidAlive(pid) {
1970
2807
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -1977,7 +2814,7 @@ function isPidAlive(pid) {
1977
2814
  }
1978
2815
  async function load(file) {
1979
2816
  try {
1980
- const raw = await fs3.readFile(file, "utf8");
2817
+ const raw = await fs4.readFile(file, "utf8");
1981
2818
  const parsed = JSON.parse(raw);
1982
2819
  if (parsed?.version === 1 && Array.isArray(parsed.instances)) {
1983
2820
  return parsed;
@@ -2125,15 +2962,15 @@ import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/cor
2125
2962
  import { probeLocalLlm } from "@wrongstack/runtime/probe";
2126
2963
 
2127
2964
  // src/server/provider-config-io.ts
2128
- import * as fs4 from "fs/promises";
2129
- import * as path4 from "path";
2965
+ import * as fs5 from "fs/promises";
2966
+ import * as path5 from "path";
2130
2967
  import { atomicWrite as atomicWrite3 } from "@wrongstack/core";
2131
2968
  import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/security";
2132
2969
  import { DefaultSecretVault } from "@wrongstack/core";
2133
2970
  async function loadSavedProviders(configPath, vault) {
2134
2971
  let raw;
2135
2972
  try {
2136
- raw = await fs4.readFile(configPath, "utf8");
2973
+ raw = await fs5.readFile(configPath, "utf8");
2137
2974
  } catch {
2138
2975
  return {};
2139
2976
  }
@@ -2150,7 +2987,7 @@ async function saveProviders(configPath, vault, providers) {
2150
2987
  let raw;
2151
2988
  let fileExists = true;
2152
2989
  try {
2153
- raw = await fs4.readFile(configPath, "utf8");
2990
+ raw = await fs5.readFile(configPath, "utf8");
2154
2991
  } catch (err) {
2155
2992
  if (err.code !== "ENOENT") {
2156
2993
  throw new Error(
@@ -2201,7 +3038,7 @@ function writeKeysBack(cfg, keys) {
2201
3038
  }
2202
3039
  cfg.apiKeys = keys;
2203
3040
  const active = keys.find((k) => k.label === cfg.activeKey) ?? expectDefined(keys[0]);
2204
- cfg.apiKey = active.apiKey;
3041
+ delete cfg.apiKey;
2205
3042
  if (!cfg.activeKey || !keys.some((k) => k.label === cfg.activeKey)) {
2206
3043
  cfg.activeKey = active.label;
2207
3044
  }
@@ -2325,9 +3162,9 @@ function createProviderHandlers(deps) {
2325
3162
  const providers = await loadConfigProviders();
2326
3163
  const result = upsertKey(providers, providerId, label, apiKey, (/* @__PURE__ */ new Date()).toISOString());
2327
3164
  if (result.ok) await saveConfigProviders(providers);
2328
- sendResult(ws, result.ok, result.message);
3165
+ sendResult2(ws, result.ok, result.message);
2329
3166
  } catch (err) {
2330
- sendResult(ws, false, errMessage(err));
3167
+ sendResult2(ws, false, errMessage(err));
2331
3168
  }
2332
3169
  }
2333
3170
  async function handleKeyDelete(ws, providerId, label) {
@@ -2335,9 +3172,9 @@ function createProviderHandlers(deps) {
2335
3172
  const providers = await loadConfigProviders();
2336
3173
  const result = deleteKey(providers, providerId, label);
2337
3174
  if (result.ok) await saveConfigProviders(providers);
2338
- sendResult(ws, result.ok, result.message);
3175
+ sendResult2(ws, result.ok, result.message);
2339
3176
  } catch (err) {
2340
- sendResult(ws, false, errMessage(err));
3177
+ sendResult2(ws, false, errMessage(err));
2341
3178
  }
2342
3179
  }
2343
3180
  async function handleKeySetActive(ws, providerId, label) {
@@ -2345,9 +3182,9 @@ function createProviderHandlers(deps) {
2345
3182
  const providers = await loadConfigProviders();
2346
3183
  const result = setActiveKey(providers, providerId, label);
2347
3184
  if (result.ok) await saveConfigProviders(providers);
2348
- sendResult(ws, result.ok, result.message);
3185
+ sendResult2(ws, result.ok, result.message);
2349
3186
  } catch (err) {
2350
- sendResult(ws, false, errMessage(err));
3187
+ sendResult2(ws, false, errMessage(err));
2351
3188
  }
2352
3189
  }
2353
3190
  async function handleProviderAdd(ws, payload) {
@@ -2355,13 +3192,13 @@ function createProviderHandlers(deps) {
2355
3192
  const providers = await loadConfigProviders();
2356
3193
  const result = addProvider(providers, payload, (/* @__PURE__ */ new Date()).toISOString());
2357
3194
  if (result.ok) await saveConfigProviders(providers);
2358
- sendResult(ws, result.ok, result.message);
3195
+ sendResult2(ws, result.ok, result.message);
2359
3196
  if (result.ok) {
2360
3197
  console.log(`[WebUI] Provider "${payload.id}" added via provider.add`);
2361
3198
  broadcastSaved(providers);
2362
3199
  }
2363
3200
  } catch (err) {
2364
- sendResult(ws, false, errMessage(err));
3201
+ sendResult2(ws, false, errMessage(err));
2365
3202
  }
2366
3203
  }
2367
3204
  async function handleProviderRemove(ws, providerId) {
@@ -2369,9 +3206,9 @@ function createProviderHandlers(deps) {
2369
3206
  const providers = await loadConfigProviders();
2370
3207
  const result = removeProvider(providers, providerId);
2371
3208
  if (result.ok) await saveConfigProviders(providers);
2372
- sendResult(ws, result.ok, result.message);
3209
+ sendResult2(ws, result.ok, result.message);
2373
3210
  } catch (err) {
2374
- sendResult(ws, false, errMessage(err));
3211
+ sendResult2(ws, false, errMessage(err));
2375
3212
  }
2376
3213
  }
2377
3214
  function broadcastSaved(providers) {
@@ -2385,15 +3222,15 @@ function createProviderHandlers(deps) {
2385
3222
  const providers = await loadConfigProviders();
2386
3223
  const cfg = providers[providerId];
2387
3224
  if (!cfg) {
2388
- sendResult(ws, false, `Unknown provider "${providerId}"`);
3225
+ sendResult2(ws, false, `Unknown provider "${providerId}"`);
2389
3226
  return;
2390
3227
  }
2391
3228
  delete cfg.models;
2392
3229
  await saveConfigProviders(providers);
2393
- sendResult(ws, true, `Cleared model allowlist for ${providerId}`);
3230
+ sendResult2(ws, true, `Cleared model allowlist for ${providerId}`);
2394
3231
  broadcastSaved(providers);
2395
3232
  } catch (err) {
2396
- sendResult(ws, false, errMessage(err));
3233
+ sendResult2(ws, false, errMessage(err));
2397
3234
  }
2398
3235
  }
2399
3236
  async function handleProviderUndoClear(ws, providerId, previousModels) {
@@ -2401,15 +3238,15 @@ function createProviderHandlers(deps) {
2401
3238
  const providers = await loadConfigProviders();
2402
3239
  const cfg = providers[providerId];
2403
3240
  if (!cfg) {
2404
- sendResult(ws, false, `Unknown provider "${providerId}"`);
3241
+ sendResult2(ws, false, `Unknown provider "${providerId}"`);
2405
3242
  return;
2406
3243
  }
2407
3244
  cfg.models = [...previousModels];
2408
3245
  await saveConfigProviders(providers);
2409
- sendResult(ws, true, `Restored ${previousModels.length} model(s) for ${providerId}`);
3246
+ sendResult2(ws, true, `Restored ${previousModels.length} model(s) for ${providerId}`);
2410
3247
  broadcastSaved(providers);
2411
3248
  } catch (err) {
2412
- sendResult(ws, false, errMessage(err));
3249
+ sendResult2(ws, false, errMessage(err));
2413
3250
  }
2414
3251
  }
2415
3252
  async function handleProviderUpdate(ws, payload) {
@@ -2417,7 +3254,7 @@ function createProviderHandlers(deps) {
2417
3254
  const providers = await loadConfigProviders();
2418
3255
  const cfg = providers[payload.id];
2419
3256
  if (!cfg) {
2420
- sendResult(ws, false, `Unknown provider "${payload.id}"`);
3257
+ sendResult2(ws, false, `Unknown provider "${payload.id}"`);
2421
3258
  return;
2422
3259
  }
2423
3260
  if (payload.family !== void 0) cfg.family = payload.family;
@@ -2425,10 +3262,10 @@ function createProviderHandlers(deps) {
2425
3262
  if (payload.envVars !== void 0) cfg.envVars = payload.envVars;
2426
3263
  if (payload.models !== void 0) cfg.models = payload.models;
2427
3264
  await saveConfigProviders(providers);
2428
- sendResult(ws, true, `Updated ${payload.id}`);
3265
+ sendResult2(ws, true, `Updated ${payload.id}`);
2429
3266
  broadcastSaved(providers);
2430
3267
  } catch (err) {
2431
- sendResult(ws, false, errMessage(err));
3268
+ sendResult2(ws, false, errMessage(err));
2432
3269
  }
2433
3270
  }
2434
3271
  async function handleProviderProbe(ws, providerId, timeoutMs) {
@@ -2473,9 +3310,12 @@ function createProviderHandlers(deps) {
2473
3310
  }
2474
3311
 
2475
3312
  // src/server/setup-events.ts
2476
- import * as path5 from "path";
3313
+ import * as fs6 from "fs/promises";
3314
+ import { watch as fsWatch } from "fs";
3315
+ import * as path6 from "path";
2477
3316
  function setupEvents(deps) {
2478
- 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 = [];
2479
3319
  events.on("iteration.started", (e) => {
2480
3320
  const maxIt = typeof context.meta["maxIterations"] === "number" ? context.meta["maxIterations"] : config.tools?.maxIterations ?? 100;
2481
3321
  broadcast2(clients, {
@@ -2506,7 +3346,11 @@ function setupEvents(deps) {
2506
3346
  events.on("tool.progress", (e) => {
2507
3347
  broadcast2(clients, {
2508
3348
  type: "tool.progress",
2509
- 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 } }
2510
3354
  });
2511
3355
  sessionBridge?.append({
2512
3356
  type: "tool_progress",
@@ -2672,20 +3516,165 @@ function setupEvents(deps) {
2672
3516
  events.onPattern("brain.*", (eventName, payload) => {
2673
3517
  broadcast2(clients, { type: "brain.event", payload: { event: eventName, ...payload } });
2674
3518
  });
2675
- const globalRoot = globalConfigPath ? path5.dirname(globalConfigPath) : void 0;
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
+ }
3661
+ const globalRoot = globalConfigPath ? path6.dirname(globalConfigPath) : void 0;
2676
3662
  if (globalRoot) {
2677
- const statusInterval = setInterval(async () => {
3663
+ const broadcastSessions = async () => {
2678
3664
  try {
2679
3665
  const { SessionRegistry } = await import("@wrongstack/core");
2680
3666
  const registry = new SessionRegistry(globalRoot);
2681
3667
  const sessions = await registry.list();
2682
- 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) => ({
2683
3670
  sessionId: s.sessionId,
2684
3671
  projectName: s.projectName,
2685
3672
  projectSlug: s.projectSlug,
2686
3673
  projectRoot: s.projectRoot,
2687
3674
  workingDir: s.workingDir,
2688
3675
  gitBranch: s.gitBranch,
3676
+ // Surface (tui/webui/cli) so Fleet HQ can label each live client node.
3677
+ clientType: s.clientType,
2689
3678
  status: s.status,
2690
3679
  pid: s.pid,
2691
3680
  startedAt: s.startedAt,
@@ -2697,24 +3686,56 @@ function setupEvents(deps) {
2697
3686
  currentTool: a.currentTool,
2698
3687
  iterations: a.iterations,
2699
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,
2700
3695
  lastActivityAt: a.lastActivityAt
2701
3696
  }))
2702
3697
  }));
2703
3698
  broadcast2(clients, { type: "sessions.status_update", payload: { sessions: live } });
2704
3699
  } catch {
2705
3700
  }
2706
- }, 5e3);
3701
+ };
3702
+ onFleetBroadcaster?.(broadcastSessions);
3703
+ const statusInterval = setInterval(() => void broadcastSessions(), 5e3);
2707
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();
2708
3721
  }
3722
+ return () => {
3723
+ for (const dispose of disposers) {
3724
+ try {
3725
+ dispose();
3726
+ } catch {
3727
+ }
3728
+ }
3729
+ };
2709
3730
  }
2710
3731
 
2711
3732
  // src/server/custom-context-modes.ts
2712
3733
  import { listContextWindowModes, atomicWrite as atomicWrite4 } from "@wrongstack/core";
2713
- import * as fs5 from "fs/promises";
2714
- import * as path6 from "path";
3734
+ import * as fs7 from "fs/promises";
3735
+ import * as path7 from "path";
2715
3736
  var STORE_FILENAME = "custom-context-modes.json";
2716
3737
  function storePath(wrongstackDir) {
2717
- return path6.join(wrongstackDir, STORE_FILENAME);
3738
+ return path7.join(wrongstackDir, STORE_FILENAME);
2718
3739
  }
2719
3740
  var BUILTIN_IDS = /* @__PURE__ */ new Set(["balanced", "frugal", "deep", "archival"]);
2720
3741
  function createCustomModeStore(wrongstackDir) {
@@ -2722,7 +3743,7 @@ function createCustomModeStore(wrongstackDir) {
2722
3743
  const load2 = async () => {
2723
3744
  modes.clear();
2724
3745
  try {
2725
- const raw = await fs5.readFile(storePath(wrongstackDir), "utf8");
3746
+ const raw = await fs7.readFile(storePath(wrongstackDir), "utf8");
2726
3747
  const parsed = JSON.parse(raw);
2727
3748
  if (Array.isArray(parsed.modes)) {
2728
3749
  for (const m of parsed.modes) {
@@ -2902,14 +3923,14 @@ function createEternalSubscription(subscribe, broadcast2, clientsRef) {
2902
3923
  }
2903
3924
 
2904
3925
  // src/server/shell-open.ts
2905
- import * as fs6 from "fs/promises";
2906
- import * as path7 from "path";
3926
+ import * as fs8 from "fs/promises";
3927
+ import * as path8 from "path";
2907
3928
  import { spawn as spawn2 } from "child_process";
2908
3929
  var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
2909
3930
  async function handleShellOpen(req, logger) {
2910
3931
  try {
2911
- const resolved = path7.resolve(req.path);
2912
- await fs6.access(resolved);
3932
+ const resolved = path8.resolve(req.path);
3933
+ await fs8.access(resolved);
2913
3934
  if (METACHAR_REGEX.test(resolved)) {
2914
3935
  return { success: false, message: "Path contains unsupported characters." };
2915
3936
  }
@@ -2955,6 +3976,43 @@ async function handleShellOpen(req, logger) {
2955
3976
  }
2956
3977
  }
2957
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
+
2958
4016
  // src/server/index.ts
2959
4017
  async function startWebUI(opts = {}) {
2960
4018
  const requestedWsPort = opts.wsPort ?? 3457;
@@ -3069,15 +4127,22 @@ async function startWebUI(opts = {}) {
3069
4127
  sessionId: session.id,
3070
4128
  projectSlug: wpaths.projectSlug,
3071
4129
  projectRoot,
3072
- projectName: path8.basename(projectRoot),
4130
+ projectName: path10.basename(projectRoot),
3073
4131
  workingDir,
4132
+ clientType: "webui",
3074
4133
  pid: process.pid,
3075
4134
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
3076
4135
  });
3077
- 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() });
3078
4142
  statusTracker.start();
3079
4143
  const stopTracking = async () => {
3080
4144
  try {
4145
+ fleetNotifier.dispose();
3081
4146
  await registry.markClosing();
3082
4147
  statusTracker?.stop();
3083
4148
  } catch {
@@ -3117,6 +4182,13 @@ async function startWebUI(opts = {}) {
3117
4182
  supportsReasoning: resolvedModel.capabilities.reasoning
3118
4183
  } : void 0;
3119
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;
3120
4192
  const systemPromptBuilder = new DefaultSystemPromptBuilder2({
3121
4193
  memoryStore,
3122
4194
  skillLoader,
@@ -3186,7 +4258,7 @@ async function startWebUI(opts = {}) {
3186
4258
  }
3187
4259
  } else {
3188
4260
  throw new Error(
3189
- "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."
3190
4262
  );
3191
4263
  }
3192
4264
  }
@@ -3274,7 +4346,7 @@ async function startWebUI(opts = {}) {
3274
4346
  const write = async () => {
3275
4347
  let raw;
3276
4348
  try {
3277
- raw = await fs7.readFile(globalConfigPath, "utf8");
4349
+ raw = await fs10.readFile(globalConfigPath, "utf8");
3278
4350
  } catch {
3279
4351
  raw = "{}";
3280
4352
  }
@@ -3583,7 +4655,7 @@ async function startWebUI(opts = {}) {
3583
4655
  inputCost,
3584
4656
  outputCost,
3585
4657
  cacheReadCost,
3586
- projectName: path8.basename(projectRoot) || projectRoot,
4658
+ projectName: path10.basename(projectRoot) || projectRoot,
3587
4659
  projectRoot,
3588
4660
  cwd: workingDir,
3589
4661
  mode: modeId,
@@ -3637,10 +4709,11 @@ async function startWebUI(opts = {}) {
3637
4709
  const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
3638
4710
  const RATE_LIMIT_WINDOW_MS = 6e4;
3639
4711
  const rateLimits = /* @__PURE__ */ new Map();
3640
- function checkRateLimit(ws, client) {
4712
+ let connSeq = 0;
4713
+ function checkRateLimit(_ws, client) {
3641
4714
  if (RATE_LIMIT_MESSAGES <= 0) return true;
3642
4715
  const now = Date.now();
3643
- const key = client.sessionId ?? String(ws);
4716
+ const key = client.connId;
3644
4717
  const limit = rateLimits.get(key);
3645
4718
  if (!limit || now > limit.resetAt) {
3646
4719
  rateLimits.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
@@ -3656,7 +4729,12 @@ async function startWebUI(opts = {}) {
3656
4729
  );
3657
4730
  const pendingConfirms = /* @__PURE__ */ new Map();
3658
4731
  const handleConnection = (ws) => {
3659
- 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
+ };
3660
4738
  clients.set(ws, client);
3661
4739
  void sessionStartPayload().then((payload) => {
3662
4740
  send(ws, { type: "session.start", payload });
@@ -3686,7 +4764,7 @@ async function startWebUI(opts = {}) {
3686
4764
  const rawObj = JSON.parse(data.toString());
3687
4765
  if (typeof rawObj === "object" && rawObj !== null) {
3688
4766
  const obj = rawObj;
3689
- if ("__proto__" in obj || "constructor" in obj || "prototype" in obj) {
4767
+ if (Object.hasOwn(obj, "__proto__") || Object.hasOwn(obj, "constructor") || Object.hasOwn(obj, "prototype")) {
3690
4768
  send(ws, {
3691
4769
  type: "error",
3692
4770
  payload: { phase: "parse", message: "Invalid message object" }
@@ -3707,8 +4785,9 @@ async function startWebUI(opts = {}) {
3707
4785
  }
3708
4786
  });
3709
4787
  ws.on("close", () => {
4788
+ const closing = clients.get(ws);
3710
4789
  clients.delete(ws);
3711
- rateLimits.delete(String(ws));
4790
+ if (closing) rateLimits.delete(closing.connId);
3712
4791
  if (pendingConfirms.size > 0) {
3713
4792
  for (const [id, resolve5] of pendingConfirms) {
3714
4793
  resolve5("no");
@@ -3734,11 +4813,27 @@ async function startWebUI(opts = {}) {
3734
4813
  { sampling: sessionLogging.sampling }
3735
4814
  );
3736
4815
  let eventsArmed = false;
4816
+ let disposeEvents = null;
4817
+ let fleetBroadcast = null;
3737
4818
  const armOnce = (label) => {
3738
4819
  if (eventsArmed) return;
3739
4820
  eventsArmed = true;
3740
4821
  console.log(`[WebUI] Backend ready (${label})`);
3741
- 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
+ });
3742
4837
  };
3743
4838
  wssPrimary.on("listening", () => armOnce(`${wsHost}:${wsPort}`));
3744
4839
  wssPrimary.on("connection", handleConnection);
@@ -3775,33 +4870,33 @@ async function startWebUI(opts = {}) {
3775
4870
  });
3776
4871
  }
3777
4872
  async function touchProjectEntry(root, workDir) {
3778
- const resolved = path8.resolve(root);
4873
+ const resolved = path10.resolve(root);
3779
4874
  const manifest = await loadManifest(globalConfigPath);
3780
4875
  const now = (/* @__PURE__ */ new Date()).toISOString();
3781
- const existing = manifest.projects.find((p) => path8.resolve(p.root) === resolved);
4876
+ const existing = manifest.projects.find((p) => path10.resolve(p.root) === resolved);
3782
4877
  if (existing) {
3783
4878
  existing.lastSeen = now;
3784
- if (workDir) existing.lastWorkingDir = path8.resolve(workDir);
4879
+ if (workDir) existing.lastWorkingDir = path10.resolve(workDir);
3785
4880
  } else {
3786
4881
  manifest.projects.push({
3787
- name: path8.basename(resolved),
4882
+ name: path10.basename(resolved),
3788
4883
  root: resolved,
3789
4884
  slug: generateProjectSlug(resolved),
3790
4885
  createdAt: now,
3791
4886
  lastSeen: now,
3792
- lastWorkingDir: workDir ? path8.resolve(workDir) : void 0
4887
+ lastWorkingDir: workDir ? path10.resolve(workDir) : void 0
3793
4888
  });
3794
4889
  }
3795
4890
  await saveManifest(manifest, globalConfigPath);
3796
4891
  await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
3797
4892
  }
3798
4893
  function projectsJsonPath(globalConfigPath2) {
3799
- const base = path8.dirname(globalConfigPath2);
3800
- return path8.join(base, "projects.json");
4894
+ const base = path10.dirname(globalConfigPath2);
4895
+ return path10.join(base, "projects.json");
3801
4896
  }
3802
4897
  async function loadManifest(globalConfigPath2) {
3803
4898
  try {
3804
- const raw = await fs7.readFile(projectsJsonPath(globalConfigPath2), "utf8");
4899
+ const raw = await fs10.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3805
4900
  const parsed = JSON.parse(raw);
3806
4901
  return { projects: parsed.projects ?? [] };
3807
4902
  } catch {
@@ -3810,16 +4905,16 @@ async function startWebUI(opts = {}) {
3810
4905
  }
3811
4906
  async function saveManifest(manifest, globalConfigPath2) {
3812
4907
  const file = projectsJsonPath(globalConfigPath2);
3813
- await fs7.mkdir(path8.dirname(file), { recursive: true });
3814
- 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");
3815
4910
  }
3816
4911
  function generateProjectSlug(rootPath) {
3817
4912
  return projectSlug(rootPath);
3818
4913
  }
3819
4914
  async function ensureProjectDataDir(slug, globalConfigPath2) {
3820
- const base = path8.dirname(globalConfigPath2);
3821
- const dir = path8.join(base, "projects", slug);
3822
- 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 });
3823
4918
  return dir;
3824
4919
  }
3825
4920
  async function handleMessage(ws, _client, msg) {
@@ -3929,7 +5024,7 @@ async function startWebUI(opts = {}) {
3929
5024
  context.readFiles.clear();
3930
5025
  context.fileMtimes.clear();
3931
5026
  tokenCounter.reset();
3932
- sendResult(ws, true, "Context cleared");
5027
+ sendResult2(ws, true, "Context cleared");
3933
5028
  broadcast(clients, {
3934
5029
  type: "session.start",
3935
5030
  payload: { ...await sessionStartPayload(), reset: true }
@@ -3966,13 +5061,13 @@ async function startWebUI(opts = {}) {
3966
5061
  repaired: report.repaired
3967
5062
  }
3968
5063
  });
3969
- sendResult(
5064
+ sendResult2(
3970
5065
  ws,
3971
5066
  true,
3972
5067
  `Compacted: ${report.before} \u2192 ${report.after} tokens (saved ~${Math.max(0, report.before - report.after)})`
3973
5068
  );
3974
5069
  } catch (err) {
3975
- sendResult(ws, false, errMessage(err));
5070
+ sendResult2(ws, false, errMessage(err));
3976
5071
  }
3977
5072
  break;
3978
5073
  }
@@ -3991,7 +5086,7 @@ async function startWebUI(opts = {}) {
3991
5086
  };
3992
5087
  broadcast(clients, { type: "context.repaired", payload });
3993
5088
  const removed = payload.removedToolUses.length + payload.removedToolResults.length + payload.removedMessages;
3994
- sendResult(
5089
+ sendResult2(
3995
5090
  ws,
3996
5091
  true,
3997
5092
  removed > 0 ? `Context repaired: removed ${removed} orphan protocol item(s)` : "Context repair found no orphan protocol blocks"
@@ -4025,14 +5120,14 @@ async function startWebUI(opts = {}) {
4025
5120
  );
4026
5121
  const custom = customModes.find((m) => m.id === id);
4027
5122
  if (!custom) {
4028
- sendResult(ws, false, `Unknown context mode "${id}"`);
5123
+ sendResult2(ws, false, `Unknown context mode "${id}"`);
4029
5124
  break;
4030
5125
  }
4031
5126
  policy = custom;
4032
5127
  }
4033
5128
  context.meta["contextWindowMode"] = policy.id;
4034
5129
  context.meta["contextWindowPolicy"] = policy;
4035
- sendResult(ws, true, `Context mode switched to ${policy.id}`);
5130
+ sendResult2(ws, true, `Context mode switched to ${policy.id}`);
4036
5131
  broadcast(clients, {
4037
5132
  type: "context.mode.changed",
4038
5133
  payload: { id: policy.id, name: policy.name, policy }
@@ -4052,7 +5147,7 @@ async function startWebUI(opts = {}) {
4052
5147
  aggressiveOn: "soft",
4053
5148
  targetLoad: 0.65
4054
5149
  });
4055
- sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
5150
+ sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
4056
5151
  break;
4057
5152
  }
4058
5153
  case "context.mode.update": {
@@ -4068,7 +5163,7 @@ async function startWebUI(opts = {}) {
4068
5163
  preserveK: payload.preserveK,
4069
5164
  eliseThreshold: payload.eliseThreshold
4070
5165
  });
4071
- sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
5166
+ sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
4072
5167
  break;
4073
5168
  }
4074
5169
  case "context.mode.delete": {
@@ -4078,7 +5173,7 @@ async function startWebUI(opts = {}) {
4078
5173
  context.meta["contextWindowPolicy"] = resolveContextWindowPolicy({}, DEFAULT_CONTEXT_WINDOW_MODE_ID);
4079
5174
  }
4080
5175
  const result = customModeStore.remove(id);
4081
- sendResult(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
5176
+ sendResult2(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
4082
5177
  break;
4083
5178
  }
4084
5179
  case "providers.list": {
@@ -4159,14 +5254,15 @@ async function startWebUI(opts = {}) {
4159
5254
  context.provider = newProv;
4160
5255
  updateAutoCompactionMaxContext?.(newProv);
4161
5256
  try {
4162
- configWriteLock = configWriteLock.then(async () => {
4163
- const raw = await fs7.readFile(globalConfigPath, "utf8");
5257
+ const next = configWriteLock.then(async () => {
5258
+ const raw = await fs10.readFile(globalConfigPath, "utf8");
4164
5259
  const parsed = JSON.parse(raw);
4165
5260
  parsed.provider = newProvider;
4166
5261
  parsed.model = newModel;
4167
5262
  await atomicWrite5(globalConfigPath, JSON.stringify(parsed, null, 2));
4168
5263
  });
4169
- await configWriteLock;
5264
+ configWriteLock = next.then(() => void 0, () => void 0);
5265
+ await next;
4170
5266
  } catch (err) {
4171
5267
  console.warn(JSON.stringify({
4172
5268
  level: "warn",
@@ -4319,13 +5415,13 @@ async function startWebUI(opts = {}) {
4319
5415
  const { id } = msg.payload;
4320
5416
  try {
4321
5417
  if (id === session.id) {
4322
- sendResult(ws, false, "Cannot delete the active session");
5418
+ sendResult2(ws, false, "Cannot delete the active session");
4323
5419
  break;
4324
5420
  }
4325
5421
  await sessionStore.delete(id);
4326
- sendResult(ws, true, `Session ${id} deleted`);
5422
+ sendResult2(ws, true, `Session ${id} deleted`);
4327
5423
  } catch (err) {
4328
- sendResult(ws, false, errMessage(err));
5424
+ sendResult2(ws, false, errMessage(err));
4329
5425
  }
4330
5426
  break;
4331
5427
  }
@@ -4333,7 +5429,7 @@ async function startWebUI(opts = {}) {
4333
5429
  const { id } = msg.payload;
4334
5430
  try {
4335
5431
  if (id === session.id) {
4336
- sendResult(ws, false, "Session is already active");
5432
+ sendResult2(ws, false, "Session is already active");
4337
5433
  break;
4338
5434
  }
4339
5435
  const resumed = await sessionStore.resume(id);
@@ -4363,14 +5459,14 @@ async function startWebUI(opts = {}) {
4363
5459
  replayUsage: resumed.data.usage
4364
5460
  }
4365
5461
  });
4366
- sendResult(ws, true, `Resumed session ${id}`);
5462
+ sendResult2(ws, true, `Resumed session ${id}`);
4367
5463
  } catch (err) {
4368
- sendResult(ws, false, errMessage(err));
5464
+ sendResult2(ws, false, errMessage(err));
4369
5465
  }
4370
5466
  break;
4371
5467
  }
4372
5468
  case "session.save": {
4373
- sendResult(ws, true, `Session ${session.id} is auto-saved`);
5469
+ sendResult2(ws, true, `Session ${session.id} is auto-saved`);
4374
5470
  break;
4375
5471
  }
4376
5472
  case "tools.list": {
@@ -4393,6 +5489,27 @@ async function startWebUI(opts = {}) {
4393
5489
  return handleMemoryRemember(ws, msg, memoryStore);
4394
5490
  case "memory.forget":
4395
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);
4396
5513
  case "skills.list": {
4397
5514
  if (!skillLoader) {
4398
5515
  send(ws, { type: "skills.list", payload: { skills: [], enabled: false } });
@@ -4402,6 +5519,18 @@ async function startWebUI(opts = {}) {
4402
5519
  const manifests = await skillLoader.list();
4403
5520
  const entries = await skillLoader.listEntries();
4404
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
+ }
4405
5534
  send(ws, {
4406
5535
  type: "skills.list",
4407
5536
  payload: {
@@ -4411,6 +5540,8 @@ async function startWebUI(opts = {}) {
4411
5540
  description: m.description,
4412
5541
  version: m.version ?? "",
4413
5542
  source: m.source,
5543
+ sourceUrl: sourceUrlsByName.get(m.name) ?? "",
5544
+ ref: refsByName.get(m.name) ?? "",
4414
5545
  path: m.path,
4415
5546
  trigger: byName.get(m.name)?.trigger ?? "",
4416
5547
  scope: byName.get(m.name)?.scope ?? []
@@ -4429,6 +5560,261 @@ async function startWebUI(opts = {}) {
4429
5560
  }
4430
5561
  break;
4431
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
+ }
4432
5818
  case "diag.get": {
4433
5819
  const usage = tokenCounter.total();
4434
5820
  send(ws, {
@@ -4456,194 +5842,84 @@ async function startWebUI(opts = {}) {
4456
5842
  break;
4457
5843
  }
4458
5844
  case "todos.get": {
4459
- send(ws, {
4460
- type: "todos.updated",
4461
- payload: { todos: [...context.todos] }
4462
- });
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);
4463
5851
  break;
4464
5852
  }
4465
5853
  case "todos.clear": {
4466
- context.state.replaceTodos([]);
4467
- sendResult(ws, true, "Todos cleared");
4468
- 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);
4469
5860
  break;
4470
5861
  }
4471
5862
  case "todos.remove": {
4472
- const payload = msg.payload;
4473
- if (!payload) {
4474
- sendResult(ws, false, "Missing id or index");
4475
- break;
4476
- }
4477
- const { id, index } = payload;
4478
- let targetIdx = -1;
4479
- if (typeof id === "string") {
4480
- targetIdx = context.todos.findIndex((t) => t.id === id);
4481
- } else if (typeof index === "number" && index > 0) {
4482
- targetIdx = index - 1;
4483
- }
4484
- if (targetIdx < 0 || !context.todos[targetIdx]) {
4485
- sendResult(ws, false, "Todo not found");
4486
- break;
4487
- }
4488
- const removed = expectDefined2(context.todos[targetIdx]);
4489
- const next = [...context.todos.slice(0, targetIdx), ...context.todos.slice(targetIdx + 1)];
4490
- context.state.replaceTodos(next);
4491
- sendResult(ws, true, `Removed: ${removed.content}`);
4492
- 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);
4493
5869
  break;
4494
5870
  }
4495
5871
  case "tasks.get": {
4496
- const taskPath = context.meta["task.path"];
4497
- if (typeof taskPath === "string" && taskPath) {
4498
- try {
4499
- const { loadTasks } = await import("@wrongstack/core");
4500
- const file = await loadTasks(taskPath);
4501
- send(ws, {
4502
- type: "tasks.updated",
4503
- payload: { tasks: file?.tasks ?? [] }
4504
- });
4505
- } catch {
4506
- send(ws, { type: "tasks.updated", payload: { tasks: [] } });
4507
- }
4508
- } else {
4509
- send(ws, { type: "tasks.updated", payload: { tasks: [], error: "Task storage not configured." } });
4510
- }
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);
4511
5878
  break;
4512
5879
  }
4513
5880
  case "plan.get": {
4514
- const planPath = context.meta["plan.path"];
4515
- if (typeof planPath === "string" && planPath) {
4516
- try {
4517
- const { loadPlan } = await import("@wrongstack/core");
4518
- const plan = await loadPlan(planPath);
4519
- send(ws, {
4520
- type: "plan.updated",
4521
- payload: {
4522
- plan: plan ?? {
4523
- version: 1,
4524
- sessionId: session.id,
4525
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4526
- items: []
4527
- }
4528
- }
4529
- });
4530
- } catch {
4531
- send(ws, {
4532
- type: "plan.updated",
4533
- payload: {
4534
- plan: {
4535
- version: 1,
4536
- sessionId: session.id,
4537
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4538
- items: []
4539
- }
4540
- }
4541
- });
4542
- }
4543
- } else {
4544
- send(ws, {
4545
- type: "plan.updated",
4546
- payload: { plan: null, error: "Plan storage is not configured for this session." }
4547
- });
4548
- }
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);
4549
5887
  break;
4550
5888
  }
4551
5889
  case "plan.template_use": {
4552
- const { template } = msg.payload;
4553
- const planPath = context.meta["plan.path"];
4554
- if (typeof planPath !== "string" || !planPath) {
4555
- sendResult(ws, false, "Plan storage is not configured for this session.");
4556
- break;
4557
- }
4558
- try {
4559
- const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
4560
- const tpl = getPlanTemplate(template);
4561
- if (!tpl) {
4562
- sendResult(ws, false, `Unknown template "${template}".`);
4563
- break;
4564
- }
4565
- let plan = await loadPlan(planPath) ?? emptyPlan(session.id);
4566
- for (const item of tpl.items) {
4567
- ({ plan } = addPlanItem(plan, item.title, item.details));
4568
- }
4569
- await savePlan(planPath, plan);
4570
- sendResult(ws, true, `Applied template "${tpl.name}" \u2014 ${tpl.items.length} items added.`);
4571
- broadcast(clients, {
4572
- type: "plan.updated",
4573
- payload: { plan }
4574
- });
4575
- } catch (err) {
4576
- sendResult(ws, false, errMessage(err));
4577
- }
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);
4578
5896
  break;
4579
5897
  }
4580
5898
  case "todo.update": {
4581
- const payload = msg.payload;
4582
- const idx = context.todos.findIndex((t) => t.id === payload.id);
4583
- if (idx === -1) {
4584
- sendResult(ws, false, "Todo not found");
4585
- break;
4586
- }
4587
- const next = [...context.todos];
4588
- const existing = expectDefined2(next[idx]);
4589
- next[idx] = {
4590
- ...existing,
4591
- status: payload.status ?? existing.status,
4592
- activeForm: payload.activeForm !== void 0 ? payload.activeForm : existing.activeForm
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)
4593
5903
  };
4594
- context.state.replaceTodos(next);
4595
- sendResult(ws, true, `Todo "${existing.content}" updated`);
4596
- broadcast(clients, { type: "todos.updated", payload: { todos: next } });
5904
+ handleTodoUpdate(ctx, ws, msg.payload);
4597
5905
  break;
4598
5906
  }
4599
5907
  case "task.update": {
4600
- const payload = msg.payload;
4601
- const taskPath = context.meta["task.path"];
4602
- if (typeof taskPath !== "string" || !taskPath) {
4603
- sendResult(ws, false, "Task storage not configured.");
4604
- break;
4605
- }
4606
- try {
4607
- const { mutateTasks } = await import("@wrongstack/core");
4608
- const file = await mutateTasks(taskPath, session.id, async (f) => {
4609
- const task = f.tasks.find((t) => t.id === payload.id);
4610
- if (!task) return f;
4611
- task.status = payload.status;
4612
- task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4613
- return f;
4614
- });
4615
- sendResult(ws, true, `Task status updated to "${payload.status}".`);
4616
- broadcast(clients, { type: "tasks.updated", payload: { tasks: file.tasks } });
4617
- } catch (err) {
4618
- sendResult(ws, false, errMessage(err));
4619
- }
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);
4620
5914
  break;
4621
5915
  }
4622
5916
  case "plan.item.update": {
4623
- const payload = msg.payload;
4624
- const planPath = context.meta["plan.path"];
4625
- if (typeof planPath !== "string" || !planPath) {
4626
- sendResult(ws, false, "Plan storage is not configured for this session.");
4627
- break;
4628
- }
4629
- try {
4630
- const { mutatePlan, setPlanItemStatus } = await import("@wrongstack/core");
4631
- let changed = false;
4632
- const plan = await mutatePlan(planPath, session.id, async (p) => {
4633
- const before = p.updatedAt;
4634
- const updated = setPlanItemStatus(p, payload.target, payload.status);
4635
- changed = updated.updatedAt !== before;
4636
- return updated;
4637
- });
4638
- if (!changed) {
4639
- sendResult(ws, false, `No plan item matched "${payload.target}".`);
4640
- break;
4641
- }
4642
- sendResult(ws, true, `Plan item status updated to "${payload.status}".`);
4643
- broadcast(clients, { type: "plan.updated", payload: { plan } });
4644
- } catch (err) {
4645
- sendResult(ws, false, errMessage(err));
4646
- }
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);
4647
5923
  break;
4648
5924
  }
4649
5925
  // ── File operations — delegated to shared handlers (file-handlers.ts) ──
@@ -4713,13 +5989,13 @@ async function startWebUI(opts = {}) {
4713
5989
  provider: config.provider,
4714
5990
  model: config.model
4715
5991
  });
4716
- sendResult(ws, true, `Switched to mode "${id}"`);
5992
+ sendResult2(ws, true, `Switched to mode "${id}"`);
4717
5993
  broadcast(clients, {
4718
5994
  type: "session.start",
4719
5995
  payload: { ...await sessionStartPayload() }
4720
5996
  });
4721
5997
  } catch (err) {
4722
- sendResult(ws, false, errMessage(err));
5998
+ sendResult2(ws, false, errMessage(err));
4723
5999
  }
4724
6000
  break;
4725
6001
  }
@@ -4773,13 +6049,13 @@ async function startWebUI(opts = {}) {
4773
6049
  const { getProcessRegistry } = await import("@wrongstack/tools");
4774
6050
  const proc = getProcessRegistry().get(pid);
4775
6051
  if (proc?.protected) {
4776
- sendResult(ws, false, `Cannot kill protected process (PID ${pid})`);
6052
+ sendResult2(ws, false, `Cannot kill protected process (PID ${pid})`);
4777
6053
  break;
4778
6054
  }
4779
6055
  getProcessRegistry().kill(pid);
4780
- sendResult(ws, true, `Killed PID ${pid}`);
6056
+ sendResult2(ws, true, `Killed PID ${pid}`);
4781
6057
  } catch (err) {
4782
- sendResult(ws, false, errMessage(err));
6058
+ sendResult2(ws, false, errMessage(err));
4783
6059
  }
4784
6060
  break;
4785
6061
  }
@@ -4787,47 +6063,25 @@ async function startWebUI(opts = {}) {
4787
6063
  try {
4788
6064
  const { getProcessRegistry } = await import("@wrongstack/tools");
4789
6065
  getProcessRegistry().killAll();
4790
- sendResult(ws, true, "All processes killed");
6066
+ sendResult2(ws, true, "All processes killed");
4791
6067
  } catch (err) {
4792
- sendResult(ws, false, errMessage(err));
6068
+ sendResult2(ws, false, errMessage(err));
4793
6069
  }
4794
6070
  break;
4795
6071
  }
4796
6072
  case "git.info": {
4797
- const cwd = projectRoot;
4798
- const execFile = (cmd, args) => new Promise((resolve5) => {
4799
- import("child_process").then(({ execFile: ef }) => {
4800
- ef(cmd, args, { cwd, timeout: 3e3 }, (err, stdout) => {
4801
- resolve5(err ? "" : stdout.trim());
4802
- });
4803
- });
4804
- });
4805
- const [branchRaw, diffRaw, statusRaw, upstreamRaw] = await Promise.all([
4806
- execFile("git", ["branch", "--show-current"]),
4807
- execFile("git", ["diff", "--stat"]),
4808
- execFile("git", ["status", "--porcelain"]),
4809
- execFile("git", ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
4810
- ]);
4811
- const branch = branchRaw || "(detached)";
4812
- const diffMatch = /\+\s*(\d+)\s*deletion/i.exec(diffRaw);
4813
- const addMatch = /(\d+)\s*insertion/i.exec(diffRaw) ?? /(\d+)\s*addition/i.exec(diffRaw);
4814
- const delMatch = /\+\s*(\d+)\s*deletion/i.exec(diffRaw);
4815
- const added = addMatch ? Number(addMatch[1]) : 0;
4816
- const deleted = delMatch ? Number(delMatch[1]) : 0;
4817
- const untracked = statusRaw.split("\n").filter((l) => l.startsWith("??")).length;
4818
- const [aheadRaw, behindRaw] = (upstreamRaw || "0 0").split(" ");
4819
- const ahead = Number(aheadRaw) || 0;
4820
- const behind = Number(behindRaw) || 0;
4821
- send(ws, {
4822
- type: "git.info",
4823
- payload: { branch, added, deleted, untracked, ahead, behind }
4824
- });
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");
4825
6079
  break;
4826
6080
  }
4827
6081
  case "goal.get": {
4828
6082
  try {
4829
- const goalPath = path8.join(projectRoot, ".wrongstack", "goal.json");
4830
- const raw = await fs7.readFile(goalPath, "utf8");
6083
+ const goalPath = resolveWstackPaths({ projectRoot }).projectGoal;
6084
+ const raw = await fs10.readFile(goalPath, "utf8");
4831
6085
  const goal = JSON.parse(raw);
4832
6086
  broadcast(clients, { type: "goal.updated", payload: goal });
4833
6087
  } catch {
@@ -4838,7 +6092,7 @@ async function startWebUI(opts = {}) {
4838
6092
  case "autonomy.switch": {
4839
6093
  const { mode } = msg.payload;
4840
6094
  context.meta["autonomy"] = mode;
4841
- sendResult(ws, true, `Autonomy mode set to "${mode}"`);
6095
+ sendResult2(ws, true, `Autonomy mode set to "${mode}"`);
4842
6096
  broadcast(clients, { type: "prefs.updated", payload: { autonomy: mode } });
4843
6097
  void persistPrefsToConfig({ autonomy: mode });
4844
6098
  break;
@@ -4887,7 +6141,7 @@ async function startWebUI(opts = {}) {
4887
6141
  try {
4888
6142
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4889
6143
  const rewinder = new DefaultSessionRewinder(
4890
- path8.join(projectRoot, ".wrongstack", "sessions"),
6144
+ path10.join(projectRoot, ".wrongstack", "sessions"),
4891
6145
  projectRoot
4892
6146
  );
4893
6147
  const checkpoints = await rewinder.listCheckpoints(session.id);
@@ -4908,18 +6162,18 @@ async function startWebUI(opts = {}) {
4908
6162
  try {
4909
6163
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4910
6164
  const rewinder = new DefaultSessionRewinder(
4911
- path8.join(projectRoot, ".wrongstack", "sessions"),
6165
+ path10.join(projectRoot, ".wrongstack", "sessions"),
4912
6166
  projectRoot
4913
6167
  );
4914
6168
  await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
4915
6169
  await context.session.truncateToCheckpoint(checkpointIndex);
4916
- sendResult(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
6170
+ sendResult2(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
4917
6171
  broadcast(clients, {
4918
6172
  type: "session.start",
4919
6173
  payload: { ...await sessionStartPayload(), reset: true }
4920
6174
  });
4921
6175
  } catch (err) {
4922
- sendResult(ws, false, errMessage(err));
6176
+ sendResult2(ws, false, errMessage(err));
4923
6177
  }
4924
6178
  break;
4925
6179
  }
@@ -4942,9 +6196,9 @@ async function startWebUI(opts = {}) {
4942
6196
  case "projects.add": {
4943
6197
  const { root: addRoot, name: displayName } = msg.payload;
4944
6198
  try {
4945
- const resolved = path8.resolve(addRoot);
4946
- await fs7.access(resolved);
4947
- const stat2 = await fs7.stat(resolved);
6199
+ const resolved = path10.resolve(addRoot);
6200
+ await fs10.access(resolved);
6201
+ const stat2 = await fs10.stat(resolved);
4948
6202
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4949
6203
  const manifest = await loadManifest(globalConfigPath);
4950
6204
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -4960,7 +6214,7 @@ async function startWebUI(opts = {}) {
4960
6214
  });
4961
6215
  break;
4962
6216
  }
4963
- const name = displayName?.trim() || path8.basename(resolved);
6217
+ const name = displayName?.trim() || path10.basename(resolved);
4964
6218
  const slug = generateProjectSlug(resolved);
4965
6219
  await ensureProjectDataDir(slug, globalConfigPath);
4966
6220
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -4979,7 +6233,7 @@ async function startWebUI(opts = {}) {
4979
6233
  send(ws, {
4980
6234
  type: "projects.added",
4981
6235
  payload: {
4982
- name: path8.basename(addRoot),
6236
+ name: path10.basename(addRoot),
4983
6237
  root: addRoot,
4984
6238
  slug: "",
4985
6239
  message: errMessage(err)
@@ -4991,17 +6245,17 @@ async function startWebUI(opts = {}) {
4991
6245
  case "projects.select": {
4992
6246
  const { root: selRoot, name: selName } = msg.payload;
4993
6247
  try {
4994
- const resolved = path8.resolve(selRoot);
6248
+ const resolved = path10.resolve(selRoot);
4995
6249
  try {
4996
- await fs7.access(resolved);
4997
- const stat2 = await fs7.stat(resolved);
6250
+ await fs10.access(resolved);
6251
+ const stat2 = await fs10.stat(resolved);
4998
6252
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4999
6253
  } catch (err) {
5000
6254
  send(ws, {
5001
6255
  type: "projects.selected",
5002
6256
  payload: {
5003
6257
  root: selRoot,
5004
- name: selName || path8.basename(selRoot),
6258
+ name: selName || path10.basename(selRoot),
5005
6259
  message: `Cannot switch: ${errMessage(err)}`
5006
6260
  }
5007
6261
  });
@@ -5013,7 +6267,7 @@ async function startWebUI(opts = {}) {
5013
6267
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
5014
6268
  entry.lastWorkingDir = resolved;
5015
6269
  } else {
5016
- const name = selName?.trim() || path8.basename(resolved);
6270
+ const name = selName?.trim() || path10.basename(resolved);
5017
6271
  const slug = generateProjectSlug(resolved);
5018
6272
  manifest.projects.push({
5019
6273
  name,
@@ -5054,13 +6308,13 @@ async function startWebUI(opts = {}) {
5054
6308
  });
5055
6309
  } catch {
5056
6310
  }
5057
- const newSessionsDir = path8.join(
5058
- path8.dirname(globalConfigPath),
6311
+ const newSessionsDir = path10.join(
6312
+ path10.dirname(globalConfigPath),
5059
6313
  "projects",
5060
6314
  switchSlug,
5061
6315
  "sessions"
5062
6316
  );
5063
- await fs7.mkdir(newSessionsDir, { recursive: true });
6317
+ await fs10.mkdir(newSessionsDir, { recursive: true });
5064
6318
  const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
5065
6319
  const oldSessionId = session.id;
5066
6320
  try {
@@ -5092,8 +6346,9 @@ async function startWebUI(opts = {}) {
5092
6346
  sessionId: session.id,
5093
6347
  projectSlug: switchSlug,
5094
6348
  projectRoot,
5095
- projectName: path8.basename(projectRoot),
6349
+ projectName: path10.basename(projectRoot),
5096
6350
  workingDir,
6351
+ clientType: "webui",
5097
6352
  pid: process.pid,
5098
6353
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
5099
6354
  });
@@ -5103,8 +6358,8 @@ async function startWebUI(opts = {}) {
5103
6358
  type: "projects.selected",
5104
6359
  payload: {
5105
6360
  root: resolved,
5106
- name: selName || path8.basename(resolved),
5107
- message: `Switched to ${selName || path8.basename(resolved)}`
6361
+ name: selName || path10.basename(resolved),
6362
+ message: `Switched to ${selName || path10.basename(resolved)}`
5108
6363
  }
5109
6364
  });
5110
6365
  broadcast(clients, {
@@ -5127,7 +6382,7 @@ async function startWebUI(opts = {}) {
5127
6382
  type: "projects.selected",
5128
6383
  payload: {
5129
6384
  root: selRoot,
5130
- name: selName || path8.basename(selRoot),
6385
+ name: selName || path10.basename(selRoot),
5131
6386
  message: errMessage(err)
5132
6387
  }
5133
6388
  });
@@ -5138,17 +6393,17 @@ async function startWebUI(opts = {}) {
5138
6393
  case "working_dir.set": {
5139
6394
  const { path: newPath } = msg.payload;
5140
6395
  try {
5141
- const resolved = path8.resolve(projectRoot, newPath);
5142
- if (!resolved.startsWith(projectRoot + path8.sep) && resolved !== projectRoot) {
5143
- 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}`);
5144
6399
  break;
5145
6400
  }
5146
6401
  try {
5147
- await fs7.access(resolved);
5148
- const stat2 = await fs7.stat(resolved);
6402
+ await fs10.access(resolved);
6403
+ const stat2 = await fs10.stat(resolved);
5149
6404
  if (!stat2.isDirectory()) throw new Error("Not a directory");
5150
6405
  } catch {
5151
- sendResult(ws, false, `Directory not found or not accessible: ${resolved}`);
6406
+ sendResult2(ws, false, `Directory not found or not accessible: ${resolved}`);
5152
6407
  break;
5153
6408
  }
5154
6409
  workingDir = resolved;
@@ -5157,9 +6412,9 @@ async function startWebUI(opts = {}) {
5157
6412
  type: "working_dir.changed",
5158
6413
  payload: { cwd: resolved, projectRoot }
5159
6414
  });
5160
- sendResult(ws, true, `Working directory set to ${resolved}`);
6415
+ sendResult2(ws, true, `Working directory set to ${resolved}`);
5161
6416
  } catch (err) {
5162
- sendResult(ws, false, errMessage(err));
6417
+ sendResult2(ws, false, errMessage(err));
5163
6418
  }
5164
6419
  break;
5165
6420
  }
@@ -5169,31 +6424,31 @@ async function startWebUI(opts = {}) {
5169
6424
  msg.payload,
5170
6425
  logger
5171
6426
  );
5172
- sendResult(ws, result.success, result.message);
6427
+ sendResult2(ws, result.success, result.message);
5173
6428
  break;
5174
6429
  }
5175
6430
  // ── Mailbox operations — project-level inter-agent messaging ────
5176
6431
  case "mailbox.messages":
5177
6432
  return handleMailboxMessages(
5178
6433
  ws,
5179
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
6434
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
5180
6435
  msg.payload
5181
6436
  );
5182
6437
  case "mailbox.agents":
5183
6438
  return handleMailboxAgents(
5184
6439
  ws,
5185
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
6440
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
5186
6441
  msg.payload
5187
6442
  );
5188
6443
  case "mailbox.clear":
5189
6444
  return handleMailboxClear(
5190
6445
  ws,
5191
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) }
6446
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) }
5192
6447
  );
5193
6448
  case "mailbox.purge":
5194
6449
  return handleMailboxPurge(
5195
6450
  ws,
5196
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
6451
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
5197
6452
  msg.payload
5198
6453
  );
5199
6454
  // ── Brain — status, autonomy ceiling, direct decision support ───
@@ -5207,7 +6462,7 @@ async function startWebUI(opts = {}) {
5207
6462
  const level = msg.payload?.level ?? "";
5208
6463
  const valid = ["off", "low", "medium", "high", "all"];
5209
6464
  if (!valid.includes(level)) {
5210
- sendResult(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
6465
+ sendResult2(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
5211
6466
  break;
5212
6467
  }
5213
6468
  brainSettings.maxAutoRisk = level;
@@ -5220,7 +6475,7 @@ async function startWebUI(opts = {}) {
5220
6475
  case "brain.ask": {
5221
6476
  const question = msg.payload?.question?.trim();
5222
6477
  if (!question) {
5223
- sendResult(ws, false, "Usage: /brain ask <question>");
6478
+ sendResult2(ws, false, "Usage: /brain ask <question>");
5224
6479
  break;
5225
6480
  }
5226
6481
  try {
@@ -5233,7 +6488,7 @@ async function startWebUI(opts = {}) {
5233
6488
  });
5234
6489
  send(ws, { type: "brain.answer", payload: { question, decision } });
5235
6490
  } catch (err) {
5236
- sendResult(ws, false, `Brain consultation failed: ${errMessage(err)}`);
6491
+ sendResult2(ws, false, `Brain consultation failed: ${errMessage(err)}`);
5237
6492
  }
5238
6493
  break;
5239
6494
  }
@@ -5260,14 +6515,28 @@ async function startWebUI(opts = {}) {
5260
6515
  broadcast,
5261
6516
  clients
5262
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
+ };
5263
6528
  const httpServer = createHttpServer({
5264
6529
  host: wsHost,
5265
- distDir: path8.resolve(import.meta.dirname, "../../dist"),
6530
+ distDir: path10.resolve(import.meta.dirname, "../../dist"),
5266
6531
  wsPort,
5267
6532
  globalRoot: wpaths.globalRoot,
5268
- apiToken: wsToken
6533
+ apiToken: wsToken,
6534
+ watcherMetrics,
6535
+ onFleetPing: () => {
6536
+ void fleetBroadcast?.();
6537
+ }
5269
6538
  });
5270
- const registryBaseDir = path8.dirname(globalConfigPath);
6539
+ const registryBaseDir = path10.dirname(globalConfigPath);
5271
6540
  httpServer.listen(httpPort, wsHost, () => {
5272
6541
  const openUrl = `http://${wsHost}:${httpPort}`;
5273
6542
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
@@ -5279,7 +6548,7 @@ async function startWebUI(opts = {}) {
5279
6548
  wsPort,
5280
6549
  host: wsHost,
5281
6550
  projectRoot,
5282
- projectName: path8.basename(projectRoot) || projectRoot,
6551
+ projectName: path10.basename(projectRoot) || projectRoot,
5283
6552
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5284
6553
  url: `http://${wsHost}:${httpPort}`
5285
6554
  },
@@ -5306,6 +6575,10 @@ async function startWebUI(opts = {}) {
5306
6575
  // reality. Crash exits are healed by the next register()/list() prune pass.
5307
6576
  onShutdown: () => {
5308
6577
  brainMonitor.stop();
6578
+ if (disposeEvents) {
6579
+ disposeEvents();
6580
+ disposeEvents = null;
6581
+ }
5309
6582
  if (eternalSubscription) {
5310
6583
  eternalSubscription.dispose();
5311
6584
  eternalSubscription = null;