@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.
@@ -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,6 +464,144 @@ 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
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'";
442
607
  async function startWebUI(opts = {}) {
@@ -640,7 +805,15 @@ async function startWebUI(opts = {}) {
640
805
  toolExecutor
641
806
  });
642
807
  console.log("[WebUI] Agent initialized");
643
- const autoPhaseHandler = new AutoPhaseWebSocketHandler(agent, context, logger, wpaths.projectAutophase);
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);
644
817
  async function sessionStartPayload() {
645
818
  let maxContext = 0;
646
819
  let inputCost = 0;
@@ -862,6 +1035,7 @@ 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
1040
  if (!checkRateLimit(ws)) {
867
1041
  send(ws, {