clay-server 4.2.0-beta.1 → 4.2.0-beta.3

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 (36) hide show
  1. package/lib/project-connection.js +5 -2
  2. package/lib/project-full-access.js +69 -56
  3. package/lib/project-sessions.js +126 -26
  4. package/lib/project-user-message.js +5 -2
  5. package/lib/project.js +1 -0
  6. package/lib/public/app.js +6 -0
  7. package/lib/public/css/filebrowser.css +20 -0
  8. package/lib/public/css/messages.css +27 -0
  9. package/lib/public/css/overlays.css +60 -0
  10. package/lib/public/css/pane.css +21 -4
  11. package/lib/public/css/pending-message-queue.css +5 -0
  12. package/lib/public/index.html +8 -3
  13. package/lib/public/modules/app-connection.js +4 -0
  14. package/lib/public/modules/app-header.js +18 -25
  15. package/lib/public/modules/app-messages.js +17 -7
  16. package/lib/public/modules/app-panels.js +22 -11
  17. package/lib/public/modules/clay-file-links.js +7 -0
  18. package/lib/public/modules/clay-log-links.js +4 -0
  19. package/lib/public/modules/github-links.js +115 -0
  20. package/lib/public/modules/markdown.js +9 -2
  21. package/lib/public/modules/mcp-ui.js +154 -17
  22. package/lib/public/modules/pane-bridge.js +2 -0
  23. package/lib/public/modules/pane-file-bridge.js +69 -0
  24. package/lib/public/modules/pending-message-queue-model.js +6 -0
  25. package/lib/public/modules/pending-message-queue-recovery.js +74 -0
  26. package/lib/public/modules/pending-message-queue.js +70 -67
  27. package/lib/public/modules/permission-control.js +187 -0
  28. package/lib/public/modules/split-view.js +29 -29
  29. package/lib/sdk-bridge.js +95 -40
  30. package/lib/session-permission-mode.js +42 -0
  31. package/lib/sessions.js +14 -1
  32. package/lib/ws-schema.js +2 -0
  33. package/lib/yoke/adapters/claude-worker-policy.js +67 -0
  34. package/lib/yoke/adapters/claude-worker.js +47 -7
  35. package/lib/yoke/adapters/claude.js +114 -6
  36. package/package.json +1 -1
