@wrongstack/webui-server 0.298.3 → 0.300.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
@@ -73,6 +73,10 @@ var BOOLEAN_PREF_KEYS = /* @__PURE__ */ new Set([
73
73
  // Display-only toggles (purely visual, persisted in localStorage via Zustand).
74
74
  "groupToolCalls",
75
75
  "showThinkingLogs",
76
+ // v15: auto-collapse of the chat input under the history (opt-in display
77
+ // toggle, default off). Whitelisted so the key survives `prefs.update`
78
+ // round-trips without tripping the "unknown preference key" rejection.
79
+ "autoCollapseInput",
76
80
  // v11 Display parity: inverse fsAccess flag.
77
81
  "allowOutsideProjectRoot",
78
82
  // v13 Display parity (TUI SettingsPicker fields 42 & 43): the read tool
@@ -397,6 +401,51 @@ function validateModelSwitchPayload(payload) {
397
401
  }
398
402
  };
399
403
  }
404
+ function validateModelFallbackChoicePayload(payload) {
405
+ if (!isRecord2(payload)) {
406
+ return {
407
+ ok: false,
408
+ message: "model.fallback_choice payload must be an object"
409
+ };
410
+ }
411
+ const requestId = payload["requestId"];
412
+ if (typeof requestId !== "string" || requestId.trim().length === 0) {
413
+ return {
414
+ ok: false,
415
+ message: "model.fallback_choice payload.requestId must be a non-empty string"
416
+ };
417
+ }
418
+ const providerId = payload["providerId"];
419
+ const model = payload["model"];
420
+ const autoSwitch = payload["autoSwitch"];
421
+ if (providerId !== void 0 && typeof providerId !== "string") {
422
+ return {
423
+ ok: false,
424
+ message: "model.fallback_choice payload.providerId must be a string when provided"
425
+ };
426
+ }
427
+ if (model !== void 0 && typeof model !== "string") {
428
+ return {
429
+ ok: false,
430
+ message: "model.fallback_choice payload.model must be a string when provided"
431
+ };
432
+ }
433
+ if (autoSwitch !== void 0 && typeof autoSwitch !== "boolean") {
434
+ return {
435
+ ok: false,
436
+ message: "model.fallback_choice payload.autoSwitch must be a boolean when provided"
437
+ };
438
+ }
439
+ return {
440
+ ok: true,
441
+ value: {
442
+ requestId: requestId.trim(),
443
+ ...typeof providerId === "string" ? { providerId } : {},
444
+ ...typeof model === "string" ? { model } : {},
445
+ ...typeof autoSwitch === "boolean" ? { autoSwitch } : {}
446
+ }
447
+ };
448
+ }
400
449
  var AUTONOMY_VALUES2 = /* @__PURE__ */ new Set(["off", "suggest", "auto", "eternal", "eternal-parallel"]);
