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