@wrongstack/webui 0.264.0 → 0.265.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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 fs10 from "fs/promises";
185
+ import * as path10 from "path";
14
186
 
15
187
  // src/server/http-server.ts
16
188
  import * as fs from "fs/promises";
@@ -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";
@@ -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) {
@@ -511,40 +1106,282 @@ async function handleFilesList(ws, msg, projectRoot) {
511
1106
  payload: { files: rankFiles(results, payload.query ?? "", limit) }
512
1107
  });
513
1108
  }
514
-
515
- // src/server/memory-handlers.ts
516
- async function handleMemoryList(ws, memoryStore) {
1109
+
1110
+ // src/server/memory-handlers.ts
1111
+ async function handleMemoryList(ws, memoryStore) {
1112
+ try {
1113
+ const text = await memoryStore.readAll();
1114
+ send(ws, { type: "memory.list", payload: { text } });
1115
+ } catch (err) {
1116
+ send(ws, {
1117
+ type: "memory.list",
1118
+ payload: { text: "", error: errMessage(err) }
1119
+ });
1120
+ }
1121
+ }
1122
+ async function handleMemoryRemember(ws, msg, memoryStore) {
1123
+ const { text, scope } = msg.payload;
1124
+ try {
1125
+ await memoryStore.remember(text, scope ?? "project-memory");
1126
+ sendResult2(ws, true, "Saved to memory");
1127
+ } catch (err) {
1128
+ sendResult2(ws, false, errMessage(err));
1129
+ }
1130
+ }
1131
+ async function handleMemoryForget(ws, msg, memoryStore) {
1132
+ const { text, scope } = msg.payload;
1133
+ try {
1134
+ const removed = await memoryStore.forget(text, scope ?? "project-memory");
1135
+ sendResult2(
1136
+ ws,
1137
+ removed > 0,
1138
+ removed > 0 ? `Removed ${removed} entr${removed === 1 ? "y" : "ies"}` : "No matching entries"
1139
+ );
1140
+ } catch (err) {
1141
+ sendResult2(ws, false, errMessage(err));
1142
+ }
1143
+ }
1144
+
1145
+ // src/server/mcp-handlers.ts
1146
+ import * as fs3 from "fs/promises";
1147
+ import * as path3 from "path";
1148
+ function isMcpServerRecord(val) {
1149
+ if (typeof val !== "object" || val === null) return false;
1150
+ return true;
1151
+ }
1152
+ function projectServer(name, cfg, _status = "stopped", tools = []) {
1153
+ return {
1154
+ name,
1155
+ transport: cfg.transport,
1156
+ status: _status,
1157
+ enabled: cfg.enabled ?? true,
1158
+ description: cfg.description,
1159
+ tools
1160
+ };
1161
+ }
1162
+ async function readConfig(configPath) {
1163
+ try {
1164
+ const content = await fs3.readFile(configPath, "utf-8");
1165
+ return JSON.parse(content);
1166
+ } catch {
1167
+ return {};
1168
+ }
1169
+ }
1170
+ async function writeConfig(configPath, cfg) {
1171
+ const dir = path3.dirname(configPath);
1172
+ await fs3.mkdir(dir, { recursive: true });
1173
+ await fs3.writeFile(configPath, JSON.stringify(cfg, null, 2), "utf-8");
1174
+ }
1175
+ async function getMcpServers(config, globalConfigPath) {
1176
+ const servers = [];
1177
+ const configured = isMcpServerRecord(config.mcpServers) ? config.mcpServers : {};
1178
+ for (const [name, cfg] of Object.entries(configured)) {
1179
+ servers.push(projectServer(name, cfg));
1180
+ }
1181
+ return servers;
1182
+ }
1183
+ function getRegistryStates(mcpRegistry) {
1184
+ const states = /* @__PURE__ */ new Map();
1185
+ if (!mcpRegistry?.list) return states;
1186
+ try {
1187
+ const list = mcpRegistry.list();
1188
+ for (const item of list) {
1189
+ states.set(item.name, { state: item.state, toolCount: item.toolCount });
1190
+ }
1191
+ } catch {
1192
+ }
1193
+ return states;
1194
+ }
1195
+ async function handleMcpList(ws, _msg, config, _globalConfigPath, mcpRegistry) {
1196
+ const servers = await getMcpServers(config, _globalConfigPath);
1197
+ const registryStates = getRegistryStates(mcpRegistry);
1198
+ for (const server of servers) {
1199
+ const registryState = registryStates.get(server.name);
1200
+ if (registryState) {
1201
+ server.status = registryState.state;
1202
+ server.tools = Array.from({ length: registryState.toolCount }, (_, i) => `tool-${i + 1}`);
1203
+ }
1204
+ }
1205
+ send(ws, { type: "mcp.list", payload: { servers } });
1206
+ }
1207
+ async function handleMcpAdd(ws, msg, config, globalConfigPath, mcpRegistry) {
1208
+ const payload = msg.payload;
1209
+ if (!payload.name) {
1210
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1211
+ return;
1212
+ }
1213
+ try {
1214
+ const diskConfig = await readConfig(globalConfigPath);
1215
+ const mcpServers = isMcpServerRecord(diskConfig.mcpServers) ? diskConfig.mcpServers : {};
1216
+ if (mcpServers[payload.name]) {
1217
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Server "${payload.name}" already exists` } });
1218
+ return;
1219
+ }
1220
+ mcpServers[payload.name] = {
1221
+ transport: payload.transport,
1222
+ description: payload.description,
1223
+ enabled: payload.enabled ?? true,
1224
+ command: payload.command,
1225
+ args: payload.args,
1226
+ env: payload.env,
1227
+ allowedTools: payload.allowedTools
1228
+ };
1229
+ diskConfig.mcpServers = mcpServers;
1230
+ await writeConfig(globalConfigPath, diskConfig);
1231
+ const newServer = projectServer(payload.name, mcpServers[payload.name]);
1232
+ send(ws, { type: "mcp.server.added", payload: { server: newServer } });
1233
+ if (mcpRegistry && (payload.enabled ?? true)) {
1234
+ const serverConfig = mcpServers[payload.name];
1235
+ try {
1236
+ await mcpRegistry.start({
1237
+ name: payload.name,
1238
+ transport: payload.transport,
1239
+ command: payload.command,
1240
+ args: payload.args,
1241
+ env: payload.env,
1242
+ allowedTools: payload.allowedTools,
1243
+ enabled: true
1244
+ });
1245
+ } catch (err) {
1246
+ send(ws, { type: "mcp.server.error", payload: { name: payload.name, error: String(err) } });
1247
+ }
1248
+ }
1249
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" added` } });
1250
+ } catch (err) {
1251
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to add server: ${err}` } });
1252
+ }
1253
+ }
1254
+ async function handleMcpRemove(ws, msg, _config, globalConfigPath, mcpRegistry) {
1255
+ const payload = msg.payload;
1256
+ if (!payload.name) {
1257
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1258
+ return;
1259
+ }
1260
+ try {
1261
+ if (mcpRegistry) {
1262
+ try {
1263
+ await mcpRegistry.stop(payload.name);
1264
+ } catch {
1265
+ }
1266
+ }
1267
+ const diskConfig = await readConfig(globalConfigPath);
1268
+ const mcpServers = isMcpServerRecord(diskConfig.mcpServers) ? diskConfig.mcpServers : {};
1269
+ if (!mcpServers[payload.name]) {
1270
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Server "${payload.name}" not found` } });
1271
+ return;
1272
+ }
1273
+ delete mcpServers[payload.name];
1274
+ diskConfig.mcpServers = mcpServers;
1275
+ await writeConfig(globalConfigPath, diskConfig);
1276
+ send(ws, { type: "mcp.server.removed", payload: { name: payload.name } });
1277
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" removed` } });
1278
+ } catch (err) {
1279
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to remove server: ${err}` } });
1280
+ }
1281
+ }
1282
+ async function handleMcpUpdate(ws, msg, _config, globalConfigPath) {
1283
+ const payload = msg.payload;
1284
+ if (!payload.name) {
1285
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1286
+ return;
1287
+ }
517
1288
  try {
518
- const text = await memoryStore.readAll();
519
- send(ws, { type: "memory.list", payload: { text } });
1289
+ const diskConfig = await readConfig(globalConfigPath);
1290
+ const mcpServers = isMcpServerRecord(diskConfig.mcpServers) ? diskConfig.mcpServers : {};
1291
+ if (!mcpServers[payload.name]) {
1292
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Server "${payload.name}" not found` } });
1293
+ return;
1294
+ }
1295
+ const existing = mcpServers[payload.name];
1296
+ mcpServers[payload.name] = {
1297
+ transport: payload.transport ?? existing.transport,
1298
+ description: payload.description ?? existing.description,
1299
+ enabled: payload.enabled ?? existing.enabled,
1300
+ command: payload.command ?? existing.command,
1301
+ args: payload.args ?? existing.args,
1302
+ env: payload.env ?? existing.env,
1303
+ allowedTools: payload.allowedTools ?? existing.allowedTools
1304
+ };
1305
+ diskConfig.mcpServers = mcpServers;
1306
+ await writeConfig(globalConfigPath, diskConfig);
1307
+ const updatedServer = projectServer(payload.name, mcpServers[payload.name]);
1308
+ send(ws, { type: "mcp.server.updated", payload: { server: updatedServer } });
1309
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" updated` } });
520
1310
  } catch (err) {
521
- send(ws, {
522
- type: "memory.list",
523
- payload: { text: "", error: errMessage(err) }
524
- });
1311
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to update server: ${err}` } });
525
1312
  }
526
1313
  }
527
- async function handleMemoryRemember(ws, msg, memoryStore) {
528
- const { text, scope } = msg.payload;
1314
+ async function handleMcpWake(ws, msg, _config, _globalConfigPath, mcpRegistry) {
1315
+ const payload = msg.payload;
1316
+ if (!payload.name) {
1317
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1318
+ return;
1319
+ }
1320
+ if (!mcpRegistry) {
1321
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "MCP registry not available" } });
1322
+ return;
1323
+ }
529
1324
  try {
530
- await memoryStore.remember(text, scope ?? "project-memory");
531
- sendResult(ws, true, "Saved to memory");
1325
+ send(ws, { type: "mcp.server.waking", payload: { name: payload.name } });
1326
+ await mcpRegistry.restart(payload.name);
1327
+ send(ws, { type: "mcp.server.connected", payload: { name: payload.name } });
1328
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" restarted` } });
532
1329
  } catch (err) {
533
- sendResult(ws, false, errMessage(err));
1330
+ send(ws, { type: "mcp.server.error", payload: { name: payload.name, error: String(err) } });
1331
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to restart "${payload.name}": ${err}` } });
534
1332
  }
535
1333
  }
536
- async function handleMemoryForget(ws, msg, memoryStore) {
537
- const { text, scope } = msg.payload;
1334
+ async function handleMcpSleep(ws, msg, _config, _globalConfigPath, mcpRegistry) {
1335
+ const payload = msg.payload;
1336
+ if (!payload.name) {
1337
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1338
+ return;
1339
+ }
1340
+ if (!mcpRegistry) {
1341
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "MCP registry not available" } });
1342
+ return;
1343
+ }
538
1344
  try {
539
- const removed = await memoryStore.forget(text, scope ?? "project-memory");
540
- sendResult(
541
- ws,
542
- removed > 0,
543
- removed > 0 ? `Removed ${removed} entr${removed === 1 ? "y" : "ies"}` : "No matching entries"
544
- );
1345
+ await mcpRegistry.stop(payload.name);
1346
+ send(ws, { type: "mcp.server.sleeping", payload: { name: payload.name } });
1347
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" stopped` } });
545
1348
  } catch (err) {
546
- sendResult(ws, false, errMessage(err));
1349
+ send(ws, { type: "mcp.server.error", payload: { name: payload.name, error: String(err) } });
1350
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to stop "${payload.name}": ${err}` } });
1351
+ }
1352
+ }
1353
+ async function handleMcpDiscover(ws, msg, _config, _globalConfigPath, _mcpRegistry) {
1354
+ const payload = msg.payload;
1355
+ if (!payload.name) {
1356
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1357
+ return;
1358
+ }
1359
+ send(ws, { type: "mcp.server.discovered", payload: { name: payload.name, tools: [] } });
1360
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" tools were discovered on connect` } });
1361
+ }
1362
+ async function handleMcpEnable(ws, msg, _config, _globalConfigPath) {
1363
+ const payload = msg.payload;
1364
+ if (!payload.name) {
1365
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1366
+ return;
1367
+ }
1368
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Enable command sent for "${payload.name}"` } });
1369
+ }
1370
+ async function handleMcpDisable(ws, msg, _config, _globalConfigPath) {
1371
+ const payload = msg.payload;
1372
+ if (!payload.name) {
1373
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1374
+ return;
1375
+ }
1376
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Disable command sent for "${payload.name}"` } });
1377
+ }
1378
+ async function handleMcpRestart(ws, msg, _config, _globalConfigPath) {
1379
+ const payload = msg.payload;
1380
+ if (!payload.name) {
1381
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
1382
+ return;
547
1383
  }
1384
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Restart command sent for "${payload.name}"` } });
548
1385
  }
549
1386
 
550
1387
  // src/server/index.ts
@@ -1956,14 +2793,14 @@ function registerShutdownHandlers(res) {
1956
2793
 
1957
2794
  // src/server/instance-registry.ts
1958
2795
  import * as os from "os";
1959
- import * as path3 from "path";
1960
- import * as fs3 from "fs/promises";
2796
+ import * as path4 from "path";
2797
+ import * as fs4 from "fs/promises";
1961
2798
  import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
1962
2799
  function defaultBaseDir() {
1963
- return path3.join(os.homedir(), ".wrongstack");
2800
+ return path4.join(os.homedir(), ".wrongstack");
1964
2801
  }
1965
2802
  function registryPath(baseDir = defaultBaseDir()) {
1966
- return path3.join(baseDir, "webui-instances.json");
2803
+ return path4.join(baseDir, "webui-instances.json");
1967
2804
  }
1968
2805
  function isPidAlive(pid) {
1969
2806
  if (!Number.isInteger(pid) || pid <= 0) return false;
@@ -1976,7 +2813,7 @@ function isPidAlive(pid) {
1976
2813
  }
1977
2814
  async function load(file) {
1978
2815
  try {
1979
- const raw = await fs3.readFile(file, "utf8");
2816
+ const raw = await fs4.readFile(file, "utf8");
1980
2817
  const parsed = JSON.parse(raw);
1981
2818
  if (parsed?.version === 1 && Array.isArray(parsed.instances)) {
1982
2819
  return parsed;
@@ -2124,15 +2961,15 @@ import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/cor
2124
2961
  import { probeLocalLlm } from "@wrongstack/runtime/probe";
2125
2962
 
2126
2963
  // src/server/provider-config-io.ts
2127
- import * as fs4 from "fs/promises";
2128
- import * as path4 from "path";
2964
+ import * as fs5 from "fs/promises";
2965
+ import * as path5 from "path";
2129
2966
  import { atomicWrite as atomicWrite3 } from "@wrongstack/core";
2130
2967
  import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/security";
2131
2968
  import { DefaultSecretVault } from "@wrongstack/core";
2132
2969
  async function loadSavedProviders(configPath, vault) {
2133
2970
  let raw;
2134
2971
  try {
2135
- raw = await fs4.readFile(configPath, "utf8");
2972
+ raw = await fs5.readFile(configPath, "utf8");
2136
2973
  } catch {
2137
2974
  return {};
2138
2975
  }
@@ -2149,7 +2986,7 @@ async function saveProviders(configPath, vault, providers) {
2149
2986
  let raw;
2150
2987
  let fileExists = true;
2151
2988
  try {
2152
- raw = await fs4.readFile(configPath, "utf8");
2989
+ raw = await fs5.readFile(configPath, "utf8");
2153
2990
  } catch (err) {
2154
2991
  if (err.code !== "ENOENT") {
2155
2992
  throw new Error(
@@ -2177,7 +3014,7 @@ async function saveProviders(configPath, vault, providers) {
2177
3014
  await atomicWrite3(configPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
2178
3015
  }
2179
3016
  function createProviderConfigIO(configPath) {
2180
- const keyFile = path4.join(path4.dirname(configPath), ".key");
3017
+ const keyFile = path5.join(path5.dirname(configPath), ".key");
2181
3018
  const vault = new DefaultSecretVault({ keyFile });
2182
3019
  return {
2183
3020
  load: () => loadSavedProviders(configPath, vault),
@@ -2208,7 +3045,7 @@ function writeKeysBack(cfg, keys) {
2208
3045
  }
2209
3046
  cfg.apiKeys = keys;
2210
3047
  const active = keys.find((k) => k.label === cfg.activeKey) ?? expectDefined(keys[0]);
2211
- cfg.apiKey = active.apiKey;
3048
+ delete cfg.apiKey;
2212
3049
  if (!cfg.activeKey || !keys.some((k) => k.label === cfg.activeKey)) {
2213
3050
  cfg.activeKey = active.label;
2214
3051
  }
@@ -2332,9 +3169,9 @@ function createProviderHandlers(deps) {
2332
3169
  const providers = await loadConfigProviders();
2333
3170
  const result = upsertKey(providers, providerId, label, apiKey, (/* @__PURE__ */ new Date()).toISOString());
2334
3171
  if (result.ok) await saveConfigProviders(providers);
2335
- sendResult(ws, result.ok, result.message);
3172
+ sendResult2(ws, result.ok, result.message);
2336
3173
  } catch (err) {
2337
- sendResult(ws, false, errMessage(err));
3174
+ sendResult2(ws, false, errMessage(err));
2338
3175
  }
2339
3176
  }
2340
3177
  async function handleKeyDelete(ws, providerId, label) {
@@ -2342,9 +3179,9 @@ function createProviderHandlers(deps) {
2342
3179
  const providers = await loadConfigProviders();
2343
3180
  const result = deleteKey(providers, providerId, label);
2344
3181
  if (result.ok) await saveConfigProviders(providers);
2345
- sendResult(ws, result.ok, result.message);
3182
+ sendResult2(ws, result.ok, result.message);
2346
3183
  } catch (err) {
2347
- sendResult(ws, false, errMessage(err));
3184
+ sendResult2(ws, false, errMessage(err));
2348
3185
  }
2349
3186
  }
2350
3187
  async function handleKeySetActive(ws, providerId, label) {
@@ -2352,9 +3189,9 @@ function createProviderHandlers(deps) {
2352
3189
  const providers = await loadConfigProviders();
2353
3190
  const result = setActiveKey(providers, providerId, label);
2354
3191
  if (result.ok) await saveConfigProviders(providers);
2355
- sendResult(ws, result.ok, result.message);
3192
+ sendResult2(ws, result.ok, result.message);
2356
3193
  } catch (err) {
2357
- sendResult(ws, false, errMessage(err));
3194
+ sendResult2(ws, false, errMessage(err));
2358
3195
  }
2359
3196
  }
2360
3197
  async function handleProviderAdd(ws, payload) {
@@ -2362,13 +3199,13 @@ function createProviderHandlers(deps) {
2362
3199
  const providers = await loadConfigProviders();
2363
3200
  const result = addProvider(providers, payload, (/* @__PURE__ */ new Date()).toISOString());
2364
3201
  if (result.ok) await saveConfigProviders(providers);
2365
- sendResult(ws, result.ok, result.message);
3202
+ sendResult2(ws, result.ok, result.message);
2366
3203
  if (result.ok) {
2367
3204
  console.log(`[WebUI] Provider "${payload.id}" added via provider.add`);
2368
3205
  broadcastSaved(providers);
2369
3206
  }
2370
3207
  } catch (err) {
2371
- sendResult(ws, false, errMessage(err));
3208
+ sendResult2(ws, false, errMessage(err));
2372
3209
  }
2373
3210
  }
2374
3211
  async function handleProviderRemove(ws, providerId) {
@@ -2376,9 +3213,9 @@ function createProviderHandlers(deps) {
2376
3213
  const providers = await loadConfigProviders();
2377
3214
  const result = removeProvider(providers, providerId);
2378
3215
  if (result.ok) await saveConfigProviders(providers);
2379
- sendResult(ws, result.ok, result.message);
3216
+ sendResult2(ws, result.ok, result.message);
2380
3217
  } catch (err) {
2381
- sendResult(ws, false, errMessage(err));
3218
+ sendResult2(ws, false, errMessage(err));
2382
3219
  }
2383
3220
  }
2384
3221
  function broadcastSaved(providers) {
@@ -2392,15 +3229,15 @@ function createProviderHandlers(deps) {
2392
3229
  const providers = await loadConfigProviders();
2393
3230
  const cfg = providers[providerId];
2394
3231
  if (!cfg) {
2395
- sendResult(ws, false, `Unknown provider "${providerId}"`);
3232
+ sendResult2(ws, false, `Unknown provider "${providerId}"`);
2396
3233
  return;
2397
3234
  }
2398
3235
  delete cfg.models;
2399
3236
  await saveConfigProviders(providers);
2400
- sendResult(ws, true, `Cleared model allowlist for ${providerId}`);
3237
+ sendResult2(ws, true, `Cleared model allowlist for ${providerId}`);
2401
3238
  broadcastSaved(providers);
2402
3239
  } catch (err) {
2403
- sendResult(ws, false, errMessage(err));
3240
+ sendResult2(ws, false, errMessage(err));
2404
3241
  }
2405
3242
  }
2406
3243
  async function handleProviderUndoClear(ws, providerId, previousModels) {
@@ -2408,15 +3245,15 @@ function createProviderHandlers(deps) {
2408
3245
  const providers = await loadConfigProviders();
2409
3246
  const cfg = providers[providerId];
2410
3247
  if (!cfg) {
2411
- sendResult(ws, false, `Unknown provider "${providerId}"`);
3248
+ sendResult2(ws, false, `Unknown provider "${providerId}"`);
2412
3249
  return;
2413
3250
  }
2414
3251
  cfg.models = [...previousModels];
2415
3252
  await saveConfigProviders(providers);
2416
- sendResult(ws, true, `Restored ${previousModels.length} model(s) for ${providerId}`);
3253
+ sendResult2(ws, true, `Restored ${previousModels.length} model(s) for ${providerId}`);
2417
3254
  broadcastSaved(providers);
2418
3255
  } catch (err) {
2419
- sendResult(ws, false, errMessage(err));
3256
+ sendResult2(ws, false, errMessage(err));
2420
3257
  }
2421
3258
  }
2422
3259
  async function handleProviderUpdate(ws, payload) {
@@ -2424,7 +3261,7 @@ function createProviderHandlers(deps) {
2424
3261
  const providers = await loadConfigProviders();
2425
3262
  const cfg = providers[payload.id];
2426
3263
  if (!cfg) {
2427
- sendResult(ws, false, `Unknown provider "${payload.id}"`);
3264
+ sendResult2(ws, false, `Unknown provider "${payload.id}"`);
2428
3265
  return;
2429
3266
  }
2430
3267
  if (payload.family !== void 0) cfg.family = payload.family;
@@ -2432,10 +3269,10 @@ function createProviderHandlers(deps) {
2432
3269
  if (payload.envVars !== void 0) cfg.envVars = payload.envVars;
2433
3270
  if (payload.models !== void 0) cfg.models = payload.models;
2434
3271
  await saveConfigProviders(providers);
2435
- sendResult(ws, true, `Updated ${payload.id}`);
3272
+ sendResult2(ws, true, `Updated ${payload.id}`);
2436
3273
  broadcastSaved(providers);
2437
3274
  } catch (err) {
2438
- sendResult(ws, false, errMessage(err));
3275
+ sendResult2(ws, false, errMessage(err));
2439
3276
  }
2440
3277
  }
2441
3278
  async function handleProviderProbe(ws, providerId, timeoutMs) {
@@ -2480,9 +3317,12 @@ function createProviderHandlers(deps) {
2480
3317
  }
2481
3318
 
2482
3319
  // src/server/setup-events.ts
2483
- import * as path5 from "path";
3320
+ import * as fs6 from "fs/promises";
3321
+ import { watch as fsWatch } from "fs";
3322
+ import * as path6 from "path";
2484
3323
  function setupEvents(deps) {
2485
- const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge } = deps;
3324
+ const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge, wpaths, watcherMetrics, onFleetBroadcaster } = deps;
3325
+ const disposers = [];
2486
3326
  events.on("iteration.started", (e) => {
2487
3327
  const maxIt = typeof context.meta["maxIterations"] === "number" ? context.meta["maxIterations"] : config.tools?.maxIterations ?? 100;
2488
3328
  broadcast2(clients, {
@@ -2513,7 +3353,11 @@ function setupEvents(deps) {
2513
3353
  events.on("tool.progress", (e) => {
2514
3354
  broadcast2(clients, {
2515
3355
  type: "tool.progress",
2516
- payload: { id: e.id, name: e.name, eventType: e.event.type, text: e.event.text }
3356
+ // Nested `event` shape the client handler reads `payload.event?.text`
3357
+ // and early-returns on a falsy text, so a flat { eventType, text } payload
3358
+ // makes live tool progress (bash streaming, partial_output, warnings)
3359
+ // never render. Must match WSToolProgress and the CLI server.
3360
+ payload: { id: e.id, name: e.name, event: { type: e.event.type, text: e.event.text, data: e.event.data } }
2517
3361
  });
2518
3362
  sessionBridge?.append({
2519
3363
  type: "tool_progress",
@@ -2679,20 +3523,165 @@ function setupEvents(deps) {
2679
3523
  events.onPattern("brain.*", (eventName, payload) => {
2680
3524
  broadcast2(clients, { type: "brain.event", payload: { event: eventName, ...payload } });
2681
3525
  });
2682
- const globalRoot = globalConfigPath ? path5.dirname(globalConfigPath) : void 0;
3526
+ events.on("client.status", async (e) => {
3527
+ broadcast2(clients, { type: "client.status_update", payload: e });
3528
+ if (wpaths?.projectStatus) {
3529
+ try {
3530
+ const statusFile = wpaths.projectStatus(e.projectHash);
3531
+ const dir = path6.dirname(statusFile);
3532
+ await fs6.mkdir(dir, { recursive: true });
3533
+ await fs6.writeFile(statusFile, JSON.stringify(e, null, 2), "utf-8");
3534
+ } catch (err) {
3535
+ console.error("[setup-events] Failed to write status.json:", err);
3536
+ }
3537
+ }
3538
+ });
3539
+ if (wpaths?.projectStatus && wpaths.configDir) {
3540
+ const projectsDir = path6.join(wpaths.configDir, "projects");
3541
+ const knownProjectHashes = /* @__PURE__ */ new Set();
3542
+ const debounceTimers = /* @__PURE__ */ new Map();
3543
+ const DEBOUNCE_MS = 150;
3544
+ const pendingStatuses = /* @__PURE__ */ new Map();
3545
+ if (watcherMetrics) {
3546
+ watcherMetrics.fileChangesDetected = 0;
3547
+ watcherMetrics.filesProcessed = 0;
3548
+ watcherMetrics.broadcastsSent = 0;
3549
+ watcherMetrics.debounceResets = 0;
3550
+ watcherMetrics.totalDebounceDelayMs = 0;
3551
+ watcherMetrics.activeProjects = 0;
3552
+ watcherMetrics.averageDebounceDelayMs = 0;
3553
+ watcherMetrics.watcherActive = true;
3554
+ }
3555
+ const getAverageDebounceDelay = () => {
3556
+ if (!watcherMetrics || watcherMetrics.broadcastsSent === 0) return 0;
3557
+ return watcherMetrics.totalDebounceDelayMs / watcherMetrics.broadcastsSent;
3558
+ };
3559
+ const logWatcherMetrics = () => {
3560
+ if (!watcherMetrics) return;
3561
+ watcherMetrics.averageDebounceDelayMs = getAverageDebounceDelay();
3562
+ console.log(
3563
+ `[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`
3564
+ );
3565
+ };
3566
+ const metricsInterval = setInterval(logWatcherMetrics, 6e4);
3567
+ const broadcastStatus = (projectHash2, statusData, actualDelayMs) => {
3568
+ broadcast2(clients, { type: "client.status_update", payload: statusData });
3569
+ if (watcherMetrics) {
3570
+ watcherMetrics.broadcastsSent++;
3571
+ watcherMetrics.totalDebounceDelayMs += actualDelayMs;
3572
+ watcherMetrics.averageDebounceDelayMs = getAverageDebounceDelay();
3573
+ }
3574
+ };
3575
+ const scheduleBroadcast = (projectHash2, statusData) => {
3576
+ const now = Date.now();
3577
+ const existing = pendingStatuses.get(projectHash2);
3578
+ if (existing && watcherMetrics) {
3579
+ watcherMetrics.debounceResets++;
3580
+ }
3581
+ pendingStatuses.set(projectHash2, {
3582
+ data: statusData,
3583
+ firstWriteAt: existing ? existing.firstWriteAt : now
3584
+ });
3585
+ const existingTimer = debounceTimers.get(projectHash2);
3586
+ if (existingTimer) {
3587
+ clearTimeout(existingTimer);
3588
+ }
3589
+ const timer = setTimeout(() => {
3590
+ debounceTimers.delete(projectHash2);
3591
+ const pending = pendingStatuses.get(projectHash2);
3592
+ if (pending) {
3593
+ const actualDelay = Date.now() - pending.firstWriteAt;
3594
+ broadcastStatus(projectHash2, pending.data, actualDelay);
3595
+ pendingStatuses.delete(projectHash2);
3596
+ }
3597
+ }, DEBOUNCE_MS);
3598
+ debounceTimers.set(projectHash2, timer);
3599
+ };
3600
+ let watcher;
3601
+ const startWatcher = async () => {
3602
+ try {
3603
+ await fs6.mkdir(projectsDir, { recursive: true });
3604
+ watcher = fsWatch(projectsDir, { persistent: true, recursive: true }, async (eventType, filename) => {
3605
+ if (eventType === "change") {
3606
+ if (filename == null) return;
3607
+ if (watcherMetrics) watcherMetrics.fileChangesDetected++;
3608
+ const targetFile = path6.join(projectsDir, String(filename));
3609
+ if (targetFile.endsWith("status.json")) {
3610
+ const projectHash2 = path6.basename(path6.dirname(targetFile));
3611
+ if (knownProjectHashes.size > 0 && !knownProjectHashes.has(projectHash2)) {
3612
+ return;
3613
+ }
3614
+ if (watcherMetrics) watcherMetrics.filesProcessed++;
3615
+ try {
3616
+ const content = await fs6.readFile(targetFile, "utf-8");
3617
+ const statusData = JSON.parse(content);
3618
+ if (statusData.projectHash) {
3619
+ const hash = String(statusData.projectHash);
3620
+ if (!knownProjectHashes.has(hash)) {
3621
+ knownProjectHashes.add(hash);
3622
+ if (watcherMetrics) watcherMetrics.activeProjects = knownProjectHashes.size;
3623
+ }
3624
+ }
3625
+ scheduleBroadcast(projectHash2, statusData);
3626
+ } catch {
3627
+ }
3628
+ }
3629
+ }
3630
+ });
3631
+ console.log(`[setup-events] Watching ${projectsDir} for status.json changes (hash-filtered, debounced)`);
3632
+ } catch (err) {
3633
+ console.error("[setup-events] Failed to start status file watcher:", err);
3634
+ }
3635
+ };
3636
+ events.on("client.status", (e) => {
3637
+ if (e.projectHash) {
3638
+ const hash = String(e.projectHash);
3639
+ if (!knownProjectHashes.has(hash)) {
3640
+ knownProjectHashes.add(hash);
3641
+ if (watcherMetrics) watcherMetrics.activeProjects = knownProjectHashes.size;
3642
+ }
3643
+ }
3644
+ });
3645
+ startWatcher();
3646
+ disposers.push(() => {
3647
+ clearInterval(metricsInterval);
3648
+ logWatcherMetrics();
3649
+ if (watcherMetrics) watcherMetrics.watcherActive = false;
3650
+ for (const [projectHash2, pending] of pendingStatuses) {
3651
+ const timer = debounceTimers.get(projectHash2);
3652
+ if (timer) {
3653
+ clearTimeout(timer);
3654
+ broadcastStatus(projectHash2, pending.data, 0);
3655
+ }
3656
+ }
3657
+ for (const timer of debounceTimers.values()) {
3658
+ clearTimeout(timer);
3659
+ }
3660
+ debounceTimers.clear();
3661
+ pendingStatuses.clear();
3662
+ if (watcher) {
3663
+ watcher.close();
3664
+ console.log("[setup-events] Closed status file watcher");
3665
+ }
3666
+ });
3667
+ }
3668
+ const globalRoot = globalConfigPath ? path6.dirname(globalConfigPath) : void 0;
2683
3669
  if (globalRoot) {
2684
- const statusInterval = setInterval(async () => {
3670
+ const broadcastSessions = async () => {
2685
3671
  try {
2686
3672
  const { SessionRegistry } = await import("@wrongstack/core");
2687
3673
  const registry = new SessionRegistry(globalRoot);
2688
3674
  const sessions = await registry.list();
2689
- const live = sessions.filter((s) => s.status !== "stale").map((s) => ({
3675
+ const mySlug = sessions.find((s) => s.pid === process.pid)?.projectSlug;
3676
+ const live = sessions.filter((s) => s.status !== "stale").filter((s) => mySlug ? s.projectSlug === mySlug : true).map((s) => ({
2690
3677
  sessionId: s.sessionId,
2691
3678
  projectName: s.projectName,
2692
3679
  projectSlug: s.projectSlug,
2693
3680
  projectRoot: s.projectRoot,
2694
3681
  workingDir: s.workingDir,
2695
3682
  gitBranch: s.gitBranch,
3683
+ // Surface (tui/webui/cli) so Fleet HQ can label each live client node.
3684
+ clientType: s.clientType,
2696
3685
  status: s.status,
2697
3686
  pid: s.pid,
2698
3687
  startedAt: s.startedAt,
@@ -2704,24 +3693,56 @@ function setupEvents(deps) {
2704
3693
  currentTool: a.currentTool,
2705
3694
  iterations: a.iterations,
2706
3695
  toolCalls: a.toolCalls,
3696
+ costUsd: a.costUsd,
3697
+ tokensIn: a.tokensIn,
3698
+ tokensOut: a.tokensOut,
3699
+ ctxPct: a.ctxPct,
3700
+ model: a.model,
3701
+ partialText: a.partialText,
2707
3702
  lastActivityAt: a.lastActivityAt
2708
3703
  }))
2709
3704
  }));
2710
3705
  broadcast2(clients, { type: "sessions.status_update", payload: { sessions: live } });
2711
3706
  } catch {
2712
3707
  }
2713
- }, 5e3);
3708
+ };
3709
+ onFleetBroadcaster?.(broadcastSessions);
3710
+ const statusInterval = setInterval(() => void broadcastSessions(), 5e3);
2714
3711
  if (statusInterval.unref) statusInterval.unref();
3712
+ disposers.push(() => clearInterval(statusInterval));
3713
+ let regDebounce;
3714
+ try {
3715
+ const regWatcher = fsWatch(globalRoot, { persistent: false }, (_event, filename) => {
3716
+ const name = filename ? String(filename) : "";
3717
+ if (!name.startsWith("session-registry.json") || name.endsWith(".lock")) return;
3718
+ if (regDebounce) clearTimeout(regDebounce);
3719
+ regDebounce = setTimeout(() => void broadcastSessions(), 150);
3720
+ });
3721
+ disposers.push(() => {
3722
+ if (regDebounce) clearTimeout(regDebounce);
3723
+ regWatcher.close();
3724
+ });
3725
+ } catch {
3726
+ }
3727
+ void broadcastSessions();
2715
3728
  }
3729
+ return () => {
3730
+ for (const dispose of disposers) {
3731
+ try {
3732
+ dispose();
3733
+ } catch {
3734
+ }
3735
+ }
3736
+ };
2716
3737
  }
2717
3738
 
2718
3739
  // src/server/custom-context-modes.ts
2719
3740
  import { listContextWindowModes, atomicWrite as atomicWrite4 } from "@wrongstack/core";
2720
- import * as fs5 from "fs/promises";
2721
- import * as path6 from "path";
3741
+ import * as fs7 from "fs/promises";
3742
+ import * as path7 from "path";
2722
3743
  var STORE_FILENAME = "custom-context-modes.json";
2723
3744
  function storePath(wrongstackDir) {
2724
- return path6.join(wrongstackDir, STORE_FILENAME);
3745
+ return path7.join(wrongstackDir, STORE_FILENAME);
2725
3746
  }
2726
3747
  var BUILTIN_IDS = /* @__PURE__ */ new Set(["balanced", "frugal", "deep", "archival"]);
2727
3748
  function createCustomModeStore(wrongstackDir) {
@@ -2729,7 +3750,7 @@ function createCustomModeStore(wrongstackDir) {
2729
3750
  const load2 = async () => {
2730
3751
  modes.clear();
2731
3752
  try {
2732
- const raw = await fs5.readFile(storePath(wrongstackDir), "utf8");
3753
+ const raw = await fs7.readFile(storePath(wrongstackDir), "utf8");
2733
3754
  const parsed = JSON.parse(raw);
2734
3755
  if (Array.isArray(parsed.modes)) {
2735
3756
  for (const m of parsed.modes) {
@@ -2905,60 +3926,346 @@ function createEternalSubscription(subscribe, broadcast2, clientsRef) {
2905
3926
  disposed = true;
2906
3927
  dispose();
2907
3928
  }
2908
- };
3929
+ };
3930
+ }
3931
+
3932
+ // src/server/shell-open.ts
3933
+ import * as fs8 from "fs/promises";
3934
+ import * as path8 from "path";
3935
+ import { spawn as spawn2 } from "child_process";
3936
+ var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
3937
+ async function handleShellOpen(req, logger) {
3938
+ try {
3939
+ const resolved = path8.resolve(req.path);
3940
+ await fs8.access(resolved);
3941
+ if (METACHAR_REGEX.test(resolved)) {
3942
+ return { success: false, message: "Path contains unsupported characters." };
3943
+ }
3944
+ const platform = process.platform;
3945
+ const launch = (cmd, args, onError) => {
3946
+ const child = spawn2(cmd, args, {
3947
+ detached: true,
3948
+ stdio: "ignore",
3949
+ windowsHide: true
3950
+ });
3951
+ child.on("error", (err) => {
3952
+ logger.warn(`shell.open spawn failed: ${err.message}`);
3953
+ onError?.();
3954
+ });
3955
+ child.unref();
3956
+ };
3957
+ if (req.target === "file-manager") {
3958
+ if (platform === "win32") launch("explorer", [resolved]);
3959
+ else if (platform === "darwin") launch("open", [resolved]);
3960
+ else launch("xdg-open", [resolved]);
3961
+ } else if (req.target === "terminal") {
3962
+ if (platform === "win32") {
3963
+ launch("cmd", ["/c", "start", "cmd", "/k", "cd", "/d", resolved]);
3964
+ } else if (platform === "darwin") {
3965
+ launch("open", ["-a", "Terminal", resolved]);
3966
+ } else {
3967
+ launch(
3968
+ "x-terminal-emulator",
3969
+ [`--working-directory=${resolved}`],
3970
+ () => launch(
3971
+ "gnome-terminal",
3972
+ [`--working-directory=${resolved}`],
3973
+ () => launch("xterm", ["-e", `cd '${resolved}' && ${process.env["SHELL"] ?? "sh"}`])
3974
+ )
3975
+ );
3976
+ }
3977
+ } else {
3978
+ return { success: false, message: `Unknown shell.open target: ${String(req.target)}` };
3979
+ }
3980
+ return { success: true, message: `Opened ${req.target} at ${resolved}` };
3981
+ } catch (err) {
3982
+ return { success: false, message: err instanceof Error ? err.message : String(err) };
3983
+ }
3984
+ }
3985
+
3986
+ // src/server/git-handlers.ts
3987
+ async function handleGitInfo(ws, projectRoot) {
3988
+ const cwd = projectRoot || void 0;
3989
+ try {
3990
+ const { execFile: ef } = await import("child_process");
3991
+ const git = (args) => new Promise((resolve5) => {
3992
+ ef("git", args, { cwd, timeout: 3e3 }, (err, stdout) => {
3993
+ resolve5(err ? "" : stdout.trim());
3994
+ });
3995
+ });
3996
+ const [branchRaw, diffRaw, statusRaw, upstreamRaw] = await Promise.all([
3997
+ git(["branch", "--show-current"]),
3998
+ git(["diff", "--stat"]),
3999
+ git(["status", "--porcelain"]),
4000
+ git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
4001
+ ]);
4002
+ const branch = branchRaw || "(detached)";
4003
+ const addMatch = /(\d+)\s+insertion/i.exec(diffRaw);
4004
+ const delMatch = /(\d+)\s+deletion/i.exec(diffRaw);
4005
+ const added = addMatch ? Number(addMatch[1]) : 0;
4006
+ const deleted = delMatch ? Number(delMatch[1]) : 0;
4007
+ const untracked = statusRaw.split("\n").filter((l) => l.startsWith("??")).length;
4008
+ const [behindRaw, aheadRaw] = (upstreamRaw || "0 0").split(" ");
4009
+ const behind = Number(behindRaw) || 0;
4010
+ const ahead = Number(aheadRaw) || 0;
4011
+ send(ws, { type: "git.info", payload: { branch, added, deleted, untracked, ahead, behind } });
4012
+ } catch {
4013
+ send(ws, { type: "git.info", payload: { branch: "", added: 0, deleted: 0, untracked: 0, ahead: 0, behind: 0 } });
4014
+ }
4015
+ }
4016
+
4017
+ // src/server/skills-handlers.ts
4018
+ import { promises as fs9 } from "fs";
4019
+ import path9 from "path";
4020
+ import JSZip from "jszip";
4021
+ import { wstackGlobalRoot } from "@wrongstack/core/utils";
4022
+ async function handleSkillsContent(ws, ctx, msg) {
4023
+ if (!ctx.skillLoader) {
4024
+ send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skills not enabled" } });
4025
+ return;
4026
+ }
4027
+ const contentPayload = msg.payload;
4028
+ if (!contentPayload?.name) {
4029
+ send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skill name is required" } });
4030
+ return;
4031
+ }
4032
+ try {
4033
+ const { name, source } = contentPayload;
4034
+ const entries = await ctx.skillLoader.listEntries();
4035
+ const entry = entries.find((e) => e.name.toLowerCase() === name.toLowerCase());
4036
+ if (!entry) {
4037
+ send(ws, { type: "skills.content", payload: { name, body: "", path: "", source, relatedFiles: [], references: [], error: `Skill "${name}" not found` } });
4038
+ return;
4039
+ }
4040
+ const body = await fs9.readFile(entry.path, "utf8");
4041
+ const skillDir = path9.dirname(entry.path);
4042
+ let relatedFiles = [];
4043
+ try {
4044
+ const files = await fs9.readdir(skillDir);
4045
+ relatedFiles = files.filter((f) => f !== path9.basename(entry.path)).map((f) => path9.join(skillDir, f));
4046
+ } catch {
4047
+ }
4048
+ const nameLower = name.toLowerCase();
4049
+ const refResults = await Promise.all(
4050
+ entries.filter((e) => e.name.toLowerCase() !== nameLower).map(async (e) => {
4051
+ try {
4052
+ const content = await fs9.readFile(e.path, "utf8");
4053
+ return [e.name, content.toLowerCase().includes(nameLower)];
4054
+ } catch {
4055
+ return [e.name, false];
4056
+ }
4057
+ })
4058
+ );
4059
+ const refs = refResults.filter(([, hasRef]) => hasRef).map(([n]) => n);
4060
+ send(ws, { type: "skills.content", payload: { name, body, path: entry.path, source, relatedFiles, references: refs } });
4061
+ } catch (err) {
4062
+ send(ws, { type: "skills.content", payload: { name: contentPayload.name, body: "", path: "", source: contentPayload.source, relatedFiles: [], references: [], error: errMessage(err) } });
4063
+ }
4064
+ }
4065
+ async function handleSkillsInstall(ws, ctx, msg) {
4066
+ if (!ctx.skillInstaller) {
4067
+ send(ws, { type: "skills.installed", payload: { success: false, error: "Skills not enabled" } });
4068
+ return;
4069
+ }
4070
+ const installPayload = msg.payload;
4071
+ if (!installPayload?.ref?.trim()) {
4072
+ send(ws, { type: "skills.installed", payload: { success: false, error: "Skill reference is required (e.g. owner/repo or https://github.com/owner/repo)" } });
4073
+ return;
4074
+ }
4075
+ try {
4076
+ const results = await ctx.skillInstaller.install(installPayload.ref.trim(), { global: installPayload.global });
4077
+ send(ws, {
4078
+ type: "skills.installed",
4079
+ payload: {
4080
+ success: true,
4081
+ results,
4082
+ error: null
4083
+ }
4084
+ });
4085
+ } catch (err) {
4086
+ send(ws, {
4087
+ type: "skills.installed",
4088
+ payload: {
4089
+ success: false,
4090
+ error: errMessage(err)
4091
+ }
4092
+ });
4093
+ }
4094
+ }
4095
+ async function handleSkillsUninstall(ws, ctx, msg) {
4096
+ if (!ctx.skillInstaller) {
4097
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skills not enabled" } });
4098
+ return;
4099
+ }
4100
+ const uninstallPayload = msg.payload;
4101
+ if (!uninstallPayload?.name?.trim()) {
4102
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skill name is required" } });
4103
+ return;
4104
+ }
4105
+ try {
4106
+ await ctx.skillInstaller.uninstall(uninstallPayload.name.trim(), { global: uninstallPayload.global });
4107
+ send(ws, { type: "skills.uninstalled", payload: { success: true, error: null } });
4108
+ } catch (err) {
4109
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: errMessage(err) } });
4110
+ }
4111
+ }
4112
+ async function handleSkillsUpdate(ws, ctx, msg) {
4113
+ if (!ctx.skillInstaller) {
4114
+ send(ws, { type: "skills.updated", payload: { success: false, error: "Skills not enabled" } });
4115
+ return;
4116
+ }
4117
+ const updatePayload = msg.payload;
4118
+ try {
4119
+ const result = await ctx.skillInstaller.update(updatePayload?.name, { global: updatePayload?.global });
4120
+ send(ws, {
4121
+ type: "skills.updated",
4122
+ payload: {
4123
+ success: true,
4124
+ error: null,
4125
+ updated: result.updated,
4126
+ unchanged: result.unchanged,
4127
+ errors: result.errors
4128
+ }
4129
+ });
4130
+ } catch (err) {
4131
+ send(ws, { type: "skills.updated", payload: { success: false, error: errMessage(err) } });
4132
+ }
4133
+ }
4134
+ async function handleSkillsCreate(ws, ctx, msg) {
4135
+ const createPayload = msg.payload;
4136
+ if (!createPayload?.name?.trim()) {
4137
+ send(ws, { type: "skills.created", payload: { success: false, error: "Skill name is required" } });
4138
+ return;
4139
+ }
4140
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(createPayload.name.trim())) {
4141
+ send(ws, { type: "skills.created", payload: { success: false, error: "Skill name must be kebab-case (e.g. my-new-skill)" } });
4142
+ return;
4143
+ }
4144
+ if (!createPayload?.description?.trim()) {
4145
+ send(ws, { type: "skills.created", payload: { success: false, error: "Description/trigger is required" } });
4146
+ return;
4147
+ }
4148
+ try {
4149
+ const targetDir = createPayload.scope === "global" ? path9.join(wstackGlobalRoot(), "skills", createPayload.name.trim()) : path9.join(ctx.projectRoot, ".wrongstack", "skills", createPayload.name.trim());
4150
+ try {
4151
+ await fs9.access(targetDir);
4152
+ send(ws, { type: "skills.created", payload: { success: false, error: `Skill "${createPayload.name}" already exists` } });
4153
+ return;
4154
+ } catch {
4155
+ }
4156
+ await fs9.mkdir(targetDir, { recursive: true });
4157
+ const lines = createPayload.description.trim().split("\n");
4158
+ const firstLine = lines[0].trim();
4159
+ const bodyLines = lines.slice(1).map((l) => l.trim()).filter(Boolean);
4160
+ const descriptionText = firstLine + (bodyLines.length > 0 ? `
4161
+ ${bodyLines.join("\n")}` : "");
4162
+ const trigger = bodyLines.find((l) => l.toLowerCase().startsWith("triggers:")) ?? "";
4163
+ const skillContent = [
4164
+ "---",
4165
+ `name: ${createPayload.name.trim()}`,
4166
+ "description: |",
4167
+ ` ${descriptionText.replace(/\n/g, "\n ")}`,
4168
+ `version: 1.0.0`,
4169
+ "---",
4170
+ "",
4171
+ `# ${createPayload.name.trim().split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}`,
4172
+ "",
4173
+ "## Overview",
4174
+ "",
4175
+ firstLine,
4176
+ "",
4177
+ ...bodyLines.length > 0 ? bodyLines.filter((l) => !l.toLowerCase().startsWith("triggers:")) : [],
4178
+ "",
4179
+ "## Rules",
4180
+ "- TODO: add your first rule",
4181
+ "",
4182
+ "## Patterns",
4183
+ "### Do",
4184
+ "```ts",
4185
+ "// TODO: add a good example",
4186
+ "```",
4187
+ "",
4188
+ "### Don't",
4189
+ "```ts",
4190
+ "// TODO: add a bad example",
4191
+ "```",
4192
+ "",
4193
+ "## Workflow",
4194
+ "1. TODO: describe step one",
4195
+ "2. TODO: describe step two",
4196
+ "",
4197
+ trigger ? `
4198
+ ${trigger}
4199
+ ` : "",
4200
+ "## Skills in scope",
4201
+ "- `bug-hunter` \u2014 for systematic bug detection patterns",
4202
+ "- `output-standards` \u2014 for standardized `<next_steps>` formatting"
4203
+ ].join("\n");
4204
+ await fs9.writeFile(path9.join(targetDir, "SKILL.md"), skillContent, "utf-8");
4205
+ send(ws, {
4206
+ type: "skills.created",
4207
+ payload: {
4208
+ success: true,
4209
+ error: null,
4210
+ skill: { name: createPayload.name.trim(), path: path9.join(targetDir, "SKILL.md"), scope: createPayload.scope }
4211
+ }
4212
+ });
4213
+ } catch (err) {
4214
+ send(ws, { type: "skills.created", payload: { success: false, error: errMessage(err) } });
4215
+ }
4216
+ }
4217
+ async function handleSkillsEdit(ws, ctx, msg) {
4218
+ if (!ctx.skillLoader) {
4219
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skills not enabled" } });
4220
+ return;
4221
+ }
4222
+ const editPayload = msg.payload;
4223
+ if (!editPayload?.name?.trim()) {
4224
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skill name is required" } });
4225
+ return;
4226
+ }
4227
+ if (!editPayload?.body) {
4228
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skill body is required" } });
4229
+ return;
4230
+ }
4231
+ try {
4232
+ const entries = await ctx.skillLoader.listEntries();
4233
+ const entry = entries.find((e) => e.name.toLowerCase() === editPayload.name.toLowerCase());
4234
+ if (!entry) {
4235
+ send(ws, { type: "skills.edited", payload: { success: false, error: `Skill "${editPayload.name}" not found` } });
4236
+ return;
4237
+ }
4238
+ if (entry.scope.includes("bundled")) {
4239
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Bundled skills cannot be edited" } });
4240
+ return;
4241
+ }
4242
+ await fs9.writeFile(entry.path, editPayload.body, "utf-8");
4243
+ send(ws, { type: "skills.edited", payload: { success: true, error: null } });
4244
+ } catch (err) {
4245
+ send(ws, { type: "skills.edited", payload: { success: false, error: errMessage(err) } });
4246
+ }
2909
4247
  }
