clay-server 2.34.0-beta.1 → 2.34.0-beta.10

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.
Files changed (40) hide show
  1. package/lib/ask-user-mcp-server.js +120 -0
  2. package/lib/daemon.js +97 -38
  3. package/lib/mate-datastore.js +27 -5
  4. package/lib/mates.js +2 -2
  5. package/lib/os-users.js +13 -0
  6. package/lib/project-connection.js +16 -9
  7. package/lib/project-mate-datastore.js +16 -2
  8. package/lib/project-sessions.js +110 -7
  9. package/lib/project-user-message.js +4 -3
  10. package/lib/project.js +97 -10
  11. package/lib/public/css/mates.css +94 -52
  12. package/lib/public/css/mobile-nav.css +0 -14
  13. package/lib/public/css/notifications-center.css +23 -19
  14. package/lib/public/css/sidebar.css +326 -101
  15. package/lib/public/index.html +24 -57
  16. package/lib/public/modules/app-dm.js +0 -2
  17. package/lib/public/modules/app-messages.js +3 -5
  18. package/lib/public/modules/app-rendering.js +0 -2
  19. package/lib/public/modules/diff.js +21 -7
  20. package/lib/public/modules/mate-datastore-ui.js +108 -98
  21. package/lib/public/modules/mate-sidebar.js +0 -9
  22. package/lib/public/modules/mate-wizard.js +15 -15
  23. package/lib/public/modules/sidebar-mobile.js +10 -20
  24. package/lib/public/modules/sidebar-sessions.js +490 -113
  25. package/lib/public/modules/sidebar.js +8 -6
  26. package/lib/public/modules/tools.js +58 -13
  27. package/lib/public/sw.js +1 -1
  28. package/lib/sdk-bridge.js +36 -28
  29. package/lib/sdk-message-processor.js +14 -3
  30. package/lib/server.js +28 -72
  31. package/lib/sessions.js +157 -20
  32. package/lib/ws-schema.js +2 -0
  33. package/lib/yoke/adapters/claude-worker.js +114 -2
  34. package/lib/yoke/adapters/claude.js +56 -5
  35. package/lib/yoke/adapters/codex.js +349 -58
  36. package/lib/yoke/index.js +73 -35
  37. package/lib/yoke/instructions.js +0 -1
  38. package/lib/yoke/mcp-bridge-server.js +14 -6
  39. package/package.json +1 -2
  40. package/lib/yoke/adapters/gemini.js +0 -709
@@ -94,12 +94,14 @@ export function initSidebar(_ctx) {
94
94
  }
95
95
  } catch (e) {}
96
96
 
97
- ctx.newSessionBtn.addEventListener("click", function () {
98
- if (ctx.ws && ctx.connected) {
99
- ctx.ws.send(JSON.stringify({ type: "new_session" }));
100
- closeSidebar();
101
- }
102
- });
97
+ if (ctx.newSessionBtn) {
98
+ ctx.newSessionBtn.addEventListener("click", function () {
99
+ if (ctx.ws && ctx.connected) {
100
+ ctx.ws.send(JSON.stringify({ type: "new_session" }));
101
+ closeSidebar();
102
+ }
103
+ });
104
+ }
103
105
 
104
106
  // --- Loop (Ralph wizard) tool-palette tile ---
105
107
  // The tile is rendered by tool-palette.js at the stable id
@@ -1,7 +1,7 @@
1
1
  import { escapeHtml, copyToClipboard } from './utils.js';
2
2
  import { iconHtml, refreshIcons } from './icons.js';
3
3
  import { renderMarkdown, highlightCodeBlocks, renderMermaidBlocks } from './markdown.js';
4
- import { renderUnifiedDiff, renderSplitDiff, renderPatchDiff } from './diff.js';
4
+ import { renderUnifiedDiff, renderSplitDiff, renderPatchDiff, reconstructPatchSources } from './diff.js';
5
5
  import { openFile } from './filebrowser.js';