@@ -0,0 +1,187 @@
1
+ // Shared session permission selector for standalone and split Driver headers.
2
+
3
+ import { store } from './store.js';
4
+ import { getWs } from './ws-ref.js';
5
+ import { showToast } from './utils.js';
6
+
7
+ var REQUEST_TIMEOUT_MS = 10000;
8
+ var requestTimers = {};
9
+
10
+ function targetKey(projectSlug, sessionId) {
11
+ return String(projectSlug || "") + ":" + String(sessionId || "");
12
+ }
13
+
14
+ function pendingMap() {
15
+ return store.get('permissionModePendingByTarget') || {};
16
+ }
17
+
18
+ export function permissionPendingFor(projectSlug, sessionId) {
19
+ return pendingMap()[targetKey(projectSlug, sessionId)] || null;
20
+ }
21
+
22
+ function replacePending(key, value) {
23
+ var next = Object.assign({}, pendingMap());
24
+ if (value) next[key] = value;
25
+ else delete next[key];
26
+ store.set({ permissionModePendingByTarget: next });
27
+ }
28
+
29
+ function clearRequest(requestId, key) {
30
+ if (requestTimers[requestId]) clearTimeout(requestTimers[requestId]);
31
+ delete requestTimers[requestId];
32
+ var pending = pendingMap()[key];
33
+ if (pending && pending.requestId === requestId) replacePending(key, null);
34
+ }
35
+
36
+ export function clearPermissionModePending() {
37
+ var ids = Object.keys(requestTimers);
38
+ for (var i = 0; i < ids.length; i++) clearTimeout(requestTimers[ids[i]]);
39
+ requestTimers = {};
40
+ store.set({ permissionModePendingByTarget: {} });
41
+ }
42
+
43
+ export function createPermissionControl(className, ariaLabel) {
44
+ var control = document.createElement("div");
45
+ control.className = "session-permission-control " + (className || "");
46
+ control.setAttribute("role", "group");
47
+ control.setAttribute("aria-label", ariaLabel || "Session permission mode");
48
+ control.innerHTML = '<div class="session-permission-segmented">' +
49
+ '<button type="button" data-permission-mode="default">Ask</button>' +
50
+ '<button type="button" data-permission-mode="auto">Auto</button>' +
51
+ '<button type="button" data-permission-mode="bypassPermissions" aria-label="Skip permissions"><span class="permission-label-long">Skip permissions</span><span class="permission-label-short" aria-hidden="true">Skip</span></button>' +
52
+ '</div><span class="session-permission-status" aria-live="polite"></span>';
53
+ return control;
54
+ }
55
+
56
+ function modeLabel(mode) {
57
+ var labels = { default: "Ask", plan: "Plan", acceptEdits: "Auto-accept edits", dontAsk: "Don't ask",
58
+ bypassPermissions: "Skip permissions", auto: "Auto" };
59
+ return labels[mode] || mode;
60
+ }
61
+
62
+ export function permissionStatusText(state, pending, autoSupported, autoVisible) {
63
+ if (state.globalPermissionModeForced) return "Skip permissions forced by the server setting";
64
+ if (pending) return "Applying…";
65
+ if (state.permissionMode === "plan") return "Plan workflow";
66
+ if (state.permissionMode === "acceptEdits") return "Auto-accept edits workflow";
67
+ if (state.permissionMode === "dontAsk") return "Don't ask workflow";
68
+ if (autoVisible && !autoSupported) return "Auto unavailable for this Claude session";
69
+ if (state.permissionMode === "auto" && !state.effectivePermissionMode) return "Auto selected · waiting for runtime";
70
+ if (state.permissionMode === "auto" && state.effectivePermissionMode !== "auto") {
71
+ return "Auto unavailable at runtime · using " + modeLabel(state.effectivePermissionMode);
72
+ }
73
+ return "";
74
+ }
75
+
76
+ function compactPermissionStatusText(state, pending, autoSupported, autoVisible, fullText) {
77
+ if (state.globalPermissionModeForced) return "Forced Skip";
78
+ if (pending) return "Applying…";
79
+ if (state.permissionMode === "plan") return "Plan";
80
+ if (state.permissionMode === "acceptEdits") return "Auto-accept";
81
+ if (state.permissionMode === "dontAsk") return "Don't ask";
82
+ if (autoVisible && !autoSupported) return "Auto unavailable";
83
+ if (state.permissionMode === "auto" && !state.effectivePermissionMode) return "Waiting for Auto";
84
+ if (state.permissionMode === "auto" && state.effectivePermissionMode !== "auto") return "Using " + modeLabel(state.effectivePermissionMode);
85
+ return fullText;
86
+ }
87
+
88
+ export function renderPermissionControl(control, state) {
89
+ if (!control) return;
90
+ var projectSlug = state.projectSlug || "";
91
+ var sessionId = state.sessionId || null;
92
+ var pending = permissionPendingFor(projectSlug, sessionId);
93
+ var autoVisible = state.vendor === "claude";
94
+ var autoSupported = !!(state.permissionCapabilities && state.permissionCapabilities.auto === true);
95
+ var globallyForced = state.globalPermissionModeForced === true;
96
+ var connected = state.connected !== false && !!getWs() && getWs().readyState === 1;
97
+ control.classList.toggle("hidden", state.visible === false);
98
+ control.classList.toggle("permission-pending", !!pending);
99
+ control.classList.toggle("permission-auto-unsupported", autoVisible && !autoSupported);
100
+ control.classList.toggle("permission-globally-forced", globallyForced);
101
+ control.classList.toggle("permission-runtime-fallback", state.permissionMode === "auto" && !!state.effectivePermissionMode && state.effectivePermissionMode !== "auto");
102
+ var buttons = control.querySelectorAll("[data-permission-mode]");
103
+ for (var i = 0; i < buttons.length; i++) {
104
+ var mode = buttons[i].dataset.permissionMode;
105
+ var unsupported = mode === "auto" && autoVisible && !autoSupported;
106
+ buttons[i].hidden = mode === "auto" && !autoVisible;
107
+ var selected = globallyForced ? mode === "bypassPermissions" : mode === state.permissionMode;
108
+ buttons[i].disabled = globallyForced || unsupported || !!pending || !connected || state.locked === true;
109
+ buttons[i].classList.toggle("active", selected);
110
+ buttons[i].setAttribute("aria-pressed", selected ? "true" : "false");
111
+ if (unsupported) buttons[i].title = "Auto permissions are unavailable for this Claude session.";
112
+ else buttons[i].removeAttribute("title");
113
+ }
114
+ var status = control.querySelector(".session-permission-status");
115
+ if (status) {
116
+ var fullStatus = permissionStatusText(state, pending, autoSupported, autoVisible);
117
+ var compact = control.classList.contains("split-pane-full-access");
118
+ status.textContent = compact ? compactPermissionStatusText(state, pending, autoSupported, autoVisible, fullStatus) : fullStatus;
119
+ status.classList.toggle("hidden", !status.textContent);
120
+ if (fullStatus) status.title = fullStatus;
121
+ else status.removeAttribute("title");
122
+ }
123
+ }
124
+
125
+ export function bindPermissionControl(control, getState) {
126
+ if (!control || control.dataset.permissionBound === "true") return;
127
+ control.dataset.permissionBound = "true";
128
+ control.addEventListener("click", function(event) {
129
+ var button = event.target.closest("[data-permission-mode]");
130
+ if (!button || button.disabled) return;
131
+ var state = getState() || {};
132
+ var mode = button.dataset.permissionMode;
133
+ var autoSupported = !!(state.permissionCapabilities && state.permissionCapabilities.auto === true);
134
+ if (state.visible === false || state.locked === true || state.globalPermissionModeForced === true || state.connected === false || store.get('connected') === false) return;
135
+ if (!state.projectSlug || state.projectSlug !== store.get('currentSlug') || !state.sessionId) return;
136
+ if (mode === "auto" && (state.vendor !== "claude" || !autoSupported)) return;
137
+ sendPermissionMode(state.projectSlug, state.sessionId, mode);
138
+ });
139
+ }
140
+
141
+ export function sendPermissionMode(projectSlug, sessionId, mode) {
142
+ var ws = getWs();
143
+ if (store.get('skipPermsEnabled') === true) {
144
+ showToast("Skip permissions is forced by the server setting.", "error");
145
+ return false;
146
+ }
147
+ if (!projectSlug || !sessionId || !ws || ws.readyState !== 1) {
148
+ showToast("Permission mode could not be changed while disconnected.", "error");
149
+ return false;
150
+ }
151
+ var key = targetKey(projectSlug, sessionId);
152
+ if (permissionPendingFor(projectSlug, sessionId)) return false;
153
+ var requestId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + ":" + String(sessionId);
154
+ replacePending(key, { requestId: requestId, projectSlug: projectSlug, sessionId: sessionId, mode: mode });
155
+ requestTimers[requestId] = setTimeout(function() {
156
+ clearRequest(requestId, key);
157
+ showToast("Permission mode change timed out. The previous mode is still shown.", "error");
158
+ }, REQUEST_TIMEOUT_MS);
159
+ try {
160
+ var sendResult = ws.send(JSON.stringify({ type: "set_permission_mode", projectSlug: projectSlug, sessionId: sessionId, mode: mode, requestId: requestId }));
161
+ if (sendResult === false) throw new Error("The permission transport declined the request.");
162
+ } catch (error) {
163
+ clearRequest(requestId, key);
164
+ showToast("Permission mode could not be sent: " + (error.message || error), "error");
165
+ return false;
166
+ }
167
+ return true;
168
+ }
169
+
170
+ export function handlePermissionModeResult(msg) {
171
+ var key = targetKey(msg.projectSlug, msg.sessionId);
172
+ var pending = pendingMap()[key];
173
+ if (!pending || pending.requestId !== msg.requestId) return;
174
+ clearRequest(msg.requestId, key);
175
+ if (!msg.ok) {
176
+ showToast(msg.error || "Permission mode could not be changed.", "error");
177
+ return;
178
+ }
179
+ if (msg.projectSlug === store.get('currentSlug') && msg.sessionId === store.get('activeSessionId')) {
180
+ store.set({
181
+ currentMode: msg.mode || "default",
182
+ effectivePermissionMode: Object.prototype.hasOwnProperty.call(msg, 'effectivePermissionMode') ? msg.effectivePermissionMode : null,
183
+ permissionCapabilities: msg.permissionCapabilities || store.get('permissionCapabilities'),
184
+ mcpPermissionModeOverrides: msg.mcpPermissionModeOverrides || {},
185
+ });
186
+ }
187
+ }
@@ -8,13 +8,15 @@ import { detachTuiView } from './session-tui-view.js';
8
8
  import { formatTokens } from './app-panels.js';