2910
-
2911
- // src/server/shell-open.ts
2912
- import * as fs6 from "fs/promises";
2913
- import * as path7 from "path";
2914
- import { spawn as spawn2 } from "child_process";
2915
- var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
2916
- async function handleShellOpen(req, logger) {
4248
+ async function handleSkillsExport(ws, ctx) {
4249
+ if (!ctx.skillLoader) {
4250
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: "Skills not enabled" } });
4251
+ return;
4252
+ }
2917
4253
  try {
2918
- const resolved = path7.resolve(req.path);
2919
- await fs6.access(resolved);
2920
- if (METACHAR_REGEX.test(resolved)) {
2921
- return { success: false, message: "Path contains unsupported characters." };
2922
- }
2923
- const platform = process.platform;
2924
- const launch = (cmd, args, onError) => {
2925
- const child = spawn2(cmd, args, {
2926
- detached: true,
2927
- stdio: "ignore",
2928
- windowsHide: true
2929
- });
2930
- child.on("error", (err) => {
2931
- logger.warn(`shell.open spawn failed: ${err.message}`);
2932
- onError?.();
2933
- });
2934
- child.unref();
2935
- };
2936
- if (req.target === "file-manager") {
2937
- if (platform === "win32") launch("explorer", [resolved]);
2938
- else if (platform === "darwin") launch("open", [resolved]);
2939
- else launch("xdg-open", [resolved]);
2940
- } else if (req.target === "terminal") {
2941
- if (platform === "win32") {
2942
- launch("cmd", ["/c", "start", "cmd", "/k", "cd", "/d", resolved]);
2943
- } else if (platform === "darwin") {
2944
- launch("open", ["-a", "Terminal", resolved]);
2945
- } else {
2946
- launch(
2947
- "x-terminal-emulator",
2948
- [`--working-directory=${resolved}`],
2949
- () => launch(
2950
- "gnome-terminal",
2951
- [`--working-directory=${resolved}`],
2952
- () => launch("xterm", ["-e", `cd '${resolved}' && ${process.env["SHELL"] ?? "sh"}`])
2953
- )
2954
- );
4254
+ const entries = await ctx.skillLoader.listEntries();
4255
+ const zip = new JSZip();
4256
+ for (const entry of entries) {
4257
+ try {
4258
+ const body = await ctx.skillLoader.readBody(entry.name);
4259
+ const safeName = entry.name.replace(/\//g, "_");
4260
+ zip.file(`${safeName}/SKILL.md`, body);
4261
+ } catch {
2955
4262
  }
2956
- } else {
2957
- return { success: false, message: `Unknown shell.open target: ${String(req.target)}` };
2958
4263
  }
2959
- return { success: true, message: `Opened ${req.target} at ${resolved}` };
4264
+ const zipBuffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
4265
+ const zipBase64 = zipBuffer.toString("base64");
4266
+ send(ws, { type: "skills.exported", payload: { zipBase64, skillCount: entries.length, error: void 0 } });
2960
4267
  } catch (err) {
2961
- return { success: false, message: err instanceof Error ? err.message : String(err) };
4268
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: errMessage(err) } });
2962
4269
  }
