@wrongstack/webui 0.8.5 → 0.9.0

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.
@@ -135,22 +135,36 @@ function patchConfig(config, updates) {
135
135
  }
136
136
 
137
137
  // src/server/autophase-ws-handler.ts
138
+ import { spawnSync } from "child_process";
138
139
  import {
139
140
  AutoPhasePlanner,
140
141
  PhaseGraphBuilder,
141
142
  PhaseOrchestrator,
142
- PhaseStore
143
+ PhaseStore,
144
+ WorktreeManager
143
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
+ }
144
154
  var AutoPhaseWebSocketHandler = class {
145
- constructor(agent, context, logger, storeDir) {
155
+ constructor(agent, context, logger, storeDir, events, projectRoot) {
146
156
  this.agent = agent;
147
157
  this.context = context;
148
158
  this.logger = logger;
159
+ this.events = events;
160
+ this.projectRoot = projectRoot;
149
161
  this.store = new PhaseStore({ baseDir: storeDir });
150
162
  }
151
163
  agent;
152
164
  context;
153
165
  logger;
166
+ events;
167
+ projectRoot;
154
168
  orchestrator = null;
155
169
  graph = null;
156
170
  store;
@@ -158,6 +172,8 @@ var AutoPhaseWebSocketHandler = class {
158
172
  broadcastInterval = null;
159
173
  /** Aborts in-flight task agents when the run is stopped. */
160
174
  abort = null;
175
+ /** Optional per-phase git-worktree isolation (lazily created at start). */
176
+ worktrees = null;
161
177
  addClient(ws) {
162
178
  const client = { ws, id: crypto.randomUUID() };
163
179
  this.clients.add(client);
@@ -245,12 +261,15 @@ var AutoPhaseWebSocketHandler = class {
245
261
  this.graph = graph;
246
262
  this.abort = new AbortController();
247
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
+ }
248
267
  this.orchestrator = new PhaseOrchestrator({
249
268
  graph,
250
269
  ctx: {
251
- executeTask: async (task, phaseId) => {
270
+ executeTask: async (task, phaseId, env) => {
252
271
  this.logger.info(`[AutoPhase] [${phaseId}] Executing: ${task.title}`);
253
- const result = await this.executeTaskWithAgent(task, phaseId);
272
+ const result = await this.executeTaskWithAgent(task, phaseId, env);
254
273
  this.logger.info(`[AutoPhase] [${phaseId}] Completed: ${task.title}`);
255
274
  return result;
256
275
  },
@@ -265,10 +284,13 @@ var AutoPhaseWebSocketHandler = class {
265
284
  this.broadcastState();
266
285
  }
267
286
  },
287
+ worktrees: this.worktrees ?? void 0,
268
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.
269
291
  maxConcurrentPhases: 1,
270
292
  // Sequential within a phase: each todo is a full-tool agent editing the
271
- // shared working tree, so running two at once risks concurrent writes.
293
+ // phase worktree, so running two at once risks concurrent writes.
272
294
  maxConcurrentTasks: 1
273
295
  });
274
296
  this.startBroadcast();
@@ -320,7 +342,7 @@ var AutoPhaseWebSocketHandler = class {
320
342
  }
321
343
  return this.defaultPhases();
322
344
  }
323
- async executeTaskWithAgent(task, phaseId) {
345
+ async executeTaskWithAgent(task, phaseId, env) {
324
346
  const prompt = `Execute task: ${task.title}
325
347
 
326
348
  Description: ${task.description}
@@ -328,8 +350,13 @@ Phase: ${phaseId}
328
350
  Priority: ${task.priority}
329
351
  Type: ${task.type}`;
330
352
  const signal = this.abort?.signal ?? new AbortController().signal;
331
- const result = await this.agent.run(prompt, { signal });
332
- return result;
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
+ }
333
360
  }
334
361
  async handleTaskStatusChange(taskId, status) {
335
362
  if (!this.graph) return;
@@ -436,8 +463,145 @@ Type: ${task.type}`;
436
463
  }
437
464
  };
438
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
+
439
604
  // src/server/index.ts
440
- 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'";
441
605
  async function startWebUI(opts = {}) {
442
606
  const wsPort = opts.wsPort ?? 3457;
443
607
  const wsHost = opts.wsHost ?? "127.0.0.1";
@@ -447,7 +611,7 @@ async function startWebUI(opts = {}) {
447
611
  let config = baseConfig;
448
612
  let configWriteLock = Promise.resolve();
449
613
  console.log("[WebUI] Config loaded:", config.provider ?? "(none)", "/", config.model ?? "(none)");
450
- if (!config.provider && config.providers && Object.keys(config.providers).length > 0) {
614
+ if (!config.provider && config.providers && typeof config.providers === "object" && config.providers !== null && !Array.isArray(config.providers) && Object.keys(config.providers).length > 0) {
451
615
  const firstKey = Object.keys(config.providers)[0];
452
616
  config = patchConfig(config, { provider: firstKey });
453
617
  console.log("[WebUI] No active provider \u2014 auto-selected:", firstKey);
@@ -639,7 +803,15 @@ async function startWebUI(opts = {}) {
639
803
  toolExecutor
640
804
  });
641
805
  console.log("[WebUI] Agent initialized");
642
- const autoPhaseHandler = new AutoPhaseWebSocketHandler(agent, context, logger, wpaths.projectAutophase);
806
+ const autoPhaseHandler = new AutoPhaseWebSocketHandler(
807
+ agent,
808
+ context,
809
+ logger,
810
+ wpaths.projectAutophase,
811
+ events,
812
+ projectRoot
813
+ );
814
+ const worktreeHandler = new WorktreeWebSocketHandler(events, logger);
643
815
  async function sessionStartPayload() {
644
816
  let maxContext = 0;
645
817
  let inputCost = 0;
@@ -730,11 +902,12 @@ async function startWebUI(opts = {}) {
730
902
  const RATE_LIMIT_MESSAGES = 60;
731
903
  const RATE_LIMIT_WINDOW_MS = 6e4;
732
904
  const rateLimits = /* @__PURE__ */ new Map();
733
- function checkRateLimit(ws) {
905
+ function checkRateLimit(ws, client) {
734
906
  const now = Date.now();
735
- const limit = rateLimits.get(ws);
907
+ const key = client.sessionId ?? String(ws);
908
+ const limit = rateLimits.get(key);
736
909
  if (!limit || now > limit.resetAt) {
737
- rateLimits.set(ws, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
910
+ rateLimits.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
738
911
  return true;
739
912
  }
740
913
  if (limit.count >= RATE_LIMIT_MESSAGES) return false;
@@ -861,8 +1034,9 @@ async function startWebUI(opts = {}) {
861
1034
  send(ws, { type: "session.start", payload });
862
1035
  });
863
1036
  autoPhaseHandler.addClient(ws);
1037
+ worktreeHandler.addClient(ws);
864
1038
  ws.on("message", async (data) => {
865
- if (!checkRateLimit(ws)) {
1039
+ if (!checkRateLimit(ws, client)) {
866
1040
  send(ws, {
867
1041
  type: "error",
868
1042
  payload: {
@@ -873,15 +1047,24 @@ async function startWebUI(opts = {}) {
873
1047
  return;
874
1048
  }
875
1049
  try {
876
- const msg = JSON.parse(data.toString());
877
- await handleMessage(ws, client, msg);
1050
+ const rawObj = JSON.parse(data.toString());
1051
+ if (typeof rawObj === "object" && rawObj !== null) {
1052
+ const obj = rawObj;
1053
+ if ("__proto__" in obj || "constructor" in obj || "prototype" in obj) {
1054
+ send(ws, { type: "error", payload: { phase: "parse", message: "Invalid message object" } });
1055
+ } else {
1056
+ await handleMessage(ws, client, rawObj);
1057
+ }
1058
+ } else {
1059
+ await handleMessage(ws, client, rawObj);
1060
+ }
878
1061
  } catch (err) {
879
1062
  console.error("[WebUI] Failed to parse message", err);
880
1063
  }
881
1064
  });
882
1065
  ws.on("close", () => {
883
1066
  clients.delete(ws);
884
- rateLimits.delete(ws);
1067
+ rateLimits.delete(String(ws));
885
1068
  console.log("[WebUI] Client disconnected, total:", clients.size);
886
1069
  if (pendingConfirms.size > 0) {
887
1070
  for (const [id, resolve2] of pendingConfirms) {
@@ -1875,7 +2058,10 @@ async function startWebUI(opts = {}) {
1875
2058
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
1876
2059
  if (ext === ".html") {
1877
2060
  res.setHeader("Cache-Control", "no-cache");
1878
- res.setHeader("Content-Security-Policy", HTML_CSP);
2061
+ res.setHeader(
2062
+ "Content-Security-Policy",
2063
+ `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:${wsPort} wss://127.0.0.1:${wsPort} ws://[::1]:${wsPort} wss://[::1]:${wsPort}; img-src 'self' data:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'`
2064
+ );
1879
2065
  }
1880
2066
  const fileContent = await fs2.readFile(resolvedPath);
1881
2067
  res.writeHead(200);
@@ -1891,7 +2077,7 @@ async function startWebUI(opts = {}) {
1891
2077
  "Referrer-Policy": "strict-origin-when-cross-origin",
1892
2078
  // SPA fallback previously shipped no CSP — apply the same policy as
1893
2079
  // the direct .html branch so deep-linked routes aren't unprotected.
1894
- "Content-Security-Policy": HTML_CSP
2080
+ "Content-Security-Policy": `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws://127.0.0.1:${wsPort} wss://127.0.0.1:${wsPort} ws://[::1]:${wsPort} wss://[::1]:${wsPort}; img-src 'self' data:; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'`
1895
2081
  });
1896
2082
  res.end(fileContent);
1897
2083
  } catch {