@wrongstack/webui-server 0.299.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.
@@ -52,6 +52,7 @@ var BOOLEAN_PREF_KEYS = /* @__PURE__ */ new Set([
52
52
  "chime",
53
53
  "confirmExit",
54
54
  "nextPrediction",
55
+ "nextStepsTool",
55
56
  "titleAnimation",
56
57
  "enhanceEnabled",
57
58
  "featureMcp",
@@ -77,6 +78,10 @@ var BOOLEAN_PREF_KEYS = /* @__PURE__ */ new Set([
77
78
  // Display-only toggles (purely visual, persisted in localStorage via Zustand).
78
79
  "groupToolCalls",
79
80
  "showThinkingLogs",
81
+ // v15: auto-collapse of the chat input under the history (opt-in display
82
+ // toggle, default off). Whitelisted so the key survives `prefs.update`
83
+ // round-trips without tripping the "unknown preference key" rejection.
84
+ "autoCollapseInput",
80
85
  // v11 Display parity: inverse fsAccess flag.
81
86
  "allowOutsideProjectRoot",
82
87
  // v13 Display parity (TUI SettingsPicker fields 42 & 43): the read tool
@@ -171,6 +176,7 @@ var ENUM_PREF_KEYS = {
171
176
  fsAccess: /* @__PURE__ */ new Set(["unrestricted", "project"]),
172
177
  // Chimera autoFix + auto-review cascade threshold
173
178
  chimeraAutoFix: /* @__PURE__ */ new Set(["off", "ask", "auto"]),
179
+ autoReviewModelSelection: /* @__PURE__ */ new Set(["round-robin", "random"]),
174
180
  autoReviewCascadeOn: /* @__PURE__ */ new Set(["off", "critical", "high"]),
175
181
  fleetChatVerbosity: /* @__PURE__ */ new Set(["off", "full"]),
176
182
  showAgentSwarmPanel: /* @__PURE__ */ new Set(["bottom", "sidebar", "off"])
@@ -401,6 +407,51 @@ function validateModelSwitchPayload(payload) {
401
407
  }
402
408
  };
403
409
  }
410
+ function validateModelFallbackChoicePayload(payload) {
411
+ if (!isRecord2(payload)) {
412
+ return {
413
+ ok: false,
414
+ message: "model.fallback_choice payload must be an object"
415
+ };
416
+ }
417
+ const requestId = payload["requestId"];
418
+ if (typeof requestId !== "string" || requestId.trim().length === 0) {
419
+ return {
420
+ ok: false,
421
+ message: "model.fallback_choice payload.requestId must be a non-empty string"
422
+ };
423
+ }
424
+ const providerId = payload["providerId"];
425
+ const model = payload["model"];
426
+ const autoSwitch = payload["autoSwitch"];
427
+ if (providerId !== void 0 && typeof providerId !== "string") {
428
+ return {
429
+ ok: false,
430
+ message: "model.fallback_choice payload.providerId must be a string when provided"
431
+ };
432
+ }
433
+ if (model !== void 0 && typeof model !== "string") {
434
+ return {
435
+ ok: false,
436
+ message: "model.fallback_choice payload.model must be a string when provided"
437
+ };
438
+ }
439
+ if (autoSwitch !== void 0 && typeof autoSwitch !== "boolean") {
440
+ return {
441
+ ok: false,
442
+ message: "model.fallback_choice payload.autoSwitch must be a boolean when provided"
443
+ };
444
+ }
445
+ return {
446
+ ok: true,
447
+ value: {
448
+ requestId: requestId.trim(),
449
+ ...typeof providerId === "string" ? { providerId } : {},
450
+ ...typeof model === "string" ? { model } : {},
451
+ ...typeof autoSwitch === "boolean" ? { autoSwitch } : {}
452
+ }
453
+ };
454
+ }
404
455
  var AUTONOMY_VALUES2 = /* @__PURE__ */ new Set(["off", "suggest", "auto", "eternal", "eternal-parallel"]);
405
456
  function validateMailboxMessagesPayload(payload) {
406
457
  if (payload === void 0) return { ok: true, value: void 0 };
@@ -4181,6 +4232,9 @@ async function handleConversationRoute(ws, msg, handlers) {
4181
4232
  case "user_message":
4182
4233
  await handlers.userMessage(ws, msg);
4183
4234
  return true;
4235
+ case "topic.advice":
4236
+ await handlers.topicAdvice(ws, msg);
4237
+ return true;
4184
4238
  case "abort":
4185
4239
  await handlers.abort(ws, msg);
4186
4240
  return true;
@@ -4196,6 +4250,7 @@ async function handleConversationRoute(ws, msg, handlers) {
4196
4250
  }
4197
4251
 
4198
4252
  // src/server/conversation-operations.ts
4253
+ import { startFreshTopicContext, TopicShiftAdvisor } from "@wrongstack/core/execution";
4199
4254
  import {
4200
4255
  buildUserContentBlocks,
4201
4256
  IncomingImageError,
@@ -4212,6 +4267,7 @@ function requestedSessionId(msg) {
4212
4267
  return payload && typeof payload === "object" && typeof payload.sessionId === "string" ? payload.sessionId : void 0;
4213
4268
  }
4214
4269
  function createConversationOperations(ctx) {
4270
+ const topicShiftAdvisor = new TopicShiftAdvisor();
4215
4271
  const sessionPayload2 = (payload) => {
4216
4272
  const provided = payload["sessionId"];
4217
4273
  const sessionId = typeof provided === "string" && provided.length > 0 ? provided : ctx.getSessionId();
@@ -4232,6 +4288,38 @@ function createConversationOperations(ctx) {
4232
4288
  return false;
4233
4289
  };
4234
4290
  return {
4291
+ topicAdvice: async (ws, msg) => {
4292
+ if (!ensureCurrentSession(ws, msg, "topic.advice")) return;
4293
+ const payload = msg.payload ?? {};
4294
+ if (typeof payload.requestId !== "string" || typeof payload.prompt !== "string") {
4295
+ ctx.send(ws, {
4296
+ type: "topic.advice_result",
4297
+ payload: sessionPayload2({
4298
+ requestId: typeof payload.requestId === "string" ? payload.requestId : "",
4299
+ suggestNewContext: false,
4300
+ confidence: 0,
4301
+ reason: "Invalid topic advice request.",
4302
+ source: "local"
4303
+ })
4304
+ });
4305
+ return;
4306
+ }
4307
+ const agent = ctx.getAgent();
4308
+ const configuredMax = agent.ctx.meta["effectiveMaxContext"];
4309
+ const maxContext = typeof configuredMax === "number" ? configuredMax : agent.ctx.provider.capabilities.maxContext;
4310
+ const advice = await topicShiftAdvisor.advise({
4311
+ prompt: payload.prompt,
4312
+ messages: agent.ctx.messages,
4313
+ provider: agent.ctx.provider,
4314
+ model: agent.ctx.model,
4315
+ contextTokens: agent.ctx.lastRequestTokens,
4316
+ maxContext
4317
+ });
4318
+ ctx.send(ws, {
4319
+ type: "topic.advice_result",
4320
+ payload: sessionPayload2({ requestId: payload.requestId, ...advice })
4321
+ });
4322
+ },
4235
4323
  userMessage: async (ws, msg) => {
4236
4324
  if (!ensureCurrentSession(ws, msg, "user_message")) return;
4237
4325
  const payload = msg.payload ?? {};
@@ -4249,6 +4337,7 @@ function createConversationOperations(ctx) {
4249
4337
  const originSessionId = ctx.getSessionId();
4250
4338
  try {
4251
4339
  const agent = ctx.getAgent();
4340
+ if (payload.freshContext === true) await startFreshTopicContext(agent.ctx);
4252
4341
  const content = typeof payload.content === "string" ? payload.content : "";
4253
4342
  let input = content;
4254
4343
  const imageBlocks = parseIncomingImages(payload.images, payload.imageBase64);
@@ -4331,13 +4420,10 @@ function createConversationOperations(ctx) {
4331
4420
 
4332
4421
  // src/server/context-editor.ts
4333
4422
  import { createHash } from "node:crypto";
4334
- import net from "node:net";
4335
4423
  import {
4336
4424
  ALLOWED_IMAGE_MEDIA_TYPES,
4337
4425
  base64DecodedBytes,
4338
4426
  isAllowedImageMediaType,
4339
- isPrivateIPv4,
4340
- isPrivateIPv6,
4341
4427
  isValidImageBase64,
4342
4428
  MAX_INCOMING_IMAGE_BYTES,
4343
4429
  repairToolUseAdjacency
@@ -4405,40 +4491,7 @@ var REVISION_PREFIX = "wrongstack-context-editor-v1\0";
4405
4491
  var MAX_MESSAGE_COUNT_GROWTH = 10;
4406
4492
  var MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
4407
4493
  var MAX_STRING_LENGTH = 8 * 1024 * 1024;
4408
- var MAX_IMAGE_URL_LENGTH = 2048;
4409
- function imageUrlRejectionReason(url) {
4410
- if (url.length > MAX_IMAGE_URL_LENGTH) {
4411
- return `image.source.url exceeds ${MAX_IMAGE_URL_LENGTH} characters.`;
4412
- }
4413
- let parsed;
4414
- try {
4415
- parsed = new URL(url);
4416
- } catch {
4417
- return "image.source.url must be an absolute URL.";
4418
- }
4419
- if (parsed.protocol !== "https:") {
4420
- return `image.source.url must use https (got "${parsed.protocol}").`;
4421
- }
4422
- if (parsed.username !== "" || parsed.password !== "") {
4423
- return "image.source.url must not embed credentials.";
4424
- }
4425
- const host = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]") ? parsed.hostname.slice(1, -1) : parsed.hostname;
4426
- const bareHost = host.endsWith(".") ? host.slice(0, -1) : host;
4427
- if (bareHost === "") {
4428
- return "image.source.url must include a hostname.";
4429
- }
4430
- if (bareHost === "localhost" || bareHost.endsWith(".localhost")) {
4431
- return "image.source.url must not target localhost.";
4432
- }
4433
- const family = net.isIP(bareHost);
4434
- if (family === 4 && isPrivateIPv4(bareHost)) {
4435
- return `image.source.url must not target a private or loopback address ("${bareHost}").`;
4436
- }
4437
- if (family === 6 && isPrivateIPv6(bareHost)) {
4438
- return `image.source.url must not target a private or loopback address ("${bareHost}").`;
4439
- }
4440
- return void 0;
4441
- }
4494
+ var MAX_REMOVAL_COUNT = 4096;
4442
4495
  function isRecord3(value) {
4443
4496
  return value !== null && typeof value === "object" && !Array.isArray(value);
4444
4497
  }
@@ -4447,7 +4500,7 @@ function canonicalize(value) {
4447
4500
  if (isRecord3(value)) {
4448
4501
  const sorted = {};
4449
4502
  for (const key of Object.keys(value).sort()) {
4450
- if (key === "_estTokens") continue;
4503
+ if (key === "_estTokens" || key === "_toolErrorInfo") continue;
4451
4504
  const item = value[key];
4452
4505
  if (item === void 0) continue;
4453
4506
  sorted[key] = canonicalize(item);
@@ -4478,6 +4531,12 @@ function isMessageRole(value) {
4478
4531
  function isPlainJsonObject(value) {
4479
4532
  return isRecord3(value);
4480
4533
  }
4534
+ function splitsSurrogatePair(text, offset) {
4535
+ if (offset <= 0 || offset >= text.length) return false;
4536
+ const previous = text.charCodeAt(offset - 1);
4537
+ const next = text.charCodeAt(offset);
4538
+ return previous >= 55296 && previous <= 56319 && next >= 56320 && next <= 57343;
4539
+ }
4481
4540
  function validateCacheControl(value, path30, errors) {
4482
4541
  if (value === void 0) return void 0;
4483
4542
  if (!isRecord3(value) || value["type"] !== "ephemeral") {
@@ -4688,19 +4747,13 @@ function validateBlock(value, path30, errors) {
4688
4747
  );
4689
4748
  return void 0;
4690
4749
  }
4691
- const urlError = imageUrlRejectionReason(url);
4692
- if (urlError !== void 0) {
4693
- error(errors, `${path30}/source/url`, "UNSAFE_IMAGE_URL", urlError);
4694
- return void 0;
4695
- }
4696
- return {
4697
- type: "image",
4698
- source: {
4699
- type: "url",
4700
- ...typeof mediaType === "string" ? { media_type: mediaType } : {},
4701
- url
4702
- }
4703
- };
4750
+ error(
4751
+ errors,
4752
+ `${path30}/source/url`,
4753
+ "UNSAFE_IMAGE_URL",
4754
+ "URL image sources are not allowed in context editor proposals; use an ingested base64 image."
4755
+ );
4756
+ return void 0;
4704
4757
  }
4705
4758
  case "thinking": {
4706
4759
  const thinking = value["thinking"];
@@ -4857,6 +4910,14 @@ function warningsForMessage(message, index) {
4857
4910
  message: "This block contains provider replay metadata and should only be removed with the whole turn if no longer needed."
4858
4911
  });
4859
4912
  }
4913
+ if (block.type === "image" && block.source.type === "url") {
4914
+ warnings.push({
4915
+ path: `/messages/${index}/content/${blockIndex}/source/url`,
4916
+ code: "UNSAFE_IMAGE_URL",
4917
+ severity: "danger",
4918
+ message: "URL image sources cannot be retained in context editor proposals; remove the whole message before applying other edits."
4919
+ });
4920
+ }
4860
4921
  if (block.type === "tool_result" && block.content.length > 2e4) {
4861
4922
  warnings.push({
4862
4923
  path: `/messages/${index}/content/${blockIndex}`,
@@ -4884,6 +4945,21 @@ function metricFor(ctx, messages, tools) {
4884
4945
  fullRequestTokens: breakdown.total
4885
4946
  };
4886
4947
  }
4948
+ function isToolResultMessage(message) {
4949
+ return Boolean(
4950
+ message?.role === "user" && Array.isArray(message.content) && message.content.length > 0 && message.content.every((block) => block.type === "tool_result")
4951
+ );
4952
+ }
4953
+ function pairedAssistantIndices(messages, userIndex) {
4954
+ if (messages[userIndex]?.role !== "user" || isToolResultMessage(messages[userIndex])) return [];
4955
+ const paired = [];
4956
+ for (let index = userIndex + 1; index < messages.length; index += 1) {
4957
+ const message = messages[index];
4958
+ if (message?.role === "user" && !isToolResultMessage(message)) break;
4959
+ if (message?.role === "assistant") paired.push(index);
4960
+ }
4961
+ return paired;
4962
+ }
4887
4963
  function buildContextEditorSnapshot(ctx, tools) {
4888
4964
  const messages = ctx.messages.map(
4889
4965
  (message) => ({
@@ -4913,11 +4989,165 @@ function buildContextEditorSnapshot(ctx, tools) {
4913
4989
  tokens: messageTokens(message.content),
4914
4990
  preview: breakdown.messages.breakdown[index]?.preview ?? "",
4915
4991
  blockCount: Array.isArray(message.content) ? message.content.length : null,
4916
- warnings: warningsForMessage(message, index)
4992
+ warnings: warningsForMessage(message, index),
4993
+ pairedAssistantIndices: pairedAssistantIndices(ctx.messages, index)
4917
4994
  })),
4918
4995
  diagnostics: toolDiagnostics(ctx.messages)
4919
4996
  };
4920
4997
  }
4998
+ function validateRemovalPlan(value, originalMessages, proposedMessages) {
4999
+ const errors = [];
5000
+ if (value === void 0) {
5001
+ error(
5002
+ errors,
5003
+ "/removals",
5004
+ "REMOVAL_PLAN_REQUIRED",
5005
+ "A removal plan is required for every context editor proposal."
5006
+ );
5007
+ return { errors };
5008
+ }
5009
+ if (!Array.isArray(value)) {
5010
+ error(errors, "/removals", "INVALID_REMOVALS", "removals must be an array.");
5011
+ return { errors };
5012
+ }
5013
+ if (value.length > MAX_REMOVAL_COUNT) {
5014
+ error(
5015
+ errors,
5016
+ "/removals",
5017
+ "TOO_MANY_REMOVALS",
5018
+ `removals must contain at most ${MAX_REMOVAL_COUNT} entries.`
5019
+ );
5020
+ return { errors };
5021
+ }
5022
+ const wholeMessages = /* @__PURE__ */ new Set();
5023
+ const touchedUsers = /* @__PURE__ */ new Set();
5024
+ const ranges = [];
5025
+ for (const [removalIndex, raw] of value.entries()) {
5026
+ const path30 = `/removals/${removalIndex}`;
5027
+ if (!isRecord3(raw) || !Number.isInteger(raw["messageIndex"])) {
5028
+ error(errors, path30, "INVALID_REMOVAL", "Removal must include an integer messageIndex.");
5029
+ continue;
5030
+ }
5031
+ const messageIndex = raw["messageIndex"];
5032
+ const original = originalMessages[messageIndex];
5033
+ if (!original) {
5034
+ error(errors, `${path30}/messageIndex`, "INVALID_MESSAGE_INDEX", "Removal messageIndex is out of range.");
5035
+ continue;
5036
+ }
5037
+ const start = raw["start"];
5038
+ const end = raw["end"];
5039
+ const blockIndex = raw["blockIndex"];
5040
+ if (start === void 0 && end === void 0 && blockIndex === void 0) {
5041
+ wholeMessages.add(messageIndex);
5042
+ if (original.role === "user") touchedUsers.add(messageIndex);
5043
+ continue;
5044
+ }
5045
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start) {
5046
+ error(errors, path30, "INVALID_RANGE", "Range removal requires integer start/end with 0 <= start < end.");
5047
+ continue;
5048
+ }
5049
+ let text;
5050
+ if (blockIndex === void 0 && typeof original.content === "string") text = original.content;
5051
+ if (Number.isInteger(blockIndex) && Array.isArray(original.content)) {
5052
+ const block = original.content[blockIndex];
5053
+ if (block?.type === "text") text = block.text;
5054
+ }
5055
+ if (text === void 0 || end > text.length) {
5056
+ error(errors, path30, "INVALID_RANGE_TARGET", "Range must target existing string or text-block content.");
5057
+ continue;
5058
+ }
5059
+ if (splitsSurrogatePair(text, start) || splitsSurrogatePair(text, end)) {
5060
+ error(
5061
+ errors,
5062
+ path30,
5063
+ "INVALID_UNICODE_RANGE",
5064
+ "Range boundaries must not split a Unicode surrogate pair."
5065
+ );
5066
+ continue;
5067
+ }
5068
+ ranges.push({
5069
+ messageIndex,
5070
+ ...blockIndex === void 0 ? {} : { blockIndex },
5071
+ start,
5072
+ end
5073
+ });
5074
+ if (original.role === "user") touchedUsers.add(messageIndex);
5075
+ }
5076
+ const rangesByTarget = /* @__PURE__ */ new Map();
5077
+ for (const range of ranges) {
5078
+ const key = `${range.messageIndex}:${range.blockIndex ?? "string"}`;
5079
+ const targetRanges = rangesByTarget.get(key) ?? [];
5080
+ targetRanges.push(range);
5081
+ rangesByTarget.set(key, targetRanges);
5082
+ }
5083
+ for (const targetRanges of rangesByTarget.values()) {
5084
+ targetRanges.sort((left, right) => (left.start ?? 0) - (right.start ?? 0));
5085
+ for (let index = 1; index < targetRanges.length; index += 1) {
5086
+ const previous = targetRanges[index - 1];
5087
+ const current2 = targetRanges[index];
5088
+ if (previous?.end !== void 0 && current2?.start !== void 0 && current2.start < previous.end) {
5089
+ error(
5090
+ errors,
5091
+ "/removals",
5092
+ "OVERLAPPING_RANGES",
5093
+ "Removal ranges targeting the same text must not overlap."
5094
+ );
5095
+ break;
5096
+ }
5097
+ }
5098
+ }
5099
+ for (const userIndex of touchedUsers) {
5100
+ for (const assistantIndex of pairedAssistantIndices(originalMessages, userIndex)) {
5101
+ if (wholeMessages.has(assistantIndex)) continue;
5102
+ error(
5103
+ errors,
5104
+ "/removals",
5105
+ "MISSING_ASSISTANT_PAIR",
5106
+ `Editing user message ${userIndex} must also remove assistant message ${assistantIndex}.`
5107
+ );
5108
+ }
5109
+ }
5110
+ const expectedMessages = structuredClone(originalMessages);
5111
+ for (const targetRanges of rangesByTarget.values()) {
5112
+ const first = targetRanges[0];
5113
+ if (!first) continue;
5114
+ const message = expectedMessages[first.messageIndex];
5115
+ if (!message) continue;
5116
+ let text;
5117
+ if (first.blockIndex === void 0 && typeof message.content === "string") {
5118
+ text = message.content;
5119
+ } else if (first.blockIndex !== void 0 && Array.isArray(message.content)) {
5120
+ const block = message.content[first.blockIndex];
5121
+ if (block?.type === "text") text = block.text;
5122
+ }
5123
+ if (text === void 0) continue;
5124
+ const pieces = [];
5125
+ let cursor = 0;
5126
+ for (const range of targetRanges) {
5127
+ if (range.start === void 0 || range.end === void 0) continue;
5128
+ pieces.push(text.slice(cursor, range.start));
5129
+ cursor = range.end;
5130
+ }
5131
+ pieces.push(text.slice(cursor));
5132
+ const nextText = pieces.join("");
5133
+ if (first.blockIndex === void 0 && typeof message.content === "string") {
5134
+ message.content = nextText;
5135
+ } else if (first.blockIndex !== void 0 && Array.isArray(message.content)) {
5136
+ const block = message.content[first.blockIndex];
5137
+ if (block?.type === "text") block.text = nextText;
5138
+ }
5139
+ }
5140
+ const expectedProposal = expectedMessages.filter((_, index) => !wholeMessages.has(index));
5141
+ if (JSON.stringify(canonicalize(expectedProposal)) !== JSON.stringify(canonicalize(proposedMessages))) {
5142
+ error(
5143
+ errors,
5144
+ "/messages",
5145
+ "REMOVAL_PLAN_MISMATCH",
5146
+ "Submitted messages do not exactly match the declared removal plan."
5147
+ );
5148
+ }
5149
+ return errors.length > 0 ? { errors } : { errors, messages: expectedProposal };
5150
+ }
4921
5151
  function validateContextEditorProposal(input) {
4922
5152
  const currentRevision = contextEditorRevision(input.ctx.messages);
4923
5153
  const before = metricFor(input.ctx, input.ctx.messages, input.tools);
@@ -4969,7 +5199,23 @@ function validateContextEditorProposal(input) {
4969
5199
  repair: emptyRepair
4970
5200
  };
4971
5201
  }
4972
- const repaired = repairToolUseAdjacency(parsed.messages);
5202
+ const removalPlan = validateRemovalPlan(
5203
+ input.removals,
5204
+ input.ctx.messages,
5205
+ parsed.messages
5206
+ );
5207
+ if (removalPlan.errors.length > 0 || !removalPlan.messages) {
5208
+ return {
5209
+ ok: false,
5210
+ baseRevision: input.baseRevision,
5211
+ currentRevision,
5212
+ before,
5213
+ validationErrors: removalPlan.errors,
5214
+ warnings: [],
5215
+ repair: emptyRepair
5216
+ };
5217
+ }
5218
+ const repaired = repairToolUseAdjacency(removalPlan.messages);
4973
5219
  const repair = {
4974
5220
  changed: repaired.report.changed,
4975
5221
  removedToolUses: repaired.report.removedToolUses,
@@ -5219,6 +5465,40 @@ import {
5219
5465
  getKanbanServerConnection,
5220
5466
  isKanbanServerAvailable
5221
5467
  } from "@wrongstack/kanban";
5468
+ import * as net from "node:net";
5469
+
5470
+ // src/server/privileged-actions.ts
5471
+ import { randomUUID as randomUUID2 } from "node:crypto";
5472
+ import {
5473
+ isTrustDecisionAllowed
5474
+ } from "@wrongstack/core/security";
5475
+ async function authorizeWebUIAction(boundary, action, logger) {
5476
+ const request = {
5477
+ version: 1,
5478
+ requestId: randomUUID2(),
5479
+ actor: {
5480
+ kind: "remote-client",
5481
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
5482
+ },
5483
+ surface: "webui",
5484
+ capability: action.capability,
5485
+ subject: action.subject,
5486
+ risk: action.risk,
5487
+ scope: {
5488
+ ...action.cwd ? { cwd: action.cwd } : {},
5489
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
5490
+ },
5491
+ authContext: { method: "session" },
5492
+ ...action.metadata ? { metadata: action.metadata } : {}
5493
+ };
5494
+ const decision = await boundary.evaluate(request);
5495
+ logger?.debug?.(
5496
+ `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
5497
+ );
5498
+ return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
5499
+ }
5500
+
5501
+ // src/server/connections-health-route.ts
5222
5502
  import { readGovernanceDaemonOperatorStatus } from "@wrongstack/runtime/governance-bootstrap";
5223
5503
  import { isSageProjectServerAvailable, SageProjectServerConnection } from "@wrongstack/sage";
5224
5504
  import {
@@ -5558,41 +5838,579 @@ async function mailboxHealth(projectRoot) {
5558
5838
  connection.close();
5559
5839
  }
5560
5840
  }
5561
- async function governanceHealth(projectRoot) {
5562
- const startedAt = Date.now();
5563
- const result = await readGovernanceDaemonOperatorStatus(projectRoot);
5564
- if (!result.available) {
5565
- const missing = result.code === "broker_missing";
5841
+ async function governanceHealth(projectRoot) {
5842
+ const startedAt = Date.now();
5843
+ const result = await readGovernanceDaemonOperatorStatus(projectRoot);
5844
+ if (!result.available) {
5845
+ const missing = result.code === "broker_missing";
5846
+ return {
5847
+ id: "governance",
5848
+ label: "Governance control plane",
5849
+ status: missing ? "offline" : "error",
5850
+ required: false,
5851
+ mode: missing ? "compatibility-default-off" : "project-daemon",
5852
+ detail: missing ? "Not active for this project. Existing agent and model execution remains unchanged." : `Read-only governance status is unavailable: ${result.message}`,
5853
+ latencyMs: Date.now() - startedAt,
5854
+ control: "none",
5855
+ ...missing ? {} : { lastError: result.message }
5856
+ };
5857
+ }
5858
+ const { status } = result;
5859
+ return {
5860
+ id: "governance",
5861
+ label: "Governance control plane",
5862
+ status: status.signal.level === "healthy" ? "healthy" : "degraded",
5863
+ required: false,
5864
+ mode: "project-daemon-advisory",
5865
+ detail: `${status.signal.message} Execution continues; no automatic task or model stop.`,
5866
+ ownerPid: status.pid,
5867
+ uptimeMs: Math.max(0, Date.now() - Date.parse(status.startedAt)),
5868
+ latencyMs: Date.now() - startedAt,
5869
+ control: "none",
5870
+ advisory: {
5871
+ code: status.signal.code,
5872
+ operatorAction: status.signal.operatorAction,
5873
+ executionDisposition: status.signal.executionDisposition
5874
+ }
5875
+ };
5876
+ }
5877
+ async function handleConnectionsServiceAction(ws, message, context) {
5878
+ if (message.type !== "connections.service_action") return false;
5879
+ const payload = message.payload;
5880
+ const serviceId = payload?.serviceId;
5881
+ const rawAction = payload?.action ?? "shutdown";
5882
+ if (!serviceId) {
5883
+ context.send(ws, {
5884
+ type: "connections.service_action_result",
5885
+ payload: {
5886
+ serviceId: null,
5887
+ action: rawAction,
5888
+ success: false,
5889
+ message: "Missing serviceId in payload"
5890
+ }
5891
+ });
5892
+ return true;
5893
+ }
5894
+ if (rawAction !== "shutdown" && rawAction !== "restart") {
5895
+ context.send(ws, {
5896
+ type: "connections.service_action_result",
5897
+ payload: {
5898
+ serviceId,
5899
+ action: rawAction,
5900
+ success: false,
5901
+ message: `Unsupported action "${rawAction}" \u2014 only "shutdown" and "restart" are supported`
5902
+ }
5903
+ });
5904
+ return true;
5905
+ }
5906
+ const action = rawAction;
5907
+ if (!context.trustBoundary) {
5908
+ context.send(ws, {
5909
+ type: "connections.service_action_result",
5910
+ payload: {
5911
+ serviceId,
5912
+ action,
5913
+ success: false,
5914
+ message: "Service control is unavailable: no policy authority is configured."
5915
+ }
5916
+ });
5917
+ return true;
5918
+ }
5919
+ const projectRootForAuth = context.getProjectRoot();
5920
+ const authorization = await authorizeWebUIAction(
5921
+ context.trustBoundary,
5922
+ {
5923
+ capability: `connections.service.${action}`,
5924
+ subject: { kind: "process", id: `${serviceId}@${projectRootForAuth}` },
5925
+ risk: "elevated",
5926
+ cwd: projectRootForAuth,
5927
+ metadata: { transport: "websocket", serviceId, action }
5928
+ },
5929
+ context.logger
5930
+ );
5931
+ if (!authorization.allowed) {
5932
+ context.send(ws, {
5933
+ type: "connections.service_action_result",
5934
+ payload: {
5935
+ serviceId,
5936
+ action,
5937
+ success: false,
5938
+ message: authorization.reason ?? "Refused by policy."
5939
+ }
5940
+ });
5941
+ return true;
5942
+ }
5943
+ if (serviceId === "webui") {
5944
+ context.send(ws, {
5945
+ type: "connections.service_action_result",
5946
+ payload: {
5947
+ serviceId: "webui",
5948
+ action,
5949
+ success: false,
5950
+ message: action === "restart" ? "Cannot restart the WebUI transport itself" : "Cannot shut down the WebUI transport itself"
5951
+ }
5952
+ });
5953
+ return true;
5954
+ }
5955
+ try {
5956
+ const result = await executeServiceAction(
5957
+ serviceId,
5958
+ action,
5959
+ context.getProjectRoot(),
5960
+ context.getIndexDir()
5961
+ );
5962
+ context.send(ws, {
5963
+ type: "connections.service_action_result",
5964
+ payload: result
5965
+ });
5966
+ } catch (error2) {
5967
+ context.send(ws, {
5968
+ type: "connections.service_action_result",
5969
+ payload: {
5970
+ serviceId,
5971
+ action,
5972
+ success: false,
5973
+ message: error2 instanceof Error ? error2.message : String(error2)
5974
+ }
5975
+ });
5976
+ }
5977
+ return true;
5978
+ }
5979
+ async function executeServiceAction(serviceId, action, projectRoot, indexDir) {
5980
+ switch (serviceId) {
5981
+ case "kanban":
5982
+ return killKanbanServer(projectRoot, action);
5983
+ case "sage":
5984
+ return killSageServer(projectRoot, action);
5985
+ case "chronicle":
5986
+ return killChronicleServer(projectRoot, action);
5987
+ case "codebase-index":
5988
+ return killCodebaseIndexServer(projectRoot, indexDir, action);
5989
+ case "mailbox":
5990
+ return killMailboxServer(projectRoot, action);
5991
+ case "governance":
5992
+ return {
5993
+ serviceId: "governance",
5994
+ action,
5995
+ success: false,
5996
+ message: "Governance health is read-only; daemon shutdown requires a separate admin control capability."
5997
+ };
5998
+ default:
5999
+ return {
6000
+ serviceId,
6001
+ action,
6002
+ success: false,
6003
+ message: `Unknown service: ${serviceId}`
6004
+ };
6005
+ }
6006
+ }
6007
+ async function killKanbanServer(projectRoot, action) {
6008
+ if (process.env["WRONGSTACK_KANBAN_SERVER"] === "0") {
6009
+ return {
6010
+ serviceId: "kanban",
6011
+ action,
6012
+ success: false,
6013
+ message: "Kanban IPC daemon is disabled via WRONGSTACK_KANBAN_SERVER=0"
6014
+ };
6015
+ }
6016
+ let connection;
6017
+ try {
6018
+ connection = await getKanbanServerConnection(projectRoot);
6019
+ } catch (error2) {
6020
+ return {
6021
+ serviceId: "kanban",
6022
+ action,
6023
+ success: false,
6024
+ message: error2 instanceof Error ? error2.message : String(error2)
6025
+ };
6026
+ }
6027
+ if (!connection) {
6028
+ return {
6029
+ serviceId: "kanban",
6030
+ action,
6031
+ success: false,
6032
+ message: "Kanban IPC daemon is not running"
6033
+ };
6034
+ }
6035
+ try {
6036
+ const result = await connection.request("shutdown", {
6037
+ reason: `WebUI request: ${action}`
6038
+ });
6039
+ if (!result.stopping) {
6040
+ return {
6041
+ serviceId: "kanban",
6042
+ action,
6043
+ success: false,
6044
+ message: "Kanban IPC daemon shutdown failed (not confirmed)"
6045
+ };
6046
+ }
6047
+ if (action === "restart") {
6048
+ closeKanbanServerConnections();
6049
+ const restartResult = await restartKanbanServer(projectRoot);
6050
+ return restartResult;
6051
+ }
6052
+ return {
6053
+ serviceId: "kanban",
6054
+ action,
6055
+ success: true,
6056
+ message: "Kanban IPC daemon shutdown requested"
6057
+ };
6058
+ } catch (error2) {
6059
+ return {
6060
+ serviceId: "kanban",
6061
+ action,
6062
+ success: false,
6063
+ message: error2 instanceof Error ? error2.message : String(error2)
6064
+ };
6065
+ }
6066
+ }
6067
+ async function restartKanbanServer(projectRoot) {
6068
+ await waitForShutdown(() => isKanbanServerAvailable(projectRoot));
6069
+ try {
6070
+ const connection = await getKanbanServerConnection(projectRoot);
6071
+ if (!connection) {
6072
+ return {
6073
+ serviceId: "kanban",
6074
+ action: "restart",
6075
+ success: false,
6076
+ message: "Kanban IPC daemon failed to restart (no connection after re-init)"
6077
+ };
6078
+ }
6079
+ await connection.request("ping", {}, { timeoutMs: 1e4 });
6080
+ return {
6081
+ serviceId: "kanban",
6082
+ action: "restart",
6083
+ success: true,
6084
+ message: "Kanban IPC daemon restarted successfully"
6085
+ };
6086
+ } catch (error2) {
6087
+ return {
6088
+ serviceId: "kanban",
6089
+ action: "restart",
6090
+ success: false,
6091
+ message: `Kanban IPC daemon restarted but verification failed: ${error2 instanceof Error ? error2.message : String(error2)}`
6092
+ };
6093
+ }
6094
+ }
6095
+ async function killSageServer(projectRoot, action) {
6096
+ if (!isSageProjectServerAvailable()) {
6097
+ return {
6098
+ serviceId: "sage",
6099
+ action,
6100
+ success: false,
6101
+ message: "SAGE project server is unavailable in this runtime"
6102
+ };
6103
+ }
6104
+ const connection = new SageProjectServerConnection(projectRoot);
6105
+ try {
6106
+ const result = await connection.shutdown(`WebUI request: ${action}`);
6107
+ if (!result.stopped) {
6108
+ return {
6109
+ serviceId: "sage",
6110
+ action,
6111
+ success: false,
6112
+ message: `SAGE memory server shutdown failed: ${result.reason ?? "unknown"}`
6113
+ };
6114
+ }
6115
+ if (action === "restart") {
6116
+ return await restartSageServer(projectRoot);
6117
+ }
6118
+ return {
6119
+ serviceId: "sage",
6120
+ action,
6121
+ success: true,
6122
+ message: "SAGE memory server shutdown requested"
6123
+ };
6124
+ } catch (error2) {
6125
+ return {
6126
+ serviceId: "sage",
6127
+ action,
6128
+ success: false,
6129
+ message: error2 instanceof Error ? error2.message : String(error2)
6130
+ };
6131
+ } finally {
6132
+ connection.close();
6133
+ }
6134
+ }
6135
+ async function restartSageServer(projectRoot) {
6136
+ await waitForShutdown(async () => {
6137
+ const probe = new SageProjectServerConnection(projectRoot);
6138
+ try {
6139
+ return await probe.status() !== null;
6140
+ } finally {
6141
+ probe.close();
6142
+ }
6143
+ });
6144
+ const verifyConn = new SageProjectServerConnection(projectRoot);
6145
+ try {
6146
+ await verifyConn.call("ping", {}, { timeoutMs: 1e4, meta: { clientId: `sage-restart-${process.pid}` } });
6147
+ return {
6148
+ serviceId: "sage",
6149
+ action: "restart",
6150
+ success: true,
6151
+ message: "SAGE memory server restarted successfully"
6152
+ };
6153
+ } catch (error2) {
6154
+ return {
6155
+ serviceId: "sage",
6156
+ action: "restart",
6157
+ success: false,
6158
+ message: `SAGE memory server restarted but verification failed: ${error2 instanceof Error ? error2.message : String(error2)}`
6159
+ };
6160
+ } finally {
6161
+ verifyConn.close();
6162
+ }
6163
+ }
6164
+ async function killChronicleServer(projectRoot, action) {
6165
+ const options = resolveChronicleProjectServerOptions({ projectRoot });
6166
+ const client = new ChronicleProjectServerClient(options);
6167
+ try {
6168
+ const result = await client.shutdown(`WebUI request: ${action}`);
6169
+ if (!result.stopped) {
6170
+ return {
6171
+ serviceId: "chronicle",
6172
+ action,
6173
+ success: false,
6174
+ message: `Chronicle telemetry server shutdown failed: ${result.reason ?? "unknown"}`
6175
+ };
6176
+ }
6177
+ if (action === "restart") {
6178
+ return await restartChronicleServer(projectRoot);
6179
+ }
6180
+ return {
6181
+ serviceId: "chronicle",
6182
+ action,
6183
+ success: true,
6184
+ message: "Chronicle telemetry server shutdown requested"
6185
+ };
6186
+ } catch (error2) {
6187
+ return {
6188
+ serviceId: "chronicle",
6189
+ action,
6190
+ success: false,
6191
+ message: error2 instanceof Error ? error2.message : String(error2)
6192
+ };
6193
+ } finally {
6194
+ client.close();
6195
+ }
6196
+ }
6197
+ async function restartChronicleServer(projectRoot) {
6198
+ const options = resolveChronicleProjectServerOptions({ projectRoot });
6199
+ const endpoint = new ChronicleProjectServerClient(options).endpoint;
6200
+ await waitForShutdown(async () => isEndpointAlive(endpoint));
6201
+ let access2;
6202
+ try {
6203
+ access2 = createChronicleProjectAccess2({ projectRoot });
6204
+ await access2.call("ping", {}, { timeoutMs: 1e4 });
6205
+ if (access2.mode !== "server") {
6206
+ return {
6207
+ serviceId: "chronicle",
6208
+ action: "restart",
6209
+ success: false,
6210
+ message: `Chronicle telemetry server restarted but running in ${access2.mode} mode (expected server)`
6211
+ };
6212
+ }
6213
+ return {
6214
+ serviceId: "chronicle",
6215
+ action: "restart",
6216
+ success: true,
6217
+ message: "Chronicle telemetry server restarted successfully"
6218
+ };
6219
+ } catch (error2) {
6220
+ return {
6221
+ serviceId: "chronicle",
6222
+ action: "restart",
6223
+ success: false,
6224
+ message: `Chronicle telemetry server restarted but verification failed: ${error2 instanceof Error ? error2.message : String(error2)}`
6225
+ };
6226
+ } finally {
6227
+ await access2?.close();
6228
+ }
6229
+ }
6230
+ async function killCodebaseIndexServer(projectRoot, indexDir, action) {
6231
+ try {
6232
+ const result = await shutdownCodebaseIndexServer(
6233
+ projectRoot,
6234
+ indexDir,
6235
+ `websocket-request:${action}`
6236
+ );
6237
+ if (!result.stopped) {
6238
+ return {
6239
+ serviceId: "codebase-index",
6240
+ action,
6241
+ success: false,
6242
+ message: `Codebase index server shutdown failed: ${result.reason ?? "unknown"}`
6243
+ };
6244
+ }
6245
+ if (action === "restart") {
6246
+ return await restartCodebaseIndexServer(projectRoot, indexDir);
6247
+ }
6248
+ return {
6249
+ serviceId: "codebase-index",
6250
+ action,
6251
+ success: true,
6252
+ message: "Codebase index server shutdown requested"
6253
+ };
6254
+ } catch (error2) {
6255
+ return {
6256
+ serviceId: "codebase-index",
6257
+ action,
6258
+ success: false,
6259
+ message: error2 instanceof Error ? error2.message : String(error2)
6260
+ };
6261
+ }
6262
+ }
6263
+ async function restartCodebaseIndexServer(projectRoot, indexDir) {
6264
+ await waitForShutdown(async () => {
6265
+ try {
6266
+ await checkCodebaseIndexServerHealth(projectRoot, indexDir, {
6267
+ timeoutMs: 1e3
6268
+ });
6269
+ return true;
6270
+ } catch {
6271
+ return false;
6272
+ }
6273
+ });
6274
+ try {
6275
+ await ensureCodebaseIndexServer2({ projectRoot, indexDir });
6276
+ const health = await checkCodebaseIndexServerHealth(projectRoot, indexDir, {
6277
+ timeoutMs: 1e4
6278
+ });
6279
+ if (health.status === "unresponsive") {
6280
+ return {
6281
+ serviceId: "codebase-index",
6282
+ action: "restart",
6283
+ success: false,
6284
+ message: "Codebase index server restarted but is unresponsive"
6285
+ };
6286
+ }
6287
+ return {
6288
+ serviceId: "codebase-index",
6289
+ action: "restart",
6290
+ success: true,
6291
+ message: "Codebase index server restarted successfully"
6292
+ };
6293
+ } catch (error2) {
6294
+ return {
6295
+ serviceId: "codebase-index",
6296
+ action: "restart",
6297
+ success: false,
6298
+ message: `Codebase index server restarted but verification failed: ${error2 instanceof Error ? error2.message : String(error2)}`
6299
+ };
6300
+ }
6301
+ }
6302
+ async function killMailboxServer(projectRoot, action) {
6303
+ if (!isMailboxProjectServerAvailable()) {
6304
+ return {
6305
+ serviceId: "mailbox",
6306
+ action,
6307
+ success: false,
6308
+ message: "Mailbox project server is unavailable in this runtime"
6309
+ };
6310
+ }
6311
+ const connection = new MailboxProjectServerConnection(
6312
+ resolveWstackPaths2({ projectRoot }).projectDir
6313
+ );
6314
+ try {
6315
+ const result = await connection.shutdown(`WebUI request: ${action}`);
6316
+ if (!result.stopped) {
6317
+ return {
6318
+ serviceId: "mailbox",
6319
+ action,
6320
+ success: false,
6321
+ message: `Mailbox IPC server shutdown failed: ${result.reason ?? "unknown"}`
6322
+ };
6323
+ }
6324
+ if (action === "restart") {
6325
+ return await restartMailboxServer(projectRoot);
6326
+ }
6327
+ return {
6328
+ serviceId: "mailbox",
6329
+ action,
6330
+ success: true,
6331
+ message: "Mailbox IPC server shutdown requested"
6332
+ };
6333
+ } catch (error2) {
6334
+ return {
6335
+ serviceId: "mailbox",
6336
+ action,
6337
+ success: false,
6338
+ message: error2 instanceof Error ? error2.message : String(error2)
6339
+ };
6340
+ } finally {
6341
+ connection.close();
6342
+ }
6343
+ }
6344
+ async function restartMailboxServer(projectRoot) {
6345
+ await waitForShutdown(async () => {
6346
+ const probe = new MailboxProjectServerConnection(
6347
+ resolveWstackPaths2({ projectRoot }).projectDir
6348
+ );
6349
+ try {
6350
+ return await probe.probeStatus() !== null;
6351
+ } finally {
6352
+ probe.close();
6353
+ }
6354
+ });
6355
+ const verifyConn = new MailboxProjectServerConnection(
6356
+ resolveWstackPaths2({ projectRoot }).projectDir
6357
+ );
6358
+ try {
6359
+ await verifyConn.call("ping", {}, { timeoutMs: 1e4 });
5566
6360
  return {
5567
- id: "governance",
5568
- label: "Governance control plane",
5569
- status: missing ? "offline" : "error",
5570
- required: false,
5571
- mode: missing ? "compatibility-default-off" : "project-daemon",
5572
- detail: missing ? "Not active for this project. Existing agent and model execution remains unchanged." : `Read-only governance status is unavailable: ${result.message}`,
5573
- latencyMs: Date.now() - startedAt,
5574
- control: "none",
5575
- ...missing ? {} : { lastError: result.message }
6361
+ serviceId: "mailbox",
6362
+ action: "restart",
6363
+ success: true,
6364
+ message: "Mailbox IPC server restarted successfully"
5576
6365
  };
6366
+ } catch (error2) {
6367
+ return {
6368
+ serviceId: "mailbox",
6369
+ action: "restart",
6370
+ success: false,
6371
+ message: `Mailbox IPC server restarted but verification failed: ${error2 instanceof Error ? error2.message : String(error2)}`
6372
+ };
6373
+ } finally {
6374
+ verifyConn.close();
5577
6375
  }
5578
- const { status } = result;
5579
- return {
5580
- id: "governance",
5581
- label: "Governance control plane",
5582
- status: status.signal.level === "healthy" ? "healthy" : "degraded",
5583
- required: false,
5584
- mode: "project-daemon-advisory",
5585
- detail: `${status.signal.message} Execution continues; no automatic task or model stop.`,
5586
- ownerPid: status.pid,
5587
- uptimeMs: Math.max(0, Date.now() - Date.parse(status.startedAt)),
5588
- latencyMs: Date.now() - startedAt,
5589
- control: "none",
5590
- advisory: {
5591
- code: status.signal.code,
5592
- operatorAction: status.signal.operatorAction,
5593
- executionDisposition: status.signal.executionDisposition
6376
+ }
6377
+ var RESTART_POLL_INTERVAL_MS = 250;
6378
+ var RESTART_DEADLINE_MS = 3e3;
6379
+ function isEndpointAlive(endpoint) {
6380
+ return new Promise((resolve15) => {
6381
+ const sock = net.createConnection(endpoint);
6382
+ const timer = setTimeout(() => {
6383
+ sock.destroy();
6384
+ resolve15(false);
6385
+ }, 500);
6386
+ timer.unref?.();
6387
+ sock.once("connect", () => {
6388
+ clearTimeout(timer);
6389
+ sock.destroy();
6390
+ resolve15(true);
6391
+ });
6392
+ sock.once("error", () => {
6393
+ clearTimeout(timer);
6394
+ sock.destroy();
6395
+ resolve15(false);
6396
+ });
6397
+ });
6398
+ }
6399
+ async function waitForShutdown(probe) {
6400
+ if (!probe) {
6401
+ await new Promise((resolve15) => setTimeout(resolve15, RESTART_POLL_INTERVAL_MS));
6402
+ return;
6403
+ }
6404
+ const deadline = Date.now() + RESTART_DEADLINE_MS;
6405
+ while (Date.now() < deadline) {
6406
+ try {
6407
+ const stillUp = await probe();
6408
+ if (!stillUp) return;
6409
+ } catch {
6410
+ return;
5594
6411
  }
5595
- };
6412
+ await new Promise((resolve15) => setTimeout(resolve15, RESTART_POLL_INTERVAL_MS));
6413
+ }
5596
6414
  }
5597
6415
  function failureService(id, label, required, mode, error2, latencyMs) {
5598
6416
  const message = error2 instanceof Error ? error2.message : String(error2);
@@ -6302,12 +7120,12 @@ Run npx tsc --noEmit to verify the fix. Output the fixed file paths.`;
6302
7120
  ...maybeVerify,
6303
7121
  onPhaseComplete: (phase) => {
6304
7122
  this.logger.info(`[Goal] Phase completed: ${phase.name}`);
6305
- void this.store.save(graph);
7123
+ this.persistDetached(graph);
6306
7124
  this.broadcastState();
6307
7125
  },
6308
7126
  onPhaseFail: (phase, error2) => {
6309
7127
  this.logger.error(`[Goal] Phase failed: ${phase.name} \u2014 ${error2.message}`);
6310
- void this.store.save(graph);
7128
+ this.persistDetached(graph);
6311
7129
  this.broadcastState();
6312
7130
  }
6313
7131
  },
@@ -6324,7 +7142,7 @@ Run npx tsc --noEmit to verify the fix. Output the fixed file paths.`;
6324
7142
  this.broadcastState();
6325
7143
  void this.orchestrator.start().then(() => {
6326
7144
  this.orchestrator?.stop();
6327
- void this.store.save(graph);
7145
+ this.persistDetached(graph);
6328
7146
  this.stopBroadcast();
6329
7147
  const failed = graph.failedPhaseIds.length > 0;
6330
7148
  this.broadcast(
@@ -6522,9 +7340,27 @@ ${result_.finalText.slice(0, 2e3)}`
6522
7340
  this.logger.warn(`[Goal] Chimera review failed for "${task.title}": ${toErrorMessage2(err)}`);
6523
7341
  }
6524
7342
  }
7343
+ /**
7344
+ * Fire-and-forget persist.
7345
+ *
7346
+ * Every detached `store.save()` used to be a bare `void`, so a rejection
7347
+ * became an unhandled rejection and — under Node 22's default
7348
+ * `--unhandled-rejections=throw` — killed the process mid-run. On Windows an
7349
+ * AV scanner or indexer holding the `.wrongstack/phases/<id>.json` rename
7350
+ * target for a few hundred ms is enough (EPERM from `atomicWrite`), and in
7351
+ * `--webui` mode that takes the CLI session down with it. `handleStop` at
7352
+ * `:549` already had the `.catch`; these call sites did not.
7353
+ */
7354
+ persistDetached(graph) {
7355
+ void this.store.save(graph).catch((err) => {
7356
+ this.logger.warn(
7357
+ `[Goal] Failed to persist phase graph: ${err instanceof Error ? err.message : String(err)}`
7358
+ );
7359
+ });
7360
+ }
6525
7361
  /** Persist + broadcast after an interactive board mutation. */
6526
7362
  afterBoardMutation() {
6527
- if (this.graph) void this.store.save(this.graph);
7363
+ if (this.graph) this.persistDetached(this.graph);
6528
7364
  this.broadcastState();
6529
7365
  }
6530
7366
  async handleTaskStatusChange(taskId, status) {
@@ -7508,12 +8344,21 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
7508
8344
  const paths = resolveWstackPaths7({ projectRoot: entry.projectRoot, globalRoot });
7509
8345
  const store = new DefaultSessionStore3({ dir: paths.projectSessions });
7510
8346
  const reader = new DefaultSessionReader2({ store });
7511
- const rawEntries = [];
8347
+ const RING = Math.max(limit * 4, 2e3);
8348
+ const ring = [];
8349
+ let totalRaw = 0;
8350
+ let dropped = false;
7512
8351
  for await (const ev of reader.replay(sessionId)) {
7513
8352
  const mapped = mapWatchEntry(ev);
7514
- if (mapped) rawEntries.push(mapped);
8353
+ if (!mapped) continue;
8354
+ totalRaw += 1;
8355
+ ring.push(mapped);
8356
+ if (ring.length > RING) {
8357
+ ring.shift();
8358
+ dropped = true;
8359
+ }
7515
8360
  }
7516
- const all = correlateToolEvents(rawEntries);
8361
+ const all = correlateToolEvents(ring);
7517
8362
  const tail2 = all.slice(-limit);
7518
8363
  res.writeHead(200, { "Content-Type": "application/json" });
7519
8364
  res.end(
@@ -7522,7 +8367,12 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
7522
8367
  status: entry.status,
7523
8368
  clientType: entry.clientType,
7524
8369
  projectName: entry.projectName,
7525
- total: all.length,
8370
+ // Exact when the whole session fit in the ring (the previous
8371
+ // behaviour). Past that, correlation never ran over the dropped
8372
+ // prefix, so report the raw event count — an upper bound — and say so
8373
+ // rather than silently understating the session's size.
8374
+ total: dropped ? totalRaw : all.length,
8375
+ ...dropped ? { truncated: true } : {},
7526
8376
  entries: tail2
7527
8377
  })
7528
8378
  );
@@ -8212,7 +9062,7 @@ async function touchProjectInManifest(options, globalConfigPath) {
8212
9062
  }
8213
9063
 
8214
9064
  // src/server/techstack-handlers.ts
8215
- import { randomUUID as randomUUID2 } from "node:crypto";
9065
+ import { randomUUID as randomUUID3 } from "node:crypto";
8216
9066
  var DEEP_DIVE_TIMEOUT_MS = 6e4;
8217
9067
  function sendJson3(res, status, data) {
8218
9068
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -8253,7 +9103,7 @@ function requireJobDeps(res, deps2) {
8253
9103
  }
8254
9104
  function startJob(res, deps2, kind) {
8255
9105
  if (!requireJobDeps(res, deps2)) return;
8256
- const jobId = randomUUID2();
9106
+ const jobId = randomUUID3();
8257
9107
  const controller = new AbortController();
8258
9108
  deps2.runningJobs?.set(jobId, controller);
8259
9109
  deps2.emit?.({ type: "techstack.job.started", payload: { jobId, kind } });
@@ -8485,19 +9335,22 @@ function extractToken(url) {
8485
9335
  function extractTokenFromCookie(cookieHeader) {
8486
9336
  if (!cookieHeader) return void 0;
8487
9337
  const raw = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader;
9338
+ let plain;
8488
9339
  for (const part of raw.split(";")) {
8489
9340
  const eq = part.indexOf("=");
8490
9341
  if (eq < 0) continue;
8491
9342
  const name2 = part.slice(0, eq).trim();
8492
- if (name2 === "ws_token") {
8493
- try {
8494
- return decodeURIComponent(part.slice(eq + 1).trim());
8495
- } catch {
8496
- return part.slice(eq + 1).trim();
8497
- }
9343
+ if (name2 !== "ws_token" && name2 !== "__Host-ws_token") continue;
9344
+ let value;
9345
+ try {
9346
+ value = decodeURIComponent(part.slice(eq + 1).trim());
9347
+ } catch {
9348
+ value = part.slice(eq + 1).trim();
8498
9349
  }
9350
+ if (name2 === "__Host-ws_token") return value;
9351
+ plain ??= value;
8499
9352
  }
8500
- return void 0;
9353
+ return plain;
8501
9354
  }
8502
9355
  function hostHeaderOk(input) {
8503
9356
  if (!isLoopbackBind(input.wsHost)) return true;
@@ -8539,7 +9392,8 @@ function verifyClient(input) {
8539
9392
  expectedToken,
8540
9393
  requireToken,
8541
9394
  allowedHostnames,
8542
- allowBrowserUrlToken
9395
+ allowBrowserUrlToken,
9396
+ allowCrossPortLoopbackCookie
8543
9397
  } = input;
8544
9398
  const urlTokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
8545
9399
  const cookieTokenOk = tokenMatches(extractTokenFromCookie(cookieHeader), expectedToken);
@@ -8554,7 +9408,10 @@ function verifyClient(input) {
8554
9408
  const { hostname: originHostname } = new URL(origin);
8555
9409
  if (isLoopbackHostname(originHostname)) {
8556
9410
  if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
8557
- return cookieTokenOk || isTrustedLoopbackOrigin(origin, hostHeader);
9411
+ if (!isTrustedLoopbackOrigin(origin, hostHeader)) {
9412
+ return Boolean(allowCrossPortLoopbackCookie) && cookieTokenOk;
9413
+ }
9414
+ return true;
8558
9415
  }
8559
9416
  return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
8560
9417
  } catch {
@@ -8589,11 +9446,22 @@ ${out}`;
8589
9446
  function firstHeader(value) {
8590
9447
  return Array.isArray(value) ? value[0] : value;
8591
9448
  }
8592
- function wsTokenCookie(token) {
8593
- return `ws_token=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
9449
+ var WS_TOKEN_COOKIE = "ws_token";
9450
+ var WS_TOKEN_COOKIE_SECURE = "__Host-ws_token";
9451
+ function wsTokenCookie(token, secure) {
9452
+ const name2 = secure ? WS_TOKEN_COOKIE_SECURE : WS_TOKEN_COOKIE;
9453
+ const parts = [
9454
+ `${name2}=${encodeURIComponent(token)}`,
9455
+ "HttpOnly",
9456
+ "SameSite=Strict",
9457
+ "Path=/",
9458
+ "Max-Age=3600"
9459
+ ];
9460
+ if (secure) parts.push("Secure");
9461
+ return parts.join("; ");
8594
9462
  }
8595
- function setAuthCookieHeaders(res, token) {
8596
- res.setHeader("Set-Cookie", wsTokenCookie(token));
9463
+ function setAuthCookieHeaders(res, token, secure) {
9464
+ res.setHeader("Set-Cookie", wsTokenCookie(token, secure));
8597
9465
  res.setHeader("Cache-Control", "no-store");
8598
9466
  }
8599
9467
  function setStaticSecurityHeaders(res) {
@@ -8601,8 +9469,16 @@ function setStaticSecurityHeaders(res) {
8601
9469
  res.setHeader("X-Frame-Options", "DENY");
8602
9470
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
8603
9471
  }
8604
- function requestToken(req, url) {
8605
- return url.searchParams.get("token") ?? firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
9472
+ function requestToken(req, url, opts = {}) {
9473
+ const queryToken = url.searchParams.get("token") ?? void 0;
9474
+ if (queryToken !== void 0 && (opts.allowQuery === true || isLoopbackPeer(req))) {
9475
+ return queryToken;
9476
+ }
9477
+ return firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
9478
+ }
9479
+ function isLoopbackPeer(req) {
9480
+ const address = req.socket.remoteAddress?.replace(/^::ffff:/i, "");
9481
+ return address !== void 0 && isLoopbackHostname(address);
8606
9482
  }
8607
9483
  function formatCspHostname(hostname) {
8608
9484
  return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
@@ -8657,7 +9533,8 @@ function strictDecodeParam(segment, res) {
8657
9533
  function createHttpServer(opts) {
8658
9534
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
8659
9535
  const distDir = path13.resolve(opts.distDir);
8660
- const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
9536
+ const requireAccessToken = true;
9537
+ const secureCookies = opts.secureCookies ?? (opts.publicWsUrl?.trim().toLowerCase().startsWith("wss:") ?? false);
8661
9538
  const trustedHostnames = (() => {
8662
9539
  const names = [...opts.allowedHostnames ?? []];
8663
9540
  if (opts.publicWsUrl) {
@@ -8692,17 +9569,17 @@ function createHttpServer(opts) {
8692
9569
  res.end(JSON.stringify({ error: "forbidden: untrusted request origin" }));
8693
9570
  return;
8694
9571
  }
8695
- const providedAccessToken = requestToken(req, url);
9572
+ const providedAccessToken = requestToken(req, url, { allowQuery: true });
8696
9573
  const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
8697
9574
  const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
8698
9575
  if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
8699
- const provided = requestToken(req, url);
9576
+ const provided = requestToken(req, url, { allowQuery: true });
8700
9577
  if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
8701
9578
  res.writeHead(401, { "Content-Type": "text/plain" });
8702
9579
  res.end("Unauthorized");
8703
9580
  return;
8704
9581
  }
8705
- setAuthCookieHeaders(res, opts.apiToken);
9582
+ setAuthCookieHeaders(res, opts.apiToken, secureCookies);
8706
9583
  res.writeHead(200, { "Content-Type": "text/plain" });
8707
9584
  res.end("ok");
8708
9585
  return;
@@ -8716,7 +9593,7 @@ function createHttpServer(opts) {
8716
9593
  return;
8717
9594
  }
8718
9595
  if (shouldSetAuthCookie && opts.apiToken) {
8719
- setAuthCookieHeaders(res, opts.apiToken);
9596
+ setAuthCookieHeaders(res, opts.apiToken, secureCookies);
8720
9597
  }
8721
9598
  if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
8722
9599
  if (requireAccessToken && !accessTokenOk) {
@@ -9546,6 +10423,27 @@ import {
9546
10423
  getServerKanbanStore
9547
10424
  } from "@wrongstack/kanban";
9548
10425
  import { recordKanbanVerificationEvidence } from "@wrongstack/tools";
10426
+
10427
+ // src/server/kanban-broadcast.ts
10428
+ function kanbanBoardMessage(board) {
10429
+ return { type: "kanban.get", payload: { success: true, data: { board } } };
10430
+ }
10431
+ function kanbanListMessage(boards) {
10432
+ return { type: "kanban.list", payload: { success: true, data: boards } };
10433
+ }
10434
+ function kanbanDeletedMessage(boardId) {
10435
+ return { type: "kanban.delete", payload: { success: true, data: { removed: true, boardId } } };
10436
+ }
10437
+ async function publishKanbanBoard(broadcast2, board, listBoards4) {
10438
+ broadcast2(kanbanBoardMessage(board));
10439
+ if (!listBoards4) return;
10440
+ try {
10441
+ broadcast2(kanbanListMessage(await listBoards4()));
10442
+ } catch {
10443
+ }
10444
+ }
10445
+
10446
+ // src/server/kanban-dispatch.ts
9549
10447
  function reply(ws, type, success, value) {
9550
10448
  send(ws, {
9551
10449
  type,
@@ -9662,10 +10560,7 @@ async function handleKanbanTaskDispatch(ws, payload, ctx) {
9662
10560
  payload: { success: true, data: { boardId: board.id, task: completedTask } }
9663
10561
  });
9664
10562
  if (completedBoard) {
9665
- ctx.broadcast?.({
9666
- type: "kanban.get",
9667
- payload: { success: true, data: { board: completedBoard } }
9668
- });
10563
+ ctx.broadcast?.(kanbanBoardMessage(completedBoard));
9669
10564
  }
9670
10565
  ctx.broadcast?.({
9671
10566
  type: "kanban.list",
@@ -9689,7 +10584,7 @@ async function handleKanbanTaskDispatch(ws, payload, ctx) {
9689
10584
  payload: { success: true, data: { boardId: board.id, task: runningTask } }
9690
10585
  });
9691
10586
  if (started?.board) {
9692
- ctx.broadcast?.({ type: "kanban.get", payload: { success: true, data: { board: started.board } } });
10587
+ ctx.broadcast?.(kanbanBoardMessage(started.board));
9693
10588
  }
9694
10589
  reply(ws, "kanban.task.dispatch", true, { boardId: board.id, task: runningTask, summary });
9695
10590
  } catch (error2) {
@@ -9932,14 +10827,11 @@ async function handleDecompositionResolution(ws, type, payload, ctx) {
9932
10827
  type: "kanban.decomposition.applied",
9933
10828
  payload: { success: true, data: { board: resolved.board } }
9934
10829
  });
9935
- ctx.broadcast?.({
9936
- type: "kanban.get",
9937
- payload: { success: true, data: { board: resolved.board } }
9938
- });
9939
- ctx.broadcast?.({
9940
- type: "kanban.list",
9941
- payload: { success: true, data: await listBoards(ctx.projectRoot) }
9942
- });
10830
+ await publishKanbanBoard(
10831
+ (message) => ctx.broadcast?.(message),
10832
+ resolved.board,
10833
+ () => listBoards(ctx.projectRoot)
10834
+ );
9943
10835
  } else {
9944
10836
  ctx.broadcast?.({
9945
10837
  type: "kanban.decomposition.resolved",
@@ -9972,10 +10864,7 @@ async function handleTaskVerification(ws, type, payload, ctx) {
9972
10864
  payload: { success: true, data: { boardId, task: freshTask } }
9973
10865
  });
9974
10866
  if (persisted) {
9975
- ctx.broadcast?.({
9976
- type: "kanban.get",
9977
- payload: { success: true, data: { board: persisted } }
9978
- });
10867
+ ctx.broadcast?.(kanbanBoardMessage(persisted));
9979
10868
  }
9980
10869
  } catch (err) {
9981
10870
  ctx.broadcast?.({
@@ -10851,10 +11740,7 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
10851
11740
  let connectionCount = 0;
10852
11741
  const broadcastDeleted = (boardId) => {
10853
11742
  knownRevisions.delete(boardId);
10854
- broadcastMessage({
10855
- type: "kanban.delete",
10856
- payload: { success: true, data: { removed: true, boardId } }
10857
- });
11743
+ broadcastMessage(kanbanDeletedMessage(boardId));
10858
11744
  };
10859
11745
  const broadcastBoard = async (boardId) => {
10860
11746
  const board = await store.getBoard(boardId);
@@ -10863,10 +11749,7 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
10863
11749
  return;
10864
11750
  }
10865
11751
  knownRevisions.set(boardId, board.updatedAt);
10866
- broadcastMessage({
10867
- type: "kanban.get",
10868
- payload: { success: true, data: { board } }
10869
- });
11752
+ broadcastMessage(kanbanBoardMessage(board));
10870
11753
  };
10871
11754
  const reconcileAfterConnect = async () => {
10872
11755
  const summaries = await store.listBoards();
@@ -10885,20 +11768,36 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
10885
11768
  }
10886
11769
  }
10887
11770
  };
10888
- return bridgeKanbanSupervisor(
11771
+ const COALESCE_MS = 300;
11772
+ const pendingBroadcasts = /* @__PURE__ */ new Map();
11773
+ const scheduleBroadcast = (boardId) => {
11774
+ if (pendingBroadcasts.has(boardId)) return;
11775
+ const timer = setTimeout(() => {
11776
+ pendingBroadcasts.delete(boardId);
11777
+ void broadcastBoard(boardId).catch(() => {
11778
+ });
11779
+ }, COALESCE_MS);
11780
+ timer.unref?.();
11781
+ pendingBroadcasts.set(boardId, timer);
11782
+ };
11783
+ const unsubscribe = bridgeKanbanSupervisor(
10889
11784
  projectRoot,
10890
11785
  async (event) => {
11786
+ const family = event.event?.split(".")[0];
11787
+ if (family !== "board" && family !== "task" && family !== "column") return;
10891
11788
  const evData = event.data;
10892
11789
  const boardId = evData?.boardId;
10893
11790
  if (!boardId) return;
10894
- try {
10895
- if (event.event === "board.deleted") {
10896
- broadcastDeleted(boardId);
10897
- return;
11791
+ if (event.event === "board.deleted") {
11792
+ const timer = pendingBroadcasts.get(boardId);
11793
+ if (timer) {
11794
+ clearTimeout(timer);
11795
+ pendingBroadcasts.delete(boardId);
10898
11796
  }
10899
- await broadcastBoard(boardId);
10900
- } catch {
11797
+ broadcastDeleted(boardId);
11798
+ return;
10901
11799
  }
11800
+ scheduleBroadcast(boardId);
10902
11801
  },
10903
11802
  {
10904
11803
  autoReconnect: true,
@@ -10906,6 +11805,11 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
10906
11805
  onConnected: reconcileAfterConnect
10907
11806
  }
10908
11807
  );
11808
+ return () => {
11809
+ for (const timer of pendingBroadcasts.values()) clearTimeout(timer);
11810
+ pendingBroadcasts.clear();
11811
+ unsubscribe();
11812
+ };
10909
11813
  }
10910
11814
 
10911
11815
  // src/server/lifecycle.ts
@@ -10922,7 +11826,13 @@ function createShutdown(res) {
10922
11826
  } catch (e) {
10923
11827
  log(`[WebUI] Error closing session: ${e instanceof Error ? e.message : String(e)}`);
10924
11828
  }
10925
- for (const ws of res.clients()) ws.close();
11829
+ for (const ws of res.clients()) {
11830
+ try {
11831
+ ws.close();
11832
+ ws.terminate?.();
11833
+ } catch {
11834
+ }
11835
+ }
10926
11836
  for (const server of res.servers) server?.close();
10927
11837
  if (res.onShutdown) {
10928
11838
  try {
@@ -11359,6 +12269,22 @@ import {
11359
12269
  restartMcp,
11360
12270
  updateMcp
11361
12271
  } from "@wrongstack/mcp";
12272
+ async function authorizeMcpMutation(ws, operation, serverName, trustBoundary) {
12273
+ if (!trustBoundary) return true;
12274
+ const authorization = await authorizeWebUIAction(trustBoundary, {
12275
+ capability: "mcp.server.configure",
12276
+ subject: { kind: "process", id: serverName },
12277
+ risk: "elevated",
12278
+ metadata: { transport: "websocket", operation }
12279
+ });
12280
+ if (!authorization.allowed) {
12281
+ send(ws, {
12282
+ type: "mcp.operation_result",
12283
+ payload: { success: false, message: `${operation} denied: ${authorization.reason}` }
12284
+ });
12285
+ }
12286
+ return authorization.allowed;
12287
+ }
11362
12288
  function mapStatus(raw) {
11363
12289
  switch (raw) {
11364
12290
  case "connected":
@@ -11433,7 +12359,7 @@ async function handleMcpList(ws, _msg, globalConfigPath, mcpRegistry) {
11433
12359
  payload: { servers: servers.map((server) => toView(server, health.get(server.name))) }
11434
12360
  });
11435
12361
  }
11436
- async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
12362
+ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry, trustBoundary) {
11437
12363
  const d = deps(ws, globalConfigPath, mcpRegistry);
11438
12364
  if (!d) return;
11439
12365
  const validated = validateMcpServerPayload(msg.payload, "mcp.add");
@@ -11444,6 +12370,7 @@ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
11444
12370
  });
11445
12371
  return;
11446
12372
  }
12373
+ if (!await authorizeMcpMutation(ws, "mcp.add", name(msg), trustBoundary)) return;
11447
12374
  const result = await addMcp(validated.value, d);
11448
12375
  if (result.ok && result.server) {
11449
12376
  send(ws, { type: "mcp.server.added", payload: { server: toView(result.server) } });
@@ -11461,7 +12388,7 @@ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
11461
12388
  payload: { success: result.ok, message: result.message }
11462
12389
  });
11463
12390
  }
11464
- async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
12391
+ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry, trustBoundary) {
11465
12392
  const d = deps(ws, globalConfigPath, mcpRegistry);
11466
12393
  if (!d) return;
11467
12394
  const validated = validateMcpServerPayload(msg.payload, "mcp.update");
@@ -11472,6 +12399,7 @@ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
11472
12399
  });
11473
12400
  return;
11474
12401
  }
12402
+ if (!await authorizeMcpMutation(ws, "mcp.update", name(msg), trustBoundary)) return;
11475
12403
  const result = await updateMcp(validated.value, d);
11476
12404
  if (result.ok && result.server) {
11477
12405
  send(ws, { type: "mcp.server.updated", payload: { server: toView(result.server) } });
@@ -12821,6 +13749,8 @@ async function handleBrainAsk(ctx, ws, question) {
12821
13749
 
12822
13750
  // src/server/context-meta.ts
12823
13751
  import { FallbackProfileManager } from "@wrongstack/core/agent";
13752
+ import { resolvePluginEnablement } from "@wrongstack/core/plugin";
13753
+ import { FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS2 } from "@wrongstack/core/utils";
12824
13754
  function seedContextMeta(config, context) {
12825
13755
  const meta = context.meta;
12826
13756
  const autonomyCfg = config.autonomy ?? {};
@@ -12836,6 +13766,7 @@ function seedContextMeta(config, context) {
12836
13766
  meta["enhanceDelayMs"] = autonomyCfg["enhanceDelayMs"] ?? 6e4;
12837
13767
  meta["enhanceLanguage"] = autonomyCfg["enhanceLanguage"] ?? "original";
12838
13768
  meta["nextPrediction"] = config.nextPrediction ?? false;
13769
+ meta["nextStepsTool"] = config.tools?.nextsteps?.enabled === true;
12839
13770
  meta["fallbackModels"] = config.fallbackModels ?? [];
12840
13771
  meta["fallbackBridge"] = config.fallbackBridge ?? "";
12841
13772
  meta["fallbackProfiles"] = config.fallbackProfiles ?? {};
@@ -12897,6 +13828,21 @@ function seedContextMeta(config, context) {
12897
13828
  meta["tgDelegate"] = tgExt?.["notifyOnDelegate"] !== false;
12898
13829
  const tgMs = tgExt?.["longToolThresholdMs"];
12899
13830
  meta["tgLongToolMs"] = typeof tgMs === "number" ? tgMs : 3e4;
13831
+ {
13832
+ const pluginsEnabled = {};
13833
+ const record = (name2) => {
13834
+ if (FORBIDDEN_PROTO_KEYS2.has(name2) || name2 in pluginsEnabled) return;
13835
+ pluginsEnabled[name2] = resolvePluginEnablement({ name: name2, config }).enabled;
13836
+ };
13837
+ for (const entry of config.plugins ?? []) {
13838
+ const name2 = typeof entry === "string" ? entry : entry?.name;
13839
+ if (typeof name2 === "string") record(name2);
13840
+ }
13841
+ for (const [name2, options] of Object.entries(config.extensions ?? {})) {
13842
+ if (typeof options?.["enabled"] === "boolean") record(name2);
13843
+ }
13844
+ if (Object.keys(pluginsEnabled).length > 0) meta["pluginsEnabled"] = pluginsEnabled;
13845
+ }
12900
13846
  const chimeraExt = config.extensions?.["wstack-chimera"];
12901
13847
  meta["chimeraEnabled"] = chimeraExt?.["enabled"] === true;
12902
13848
  meta["chimeraProvider"] = chimeraExt?.["provider"] ?? "";
@@ -12909,6 +13855,7 @@ function seedContextMeta(config, context) {
12909
13855
  meta["autoReviewProvider"] = autoReviewExt?.["provider"] ?? "";
12910
13856
  meta["autoReviewModel"] = autoReviewExt?.["model"] ?? "";
12911
13857
  meta["autoReviewFallbackProfile"] = autoReviewExt?.["fallbackProfile"] ?? "";
13858
+ meta["autoReviewModelSelection"] = autoReviewExt?.["modelSelection"] === "random" ? "random" : "round-robin";
12912
13859
  meta["autoReviewFallbackModels"] = Array.isArray(autoReviewExt?.["fallbackModels"]) ? autoReviewExt?.["fallbackModels"] : [];
12913
13860
  meta["autoReviewDebounceMs"] = typeof autoReviewExt?.["debounceMs"] === "number" && autoReviewExt["debounceMs"] >= 0 ? autoReviewExt["debounceMs"] : 15e3;
12914
13861
  meta["autoReviewMaxFilesPerBatch"] = typeof autoReviewExt?.["maxFilesPerBatch"] === "number" && autoReviewExt["maxFilesPerBatch"] >= 1 ? autoReviewExt["maxFilesPerBatch"] : 15;
@@ -12934,8 +13881,9 @@ function seedContextMeta(config, context) {
12934
13881
  // src/server/pref-helpers.ts
12935
13882
  import * as fs13 from "node:fs/promises";
12936
13883
  import * as path15 from "node:path";
13884
+ import { pluginEntryMatchesName } from "@wrongstack/core/plugin";
12937
13885
  import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets } from "@wrongstack/core/security";
12938
- import { atomicWrite as atomicWrite6, backupConfigFile, FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS2 } from "@wrongstack/core/utils";
13886
+ import { atomicWrite as atomicWrite6, backupConfigFile, FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS3 } from "@wrongstack/core/utils";
12939
13887
  var PREF_KEYS = [
12940
13888
  "autonomy",
12941
13889
  "autonomyDelayMs",
@@ -12945,6 +13893,7 @@ var PREF_KEYS = [
12945
13893
  "chime",
12946
13894
  "confirmExit",
12947
13895
  "nextPrediction",
13896
+ "nextStepsTool",
12948
13897
  "enhanceEnabled",
12949
13898
  "enhanceDelayMs",
12950
13899
  "enhanceLanguage",
@@ -13008,6 +13957,7 @@ var PREF_KEYS = [
13008
13957
  "autoReviewProvider",
13009
13958
  "autoReviewModel",
13010
13959
  "autoReviewFallbackProfile",
13960
+ "autoReviewModelSelection",
13011
13961
  "autoReviewFallbackModels",
13012
13962
  "autoReviewDebounceMs",
13013
13963
  "autoReviewMaxFilesPerBatch",
@@ -13016,6 +13966,8 @@ var PREF_KEYS = [
13016
13966
  // Display-only toggles (purely visual WebUI prefs, not persisted to config).
13017
13967
  "groupToolCalls",
13018
13968
  "showThinkingLogs",
13969
+ // v15: chat-input auto-collapse (opt-in display toggle, default off).
13970
+ "autoCollapseInput",
13019
13971
  // Per-plugin enable/disable map (parity with the embedded server).
13020
13972
  "pluginsEnabled",
13021
13973
  // Fleet chat verbosity: off | full (migrated from streamFleet boolean).
@@ -13070,6 +14022,8 @@ async function updateGlobalConfig(deps2, holder, mutate, errorLabel) {
13070
14022
  var DISPLAY_ONLY_KEYS = /* @__PURE__ */ new Set([
13071
14023
  "groupToolCalls",
13072
14024
  "showThinkingLogs",
14025
+ // v15: chat-input auto-collapse (opt-in display toggle, default off).
14026
+ "autoCollapseInput",
13073
14027
  "autoReviewFallbackModels",
13074
14028
  // v11 Display parity: agent-swarm panel + inverse fsAccess flag.
13075
14029
  // The TUI settings picker mirrors these so the browser can keep the
@@ -13217,6 +14171,11 @@ async function persistPrefsToConfig(deps2, holder, payload) {
13217
14171
  toolsCfg.maxIterations = payload["maxIterations"];
13218
14172
  decrypted.tools = toolsCfg;
13219
14173
  }
14174
+ if (typeof payload["nextStepsTool"] === "boolean") {
14175
+ const toolsCfg = decrypted.tools ?? {};
14176
+ toolsCfg.nextsteps = { enabled: payload["nextStepsTool"] };
14177
+ decrypted.tools = toolsCfg;
14178
+ }
13220
14179
  const hqTouched = typeof payload["hqEnabled"] === "boolean" || typeof payload["hqUrl"] === "string" || typeof payload["hqToken"] === "string" || typeof payload["hqRawContent"] === "boolean";
13221
14180
  if (hqTouched) {
13222
14181
  const hqCfg = decrypted.hq ?? {};
@@ -13280,15 +14239,29 @@ async function persistPrefsToConfig(deps2, holder, payload) {
13280
14239
  decrypted.debugStream = payload["debugStream"];
13281
14240
  if (typeof payload["pluginsEnabled"] === "object" && payload["pluginsEnabled"] !== null) {
13282
14241
  const ext = decrypted.extensions ?? {};
14242
+ const toggled = [];
13283
14243
  for (const [pluginName, enabled] of Object.entries(
13284
14244
  payload["pluginsEnabled"]
13285
14245
  )) {
13286
- if (FORBIDDEN_PROTO_KEYS2.has(pluginName)) continue;
14246
+ if (FORBIDDEN_PROTO_KEYS3.has(pluginName)) continue;
14247
+ if (typeof enabled !== "boolean") continue;
13287
14248
  const pExt = ext[pluginName] ?? {};
13288
14249
  pExt["enabled"] = enabled;
13289
14250
  ext[pluginName] = pExt;
14251
+ toggled.push([pluginName, enabled]);
13290
14252
  }
13291
14253
  decrypted.extensions = ext;
14254
+ if (Array.isArray(decrypted.plugins) && toggled.length > 0) {
14255
+ decrypted.plugins = decrypted.plugins.map((entry) => {
14256
+ const entryName = typeof entry === "string" ? entry : entry?.name;
14257
+ if (typeof entryName !== "string") return entry;
14258
+ const hit = toggled.find(([name2]) => pluginEntryMatchesName(entryName, name2));
14259
+ if (!hit) return entry;
14260
+ const [, enabled] = hit;
14261
+ if (typeof entry === "string") return enabled ? entry : { name: entry, enabled: false };
14262
+ return { ...entry, enabled };
14263
+ });
14264
+ }
13292
14265
  }
13293
14266
  const chimeraTouched = typeof payload["chimeraEnabled"] === "boolean" || typeof payload["chimeraProvider"] === "string" || typeof payload["chimeraModel"] === "string" || typeof payload["chimeraMaxFiles"] === "number" || typeof payload["chimeraAutoFix"] === "string";
13294
14267
  if (chimeraTouched) {
@@ -13310,7 +14283,7 @@ async function persistPrefsToConfig(deps2, holder, payload) {
13310
14283
  ext["wstack-chimera"] = chimera;
13311
14284
  decrypted.extensions = ext;
13312
14285
  }
13313
- 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";
14286
+ 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";
13314
14287
  if (autoReviewTouched) {
13315
14288
  const ext = decrypted.extensions ?? {};
13316
14289
  const ar = ext["wstack-auto-review"] ?? {};
@@ -13327,6 +14300,9 @@ async function persistPrefsToConfig(deps2, holder, payload) {
13327
14300
  ar["fallbackProfile"] = payload["autoReviewFallbackProfile"];
13328
14301
  }
13329
14302
  }
14303
+ if (payload["autoReviewModelSelection"] === "round-robin" || payload["autoReviewModelSelection"] === "random") {
14304
+ ar["modelSelection"] = payload["autoReviewModelSelection"];
14305
+ }
13330
14306
  if (typeof payload["autoReviewDebounceMs"] === "number" && payload["autoReviewDebounceMs"] >= 0) {
13331
14307
  ar["debounceMs"] = payload["autoReviewDebounceMs"];
13332
14308
  }
@@ -13397,39 +14373,6 @@ async function handlePrefsRoute(ws, msg, handlers) {
13397
14373
  // src/server/process-handlers.ts
13398
14374
  import { createCompatibilityTrustBoundary } from "@wrongstack/core/security";
13399
14375
  import { getProcessRegistry as getProcessRegistry2 } from "@wrongstack/tools";
13400
-
13401
- // src/server/privileged-actions.ts
13402
- import { randomUUID as randomUUID3 } from "node:crypto";
13403
- import {
13404
- isTrustDecisionAllowed
13405
- } from "@wrongstack/core/security";
13406
- async function authorizeWebUIAction(boundary, action, logger) {
13407
- const request = {
13408
- version: 1,
13409
- requestId: randomUUID3(),
13410
- actor: {
13411
- kind: "remote-client",
13412
- ...action.sessionId ? { sessionId: action.sessionId } : {}
13413
- },
13414
- surface: "webui",
13415
- capability: action.capability,
13416
- subject: action.subject,
13417
- risk: action.risk,
13418
- scope: {
13419
- ...action.cwd ? { cwd: action.cwd } : {},
13420
- ...action.sessionId ? { sessionId: action.sessionId } : {}
13421
- },
13422
- authContext: { method: "session" },
13423
- ...action.metadata ? { metadata: action.metadata } : {}
13424
- };
13425
- const decision = await boundary.evaluate(request);
13426
- logger?.debug?.(
13427
- `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
13428
- );
13429
- return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
13430
- }
13431
-
13432
- // src/server/process-handlers.ts
13433
14376
  function handleProcessList(ws) {
13434
14377
  try {
13435
14378
  const procs = getProcessRegistry2().list();
@@ -13720,6 +14663,7 @@ function createProjectHandlers(ctx) {
13720
14663
  ctx.context.session = next;
13721
14664
  ctx.context.state.replaceMessages([]);
13722
14665
  ctx.context.state.replaceTodos([]);
14666
+ ctx.context.clearMemoryEvidence?.();
13723
14667
  ctx.context.readFiles.clear();
13724
14668
  ctx.context.fileMtimes.clear();
13725
14669
  ctx.tokenCounter.reset();
@@ -13764,7 +14708,7 @@ function createProjectHandlers(ctx) {
13764
14708
  }
13765
14709
 
13766
14710
  // src/server/provider-handlers.ts
13767
- import { resolveProviderModelList } from "@wrongstack/core/models";
14711
+ import { hasProviderCredential, resolveProviderModelList } from "@wrongstack/core/models";
13768
14712
  import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/core/security";
13769
14713
  import { toErrorMessage as toErrorMessage6 } from "@wrongstack/core/utils";
13770
14714
  import {
@@ -14071,7 +15015,7 @@ function createProviderOperations(deps2) {
14071
15015
  }
14072
15016
  try {
14073
15017
  const providers = await deps2.modelsRegistry.listProviders();
14074
- const savedIds = new Set(Object.keys(await loadConfigProviders()));
15018
+ const savedProviders = await loadConfigProviders();
14075
15019
  sendMessage(ws, {
14076
15020
  type: "provider.catalog",
14077
15021
  payload: {
@@ -14082,7 +15026,7 @@ function createProviderOperations(deps2) {
14082
15026
  apiBase: provider.apiBase,
14083
15027
  envVars: provider.envVars,
14084
15028
  modelCount: provider.models.length,
14085
- hasApiKey: savedIds.has(provider.id) || provider.envVars.some((name2) => !!process.env[name2])
15029
+ hasApiKey: hasProviderCredential(provider, { providers: savedProviders })
14086
15030
  }))
14087
15031
  }
14088
15032
  });
@@ -14245,8 +15189,10 @@ function createProviderOperations(deps2) {
14245
15189
  if (result.ok) {
14246
15190
  deps2.log?.(`[WebUI] Provider "${payload.id}" added via provider.add`);
14247
15191
  }
15192
+ return result.ok;
14248
15193
  } catch (err) {
14249
15194
  sendOperationResult(ws, false, errMessage(err));
15195
+ return false;
14250
15196
  }
14251
15197
  }
14252
15198
  async function handleProviderRemove(ws, providerId) {
@@ -14564,9 +15510,11 @@ var CLIENT_CONVERSATION_MESSAGE_TYPES = [
14564
15510
  "ping",
14565
15511
  "user_message",
14566
15512
  "tool.confirm_result",
15513
+ "topic.advice",
14567
15514
  "completion.request",
14568
15515
  "model.switch",
14569
15516
  "model.refine",
15517
+ "model.fallback_choice",
14570
15518
  "autonomy.switch",
14571
15519
  "context.clear",
14572
15520
  "context.compact",
@@ -14838,6 +15786,7 @@ var SERVER_CONVERSATION_MESSAGE_TYPES = [
14838
15786
  "provider.active_blocked",
14839
15787
  "provider.error",
14840
15788
  "provider.fallback",
15789
+ "provider.fallback_pending",
14841
15790
  "provider.response",
14842
15791
  "provider.retry",
14843
15792
  "provider.status_changed",
@@ -14864,6 +15813,7 @@ var SERVER_CONVERSATION_MESSAGE_TYPES = [
14864
15813
  "tool.loop_detected",
14865
15814
  "tool.progress",
14866
15815
  "tool.started",
15816
+ "topic.advice_result",
14867
15817
  "tools.list",
14868
15818
  "trust.persisted"
14869
15819
  ];
@@ -14879,6 +15829,7 @@ var SERVER_COLLABORATION_MESSAGE_TYPES = [
14879
15829
  "collab.state",
14880
15830
  "mailbox.action_result",
14881
15831
  "mailbox.agent_registered",
15832
+ "mailbox.agent_deregistered",
14882
15833
  "mailbox.agents",
14883
15834
  "mailbox.cleared",
14884
15835
  "mailbox.compacted",
@@ -15194,6 +16145,8 @@ var SURFACE_PROTOCOL_CAPABILITIES = [
15194
16145
  "chronicle.metrics",
15195
16146
  "chronicle.status",
15196
16147
  "connections.health",
16148
+ /** Bounded topic-shift advice plus same-session provider-context boundaries. */
16149
+ "context.topic-boundary",
15197
16150
  /** Interview resume/discard + lastAgentText/lastRunId continuity. */
15198
16151
  "sdd.interview.continuity",
15199
16152
  /** Launch multi-agent runs from a graph id or resolved spec id. */
@@ -15585,6 +16538,7 @@ function createSessionHandlers(ctx) {
15585
16538
  await ctx.onBeforeSessionTodosReplaced?.(next.id, sessionsDirectory());
15586
16539
  ctx.context.state.replaceTodos(todos);
15587
16540
  resetContextAccounting();
16541
+ ctx.context.clearMemoryEvidence?.();
15588
16542
  ctx.context.readFiles.clear();
15589
16543
  ctx.context.fileMtimes.clear();
15590
16544
  ctx.context.state.setMeta?.(
@@ -15632,6 +16586,7 @@ function createSessionHandlers(ctx) {
15632
16586
  ctx.context.state.replaceMessages([]);
15633
16587
  ctx.context.state.replaceTodos([]);
15634
16588
  resetContextAccounting();
16589
+ ctx.context.clearMemoryEvidence?.();
15635
16590
  ctx.context.readFiles.clear();
15636
16591
  ctx.context.fileMtimes.clear();
15637
16592
  ctx.tokenCounter.reset?.();
@@ -15646,6 +16601,7 @@ function createSessionHandlers(ctx) {
15646
16601
  ctx.context.state.replaceMessages([]);
15647
16602
  ctx.context.state.replaceTodos([]);
15648
16603
  resetContextAccounting();
16604
+ ctx.context.clearMemoryEvidence?.();
15649
16605
  ctx.context.readFiles.clear();
15650
16606
  ctx.context.fileMtimes.clear();
15651
16607
  ctx.tokenCounter.reset?.();
@@ -15746,6 +16702,7 @@ function createSessionHandlers(ctx) {
15746
16702
  tools: ctx.listTools?.() ?? ctx.toolRegistry?.list(),
15747
16703
  baseRevision: typeof payload["baseRevision"] === "string" ? payload["baseRevision"] : "",
15748
16704
  messages: payload["messages"],
16705
+ removals: payload["removals"],
15749
16706
  allowRepair: payload["allowRepair"] === true,
15750
16707
  runActive: ctx.isRunActive?.() === true
15751
16708
  });
@@ -15762,6 +16719,7 @@ function createSessionHandlers(ctx) {
15762
16719
  tools: ctx.listTools?.() ?? ctx.toolRegistry?.list(),
15763
16720
  baseRevision: typeof payload["baseRevision"] === "string" ? payload["baseRevision"] : "",
15764
16721
  messages: payload["messages"],
16722
+ removals: payload["removals"],
15765
16723
  allowRepair: payload["allowRepair"] === true,
15766
16724
  runActive: ctx.isRunActive?.() === true
15767
16725
  });
@@ -16581,6 +17539,19 @@ async function handleCodebaseIndexServerControl(ws, message, deps2) {
16581
17539
  return true;
16582
17540
  }
16583
17541
 
17542
+ // src/server/fallback-choice.ts
17543
+ function emitFallbackChoice(events, msg) {
17544
+ const parsed = validateModelFallbackChoicePayload(msg.payload);
17545
+ if (!parsed.ok) return parsed;
17546
+ events?.emit("provider.fallback_choice", {
17547
+ requestId: parsed.value.requestId,
17548
+ ...parsed.value.providerId ? { providerId: parsed.value.providerId } : {},
17549
+ ...parsed.value.model ? { model: parsed.value.model } : {},
17550
+ ...parsed.value.autoSwitch ? { autoSwitch: true } : {}
17551
+ });
17552
+ return { ok: true };
17553
+ }
17554
+
16584
17555
  // src/server/agent-roster-routes.ts
16585
17556
  async function handleAgentRosterRoute(ws, msg, handlers) {
16586
17557
  if (!msg.type.startsWith("agent-roster.")) return false;
@@ -16745,13 +17716,17 @@ async function handleProviderRoute(ws, msg, routes) {
16745
17716
  case "model.refine":
16746
17717
  await routes.refineModel(ws, msg);
16747
17718
  return true;
17719
+ case "model.fallback_choice":
17720
+ await routes.fallbackChoice(ws, msg);
17721
+ return true;
16748
17722
  case "key.add":
16749
17723
  case "key.update": {
16750
17724
  const payload = asPayloadRecord(msg);
16751
17725
  const providerId = payload ? requiredString(payload, "providerId") : null;
16752
17726
  const label = payload ? requiredString(payload, "label") : null;
16753
17727
  const apiKey = payload ? requiredString(payload, "apiKey") : null;
16754
- if (!providerId || !label || !apiKey) return invalidPayload(ws, msg.type);
17728
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label || !apiKey)
17729
+ return invalidPayload(ws, msg.type);
16755
17730
  await routes.providerHandlers.handleKeyUpsert(ws, providerId, label, apiKey);
16756
17731
  return true;
16757
17732
  }
@@ -16759,7 +17734,8 @@ async function handleProviderRoute(ws, msg, routes) {
16759
17734
  const payload = asPayloadRecord(msg);
16760
17735
  const providerId = payload ? requiredString(payload, "providerId") : null;
16761
17736
  const label = payload ? requiredString(payload, "label") : null;
16762
- if (!providerId || !label) return invalidPayload(ws, msg.type);
17737
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label)
17738
+ return invalidPayload(ws, msg.type);
16763
17739
  await routes.providerHandlers.handleKeyDelete(ws, providerId, label);
16764
17740
  return true;
16765
17741
  }
@@ -16767,7 +17743,8 @@ async function handleProviderRoute(ws, msg, routes) {
16767
17743
  const payload = asPayloadRecord(msg);
16768
17744
  const providerId = payload ? requiredString(payload, "providerId") : null;
16769
17745
  const label = payload ? requiredString(payload, "label") : null;
16770
- if (!providerId || !label) return invalidPayload(ws, msg.type);
17746
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label)
17747
+ return invalidPayload(ws, msg.type);
16771
17748
  await routes.providerHandlers.handleKeySetActive(ws, providerId, label);
16772
17749
  return true;
16773
17750
  }
@@ -16779,11 +17756,11 @@ async function handleProviderRoute(ws, msg, routes) {
16779
17756
  const apiKey = payload?.["apiKey"];
16780
17757
  const models = payload ? optionalStringArray(payload, "models") : null;
16781
17758
  const customModels = payload ? optionalCustomModels(payload) : null;
16782
- if (!id || !family) return invalidPayload(ws, msg.type);
17759
+ if (!id || !SAFE_CONFIG_KEY.test(id) || !family) return invalidPayload(ws, msg.type);
16783
17760
  if (baseUrl !== void 0 && typeof baseUrl !== "string") return invalidPayload(ws, msg.type);
16784
17761
  if (apiKey !== void 0 && typeof apiKey !== "string") return invalidPayload(ws, msg.type);
16785
17762
  if (models === null || customModels === null) return invalidPayload(ws, msg.type);
16786
- await routes.providerHandlers.handleProviderAdd(ws, {
17763
+ const added = await routes.providerHandlers.handleProviderAdd(ws, {
16787
17764
  id,
16788
17765
  family,
16789
17766
  baseUrl,
@@ -16791,20 +17768,22 @@ async function handleProviderRoute(ws, msg, routes) {
16791
17768
  models,
16792
17769
  customModels
16793
17770
  });
16794
- await routes.adoptDefaultProviderIfUnset(id);
17771
+ if (added) {
17772
+ void routes.adoptDefaultProviderIfUnset(id).catch(() => void 0);
17773
+ }
16795
17774
  return true;
16796
17775
  }
16797
17776
  case "provider.remove": {
16798
17777
  const payload = asPayloadRecord(msg);
16799
17778
  const providerId = payload ? requiredString(payload, "providerId") : null;
16800
- if (!providerId) return invalidPayload(ws, msg.type);
17779
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId)) return invalidPayload(ws, msg.type);
16801
17780
  await routes.providerHandlers.handleProviderRemove(ws, providerId);
16802
17781
  return true;
16803
17782
  }
16804
17783
  case "provider.clear_models": {
16805
17784
  const payload = asPayloadRecord(msg);
16806
17785
  const providerId = payload ? requiredString(payload, "providerId") : null;
16807
- if (!providerId) return invalidPayload(ws, msg.type);
17786
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId)) return invalidPayload(ws, msg.type);
16808
17787
  await routes.providerHandlers.handleProviderClearModels(ws, providerId);
16809
17788
  return true;
16810
17789
  }
@@ -16836,7 +17815,8 @@ async function handleProviderRoute(ws, msg, routes) {
16836
17815
  const payload = asPayloadRecord(msg);
16837
17816
  const providerId = payload ? requiredString(payload, "providerId") : null;
16838
17817
  const previousModels = payload ? optionalStringArray(payload, "previousModels") : null;
16839
- if (!providerId || !previousModels) return invalidPayload(ws, msg.type);
17818
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !previousModels)
17819
+ return invalidPayload(ws, msg.type);
16840
17820
  await routes.providerHandlers.handleProviderUndoClear(ws, providerId, previousModels);
16841
17821
  return true;
16842
17822
  }
@@ -16846,7 +17826,7 @@ async function handleProviderRoute(ws, msg, routes) {
16846
17826
  const envVars = payload ? optionalStringArray(payload, "envVars") : null;
16847
17827
  const models = payload ? optionalStringArray(payload, "models") : null;
16848
17828
  const customModels = payload ? optionalCustomModels(payload) : null;
16849
- if (!payload || !id || envVars === null || models === null || customModels === null)
17829
+ if (!payload || !id || !SAFE_CONFIG_KEY.test(id) || envVars === null || models === null || customModels === null)
16850
17830
  return invalidPayload(ws, msg.type);
16851
17831
  for (const key of ["family", "baseUrl"]) {
16852
17832
  if (payload[key] !== void 0 && typeof payload[key] !== "string")
@@ -16866,7 +17846,8 @@ async function handleProviderRoute(ws, msg, routes) {
16866
17846
  const payload = asPayloadRecord(msg);
16867
17847
  const providerId = payload ? requiredString(payload, "providerId") : null;
16868
17848
  const timeoutMs = payload ? optionalNumber(payload, "timeoutMs") : null;
16869
- if (!providerId || timeoutMs === null) return invalidPayload(ws, msg.type);
17849
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || timeoutMs === null)
17850
+ return invalidPayload(ws, msg.type);
16870
17851
  await routes.providerHandlers.handleProviderProbe(ws, providerId, timeoutMs);
16871
17852
  return true;
16872
17853
  }
@@ -16875,7 +17856,7 @@ async function handleProviderRoute(ws, msg, routes) {
16875
17856
  const kind = oauthKind(payload);
16876
17857
  const providerId = payload?.["providerId"];
16877
17858
  if (!kind) return invalidPayload(ws, msg.type);
16878
- if (providerId !== void 0 && typeof providerId !== "string") {
17859
+ if (providerId !== void 0 && (typeof providerId !== "string" || !SAFE_CONFIG_KEY.test(providerId))) {
16879
17860
  return invalidPayload(ws, msg.type);
16880
17861
  }
16881
17862
  await routes.providerHandlers.handleOAuthStart(ws, kind, providerId);
@@ -16914,7 +17895,8 @@ async function handleProviderRoute(ws, msg, routes) {
16914
17895
  const payload = asPayloadRecord(msg);
16915
17896
  const providerId = payload ? requiredString(payload, "providerId") : null;
16916
17897
  const model = payload ? requiredString(payload, "model") : null;
16917
- if (!providerId || !model) return invalidPayload(ws, msg.type);
17898
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !model)
17899
+ return invalidPayload(ws, msg.type);
16918
17900
  const released = routes.statusTracker.retryNow(providerId, model);
16919
17901
  sendResult2(
16920
17902
  ws,
@@ -16931,7 +17913,8 @@ async function handleProviderRoute(ws, msg, routes) {
16931
17913
  const payload = asPayloadRecord(msg);
16932
17914
  const providerId = payload ? requiredString(payload, "providerId") : null;
16933
17915
  const model = payload ? requiredString(payload, "model") : null;
16934
- if (!providerId || !model) return invalidPayload(ws, msg.type);
17916
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !model)
17917
+ return invalidPayload(ws, msg.type);
16935
17918
  routes.statusTracker.clear(providerId, model);
16936
17919
  sendResult2(ws, true, `Cleared tracking for ${providerId}/${model}.`);
16937
17920
  return true;
@@ -18089,7 +19072,7 @@ function registerSetupEventsClientStatusWriter(deps2) {
18089
19072
  const on = (event, listener) => events.on(event, listener);
18090
19073
  return on("client.status", async (e) => {
18091
19074
  broadcast2(clients, { type: "client.status_update", payload: e });
18092
- if (wpaths?.projectStatus) {
19075
+ if (wpaths?.projectStatus && e.projectHash !== "unknown") {
18093
19076
  try {
18094
19077
  const statusFile = wpaths.projectStatus(e.projectHash);
18095
19078
  const dir = path19.dirname(statusFile);
@@ -18264,7 +19247,10 @@ function registerSetupEventsProviderHandlers({
18264
19247
  sessionId: e.sessionId,
18265
19248
  providerId: e.providerId,
18266
19249
  modelId: e.modelId,
18267
- maxContext: e.maxContext
19250
+ maxContext: e.maxContext,
19251
+ ...e.previousMaxContext !== void 0 ? { previousMaxContext: e.previousMaxContext } : {},
19252
+ ...e.source !== void 0 ? { source: e.source } : {},
19253
+ ...e.decreased !== void 0 ? { decreased: e.decreased } : {}
18268
19254
  })
18269
19255
  });
18270
19256
  });
@@ -18961,7 +19947,22 @@ function setupEvents(deps2) {
18961
19947
  from: e.from,
18962
19948
  to: e.to,
18963
19949
  status: e.status,
18964
- providerSwitched: e.providerSwitched
19950
+ providerSwitched: e.providerSwitched,
19951
+ ...e.requestId ? { requestId: e.requestId } : {}
19952
+ })
19953
+ });
19954
+ });
19955
+ on("provider.fallback_pending", (e) => {
19956
+ broadcast2(clients, {
19957
+ type: "provider.fallback_pending",
19958
+ payload: sessionPayload2({
19959
+ sessionId: e.sessionId,
19960
+ from: e.from,
19961
+ status: e.status,
19962
+ candidates: e.candidates,
19963
+ autoSwitchSeconds: e.autoSwitchSeconds,
19964
+ requestId: e.requestId,
19965
+ timestamp: e.timestamp
18965
19966
  })
18966
19967
  });
18967
19968
  });
@@ -19046,6 +20047,15 @@ function setupEvents(deps2) {
19046
20047
  type: "mailbox.agent_registered",
19047
20048
  payload
19048
20049
  });
20050
+ }),
20051
+ // Deregistration (subagent retirement) must reach the browser too —
20052
+ // otherwise dead agents linger in the client roster until an unrelated
20053
+ // refresh. Emitted by sqlite-mailbox.deregisterAgent with { agentId }.
20054
+ events.onPattern("mailbox.agent_deregistered", (_e, payload) => {
20055
+ broadcast2(clients, {
20056
+ type: "mailbox.agent_deregistered",
20057
+ payload
20058
+ });
19049
20059
  })
19050
20060
  );
19051
20061
  const forwardSubagent = (kind, payload) => broadcast2(clients, { type: "subagent.event", payload: sessionPayload2({ kind, ...payload }) });
@@ -19350,7 +20360,15 @@ var SpecsWebSocketHandler = class {
19350
20360
  this.clients.add(client);
19351
20361
  ws.on("close", () => this.clients.delete(client));
19352
20362
  ws.on("error", () => this.clients.delete(client));
19353
- void this.sendList(client);
20363
+ void this.sendList(client).catch((err) => {
20364
+ console.warn(
20365
+ JSON.stringify({
20366
+ level: "warn",
20367
+ event: "specs.initial_send_failed",
20368
+ message: err instanceof Error ? err.message : String(err)
20369
+ })
20370
+ );
20371
+ });
19354
20372
  }
19355
20373
  dispose() {
19356
20374
  this.clients.clear();
@@ -19564,15 +20582,17 @@ import {
19564
20582
 
19565
20583
  // src/server/discover-mailbox-bridge.ts
19566
20584
  import { spawn as spawn2 } from "node:child_process";
19567
- import { createRequire } from "node:module";
19568
20585
  import { existsSync } from "node:fs";
20586
+ import { createRequire } from "node:module";
19569
20587
  import { dirname as dirname7, join as join10 } from "node:path";
19570
- import { resolveProjectDir as resolveProjectDir2 } from "@wrongstack/core/coordination";
20588
+ import {
20589
+ readLiveLock,
20590
+ resolveProjectDir as resolveProjectDir2
20591
+ } from "@wrongstack/core/coordination";
19571
20592
  import { wstackGlobalRoot } from "@wrongstack/core/utils";
19572
- import { readLiveLock } from "@wrongstack/core/coordination";
19573
20593
  var MAILBOX_BRIDGE_BOOT_TIMEOUT_MS = 5e3;
19574
20594
  async function discoverMailboxBridgeForWebui(params) {
19575
- const mode = params.config?.features?.mailboxBridge ?? "auto";
20595
+ const mode = params.config?.features?.mailboxBridge ?? "off";
19576
20596
  if (mode === "off") return;
19577
20597
  const projectDir = resolveProjectDir2(params.projectRoot, wstackGlobalRoot());
19578
20598
  let result = await readLiveLock(projectDir);
@@ -19834,6 +20854,13 @@ var TerminalWebSocketHandler = class {
19834
20854
  this.send(ws, { type: "terminal.exit", payload: { id: payload.id, exitCode: -1 } });
19835
20855
  return;
19836
20856
  }
20857
+ if (this.sessions.get(ws) !== map) {
20858
+ this.logger.info?.(
20859
+ `terminal.create raced a disconnect (id=${payload.id}) \u2014 killing the orphan`
20860
+ );
20861
+ this.killPty(pty, "terminal create after disconnect");
20862
+ return;
20863
+ }
19837
20864
  map.set(payload.id, pty);
19838
20865
  this.logger.info?.(`terminal.create spawned (id=${payload.id}, pid=${pty.pid ?? "?"}) in ${cwd}`);
19839
20866
  pty.onData((data) => {
@@ -21259,6 +22286,15 @@ function createMessageDispatcher(opts) {
21259
22286
  msg
21260
22287
  ))
21261
22288
  return;
22289
+ if (await handleConnectionsServiceAction(ws, msg, {
22290
+ trustBoundary: deps2.trustBoundary,
22291
+ logger: deps2.logger,
22292
+ getProjectRoot: state.getProjectRoot,
22293
+ getIndexDir: () => typeof deps2.context.meta["codebaseIndexDir"] === "string" ? deps2.context.meta["codebaseIndexDir"] : void 0,
22294
+ send,
22295
+ backend: "standalone"
22296
+ }))
22297
+ return;
21262
22298
  if (await handleCodebaseIndexServerControl(ws, msg, {
21263
22299
  trustBoundary: deps2.trustBoundary,
21264
22300
  logger: deps2.logger,
@@ -21330,29 +22366,10 @@ import { attachSessionKanbanMirror, hydrateSessionKanban } from "@wrongstack/too
21330
22366
  // src/server/model-auto-discovery.ts
21331
22367
  import * as fs19 from "node:fs/promises";
21332
22368
  import * as path24 from "node:path";
21333
- import { COMPATIBLE_PRESETS, discoverOpenAICompatibleModels } from "@wrongstack/providers";
22369
+ import { discoverOpenAICompatibleModels, resolveDiscoveryTargets } from "@wrongstack/providers";
21334
22370
  function isOverlayRegistry(value) {
21335
22371
  return !!value && typeof value === "object" && typeof value.mergeOverlay === "function";
21336
22372
  }
21337
- function resolveKey(cfg) {
21338
- if (Array.isArray(cfg.apiKeys) && cfg.apiKeys.length > 0) {
21339
- const active = cfg.activeKey ? cfg.apiKeys.find((key) => key.label === cfg.activeKey) : void 0;
21340
- return (active ?? cfg.apiKeys[0])?.apiKey;
21341
- }
21342
- return cfg.apiKey && cfg.apiKey.length > 0 ? cfg.apiKey : void 0;
21343
- }
21344
- function eligibleProviders(config) {
21345
- const out = [];
21346
- for (const [id, cfg] of Object.entries(config.providers ?? {})) {
21347
- const preset = COMPATIBLE_PRESETS[id];
21348
- const enabled = cfg.autoDiscoverModels ?? preset?.autoDiscover ?? false;
21349
- if (!enabled) continue;
21350
- const baseUrl = cfg.baseUrl ?? preset?.defaultBaseUrl;
21351
- if (!baseUrl) continue;
21352
- out.push({ id, cfg, baseUrl, apiKey: resolveKey(cfg) });
21353
- }
21354
- return out;
21355
- }
21356
22373
  async function readCache(file) {
21357
22374
  try {
21358
22375
  return JSON.parse(await fs19.readFile(file, "utf8"));
@@ -21363,14 +22380,13 @@ async function readCache(file) {
21363
22380
  async function discoverAndMergeWebuiProviders(opts) {
21364
22381
  const registry = opts.registry;
21365
22382
  if (!isOverlayRegistry(registry)) return;
21366
- const targets = eligibleProviders(opts.config);
22383
+ const targets = resolveDiscoveryTargets(opts.config);
21367
22384
  if (targets.length === 0) return;
21368
22385
  const cacheFile = path24.join(opts.cacheDir, "discovered-models-cache.json");
21369
22386
  const cache2 = await readCache(cacheFile);
21370
22387
  let cacheDirty = false;
21371
22388
  await Promise.all(
21372
- targets.map(async ({ id, cfg, baseUrl, apiKey }) => {
21373
- const cacheKey = `${id}\0${baseUrl}`;
22389
+ targets.map(async ({ id, cfg, baseUrl, apiKey, cacheKey }) => {
21374
22390
  const provider = await discoverOpenAICompatibleModels(id, {
21375
22391
  baseUrl,
21376
22392
  apiKey,
@@ -21736,15 +22752,6 @@ async function createPreContextServices(input) {
21736
22752
  logger.warn(`models.dev refresh failed (${toErrorMessage11(err)}); using cached catalog`);
21737
22753
  }
21738
22754
  }
21739
- try {
21740
- await installCatalogModelOutputLimits({
21741
- registry: modelsRegistry,
21742
- getConfig: () => config,
21743
- log: (message) => logger.debug(message)
21744
- });
21745
- } catch (err) {
21746
- logger.debug(`model output-limit index skipped: ${toErrorMessage11(err)}`);
21747
- }
21748
22755
  try {
21749
22756
  await discoverAndMergeWebuiProviders({
21750
22757
  config,
@@ -21755,6 +22762,15 @@ async function createPreContextServices(input) {
21755
22762
  } catch (err) {
21756
22763
  logger.debug(`provider auto-discovery skipped: ${toErrorMessage11(err)}`);
21757
22764
  }
22765
+ try {
22766
+ await installCatalogModelOutputLimits({
22767
+ registry: modelsRegistry,
22768
+ getConfig: () => config,
22769
+ log: (message) => logger.debug(message)
22770
+ });
22771
+ } catch (err) {
22772
+ logger.debug(`model output-limit index skipped: ${toErrorMessage11(err)}`);
22773
+ }
21758
22774
  const events = opts.services?.events ?? new EventBus();
21759
22775
  events.setLogger(logger);
21760
22776
  const container = createDefaultContainer({ config, wpaths, logger, modelsRegistry, events });
@@ -21785,6 +22801,7 @@ async function createPreContextServices(input) {
21785
22801
  registry: toolRegistry,
21786
22802
  tier: normalizeTokenSavingTier(config.features.tokenSavingMode),
21787
22803
  memory: { enabled: config.features.memory, store: memoryStore },
22804
+ nextSteps: { enabled: config.tools?.nextsteps?.enabled === true },
21788
22805
  coordinationTools: [
21789
22806
  makeMailboxTool({ projectDir: wpaths.projectDir, events }),
21790
22807
  makeMailSendTool({ projectDir: wpaths.projectDir, events }),
@@ -22141,7 +23158,16 @@ function buildRoutes(state, deps2, cb) {
22141
23158
  refineModel: (ws, msg) => modelOperations.refineModel(
22142
23159
  ws,
22143
23160
  msg.payload
22144
- )
23161
+ ),
23162
+ fallbackChoice: async (ws, msg) => {
23163
+ const result = emitFallbackChoice(deps2.events, msg);
23164
+ if (!result.ok) {
23165
+ send(ws, {
23166
+ type: "error",
23167
+ payload: { phase: "invalid_request", message: result.message }
23168
+ });
23169
+ }
23170
+ }
22145
23171
  };
22146
23172
  const sessionRoutes = createSessionHandlers({
22147
23173
  config: state.getConfig(),
@@ -22319,8 +23345,10 @@ function buildRoutes(state, deps2, cb) {
22319
23345
  });
22320
23346
  const mcpRoutes = {
22321
23347
  list: (ws, msg) => handleMcpList(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
22322
- add: (ws, msg) => handleMcpAdd(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
22323
- update: (ws, msg) => handleMcpUpdate(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
23348
+ // add/update are the spawn-capable pair they take a `command`/`args`
23349
+ // from the wire and start it. They go past the trust boundary (M1).
23350
+ add: (ws, msg) => handleMcpAdd(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry, deps2.trustBoundary),
23351
+ update: (ws, msg) => handleMcpUpdate(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry, deps2.trustBoundary),
22324
23352
  remove: (ws, msg) => handleMcpRemove(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
22325
23353
  enable: (ws, msg) => handleMcpEnable(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
22326
23354
  disable: (ws, msg) => handleMcpDisable(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
@@ -22492,7 +23520,10 @@ function createWsServers(httpServer, ports, accessToken) {
22492
23520
  expectedToken: wsToken,
22493
23521
  requireToken: ports.requireToken,
22494
23522
  allowedHostnames: publicHostnames,
22495
- allowBrowserUrlToken: Boolean(ports.publicWsUrl)
23523
+ allowBrowserUrlToken: Boolean(ports.publicWsUrl),
23524
+ // WS-003 opt-out for the Vite dev loop only (app and WS server cannot
23525
+ // share a port). Off unless explicitly requested — see ws-auth.ts.
23526
+ allowCrossPortLoopbackCookie: process.env["WRONGSTACK_WEBUI_DEV_CROSS_PORT_WS"] === "1"
22496
23527
  });
22497
23528
  const WS_MAX_PAYLOAD = 20 * 1024 * 1024;
22498
23529
  const wssPrimary = new WebSocketServer({
@@ -22958,7 +23989,8 @@ async function startWebUI(opts = {}) {
22958
23989
  watcherMetricsRef
22959
23990
  );
22960
23991
  httpServer.listen(httpPort, wsHost, () => {
22961
- console.log(`[WebUI] HTTP server running on http://${wsHost}:${httpPort}`);
23992
+ const tokenQuery = accessToken ? `/?token=${encodeURIComponent(accessToken)}` : "";
23993
+ console.log(`[WebUI] HTTP server running on http://${wsHost}:${httpPort}${tokenQuery}`);
22962
23994
  const extraUrls = formatExternalAccessUrls({
22963
23995
  bindHost: wsHost,
22964
23996
  port: httpPort,
@@ -22980,8 +24012,11 @@ async function startWebUI(opts = {}) {
22980
24012
  (req, socket, head2) => httpServer.emit("upgrade", req, socket, head2)
22981
24013
  );
22982
24014
  companionServer.on("error", (err) => {
22983
- if (err.code !== "EAFNOSUPPORT" && err.code !== "EADDRNOTAVAIL" && err.code !== "EADDRINUSE") {
22984
- throw err;
24015
+ const expected = err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL" || err.code === "EADDRINUSE";
24016
+ if (!expected) {
24017
+ console.warn(
24018
+ `[WebUI] companion listener on ${companionLabel} failed (${err.code ?? "unknown"}): ${err.message}. The primary address is unaffected.`
24019
+ );
22985
24020
  }
22986
24021
  });
22987
24022
  companionServer.listen(httpPort, companion, () => {
@@ -23223,24 +24258,23 @@ async function startWebUI(opts = {}) {
23223
24258
  clients,
23224
24259
  pendingConfirms,
23225
24260
  onSecurityRejection: (ev) => {
23226
- try {
23227
- void mailbox.send({
23228
- from: context.agentId,
23229
- to: "*",
23230
- type: "note",
23231
- audience: "leaders",
23232
- subject: `Security rejection: ${ev.issueCode}`,
23233
- body: `Decoder tripwire ${ev.issueCode}: ${ev.issueMessage}
24261
+ void mailbox.send({
24262
+ from: context.agentId,
24263
+ to: "*",
24264
+ type: "note",
24265
+ audience: "leaders",
24266
+ subject: `Security rejection: ${ev.issueCode}`,
24267
+ body: `Decoder tripwire ${ev.issueCode}: ${ev.issueMessage}
23234
24268
 
23235
24269
  connectionId: ${ev.connectionId ?? "?"}
23236
24270
  sessionId: ${ev.sessionId ?? "?"}
23237
24271
  agentId: ${ev.agentId ?? "?"}
23238
24272
  projectRoot: ${ev.projectRoot ?? "?"}`,
23239
- priority: "high",
23240
- senderSessionId: session.id
23241
- });
23242
- } catch {
23243
- }
24273
+ priority: "high",
24274
+ senderSessionId: session.id
24275
+ }).catch((err) => {
24276
+ console.warn(`[WebUI] security-rejection mailbox note failed: ${String(err)}`);
24277
+ });
23244
24278
  },
23245
24279
  goalHandler,
23246
24280
  specsHandler,