9
9
  import { VENDOR_AVATARS, VENDOR_NAMES } from './app-rendering.js';
10
10
  import { groupedSessionIds, findSplitGroup, isConfiguredWorker } from './split-group-helpers.js';
11
- import { showConfirm } from './app-misc.js';
12
11
  import { syncPairChrome } from './split-pair-ui.js';
13
12
  import { presentMarkdownEdit } from './filebrowser.js';
14
13
  import { autoStartLoginIfNeeded } from './vendor-login.js';
15
14
  import { isCurrentProjectSessionReady } from './split-session-boundary.js';
16
15
  import { splitPanesMatchProject, hasCurrentSplitGroup, reconcileRestoredSplit } from './project-activation.js';
17
16
  import { handlePaneIssueMessage } from './pane-issue-bridge.js';
17
+ import { handlePaneFileMessage, revealFilesPanel } from './pane-file-bridge.js';
18
+ import { openFile } from './filebrowser.js';
19
+ import { bindPermissionControl, createPermissionControl, renderPermissionControl } from './permission-control.js';
18
20
 
19
21
  var host = null;
20
22
  var nativeApp = null;
@@ -161,25 +163,28 @@ export function syncPaneTitles() {
161
163
  syncPairChrome(host, split);
162
164
  }
163
165
 
