@wrongstack/webui 0.260.0 → 0.265.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,179 @@
1
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
- }) : x)(function(x) {
4
- if (typeof require !== "undefined") return require.apply(this, arguments);
5
- throw Error('Dynamic require of "' + x + '" is not supported');
6
- });
1
+ // src/server/index.ts
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
+ }
7
171
 
8
172
  // src/server/index.ts
9
- import { expectDefined as expectDefined2, GlobalMailbox as GlobalMailbox2, projectSlug, getSessionRegistry, AgentStatusTracker } from "@wrongstack/core";
10
173
  import { makeMailboxTool, makeMailSendTool, makeMailInboxTool, mailboxSessionTag } from "@wrongstack/core";
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";
11
177
  import {
12
178
  BrainMonitor,
13
179
  DefaultBrainArbiter,
@@ -15,8 +181,8 @@ import {
15
181
  createAutonomyBrain,
16
182
  createTieredBrainArbiter
17
183
  } from "@wrongstack/core";
18
- import * as fs7 from "fs/promises";
19
- import * as path9 from "path";
184
+ import * as fs10 from "fs/promises";
185
+ import * as path10 from "path";
20
186
 
21
187
  // src/server/http-server.ts
22
188
  import * as fs from "fs/promises";
@@ -24,7 +190,7 @@ import * as http from "http";
24
190
  import * as path from "path";
25
191
 
26
192
  // src/server/ws-auth.ts
27
- import { Buffer as Buffer2 } from "buffer";
193
+ import { Buffer } from "buffer";
28
194
  import { timingSafeEqual } from "crypto";
29
195
  function isLoopbackHostname(hostname) {
30
196
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
@@ -43,8 +209,8 @@ function isLoopbackBind(wsHost) {
43
209
  }
44
210
  function tokenMatches(provided, expected) {
45
211
  if (!provided) return false;
46
- const a = Buffer2.from(provided);
47
- const b = Buffer2.from(expected);
212
+ const a = Buffer.from(provided);
213
+ const b = Buffer.from(expected);
48
214
  if (a.length !== b.length) return false;
49
215
  return timingSafeEqual(a, b);
50
216
  }
@@ -135,6 +301,13 @@ function isInsideDist(candidate, distDir) {
135
301
  const resolved = path.resolve(candidate);
136
302
  return resolved === root || resolved.startsWith(root + path.sep);
137
303
  }
304
+ function decodeSessionId(segment) {
305
+ try {
306
+ return decodeURIComponent(segment);
307
+ } catch {
308
+ return segment;
309
+ }
310
+ }
138
311
  function createHttpServer(opts) {
139
312
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
140
313
  const distDir = path.resolve(opts.distDir);
@@ -160,6 +333,22 @@ function createHttpServer(opts) {
160
333
  res.end("ok");
161
334
  return;
162
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
+ }
163
352
  if (url.pathname === "/api/sessions" && req.method === "GET") {
164
353
  const headerToken = req.headers["x-ws-token"];
165
354
  const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
@@ -180,7 +369,89 @@ function createHttpServer(opts) {
180
369
  res.end(JSON.stringify({ error: "Unauthorized" }));
181
370
  return;
182
371
  }
183
- 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
+ }
184
455
  return;
185
456
  }
186
457
  let filePath;
@@ -312,6 +583,324 @@ async function handleApiSessionAgents(res, globalRoot, sessionId) {
312
583
  res.end(JSON.stringify({ error: String(err) }));
313
584
  }
314
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
+ }
315
904
 
316
905
  // src/server/file-handlers.ts
317
906
  import * as fs2 from "fs/promises";
@@ -385,7 +974,7 @@ function broadcast(clients, msg) {
385
974
  }
386
975
  }
387
976
  }
388
- function sendResult(ws, success, message) {
977
+ function sendResult2(ws, success, message) {
389
978
  send(ws, { type: "key.operation_result", payload: { success, message } });
390
979
  }
391
980
  function errMessage(err) {
@@ -534,24 +1123,266 @@ async function handleMemoryRemember(ws, msg, memoryStore) {
534
1123
  const { text, scope } = msg.payload;
535
1124
  try {
536
1125
  await memoryStore.remember(text, scope ?? "project-memory");
537
- sendResult(ws, true, "Saved to memory");
1126
+ sendResult2(ws, true, "Saved to memory");
538
1127
  } catch (err) {
539
- sendResult(ws, false, errMessage(err));
1128
+ sendResult2(ws, false, errMessage(err));
540
1129
  }
541
1130
  }
542
1131
  async function handleMemoryForget(ws, msg, memoryStore) {
543
1132
  const { text, scope } = msg.payload;
544
1133
  try {
545
1134
  const removed = await memoryStore.forget(text, scope ?? "project-memory");
546
- sendResult(
1135
+ sendResult2(
547
1136
  ws,
548
1137
  removed > 0,
549
1138
  removed > 0 ? `Removed ${removed} entr${removed === 1 ? "y" : "ies"}` : "No matching entries"
550
1139
  );
551
1140
  } catch (err) {
552
- sendResult(ws, false, errMessage(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}` } });
553
1280
  }
554
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
+ }
1288
+ try {
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` } });
1310
+ } catch (err) {
1311
+ send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to update server: ${err}` } });
1312
+ }
1313
+ }
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
+ }
1324
+ try {
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` } });
1329
+ } catch (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}` } });
1332
+ }
1333
+ }
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
+ }
1344
+ try {
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` } });
1348
+ } catch (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;
1383
+ }
1384
+ send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Restart command sent for "${payload.name}"` } });
1385
+ }
555
1386
 
556
1387
  // src/server/index.ts
557
1388
  import {
@@ -710,6 +1541,7 @@ function patchConfig(config, updates) {
710
1541
 
711
1542
  // src/server/autophase-ws-handler.ts
712
1543
  import { spawnSync } from "child_process";
1544
+ import { toErrorMessage } from "@wrongstack/core/utils";
713
1545
  import {
714
1546
  AutoPhasePlanner,
715
1547
  PhaseGraphBuilder,
@@ -879,7 +1711,7 @@ var AutoPhaseWebSocketHandler = class {
879
1711
  );
880
1712
  this.broadcastState();
881
1713
  }).catch((err) => {
882
- this.logger.error(`[AutoPhase] Aborted: ${err instanceof Error ? err.message : String(err)}`);
1714
+ this.logger.error(`[AutoPhase] Aborted: ${toErrorMessage(err)}`);
883
1715
  this.stopBroadcast();
884
1716
  this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
885
1717
  });
@@ -912,7 +1744,7 @@ var AutoPhaseWebSocketHandler = class {
912
1744
  }
913
1745
  this.logger.info(`[AutoPhase] Planner produced no phases; using defaults for: ${goal}`);
914
1746
  } catch (err) {
915
- this.logger.error(`[AutoPhase] Planning failed, using defaults: ${err instanceof Error ? err.message : String(err)}`);
1747
+ this.logger.error(`[AutoPhase] Planning failed, using defaults: ${toErrorMessage(err)}`);
916
1748
  }
917
1749
  return this.defaultPhases();
918
1750
  }
@@ -1039,6 +1871,7 @@ Type: ${task.type}`;
1039
1871
 
1040
1872
  // src/server/collaboration-ws-handler.ts
1041
1873
  import { randomUUID } from "crypto";
1874
+ import { toErrorMessage as toErrorMessage2 } from "@wrongstack/core/utils";
1042
1875
  var REPLAY_LIMIT = 50;
1043
1876
  var PAUSE_TIMEOUT_MS = 6e4;
1044
1877
  var CollaborationWebSocketHandler = class {
@@ -1166,7 +1999,7 @@ var CollaborationWebSocketHandler = class {
1166
1999
  if (this.reader) {
1167
2000
  this.replayHistory(ws, sessionId).catch((err) => {
1168
2001
  this.logger.debug?.(
1169
- `collab: replay failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`
2002
+ `collab: replay failed for ${sessionId}: ${toErrorMessage2(err)}`
1170
2003
  );
1171
2004
  });
1172
2005
  }
@@ -1276,7 +2109,7 @@ var CollaborationWebSocketHandler = class {
1276
2109
  this.send(
1277
2110
  ws,
1278
2111
  this.errorMessage(
1279
- `annotation rejected: ${err instanceof Error ? err.message : String(err)}`
2112
+ `annotation rejected: ${toErrorMessage2(err)}`
1280
2113
  )
1281
2114
  );
1282
2115
  }
@@ -1343,7 +2176,7 @@ var CollaborationWebSocketHandler = class {
1343
2176
  this.send(
1344
2177
  ws,
1345
2178
  this.errorMessage(
1346
- `resolve failed: ${err instanceof Error ? err.message : String(err)}`
2179
+ `resolve failed: ${toErrorMessage2(err)}`
1347
2180
  )
1348
2181
  );
1349
2182
  }
@@ -1391,7 +2224,7 @@ var CollaborationWebSocketHandler = class {
1391
2224
  if (p.ws.readyState === 1) p.ws.send(data);
1392
2225
  } catch (err) {
1393
2226
  this.logger.debug?.(
1394
- `collab broadcast failed: ${err instanceof Error ? err.message : String(err)}`
2227
+ `collab broadcast failed: ${toErrorMessage2(err)}`
1395
2228
  );
1396
2229
  }
1397
2230
  }
@@ -1418,7 +2251,7 @@ var CollaborationWebSocketHandler = class {
1418
2251
  }
1419
2252
  } catch (err) {
1420
2253
  this.logger.debug?.(
1421
- `collab: session reader rejected ${sessionId}: ${err instanceof Error ? err.message : String(err)}`
2254
+ `collab: session reader rejected ${sessionId}: ${toErrorMessage2(err)}`
1422
2255
  );
1423
2256
  return;
1424
2257
  }
@@ -1499,7 +2332,7 @@ var CollaborationWebSocketHandler = class {
1499
2332
  if (p.ws.readyState === 1) p.ws.send(data);
1500
2333
  } catch (err) {
1501
2334
  this.logger.debug?.(
1502
- `collab broadcast failed: ${err instanceof Error ? err.message : String(err)}`
2335
+ `collab broadcast failed: ${toErrorMessage2(err)}`
1503
2336
  );
1504
2337
  }
1505
2338
  }
@@ -1696,6 +2529,7 @@ var CollaborationWebSocketHandler = class {
1696
2529
  };
1697
2530
 
1698
2531
  // src/server/worktree-ws-handler.ts
