@wrongstack/webui-server 0.302.0 → 0.302.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
@@ -4409,7 +4409,8 @@ function createConversationOperations(ctx) {
4409
4409
  userMessage: async (ws, msg) => {
4410
4410
  if (!ensureCurrentSession(ws, msg, "user_message")) return;
4411
4411
  const payload = msg.payload ?? {};
4412
- const controller = ctx.runControl.begin(ws);
4412
+ const originSessionId = ctx.getSessionId();
4413
+ const controller = ctx.runControl.begin(ws, originSessionId);
4413
4414
  if (!controller) {
4414
4415
  ctx.send(ws, {
4415
4416
  type: "error",
@@ -4420,7 +4421,6 @@ function createConversationOperations(ctx) {
4420
4421
  });
4421
4422
  return;
4422
4423
  }
4423
- const originSessionId = ctx.getSessionId();
4424
4424
  try {
4425
4425
  const agent = ctx.getAgent();
4426
4426
  if (payload.freshContext === true) await startFreshTopicContext(agent.ctx);
@@ -4479,12 +4479,13 @@ function createConversationOperations(ctx) {
4479
4479
  });
4480
4480
  }
4481
4481
  } finally {
4482
- ctx.runControl.end(ws, controller);
4482
+ ctx.runControl.end(ws, originSessionId, controller);
4483
4483
  }
4484
4484
  },
