@wrongstack/webui 0.264.0 → 0.267.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,179 @@
1
1
  // src/server/index.ts
2
- import { expectDefined as expectDefined2, GlobalMailbox as GlobalMailbox2, projectSlug, getSessionRegistry, AgentStatusTracker } from "@wrongstack/core";
2
+ import { expectDefined as expectDefined2, GlobalMailbox as GlobalMailbox2, projectSlug, getSessionRegistry, AgentStatusTracker, FleetNotifier } from "@wrongstack/core";
3
+
4
+ // src/server/handlers/worklist-handlers.ts
5
+ function sendResult(ws, ctx, ok, message) {
6
+ ctx.send(ws, { type: ok ? "ok" : "error", message });
7
+ }
8
+ function handleTodosGet(ctx, ws) {
9
+ ctx.send(ws, { type: "todos.updated", payload: { todos: ctx.context.todos } });
10
+ }
11
+ function handleTodosClear(ctx, ws) {
12
+ ctx.replaceTodos?.([]);
13
+ ctx.broadcast({ type: "todos.cleared" });
14
+ sendResult(ws, ctx, true, "Todo board cleared.");
15
+ }
16
+ function handleTodosRemove(ctx, ws, payload) {
17
+ if (!payload || payload.id === void 0 && payload.index === void 0) {
18
+ sendResult(ws, ctx, false, "todos.remove requires id or index.");
19
+ return;
20
+ }
21
+ const next = payload.id !== void 0 ? ctx.context.todos.filter((t) => t.id !== payload.id) : ctx.context.todos.filter((_, i) => i !== payload.index);
22
+ ctx.replaceTodos?.(next);
23
+ ctx.broadcast({ type: "todos.updated", payload: { todos: next } });
24
+ sendResult(ws, ctx, true, "Todo item removed.");
25
+ }
26
+ function handleTodoUpdate(ctx, ws, payload) {
27
+ const todo = ctx.context.todos.find((t) => t.id === payload.id);
28
+ if (!todo) {
29
+ sendResult(ws, ctx, false, `No todo with id "${payload.id}".`);
30
+ return;
31
+ }
32
+ const next = ctx.context.todos.map(
33
+ (t) => t.id === payload.id ? { ...t, ...payload.status !== void 0 && { status: payload.status }, ...payload.activeForm !== void 0 && { activeForm: payload.activeForm } } : t
34
+ );
35
+ ctx.replaceTodos?.(next);
36
+ ctx.broadcast({ type: "todos.updated", payload: { todos: next } });
37
+ sendResult(ws, ctx, true, `Todo "${todo.content}" updated.`);
38
+ }
39
+ async function handleTasksGet(ctx, ws) {
40
+ const taskPath = ctx.context.meta["task.path"];
41
+ if (typeof taskPath === "string" && taskPath) {
42
+ try {
43
+ const { loadTasks } = await import("@wrongstack/core");
44
+ const file = await loadTasks(taskPath);
45
+ ctx.send(ws, { type: "tasks.updated", payload: { tasks: file?.tasks ?? [] } });
46
+ } catch {
47
+ ctx.send(ws, { type: "tasks.updated", payload: { tasks: [] } });
48
+ }
49
+ } else {
50
+ ctx.send(ws, {
51
+ type: "tasks.updated",
52
+ payload: { tasks: [], error: "Task storage not configured." }
53
+ });
54
+ }
55
+ }
56
+ async function handleTaskUpdate(ctx, ws, payload) {
57
+ const taskPath = ctx.context.meta["task.path"];
58
+ if (typeof taskPath !== "string" || !taskPath) {
59
+ sendResult(ws, ctx, false, "Task storage is not configured for this session.");
60
+ return;
61
+ }
62
+ try {
63
+ const { loadTasks, saveTasks } = await import("@wrongstack/core");
64
+ const file = await loadTasks(taskPath);
65
+ if (!file) {
66
+ sendResult(ws, ctx, false, "No task file found.");
67
+ return;
68
+ }
69
+ const idx = file.tasks.findIndex((t) => t.id === payload.id);
70
+ if (idx === -1) {
71
+ sendResult(ws, ctx, false, `Task "${payload.id}" not found.`);
72
+ return;
73
+ }
74
+ file.tasks[idx] = { ...file.tasks[idx], status: payload.status };
75
+ await saveTasks(taskPath, file);
76
+ ctx.broadcast({ type: "tasks.updated", payload: { tasks: file.tasks } });
77
+ sendResult(ws, ctx, true, `Task "${payload.id}" marked ${payload.status}.`);
78
+ } catch (err) {
79
+ sendResult(ws, ctx, false, String(err));
80
+ }
81
+ }
82
+ async function handlePlanGet(ctx, ws) {
83
+ const planPath = ctx.context.meta["plan.path"];
84
+ const sessionId = ctx.context.session?.id ?? "";
85
+ if (typeof planPath === "string" && planPath) {
86
+ try {
87
+ const { loadPlan } = await import("@wrongstack/core");
88
+ const plan = await loadPlan(planPath);
89
+ ctx.send(ws, {
90
+ type: "plan.updated",
91
+ payload: {
92
+ plan: plan ?? {
93
+ version: 1,
94
+ sessionId,
95
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
96
+ items: []
97
+ }
98
+ }
99
+ });
100
+ } catch {
101
+ ctx.send(ws, {
102
+ type: "plan.updated",
103
+ payload: {
104
+ plan: {
105
+ version: 1,
106
+ sessionId,
107
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
108
+ items: []
109
+ }
110
+ }
111
+ });
112
+ }
113
+ } else {
114
+ ctx.send(ws, {
115
+ type: "plan.updated",
116
+ payload: { plan: null, error: "Plan storage is not configured for this session." }
117
+ });
118
+ }
119
+ }
120
+ async function handlePlanTemplateUse(ctx, ws, template) {
121
+ const planPath = ctx.context.meta["plan.path"];
122
+ const sessionId = ctx.context.session?.id ?? "";
123
+ if (typeof planPath !== "string" || !planPath) {
124
+ sendResult(ws, ctx, false, "Plan storage is not configured for this session.");
125
+ return;
126
+ }
127
+ try {
128
+ const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
129
+ const tpl = getPlanTemplate(template);
130
+ if (!tpl) {
131
+ sendResult(ws, ctx, false, `Unknown template "${template}".`);
132
+ return;
133
+ }
134
+ let plan = await loadPlan(planPath) ?? emptyPlan(sessionId);
135
+ for (const item of tpl.items) {
136
+ ({ plan } = addPlanItem(plan, item.title, item.details));
137
+ }
138
+ await savePlan(planPath, plan);
139
+ sendResult(ws, ctx, true, `Applied template "${tpl.name}" \u2014 ${tpl.items.length} items added.`);
140
+ ctx.broadcast({ type: "plan.updated", payload: { plan } });
141
+ } catch (err) {
142
+ sendResult(ws, ctx, false, String(err));
143
+ }
144
+ }
145
+ async function handlePlanItemUpdate(ctx, ws, payload) {
146
+ const planPath = ctx.context.meta["plan.path"];
147
+ const sessionId = ctx.context.session?.id ?? "";
148
+ if (typeof planPath !== "string" || !planPath) {
149
+ sendResult(ws, ctx, false, "Plan storage is not configured for this session.");
150
+ return;
151
+ }
152
+ try {
153
+ const { loadPlan, savePlan, mutatePlan, setPlanItemStatus } = await import("@wrongstack/core");
154
+ let changed = false;
155
+ const plan = await mutatePlan(planPath, sessionId, async (p) => {
156
+ const before = p.updatedAt;
157
+ const updated = setPlanItemStatus(p, payload.target, payload.status);
158
+ changed = updated.updatedAt !== before;
159
+ return updated;
160
+ });
161
+ if (!changed) {
162
+ sendResult(ws, ctx, false, `No plan item matched "${payload.target}".`);
163
+ return;
164
+ }
165
+ sendResult(ws, ctx, true, `Plan item status updated to "${payload.status}".`);
166
+ ctx.broadcast({ type: "plan.updated", payload: { plan } });
167
+ } catch (err) {
168
+ sendResult(ws, ctx, false, String(err));
169
+ }
170
+ }
171
+
172
+ // src/server/index.ts
3
173
  import { makeMailboxTool, makeMailSendTool, makeMailInboxTool, mailboxSessionTag } from "@wrongstack/core";
4
- import { toErrorMessage as toErrorMessage5 } from "@wrongstack/core/utils";
174
+ import { toErrorMessage as toErrorMessage5, wstackGlobalRoot as wstackGlobalRoot2, projectHash, resolveWstackPaths } from "@wrongstack/core/utils";
175
+ import { SkillInstaller } from "@wrongstack/core/skills";
176
+ import JSZip2 from "jszip";
5
177
  import {
6
178
  BrainMonitor,
7
179
  DefaultBrainArbiter,
@@ -9,8 +181,8 @@ import {
9
181
  createAutonomyBrain,
10
182
  createTieredBrainArbiter
11
183
  } from "@wrongstack/core";
12
- import * as fs7 from "fs/promises";
13
- import * as path8 from "path";
184
+ import * as fs9 from "fs/promises";
185
+ import * as path9 from "path";
14
186
 
15
187
  // src/server/http-server.ts
16
188
  import * as fs from "fs/promises";
@@ -52,8 +224,8 @@ function extractTokenFromCookie(cookieHeader) {
52
224
  for (const part of raw.split(";")) {
53
225
  const eq = part.indexOf("=");
54
226
  if (eq < 0) continue;
55
- const name = part.slice(0, eq).trim();
56
- if (name === "ws_token") {
227
+ const name2 = part.slice(0, eq).trim();
228
+ if (name2 === "ws_token") {
57
229
  try {
58
230
  return decodeURIComponent(part.slice(eq + 1).trim());
59
231
  } catch {
@@ -129,6 +301,13 @@ function isInsideDist(candidate, distDir) {
129
301
  const resolved = path.resolve(candidate);
130
302
  return resolved === root || resolved.startsWith(root + path.sep);
131
303
  }
304
+ function decodeSessionId(segment) {
305
+ try {
306
+ return decodeURIComponent(segment);
307
+ } catch {
308
+ return segment;
309
+ }
310
+ }
132
311
  function createHttpServer(opts) {
133
312
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
134
313
  const distDir = path.resolve(opts.distDir);
@@ -154,6 +333,22 @@ function createHttpServer(opts) {
154
333
  res.end("ok");
155
334
  return;
156
335
  }
336
+ if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
337
+ const headerToken = req.headers["x-ws-token"];
338
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
339
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
340
+ res.writeHead(401, { "Content-Type": "application/json" });
341
+ res.end(JSON.stringify({ error: "Unauthorized" }));
342
+ return;
343
+ }
344
+ try {
345
+ opts.onFleetPing?.();
346
+ } catch {
347
+ }
348
+ res.writeHead(204);
349
+ res.end();
350
+ return;
351
+ }
157
352
  if (url.pathname === "/api/sessions" && req.method === "GET") {
158
353
  const headerToken = req.headers["x-ws-token"];
159
354
  const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
@@ -174,7 +369,89 @@ function createHttpServer(opts) {
174
369
  res.end(JSON.stringify({ error: "Unauthorized" }));
175
370
  return;
176
371
  }
177
- await handleApiSessionAgents(res, opts.globalRoot, agentsMatch[1]);
372
+ await handleApiSessionAgents(res, opts.globalRoot, decodeSessionId(agentsMatch[1]));
373
+ return;
374
+ }
375
+ const eventsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
376
+ if (eventsMatch && req.method === "GET") {
377
+ const headerToken = req.headers["x-ws-token"];
378
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
379
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
380
+ res.writeHead(401, { "Content-Type": "application/json" });
381
+ res.end(JSON.stringify({ error: "Unauthorized" }));
382
+ return;
383
+ }
384
+ const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10);
385
+ const limit = Math.min(500, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 200));
386
+ await handleApiSessionEvents(res, opts.globalRoot, decodeSessionId(eventsMatch[1]), limit);
387
+ return;
388
+ }
389
+ const msgMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/message$/);
390
+ if (msgMatch && req.method === "POST") {
391
+ const headerToken = req.headers["x-ws-token"];
392
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
393
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
394
+ res.writeHead(401, { "Content-Type": "application/json" });
395
+ res.end(JSON.stringify({ error: "Unauthorized" }));
396
+ return;
397
+ }
398
+ await handleApiSessionMessage(res, req, opts.globalRoot, decodeSessionId(msgMatch[1]));
399
+ return;
400
+ }
401
+ const mailboxMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/mailbox$/);
402
+ if (mailboxMatch && req.method === "GET") {
403
+ const headerToken = req.headers["x-ws-token"];
404
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
405
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
406
+ res.writeHead(401, { "Content-Type": "application/json" });
407
+ res.end(JSON.stringify({ error: "Unauthorized" }));
408
+ return;
409
+ }
410
+ await handleApiSessionMailbox(res, opts.globalRoot, decodeSessionId(mailboxMatch[1]));
411
+ return;
412
+ }
413
+ const interruptMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/interrupt$/);
414
+ if (interruptMatch && req.method === "POST") {
415
+ const headerToken = req.headers["x-ws-token"];
416
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
417
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
418
+ res.writeHead(401, { "Content-Type": "application/json" });
419
+ res.end(JSON.stringify({ error: "Unauthorized" }));
420
+ return;
421
+ }
422
+ await handleApiSessionInterrupt(
423
+ res,
424
+ req,
425
+ opts.globalRoot,
426
+ decodeSessionId(interruptMatch[1])
427
+ );
428
+ return;
429
+ }
430
+ if (url.pathname === "/api/fleet/broadcast" && req.method === "POST") {
431
+ const headerToken = req.headers["x-ws-token"];
432
+ const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
433
+ if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
434
+ res.writeHead(401, { "Content-Type": "application/json" });
435
+ res.end(JSON.stringify({ error: "Unauthorized" }));
436
+ return;
437
+ }
438
+ await handleApiFleetBroadcast(res, req, opts.globalRoot);
439
+ return;
440
+ }
441
+ if (url.pathname === "/debug/watcher-metrics" && req.method === "GET") {
442
+ if (opts.watcherMetrics) {
443
+ const avgDelay = opts.watcherMetrics.broadcastsSent > 0 ? opts.watcherMetrics.totalDebounceDelayMs / opts.watcherMetrics.broadcastsSent : 0;
444
+ const response = {
445
+ ...opts.watcherMetrics,
446
+ averageDebounceDelayMs: avgDelay,
447
+ timestamp: Date.now()
448
+ };
449
+ res.writeHead(200, { "Content-Type": "application/json" });
450
+ res.end(JSON.stringify(response));
451
+ } else {
452
+ res.writeHead(503, { "Content-Type": "application/json" });
453
+ res.end(JSON.stringify({ error: "File watcher metrics not available" }));
454
+ }
178
455
  return;
179
456
  }
180
457
  let filePath;
@@ -306,6 +583,324 @@ async function handleApiSessionAgents(res, globalRoot, sessionId) {
306
583
  res.end(JSON.stringify({ error: String(err) }));
307
584
  }
308
585
  }
