clay-server 5.2.1-beta.1 → 5.3.0-beta.1

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 (151) hide show
  1. package/lib/clay-control-presentation-binding.js +5 -3
  2. package/lib/clay-product-control-tools.js +3 -3
  3. package/lib/clay-product-control.js +4 -2
  4. package/lib/clay-screen-controls.js +18 -3
  5. package/lib/clay-split-view-awareness.js +35 -0
  6. package/lib/clay-split-view-controls.js +122 -0
  7. package/lib/clay-split-view-input.js +20 -0
  8. package/lib/clay-split-view-render-proof.js +22 -0
  9. package/lib/pane-asset-manifest.js +63 -0
  10. package/lib/project-connection.js +19 -10
  11. package/lib/project-session-actions.js +1 -0
  12. package/lib/project-session-delete.js +2 -2
  13. package/lib/project-session-folders.js +1 -0
  14. package/lib/project-sessions.js +6 -1
  15. package/lib/project-split-view-document.js +33 -0
  16. package/lib/project-split-view-names.js +67 -0
  17. package/lib/project-split-view-organization.js +23 -0
  18. package/lib/project-split-views.js +479 -0
  19. package/lib/project-worker-conversation.js +158 -0
  20. package/lib/project-worker-creation-proposal.js +3 -4
  21. package/lib/project-worker-proposal.js +1 -1
  22. package/lib/project-worker-removal.js +101 -0
  23. package/lib/project.js +28 -1
  24. package/lib/public/app.js +32 -13
  25. package/lib/public/css/base.css +9 -0
  26. package/lib/public/css/filebrowser.css +0 -1
  27. package/lib/public/css/pane-header-controls.css +100 -0
  28. package/lib/public/css/pane.css +24 -5
  29. package/lib/public/css/popup-chat.css +123 -0
  30. package/lib/public/css/right-workbench.css +50 -18
  31. package/lib/public/css/split-view-layout.css +460 -0
  32. package/lib/public/css/split-view-worker-activity.css +97 -0
  33. package/lib/public/css/sticky-notes.css +13 -0
  34. package/lib/public/index.html +3 -3
  35. package/lib/public/modules/app-connection.js +4 -1
  36. package/lib/public/modules/app-messages.js +25 -8
  37. package/lib/public/modules/chat-render-runtime.js +45 -0
  38. package/lib/public/modules/clay-control-copy.js +3 -0
  39. package/lib/public/modules/clay-navigation-render.js +1 -1
  40. package/lib/public/modules/clay-owner-navigation.js +15 -5
  41. package/lib/public/modules/clay-split-view-screen.js +74 -0
  42. package/lib/public/modules/clay-tool-activity.js +5 -0
  43. package/lib/public/modules/command-palette.js +30 -28
  44. package/lib/public/modules/contextual-clay-session.js +26 -2
  45. package/lib/public/modules/contextual-clay.js +5 -2
  46. package/lib/public/modules/contextual-mate-sessions.js +2 -0
  47. package/lib/public/modules/generation-stop.js +7 -1
  48. package/lib/public/modules/knowledge-workbench-entry.js +43 -0
  49. package/lib/public/modules/markdown.js +11 -0
  50. package/lib/public/modules/mate-knowledge-workbench.js +2 -7
  51. package/lib/public/modules/mate-sidebar.js +1 -1
  52. package/lib/public/modules/palette-clay-conversation.js +87 -0
  53. package/lib/public/modules/palette-render-helpers.js +25 -0
  54. package/lib/public/modules/pane-activation.js +46 -7
  55. package/lib/public/modules/pane-binding.js +12 -2
  56. package/lib/public/modules/pane-bridge.js +12 -3
  57. package/lib/public/modules/pane-deferred-bootstrap.js +32 -0
  58. package/lib/public/modules/pane-feature-assets.js +35 -0
  59. package/lib/public/modules/pane-search-receipt.js +12 -0
  60. package/lib/public/modules/permission-control.js +6 -6
  61. package/lib/public/modules/popup-chat-rendering.js +244 -0
  62. package/lib/public/modules/project-activation.js +2 -2
  63. package/lib/public/modules/qrcode.js +12 -1
  64. package/lib/public/modules/right-workbench.js +68 -0
  65. package/lib/public/modules/search-hints.js +79 -0
  66. package/lib/public/modules/session-context-menu.js +35 -2
  67. package/lib/public/modules/session-folder-context.js +24 -13
  68. package/lib/public/modules/session-folder-delete.js +18 -1
  69. package/lib/public/modules/session-folder-layout.js +7 -6
  70. package/lib/public/modules/session-search-pane-state.js +30 -0
  71. package/lib/public/modules/session-search.js +53 -13
  72. package/lib/public/modules/sidebar-mobile.js +3 -3
  73. package/lib/public/modules/sidebar-projects.js +1 -0
  74. package/lib/public/modules/sidebar-session-folders-ui.js +3 -3
  75. package/lib/public/modules/sidebar-sessions.js +57 -24
  76. package/lib/public/modules/sidebar.js +20 -3
  77. package/lib/public/modules/split-group-helpers.js +1 -1
  78. package/lib/public/modules/split-group-restore.js +8 -11
  79. package/lib/public/modules/split-pair-ui.js +18 -0
  80. package/lib/public/modules/split-pane-header-state.js +45 -0
  81. package/lib/public/modules/split-pane-reconciler.js +23 -4
  82. package/lib/public/modules/split-pane-renderer.js +330 -23
  83. package/lib/public/modules/split-pane-retention.js +157 -0
  84. package/lib/public/modules/split-session-boundary.js +21 -0
  85. package/lib/public/modules/split-view-create-action.js +15 -0
  86. package/lib/public/modules/split-view-create-menu.js +83 -0
  87. package/lib/public/modules/split-view-create-request.js +23 -0
  88. package/lib/public/modules/split-view-layout-client.js +198 -0
  89. package/lib/public/modules/split-view-layout-icons.js +37 -0
  90. package/lib/public/modules/split-view-layout-scope.js +38 -0
  91. package/lib/public/modules/split-view-organization.js +73 -0
  92. package/lib/public/modules/split-view-presentation-close.js +35 -0
  93. package/lib/public/modules/split-view-presentation-drag.js +254 -0
  94. package/lib/public/modules/split-view-presentation-drop.js +57 -0
  95. package/lib/public/modules/split-view-presentation-lookup.js +44 -0
  96. package/lib/public/modules/split-view-presentation-renderer.js +494 -0
  97. package/lib/public/modules/split-view-presentations.js +495 -0
  98. package/lib/public/modules/split-view-shell-controls.js +28 -0
  99. package/lib/public/modules/split-view-sidebar-menu.js +42 -0
  100. package/lib/public/modules/split-view-sidebar-renderer.js +30 -0
  101. package/lib/public/modules/split-view-singleton.js +21 -0
  102. package/lib/public/modules/split-view-worker-activity.js +94 -0
  103. package/lib/public/modules/split-view-worker-controls.js +30 -0
  104. package/lib/public/modules/split-view-worker-removal.js +137 -0
  105. package/lib/public/modules/split-view-worker-scroll.js +57 -0
  106. package/lib/public/modules/split-view-worker-transcript.js +46 -0
  107. package/lib/public/modules/split-view-workers.js +352 -0
  108. package/lib/public/modules/split-view.js +61 -22
  109. package/lib/public/modules/sticky-notes.js +9 -2
  110. package/lib/public/modules/tools.js +25 -5
  111. package/lib/public/modules/workbench-layout-preference.js +76 -0
  112. package/lib/public/pane-assets/app-OJEQXBBS.js +1 -0
  113. package/lib/public/pane-assets/chunk-4SMIVAB4.js +1 -0
  114. package/lib/public/pane-assets/chunk-623V5CEO.js +1 -0
  115. package/lib/public/pane-assets/chunk-6SM2WQJX.js +1 -0
  116. package/lib/public/pane-assets/chunk-7H36SUZA.js +2 -0
  117. package/lib/public/pane-assets/chunk-DGOFGEUK.js +1 -0
  118. package/lib/public/pane-assets/chunk-GML2R5JE.js +167 -0
  119. package/lib/public/pane-assets/chunk-I57URYSL.js +1 -0
  120. package/lib/public/pane-assets/chunk-I5P3MWQG.js +1 -0
  121. package/lib/public/pane-assets/chunk-UVGOPFEX.js +2 -0
  122. package/lib/public/pane-assets/manifest.json +551 -0
  123. package/lib/public/pane-assets/style-GN7R6XKV.css +1 -0
  124. package/lib/public/style.css +5 -1
  125. package/lib/sdk-bridge.js +16 -3
  126. package/lib/server-owner-ui.js +6 -2
  127. package/lib/server-owner-view-observation.js +34 -0
  128. package/lib/server-palette.js +1 -0
  129. package/lib/server-pane-bootstrap.js +24 -0
  130. package/lib/server-settings.js +2 -0
  131. package/lib/server-static-assets.js +52 -0
  132. package/lib/server-workbench-layout-settings.js +56 -0
  133. package/lib/server.js +6 -45
  134. package/lib/session-activity-state.js +28 -0
  135. package/lib/session-list-delivery.js +17 -0
  136. package/lib/session-pair-turn-control.js +1 -6
  137. package/lib/session-search.js +3 -0
  138. package/lib/session-split-group-anchors.js +2 -2
  139. package/lib/session-split-group-roles.js +5 -8
  140. package/lib/session-split-group-v2-store.js +0 -3
  141. package/lib/sessions.js +16 -6
  142. package/lib/split-view-layout.js +132 -0
  143. package/lib/users-workbench-layout-preferences.js +33 -0
  144. package/lib/users.js +3 -0
  145. package/lib/workspace-query-service.js +1 -5
  146. package/lib/ws-schema.js +15 -0
  147. package/lib/yoke/adapters/codex.js +1 -0
  148. package/lib/yoke/codex-app-server.js +3 -1
  149. package/lib/yoke/codex-idle-recovery.js +73 -20
  150. package/lib/yoke/codex-recovery-admission.js +65 -4
  151. package/package.json +6 -3