164
- function updatePaneFullAccessButton(button, session) {
165
- if (!button) return;
166
+ function panePermissionControlState(session) {
166
167
  var mode = session && (session.runtimeMode || session.mode || "gui");
167
168
  var worker = !!session && isConfiguredWorker(store.get('splitGroups'), session.id);
168
- // Permission policy belongs to the Driver. A configured Worker inherits it
169
- // server-side and must not present a second, misleading control.
170
- var visible = !!session && !worker && !store.get('skipPermsEnabled') && mode === "gui";
171
- var enabled = visible && session.permissionMode === "bypassPermissions";
172
- button.classList.toggle("hidden", !visible);
173
- button.classList.toggle("active", enabled);
174
- button.setAttribute("aria-checked", enabled ? "true" : "false");
175
- button.title = enabled ? "Skip Permissions is on — click to restore permission prompts" : "Skip Permissions is off — permission prompts are on";
169
+ return {
170
+ projectSlug: store.get('currentSlug'),
171
+ sessionId: session && session.id,
172
+ vendor: session && session.vendor || store.get('currentVendor') || "claude",
173
+ permissionMode: session && session.permissionMode || "default",
174
+ effectivePermissionMode: session && session.effectivePermissionMode || null,
175
+ permissionCapabilities: session && session.permissionCapabilities || { auto: false, mcpOverride: false },
176
+ connected: store.get('connected'),
177
+ globalPermissionModeForced: store.get('skipPermsEnabled') === true,
178
+ visible: !!session && !worker && mode === "gui",
179
+ locked: worker,
180
+ };
176
181
  }
177
182
 
178
- function setPaneFullAccess(sessionId, enabled) {
179
- var ws = getWs();
180
- if (ws && ws.readyState === 1) {
181
- ws.send(JSON.stringify({ type: "set_session_full_access", id: sessionId, enabled: enabled }));
182
- }
183
+ function updatePaneFullAccessButton(button, session) {
184
+ if (!button) return;
185
+ // Permission policy belongs to the Driver. A configured Worker inherits it
186
+ // server-side and must not present a second, misleading control.
187
+ renderPermissionControl(button, panePermissionControlState(session));
183
188
  }
184
189
 
185
190
  function createPane(pane, index) {
@@ -208,6 +213,8 @@ function createPane(pane, index) {
208
213
 
209
214
  var frame = document.createElement("iframe");
210
215
  frame.className = "split-pane-frame";
216
+ frame.dataset.projectSlug = pane.slug;
217
+ frame.dataset.sessionId = String(pane.sessionId);
211
218
  frame.src = paneUrl(pane);
212
219
  frame.title = pane.title;
213
220
 
@@ -225,21 +232,11 @@ function createPane(pane, index) {
225
232
  });
226
233
  header.appendChild(ctxChip);
227
234
 
228
- var fullAccess = document.createElement("button");
229
- fullAccess.type = "button";
230
- fullAccess.className = "session-full-access split-pane-full-access hidden";
231
- fullAccess.setAttribute("role", "switch");
232
- fullAccess.innerHTML = '<span class="session-full-access-label">Skip Permissions</span><span class="session-full-access-track" aria-hidden="true"><span></span></span>';
235
+ var fullAccess = createPermissionControl("split-pane-full-access hidden", "Driver session permission mode");
233
236
  updatePaneFullAccessButton(fullAccess, session);
234
- fullAccess.addEventListener("click", function () {
237
+ bindPermissionControl(fullAccess, function() {
235
238
  var current = sessionById(pane.sessionId);
236
- if (current && current.permissionMode === "bypassPermissions") {
237
- setPaneFullAccess(pane.sessionId, false);
238
- return;
239
- }
240
- showConfirm("Skip permission prompts for this session? Tool requests will be approved automatically.", function () {
241
- setPaneFullAccess(pane.sessionId, true);
242
- }, "Skip Permissions", true);
239
+ return panePermissionControlState(current);
243
240
  });
244
241
  header.appendChild(fullAccess);
245
242
  // Permission state belongs to the session identity on the left. Keep it
@@ -293,6 +290,7 @@ function handlePaneMessage(event) {
293
290
  return;
294
291
  }
295
292
  if (handlePaneIssueMessage(event, host)) return;
293
+ if (handlePaneFileMessage(event, host, openFile, revealFilesPanel)) return;
296
294
  if (msg.type !== "clay-pane-context") return;
297
295
  var frames = host.querySelectorAll(".split-pane-frame");
298
296
  for (var i = 0; i < frames.length; i++) {
@@ -527,6 +525,8 @@ export function initSplitView() {
527
525
  document.addEventListener("click", handleSidebarSessionClick, true);
528
526
  store.subscribe(function (state, prev) {
529
527
  if (state.splitPanes !== prev.splitPanes) renderSplit(state.splitPanes);
528
+ if (state.permissionModePendingByTarget !== prev.permissionModePendingByTarget || state.connected !== prev.connected ||
529
+ state.skipPermsEnabled !== prev.skipPermsEnabled) syncPaneTitles();
530
530
  // Switching to a session outside the open split (new_session, palette,
531
531
  // notification click) closes the split UI; the group itself persists.
532
532
  if (state.activeSessionId !== prev.activeSessionId && state.splitPanes && state.splitPanes.panes) {
package/lib/sdk-bridge.js CHANGED
@@ -788,61 +788,61 @@ function createSDKBridge(opts) {
788
788
  return null; // Not whitelisted
789
789
  }
790
790
 
791
- function handleCanUseTool(session, toolName, input, opts) {
791
+ function resolveSessionPermissionMode(session) {
792
+ var mode = session.permissionMode || sm.currentPermissionMode;
793
+ var router = getWorkerPermissionRouter();
794
+ if (router && typeof router.inheritedPermissionMode === "function") {
795
+ var inherited = router.inheritedPermissionMode(session);
796
+ if (inherited) mode = inherited;
797
+ }
798
+ return mode;
799
+ }
800
+
801
+ function handlePreToolUsePolicy(session, toolName, input) {
792
802
  var debateDecision = homeDebateToolPolicy.initialToolDecision(session, toolName, input);
793
- if (debateDecision) return Promise.resolve(debateDecision);
803
+ if (debateDecision) return debateDecision;
794
804
  // The dynamic proposal tool is bound to this exact query session. It only
795
805
  // posts the inline proposal; starting the debate still requires the user.
796
806
  if (session.homeDebatePlanning === true && toolName === "propose_debate") {
797
- return Promise.resolve({ behavior: "allow", updatedInput: input });
807
+ return { behavior: "allow", updatedInput: input };
798
808
  }
799
809
  // Mate creation proposals only present the exact-session approval card;
800
810
  // creating the Mate still requires the user's explicit action there.
801
811
  if (session.mateCreationMode === true && toolName === "propose_mate") {
802
- return Promise.resolve({ behavior: "allow", updatedInput: input });
803
- }
804
- // Full-auto mode: auto-approve everything except AskUserQuestion
805
- // (which still needs to go through the user interaction flow).
806
- var clayPermissionMode = session.permissionMode || sm.currentPermissionMode;
807
- // A configured Split Worker is Driver-operated, so it runs under its
808
- // Driver's permission mode, re-resolved from the live pair on every single
809
- // decision. Nothing is copied at creation and the Worker's own persisted
810
- // setting is never written, so toggling skip permissions on the Driver
811
- // applies to the Worker's next call in either direction, mid-turn included.
812
- var _wpMode = getWorkerPermissionRouter();
813
- if (_wpMode && typeof _wpMode.inheritedPermissionMode === "function") {
814
- var inherited = _wpMode.inheritedPermissionMode(session);
815
- if (inherited) clayPermissionMode = inherited;
816
- }
817
- // AskUserQuestion is genuine user input, not an authorization, so it is
818
- // excluded here exactly as it always was: a Driver in skip mode still
819
- // cannot silently answer a question addressed to the person.
820
- if (clayPermissionMode === "bypassPermissions" && toolName !== "AskUserQuestion") {
821
- return Promise.resolve({ behavior: "allow", updatedInput: input });
812
+ return { behavior: "allow", updatedInput: input };
822
813
  }
823
-
824
814
  // Ralph Loop execution: auto-approve all tools, deny interactive ones.
825
815
  // Crafting sessions are interactive — user and Claude collaborate to build PROMPT.md / JUDGE.md.
826
816
  if (session.loop && session.loop.active && session.loop.role !== "crafting") {
827
817
  if (toolName === "AskUserQuestion") {
828
- return Promise.resolve({ behavior: "deny", message: "Autonomous mode. Make your own decision." });
818
+ return { behavior: "deny", message: "Autonomous mode. Make your own decision." };
829
819
  }
830
820
  if (toolName === "EnterPlanMode") {
831
- return Promise.resolve({ behavior: "deny", message: "Do not enter plan mode. Execute directly." });
821
+ return { behavior: "deny", message: "Do not enter plan mode. Execute directly." };
832
822
  }
833
- return Promise.resolve({ behavior: "allow", updatedInput: input });
823
+ return { behavior: "allow", updatedInput: input };
834
824
  }
835
825
 
836
826
  // Check shared whitelist (read-only tools, safe browser tools, safe bash commands)
837
827
  var whitelisted = checkToolWhitelist(toolName, input);
838
828
  if (whitelisted) {
839
- return Promise.resolve(whitelisted);
829
+ return whitelisted;
840
830
  }
841
831
 
832
+ return null;
833
+ }
834
+
835
+ function handleCanUseTool(session, toolName, input, opts) {
836
+ var initialDecision = handlePreToolUsePolicy(session, toolName, input);
837
+ if (initialDecision) return Promise.resolve(initialDecision);
838
+ // Skip permissions removes only the remaining Clay prompt. The SDK's
839
+ // deny/ask rules and Auto classifier have already run before this callback.
840
+ var skipRemainingPermissions = resolveSessionPermissionMode(session) === "bypassPermissions" && toolName !== "AskUserQuestion";
841
+
842
+ if (skipRemainingPermissions) return Promise.resolve({ behavior: "allow", updatedInput: input });
843
+
842
844
  // Auto-approve if tool was previously allowed for session
843
- if (session.allowedTools && session.allowedTools[toolName]) {
844
- return Promise.resolve({ behavior: "allow", updatedInput: input });
845
- }
845
+ if (session.allowedTools && session.allowedTools[toolName]) return Promise.resolve({ behavior: "allow", updatedInput: input });
846
846
 
847
847
  // A visible Split Worker's remaining prompts belong to its paired Driver,
848
848
  // not to the Worker pane. This sits below every auto-decision above, so
@@ -988,12 +988,32 @@ function createSDKBridge(opts) {
988
988
  }
989
989
  }
990
990
 
991
+ function ownsQueryRuntime(session, queryInstance, queryGeneration) {
992
+ return session.queryInstance === queryInstance
993
+ && Number(session._sdkQueryGeneration || 0) === Number(queryGeneration)
994
+ && (!sm.sessions || typeof sm.sessions.get !== "function" || sm.sessions.get(session.localId) === session);
995
+ }
996
+
997
+ function recordRuntimeEffectivePermissionMode(session, queryInstance, queryGeneration, message) {
998
+ if (!message || (message.yokeType !== "init" && message.yokeType !== "status") || !message.permissionMode) return false;
999
+ if (!ownsQueryRuntime(session, queryInstance, queryGeneration)) return false;
1000
+ session.effectivePermissionMode = message.permissionMode;
1001
+ session.permissionModeFallbackReason = null;
1002
+ sm.saveSessionFile(session);
1003
+ sendToSession(session, { type: "config_state", sessionId: session.localId, vendor: session.vendor || "claude",
1004
+ mode: session.permissionMode || sm.currentPermissionMode || "default", effectivePermissionMode: message.permissionMode,
1005
+ permissionCapabilities: session.permissionCapabilities || { auto: false, mcpOverride: false },
1006
+ mcpPermissionModeOverrides: session.mcpPermissionModeOverrides || {} });
1007
+ return true;
1008
+ }
1009
+
991
1010
  async function processQueryStream(session, codexCatalogCandidate) {
992
1011
  // Capture references at start so we only clean up OUR resources in finally,
993
1012
  // not resources from a newer query that may have been created after an abort.
994
1013
  var myQueryInstance = session.queryInstance;
995
1014
  var myAbortController = session.abortController;
996
1015
  var myMessageQueue = session.messageQueue;
1016
+ var myQueryGeneration = Number(session._sdkQueryGeneration || 0);
997
1017
  console.log("[sdk-bridge] processQueryStream: starting for-await loop, vendor=" + (session.vendor || adapter.vendor));
998
1018
  try {
999
1019
  for await (var msg of myQueryInstance) {
@@ -1023,8 +1043,10 @@ function createSDKBridge(opts) {
1023
1043
  sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: session.permissionMode || sm.currentPermissionMode || "default", effort: session.effort, betas: sm.currentBetas || [] });
1024
1044
  break;
1025
1045
  case "permission_mode_changed":
1026
- sm.currentPermissionMode = metaData.mode;
1027
- sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: session.permissionMode || sm.currentPermissionMode, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1046
+ // Setter notifications are acknowledgements, not a new project
1047
+ // default and not proof of the runtime-effective mode. Only an
1048
+ // init/status event below may confirm effectivePermissionMode.
1049
+ sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: session.permissionMode || sm.currentPermissionMode || "default", effectivePermissionMode: session.effectivePermissionMode || null, permissionCapabilities: session.permissionCapabilities || { auto: false, mcpOverride: false }, mcpPermissionModeOverrides: session.mcpPermissionModeOverrides || {}, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1028
1050
  break;
1029
1051
  case "worker_error":
1030
1052
  send({ type: "error", text: metaData.error });
@@ -1033,6 +1055,10 @@ function createSDKBridge(opts) {
1033
1055
  continue;
1034
1056
  }
1035
1057
  processSDKMessage(session, msg);
1058
+ // SDK init/status events are the only authority for runtime-effective
1059
+ // permission state. Setter acknowledgement deliberately does not
1060
+ // change this field.
1061
+ recordRuntimeEffectivePermissionMode(session, myQueryInstance, myQueryGeneration, msg);
1036
1062
  if (codexCatalogCandidate && msg && msg.yokeType === "session_started" && msg.sessionId
1037
1063
  && session.queryInstance === myQueryInstance
1038
1064
  && Number(session._sdkQueryGeneration || 0) === codexCatalogCandidate.generation
@@ -1843,12 +1869,20 @@ function createSDKBridge(opts) {
1843
1869
  abortController: linuxUser ? undefined : session.abortController,
1844
1870
  canUseTool: function(toolName, input, toolOpts) {
1845
1871
  var nativeQuestionError = toolName === "AskUserQuestion" ? scheduleQuestionError(input) : null;
1846
- if (nativeQuestionError) return Promise.resolve({ behavior: "deny", message: nativeQuestionError });
1872
+ if (nativeQuestionError) return { behavior: "deny", message: nativeQuestionError };
1847
1873
  var permissionName = toolName;
1848
1874
  var permissionToolDef = findSessionToolDef(toolName);
1849
1875
  if (permissionToolDef && permissionToolDef.permissionName) permissionName = permissionToolDef.permissionName;
1850
1876
  return handleCanUseTool(session, permissionName, input, toolOpts);
1851
1877
  },
1878
+ preToolUsePolicy: function(toolName, input) {
1879
+ var nativeQuestionError = toolName === "AskUserQuestion" ? scheduleQuestionError(input) : null;
1880
+ if (nativeQuestionError) return Promise.resolve({ behavior: "deny", message: nativeQuestionError });
1881
+ var permissionName = toolName;
1882
+ var permissionToolDef = findSessionToolDef(toolName);
1883
+ if (permissionToolDef && permissionToolDef.permissionName) permissionName = permissionToolDef.permissionName;
1884
+ return handlePreToolUsePolicy(session, permissionName, input);
1885
+ },
1852
1886
  onUserInputRequest: function(request, respond) {
1853
1887
  return sessionUserInputHandler(request, respond);
1854
1888
  },
@@ -1948,6 +1982,24 @@ function createSDKBridge(opts) {
1948
1982
 
1949
1983
  // Store QueryHandle on session for iteration and control.
1950
1984
  session.queryInstance = handle;
1985
+ // Capabilities come from the installed query handle, never a model-name
1986
+ // table. `acceptEdits` and MCP overrides are Claude SDK semantics; a
1987
+ // vendor may expose the generic setter without implementing either.
1988
+ session.permissionCapabilities = require("./session-permission-mode").capabilities(session, sm.defaultVendor);
1989
+ session.permissionCapabilities.mcpOverride = sessionAdapter.vendor === "claude" && typeof handle.setMcpPermissionModeOverride === "function";
1990
+ session.effectivePermissionMode = null;
1991
+ sm.saveSessionFile(session);
1992
+ sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: session.permissionMode || sm.currentPermissionMode || "default", effectivePermissionMode: null, permissionCapabilities: session.permissionCapabilities, mcpPermissionModeOverrides: session.mcpPermissionModeOverrides || {}, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1993
+
1994
+ if (session.permissionMode === "auto" && session.permissionCapabilities.mcpOverride) {
1995
+ var overrideNames = Object.keys(session.mcpPermissionModeOverrides || {});
1996
+ for (var oi = 0; oi < overrideNames.length; oi++) {
1997
+ try { await handle.setMcpPermissionModeOverride(overrideNames[oi], session.mcpPermissionModeOverrides[overrideNames[oi]]); }
1998
+ catch (overrideError) {
1999
+ sendToSession(session, { type: "error", text: "Failed to restore the AI review policy for " + overrideNames[oi] + ": " + (overrideError.message || overrideError) });
2000
+ }
2001
+ }
2002
+ }
1951
2003
 
1952
2004
  if (typeof beforePush === "function" && beforePush() !== true) {
1953
2005
  try { handle.close(); } catch (e) {}
@@ -2358,17 +2410,15 @@ function createSDKBridge(opts) {
2358
2410
  async function setPermissionMode(session, mode) {
2359
2411
  if (!session.queryInstance) {
2360
2412
  // No active query — just store the mode for next startQuery
2361
- sm.currentPermissionMode = mode;
2362
- sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: sm.currentPermissionMode, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
2363
- return;
2413
+ return { ok: true, effectiveMode: mode };
2364
2414
  }
2365
2415
  try {
2366
2416
  // Route through QueryHandle (works for both in-process and worker paths)
2367
2417
  await session.queryInstance.setPermissionMode(mode);
2368
- sm.currentPermissionMode = mode;
2369
- sendToSession(session, { type: "config_state", model: session.model || defaultModelForVendor(session.vendor || "claude"), vendor: session.vendor || "claude", sessionId: session.localId, mode: sm.currentPermissionMode, effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
2418
+ return { ok: true, effectiveMode: mode };
2370
2419
  } catch (e) {
2371
2420
  sendToSession(session, { type: "error", text: "Failed to set permission mode: " + (e.message || e) });
2421
+ return { ok: false, error: e.message || String(e) };
2372
2422
  }
2373
2423
  }
2374
2424
 
@@ -2404,12 +2454,14 @@ function createSDKBridge(opts) {
2404
2454
 
2405
2455
  // Override the permission mode for one MCP server on the active query.
2406
2456
  async function setMcpPermissionModeOverride(session, serverName, mode) {
2407
- if (!session || !session.queryInstance) return;
2457
+ if (!session || !session.queryInstance || typeof session.queryInstance.setMcpPermissionModeOverride !== "function") return { ok: false, error: "MCP permission controls are unavailable for this session." };
2408
2458
  try {
2409
2459
  // Route through QueryHandle (works for both in-process and worker paths)
2410
2460
  await session.queryInstance.setMcpPermissionModeOverride(serverName, mode);
2461
+ return { ok: true };
2411
2462
  } catch (e) {
2412
2463
  send({ type: "error", text: "Failed to set MCP permission mode: " + (e.message || e) });
2464
+ return { ok: false, error: e.message || String(e) };
2413
2465
  }
2414
2466
  }
2415
2467
 
@@ -2583,6 +2635,9 @@ function createSDKBridge(opts) {
2583
2635
  createMessageQueue: createMessageQueue,
2584
2636
  processSDKMessage: processSDKMessage,
2585
2637
  checkToolWhitelist: checkToolWhitelist,
2638
+ handlePreToolUsePolicy: handlePreToolUsePolicy,
2639
+ ownsQueryRuntime: ownsQueryRuntime,
2640
+ recordRuntimeEffectivePermissionMode: recordRuntimeEffectivePermissionMode,
2586
2641
  handleCanUseTool: handleCanUseTool,
2587
2642
  handleElicitation: handleElicitation,
2588
2643
  processQueryStream: processQueryStream,
@@ -0,0 +1,42 @@
1
+ // Canonical, server-owned permission selection validation. The requested mode
2
+ // is persisted separately from the SDK mode because Clay keeps the final
3
+ // canUseTool decision for GUI sessions.
4
+
5
+ function normalize(mode) {
6
+ if (mode === "default" || mode === "auto" || mode === "plan" || mode === "acceptEdits" || mode === "bypassPermissions") return mode;
7
+ return null;
8
+ }
9
+
10
+ function capabilities(session, defaultVendor) {
11
+ var vendor = session && session.vendor || defaultVendor || "claude";
12
+ return {
13
+ auto: vendor === "claude",
14
+ mcpOverride: vendor === "claude" && !!(session && session.queryInstance &&
15
+ typeof session.queryInstance.setMcpPermissionModeOverride === "function"),
16
+ };
17
+ }
18
+
19
+ function supportsAuto(session, defaultVendor) {
20
+ return capabilities(session, defaultVendor).auto;
21
+ }
22
+
23
+ function validate(session, mode, defaultVendor) {
24
+ var requested = normalize(mode);
25
+ if (!requested) return { ok: false, error: "Unknown permission mode." };
26
+ if (requested === "auto" && !supportsAuto(session, defaultVendor)) {
27
+ return { ok: false, error: "Auto permissions are not available for this session." };
28
+ }
29
+ return { ok: true, mode: requested };
30
+ }
31
+
32
+ function clientState(session, fallback, defaultVendor) {
33
+ var requested = session && session.permissionMode || fallback || "default";
34
+ return {
35
+ requestedPermissionMode: requested,
36
+ effectivePermissionMode: session && session.effectivePermissionMode || null,
37
+ permissionCapabilities: capabilities(session, defaultVendor),
38
+ mcpPermissionModeOverrides: session && session.mcpPermissionModeOverrides || {},
39
+ };
40
+ }
41
+
42
+ module.exports = { validate: validate, clientState: clientState, supportsAuto: supportsAuto, capabilities: capabilities };
package/lib/sessions.js CHANGED
@@ -177,6 +177,7 @@ function createSessionManager(opts) {
177
177
  // so lazy-resume (`claude --resume`) re-spawns with the same flag.
178
178
  if (session.dangerouslySkipPermissions) metaObj.dangerouslySkipPermissions = true;
179
179
  if (session.permissionMode) metaObj.permissionMode = session.permissionMode;
180
+ if (session.mcpPermissionModeOverrides) metaObj.mcpPermissionModeOverrides = session.mcpPermissionModeOverrides;
180
181
  if (session.permissionModeBeforeFullAccess) metaObj.permissionModeBeforeFullAccess = session.permissionModeBeforeFullAccess;
181
182
  if (session.autonomousRun) metaObj.autonomousRun = session.autonomousRun;
182
183
  if (session.loopInterviewBrief) metaObj.loopInterviewBrief = session.loopInterviewBrief;
@@ -348,6 +349,10 @@ function createSessionManager(opts) {
348
349
  // Clay's own per-session permission mode.
349
350
  session.dangerouslySkipPermissions = session.mode === "tui" && !!m.dangerouslySkipPermissions;
350
351
  session.permissionMode = m.permissionMode || (session.mode === "gui" && m.dangerouslySkipPermissions ? "bypassPermissions" : null);
352
+ // Runtime-effective state is observed from the live SDK only. A daemon
353
+ // restore has no active runtime to confirm the old value.
354
+ session.effectivePermissionMode = null;
355
+ session.mcpPermissionModeOverrides = m.mcpPermissionModeOverrides && typeof m.mcpPermissionModeOverrides === "object" ? m.mcpPermissionModeOverrides : {};
351
356
  session.permissionModeBeforeFullAccess = m.permissionModeBeforeFullAccess || null;
352
357
  session.autonomousRun = restoreStoredRun(m.autonomousRun);
353
358
  session.loopInterviewBrief = m.loopInterviewBrief && typeof m.loopInterviewBrief === "object" ? m.loopInterviewBrief : null;
@@ -587,6 +592,9 @@ function createSessionManager(opts) {
587
592
  runtimeTerminalId: typeof s.runtimeTerminalId === "number" ? s.runtimeTerminalId : null,
588
593
  dangerouslySkipPermissions: !!s.dangerouslySkipPermissions,
589
594
  permissionMode: s.permissionMode || null,
595
+ effectivePermissionMode: s.effectivePermissionMode || null,
596
+ permissionCapabilities: require("./session-permission-mode").capabilities(s, defaultVendor),
597
+ mcpPermissionModeOverrides: s.mcpPermissionModeOverrides || {},
590
598
  autonomousRun: s.autonomousRun ? {
591
599
  id: s.autonomousRun.id,
592
600
  state: s.autonomousRun.state,
@@ -676,6 +684,8 @@ function createSessionManager(opts) {
676
684
  mode: (sessionOpts && sessionOpts.mode === "tui") ? "tui" : "gui",
677
685
  dangerouslySkipPermissions: !!(sessionOpts && sessionOpts.mode === "tui" && sessionOpts.dangerouslySkipPermissions),
678
686
  permissionMode: (sessionOpts && sessionOpts.permissionMode) || null,
687
+ effectivePermissionMode: null,
688
+ mcpPermissionModeOverrides: {},
679
689
  permissionModeBeforeFullAccess: null,
680
690
  autonomousRun: null,
681
691
  loopInterviewBrief: null,
@@ -725,6 +735,8 @@ function createSessionManager(opts) {
725
735
  mode: (sessionOpts && sessionOpts.mode === "tui") ? "tui" : "gui",
726
736
  dangerouslySkipPermissions: !!(sessionOpts && sessionOpts.mode === "tui" && sessionOpts.dangerouslySkipPermissions),
727
737
  permissionMode: (sessionOpts && sessionOpts.permissionMode) || null,
738
+ effectivePermissionMode: null,
739
+ mcpPermissionModeOverrides: {},
728
740
  permissionModeBeforeFullAccess: null,
729
741
  autonomousRun: null,
730
742
  loopInterviewBrief: null,
@@ -826,7 +838,8 @@ function createSessionManager(opts) {
826
838
  var _capsByVendor = capabilitiesByVendor || {};
827
839
  var _sessionVendor = session.vendor || defaultVendor || "claude";
828
840
  var _vendorCaps = _capsByVendor[_sessionVendor] || {};
829
- _send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, model: session.model || null, effort: session.effort || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended, dangerouslySkipPermissions: !!session.dangerouslySkipPermissions, permissionMode: session.permissionMode || null, autonomousRun: projectAutonomousRun(session.autonomousRun), loopInterviewBrief: session.loopInterviewBrief || null, loopInterviewHandoff: session.loopInterviewHandoff || null, scheduledTaskDraft: session.scheduledTaskDraft || null });
841
+ var _permissionState = require("./session-permission-mode").clientState(session, "default", defaultVendor);
842
+ _send({ type: "session_switched", id: localId, cliSessionId: session.cliSessionId || null, loop: session.loop || null, vendor: session.vendor || null, model: session.model || null, effort: session.effort || null, hasHistory: (session.history && session.history.length > 0), capabilities: _vendorCaps, isProcessing: !!session.isProcessing, mode: session.mode || "gui", terminalId: typeof session.terminalId === "number" ? session.terminalId : null, runtimeMode: session.runtimeMode || null, runtimeTerminalId: typeof session.runtimeTerminalId === "number" ? session.runtimeTerminalId : null, tuiSuspended: !!session.tuiSuspended, dangerouslySkipPermissions: !!session.dangerouslySkipPermissions, permissionMode: _permissionState.requestedPermissionMode, effectivePermissionMode: _permissionState.effectivePermissionMode, permissionCapabilities: _permissionState.permissionCapabilities, mcpPermissionModeOverrides: _permissionState.mcpPermissionModeOverrides, autonomousRun: projectAutonomousRun(session.autonomousRun), loopInterviewBrief: session.loopInterviewBrief || null, loopInterviewHandoff: session.loopInterviewHandoff || null, scheduledTaskDraft: session.scheduledTaskDraft || null });
830
843
  // Send vendor-specific slash commands
831
844
  var _vendorCmds = (session.slashCommandsByVendor && session.slashCommandsByVendor[_sessionVendor])
832
845
  || session.slashCommands