@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.
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ var BOOLEAN_PREF_KEYS = /* @__PURE__ */ new Set([
48
48
  "chime",
49
49
  "confirmExit",
50
50
  "nextPrediction",
51
+ "nextStepsTool",
51
52
  "titleAnimation",
52
53
  "enhanceEnabled",
53
54
  "featureMcp",
@@ -73,6 +74,10 @@ var BOOLEAN_PREF_KEYS = /* @__PURE__ */ new Set([
73
74
  // Display-only toggles (purely visual, persisted in localStorage via Zustand).
74
75
  "groupToolCalls",
75
76
  "showThinkingLogs",
77
+ // v15: auto-collapse of the chat input under the history (opt-in display
78
+ // toggle, default off). Whitelisted so the key survives `prefs.update`
79
+ // round-trips without tripping the "unknown preference key" rejection.
80
+ "autoCollapseInput",
76
81
  // v11 Display parity: inverse fsAccess flag.
77
82
  "allowOutsideProjectRoot",
78
83
  // v13 Display parity (TUI SettingsPicker fields 42 & 43): the read tool
@@ -167,6 +172,7 @@ var ENUM_PREF_KEYS = {
167
172
  fsAccess: /* @__PURE__ */ new Set(["unrestricted", "project"]),
168
173
  // Chimera autoFix + auto-review cascade threshold
169
174
  chimeraAutoFix: /* @__PURE__ */ new Set(["off", "ask", "auto"]),
175
+ autoReviewModelSelection: /* @__PURE__ */ new Set(["round-robin", "random"]),
170
176
  autoReviewCascadeOn: /* @__PURE__ */ new Set(["off", "critical", "high"]),
171
177
  fleetChatVerbosity: /* @__PURE__ */ new Set(["off", "full"]),
172
178
  showAgentSwarmPanel: /* @__PURE__ */ new Set(["bottom", "sidebar", "off"])
@@ -397,6 +403,51 @@ function validateModelSwitchPayload(payload) {
397
403
  }
398
404
  };
399
405
  }
406
+ function validateModelFallbackChoicePayload(payload) {
407
+ if (!isRecord2(payload)) {
408
+ return {
409
+ ok: false,
410
+ message: "model.fallback_choice payload must be an object"
411
+ };
412
+ }
413
+ const requestId = payload["requestId"];
414
+ if (typeof requestId !== "string" || requestId.trim().length === 0) {
415
+ return {
416
+ ok: false,
417
+ message: "model.fallback_choice payload.requestId must be a non-empty string"
418
+ };
419
+ }
420
+ const providerId = payload["providerId"];
421
+ const model = payload["model"];
422
+ const autoSwitch = payload["autoSwitch"];
423
+ if (providerId !== void 0 && typeof providerId !== "string") {
424
+ return {
425
+ ok: false,
426
+ message: "model.fallback_choice payload.providerId must be a string when provided"
427
+ };
428
+ }
429
+ if (model !== void 0 && typeof model !== "string") {
430
+ return {
431
+ ok: false,
432
+ message: "model.fallback_choice payload.model must be a string when provided"
433
+ };
434
+ }
435
+ if (autoSwitch !== void 0 && typeof autoSwitch !== "boolean") {
436
+ return {
437
+ ok: false,
438
+ message: "model.fallback_choice payload.autoSwitch must be a boolean when provided"
439
+ };
440
+ }
441
+ return {
442
+ ok: true,
443
+ value: {
444
+ requestId: requestId.trim(),
445
+ ...typeof providerId === "string" ? { providerId } : {},
446
+ ...typeof model === "string" ? { model } : {},
447
+ ...typeof autoSwitch === "boolean" ? { autoSwitch } : {}
448
+ }
449
+ };
450
+ }
400
451
  var AUTONOMY_VALUES2 = /* @__PURE__ */ new Set(["off", "suggest", "auto", "eternal", "eternal-parallel"]);
401
452
  function validateMailboxMessagesPayload(payload) {
402
453
  if (payload === void 0) return { ok: true, value: void 0 };
@@ -4267,6 +4318,9 @@ async function handleConversationRoute(ws, msg, handlers) {
4267
4318
  case "user_message":
4268
4319
  await handlers.userMessage(ws, msg);
4269
4320
  return true;
4321
+ case "topic.advice":
4322
+ await handlers.topicAdvice(ws, msg);
4323
+ return true;
4270
4324
  case "abort":
4271
4325
  await handlers.abort(ws, msg);
4272
4326
  return true;
@@ -4282,6 +4336,7 @@ async function handleConversationRoute(ws, msg, handlers) {
4282
4336
  }
4283
4337
 
4284
4338
  // src/server/conversation-operations.ts
4339
+ import { startFreshTopicContext, TopicShiftAdvisor } from "@wrongstack/core/execution";
4285
4340
  import {
4286
4341
  buildUserContentBlocks,
4287
4342
  IncomingImageError,
@@ -4298,6 +4353,7 @@ function requestedSessionId(msg) {
4298
4353
  return payload && typeof payload === "object" && typeof payload.sessionId === "string" ? payload.sessionId : void 0;
4299
4354
  }
4300
4355
  function createConversationOperations(ctx) {
4356
+ const topicShiftAdvisor = new TopicShiftAdvisor();
4301
4357
  const sessionPayload2 = (payload) => {
4302
4358
  const provided = payload["sessionId"];
4303
4359
  const sessionId = typeof provided === "string" && provided.length > 0 ? provided : ctx.getSessionId();
@@ -4318,6 +4374,38 @@ function createConversationOperations(ctx) {
4318
4374
  return false;
4319
4375
  };
4320
4376
  return {
4377
+ topicAdvice: async (ws, msg) => {
4378
+ if (!ensureCurrentSession(ws, msg, "topic.advice")) return;
4379
+ const payload = msg.payload ?? {};
4380
+ if (typeof payload.requestId !== "string" || typeof payload.prompt !== "string") {
4381
+ ctx.send(ws, {
4382
+ type: "topic.advice_result",
4383
+ payload: sessionPayload2({
4384
+ requestId: typeof payload.requestId === "string" ? payload.requestId : "",
4385
+ suggestNewContext: false,
4386
+ confidence: 0,
4387
+ reason: "Invalid topic advice request.",
4388
+ source: "local"
4389
+ })
4390
+ });
4391
+ return;
4392
+ }
4393
+ const agent = ctx.getAgent();
4394
+ const configuredMax = agent.ctx.meta["effectiveMaxContext"];
4395
+ const maxContext = typeof configuredMax === "number" ? configuredMax : agent.ctx.provider.capabilities.maxContext;
4396
+ const advice = await topicShiftAdvisor.advise({
4397
+ prompt: payload.prompt,
4398
+ messages: agent.ctx.messages,
4399
+ provider: agent.ctx.provider,
4400
+ model: agent.ctx.model,
4401
+ contextTokens: agent.ctx.lastRequestTokens,
4402
+ maxContext
4403
+ });
4404
+ ctx.send(ws, {
4405
+ type: "topic.advice_result",
4406
+ payload: sessionPayload2({ requestId: payload.requestId, ...advice })
4407
+ });
4408
+ },
4321
4409
  userMessage: async (ws, msg) => {
4322
4410
  if (!ensureCurrentSession(ws, msg, "user_message")) return;
4323
4411
  const payload = msg.payload ?? {};
@@ -4335,6 +4423,7 @@ function createConversationOperations(ctx) {
4335
4423
  const originSessionId = ctx.getSessionId();
4336
4424
  try {
4337
4425
  const agent = ctx.getAgent();
4426
+ if (payload.freshContext === true) await startFreshTopicContext(agent.ctx);
4338
4427
  const content = typeof payload.content === "string" ? payload.content : "";
4339
4428
  let input = content;
4340
4429
  const imageBlocks = parseIncomingImages(payload.images, payload.imageBase64);
@@ -4417,13 +4506,10 @@ function createConversationOperations(ctx) {
4417
4506
 
4418
4507
  // src/server/context-editor.ts
4419
4508
  import { createHash } from "node:crypto";
4420
- import net from "node:net";
4421
4509
  import {
4422
4510
  ALLOWED_IMAGE_MEDIA_TYPES,
4423
4511
  base64DecodedBytes,
4424
4512
  isAllowedImageMediaType,
4425
- isPrivateIPv4,
4426
- isPrivateIPv6,
4427
4513
  isValidImageBase64,
4428
4514
  MAX_INCOMING_IMAGE_BYTES,
4429
4515
  repairToolUseAdjacency
@@ -4491,40 +4577,7 @@ var REVISION_PREFIX = "wrongstack-context-editor-v1\0";
4491
4577
  var MAX_MESSAGE_COUNT_GROWTH = 10;
4492
4578
  var MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
4493
4579
  var MAX_STRING_LENGTH = 8 * 1024 * 1024;
4494
- var MAX_IMAGE_URL_LENGTH = 2048;
4495
- function imageUrlRejectionReason(url) {
4496
- if (url.length > MAX_IMAGE_URL_LENGTH) {
4497
- return `image.source.url exceeds ${MAX_IMAGE_URL_LENGTH} characters.`;
4498
- }
4499
- let parsed;
4500
- try {
4501
- parsed = new URL(url);
4502
- } catch {
4503
- return "image.source.url must be an absolute URL.";
4504
- }
4505
- if (parsed.protocol !== "https:") {
4506
- return `image.source.url must use https (got "${parsed.protocol}").`;
4507
- }
4508
- if (parsed.username !== "" || parsed.password !== "") {
4509
- return "image.source.url must not embed credentials.";
4510
- }
4511
- const host = parsed.hostname.startsWith("[") && parsed.hostname.endsWith("]") ? parsed.hostname.slice(1, -1) : parsed.hostname;
4512
- const bareHost = host.endsWith(".") ? host.slice(0, -1) : host;
4513
- if (bareHost === "") {
4514
- return "image.source.url must include a hostname.";
4515
- }
4516
- if (bareHost === "localhost" || bareHost.endsWith(".localhost")) {
4517
- return "image.source.url must not target localhost.";
4518
- }
4519
- const family = net.isIP(bareHost);
4520
- if (family === 4 && isPrivateIPv4(bareHost)) {
4521
- return `image.source.url must not target a private or loopback address ("${bareHost}").`;
4522
- }
4523
- if (family === 6 && isPrivateIPv6(bareHost)) {
4524
- return `image.source.url must not target a private or loopback address ("${bareHost}").`;
4525
- }
4526
- return void 0;
4527
- }
4580
+ var MAX_REMOVAL_COUNT = 4096;
4528
4581
  function isRecord3(value) {
4529
4582
  return value !== null && typeof value === "object" && !Array.isArray(value);
4530
4583
  }
@@ -4533,7 +4586,7 @@ function canonicalize(value) {
4533
4586
  if (isRecord3(value)) {
4534
4587
  const sorted = {};
4535
4588
  for (const key of Object.keys(value).sort()) {
4536
- if (key === "_estTokens") continue;
4589
+ if (key === "_estTokens" || key === "_toolErrorInfo") continue;
4537
4590
  const item = value[key];
4538
4591
  if (item === void 0) continue;
4539
4592
  sorted[key] = canonicalize(item);
@@ -4564,6 +4617,12 @@ function isMessageRole(value) {
4564
4617
  function isPlainJsonObject(value) {
4565
4618
  return isRecord3(value);
4566
4619
  }
4620
+ function splitsSurrogatePair(text2, offset) {
4621
+ if (offset <= 0 || offset >= text2.length) return false;
4622
+ const previous = text2.charCodeAt(offset - 1);
4623
+ const next = text2.charCodeAt(offset);
4624
+ return previous >= 55296 && previous <= 56319 && next >= 56320 && next <= 57343;
4625
+ }
4567
4626
  function validateCacheControl(value, path35, errors) {
4568
4627
  if (value === void 0) return void 0;
4569
4628
  if (!isRecord3(value) || value["type"] !== "ephemeral") {
@@ -4774,19 +4833,13 @@ function validateBlock(value, path35, errors) {
4774
4833
  );
4775
4834
  return void 0;
4776
4835
  }
4777
- const urlError = imageUrlRejectionReason(url);
4778
- if (urlError !== void 0) {
4779
- error(errors, `${path35}/source/url`, "UNSAFE_IMAGE_URL", urlError);
4780
- return void 0;
4781
- }
4782
- return {
4783
- type: "image",
4784
- source: {
4785
- type: "url",
4786
- ...typeof mediaType === "string" ? { media_type: mediaType } : {},
4787
- url
4788
- }
4789
- };
4836
+ error(
4837
+ errors,
4838
+ `${path35}/source/url`,
4839
+ "UNSAFE_IMAGE_URL",
4840
+ "URL image sources are not allowed in context editor proposals; use an ingested base64 image."
4841
+ );
4842
+ return void 0;
4790
4843
  }
4791
4844
  case "thinking": {
4792
4845
  const thinking = value["thinking"];
@@ -4943,6 +4996,14 @@ function warningsForMessage(message, index) {
4943
4996
  message: "This block contains provider replay metadata and should only be removed with the whole turn if no longer needed."
4944
4997
  });
4945
4998
  }
4999
+ if (block.type === "image" && block.source.type === "url") {
5000
+ warnings.push({
5001
+ path: `/messages/${index}/content/${blockIndex}/source/url`,
5002
+ code: "UNSAFE_IMAGE_URL",
5003
+ severity: "danger",
5004
+ message: "URL image sources cannot be retained in context editor proposals; remove the whole message before applying other edits."
5005
+ });
5006
+ }
4946
5007
  if (block.type === "tool_result" && block.content.length > 2e4) {
4947
5008
  warnings.push({
4948
5009
  path: `/messages/${index}/content/${blockIndex}`,
@@ -4970,6 +5031,21 @@ function metricFor(ctx, messages, tools) {
4970
5031
  fullRequestTokens: breakdown.total
4971
5032
  };
4972
5033
  }
5034
+ function isToolResultMessage(message) {
5035
+ return Boolean(
5036
+ message?.role === "user" && Array.isArray(message.content) && message.content.length > 0 && message.content.every((block) => block.type === "tool_result")
5037
+ );
5038
+ }
5039
+ function pairedAssistantIndices(messages, userIndex) {
5040
+ if (messages[userIndex]?.role !== "user" || isToolResultMessage(messages[userIndex])) return [];
5041
+ const paired = [];
5042
+ for (let index = userIndex + 1; index < messages.length; index += 1) {
5043
+ const message = messages[index];
5044
+ if (message?.role === "user" && !isToolResultMessage(message)) break;
5045
+ if (message?.role === "assistant") paired.push(index);
5046
+ }
5047
+ return paired;
5048
+ }
4973
5049
  function buildContextEditorSnapshot(ctx, tools) {
4974
5050
  const messages = ctx.messages.map(
4975
5051
  (message) => ({
@@ -4999,11 +5075,165 @@ function buildContextEditorSnapshot(ctx, tools) {
4999
5075
  tokens: messageTokens(message.content),
5000
5076
  preview: breakdown.messages.breakdown[index]?.preview ?? "",
5001
5077
  blockCount: Array.isArray(message.content) ? message.content.length : null,
5002
- warnings: warningsForMessage(message, index)
5078
+ warnings: warningsForMessage(message, index),
5079
+ pairedAssistantIndices: pairedAssistantIndices(ctx.messages, index)
5003
5080
  })),
5004
5081
  diagnostics: toolDiagnostics(ctx.messages)
5005
5082
  };
5006
5083
  }
5084
+ function validateRemovalPlan(value, originalMessages, proposedMessages) {
5085
+ const errors = [];
5086
+ if (value === void 0) {
5087
+ error(
5088
+ errors,
5089
+ "/removals",
5090
+ "REMOVAL_PLAN_REQUIRED",
5091
+ "A removal plan is required for every context editor proposal."
5092
+ );
5093
+ return { errors };
5094
+ }
5095
+ if (!Array.isArray(value)) {
5096
+ error(errors, "/removals", "INVALID_REMOVALS", "removals must be an array.");
5097
+ return { errors };
5098
+ }
5099
+ if (value.length > MAX_REMOVAL_COUNT) {
5100
+ error(
5101
+ errors,
5102
+ "/removals",
5103
+ "TOO_MANY_REMOVALS",
5104
+ `removals must contain at most ${MAX_REMOVAL_COUNT} entries.`
5105
+ );
5106
+ return { errors };
5107
+ }
5108
+ const wholeMessages = /* @__PURE__ */ new Set();
5109
+ const touchedUsers = /* @__PURE__ */ new Set();
5110
+ const ranges = [];
5111
+ for (const [removalIndex, raw] of value.entries()) {
5112
+ const path35 = `/removals/${removalIndex}`;
5113
+ if (!isRecord3(raw) || !Number.isInteger(raw["messageIndex"])) {
5114
+ error(errors, path35, "INVALID_REMOVAL", "Removal must include an integer messageIndex.");
5115
+ continue;
5116
+ }
5117
+ const messageIndex = raw["messageIndex"];
5118
+ const original = originalMessages[messageIndex];
5119
+ if (!original) {
5120
+ error(errors, `${path35}/messageIndex`, "INVALID_MESSAGE_INDEX", "Removal messageIndex is out of range.");
5121
+ continue;
5122
+ }
5123
+ const start = raw["start"];
5124
+ const end = raw["end"];
5125
+ const blockIndex = raw["blockIndex"];
5126
+ if (start === void 0 && end === void 0 && blockIndex === void 0) {
5127
+ wholeMessages.add(messageIndex);
5128
+ if (original.role === "user") touchedUsers.add(messageIndex);
5129
+ continue;
5130
+ }
5131
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start) {
5132
+ error(errors, path35, "INVALID_RANGE", "Range removal requires integer start/end with 0 <= start < end.");
5133
+ continue;
5134
+ }
5135
+ let text2;
5136
+ if (blockIndex === void 0 && typeof original.content === "string") text2 = original.content;
5137
+ if (Number.isInteger(blockIndex) && Array.isArray(original.content)) {
5138
+ const block = original.content[blockIndex];
5139
+ if (block?.type === "text") text2 = block.text;
5140
+ }
5141
+ if (text2 === void 0 || end > text2.length) {
5142
+ error(errors, path35, "INVALID_RANGE_TARGET", "Range must target existing string or text-block content.");
5143
+ continue;
5144
+ }
5145
+ if (splitsSurrogatePair(text2, start) || splitsSurrogatePair(text2, end)) {
5146
+ error(
5147
+ errors,
5148
+ path35,
5149
+ "INVALID_UNICODE_RANGE",
5150
+ "Range boundaries must not split a Unicode surrogate pair."
5151
+ );
5152
+ continue;
5153
+ }
5154
+ ranges.push({
5155
+ messageIndex,
5156
+ ...blockIndex === void 0 ? {} : { blockIndex },
5157
+ start,
5158
+ end
5159
+ });
5160
+ if (original.role === "user") touchedUsers.add(messageIndex);
5161
+ }
5162
+ const rangesByTarget = /* @__PURE__ */ new Map();
5163
+ for (const range of ranges) {
5164
+ const key = `${range.messageIndex}:${range.blockIndex ?? "string"}`;
5165
+ const targetRanges = rangesByTarget.get(key) ?? [];
5166
+ targetRanges.push(range);
5167
+ rangesByTarget.set(key, targetRanges);
5168
+ }
5169
+ for (const targetRanges of rangesByTarget.values()) {
5170
+ targetRanges.sort((left, right) => (left.start ?? 0) - (right.start ?? 0));
5171
+ for (let index = 1; index < targetRanges.length; index += 1) {
5172
+ const previous = targetRanges[index - 1];
5173
+ const current2 = targetRanges[index];
5174
+ if (previous?.end !== void 0 && current2?.start !== void 0 && current2.start < previous.end) {
5175
+ error(
5176
+ errors,
5177
+ "/removals",
5178
+ "OVERLAPPING_RANGES",
5179
+ "Removal ranges targeting the same text must not overlap."
5180
+ );
5181
+ break;
5182
+ }
5183
+ }
5184
+ }
5185
+ for (const userIndex of touchedUsers) {
5186
+ for (const assistantIndex of pairedAssistantIndices(originalMessages, userIndex)) {
5187
+ if (wholeMessages.has(assistantIndex)) continue;
5188
+ error(
5189
+ errors,
5190
+ "/removals",
5191
+ "MISSING_ASSISTANT_PAIR",
5192
+ `Editing user message ${userIndex} must also remove assistant message ${assistantIndex}.`
5193
+ );
5194
+ }
5195
+ }
5196
+ const expectedMessages = structuredClone(originalMessages);
5197
+ for (const targetRanges of rangesByTarget.values()) {
5198
+ const first = targetRanges[0];
5199
+ if (!first) continue;
5200
+ const message = expectedMessages[first.messageIndex];
5201
+ if (!message) continue;
5202
+ let text2;
5203
+ if (first.blockIndex === void 0 && typeof message.content === "string") {
5204
+ text2 = message.content;
5205
+ } else if (first.blockIndex !== void 0 && Array.isArray(message.content)) {
5206
+ const block = message.content[first.blockIndex];
5207
+ if (block?.type === "text") text2 = block.text;
5208
+ }
5209
+ if (text2 === void 0) continue;
5210
+ const pieces = [];
5211
+ let cursor = 0;
5212
+ for (const range of targetRanges) {
5213
+ if (range.start === void 0 || range.end === void 0) continue;
5214
+ pieces.push(text2.slice(cursor, range.start));
5215
+ cursor = range.end;
5216
+ }
5217
+ pieces.push(text2.slice(cursor));
5218
+ const nextText = pieces.join("");
5219
+ if (first.blockIndex === void 0 && typeof message.content === "string") {
5220
+ message.content = nextText;
5221
+ } else if (first.blockIndex !== void 0 && Array.isArray(message.content)) {
5222
+ const block = message.content[first.blockIndex];
5223
+ if (block?.type === "text") block.text = nextText;
5224
+ }
5225
+ }
5226
+ const expectedProposal = expectedMessages.filter((_, index) => !wholeMessages.has(index));
5227
+ if (JSON.stringify(canonicalize(expectedProposal)) !== JSON.stringify(canonicalize(proposedMessages))) {
5228
+ error(
5229
+ errors,
5230
+ "/messages",
5231
+ "REMOVAL_PLAN_MISMATCH",
5232
+ "Submitted messages do not exactly match the declared removal plan."
5233
+ );
5234
+ }
5235
+ return errors.length > 0 ? { errors } : { errors, messages: expectedProposal };
5236
+ }
5007
5237
  function validateContextEditorProposal(input) {
5008
5238
  const currentRevision = contextEditorRevision(input.ctx.messages);
5009
5239
  const before = metricFor(input.ctx, input.ctx.messages, input.tools);
@@ -5055,7 +5285,23 @@ function validateContextEditorProposal(input) {
5055
5285
  repair: emptyRepair
5056
5286
  };
5057
5287
  }
5058
- const repaired = repairToolUseAdjacency(parsed.messages);
5288
+ const removalPlan = validateRemovalPlan(
5289
+ input.removals,
5290
+ input.ctx.messages,
5291
+ parsed.messages
5292
+ );
5293
+ if (removalPlan.errors.length > 0 || !removalPlan.messages) {
5294
+ return {
5295
+ ok: false,
5296
+ baseRevision: input.baseRevision,
5297
+ currentRevision,
5298
+ before,
5299
+ validationErrors: removalPlan.errors,
5300
+ warnings: [],
5301
+ repair: emptyRepair
5302
+ };
5303
+ }
5304
+ const repaired = repairToolUseAdjacency(removalPlan.messages);
5059
5305
  const repair = {
5060
5306
  changed: repaired.report.changed,
5061
5307
  removedToolUses: repaired.report.removedToolUses,
@@ -5305,7 +5551,40 @@ import {
5305
5551
  getKanbanServerConnection,
5306
5552
  isKanbanServerAvailable
5307
5553
  } from "@wrongstack/kanban";
5308
- import * as net2 from "node:net";
5554
+ import * as net from "node:net";
5555
+
5556
+ // src/server/privileged-actions.ts
5557
+ import { randomUUID as randomUUID2 } from "node:crypto";
5558
+ import {
5559
+ isTrustDecisionAllowed
5560
+ } from "@wrongstack/core/security";
5561
+ async function authorizeWebUIAction(boundary, action, logger) {
5562
+ const request = {
5563
+ version: 1,
5564
+ requestId: randomUUID2(),
5565
+ actor: {
5566
+ kind: "remote-client",
5567
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
5568
+ },
5569
+ surface: "webui",
5570
+ capability: action.capability,
5571
+ subject: action.subject,
5572
+ risk: action.risk,
5573
+ scope: {
5574
+ ...action.cwd ? { cwd: action.cwd } : {},
5575
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
5576
+ },
5577
+ authContext: { method: "session" },
5578
+ ...action.metadata ? { metadata: action.metadata } : {}
5579
+ };
5580
+ const decision = await boundary.evaluate(request);
5581
+ logger?.debug?.(
5582
+ `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
5583
+ );
5584
+ return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
5585
+ }
5586
+
5587
+ // src/server/connections-health-route.ts
5309
5588
  import { readGovernanceDaemonOperatorStatus } from "@wrongstack/runtime/governance-bootstrap";
5310
5589
  import { isSageProjectServerAvailable, SageProjectServerConnection } from "@wrongstack/sage";
5311
5590
  import {
@@ -5711,6 +5990,42 @@ async function handleConnectionsServiceAction(ws, message, context) {
5711
5990
  return true;
5712
5991
  }
5713
5992
  const action = rawAction;
5993
+ if (!context.trustBoundary) {
5994
+ context.send(ws, {
5995
+ type: "connections.service_action_result",
5996
+ payload: {
5997
+ serviceId,
5998
+ action,
5999
+ success: false,
6000
+ message: "Service control is unavailable: no policy authority is configured."
6001
+ }
6002
+ });
6003
+ return true;
6004
+ }
6005
+ const projectRootForAuth = context.getProjectRoot();
6006
+ const authorization = await authorizeWebUIAction(
6007
+ context.trustBoundary,
6008
+ {
6009
+ capability: `connections.service.${action}`,
6010
+ subject: { kind: "process", id: `${serviceId}@${projectRootForAuth}` },
6011
+ risk: "elevated",
6012
+ cwd: projectRootForAuth,
6013
+ metadata: { transport: "websocket", serviceId, action }
6014
+ },
6015
+ context.logger
6016
+ );
6017
+ if (!authorization.allowed) {
6018
+ context.send(ws, {
6019
+ type: "connections.service_action_result",
6020
+ payload: {
6021
+ serviceId,
6022
+ action,
6023
+ success: false,
6024
+ message: authorization.reason ?? "Refused by policy."
6025
+ }
6026
+ });
6027
+ return true;
6028
+ }
5714
6029
  if (serviceId === "webui") {
5715
6030
  context.send(ws, {
5716
6031
  type: "connections.service_action_result",
@@ -6149,7 +6464,7 @@ var RESTART_POLL_INTERVAL_MS = 250;
6149
6464
  var RESTART_DEADLINE_MS = 3e3;
6150
6465
  function isEndpointAlive(endpoint) {
6151
6466
  return new Promise((resolve16) => {
6152
- const sock = net2.createConnection(endpoint);
6467
+ const sock = net.createConnection(endpoint);
6153
6468
  const timer = setTimeout(() => {
6154
6469
  sock.destroy();
6155
6470
  resolve16(false);
@@ -6891,12 +7206,12 @@ Run npx tsc --noEmit to verify the fix. Output the fixed file paths.`;
6891
7206
  ...maybeVerify,
6892
7207
  onPhaseComplete: (phase) => {
6893
7208
  this.logger.info(`[Goal] Phase completed: ${phase.name}`);
6894
- void this.store.save(graph);
7209
+ this.persistDetached(graph);
6895
7210
  this.broadcastState();
6896
7211
  },
6897
7212
  onPhaseFail: (phase, error2) => {
6898
7213
  this.logger.error(`[Goal] Phase failed: ${phase.name} \u2014 ${error2.message}`);
6899
- void this.store.save(graph);
7214
+ this.persistDetached(graph);
6900
7215
  this.broadcastState();
6901
7216
  }
6902
7217
  },
@@ -6913,7 +7228,7 @@ Run npx tsc --noEmit to verify the fix. Output the fixed file paths.`;
6913
7228
  this.broadcastState();
6914
7229
  void this.orchestrator.start().then(() => {
6915
7230
  this.orchestrator?.stop();
6916
- void this.store.save(graph);
7231
+ this.persistDetached(graph);
6917
7232
  this.stopBroadcast();
6918
7233
  const failed = graph.failedPhaseIds.length > 0;
6919
7234
  this.broadcast(
@@ -7111,9 +7426,27 @@ ${result_.finalText.slice(0, 2e3)}`
7111
7426
  this.logger.warn(`[Goal] Chimera review failed for "${task.title}": ${toErrorMessage2(err)}`);
7112
7427
  }
7113
7428
  }
7429
+ /**
7430
+ * Fire-and-forget persist.
7431
+ *
7432
+ * Every detached `store.save()` used to be a bare `void`, so a rejection
7433
+ * became an unhandled rejection and — under Node 22's default
7434
+ * `--unhandled-rejections=throw` — killed the process mid-run. On Windows an
7435
+ * AV scanner or indexer holding the `.wrongstack/phases/<id>.json` rename
7436
+ * target for a few hundred ms is enough (EPERM from `atomicWrite`), and in
7437
+ * `--webui` mode that takes the CLI session down with it. `handleStop` at
7438
+ * `:549` already had the `.catch`; these call sites did not.
7439
+ */
7440
+ persistDetached(graph) {
7441
+ void this.store.save(graph).catch((err) => {
7442
+ this.logger.warn(
7443
+ `[Goal] Failed to persist phase graph: ${err instanceof Error ? err.message : String(err)}`
7444
+ );
7445
+ });
7446
+ }
7114
7447
  /** Persist + broadcast after an interactive board mutation. */
7115
7448
  afterBoardMutation() {
7116
- if (this.graph) void this.store.save(this.graph);
7449
+ if (this.graph) this.persistDetached(this.graph);
7117
7450
  this.broadcastState();
7118
7451
  }
7119
7452
  async handleTaskStatusChange(taskId, status) {
@@ -8105,12 +8438,21 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8105
8438
  const paths = resolveWstackPaths7({ projectRoot: entry.projectRoot, globalRoot });
8106
8439
  const store = new DefaultSessionStore4({ dir: paths.projectSessions });
8107
8440
  const reader = new DefaultSessionReader2({ store });
8108
- const rawEntries = [];
8441
+ const RING = Math.max(limit * 4, 2e3);
8442
+ const ring = [];
8443
+ let totalRaw = 0;
8444
+ let dropped = false;
8109
8445
  for await (const ev of reader.replay(sessionId)) {
8110
8446
  const mapped = mapWatchEntry(ev);
8111
- if (mapped) rawEntries.push(mapped);
8447
+ if (!mapped) continue;
8448
+ totalRaw += 1;
8449
+ ring.push(mapped);
8450
+ if (ring.length > RING) {
8451
+ ring.shift();
8452
+ dropped = true;
8453
+ }
8112
8454
  }
8113
- const all = correlateToolEvents(rawEntries);
8455
+ const all = correlateToolEvents(ring);
8114
8456
  const tail2 = all.slice(-limit);
8115
8457
  res.writeHead(200, { "Content-Type": "application/json" });
8116
8458
  res.end(
@@ -8119,7 +8461,12 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8119
8461
  status: entry.status,
8120
8462
  clientType: entry.clientType,
8121
8463
  projectName: entry.projectName,
8122
- total: all.length,
8464
+ // Exact when the whole session fit in the ring (the previous
8465
+ // behaviour). Past that, correlation never ran over the dropped
8466
+ // prefix, so report the raw event count — an upper bound — and say so
8467
+ // rather than silently understating the session's size.
8468
+ total: dropped ? totalRaw : all.length,
8469
+ ...dropped ? { truncated: true } : {},
8123
8470
  entries: tail2
8124
8471
  })
8125
8472
  );
@@ -8809,7 +9156,7 @@ async function touchProjectInManifest(options, globalConfigPath) {
8809
9156
  }
8810
9157
 
8811
9158
  // src/server/techstack-handlers.ts
8812
- import { randomUUID as randomUUID2 } from "node:crypto";
9159
+ import { randomUUID as randomUUID3 } from "node:crypto";
8813
9160
  var DEEP_DIVE_TIMEOUT_MS = 6e4;
8814
9161
  function sendJson3(res, status, data) {
8815
9162
  res.writeHead(status, { "Content-Type": "application/json" });
@@ -8850,7 +9197,7 @@ function requireJobDeps(res, deps2) {
8850
9197
  }
8851
9198
  function startJob(res, deps2, kind) {
8852
9199
  if (!requireJobDeps(res, deps2)) return;
8853
- const jobId = randomUUID2();
9200
+ const jobId = randomUUID3();
8854
9201
  const controller = new AbortController();
8855
9202
  deps2.runningJobs?.set(jobId, controller);
8856
9203
  deps2.emit?.({ type: "techstack.job.started", payload: { jobId, kind } });
@@ -9082,19 +9429,22 @@ function extractToken(url) {
9082
9429
  function extractTokenFromCookie(cookieHeader) {
9083
9430
  if (!cookieHeader) return void 0;
9084
9431
  const raw = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader;
9432
+ let plain;
9085
9433
  for (const part of raw.split(";")) {
9086
9434
  const eq = part.indexOf("=");
9087
9435
  if (eq < 0) continue;
9088
9436
  const name2 = part.slice(0, eq).trim();
9089
- if (name2 === "ws_token") {
9090
- try {
9091
- return decodeURIComponent(part.slice(eq + 1).trim());
9092
- } catch {
9093
- return part.slice(eq + 1).trim();
9094
- }
9437
+ if (name2 !== "ws_token" && name2 !== "__Host-ws_token") continue;
9438
+ let value;
9439
+ try {
9440
+ value = decodeURIComponent(part.slice(eq + 1).trim());
9441
+ } catch {
9442
+ value = part.slice(eq + 1).trim();
9095
9443
  }
9444
+ if (name2 === "__Host-ws_token") return value;
9445
+ plain ??= value;
9096
9446
  }
9097
- return void 0;
9447
+ return plain;
9098
9448
  }
9099
9449
  function hostHeaderOk(input) {
9100
9450
  if (!isLoopbackBind(input.wsHost)) return true;
@@ -9136,7 +9486,8 @@ function verifyClient(input) {
9136
9486
  expectedToken,
9137
9487
  requireToken,
9138
9488
  allowedHostnames,
9139
- allowBrowserUrlToken
9489
+ allowBrowserUrlToken,
9490
+ allowCrossPortLoopbackCookie
9140
9491
  } = input;
9141
9492
  const urlTokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
9142
9493
  const cookieTokenOk = tokenMatches(extractTokenFromCookie(cookieHeader), expectedToken);
@@ -9151,7 +9502,10 @@ function verifyClient(input) {
9151
9502
  const { hostname: originHostname } = new URL(origin);
9152
9503
  if (isLoopbackHostname(originHostname)) {
9153
9504
  if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
9154
- return cookieTokenOk || isTrustedLoopbackOrigin(origin, hostHeader);
9505
+ if (!isTrustedLoopbackOrigin(origin, hostHeader)) {
9506
+ return Boolean(allowCrossPortLoopbackCookie) && cookieTokenOk;
9507
+ }
9508
+ return true;
9155
9509
  }
9156
9510
  return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
9157
9511
  } catch {
@@ -9186,11 +9540,22 @@ ${out}`;
9186
9540
  function firstHeader(value) {
9187
9541
  return Array.isArray(value) ? value[0] : value;
9188
9542
  }
9189
- function wsTokenCookie(token) {
9190
- return `ws_token=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
9543
+ var WS_TOKEN_COOKIE = "ws_token";
9544
+ var WS_TOKEN_COOKIE_SECURE = "__Host-ws_token";
9545
+ function wsTokenCookie(token, secure) {
9546
+ const name2 = secure ? WS_TOKEN_COOKIE_SECURE : WS_TOKEN_COOKIE;
9547
+ const parts = [
9548
+ `${name2}=${encodeURIComponent(token)}`,
9549
+ "HttpOnly",
9550
+ "SameSite=Strict",
9551
+ "Path=/",
9552
+ "Max-Age=3600"
9553
+ ];
9554
+ if (secure) parts.push("Secure");
9555
+ return parts.join("; ");
9191
9556
  }
9192
- function setAuthCookieHeaders(res, token) {
9193
- res.setHeader("Set-Cookie", wsTokenCookie(token));
9557
+ function setAuthCookieHeaders(res, token, secure) {
9558
+ res.setHeader("Set-Cookie", wsTokenCookie(token, secure));
9194
9559
  res.setHeader("Cache-Control", "no-store");
9195
9560
  }
9196
9561
  function setStaticSecurityHeaders(res) {
@@ -9198,8 +9563,16 @@ function setStaticSecurityHeaders(res) {
9198
9563
  res.setHeader("X-Frame-Options", "DENY");
9199
9564
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
9200
9565
  }
9201
- function requestToken(req, url) {
9202
- return url.searchParams.get("token") ?? firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
9566
+ function requestToken(req, url, opts = {}) {
9567
+ const queryToken = url.searchParams.get("token") ?? void 0;
9568
+ if (queryToken !== void 0 && (opts.allowQuery === true || isLoopbackPeer(req))) {
9569
+ return queryToken;
9570
+ }
9571
+ return firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
9572
+ }
9573
+ function isLoopbackPeer(req) {
9574
+ const address = req.socket.remoteAddress?.replace(/^::ffff:/i, "");
9575
+ return address !== void 0 && isLoopbackHostname(address);
9203
9576
  }
9204
9577
  function formatCspHostname(hostname) {
9205
9578
  return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
@@ -9254,7 +9627,8 @@ function strictDecodeParam(segment, res) {
9254
9627
  function createHttpServer(opts) {
9255
9628
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
9256
9629
  const distDir = path13.resolve(opts.distDir);
9257
- const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
9630
+ const requireAccessToken = true;
9631
+ const secureCookies = opts.secureCookies ?? (opts.publicWsUrl?.trim().toLowerCase().startsWith("wss:") ?? false);
9258
9632
  const trustedHostnames = (() => {
9259
9633
  const names = [...opts.allowedHostnames ?? []];
9260
9634
  if (opts.publicWsUrl) {
@@ -9289,17 +9663,17 @@ function createHttpServer(opts) {
9289
9663
  res.end(JSON.stringify({ error: "forbidden: untrusted request origin" }));
9290
9664
  return;
9291
9665
  }
9292
- const providedAccessToken = requestToken(req, url);
9666
+ const providedAccessToken = requestToken(req, url, { allowQuery: true });
9293
9667
  const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
9294
9668
  const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
9295
9669
  if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
9296
- const provided = requestToken(req, url);
9670
+ const provided = requestToken(req, url, { allowQuery: true });
9297
9671
  if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
9298
9672
  res.writeHead(401, { "Content-Type": "text/plain" });
9299
9673
  res.end("Unauthorized");
9300
9674
  return;
9301
9675
  }
9302
- setAuthCookieHeaders(res, opts.apiToken);
9676
+ setAuthCookieHeaders(res, opts.apiToken, secureCookies);
9303
9677
  res.writeHead(200, { "Content-Type": "text/plain" });
9304
9678
  res.end("ok");
9305
9679
  return;
@@ -9313,7 +9687,7 @@ function createHttpServer(opts) {
9313
9687
  return;
9314
9688
  }
9315
9689
  if (shouldSetAuthCookie && opts.apiToken) {
9316
- setAuthCookieHeaders(res, opts.apiToken);
9690
+ setAuthCookieHeaders(res, opts.apiToken, secureCookies);
9317
9691
  }
9318
9692
  if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
9319
9693
  if (requireAccessToken && !accessTokenOk) {
@@ -10150,6 +10524,27 @@ import {
10150
10524
  getServerKanbanStore
10151
10525
  } from "@wrongstack/kanban";
10152
10526
  import { recordKanbanVerificationEvidence } from "@wrongstack/tools";
10527
+
10528
+ // src/server/kanban-broadcast.ts
10529
+ function kanbanBoardMessage(board) {
10530
+ return { type: "kanban.get", payload: { success: true, data: { board } } };
10531
+ }
10532
+ function kanbanListMessage(boards) {
10533
+ return { type: "kanban.list", payload: { success: true, data: boards } };
10534
+ }
10535
+ function kanbanDeletedMessage(boardId) {
10536
+ return { type: "kanban.delete", payload: { success: true, data: { removed: true, boardId } } };
10537
+ }
10538
+ async function publishKanbanBoard(broadcast2, board, listBoards6) {
10539
+ broadcast2(kanbanBoardMessage(board));
10540
+ if (!listBoards6) return;
10541
+ try {
10542
+ broadcast2(kanbanListMessage(await listBoards6()));
10543
+ } catch {
10544
+ }
10545
+ }
10546
+
10547
+ // src/server/kanban-dispatch.ts
10153
10548
  function parseResolvedDispatchRoute(summary) {
10154
10549
  const tags = summary.match(/Spawned subagent\s+\S+\s+\((.*?)\)\s+for task/i)?.[1];
10155
10550
  if (!tags) return {};
@@ -10280,10 +10675,7 @@ async function handleKanbanTaskDispatch(ws, payload, ctx) {
10280
10675
  payload: { success: true, data: { boardId: board.id, task: completedTask } }
10281
10676
  });
10282
10677
  if (completedBoard) {
10283
- ctx.broadcast?.({
10284
- type: "kanban.get",
10285
- payload: { success: true, data: { board: completedBoard } }
10286
- });
10678
+ ctx.broadcast?.(kanbanBoardMessage(completedBoard));
10287
10679
  }
10288
10680
  ctx.broadcast?.({
10289
10681
  type: "kanban.list",
@@ -10307,7 +10699,7 @@ async function handleKanbanTaskDispatch(ws, payload, ctx) {
10307
10699
  payload: { success: true, data: { boardId: board.id, task: runningTask } }
10308
10700
  });
10309
10701
  if (started?.board) {
10310
- ctx.broadcast?.({ type: "kanban.get", payload: { success: true, data: { board: started.board } } });
10702
+ ctx.broadcast?.(kanbanBoardMessage(started.board));
10311
10703
  }
10312
10704
  reply(ws, "kanban.task.dispatch", true, { boardId: board.id, task: runningTask, summary });
10313
10705
  } catch (error2) {
@@ -10550,14 +10942,11 @@ async function handleDecompositionResolution(ws, type, payload, ctx) {
10550
10942
  type: "kanban.decomposition.applied",
10551
10943
  payload: { success: true, data: { board: resolved.board } }
10552
10944
  });
10553
- ctx.broadcast?.({
10554
- type: "kanban.get",
10555
- payload: { success: true, data: { board: resolved.board } }
10556
- });
10557
- ctx.broadcast?.({
10558
- type: "kanban.list",
10559
- payload: { success: true, data: await listBoards(ctx.projectRoot) }
10560
- });
10945
+ await publishKanbanBoard(
10946
+ (message) => ctx.broadcast?.(message),
10947
+ resolved.board,
10948
+ () => listBoards(ctx.projectRoot)
10949
+ );
10561
10950
  } else {
10562
10951
  ctx.broadcast?.({
10563
10952
  type: "kanban.decomposition.resolved",
@@ -10590,10 +10979,7 @@ async function handleTaskVerification(ws, type, payload, ctx) {
10590
10979
  payload: { success: true, data: { boardId, task: freshTask } }
10591
10980
  });
10592
10981
  if (persisted) {
10593
- ctx.broadcast?.({
10594
- type: "kanban.get",
10595
- payload: { success: true, data: { board: persisted } }
10596
- });
10982
+ ctx.broadcast?.(kanbanBoardMessage(persisted));
10597
10983
  }
10598
10984
  } catch (err) {
10599
10985
  ctx.broadcast?.({
@@ -11516,10 +11902,7 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11516
11902
  let connectionCount = 0;
11517
11903
  const broadcastDeleted = (boardId) => {
11518
11904
  knownRevisions.delete(boardId);
11519
- broadcastMessage({
11520
- type: "kanban.delete",
11521
- payload: { success: true, data: { removed: true, boardId } }
11522
- });
11905
+ broadcastMessage(kanbanDeletedMessage(boardId));
11523
11906
  };
11524
11907
  const broadcastBoard = async (boardId) => {
11525
11908
  const board = await store.getBoard(boardId);
@@ -11528,10 +11911,7 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11528
11911
  return;
11529
11912
  }
11530
11913
  knownRevisions.set(boardId, board.updatedAt);
11531
- broadcastMessage({
11532
- type: "kanban.get",
11533
- payload: { success: true, data: { board } }
11534
- });
11914
+ broadcastMessage(kanbanBoardMessage(board));
11535
11915
  };
11536
11916
  const reconcileAfterConnect = async () => {
11537
11917
  const summaries = await store.listBoards();
@@ -11550,20 +11930,36 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11550
11930
  }
11551
11931
  }
11552
11932
  };
11553
- return bridgeKanbanSupervisor(
11933
+ const COALESCE_MS = 300;
11934
+ const pendingBroadcasts = /* @__PURE__ */ new Map();
11935
+ const scheduleBroadcast = (boardId) => {
11936
+ if (pendingBroadcasts.has(boardId)) return;
11937
+ const timer = setTimeout(() => {
11938
+ pendingBroadcasts.delete(boardId);
11939
+ void broadcastBoard(boardId).catch(() => {
11940
+ });
11941
+ }, COALESCE_MS);
11942
+ timer.unref?.();
11943
+ pendingBroadcasts.set(boardId, timer);
11944
+ };
11945
+ const unsubscribe = bridgeKanbanSupervisor(
11554
11946
  projectRoot,
11555
11947
  async (event) => {
11948
+ const family = event.event?.split(".")[0];
11949
+ if (family !== "board" && family !== "task" && family !== "column") return;
11556
11950
  const evData = event.data;
11557
11951
  const boardId = evData?.boardId;
11558
11952
  if (!boardId) return;
11559
- try {
11560
- if (event.event === "board.deleted") {
11561
- broadcastDeleted(boardId);
11562
- return;
11953
+ if (event.event === "board.deleted") {
11954
+ const timer = pendingBroadcasts.get(boardId);
11955
+ if (timer) {
11956
+ clearTimeout(timer);
11957
+ pendingBroadcasts.delete(boardId);
11563
11958
  }
11564
- await broadcastBoard(boardId);
11565
- } catch {
11959
+ broadcastDeleted(boardId);
11960
+ return;
11566
11961
  }
11962
+ scheduleBroadcast(boardId);
11567
11963
  },
11568
11964
  {
11569
11965
  autoReconnect: true,
@@ -11571,6 +11967,11 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11571
11967
  onConnected: reconcileAfterConnect
11572
11968
  }
11573
11969
  );
11970
+ return () => {
11971
+ for (const timer of pendingBroadcasts.values()) clearTimeout(timer);
11972
+ pendingBroadcasts.clear();
11973
+ unsubscribe();
11974
+ };
11574
11975
  }
11575
11976
 
11576
11977
  // src/server/kanban-board-watcher.ts
@@ -11592,7 +11993,13 @@ function createShutdown(res) {
11592
11993
  } catch (e) {
11593
11994
  log(`[WebUI] Error closing session: ${e instanceof Error ? e.message : String(e)}`);
11594
11995
  }
11595
- for (const ws of res.clients()) ws.close();
11996
+ for (const ws of res.clients()) {
11997
+ try {
11998
+ ws.close();
11999
+ ws.terminate?.();
12000
+ } catch {
12001
+ }
12002
+ }
11596
12003
  for (const server of res.servers) server?.close();
11597
12004
  if (res.onShutdown) {
11598
12005
  try {
@@ -12029,6 +12436,22 @@ import {
12029
12436
  restartMcp,
12030
12437
  updateMcp
12031
12438
  } from "@wrongstack/mcp";
12439
+ async function authorizeMcpMutation(ws, operation, serverName, trustBoundary) {
12440
+ if (!trustBoundary) return true;
12441
+ const authorization = await authorizeWebUIAction(trustBoundary, {
12442
+ capability: "mcp.server.configure",
12443
+ subject: { kind: "process", id: serverName },
12444
+ risk: "elevated",
12445
+ metadata: { transport: "websocket", operation }
12446
+ });
12447
+ if (!authorization.allowed) {
12448
+ send(ws, {
12449
+ type: "mcp.operation_result",
12450
+ payload: { success: false, message: `${operation} denied: ${authorization.reason}` }
12451
+ });
12452
+ }
12453
+ return authorization.allowed;
12454
+ }
12032
12455
  function mapStatus(raw) {
12033
12456
  switch (raw) {
12034
12457
  case "connected":
@@ -12103,7 +12526,7 @@ async function handleMcpList(ws, _msg, globalConfigPath, mcpRegistry) {
12103
12526
  payload: { servers: servers.map((server) => toView(server, health.get(server.name))) }
12104
12527
  });
12105
12528
  }
12106
- async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
12529
+ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry, trustBoundary) {
12107
12530
  const d = deps(ws, globalConfigPath, mcpRegistry);
12108
12531
  if (!d) return;
12109
12532
  const validated = validateMcpServerPayload(msg.payload, "mcp.add");
@@ -12114,6 +12537,7 @@ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
12114
12537
  });
12115
12538
  return;
12116
12539
  }
12540
+ if (!await authorizeMcpMutation(ws, "mcp.add", name(msg), trustBoundary)) return;
12117
12541
  const result = await addMcp(validated.value, d);
12118
12542
  if (result.ok && result.server) {
12119
12543
  send(ws, { type: "mcp.server.added", payload: { server: toView(result.server) } });
@@ -12131,7 +12555,7 @@ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
12131
12555
  payload: { success: result.ok, message: result.message }
12132
12556
  });
12133
12557
  }
12134
- async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
12558
+ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry, trustBoundary) {
12135
12559
  const d = deps(ws, globalConfigPath, mcpRegistry);
12136
12560
  if (!d) return;
12137
12561
  const validated = validateMcpServerPayload(msg.payload, "mcp.update");
@@ -12142,6 +12566,7 @@ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
12142
12566
  });
12143
12567
  return;
12144
12568
  }
12569
+ if (!await authorizeMcpMutation(ws, "mcp.update", name(msg), trustBoundary)) return;
12145
12570
  const result = await updateMcp(validated.value, d);
12146
12571
  if (result.ok && result.server) {
12147
12572
  send(ws, { type: "mcp.server.updated", payload: { server: toView(result.server) } });
@@ -13285,7 +13710,7 @@ function openBrowser(url, platform = process.platform) {
13285
13710
  }
13286
13711
 
13287
13712
  // src/server/port-utils.ts
13288
- import * as net3 from "node:net";
13713
+ import * as net2 from "node:net";
13289
13714
  import { ToolValidationError as ToolValidationError4 } from "@wrongstack/core/types";
13290
13715
  var SURFACE_DEFAULT_PORTS = {
13291
13716
  webui: { http: 3456 },
@@ -13299,7 +13724,7 @@ function getSurfaceDefaultPorts(surface) {
13299
13724
  }
13300
13725
  function isPortFree(host, port) {
13301
13726
  return new Promise((resolve16) => {
13302
- const srv = net3.createServer();
13727
+ const srv = net2.createServer();
13303
13728
  srv.once("error", () => resolve16(false));
13304
13729
  srv.once("listening", () => {
13305
13730
  srv.close(() => resolve16(true));
@@ -13560,7 +13985,8 @@ function registerWebuiInstance(p, deps2 = {}) {
13560
13985
  host: p.host,
13561
13986
  port: p.httpPort,
13562
13987
  publicUrl: p.publicUrl
13563
- })
13988
+ }),
13989
+ ...p.authToken ? { authToken: p.authToken } : {}
13564
13990
  },
13565
13991
  p.registryBaseDir
13566
13992
  ).catch(() => {
@@ -13594,10 +14020,31 @@ ${extraBlock}`
13594
14020
  if (p.open) launch(openUrl);
13595
14021
  });
13596
14022
  }
14023
+ var DEFAULT_CHILD_CLEANUP_TIMEOUT_MS = 1e4;
14024
+ async function runBounded(work, timeoutMs, label, debug) {
14025
+ let timer;
14026
+ try {
14027
+ await Promise.race([
14028
+ Promise.resolve().then(() => work()).catch((err) => {
14029
+ debug(`[webui-server] ${label} failed: ${err}`);
14030
+ }),
14031
+ new Promise((resolve16) => {
14032
+ timer = setTimeout(() => {
14033
+ debug(`[webui-server] ${label} timed out after ${timeoutMs}ms`);
14034
+ resolve16();
14035
+ }, timeoutMs);
14036
+ timer.unref?.();
14037
+ })
14038
+ ]);
14039
+ } finally {
14040
+ if (timer) clearTimeout(timer);
14041
+ }
14042
+ }
13597
14043
  function createWebuiShutdown(res) {
13598
14044
  const log = res.log ?? ((m) => console.log(m));
13599
14045
  const debug = res.debug ?? ((m) => console.debug(m));
13600
14046
  const unregister = res.unregisterFn ?? unregisterInstance;
14047
+ const childTimeout = Math.max(1, res.childCleanupTimeoutMs ?? DEFAULT_CHILD_CLEANUP_TIMEOUT_MS);
13601
14048
  let started = false;
13602
14049
  return () => {
13603
14050
  if (started) return;
@@ -13605,17 +14052,30 @@ function createWebuiShutdown(res) {
13605
14052
  log("[WebUI] Shutting down...");
13606
14053
  res.abortInFlight();
13607
14054
  res.unsubscribeEvents();
13608
- res.disposeResources?.();
13609
- res.closeClients();
13610
- const unregistered = unregister(res.pid, res.registryBaseDir).catch(
13611
- (err) => debug(`[webui-server] unregister failed: ${err}`)
13612
- );
13613
- res.closeHttpServer();
13614
- res.wss.close(() => {
13615
- void unregistered.then(() => {
13616
- log("[WebUI] Server stopped");
13617
- res.onStopped();
14055
+ void (async () => {
14056
+ if (res.stopOwnedChildren) {
14057
+ await runBounded(res.stopOwnedChildren, childTimeout, "stopOwnedChildren", debug);
14058
+ }
14059
+ if (res.disposeResources) {
14060
+ await runBounded(res.disposeResources, Math.min(childTimeout, 5e3), "disposeResources", debug);
14061
+ }
14062
+ res.closeClients();
14063
+ res.closeHttpServer();
14064
+ const unregistered = unregister(res.pid, res.registryBaseDir).catch(
14065
+ (err) => debug(`[webui-server] unregister failed: ${err}`)
14066
+ );
14067
+ await new Promise((resolve16) => {
14068
+ res.wss.close(() => resolve16());
13618
14069
  });
14070
+ await unregistered;
14071
+ log("[WebUI] Server stopped");
14072
+ res.onStopped();
14073
+ })().catch((err) => {
14074
+ debug(`[webui-server] shutdown sequence failed: ${err}`);
14075
+ try {
14076
+ res.onStopped();
14077
+ } catch {
14078
+ }
13619
14079
  });
13620
14080
  };
13621
14081
  }
@@ -14096,12 +14556,9 @@ function createKanbanRunMirror(deps2) {
14096
14556
  }
14097
14557
  async function publish(board) {
14098
14558
  if (board) {
14099
- broadcast2({ type: "kanban.get", payload: { success: true, data: { board } } });
14559
+ broadcast2(kanbanBoardMessage(board));
14100
14560
  }
14101
- broadcast2({
14102
- type: "kanban.list",
14103
- payload: { success: true, data: await listBoards3(projectRoot) }
14104
- });
14561
+ broadcast2(kanbanListMessage(await listBoards3(projectRoot)));
14105
14562
  }
14106
14563
  async function projectSdd(runId, snapshot) {
14107
14564
  const k = mapKey("sdd", runId);
@@ -14504,14 +14961,11 @@ function createKanbanSupervisor(deps2) {
14504
14961
  publish(snapshot);
14505
14962
  const changedBoard = recovered?.board ?? gateSwept ?? reconciled?.board;
14506
14963
  if (changedBoard) {
14507
- deps2.broadcast({
14508
- type: "kanban.get",
14509
- payload: { success: true, data: { board: changedBoard } }
14510
- });
14511
- deps2.broadcast({
14512
- type: "kanban.list",
14513
- payload: { success: true, data: await listBoards4(deps2.projectRoot) }
14514
- });
14964
+ await publishKanbanBoard(
14965
+ deps2.broadcast,
14966
+ changedBoard,
14967
+ () => listBoards4(deps2.projectRoot)
14968
+ );
14515
14969
  }
14516
14970
  if (config.mode === "agentic" && anomalyCount > 0) {
14517
14971
  await maybeRunAgent(board, config, health, snapshot);
@@ -14723,6 +15177,8 @@ function buildAuditPrompt(board, health) {
14723
15177
 
14724
15178
  // src/server/context-meta.ts
14725
15179
  import { FallbackProfileManager } from "@wrongstack/core/agent";
15180
+ import { resolvePluginEnablement } from "@wrongstack/core/plugin";
15181
+ import { FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS2 } from "@wrongstack/core/utils";
14726
15182
  function seedContextMeta(config, context) {
14727
15183
  const meta = context.meta;
14728
15184
  const autonomyCfg = config.autonomy ?? {};
@@ -14738,6 +15194,7 @@ function seedContextMeta(config, context) {
14738
15194
  meta["enhanceDelayMs"] = autonomyCfg["enhanceDelayMs"] ?? 6e4;
14739
15195
  meta["enhanceLanguage"] = autonomyCfg["enhanceLanguage"] ?? "original";
14740
15196
  meta["nextPrediction"] = config.nextPrediction ?? false;
15197
+ meta["nextStepsTool"] = config.tools?.nextsteps?.enabled === true;
14741
15198
  meta["fallbackModels"] = config.fallbackModels ?? [];
14742
15199
  meta["fallbackBridge"] = config.fallbackBridge ?? "";
14743
15200
  meta["fallbackProfiles"] = config.fallbackProfiles ?? {};
@@ -14799,6 +15256,21 @@ function seedContextMeta(config, context) {
14799
15256
  meta["tgDelegate"] = tgExt?.["notifyOnDelegate"] !== false;
14800
15257
  const tgMs = tgExt?.["longToolThresholdMs"];
14801
15258
  meta["tgLongToolMs"] = typeof tgMs === "number" ? tgMs : 3e4;
15259
+ {
15260
+ const pluginsEnabled = {};
15261
+ const record2 = (name2) => {
15262
+ if (FORBIDDEN_PROTO_KEYS2.has(name2) || name2 in pluginsEnabled) return;
15263
+ pluginsEnabled[name2] = resolvePluginEnablement({ name: name2, config }).enabled;
15264
+ };
15265
+ for (const entry of config.plugins ?? []) {
15266
+ const name2 = typeof entry === "string" ? entry : entry?.name;
15267
+ if (typeof name2 === "string") record2(name2);
15268
+ }
15269
+ for (const [name2, options] of Object.entries(config.extensions ?? {})) {
15270
+ if (typeof options?.["enabled"] === "boolean") record2(name2);
15271
+ }
15272
+ if (Object.keys(pluginsEnabled).length > 0) meta["pluginsEnabled"] = pluginsEnabled;
15273
+ }
14802
15274
  const chimeraExt = config.extensions?.["wstack-chimera"];
14803
15275
  meta["chimeraEnabled"] = chimeraExt?.["enabled"] === true;
14804
15276
  meta["chimeraProvider"] = chimeraExt?.["provider"] ?? "";
@@ -14811,6 +15283,7 @@ function seedContextMeta(config, context) {
14811
15283
  meta["autoReviewProvider"] = autoReviewExt?.["provider"] ?? "";
14812
15284
  meta["autoReviewModel"] = autoReviewExt?.["model"] ?? "";
14813
15285
  meta["autoReviewFallbackProfile"] = autoReviewExt?.["fallbackProfile"] ?? "";
15286
+ meta["autoReviewModelSelection"] = autoReviewExt?.["modelSelection"] === "random" ? "random" : "round-robin";
14814
15287
  meta["autoReviewFallbackModels"] = Array.isArray(autoReviewExt?.["fallbackModels"]) ? autoReviewExt?.["fallbackModels"] : [];
14815
15288
  meta["autoReviewDebounceMs"] = typeof autoReviewExt?.["debounceMs"] === "number" && autoReviewExt["debounceMs"] >= 0 ? autoReviewExt["debounceMs"] : 15e3;
14816
15289
  meta["autoReviewMaxFilesPerBatch"] = typeof autoReviewExt?.["maxFilesPerBatch"] === "number" && autoReviewExt["maxFilesPerBatch"] >= 1 ? autoReviewExt["maxFilesPerBatch"] : 15;
@@ -14836,8 +15309,9 @@ function seedContextMeta(config, context) {
14836
15309
  // src/server/pref-helpers.ts
14837
15310
  import * as fs13 from "node:fs/promises";
14838
15311
  import * as path18 from "node:path";
15312
+ import { pluginEntryMatchesName } from "@wrongstack/core/plugin";
14839
15313
  import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets } from "@wrongstack/core/security";
14840
- import { atomicWrite as atomicWrite6, backupConfigFile, FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS2 } from "@wrongstack/core/utils";
15314
+ import { atomicWrite as atomicWrite6, backupConfigFile, FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS3 } from "@wrongstack/core/utils";
14841
15315
  var PREF_KEYS = [
14842
15316
  "autonomy",
14843
15317
  "autonomyDelayMs",
@@ -14847,6 +15321,7 @@ var PREF_KEYS = [
14847
15321
  "chime",
14848
15322
  "confirmExit",
14849
15323
  "nextPrediction",
15324
+ "nextStepsTool",
14850
15325
  "enhanceEnabled",
14851
15326
  "enhanceDelayMs",
14852
15327
  "enhanceLanguage",
@@ -14910,6 +15385,7 @@ var PREF_KEYS = [
14910
15385
  "autoReviewProvider",
14911
15386
  "autoReviewModel",
14912
15387
  "autoReviewFallbackProfile",
15388
+ "autoReviewModelSelection",
14913
15389
  "autoReviewFallbackModels",
14914
15390
  "autoReviewDebounceMs",
14915
15391
  "autoReviewMaxFilesPerBatch",
@@ -14918,6 +15394,8 @@ var PREF_KEYS = [
14918
15394
  // Display-only toggles (purely visual WebUI prefs, not persisted to config).
14919
15395
  "groupToolCalls",
14920
15396
  "showThinkingLogs",
15397
+ // v15: chat-input auto-collapse (opt-in display toggle, default off).
15398
+ "autoCollapseInput",
14921
15399
  // Per-plugin enable/disable map (parity with the embedded server).
14922
15400
  "pluginsEnabled",
14923
15401
  // Fleet chat verbosity: off | full (migrated from streamFleet boolean).
@@ -14972,6 +15450,8 @@ async function updateGlobalConfig(deps2, holder, mutate, errorLabel) {
14972
15450
  var DISPLAY_ONLY_KEYS = /* @__PURE__ */ new Set([
14973
15451
  "groupToolCalls",
14974
15452
  "showThinkingLogs",
15453
+ // v15: chat-input auto-collapse (opt-in display toggle, default off).
15454
+ "autoCollapseInput",
14975
15455
  "autoReviewFallbackModels",
14976
15456
  // v11 Display parity: agent-swarm panel + inverse fsAccess flag.
14977
15457
  // The TUI settings picker mirrors these so the browser can keep the
@@ -15119,6 +15599,11 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15119
15599
  toolsCfg.maxIterations = payload["maxIterations"];
15120
15600
  decrypted.tools = toolsCfg;
15121
15601
  }
15602
+ if (typeof payload["nextStepsTool"] === "boolean") {
15603
+ const toolsCfg = decrypted.tools ?? {};
15604
+ toolsCfg.nextsteps = { enabled: payload["nextStepsTool"] };
15605
+ decrypted.tools = toolsCfg;
15606
+ }
15122
15607
  const hqTouched = typeof payload["hqEnabled"] === "boolean" || typeof payload["hqUrl"] === "string" || typeof payload["hqToken"] === "string" || typeof payload["hqRawContent"] === "boolean";
15123
15608
  if (hqTouched) {
15124
15609
  const hqCfg = decrypted.hq ?? {};
@@ -15182,15 +15667,29 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15182
15667
  decrypted.debugStream = payload["debugStream"];
15183
15668
  if (typeof payload["pluginsEnabled"] === "object" && payload["pluginsEnabled"] !== null) {
15184
15669
  const ext = decrypted.extensions ?? {};
15670
+ const toggled = [];
15185
15671
  for (const [pluginName, enabled] of Object.entries(
15186
15672
  payload["pluginsEnabled"]
15187
15673
  )) {
15188
- if (FORBIDDEN_PROTO_KEYS2.has(pluginName)) continue;
15674
+ if (FORBIDDEN_PROTO_KEYS3.has(pluginName)) continue;
15675
+ if (typeof enabled !== "boolean") continue;
15189
15676
  const pExt = ext[pluginName] ?? {};
15190
15677
  pExt["enabled"] = enabled;
15191
15678
  ext[pluginName] = pExt;
15679
+ toggled.push([pluginName, enabled]);
15192
15680
  }
15193
15681
  decrypted.extensions = ext;
15682
+ if (Array.isArray(decrypted.plugins) && toggled.length > 0) {
15683
+ decrypted.plugins = decrypted.plugins.map((entry) => {
15684
+ const entryName = typeof entry === "string" ? entry : entry?.name;
15685
+ if (typeof entryName !== "string") return entry;
15686
+ const hit = toggled.find(([name2]) => pluginEntryMatchesName(entryName, name2));
15687
+ if (!hit) return entry;
15688
+ const [, enabled] = hit;
15689
+ if (typeof entry === "string") return enabled ? entry : { name: entry, enabled: false };
15690
+ return { ...entry, enabled };
15691
+ });
15692
+ }
15194
15693
  }
15195
15694
  const chimeraTouched = typeof payload["chimeraEnabled"] === "boolean" || typeof payload["chimeraProvider"] === "string" || typeof payload["chimeraModel"] === "string" || typeof payload["chimeraMaxFiles"] === "number" || typeof payload["chimeraAutoFix"] === "string";
15196
15695
  if (chimeraTouched) {
@@ -15212,7 +15711,7 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15212
15711
  ext["wstack-chimera"] = chimera;
15213
15712
  decrypted.extensions = ext;
15214
15713
  }
15215
- const autoReviewTouched = typeof payload["autoReviewEnabled"] === "boolean" || typeof payload["autoReviewProvider"] === "string" || typeof payload["autoReviewModel"] === "string" || typeof payload["autoReviewFallbackProfile"] === "string" || Array.isArray(payload["autoReviewFallbackModels"]) || typeof payload["autoReviewDebounceMs"] === "number" || typeof payload["autoReviewMaxFilesPerBatch"] === "number" || typeof payload["autoReviewMaxConcurrentReviews"] === "number" || typeof payload["autoReviewCascadeOn"] === "string";
15714
+ const autoReviewTouched = typeof payload["autoReviewEnabled"] === "boolean" || typeof payload["autoReviewProvider"] === "string" || typeof payload["autoReviewModel"] === "string" || typeof payload["autoReviewFallbackProfile"] === "string" || typeof payload["autoReviewModelSelection"] === "string" || Array.isArray(payload["autoReviewFallbackModels"]) || typeof payload["autoReviewDebounceMs"] === "number" || typeof payload["autoReviewMaxFilesPerBatch"] === "number" || typeof payload["autoReviewMaxConcurrentReviews"] === "number" || typeof payload["autoReviewCascadeOn"] === "string";
15216
15715
  if (autoReviewTouched) {
15217
15716
  const ext = decrypted.extensions ?? {};
15218
15717
  const ar = ext["wstack-auto-review"] ?? {};
@@ -15229,6 +15728,9 @@ async function persistPrefsToConfig(deps2, holder, payload) {
15229
15728
  ar["fallbackProfile"] = payload["autoReviewFallbackProfile"];
15230
15729
  }
15231
15730
  }
15731
+ if (payload["autoReviewModelSelection"] === "round-robin" || payload["autoReviewModelSelection"] === "random") {
15732
+ ar["modelSelection"] = payload["autoReviewModelSelection"];
15733
+ }
15232
15734
  if (typeof payload["autoReviewDebounceMs"] === "number" && payload["autoReviewDebounceMs"] >= 0) {
15233
15735
  ar["debounceMs"] = payload["autoReviewDebounceMs"];
15234
15736
  }
@@ -15299,39 +15801,6 @@ async function handlePrefsRoute(ws, msg, handlers) {
15299
15801
  // src/server/process-handlers.ts
15300
15802
  import { createCompatibilityTrustBoundary } from "@wrongstack/core/security";
15301
15803
  import { getProcessRegistry as getProcessRegistry2 } from "@wrongstack/tools";
15302
-
15303
- // src/server/privileged-actions.ts
15304
- import { randomUUID as randomUUID4 } from "node:crypto";
15305
- import {
15306
- isTrustDecisionAllowed
15307
- } from "@wrongstack/core/security";
15308
- async function authorizeWebUIAction(boundary, action, logger) {
15309
- const request = {
15310
- version: 1,
15311
- requestId: randomUUID4(),
15312
- actor: {
15313
- kind: "remote-client",
15314
- ...action.sessionId ? { sessionId: action.sessionId } : {}
15315
- },
15316
- surface: "webui",
15317
- capability: action.capability,
15318
- subject: action.subject,
15319
- risk: action.risk,
15320
- scope: {
15321
- ...action.cwd ? { cwd: action.cwd } : {},
15322
- ...action.sessionId ? { sessionId: action.sessionId } : {}
15323
- },
15324
- authContext: { method: "session" },
15325
- ...action.metadata ? { metadata: action.metadata } : {}
15326
- };
15327
- const decision = await boundary.evaluate(request);
15328
- logger?.debug?.(
15329
- `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
15330
- );
15331
- return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
15332
- }
15333
-
15334
- // src/server/process-handlers.ts
15335
15804
  function handleProcessList(ws) {
15336
15805
  try {
15337
15806
  const procs = getProcessRegistry2().list();
@@ -15630,6 +16099,7 @@ function createProjectHandlers(ctx) {
15630
16099
  ctx.context.session = next;
15631
16100
  ctx.context.state.replaceMessages([]);
15632
16101
  ctx.context.state.replaceTodos([]);
16102
+ ctx.context.clearMemoryEvidence?.();
15633
16103
  ctx.context.readFiles.clear();
15634
16104
  ctx.context.fileMtimes.clear();
15635
16105
  ctx.tokenCounter.reset();
@@ -15674,7 +16144,7 @@ function createProjectHandlers(ctx) {
15674
16144
  }
15675
16145
 
15676
16146
  // src/server/provider-handlers.ts
15677
- import { resolveProviderModelList } from "@wrongstack/core/models";
16147
+ import { hasProviderCredential, resolveProviderModelList } from "@wrongstack/core/models";
15678
16148
  import { DefaultSecretScrubber as DefaultSecretScrubber2 } from "@wrongstack/core/security";
15679
16149
  import { toErrorMessage as toErrorMessage6 } from "@wrongstack/core/utils";
15680
16150
  import {
@@ -15981,7 +16451,7 @@ function createProviderOperations(deps2) {
15981
16451
  }
15982
16452
  try {
15983
16453
  const providers = await deps2.modelsRegistry.listProviders();
15984
- const savedIds = new Set(Object.keys(await loadConfigProviders()));
16454
+ const savedProviders = await loadConfigProviders();
15985
16455
  sendMessage(ws, {
15986
16456
  type: "provider.catalog",
15987
16457
  payload: {
@@ -15992,7 +16462,7 @@ function createProviderOperations(deps2) {
15992
16462
  apiBase: provider.apiBase,
15993
16463
  envVars: provider.envVars,
15994
16464
  modelCount: provider.models.length,
15995
- hasApiKey: savedIds.has(provider.id) || provider.envVars.some((name2) => !!process.env[name2])
16465
+ hasApiKey: hasProviderCredential(provider, { providers: savedProviders })
15996
16466
  }))
15997
16467
  }
15998
16468
  });
@@ -16155,8 +16625,10 @@ function createProviderOperations(deps2) {
16155
16625
  if (result.ok) {
16156
16626
  deps2.log?.(`[WebUI] Provider "${payload.id}" added via provider.add`);
16157
16627
  }
16628
+ return result.ok;
16158
16629
  } catch (err) {
16159
16630
  sendOperationResult(ws, false, errMessage(err));
16631
+ return false;
16160
16632
  }
16161
16633
  }
16162
16634
  async function handleProviderRemove(ws, providerId) {
@@ -16537,9 +17009,11 @@ var CLIENT_CONVERSATION_MESSAGE_TYPES = [
16537
17009
  "ping",
16538
17010
  "user_message",
16539
17011
  "tool.confirm_result",
17012
+ "topic.advice",
16540
17013
  "completion.request",
16541
17014
  "model.switch",
16542
17015
  "model.refine",
17016
+ "model.fallback_choice",
16543
17017
  "autonomy.switch",
16544
17018
  "context.clear",
16545
17019
  "context.compact",
@@ -16811,6 +17285,7 @@ var SERVER_CONVERSATION_MESSAGE_TYPES = [
16811
17285
  "provider.active_blocked",
16812
17286
  "provider.error",
16813
17287
  "provider.fallback",
17288
+ "provider.fallback_pending",
16814
17289
  "provider.response",
16815
17290
  "provider.retry",
16816
17291
  "provider.status_changed",
@@ -16837,6 +17312,7 @@ var SERVER_CONVERSATION_MESSAGE_TYPES = [
16837
17312
  "tool.loop_detected",
16838
17313
  "tool.progress",
16839
17314
  "tool.started",
17315
+ "topic.advice_result",
16840
17316
  "tools.list",
16841
17317
  "trust.persisted"
16842
17318
  ];
@@ -16852,6 +17328,7 @@ var SERVER_COLLABORATION_MESSAGE_TYPES = [
16852
17328
  "collab.state",
16853
17329
  "mailbox.action_result",
16854
17330
  "mailbox.agent_registered",
17331
+ "mailbox.agent_deregistered",
16855
17332
  "mailbox.agents",
16856
17333
  "mailbox.cleared",
16857
17334
  "mailbox.compacted",
@@ -17231,6 +17708,13 @@ function projectToolMessage(message) {
17231
17708
  }
17232
17709
  return null;
17233
17710
  }
17711
+ function optionalFinite(value) {
17712
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
17713
+ return value;
17714
+ }
17715
+ function optionalString2(value) {
17716
+ return typeof value === "string" && value.length > 0 ? value : void 0;
17717
+ }
17234
17718
  function projectFleetMessage(message) {
17235
17719
  const payload = record(message.payload);
17236
17720
  if (!payload) return null;
@@ -17239,7 +17723,15 @@ function projectFleetMessage(message) {
17239
17723
  return {
17240
17724
  kind: "concurrency",
17241
17725
  active: finite(payload["fleetConcurrency"]),
17242
- maximum: finite(payload["fleetConcurrencyMax"])
17726
+ maximum: finite(payload["fleetConcurrencyMax"]),
17727
+ maxSpawns: optionalFinite(payload["maxSpawns"]),
17728
+ usedSpawns: optionalFinite(payload["usedSpawns"]),
17729
+ remainingSpawns: optionalFinite(payload["remainingSpawns"]),
17730
+ maxSpawnsSource: optionalString2(payload["maxSpawnsSource"]),
17731
+ maxConcurrentSource: optionalString2(payload["maxConcurrentSource"]),
17732
+ effectiveSource: optionalString2(payload["effectiveSource"]),
17733
+ checkpointMaxSpawns: optionalFinite(payload["checkpointMaxSpawns"]),
17734
+ ceilingMismatch: payload["ceilingMismatch"] === true ? true : void 0
17243
17735
  };
17244
17736
  case "client.status_update":
17245
17737
  return { kind: "client-status", status: payload };
@@ -17330,6 +17822,8 @@ var SURFACE_PROTOCOL_CAPABILITIES = [
17330
17822
  "chronicle.metrics",
17331
17823
  "chronicle.status",
17332
17824
  "connections.health",
17825
+ /** Bounded topic-shift advice plus same-session provider-context boundaries. */
17826
+ "context.topic-boundary",
17333
17827
  /** Interview resume/discard + lastAgentText/lastRunId continuity. */
17334
17828
  "sdd.interview.continuity",
17335
17829
  /** Launch multi-agent runs from a graph id or resolved spec id. */
@@ -17734,6 +18228,7 @@ function createSessionHandlers(ctx) {
17734
18228
  await ctx.onBeforeSessionTodosReplaced?.(next.id, sessionsDirectory());
17735
18229
  ctx.context.state.replaceTodos(todos);
17736
18230
  resetContextAccounting();
18231
+ ctx.context.clearMemoryEvidence?.();
17737
18232
  ctx.context.readFiles.clear();
17738
18233
  ctx.context.fileMtimes.clear();
17739
18234
  ctx.context.state.setMeta?.(
@@ -17781,6 +18276,7 @@ function createSessionHandlers(ctx) {
17781
18276
  ctx.context.state.replaceMessages([]);
17782
18277
  ctx.context.state.replaceTodos([]);
17783
18278
  resetContextAccounting();
18279
+ ctx.context.clearMemoryEvidence?.();
17784
18280
  ctx.context.readFiles.clear();
17785
18281
  ctx.context.fileMtimes.clear();
17786
18282
  ctx.tokenCounter.reset?.();
@@ -17795,6 +18291,7 @@ function createSessionHandlers(ctx) {
17795
18291
  ctx.context.state.replaceMessages([]);
17796
18292
  ctx.context.state.replaceTodos([]);
17797
18293
  resetContextAccounting();
18294
+ ctx.context.clearMemoryEvidence?.();
17798
18295
  ctx.context.readFiles.clear();
17799
18296
  ctx.context.fileMtimes.clear();
17800
18297
  ctx.tokenCounter.reset?.();
@@ -17895,6 +18392,7 @@ function createSessionHandlers(ctx) {
17895
18392
  tools: ctx.listTools?.() ?? ctx.toolRegistry?.list(),
17896
18393
  baseRevision: typeof payload["baseRevision"] === "string" ? payload["baseRevision"] : "",
17897
18394
  messages: payload["messages"],
18395
+ removals: payload["removals"],
17898
18396
  allowRepair: payload["allowRepair"] === true,
17899
18397
  runActive: ctx.isRunActive?.() === true
17900
18398
  });
@@ -17911,6 +18409,7 @@ function createSessionHandlers(ctx) {
17911
18409
  tools: ctx.listTools?.() ?? ctx.toolRegistry?.list(),
17912
18410
  baseRevision: typeof payload["baseRevision"] === "string" ? payload["baseRevision"] : "",
17913
18411
  messages: payload["messages"],
18412
+ removals: payload["removals"],
17914
18413
  allowRepair: payload["allowRepair"] === true,
17915
18414
  runActive: ctx.isRunActive?.() === true
17916
18415
  });
@@ -18892,6 +19391,19 @@ async function handleCodebaseIndexServerControl(ws, message, deps2) {
18892
19391
  return true;
18893
19392
  }
18894
19393
 
19394
+ // src/server/fallback-choice.ts
19395
+ function emitFallbackChoice(events, msg) {
19396
+ const parsed = validateModelFallbackChoicePayload(msg.payload);
19397
+ if (!parsed.ok) return parsed;
19398
+ events?.emit("provider.fallback_choice", {
19399
+ requestId: parsed.value.requestId,
19400
+ ...parsed.value.providerId ? { providerId: parsed.value.providerId } : {},
19401
+ ...parsed.value.model ? { model: parsed.value.model } : {},
19402
+ ...parsed.value.autoSwitch ? { autoSwitch: true } : {}
19403
+ });
19404
+ return { ok: true };
19405
+ }
19406
+
18895
19407
  // src/server/agent-roster-routes.ts
18896
19408
  async function handleAgentRosterRoute(ws, msg, handlers) {
18897
19409
  if (!msg.type.startsWith("agent-roster.")) return false;
@@ -19056,13 +19568,17 @@ async function handleProviderRoute(ws, msg, routes) {
19056
19568
  case "model.refine":
19057
19569
  await routes.refineModel(ws, msg);
19058
19570
  return true;
19571
+ case "model.fallback_choice":
19572
+ await routes.fallbackChoice(ws, msg);
19573
+ return true;
19059
19574
  case "key.add":
19060
19575
  case "key.update": {
19061
19576
  const payload = asPayloadRecord(msg);
19062
19577
  const providerId = payload ? requiredString(payload, "providerId") : null;
19063
19578
  const label = payload ? requiredString(payload, "label") : null;
19064
19579
  const apiKey = payload ? requiredString(payload, "apiKey") : null;
19065
- if (!providerId || !label || !apiKey) return invalidPayload(ws, msg.type);
19580
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label || !apiKey)
19581
+ return invalidPayload(ws, msg.type);
19066
19582
  await routes.providerHandlers.handleKeyUpsert(ws, providerId, label, apiKey);
19067
19583
  return true;
19068
19584
  }
@@ -19070,7 +19586,8 @@ async function handleProviderRoute(ws, msg, routes) {
19070
19586
  const payload = asPayloadRecord(msg);
19071
19587
  const providerId = payload ? requiredString(payload, "providerId") : null;
19072
19588
  const label = payload ? requiredString(payload, "label") : null;
19073
- if (!providerId || !label) return invalidPayload(ws, msg.type);
19589
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label)
19590
+ return invalidPayload(ws, msg.type);
19074
19591
  await routes.providerHandlers.handleKeyDelete(ws, providerId, label);
19075
19592
  return true;
19076
19593
  }
@@ -19078,7 +19595,8 @@ async function handleProviderRoute(ws, msg, routes) {
19078
19595
  const payload = asPayloadRecord(msg);
19079
19596
  const providerId = payload ? requiredString(payload, "providerId") : null;
19080
19597
  const label = payload ? requiredString(payload, "label") : null;
19081
- if (!providerId || !label) return invalidPayload(ws, msg.type);
19598
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label)
19599
+ return invalidPayload(ws, msg.type);
19082
19600
  await routes.providerHandlers.handleKeySetActive(ws, providerId, label);
19083
19601
  return true;
19084
19602
  }
@@ -19090,11 +19608,11 @@ async function handleProviderRoute(ws, msg, routes) {
19090
19608
  const apiKey = payload?.["apiKey"];
19091
19609
  const models = payload ? optionalStringArray(payload, "models") : null;
19092
19610
  const customModels = payload ? optionalCustomModels(payload) : null;
19093
- if (!id || !family) return invalidPayload(ws, msg.type);
19611
+ if (!id || !SAFE_CONFIG_KEY.test(id) || !family) return invalidPayload(ws, msg.type);
19094
19612
  if (baseUrl !== void 0 && typeof baseUrl !== "string") return invalidPayload(ws, msg.type);
19095
19613
  if (apiKey !== void 0 && typeof apiKey !== "string") return invalidPayload(ws, msg.type);
19096
19614
  if (models === null || customModels === null) return invalidPayload(ws, msg.type);
19097
- await routes.providerHandlers.handleProviderAdd(ws, {
19615
+ const added = await routes.providerHandlers.handleProviderAdd(ws, {
19098
19616
  id,
19099
19617
  family,
19100
19618
  baseUrl,
@@ -19102,20 +19620,22 @@ async function handleProviderRoute(ws, msg, routes) {
19102
19620
  models,
19103
19621
  customModels
19104
19622
  });
19105
- await routes.adoptDefaultProviderIfUnset(id);
19623
+ if (added) {
19624
+ void routes.adoptDefaultProviderIfUnset(id).catch(() => void 0);
19625
+ }
19106
19626
  return true;
19107
19627
  }
19108
19628
  case "provider.remove": {
19109
19629
  const payload = asPayloadRecord(msg);
19110
19630
  const providerId = payload ? requiredString(payload, "providerId") : null;
19111
- if (!providerId) return invalidPayload(ws, msg.type);
19631
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId)) return invalidPayload(ws, msg.type);
19112
19632
  await routes.providerHandlers.handleProviderRemove(ws, providerId);
19113
19633
  return true;
19114
19634
  }
19115
19635
  case "provider.clear_models": {
19116
19636
  const payload = asPayloadRecord(msg);
19117
19637
  const providerId = payload ? requiredString(payload, "providerId") : null;
19118
- if (!providerId) return invalidPayload(ws, msg.type);
19638
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId)) return invalidPayload(ws, msg.type);
19119
19639
  await routes.providerHandlers.handleProviderClearModels(ws, providerId);
19120
19640
  return true;
19121
19641
  }
@@ -19147,7 +19667,8 @@ async function handleProviderRoute(ws, msg, routes) {
19147
19667
  const payload = asPayloadRecord(msg);
19148
19668
  const providerId = payload ? requiredString(payload, "providerId") : null;
19149
19669
  const previousModels = payload ? optionalStringArray(payload, "previousModels") : null;
19150
- if (!providerId || !previousModels) return invalidPayload(ws, msg.type);
19670
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !previousModels)
19671
+ return invalidPayload(ws, msg.type);
19151
19672
  await routes.providerHandlers.handleProviderUndoClear(ws, providerId, previousModels);
19152
19673
  return true;
19153
19674
  }
@@ -19157,7 +19678,7 @@ async function handleProviderRoute(ws, msg, routes) {
19157
19678
  const envVars = payload ? optionalStringArray(payload, "envVars") : null;
19158
19679
  const models = payload ? optionalStringArray(payload, "models") : null;
19159
19680
  const customModels = payload ? optionalCustomModels(payload) : null;
19160
- if (!payload || !id || envVars === null || models === null || customModels === null)
19681
+ if (!payload || !id || !SAFE_CONFIG_KEY.test(id) || envVars === null || models === null || customModels === null)
19161
19682
  return invalidPayload(ws, msg.type);
19162
19683
  for (const key of ["family", "baseUrl"]) {
19163
19684
  if (payload[key] !== void 0 && typeof payload[key] !== "string")
@@ -19177,7 +19698,8 @@ async function handleProviderRoute(ws, msg, routes) {
19177
19698
  const payload = asPayloadRecord(msg);
19178
19699
  const providerId = payload ? requiredString(payload, "providerId") : null;
19179
19700
  const timeoutMs = payload ? optionalNumber(payload, "timeoutMs") : null;
19180
- if (!providerId || timeoutMs === null) return invalidPayload(ws, msg.type);
19701
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || timeoutMs === null)
19702
+ return invalidPayload(ws, msg.type);
19181
19703
  await routes.providerHandlers.handleProviderProbe(ws, providerId, timeoutMs);
19182
19704
  return true;
19183
19705
  }
@@ -19186,7 +19708,7 @@ async function handleProviderRoute(ws, msg, routes) {
19186
19708
  const kind = oauthKind(payload);
19187
19709
  const providerId = payload?.["providerId"];
19188
19710
  if (!kind) return invalidPayload(ws, msg.type);
19189
- if (providerId !== void 0 && typeof providerId !== "string") {
19711
+ if (providerId !== void 0 && (typeof providerId !== "string" || !SAFE_CONFIG_KEY.test(providerId))) {
19190
19712
  return invalidPayload(ws, msg.type);
19191
19713
  }
19192
19714
  await routes.providerHandlers.handleOAuthStart(ws, kind, providerId);
@@ -19225,7 +19747,8 @@ async function handleProviderRoute(ws, msg, routes) {
19225
19747
  const payload = asPayloadRecord(msg);
19226
19748
  const providerId = payload ? requiredString(payload, "providerId") : null;
19227
19749
  const model = payload ? requiredString(payload, "model") : null;
19228
- if (!providerId || !model) return invalidPayload(ws, msg.type);
19750
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !model)
19751
+ return invalidPayload(ws, msg.type);
19229
19752
  const released = routes.statusTracker.retryNow(providerId, model);
19230
19753
  sendResult2(
19231
19754
  ws,
@@ -19242,7 +19765,8 @@ async function handleProviderRoute(ws, msg, routes) {
19242
19765
  const payload = asPayloadRecord(msg);
19243
19766
  const providerId = payload ? requiredString(payload, "providerId") : null;
19244
19767
  const model = payload ? requiredString(payload, "model") : null;
19245
- if (!providerId || !model) return invalidPayload(ws, msg.type);
19768
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !model)
19769
+ return invalidPayload(ws, msg.type);
19246
19770
  routes.statusTracker.clear(providerId, model);
19247
19771
  sendResult2(ws, true, `Cleared tracking for ${providerId}/${model}.`);
19248
19772
  return true;
@@ -19553,6 +20077,7 @@ function createEmbeddedMessageRouter(deps2) {
19553
20077
  };
19554
20078
  const guardedTypes = /* @__PURE__ */ new Set([
19555
20079
  "user_message",
20080
+ "topic.advice",
19556
20081
  "abort",
19557
20082
  "tool.confirm_result",
19558
20083
  "session.new",
@@ -19600,8 +20125,10 @@ function createEmbeddedMessageRouter(deps2) {
19600
20125
  };
19601
20126
  const mcp = {
19602
20127
  list: (ws, msg) => handleMcpList(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
19603
- add: (ws, msg) => handleMcpAdd(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
19604
- update: (ws, msg) => handleMcpUpdate(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
20128
+ // add/update are the spawn-capable pair they take a `command`/`args`
20129
+ // from the wire and start it. They go past the trust boundary (M1).
20130
+ add: (ws, msg) => handleMcpAdd(ws, msg, opts.profileConfigPath, opts.mcpRegistry, deps2.trustBoundary),
20131
+ update: (ws, msg) => handleMcpUpdate(ws, msg, opts.profileConfigPath, opts.mcpRegistry, deps2.trustBoundary),
19605
20132
  remove: (ws, msg) => handleMcpRemove(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
19606
20133
  enable: (ws, msg) => handleMcpEnable(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
19607
20134
  disable: (ws, msg) => handleMcpDisable(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
@@ -19688,6 +20215,15 @@ function createEmbeddedMessageRouter(deps2) {
19688
20215
  searchProviderModels: (ws, query, limit) => providerOperations.handleProviderModelsSearch(ws, query, limit),
19689
20216
  switchModel: (ws, msg) => modelOperations.switchModel(ws, msg.payload),
19690
20217
  refineModel: (ws, msg) => modelOperations.refineModel(ws, msg.payload),
20218
+ fallbackChoice: async (ws, msg) => {
20219
+ const result = emitFallbackChoice(deps2.sessionCtx.opts.events, msg);
20220
+ if (!result.ok) {
20221
+ send2(ws, {
20222
+ type: "error",
20223
+ payload: { phase: "invalid_request", message: result.message }
20224
+ });
20225
+ }
20226
+ },
19691
20227
  adoptDefaultProviderIfUnset: providerOperations.adoptDefaultProviderIfUnset,
19692
20228
  providerHandlers: providerOperations,
19693
20229
  statusTracker: deps2.statusTracker
@@ -19869,6 +20405,8 @@ function createEmbeddedMessageRouter(deps2) {
19869
20405
  ))
19870
20406
  return;
19871
20407
  if (await handleConnectionsServiceAction(ws, message, {
20408
+ trustBoundary: deps2.trustBoundary,
20409
+ logger: deps2.logger,
19872
20410
  getProjectRoot: projectRoot,
19873
20411
  getIndexDir: () => typeof opts.agent.ctx.meta["codebaseIndexDir"] === "string" ? opts.agent.ctx.meta["codebaseIndexDir"] : void 0,
19874
20412
  send: send2,
@@ -19981,17 +20519,12 @@ function createProviderStore(deps2) {
19981
20519
  cfg.activeKey = active.label;
19982
20520
  }
19983
20521
  }
19984
- function maskedKey2(key) {
19985
- if (!key) return "\u2014";
19986
- if (key.length <= 8) return "\u2022".repeat(key.length);
19987
- return `${key.slice(0, 4)}\u2026${key.slice(-4)}`;
19988
- }
19989
20522
  return {
19990
20523
  load: loadSavedProviders2,
19991
20524
  save: saveProviders2,
19992
20525
  normalizeKeys: normalizeKeys2,
19993
20526
  writeKeysBack: writeKeysBack2,
19994
- maskedKey: maskedKey2
20527
+ maskedKey
19995
20528
  };
19996
20529
  }
19997
20530
 
@@ -20860,7 +21393,7 @@ function registerSetupEventsClientStatusWriter(deps2) {
20860
21393
  const on = (event, listener) => events.on(event, listener);
20861
21394
  return on("client.status", async (e) => {
20862
21395
  broadcast2(clients, { type: "client.status_update", payload: e });
20863
- if (wpaths?.projectStatus) {
21396
+ if (wpaths?.projectStatus && e.projectHash !== "unknown") {
20864
21397
  try {
20865
21398
  const statusFile = wpaths.projectStatus(e.projectHash);
20866
21399
  const dir = path24.dirname(statusFile);
@@ -21035,7 +21568,10 @@ function registerSetupEventsProviderHandlers({
21035
21568
  sessionId: e.sessionId,
21036
21569
  providerId: e.providerId,
21037
21570
  modelId: e.modelId,
21038
- maxContext: e.maxContext
21571
+ maxContext: e.maxContext,
21572
+ ...e.previousMaxContext !== void 0 ? { previousMaxContext: e.previousMaxContext } : {},
21573
+ ...e.source !== void 0 ? { source: e.source } : {},
21574
+ ...e.decreased !== void 0 ? { decreased: e.decreased } : {}
21039
21575
  })
21040
21576
  });
21041
21577
  });
@@ -21732,7 +22268,22 @@ function setupEvents(deps2) {
21732
22268
  from: e.from,
21733
22269
  to: e.to,
21734
22270
  status: e.status,
21735
- providerSwitched: e.providerSwitched
22271
+ providerSwitched: e.providerSwitched,
22272
+ ...e.requestId ? { requestId: e.requestId } : {}
22273
+ })
22274
+ });
22275
+ });
22276
+ on("provider.fallback_pending", (e) => {
22277
+ broadcast2(clients, {
22278
+ type: "provider.fallback_pending",
22279
+ payload: sessionPayload2({
22280
+ sessionId: e.sessionId,
22281
+ from: e.from,
22282
+ status: e.status,
22283
+ candidates: e.candidates,
22284
+ autoSwitchSeconds: e.autoSwitchSeconds,
22285
+ requestId: e.requestId,
22286
+ timestamp: e.timestamp
21736
22287
  })
21737
22288
  });
21738
22289
  });
@@ -21817,6 +22368,15 @@ function setupEvents(deps2) {
21817
22368
  type: "mailbox.agent_registered",
21818
22369
  payload
21819
22370
  });
22371
+ }),
22372
+ // Deregistration (subagent retirement) must reach the browser too —
22373
+ // otherwise dead agents linger in the client roster until an unrelated
22374
+ // refresh. Emitted by sqlite-mailbox.deregisterAgent with { agentId }.
22375
+ events.onPattern("mailbox.agent_deregistered", (_e, payload) => {
22376
+ broadcast2(clients, {
22377
+ type: "mailbox.agent_deregistered",
22378
+ payload
22379
+ });
21820
22380
  })
21821
22381
  );
21822
22382
  const forwardSubagent = (kind, payload) => broadcast2(clients, { type: "subagent.event", payload: sessionPayload2({ kind, ...payload }) });
@@ -22121,7 +22681,15 @@ var SpecsWebSocketHandler = class {
22121
22681
  this.clients.add(client);
22122
22682
  ws.on("close", () => this.clients.delete(client));
22123
22683
  ws.on("error", () => this.clients.delete(client));
22124
- void this.sendList(client);
22684
+ void this.sendList(client).catch((err) => {
22685
+ console.warn(
22686
+ JSON.stringify({
22687
+ level: "warn",
22688
+ event: "specs.initial_send_failed",
22689
+ message: err instanceof Error ? err.message : String(err)
22690
+ })
22691
+ );
22692
+ });
22125
22693
  }
22126
22694
  dispose() {
22127
22695
  this.clients.clear();
@@ -22335,15 +22903,17 @@ import {
22335
22903
 
22336
22904
  // src/server/discover-mailbox-bridge.ts
22337
22905
  import { spawn as spawn4 } from "node:child_process";
22338
- import { createRequire } from "node:module";
22339
22906
  import { existsSync as existsSync2 } from "node:fs";
22907
+ import { createRequire } from "node:module";
22340
22908
  import { dirname as dirname10, join as join13 } from "node:path";
22341
- import { resolveProjectDir as resolveProjectDir3 } from "@wrongstack/core/coordination";
22909
+ import {
22910
+ readLiveLock,
22911
+ resolveProjectDir as resolveProjectDir3
22912
+ } from "@wrongstack/core/coordination";
22342
22913
  import { wstackGlobalRoot as wstackGlobalRoot3 } from "@wrongstack/core/utils";
22343
- import { readLiveLock } from "@wrongstack/core/coordination";
22344
22914
  var MAILBOX_BRIDGE_BOOT_TIMEOUT_MS = 5e3;
22345
22915
  async function discoverMailboxBridgeForWebui(params) {
22346
- const mode = params.config?.features?.mailboxBridge ?? "auto";
22916
+ const mode = params.config?.features?.mailboxBridge ?? "off";
22347
22917
  if (mode === "off") return;
22348
22918
  const projectDir = resolveProjectDir3(params.projectRoot, wstackGlobalRoot3());
22349
22919
  let result = await readLiveLock(projectDir);
@@ -22605,6 +23175,13 @@ var TerminalWebSocketHandler = class {
22605
23175
  this.send(ws, { type: "terminal.exit", payload: { id: payload.id, exitCode: -1 } });
22606
23176
  return;
22607
23177
  }
23178
+ if (this.sessions.get(ws) !== map) {
23179
+ this.logger.info?.(
23180
+ `terminal.create raced a disconnect (id=${payload.id}) \u2014 killing the orphan`
23181
+ );
23182
+ this.killPty(pty, "terminal create after disconnect");
23183
+ return;
23184
+ }
22608
23185
  map.set(payload.id, pty);
22609
23186
  this.logger.info?.(`terminal.create spawned (id=${payload.id}, pid=${pty.pid ?? "?"}) in ${cwd}`);
22610
23187
  pty.onData((data) => {
@@ -24030,6 +24607,15 @@ function createMessageDispatcher(opts) {
24030
24607
  msg
24031
24608
  ))
24032
24609
  return;
24610
+ if (await handleConnectionsServiceAction(ws, msg, {
24611
+ trustBoundary: deps2.trustBoundary,
24612
+ logger: deps2.logger,
24613
+ getProjectRoot: state.getProjectRoot,
24614
+ getIndexDir: () => typeof deps2.context.meta["codebaseIndexDir"] === "string" ? deps2.context.meta["codebaseIndexDir"] : void 0,
24615
+ send,
24616
+ backend: "standalone"
24617
+ }))
24618
+ return;
24033
24619
  if (await handleCodebaseIndexServerControl(ws, msg, {
24034
24620
  trustBoundary: deps2.trustBoundary,
24035
24621
  logger: deps2.logger,
@@ -24101,29 +24687,10 @@ import { attachSessionKanbanMirror, hydrateSessionKanban } from "@wrongstack/too
24101
24687
  // src/server/model-auto-discovery.ts
24102
24688
  import * as fs21 from "node:fs/promises";
24103
24689
  import * as path29 from "node:path";
24104
- import { COMPATIBLE_PRESETS, discoverOpenAICompatibleModels } from "@wrongstack/providers";
24690
+ import { discoverOpenAICompatibleModels, resolveDiscoveryTargets } from "@wrongstack/providers";
24105
24691
  function isOverlayRegistry(value) {
24106
24692
  return !!value && typeof value === "object" && typeof value.mergeOverlay === "function";
24107
24693
  }
24108
- function resolveKey(cfg) {
24109
- if (Array.isArray(cfg.apiKeys) && cfg.apiKeys.length > 0) {
24110
- const active = cfg.activeKey ? cfg.apiKeys.find((key) => key.label === cfg.activeKey) : void 0;
24111
- return (active ?? cfg.apiKeys[0])?.apiKey;
24112
- }
24113
- return cfg.apiKey && cfg.apiKey.length > 0 ? cfg.apiKey : void 0;
24114
- }
24115
- function eligibleProviders(config) {
24116
- const out = [];
24117
- for (const [id, cfg] of Object.entries(config.providers ?? {})) {
24118
- const preset = COMPATIBLE_PRESETS[id];
24119
- const enabled = cfg.autoDiscoverModels ?? preset?.autoDiscover ?? false;
24120
- if (!enabled) continue;
24121
- const baseUrl = cfg.baseUrl ?? preset?.defaultBaseUrl;
24122
- if (!baseUrl) continue;
24123
- out.push({ id, cfg, baseUrl, apiKey: resolveKey(cfg) });
24124
- }
24125
- return out;
24126
- }
24127
24694
  async function readCache(file) {
24128
24695
  try {
24129
24696
  return JSON.parse(await fs21.readFile(file, "utf8"));
@@ -24134,14 +24701,13 @@ async function readCache(file) {
24134
24701
  async function discoverAndMergeWebuiProviders(opts) {
24135
24702
  const registry = opts.registry;
24136
24703
  if (!isOverlayRegistry(registry)) return;
24137
- const targets = eligibleProviders(opts.config);
24704
+ const targets = resolveDiscoveryTargets(opts.config);
24138
24705
  if (targets.length === 0) return;
24139
24706
  const cacheFile = path29.join(opts.cacheDir, "discovered-models-cache.json");
24140
24707
  const cache2 = await readCache(cacheFile);
24141
24708
  let cacheDirty = false;
24142
24709
  await Promise.all(
24143
- targets.map(async ({ id, cfg, baseUrl, apiKey }) => {
24144
- const cacheKey = `${id}\0${baseUrl}`;
24710
+ targets.map(async ({ id, cfg, baseUrl, apiKey, cacheKey }) => {
24145
24711
  const provider = await discoverOpenAICompatibleModels(id, {
24146
24712
  baseUrl,
24147
24713
  apiKey,
@@ -24507,15 +25073,6 @@ async function createPreContextServices(input) {
24507
25073
  logger.warn(`models.dev refresh failed (${toErrorMessage12(err)}); using cached catalog`);
24508
25074
  }
24509
25075
  }
24510
- try {
24511
- await installCatalogModelOutputLimits({
24512
- registry: modelsRegistry,
24513
- getConfig: () => config,
24514
- log: (message) => logger.debug(message)
24515
- });
24516
- } catch (err) {
24517
- logger.debug(`model output-limit index skipped: ${toErrorMessage12(err)}`);
24518
- }
24519
25076
  try {
24520
25077
  await discoverAndMergeWebuiProviders({
24521
25078
  config,
@@ -24526,6 +25083,15 @@ async function createPreContextServices(input) {
24526
25083
  } catch (err) {
24527
25084
  logger.debug(`provider auto-discovery skipped: ${toErrorMessage12(err)}`);
24528
25085
  }
25086
+ try {
25087
+ await installCatalogModelOutputLimits({
25088
+ registry: modelsRegistry,
25089
+ getConfig: () => config,
25090
+ log: (message) => logger.debug(message)
25091
+ });
25092
+ } catch (err) {
25093
+ logger.debug(`model output-limit index skipped: ${toErrorMessage12(err)}`);
25094
+ }
24529
25095
  const events = opts.services?.events ?? new EventBus();
24530
25096
  events.setLogger(logger);
24531
25097
  const container = createDefaultContainer({ config, wpaths, logger, modelsRegistry, events });
@@ -24556,6 +25122,7 @@ async function createPreContextServices(input) {
24556
25122
  registry: toolRegistry,
24557
25123
  tier: normalizeTokenSavingTier(config.features.tokenSavingMode),
24558
25124
  memory: { enabled: config.features.memory, store: memoryStore },
25125
+ nextSteps: { enabled: config.tools?.nextsteps?.enabled === true },
24559
25126
  coordinationTools: [
24560
25127
  makeMailboxTool({ projectDir: wpaths.projectDir, events }),
24561
25128
  makeMailSendTool({ projectDir: wpaths.projectDir, events }),
@@ -24912,7 +25479,16 @@ function buildRoutes(state, deps2, cb) {
24912
25479
  refineModel: (ws, msg) => modelOperations.refineModel(
24913
25480
  ws,
24914
25481
  msg.payload
24915
- )
25482
+ ),
25483
+ fallbackChoice: async (ws, msg) => {
25484
+ const result = emitFallbackChoice(deps2.events, msg);
25485
+ if (!result.ok) {
25486
+ send(ws, {
25487
+ type: "error",
25488
+ payload: { phase: "invalid_request", message: result.message }
25489
+ });
25490
+ }
25491
+ }
24916
25492
  };
24917
25493
  const sessionRoutes = createSessionHandlers({
24918
25494
  config: state.getConfig(),
@@ -25090,8 +25666,10 @@ function buildRoutes(state, deps2, cb) {
25090
25666
  });
25091
25667
  const mcpRoutes = {
25092
25668
  list: (ws, msg) => handleMcpList(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25093
- add: (ws, msg) => handleMcpAdd(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25094
- update: (ws, msg) => handleMcpUpdate(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25669
+ // add/update are the spawn-capable pair they take a `command`/`args`
25670
+ // from the wire and start it. They go past the trust boundary (M1).
25671
+ add: (ws, msg) => handleMcpAdd(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry, deps2.trustBoundary),
25672
+ update: (ws, msg) => handleMcpUpdate(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry, deps2.trustBoundary),
25095
25673
  remove: (ws, msg) => handleMcpRemove(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25096
25674
  enable: (ws, msg) => handleMcpEnable(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25097
25675
  disable: (ws, msg) => handleMcpDisable(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
@@ -25263,7 +25841,10 @@ function createWsServers(httpServer, ports, accessToken) {
25263
25841
  expectedToken: wsToken,
25264
25842
  requireToken: ports.requireToken,
25265
25843
  allowedHostnames: publicHostnames,
25266
- allowBrowserUrlToken: Boolean(ports.publicWsUrl)
25844
+ allowBrowserUrlToken: Boolean(ports.publicWsUrl),
25845
+ // WS-003 opt-out for the Vite dev loop only (app and WS server cannot
25846
+ // share a port). Off unless explicitly requested — see ws-auth.ts.
25847
+ allowCrossPortLoopbackCookie: process.env["WRONGSTACK_WEBUI_DEV_CROSS_PORT_WS"] === "1"
25267
25848
  });
25268
25849
  const WS_MAX_PAYLOAD = 20 * 1024 * 1024;
25269
25850
  const wssPrimary = new WebSocketServer({
@@ -25729,7 +26310,8 @@ async function startWebUI(opts = {}) {
25729
26310
  watcherMetricsRef
25730
26311
  );
25731
26312
  httpServer.listen(httpPort, wsHost, () => {
25732
- console.log(`[WebUI] HTTP server running on http://${wsHost}:${httpPort}`);
26313
+ const tokenQuery = accessToken ? `/?token=${encodeURIComponent(accessToken)}` : "";
26314
+ console.log(`[WebUI] HTTP server running on http://${wsHost}:${httpPort}${tokenQuery}`);
25733
26315
  const extraUrls = formatExternalAccessUrls({
25734
26316
  bindHost: wsHost,
25735
26317
  port: httpPort,
@@ -25751,8 +26333,11 @@ async function startWebUI(opts = {}) {
25751
26333
  (req, socket, head2) => httpServer.emit("upgrade", req, socket, head2)
25752
26334
  );
25753
26335
  companionServer.on("error", (err) => {
25754
- if (err.code !== "EAFNOSUPPORT" && err.code !== "EADDRNOTAVAIL" && err.code !== "EADDRINUSE") {
25755
- throw err;
26336
+ const expected = err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL" || err.code === "EADDRINUSE";
26337
+ if (!expected) {
26338
+ console.warn(
26339
+ `[WebUI] companion listener on ${companionLabel} failed (${err.code ?? "unknown"}): ${err.message}. The primary address is unaffected.`
26340
+ );
25756
26341
  }
25757
26342
  });
25758
26343
  companionServer.listen(httpPort, companion, () => {
@@ -25994,24 +26579,23 @@ async function startWebUI(opts = {}) {
25994
26579
  clients,
25995
26580
  pendingConfirms,
25996
26581
  onSecurityRejection: (ev) => {
25997
- try {
25998
- void mailbox.send({
25999
- from: context.agentId,
26000
- to: "*",
26001
- type: "note",
26002
- audience: "leaders",
26003
- subject: `Security rejection: ${ev.issueCode}`,
26004
- body: `Decoder tripwire ${ev.issueCode}: ${ev.issueMessage}
26582
+ void mailbox.send({
26583
+ from: context.agentId,
26584
+ to: "*",
26585
+ type: "note",
26586
+ audience: "leaders",
26587
+ subject: `Security rejection: ${ev.issueCode}`,
26588
+ body: `Decoder tripwire ${ev.issueCode}: ${ev.issueMessage}
26005
26589
 
26006
26590
  connectionId: ${ev.connectionId ?? "?"}
26007
26591
  sessionId: ${ev.sessionId ?? "?"}
26008
26592
  agentId: ${ev.agentId ?? "?"}
26009
26593
  projectRoot: ${ev.projectRoot ?? "?"}`,
26010
- priority: "high",
26011
- senderSessionId: session.id
26012
- });
26013
- } catch {
26014
- }
26594
+ priority: "high",
26595
+ senderSessionId: session.id
26596
+ }).catch((err) => {
26597
+ console.warn(`[WebUI] security-rejection mailbox note failed: ${String(err)}`);
26598
+ });
26015
26599
  },
26016
26600
  goalHandler,
26017
26601
  specsHandler,
@@ -26096,6 +26680,7 @@ export {
26096
26680
  SddWizardWebSocketHandler,
26097
26681
  SpecsWebSocketHandler,
26098
26682
  TerminalWebSocketHandler,
26683
+ WEBUI_WS_MAX_BUFFERED_BYTES,
26099
26684
  WorktreeWebSocketHandler,
26100
26685
  addProvider,
26101
26686
  announceWebuiReady,
@@ -26350,6 +26935,7 @@ export {
26350
26935
  seedContextMeta,
26351
26936
  send,
26352
26937
  sendResult2 as sendResult,
26938
+ sendSerialized,
26353
26939
  setActiveKey,
26354
26940
  setupEvents,
26355
26941
  setupWebUICodebaseIndexing,