@wrongstack/webui-server 0.300.0 → 0.302.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.
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ var BOOLEAN_PREF_KEYS = /* @__PURE__ */ new Set([
48
48
  "chime",
49
49
  "confirmExit",
50
50
  "nextPrediction",
51
+ "nextStepsTool",
51
52
  "titleAnimation",
52
53
  "enhanceEnabled",
53
54
  "featureMcp",
@@ -171,6 +172,7 @@ var ENUM_PREF_KEYS = {
171
172
  fsAccess: /* @__PURE__ */ new Set(["unrestricted", "project"]),
172
173
  // Chimera autoFix + auto-review cascade threshold
173
174
  chimeraAutoFix: /* @__PURE__ */ new Set(["off", "ask", "auto"]),
175
+ autoReviewModelSelection: /* @__PURE__ */ new Set(["round-robin", "random"]),
174
176
  autoReviewCascadeOn: /* @__PURE__ */ new Set(["off", "critical", "high"]),
175
177
  fleetChatVerbosity: /* @__PURE__ */ new Set(["off", "full"]),
176
178
  showAgentSwarmPanel: /* @__PURE__ */ new Set(["bottom", "sidebar", "off"])
@@ -4316,6 +4318,9 @@ async function handleConversationRoute(ws, msg, handlers) {
4316
4318
  case "user_message":
4317
4319
  await handlers.userMessage(ws, msg);
4318
4320
  return true;
4321
+ case "topic.advice":
4322
+ await handlers.topicAdvice(ws, msg);
4323
+ return true;
4319
4324
  case "abort":
4320
4325
  await handlers.abort(ws, msg);
4321
4326
  return true;
@@ -4331,6 +4336,7 @@ async function handleConversationRoute(ws, msg, handlers) {
4331
4336
  }
4332
4337
 
4333
4338
  // src/server/conversation-operations.ts
4339
+ import { startFreshTopicContext, TopicShiftAdvisor } from "@wrongstack/core/execution";
4334
4340
  import {
4335
4341
  buildUserContentBlocks,
4336
4342
  IncomingImageError,
@@ -4347,6 +4353,7 @@ function requestedSessionId(msg) {
4347
4353
  return payload && typeof payload === "object" && typeof payload.sessionId === "string" ? payload.sessionId : void 0;
4348
4354
  }
4349
4355
  function createConversationOperations(ctx) {
4356
+ const topicShiftAdvisor = new TopicShiftAdvisor();
4350
4357
  const sessionPayload2 = (payload) => {
4351
4358
  const provided = payload["sessionId"];
4352
4359
  const sessionId = typeof provided === "string" && provided.length > 0 ? provided : ctx.getSessionId();
@@ -4367,6 +4374,38 @@ function createConversationOperations(ctx) {
4367
4374
  return false;
4368
4375
  };
4369
4376
  return {
4377
+ topicAdvice: async (ws, msg) => {
4378
+ if (!ensureCurrentSession(ws, msg, "topic.advice")) return;
4379
+ const payload = msg.payload ?? {};
4380
+ if (typeof payload.requestId !== "string" || typeof payload.prompt !== "string") {
4381
+ ctx.send(ws, {
4382
+ type: "topic.advice_result",
4383
+ payload: sessionPayload2({
4384
+ requestId: typeof payload.requestId === "string" ? payload.requestId : "",
4385
+ suggestNewContext: false,
4386
+ confidence: 0,
4387
+ reason: "Invalid topic advice request.",
4388
+ source: "local"
4389
+ })
4390
+ });
4391
+ return;
4392
+ }
4393
+ const agent = ctx.getAgent();
4394
+ const configuredMax = agent.ctx.meta["effectiveMaxContext"];
4395
+ const maxContext = typeof configuredMax === "number" ? configuredMax : agent.ctx.provider.capabilities.maxContext;
4396
+ const advice = await topicShiftAdvisor.advise({
4397
+ prompt: payload.prompt,
4398
+ messages: agent.ctx.messages,
4399
+ provider: agent.ctx.provider,
4400
+ model: agent.ctx.model,
4401
+ contextTokens: agent.ctx.lastRequestTokens,
4402
+ maxContext
4403
+ });
4404
+ ctx.send(ws, {
4405
+ type: "topic.advice_result",
4406
+ payload: sessionPayload2({ requestId: payload.requestId, ...advice })
4407
+ });
4408
+ },
4370
4409
  userMessage: async (ws, msg) => {
4371
4410
  if (!ensureCurrentSession(ws, msg, "user_message")) return;
4372
4411
  const payload = msg.payload ?? {};
@@ -4384,6 +4423,7 @@ function createConversationOperations(ctx) {
4384
4423
  const originSessionId = ctx.getSessionId();
4385
4424
  try {
4386
4425
  const agent = ctx.getAgent();
4426
+ if (payload.freshContext === true) await startFreshTopicContext(agent.ctx);
4387
4427
  const content = typeof payload.content === "string" ? payload.content : "";
4388
4428
  let input = content;
4389
4429
  const imageBlocks = parseIncomingImages(payload.images, payload.imageBase64);
@@ -5512,6 +5552,39 @@ import {
5512
5552
  isKanbanServerAvailable
5513
5553
  } from "@wrongstack/kanban";
5514
5554
  import * as net from "node:net";
5555
+
5556
+ // src/server/privileged-actions.ts
5557
+ import { randomUUID as randomUUID2 } from "node:crypto";
5558
+ import {
5559
+ isTrustDecisionAllowed
5560
+ } from "@wrongstack/core/security";
5561
+ async function authorizeWebUIAction(boundary, action, logger) {
5562
+ const request = {
5563
+ version: 1,
5564
+ requestId: randomUUID2(),
5565
+ actor: {
5566
+ kind: "remote-client",
5567
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
5568
+ },
5569
+ surface: "webui",
5570
+ capability: action.capability,
5571
+ subject: action.subject,
5572
+ risk: action.risk,
5573
+ scope: {
5574
+ ...action.cwd ? { cwd: action.cwd } : {},
5575
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
5576
+ },
5577
+ authContext: { method: "session" },
5578
+ ...action.metadata ? { metadata: action.metadata } : {}
5579
+ };
5580
+ const decision = await boundary.evaluate(request);
5581
+ logger?.debug?.(
5582
+ `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
5583
+ );
5584
+ return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
5585
+ }
5586
+
5587
+ // src/server/connections-health-route.ts
5515
5588
  import { readGovernanceDaemonOperatorStatus } from "@wrongstack/runtime/governance-bootstrap";
5516
5589
  import { isSageProjectServerAvailable, SageProjectServerConnection } from "@wrongstack/sage";
5517
5590
  import {
@@ -5917,6 +5990,42 @@ async function handleConnectionsServiceAction(ws, message, context) {
5917
5990
  return true;
5918
5991
  }
5919
5992
  const action = rawAction;
5993
+ if (!context.trustBoundary) {
5994
+ context.send(ws, {
5995
+ type: "connections.service_action_result",
5996
+ payload: {
5997
+ serviceId,
5998
+ action,
5999
+ success: false,
6000
+ message: "Service control is unavailable: no policy authority is configured."
6001
+ }
6002
+ });
6003
+ return true;
6004
+ }
6005
+ const projectRootForAuth = context.getProjectRoot();
6006
+ const authorization = await authorizeWebUIAction(
6007
+ context.trustBoundary,
6008
+ {
6009
+ capability: `connections.service.${action}`,
6010
+ subject: { kind: "process", id: `${serviceId}@${projectRootForAuth}` },
6011
+ risk: "elevated",
6012
+ cwd: projectRootForAuth,
6013
+ metadata: { transport: "websocket", serviceId, action }
6014
+ },
6015
+ context.logger
6016
+ );
6017
+ if (!authorization.allowed) {
6018
+ context.send(ws, {
6019
+ type: "connections.service_action_result",
6020
+ payload: {
6021
+ serviceId,
6022
+ action,
6023
+ success: false,
6024
+ message: authorization.reason ?? "Refused by policy."
6025
+ }
6026
+ });
6027
+ return true;
6028
+ }
5920
6029
  if (serviceId === "webui") {
5921
6030
  context.send(ws, {
5922
6031
  type: "connections.service_action_result",
@@ -6354,28 +6463,28 @@ async function restartMailboxServer(projectRoot) {
6354
6463
  var RESTART_POLL_INTERVAL_MS = 250;
6355
6464
  var RESTART_DEADLINE_MS = 3e3;
6356
6465
  function isEndpointAlive(endpoint) {
6357
- return new Promise((resolve16) => {
6466
+ return new Promise((resolve17) => {
6358
6467
  const sock = net.createConnection(endpoint);
6359
6468
  const timer = setTimeout(() => {
6360
6469
  sock.destroy();
6361
- resolve16(false);
6470
+ resolve17(false);
6362
6471
  }, 500);
6363
6472
  timer.unref?.();
6364
6473
  sock.once("connect", () => {
6365
6474
  clearTimeout(timer);
6366
6475
  sock.destroy();
6367
- resolve16(true);
6476
+ resolve17(true);
6368
6477
  });
6369
6478
  sock.once("error", () => {
6370
6479
  clearTimeout(timer);
6371
6480
  sock.destroy();
6372
- resolve16(false);
6481
+ resolve17(false);
6373
6482
  });
6374
6483
  });
6375
6484
  }
6376
6485
  async function waitForShutdown(probe) {
6377
6486
  if (!probe) {
6378
- await new Promise((resolve16) => setTimeout(resolve16, RESTART_POLL_INTERVAL_MS));
6487
+ await new Promise((resolve17) => setTimeout(resolve17, RESTART_POLL_INTERVAL_MS));
6379
6488
  return;
6380
6489
  }
6381
6490
  const deadline = Date.now() + RESTART_DEADLINE_MS;
@@ -6386,7 +6495,7 @@ async function waitForShutdown(probe) {
6386
6495
  } catch {
6387
6496
  return;
6388
6497
  }
6389
- await new Promise((resolve16) => setTimeout(resolve16, RESTART_POLL_INTERVAL_MS));
6498
+ await new Promise((resolve17) => setTimeout(resolve17, RESTART_POLL_INTERVAL_MS));
6390
6499
  }
6391
6500
  }
6392
6501
  function failureService(id, label, required, mode, error2, latencyMs) {
@@ -6549,9 +6658,9 @@ async function handleGitInfo(ws, projectRoot) {
6549
6658
  const cwd = projectRoot || void 0;
6550
6659
  try {
6551
6660
  const { execFile: ef } = await import("node:child_process");
6552
- const git = (args) => new Promise((resolve16) => {
6661
+ const git = (args) => new Promise((resolve17) => {
6553
6662
  ef("git", args, { cwd, timeout: 3e3 }, (err, stdout) => {
6554
- resolve16(err ? "" : stdout.trim());
6663
+ resolve17(err ? "" : stdout.trim());
6555
6664
  });
6556
6665
  });
6557
6666
  const [branchRaw, diffRaw, statusRaw, upstreamRaw] = await Promise.all([
@@ -6577,12 +6686,12 @@ async function handleGitInfo(ws, projectRoot) {
6577
6686
  function makeGit(cwd) {
6578
6687
  return async (args) => {
6579
6688
  const { execFile: ef } = await import("node:child_process");
6580
- return new Promise((resolve16) => {
6689
+ return new Promise((resolve17) => {
6581
6690
  ef(
6582
6691
  "git",
6583
6692
  args,
6584
6693
  { cwd, timeout: 5e3, maxBuffer: 1024 * 1024 * 16 },
6585
- (err, stdout) => resolve16(err ? "" : stdout)
6694
+ (err, stdout) => resolve17(err ? "" : stdout)
6586
6695
  );
6587
6696
  });
6588
6697
  };
@@ -6747,7 +6856,7 @@ import { execFile } from "node:child_process";
6747
6856
  var GIT_TIMEOUT_MS = 1e4;
6748
6857
  var GIT_MAX_OUTPUT_BYTES = 1024 * 1024;
6749
6858
  function gitStdout(cwd, args) {
6750
- return new Promise((resolve16) => {
6859
+ return new Promise((resolve17) => {
6751
6860
  execFile(
6752
6861
  "git",
6753
6862
  [...args],
@@ -6758,7 +6867,7 @@ function gitStdout(cwd, args) {
6758
6867
  timeout: GIT_TIMEOUT_MS,
6759
6868
  maxBuffer: GIT_MAX_OUTPUT_BYTES
6760
6869
  },
6761
- (error2, stdout) => resolve16(error2 ? null : stdout)
6870
+ (error2, stdout) => resolve17(error2 ? null : stdout)
6762
6871
  );
6763
6872
  });
6764
6873
  }
@@ -7049,14 +7158,14 @@ var GoalWebSocketHandler = class {
7049
7158
  const cwd = env?.cwd ?? this.projectRoot;
7050
7159
  try {
7051
7160
  const { execFile: execFile2 } = await import("node:child_process");
7052
- const result = await new Promise((resolve16) => {
7161
+ const result = await new Promise((resolve17) => {
7053
7162
  const npxCommand = process.platform === "win32" ? "npx.cmd" : "npx";
7054
7163
  execFile2(npxCommand, ["tsc", "--noEmit"], { cwd, timeout: 6e4 }, (err, stdout, stderr) => {
7055
7164
  if (err && err.code === "ENOENT") {
7056
- resolve16("[verify] tsc not found \u2014 skipping");
7165
+ resolve17("[verify] tsc not found \u2014 skipping");
7057
7166
  return;
7058
7167
  }
7059
- resolve16(stdout + stderr);
7168
+ resolve17(stdout + stderr);
7060
7169
  });
7061
7170
  });
7062
7171
  if (result.includes("[verify]") || result.trim().length === 0) {
@@ -7097,12 +7206,12 @@ Run npx tsc --noEmit to verify the fix. Output the fixed file paths.`;
7097
7206
  ...maybeVerify,
7098
7207
  onPhaseComplete: (phase) => {
7099
7208
  this.logger.info(`[Goal] Phase completed: ${phase.name}`);
7100
- void this.store.save(graph);
7209
+ this.persistDetached(graph);
7101
7210
  this.broadcastState();
7102
7211
  },
7103
7212
  onPhaseFail: (phase, error2) => {
7104
7213
  this.logger.error(`[Goal] Phase failed: ${phase.name} \u2014 ${error2.message}`);
7105
- void this.store.save(graph);
7214
+ this.persistDetached(graph);
7106
7215
  this.broadcastState();
7107
7216
  }
7108
7217
  },
@@ -7119,7 +7228,7 @@ Run npx tsc --noEmit to verify the fix. Output the fixed file paths.`;
7119
7228
  this.broadcastState();
7120
7229
  void this.orchestrator.start().then(() => {
7121
7230
  this.orchestrator?.stop();
7122
- void this.store.save(graph);
7231
+ this.persistDetached(graph);
7123
7232
  this.stopBroadcast();
7124
7233
  const failed = graph.failedPhaseIds.length > 0;
7125
7234
  this.broadcast(
@@ -7317,9 +7426,27 @@ ${result_.finalText.slice(0, 2e3)}`
7317
7426
  this.logger.warn(`[Goal] Chimera review failed for "${task.title}": ${toErrorMessage2(err)}`);
7318
7427
  }
7319
7428
  }
7429
+ /**
7430
+ * Fire-and-forget persist.
7431
+ *
7432
+ * Every detached `store.save()` used to be a bare `void`, so a rejection
7433
+ * became an unhandled rejection and — under Node 22's default
7434
+ * `--unhandled-rejections=throw` — killed the process mid-run. On Windows an
7435
+ * AV scanner or indexer holding the `.wrongstack/phases/<id>.json` rename
7436
+ * target for a few hundred ms is enough (EPERM from `atomicWrite`), and in
7437
+ * `--webui` mode that takes the CLI session down with it. `handleStop` at
7438
+ * `:549` already had the `.catch`; these call sites did not.
7439
+ */
7440
+ persistDetached(graph) {
7441
+ void this.store.save(graph).catch((err) => {
7442
+ this.logger.warn(
7443
+ `[Goal] Failed to persist phase graph: ${err instanceof Error ? err.message : String(err)}`
7444
+ );
7445
+ });
7446
+ }
7320
7447
  /** Persist + broadcast after an interactive board mutation. */
7321
7448
  afterBoardMutation() {
7322
- if (this.graph) void this.store.save(this.graph);
7449
+ if (this.graph) this.persistDetached(this.graph);
7323
7450
  this.broadcastState();
7324
7451
  }
7325
7452
  async handleTaskStatusChange(taskId, status) {
@@ -7776,7 +7903,7 @@ function pushEvent(event) {
7776
7903
  }
7777
7904
  }
7778
7905
  function parseBody(req) {
7779
- return new Promise((resolve16, reject) => {
7906
+ return new Promise((resolve17, reject) => {
7780
7907
  let body = "";
7781
7908
  let bodyBytes = 0;
7782
7909
  let tooLarge = false;
@@ -7796,7 +7923,7 @@ function parseBody(req) {
7796
7923
  return;
7797
7924
  }
7798
7925
  try {
7799
- resolve16(JSON.parse(body));
7926
+ resolve17(JSON.parse(body));
7800
7927
  } catch {
7801
7928
  reject(new Error("Invalid JSON"));
7802
7929
  }
@@ -7891,7 +8018,7 @@ import * as path10 from "node:path";
7891
8018
  import { runDeadCodeScan } from "@wrongstack/tools/codebase-index";
7892
8019
  var MAX_BODY_BYTES = 10 * 1024 * 1024;
7893
8020
  function readJsonBody(req) {
7894
- return new Promise((resolve16, reject) => {
8021
+ return new Promise((resolve17, reject) => {
7895
8022
  const chunks = [];
7896
8023
  let total = 0;
7897
8024
  req.on("data", (chunk) => {
@@ -7903,7 +8030,7 @@ function readJsonBody(req) {
7903
8030
  }
7904
8031
  chunks.push(chunk);
7905
8032
  });
7906
- req.on("end", () => resolve16(Buffer.concat(chunks).toString("utf8")));
8033
+ req.on("end", () => resolve17(Buffer.concat(chunks).toString("utf8")));
7907
8034
  req.on("error", (err) => reject(err));
7908
8035
  });
7909
8036
  }
@@ -8311,12 +8438,21 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8311
8438
  const paths = resolveWstackPaths7({ projectRoot: entry.projectRoot, globalRoot });
8312
8439
  const store = new DefaultSessionStore4({ dir: paths.projectSessions });
8313
8440
  const reader = new DefaultSessionReader2({ store });
8314
- const rawEntries = [];
8441
+ const RING = Math.max(limit * 4, 2e3);
8442
+ const ring = [];
8443
+ let totalRaw = 0;
8444
+ let dropped = false;
8315
8445
  for await (const ev of reader.replay(sessionId)) {
8316
8446
  const mapped = mapWatchEntry(ev);
8317
- if (mapped) rawEntries.push(mapped);
8447
+ if (!mapped) continue;
8448
+ totalRaw += 1;
8449
+ ring.push(mapped);
8450
+ if (ring.length > RING) {
8451
+ ring.shift();
8452
+ dropped = true;
8453
+ }
8318
8454
  }
8319
- const all = correlateToolEvents(rawEntries);
8455
+ const all = correlateToolEvents(ring);
8320
8456
  const tail2 = all.slice(-limit);
8321
8457
  res.writeHead(200, { "Content-Type": "application/json" });
8322
8458
  res.end(
@@ -8325,7 +8461,12 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8325
8461
  status: entry.status,
8326
8462
  clientType: entry.clientType,
8327
8463
  projectName: entry.projectName,
8328
- total: all.length,
8464
+ // Exact when the whole session fit in the ring (the previous
8465
+ // behaviour). Past that, correlation never ran over the dropped
8466
+ // prefix, so report the raw event count — an upper bound — and say so
8467
+ // rather than silently understating the session's size.
8468
+ total: dropped ? totalRaw : all.length,
8469
+ ...dropped ? { truncated: true } : {},
8329
8470
  entries: tail2
8330
8471
  })
8331
8472
  );
@@ -8335,7 +8476,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8335
8476
  }
8336
8477
  }
8337
8478
  function readJsonBody2(req) {
8338
- return new Promise((resolve16, reject) => {
8479
+ return new Promise((resolve17, reject) => {
8339
8480
  const contentType = (req.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
8340
8481
  if (contentType !== "application/json") {
8341
8482
  reject(new Error(`Unsupported Content-Type: ${contentType || "(absent)"}`));
@@ -8351,7 +8492,7 @@ function readJsonBody2(req) {
8351
8492
  });
8352
8493
  req.on("end", () => {
8353
8494
  try {
8354
- resolve16(data ? JSON.parse(data) : {});
8495
+ resolve17(data ? JSON.parse(data) : {});
8355
8496
  } catch (err) {
8356
8497
  reject(err instanceof Error ? err : new Error(String(err)));
8357
8498
  }
@@ -8627,14 +8768,14 @@ async function readJsonBody3(res, req) {
8627
8768
  });
8628
8769
  return null;
8629
8770
  }
8630
- return new Promise((resolve16) => {
8771
+ return new Promise((resolve17) => {
8631
8772
  let data = "";
8632
8773
  let failed = false;
8633
8774
  const fail2 = (message) => {
8634
8775
  if (failed) return;
8635
8776
  failed = true;
8636
8777
  sendJson2(res, 400, { error: { code: "INVALID_BODY", message } });
8637
- resolve16(null);
8778
+ resolve17(null);
8638
8779
  };
8639
8780
  req.on("data", (chunk) => {
8640
8781
  if (failed) return;
@@ -8647,7 +8788,7 @@ async function readJsonBody3(res, req) {
8647
8788
  req.on("end", () => {
8648
8789
  if (failed) return;
8649
8790
  try {
8650
- resolve16(data.trim().length === 0 ? {} : JSON.parse(data));
8791
+ resolve17(data.trim().length === 0 ? {} : JSON.parse(data));
8651
8792
  } catch {
8652
8793
  fail2("Request body is not valid JSON");
8653
8794
  }
@@ -9015,7 +9156,7 @@ async function touchProjectInManifest(options, globalConfigPath) {
9015
9156
  }
9016
9157
 
9017
9158
  // src/server/techstack-handlers.ts
9018
- import { randomUUID as randomUUID2 } from "node:crypto";
9159
+ import { randomUUID as randomUUID3 } from "node:crypto";
9019
9160
  var DEEP_DIVE_TIMEOUT_MS = 6e4;
9020
9161
  function sendJson3(res, status, data) {
9021
9162
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -9056,7 +9197,7 @@ function requireJobDeps(res, deps2) {
9056
9197
  }
9057
9198
  function startJob(res, deps2, kind) {
9058
9199
  if (!requireJobDeps(res, deps2)) return;
9059
- const jobId = randomUUID2();
9200
+ const jobId = randomUUID3();
9060
9201
  const controller = new AbortController();
9061
9202
  deps2.runningJobs?.set(jobId, controller);
9062
9203
  deps2.emit?.({ type: "techstack.job.started", payload: { jobId, kind } });
@@ -9486,7 +9627,7 @@ function strictDecodeParam(segment, res) {
9486
9627
  function createHttpServer(opts) {
9487
9628
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
9488
9629
  const distDir = path13.resolve(opts.distDir);
9489
- const requireAccessToken = true;
9630
+ const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
9490
9631
  const secureCookies = opts.secureCookies ?? (opts.publicWsUrl?.trim().toLowerCase().startsWith("wss:") ?? false);
9491
9632
  const trustedHostnames = (() => {
9492
9633
  const names = [...opts.allowedHostnames ?? []];
@@ -9522,7 +9663,7 @@ function createHttpServer(opts) {
9522
9663
  res.end(JSON.stringify({ error: "forbidden: untrusted request origin" }));
9523
9664
  return;
9524
9665
  }
9525
- const providedAccessToken = requestToken(req, url);
9666
+ const providedAccessToken = requestToken(req, url, { allowQuery: true });
9526
9667
  const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
9527
9668
  const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
9528
9669
  if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
@@ -10032,10 +10173,78 @@ function createHttpServer(opts) {
10032
10173
  }
10033
10174
 
10034
10175
  // src/server/instance-registry.ts
10176
+ import * as fs11 from "node:fs/promises";
10035
10177
  import * as os from "node:os";
10036
10178
  import * as path14 from "node:path";
10037
- import * as fs11 from "node:fs/promises";
10038
10179
  import { atomicWrite as atomicWrite4 } from "@wrongstack/core/utils";
10180
+ function normalizeRoot(root) {
10181
+ const resolved = path14.resolve(root);
10182
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
10183
+ }
10184
+ function isLiveSessionStatus(status) {
10185
+ return status === "active" || status === "idle";
10186
+ }
10187
+ function instanceRole(instance) {
10188
+ return instance.role ?? "standalone";
10189
+ }
10190
+ function resolveAttachability(input) {
10191
+ const { session, instance } = input;
10192
+ if (!isLiveSessionStatus(session.status)) {
10193
+ return { attachable: false, degradedReason: "session-not-live" };
10194
+ }
10195
+ if (!instance) {
10196
+ return { attachable: false, degradedReason: "live-session-no-webui-endpoint" };
10197
+ }
10198
+ if (instance.pid !== session.pid) {
10199
+ return { attachable: false, degradedReason: "endpoint-owner-mismatch" };
10200
+ }
10201
+ if (!instance.sessionId) {
10202
+ return { attachable: false, instance, degradedReason: "endpoint-missing-session-id" };
10203
+ }
10204
+ if (instance.sessionId !== session.sessionId) {
10205
+ return { attachable: false, instance, degradedReason: "endpoint-session-mismatch" };
10206
+ }
10207
+ if (instanceRole(instance) !== "session-child") {
10208
+ return { attachable: false, instance, degradedReason: "endpoint-not-session-child" };
10209
+ }
10210
+ if (instance.attachable === false) {
10211
+ return { attachable: false, instance, degradedReason: "endpoint-not-attachable" };
10212
+ }
10213
+ return {
10214
+ attachable: true,
10215
+ endpoint: {
10216
+ host: instance.host,
10217
+ httpPort: instance.httpPort,
10218
+ url: instance.url,
10219
+ ...instance.authToken ? { authToken: instance.authToken } : {}
10220
+ }
10221
+ };
10222
+ }
10223
+ function joinSessionRegistryWithWebUIInstances(input) {
10224
+ const targetRoot = input.projectRoot ? normalizeRoot(input.projectRoot) : void 0;
10225
+ const sessions = input.sessions.filter((session) => {
10226
+ if (input.projectSlug && session.projectSlug !== input.projectSlug) return false;
10227
+ if (targetRoot && normalizeRoot(session.projectRoot) !== targetRoot) return false;
10228
+ return true;
10229
+ });
10230
+ return sessions.map((session) => {
10231
+ const instance = input.instances.find((candidate) => candidate.sessionId === session.sessionId) ?? input.instances.find(
10232
+ (candidate) => candidate.pid === session.pid && normalizeRoot(candidate.projectRoot) === normalizeRoot(session.projectRoot)
10233
+ );
10234
+ const resolved = resolveAttachability({ session, instance });
10235
+ return {
10236
+ sessionId: session.sessionId,
10237
+ projectRoot: session.projectRoot,
10238
+ workingDir: session.workingDir,
10239
+ sessionPid: session.pid,
10240
+ status: session.status,
10241
+ ...instance ? { instance } : {},
10242
+ ...resolved.endpoint ? { endpoint: resolved.endpoint } : {},
10243
+ attachable: resolved.attachable,
10244
+ ...resolved.degradedReason ? { degradedReason: resolved.degradedReason } : {}
10245
+ };
10246
+ });
10247
+ }
10039
10248
  function defaultBaseDir() {
10040
10249
  return path14.join(os.homedir(), ".wrongstack");
10041
10250
  }
@@ -10383,6 +10592,27 @@ import {
10383
10592
  getServerKanbanStore
10384
10593
  } from "@wrongstack/kanban";
10385
10594
  import { recordKanbanVerificationEvidence } from "@wrongstack/tools";
10595
+
10596
+ // src/server/kanban-broadcast.ts
10597
+ function kanbanBoardMessage(board) {
10598
+ return { type: "kanban.get", payload: { success: true, data: { board } } };
10599
+ }
10600
+ function kanbanListMessage(boards) {
10601
+ return { type: "kanban.list", payload: { success: true, data: boards } };
10602
+ }
10603
+ function kanbanDeletedMessage(boardId) {
10604
+ return { type: "kanban.delete", payload: { success: true, data: { removed: true, boardId } } };
10605
+ }
10606
+ async function publishKanbanBoard(broadcast2, board, listBoards6) {
10607
+ broadcast2(kanbanBoardMessage(board));
10608
+ if (!listBoards6) return;
10609
+ try {
10610
+ broadcast2(kanbanListMessage(await listBoards6()));
10611
+ } catch {
10612
+ }
10613
+ }
10614
+
10615
+ // src/server/kanban-dispatch.ts
10386
10616
  function parseResolvedDispatchRoute(summary) {
10387
10617
  const tags = summary.match(/Spawned subagent\s+\S+\s+\((.*?)\)\s+for task/i)?.[1];
10388
10618
  if (!tags) return {};
@@ -10513,10 +10743,7 @@ async function handleKanbanTaskDispatch(ws, payload, ctx) {
10513
10743
  payload: { success: true, data: { boardId: board.id, task: completedTask } }
10514
10744
  });
10515
10745
  if (completedBoard) {
10516
- ctx.broadcast?.({
10517
- type: "kanban.get",
10518
- payload: { success: true, data: { board: completedBoard } }
10519
- });
10746
+ ctx.broadcast?.(kanbanBoardMessage(completedBoard));
10520
10747
  }
10521
10748
  ctx.broadcast?.({
10522
10749
  type: "kanban.list",
@@ -10540,7 +10767,7 @@ async function handleKanbanTaskDispatch(ws, payload, ctx) {
10540
10767
  payload: { success: true, data: { boardId: board.id, task: runningTask } }
10541
10768
  });
10542
10769
  if (started?.board) {
10543
- ctx.broadcast?.({ type: "kanban.get", payload: { success: true, data: { board: started.board } } });
10770
+ ctx.broadcast?.(kanbanBoardMessage(started.board));
10544
10771
  }
10545
10772
  reply(ws, "kanban.task.dispatch", true, { boardId: board.id, task: runningTask, summary });
10546
10773
  } catch (error2) {
@@ -10783,14 +11010,11 @@ async function handleDecompositionResolution(ws, type, payload, ctx) {
10783
11010
  type: "kanban.decomposition.applied",
10784
11011
  payload: { success: true, data: { board: resolved.board } }
10785
11012
  });
10786
- ctx.broadcast?.({
10787
- type: "kanban.get",
10788
- payload: { success: true, data: { board: resolved.board } }
10789
- });
10790
- ctx.broadcast?.({
10791
- type: "kanban.list",
10792
- payload: { success: true, data: await listBoards(ctx.projectRoot) }
10793
- });
11013
+ await publishKanbanBoard(
11014
+ (message) => ctx.broadcast?.(message),
11015
+ resolved.board,
11016
+ () => listBoards(ctx.projectRoot)
11017
+ );
10794
11018
  } else {
10795
11019
  ctx.broadcast?.({
10796
11020
  type: "kanban.decomposition.resolved",
@@ -10823,10 +11047,7 @@ async function handleTaskVerification(ws, type, payload, ctx) {
10823
11047
  payload: { success: true, data: { boardId, task: freshTask } }
10824
11048
  });
10825
11049
  if (persisted) {
10826
- ctx.broadcast?.({
10827
- type: "kanban.get",
10828
- payload: { success: true, data: { board: persisted } }
10829
- });
11050
+ ctx.broadcast?.(kanbanBoardMessage(persisted));
10830
11051
  }
10831
11052
  } catch (err) {
10832
11053
  ctx.broadcast?.({
@@ -11749,10 +11970,7 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11749
11970
  let connectionCount = 0;
11750
11971
  const broadcastDeleted = (boardId) => {
11751
11972
  knownRevisions.delete(boardId);
11752
- broadcastMessage({
11753
- type: "kanban.delete",
11754
- payload: { success: true, data: { removed: true, boardId } }
11755
- });
11973
+ broadcastMessage(kanbanDeletedMessage(boardId));
11756
11974
  };
11757
11975
  const broadcastBoard = async (boardId) => {
11758
11976
  const board = await store.getBoard(boardId);
@@ -11761,10 +11979,7 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11761
11979
  return;
11762
11980
  }
11763
11981
  knownRevisions.set(boardId, board.updatedAt);
11764
- broadcastMessage({
11765
- type: "kanban.get",
11766
- payload: { success: true, data: { board } }
11767
- });
11982
+ broadcastMessage(kanbanBoardMessage(board));
11768
11983
  };
11769
11984
  const reconcileAfterConnect = async () => {
11770
11985
  const summaries = await store.listBoards();
@@ -11783,20 +11998,36 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11783
11998
  }
11784
11999
  }
11785
12000
  };
11786
- return bridgeKanbanSupervisor(
12001
+ const COALESCE_MS = 300;
12002
+ const pendingBroadcasts = /* @__PURE__ */ new Map();
12003
+ const scheduleBroadcast = (boardId) => {
12004
+ if (pendingBroadcasts.has(boardId)) return;
12005
+ const timer = setTimeout(() => {
12006
+ pendingBroadcasts.delete(boardId);
12007
+ void broadcastBoard(boardId).catch(() => {
12008
+ });
12009
+ }, COALESCE_MS);
12010
+ timer.unref?.();
12011
+ pendingBroadcasts.set(boardId, timer);
12012
+ };
12013
+ const unsubscribe = bridgeKanbanSupervisor(
11787
12014
  projectRoot,
11788
12015
  async (event) => {
12016
+ const family = event.event?.split(".")[0];
12017
+ if (family !== "board" && family !== "task" && family !== "column") return;
11789
12018
  const evData = event.data;
11790
12019
  const boardId = evData?.boardId;
11791
12020
  if (!boardId) return;
11792
- try {
11793
- if (event.event === "board.deleted") {
11794
- broadcastDeleted(boardId);
11795
- return;
12021
+ if (event.event === "board.deleted") {
12022
+ const timer = pendingBroadcasts.get(boardId);
12023
+ if (timer) {
12024
+ clearTimeout(timer);
12025
+ pendingBroadcasts.delete(boardId);
11796
12026
  }
11797
- await broadcastBoard(boardId);
11798
- } catch {
12027
+ broadcastDeleted(boardId);
12028
+ return;
11799
12029
  }
12030
+ scheduleBroadcast(boardId);
11800
12031
  },
11801
12032
  {
11802
12033
  autoReconnect: true,
@@ -11804,6 +12035,11 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11804
12035
  onConnected: reconcileAfterConnect
11805
12036
  }
11806
12037
  );
12038
+ return () => {
12039
+ for (const timer of pendingBroadcasts.values()) clearTimeout(timer);
12040
+ pendingBroadcasts.clear();
12041
+ unsubscribe();
12042
+ };
11807
12043
  }
11808
12044
 
11809
12045
  // src/server/kanban-board-watcher.ts
@@ -11825,7 +12061,13 @@ function createShutdown(res) {
11825
12061
  } catch (e) {
11826
12062
  log(`[WebUI] Error closing session: ${e instanceof Error ? e.message : String(e)}`);
11827
12063
  }
11828
- for (const ws of res.clients()) ws.close();
12064
+ for (const ws of res.clients()) {
12065
+ try {
12066
+ ws.close();
12067
+ ws.terminate?.();
12068
+ } catch {
12069
+ }
12070
+ }
11829
12071
  for (const server of res.servers) server?.close();
11830
12072
  if (res.onShutdown) {
11831
12073
  try {
@@ -12262,39 +12504,6 @@ import {
12262
12504
  restartMcp,
12263
12505
  updateMcp
12264
12506
  } from "@wrongstack/mcp";
12265
-
12266
- // src/server/privileged-actions.ts
12267
- import { randomUUID as randomUUID3 } from "node:crypto";
12268
- import {
12269
- isTrustDecisionAllowed
12270
- } from "@wrongstack/core/security";
12271
- async function authorizeWebUIAction(boundary, action, logger) {
12272
- const request = {
12273
- version: 1,
12274
- requestId: randomUUID3(),
12275
- actor: {
12276
- kind: "remote-client",
12277
- ...action.sessionId ? { sessionId: action.sessionId } : {}
12278
- },
12279
- surface: "webui",
12280
- capability: action.capability,
12281
- subject: action.subject,
12282
- risk: action.risk,
12283
- scope: {
12284
- ...action.cwd ? { cwd: action.cwd } : {},
12285
- ...action.sessionId ? { sessionId: action.sessionId } : {}
12286
- },
12287
- authContext: { method: "session" },
12288
- ...action.metadata ? { metadata: action.metadata } : {}
12289
- };
12290
- const decision = await boundary.evaluate(request);
12291
- logger?.debug?.(
12292
- `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
12293
- );
12294
- return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
12295
- }
12296
-
12297
- // src/server/mcp-handlers.ts
12298
12507
  async function authorizeMcpMutation(ws, operation, serverName, trustBoundary) {
12299
12508
  if (!trustBoundary) return true;
12300
12509
  const authorization = await authorizeWebUIAction(trustBoundary, {
@@ -13582,16 +13791,16 @@ function getSurfaceDefaultPorts(surface) {
13582
13791
  return { http: SURFACE_DEFAULT_PORTS[surface].http };
13583
13792
  }
13584
13793
  function isPortFree(host, port) {
13585
- return new Promise((resolve16) => {
13794
+ return new Promise((resolve17) => {
13586
13795
  const srv = net2.createServer();
13587
- srv.once("error", () => resolve16(false));
13796
+ srv.once("error", () => resolve17(false));
13588
13797
  srv.once("listening", () => {
13589
- srv.close(() => resolve16(true));
13798
+ srv.close(() => resolve17(true));
13590
13799
  });
13591
13800
  try {
13592
13801
  srv.listen(port, host);
13593
13802
  } catch {
13594
- resolve16(false);
13803
+ resolve17(false);
13595
13804
  }
13596
13805
  });
13597
13806
  }
@@ -13747,7 +13956,7 @@ async function startStaticServe(opts, deps2 = {}) {
13747
13956
  return { server, port: opts.httpPort };
13748
13957
  }
13749
13958
  function runPnpmBuild(cwd, workspace, timeoutMs) {
13750
- return new Promise((resolve16, reject) => {
13959
+ return new Promise((resolve17, reject) => {
13751
13960
  const child = spawn2("pnpm", ["--filter", workspace, "build"], {
13752
13961
  cwd,
13753
13962
  shell: process.platform === "win32",
@@ -13765,7 +13974,7 @@ function runPnpmBuild(cwd, workspace, timeoutMs) {
13765
13974
  });
13766
13975
  child.once("close", (code) => {
13767
13976
  clearTimeout(timer);
13768
- if (code === 0) resolve16();
13977
+ if (code === 0) resolve17();
13769
13978
  else reject(new Error(`pnpm build exited with code ${String(code)}`));
13770
13979
  });
13771
13980
  });
@@ -13829,27 +14038,41 @@ function formatExternalAccessUrls(opts) {
13829
14038
  }
13830
14039
 
13831
14040
  // src/server/embedded-lifecycle.ts
13832
- function registerWebuiInstance(p, deps2 = {}) {
14041
+ async function registerWebuiInstance(p, deps2 = {}) {
13833
14042
  const register = deps2.registerFn ?? registerInstance;
13834
- void register(
13835
- {
13836
- pid: p.pid,
13837
- surface: p.surface,
13838
- httpPort: p.httpPort,
13839
- host: p.host,
13840
- projectRoot: p.projectRoot,
13841
- projectName: path16.basename(p.projectRoot) || p.projectRoot,
13842
- startedAt: p.startedAt,
13843
- url: buildWebUIAccessUrl({
14043
+ try {
14044
+ await register(
14045
+ {
14046
+ pid: p.pid,
14047
+ surface: p.surface,
14048
+ httpPort: p.httpPort,
13844
14049
  host: p.host,
13845
- port: p.httpPort,
13846
- publicUrl: p.publicUrl
13847
- }),
13848
- ...p.authToken ? { authToken: p.authToken } : {}
13849
- },
13850
- p.registryBaseDir
13851
- ).catch(() => {
13852
- });
14050
+ projectRoot: p.projectRoot,
14051
+ projectName: path16.basename(p.projectRoot) || p.projectRoot,
14052
+ startedAt: p.startedAt,
14053
+ url: buildWebUIAccessUrl({
14054
+ host: p.host,
14055
+ port: p.httpPort,
14056
+ publicUrl: p.publicUrl
14057
+ }),
14058
+ ...p.authToken ? { authToken: p.authToken } : {},
14059
+ ...p.role ? { role: p.role } : {},
14060
+ ...p.sessionId ? { sessionId: p.sessionId } : {},
14061
+ ...p.parentPid !== void 0 ? { parentPid: p.parentPid } : {},
14062
+ ...p.parentShellId ? { parentShellId: p.parentShellId } : {},
14063
+ ...p.runtimeId ? { runtimeId: p.runtimeId } : {},
14064
+ ...p.attachable !== void 0 ? { attachable: p.attachable } : {},
14065
+ ...p.authToken ? { auth: { scheme: "registry-token", tokenPresent: true } } : {},
14066
+ ...p.lastReadyAt ? { lastReadyAt: p.lastReadyAt } : {},
14067
+ ...p.protocolVersion !== void 0 ? { protocolVersion: p.protocolVersion } : {},
14068
+ ...p.capabilities ? { capabilities: p.capabilities } : {}
14069
+ },
14070
+ p.registryBaseDir
14071
+ );
14072
+ return true;
14073
+ } catch {
14074
+ return false;
14075
+ }
13853
14076
  }
13854
14077
  function announceWebuiReady(p) {
13855
14078
  const log = p.log ?? ((m) => console.log(m));
@@ -13887,10 +14110,10 @@ async function runBounded(work, timeoutMs, label, debug) {
13887
14110
  Promise.resolve().then(() => work()).catch((err) => {
13888
14111
  debug(`[webui-server] ${label} failed: ${err}`);
13889
14112
  }),
13890
- new Promise((resolve16) => {
14113
+ new Promise((resolve17) => {
13891
14114
  timer = setTimeout(() => {
13892
14115
  debug(`[webui-server] ${label} timed out after ${timeoutMs}ms`);
13893
- resolve16();
14116
+ resolve17();
13894
14117
  }, timeoutMs);
13895
14118
  timer.unref?.();
13896
14119
  })
@@ -13923,8 +14146,8 @@ function createWebuiShutdown(res) {
13923
14146
  const unregistered = unregister(res.pid, res.registryBaseDir).catch(
13924
14147
  (err) => debug(`[webui-server] unregister failed: ${err}`)
13925
14148
  );
13926
- await new Promise((resolve16) => {
13927
- res.wss.close(() => resolve16());
14149
+ await new Promise((resolve17) => {
14150
+ res.wss.close(() => resolve17());
13928
14151
  });
13929
14152
  await unregistered;
13930
14153
  log("[WebUI] Server stopped");
@@ -14415,12 +14638,9 @@ function createKanbanRunMirror(deps2) {
14415
14638
  }
14416
14639
  async function publish(board) {
14417
14640
  if (board) {
14418
- broadcast2({ type: "kanban.get", payload: { success: true, data: { board } } });
14641
+ broadcast2(kanbanBoardMessage(board));
14419
14642
  }
14420
- broadcast2({
14421
- type: "kanban.list",
14422
- payload: { success: true, data: await listBoards3(projectRoot) }
14423
- });
14643
+ broadcast2(kanbanListMessage(await listBoards3(projectRoot)));
14424
14644
  }
14425
14645
  async function projectSdd(runId, snapshot) {
14426
14646
  const k = mapKey("sdd", runId);
@@ -14823,14 +15043,11 @@ function createKanbanSupervisor(deps2) {
14823
15043
  publish(snapshot);
14824
15044
  const changedBoard = recovered?.board ?? gateSwept ?? reconciled?.board;
14825
15045
  if (changedBoard) {
14826
- deps2.broadcast({
14827
- type: "kanban.get",
14828
- payload: { success: true, data: { board: changedBoard } }
14829
- });
14830
- deps2.broadcast({
14831
- type: "kanban.list",
14832
- payload: { success: true, data: await listBoards4(deps2.projectRoot) }
14833
- });
15046
+ await publishKanbanBoard(
15047
+ deps2.broadcast,
15048
+ changedBoard,
15049
+ () => listBoards4(deps2.projectRoot)
15050
+ );
14834
15051
  }
14835
15052
  if (config.mode === "agentic" && anomalyCount > 0) {
14836
15053
  await maybeRunAgent(board, config, health, snapshot);
@@ -15059,6 +15276,7 @@ function seedContextMeta(config, context) {
15059
15276
  meta["enhanceDelayMs"] = autonomyCfg["enhanceDelayMs"] ?? 6e4;
15060
15277
  meta["enhanceLanguage"] = autonomyCfg["enhanceLanguage"] ?? "original";
15061
15278
  meta["nextPrediction"] = config.nextPrediction ?? false;
15279
+ meta["nextStepsTool"] = config.tools?.nextsteps?.enabled === true;
15062
15280
  meta["fallbackModels"] = config.fallbackModels ?? [];
15063
15281
  meta["fallbackBridge"] = config.fallbackBridge ?? "";
15064
15282
  meta["fallbackProfiles"] = config.fallbackProfiles ?? {};
@@ -15147,6 +15365,7 @@ function seedContextMeta(config, context) {
15147
15365
  meta["autoReviewProvider"] = autoReviewExt?.["provider"] ?? "";
15148
15366
  meta["autoReviewModel"] = autoReviewExt?.["model"] ?? "";
15149
15367
  meta["autoReviewFallbackProfile"] = autoReviewExt?.["fallbackProfile"] ?? "";
15368
+ meta["autoReviewModelSelection"] = autoReviewExt?.["modelSelection"] === "random" ? "random" : "round-robin";
15150
15369
  meta["autoReviewFallbackModels"] = Array.isArray(autoReviewExt?.["fallbackModels"]) ? autoReviewExt?.["fallbackModels"] : [];
15151
15370
  meta["autoReviewDebounceMs"] = typeof autoReviewExt?.["debounceMs"] === "number" && autoReviewExt["debounceMs"] >= 0 ? autoReviewExt["debounceMs"] : 15e3;
15152
15371
  meta["autoReviewMaxFilesPerBatch"] = typeof autoReviewExt?.["maxFilesPerBatch"] === "number" && autoReviewExt["maxFilesPerBatch"] >= 1 ? autoReviewExt["maxFilesPerBatch"] : 15;
@@ -15184,6 +15403,7 @@ var PREF_KEYS = [
15184
15403
  "chime",
15185
15404
  "confirmExit",
15186
15405
  "nextPrediction",
15406
+ "nextStepsTool",
15187
15407
  "enhanceEnabled",
15188
15408
  "enhanceDelayMs",
15189
15409
  "enhanceLanguage",
@@ -15247,6 +15467,7 @@ var PREF_KEYS = [
15247
15467
  "autoReviewProvider",
15248
15468
  "autoReviewModel",
15249
15469
  "autoReviewFallbackProfile",
15470
+ "autoReviewModelSelection",
15250
15471
  "autoReviewFallbackModels",
15251
15472
  "autoReviewDebounceMs",
15252
15473
  "autoReviewMaxFilesPerBatch",
@@ -15460,6 +15681,11 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15460
15681
  toolsCfg.maxIterations = payload["maxIterations"];
15461
15682
  decrypted.tools = toolsCfg;
15462
15683
  }
15684
+ if (typeof payload["nextStepsTool"] === "boolean") {
15685
+ const toolsCfg = decrypted.tools ?? {};
15686
+ toolsCfg.nextsteps = { enabled: payload["nextStepsTool"] };
15687
+ decrypted.tools = toolsCfg;
15688
+ }
15463
15689
  const hqTouched = typeof payload["hqEnabled"] === "boolean" || typeof payload["hqUrl"] === "string" || typeof payload["hqToken"] === "string" || typeof payload["hqRawContent"] === "boolean";
15464
15690
  if (hqTouched) {
15465
15691
  const hqCfg = decrypted.hq ?? {};
@@ -15567,7 +15793,7 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15567
15793
  ext["wstack-chimera"] = chimera;
15568
15794
  decrypted.extensions = ext;
15569
15795
  }
15570
- const autoReviewTouched = typeof payload["autoReviewEnabled"] === "boolean" || typeof payload["autoReviewProvider"] === "string" || typeof payload["autoReviewModel"] === "string" || typeof payload["autoReviewFallbackProfile"] === "string" || Array.isArray(payload["autoReviewFallbackModels"]) || typeof payload["autoReviewDebounceMs"] === "number" || typeof payload["autoReviewMaxFilesPerBatch"] === "number" || typeof payload["autoReviewMaxConcurrentReviews"] === "number" || typeof payload["autoReviewCascadeOn"] === "string";
15796
+ const autoReviewTouched = typeof payload["autoReviewEnabled"] === "boolean" || typeof payload["autoReviewProvider"] === "string" || typeof payload["autoReviewModel"] === "string" || typeof payload["autoReviewFallbackProfile"] === "string" || typeof payload["autoReviewModelSelection"] === "string" || Array.isArray(payload["autoReviewFallbackModels"]) || typeof payload["autoReviewDebounceMs"] === "number" || typeof payload["autoReviewMaxFilesPerBatch"] === "number" || typeof payload["autoReviewMaxConcurrentReviews"] === "number" || typeof payload["autoReviewCascadeOn"] === "string";
15571
15797
  if (autoReviewTouched) {
15572
15798
  const ext = decrypted.extensions ?? {};
15573
15799
  const ar = ext["wstack-auto-review"] ?? {};
@@ -15584,6 +15810,9 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15584
15810
  ar["fallbackProfile"] = payload["autoReviewFallbackProfile"];
15585
15811
  }
15586
15812
  }
15813
+ if (payload["autoReviewModelSelection"] === "round-robin" || payload["autoReviewModelSelection"] === "random") {
15814
+ ar["modelSelection"] = payload["autoReviewModelSelection"];
15815
+ }
15587
15816
  if (typeof payload["autoReviewDebounceMs"] === "number" && payload["autoReviewDebounceMs"] >= 0) {
15588
15817
  ar["debounceMs"] = payload["autoReviewDebounceMs"];
15589
15818
  }
@@ -15952,6 +16181,7 @@ function createProjectHandlers(ctx) {
15952
16181
  ctx.context.session = next;
15953
16182
  ctx.context.state.replaceMessages([]);
15954
16183
  ctx.context.state.replaceTodos([]);
16184
+ ctx.context.clearMemoryEvidence?.();
15955
16185
  ctx.context.readFiles.clear();
15956
16186
  ctx.context.fileMtimes.clear();
15957
16187
  ctx.tokenCounter.reset();
@@ -15996,7 +16226,7 @@ function createProjectHandlers(ctx) {
15996
16226
  }
15997
16227
 
15998
16228
  // src/server/provider-handlers.ts
15999
- import { resolveProviderModelList } from "@wrongstack/core/models";
16229
+ import { hasProviderCredential, resolveProviderModelList } from "@wrongstack/core/models";
16000
16230
  import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/core/security";
16001
16231
  import { toErrorMessage as toErrorMessage6 } from "@wrongstack/core/utils";
16002
16232
  import {
@@ -16303,7 +16533,7 @@ function createProviderOperations(deps2) {
16303
16533
  }
16304
16534
  try {
16305
16535
  const providers = await deps2.modelsRegistry.listProviders();
16306
- const savedIds = new Set(Object.keys(await loadConfigProviders()));
16536
+ const savedProviders = await loadConfigProviders();
16307
16537
  sendMessage(ws, {
16308
16538
  type: "provider.catalog",
16309
16539
  payload: {
@@ -16314,7 +16544,7 @@ function createProviderOperations(deps2) {
16314
16544
  apiBase: provider.apiBase,
16315
16545
  envVars: provider.envVars,
16316
16546
  modelCount: provider.models.length,
16317
- hasApiKey: savedIds.has(provider.id) || provider.envVars.some((name2) => !!process.env[name2])
16547
+ hasApiKey: hasProviderCredential(provider, { providers: savedProviders })
16318
16548
  }))
16319
16549
  }
16320
16550
  });
@@ -16861,6 +17091,7 @@ var CLIENT_CONVERSATION_MESSAGE_TYPES = [
16861
17091
  "ping",
16862
17092
  "user_message",
16863
17093
  "tool.confirm_result",
17094
+ "topic.advice",
16864
17095
  "completion.request",
16865
17096
  "model.switch",
16866
17097
  "model.refine",
@@ -17163,6 +17394,7 @@ var SERVER_CONVERSATION_MESSAGE_TYPES = [
17163
17394
  "tool.loop_detected",
17164
17395
  "tool.progress",
17165
17396
  "tool.started",
17397
+ "topic.advice_result",
17166
17398
  "tools.list",
17167
17399
  "trust.persisted"
17168
17400
  ];
@@ -17672,6 +17904,8 @@ var SURFACE_PROTOCOL_CAPABILITIES = [
17672
17904
  "chronicle.metrics",
17673
17905
  "chronicle.status",
17674
17906
  "connections.health",
17907
+ /** Bounded topic-shift advice plus same-session provider-context boundaries. */
17908
+ "context.topic-boundary",
17675
17909
  /** Interview resume/discard + lastAgentText/lastRunId continuity. */
17676
17910
  "sdd.interview.continuity",
17677
17911
  /** Launch multi-agent runs from a graph id or resolved spec id. */
@@ -18076,6 +18310,7 @@ function createSessionHandlers(ctx) {
18076
18310
  await ctx.onBeforeSessionTodosReplaced?.(next.id, sessionsDirectory());
18077
18311
  ctx.context.state.replaceTodos(todos);
18078
18312
  resetContextAccounting();
18313
+ ctx.context.clearMemoryEvidence?.();
18079
18314
  ctx.context.readFiles.clear();
18080
18315
  ctx.context.fileMtimes.clear();
18081
18316
  ctx.context.state.setMeta?.(
@@ -18123,6 +18358,7 @@ function createSessionHandlers(ctx) {
18123
18358
  ctx.context.state.replaceMessages([]);
18124
18359
  ctx.context.state.replaceTodos([]);
18125
18360
  resetContextAccounting();
18361
+ ctx.context.clearMemoryEvidence?.();
18126
18362
  ctx.context.readFiles.clear();
18127
18363
  ctx.context.fileMtimes.clear();
18128
18364
  ctx.tokenCounter.reset?.();
@@ -18137,6 +18373,7 @@ function createSessionHandlers(ctx) {
18137
18373
  ctx.context.state.replaceMessages([]);
18138
18374
  ctx.context.state.replaceTodos([]);
18139
18375
  resetContextAccounting();
18376
+ ctx.context.clearMemoryEvidence?.();
18140
18377
  ctx.context.readFiles.clear();
18141
18378
  ctx.context.fileMtimes.clear();
18142
18379
  ctx.tokenCounter.reset?.();
@@ -19922,6 +20159,7 @@ function createEmbeddedMessageRouter(deps2) {
19922
20159
  };
19923
20160
  const guardedTypes = /* @__PURE__ */ new Set([
19924
20161
  "user_message",
20162
+ "topic.advice",
19925
20163
  "abort",
19926
20164
  "tool.confirm_result",
19927
20165
  "session.new",
@@ -20249,6 +20487,8 @@ function createEmbeddedMessageRouter(deps2) {
20249
20487
  ))
20250
20488
  return;
20251
20489
  if (await handleConnectionsServiceAction(ws, message, {
20490
+ trustBoundary: deps2.trustBoundary,
20491
+ logger: deps2.logger,
20252
20492
  getProjectRoot: projectRoot,
20253
20493
  getIndexDir: () => typeof opts.agent.ctx.meta["codebaseIndexDir"] === "string" ? opts.agent.ctx.meta["codebaseIndexDir"] : void 0,
20254
20494
  send: send2,
@@ -20300,8 +20540,8 @@ function createConfigWriteLock() {
20300
20540
  acquire() {
20301
20541
  const prev = lock;
20302
20542
  let release = () => void 0;
20303
- lock = new Promise((resolve16) => {
20304
- release = resolve16;
20543
+ lock = new Promise((resolve17) => {
20544
+ release = resolve17;
20305
20545
  });
20306
20546
  return { prev, release };
20307
20547
  }
@@ -21235,7 +21475,7 @@ function registerSetupEventsClientStatusWriter(deps2) {
21235
21475
  const on = (event, listener) => events.on(event, listener);
21236
21476
  return on("client.status", async (e) => {
21237
21477
  broadcast2(clients, { type: "client.status_update", payload: e });
21238
- if (wpaths?.projectStatus) {
21478
+ if (wpaths?.projectStatus && e.projectHash !== "unknown") {
21239
21479
  try {
21240
21480
  const statusFile = wpaths.projectStatus(e.projectHash);
21241
21481
  const dir = path24.dirname(statusFile);
@@ -21410,7 +21650,10 @@ function registerSetupEventsProviderHandlers({
21410
21650
  sessionId: e.sessionId,
21411
21651
  providerId: e.providerId,
21412
21652
  modelId: e.modelId,
21413
- maxContext: e.maxContext
21653
+ maxContext: e.maxContext,
21654
+ ...e.previousMaxContext !== void 0 ? { previousMaxContext: e.previousMaxContext } : {},
21655
+ ...e.source !== void 0 ? { source: e.source } : {},
21656
+ ...e.decreased !== void 0 ? { decreased: e.decreased } : {}
21414
21657
  })
21415
21658
  });
21416
21659
  });
@@ -22520,7 +22763,15 @@ var SpecsWebSocketHandler = class {
22520
22763
  this.clients.add(client);
22521
22764
  ws.on("close", () => this.clients.delete(client));
22522
22765
  ws.on("error", () => this.clients.delete(client));
22523
- void this.sendList(client);
22766
+ void this.sendList(client).catch((err) => {
22767
+ console.warn(
22768
+ JSON.stringify({
22769
+ level: "warn",
22770
+ event: "specs.initial_send_failed",
22771
+ message: err instanceof Error ? err.message : String(err)
22772
+ })
22773
+ );
22774
+ });
22524
22775
  }
22525
22776
  dispose() {
22526
22777
  this.clients.clear();
@@ -22734,15 +22985,17 @@ import {
22734
22985
 
22735
22986
  // src/server/discover-mailbox-bridge.ts
22736
22987
  import { spawn as spawn4 } from "node:child_process";
22737
- import { createRequire } from "node:module";
22738
22988
  import { existsSync as existsSync2 } from "node:fs";
22989
+ import { createRequire } from "node:module";
22739
22990
  import { dirname as dirname10, join as join13 } from "node:path";
22740
- import { resolveProjectDir as resolveProjectDir3 } from "@wrongstack/core/coordination";
22991
+ import {
22992
+ readLiveLock,
22993
+ resolveProjectDir as resolveProjectDir3
22994
+ } from "@wrongstack/core/coordination";
22741
22995
  import { wstackGlobalRoot as wstackGlobalRoot3 } from "@wrongstack/core/utils";
22742
- import { readLiveLock } from "@wrongstack/core/coordination";
22743
22996
  var MAILBOX_BRIDGE_BOOT_TIMEOUT_MS = 5e3;
22744
22997
  async function discoverMailboxBridgeForWebui(params) {
22745
- const mode = params.config?.features?.mailboxBridge ?? "auto";
22998
+ const mode = params.config?.features?.mailboxBridge ?? "off";
22746
22999
  if (mode === "off") return;
22747
23000
  const projectDir = resolveProjectDir3(params.projectRoot, wstackGlobalRoot3());
22748
23001
  let result = await readLiveLock(projectDir);
@@ -22874,7 +23127,7 @@ function findWorkspaceCliEntry(projectRoot) {
22874
23127
  return null;
22875
23128
  }
22876
23129
  function sleep(ms) {
22877
- return new Promise((resolve16) => setTimeout(resolve16, ms));
23130
+ return new Promise((resolve17) => setTimeout(resolve17, ms));
22878
23131
  }
22879
23132
 
22880
23133
  // src/server/terminal-ws-handler.ts
@@ -23004,6 +23257,13 @@ var TerminalWebSocketHandler = class {
23004
23257
  this.send(ws, { type: "terminal.exit", payload: { id: payload.id, exitCode: -1 } });
23005
23258
  return;
23006
23259
  }
23260
+ if (this.sessions.get(ws) !== map) {
23261
+ this.logger.info?.(
23262
+ `terminal.create raced a disconnect (id=${payload.id}) \u2014 killing the orphan`
23263
+ );
23264
+ this.killPty(pty, "terminal create after disconnect");
23265
+ return;
23266
+ }
23007
23267
  map.set(payload.id, pty);
23008
23268
  this.logger.info?.(`terminal.create spawned (id=${payload.id}, pid=${pty.pid ?? "?"}) in ${cwd}`);
23009
23269
  pty.onData((data) => {
@@ -23110,7 +23370,7 @@ function clampDim(value, fallback) {
23110
23370
  }
23111
23371
 
23112
23372
  // src/server/worktree-ws-handler.ts
23113
- import { join as join14, resolve as resolve13, sep as sep5 } from "node:path";
23373
+ import { join as join14, resolve as resolve14, sep as sep5 } from "node:path";
23114
23374
  import { toErrorMessage as toErrorMessage9 } from "@wrongstack/core/utils";
23115
23375
  import { WorktreeManager as WorktreeManager3 } from "@wrongstack/core/worktree";
23116
23376
  import { cleanupStaleSddWorktrees as cleanupStaleSddWorktrees2 } from "@wrongstack/sdd";
@@ -23195,11 +23455,11 @@ var WorktreeWebSocketHandler = class {
23195
23455
  // ── orphan management ─────────────────────────────────────────────────────
23196
23456
  /** Absolute managed-worktrees root for this project. */
23197
23457
  worktreesRoot() {
23198
- return resolve13(join14(this.management.projectRoot, ".wrongstack", "worktrees"));
23458
+ return resolve14(join14(this.management.projectRoot, ".wrongstack", "worktrees"));
23199
23459
  }
23200
23460
  /** True iff `dir` resolves strictly inside the managed worktrees root. */
23201
23461
  underRoot(dir) {
23202
- const abs = resolve13(dir);
23462
+ const abs = resolve14(dir);
23203
23463
  const root = this.worktreesRoot();
23204
23464
  return abs !== root && abs.startsWith(root + sep5);
23205
23465
  }
@@ -23423,7 +23683,7 @@ var WorktreeWebSocketHandler = class {
23423
23683
  }
23424
23684
  const base = baseBranch && MANAGED_BRANCH_RE.test(baseBranch) ? baseBranch : void 0;
23425
23685
  const wt = new WorktreeManager3({ projectRoot: this.management.projectRoot });
23426
- const summary = await wt.diffSummary(resolve13(dir), base);
23686
+ const summary = await wt.diffSummary(resolve14(dir), base);
23427
23687
  this.broadcast({ type: "worktree.diff_result", payload: { dir, summary } });
23428
23688
  }
23429
23689
  // ── internals ───────────────────────────────────────────────────────────
@@ -24429,6 +24689,15 @@ function createMessageDispatcher(opts) {
24429
24689
  msg
24430
24690
  ))
24431
24691
  return;
24692
+ if (await handleConnectionsServiceAction(ws, msg, {
24693
+ trustBoundary: deps2.trustBoundary,
24694
+ logger: deps2.logger,
24695
+ getProjectRoot: state.getProjectRoot,
24696
+ getIndexDir: () => typeof deps2.context.meta["codebaseIndexDir"] === "string" ? deps2.context.meta["codebaseIndexDir"] : void 0,
24697
+ send,
24698
+ backend: "standalone"
24699
+ }))
24700
+ return;
24432
24701
  if (await handleCodebaseIndexServerControl(ws, msg, {
24433
24702
  trustBoundary: deps2.trustBoundary,
24434
24703
  logger: deps2.logger,
@@ -24935,6 +25204,7 @@ async function createPreContextServices(input) {
24935
25204
  registry: toolRegistry,
24936
25205
  tier: normalizeTokenSavingTier(config.features.tokenSavingMode),
24937
25206
  memory: { enabled: config.features.memory, store: memoryStore },
25207
+ nextSteps: { enabled: config.tools?.nextsteps?.enabled === true },
24938
25208
  coordinationTools: [
24939
25209
  makeMailboxTool({ projectDir: wpaths.projectDir, events }),
24940
25210
  makeMailSendTool({ projectDir: wpaths.projectDir, events }),
@@ -26043,7 +26313,7 @@ async function startWebUI(opts = {}) {
26043
26313
  if (events.listenerCount("tool.confirm_needed") === 0) {
26044
26314
  throw new Error("No permission confirmation surface is connected");
26045
26315
  }
26046
- const decision = await new Promise((resolve16) => {
26316
+ const decision = await new Promise((resolve17) => {
26047
26317
  events.emit("tool.confirm_needed", {
26048
26318
  sessionId: context.session.id,
26049
26319
  tool: confirmTool,
@@ -26053,7 +26323,7 @@ async function startWebUI(opts = {}) {
26053
26323
  decisionSource: pending.decisionSource,
26054
26324
  riskTier: pending.riskTier,
26055
26325
  boundaryReason: pending.boundaryReason,
26056
- resolve: resolve16
26326
+ resolve: resolve17
26057
26327
  });
26058
26328
  });
26059
26329
  const rule = { tool: "language_package", pattern: pending.suggestedPattern };
@@ -26122,7 +26392,8 @@ async function startWebUI(opts = {}) {
26122
26392
  watcherMetricsRef
26123
26393
  );
26124
26394
  httpServer.listen(httpPort, wsHost, () => {
26125
- console.log(`[WebUI] HTTP server running on http://${wsHost}:${httpPort}`);
26395
+ const tokenQuery = accessToken ? `/?token=${encodeURIComponent(accessToken)}` : "";
26396
+ console.log(`[WebUI] HTTP server running on http://${wsHost}:${httpPort}${tokenQuery}`);
26126
26397
  const extraUrls = formatExternalAccessUrls({
26127
26398
  bindHost: wsHost,
26128
26399
  port: httpPort,
@@ -26144,8 +26415,11 @@ async function startWebUI(opts = {}) {
26144
26415
  (req, socket, head2) => httpServer.emit("upgrade", req, socket, head2)
26145
26416
  );
26146
26417
  companionServer.on("error", (err) => {
26147
- if (err.code !== "EAFNOSUPPORT" && err.code !== "EADDRNOTAVAIL" && err.code !== "EADDRINUSE") {
26148
- throw err;
26418
+ const expected = err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL" || err.code === "EADDRINUSE";
26419
+ if (!expected) {
26420
+ console.warn(
26421
+ `[WebUI] companion listener on ${companionLabel} failed (${err.code ?? "unknown"}): ${err.message}. The primary address is unaffected.`
26422
+ );
26149
26423
  }
26150
26424
  });
26151
26425
  companionServer.listen(httpPort, companion, () => {
@@ -26387,24 +26661,23 @@ async function startWebUI(opts = {}) {
26387
26661
  clients,
26388
26662
  pendingConfirms,
26389
26663
  onSecurityRejection: (ev) => {
26390
- try {
26391
- void mailbox.send({
26392
- from: context.agentId,
26393
- to: "*",
26394
- type: "note",
26395
- audience: "leaders",
26396
- subject: `Security rejection: ${ev.issueCode}`,
26397
- body: `Decoder tripwire ${ev.issueCode}: ${ev.issueMessage}
26664
+ void mailbox.send({
26665
+ from: context.agentId,
26666
+ to: "*",
26667
+ type: "note",
26668
+ audience: "leaders",
26669
+ subject: `Security rejection: ${ev.issueCode}`,
26670
+ body: `Decoder tripwire ${ev.issueCode}: ${ev.issueMessage}
26398
26671
 
26399
26672
  connectionId: ${ev.connectionId ?? "?"}
26400
26673
  sessionId: ${ev.sessionId ?? "?"}
26401
26674
  agentId: ${ev.agentId ?? "?"}
26402
26675
  projectRoot: ${ev.projectRoot ?? "?"}`,
26403
- priority: "high",
26404
- senderSessionId: session.id
26405
- });
26406
- } catch {
26407
- }
26676
+ priority: "high",
26677
+ senderSessionId: session.id
26678
+ }).catch((err) => {
26679
+ console.warn(`[WebUI] security-rejection mailbox note failed: ${String(err)}`);
26680
+ });
26408
26681
  },
26409
26682
  goalHandler,
26410
26683
  specsHandler,
@@ -26489,6 +26762,7 @@ export {
26489
26762
  SddWizardWebSocketHandler,
26490
26763
  SpecsWebSocketHandler,
26491
26764
  TerminalWebSocketHandler,
26765
+ WEBUI_WS_MAX_BUFFERED_BYTES,
26492
26766
  WorktreeWebSocketHandler,
26493
26767
  addProvider,
26494
26768
  announceWebuiReady,
@@ -26691,6 +26965,7 @@ export {
26691
26965
  isPortFree,
26692
26966
  isRegisteredMessageType,
26693
26967
  isWildcardBind,
26968
+ joinSessionRegistryWithWebUIInstances,
26694
26969
  listInstances,
26695
26970
  loadManifest,
26696
26971
  loadSavedProviders,
@@ -26743,6 +27018,7 @@ export {
26743
27018
  seedContextMeta,
26744
27019
  send,
26745
27020
  sendResult2 as sendResult,
27021
+ sendSerialized,
26746
27022
  setActiveKey,
26747
27023
  setupEvents,
26748
27024
  setupWebUICodebaseIndexing,