@wrongstack/webui 0.8.5 → 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.
@@ -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,6 +463,144 @@ 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
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'";
441
606
  async function startWebUI(opts = {}) {
@@ -639,7 +804,15 @@ async function startWebUI(opts = {}) {
639
804
  toolExecutor
640
805
  });
641
806
  console.log("[WebUI] Agent initialized");
642
- 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);
643
816
  async function sessionStartPayload() {
644
817
  let maxContext = 0;
645
818
  let inputCost = 0;
@@ -861,6 +1034,7 @@ 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
1039
  if (!checkRateLimit(ws)) {
866
1040
  send(ws, {