2532
+ import { toErrorMessage as toErrorMessage3 } from "@wrongstack/core/utils";
1699
2533
  var MAX_ACTIVITY = 6;
1700
2534
  var WorktreeWebSocketHandler = class {
1701
2535
  constructor(events, logger) {
@@ -1821,7 +2655,7 @@ var WorktreeWebSocketHandler = class {
1821
2655
  try {
1822
2656
  if (ws.readyState === 1) ws.send(data);
1823
2657
  } catch (err) {
1824
- this.logger.debug?.(`worktree broadcast failed: ${err instanceof Error ? err.message : String(err)}`);
2658
+ this.logger.debug?.(`worktree broadcast failed: ${toErrorMessage3(err)}`);
1825
2659
  }
1826
2660
  }
1827
2661
  }
@@ -1834,22 +2668,14 @@ var WorktreeWebSocketHandler = class {
1834
2668
  };
1835
2669
 
1836
2670
  // src/server/mailbox-handlers.ts
1837
- import * as path3 from "path";
1838
- import { GlobalMailbox } from "@wrongstack/core";
1839
- function resolveProjectDir(projectRoot, globalRoot) {
1840
- const { createHash } = __require("crypto");
1841
- const hash = createHash("sha256").update(path3.resolve(projectRoot)).digest("hex").slice(0, 6);
1842
- const slug = path3.basename(projectRoot).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40) || "project";
1843
- return path3.join(globalRoot, "projects", `${slug}-${hash}`);
1844
- }
2671
+ import { GlobalMailbox, resolveProjectDir } from "@wrongstack/core";
1845
2672
  async function handleMailboxMessages(ws, deps, payload) {
1846
2673
  try {
1847
2674
  const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
1848
2675
  const mb = new GlobalMailbox(dir);
1849
2676
  const messages = await mb.query({
1850
2677
  limit: payload?.limit ?? 30,
1851
- to: payload?.agentId,
1852
- unreadBy: payload?.unreadOnly ? payload.agentId : void 0
2678
+ incompleteOnly: payload?.incompleteOnly ?? false
1853
2679
  });
1854
2680
  send(ws, {
1855
2681
  type: "mailbox.messages",
@@ -1866,10 +2692,12 @@ async function handleMailboxMessages(ws, deps, payload) {
1866
2692
  readByCount: Object.keys(m.readBy).length,
1867
2693
  completed: m.completed,
1868
2694
  completedBy: m.completedBy,
2695
+ completedAt: m.completedAt,
1869
2696
  outcome: m.outcome,
1870
2697
  timestamp: m.timestamp,
1871
2698
  replyTo: m.replyTo,
1872
- senderSessionId: m.senderSessionId
2699
+ senderSessionId: m.senderSessionId,
2700
+ taskContext: m.taskContext
1873
2701
  }))
1874
2702
  }
1875
2703
  });
@@ -1916,6 +2744,16 @@ async function handleMailboxClear(ws, deps) {
1916
2744
  send(ws, { type: "mailbox.cleared", payload: { error: errMessage(err) } });
1917
2745
  }
1918
2746
  }
2747
+ async function handleMailboxPurge(ws, deps, opts) {
2748
+ try {
2749
+ const dir = resolveProjectDir(deps.projectRoot, deps.globalRoot);
2750
+ const mb = new GlobalMailbox(dir);
2751
+ const result = await mb.purgeStale(opts);
2752
+ send(ws, { type: "mailbox.purged", payload: result });
2753
+ } catch (err) {
2754
+ send(ws, { type: "mailbox.purged", payload: { error: errMessage(err) } });
2755
+ }
2756
+ }
1919
2757
 
1920
2758
  // src/server/lifecycle.ts
