@wrongstack/webui-server 0.308.1 → 0.308.2

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
@@ -16042,9 +16042,19 @@ async function handleBrainAsk(ctx, ws, question) {
16042
16042
  risk: "medium",
16043
16043
  fallback: "ask_human"
16044
16044
  });
16045
+ const answerSessionId = ctx.getSessionId?.();
16045
16046
  ctx.send(ws, {
16046
16047
  type: "brain.answer",
16047
- payload: { sessionId: ctx.getSessionId?.(), question: q, decision }
16048
+ // Omit sessionId when there is no session: this is a direct reply to
16049
+ // the asker, not a broadcast, but the client's session gate
16050
+ // (isActiveSessionMessage) is fail-closed on a present-but-empty
16051
+ // sessionId — stamping '' would hide the answer from its own asker
16052
+ // in an embedded host with an unbound agent context.
16053
+ payload: {
16054
+ ...answerSessionId ? { sessionId: answerSessionId } : {},
16055
+ question: q,
16056
+ decision
16057
+ }
16048
16058
  });
16049
16059
  } catch (err) {
16050
16060
  sendResult6(ctx, ws, false, `Brain consultation failed: ${toErrorMessage6(err)}`);
@@ -18530,7 +18540,12 @@ function createProviderHandlers(deps2) {
18530
18540
 
18531
18541
  // src/server/session-handlers.ts
18532
18542
  import { loadTodosCheckpoint } from "@wrongstack/core/storage";
18533
- import { DEFAULT_CONTEXT_WINDOW_MODE_ID, resolveContextWindowPolicy } from "@wrongstack/core/types";
18543
+ import {
18544
+ CONTEXT_WINDOW_MODE_PINNED_META_KEY,
18545
+ DEFAULT_CONTEXT_WINDOW_MODE_ID,
18546
+ isContextWindowModeId,
18547
+ resolveContextWindowPolicy
18548
+ } from "@wrongstack/core/types";
18534
18549
  import { repairToolUseAdjacency as repairToolUseAdjacency2, sessionScopedPath } from "@wrongstack/core/utils";
18535
18550
 
18536
18551
  // src/protocol/connection-fsm.ts
@@ -19115,6 +19130,7 @@ var SERVER_WORKSPACE_MESSAGE_TYPES = [
19115
19130
  var SERVER_CONFIGURATION_MESSAGE_TYPES = [
19116
19131
  "auth.oauth.status",
19117
19132
  "codebase.index.server.shutdown_result",
19133
+ "connections.auto_heal_status",
19118
19134
  "connections.health_error",
19119
19135
  "connections.health_result",
19120
19136
  "connections.service_action_result",
@@ -20084,8 +20100,8 @@ function createSessionHandlers(ctx) {
20084
20100
  return;
20085
20101
  }
20086
20102
  const { id } = parsed.value;
20087
- let policy = resolveContextWindowPolicy({}, id);
20088
- if (policy.id !== id) {
20103
+ let policy = resolveContextWindowPolicy({}, id, readSessionWindowTokens(ctx.context));
20104
+ if (!isContextWindowModeId(id) && policy.id !== id) {
20089
20105
  const customModes = (await modeStore()).list().filter((m) => m.custom === true);
20090
20106
  const custom = customModes.find((m) => m.id === id);
20091
20107
  if (!custom) {
@@ -20096,6 +20112,7 @@ function createSessionHandlers(ctx) {
20096
20112
  }
20097
20113
  ctx.context.meta["contextWindowMode"] = policy.id;
20098
20114
  ctx.context.meta["contextWindowPolicy"] = policy;
20115
+ ctx.context.meta[CONTEXT_WINDOW_MODE_PINNED_META_KEY] = true;
20099
20116
  result(ws, true, `Context mode switched to ${policy.id}`);
20100
20117
  broadcastToAll({
20101
20118
  type: "context.mode.changed",
@@ -20159,11 +20176,14 @@ function createSessionHandlers(ctx) {
20159
20176
  }
20160
20177
  const { id } = parsed.value;
20161
20178
  if (String(ctx.context.meta["contextWindowMode"] ?? "") === id) {
20162
- ctx.context.meta["contextWindowMode"] = DEFAULT_CONTEXT_WINDOW_MODE_ID;
20163
- ctx.context.meta["contextWindowPolicy"] = resolveContextWindowPolicy(
20179
+ const policy = resolveContextWindowPolicy(
20164
20180
  {},
20165
- DEFAULT_CONTEXT_WINDOW_MODE_ID
20181
+ DEFAULT_CONTEXT_WINDOW_MODE_ID,
20182
+ readSessionWindowTokens(ctx.context)
20166
20183
  );
20184
+ ctx.context.meta["contextWindowMode"] = policy.id;
20185
+ ctx.context.meta["contextWindowPolicy"] = policy;
20186
+ delete ctx.context.meta[CONTEXT_WINDOW_MODE_PINNED_META_KEY];
20167
20187
  }
20168
20188
  const store = await modeStore();
20169
20189
  const operation = store.remove(id);
@@ -20370,6 +20390,12 @@ function createSessionHandlers(ctx) {
20370
20390
  }
20371
20391
  };
20372
20392
  }
20393
+ function readSessionWindowTokens(context) {
20394
+ const meta = context.meta?.["effectiveMaxContext"];
20395
+ if (typeof meta === "number" && Number.isFinite(meta) && meta > 0) return meta;
20396
+ const cap = context.provider?.capabilities?.maxContext;
20397
+ return typeof cap === "number" && Number.isFinite(cap) && cap > 0 ? cap : 0;
20398
+ }
20373
20399
 
20374
20400
  // src/server/embedded-host-adapters.ts
20375
20401
  async function applyEmbeddedModelSwitch(ctx, providerId, modelId) {
@@ -21032,6 +21058,259 @@ async function handleCodebaseIndexServerControl(ws, message, deps2) {
21032
21058
  return true;
21033
21059
  }
21034
21060
 
21061
+ // src/server/connections/auto-healer.ts
21062
+ var AUTO_HEAL_ENV_FLAG = "WRONGSTACK_AUTO_HEAL_SERVICES";
21063
+ var AUTO_HEAL_DEFAULT_INTERVAL_MS = 3e4;
21064
+ var AUTO_HEAL_DEFAULT_COOLDOWN_MS = 5 * 6e4;
21065
+ var AUTO_HEAL_DEFAULT_MAX_ATTEMPTS = 3;
21066
+ var RESTARTABLE_SERVICE_IDS = /* @__PURE__ */ new Set([
21067
+ "kanban",
21068
+ "sage",
21069
+ "chronicle",
21070
+ "codebase-index",
21071
+ "mailbox"
21072
+ ]);
21073
+ function isAutoHealEnabled() {
21074
+ return process.env[AUTO_HEAL_ENV_FLAG] === "1";
21075
+ }
21076
+ function createAutoHealer(options) {
21077
+ const enabled = options.enabled ?? isAutoHealEnabled();
21078
+ const intervalMs = options.intervalMs ?? AUTO_HEAL_DEFAULT_INTERVAL_MS;
21079
+ const cooldownMs = options.cooldownMs ?? AUTO_HEAL_DEFAULT_COOLDOWN_MS;
21080
+ const maxAttempts = options.maxAttempts ?? AUTO_HEAL_DEFAULT_MAX_ATTEMPTS;
21081
+ const collect = options.collect ?? (() => collectConnectionsHealth({
21082
+ projectRoot: options.projectRoot(),
21083
+ indexDir: options.indexDir(),
21084
+ backend: "standalone"
21085
+ }));
21086
+ const execute = options.execute ?? executeServiceAction;
21087
+ const services = /* @__PURE__ */ new Map();
21088
+ let timer = null;
21089
+ let running = false;
21090
+ let ticking = false;
21091
+ let disposed = false;
21092
+ let inFlightTick = null;
21093
+ let lastTickAt = null;
21094
+ let warnedNoBoundary = false;
21095
+ function stateFor(serviceId) {
21096
+ let state = services.get(serviceId);
21097
+ if (!state) {
21098
+ state = {
21099
+ lastAttemptAt: null,
21100
+ consecutiveFailures: 0,
21101
+ lastSuccess: null,
21102
+ lastMessage: null,
21103
+ inFlight: false,
21104
+ escalated: false
21105
+ };
21106
+ services.set(serviceId, state);
21107
+ }
21108
+ return state;
21109
+ }
21110
+ function snapshot() {
21111
+ return {
21112
+ enabled,
21113
+ running,
21114
+ lastTickAt,
21115
+ services: Object.fromEntries(services)
21116
+ };
21117
+ }
21118
+ function emitStatus(event) {
21119
+ try {
21120
+ options.onStatus?.({ ...event, at: Date.now() });
21121
+ } catch (error2) {
21122
+ options.logger?.warn?.(
21123
+ `[AutoHeal] onStatus hook threw: ${error2 instanceof Error ? error2.message : String(error2)}`
21124
+ );
21125
+ }
21126
+ }
21127
+ async function tick() {
21128
+ if (!enabled || disposed) return snapshot();
21129
+ if (!options.trustBoundary) {
21130
+ if (!warnedNoBoundary) {
21131
+ warnedNoBoundary = true;
21132
+ options.logger?.warn?.(
21133
+ "[AutoHeal] Disabled: no policy authority (trust boundary) is configured."
21134
+ );
21135
+ }
21136
+ return snapshot();
21137
+ }
21138
+ if (ticking) return snapshot();
21139
+ ticking = true;
21140
+ try {
21141
+ const report = await collect();
21142
+ const now = Date.now();
21143
+ const projectRoot = options.projectRoot();
21144
+ const indexDir = options.indexDir();
21145
+ for (const service of report.services) {
21146
+ if (disposed) break;
21147
+ const state = stateFor(service.id);
21148
+ if (service.status !== "error") {
21149
+ state.consecutiveFailures = 0;
21150
+ state.escalated = false;
21151
+ continue;
21152
+ }
21153
+ if (!RESTARTABLE_SERVICE_IDS.has(service.id) || service.control === "none") {
21154
+ continue;
21155
+ }
21156
+ if (state.lastAttemptAt !== null && now - state.lastAttemptAt < cooldownMs) continue;
21157
+ if (state.consecutiveFailures >= maxAttempts) {
21158
+ state.escalated = true;
21159
+ options.logger?.warn?.(
21160
+ `[AutoHeal] ${service.id} left to manual intervention after ${state.consecutiveFailures} failed auto-restart(s): ${state.lastMessage ?? "unknown"}`
21161
+ );
21162
+ continue;
21163
+ }
21164
+ if (state.inFlight) continue;
21165
+ const authorization = await authorizeWebUIAction(
21166
+ options.trustBoundary,
21167
+ {
21168
+ capability: "connections.service.restart",
21169
+ subject: { kind: "process", id: `${service.id}@${projectRoot}` },
21170
+ risk: "elevated",
21171
+ cwd: projectRoot,
21172
+ metadata: { transport: "auto-heal", serviceId: service.id, action: "restart" }
21173
+ },
21174
+ options.logger
21175
+ );
21176
+ if (disposed) break;
21177
+ if (!authorization.allowed) {
21178
+ state.lastAttemptAt = now;
21179
+ state.lastMessage = `refused by policy: ${authorization.reason}`;
21180
+ emitStatus({
21181
+ serviceId: service.id,
21182
+ phase: "refused",
21183
+ message: `refused by policy: ${authorization.reason}`,
21184
+ attempt: state.consecutiveFailures + 1
21185
+ });
21186
+ options.logger?.warn?.(
21187
+ `[AutoHeal] ${service.id} restart refused by policy: ${authorization.reason}`
21188
+ );
21189
+ continue;
21190
+ }
21191
+ state.inFlight = true;
21192
+ const attempt = state.consecutiveFailures + 1;
21193
+ emitStatus({
21194
+ serviceId: service.id,
21195
+ phase: "restarting",
21196
+ message: `Auto-restarting ${service.id}`,
21197
+ attempt
21198
+ });
21199
+ try {
21200
+ const result = await execute(service.id, "restart", projectRoot, indexDir);
21201
+ state.consecutiveFailures = result.success ? 0 : state.consecutiveFailures + 1;
21202
+ state.lastSuccess = result.success;
21203
+ state.lastMessage = result.message;
21204
+ emitStatus({
21205
+ serviceId: service.id,
21206
+ phase: result.success ? "restarted" : "failed",
21207
+ message: result.message,
21208
+ attempt
21209
+ });
21210
+ if (!result.success && state.consecutiveFailures >= maxAttempts) {
21211
+ state.escalated = true;
21212
+ emitStatus({
21213
+ serviceId: service.id,
21214
+ phase: "escalated",
21215
+ message: `left to manual intervention after ${state.consecutiveFailures} failed auto-restart(s): ${result.message}`,
21216
+ attempt
21217
+ });
21218
+ options.logger?.warn?.(
21219
+ `[AutoHeal] ${service.id} escalated after ${state.consecutiveFailures} failed auto-restart(s): ${result.message}`
21220
+ );
21221
+ }
21222
+ options.logger?.[result.success ? "info" : "warn"]?.(
21223
+ `[AutoHeal] ${service.id} auto-restart ${result.success ? "succeeded" : "failed"}: ${result.message}`
21224
+ );
21225
+ } catch (error2) {
21226
+ state.consecutiveFailures += 1;
21227
+ state.lastSuccess = false;
21228
+ state.lastMessage = error2 instanceof Error ? error2.message : String(error2);
21229
+ emitStatus({
21230
+ serviceId: service.id,
21231
+ phase: "failed",
21232
+ message: state.lastMessage,
21233
+ attempt
21234
+ });
21235
+ if (state.consecutiveFailures >= maxAttempts) {
21236
+ state.escalated = true;
21237
+ emitStatus({
21238
+ serviceId: service.id,
21239
+ phase: "escalated",
21240
+ message: `left to manual intervention after ${state.consecutiveFailures} failed auto-restart(s): ${state.lastMessage}`,
21241
+ attempt
21242
+ });
21243
+ options.logger?.warn?.(
21244
+ `[AutoHeal] ${service.id} escalated after ${state.consecutiveFailures} failed auto-restart(s): ${state.lastMessage}`
21245
+ );
21246
+ }
21247
+ options.logger?.warn?.(
21248
+ `[AutoHeal] ${service.id} auto-restart threw: ${state.lastMessage}`
21249
+ );
21250
+ } finally {
21251
+ state.lastAttemptAt = Date.now();
21252
+ state.inFlight = false;
21253
+ }
21254
+ }
21255
+ lastTickAt = Date.now();
21256
+ } catch (error2) {
21257
+ options.logger?.warn?.(
21258
+ `[AutoHeal] health collect failed: ${error2 instanceof Error ? error2.message : String(error2)}`
21259
+ );
21260
+ } finally {
21261
+ ticking = false;
21262
+ }
21263
+ return snapshot();
21264
+ }
21265
+ function runTick() {
21266
+ if (!enabled || disposed || running === false || ticking) return;
21267
+ const pending = tick();
21268
+ const tracked = pending.then(
21269
+ () => void 0,
21270
+ () => void 0
21271
+ );
21272
+ inFlightTick = tracked;
21273
+ void tracked.finally(() => {
21274
+ if (inFlightTick === tracked) inFlightTick = null;
21275
+ });
21276
+ }
21277
+ function stopInternal() {
21278
+ if (timer) {
21279
+ clearInterval(timer);
21280
+ timer = null;
21281
+ }
21282
+ running = false;
21283
+ }
21284
+ return {
21285
+ start() {
21286
+ if (!enabled || running || disposed) return;
21287
+ running = true;
21288
+ runTick();
21289
+ timer = setInterval(runTick, intervalMs);
21290
+ timer.unref?.();
21291
+ },
21292
+ stop: stopInternal,
21293
+ async dispose() {
21294
+ stopInternal();
21295
+ disposed = true;
21296
+ const pending = inFlightTick;
21297
+ if (pending) {
21298
+ await Promise.race([
21299
+ pending,
21300
+ new Promise((resolve20) => {
21301
+ const t = setTimeout(resolve20, 3e4);
21302
+ t.unref?.();
21303
+ })
21304
+ ]);
21305
+ }
21306
+ disposed = true;
21307
+ },
21308
+ tick,
21309
+ getSnapshot: snapshot,
21310
+ isRunning: () => running
21311
+ };
21312
+ }
21313
+
21035
21314
  // src/server/fallback-choice.ts
21036
21315
  function emitFallbackChoice(events, msg) {
21037
21316
  const parsed = validateModelFallbackChoicePayload(msg.payload);
@@ -21721,6 +22000,22 @@ async function handleShellOpen(req, logger, options) {
21721
22000
  function createEmbeddedMessageRouter(deps2) {
21722
22001
  const { opts, send: send2, sendResult: sendResult7 } = deps2;
21723
22002
  const projectRoot = () => opts.projectRoot ?? opts.agent.ctx.projectRoot ?? "";
22003
+ const autoHealer = createAutoHealer({
22004
+ projectRoot,
22005
+ indexDir: () => typeof opts.agent.ctx.meta["codebaseIndexDir"] === "string" ? opts.agent.ctx.meta["codebaseIndexDir"] : void 0,
22006
+ trustBoundary: deps2.trustBoundary,
22007
+ logger: deps2.logger,
22008
+ onStatus: (event) => deps2.providerCtx.broadcast({
22009
+ type: "connections.auto_heal_status",
22010
+ payload: event
22011
+ })
22012
+ });
22013
+ autoHealer.start();
22014
+ if (deps2.onDispose) {
22015
+ deps2.onDispose(async () => {
22016
+ await autoHealer.dispose();
22017
+ });
22018
+ }
21724
22019
  const terminal = async (ws, message) => {
21725
22020
  await deps2.terminalHandler.handleMessage(ws, message).catch((error2) => {
21726
22021
  const text2 = error2 instanceof Error ? error2.message : String(error2);
@@ -24704,6 +24999,7 @@ import {
24704
24999
  import { TOKENS as TOKENS2 } from "@wrongstack/core/kernel";
24705
25000
  import { SessionMemoryConsolidator } from "@wrongstack/core/storage";
24706
25001
  import {
25002
+ CONTEXT_WINDOW_MODE_PINNED_META_KEY as CONTEXT_WINDOW_MODE_PINNED_META_KEY2,
24707
25003
  DEFAULT_TOOLS_CONFIG,
24708
25004
  resolveContextWindowPolicy as resolveContextWindowPolicy2
24709
25005
  } from "@wrongstack/core/types";
@@ -25624,22 +25920,26 @@ async function createAgentServices(input) {
25624
25920
  summarizerModel: config.context?.summarizerModel,
25625
25921
  llmSelector: config.context?.llmSelector
25626
25922
  });
25627
- const initialContextPolicy = resolveContextWindowPolicy2(config.context);
25923
+ let effectiveMaxContext = 0;
25924
+ try {
25925
+ const m = await resolveProviderModelMetadata(
25926
+ modelsRegistry,
25927
+ config.provider,
25928
+ context.model,
25929
+ config.providers?.[config.provider]
25930
+ );
25931
+ effectiveMaxContext = m?.capabilities?.maxContext ?? 0;
25932
+ } catch {
25933
+ }
25934
+ if (!effectiveMaxContext) effectiveMaxContext = config.context?.effectiveMaxContext ?? 0;
25935
+ if (!effectiveMaxContext) effectiveMaxContext = provider.capabilities.maxContext;
25936
+ const initialContextPolicy = resolveContextWindowPolicy2(
25937
+ config.context,
25938
+ void 0,
25939
+ effectiveMaxContext
25940
+ );
25628
25941
  let autoCompactor;
25629
25942
  if (config.context?.autoCompact !== false) {
25630
- let effectiveMaxContext = 0;
25631
- try {
25632
- const m = await resolveProviderModelMetadata(
25633
- modelsRegistry,
25634
- config.provider,
25635
- context.model,
25636
- config.providers?.[config.provider]
25637
- );
25638
- effectiveMaxContext = m?.capabilities?.maxContext ?? 0;
25639
- } catch {
25640
- }
25641
- if (!effectiveMaxContext) effectiveMaxContext = config.context?.effectiveMaxContext ?? 0;
25642
- if (!effectiveMaxContext) effectiveMaxContext = provider.capabilities.maxContext;
25643
25943
  autoCompactor = new AutoCompactionMiddlewareCtor(
25644
25944
  compactor,
25645
25945
  effectiveMaxContext,
@@ -25694,6 +25994,15 @@ async function createAgentServices(input) {
25694
25994
  context.meta["effectiveMaxContext"] = newMaxContext;
25695
25995
  autoCompactor?.setMaxContext(newMaxContext);
25696
25996
  autoCompactor?.setEnabled(config.context?.autoCompact !== false);
25997
+ if (context.meta[CONTEXT_WINDOW_MODE_PINNED_META_KEY2] !== true) {
25998
+ const policy = resolveContextWindowPolicy2(
25999
+ currentConfig.context ?? {},
26000
+ void 0,
26001
+ newMaxContext
26002
+ );
26003
+ context.meta["contextWindowMode"] = policy.id;
26004
+ context.meta["contextWindowPolicy"] = policy;
26005
+ }
25697
26006
  } else {
25698
26007
  delete context.meta["effectiveMaxContext"];
25699
26008
  autoCompactor?.setEnabled(false);
@@ -26325,8 +26634,22 @@ function createMessageDispatcher(opts) {
26325
26634
  broadcast: (message) => broadcast(state.getClients(), message),
26326
26635
  log: (message) => deps2.logger.warn?.(`[KanbanSupervisor] ${message}`)
26327
26636
  });
26637
+ const autoHealer = createAutoHealer({
26638
+ projectRoot: () => state.getProjectRoot(),
26639
+ indexDir: () => typeof deps2.context.meta["codebaseIndexDir"] === "string" ? deps2.context.meta["codebaseIndexDir"] : void 0,
26640
+ trustBoundary: deps2.trustBoundary,
26641
+ logger: deps2.logger,
26642
+ onStatus: (event) => broadcast(state.getClients(), {
26643
+ type: "connections.auto_heal_status",
26644
+ payload: event
26645
+ })
26646
+ });
26647
+ autoHealer.start();
26328
26648
  if (opts.onDispose) {
26329
- const dispose = () => kanbanSupervisor.dispose();
26649
+ const dispose = async () => {
26650
+ kanbanSupervisor.dispose();
26651
+ await autoHealer.dispose();
26652
+ };
26330
26653
  opts.onDispose(dispose);
26331
26654
  }
26332
26655
  const kanbanContext = () => ({
@@ -27190,7 +27513,11 @@ async function createPreContextServices(input) {
27190
27513
  model: config.model
27191
27514
  });
27192
27515
  context.meta["promptOnlineAgents"] = onlineAgents;
27193
- const initialContextPolicy = resolveContextWindowPolicy3(config.context);
27516
+ const initialContextPolicy = resolveContextWindowPolicy3(
27517
+ config.context,
27518
+ void 0,
27519
+ provider.capabilities?.maxContext
27520
+ );
27194
27521
  context.meta["contextWindowMode"] = initialContextPolicy.id;
27195
27522
  context.meta["contextWindowPolicy"] = initialContextPolicy;
27196
27523
  context.state.setMeta(
@@ -28035,7 +28362,7 @@ function setupWebuiShutdown(options) {
28035
28362
  onPreShutdown: async () => {
28036
28363
  await options.stopEmptySessionCleanup.dispose();
28037
28364
  const disposeKanban = options.getKanbanSupervisorDispose();
28038
- disposeKanban?.();
28365
+ await disposeKanban?.();
28039
28366
  },
28040
28367
  onShutdown: async () => {
28041
28368
  unregister();
@@ -580,6 +580,7 @@ var SERVER_WORKSPACE_MESSAGE_TYPES = [
580
580
  var SERVER_CONFIGURATION_MESSAGE_TYPES = [
581
581
  "auth.oauth.status",
582
582
  "codebase.index.server.shutdown_result",
583
+ "connections.auto_heal_status",
583
584
  "connections.health_error",
584
585
  "connections.health_result",
585
586
  "connections.service_action_result",
@@ -1,5 +1,5 @@
1
1
  export declare const CLIENT_MESSAGE_TYPES: readonly ["abort", "ping", "user_message", "tool.confirm_result", "topic.advice", "completion.request", "model.switch", "model.refine", "model.fallback_choice", "autonomy.switch", "context.clear", "context.compact", "context.debug", "context.editor.open", "context.editor.validate", "context.editor.apply", "context.mode.create", "context.mode.delete", "context.mode.switch", "context.mode.update", "context.modes.list", "context.repair", "mode.switch", "modes.list", "session.checkpoints", "session.delete", "session.inspect", "session.new", "session.rename", "session.resume", "session.rewind", "session.save", "sessions.list", "side_effects.list", "stats.get", "todo.update", "todos.clear", "todos.get", "todos.remove", "collab.join", "collab.leave", "collab.annotate", "collab.resolve", "collab.request_pause", "collab.resume", "collab.grant_control", "collab.inject_tool", "mailbox.action", "mailbox.agents", "mailbox.clear", "mailbox.compact", "mailbox.messages", "mailbox.purge", "mailbox.send", "files.create", "files.delete", "files.list", "files.move", "files.read", "files.rename", "files.skeleton", "files.tree", "files.write", "git.changes", "git.diff", "git.info", "projects.add", "projects.list", "projects.select", "working_dir.set", "worktree.cleanup", "worktree.diff", "worktree.merge", "worktree.remove", "worktree.scan", "shell.open", "process.kill", "process.killAll", "process.list", "terminal.close", "terminal.create", "terminal.input", "terminal.resize", "codebase.index.server.shutdown", "connections.health", "connections.service_action", "diag.get", "key.add", "key.delete", "key.set_active", "key.update", "prefs.get", "prefs.update", "provider.add", "provider.clear_models", "provider.custom_models.remove", "provider.custom_models.set", "provider.models", "provider.models.search", "provider.probe", "provider.remove", "provider.status.clear", "provider.status.get", "provider.status.retry", "provider.undo_clear", "provider.update", "providers.list", "providers.saved", "tool.disable", "tool.enable", "tools.list", "webui.shutdown", "goal-state.get", "goal.addTask", "goal.assess", "goal.assignTask", "goal.clear", "goal.get", "goal.list", "goal.load", "goal.moveTask", "goal.pause", "goal.resume", "goal.retryTask", "goal.revert", "goal.runTask", "goal.save", "goal.selectPhase", "goal.start", "goal.state", "goal.status", "goal.stop", "goal.taskStatus", "goal.toggleAutonomous", "plan.get", "plan.item.update", "plan.template_use", "task.update", "tasks.get", "sdd.board.cancel_task", "sdd.board.cleanup_worktrees", "sdd.board.delete_task", "sdd.board.destroy", "sdd.board.get", "sdd.board.list", "sdd.board.pause", "sdd.board.reassign", "sdd.board.resume", "sdd.board.retry", "sdd.board.retry_all_failed", "sdd.board.rollback", "sdd.board.set_task_fallbacks", "sdd.board.set_task_model", "sdd.board.set_task_verification", "sdd.board.split_task", "sdd.board.stop", "sdd.run.from_graph", "sdd.run.from_spec", "sdd.run.start", "sdd.spec.approve", "sdd.spec.discard", "sdd.spec.get", "sdd.spec.message", "sdd.spec.rewind", "sdd.spec.start", "specs.get", "specs.list", "specs.taskStatus", "brain.ask", "brain.config.get", "brain.config.set", "brain.risk", "brain.status", "chronicle.facet", "chronicle.facets", "chronicle.graph", "chronicle.metrics", "chronicle.query", "chronicle.status", "config.doctor", "design.list", "design.materialize", "design.set", "design.state", "design.swap", "design.tune", "design.use", "design.verify", "memory.list", "memory.sage.backfillRecoverable", "memory.sage.candidateResolve", "memory.sage.delete", "memory.sage.forFile", "memory.sage.get", "memory.sage.graph", "memory.sage.list", "memory.sage.listCandidates", "memory.sage.listPage", "memory.sage.recover", "memory.sage.remember", "memory.sage.searchBreakdown", "memory.sage.update", "auth.oauth.cancel", "auth.oauth.code", "auth.oauth.start", "mcp.add", "mcp.disable", "mcp.discover", "mcp.enable", "mcp.list", "mcp.prompt.get", "mcp.prompts", "mcp.remove", "mcp.resource.read", "mcp.resources", "mcp.restart", "mcp.sleep", "mcp.update", "mcp.wake", "prompts.content", "prompts.create", "prompts.favorite", "prompts.journal", "prompts.list", "prompts.recent", "prompts.search", "prompts.used", "skills.content", "skills.create", "skills.edit", "skills.export", "skills.install", "skills.list", "skills.uninstall", "skills.update"];
2
- export declare const SERVER_MESSAGE_TYPES: readonly ["error", "log", "pong", "side_effects", "agent.status_changed", "agent.timeline.message", "client.status_update", "chimera.report_available", "compaction.failed", "completion.result", "context.compacted", "context.debug", "context.editor.snapshot", "context.editor.validation", "context.editor.applied", "context.mode.changed", "context.modes.list", "context.repaired", "ctx.max_context", "ctx.pct", "delegate.completed", "delegate.started", "iteration.completed", "iteration.limit_reached", "iteration.started", "model.refine_result", "modes.list", "provider.active_blocked", "provider.error", "provider.fallback", "provider.fallback_pending", "provider.response", "provider.retry", "provider.status_changed", "provider.stream_error", "provider.text_delta", "provider.thinking_delta", "run.result", "session.checkpoints", "session.damaged", "session.end", "session.inspect", "session.rewound", "session.start", "session.stats", "sessions.list", "sessions.status_update", "stats.get", "token.cost_estimate_unavailable", "token.threshold", "tool.confirm_needed", "tool.disabled", "tool.enabled", "tool.executed", "tool.loop_detected", "tool.progress", "tool.started", "topic.advice_result", "tools.list", "trust.persisted", "collab.annotation.added", "collab.annotation.resolved", "collab.event", "collab.injection.granted", "collab.participant.joined", "collab.participant.left", "collab.pause.granted", "collab.pause.released", "collab.state", "mailbox.action_result", "mailbox.agent_registered", "mailbox.agent_deregistered", "mailbox.agents", "mailbox.cleared", "mailbox.compacted", "mailbox.event", "mailbox.messages", "mailbox.sent", "mailbox.purged", "mailbox.received", "subagent.budget_extended", "subagent.event", "checkpoint.written", "codemap.file_event", "codemap.index_updated", "codemap.tool_executed", "codemap.tool_started", "file.saved", "files.created", "files.deleted", "files.list", "files.moved", "files.read", "files.renamed", "files.skeleton_result", "files.tree", "files.tree.changed", "files.written", "git.changes", "git.diff", "git.info", "process.list", "projects.added", "projects.list", "projects.selected", "terminal.exit", "terminal.output", "working_dir.changed", "worktree.cleanup_result", "worktree.diff_result", "worktree.event", "worktree.merge_result", "worktree.orphans", "worktree.state", "auth.oauth.status", "codebase.index.server.shutdown_result", "connections.health_error", "connections.health_result", "connections.service_action_result", "diag.get", "key.operation_result", "model.switch_result", "prefs.updated", "provider.catalog", "provider.models", "provider.models.search_result", "provider.probe", "provider.status.snapshot", "providers.saved", "budget.decision", "budget.threshold_reached", "coordinator.stats", "coordinator.status", "eternal.iteration", "fleet.concurrency_update", "goal-state.updated", "goal.assess.result", "goal.list", "goal.paused", "goal.resumed", "goal.saved", "goal.error", "goal.stopped", "goal.failed", "goal.completed", "goal.cleared", "goal.reverted", "goal.progress", "goal.state", "in_flight.ended", "in_flight.started", "plan.updated", "task.completed", "task.failed", "task.pending", "task.started", "tasks.updated", "todos.cleared", "todos.updated", "kanban.task.activity", "sdd.board.lifecycle_result", "sdd.board.list", "sdd.board.snapshot", "sdd.run.started", "sdd.spec.agent_text", "sdd.spec.error", "sdd.spec.snapshot", "specs.detail", "specs.list", "consensus.vote_cast", "consensus.vote_initiated", "consensus.vote_resolved", "cron.job_fired", "cron.snapshot", "techstack.job.cancelled", "techstack.job.failed", "techstack.job.progress", "techstack.job.started", "techstack.report.delivered", "techstack.report.ready", "techstack.snapshot.updated", "techstack.workspace.completed", "brain.answer", "brain.config", "brain.event", "brain.status", "chronicle.error", "chronicle.facet_result", "chronicle.facets_result", "chronicle.graph_result", "chronicle.metrics_result", "chronicle.query_result", "chronicle.status_result", "config.doctor.result", "design.list", "design.materialize", "design.set", "design.state", "design.swap", "design.tune", "design.use", "design.verify", "memory.event", "memory.list", "memory.sage.backfillRecoverable", "memory.sage.candidateResolve", "memory.sage.delete", "memory.sage.forFile", "memory.sage.get", "memory.sage.graph", "memory.sage.list", "memory.sage.listCandidates", "memory.sage.listPage", "memory.sage.recover", "memory.sage.remember", "memory.sage.searchBreakdown", "memory.sage.update", "mcp.content.error", "mcp.content.selected", "mcp.list", "mcp.operation_result", "mcp.prompts", "mcp.resources", "mcp.server.added", "mcp.server.connected", "mcp.server.disconnected", "mcp.server.discovered", "mcp.server.error", "mcp.server.reconnected", "mcp.server.removed", "mcp.server.sleeping", "mcp.server.updated", "mcp.server.waking", "prompts.content", "prompts.created", "prompts.favorite", "prompts.journal", "prompts.list", "prompts.recent", "prompts.search", "prompts.used", "skills.content", "skills.created", "skills.edited", "skills.exported", "skills.installed", "skills.list", "skills.uninstalled", "skills.updated"];
2
+ export declare const SERVER_MESSAGE_TYPES: readonly ["error", "log", "pong", "side_effects", "agent.status_changed", "agent.timeline.message", "client.status_update", "chimera.report_available", "compaction.failed", "completion.result", "context.compacted", "context.debug", "context.editor.snapshot", "context.editor.validation", "context.editor.applied", "context.mode.changed", "context.modes.list", "context.repaired", "ctx.max_context", "ctx.pct", "delegate.completed", "delegate.started", "iteration.completed", "iteration.limit_reached", "iteration.started", "model.refine_result", "modes.list", "provider.active_blocked", "provider.error", "provider.fallback", "provider.fallback_pending", "provider.response", "provider.retry", "provider.status_changed", "provider.stream_error", "provider.text_delta", "provider.thinking_delta", "run.result", "session.checkpoints", "session.damaged", "session.end", "session.inspect", "session.rewound", "session.start", "session.stats", "sessions.list", "sessions.status_update", "stats.get", "token.cost_estimate_unavailable", "token.threshold", "tool.confirm_needed", "tool.disabled", "tool.enabled", "tool.executed", "tool.loop_detected", "tool.progress", "tool.started", "topic.advice_result", "tools.list", "trust.persisted", "collab.annotation.added", "collab.annotation.resolved", "collab.event", "collab.injection.granted", "collab.participant.joined", "collab.participant.left", "collab.pause.granted", "collab.pause.released", "collab.state", "mailbox.action_result", "mailbox.agent_registered", "mailbox.agent_deregistered", "mailbox.agents", "mailbox.cleared", "mailbox.compacted", "mailbox.event", "mailbox.messages", "mailbox.sent", "mailbox.purged", "mailbox.received", "subagent.budget_extended", "subagent.event", "checkpoint.written", "codemap.file_event", "codemap.index_updated", "codemap.tool_executed", "codemap.tool_started", "file.saved", "files.created", "files.deleted", "files.list", "files.moved", "files.read", "files.renamed", "files.skeleton_result", "files.tree", "files.tree.changed", "files.written", "git.changes", "git.diff", "git.info", "process.list", "projects.added", "projects.list", "projects.selected", "terminal.exit", "terminal.output", "working_dir.changed", "worktree.cleanup_result", "worktree.diff_result", "worktree.event", "worktree.merge_result", "worktree.orphans", "worktree.state", "auth.oauth.status", "codebase.index.server.shutdown_result", "connections.auto_heal_status", "connections.health_error", "connections.health_result", "connections.service_action_result", "diag.get", "key.operation_result", "model.switch_result", "prefs.updated", "provider.catalog", "provider.models", "provider.models.search_result", "provider.probe", "provider.status.snapshot", "providers.saved", "budget.decision", "budget.threshold_reached", "coordinator.stats", "coordinator.status", "eternal.iteration", "fleet.concurrency_update", "goal-state.updated", "goal.assess.result", "goal.list", "goal.paused", "goal.resumed", "goal.saved", "goal.error", "goal.stopped", "goal.failed", "goal.completed", "goal.cleared", "goal.reverted", "goal.progress", "goal.state", "in_flight.ended", "in_flight.started", "plan.updated", "task.completed", "task.failed", "task.pending", "task.started", "tasks.updated", "todos.cleared", "todos.updated", "kanban.task.activity", "sdd.board.lifecycle_result", "sdd.board.list", "sdd.board.snapshot", "sdd.run.started", "sdd.spec.agent_text", "sdd.spec.error", "sdd.spec.snapshot", "specs.detail", "specs.list", "consensus.vote_cast", "consensus.vote_initiated", "consensus.vote_resolved", "cron.job_fired", "cron.snapshot", "techstack.job.cancelled", "techstack.job.failed", "techstack.job.progress", "techstack.job.started", "techstack.report.delivered", "techstack.report.ready", "techstack.snapshot.updated", "techstack.workspace.completed", "brain.answer", "brain.config", "brain.event", "brain.status", "chronicle.error", "chronicle.facet_result", "chronicle.facets_result", "chronicle.graph_result", "chronicle.metrics_result", "chronicle.query_result", "chronicle.status_result", "config.doctor.result", "design.list", "design.materialize", "design.set", "design.state", "design.swap", "design.tune", "design.use", "design.verify", "memory.event", "memory.list", "memory.sage.backfillRecoverable", "memory.sage.candidateResolve", "memory.sage.delete", "memory.sage.forFile", "memory.sage.get", "memory.sage.graph", "memory.sage.list", "memory.sage.listCandidates", "memory.sage.listPage", "memory.sage.recover", "memory.sage.remember", "memory.sage.searchBreakdown", "memory.sage.update", "mcp.content.error", "mcp.content.selected", "mcp.list", "mcp.operation_result", "mcp.prompts", "mcp.resources", "mcp.server.added", "mcp.server.connected", "mcp.server.disconnected", "mcp.server.discovered", "mcp.server.error", "mcp.server.reconnected", "mcp.server.removed", "mcp.server.sleeping", "mcp.server.updated", "mcp.server.waking", "prompts.content", "prompts.created", "prompts.favorite", "prompts.journal", "prompts.list", "prompts.recent", "prompts.search", "prompts.used", "skills.content", "skills.created", "skills.edited", "skills.exported", "skills.installed", "skills.list", "skills.uninstalled", "skills.updated"];
3
3
  export type ExactClientMessageType = (typeof CLIENT_MESSAGE_TYPES)[number];
4
4
  export type ExactServerMessageType = (typeof SERVER_MESSAGE_TYPES)[number];
5
5
  export declare function isRegisteredMessageType(type: string, direction: 'client' | 'server'): boolean;
@@ -1,3 +1,3 @@
1
1
  export declare const SERVER_WORKSPACE_MESSAGE_TYPES: readonly ['checkpoint.written', 'codemap.file_event', 'codemap.index_updated', 'codemap.tool_executed', 'codemap.tool_started', 'file.saved', 'files.created', 'files.deleted', 'files.list', 'files.moved', 'files.read', 'files.renamed', 'files.skeleton_result', 'files.tree', 'files.tree.changed', 'files.written', 'git.changes', 'git.diff', 'git.info', 'process.list', 'projects.added', 'projects.list', 'projects.selected', 'terminal.exit', 'terminal.output', 'working_dir.changed', 'worktree.cleanup_result', 'worktree.diff_result', 'worktree.event', 'worktree.merge_result', 'worktree.orphans', 'worktree.state'];
2
- export declare const SERVER_CONFIGURATION_MESSAGE_TYPES: readonly ['auth.oauth.status', 'codebase.index.server.shutdown_result', 'connections.health_error', 'connections.health_result', 'connections.service_action_result', 'diag.get', 'key.operation_result', 'model.switch_result', 'prefs.updated', 'provider.catalog', 'provider.models', 'provider.models.search_result', 'provider.probe', 'provider.status.snapshot', 'providers.saved'];
2
+ export declare const SERVER_CONFIGURATION_MESSAGE_TYPES: readonly ['auth.oauth.status', 'codebase.index.server.shutdown_result', 'connections.auto_heal_status', 'connections.health_error', 'connections.health_result', 'connections.service_action_result', 'diag.get', 'key.operation_result', 'model.switch_result', 'prefs.updated', 'provider.catalog', 'provider.models', 'provider.models.search_result', 'provider.probe', 'provider.status.snapshot', 'providers.saved'];
3
3
  //# sourceMappingURL=server-workspace.d.ts.map
@@ -0,0 +1,85 @@
1
+ import type { Logger } from '@wrongstack/core/types';
2
+ import type { TrustBoundary } from '@wrongstack/core/security';
3
+ import type { ConnectionHealthService, ConnectionsHealthReport, ServiceActionResult } from './types.js';
4
+ /**
5
+ * Auto-heal watchdog for /connections IPC services.
6
+ *
7
+ * There is deliberately no retry loop in the /connections layer: the health
8
+ * report is a passive, read-only probe and the RotateCcw button drives the
9
+ * only restart path (`connections.service_action` → `executeServiceAction`).
10
+ * This module turns that same machinery into an opt-in server-side watchdog:
11
+ * on an interval it collects the health report and, for any restartable
12
+ * service stuck in `error`, re-runs the exact restart used by the button
13
+ * (shutdown → wait-for-dead → respawn → ping verification).
14
+ *
15
+ * Trigger contract — status `error` only. `offline` / `unavailable` are the
16
+ * normal sleeping states of on-demand daemons and must never be restarted;
17
+ * `degraded` means the service is serving (inline fallback, quarantine,
18
+ * damaged rows) and should also never trigger a restart.
19
+ */
20
+ export declare const AUTO_HEAL_ENV_FLAG = "WRONGSTACK_AUTO_HEAL_SERVICES";
21
+ export declare const AUTO_HEAL_DEFAULT_INTERVAL_MS = 30000;
22
+ export declare const AUTO_HEAL_DEFAULT_COOLDOWN_MS: number;
23
+ export declare const AUTO_HEAL_DEFAULT_MAX_ATTEMPTS = 3;
24
+ export interface AutoHealServiceState {
25
+ lastAttemptAt: number | null;
26
+ consecutiveFailures: number;
27
+ lastSuccess: boolean | null;
28
+ lastMessage: string | null;
29
+ inFlight: boolean;
30
+ escalated: boolean;
31
+ }
32
+ /** UI-facing event emitted at each healer decision point. */
33
+ export type AutoHealStatusPhase = 'restarting' | 'restarted' | 'failed' | 'escalated' | 'refused';
34
+ export interface AutoHealStatusEvent {
35
+ serviceId: ConnectionHealthService['id'] | null;
36
+ phase: AutoHealStatusPhase;
37
+ message: string;
38
+ at: number;
39
+ /** 1-based attempt number within the current failure streak. */
40
+ attempt: number;
41
+ }
42
+ export interface AutoHealSnapshot {
43
+ enabled: boolean;
44
+ running: boolean;
45
+ lastTickAt: number | null;
46
+ services: Record<string, AutoHealServiceState>;
47
+ }
48
+ export interface AutoHealerOptions {
49
+ projectRoot: () => string;
50
+ indexDir: () => string | undefined;
51
+ /** Policy boundary. When absent, auto-heal refuses to act (same rule as the manual WS path). */
52
+ trustBoundary?: TrustBoundary | undefined;
53
+ logger?: Logger | undefined;
54
+ /**
55
+ * UI visibility hook. The wiring layer forwards these to the WebSocket
56
+ * broadcast as `connections.auto_heal_status` so clients can render an
57
+ * "auto-restarting…" state instead of silently flipping statuses.
58
+ */
59
+ onStatus?: ((event: AutoHealStatusEvent) => void) | undefined;
60
+ /** Health collector override (tests). Defaults to the real per-project collector. */
61
+ collect?: () => Promise<ConnectionsHealthReport>;
62
+ /** Restart executor override (tests). Defaults to the button's `executeServiceAction`. */
63
+ execute?: (serviceId: ConnectionHealthService['id'], action: 'restart', projectRoot: string, indexDir: string | undefined) => Promise<ServiceActionResult>;
64
+ /** Enable override. Defaults to the `WRONGSTACK_AUTO_HEAL_SERVICES` env flag. */
65
+ enabled?: boolean;
66
+ intervalMs?: number;
67
+ cooldownMs?: number;
68
+ maxAttempts?: number;
69
+ }
70
+ export interface AutoHealer {
71
+ start(): void;
72
+ stop(): void;
73
+ /**
74
+ * Idempotent teardown: stops the interval and waits for any in-flight tick
75
+ * (including a restart in progress) to finish. No new restart is started
76
+ * after disposal begins. Safe to call multiple times and when never started.
77
+ */
78
+ dispose(): Promise<void>;
79
+ tick(): Promise<AutoHealSnapshot>;
80
+ getSnapshot(): AutoHealSnapshot;
81
+ isRunning(): boolean;
82
+ }
83
+ export declare function isAutoHealEnabled(): boolean;
84
+ export declare function createAutoHealer(options: AutoHealerOptions): AutoHealer;
85
+ //# sourceMappingURL=auto-healer.d.ts.map
@@ -40,6 +40,14 @@ export interface EmbeddedMessageRouterDeps {
40
40
  };
41
41
  currentSessionId: () => string;
42
42
  shutdown: () => void;
43
+ /**
44
+ * Caller-supplied register hook for long-lived disposables created inside
45
+ * the router (the auto-heal watchdog). The router hands its disposer to the
46
+ * caller once during construction; the caller is responsible for invoking
47
+ * it during its own shutdown — the router itself never invokes it. Mirrors
48
+ * `MessageDispatcherOptions.onDispose` in the standalone dispatcher.
49
+ */
50
+ onDispose?: ((disposer: () => void | Promise<void>) => void) | undefined;
43
51
  providerCtx: EmbeddedProviderContext;
44
52
  brainCtx: BrainHandlerContext;
45
53
  introspectionCtx: IntrospectionRouteContext;
@@ -15217,9 +15217,19 @@ async function handleBrainAsk(ctx, ws, question) {
15217
15217
  risk: "medium",
15218
15218
  fallback: "ask_human"
15219
15219
  });
15220
+ const answerSessionId = ctx.getSessionId?.();
15220
15221
  ctx.send(ws, {
15221
15222
  type: "brain.answer",
15222
- payload: { sessionId: ctx.getSessionId?.(), question: q, decision }
15223
+ // Omit sessionId when there is no session: this is a direct reply to
15224
+ // the asker, not a broadcast, but the client's session gate
15225
+ // (isActiveSessionMessage) is fail-closed on a present-but-empty
15226
+ // sessionId — stamping '' would hide the answer from its own asker
15227
+ // in an embedded host with an unbound agent context.
15228
+ payload: {
15229
+ ...answerSessionId ? { sessionId: answerSessionId } : {},
15230
+ question: q,
15231
+ decision
15232
+ }
15223
15233
  });
15224
15234
  } catch (err) {
15225
15235
  sendResult6(ctx, ws, false, `Brain consultation failed: ${toErrorMessage6(err)}`);
@@ -17303,7 +17313,12 @@ function createProviderHandlers(deps2) {
17303
17313
 
17304
17314
  // src/server/session-handlers.ts
17305
17315
  import { loadTodosCheckpoint } from "@wrongstack/core/storage";
17306
- import { DEFAULT_CONTEXT_WINDOW_MODE_ID, resolveContextWindowPolicy } from "@wrongstack/core/types";
17316
+ import {
17317
+ CONTEXT_WINDOW_MODE_PINNED_META_KEY,
17318
+ DEFAULT_CONTEXT_WINDOW_MODE_ID,
17319
+ isContextWindowModeId,
17320
+ resolveContextWindowPolicy
17321
+ } from "@wrongstack/core/types";
17307
17322
  import { repairToolUseAdjacency as repairToolUseAdjacency2, sessionScopedPath } from "@wrongstack/core/utils";
17308
17323
 
17309
17324
  // src/protocol/client-conversation.ts
@@ -17825,6 +17840,7 @@ var SERVER_WORKSPACE_MESSAGE_TYPES = [
17825
17840
  var SERVER_CONFIGURATION_MESSAGE_TYPES = [
17826
17841
  "auth.oauth.status",
17827
17842
  "codebase.index.server.shutdown_result",
17843
+ "connections.auto_heal_status",
17828
17844
  "connections.health_error",
17829
17845
  "connections.health_result",
17830
17846
  "connections.service_action_result",
@@ -18603,8 +18619,8 @@ function createSessionHandlers(ctx) {
18603
18619
  return;
18604
18620
  }
18605
18621
  const { id } = parsed.value;
18606
- let policy = resolveContextWindowPolicy({}, id);
18607
- if (policy.id !== id) {
18622
+ let policy = resolveContextWindowPolicy({}, id, readSessionWindowTokens(ctx.context));
18623
+ if (!isContextWindowModeId(id) && policy.id !== id) {
18608
18624
  const customModes = (await modeStore()).list().filter((m) => m.custom === true);
18609
18625
  const custom = customModes.find((m) => m.id === id);
18610
18626
  if (!custom) {
@@ -18615,6 +18631,7 @@ function createSessionHandlers(ctx) {
18615
18631
  }
18616
18632
  ctx.context.meta["contextWindowMode"] = policy.id;
18617
18633
  ctx.context.meta["contextWindowPolicy"] = policy;
18634
+ ctx.context.meta[CONTEXT_WINDOW_MODE_PINNED_META_KEY] = true;
18618
18635
  result(ws, true, `Context mode switched to ${policy.id}`);
18619
18636
  broadcastToAll({
18620
18637
  type: "context.mode.changed",
@@ -18678,11 +18695,14 @@ function createSessionHandlers(ctx) {
18678
18695
  }
18679
18696
  const { id } = parsed.value;
18680
18697
  if (String(ctx.context.meta["contextWindowMode"] ?? "") === id) {
18681
- ctx.context.meta["contextWindowMode"] = DEFAULT_CONTEXT_WINDOW_MODE_ID;
18682
- ctx.context.meta["contextWindowPolicy"] = resolveContextWindowPolicy(
18698
+ const policy = resolveContextWindowPolicy(
18683
18699
  {},
18684
- DEFAULT_CONTEXT_WINDOW_MODE_ID
18700
+ DEFAULT_CONTEXT_WINDOW_MODE_ID,
18701
+ readSessionWindowTokens(ctx.context)
18685
18702
  );
18703
+ ctx.context.meta["contextWindowMode"] = policy.id;
18704
+ ctx.context.meta["contextWindowPolicy"] = policy;
18705
+ delete ctx.context.meta[CONTEXT_WINDOW_MODE_PINNED_META_KEY];
18686
18706
  }
18687
18707
  const store = await modeStore();
18688
18708
  const operation = store.remove(id);
@@ -18889,6 +18909,12 @@ function createSessionHandlers(ctx) {
18889
18909
  }
18890
18910
  };
18891
18911
  }
18912
+ function readSessionWindowTokens(context) {
18913
+ const meta = context.meta?.["effectiveMaxContext"];
18914
+ if (typeof meta === "number" && Number.isFinite(meta) && meta > 0) return meta;
18915
+ const cap = context.provider?.capabilities?.maxContext;
18916
+ return typeof cap === "number" && Number.isFinite(cap) && cap > 0 ? cap : 0;
18917
+ }
18892
18918
 
18893
18919
  // src/server/agent-roster-handlers.ts
18894
18920
  import {
@@ -19387,6 +19413,259 @@ async function handleCodebaseIndexServerControl(ws, message, deps2) {
19387
19413
  return true;
19388
19414
  }
19389
19415
 
19416
+ // src/server/connections/auto-healer.ts
19417
+ var AUTO_HEAL_ENV_FLAG = "WRONGSTACK_AUTO_HEAL_SERVICES";
19418
+ var AUTO_HEAL_DEFAULT_INTERVAL_MS = 3e4;
19419
+ var AUTO_HEAL_DEFAULT_COOLDOWN_MS = 5 * 6e4;
19420
+ var AUTO_HEAL_DEFAULT_MAX_ATTEMPTS = 3;
19421
+ var RESTARTABLE_SERVICE_IDS = /* @__PURE__ */ new Set([
19422
+ "kanban",
19423
+ "sage",
19424
+ "chronicle",
19425
+ "codebase-index",
19426
+ "mailbox"
19427
+ ]);
19428
+ function isAutoHealEnabled() {
19429
+ return process.env[AUTO_HEAL_ENV_FLAG] === "1";
19430
+ }
19431
+ function createAutoHealer(options) {
19432
+ const enabled = options.enabled ?? isAutoHealEnabled();
19433
+ const intervalMs = options.intervalMs ?? AUTO_HEAL_DEFAULT_INTERVAL_MS;
19434
+ const cooldownMs = options.cooldownMs ?? AUTO_HEAL_DEFAULT_COOLDOWN_MS;
19435
+ const maxAttempts = options.maxAttempts ?? AUTO_HEAL_DEFAULT_MAX_ATTEMPTS;
19436
+ const collect = options.collect ?? (() => collectConnectionsHealth({
19437
+ projectRoot: options.projectRoot(),
19438
+ indexDir: options.indexDir(),
19439
+ backend: "standalone"
19440
+ }));
19441
+ const execute = options.execute ?? executeServiceAction;
19442
+ const services = /* @__PURE__ */ new Map();
19443
+ let timer = null;
19444
+ let running = false;
19445
+ let ticking = false;
19446
+ let disposed = false;
19447
+ let inFlightTick = null;
19448
+ let lastTickAt = null;
19449
+ let warnedNoBoundary = false;
19450
+ function stateFor(serviceId) {
19451
+ let state = services.get(serviceId);
19452
+ if (!state) {
19453
+ state = {
19454
+ lastAttemptAt: null,
19455
+ consecutiveFailures: 0,
19456
+ lastSuccess: null,
19457
+ lastMessage: null,
19458
+ inFlight: false,
19459
+ escalated: false
19460
+ };
19461
+ services.set(serviceId, state);
19462
+ }
19463
+ return state;
19464
+ }
19465
+ function snapshot() {
19466
+ return {
19467
+ enabled,
19468
+ running,
19469
+ lastTickAt,
19470
+ services: Object.fromEntries(services)
19471
+ };
19472
+ }
19473
+ function emitStatus(event) {
19474
+ try {
19475
+ options.onStatus?.({ ...event, at: Date.now() });
19476
+ } catch (error2) {
19477
+ options.logger?.warn?.(
19478
+ `[AutoHeal] onStatus hook threw: ${error2 instanceof Error ? error2.message : String(error2)}`
19479
+ );
19480
+ }
19481
+ }
19482
+ async function tick() {
19483
+ if (!enabled || disposed) return snapshot();
19484
+ if (!options.trustBoundary) {
19485
+ if (!warnedNoBoundary) {
19486
+ warnedNoBoundary = true;
19487
+ options.logger?.warn?.(
19488
+ "[AutoHeal] Disabled: no policy authority (trust boundary) is configured."
19489
+ );
19490
+ }
19491
+ return snapshot();
19492
+ }
19493
+ if (ticking) return snapshot();
19494
+ ticking = true;
19495
+ try {
19496
+ const report = await collect();
19497
+ const now = Date.now();
19498
+ const projectRoot = options.projectRoot();
19499
+ const indexDir = options.indexDir();
19500
+ for (const service of report.services) {
19501
+ if (disposed) break;
19502
+ const state = stateFor(service.id);
19503
+ if (service.status !== "error") {
19504
+ state.consecutiveFailures = 0;
19505
+ state.escalated = false;
19506
+ continue;
19507
+ }
19508
+ if (!RESTARTABLE_SERVICE_IDS.has(service.id) || service.control === "none") {
19509
+ continue;
19510
+ }
19511
+ if (state.lastAttemptAt !== null && now - state.lastAttemptAt < cooldownMs) continue;
19512
+ if (state.consecutiveFailures >= maxAttempts) {
19513
+ state.escalated = true;
19514
+ options.logger?.warn?.(
19515
+ `[AutoHeal] ${service.id} left to manual intervention after ${state.consecutiveFailures} failed auto-restart(s): ${state.lastMessage ?? "unknown"}`
19516
+ );
19517
+ continue;
19518
+ }
19519
+ if (state.inFlight) continue;
19520
+ const authorization = await authorizeWebUIAction(
19521
+ options.trustBoundary,
19522
+ {
19523
+ capability: "connections.service.restart",
19524
+ subject: { kind: "process", id: `${service.id}@${projectRoot}` },
19525
+ risk: "elevated",
19526
+ cwd: projectRoot,
19527
+ metadata: { transport: "auto-heal", serviceId: service.id, action: "restart" }
19528
+ },
19529
+ options.logger
19530
+ );
19531
+ if (disposed) break;
19532
+ if (!authorization.allowed) {
19533
+ state.lastAttemptAt = now;
19534
+ state.lastMessage = `refused by policy: ${authorization.reason}`;
19535
+ emitStatus({
19536
+ serviceId: service.id,
19537
+ phase: "refused",
19538
+ message: `refused by policy: ${authorization.reason}`,
19539
+ attempt: state.consecutiveFailures + 1
19540
+ });
19541
+ options.logger?.warn?.(
19542
+ `[AutoHeal] ${service.id} restart refused by policy: ${authorization.reason}`
19543
+ );
19544
+ continue;
19545
+ }
19546
+ state.inFlight = true;
19547
+ const attempt = state.consecutiveFailures + 1;
19548
+ emitStatus({
19549
+ serviceId: service.id,
19550
+ phase: "restarting",
19551
+ message: `Auto-restarting ${service.id}`,
19552
+ attempt
19553
+ });
19554
+ try {
19555
+ const result = await execute(service.id, "restart", projectRoot, indexDir);
19556
+ state.consecutiveFailures = result.success ? 0 : state.consecutiveFailures + 1;
19557
+ state.lastSuccess = result.success;
19558
+ state.lastMessage = result.message;
19559
+ emitStatus({
19560
+ serviceId: service.id,
19561
+ phase: result.success ? "restarted" : "failed",
19562
+ message: result.message,
19563
+ attempt
19564
+ });
19565
+ if (!result.success && state.consecutiveFailures >= maxAttempts) {
19566
+ state.escalated = true;
19567
+ emitStatus({
19568
+ serviceId: service.id,
19569
+ phase: "escalated",
19570
+ message: `left to manual intervention after ${state.consecutiveFailures} failed auto-restart(s): ${result.message}`,
19571
+ attempt
19572
+ });
19573
+ options.logger?.warn?.(
19574
+ `[AutoHeal] ${service.id} escalated after ${state.consecutiveFailures} failed auto-restart(s): ${result.message}`
19575
+ );
19576
+ }
19577
+ options.logger?.[result.success ? "info" : "warn"]?.(
19578
+ `[AutoHeal] ${service.id} auto-restart ${result.success ? "succeeded" : "failed"}: ${result.message}`
19579
+ );
19580
+ } catch (error2) {
19581
+ state.consecutiveFailures += 1;
19582
+ state.lastSuccess = false;
19583
+ state.lastMessage = error2 instanceof Error ? error2.message : String(error2);
19584
+ emitStatus({
19585
+ serviceId: service.id,
19586
+ phase: "failed",
19587
+ message: state.lastMessage,
19588
+ attempt
19589
+ });
19590
+ if (state.consecutiveFailures >= maxAttempts) {
19591
+ state.escalated = true;
19592
+ emitStatus({
19593
+ serviceId: service.id,
19594
+ phase: "escalated",
19595
+ message: `left to manual intervention after ${state.consecutiveFailures} failed auto-restart(s): ${state.lastMessage}`,
19596
+ attempt
19597
+ });
19598
+ options.logger?.warn?.(
19599
+ `[AutoHeal] ${service.id} escalated after ${state.consecutiveFailures} failed auto-restart(s): ${state.lastMessage}`
19600
+ );
19601
+ }
19602
+ options.logger?.warn?.(
19603
+ `[AutoHeal] ${service.id} auto-restart threw: ${state.lastMessage}`
19604
+ );
19605
+ } finally {
19606
+ state.lastAttemptAt = Date.now();
19607
+ state.inFlight = false;
19608
+ }
19609
+ }
19610
+ lastTickAt = Date.now();
19611
+ } catch (error2) {
19612
+ options.logger?.warn?.(
19613
+ `[AutoHeal] health collect failed: ${error2 instanceof Error ? error2.message : String(error2)}`
19614
+ );
19615
+ } finally {
19616
+ ticking = false;
19617
+ }
19618
+ return snapshot();
19619
+ }
19620
+ function runTick() {
19621
+ if (!enabled || disposed || running === false || ticking) return;
19622
+ const pending = tick();
19623
+ const tracked = pending.then(
19624
+ () => void 0,
19625
+ () => void 0
19626
+ );
19627
+ inFlightTick = tracked;
19628
+ void tracked.finally(() => {
19629
+ if (inFlightTick === tracked) inFlightTick = null;
19630
+ });
19631
+ }
19632
+ function stopInternal() {
19633
+ if (timer) {
19634
+ clearInterval(timer);
19635
+ timer = null;
19636
+ }
19637
+ running = false;
19638
+ }
19639
+ return {
19640
+ start() {
19641
+ if (!enabled || running || disposed) return;
19642
+ running = true;
19643
+ runTick();
19644
+ timer = setInterval(runTick, intervalMs);
19645
+ timer.unref?.();
19646
+ },
19647
+ stop: stopInternal,
19648
+ async dispose() {
19649
+ stopInternal();
19650
+ disposed = true;
19651
+ const pending = inFlightTick;
19652
+ if (pending) {
19653
+ await Promise.race([
19654
+ pending,
19655
+ new Promise((resolve19) => {
19656
+ const t = setTimeout(resolve19, 3e4);
19657
+ t.unref?.();
19658
+ })
19659
+ ]);
19660
+ }
19661
+ disposed = true;
19662
+ },
19663
+ tick,
19664
+ getSnapshot: snapshot,
19665
+ isRunning: () => running
19666
+ };
19667
+ }
19668
+
19390
19669
  // src/server/fallback-choice.ts
19391
19670
  function emitFallbackChoice(events, msg) {
19392
19671
  const parsed = validateModelFallbackChoicePayload(msg.payload);
@@ -22572,6 +22851,7 @@ import {
22572
22851
  import { TOKENS } from "@wrongstack/core/kernel";
22573
22852
  import { SessionMemoryConsolidator } from "@wrongstack/core/storage";
22574
22853
  import {
22854
+ CONTEXT_WINDOW_MODE_PINNED_META_KEY as CONTEXT_WINDOW_MODE_PINNED_META_KEY2,
22575
22855
  DEFAULT_TOOLS_CONFIG,
22576
22856
  resolveContextWindowPolicy as resolveContextWindowPolicy2
22577
22857
  } from "@wrongstack/core/types";
@@ -23492,22 +23772,26 @@ async function createAgentServices(input) {
23492
23772
  summarizerModel: config.context?.summarizerModel,
23493
23773
  llmSelector: config.context?.llmSelector
23494
23774
  });
23495
- const initialContextPolicy = resolveContextWindowPolicy2(config.context);
23775
+ let effectiveMaxContext = 0;
23776
+ try {
23777
+ const m = await resolveProviderModelMetadata(
23778
+ modelsRegistry,
23779
+ config.provider,
23780
+ context.model,
23781
+ config.providers?.[config.provider]
23782
+ );
23783
+ effectiveMaxContext = m?.capabilities?.maxContext ?? 0;
23784
+ } catch {
23785
+ }
23786
+ if (!effectiveMaxContext) effectiveMaxContext = config.context?.effectiveMaxContext ?? 0;
23787
+ if (!effectiveMaxContext) effectiveMaxContext = provider.capabilities.maxContext;
23788
+ const initialContextPolicy = resolveContextWindowPolicy2(
23789
+ config.context,
23790
+ void 0,
23791
+ effectiveMaxContext
23792
+ );
23496
23793
  let autoCompactor;
23497
23794
  if (config.context?.autoCompact !== false) {
23498
- let effectiveMaxContext = 0;
23499
- try {
23500
- const m = await resolveProviderModelMetadata(
23501
- modelsRegistry,
23502
- config.provider,
23503
- context.model,
23504
- config.providers?.[config.provider]
23505
- );
23506
- effectiveMaxContext = m?.capabilities?.maxContext ?? 0;
23507
- } catch {
23508
- }
23509
- if (!effectiveMaxContext) effectiveMaxContext = config.context?.effectiveMaxContext ?? 0;
23510
- if (!effectiveMaxContext) effectiveMaxContext = provider.capabilities.maxContext;
23511
23795
  autoCompactor = new AutoCompactionMiddlewareCtor(
23512
23796
  compactor,
23513
23797
  effectiveMaxContext,
@@ -23562,6 +23846,15 @@ async function createAgentServices(input) {
23562
23846
  context.meta["effectiveMaxContext"] = newMaxContext;
23563
23847
  autoCompactor?.setMaxContext(newMaxContext);
23564
23848
  autoCompactor?.setEnabled(config.context?.autoCompact !== false);
23849
+ if (context.meta[CONTEXT_WINDOW_MODE_PINNED_META_KEY2] !== true) {
23850
+ const policy = resolveContextWindowPolicy2(
23851
+ currentConfig.context ?? {},
23852
+ void 0,
23853
+ newMaxContext
23854
+ );
23855
+ context.meta["contextWindowMode"] = policy.id;
23856
+ context.meta["contextWindowPolicy"] = policy;
23857
+ }
23565
23858
  } else {
23566
23859
  delete context.meta["effectiveMaxContext"];
23567
23860
  autoCompactor?.setEnabled(false);
@@ -24193,8 +24486,22 @@ function createMessageDispatcher(opts) {
24193
24486
  broadcast: (message) => broadcast(state.getClients(), message),
24194
24487
  log: (message) => deps2.logger.warn?.(`[KanbanSupervisor] ${message}`)
24195
24488
  });
24489
+ const autoHealer = createAutoHealer({
24490
+ projectRoot: () => state.getProjectRoot(),
24491
+ indexDir: () => typeof deps2.context.meta["codebaseIndexDir"] === "string" ? deps2.context.meta["codebaseIndexDir"] : void 0,
24492
+ trustBoundary: deps2.trustBoundary,
24493
+ logger: deps2.logger,
24494
+ onStatus: (event) => broadcast(state.getClients(), {
24495
+ type: "connections.auto_heal_status",
24496
+ payload: event
24497
+ })
24498
+ });
24499
+ autoHealer.start();
24196
24500
  if (opts.onDispose) {
24197
- const dispose = () => kanbanSupervisor.dispose();
24501
+ const dispose = async () => {
24502
+ kanbanSupervisor.dispose();
24503
+ await autoHealer.dispose();
24504
+ };
24198
24505
  opts.onDispose(dispose);
24199
24506
  }
24200
24507
  const kanbanContext = () => ({
@@ -25058,7 +25365,11 @@ async function createPreContextServices(input) {
25058
25365
  model: config.model
25059
25366
  });
25060
25367
  context.meta["promptOnlineAgents"] = onlineAgents;
25061
- const initialContextPolicy = resolveContextWindowPolicy3(config.context);
25368
+ const initialContextPolicy = resolveContextWindowPolicy3(
25369
+ config.context,
25370
+ void 0,
25371
+ provider.capabilities?.maxContext
25372
+ );
25062
25373
  context.meta["contextWindowMode"] = initialContextPolicy.id;
25063
25374
  context.meta["contextWindowPolicy"] = initialContextPolicy;
25064
25375
  context.state.setMeta(
@@ -25903,7 +26214,7 @@ function setupWebuiShutdown(options) {
25903
26214
  onPreShutdown: async () => {
25904
26215
  await options.stopEmptySessionCleanup.dispose();
25905
26216
  const disposeKanban = options.getKanbanSupervisorDispose();
25906
- disposeKanban?.();
26217
+ await disposeKanban?.();
25907
26218
  },
25908
26219
  onShutdown: async () => {
25909
26220
  unregister();
@@ -12,7 +12,7 @@ export declare function setupWebuiShutdown(options: {
12
12
  stopEmptySessionCleanup: {
13
13
  dispose: () => Promise<void>;
14
14
  };
15
- getKanbanSupervisorDispose: () => (() => void) | null;
15
+ getKanbanSupervisorDispose: () => (() => void | Promise<void>) | null;
16
16
  todosCheckpoint: {
17
17
  detach: () => Promise<void>;
18
18
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrongstack/webui-server",
3
- "version": "0.308.1",
3
+ "version": "0.308.2",
4
4
  "license": "MIT",
5
5
  "description": "WrongStack WebUI HTTP/WebSocket server module — extracted from @wrongstack/webui in PR #243/244 to remove the CLI -> @wrongstack/webui/server cross-package edge (audit §3.1.1). Pure backend: HTTP routes, WebSocket handlers, MCP tool wrappers, HTML serving. The web frontend lives in @wrongstack/webui; this package is the standalone server it can run on.",
6
6
  "keywords": [
@@ -40,17 +40,17 @@
40
40
  ],
41
41
  "dependencies": {
42
42
  "ws": "^8.21.3",
43
- "@wrongstack/core": "0.308.1",
44
- "@wrongstack/requirement-intake": "0.308.1",
45
- "@wrongstack/kanban": "0.308.1",
46
- "@wrongstack/sdd": "0.308.1",
47
- "@wrongstack/mcp": "0.308.1",
48
- "@wrongstack/providers": "0.308.1",
49
- "@wrongstack/runtime": "0.308.1",
50
- "@wrongstack/techstack": "0.308.1",
51
- "@wrongstack/sage": "0.308.1",
52
- "@wrongstack/tools": "0.308.1",
53
- "@wrongstack/vector-memory": "0.308.1"
43
+ "@wrongstack/sage": "0.308.2",
44
+ "@wrongstack/requirement-intake": "0.308.2",
45
+ "@wrongstack/core": "0.308.2",
46
+ "@wrongstack/providers": "0.308.2",
47
+ "@wrongstack/kanban": "0.308.2",
48
+ "@wrongstack/runtime": "0.308.2",
49
+ "@wrongstack/techstack": "0.308.2",
50
+ "@wrongstack/mcp": "0.308.2",
51
+ "@wrongstack/sdd": "0.308.2",
52
+ "@wrongstack/vector-memory": "0.308.2",
53
+ "@wrongstack/tools": "0.308.2"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/node": "^26.2.0",