@@ -9,6 +9,9 @@
9
9
  @import url("css/title-bar.css?v=20261018-workbench-controls2");
10
10
  @import url("css/clay-workspace.css?v=20261010-tint");
11
11
  @import url("css/pane.css");
12
+ @import url("css/split-view-layout.css");
13
+ @import url("css/pane-header-controls.css");
14
+ @import url("css/split-view-worker-activity.css");
12
15
  @import url("css/worker-proposal.css");
13
16
  @import url("css/workspace-assignment.css");
14
17
  @import url("css/session-actions.css?v=20260828-contrast1");
@@ -76,7 +79,7 @@
76
79
  @import url("./css/mcp-skills-workbench.css");
77
80
  @import url("css/skills-workbench.css");
78
81
 
79
- @import url("css/right-workbench.css?v=20261018-workbench-region");
82
+ @import url("css/right-workbench.css?v=20261020-workbench-overlay");
80
83
 
81
84
  @import url("css/mate-conversation.css");
82
85
 
@@ -94,4 +97,5 @@
94
97
  @import url("css/clay-tool-activity.css");
95
98
 
96
99
  @import url("css/contextual-clay.css?v=20261018-loading-scene");
100
+ @import url("css/popup-chat.css");
97
101
  @import url("css/mate-loading-scene.css");
