@wrongstack/webui 0.1.7

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.
@@ -0,0 +1,1171 @@
1
+ // src/server/index.ts
2
+ import * as fs from "fs/promises";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { WebSocketServer, WebSocket } from "ws";
6
+ import {
7
+ Agent,
8
+ AutoCompactionMiddleware,
9
+ Container,
10
+ Context,
11
+ DefaultConfigLoader,
12
+ DefaultConfigStore,
13
+ DefaultErrorHandler,
14
+ DefaultLogger,
15
+ DefaultMemoryStore,
16
+ DefaultModelsRegistry,
17
+ DefaultPathResolver,
18
+ DefaultPermissionPolicy,
19
+ DefaultRetryPolicy,
20
+ DefaultSecretScrubber,
21
+ DefaultSecretVault,
22
+ DefaultSessionStore,
23
+ DefaultSkillLoader,
24
+ DefaultModeStore,
25
+ DefaultSystemPromptBuilder,
26
+ DefaultTokenCounter,
27
+ EventBus,
28
+ HybridCompactor,
29
+ ProviderRegistry,
30
+ ToolRegistry,
31
+ TOKENS,
32
+ createDefaultPipelines,
33
+ atomicWrite,
34
+ migratePlaintextSecrets,
35
+ resolveWstackPaths
36
+ } from "@wrongstack/core";
37
+ import {
38
+ buildProviderFactoriesFromRegistry,
39
+ makeProviderFromConfig
40
+ } from "@wrongstack/providers";
41
+ import { builtinTools } from "@wrongstack/tools/builtin";
42
+ import { rememberTool, forgetTool } from "@wrongstack/tools";
43
+ async function bootConfig() {
44
+ const cwd = process.cwd();
45
+ const pathResolver = new DefaultPathResolver(cwd);
46
+ const projectRoot = pathResolver.projectRoot;
47
+ const userHome = os.homedir();
48
+ const wpaths = resolveWstackPaths({ projectRoot, userHome });
49
+ await fs.mkdir(wpaths.globalRoot, { recursive: true });
50
+ await fs.mkdir(wpaths.projectDir, { recursive: true });
51
+ await fs.mkdir(wpaths.projectSessions, { recursive: true });
52
+ const vault = new DefaultSecretVault({ keyFile: wpaths.secretsKey });
53
+ for (const file of [wpaths.globalConfig, wpaths.projectLocalConfig]) {
54
+ try {
55
+ const { migrated } = await migratePlaintextSecrets(file, vault);
56
+ if (migrated > 0) {
57
+ process.stderr.write(`[WebUI] Encrypted ${migrated} plaintext secret(s) in ${file}
58
+ `);
59
+ }
60
+ } catch {
61
+ }
62
+ }
63
+ const configLoader = new DefaultConfigLoader({ paths: wpaths, vault });
64
+ const config = await configLoader.load({ cliFlags: {} });
65
+ const logger = new DefaultLogger({
66
+ level: config.log?.level ?? "info",
67
+ file: wpaths.logFile
68
+ });
69
+ return {
70
+ config,
71
+ vault,
72
+ globalConfigPath: wpaths.globalConfig,
73
+ projectRoot,
74
+ wpaths,
75
+ logger
76
+ };
77
+ }
78
+ function patchConfig(config, updates) {
79
+ return Object.freeze({ ...config, ...updates });
80
+ }
81
+ async function startWebUI(opts = {}) {
82
+ const wsPort = opts.wsPort ?? 3457;
83
+ const wsHost = opts.wsHost ?? "127.0.0.1";
84
+ console.log("[WebUI] Starting backend services...");
85
+ const boot = await bootConfig();
86
+ const { config: baseConfig, vault, globalConfigPath, projectRoot, wpaths, logger } = boot;
87
+ let config = baseConfig;
88
+ console.log("[WebUI] Config loaded:", config.provider, "/", config.model);
89
+ const modelsRegistry = new DefaultModelsRegistry({
90
+ cacheFile: wpaths.modelsCache,
91
+ ttlSeconds: 24 * 3600
92
+ });
93
+ const container = new Container();
94
+ const configStore = new DefaultConfigStore(config);
95
+ container.bind(TOKENS.ConfigStore, () => configStore);
96
+ container.bind(TOKENS.Logger, () => logger);
97
+ container.bind(TOKENS.SecretScrubber, () => new DefaultSecretScrubber());
98
+ container.bind(TOKENS.RetryPolicy, () => new DefaultRetryPolicy());
99
+ container.bind(TOKENS.ErrorHandler, () => new DefaultErrorHandler());
100
+ container.bind(TOKENS.ModelsRegistry, () => modelsRegistry);
101
+ container.bind(TOKENS.PermissionPolicy, () => new DefaultPermissionPolicy({
102
+ trustFile: wpaths.projectTrust,
103
+ yolo: false,
104
+ promptDelegate: void 0
105
+ }));
106
+ const providerRegistry = new ProviderRegistry();
107
+ try {
108
+ const factories = await buildProviderFactoriesFromRegistry({
109
+ registry: modelsRegistry,
110
+ log: logger
111
+ });
112
+ for (const f of factories) providerRegistry.register(f);
113
+ console.log("[WebUI] Provider registry loaded:", providerRegistry.list().length, "providers");
114
+ } catch (err) {
115
+ console.warn("[WebUI] Failed to load provider registry:", err);
116
+ }
117
+ const toolRegistry = new ToolRegistry();
118
+ for (const t of builtinTools) toolRegistry.register(t);
119
+ const memoryStore = new DefaultMemoryStore({ paths: wpaths });
120
+ if (config.features.memory) {
121
+ toolRegistry.register(rememberTool(memoryStore));
122
+ toolRegistry.register(forgetTool(memoryStore));
123
+ }
124
+ console.log("[WebUI] Tool registry loaded:", toolRegistry.list().length, "tools");
125
+ const events = new EventBus();
126
+ events.setLogger(logger);
127
+ const sessionStore = new DefaultSessionStore({ dir: wpaths.projectSessions });
128
+ let session = await sessionStore.create({
129
+ id: "",
130
+ title: "",
131
+ model: config.model,
132
+ provider: config.provider
133
+ });
134
+ let sessionStartedAt = Date.now();
135
+ console.log("[WebUI] Session created:", session.id);
136
+ const tokenCounter = new DefaultTokenCounter({
137
+ registry: modelsRegistry,
138
+ providerId: config.provider
139
+ });
140
+ const modeStore = new DefaultModeStore({ directory: wpaths.configDir });
141
+ const activeMode = await modeStore.getActiveMode();
142
+ let modeId = activeMode?.id ?? "default";
143
+ const modePrompt = activeMode?.prompt ?? "";
144
+ const resolvedModel = await modelsRegistry.getModel(config.provider, config.model);
145
+ const modelCapabilities = resolvedModel?.capabilities ? {
146
+ maxContextTokens: resolvedModel.capabilities.maxContext,
147
+ supportsTools: resolvedModel.capabilities.tools,
148
+ supportsVision: resolvedModel.capabilities.vision,
149
+ supportsReasoning: resolvedModel.capabilities.reasoning
150
+ } : void 0;
151
+ const skillLoader = config.features.skills ? new DefaultSkillLoader({ paths: wpaths }) : void 0;
152
+ const systemPromptBuilder = new DefaultSystemPromptBuilder({
153
+ memoryStore,
154
+ skillLoader,
155
+ modeStore,
156
+ modeId,
157
+ modePrompt,
158
+ modelCapabilities
159
+ });
160
+ const systemPrompt = await systemPromptBuilder.build({
161
+ cwd: projectRoot,
162
+ projectRoot,
163
+ tools: toolRegistry.list(),
164
+ provider: config.provider,
165
+ model: config.model
166
+ });
167
+ const providerConfig = config.providers?.[config.provider] ?? {
168
+ type: config.provider,
169
+ apiKey: config.apiKey,
170
+ baseUrl: config.baseUrl
171
+ };
172
+ let provider;
173
+ try {
174
+ const cfgWithType = { ...providerConfig, type: config.provider };
175
+ if (config.features.modelsRegistry && providerRegistry.has(config.provider)) {
176
+ provider = providerRegistry.create(cfgWithType);
177
+ } else {
178
+ provider = makeProviderFromConfig(config.provider, cfgWithType);
179
+ }
180
+ } catch (err) {
181
+ console.error("[WebUI] Failed to create provider:", err);
182
+ throw err;
183
+ }
184
+ const context = new Context({
185
+ systemPrompt,
186
+ provider,
187
+ session,
188
+ signal: new AbortController().signal,
189
+ tokenCounter,
190
+ cwd: projectRoot,
191
+ projectRoot,
192
+ model: config.model
193
+ });
194
+ const pipelines = createDefaultPipelines();
195
+ const compactor = new HybridCompactor({
196
+ preserveK: config.context?.preserveK ?? 20,
197
+ eliseThreshold: config.context?.eliseThreshold ?? 0.7
198
+ });
199
+ if (config.context?.autoCompact !== false) {
200
+ const autoCompactor = new AutoCompactionMiddleware(
201
+ compactor,
202
+ 2e5,
203
+ (ctx) => {
204
+ let total = 0;
205
+ for (const m of ctx.messages) {
206
+ if (typeof m.content === "string") total += Math.ceil(m.content.length / 4);
207
+ }
208
+ return total;
209
+ },
210
+ { warn: 0.7, soft: 0.85, hard: 0.95 },
211
+ { events }
212
+ );
213
+ pipelines.contextWindow.use({ name: "AutoCompaction", handler: autoCompactor.handler() });
214
+ }
215
+ const agent = new Agent({
216
+ container,
217
+ tools: toolRegistry,
218
+ providers: providerRegistry,
219
+ events,
220
+ pipelines,
221
+ context,
222
+ maxIterations: config.tools?.maxIterations ?? 100,
223
+ iterationTimeoutMs: config.tools?.iterationTimeoutMs ?? 12e4,
224
+ executionStrategy: config.tools?.defaultExecutionStrategy ?? "sequential",
225
+ perIterationOutputCapBytes: config.tools?.perIterationOutputCapBytes ?? 5e4,
226
+ confirmAwaiter: void 0
227
+ });
228
+ console.log("[WebUI] Agent initialized");
229
+ async function sessionStartPayload() {
230
+ let maxContext = 0;
231
+ let inputCost = 0;
232
+ let outputCost = 0;
233
+ let cacheReadCost = 0;
234
+ try {
235
+ const m = await modelsRegistry.getModel(config.provider, config.model);
236
+ maxContext = m?.capabilities?.maxContext ?? 0;
237
+ const cost = m?.cost;
238
+ inputCost = cost?.input ?? 0;
239
+ outputCost = cost?.output ?? 0;
240
+ cacheReadCost = cost?.cache_read ?? 0;
241
+ } catch {
242
+ }
243
+ return {
244
+ sessionId: session.id,
245
+ model: config.model,
246
+ provider: config.provider,
247
+ maxContext,
248
+ inputCost,
249
+ outputCost,
250
+ cacheReadCost,
251
+ projectName: path.basename(projectRoot) || projectRoot,
252
+ cwd: projectRoot,
253
+ mode: modeId
254
+ };
255
+ }
256
+ const wssPrimary = new WebSocketServer({ port: wsPort, host: wsHost });
257
+ const wssSecondary = wsHost === "127.0.0.1" ? new WebSocketServer({ port: wsPort, host: "::1" }) : null;
258
+ const clients = /* @__PURE__ */ new Map();
259
+ let abortController = null;
260
+ console.log(
261
+ `[WebUI] WebSocket server running on ws://${wsHost}:${wsPort}` + (wssSecondary ? ` (and ws://[::1]:${wsPort})` : "")
262
+ );
263
+ function setupEvents() {
264
+ events.on("iteration.started", (e) => {
265
+ broadcast({
266
+ type: "iteration.started",
267
+ payload: { index: e.index, maxIterations: config.tools?.maxIterations ?? 100 }
268
+ });
269
+ });
270
+ events.on("provider.text_delta", (e) => {
271
+ broadcast({ type: "provider.text_delta", payload: { text: e.text, messageId: "current" } });
272
+ });
273
+ events.on("tool.started", (e) => {
274
+ broadcast({
275
+ type: "tool.started",
276
+ payload: { id: e.id, name: e.name, input: e.input, messageId: `tool_${e.id}` }
277
+ });
278
+ });
279
+ events.on("tool.progress", (e) => {
280
+ broadcast({
281
+ type: "tool.progress",
282
+ payload: {
283
+ id: e.id,
284
+ name: e.name,
285
+ eventType: e.event.type,
286
+ text: e.event.text
287
+ }
288
+ });
289
+ });
290
+ events.on("tool.executed", (e) => {
291
+ broadcast({
292
+ type: "tool.executed",
293
+ payload: {
294
+ // Forward the tool_use id so frontend can correlate with the
295
+ // matching tool.started bubble — without this, parallel tool calls
296
+ // all stay stuck on "Running…" because the frontend can't tell
297
+ // which bubble this result belongs to.
298
+ id: e.id,
299
+ name: e.name,
300
+ durationMs: e.durationMs,
301
+ ok: e.ok,
302
+ input: e.input,
303
+ output: e.output
304
+ }
305
+ });
306
+ broadcast({
307
+ type: "todos.updated",
308
+ payload: { todos: [...context.todos] }
309
+ });
310
+ });
311
+ events.on("provider.response", (e) => {
312
+ broadcast({
313
+ type: "provider.response",
314
+ payload: {
315
+ usage: e.usage,
316
+ stopReason: e.stopReason,
317
+ messageId: "current"
318
+ }
319
+ });
320
+ });
321
+ events.on("error", (e) => {
322
+ broadcast({
323
+ type: "error",
324
+ payload: {
325
+ phase: e.phase,
326
+ message: e.err instanceof Error ? e.err.message : String(e.err)
327
+ }
328
+ });
329
+ });
330
+ }
331
+ function send(ws, msg) {
332
+ if (ws.readyState === WebSocket.OPEN) {
333
+ ws.send(JSON.stringify(msg));
334
+ }
335
+ }
336
+ function broadcast(msg) {
337
+ const data = JSON.stringify(msg);
338
+ for (const [ws] of clients) {
339
+ if (ws.readyState === WebSocket.OPEN) {
340
+ ws.send(data);
341
+ }
342
+ }
343
+ }
344
+ const handleConnection = (ws) => {
345
+ const client = { ws, sessionId: session.id };
346
+ clients.set(ws, client);
347
+ console.log("[WebUI] Client connected, total:", clients.size);
348
+ void sessionStartPayload().then((payload) => {
349
+ send(ws, { type: "session.start", payload });
350
+ });
351
+ ws.on("message", async (data) => {
352
+ try {
353
+ const msg = JSON.parse(data.toString());
354
+ await handleMessage(ws, client, msg);
355
+ } catch (err) {
356
+ console.error("[WebUI] Failed to parse message", err);
357
+ }
358
+ });
359
+ ws.on("close", () => {
360
+ clients.delete(ws);
361
+ console.log("[WebUI] Client disconnected, total:", clients.size);
362
+ });
363
+ ws.on("error", (err) => {
364
+ console.warn("[WebUI] Client socket error:", err.message);
365
+ });
366
+ };
367
+ let eventsArmed = false;
368
+ const armOnce = (label) => {
369
+ if (eventsArmed) return;
370
+ eventsArmed = true;
371
+ console.log(`[WebUI] Backend ready (${label})`);
372
+ setupEvents();
373
+ };
374
+ wssPrimary.on("listening", () => armOnce(`${wsHost}:${wsPort}`));
375
+ wssPrimary.on("connection", handleConnection);
376
+ wssPrimary.on("error", (err) => {
377
+ console.error(`[WebUI] Primary WS server error (${wsHost}):`, err);
378
+ });
379
+ if (wssSecondary) {
380
+ wssSecondary.on("listening", () => armOnce(`::1:${wsPort}`));
381
+ wssSecondary.on("connection", handleConnection);
382
+ wssSecondary.on("error", (err) => {
383
+ if (err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL") {
384
+ console.warn("[WebUI] IPv6 loopback not available, v4-only:", err.code);
385
+ } else {
386
+ console.error("[WebUI] Secondary WS server error (::1):", err);
387
+ }
388
+ });
389
+ }
390
+ async function handleMessage(ws, client, msg) {
391
+ switch (msg.type) {
392
+ case "user_message": {
393
+ const content = msg.payload.content;
394
+ abortController?.abort();
395
+ abortController = new AbortController();
396
+ try {
397
+ const result = await agent.run(content, { signal: abortController.signal });
398
+ send(ws, {
399
+ type: "run.result",
400
+ payload: {
401
+ status: result.status,
402
+ iterations: result.iterations,
403
+ finalText: result.finalText,
404
+ error: result.error ? {
405
+ code: result.error.code,
406
+ message: result.error.message,
407
+ recoverable: result.error.recoverable
408
+ } : void 0
409
+ }
410
+ });
411
+ } catch (err) {
412
+ send(ws, {
413
+ type: "error",
414
+ payload: {
415
+ phase: "agent.run",
416
+ message: err instanceof Error ? err.message : String(err)
417
+ }
418
+ });
419
+ } finally {
420
+ abortController = null;
421
+ }
422
+ break;
423
+ }
424
+ case "abort":
425
+ abortController?.abort();
426
+ broadcast({ type: "error", payload: { phase: "abort", message: "User aborted" } });
427
+ break;
428
+ case "ping":
429
+ send(ws, { type: "pong", payload: {} });
430
+ break;
431
+ case "session.new": {
432
+ session = await sessionStore.create({
433
+ id: "",
434
+ title: "",
435
+ model: config.model,
436
+ provider: config.provider
437
+ });
438
+ context.session = session;
439
+ context.state.replaceMessages([]);
440
+ context.state.replaceTodos([]);
441
+ context.readFiles.clear();
442
+ context.fileMtimes.clear();
443
+ tokenCounter.reset();
444
+ sessionStartedAt = Date.now();
445
+ broadcast({ type: "session.start", payload: await sessionStartPayload() });
446
+ break;
447
+ }
448
+ case "context.clear": {
449
+ context.state.replaceMessages([]);
450
+ context.state.replaceTodos([]);
451
+ context.readFiles.clear();
452
+ context.fileMtimes.clear();
453
+ tokenCounter.reset();
454
+ sendResult(ws, true, "Context cleared");
455
+ broadcast({
456
+ type: "session.start",
457
+ payload: { ...await sessionStartPayload(), reset: true }
458
+ });
459
+ break;
460
+ }
461
+ case "context.debug": {
462
+ const estimate = (s) => Math.ceil(s.length / 4);
463
+ const stringifyContent = (c) => {
464
+ if (typeof c === "string") return c;
465
+ try {
466
+ return JSON.stringify(c);
467
+ } catch {
468
+ return String(c);
469
+ }
470
+ };
471
+ const sysTokens = context.systemPrompt.reduce(
472
+ (acc, b) => acc + estimate(b.text ?? ""),
473
+ 0
474
+ );
475
+ const tools = toolRegistry.list();
476
+ const toolBreakdown = tools.map((t) => {
477
+ const schema = t.inputSchema ?? {};
478
+ const desc = t.description ?? "";
479
+ return {
480
+ name: t.name,
481
+ tokens: estimate(t.name) + estimate(desc) + estimate(stringifyContent(schema))
482
+ };
483
+ });
484
+ const toolTokens = toolBreakdown.reduce((a, b) => a + b.tokens, 0);
485
+ const messageBreakdown = context.messages.map((m, i) => {
486
+ let tk = 0;
487
+ if (typeof m.content === "string") {
488
+ tk = estimate(m.content);
489
+ } else if (Array.isArray(m.content)) {
490
+ for (const b of m.content) {
491
+ if (b.type === "text") tk += estimate(b.text ?? "");
492
+ else if (b.type === "tool_use") tk += estimate(stringifyContent(b.input));
493
+ else if (b.type === "tool_result") tk += estimate(stringifyContent(b.content));
494
+ else tk += estimate(stringifyContent(b));
495
+ }
496
+ }
497
+ return {
498
+ index: i,
499
+ role: m.role,
500
+ tokens: tk,
501
+ preview: typeof m.content === "string" ? m.content.slice(0, 60) : Array.isArray(m.content) ? m.content.map(
502
+ (b) => b.type === "text" ? (b.text ?? "").slice(0, 40) : b.type === "tool_use" ? `[tool_use: ${b.name}]` : b.type === "tool_result" ? `[tool_result]` : `[${b.type}]`
503
+ ).join(" ").slice(0, 60) : ""
504
+ };
505
+ });
506
+ const msgTokens = messageBreakdown.reduce((a, b) => a + b.tokens, 0);
507
+ const total = sysTokens + toolTokens + msgTokens;
508
+ send(ws, {
509
+ type: "context.debug",
510
+ payload: {
511
+ total,
512
+ systemPrompt: sysTokens,
513
+ tools: { total: toolTokens, count: tools.length, breakdown: toolBreakdown },
514
+ messages: { total: msgTokens, count: context.messages.length, breakdown: messageBreakdown }
515
+ }
516
+ });
517
+ break;
518
+ }
519
+ case "context.compact": {
520
+ const aggressive = !!msg.payload?.aggressive;
521
+ try {
522
+ const report = await compactor.compact(context, { aggressive });
523
+ send(ws, {
524
+ type: "context.compacted",
525
+ payload: {
526
+ before: report.before,
527
+ after: report.after,
528
+ saved: Math.max(0, report.before - report.after),
529
+ reductions: report.reductions
530
+ }
531
+ });
532
+ sendResult(
533
+ ws,
534
+ true,
535
+ `Compacted: ${report.before} \u2192 ${report.after} tokens (saved ~${Math.max(0, report.before - report.after)})`
536
+ );
537
+ } catch (err) {
538
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
539
+ }
540
+ break;
541
+ }
542
+ case "providers.list": {
543
+ const providers = await modelsRegistry.listProviders();
544
+ const savedIds = new Set(Object.keys(config.providers ?? {}));
545
+ send(ws, {
546
+ type: "provider.catalog",
547
+ payload: {
548
+ providers: providers.map((p) => ({
549
+ id: p.id,
550
+ name: p.name,
551
+ family: p.family,
552
+ apiBase: p.apiBase,
553
+ envVars: p.envVars,
554
+ modelCount: p.models.length,
555
+ hasApiKey: savedIds.has(p.id) || p.envVars.some((v) => !!process.env[v])
556
+ }))
557
+ }
558
+ });
559
+ break;
560
+ }
561
+ case "provider.models": {
562
+ const providerId = msg.payload.providerId;
563
+ const provider2 = await modelsRegistry.getProvider(providerId);
564
+ if (provider2) {
565
+ send(ws, {
566
+ type: "provider.models",
567
+ payload: {
568
+ provider: providerId,
569
+ models: provider2.models.map((m) => ({
570
+ id: m.id,
571
+ name: m.name,
572
+ releaseDate: m.release_date,
573
+ contextWindow: m.limit?.context,
574
+ inputCost: m.cost?.input,
575
+ outputCost: m.cost?.output,
576
+ capabilities: [
577
+ ...m.tool_call ? ["tools"] : [],
578
+ ...m.reasoning ? ["reasoning"] : []
579
+ ]
580
+ }))
581
+ }
582
+ });
583
+ }
584
+ break;
585
+ }
586
+ case "model.switch": {
587
+ const { provider: newProvider, model: newModel } = msg.payload;
588
+ try {
589
+ config = patchConfig(config, { provider: newProvider, model: newModel });
590
+ configStore.update({ provider: newProvider, model: newModel });
591
+ context.model = newModel;
592
+ const providerCfg = config.providers?.[newProvider] ?? { type: newProvider };
593
+ const newProv = providerRegistry.has(newProvider) ? providerRegistry.create({ ...providerCfg, type: newProvider }) : makeProviderFromConfig(newProvider, providerCfg);
594
+ context.provider = newProv;
595
+ try {
596
+ const raw = await fs.readFile(globalConfigPath, "utf8");
597
+ const parsed = JSON.parse(raw);
598
+ parsed.provider = newProvider;
599
+ parsed.model = newModel;
600
+ await atomicWrite(globalConfigPath, JSON.stringify(parsed, null, 2));
601
+ } catch (err) {
602
+ console.warn("[WebUI] Failed to save config:", err);
603
+ }
604
+ send(ws, {
605
+ type: "key.operation_result",
606
+ payload: { success: true, message: `Switched to ${newProvider} / ${newModel}` }
607
+ });
608
+ } catch (err) {
609
+ send(ws, {
610
+ type: "key.operation_result",
611
+ payload: {
612
+ success: false,
613
+ message: `Switch failed: ${err instanceof Error ? err.message : String(err)}`
614
+ }
615
+ });
616
+ break;
617
+ }
618
+ broadcast({ type: "session.start", payload: await sessionStartPayload() });
619
+ break;
620
+ }
621
+ case "key.add":
622
+ case "key.update": {
623
+ const { providerId, label, apiKey } = msg.payload;
624
+ await handleKeyUpsert(ws, providerId, label, apiKey);
625
+ break;
626
+ }
627
+ case "key.delete": {
628
+ const { providerId, label } = msg.payload;
629
+ await handleKeyDelete(ws, providerId, label);
630
+ break;
631
+ }
632
+ case "key.set_active": {
633
+ const { providerId, label } = msg.payload;
634
+ await handleKeySetActive(ws, providerId, label);
635
+ break;
636
+ }
637
+ case "provider.add": {
638
+ const p = msg.payload;
639
+ await handleProviderAdd(ws, p);
640
+ break;
641
+ }
642
+ case "provider.remove": {
643
+ const { providerId } = msg.payload;
644
+ await handleProviderRemove(ws, providerId);
645
+ break;
646
+ }
647
+ case "providers.saved": {
648
+ try {
649
+ const providers = await loadSavedProviders();
650
+ send(ws, {
651
+ type: "providers.saved",
652
+ payload: {
653
+ providers: Object.entries(providers).map(([id, cfg]) => ({
654
+ id,
655
+ family: cfg.family,
656
+ baseUrl: cfg.baseUrl,
657
+ apiKeys: normalizeKeys(cfg).map((k) => ({
658
+ label: k.label,
659
+ maskedKey: maskedKey(k.apiKey),
660
+ isActive: k.label === cfg.activeKey,
661
+ createdAt: k.createdAt ?? ""
662
+ }))
663
+ }))
664
+ }
665
+ });
666
+ } catch {
667
+ send(ws, { type: "providers.saved", payload: { providers: [] } });
668
+ }
669
+ break;
670
+ }
671
+ case "sessions.list": {
672
+ const limit = msg.payload?.limit ?? 50;
673
+ try {
674
+ const list = await sessionStore.list(limit);
675
+ send(ws, {
676
+ type: "sessions.list",
677
+ payload: {
678
+ sessions: list.map((s) => ({
679
+ id: s.id,
680
+ title: s.title,
681
+ startedAt: s.startedAt,
682
+ model: s.model,
683
+ provider: s.provider,
684
+ tokenTotal: s.tokenTotal,
685
+ isCurrent: s.id === session.id
686
+ }))
687
+ }
688
+ });
689
+ } catch (err) {
690
+ send(ws, {
691
+ type: "sessions.list",
692
+ payload: { sessions: [], error: err instanceof Error ? err.message : String(err) }
693
+ });
694
+ }
695
+ break;
696
+ }
697
+ case "session.delete": {
698
+ const { id } = msg.payload;
699
+ try {
700
+ if (id === session.id) {
701
+ sendResult(ws, false, "Cannot delete the active session");
702
+ break;
703
+ }
704
+ await sessionStore.delete(id);
705
+ sendResult(ws, true, `Session ${id} deleted`);
706
+ } catch (err) {
707
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
708
+ }
709
+ break;
710
+ }
711
+ case "session.resume": {
712
+ const { id } = msg.payload;
713
+ try {
714
+ if (id === session.id) {
715
+ sendResult(ws, false, "Session is already active");
716
+ break;
717
+ }
718
+ const resumed = await sessionStore.resume(id);
719
+ try {
720
+ await session.close();
721
+ } catch {
722
+ }
723
+ session = resumed.writer;
724
+ context.session = session;
725
+ context.state.replaceMessages(resumed.data.messages);
726
+ context.readFiles.clear();
727
+ context.fileMtimes.clear();
728
+ tokenCounter.reset();
729
+ tokenCounter.account(resumed.data.usage, config.model);
730
+ sessionStartedAt = Date.now();
731
+ broadcast({
732
+ type: "session.start",
733
+ payload: {
734
+ ...await sessionStartPayload(),
735
+ reset: true,
736
+ replayMessages: resumed.data.messages,
737
+ replayUsage: resumed.data.usage
738
+ }
739
+ });
740
+ sendResult(ws, true, `Resumed session ${id}`);
741
+ } catch (err) {
742
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
743
+ }
744
+ break;
745
+ }
746
+ case "session.save": {
747
+ sendResult(ws, true, `Session ${session.id} is auto-saved`);
748
+ break;
749
+ }
750
+ case "tools.list": {
751
+ const list = toolRegistry.list().map((t) => {
752
+ const schema = t.inputSchema ?? {};
753
+ const params = schema.properties ? Object.keys(schema.properties) : [];
754
+ return {
755
+ name: t.name,
756
+ description: t.description ?? "",
757
+ params
758
+ };
759
+ });
760
+ send(ws, { type: "tools.list", payload: { tools: list } });
761
+ break;
762
+ }
763
+ case "memory.list": {
764
+ try {
765
+ const text = await memoryStore.readAll();
766
+ send(ws, { type: "memory.list", payload: { text } });
767
+ } catch (err) {
768
+ send(ws, {
769
+ type: "memory.list",
770
+ payload: { text: "", error: err instanceof Error ? err.message : String(err) }
771
+ });
772
+ }
773
+ break;
774
+ }
775
+ case "memory.remember": {
776
+ const { text, scope } = msg.payload;
777
+ try {
778
+ await memoryStore.remember(text, scope ?? "project-memory");
779
+ sendResult(ws, true, "Saved to memory");
780
+ } catch (err) {
781
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
782
+ }
783
+ break;
784
+ }
785
+ case "memory.forget": {
786
+ const { text, scope } = msg.payload;
787
+ try {
788
+ const removed = await memoryStore.forget(text, scope ?? "project-memory");
789
+ sendResult(ws, removed > 0, removed > 0 ? `Removed ${removed} entr${removed === 1 ? "y" : "ies"}` : "No matching entries");
790
+ } catch (err) {
791
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
792
+ }
793
+ break;
794
+ }
795
+ case "skills.list": {
796
+ if (!skillLoader) {
797
+ send(ws, { type: "skills.list", payload: { skills: [], enabled: false } });
798
+ break;
799
+ }
800
+ try {
801
+ const manifests = await skillLoader.list();
802
+ const entries = await skillLoader.listEntries();
803
+ const byName = new Map(entries.map((e) => [e.name, e]));
804
+ send(ws, {
805
+ type: "skills.list",
806
+ payload: {
807
+ enabled: true,
808
+ skills: manifests.map((m) => ({
809
+ name: m.name,
810
+ description: m.description,
811
+ version: m.version ?? "",
812
+ source: m.source,
813
+ path: m.path,
814
+ trigger: byName.get(m.name)?.trigger ?? "",
815
+ scope: byName.get(m.name)?.scope ?? []
816
+ }))
817
+ }
818
+ });
819
+ } catch (err) {
820
+ send(ws, {
821
+ type: "skills.list",
822
+ payload: { skills: [], enabled: true, error: err instanceof Error ? err.message : String(err) }
823
+ });
824
+ }
825
+ break;
826
+ }
827
+ case "diag.get": {
828
+ const usage = tokenCounter.total();
829
+ send(ws, {
830
+ type: "diag.get",
831
+ payload: {
832
+ provider: config.provider,
833
+ model: config.model,
834
+ cwd: projectRoot,
835
+ sessionId: session.id,
836
+ tools: {
837
+ count: toolRegistry.list().length,
838
+ names: toolRegistry.list().map((t) => t.name)
839
+ },
840
+ features: {
841
+ memory: !!config.features?.memory,
842
+ skills: !!config.features?.skills,
843
+ modelsRegistry: !!config.features?.modelsRegistry
844
+ },
845
+ mode: modeId ?? "default",
846
+ usage,
847
+ messages: context.messages.length,
848
+ todos: context.todos.length
849
+ }
850
+ });
851
+ break;
852
+ }
853
+ case "todos.get": {
854
+ send(ws, {
855
+ type: "todos.updated",
856
+ payload: { todos: [...context.todos] }
857
+ });
858
+ break;
859
+ }
860
+ case "todos.clear": {
861
+ context.todos.length = 0;
862
+ sendResult(ws, true, "Todos cleared");
863
+ broadcast({ type: "todos.updated", payload: { todos: [] } });
864
+ break;
865
+ }
866
+ case "files.list": {
867
+ const payload = msg.payload ?? {};
868
+ const query = (payload.query ?? "").toLowerCase();
869
+ const limit = payload.limit ?? 50;
870
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
871
+ ".git",
872
+ "node_modules",
873
+ "dist",
874
+ "build",
875
+ ".next",
876
+ ".turbo",
877
+ ".cache",
878
+ "target",
879
+ "coverage",
880
+ ".nyc_output",
881
+ "out",
882
+ ".pnpm-store",
883
+ ".parcel-cache"
884
+ ]);
885
+ const results = [];
886
+ async function walk(dir, rel, depth) {
887
+ if (depth > 8 || results.length >= 600) return;
888
+ let entries = [];
889
+ try {
890
+ entries = await fs.readdir(dir, { withFileTypes: true });
891
+ } catch {
892
+ return;
893
+ }
894
+ for (const e of entries) {
895
+ if (results.length >= 600) return;
896
+ if (e.name.startsWith(".") && e.name !== ".wrongstack" && e.name !== ".env.example") {
897
+ if (e.name !== ".gitignore" && e.name !== ".eslintrc" && e.name !== ".prettierrc") continue;
898
+ }
899
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
900
+ if (e.isDirectory()) {
901
+ if (SKIP_DIRS.has(e.name)) continue;
902
+ await walk(path.join(dir, e.name), childRel, depth + 1);
903
+ } else if (e.isFile()) {
904
+ results.push(childRel);
905
+ }
906
+ }
907
+ }
908
+ await walk(projectRoot, "", 0);
909
+ const scored = [];
910
+ for (const p of results) {
911
+ if (!query) {
912
+ scored.push({ path: p, score: 0 });
913
+ continue;
914
+ }
915
+ const lower = p.toLowerCase();
916
+ const base = lower.split("/").pop() ?? lower;
917
+ let score = 0;
918
+ if (base === query) score = 100;
919
+ else if (base.startsWith(query)) score = 60;
920
+ else if (lower.includes(query)) score = 20;
921
+ else continue;
922
+ score -= p.split("/").length;
923
+ scored.push({ path: p, score });
924
+ }
925
+ scored.sort((a, b) => b.score - a.score || a.path.localeCompare(b.path));
926
+ send(ws, {
927
+ type: "files.list",
928
+ payload: { files: scored.slice(0, limit).map((s) => s.path) }
929
+ });
930
+ break;
931
+ }
932
+ case "modes.list": {
933
+ try {
934
+ const modes = await modeStore.listModes();
935
+ const active = await modeStore.getActiveMode();
936
+ send(ws, {
937
+ type: "modes.list",
938
+ payload: {
939
+ modes: modes.map((m) => ({
940
+ id: m.id,
941
+ name: m.name,
942
+ description: m.description,
943
+ isActive: m.id === (active?.id ?? "default")
944
+ })),
945
+ activeId: active?.id ?? "default"
946
+ }
947
+ });
948
+ } catch (err) {
949
+ send(ws, {
950
+ type: "modes.list",
951
+ payload: { modes: [], activeId: "default", error: err instanceof Error ? err.message : String(err) }
952
+ });
953
+ }
954
+ break;
955
+ }
956
+ case "mode.switch": {
957
+ const { id } = msg.payload;
958
+ try {
959
+ if (id === "default") {
960
+ await modeStore.setActiveMode(null);
961
+ } else {
962
+ const found = await modeStore.getMode(id);
963
+ if (!found) throw new Error(`Unknown mode "${id}"`);
964
+ await modeStore.setActiveMode(id);
965
+ }
966
+ modeId = id;
967
+ sendResult(ws, true, `Switched to mode "${id}"`);
968
+ broadcast({
969
+ type: "session.start",
970
+ payload: { ...await sessionStartPayload() }
971
+ });
972
+ } catch (err) {
973
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
974
+ }
975
+ break;
976
+ }
977
+ case "stats.get": {
978
+ const usage = tokenCounter.total();
979
+ const cacheStats = tokenCounter.cacheStats();
980
+ const m = await modelsRegistry.getModel(config.provider, config.model).catch(() => null);
981
+ const inputCost = m?.cost?.input ?? 0;
982
+ const outputCost = m?.cost?.output ?? 0;
983
+ const cacheReadCost = m?.cost?.cache_read ?? 0;
984
+ const cost = (usage.input * inputCost + usage.output * outputCost + (usage.cacheRead ?? 0) * cacheReadCost) / 1e6;
985
+ send(ws, {
986
+ type: "stats.get",
987
+ payload: {
988
+ sessionId: session.id,
989
+ provider: config.provider,
990
+ model: config.model,
991
+ usage,
992
+ cache: cacheStats,
993
+ cost,
994
+ messages: context.messages.length,
995
+ readFiles: context.readFiles.size,
996
+ tools: toolRegistry.list().length,
997
+ elapsedMs: Date.now() - sessionStartedAt
998
+ }
999
+ });
1000
+ break;
1001
+ }
1002
+ }
1003
+ }
1004
+ async function loadSavedProviders() {
1005
+ try {
1006
+ const raw = await fs.readFile(globalConfigPath, "utf8");
1007
+ const parsed = JSON.parse(raw);
1008
+ return parsed.providers ?? {};
1009
+ } catch {
1010
+ return {};
1011
+ }
1012
+ }
1013
+ async function saveProviders(providers) {
1014
+ let parsed;
1015
+ try {
1016
+ const raw = await fs.readFile(globalConfigPath, "utf8");
1017
+ parsed = JSON.parse(raw);
1018
+ } catch {
1019
+ parsed = {};
1020
+ }
1021
+ parsed["providers"] = providers;
1022
+ await atomicWrite(globalConfigPath, JSON.stringify(parsed, null, 2), { mode: 384 });
1023
+ }
1024
+ function normalizeKeys(cfg) {
1025
+ if (Array.isArray(cfg.apiKeys) && cfg.apiKeys.length > 0) {
1026
+ return cfg.apiKeys.map((k) => ({ ...k }));
1027
+ }
1028
+ if (typeof cfg.apiKey === "string" && cfg.apiKey.length > 0) {
1029
+ return [{ label: "default", apiKey: cfg.apiKey, createdAt: "" }];
1030
+ }
1031
+ return [];
1032
+ }
1033
+ function writeKeysBack(cfg, keys) {
1034
+ if (keys.length === 0) {
1035
+ delete cfg.apiKeys;
1036
+ delete cfg.apiKey;
1037
+ delete cfg.activeKey;
1038
+ return;
1039
+ }
1040
+ cfg.apiKeys = keys;
1041
+ const active = keys.find((k) => k.label === cfg.activeKey) ?? keys[0];
1042
+ cfg.apiKey = active.apiKey;
1043
+ if (!cfg.activeKey || !keys.some((k) => k.label === cfg.activeKey)) {
1044
+ cfg.activeKey = active.label;
1045
+ }
1046
+ }
1047
+ function maskedKey(key) {
1048
+ if (!key) return "\u2014";
1049
+ if (key.length <= 8) return "\u2022".repeat(key.length);
1050
+ return `${key.slice(0, 4)}\u2026${key.slice(-4)}`;
1051
+ }
1052
+ function sendResult(ws, success, message) {
1053
+ send(ws, { type: "key.operation_result", payload: { success, message } });
1054
+ }
1055
+ async function handleKeyUpsert(ws, providerId, label, apiKey) {
1056
+ try {
1057
+ const providers = await loadSavedProviders();
1058
+ const existing = providers[providerId] ?? { type: providerId };
1059
+ const keys = normalizeKeys(existing);
1060
+ const idx = keys.findIndex((k) => k.label === label);
1061
+ const nowIso = (/* @__PURE__ */ new Date()).toISOString();
1062
+ if (idx >= 0) {
1063
+ keys[idx] = { ...keys[idx], apiKey, createdAt: nowIso };
1064
+ } else {
1065
+ keys.push({ label, apiKey, createdAt: nowIso });
1066
+ }
1067
+ writeKeysBack(existing, keys);
1068
+ if (!existing.activeKey) existing.activeKey = label;
1069
+ providers[providerId] = existing;
1070
+ await saveProviders(providers);
1071
+ sendResult(ws, true, `Key "${label}" saved for ${providerId}`);
1072
+ } catch (err) {
1073
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
1074
+ }
1075
+ }
1076
+ async function handleKeyDelete(ws, providerId, label) {
1077
+ try {
1078
+ const providers = await loadSavedProviders();
1079
+ const existing = providers[providerId];
1080
+ if (!existing) {
1081
+ sendResult(ws, false, `Provider "${providerId}" not found`);
1082
+ return;
1083
+ }
1084
+ const keys = normalizeKeys(existing).filter((k) => k.label !== label);
1085
+ if (keys.length === 0) {
1086
+ delete providers[providerId];
1087
+ } else {
1088
+ writeKeysBack(existing, keys);
1089
+ if (existing.activeKey === label) existing.activeKey = keys[0].label;
1090
+ providers[providerId] = existing;
1091
+ }
1092
+ await saveProviders(providers);
1093
+ sendResult(ws, true, `Key "${label}" deleted from ${providerId}`);
1094
+ } catch (err) {
1095
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
1096
+ }
1097
+ }
1098
+ async function handleKeySetActive(ws, providerId, label) {
1099
+ try {
1100
+ const providers = await loadSavedProviders();
1101
+ const existing = providers[providerId];
1102
+ if (!existing) {
1103
+ sendResult(ws, false, `Provider "${providerId}" not found`);
1104
+ return;
1105
+ }
1106
+ existing.activeKey = label;
1107
+ writeKeysBack(existing, normalizeKeys(existing));
1108
+ providers[providerId] = existing;
1109
+ await saveProviders(providers);
1110
+ sendResult(ws, true, `Active key for ${providerId} set to "${label}"`);
1111
+ } catch (err) {
1112
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
1113
+ }
1114
+ }
1115
+ async function handleProviderAdd(ws, payload) {
1116
+ try {
1117
+ const providers = await loadSavedProviders();
1118
+ if (providers[payload.id]) {
1119
+ sendResult(ws, false, `Provider "${payload.id}" already exists. Use key.add to add a key.`);
1120
+ return;
1121
+ }
1122
+ const newProv = {
1123
+ type: payload.id,
1124
+ family: payload.family,
1125
+ baseUrl: payload.baseUrl
1126
+ };
1127
+ if (payload.apiKey) {
1128
+ newProv.apiKeys = [{ label: "default", apiKey: payload.apiKey, createdAt: (/* @__PURE__ */ new Date()).toISOString() }];
1129
+ newProv.activeKey = "default";
1130
+ }
1131
+ providers[payload.id] = newProv;
1132
+ await saveProviders(providers);
1133
+ sendResult(ws, true, `Provider "${payload.id}" added`);
1134
+ } catch (err) {
1135
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
1136
+ }
1137
+ }
1138
+ async function handleProviderRemove(ws, providerId) {
1139
+ try {
1140
+ const providers = await loadSavedProviders();
1141
+ if (!providers[providerId]) {
1142
+ sendResult(ws, false, `Provider "${providerId}" not found`);
1143
+ return;
1144
+ }
1145
+ delete providers[providerId];
1146
+ await saveProviders(providers);
1147
+ sendResult(ws, true, `Provider "${providerId}" removed`);
1148
+ } catch (err) {
1149
+ sendResult(ws, false, err instanceof Error ? err.message : String(err));
1150
+ }
1151
+ }
1152
+ const shutdown = async () => {
1153
+ console.log("[WebUI] Shutting down...");
1154
+ try {
1155
+ await session.append({ type: "session_end", ts: (/* @__PURE__ */ new Date()).toISOString(), usage: tokenCounter.total() });
1156
+ await session.close();
1157
+ } catch (e) {
1158
+ console.warn("[WebUI] Error closing session:", e);
1159
+ }
1160
+ for (const [ws] of clients) ws.close();
1161
+ wssPrimary.close();
1162
+ wssSecondary?.close();
1163
+ process.exit(0);
1164
+ };
1165
+ process.on("SIGINT", shutdown);
1166
+ process.on("SIGTERM", shutdown);
1167
+ }
1168
+ export {
1169
+ startWebUI
1170
+ };
1171
+ //# sourceMappingURL=index.js.map