@wrongstack/webui-server 0.302.0 → 0.303.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1421,7 +1421,13 @@ function indexDbVersion(projectRoot, indexDir) {
1421
1421
  try {
1422
1422
  const dir = resolveIndexDir(projectRoot, indexDir);
1423
1423
  const st = fs.statSync(path.join(dir, DB_FILE));
1424
- return `${st.mtimeMs}:${st.size}`;
1424
+ let wal = "";
1425
+ try {
1426
+ const walSt = fs.statSync(path.join(dir, `${DB_FILE}-wal`));
1427
+ wal = `:${walSt.mtimeMs}:${walSt.size}`;
1428
+ } catch {
1429
+ }
1430
+ return `${st.mtimeMs}:${st.size}${wal}`;
1425
1431
  } catch {
1426
1432
  return "missing";
1427
1433
  }
@@ -4323,7 +4329,8 @@ function createConversationOperations(ctx) {
4323
4329
  userMessage: async (ws, msg) => {
4324
4330
  if (!ensureCurrentSession(ws, msg, "user_message")) return;
4325
4331
  const payload = msg.payload ?? {};
4326
- const controller = ctx.runControl.begin(ws);
4332
+ const originSessionId = ctx.getSessionId();
4333
+ const controller = ctx.runControl.begin(ws, originSessionId);
4327
4334
  if (!controller) {
4328
4335
  ctx.send(ws, {
4329
4336
  type: "error",
@@ -4334,7 +4341,6 @@ function createConversationOperations(ctx) {
4334
4341
  });
4335
4342
  return;
4336
4343
  }
4337
- const originSessionId = ctx.getSessionId();
4338
4344
  try {
4339
4345
  const agent = ctx.getAgent();
4340
4346
  if (payload.freshContext === true) await startFreshTopicContext(agent.ctx);
@@ -4393,12 +4399,13 @@ function createConversationOperations(ctx) {
4393
4399
  });
4394
4400
  }
4395
4401
  } finally {
4396
- ctx.runControl.end(ws, controller);
4402
+ ctx.runControl.end(ws, originSessionId, controller);
4397
4403
  }
4398
4404
  },
4399
4405
  abort: (ws, msg) => {
4400
4406
  if (!ensureCurrentSession(ws, msg, "abort")) return;
4401
- ctx.runControl.abort(ws);
4407
+ const sessionId = requestedSessionId(msg) ?? ctx.getSessionId();
4408
+ ctx.runControl.abort(ws, sessionId);
4402
4409
  ctx.notifyAbort(ws, {
4403
4410
  type: "error",
4404
4411
  payload: sessionPayload2({ phase: "abort", message: "User aborted" })
@@ -5450,6 +5457,7 @@ function createConnectionLifecycle(options) {
5450
5457
  }
5451
5458
 
5452
5459
  // src/server/connections-health-route.ts
5460
+ import * as net from "node:net";
5453
5461
  import {
5454
5462
  ChronicleProjectServerClient,
5455
5463
  createChronicleProjectAccess as createChronicleProjectAccess2,
@@ -5459,13 +5467,22 @@ import {
5459
5467
  isMailboxProjectServerAvailable,
5460
5468
  MailboxProjectServerConnection
5461
5469
  } from "@wrongstack/core/coordination";
5470
+ import { SessionCatalogProjectClient } from "@wrongstack/core/session-catalog";
5462
5471
  import { resolveWstackPaths as resolveWstackPaths2 } from "@wrongstack/core/utils";
5463
5472
  import {
5464
5473
  closeKanbanServerConnections,
5465
5474
  getKanbanServerConnection,
5466
5475
  isKanbanServerAvailable
5467
5476
  } from "@wrongstack/kanban";
5468
- import * as net from "node:net";
5477
+ import { readGovernanceDaemonOperatorStatus } from "@wrongstack/runtime/governance-bootstrap";
5478
+ import { isSageProjectServerAvailable, SageProjectServerConnection } from "@wrongstack/sage";
5479
+ import {
5480
+ checkCodebaseIndexServerHealth,
5481
+ ensureCodebaseIndexServer as ensureCodebaseIndexServer2,
5482
+ getIndexState,
5483
+ resolveProjectIndexDaemonAvailability,
5484
+ shutdownCodebaseIndexServer
5485
+ } from "@wrongstack/tools";
5469
5486
 
5470
5487
  // src/server/privileged-actions.ts
5471
5488
  import { randomUUID as randomUUID2 } from "node:crypto";
@@ -5499,15 +5516,6 @@ async function authorizeWebUIAction(boundary, action, logger) {
5499
5516
  }
5500
5517
 
5501
5518
  // src/server/connections-health-route.ts
5502
- import { readGovernanceDaemonOperatorStatus } from "@wrongstack/runtime/governance-bootstrap";
5503
- import { isSageProjectServerAvailable, SageProjectServerConnection } from "@wrongstack/sage";
5504
- import {
5505
- checkCodebaseIndexServerHealth,
5506
- ensureCodebaseIndexServer as ensureCodebaseIndexServer2,
5507
- getIndexState,
5508
- resolveProjectIndexDaemonAvailability,
5509
- shutdownCodebaseIndexServer
5510
- } from "@wrongstack/tools";
5511
5519
  async function handleConnectionsHealthRoute(context, ws, message) {
5512
5520
  if (message.type !== "connections.health") return false;
5513
5521
  try {
@@ -5528,6 +5536,7 @@ async function handleConnectionsHealthRoute(context, ws, message) {
5528
5536
  async function collectConnectionsHealth(options) {
5529
5537
  const services = await Promise.all([
5530
5538
  Promise.resolve(webuiHealth(options.backend)),
5539
+ sessionCatalogHealth(options.projectRoot),
5531
5540
  chronicleHealth(options.projectRoot),
5532
5541
  codebaseIndexHealth(options.projectRoot, options.indexDir),
5533
5542
  sageHealth(options.projectRoot),
@@ -5545,6 +5554,50 @@ async function collectConnectionsHealth(options) {
5545
5554
  services
5546
5555
  };
5547
5556
  }
5557
+ async function sessionCatalogHealth(projectRoot) {
5558
+ const startedAt = Date.now();
5559
+ try {
5560
+ const paths = resolveWstackPaths2({ projectRoot });
5561
+ const client = new SessionCatalogProjectClient({
5562
+ projectDir: paths.projectDir,
5563
+ projectRoot
5564
+ });
5565
+ try {
5566
+ const health = await client.ping();
5567
+ return {
5568
+ id: "session-catalog",
5569
+ label: "Session Catalog",
5570
+ status: health.damagedRows > 0 ? "degraded" : "healthy",
5571
+ required: true,
5572
+ mode: "project-daemon",
5573
+ 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).`,
5574
+ ownerPid: health.pid,
5575
+ endpoint: health.endpoint,
5576
+ storage: health.databasePath,
5577
+ uptimeMs: health.uptimeMs,
5578
+ latencyMs: Date.now() - startedAt,
5579
+ clients: health.clients,
5580
+ activeRequests: health.activeRequests,
5581
+ queuedWork: health.reservations + health.maintenanceLeases,
5582
+ control: "none"
5583
+ };
5584
+ } finally {
5585
+ await client.close().catch(() => void 0);
5586
+ }
5587
+ } catch (error2) {
5588
+ return {
5589
+ id: "session-catalog",
5590
+ label: "Session Catalog",
5591
+ status: "error",
5592
+ required: true,
5593
+ mode: "project-daemon",
5594
+ detail: "Project-scoped session ownership and catalog are unavailable.",
5595
+ latencyMs: Date.now() - startedAt,
5596
+ lastError: error2 instanceof Error ? error2.message : String(error2),
5597
+ control: "none"
5598
+ };
5599
+ }
5600
+ }
5548
5601
  function webuiHealth(backend) {
5549
5602
  return {
5550
5603
  id: "webui",
@@ -6143,7 +6196,11 @@ async function restartSageServer(projectRoot) {
6143
6196
  });
6144
6197
  const verifyConn = new SageProjectServerConnection(projectRoot);
6145
6198
  try {
6146
- await verifyConn.call("ping", {}, { timeoutMs: 1e4, meta: { clientId: `sage-restart-${process.pid}` } });
6199
+ await verifyConn.call(
6200
+ "ping",
6201
+ {},
6202
+ { timeoutMs: 1e4, meta: { clientId: `sage-restart-${process.pid}` } }
6203
+ );
6147
6204
  return {
6148
6205
  serviceId: "sage",
6149
6206
  action: "restart",
@@ -7552,12 +7609,34 @@ function handleTodosGet(ctx, ws) {
7552
7609
  payload: sessionPayload(ctx, { todos: [...ctx.context.todos] })
7553
7610
  });
7554
7611
  }
7555
- function handleTodosClear(ctx, ws) {
7556
- ctx.replaceTodos?.([]);
7557
- sendResult3(ctx, ws, true, "Todos cleared");
7558
- ctx.broadcast({ type: "todos.updated", payload: sessionPayload(ctx, { todos: [] }) });
7612
+ async function commitTodos(ctx, todos) {
7613
+ if (ctx.mutateTodos) {
7614
+ const result = await ctx.mutateTodos(todos);
7615
+ return { todos: result.todos, warnings: result.warnings ?? [] };
7616
+ }
7617
+ ctx.replaceTodos?.(todos);
7618
+ return { todos: [...todos], warnings: [] };
7619
+ }
7620
+ function managedProjectionMessage() {
7621
+ return "Kanban-bound todos are task projections. Change or remove the task from Kanban.";
7559
7622
  }
7560
- function handleTodosRemove(ctx, ws, payload) {
7623
+ async function handleTodosClear(ctx, ws) {
7624
+ if (ctx.context.todos.some((todo) => todo.kanbanBoardId && todo.kanbanTaskId)) {
7625
+ sendResult3(ctx, ws, false, managedProjectionMessage());
7626
+ return;
7627
+ }
7628
+ try {
7629
+ const result = await commitTodos(ctx, []);
7630
+ sendResult3(ctx, ws, true, "Todos cleared");
7631
+ ctx.broadcast({
7632
+ type: "todos.updated",
7633
+ payload: sessionPayload(ctx, { todos: result.todos })
7634
+ });
7635
+ } catch (error2) {
7636
+ sendResult3(ctx, ws, false, error2 instanceof Error ? error2.message : String(error2));
7637
+ }
7638
+ }
7639
+ async function handleTodosRemove(ctx, ws, payload) {
7561
7640
  if (!payload) {
7562
7641
  sendResult3(ctx, ws, false, "Missing id or index");
7563
7642
  return;
@@ -7574,12 +7653,27 @@ function handleTodosRemove(ctx, ws, payload) {
7574
7653
  sendResult3(ctx, ws, false, "Todo not found");
7575
7654
  return;
7576
7655
  }
7656
+ if (removed.kanbanBoardId && removed.kanbanTaskId) {
7657
+ sendResult3(ctx, ws, false, managedProjectionMessage());
7658
+ return;
7659
+ }
7577
7660
  const next = [...todos.slice(0, targetIndex), ...todos.slice(targetIndex + 1)];
7578
- ctx.replaceTodos?.(next);
7579
- sendResult3(ctx, ws, true, `Removed: ${removed.content}`);
7580
- ctx.broadcast({ type: "todos.updated", payload: sessionPayload(ctx, { todos: next }) });
7661
+ try {
7662
+ const result = await commitTodos(ctx, next);
7663
+ sendResult3(ctx, ws, true, `Removed: ${removed.content}`);
7664
+ ctx.broadcast({
7665
+ type: "todos.updated",
7666
+ payload: sessionPayload(ctx, { todos: result.todos })
7667
+ });
7668
+ } catch (error2) {
7669
+ sendResult3(ctx, ws, false, error2 instanceof Error ? error2.message : String(error2));
7670
+ }
7581
7671
  }
7582
- function handleTodoUpdate(ctx, ws, payload) {
7672
+ async function handleTodoUpdate(ctx, ws, payload) {
7673
+ if (!payload || typeof payload.id !== "string" || payload.status !== void 0 && payload.status !== "pending" && payload.status !== "in_progress" && payload.status !== "completed" || payload.activeForm !== void 0 && typeof payload.activeForm !== "string") {
7674
+ sendResult3(ctx, ws, false, "Invalid todo update payload");
7675
+ return;
7676
+ }
7583
7677
  const index = ctx.context.todos.findIndex((todo) => todo.id === payload.id);
7584
7678
  const existing = ctx.context.todos[index];
7585
7679
  if (index === -1 || !existing) {
@@ -7592,9 +7686,25 @@ function handleTodoUpdate(ctx, ws, payload) {
7592
7686
  status: payload.status ?? existing.status,
7593
7687
  activeForm: payload.activeForm !== void 0 ? payload.activeForm : existing.activeForm
7594
7688
  };
7595
- ctx.replaceTodos?.(next);
7596
- sendResult3(ctx, ws, true, `Todo "${existing.content}" updated`);
7597
- ctx.broadcast({ type: "todos.updated", payload: sessionPayload(ctx, { todos: next }) });
7689
+ try {
7690
+ const result = await commitTodos(ctx, next);
7691
+ const projected = result.todos.find((todo) => todo.id === existing.id);
7692
+ const requestedStatus = payload.status ?? existing.status;
7693
+ const projectionRejected = Boolean(existing.kanbanBoardId && existing.kanbanTaskId) && projected?.status !== requestedStatus;
7694
+ const warning = result.warnings[0];
7695
+ sendResult3(
7696
+ ctx,
7697
+ ws,
7698
+ !projectionRejected,
7699
+ projectionRejected ? warning ?? `Kanban kept "${existing.content}" at ${projected?.status ?? "its current state"}.` : warning ? `Todo "${existing.content}" updated. ${warning}` : `Todo "${existing.content}" updated`
7700
+ );
7701
+ ctx.broadcast({
7702
+ type: "todos.updated",
7703
+ payload: sessionPayload(ctx, { todos: result.todos })
7704
+ });
7705
+ } catch (error2) {
7706
+ sendResult3(ctx, ws, false, error2 instanceof Error ? error2.message : String(error2));
7707
+ }
7598
7708
  }
7599
7709
  async function handleTasksGet(ctx, ws) {
7600
7710
  const taskPath = taskPathOf(ctx);
@@ -7622,14 +7732,32 @@ async function handleTaskUpdate(ctx, ws, payload) {
7622
7732
  return;
7623
7733
  }
7624
7734
  try {
7625
- const file = await mutateTasks(taskPath, currentSessionId(ctx), async (tasks) => {
7626
- const task = tasks.tasks.find((candidate) => candidate.id === payload.id);
7627
- if (!task) return tasks;
7628
- task.status = payload.status;
7629
- task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7630
- return tasks;
7631
- });
7632
- sendResult3(ctx, ws, true, `Task status updated to "${payload.status}".`);
7735
+ let file;
7736
+ if (ctx.mutateTaskStatus) {
7737
+ const result = await ctx.mutateTaskStatus(payload.id, payload.status);
7738
+ if (!result.ok) {
7739
+ sendResult3(ctx, ws, false, result.message);
7740
+ return;
7741
+ }
7742
+ file = await loadTasks(taskPath);
7743
+ if (!file) throw new Error("Task mutation succeeded but its persisted snapshot is missing.");
7744
+ sendResult3(ctx, ws, true, result.message);
7745
+ } else {
7746
+ let matched = false;
7747
+ file = await mutateTasks(taskPath, currentSessionId(ctx), async (tasks) => {
7748
+ const task = tasks.tasks.find((candidate) => candidate.id === payload.id);
7749
+ if (!task) return tasks;
7750
+ matched = true;
7751
+ task.status = payload.status;
7752
+ task.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7753
+ return tasks;
7754
+ });
7755
+ if (!matched) {
7756
+ sendResult3(ctx, ws, false, `Task "${payload.id}" not found.`);
7757
+ return;
7758
+ }
7759
+ sendResult3(ctx, ws, true, `Task status updated to "${payload.status}".`);
7760
+ }
7633
7761
  ctx.broadcast({
7634
7762
  type: "tasks.updated",
7635
7763
  payload: sessionPayload(ctx, { tasks: file.tasks })
@@ -7676,6 +7804,18 @@ async function handlePlanTemplateUse(ctx, ws, template) {
7676
7804
  return;
7677
7805
  }
7678
7806
  try {
7807
+ if (ctx.mutatePlan) {
7808
+ const result = await ctx.mutatePlan({ action: "template_use", template });
7809
+ if (!result.ok) {
7810
+ sendResult3(ctx, ws, false, result.message);
7811
+ return;
7812
+ }
7813
+ const plan2 = await loadPlan(planPath);
7814
+ if (!plan2) throw new Error("Plan mutation succeeded but its persisted snapshot is missing.");
7815
+ sendResult3(ctx, ws, true, result.message);
7816
+ ctx.broadcast({ type: "plan.updated", payload: sessionPayload(ctx, { plan: plan2 }) });
7817
+ return;
7818
+ }
7679
7819
  const templateDefinition = getPlanTemplate(template);
7680
7820
  if (!templateDefinition) {
7681
7821
  sendResult3(ctx, ws, false, `Unknown template "${template}".`);
@@ -7704,6 +7844,22 @@ async function handlePlanItemUpdate(ctx, ws, payload) {
7704
7844
  return;
7705
7845
  }
7706
7846
  try {
7847
+ if (ctx.mutatePlan) {
7848
+ const result = await ctx.mutatePlan({
7849
+ action: "status",
7850
+ target: payload.target,
7851
+ status: payload.status
7852
+ });
7853
+ if (!result.ok) {
7854
+ sendResult3(ctx, ws, false, result.message);
7855
+ return;
7856
+ }
7857
+ const plan2 = await loadPlan(planPath);
7858
+ if (!plan2) throw new Error("Plan mutation succeeded but its persisted snapshot is missing.");
7859
+ sendResult3(ctx, ws, true, result.message);
7860
+ ctx.broadcast({ type: "plan.updated", payload: sessionPayload(ctx, { plan: plan2 }) });
7861
+ return;
7862
+ }
7707
7863
  let changed = false;
7708
7864
  const plan = await mutatePlan(planPath, currentSessionId(ctx), async (currentPlan) => {
7709
7865
  const before = currentPlan.updatedAt;
@@ -7727,13 +7883,17 @@ async function handleWorklistMessage(ctx, ws, message) {
7727
7883
  handleTodosGet(ctx, ws);
7728
7884
  return;
7729
7885
  case "todos.clear":
7730
- handleTodosClear(ctx, ws);
7886
+ await handleTodosClear(ctx, ws);
7731
7887
  return;
7732
7888
  case "todos.remove":
7733
- handleTodosRemove(ctx, ws, message.payload);
7889
+ await handleTodosRemove(
7890
+ ctx,
7891
+ ws,
7892
+ message.payload
7893
+ );
7734
7894
  return;
7735
7895
  case "todo.update":
7736
- handleTodoUpdate(
7896
+ await handleTodoUpdate(
7737
7897
  ctx,
7738
7898
  ws,
7739
7899
  message.payload
@@ -8154,8 +8314,8 @@ async function handleApiSessions(res, globalRoot) {
8154
8314
  return;
8155
8315
  }
8156
8316
  try {
8157
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8158
- const registry = new SessionRegistry(globalRoot);
8317
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8318
+ const registry = getSessionRegistry3(globalRoot);
8159
8319
  const sessions = await registry.list();
8160
8320
  const result = sessions.map((s) => ({
8161
8321
  sessionId: s.sessionId,
@@ -8192,8 +8352,8 @@ async function handleApiSessionAgents(res, globalRoot, sessionId) {
8192
8352
  return;
8193
8353
  }
8194
8354
  try {
8195
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8196
- const registry = new SessionRegistry(globalRoot);
8355
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8356
+ const registry = getSessionRegistry3(globalRoot);
8197
8357
  const entry = await registry.get(sessionId);
8198
8358
  if (!entry) {
8199
8359
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -8201,20 +8361,22 @@ async function handleApiSessionAgents(res, globalRoot, sessionId) {
8201
8361
  return;
8202
8362
  }
8203
8363
  res.writeHead(200, { "Content-Type": "application/json" });
8204
- res.end(JSON.stringify({
8205
- sessionId: entry.sessionId,
8206
- projectName: entry.projectName,
8207
- status: entry.status,
8208
- agents: entry.agents.map((a) => ({
8209
- id: a.id,
8210
- name: a.name,
8211
- status: a.status,
8212
- currentTool: a.currentTool,
8213
- iterations: a.iterations,
8214
- toolCalls: a.toolCalls,
8215
- lastActivityAt: a.lastActivityAt
8216
- }))
8217
- }));
8364
+ res.end(
8365
+ JSON.stringify({
8366
+ sessionId: entry.sessionId,
8367
+ projectName: entry.projectName,
8368
+ status: entry.status,
8369
+ agents: entry.agents.map((a) => ({
8370
+ id: a.id,
8371
+ name: a.name,
8372
+ status: a.status,
8373
+ currentTool: a.currentTool,
8374
+ iterations: a.iterations,
8375
+ toolCalls: a.toolCalls,
8376
+ lastActivityAt: a.lastActivityAt
8377
+ }))
8378
+ })
8379
+ );
8218
8380
  } catch (err) {
8219
8381
  res.writeHead(500, { "Content-Type": "application/json" });
8220
8382
  res.end(JSON.stringify({ error: sanitizeApiError(err) }));
@@ -8332,9 +8494,9 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8332
8494
  return;
8333
8495
  }
8334
8496
  try {
8335
- const { SessionRegistry, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core/storage");
8497
+ const { getSessionRegistry: getSessionRegistry3, DefaultSessionStore: DefaultSessionStore3, DefaultSessionReader: DefaultSessionReader2 } = await import("@wrongstack/core/storage");
8336
8498
  const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8337
- const registry = new SessionRegistry(globalRoot);
8499
+ const registry = getSessionRegistry3(globalRoot);
8338
8500
  const entry = await registry.get(sessionId);
8339
8501
  if (!entry) {
8340
8502
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -8342,7 +8504,10 @@ async function handleApiSessionEvents(res, globalRoot, sessionId, limit) {
8342
8504
  return;
8343
8505
  }
8344
8506
  const paths = resolveWstackPaths7({ projectRoot: entry.projectRoot, globalRoot });
8345
- const store = new DefaultSessionStore3({ dir: paths.projectSessions });
8507
+ const store = new DefaultSessionStore3({
8508
+ dir: paths.projectSessions,
8509
+ projectRoot: entry.projectRoot
8510
+ });
8346
8511
  const reader = new DefaultSessionReader2({ store });
8347
8512
  const RING = Math.max(limit * 4, 2e3);
8348
8513
  const ring = [];
@@ -8434,10 +8599,10 @@ async function handleApiSessionMessage(res, req, globalRoot, sessionId) {
8434
8599
  const priority = ["low", "normal", "high"].includes(rawPriority) ? rawPriority : "high";
8435
8600
  const subject = typeof body["subject"] === "string" && body["subject"].trim() ? body["subject"].trim() : "Message from Fleet HQ";
8436
8601
  try {
8437
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8602
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8438
8603
  const { getSharedProjectMailbox: getSharedProjectMailbox5, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8439
8604
  const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8440
- const registry = new SessionRegistry(globalRoot);
8605
+ const registry = getSessionRegistry3(globalRoot);
8441
8606
  const entry = await registry.get(sessionId);
8442
8607
  if (!entry) {
8443
8608
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -8462,10 +8627,10 @@ async function handleApiSessionMailbox(res, globalRoot, sessionId) {
8462
8627
  return;
8463
8628
  }
8464
8629
  try {
8465
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8630
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8466
8631
  const { getSharedProjectMailbox: getSharedProjectMailbox5, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8467
8632
  const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8468
- const registry = new SessionRegistry(globalRoot);
8633
+ const registry = getSessionRegistry3(globalRoot);
8469
8634
  const entry = await registry.get(sessionId);
8470
8635
  if (!entry) {
8471
8636
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -8522,10 +8687,10 @@ async function handleApiSessionInterrupt(res, req, globalRoot, sessionId) {
8522
8687
  const reason = typeof body["reason"] === "string" && body["reason"].trim() ? body["reason"].trim() : "Operator requested stop from Fleet HQ";
8523
8688
  const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
8524
8689
  try {
8525
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8690
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8526
8691
  const { getSharedProjectMailbox: getSharedProjectMailbox5, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8527
8692
  const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8528
- const registry = new SessionRegistry(globalRoot);
8693
+ const registry = getSessionRegistry3(globalRoot);
8529
8694
  const entry = await registry.get(sessionId);
8530
8695
  if (!entry) {
8531
8696
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -8571,10 +8736,10 @@ async function handleApiFleetBroadcast(res, req, globalRoot) {
8571
8736
  }
8572
8737
  const from = typeof body["from"] === "string" && body["from"].trim() ? body["from"].trim() : "human@webui";
8573
8738
  try {
8574
- const { SessionRegistry } = await import("@wrongstack/core/storage");
8739
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
8575
8740
  const { getSharedProjectMailbox: getSharedProjectMailbox5, mailboxSessionTag: mailboxSessionTag2 } = await import("@wrongstack/core/coordination");
8576
8741
  const { resolveWstackPaths: resolveWstackPaths7 } = await import("@wrongstack/core/utils");
8577
- const registry = new SessionRegistry(globalRoot);
8742
+ const registry = getSessionRegistry3(globalRoot);
8578
8743
  const all = await registry.list();
8579
8744
  const mySlug = all.find((s) => s.pid === process.pid)?.projectSlug;
8580
8745
  const targets = all.filter((s) => s.status !== "stale").filter((s) => mySlug ? s.projectSlug === mySlug : true);
@@ -10434,11 +10599,11 @@ function kanbanListMessage(boards) {
10434
10599
  function kanbanDeletedMessage(boardId) {
10435
10600
  return { type: "kanban.delete", payload: { success: true, data: { removed: true, boardId } } };
10436
10601
  }
10437
- async function publishKanbanBoard(broadcast2, board, listBoards4) {
10602
+ async function publishKanbanBoard(broadcast2, board, listBoards5) {
10438
10603
  broadcast2(kanbanBoardMessage(board));
10439
- if (!listBoards4) return;
10604
+ if (!listBoards5) return;
10440
10605
  try {
10441
- broadcast2(kanbanListMessage(await listBoards4()));
10606
+ broadcast2(kanbanListMessage(await listBoards5()));
10442
10607
  } catch {
10443
10608
  }
10444
10609
  }
@@ -10689,9 +10854,9 @@ import {
10689
10854
  claimReadyTask,
10690
10855
  copyTaskToBoard,
10691
10856
  createBoard,
10857
+ createBoardFromText,
10692
10858
  duplicateBoard,
10693
10859
  exportBoardToTaskGraph,
10694
- createBoardFromText,
10695
10860
  getBoard,
10696
10861
  getKanbanOrchestrationSnapshot,
10697
10862
  getKanbanQueueHealth,
@@ -10879,8 +11044,35 @@ async function handleTaskVerification(ws, type, payload, ctx) {
10879
11044
  }
10880
11045
  }
10881
11046
 
11047
+ // src/server/kanban-route-pagination.ts
11048
+ function paginateKanbanBoards(boards, input) {
11049
+ const pageSize = Math.min(100, Math.max(1, Math.floor(input.pageSize)));
11050
+ const activeSessionIds = new Set(input.activeSessionIds ?? []);
11051
+ const isActive = (board) => board.presence?.some((entry) => entry.active) === true || board.tags?.some((tag) => tag.startsWith("session:") && activeSessionIds.has(tag.slice(8))) === true;
11052
+ const sorted = [...boards].sort((left, right) => {
11053
+ const activityOrder = Number(isActive(right)) - Number(isActive(left));
11054
+ return activityOrder || right.updatedAt.localeCompare(left.updatedAt);
11055
+ });
11056
+ const activeTotal = sorted.filter(isActive).length;
11057
+ const total = sorted.length;
11058
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
11059
+ const requestedPage = Number.isFinite(input.page) ? Math.floor(input.page) : 1;
11060
+ const page = Math.min(totalPages, Math.max(1, requestedPage));
11061
+ const start = (page - 1) * pageSize;
11062
+ return {
11063
+ items: sorted.slice(start, start + pageSize),
11064
+ total,
11065
+ page,
11066
+ pageSize,
11067
+ totalPages,
11068
+ activeTotal,
11069
+ orphanedTotal: total - activeTotal
11070
+ };
11071
+ }
11072
+
10882
11073
  // src/server/kanban-task-routes.ts
10883
11074
  import {
11075
+ getKanbanWorkbench,
10884
11076
  getTask,
10885
11077
  listTaskActivity,
10886
11078
  recordTaskActivity,
@@ -10888,6 +11080,16 @@ import {
10888
11080
  } from "@wrongstack/kanban";
10889
11081
  async function handleKanbanTaskRoute(ws, type, payload, ctx) {
10890
11082
  switch (type) {
11083
+ case "kanban.workbench":
11084
+ ok(
11085
+ ws,
11086
+ type,
11087
+ await getKanbanWorkbench(ctx.projectRoot, {
11088
+ ...typeof payload?.limitPerLane === "number" ? { limitPerLane: payload.limitPerLane } : {},
11089
+ ...typeof payload?.alertLimit === "number" ? { alertLimit: payload.alertLimit } : {}
11090
+ })
11091
+ );
11092
+ return true;
10891
11093
  case "kanban.task.remove":
10892
11094
  await handleTaskRemove(ws, type, payload, ctx);
10893
11095
  return true;
@@ -10980,40 +11182,11 @@ async function handleTaskActivityAdd(ws, type, payload, ctx) {
10980
11182
  outcome,
10981
11183
  ...typeof payload?.details === "string" && payload.details.trim() ? { details: payload.details.trim() } : {}
10982
11184
  },
10983
- activityContext(
10984
- ctx,
10985
- payload?.actor ?? ctx.context?.agentId ?? "webui"
10986
- )
11185
+ activityContext(ctx, payload?.actor ?? ctx.context?.agentId ?? "webui")
10987
11186
  );
10988
11187
  board ? ok(ws, type, board) : fail(ws, type, "Board or task not found");
10989
11188
  }
10990
11189
 
10991
- // src/server/kanban-route-pagination.ts
10992
- function paginateKanbanBoards(boards, input) {
10993
- const pageSize = Math.min(100, Math.max(1, Math.floor(input.pageSize)));
10994
- const activeSessionIds = new Set(input.activeSessionIds ?? []);
10995
- const isActive = (board) => board.presence?.some((entry) => entry.active) === true || board.tags?.some((tag) => tag.startsWith("session:") && activeSessionIds.has(tag.slice(8))) === true;
10996
- const sorted = [...boards].sort((left, right) => {
10997
- const activityOrder = Number(isActive(right)) - Number(isActive(left));
10998
- return activityOrder || right.updatedAt.localeCompare(left.updatedAt);
10999
- });
11000
- const activeTotal = sorted.filter(isActive).length;
11001
- const total = sorted.length;
11002
- const totalPages = Math.max(1, Math.ceil(total / pageSize));
11003
- const requestedPage = Number.isFinite(input.page) ? Math.floor(input.page) : 1;
11004
- const page = Math.min(totalPages, Math.max(1, requestedPage));
11005
- const start = (page - 1) * pageSize;
11006
- return {
11007
- items: sorted.slice(start, start + pageSize),
11008
- total,
11009
- page,
11010
- pageSize,
11011
- totalPages,
11012
- activeTotal,
11013
- orphanedTotal: total - activeTotal
11014
- };
11015
- }
11016
-
11017
11190
  // src/server/kanban-routes.ts
11018
11191
  async function handleKanbanRoute(ws, msg, ctx) {
11019
11192
  if (!msg.type.startsWith("kanban.")) return false;
@@ -11074,6 +11247,14 @@ async function handleKanbanRoute(ws, msg, ctx) {
11074
11247
  fail(ws, type, `Board not found: ${boardId}`);
11075
11248
  return true;
11076
11249
  }
11250
+ if (ctx.supervisor) {
11251
+ const snapshots = await ctx.supervisor.auditNow(boardId);
11252
+ const snapshot = snapshots[0];
11253
+ if (snapshot) {
11254
+ ok(ws, type, snapshot);
11255
+ return true;
11256
+ }
11257
+ }
11077
11258
  const reconciled = await reconcileKanbanBoard(ctx.projectRoot, boardId);
11078
11259
  let health = await getKanbanQueueHealth(ctx.projectRoot, { boardId });
11079
11260
  const recovered = health.staleAssignments.count ? await recoverStaleTaskAssignments(ctx.projectRoot, boardId, {
@@ -11784,7 +11965,9 @@ function subscribeKanbanDaemonEvents(projectRoot, broadcastMessage) {
11784
11965
  projectRoot,
11785
11966
  async (event) => {
11786
11967
  const family = event.event?.split(".")[0];
11787
- if (family !== "board" && family !== "task" && family !== "column") return;
11968
+ if (family !== "board" && family !== "task" && family !== "column" && family !== "contract") {
11969
+ return;
11970
+ }
11788
11971
  const evData = event.data;
11789
11972
  const boardId = evData?.boardId;
11790
11973
  if (!boardId) return;
@@ -11833,6 +12016,15 @@ function createShutdown(res) {
11833
12016
  } catch {
11834
12017
  }
11835
12018
  }
12019
+ if (res.onPreShutdown) {
12020
+ try {
12021
+ await res.onPreShutdown();
12022
+ } catch (e) {
12023
+ log(
12024
+ `[WebUI] Error during pre-shutdown cleanup: ${e instanceof Error ? e.message : String(e)}`
12025
+ );
12026
+ }
12027
+ }
11836
12028
  for (const server of res.servers) server?.close();
11837
12029
  if (res.onShutdown) {
11838
12030
  try {
@@ -13087,14 +13279,17 @@ async function handleSageForFile(ws, msg, memoryStore) {
13087
13279
  send(ws, { type: "memory.sage.forFile", payload: { error: "filePath is required" } });
13088
13280
  return;
13089
13281
  }
13282
+ const includeSuperseded = typeof payload["showSuperseded"] === "boolean" ? payload["showSuperseded"] : typeof payload["includeSuperseded"] === "boolean" ? payload["includeSuperseded"] : void 0;
13283
+ const includeDeleted = payload["showDeleted"] === true || payload["includeDeleted"] === true;
13090
13284
  try {
13091
13285
  const response = await Sage.findMemoriesForFile(filePath, {
13092
13286
  ...typeof payload["lineStart"] === "number" ? { lineStart: payload["lineStart"] } : {},
13093
13287
  ...typeof payload["lineEnd"] === "number" ? { lineEnd: payload["lineEnd"] } : {},
13094
13288
  ...typeof payload["limit"] === "number" ? { limit: payload["limit"] } : {},
13095
- ...payload["includeDeleted"] === true ? { includeDeleted: true } : {}
13289
+ ...includeSuperseded !== void 0 ? { includeSuperseded } : {},
13290
+ ...includeDeleted ? { includeDeleted: true } : {}
13096
13291
  });
13097
- send(ws, { type: "memory.sage.forFile", payload: response });
13292
+ send(ws, { type: "memory.sage.forFile", payload: { response } });
13098
13293
  } catch (err) {
13099
13294
  send(ws, { type: "memory.sage.forFile", payload: { error: errMessage(err) } });
13100
13295
  }
@@ -13747,6 +13942,324 @@ async function handleBrainAsk(ctx, ws, question) {
13747
13942
  }
13748
13943
  }
13749
13944
 
13945
+ // src/server/kanban-supervisor.ts
13946
+ import {
13947
+ finalizeTaskCompletion,
13948
+ getBoard as getBoard2,
13949
+ getKanbanQueueHealth as getKanbanQueueHealth2,
13950
+ listBoards as listBoards3,
13951
+ reconcileKanbanBoard as reconcileKanbanBoard2,
13952
+ recoverStaleTaskAssignments as recoverStaleTaskAssignments2,
13953
+ resolveGateEnforcement
13954
+ } from "@wrongstack/kanban";
13955
+ function resolveProjectRoot(deps2) {
13956
+ const root = deps2.projectRoot;
13957
+ return typeof root === "function" ? root() : root;
13958
+ }
13959
+ var DEFAULT_INTERVAL_MS = 1e4;
13960
+ var MIN_INTERVAL_MS = 2e3;
13961
+ var DEFAULT_AGENT_COOLDOWN_MS = 5 * 6e4;
13962
+ var DEFAULT_CONFIG = {
13963
+ enabled: true,
13964
+ mode: "deterministic",
13965
+ intervalMs: DEFAULT_INTERVAL_MS,
13966
+ recoveryMode: "auto"
13967
+ };
13968
+ function createKanbanSupervisor(deps2) {
13969
+ const snapshots = /* @__PURE__ */ new Map();
13970
+ const nextDue = /* @__PURE__ */ new Map();
13971
+ const agentLastRun = /* @__PURE__ */ new Map();
13972
+ const agentRunning = /* @__PURE__ */ new Set();
13973
+ let disposed = false;
13974
+ let nextTimer;
13975
+ const forgetBoard = (boardId) => {
13976
+ snapshots.delete(boardId);
13977
+ nextDue.delete(boardId);
13978
+ agentLastRun.delete(boardId);
13979
+ agentRunning.delete(boardId);
13980
+ };
13981
+ const pruneAbsentBoards = (presentBoardIds) => {
13982
+ for (const boardId of snapshots.keys()) {
13983
+ if (!presentBoardIds.has(boardId)) forgetBoard(boardId);
13984
+ }
13985
+ for (const boardId of nextDue.keys()) {
13986
+ if (!presentBoardIds.has(boardId)) forgetBoard(boardId);
13987
+ }
13988
+ for (const boardId of agentLastRun.keys()) {
13989
+ if (!presentBoardIds.has(boardId)) forgetBoard(boardId);
13990
+ }
13991
+ for (const boardId of agentRunning) {
13992
+ if (!presentBoardIds.has(boardId)) forgetBoard(boardId);
13993
+ }
13994
+ };
13995
+ const publish = (snapshot) => {
13996
+ snapshots.set(snapshot.boardId, snapshot);
13997
+ deps2.broadcast({
13998
+ type: "kanban.supervisor.status",
13999
+ payload: { success: true, data: snapshot }
14000
+ });
14001
+ };
14002
+ const auditBoard = async (board) => {
14003
+ const config = effectiveConfig(board);
14004
+ const auditedAt = (/* @__PURE__ */ new Date()).toISOString();
14005
+ const intervalMs = Math.max(MIN_INTERVAL_MS, config.intervalMs ?? DEFAULT_INTERVAL_MS);
14006
+ const nextAuditAt = new Date(Date.now() + intervalMs).toISOString();
14007
+ nextDue.set(board.id, Date.now() + intervalMs);
14008
+ if (!config.enabled) {
14009
+ const snapshot2 = {
14010
+ boardId: board.id,
14011
+ status: "disabled",
14012
+ mode: config.mode,
14013
+ lastAuditAt: auditedAt,
14014
+ nextAuditAt,
14015
+ reconciledTaskIds: [],
14016
+ staleRecoveredTaskIds: [],
14017
+ anomalyCount: 0,
14018
+ summary: "Supervision is disabled for this board."
14019
+ };
14020
+ publish(snapshot2);
14021
+ return snapshot2;
14022
+ }
14023
+ const reconciled = await reconcileKanbanBoard2(resolveProjectRoot(deps2), board.id);
14024
+ const gateSwept = await sweepGateParkedTasks(deps2, reconciled?.board ?? board);
14025
+ let health = await getKanbanQueueHealth2(resolveProjectRoot(deps2), { boardId: board.id });
14026
+ const recovered = health.staleAssignments.count ? await recoverStaleTaskAssignments2(resolveProjectRoot(deps2), board.id, {
14027
+ mode: config.recoveryMode ?? "auto",
14028
+ reason: "Kanban supervisor found an expired worker lease."
14029
+ }) : null;
14030
+ if (recovered)
14031
+ health = await getKanbanQueueHealth2(resolveProjectRoot(deps2), { boardId: board.id });
14032
+ const anomalyCount = countAnomalies(health);
14033
+ const snapshot = {
14034
+ boardId: board.id,
14035
+ status: anomalyCount > 0 ? "attention" : "healthy",
14036
+ mode: config.mode,
14037
+ lastAuditAt: auditedAt,
14038
+ nextAuditAt,
14039
+ reconciledTaskIds: reconciled?.tasks.map((task) => task.id) ?? [],
14040
+ staleRecoveredTaskIds: recovered?.tasks.map((task) => task.id) ?? [],
14041
+ anomalyCount,
14042
+ summary: healthSummary(health)
14043
+ };
14044
+ publish(snapshot);
14045
+ const changedBoard = recovered?.board ?? gateSwept ?? reconciled?.board;
14046
+ if (changedBoard) {
14047
+ await publishKanbanBoard(
14048
+ deps2.broadcast,
14049
+ changedBoard,
14050
+ () => listBoards3(resolveProjectRoot(deps2))
14051
+ );
14052
+ }
14053
+ if (config.mode === "agentic" && anomalyCount > 0) {
14054
+ await maybeRunAgent(board, config, health, snapshot);
14055
+ }
14056
+ return snapshots.get(board.id) ?? snapshot;
14057
+ };
14058
+ const maybeRunAgent = async (board, config, health, snapshot) => {
14059
+ if (!deps2.dispatchTask || agentRunning.has(board.id)) return;
14060
+ const cooldownMs = Math.max(
14061
+ MIN_INTERVAL_MS,
14062
+ config.agentCooldownMs ?? DEFAULT_AGENT_COOLDOWN_MS
14063
+ );
14064
+ if (Date.now() - (agentLastRun.get(board.id) ?? 0) < cooldownMs) return;
14065
+ agentRunning.add(board.id);
14066
+ agentLastRun.set(board.id, Date.now());
14067
+ const watchdog = setTimeout(
14068
+ () => agentRunning.delete(board.id),
14069
+ Math.max(cooldownMs * 2, DEFAULT_AGENT_COOLDOWN_MS * 2)
14070
+ );
14071
+ watchdog.unref?.();
14072
+ publish({
14073
+ ...snapshot,
14074
+ status: "running",
14075
+ lastAgentRunAt: (/* @__PURE__ */ new Date()).toISOString(),
14076
+ summary: `Agentic anomaly review started. ${snapshot.summary ?? ""}`.trim()
14077
+ });
14078
+ const routing = config.routing ?? { mode: "session" };
14079
+ try {
14080
+ const spawnSummary = await deps2.dispatchTask(buildAuditPrompt(board, health), {
14081
+ ...dispatchRoute(routing),
14082
+ ...config.skills?.length ? { skills: config.skills } : {},
14083
+ name: `kanban-supervisor-${board.id.slice(0, 6)}`,
14084
+ // Carry the board identity into the spawned TaskSpec.context so the
14085
+ // tool-runtime boundary gate (`evaluateToolKanbanBoundary`) can resolve
14086
+ // the live board policy instead of failing open. Whole-board agentic
14087
+ // runs have no taskId, so only boardId is propagated.
14088
+ context: { kanban: { boardId: board.id, projectRoot: resolveProjectRoot(deps2) } },
14089
+ onDone: async (result) => {
14090
+ clearTimeout(watchdog);
14091
+ agentRunning.delete(board.id);
14092
+ if (await getBoard2(resolveProjectRoot(deps2), board.id) === null) return;
14093
+ const current3 = snapshots.get(board.id) ?? snapshot;
14094
+ publish({
14095
+ ...current3,
14096
+ status: result.status === "failed" ? "error" : current3.anomalyCount ? "attention" : "healthy",
14097
+ lastAgentRunAt: (/* @__PURE__ */ new Date()).toISOString(),
14098
+ summary: result.result ?? current3.summary,
14099
+ ...result.error ? { error: result.error } : {}
14100
+ });
14101
+ }
14102
+ });
14103
+ const current2 = snapshots.get(board.id) ?? snapshot;
14104
+ publish({ ...current2, status: "running", summary: spawnSummary });
14105
+ } catch (error2) {
14106
+ clearTimeout(watchdog);
14107
+ agentRunning.delete(board.id);
14108
+ const message = error2 instanceof Error ? error2.message : String(error2);
14109
+ deps2.log?.(`[KanbanSupervisor] ${board.id}: ${message}`);
14110
+ publish({ ...snapshot, status: "error", error: message });
14111
+ }
14112
+ };
14113
+ const auditNow = async (boardId) => {
14114
+ let boards;
14115
+ if (boardId) {
14116
+ const board = await getBoard2(resolveProjectRoot(deps2), boardId);
14117
+ if (board === null) {
14118
+ forgetBoard(boardId);
14119
+ boards = [];
14120
+ } else {
14121
+ boards = [board];
14122
+ }
14123
+ } else {
14124
+ const summaries = await listBoards3(resolveProjectRoot(deps2));
14125
+ pruneAbsentBoards(new Set(summaries.map((summary) => summary.id)));
14126
+ boards = (await Promise.all(
14127
+ summaries.map((summary) => getBoard2(resolveProjectRoot(deps2), summary.id))
14128
+ )).filter((board) => Boolean(board));
14129
+ }
14130
+ const results = [];
14131
+ for (const board of boards) results.push(await auditBoard(board));
14132
+ scheduleNext();
14133
+ return results;
14134
+ };
14135
+ const scheduleNext = () => {
14136
+ if (disposed) return;
14137
+ if (nextTimer !== void 0) {
14138
+ clearTimeout(nextTimer);
14139
+ nextTimer = void 0;
14140
+ }
14141
+ const now = Date.now();
14142
+ let minDue = Infinity;
14143
+ for (const due of nextDue.values()) {
14144
+ if (due < minDue) minDue = due;
14145
+ }
14146
+ if (!Number.isFinite(minDue) || minDue <= now) {
14147
+ nextTimer = setTimeout(() => void tick(), MIN_INTERVAL_MS);
14148
+ nextTimer.unref?.();
14149
+ return;
14150
+ }
14151
+ const delay = Math.min(minDue - now, DEFAULT_INTERVAL_MS);
14152
+ if (delay <= 0) {
14153
+ nextTimer = setTimeout(() => void tick(), MIN_INTERVAL_MS);
14154
+ } else {
14155
+ nextTimer = setTimeout(() => void tick(), delay);
14156
+ }
14157
+ nextTimer.unref?.();
14158
+ };
14159
+ const tick = async () => {
14160
+ if (disposed) return;
14161
+ try {
14162
+ const now = Date.now();
14163
+ const summaries = await listBoards3(resolveProjectRoot(deps2));
14164
+ pruneAbsentBoards(new Set(summaries.map((summary) => summary.id)));
14165
+ for (const summary of summaries) {
14166
+ if ((nextDue.get(summary.id) ?? 0) > now) continue;
14167
+ const board = await getBoard2(resolveProjectRoot(deps2), summary.id);
14168
+ if (board) await auditBoard(board);
14169
+ }
14170
+ } catch (error2) {
14171
+ deps2.log?.(`[KanbanSupervisor] ${error2 instanceof Error ? error2.message : String(error2)}`);
14172
+ } finally {
14173
+ scheduleNext();
14174
+ }
14175
+ };
14176
+ void scheduleNext();
14177
+ return {
14178
+ getSnapshot: (boardId) => snapshots.get(boardId),
14179
+ auditNow,
14180
+ getStats: () => ({
14181
+ snapshots: snapshots.size,
14182
+ scheduledBoards: nextDue.size,
14183
+ agentCooldowns: agentLastRun.size,
14184
+ runningAgents: agentRunning.size
14185
+ }),
14186
+ dispose() {
14187
+ disposed = true;
14188
+ if (nextTimer !== void 0) {
14189
+ clearTimeout(nextTimer);
14190
+ nextTimer = void 0;
14191
+ }
14192
+ snapshots.clear();
14193
+ nextDue.clear();
14194
+ agentLastRun.clear();
14195
+ agentRunning.clear();
14196
+ }
14197
+ };
14198
+ }
14199
+ function effectiveConfig(board) {
14200
+ return { ...DEFAULT_CONFIG, ...board.supervisor ?? {} };
14201
+ }
14202
+ async function sweepGateParkedTasks(deps2, board) {
14203
+ if (resolveGateEnforcement(board) === "off") return void 0;
14204
+ const parked = board.tasks.filter(
14205
+ (task) => task.status === "review" && task.assignment?.status === "completed" && !task.verificationReport
14206
+ );
14207
+ let lastBoard;
14208
+ for (const task of parked) {
14209
+ try {
14210
+ const finalized = await finalizeTaskCompletion(resolveProjectRoot(deps2), board.id, task.id, {
14211
+ eventContext: { actor: "kanban-supervisor" }
14212
+ });
14213
+ if (finalized) lastBoard = finalized.board;
14214
+ } catch (error2) {
14215
+ deps2.log?.(
14216
+ `[KanbanSupervisor] completion gate sweep failed for ${task.id}: ${error2 instanceof Error ? error2.message : String(error2)}`
14217
+ );
14218
+ }
14219
+ }
14220
+ return lastBoard;
14221
+ }
14222
+ function dispatchRoute(routing) {
14223
+ if (routing.mode === "session") return {};
14224
+ return {
14225
+ ...routing.provider ? { provider: routing.provider } : {},
14226
+ ...routing.model ? { model: routing.model } : {},
14227
+ ...routing.fallbackProfile ? { fallbackProfile: routing.fallbackProfile } : {},
14228
+ ...routing.fallbackModels?.length ? { fallbackModels: routing.fallbackModels } : {}
14229
+ };
14230
+ }
14231
+ function countAnomalies(health) {
14232
+ return health.staleAssignments.count + health.heartbeatDue.count + health.counts.failed + health.counts.blocked;
14233
+ }
14234
+ function healthSummary(health) {
14235
+ return [
14236
+ `${health.counts.running} running`,
14237
+ `${health.counts.ready} ready`,
14238
+ `${health.counts.review} review`,
14239
+ `${health.counts.blocked} blocked`,
14240
+ `${health.counts.failed} failed`,
14241
+ `${health.staleAssignments.count} stale`,
14242
+ `${health.dependencyBlocked.count} dependency-blocked`
14243
+ ].join(" \xB7 ");
14244
+ }
14245
+ function buildAuditPrompt(board, health) {
14246
+ const taskLines = board.tasks.map(
14247
+ (task) => `- ${task.id}: ${task.title} [task=${task.status}; assignment=${task.assignment?.status ?? "none"}; column=${task.columnId}]`
14248
+ );
14249
+ return [
14250
+ "You are the explicitly configured WrongStack Kanban supervisor.",
14251
+ "Audit only this board. Do not implement product tasks.",
14252
+ `Board: ${board.title} (${board.id})`,
14253
+ `Health: ${healthSummary(health)}`,
14254
+ "",
14255
+ "Tasks:",
14256
+ ...taskLines,
14257
+ "",
14258
+ "Use the kanban tool for corrections. Preserve manual blockers and dependencies.",
14259
+ "Fix only demonstrable status/assignment/column drift, then report every action and remaining anomaly."
14260
+ ].join("\n");
14261
+ }
14262
+
13750
14263
  // src/server/context-meta.ts
13751
14264
  import { FallbackProfileManager } from "@wrongstack/core/agent";
13752
14265
  import { resolvePluginEnablement } from "@wrongstack/core/plugin";
@@ -16222,6 +16735,8 @@ function labelForEvent(e) {
16222
16735
  const count = e.messagesOmitted ?? e.messages.length;
16223
16736
  return `Messages replaced (${e.messagesOmitted ? "~" : ""}${count} msgs)`;
16224
16737
  }
16738
+ case "messages_dropped":
16739
+ return `Oldest ${e.count} message${e.count === 1 ? "" : "s"} evicted`;
16225
16740
  case "message_truncated":
16226
16741
  return `Message truncated: ${e.before} \u2192 ${e.after}`;
16227
16742
  case "file_event":
@@ -16309,6 +16824,8 @@ function detailForEvent(e) {
16309
16824
  return `at index ${e.index}`;
16310
16825
  case "messages_replaced":
16311
16826
  return `${e.messagesOmitted ?? e.messages.length} total`;
16827
+ case "messages_dropped":
16828
+ return `dropped ${e.count} from the front`;
16312
16829
  case "message_truncated":
16313
16830
  return `truncated to ${e.after} tokens`;
16314
16831
  case "mode_changed":
@@ -16526,7 +17043,7 @@ function createSessionHandlers(ctx) {
16526
17043
  const current2 = ctx.getSession();
16527
17044
  if (current2 !== next) {
16528
17045
  try {
16529
- ctx.abortActiveRun?.();
17046
+ ctx.abortActiveRun?.(current2.id);
16530
17047
  } catch {
16531
17048
  }
16532
17049
  await finalizeSession(current2);
@@ -17059,85 +17576,51 @@ function createSessionHandlers(ctx) {
17059
17576
  // src/server/agent-roster-handlers.ts
17060
17577
  import {
17061
17578
  applyProjectAgentConfig,
17062
- buildConsolidationInstruction,
17063
17579
  captureLearnedFromAgentOutputDetailed,
17064
17580
  clearProjectAgentConsolidated,
17581
+ clearProjectSkillAugmentation,
17065
17582
  createProjectAgent,
17066
17583
  detectLearnedConflicts,
17584
+ evaluateAutoOptimize,
17067
17585
  FLEET_ROSTER,
17068
17586
  getProjectAgentLearnStats,
17069
17587
  isConsolidated,
17070
17588
  listProjectAgentLearnedEntries,
17071
17589
  listProjectAgentRoles,
17590
+ listProjectSkillAugmentations,
17072
17591
  loadConsolidationMetadata,
17073
17592
  loadProjectAgentConfig,
17074
17593
  loadProjectAgentConsolidated,
17075
17594
  loadProjectAgentIdentity,
17076
17595
  loadProjectAgentLearned,
17077
17596
  loadProjectAgentProfile,
17597
+ loadProjectSkillAugmentation,
17598
+ loadSkillAffinity,
17599
+ optimizeProjectAgentLearning,
17600
+ readRawLearnedEntries,
17078
17601
  resetProjectAgentIdentity,
17602
+ resolveAutoOptimizePolicy,
17603
+ resolveRoleSkillCandidates,
17079
17604
  saveProjectAgentConsolidated,
17605
+ saveProjectSkillAugmentation,
17606
+ setSkillPinned,
17080
17607
  slugifyProjectAgentRole,
17081
17608
  updateProjectAgentConfig,
17082
17609
  updateProjectAgentIdentity,
17083
17610
  updateProjectAgentLearned,
17084
17611
  updateProjectAgentLearningPolicy
17085
17612
  } from "@wrongstack/core/coordination";
17086
- import { isTextBlock } from "@wrongstack/core/types";
17087
- var CONSOLIDATION_MAX_TOKENS = 8e3;
17088
- var CONSOLIDATION_TIMEOUT_MS = 12e4;
17089
17613
  var AgentRosterWSHandler = class {
17090
17614
  getProjectRoot;
17091
17615
  getLlm;
17092
17616
  broadcast;
17617
+ getAutoOptimizeSettings;
17093
17618
  constructor(opts) {
17094
17619
  this.getProjectRoot = typeof opts.projectRoot === "function" ? opts.projectRoot : () => opts.projectRoot;
17095
17620
  this.getLlm = opts.getLlm ?? (() => void 0);
17096
17621
  this.broadcast = opts.broadcast ?? (() => {
17097
17622
  });
17098
- }
17099
- /**
17100
- * Run the consolidation LLM synthesis headlessly and return the cleaned
17101
- * document text. Returns undefined when no LLM is available so the caller
17102
- * can fall back to the instruction-only path.
17103
- */
17104
- async synthesizeConsolidation(instruction) {
17105
- const llm = this.getLlm();
17106
- if (!llm) return void 0;
17107
- const req = {
17108
- model: llm.model,
17109
- system: [
17110
- {
17111
- type: "text",
17112
- text: "You are a precise technical editor. You consolidate an AI agent role's captured learning entries into a single, durable, role-scoped instruction document. Output ONLY the consolidated markdown document \u2014 no preamble, no code fences, no commentary about the consolidation process."
17113
- }
17114
- ],
17115
- messages: [{ role: "user", content: instruction }],
17116
- maxTokens: CONSOLIDATION_MAX_TOKENS
17117
- };
17118
- const timer = new AbortController();
17119
- let timedOut = false;
17120
- const to = setTimeout(() => {
17121
- timedOut = true;
17122
- timer.abort(new Error("consolidation timeout"));
17123
- }, CONSOLIDATION_TIMEOUT_MS);
17124
- to.unref();
17125
- try {
17126
- const res = await llm.provider.complete(req, { signal: timer.signal });
17127
- const text = res.content.filter(isTextBlock).map((block) => block.text).join("\n").trim();
17128
- const wholeDocFence = /^```(?:markdown|md)?[^\n]*\n([\s\S]*?)\n?```\s*$/i;
17129
- const wrapped = wholeDocFence.exec(text);
17130
- const inner = wrapped?.[1];
17131
- const unfenced = inner !== void 0 ? inner.trim() : text;
17132
- return { content: unfenced, model: llm.model };
17133
- } catch (err) {
17134
- if (timedOut) {
17135
- throw new Error(`consolidation timed out after ${CONSOLIDATION_TIMEOUT_MS}ms`);
17136
- }
17137
- throw err;
17138
- } finally {
17139
- clearTimeout(to);
17140
- }
17623
+ this.getAutoOptimizeSettings = opts.getAutoOptimizeSettings;
17141
17624
  }
17142
17625
  /** Handle an incoming client message. Returns a response payload. */
17143
17626
  async handleMessage(_ws, type, payload) {
@@ -17342,70 +17825,47 @@ ${String(p.content ?? "")}`;
17342
17825
  const conflicts = detectLearnedConflicts(projectRoot);
17343
17826
  return { type, payload: { conflicts } };
17344
17827
  }
17345
- // ── Consolidate learned entries (headless LLM synthesis) ──────────
17346
- // Runs the whole optimization on the server: read raw entries
17347
- // synthesize with the active model write consolidated.md +
17348
- // consolidation.json. No chat round-trip. When no LLM is available the
17349
- // handler degrades to returning the instruction so a caller (e.g. the
17350
- // chat agent) can still perform the consolidation manually.
17351
- case "agent-roster.consolidate": {
17828
+ // ── Optimize: distil captures into skill addenda + a consolidated doc,
17829
+ // then archive and reset the raw buffer. Shared implementation with the
17830
+ // CLI (`optimizeProjectAgentLearning`) so both surfaces persist the same
17831
+ // artifacts instead of the CLI producing markdown nobody saved.
17832
+ case "agent-roster.consolidate":
17833
+ case "agent-roster.optimize": {
17352
17834
  if (!role) return { type, payload: { error: "role required" } };
17353
- const { instruction, rawEntries, hasExistingConsolidation } = buildConsolidationInstruction(role, projectRoot);
17354
- if (rawEntries.length === 0) {
17835
+ const hasExistingConsolidation = isConsolidated(role, projectRoot);
17836
+ const pending = readRawLearnedEntries(role, projectRoot);
17837
+ if (pending.length === 0) {
17355
17838
  return {
17356
17839
  type: "agent-roster.consolidate",
17357
17840
  payload: {
17358
17841
  role,
17359
17842
  consolidated: false,
17360
17843
  rawEntryCount: 0,
17844
+ skills: [],
17361
17845
  hasExistingConsolidation,
17362
17846
  currentStats: getProjectAgentLearnStats(role, projectRoot)
17363
17847
  }
17364
17848
  };
17365
17849
  }
17366
- let synth;
17367
- try {
17368
- synth = await this.synthesizeConsolidation(instruction);
17369
- } catch (err) {
17370
- return {
17371
- type: "agent-roster.consolidate",
17372
- payload: {
17373
- role,
17374
- consolidated: false,
17375
- rawEntryCount: rawEntries.length,
17376
- hasExistingConsolidation,
17377
- currentStats: getProjectAgentLearnStats(role, projectRoot),
17378
- error: err instanceof Error ? err.message : "consolidation failed"
17379
- }
17380
- };
17381
- }
17382
- if (synth && synth.content.length > 0) {
17383
- let stats;
17384
- let metadata;
17385
- try {
17386
- saveProjectAgentConsolidated(role, synth.content, projectRoot, {
17387
- trigger: "manual",
17388
- model: synth.model
17389
- });
17390
- stats = getProjectAgentLearnStats(role, projectRoot);
17391
- metadata = loadConsolidationMetadata(role, projectRoot);
17392
- } catch (err) {
17393
- return {
17394
- type: "agent-roster.consolidate",
17395
- payload: {
17396
- role,
17397
- consolidated: false,
17398
- rawEntryCount: rawEntries.length,
17399
- hasExistingConsolidation,
17400
- currentStats: getProjectAgentLearnStats(role, projectRoot),
17401
- error: err instanceof Error ? err.message : "failed to persist consolidation"
17402
- }
17403
- };
17404
- }
17850
+ const llm = this.getLlm();
17851
+ const result = await optimizeProjectAgentLearning(role, projectRoot, {
17852
+ ...llm ? { llm } : {},
17853
+ trigger: "manual"
17854
+ });
17855
+ const currentStats = getProjectAgentLearnStats(role, projectRoot);
17856
+ const metadata = loadConsolidationMetadata(role, projectRoot);
17857
+ const basePayload = {
17858
+ role,
17859
+ rawEntryCount: result.rawEntryCount,
17860
+ skills: result.skills,
17861
+ hasExistingConsolidation,
17862
+ currentStats
17863
+ };
17864
+ if (result.status === "optimized") {
17405
17865
  try {
17406
17866
  this.broadcast({
17407
17867
  type: "agent-roster.updated",
17408
- payload: { role, reason: "consolidated", currentStats: stats, metadata }
17868
+ payload: { role, reason: "consolidated", currentStats, metadata }
17409
17869
  });
17410
17870
  } catch (e) {
17411
17871
  console.warn(
@@ -17421,44 +17881,101 @@ ${String(p.content ?? "")}`;
17421
17881
  return {
17422
17882
  type: "agent-roster.consolidate",
17423
17883
  payload: {
17424
- role,
17884
+ ...basePayload,
17425
17885
  consolidated: true,
17426
- rawEntryCount: rawEntries.length,
17427
- content: synth.content,
17428
- model: synth.model,
17429
- currentStats: stats,
17886
+ content: result.content,
17887
+ model: result.model,
17888
+ pruned: result.pruned,
17430
17889
  metadata
17431
17890
  }
17432
17891
  };
17433
17892
  }
17434
- if (synth) {
17435
- return {
17436
- type: "agent-roster.consolidate",
17437
- payload: {
17438
- role,
17439
- consolidated: false,
17440
- emptySynthesis: true,
17441
- model: synth.model,
17442
- rawEntryCount: rawEntries.length,
17443
- hasExistingConsolidation,
17444
- currentStats: getProjectAgentLearnStats(role, projectRoot)
17445
- }
17446
- };
17447
- }
17448
17893
  return {
17449
17894
  type: "agent-roster.consolidate",
17450
17895
  payload: {
17451
- role,
17896
+ ...basePayload,
17452
17897
  consolidated: false,
17453
- instruction,
17454
- rawEntryCount: rawEntries.length,
17455
- hasExistingConsolidation,
17456
- currentStats: getProjectAgentLearnStats(role, projectRoot),
17457
- // Instruction for the leader agent to execute the consolidation
17458
- leaderInstruction: `Optimize what the "${role}" agent has learned. Read its raw learned entries, synthesize them into a single narrowly-scoped document preserving every fact, and save the result. The instruction text contains the full details and raw entries.`
17898
+ ...result.status === "empty-synthesis" ? { emptySynthesis: true, model: result.model } : {},
17899
+ ...result.status === "failed" ? { error: result.error } : {},
17900
+ ...result.status === "no-llm" ? {
17901
+ instruction: result.instruction,
17902
+ leaderInstruction: `Optimize what the "${role}" agent has learned. Read its raw learned entries, synthesize them into a single narrowly-scoped document preserving every fact, and save the result. The instruction text contains the full details and raw entries.`
17903
+ } : {}
17904
+ }
17905
+ };
17906
+ }
17907
+ // ── Automatic-optimization status ─────────────────────────────────
17908
+ // Read-only: says whether the background scheduler considers each role
17909
+ // eligible right now, and why not when it does not. Surfacing the reason
17910
+ // is what keeps "nothing happened" from looking like a broken feature.
17911
+ case "agent-roster.auto-optimize-status": {
17912
+ const policy = resolveAutoOptimizePolicy(
17913
+ this.getAutoOptimizeSettings?.() ?? void 0
17914
+ );
17915
+ const roles = role ? [role] : listProjectAgentRoles(projectRoot);
17916
+ return {
17917
+ type,
17918
+ payload: {
17919
+ policy,
17920
+ roles: roles.map((current2) => {
17921
+ try {
17922
+ const decision = evaluateAutoOptimize(current2, projectRoot, policy);
17923
+ return { role: current2, ...decision };
17924
+ } catch {
17925
+ return { role: current2, eligible: false, reason: "disabled" };
17926
+ }
17927
+ })
17459
17928
  }
17460
17929
  };
17461
17930
  }
17931
+ // ── Skill layer: what this project has developed for each role skill ──
17932
+ case "agent-roster.skills": {
17933
+ if (!role) return { type, payload: { error: "role required" } };
17934
+ const candidates = resolveRoleSkillCandidates(role, projectRoot);
17935
+ const developed = listProjectSkillAugmentations(role, projectRoot);
17936
+ const affinity = loadSkillAffinity(role, projectRoot);
17937
+ return {
17938
+ type,
17939
+ payload: {
17940
+ role,
17941
+ skills: candidates.map((skill) => ({
17942
+ skill,
17943
+ developed: developed.includes(skill),
17944
+ affinity: affinity.entries[skill] ?? null
17945
+ }))
17946
+ }
17947
+ };
17948
+ }
17949
+ case "agent-roster.read-skill": {
17950
+ const skill = typeof p.skill === "string" ? p.skill : "";
17951
+ if (!role || !skill) return { type, payload: { error: "role and skill required" } };
17952
+ return {
17953
+ type,
17954
+ payload: { role, skill, content: loadProjectSkillAugmentation(role, skill, projectRoot) }
17955
+ };
17956
+ }
17957
+ case "agent-roster.save-skill": {
17958
+ const skill = typeof p.skill === "string" ? p.skill : "";
17959
+ if (!role || !skill || typeof p.content !== "string") {
17960
+ return { type, payload: { error: "role, skill and content required" } };
17961
+ }
17962
+ const savedPath = saveProjectSkillAugmentation(role, skill, p.content, projectRoot);
17963
+ return { type, payload: { role, skill, path: savedPath, success: true } };
17964
+ }
17965
+ case "agent-roster.clear-skill": {
17966
+ const skill = typeof p.skill === "string" ? p.skill : "";
17967
+ if (!role) return { type, payload: { error: "role required" } };
17968
+ clearProjectSkillAugmentation(role, skill || void 0, projectRoot);
17969
+ return { type, payload: { role, skill: skill || null, success: true } };
17970
+ }
17971
+ case "agent-roster.pin-skill": {
17972
+ const skill = typeof p.skill === "string" ? p.skill : "";
17973
+ if (!role || !skill || typeof p.pinned !== "boolean") {
17974
+ return { type, payload: { error: "role, skill and boolean pinned required" } };
17975
+ }
17976
+ const affinity = setSkillPinned(role, skill, p.pinned, projectRoot);
17977
+ return { type, payload: { role, skill, pinned: p.pinned, affinity, success: true } };
17978
+ }
17462
17979
  // ── Save consolidated document ────────────────────────────────────
17463
17980
  case "agent-roster.save-consolidated": {
17464
17981
  if (!role || typeof p.content !== "string") {
@@ -18211,7 +18728,7 @@ async function handleShellOpen(req, logger, options) {
18211
18728
  import {
18212
18729
  enqueueKanbanWorkflowCommand,
18213
18730
  kanbanWorkflowId,
18214
- listBoards as listBoards3,
18731
+ listBoards as listBoards4,
18215
18732
  listKanbanWorkflowStates
18216
18733
  } from "@wrongstack/kanban";
18217
18734
  import {
@@ -18391,7 +18908,7 @@ var SddBoardWebSocketHandler = class {
18391
18908
  this.broadcast({ type: "sdd.board.snapshot", payload: null });
18392
18909
  if (this.lifecycle) {
18393
18910
  try {
18394
- const boards = await listBoards3(this.lifecycle.projectRoot);
18911
+ const boards = await listBoards4(this.lifecycle.projectRoot);
18395
18912
  this.broadcast({
18396
18913
  type: "kanban.list",
18397
18914
  payload: { success: true, data: boards }
@@ -19093,7 +19610,6 @@ function registerSetupEventsClientStatusWriter(deps2) {
19093
19610
  }
19094
19611
 
19095
19612
  // src/server/setup-events-fleet-broadcaster.ts
19096
- import { watch as fsWatch } from "node:fs";
19097
19613
  import * as path20 from "node:path";
19098
19614
  function registerSetupEventsFleetBroadcaster(deps2) {
19099
19615
  const { globalConfigPath, wpaths, context, clients, broadcast: broadcast2, onFleetBroadcaster, isDisposed } = deps2;
@@ -19102,8 +19618,8 @@ function registerSetupEventsFleetBroadcaster(deps2) {
19102
19618
  const disposers = [];
19103
19619
  const broadcastSessions = async () => {
19104
19620
  try {
19105
- const { SessionRegistry } = await import("@wrongstack/core/storage");
19106
- const registry = new SessionRegistry(globalRoot);
19621
+ const { getSessionRegistry: getSessionRegistry3 } = await import("@wrongstack/core/storage");
19622
+ const registry = getSessionRegistry3(globalRoot);
19107
19623
  const sessions = await registry.list();
19108
19624
  const ownEntry = sessions.find((s) => s.pid === process.pid);
19109
19625
  const mySlug = ownEntry?.projectSlug ?? wpaths?.projectSlug;
@@ -19150,7 +19666,7 @@ function registerSetupEventsFleetBroadcaster(deps2) {
19150
19666
  }
19151
19667
  };
19152
19668
  onFleetBroadcaster?.(broadcastSessions);
19153
- let regWatchLive = false;
19669
+ let subscriptionLive = false;
19154
19670
  let statusTimer;
19155
19671
  const scheduleStatusPoll = () => {
19156
19672
  if (isDisposed()) return;
@@ -19159,35 +19675,31 @@ function registerSetupEventsFleetBroadcaster(deps2) {
19159
19675
  void broadcastSessions();
19160
19676
  scheduleStatusPoll();
19161
19677
  },
19162
- regWatchLive ? 3e4 : 5e3
19678
+ subscriptionLive ? 3e4 : 5e3
19163
19679
  );
19164
19680
  if (statusTimer.unref) statusTimer.unref();
19165
19681
  };
19166
19682
  disposers.push(() => {
19167
19683
  if (statusTimer) clearTimeout(statusTimer);
19168
19684
  });
19169
- let regDebounce;
19170
- try {
19171
- const regWatcher = fsWatch(globalRoot, { persistent: false }, (_event, filename) => {
19172
- const name2 = filename ? String(filename) : "";
19173
- if (!name2.startsWith("session-registry.json") || name2.endsWith(".lock")) return;
19174
- if (regDebounce) clearTimeout(regDebounce);
19175
- regDebounce = setTimeout(() => void broadcastSessions(), 150);
19176
- });
19177
- regWatcher.on("error", () => {
19178
- regWatchLive = false;
19179
- try {
19180
- regWatcher.close();
19181
- } catch {
19182
- }
19183
- });
19184
- regWatchLive = true;
19185
- disposers.push(() => {
19186
- if (regDebounce) clearTimeout(regDebounce);
19187
- regWatcher.close();
19188
- });
19189
- } catch {
19190
- }
19685
+ let eventDebounce;
19686
+ let unsubscribe;
19687
+ void import("@wrongstack/core/storage").then(async ({ getSessionRegistry: getSessionRegistry3 }) => {
19688
+ const registry = getSessionRegistry3(globalRoot);
19689
+ const projectSlug2 = wpaths?.projectSlug;
19690
+ if (!projectSlug2 || isDisposed()) return;
19691
+ unsubscribe = await registry.subscribeProject(projectSlug2, context.projectRoot, () => {
19692
+ if (eventDebounce) clearTimeout(eventDebounce);
19693
+ eventDebounce = setTimeout(() => void broadcastSessions(), 25);
19694
+ });
19695
+ subscriptionLive = true;
19696
+ }).catch(() => {
19697
+ subscriptionLive = false;
19698
+ });
19699
+ disposers.push(() => {
19700
+ if (eventDebounce) clearTimeout(eventDebounce);
19701
+ void unsubscribe?.();
19702
+ });
19191
19703
  scheduleStatusPoll();
19192
19704
  void broadcastSessions();
19193
19705
  return () => {
@@ -19300,7 +19812,7 @@ function createSetupEventSessionHelpers(context, sessionBridge) {
19300
19812
  }
19301
19813
 
19302
19814
  // src/server/setup-events-status-watcher.ts
19303
- import { watch as fsWatch2 } from "node:fs";
19815
+ import { watch as fsWatch } from "node:fs";
19304
19816
  import * as fs18 from "node:fs/promises";
19305
19817
  import * as path22 from "node:path";
19306
19818
 
@@ -19390,7 +19902,7 @@ function registerSetupEventsStatusWatcher(deps2) {
19390
19902
  try {
19391
19903
  await fs18.mkdir(projectsDir, { recursive: true });
19392
19904
  if (isDisposed()) return;
19393
- watcher = fsWatch2(
19905
+ watcher = fsWatch(
19394
19906
  projectsDir,
19395
19907
  { persistent: true, recursive: true },
19396
19908
  async (eventType, filename) => {
@@ -20552,7 +21064,6 @@ import {
20552
21064
  mailboxSessionTag,
20553
21065
  ObservableBrainArbiter as ObservableBrainArbiterCtor
20554
21066
  } from "@wrongstack/core/coordination";
20555
- import { DEFAULT_TOOLS_CONFIG } from "@wrongstack/core/types";
20556
21067
  import { installDesignStudioMiddleware } from "@wrongstack/core/design";
20557
21068
  import {
20558
21069
  AutoCompactionMiddleware as AutoCompactionMiddlewareCtor,
@@ -20564,6 +21075,7 @@ import {
20564
21075
  import { TOKENS } from "@wrongstack/core/kernel";
20565
21076
  import { SessionMemoryConsolidator } from "@wrongstack/core/storage";
20566
21077
  import {
21078
+ DEFAULT_TOOLS_CONFIG,
20567
21079
  resolveContextWindowPolicy as resolveContextWindowPolicy2
20568
21080
  } from "@wrongstack/core/types";
20569
21081
  import {
@@ -21445,6 +21957,11 @@ async function createAgentServices(input) {
21445
21957
  taskAware: config.Sage?.inject?.taskAware,
21446
21958
  minScore: config.Sage?.inject?.minScore,
21447
21959
  minImportance: config.Sage?.inject?.minImportance,
21960
+ // Forward the explicit relation floor so an operator-configured
21961
+ // `Sage.inject.relationFloor` is honored in WebUI sessions. Without
21962
+ // this we silently fall back to MIN_RELATION_STRENGTH (0.85), which
21963
+ // is the CLI default but masks operator overrides.
21964
+ relationFloor: config.Sage?.inject?.relationFloor,
21448
21965
  repeatCooldownMs: config.Sage?.inject?.repeatCooldownMs,
21449
21966
  verifyOnMutation: config.Sage?.hygiene?.autoOnFileChange,
21450
21967
  triggers: config.Sage?.inject?.triggers,
@@ -21466,6 +21983,10 @@ async function createAgentServices(input) {
21466
21983
  maxMemories: config.Sage?.inject?.maxTurnMemories,
21467
21984
  maxChars: config.Sage?.inject?.maxCharsPerTurn,
21468
21985
  minScore: config.Sage?.inject?.minScore,
21986
+ // CLI parity: honor `Sage.retrieval.metadataWeight` so the same config
21987
+ // value drives both runtimes instead of silently falling back to the
21988
+ // 0.3 default. The undefined case keeps the middleware's own default.
21989
+ metadataWeight: config.Sage?.retrieval?.metadataWeight,
21469
21990
  getSessionId: getSageSessionId,
21470
21991
  tracker: sageInjectionTracker
21471
21992
  })
@@ -21490,25 +22011,29 @@ async function createAgentServices(input) {
21490
22011
  strategy: config.context?.strategy,
21491
22012
  preserveK: config.context?.preserveK ?? 10,
21492
22013
  eliseThreshold: config.context?.eliseThreshold ?? 2e3,
22014
+ // Match the CLI/TUI runtime: keep corrections, errors and decisions
22015
+ // verbatim while collapsing routine assistant chatter/tool protocol.
22016
+ // Without this WebUI's hybrid strategy builds an ever-growing lossless
22017
+ // digest and eventually relies on blunt emergency head/tail trimming.
22018
+ smart: true,
21493
22019
  summarizerModel: config.context?.summarizerModel,
21494
22020
  llmSelector: config.context?.llmSelector
21495
22021
  });
21496
22022
  const initialContextPolicy = resolveContextWindowPolicy2(config.context);
21497
22023
  let autoCompactor;
21498
22024
  if (config.context?.autoCompact !== false) {
21499
- let effectiveMaxContext = config.context?.effectiveMaxContext ?? 0;
21500
- if (!effectiveMaxContext) {
21501
- try {
21502
- const m = await resolveProviderModelMetadata(
21503
- modelsRegistry,
21504
- config.provider,
21505
- context.model,
21506
- config.providers?.[config.provider]
21507
- );
21508
- effectiveMaxContext = m?.capabilities?.maxContext ?? 0;
21509
- } catch {
21510
- }
22025
+ let effectiveMaxContext = 0;
22026
+ try {
22027
+ const m = await resolveProviderModelMetadata(
22028
+ modelsRegistry,
22029
+ config.provider,
22030
+ context.model,
22031
+ config.providers?.[config.provider]
22032
+ );
22033
+ effectiveMaxContext = m?.capabilities?.maxContext ?? 0;
22034
+ } catch {
21511
22035
  }
22036
+ if (!effectiveMaxContext) effectiveMaxContext = config.context?.effectiveMaxContext ?? 0;
21512
22037
  if (!effectiveMaxContext) effectiveMaxContext = provider.capabilities.maxContext;
21513
22038
  autoCompactor = new AutoCompactionMiddlewareCtor(
21514
22039
  compactor,
@@ -22029,6 +22554,7 @@ async function setupWebUiGovernance(input, dependencies = DEFAULT_DEPENDENCIES)
22029
22554
 
22030
22555
  // src/server/message-dispatcher.ts
22031
22556
  import path23 from "node:path";
22557
+ import { planTool, taskTool, todoTool } from "@wrongstack/tools";
22032
22558
  function createMessageDispatcher(opts) {
22033
22559
  const { state, deps: deps2, routes, promptsCtx, codebaseIndexing, runLock, pendingConfirms } = opts;
22034
22560
  function makeWorklistContext() {
@@ -22040,7 +22566,20 @@ function createMessageDispatcher(opts) {
22040
22566
  },
22041
22567
  send: (w, m) => send(w, m),
22042
22568
  broadcast: (m) => broadcast(state.getClients(), m),
22043
- replaceTodos: (todos) => deps2.context.state.replaceTodos(todos)
22569
+ replaceTodos: (todos) => deps2.context.state.replaceTodos(todos),
22570
+ mutateTodos: async (todos) => {
22571
+ const result = await todoTool.execute({ todos }, deps2.context, {
22572
+ signal: AbortSignal.timeout(3e4)
22573
+ });
22574
+ return {
22575
+ todos: [...deps2.context.todos],
22576
+ ...result.kanban_warnings ? { warnings: result.kanban_warnings } : {}
22577
+ };
22578
+ },
22579
+ mutateTaskStatus: async (id, status) => taskTool.execute({ action: "status", id, status }, deps2.context, {
22580
+ signal: AbortSignal.timeout(3e4)
22581
+ }),
22582
+ mutatePlan: async (operation) => planTool.execute(operation, deps2.context, { signal: AbortSignal.timeout(3e4) })
22044
22583
  };
22045
22584
  }
22046
22585
  function makeSkillsContext() {
@@ -22130,16 +22669,24 @@ function createMessageDispatcher(opts) {
22130
22669
  getAgent: () => deps2.agent,
22131
22670
  getSessionId: () => state.getSession().id,
22132
22671
  runControl: {
22133
- begin: () => {
22672
+ begin: (_ws, sessionId) => {
22134
22673
  if (runLock.get()) return void 0;
22135
22674
  const controller = new AbortController();
22136
22675
  runLock.set(controller);
22676
+ runLock.setSession(sessionId);
22137
22677
  return controller;
22138
22678
  },
22139
- end: (_ws, controller) => {
22140
- if (runLock.get() === controller) runLock.set(null);
22679
+ end: (_ws, _sessionId, controller) => {
22680
+ if (runLock.get() === controller) {
22681
+ runLock.set(null);
22682
+ runLock.setSession(null);
22683
+ }
22141
22684
  },
22142
- abort: () => runLock.get()?.abort()
22685
+ abort: (_ws, sessionId) => {
22686
+ if (runLock.getSession() === sessionId || !runLock.getSession()) {
22687
+ runLock.get()?.abort();
22688
+ }
22689
+ }
22143
22690
  },
22144
22691
  pendingConfirms,
22145
22692
  send,
@@ -22161,10 +22708,20 @@ function createMessageDispatcher(opts) {
22161
22708
  const goalSnapshotRoutes = {
22162
22709
  getSnapshot: () => handleGoalGet(state.getProjectRoot(), (message) => broadcast(state.getClients(), message))
22163
22710
  };
22711
+ const kanbanSupervisor = createKanbanSupervisor({
22712
+ projectRoot: () => state.getProjectRoot(),
22713
+ broadcast: (message) => broadcast(state.getClients(), message),
22714
+ log: (message) => deps2.logger.warn?.(`[KanbanSupervisor] ${message}`)
22715
+ });
22716
+ if (opts.onDispose) {
22717
+ const dispose = () => kanbanSupervisor.dispose();
22718
+ opts.onDispose(dispose);
22719
+ }
22164
22720
  const kanbanContext = () => ({
22165
22721
  projectRoot: state.getProjectRoot(),
22166
22722
  context: deps2.context,
22167
- broadcast: (message) => broadcast(state.getClients(), message)
22723
+ broadcast: (message) => broadcast(state.getClients(), message),
22724
+ supervisor: kanbanSupervisor
22168
22725
  });
22169
22726
  const kanbanHostRoutes = {
22170
22727
  meta: async (ws) => {
@@ -22234,6 +22791,7 @@ function createMessageDispatcher(opts) {
22234
22791
  agentRoster: {
22235
22792
  rosterHandler: new AgentRosterWSHandler({
22236
22793
  projectRoot: state.getProjectRoot,
22794
+ getAutoOptimizeSettings: () => state.getConfig().fleet?.learning?.autoOptimize,
22237
22795
  getLlm: () => {
22238
22796
  const ctx = deps2.agent.ctx;
22239
22797
  return ctx.provider && ctx.model ? { provider: ctx.provider, model: ctx.model } : void 0;
@@ -22680,6 +23238,14 @@ async function createStandaloneSessionIdentityLifecycle(opts) {
22680
23238
  transition = transition.then(async () => {
22681
23239
  if (stopped) return;
22682
23240
  if (pendingClaim?.sessionId === sessionId) {
23241
+ await pendingClaim.claim.activate({
23242
+ sessionId,
23243
+ ...target,
23244
+ clientType: "webui",
23245
+ pid: process.pid,
23246
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
23247
+ agents: statusTracker.getAgents()
23248
+ });
22683
23249
  pendingClaim = void 0;
22684
23250
  } else {
22685
23251
  await register(sessionId, true, target);
@@ -22703,14 +23269,35 @@ async function createStandaloneSessionIdentityLifecycle(opts) {
22703
23269
  }
22704
23270
  if (sessionId === activeSessionId) return async () => {
22705
23271
  };
22706
- const previousSessionId = activeSessionId;
22707
- const previousTarget = activeTarget;
22708
23272
  const token = Symbol(sessionId);
22709
- await register(sessionId, true, target);
22710
- pendingClaim = { sessionId, previousSessionId, token };
23273
+ if ("reserveResume" in registry && typeof registry.reserveResume === "function") {
23274
+ const reservation = await registry.reserveResume({
23275
+ sessionId,
23276
+ projectSlug: target.projectSlug,
23277
+ projectRoot: target.projectRoot
23278
+ });
23279
+ pendingClaim = { sessionId, token, claim: reservation, target };
23280
+ } else {
23281
+ await register(sessionId, true, target);
23282
+ pendingClaim = {
23283
+ sessionId,
23284
+ token,
23285
+ target,
23286
+ claim: {
23287
+ reservation: {
23288
+ reservationId: "legacy",
23289
+ targetSessionId: sessionId,
23290
+ requesterInstanceId: "legacy",
23291
+ expiresAt: Number.MAX_SAFE_INTEGER
23292
+ },
23293
+ activate: async () => void 0,
23294
+ cancel: async () => register(activeSessionId, true, activeTarget)
23295
+ }
23296
+ };
23297
+ }
22711
23298
  return async () => {
22712
23299
  if (pendingClaim?.token !== token) return;
22713
- await register(previousSessionId, true, previousTarget);
23300
+ await pendingClaim.claim.cancel();
22714
23301
  pendingClaim = void 0;
22715
23302
  };
22716
23303
  };
@@ -23415,11 +24002,11 @@ function buildRoutes(state, deps2, cb) {
23415
24002
  }
23416
24003
 
23417
24004
  // src/server/server-runtime.ts
23418
- import * as path28 from "node:path";
23419
24005
  import { createRequire as createRequire4 } from "node:module";
24006
+ import * as path28 from "node:path";
23420
24007
  import { fileURLToPath } from "node:url";
23421
- import { WebSocketServer } from "ws";
23422
24008
  import { toErrorMessage as toErrorMessage12 } from "@wrongstack/core/utils";
24009
+ import { WebSocketServer } from "ws";
23423
24010
  async function resolvePorts(opts) {
23424
24011
  const surface = opts.surface ?? "webui";
23425
24012
  const surfaceDefaults = surface === "simpleui" ? { http: 3466 } : { http: 3456 };
@@ -23637,10 +24224,11 @@ function startHttpServer(opts) {
23637
24224
  return httpServer;
23638
24225
  }
23639
24226
  function registerShutdown(deps2) {
23640
- registerShutdownHandlers({
24227
+ return registerShutdownHandlers({
23641
24228
  flushSession: deps2.flushSession,
23642
24229
  clients: deps2.clients,
23643
24230
  servers: deps2.servers,
24231
+ onPreShutdown: deps2.onPreShutdown,
23644
24232
  onShutdown: deps2.onShutdown
23645
24233
  });
23646
24234
  }
@@ -23955,10 +24543,15 @@ async function startWebUI(opts = {}) {
23955
24543
  );
23956
24544
  }
23957
24545
  let _runLock = null;
24546
+ let _runLockSession = null;
23958
24547
  const runLockControl = {
23959
24548
  get: () => _runLock,
23960
24549
  set: (ctrl) => {
23961
24550
  _runLock = ctrl;
24551
+ },
24552
+ getSession: () => _runLockSession,
24553
+ setSession: (id) => {
24554
+ _runLockSession = id;
23962
24555
  }
23963
24556
  };
23964
24557
  const pendingConfirms = /* @__PURE__ */ new Map();
@@ -24083,6 +24676,7 @@ async function startWebUI(opts = {}) {
24083
24676
  if (ctrl) {
24084
24677
  ctrl.abort();
24085
24678
  runLockControl.set(null);
24679
+ runLockControl.setSession(null);
24086
24680
  }
24087
24681
  },
24088
24682
  isRunActive: () => runLockControl.get() !== null,
@@ -24232,6 +24826,7 @@ async function startWebUI(opts = {}) {
24232
24826
  })
24233
24827
  });
24234
24828
  const routes = buildRoutes(state, deps2, cb);
24829
+ let kanbanSupervisorDispose = null;
24235
24830
  const handleMessage = createMessageDispatcher({
24236
24831
  state,
24237
24832
  deps: deps2,
@@ -24239,7 +24834,10 @@ async function startWebUI(opts = {}) {
24239
24834
  promptsCtx,
24240
24835
  codebaseIndexing,
24241
24836
  runLock: runLockControl,
24242
- pendingConfirms
24837
+ pendingConfirms,
24838
+ onDispose: (dispose) => {
24839
+ kanbanSupervisorDispose = dispose;
24840
+ }
24243
24841
  });
24244
24842
  const mailbox = getSharedProjectMailbox4(
24245
24843
  resolveProjectDir3(context.projectRoot, wstackGlobalRoot2()),
@@ -24273,7 +24871,14 @@ projectRoot: ${ev.projectRoot ?? "?"}`,
24273
24871
  priority: "high",
24274
24872
  senderSessionId: session.id
24275
24873
  }).catch((err) => {
24276
- console.warn(`[WebUI] security-rejection mailbox note failed: ${String(err)}`);
24874
+ console.warn(
24875
+ JSON.stringify({
24876
+ level: "warn",
24877
+ event: "webui.security_rejection_mailbox_note_failed",
24878
+ message: String(err),
24879
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
24880
+ })
24881
+ );
24277
24882
  });
24278
24883
  },
24279
24884
  goalHandler,
@@ -24287,7 +24892,9 @@ projectRoot: ${ev.projectRoot ?? "?"}`,
24287
24892
  });
24288
24893
  wssPrimary.on("connection", handleConnection);
24289
24894
  if (wssSecondary) wssSecondary.on("connection", handleConnection);
24290
- registerShutdown({
24895
+ let unregisterShutdown = () => {
24896
+ };
24897
+ unregisterShutdown = registerShutdown({
24291
24898
  flushSession: async () => {
24292
24899
  await session.append({
24293
24900
  type: "session_end",
@@ -24303,7 +24910,12 @@ projectRoot: ${ev.projectRoot ?? "?"}`,
24303
24910
  wssPrimary,
24304
24911
  ...wssSecondary ? [wssSecondary] : []
24305
24912
  ],
24913
+ onPreShutdown: () => {
24914
+ kanbanSupervisorDispose?.();
24915
+ kanbanSupervisorDispose = null;
24916
+ },
24306
24917
  onShutdown: async () => {
24918
+ unregisterShutdown();
24307
24919
  await todosCheckpoint.detach();
24308
24920
  await stopHeapWatchdog();
24309
24921
  credentialWatcherClose?.();