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