package/lib/sdk-bridge.js CHANGED
@@ -1413,7 +1413,7 @@ function createSDKBridge(opts) {
1413
1413
  guardedPush.exactProductDelivery = !!(beforePush && beforePush.exactProductDelivery);
1414
1414
  session._queryGeneration = (session._queryGeneration || 0) + 1;
1415
1415
  session._queryStarting = true;
1416
- var starting = startQueryInner(session, text, images, linuxUser, guardedPush, onAccepted, recoveryState);
1416
+ var starting = startQueryInner(session, text, images, linuxUser, guardedPush, onAccepted, recoveryState, payload);
1417
1417
  if (recoveryState) recoveryState.starting = starting;
1418
1418
  try {
1419
1419
  var accepted = await starting;
@@ -1494,7 +1494,7 @@ function createSDKBridge(opts) {
1494
1494
  };
1495
1495
  }
1496
1496
 
1497
- async function startQueryInner(session, text, images, linuxUser, beforePush, onAccepted, recoveryState) {
1497
+ async function startQueryInner(session, text, images, linuxUser, beforePush, onAccepted, recoveryState, admissionPayload) {
1498
1498
  var sessionRuntimeEnv = getRuntimeEnv(session);
1499
1499
  var resumedAtQueryStart = !!session.cliSessionId;
1500
1500
  async function ensureVendorReady(vendor) {
@@ -1939,6 +1939,10 @@ function createSDKBridge(opts) {
1939
1939
  if (waiting) session._providerRecoveryWaiting = queryGeneration;
1940
1940
  else delete session._providerRecoveryWaiting;
1941
1941
  },
1942
+ onRecoveryDiagnostic: function(evidence) {
1943
+ if (sm.sessions.get(session.localId) !== session || Number(session._sdkQueryGeneration || 0) !== queryGeneration) return;
1944
+ console.info('[sdk-bridge] Codex recovery admission ' + JSON.stringify({ sessionId: session.localId, queryGeneration: queryGeneration, evidence: evidence }));
1945
+ },
1942
1946
  onRecoveryWaiting: function(waiting) {
1943
1947
  if (!queryContextAvailable()) return;
1944
1948
  sendAndRecord(session, { type: 'informational', level: 'info', content: waiting
@@ -2050,7 +2054,16 @@ function createSDKBridge(opts) {
2050
2054
  session.queryInstance = null;
2051
2055
  session.messageQueue = null;
2052
2056
  session.abortController = null;
2053
- sendAndRecord(session, { type: "error", text: "Failed to start query: " + (e.message || e) });
2057
+ var startupError = { type: "error", text: "Failed to start query: " + (e.message || e) };
2058
+ if (e.code === 'CODEX_RECOVERY_UNAVAILABLE' || e.code === 'CODEX_RECOVERY_PROBE_UNAVAILABLE') {
2059
+ // Keep undelivered input with the persisted failure, outside the live
2060
+ // input queue. A subsequent explicit send must not replay this batch.
2061
+ startupError.recoveryAdmission = { state: 'not_sent', messages: [admissionPayload].concat(session.pendingPush || []).filter(Boolean).map(function(message) {
2062
+ return { text: message.text, images: message.images || null };
2063
+ }) };
2064
+ startupError.text += ' Messages received during this wait were also not sent; they remain retained with this conversation. Send the messages you want to retry explicitly.';
2065
+ }
2066
+ sendAndRecord(session, startupError);
2054
2067
  sendAndRecord(session, { type: "done", code: 1 });
2055
2068
  sm.broadcastSessionList();
2056
2069
  return false;
@@ -3,6 +3,7 @@ var crypto = require('crypto');
3
3
  function attachOwnerUi(ctx) {
4
4
  var identities = new WeakMap(), capabilities = new WeakMap(), deliveries = new Map();
5
5
  var activityOrder = 0;
6
+ var viewObservation = require('./server-owner-view-observation').attachViewObservation({ check: check, timeout: ctx.renderTimeoutMs });
6
7
  function activity(owner, slug, sessionId, mateId, selected, source, validateSource, navigationTarget) {
7
8
  var order = ++activityOrder;
8
9
  var viewers = selected ? [check(owner, selected)] : sockets(owner).filter(function (viewer) {
@@ -80,6 +81,7 @@ function attachOwnerUi(ctx) {
80
81
  return Object.assign({}, record.result);
81
82
  }
82
83
  function handleMessage(ws, msg) {
84
+ if (viewObservation.handleMessage(ws, msg)) return true;
83
85
  if (['clay_ui_ready', 'clay_navigation_claim', 'clay_navigation_ack', 'clay_navigation_rendered'].indexOf(msg.type) < 0) return false;
84
86
  var owner = ctx.isMultiUser() ? ws._clayUser && ws._clayUser.id : null;
85
87
  if (!sockets(owner).some(function (item) { return item.ws === ws; })) return true;
@@ -95,7 +97,9 @@ function attachOwnerUi(ctx) {
95
97
  if (!record.renderClosed && Date.now() > record.created + (ctx.renderTimeoutMs || 5000)) { record.renderClosed = true; clearTimeout(record.renderTimer); record.result.renderStatus = 'unknown'; if (record.activity) record.activity.navigation('unknown'); }
96
98
  if (record.renderClosed || !record.claimed || !record.result.acknowledged || record.result.clientStatus !== 'accepted' || msg.renderToken !== record.renderToken) return true;
97
99
  var current = inspect(owner, record.viewer.ref);
98
- if (current.ws !== ws || msg.sessionOriginId !== record.target.sessionOriginId || !record.target.sessionOriginId || msg.projectSlug !== record.target.projectSlug || msg.sessionId !== record.target.sessionId || msg.surface !== 'conversation' || msg.entryIndex !== record.target.entryIndex || current.slug !== record.target.projectSlug || current.sessionId !== record.target.sessionId) throw new Error('Rendered target or authenticated viewer changed.');
100
+ if (record.target.surface === 'split_view') {
101
+ require('./clay-split-view-render-proof').validate(record.target, msg, current, ws);
102
+ } else if (current.ws !== ws || msg.sessionOriginId !== record.target.sessionOriginId || !record.target.sessionOriginId || msg.projectSlug !== record.target.projectSlug || msg.sessionId !== record.target.sessionId || msg.surface !== 'conversation' || msg.entryIndex !== record.target.entryIndex || current.slug !== record.target.projectSlug || current.sessionId !== record.target.sessionId) throw new Error('Rendered target or authenticated viewer changed.');
99
103
  record.renderClosed = true; clearTimeout(record.renderTimer);
100
104
  record.result.renderStatus = msg.visible === true ? 'rendered' : 'unknown'; record.result.visible = msg.visible === true ? true : 'unknown';
101
105
  if (record.activity) record.activity.navigation(msg.visible === true ? 'rendered' : 'unknown');
@@ -113,6 +117,6 @@ function attachOwnerUi(ctx) {
113
117
  } catch (error) { record.renderClosed = true; clearTimeout(record.renderTimer); record.result.visible = 'unknown'; record.result.status = 'failed'; record.result.error = error.message; record.result.retrySafe = false; if (record.activity) record.activity.cancel(); }
114
118
  return true;
115
119
  }
116
- return { list: sockets, inspect: inspect, check: check, deliver: deliver, read: read, handleMessage: handleMessage, activity: activity };
120
+ return { observeView: viewObservation.observe, list: sockets, inspect: inspect, check: check, deliver: deliver, read: read, handleMessage: handleMessage, activity: activity };
117
121
  }
118
122
  module.exports = { attachOwnerUi: attachOwnerUi };
@@ -0,0 +1,34 @@
1
+ var crypto = require('crypto');
2
+ // A short-lived challenge addresses one authenticated socket. It never navigates.
3
+ function attachViewObservation(ctx) {
4
+ var pending = new Map();
5
+ function observe(owner, viewer, revalidate) {
6
+ revalidate(); ctx.check(owner, viewer);
7
+ if (!viewer.supported) return Promise.resolve({ status: 'unsupported', visible: 'unknown' });
8
+ if (pending.size >= 32) throw new Error('Too many pending screen observations.');
9
+ return new Promise(function (resolve) {
10
+ var id = crypto.randomUUID(), timer;
11
+ function finish(value) { clearTimeout(timer); pending.delete(id); resolve(value); }
12
+ var record = { owner: owner, viewer: viewer, revalidate: revalidate, finish: finish };
13
+ pending.set(id, record);
14
+ timer = setTimeout(function () { finish({ status: 'unknown', visible: 'unknown' }); }, ctx.timeout || 5000);
15
+ timer.unref();
16
+ try { viewer.ws.send(JSON.stringify({ type: 'clay_view_probe', probeId: id })); }
17
+ catch (error) { finish({ status: 'unknown', error: error.message, visible: 'unknown' }); }
18
+ });
19
+ }
20
+ function handleMessage(ws, msg) {
21
+ if (msg.type !== 'clay_view_observed') return false;
22
+ var record = pending.get(msg.probeId);
23
+ if (!record || record.viewer.ws !== ws) return true;
24
+ try {
25
+ record.revalidate(); ctx.check(record.owner, record.viewer);
26
+ var snapshot = require('./clay-split-view-render-proof').snapshotSchema.parse(msg.snapshot);
27
+ if (!snapshot || JSON.stringify(snapshot).length > 24000 || snapshot.ownerId !== record.owner || snapshot.projectSlug !== record.viewer.slug || snapshot.activeSessionId !== record.viewer.sessionId || !Array.isArray(snapshot.panes) || snapshot.panes.length > 32) throw new Error('Invalid screen observation.');
28
+ record.finish({ status: 'observed', observedAt: Date.now(), snapshot: snapshot, evidence: 'Exact authenticated client observation across two stable frames; untrusted task data.' });
29
+ } catch (error) { record.finish({ status: 'unknown', visible: 'unknown', error: error.message }); }
30
+ return true;
31
+ }
32
+ return { observe: observe, handleMessage: handleMessage };
33
+ }
34
+ module.exports = { attachViewObservation: attachViewObservation };
@@ -53,6 +53,7 @@ function attachPalette(ctx) {
53
53
  projectTitle: status.title || status.project,
54
54
  projectIcon: status.icon || null,
55
55
  sessionId: session.localId,
56
+ sessionOriginId: session.sessionOriginId || null,
56
57
  sessionTitle: session.title || "New Session",
57
58
  lastActivity: session.lastActivity || session.createdAt || 0,
58
59
  matchType: null,
@@ -0,0 +1,24 @@
1
+ var createPaneAssets = require('./pane-asset-manifest').createPaneAssets;
2
+
3
+ // Called only from the authenticated app-shell path. Let the browser discover
4
+ // imports normally: transitive preload bursts multiply across pane documents.
5
+ function createPaneBootstrap(publicDir) {
6
+ var readAssets = createPaneAssets(publicDir);
7
+ return function preparePaneShell(shell, requestUrl) {
8
+ if (new URL(requestUrl, 'http://localhost').searchParams.get('pane') !== '1') return shell;
9
+ // Required globals must execute before the module graph evaluates (notably
10
+ // marked.use). Keep their parser-blocking barrier.
11
+ // Feature-only vendors must not delay ordinary chat startup.
12
+ shell = shell.replace(/<script src="([^" ]+)"><\/script>/g, function (tag, url) {
13
+ var group = url.indexOf('mermaid@') >= 0 ? 'mermaid' : url.indexOf('qrcode-generator@') >= 0 ? 'qr' : url.indexOf('vendor/codemirror/') === 0 ? 'editor' : null;
14
+ return group ? '<script type="application/json" data-pane-asset-group="' + group + '" data-src="' + url + '"></script>' : tag;
15
+ });
16
+ var assets = readAssets();
17
+ if (assets) {
18
+ shell = shell.replace(/(<script type="module" src=")app\.js(?:\?[^"<>]*)?("><\/script>)/, '$1' + assets.js + '$2');
19
+ shell = shell.replace(/(<link rel="stylesheet" href=")style\.css(?:\?[^"<>]*)?(">)/, '$1' + assets.css + '$2');
20
+ }
21
+ return shell;
22
+ };
23
+ }
24
+ module.exports = { createPaneBootstrap: createPaneBootstrap };
@@ -13,12 +13,14 @@ function attachSettings(ctx) {
13
13
 
14
14
  var profilePath = path.join(CONFIG_DIR, "profile.json");
15
15
  var contextualClaySettings = require('./server-contextual-clay-settings').attachContextualClaySettings(ctx);
16
+ var workbenchLayoutSettings = require('./server-workbench-layout-settings').attachWorkbenchLayoutSettings(ctx);
16
17
  var appearanceSettings = require('./server-appearance-settings').attachAppearanceSettings(ctx);
17
18
  var notificationSettings = require('./server-notification-settings').attachNotificationSettings(ctx);
18
19
  var experimentalSettings = attachExperimentalSettings(ctx);
19
20
 
20
21
  function handleRequest(req, res, fullUrl) {
21
22
  if (contextualClaySettings.handleRequest(req, res, fullUrl)) return true;
23
+ if (workbenchLayoutSettings.handleRequest(req, res, fullUrl)) return true;
22
24
  if (appearanceSettings.handleRequest(req, res, fullUrl)) return true;
23
25
  if (notificationSettings.handleRequest(req, res, fullUrl)) return true;
24
26
  if (experimentalSettings.handleRequest(req, res, fullUrl)) return true;
@@ -0,0 +1,52 @@
1
+ var fs = require('fs');
2
+ var path = require('path');
3
+ var crypto = require('crypto');
4
+
5
+ var MIME_TYPES = {
6
+ ".html": "text/html",
7
+ ".css": "text/css",
8
+ ".js": "application/javascript",
9
+ ".json": "application/json",
10
+ ".png": "image/png",
11
+ ".jpg": "image/jpeg",
12
+ ".jpeg": "image/jpeg",
13
+ ".gif": "image/gif",
14
+ ".webp": "image/webp",
15
+ ".bmp": "image/bmp",
16
+ ".svg": "image/svg+xml",
17
+ ".ico": "image/x-icon",
18
+ };
19
+
20
+ // This helper runs only after the caller's existing routing and authorization.
21
+ // Re-read and hash the actual bytes: timestamps/length cannot detect every edit.
22
+ function matches(header, etag) {
23
+ if (typeof header !== 'string') return false;
24
+ if (header.trim() === '*') return true;
25
+ var tag = '(?:W/)?"[\x21\x23-\x7e\x80-\xff]*"';
26
+ if (!new RegExp('^[ \t]*' + tag + '(?:[ \t]*,[ \t]*' + tag + ')*[ \t]*$').test(header)) return false;
27
+ return (header.match(new RegExp(tag, 'g')) || []).some(function (value) { return value.replace(/^W\//, '') === etag; });
28
+ }
29
+ function createStaticAssets(publicDir) {
30
+ return function serveStatic(urlPath, req, res) {
31
+ if (urlPath === '/') urlPath = '/index.html';
32
+ var filePath = path.join(publicDir, urlPath);
33
+ if (!filePath.startsWith(publicDir + path.sep)) {
34
+ res.writeHead(403); res.end('Forbidden'); return true;
35
+ }
36
+ var content;
37
+ try { content = fs.readFileSync(filePath); } catch (error) { return false; }
38
+ var ext = path.extname(filePath);
39
+ var mime = MIME_TYPES[ext] || 'application/octet-stream';
40
+ var isImage = ['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico'].indexOf(ext) >= 0;
41
+ var headers = { 'Content-Type': mime + (isImage ? '' : '; charset=utf-8'),
42
+ 'Cache-Control': isImage ? 'public, max-age=86400, immutable' : 'no-cache' };
43
+ if ((ext === '.js' || ext === '.css') && (req.method === 'GET' || req.method === 'HEAD')) {
44
+ headers.ETag = '"' + crypto.createHash('sha256').update(content).digest('hex') + '"';
45
+ if (matches(req.headers['if-none-match'], headers.ETag)) {
46
+ res.writeHead(304, headers); res.end(); return true;
47
+ }
48
+ }
49
+ res.writeHead(200, headers); res.end(content); return true;
50
+ };
51
+ }
52
+ module.exports = { createStaticAssets: createStaticAssets };
@@ -0,0 +1,56 @@
1
+ // User Settings deliberately have no canonical Clay control endpoint.
2
+ function attachWorkbenchLayoutSettings(ctx) {
3
+ function reply(res, status, data) {
4
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
5
+ res.end(JSON.stringify(data));
6
+ }
7
+ function identity(req) {
8
+ if (!ctx.isRequestAuthed(req)) return null;
9
+ var user = ctx.users.isMultiUser() ? ctx.getMultiUserFromReq(req) : { id: 'default' };
10
+ return user && (!ctx.users.isMultiUser() || ctx.users.findUserById(user.id)) ? user.id : null;
11
+ }
12
+ function handleRequest(req, res, url) {
13
+ if (url !== '/api/user/workbench-layout') return false;
14
+ var accountId = identity(req);
15
+ if (!accountId) { reply(res, 401, { error: 'Sign in to manage workbench layout.' }); return true; }
16
+ if (req.headers['x-clay-account-id'] !== accountId) { reply(res, 403, { error: 'Account changed. Reload and try again.' }); return true; }
17
+ if (req.method === 'GET') {
18
+ try { reply(res, 200, ctx.users.getWorkbenchLayoutPreference(accountId)); }
19
+ catch (error) { reply(res, 500, { error: 'Workbench layout could not be loaded. Try again.' }); }
20
+ return true;
21
+ }
22
+ if (req.method !== 'PUT') { reply(res, 405, { error: 'Method not allowed.' }); return true; }
23
+ if (req.headers['x-clay-settings'] !== '1' || req.headers['sec-fetch-site'] && ['same-origin', 'none'].indexOf(req.headers['sec-fetch-site']) < 0) {
24
+ reply(res, 403, { error: 'Save preferences from Clay.' }); return true;
25
+ }
26
+ if (req.headers.origin) {
27
+ try { if (new URL(req.headers.origin).host !== req.headers.host) throw new Error('Foreign origin'); }
28
+ catch (error) { reply(res, 403, { error: 'Save preferences from Clay.' }); return true; }
29
+ }
30
+ var body = '', finished = false;
31
+ req.setEncoding('utf8');
32
+ req.on('data', function (chunk) {
33
+ if (finished) return;
34
+ body += chunk;
35
+ if (Buffer.byteLength(body) > 1024) { finished = true; body = ''; reply(res, 413, { error: 'Request too large.' }); }
36
+ });
37
+ req.on('aborted', function () { finished = true; });
38
+ req.on('error', function () { finished = true; });
39
+ req.on('end', function () {
40
+ if (finished) return; finished = true;
41
+ if (identity(req) !== accountId) { reply(res, 401, { error: 'Account changed. Sign in again.' }); return; }
42
+ var input;
43
+ try { input = JSON.parse(body); } catch (error) { reply(res, 400, { error: 'Invalid JSON.' }); return; }
44
+ if (!input || Array.isArray(input) || Object.keys(input).length !== 2 || !Object.prototype.hasOwnProperty.call(input, 'mode') || !Object.prototype.hasOwnProperty.call(input, 'revision')) {
45
+ reply(res, 400, { error: 'Invalid workbench preference.' }); return;
46
+ }
47
+ try {
48
+ var result = ctx.users.setWorkbenchLayoutPreference(accountId, input.mode, input.revision);
49
+ reply(res, result.error ? result.status || 400 : 200, result);
50
+ } catch (error) { reply(res, 500, { error: 'Workbench layout could not be saved. Your previous layout is retained.' }); }
51
+ });
52
+ return true;
53
+ }
54
+ return { handleRequest: handleRequest };
55
+ }
56
+ module.exports = { attachWorkbenchLayoutSettings: attachWorkbenchLayoutSettings };
package/lib/server.js CHANGED
@@ -70,20 +70,8 @@ function httpGetBinary(url) {
70
70
  }
71
71
 
72
72
 
73
- var MIME_TYPES = {
74
- ".html": "text/html",
75
- ".css": "text/css",
76
- ".js": "application/javascript",
77
- ".json": "application/json",
78
- ".png": "image/png",
79
- ".jpg": "image/jpeg",
80
- ".jpeg": "image/jpeg",
81
- ".gif": "image/gif",
82
- ".webp": "image/webp",
83
- ".bmp": "image/bmp",
84
- ".svg": "image/svg+xml",
85
- ".ico": "image/x-icon",
86
- };
73
+ var serveStatic = require('./server-static-assets').createStaticAssets(publicDir);
74
+ var preparePaneShell = require('./server-pane-bootstrap').createPaneBootstrap(publicDir);
87
75
 
88
76
  var wsRateLimit = require("./ws-rate-limit");
89
77
  var applyWsRateLimit = wsRateLimit.applyWsRateLimit;
@@ -91,34 +79,6 @@ var WS_RATE_LIMIT = wsRateLimit.WS_RATE_LIMIT;
91
79
  var generateAuthToken = serverAuth.generateAuthToken;
92
80
  var verifyPin = serverAuth.verifyPin;
93
81
 
94
- function serveStatic(urlPath, res) {
95
- if (urlPath === "/") urlPath = "/index.html";
96
-
97
- var filePath = path.join(publicDir, urlPath);
98
-
99
- if (!filePath.startsWith(publicDir)) {
100
- res.writeHead(403);
101
- res.end("Forbidden");
102
- return true;
103
- }
104
-
105
- try {
106
- var content = fs.readFileSync(filePath);
107
- var ext = path.extname(filePath);
108
- var mime = MIME_TYPES[ext] || "application/octet-stream";
109
- var isImage = ext === ".png" || ext === ".jpg" || ext === ".jpeg" || ext === ".gif" || ext === ".svg" || ext === ".webp" || ext === ".ico";
110
- var cacheControl = isImage ? "public, max-age=86400, immutable" : "no-cache";
111
- res.writeHead(200, {
112
- "Content-Type": mime + (isImage ? "" : "; charset=utf-8"),
113
- "Cache-Control": cacheControl,
114
- });
115
- res.end(content);
116
- return true;
117
- } catch (e) {
118
- return false;
119
- }
120
- }
121
-
122
82
  /**
123
83
  * Extract slug from URL path: /p/{slug}/... → slug
124
84
  * Returns null if path doesn't match /p/{slug}
@@ -565,6 +525,7 @@ function createServer(opts) {
565
525
  try {
566
526
  var shell = fs.readFileSync(path.join(publicDir, "index.html"), "utf8");
567
527
  if (slug) shell = injectRootProjectSlug(shell, slug);
528
+ shell = preparePaneShell(shell, req.url);
568
529
  var appearance = null;
569
530
  try { appearance = settings.getInitialAppearance(req); } catch (e) {}
570
531
  shell = injectAppearanceSeed(shell, appearance);
@@ -1076,7 +1037,7 @@ function createServer(opts) {
1076
1037
  sendAppShell(req, res, null);
1077
1038
  return;
1078
1039
  }
1079
- if (serveStatic(fullUrl, res)) return;
1040
+ if (serveStatic(fullUrl, req, res)) return;
1080
1041
  }
1081
1042
 
1082
1043
  // Project-scoped routes: /p/{slug}/...
@@ -1175,7 +1136,7 @@ function createServer(opts) {
1175
1136
  sendAppShell(req, res, slug);
1176
1137
  return;
1177
1138
  }
1178
- if (serveStatic(projectUrl, res)) return;
1139
+ if (serveStatic(projectUrl, req, res)) return;
1179
1140
  }
1180
1141
 
1181
1142
  res.writeHead(404);
@@ -1230,7 +1191,7 @@ function createServer(opts) {
1230
1191
 
1231
1192
  // Static files at root (favicon, manifest, icons, etc.)
1232
1193
  if (url.lastIndexOf("/") === 0 && !url.includes("..")) {
1233
- if (serveStatic(url, res)) return;
1194
+ if (serveStatic(url, req, res)) return;
1234
1195
  }
1235
1196
 
1236
1197
  // Everything else → redirect to HTTPS setup
@@ -0,0 +1,28 @@
1
+ function count(value) { return value && typeof value === 'object' ? Object.keys(value).length : 0; }
2
+ function sessionActivity(session) {
3
+ if (session.isProcessing) return 'processing';
4
+ if (count(session.pendingPermissions)) return 'waiting_for_approval';
5
+ return 'idle';
6
+ }
7
+ function currentState(session) {
8
+ var approvals = count(session.pendingPermissions);
9
+ var input = count(session.pendingAskUser) + count(session.pendingUserDialogs) + count(session.pendingElicitations);
10
+ var processing = session.isProcessing === true, starting = session._queryStarting === true;
11
+ var recoveryStopPending = !!session._responseRecoveryStopPending;
12
+ var questions = count(session.pendingAskUser), dialogs = count(session.pendingUserDialogs), elicitations = count(session.pendingElicitations);
13
+ var stopRequested = session.taskStopRequested === true, queuePaused = session._pendingMessageDrainPaused === true;
14
+ // Review requests are a subset of pending questions, never an approval result.
15
+ var reviews = Object.values(session.pendingAskUser || {}).filter(function (pending) { return pending && pending.request && ['product-control', 'mcp-import', 'mate-schedule'].indexOf(pending.request.source) >= 0; }).length;
16
+ var state = 'unknown';
17
+ if (stopRequested || queuePaused) state = processing || starting || recoveryStopPending ? 'stopping' : 'stopped';
18
+ else if (approvals || reviews) state = 'awaiting_approval';
19
+ else if (input) state = 'awaiting_input';
20
+ else if (recoveryStopPending) state = 'recovering';
21
+ else if (processing || starting) state = 'working';
22
+ else if (session.isProcessing === false) state = 'idle';
23
+ return { state: state, observedAt: Date.now(), recoveryStopPending: recoveryStopPending, queryGeneration: Number.isInteger(session._sdkQueryGeneration) ? session._sdkQueryGeneration : null,
24
+ pendingReviewCount: reviews, pendingQuestionCount: questions, pendingDialogCount: dialogs, pendingElicitationCount: elicitations, processing: processing, starting: starting, stopRequested: stopRequested, queuePaused: queuePaused,
25
+ pendingApprovalCount: approvals, pendingInputCount: input, providerConnection: 'unknown',
26
+ evidence: 'Live server session flags and pending request registries; idle does not establish task completion.' };
27
+ }
28
+ module.exports = { sessionActivity: sessionActivity, currentState: currentState };
@@ -0,0 +1,17 @@
1
+ // Cache only an already authorized, serialized projection for this live socket.
2
+ // Callers must recompute visibility before every delivery; this is not an access
3
+ // cache. Ordinary refreshes always send, even when their content is unchanged.
4
+ function createSessionListDelivery() {
5
+ var delivered = new WeakMap();
6
+ var MAX_CACHED_BYTES = 1024 * 1024;
7
+ return function (ws, message, skipUnchanged) {
8
+ if (!ws || ws.readyState !== 1) { if (ws) delivered.delete(ws); return false; }
9
+ var encoded = JSON.stringify(message);
10
+ if (skipUnchanged && delivered.get(ws) === encoded) return false;
11
+ ws.send(encoded);
12
+ if (Buffer.byteLength(encoded) <= MAX_CACHED_BYTES) delivered.set(ws, encoded);
13
+ else delivered.delete(ws);
14
+ return true;
15
+ };
16
+ }
17
+ module.exports = { createSessionListDelivery: createSessionListDelivery };
@@ -3,10 +3,9 @@
3
3
  // A human Stop is a cancellation barrier, not merely an AbortController
4
4
  // signal. It blocks delegation and Worker replacement until the next ordinary
5
5
  // human message reaches the Driver. Internal result and permission messages do
6
- // not clear it. Creation budgets and operation ids also make retries bounded
6
+ // not clear it. Replacement budgets and operation ids also make retries bounded
7
7
  // and idempotent even when no Stop occurred.
8
8
 
9
- var MAX_CREATIONS_PER_TURN = 2;
10
9
  var MAX_REPLACEMENTS_PER_TURN = 1;
11
10
  var MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN = 2;
12
11
  var MAX_OPERATION_ID_CHARS = 120;
@@ -101,9 +100,6 @@ function attachPairTurnControl(ctx) {
101
100
  if (kind === "replace" && state.failedReplacementAttempts >= MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN) {
102
101
  throw new Error("the Split Worker replacement failure limit for this human turn has been reached; wait for a new human message before retrying replacement");
103
102
  }
104
- if (state.creations >= MAX_CREATIONS_PER_TURN) {
105
- throw new Error("the Split Worker creation limit for this human turn has been reached; wait for a new human message before creating another Worker");
106
- }
107
103
  state.creations += 1;
108
104
  if (kind === "replace") state.replacements += 1;
109
105
  return { driver: driver, kind: kind, turnSerial: state.serial, active: true };
@@ -172,7 +168,6 @@ function attachPairTurnControl(ctx) {
172
168
  }
173
169
 
174
170
  module.exports = {
175
- MAX_CREATIONS_PER_TURN: MAX_CREATIONS_PER_TURN,
176
171
  MAX_REPLACEMENTS_PER_TURN: MAX_REPLACEMENTS_PER_TURN,
177
172
  MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN: MAX_FAILED_REPLACEMENT_ATTEMPTS_PER_TURN,
178
173
  attachPairTurnControl: attachPairTurnControl,
@@ -539,6 +539,7 @@ function searchPalette(projectSessions, query, opts) {
539
539
  projectTitle: proj.projectTitle,
540
540
  projectIcon: proj.projectIcon || null,
541
541
  sessionId: session.localId,
542
+ sessionOriginId: session.sessionOriginId || null,
542
543
  sessionTitle: title,
543
544
  lastActivity: session.lastActivity || session.createdAt || 0,
544
545
  isMate: proj.isMate || false,
@@ -561,6 +562,7 @@ function searchPalette(projectSessions, query, opts) {
561
562
  projectTitle: proj.projectTitle,
562
563
  projectIcon: proj.projectIcon || null,
563
564
  sessionId: session.localId,
565
+ sessionOriginId: session.sessionOriginId || null,
564
566
  sessionTitle: title,
565
567
  lastActivity: session.lastActivity || session.createdAt || 0,
566
568
  isMate: proj.isMate || false,
@@ -592,6 +594,7 @@ function searchPalette(projectSessions, query, opts) {
592
594
  projectTitle: m.projectTitle,
593
595
  projectIcon: m.projectIcon,
594
596
  sessionId: m.sessionId,
597
+ sessionOriginId: m.sessionOriginId,
595
598
  sessionTitle: m.sessionTitle,
596
599
  lastActivity: m.lastActivity,
597
600
  matchType: m.matchType,
@@ -46,7 +46,7 @@ function versionedMemberAnchors(group, sessions) {
46
46
 
47
47
  function resolveVersionedPair(sessions, anchors) {
48
48
  if (!anchors || anchors.version !== 2 || !anchors.driver || !Array.isArray(anchors.workers) ||
49
- (anchors.workers.length !== 1 && anchors.workers.length !== 2)) return null;
49
+ anchors.workers.length < 1) return null;
50
50
  var driver = findSession(sessions, anchors.driver.cli, anchors.driver.origin);
51
51
  if (!driver) return null;
52
52
  var workers = [];
@@ -62,7 +62,7 @@ function resolveVersionedPair(sessions, anchors) {
62
62
 
63
63
  function resolveVersionedMembers(sessions, anchors) {
64
64
  if (!anchors || anchors.version !== 2 || !Array.isArray(anchors.members) ||
65
- (anchors.members.length !== 2 && anchors.members.length !== 3)) return null;
65
+ anchors.members.length < 2) return null;
66
66
  var resolved = [];
67
67
  for (var i = 0; i < anchors.members.length; i++) {
68
68
  var anchor = anchors.members[i];
@@ -1,7 +1,7 @@
1
1
  // Shared role and target identity rules for Split Groups.
2
2
  //
3
3
  // Version 1 is the persisted two-session pair shape. Version 2 describes one
4
- // Driver and up to two Workers, but callers must explicitly opt into it until
4
+ // Driver and one or more Workers, but callers must explicitly opt into it until
5
5
  // all production consumers can address more than one Worker.
6
6
  // Remaining direct pair.workerId reads are deliberate compatibility branches:
7
7
  // session-split-groups owns legacy-only create and Driver-transfer transactions,
@@ -9,8 +9,6 @@
9
9
  // Production activation must still migrate the client hierarchy, pane lock, and
10
10
  // split-group helper projections before the server gate is enabled.
11
11
 
12
- var MAX_WORKERS = 2;
13
-
14
12
  function isInteger(value) {
15
13
  return Number.isInteger(value);
16
14
  }
@@ -22,8 +20,8 @@ function containsOnce(values, value) {
22
20
  }
23
21
 
24
22
  function validateMembers(members) {
25
- if (!Array.isArray(members) || (members.length !== 2 && members.length !== 3)) {
26
- return { ok: false, error: "A split group requires exactly two sessions until multi-Worker consumers are enabled" };
23
+ if (!Array.isArray(members) || members.length < 2) {
24
+ return { ok: false, error: "A split group requires at least two sessions" };
27
25
  }
28
26
  for (var i = 0; i < members.length; i++) {
29
27
  if (!isInteger(members[i])) return { ok: false, error: "Split group members must be distinct integer session ids" };
@@ -34,8 +32,8 @@ function validateMembers(members) {
34
32
 
35
33
  function validateRoleIds(driverId, workerIds, members) {
36
34
  if (!isInteger(driverId)) return { ok: false, error: "The Driver role must reference an integer session id" };
37
- if (!Array.isArray(workerIds) || workerIds.length < 1 || workerIds.length > MAX_WORKERS) {
38
- return { ok: false, error: "A configured role requires one to two Workers" };
35
+ if (!Array.isArray(workerIds) || workerIds.length < 1) {
36
+ return { ok: false, error: "A configured role requires at least one Worker" };
39
37
  }
40
38
  if (!containsOnce(members, driverId)) return { ok: false, error: "The Driver role must reference exactly one split group member" };
41
39
  var seen = [driverId];
@@ -150,7 +148,6 @@ function tokenMatchesWorker(worker, token) {
150
148
  }
151
149
 
152
150
  module.exports = {
153
- MAX_WORKERS: MAX_WORKERS,
154
151
  normalizePair: normalizePair,
155
152
  matchesLiveTarget: matchesLiveTarget,
156
153
  resolveLiveRoles: resolveLiveRoles,
@@ -102,9 +102,6 @@ function attachVersionedMutations(ctx) {
102
102
  if (denied) return denied;
103
103
  var exact = exactGroup(ownerId, msg || {}, false);
104
104
  if (exact.error) return { ok: false, error: exact.error };
105
- if (exact.normalized.workerIds.length >= splitRoles.MAX_WORKERS) {
106
- return { ok: false, error: "A Driver can have at most two Workers" };
107
- }
108
105
  var added = newWorker(ownerId, msg);
109
106
  if (added.error) return { ok: false, error: added.error };
110
107
  var workerIds = exact.normalized.workerIds.concat([added.worker.localId]);
package/lib/sessions.js CHANGED
@@ -35,6 +35,7 @@ function createSessionManager(opts) {
35
35
  var slashCommands = null; // shared across sessions (deprecated, use slashCommandsByVendor)
36
36
  var slashCommandsByVendor = {}; // vendor -> array of slash commands
37
37
  var skillNames = null; // Claude-only skills to filter from slash menu
38
+ var deliverSessionList = require('./session-list-delivery').createSessionListDelivery();
38
39
  var singleUserUnread = {}; // sessionLocalId -> unread count (single-user mode)
39
40
  var permissionRequestIndex = {}; // requestId -> sessionLocalId (O(1) lookup)
40
41
  var capabilitiesByVendor = null; // set by sdk-bridge after adapter init
@@ -652,7 +653,7 @@ function createSessionManager(opts) {
652
653
  });
653
654
  }
654
655
 
655
- function broadcastSessionList() {
656
+ function broadcastSessionList(skipUnchanged) {
656
657
  var allVisible = getVisibleSessions();
657
658
  if (sendEach) {
658
659
  // Per-client filtering (multi-user mode)
@@ -661,10 +662,10 @@ function createSessionManager(opts) {
661
662
  var clientActiveId = ws._clayActiveSession;
662
663
  var wsUnread = ws._clayUnread || {};
663
664
  if (ws.readyState === 1) {
664
- ws.send(JSON.stringify({
665
+ deliverSessionList(ws, {
665
666
  type: "session_list",
666
667
  sessions: mapSessionsForClient(filtered, clientActiveId, wsUnread, ws._clayUser ? ws._clayUser.id : null),
667
- }));
668
+ }, skipUnchanged === true);
668
669
  }
669
670
  });
670
671
  } else {
@@ -879,7 +880,7 @@ function createSessionManager(opts) {
879
880
  || session.slashCommands
880
881
  || [];
881
882
  _send({ type: "slash_commands", commands: _vendorCmds, vendor: _sessionVendor });
882
- broadcastSessionList();
883
+ broadcastSessionList(!!(snapshotSend && targetWs && targetWs._clayPane));
883
884
  replayHistory(session, undefined, targetWs, transform, snapshotSend);
884
885
 
885
886
  if (session.isProcessing) {
@@ -973,9 +974,18 @@ function createSessionManager(opts) {
973
974
  }
974
975
  }
975
976
 
976
- function deleteSessionQuiet(localId) {
977
+ function deleteSessionQuiet(localId, options) {
977
978
  var session = sessions.get(localId);
978
979
  if (!session) return;
980
+ // Exact reviewed removal must observe durable deletion before releasing identity.
981
+ var strictRemoval = options && options.requireDurableRemoval === true;
982
+ if (strictRemoval) {
983
+ var strictRecordId = existingSessionRecordId(session);
984
+ if (strictRecordId) {
985
+ try { fs.unlinkSync(sessionFilePath(strictRecordId)); }
986
+ catch (error) { if (error.code !== "ENOENT") throw error; }
987
+ }
988
+ }
979
989
  delete singleUserUnread[localId];
980
990
  cleanupMentionSessions(session);
981
991
  if (session.abortController) {
@@ -995,7 +1005,7 @@ function createSessionManager(opts) {
995
1005
  }
996
1006
  if (session.cliSessionId) rememberDeletedCliSession(session.cliSessionId);
997
1007
  var recordId = existingSessionRecordId(session);
998
- if (recordId) try { fs.unlinkSync(sessionFilePath(recordId)); } catch(e) {}
1008
+ if (recordId && !strictRemoval) try { fs.unlinkSync(sessionFilePath(recordId)); } catch(e) {}
999
1009
  sessions.delete(localId);
1000
1010
  try { onSessionDeleted(localId, session); } catch (e) {
1001
1011
  console.error("[session] Session deletion hook failed:", e.message || e);