@wrongstack/webui 0.264.0 → 0.265.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/index-CC3KdGXG.js +118 -0
- package/dist/assets/index-CFHDQU3T.css +2 -0
- package/dist/assets/{vendor-CEQg2uSG.css → vendor-B2D6LvU3.css} +1 -1
- package/dist/assets/vendor-BX-wfDR9.js +1326 -0
- package/dist/index.html +4 -4
- package/dist/index.js +8949 -4311
- package/dist/index.js.map +1 -1
- package/dist/server/entry.js +1612 -339
- package/dist/server/entry.js.map +1 -1
- package/dist/server/handlers.d.ts +45 -0
- package/dist/server/handlers.js +179 -0
- package/dist/server/handlers.js.map +1 -0
- package/dist/server/index.d.ts +147 -6
- package/dist/server/index.js +1945 -405
- package/dist/server/index.js.map +1 -1
- package/dist/types.d.ts +391 -2
- package/package.json +7 -5
- package/dist/assets/index-BBPaC1tO.js +0 -170
- package/dist/assets/index-DJmqJ5Wo.css +0 -2
- package/dist/assets/vendor-pWpGJmMc.js +0 -1303
package/dist/server/entry.js
CHANGED
|
@@ -1,8 +1,180 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// src/server/index.ts
|
|
3
|
-
import { expectDefined as expectDefined2, GlobalMailbox as GlobalMailbox2, projectSlug, getSessionRegistry, AgentStatusTracker } from "@wrongstack/core";
|
|
3
|
+
import { expectDefined as expectDefined2, GlobalMailbox as GlobalMailbox2, projectSlug, getSessionRegistry, AgentStatusTracker, FleetNotifier } from "@wrongstack/core";
|
|
4
|
+
|
|
5
|
+
// src/server/handlers/worklist-handlers.ts
|
|
6
|
+
function sendResult(ws, ctx, ok, message) {
|
|
7
|
+
ctx.send(ws, { type: ok ? "ok" : "error", message });
|
|
8
|
+
}
|
|
9
|
+
function handleTodosGet(ctx, ws) {
|
|
10
|
+
ctx.send(ws, { type: "todos.updated", payload: { todos: ctx.context.todos } });
|
|
11
|
+
}
|
|
12
|
+
function handleTodosClear(ctx, ws) {
|
|
13
|
+
ctx.replaceTodos?.([]);
|
|
14
|
+
ctx.broadcast({ type: "todos.cleared" });
|
|
15
|
+
sendResult(ws, ctx, true, "Todo board cleared.");
|
|
16
|
+
}
|
|
17
|
+
function handleTodosRemove(ctx, ws, payload) {
|
|
18
|
+
if (!payload || payload.id === void 0 && payload.index === void 0) {
|
|
19
|
+
sendResult(ws, ctx, false, "todos.remove requires id or index.");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const next = payload.id !== void 0 ? ctx.context.todos.filter((t) => t.id !== payload.id) : ctx.context.todos.filter((_, i) => i !== payload.index);
|
|
23
|
+
ctx.replaceTodos?.(next);
|
|
24
|
+
ctx.broadcast({ type: "todos.updated", payload: { todos: next } });
|
|
25
|
+
sendResult(ws, ctx, true, "Todo item removed.");
|
|
26
|
+
}
|
|
27
|
+
function handleTodoUpdate(ctx, ws, payload) {
|
|
28
|
+
const todo = ctx.context.todos.find((t) => t.id === payload.id);
|
|
29
|
+
if (!todo) {
|
|
30
|
+
sendResult(ws, ctx, false, `No todo with id "${payload.id}".`);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const next = ctx.context.todos.map(
|
|
34
|
+
(t) => t.id === payload.id ? { ...t, ...payload.status !== void 0 && { status: payload.status }, ...payload.activeForm !== void 0 && { activeForm: payload.activeForm } } : t
|
|
35
|
+
);
|
|
36
|
+
ctx.replaceTodos?.(next);
|
|
37
|
+
ctx.broadcast({ type: "todos.updated", payload: { todos: next } });
|
|
38
|
+
sendResult(ws, ctx, true, `Todo "${todo.content}" updated.`);
|
|
39
|
+
}
|
|
40
|
+
async function handleTasksGet(ctx, ws) {
|
|
41
|
+
const taskPath = ctx.context.meta["task.path"];
|
|
42
|
+
if (typeof taskPath === "string" && taskPath) {
|
|
43
|
+
try {
|
|
44
|
+
const { loadTasks } = await import("@wrongstack/core");
|
|
45
|
+
const file = await loadTasks(taskPath);
|
|
46
|
+
ctx.send(ws, { type: "tasks.updated", payload: { tasks: file?.tasks ?? [] } });
|
|
47
|
+
} catch {
|
|
48
|
+
ctx.send(ws, { type: "tasks.updated", payload: { tasks: [] } });
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
ctx.send(ws, {
|
|
52
|
+
type: "tasks.updated",
|
|
53
|
+
payload: { tasks: [], error: "Task storage not configured." }
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function handleTaskUpdate(ctx, ws, payload) {
|
|
58
|
+
const taskPath = ctx.context.meta["task.path"];
|
|
59
|
+
if (typeof taskPath !== "string" || !taskPath) {
|
|
60
|
+
sendResult(ws, ctx, false, "Task storage is not configured for this session.");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const { loadTasks, saveTasks } = await import("@wrongstack/core");
|
|
65
|
+
const file = await loadTasks(taskPath);
|
|
66
|
+
if (!file) {
|
|
67
|
+
sendResult(ws, ctx, false, "No task file found.");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const idx = file.tasks.findIndex((t) => t.id === payload.id);
|
|
71
|
+
if (idx === -1) {
|
|
72
|
+
sendResult(ws, ctx, false, `Task "${payload.id}" not found.`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
file.tasks[idx] = { ...file.tasks[idx], status: payload.status };
|
|
76
|
+
await saveTasks(taskPath, file);
|
|
77
|
+
ctx.broadcast({ type: "tasks.updated", payload: { tasks: file.tasks } });
|
|
78
|
+
sendResult(ws, ctx, true, `Task "${payload.id}" marked ${payload.status}.`);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
sendResult(ws, ctx, false, String(err));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function handlePlanGet(ctx, ws) {
|
|
84
|
+
const planPath = ctx.context.meta["plan.path"];
|
|
85
|
+
const sessionId = ctx.context.session?.id ?? "";
|
|
86
|
+
if (typeof planPath === "string" && planPath) {
|
|
87
|
+
try {
|
|
88
|
+
const { loadPlan } = await import("@wrongstack/core");
|
|
89
|
+
const plan = await loadPlan(planPath);
|
|
90
|
+
ctx.send(ws, {
|
|
91
|
+
type: "plan.updated",
|
|
92
|
+
payload: {
|
|
93
|
+
plan: plan ?? {
|
|
94
|
+
version: 1,
|
|
95
|
+
sessionId,
|
|
96
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
97
|
+
items: []
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
ctx.send(ws, {
|
|
103
|
+
type: "plan.updated",
|
|
104
|
+
payload: {
|
|
105
|
+
plan: {
|
|
106
|
+
version: 1,
|
|
107
|
+
sessionId,
|
|
108
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
109
|
+
items: []
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
ctx.send(ws, {
|
|
116
|
+
type: "plan.updated",
|
|
117
|
+
payload: { plan: null, error: "Plan storage is not configured for this session." }
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function handlePlanTemplateUse(ctx, ws, template) {
|
|
122
|
+
const planPath = ctx.context.meta["plan.path"];
|
|
123
|
+
const sessionId = ctx.context.session?.id ?? "";
|
|
124
|
+
if (typeof planPath !== "string" || !planPath) {
|
|
125
|
+
sendResult(ws, ctx, false, "Plan storage is not configured for this session.");
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
|
|
130
|
+
const tpl = getPlanTemplate(template);
|
|
131
|
+
if (!tpl) {
|
|
132
|
+
sendResult(ws, ctx, false, `Unknown template "${template}".`);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
let plan = await loadPlan(planPath) ?? emptyPlan(sessionId);
|
|
136
|
+
for (const item of tpl.items) {
|
|
137
|
+
({ plan } = addPlanItem(plan, item.title, item.details));
|
|
138
|
+
}
|
|
139
|
+
await savePlan(planPath, plan);
|
|
140
|
+
sendResult(ws, ctx, true, `Applied template "${tpl.name}" \u2014 ${tpl.items.length} items added.`);
|
|
141
|
+
ctx.broadcast({ type: "plan.updated", payload: { plan } });
|
|
142
|
+
} catch (err) {
|
|
143
|
+
sendResult(ws, ctx, false, String(err));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function handlePlanItemUpdate(ctx, ws, payload) {
|
|
147
|
+
const planPath = ctx.context.meta["plan.path"];
|
|
148
|
+
const sessionId = ctx.context.session?.id ?? "";
|
|
149
|
+
if (typeof planPath !== "string" || !planPath) {
|
|
150
|
+
sendResult(ws, ctx, false, "Plan storage is not configured for this session.");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const { loadPlan, savePlan, mutatePlan, setPlanItemStatus } = await import("@wrongstack/core");
|
|
155
|
+
let changed = false;
|
|
156
|
+
const plan = await mutatePlan(planPath, sessionId, async (p) => {
|
|
157
|
+
const before = p.updatedAt;
|
|
158
|
+
const updated = setPlanItemStatus(p, payload.target, payload.status);
|
|
159
|
+
changed = updated.updatedAt !== before;
|
|
160
|
+
return updated;
|
|
161
|
+
});
|
|
162
|
+
if (!changed) {
|
|
163
|
+
sendResult(ws, ctx, false, `No plan item matched "${payload.target}".`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
sendResult(ws, ctx, true, `Plan item status updated to "${payload.status}".`);
|
|
167
|
+
ctx.broadcast({ type: "plan.updated", payload: { plan } });
|
|
168
|
+
} catch (err) {
|
|
169
|
+
sendResult(ws, ctx, false, String(err));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/server/index.ts
|
|
4
174
|
import { makeMailboxTool, makeMailSendTool, makeMailInboxTool, mailboxSessionTag } from "@wrongstack/core";
|
|
5
|
-
import { toErrorMessage as toErrorMessage5 } from "@wrongstack/core/utils";
|
|
175
|
+
import { toErrorMessage as toErrorMessage5, wstackGlobalRoot as wstackGlobalRoot2, projectHash, resolveWstackPaths } from "@wrongstack/core/utils";
|
|
176
|
+
import { SkillInstaller } from "@wrongstack/core/skills";
|
|
177
|
+
import JSZip2 from "jszip";
|
|
6
178
|
import {
|
|
7
179
|
BrainMonitor,
|
|
8
180
|
DefaultBrainArbiter,
|
|
@@ -10,8 +182,8 @@ import {
|
|
|
10
182
|
createAutonomyBrain,
|
|
11
183
|
createTieredBrainArbiter
|
|
12
184
|
} from "@wrongstack/core";
|
|
13
|
-
import * as
|
|
14
|
-
import * as
|
|
185
|
+
import * as fs10 from "fs/promises";
|
|
186
|
+
import * as path10 from "path";
|
|
15
187
|
|
|
16
188
|
// src/server/http-server.ts
|
|
17
189
|
import * as fs from "fs/promises";
|
|
@@ -130,6 +302,13 @@ function isInsideDist(candidate, distDir) {
|
|
|
130
302
|
const resolved = path.resolve(candidate);
|
|
131
303
|
return resolved === root || resolved.startsWith(root + path.sep);
|
|
132
304
|
}
|
|
305
|
+
function decodeSessionId(segment) {
|
|
306
|
+
try {
|
|
307
|
+
return decodeURIComponent(segment);
|
|
308
|
+
} catch {
|
|
309
|
+
return segment;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
133
312
|
function createHttpServer(opts) {
|
|
134
313
|
const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
|
|
135
314
|
const distDir = path.resolve(opts.distDir);
|
|
@@ -155,6 +334,22 @@ function createHttpServer(opts) {
|
|
|
155
334
|
res.end("ok");
|
|
156
335
|
return;
|
|
157
336
|
}
|
|
337
|
+
if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
|
|
338
|
+
const headerToken = req.headers["x-ws-token"];
|
|
339
|
+
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
340
|
+
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
341
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
342
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
opts.onFleetPing?.();
|
|
347
|
+
} catch {
|
|
348
|
+
}
|
|
349
|
+
res.writeHead(204);
|
|
350
|
+
res.end();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
158
353
|
if (url.pathname === "/api/sessions" && req.method === "GET") {
|
|
159
354
|
const headerToken = req.headers["x-ws-token"];
|
|
160
355
|
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
@@ -175,7 +370,89 @@ function createHttpServer(opts) {
|
|
|
175
370
|
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
176
371
|
return;
|
|
177
372
|
}
|
|
178
|
-
await handleApiSessionAgents(res, opts.globalRoot, agentsMatch[1]);
|
|
373
|
+
await handleApiSessionAgents(res, opts.globalRoot, decodeSessionId(agentsMatch[1]));
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const eventsMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/events$/);
|
|
377
|
+
if (eventsMatch && req.method === "GET") {
|
|
378
|
+
const headerToken = req.headers["x-ws-token"];
|
|
379
|
+
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
380
|
+
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
381
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
382
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const rawLimit = Number.parseInt(url.searchParams.get("limit") ?? "200", 10);
|
|
386
|
+
const limit = Math.min(500, Math.max(1, Number.isFinite(rawLimit) ? rawLimit : 200));
|
|
387
|
+
await handleApiSessionEvents(res, opts.globalRoot, decodeSessionId(eventsMatch[1]), limit);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const msgMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/message$/);
|
|
391
|
+
if (msgMatch && req.method === "POST") {
|
|
392
|
+
const headerToken = req.headers["x-ws-token"];
|
|
393
|
+
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
394
|
+
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
395
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
396
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
await handleApiSessionMessage(res, req, opts.globalRoot, decodeSessionId(msgMatch[1]));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const mailboxMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/mailbox$/);
|
|
403
|
+
if (mailboxMatch && req.method === "GET") {
|
|
404
|
+
const headerToken = req.headers["x-ws-token"];
|
|
405
|
+
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
406
|
+
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
407
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
408
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
await handleApiSessionMailbox(res, opts.globalRoot, decodeSessionId(mailboxMatch[1]));
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
const interruptMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/interrupt$/);
|
|
415
|
+
if (interruptMatch && req.method === "POST") {
|
|
416
|
+
const headerToken = req.headers["x-ws-token"];
|
|
417
|
+
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
418
|
+
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
419
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
420
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
await handleApiSessionInterrupt(
|
|
424
|
+
res,
|
|
425
|
+
req,
|
|
426
|
+
opts.globalRoot,
|
|
427
|
+
decodeSessionId(interruptMatch[1])
|
|
428
|
+
);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (url.pathname === "/api/fleet/broadcast" && req.method === "POST") {
|
|
432
|
+
const headerToken = req.headers["x-ws-token"];
|
|
433
|
+
const provided = Array.isArray(headerToken) ? headerToken[0] : headerToken;
|
|
434
|
+
if (requireApiToken && !tokenMatches(provided, opts.apiToken ?? "")) {
|
|
435
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
436
|
+
res.end(JSON.stringify({ error: "Unauthorized" }));
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
await handleApiFleetBroadcast(res, req, opts.globalRoot);
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
if (url.pathname === "/debug/watcher-metrics" && req.method === "GET") {
|
|
443
|
+
if (opts.watcherMetrics) {
|
|
444
|
+
const avgDelay = opts.watcherMetrics.broadcastsSent > 0 ? opts.watcherMetrics.totalDebounceDelayMs / opts.watcherMetrics.broadcastsSent : 0;
|
|
445
|
+
const response = {
|
|
446
|
+
...opts.watcherMetrics,
|
|
447
|
+
averageDebounceDelayMs: avgDelay,
|
|
448
|
+
timestamp: Date.now()
|
|
449
|
+
};
|
|
450
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
451
|
+
res.end(JSON.stringify(response));
|
|
452
|
+
} else {
|
|
453
|
+
res.writeHead(503, { "Content-Type": "application/json" });
|
|
454
|
+
res.end(JSON.stringify({ error: "File watcher metrics not available" }));
|
|
455
|
+
}
|
|
179
456
|
return;
|
|
180
457
|
}
|
|
181
458
|
let filePath;
|
|
@@ -307,6 +584,324 @@ async function handleApiSessionAgents(res, globalRoot, sessionId) {
|
|
|
307
584
|
res.end(JSON.stringify({ error: String(err) }));
|
|
308
585
|
}
|
|
309
586
|
}
|
|
587
|
+
function blocksToText(content) {
|
|
588
|
+
if (typeof content === "string") return content;
|
|
589
|
+
if (Array.isArray(content)) {
|
|
590
|
+
return content.filter(
|
|
591
|
+
(b) => !!b && typeof b === "object" && b.type === "text" && typeof b.text === "string"
|
|
592
|
+
).map((b) => b.text).join("\n");
|
|
593
|
+
}
|
|
594
|
+
return "";
|
|
595
|
+
}
|
|
596
|
+
function clip(s, n = 600) {
|
|
597
|
+
return s.length > n ? `${s.slice(0, n)}\u2026` : s;
|
|
598
|
+
}
|
|
599
|
+
function asString(v) {
|
|
600
|
+
if (typeof v === "string") return v;
|
|
601
|
+
try {
|
|
602
|
+
return JSON.stringify(v);
|
|
603
|
+
} catch {
|
|
604
|
+
return String(v);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function mapWatchEntry(ev) {
|
|
608
|
+
const ts = typeof ev["ts"] === "string" ? ev["ts"] : "";
|
|
609
|
+
switch (ev["type"]) {
|
|
610
|
+
case "user_input":
|
|
611
|
+
return { ts, role: "user", text: clip(blocksToText(ev["content"])) };
|
|
612
|
+
case "llm_response": {
|
|
613
|
+
const text = blocksToText(ev["content"]);
|
|
614
|
+
return text.trim() ? { ts, role: "assistant", text: clip(text) } : null;
|
|
615
|
+
}
|
|
616
|
+
case "tool_use":
|
|
617
|
+
case "tool_call_start": {
|
|
618
|
+
const input = ev["input"] ?? ev["args"];
|
|
619
|
+
const preview = input !== void 0 && input !== null ? clip(asString(input), 160) : "";
|
|
620
|
+
return { ts, role: "tool", tool: String(ev["name"] ?? "tool"), text: preview };
|
|
621
|
+
}
|
|
622
|
+
case "tool_result": {
|
|
623
|
+
if (ev["isError"]) return { ts, role: "error", text: clip(asString(ev["content"])) };
|
|
624
|
+
const out = asString(ev["content"]).trim();
|
|
625
|
+
return out ? { ts, role: "tool", tool: "\u21B3 result", text: clip(out, 240) } : null;
|
|
626
|
+
}
|
|
627
|
+
case "error":
|
|
628
|
+
case "provider_error":
|
|
629
|
+
return { ts, role: "error", text: clip(String(ev["message"] ?? "error")) };
|
|
630
|
+
case "agent_spawned":
|
|
631
|
+
return { ts, role: "system", text: `spawned ${String(ev["role"] ?? "agent")}` };
|
|
632
|
+
case "task_completed":
|
|
633
|
+
return { ts, role: "system", text: `task done: ${String(ev["title"] ?? "")}` };
|
|
634
|
+
case "task_failed":
|
|
635
|
+
return { ts, role: "system", text: `task failed: ${String(ev["title"] ?? "")}` };
|
|
636
|
+
default:
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
|
|
641
|
+
if (!globalRoot) {
|
|
642
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
643
|
+
res.end(JSON.stringify({ error: "SessionRegistry not available" }));
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core");
|
|
648
|
+
const registry = new SessionRegistry(globalRoot);
|
|
649
|
+
const entry = await registry.get(sessionId);
|
|
650
|
+
if (!entry) {
|
|
651
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
652
|
+
res.end(JSON.stringify({ error: "Session not found" }));
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
|
|
656
|
+
const store = new DefaultSessionStore3({ dir: paths.projectSessions });
|
|
657
|
+
const reader = new DefaultSessionReader2({ store });
|
|
658
|
+
const all = [];
|
|
659
|
+
for await (const ev of reader.replay(sessionId)) {
|
|
660
|
+
const mapped = mapWatchEntry(ev);
|
|
661
|
+
if (mapped) all.push(mapped);
|
|
662
|
+
}
|
|
663
|
+
const tail = all.slice(-limit);
|
|
664
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
665
|
+
res.end(
|
|
666
|
+
JSON.stringify({
|
|
667
|
+
sessionId,
|
|
668
|
+
status: entry.status,
|
|
669
|
+
clientType: entry.clientType,
|
|
670
|
+
projectName: entry.projectName,
|
|
671
|
+
total: all.length,
|
|
672
|
+
entries: tail
|
|
673
|
+
})
|
|
674
|
+
);
|
|
675
|
+
} catch (err) {
|
|
676
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
677
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
function readJsonBody(req) {
|
|
681
|
+
return new Promise((resolve5, reject) => {
|
|
682
|
+
let data = "";
|
|
683
|
+
req.on("data", (chunk) => {
|
|
684
|
+
data += chunk;
|
|
685
|
+
if (data.length > 64e3) {
|
|
686
|
+
reject(new Error("Request body too large"));
|
|
687
|
+
req.destroy();
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
req.on("end", () => {
|
|
691
|
+
try {
|
|
692
|
+
resolve5(data ? JSON.parse(data) : {});
|
|
693
|
+
} catch (err) {
|
|
694
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
req.on("error", reject);
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
|
|
701
|
+
if (!globalRoot) {
|
|
702
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
703
|
+
res.end(JSON.stringify({ error: "SessionRegistry not available" }));
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
let body;
|
|
707
|
+
try {
|
|
708
|
+
body = await readJsonBody(req);
|
|
709
|
+
} catch {
|
|
710
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
711
|
+
res.end(JSON.stringify({ error: "Invalid request body" }));
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const text = typeof body["text"] === "string" ? body["text"].trim() : "";
|
|
715
|
+
if (!text) {
|
|
716
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
717
|
+
res.end(JSON.stringify({ error: "text is required" }));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
|
|
721
|
+
const ALLOWED = /* @__PURE__ */ new Set(["steer", "ask", "assign", "note", "btw"]);
|
|
722
|
+
const rawType = typeof body["type"] === "string" ? body["type"] : "steer";
|
|
723
|
+
const type = ALLOWED.has(rawType) ? rawType : "steer";
|
|
724
|
+
const rawPriority = typeof body["priority"] === "string" ? body["priority"] : "";
|
|
725
|
+
const priority = ["low", "normal", "high"].includes(rawPriority) ? rawPriority : "high";
|
|
726
|
+
const subject = typeof body["subject"] === "string" && body["subject"].trim() ? body["subject"].trim() : "Message from Fleet HQ";
|
|
727
|
+
try {
|
|
728
|
+
const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
|
|
729
|
+
const registry = new SessionRegistry(globalRoot);
|
|
730
|
+
const entry = await registry.get(sessionId);
|
|
731
|
+
if (!entry) {
|
|
732
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
733
|
+
res.end(JSON.stringify({ error: "Session not found" }));
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
|
|
737
|
+
const mailbox = new GlobalMailbox3(paths.projectDir);
|
|
738
|
+
const to = `leader@${mailboxSessionTag2(sessionId)}`;
|
|
739
|
+
const sent = await mailbox.send({ from, to, type, subject, body: text, priority });
|
|
740
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
741
|
+
res.end(JSON.stringify({ ok: true, id: sent.id, to, type, delivered: entry.status }));
|
|
742
|
+
} catch (err) {
|
|
743
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
744
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
async function handleApiSessionMailbox(res, globalRoot, sessionId) {
|
|
748
|
+
if (!globalRoot) {
|
|
749
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
750
|
+
res.end(JSON.stringify({ error: "SessionRegistry not available" }));
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
|
|
755
|
+
const registry = new SessionRegistry(globalRoot);
|
|
756
|
+
const entry = await registry.get(sessionId);
|
|
757
|
+
if (!entry) {
|
|
758
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
759
|
+
res.end(JSON.stringify({ error: "Session not found" }));
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
|
|
763
|
+
const mailbox = new GlobalMailbox3(paths.projectDir);
|
|
764
|
+
const leaderAddr = `leader@${mailboxSessionTag2(sessionId)}`;
|
|
765
|
+
const [inbound, outbound] = await Promise.all([
|
|
766
|
+
mailbox.query({ to: leaderAddr, limit: 50 }),
|
|
767
|
+
mailbox.query({ from: leaderAddr, limit: 50 })
|
|
768
|
+
]);
|
|
769
|
+
const seen = /* @__PURE__ */ new Set();
|
|
770
|
+
const thread = [...inbound, ...outbound].filter((m) => {
|
|
771
|
+
if (seen.has(m.id)) return false;
|
|
772
|
+
seen.add(m.id);
|
|
773
|
+
return true;
|
|
774
|
+
}).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)).map((m) => ({
|
|
775
|
+
id: m.id,
|
|
776
|
+
from: m.from,
|
|
777
|
+
to: m.to,
|
|
778
|
+
type: m.type,
|
|
779
|
+
subject: m.subject,
|
|
780
|
+
body: m.body,
|
|
781
|
+
priority: m.priority,
|
|
782
|
+
// Whether the leader has read it, and when.
|
|
783
|
+
readByLeader: m.readBy?.[leaderAddr] ?? null,
|
|
784
|
+
readByCount: Object.keys(m.readBy ?? {}).length,
|
|
785
|
+
completed: m.completed,
|
|
786
|
+
outcome: m.outcome ?? null,
|
|
787
|
+
timestamp: m.timestamp,
|
|
788
|
+
replyTo: m.replyTo ?? null,
|
|
789
|
+
fromLeader: m.from === leaderAddr
|
|
790
|
+
}));
|
|
791
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
792
|
+
res.end(JSON.stringify({ sessionId, leader: leaderAddr, status: entry.status, thread }));
|
|
793
|
+
} catch (err) {
|
|
794
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
795
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
|
|
799
|
+
if (!globalRoot) {
|
|
800
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
801
|
+
res.end(JSON.stringify({ error: "SessionRegistry not available" }));
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
let body = {};
|
|
805
|
+
try {
|
|
806
|
+
body = await readJsonBody(req);
|
|
807
|
+
} catch {
|
|
808
|
+
}
|
|
809
|
+
const reason = typeof body["reason"] === "string" && body["reason"].trim() ? body["reason"].trim() : "Operator requested stop from Fleet HQ";
|
|
810
|
+
const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
|
|
811
|
+
try {
|
|
812
|
+
const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
|
|
813
|
+
const registry = new SessionRegistry(globalRoot);
|
|
814
|
+
const entry = await registry.get(sessionId);
|
|
815
|
+
if (!entry) {
|
|
816
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
817
|
+
res.end(JSON.stringify({ error: "Session not found" }));
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const paths = resolveWstackPaths2({ projectRoot: entry.projectRoot, globalRoot });
|
|
821
|
+
const mailbox = new GlobalMailbox3(paths.projectDir);
|
|
822
|
+
const to = `leader@${mailboxSessionTag2(sessionId)}`;
|
|
823
|
+
const sent = await mailbox.send({
|
|
824
|
+
from,
|
|
825
|
+
to,
|
|
826
|
+
type: "control",
|
|
827
|
+
subject: "interrupt",
|
|
828
|
+
body: reason,
|
|
829
|
+
priority: "high"
|
|
830
|
+
});
|
|
831
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
832
|
+
res.end(JSON.stringify({ ok: true, id: sent.id, to, delivered: entry.status }));
|
|
833
|
+
} catch (err) {
|
|
834
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
835
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
async function handleApiFleetBroadcast(res, req, globalRoot) {
|
|
839
|
+
if (!globalRoot) {
|
|
840
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
841
|
+
res.end(JSON.stringify({ error: "SessionRegistry not available" }));
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
let body;
|
|
845
|
+
try {
|
|
846
|
+
body = await readJsonBody(req);
|
|
847
|
+
} catch {
|
|
848
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
849
|
+
res.end(JSON.stringify({ error: "Invalid request body" }));
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const text = typeof body["text"] === "string" ? body["text"].trim() : "";
|
|
853
|
+
if (!text) {
|
|
854
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
855
|
+
res.end(JSON.stringify({ error: "text is required" }));
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
|
|
859
|
+
try {
|
|
860
|
+
const { SessionRegistry, resolveWstackPaths: resolveWstackPaths2, GlobalMailbox: GlobalMailbox3, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core");
|
|
861
|
+
const registry = new SessionRegistry(globalRoot);
|
|
862
|
+
const all = await registry.list();
|
|
863
|
+
const mySlug = all.find((s) => s.pid === process.pid)?.projectSlug;
|
|
864
|
+
const targets = all.filter((s) => s.status !== "stale").filter((s) => mySlug ? s.projectSlug === mySlug : true);
|
|
865
|
+
if (targets.length === 0) {
|
|
866
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
867
|
+
res.end(JSON.stringify({ ok: true, delivered: 0 }));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const mbByDir = /* @__PURE__ */ new Map();
|
|
871
|
+
const mailboxFor = (projectRoot) => {
|
|
872
|
+
const dir = resolveWstackPaths2({ projectRoot, globalRoot }).projectDir;
|
|
873
|
+
let mb = mbByDir.get(dir);
|
|
874
|
+
if (!mb) {
|
|
875
|
+
mb = new GlobalMailbox3(dir);
|
|
876
|
+
mbByDir.set(dir, mb);
|
|
877
|
+
}
|
|
878
|
+
return mb;
|
|
879
|
+
};
|
|
880
|
+
let delivered = 0;
|
|
881
|
+
await Promise.all(
|
|
882
|
+
targets.map(async (s) => {
|
|
883
|
+
try {
|
|
884
|
+
const mb = mailboxFor(s.projectRoot);
|
|
885
|
+
await mb.send({
|
|
886
|
+
from,
|
|
887
|
+
to: `leader@${mailboxSessionTag2(s.sessionId)}`,
|
|
888
|
+
type: "steer",
|
|
889
|
+
subject: "Broadcast from Fleet HQ",
|
|
890
|
+
body: text,
|
|
891
|
+
priority: "high"
|
|
892
|
+
});
|
|
893
|
+
delivered++;
|
|
894
|
+
} catch {
|
|
895
|
+
}
|
|
896
|
+
})
|
|
897
|
+
);
|
|
898
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
899
|
+
res.end(JSON.stringify({ ok: true, delivered, targets: targets.length }));
|
|
900
|
+
} catch (err) {
|
|
901
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
902
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
903
|
+
}
|
|
904
|
+
}
|
|
310
905
|
|
|
311
906
|
// src/server/file-handlers.ts
|
|
312
907
|
import * as fs2 from "fs/promises";
|
|
@@ -380,7 +975,7 @@ function broadcast(clients, msg) {
|
|
|
380
975
|
}
|
|
381
976
|
}
|
|
382
977
|
}
|
|
383
|
-
function
|
|
978
|
+
function sendResult2(ws, success, message) {
|
|
384
979
|
send(ws, { type: "key.operation_result", payload: { success, message } });
|
|
385
980
|
}
|
|
386
981
|
function errMessage(err) {
|
|
@@ -529,23 +1124,265 @@ async function handleMemoryRemember(ws, msg, memoryStore) {
|
|
|
529
1124
|
const { text, scope } = msg.payload;
|
|
530
1125
|
try {
|
|
531
1126
|
await memoryStore.remember(text, scope ?? "project-memory");
|
|
532
|
-
|
|
1127
|
+
sendResult2(ws, true, "Saved to memory");
|
|
533
1128
|
} catch (err) {
|
|
534
|
-
|
|
1129
|
+
sendResult2(ws, false, errMessage(err));
|
|
535
1130
|
}
|
|
536
1131
|
}
|
|
537
1132
|
async function handleMemoryForget(ws, msg, memoryStore) {
|
|
538
1133
|
const { text, scope } = msg.payload;
|
|
539
1134
|
try {
|
|
540
1135
|
const removed = await memoryStore.forget(text, scope ?? "project-memory");
|
|
541
|
-
|
|
1136
|
+
sendResult2(
|
|
542
1137
|
ws,
|
|
543
1138
|
removed > 0,
|
|
544
1139
|
removed > 0 ? `Removed ${removed} entr${removed === 1 ? "y" : "ies"}` : "No matching entries"
|
|
545
1140
|
);
|
|
546
1141
|
} catch (err) {
|
|
547
|
-
|
|
1142
|
+
sendResult2(ws, false, errMessage(err));
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/server/mcp-handlers.ts
|
|
1147
|
+
import * as fs3 from "fs/promises";
|
|
1148
|
+
import * as path3 from "path";
|
|
1149
|
+
function isMcpServerRecord(val) {
|
|
1150
|
+
if (typeof val !== "object" || val === null) return false;
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
function projectServer(name, cfg, _status = "stopped", tools = []) {
|
|
1154
|
+
return {
|
|
1155
|
+
name,
|
|
1156
|
+
transport: cfg.transport,
|
|
1157
|
+
status: _status,
|
|
1158
|
+
enabled: cfg.enabled ?? true,
|
|
1159
|
+
description: cfg.description,
|
|
1160
|
+
tools
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
async function readConfig(configPath) {
|
|
1164
|
+
try {
|
|
1165
|
+
const content = await fs3.readFile(configPath, "utf-8");
|
|
1166
|
+
return JSON.parse(content);
|
|
1167
|
+
} catch {
|
|
1168
|
+
return {};
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
async function writeConfig(configPath, cfg) {
|
|
1172
|
+
const dir = path3.dirname(configPath);
|
|
1173
|
+
await fs3.mkdir(dir, { recursive: true });
|
|
1174
|
+
await fs3.writeFile(configPath, JSON.stringify(cfg, null, 2), "utf-8");
|
|
1175
|
+
}
|
|
1176
|
+
async function getMcpServers(config, globalConfigPath) {
|
|
1177
|
+
const servers = [];
|
|
1178
|
+
const configured = isMcpServerRecord(config.mcpServers) ? config.mcpServers : {};
|
|
1179
|
+
for (const [name, cfg] of Object.entries(configured)) {
|
|
1180
|
+
servers.push(projectServer(name, cfg));
|
|
1181
|
+
}
|
|
1182
|
+
return servers;
|
|
1183
|
+
}
|
|
1184
|
+
function getRegistryStates(mcpRegistry) {
|
|
1185
|
+
const states = /* @__PURE__ */ new Map();
|
|
1186
|
+
if (!mcpRegistry?.list) return states;
|
|
1187
|
+
try {
|
|
1188
|
+
const list = mcpRegistry.list();
|
|
1189
|
+
for (const item of list) {
|
|
1190
|
+
states.set(item.name, { state: item.state, toolCount: item.toolCount });
|
|
1191
|
+
}
|
|
1192
|
+
} catch {
|
|
1193
|
+
}
|
|
1194
|
+
return states;
|
|
1195
|
+
}
|
|
1196
|
+
async function handleMcpList(ws, _msg, config, _globalConfigPath, mcpRegistry) {
|
|
1197
|
+
const servers = await getMcpServers(config, _globalConfigPath);
|
|
1198
|
+
const registryStates = getRegistryStates(mcpRegistry);
|
|
1199
|
+
for (const server of servers) {
|
|
1200
|
+
const registryState = registryStates.get(server.name);
|
|
1201
|
+
if (registryState) {
|
|
1202
|
+
server.status = registryState.state;
|
|
1203
|
+
server.tools = Array.from({ length: registryState.toolCount }, (_, i) => `tool-${i + 1}`);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
send(ws, { type: "mcp.list", payload: { servers } });
|
|
1207
|
+
}
|
|
1208
|
+
async function handleMcpAdd(ws, msg, config, globalConfigPath, mcpRegistry) {
|
|
1209
|
+
const payload = msg.payload;
|
|
1210
|
+
if (!payload.name) {
|
|
1211
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
try {
|
|
1215
|
+
const diskConfig = await readConfig(globalConfigPath);
|
|
1216
|
+
const mcpServers = isMcpServerRecord(diskConfig.mcpServers) ? diskConfig.mcpServers : {};
|
|
1217
|
+
if (mcpServers[payload.name]) {
|
|
1218
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Server "${payload.name}" already exists` } });
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
mcpServers[payload.name] = {
|
|
1222
|
+
transport: payload.transport,
|
|
1223
|
+
description: payload.description,
|
|
1224
|
+
enabled: payload.enabled ?? true,
|
|
1225
|
+
command: payload.command,
|
|
1226
|
+
args: payload.args,
|
|
1227
|
+
env: payload.env,
|
|
1228
|
+
allowedTools: payload.allowedTools
|
|
1229
|
+
};
|
|
1230
|
+
diskConfig.mcpServers = mcpServers;
|
|
1231
|
+
await writeConfig(globalConfigPath, diskConfig);
|
|
1232
|
+
const newServer = projectServer(payload.name, mcpServers[payload.name]);
|
|
1233
|
+
send(ws, { type: "mcp.server.added", payload: { server: newServer } });
|
|
1234
|
+
if (mcpRegistry && (payload.enabled ?? true)) {
|
|
1235
|
+
const serverConfig = mcpServers[payload.name];
|
|
1236
|
+
try {
|
|
1237
|
+
await mcpRegistry.start({
|
|
1238
|
+
name: payload.name,
|
|
1239
|
+
transport: payload.transport,
|
|
1240
|
+
command: payload.command,
|
|
1241
|
+
args: payload.args,
|
|
1242
|
+
env: payload.env,
|
|
1243
|
+
allowedTools: payload.allowedTools,
|
|
1244
|
+
enabled: true
|
|
1245
|
+
});
|
|
1246
|
+
} catch (err) {
|
|
1247
|
+
send(ws, { type: "mcp.server.error", payload: { name: payload.name, error: String(err) } });
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" added` } });
|
|
1251
|
+
} catch (err) {
|
|
1252
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to add server: ${err}` } });
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
async function handleMcpRemove(ws, msg, _config, globalConfigPath, mcpRegistry) {
|
|
1256
|
+
const payload = msg.payload;
|
|
1257
|
+
if (!payload.name) {
|
|
1258
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
try {
|
|
1262
|
+
if (mcpRegistry) {
|
|
1263
|
+
try {
|
|
1264
|
+
await mcpRegistry.stop(payload.name);
|
|
1265
|
+
} catch {
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
const diskConfig = await readConfig(globalConfigPath);
|
|
1269
|
+
const mcpServers = isMcpServerRecord(diskConfig.mcpServers) ? diskConfig.mcpServers : {};
|
|
1270
|
+
if (!mcpServers[payload.name]) {
|
|
1271
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Server "${payload.name}" not found` } });
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
delete mcpServers[payload.name];
|
|
1275
|
+
diskConfig.mcpServers = mcpServers;
|
|
1276
|
+
await writeConfig(globalConfigPath, diskConfig);
|
|
1277
|
+
send(ws, { type: "mcp.server.removed", payload: { name: payload.name } });
|
|
1278
|
+
send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" removed` } });
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to remove server: ${err}` } });
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
async function handleMcpUpdate(ws, msg, _config, globalConfigPath) {
|
|
1284
|
+
const payload = msg.payload;
|
|
1285
|
+
if (!payload.name) {
|
|
1286
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
try {
|
|
1290
|
+
const diskConfig = await readConfig(globalConfigPath);
|
|
1291
|
+
const mcpServers = isMcpServerRecord(diskConfig.mcpServers) ? diskConfig.mcpServers : {};
|
|
1292
|
+
if (!mcpServers[payload.name]) {
|
|
1293
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Server "${payload.name}" not found` } });
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
const existing = mcpServers[payload.name];
|
|
1297
|
+
mcpServers[payload.name] = {
|
|
1298
|
+
transport: payload.transport ?? existing.transport,
|
|
1299
|
+
description: payload.description ?? existing.description,
|
|
1300
|
+
enabled: payload.enabled ?? existing.enabled,
|
|
1301
|
+
command: payload.command ?? existing.command,
|
|
1302
|
+
args: payload.args ?? existing.args,
|
|
1303
|
+
env: payload.env ?? existing.env,
|
|
1304
|
+
allowedTools: payload.allowedTools ?? existing.allowedTools
|
|
1305
|
+
};
|
|
1306
|
+
diskConfig.mcpServers = mcpServers;
|
|
1307
|
+
await writeConfig(globalConfigPath, diskConfig);
|
|
1308
|
+
const updatedServer = projectServer(payload.name, mcpServers[payload.name]);
|
|
1309
|
+
send(ws, { type: "mcp.server.updated", payload: { server: updatedServer } });
|
|
1310
|
+
send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" updated` } });
|
|
1311
|
+
} catch (err) {
|
|
1312
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to update server: ${err}` } });
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
async function handleMcpWake(ws, msg, _config, _globalConfigPath, mcpRegistry) {
|
|
1316
|
+
const payload = msg.payload;
|
|
1317
|
+
if (!payload.name) {
|
|
1318
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
if (!mcpRegistry) {
|
|
1322
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "MCP registry not available" } });
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
try {
|
|
1326
|
+
send(ws, { type: "mcp.server.waking", payload: { name: payload.name } });
|
|
1327
|
+
await mcpRegistry.restart(payload.name);
|
|
1328
|
+
send(ws, { type: "mcp.server.connected", payload: { name: payload.name } });
|
|
1329
|
+
send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" restarted` } });
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
send(ws, { type: "mcp.server.error", payload: { name: payload.name, error: String(err) } });
|
|
1332
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to restart "${payload.name}": ${err}` } });
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
async function handleMcpSleep(ws, msg, _config, _globalConfigPath, mcpRegistry) {
|
|
1336
|
+
const payload = msg.payload;
|
|
1337
|
+
if (!payload.name) {
|
|
1338
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
if (!mcpRegistry) {
|
|
1342
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "MCP registry not available" } });
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
try {
|
|
1346
|
+
await mcpRegistry.stop(payload.name);
|
|
1347
|
+
send(ws, { type: "mcp.server.sleeping", payload: { name: payload.name } });
|
|
1348
|
+
send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" stopped` } });
|
|
1349
|
+
} catch (err) {
|
|
1350
|
+
send(ws, { type: "mcp.server.error", payload: { name: payload.name, error: String(err) } });
|
|
1351
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: `Failed to stop "${payload.name}": ${err}` } });
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
async function handleMcpDiscover(ws, msg, _config, _globalConfigPath, _mcpRegistry) {
|
|
1355
|
+
const payload = msg.payload;
|
|
1356
|
+
if (!payload.name) {
|
|
1357
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
send(ws, { type: "mcp.server.discovered", payload: { name: payload.name, tools: [] } });
|
|
1361
|
+
send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Server "${payload.name}" tools were discovered on connect` } });
|
|
1362
|
+
}
|
|
1363
|
+
async function handleMcpEnable(ws, msg, _config, _globalConfigPath) {
|
|
1364
|
+
const payload = msg.payload;
|
|
1365
|
+
if (!payload.name) {
|
|
1366
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Enable command sent for "${payload.name}"` } });
|
|
1370
|
+
}
|
|
1371
|
+
async function handleMcpDisable(ws, msg, _config, _globalConfigPath) {
|
|
1372
|
+
const payload = msg.payload;
|
|
1373
|
+
if (!payload.name) {
|
|
1374
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Disable command sent for "${payload.name}"` } });
|
|
1378
|
+
}
|
|
1379
|
+
async function handleMcpRestart(ws, msg, _config, _globalConfigPath) {
|
|
1380
|
+
const payload = msg.payload;
|
|
1381
|
+
if (!payload.name) {
|
|
1382
|
+
send(ws, { type: "mcp.operation_result", payload: { success: false, message: "Server name is required" } });
|
|
1383
|
+
return;
|
|
548
1384
|
}
|
|
1385
|
+
send(ws, { type: "mcp.operation_result", payload: { success: true, message: `Restart command sent for "${payload.name}"` } });
|
|
549
1386
|
}
|
|
550
1387
|
|
|
551
1388
|
// src/server/index.ts
|
|
@@ -1957,14 +2794,14 @@ function registerShutdownHandlers(res) {
|
|
|
1957
2794
|
|
|
1958
2795
|
// src/server/instance-registry.ts
|
|
1959
2796
|
import * as os from "os";
|
|
1960
|
-
import * as
|
|
1961
|
-
import * as
|
|
2797
|
+
import * as path4 from "path";
|
|
2798
|
+
import * as fs4 from "fs/promises";
|
|
1962
2799
|
import { atomicWrite as atomicWrite2 } from "@wrongstack/core";
|
|
1963
2800
|
function defaultBaseDir() {
|
|
1964
|
-
return
|
|
2801
|
+
return path4.join(os.homedir(), ".wrongstack");
|
|
1965
2802
|
}
|
|
1966
2803
|
function registryPath(baseDir = defaultBaseDir()) {
|
|
1967
|
-
return
|
|
2804
|
+
return path4.join(baseDir, "webui-instances.json");
|
|
1968
2805
|
}
|
|
1969
2806
|
function isPidAlive(pid) {
|
|
1970
2807
|
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
@@ -1977,7 +2814,7 @@ function isPidAlive(pid) {
|
|
|
1977
2814
|
}
|
|
1978
2815
|
async function load(file) {
|
|
1979
2816
|
try {
|
|
1980
|
-
const raw = await
|
|
2817
|
+
const raw = await fs4.readFile(file, "utf8");
|
|
1981
2818
|
const parsed = JSON.parse(raw);
|
|
1982
2819
|
if (parsed?.version === 1 && Array.isArray(parsed.instances)) {
|
|
1983
2820
|
return parsed;
|
|
@@ -2125,15 +2962,15 @@ import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/cor
|
|
|
2125
2962
|
import { probeLocalLlm } from "@wrongstack/runtime/probe";
|
|
2126
2963
|
|
|
2127
2964
|
// src/server/provider-config-io.ts
|
|
2128
|
-
import * as
|
|
2129
|
-
import * as
|
|
2965
|
+
import * as fs5 from "fs/promises";
|
|
2966
|
+
import * as path5 from "path";
|
|
2130
2967
|
import { atomicWrite as atomicWrite3 } from "@wrongstack/core";
|
|
2131
2968
|
import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/security";
|
|
2132
2969
|
import { DefaultSecretVault } from "@wrongstack/core";
|
|
2133
2970
|
async function loadSavedProviders(configPath, vault) {
|
|
2134
2971
|
let raw;
|
|
2135
2972
|
try {
|
|
2136
|
-
raw = await
|
|
2973
|
+
raw = await fs5.readFile(configPath, "utf8");
|
|
2137
2974
|
} catch {
|
|
2138
2975
|
return {};
|
|
2139
2976
|
}
|
|
@@ -2150,7 +2987,7 @@ async function saveProviders(configPath, vault, providers) {
|
|
|
2150
2987
|
let raw;
|
|
2151
2988
|
let fileExists = true;
|
|
2152
2989
|
try {
|
|
2153
|
-
raw = await
|
|
2990
|
+
raw = await fs5.readFile(configPath, "utf8");
|
|
2154
2991
|
} catch (err) {
|
|
2155
2992
|
if (err.code !== "ENOENT") {
|
|
2156
2993
|
throw new Error(
|
|
@@ -2201,7 +3038,7 @@ function writeKeysBack(cfg, keys) {
|
|
|
2201
3038
|
}
|
|
2202
3039
|
cfg.apiKeys = keys;
|
|
2203
3040
|
const active = keys.find((k) => k.label === cfg.activeKey) ?? expectDefined(keys[0]);
|
|
2204
|
-
cfg.apiKey
|
|
3041
|
+
delete cfg.apiKey;
|
|
2205
3042
|
if (!cfg.activeKey || !keys.some((k) => k.label === cfg.activeKey)) {
|
|
2206
3043
|
cfg.activeKey = active.label;
|
|
2207
3044
|
}
|
|
@@ -2325,9 +3162,9 @@ function createProviderHandlers(deps) {
|
|
|
2325
3162
|
const providers = await loadConfigProviders();
|
|
2326
3163
|
const result = upsertKey(providers, providerId, label, apiKey, (/* @__PURE__ */ new Date()).toISOString());
|
|
2327
3164
|
if (result.ok) await saveConfigProviders(providers);
|
|
2328
|
-
|
|
3165
|
+
sendResult2(ws, result.ok, result.message);
|
|
2329
3166
|
} catch (err) {
|
|
2330
|
-
|
|
3167
|
+
sendResult2(ws, false, errMessage(err));
|
|
2331
3168
|
}
|
|
2332
3169
|
}
|
|
2333
3170
|
async function handleKeyDelete(ws, providerId, label) {
|
|
@@ -2335,9 +3172,9 @@ function createProviderHandlers(deps) {
|
|
|
2335
3172
|
const providers = await loadConfigProviders();
|
|
2336
3173
|
const result = deleteKey(providers, providerId, label);
|
|
2337
3174
|
if (result.ok) await saveConfigProviders(providers);
|
|
2338
|
-
|
|
3175
|
+
sendResult2(ws, result.ok, result.message);
|
|
2339
3176
|
} catch (err) {
|
|
2340
|
-
|
|
3177
|
+
sendResult2(ws, false, errMessage(err));
|
|
2341
3178
|
}
|
|
2342
3179
|
}
|
|
2343
3180
|
async function handleKeySetActive(ws, providerId, label) {
|
|
@@ -2345,9 +3182,9 @@ function createProviderHandlers(deps) {
|
|
|
2345
3182
|
const providers = await loadConfigProviders();
|
|
2346
3183
|
const result = setActiveKey(providers, providerId, label);
|
|
2347
3184
|
if (result.ok) await saveConfigProviders(providers);
|
|
2348
|
-
|
|
3185
|
+
sendResult2(ws, result.ok, result.message);
|
|
2349
3186
|
} catch (err) {
|
|
2350
|
-
|
|
3187
|
+
sendResult2(ws, false, errMessage(err));
|
|
2351
3188
|
}
|
|
2352
3189
|
}
|
|
2353
3190
|
async function handleProviderAdd(ws, payload) {
|
|
@@ -2355,13 +3192,13 @@ function createProviderHandlers(deps) {
|
|
|
2355
3192
|
const providers = await loadConfigProviders();
|
|
2356
3193
|
const result = addProvider(providers, payload, (/* @__PURE__ */ new Date()).toISOString());
|
|
2357
3194
|
if (result.ok) await saveConfigProviders(providers);
|
|
2358
|
-
|
|
3195
|
+
sendResult2(ws, result.ok, result.message);
|
|
2359
3196
|
if (result.ok) {
|
|
2360
3197
|
console.log(`[WebUI] Provider "${payload.id}" added via provider.add`);
|
|
2361
3198
|
broadcastSaved(providers);
|
|
2362
3199
|
}
|
|
2363
3200
|
} catch (err) {
|
|
2364
|
-
|
|
3201
|
+
sendResult2(ws, false, errMessage(err));
|
|
2365
3202
|
}
|
|
2366
3203
|
}
|
|
2367
3204
|
async function handleProviderRemove(ws, providerId) {
|
|
@@ -2369,9 +3206,9 @@ function createProviderHandlers(deps) {
|
|
|
2369
3206
|
const providers = await loadConfigProviders();
|
|
2370
3207
|
const result = removeProvider(providers, providerId);
|
|
2371
3208
|
if (result.ok) await saveConfigProviders(providers);
|
|
2372
|
-
|
|
3209
|
+
sendResult2(ws, result.ok, result.message);
|
|
2373
3210
|
} catch (err) {
|
|
2374
|
-
|
|
3211
|
+
sendResult2(ws, false, errMessage(err));
|
|
2375
3212
|
}
|
|
2376
3213
|
}
|
|
2377
3214
|
function broadcastSaved(providers) {
|
|
@@ -2385,15 +3222,15 @@ function createProviderHandlers(deps) {
|
|
|
2385
3222
|
const providers = await loadConfigProviders();
|
|
2386
3223
|
const cfg = providers[providerId];
|
|
2387
3224
|
if (!cfg) {
|
|
2388
|
-
|
|
3225
|
+
sendResult2(ws, false, `Unknown provider "${providerId}"`);
|
|
2389
3226
|
return;
|
|
2390
3227
|
}
|
|
2391
3228
|
delete cfg.models;
|
|
2392
3229
|
await saveConfigProviders(providers);
|
|
2393
|
-
|
|
3230
|
+
sendResult2(ws, true, `Cleared model allowlist for ${providerId}`);
|
|
2394
3231
|
broadcastSaved(providers);
|
|
2395
3232
|
} catch (err) {
|
|
2396
|
-
|
|
3233
|
+
sendResult2(ws, false, errMessage(err));
|
|
2397
3234
|
}
|
|
2398
3235
|
}
|
|
2399
3236
|
async function handleProviderUndoClear(ws, providerId, previousModels) {
|
|
@@ -2401,15 +3238,15 @@ function createProviderHandlers(deps) {
|
|
|
2401
3238
|
const providers = await loadConfigProviders();
|
|
2402
3239
|
const cfg = providers[providerId];
|
|
2403
3240
|
if (!cfg) {
|
|
2404
|
-
|
|
3241
|
+
sendResult2(ws, false, `Unknown provider "${providerId}"`);
|
|
2405
3242
|
return;
|
|
2406
3243
|
}
|
|
2407
3244
|
cfg.models = [...previousModels];
|
|
2408
3245
|
await saveConfigProviders(providers);
|
|
2409
|
-
|
|
3246
|
+
sendResult2(ws, true, `Restored ${previousModels.length} model(s) for ${providerId}`);
|
|
2410
3247
|
broadcastSaved(providers);
|
|
2411
3248
|
} catch (err) {
|
|
2412
|
-
|
|
3249
|
+
sendResult2(ws, false, errMessage(err));
|
|
2413
3250
|
}
|
|
2414
3251
|
}
|
|
2415
3252
|
async function handleProviderUpdate(ws, payload) {
|
|
@@ -2417,7 +3254,7 @@ function createProviderHandlers(deps) {
|
|
|
2417
3254
|
const providers = await loadConfigProviders();
|
|
2418
3255
|
const cfg = providers[payload.id];
|
|
2419
3256
|
if (!cfg) {
|
|
2420
|
-
|
|
3257
|
+
sendResult2(ws, false, `Unknown provider "${payload.id}"`);
|
|
2421
3258
|
return;
|
|
2422
3259
|
}
|
|
2423
3260
|
if (payload.family !== void 0) cfg.family = payload.family;
|
|
@@ -2425,10 +3262,10 @@ function createProviderHandlers(deps) {
|
|
|
2425
3262
|
if (payload.envVars !== void 0) cfg.envVars = payload.envVars;
|
|
2426
3263
|
if (payload.models !== void 0) cfg.models = payload.models;
|
|
2427
3264
|
await saveConfigProviders(providers);
|
|
2428
|
-
|
|
3265
|
+
sendResult2(ws, true, `Updated ${payload.id}`);
|
|
2429
3266
|
broadcastSaved(providers);
|
|
2430
3267
|
} catch (err) {
|
|
2431
|
-
|
|
3268
|
+
sendResult2(ws, false, errMessage(err));
|
|
2432
3269
|
}
|
|
2433
3270
|
}
|
|
2434
3271
|
async function handleProviderProbe(ws, providerId, timeoutMs) {
|
|
@@ -2473,9 +3310,12 @@ function createProviderHandlers(deps) {
|
|
|
2473
3310
|
}
|
|
2474
3311
|
|
|
2475
3312
|
// src/server/setup-events.ts
|
|
2476
|
-
import * as
|
|
3313
|
+
import * as fs6 from "fs/promises";
|
|
3314
|
+
import { watch as fsWatch } from "fs";
|
|
3315
|
+
import * as path6 from "path";
|
|
2477
3316
|
function setupEvents(deps) {
|
|
2478
|
-
const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge } = deps;
|
|
3317
|
+
const { events, broadcast: broadcast2, clients, config, context, pendingConfirms, globalConfigPath, sessionBridge, wpaths, watcherMetrics, onFleetBroadcaster } = deps;
|
|
3318
|
+
const disposers = [];
|
|
2479
3319
|
events.on("iteration.started", (e) => {
|
|
2480
3320
|
const maxIt = typeof context.meta["maxIterations"] === "number" ? context.meta["maxIterations"] : config.tools?.maxIterations ?? 100;
|
|
2481
3321
|
broadcast2(clients, {
|
|
@@ -2506,7 +3346,11 @@ function setupEvents(deps) {
|
|
|
2506
3346
|
events.on("tool.progress", (e) => {
|
|
2507
3347
|
broadcast2(clients, {
|
|
2508
3348
|
type: "tool.progress",
|
|
2509
|
-
|
|
3349
|
+
// Nested `event` shape — the client handler reads `payload.event?.text`
|
|
3350
|
+
// and early-returns on a falsy text, so a flat { eventType, text } payload
|
|
3351
|
+
// makes live tool progress (bash streaming, partial_output, warnings)
|
|
3352
|
+
// never render. Must match WSToolProgress and the CLI server.
|
|
3353
|
+
payload: { id: e.id, name: e.name, event: { type: e.event.type, text: e.event.text, data: e.event.data } }
|
|
2510
3354
|
});
|
|
2511
3355
|
sessionBridge?.append({
|
|
2512
3356
|
type: "tool_progress",
|
|
@@ -2672,20 +3516,165 @@ function setupEvents(deps) {
|
|
|
2672
3516
|
events.onPattern("brain.*", (eventName, payload) => {
|
|
2673
3517
|
broadcast2(clients, { type: "brain.event", payload: { event: eventName, ...payload } });
|
|
2674
3518
|
});
|
|
2675
|
-
|
|
3519
|
+
events.on("client.status", async (e) => {
|
|
3520
|
+
broadcast2(clients, { type: "client.status_update", payload: e });
|
|
3521
|
+
if (wpaths?.projectStatus) {
|
|
3522
|
+
try {
|
|
3523
|
+
const statusFile = wpaths.projectStatus(e.projectHash);
|
|
3524
|
+
const dir = path6.dirname(statusFile);
|
|
3525
|
+
await fs6.mkdir(dir, { recursive: true });
|
|
3526
|
+
await fs6.writeFile(statusFile, JSON.stringify(e, null, 2), "utf-8");
|
|
3527
|
+
} catch (err) {
|
|
3528
|
+
console.error("[setup-events] Failed to write status.json:", err);
|
|
3529
|
+
}
|
|
3530
|
+
}
|
|
3531
|
+
});
|
|
3532
|
+
if (wpaths?.projectStatus && wpaths.configDir) {
|
|
3533
|
+
const projectsDir = path6.join(wpaths.configDir, "projects");
|
|
3534
|
+
const knownProjectHashes = /* @__PURE__ */ new Set();
|
|
3535
|
+
const debounceTimers = /* @__PURE__ */ new Map();
|
|
3536
|
+
const DEBOUNCE_MS = 150;
|
|
3537
|
+
const pendingStatuses = /* @__PURE__ */ new Map();
|
|
3538
|
+
if (watcherMetrics) {
|
|
3539
|
+
watcherMetrics.fileChangesDetected = 0;
|
|
3540
|
+
watcherMetrics.filesProcessed = 0;
|
|
3541
|
+
watcherMetrics.broadcastsSent = 0;
|
|
3542
|
+
watcherMetrics.debounceResets = 0;
|
|
3543
|
+
watcherMetrics.totalDebounceDelayMs = 0;
|
|
3544
|
+
watcherMetrics.activeProjects = 0;
|
|
3545
|
+
watcherMetrics.averageDebounceDelayMs = 0;
|
|
3546
|
+
watcherMetrics.watcherActive = true;
|
|
3547
|
+
}
|
|
3548
|
+
const getAverageDebounceDelay = () => {
|
|
3549
|
+
if (!watcherMetrics || watcherMetrics.broadcastsSent === 0) return 0;
|
|
3550
|
+
return watcherMetrics.totalDebounceDelayMs / watcherMetrics.broadcastsSent;
|
|
3551
|
+
};
|
|
3552
|
+
const logWatcherMetrics = () => {
|
|
3553
|
+
if (!watcherMetrics) return;
|
|
3554
|
+
watcherMetrics.averageDebounceDelayMs = getAverageDebounceDelay();
|
|
3555
|
+
console.log(
|
|
3556
|
+
`[setup-events] File watcher stats: ${watcherMetrics.broadcastsSent} broadcasts, ${watcherMetrics.fileChangesDetected} file changes, ${watcherMetrics.debounceResets} debounce resets, avg delay: ${watcherMetrics.averageDebounceDelayMs.toFixed(1)}ms, ${watcherMetrics.activeProjects} active projects`
|
|
3557
|
+
);
|
|
3558
|
+
};
|
|
3559
|
+
const metricsInterval = setInterval(logWatcherMetrics, 6e4);
|
|
3560
|
+
const broadcastStatus = (projectHash2, statusData, actualDelayMs) => {
|
|
3561
|
+
broadcast2(clients, { type: "client.status_update", payload: statusData });
|
|
3562
|
+
if (watcherMetrics) {
|
|
3563
|
+
watcherMetrics.broadcastsSent++;
|
|
3564
|
+
watcherMetrics.totalDebounceDelayMs += actualDelayMs;
|
|
3565
|
+
watcherMetrics.averageDebounceDelayMs = getAverageDebounceDelay();
|
|
3566
|
+
}
|
|
3567
|
+
};
|
|
3568
|
+
const scheduleBroadcast = (projectHash2, statusData) => {
|
|
3569
|
+
const now = Date.now();
|
|
3570
|
+
const existing = pendingStatuses.get(projectHash2);
|
|
3571
|
+
if (existing && watcherMetrics) {
|
|
3572
|
+
watcherMetrics.debounceResets++;
|
|
3573
|
+
}
|
|
3574
|
+
pendingStatuses.set(projectHash2, {
|
|
3575
|
+
data: statusData,
|
|
3576
|
+
firstWriteAt: existing ? existing.firstWriteAt : now
|
|
3577
|
+
});
|
|
3578
|
+
const existingTimer = debounceTimers.get(projectHash2);
|
|
3579
|
+
if (existingTimer) {
|
|
3580
|
+
clearTimeout(existingTimer);
|
|
3581
|
+
}
|
|
3582
|
+
const timer = setTimeout(() => {
|
|
3583
|
+
debounceTimers.delete(projectHash2);
|
|
3584
|
+
const pending = pendingStatuses.get(projectHash2);
|
|
3585
|
+
if (pending) {
|
|
3586
|
+
const actualDelay = Date.now() - pending.firstWriteAt;
|
|
3587
|
+
broadcastStatus(projectHash2, pending.data, actualDelay);
|
|
3588
|
+
pendingStatuses.delete(projectHash2);
|
|
3589
|
+
}
|
|
3590
|
+
}, DEBOUNCE_MS);
|
|
3591
|
+
debounceTimers.set(projectHash2, timer);
|
|
3592
|
+
};
|
|
3593
|
+
let watcher;
|
|
3594
|
+
const startWatcher = async () => {
|
|
3595
|
+
try {
|
|
3596
|
+
await fs6.mkdir(projectsDir, { recursive: true });
|
|
3597
|
+
watcher = fsWatch(projectsDir, { persistent: true, recursive: true }, async (eventType, filename) => {
|
|
3598
|
+
if (eventType === "change") {
|
|
3599
|
+
if (filename == null) return;
|
|
3600
|
+
if (watcherMetrics) watcherMetrics.fileChangesDetected++;
|
|
3601
|
+
const targetFile = path6.join(projectsDir, String(filename));
|
|
3602
|
+
if (targetFile.endsWith("status.json")) {
|
|
3603
|
+
const projectHash2 = path6.basename(path6.dirname(targetFile));
|
|
3604
|
+
if (knownProjectHashes.size > 0 && !knownProjectHashes.has(projectHash2)) {
|
|
3605
|
+
return;
|
|
3606
|
+
}
|
|
3607
|
+
if (watcherMetrics) watcherMetrics.filesProcessed++;
|
|
3608
|
+
try {
|
|
3609
|
+
const content = await fs6.readFile(targetFile, "utf-8");
|
|
3610
|
+
const statusData = JSON.parse(content);
|
|
3611
|
+
if (statusData.projectHash) {
|
|
3612
|
+
const hash = String(statusData.projectHash);
|
|
3613
|
+
if (!knownProjectHashes.has(hash)) {
|
|
3614
|
+
knownProjectHashes.add(hash);
|
|
3615
|
+
if (watcherMetrics) watcherMetrics.activeProjects = knownProjectHashes.size;
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
scheduleBroadcast(projectHash2, statusData);
|
|
3619
|
+
} catch {
|
|
3620
|
+
}
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
});
|
|
3624
|
+
console.log(`[setup-events] Watching ${projectsDir} for status.json changes (hash-filtered, debounced)`);
|
|
3625
|
+
} catch (err) {
|
|
3626
|
+
console.error("[setup-events] Failed to start status file watcher:", err);
|
|
3627
|
+
}
|
|
3628
|
+
};
|
|
3629
|
+
events.on("client.status", (e) => {
|
|
3630
|
+
if (e.projectHash) {
|
|
3631
|
+
const hash = String(e.projectHash);
|
|
3632
|
+
if (!knownProjectHashes.has(hash)) {
|
|
3633
|
+
knownProjectHashes.add(hash);
|
|
3634
|
+
if (watcherMetrics) watcherMetrics.activeProjects = knownProjectHashes.size;
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
});
|
|
3638
|
+
startWatcher();
|
|
3639
|
+
disposers.push(() => {
|
|
3640
|
+
clearInterval(metricsInterval);
|
|
3641
|
+
logWatcherMetrics();
|
|
3642
|
+
if (watcherMetrics) watcherMetrics.watcherActive = false;
|
|
3643
|
+
for (const [projectHash2, pending] of pendingStatuses) {
|
|
3644
|
+
const timer = debounceTimers.get(projectHash2);
|
|
3645
|
+
if (timer) {
|
|
3646
|
+
clearTimeout(timer);
|
|
3647
|
+
broadcastStatus(projectHash2, pending.data, 0);
|
|
3648
|
+
}
|
|
3649
|
+
}
|
|
3650
|
+
for (const timer of debounceTimers.values()) {
|
|
3651
|
+
clearTimeout(timer);
|
|
3652
|
+
}
|
|
3653
|
+
debounceTimers.clear();
|
|
3654
|
+
pendingStatuses.clear();
|
|
3655
|
+
if (watcher) {
|
|
3656
|
+
watcher.close();
|
|
3657
|
+
console.log("[setup-events] Closed status file watcher");
|
|
3658
|
+
}
|
|
3659
|
+
});
|
|
3660
|
+
}
|
|
3661
|
+
const globalRoot = globalConfigPath ? path6.dirname(globalConfigPath) : void 0;
|
|
2676
3662
|
if (globalRoot) {
|
|
2677
|
-
const
|
|
3663
|
+
const broadcastSessions = async () => {
|
|
2678
3664
|
try {
|
|
2679
3665
|
const { SessionRegistry } = await import("@wrongstack/core");
|
|
2680
3666
|
const registry = new SessionRegistry(globalRoot);
|
|
2681
3667
|
const sessions = await registry.list();
|
|
2682
|
-
const
|
|
3668
|
+
const mySlug = sessions.find((s) => s.pid === process.pid)?.projectSlug;
|
|
3669
|
+
const live = sessions.filter((s) => s.status !== "stale").filter((s) => mySlug ? s.projectSlug === mySlug : true).map((s) => ({
|
|
2683
3670
|
sessionId: s.sessionId,
|
|
2684
3671
|
projectName: s.projectName,
|
|
2685
3672
|
projectSlug: s.projectSlug,
|
|
2686
3673
|
projectRoot: s.projectRoot,
|
|
2687
3674
|
workingDir: s.workingDir,
|
|
2688
3675
|
gitBranch: s.gitBranch,
|
|
3676
|
+
// Surface (tui/webui/cli) so Fleet HQ can label each live client node.
|
|
3677
|
+
clientType: s.clientType,
|
|
2689
3678
|
status: s.status,
|
|
2690
3679
|
pid: s.pid,
|
|
2691
3680
|
startedAt: s.startedAt,
|
|
@@ -2697,24 +3686,56 @@ function setupEvents(deps) {
|
|
|
2697
3686
|
currentTool: a.currentTool,
|
|
2698
3687
|
iterations: a.iterations,
|
|
2699
3688
|
toolCalls: a.toolCalls,
|
|
3689
|
+
costUsd: a.costUsd,
|
|
3690
|
+
tokensIn: a.tokensIn,
|
|
3691
|
+
tokensOut: a.tokensOut,
|
|
3692
|
+
ctxPct: a.ctxPct,
|
|
3693
|
+
model: a.model,
|
|
3694
|
+
partialText: a.partialText,
|
|
2700
3695
|
lastActivityAt: a.lastActivityAt
|
|
2701
3696
|
}))
|
|
2702
3697
|
}));
|
|
2703
3698
|
broadcast2(clients, { type: "sessions.status_update", payload: { sessions: live } });
|
|
2704
3699
|
} catch {
|
|
2705
3700
|
}
|
|
2706
|
-
}
|
|
3701
|
+
};
|
|
3702
|
+
onFleetBroadcaster?.(broadcastSessions);
|
|
3703
|
+
const statusInterval = setInterval(() => void broadcastSessions(), 5e3);
|
|
2707
3704
|
if (statusInterval.unref) statusInterval.unref();
|
|
3705
|
+
disposers.push(() => clearInterval(statusInterval));
|
|
3706
|
+
let regDebounce;
|
|
3707
|
+
try {
|
|
3708
|
+
const regWatcher = fsWatch(globalRoot, { persistent: false }, (_event, filename) => {
|
|
3709
|
+
const name = filename ? String(filename) : "";
|
|
3710
|
+
if (!name.startsWith("session-registry.json") || name.endsWith(".lock")) return;
|
|
3711
|
+
if (regDebounce) clearTimeout(regDebounce);
|
|
3712
|
+
regDebounce = setTimeout(() => void broadcastSessions(), 150);
|
|
3713
|
+
});
|
|
3714
|
+
disposers.push(() => {
|
|
3715
|
+
if (regDebounce) clearTimeout(regDebounce);
|
|
3716
|
+
regWatcher.close();
|
|
3717
|
+
});
|
|
3718
|
+
} catch {
|
|
3719
|
+
}
|
|
3720
|
+
void broadcastSessions();
|
|
2708
3721
|
}
|
|
3722
|
+
return () => {
|
|
3723
|
+
for (const dispose of disposers) {
|
|
3724
|
+
try {
|
|
3725
|
+
dispose();
|
|
3726
|
+
} catch {
|
|
3727
|
+
}
|
|
3728
|
+
}
|
|
3729
|
+
};
|
|
2709
3730
|
}
|
|
2710
3731
|
|
|
2711
3732
|
// src/server/custom-context-modes.ts
|
|
2712
3733
|
import { listContextWindowModes, atomicWrite as atomicWrite4 } from "@wrongstack/core";
|
|
2713
|
-
import * as
|
|
2714
|
-
import * as
|
|
3734
|
+
import * as fs7 from "fs/promises";
|
|
3735
|
+
import * as path7 from "path";
|
|
2715
3736
|
var STORE_FILENAME = "custom-context-modes.json";
|
|
2716
3737
|
function storePath(wrongstackDir) {
|
|
2717
|
-
return
|
|
3738
|
+
return path7.join(wrongstackDir, STORE_FILENAME);
|
|
2718
3739
|
}
|
|
2719
3740
|
var BUILTIN_IDS = /* @__PURE__ */ new Set(["balanced", "frugal", "deep", "archival"]);
|
|
2720
3741
|
function createCustomModeStore(wrongstackDir) {
|
|
@@ -2722,7 +3743,7 @@ function createCustomModeStore(wrongstackDir) {
|
|
|
2722
3743
|
const load2 = async () => {
|
|
2723
3744
|
modes.clear();
|
|
2724
3745
|
try {
|
|
2725
|
-
const raw = await
|
|
3746
|
+
const raw = await fs7.readFile(storePath(wrongstackDir), "utf8");
|
|
2726
3747
|
const parsed = JSON.parse(raw);
|
|
2727
3748
|
if (Array.isArray(parsed.modes)) {
|
|
2728
3749
|
for (const m of parsed.modes) {
|
|
@@ -2902,14 +3923,14 @@ function createEternalSubscription(subscribe, broadcast2, clientsRef) {
|
|
|
2902
3923
|
}
|
|
2903
3924
|
|
|
2904
3925
|
// src/server/shell-open.ts
|
|
2905
|
-
import * as
|
|
2906
|
-
import * as
|
|
3926
|
+
import * as fs8 from "fs/promises";
|
|
3927
|
+
import * as path8 from "path";
|
|
2907
3928
|
import { spawn as spawn2 } from "child_process";
|
|
2908
3929
|
var METACHAR_REGEX = /[&|<>^"'`\n\r]/;
|
|
2909
3930
|
async function handleShellOpen(req, logger) {
|
|
2910
3931
|
try {
|
|
2911
|
-
const resolved =
|
|
2912
|
-
await
|
|
3932
|
+
const resolved = path8.resolve(req.path);
|
|
3933
|
+
await fs8.access(resolved);
|
|
2913
3934
|
if (METACHAR_REGEX.test(resolved)) {
|
|
2914
3935
|
return { success: false, message: "Path contains unsupported characters." };
|
|
2915
3936
|
}
|
|
@@ -2955,6 +3976,43 @@ async function handleShellOpen(req, logger) {
|
|
|
2955
3976
|
}
|
|
2956
3977
|
}
|
|
2957
3978
|
|
|
3979
|
+
// src/server/git-handlers.ts
|
|
3980
|
+
async function handleGitInfo(ws, projectRoot) {
|
|
3981
|
+
const cwd = projectRoot || void 0;
|
|
3982
|
+
try {
|
|
3983
|
+
const { execFile: ef } = await import("child_process");
|
|
3984
|
+
const git = (args) => new Promise((resolve5) => {
|
|
3985
|
+
ef("git", args, { cwd, timeout: 3e3 }, (err, stdout) => {
|
|
3986
|
+
resolve5(err ? "" : stdout.trim());
|
|
3987
|
+
});
|
|
3988
|
+
});
|
|
3989
|
+
const [branchRaw, diffRaw, statusRaw, upstreamRaw] = await Promise.all([
|
|
3990
|
+
git(["branch", "--show-current"]),
|
|
3991
|
+
git(["diff", "--stat"]),
|
|
3992
|
+
git(["status", "--porcelain"]),
|
|
3993
|
+
git(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
|
|
3994
|
+
]);
|
|
3995
|
+
const branch = branchRaw || "(detached)";
|
|
3996
|
+
const addMatch = /(\d+)\s+insertion/i.exec(diffRaw);
|
|
3997
|
+
const delMatch = /(\d+)\s+deletion/i.exec(diffRaw);
|
|
3998
|
+
const added = addMatch ? Number(addMatch[1]) : 0;
|
|
3999
|
+
const deleted = delMatch ? Number(delMatch[1]) : 0;
|
|
4000
|
+
const untracked = statusRaw.split("\n").filter((l) => l.startsWith("??")).length;
|
|
4001
|
+
const [behindRaw, aheadRaw] = (upstreamRaw || "0 0").split(" ");
|
|
4002
|
+
const behind = Number(behindRaw) || 0;
|
|
4003
|
+
const ahead = Number(aheadRaw) || 0;
|
|
4004
|
+
send(ws, { type: "git.info", payload: { branch, added, deleted, untracked, ahead, behind } });
|
|
4005
|
+
} catch {
|
|
4006
|
+
send(ws, { type: "git.info", payload: { branch: "", added: 0, deleted: 0, untracked: 0, ahead: 0, behind: 0 } });
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
|
|
4010
|
+
// src/server/skills-handlers.ts
|
|
4011
|
+
import { promises as fs9 } from "fs";
|
|
4012
|
+
import path9 from "path";
|
|
4013
|
+
import JSZip from "jszip";
|
|
4014
|
+
import { wstackGlobalRoot } from "@wrongstack/core/utils";
|
|
4015
|
+
|
|
2958
4016
|
// src/server/index.ts
|
|
2959
4017
|
async function startWebUI(opts = {}) {
|
|
2960
4018
|
const requestedWsPort = opts.wsPort ?? 3457;
|
|
@@ -3069,15 +4127,22 @@ async function startWebUI(opts = {}) {
|
|
|
3069
4127
|
sessionId: session.id,
|
|
3070
4128
|
projectSlug: wpaths.projectSlug,
|
|
3071
4129
|
projectRoot,
|
|
3072
|
-
projectName:
|
|
4130
|
+
projectName: path10.basename(projectRoot),
|
|
3073
4131
|
workingDir,
|
|
4132
|
+
clientType: "webui",
|
|
3074
4133
|
pid: process.pid,
|
|
3075
4134
|
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3076
4135
|
});
|
|
3077
|
-
|
|
4136
|
+
const fleetNotifier = new FleetNotifier({
|
|
4137
|
+
baseDir: wpaths.globalRoot,
|
|
4138
|
+
projectRoot,
|
|
4139
|
+
selfPid: process.pid
|
|
4140
|
+
});
|
|
4141
|
+
statusTracker = new AgentStatusTracker({ events, registry, onUpdate: () => fleetNotifier.notify() });
|
|
3078
4142
|
statusTracker.start();
|
|
3079
4143
|
const stopTracking = async () => {
|
|
3080
4144
|
try {
|
|
4145
|
+
fleetNotifier.dispose();
|
|
3081
4146
|
await registry.markClosing();
|
|
3082
4147
|
statusTracker?.stop();
|
|
3083
4148
|
} catch {
|
|
@@ -3117,6 +4182,13 @@ async function startWebUI(opts = {}) {
|
|
|
3117
4182
|
supportsReasoning: resolvedModel.capabilities.reasoning
|
|
3118
4183
|
} : void 0;
|
|
3119
4184
|
const skillLoader = config.features.skills ? new DefaultSkillLoader2({ paths: wpaths }) : void 0;
|
|
4185
|
+
const skillInstaller = config.features.skills ? new SkillInstaller({
|
|
4186
|
+
manifestPath: path10.join(wstackGlobalRoot2(), "installed-skills.json"),
|
|
4187
|
+
projectSkillsDir: path10.join(projectRoot, ".wrongstack", "skills"),
|
|
4188
|
+
globalSkillsDir: path10.join(wstackGlobalRoot2(), "skills"),
|
|
4189
|
+
projectHash: projectHash(projectRoot),
|
|
4190
|
+
skillLoader
|
|
4191
|
+
}) : void 0;
|
|
3120
4192
|
const systemPromptBuilder = new DefaultSystemPromptBuilder2({
|
|
3121
4193
|
memoryStore,
|
|
3122
4194
|
skillLoader,
|
|
@@ -3186,7 +4258,7 @@ async function startWebUI(opts = {}) {
|
|
|
3186
4258
|
}
|
|
3187
4259
|
} else {
|
|
3188
4260
|
throw new Error(
|
|
3189
|
-
"No provider configured. Run `wrongstack
|
|
4261
|
+
"No provider configured. Run `wrongstack auth` to set up, or configure via the WebUI."
|
|
3190
4262
|
);
|
|
3191
4263
|
}
|
|
3192
4264
|
}
|
|
@@ -3274,7 +4346,7 @@ async function startWebUI(opts = {}) {
|
|
|
3274
4346
|
const write = async () => {
|
|
3275
4347
|
let raw;
|
|
3276
4348
|
try {
|
|
3277
|
-
raw = await
|
|
4349
|
+
raw = await fs10.readFile(globalConfigPath, "utf8");
|
|
3278
4350
|
} catch {
|
|
3279
4351
|
raw = "{}";
|
|
3280
4352
|
}
|
|
@@ -3583,7 +4655,7 @@ async function startWebUI(opts = {}) {
|
|
|
3583
4655
|
inputCost,
|
|
3584
4656
|
outputCost,
|
|
3585
4657
|
cacheReadCost,
|
|
3586
|
-
projectName:
|
|
4658
|
+
projectName: path10.basename(projectRoot) || projectRoot,
|
|
3587
4659
|
projectRoot,
|
|
3588
4660
|
cwd: workingDir,
|
|
3589
4661
|
mode: modeId,
|
|
@@ -3637,10 +4709,11 @@ async function startWebUI(opts = {}) {
|
|
|
3637
4709
|
const RATE_LIMIT_MESSAGES = Number.parseInt(process.env["WEBUI_RATE_LIMIT"] ?? "0", 10);
|
|
3638
4710
|
const RATE_LIMIT_WINDOW_MS = 6e4;
|
|
3639
4711
|
const rateLimits = /* @__PURE__ */ new Map();
|
|
3640
|
-
|
|
4712
|
+
let connSeq = 0;
|
|
4713
|
+
function checkRateLimit(_ws, client) {
|
|
3641
4714
|
if (RATE_LIMIT_MESSAGES <= 0) return true;
|
|
3642
4715
|
const now = Date.now();
|
|
3643
|
-
const key = client.
|
|
4716
|
+
const key = client.connId;
|
|
3644
4717
|
const limit = rateLimits.get(key);
|
|
3645
4718
|
if (!limit || now > limit.resetAt) {
|
|
3646
4719
|
rateLimits.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
|
@@ -3656,7 +4729,12 @@ async function startWebUI(opts = {}) {
|
|
|
3656
4729
|
);
|
|
3657
4730
|
const pendingConfirms = /* @__PURE__ */ new Map();
|
|
3658
4731
|
const handleConnection = (ws) => {
|
|
3659
|
-
const client = {
|
|
4732
|
+
const client = {
|
|
4733
|
+
ws,
|
|
4734
|
+
sessionId: session.id,
|
|
4735
|
+
connectedAt: Date.now(),
|
|
4736
|
+
connId: `c${++connSeq}`
|
|
4737
|
+
};
|
|
3660
4738
|
clients.set(ws, client);
|
|
3661
4739
|
void sessionStartPayload().then((payload) => {
|
|
3662
4740
|
send(ws, { type: "session.start", payload });
|
|
@@ -3686,7 +4764,7 @@ async function startWebUI(opts = {}) {
|
|
|
3686
4764
|
const rawObj = JSON.parse(data.toString());
|
|
3687
4765
|
if (typeof rawObj === "object" && rawObj !== null) {
|
|
3688
4766
|
const obj = rawObj;
|
|
3689
|
-
if ("__proto__"
|
|
4767
|
+
if (Object.hasOwn(obj, "__proto__") || Object.hasOwn(obj, "constructor") || Object.hasOwn(obj, "prototype")) {
|
|
3690
4768
|
send(ws, {
|
|
3691
4769
|
type: "error",
|
|
3692
4770
|
payload: { phase: "parse", message: "Invalid message object" }
|
|
@@ -3707,8 +4785,9 @@ async function startWebUI(opts = {}) {
|
|
|
3707
4785
|
}
|
|
3708
4786
|
});
|
|
3709
4787
|
ws.on("close", () => {
|
|
4788
|
+
const closing = clients.get(ws);
|
|
3710
4789
|
clients.delete(ws);
|
|
3711
|
-
rateLimits.delete(
|
|
4790
|
+
if (closing) rateLimits.delete(closing.connId);
|
|
3712
4791
|
if (pendingConfirms.size > 0) {
|
|
3713
4792
|
for (const [id, resolve5] of pendingConfirms) {
|
|
3714
4793
|
resolve5("no");
|
|
@@ -3734,11 +4813,27 @@ async function startWebUI(opts = {}) {
|
|
|
3734
4813
|
{ sampling: sessionLogging.sampling }
|
|
3735
4814
|
);
|
|
3736
4815
|
let eventsArmed = false;
|
|
4816
|
+
let disposeEvents = null;
|
|
4817
|
+
let fleetBroadcast = null;
|
|
3737
4818
|
const armOnce = (label) => {
|
|
3738
4819
|
if (eventsArmed) return;
|
|
3739
4820
|
eventsArmed = true;
|
|
3740
4821
|
console.log(`[WebUI] Backend ready (${label})`);
|
|
3741
|
-
setupEvents({
|
|
4822
|
+
disposeEvents = setupEvents({
|
|
4823
|
+
events,
|
|
4824
|
+
broadcast,
|
|
4825
|
+
clients,
|
|
4826
|
+
config,
|
|
4827
|
+
context,
|
|
4828
|
+
pendingConfirms,
|
|
4829
|
+
globalConfigPath,
|
|
4830
|
+
sessionBridge,
|
|
4831
|
+
wpaths,
|
|
4832
|
+
watcherMetrics,
|
|
4833
|
+
onFleetBroadcaster: (fn) => {
|
|
4834
|
+
fleetBroadcast = fn;
|
|
4835
|
+
}
|
|
4836
|
+
});
|
|
3742
4837
|
};
|
|
3743
4838
|
wssPrimary.on("listening", () => armOnce(`${wsHost}:${wsPort}`));
|
|
3744
4839
|
wssPrimary.on("connection", handleConnection);
|
|
@@ -3775,33 +4870,33 @@ async function startWebUI(opts = {}) {
|
|
|
3775
4870
|
});
|
|
3776
4871
|
}
|
|
3777
4872
|
async function touchProjectEntry(root, workDir) {
|
|
3778
|
-
const resolved =
|
|
4873
|
+
const resolved = path10.resolve(root);
|
|
3779
4874
|
const manifest = await loadManifest(globalConfigPath);
|
|
3780
4875
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3781
|
-
const existing = manifest.projects.find((p) =>
|
|
4876
|
+
const existing = manifest.projects.find((p) => path10.resolve(p.root) === resolved);
|
|
3782
4877
|
if (existing) {
|
|
3783
4878
|
existing.lastSeen = now;
|
|
3784
|
-
if (workDir) existing.lastWorkingDir =
|
|
4879
|
+
if (workDir) existing.lastWorkingDir = path10.resolve(workDir);
|
|
3785
4880
|
} else {
|
|
3786
4881
|
manifest.projects.push({
|
|
3787
|
-
name:
|
|
4882
|
+
name: path10.basename(resolved),
|
|
3788
4883
|
root: resolved,
|
|
3789
4884
|
slug: generateProjectSlug(resolved),
|
|
3790
4885
|
createdAt: now,
|
|
3791
4886
|
lastSeen: now,
|
|
3792
|
-
lastWorkingDir: workDir ?
|
|
4887
|
+
lastWorkingDir: workDir ? path10.resolve(workDir) : void 0
|
|
3793
4888
|
});
|
|
3794
4889
|
}
|
|
3795
4890
|
await saveManifest(manifest, globalConfigPath);
|
|
3796
4891
|
await ensureProjectDataDir(generateProjectSlug(resolved), globalConfigPath);
|
|
3797
4892
|
}
|
|
3798
4893
|
function projectsJsonPath(globalConfigPath2) {
|
|
3799
|
-
const base =
|
|
3800
|
-
return
|
|
4894
|
+
const base = path10.dirname(globalConfigPath2);
|
|
4895
|
+
return path10.join(base, "projects.json");
|
|
3801
4896
|
}
|
|
3802
4897
|
async function loadManifest(globalConfigPath2) {
|
|
3803
4898
|
try {
|
|
3804
|
-
const raw = await
|
|
4899
|
+
const raw = await fs10.readFile(projectsJsonPath(globalConfigPath2), "utf8");
|
|
3805
4900
|
const parsed = JSON.parse(raw);
|
|
3806
4901
|
return { projects: parsed.projects ?? [] };
|
|
3807
4902
|
} catch {
|
|
@@ -3810,16 +4905,16 @@ async function startWebUI(opts = {}) {
|
|
|
3810
4905
|
}
|
|
3811
4906
|
async function saveManifest(manifest, globalConfigPath2) {
|
|
3812
4907
|
const file = projectsJsonPath(globalConfigPath2);
|
|
3813
|
-
await
|
|
3814
|
-
await
|
|
4908
|
+
await fs10.mkdir(path10.dirname(file), { recursive: true });
|
|
4909
|
+
await fs10.writeFile(file, JSON.stringify(manifest, null, 2), "utf8");
|
|
3815
4910
|
}
|
|
3816
4911
|
function generateProjectSlug(rootPath) {
|
|
3817
4912
|
return projectSlug(rootPath);
|
|
3818
4913
|
}
|
|
3819
4914
|
async function ensureProjectDataDir(slug, globalConfigPath2) {
|
|
3820
|
-
const base =
|
|
3821
|
-
const dir =
|
|
3822
|
-
await
|
|
4915
|
+
const base = path10.dirname(globalConfigPath2);
|
|
4916
|
+
const dir = path10.join(base, "projects", slug);
|
|
4917
|
+
await fs10.mkdir(dir, { recursive: true });
|
|
3823
4918
|
return dir;
|
|
3824
4919
|
}
|
|
3825
4920
|
async function handleMessage(ws, _client, msg) {
|
|
@@ -3929,7 +5024,7 @@ async function startWebUI(opts = {}) {
|
|
|
3929
5024
|
context.readFiles.clear();
|
|
3930
5025
|
context.fileMtimes.clear();
|
|
3931
5026
|
tokenCounter.reset();
|
|
3932
|
-
|
|
5027
|
+
sendResult2(ws, true, "Context cleared");
|
|
3933
5028
|
broadcast(clients, {
|
|
3934
5029
|
type: "session.start",
|
|
3935
5030
|
payload: { ...await sessionStartPayload(), reset: true }
|
|
@@ -3966,13 +5061,13 @@ async function startWebUI(opts = {}) {
|
|
|
3966
5061
|
repaired: report.repaired
|
|
3967
5062
|
}
|
|
3968
5063
|
});
|
|
3969
|
-
|
|
5064
|
+
sendResult2(
|
|
3970
5065
|
ws,
|
|
3971
5066
|
true,
|
|
3972
5067
|
`Compacted: ${report.before} \u2192 ${report.after} tokens (saved ~${Math.max(0, report.before - report.after)})`
|
|
3973
5068
|
);
|
|
3974
5069
|
} catch (err) {
|
|
3975
|
-
|
|
5070
|
+
sendResult2(ws, false, errMessage(err));
|
|
3976
5071
|
}
|
|
3977
5072
|
break;
|
|
3978
5073
|
}
|
|
@@ -3991,7 +5086,7 @@ async function startWebUI(opts = {}) {
|
|
|
3991
5086
|
};
|
|
3992
5087
|
broadcast(clients, { type: "context.repaired", payload });
|
|
3993
5088
|
const removed = payload.removedToolUses.length + payload.removedToolResults.length + payload.removedMessages;
|
|
3994
|
-
|
|
5089
|
+
sendResult2(
|
|
3995
5090
|
ws,
|
|
3996
5091
|
true,
|
|
3997
5092
|
removed > 0 ? `Context repaired: removed ${removed} orphan protocol item(s)` : "Context repair found no orphan protocol blocks"
|
|
@@ -4025,14 +5120,14 @@ async function startWebUI(opts = {}) {
|
|
|
4025
5120
|
);
|
|
4026
5121
|
const custom = customModes.find((m) => m.id === id);
|
|
4027
5122
|
if (!custom) {
|
|
4028
|
-
|
|
5123
|
+
sendResult2(ws, false, `Unknown context mode "${id}"`);
|
|
4029
5124
|
break;
|
|
4030
5125
|
}
|
|
4031
5126
|
policy = custom;
|
|
4032
5127
|
}
|
|
4033
5128
|
context.meta["contextWindowMode"] = policy.id;
|
|
4034
5129
|
context.meta["contextWindowPolicy"] = policy;
|
|
4035
|
-
|
|
5130
|
+
sendResult2(ws, true, `Context mode switched to ${policy.id}`);
|
|
4036
5131
|
broadcast(clients, {
|
|
4037
5132
|
type: "context.mode.changed",
|
|
4038
5133
|
payload: { id: policy.id, name: policy.name, policy }
|
|
@@ -4052,7 +5147,7 @@ async function startWebUI(opts = {}) {
|
|
|
4052
5147
|
aggressiveOn: "soft",
|
|
4053
5148
|
targetLoad: 0.65
|
|
4054
5149
|
});
|
|
4055
|
-
|
|
5150
|
+
sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" created`);
|
|
4056
5151
|
break;
|
|
4057
5152
|
}
|
|
4058
5153
|
case "context.mode.update": {
|
|
@@ -4068,7 +5163,7 @@ async function startWebUI(opts = {}) {
|
|
|
4068
5163
|
preserveK: payload.preserveK,
|
|
4069
5164
|
eliseThreshold: payload.eliseThreshold
|
|
4070
5165
|
});
|
|
4071
|
-
|
|
5166
|
+
sendResult2(ws, result.ok, result.error ?? `Mode "${payload.id}" updated`);
|
|
4072
5167
|
break;
|
|
4073
5168
|
}
|
|
4074
5169
|
case "context.mode.delete": {
|
|
@@ -4078,7 +5173,7 @@ async function startWebUI(opts = {}) {
|
|
|
4078
5173
|
context.meta["contextWindowPolicy"] = resolveContextWindowPolicy({}, DEFAULT_CONTEXT_WINDOW_MODE_ID);
|
|
4079
5174
|
}
|
|
4080
5175
|
const result = customModeStore.remove(id);
|
|
4081
|
-
|
|
5176
|
+
sendResult2(ws, result.ok, result.error ?? `Mode "${id}" deleted`);
|
|
4082
5177
|
break;
|
|
4083
5178
|
}
|
|
4084
5179
|
case "providers.list": {
|
|
@@ -4159,14 +5254,15 @@ async function startWebUI(opts = {}) {
|
|
|
4159
5254
|
context.provider = newProv;
|
|
4160
5255
|
updateAutoCompactionMaxContext?.(newProv);
|
|
4161
5256
|
try {
|
|
4162
|
-
|
|
4163
|
-
const raw = await
|
|
5257
|
+
const next = configWriteLock.then(async () => {
|
|
5258
|
+
const raw = await fs10.readFile(globalConfigPath, "utf8");
|
|
4164
5259
|
const parsed = JSON.parse(raw);
|
|
4165
5260
|
parsed.provider = newProvider;
|
|
4166
5261
|
parsed.model = newModel;
|
|
4167
5262
|
await atomicWrite5(globalConfigPath, JSON.stringify(parsed, null, 2));
|
|
4168
5263
|
});
|
|
4169
|
-
|
|
5264
|
+
configWriteLock = next.then(() => void 0, () => void 0);
|
|
5265
|
+
await next;
|
|
4170
5266
|
} catch (err) {
|
|
4171
5267
|
console.warn(JSON.stringify({
|
|
4172
5268
|
level: "warn",
|
|
@@ -4319,13 +5415,13 @@ async function startWebUI(opts = {}) {
|
|
|
4319
5415
|
const { id } = msg.payload;
|
|
4320
5416
|
try {
|
|
4321
5417
|
if (id === session.id) {
|
|
4322
|
-
|
|
5418
|
+
sendResult2(ws, false, "Cannot delete the active session");
|
|
4323
5419
|
break;
|
|
4324
5420
|
}
|
|
4325
5421
|
await sessionStore.delete(id);
|
|
4326
|
-
|
|
5422
|
+
sendResult2(ws, true, `Session ${id} deleted`);
|
|
4327
5423
|
} catch (err) {
|
|
4328
|
-
|
|
5424
|
+
sendResult2(ws, false, errMessage(err));
|
|
4329
5425
|
}
|
|
4330
5426
|
break;
|
|
4331
5427
|
}
|
|
@@ -4333,7 +5429,7 @@ async function startWebUI(opts = {}) {
|
|
|
4333
5429
|
const { id } = msg.payload;
|
|
4334
5430
|
try {
|
|
4335
5431
|
if (id === session.id) {
|
|
4336
|
-
|
|
5432
|
+
sendResult2(ws, false, "Session is already active");
|
|
4337
5433
|
break;
|
|
4338
5434
|
}
|
|
4339
5435
|
const resumed = await sessionStore.resume(id);
|
|
@@ -4363,14 +5459,14 @@ async function startWebUI(opts = {}) {
|
|
|
4363
5459
|
replayUsage: resumed.data.usage
|
|
4364
5460
|
}
|
|
4365
5461
|
});
|
|
4366
|
-
|
|
5462
|
+
sendResult2(ws, true, `Resumed session ${id}`);
|
|
4367
5463
|
} catch (err) {
|
|
4368
|
-
|
|
5464
|
+
sendResult2(ws, false, errMessage(err));
|
|
4369
5465
|
}
|
|
4370
5466
|
break;
|
|
4371
5467
|
}
|
|
4372
5468
|
case "session.save": {
|
|
4373
|
-
|
|
5469
|
+
sendResult2(ws, true, `Session ${session.id} is auto-saved`);
|
|
4374
5470
|
break;
|
|
4375
5471
|
}
|
|
4376
5472
|
case "tools.list": {
|
|
@@ -4393,6 +5489,27 @@ async function startWebUI(opts = {}) {
|
|
|
4393
5489
|
return handleMemoryRemember(ws, msg, memoryStore);
|
|
4394
5490
|
case "memory.forget":
|
|
4395
5491
|
return handleMemoryForget(ws, msg, memoryStore);
|
|
5492
|
+
// ── MCP operations — delegated to shared handlers (mcp-handlers.ts) ──
|
|
5493
|
+
case "mcp.list":
|
|
5494
|
+
return handleMcpList(ws, msg, config, globalConfigPath, void 0);
|
|
5495
|
+
case "mcp.add":
|
|
5496
|
+
return handleMcpAdd(ws, msg, config, globalConfigPath, void 0);
|
|
5497
|
+
case "mcp.remove":
|
|
5498
|
+
return handleMcpRemove(ws, msg, config, globalConfigPath, void 0);
|
|
5499
|
+
case "mcp.update":
|
|
5500
|
+
return handleMcpUpdate(ws, msg, config, globalConfigPath);
|
|
5501
|
+
case "mcp.wake":
|
|
5502
|
+
return handleMcpWake(ws, msg, config, globalConfigPath, void 0);
|
|
5503
|
+
case "mcp.sleep":
|
|
5504
|
+
return handleMcpSleep(ws, msg, config, globalConfigPath, void 0);
|
|
5505
|
+
case "mcp.discover":
|
|
5506
|
+
return handleMcpDiscover(ws, msg, config, globalConfigPath);
|
|
5507
|
+
case "mcp.enable":
|
|
5508
|
+
return handleMcpEnable(ws, msg, config, globalConfigPath);
|
|
5509
|
+
case "mcp.disable":
|
|
5510
|
+
return handleMcpDisable(ws, msg, config, globalConfigPath);
|
|
5511
|
+
case "mcp.restart":
|
|
5512
|
+
return handleMcpRestart(ws, msg, config, globalConfigPath);
|
|
4396
5513
|
case "skills.list": {
|
|
4397
5514
|
if (!skillLoader) {
|
|
4398
5515
|
send(ws, { type: "skills.list", payload: { skills: [], enabled: false } });
|
|
@@ -4402,6 +5519,18 @@ async function startWebUI(opts = {}) {
|
|
|
4402
5519
|
const manifests = await skillLoader.list();
|
|
4403
5520
|
const entries = await skillLoader.listEntries();
|
|
4404
5521
|
const byName = new Map(entries.map((e) => [e.name, e]));
|
|
5522
|
+
const sourceUrlsByName = /* @__PURE__ */ new Map();
|
|
5523
|
+
const refsByName = /* @__PURE__ */ new Map();
|
|
5524
|
+
if (skillInstaller) {
|
|
5525
|
+
try {
|
|
5526
|
+
const installed = await skillInstaller.listInstalled();
|
|
5527
|
+
for (const entry of installed) {
|
|
5528
|
+
sourceUrlsByName.set(entry.name, entry.source);
|
|
5529
|
+
refsByName.set(entry.name, entry.ref);
|
|
5530
|
+
}
|
|
5531
|
+
} catch {
|
|
5532
|
+
}
|
|
5533
|
+
}
|
|
4405
5534
|
send(ws, {
|
|
4406
5535
|
type: "skills.list",
|
|
4407
5536
|
payload: {
|
|
@@ -4411,6 +5540,8 @@ async function startWebUI(opts = {}) {
|
|
|
4411
5540
|
description: m.description,
|
|
4412
5541
|
version: m.version ?? "",
|
|
4413
5542
|
source: m.source,
|
|
5543
|
+
sourceUrl: sourceUrlsByName.get(m.name) ?? "",
|
|
5544
|
+
ref: refsByName.get(m.name) ?? "",
|
|
4414
5545
|
path: m.path,
|
|
4415
5546
|
trigger: byName.get(m.name)?.trigger ?? "",
|
|
4416
5547
|
scope: byName.get(m.name)?.scope ?? []
|
|
@@ -4429,6 +5560,261 @@ async function startWebUI(opts = {}) {
|
|
|
4429
5560
|
}
|
|
4430
5561
|
break;
|
|
4431
5562
|
}
|
|
5563
|
+
case "skills.content": {
|
|
5564
|
+
if (!skillLoader) {
|
|
5565
|
+
send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skills not enabled" } });
|
|
5566
|
+
break;
|
|
5567
|
+
}
|
|
5568
|
+
const contentPayload = msg.payload;
|
|
5569
|
+
if (!contentPayload?.name) {
|
|
5570
|
+
send(ws, { type: "skills.content", payload: { name: "", body: "", path: "", source: "", relatedFiles: [], references: [], error: "Skill name is required" } });
|
|
5571
|
+
break;
|
|
5572
|
+
}
|
|
5573
|
+
try {
|
|
5574
|
+
const { name, source } = contentPayload;
|
|
5575
|
+
const entries = await skillLoader.listEntries();
|
|
5576
|
+
const entry = entries.find((e) => e.name.toLowerCase() === name.toLowerCase());
|
|
5577
|
+
if (!entry) {
|
|
5578
|
+
send(ws, { type: "skills.content", payload: { name, body: "", path: "", source, relatedFiles: [], references: [], error: `Skill "${name}" not found` } });
|
|
5579
|
+
break;
|
|
5580
|
+
}
|
|
5581
|
+
const body = await skillLoader.readBody(name);
|
|
5582
|
+
const skillDir = path10.dirname(entry.path);
|
|
5583
|
+
let relatedFiles = [];
|
|
5584
|
+
try {
|
|
5585
|
+
const files = await fs10.readdir(skillDir);
|
|
5586
|
+
relatedFiles = files.filter((f) => f !== path10.basename(entry.path)).map((f) => path10.join(skillDir, f));
|
|
5587
|
+
} catch {
|
|
5588
|
+
}
|
|
5589
|
+
const refs = [];
|
|
5590
|
+
for (const e of entries) {
|
|
5591
|
+
if (e.name.toLowerCase() === name.toLowerCase()) continue;
|
|
5592
|
+
try {
|
|
5593
|
+
const content = await skillLoader.readBody(e.name);
|
|
5594
|
+
if (content.toLowerCase().includes(name.toLowerCase())) {
|
|
5595
|
+
refs.push(e.name);
|
|
5596
|
+
}
|
|
5597
|
+
} catch {
|
|
5598
|
+
}
|
|
5599
|
+
}
|
|
5600
|
+
send(ws, { type: "skills.content", payload: { name, body, path: entry.path, source, relatedFiles, references: refs } });
|
|
5601
|
+
} catch (err) {
|
|
5602
|
+
send(ws, { type: "skills.content", payload: { name: contentPayload.name, body: "", path: "", source: contentPayload.source, relatedFiles: [], references: [], error: errMessage(err) } });
|
|
5603
|
+
}
|
|
5604
|
+
break;
|
|
5605
|
+
}
|
|
5606
|
+
case "skills.install": {
|
|
5607
|
+
if (!skillInstaller) {
|
|
5608
|
+
send(ws, { type: "skills.installed", payload: { success: false, error: "Skills not enabled" } });
|
|
5609
|
+
break;
|
|
5610
|
+
}
|
|
5611
|
+
const installPayload = msg.payload;
|
|
5612
|
+
if (!installPayload?.ref?.trim()) {
|
|
5613
|
+
send(ws, { type: "skills.installed", payload: { success: false, error: "Skill reference is required (e.g. owner/repo or https://github.com/owner/repo)" } });
|
|
5614
|
+
break;
|
|
5615
|
+
}
|
|
5616
|
+
try {
|
|
5617
|
+
const results = await skillInstaller.install(installPayload.ref.trim(), { global: installPayload.global });
|
|
5618
|
+
send(ws, {
|
|
5619
|
+
type: "skills.installed",
|
|
5620
|
+
payload: {
|
|
5621
|
+
success: true,
|
|
5622
|
+
results,
|
|
5623
|
+
error: null
|
|
5624
|
+
}
|
|
5625
|
+
});
|
|
5626
|
+
} catch (err) {
|
|
5627
|
+
send(ws, {
|
|
5628
|
+
type: "skills.installed",
|
|
5629
|
+
payload: {
|
|
5630
|
+
success: false,
|
|
5631
|
+
error: errMessage(err)
|
|
5632
|
+
}
|
|
5633
|
+
});
|
|
5634
|
+
}
|
|
5635
|
+
break;
|
|
5636
|
+
}
|
|
5637
|
+
case "skills.uninstall": {
|
|
5638
|
+
if (!skillInstaller) {
|
|
5639
|
+
send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skills not enabled" } });
|
|
5640
|
+
break;
|
|
5641
|
+
}
|
|
5642
|
+
const uninstallPayload = msg.payload;
|
|
5643
|
+
if (!uninstallPayload?.name?.trim()) {
|
|
5644
|
+
send(ws, { type: "skills.uninstalled", payload: { success: false, error: "Skill name is required" } });
|
|
5645
|
+
break;
|
|
5646
|
+
}
|
|
5647
|
+
try {
|
|
5648
|
+
await skillInstaller.uninstall(uninstallPayload.name.trim(), { global: uninstallPayload.global });
|
|
5649
|
+
send(ws, { type: "skills.uninstalled", payload: { success: true, error: null } });
|
|
5650
|
+
} catch (err) {
|
|
5651
|
+
send(ws, { type: "skills.uninstalled", payload: { success: false, error: errMessage(err) } });
|
|
5652
|
+
}
|
|
5653
|
+
break;
|
|
5654
|
+
}
|
|
5655
|
+
case "skills.update": {
|
|
5656
|
+
if (!skillInstaller) {
|
|
5657
|
+
send(ws, { type: "skills.updated", payload: { success: false, error: "Skills not enabled" } });
|
|
5658
|
+
break;
|
|
5659
|
+
}
|
|
5660
|
+
const updatePayload = msg.payload;
|
|
5661
|
+
try {
|
|
5662
|
+
const result = await skillInstaller.update(updatePayload?.name, { global: updatePayload?.global });
|
|
5663
|
+
send(ws, {
|
|
5664
|
+
type: "skills.updated",
|
|
5665
|
+
payload: {
|
|
5666
|
+
success: true,
|
|
5667
|
+
error: null,
|
|
5668
|
+
updated: result.updated,
|
|
5669
|
+
unchanged: result.unchanged,
|
|
5670
|
+
errors: result.errors
|
|
5671
|
+
}
|
|
5672
|
+
});
|
|
5673
|
+
} catch (err) {
|
|
5674
|
+
send(ws, { type: "skills.updated", payload: { success: false, error: errMessage(err) } });
|
|
5675
|
+
}
|
|
5676
|
+
break;
|
|
5677
|
+
}
|
|
5678
|
+
case "skills.create": {
|
|
5679
|
+
const createPayload = msg.payload;
|
|
5680
|
+
if (!createPayload?.name?.trim()) {
|
|
5681
|
+
send(ws, { type: "skills.created", payload: { success: false, error: "Skill name is required" } });
|
|
5682
|
+
break;
|
|
5683
|
+
}
|
|
5684
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(createPayload.name.trim())) {
|
|
5685
|
+
send(ws, { type: "skills.created", payload: { success: false, error: "Skill name must be kebab-case (e.g. my-new-skill)" } });
|
|
5686
|
+
break;
|
|
5687
|
+
}
|
|
5688
|
+
if (!createPayload?.description?.trim()) {
|
|
5689
|
+
send(ws, { type: "skills.created", payload: { success: false, error: "Description/trigger is required" } });
|
|
5690
|
+
break;
|
|
5691
|
+
}
|
|
5692
|
+
try {
|
|
5693
|
+
const targetDir = createPayload.scope === "global" ? path10.join(wstackGlobalRoot2(), "skills", createPayload.name.trim()) : path10.join(projectRoot, ".wrongstack", "skills", createPayload.name.trim());
|
|
5694
|
+
try {
|
|
5695
|
+
await fs10.access(targetDir);
|
|
5696
|
+
send(ws, { type: "skills.created", payload: { success: false, error: `Skill "${createPayload.name}" already exists` } });
|
|
5697
|
+
break;
|
|
5698
|
+
} catch {
|
|
5699
|
+
}
|
|
5700
|
+
await fs10.mkdir(targetDir, { recursive: true });
|
|
5701
|
+
const lines = createPayload.description.trim().split("\n");
|
|
5702
|
+
const firstLine = lines[0].trim();
|
|
5703
|
+
const bodyLines = lines.slice(1).map((l) => l.trim()).filter(Boolean);
|
|
5704
|
+
const descriptionText = firstLine + (bodyLines.length > 0 ? `
|
|
5705
|
+
${bodyLines.join("\n")}` : "");
|
|
5706
|
+
const trigger = bodyLines.find((l) => l.toLowerCase().startsWith("triggers:")) ?? "";
|
|
5707
|
+
const skillContent = [
|
|
5708
|
+
"---",
|
|
5709
|
+
`name: ${createPayload.name.trim()}`,
|
|
5710
|
+
"description: |",
|
|
5711
|
+
` ${descriptionText.replace(/\n/g, "\n ")}`,
|
|
5712
|
+
`version: 1.0.0`,
|
|
5713
|
+
"---",
|
|
5714
|
+
"",
|
|
5715
|
+
`# ${createPayload.name.trim().split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ")}`,
|
|
5716
|
+
"",
|
|
5717
|
+
"## Overview",
|
|
5718
|
+
"",
|
|
5719
|
+
firstLine,
|
|
5720
|
+
"",
|
|
5721
|
+
...bodyLines.length > 0 ? bodyLines.filter((l) => !l.toLowerCase().startsWith("triggers:")) : [],
|
|
5722
|
+
"",
|
|
5723
|
+
"## Rules",
|
|
5724
|
+
"- TODO: add your first rule",
|
|
5725
|
+
"",
|
|
5726
|
+
"## Patterns",
|
|
5727
|
+
"### Do",
|
|
5728
|
+
"```ts",
|
|
5729
|
+
"// TODO: add a good example",
|
|
5730
|
+
"```",
|
|
5731
|
+
"",
|
|
5732
|
+
"### Don't",
|
|
5733
|
+
"```ts",
|
|
5734
|
+
"// TODO: add a bad example",
|
|
5735
|
+
"```",
|
|
5736
|
+
"",
|
|
5737
|
+
"## Workflow",
|
|
5738
|
+
"1. TODO: describe step one",
|
|
5739
|
+
"2. TODO: describe step two",
|
|
5740
|
+
"",
|
|
5741
|
+
trigger ? `
|
|
5742
|
+
${trigger}
|
|
5743
|
+
` : "",
|
|
5744
|
+
"## Skills in scope",
|
|
5745
|
+
"- `bug-hunter` \u2014 for systematic bug detection patterns",
|
|
5746
|
+
"- `output-standards` \u2014 for standardized `<next_steps>` formatting"
|
|
5747
|
+
].join("\n");
|
|
5748
|
+
await fs10.writeFile(path10.join(targetDir, "SKILL.md"), skillContent, "utf-8");
|
|
5749
|
+
send(ws, {
|
|
5750
|
+
type: "skills.created",
|
|
5751
|
+
payload: {
|
|
5752
|
+
success: true,
|
|
5753
|
+
error: null,
|
|
5754
|
+
skill: { name: createPayload.name.trim(), path: path10.join(targetDir, "SKILL.md"), scope: createPayload.scope }
|
|
5755
|
+
}
|
|
5756
|
+
});
|
|
5757
|
+
} catch (err) {
|
|
5758
|
+
send(ws, { type: "skills.created", payload: { success: false, error: errMessage(err) } });
|
|
5759
|
+
}
|
|
5760
|
+
break;
|
|
5761
|
+
}
|
|
5762
|
+
case "skills.edit": {
|
|
5763
|
+
if (!skillLoader) {
|
|
5764
|
+
send(ws, { type: "skills.edited", payload: { success: false, error: "Skills not enabled" } });
|
|
5765
|
+
break;
|
|
5766
|
+
}
|
|
5767
|
+
const editPayload = msg.payload;
|
|
5768
|
+
if (!editPayload?.name?.trim()) {
|
|
5769
|
+
send(ws, { type: "skills.edited", payload: { success: false, error: "Skill name is required" } });
|
|
5770
|
+
break;
|
|
5771
|
+
}
|
|
5772
|
+
if (!editPayload?.body) {
|
|
5773
|
+
send(ws, { type: "skills.edited", payload: { success: false, error: "Skill body is required" } });
|
|
5774
|
+
break;
|
|
5775
|
+
}
|
|
5776
|
+
try {
|
|
5777
|
+
const entries = await skillLoader.listEntries();
|
|
5778
|
+
const entry = entries.find((e) => e.name.toLowerCase() === editPayload.name.toLowerCase());
|
|
5779
|
+
if (!entry) {
|
|
5780
|
+
send(ws, { type: "skills.edited", payload: { success: false, error: `Skill "${editPayload.name}" not found` } });
|
|
5781
|
+
break;
|
|
5782
|
+
}
|
|
5783
|
+
if (entry.scope.includes("bundled")) {
|
|
5784
|
+
send(ws, { type: "skills.edited", payload: { success: false, error: "Bundled skills cannot be edited" } });
|
|
5785
|
+
break;
|
|
5786
|
+
}
|
|
5787
|
+
await fs10.writeFile(entry.path, editPayload.body, "utf-8");
|
|
5788
|
+
send(ws, { type: "skills.edited", payload: { success: true, error: null } });
|
|
5789
|
+
} catch (err) {
|
|
5790
|
+
send(ws, { type: "skills.edited", payload: { success: false, error: errMessage(err) } });
|
|
5791
|
+
}
|
|
5792
|
+
break;
|
|
5793
|
+
}
|
|
5794
|
+
case "skills.export": {
|
|
5795
|
+
if (!skillLoader) {
|
|
5796
|
+
send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: "Skills not enabled" } });
|
|
5797
|
+
break;
|
|
5798
|
+
}
|
|
5799
|
+
try {
|
|
5800
|
+
const entries = await skillLoader.listEntries();
|
|
5801
|
+
const zip = new JSZip2();
|
|
5802
|
+
for (const entry of entries) {
|
|
5803
|
+
try {
|
|
5804
|
+
const body = await skillLoader.readBody(entry.name);
|
|
5805
|
+
const safeName = entry.name.replace(/\//g, "_");
|
|
5806
|
+
zip.file(`${safeName}/SKILL.md`, body);
|
|
5807
|
+
} catch {
|
|
5808
|
+
}
|
|
5809
|
+
}
|
|
5810
|
+
const zipBuffer = await zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" });
|
|
5811
|
+
const zipBase64 = zipBuffer.toString("base64");
|
|
5812
|
+
send(ws, { type: "skills.exported", payload: { zipBase64, skillCount: entries.length, error: void 0 } });
|
|
5813
|
+
} catch (err) {
|
|
5814
|
+
send(ws, { type: "skills.exported", payload: { zipBase64: "", skillCount: 0, error: errMessage(err) } });
|
|
5815
|
+
}
|
|
5816
|
+
break;
|
|
5817
|
+
}
|
|
4432
5818
|
case "diag.get": {
|
|
4433
5819
|
const usage = tokenCounter.total();
|
|
4434
5820
|
send(ws, {
|
|
@@ -4456,194 +5842,84 @@ async function startWebUI(opts = {}) {
|
|
|
4456
5842
|
break;
|
|
4457
5843
|
}
|
|
4458
5844
|
case "todos.get": {
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
5845
|
+
const ctx = {
|
|
5846
|
+
context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
|
|
5847
|
+
send: (w, m) => send(w, m),
|
|
5848
|
+
broadcast: (m) => broadcast(clients, m)
|
|
5849
|
+
};
|
|
5850
|
+
handleTodosGet(ctx, ws);
|
|
4463
5851
|
break;
|
|
4464
5852
|
}
|
|
4465
5853
|
case "todos.clear": {
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
5854
|
+
const ctx = {
|
|
5855
|
+
context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
|
|
5856
|
+
send: (w, m) => send(w, m),
|
|
5857
|
+
broadcast: (m) => broadcast(clients, m)
|
|
5858
|
+
};
|
|
5859
|
+
handleTodosClear(ctx, ws);
|
|
4469
5860
|
break;
|
|
4470
5861
|
}
|
|
4471
5862
|
case "todos.remove": {
|
|
4472
|
-
const
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
}
|
|
4477
|
-
|
|
4478
|
-
let targetIdx = -1;
|
|
4479
|
-
if (typeof id === "string") {
|
|
4480
|
-
targetIdx = context.todos.findIndex((t) => t.id === id);
|
|
4481
|
-
} else if (typeof index === "number" && index > 0) {
|
|
4482
|
-
targetIdx = index - 1;
|
|
4483
|
-
}
|
|
4484
|
-
if (targetIdx < 0 || !context.todos[targetIdx]) {
|
|
4485
|
-
sendResult(ws, false, "Todo not found");
|
|
4486
|
-
break;
|
|
4487
|
-
}
|
|
4488
|
-
const removed = expectDefined2(context.todos[targetIdx]);
|
|
4489
|
-
const next = [...context.todos.slice(0, targetIdx), ...context.todos.slice(targetIdx + 1)];
|
|
4490
|
-
context.state.replaceTodos(next);
|
|
4491
|
-
sendResult(ws, true, `Removed: ${removed.content}`);
|
|
4492
|
-
broadcast(clients, { type: "todos.updated", payload: { todos: next } });
|
|
5863
|
+
const ctx = {
|
|
5864
|
+
context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
|
|
5865
|
+
send: (w, m) => send(w, m),
|
|
5866
|
+
broadcast: (m) => broadcast(clients, m)
|
|
5867
|
+
};
|
|
5868
|
+
handleTodosRemove(ctx, ws, msg.payload);
|
|
4493
5869
|
break;
|
|
4494
5870
|
}
|
|
4495
5871
|
case "tasks.get": {
|
|
4496
|
-
const
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
type: "tasks.updated",
|
|
4503
|
-
payload: { tasks: file?.tasks ?? [] }
|
|
4504
|
-
});
|
|
4505
|
-
} catch {
|
|
4506
|
-
send(ws, { type: "tasks.updated", payload: { tasks: [] } });
|
|
4507
|
-
}
|
|
4508
|
-
} else {
|
|
4509
|
-
send(ws, { type: "tasks.updated", payload: { tasks: [], error: "Task storage not configured." } });
|
|
4510
|
-
}
|
|
5872
|
+
const ctx = {
|
|
5873
|
+
context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
|
|
5874
|
+
send: (w, m) => send(w, m),
|
|
5875
|
+
broadcast: (m) => broadcast(clients, m)
|
|
5876
|
+
};
|
|
5877
|
+
await handleTasksGet(ctx, ws);
|
|
4511
5878
|
break;
|
|
4512
5879
|
}
|
|
4513
5880
|
case "plan.get": {
|
|
4514
|
-
const
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
type: "plan.updated",
|
|
4521
|
-
payload: {
|
|
4522
|
-
plan: plan ?? {
|
|
4523
|
-
version: 1,
|
|
4524
|
-
sessionId: session.id,
|
|
4525
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4526
|
-
items: []
|
|
4527
|
-
}
|
|
4528
|
-
}
|
|
4529
|
-
});
|
|
4530
|
-
} catch {
|
|
4531
|
-
send(ws, {
|
|
4532
|
-
type: "plan.updated",
|
|
4533
|
-
payload: {
|
|
4534
|
-
plan: {
|
|
4535
|
-
version: 1,
|
|
4536
|
-
sessionId: session.id,
|
|
4537
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4538
|
-
items: []
|
|
4539
|
-
}
|
|
4540
|
-
}
|
|
4541
|
-
});
|
|
4542
|
-
}
|
|
4543
|
-
} else {
|
|
4544
|
-
send(ws, {
|
|
4545
|
-
type: "plan.updated",
|
|
4546
|
-
payload: { plan: null, error: "Plan storage is not configured for this session." }
|
|
4547
|
-
});
|
|
4548
|
-
}
|
|
5881
|
+
const ctx = {
|
|
5882
|
+
context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
|
|
5883
|
+
send: (w, m) => send(w, m),
|
|
5884
|
+
broadcast: (m) => broadcast(clients, m)
|
|
5885
|
+
};
|
|
5886
|
+
await handlePlanGet(ctx, ws);
|
|
4549
5887
|
break;
|
|
4550
5888
|
}
|
|
4551
5889
|
case "plan.template_use": {
|
|
4552
|
-
const
|
|
4553
|
-
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
try {
|
|
4559
|
-
const { getPlanTemplate, loadPlan, savePlan, emptyPlan, addPlanItem } = await import("@wrongstack/core");
|
|
4560
|
-
const tpl = getPlanTemplate(template);
|
|
4561
|
-
if (!tpl) {
|
|
4562
|
-
sendResult(ws, false, `Unknown template "${template}".`);
|
|
4563
|
-
break;
|
|
4564
|
-
}
|
|
4565
|
-
let plan = await loadPlan(planPath) ?? emptyPlan(session.id);
|
|
4566
|
-
for (const item of tpl.items) {
|
|
4567
|
-
({ plan } = addPlanItem(plan, item.title, item.details));
|
|
4568
|
-
}
|
|
4569
|
-
await savePlan(planPath, plan);
|
|
4570
|
-
sendResult(ws, true, `Applied template "${tpl.name}" \u2014 ${tpl.items.length} items added.`);
|
|
4571
|
-
broadcast(clients, {
|
|
4572
|
-
type: "plan.updated",
|
|
4573
|
-
payload: { plan }
|
|
4574
|
-
});
|
|
4575
|
-
} catch (err) {
|
|
4576
|
-
sendResult(ws, false, errMessage(err));
|
|
4577
|
-
}
|
|
5890
|
+
const ctx = {
|
|
5891
|
+
context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
|
|
5892
|
+
send: (w, m) => send(w, m),
|
|
5893
|
+
broadcast: (m) => broadcast(clients, m)
|
|
5894
|
+
};
|
|
5895
|
+
await handlePlanTemplateUse(ctx, ws, msg.payload.template);
|
|
4578
5896
|
break;
|
|
4579
5897
|
}
|
|
4580
5898
|
case "todo.update": {
|
|
4581
|
-
const
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
break;
|
|
4586
|
-
}
|
|
4587
|
-
const next = [...context.todos];
|
|
4588
|
-
const existing = expectDefined2(next[idx]);
|
|
4589
|
-
next[idx] = {
|
|
4590
|
-
...existing,
|
|
4591
|
-
status: payload.status ?? existing.status,
|
|
4592
|
-
activeForm: payload.activeForm !== void 0 ? payload.activeForm : existing.activeForm
|
|
5899
|
+
const ctx = {
|
|
5900
|
+
context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
|
|
5901
|
+
send: (w, m) => send(w, m),
|
|
5902
|
+
broadcast: (m) => broadcast(clients, m)
|
|
4593
5903
|
};
|
|
4594
|
-
|
|
4595
|
-
sendResult(ws, true, `Todo "${existing.content}" updated`);
|
|
4596
|
-
broadcast(clients, { type: "todos.updated", payload: { todos: next } });
|
|
5904
|
+
handleTodoUpdate(ctx, ws, msg.payload);
|
|
4597
5905
|
break;
|
|
4598
5906
|
}
|
|
4599
5907
|
case "task.update": {
|
|
4600
|
-
const
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
try {
|
|
4607
|
-
const { mutateTasks } = await import("@wrongstack/core");
|
|
4608
|
-
const file = await mutateTasks(taskPath, session.id, async (f) => {
|
|
4609
|
-
const task = f.tasks.find((t) => t.id === payload.id);
|
|
4610
|
-
if (!task) return f;
|
|
4611
|
-
task.status = payload.status;
|
|
4612
|
-
task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4613
|
-
return f;
|
|
4614
|
-
});
|
|
4615
|
-
sendResult(ws, true, `Task status updated to "${payload.status}".`);
|
|
4616
|
-
broadcast(clients, { type: "tasks.updated", payload: { tasks: file.tasks } });
|
|
4617
|
-
} catch (err) {
|
|
4618
|
-
sendResult(ws, false, errMessage(err));
|
|
4619
|
-
}
|
|
5908
|
+
const ctx = {
|
|
5909
|
+
context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
|
|
5910
|
+
send: (w, m) => send(w, m),
|
|
5911
|
+
broadcast: (m) => broadcast(clients, m)
|
|
5912
|
+
};
|
|
5913
|
+
await handleTaskUpdate(ctx, ws, msg.payload);
|
|
4620
5914
|
break;
|
|
4621
5915
|
}
|
|
4622
5916
|
case "plan.item.update": {
|
|
4623
|
-
const
|
|
4624
|
-
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
try {
|
|
4630
|
-
const { mutatePlan, setPlanItemStatus } = await import("@wrongstack/core");
|
|
4631
|
-
let changed = false;
|
|
4632
|
-
const plan = await mutatePlan(planPath, session.id, async (p) => {
|
|
4633
|
-
const before = p.updatedAt;
|
|
4634
|
-
const updated = setPlanItemStatus(p, payload.target, payload.status);
|
|
4635
|
-
changed = updated.updatedAt !== before;
|
|
4636
|
-
return updated;
|
|
4637
|
-
});
|
|
4638
|
-
if (!changed) {
|
|
4639
|
-
sendResult(ws, false, `No plan item matched "${payload.target}".`);
|
|
4640
|
-
break;
|
|
4641
|
-
}
|
|
4642
|
-
sendResult(ws, true, `Plan item status updated to "${payload.status}".`);
|
|
4643
|
-
broadcast(clients, { type: "plan.updated", payload: { plan } });
|
|
4644
|
-
} catch (err) {
|
|
4645
|
-
sendResult(ws, false, errMessage(err));
|
|
4646
|
-
}
|
|
5917
|
+
const ctx = {
|
|
5918
|
+
context: { todos: context.todos, meta: context.meta, session: context.session ? { id: context.session.id } : null, state: context.state },
|
|
5919
|
+
send: (w, m) => send(w, m),
|
|
5920
|
+
broadcast: (m) => broadcast(clients, m)
|
|
5921
|
+
};
|
|
5922
|
+
await handlePlanItemUpdate(ctx, ws, msg.payload);
|
|
4647
5923
|
break;
|
|
4648
5924
|
}
|
|
4649
5925
|
// ── File operations — delegated to shared handlers (file-handlers.ts) ──
|
|
@@ -4713,13 +5989,13 @@ async function startWebUI(opts = {}) {
|
|
|
4713
5989
|
provider: config.provider,
|
|
4714
5990
|
model: config.model
|
|
4715
5991
|
});
|
|
4716
|
-
|
|
5992
|
+
sendResult2(ws, true, `Switched to mode "${id}"`);
|
|
4717
5993
|
broadcast(clients, {
|
|
4718
5994
|
type: "session.start",
|
|
4719
5995
|
payload: { ...await sessionStartPayload() }
|
|
4720
5996
|
});
|
|
4721
5997
|
} catch (err) {
|
|
4722
|
-
|
|
5998
|
+
sendResult2(ws, false, errMessage(err));
|
|
4723
5999
|
}
|
|
4724
6000
|
break;
|
|
4725
6001
|
}
|
|
@@ -4773,13 +6049,13 @@ async function startWebUI(opts = {}) {
|
|
|
4773
6049
|
const { getProcessRegistry } = await import("@wrongstack/tools");
|
|
4774
6050
|
const proc = getProcessRegistry().get(pid);
|
|
4775
6051
|
if (proc?.protected) {
|
|
4776
|
-
|
|
6052
|
+
sendResult2(ws, false, `Cannot kill protected process (PID ${pid})`);
|
|
4777
6053
|
break;
|
|
4778
6054
|
}
|
|
4779
6055
|
getProcessRegistry().kill(pid);
|
|
4780
|
-
|
|
6056
|
+
sendResult2(ws, true, `Killed PID ${pid}`);
|
|
4781
6057
|
} catch (err) {
|
|
4782
|
-
|
|
6058
|
+
sendResult2(ws, false, errMessage(err));
|
|
4783
6059
|
}
|
|
4784
6060
|
break;
|
|
4785
6061
|
}
|
|
@@ -4787,47 +6063,25 @@ async function startWebUI(opts = {}) {
|
|
|
4787
6063
|
try {
|
|
4788
6064
|
const { getProcessRegistry } = await import("@wrongstack/tools");
|
|
4789
6065
|
getProcessRegistry().killAll();
|
|
4790
|
-
|
|
6066
|
+
sendResult2(ws, true, "All processes killed");
|
|
4791
6067
|
} catch (err) {
|
|
4792
|
-
|
|
6068
|
+
sendResult2(ws, false, errMessage(err));
|
|
4793
6069
|
}
|
|
4794
6070
|
break;
|
|
4795
6071
|
}
|
|
4796
6072
|
case "git.info": {
|
|
4797
|
-
|
|
4798
|
-
|
|
4799
|
-
|
|
4800
|
-
|
|
4801
|
-
|
|
4802
|
-
|
|
4803
|
-
});
|
|
4804
|
-
});
|
|
4805
|
-
const [branchRaw, diffRaw, statusRaw, upstreamRaw] = await Promise.all([
|
|
4806
|
-
execFile("git", ["branch", "--show-current"]),
|
|
4807
|
-
execFile("git", ["diff", "--stat"]),
|
|
4808
|
-
execFile("git", ["status", "--porcelain"]),
|
|
4809
|
-
execFile("git", ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
|
|
4810
|
-
]);
|
|
4811
|
-
const branch = branchRaw || "(detached)";
|
|
4812
|
-
const diffMatch = /\+\s*(\d+)\s*deletion/i.exec(diffRaw);
|
|
4813
|
-
const addMatch = /(\d+)\s*insertion/i.exec(diffRaw) ?? /(\d+)\s*addition/i.exec(diffRaw);
|
|
4814
|
-
const delMatch = /\+\s*(\d+)\s*deletion/i.exec(diffRaw);
|
|
4815
|
-
const added = addMatch ? Number(addMatch[1]) : 0;
|
|
4816
|
-
const deleted = delMatch ? Number(delMatch[1]) : 0;
|
|
4817
|
-
const untracked = statusRaw.split("\n").filter((l) => l.startsWith("??")).length;
|
|
4818
|
-
const [aheadRaw, behindRaw] = (upstreamRaw || "0 0").split(" ");
|
|
4819
|
-
const ahead = Number(aheadRaw) || 0;
|
|
4820
|
-
const behind = Number(behindRaw) || 0;
|
|
4821
|
-
send(ws, {
|
|
4822
|
-
type: "git.info",
|
|
4823
|
-
payload: { branch, added, deleted, untracked, ahead, behind }
|
|
4824
|
-
});
|
|
6073
|
+
await handleGitInfo(ws, projectRoot);
|
|
6074
|
+
break;
|
|
6075
|
+
}
|
|
6076
|
+
case "webui.shutdown": {
|
|
6077
|
+
console.log("[WebUI] Shutdown requested from client");
|
|
6078
|
+
process.kill(process.pid, "SIGINT");
|
|
4825
6079
|
break;
|
|
4826
6080
|
}
|
|
4827
6081
|
case "goal.get": {
|
|
4828
6082
|
try {
|
|
4829
|
-
const goalPath =
|
|
4830
|
-
const raw = await
|
|
6083
|
+
const goalPath = resolveWstackPaths({ projectRoot }).projectGoal;
|
|
6084
|
+
const raw = await fs10.readFile(goalPath, "utf8");
|
|
4831
6085
|
const goal = JSON.parse(raw);
|
|
4832
6086
|
broadcast(clients, { type: "goal.updated", payload: goal });
|
|
4833
6087
|
} catch {
|
|
@@ -4838,7 +6092,7 @@ async function startWebUI(opts = {}) {
|
|
|
4838
6092
|
case "autonomy.switch": {
|
|
4839
6093
|
const { mode } = msg.payload;
|
|
4840
6094
|
context.meta["autonomy"] = mode;
|
|
4841
|
-
|
|
6095
|
+
sendResult2(ws, true, `Autonomy mode set to "${mode}"`);
|
|
4842
6096
|
broadcast(clients, { type: "prefs.updated", payload: { autonomy: mode } });
|
|
4843
6097
|
void persistPrefsToConfig({ autonomy: mode });
|
|
4844
6098
|
break;
|
|
@@ -4887,7 +6141,7 @@ async function startWebUI(opts = {}) {
|
|
|
4887
6141
|
try {
|
|
4888
6142
|
const { DefaultSessionRewinder } = await import("@wrongstack/core");
|
|
4889
6143
|
const rewinder = new DefaultSessionRewinder(
|
|
4890
|
-
|
|
6144
|
+
path10.join(projectRoot, ".wrongstack", "sessions"),
|
|
4891
6145
|
projectRoot
|
|
4892
6146
|
);
|
|
4893
6147
|
const checkpoints = await rewinder.listCheckpoints(session.id);
|
|
@@ -4908,18 +6162,18 @@ async function startWebUI(opts = {}) {
|
|
|
4908
6162
|
try {
|
|
4909
6163
|
const { DefaultSessionRewinder } = await import("@wrongstack/core");
|
|
4910
6164
|
const rewinder = new DefaultSessionRewinder(
|
|
4911
|
-
|
|
6165
|
+
path10.join(projectRoot, ".wrongstack", "sessions"),
|
|
4912
6166
|
projectRoot
|
|
4913
6167
|
);
|
|
4914
6168
|
await rewinder.rewindToCheckpoint(session.id, checkpointIndex);
|
|
4915
6169
|
await context.session.truncateToCheckpoint(checkpointIndex);
|
|
4916
|
-
|
|
6170
|
+
sendResult2(ws, true, `Rewound to checkpoint ${checkpointIndex}`);
|
|
4917
6171
|
broadcast(clients, {
|
|
4918
6172
|
type: "session.start",
|
|
4919
6173
|
payload: { ...await sessionStartPayload(), reset: true }
|
|
4920
6174
|
});
|
|
4921
6175
|
} catch (err) {
|
|
4922
|
-
|
|
6176
|
+
sendResult2(ws, false, errMessage(err));
|
|
4923
6177
|
}
|
|
4924
6178
|
break;
|
|
4925
6179
|
}
|
|
@@ -4942,9 +6196,9 @@ async function startWebUI(opts = {}) {
|
|
|
4942
6196
|
case "projects.add": {
|
|
4943
6197
|
const { root: addRoot, name: displayName } = msg.payload;
|
|
4944
6198
|
try {
|
|
4945
|
-
const resolved =
|
|
4946
|
-
await
|
|
4947
|
-
const stat2 = await
|
|
6199
|
+
const resolved = path10.resolve(addRoot);
|
|
6200
|
+
await fs10.access(resolved);
|
|
6201
|
+
const stat2 = await fs10.stat(resolved);
|
|
4948
6202
|
if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
|
|
4949
6203
|
const manifest = await loadManifest(globalConfigPath);
|
|
4950
6204
|
const existing = manifest.projects.find((p) => p.root === resolved);
|
|
@@ -4960,7 +6214,7 @@ async function startWebUI(opts = {}) {
|
|
|
4960
6214
|
});
|
|
4961
6215
|
break;
|
|
4962
6216
|
}
|
|
4963
|
-
const name = displayName?.trim() ||
|
|
6217
|
+
const name = displayName?.trim() || path10.basename(resolved);
|
|
4964
6218
|
const slug = generateProjectSlug(resolved);
|
|
4965
6219
|
await ensureProjectDataDir(slug, globalConfigPath);
|
|
4966
6220
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -4979,7 +6233,7 @@ async function startWebUI(opts = {}) {
|
|
|
4979
6233
|
send(ws, {
|
|
4980
6234
|
type: "projects.added",
|
|
4981
6235
|
payload: {
|
|
4982
|
-
name:
|
|
6236
|
+
name: path10.basename(addRoot),
|
|
4983
6237
|
root: addRoot,
|
|
4984
6238
|
slug: "",
|
|
4985
6239
|
message: errMessage(err)
|
|
@@ -4991,17 +6245,17 @@ async function startWebUI(opts = {}) {
|
|
|
4991
6245
|
case "projects.select": {
|
|
4992
6246
|
const { root: selRoot, name: selName } = msg.payload;
|
|
4993
6247
|
try {
|
|
4994
|
-
const resolved =
|
|
6248
|
+
const resolved = path10.resolve(selRoot);
|
|
4995
6249
|
try {
|
|
4996
|
-
await
|
|
4997
|
-
const stat2 = await
|
|
6250
|
+
await fs10.access(resolved);
|
|
6251
|
+
const stat2 = await fs10.stat(resolved);
|
|
4998
6252
|
if (!stat2.isDirectory()) throw new Error(`Not a directory: ${resolved}`);
|
|
4999
6253
|
} catch (err) {
|
|
5000
6254
|
send(ws, {
|
|
5001
6255
|
type: "projects.selected",
|
|
5002
6256
|
payload: {
|
|
5003
6257
|
root: selRoot,
|
|
5004
|
-
name: selName ||
|
|
6258
|
+
name: selName || path10.basename(selRoot),
|
|
5005
6259
|
message: `Cannot switch: ${errMessage(err)}`
|
|
5006
6260
|
}
|
|
5007
6261
|
});
|
|
@@ -5013,7 +6267,7 @@ async function startWebUI(opts = {}) {
|
|
|
5013
6267
|
entry.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
|
|
5014
6268
|
entry.lastWorkingDir = resolved;
|
|
5015
6269
|
} else {
|
|
5016
|
-
const name = selName?.trim() ||
|
|
6270
|
+
const name = selName?.trim() || path10.basename(resolved);
|
|
5017
6271
|
const slug = generateProjectSlug(resolved);
|
|
5018
6272
|
manifest.projects.push({
|
|
5019
6273
|
name,
|
|
@@ -5054,13 +6308,13 @@ async function startWebUI(opts = {}) {
|
|
|
5054
6308
|
});
|
|
5055
6309
|
} catch {
|
|
5056
6310
|
}
|
|
5057
|
-
const newSessionsDir =
|
|
5058
|
-
|
|
6311
|
+
const newSessionsDir = path10.join(
|
|
6312
|
+
path10.dirname(globalConfigPath),
|
|
5059
6313
|
"projects",
|
|
5060
6314
|
switchSlug,
|
|
5061
6315
|
"sessions"
|
|
5062
6316
|
);
|
|
5063
|
-
await
|
|
6317
|
+
await fs10.mkdir(newSessionsDir, { recursive: true });
|
|
5064
6318
|
const newSessionStore = new DefaultSessionStore2({ dir: newSessionsDir });
|
|
5065
6319
|
const oldSessionId = session.id;
|
|
5066
6320
|
try {
|
|
@@ -5092,8 +6346,9 @@ async function startWebUI(opts = {}) {
|
|
|
5092
6346
|
sessionId: session.id,
|
|
5093
6347
|
projectSlug: switchSlug,
|
|
5094
6348
|
projectRoot,
|
|
5095
|
-
projectName:
|
|
6349
|
+
projectName: path10.basename(projectRoot),
|
|
5096
6350
|
workingDir,
|
|
6351
|
+
clientType: "webui",
|
|
5097
6352
|
pid: process.pid,
|
|
5098
6353
|
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5099
6354
|
});
|
|
@@ -5103,8 +6358,8 @@ async function startWebUI(opts = {}) {
|
|
|
5103
6358
|
type: "projects.selected",
|
|
5104
6359
|
payload: {
|
|
5105
6360
|
root: resolved,
|
|
5106
|
-
name: selName ||
|
|
5107
|
-
message: `Switched to ${selName ||
|
|
6361
|
+
name: selName || path10.basename(resolved),
|
|
6362
|
+
message: `Switched to ${selName || path10.basename(resolved)}`
|
|
5108
6363
|
}
|
|
5109
6364
|
});
|
|
5110
6365
|
broadcast(clients, {
|
|
@@ -5127,7 +6382,7 @@ async function startWebUI(opts = {}) {
|
|
|
5127
6382
|
type: "projects.selected",
|
|
5128
6383
|
payload: {
|
|
5129
6384
|
root: selRoot,
|
|
5130
|
-
name: selName ||
|
|
6385
|
+
name: selName || path10.basename(selRoot),
|
|
5131
6386
|
message: errMessage(err)
|
|
5132
6387
|
}
|
|
5133
6388
|
});
|
|
@@ -5138,17 +6393,17 @@ async function startWebUI(opts = {}) {
|
|
|
5138
6393
|
case "working_dir.set": {
|
|
5139
6394
|
const { path: newPath } = msg.payload;
|
|
5140
6395
|
try {
|
|
5141
|
-
const resolved =
|
|
5142
|
-
if (!resolved.startsWith(projectRoot +
|
|
5143
|
-
|
|
6396
|
+
const resolved = path10.resolve(projectRoot, newPath);
|
|
6397
|
+
if (!resolved.startsWith(projectRoot + path10.sep) && resolved !== projectRoot) {
|
|
6398
|
+
sendResult2(ws, false, `Path must stay inside the project root: ${projectRoot}`);
|
|
5144
6399
|
break;
|
|
5145
6400
|
}
|
|
5146
6401
|
try {
|
|
5147
|
-
await
|
|
5148
|
-
const stat2 = await
|
|
6402
|
+
await fs10.access(resolved);
|
|
6403
|
+
const stat2 = await fs10.stat(resolved);
|
|
5149
6404
|
if (!stat2.isDirectory()) throw new Error("Not a directory");
|
|
5150
6405
|
} catch {
|
|
5151
|
-
|
|
6406
|
+
sendResult2(ws, false, `Directory not found or not accessible: ${resolved}`);
|
|
5152
6407
|
break;
|
|
5153
6408
|
}
|
|
5154
6409
|
workingDir = resolved;
|
|
@@ -5157,9 +6412,9 @@ async function startWebUI(opts = {}) {
|
|
|
5157
6412
|
type: "working_dir.changed",
|
|
5158
6413
|
payload: { cwd: resolved, projectRoot }
|
|
5159
6414
|
});
|
|
5160
|
-
|
|
6415
|
+
sendResult2(ws, true, `Working directory set to ${resolved}`);
|
|
5161
6416
|
} catch (err) {
|
|
5162
|
-
|
|
6417
|
+
sendResult2(ws, false, errMessage(err));
|
|
5163
6418
|
}
|
|
5164
6419
|
break;
|
|
5165
6420
|
}
|
|
@@ -5169,31 +6424,31 @@ async function startWebUI(opts = {}) {
|
|
|
5169
6424
|
msg.payload,
|
|
5170
6425
|
logger
|
|
5171
6426
|
);
|
|
5172
|
-
|
|
6427
|
+
sendResult2(ws, result.success, result.message);
|
|
5173
6428
|
break;
|
|
5174
6429
|
}
|
|
5175
6430
|
// ── Mailbox operations — project-level inter-agent messaging ────
|
|
5176
6431
|
case "mailbox.messages":
|
|
5177
6432
|
return handleMailboxMessages(
|
|
5178
6433
|
ws,
|
|
5179
|
-
{ projectRoot, globalRoot:
|
|
6434
|
+
{ projectRoot, globalRoot: path10.dirname(globalConfigPath) },
|
|
5180
6435
|
msg.payload
|
|
5181
6436
|
);
|
|
5182
6437
|
case "mailbox.agents":
|
|
5183
6438
|
return handleMailboxAgents(
|
|
5184
6439
|
ws,
|
|
5185
|
-
{ projectRoot, globalRoot:
|
|
6440
|
+
{ projectRoot, globalRoot: path10.dirname(globalConfigPath) },
|
|
5186
6441
|
msg.payload
|
|
5187
6442
|
);
|
|
5188
6443
|
case "mailbox.clear":
|
|
5189
6444
|
return handleMailboxClear(
|
|
5190
6445
|
ws,
|
|
5191
|
-
{ projectRoot, globalRoot:
|
|
6446
|
+
{ projectRoot, globalRoot: path10.dirname(globalConfigPath) }
|
|
5192
6447
|
);
|
|
5193
6448
|
case "mailbox.purge":
|
|
5194
6449
|
return handleMailboxPurge(
|
|
5195
6450
|
ws,
|
|
5196
|
-
{ projectRoot, globalRoot:
|
|
6451
|
+
{ projectRoot, globalRoot: path10.dirname(globalConfigPath) },
|
|
5197
6452
|
msg.payload
|
|
5198
6453
|
);
|
|
5199
6454
|
// ── Brain — status, autonomy ceiling, direct decision support ───
|
|
@@ -5207,7 +6462,7 @@ async function startWebUI(opts = {}) {
|
|
|
5207
6462
|
const level = msg.payload?.level ?? "";
|
|
5208
6463
|
const valid = ["off", "low", "medium", "high", "all"];
|
|
5209
6464
|
if (!valid.includes(level)) {
|
|
5210
|
-
|
|
6465
|
+
sendResult2(ws, false, `Unknown risk level "${level}". Use: ${valid.join(", ")}.`);
|
|
5211
6466
|
break;
|
|
5212
6467
|
}
|
|
5213
6468
|
brainSettings.maxAutoRisk = level;
|
|
@@ -5220,7 +6475,7 @@ async function startWebUI(opts = {}) {
|
|
|
5220
6475
|
case "brain.ask": {
|
|
5221
6476
|
const question = msg.payload?.question?.trim();
|
|
5222
6477
|
if (!question) {
|
|
5223
|
-
|
|
6478
|
+
sendResult2(ws, false, "Usage: /brain ask <question>");
|
|
5224
6479
|
break;
|
|
5225
6480
|
}
|
|
5226
6481
|
try {
|
|
@@ -5233,7 +6488,7 @@ async function startWebUI(opts = {}) {
|
|
|
5233
6488
|
});
|
|
5234
6489
|
send(ws, { type: "brain.answer", payload: { question, decision } });
|
|
5235
6490
|
} catch (err) {
|
|
5236
|
-
|
|
6491
|
+
sendResult2(ws, false, `Brain consultation failed: ${errMessage(err)}`);
|
|
5237
6492
|
}
|
|
5238
6493
|
break;
|
|
5239
6494
|
}
|
|
@@ -5260,14 +6515,28 @@ async function startWebUI(opts = {}) {
|
|
|
5260
6515
|
broadcast,
|
|
5261
6516
|
clients
|
|
5262
6517
|
});
|
|
6518
|
+
const watcherMetrics = {
|
|
6519
|
+
fileChangesDetected: 0,
|
|
6520
|
+
filesProcessed: 0,
|
|
6521
|
+
broadcastsSent: 0,
|
|
6522
|
+
debounceResets: 0,
|
|
6523
|
+
totalDebounceDelayMs: 0,
|
|
6524
|
+
activeProjects: 0,
|
|
6525
|
+
averageDebounceDelayMs: 0,
|
|
6526
|
+
watcherActive: false
|
|
6527
|
+
};
|
|
5263
6528
|
const httpServer = createHttpServer({
|
|
5264
6529
|
host: wsHost,
|
|
5265
|
-
distDir:
|
|
6530
|
+
distDir: path10.resolve(import.meta.dirname, "../../dist"),
|
|
5266
6531
|
wsPort,
|
|
5267
6532
|
globalRoot: wpaths.globalRoot,
|
|
5268
|
-
apiToken: wsToken
|
|
6533
|
+
apiToken: wsToken,
|
|
6534
|
+
watcherMetrics,
|
|
6535
|
+
onFleetPing: () => {
|
|
6536
|
+
void fleetBroadcast?.();
|
|
6537
|
+
}
|
|
5269
6538
|
});
|
|
5270
|
-
const registryBaseDir =
|
|
6539
|
+
const registryBaseDir = path10.dirname(globalConfigPath);
|
|
5271
6540
|
httpServer.listen(httpPort, wsHost, () => {
|
|
5272
6541
|
const openUrl = `http://${wsHost}:${httpPort}`;
|
|
5273
6542
|
console.log(`[WebUI] HTTP server running on ${openUrl}`);
|
|
@@ -5279,7 +6548,7 @@ async function startWebUI(opts = {}) {
|
|
|
5279
6548
|
wsPort,
|
|
5280
6549
|
host: wsHost,
|
|
5281
6550
|
projectRoot,
|
|
5282
|
-
projectName:
|
|
6551
|
+
projectName: path10.basename(projectRoot) || projectRoot,
|
|
5283
6552
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5284
6553
|
url: `http://${wsHost}:${httpPort}`
|
|
5285
6554
|
},
|
|
@@ -5306,6 +6575,10 @@ async function startWebUI(opts = {}) {
|
|
|
5306
6575
|
// reality. Crash exits are healed by the next register()/list() prune pass.
|
|
5307
6576
|
onShutdown: () => {
|
|
5308
6577
|
brainMonitor.stop();
|
|
6578
|
+
if (disposeEvents) {
|
|
6579
|
+
disposeEvents();
|
|
6580
|
+
disposeEvents = null;
|
|
6581
|
+
}
|
|
5309
6582
|
if (eternalSubscription) {
|
|
5310
6583
|
eternalSubscription.dispose();
|
|
5311
6584
|
eternalSubscription = null;
|