1921
2759
  function createShutdown(res) {
@@ -1956,7 +2794,7 @@ function registerShutdownHandlers(res) {
1956
2794
  // src/server/instance-registry.ts
1957
2795
  import * as os from "os";
1958
2796
  import * as path4 from "path";
1959
- import * as fs3 from "fs/promises";
2797
+ import * as fs4 from "fs/promises";
1960
2798
  import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
1961
2799
  function defaultBaseDir() {
1962
2800
  return path4.join(os.homedir(), ".wrongstack");
@@ -1975,7 +2813,7 @@ function isPidAlive(pid) {
1975
2813
  }
1976
2814
  async function load(file) {
1977
2815
  try {
1978
- const raw = await fs3.readFile(file, "utf8");
2816
+ const raw = await fs4.readFile(file, "utf8");
1979
2817
  const parsed = JSON.parse(raw);
1980
2818
  if (parsed?.version === 1 && Array.isArray(parsed.instances)) {
1981
2819
  return parsed;
@@ -2034,16 +2872,16 @@ function formatInstances(instances) {
2034
2872
  // src/server/port-utils.ts
2035
2873
  import * as net from "net";
2036
2874
  function isPortFree(host, port) {
2037
- return new Promise((resolve6) => {
2875
+ return new Promise((resolve5) => {
2038
2876
  const srv = net.createServer();
2039
- srv.once("error", () => resolve6(false));
2877
+ srv.once("error", () => resolve5(false));
2040
2878
  srv.once("listening", () => {
2041
- srv.close(() => resolve6(true));
2879
+ srv.close(() => resolve5(true));
2042
2880
  });
2043
2881
  try {
2044
2882
  srv.listen(port, host);
2045
2883
  } catch {
2046
- resolve6(false);
2884
+ resolve5(false);
2047
2885
  }
2048
2886
  });
2049
2887
  }
@@ -2118,8 +2956,12 @@ function computeUsageCost(usage, rates) {
2118
2956
  return (usage.input * rates.input + usage.output * rates.output + (usage.cacheRead ?? 0) * rates.cacheRead) / 1e6;
2119
2957
  }
2120
2958
 
2959
+ // src/server/provider-handlers.ts
2960
+ import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/core";
2961
+ import { probeLocalLlm } from "@wrongstack/runtime/probe";
2962
+
2121
2963
  // src/server/provider-config-io.ts
2122
- import * as fs4 from "fs/promises";
2964
+ import * as fs5 from "fs/promises";
2123
2965
  import * as path5 from "path";
2124
2966
  import { atomicWrite as atomicWrite3 } from "@wrongstack/core";
2125
2967
  import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/security";
@@ -2127,7 +2969,7 @@ import { DefaultSecretVault } from "@wrongstack/core";
2127
2969
  async function loadSavedProviders(configPath, vault) {
2128
2970
  let raw;
2129
2971
  try {
2130
- raw = await fs4.readFile(configPath, "utf8");
2972
+ raw = await fs5.readFile(configPath, "utf8");
2131
2973
  } catch {
2132
2974
  return {};
2133
2975
  }
@@ -2144,7 +2986,7 @@ async function saveProviders(configPath, vault, providers) {
2144
2986
  let raw;
2145
2987
  let fileExists = true;
2146
2988
  try {
2147
- raw = await fs4.readFile(configPath, "utf8");
2989
+ raw = await fs5.readFile(configPath, "utf8");
2148
2990
  } catch (err) {
2149
2991
  if (err.code !== "ENOENT") {
2150
2992
  throw new Error(
@@ -2180,6 +3022,9 @@ function createProviderConfigIO(configPath) {
2180
3022
  };
2181
3023
  }
2182
3024
 
3025
+ // src/server/provider-handlers.ts
3026
+ import { toErrorMessage as toErrorMessage4 } from "@wrongstack/core/utils";
3027
+
2183
3028
  // src/server/provider-keys.ts
2184
3029
  import { expectDefined } from "@wrongstack/core";
2185
3030
  function normalizeKeys(cfg) {
@@ -2200,7 +3045,7 @@ function writeKeysBack(cfg, keys) {
2200
3045
  }
2201
3046
  cfg.apiKeys = keys;
2202
3047
  const active = keys.find((k) => k.label === cfg.activeKey) ?? expectDefined(keys[0]);
2203
- cfg.apiKey = active.apiKey;
3048
+ delete cfg.apiKey;
2204
3049
  if (!cfg.activeKey || !keys.some((k) => k.label === cfg.activeKey)) {
2205
3050
  cfg.activeKey = active.label;
2206
3051
  }
@@ -2277,6 +3122,28 @@ function removeProvider(providers, providerId) {
2277
3122
  }
2278
3123
 
2279
3124
  // src/server/provider-handlers.ts
3125
+ function projectSavedProviders(providers) {
3126
+ return Object.entries(providers).map(([id, cfg]) => {
3127
+ const keys = normalizeKeys(cfg);
3128
+ const models = cfg.models;
3129
+ const view = {
3130
+ id,
3131
+ family: cfg.family ?? id,
3132
+ baseUrl: cfg.baseUrl,
3133
+ models,
3134
+ apiKeys: keys.map((k) => ({
3135
+ label: k.label,
3136
+ maskedKey: maskedKey(k.apiKey),
3137
+ isActive: k.label === cfg.activeKey,
3138
+ createdAt: k.createdAt
3139
+ }))
3140
+ };
3141
+ const picked = models && models.length > 0 ? models[0] : void 0;
3142
+ if (picked !== void 0) view.pickedModelId = picked;
3143
+ return view;
3144
+ });
3145
+ }
3146
+ var probeScrubber = new DefaultSecretScrubber2();
2280
3147
  function createProviderHandlers(deps) {
2281
3148
  const { globalConfigPath, vault, broadcast: broadcast2, clients } = deps;
2282
3149
  let configWriteLock = deps.getConfigWriteLock();
@@ -2285,7 +3152,7 @@ function createProviderHandlers(deps) {
2285
3152
  }
2286
3153
  async function saveConfigProviders(providers) {
2287
3154
  const next = configWriteLock.then(() => saveProviders(globalConfigPath, vault, providers)).catch((err) => {
2288
- const msg = err instanceof Error ? err.message : String(err);
3155
+ const msg = toErrorMessage4(err);
2289
3156
  console.error(JSON.stringify({
2290
3157
  level: "error",
2291
3158
  event: "webui.provider_save_failed",
@@ -2302,9 +3169,9 @@ function createProviderHandlers(deps) {
2302
3169
  const providers = await loadConfigProviders();
2303
3170
  const result = upsertKey(providers, providerId, label, apiKey, (/* @__PURE__ */ new Date()).toISOString());
2304
3171
  if (result.ok) await saveConfigProviders(providers);
2305
- sendResult(ws, result.ok, result.message);
3172
+ sendResult2(ws, result.ok, result.message);
2306
3173
  } catch (err) {
2307
- sendResult(ws, false, errMessage(err));
3174
+ sendResult2(ws, false, errMessage(err));
2308
3175
  }
2309
3176
  }
2310
3177
  async function handleKeyDelete(ws, providerId, label) {
@@ -2312,9 +3179,9 @@ function createProviderHandlers(deps) {
2312
3179
  const providers = await loadConfigProviders();
2313
3180
  const result = deleteKey(providers, providerId, label);
2314
3181
  if (result.ok) await saveConfigProviders(providers);
2315
- sendResult(ws, result.ok, result.message);
3182
+ sendResult2(ws, result.ok, result.message);
2316
3183
  } catch (err) {
2317
- sendResult(ws, false, errMessage(err));
3184
+ sendResult2(ws, false, errMessage(err));
2318
3185
  }
2319
3186
  }
2320
3187
  async function handleKeySetActive(ws, providerId, label) {
@@ -2322,9 +3189,9 @@ function createProviderHandlers(deps) {
2322
3189
  const providers = await loadConfigProviders();
2323
3190
  const result = setActiveKey(providers, providerId, label);
2324
3191
  if (result.ok) await saveConfigProviders(providers);
2325
- sendResult(ws, result.ok, result.message);
3192
+ sendResult2(ws, result.ok, result.message);
2326
3193
  } catch (err) {
2327
- sendResult(ws, false, errMessage(err));
3194
+ sendResult2(ws, false, errMessage(err));
2328
3195
  }
2329
3196
  }
2330
3197
  async function handleProviderAdd(ws, payload) {
@@ -2332,31 +3199,13 @@ function createProviderHandlers(deps) {
2332
3199
  const providers = await loadConfigProviders();
2333
3200
  const result = addProvider(providers, payload, (/* @__PURE__ */ new Date()).toISOString());
2334
3201
  if (result.ok) await saveConfigProviders(providers);
2335
- sendResult(ws, result.ok, result.message);
3202
+ sendResult2(ws, result.ok, result.message);
2336
3203
  if (result.ok) {
2337
3204
  console.log(`[WebUI] Provider "${payload.id}" added via provider.add`);
2338
- broadcast2(clients, {
2339
- type: "providers.saved",
2340
- payload: {
2341
- providers: Object.entries(providers).map(([id, cfg]) => {
2342
- const keys = normalizeKeys(cfg);
2343
- return {
2344
- id,
2345
- family: cfg.family ?? id,
2346
- baseUrl: cfg.baseUrl,
2347
- apiKeys: keys.map((k) => ({
2348
- label: k.label,
2349
- maskedKey: maskedKey(k.apiKey),
2350
- isActive: k.label === cfg.activeKey,
2351
- createdAt: k.createdAt
2352
- }))
2353
- };
2354
- })
2355
- }
2356
- });
3205
+ broadcastSaved(providers);
2357
3206
  }
2358
3207
  } catch (err) {
2359
- sendResult(ws, false, errMessage(err));
3208
+ sendResult2(ws, false, errMessage(err));
2360
3209
  }
2361
3210
  }
2362
3211
  async function handleProviderRemove(ws, providerId) {
@@ -2364,18 +3213,116 @@ function createProviderHandlers(deps) {
2364
3213
  const providers = await loadConfigProviders();
2365
3214
  const result = removeProvider(providers, providerId);
2366
3215
  if (result.ok) await saveConfigProviders(providers);
2367
- sendResult(ws, result.ok, result.message);
3216
+ sendResult2(ws, result.ok, result.message);
3217
+ } catch (err) {
3218
+ sendResult2(ws, false, errMessage(err));
3219
+ }
3220
+ }
3221
+ function broadcastSaved(providers) {
3222
+ broadcast2(clients, {
3223
+ type: "providers.saved",
3224
+ payload: { providers: projectSavedProviders(providers) }
3225
+ });
3226
+ }
3227
+ async function handleProviderClearModels(ws, providerId) {
3228
+ try {
3229
+ const providers = await loadConfigProviders();
3230
+ const cfg = providers[providerId];
3231
+ if (!cfg) {
3232
+ sendResult2(ws, false, `Unknown provider "${providerId}"`);
3233
+ return;
3234
+ }
3235
+ delete cfg.models;
3236
+ await saveConfigProviders(providers);
3237
+ sendResult2(ws, true, `Cleared model allowlist for ${providerId}`);
3238
+ broadcastSaved(providers);
3239
+ } catch (err) {
3240
+ sendResult2(ws, false, errMessage(err));
3241
+ }
3242
+ }
3243
+ async function handleProviderUndoClear(ws, providerId, previousModels) {
3244
+ try {
3245
+ const providers = await loadConfigProviders();
3246
+ const cfg = providers[providerId];
3247
+ if (!cfg) {
3248
+ sendResult2(ws, false, `Unknown provider "${providerId}"`);
3249
+ return;
3250
+ }
3251
+ cfg.models = [...previousModels];
3252
+ await saveConfigProviders(providers);
3253
+ sendResult2(ws, true, `Restored ${previousModels.length} model(s) for ${providerId}`);
3254
+ broadcastSaved(providers);
3255
+ } catch (err) {
3256
+ sendResult2(ws, false, errMessage(err));
3257
+ }
3258
+ }
3259
+ async function handleProviderUpdate(ws, payload) {
3260
+ try {
3261
+ const providers = await loadConfigProviders();
3262
+ const cfg = providers[payload.id];
3263
+ if (!cfg) {
3264
+ sendResult2(ws, false, `Unknown provider "${payload.id}"`);
3265
+ return;
3266
+ }
3267
+ if (payload.family !== void 0) cfg.family = payload.family;
3268
+ if (payload.baseUrl !== void 0) cfg.baseUrl = payload.baseUrl;
3269
+ if (payload.envVars !== void 0) cfg.envVars = payload.envVars;
3270
+ if (payload.models !== void 0) cfg.models = payload.models;
3271
+ await saveConfigProviders(providers);
3272
+ sendResult2(ws, true, `Updated ${payload.id}`);
3273
+ broadcastSaved(providers);
3274
+ } catch (err) {
3275
+ sendResult2(ws, false, errMessage(err));
3276
+ }
3277
+ }
3278
+ async function handleProviderProbe(ws, providerId, timeoutMs) {
3279
+ const reply = (payload) => send(ws, { type: "provider.probe", payload: { providerId, ...payload } });
3280
+ try {
3281
+ const providers = await loadConfigProviders();
3282
+ const cfg = providers[providerId];
3283
+ if (!cfg) {
3284
+ reply({ ok: false, status: "no_provider" });
3285
+ return;
3286
+ }
3287
+ if (!cfg.baseUrl) {
3288
+ reply({ ok: false, status: "no_base_url" });
3289
+ return;
3290
+ }
3291
+ const keys = normalizeKeys(cfg);
3292
+ const active = keys.find((k) => k.label === cfg.activeKey) ?? keys[0];
3293
+ const result = await probeLocalLlm({
3294
+ baseUrl: cfg.baseUrl,
3295
+ apiKey: active?.apiKey,
3296
+ noAuth: false,
3297
+ scrubber: probeScrubber,
3298
+ ...timeoutMs !== void 0 ? { timeoutMs } : {}
3299
+ });
3300
+ reply(result);
2368
3301
  } catch (err) {
2369
- sendResult(ws, false, errMessage(err));
3302
+ reply({ ok: false, status: "unreachable", detail: errMessage(err) });
2370
3303
  }
2371
3304
  }
2372
- return { handleKeyUpsert, handleKeyDelete, handleKeySetActive, handleProviderAdd, handleProviderRemove, loadConfigProviders };
3305
+ return {
3306
+ handleKeyUpsert,
3307
+ handleKeyDelete,
3308
+ handleKeySetActive,
3309
+ handleProviderAdd,
3310
+ handleProviderRemove,
3311
+ handleProviderClearModels,
3312
+ handleProviderUndoClear,
3313
+ handleProviderUpdate,
3314
+ handleProviderProbe,
3315
+ loadConfigProviders
3316
+ };
2373
3317
  }
2374
3318
 
2375
3319
  // src/server/setup-events.ts
3320
+ import * as fs6 from "fs/promises";
3321
+ import { watch as fsWatch } from "fs";
2376
3322
  import * as path6 from "path";
2377
3323
  function setupEvents(deps) {
2378
- 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 = [];
2379
3326
  events.on("iteration.started", (e) => {
2380
3327
  const maxIt = typeof context.meta["maxIterations"] === "number" ? context.meta["maxIterations"] : config.tools?.maxIterations ?? 100;
2381
3328
  broadcast2(clients, {
@@ -2406,7 +3353,11 @@ function setupEvents(deps) {
2406
3353
  events.on("tool.progress", (e) => {
2407
3354
  broadcast2(clients, {
2408
3355
  type: "tool.progress",
2409
- 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 } }
2410
3361
  });
2411
3362
  sessionBridge?.append({
2412
3363
  type: "tool_progress",
@@ -2572,20 +3523,165 @@ function setupEvents(deps) {
2572
3523
  events.onPattern("brain.*", (eventName, payload) => {
2573
3524
  broadcast2(clients, { type: "brain.event", payload: { event: eventName, ...payload } });
2574
3525
  });
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
+ }
2575
3668
  const globalRoot = globalConfigPath ? path6.dirname(globalConfigPath) : void 0;
2576
3669
  if (globalRoot) {
2577
- const statusInterval = setInterval(async () => {
3670
+ const broadcastSessions = async () => {
2578
3671
  try {
2579
3672
  const { SessionRegistry } = await import("@wrongstack/core");
2580
3673
  const registry = new SessionRegistry(globalRoot);
2581
3674
  const sessions = await registry.list();
2582
- 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) => ({
2583
3677
  sessionId: s.sessionId,
2584
3678
  projectName: s.projectName,
2585
3679
  projectSlug: s.projectSlug,
2586
3680
  projectRoot: s.projectRoot,
2587
3681
  workingDir: s.workingDir,
2588
3682
  gitBranch: s.gitBranch,
3683
+ // Surface (tui/webui/cli) so Fleet HQ can label each live client node.
3684
+ clientType: s.clientType,
2589
3685
  status: s.status,
2590
3686
  pid: s.pid,
2591
3687
  startedAt: s.startedAt,
@@ -2597,20 +3693,52 @@ function setupEvents(deps) {
2597
3693
  currentTool: a.currentTool,
2598
3694
  iterations: a.iterations,
2599
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,
2600
3702
  lastActivityAt: a.lastActivityAt
2601
3703
  }))
2602
3704
  }));
2603
3705
  broadcast2(clients, { type: "sessions.status_update", payload: { sessions: live } });
2604
3706
  } catch {
2605
3707
  }
2606
- }, 5e3);
3708
+ };
3709
+ onFleetBroadcaster?.(broadcastSessions);
3710
+ const statusInterval = setInterval(() => void broadcastSessions(), 5e3);
2607
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();
2608
3728
  }
3729
+ return () => {
3730
+ for (const dispose of disposers) {
3731
+ try {
3732
+ dispose();
3733
+ } catch {
3734
+ }
3735
+ }
3736
+ };
2609
3737
  }
2610
3738
 
2611
3739
  // src/server/custom-context-modes.ts
2612
3740
  import { listContextWindowModes, atomicWrite as atomicWrite4 } from "@wrongstack/core";
2613
- import * as fs5 from "fs/promises";
3741
+ import * as fs7 from "fs/promises";
2614
3742
  import * as path7 from "path";
2615
3743
  var STORE_FILENAME = "custom-context-modes.json";
2616
3744
  function storePath(wrongstackDir) {
@@ -2622,7 +3750,7 @@ function createCustomModeStore(wrongstackDir) {
2622
3750
  const load2 = async () => {
2623
3751
  modes.clear();
2624
3752
  try {
2625
- const raw = await fs5.readFile(storePath(wrongstackDir), "utf8");
3753
+ const raw = await fs7.readFile(storePath(wrongstackDir), "utf8");
2626
3754
  const parsed = JSON.parse(raw);
2627
3755
  if (Array.isArray(parsed.modes)) {
2628
3756
  for (const m of parsed.modes) {
@@ -2798,60 +3926,346 @@ function createEternalSubscription(subscribe, broadcast2, clientsRef) {
2798
3926
  disposed = true;
2799
3927
  dispose();
2800
3928
  }
2801
- };
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
+ }
2802
4247
  }
2803
-
2804
- // src/server/shell-open.ts
2805
- import * as fs6 from "fs/promises";
2806
- import * as path8 from "path";
2807
- import { spawn as spawn2 } from "child_process";
2808
- var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
2809
- 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
+ }
2810
4253
  try {
2811
- const resolved = path8.resolve(req.path);
2812
- await fs6.access(resolved);
2813
- if (METACHAR_REGEX.test(resolved)) {
2814
- return { success: false, message: "Path contains unsupported characters." };
2815
- }
2816
- const platform = process.platform;
2817
- const launch = (cmd, args, onError) => {
2818
- const child = spawn2(cmd, args, {
2819
- detached: true,
2820
- stdio: "ignore",
2821
- windowsHide: true
2822
- });
2823
- child.on("error", (err) => {
2824
- logger.warn(`shell.open spawn failed: ${err.message}`);
2825
- onError?.();
2826
- });
2827
- child.unref();
2828
- };
2829
- if (req.target === "file-manager") {
2830
- if (platform === "win32") launch("explorer", [resolved]);
2831
- else if (platform === "darwin") launch("open", [resolved]);
2832
- else launch("xdg-open", [resolved]);
2833
- } else if (req.target === "terminal") {
2834
- if (platform === "win32") {
2835
- launch("cmd", ["/c", "start", "cmd", "/k", "cd", "/d", resolved]);
2836
- } else if (platform === "darwin") {
2837
- launch("open", ["-a", "Terminal", resolved]);
2838
- } else {
2839
- launch(
2840
- "x-terminal-emulator",
2841
- [`--working-directory=${resolved}`],
2842
- () => launch(
2843
- "gnome-terminal",
2844
- [`--working-directory=${resolved}`],
2845
- () => launch("xterm", ["-e", `cd '${resolved}' && ${process.env["SHELL"] ?? "sh"}`])
2846
- )
2847
- );
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 {
2848
4262
  }
2849
- } else {
2850
- return { success: false, message: `Unknown shell.open target: ${String(req.target)}` };
2851
4263
  }
2852
- 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 } });
2853
4267
  } catch (err) {
2854
- return { success: false, message: err instanceof Error ? err.message : String(err) };
4268
+ send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: errMessage(err) } });
2855
4269
  }
2856
4270
  }
2857
4271
 
@@ -2920,7 +4334,7 @@ async function startWebUI(opts = {}) {
2920
4334
  console.warn(JSON.stringify({
2921
4335
  level: "warn",
2922
4336
  event: "webui.provider_registry_load_failed",
2923
- message: err instanceof Error ? err.message : String(err),
4337
+ message: toErrorMessage5(err),
2924
4338
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
2925
4339
  }));
2926
4340
  }
@@ -2969,15 +4383,22 @@ async function startWebUI(opts = {}) {
2969
4383
  sessionId: session.id,
2970
4384
  projectSlug: wpaths.projectSlug,
2971
4385
  projectRoot,
2972
- projectName: path9.basename(projectRoot),
4386
+ projectName: path10.basename(projectRoot),
2973
4387
  workingDir,
4388
+ clientType: "webui",
2974
4389
  pid: process.pid,
2975
4390
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
2976
4391
  });
2977
- 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() });
2978
4398
  statusTracker.start();
2979
4399
  const stopTracking = async () => {
2980
4400
  try {
4401
+ fleetNotifier.dispose();
2981
4402
  await registry.markClosing();
2982
4403
  statusTracker?.stop();
2983
4404
  } catch {
@@ -3017,6 +4438,13 @@ async function startWebUI(opts = {}) {
3017
4438
  supportsReasoning: resolvedModel.capabilities.reasoning
3018
4439
  } : void 0;
3019
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;
3020
4448
  const systemPromptBuilder = new DefaultSystemPromptBuilder2({
3021
4449
  memoryStore,
3022
4450
  skillLoader,
@@ -3057,7 +4485,7 @@ async function startWebUI(opts = {}) {
3057
4485
  console.error(JSON.stringify({
3058
4486
  level: "error",
3059
4487
  event: "webui.provider_create_failed",
3060
- message: err instanceof Error ? err.message : String(err),
4488
+ message: toErrorMessage5(err),
3061
4489
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3062
4490
  }));
3063
4491
  throw err;
@@ -3079,14 +4507,14 @@ async function startWebUI(opts = {}) {
3079
4507
  console.error(JSON.stringify({
3080
4508
  level: "error",
3081
4509
  event: "webui.provider_stub_create_failed",
3082
- message: err instanceof Error ? err.message : String(err),
4510
+ message: toErrorMessage5(err),
3083
4511
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3084
4512
  }));
3085
4513
  throw err;
3086
4514
  }
3087
4515
  } else {
3088
4516
  throw new Error(
3089
- "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."
3090
4518
  );
3091
4519
  }
3092
4520
  }
@@ -3128,6 +4556,12 @@ async function startWebUI(opts = {}) {
3128
4556
  context.meta["logLevel"] = config.log?.level ?? "info";
3129
4557
  context.meta["auditLevel"] = config.session?.auditLevel ?? "standard";
3130
4558
  context.meta["maxIterations"] = config.tools?.maxIterations ?? 500;
4559
+ const tgExt = config.extensions?.["telegram"];
4560
+ context.meta["tgConfigured"] = typeof tgExt?.["botToken"] === "string" && tgExt["botToken"].length > 0;
4561
+ context.meta["tgSessionEnd"] = tgExt?.["notifyOnSessionEnd"] === true;
4562
+ context.meta["tgDelegate"] = tgExt?.["notifyOnDelegate"] !== false;
4563
+ const tgMs = tgExt?.["longToolThresholdMs"];
4564
+ context.meta["tgLongToolMs"] = typeof tgMs === "number" ? tgMs : 3e4;
3131
4565
  }
3132
4566
  const PREF_KEYS = [
3133
4567
  "autonomy",
@@ -3151,7 +4585,11 @@ async function startWebUI(opts = {}) {
3151
4585
  "contextAutoCompact",
3152
4586
  "contextStrategy",
3153
4587
  "logLevel",
3154
- "auditLevel"
4588
+ "auditLevel",
4589
+ "tgConfigured",
4590
+ "tgSessionEnd",
4591
+ "tgDelegate",
4592
+ "tgLongToolMs"
3155
4593
  ];
3156
4594
  const prefSnapshot = () => {
3157
4595
  const snapshot = {};
@@ -3164,7 +4602,7 @@ async function startWebUI(opts = {}) {
3164
4602
  const write = async () => {
3165
4603
  let raw;
3166
4604
  try {
3167
- raw = await fs7.readFile(globalConfigPath, "utf8");
4605
+ raw = await fs10.readFile(globalConfigPath, "utf8");
3168
4606
  } catch {
3169
4607
  raw = "{}";
3170
4608
  }
@@ -3236,6 +4674,22 @@ async function startWebUI(opts = {}) {
3236
4674
  toolsCfg.maxIterations = payload["maxIterations"];
3237
4675
  decrypted.tools = toolsCfg;
3238
4676
  }
4677
+ const tgTouched = typeof payload["tgSessionEnd"] === "boolean" || typeof payload["tgDelegate"] === "boolean" || typeof payload["tgLongToolMs"] === "number";
4678
+ if (tgTouched) {
4679
+ const ext = decrypted.extensions ?? {};
4680
+ const tg = ext["telegram"] ?? {};
4681
+ if (typeof payload["tgSessionEnd"] === "boolean") {
4682
+ tg["notifyOnSessionEnd"] = payload["tgSessionEnd"];
4683
+ }
4684
+ if (typeof payload["tgDelegate"] === "boolean") {
4685
+ tg["notifyOnDelegate"] = payload["tgDelegate"];
4686
+ }
4687
+ if (typeof payload["tgLongToolMs"] === "number") {
4688
+ tg["longToolThresholdMs"] = payload["tgLongToolMs"];
4689
+ }
4690
+ ext["telegram"] = tg;
4691
+ decrypted.extensions = ext;
4692
+ }
3239
4693
  const encrypted = encryptConfigSecrets2(decrypted, vault);
3240
4694
  await atomicWrite5(globalConfigPath, JSON.stringify(encrypted, null, 2), { mode: 384 });
3241
4695
  };
@@ -3457,7 +4911,7 @@ async function startWebUI(opts = {}) {
3457
4911
  inputCost,
3458
4912
  outputCost,
3459
4913
  cacheReadCost,
3460
- projectName: path9.basename(projectRoot) || projectRoot,
4914
+ projectName: path10.basename(projectRoot) || projectRoot,
3461
4915
  projectRoot,
3462
4916
  cwd: workingDir,
3463
4917
  mode: modeId,
@@ -3511,10 +4965,11 @@ async function startWebUI(opts = {}) {
3511
4965
  const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
3512
4966
  const RATE_LIMIT_WINDOW_MS = 6e4;
3513
4967
  const rateLimits = /* @__PURE__ */ new Map();
3514
- function checkRateLimit(ws, client) {
4968
+ let connSeq = 0;
4969
+ function checkRateLimit(_ws, client) {
3515
4970
  if (RATE_LIMIT_MESSAGES <= 0) return true;
3516
4971
  const now = Date.now();
3517
- const key = client.sessionId ?? String(ws);
4972
+ const key = client.connId;
3518
4973
  const limit = rateLimits.get(key);
3519
4974
  if (!limit || now > limit.resetAt) {
3520
4975
  rateLimits.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
@@ -3530,7 +4985,12 @@ async function startWebUI(opts = {}) {
3530
4985
  );
3531
4986
  const pendingConfirms = /* @__PURE__ */ new Map();
3532
4987
  const handleConnection = (ws) => {
3533
- 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
+ };
3534
4994
  clients.set(ws, client);
3535
4995
  void sessionStartPayload().then((payload) => {
3536
4996
  send(ws, { type: "session.start", payload });
@@ -3538,7 +4998,7 @@ async function startWebUI(opts = {}) {
3538
4998
  console.warn(JSON.stringify({
3539
4999
  level: "warn",
3540
5000
  event: "webui.session_start_payload_failed",
3541
- message: err instanceof Error ? err.message : String(err),
5001
+ message: toErrorMessage5(err),
3542
5002
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3543
5003
  }));
3544
5004
  });
@@ -3560,7 +5020,7 @@ async function startWebUI(opts = {}) {
3560
5020
  const rawObj = JSON.parse(data.toString());
3561
5021
  if (typeof rawObj === "object" && rawObj !== null) {
3562
5022
  const obj = rawObj;
3563
- if ("__proto__" in obj || "constructor" in obj || "prototype" in obj) {
5023
+ if (Object.hasOwn(obj, "__proto__") || Object.hasOwn(obj, "constructor") || Object.hasOwn(obj, "prototype")) {
3564
5024
  send(ws, {
3565
5025
  type: "error",
3566
5026
  payload: { phase: "parse", message: "Invalid message object" }
@@ -3575,17 +5035,18 @@ async function startWebUI(opts = {}) {
3575
5035
  console.error(JSON.stringify({
3576
5036
  level: "error",
3577
5037
  event: "webui.ws_message_parse_failed",
3578
- message: err instanceof Error ? err.message : String(err),
5038
+ message: toErrorMessage5(err),
3579
5039
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3580
5040
  }));
3581
5041
  }
3582
5042
  });
3583
5043
  ws.on("close", () => {
5044
+ const closing = clients.get(ws);
3584
5045
  clients.delete(ws);
3585
- rateLimits.delete(String(ws));
5046
+ if (closing) rateLimits.delete(closing.connId);
3586
5047
  if (pendingConfirms.size > 0) {
3587
- for (const [id, resolve6] of pendingConfirms) {
3588
- resolve6("no");
5048
+ for (const [id, resolve5] of pendingConfirms) {
5049
+ resolve5("no");
3589
5050
  pendingConfirms.delete(id);
3590
5051
  }
3591
5052
  }
@@ -3608,11 +5069,27 @@ async function startWebUI(opts = {}) {
3608
5069
  { sampling: sessionLogging.sampling }
3609
5070
  );
3610
5071
  let eventsArmed = false;
5072
+ let disposeEvents = null;
5073
+ let fleetBroadcast = null;
3611
5074
  const armOnce = (label) => {
3612
5075
  if (eventsArmed) return;
3613
5076
  eventsArmed = true;
3614
5077
  console.log(`[WebUI] Backend ready (${label})`);
3615
- 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
+ });
3616
5093
  };
3617
5094
  wssPrimary.on("listening", () => armOnce(`${wsHost}:${wsPort}`));
3618
5095
  wssPrimary.on("connection", handleConnection);
@@ -3621,7 +5098,7 @@ async function startWebUI(opts = {}) {
3621
5098
  level: "error",
3622
5099
  event: "webui.ws_server_error",
3623
5100
  host: wsHost,
3624
- message: err instanceof Error ? err.message : String(err),
5101
+ message: toErrorMessage5(err),
3625
5102
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
3626
5103
  }));
3627
5104
  });
@@ -3649,33 +5126,33 @@ async function startWebUI(opts = {}) {
3649
5126
  });
3650
5127
  }
3651
5128
  async function touchProjectEntry(root, workDir) {
3652
- const resolved = path9.resolve(root);
5129
+ const resolved = path10.resolve(root);
3653
5130
  const manifest = await loadManifest(globalConfigPath);
3654
5131
  const now = (/* @__PURE__ */ new Date()).toISOString();
3655
- const existing = manifest.projects.find((p) => path9.resolve(p.root) === resolved);
5132
+ const existing = manifest.projects.find((p) => path10.resolve(p.root) === resolved);
3656
5133
  if (existing) {
3657
5134
  existing.lastSeen = now;
3658
- if (workDir) existing.lastWorkingDir = path9.resolve(workDir);
5135
+ if (workDir) existing.lastWorkingDir = path10.resolve(workDir);
3659
5136
  } else {
3660
5137
  manifest.projects.push({
3661
- name: path9.basename(resolved),
5138
+ name: path10.basename(resolved),
3662
5139
  root: resolved,
3663
5140
  slug: generateProjectSlug(resolved),
3664
5141
  createdAt: now,
3665
5142
  lastSeen: now,
3666
- lastWorkingDir: workDir ? path9.resolve(workDir) : void 0
5143
+ lastWorkingDir: workDir ? path10.resolve(workDir) : void 0
3667
5144
  });
3668
5145
  }
3669
5146
  await saveManifest(manifest, globalConfigPath);
3670
5147
  await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
3671
5148
  }
3672
5149
  function projectsJsonPath(globalConfigPath2) {
3673
- const base = path9.dirname(globalConfigPath2);
3674
- return path9.join(base, "projects.json");
5150
+ const base = path10.dirname(globalConfigPath2);
5151
+ return path10.join(base, "projects.json");
3675
5152
  }
3676
5153
  async function loadManifest(globalConfigPath2) {
3677
5154
  try {
3678
- const raw = await fs7.readFile(projectsJsonPath(globalConfigPath2), "utf8");
5155
+ const raw = await fs10.readFile(projectsJsonPath(globalConfigPath2), "utf8");
3679
5156
  const parsed = JSON.parse(raw);
3680
5157
  return { projects: parsed.projects ?? [] };
3681
5158
  } catch {
@@ -3684,16 +5161,16 @@ async function startWebUI(opts = {}) {
3684
5161
  }
3685
5162
  async function saveManifest(manifest, globalConfigPath2) {
3686
5163
  const file = projectsJsonPath(globalConfigPath2);
3687
- await fs7.mkdir(path9.dirname(file), { recursive: true });
3688
- 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");
3689
5166
  }
3690
5167
  function generateProjectSlug(rootPath) {
3691
5168
  return projectSlug(rootPath);
3692
5169
  }
3693
5170
  async function ensureProjectDataDir(slug, globalConfigPath2) {
3694
- const base = path9.dirname(globalConfigPath2);
3695
- const dir = path9.join(base, "projects", slug);
3696
- 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 });
3697
5174
  return dir;
3698
5175
  }
3699
5176
  async function handleMessage(ws, _client, msg) {
@@ -3704,7 +5181,9 @@ async function startWebUI(opts = {}) {
3704
5181
  case "collab.join":
3705
5182
  case "collab.leave":
3706
5183
  case "collab.annotate":
3707
- case "collab.resolve": {
5184
+ case "collab.resolve":
5185
+ case "collab.request_pause":
5186
+ case "collab.resume": {
3708
5187
  collabHandler.handleMessage(ws, msg);
3709
5188
  return;
3710
5189
  }
@@ -3755,10 +5234,10 @@ async function startWebUI(opts = {}) {
3755
5234
  }
3756
5235
  case "tool.confirm_result": {
3757
5236
  const { id, decision } = msg.payload;
3758
- const resolve6 = pendingConfirms.get(id);
3759
- if (resolve6) {
5237
+ const resolve5 = pendingConfirms.get(id);
5238
+ if (resolve5) {
3760
5239
  pendingConfirms.delete(id);
3761
- resolve6(decision);
5240
+ resolve5(decision);
3762
5241
  }
3763
5242
  break;
3764
5243
  }
@@ -3801,7 +5280,7 @@ async function startWebUI(opts = {}) {
3801
5280
  context.readFiles.clear();
3802
5281
  context.fileMtimes.clear();
3803
5282
  tokenCounter.reset();
3804
- sendResult(ws, true, "Context cleared");
5283
+ sendResult2(ws, true, "Context cleared");
3805
5284
  broadcast(clients, {
3806
5285
  type: "session.start",
3807
5286
  payload: { ...await sessionStartPayload(), reset: true }
@@ -3838,13 +5317,13 @@ async function startWebUI(opts = {}) {
3838
5317
  repaired: report.repaired
3839
5318
  }
3840
5319
  });
3841
- sendResult(
5320
+ sendResult2(
3842
5321
  ws,
3843
5322
  true,
3844
5323
  `Compacted: ${report.before} \u2192 ${report.after} tokens (saved ~${Math.max(0, report.before - report.after)})`
3845
5324
  );
3846
5325
  } catch (err) {
3847
- sendResult(ws, false, errMessage(err));
5326
+ sendResult2(ws, false, errMessage(err));
3848
5327
  }
3849
5328
  break;
3850
5329
  }
@@ -3863,7 +5342,7 @@ async function startWebUI(opts = {}) {
3863
5342
  };
3864
5343
  broadcast(clients, { type: "context.repaired", payload });
3865
5344
  const removed = payload.removedToolUses.length + payload.removedToolResults.length + payload.removedMessages;
3866
- sendResult(
5345
+ sendResult2(
3867
5346
  ws,
3868
5347
  true,
3869
5348
  removed > 0 ? `Context repaired: removed ${removed} orphan protocol item(s)` : "Context repair found no orphan protocol blocks"
@@ -3897,14 +5376,14 @@ async function startWebUI(opts = {}) {
3897
5376
  );
3898
5377
  const custom = customModes.find((m) => m.id === id);
3899
5378
  if (!custom) {
3900
- sendResult(ws, false, `Unknown context mode "${id}"`);
5379
+ sendResult2(ws, false, `Unknown context mode "${id}"`);
3901
5380
  break;
3902
5381
  }
3903
5382
  policy = custom;
3904
5383
  }
3905
5384
  context.meta["contextWindowMode"] = policy.id;
3906
5385
  context.meta["contextWindowPolicy"] = policy;
3907
- sendResult(ws, true, `Context mode switched to ${policy.id}`);
5386
+ sendResult2(ws, true, `Context mode switched to ${policy.id}`);
3908
5387
  broadcast(clients, {
3909
5388
  type: "context.mode.changed",
3910
5389
  payload: { id: policy.id, name: policy.name, policy }
@@ -3924,7 +5403,7 @@ async function startWebUI(opts = {}) {
3924
5403
  aggressiveOn: "soft",
3925
5404
  targetLoad: 0.65
3926
5405
  });
3927
- sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
5406
+ sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
3928
5407
  break;
3929
5408
  }
3930
5409
  case "context.mode.update": {
@@ -3940,7 +5419,7 @@ async function startWebUI(opts = {}) {
3940
5419
  preserveK: payload.preserveK,
3941
5420
  eliseThreshold: payload.eliseThreshold
3942
5421
  });
3943
- sendResult(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
5422
+ sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
3944
5423
  break;
3945
5424
  }
3946
5425
  case "context.mode.delete": {
@@ -3950,7 +5429,7 @@ async function startWebUI(opts = {}) {
3950
5429
  context.meta["contextWindowPolicy"] = resolveContextWindowPolicy({}, DEFAULT_CONTEXT_WINDOW_MODE_ID);
3951
5430
  }
3952
5431
  const result = customModeStore.remove(id);
3953
- sendResult(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
5432
+ sendResult2(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
3954
5433
  break;
3955
5434
  }
3956
5435
  case "providers.list": {
@@ -4031,19 +5510,20 @@ async function startWebUI(opts = {}) {
4031
5510
  context.provider = newProv;
4032
5511
  updateAutoCompactionMaxContext?.(newProv);
4033
5512
  try {
4034
- configWriteLock = configWriteLock.then(async () => {
4035
- const raw = await fs7.readFile(globalConfigPath, "utf8");
5513
+ const next = configWriteLock.then(async () => {
5514
+ const raw = await fs10.readFile(globalConfigPath, "utf8");
4036
5515
  const parsed = JSON.parse(raw);
4037
5516
  parsed.provider = newProvider;
4038
5517
  parsed.model = newModel;
4039
5518
  await atomicWrite5(globalConfigPath, JSON.stringify(parsed, null, 2));
4040
5519
  });
4041
- await configWriteLock;
5520
+ configWriteLock = next.then(() => void 0, () => void 0);
5521
+ await next;
4042
5522
  } catch (err) {
4043
5523
  console.warn(JSON.stringify({
4044
5524
  level: "warn",
4045
5525
  event: "webui.config_save_failed",
4046
- message: err instanceof Error ? err.message : String(err),
5526
+ message: toErrorMessage5(err),
4047
5527
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
4048
5528
  }));
4049
5529
  }
@@ -4141,6 +5621,26 @@ async function startWebUI(opts = {}) {
4141
5621
  await providerHandlers.handleProviderRemove(ws, providerId);
4142
5622
  break;
4143
5623
  }
5624
+ case "provider.clear_models": {
5625
+ const { providerId } = msg.payload;
5626
+ await providerHandlers.handleProviderClearModels(ws, providerId);
5627
+ break;
5628
+ }
5629
+ case "provider.undo_clear": {
5630
+ const { providerId, previousModels } = msg.payload;
5631
+ await providerHandlers.handleProviderUndoClear(ws, providerId, previousModels);
5632
+ break;
5633
+ }
5634
+ case "provider.update": {
5635
+ const p = msg.payload;
5636
+ await providerHandlers.handleProviderUpdate(ws, p);
5637
+ break;
5638
+ }
5639
+ case "provider.probe": {
5640
+ const { providerId, timeoutMs } = msg.payload;
5641
+ await providerHandlers.handleProviderProbe(ws, providerId, timeoutMs);
5642
+ break;
5643
+ }
4144
5644
  case "sessions.list": {
4145
5645
  const limit = msg.payload?.limit ?? 50;
4146
5646
  try {
@@ -4171,13 +5671,13 @@ async function startWebUI(opts = {}) {
4171
5671
  const { id } = msg.payload;
4172
5672
  try {
4173
5673
  if (id === session.id) {
4174
- sendResult(ws, false, "Cannot delete the active session");
5674
+ sendResult2(ws, false, "Cannot delete the active session");
4175
5675
  break;
4176
5676
  }
4177
5677
  await sessionStore.delete(id);
4178
- sendResult(ws, true, `Session ${id} deleted`);
5678
+ sendResult2(ws, true, `Session ${id} deleted`);
4179
5679
  } catch (err) {
4180
- sendResult(ws, false, errMessage(err));
5680
+ sendResult2(ws, false, errMessage(err));
4181
5681
  }
4182
5682
  break;
4183
5683
  }
@@ -4185,7 +5685,7 @@ async function startWebUI(opts = {}) {
4185
5685
  const { id } = msg.payload;
4186
5686
  try {
4187
5687
  if (id === session.id) {
4188
- sendResult(ws, false, "Session is already active");
5688
+ sendResult2(ws, false, "Session is already active");
4189
5689
  break;
4190
5690
  }
4191
5691
  const resumed = await sessionStore.resume(id);
@@ -4215,14 +5715,14 @@ async function startWebUI(opts = {}) {
4215
5715
  replayUsage: resumed.data.usage
4216
5716
  }
4217
5717
  });
4218
- sendResult(ws, true, `Resumed session ${id}`);
5718
+ sendResult2(ws, true, `Resumed session ${id}`);
4219
5719
  } catch (err) {
4220
- sendResult(ws, false, errMessage(err));
5720
+ sendResult2(ws, false, errMessage(err));
4221
5721
  }
4222
5722
  break;
4223
5723
  }
4224
5724
  case "session.save": {
4225
- sendResult(ws, true, `Session ${session.id} is auto-saved`);
5725
+ sendResult2(ws, true, `Session ${session.id} is auto-saved`);
4226
5726
  break;
4227
5727
  }
4228
5728
  case "tools.list": {
@@ -4245,6 +5745,27 @@ async function startWebUI(opts = {}) {
4245
5745
  return handleMemoryRemember(ws, msg, memoryStore);
4246
5746
  case "memory.forget":
4247
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);
4248
5769
  case "skills.list": {
4249
5770
  if (!skillLoader) {
4250
5771
  send(ws, { type: "skills.list", payload: { skills: [], enabled: false } });
@@ -4254,6 +5775,18 @@ async function startWebUI(opts = {}) {
4254
5775
  const manifests = await skillLoader.list();
4255
5776
  const entries = await skillLoader.listEntries();
4256
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
+ }
4257
5790
  send(ws, {
4258
5791
  type: "skills.list",
4259
5792
  payload: {
@@ -4263,6 +5796,8 @@ async function startWebUI(opts = {}) {
4263
5796
  description: m.description,
4264
5797
  version: m.version ?? "",
4265
5798
  source: m.source,
5799
+ sourceUrl: sourceUrlsByName.get(m.name) ?? "",
5800
+ ref: refsByName.get(m.name) ?? "",
4266
5801
  path: m.path,
4267
5802
  trigger: byName.get(m.name)?.trigger ?? "",
4268
5803
  scope: byName.get(m.name)?.scope ?? []
@@ -4281,6 +5816,261 @@ async function startWebUI(opts = {}) {
4281
5816
  }
4282
5817
  break;
4283
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
+ }
4284
6074
  case "diag.get": {
4285
6075
  const usage = tokenCounter.total();
4286
6076
  send(ws, {
@@ -4308,125 +6098,84 @@ async function startWebUI(opts = {}) {
4308
6098
  break;
4309
6099
  }
4310
6100
  case "todos.get": {
4311
- send(ws, {
4312
- type: "todos.updated",
4313
- payload: { todos: [...context.todos] }
4314
- });
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);
4315
6107
  break;
4316
6108
  }
4317
6109
  case "todos.clear": {
4318
- context.state.replaceTodos([]);
4319
- sendResult(ws, true, "Todos cleared");
4320
- 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);
4321
6116
  break;
4322
6117
  }
4323
6118
  case "todos.remove": {
4324
- const payload = msg.payload;
4325
- if (!payload) {
4326
- sendResult(ws, false, "Missing id or index");
4327
- break;
4328
- }
4329
- const { id, index } = payload;
4330
- let targetIdx = -1;
4331
- if (typeof id === "string") {
4332
- targetIdx = context.todos.findIndex((t) => t.id === id);
4333
- } else if (typeof index === "number" && index > 0) {
4334
- targetIdx = index - 1;
4335
- }
4336
- if (targetIdx < 0 || !context.todos[targetIdx]) {
4337
- sendResult(ws, false, "Todo not found");
4338
- break;
4339
- }
4340
- const removed = expectDefined2(context.todos[targetIdx]);
4341
- const next = [...context.todos.slice(0, targetIdx), ...context.todos.slice(targetIdx + 1)];
4342
- context.state.replaceTodos(next);
4343
- sendResult(ws, true, `Removed: ${removed.content}`);
4344
- 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);
4345
6125
  break;
4346
6126
  }
4347
6127
  case "tasks.get": {
4348
- const taskPath = context.meta["task.path"];
4349
- if (typeof taskPath === "string" && taskPath) {
4350
- try {
4351
- const { loadTasks } = await import("@wrongstack/core");
4352
- const file = await loadTasks(taskPath);
4353
- send(ws, {
4354
- type: "tasks.updated",
4355
- payload: { tasks: file?.tasks ?? [] }
4356
- });
4357
- } catch {
4358
- send(ws, { type: "tasks.updated", payload: { tasks: [] } });
4359
- }
4360
- } else {
4361
- send(ws, { type: "tasks.updated", payload: { tasks: [], error: "Task storage not configured." } });
4362
- }
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);
4363
6134
  break;
4364
6135
  }
4365
6136
  case "plan.get": {
4366
- const planPath = context.meta["plan.path"];
4367
- if (typeof planPath === "string" && planPath) {
4368
- try {
4369
- const { loadPlan } = await import("@wrongstack/core");
4370
- const plan = await loadPlan(planPath);
4371
- send(ws, {
4372
- type: "plan.updated",
4373
- payload: {
4374
- plan: plan ?? {
4375
- version: 1,
4376
- sessionId: session.id,
4377
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4378
- items: []
4379
- }
4380
- }
4381
- });
4382
- } catch {
4383
- send(ws, {
4384
- type: "plan.updated",
4385
- payload: {
4386
- plan: {
4387
- version: 1,
4388
- sessionId: session.id,
4389
- updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
4390
- items: []
4391
- }
4392
- }
4393
- });
4394
- }
4395
- } else {
4396
- send(ws, {
4397
- type: "plan.updated",
4398
- payload: { plan: null, error: "Plan storage is not configured for this session." }
4399
- });
4400
- }
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);
4401
6143
  break;
4402
6144
  }
4403
6145
  case "plan.template_use": {
4404
- const { template } = msg.payload;
4405
- const planPath = context.meta["plan.path"];
4406
- if (typeof planPath !== "string" || !planPath) {
4407
- sendResult(ws, false, "Plan storage is not configured for this session.");
4408
- break;
4409
- }
4410
- try {
4411
- const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
4412
- const tpl = getPlanTemplate(template);
4413
- if (!tpl) {
4414
- sendResult(ws, false, `Unknown template "${template}".`);
4415
- break;
4416
- }
4417
- let plan = await loadPlan(planPath) ?? emptyPlan(session.id);
4418
- for (const item of tpl.items) {
4419
- ({ plan } = addPlanItem(plan, item.title, item.details));
4420
- }
4421
- await savePlan(planPath, plan);
4422
- sendResult(ws, true, `Applied template "${tpl.name}" \u2014 ${tpl.items.length} items added.`);
4423
- broadcast(clients, {
4424
- type: "plan.updated",
4425
- payload: { plan }
4426
- });
4427
- } catch (err) {
4428
- sendResult(ws, false, errMessage(err));
4429
- }
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);
6152
+ break;
6153
+ }
6154
+ case "todo.update": {
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)
6159
+ };
6160
+ handleTodoUpdate(ctx, ws, msg.payload);
6161
+ break;
6162
+ }
6163
+ case "task.update": {
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);
6170
+ break;
6171
+ }
6172
+ case "plan.item.update": {
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);
4430
6179
  break;
4431
6180
  }
4432
6181
  // ── File operations — delegated to shared handlers (file-handlers.ts) ──
@@ -4496,13 +6245,13 @@ async function startWebUI(opts = {}) {
4496
6245
  provider: config.provider,
4497
6246
  model: config.model
4498
6247
  });
4499
- sendResult(ws, true, `Switched to mode "${id}"`);
6248
+ sendResult2(ws, true, `Switched to mode "${id}"`);
4500
6249
  broadcast(clients, {
4501
6250
  type: "session.start",
4502
6251
  payload: { ...await sessionStartPayload() }
4503
6252
  });
4504
6253
  } catch (err) {
4505
- sendResult(ws, false, errMessage(err));
6254
+ sendResult2(ws, false, errMessage(err));
4506
6255
  }
4507
6256
  break;
4508
6257
  }
@@ -4556,13 +6305,13 @@ async function startWebUI(opts = {}) {
4556
6305
  const { getProcessRegistry } = await import("@wrongstack/tools");
4557
6306
  const proc = getProcessRegistry().get(pid);
4558
6307
  if (proc?.protected) {
4559
- sendResult(ws, false, `Cannot kill protected process (PID ${pid})`);
6308
+ sendResult2(ws, false, `Cannot kill protected process (PID ${pid})`);
4560
6309
  break;
4561
6310
  }
4562
6311
  getProcessRegistry().kill(pid);
4563
- sendResult(ws, true, `Killed PID ${pid}`);
6312
+ sendResult2(ws, true, `Killed PID ${pid}`);
4564
6313
  } catch (err) {
4565
- sendResult(ws, false, errMessage(err));
6314
+ sendResult2(ws, false, errMessage(err));
4566
6315
  }
4567
6316
  break;
4568
6317
  }
@@ -4570,16 +6319,25 @@ async function startWebUI(opts = {}) {
4570
6319
  try {
4571
6320
  const { getProcessRegistry } = await import("@wrongstack/tools");
4572
6321
  getProcessRegistry().killAll();
4573
- sendResult(ws, true, "All processes killed");
6322
+ sendResult2(ws, true, "All processes killed");
4574
6323
  } catch (err) {
4575
- sendResult(ws, false, errMessage(err));
6324
+ sendResult2(ws, false, errMessage(err));
4576
6325
  }
4577
6326
  break;
4578
6327
  }
6328
+ case "git.info": {
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");
6335
+ break;
6336
+ }
4579
6337
  case "goal.get": {
4580
6338
  try {
4581
- const goalPath = path9.join(projectRoot, ".wrongstack", "goal.json");
4582
- const raw = await fs7.readFile(goalPath, "utf8");
6339
+ const goalPath = resolveWstackPaths({ projectRoot }).projectGoal;
6340
+ const raw = await fs10.readFile(goalPath, "utf8");
4583
6341
  const goal = JSON.parse(raw);
4584
6342
  broadcast(clients, { type: "goal.updated", payload: goal });
4585
6343
  } catch {
@@ -4590,7 +6348,7 @@ async function startWebUI(opts = {}) {
4590
6348
  case "autonomy.switch": {
4591
6349
  const { mode } = msg.payload;
4592
6350
  context.meta["autonomy"] = mode;
4593
- sendResult(ws, true, `Autonomy mode set to "${mode}"`);
6351
+ sendResult2(ws, true, `Autonomy mode set to "${mode}"`);
4594
6352
  broadcast(clients, { type: "prefs.updated", payload: { autonomy: mode } });
4595
6353
  void persistPrefsToConfig({ autonomy: mode });
4596
6354
  break;
@@ -4639,7 +6397,7 @@ async function startWebUI(opts = {}) {
4639
6397
  try {
4640
6398
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4641
6399
  const rewinder = new DefaultSessionRewinder(
4642
- path9.join(projectRoot, ".wrongstack", "sessions"),
6400
+ path10.join(projectRoot, ".wrongstack", "sessions"),
4643
6401
  projectRoot
4644
6402
  );
4645
6403
  const checkpoints = await rewinder.listCheckpoints(session.id);
@@ -4660,18 +6418,18 @@ async function startWebUI(opts = {}) {
4660
6418
  try {
4661
6419
  const { DefaultSessionRewinder } = await import("@wrongstack/core");
4662
6420
  const rewinder = new DefaultSessionRewinder(
4663
- path9.join(projectRoot, ".wrongstack", "sessions"),
6421
+ path10.join(projectRoot, ".wrongstack", "sessions"),
4664
6422
  projectRoot
4665
6423
  );
4666
6424
  await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
4667
6425
  await context.session.truncateToCheckpoint(checkpointIndex);
4668
- sendResult(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
6426
+ sendResult2(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
4669
6427
  broadcast(clients, {
4670
6428
  type: "session.start",
4671
6429
  payload: { ...await sessionStartPayload(), reset: true }
4672
6430
  });
4673
6431
  } catch (err) {
4674
- sendResult(ws, false, errMessage(err));
6432
+ sendResult2(ws, false, errMessage(err));
4675
6433
  }
4676
6434
  break;
4677
6435
  }
@@ -4694,9 +6452,9 @@ async function startWebUI(opts = {}) {
4694
6452
  case "projects.add": {
4695
6453
  const { root: addRoot, name: displayName } = msg.payload;
4696
6454
  try {
4697
- const resolved = path9.resolve(addRoot);
4698
- await fs7.access(resolved);
4699
- const stat2 = await fs7.stat(resolved);
6455
+ const resolved = path10.resolve(addRoot);
6456
+ await fs10.access(resolved);
6457
+ const stat2 = await fs10.stat(resolved);
4700
6458
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4701
6459
  const manifest = await loadManifest(globalConfigPath);
4702
6460
  const existing = manifest.projects.find((p) => p.root === resolved);
@@ -4712,7 +6470,7 @@ async function startWebUI(opts = {}) {
4712
6470
  });
4713
6471
  break;
4714
6472
  }
4715
- const name = displayName?.trim() || path9.basename(resolved);
6473
+ const name = displayName?.trim() || path10.basename(resolved);
4716
6474
  const slug = generateProjectSlug(resolved);
4717
6475
  await ensureProjectDataDir(slug, globalConfigPath);
4718
6476
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -4731,7 +6489,7 @@ async function startWebUI(opts = {}) {
4731
6489
  send(ws, {
4732
6490
  type: "projects.added",
4733
6491
  payload: {
4734
- name: path9.basename(addRoot),
6492
+ name: path10.basename(addRoot),
4735
6493
  root: addRoot,
4736
6494
  slug: "",
4737
6495
  message: errMessage(err)
@@ -4743,17 +6501,17 @@ async function startWebUI(opts = {}) {
4743
6501
  case "projects.select": {
4744
6502
  const { root: selRoot, name: selName } = msg.payload;
4745
6503
  try {
4746
- const resolved = path9.resolve(selRoot);
6504
+ const resolved = path10.resolve(selRoot);
4747
6505
  try {
4748
- await fs7.access(resolved);
4749
- const stat2 = await fs7.stat(resolved);
6506
+ await fs10.access(resolved);
6507
+ const stat2 = await fs10.stat(resolved);
4750
6508
  if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
4751
6509
  } catch (err) {
4752
6510
  send(ws, {
4753
6511
  type: "projects.selected",
4754
6512
  payload: {
4755
6513
  root: selRoot,
4756
- name: selName || path9.basename(selRoot),
6514
+ name: selName || path10.basename(selRoot),
4757
6515
  message: `Cannot switch: ${errMessage(err)}`
4758
6516
  }
4759
6517
  });
@@ -4765,7 +6523,7 @@ async function startWebUI(opts = {}) {
4765
6523
  entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
4766
6524
  entry.lastWorkingDir = resolved;
4767
6525
  } else {
4768
- const name = selName?.trim() || path9.basename(resolved);
6526
+ const name = selName?.trim() || path10.basename(resolved);
4769
6527
  const slug = generateProjectSlug(resolved);
4770
6528
  manifest.projects.push({
4771
6529
  name,
@@ -4806,13 +6564,13 @@ async function startWebUI(opts = {}) {
4806
6564
  });
4807
6565
  } catch {
4808
6566
  }
4809
- const newSessionsDir = path9.join(
4810
- path9.dirname(globalConfigPath),
6567
+ const newSessionsDir = path10.join(
6568
+ path10.dirname(globalConfigPath),
4811
6569
  "projects",
4812
6570
  switchSlug,
4813
6571
  "sessions"
4814
6572
  );
4815
- await fs7.mkdir(newSessionsDir, { recursive: true });
6573
+ await fs10.mkdir(newSessionsDir, { recursive: true });
4816
6574
  const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
4817
6575
  const oldSessionId = session.id;
4818
6576
  try {
@@ -4844,8 +6602,9 @@ async function startWebUI(opts = {}) {
4844
6602
  sessionId: session.id,
4845
6603
  projectSlug: switchSlug,
4846
6604
  projectRoot,
4847
- projectName: path9.basename(projectRoot),
6605
+ projectName: path10.basename(projectRoot),
4848
6606
  workingDir,
6607
+ clientType: "webui",
4849
6608
  pid: process.pid,
4850
6609
  startedAt: (/* @__PURE__ */ new Date()).toISOString()
4851
6610
  });
@@ -4855,8 +6614,8 @@ async function startWebUI(opts = {}) {
4855
6614
  type: "projects.selected",
4856
6615
  payload: {
4857
6616
  root: resolved,
4858
- name: selName || path9.basename(resolved),
4859
- message: `Switched to ${selName || path9.basename(resolved)}`
6617
+ name: selName || path10.basename(resolved),
6618
+ message: `Switched to ${selName || path10.basename(resolved)}`
4860
6619
  }
4861
6620
  });
4862
6621
  broadcast(clients, {
@@ -4879,7 +6638,7 @@ async function startWebUI(opts = {}) {
4879
6638
  type: "projects.selected",
4880
6639
  payload: {
4881
6640
  root: selRoot,
4882
- name: selName || path9.basename(selRoot),
6641
+ name: selName || path10.basename(selRoot),
4883
6642
  message: errMessage(err)
4884
6643
  }
4885
6644
  });
@@ -4890,17 +6649,17 @@ async function startWebUI(opts = {}) {
4890
6649
  case "working_dir.set": {
4891
6650
  const { path: newPath } = msg.payload;
4892
6651
  try {
4893
- const resolved = path9.resolve(projectRoot, newPath);
4894
- if (!resolved.startsWith(projectRoot + path9.sep) && resolved !== projectRoot) {
4895
- 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}`);
4896
6655
  break;
4897
6656
  }
4898
6657
  try {
4899
- await fs7.access(resolved);
4900
- const stat2 = await fs7.stat(resolved);
6658
+ await fs10.access(resolved);
6659
+ const stat2 = await fs10.stat(resolved);
4901
6660
  if (!stat2.isDirectory()) throw new Error("Not a directory");
4902
6661
  } catch {
4903
- sendResult(ws, false, `Directory not found or not accessible: ${resolved}`);
6662
+ sendResult2(ws, false, `Directory not found or not accessible: ${resolved}`);
4904
6663
  break;
4905
6664
  }
4906
6665
  workingDir = resolved;
@@ -4909,9 +6668,9 @@ async function startWebUI(opts = {}) {
4909
6668
  type: "working_dir.changed",
4910
6669
  payload: { cwd: resolved, projectRoot }
4911
6670
  });
4912
- sendResult(ws, true, `Working directory set to ${resolved}`);
6671
+ sendResult2(ws, true, `Working directory set to ${resolved}`);
4913
6672
  } catch (err) {
4914
- sendResult(ws, false, errMessage(err));
6673
+ sendResult2(ws, false, errMessage(err));
4915
6674
  }
4916
6675
  break;
4917
6676
  }
@@ -4921,26 +6680,32 @@ async function startWebUI(opts = {}) {
4921
6680
  msg.payload,
4922
6681
  logger
4923
6682
  );
4924
- sendResult(ws, result.success, result.message);
6683
+ sendResult2(ws, result.success, result.message);
4925
6684
  break;
4926
6685
  }
4927
6686
  // ── Mailbox operations — project-level inter-agent messaging ────
4928
6687
  case "mailbox.messages":
4929
6688
  return handleMailboxMessages(
4930
6689
  ws,
4931
- { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
6690
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
4932
6691
  msg.payload
4933
6692
  );
4934
6693
  case "mailbox.agents":
4935
6694
  return handleMailboxAgents(
4936
6695
  ws,
4937
- { projectRoot, globalRoot: path9.dirname(globalConfigPath) },
6696
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
4938
6697
  msg.payload
4939
6698
  );
4940
6699
  case "mailbox.clear":
4941
6700
  return handleMailboxClear(
4942
6701
  ws,
4943
- { projectRoot, globalRoot: path9.dirname(globalConfigPath) }
6702
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) }
6703
+ );
6704
+ case "mailbox.purge":
6705
+ return handleMailboxPurge(
6706
+ ws,
6707
+ { projectRoot, globalRoot: path10.dirname(globalConfigPath) },
6708
+ msg.payload
4944
6709
  );
4945
6710
  // ── Brain — status, autonomy ceiling, direct decision support ───
4946
6711
  case "brain.status":
@@ -4953,7 +6718,7 @@ async function startWebUI(opts = {}) {
4953
6718
  const level = msg.payload?.level ?? "";
4954
6719
  const valid = ["off", "low", "medium", "high", "all"];
4955
6720
  if (!valid.includes(level)) {
4956
- sendResult(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
6721
+ sendResult2(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
4957
6722
  break;
4958
6723
  }
4959
6724
  brainSettings.maxAutoRisk = level;
@@ -4966,7 +6731,7 @@ async function startWebUI(opts = {}) {
4966
6731
  case "brain.ask": {
4967
6732
  const question = msg.payload?.question?.trim();
4968
6733
  if (!question) {
4969
- sendResult(ws, false, "Usage: /brain ask <question>");
6734
+ sendResult2(ws, false, "Usage: /brain ask <question>");
4970
6735
  break;
4971
6736
  }
4972
6737
  try {
@@ -4979,7 +6744,7 @@ async function startWebUI(opts = {}) {
4979
6744
  });
4980
6745
  send(ws, { type: "brain.answer", payload: { question, decision } });
4981
6746
  } catch (err) {
4982
- sendResult(ws, false, `Brain consultation failed: ${errMessage(err)}`);
6747
+ sendResult2(ws, false, `Brain consultation failed: ${errMessage(err)}`);
4983
6748
  }
4984
6749
  break;
4985
6750
  }
@@ -5006,14 +6771,28 @@ async function startWebUI(opts = {}) {
5006
6771
  broadcast,
5007
6772
  clients
5008
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
+ };
5009
6784
  const httpServer = createHttpServer({
5010
6785
  host: wsHost,
5011
- distDir: path9.resolve(import.meta.dirname, "../../dist"),
6786
+ distDir: path10.resolve(import.meta.dirname, "../../dist"),
5012
6787
  wsPort,
5013
6788
  globalRoot: wpaths.globalRoot,
5014
- apiToken: wsToken
6789
+ apiToken: wsToken,
6790
+ watcherMetrics,
6791
+ onFleetPing: () => {
6792
+ void fleetBroadcast?.();
6793
+ }
5015
6794
  });
5016
- const registryBaseDir = path9.dirname(globalConfigPath);
6795
+ const registryBaseDir = path10.dirname(globalConfigPath);
5017
6796
  httpServer.listen(httpPort, wsHost, () => {
5018
6797
  const openUrl = `http://${wsHost}:${httpPort}`;
5019
6798
  console.log(`[WebUI] HTTP server running on ${openUrl}`);
@@ -5025,7 +6804,7 @@ async function startWebUI(opts = {}) {
5025
6804
  wsPort,
5026
6805
  host: wsHost,
5027
6806
  projectRoot,
5028
- projectName: path9.basename(projectRoot) || projectRoot,
6807
+ projectName: path10.basename(projectRoot) || projectRoot,
5029
6808
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5030
6809
  url: `http://${wsHost}:${httpPort}`
5031
6810
  },
@@ -5052,6 +6831,10 @@ async function startWebUI(opts = {}) {
5052
6831
  // reality. Crash exits are healed by the next register()/list() prune pass.
5053
6832
  onShutdown: () => {
5054
6833
  brainMonitor.stop();
6834
+ if (disposeEvents) {
6835
+ disposeEvents();
6836
+ disposeEvents = null;
6837
+ }
5055
6838
  if (eternalSubscription) {
5056
6839
  eternalSubscription.dispose();
5057
6840
  eternalSubscription = null;
@@ -5082,10 +6865,28 @@ export {
5082
6865
  handleFilesRead,
5083
6866
  handleFilesTree,
5084
6867
  handleFilesWrite,
6868
+ handleGitInfo,
6869
+ handleMcpAdd,
6870
+ handleMcpDisable,
6871
+ handleMcpDiscover,
6872
+ handleMcpEnable,
6873
+ handleMcpList,
6874
+ handleMcpRemove,
6875
+ handleMcpRestart,
6876
+ handleMcpSleep,
6877
+ handleMcpUpdate,
6878
+ handleMcpWake,
5085
6879
  handleMemoryForget,
5086
6880
  handleMemoryList,
5087
6881
  handleMemoryRemember,
5088
6882
  handleShellOpen,
6883
+ handleSkillsContent,
6884
+ handleSkillsCreate,
6885
+ handleSkillsEdit,
6886
+ handleSkillsExport,
6887
+ handleSkillsInstall,
6888
+ handleSkillsUninstall,
6889
+ handleSkillsUpdate,
5089
6890
  hostHeaderOk,
5090
6891
  injectWsPort,
5091
6892
  isLoopbackBind,
@@ -5103,7 +6904,7 @@ export {
5103
6904
  removeProvider,
5104
6905
  saveProviders,
5105
6906
  send,
5106
- sendResult,
6907
+ sendResult2 as sendResult,
5107
6908
  setActiveKey,
5108
6909
  startWebUI,
5109
6910
  stringifyContent,