@wrongstack/webui-server 0.300.0 → 0.301.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",
@@ -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) {
@@ -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
  );
@@ -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 } });
@@ -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)) {
@@ -10383,6 +10524,27 @@ import {
10383
10524
  getServerKanbanStore
10384
10525
  } from "@wrongstack/kanban";
10385
10526
  import { recordKanbanVerificationEvidence } from "@wrongstack/tools";
10527
+
10528
+ // src/server/kanban-broadcast.ts
10529
+ function kanbanBoardMessage(board) {
10530
+ return { type: "kanban.get", payload: { success: true, data: { board } } };
10531
+ }
10532
+ function kanbanListMessage(boards) {
10533
+ return { type: "kanban.list", payload: { success: true, data: boards } };
10534
+ }
10535
+ function kanbanDeletedMessage(boardId) {
10536
+ return { type: "kanban.delete", payload: { success: true, data: { removed: true, boardId } } };
10537
+ }
10538
+ async function publishKanbanBoard(broadcast2, board, listBoards6) {
10539
+ broadcast2(kanbanBoardMessage(board));
10540
+ if (!listBoards6) return;
10541
+ try {
10542
+ broadcast2(kanbanListMessage(await listBoards6()));
10543
+ } catch {
10544
+ }
10545
+ }
10546
+
10547
+ // src/server/kanban-dispatch.ts
10386
10548
  function parseResolvedDispatchRoute(summary) {
10387
10549
  const tags = summary.match(/Spawned subagent\s+\S+\s+\((.*?)\)\s+for task/i)?.[1];
10388
10550
  if (!tags) return {};
@@ -10513,10 +10675,7 @@ async function handleKanbanTaskDispatch(ws, payload, ctx) {
10513
10675
  payload: { success: true, data: { boardId: board.id, task: completedTask } }
10514
10676
  });
10515
10677
  if (completedBoard) {
10516
- ctx.broadcast?.({
10517
- type: "kanban.get",
10518
- payload: { success: true, data: { board: completedBoard } }
10519
- });
10678
+ ctx.broadcast?.(kanbanBoardMessage(completedBoard));
10520
10679
  }
10521
10680
  ctx.broadcast?.({
10522
10681
  type: "kanban.list",
@@ -10540,7 +10699,7 @@ async function handleKanbanTaskDispatch(ws, payload, ctx) {
10540
10699
  payload: { success: true, data: { boardId: board.id, task: runningTask } }
10541
10700
  });
10542
10701
  if (started?.board) {
10543
- ctx.broadcast?.({ type: "kanban.get", payload: { success: true, data: { board: started.board } } });
10702
+ ctx.broadcast?.(kanbanBoardMessage(started.board));
10544
10703
  }
10545
10704
  reply(ws, "kanban.task.dispatch", true, { boardId: board.id, task: runningTask, summary });
10546
10705
  } catch (error2) {
@@ -10783,14 +10942,11 @@ async function handleDecompositionResolution(ws, type, payload, ctx) {
10783
10942
  type: "kanban.decomposition.applied",
10784
10943
  payload: { success: true, data: { board: resolved.board } }
10785
10944
  });
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
- });
10945
+ await publishKanbanBoard(
10946
+ (message) => ctx.broadcast?.(message),
10947
+ resolved.board,
10948
+ () => listBoards(ctx.projectRoot)
10949
+ );
10794
10950
  } else {
10795
10951
  ctx.broadcast?.({
10796
10952
  type: "kanban.decomposition.resolved",
@@ -10823,10 +10979,7 @@ async function handleTaskVerification(ws, type, payload, ctx) {
10823
10979
  payload: { success: true, data: { boardId, task: freshTask } }
10824
10980
  });
10825
10981
  if (persisted) {
10826
- ctx.broadcast?.({
10827
- type: "kanban.get",
10828
- payload: { success: true, data: { board: persisted } }
10829
- });
10982
+ ctx.broadcast?.(kanbanBoardMessage(persisted));
10830
10983
  }
10831
10984
  } catch (err) {
10832
10985
  ctx.broadcast?.({
@@ -11749,10 +11902,7 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11749
11902
  let connectionCount = 0;
11750
11903
  const broadcastDeleted = (boardId) => {
11751
11904
  knownRevisions.delete(boardId);
11752
- broadcastMessage({
11753
- type: "kanban.delete",
11754
- payload: { success: true, data: { removed: true, boardId } }
11755
- });
11905
+ broadcastMessage(kanbanDeletedMessage(boardId));
11756
11906
  };
11757
11907
  const broadcastBoard = async (boardId) => {
11758
11908
  const board = await store.getBoard(boardId);
@@ -11761,10 +11911,7 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11761
11911
  return;
11762
11912
  }
11763
11913
  knownRevisions.set(boardId, board.updatedAt);
11764
- broadcastMessage({
11765
- type: "kanban.get",
11766
- payload: { success: true, data: { board } }
11767
- });
11914
+ broadcastMessage(kanbanBoardMessage(board));
11768
11915
  };
11769
11916
  const reconcileAfterConnect = async () => {
11770
11917
  const summaries = await store.listBoards();
@@ -11783,20 +11930,36 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11783
11930
  }
11784
11931
  }
11785
11932
  };
11786
- return bridgeKanbanSupervisor(
11933
+ const COALESCE_MS = 300;
11934
+ const pendingBroadcasts = /* @__PURE__ */ new Map();
11935
+ const scheduleBroadcast = (boardId) => {
11936
+ if (pendingBroadcasts.has(boardId)) return;
11937
+ const timer = setTimeout(() => {
11938
+ pendingBroadcasts.delete(boardId);
11939
+ void broadcastBoard(boardId).catch(() => {
11940
+ });
11941
+ }, COALESCE_MS);
11942
+ timer.unref?.();
11943
+ pendingBroadcasts.set(boardId, timer);
11944
+ };
11945
+ const unsubscribe = bridgeKanbanSupervisor(
11787
11946
  projectRoot,
11788
11947
  async (event) => {
11948
+ const family = event.event?.split(".")[0];
11949
+ if (family !== "board" && family !== "task" && family !== "column") return;
11789
11950
  const evData = event.data;
11790
11951
  const boardId = evData?.boardId;
11791
11952
  if (!boardId) return;
11792
- try {
11793
- if (event.event === "board.deleted") {
11794
- broadcastDeleted(boardId);
11795
- return;
11953
+ if (event.event === "board.deleted") {
11954
+ const timer = pendingBroadcasts.get(boardId);
11955
+ if (timer) {
11956
+ clearTimeout(timer);
11957
+ pendingBroadcasts.delete(boardId);
11796
11958
  }
11797
- await broadcastBoard(boardId);
11798
- } catch {
11959
+ broadcastDeleted(boardId);
11960
+ return;
11799
11961
  }
11962
+ scheduleBroadcast(boardId);
11800
11963
  },
11801
11964
  {
11802
11965
  autoReconnect: true,
@@ -11804,6 +11967,11 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11804
11967
  onConnected: reconcileAfterConnect
11805
11968
  }
11806
11969
  );
11970
+ return () => {
11971
+ for (const timer of pendingBroadcasts.values()) clearTimeout(timer);
11972
+ pendingBroadcasts.clear();
11973
+ unsubscribe();
11974
+ };
11807
11975
  }
11808
11976
 
11809
11977
  // src/server/kanban-board-watcher.ts
@@ -11825,7 +11993,13 @@ function createShutdown(res) {
11825
11993
  } catch (e) {
11826
11994
  log(`[WebUI] Error closing session: ${e instanceof Error ? e.message : String(e)}`);
11827
11995
  }
11828
- for (const ws of res.clients()) ws.close();
11996
+ for (const ws of res.clients()) {
11997
+ try {
11998
+ ws.close();
11999
+ ws.terminate?.();
12000
+ } catch {
12001
+ }
12002
+ }
11829
12003
  for (const server of res.servers) server?.close();
11830
12004
  if (res.onShutdown) {
11831
12005
  try {
@@ -12262,39 +12436,6 @@ import {
12262
12436
  restartMcp,
12263
12437
  updateMcp
12264
12438
  } 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
12439
  async function authorizeMcpMutation(ws, operation, serverName, trustBoundary) {
12299
12440
  if (!trustBoundary) return true;
12300
12441
  const authorization = await authorizeWebUIAction(trustBoundary, {
@@ -14415,12 +14556,9 @@ function createKanbanRunMirror(deps2) {
14415
14556
  }
14416
14557
  async function publish(board) {
14417
14558
  if (board) {
14418
- broadcast2({ type: "kanban.get", payload: { success: true, data: { board } } });
14559
+ broadcast2(kanbanBoardMessage(board));
14419
14560
  }
14420
- broadcast2({
14421
- type: "kanban.list",
14422
- payload: { success: true, data: await listBoards3(projectRoot) }
14423
- });
14561
+ broadcast2(kanbanListMessage(await listBoards3(projectRoot)));
14424
14562
  }
14425
14563
  async function projectSdd(runId, snapshot) {
14426
14564
  const k = mapKey("sdd", runId);
@@ -14823,14 +14961,11 @@ function createKanbanSupervisor(deps2) {
14823
14961
  publish(snapshot);
14824
14962
  const changedBoard = recovered?.board ?? gateSwept ?? reconciled?.board;
14825
14963
  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
- });
14964
+ await publishKanbanBoard(
14965
+ deps2.broadcast,
14966
+ changedBoard,
14967
+ () => listBoards4(deps2.projectRoot)
14968
+ );
14834
14969
  }
14835
14970
  if (config.mode === "agentic" && anomalyCount > 0) {
14836
14971
  await maybeRunAgent(board, config, health, snapshot);
@@ -15059,6 +15194,7 @@ function seedContextMeta(config, context) {
15059
15194
  meta["enhanceDelayMs"] = autonomyCfg["enhanceDelayMs"] ?? 6e4;
15060
15195
  meta["enhanceLanguage"] = autonomyCfg["enhanceLanguage"] ?? "original";
15061
15196
  meta["nextPrediction"] = config.nextPrediction ?? false;
15197
+ meta["nextStepsTool"] = config.tools?.nextsteps?.enabled === true;
15062
15198
  meta["fallbackModels"] = config.fallbackModels ?? [];
15063
15199
  meta["fallbackBridge"] = config.fallbackBridge ?? "";
15064
15200
  meta["fallbackProfiles"] = config.fallbackProfiles ?? {};
@@ -15147,6 +15283,7 @@ function seedContextMeta(config, context) {
15147
15283
  meta["autoReviewProvider"] = autoReviewExt?.["provider"] ?? "";
15148
15284
  meta["autoReviewModel"] = autoReviewExt?.["model"] ?? "";
15149
15285
  meta["autoReviewFallbackProfile"] = autoReviewExt?.["fallbackProfile"] ?? "";
15286
+ meta["autoReviewModelSelection"] = autoReviewExt?.["modelSelection"] === "random" ? "random" : "round-robin";
15150
15287
  meta["autoReviewFallbackModels"] = Array.isArray(autoReviewExt?.["fallbackModels"]) ? autoReviewExt?.["fallbackModels"] : [];
15151
15288
  meta["autoReviewDebounceMs"] = typeof autoReviewExt?.["debounceMs"] === "number" && autoReviewExt["debounceMs"] >= 0 ? autoReviewExt["debounceMs"] : 15e3;
15152
15289
  meta["autoReviewMaxFilesPerBatch"] = typeof autoReviewExt?.["maxFilesPerBatch"] === "number" && autoReviewExt["maxFilesPerBatch"] >= 1 ? autoReviewExt["maxFilesPerBatch"] : 15;
@@ -15184,6 +15321,7 @@ var PREF_KEYS = [
15184
15321
  "chime",
15185
15322
  "confirmExit",
15186
15323
  "nextPrediction",
15324
+ "nextStepsTool",
15187
15325
  "enhanceEnabled",
15188
15326
  "enhanceDelayMs",
15189
15327
  "enhanceLanguage",
@@ -15247,6 +15385,7 @@ var PREF_KEYS = [
15247
15385
  "autoReviewProvider",
15248
15386
  "autoReviewModel",
15249
15387
  "autoReviewFallbackProfile",
15388
+ "autoReviewModelSelection",
15250
15389
  "autoReviewFallbackModels",
15251
15390
  "autoReviewDebounceMs",
15252
15391
  "autoReviewMaxFilesPerBatch",
@@ -15460,6 +15599,11 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15460
15599
  toolsCfg.maxIterations = payload["maxIterations"];
15461
15600
  decrypted.tools = toolsCfg;
15462
15601
  }
15602
+ if (typeof payload["nextStepsTool"] === "boolean") {
15603
+ const toolsCfg = decrypted.tools ?? {};
15604
+ toolsCfg.nextsteps = { enabled: payload["nextStepsTool"] };
15605
+ decrypted.tools = toolsCfg;
15606
+ }
15463
15607
  const hqTouched = typeof payload["hqEnabled"] === "boolean" || typeof payload["hqUrl"] === "string" || typeof payload["hqToken"] === "string" || typeof payload["hqRawContent"] === "boolean";
15464
15608
  if (hqTouched) {
15465
15609
  const hqCfg = decrypted.hq ?? {};
@@ -15567,7 +15711,7 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15567
15711
  ext["wstack-chimera"] = chimera;
15568
15712
  decrypted.extensions = ext;
15569
15713
  }
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";
15714
+ 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
15715
  if (autoReviewTouched) {
15572
15716
  const ext = decrypted.extensions ?? {};
15573
15717
  const ar = ext["wstack-auto-review"] ?? {};
@@ -15584,6 +15728,9 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15584
15728
  ar["fallbackProfile"] = payload["autoReviewFallbackProfile"];
15585
15729
  }
15586
15730
  }
15731
+ if (payload["autoReviewModelSelection"] === "round-robin" || payload["autoReviewModelSelection"] === "random") {
15732
+ ar["modelSelection"] = payload["autoReviewModelSelection"];
15733
+ }
15587
15734
  if (typeof payload["autoReviewDebounceMs"] === "number" && payload["autoReviewDebounceMs"] >= 0) {
15588
15735
  ar["debounceMs"] = payload["autoReviewDebounceMs"];
15589
15736
  }
@@ -15952,6 +16099,7 @@ function createProjectHandlers(ctx) {
15952
16099
  ctx.context.session = next;
15953
16100
  ctx.context.state.replaceMessages([]);
15954
16101
  ctx.context.state.replaceTodos([]);
16102
+ ctx.context.clearMemoryEvidence?.();
15955
16103
  ctx.context.readFiles.clear();
15956
16104
  ctx.context.fileMtimes.clear();
15957
16105
  ctx.tokenCounter.reset();
@@ -15996,7 +16144,7 @@ function createProjectHandlers(ctx) {
15996
16144
  }
15997
16145
 
15998
16146
  // src/server/provider-handlers.ts
15999
- import { resolveProviderModelList } from "@wrongstack/core/models";
16147
+ import { hasProviderCredential, resolveProviderModelList } from "@wrongstack/core/models";
16000
16148
  import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/core/security";
16001
16149
  import { toErrorMessage as toErrorMessage6 } from "@wrongstack/core/utils";
16002
16150
  import {
@@ -16303,7 +16451,7 @@ function createProviderOperations(deps2) {
16303
16451
  }
16304
16452
  try {
16305
16453
  const providers = await deps2.modelsRegistry.listProviders();
16306
- const savedIds = new Set(Object.keys(await loadConfigProviders()));
16454
+ const savedProviders = await loadConfigProviders();
16307
16455
  sendMessage(ws, {
16308
16456
  type: "provider.catalog",
16309
16457
  payload: {
@@ -16314,7 +16462,7 @@ function createProviderOperations(deps2) {
16314
16462
  apiBase: provider.apiBase,
16315
16463
  envVars: provider.envVars,
16316
16464
  modelCount: provider.models.length,
16317
- hasApiKey: savedIds.has(provider.id) || provider.envVars.some((name2) => !!process.env[name2])
16465
+ hasApiKey: hasProviderCredential(provider, { providers: savedProviders })
16318
16466
  }))
16319
16467
  }
16320
16468
  });
@@ -16861,6 +17009,7 @@ var CLIENT_CONVERSATION_MESSAGE_TYPES = [
16861
17009
  "ping",
16862
17010
  "user_message",
16863
17011
  "tool.confirm_result",
17012
+ "topic.advice",
16864
17013
  "completion.request",
16865
17014
  "model.switch",
16866
17015
  "model.refine",
@@ -17163,6 +17312,7 @@ var SERVER_CONVERSATION_MESSAGE_TYPES = [
17163
17312
  "tool.loop_detected",
17164
17313
  "tool.progress",
17165
17314
  "tool.started",
17315
+ "topic.advice_result",
17166
17316
  "tools.list",
17167
17317
  "trust.persisted"
17168
17318
  ];
@@ -17672,6 +17822,8 @@ var SURFACE_PROTOCOL_CAPABILITIES = [
17672
17822
  "chronicle.metrics",
17673
17823
  "chronicle.status",
17674
17824
  "connections.health",
17825
+ /** Bounded topic-shift advice plus same-session provider-context boundaries. */
17826
+ "context.topic-boundary",
17675
17827
  /** Interview resume/discard + lastAgentText/lastRunId continuity. */
17676
17828
  "sdd.interview.continuity",
17677
17829
  /** Launch multi-agent runs from a graph id or resolved spec id. */
@@ -18076,6 +18228,7 @@ function createSessionHandlers(ctx) {
18076
18228
  await ctx.onBeforeSessionTodosReplaced?.(next.id, sessionsDirectory());
18077
18229
  ctx.context.state.replaceTodos(todos);
18078
18230
  resetContextAccounting();
18231
+ ctx.context.clearMemoryEvidence?.();
18079
18232
  ctx.context.readFiles.clear();
18080
18233
  ctx.context.fileMtimes.clear();
18081
18234
  ctx.context.state.setMeta?.(
@@ -18123,6 +18276,7 @@ function createSessionHandlers(ctx) {
18123
18276
  ctx.context.state.replaceMessages([]);
18124
18277
  ctx.context.state.replaceTodos([]);
18125
18278
  resetContextAccounting();
18279
+ ctx.context.clearMemoryEvidence?.();
18126
18280
  ctx.context.readFiles.clear();
18127
18281
  ctx.context.fileMtimes.clear();
18128
18282
  ctx.tokenCounter.reset?.();
@@ -18137,6 +18291,7 @@ function createSessionHandlers(ctx) {
18137
18291
  ctx.context.state.replaceMessages([]);
18138
18292
  ctx.context.state.replaceTodos([]);
18139
18293
  resetContextAccounting();
18294
+ ctx.context.clearMemoryEvidence?.();
18140
18295
  ctx.context.readFiles.clear();
18141
18296
  ctx.context.fileMtimes.clear();
18142
18297
  ctx.tokenCounter.reset?.();
@@ -19922,6 +20077,7 @@ function createEmbeddedMessageRouter(deps2) {
19922
20077
  };
19923
20078
  const guardedTypes = /* @__PURE__ */ new Set([
19924
20079
  "user_message",
20080
+ "topic.advice",
19925
20081
  "abort",
19926
20082
  "tool.confirm_result",
19927
20083
  "session.new",
@@ -20249,6 +20405,8 @@ function createEmbeddedMessageRouter(deps2) {
20249
20405
  ))
20250
20406
  return;
20251
20407
  if (await handleConnectionsServiceAction(ws, message, {
20408
+ trustBoundary: deps2.trustBoundary,
20409
+ logger: deps2.logger,
20252
20410
  getProjectRoot: projectRoot,
20253
20411
  getIndexDir: () => typeof opts.agent.ctx.meta["codebaseIndexDir"] === "string" ? opts.agent.ctx.meta["codebaseIndexDir"] : void 0,
20254
20412
  send: send2,
@@ -21235,7 +21393,7 @@ function registerSetupEventsClientStatusWriter(deps2) {
21235
21393
  const on = (event, listener) => events.on(event, listener);
21236
21394
  return on("client.status", async (e) => {
21237
21395
  broadcast2(clients, { type: "client.status_update", payload: e });
21238
- if (wpaths?.projectStatus) {
21396
+ if (wpaths?.projectStatus && e.projectHash !== "unknown") {
21239
21397
  try {
21240
21398
  const statusFile = wpaths.projectStatus(e.projectHash);
21241
21399
  const dir = path24.dirname(statusFile);
@@ -21410,7 +21568,10 @@ function registerSetupEventsProviderHandlers({
21410
21568
  sessionId: e.sessionId,
21411
21569
  providerId: e.providerId,
21412
21570
  modelId: e.modelId,
21413
- maxContext: e.maxContext
21571
+ maxContext: e.maxContext,
21572
+ ...e.previousMaxContext !== void 0 ? { previousMaxContext: e.previousMaxContext } : {},
21573
+ ...e.source !== void 0 ? { source: e.source } : {},
21574
+ ...e.decreased !== void 0 ? { decreased: e.decreased } : {}
21414
21575
  })
21415
21576
  });
21416
21577
  });
@@ -22520,7 +22681,15 @@ var SpecsWebSocketHandler = class {
22520
22681
  this.clients.add(client);
22521
22682
  ws.on("close", () => this.clients.delete(client));
22522
22683
  ws.on("error", () => this.clients.delete(client));
22523
- void this.sendList(client);
22684
+ void this.sendList(client).catch((err) => {
22685
+ console.warn(
22686
+ JSON.stringify({
22687
+ level: "warn",
22688
+ event: "specs.initial_send_failed",
22689
+ message: err instanceof Error ? err.message : String(err)
22690
+ })
22691
+ );
22692
+ });
22524
22693
  }
22525
22694
  dispose() {
22526
22695
  this.clients.clear();
@@ -22734,15 +22903,17 @@ import {
22734
22903
 
22735
22904
  // src/server/discover-mailbox-bridge.ts
22736
22905
  import { spawn as spawn4 } from "node:child_process";
22737
- import { createRequire } from "node:module";
22738
22906
  import { existsSync as existsSync2 } from "node:fs";
22907
+ import { createRequire } from "node:module";
22739
22908
  import { dirname as dirname10, join as join13 } from "node:path";
22740
- import { resolveProjectDir as resolveProjectDir3 } from "@wrongstack/core/coordination";
22909
+ import {
22910
+ readLiveLock,
22911
+ resolveProjectDir as resolveProjectDir3
22912
+ } from "@wrongstack/core/coordination";
22741
22913
  import { wstackGlobalRoot as wstackGlobalRoot3 } from "@wrongstack/core/utils";
22742
- import { readLiveLock } from "@wrongstack/core/coordination";
22743
22914
  var MAILBOX_BRIDGE_BOOT_TIMEOUT_MS = 5e3;
22744
22915
  async function discoverMailboxBridgeForWebui(params) {
22745
- const mode = params.config?.features?.mailboxBridge ?? "auto";
22916
+ const mode = params.config?.features?.mailboxBridge ?? "off";
22746
22917
  if (mode === "off") return;
22747
22918
  const projectDir = resolveProjectDir3(params.projectRoot, wstackGlobalRoot3());
22748
22919
  let result = await readLiveLock(projectDir);
@@ -23004,6 +23175,13 @@ var TerminalWebSocketHandler = class {
23004
23175
  this.send(ws, { type: "terminal.exit", payload: { id: payload.id, exitCode: -1 } });
23005
23176
  return;
23006
23177
  }
23178
+ if (this.sessions.get(ws) !== map) {
23179
+ this.logger.info?.(
23180
+ `terminal.create raced a disconnect (id=${payload.id}) \u2014 killing the orphan`
23181
+ );
23182
+ this.killPty(pty, "terminal create after disconnect");
23183
+ return;
23184
+ }
23007
23185
  map.set(payload.id, pty);
23008
23186
  this.logger.info?.(`terminal.create spawned (id=${payload.id}, pid=${pty.pid ?? "?"}) in ${cwd}`);
23009
23187
  pty.onData((data) => {
@@ -24429,6 +24607,15 @@ function createMessageDispatcher(opts) {
24429
24607
  msg
24430
24608
  ))
24431
24609
  return;
24610
+ if (await handleConnectionsServiceAction(ws, msg, {
24611
+ trustBoundary: deps2.trustBoundary,
24612
+ logger: deps2.logger,
24613
+ getProjectRoot: state.getProjectRoot,
24614
+ getIndexDir: () => typeof deps2.context.meta["codebaseIndexDir"] === "string" ? deps2.context.meta["codebaseIndexDir"] : void 0,
24615
+ send,
24616
+ backend: "standalone"
24617
+ }))
24618
+ return;
24432
24619
  if (await handleCodebaseIndexServerControl(ws, msg, {
24433
24620
  trustBoundary: deps2.trustBoundary,
24434
24621
  logger: deps2.logger,
@@ -24935,6 +25122,7 @@ async function createPreContextServices(input) {
24935
25122
  registry: toolRegistry,
24936
25123
  tier: normalizeTokenSavingTier(config.features.tokenSavingMode),
24937
25124
  memory: { enabled: config.features.memory, store: memoryStore },
25125
+ nextSteps: { enabled: config.tools?.nextsteps?.enabled === true },
24938
25126
  coordinationTools: [
24939
25127
  makeMailboxTool({ projectDir: wpaths.projectDir, events }),
24940
25128
  makeMailSendTool({ projectDir: wpaths.projectDir, events }),
@@ -26122,7 +26310,8 @@ async function startWebUI(opts = {}) {
26122
26310
  watcherMetricsRef
26123
26311
  );
26124
26312
  httpServer.listen(httpPort, wsHost, () => {
26125
- console.log(`[WebUI] HTTP server running on http://${wsHost}:${httpPort}`);
26313
+ const tokenQuery = accessToken ? `/?token=${encodeURIComponent(accessToken)}` : "";
26314
+ console.log(`[WebUI] HTTP server running on http://${wsHost}:${httpPort}${tokenQuery}`);
26126
26315
  const extraUrls = formatExternalAccessUrls({
26127
26316
  bindHost: wsHost,
26128
26317
  port: httpPort,
@@ -26144,8 +26333,11 @@ async function startWebUI(opts = {}) {
26144
26333
  (req, socket, head2) => httpServer.emit("upgrade", req, socket, head2)
26145
26334
  );
26146
26335
  companionServer.on("error", (err) => {
26147
- if (err.code !== "EAFNOSUPPORT" && err.code !== "EADDRNOTAVAIL" && err.code !== "EADDRINUSE") {
26148
- throw err;
26336
+ const expected = err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL" || err.code === "EADDRINUSE";
26337
+ if (!expected) {
26338
+ console.warn(
26339
+ `[WebUI] companion listener on ${companionLabel} failed (${err.code ?? "unknown"}): ${err.message}. The primary address is unaffected.`
26340
+ );
26149
26341
  }
26150
26342
  });
26151
26343
  companionServer.listen(httpPort, companion, () => {
@@ -26387,24 +26579,23 @@ async function startWebUI(opts = {}) {
26387
26579
  clients,
26388
26580
  pendingConfirms,
26389
26581
  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}
26582
+ void mailbox.send({
26583
+ from: context.agentId,
26584
+ to: "*",
26585
+ type: "note",
26586
+ audience: "leaders",
26587
+ subject: `Security rejection: ${ev.issueCode}`,
26588
+ body: `Decoder tripwire ${ev.issueCode}: ${ev.issueMessage}
26398
26589
 
26399
26590
  connectionId: ${ev.connectionId ?? "?"}
26400
26591
  sessionId: ${ev.sessionId ?? "?"}
26401
26592
  agentId: ${ev.agentId ?? "?"}
26402
26593
  projectRoot: ${ev.projectRoot ?? "?"}`,
26403
- priority: "high",
26404
- senderSessionId: session.id
26405
- });
26406
- } catch {
26407
- }
26594
+ priority: "high",
26595
+ senderSessionId: session.id
26596
+ }).catch((err) => {
26597
+ console.warn(`[WebUI] security-rejection mailbox note failed: ${String(err)}`);
26598
+ });
26408
26599
  },
26409
26600
  goalHandler,
26410
26601
  specsHandler,
@@ -26489,6 +26680,7 @@ export {
26489
26680
  SddWizardWebSocketHandler,
26490
26681
  SpecsWebSocketHandler,
26491
26682
  TerminalWebSocketHandler,
26683
+ WEBUI_WS_MAX_BUFFERED_BYTES,
26492
26684
  WorktreeWebSocketHandler,
26493
26685
  addProvider,
26494
26686
  announceWebuiReady,
@@ -26743,6 +26935,7 @@ export {
26743
26935
  seedContextMeta,
26744
26936
  send,
26745
26937
  sendResult2 as sendResult,
26938
+ sendSerialized,
26746
26939
  setActiveKey,
26747
26940
  setupEvents,
26748
26941
  setupWebUICodebaseIndexing,