401
450
  function validateMailboxMessagesPayload(payload) {
402
451
  if (payload === void 0) return { ok: true, value: void 0 };
@@ -4417,13 +4466,10 @@ function createConversationOperations(ctx) {
4417
4466
 
4418
4467
  // src/server/context-editor.ts
4419
4468
  import { createHash } from "node:crypto";
4420
- import net from "node:net";
4421
4469
  import {
4422
4470
  ALLOWED_IMAGE_MEDIA_TYPES,
4423
4471
  base64DecodedBytes,
4424
4472
  isAllowedImageMediaType,
4425
- isPrivateIPv4,
4426
- isPrivateIPv6,
4427
4473
  isValidImageBase64,
4428
4474
  MAX_INCOMING_IMAGE_BYTES,
4429
4475
  repairToolUseAdjacency
@@ -4491,40 +4537,7 @@ var REVISION_PREFIX = "wrongstack-context-editor-v1\0";
4491
4537
  var MAX_MESSAGE_COUNT_GROWTH = 10;
4492
4538
  var MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
4493
4539
  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
- }
4540
+ var MAX_REMOVAL_COUNT = 4096;
4528
4541
  function isRecord3(value) {
4529
4542
  return value !== null && typeof value === "object" && !Array.isArray(value);
4530
4543
  }
@@ -4533,7 +4546,7 @@ function canonicalize(value) {
4533
4546
  if (isRecord3(value)) {
4534
4547
  const sorted = {};
4535
4548
  for (const key of Object.keys(value).sort()) {
4536
- if (key === "_estTokens") continue;
4549
+ if (key === "_estTokens" || key === "_toolErrorInfo") continue;
4537
4550
  const item = value[key];
4538
4551
  if (item === void 0) continue;
4539
4552
  sorted[key] = canonicalize(item);
@@ -4564,6 +4577,12 @@ function isMessageRole(value) {
4564
4577
  function isPlainJsonObject(value) {
4565
4578
  return isRecord3(value);
4566
4579
  }
4580
+ function splitsSurrogatePair(text2, offset) {
4581
+ if (offset <= 0 || offset >= text2.length) return false;
4582
+ const previous = text2.charCodeAt(offset - 1);
4583
+ const next = text2.charCodeAt(offset);
4584
+ return previous >= 55296 && previous <= 56319 && next >= 56320 && next <= 57343;
4585
+ }
4567
4586
  function validateCacheControl(value, path35, errors) {
4568
4587
  if (value === void 0) return void 0;
4569
4588
  if (!isRecord3(value) || value["type"] !== "ephemeral") {
@@ -4774,19 +4793,13 @@ function validateBlock(value, path35, errors) {
4774
4793
  );
4775
4794
  return void 0;
4776
4795
  }
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
- };
4796
+ error(
4797
+ errors,
4798
+ `${path35}/source/url`,
4799
+ "UNSAFE_IMAGE_URL",
4800
+ "URL image sources are not allowed in context editor proposals; use an ingested base64 image."
4801
+ );
4802
+ return void 0;
4790
4803
  }
4791
4804
  case "thinking": {
4792
4805
  const thinking = value["thinking"];
@@ -4943,6 +4956,14 @@ function warningsForMessage(message, index) {
4943
4956
  message: "This block contains provider replay metadata and should only be removed with the whole turn if no longer needed."
4944
4957
  });
4945
4958
  }
4959
+ if (block.type === "image" && block.source.type === "url") {
4960
+ warnings.push({
4961
+ path: `/messages/${index}/content/${blockIndex}/source/url`,
4962
+ code: "UNSAFE_IMAGE_URL",
4963
+ severity: "danger",
4964
+ message: "URL image sources cannot be retained in context editor proposals; remove the whole message before applying other edits."
4965
+ });
4966
+ }
4946
4967
  if (block.type === "tool_result" && block.content.length > 2e4) {
4947
4968
  warnings.push({
4948
4969
  path: `/messages/${index}/content/${blockIndex}`,
@@ -4970,6 +4991,21 @@ function metricFor(ctx, messages, tools) {
4970
4991
  fullRequestTokens: breakdown.total
4971
4992
  };
4972
4993
  }
4994
+ function isToolResultMessage(message) {
4995
+ return Boolean(
4996
+ message?.role === "user" && Array.isArray(message.content) && message.content.length > 0 && message.content.every((block) => block.type === "tool_result")
4997
+ );
4998
+ }
4999
+ function pairedAssistantIndices(messages, userIndex) {
5000
+ if (messages[userIndex]?.role !== "user" || isToolResultMessage(messages[userIndex])) return [];
5001
+ const paired = [];
5002
+ for (let index = userIndex + 1; index < messages.length; index += 1) {
5003
+ const message = messages[index];
5004
+ if (message?.role === "user" && !isToolResultMessage(message)) break;
5005
+ if (message?.role === "assistant") paired.push(index);
5006
+ }
5007
+ return paired;
5008
+ }
4973
5009
  function buildContextEditorSnapshot(ctx, tools) {
4974
5010
  const messages = ctx.messages.map(
4975
5011
  (message) => ({
@@ -4999,11 +5035,165 @@ function buildContextEditorSnapshot(ctx, tools) {
4999
5035
  tokens: messageTokens(message.content),
5000
5036
  preview: breakdown.messages.breakdown[index]?.preview ?? "",
5001
5037
  blockCount: Array.isArray(message.content) ? message.content.length : null,
5002
- warnings: warningsForMessage(message, index)
5038
+ warnings: warningsForMessage(message, index),
5039
+ pairedAssistantIndices: pairedAssistantIndices(ctx.messages, index)
5003
5040
  })),
5004
5041
  diagnostics: toolDiagnostics(ctx.messages)
5005
5042
  };
5006
5043
  }
5044
+ function validateRemovalPlan(value, originalMessages, proposedMessages) {
5045
+ const errors = [];
5046
+ if (value === void 0) {
5047
+ error(
5048
+ errors,
5049
+ "/removals",
5050
+ "REMOVAL_PLAN_REQUIRED",
5051
+ "A removal plan is required for every context editor proposal."
5052
+ );
5053
+ return { errors };
5054
+ }
5055
+ if (!Array.isArray(value)) {
5056
+ error(errors, "/removals", "INVALID_REMOVALS", "removals must be an array.");
5057
+ return { errors };
5058
+ }
5059
+ if (value.length > MAX_REMOVAL_COUNT) {
5060
+ error(
5061
+ errors,
5062
+ "/removals",
5063
+ "TOO_MANY_REMOVALS",
5064
+ `removals must contain at most ${MAX_REMOVAL_COUNT} entries.`
5065
+ );
5066
+ return { errors };
5067
+ }
5068
+ const wholeMessages = /* @__PURE__ */ new Set();
5069
+ const touchedUsers = /* @__PURE__ */ new Set();
5070
+ const ranges = [];
5071
+ for (const [removalIndex, raw] of value.entries()) {
5072
+ const path35 = `/removals/${removalIndex}`;
5073
+ if (!isRecord3(raw) || !Number.isInteger(raw["messageIndex"])) {
5074
+ error(errors, path35, "INVALID_REMOVAL", "Removal must include an integer messageIndex.");
5075
+ continue;
5076
+ }
5077
+ const messageIndex = raw["messageIndex"];
5078
+ const original = originalMessages[messageIndex];
5079
+ if (!original) {
5080
+ error(errors, `${path35}/messageIndex`, "INVALID_MESSAGE_INDEX", "Removal messageIndex is out of range.");
5081
+ continue;
5082
+ }
5083
+ const start = raw["start"];
5084
+ const end = raw["end"];
5085
+ const blockIndex = raw["blockIndex"];
5086
+ if (start === void 0 && end === void 0 && blockIndex === void 0) {
5087
+ wholeMessages.add(messageIndex);
5088
+ if (original.role === "user") touchedUsers.add(messageIndex);
5089
+ continue;
5090
+ }
5091
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end <= start) {
5092
+ error(errors, path35, "INVALID_RANGE", "Range removal requires integer start/end with 0 <= start < end.");
5093
+ continue;
5094
+ }
5095
+ let text2;
5096
+ if (blockIndex === void 0 && typeof original.content === "string") text2 = original.content;
5097
+ if (Number.isInteger(blockIndex) && Array.isArray(original.content)) {
5098
+ const block = original.content[blockIndex];
5099
+ if (block?.type === "text") text2 = block.text;
5100
+ }
5101
+ if (text2 === void 0 || end > text2.length) {
5102
+ error(errors, path35, "INVALID_RANGE_TARGET", "Range must target existing string or text-block content.");
5103
+ continue;
5104
+ }
5105
+ if (splitsSurrogatePair(text2, start) || splitsSurrogatePair(text2, end)) {
5106
+ error(
5107
+ errors,
5108
+ path35,
5109
+ "INVALID_UNICODE_RANGE",
5110
+ "Range boundaries must not split a Unicode surrogate pair."
5111
+ );
5112
+ continue;
5113
+ }
5114
+ ranges.push({
5115
+ messageIndex,
5116
+ ...blockIndex === void 0 ? {} : { blockIndex },
5117
+ start,
5118
+ end
5119
+ });
5120
+ if (original.role === "user") touchedUsers.add(messageIndex);
5121
+ }
5122
+ const rangesByTarget = /* @__PURE__ */ new Map();
5123
+ for (const range of ranges) {
5124
+ const key = `${range.messageIndex}:${range.blockIndex ?? "string"}`;
5125
+ const targetRanges = rangesByTarget.get(key) ?? [];
5126
+ targetRanges.push(range);
5127
+ rangesByTarget.set(key, targetRanges);
5128
+ }
5129
+ for (const targetRanges of rangesByTarget.values()) {
5130
+ targetRanges.sort((left, right) => (left.start ?? 0) - (right.start ?? 0));
5131
+ for (let index = 1; index < targetRanges.length; index += 1) {
5132
+ const previous = targetRanges[index - 1];
5133
+ const current2 = targetRanges[index];
5134
+ if (previous?.end !== void 0 && current2?.start !== void 0 && current2.start < previous.end) {
5135
+ error(
5136
+ errors,
5137
+ "/removals",
5138
+ "OVERLAPPING_RANGES",
5139
+ "Removal ranges targeting the same text must not overlap."
5140
+ );
5141
+ break;
5142
+ }
5143
+ }
5144
+ }
5145
+ for (const userIndex of touchedUsers) {
5146
+ for (const assistantIndex of pairedAssistantIndices(originalMessages, userIndex)) {
5147
+ if (wholeMessages.has(assistantIndex)) continue;
5148
+ error(
5149
+ errors,
5150
+ "/removals",
5151
+ "MISSING_ASSISTANT_PAIR",
5152
+ `Editing user message ${userIndex} must also remove assistant message ${assistantIndex}.`
5153
+ );
5154
+ }
5155
+ }
5156
+ const expectedMessages = structuredClone(originalMessages);
5157
+ for (const targetRanges of rangesByTarget.values()) {
5158
+ const first = targetRanges[0];
5159
+ if (!first) continue;
5160
+ const message = expectedMessages[first.messageIndex];
5161
+ if (!message) continue;
5162
+ let text2;
5163
+ if (first.blockIndex === void 0 && typeof message.content === "string") {
5164
+ text2 = message.content;
5165
+ } else if (first.blockIndex !== void 0 && Array.isArray(message.content)) {
5166
+ const block = message.content[first.blockIndex];
5167
+ if (block?.type === "text") text2 = block.text;
5168
+ }
5169
+ if (text2 === void 0) continue;
5170
+ const pieces = [];
5171
+ let cursor = 0;
5172
+ for (const range of targetRanges) {
5173
+ if (range.start === void 0 || range.end === void 0) continue;
5174
+ pieces.push(text2.slice(cursor, range.start));
5175
+ cursor = range.end;
5176
+ }
5177
+ pieces.push(text2.slice(cursor));
5178
+ const nextText = pieces.join("");
5179
+ if (first.blockIndex === void 0 && typeof message.content === "string") {
5180
+ message.content = nextText;
5181
+ } else if (first.blockIndex !== void 0 && Array.isArray(message.content)) {
5182
+ const block = message.content[first.blockIndex];
5183
+ if (block?.type === "text") block.text = nextText;
5184
+ }
5185
+ }
5186
+ const expectedProposal = expectedMessages.filter((_, index) => !wholeMessages.has(index));
5187
+ if (JSON.stringify(canonicalize(expectedProposal)) !== JSON.stringify(canonicalize(proposedMessages))) {
5188
+ error(
5189
+ errors,
5190
+ "/messages",
5191
+ "REMOVAL_PLAN_MISMATCH",
5192
+ "Submitted messages do not exactly match the declared removal plan."
5193
+ );
5194
+ }
5195
+ return errors.length > 0 ? { errors } : { errors, messages: expectedProposal };
5196
+ }
5007
5197
  function validateContextEditorProposal(input) {
5008
5198
  const currentRevision = contextEditorRevision(input.ctx.messages);
5009
5199
  const before = metricFor(input.ctx, input.ctx.messages, input.tools);
@@ -5055,7 +5245,23 @@ function validateContextEditorProposal(input) {
5055
5245
  repair: emptyRepair
5056
5246
  };
5057
5247
  }
5058
- const repaired = repairToolUseAdjacency(parsed.messages);
5248
+ const removalPlan = validateRemovalPlan(
5249
+ input.removals,
5250
+ input.ctx.messages,
5251
+ parsed.messages
5252
+ );
5253
+ if (removalPlan.errors.length > 0 || !removalPlan.messages) {
5254
+ return {
5255
+ ok: false,
5256
+ baseRevision: input.baseRevision,
5257
+ currentRevision,
5258
+ before,
5259
+ validationErrors: removalPlan.errors,
5260
+ warnings: [],
5261
+ repair: emptyRepair
5262
+ };
5263
+ }
5264
+ const repaired = repairToolUseAdjacency(removalPlan.messages);
5059
5265
  const repair = {
5060
5266
  changed: repaired.report.changed,
5061
5267
  removedToolUses: repaired.report.removedToolUses,
@@ -5305,7 +5511,7 @@ import {
5305
5511
  getKanbanServerConnection,
5306
5512
  isKanbanServerAvailable
5307
5513
  } from "@wrongstack/kanban";
5308
- import * as net2 from "node:net";
5514
+ import * as net from "node:net";
5309
5515
  import { readGovernanceDaemonOperatorStatus } from "@wrongstack/runtime/governance-bootstrap";
5310
5516
  import { isSageProjectServerAvailable, SageProjectServerConnection } from "@wrongstack/sage";
5311
5517
  import {
@@ -6149,7 +6355,7 @@ var RESTART_POLL_INTERVAL_MS = 250;
6149
6355
  var RESTART_DEADLINE_MS = 3e3;
6150
6356
  function isEndpointAlive(endpoint) {
6151
6357
  return new Promise((resolve16) => {
6152
- const sock = net2.createConnection(endpoint);
6358
+ const sock = net.createConnection(endpoint);
6153
6359
  const timer = setTimeout(() => {
6154
6360
  sock.destroy();
6155
6361
  resolve16(false);
@@ -8094,7 +8300,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8094
8300
  }
8095
8301
  try {
8096
8302
  const { SessionRegistry, DefaultSessionStore: DefaultSessionStore4, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core/storage");
8097
- const { resolveWstackPaths: resolveWstackPaths6 } = await import("@wrongstack/core/utils");
8303
+ const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8098
8304
  const registry = new SessionRegistry(globalRoot);
8099
8305
  const entry = await registry.get(sessionId);
8100
8306
  if (!entry) {
@@ -8102,7 +8308,7 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8102
8308
  res.end(JSON.stringify({ error: "Session not found" }));
8103
8309
  return;
8104
8310
  }
8105
- const paths = resolveWstackPaths6({ projectRoot: entry.projectRoot, globalRoot });
8311
+ const paths = resolveWstackPaths7({ projectRoot: entry.projectRoot, globalRoot });
8106
8312
  const store = new DefaultSessionStore4({ dir: paths.projectSessions });
8107
8313
  const reader = new DefaultSessionReader2({ store });
8108
8314
  const rawEntries = [];
@@ -8183,7 +8389,7 @@ async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
8183
8389
  try {
8184
8390
  const { SessionRegistry } = await import("@wrongstack/core/storage");
8185
8391
  const { getSharedProjectMailbox: getSharedProjectMailbox6, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8186
- const { resolveWstackPaths: resolveWstackPaths6 } = await import("@wrongstack/core/utils");
8392
+ const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8187
8393
  const registry = new SessionRegistry(globalRoot);
8188
8394
  const entry = await registry.get(sessionId);
8189
8395
  if (!entry) {
@@ -8191,7 +8397,7 @@ async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
8191
8397
  res.end(JSON.stringify({ error: "Session not found" }));
8192
8398
  return;
8193
8399
  }
8194
- const paths = resolveWstackPaths6({ projectRoot: entry.projectRoot, globalRoot });
8400
+ const paths = resolveWstackPaths7({ projectRoot: entry.projectRoot, globalRoot });
8195
8401
  const mailbox = getSharedProjectMailbox6(paths.projectDir);
8196
8402
  const to = `leader@${mailboxSessionTag2(sessionId)}`;
8197
8403
  const sent = await mailbox.send({ from, to, type, subject, body: text2, priority });
@@ -8211,7 +8417,7 @@ async function handleApiSessionMailbox(res, globalRoot, sessionId) {
8211
8417
  try {
8212
8418
  const { SessionRegistry } = await import("@wrongstack/core/storage");
8213
8419
  const { getSharedProjectMailbox: getSharedProjectMailbox6, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8214
- const { resolveWstackPaths: resolveWstackPaths6 } = await import("@wrongstack/core/utils");
8420
+ const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8215
8421
  const registry = new SessionRegistry(globalRoot);
8216
8422
  const entry = await registry.get(sessionId);
8217
8423
  if (!entry) {
@@ -8219,7 +8425,7 @@ async function handleApiSessionMailbox(res, globalRoot, sessionId) {
8219
8425
  res.end(JSON.stringify({ error: "Session not found" }));
8220
8426
  return;
8221
8427
  }
8222
- const paths = resolveWstackPaths6({ projectRoot: entry.projectRoot, globalRoot });
8428
+ const paths = resolveWstackPaths7({ projectRoot: entry.projectRoot, globalRoot });
8223
8429
  const mailbox = getSharedProjectMailbox6(paths.projectDir);
8224
8430
  const leaderAddr = `leader@${mailboxSessionTag2(sessionId)}`;
8225
8431
  const [inbound, outbound] = await Promise.all([
@@ -8271,7 +8477,7 @@ async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
8271
8477
  try {
8272
8478
  const { SessionRegistry } = await import("@wrongstack/core/storage");
8273
8479
  const { getSharedProjectMailbox: getSharedProjectMailbox6, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8274
- const { resolveWstackPaths: resolveWstackPaths6 } = await import("@wrongstack/core/utils");
8480
+ const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8275
8481
  const registry = new SessionRegistry(globalRoot);
8276
8482
  const entry = await registry.get(sessionId);
8277
8483
  if (!entry) {
@@ -8279,7 +8485,7 @@ async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
8279
8485
  res.end(JSON.stringify({ error: "Session not found" }));
8280
8486
  return;
8281
8487
  }
8282
- const paths = resolveWstackPaths6({ projectRoot: entry.projectRoot, globalRoot });
8488
+ const paths = resolveWstackPaths7({ projectRoot: entry.projectRoot, globalRoot });
8283
8489
  const mailbox = getSharedProjectMailbox6(paths.projectDir);
8284
8490
  const to = `leader@${mailboxSessionTag2(sessionId)}`;
8285
8491
  const sent = await mailbox.sendRuntimeControl({
@@ -8320,7 +8526,7 @@ async function handleApiFleetBroadcast(res, req, globalRoot) {
8320
8526
  try {
8321
8527
  const { SessionRegistry } = await import("@wrongstack/core/storage");
8322
8528
  const { getSharedProjectMailbox: getSharedProjectMailbox6, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8323
- const { resolveWstackPaths: resolveWstackPaths6 } = await import("@wrongstack/core/utils");
8529
+ const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8324
8530
  const registry = new SessionRegistry(globalRoot);
8325
8531
  const all = await registry.list();
8326
8532
  const mySlug = all.find((s) => s.pid === process.pid)?.projectSlug;
@@ -8332,7 +8538,7 @@ async function handleApiFleetBroadcast(res, req, globalRoot) {
8332
8538
  }
8333
8539
  const mbByDir = /* @__PURE__ */ new Map();
8334
8540
  const mailboxFor = (projectRoot) => {
8335
- const dir = resolveWstackPaths6({ projectRoot, globalRoot }).projectDir;
8541
+ const dir = resolveWstackPaths7({ projectRoot, globalRoot }).projectDir;
8336
8542
  let mb = mbByDir.get(dir);
8337
8543
  if (!mb) {
8338
8544
  mb = getSharedProjectMailbox6(dir);
@@ -8366,6 +8572,246 @@ async function handleApiFleetBroadcast(res, req, globalRoot) {
8366
8572
  }
8367
8573
  }
8368
8574
 
8575
+ // src/server/requirement-intake-handlers.ts
8576
+ import { readProjectIdentity } from "@wrongstack/core/utils";
8577
+ import {
8578
+ IntakeError,
8579
+ IntakeValidationError
8580
+ } from "@wrongstack/requirement-intake";
8581
+ var SERVER_ACTOR = { id: "webui-server", type: "agent" };
8582
+ var MAX_INTAKE_BODY_BYTES = 512e3;
8583
+ function intakeContext(projectId) {
8584
+ return { ...SERVER_ACTOR, projectId };
8585
+ }
8586
+ function sendJson2(res, status, body) {
8587
+ res.writeHead(status, { "Content-Type": "application/json" });
8588
+ res.end(JSON.stringify(body));
8589
+ }
8590
+ function sendNotFound(res, intakeId) {
8591
+ sendJson2(res, 404, {
8592
+ error: {
8593
+ code: "INTAKE_NOT_FOUND",
8594
+ message: `Requirement intake record not found: ${intakeId}`
8595
+ }
8596
+ });
8597
+ }
8598
+ function sendIntakeError(res, error2) {
8599
+ if (error2 instanceof IntakeValidationError) {
8600
+ sendJson2(res, 400, {
8601
+ error: { code: error2.code, message: error2.message, issues: error2.issues }
8602
+ });
8603
+ return;
8604
+ }
8605
+ if (error2 instanceof IntakeError) {
8606
+ const status = error2.code === "INTAKE_UNAUTHORIZED" ? 403 : error2.code === "INTAKE_NOT_FOUND" ? 404 : error2.code === "INTAKE_SUGGESTION_ERROR" ? 502 : 409;
8607
+ sendJson2(res, status, { error: { code: error2.code, message: error2.message } });
8608
+ return;
8609
+ }
8610
+ sendJson2(res, 500, { error: { code: "INTERNAL_ERROR", message: "Internal server error" } });
8611
+ }
8612
+ function serviceOr503(res, service) {
8613
+ if (service) return true;
8614
+ sendJson2(res, 503, {
8615
+ error: { code: "INTAKE_UNAVAILABLE", message: "Requirement intake service not configured" }
8616
+ });
8617
+ return false;
8618
+ }
8619
+ async function readJsonBody3(res, req) {
8620
+ const contentType = (req.headers["content-type"] ?? "").split(";")[0]?.trim().toLowerCase();
8621
+ if (contentType !== "application/json") {
8622
+ sendJson2(res, 400, {
8623
+ error: {
8624
+ code: "INVALID_CONTENT_TYPE",
8625
+ message: `Unsupported Content-Type: ${contentType || "(absent)"}`
8626
+ }
8627
+ });
8628
+ return null;
8629
+ }
8630
+ return new Promise((resolve16) => {
8631
+ let data = "";
8632
+ let failed = false;
8633
+ const fail2 = (message) => {
8634
+ if (failed) return;
8635
+ failed = true;
8636
+ sendJson2(res, 400, { error: { code: "INVALID_BODY", message } });
8637
+ resolve16(null);
8638
+ };
8639
+ req.on("data", (chunk) => {
8640
+ if (failed) return;
8641
+ data += chunk.toString("utf8");
8642
+ if (data.length > MAX_INTAKE_BODY_BYTES) {
8643
+ req.destroy();
8644
+ fail2("Request body too large");
8645
+ }
8646
+ });
8647
+ req.on("end", () => {
8648
+ if (failed) return;
8649
+ try {
8650
+ resolve16(data.trim().length === 0 ? {} : JSON.parse(data));
8651
+ } catch {
8652
+ fail2("Request body is not valid JSON");
8653
+ }
8654
+ });
8655
+ req.on("error", () => fail2("Failed to read request body"));
8656
+ });
8657
+ }
8658
+ async function discoverIntake(res, service, intakeId) {
8659
+ const record2 = await service.getIntake(intakeId, intakeContext(""));
8660
+ if (!record2) {
8661
+ sendNotFound(res, intakeId);
8662
+ return null;
8663
+ }
8664
+ return record2;
8665
+ }
8666
+ async function handleRequirementIntakeListForServer(res, service, projectRoot) {
8667
+ if (!serviceOr503(res, service)) return;
8668
+ if (!projectRoot) {
8669
+ sendJson2(res, 503, {
8670
+ error: { code: "PROJECT_ROOT_NOT_CONFIGURED", message: "Project root not configured" }
8671
+ });
8672
+ return;
8673
+ }
8674
+ try {
8675
+ const identity = await readProjectIdentity(projectRoot);
8676
+ if (!identity) {
8677
+ sendJson2(res, 404, {
8678
+ error: {
8679
+ code: "PROJECT_IDENTITY_NOT_FOUND",
8680
+ message: "No project identity found \u2014 run `wstack init` first"
8681
+ }
8682
+ });
8683
+ return;
8684
+ }
8685
+ const projectId = identity.projectId;
8686
+ const records = await service.listIntakes(projectId, intakeContext(projectId));
8687
+ sendJson2(res, 200, { projectId, intakes: records });
8688
+ } catch (error2) {
8689
+ sendIntakeError(res, error2);
8690
+ }
8691
+ }
8692
+ async function handleRequirementIntakeCreate(res, req, service, projectId) {
8693
+ if (!serviceOr503(res, service)) return;
8694
+ const body = await readJsonBody3(res, req);
8695
+ if (body === null) return;
8696
+ try {
8697
+ const result = await service.createIntake(body, intakeContext(projectId));
8698
+ sendJson2(res, result.idempotent ? 200 : 201, result);
8699
+ } catch (error2) {
8700
+ sendIntakeError(res, error2);
8701
+ }
8702
+ }
8703
+ async function handleRequirementIntakeList(res, service, projectId) {
8704
+ if (!serviceOr503(res, service)) return;
8705
+ try {
8706
+ const records = await service.listIntakes(projectId, intakeContext(projectId));
8707
+ sendJson2(res, 200, records);
8708
+ } catch (error2) {
8709
+ sendIntakeError(res, error2);
8710
+ }
8711
+ }
8712
+ async function handleRequirementIntakeGet(res, service, intakeId) {
8713
+ if (!serviceOr503(res, service)) return;
8714
+ try {
8715
+ const record2 = await discoverIntake(res, service, intakeId);
8716
+ if (!record2) return;
8717
+ sendJson2(res, 200, record2);
8718
+ } catch (error2) {
8719
+ sendIntakeError(res, error2);
8720
+ }
8721
+ }
8722
+ async function handleRequirementIntakeUpdate(res, req, service, intakeId) {
8723
+ if (!serviceOr503(res, service)) return;
8724
+ const body = await readJsonBody3(res, req);
8725
+ if (body === null) return;
8726
+ try {
8727
+ const record2 = await discoverIntake(res, service, intakeId);
8728
+ if (!record2) return;
8729
+ const expectedVersion = typeof body["expectedVersion"] === "number" ? body["expectedVersion"] : void 0;
8730
+ const { expectedVersion: _ignored, ...patch } = body;
8731
+ void _ignored;
8732
+ const updated = await service.updateIntake(
8733
+ intakeId,
8734
+ patch,
8735
+ intakeContext(record2.projectId),
8736
+ expectedVersion
8737
+ );
8738
+ sendJson2(res, 200, updated);
8739
+ } catch (error2) {
8740
+ sendIntakeError(res, error2);
8741
+ }
8742
+ }
8743
+ async function handleRequirementIntakeAnswers(res, req, service, intakeId) {
8744
+ if (!serviceOr503(res, service)) return;
8745
+ const body = await readJsonBody3(res, req);
8746
+ if (body === null) return;
8747
+ try {
8748
+ const record2 = await discoverIntake(res, service, intakeId);
8749
+ if (!record2) return;
8750
+ const updated = await service.addAnswer(
8751
+ intakeId,
8752
+ body,
8753
+ intakeContext(record2.projectId)
8754
+ );
8755
+ sendJson2(res, 200, updated);
8756
+ } catch (error2) {
8757
+ sendIntakeError(res, error2);
8758
+ }
8759
+ }
8760
+ async function handleRequirementIntakeSuggestions(res, req, service, intakeId) {
8761
+ if (!serviceOr503(res, service)) return;
8762
+ const body = await readJsonBody3(res, req);
8763
+ if (body === null) return;
8764
+ try {
8765
+ const record2 = await discoverIntake(res, service, intakeId);
8766
+ if (!record2) return;
8767
+ const focus = Array.isArray(body["focus"]) ? body["focus"] : void 0;
8768
+ const suggestions = await service.generateSuggestions(
8769
+ intakeId,
8770
+ intakeContext(record2.projectId),
8771
+ focus
8772
+ );
8773
+ sendJson2(res, 200, { suggestions });
8774
+ } catch (error2) {
8775
+ sendIntakeError(res, error2);
8776
+ }
8777
+ }
8778
+ async function handleRequirementIntakeSubmit(res, service, intakeId) {
8779
+ if (!serviceOr503(res, service)) return;
8780
+ try {
8781
+ const record2 = await discoverIntake(res, service, intakeId);
8782
+ if (!record2) return;
8783
+ const result = await service.submitIntake(intakeId, intakeContext(record2.projectId));
8784
+ sendJson2(res, 200, result);
8785
+ } catch (error2) {
8786
+ sendIntakeError(res, error2);
8787
+ }
8788
+ }
8789
+ async function handleRequirementIntakeCancel(res, req, service, intakeId) {
8790
+ if (!serviceOr503(res, service)) return;
8791
+ const body = await readJsonBody3(res, req);
8792
+ if (body === null) return;
8793
+ try {
8794
+ const record2 = await discoverIntake(res, service, intakeId);
8795
+ if (!record2) return;
8796
+ const reason = typeof body["reason"] === "string" ? body["reason"] : void 0;
8797
+ const updated = await service.cancelIntake(intakeId, intakeContext(record2.projectId), reason);
8798
+ sendJson2(res, 200, updated);
8799
+ } catch (error2) {
8800
+ sendIntakeError(res, error2);
8801
+ }
8802
+ }
8803
+ async function handleRequirementIntakeArchive(res, service, intakeId) {
8804
+ if (!serviceOr503(res, service)) return;
8805
+ try {
8806
+ const record2 = await discoverIntake(res, service, intakeId);
8807
+ if (!record2) return;
8808
+ const updated = await service.archiveIntake(intakeId, intakeContext(record2.projectId));
8809
+ sendJson2(res, 200, updated);
8810
+ } catch (error2) {
8811
+ sendIntakeError(res, error2);
8812
+ }
8813
+ }
8814
+
8369
8815
  // src/server/memory-diagnostics.ts
8370
8816
  import * as fs8 from "node:fs/promises";
8371
8817
  import * as path11 from "node:path";
@@ -8571,7 +9017,7 @@ async function touchProjectInManifest(options, globalConfigPath) {
8571
9017
  // src/server/techstack-handlers.ts
8572
9018
  import { randomUUID as randomUUID2 } from "node:crypto";
8573
9019
  var DEEP_DIVE_TIMEOUT_MS = 6e4;
8574
- function sendJson2(res, status, data) {
9020
+ function sendJson3(res, status, data) {
8575
9021
  res.writeHead(status, { "Content-Type": "application/json" });
8576
9022
  res.end(JSON.stringify(data));
8577
9023
  }
@@ -8586,13 +9032,13 @@ function handleTechStackSnapshot(res, deps2) {
8586
9032
  try {
8587
9033
  const snapshot = deps2.store.getSnapshot(deps2.projectId);
8588
9034
  if (!snapshot) {
8589
- sendJson2(res, 404, { snapshot: null, stale: false });
9035
+ sendJson3(res, 404, { snapshot: null, stale: false });
8590
9036
  return;
8591
9037
  }
8592
9038
  const ageMs = Date.now() - new Date(snapshot.createdAt).getTime();
8593
- sendJson2(res, 200, { snapshot, stale: ageMs > 24 * 60 * 60 * 1e3 });
9039
+ sendJson3(res, 200, { snapshot, stale: ageMs > 24 * 60 * 60 * 1e3 });
8594
9040
  } catch (error2) {
8595
- sendJson2(res, 500, {
9041
+ sendJson3(res, 500, {
8596
9042
  error: "TechStack store unavailable",
8597
9043
  detail: errorMessage(error2)
8598
9044
  });
@@ -8603,7 +9049,7 @@ function errorMessage(error2) {
8603
9049
  }
8604
9050
  function requireJobDeps(res, deps2) {
8605
9051
  if (!deps2.projectRoot || !deps2.engine) {
8606
- sendJson2(res, 503, { error: "TechStack engine unavailable" });
9052
+ sendJson3(res, 503, { error: "TechStack engine unavailable" });
8607
9053
  return false;
8608
9054
  }
8609
9055
  return true;
@@ -8614,7 +9060,7 @@ function startJob(res, deps2, kind) {
8614
9060
  const controller = new AbortController();
8615
9061
  deps2.runningJobs?.set(jobId, controller);
8616
9062
  deps2.emit?.({ type: "techstack.job.started", payload: { jobId, kind } });
8617
- sendJson2(res, 202, { jobId, kind, status: "queued" });
9063
+ sendJson3(res, 202, { jobId, kind, status: "queued" });
8618
9064
  void buildResearcher(deps2, kind).catch(() => void 0).then(
8619
9065
  (researcher) => deps2.engine.analyze(deps2.projectId, {
8620
9066
  targetRoot: deps2.projectRoot,
@@ -8660,24 +9106,24 @@ function handleTechStackCancel(res, deps2, jobId) {
8660
9106
  if (controller && !controller.signal.aborted) controller.abort();
8661
9107
  deps2.store.updateJobStatus(jobId, "cancelled");
8662
9108
  deps2.emit?.({ type: "techstack.job.cancelled", payload: { jobId } });
8663
- sendJson2(res, 200, { jobId, status: "cancelled" });
9109
+ sendJson3(res, 200, { jobId, status: "cancelled" });
8664
9110
  }
8665
9111
  async function handleTechStackDependencyResearch(res, deps2, dependencyId) {
8666
9112
  const snapshot = deps2.store.getSnapshot(deps2.projectId);
8667
9113
  const dependency = snapshot?.dependencies.find((dep) => dep.id === dependencyId);
8668
9114
  if (!dependency) {
8669
- sendJson2(res, 404, { error: "Dependency not found in the current snapshot" });
9115
+ sendJson3(res, 404, { error: "Dependency not found in the current snapshot" });
8670
9116
  return;
8671
9117
  }
8672
9118
  let researcher;
8673
9119
  try {
8674
9120
  researcher = await buildResearcher(deps2, "analyze");
8675
9121
  } catch (error2) {
8676
- sendJson2(res, 503, { error: "Research unavailable", detail: errorMessage(error2) });
9122
+ sendJson3(res, 503, { error: "Research unavailable", detail: errorMessage(error2) });
8677
9123
  return;
8678
9124
  }
8679
9125
  if (!researcher) {
8680
- sendJson2(res, 503, {
9126
+ sendJson3(res, 503, {
8681
9127
  error: "No model configured \u2014 connect a provider to run LLM analysis."
8682
9128
  });
8683
9129
  return;
@@ -8694,9 +9140,9 @@ async function handleTechStackDependencyResearch(res, deps2, dependencyId) {
8694
9140
  [triaged ?? { dependency, cluster: "breaking_change", priority: 0 }],
8695
9141
  { signal: controller.signal }
8696
9142
  );
8697
- sendJson2(res, 200, { dependencyId, findings });
9143
+ sendJson3(res, 200, { dependencyId, findings });
8698
9144
  } catch (error2) {
8699
- sendJson2(res, 500, { error: "Research failed", detail: errorMessage(error2) });
9145
+ sendJson3(res, 500, { error: "Research failed", detail: errorMessage(error2) });
8700
9146
  } finally {
8701
9147
  clearTimeout(timeout);
8702
9148
  controller.abort();
@@ -8705,15 +9151,15 @@ async function handleTechStackDependencyResearch(res, deps2, dependencyId) {
8705
9151
  function handleTechStackJobStatus(res, deps2, jobId) {
8706
9152
  const job = deps2.store.getJob(jobId);
8707
9153
  if (!job) {
8708
- sendJson2(res, 404, { error: "Job not found" });
9154
+ sendJson3(res, 404, { error: "Job not found" });
8709
9155
  return;
8710
9156
  }
8711
- sendJson2(res, 200, { job });
9157
+ sendJson3(res, 200, { job });
8712
9158
  }
8713
9159
  function handleTechStackReport(res, deps2, reportId, format) {
8714
9160
  const snapshot = deps2.store.getSnapshotById(reportId);
8715
9161
  if (!snapshot) {
8716
- sendJson2(res, 404, { error: "Report not found" });
9162
+ sendJson3(res, 404, { error: "Report not found" });
8717
9163
  return;
8718
9164
  }
8719
9165
  if (deps2.engine) {
@@ -8724,30 +9170,30 @@ function handleTechStackReport(res, deps2, reportId, format) {
8724
9170
  });
8725
9171
  res.end(report);
8726
9172
  } else {
8727
- sendJson2(res, 200, snapshot);
9173
+ sendJson3(res, 200, snapshot);
8728
9174
  }
8729
9175
  }
8730
9176
  async function handleTechStackTrends(res, deps2) {
8731
9177
  try {
8732
9178
  const { TrendStore } = await import("@wrongstack/techstack");
8733
- sendJson2(res, 200, { trend: new TrendStore(deps2.store).analyze(deps2.projectId) });
9179
+ sendJson3(res, 200, { trend: new TrendStore(deps2.store).analyze(deps2.projectId) });
8734
9180
  } catch (error2) {
8735
- sendJson2(res, 500, { error: "Trend analysis failed", detail: errorMessage(error2) });
9181
+ sendJson3(res, 500, { error: "Trend analysis failed", detail: errorMessage(error2) });
8736
9182
  }
8737
9183
  }
8738
9184
  async function handleTechStackRemediationPlan(res, deps2) {
8739
9185
  const snapshot = deps2.store.getSnapshot(deps2.projectId);
8740
9186
  if (!snapshot) {
8741
- sendJson2(res, 404, { error: "No TechStack snapshot is available" });
9187
+ sendJson3(res, 404, { error: "No TechStack snapshot is available" });
8742
9188
  return;
8743
9189
  }
8744
9190
  const { applyPlan, generateUpgradePlan } = await import("@wrongstack/techstack");
8745
9191
  const plan = generateUpgradePlan(snapshot);
8746
- sendJson2(res, 200, { plan, preview: await applyPlan(plan) });
9192
+ sendJson3(res, 200, { plan, preview: await applyPlan(plan) });
8747
9193
  }
8748
9194
  async function handleTechStackRemediationApply(req, res, deps2) {
8749
9195
  if (!deps2.executePackageOperation) {
8750
- sendJson2(res, 503, { error: "Permission-governed package execution is unavailable" });
9196
+ sendJson3(res, 503, { error: "Permission-governed package execution is unavailable" });
8751
9197
  return;
8752
9198
  }
8753
9199
  let approvedItems;
@@ -8760,16 +9206,16 @@ async function handleTechStackRemediationApply(req, res, deps2) {
8760
9206
  const body = JSON.parse(raw || "{}");
8761
9207
  approvedItems = Array.isArray(body.approvedItems) ? body.approvedItems.filter((value) => typeof value === "string") : [];
8762
9208
  } catch (error2) {
8763
- sendJson2(res, 400, { error: "Invalid request body", detail: errorMessage(error2) });
9209
+ sendJson3(res, 400, { error: "Invalid request body", detail: errorMessage(error2) });
8764
9210
  return;
8765
9211
  }
8766
9212
  if (approvedItems.length === 0) {
8767
- sendJson2(res, 400, { error: "approvedItems must explicitly identify at least one plan item" });
9213
+ sendJson3(res, 400, { error: "approvedItems must explicitly identify at least one plan item" });
8768
9214
  return;
8769
9215
  }
8770
9216
  const snapshot = deps2.store.getSnapshot(deps2.projectId);
8771
9217
  if (!snapshot) {
8772
- sendJson2(res, 404, { error: "No TechStack snapshot is available" });
9218
+ sendJson3(res, 404, { error: "No TechStack snapshot is available" });
8773
9219
  return;
8774
9220
  }
8775
9221
  const approved = new Set(approvedItems);
@@ -8784,14 +9230,17 @@ async function handleTechStackRemediationApply(req, res, deps2) {
8784
9230
  return executePackageOperation(operation, workspace?.relativeRoot);
8785
9231
  }
8786
9232
  });
8787
- sendJson2(res, 200, { plan, result });
9233
+ sendJson3(res, 200, { plan, result });
8788
9234
  }
8789
9235
 
8790
9236
  // src/server/ws-auth.ts
8791
9237
  import { Buffer as Buffer2 } from "node:buffer";
8792
9238
  import { timingSafeEqual } from "node:crypto";
9239
+ import { isLoopbackHost as isLoopbackHostCore } from "@wrongstack/core/hq";
8793
9240
  function isLoopbackHostname(hostname) {
8794
- return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
9241
+ const normalized = hostname.toLowerCase();
9242
+ if (normalized === "localhost") return true;
9243
+ return isLoopbackHostCore(normalized);
8795
9244
  }
8796
9245
  function effectivePort(url) {
8797
9246
  if (url.port) return url.port;
@@ -8812,7 +9261,7 @@ function isTrustedLoopbackOrigin(origin, hostHeader) {
8812
9261
  }
8813
9262
  }
8814
9263
  function isLoopbackBind(wsHost) {
8815
- return wsHost === "127.0.0.1" || wsHost === "::1" || wsHost === "localhost";
9264
+ return isLoopbackHostCore(wsHost) || wsHost === "localhost";
8816
9265
  }
8817
9266
  function isWildcardBind(wsHost) {
8818
9267
  return wsHost === "0.0.0.0" || wsHost === "::" || wsHost === "[::]";
@@ -8839,19 +9288,22 @@ function extractToken(url) {
8839
9288
  function extractTokenFromCookie(cookieHeader) {
8840
9289
  if (!cookieHeader) return void 0;
8841
9290
  const raw = Array.isArray(cookieHeader) ? cookieHeader.join("; ") : cookieHeader;
9291
+ let plain;
8842
9292
  for (const part of raw.split(";")) {
8843
9293
  const eq = part.indexOf("=");
8844
9294
  if (eq < 0) continue;
8845
9295
  const name2 = part.slice(0, eq).trim();
8846
- if (name2 === "ws_token") {
8847
- try {
8848
- return decodeURIComponent(part.slice(eq + 1).trim());
8849
- } catch {
8850
- return part.slice(eq + 1).trim();
8851
- }
9296
+ if (name2 !== "ws_token" && name2 !== "__Host-ws_token") continue;
9297
+ let value;
9298
+ try {
9299
+ value = decodeURIComponent(part.slice(eq + 1).trim());
9300
+ } catch {
9301
+ value = part.slice(eq + 1).trim();
8852
9302
  }
9303
+ if (name2 === "__Host-ws_token") return value;
9304
+ plain ??= value;
8853
9305
  }
8854
- return void 0;
9306
+ return plain;
8855
9307
  }
8856
9308
  function hostHeaderOk(input) {
8857
9309
  if (!isLoopbackBind(input.wsHost)) return true;
@@ -8893,7 +9345,8 @@ function verifyClient(input) {
8893
9345
  expectedToken,
8894
9346
  requireToken,
8895
9347
  allowedHostnames,
8896
- allowBrowserUrlToken
9348
+ allowBrowserUrlToken,
9349
+ allowCrossPortLoopbackCookie
8897
9350
  } = input;
8898
9351
  const urlTokenOk = tokenMatches(extractToken(url ?? ""), expectedToken);
8899
9352
  const cookieTokenOk = tokenMatches(extractTokenFromCookie(cookieHeader), expectedToken);
@@ -8908,7 +9361,10 @@ function verifyClient(input) {
8908
9361
  const { hostname: originHostname } = new URL(origin);
8909
9362
  if (isLoopbackHostname(originHostname)) {
8910
9363
  if (requireToken || !isLoopbackBind(wsHost)) return cookieTokenOk;
8911
- return cookieTokenOk || isTrustedLoopbackOrigin(origin, hostHeader);
9364
+ if (!isTrustedLoopbackOrigin(origin, hostHeader)) {
9365
+ return Boolean(allowCrossPortLoopbackCookie) && cookieTokenOk;
9366
+ }
9367
+ return true;
8912
9368
  }
8913
9369
  return cookieTokenOk || Boolean(allowBrowserUrlToken) && urlTokenOk && allowedHostname(originHostname, allowedHostnames);
8914
9370
  } catch {
@@ -8943,11 +9399,22 @@ ${out}`;
8943
9399
  function firstHeader(value) {
8944
9400
  return Array.isArray(value) ? value[0] : value;
8945
9401
  }
8946
- function wsTokenCookie(token) {
8947
- return `ws_token=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=3600`;
9402
+ var WS_TOKEN_COOKIE = "ws_token";
9403
+ var WS_TOKEN_COOKIE_SECURE = "__Host-ws_token";
9404
+ function wsTokenCookie(token, secure) {
9405
+ const name2 = secure ? WS_TOKEN_COOKIE_SECURE : WS_TOKEN_COOKIE;
9406
+ const parts = [
9407
+ `${name2}=${encodeURIComponent(token)}`,
9408
+ "HttpOnly",
9409
+ "SameSite=Strict",
9410
+ "Path=/",
9411
+ "Max-Age=3600"
9412
+ ];
9413
+ if (secure) parts.push("Secure");
9414
+ return parts.join("; ");
8948
9415
  }
8949
- function setAuthCookieHeaders(res, token) {
8950
- res.setHeader("Set-Cookie", wsTokenCookie(token));
9416
+ function setAuthCookieHeaders(res, token, secure) {
9417
+ res.setHeader("Set-Cookie", wsTokenCookie(token, secure));
8951
9418
  res.setHeader("Cache-Control", "no-store");
8952
9419
  }
8953
9420
  function setStaticSecurityHeaders(res) {
@@ -8955,8 +9422,16 @@ function setStaticSecurityHeaders(res) {
8955
9422
  res.setHeader("X-Frame-Options", "DENY");
8956
9423
  res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
8957
9424
  }
8958
- function requestToken(req, url) {
8959
- return url.searchParams.get("token") ?? firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
9425
+ function requestToken(req, url, opts = {}) {
9426
+ const queryToken = url.searchParams.get("token") ?? void 0;
9427
+ if (queryToken !== void 0 && (opts.allowQuery === true || isLoopbackPeer(req))) {
9428
+ return queryToken;
9429
+ }
9430
+ return firstHeader(req.headers["x-ws-token"]) ?? extractTokenFromCookie(req.headers.cookie);
9431
+ }
9432
+ function isLoopbackPeer(req) {
9433
+ const address = req.socket.remoteAddress?.replace(/^::ffff:/i, "");
9434
+ return address !== void 0 && isLoopbackHostname(address);
8960
9435
  }
8961
9436
  function formatCspHostname(hostname) {
8962
9437
  return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
@@ -9011,7 +9486,8 @@ function strictDecodeParam(segment, res) {
9011
9486
  function createHttpServer(opts) {
9012
9487
  const port = opts.port ?? Number.parseInt(process.env["PORT"] ?? "3456", 10);
9013
9488
  const distDir = path13.resolve(opts.distDir);
9014
- const requireAccessToken = Boolean(opts.requireToken) || !isLoopbackBind(opts.host);
9489
+ const requireAccessToken = true;
9490
+ const secureCookies = opts.secureCookies ?? (opts.publicWsUrl?.trim().toLowerCase().startsWith("wss:") ?? false);
9015
9491
  const trustedHostnames = (() => {
9016
9492
  const names = [...opts.allowedHostnames ?? []];
9017
9493
  if (opts.publicWsUrl) {
@@ -9050,13 +9526,13 @@ function createHttpServer(opts) {
9050
9526
  const accessTokenOk = Boolean(opts.apiToken) && tokenMatches(providedAccessToken, opts.apiToken ?? "");
9051
9527
  const shouldSetAuthCookie = Boolean(opts.apiToken) && tokenMatches(url.searchParams.get("token") ?? void 0, opts.apiToken ?? "");
9052
9528
  if (url.pathname === "/ws-auth" && req.method === "GET" && (opts.enableWsCookie ?? true)) {
9053
- const provided = requestToken(req, url);
9529
+ const provided = requestToken(req, url, { allowQuery: true });
9054
9530
  if (!provided || !opts.apiToken || !tokenMatches(provided, opts.apiToken)) {
9055
9531
  res.writeHead(401, { "Content-Type": "text/plain" });
9056
9532
  res.end("Unauthorized");
9057
9533
  return;
9058
9534
  }
9059
- setAuthCookieHeaders(res, opts.apiToken);
9535
+ setAuthCookieHeaders(res, opts.apiToken, secureCookies);
9060
9536
  res.writeHead(200, { "Content-Type": "text/plain" });
9061
9537
  res.end("ok");
9062
9538
  return;
@@ -9070,7 +9546,7 @@ function createHttpServer(opts) {
9070
9546
  return;
9071
9547
  }
9072
9548
  if (shouldSetAuthCookie && opts.apiToken) {
9073
- setAuthCookieHeaders(res, opts.apiToken);
9549
+ setAuthCookieHeaders(res, opts.apiToken, secureCookies);
9074
9550
  }
9075
9551
  if (url.pathname === "/api/fleet/ping" && req.method === "POST") {
9076
9552
  if (requireAccessToken && !accessTokenOk) {
@@ -9188,6 +9664,88 @@ function createHttpServer(opts) {
9188
9664
  await handleApiAnalyticsSummary(res);
9189
9665
  return;
9190
9666
  }
9667
+ if (url.pathname === "/api/requirement-intakes" && req.method === "GET") {
9668
+ if (requireAccessToken && !accessTokenOk) {
9669
+ res.writeHead(401, { "Content-Type": "application/json" });
9670
+ res.end(JSON.stringify({ error: "Unauthorized" }));
9671
+ return;
9672
+ }
9673
+ await handleRequirementIntakeListForServer(res, opts.intakeService, opts.projectRoot);
9674
+ return;
9675
+ }
9676
+ const intakeProjectMatch = url.pathname.match(
9677
+ /^\/api\/projects\/([^/]+)\/requirement-intakes$/
9678
+ );
9679
+ if (intakeProjectMatch && req.method === "POST") {
9680
+ if (requireAccessToken && !accessTokenOk) {
9681
+ res.writeHead(401, { "Content-Type": "application/json" });
9682
+ res.end(JSON.stringify({ error: "Unauthorized" }));
9683
+ return;
9684
+ }
9685
+ await handleRequirementIntakeCreate(
9686
+ res,
9687
+ req,
9688
+ opts.intakeService,
9689
+ decodeURIComponent(intakeProjectMatch[1])
9690
+ );
9691
+ return;
9692
+ }
9693
+ if (intakeProjectMatch && req.method === "GET") {
9694
+ if (requireAccessToken && !accessTokenOk) {
9695
+ res.writeHead(401, { "Content-Type": "application/json" });
9696
+ res.end(JSON.stringify({ error: "Unauthorized" }));
9697
+ return;
9698
+ }
9699
+ await handleRequirementIntakeList(
9700
+ res,
9701
+ opts.intakeService,
9702
+ decodeURIComponent(intakeProjectMatch[1])
9703
+ );
9704
+ return;
9705
+ }
9706
+ const intakeIdMatch = url.pathname.match(/^\/api\/requirement-intakes\/([^/]+)$/);
9707
+ if (intakeIdMatch && req.method === "GET") {
9708
+ if (requireAccessToken && !accessTokenOk) {
9709
+ res.writeHead(401, { "Content-Type": "application/json" });
9710
+ res.end(JSON.stringify({ error: "Unauthorized" }));
9711
+ return;
9712
+ }
9713
+ await handleRequirementIntakeGet(res, opts.intakeService, intakeIdMatch[1]);
9714
+ return;
9715
+ }
9716
+ if (intakeIdMatch && req.method === "PATCH") {
9717
+ if (requireAccessToken && !accessTokenOk) {
9718
+ res.writeHead(401, { "Content-Type": "application/json" });
9719
+ res.end(JSON.stringify({ error: "Unauthorized" }));
9720
+ return;
9721
+ }
9722
+ await handleRequirementIntakeUpdate(res, req, opts.intakeService, intakeIdMatch[1]);
9723
+ return;
9724
+ }
9725
+ const intakeActionMatch = url.pathname.match(
9726
+ /^\/api\/requirement-intakes\/([^/]+)\/(answers|suggestions|submit|cancel|archive)$/
9727
+ );
9728
+ if (intakeActionMatch && req.method === "POST") {
9729
+ if (requireAccessToken && !accessTokenOk) {
9730
+ res.writeHead(401, { "Content-Type": "application/json" });
9731
+ res.end(JSON.stringify({ error: "Unauthorized" }));
9732
+ return;
9733
+ }
9734
+ const intakeId = intakeActionMatch[1];
9735
+ const action = intakeActionMatch[2];
9736
+ if (action === "answers") {
9737
+ await handleRequirementIntakeAnswers(res, req, opts.intakeService, intakeId);
9738
+ } else if (action === "suggestions") {
9739
+ await handleRequirementIntakeSuggestions(res, req, opts.intakeService, intakeId);
9740
+ } else if (action === "submit") {
9741
+ await handleRequirementIntakeSubmit(res, opts.intakeService, intakeId);
9742
+ } else if (action === "cancel") {
9743
+ await handleRequirementIntakeCancel(res, req, opts.intakeService, intakeId);
9744
+ } else {
9745
+ await handleRequirementIntakeArchive(res, opts.intakeService, intakeId);
9746
+ }
9747
+ return;
9748
+ }
9191
9749
  if (url.pathname === "/api/codemap/packages" && req.method === "GET") {
9192
9750
  if (requireAccessToken && !accessTokenOk) {
9193
9751
  res.writeHead(401, { "Content-Type": "application/json" });
@@ -9383,17 +9941,25 @@ function createHttpServer(opts) {
9383
9941
  res.end(JSON.stringify({ error: "Project root not configured" }));
9384
9942
  return;
9385
9943
  }
9386
- await handleDeadCodeScan(res, {
9387
- projectRoot: opts.projectRoot,
9388
- indexDir: opts.indexDir
9389
- }, req);
9944
+ await handleDeadCodeScan(
9945
+ res,
9946
+ {
9947
+ projectRoot: opts.projectRoot,
9948
+ indexDir: opts.indexDir
9949
+ },
9950
+ req
9951
+ );
9390
9952
  return;
9391
9953
  }
9392
9954
  if (url.pathname === "/api/deadcode/action-plan" && req.method === "POST") {
9393
- await handleDeadCodeActionPlan(res, {
9394
- projectRoot: opts.projectRoot ?? "",
9395
- indexDir: opts.indexDir
9396
- }, req);
9955
+ await handleDeadCodeActionPlan(
9956
+ res,
9957
+ {
9958
+ projectRoot: opts.projectRoot ?? "",
9959
+ indexDir: opts.indexDir
9960
+ },
9961
+ req
9962
+ );
9397
9963
  return;
9398
9964
  }
9399
9965
  if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
@@ -11696,6 +12262,55 @@ import {
11696
12262
  restartMcp,
11697
12263
  updateMcp
11698
12264
  } from "@wrongstack/mcp";
12265
+
12266
+ // src/server/privileged-actions.ts
12267
+ import { randomUUID as randomUUID3 } from "node:crypto";
12268
+ import {
12269
+ isTrustDecisionAllowed
12270
+ } from "@wrongstack/core/security";
12271
+ async function authorizeWebUIAction(boundary, action, logger) {
12272
+ const request = {
12273
+ version: 1,
12274
+ requestId: randomUUID3(),
12275
+ actor: {
12276
+ kind: "remote-client",
12277
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
12278
+ },
12279
+ surface: "webui",
12280
+ capability: action.capability,
12281
+ subject: action.subject,
12282
+ risk: action.risk,
12283
+ scope: {
12284
+ ...action.cwd ? { cwd: action.cwd } : {},
12285
+ ...action.sessionId ? { sessionId: action.sessionId } : {}
12286
+ },
12287
+ authContext: { method: "session" },
12288
+ ...action.metadata ? { metadata: action.metadata } : {}
12289
+ };
12290
+ const decision = await boundary.evaluate(request);
12291
+ logger?.debug?.(
12292
+ `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
12293
+ );
12294
+ return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
12295
+ }
12296
+
12297
+ // src/server/mcp-handlers.ts
12298
+ async function authorizeMcpMutation(ws, operation, serverName, trustBoundary) {
12299
+ if (!trustBoundary) return true;
12300
+ const authorization = await authorizeWebUIAction(trustBoundary, {
12301
+ capability: "mcp.server.configure",
12302
+ subject: { kind: "process", id: serverName },
12303
+ risk: "elevated",
12304
+ metadata: { transport: "websocket", operation }
12305
+ });
12306
+ if (!authorization.allowed) {
12307
+ send(ws, {
12308
+ type: "mcp.operation_result",
12309
+ payload: { success: false, message: `${operation} denied: ${authorization.reason}` }
12310
+ });
12311
+ }
12312
+ return authorization.allowed;
12313
+ }
11699
12314
  function mapStatus(raw) {
11700
12315
  switch (raw) {
11701
12316
  case "connected":
@@ -11770,7 +12385,7 @@ async function handleMcpList(ws, _msg, globalConfigPath, mcpRegistry) {
11770
12385
  payload: { servers: servers.map((server) => toView(server, health.get(server.name))) }
11771
12386
  });
11772
12387
  }
11773
- async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
12388
+ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry, trustBoundary) {
11774
12389
  const d = deps(ws, globalConfigPath, mcpRegistry);
11775
12390
  if (!d) return;
11776
12391
  const validated = validateMcpServerPayload(msg.payload, "mcp.add");
@@ -11781,6 +12396,7 @@ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
11781
12396
  });
11782
12397
  return;
11783
12398
  }
12399
+ if (!await authorizeMcpMutation(ws, "mcp.add", name(msg), trustBoundary)) return;
11784
12400
  const result = await addMcp(validated.value, d);
11785
12401
  if (result.ok && result.server) {
11786
12402
  send(ws, { type: "mcp.server.added", payload: { server: toView(result.server) } });
@@ -11798,7 +12414,7 @@ async function handleMcpAdd(ws, msg, globalConfigPath, mcpRegistry) {
11798
12414
  payload: { success: result.ok, message: result.message }
11799
12415
  });
11800
12416
  }
11801
- async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
12417
+ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry, trustBoundary) {
11802
12418
  const d = deps(ws, globalConfigPath, mcpRegistry);
11803
12419
  if (!d) return;
11804
12420
  const validated = validateMcpServerPayload(msg.payload, "mcp.update");
@@ -11809,6 +12425,7 @@ async function handleMcpUpdate(ws, msg, globalConfigPath, mcpRegistry) {
11809
12425
  });
11810
12426
  return;
11811
12427
  }
12428
+ if (!await authorizeMcpMutation(ws, "mcp.update", name(msg), trustBoundary)) return;
11812
12429
  const result = await updateMcp(validated.value, d);
11813
12430
  if (result.ok && result.server) {
11814
12431
  send(ws, { type: "mcp.server.updated", payload: { server: toView(result.server) } });
@@ -12952,7 +13569,7 @@ function openBrowser(url, platform = process.platform) {
12952
13569
  }
12953
13570
 
12954
13571
  // src/server/port-utils.ts
12955
- import * as net3 from "node:net";
13572
+ import * as net2 from "node:net";
12956
13573
  import { ToolValidationError as ToolValidationError4 } from "@wrongstack/core/types";
12957
13574
  var SURFACE_DEFAULT_PORTS = {
12958
13575
  webui: { http: 3456 },
@@ -12966,7 +13583,7 @@ function getSurfaceDefaultPorts(surface) {
12966
13583
  }
12967
13584
  function isPortFree(host, port) {
12968
13585
  return new Promise((resolve16) => {
12969
- const srv = net3.createServer();
13586
+ const srv = net2.createServer();
12970
13587
  srv.once("error", () => resolve16(false));
12971
13588
  srv.once("listening", () => {
12972
13589
  srv.close(() => resolve16(true));
@@ -13000,6 +13617,27 @@ import { spawn as spawn2 } from "node:child_process";
13000
13617
  import { existsSync } from "node:fs";
13001
13618
  import { findPackageJSON } from "node:module";
13002
13619
  import * as path15 from "node:path";
13620
+
13621
+ // src/server/intake-service.ts
13622
+ import { resolveWstackPaths as resolveWstackPaths4 } from "@wrongstack/core/utils";
13623
+ import {
13624
+ AllowAllIntakeAuthorizer,
13625
+ RequirementIntakeService,
13626
+ RequirementIntakeStore
13627
+ } from "@wrongstack/requirement-intake";
13628
+ function createProjectIntakeService(opts) {
13629
+ return new RequirementIntakeService({
13630
+ store: new RequirementIntakeStore({
13631
+ baseDir: resolveWstackPaths4({
13632
+ projectRoot: opts.projectRoot,
13633
+ globalRoot: opts.globalRoot
13634
+ }).projectRequirementIntakes
13635
+ }),
13636
+ authorizer: new AllowAllIntakeAuthorizer()
13637
+ });
13638
+ }
13639
+
13640
+ // src/server/frontend-static-serve.ts
13003
13641
  function resolveDistDir(input) {
13004
13642
  const options = typeof input === "string" ? { explicitDistDir: input } : input ?? {};
13005
13643
  if (options.explicitDistDir) return path15.resolve(options.explicitDistDir);
@@ -13084,6 +13722,10 @@ async function startStaticServe(opts, deps2 = {}) {
13084
13722
  const create = deps2.createServer ?? createHttpServer;
13085
13723
  const distDir = deps2.resolveDist ? deps2.resolveDist(opts.distDir) : await ensureDist(opts.distDir);
13086
13724
  if (distDir === null) return null;
13725
+ const intakeService = opts.intakeService ?? (opts.projectRoot ? createProjectIntakeService({
13726
+ projectRoot: opts.projectRoot,
13727
+ globalRoot: opts.globalRoot
13728
+ }) : void 0);
13087
13729
  const server = create({
13088
13730
  host: opts.host,
13089
13731
  port: opts.httpPort,
@@ -13096,7 +13738,8 @@ async function startStaticServe(opts, deps2 = {}) {
13096
13738
  publicWsUrl: opts.publicWsUrl,
13097
13739
  apiToken: opts.apiToken,
13098
13740
  requireToken: opts.requireToken,
13099
- allowedHostnames: opts.allowedHostnames
13741
+ allowedHostnames: opts.allowedHostnames,
13742
+ intakeService
13100
13743
  });
13101
13744
  if (!opts.deferListen) {
13102
13745
  server.listen(opts.httpPort, opts.host);
@@ -13201,7 +13844,8 @@ function registerWebuiInstance(p, deps2 = {}) {
13201
13844
  host: p.host,
13202
13845
  port: p.httpPort,
13203
13846
  publicUrl: p.publicUrl
13204
- })
13847
+ }),
13848
+ ...p.authToken ? { authToken: p.authToken } : {}
13205
13849
  },
13206
13850
  p.registryBaseDir
13207
13851
  ).catch(() => {
@@ -13235,10 +13879,31 @@ ${extraBlock}`
13235
13879
  if (p.open) launch(openUrl);
13236
13880
  });
13237
13881
  }
13882
+ var DEFAULT_CHILD_CLEANUP_TIMEOUT_MS = 1e4;
13883
+ async function runBounded(work, timeoutMs, label, debug) {
13884
+ let timer;
13885
+ try {
13886
+ await Promise.race([
13887
+ Promise.resolve().then(() => work()).catch((err) => {
13888
+ debug(`[webui-server] ${label} failed: ${err}`);
13889
+ }),
13890
+ new Promise((resolve16) => {
13891
+ timer = setTimeout(() => {
13892
+ debug(`[webui-server] ${label} timed out after ${timeoutMs}ms`);
13893
+ resolve16();
13894
+ }, timeoutMs);
13895
+ timer.unref?.();
13896
+ })
13897
+ ]);
13898
+ } finally {
13899
+ if (timer) clearTimeout(timer);
13900
+ }
13901
+ }
13238
13902
  function createWebuiShutdown(res) {
13239
13903
  const log = res.log ?? ((m) => console.log(m));
13240
13904
  const debug = res.debug ?? ((m) => console.debug(m));
13241
13905
  const unregister = res.unregisterFn ?? unregisterInstance;
13906
+ const childTimeout = Math.max(1, res.childCleanupTimeoutMs ?? DEFAULT_CHILD_CLEANUP_TIMEOUT_MS);
13242
13907
  let started = false;
13243
13908
  return () => {
13244
13909
  if (started) return;
@@ -13246,17 +13911,30 @@ function createWebuiShutdown(res) {
13246
13911
  log("[WebUI] Shutting down...");
13247
13912
  res.abortInFlight();
13248
13913
  res.unsubscribeEvents();
13249
- res.disposeResources?.();
13250
- res.closeClients();
13251
- const unregistered = unregister(res.pid, res.registryBaseDir).catch(
13252
- (err) => debug(`[webui-server] unregister failed: ${err}`)
13253
- );
13254
- res.closeHttpServer();
13255
- res.wss.close(() => {
13256
- void unregistered.then(() => {
13257
- log("[WebUI] Server stopped");
13258
- res.onStopped();
13914
+ void (async () => {
13915
+ if (res.stopOwnedChildren) {
13916
+ await runBounded(res.stopOwnedChildren, childTimeout, "stopOwnedChildren", debug);
13917
+ }
13918
+ if (res.disposeResources) {
13919
+ await runBounded(res.disposeResources, Math.min(childTimeout, 5e3), "disposeResources", debug);
13920
+ }
13921
+ res.closeClients();
13922
+ res.closeHttpServer();
13923
+ const unregistered = unregister(res.pid, res.registryBaseDir).catch(
13924
+ (err) => debug(`[webui-server] unregister failed: ${err}`)
13925
+ );
13926
+ await new Promise((resolve16) => {
13927
+ res.wss.close(() => resolve16());
13259
13928
  });
13929
+ await unregistered;
13930
+ log("[WebUI] Server stopped");
13931
+ res.onStopped();
13932
+ })().catch((err) => {
13933
+ debug(`[webui-server] shutdown sequence failed: ${err}`);
13934
+ try {
13935
+ res.onStopped();
13936
+ } catch {
13937
+ }
13260
13938
  });
13261
13939
  };
13262
13940
  }
@@ -13511,7 +14189,21 @@ function createWebuiClientPresence(deps2) {
13511
14189
  }
13512
14190
 
13513
14191
  // src/server/brain-handlers.ts
14192
+ import { BUILTIN_COUNCIL_PERSONAS } from "@wrongstack/core/execution";
13514
14193
  import { toErrorMessage as toErrorMessage5 } from "@wrongstack/core/utils";
14194
+ var COUNCIL_PERSONA_CATALOG = Object.freeze(
14195
+ BUILTIN_COUNCIL_PERSONAS.map(
14196
+ (persona) => Object.freeze({
14197
+ id: persona.id,
14198
+ name: persona.name,
14199
+ description: persona.description,
14200
+ ...persona.defaultVeto !== void 0 ? { defaultVeto: persona.defaultVeto } : {}
14201
+ })
14202
+ )
14203
+ );
14204
+ function brainConfigPayload(runtime) {
14205
+ return { ...runtime.getSnapshot(), personaCatalog: COUNCIL_PERSONA_CATALOG };
14206
+ }
13515
14207
  function sendResult6(ctx, ws, success, message) {
13516
14208
  ctx.send(ws, { type: "key.operation_result", payload: { success, message } });
13517
14209
  }
@@ -13551,7 +14243,7 @@ function handleBrainConfigGet(ctx, ws) {
13551
14243
  }
13552
14244
  ctx.send(ws, {
13553
14245
  type: "brain.config",
13554
- payload: { config: ctx.brainRuntime.getSnapshot(), persisted: true }
14246
+ payload: { config: brainConfigPayload(ctx.brainRuntime), persisted: true }
13555
14247
  });
13556
14248
  }
13557
14249
  async function handleBrainConfigSet(ctx, ws, payload) {
@@ -13575,7 +14267,7 @@ async function handleBrainConfigSet(ctx, ws, payload) {
13575
14267
  ctx.send(ws, {
13576
14268
  type: "brain.config",
13577
14269
  payload: {
13578
- config: ctx.brainRuntime.getSnapshot(),
14270
+ config: brainConfigPayload(ctx.brainRuntime),
13579
14271
  persisted: result.ok,
13580
14272
  ...result.ok ? {} : { error: result.error ?? "Persist failed." }
13581
14273
  }
@@ -13585,7 +14277,7 @@ async function handleBrainConfigSet(ctx, ws, payload) {
13585
14277
  ctx.send(ws, {
13586
14278
  type: "brain.config",
13587
14279
  payload: {
13588
- config: ctx.brainRuntime.getSnapshot(),
14280
+ config: brainConfigPayload(ctx.brainRuntime),
13589
14281
  persisted: false,
13590
14282
  error: `Invalid Brain setting: ${toErrorMessage5(err)}`
13591
14283
  }
@@ -14350,6 +15042,8 @@ function buildAuditPrompt(board, health) {
14350
15042
 
14351
15043
  // src/server/context-meta.ts
14352
15044
  import { FallbackProfileManager } from "@wrongstack/core/agent";
15045
+ import { resolvePluginEnablement } from "@wrongstack/core/plugin";
15046
+ import { FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS2 } from "@wrongstack/core/utils";
14353
15047
  function seedContextMeta(config, context) {
14354
15048
  const meta = context.meta;
14355
15049
  const autonomyCfg = config.autonomy ?? {};
@@ -14426,6 +15120,21 @@ function seedContextMeta(config, context) {
14426
15120
  meta["tgDelegate"] = tgExt?.["notifyOnDelegate"] !== false;
14427
15121
  const tgMs = tgExt?.["longToolThresholdMs"];
14428
15122
  meta["tgLongToolMs"] = typeof tgMs === "number" ? tgMs : 3e4;
15123
+ {
15124
+ const pluginsEnabled = {};
15125
+ const record2 = (name2) => {
15126
+ if (FORBIDDEN_PROTO_KEYS2.has(name2) || name2 in pluginsEnabled) return;
15127
+ pluginsEnabled[name2] = resolvePluginEnablement({ name: name2, config }).enabled;
15128
+ };
15129
+ for (const entry of config.plugins ?? []) {
15130
+ const name2 = typeof entry === "string" ? entry : entry?.name;
15131
+ if (typeof name2 === "string") record2(name2);
15132
+ }
15133
+ for (const [name2, options] of Object.entries(config.extensions ?? {})) {
15134
+ if (typeof options?.["enabled"] === "boolean") record2(name2);
15135
+ }
15136
+ if (Object.keys(pluginsEnabled).length > 0) meta["pluginsEnabled"] = pluginsEnabled;
15137
+ }
14429
15138
  const chimeraExt = config.extensions?.["wstack-chimera"];
14430
15139
  meta["chimeraEnabled"] = chimeraExt?.["enabled"] === true;
14431
15140
  meta["chimeraProvider"] = chimeraExt?.["provider"] ?? "";
@@ -14463,8 +15172,9 @@ function seedContextMeta(config, context) {
14463
15172
  // src/server/pref-helpers.ts
14464
15173
  import * as fs13 from "node:fs/promises";
14465
15174
  import * as path18 from "node:path";
15175
+ import { pluginEntryMatchesName } from "@wrongstack/core/plugin";
14466
15176
  import { decryptConfigSecrets as decryptConfigSecrets2, encryptConfigSecrets } from "@wrongstack/core/security";
14467
- import { atomicWrite as atomicWrite6, backupConfigFile, FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS2 } from "@wrongstack/core/utils";
15177
+ import { atomicWrite as atomicWrite6, backupConfigFile, FORBIDDEN_PROTO_KEYS as FORBIDDEN_PROTO_KEYS3 } from "@wrongstack/core/utils";
14468
15178
  var PREF_KEYS = [
14469
15179
  "autonomy",
14470
15180
  "autonomyDelayMs",
@@ -14545,6 +15255,8 @@ var PREF_KEYS = [
14545
15255
  // Display-only toggles (purely visual WebUI prefs, not persisted to config).
14546
15256
  "groupToolCalls",
14547
15257
  "showThinkingLogs",
15258
+ // v15: chat-input auto-collapse (opt-in display toggle, default off).
15259
+ "autoCollapseInput",
14548
15260
  // Per-plugin enable/disable map (parity with the embedded server).
14549
15261
  "pluginsEnabled",
14550
15262
  // Fleet chat verbosity: off | full (migrated from streamFleet boolean).
@@ -14599,6 +15311,8 @@ async function updateGlobalConfig(deps2, holder, mutate, errorLabel) {
14599
15311
  var DISPLAY_ONLY_KEYS = /* @__PURE__ */ new Set([
14600
15312
  "groupToolCalls",
14601
15313
  "showThinkingLogs",
15314
+ // v15: chat-input auto-collapse (opt-in display toggle, default off).
15315
+ "autoCollapseInput",
14602
15316
  "autoReviewFallbackModels",
14603
15317
  // v11 Display parity: agent-swarm panel + inverse fsAccess flag.
14604
15318
  // The TUI settings picker mirrors these so the browser can keep the
@@ -14809,15 +15523,29 @@ async function persistPrefsToConfig(deps2, holder, payload) {
14809
15523
  decrypted.debugStream = payload["debugStream"];
14810
15524
  if (typeof payload["pluginsEnabled"] === "object" && payload["pluginsEnabled"] !== null) {
14811
15525
  const ext = decrypted.extensions ?? {};
15526
+ const toggled = [];
14812
15527
  for (const [pluginName, enabled] of Object.entries(
14813
15528
  payload["pluginsEnabled"]
14814
15529
  )) {
14815
- if (FORBIDDEN_PROTO_KEYS2.has(pluginName)) continue;
15530
+ if (FORBIDDEN_PROTO_KEYS3.has(pluginName)) continue;
15531
+ if (typeof enabled !== "boolean") continue;
14816
15532
  const pExt = ext[pluginName] ?? {};
14817
15533
  pExt["enabled"] = enabled;
14818
15534
  ext[pluginName] = pExt;
15535
+ toggled.push([pluginName, enabled]);
14819
15536
  }
14820
15537
  decrypted.extensions = ext;
15538
+ if (Array.isArray(decrypted.plugins) && toggled.length > 0) {
15539
+ decrypted.plugins = decrypted.plugins.map((entry) => {
15540
+ const entryName = typeof entry === "string" ? entry : entry?.name;
15541
+ if (typeof entryName !== "string") return entry;
15542
+ const hit = toggled.find(([name2]) => pluginEntryMatchesName(entryName, name2));
15543
+ if (!hit) return entry;
15544
+ const [, enabled] = hit;
15545
+ if (typeof entry === "string") return enabled ? entry : { name: entry, enabled: false };
15546
+ return { ...entry, enabled };
15547
+ });
15548
+ }
14821
15549
  }
14822
15550
  const chimeraTouched = typeof payload["chimeraEnabled"] === "boolean" || typeof payload["chimeraProvider"] === "string" || typeof payload["chimeraModel"] === "string" || typeof payload["chimeraMaxFiles"] === "number" || typeof payload["chimeraAutoFix"] === "string";
14823
15551
  if (chimeraTouched) {
@@ -14926,39 +15654,6 @@ async function handlePrefsRoute(ws, msg, handlers) {
14926
15654
  // src/server/process-handlers.ts
14927
15655
  import { createCompatibilityTrustBoundary } from "@wrongstack/core/security";
14928
15656
  import { getProcessRegistry as getProcessRegistry2 } from "@wrongstack/tools";
14929
-
14930
- // src/server/privileged-actions.ts
14931
- import { randomUUID as randomUUID4 } from "node:crypto";
14932
- import {
14933
- isTrustDecisionAllowed
14934
- } from "@wrongstack/core/security";
14935
- async function authorizeWebUIAction(boundary, action, logger) {
14936
- const request = {
14937
- version: 1,
14938
- requestId: randomUUID4(),
14939
- actor: {
14940
- kind: "remote-client",
14941
- ...action.sessionId ? { sessionId: action.sessionId } : {}
14942
- },
14943
- surface: "webui",
14944
- capability: action.capability,
14945
- subject: action.subject,
14946
- risk: action.risk,
14947
- scope: {
14948
- ...action.cwd ? { cwd: action.cwd } : {},
14949
- ...action.sessionId ? { sessionId: action.sessionId } : {}
14950
- },
14951
- authContext: { method: "session" },
14952
- ...action.metadata ? { metadata: action.metadata } : {}
14953
- };
14954
- const decision = await boundary.evaluate(request);
14955
- logger?.debug?.(
14956
- `[trust-boundary] ${request.capability} ${decision.kind} request=${request.requestId}`
14957
- );
14958
- return { allowed: isTrustDecisionAllowed(decision), reason: decision.reason, request };
14959
- }
14960
-
14961
- // src/server/process-handlers.ts
14962
15657
  function handleProcessList(ws) {
14963
15658
  try {
14964
15659
  const procs = getProcessRegistry2().list();
@@ -15069,7 +15764,7 @@ import { makeProviderFromConfig } from "@wrongstack/providers";
15069
15764
  import * as fs14 from "node:fs/promises";
15070
15765
  import * as path19 from "node:path";
15071
15766
  import { DefaultSessionStore } from "@wrongstack/core/storage";
15072
- import { resolveWstackPaths as resolveWstackPaths4 } from "@wrongstack/core/utils";
15767
+ import { resolveWstackPaths as resolveWstackPaths5 } from "@wrongstack/core/utils";
15073
15768
  function createProjectHandlers(ctx) {
15074
15769
  const sendTo = (ws, message) => {
15075
15770
  if (ctx.sendMessage) ctx.sendMessage(ws, message);
@@ -15187,7 +15882,7 @@ function createProjectHandlers(ctx) {
15187
15882
  });
15188
15883
  return;
15189
15884
  }
15190
- const paths = resolveWstackPaths4({
15885
+ const paths = resolveWstackPaths5({
15191
15886
  projectRoot: resolved,
15192
15887
  globalRoot: ctx.wpaths.globalRoot
15193
15888
  });
@@ -15198,7 +15893,7 @@ function createProjectHandlers(ctx) {
15198
15893
  const previous = ctx.getSession();
15199
15894
  const previousId = previous.id;
15200
15895
  const previousProjectRoot = ctx.getProjectRoot();
15201
- const previousPaths = resolveWstackPaths4({
15896
+ const previousPaths = resolveWstackPaths5({
15202
15897
  projectRoot: previousProjectRoot,
15203
15898
  globalRoot: ctx.wpaths.globalRoot
15204
15899
  });
@@ -15782,8 +16477,10 @@ function createProviderOperations(deps2) {
15782
16477
  if (result.ok) {
15783
16478
  deps2.log?.(`[WebUI] Provider "${payload.id}" added via provider.add`);
15784
16479
  }
16480
+ return result.ok;
15785
16481
  } catch (err) {
15786
16482
  sendOperationResult(ws, false, errMessage(err));
16483
+ return false;
15787
16484
  }
15788
16485
  }
15789
16486
  async function handleProviderRemove(ws, providerId) {
@@ -16167,6 +16864,7 @@ var CLIENT_CONVERSATION_MESSAGE_TYPES = [
16167
16864
  "completion.request",
16168
16865
  "model.switch",
16169
16866
  "model.refine",
16867
+ "model.fallback_choice",
16170
16868
  "autonomy.switch",
16171
16869
  "context.clear",
16172
16870
  "context.compact",
@@ -16184,6 +16882,7 @@ var CLIENT_CONVERSATION_MESSAGE_TYPES = [
16184
16882
  "modes.list",
16185
16883
  "session.checkpoints",
16186
16884
  "session.delete",
16885
+ "session.inspect",
16187
16886
  "session.new",
16188
16887
  "session.rename",
16189
16888
  "session.resume",
@@ -16437,6 +17136,7 @@ var SERVER_CONVERSATION_MESSAGE_TYPES = [
16437
17136
  "provider.active_blocked",
16438
17137
  "provider.error",
16439
17138
  "provider.fallback",
17139
+ "provider.fallback_pending",
16440
17140
  "provider.response",
16441
17141
  "provider.retry",
16442
17142
  "provider.status_changed",
@@ -16447,6 +17147,7 @@ var SERVER_CONVERSATION_MESSAGE_TYPES = [
16447
17147
  "session.checkpoints",
16448
17148
  "session.damaged",
16449
17149
  "session.end",
17150
+ "session.inspect",
16450
17151
  "session.rewound",
16451
17152
  "session.start",
16452
17153
  "session.stats",
@@ -16477,6 +17178,7 @@ var SERVER_COLLABORATION_MESSAGE_TYPES = [
16477
17178
  "collab.state",
16478
17179
  "mailbox.action_result",
16479
17180
  "mailbox.agent_registered",
17181
+ "mailbox.agent_deregistered",
16480
17182
  "mailbox.agents",
16481
17183
  "mailbox.cleared",
16482
17184
  "mailbox.compacted",
@@ -16856,6 +17558,13 @@ function projectToolMessage(message) {
16856
17558
  }
16857
17559
  return null;
16858
17560
  }
17561
+ function optionalFinite(value) {
17562
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
17563
+ return value;
17564
+ }
17565
+ function optionalString2(value) {
17566
+ return typeof value === "string" && value.length > 0 ? value : void 0;
17567
+ }
16859
17568
  function projectFleetMessage(message) {
16860
17569
  const payload = record(message.payload);
16861
17570
  if (!payload) return null;
@@ -16864,7 +17573,15 @@ function projectFleetMessage(message) {
16864
17573
  return {
16865
17574
  kind: "concurrency",
16866
17575
  active: finite(payload["fleetConcurrency"]),
16867
- maximum: finite(payload["fleetConcurrencyMax"])
17576
+ maximum: finite(payload["fleetConcurrencyMax"]),
17577
+ maxSpawns: optionalFinite(payload["maxSpawns"]),
17578
+ usedSpawns: optionalFinite(payload["usedSpawns"]),
17579
+ remainingSpawns: optionalFinite(payload["remainingSpawns"]),
17580
+ maxSpawnsSource: optionalString2(payload["maxSpawnsSource"]),
17581
+ maxConcurrentSource: optionalString2(payload["maxConcurrentSource"]),
17582
+ effectiveSource: optionalString2(payload["effectiveSource"]),
17583
+ checkpointMaxSpawns: optionalFinite(payload["checkpointMaxSpawns"]),
17584
+ ceilingMismatch: payload["ceilingMismatch"] === true ? true : void 0
16868
17585
  };
16869
17586
  case "client.status_update":
16870
17587
  return { kind: "client-status", status: payload };
@@ -16981,6 +17698,271 @@ function negotiateProtocol(peer) {
16981
17698
  }
16982
17699
 
16983
17700
  // src/server/session-history.ts
17701
+ function blockText(block) {
17702
+ if (typeof block === "string") return block;
17703
+ switch (block.type) {
17704
+ case "text":
17705
+ return block.text;
17706
+ case "thinking":
17707
+ return block.thinking;
17708
+ case "tool_use":
17709
+ return `[tool:${block.name}]`;
17710
+ case "tool_result":
17711
+ return block.content;
17712
+ case "image":
17713
+ return "[image]";
17714
+ default: {
17715
+ const _exhaustive = block;
17716
+ void _exhaustive;
17717
+ return "[block]";
17718
+ }
17719
+ }
17720
+ }
17721
+ function labelForEvent(e) {
17722
+ switch (e.type) {
17723
+ case "session_start":
17724
+ return "Session started";
17725
+ case "session_resumed":
17726
+ return "Session resumed";
17727
+ case "session_forked":
17728
+ return `Forked from ${e.parentSessionId}`;
17729
+ case "user_input": {
17730
+ const content = typeof e.content === "string" ? e.content : Array.isArray(e.content) ? e.content.map(blockText).join("") : "[input]";
17731
+ return `User: ${content.length > 100 ? content.slice(0, 100) + "\u2026" : content}`;
17732
+ }
17733
+ case "llm_request":
17734
+ return "LLM request";
17735
+ case "llm_response":
17736
+ return "LLM response";
17737
+ case "tool_use":
17738
+ return `Tool: ${e.name}`;
17739
+ case "tool_call_start":
17740
+ return `Tool start: ${e.name}`;
17741
+ case "tool_call_end":
17742
+ return `Tool done: ${e.name} (${e.durationMs}ms, ${e.ok === false ? "error" : "ok"})`;
17743
+ case "tool_result":
17744
+ return `Tool result: ${e.id}${e.isError ? " (error)" : ""}`;
17745
+ case "tool_progress":
17746
+ return `Progress: ${e.name} \u2014 ${e.event.type}`;
17747
+ case "compaction":
17748
+ return `Compaction: ${e.before} \u2192 ${e.after} tokens`;
17749
+ case "context_snapshot":
17750
+ return "Context snapshot";
17751
+ case "error":
17752
+ return `Error: ${e.message.length > 100 ? e.message.slice(0, 100) + "\u2026" : e.message}`;
17753
+ case "session_end":
17754
+ return "Session ended";
17755
+ case "message_appended":
17756
+ return `Message appended: ${e.message.role}`;
17757
+ case "message_updated":
17758
+ return "Message updated";
17759
+ case "messages_replaced": {
17760
+ const count = e.messagesOmitted ?? e.messages.length;
17761
+ return `Messages replaced (${e.messagesOmitted ? "~" : ""}${count} msgs)`;
17762
+ }
17763
+ case "message_truncated":
17764
+ return `Message truncated: ${e.before} \u2192 ${e.after}`;
17765
+ case "file_event":
17766
+ return `File ${e.operation}: ${e.filePath} (${e.toolName})`;
17767
+ case "file_snapshot":
17768
+ return `File snapshot at prompt #${e.promptIndex}`;
17769
+ case "file_observation":
17770
+ return `File observation: ${e.path}`;
17771
+ case "rewound":
17772
+ return `Rewound to prompt #${e.toPromptIndex} (${e.revertedFiles.length} files)`;
17773
+ case "mode_changed":
17774
+ return `Mode: ${e.from} \u2192 ${e.to}`;
17775
+ case "task_created":
17776
+ return `Task created: ${e.title}`;
17777
+ case "task_updated":
17778
+ return `Task ${e.taskId}: ${e.status}`;
17779
+ case "task_completed":
17780
+ return `Task done: ${e.title}`;
17781
+ case "task_failed":
17782
+ return `Task failed: ${e.title} \u2014 ${e.error.length > 80 ? e.error.slice(0, 80) + "\u2026" : e.error}`;
17783
+ case "agent_spawned":
17784
+ return `Agent spawned: ${e.role}`;
17785
+ case "agent_stopped":
17786
+ return "Agent stopped";
17787
+ case "agent_error":
17788
+ return `Agent error: ${e.error.length > 80 ? e.error.slice(0, 80) + "\u2026" : e.error}`;
17789
+ case "provider_retry":
17790
+ return `Retry: ${e.description} (attempt ${e.attempt})`;
17791
+ case "provider_error":
17792
+ return `Provider error: ${e.description}`;
17793
+ case "checkpoint":
17794
+ return `Checkpoint at prompt #${e.promptIndex}`;
17795
+ case "in_flight_start":
17796
+ return `In-flight started: ${e.context}`;
17797
+ case "in_flight_end":
17798
+ return `In-flight ended: ${e.reason}`;
17799
+ case "side_effect":
17800
+ return `Side effect: ${e.toolName} (${e.risk})`;
17801
+ case "spec_parsed":
17802
+ return `Spec parsed: ${e.title}`;
17803
+ case "spec_analyzed":
17804
+ return `Spec analyzed: ${e.specId}`;
17805
+ case "skill_activated":
17806
+ return `Skill: ${e.skillName}`;
17807
+ case "skill_deactivated":
17808
+ return `Skill done: ${e.skillName}`;
17809
+ default: {
17810
+ const _exhaustive = e;
17811
+ void _exhaustive;
17812
+ return String(_exhaustive);
17813
+ }
17814
+ }
17815
+ }
17816
+ function detailForEvent(e) {
17817
+ switch (e.type) {
17818
+ case "session_start":
17819
+ return `${e.model} @ ${e.provider}`;
17820
+ case "session_resumed":
17821
+ return `${e.model} @ ${e.provider}`;
17822
+ case "session_forked":
17823
+ return `parent checkpoint: ${e.parentCheckpointHash.slice(0, 12)}\u2026`;
17824
+ case "llm_request":
17825
+ return `${e.model} \xB7 ${e.messageCount} msgs \xB7 ${e.toolCount ?? "?"} tools`;
17826
+ case "llm_response":
17827
+ return `${e.stopReason} \xB7 ${e.usage.input ?? 0}+${e.usage.output ?? 0} tokens`;
17828
+ case "tool_use":
17829
+ return `id: ${e.id}`;
17830
+ case "tool_call_start":
17831
+ return `id: ${e.id}`;
17832
+ case "tool_call_end":
17833
+ return `${(e.outputBytes ?? e.outputSize ?? 0).toLocaleString()} B \xB7 ${e.outputLines ?? 0} lines`;
17834
+ case "tool_progress":
17835
+ return `${e.event.type}${e.event.text ? `: ${e.event.text}` : ""}`;
17836
+ case "compaction":
17837
+ return `saved ~${Math.max(0, e.before - e.after)} tokens`;
17838
+ case "context_snapshot":
17839
+ return `${e.messages.length} msgs${e.messagesOmitted ? ` (${e.messagesOmitted} omitted)` : ""}`;
17840
+ case "error":
17841
+ return `phase: ${e.phase}`;
17842
+ case "session_end":
17843
+ return `${e.usage.input ?? 0}+${e.usage.output ?? 0} total tokens`;
17844
+ case "message_appended":
17845
+ return `appended ${e.message.role}`;
17846
+ case "message_updated":
17847
+ return `at index ${e.index}`;
17848
+ case "messages_replaced":
17849
+ return `${e.messagesOmitted ?? e.messages.length} total`;
17850
+ case "message_truncated":
17851
+ return `truncated to ${e.after} tokens`;
17852
+ case "mode_changed":
17853
+ return `${e.from} \u2192 ${e.to}`;
17854
+ case "task_created":
17855
+ return `id: ${e.taskId}`;
17856
+ case "task_updated":
17857
+ return `${e.taskId}: ${e.status}`;
17858
+ case "task_completed":
17859
+ return `${e.taskId}`;
17860
+ case "task_failed":
17861
+ return `${e.taskId}: ${e.error}`;
17862
+ case "agent_spawned":
17863
+ return `id: ${e.agentId} (${e.role})`;
17864
+ case "agent_stopped":
17865
+ return `id: ${e.agentId}`;
17866
+ case "agent_error":
17867
+ return `id: ${e.agentId}: ${e.error}`;
17868
+ case "file_event":
17869
+ return `${e.filePath} (${e.toolName})`;
17870
+ case "file_snapshot":
17871
+ return `${e.files.length} files at prompt #${e.promptIndex}`;
17872
+ case "file_observation":
17873
+ return `${e.source === "user" ? "user-saved" : "tool-written"} at ${e.path}`;
17874
+ case "rewound":
17875
+ return `${e.revertedFiles.length} files reverted`;
17876
+ case "in_flight_start":
17877
+ return e.context;
17878
+ case "in_flight_end":
17879
+ return `${e.reason}`;
17880
+ case "side_effect":
17881
+ return `${e.toolName} (${e.risk})${e.outcome ? `: ${e.outcome}` : ""}`;
17882
+ case "provider_retry":
17883
+ return `delay ${e.delayMs}ms${e.status ? ` \xB7 HTTP ${e.status}` : ""}`;
17884
+ case "provider_error":
17885
+ return `${e.retryable ? "retryable" : "fatal"}${e.status ? ` \xB7 HTTP ${e.status}` : ""}`;
17886
+ case "user_input":
17887
+ return typeof e.content === "string" ? `${e.content.length} chars` : `${e.content.length} blocks`;
17888
+ case "tool_result":
17889
+ return `${e.isError ? "error" : "ok"}`;
17890
+ case "checkpoint":
17891
+ return `prompt #${e.promptIndex}`;
17892
+ case "spec_parsed":
17893
+ return `${e.title} (${e.completeness}% complete)`;
17894
+ case "spec_analyzed":
17895
+ return `${e.gaps.length} gaps identified`;
17896
+ case "skill_activated":
17897
+ return `at ${e.skillName}`;
17898
+ case "skill_deactivated":
17899
+ return `at ${e.skillName}`;
17900
+ default: {
17901
+ const _exhaustive = e;
17902
+ void _exhaustive;
17903
+ return "";
17904
+ }
17905
+ }
17906
+ }
17907
+ function buildInspectPayload(summary, events, fallback) {
17908
+ const inspectEvents = events.map((e) => ({
17909
+ ts: e.ts,
17910
+ type: e.type,
17911
+ label: labelForEvent(e),
17912
+ detail: detailForEvent(e)
17913
+ }));
17914
+ const fileEvents = [];
17915
+ let computedToolCallCount = 0;
17916
+ let computedToolErrorCount = 0;
17917
+ let computedFileChangeCount = 0;
17918
+ let computedCompactionCount = 0;
17919
+ let computedMessageCount = 0;
17920
+ let computedIterationCount = 0;
17921
+ const computedToolBreakdown = {};
17922
+ for (const e of events) {
17923
+ if (e.type === "file_event") {
17924
+ fileEvents.push({
17925
+ operation: e.operation,
17926
+ filePath: e.filePath,
17927
+ toolName: e.toolName,
17928
+ ts: e.ts
17929
+ });
17930
+ computedFileChangeCount++;
17931
+ } else if (e.type === "tool_call_end") {
17932
+ computedToolCallCount++;
17933
+ if (e.ok === false) computedToolErrorCount++;
17934
+ computedToolBreakdown[e.name] = (computedToolBreakdown[e.name] ?? 0) + 1;
17935
+ } else if (e.type === "compaction") {
17936
+ computedCompactionCount++;
17937
+ } else if (e.type === "user_input") {
17938
+ computedMessageCount++;
17939
+ } else if (e.type === "llm_response") {
17940
+ computedIterationCount++;
17941
+ }
17942
+ }
17943
+ const s = summary;
17944
+ return {
17945
+ id: s?.id ?? fallback.id,
17946
+ title: s?.title ?? fallback.title,
17947
+ ...s?.name !== void 0 ? { name: s.name } : {},
17948
+ model: s?.model ?? fallback.model,
17949
+ provider: s?.provider ?? fallback.provider,
17950
+ startedAt: s?.startedAt ?? fallback.startedAt,
17951
+ ...(s?.endedAt ?? fallback.endedAt) !== void 0 ? { endedAt: s?.endedAt ?? fallback.endedAt } : {},
17952
+ tokenTotal: s?.tokenTotal ?? 0,
17953
+ ...s?.outcome !== void 0 ? { outcome: s.outcome } : {},
17954
+ messageCount: s?.messageCount ?? computedMessageCount,
17955
+ iterationCount: s?.iterationCount ?? computedIterationCount,
17956
+ toolCallCount: s?.toolCallCount ?? computedToolCallCount,
17957
+ toolErrorCount: s?.toolErrorCount ?? computedToolErrorCount,
17958
+ fileChangeCount: s?.fileChangeCount ?? computedFileChangeCount,
17959
+ compactionCount: s?.compactionCount ?? computedCompactionCount,
17960
+ toolBreakdown: s?.toolBreakdown ?? computedToolBreakdown,
17961
+ events: inspectEvents,
17962
+ fileEvents,
17963
+ ...s?.lastUserMessage !== void 0 ? { lastUserMessage: s.lastUserMessage } : {}
17964
+ };
17965
+ }
16984
17966
  function toSessionHistoryEntry(summary, currentSessionId2) {
16985
17967
  return {
16986
17968
  id: summary.id,
@@ -17255,6 +18237,7 @@ function createSessionHandlers(ctx) {
17255
18237
  tools: ctx.listTools?.() ?? ctx.toolRegistry?.list(),
17256
18238
  baseRevision: typeof payload["baseRevision"] === "string" ? payload["baseRevision"] : "",
17257
18239
  messages: payload["messages"],
18240
+ removals: payload["removals"],
17258
18241
  allowRepair: payload["allowRepair"] === true,
17259
18242
  runActive: ctx.isRunActive?.() === true
17260
18243
  });
@@ -17271,6 +18254,7 @@ function createSessionHandlers(ctx) {
17271
18254
  tools: ctx.listTools?.() ?? ctx.toolRegistry?.list(),
17272
18255
  baseRevision: typeof payload["baseRevision"] === "string" ? payload["baseRevision"] : "",
17273
18256
  messages: payload["messages"],
18257
+ removals: payload["removals"],
17274
18258
  allowRepair: payload["allowRepair"] === true,
17275
18259
  runActive: ctx.isRunActive?.() === true
17276
18260
  });
@@ -17527,6 +18511,47 @@ function createSessionHandlers(ctx) {
17527
18511
  if (!ensureCurrentSession(ws, msg, "session.save")) return;
17528
18512
  result(ws, true, `Session ${ctx.getSession().id} is auto-saved`);
17529
18513
  },
18514
+ inspectSession: async (ws, msg) => {
18515
+ const { id } = msg.payload;
18516
+ if (!id) {
18517
+ sendTo(ws, {
18518
+ type: "session.inspect",
18519
+ payload: { id: "", error: "Session id is required" }
18520
+ });
18521
+ return;
18522
+ }
18523
+ try {
18524
+ const store = ctx.getSessionStore();
18525
+ const data = await store.load(id);
18526
+ let summary;
18527
+ try {
18528
+ const summaries = await store.list(200);
18529
+ summary = summaries.find((s) => s.id === id);
18530
+ } catch {
18531
+ summary = void 0;
18532
+ }
18533
+ const payload = buildInspectPayload(summary, data.events, {
18534
+ id: data.metadata.id,
18535
+ title: data.metadata.title ?? "",
18536
+ model: data.metadata.model ?? "",
18537
+ provider: data.metadata.provider ?? "",
18538
+ startedAt: data.metadata.startedAt,
18539
+ endedAt: data.metadata.endedAt
18540
+ });
18541
+ sendTo(ws, {
18542
+ type: "session.inspect",
18543
+ payload
18544
+ });
18545
+ } catch (err) {
18546
+ sendTo(ws, {
18547
+ type: "session.inspect",
18548
+ payload: {
18549
+ id,
18550
+ error: err instanceof Error ? err.message : String(err)
18551
+ }
18552
+ });
18553
+ }
18554
+ },
17530
18555
  listCheckpoints: async (ws, msg) => {
17531
18556
  if (!ensureCurrentSession(ws, msg, "session.checkpoints")) return;
17532
18557
  try {
@@ -18211,6 +19236,19 @@ async function handleCodebaseIndexServerControl(ws, message, deps2) {
18211
19236
  return true;
18212
19237
  }
18213
19238
 
19239
+ // src/server/fallback-choice.ts
19240
+ function emitFallbackChoice(events, msg) {
19241
+ const parsed = validateModelFallbackChoicePayload(msg.payload);
19242
+ if (!parsed.ok) return parsed;
19243
+ events?.emit("provider.fallback_choice", {
19244
+ requestId: parsed.value.requestId,
19245
+ ...parsed.value.providerId ? { providerId: parsed.value.providerId } : {},
19246
+ ...parsed.value.model ? { model: parsed.value.model } : {},
19247
+ ...parsed.value.autoSwitch ? { autoSwitch: true } : {}
19248
+ });
19249
+ return { ok: true };
19250
+ }
19251
+
18214
19252
  // src/server/agent-roster-routes.ts
18215
19253
  async function handleAgentRosterRoute(ws, msg, handlers) {
18216
19254
  if (!msg.type.startsWith("agent-roster.")) return false;
@@ -18375,13 +19413,17 @@ async function handleProviderRoute(ws, msg, routes) {
18375
19413
  case "model.refine":
18376
19414
  await routes.refineModel(ws, msg);
18377
19415
  return true;
19416
+ case "model.fallback_choice":
19417
+ await routes.fallbackChoice(ws, msg);
19418
+ return true;
18378
19419
  case "key.add":
18379
19420
  case "key.update": {
18380
19421
  const payload = asPayloadRecord(msg);
18381
19422
  const providerId = payload ? requiredString(payload, "providerId") : null;
18382
19423
  const label = payload ? requiredString(payload, "label") : null;
18383
19424
  const apiKey = payload ? requiredString(payload, "apiKey") : null;
18384
- if (!providerId || !label || !apiKey) return invalidPayload(ws, msg.type);
19425
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label || !apiKey)
19426
+ return invalidPayload(ws, msg.type);
18385
19427
  await routes.providerHandlers.handleKeyUpsert(ws, providerId, label, apiKey);
18386
19428
  return true;
18387
19429
  }
@@ -18389,7 +19431,8 @@ async function handleProviderRoute(ws, msg, routes) {
18389
19431
  const payload = asPayloadRecord(msg);
18390
19432
  const providerId = payload ? requiredString(payload, "providerId") : null;
18391
19433
  const label = payload ? requiredString(payload, "label") : null;
18392
- if (!providerId || !label) return invalidPayload(ws, msg.type);
19434
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label)
19435
+ return invalidPayload(ws, msg.type);
18393
19436
  await routes.providerHandlers.handleKeyDelete(ws, providerId, label);
18394
19437
  return true;
18395
19438
  }
@@ -18397,7 +19440,8 @@ async function handleProviderRoute(ws, msg, routes) {
18397
19440
  const payload = asPayloadRecord(msg);
18398
19441
  const providerId = payload ? requiredString(payload, "providerId") : null;
18399
19442
  const label = payload ? requiredString(payload, "label") : null;
18400
- if (!providerId || !label) return invalidPayload(ws, msg.type);
19443
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !label)
19444
+ return invalidPayload(ws, msg.type);
18401
19445
  await routes.providerHandlers.handleKeySetActive(ws, providerId, label);
18402
19446
  return true;
18403
19447
  }
@@ -18409,11 +19453,11 @@ async function handleProviderRoute(ws, msg, routes) {
18409
19453
  const apiKey = payload?.["apiKey"];
18410
19454
  const models = payload ? optionalStringArray(payload, "models") : null;
18411
19455
  const customModels = payload ? optionalCustomModels(payload) : null;
18412
- if (!id || !family) return invalidPayload(ws, msg.type);
19456
+ if (!id || !SAFE_CONFIG_KEY.test(id) || !family) return invalidPayload(ws, msg.type);
18413
19457
  if (baseUrl !== void 0 && typeof baseUrl !== "string") return invalidPayload(ws, msg.type);
18414
19458
  if (apiKey !== void 0 && typeof apiKey !== "string") return invalidPayload(ws, msg.type);
18415
19459
  if (models === null || customModels === null) return invalidPayload(ws, msg.type);
18416
- await routes.providerHandlers.handleProviderAdd(ws, {
19460
+ const added = await routes.providerHandlers.handleProviderAdd(ws, {
18417
19461
  id,
18418
19462
  family,
18419
19463
  baseUrl,
@@ -18421,20 +19465,22 @@ async function handleProviderRoute(ws, msg, routes) {
18421
19465
  models,
18422
19466
  customModels
18423
19467
  });
18424
- await routes.adoptDefaultProviderIfUnset(id);
19468
+ if (added) {
19469
+ void routes.adoptDefaultProviderIfUnset(id).catch(() => void 0);
19470
+ }
18425
19471
  return true;
18426
19472
  }
18427
19473
  case "provider.remove": {
18428
19474
  const payload = asPayloadRecord(msg);
18429
19475
  const providerId = payload ? requiredString(payload, "providerId") : null;
18430
- if (!providerId) return invalidPayload(ws, msg.type);
19476
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId)) return invalidPayload(ws, msg.type);
18431
19477
  await routes.providerHandlers.handleProviderRemove(ws, providerId);
18432
19478
  return true;
18433
19479
  }
18434
19480
  case "provider.clear_models": {
18435
19481
  const payload = asPayloadRecord(msg);
18436
19482
  const providerId = payload ? requiredString(payload, "providerId") : null;
18437
- if (!providerId) return invalidPayload(ws, msg.type);
19483
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId)) return invalidPayload(ws, msg.type);
18438
19484
  await routes.providerHandlers.handleProviderClearModels(ws, providerId);
18439
19485
  return true;
18440
19486
  }
@@ -18448,7 +19494,7 @@ async function handleProviderRoute(ws, msg, routes) {
18448
19494
  const customModelRaw = payload["customModel"];
18449
19495
  if (!isRecord4(customModelRaw)) return invalidPayload(ws, msg.type);
18450
19496
  const cm = optionalCustomModels({ customModels: { [modelId]: customModelRaw } });
18451
- if (!cm || !cm[modelId]) return invalidPayload(ws, msg.type);
19497
+ if (!cm?.[modelId]) return invalidPayload(ws, msg.type);
18452
19498
  await routes.providerHandlers.handleCustomModelSet(ws, providerId, modelId, cm[modelId]);
18453
19499
  return true;
18454
19500
  }
@@ -18466,7 +19512,8 @@ async function handleProviderRoute(ws, msg, routes) {
18466
19512
  const payload = asPayloadRecord(msg);
18467
19513
  const providerId = payload ? requiredString(payload, "providerId") : null;
18468
19514
  const previousModels = payload ? optionalStringArray(payload, "previousModels") : null;
18469
- if (!providerId || !previousModels) return invalidPayload(ws, msg.type);
19515
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !previousModels)
19516
+ return invalidPayload(ws, msg.type);
18470
19517
  await routes.providerHandlers.handleProviderUndoClear(ws, providerId, previousModels);
18471
19518
  return true;
18472
19519
  }
@@ -18476,7 +19523,7 @@ async function handleProviderRoute(ws, msg, routes) {
18476
19523
  const envVars = payload ? optionalStringArray(payload, "envVars") : null;
18477
19524
  const models = payload ? optionalStringArray(payload, "models") : null;
18478
19525
  const customModels = payload ? optionalCustomModels(payload) : null;
18479
- if (!payload || !id || envVars === null || models === null || customModels === null)
19526
+ if (!payload || !id || !SAFE_CONFIG_KEY.test(id) || envVars === null || models === null || customModels === null)
18480
19527
  return invalidPayload(ws, msg.type);
18481
19528
  for (const key of ["family", "baseUrl"]) {
18482
19529
  if (payload[key] !== void 0 && typeof payload[key] !== "string")
@@ -18496,7 +19543,8 @@ async function handleProviderRoute(ws, msg, routes) {
18496
19543
  const payload = asPayloadRecord(msg);
18497
19544
  const providerId = payload ? requiredString(payload, "providerId") : null;
18498
19545
  const timeoutMs = payload ? optionalNumber(payload, "timeoutMs") : null;
18499
- if (!providerId || timeoutMs === null) return invalidPayload(ws, msg.type);
19546
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || timeoutMs === null)
19547
+ return invalidPayload(ws, msg.type);
18500
19548
  await routes.providerHandlers.handleProviderProbe(ws, providerId, timeoutMs);
18501
19549
  return true;
18502
19550
  }
@@ -18505,7 +19553,7 @@ async function handleProviderRoute(ws, msg, routes) {
18505
19553
  const kind = oauthKind(payload);
18506
19554
  const providerId = payload?.["providerId"];
18507
19555
  if (!kind) return invalidPayload(ws, msg.type);
18508
- if (providerId !== void 0 && typeof providerId !== "string") {
19556
+ if (providerId !== void 0 && (typeof providerId !== "string" || !SAFE_CONFIG_KEY.test(providerId))) {
18509
19557
  return invalidPayload(ws, msg.type);
18510
19558
  }
18511
19559
  await routes.providerHandlers.handleOAuthStart(ws, kind, providerId);
@@ -18544,7 +19592,8 @@ async function handleProviderRoute(ws, msg, routes) {
18544
19592
  const payload = asPayloadRecord(msg);
18545
19593
  const providerId = payload ? requiredString(payload, "providerId") : null;
18546
19594
  const model = payload ? requiredString(payload, "model") : null;
18547
- if (!providerId || !model) return invalidPayload(ws, msg.type);
19595
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !model)
19596
+ return invalidPayload(ws, msg.type);
18548
19597
  const released = routes.statusTracker.retryNow(providerId, model);
18549
19598
  sendResult2(
18550
19599
  ws,
@@ -18561,7 +19610,8 @@ async function handleProviderRoute(ws, msg, routes) {
18561
19610
  const payload = asPayloadRecord(msg);
18562
19611
  const providerId = payload ? requiredString(payload, "providerId") : null;
18563
19612
  const model = payload ? requiredString(payload, "model") : null;
18564
- if (!providerId || !model) return invalidPayload(ws, msg.type);
19613
+ if (!providerId || !SAFE_CONFIG_KEY.test(providerId) || !model)
19614
+ return invalidPayload(ws, msg.type);
18565
19615
  routes.statusTracker.clear(providerId, model);
18566
19616
  sendResult2(ws, true, `Cleared tracking for ${providerId}/${model}.`);
18567
19617
  return true;
@@ -18642,6 +19692,9 @@ async function handleSessionRoute(ws, msg, handlers) {
18642
19692
  case "session.save":
18643
19693
  await handlers.saveSession(ws, msg);
18644
19694
  return true;
19695
+ case "session.inspect":
19696
+ await handlers.inspectSession(ws, msg);
19697
+ return true;
18645
19698
  case "session.checkpoints":
18646
19699
  await handlers.listCheckpoints(ws, msg);
18647
19700
  return true;
@@ -18916,8 +19969,10 @@ function createEmbeddedMessageRouter(deps2) {
18916
19969
  };
18917
19970
  const mcp = {
18918
19971
  list: (ws, msg) => handleMcpList(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
18919
- add: (ws, msg) => handleMcpAdd(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
18920
- update: (ws, msg) => handleMcpUpdate(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
19972
+ // add/update are the spawn-capable pair they take a `command`/`args`
19973
+ // from the wire and start it. They go past the trust boundary (M1).
19974
+ add: (ws, msg) => handleMcpAdd(ws, msg, opts.profileConfigPath, opts.mcpRegistry, deps2.trustBoundary),
19975
+ update: (ws, msg) => handleMcpUpdate(ws, msg, opts.profileConfigPath, opts.mcpRegistry, deps2.trustBoundary),
18921
19976
  remove: (ws, msg) => handleMcpRemove(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
18922
19977
  enable: (ws, msg) => handleMcpEnable(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
18923
19978
  disable: (ws, msg) => handleMcpDisable(ws, msg, opts.profileConfigPath, opts.mcpRegistry),
@@ -19004,6 +20059,15 @@ function createEmbeddedMessageRouter(deps2) {
19004
20059
  searchProviderModels: (ws, query, limit) => providerOperations.handleProviderModelsSearch(ws, query, limit),
19005
20060
  switchModel: (ws, msg) => modelOperations.switchModel(ws, msg.payload),
19006
20061
  refineModel: (ws, msg) => modelOperations.refineModel(ws, msg.payload),
20062
+ fallbackChoice: async (ws, msg) => {
20063
+ const result = emitFallbackChoice(deps2.sessionCtx.opts.events, msg);
20064
+ if (!result.ok) {
20065
+ send2(ws, {
20066
+ type: "error",
20067
+ payload: { phase: "invalid_request", message: result.message }
20068
+ });
20069
+ }
20070
+ },
19007
20071
  adoptDefaultProviderIfUnset: providerOperations.adoptDefaultProviderIfUnset,
19008
20072
  providerHandlers: providerOperations,
19009
20073
  statusTracker: deps2.statusTracker
@@ -19297,17 +20361,12 @@ function createProviderStore(deps2) {
19297
20361
  cfg.activeKey = active.label;
19298
20362
  }
19299
20363
  }
19300
- function maskedKey2(key) {
19301
- if (!key) return "\u2014";
19302
- if (key.length <= 8) return "\u2022".repeat(key.length);
19303
- return `${key.slice(0, 4)}\u2026${key.slice(-4)}`;
19304
- }
19305
20364
  return {
19306
20365
  load: loadSavedProviders2,
19307
20366
  save: saveProviders2,
19308
20367
  normalizeKeys: normalizeKeys2,
19309
20368
  writeKeysBack: writeKeysBack2,
19310
- maskedKey: maskedKey2
20369
+ maskedKey
19311
20370
  };
19312
20371
  }
19313
20372
 
@@ -20974,7 +22033,8 @@ function setupEvents(deps2) {
20974
22033
  attempt: e.attempt,
20975
22034
  delayMs: e.delayMs,
20976
22035
  status: e.status,
20977
- description: e.description
22036
+ description: e.description,
22037
+ ...e.errorBody ? { errorBody: e.errorBody } : {}
20978
22038
  })
20979
22039
  });
20980
22040
  appendForCurrentSession(e.sessionId, {
@@ -20984,7 +22044,8 @@ function setupEvents(deps2) {
20984
22044
  attempt: e.attempt,
20985
22045
  delayMs: e.delayMs,
20986
22046
  status: e.status,
20987
- description: e.description
22047
+ description: e.description,
22048
+ ...e.errorBody ? { errorBody: e.errorBody } : {}
20988
22049
  });
20989
22050
  });
20990
22051
  on("provider.status_changed", (e) => {
@@ -21024,7 +22085,8 @@ function setupEvents(deps2) {
21024
22085
  providerId: e.providerId,
21025
22086
  status: e.status,
21026
22087
  description: e.description,
21027
- retryable: e.retryable
22088
+ retryable: e.retryable,
22089
+ ...e.errorBody ? { errorBody: e.errorBody } : {}
21028
22090
  })
21029
22091
  });
21030
22092
  appendForCurrentSession(e.sessionId, {
@@ -21033,7 +22095,8 @@ function setupEvents(deps2) {
21033
22095
  providerId: e.providerId,
21034
22096
  status: e.status,
21035
22097
  description: e.description,
21036
- retryable: e.retryable
22098
+ retryable: e.retryable,
22099
+ ...e.errorBody ? { errorBody: e.errorBody } : {}
21037
22100
  });
21038
22101
  });
21039
22102
  on("provider.fallback", (e) => {
@@ -21044,7 +22107,22 @@ function setupEvents(deps2) {
21044
22107
  from: e.from,
21045
22108
  to: e.to,
21046
22109
  status: e.status,
21047
- providerSwitched: e.providerSwitched
22110
+ providerSwitched: e.providerSwitched,
22111
+ ...e.requestId ? { requestId: e.requestId } : {}
22112
+ })
22113
+ });
22114
+ });
22115
+ on("provider.fallback_pending", (e) => {
22116
+ broadcast2(clients, {
22117
+ type: "provider.fallback_pending",
22118
+ payload: sessionPayload2({
22119
+ sessionId: e.sessionId,
22120
+ from: e.from,
22121
+ status: e.status,
22122
+ candidates: e.candidates,
22123
+ autoSwitchSeconds: e.autoSwitchSeconds,
22124
+ requestId: e.requestId,
22125
+ timestamp: e.timestamp
21048
22126
  })
21049
22127
  });
21050
22128
  });
@@ -21129,6 +22207,15 @@ function setupEvents(deps2) {
21129
22207
  type: "mailbox.agent_registered",
21130
22208
  payload
21131
22209
  });
22210
+ }),
22211
+ // Deregistration (subagent retirement) must reach the browser too —
22212
+ // otherwise dead agents linger in the client roster until an unrelated
22213
+ // refresh. Emitted by sqlite-mailbox.deregisterAgent with { agentId }.
22214
+ events.onPattern("mailbox.agent_deregistered", (_e, payload) => {
22215
+ broadcast2(clients, {
22216
+ type: "mailbox.agent_deregistered",
22217
+ payload
22218
+ });
21132
22219
  })
21133
22220
  );
21134
22221
  const forwardSubagent = (kind, payload) => broadcast2(clients, { type: "subagent.event", payload: sessionPayload2({ kind, ...payload }) });
@@ -23413,29 +24500,10 @@ import { attachSessionKanbanMirror, hydrateSessionKanban } from "@wrongstack/too
23413
24500
  // src/server/model-auto-discovery.ts
23414
24501
  import * as fs21 from "node:fs/promises";
23415
24502
  import * as path29 from "node:path";
23416
- import { COMPATIBLE_PRESETS, discoverOpenAICompatibleModels } from "@wrongstack/providers";
24503
+ import { discoverOpenAICompatibleModels, resolveDiscoveryTargets } from "@wrongstack/providers";
23417
24504
  function isOverlayRegistry(value) {
23418
24505
  return !!value && typeof value === "object" && typeof value.mergeOverlay === "function";
23419
24506
  }
23420
- function resolveKey(cfg) {
23421
- if (Array.isArray(cfg.apiKeys) && cfg.apiKeys.length > 0) {
23422
- const active = cfg.activeKey ? cfg.apiKeys.find((key) => key.label === cfg.activeKey) : void 0;
23423
- return (active ?? cfg.apiKeys[0])?.apiKey;
23424
- }
23425
- return cfg.apiKey && cfg.apiKey.length > 0 ? cfg.apiKey : void 0;
23426
- }
23427
- function eligibleProviders(config) {
23428
- const out = [];
23429
- for (const [id, cfg] of Object.entries(config.providers ?? {})) {
23430
- const preset = COMPATIBLE_PRESETS[id];
23431
- const enabled = cfg.autoDiscoverModels ?? preset?.autoDiscover ?? false;
23432
- if (!enabled) continue;
23433
- const baseUrl = cfg.baseUrl ?? preset?.defaultBaseUrl;
23434
- if (!baseUrl) continue;
23435
- out.push({ id, cfg, baseUrl, apiKey: resolveKey(cfg) });
23436
- }
23437
- return out;
23438
- }
23439
24507
  async function readCache(file) {
23440
24508
  try {
23441
24509
  return JSON.parse(await fs21.readFile(file, "utf8"));
@@ -23446,14 +24514,13 @@ async function readCache(file) {
23446
24514
  async function discoverAndMergeWebuiProviders(opts) {
23447
24515
  const registry = opts.registry;
23448
24516
  if (!isOverlayRegistry(registry)) return;
23449
- const targets = eligibleProviders(opts.config);
24517
+ const targets = resolveDiscoveryTargets(opts.config);
23450
24518
  if (targets.length === 0) return;
23451
24519
  const cacheFile = path29.join(opts.cacheDir, "discovered-models-cache.json");
23452
24520
  const cache2 = await readCache(cacheFile);
23453
24521
  let cacheDirty = false;
23454
24522
  await Promise.all(
23455
- targets.map(async ({ id, cfg, baseUrl, apiKey }) => {
23456
- const cacheKey = `${id}\0${baseUrl}`;
24523
+ targets.map(async ({ id, cfg, baseUrl, apiKey, cacheKey }) => {
23457
24524
  const provider = await discoverOpenAICompatibleModels(id, {
23458
24525
  baseUrl,
23459
24526
  apiKey,
@@ -23819,15 +24886,6 @@ async function createPreContextServices(input) {
23819
24886
  logger.warn(`models.dev refresh failed (${toErrorMessage12(err)}); using cached catalog`);
23820
24887
  }
23821
24888
  }
23822
- try {
23823
- await installCatalogModelOutputLimits({
23824
- registry: modelsRegistry,
23825
- getConfig: () => config,
23826
- log: (message) => logger.debug(message)
23827
- });
23828
- } catch (err) {
23829
- logger.debug(`model output-limit index skipped: ${toErrorMessage12(err)}`);
23830
- }
23831
24889
  try {
23832
24890
  await discoverAndMergeWebuiProviders({
23833
24891
  config,
@@ -23838,6 +24896,15 @@ async function createPreContextServices(input) {
23838
24896
  } catch (err) {
23839
24897
  logger.debug(`provider auto-discovery skipped: ${toErrorMessage12(err)}`);
23840
24898
  }
24899
+ try {
24900
+ await installCatalogModelOutputLimits({
24901
+ registry: modelsRegistry,
24902
+ getConfig: () => config,
24903
+ log: (message) => logger.debug(message)
24904
+ });
24905
+ } catch (err) {
24906
+ logger.debug(`model output-limit index skipped: ${toErrorMessage12(err)}`);
24907
+ }
23841
24908
  const events = opts.services?.events ?? new EventBus();
23842
24909
  events.setLogger(logger);
23843
24910
  const container = createDefaultContainer({ config, wpaths, logger, modelsRegistry, events });
@@ -24110,7 +25177,7 @@ import { makeProviderFromConfig as makeProviderFromConfig4, withCatalogCapabilit
24110
25177
 
24111
25178
  // src/server/mode-handlers.ts
24112
25179
  import { DefaultSystemPromptBuilder as DefaultSystemPromptBuilder2 } from "@wrongstack/core/agent";
24113
- import { resolveWstackPaths as resolveWstackPaths5 } from "@wrongstack/core/utils";
25180
+ import { resolveWstackPaths as resolveWstackPaths6 } from "@wrongstack/core/utils";
24114
25181
  function createModeHandlers(context) {
24115
25182
  return createModeRouteHandlers({
24116
25183
  modeStore: context.modeStore,
@@ -24119,7 +25186,7 @@ function createModeHandlers(context) {
24119
25186
  send,
24120
25187
  afterSwitch: async (id) => {
24121
25188
  const modePrompt = id === "default" ? "" : (await context.modeStore.getMode(id))?.prompt ?? "";
24122
- const paths = resolveWstackPaths5({
25189
+ const paths = resolveWstackPaths6({
24123
25190
  projectRoot: context.projectRoot,
24124
25191
  globalRoot: context.globalRoot
24125
25192
  });
@@ -24224,7 +25291,16 @@ function buildRoutes(state, deps2, cb) {
24224
25291
  refineModel: (ws, msg) => modelOperations.refineModel(
24225
25292
  ws,
24226
25293
  msg.payload
24227
- )
25294
+ ),
25295
+ fallbackChoice: async (ws, msg) => {
25296
+ const result = emitFallbackChoice(deps2.events, msg);
25297
+ if (!result.ok) {
25298
+ send(ws, {
25299
+ type: "error",
25300
+ payload: { phase: "invalid_request", message: result.message }
25301
+ });
25302
+ }
25303
+ }
24228
25304
  };
24229
25305
  const sessionRoutes = createSessionHandlers({
24230
25306
  config: state.getConfig(),
@@ -24402,8 +25478,10 @@ function buildRoutes(state, deps2, cb) {
24402
25478
  });
24403
25479
  const mcpRoutes = {
24404
25480
  list: (ws, msg) => handleMcpList(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
24405
- add: (ws, msg) => handleMcpAdd(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
24406
- update: (ws, msg) => handleMcpUpdate(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
25481
+ // add/update are the spawn-capable pair they take a `command`/`args`
25482
+ // from the wire and start it. They go past the trust boundary (M1).
25483
+ add: (ws, msg) => handleMcpAdd(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry, deps2.trustBoundary),
25484
+ update: (ws, msg) => handleMcpUpdate(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry, deps2.trustBoundary),
24407
25485
  remove: (ws, msg) => handleMcpRemove(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
24408
25486
  enable: (ws, msg) => handleMcpEnable(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
24409
25487
  disable: (ws, msg) => handleMcpDisable(ws, msg, deps2.profileConfigPath, deps2.mcpRegistry),
@@ -24479,7 +25557,10 @@ async function resolvePorts(opts) {
24479
25557
  const surface = opts.surface ?? "webui";
24480
25558
  const surfaceDefaults = surface === "simpleui" ? { http: 3466 } : { http: 3456 };
24481
25559
  const wsHost = opts.wsHost ?? process.env["WEBUI_HOST"] ?? process.env["WS_HOST"] ?? "127.0.0.1";
24482
- const requestedHttpPort = opts.httpPort ?? opts.webuiPort ?? opts.port ?? Number.parseInt(process.env["WEBUI_PORT"] ?? process.env["PORT"] ?? String(surfaceDefaults.http), 10);
25560
+ const requestedHttpPort = opts.httpPort ?? opts.webuiPort ?? opts.port ?? Number.parseInt(
25561
+ process.env["WEBUI_PORT"] ?? process.env["PORT"] ?? String(surfaceDefaults.http),
25562
+ 10
25563
+ );
24483
25564
  const publicUrl = opts.publicUrl ?? process.env["WEBUI_PUBLIC_URL"];
24484
25565
  const publicWsUrl = opts.publicWsUrl ?? process.env["WEBUI_PUBLIC_WS_URL"];
24485
25566
  const requireToken = opts.requireToken ?? envFlag("WEBUI_REQUIRE_TOKEN");
@@ -24488,7 +25569,16 @@ async function resolvePorts(opts) {
24488
25569
  if (!strictPort) {
24489
25570
  httpPort = await findFreePort(wsHost, requestedHttpPort);
24490
25571
  if (httpPort !== requestedHttpPort) {
24491
- console.warn(JSON.stringify({ level: "warn", event: "webui.port_reassigned", protocol: "HTTP", requested: requestedHttpPort, assigned: httpPort, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
25572
+ console.warn(
25573
+ JSON.stringify({
25574
+ level: "warn",
25575
+ event: "webui.port_reassigned",
25576
+ protocol: "HTTP",
25577
+ requested: requestedHttpPort,
25578
+ assigned: httpPort,
25579
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
25580
+ })
25581
+ );
24492
25582
  }
24493
25583
  }
24494
25584
  return { wsHost, httpPort, publicUrl, publicWsUrl, requireToken };
@@ -24563,7 +25653,10 @@ function createWsServers(httpServer, ports, accessToken) {
24563
25653
  expectedToken: wsToken,
24564
25654
  requireToken: ports.requireToken,
24565
25655
  allowedHostnames: publicHostnames,
24566
- allowBrowserUrlToken: Boolean(ports.publicWsUrl)
25656
+ allowBrowserUrlToken: Boolean(ports.publicWsUrl),
25657
+ // WS-003 opt-out for the Vite dev loop only (app and WS server cannot
25658
+ // share a port). Off unless explicitly requested — see ws-auth.ts.
25659
+ allowCrossPortLoopbackCookie: process.env["WRONGSTACK_WEBUI_DEV_CROSS_PORT_WS"] === "1"
24567
25660
  });
24568
25661
  const WS_MAX_PAYLOAD = 20 * 1024 * 1024;
24569
25662
  const wssPrimary = new WebSocketServer({
@@ -24594,21 +25687,49 @@ function armEvents(wssPrimary, wssSecondary, wsHost, httpPort, setupInput, watch
24594
25687
  if (eventsArmed) return;
24595
25688
  eventsArmed = true;
24596
25689
  console.log(`[WebUI] Backend ready (${label})`);
24597
- disposeEvents = setupEvents({ ...setupInput, watcherMetrics, onFleetBroadcaster: (fn) => {
24598
- fleetBroadcast = fn;
24599
- } });
25690
+ disposeEvents = setupEvents({
25691
+ ...setupInput,
25692
+ watcherMetrics,
25693
+ onFleetBroadcaster: (fn) => {
25694
+ fleetBroadcast = fn;
25695
+ }
25696
+ });
24600
25697
  };
24601
25698
  wssPrimary.on("listening", () => arm(`${wsHost}:${httpPort}`));
24602
25699
  wssPrimary.on("error", (err) => {
24603
- console.error(JSON.stringify({ level: "error", event: "webui.ws_server_error", host: wsHost, message: toErrorMessage13(err), timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
25700
+ console.error(
25701
+ JSON.stringify({
25702
+ level: "error",
25703
+ event: "webui.ws_server_error",
25704
+ host: wsHost,
25705
+ message: toErrorMessage13(err),
25706
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
25707
+ })
25708
+ );
24604
25709
  });
24605
25710
  if (wssSecondary) {
24606
25711
  wssSecondary.on("listening", () => arm(`::1:${httpPort}`));
24607
25712
  wssSecondary.on("error", (err) => {
24608
25713
  if (err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL") {
24609
- console.warn(JSON.stringify({ level: "warn", event: "webui.ipv6_unavailable", code: err.code, message: err.message, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
25714
+ console.warn(
25715
+ JSON.stringify({
25716
+ level: "warn",
25717
+ event: "webui.ipv6_unavailable",
25718
+ code: err.code,
25719
+ message: err.message,
25720
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
25721
+ })
25722
+ );
24610
25723
  } else {
24611
- console.error(JSON.stringify({ level: "error", event: "webui.ws_server_error", host: "::1", message: err.message, timestamp: (/* @__PURE__ */ new Date()).toISOString() }));
25724
+ console.error(
25725
+ JSON.stringify({
25726
+ level: "error",
25727
+ event: "webui.ws_server_error",
25728
+ host: "::1",
25729
+ message: err.message,
25730
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
25731
+ })
25732
+ );
24612
25733
  }
24613
25734
  });
24614
25735
  }
@@ -24629,6 +25750,7 @@ function resolveWebuiDistDir(fromUrl, explicitDistDir) {
24629
25750
  }
24630
25751
  }
24631
25752
  function startHttpServer(opts) {
25753
+ const intakeService = opts.intakeService ?? createProjectIntakeService({ projectRoot: opts.projectRoot, globalRoot: opts.globalRoot });
24632
25754
  const httpServer = createHttpServer({
24633
25755
  host: opts.wsHost,
24634
25756
  port: opts.httpPort,
@@ -24642,7 +25764,8 @@ function startHttpServer(opts) {
24642
25764
  onTechStackEvent: opts.onTechStackEvent,
24643
25765
  getLlm: opts.getLlm,
24644
25766
  executePackageOperation: opts.executePackageOperation,
24645
- projectRoot: opts.projectRoot
25767
+ projectRoot: opts.projectRoot,
25768
+ intakeService
24646
25769
  });
24647
25770
  return httpServer;
24648
25771
  }