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