6
6
  import { mateAvatarUrl } from './avatar.js';
7
7
  import { getChatLayout } from './theme.js';
@@ -799,7 +799,7 @@ function resolvePermissionIdentity(mateId, vendor) {
799
799
  }
800
800
  }
801
801
  // Project chat: use vendor name and avatar
802
- var vendorAvatars = { claude: "/claude-code-avatar.png", codex: "/codex-avatar.png", gemini: "/gemini-avatar.png" };
802
+ var vendorAvatars = { claude: "/claude-code-avatar.png", codex: "/codex-avatar.png" };
803
803
  var vendorName = (vendor && VENDOR_NAMES[vendor]) || VENDOR_NAMES.claude;
804
804
  return {
805
805
  name: vendorName,
@@ -1587,14 +1587,32 @@ export function stopThinking(duration) {
1587
1587
  } else {
1588
1588
  currentThinking.el.querySelector(".thinking-duration").textContent = " " + secs.toFixed(1) + "s";
1589
1589
  }
1590
- // In mate mode: hide sparkle activity, show compact expandable thinking header
1590
+ // If no thinking text was streamed (e.g. Codex reasoning items arrive
1591
+ // with encrypted/hidden content, or Claude without extended-thinking),
1592
+ // the expand affordance is misleading because there's nothing inside.
1593
+ // Strip the chevron and the click handler so the header reads as a
1594
+ // plain label.
1595
+ var hasContent = !!(currentThinking.fullText && currentThinking.fullText.length > 0);
1596
+ if (!hasContent) {
1597
+ currentThinking.el.classList.add("empty");
1598
+ var chev = currentThinking.el.querySelector(".thinking-chevron");
1599
+ if (chev) chev.style.display = "none";
1600
+ var hdr = currentThinking.el.querySelector(".thinking-header");
1601
+ if (hdr) {
1602
+ hdr.style.cursor = "default";
1603
+ // Replace click listener by cloning the node (cheapest way to strip listeners).
1604
+ var clone = hdr.cloneNode(true);
1605
+ hdr.parentNode.replaceChild(clone, hdr);
1606
+ }
1607
+ }
1608
+ // In mate mode: hide sparkle activity, show compact thinking header.
1591
1609
  if (currentThinking.el.classList.contains("mate-thinking")) {
1592
1610
  var actRow = currentThinking.el.querySelector(".mate-thinking-activity");
1593
1611
  if (actRow) actRow.style.display = "none";
1594
1612
  var header = currentThinking.el.querySelector(".thinking-header");
1595
1613
  if (header) {
1596
1614
  header.style.display = "";
1597
- header.style.cursor = "pointer";
1615
+ header.style.cursor = hasContent ? "pointer" : "default";
1598
1616
  }
1599
1617
  }
1600
1618
  currentThinking = null;
@@ -1722,12 +1740,12 @@ export function updateToolExecuting(id, name, input) {
1722
1740
  ctx.scrollToBottom();
1723
1741
  }
1724
1742
 
1725
- function renderEditDiff(oldStr, newStr, filePath) {
1743
+ // Shared chrome (filename header + unified/split toggle) for diff renderings.
1744
+ // makeUnified and makeSplit are factories that return a fresh body element.
1745
+ function buildDiffChrome(filePath, linkOldStr, linkNewStr, makeUnified, makeSplit) {
1726
1746
  var wrapper = document.createElement("div");
1727
1747
  wrapper.className = "edit-diff";
1728
- var lang = getLanguageFromPath(filePath);
1729
1748
 
1730
- // Header with file path and split toggle (desktop only)
1731
1749
  var header = document.createElement("div");
1732
1750
  header.className = "edit-diff-header";
1733
1751
 
@@ -1740,7 +1758,7 @@ function renderEditDiff(oldStr, newStr, filePath) {
1740
1758
  e.stopPropagation();
1741
1759
  openFile(fp, { diff: { oldStr: os || "", newStr: ns || "" } });
1742
1760
  });
1743
- })(filePath, oldStr, newStr);
1761
+ })(filePath, linkOldStr, linkNewStr);
1744
1762
  }
