@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.
- package/dist/assets/{index-DjDDhHNu.js → index-B5qzSV8A.js} +25 -25
- package/dist/assets/index-BTevO8Vz.css +1 -0
- package/dist/index.css +117 -0
- package/dist/index.css.map +1 -1
- package/dist/index.html +2 -2
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -1
- package/dist/server/entry.js +523 -10
- package/dist/server/entry.js.map +1 -1
- package/dist/server/index.js +523 -10
- package/dist/server/index.js.map +1 -1
- package/package.json +5 -5
- package/dist/assets/index-aTQFIbqW.css +0 -1
package/dist/server/index.js
CHANGED
|
@@ -32,7 +32,7 @@ import { decryptConfigSecrets, encryptConfigSecrets } from "@wrongstack/core/sec
|
|
|
32
32
|
import { buildProviderFactoriesFromRegistry, makeProviderFromConfig } from "@wrongstack/providers";
|
|
33
33
|
import { builtinToolsPack, forgetTool, rememberTool } from "@wrongstack/tools";
|
|
34
34
|
import { WebSocket, WebSocketServer } from "ws";
|
|
35
|
-
import { randomBytes } from "crypto";
|
|
35
|
+
import { randomBytes, timingSafeEqual } from "crypto";
|
|
36
36
|
|
|
37
37
|
// ../runtime/src/container.ts
|
|
38
38
|
import {
|
|
@@ -134,7 +134,475 @@ function patchConfig(config, updates) {
|
|
|
134
134
|
return Object.freeze({ ...config, ...updates });
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// src/server/autophase-ws-handler.ts
|
|
138
|
+
import { spawnSync } from "child_process";
|
|
139
|
+
import {
|
|
140
|
+
AutoPhasePlanner,
|
|
141
|
+
PhaseGraphBuilder,
|
|
142
|
+
PhaseOrchestrator,
|
|
143
|
+
PhaseStore,
|
|
144
|
+
WorktreeManager
|
|
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
|
+
}
|
|
154
|
+
var AutoPhaseWebSocketHandler = class {
|
|
155
|
+
constructor(agent, context, logger, storeDir, events, projectRoot) {
|
|
156
|
+
this.agent = agent;
|
|
157
|
+
this.context = context;
|
|
158
|
+
this.logger = logger;
|
|
159
|
+
this.events = events;
|
|
160
|
+
this.projectRoot = projectRoot;
|
|
161
|
+
this.store = new PhaseStore({ baseDir: storeDir });
|
|
162
|
+
}
|
|
163
|
+
agent;
|
|
164
|
+
context;
|
|
165
|
+
logger;
|
|
166
|
+
events;
|
|
167
|
+
projectRoot;
|
|
168
|
+
orchestrator = null;
|
|
169
|
+
graph = null;
|
|
170
|
+
store;
|
|
171
|
+
clients = /* @__PURE__ */ new Set();
|
|
172
|
+
broadcastInterval = null;
|
|
173
|
+
/** Aborts in-flight task agents when the run is stopped. */
|
|
174
|
+
abort = null;
|
|
175
|
+
/** Optional per-phase git-worktree isolation (lazily created at start). */
|
|
176
|
+
worktrees = null;
|
|
177
|
+
addClient(ws) {
|
|
178
|
+
const client = { ws, id: crypto.randomUUID() };
|
|
179
|
+
this.clients.add(client);
|
|
180
|
+
ws.on("close", () => this.clients.delete(client));
|
|
181
|
+
ws.on("error", () => this.clients.delete(client));
|
|
182
|
+
this.sendState(client);
|
|
183
|
+
}
|
|
184
|
+
async handleMessage(msg) {
|
|
185
|
+
switch (msg.type) {
|
|
186
|
+
case "autophase.start":
|
|
187
|
+
await this.handleStart(msg.payload);
|
|
188
|
+
break;
|
|
189
|
+
case "autophase.pause":
|
|
190
|
+
this.orchestrator?.pause();
|
|
191
|
+
this.broadcast({ type: "autophase.paused", payload: {} });
|
|
192
|
+
break;
|
|
193
|
+
case "autophase.resume":
|
|
194
|
+
this.orchestrator?.resume();
|
|
195
|
+
this.broadcast({ type: "autophase.resumed", payload: {} });
|
|
196
|
+
break;
|
|
197
|
+
case "autophase.stop":
|
|
198
|
+
this.abort?.abort();
|
|
199
|
+
this.orchestrator?.stop();
|
|
200
|
+
this.stopBroadcast();
|
|
201
|
+
if (this.graph) void this.store.save(this.graph);
|
|
202
|
+
this.broadcast({ type: "autophase.stopped", payload: {} });
|
|
203
|
+
break;
|
|
204
|
+
case "autophase.status":
|
|
205
|
+
this.broadcastState();
|
|
206
|
+
break;
|
|
207
|
+
case "autophase.selectPhase": {
|
|
208
|
+
const phaseId = msg.payload?.phaseId;
|
|
209
|
+
if (phaseId && this.graph) {
|
|
210
|
+
this.broadcastState(phaseId);
|
|
211
|
+
}
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
case "autophase.taskStatus": {
|
|
215
|
+
const { taskId, status } = msg.payload;
|
|
216
|
+
await this.handleTaskStatusChange(taskId, status);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case "autophase.toggleAutonomous": {
|
|
220
|
+
const autonomous = msg.payload?.autonomous ?? !this.graph?.autonomous;
|
|
221
|
+
if (this.graph) {
|
|
222
|
+
this.graph.autonomous = autonomous;
|
|
223
|
+
await this.store.save(this.graph);
|
|
224
|
+
this.broadcast({ type: "autophase.state", payload: this.buildState() });
|
|
225
|
+
}
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case "autophase.save": {
|
|
229
|
+
if (this.graph) {
|
|
230
|
+
await this.store.save(this.graph);
|
|
231
|
+
this.broadcast({ type: "autophase.saved", payload: { graphId: this.graph.id } });
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
case "autophase.list": {
|
|
236
|
+
const graphs = await this.store.list();
|
|
237
|
+
this.broadcast({ type: "autophase.list", payload: { graphs } });
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
case "autophase.load": {
|
|
241
|
+
const graphId = msg.payload?.graphId;
|
|
242
|
+
if (graphId) {
|
|
243
|
+
const graph = await this.store.load(graphId);
|
|
244
|
+
if (graph) {
|
|
245
|
+
this.graph = graph;
|
|
246
|
+
this.broadcast({ type: "autophase.state", payload: this.buildState() });
|
|
247
|
+
} else {
|
|
248
|
+
this.broadcast({ type: "autophase.error", payload: { message: `Graph not found: ${graphId}` } });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
async handleStart(payload) {
|
|
256
|
+
const title = payload?.goal || payload?.title || "Untitled Project";
|
|
257
|
+
const autonomous = payload?.autonomous ?? true;
|
|
258
|
+
const phases = Array.isArray(payload?.phases) ? payload.phases : await this.planPhases(title);
|
|
259
|
+
this.logger.info(`[AutoPhase] Starting: ${title}`);
|
|
260
|
+
const graph = await new PhaseGraphBuilder({ title, phases, autonomous }).build();
|
|
261
|
+
this.graph = graph;
|
|
262
|
+
this.abort = new AbortController();
|
|
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
|
+
}
|
|
267
|
+
this.orchestrator = new PhaseOrchestrator({
|
|
268
|
+
graph,
|
|
269
|
+
ctx: {
|
|
270
|
+
executeTask: async (task, phaseId, env) => {
|
|
271
|
+
this.logger.info(`[AutoPhase] [${phaseId}] Executing: ${task.title}`);
|
|
272
|
+
const result = await this.executeTaskWithAgent(task, phaseId, env);
|
|
273
|
+
this.logger.info(`[AutoPhase] [${phaseId}] Completed: ${task.title}`);
|
|
274
|
+
return result;
|
|
275
|
+
},
|
|
276
|
+
onPhaseComplete: (phase) => {
|
|
277
|
+
this.logger.info(`[AutoPhase] Phase completed: ${phase.name}`);
|
|
278
|
+
void this.store.save(graph);
|
|
279
|
+
this.broadcastState();
|
|
280
|
+
},
|
|
281
|
+
onPhaseFail: (phase, error) => {
|
|
282
|
+
this.logger.error(`[AutoPhase] Phase failed: ${phase.name} \u2014 ${error.message}`);
|
|
283
|
+
void this.store.save(graph);
|
|
284
|
+
this.broadcastState();
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
worktrees: this.worktrees ?? void 0,
|
|
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.
|
|
291
|
+
maxConcurrentPhases: 1,
|
|
292
|
+
// Sequential within a phase: each todo is a full-tool agent editing the
|
|
293
|
+
// phase worktree, so running two at once risks concurrent writes.
|
|
294
|
+
maxConcurrentTasks: 1
|
|
295
|
+
});
|
|
296
|
+
this.startBroadcast();
|
|
297
|
+
this.broadcastState();
|
|
298
|
+
void this.orchestrator.start().then(() => {
|
|
299
|
+
this.orchestrator?.stop();
|
|
300
|
+
void this.store.save(graph);
|
|
301
|
+
this.stopBroadcast();
|
|
302
|
+
const failed = graph.failedPhaseIds.length > 0;
|
|
303
|
+
this.broadcast(
|
|
304
|
+
failed ? { type: "autophase.failed", payload: { title } } : { type: "autophase.completed", payload: { title } }
|
|
305
|
+
);
|
|
306
|
+
this.broadcastState();
|
|
307
|
+
}).catch((err) => {
|
|
308
|
+
this.logger.error(`[AutoPhase] Aborted: ${err instanceof Error ? err.message : String(err)}`);
|
|
309
|
+
this.stopBroadcast();
|
|
310
|
+
this.broadcast({ type: "autophase.failed", payload: { title, error: String(err) } });
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
/** Generic fallback phases when the LLM planner produces nothing usable. */
|
|
314
|
+
defaultPhases() {
|
|
315
|
+
return [
|
|
316
|
+
{ name: "Discovery", description: "Requirements gathering", priority: "high", estimateHours: 2, parallelizable: false },
|
|
317
|
+
{ name: "Design", description: "Architecture and design", priority: "critical", estimateHours: 4, parallelizable: false },
|
|
318
|
+
{ name: "Implementation", description: "Core development", priority: "critical", estimateHours: 12, parallelizable: false },
|
|
319
|
+
{ name: "Testing", description: "Unit and integration tests", priority: "high", estimateHours: 6, parallelizable: true },
|
|
320
|
+
{ name: "Deployment", description: "Deploy to production", priority: "medium", estimateHours: 2, parallelizable: false }
|
|
321
|
+
];
|
|
322
|
+
}
|
|
323
|
+
/** Plan phases+todos for the goal via the LLM; fall back to defaults on failure. */
|
|
324
|
+
async planPhases(goal) {
|
|
325
|
+
try {
|
|
326
|
+
const planner = new AutoPhasePlanner({
|
|
327
|
+
goal,
|
|
328
|
+
runOnce: async (prompt) => {
|
|
329
|
+
const result = await this.agent.run(prompt, { signal: new AbortController().signal });
|
|
330
|
+
return result.status === "done" ? result.finalText ?? "" : "";
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
const { phases, parseFailed } = await planner.plan();
|
|
334
|
+
if (!parseFailed && phases.length > 0) {
|
|
335
|
+
const todos = phases.reduce((n, p) => n + (p.taskTemplates?.length ?? 0), 0);
|
|
336
|
+
this.logger.info(`[AutoPhase] Planned ${phases.length} phases / ${todos} todos for: ${goal}`);
|
|
337
|
+
return phases;
|
|
338
|
+
}
|
|
339
|
+
this.logger.info(`[AutoPhase] Planner produced no phases; using defaults for: ${goal}`);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
this.logger.error(`[AutoPhase] Planning failed, using defaults: ${err instanceof Error ? err.message : String(err)}`);
|
|
342
|
+
}
|
|
343
|
+
return this.defaultPhases();
|
|
344
|
+
}
|
|
345
|
+
async executeTaskWithAgent(task, phaseId, env) {
|
|
346
|
+
const prompt = `Execute task: ${task.title}
|
|
347
|
+
|
|
348
|
+
Description: ${task.description}
|
|
349
|
+
Phase: ${phaseId}
|
|
350
|
+
Priority: ${task.priority}
|
|
351
|
+
Type: ${task.type}`;
|
|
352
|
+
const signal = this.abort?.signal ?? new AbortController().signal;
|
|
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
|
+
}
|
|
360
|
+
}
|
|
361
|
+
async handleTaskStatusChange(taskId, status) {
|
|
362
|
+
if (!this.graph) return;
|
|
363
|
+
for (const phase of this.graph.phases.values()) {
|
|
364
|
+
const task = phase.taskGraph.nodes.get(taskId);
|
|
365
|
+
if (task) {
|
|
366
|
+
task.status = status;
|
|
367
|
+
task.updatedAt = Date.now();
|
|
368
|
+
this.broadcastState();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
startBroadcast() {
|
|
374
|
+
if (this.broadcastInterval) return;
|
|
375
|
+
this.broadcastInterval = setInterval(() => {
|
|
376
|
+
const progress = this.orchestrator?.getProgress();
|
|
377
|
+
if (progress) this.broadcast({ type: "autophase.progress", payload: progress });
|
|
378
|
+
this.broadcastState();
|
|
379
|
+
}, 2e3);
|
|
380
|
+
}
|
|
381
|
+
stopBroadcast() {
|
|
382
|
+
if (this.broadcastInterval) {
|
|
383
|
+
clearInterval(this.broadcastInterval);
|
|
384
|
+
this.broadcastInterval = null;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
broadcastState(activePhaseId) {
|
|
388
|
+
if (!this.graph) return;
|
|
389
|
+
const state = this.buildState(activePhaseId);
|
|
390
|
+
this.broadcast({ type: "autophase.state", payload: state });
|
|
391
|
+
}
|
|
392
|
+
buildState(activePhaseId) {
|
|
393
|
+
if (!this.graph) {
|
|
394
|
+
return { phases: [], tasks: [], overallPercent: 0, autonomous: true, title: "" };
|
|
395
|
+
}
|
|
396
|
+
const phases = Array.from(this.graph.phases.values());
|
|
397
|
+
const currentActiveId = activePhaseId || phases.find((p) => p.status === "running")?.id || phases[0]?.id || "";
|
|
398
|
+
const activePhase = this.graph.phases.get(currentActiveId);
|
|
399
|
+
const totalTasks = phases.reduce((sum, p) => sum + p.taskGraph.nodes.size, 0);
|
|
400
|
+
const completedTasks = phases.reduce(
|
|
401
|
+
(sum, p) => sum + Array.from(p.taskGraph.nodes.values()).filter((t) => t.status === "completed").length,
|
|
402
|
+
0
|
|
403
|
+
);
|
|
404
|
+
const phaseItems = phases.map((p) => ({
|
|
405
|
+
id: p.id,
|
|
406
|
+
name: p.name,
|
|
407
|
+
description: p.description,
|
|
408
|
+
status: p.status,
|
|
409
|
+
priority: p.priority,
|
|
410
|
+
estimateHours: p.estimateHours,
|
|
411
|
+
actualDurationMs: p.actualDurationMs,
|
|
412
|
+
startedAt: p.startedAt,
|
|
413
|
+
completedAt: p.completedAt,
|
|
414
|
+
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,
|
|
415
|
+
taskCount: p.taskGraph.nodes.size,
|
|
416
|
+
completedTasks: Array.from(p.taskGraph.nodes.values()).filter((t) => t.status === "completed").length,
|
|
417
|
+
assignedAgents: p.assignedAgents,
|
|
418
|
+
isActive: p.id === currentActiveId
|
|
419
|
+
}));
|
|
420
|
+
const taskItems = activePhase ? Array.from(activePhase.taskGraph.nodes.values()).map((t) => ({
|
|
421
|
+
id: t.id,
|
|
422
|
+
title: t.title,
|
|
423
|
+
description: t.description,
|
|
424
|
+
status: t.status,
|
|
425
|
+
priority: t.priority,
|
|
426
|
+
type: t.type,
|
|
427
|
+
estimateHours: t.estimateHours,
|
|
428
|
+
actualHours: t.actualHours,
|
|
429
|
+
assignee: t.assignee,
|
|
430
|
+
tags: t.tags || [],
|
|
431
|
+
startedAt: t.startedAt,
|
|
432
|
+
completedAt: t.completedAt
|
|
433
|
+
})) : [];
|
|
434
|
+
const completedPhases = phases.filter((p) => p.status === "completed").length;
|
|
435
|
+
return {
|
|
436
|
+
title: this.graph.title,
|
|
437
|
+
phases: phaseItems,
|
|
438
|
+
tasks: taskItems,
|
|
439
|
+
activePhaseId: currentActiveId,
|
|
440
|
+
overallPercent: phases.length > 0 ? Math.round(completedPhases / phases.length * 100) : 0,
|
|
441
|
+
autonomous: this.graph.autonomous,
|
|
442
|
+
totalTasks,
|
|
443
|
+
completedTasks
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
sendState(client) {
|
|
447
|
+
if (!this.graph) return;
|
|
448
|
+
const state = this.buildState();
|
|
449
|
+
this.send(client, { type: "autophase.state", payload: state });
|
|
450
|
+
}
|
|
451
|
+
broadcast(msg) {
|
|
452
|
+
const data = JSON.stringify(msg);
|
|
453
|
+
for (const client of this.clients) {
|
|
454
|
+
if (client.ws.readyState === 1) {
|
|
455
|
+
client.ws.send(data);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
send(client, msg) {
|
|
460
|
+
if (client.ws.readyState === 1) {
|
|
461
|
+
client.ws.send(JSON.stringify(msg));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
};
|
|
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
|
+
|
|
137
604
|
// src/server/index.ts
|
|
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'";
|
|
138
606
|
async function startWebUI(opts = {}) {
|
|
139
607
|
const wsPort = opts.wsPort ?? 3457;
|
|
140
608
|
const wsHost = opts.wsHost ?? "127.0.0.1";
|
|
@@ -336,6 +804,15 @@ async function startWebUI(opts = {}) {
|
|
|
336
804
|
toolExecutor
|
|
337
805
|
});
|
|
338
806
|
console.log("[WebUI] Agent initialized");
|
|
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);
|
|
339
816
|
async function sessionStartPayload() {
|
|
340
817
|
let maxContext = 0;
|
|
341
818
|
let inputCost = 0;
|
|
@@ -368,12 +845,33 @@ async function startWebUI(opts = {}) {
|
|
|
368
845
|
const wsToken = randomBytes(16).toString("hex");
|
|
369
846
|
console.log(`[WebUI] WS auth token: ${wsToken.slice(0, 4)}\u2026${wsToken.slice(-4)} (masked)`);
|
|
370
847
|
const isLoopback = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
848
|
+
const tokenMatches = (provided) => {
|
|
849
|
+
if (!provided) return false;
|
|
850
|
+
const a = Buffer.from(provided);
|
|
851
|
+
const b = Buffer.from(wsToken);
|
|
852
|
+
if (a.length !== b.length) return false;
|
|
853
|
+
return timingSafeEqual(a, b);
|
|
854
|
+
};
|
|
855
|
+
const hostHeaderOk = (req) => {
|
|
856
|
+
const boundToLoopback = wsHost === "127.0.0.1" || wsHost === "::1" || wsHost === "localhost";
|
|
857
|
+
if (!boundToLoopback) return true;
|
|
858
|
+
const hostHeader = (req.headers.host ?? "").trim();
|
|
859
|
+
if (!hostHeader) return false;
|
|
860
|
+
let hostname;
|
|
861
|
+
try {
|
|
862
|
+
hostname = new URL(`http://${hostHeader}`).hostname;
|
|
863
|
+
} catch {
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
return isLoopback(hostname);
|
|
867
|
+
};
|
|
371
868
|
const verifyClient = (info) => {
|
|
372
869
|
const origin = info.origin;
|
|
373
870
|
const url = info.req.url ?? "";
|
|
374
871
|
const tokenMatch = url.match(/[?&]token=([^&]+)/);
|
|
375
872
|
const providedToken = tokenMatch ? tokenMatch[1] : void 0;
|
|
376
|
-
const tokenOk = providedToken
|
|
873
|
+
const tokenOk = tokenMatches(providedToken);
|
|
874
|
+
if (!hostHeaderOk(info.req)) return false;
|
|
377
875
|
if (!origin) {
|
|
378
876
|
const remoteIp = info.req.socket.remoteAddress ?? "";
|
|
379
877
|
const isRemoteLoopback = remoteIp === "127.0.0.1" || remoteIp === "::1";
|
|
@@ -383,18 +881,24 @@ async function startWebUI(opts = {}) {
|
|
|
383
881
|
try {
|
|
384
882
|
const { hostname } = new URL(origin);
|
|
385
883
|
if (isLoopback(hostname)) return true;
|
|
386
|
-
if (wsHost === "0.0.0.0") return tokenOk;
|
|
387
884
|
return tokenOk;
|
|
388
885
|
} catch {
|
|
389
886
|
return false;
|
|
390
887
|
}
|
|
391
888
|
};
|
|
889
|
+
const WS_MAX_PAYLOAD = 8 * 1024 * 1024;
|
|
392
890
|
const wssPrimary = new WebSocketServer({
|
|
393
891
|
port: wsPort,
|
|
394
892
|
host: wsHost,
|
|
395
|
-
verifyClient
|
|
893
|
+
verifyClient,
|
|
894
|
+
maxPayload: WS_MAX_PAYLOAD
|
|
396
895
|
});
|
|
397
|
-
const wssSecondary = wsHost === "127.0.0.1" ? new WebSocketServer({
|
|
896
|
+
const wssSecondary = wsHost === "127.0.0.1" ? new WebSocketServer({
|
|
897
|
+
port: wsPort,
|
|
898
|
+
host: "::1",
|
|
899
|
+
verifyClient,
|
|
900
|
+
maxPayload: WS_MAX_PAYLOAD
|
|
901
|
+
}) : null;
|
|
398
902
|
const clients = /* @__PURE__ */ new Map();
|
|
399
903
|
const RATE_LIMIT_MESSAGES = 60;
|
|
400
904
|
const RATE_LIMIT_WINDOW_MS = 6e4;
|
|
@@ -529,6 +1033,8 @@ async function startWebUI(opts = {}) {
|
|
|
529
1033
|
void sessionStartPayload().then((payload) => {
|
|
530
1034
|
send(ws, { type: "session.start", payload });
|
|
531
1035
|
});
|
|
1036
|
+
autoPhaseHandler.addClient(ws);
|
|
1037
|
+
worktreeHandler.addClient(ws);
|
|
532
1038
|
ws.on("message", async (data) => {
|
|
533
1039
|
if (!checkRateLimit(ws)) {
|
|
534
1040
|
send(ws, {
|
|
@@ -1341,6 +1847,12 @@ async function startWebUI(opts = {}) {
|
|
|
1341
1847
|
});
|
|
1342
1848
|
break;
|
|
1343
1849
|
}
|
|
1850
|
+
default:
|
|
1851
|
+
if (msg.type.startsWith("autophase.")) {
|
|
1852
|
+
await autoPhaseHandler.handleMessage(msg);
|
|
1853
|
+
} else {
|
|
1854
|
+
send(ws, { type: "error", payload: { phase: "handleMessage", message: `Unknown message type: ${msg.type}` } });
|
|
1855
|
+
}
|
|
1344
1856
|
}
|
|
1345
1857
|
}
|
|
1346
1858
|
async function loadSavedProviders() {
|
|
@@ -1537,10 +2049,7 @@ async function startWebUI(opts = {}) {
|
|
|
1537
2049
|
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
1538
2050
|
if (ext === ".html") {
|
|
1539
2051
|
res.setHeader("Cache-Control", "no-cache");
|
|
1540
|
-
res.setHeader(
|
|
1541
|
-
"Content-Security-Policy",
|
|
1542
|
-
"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:"
|
|
1543
|
-
);
|
|
2052
|
+
res.setHeader("Content-Security-Policy", HTML_CSP);
|
|
1544
2053
|
}
|
|
1545
2054
|
const fileContent = await fs2.readFile(resolvedPath);
|
|
1546
2055
|
res.writeHead(200);
|
|
@@ -1552,7 +2061,11 @@ async function startWebUI(opts = {}) {
|
|
|
1552
2061
|
res.writeHead(200, {
|
|
1553
2062
|
"Content-Type": "text/html",
|
|
1554
2063
|
"X-Content-Type-Options": "nosniff",
|
|
1555
|
-
"X-Frame-Options": "DENY"
|
|
2064
|
+
"X-Frame-Options": "DENY",
|
|
2065
|
+
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
2066
|
+
// SPA fallback previously shipped no CSP — apply the same policy as
|
|
2067
|
+
// the direct .html branch so deep-linked routes aren't unprotected.
|
|
2068
|
+
"Content-Security-Policy": HTML_CSP
|
|
1556
2069
|
});
|
|
1557
2070
|
res.end(fileContent);
|
|
1558
2071
|
} catch {
|