2963
4270
  }
2964
4271
 
@@ -3076,15 +4383,22 @@ async function startWebUI(opts = {}) {
3076
4383
  sessionId: session.id,
3077
4384
  projectSlug: wpaths.projectSlug,
3078
4385
  projectRoot,
3079
- projectName: path8.basename(projectRoot),
4386
+ projectName: path10.basename(projectRoot),
3080
4387
  workingDir,
4388
+ clientType: "webui",
3081
4389
  pid: process.pid,
3082
4390
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
3083
4391
  });
3084
- statusTracker = new AgentStatusTracker({ events, registry });
4392
+ const fleetNotifier = new FleetNotifier({
4393
+ baseDir: wpaths.globalRoot,
4394
+ projectRoot,
4395
+ selfPid: process.pid
4396
+ });
4397
+ statusTracker = new AgentStatusTracker({ events, registry, onUpdate: () => fleetNotifier.notify() });
3085
4398
  statusTracker.start();
3086
4399
  const stopTracking = async () => {
3087
4400
  try {
4401
+ fleetNotifier.dispose();
3088
4402
  await registry.markClosing();
3089
4403
  statusTracker?.stop();
3090
4404
  } catch {
@@ -3124,6 +4438,13 @@ async function startWebUI(opts = {}) {
3124
4438
  supportsReasoning: resolvedModel.capabilities.reasoning
3125
4439
  } : void 0;
3126
4440
  const skillLoader = config.features.skills ? new DefaultSkillLoader2({ paths: wpaths }) : void 0;
4441
+ const skillInstaller = config.features.skills ? new SkillInstaller({
4442
+ manifestPath: path10.join(wstackGlobalRoot2(), "installed-skills.json"),
4443
+ projectSkillsDir: path10.join(projectRoot, ".wrongstack", "skills"),
4444
+ globalSkillsDir: path10.join(wstackGlobalRoot2(), "skills"),
4445
+ projectHash: projectHash(projectRoot),
4446
+ skillLoader
4447
+ }) : void 0;
3127
4448
  const systemPromptBuilder = new DefaultSystemPromptBuilder2({
3128
4449
  memoryStore,
3129
4450
  skillLoader,
@@ -3193,7 +4514,7 @@ async function startWebUI(opts = {}) {
3193
4514
  }
3194
4515
  } else {
3195
4516
  throw new Error(
3196
- "No provider configured. Run `wrongstack init` first, or configure via the WebUI."
4517
+ "No provider configured. Run `wrongstack auth` to set up, or configure via the WebUI."
3197
4518
  );
3198
4519
  }
3199
4520
  }
@@ -3281,7 +4602,7 @@ async function startWebUI(opts = {}) {
3281
4602
  const write = async () => {
3282
4603
  let raw;
3283
4604
  try {
3284
- raw = await fs7.readFile(globalConfigPath, "utf8");
4605
+ raw = await fs10.readFile(globalConfigPath, "utf8");
3285
4606
  } catch {
3286
4607
  raw = "{}";
3287
4608
  }
@@ -3590,7 +4911,7 @@ async function startWebUI(opts = {}) {
3590
4911
  inputCost,
3591
4912
  outputCost,
3592
4913
  cacheReadCost,
3593
- projectName: path8.basename(projectRoot) || projectRoot,
4914
+ projectName: path10.basename(projectRoot) || projectRoot,
3594
4915
  projectRoot,
3595
4916
  cwd: workingDir,
3596
4917
  mode: modeId,
@@ -3644,10 +4965,11 @@ async function startWebUI(opts = {}) {
3644
4965
  const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
3645
4966
  const RATE_LIMIT_WINDOW_MS = 6e4;
3646
4967
  const rateLimits = /* @__PURE__ */ new Map();
3647
- function checkRateLimit(ws, client) {
4968
+ let connSeq = 0;
4969
+ function checkRateLimit(_ws, client) {
3648
4970
  if (RATE_LIMIT_MESSAGES <= 0) return true;
3649
4971
  const now = Date.now();
3650
- const key = client.sessionId ?? String(ws);
4972
+ const key = client.connId;
3651
4973
  const limit = rateLimits.get(key);
3652
4974
  if (!limit || now > limit.resetAt) {
3653
4975
  rateLimits.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
@@ -3663,7 +4985,12 @@ async function startWebUI(opts = {}) {
3663
4985
  );
3664
4986
  const pendingConfirms = /* @__PURE__ */ new Map();
3665
4987
  const handleConnection = (ws) => {
3666
- const client = { ws, sessionId: session.id, connectedAt: Date.now() };
4988
+ const client = {
4989
+ ws,
4990
+ sessionId: session.id,
4991
+ connectedAt: Date.now(),
4992
+ connId: `c${++connSeq}`
4993
+ };
3667
4994
  clients.set(ws, client);
3668
4995
  void sessionStartPayload().then((payload) => {
3669
4996
  send(ws, { type: "session.start", payload });
@@ -3693,7 +5020,7 @@ async function startWebUI(opts = {}) {
3693
5020
  const rawObj = JSON.parse(data.toString());
3694
5021
  if (typeof rawObj === "object" && rawObj !== null) {
3695
5022
  const obj = rawObj;
3696
- if ("__proto__" in obj || "constructor" in obj || "prototype" in obj) {
5023
+ if (Object.hasOwn(obj, "__proto__") || Object.hasOwn(obj, "constructor") || Object.hasOwn(obj, "prototype")) {
3697
5024
  send(ws, {
3698
5025
  type: "error",
3699
5026
  payload: { phase: "parse", message: "Invalid message object" }
@@ -3714,8 +5041,9 @@ async function startWebUI(opts = {}) {
3714
5041
  }
3715
5042
  });
3716
5043
  ws.on("close", () => {
5044
+ const closing = clients.get(ws);
3717
5045
  clients.delete(ws);
3718
- rateLimits.delete(String(ws));
5046
+ if (closing) rateLimits.delete(closing.connId);
3719
5047
  if (pendingConfirms.size > 0) {
3720
5048
  for (const [id, resolve5] of pendingConfirms) {
3721
5049
  resolve5("no");
@@ -3741,11 +5069,27 @@ async function startWebUI(opts = {}) {
3741
5069
  { sampling: sessionLogging.sampling }
3742
5070
  );
3743
5071
  let eventsArmed = false;
5072
+ let disposeEvents = null;
5073
+ let fleetBroadcast = null;
3744
5074
  const armOnce = (label) => {
3745
5075
  if (eventsArmed) return;
3746
5076
  eventsArmed = true;
3747
5077
  console.log(`[WebUI] Backend ready (${label})`);
3748
- setupEvents({ events, broadcast, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge });
5078
+ disposeEvents = setupEvents({
5079
+ events,
5080
+ broadcast,
5081
+ clients,
5082
+ config,
5083
+ context,
5084
+ pendingConfirms,
5085
+ globalConfigPath,
5086
+ sessionBridge,
5087
+ wpaths,
5088
+ watcherMetrics,
5089
+ onFleetBroadcaster: (fn) => {
5090
+ fleetBroadcast = fn;
5091
+ }
5092
+ });
3749
5093
  };
3750
5094
  wssPrimary.on("listening", () => armOnce(`${wsHost}:${wsPort}`));
3751
5095
  wssPrimary.on("connection", handleConnection);
@@ -3782,33 +5126,33 @@ async function startWebUI(opts = {}) {
3782
5126
  });
3783
5127
  }
3784
5128
  async function touchProjectEntry(root, workDir) {
3785
- const resolved = path8.resolve(root);
5129
+ const resolved = path10.resolve(root);
3786
5130
  const manifest = await loadManifest(globalConfigPath);
3787
5131
  const now = (/* @__PURE__ */ new Date()).toISOString();
3788
- const existing = manifest.projects.find((p) => path8.resolve(p.root) === resolved);
5132
+ const existing = manifest.projects.find((p) => path10.resolve(p.root) === resolved);
3789
5133
  if (existing) {
3790
5134
  existing.lastSeen = now;
3791
- if (workDir) existing.lastWorkingDir = path8.resolve(workDir);
5135
+ if (workDir) existing.lastWorkingDir = path10.resolve(workDir);
3792
5136
  } else {
3793
5137
  manifest.projects.push({
3794
- name: path8.basename(resolved),
5138
+ name: path10.basename(resolved),
3795
5139
  root: resolved,
3796
5140
  slug: generateProjectSlug(resolved),
3797
5141
  createdAt: now,
3798
5142
  lastSeen: now,
3799
- lastWorkingDir: workDir ? path8.resolve(workDir) : void 0
5143
+ lastWorkingDir: workDir ? path10.resolve(workDir) : void 0
3800
5144
  });
3801
5145
  }
3802
5146
  await saveManifest(manifest, globalConfigPath);
3803
5147
  await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
3804
5148
  }
3805
5149
  function projectsJsonPath(globalConfigPath2) {
3806
- const base = path8.dirname(globalConfigPath2);
3807
- return path8.join(base, "projects.json");
5150
+ const base = path10.dirname(globalConfigPath2);
5151
+ return path10.join(base, "projects.json");
3808
5152
  }
3809
5153
  async function loadManifest(globalConfigPath2) {
3810
5154
  try {
3811
- const raw = await fs7.readFile(projectsJsonPath(globalConfigPath2), "utf8");
5155
+ const raw = await fs10.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3812
5156
  const parsed = JSON.parse(raw);
3813
5157
  return { projects: parsed.projects ?? [] };
3814
5158
  } catch {
@@ -3817,16 +5161,16 @@ async function startWebUI(opts = {}) {
3817
5161
  }
3818
5162
  async function saveManifest(manifest, globalConfigPath2) {
3819
5163
  const file = projectsJsonPath(globalConfigPath2);
3820
- await fs7.mkdir(path8.dirname(file), { recursive: true });
3821
- await fs7.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
5164
+ await fs10.mkdir(path10.dirname(file), { recursive: true });
5165
+ await fs10.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
3822
5166
  }
3823
5167
  function generateProjectSlug(rootPath) {
3824
5168
  return projectSlug(rootPath);
3825
5169
  }
3826
5170
  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 });
5171
+ const base = path10.dirname(globalConfigPath2);
5172
+ const dir = path10.join(base, "projects", slug);
5173
+ await fs10.mkdir(dir, { recursive: true });
3830
5174
  return dir;
3831
5175
  }
3832
5176
  async function handleMessage(ws, _client, msg) {
@@ -3936,7 +5280,7 @@ async function startWebUI(opts = {}) {
3936
5280
  context.readFiles.clear();
3937
5281
  context.fileMtimes.clear();
3938
5282
  tokenCounter.reset();
3939
- sendResult(ws, true, "Context cleared");
5283
+ sendResult2(ws, true, "Context cleared");
3940
5284
  broadcast(clients, {
3941
5285
  type: "session.start",
3942
5286
  payload: { ...await sessionStartPayload(), reset: true }
@@ -3973,13 +5317,13 @@ async function startWebUI(opts = {}) {
3973
5317
  repaired: report.repaired
3974
5318
  }
3975
5319
  });
3976
- sendResult(
5320
+ sendResult2(
3977
5321
  ws,
3978
5322
  true,
3979
5323
  `Compacted: ${report.before} \u2192 ${report.after} tokens (saved ~${Math.max(0, report.before - report.after)})`
3980
5324
  );
3981
5325
  } catch (err) {
3982
- sendResult(ws, false, errMessage(err));
5326
+ sendResult2(ws, false, errMessage(err));
3983
5327
  }
3984
5328
  break;
3985
5329
  }
@@ -3998,7 +5342,7 @@ async function startWebUI(opts = {}) {
3998
5342
  };
3999
5343
  broadcast(clients, { type: "context.repaired", payload });
4000
5344
  const removed = payload.removedToolUses.length + payload.removedToolResults.length + payload.removedMessages;
4001
- sendResult(
5345
+ sendResult2(
4002
5346
  ws,
4003
5347
  true,
4004
5348
  removed > 0 ? `Context repaired: removed ${removed} orphan protocol item(s)` : "Context repair found no orphan protocol blocks"
@@ -4032,14 +5376,14 @@ async function startWebUI(opts = {}) {
4032
5376
  );
4033
5377
  const custom = customModes.find((m) => m.id === id);
4034
5378
  if (!custom) {
4035
- sendResult(ws, false, `Unknown context mode "${id}"`);
5379
+ sendResult2(ws, false, `Unknown context mode "${id}"`);
4036
5380
  break;
4037
5381
  }
4038
5382
  policy = custom;
4039
5383
  }
4040
5384
  context.meta["contextWindowMode"] = policy.id;
4041
5385
  context.meta["contextWindowPolicy"] = policy;
4042
- sendResult(ws, true, `Context mode switched to ${policy.id}`);
5386
+ sendResult2(ws, true, `Context mode switched to ${policy.id}`);
4043
5387
  broadcast(clients, {
4044
5388
  type: "context.mode.changed",
4045
5389
  payload: { id: policy.id, name: policy.name, policy }
@@ -4059,7 +5403,7 @@ async function startWebUI(opts = {}) {
4059
5403
  aggressiveOn: "soft",
4060
5404
  targetLoad: 0.65
4061
5405
  });
4062
- sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
5406
+ sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
4063
5407
  break;
4064
5408
  }
4065
5409
  case "context.mode.update": {
@@ -4075,7 +5419,7 @@ async function startWebUI(opts = {}) {
4075
5419
  preserveK: payload.preserveK,
4076
5420
  eliseThreshold: payload.eliseThreshold
4077
5421
  });
4078
- sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
5422
+ sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
4079
5423
  break;
4080
5424
  }
4081
5425
  case "context.mode.delete": {
@@ -4085,7 +5429,7 @@ async function startWebUI(opts = {}) {
4085
5429
  context.meta["contextWindowPolicy"] = resolveContextWindowPolicy({}, DEFAULT_CONTEXT_WINDOW_MODE_ID);
4086
5430
  }
4087
5431
  const result = customModeStore.remove(id);
4088
- sendResult(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
5432
+ sendResult2(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
4089
5433
  break;
4090
5434
  }
4091
5435
  case "providers.list": {
@@ -4166,14 +5510,15 @@ async function startWebUI(opts = {}) {
4166
5510
  context.provider = newProv;
4167
5511
  updateAutoCompactionMaxContext?.(newProv);
4168
5512
  try {
4169
- configWriteLock = configWriteLock.then(async () => {
4170
- const raw = await fs7.readFile(globalConfigPath, "utf8");
5513
+ const next = configWriteLock.then(async () => {
5514
+ const raw = await fs10.readFile(globalConfigPath, "utf8");
4171
5515
  const parsed = JSON.parse(raw);
4172
5516
  parsed.provider = newProvider;
4173
5517
  parsed.model = newModel;
4174
5518
  await atomicWrite5(globalConfigPath, JSON.stringify(parsed, null, 2));
4175
5519
  });
4176
- await configWriteLock;
5520
+ configWriteLock = next.then(() => void 0, () => void 0);
5521
+ await next;
4177
5522
  } catch (err) {
4178
5523
  console.warn(JSON.stringify({
4179
5524
  level: "warn",
@@ -4326,13 +5671,13 @@ async function startWebUI(opts = {}) {
4326
5671
  const { id } = msg.payload;
4327
5672
  try {
4328
5673
  if (id === session.id) {
4329
- sendResult(ws, false, "Cannot delete the active session");
5674
+ sendResult2(ws, false, "Cannot delete the active session");
4330
5675
  break;
4331
5676
  }
4332
5677
  await sessionStore.delete(id);
4333
- sendResult(ws, true, `Session ${id} deleted`);
5678
+ sendResult2(ws, true, `Session ${id} deleted`);
4334
5679
  } catch (err) {
4335
- sendResult(ws, false, errMessage(err));
5680
+ sendResult2(ws, false, errMessage(err));
4336
5681
  }
4337
5682
  break;
4338
5683
  }
@@ -4340,7 +5685,7 @@ async function startWebUI(opts = {}) {
4340
5685
  const { id } = msg.payload;
4341
5686
  try {
4342
5687
  if (id === session.id) {
4343
- sendResult(ws, false, "Session is already active");
5688
+ sendResult2(ws, false, "Session is already active");
4344
5689
  break;
4345
5690
  }
4346
5691
  const resumed = await sessionStore.resume(id);
@@ -4370,14 +5715,14 @@ async function startWebUI(opts = {}) {
4370
5715
  replayUsage: resumed.data.usage
4371
5716
  }
4372
5717
  });
4373
- sendResult(ws, true, `Resumed session ${id}`);
5718
+ sendResult2(ws, true, `Resumed session ${id}`);
4374
5719
  } catch (err) {
4375
- sendResult(ws, false, errMessage(err));
5720
+ sendResult2(ws, false, errMessage(err));
4376
5721
  }
4377
5722
  break;
4378
5723
  }
4379
5724
  case "session.save": {
4380
- sendResult(ws, true, `Session ${session.id} is auto-saved`);
5725
+ sendResult2(ws, true, `Session ${session.id} is auto-saved`);
4381
5726
  break;
4382
5727
  }
4383
5728
  case "tools.list": {
@@ -4400,6 +5745,27 @@ async function startWebUI(opts = {}) {
4400
5745
  return handleMemoryRemember(ws, msg, memoryStore);
4401
5746
  case "memory.forget":
4402
5747
  return handleMemoryForget(ws, msg, memoryStore);
5748
+ // ── MCP operations — delegated to shared handlers (mcp-handlers.ts) ──
5749
+ case "mcp.list":
5750
+ return handleMcpList(ws, msg, config, globalConfigPath, void 0);
5751
+ case "mcp.add":
5752
+ return handleMcpAdd(ws, msg, config, globalConfigPath, void 0);
5753
+ case "mcp.remove":
5754
+ return handleMcpRemove(ws, msg, config, globalConfigPath, void 0);
5755
+ case "mcp.update":
5756
+ return handleMcpUpdate(ws, msg, config, globalConfigPath);
5757
+ case "mcp.wake":
5758
+ return handleMcpWake(ws, msg, config, globalConfigPath, void 0);
5759
+ case "mcp.sleep":
5760
+ return handleMcpSleep(ws, msg, config, globalConfigPath, void 0);
5761
+ case "mcp.discover":
5762
+ return handleMcpDiscover(ws, msg, config, globalConfigPath);
5763
+ case "mcp.enable":
5764
+ return handleMcpEnable(ws, msg, config, globalConfigPath);
5765
+ case "mcp.disable":
5766
+ return handleMcpDisable(ws, msg, config, globalConfigPath);
5767
+ case "mcp.restart":
5768
+ return handleMcpRestart(ws, msg, config, globalConfigPath);
4403
5769
  case "skills.list": {
4404
5770
  if (!skillLoader) {
4405
5771
  send(ws, { type: "skills.list", payload: { skills: [], enabled: false } });
@@ -4409,6 +5775,18 @@ async function startWebUI(opts = {}) {
4409
5775
  const manifests = await skillLoader.list();
4410
5776
  const entries = await skillLoader.listEntries();
4411
5777
  const byName = new Map(entries.map((e) => [e.name, e]));
5778
+ const sourceUrlsByName = /* @__PURE__ */ new Map();
5779
+ const refsByName = /* @__PURE__ */ new Map();
5780
+ if (skillInstaller) {
5781
+ try {
5782
+ const installed = await skillInstaller.listInstalled();
5783
+ for (const entry of installed) {
5784
+ sourceUrlsByName.set(entry.name, entry.source);
5785
+ refsByName.set(entry.name, entry.ref);
5786
+ }
5787
+ } catch {
5788
+ }
5789
+ }
4412
5790
  send(ws, {
4413
5791
  type: "skills.list",
4414
5792
  payload: {
@@ -4418,6 +5796,8 @@ async function startWebUI(opts = {}) {
4418
5796
  description: m.description,
4419
5797
  version: m.version ?? "",
4420
5798
  source: m.source,
5799
+ sourceUrl: sourceUrlsByName.get(m.name) ?? "",
5800
+ ref: refsByName.get(m.name) ?? "",
4421
5801
  path: m.path,
4422
5802
  trigger: byName.get(m.name)?.trigger ?? "",
4423
5803
  scope: byName.get(m.name)?.scope ?? []
@@ -4436,6 +5816,261 @@ async function startWebUI(opts = {}) {
4436
5816
  }
4437
5817
  break;
4438
5818
  }
5819
+ case "skills.content": {
5820
+ if (!skillLoader) {
5821
+ send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skills not enabled" } });
5822
+ break;
5823
+ }
5824
+ const contentPayload = msg.payload;
5825
+ if (!contentPayload?.name) {
5826
+ send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skill name is required" } });
5827
+ break;
5828
+ }
5829
+ try {
5830
+ const { name, source } = contentPayload;
5831
+ const entries = await skillLoader.listEntries();
5832
+ const entry = entries.find((e) => e.name.toLowerCase() === name.toLowerCase());
5833
+ if (!entry) {
5834
+ send(ws, { type: "skills.content", payload: { name, body: "", path: "", source, relatedFiles: [], references: [], error: `Skill "${name}" not found` } });
5835
+ break;
5836
+ }
5837
+ const body = await skillLoader.readBody(name);
5838
+ const skillDir = path10.dirname(entry.path);
5839
+ let relatedFiles = [];
5840
+ try {
5841
+ const files = await fs10.readdir(skillDir);
5842
+ relatedFiles = files.filter((f) => f !== path10.basename(entry.path)).map((f) => path10.join(skillDir, f));
5843
+ } catch {
5844
+ }
5845
+ const refs = [];
5846
+ for (const e of entries) {
5847
+ if (e.name.toLowerCase() === name.toLowerCase()) continue;
5848
+ try {
5849
+ const content = await skillLoader.readBody(e.name);
5850
+ if (content.toLowerCase().includes(name.toLowerCase())) {
5851
+ refs.push(e.name);
5852
+ }
5853
+ } catch {
5854
+ }
5855
+ }
5856
+ send(ws, { type: "skills.content", payload: { name, body, path: entry.path, source, relatedFiles, references: refs } });
5857
+ } catch (err) {
5858
+ send(ws, { type: "skills.content", payload: { name: contentPayload.name, body: "", path: "", source: contentPayload.source, relatedFiles: [], references: [], error: errMessage(err) } });
5859
+ }
5860
+ break;
5861
+ }
5862
+ case "skills.install": {
5863
+ if (!skillInstaller) {
5864
+ send(ws, { type: "skills.installed", payload: { success: false, error: "Skills not enabled" } });
5865
+ break;
5866
+ }
5867
+ const installPayload = msg.payload;
5868
+ if (!installPayload?.ref?.trim()) {
5869
+ send(ws, { type: "skills.installed", payload: { success: false, error: "Skill reference is required (e.g. owner/repo or https://github.com/owner/repo)" } });
5870
+ break;
5871
+ }
5872
+ try {
5873
+ const results = await skillInstaller.install(installPayload.ref.trim(), { global: installPayload.global });
5874
+ send(ws, {
5875
+ type: "skills.installed",
5876
+ payload: {
5877
+ success: true,
5878
+ results,
5879
+ error: null
5880
+ }
5881
+ });
5882
+ } catch (err) {
5883
+ send(ws, {
5884
+ type: "skills.installed",
5885
+ payload: {
5886
+ success: false,
5887
+ error: errMessage(err)
5888
+ }
5889
+ });
5890
+ }
5891
+ break;
5892
+ }
5893
+ case "skills.uninstall": {
5894
+ if (!skillInstaller) {
5895
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skills not enabled" } });
5896
+ break;
5897
+ }
5898
+ const uninstallPayload = msg.payload;
5899
+ if (!uninstallPayload?.name?.trim()) {
5900
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skill name is required" } });
5901
+ break;
5902
+ }
5903
+ try {
5904
+ await skillInstaller.uninstall(uninstallPayload.name.trim(), { global: uninstallPayload.global });
5905
+ send(ws, { type: "skills.uninstalled", payload: { success: true, error: null } });
5906
+ } catch (err) {
5907
+ send(ws, { type: "skills.uninstalled", payload: { success: false, error: errMessage(err) } });
5908
+ }
5909
+ break;
5910
+ }
5911
+ case "skills.update": {
5912
+ if (!skillInstaller) {
5913
+ send(ws, { type: "skills.updated", payload: { success: false, error: "Skills not enabled" } });
5914
+ break;
5915
+ }
5916
+ const updatePayload = msg.payload;
5917
+ try {
5918
+ const result = await skillInstaller.update(updatePayload?.name, { global: updatePayload?.global });
5919
+ send(ws, {
5920
+ type: "skills.updated",
5921
+ payload: {
5922
+ success: true,
5923
+ error: null,
5924
+ updated: result.updated,
5925
+ unchanged: result.unchanged,
5926
+ errors: result.errors
5927
+ }
5928
+ });
5929
+ } catch (err) {
5930
+ send(ws, { type: "skills.updated", payload: { success: false, error: errMessage(err) } });
5931
+ }
5932
+ break;
5933
+ }
5934
+ case "skills.create": {
5935
+ const createPayload = msg.payload;
5936
+ if (!createPayload?.name?.trim()) {
5937
+ send(ws, { type: "skills.created", payload: { success: false, error: "Skill name is required" } });
5938
+ break;
5939
+ }
5940
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(createPayload.name.trim())) {
5941
+ send(ws, { type: "skills.created", payload: { success: false, error: "Skill name must be kebab-case (e.g. my-new-skill)" } });
5942
+ break;
5943
+ }
5944
+ if (!createPayload?.description?.trim()) {
5945
+ send(ws, { type: "skills.created", payload: { success: false, error: "Description/trigger is required" } });
5946
+ break;
5947
+ }
5948
+ try {
5949
+ const targetDir = createPayload.scope === "global" ? path10.join(wstackGlobalRoot2(), "skills", createPayload.name.trim()) : path10.join(projectRoot, ".wrongstack", "skills", createPayload.name.trim());
5950
+ try {
5951
+ await fs10.access(targetDir);
5952
+ send(ws, { type: "skills.created", payload: { success: false, error: `Skill "${createPayload.name}" already exists` } });
5953
+ break;
5954
+ } catch {
5955
+ }
5956
+ await fs10.mkdir(targetDir, { recursive: true });
5957
+ const lines = createPayload.description.trim().split("\n");
5958
+ const firstLine = lines[0].trim();
5959
+ const bodyLines = lines.slice(1).map((l) => l.trim()).filter(Boolean);
5960
+ const descriptionText = firstLine + (bodyLines.length > 0 ? `
5961
+ ${bodyLines.join("\n")}` : "");
5962
+ const trigger = bodyLines.find((l) => l.toLowerCase().startsWith("triggers:")) ?? "";
5963
+ const skillContent = [
5964
+ "---",
5965
+ `name: ${createPayload.name.trim()}`,
5966
+ "description: |",
5967
+ ` ${descriptionText.replace(/\n/g, "\n ")}`,
5968
+ `version: 1.0.0`,
5969
+ "---",
5970
+ "",
5971
+ `# ${createPayload.name.trim().split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}`,
5972
+ "",
5973
+ "## Overview",
5974
+ "",
5975
+ firstLine,
5976
+ "",
5977
+ ...bodyLines.length > 0 ? bodyLines.filter((l) => !l.toLowerCase().startsWith("triggers:")) : [],
5978
+ "",
5979
+ "## Rules",
5980
+ "- TODO: add your first rule",
5981
+ "",
5982
+ "## Patterns",
5983
+ "### Do",
5984
+ "```ts",
5985
+ "// TODO: add a good example",
5986
+ "```",
5987
+ "",
5988
+ "### Don't",
5989
+ "```ts",
5990
+ "// TODO: add a bad example",
5991
+ "```",
5992
+ "",
5993
+ "## Workflow",
5994
+ "1. TODO: describe step one",
5995
+ "2. TODO: describe step two",
5996
+ "",
5997
+ trigger ? `
5998
+ ${trigger}
5999
+ ` : "",
6000
+ "## Skills in scope",
6001
+ "- `bug-hunter` \u2014 for systematic bug detection patterns",
6002
+ "- `output-standards` \u2014 for standardized `<next_steps>` formatting"
6003
+ ].join("\n");
6004
+ await fs10.writeFile(path10.join(targetDir, "SKILL.md"), skillContent, "utf-8");
6005
+ send(ws, {
6006
+ type: "skills.created",
6007
+ payload: {
6008
+ success: true,
6009
+ error: null,
6010
+ skill: { name: createPayload.name.trim(), path: path10.join(targetDir, "SKILL.md"), scope: createPayload.scope }
6011
+ }
6012
+ });
6013
+ } catch (err) {
6014
+ send(ws, { type: "skills.created", payload: { success: false, error: errMessage(err) } });
6015
+ }
6016
+ break;
6017
+ }
6018
+ case "skills.edit": {
6019
+ if (!skillLoader) {
6020
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skills not enabled" } });
6021
+ break;
6022
+ }
6023
+ const editPayload = msg.payload;
6024
+ if (!editPayload?.name?.trim()) {
6025
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skill name is required" } });
6026
+ break;
6027
+ }
6028
+ if (!editPayload?.body) {
6029
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Skill body is required" } });
6030
+ break;
6031
+ }
6032
+ try {
6033
+ const entries = await skillLoader.listEntries();
6034
+ const entry = entries.find((e) => e.name.toLowerCase() === editPayload.name.toLowerCase());
6035
+ if (!entry) {
6036
+ send(ws, { type: "skills.edited", payload: { success: false, error: `Skill "${editPayload.name}" not found` } });
6037
+ break;
6038
+ }
6039
+ if (entry.scope.includes("bundled")) {
6040
+ send(ws, { type: "skills.edited", payload: { success: false, error: "Bundled skills cannot be edited" } });
6041
+ break;
6042
+ }
6043
+ await fs10.writeFile(entry.path, editPayload.body, "utf-8");
6044
+ send(ws, { type: "skills.edited", payload: { success: true, error: null } });
6045
+ } catch (err) {
6046
+ send(ws, { type: "skills.edited", payload: { success: false, error: errMessage(err) } });
6047
+ }
6048
+ break;
6049
+ }
6050
+ case "skills.export": {
6051
+ if (!skillLoader) {
6052
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: "Skills not enabled" } });
6053
+ break;
6054
+ }
6055
+ try {
6056
+ const entries = await skillLoader.listEntries();
6057
+ const zip = new JSZip2();
6058
+ for (const entry of entries) {
6059
+ try {
6060
+ const body = await skillLoader.readBody(entry.name);
6061
+ const safeName = entry.name.replace(/\//g, "_");
6062
+ zip.file(`${safeName}/SKILL.md`, body);
6063
+ } catch {
6064
+ }
6065
+ }
6066
+ const zipBuffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
6067
+ const zipBase64 = zipBuffer.toString("base64");
6068
+ send(ws, { type: "skills.exported", payload: { zipBase64, skillCount: entries.length, error: void 0 } });
6069
+ } catch (err) {
6070
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: errMessage(err) } });
6071
+ }
6072
+ break;
6073
+ }
4439
6074
  case "diag.get": {
4440
6075
  const usage = tokenCounter.total();
4441
6076
  send(ws, {
@@ -4463,194 +6098,84 @@ async function startWebUI(opts = {}) {
4463
6098
  break;
4464
6099
  }
4465
6100
  case "todos.get": {
4466
- send(ws, {
4467
- type: "todos.updated",
4468
- payload: { todos: [...context.todos] }
4469
- });
6101
+ const ctx = {
6102
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6103
+ send: (w, m) => send(w, m),
6104
+ broadcast: (m) => broadcast(clients, m)
6105
+ };
6106
+ handleTodosGet(ctx, ws);
4470
6107
  break;
4471
6108
  }
4472
6109
  case "todos.clear": {
4473
- context.state.replaceTodos([]);
4474
- sendResult(ws, true, "Todos cleared");
4475
- broadcast(clients, { type: "todos.updated", payload: { todos: [] } });
6110
+ const ctx = {
6111
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6112
+ send: (w, m) => send(w, m),
6113
+ broadcast: (m) => broadcast(clients, m)
6114
+ };
6115
+ handleTodosClear(ctx, ws);
4476
6116
  break;
4477
6117
  }
4478
6118
  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 } });
6119
+ const ctx = {
6120
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6121
+ send: (w, m) => send(w, m),
6122
+ broadcast: (m) => broadcast(clients, m)
6123
+ };
6124
+ handleTodosRemove(ctx, ws, msg.payload);
4500
6125
  break;
4501
6126
  }
4502
6127
  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
- }
6128
+ const ctx = {
6129
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6130
+ send: (w, m) => send(w, m),
6131
+ broadcast: (m) => broadcast(clients, m)
6132
+ };
6133
+ await handleTasksGet(ctx, ws);
4518
6134
  break;
4519
6135
  }
4520
6136
  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
- }
6137
+ const ctx = {
6138
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6139
+ send: (w, m) => send(w, m),
6140
+ broadcast: (m) => broadcast(clients, m)
6141
+ };
6142
+ await handlePlanGet(ctx, ws);
4556
6143
  break;
4557
6144
  }
4558
6145
  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
- }
6146
+ const ctx = {
6147
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6148
+ send: (w, m) => send(w, m),
6149
+ broadcast: (m) => broadcast(clients, m)
6150
+ };
6151
+ await handlePlanTemplateUse(ctx, ws, msg.payload.template);
4585
6152
  break;
4586
6153
  }
4587
6154
  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
6155
+ const ctx = {
6156
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6157
+ send: (w, m) => send(w, m),
6158
+ broadcast: (m) => broadcast(clients, m)
4600
6159
  };
4601
- context.state.replaceTodos(next);
4602
- sendResult(ws, true, `Todo "${existing.content}" updated`);
4603
- broadcast(clients, { type: "todos.updated", payload: { todos: next } });
6160
+ handleTodoUpdate(ctx, ws, msg.payload);
4604
6161
  break;
4605
6162
  }
4606
6163
  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
- }
6164
+ const ctx = {
6165
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6166
+ send: (w, m) => send(w, m),
6167
+ broadcast: (m) => broadcast(clients, m)
6168
+ };
6169
+ await handleTaskUpdate(ctx, ws, msg.payload);
4627
6170
  break;
4628
6171
  }
4629
6172
  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
- }
6173
+ const ctx = {
6174
+ context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
6175
+ send: (w, m) => send(w, m),
6176
+ broadcast: (m) => broadcast(clients, m)
6177
+ };
6178
+ await handlePlanItemUpdate(ctx, ws, msg.payload);
4654
6179
  break;
4655
6180
  }
4656
6181
  // ── File operations — delegated to shared handlers (file-handlers.ts) ──
@@ -4720,13 +6245,13 @@ async function startWebUI(opts = {}) {
4720
6245
  provider: config.provider,
4721
6246
  model: config.model
4722
6247
  });
4723
- sendResult(ws, true, `Switched to mode "${id}"`);
6248
+ sendResult2(ws, true, `Switched to mode "${id}"`);
4724
6249
  broadcast(clients, {
4725
6250
  type: "session.start",
4726
6251
  payload: { ...await sessionStartPayload() }
4727
6252
  });
4728
6253
  } catch (err) {
4729
- sendResult(ws, false, errMessage(err));
6254
+ sendResult2(ws, false, errMessage(err));
4730
6255
  }
4731
6256
  break;
4732
6257
  }
@@ -4780,13 +6305,13 @@ async function startWebUI(opts = {}) {
4780
6305
  const { getProcessRegistry } = await import("@wrongstack/tools");
4781
6306
  const proc = getProcessRegistry().get(pid);
4782
6307
  if (proc?.protected) {
4783
- sendResult(ws, false, `Cannot kill protected process (PID ${pid})`);
6308
+ sendResult2(ws, false, `Cannot kill protected process (PID ${pid})`);
4784
6309
  break;
4785
6310
  }
4786
6311
  getProcessRegistry().kill(pid);
4787
- sendResult(ws, true, `Killed PID ${pid}`);
6312
+ sendResult2(ws, true, `Killed PID ${pid}`);
4788
6313
  } catch (err) {
4789
- sendResult(ws, false, errMessage(err));
6314
+ sendResult2(ws, false, errMessage(err));
4790
6315
  }
4791
6316
  break;
4792
6317
  }
@@ -4794,47 +6319,25 @@ async function startWebUI(opts = {}) {
4794
6319
  try {
4795
6320
  const { getProcessRegistry } = await import("@wrongstack/tools");
4796
6321
  getProcessRegistry().killAll();
4797
- sendResult(ws, true, "All processes killed");
6322
+ sendResult2(ws, true, "All processes killed");
4798
6323
  } catch (err) {
4799
- sendResult(ws, false, errMessage(err));
6324
+ sendResult2(ws, false, errMessage(err));
4800
6325
  }
4801
6326
  break;
4802
6327
  }
4803
6328
  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
- });
6329
+ await handleGitInfo(ws, projectRoot);
6330
+ break;
6331
+ }
6332
+ case "webui.shutdown": {
6333
+ console.log("[WebUI] Shutdown requested from client");
6334
+ process.kill(process.pid, "SIGINT");
4832
6335
  break;
4833
6336
  }
4834
6337
  case "goal.get": {
4835
6338
  try {
4836
- const goalPath = path8.join(projectRoot, ".wrongstack", "goal.json");
4837
- const raw = await fs7.readFile(goalPath, "utf8");
6339
+ const goalPath = resolveWstackPaths({ projectRoot }).projectGoal;
6340
+ const raw = await fs10.readFile(goalPath, "utf8");
4838
6341
  const goal = JSON.parse(raw);
4839
6342
  broadcast(clients, { type: "goal.updated", payload: goal });
4840
6343
  } catch {
@@ -4845,7 +6348,7 @@ async function startWebUI(opts = {}) {
4845
6348
  case "autonomy.switch": {
4846
6349
  const { mode } = msg.payload;
4847
6350
  context.meta["autonomy"] = mode;
4848
- sendResult(ws, true, `Autonomy mode set to "${mode}"`);
6351
+ sendResult2(ws, true, `Autonomy mode set to "${mode}"`);
4849
6352
  broadcast(clients, { type: "prefs.updated", payload: { autonomy: mode } });
4850
6353
  void persistPrefsToConfig({ autonomy: mode });
4851
6354
  break;
@@ -4894,7 +6397,7 @@ async function startWebUI(opts = {}) {
4894
6397
  try {
4895
6398
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4896
6399
  const rewinder = new DefaultSessionRewinder(
4897
- path8.join(projectRoot, ".wrongstack", "sessions"),
6400
+ path10.join(projectRoot, ".wrongstack", "sessions"),
4898
6401
  projectRoot
4899
6402
  );
4900
6403
  const checkpoints = await rewinder.listCheckpoints(session.id);
@@ -4915,18 +6418,18 @@ async function startWebUI(opts = {}) {
4915
6418
  try {
4916
6419
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4917
6420
  const rewinder = new DefaultSessionRewinder(
4918
- path8.join(projectRoot, ".wrongstack", "sessions"),
6421
+ path10.join(projectRoot, ".wrongstack", "sessions"),
4919
6422
  projectRoot
4920
6423
  );
4921
6424
  await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
4922
6425
  await context.session.truncateToCheckpoint(checkpointIndex);
4923
- sendResult(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
6426
+ sendResult2(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
4924
6427
  broadcast(clients, {
4925
6428
  type: "session.start",
4926
6429
  payload: { ...await sessionStartPayload(), reset: true }
4927
6430
  });
4928
6431
  } catch (err) {
4929
- sendResult(ws, false, errMessage(err));
6432
+ sendResult2(ws, false, errMessage(err));
4930
6433
  }
4931
6434
  break;
4932
6435
  }
@@ -4949,9 +6452,9 @@ async function startWebUI(opts = {}) {
4949
6452
  case "projects.add": {
4950
6453
  const { root: addRoot, name: displayName } = msg.payload;
4951
6454
  try {
4952
- const resolved = path8.resolve(addRoot);
4953
- await fs7.access(resolved);
4954
- const stat2 = await fs7.stat(resolved);
6455
+ const resolved = path10.resolve(addRoot);
6456
+ await fs10.access(resolved);
6457
+ const stat2 = await fs10.stat(resolved);
4955
6458
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4956
6459
  const manifest = await loadManifest(globalConfigPath);
4957
6460
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -4967,7 +6470,7 @@ async function startWebUI(opts = {}) {
4967
6470
  });
4968
6471
  break;
4969
6472
  }
4970
- const name = displayName?.trim() || path8.basename(resolved);
6473
+ const name = displayName?.trim() || path10.basename(resolved);
4971
6474
  const slug = generateProjectSlug(resolved);
4972
6475
  await ensureProjectDataDir(slug, globalConfigPath);
4973
6476
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -4986,7 +6489,7 @@ async function startWebUI(opts = {}) {
4986
6489
  send(ws, {
4987
6490
  type: "projects.added",
4988
6491
  payload: {
4989
- name: path8.basename(addRoot),
6492
+ name: path10.basename(addRoot),
4990
6493
  root: addRoot,
4991
6494
  slug: "",
4992
6495
  message: errMessage(err)
@@ -4998,17 +6501,17 @@ async function startWebUI(opts = {}) {
4998
6501
  case "projects.select": {
4999
6502
  const { root: selRoot, name: selName } = msg.payload;
5000
6503
  try {
5001
- const resolved = path8.resolve(selRoot);
6504
+ const resolved = path10.resolve(selRoot);
5002
6505
  try {
5003
- await fs7.access(resolved);
5004
- const stat2 = await fs7.stat(resolved);
6506
+ await fs10.access(resolved);
6507
+ const stat2 = await fs10.stat(resolved);
5005
6508
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
5006
6509
  } catch (err) {
5007
6510
  send(ws, {
5008
6511
  type: "projects.selected",
5009
6512
  payload: {
5010
6513
  root: selRoot,
5011
- name: selName || path8.basename(selRoot),
6514
+ name: selName || path10.basename(selRoot),
5012
6515
  message: `Cannot switch: ${errMessage(err)}`
5013
6516
  }
5014
6517
  });
@@ -5020,7 +6523,7 @@ async function startWebUI(opts = {}) {
5020
6523
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
5021
6524
  entry.lastWorkingDir = resolved;
5022
6525
  } else {
5023
- const name = selName?.trim() || path8.basename(resolved);
6526
+ const name = selName?.trim() || path10.basename(resolved);
5024
6527
  const slug = generateProjectSlug(resolved);
5025
6528
  manifest.projects.push({
5026
6529
  name,
@@ -5061,13 +6564,13 @@ async function startWebUI(opts = {}) {
5061
6564
  });
5062
6565
  } catch {
5063
6566
  }
5064
- const newSessionsDir = path8.join(
5065
- path8.dirname(globalConfigPath),
6567
+ const newSessionsDir = path10.join(
6568
+ path10.dirname(globalConfigPath),
5066
6569
  "projects",
5067
6570
  switchSlug,
5068
6571
  "sessions"
5069
6572
  );
5070
- await fs7.mkdir(newSessionsDir, { recursive: true });
6573
+ await fs10.mkdir(newSessionsDir, { recursive: true });
5071
6574
  const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
5072
6575
  const oldSessionId = session.id;
5073
6576
  try {
@@ -5099,8 +6602,9 @@ async function startWebUI(opts = {}) {
5099
6602
  sessionId: session.id,
5100
6603
  projectSlug: switchSlug,
5101
6604
  projectRoot,
5102
- projectName: path8.basename(projectRoot),
6605
+ projectName: path10.basename(projectRoot),
5103
6606
  workingDir,
6607
+ clientType: "webui",
5104
6608
  pid: process.pid,
5105
6609
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
5106
6610
  });
@@ -5110,8 +6614,8 @@ async function startWebUI(opts = {}) {
5110
6614
  type: "projects.selected",
5111
6615
  payload: {
5112
6616
  root: resolved,
5113
- name: selName || path8.basename(resolved),
5114
- message: `Switched to ${selName || path8.basename(resolved)}`
6617
+ name: selName || path10.basename(resolved),
6618
+ message: `Switched to ${selName || path10.basename(resolved)}`
5115
6619
  }
5116
6620
  });
5117
6621
  broadcast(clients, {
@@ -5134,7 +6638,7 @@ async function startWebUI(opts = {}) {
5134
6638
  type: "projects.selected",
5135
6639
  payload: {
5136
6640
  root: selRoot,
5137
- name: selName || path8.basename(selRoot),
6641
+ name: selName || path10.basename(selRoot),
5138
6642
  message: errMessage(err)
5139
6643
  }
5140
6644
  });
@@ -5145,17 +6649,17 @@ async function startWebUI(opts = {}) {
5145
6649
  case "working_dir.set": {
5146
6650
  const { path: newPath } = msg.payload;
5147
6651
  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}`);
6652
+ const resolved = path10.resolve(projectRoot, newPath);
6653
+ if (!resolved.startsWith(projectRoot + path10.sep) && resolved !== projectRoot) {
6654
+ sendResult2(ws, false, `Path must stay inside the project root: ${projectRoot}`);
5151
6655
  break;
5152
6656
  }
5153
6657
  try {
5154
- await fs7.access(resolved);
5155
- const stat2 = await fs7.stat(resolved);
6658
+ await fs10.access(resolved);
6659
+ const stat2 = await fs10.stat(resolved);
5156
6660
  if (!stat2.isDirectory()) throw new Error("Not a directory");
5157
6661
  } catch {
5158
- sendResult(ws, false, `Directory not found or not accessible: ${resolved}`);
6662
+ sendResult2(ws, false, `Directory not found or not accessible: ${resolved}`);
5159
6663
  break;
5160
6664
  }
5161
6665
  workingDir = resolved;
@@ -5164,9 +6668,9 @@ async function startWebUI(opts = {}) {
5164
6668
  type: "working_dir.changed",
5165
6669
  payload: { cwd: resolved, projectRoot }
5166
6670
  });
5167
- sendResult(ws, true, `Working directory set to ${resolved}`);
6671
+ sendResult2(ws, true, `Working directory set to ${resolved}`);
5168
6672
  } catch (err) {
5169
- sendResult(ws, false, errMessage(err));
6673
+ sendResult2(ws, false, errMessage(err));
5170
6674
  }
5171
6675
  break;
5172
6676
  }
@@ -5176,31 +6680,31 @@ async function startWebUI(opts = {}) {
5176
6680
  msg.payload,
5177
6681
  logger
5178
6682
  );
5179
- sendResult(ws, result.success, result.message);
6683
+ sendResult2(ws, result.success, result.message);
5180
6684
  break;
5181
6685
  }
5182
6686
  // ── Mailbox operations — project-level inter-agent messaging ────
5183
6687
  case "mailbox.messages":
5184
6688
  return handleMailboxMessages(
5185
6689
  ws,
5186
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
6690
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
5187
6691
  msg.payload
5188
6692
  );
5189
6693
  case "mailbox.agents":
5190
6694
  return handleMailboxAgents(
5191
6695
  ws,
5192
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
6696
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
5193
6697
  msg.payload
5194
6698
  );
5195
6699
  case "mailbox.clear":
5196
6700
  return handleMailboxClear(
5197
6701
  ws,
5198
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) }
6702
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) }
5199
6703
  );
5200
6704
  case "mailbox.purge":
5201
6705
  return handleMailboxPurge(
5202
6706
  ws,
5203
- { projectRoot, globalRoot: path8.dirname(globalConfigPath) },
6707
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
5204
6708
  msg.payload
5205
6709
  );
5206
6710
  // ── Brain — status, autonomy ceiling, direct decision support ───
@@ -5214,7 +6718,7 @@ async function startWebUI(opts = {}) {
5214
6718
  const level = msg.payload?.level ?? "";
5215
6719
  const valid = ["off", "low", "medium", "high", "all"];
5216
6720
  if (!valid.includes(level)) {
5217
- sendResult(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
6721
+ sendResult2(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
5218
6722
  break;
5219
6723
  }
5220
6724
  brainSettings.maxAutoRisk = level;
@@ -5227,7 +6731,7 @@ async function startWebUI(opts = {}) {
5227
6731
  case "brain.ask": {
5228
6732
  const question = msg.payload?.question?.trim();
5229
6733
  if (!question) {
5230
- sendResult(ws, false, "Usage: /brain ask <question>");
6734
+ sendResult2(ws, false, "Usage: /brain ask <question>");
5231
6735
  break;
5232
6736
  }
5233
6737
  try {
@@ -5240,7 +6744,7 @@ async function startWebUI(opts = {}) {
5240
6744
  });
5241
6745
  send(ws, { type: "brain.answer", payload: { question, decision } });
5242
6746
  } catch (err) {
5243
- sendResult(ws, false, `Brain consultation failed: ${errMessage(err)}`);
6747
+ sendResult2(ws, false, `Brain consultation failed: ${errMessage(err)}`);
5244
6748
  }
5245
6749
  break;
5246
6750
  }
@@ -5267,14 +6771,28 @@ async function startWebUI(opts = {}) {
5267
6771
  broadcast,
5268
6772
  clients
5269
6773
  });
6774
+ const watcherMetrics = {
6775
+ fileChangesDetected: 0,
6776
+ filesProcessed: 0,
6777
+ broadcastsSent: 0,
6778
+ debounceResets: 0,
6779
+ totalDebounceDelayMs: 0,
6780
+ activeProjects: 0,
6781
+ averageDebounceDelayMs: 0,
6782
+ watcherActive: false
6783
+ };
5270
6784
  const httpServer = createHttpServer({
5271
6785
  host: wsHost,
5272
- distDir: path8.resolve(import.meta.dirname, "../../dist"),
6786
+ distDir: path10.resolve(import.meta.dirname, "../../dist"),
5273
6787
  wsPort,
5274
6788
  globalRoot: wpaths.globalRoot,
5275
- apiToken: wsToken
6789
+ apiToken: wsToken,
6790
+ watcherMetrics,
6791
+ onFleetPing: () => {
6792
+ void fleetBroadcast?.();
6793
+ }
5276
6794
  });
5277
- const registryBaseDir = path8.dirname(globalConfigPath);
6795
+ const registryBaseDir = path10.dirname(globalConfigPath);
5278
6796
  httpServer.listen(httpPort, wsHost, () => {
5279
6797
  const openUrl = `http://${wsHost}:${httpPort}`;
5280
6798
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
@@ -5286,7 +6804,7 @@ async function startWebUI(opts = {}) {
5286
6804
  wsPort,
5287
6805
  host: wsHost,
5288
6806
  projectRoot,
5289
- projectName: path8.basename(projectRoot) || projectRoot,
6807
+ projectName: path10.basename(projectRoot) || projectRoot,
5290
6808
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5291
6809
  url: `http://${wsHost}:${httpPort}`
5292
6810
  },
@@ -5313,6 +6831,10 @@ async function startWebUI(opts = {}) {
5313
6831
  // reality. Crash exits are healed by the next register()/list() prune pass.
5314
6832
  onShutdown: () => {
5315
6833
  brainMonitor.stop();
6834
+ if (disposeEvents) {
6835
+ disposeEvents();
6836
+ disposeEvents = null;
6837
+ }
5316
6838
  if (eternalSubscription) {
5317
6839
  eternalSubscription.dispose();
5318
6840
  eternalSubscription = null;
@@ -5343,10 +6865,28 @@ export {
5343
6865
  handleFilesRead,
5344
6866
  handleFilesTree,
5345
6867
  handleFilesWrite,
6868
+ handleGitInfo,
6869
+ handleMcpAdd,
6870
+ handleMcpDisable,
6871
+ handleMcpDiscover,
6872
+ handleMcpEnable,
6873
+ handleMcpList,
6874
+ handleMcpRemove,
6875
+ handleMcpRestart,
6876
+ handleMcpSleep,
6877
+ handleMcpUpdate,
6878
+ handleMcpWake,
5346
6879
  handleMemoryForget,
5347
6880
  handleMemoryList,
5348
6881
  handleMemoryRemember,
5349
6882
  handleShellOpen,
6883
+ handleSkillsContent,
6884
+ handleSkillsCreate,
6885
+ handleSkillsEdit,
6886
+ handleSkillsExport,
6887
+ handleSkillsInstall,
6888
+ handleSkillsUninstall,
6889
+ handleSkillsUpdate,
5350
6890
  hostHeaderOk,
5351
6891
  injectWsPort,
5352
6892
  isLoopbackBind,
@@ -5364,7 +6904,7 @@ export {
5364
6904
  removeProvider,
5365
6905
  saveProviders,
5366
6906
  send,
5367
- sendResult,
6907
+ sendResult2 as sendResult,
5368
6908
  setActiveKey,
5369
6909
  startWebUI,
5370
6910
  stringifyContent,