586
+ function blocksToText(content) {
587
+ if (typeof content === "string") return content;
588
+ if (Array.isArray(content)) {
589
+ return content.filter(
590
+ (b) => !!b && typeof b === "object" && b.type === "text" && typeof b.text === "string"
591
+ ).map((b) => b.text).join("\n");
592
+ }
593
+ return "";
594
+ }
595
+ function clip(s, n = 600) {
596
+ return s.length > n ? `${s.slice(0, n)}\u2026` : s;
597
+ }
598
+ function asString(v) {
599
+ if (typeof v === "string") return v;
600
+ try {
601
+ return JSON.stringify(v);
602
+ } catch {
603
+ return String(v);
604
+ }
605
+ }
606
+ function mapWatchEntry(ev) {
607
+ const ts = typeof ev["ts"] === "string" ? ev["ts"] : "";
608
+ switch (ev["type"]) {
609
+ case "user_input":
610
+ return { ts, role: "user", text: clip(blocksToText(ev["content"])) };
611
+ case "llm_response": {
612
+ const text = blocksToText(ev["content"]);
613
+ return text.trim() ? { ts, role: "assistant", text: clip(text) } : null;
614
+ }
615
+ case "tool_use":
616
+ case "tool_call_start": {
617
+ const input = ev["input"] ?? ev["args"];
618
+ const preview = input !== void 0 && input !== null ? clip(asString(input), 160) : "";
619
+ return { ts, role: "tool", tool: String(ev["name"] ?? "tool"), text: preview };
620
+ }
621
+ case "tool_result": {
622
+ if (ev["isError"]) return { ts, role: "error", text: clip(asString(ev["content"])) };
623
+ const out = asString(ev["content"]).trim();
624
+ return out ? { ts, role: "tool", tool: "\u21B3 result", text: clip(out, 240) } : null;
625
+ }
626
+ case "error":
627
+ case "provider_error":
628
+ return { ts, role: "error", text: clip(String(ev["message"] ?? "error")) };
629
+ case "agent_spawned":
630
+ return { ts, role: "system", text: `spawned ${String(ev["role"] ?? "agent")}` };
631
+ case "task_completed":
632
+ return { ts, role: "system", text: `task done: ${String(ev["title"] ?? "")}` };
633
+ case "task_failed":
634
+ return { ts, role: "system", text: `task failed: ${String(ev["title"] ?? "")}` };
635
+ default:
636
+ return null;
637
+ }
638
+ }
639
+ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
640
+ if (!globalRoot) {
641
+ res.writeHead(500, { "Content-Type": "application/json" });
642
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
643
+ return;
644
+ }
645
+ try {
646
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
647
+ const registry = new SessionRegistry(globalRoot);
648
+ const entry = await registry.get(sessionId);
649
+ if (!entry) {
650
+ res.writeHead(404, { "Content-Type": "application/json" });
651
+ res.end(JSON.stringify({ error: "Session not found" }));
652
+ return;
653
+ }
654
+ const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
655
+ const store = new DefaultSessionStore3({ dir: paths.projectSessions });
656
+ const reader = new DefaultSessionReader2({ store });
657
+ const all = [];
658
+ for await (const ev of reader.replay(sessionId)) {
659
+ const mapped = mapWatchEntry(ev);
660
+ if (mapped) all.push(mapped);
661
+ }
662
+ const tail = all.slice(-limit);
663
+ res.writeHead(200, { "Content-Type": "application/json" });
664
+ res.end(
665
+ JSON.stringify({
666
+ sessionId,
667
+ status: entry.status,
668
+ clientType: entry.clientType,
669
+ projectName: entry.projectName,
670
+ total: all.length,
671
+ entries: tail
672
+ })
673
+ );
674
+ } catch (err) {
675
+ res.writeHead(500, { "Content-Type": "application/json" });
676
+ res.end(JSON.stringify({ error: String(err) }));
677
+ }
678
+ }
679
+ function readJsonBody(req) {
680
+ return new Promise((resolve5, reject) => {
681
+ let data = "";
682
+ req.on("data", (chunk) => {
683
+ data += chunk;
684
+ if (data.length > 64e3) {
685
+ reject(new Error("Request body too large"));
686
+ req.destroy();
687
+ }
688
+ });
689
+ req.on("end", () => {
690
+ try {
691
+ resolve5(data ? JSON.parse(data) : {});
692
+ } catch (err) {
693
+ reject(err instanceof Error ? err : new Error(String(err)));
694
+ }
695
+ });
696
+ req.on("error", reject);
697
+ });
698
+ }
699
+ async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
700
+ if (!globalRoot) {
701
+ res.writeHead(500, { "Content-Type": "application/json" });
702
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
703
+ return;
704
+ }
705
+ let body;
706
+ try {
707
+ body = await readJsonBody(req);
708
+ } catch {
709
+ res.writeHead(400, { "Content-Type": "application/json" });
710
+ res.end(JSON.stringify({ error: "Invalid request body" }));
711
+ return;
712
+ }
713
+ const text = typeof body["text"] === "string" ? body["text"].trim() : "";
714
+ if (!text) {
715
+ res.writeHead(400, { "Content-Type": "application/json" });
716
+ res.end(JSON.stringify({ error: "text is required" }));
717
+ return;
718
+ }
719
+ const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
720
+ const ALLOWED = /* @__PURE__ */ new Set(["steer", "ask", "assign", "note", "btw"]);
721
+ const rawType = typeof body["type"] === "string" ? body["type"] : "steer";
722
+ const type = ALLOWED.has(rawType) ? rawType : "steer";
723
+ const rawPriority = typeof body["priority"] === "string" ? body["priority"] : "";
724
+ const priority = ["low", "normal", "high"].includes(rawPriority) ? rawPriority : "high";
725
+ const subject = typeof body["subject"] === "string" && body["subject"].trim() ? body["subject"].trim() : "Message from Fleet HQ";
726
+ try {
727
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
728
+ const registry = new SessionRegistry(globalRoot);
729
+ const entry = await registry.get(sessionId);
730
+ if (!entry) {
731
+ res.writeHead(404, { "Content-Type": "application/json" });
732
+ res.end(JSON.stringify({ error: "Session not found" }));
733
+ return;
734
+ }
735
+ const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
736
+ const mailbox = new GlobalMailbox3(paths.projectDir);
737
+ const to = `leader@${mailboxSessionTag2(sessionId)}`;
738
+ const sent = await mailbox.send({ from, to, type, subject, body: text, priority });
739
+ res.writeHead(200, { "Content-Type": "application/json" });
740
+ res.end(JSON.stringify({ ok: true, id: sent.id, to, type, delivered: entry.status }));
741
+ } catch (err) {
742
+ res.writeHead(500, { "Content-Type": "application/json" });
743
+ res.end(JSON.stringify({ error: String(err) }));
744
+ }
745
+ }
746
+ async function handleApiSessionMailbox(res, globalRoot, sessionId) {
747
+ if (!globalRoot) {
748
+ res.writeHead(500, { "Content-Type": "application/json" });
749
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
750
+ return;
751
+ }
752
+ try {
753
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
754
+ const registry = new SessionRegistry(globalRoot);
755
+ const entry = await registry.get(sessionId);
756
+ if (!entry) {
757
+ res.writeHead(404, { "Content-Type": "application/json" });
758
+ res.end(JSON.stringify({ error: "Session not found" }));
759
+ return;
760
+ }
761
+ const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
762
+ const mailbox = new GlobalMailbox3(paths.projectDir);
763
+ const leaderAddr = `leader@${mailboxSessionTag2(sessionId)}`;
764
+ const [inbound, outbound] = await Promise.all([
765
+ mailbox.query({ to: leaderAddr, limit: 50 }),
766
+ mailbox.query({ from: leaderAddr, limit: 50 })
767
+ ]);
768
+ const seen = /* @__PURE__ */ new Set();
769
+ const thread = [...inbound, ...outbound].filter((m) => {
770
+ if (seen.has(m.id)) return false;
771
+ seen.add(m.id);
772
+ return true;
773
+ }).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)).map((m) => ({
774
+ id: m.id,
775
+ from: m.from,
776
+ to: m.to,
777
+ type: m.type,
778
+ subject: m.subject,
779
+ body: m.body,
780
+ priority: m.priority,
781
+ // Whether the leader has read it, and when.
782
+ readByLeader: m.readBy?.[leaderAddr] ?? null,
783
+ readByCount: Object.keys(m.readBy ?? {}).length,
784
+ completed: m.completed,
785
+ outcome: m.outcome ?? null,
786
+ timestamp: m.timestamp,
787
+ replyTo: m.replyTo ?? null,
788
+ fromLeader: m.from === leaderAddr
789
+ }));
790
+ res.writeHead(200, { "Content-Type": "application/json" });
791
+ res.end(JSON.stringify({ sessionId, leader: leaderAddr, status: entry.status, thread }));
792
+ } catch (err) {
793
+ res.writeHead(500, { "Content-Type": "application/json" });
794
+ res.end(JSON.stringify({ error: String(err) }));
795
+ }
796
+ }
797
+ async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
798
+ if (!globalRoot) {
799
+ res.writeHead(500, { "Content-Type": "application/json" });
800
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
801
+ return;
802
+ }
803
+ let body = {};
804
+ try {
805
+ body = await readJsonBody(req);
806
+ } catch {
807
+ }
808
+ const reason = typeof body["reason"] === "string" && body["reason"].trim() ? body["reason"].trim() : "Operator requested stop from Fleet HQ";
809
+ const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
810
+ try {
811
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
812
+ const registry = new SessionRegistry(globalRoot);
813
+ const entry = await registry.get(sessionId);
814
+ if (!entry) {
815
+ res.writeHead(404, { "Content-Type": "application/json" });
816
+ res.end(JSON.stringify({ error: "Session not found" }));
817
+ return;
818
+ }
819
+ const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
820
+ const mailbox = new GlobalMailbox3(paths.projectDir);
821
+ const to = `leader@${mailboxSessionTag2(sessionId)}`;
822
+ const sent = await mailbox.send({
823
+ from,
824
+ to,
825
+ type: "control",
826
+ subject: "interrupt",
827
+ body: reason,
828
+ priority: "high"
829
+ });
830
+ res.writeHead(200, { "Content-Type": "application/json" });
831
+ res.end(JSON.stringify({ ok: true, id: sent.id, to, delivered: entry.status }));
832
+ } catch (err) {
833
+ res.writeHead(500, { "Content-Type": "application/json" });
834
+ res.end(JSON.stringify({ error: String(err) }));
835
+ }
836
+ }
837
+ async function handleApiFleetBroadcast(res, req, globalRoot) {
838
+ if (!globalRoot) {
839
+ res.writeHead(500, { "Content-Type": "application/json" });
840
+ res.end(JSON.stringify({ error: "SessionRegistry not available" }));
841
+ return;
842
+ }
843
+ let body;
844
+ try {
845
+ body = await readJsonBody(req);
846
+ } catch {
847
+ res.writeHead(400, { "Content-Type": "application/json" });
848
+ res.end(JSON.stringify({ error: "Invalid request body" }));
849
+ return;
850
+ }
851
+ const text = typeof body["text"] === "string" ? body["text"].trim() : "";
852
+ if (!text) {
853
+ res.writeHead(400, { "Content-Type": "application/json" });
854
+ res.end(JSON.stringify({ error: "text is required" }));
855
+ return;
856
+ }
857
+ const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
858
+ try {
859
+ const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
860
+ const registry = new SessionRegistry(globalRoot);
861
+ const all = await registry.list();
862
+ const mySlug = all.find((s) => s.pid === process.pid)?.projectSlug;
863
+ const targets = all.filter((s) => s.status !== "stale").filter((s) => mySlug ? s.projectSlug === mySlug : true);
864
+ if (targets.length === 0) {
865
+ res.writeHead(200, { "Content-Type": "application/json" });
866
+ res.end(JSON.stringify({ ok: true, delivered: 0 }));
867
+ return;
868
+ }
869
+ const mbByDir = /* @__PURE__ */ new Map();
870
+ const mailboxFor = (projectRoot) => {
871
+ const dir = resolveWstackPaths2({ projectRoot, globalRoot }).projectDir;
872
+ let mb = mbByDir.get(dir);
873
+ if (!mb) {
874
+ mb = new GlobalMailbox3(dir);
875
+ mbByDir.set(dir, mb);
876
+ }
877
+ return mb;
878
+ };
879
+ let delivered = 0;
880
+ await Promise.all(
881
+ targets.map(async (s) => {
882
+ try {
883
+ const mb = mailboxFor(s.projectRoot);
884
+ await mb.send({
885
+ from,
886
+ to: `leader@${mailboxSessionTag2(s.sessionId)}`,
887
+ type: "steer",
888
+ subject: "Broadcast from Fleet HQ",
889
+ body: text,
890
+ priority: "high"
891
+ });
892
+ delivered++;
893
+ } catch {
894
+ }
895
+ })
896
+ );
897
+ res.writeHead(200, { "Content-Type": "application/json" });
898
+ res.end(JSON.stringify({ ok: true, delivered, targets: targets.length }));
899
+ } catch (err) {
900
+ res.writeHead(500, { "Content-Type": "application/json" });
901
+ res.end(JSON.stringify({ error: String(err) }));
902
+ }
903
+ }
309
904
 
310
905
  // src/server/file-handlers.ts
311
906
  import * as fs2 from "fs/promises";
@@ -335,8 +930,8 @@ var KEEP_DOTFILES = /* @__PURE__ */ new Set([
335
930
  ".eslintrc",
336
931
  ".prettierrc"
337
932
  ]);
338
- function isHiddenEntry(name) {
339
- return name.startsWith(".") && !KEEP_DOTFILES.has(name);
933
+ function isHiddenEntry(name2) {
934
+ return name2.startsWith(".") && !KEEP_DOTFILES.has(name2);
340
935
  }
341
936
  function rankFiles(paths, query, limit) {
342
937
  const q = query.toLowerCase();
@@ -379,7 +974,7 @@ function broadcast(clients, msg) {
379
974
  }
380
975
  }
381
976
  }
382
- function sendResult(ws, success, message) {
977
+ function sendResult2(ws, success, message) {
383
978
  send(ws, { type: "key.operation_result", payload: { success, message } });
384
979
  }
385
980
  function errMessage(err) {
@@ -528,23 +1123,238 @@ async function handleMemoryRemember(ws, msg, memoryStore) {
528
1123
  const { text, scope } = msg.payload;
529
1124
  try {
530
1125
  await memoryStore.remember(text, scope ?? "project-memory");
531
- sendResult(ws, true, "Saved to memory");
1126
+ sendResult2(ws, true, "Saved to memory");
532
1127
  } catch (err) {
533
- sendResult(ws, false, errMessage(err));
1128
+ sendResult2(ws, false, errMessage(err));
534
1129
  }
535
1130
  }
536
1131
  async function handleMemoryForget(ws, msg, memoryStore) {
537
1132
  const { text, scope } = msg.payload;
538
1133
  try {
539
1134
  const removed = await memoryStore.forget(text, scope ?? "project-memory");
540
- sendResult(
1135
+ sendResult2(
541
1136
  ws,
542
1137
  removed > 0,
543
1138
  removed > 0 ? `Removed ${removed} entr${removed === 1 ? "y" : "ies"}` : "No matching entries"
544
1139
  );
545
1140
  } catch (err) {
546
- sendResult(ws, false, errMessage(err));
1141
+ sendResult2(ws, false, errMessage(err));
1142
+ }
1143
+ }
1144
+
1145
+ // src/server/mcp-handlers.ts
1146
+ import { allServers } from "@wrongstack/core";
1147
+ import {
1148
+ addMcp,
1149
+ disableMcp,
1150
+ discoverMcp,
1151
+ enableMcp,
1152
+ listMcp,
1153
+ removeMcp,
1154
+ restartMcp,
1155
+ updateMcp
1156
+ } from "@wrongstack/mcp";
1157
+ function mapStatus(raw) {
1158
+ switch (raw) {
1159
+ case "connected":
1160
+ return "connected";
1161
+ case "connecting":
1162
+ case "reconnecting":
1163
+ return "connecting";
1164
+ case "failed":
1165
+ return "error";
1166
+ case "dormant":
1167
+ return "sleeping";
1168
+ default:
1169
+ return "stopped";
1170
+ }
1171
+ }
1172
+ function toView(info) {
1173
+ const view = {
1174
+ name: info.name,
1175
+ transport: info.transport,
1176
+ // A dormant lazy server is "asleep", not stopped — preserve that even when
1177
+ // it's enabled in config.
1178
+ status: info.status === "dormant" ? "sleeping" : info.enabled === false ? "stopped" : mapStatus(info.status),
1179
+ enabled: info.enabled,
1180
+ tools: info.tools
1181
+ };
1182
+ if (info.description !== void 0) view.description = info.description;
1183
+ if (info.lazy !== void 0) view.lazy = info.lazy;
1184
+ return view;
1185
+ }
1186
+ function deps(ws, globalConfigPath, registry) {
1187
+ if (!registry || !globalConfigPath) {
1188
+ send(ws, {
1189
+ type: "mcp.operation_result",
1190
+ payload: { success: false, message: "MCP registry is not available in this session." }
1191
+ });
1192
+ return null;
1193
+ }
1194
+ return { configPath: globalConfigPath, registry, presets: allServers() };
1195
+ }
1196
+ function name(msg) {
1197
+ return msg.payload?.name ?? "";
1198
+ }
1199
+ async function handleMcpList(ws, _msg, globalConfigPath, mcpRegistry) {
1200
+ if (!mcpRegistry || !globalConfigPath) {
1201
+ send(ws, { type: "mcp.list", payload: { servers: [] } });
1202
+ return;
1203
+ }
1204
+ const servers = await listMcp({
1205
+ configPath: globalConfigPath,
1206
+ registry: mcpRegistry,
1207
+ presets: allServers()
1208
+ });
1209
+ send(ws, { type: "mcp.list", payload: { servers: servers.map(toView) } });
1210
+ }
1211
+ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
1212
+ const d = deps(ws, globalConfigPath, mcpRegistry);
1213
+ if (!d) return;
1214
+ const result = await addMcp(msg.payload, d);
1215
+ if (result.ok && result.server) {
1216
+ send(ws, { type: "mcp.server.added", payload: { server: toView(result.server) } });
1217
+ if (result.registryError) {
1218
+ send(ws, {
1219
+ type: "mcp.server.error",
1220
+ payload: { name: result.server.name, error: result.registryError }
1221
+ });
1222
+ } else if (result.server.enabled) {
1223
+ send(ws, { type: "mcp.server.connected", payload: { name: result.server.name } });
1224
+ }
1225
+ }
1226
+ send(ws, {
1227
+ type: "mcp.operation_result",
1228
+ payload: { success: result.ok, message: result.message }
1229
+ });
1230
+ }
1231
+ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
1232
+ const d = deps(ws, globalConfigPath, mcpRegistry);
1233
+ if (!d) return;
1234
+ const result = await updateMcp(msg.payload, d);
1235
+ if (result.ok && result.server) {
1236
+ send(ws, { type: "mcp.server.updated", payload: { server: toView(result.server) } });
1237
+ }
1238
+ send(ws, {
1239
+ type: "mcp.operation_result",
1240
+ payload: { success: result.ok, message: result.message }
1241
+ });
1242
+ }
1243
+ async function handleMcpRemove(ws, msg, globalConfigPath, mcpRegistry) {
1244
+ const d = deps(ws, globalConfigPath, mcpRegistry);
1245
+ if (!d) return;
1246
+ const result = await removeMcp(name(msg), d);
1247
+ if (result.ok) {
1248
+ send(ws, { type: "mcp.server.removed", payload: { name: name(msg) } });
1249
+ }
1250
+ send(ws, {
1251
+ type: "mcp.operation_result",
1252
+ payload: { success: result.ok, message: result.message }
1253
+ });
1254
+ }
1255
+ async function handleMcpEnable(ws, msg, globalConfigPath, mcpRegistry) {
1256
+ const d = deps(ws, globalConfigPath, mcpRegistry);
1257
+ if (!d) return;
1258
+ const result = await enableMcp(name(msg), d);
1259
+ if (result.ok && result.server) {
1260
+ send(ws, { type: "mcp.server.updated", payload: { server: toView(result.server) } });
1261
+ if (result.registryError) {
1262
+ send(ws, {
1263
+ type: "mcp.server.error",
1264
+ payload: { name: name(msg), error: result.registryError }
1265
+ });
1266
+ } else {
1267
+ send(ws, { type: "mcp.server.connected", payload: { name: name(msg) } });
1268
+ }
1269
+ }
1270
+ send(ws, {
1271
+ type: "mcp.operation_result",
1272
+ payload: { success: result.ok, message: result.message }
1273
+ });
1274
+ }
1275
+ async function handleMcpDisable(ws, msg, globalConfigPath, mcpRegistry) {
1276
+ const d = deps(ws, globalConfigPath, mcpRegistry);
1277
+ if (!d) return;
1278
+ const result = await disableMcp(name(msg), d);
1279
+ if (result.ok) {
1280
+ send(ws, { type: "mcp.server.sleeping", payload: { name: name(msg) } });
1281
+ if (result.server) {
1282
+ send(ws, { type: "mcp.server.updated", payload: { server: toView(result.server) } });
1283
+ }
1284
+ }
1285
+ send(ws, {
1286
+ type: "mcp.operation_result",
1287
+ payload: { success: result.ok, message: result.message }
1288
+ });
1289
+ }
1290
+ async function handleMcpSleep(ws, msg, globalConfigPath, mcpRegistry) {
1291
+ const d = deps(ws, globalConfigPath, mcpRegistry);
1292
+ if (!d) return;
1293
+ try {
1294
+ await d.registry.stop(name(msg));
1295
+ send(ws, { type: "mcp.server.sleeping", payload: { name: name(msg) } });
1296
+ send(ws, {
1297
+ type: "mcp.operation_result",
1298
+ payload: { success: true, message: `Server "${name(msg)}" stopped` }
1299
+ });
1300
+ } catch (err) {
1301
+ const error = err instanceof Error ? err.message : String(err);
1302
+ send(ws, { type: "mcp.server.error", payload: { name: name(msg), error } });
1303
+ send(ws, {
1304
+ type: "mcp.operation_result",
1305
+ payload: { success: false, message: `Failed to stop "${name(msg)}": ${error}` }
1306
+ });
1307
+ }
1308
+ }
1309
+ async function handleMcpWake(ws, msg, globalConfigPath, mcpRegistry) {
1310
+ const d = deps(ws, globalConfigPath, mcpRegistry);
1311
+ if (!d) return;
1312
+ send(ws, { type: "mcp.server.waking", payload: { name: name(msg) } });
1313
+ const result = await restartMcp(name(msg), d);
1314
+ if (result.ok && !result.registryError) {
1315
+ send(ws, { type: "mcp.server.connected", payload: { name: name(msg) } });
1316
+ } else if (result.registryError) {
1317
+ send(ws, {
1318
+ type: "mcp.server.error",
1319
+ payload: { name: name(msg), error: result.registryError }
1320
+ });
1321
+ }
1322
+ send(ws, {
1323
+ type: "mcp.operation_result",
1324
+ payload: { success: result.ok, message: result.message }
1325
+ });
1326
+ }
1327
+ async function handleMcpRestart(ws, msg, globalConfigPath, mcpRegistry) {
1328
+ const d = deps(ws, globalConfigPath, mcpRegistry);
1329
+ if (!d) return;
1330
+ const result = await restartMcp(name(msg), d);
1331
+ if (result.ok && !result.registryError) {
1332
+ send(ws, { type: "mcp.server.connected", payload: { name: name(msg) } });
1333
+ } else if (result.registryError) {
1334
+ send(ws, {
1335
+ type: "mcp.server.error",
1336
+ payload: { name: name(msg), error: result.registryError }
1337
+ });
1338
+ }
1339
+ send(ws, {
1340
+ type: "mcp.operation_result",
1341
+ payload: { success: result.ok, message: result.message }
1342
+ });
1343
+ }
1344
+ async function handleMcpDiscover(ws, msg, globalConfigPath, mcpRegistry) {
1345
+ const d = deps(ws, globalConfigPath, mcpRegistry);
1346
+ if (!d) return;
1347
+ const result = await discoverMcp(name(msg), d);
1348
+ if (result.ok) {
1349
+ send(ws, {
1350
+ type: "mcp.server.discovered",
1351
+ payload: { name: name(msg), tools: result.tools ?? [] }
1352
+ });
547
1353
  }
1354
+ send(ws, {
1355
+ type: "mcp.operation_result",
1356
+ payload: { success: result.ok, message: result.message }
1357
+ });
548
1358
  }
549
1359
 
550
1360
  // src/server/index.ts
@@ -580,12 +1390,14 @@ import {
580
1390
  repairToolUseAdjacency,
581
1391
  resolveContextWindowPolicy,
582
1392
  enhanceUserPrompt,
583
- recentTextTurns
1393
+ recentTextTurns,
1394
+ resolveProviderModelList
584
1395
  } from "@wrongstack/core";
585
1396
  import { ToolExecutor } from "@wrongstack/core/execution";
586
1397
  import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets as encryptConfigSecrets2 } from "@wrongstack/core/security";