1745
1763
  header.appendChild(pathSpan);
1746
1764
 
@@ -1766,7 +1784,7 @@ function renderEditDiff(oldStr, newStr, filePath) {
1766
1784
 
1767
1785
  wrapper.appendChild(header);
1768
1786
 
1769
- var currentBody = renderUnifiedDiff(oldStr, newStr, lang);
1787
+ var currentBody = makeUnified();
1770
1788
  wrapper.appendChild(currentBody);
1771
1789
 
1772
1790
  unifiedBtn.addEventListener("click", function (e) {
@@ -1776,7 +1794,7 @@ function renderEditDiff(oldStr, newStr, filePath) {
1776
1794
  unifiedBtn.classList.add("active");
1777
1795
  splitBtn.classList.remove("active");
1778
1796
  wrapper.removeChild(currentBody);
1779
- currentBody = renderUnifiedDiff(oldStr, newStr, lang);
1797
+ currentBody = makeUnified();
1780
1798
  wrapper.appendChild(currentBody);
1781
1799
  refreshIcons();
1782
1800
  });
@@ -1788,7 +1806,7 @@ function renderEditDiff(oldStr, newStr, filePath) {
1788
1806
  splitBtn.classList.add("active");
1789
1807
  unifiedBtn.classList.remove("active");
1790
1808
  wrapper.removeChild(currentBody);
1791
- currentBody = renderSplitDiff(oldStr, newStr, lang);
1809
+ currentBody = makeSplit();
1792
1810
  wrapper.appendChild(currentBody);
1793
1811
  refreshIcons();
1794
1812
  });
@@ -1796,6 +1814,29 @@ function renderEditDiff(oldStr, newStr, filePath) {
1796
1814
  return wrapper;
1797
1815
  }
1798
1816
 
1817
+ function renderEditDiff(oldStr, newStr, filePath) {
1818
+ var lang = getLanguageFromPath(filePath);
1819
+ return buildDiffChrome(
1820
+ filePath,
1821
+ oldStr,
1822
+ newStr,
1823
+ function () { return renderUnifiedDiff(oldStr, newStr, lang); },
1824
+ function () { return renderSplitDiff(oldStr, newStr, lang); }
1825
+ );
1826
+ }
1827
+
1828
+ function renderPatchDiffBlock(patchText, filePath) {
1829
+ var lang = getLanguageFromPath(filePath);
1830
+ var sources = reconstructPatchSources(patchText);
1831
+ return buildDiffChrome(
1832
+ filePath,
1833
+ sources.oldStr,
1834
+ sources.newStr,
1835
+ function () { return renderPatchDiff(patchText, lang); },
1836
+ function () { return renderSplitDiff(sources.oldStr, sources.newStr, lang); }
1837
+ );
1838
+ }
1839
+
1799
1840
  function isDiffContent(text) {
1800
1841
  var lines = text.split("\n");
1801
1842
  var hasHunkHeader = false;
@@ -1903,8 +1944,12 @@ export function updateToolResult(id, content, isError, images) {
1903
1944
  if (hasEditDiff) {
1904
1945
  resultBlock.appendChild(renderEditDiff(tool.input.old_string, tool.input.new_string, tool.input.file_path));
1905
1946
  } else if (!isError && isDiffContent(displayContent)) {
1906
- var patchLang = tool.input && tool.input.file_path ? getLanguageFromPath(tool.input.file_path) : null;
1907
- resultBlock.appendChild(renderPatchDiff(displayContent, patchLang));
1947
+ var patchFilePath = tool.input && tool.input.file_path ? tool.input.file_path : null;
1948
+ if (patchFilePath) {
1949
+ resultBlock.appendChild(renderPatchDiffBlock(displayContent, patchFilePath));
1950
+ } else {
1951
+ resultBlock.appendChild(renderPatchDiff(displayContent, null));
1952
+ }
1908
1953
  } else if (!isError && tool.name === "Read" && tool.input && tool.input.file_path && isImagePath(tool.input.file_path)) {
1909
1954
  // Image file: show inline preview
1910
1955
  var imgWrap = document.createElement("div");
package/lib/public/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- var CACHE_NAME = "clay-offline-v2";
1
+ var CACHE_NAME = "clay-offline-v3";
2
2
 
3
3
  self.addEventListener("install", function (event) {
4
4
  event.waitUntil(self.skipWaiting());
package/lib/sdk-bridge.js CHANGED
@@ -1175,17 +1175,21 @@ function createSDKBridge(opts) {
1175
1175
 
1176
1176
  if (dangerouslySkipPermissions) {
1177
1177
  claudeOpts.allowDangerouslySkipPermissions = true;
1178
+ claudeOpts.permissionMode = "bypassPermissions";
1179
+ } else {
1180
+ var globalMode = sm.currentPermissionMode || "default";
1181
+ var effectiveDefault;
1182
+ if (globalMode === "bypassPermissions") effectiveDefault = "bypassPermissions";
1183
+ else if (session.acceptEditsAfterStart) effectiveDefault = "acceptEdits";
1184
+ else effectiveDefault = globalMode;
1185
+ var modeToApply = session._loopPermissionMode || effectiveDefault;
1186
+ if (modeToApply && modeToApply !== "default") {
1187
+ claudeOpts.permissionMode = modeToApply;
1188
+ }
1178
1189
  }
1179
- var globalMode = sm.currentPermissionMode || "default";
1180
- var effectiveDefault;
1181
- if (globalMode === "bypassPermissions") effectiveDefault = "bypassPermissions";
1182
- else if (session.acceptEditsAfterStart) effectiveDefault = "acceptEdits";
1183
- else effectiveDefault = globalMode;
1184
- var modeToApply = session._loopPermissionMode || effectiveDefault;
1190
+ // Clear one-shot acceptEditsAfterStart regardless of which branch ran above,
1191
+ // so the flag does not linger into subsequent turns.
1185
1192
  if (session.acceptEditsAfterStart) delete session.acceptEditsAfterStart;
1186
- if (modeToApply && modeToApply !== "default") {
1187
- claudeOpts.permissionMode = modeToApply;
1188
- }
1189
1193
  if (session.cliSessionId && session.lastRewindUuid) {
1190
1194
  claudeOpts.resumeSessionAt = session.lastRewindUuid;
1191
1195
  delete session.lastRewindUuid;
@@ -1206,21 +1210,30 @@ function createSDKBridge(opts) {
1206
1210
  }
1207
1211
  }
1208
1212
 
1209
- // Use vendor-specific model: if session vendor differs from default, use that vendor's default model
1213
+ // Pick a model that belongs to the session's vendor. sm.currentModel is
1214
+ // shared project-wide, so a Codex session that last set it to
1215
+ // "gpt-5.4-mini" would otherwise leak into a Claude session in the same
1216
+ // project (or in another session that switches vendor to claude) and
1217
+ // Claude would reject the unknown model. We validate against the
1218
+ // session vendor's model list regardless of which vendor happens to be
1219
+ // the project's default adapter.
1210
1220
  var queryModel = ls.model || sm.currentModel || undefined;
1211
- if (session.vendor && session.vendor !== (adapter && adapter.vendor)) {
1212
- var vendorModels = (sm.modelsByVendor && sm.modelsByVendor[session.vendor]) || [];
1221
+ var sessionVendor = session.vendor || (adapter && adapter.vendor) || null;
1222
+ if (sessionVendor) {
1223
+ var vendorModels = (sm.modelsByVendor && sm.modelsByVendor[sessionVendor]) || [];
1213
1224
  if (vendorModels.length > 0 && queryModel && vendorModels.indexOf(queryModel) === -1) {
1214
1225
  queryModel = vendorModels[0];
1215
1226
  }
1216
1227
  }
1217
1228
 
1218
1229
  var codexConfig = getCodexConfig(sm);
1230
+ var mergedMcpServers = mergeMcpServers(getMcpServers(), getRemoteMcpServers) || undefined;
1219
1231
  var queryOpts = {
1220
1232
  cwd: cwd,
1221
1233
  model: queryModel,
1222
1234
  effort: ls.effort || sm.currentEffort || undefined,
1223
- toolServers: mergeMcpServers(getMcpServers(), getRemoteMcpServers) || undefined,
1235
+ toolServers: mergedMcpServers,
1236
+ toolServerDescriptors: extractMcpDescriptors(mergedMcpServers) || undefined,
1224
1237
  resumeSessionId: session.cliSessionId || undefined,
1225
1238
  abortController: linuxUser ? undefined : session.abortController,
1226
1239
  canUseTool: function(toolName, input, toolOpts) {
@@ -1229,6 +1242,9 @@ function createSDKBridge(opts) {
1229
1242
  onElicitation: function(request, elicitOpts) {
1230
1243
  return handleElicitation(session, request, elicitOpts);
1231
1244
  },
1245
+ callMcpTool: function(serverName, toolName, args) {
1246
+ return callMcpToolHandler(mergedMcpServers, serverName, toolName, args);
1247
+ },
1232
1248
  adapterOptions: {
1233
1249
  CLAUDE: claudeOpts,
1234
1250
  CODEX: {
@@ -1439,22 +1455,14 @@ function createSDKBridge(opts) {
1439
1455
  }
1440
1456
  }
1441
1457
 
1442
- // Initialize other adapters in parallel (skip if already have models cached)
1458
+ // Non-default adapters are NOT eagerly initialized here. Doing so used
1459
+ // to spawn a CodexAppServer and an mcp-bridge child per project even
1460
+ // when the user never touched that vendor. Lazy paths cover the gap:
1461
+ // - get_vendor_models (project.js) inits a vendor when the user
1462
+ // opens its model picker.
1463
+ // - ensureVendorReady (this file) inits a vendor when a session
1464
+ // actually issues a query with it.
1443
1465
  sm.modelsByVendor = sm.modelsByVendor || {};
1444
- var otherVendors = Object.keys(adapters).filter(function(v) {
1445
- return v !== defaultVendor && !sm.modelsByVendor[v];
1446
- });
1447
- for (var i = 0; i < otherVendors.length; i++) {
1448
- (function(v) {
1449
- adapters[v].init({ cwd: cwd, clayPort: clayPort, clayTls: clayTls, clayAuthToken: clayAuthToken, slug: slug }).then(function(r) {
1450
- sm.modelsByVendor[v] = r.models || [];
1451
- sm.capabilitiesByVendor[v] = r.capabilities || {};
1452
- if (r.slashCommands) sm.setSlashCommandsForVendor(v, r.slashCommands);
1453
- }).catch(function(e) {
1454
- console.error("[sdk-bridge] warmup: " + v + " init failed:", e.message || e);
1455
- });
1456
- })(otherVendors[i]);
1457
- }
1458
1466
 
1459
1467
  // Detect installed vendors per-user (binary existence check)
1460
1468
  sm.installedVendors = detectInstalledVendors(linuxUser);
@@ -370,12 +370,23 @@ function attachMessageProcessor(ctx) {
370
370
  session.sentToolResults = {};
371
371
  session.pendingPermissions = {};
372
372
  session.pendingElicitations = {};
373
- // Record ask_user_answered for any leftover pending questions so replay pairs correctly
373
+ // Record ask_user_answered for any leftover pending questions so replay pairs correctly.
374
+ // EXCEPTION: "mcp" mode entries are stateless — the tool returned immediately and the
375
+ // turn is expected to end while the card is still awaiting the user's answer. Those
376
+ // entries must survive across turns so the eventual ask_user_response can inject the
377
+ // answer as the next user message. Only blocking modes (Claude canUseTool) get closed.
374
378
  var leftoverAskIds = Object.keys(session.pendingAskUser);
379
+ var keptAskUser = {};
375
380
  for (var lai = 0; lai < leftoverAskIds.length; lai++) {
376
- sendAndRecord(session, { type: "ask_user_answered", toolId: leftoverAskIds[lai] });
381
+ var lid = leftoverAskIds[lai];
382
+ var lentry = session.pendingAskUser[lid];
383
+ if (lentry && lentry.mode === "mcp") {
384
+ keptAskUser[lid] = lentry;
385
+ continue;
386
+ }
387
+ sendAndRecord(session, { type: "ask_user_answered", toolId: lid });
377
388
  }
378
- session.pendingAskUser = {};
389
+ session.pendingAskUser = keptAskUser;
379
390
  session.activeTaskToolIds = {};
380
391
  session.taskIdMap = {};
381
392
  // Only clear rateLimitResetsAt on genuine success (non-zero cost).
package/lib/server.js CHANGED
@@ -459,75 +459,6 @@ function createServer(opts) {
459
459
  return;
460
460
  }
461
461
 
462
- // --- Global MCP bridge endpoint (localhost only) ---
463
- // Used by Codex mcp-bridge-server.js which can't know the active project slug.
464
- // Aggregates MCP tools from all project contexts.
465
- if (req.method === "POST" && fullUrl === "/api/mcp-bridge") {
466
- var mcpRemoteAddr = req.socket.remoteAddress || "";
467
- var mcpIsLocal = mcpRemoteAddr === "127.0.0.1" || mcpRemoteAddr === "::1" || mcpRemoteAddr === "::ffff:127.0.0.1";
468
- if (!mcpIsLocal) {
469
- res.writeHead(403, { "Content-Type": "application/json" });
470
- res.end('{"error":"Forbidden"}');
471
- return;
472
- }
473
- var parseJsonBody = function(r) {
474
- return new Promise(function(resolve, reject) {
475
- var chunks = [];
476
- r.on("data", function(c) { chunks.push(c); });
477
- r.on("end", function() {
478
- try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
479
- catch(e) { reject(e); }
480
- });
481
- });
482
- };
483
- parseJsonBody(req).then(function(body) {
484
- // Find the first project context that has a MCP bridge handler
485
- var handler = null;
486
- projects.forEach(function(ctx) {
487
- if (handler) return;
488
- if (ctx.getMcpBridgeHandler) {
489
- var h = ctx.getMcpBridgeHandler();
490
- if (h) handler = h;
491
- }
492
- });
493
- if (!handler) {
494
- res.writeHead(500, { "Content-Type": "application/json" });
495
- res.end('{"error":"No MCP bridge handler available"}');
496
- return;
497
- }
498
- if (body.action === "list_tools") {
499
- handler.listTools().then(function(tools) {
500
- var serverCounts = {};
501
- for (var ti = 0; ti < tools.length; ti++) {
502
- serverCounts[tools[ti].server] = (serverCounts[tools[ti].server] || 0) + 1;
503
- }
504
- console.log("[mcp-bridge-http] global list_tools:", tools.length, "tools -", Object.keys(serverCounts).map(function(s) { return s + "(" + serverCounts[s] + ")"; }).join(", ") || "(none)");
505
- res.writeHead(200, { "Content-Type": "application/json" });
506
- res.end(JSON.stringify({ tools: tools }));
507
- }).catch(function(err) {
508
- res.writeHead(500, { "Content-Type": "application/json" });
509
- res.end(JSON.stringify({ error: err.message }));
510
- });
511
- } else if (body.action === "call_tool") {
512
- console.log("[mcp-bridge-http] global call_tool:", body.server + "/" + body.tool);
513
- handler.callTool(body.server, body.tool, body.args || {}).then(function(result) {
514
- res.writeHead(200, { "Content-Type": "application/json" });
515
- res.end(JSON.stringify({ result: result }));
516
- }).catch(function(err) {
517
- res.writeHead(200, { "Content-Type": "application/json" });
518
- res.end(JSON.stringify({ error: err.message }));
519
- });
520
- } else {
521
- res.writeHead(400, { "Content-Type": "application/json" });
522
- res.end('{"error":"Unknown action"}');
523
- }
524
- }).catch(function() {
525
- res.writeHead(400, { "Content-Type": "application/json" });
526
- res.end('{"error":"Invalid JSON"}');
527
- });
528
- return;
529
- }
530
-
531
462
  // --- Skills routes (delegated to server-skills) ---
532
463
  if (skills.handleRequest(req, res, fullUrl)) return;
533
464
 
@@ -1090,7 +1021,11 @@ function createServer(opts) {
1090
1021
  getProject: function (s) { return projects.get(s) || null; },
1091
1022
  });
1092
1023
  projects.set(slug, ctx);
1093
- ctx.warmup();
1024
+ // ctx.warmup() is now deferred to the first websocket connection into
1025
+ // this project (see project-connection.js handleConnection). Warming
1026
+ // every project at startup spawned a CodexAppServer and an mcp-bridge
1027
+ // child for each one, which cost 30+ processes on daemons with many
1028
+ // projects/mates even though the user typically only opens one.
1094
1029
  // Schedule project registry refresh for all mates when a non-mate project is added
1095
1030
  if (!extra.isMate) scheduleRegistryRefresh();
1096
1031
  return true;
@@ -1126,8 +1061,13 @@ function createServer(opts) {
1126
1061
  var ctx = projects.get(slug);
1127
1062
  if (!ctx) return false;
1128
1063
  var wasMate = ctx.getStatus().isMate;
1129
- ctx.destroy();
1064
+ var shutdownResult = ctx.destroy();
1130
1065
  projects.delete(slug);
1066
+ if (shutdownResult && typeof shutdownResult.catch === "function") {
1067
+ shutdownResult.catch(function(err) {
1068
+ console.error("[server] Project destroy failed for " + slug + ":", err && err.message ? err.message : err);
1069
+ });
1070
+ }
1131
1071
  if (!wasMate) scheduleRegistryRefresh();
1132
1072
  return true;
1133
1073
  }
@@ -1289,12 +1229,26 @@ function createServer(opts) {
1289
1229
  });
1290
1230
  }
1291
1231
 
1232
+ function forEachProject(fn) {
1233
+ projects.forEach(function (ctx, slug) {
1234
+ fn(ctx, slug);
1235
+ });
1236
+ }
1237
+
1292
1238
  function destroyAll() {
1239
+ var shutdowns = [];
1293
1240
  projects.forEach(function (ctx, slug) {
1294
1241
  console.log("[server] Destroying project:", slug);
1295
- ctx.destroy();
1242
+ var result = ctx.destroy();
1243
+ if (result && typeof result.then === "function") {
1244
+ shutdowns.push(result.catch(function(err) {
1245
+ console.error("[server] Project destroy failed for " + slug + ":", err && err.message ? err.message : err);
1246
+ return false;
1247
+ }));
1248
+ }
1296
1249
  });
1297
1250
  projects.clear();
1251
+ return Promise.all(shutdowns);
1298
1252
  }
1299
1253
 
1300
1254
  // --- Periodic cleanup of old chat images ---
@@ -1358,6 +1312,8 @@ function createServer(opts) {
1358
1312
  setRecovery: auth.setRecovery,
1359
1313
  clearRecovery: auth.clearRecovery,
1360
1314
  broadcastAll: broadcastAll,
1315
+ forEachProject: forEachProject,
1316
+ destroyProject: removeProject,
1361
1317
  destroyAll: destroyAll,
1362
1318
  };
1363
1319
  }