4485
4485
  abort: (ws, msg) => {
4486
4486
  if (!ensureCurrentSession(ws, msg, "abort")) return;
4487
- ctx.runControl.abort(ws);
4487
+ const sessionId = requestedSessionId(msg) ?? ctx.getSessionId();
4488
+ ctx.runControl.abort(ws, sessionId);
4488
4489
  ctx.notifyAbort(ws, {
4489
4490
  type: "error",
4490
4491
  payload: sessionPayload2({ phase: "abort", message: "User aborted" })
@@ -5536,6 +5537,7 @@ function createConnectionLifecycle(options) {
5536
5537
  }
5537
5538
 
5538
5539
  // src/server/connections-health-route.ts
5540
+ import * as net from "node:net";
5539
5541
  import {
5540
5542
  ChronicleProjectServerClient,
5541
5543
  createChronicleProjectAccess as createChronicleProjectAccess2,
@@ -5545,13 +5547,22 @@ import {
5545
5547
  isMailboxProjectServerAvailable,
5546
5548
  MailboxProjectServerConnection
5547
5549
  } from "@wrongstack/core/coordination";
5550
+ import { SessionCatalogProjectClient } from "@wrongstack/core/session-catalog";
5548
5551
  import { resolveWstackPaths as resolveWstackPaths2 } from "@wrongstack/core/utils";
5549
5552
  import {
5550
5553
  closeKanbanServerConnections,
5551
5554
  getKanbanServerConnection,
5552
5555
  isKanbanServerAvailable
5553
5556
  } from "@wrongstack/kanban";
5554
- import * as net from "node:net";
5557
+ import { readGovernanceDaemonOperatorStatus } from "@wrongstack/runtime/governance-bootstrap";
5558
+ import { isSageProjectServerAvailable, SageProjectServerConnection } from "@wrongstack/sage";
5559
+ import {
5560
+ checkCodebaseIndexServerHealth,
5561
+ ensureCodebaseIndexServer as ensureCodebaseIndexServer2,
5562
+ getIndexState,
5563
+ resolveProjectIndexDaemonAvailability,
5564
+ shutdownCodebaseIndexServer
5565
+ } from "@wrongstack/tools";
5555
5566
 
5556
5567
  // src/server/privileged-actions.ts
5557
5568
  import { randomUUID as randomUUID2 } from "node:crypto";
@@ -5585,15 +5596,6 @@ async function authorizeWebUIAction(boundary, action, logger) {
5585
5596
  }
5586
5597
 
5587
5598
  // src/server/connections-health-route.ts
5588
- import { readGovernanceDaemonOperatorStatus } from "@wrongstack/runtime/governance-bootstrap";
5589
- import { isSageProjectServerAvailable, SageProjectServerConnection } from "@wrongstack/sage";
5590
- import {
5591
- checkCodebaseIndexServerHealth,
5592
- ensureCodebaseIndexServer as ensureCodebaseIndexServer2,
5593
- getIndexState,
5594
- resolveProjectIndexDaemonAvailability,
5595
- shutdownCodebaseIndexServer
5596
- } from "@wrongstack/tools";
5597
5599
  async function handleConnectionsHealthRoute(context, ws, message) {
5598
5600
  if (message.type !== "connections.health") return false;
5599
5601
  try {
@@ -5614,6 +5616,7 @@ async function handleConnectionsHealthRoute(context, ws, message) {
5614
5616
  async function collectConnectionsHealth(options) {
5615
5617
  const services = await Promise.all([
5616
5618
  Promise.resolve(webuiHealth(options.backend)),
5619
+ sessionCatalogHealth(options.projectRoot),
5617
5620
  chronicleHealth(options.projectRoot),
5618
5621
  codebaseIndexHealth(options.projectRoot, options.indexDir),
5619
5622
  sageHealth(options.projectRoot),
@@ -5631,6 +5634,50 @@ async function collectConnectionsHealth(options) {
5631
5634
  services
5632
5635
  };
5633
5636
  }
5637
+ async function sessionCatalogHealth(projectRoot) {
5638
+ const startedAt = Date.now();
5639
+ try {
5640
+ const paths = resolveWstackPaths2({ projectRoot });
5641
+ const client = new SessionCatalogProjectClient({
5642
+ projectDir: paths.projectDir,
5643
+ projectRoot
5644
+ });
5645
+ try {
5646
+ const health = await client.ping();
5647
+ return {
5648
+ id: "session-catalog",
5649
+ label: "Session Catalog",
5650
+ status: health.damagedRows > 0 ? "degraded" : "healthy",
5651
+ required: true,
5652
+ mode: "project-daemon",
5653
+ detail: health.damagedRows > 0 ? `${health.damagedRows} damaged catalog row(s); rebuild is required.` : `${health.catalogRows} catalog session(s), ${health.liveLeases} live lease(s), ${health.reservations} reservation(s).`,
5654
+ ownerPid: health.pid,
5655
+ endpoint: health.endpoint,
5656
+ storage: health.databasePath,
5657
+ uptimeMs: health.uptimeMs,
5658
+ latencyMs: Date.now() - startedAt,
5659
+ clients: health.clients,
5660
+ activeRequests: health.activeRequests,
5661
+ queuedWork: health.reservations + health.maintenanceLeases,
5662
+ control: "none"
5663
+ };
5664
+ } finally {
5665
+ await client.close().catch(() => void 0);
5666
+ }
5667
+ } catch (error2) {
5668
+ return {
5669
+ id: "session-catalog",
5670
+ label: "Session Catalog",
5671
+ status: "error",
5672
+ required: true,
5673
+ mode: "project-daemon",
5674
+ detail: "Project-scoped session ownership and catalog are unavailable.",
5675
+ latencyMs: Date.now() - startedAt,
5676
+ lastError: error2 instanceof Error ? error2.message : String(error2),
5677
+ control: "none"
5678
+ };
5679
+ }
5680
+ }
5634
5681
  function webuiHealth(backend) {
5635
5682
  return {
5636
5683
  id: "webui",
@@ -6229,7 +6276,11 @@ async function restartSageServer(projectRoot) {
6229
6276
  });
6230
6277
  const verifyConn = new SageProjectServerConnection(projectRoot);
6231
6278
  try {
6232
- await verifyConn.call("ping", {}, { timeoutMs: 1e4, meta: { clientId: `sage-restart-${process.pid}` } });
6279
+ await verifyConn.call(
6280
+ "ping",
6281
+ {},
6282
+ { timeoutMs: 1e4, meta: { clientId: `sage-restart-${process.pid}` } }
6283
+ );
6233
6284
  return {
6234
6285
  serviceId: "sage",
6235
6286
  action: "restart",
@@ -8248,8 +8299,8 @@ async function handleApiSessions(res, globalRoot) {
8248
8299
  return;
8249
8300
  }
8250
8301
  try {
8251
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8252
- const registry = new SessionRegistry(globalRoot);
8302
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8303
+ const registry = getSessionRegistry3(globalRoot);
8253
8304
  const sessions = await registry.list();
8254
8305
  const result = sessions.map((s) => ({
8255
8306
  sessionId: s.sessionId,
@@ -8286,8 +8337,8 @@ async function handleApiSessionAgents(res, globalRoot, sessionId) {
8286
8337
  return;
8287
8338
  }
8288
8339
  try {
8289
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8290
- const registry = new SessionRegistry(globalRoot);
8340
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8341
+ const registry = getSessionRegistry3(globalRoot);
8291
8342
  const entry = await registry.get(sessionId);
8292
8343
  if (!entry) {
8293
8344
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -8295,20 +8346,22 @@ async function handleApiSessionAgents(res, globalRoot, sessionId) {
8295
8346
  return;
8296
8347
  }
8297
8348
  res.writeHead(200, { "Content-Type": "application/json" });
8298
- res.end(JSON.stringify({
8299
- sessionId: entry.sessionId,
8300
- projectName: entry.projectName,
8301
- status: entry.status,
8302
- agents: entry.agents.map((a) => ({
8303
- id: a.id,
8304
- name: a.name,
8305
- status: a.status,
8306
- currentTool: a.currentTool,
8307
- iterations: a.iterations,
8308
- toolCalls: a.toolCalls,
8309
- lastActivityAt: a.lastActivityAt
8310
- }))
8311
- }));
8349
+ res.end(
8350
+ JSON.stringify({
8351
+ sessionId: entry.sessionId,
8352
+ projectName: entry.projectName,
8353
+ status: entry.status,
8354
+ agents: entry.agents.map((a) => ({
8355
+ id: a.id,
8356
+ name: a.name,
8357
+ status: a.status,
8358
+ currentTool: a.currentTool,
8359
+ iterations: a.iterations,
8360
+ toolCalls: a.toolCalls,
8361
+ lastActivityAt: a.lastActivityAt
8362
+ }))
8363
+ })
8364
+ );
8312
8365
  } catch (err) {
8313
8366
  res.writeHead(500, { "Content-Type": "application/json" });
8314
8367
  res.end(JSON.stringify({ error: sanitizeApiError(err) }));
@@ -8426,9 +8479,9 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8426
8479
  return;
8427
8480
  }
8428
8481
  try {
8429
- const { SessionRegistry, DefaultSessionStore: DefaultSessionStore4, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core/storage");
8482
+ const { getSessionRegistry: getSessionRegistry3, DefaultSessionStore: DefaultSessionStore4, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core/storage");
8430
8483
  const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8431
- const registry = new SessionRegistry(globalRoot);
8484
+ const registry = getSessionRegistry3(globalRoot);
8432
8485
  const entry = await registry.get(sessionId);
8433
8486
  if (!entry) {
8434
8487
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -8436,7 +8489,10 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8436
8489
  return;
8437
8490
  }
8438
8491
  const paths = resolveWstackPaths7({ projectRoot: entry.projectRoot, globalRoot });
8439
- const store = new DefaultSessionStore4({ dir: paths.projectSessions });
8492
+ const store = new DefaultSessionStore4({
8493
+ dir: paths.projectSessions,
8494
+ projectRoot: entry.projectRoot
8495
+ });
8440
8496
  const reader = new DefaultSessionReader2({ store });
8441
8497
  const RING = Math.max(limit * 4, 2e3);
8442
8498
  const ring = [];
@@ -8528,10 +8584,10 @@ async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
8528
8584
  const priority = ["low", "normal", "high"].includes(rawPriority) ? rawPriority : "high";
8529
8585
  const subject = typeof body["subject"] === "string" && body["subject"].trim() ? body["subject"].trim() : "Message from Fleet HQ";
8530
8586
  try {
8531
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8587
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8532
8588
  const { getSharedProjectMailbox: getSharedProjectMailbox6, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8533
8589
  const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8534
- const registry = new SessionRegistry(globalRoot);
8590
+ const registry = getSessionRegistry3(globalRoot);
8535
8591
  const entry = await registry.get(sessionId);
8536
8592
  if (!entry) {
8537
8593
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -8556,10 +8612,10 @@ async function handleApiSessionMailbox(res, globalRoot, sessionId) {
8556
8612
  return;
8557
8613
  }
8558
8614
  try {
8559
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8615
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8560
8616
  const { getSharedProjectMailbox: getSharedProjectMailbox6, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8561
8617
  const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8562
- const registry = new SessionRegistry(globalRoot);
8618
+ const registry = getSessionRegistry3(globalRoot);
8563
8619
  const entry = await registry.get(sessionId);
8564
8620
  if (!entry) {
8565
8621
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -8616,10 +8672,10 @@ async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
8616
8672
  const reason = typeof body["reason"] === "string" && body["reason"].trim() ? body["reason"].trim() : "Operator requested stop from Fleet HQ";
8617
8673
  const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
8618
8674
  try {
8619
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8675
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8620
8676
  const { getSharedProjectMailbox: getSharedProjectMailbox6, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8621
8677
  const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8622
- const registry = new SessionRegistry(globalRoot);
8678
+ const registry = getSessionRegistry3(globalRoot);
8623
8679
  const entry = await registry.get(sessionId);
8624
8680
  if (!entry) {
8625
8681
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -8665,10 +8721,10 @@ async function handleApiFleetBroadcast(res, req, globalRoot) {
8665
8721
  }
8666
8722
  const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
8667
8723
  try {
8668
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8724
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8669
8725
  const { getSharedProjectMailbox: getSharedProjectMailbox6, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8670
8726
  const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8671
- const registry = new SessionRegistry(globalRoot);
8727
+ const registry = getSessionRegistry3(globalRoot);
8672
8728
  const all = await registry.list();
8673
8729
  const mySlug = all.find((s) => s.pid === process.pid)?.projectSlug;
8674
8730
  const targets = all.filter((s) => s.status !== "stale").filter((s) => mySlug ? s.projectSlug === mySlug : true);
@@ -11304,6 +11360,14 @@ async function handleKanbanRoute(ws, msg, ctx) {
11304
11360
  fail(ws, type, `Board not found: ${boardId}`);
11305
11361
  return true;
11306
11362
  }
11363
+ if (ctx.supervisor) {
11364
+ const snapshots = await ctx.supervisor.auditNow(boardId);
11365
+ const snapshot = snapshots[0];
11366
+ if (snapshot) {
11367
+ ok(ws, type, snapshot);
11368
+ return true;
11369
+ }
11370
+ }
11307
11371
  const reconciled = await reconcileKanbanBoard(ctx.projectRoot, boardId);
11308
11372
  let health = await getKanbanQueueHealth(ctx.projectRoot, { boardId });
11309
11373
  const recovered = health.staleAssignments.count ? await recoverStaleTaskAssignments(ctx.projectRoot, boardId, {
@@ -12068,6 +12132,15 @@ function createShutdown(res) {
12068
12132
  } catch {
12069
12133
  }
12070
12134
  }
12135
+ if (res.onPreShutdown) {
12136
+ try {
12137
+ await res.onPreShutdown();
12138
+ } catch (e) {
12139
+ log(
12140
+ `[WebUI] Error during pre-shutdown cleanup: ${e instanceof Error ? e.message : String(e)}`
12141
+ );
12142
+ }
12143
+ }
12071
12144
  for (const server of res.servers) server?.close();
12072
12145
  if (res.onShutdown) {
12073
12146
  try {
@@ -14956,6 +15029,10 @@ import {
14956
15029
  recoverStaleTaskAssignments as recoverStaleTaskAssignments2,
14957
15030
  resolveGateEnforcement
14958
15031
  } from "@wrongstack/kanban";
15032
+ function resolveProjectRoot(deps2) {
15033
+ const root = deps2.projectRoot;
15034
+ return typeof root === "function" ? root() : root;
15035
+ }
14959
15036
  var DEFAULT_INTERVAL_MS = 1e4;
14960
15037
  var MIN_INTERVAL_MS = 2e3;
14961
15038
  var DEFAULT_AGENT_COOLDOWN_MS = 5 * 6e4;
@@ -15020,14 +15097,14 @@ function createKanbanSupervisor(deps2) {
15020
15097
  publish(snapshot2);
15021
15098
  return snapshot2;
15022
15099
  }
15023
- const reconciled = await reconcileKanbanBoard2(deps2.projectRoot, board.id);
15100
+ const reconciled = await reconcileKanbanBoard2(resolveProjectRoot(deps2), board.id);
15024
15101
  const gateSwept = await sweepGateParkedTasks(deps2, reconciled?.board ?? board);
15025
- let health = await getKanbanQueueHealth2(deps2.projectRoot, { boardId: board.id });
15026
- const recovered = health.staleAssignments.count ? await recoverStaleTaskAssignments2(deps2.projectRoot, board.id, {
15102
+ let health = await getKanbanQueueHealth2(resolveProjectRoot(deps2), { boardId: board.id });
15103
+ const recovered = health.staleAssignments.count ? await recoverStaleTaskAssignments2(resolveProjectRoot(deps2), board.id, {
15027
15104
  mode: config.recoveryMode ?? "auto",
15028
15105
  reason: "Kanban supervisor found an expired worker lease."
15029
15106
  }) : null;
15030
- if (recovered) health = await getKanbanQueueHealth2(deps2.projectRoot, { boardId: board.id });
15107
+ if (recovered) health = await getKanbanQueueHealth2(resolveProjectRoot(deps2), { boardId: board.id });
15031
15108
  const anomalyCount = countAnomalies(health);
15032
15109
  const snapshot = {
15033
15110
  boardId: board.id,
@@ -15046,7 +15123,7 @@ function createKanbanSupervisor(deps2) {
15046
15123
  await publishKanbanBoard(
15047
15124
  deps2.broadcast,
15048
15125
  changedBoard,
15049
- () => listBoards4(deps2.projectRoot)
15126
+ () => listBoards4(resolveProjectRoot(deps2))
15050
15127
  );
15051
15128
  }
15052
15129
  if (config.mode === "agentic" && anomalyCount > 0) {
@@ -15084,11 +15161,11 @@ function createKanbanSupervisor(deps2) {
15084
15161
  // tool-runtime boundary gate (`evaluateToolKanbanBoundary`) can resolve
15085
15162
  // the live board policy instead of failing open. Whole-board agentic
15086
15163
  // runs have no taskId, so only boardId is propagated.
15087
- context: { kanban: { boardId: board.id, projectRoot: deps2.projectRoot } },
15164
+ context: { kanban: { boardId: board.id, projectRoot: resolveProjectRoot(deps2) } },
15088
15165
  onDone: async (result) => {
15089
15166
  clearTimeout(watchdog);
15090
15167
  agentRunning.delete(board.id);
15091
- if (await getBoard3(deps2.projectRoot, board.id) === null) return;
15168
+ if (await getBoard3(resolveProjectRoot(deps2), board.id) === null) return;
15092
15169
  const current3 = snapshots.get(board.id) ?? snapshot;
15093
15170
  publish({
15094
15171
  ...current3,
@@ -15112,7 +15189,7 @@ function createKanbanSupervisor(deps2) {
15112
15189
  const auditNow = async (boardId) => {
15113
15190
  let boards;
15114
15191
  if (boardId) {
15115
- const board = await getBoard3(deps2.projectRoot, boardId);
15192
+ const board = await getBoard3(resolveProjectRoot(deps2), boardId);
15116
15193
  if (board === null) {
15117
15194
  forgetBoard(boardId);
15118
15195
  boards = [];
@@ -15120,9 +15197,9 @@ function createKanbanSupervisor(deps2) {
15120
15197
  boards = [board];
15121
15198
  }
15122
15199
  } else {
15123
- const summaries = await listBoards4(deps2.projectRoot);
15200
+ const summaries = await listBoards4(resolveProjectRoot(deps2));
15124
15201
  pruneAbsentBoards(new Set(summaries.map((summary) => summary.id)));
15125
- boards = (await Promise.all(summaries.map((summary) => getBoard3(deps2.projectRoot, summary.id)))).filter((board) => Boolean(board));
15202
+ boards = (await Promise.all(summaries.map((summary) => getBoard3(resolveProjectRoot(deps2), summary.id)))).filter((board) => Boolean(board));
15126
15203
  }
15127
15204
  const results = [];
15128
15205
  for (const board of boards) results.push(await auditBoard(board));
@@ -15157,11 +15234,11 @@ function createKanbanSupervisor(deps2) {
15157
15234
  if (disposed) return;
15158
15235
  try {
15159
15236
  const now = Date.now();
15160
- const summaries = await listBoards4(deps2.projectRoot);
15237
+ const summaries = await listBoards4(resolveProjectRoot(deps2));
15161
15238
  pruneAbsentBoards(new Set(summaries.map((summary) => summary.id)));
15162
15239
  for (const summary of summaries) {
15163
15240
  if ((nextDue.get(summary.id) ?? 0) > now) continue;
15164
- const board = await getBoard3(deps2.projectRoot, summary.id);
15241
+ const board = await getBoard3(resolveProjectRoot(deps2), summary.id);
15165
15242
  if (board) await auditBoard(board);
15166
15243
  }
15167
15244
  } catch (error2) {
@@ -15204,7 +15281,7 @@ async function sweepGateParkedTasks(deps2, board) {
15204
15281
  let lastBoard;
15205
15282
  for (const task of parked) {
15206
15283
  try {
15207
- const finalized = await finalizeTaskCompletion(deps2.projectRoot, board.id, task.id, {
15284
+ const finalized = await finalizeTaskCompletion(resolveProjectRoot(deps2), board.id, task.id, {
15208
15285
  eventContext: { actor: "kanban-supervisor" }
15209
15286
  });
15210
15287
  if (finalized) lastBoard = finalized.board;
@@ -17994,6 +18071,8 @@ function labelForEvent(e) {
17994
18071
  const count = e.messagesOmitted ?? e.messages.length;
17995
18072
  return `Messages replaced (${e.messagesOmitted ? "~" : ""}${count} msgs)`;
17996
18073
  }
18074
+ case "messages_dropped":
18075
+ return `Oldest ${e.count} message${e.count === 1 ? "" : "s"} evicted`;
17997
18076
  case "message_truncated":
17998
18077
  return `Message truncated: ${e.before} \u2192 ${e.after}`;
17999
18078
  case "file_event":
@@ -18081,6 +18160,8 @@ function detailForEvent(e) {
18081
18160
  return `at index ${e.index}`;
18082
18161
  case "messages_replaced":
18083
18162
  return `${e.messagesOmitted ?? e.messages.length} total`;
18163
+ case "messages_dropped":
18164
+ return `dropped ${e.count} from the front`;
18084
18165
  case "message_truncated":
18085
18166
  return `truncated to ${e.after} tokens`;
18086
18167
  case "mode_changed":
@@ -18298,7 +18379,7 @@ function createSessionHandlers(ctx) {
18298
18379
  const current2 = ctx.getSession();
18299
18380
  if (current2 !== next) {
18300
18381
  try {
18301
- ctx.abortActiveRun?.();
18382
+ ctx.abortActiveRun?.(current2.id);
18302
18383
  } catch {
18303
18384
  }
18304
18385
  await finalizeSession(current2);
@@ -18885,16 +18966,17 @@ function createEmbeddedConversationRoutes(ctx) {
18885
18966
  getAgent: () => ctx.agent,
18886
18967
  getSessionId: () => ctx.agent.ctx.session?.id ?? "",
18887
18968
  runControl: {
18888
- begin: (ws) => {
18889
- if (ctx.abortControllers.has(ws)) return void 0;
18969
+ begin: (_ws, sessionId) => {
18970
+ if (ctx.abortControllers.has(sessionId)) return void 0;
18890
18971
  const controller = new AbortController();
18891
- ctx.abortControllers.set(ws, controller);
18972
+ ctx.abortControllers.set(sessionId, controller);
18892
18973
  return controller;
18893
18974
  },
18894
- end: (ws, controller) => {
18895
- if (ctx.abortControllers.get(ws) === controller) ctx.abortControllers.delete(ws);
18975
+ end: (_ws, sessionId, controller) => {
18976
+ if (ctx.abortControllers.get(sessionId) === controller)
18977
+ ctx.abortControllers.delete(sessionId);
18896
18978
  },
18897
- abort: (ws) => ctx.abortControllers.get(ws)?.abort()
18979
+ abort: (_ws, sessionId) => ctx.abortControllers.get(sessionId)?.abort()
18898
18980
  },
18899
18981
  pendingConfirms: ctx.pendingConfirms,
18900
18982
  send: ctx.send,
@@ -20316,9 +20398,13 @@ function createEmbeddedMessageRouter(deps2) {
20316
20398
  // stream from the previous session would otherwise keep running in the
20317
20399
  // background after session.new/resume. The run's own end() cleanup
20318
20400
  // removes controllers from the map when it unwinds.
20319
- abortActiveRun: () => {
20320
- for (const controller of deps2.conversationCtx.abortControllers.values()) {
20321
- controller.abort();
20401
+ abortActiveRun: (sessionId) => {
20402
+ if (sessionId) {
20403
+ deps2.conversationCtx.abortControllers.get(sessionId)?.abort();
20404
+ } else {
20405
+ for (const controller of [...deps2.conversationCtx.abortControllers.values()]) {
20406
+ controller.abort();
20407
+ }
20322
20408
  }
20323
20409
  },
20324
20410
  isRunActive: () => deps2.conversationCtx.abortControllers.size > 0
@@ -21496,7 +21582,6 @@ function registerSetupEventsClientStatusWriter(deps2) {
21496
21582
  }
21497
21583
 
21498
21584
  // src/server/setup-events-fleet-broadcaster.ts
21499
- import { watch as fsWatch } from "node:fs";
21500
21585
  import * as path25 from "node:path";
21501
21586
  function registerSetupEventsFleetBroadcaster(deps2) {
21502
21587
  const { globalConfigPath, wpaths, context, clients, broadcast: broadcast2, onFleetBroadcaster, isDisposed } = deps2;
@@ -21505,8 +21590,8 @@ function registerSetupEventsFleetBroadcaster(deps2) {
21505
21590
  const disposers = [];
21506
21591
  const broadcastSessions = async () => {
21507
21592
  try {
21508
- const { SessionRegistry } = await import("@wrongstack/core/storage");
21509
- const registry = new SessionRegistry(globalRoot);
21593
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
21594
+ const registry = getSessionRegistry3(globalRoot);
21510
21595
  const sessions = await registry.list();
21511
21596
  const ownEntry = sessions.find((s) => s.pid === process.pid);
21512
21597
  const mySlug = ownEntry?.projectSlug ?? wpaths?.projectSlug;
@@ -21553,7 +21638,7 @@ function registerSetupEventsFleetBroadcaster(deps2) {
21553
21638
  }
21554
21639
  };
21555
21640
  onFleetBroadcaster?.(broadcastSessions);
21556
- let regWatchLive = false;
21641
+ let subscriptionLive = false;
21557
21642
  let statusTimer;
21558
21643
  const scheduleStatusPoll = () => {
21559
21644
  if (isDisposed()) return;
@@ -21562,35 +21647,31 @@ function registerSetupEventsFleetBroadcaster(deps2) {
21562
21647
  void broadcastSessions();
21563
21648
  scheduleStatusPoll();
21564
21649
  },
21565
- regWatchLive ? 3e4 : 5e3
21650
+ subscriptionLive ? 3e4 : 5e3
21566
21651
  );
21567
21652
  if (statusTimer.unref) statusTimer.unref();
21568
21653
  };
21569
21654
  disposers.push(() => {
21570
21655
  if (statusTimer) clearTimeout(statusTimer);
21571
21656
  });
21572
- let regDebounce;
21573
- try {
21574
- const regWatcher = fsWatch(globalRoot, { persistent: false }, (_event, filename) => {
21575
- const name2 = filename ? String(filename) : "";
21576
- if (!name2.startsWith("session-registry.json") || name2.endsWith(".lock")) return;
21577
- if (regDebounce) clearTimeout(regDebounce);
21578
- regDebounce = setTimeout(() => void broadcastSessions(), 150);
21579
- });
21580
- regWatcher.on("error", () => {
21581
- regWatchLive = false;
21582
- try {
21583
- regWatcher.close();
21584
- } catch {
21585
- }
21586
- });
21587
- regWatchLive = true;
21588
- disposers.push(() => {
21589
- if (regDebounce) clearTimeout(regDebounce);
21590
- regWatcher.close();
21591
- });
21592
- } catch {
21593
- }
21657
+ let eventDebounce;
21658
+ let unsubscribe;
21659
+ void import("@wrongstack/core/storage").then(async ({ getSessionRegistry: getSessionRegistry3 }) => {
21660
+ const registry = getSessionRegistry3(globalRoot);
21661
+ const projectSlug2 = wpaths?.projectSlug;
21662
+ if (!projectSlug2 || isDisposed()) return;
21663
+ unsubscribe = await registry.subscribeProject(projectSlug2, context.projectRoot, () => {
21664
+ if (eventDebounce) clearTimeout(eventDebounce);
21665
+ eventDebounce = setTimeout(() => void broadcastSessions(), 25);
21666
+ });
21667
+ subscriptionLive = true;
21668
+ }).catch(() => {
21669
+ subscriptionLive = false;
21670
+ });
21671
+ disposers.push(() => {
21672
+ if (eventDebounce) clearTimeout(eventDebounce);
21673
+ void unsubscribe?.();
21674
+ });
21594
21675
  scheduleStatusPoll();
21595
21676
  void broadcastSessions();
21596
21677
  return () => {
@@ -21703,7 +21784,7 @@ function createSetupEventSessionHelpers(context, sessionBridge) {
21703
21784
  }
21704
21785
 
21705
21786
  // src/server/setup-events-status-watcher.ts
21706
- import { watch as fsWatch2 } from "node:fs";
21787
+ import { watch as fsWatch } from "node:fs";
21707
21788
  import * as fs20 from "node:fs/promises";
21708
21789
  import * as path27 from "node:path";
21709
21790
 
@@ -21793,7 +21874,7 @@ function registerSetupEventsStatusWatcher(deps2) {
21793
21874
  try {
21794
21875
  await fs20.mkdir(projectsDir, { recursive: true });
21795
21876
  if (isDisposed()) return;
21796
- watcher = fsWatch2(
21877
+ watcher = fsWatch(
21797
21878
  projectsDir,
21798
21879
  { persistent: true, recursive: true },
21799
21880
  async (eventType, filename) => {
@@ -22955,7 +23036,6 @@ import {
22955
23036
  mailboxSessionTag,
22956
23037
  ObservableBrainArbiter as ObservableBrainArbiterCtor
22957
23038
  } from "@wrongstack/core/coordination";
22958
- import { DEFAULT_TOOLS_CONFIG } from "@wrongstack/core/types";
22959
23039
  import { installDesignStudioMiddleware } from "@wrongstack/core/design";
22960
23040
  import {
22961
23041
  AutoCompactionMiddleware as AutoCompactionMiddlewareCtor,
@@ -22967,6 +23047,7 @@ import {
22967
23047
  import { TOKENS as TOKENS2 } from "@wrongstack/core/kernel";
22968
23048
  import { SessionMemoryConsolidator } from "@wrongstack/core/storage";
22969
23049
  import {
23050
+ DEFAULT_TOOLS_CONFIG,
22970
23051
  resolveContextWindowPolicy as resolveContextWindowPolicy2
22971
23052
  } from "@wrongstack/core/types";
22972
23053
  import {
@@ -23848,6 +23929,11 @@ async function createAgentServices(input) {
23848
23929
  taskAware: config.Sage?.inject?.taskAware,
23849
23930
  minScore: config.Sage?.inject?.minScore,
23850
23931
  minImportance: config.Sage?.inject?.minImportance,
23932
+ // Forward the explicit relation floor so an operator-configured
23933
+ // `Sage.inject.relationFloor` is honored in WebUI sessions. Without
23934
+ // this we silently fall back to MIN_RELATION_STRENGTH (0.85), which
23935
+ // is the CLI default but masks operator overrides.
23936
+ relationFloor: config.Sage?.inject?.relationFloor,
23851
23937
  repeatCooldownMs: config.Sage?.inject?.repeatCooldownMs,
23852
23938
  verifyOnMutation: config.Sage?.hygiene?.autoOnFileChange,
23853
23939
  triggers: config.Sage?.inject?.triggers,
@@ -23869,6 +23955,10 @@ async function createAgentServices(input) {
23869
23955
  maxMemories: config.Sage?.inject?.maxTurnMemories,
23870
23956
  maxChars: config.Sage?.inject?.maxCharsPerTurn,
23871
23957
  minScore: config.Sage?.inject?.minScore,
23958
+ // CLI parity: honor `Sage.retrieval.metadataWeight` so the same config
23959
+ // value drives both runtimes instead of silently falling back to the
23960
+ // 0.3 default. The undefined case keeps the middleware's own default.
23961
+ metadataWeight: config.Sage?.retrieval?.metadataWeight,
23872
23962
  getSessionId: getSageSessionId,
23873
23963
  tracker: sageInjectionTracker
23874
23964
  })
@@ -23893,25 +23983,29 @@ async function createAgentServices(input) {
23893
23983
  strategy: config.context?.strategy,
23894
23984
  preserveK: config.context?.preserveK ?? 10,
23895
23985
  eliseThreshold: config.context?.eliseThreshold ?? 2e3,
23986
+ // Match the CLI/TUI runtime: keep corrections, errors and decisions
23987
+ // verbatim while collapsing routine assistant chatter/tool protocol.
23988
+ // Without this WebUI's hybrid strategy builds an ever-growing lossless
23989
+ // digest and eventually relies on blunt emergency head/tail trimming.
23990
+ smart: true,
23896
23991
  summarizerModel: config.context?.summarizerModel,
23897
23992
  llmSelector: config.context?.llmSelector
23898
23993
  });
23899
23994
  const initialContextPolicy = resolveContextWindowPolicy2(config.context);
23900
23995
  let autoCompactor;
23901
23996
  if (config.context?.autoCompact !== false) {
23902
- let effectiveMaxContext = config.context?.effectiveMaxContext ?? 0;
23903
- if (!effectiveMaxContext) {
23904
- try {
23905
- const m = await resolveProviderModelMetadata(
23906
- modelsRegistry,
23907
- config.provider,
23908
- context.model,
23909
- config.providers?.[config.provider]
23910
- );
23911
- effectiveMaxContext = m?.capabilities?.maxContext ?? 0;
23912
- } catch {
23913
- }
23997
+ let effectiveMaxContext = 0;
23998
+ try {
23999
+ const m = await resolveProviderModelMetadata(
24000
+ modelsRegistry,
24001
+ config.provider,
24002
+ context.model,
24003
+ config.providers?.[config.provider]
24004
+ );
24005
+ effectiveMaxContext = m?.capabilities?.maxContext ?? 0;
24006
+ } catch {
23914
24007
  }
24008
+ if (!effectiveMaxContext) effectiveMaxContext = config.context?.effectiveMaxContext ?? 0;
23915
24009
  if (!effectiveMaxContext) effectiveMaxContext = provider.capabilities.maxContext;
23916
24010
  autoCompactor = new AutoCompactionMiddlewareCtor(
23917
24011
  compactor,
@@ -24533,16 +24627,24 @@ function createMessageDispatcher(opts) {
24533
24627
  getAgent: () => deps2.agent,
24534
24628
  getSessionId: () => state.getSession().id,
24535
24629
  runControl: {
24536
- begin: () => {
24630
+ begin: (_ws, sessionId) => {
24537
24631
  if (runLock.get()) return void 0;
24538
24632
  const controller = new AbortController();
24539
24633
  runLock.set(controller);
24634
+ runLock.setSession(sessionId);
24540
24635
  return controller;
24541
24636
  },
24542
- end: (_ws, controller) => {
24543
- if (runLock.get() === controller) runLock.set(null);
24637
+ end: (_ws, _sessionId, controller) => {
24638
+ if (runLock.get() === controller) {
24639
+ runLock.set(null);
24640
+ runLock.setSession(null);
24641
+ }
24544
24642
  },
24545
- abort: () => runLock.get()?.abort()
24643
+ abort: (_ws, sessionId) => {
24644
+ if (runLock.getSession() === sessionId || !runLock.getSession()) {
24645
+ runLock.get()?.abort();
24646
+ }
24647
+ }
24546
24648
  },
24547
24649
  pendingConfirms,
24548
24650
  send,
@@ -24564,10 +24666,20 @@ function createMessageDispatcher(opts) {
24564
24666
  const goalSnapshotRoutes = {
24565
24667
  getSnapshot: () => handleGoalGet(state.getProjectRoot(), (message) => broadcast(state.getClients(), message))
24566
24668
  };
24669
+ const kanbanSupervisor = createKanbanSupervisor({
24670
+ projectRoot: () => state.getProjectRoot(),
24671
+ broadcast: (message) => broadcast(state.getClients(), message),
24672
+ log: (message) => deps2.logger.warn?.(`[KanbanSupervisor] ${message}`)
24673
+ });
24674
+ if (opts.onDispose) {
24675
+ const dispose = () => kanbanSupervisor.dispose();
24676
+ opts.onDispose(dispose);
24677
+ }
24567
24678
  const kanbanContext = () => ({
24568
24679
  projectRoot: state.getProjectRoot(),
24569
24680
  context: deps2.context,
24570
- broadcast: (message) => broadcast(state.getClients(), message)
24681
+ broadcast: (message) => broadcast(state.getClients(), message),
24682
+ supervisor: kanbanSupervisor
24571
24683
  });
24572
24684
  const kanbanHostRoutes = {
24573
24685
  meta: async (ws) => {
@@ -25083,6 +25195,14 @@ async function createStandaloneSessionIdentityLifecycle(opts) {
25083
25195
  transition = transition.then(async () => {
25084
25196
  if (stopped) return;
25085
25197
  if (pendingClaim?.sessionId === sessionId) {
25198
+ await pendingClaim.claim.activate({
25199
+ sessionId,
25200
+ ...target,
25201
+ clientType: "webui",
25202
+ pid: process.pid,
25203
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
25204
+ agents: statusTracker.getAgents()
25205
+ });
25086
25206
  pendingClaim = void 0;
25087
25207
  } else {
25088
25208
  await register(sessionId, true, target);
@@ -25106,14 +25226,35 @@ async function createStandaloneSessionIdentityLifecycle(opts) {
25106
25226
  }
25107
25227
  if (sessionId === activeSessionId) return async () => {
25108
25228
  };
25109
- const previousSessionId = activeSessionId;
25110
- const previousTarget = activeTarget;
25111
25229
  const token = Symbol(sessionId);
25112
- await register(sessionId, true, target);
25113
- pendingClaim = { sessionId, previousSessionId, token };
25230
+ if ("reserveResume" in registry && typeof registry.reserveResume === "function") {
25231
+ const reservation = await registry.reserveResume({
25232
+ sessionId,
25233
+ projectSlug: target.projectSlug,
25234
+ projectRoot: target.projectRoot
25235
+ });
25236
+ pendingClaim = { sessionId, token, claim: reservation, target };
25237
+ } else {
25238
+ await register(sessionId, true, target);
25239
+ pendingClaim = {
25240
+ sessionId,
25241
+ token,
25242
+ target,
25243
+ claim: {
25244
+ reservation: {
25245
+ reservationId: "legacy",
25246
+ targetSessionId: sessionId,
25247
+ requesterInstanceId: "legacy",
25248
+ expiresAt: Number.MAX_SAFE_INTEGER
25249
+ },
25250
+ activate: async () => void 0,
25251
+ cancel: async () => register(activeSessionId, true, activeTarget)
25252
+ }
25253
+ };
25254
+ }
25114
25255
  return async () => {
25115
25256
  if (pendingClaim?.token !== token) return;
25116
- await register(previousSessionId, true, previousTarget);
25257
+ await pendingClaim.claim.cancel();
25117
25258
  pendingClaim = void 0;
25118
25259
  };
25119
25260
  };
@@ -26040,10 +26181,11 @@ function startHttpServer(opts) {
26040
26181
  return httpServer;
26041
26182
  }
26042
26183
  function registerShutdown(deps2) {
26043
- registerShutdownHandlers({
26184
+ return registerShutdownHandlers({
26044
26185
  flushSession: deps2.flushSession,
26045
26186
  clients: deps2.clients,
26046
26187
  servers: deps2.servers,
26188
+ onPreShutdown: deps2.onPreShutdown,
26047
26189
  onShutdown: deps2.onShutdown
26048
26190
  });
26049
26191
  }
@@ -26358,10 +26500,15 @@ async function startWebUI(opts = {}) {
26358
26500
  );
26359
26501
  }
26360
26502
  let _runLock = null;
26503
+ let _runLockSession = null;
26361
26504
  const runLockControl = {
26362
26505
  get: () => _runLock,
26363
26506
  set: (ctrl) => {
26364
26507
  _runLock = ctrl;
26508
+ },
26509
+ getSession: () => _runLockSession,
26510
+ setSession: (id) => {
26511
+ _runLockSession = id;
26365
26512
  }
26366
26513
  };
26367
26514
  const pendingConfirms = /* @__PURE__ */ new Map();
@@ -26486,6 +26633,7 @@ async function startWebUI(opts = {}) {
26486
26633
  if (ctrl) {
26487
26634
  ctrl.abort();
26488
26635
  runLockControl.set(null);
26636
+ runLockControl.setSession(null);
26489
26637
  }
26490
26638
  },
26491
26639
  isRunActive: () => runLockControl.get() !== null,
@@ -26635,6 +26783,7 @@ async function startWebUI(opts = {}) {
26635
26783
  })
26636
26784
  });
26637
26785
  const routes = buildRoutes(state, deps2, cb);
26786
+ let kanbanSupervisorDispose = null;
26638
26787
  const handleMessage = createMessageDispatcher({
26639
26788
  state,
26640
26789
  deps: deps2,
@@ -26642,7 +26791,10 @@ async function startWebUI(opts = {}) {
26642
26791
  promptsCtx,
26643
26792
  codebaseIndexing,
26644
26793
  runLock: runLockControl,
26645
- pendingConfirms
26794
+ pendingConfirms,
26795
+ onDispose: (dispose) => {
26796
+ kanbanSupervisorDispose = dispose;
26797
+ }
26646
26798
  });
26647
26799
  const mailbox = getSharedProjectMailbox5(
26648
26800
  resolveProjectDir4(context.projectRoot, wstackGlobalRoot4()),
@@ -26676,7 +26828,14 @@ projectRoot: ${ev.projectRoot ?? "?"}`,
26676
26828
  priority: "high",
26677
26829
  senderSessionId: session.id
26678
26830
  }).catch((err) => {
26679
- console.warn(`[WebUI] security-rejection mailbox note failed: ${String(err)}`);
26831
+ console.warn(
26832
+ JSON.stringify({
26833
+ level: "warn",
26834
+ event: "webui.security_rejection_mailbox_note_failed",
26835
+ message: String(err),
26836
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
26837
+ })
26838
+ );
26680
26839
  });
26681
26840
  },
26682
26841
  goalHandler,
@@ -26690,7 +26849,9 @@ projectRoot: ${ev.projectRoot ?? "?"}`,
26690
26849
  });
26691
26850
  wssPrimary.on("connection", handleConnection);
26692
26851
  if (wssSecondary) wssSecondary.on("connection", handleConnection);
26693
- registerShutdown({
26852
+ let unregisterShutdown = () => {
26853
+ };
26854
+ unregisterShutdown = registerShutdown({
26694
26855
  flushSession: async () => {
26695
26856
  await session.append({
26696
26857
  type: "session_end",
@@ -26706,7 +26867,12 @@ projectRoot: ${ev.projectRoot ?? "?"}`,
26706
26867
  wssPrimary,
26707
26868
  ...wssSecondary ? [wssSecondary] : []
26708
26869
  ],
26870
+ onPreShutdown: () => {
26871
+ kanbanSupervisorDispose?.();
26872
+ kanbanSupervisorDispose = null;
26873
+ },
26709
26874
  onShutdown: async () => {
26875
+ unregisterShutdown();
26710
26876
  await todosCheckpoint.detach();
26711
26877
  await stopHeapWatchdog();
26712
26878
  credentialWatcherClose?.();