587
1398
  import { buildProviderFactoriesFromRegistry, makeProviderFromConfig } from "@wrongstack/providers";
588
1399
  import { builtinToolsPack, forgetTool, rememberTool, searchMemoryTool, relatedMemoryTool } from "@wrongstack/tools";
1400
+ import { MCPRegistry } from "@wrongstack/mcp";
589
1401
  import { WebSocketServer } from "ws";
590
1402
 
591
1403
  // ../runtime/src/container.ts
@@ -1832,9 +2644,9 @@ var WorktreeWebSocketHandler = class {
1832
2644
 
1833
2645
  // src/server/mailbox-handlers.ts
1834
2646
  import { GlobalMailbox, resolveProjectDir } from "@wrongstack/core";
1835
- async function handleMailboxMessages(ws, deps, payload) {
2647
+ async function handleMailboxMessages(ws, deps2, payload) {
1836
2648
  try {
1837
- const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
2649
+ const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
1838
2650
  const mb = new GlobalMailbox(dir);
1839
2651
  const messages = await mb.query({
1840
2652
  limit: payload?.limit ?? 30,
@@ -1868,9 +2680,9 @@ async function handleMailboxMessages(ws, deps, payload) {
1868
2680
  send(ws, { type: "mailbox.messages", payload: { messages: [], error: errMessage(err) } });
1869
2681
  }
1870
2682
  }
1871
- async function handleMailboxAgents(ws, deps, payload) {
2683
+ async function handleMailboxAgents(ws, deps2, payload) {
1872
2684
  try {
1873
- const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
2685
+ const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
1874
2686
  const mb = new GlobalMailbox(dir);
1875
2687
  const agents = payload?.onlineOnly ? await mb.getOnlineAgents() : await mb.getAgentStatuses();
1876
2688
  send(ws, {
@@ -1897,9 +2709,9 @@ async function handleMailboxAgents(ws, deps, payload) {
1897
2709
  send(ws, { type: "mailbox.agents", payload: { agents: [], error: errMessage(err) } });
1898
2710
  }
1899
2711
  }
1900
- async function handleMailboxClear(ws, deps) {
2712
+ async function handleMailboxClear(ws, deps2) {
1901
2713
  try {
1902
- const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
2714
+ const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
1903
2715
  const mb = new GlobalMailbox(dir);
1904
2716
  await mb.clearAll();
1905
2717
  send(ws, { type: "mailbox.cleared", payload: {} });
@@ -1907,9 +2719,9 @@ async function handleMailboxClear(ws, deps) {
1907
2719
  send(ws, { type: "mailbox.cleared", payload: { error: errMessage(err) } });
1908
2720
  }
1909
2721
  }
1910
- async function handleMailboxPurge(ws, deps, opts) {
2722
+ async function handleMailboxPurge(ws, deps2, opts) {
1911
2723
  try {
1912
- const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
2724
+ const dir = resolveProjectDir(deps2.projectRoot, deps2.globalRoot);
1913
2725
  const mb = new GlobalMailbox(dir);
1914
2726
  const result = await mb.purgeStale(opts);
1915
2727
  send(ws, { type: "mailbox.purged", payload: result });
@@ -2208,7 +3020,7 @@ function writeKeysBack(cfg, keys) {
2208
3020
  }
2209
3021
  cfg.apiKeys = keys;
2210
3022
  const active = keys.find((k) => k.label === cfg.activeKey) ?? expectDefined(keys[0]);
2211
- cfg.apiKey = active.apiKey;
3023
+ delete cfg.apiKey;
2212
3024
  if (!cfg.activeKey || !keys.some((k) => k.label === cfg.activeKey)) {
2213
3025
  cfg.activeKey = active.label;
2214
3026
  }
@@ -2307,9 +3119,9 @@ function projectSavedProviders(providers) {
2307
3119
  });
2308
3120
  }
2309
3121
  var probeScrubber = new DefaultSecretScrubber2();
2310
- function createProviderHandlers(deps) {
2311
- const { globalConfigPath, vault, broadcast: broadcast2, clients } = deps;
2312
- let configWriteLock = deps.getConfigWriteLock();
3122
+ function createProviderHandlers(deps2) {
3123
+ const { globalConfigPath, vault, broadcast: broadcast2, clients } = deps2;
3124
+ let configWriteLock = deps2.getConfigWriteLock();
2313
3125
  async function loadConfigProviders() {
2314
3126
  return loadSavedProviders(globalConfigPath, vault);
2315
3127
  }
@@ -2324,7 +3136,7 @@ function createProviderHandlers(deps) {
2324
3136
  }));
2325
3137
  });
2326
3138
  configWriteLock = next;
2327
- deps.setConfigWriteLock(next);
3139
+ deps2.setConfigWriteLock(next);
2328
3140
  await next;
2329
3141
  }
2330
3142
  async function handleKeyUpsert(ws, providerId, label, apiKey) {
@@ -2332,9 +3144,9 @@ function createProviderHandlers(deps) {
2332
3144
  const providers = await loadConfigProviders();
2333
3145
  const result = upsertKey(providers, providerId, label, apiKey, (/* @__PURE__ */ new Date()).toISOString());
2334
3146
  if (result.ok) await saveConfigProviders(providers);
2335
- sendResult(ws, result.ok, result.message);
3147
+ sendResult2(ws, result.ok, result.message);
2336
3148
  } catch (err) {
2337
- sendResult(ws, false, errMessage(err));
3149
+ sendResult2(ws, false, errMessage(err));
2338
3150
  }
2339
3151
  }
2340
3152
  async function handleKeyDelete(ws, providerId, label) {
@@ -2342,9 +3154,9 @@ function createProviderHandlers(deps) {
2342
3154
  const providers = await loadConfigProviders();
2343
3155
  const result = deleteKey(providers, providerId, label);
2344
3156
  if (result.ok) await saveConfigProviders(providers);
2345
- sendResult(ws, result.ok, result.message);
3157
+ sendResult2(ws, result.ok, result.message);
2346
3158
  } catch (err) {
2347
- sendResult(ws, false, errMessage(err));
3159
+ sendResult2(ws, false, errMessage(err));
2348
3160
  }
2349
3161
  }
2350
3162
  async function handleKeySetActive(ws, providerId, label) {
@@ -2352,9 +3164,9 @@ function createProviderHandlers(deps) {
2352
3164
  const providers = await loadConfigProviders();
2353
3165
  const result = setActiveKey(providers, providerId, label);
2354
3166
  if (result.ok) await saveConfigProviders(providers);
2355
- sendResult(ws, result.ok, result.message);
3167
+ sendResult2(ws, result.ok, result.message);
2356
3168
  } catch (err) {
2357
- sendResult(ws, false, errMessage(err));
3169
+ sendResult2(ws, false, errMessage(err));
2358
3170
  }
2359
3171
  }
2360
3172
  async function handleProviderAdd(ws, payload) {
@@ -2362,13 +3174,13 @@ function createProviderHandlers(deps) {
2362
3174
  const providers = await loadConfigProviders();
2363
3175
  const result = addProvider(providers, payload, (/* @__PURE__ */ new Date()).toISOString());
2364
3176
  if (result.ok) await saveConfigProviders(providers);
2365
- sendResult(ws, result.ok, result.message);
3177
+ sendResult2(ws, result.ok, result.message);
2366
3178
  if (result.ok) {
2367
3179
  console.log(`[WebUI] Provider "${payload.id}" added via provider.add`);
2368
3180
  broadcastSaved(providers);
2369
3181
  }
2370
3182
  } catch (err) {
2371
- sendResult(ws, false, errMessage(err));
3183
+ sendResult2(ws, false, errMessage(err));
2372
3184
  }
2373
3185
  }
2374
3186
  async function handleProviderRemove(ws, providerId) {
@@ -2376,9 +3188,9 @@ function createProviderHandlers(deps) {
2376
3188
  const providers = await loadConfigProviders();
2377
3189
  const result = removeProvider(providers, providerId);
2378
3190
  if (result.ok) await saveConfigProviders(providers);
2379
- sendResult(ws, result.ok, result.message);
3191
+ sendResult2(ws, result.ok, result.message);
2380
3192
  } catch (err) {
2381
- sendResult(ws, false, errMessage(err));
3193
+ sendResult2(ws, false, errMessage(err));
2382
3194
  }
2383
3195
  }
2384
3196
  function broadcastSaved(providers) {
@@ -2392,15 +3204,15 @@ function createProviderHandlers(deps) {
2392
3204
  const providers = await loadConfigProviders();
2393
3205
  const cfg = providers[providerId];
2394
3206
  if (!cfg) {
2395
- sendResult(ws, false, `Unknown provider "${providerId}"`);
3207
+ sendResult2(ws, false, `Unknown provider "${providerId}"`);
2396
3208
  return;
2397
3209
  }
2398
3210
  delete cfg.models;
2399
3211
  await saveConfigProviders(providers);
2400
- sendResult(ws, true, `Cleared model allowlist for ${providerId}`);
3212
+ sendResult2(ws, true, `Cleared model allowlist for ${providerId}`);
2401
3213
  broadcastSaved(providers);
2402
3214
  } catch (err) {
2403
- sendResult(ws, false, errMessage(err));
3215
+ sendResult2(ws, false, errMessage(err));
2404
3216
  }
2405
3217
  }
2406
3218
  async function handleProviderUndoClear(ws, providerId, previousModels) {
@@ -2408,15 +3220,15 @@ function createProviderHandlers(deps) {
2408
3220
  const providers = await loadConfigProviders();
2409
3221
  const cfg = providers[providerId];
2410
3222
  if (!cfg) {
2411
- sendResult(ws, false, `Unknown provider "${providerId}"`);
3223
+ sendResult2(ws, false, `Unknown provider "${providerId}"`);
2412
3224
  return;
2413
3225
  }
2414
3226
  cfg.models = [...previousModels];
2415
3227
  await saveConfigProviders(providers);
2416
- sendResult(ws, true, `Restored ${previousModels.length} model(s) for ${providerId}`);
3228
+ sendResult2(ws, true, `Restored ${previousModels.length} model(s) for ${providerId}`);
2417
3229
  broadcastSaved(providers);
2418
3230
  } catch (err) {
2419
- sendResult(ws, false, errMessage(err));
3231
+ sendResult2(ws, false, errMessage(err));
2420
3232
  }
2421
3233
  }
2422
3234
  async function handleProviderUpdate(ws, payload) {
@@ -2424,7 +3236,7 @@ function createProviderHandlers(deps) {
2424
3236
  const providers = await loadConfigProviders();
2425
3237
  const cfg = providers[payload.id];
2426
3238
  if (!cfg) {
2427
- sendResult(ws, false, `Unknown provider "${payload.id}"`);
3239
+ sendResult2(ws, false, `Unknown provider "${payload.id}"`);
2428
3240
  return;
2429
3241
  }
2430
3242
  if (payload.family !== void 0) cfg.family = payload.family;
@@ -2432,10 +3244,10 @@ function createProviderHandlers(deps) {
2432
3244
  if (payload.envVars !== void 0) cfg.envVars = payload.envVars;
2433
3245
  if (payload.models !== void 0) cfg.models = payload.models;
2434
3246
  await saveConfigProviders(providers);
2435
- sendResult(ws, true, `Updated ${payload.id}`);
3247
+ sendResult2(ws, true, `Updated ${payload.id}`);
2436
3248
  broadcastSaved(providers);
2437
3249
  } catch (err) {
2438
- sendResult(ws, false, errMessage(err));
3250
+ sendResult2(ws, false, errMessage(err));
2439
3251
  }
2440
3252
  }
2441
3253
  async function handleProviderProbe(ws, providerId, timeoutMs) {
@@ -2480,9 +3292,12 @@ function createProviderHandlers(deps) {
2480
3292
  }
2481
3293
 
2482
3294
  // src/server/setup-events.ts
3295
+ import * as fs5 from "fs/promises";
3296
+ import { watch as fsWatch } from "fs";
2483
3297
  import * as path5 from "path";
2484
- function setupEvents(deps) {
2485
- const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge } = deps;
3298
+ function setupEvents(deps2) {
3299
+ const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge, wpaths, watcherMetrics, onFleetBroadcaster } = deps2;
3300
+ const disposers = [];
2486
3301
  events.on("iteration.started", (e) => {
2487
3302
  const maxIt = typeof context.meta["maxIterations"] === "number" ? context.meta["maxIterations"] : config.tools?.maxIterations ?? 100;
2488
3303
  broadcast2(clients, {
@@ -2513,7 +3328,11 @@ function setupEvents(deps) {
2513
3328
  events.on("tool.progress", (e) => {
2514
3329
  broadcast2(clients, {
2515
3330
  type: "tool.progress",
2516
- payload: { id: e.id, name: e.name, eventType: e.event.type, text: e.event.text }
3331
+ // Nested `event` shape the client handler reads `payload.event?.text`
3332
+ // and early-returns on a falsy text, so a flat { eventType, text } payload
3333
+ // makes live tool progress (bash streaming, partial_output, warnings)
3334
+ // never render. Must match WSToolProgress and the CLI server.
3335
+ payload: { id: e.id, name: e.name, event: { type: e.event.type, text: e.event.text, data: e.event.data } }
2517
3336
  });
2518
3337
  sessionBridge?.append({
2519
3338
  type: "tool_progress",
@@ -2679,20 +3498,165 @@ function setupEvents(deps) {
2679
3498
  events.onPattern("brain.*", (eventName, payload) => {
2680
3499
  broadcast2(clients, { type: "brain.event", payload: { event: eventName, ...payload } });
2681
3500
  });
3501
+ events.on("client.status", async (e) => {
3502
+ broadcast2(clients, { type: "client.status_update", payload: e });
3503
+ if (wpaths?.projectStatus) {
3504
+ try {
3505
+ const statusFile = wpaths.projectStatus(e.projectHash);
3506
+ const dir = path5.dirname(statusFile);
3507
+ await fs5.mkdir(dir, { recursive: true });
3508
+ await fs5.writeFile(statusFile, JSON.stringify(e, null, 2), "utf-8");
3509
+ } catch (err) {
3510
+ console.error("[setup-events] Failed to write status.json:", err);
3511
+ }
3512
+ }
3513
+ });
3514
+ if (wpaths?.projectStatus && wpaths.configDir) {
3515
+ const projectsDir = path5.join(wpaths.configDir, "projects");
3516
+ const knownProjectHashes = /* @__PURE__ */ new Set();
3517
+ const debounceTimers = /* @__PURE__ */ new Map();
3518
+ const DEBOUNCE_MS = 150;
3519
+ const pendingStatuses = /* @__PURE__ */ new Map();
3520
+ if (watcherMetrics) {
3521
+ watcherMetrics.fileChangesDetected = 0;
3522
+ watcherMetrics.filesProcessed = 0;
3523
+ watcherMetrics.broadcastsSent = 0;
3524
+ watcherMetrics.debounceResets = 0;
3525
+ watcherMetrics.totalDebounceDelayMs = 0;
3526
+ watcherMetrics.activeProjects = 0;
3527
+ watcherMetrics.averageDebounceDelayMs = 0;
3528
+ watcherMetrics.watcherActive = true;
3529
+ }
3530
+ const getAverageDebounceDelay = () => {
3531
+ if (!watcherMetrics || watcherMetrics.broadcastsSent === 0) return 0;
3532
+ return watcherMetrics.totalDebounceDelayMs / watcherMetrics.broadcastsSent;
3533
+ };
3534
+ const logWatcherMetrics = () => {
3535
+ if (!watcherMetrics) return;
3536
+ watcherMetrics.averageDebounceDelayMs = getAverageDebounceDelay();
3537
+ console.log(
3538
+ `[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`
3539
+ );
3540
+ };
3541
+ const metricsInterval = setInterval(logWatcherMetrics, 6e4);
3542
+ const broadcastStatus = (projectHash2, statusData, actualDelayMs) => {
3543
+ broadcast2(clients, { type: "client.status_update", payload: statusData });
3544
+ if (watcherMetrics) {
3545
+ watcherMetrics.broadcastsSent++;
3546
+ watcherMetrics.totalDebounceDelayMs += actualDelayMs;
3547
+ watcherMetrics.averageDebounceDelayMs = getAverageDebounceDelay();
3548
+ }
3549
+ };
3550
+ const scheduleBroadcast = (projectHash2, statusData) => {
3551
+ const now = Date.now();
3552
+ const existing = pendingStatuses.get(projectHash2);
3553
+ if (existing && watcherMetrics) {
3554
+ watcherMetrics.debounceResets++;
3555
+ }
3556
+ pendingStatuses.set(projectHash2, {
3557
+ data: statusData,
3558
+ firstWriteAt: existing ? existing.firstWriteAt : now
3559
+ });
3560
+ const existingTimer = debounceTimers.get(projectHash2);
3561
+ if (existingTimer) {
3562
+ clearTimeout(existingTimer);
3563
+ }
3564
+ const timer = setTimeout(() => {
3565
+ debounceTimers.delete(projectHash2);
3566
+ const pending = pendingStatuses.get(projectHash2);
3567
+ if (pending) {
3568
+ const actualDelay = Date.now() - pending.firstWriteAt;
3569
+ broadcastStatus(projectHash2, pending.data, actualDelay);
3570
+ pendingStatuses.delete(projectHash2);
3571
+ }
3572
+ }, DEBOUNCE_MS);
3573
+ debounceTimers.set(projectHash2, timer);
3574
+ };
3575
+ let watcher;
3576
+ const startWatcher = async () => {
3577
+ try {
3578
+ await fs5.mkdir(projectsDir, { recursive: true });
3579
+ watcher = fsWatch(projectsDir, { persistent: true, recursive: true }, async (eventType, filename) => {
3580
+ if (eventType === "change") {
3581
+ if (filename == null) return;
3582
+ if (watcherMetrics) watcherMetrics.fileChangesDetected++;
3583
+ const targetFile = path5.join(projectsDir, String(filename));
3584
+ if (targetFile.endsWith("status.json")) {
3585
+ const projectHash2 = path5.basename(path5.dirname(targetFile));
3586
+ if (knownProjectHashes.size > 0 && !knownProjectHashes.has(projectHash2)) {
3587
+ return;
3588
+ }
3589
+ if (watcherMetrics) watcherMetrics.filesProcessed++;
3590
+ try {
3591
+ const content = await fs5.readFile(targetFile, "utf-8");
3592
+ const statusData = JSON.parse(content);
3593
+ if (statusData.projectHash) {
3594
+ const hash = String(statusData.projectHash);
3595
+ if (!knownProjectHashes.has(hash)) {
3596
+ knownProjectHashes.add(hash);
3597
+ if (watcherMetrics) watcherMetrics.activeProjects = knownProjectHashes.size;
3598
+ }
3599
+ }
3600
+ scheduleBroadcast(projectHash2, statusData);
3601
+ } catch {
3602
+ }
3603
+ }
3604
+ }
3605
+ });
3606
+ console.log(`[setup-events] Watching ${projectsDir} for status.json changes (hash-filtered, debounced)`);
3607
+ } catch (err) {
3608
+ console.error("[setup-events] Failed to start status file watcher:", err);
3609
+ }
3610
+ };
3611
+ events.on("client.status", (e) => {
3612
+ if (e.projectHash) {
3613
+ const hash = String(e.projectHash);
3614
+ if (!knownProjectHashes.has(hash)) {
3615
+ knownProjectHashes.add(hash);
3616
+ if (watcherMetrics) watcherMetrics.activeProjects = knownProjectHashes.size;
3617
+ }
3618
+ }
3619
+ });
3620
+ startWatcher();
3621
+ disposers.push(() => {
3622
+ clearInterval(metricsInterval);
3623
+ logWatcherMetrics();
3624
+ if (watcherMetrics) watcherMetrics.watcherActive = false;
3625
+ for (const [projectHash2, pending] of pendingStatuses) {
3626
+ const timer = debounceTimers.get(projectHash2);
3627
+ if (timer) {
3628
+ clearTimeout(timer);
3629
+ broadcastStatus(projectHash2, pending.data, 0);
3630
+ }
3631
+ }
3632
+ for (const timer of debounceTimers.values()) {
3633
+ clearTimeout(timer);
3634
+ }
3635
+ debounceTimers.clear();
3636
+ pendingStatuses.clear();
3637
+ if (watcher) {
3638
+ watcher.close();
3639
+ console.log("[setup-events] Closed status file watcher");
3640
+ }
3641
+ });
3642
+ }
2682
3643
  const globalRoot = globalConfigPath ? path5.dirname(globalConfigPath) : void 0;
2683
3644
  if (globalRoot) {
2684
- const statusInterval = setInterval(async () => {
3645
+ const broadcastSessions = async () => {
2685
3646
  try {
2686
3647
  const { SessionRegistry } = await import("@wrongstack/core");
2687
3648
  const registry = new SessionRegistry(globalRoot);
2688
3649
  const sessions = await registry.list();
2689
- const live = sessions.filter((s) => s.status !== "stale").map((s) => ({
3650
+ const mySlug = sessions.find((s) => s.pid === process.pid)?.projectSlug;
3651
+ const live = sessions.filter((s) => s.status !== "stale").filter((s) => mySlug ? s.projectSlug === mySlug : true).map((s) => ({
2690
3652
  sessionId: s.sessionId,
2691
3653
  projectName: s.projectName,
2692
3654
  projectSlug: s.projectSlug,
2693
3655
  projectRoot: s.projectRoot,
2694
3656
  workingDir: s.workingDir,
2695
3657
  gitBranch: s.gitBranch,
3658
+ // Surface (tui/webui/cli) so Fleet HQ can label each live client node.
3659
+ clientType: s.clientType,
2696
3660
  status: s.status,
2697
3661
  pid: s.pid,
2698
3662
  startedAt: s.startedAt,
@@ -2704,20 +3668,52 @@ function setupEvents(deps) {
2704
3668
  currentTool: a.currentTool,
2705
3669
  iterations: a.iterations,
2706
3670
  toolCalls: a.toolCalls,
3671
+ costUsd: a.costUsd,
3672
+ tokensIn: a.tokensIn,
3673
+ tokensOut: a.tokensOut,
3674
+ ctxPct: a.ctxPct,
3675
+ model: a.model,
3676
+ partialText: a.partialText,
2707
3677
  lastActivityAt: a.lastActivityAt
2708
3678
  }))
2709
3679
  }));
2710
3680
  broadcast2(clients, { type: "sessions.status_update", payload: { sessions: live } });
2711
3681
  } catch {
2712
3682
  }
2713
- }, 5e3);
3683
+ };
3684
+ onFleetBroadcaster?.(broadcastSessions);
3685
+ const statusInterval = setInterval(() => void broadcastSessions(), 5e3);
2714
3686
  if (statusInterval.unref) statusInterval.unref();
3687
+ disposers.push(() => clearInterval(statusInterval));
3688
+ let regDebounce;
3689
+ try {
3690
+ const regWatcher = fsWatch(globalRoot, { persistent: false }, (_event, filename) => {
3691
+ const name2 = filename ? String(filename) : "";
3692
+ if (!name2.startsWith("session-registry.json") || name2.endsWith(".lock")) return;
3693
+ if (regDebounce) clearTimeout(regDebounce);
3694
+ regDebounce = setTimeout(() => void broadcastSessions(), 150);
3695
+ });
3696
+ disposers.push(() => {
3697
+ if (regDebounce) clearTimeout(regDebounce);
3698
+ regWatcher.close();
3699
+ });
3700
+ } catch {
3701
+ }
3702
+ void broadcastSessions();
2715
3703
  }
3704
+ return () => {
3705
+ for (const dispose of disposers) {
3706
+ try {
3707
+ dispose();
3708
+ } catch {
3709
+ }
3710
+ }
3711
+ };
2716
3712
  }
2717
3713
 
2718
3714
  // src/server/custom-context-modes.ts
2719
3715
  import { listContextWindowModes, atomicWrite as atomicWrite4 } from "@wrongstack/core";
2720
- import * as fs5 from "fs/promises";
3716
+ import * as fs6 from "fs/promises";
2721
3717
  import * as path6 from "path";
2722
3718
  var STORE_FILENAME = "custom-context-modes.json";
2723
3719
  function storePath(wrongstackDir) {
@@ -2729,7 +3725,7 @@ function createCustomModeStore(wrongstackDir) {
2729
3725
  const load2 = async () => {
2730
3726
  modes.clear();
2731
3727
  try {
2732
- const raw = await fs5.readFile(storePath(wrongstackDir), "utf8");
3728
+ const raw = await fs6.readFile(storePath(wrongstackDir), "utf8");
2733
3729
  const parsed = JSON.parse(raw);
2734
3730
  if (Array.isArray(parsed.modes)) {
2735
3731
  for (const m of parsed.modes) {
@@ -2909,14 +3905,14 @@ function createEternalSubscription(subscribe, broadcast2, clientsRef) {
2909
3905
  }
2910
3906
 
2911
3907
  // src/server/shell-open.ts
2912
- import * as fs6 from "fs/promises";
3908
+ import * as fs7 from "fs/promises";
2913
3909
  import * as path7 from "path";
2914
3910
  import { spawn as spawn2 } from "child_process";
2915
3911
  var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
2916
3912
  async function handleShellOpen(req, logger) {
2917
3913
  try {
2918
3914
  const resolved = path7.resolve(req.path);
2919
- await fs6.access(resolved);
3915
+ await fs7.access(resolved);
2920
3916
  if (METACHAR_REGEX.test(resolved)) {
2921
3917
  return { success: false, message: "Path contains unsupported characters." };
2922
3918
  }
@@ -2953,12 +3949,436 @@ async function handleShellOpen(req, logger) {
2953
3949
  )
2954
3950
  );
2955
3951
  }
2956
- } else {
2957
- return { success: false, message: `Unknown shell.open target: ${String(req.target)}` };
3952
+ } else {
3953
+ return { success: false, message: `Unknown shell.open target: ${String(req.target)}` };
3954
+ }
3955
+ return { success: true, message: `Opened ${req.target} at ${resolved}` };
3956
+ } catch (err) {
3957
+ return { success: false, message: err instanceof Error ? err.message : String(err) };
3958
+ }
3959
+ }
3960
+
3961
+ // src/server/git-handlers.ts
3962
+ async function handleGitInfo(ws, projectRoot) {
3963
+ const cwd = projectRoot || void 0;
3964
+ try {
3965
+ const { execFile: ef } = await import("child_process");
3966
+ const git = (args) => new Promise((resolve5) => {
3967
+ ef("git", args, { cwd, timeout: 3e3 }, (err, stdout) => {
3968
+ resolve5(err ? "" : stdout.trim());
3969
+ });
3970
+ });
3971
+ const [branchRaw, diffRaw, statusRaw, upstreamRaw] = await Promise.all([
3972
+ git(["branch", "--show-current"]),
3973
+ git(["diff", "--stat"]),
3974
+ git(["status", "--porcelain"]),
3975
+ git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
3976
+ ]);
3977
+ const branch = branchRaw || "(detached)";
3978
+ const addMatch = /(\d+)\s+insertion/i.exec(diffRaw);
3979
+ const delMatch = /(\d+)\s+deletion/i.exec(diffRaw);
3980
+ const added = addMatch ? Number(addMatch[1]) : 0;
3981
+ const deleted = delMatch ? Number(delMatch[1]) : 0;
3982
+ const untracked = statusRaw.split("\n").filter((l) => l.startsWith("??")).length;
3983
+ const [behindRaw, aheadRaw] = (upstreamRaw || "0 0").split(" ");
3984
+ const behind = Number(behindRaw) || 0;
3985
+ const ahead = Number(aheadRaw) || 0;
3986
+ send(ws, { type: "git.info", payload: { branch, added, deleted, untracked, ahead, behind } });
3987
+ } catch {
3988
+ send(ws, { type: "git.info", payload: { branch: "", added: 0, deleted: 0, untracked: 0, ahead: 0, behind: 0 } });
3989
+ }
3990
+ }
3991
+ function makeGit(cwd) {
3992
+ return async (args) => {
3993
+ const { execFile: ef } = await import("child_process");
3994
+ return new Promise((resolve5) => {
3995
+ ef(
3996
+ "git",
3997
+ args,
3998
+ { cwd, timeout: 5e3, maxBuffer: 1024 * 1024 * 16 },
3999
+ (err, stdout) => resolve5(err ? "" : stdout)
4000
+ );
4001
+ });
4002
+ };
4003
+ }
4004
+ async function handleGitChanges(ws, projectRoot) {
4005
+ const cwd = projectRoot || void 0;
4006
+ try {
4007
+ const git = makeGit(cwd);
4008
+ const [statusRaw, unstagedNumstat, stagedNumstat] = await Promise.all([
4009
+ git(["status", "--porcelain", "-z"]),
4010
+ git(["diff", "--numstat", "-z"]),
4011
+ git(["diff", "--cached", "--numstat", "-z"])
4012
+ ]);
4013
+ const counts = /* @__PURE__ */ new Map();
4014
+ const parseNumstat = (raw) => {
4015
+ const parts = raw.split("\0");
4016
+ for (let i = 0; i < parts.length; i++) {
4017
+ const entry = parts[i];
4018
+ if (!entry) continue;
4019
+ const m = /^(\d+|-)\t(\d+|-)\t(.*)$/.exec(entry);
4020
+ if (!m) continue;
4021
+ const added = m[1] === "-" ? 0 : Number(m[1]);
4022
+ const deleted = m[2] === "-" ? 0 : Number(m[2]);
4023
+ let path10 = m[3] ?? "";
4024
+ if (path10 === "") {
4025
+ i += 1;
4026
+ path10 = parts[i + 1] ?? parts[i] ?? "";
4027
+ i += 1;
4028
+ }
4029
+ if (!path10) continue;
4030
+ const prev = counts.get(path10) ?? { added: 0, deleted: 0 };
4031
+ counts.set(path10, { added: prev.added + added, deleted: prev.deleted + deleted });
4032
+ }
4033
+ };
4034
+ parseNumstat(unstagedNumstat);
4035
+ parseNumstat(stagedNumstat);
4036
+ const records = statusRaw.split("\0").filter((r) => r.length > 0);
4037
+ const files = [];
4038
+ for (let i = 0; i < records.length; i++) {
4039
+ const rec = records[i];
4040
+ if (!rec || rec.length < 3) continue;
4041
+ const x = rec[0] ?? " ";
4042
+ const y = rec[1] ?? " ";
4043
+ const path10 = rec.slice(3);
4044
+ const isRename = x === "R" || x === "C" || y === "R" || y === "C";
4045
+ if (isRename) i += 1;
4046
+ let status;
4047
+ if (x === "?" && y === "?") status = "?";
4048
+ else if (x === "U" || y === "U" || x === "A" && y === "A" || x === "D" && y === "D") status = "U";
4049
+ else if (x === "R" || y === "R") status = "R";
4050
+ else if (x === "C" || y === "C") status = "C";
4051
+ else if (x === "A" || y === "A") status = "A";
4052
+ else if (x === "D" || y === "D") status = "D";
4053
+ else status = "M";
4054
+ const staged = x !== " " && x !== "?";
4055
+ let added = counts.get(path10)?.added ?? 0;
4056
+ let deleted = counts.get(path10)?.deleted ?? 0;
4057
+ if (status === "?") {
4058
+ added = await countUntrackedLines(cwd, path10);
4059
+ deleted = 0;
4060
+ }
4061
+ files.push({ path: path10, status, added, deleted, staged });
4062
+ }
4063
+ send(ws, { type: "git.changes", payload: { files } });
4064
+ } catch (err) {
4065
+ send(ws, {
4066
+ type: "git.changes",
4067
+ payload: { files: [], error: err instanceof Error ? err.message : String(err) }
4068
+ });
4069
+ }
4070
+ }
4071
+ async function countUntrackedLines(cwd, relPath) {
4072
+ try {
4073
+ const { readFile: readFile8 } = await import("fs/promises");
4074
+ const { join: join8 } = await import("path");
4075
+ const abs = cwd ? join8(cwd, relPath) : relPath;
4076
+ const buf = await readFile8(abs);
4077
+ if (buf.includes(0)) return 0;
4078
+ if (buf.length === 0) return 0;
4079
+ let lines = 0;
4080
+ for (let i = 0; i < buf.length; i++) if (buf[i] === 10) lines++;
4081
+ if (buf[buf.length - 1] !== 10) lines++;
4082
+ return lines;
4083
+ } catch {
4084
+ return 0;
4085
+ }
4086
+ }
4087
+ var MAX_DIFF_BYTES = 2 * 1024 * 1024;
4088
+ async function handleGitDiff(ws, projectRoot, path10) {
4089
+ const cwd = projectRoot || void 0;
4090
+ const reply = (extra) => send(ws, { type: "git.diff", payload: { path: path10, ...extra } });
4091
+ if (!path10 || path10.includes("\0") || path10.includes("..")) {
4092
+ reply({ oldText: "", newText: "", error: "invalid path" });
4093
+ return;
4094
+ }
4095
+ try {
4096
+ const git = makeGit(cwd);
4097
+ const { readFile: readFile8 } = await import("fs/promises");
4098
+ const { join: join8 } = await import("path");
4099
+ const oldText = await git(["show", `HEAD:${path10}`]);
4100
+ let newText = "";
4101
+ try {
4102
+ const abs = cwd ? join8(cwd, path10) : path10;
4103
+ const buf = await readFile8(abs);
4104
+ if (buf.includes(0)) {
4105
+ reply({ oldText: "", newText: "", binary: true });
4106
+ return;
4107
+ }
4108
+ if (buf.length > MAX_DIFF_BYTES) {
4109
+ reply({ oldText: "", newText: "", tooLarge: true });
4110
+ return;
4111
+ }
4112
+ newText = buf.toString("utf8");
4113
+ } catch {
4114
+ newText = "";
4115
+ }
4116
+ if ((oldText.length || 0) > MAX_DIFF_BYTES) {
4117
+ reply({ oldText: "", newText: "", tooLarge: true });
4118
+ return;
4119
+ }
4120
+ if (oldText.includes("\0")) {
4121
+ reply({ oldText: "", newText: "", binary: true });
4122
+ return;
4123
+ }
4124
+ reply({ oldText, newText });
4125
+ } catch (err) {
4126
+ reply({ oldText: "", newText: "", error: err instanceof Error ? err.message : String(err) });
4127
+ }
4128
+ }
4129
+
4130
+ // src/server/skills-handlers.ts
4131
+ import { promises as fs8 } from "fs";
4132
+ import path8 from "path";
4133
+ import JSZip from "jszip";
4134
+ import { wstackGlobalRoot } from "@wrongstack/core/utils";
4135
+ async function handleSkillsContent(ws, ctx, msg) {
4136
+ if (!ctx.skillLoader) {
4137
+ send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skills not enabled" } });
4138
+ return;
4139
+ }
4140
+ const contentPayload = msg.payload;
4141
+ if (!contentPayload?.name) {
4142
+ send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skill name is required" } });
4143
+ return;
4144
+ }
4145
+ try {
4146
+ const { name: name2, source } = contentPayload;
4147
+ const entries = await ctx.skillLoader.listEntries();
4148
+ const entry = entries.find((e) => e.name.toLowerCase() === name2.toLowerCase());
4149
+ if (!entry) {
4150
+ send(ws, { type: "skills.content", payload: { name: name2, body: "", path: "", source, relatedFiles: [], references: [], error: `Skill "${name2}" not found` } });
4151
+ return;
4152
+ }
4153
+ const body = await fs8.readFile(entry.path, "utf8");
4154
+ const skillDir = path8.dirname(entry.path);
4155
+ let relatedFiles = [];
4156
+ try {
4157
+ const files = await fs8.readdir(skillDir);
4158
+ relatedFiles = files.filter((f) => f !== path8.basename(entry.path)).map((f) => path8.join(skillDir, f));
4159
+ } catch {
4160
+ }
4161
+ const nameLower = name2.toLowerCase();
4162
+ const refResults = await Promise.all(
4163
+ entries.filter((e) => e.name.toLowerCase() !== nameLower).map(async (e) => {
4164
+ try {
4165
+ const content = await fs8.readFile(e.path, "utf8");
4166
+ return [e.name, content.toLowerCase().includes(nameLower)];
4167
+ } catch {
4168
+ return [e.name, false];
4169
+ }
4170
+ })
4171
+ );
4172
+ const refs = refResults.filter(([, hasRef]) => hasRef).map(([n]) => n);
4173
+ send(ws, { type: "skills.content", payload: { name: name2, body, path: entry.path, source, relatedFiles, references: refs } });
4174
+ } catch (err) {
4175
+ send(ws, { type: "skills.content", payload: { name: contentPayload.name, body: "", path: "", source: contentPayload.source, relatedFiles: [], references: [], error: errMessage(err) } });
4176
+ }
4177
+ }
4178
+ async function handleSkillsInstall(ws, ctx, msg) {
4179
+ if (!ctx.skillInstaller) {
4180
+ send(ws, { type: "skills.installed", payload: { success: false, error: "Skills not enabled" } });
4181
+ return;
4182
+ }
4183
+ const installPayload = msg.payload;
4184
+ if (!installPayload?.ref?.trim()) {
4185
+ send(ws, { type: "skills.installed", payload: { success: false, error: "Skill reference is required (e.g. owner/repo or https://github.com/owner/repo)" } });
4186
+ return;
4187
+ }
4188
+ try {
4189
+ const results = await ctx.skillInstaller.install(installPayload.ref.trim(), { global: installPayload.global });
4190
+ send(ws, {
4191
+ type: "skills.installed",
4192
+ payload: {
4193
+ success: true,
4194
+ results,
4195
+ error: null
4196
+ }
4197
+ });
4198
+ } catch (err) {
4199
+ send(ws, {
4200
+ type: "skills.installed",
4201
+ payload: {
4202
+ success: false,
4203
+ error: errMessage(err)
4204
+ }
4205
+ });
4206
+ }
4207
+ }
4208
+ async function handleSkillsUninstall(ws, ctx, msg) {
4209
+ if (!ctx.skillInstaller) {
4210
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skills not enabled" } });
4211
+ return;
4212
+ }
4213
+ const uninstallPayload = msg.payload;
4214
+ if (!uninstallPayload?.name?.trim()) {
4215
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skill name is required" } });
4216
+ return;
4217
+ }
4218
+ try {
4219
+ await ctx.skillInstaller.uninstall(uninstallPayload.name.trim(), { global: uninstallPayload.global });
4220
+ send(ws, { type: "skills.uninstalled", payload: { success: true, error: null } });
4221
+ } catch (err) {
4222
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: errMessage(err) } });
4223
+ }
4224
+ }
4225
+ async function handleSkillsUpdate(ws, ctx, msg) {
4226
+ if (!ctx.skillInstaller) {
4227
+ send(ws, { type: "skills.updated", payload: { success: false, error: "Skills not enabled" } });
4228
+ return;
4229
+ }
4230
+ const updatePayload = msg.payload;
4231
+ try {
4232
+ const result = await ctx.skillInstaller.update(updatePayload?.name, { global: updatePayload?.global });
4233
+ send(ws, {
4234
+ type: "skills.updated",
4235
+ payload: {
4236
+ success: true,
4237
+ error: null,
4238
+ updated: result.updated,
4239
+ unchanged: result.unchanged,
4240
+ errors: result.errors
4241
+ }
4242
+ });
4243
+ } catch (err) {
4244
+ send(ws, { type: "skills.updated", payload: { success: false, error: errMessage(err) } });
4245
+ }
4246
+ }
4247
+ async function handleSkillsCreate(ws, ctx, msg) {
4248
+ const createPayload = msg.payload;
4249
+ if (!createPayload?.name?.trim()) {
4250
+ send(ws, { type: "skills.created", payload: { success: false, error: "Skill name is required" } });
4251
+ return;
4252
+ }
4253
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(createPayload.name.trim())) {
4254
+ send(ws, { type: "skills.created", payload: { success: false, error: "Skill name must be kebab-case (e.g. my-new-skill)" } });
4255
+ return;
4256
+ }
4257
+ if (!createPayload?.description?.trim()) {
4258
+ send(ws, { type: "skills.created", payload: { success: false, error: "Description/trigger is required" } });
4259
+ return;
4260
+ }
4261
+ try {
4262
+ const targetDir = createPayload.scope === "global" ? path8.join(wstackGlobalRoot(), "skills", createPayload.name.trim()) : path8.join(ctx.projectRoot, ".wrongstack", "skills", createPayload.name.trim());
4263
+ try {
4264
+ await fs8.access(targetDir);
4265
+ send(ws, { type: "skills.created", payload: { success: false, error: `Skill "${createPayload.name}" already exists` } });
4266
+ return;
4267
+ } catch {
4268
+ }
4269
+ await fs8.mkdir(targetDir, { recursive: true });
4270
+ const lines = createPayload.description.trim().split("\n");
4271
+ const firstLine = lines[0].trim();
4272
+ const bodyLines = lines.slice(1).map((l) => l.trim()).filter(Boolean);
4273
+ const descriptionText = firstLine + (bodyLines.length > 0 ? `
4274
+ ${bodyLines.join("\n")}` : "");
4275
+ const trigger = bodyLines.find((l) => l.toLowerCase().startsWith("triggers:")) ?? "";
4276
+ const skillContent = [
4277
+ "---",
4278
+ `name: ${createPayload.name.trim()}`,
4279
+ "description: |",
4280
+ ` ${descriptionText.replace(/\n/g, "\n ")}`,
4281
+ `version: 1.0.0`,
4282
+ "---",
4283
+ "",
4284
+ `# ${createPayload.name.trim().split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}`,
4285
+ "",
4286
+ "## Overview",
4287
+ "",
4288
+ firstLine,
4289
+ "",
4290
+ ...bodyLines.length > 0 ? bodyLines.filter((l) => !l.toLowerCase().startsWith("triggers:")) : [],
4291
+ "",
4292
+ "## Rules",
4293
+ "- TODO: add your first rule",
4294
+ "",
4295
+ "## Patterns",
4296
+ "### Do",
4297
+ "```ts",
4298
+ "// TODO: add a good example",
4299
+ "```",
4300
+ "",
4301
+ "### Don't",
4302
+ "```ts",
4303
+ "// TODO: add a bad example",
4304
+ "```",
4305
+ "",
4306
+ "## Workflow",
4307
+ "1. TODO: describe step one",
4308
+ "2. TODO: describe step two",
4309
+ "",
4310
+ trigger ? `
4311
+ ${trigger}
4312
+ ` : "",
4313
+ "## Skills in scope",
4314
+ "- `bug-hunter` \u2014 for systematic bug detection patterns",
4315
+ "- `output-standards` \u2014 for standardized `<next_steps>` formatting"
4316
+ ].join("\n");
4317
+ await fs8.writeFile(path8.join(targetDir, "SKILL.md"), skillContent, "utf-8");
4318
+ send(ws, {
4319
+ type: "skills.created",
4320
+ payload: {
4321
+ success: true,
4322
+ error: null,
4323
+ skill: { name: createPayload.name.trim(), path: path8.join(targetDir, "SKILL.md"), scope: createPayload.scope }
4324
+ }
4325
+ });
4326
+ } catch (err) {
4327
+ send(ws, { type: "skills.created", payload: { success: false, error: errMessage(err) } });
4328
+ }
4329
+ }
4330
+ async function handleSkillsEdit(ws, ctx, msg) {
4331
+ if (!ctx.skillLoader) {
4332
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skills not enabled" } });
4333
+ return;
4334
+ }
4335
+ const editPayload = msg.payload;
4336
+ if (!editPayload?.name?.trim()) {
4337
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skill name is required" } });
4338
+ return;
4339
+ }
4340
+ if (!editPayload?.body) {
4341
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skill body is required" } });
4342
+ return;
4343
+ }
4344
+ try {
4345
+ const entries = await ctx.skillLoader.listEntries();
4346
+ const entry = entries.find((e) => e.name.toLowerCase() === editPayload.name.toLowerCase());
4347
+ if (!entry) {
4348
+ send(ws, { type: "skills.edited", payload: { success: false, error: `Skill "${editPayload.name}" not found` } });
4349
+ return;
2958
4350
  }
2959
- return { success: true, message: `Opened ${req.target} at ${resolved}` };
4351
+ if (entry.scope.includes("bundled")) {
4352
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Bundled skills cannot be edited" } });
4353
+ return;
4354
+ }
4355
+ await fs8.writeFile(entry.path, editPayload.body, "utf-8");
4356
+ send(ws, { type: "skills.edited", payload: { success: true, error: null } });
2960
4357
  } catch (err) {
2961
- return { success: false, message: err instanceof Error ? err.message : String(err) };
4358
+ send(ws, { type: "skills.edited", payload: { success: false, error: errMessage(err) } });
4359
+ }
4360
+ }
4361
+ async function handleSkillsExport(ws, ctx) {
4362
+ if (!ctx.skillLoader) {
4363
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: "Skills not enabled" } });
4364
+ return;
4365
+ }
4366
+ try {
4367
+ const entries = await ctx.skillLoader.listEntries();
4368
+ const zip = new JSZip();
4369
+ for (const entry of entries) {
4370
+ try {
4371
+ const body = await ctx.skillLoader.readBody(entry.name);
4372
+ const safeName = entry.name.replace(/\//g, "_");
4373
+ zip.file(`${safeName}/SKILL.md`, body);
4374
+ } catch {
4375
+ }
4376
+ }
4377
+ const zipBuffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
4378
+ const zipBase64 = zipBuffer.toString("base64");
4379
+ send(ws, { type: "skills.exported", payload: { zipBase64, skillCount: entries.length, error: void 0 } });
4380
+ } catch (err) {
4381
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: errMessage(err) } });
2962
4382
  }
2963
4383
  }
2964
4384
 
@@ -3049,6 +4469,21 @@ async function startWebUI(opts = {}) {
3049
4469
  toolRegistry.register(makeMailSendTool({ projectDir: wpaths.projectDir, events }));
3050
4470
  toolRegistry.register(makeMailInboxTool({ projectDir: wpaths.projectDir, events }));
3051
4471
  console.log("[WebUI] Tool registry loaded:", toolRegistry.list().length, "tools");
4472
+ const mcpRegistry = new MCPRegistry({
4473
+ toolRegistry,
4474
+ events,
4475
+ log: logger,
4476
+ // Lazy-connect (per-server `lazy`) manifest cache + default idle auto-sleep.
4477
+ cacheDir: wpaths.cacheDir
4478
+ });
4479
+ if (config.features.mcp && config.mcpServers) {
4480
+ for (const [name2, cfg] of Object.entries(config.mcpServers)) {
4481
+ if (cfg.enabled === false) continue;
4482
+ void mcpRegistry.start({ ...cfg, name: name2 }).catch((err) => {
4483
+ logger.warn(`MCP server "${name2}" failed to start at boot`, err);
4484
+ });
4485
+ }
4486
+ }
3052
4487
  let sessionStore = opts.services?.session ?? new DefaultSessionStore2({ dir: wpaths.projectSessions });
3053
4488
  if (!opts.services?.session) {
3054
4489
  sessionStore.prune(DEFAULT_SESSION_PRUNE_DAYS).then((count) => {
@@ -3076,15 +4511,22 @@ async function startWebUI(opts = {}) {
3076
4511
  sessionId: session.id,
3077
4512
  projectSlug: wpaths.projectSlug,
3078
4513
  projectRoot,
3079
- projectName: path8.basename(projectRoot),
4514
+ projectName: path9.basename(projectRoot),
3080
4515
  workingDir,
4516
+ clientType: "webui",
3081
4517
  pid: process.pid,
3082
4518
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
3083
4519
  });
3084
- statusTracker = new AgentStatusTracker({ events, registry });
4520
+ const fleetNotifier = new FleetNotifier({
4521
+ baseDir: wpaths.globalRoot,
4522
+ projectRoot,
4523
+ selfPid: process.pid
4524
+ });
4525
+ statusTracker = new AgentStatusTracker({ events, registry, onUpdate: () => fleetNotifier.notify() });
3085
4526
  statusTracker.start();
3086
4527
  const stopTracking = async () => {
3087
4528
  try {
4529
+ fleetNotifier.dispose();
3088
4530
  await registry.markClosing();
3089
4531
  statusTracker?.stop();
3090
4532
  } catch {
@@ -3124,6 +4566,13 @@ async function startWebUI(opts = {}) {
3124
4566
  supportsReasoning: resolvedModel.capabilities.reasoning
3125
4567
  } : void 0;
3126
4568
  const skillLoader = config.features.skills ? new DefaultSkillLoader2({ paths: wpaths }) : void 0;
4569
+ const skillInstaller = config.features.skills ? new SkillInstaller({
4570
+ manifestPath: path9.join(wstackGlobalRoot2(), "installed-skills.json"),
4571
+ projectSkillsDir: path9.join(projectRoot, ".wrongstack", "skills"),
4572
+ globalSkillsDir: path9.join(wstackGlobalRoot2(), "skills"),
4573
+ projectHash: projectHash(projectRoot),
4574
+ skillLoader
4575
+ }) : void 0;
3127
4576
  const systemPromptBuilder = new DefaultSystemPromptBuilder2({
3128
4577
  memoryStore,
3129
4578
  skillLoader,
@@ -3193,7 +4642,7 @@ async function startWebUI(opts = {}) {
3193
4642
  }
3194
4643
  } else {
3195
4644
  throw new Error(
3196
- "No provider configured. Run `wrongstack init` first, or configure via the WebUI."
4645
+ "No provider configured. Run `wrongstack auth` to set up, or configure via the WebUI."
3197
4646
  );
3198
4647
  }
3199
4648
  }
@@ -3281,7 +4730,7 @@ async function startWebUI(opts = {}) {
3281
4730
  const write = async () => {
3282
4731
  let raw;
3283
4732
  try {
3284
- raw = await fs7.readFile(globalConfigPath, "utf8");
4733
+ raw = await fs9.readFile(globalConfigPath, "utf8");
3285
4734
  } catch {
3286
4735
  raw = "{}";
3287
4736
  }
@@ -3590,7 +5039,7 @@ async function startWebUI(opts = {}) {
3590
5039
  inputCost,
3591
5040
  outputCost,
3592
5041
  cacheReadCost,
3593
- projectName: path8.basename(projectRoot) || projectRoot,
5042
+ projectName: path9.basename(projectRoot) || projectRoot,
3594
5043
  projectRoot,
3595
5044
  cwd: workingDir,
3596
5045
  mode: modeId,
@@ -3644,10 +5093,11 @@ async function startWebUI(opts = {}) {
3644
5093
  const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
3645
5094
  const RATE_LIMIT_WINDOW_MS = 6e4;
3646
5095
  const rateLimits = /* @__PURE__ */ new Map();
3647
- function checkRateLimit(ws, client) {
5096
+ let connSeq = 0;
5097
+ function checkRateLimit(_ws, client) {
3648
5098
  if (RATE_LIMIT_MESSAGES <= 0) return true;
3649
5099
  const now = Date.now();
3650
- const key = client.sessionId ?? String(ws);
5100
+ const key = client.connId;
3651
5101
  const limit = rateLimits.get(key);
3652
5102
  if (!limit || now > limit.resetAt) {
3653
5103
  rateLimits.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
@@ -3663,7 +5113,12 @@ async function startWebUI(opts = {}) {
3663
5113
  );
3664
5114
  const pendingConfirms = /* @__PURE__ */ new Map();
3665
5115
  const handleConnection = (ws) => {
3666
- const client = { ws, sessionId: session.id, connectedAt: Date.now() };
5116
+ const client = {
5117
+ ws,
5118
+ sessionId: session.id,
5119
+ connectedAt: Date.now(),
5120
+ connId: `c${++connSeq}`
5121
+ };
3667
5122
  clients.set(ws, client);
3668
5123
  void sessionStartPayload().then((payload) => {
3669
5124
  send(ws, { type: "session.start", payload });
@@ -3693,7 +5148,7 @@ async function startWebUI(opts = {}) {
3693
5148
  const rawObj = JSON.parse(data.toString());
3694
5149
  if (typeof rawObj === "object" && rawObj !== null) {
3695
5150
  const obj = rawObj;
3696
- if ("__proto__" in obj || "constructor" in obj || "prototype" in obj) {
5151
+ if (Object.hasOwn(obj, "__proto__") || Object.hasOwn(obj, "constructor") || Object.hasOwn(obj, "prototype")) {
3697
5152
  send(ws, {
3698
5153
  type: "error",
3699
5154
  payload: { phase: "parse", message: "Invalid message object" }
@@ -3714,8 +5169,9 @@ async function startWebUI(opts = {}) {
3714
5169
  }
3715
5170
  });
3716
5171
  ws.on("close", () => {
5172
+ const closing = clients.get(ws);
3717
5173
  clients.delete(ws);
3718
- rateLimits.delete(String(ws));
5174
+ if (closing) rateLimits.delete(closing.connId);
3719
5175
  if (pendingConfirms.size > 0) {
3720
5176
  for (const [id, resolve5] of pendingConfirms) {
3721
5177
  resolve5("no");
@@ -3741,11 +5197,27 @@ async function startWebUI(opts = {}) {
3741
5197
  { sampling: sessionLogging.sampling }
3742
5198
  );
3743
5199
  let eventsArmed = false;
5200
+ let disposeEvents = null;
5201
+ let fleetBroadcast = null;
3744
5202
  const armOnce = (label) => {
3745
5203
  if (eventsArmed) return;
3746
5204
  eventsArmed = true;
3747
5205
  console.log(`[WebUI] Backend ready (${label})`);
3748
- setupEvents({ events, broadcast, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge });
5206
+ disposeEvents = setupEvents({
5207
+ events,
5208
+ broadcast,
5209
+ clients,
5210
+ config,
5211
+ context,
5212
+ pendingConfirms,
5213
+ globalConfigPath,
5214
+ sessionBridge,
5215
+ wpaths,
5216
+ watcherMetrics,
5217
+ onFleetBroadcaster: (fn) => {
5218
+ fleetBroadcast = fn;
5219
+ }
5220
+ });
3749
5221
  };
3750
5222
  wssPrimary.on("listening", () => armOnce(`${wsHost}:${wsPort}`));
3751
5223
  wssPrimary.on("connection", handleConnection);
@@ -3782,33 +5254,33 @@ async function startWebUI(opts = {}) {
3782
5254
  });
3783
5255
  }
3784
5256
  async function touchProjectEntry(root, workDir) {
3785
- const resolved = path8.resolve(root);
5257
+ const resolved = path9.resolve(root);
3786
5258
  const manifest = await loadManifest(globalConfigPath);
3787
5259
  const now = (/* @__PURE__ */ new Date()).toISOString();
3788
- const existing = manifest.projects.find((p) => path8.resolve(p.root) === resolved);
5260
+ const existing = manifest.projects.find((p) => path9.resolve(p.root) === resolved);
3789
5261
  if (existing) {
3790
5262
  existing.lastSeen = now;
3791
- if (workDir) existing.lastWorkingDir = path8.resolve(workDir);
5263
+ if (workDir) existing.lastWorkingDir = path9.resolve(workDir);
3792
5264
  } else {
3793
5265
  manifest.projects.push({
3794
- name: path8.basename(resolved),
5266
+ name: path9.basename(resolved),
3795
5267
  root: resolved,
3796
5268
  slug: generateProjectSlug(resolved),
3797
5269
  createdAt: now,
3798
5270
  lastSeen: now,
3799
- lastWorkingDir: workDir ? path8.resolve(workDir) : void 0
5271
+ lastWorkingDir: workDir ? path9.resolve(workDir) : void 0
3800
5272
  });
3801
5273
  }
3802
5274
  await saveManifest(manifest, globalConfigPath);
3803
5275
  await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
3804
5276
  }
3805
5277
  function projectsJsonPath(globalConfigPath2) {
3806
- const base = path8.dirname(globalConfigPath2);
3807
- return path8.join(base, "projects.json");
5278
+ const base = path9.dirname(globalConfigPath2);
5279
+ return path9.join(base, "projects.json");
3808
5280
  }
3809
5281
  async function loadManifest(globalConfigPath2) {
3810
5282
  try {
3811
- const raw = await fs7.readFile(projectsJsonPath(globalConfigPath2), "utf8");
5283
+ const raw = await fs9.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3812
5284
  const parsed = JSON.parse(raw);
3813
5285
  return { projects: parsed.projects ?? [] };
3814
5286
  } catch {
@@ -3817,16 +5289,16 @@ async function startWebUI(opts = {}) {
3817
5289
  }
3818
5290
  async function saveManifest(manifest, globalConfigPath2) {
3819
5291
  const file = projectsJsonPath(globalConfigPath2);
3820
- await fs7.mkdir(path8.dirname(file), { recursive: true });
3821
- await fs7.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
5292
+ await fs9.mkdir(path9.dirname(file), { recursive: true });
5293
+ await fs9.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
3822
5294
  }
3823
5295
  function generateProjectSlug(rootPath) {
3824
5296
  return projectSlug(rootPath);
3825
5297
  }
3826
5298
  async function ensureProjectDataDir(slug, globalConfigPath2) {
3827
- const base = path8.dirname(globalConfigPath2);
3828
- const dir = path8.join(base, "projects", slug);
3829
- await fs7.mkdir(dir, { recursive: true });
5299
+ const base = path9.dirname(globalConfigPath2);
5300
+ const dir = path9.join(base, "projects", slug);
5301
+ await fs9.mkdir(dir, { recursive: true });
3830
5302
  return dir;
3831
5303
  }
3832
5304
  async function handleMessage(ws, _client, msg) {
@@ -3936,7 +5408,7 @@ async function startWebUI(opts = {}) {
3936
5408
  context.readFiles.clear();
3937
5409
  context.fileMtimes.clear();
3938
5410
  tokenCounter.reset();
3939
- sendResult(ws, true, "Context cleared");
5411
+ sendResult2(ws, true, "Context cleared");
3940
5412
  broadcast(clients, {
3941
5413
  type: "session.start",
3942
5414
  payload: { ...await sessionStartPayload(), reset: true }
@@ -3973,13 +5445,13 @@ async function startWebUI(opts = {}) {
3973
5445
  repaired: report.repaired
3974
5446
  }
3975
5447
  });
3976
- sendResult(
5448
+ sendResult2(
3977
5449
  ws,
3978
5450
  true,
3979
5451
  `Compacted: ${report.before} \u2192 ${report.after} tokens (saved ~${Math.max(0, report.before - report.after)})`
3980
5452
  );
3981
5453
  } catch (err) {
3982
- sendResult(ws, false, errMessage(err));
5454
+ sendResult2(ws, false, errMessage(err));
3983
5455
  }
3984
5456
  break;
3985
5457
  }
@@ -3998,7 +5470,7 @@ async function startWebUI(opts = {}) {
3998
5470
  };
3999
5471
  broadcast(clients, { type: "context.repaired", payload });
4000
5472
  const removed = payload.removedToolUses.length + payload.removedToolResults.length + payload.removedMessages;
4001
- sendResult(
5473
+ sendResult2(
4002
5474
  ws,
4003
5475
  true,
4004
5476
  removed > 0 ? `Context repaired: removed ${removed} orphan protocol item(s)` : "Context repair found no orphan protocol blocks"
@@ -4032,14 +5504,14 @@ async function startWebUI(opts = {}) {
4032
5504
  );
4033
5505
  const custom = customModes.find((m) => m.id === id);
4034
5506
  if (!custom) {
4035
- sendResult(ws, false, `Unknown context mode "${id}"`);
5507
+ sendResult2(ws, false, `Unknown context mode "${id}"`);
4036
5508
  break;
4037
5509
  }
4038
5510
  policy = custom;
4039
5511
  }
4040
5512
  context.meta["contextWindowMode"] = policy.id;
4041
5513
  context.meta["contextWindowPolicy"] = policy;
4042
- sendResult(ws, true, `Context mode switched to ${policy.id}`);
5514
+ sendResult2(ws, true, `Context mode switched to ${policy.id}`);
4043
5515
  broadcast(clients, {
4044
5516
  type: "context.mode.changed",
4045
5517
  payload: { id: policy.id, name: policy.name, policy }
@@ -4059,7 +5531,7 @@ async function startWebUI(opts = {}) {
4059
5531
  aggressiveOn: "soft",
4060
5532
  targetLoad: 0.65
4061
5533
  });
4062
- sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
5534
+ sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
4063
5535
  break;
4064
5536
  }
4065
5537
  case "context.mode.update": {
@@ -4075,7 +5547,7 @@ async function startWebUI(opts = {}) {
4075
5547
  preserveK: payload.preserveK,
4076
5548
  eliseThreshold: payload.eliseThreshold
4077
5549
  });
4078
- sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
5550
+ sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
4079
5551
  break;
4080
5552
  }
4081
5553
  case "context.mode.delete": {
@@ -4085,7 +5557,7 @@ async function startWebUI(opts = {}) {
4085
5557
  context.meta["contextWindowPolicy"] = resolveContextWindowPolicy({}, DEFAULT_CONTEXT_WINDOW_MODE_ID);
4086
5558
  }
4087
5559
  const result = customModeStore.remove(id);
4088
- sendResult(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
5560
+ sendResult2(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
4089
5561
  break;
4090
5562
  }
4091
5563
  case "providers.list": {
@@ -4132,27 +5604,17 @@ async function startWebUI(opts = {}) {
4132
5604
  }
4133
5605
  case "provider.models": {
4134
5606
  const providerId = msg.payload.providerId;
4135
- const provider2 = await modelsRegistry.getProvider(providerId);
4136
- if (provider2) {
4137
- send(ws, {
4138
- type: "provider.models",
4139
- payload: {
4140
- provider: providerId,
4141
- models: provider2.models.map((m) => ({
4142
- id: m.id,
4143
- name: m.name,
4144
- releaseDate: m.release_date,
4145
- contextWindow: m.limit?.context,
4146
- inputCost: m.cost?.input,
4147
- outputCost: m.cost?.output,
4148
- capabilities: [
4149
- ...m.tool_call ? ["tools"] : [],
4150
- ...m.reasoning ? ["reasoning"] : []
4151
- ]
4152
- }))
4153
- }
4154
- });
4155
- }
5607
+ const saved = await providerHandlers.loadConfigProviders();
5608
+ const cfg = saved[providerId];
5609
+ const catalogId = cfg?.type && cfg.type !== providerId ? cfg.type : providerId;
5610
+ const provider2 = await modelsRegistry.getProvider(catalogId);
5611
+ send(ws, {
5612
+ type: "provider.models",
5613
+ payload: {
5614
+ provider: providerId,
5615
+ models: resolveProviderModelList(cfg?.models, provider2)
5616
+ }
5617
+ });
4156
5618
  break;
4157
5619
  }
4158
5620
  case "model.switch": {
@@ -4166,14 +5628,15 @@ async function startWebUI(opts = {}) {
4166
5628
  context.provider = newProv;
4167
5629
  updateAutoCompactionMaxContext?.(newProv);
4168
5630
  try {
4169
- configWriteLock = configWriteLock.then(async () => {
4170
- const raw = await fs7.readFile(globalConfigPath, "utf8");
5631
+ const next = configWriteLock.then(async () => {
5632
+ const raw = await fs9.readFile(globalConfigPath, "utf8");
4171
5633
  const parsed = JSON.parse(raw);
4172
5634
  parsed.provider = newProvider;
4173
5635
  parsed.model = newModel;
4174
5636
  await atomicWrite5(globalConfigPath, JSON.stringify(parsed, null, 2));
4175
5637
  });
4176
- await configWriteLock;
5638
+ configWriteLock = next.then(() => void 0, () => void 0);
5639
+ await next;
4177
5640
  } catch (err) {
4178
5641
  console.warn(JSON.stringify({
4179
5642
  level: "warn",
@@ -4326,13 +5789,13 @@ async function startWebUI(opts = {}) {
4326
5789
  const { id } = msg.payload;
4327
5790
  try {
4328
5791
  if (id === session.id) {
4329
- sendResult(ws, false, "Cannot delete the active session");
5792
+ sendResult2(ws, false, "Cannot delete the active session");
4330
5793
  break;
4331
5794
  }
4332
5795
  await sessionStore.delete(id);
4333
- sendResult(ws, true, `Session ${id} deleted`);
5796
+ sendResult2(ws, true, `Session ${id} deleted`);
4334
5797
  } catch (err) {
4335
- sendResult(ws, false, errMessage(err));
5798
+ sendResult2(ws, false, errMessage(err));
4336
5799
  }
4337
5800
  break;
4338
5801
  }
@@ -4340,7 +5803,7 @@ async function startWebUI(opts = {}) {
4340
5803
  const { id } = msg.payload;
4341
5804
  try {
4342
5805
  if (id === session.id) {
4343
- sendResult(ws, false, "Session is already active");
5806
+ sendResult2(ws, false, "Session is already active");
4344
5807
  break;
4345
5808
  }
4346
5809
  const resumed = await sessionStore.resume(id);
@@ -4370,14 +5833,14 @@ async function startWebUI(opts = {}) {
4370
5833
  replayUsage: resumed.data.usage
4371
5834
  }
4372
5835
  });
4373
- sendResult(ws, true, `Resumed session ${id}`);
5836
+ sendResult2(ws, true, `Resumed session ${id}`);
4374
5837
  } catch (err) {
4375
- sendResult(ws, false, errMessage(err));
5838
+ sendResult2(ws, false, errMessage(err));
4376
5839
  }
4377
5840
  break;
4378
5841
  }
4379
5842
  case "session.save": {
4380
- sendResult(ws, true, `Session ${session.id} is auto-saved`);
5843
+ sendResult2(ws, true, `Session ${session.id} is auto-saved`);
4381
5844
  break;
4382
5845
  }
4383
5846
  case "tools.list": {
@@ -4400,6 +5863,28 @@ async function startWebUI(opts = {}) {
4400
5863
  return handleMemoryRemember(ws, msg, memoryStore);
4401
5864
  case "memory.forget":
4402
5865
  return handleMemoryForget(ws, msg, memoryStore);
5866
+ // ── MCP operations — delegated to shared handlers (mcp-handlers.ts),
5867
+ // backed by the live MCPRegistry constructed above. ──
5868
+ case "mcp.list":
5869
+ return handleMcpList(ws, msg, globalConfigPath, mcpRegistry);
5870
+ case "mcp.add":
5871
+ return handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry);
5872
+ case "mcp.remove":
5873
+ return handleMcpRemove(ws, msg, globalConfigPath, mcpRegistry);
5874
+ case "mcp.update":
5875
+ return handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry);
5876
+ case "mcp.wake":
5877
+ return handleMcpWake(ws, msg, globalConfigPath, mcpRegistry);
5878
+ case "mcp.sleep":
5879
+ return handleMcpSleep(ws, msg, globalConfigPath, mcpRegistry);
5880
+ case "mcp.discover":
5881
+ return handleMcpDiscover(ws, msg, globalConfigPath, mcpRegistry);
5882
+ case "mcp.enable":
5883
+ return handleMcpEnable(ws, msg, globalConfigPath, mcpRegistry);
5884
+ case "mcp.disable":
5885
+ return handleMcpDisable(ws, msg, globalConfigPath, mcpRegistry);
5886
+ case "mcp.restart":
5887
+ return handleMcpRestart(ws, msg, globalConfigPath, mcpRegistry);
4403
5888
  case "skills.list": {
4404
5889
  if (!skillLoader) {
4405
5890
  send(ws, { type: "skills.list", payload: { skills: [], enabled: false } });
@@ -4409,6 +5894,18 @@ async function startWebUI(opts = {}) {
4409
5894
  const manifests = await skillLoader.list();
4410
5895
  const entries = await skillLoader.listEntries();
4411
5896
  const byName = new Map(entries.map((e) => [e.name, e]));
5897
+ const sourceUrlsByName = /* @__PURE__ */ new Map();
5898
+ const refsByName = /* @__PURE__ */ new Map();
5899
+ if (skillInstaller) {
5900
+ try {
5901
+ const installed = await skillInstaller.listInstalled();
5902
+ for (const entry of installed) {
5903
+ sourceUrlsByName.set(entry.name, entry.source);
5904
+ refsByName.set(entry.name, entry.ref);
5905
+ }
5906
+ } catch {
5907
+ }
5908
+ }
4412
5909
  send(ws, {
4413
5910
  type: "skills.list",
4414
5911
  payload: {
@@ -4418,6 +5915,8 @@ async function startWebUI(opts = {}) {
4418
5915
  description: m.description,
4419
5916
  version: m.version ?? "",
4420
5917
  source: m.source,
5918
+ sourceUrl: sourceUrlsByName.get(m.name) ?? "",
5919
+ ref: refsByName.get(m.name) ?? "",
4421
5920
  path: m.path,
4422
5921
  trigger: byName.get(m.name)?.trigger ?? "",
4423
5922
  scope: byName.get(m.name)?.scope ?? []
@@ -4436,6 +5935,261 @@ async function startWebUI(opts = {}) {
4436
5935
  }
4437
5936
  break;
4438
5937
  }
5938
+ case "skills.content": {
5939
+ if (!skillLoader) {
5940
+ send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skills not enabled" } });
5941
+ break;
5942
+ }
5943
+ const contentPayload = msg.payload;
5944
+ if (!contentPayload?.name) {
5945
+ send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skill name is required" } });
5946
+ break;
5947
+ }
5948
+ try {
5949
+ const { name: name2, source } = contentPayload;
5950
+ const entries = await skillLoader.listEntries();
5951
+ const entry = entries.find((e) => e.name.toLowerCase() === name2.toLowerCase());
5952
+ if (!entry) {
5953
+ send(ws, { type: "skills.content", payload: { name: name2, body: "", path: "", source, relatedFiles: [], references: [], error: `Skill "${name2}" not found` } });
5954
+ break;
5955
+ }
5956
+ const body = await skillLoader.readBody(name2);
5957
+ const skillDir = path9.dirname(entry.path);
5958
+ let relatedFiles = [];
5959
+ try {
5960
+ const files = await fs9.readdir(skillDir);
5961
+ relatedFiles = files.filter((f) => f !== path9.basename(entry.path)).map((f) => path9.join(skillDir, f));
5962
+ } catch {
5963
+ }
5964
+ const refs = [];
5965
+ for (const e of entries) {
5966
+ if (e.name.toLowerCase() === name2.toLowerCase()) continue;
5967
+ try {
5968
+ const content = await skillLoader.readBody(e.name);
5969
+ if (content.toLowerCase().includes(name2.toLowerCase())) {
5970
+ refs.push(e.name);
5971
+ }
5972
+ } catch {
5973
+ }
5974
+ }
5975
+ send(ws, { type: "skills.content", payload: { name: name2, body, path: entry.path, source, relatedFiles, references: refs } });
5976
+ } catch (err) {
5977
+ send(ws, { type: "skills.content", payload: { name: contentPayload.name, body: "", path: "", source: contentPayload.source, relatedFiles: [], references: [], error: errMessage(err) } });
5978
+ }
5979
+ break;
5980
+ }
5981
+ case "skills.install": {
5982
+ if (!skillInstaller) {
5983
+ send(ws, { type: "skills.installed", payload: { success: false, error: "Skills not enabled" } });
5984
+ break;
5985
+ }
5986
+ const installPayload = msg.payload;
5987
+ if (!installPayload?.ref?.trim()) {
5988
+ send(ws, { type: "skills.installed", payload: { success: false, error: "Skill reference is required (e.g. owner/repo or https://github.com/owner/repo)" } });
5989
+ break;
5990
+ }
5991
+ try {
5992
+ const results = await skillInstaller.install(installPayload.ref.trim(), { global: installPayload.global });
5993
+ send(ws, {
5994
+ type: "skills.installed",
5995
+ payload: {
5996
+ success: true,
5997
+ results,
5998
+ error: null
5999
+ }
6000
+ });
6001
+ } catch (err) {
6002
+ send(ws, {
6003
+ type: "skills.installed",
6004
+ payload: {
6005
+ success: false,
6006
+ error: errMessage(err)
6007
+ }
6008
+ });
6009
+ }
6010
+ break;
6011
+ }
6012
+ case "skills.uninstall": {
6013
+ if (!skillInstaller) {
6014
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skills not enabled" } });
6015
+ break;
6016
+ }
6017
+ const uninstallPayload = msg.payload;
6018
+ if (!uninstallPayload?.name?.trim()) {
6019
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skill name is required" } });
6020
+ break;
6021
+ }
6022
+ try {
6023
+ await skillInstaller.uninstall(uninstallPayload.name.trim(), { global: uninstallPayload.global });
6024
+ send(ws, { type: "skills.uninstalled", payload: { success: true, error: null } });
6025
+ } catch (err) {
6026
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: errMessage(err) } });
6027
+ }
6028
+ break;
6029
+ }
6030
+ case "skills.update": {
6031
+ if (!skillInstaller) {
6032
+ send(ws, { type: "skills.updated", payload: { success: false, error: "Skills not enabled" } });
6033
+ break;
6034
+ }
6035
+ const updatePayload = msg.payload;
6036
+ try {
6037
+ const result = await skillInstaller.update(updatePayload?.name, { global: updatePayload?.global });
6038
+ send(ws, {
6039
+ type: "skills.updated",
6040
+ payload: {
6041
+ success: true,
6042
+ error: null,
6043
+ updated: result.updated,
6044
+ unchanged: result.unchanged,
6045
+ errors: result.errors
6046
+ }
6047
+ });
6048
+ } catch (err) {
6049
+ send(ws, { type: "skills.updated", payload: { success: false, error: errMessage(err) } });
6050
+ }
6051
+ break;
6052
+ }
6053
+ case "skills.create": {
6054
+ const createPayload = msg.payload;
6055
+ if (!createPayload?.name?.trim()) {
6056
+ send(ws, { type: "skills.created", payload: { success: false, error: "Skill name is required" } });
6057
+ break;
6058
+ }
6059
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(createPayload.name.trim())) {
6060
+ send(ws, { type: "skills.created", payload: { success: false, error: "Skill name must be kebab-case (e.g. my-new-skill)" } });
6061
+ break;
6062
+ }
6063
+ if (!createPayload?.description?.trim()) {
6064
+ send(ws, { type: "skills.created", payload: { success: false, error: "Description/trigger is required" } });
6065
+ break;
6066
+ }
6067
+ try {
6068
+ const targetDir = createPayload.scope === "global" ? path9.join(wstackGlobalRoot2(), "skills", createPayload.name.trim()) : path9.join(projectRoot, ".wrongstack", "skills", createPayload.name.trim());
6069
+ try {
6070
+ await fs9.access(targetDir);
6071
+ send(ws, { type: "skills.created", payload: { success: false, error: `Skill "${createPayload.name}" already exists` } });
6072
+ break;
6073
+ } catch {
6074
+ }
6075
+ await fs9.mkdir(targetDir, { recursive: true });
6076
+ const lines = createPayload.description.trim().split("\n");
6077
+ const firstLine = lines[0].trim();
6078
+ const bodyLines = lines.slice(1).map((l) => l.trim()).filter(Boolean);
6079
+ const descriptionText = firstLine + (bodyLines.length > 0 ? `
6080
+ ${bodyLines.join("\n")}` : "");
6081
+ const trigger = bodyLines.find((l) => l.toLowerCase().startsWith("triggers:")) ?? "";
6082
+ const skillContent = [
6083
+ "---",
6084
+ `name: ${createPayload.name.trim()}`,
6085
+ "description: |",
6086
+ ` ${descriptionText.replace(/\n/g, "\n ")}`,
6087
+ `version: 1.0.0`,
6088
+ "---",
6089
+ "",
6090
+ `# ${createPayload.name.trim().split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}`,
6091
+ "",
6092
+ "## Overview",
6093
+ "",
6094
+ firstLine,
6095
+ "",
6096
+ ...bodyLines.length > 0 ? bodyLines.filter((l) => !l.toLowerCase().startsWith("triggers:")) : [],
6097
+ "",
6098
+ "## Rules",
6099
+ "- TODO: add your first rule",
6100
+ "",
6101
+ "## Patterns",
6102
+ "### Do",
6103
+ "```ts",
6104
+ "// TODO: add a good example",
6105
+ "```",
6106
+ "",
6107
+ "### Don't",
6108
+ "```ts",
6109
+ "// TODO: add a bad example",
6110
+ "```",
6111
+ "",
6112
+ "## Workflow",
6113
+ "1. TODO: describe step one",
6114
+ "2. TODO: describe step two",
6115
+ "",
6116
+ trigger ? `
6117
+ ${trigger}
6118
+ ` : "",
6119
+ "## Skills in scope",
6120
+ "- `bug-hunter` \u2014 for systematic bug detection patterns",
6121
+ "- `output-standards` \u2014 for standardized `<next_steps>` formatting"
6122
+ ].join("\n");
6123
+ await fs9.writeFile(path9.join(targetDir, "SKILL.md"), skillContent, "utf-8");
6124
+ send(ws, {
6125
+ type: "skills.created",
6126
+ payload: {
6127
+ success: true,
6128
+ error: null,
6129
+ skill: { name: createPayload.name.trim(), path: path9.join(targetDir, "SKILL.md"), scope: createPayload.scope }
6130
+ }
6131
+ });
6132
+ } catch (err) {
6133
+ send(ws, { type: "skills.created", payload: { success: false, error: errMessage(err) } });
6134
+ }
6135
+ break;
6136
+ }
6137
+ case "skills.edit": {
6138
+ if (!skillLoader) {
6139
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skills not enabled" } });
6140
+ break;
6141
+ }
6142
+ const editPayload = msg.payload;
6143
+ if (!editPayload?.name?.trim()) {
6144
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skill name is required" } });
6145
+ break;
6146
+ }
6147
+ if (!editPayload?.body) {
6148
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skill body is required" } });
6149
+ break;
6150
+ }
6151
+ try {
6152
+ const entries = await skillLoader.listEntries();
6153
+ const entry = entries.find((e) => e.name.toLowerCase() === editPayload.name.toLowerCase());
6154
+ if (!entry) {
6155
+ send(ws, { type: "skills.edited", payload: { success: false, error: `Skill "${editPayload.name}" not found` } });
6156
+ break;
6157
+ }
6158
+ if (entry.scope.includes("bundled")) {
6159
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Bundled skills cannot be edited" } });
6160
+ break;
6161
+ }
6162
+ await fs9.writeFile(entry.path, editPayload.body, "utf-8");
6163
+ send(ws, { type: "skills.edited", payload: { success: true, error: null } });
6164
+ } catch (err) {
6165
+ send(ws, { type: "skills.edited", payload: { success: false, error: errMessage(err) } });
6166
+ }
6167
+ break;
6168
+ }
6169
+ case "skills.export": {
6170
+ if (!skillLoader) {
6171
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: "Skills not enabled" } });
6172
+ break;
6173
+ }
6174
+ try {
6175
+ const entries = await skillLoader.listEntries();
6176
+ const zip = new JSZip2();
6177
+ for (const entry of entries) {
6178
+ try {
6179
+ const body = await skillLoader.readBody(entry.name);
6180
+ const safeName = entry.name.replace(/\//g, "_");
6181
+ zip.file(`${safeName}/SKILL.md`, body);
6182
+ } catch {
6183
+ }
6184
+ }
6185
+ const zipBuffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
6186
+ const zipBase64 = zipBuffer.toString("base64");
6187
+ send(ws, { type: "skills.exported", payload: { zipBase64, skillCount: entries.length, error: void 0 } });
6188
+ } catch (err) {
6189
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: errMessage(err) } });
6190
+ }
6191
+ break;
6192
+ }
4439
6193
  case "diag.get": {
4440
6194
  const usage = tokenCounter.total();
4441
6195
  send(ws, {
@@ -4463,194 +6217,84 @@ async function startWebUI(opts = {}) {
4463
6217
  break;
4464
6218
  }
4465
6219
  case "todos.get": {
4466
- send(ws, {
4467
- type: "todos.updated",
4468
- payload: { todos: [...context.todos] }
4469
- });
6220
+ const ctx = {
6221
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6222
+ send: (w, m) => send(w, m),
6223
+ broadcast: (m) => broadcast(clients, m)
6224
+ };
6225
+ handleTodosGet(ctx, ws);
4470
6226
  break;
4471
6227
  }
4472
6228
  case "todos.clear": {
4473
- context.state.replaceTodos([]);
4474
- sendResult(ws, true, "Todos cleared");
4475
- broadcast(clients, { type: "todos.updated", payload: { todos: [] } });
6229
+ const ctx = {
6230
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6231
+ send: (w, m) => send(w, m),
6232
+ broadcast: (m) => broadcast(clients, m)
6233
+ };
6234
+ handleTodosClear(ctx, ws);
4476
6235
  break;
4477
6236
  }
4478
6237
  case "todos.remove": {
4479
- const payload = msg.payload;
4480
- if (!payload) {
4481
- sendResult(ws, false, "Missing id or index");
4482
- break;
4483
- }
4484
- const { id, index } = payload;
4485
- let targetIdx = -1;
4486
- if (typeof id === "string") {
4487
- targetIdx = context.todos.findIndex((t) => t.id === id);
4488
- } else if (typeof index === "number" && index > 0) {
4489
- targetIdx = index - 1;
4490
- }
4491
- if (targetIdx < 0 || !context.todos[targetIdx]) {
4492
- sendResult(ws, false, "Todo not found");
4493
- break;
4494
- }
4495
- const removed = expectDefined2(context.todos[targetIdx]);
4496
- const next = [...context.todos.slice(0, targetIdx), ...context.todos.slice(targetIdx + 1)];
4497
- context.state.replaceTodos(next);
4498
- sendResult(ws, true, `Removed: ${removed.content}`);
4499
- broadcast(clients, { type: "todos.updated", payload: { todos: next } });
6238
+ const ctx = {
6239
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6240
+ send: (w, m) => send(w, m),
6241
+ broadcast: (m) => broadcast(clients, m)
6242
+ };
6243
+ handleTodosRemove(ctx, ws, msg.payload);
4500
6244
  break;
4501
6245
  }
4502
6246
  case "tasks.get": {
4503
- const taskPath = context.meta["task.path"];
4504
- if (typeof taskPath === "string" && taskPath) {
4505
- try {
4506
- const { loadTasks } = await import("@wrongstack/core");
4507
- const file = await loadTasks(taskPath);
4508
- send(ws, {
4509
- type: "tasks.updated",
4510
- payload: { tasks: file?.tasks ?? [] }
4511
- });
4512
- } catch {
4513
- send(ws, { type: "tasks.updated", payload: { tasks: [] } });
4514
- }
4515
- } else {
4516
- send(ws, { type: "tasks.updated", payload: { tasks: [], error: "Task storage not configured." } });
4517
- }
6247
+ const ctx = {
6248
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6249
+ send: (w, m) => send(w, m),
6250
+ broadcast: (m) => broadcast(clients, m)
6251
+ };
6252
+ await handleTasksGet(ctx, ws);
4518
6253
  break;
4519
6254
  }
4520
6255
  case "plan.get": {
4521
- const planPath = context.meta["plan.path"];
4522
- if (typeof planPath === "string" && planPath) {
4523
- try {
4524
- const { loadPlan } = await import("@wrongstack/core");
4525
- const plan = await loadPlan(planPath);
4526
- send(ws, {
4527
- type: "plan.updated",
4528
- payload: {
4529
- plan: plan ?? {
4530
- version: 1,
4531
- sessionId: session.id,
4532
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4533
- items: []
4534
- }
4535
- }
4536
- });
4537
- } catch {
4538
- send(ws, {
4539
- type: "plan.updated",
4540
- payload: {
4541
- plan: {
4542
- version: 1,
4543
- sessionId: session.id,
4544
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4545
- items: []
4546
- }
4547
- }
4548
- });
4549
- }
4550
- } else {
4551
- send(ws, {
4552
- type: "plan.updated",
4553
- payload: { plan: null, error: "Plan storage is not configured for this session." }
4554
- });
4555
- }
6256
+ const ctx = {
6257
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6258
+ send: (w, m) => send(w, m),
6259
+ broadcast: (m) => broadcast(clients, m)
6260
+ };
6261
+ await handlePlanGet(ctx, ws);
4556
6262
  break;
4557
6263
  }
4558
6264
  case "plan.template_use": {
4559
- const { template } = msg.payload;
4560
- const planPath = context.meta["plan.path"];
4561
- if (typeof planPath !== "string" || !planPath) {
4562
- sendResult(ws, false, "Plan storage is not configured for this session.");
4563
- break;
4564
- }
4565
- try {
4566
- const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
4567
- const tpl = getPlanTemplate(template);
4568
- if (!tpl) {
4569
- sendResult(ws, false, `Unknown template "${template}".`);
4570
- break;
4571
- }
4572
- let plan = await loadPlan(planPath) ?? emptyPlan(session.id);
4573
- for (const item of tpl.items) {
4574
- ({ plan } = addPlanItem(plan, item.title, item.details));
4575
- }
4576
- await savePlan(planPath, plan);
4577
- sendResult(ws, true, `Applied template "${tpl.name}" \u2014 ${tpl.items.length} items added.`);
4578
- broadcast(clients, {
4579
- type: "plan.updated",
4580
- payload: { plan }
4581
- });
4582
- } catch (err) {
4583
- sendResult(ws, false, errMessage(err));
4584
- }
6265
+ const ctx = {
6266
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6267
+ send: (w, m) => send(w, m),
6268
+ broadcast: (m) => broadcast(clients, m)
6269
+ };
6270
+ await handlePlanTemplateUse(ctx, ws, msg.payload.template);
4585
6271
  break;
4586
6272
  }
4587
6273
  case "todo.update": {
4588
- const payload = msg.payload;
4589
- const idx = context.todos.findIndex((t) => t.id === payload.id);
4590
- if (idx === -1) {
4591
- sendResult(ws, false, "Todo not found");
4592
- break;
4593
- }
4594
- const next = [...context.todos];
4595
- const existing = expectDefined2(next[idx]);
4596
- next[idx] = {
4597
- ...existing,
4598
- status: payload.status ?? existing.status,
4599
- activeForm: payload.activeForm !== void 0 ? payload.activeForm : existing.activeForm
6274
+ const ctx = {
6275
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6276
+ send: (w, m) => send(w, m),
6277
+ broadcast: (m) => broadcast(clients, m)
4600
6278
  };
4601
- context.state.replaceTodos(next);
4602
- sendResult(ws, true, `Todo "${existing.content}" updated`);
4603
- broadcast(clients, { type: "todos.updated", payload: { todos: next } });
6279
+ handleTodoUpdate(ctx, ws, msg.payload);
4604
6280
  break;
4605
6281
  }
4606
6282
  case "task.update": {
4607
- const payload = msg.payload;
4608
- const taskPath = context.meta["task.path"];
4609
- if (typeof taskPath !== "string" || !taskPath) {
4610
- sendResult(ws, false, "Task storage not configured.");
4611
- break;
4612
- }
4613
- try {
4614
- const { mutateTasks } = await import("@wrongstack/core");
4615
- const file = await mutateTasks(taskPath, session.id, async (f) => {
4616
- const task = f.tasks.find((t) => t.id === payload.id);
4617
- if (!task) return f;
4618
- task.status = payload.status;
4619
- task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4620
- return f;
4621
- });
4622
- sendResult(ws, true, `Task status updated to "${payload.status}".`);
4623
- broadcast(clients, { type: "tasks.updated", payload: { tasks: file.tasks } });
4624
- } catch (err) {
4625
- sendResult(ws, false, errMessage(err));
4626
- }
6283
+ const ctx = {
6284
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6285
+ send: (w, m) => send(w, m),
6286
+ broadcast: (m) => broadcast(clients, m)
6287
+ };
6288
+ await handleTaskUpdate(ctx, ws, msg.payload);
4627
6289
  break;
4628
6290
  }
4629
6291
  case "plan.item.update": {
4630
- const payload = msg.payload;
4631
- const planPath = context.meta["plan.path"];
4632
- if (typeof planPath !== "string" || !planPath) {
4633
- sendResult(ws, false, "Plan storage is not configured for this session.");
4634
- break;
4635
- }
4636
- try {
4637
- const { mutatePlan, setPlanItemStatus } = await import("@wrongstack/core");
4638
- let changed = false;
4639
- const plan = await mutatePlan(planPath, session.id, async (p) => {
4640
- const before = p.updatedAt;
4641
- const updated = setPlanItemStatus(p, payload.target, payload.status);
4642
- changed = updated.updatedAt !== before;
4643
- return updated;
4644
- });
4645
- if (!changed) {
4646
- sendResult(ws, false, `No plan item matched "${payload.target}".`);
4647
- break;
4648
- }
4649
- sendResult(ws, true, `Plan item status updated to "${payload.status}".`);
4650
- broadcast(clients, { type: "plan.updated", payload: { plan } });
4651
- } catch (err) {
4652
- sendResult(ws, false, errMessage(err));
4653
- }
6292
+ const ctx = {
6293
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6294
+ send: (w, m) => send(w, m),
6295
+ broadcast: (m) => broadcast(clients, m)
6296
+ };
6297
+ await handlePlanItemUpdate(ctx, ws, msg.payload);
4654
6298
  break;
4655
6299
  }
4656
6300
  // ── File operations — delegated to shared handlers (file-handlers.ts) ──
@@ -4720,13 +6364,13 @@ async function startWebUI(opts = {}) {
4720
6364
  provider: config.provider,
4721
6365
  model: config.model
4722
6366
  });
4723
- sendResult(ws, true, `Switched to mode "${id}"`);
6367
+ sendResult2(ws, true, `Switched to mode "${id}"`);
4724
6368
  broadcast(clients, {
4725
6369
  type: "session.start",
4726
6370
  payload: { ...await sessionStartPayload() }
4727
6371
  });
4728
6372
  } catch (err) {
4729
- sendResult(ws, false, errMessage(err));
6373
+ sendResult2(ws, false, errMessage(err));
4730
6374
  }
4731
6375
  break;
4732
6376
  }
@@ -4780,13 +6424,13 @@ async function startWebUI(opts = {}) {
4780
6424
  const { getProcessRegistry } = await import("@wrongstack/tools");
4781
6425
  const proc = getProcessRegistry().get(pid);
4782
6426
  if (proc?.protected) {
4783
- sendResult(ws, false, `Cannot kill protected process (PID ${pid})`);
6427
+ sendResult2(ws, false, `Cannot kill protected process (PID ${pid})`);
4784
6428
  break;
4785
6429
  }
4786
6430
  getProcessRegistry().kill(pid);
4787
- sendResult(ws, true, `Killed PID ${pid}`);
6431
+ sendResult2(ws, true, `Killed PID ${pid}`);
4788
6432
  } catch (err) {
4789
- sendResult(ws, false, errMessage(err));
6433
+ sendResult2(ws, false, errMessage(err));
4790
6434
  }
4791
6435
  break;
4792
6436
  }
@@ -4794,47 +6438,33 @@ async function startWebUI(opts = {}) {
4794
6438
  try {
4795
6439
  const { getProcessRegistry } = await import("@wrongstack/tools");
4796
6440
  getProcessRegistry().killAll();
4797
- sendResult(ws, true, "All processes killed");
6441
+ sendResult2(ws, true, "All processes killed");
4798
6442
  } catch (err) {
4799
- sendResult(ws, false, errMessage(err));
6443
+ sendResult2(ws, false, errMessage(err));
4800
6444
  }
4801
6445
  break;
4802
6446
  }
4803
6447
  case "git.info": {
4804
- const cwd = projectRoot;
4805
- const execFile = (cmd, args) => new Promise((resolve5) => {
4806
- import("child_process").then(({ execFile: ef }) => {
4807
- ef(cmd, args, { cwd, timeout: 3e3 }, (err, stdout) => {
4808
- resolve5(err ? "" : stdout.trim());
4809
- });
4810
- });
4811
- });
4812
- const [branchRaw, diffRaw, statusRaw, upstreamRaw] = await Promise.all([
4813
- execFile("git", ["branch", "--show-current"]),
4814
- execFile("git", ["diff", "--stat"]),
4815
- execFile("git", ["status", "--porcelain"]),
4816
- execFile("git", ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
4817
- ]);
4818
- const branch = branchRaw || "(detached)";
4819
- const diffMatch = /\+\s*(\d+)\s*deletion/i.exec(diffRaw);
4820
- const addMatch = /(\d+)\s*insertion/i.exec(diffRaw) ?? /(\d+)\s*addition/i.exec(diffRaw);
4821
- const delMatch = /\+\s*(\d+)\s*deletion/i.exec(diffRaw);
4822
- const added = addMatch ? Number(addMatch[1]) : 0;
4823
- const deleted = delMatch ? Number(delMatch[1]) : 0;
4824
- const untracked = statusRaw.split("\n").filter((l) => l.startsWith("??")).length;
4825
- const [aheadRaw, behindRaw] = (upstreamRaw || "0 0").split(" ");
4826
- const ahead = Number(aheadRaw) || 0;
4827
- const behind = Number(behindRaw) || 0;
4828
- send(ws, {
4829
- type: "git.info",
4830
- payload: { branch, added, deleted, untracked, ahead, behind }
4831
- });
6448
+ await handleGitInfo(ws, projectRoot);
6449
+ break;
6450
+ }
6451
+ case "git.changes": {
6452
+ await handleGitChanges(ws, projectRoot);
6453
+ break;
6454
+ }
6455
+ case "git.diff": {
6456
+ await handleGitDiff(ws, projectRoot, String(msg.payload?.path ?? ""));
6457
+ break;
6458
+ }
6459
+ case "webui.shutdown": {
6460
+ console.log("[WebUI] Shutdown requested from client");
6461
+ process.kill(process.pid, "SIGINT");
4832
6462
  break;
4833
6463
  }
4834
6464
  case "goal.get": {
4835
6465
  try {
4836
- const goalPath = path8.join(projectRoot, ".wrongstack", "goal.json");
4837
- const raw = await fs7.readFile(goalPath, "utf8");
6466
+ const goalPath = resolveWstackPaths({ projectRoot }).projectGoal;
6467
+ const raw = await fs9.readFile(goalPath, "utf8");
4838
6468
  const goal = JSON.parse(raw);
4839
6469
  broadcast(clients, { type: "goal.updated", payload: goal });
4840
6470
  } catch {
@@ -4845,7 +6475,7 @@ async function startWebUI(opts = {}) {
4845
6475
  case "autonomy.switch": {
4846
6476
  const { mode } = msg.payload;
4847
6477
  context.meta["autonomy"] = mode;
4848
- sendResult(ws, true, `Autonomy mode set to "${mode}"`);
6478
+ sendResult2(ws, true, `Autonomy mode set to "${mode}"`);
4849
6479
  broadcast(clients, { type: "prefs.updated", payload: { autonomy: mode } });
4850
6480
  void persistPrefsToConfig({ autonomy: mode });
4851
6481
  break;
@@ -4894,7 +6524,7 @@ async function startWebUI(opts = {}) {
4894
6524
  try {
4895
6525
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4896
6526
  const rewinder = new DefaultSessionRewinder(
4897
- path8.join(projectRoot, ".wrongstack", "sessions"),
6527
+ path9.join(projectRoot, ".wrongstack", "sessions"),
4898
6528
  projectRoot
4899
6529
  );
4900
6530
  const checkpoints = await rewinder.listCheckpoints(session.id);
@@ -4915,18 +6545,18 @@ async function startWebUI(opts = {}) {
4915
6545
  try {
4916
6546
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4917
6547
  const rewinder = new DefaultSessionRewinder(
4918
- path8.join(projectRoot, ".wrongstack", "sessions"),
6548
+ path9.join(projectRoot, ".wrongstack", "sessions"),
4919
6549
  projectRoot
4920
6550
  );
4921
6551
  await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
4922
6552
  await context.session.truncateToCheckpoint(checkpointIndex);
4923
- sendResult(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
6553
+ sendResult2(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
4924
6554
  broadcast(clients, {
4925
6555
  type: "session.start",
4926
6556
  payload: { ...await sessionStartPayload(), reset: true }
4927
6557
  });
4928
6558
  } catch (err) {
4929
- sendResult(ws, false, errMessage(err));
6559
+ sendResult2(ws, false, errMessage(err));
4930
6560
  }
4931
6561
  break;
4932
6562
  }
@@ -4949,9 +6579,9 @@ async function startWebUI(opts = {}) {
4949
6579
  case "projects.add": {
4950
6580
  const { root: addRoot, name: displayName } = msg.payload;
4951
6581
  try {
4952
- const resolved = path8.resolve(addRoot);
4953
- await fs7.access(resolved);
4954
- const stat2 = await fs7.stat(resolved);
6582
+ const resolved = path9.resolve(addRoot);
6583
+ await fs9.access(resolved);
6584
+ const stat2 = await fs9.stat(resolved);
4955
6585
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4956
6586
  const manifest = await loadManifest(globalConfigPath);
4957
6587
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -4967,26 +6597,26 @@ async function startWebUI(opts = {}) {
4967
6597
  });
4968
6598
  break;
4969
6599
  }
4970
- const name = displayName?.trim() || path8.basename(resolved);
6600
+ const name2 = displayName?.trim() || path9.basename(resolved);
4971
6601
  const slug = generateProjectSlug(resolved);
4972
6602
  await ensureProjectDataDir(slug, globalConfigPath);
4973
6603
  const now = (/* @__PURE__ */ new Date()).toISOString();
4974
- manifest.projects.push({ name, root: resolved, slug, lastSeen: now, createdAt: now });
6604
+ manifest.projects.push({ name: name2, root: resolved, slug, lastSeen: now, createdAt: now });
4975
6605
  await saveManifest(manifest, globalConfigPath);
4976
6606
  send(ws, {
4977
6607
  type: "projects.added",
4978
6608
  payload: {
4979
- name,
6609
+ name: name2,
4980
6610
  root: resolved,
4981
6611
  slug,
4982
- message: `Registered project "${name}"`
6612
+ message: `Registered project "${name2}"`
4983
6613
  }
4984
6614
  });
4985
6615
  } catch (err) {
4986
6616
  send(ws, {
4987
6617
  type: "projects.added",
4988
6618
  payload: {
4989
- name: path8.basename(addRoot),
6619
+ name: path9.basename(addRoot),
4990
6620
  root: addRoot,
4991
6621
  slug: "",
4992
6622
  message: errMessage(err)
@@ -4998,17 +6628,17 @@ async function startWebUI(opts = {}) {
4998
6628
  case "projects.select": {
4999
6629
  const { root: selRoot, name: selName } = msg.payload;
5000
6630
  try {
5001
- const resolved = path8.resolve(selRoot);
6631
+ const resolved = path9.resolve(selRoot);
5002
6632
  try {
5003
- await fs7.access(resolved);
5004
- const stat2 = await fs7.stat(resolved);
6633
+ await fs9.access(resolved);
6634
+ const stat2 = await fs9.stat(resolved);
5005
6635
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
5006
6636
  } catch (err) {
5007
6637
  send(ws, {
5008
6638
  type: "projects.selected",
5009
6639
  payload: {
5010
6640
  root: selRoot,
5011
- name: selName || path8.basename(selRoot),
6641
+ name: selName || path9.basename(selRoot),
5012
6642
  message: `Cannot switch: ${errMessage(err)}`
5013
6643
  }
5014
6644
  });
@@ -5020,10 +6650,10 @@ async function startWebUI(opts = {}) {
5020
6650
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
5021
6651
  entry.lastWorkingDir = resolved;
5022
6652
  } else {
5023
- const name = selName?.trim() || path8.basename(resolved);
6653
+ const name2 = selName?.trim() || path9.basename(resolved);
5024
6654
  const slug = generateProjectSlug(resolved);
5025
6655
  manifest.projects.push({
5026
- name,
6656
+ name: name2,
5027
6657
  root: resolved,
5028
6658
  slug,
5029
6659
  lastSeen: (/* @__PURE__ */ new Date()).toISOString(),
@@ -5061,13 +6691,13 @@ async function startWebUI(opts = {}) {
5061
6691
  });
5062
6692
  } catch {
5063
6693
  }
5064
- const newSessionsDir = path8.join(
5065
- path8.dirname(globalConfigPath),
6694
+ const newSessionsDir = path9.join(
6695
+ path9.dirname(globalConfigPath),
5066
6696
  "projects",
5067
6697
  switchSlug,
5068
6698
  "sessions"
5069
6699
  );
5070
- await fs7.mkdir(newSessionsDir, { recursive: true });
6700
+ await fs9.mkdir(newSessionsDir, { recursive: true });
5071
6701
  const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
5072
6702
  const oldSessionId = session.id;
5073
6703
  try {
@@ -5099,8 +6729,9 @@ async function startWebUI(opts = {}) {
5099
6729
  sessionId: session.id,
5100
6730
  projectSlug: switchSlug,
5101
6731
  projectRoot,
5102
- projectName: path8.basename(projectRoot),
6732
+ projectName: path9.basename(projectRoot),
5103
6733
  workingDir,
6734
+ clientType: "webui",
5104
6735
  pid: process.pid,
5105
6736
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
5106
6737
  });
@@ -5110,8 +6741,8 @@ async function startWebUI(opts = {}) {
5110
6741
  type: "projects.selected",
5111
6742
  payload: {
5112
6743
  root: resolved,
5113
- name: selName || path8.basename(resolved),
5114
- message: `Switched to ${selName || path8.basename(resolved)}`
6744
+ name: selName || path9.basename(resolved),
6745
+ message: `Switched to ${selName || path9.basename(resolved)}`
5115
6746
  }
5116
6747
  });
5117
6748
  broadcast(clients, {
@@ -5134,7 +6765,7 @@ async function startWebUI(opts = {}) {
5134
6765
  type: "projects.selected",
5135
6766
  payload: {
5136
6767
  root: selRoot,
5137
- name: selName || path8.basename(selRoot),
6768
+ name: selName || path9.basename(selRoot),
5138
6769
  message: errMessage(err)
5139
6770
  }
5140
6771
  });
@@ -5145,17 +6776,17 @@ async function startWebUI(opts = {}) {
5145
6776
  case "working_dir.set": {
5146
6777
  const { path: newPath } = msg.payload;
5147
6778
  try {
5148
- const resolved = path8.resolve(projectRoot, newPath);
5149
- if (!resolved.startsWith(projectRoot + path8.sep) && resolved !== projectRoot) {
5150
- sendResult(ws, false, `Path must stay inside the project root: ${projectRoot}`);
6779
+ const resolved = path9.resolve(projectRoot, newPath);
6780
+ if (!resolved.startsWith(projectRoot + path9.sep) && resolved !== projectRoot) {
6781
+ sendResult2(ws, false, `Path must stay inside the project root: ${projectRoot}`);
5151
6782
  break;
5152
6783
  }
5153
6784
  try {
5154
- await fs7.access(resolved);
5155
- const stat2 = await fs7.stat(resolved);
6785
+ await fs9.access(resolved);
6786
+ const stat2 = await fs9.stat(resolved);
5156
6787
  if (!stat2.isDirectory()) throw new Error("Not a directory");
5157
6788
  } catch {
5158
- sendResult(ws, false, `Directory not found or not accessible: ${resolved}`);
6789
+ sendResult2(ws, false, `Directory not found or not accessible: ${resolved}`);
5159
6790
  break;
5160
6791
  }
5161
6792
  workingDir = resolved;
@@ -5164,9 +6795,9 @@ async function startWebUI(opts = {}) {
5164
6795
  type: "working_dir.changed",
5165
6796
  payload: { cwd: resolved, projectRoot }
5166
6797
  });
5167
- sendResult(ws, true, `Working directory set to ${resolved}`);
6798
+ sendResult2(ws, true, `Working directory set to ${resolved}`);
5168
6799
  } catch (err) {
5169
- sendResult(ws, false, errMessage(err));
6800
+ sendResult2(ws, false, errMessage(err));
5170
6801
  }
5171
6802
  break;
5172
6803
  }
@@ -5176,31 +6807,31 @@ async function startWebUI(opts = {}) {
5176
6807
  msg.payload,
5177
6808
  logger
5178
6809
  );
5179
- sendResult(ws, result.success, result.message);
6810
+ sendResult2(ws, result.success, result.message);
5180
6811
  break;
5181
6812
  }
5182
6813
  // ── Mailbox operations — project-level inter-agent messaging ────
5183
6814
  case "mailbox.messages":
5184
6815
  return handleMailboxMessages(
5185
6816
  ws,
5186
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
6817
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
5187
6818
  msg.payload
5188
6819
  );
5189
6820
  case "mailbox.agents":
5190
6821
  return handleMailboxAgents(
5191
6822
  ws,
5192
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
6823
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
5193
6824
  msg.payload
5194
6825
  );
5195
6826
  case "mailbox.clear":
5196
6827
  return handleMailboxClear(
5197
6828
  ws,
5198
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) }
6829
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) }
5199
6830
  );
5200
6831
  case "mailbox.purge":
5201
6832
  return handleMailboxPurge(
5202
6833
  ws,
5203
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
6834
+ { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
5204
6835
  msg.payload
5205
6836
  );
5206
6837
  // ── Brain — status, autonomy ceiling, direct decision support ───
@@ -5214,7 +6845,7 @@ async function startWebUI(opts = {}) {
5214
6845
  const level = msg.payload?.level ?? "";
5215
6846
  const valid = ["off", "low", "medium", "high", "all"];
5216
6847
  if (!valid.includes(level)) {
5217
- sendResult(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
6848
+ sendResult2(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
5218
6849
  break;
5219
6850
  }
5220
6851
  brainSettings.maxAutoRisk = level;
@@ -5227,7 +6858,7 @@ async function startWebUI(opts = {}) {
5227
6858
  case "brain.ask": {
5228
6859
  const question = msg.payload?.question?.trim();
5229
6860
  if (!question) {
5230
- sendResult(ws, false, "Usage: /brain ask <question>");
6861
+ sendResult2(ws, false, "Usage: /brain ask <question>");
5231
6862
  break;
5232
6863
  }
5233
6864
  try {
@@ -5240,7 +6871,7 @@ async function startWebUI(opts = {}) {
5240
6871
  });
5241
6872
  send(ws, { type: "brain.answer", payload: { question, decision } });
5242
6873
  } catch (err) {
5243
- sendResult(ws, false, `Brain consultation failed: ${errMessage(err)}`);
6874
+ sendResult2(ws, false, `Brain consultation failed: ${errMessage(err)}`);
5244
6875
  }
5245
6876
  break;
5246
6877
  }
@@ -5267,14 +6898,28 @@ async function startWebUI(opts = {}) {
5267
6898
  broadcast,
5268
6899
  clients
5269
6900
  });
6901
+ const watcherMetrics = {
6902
+ fileChangesDetected: 0,
6903
+ filesProcessed: 0,
6904
+ broadcastsSent: 0,
6905
+ debounceResets: 0,
6906
+ totalDebounceDelayMs: 0,
6907
+ activeProjects: 0,
6908
+ averageDebounceDelayMs: 0,
6909
+ watcherActive: false
6910
+ };
5270
6911
  const httpServer = createHttpServer({
5271
6912
  host: wsHost,
5272
- distDir: path8.resolve(import.meta.dirname, "../../dist"),
6913
+ distDir: path9.resolve(import.meta.dirname, "../../dist"),
5273
6914
  wsPort,
5274
6915
  globalRoot: wpaths.globalRoot,
5275
- apiToken: wsToken
6916
+ apiToken: wsToken,
6917
+ watcherMetrics,
6918
+ onFleetPing: () => {
6919
+ void fleetBroadcast?.();
6920
+ }
5276
6921
  });
5277
- const registryBaseDir = path8.dirname(globalConfigPath);
6922
+ const registryBaseDir = path9.dirname(globalConfigPath);
5278
6923
  httpServer.listen(httpPort, wsHost, () => {
5279
6924
  const openUrl = `http://${wsHost}:${httpPort}`;
5280
6925
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
@@ -5286,7 +6931,7 @@ async function startWebUI(opts = {}) {
5286
6931
  wsPort,
5287
6932
  host: wsHost,
5288
6933
  projectRoot,
5289
- projectName: path8.basename(projectRoot) || projectRoot,
6934
+ projectName: path9.basename(projectRoot) || projectRoot,
5290
6935
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5291
6936
  url: `http://${wsHost}:${httpPort}`
5292
6937
  },
@@ -5313,6 +6958,11 @@ async function startWebUI(opts = {}) {
5313
6958
  // reality. Crash exits are healed by the next register()/list() prune pass.
5314
6959
  onShutdown: () => {
5315
6960
  brainMonitor.stop();
6961
+ void mcpRegistry.stopAll().catch(() => void 0);
6962
+ if (disposeEvents) {
6963
+ disposeEvents();
6964
+ disposeEvents = null;
6965
+ }
5316
6966
  if (eternalSubscription) {
5317
6967
  eternalSubscription.dispose();
5318
6968
  eternalSubscription = null;
@@ -5343,10 +6993,30 @@ export {
5343
6993
  handleFilesRead,
5344
6994
  handleFilesTree,
5345
6995
  handleFilesWrite,
6996
+ handleGitChanges,
6997
+ handleGitDiff,
6998
+ handleGitInfo,
6999
+ handleMcpAdd,
7000
+ handleMcpDisable,
7001
+ handleMcpDiscover,
7002
+ handleMcpEnable,
7003
+ handleMcpList,
7004
+ handleMcpRemove,
7005
+ handleMcpRestart,
7006
+ handleMcpSleep,
7007
+ handleMcpUpdate,
7008
+ handleMcpWake,
5346
7009
  handleMemoryForget,
5347
7010
  handleMemoryList,
5348
7011
  handleMemoryRemember,
5349
7012
  handleShellOpen,
7013
+ handleSkillsContent,
7014
+ handleSkillsCreate,
7015
+ handleSkillsEdit,
7016
+ handleSkillsExport,
7017
+ handleSkillsInstall,
7018
+ handleSkillsUninstall,
7019
+ handleSkillsUpdate,
5350
7020
  hostHeaderOk,
5351
7021
  injectWsPort,
5352
7022
  isLoopbackBind,
@@ -5364,7 +7034,7 @@ export {
5364
7034
  removeProvider,
5365
7035
  saveProviders,
5366
7036
  send,
5367
- sendResult,
7037
+ sendResult2 as sendResult,
5368
7038
  setActiveKey,
5369
7039
  startWebUI,
5370
7040
  stringifyContent,