@wrongstack/webui 0.8.4 → 0.8.6

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.
@@ -32,7 +32,7 @@ import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/sec
32
32
  import { buildProviderFactoriesFromRegistry, makeProviderFromConfig } from "@wrongstack/providers";
33
33
  import { builtinToolsPack, forgetTool, rememberTool } from "@wrongstack/tools";
34
34
  import { WebSocket, WebSocketServer } from "ws";
35
- import { randomBytes } from "crypto";
35
+ import { randomBytes, timingSafeEqual } from "crypto";
36
36
 
37
37
  // ../runtime/src/container.ts
38
38
  import {
@@ -134,7 +134,475 @@ function patchConfig(config, updates) {
134
134
  return Object.freeze({ ...config, ...updates });
135
135
  }
136
136
 
137
+ // src/server/autophase-ws-handler.ts
138
+ import { spawnSync } from "child_process";
139
+ import {
140
+ AutoPhasePlanner,
141
+ PhaseGraphBuilder,
142
+ PhaseOrchestrator,
143
+ PhaseStore,
144
+ WorktreeManager
145
+ } from "@wrongstack/core";
146
+ function isGitRepo(cwd) {
147
+ try {
148
+ const r = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, encoding: "utf8" });
149
+ return r.status === 0 && r.stdout.trim() === "true";
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+ var AutoPhaseWebSocketHandler = class {
155
+ constructor(agent, context, logger, storeDir, events, projectRoot) {
156
+ this.agent = agent;
157
+ this.context = context;
158
+ this.logger = logger;
159
+ this.events = events;
160
+ this.projectRoot = projectRoot;
161
+ this.store = new PhaseStore({ baseDir: storeDir });
162
+ }
163
+ agent;
164
+ context;
165
+ logger;
166
+ events;
167
+ projectRoot;
168
+ orchestrator = null;
169
+ graph = null;
170
+ store;
171
+ clients = /* @__PURE__ */ new Set();
172
+ broadcastInterval = null;
173
+ /** Aborts in-flight task agents when the run is stopped. */
174
+ abort = null;
175
+ /** Optional per-phase git-worktree isolation (lazily created at start). */
176
+ worktrees = null;
177
+ addClient(ws) {
178
+ const client = { ws, id: crypto.randomUUID() };
179
+ this.clients.add(client);
180
+ ws.on("close", () => this.clients.delete(client));
181
+ ws.on("error", () => this.clients.delete(client));
182
+ this.sendState(client);
183
+ }
184
+ async handleMessage(msg) {
185
+ switch (msg.type) {
186
+ case "autophase.start":
187
+ await this.handleStart(msg.payload);
188
+ break;
189
+ case "autophase.pause":
190
+ this.orchestrator?.pause();
191
+ this.broadcast({ type: "autophase.paused", payload: {} });
192
+ break;
193
+ case "autophase.resume":
194
+ this.orchestrator?.resume();
195
+ this.broadcast({ type: "autophase.resumed", payload: {} });
196
+ break;
197
+ case "autophase.stop":
198
+ this.abort?.abort();
199
+ this.orchestrator?.stop();
200
+ this.stopBroadcast();
201
+ if (this.graph) void this.store.save(this.graph);
202
+ this.broadcast({ type: "autophase.stopped", payload: {} });
203
+ break;
204
+ case "autophase.status":
205
+ this.broadcastState();
206
+ break;
207
+ case "autophase.selectPhase": {
208
+ const phaseId = msg.payload?.phaseId;
209
+ if (phaseId && this.graph) {
210
+ this.broadcastState(phaseId);
211
+ }
212
+ break;
213
+ }
214
+ case "autophase.taskStatus": {
215
+ const { taskId, status } = msg.payload;
216
+ await this.handleTaskStatusChange(taskId, status);
217
+ break;
218
+ }
219
+ case "autophase.toggleAutonomous": {
220
+ const autonomous = msg.payload?.autonomous ?? !this.graph?.autonomous;
221
+ if (this.graph) {
222
+ this.graph.autonomous = autonomous;
223
+ await this.store.save(this.graph);
224
+ this.broadcast({ type: "autophase.state", payload: this.buildState() });
225
+ }
226
+ break;
227
+ }
228
+ case "autophase.save": {
229
+ if (this.graph) {
230
+ await this.store.save(this.graph);
231
+ this.broadcast({ type: "autophase.saved", payload: { graphId: this.graph.id } });
232
+ }
233
+ break;
234
+ }
235
+ case "autophase.list": {
236
+ const graphs = await this.store.list();
237
+ this.broadcast({ type: "autophase.list", payload: { graphs } });
238
+ break;
239
+ }
240
+ case "autophase.load": {
241
+ const graphId = msg.payload?.graphId;
242
+ if (graphId) {
243
+ const graph = await this.store.load(graphId);
244
+ if (graph) {
245
+ this.graph = graph;
246
+ this.broadcast({ type: "autophase.state", payload: this.buildState() });
247
+ } else {
248
+ this.broadcast({ type: "autophase.error", payload: { message: `Graph not found: ${graphId}` } });
249
+ }
250
+ }
251
+ break;
252
+ }
253
+ }
254
+ }
255
+ async handleStart(payload) {
256
+ const title = payload?.goal || payload?.title || "Untitled Project";
257
+ const autonomous = payload?.autonomous ?? true;
258
+ const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(title);
259
+ this.logger.info(`[AutoPhase] Starting: ${title}`);
260
+ const graph = await new PhaseGraphBuilder({ title, phases, autonomous }).build();
261
+ this.graph = graph;
262
+ this.abort = new AbortController();
263
+ await this.store.save(graph);
264
+ if (!this.worktrees && this.events && this.projectRoot && process.env["WRONGSTACK_AUTOPHASE_WORKTREES"] !== "0" && isGitRepo(this.projectRoot)) {
265
+ this.worktrees = new WorktreeManager({ projectRoot: this.projectRoot, events: this.events });
266
+ }
267
+ this.orchestrator = new PhaseOrchestrator({
268
+ graph,
269
+ ctx: {
270
+ executeTask: async (task, phaseId, env) => {
271
+ this.logger.info(`[AutoPhase] [${phaseId}] Executing: ${task.title}`);
272
+ const result = await this.executeTaskWithAgent(task, phaseId, env);
273
+ this.logger.info(`[AutoPhase] [${phaseId}] Completed: ${task.title}`);
274
+ return result;
275
+ },
276
+ onPhaseComplete: (phase) => {
277
+ this.logger.info(`[AutoPhase] Phase completed: ${phase.name}`);
278
+ void this.store.save(graph);
279
+ this.broadcastState();
280
+ },
281
+ onPhaseFail: (phase, error) => {
282
+ this.logger.error(`[AutoPhase] Phase failed: ${phase.name} \u2014 ${error.message}`);
283
+ void this.store.save(graph);
284
+ this.broadcastState();
285
+ }
286
+ },
287
+ worktrees: this.worktrees ?? void 0,
288
+ autonomous,
289
+ // Must stay 1: phase tasks run on the single shared context whose cwd we
290
+ // swap per phase, so parallel phases would race on context.cwd.
291
+ maxConcurrentPhases: 1,
292
+ // Sequential within a phase: each todo is a full-tool agent editing the
293
+ // phase worktree, so running two at once risks concurrent writes.
294
+ maxConcurrentTasks: 1
295
+ });
296
+ this.startBroadcast();
297
+ this.broadcastState();
298
+ void this.orchestrator.start().then(() => {
299
+ this.orchestrator?.stop();
300
+ void this.store.save(graph);
301
+ this.stopBroadcast();
302
+ const failed = graph.failedPhaseIds.length > 0;
303
+ this.broadcast(
304
+ failed ? { type: "autophase.failed", payload: { title } } : { type: "autophase.completed", payload: { title } }
305
+ );
306
+ this.broadcastState();
307
+ }).catch((err) => {
308
+ this.logger.error(`[AutoPhase] Aborted: ${err instanceof Error ? err.message : String(err)}`);
309
+ this.stopBroadcast();
310
+ this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
311
+ });
312
+ }
313
+ /** Generic fallback phases when the LLM planner produces nothing usable. */
314
+ defaultPhases() {
315
+ return [
316
+ { name: "Discovery", description: "Requirements gathering", priority: "high", estimateHours: 2, parallelizable: false },
317
+ { name: "Design", description: "Architecture and design", priority: "critical", estimateHours: 4, parallelizable: false },
318
+ { name: "Implementation", description: "Core development", priority: "critical", estimateHours: 12, parallelizable: false },
319
+ { name: "Testing", description: "Unit and integration tests", priority: "high", estimateHours: 6, parallelizable: true },
320
+ { name: "Deployment", description: "Deploy to production", priority: "medium", estimateHours: 2, parallelizable: false }
321
+ ];
322
+ }
323
+ /** Plan phases+todos for the goal via the LLM; fall back to defaults on failure. */
324
+ async planPhases(goal) {
325
+ try {
326
+ const planner = new AutoPhasePlanner({
327
+ goal,
328
+ runOnce: async (prompt) => {
329
+ const result = await this.agent.run(prompt, { signal: new AbortController().signal });
330
+ return result.status === "done" ? result.finalText ?? "" : "";
331
+ }
332
+ });
333
+ const { phases, parseFailed } = await planner.plan();
334
+ if (!parseFailed && phases.length > 0) {
335
+ const todos = phases.reduce((n, p) => n + (p.taskTemplates?.length ?? 0), 0);
336
+ this.logger.info(`[AutoPhase] Planned ${phases.length} phases / ${todos} todos for: ${goal}`);
337
+ return phases;
338
+ }
339
+ this.logger.info(`[AutoPhase] Planner produced no phases; using defaults for: ${goal}`);
340
+ } catch (err) {
341
+ this.logger.error(`[AutoPhase] Planning failed, using defaults: ${err instanceof Error ? err.message : String(err)}`);
342
+ }
343
+ return this.defaultPhases();
344
+ }
345
+ async executeTaskWithAgent(task, phaseId, env) {
346
+ const prompt = `Execute task: ${task.title}
347
+
348
+ Description: ${task.description}
349
+ Phase: ${phaseId}
350
+ Priority: ${task.priority}
351
+ Type: ${task.type}`;
352
+ const signal = this.abort?.signal ?? new AbortController().signal;
353
+ const prevCwd = this.context.cwd;
354
+ if (env?.cwd) this.context.cwd = env.cwd;
355
+ try {
356
+ return await this.agent.run(prompt, { signal });
357
+ } finally {
358
+ this.context.cwd = prevCwd;
359
+ }
360
+ }
361
+ async handleTaskStatusChange(taskId, status) {
362
+ if (!this.graph) return;
363
+ for (const phase of this.graph.phases.values()) {
364
+ const task = phase.taskGraph.nodes.get(taskId);
365
+ if (task) {
366
+ task.status = status;
367
+ task.updatedAt = Date.now();
368
+ this.broadcastState();
369
+ return;
370
+ }
371
+ }
372
+ }
373
+ startBroadcast() {
374
+ if (this.broadcastInterval) return;
375
+ this.broadcastInterval = setInterval(() => {
376
+ const progress = this.orchestrator?.getProgress();
377
+ if (progress) this.broadcast({ type: "autophase.progress", payload: progress });
378
+ this.broadcastState();
379
+ }, 2e3);
380
+ }
381
+ stopBroadcast() {
382
+ if (this.broadcastInterval) {
383
+ clearInterval(this.broadcastInterval);
384
+ this.broadcastInterval = null;
385
+ }
386
+ }
387
+ broadcastState(activePhaseId) {
388
+ if (!this.graph) return;
389
+ const state = this.buildState(activePhaseId);
390
+ this.broadcast({ type: "autophase.state", payload: state });
391
+ }
392
+ buildState(activePhaseId) {
393
+ if (!this.graph) {
394
+ return { phases: [], tasks: [], overallPercent: 0, autonomous: true, title: "" };
395
+ }
396
+ const phases = Array.from(this.graph.phases.values());
397
+ const currentActiveId = activePhaseId || phases.find((p) => p.status === "running")?.id || phases[0]?.id || "";
398
+ const activePhase = this.graph.phases.get(currentActiveId);
399
+ const totalTasks = phases.reduce((sum, p) => sum + p.taskGraph.nodes.size, 0);
400
+ const completedTasks = phases.reduce(
401
+ (sum, p) => sum + Array.from(p.taskGraph.nodes.values()).filter((t) => t.status === "completed").length,
402
+ 0
403
+ );
404
+ const phaseItems = phases.map((p) => ({
405
+ id: p.id,
406
+ name: p.name,
407
+ description: p.description,
408
+ status: p.status,
409
+ priority: p.priority,
410
+ estimateHours: p.estimateHours,
411
+ actualDurationMs: p.actualDurationMs,
412
+ startedAt: p.startedAt,
413
+ completedAt: p.completedAt,
414
+ progressPercent: p.taskGraph.nodes.size > 0 ? Math.round(Array.from(p.taskGraph.nodes.values()).filter((t) => t.status === "completed").length / p.taskGraph.nodes.size * 100) : 0,
415
+ taskCount: p.taskGraph.nodes.size,
416
+ completedTasks: Array.from(p.taskGraph.nodes.values()).filter((t) => t.status === "completed").length,
417
+ assignedAgents: p.assignedAgents,
418
+ isActive: p.id === currentActiveId
419
+ }));
420
+ const taskItems = activePhase ? Array.from(activePhase.taskGraph.nodes.values()).map((t) => ({
421
+ id: t.id,
422
+ title: t.title,
423
+ description: t.description,
424
+ status: t.status,
425
+ priority: t.priority,
426
+ type: t.type,
427
+ estimateHours: t.estimateHours,
428
+ actualHours: t.actualHours,
429
+ assignee: t.assignee,
430
+ tags: t.tags || [],
431
+ startedAt: t.startedAt,
432
+ completedAt: t.completedAt
433
+ })) : [];
434
+ const completedPhases = phases.filter((p) => p.status === "completed").length;
435
+ return {
436
+ title: this.graph.title,
437
+ phases: phaseItems,
438
+ tasks: taskItems,
439
+ activePhaseId: currentActiveId,
440
+ overallPercent: phases.length > 0 ? Math.round(completedPhases / phases.length * 100) : 0,
441
+ autonomous: this.graph.autonomous,
442
+ totalTasks,
443
+ completedTasks
444
+ };
445
+ }
446
+ sendState(client) {
447
+ if (!this.graph) return;
448
+ const state = this.buildState();
449
+ this.send(client, { type: "autophase.state", payload: state });
450
+ }
451
+ broadcast(msg) {
452
+ const data = JSON.stringify(msg);
453
+ for (const client of this.clients) {
454
+ if (client.ws.readyState === 1) {
455
+ client.ws.send(data);
456
+ }
457
+ }
458
+ }
459
+ send(client, msg) {
460
+ if (client.ws.readyState === 1) {
461
+ client.ws.send(JSON.stringify(msg));
462
+ }
463
+ }
464
+ };
465
+
466
+ // src/server/worktree-ws-handler.ts
467
+ var MAX_ACTIVITY = 6;
468
+ var WorktreeWebSocketHandler = class {
469
+ constructor(events, logger) {
470
+ this.events = events;
471
+ this.logger = logger;
472
+ this.subscribe();
473
+ }
474
+ events;
475
+ logger;
476
+ clients = /* @__PURE__ */ new Set();
477
+ handles = /* @__PURE__ */ new Map();
478
+ baseBranch = "";
479
+ broadcastInterval = null;
480
+ offs = [];
481
+ addClient(ws) {
482
+ this.clients.add(ws);
483
+ ws.on("close", () => this.clients.delete(ws));
484
+ ws.on("error", () => this.clients.delete(ws));
485
+ this.send(ws, this.stateMessage());
486
+ }
487
+ dispose() {
488
+ for (const off of this.offs) off();
489
+ this.offs.length = 0;
490
+ this.stopBroadcast();
491
+ }
492
+ // ── internals ───────────────────────────────────────────────────────────
493
+ subscribe() {
494
+ const on = this.events.on.bind(this.events);
495
+ this.offs.push(
496
+ on("worktree.allocated", (p) => {
497
+ const e = p;
498
+ this.baseBranch = e.baseBranch || this.baseBranch;
499
+ this.upsert(e.handleId, {
500
+ handleId: e.handleId,
501
+ ownerId: e.ownerId,
502
+ ownerLabel: e.ownerLabel,
503
+ branch: e.branch,
504
+ baseBranch: e.baseBranch,
505
+ status: "active",
506
+ insertions: 0,
507
+ deletions: 0,
508
+ files: 0,
509
+ allocatedAt: Date.now(),
510
+ lastEventAt: Date.now(),
511
+ recentActivity: []
512
+ });
513
+ this.activity(e.handleId, "allocated", `branch ${e.branch}`);
514
+ this.ensureBroadcast();
515
+ }),
516
+ on("worktree.committed", (p) => {
517
+ const e = p;
518
+ this.patch(e.handleId, { status: "committing", insertions: e.insertions, deletions: e.deletions, files: e.files });
519
+ if (e.committed) this.activity(e.handleId, "committed", `+${e.insertions}/-${e.deletions} (${e.files}f)`);
520
+ this.broadcastState();
521
+ }),
522
+ on("worktree.merged", (p) => {
523
+ const e = p;
524
+ this.patch(e.handleId, { status: "merged" });
525
+ this.activity(e.handleId, "merged", `\u2192 ${e.baseBranch}`);
526
+ this.broadcastState();
527
+ }),
528
+ on("worktree.conflict", (p) => {
529
+ const e = p;
530
+ this.patch(e.handleId, { status: "needs-review", conflictFiles: e.conflictFiles });
531
+ this.activity(e.handleId, "conflict", e.conflictFiles.join(", "));
532
+ this.broadcastState();
533
+ }),
534
+ on("worktree.failed", (p) => {
535
+ const e = p;
536
+ this.patch(e.handleId, { status: "failed" });
537
+ this.activity(e.handleId, "failed", e.error);
538
+ this.broadcastState();
539
+ }),
540
+ on("worktree.released", (p) => {
541
+ const e = p;
542
+ if (!e.kept) this.handles.delete(e.handleId);
543
+ this.activity(e.handleId, "released", e.kept ? "kept for review" : "removed");
544
+ if (this.handles.size === 0) this.stopBroadcast();
545
+ else this.broadcastState();
546
+ })
547
+ );
548
+ }
549
+ upsert(id, view) {
550
+ this.handles.set(id, view);
551
+ }
552
+ patch(id, patch) {
553
+ const cur = this.handles.get(id);
554
+ if (!cur) return;
555
+ this.handles.set(id, { ...cur, ...patch, lastEventAt: Date.now() });
556
+ }
557
+ activity(id, kind, text) {
558
+ const cur = this.handles.get(id);
559
+ if (cur) {
560
+ const recentActivity = [...cur.recentActivity, { kind, text, at: Date.now() }].slice(-MAX_ACTIVITY);
561
+ this.handles.set(id, { ...cur, recentActivity });
562
+ }
563
+ this.broadcast({ type: "worktree.event", payload: { kind, handleId: id, text, at: Date.now() } });
564
+ }
565
+ stateMessage() {
566
+ return {
567
+ type: "worktree.state",
568
+ payload: { worktrees: [...this.handles.values()], baseBranch: this.baseBranch }
569
+ };
570
+ }
571
+ broadcastState() {
572
+ this.broadcast(this.stateMessage());
573
+ }
574
+ ensureBroadcast() {
575
+ this.broadcast(this.stateMessage());
576
+ if (this.broadcastInterval) return;
577
+ this.broadcastInterval = setInterval(() => this.broadcast(this.stateMessage()), 2e3);
578
+ }
579
+ stopBroadcast() {
580
+ this.broadcast(this.stateMessage());
581
+ if (this.broadcastInterval) {
582
+ clearInterval(this.broadcastInterval);
583
+ this.broadcastInterval = null;
584
+ }
585
+ }
586
+ broadcast(msg) {
587
+ const data = JSON.stringify(msg);
588
+ for (const ws of this.clients) {
589
+ try {
590
+ if (ws.readyState === 1) ws.send(data);
591
+ } catch (err) {
592
+ this.logger.debug?.(`worktree broadcast failed: ${err instanceof Error ? err.message : String(err)}`);
593
+ }
594
+ }
595
+ }
596
+ send(ws, msg) {
597
+ try {
598
+ if (ws.readyState === 1) ws.send(JSON.stringify(msg));
599
+ } catch {
600
+ }
601
+ }
602
+ };
603
+
137
604
  // src/server/index.ts
605
+ var HTML_CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'";
138
606
  async function startWebUI(opts = {}) {
139
607
  const wsPort = opts.wsPort ?? 3457;
140
608
  const wsHost = opts.wsHost ?? "127.0.0.1";
@@ -336,6 +804,15 @@ async function startWebUI(opts = {}) {
336
804
  toolExecutor
337
805
  });
338
806
  console.log("[WebUI] Agent initialized");
807
+ const autoPhaseHandler = new AutoPhaseWebSocketHandler(
808
+ agent,
809
+ context,
810
+ logger,
811
+ wpaths.projectAutophase,
812
+ events,
813
+ projectRoot
814
+ );
815
+ const worktreeHandler = new WorktreeWebSocketHandler(events, logger);
339
816
  async function sessionStartPayload() {
340
817
  let maxContext = 0;
341
818
  let inputCost = 0;
@@ -368,12 +845,33 @@ async function startWebUI(opts = {}) {
368
845
  const wsToken = randomBytes(16).toString("hex");
369
846
  console.log(`[WebUI] WS auth token: ${wsToken.slice(0, 4)}\u2026${wsToken.slice(-4)} (masked)`);
370
847
  const isLoopback = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
848
+ const tokenMatches = (provided) => {
849
+ if (!provided) return false;
850
+ const a = Buffer.from(provided);
851
+ const b = Buffer.from(wsToken);
852
+ if (a.length !== b.length) return false;
853
+ return timingSafeEqual(a, b);
854
+ };
855
+ const hostHeaderOk = (req) => {
856
+ const boundToLoopback = wsHost === "127.0.0.1" || wsHost === "::1" || wsHost === "localhost";
857
+ if (!boundToLoopback) return true;
858
+ const hostHeader = (req.headers.host ?? "").trim();
859
+ if (!hostHeader) return false;
860
+ let hostname;
861
+ try {
862
+ hostname = new URL(`http://${hostHeader}`).hostname;
863
+ } catch {
864
+ return false;
865
+ }
866
+ return isLoopback(hostname);
867
+ };
371
868
  const verifyClient = (info) => {
372
869
  const origin = info.origin;
373
870
  const url = info.req.url ?? "";
374
871
  const tokenMatch = url.match(/[?&]token=([^&]+)/);
375
872
  const providedToken = tokenMatch ? tokenMatch[1] : void 0;
376
- const tokenOk = providedToken === wsToken;
873
+ const tokenOk = tokenMatches(providedToken);
874
+ if (!hostHeaderOk(info.req)) return false;
377
875
  if (!origin) {
378
876
  const remoteIp = info.req.socket.remoteAddress ?? "";
379
877
  const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
@@ -383,18 +881,24 @@ async function startWebUI(opts = {}) {
383
881
  try {
384
882
  const { hostname } = new URL(origin);
385
883
  if (isLoopback(hostname)) return true;
386
- if (wsHost === "0.0.0.0") return tokenOk;
387
884
  return tokenOk;
388
885
  } catch {
389
886
  return false;
390
887
  }
391
888
  };
889
+ const WS_MAX_PAYLOAD = 8 * 1024 * 1024;
392
890
  const wssPrimary = new WebSocketServer({
393
891
  port: wsPort,
394
892
  host: wsHost,
395
- verifyClient
893
+ verifyClient,
894
+ maxPayload: WS_MAX_PAYLOAD
396
895
  });
397
- const wssSecondary = wsHost === "127.0.0.1" ? new WebSocketServer({ port: wsPort, host: "::1", verifyClient }) : null;
896
+ const wssSecondary = wsHost === "127.0.0.1" ? new WebSocketServer({
897
+ port: wsPort,
898
+ host: "::1",
899
+ verifyClient,
900
+ maxPayload: WS_MAX_PAYLOAD
901
+ }) : null;
398
902
  const clients = /* @__PURE__ */ new Map();
399
903
  const RATE_LIMIT_MESSAGES = 60;
400
904
  const RATE_LIMIT_WINDOW_MS = 6e4;
@@ -529,6 +1033,8 @@ async function startWebUI(opts = {}) {
529
1033
  void sessionStartPayload().then((payload) => {
530
1034
  send(ws, { type: "session.start", payload });
531
1035
  });
1036
+ autoPhaseHandler.addClient(ws);
1037
+ worktreeHandler.addClient(ws);
532
1038
  ws.on("message", async (data) => {
533
1039
  if (!checkRateLimit(ws)) {
534
1040
  send(ws, {
@@ -1341,6 +1847,12 @@ async function startWebUI(opts = {}) {
1341
1847
  });
1342
1848
  break;
1343
1849
  }
1850
+ default:
1851
+ if (msg.type.startsWith("autophase.")) {
1852
+ await autoPhaseHandler.handleMessage(msg);
1853
+ } else {
1854
+ send(ws, { type: "error", payload: { phase: "handleMessage", message: `Unknown message type: ${msg.type}` } });
1855
+ }
1344
1856
  }
1345
1857
  }
1346
1858
  async function loadSavedProviders() {
@@ -1537,10 +2049,7 @@ async function startWebUI(opts = {}) {
1537
2049
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
1538
2050
  if (ext === ".html") {
1539
2051
  res.setHeader("Cache-Control", "no-cache");
1540
- res.setHeader(
1541
- "Content-Security-Policy",
1542
- "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; font-src 'self' data:"
1543
- );
2052
+ res.setHeader("Content-Security-Policy", HTML_CSP);
1544
2053
  }
1545
2054
  const fileContent = await fs2.readFile(resolvedPath);
1546
2055
  res.writeHead(200);
@@ -1552,7 +2061,11 @@ async function startWebUI(opts = {}) {
1552
2061
  res.writeHead(200, {
1553
2062
  "Content-Type": "text/html",
1554
2063
  "X-Content-Type-Options": "nosniff",
1555
- "X-Frame-Options": "DENY"
2064
+ "X-Frame-Options": "DENY",
2065
+ "Referrer-Policy": "strict-origin-when-cross-origin",
2066
+ // SPA fallback previously shipped no CSP — apply the same policy as
2067
+ // the direct .html branch so deep-linked routes aren't unprotected.
2068
+ "Content-Security-Policy": HTML_CSP
1556
2069
  });
1557
2070
  res.end(fileContent);
1558
2071
  } catch {