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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +73 -0
  26. package/lib/public/modules/pending-message-queue.js +67 -64
  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
@@ -408,9 +408,14 @@
408
408
  <span class="header-title" id="header-title">Connecting...</span>
409
409
  <button id="header-rename-btn" type="button" title="Rename session"><i data-lucide="pencil"></i></button>
410
410
  <button id="header-info-btn" type="button" title="Session info"><i data-lucide="info"></i></button>
411
- <button id="header-full-access-btn" type="button" class="session-full-access hidden" role="switch" aria-checked="false" title="Skip Permissions is off — permission prompts are on">
412
- <span class="session-full-access-label">Skip Permissions</span><span class="session-full-access-track" aria-hidden="true"><span></span></span>
413
- </button>
411
+ <div id="header-full-access-btn" class="session-permission-control hidden" role="group" aria-label="Session permission mode">
412
+ <div class="session-permission-segmented">
413
+ <button type="button" data-permission-mode="default">Ask</button>
414
+ <button type="button" data-permission-mode="auto">Auto</button>
415
+ <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>
416
+ </div>
417
+ <span class="session-permission-status hidden" aria-live="polite"></span>
418
+ </div>
414
419
  <button type="button" id="header-tui-close-btn" class="header-tui-close-btn hidden" title="Close terminal (keeps history, resume anytime)" aria-label="Close terminal"><i data-lucide="power"></i></button>
415
420
  <div id="mate-mobile-title" class="mate-mobile-title hidden">
416
421
  <img id="mate-mobile-avatar" class="mate-mobile-avatar" alt="">
@@ -20,6 +20,8 @@ import { beginDefaultAiConnection, requestDefaultAi } from './default-ai.js';
20
20
  import { beginContextViewConnection, requestContextView } from './context-view-preference.js';
21
21
  import { beginDefaultVendorConnection, requestDefaultVendor } from './default-vendor.js';
22
22
  import { clearProjectSplitState } from './split-session-boundary.js';
23
+ import { clearPermissionModePending } from './permission-control.js';
24
+ import { clearMcpPermissionModePending } from './mcp-ui.js';
23
25
 
24
26
  var reconnectTimer = null;
25
27
  var reconnectDelay = 1000;
@@ -165,6 +167,8 @@ export function setStatus(status) {
165
167
  } else if (status === "processing") {
166
168
  store.set({ processing: true });
167
169
  } else {
170
+ clearPermissionModePending();
171
+ clearMcpPermissionModePending();
168
172
  store.set({ connected: false, processing: false });
169
173
  }
170
174
  }
@@ -12,7 +12,7 @@ import { saveToolState, resetToolState, restoreToolState } from './tools.js';
12
12
  import { getSessionUsage, setSessionUsage, getContextData, setContextData, updateContextPanel, updateUsagePanel } from './app-panels.js';
13
13
  import { onHistoryPrepended as onSessionSearchHistoryPrepended } from './session-search.js';
14
14
  import { getCachedSessions } from './sidebar-sessions.js';
15
- import { showConfirm } from './app-misc.js';
15
+ import { bindPermissionControl, renderPermissionControl } from './permission-control.js';
16
16
 
17
17
  // --- Module-owned state ---
18
18
  var sessionInfoPopover = null;
@@ -24,37 +24,30 @@ export function initHeader() {
24
24
  var headerInfoBtn = document.getElementById("header-info-btn");
25
25
  var headerFullAccessBtn = document.getElementById("header-full-access-btn");
26
26
 
27
- function updateFullAccessButton() {
28
- if (!headerFullAccessBtn) return;
27
+ function permissionControlState() {
29
28
  var s = store.snap();
30
- var visible = !s.dmMode && !s.splitPanes && !s.skipPermsEnabled && s.activeSessionMode === "gui" && !!s.activeSessionId;
31
- var enabled = !!s.sessionFullAccess;
32
- headerFullAccessBtn.classList.toggle("hidden", !visible);
33
- headerFullAccessBtn.classList.toggle("active", enabled);
34
- headerFullAccessBtn.setAttribute("aria-checked", enabled ? "true" : "false");
35
- headerFullAccessBtn.title = enabled ? "Skip Permissions is on — click to restore permission prompts" : "Skip Permissions is off — permission prompts are on";
29
+ return {
30
+ projectSlug: s.currentSlug,
31
+ sessionId: s.activeSessionId,
32
+ vendor: s.currentVendor || "claude",
33
+ permissionMode: s.currentMode,
34
+ effectivePermissionMode: s.effectivePermissionMode,
35
+ permissionCapabilities: s.permissionCapabilities,
36
+ connected: s.connected,
37
+ globalPermissionModeForced: s.skipPermsEnabled === true,
38
+ visible: !s.dmMode && !s.splitPanes && s.activeSessionMode === "gui" && !!s.activeSessionId,
39
+ };
36
40
  }
37
41
 
38
- function sendFullAccess(enabled) {
39
- var ws = getWs();
40
- var id = store.get('activeSessionId');
41
- if (ws && ws.readyState === 1 && id) {
42
- ws.send(JSON.stringify({ type: "set_session_full_access", id: id, enabled: enabled }));
43
- }
42
+ function updateFullAccessButton() {
43
+ if (!headerFullAccessBtn) return;
44
+ renderPermissionControl(headerFullAccessBtn, permissionControlState());
44
45
  }
45
46
 
46
47
  if (headerFullAccessBtn) {
47
- headerFullAccessBtn.addEventListener("click", function () {
48
- if (store.get('sessionFullAccess')) {
49
- sendFullAccess(false);
50
- return;
51
- }
52
- showConfirm("Skip permission prompts for this session? Tool requests will be approved automatically.", function () {
53
- sendFullAccess(true);
54
- }, "Skip Permissions", true);
55
- });
48
+ bindPermissionControl(headerFullAccessBtn, permissionControlState);
56
49
  store.subscribe(function (state, prev) {
57
- if (state.sessionFullAccess !== prev.sessionFullAccess || state.activeSessionId !== prev.activeSessionId ||
50
+ if (state.currentMode !== prev.currentMode || state.effectivePermissionMode !== prev.effectivePermissionMode || state.permissionCapabilities !== prev.permissionCapabilities || state.permissionModePendingByTarget !== prev.permissionModePendingByTarget || state.connected !== prev.connected || state.activeSessionId !== prev.activeSessionId || state.currentSlug !== prev.currentSlug || state.currentVendor !== prev.currentVendor ||
58
51
  state.activeSessionMode !== prev.activeSessionMode ||
59
52
  state.dmMode !== prev.dmMode || state.splitPanes !== prev.splitPanes ||
60
53
  state.skipPermsEnabled !== prev.skipPermsEnabled) updateFullAccessButton();
@@ -54,7 +54,8 @@ import { showRewindModal, onRewindComplete, setRewindMode, onRewindError, clearP
54
54
  import { checkAdminAccess } from './admin.js';
55
55
  import { mateAvatarUrl } from './avatar.js';
56
56
  import { showImageModal, sendExtensionCommand, handleMcpToolCallMessage } from './app-misc.js';
57
- import { handleMcpServersState } from './mcp-ui.js';
57
+ import { handleMcpServersState, handleMcpPermissionModeResult } from './mcp-ui.js';
58
+ import { handlePermissionModeResult } from './permission-control.js';
58
59
  import { handleShellCommandResult } from './shell-command.js';
59
60
  import { handleLoopRegistryUpdated, handleScheduleRunStarted, handleScheduleRunFinished, handleLoopScheduled, isSchedulerOpen, enterCraftingMode, exitCraftingMode, handleLoopRegistryFiles } from './scheduler.js';
60
61
  import { handleScheduledTaskMessage, handleScheduledTaskSessionSwitched } from './scheduled-tasks.js';
@@ -374,11 +375,9 @@ export function processMessage(msg) {
374
375
  if (msg.ownerLocked !== undefined) store.set({ ownerLocked: !!msg.ownerLocked });
375
376
  if (msg.osUsers !== undefined) store.set({ isOsUsers: !!msg.osUsers });
376
377
  if (msg.lanHost) window.__lanHost = msg.lanHost;
377
- if (msg.dangerouslySkipPermissions) {
378
- store.set({ skipPermsEnabled: true });
379
- var spBanner = document.getElementById("skip-perms-pill");
380
- if (spBanner) spBanner.classList.remove("hidden");
381
- }
378
+ store.set({ skipPermsEnabled: msg.dangerouslySkipPermissions === true });
379
+ var spBanner = document.getElementById("skip-perms-pill");
380
+ if (spBanner) spBanner.classList.toggle("hidden", msg.dangerouslySkipPermissions !== true);
382
381
  updateProjectList(msg);
383
382
  refreshProjectSessionVisibilitySettings(msg.projects);
384
383
  break;
@@ -555,6 +554,9 @@ export function processMessage(msg) {
555
554
  }
556
555
  }
557
556
  if (msg.mode) _cs.currentMode = msg.mode;
557
+ if (Object.prototype.hasOwnProperty.call(msg, 'effectivePermissionMode')) _cs.effectivePermissionMode = msg.effectivePermissionMode;
558
+ if (msg.permissionCapabilities) _cs.permissionCapabilities = msg.permissionCapabilities;
559
+ if (msg.mcpPermissionModeOverrides) _cs.mcpPermissionModeOverrides = msg.mcpPermissionModeOverrides;
558
560
  if (msg.effort) _cs.currentEffort = msg.effort;
559
561
  if (msg.betas) _cs.currentBetas = msg.betas;
560
562
  if (msg.thinking) _cs.currentThinking = msg.thinking;
@@ -823,7 +825,7 @@ export function processMessage(msg) {
823
825
  var _effectiveTerminalId = (typeof msg.runtimeTerminalId === "number")
824
826
  ? msg.runtimeTerminalId
825
827
  : (typeof msg.terminalId === "number" ? msg.terminalId : null);
826
- store.set({ activeSessionId: msg.id, cliSessionId: msg.cliSessionId || null, currentModel: msg.model || "", currentEffort: msg.effort || store.get('currentEffort'), vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing, activeSessionMode: _effectiveMode, activeTerminalId: _effectiveTerminalId, sessionHasHistory: !!msg.hasHistory, sessionVendorBound: !!msg.vendor || !!msg.hasHistory, sessionFullAccess: msg.permissionMode === "bypassPermissions", currentMode: msg.permissionMode || store.get('currentMode') });
828
+ store.set({ activeSessionId: msg.id, cliSessionId: msg.cliSessionId || null, currentModel: msg.model || "", currentEffort: msg.effort || store.get('currentEffort'), vendorCapabilities: msg.capabilities || {}, sessionIsProcessing: !!msg.isProcessing, activeSessionMode: _effectiveMode, activeTerminalId: _effectiveTerminalId, sessionHasHistory: !!msg.hasHistory, sessionVendorBound: !!msg.vendor || !!msg.hasHistory, sessionFullAccess: msg.permissionMode === "bypassPermissions", currentMode: msg.permissionMode || store.get('currentMode'), effectivePermissionMode: msg.effectivePermissionMode || null, permissionCapabilities: msg.permissionCapabilities || { auto: false, mcpOverride: false }, mcpPermissionModeOverrides: msg.mcpPermissionModeOverrides || {} });
827
829
  handleScheduledTaskSessionSwitched(msg);
828
830
  requestLoopInterviewState();
829
831
  replayPendingMessages(msg.id);
@@ -1598,6 +1600,14 @@ export function processMessage(msg) {
1598
1600
  handleMcpServersState(msg);
1599
1601
  break;
1600
1602
 
1603
+ case "mcp_permission_mode_result":
1604
+ handleMcpPermissionModeResult(msg);
1605
+ break;
1606
+
1607
+ case "permission_mode_result":
1608
+ handlePermissionModeResult(msg);
1609
+ break;
1610
+
1601
1611
  case "term_created":
1602
1612
  // Login terminals never come through here: they are created by the
1603
1613
  // server-owned flow and announced with vendor_login_ready instead.
@@ -8,6 +8,7 @@ import { getWs } from './ws-ref.js';
8
8
  import { getContextView as getStoredContextView, getEffectiveContextView, setContextView as setStoredContextView } from './context-view-preference.js';
9
9
  import { reportPaneContext } from './pane-bridge.js';
10
10
  import { setupModelPicker, renderModelPicker, requestVendorModels, prepareModelPickerOpen, modelDisplayName } from './model-picker.js';
11
+ import { sendPermissionMode } from './permission-control.js';
11
12
 
12
13
  // --- Module-owned state (not in store) ---
13
14
  var sessionUsage = { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 };
@@ -85,11 +86,12 @@ var contextMiniLabel = null;
85
86
 
86
87
  // --- Constants ---
87
88
  var MODE_OPTIONS = [
88
- { value: "default", label: "Default" },
89
+ { value: "default", label: "Ask" },
89
90
  { value: "plan", label: "Plan" },
90
91
  { value: "acceptEdits", label: "Auto-accept edits" },
91
92
  ];
92
- var MODE_FULL_AUTO = { value: "bypassPermissions", label: "Full auto" };
93
+ var MODE_FULL_AUTO = { value: "auto", label: "Auto" };
94
+ var MODE_SKIP = { value: "bypassPermissions", label: "Skip permissions" };
93
95
  var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
94
96
  var EFFORT_LEVELS_BY_VENDOR = {
95
97
  claude: ["low", "medium", "high", "xhigh", "max"],
@@ -142,7 +144,8 @@ function modeDisplayName(value) {
142
144
  for (var i = 0; i < MODE_OPTIONS.length; i++) {
143
145
  if (MODE_OPTIONS[i].value === value) return MODE_OPTIONS[i].label;
144
146
  }
145
- if (value === "bypassPermissions") return "Full auto";
147
+ if (value === "auto") return "Auto";
148
+ if (value === "bypassPermissions") return "Skip permissions";
146
149
  if (value === "dontAsk") return "Don\u2019t ask";
147
150
  return value;
148
151
  }
@@ -196,22 +199,29 @@ function rebuildModeList() {
196
199
  if (!configModeList) return;
197
200
  configModeList.innerHTML = "";
198
201
  var options = MODE_OPTIONS.slice();
199
- if (store.get('skipPermsEnabled')) {
200
- options.push(MODE_FULL_AUTO);
201
- }
202
+ options.push(MODE_FULL_AUTO);
203
+ options.push(MODE_SKIP);
204
+ var autoSupported = store.get('permissionCapabilities').auto === true;
205
+ var globallyForced = store.get('skipPermsEnabled') === true;
202
206
  for (var i = 0; i < options.length; i++) {
203
207
  var opt = options[i];
204
208
  var btn = document.createElement("button");
205
209
  btn.className = "config-radio-item";
206
- if (opt.value === store.get('currentMode')) btn.classList.add("active");
210
+ if (opt.value === (globallyForced ? "bypassPermissions" : store.get('currentMode'))) btn.classList.add("active");
207
211
  btn.dataset.mode = opt.value;
208
212
  btn.textContent = opt.label;
213
+ if (opt.value === "auto" && !autoSupported) {
214
+ btn.disabled = true;
215
+ btn.title = "Auto permissions require a Claude session.";
216
+ btn.setAttribute("aria-description", "Unavailable because this is not a Claude session.");
217
+ }
218
+ if (globallyForced) {
219
+ btn.disabled = true;
220
+ btn.title = "Skip permissions is forced by the server setting.";
221
+ }
209
222
  btn.addEventListener("click", function () {
210
223
  var mode = this.dataset.mode;
211
- var ws = getWs();
212
- if (ws && ws.readyState === 1) {
213
- ws.send(JSON.stringify({ type: "set_permission_mode", mode: mode }));
214
- }
224
+ sendPermissionMode(store.get('currentSlug'), store.get('activeSessionId'), mode);
215
225
  configPopover.classList.add("hidden");
216
226
  configChip.classList.remove("active");
217
227
  });
@@ -420,6 +430,7 @@ export function initPanels() {
420
430
  state.codexApproval !== prev.codexApproval ||
421
431
  state.codexSandbox !== prev.codexSandbox ||
422
432
  state.codexWebSearch !== prev.codexWebSearch ||
433
+ state.skipPermsEnabled !== prev.skipPermsEnabled ||
423
434
  state.sessionHasHistory !== prev.sessionHasHistory ||
424
435
  state.activeSessionMode !== prev.activeSessionMode) {
425
436
  updateConfigChip();
@@ -3,6 +3,7 @@
3
3
  import { escapeHtml } from './utils.js';
4
4
  import { refreshIcons } from './icons.js';
5
5
  import { store } from './store.js';
6
+ import { forwardPaneFileReference } from './pane-bridge.js';
6
7
 
7
8
  var openFileHandler = null;
8
9
  var delegatedBinding = false;
@@ -99,6 +100,12 @@ function bindDelegatedHandler() {
99
100
  document.addEventListener("click", function (event) {
100
101
  var button = event.target && event.target.closest ? event.target.closest(".clay-file-link") : null;
101
102
  if (!button) return;
103
+ if (store.get('paneMode') && forwardPaneFileReference(button.dataset.filePath, {
104
+ line: button.dataset.fileLine ? Number(button.dataset.fileLine) : null,
105
+ column: button.dataset.fileColumn ? Number(button.dataset.fileColumn) : null,
106
+ projectSlug: button.dataset.fileProjectSlug || null,
107
+ sessionId: button.dataset.fileSessionId || null
108
+ })) return;
102
109
  if (!openFileHandler) { showUnavailable(button); return; }
103
110
  var result = openFileHandler(button.dataset.filePath, {
104
111
  line: button.dataset.fileLine ? Number(button.dataset.fileLine) : null,
@@ -1,4 +1,5 @@
1
1
  import { enhanceClayIssueLinks, isExactClayIssueReference } from './clay-issue-links.js';
2
+ import { enhanceGithubLinks } from './github-links.js';
2
3
 
3
4
  export function renderClayRecordLink(href, text) {
4
5
  if (!isExactClayLogReference(href) && !isExactClayIssueReference(href)) return null;
@@ -80,4 +81,7 @@ export function enhanceClayLogLinks(root) {
80
81
  fragment.appendChild(document.createTextNode(text.slice(offset)));
81
82
  nodes[i].parentNode.replaceChild(fragment, nodes[i]);
82
83
  }
84
+ // Issues and Logs call this enhancer directly, without the transcript
85
+ // session-link pass. Keep external GitHub chips common to those surfaces.
86
+ enhanceGithubLinks(root);
83
87
  }
@@ -0,0 +1,115 @@
1
+ import { escapeHtml } from './utils.js';
2
+ import { iconHtml, refreshIcons } from './icons.js';
3
+
4
+ var GITLAB_RESERVED = { admin: true, dashboard: true, explore: true, groups: true, help: true, profile: true, projects: true, public: true, search: true, snippets: true, users: true };
5
+
6
+ function parseTarget(href, provider) {
7
+ if (typeof href !== "string" || !href) return null;
8
+ var url;
9
+ try { url = new URL(href); } catch (e) { return null; }
10
+ if (url.protocol !== "https:" || url.hostname !== provider.host || url.port || url.username || url.password) return null;
11
+ if (url.pathname.indexOf("//") !== -1 || /%(?:2f|5c)/i.test(url.pathname)) return null;
12
+ var parts = url.pathname.split("/").filter(function (part) { return part !== ""; });
13
+ var repoParts = parts;
14
+ var resourceParts = [];
15
+ if (provider.name === "github") {
16
+ repoParts = parts.slice(0, 2); resourceParts = parts.slice(2);
17
+ } else if (provider.separator) {
18
+ var separator = parts.indexOf(provider.separator);
19
+ if (separator !== -1) { repoParts = parts.slice(0, separator); resourceParts = parts.slice(separator + 1); }
20
+ }
21
+ if (repoParts.length < 2 || (provider.reserved && provider.reserved[repoParts[0].toLowerCase()])) return null;
22
+ if (provider.ownerPattern && !provider.ownerPattern.test(repoParts[0])) return null;
23
+ if (provider.repoPattern && !provider.repoPattern.test(repoParts[repoParts.length - 1])) return null;
24
+ for (var i = 0; i < repoParts.length; i++) {
25
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,99})$/.test(repoParts[i])) return null;
26
+ }
27
+ if (provider.name === "github" && repoParts.length !== 2) return null;
28
+ if (provider.name === "gitlab" && resourceParts.length > 2) return null;
29
+ var target = { url: url, provider: provider.name, icon: provider.icon, cssClass: provider.cssClass, owner: repoParts.slice(0, -1).join("/"), repo: repoParts[repoParts.length - 1], kind: "repository", label: repoParts.join("/") };
30
+ if (!resourceParts.length) return target;
31
+ if (provider.name === "github") {
32
+ if (resourceParts.length === 1 && (resourceParts[0] === "issues" || resourceParts[0] === "pulls")) {
33
+ target.kind = resourceParts[0]; target.label = resourceParts[0] === "issues" ? "Issues" : "Pull requests"; return target;
34
+ }
35
+ if (resourceParts.length === 2 && (resourceParts[0] === "issues" || resourceParts[0] === "pull") && /^\d+$/.test(resourceParts[1])) {
36
+ target.kind = resourceParts[0] === "pull" ? "pull" : "issue"; target.number = resourceParts[1]; target.label = target.kind === "pull" ? "PR #" + target.number : "Issue #" + target.number; return target;
37
+ }
38
+ return null;
39
+ }
40
+ if (resourceParts.length === 1 && (resourceParts[0] === "issues" || resourceParts[0] === "merge_requests")) {
41
+ target.kind = resourceParts[0] === "issues" ? "issues" : "merge_requests"; target.label = target.kind === "issues" ? "Issues" : "Merge requests"; return target;
42
+ }
43
+ if (resourceParts.length === 2 && (resourceParts[0] === "issues" || resourceParts[0] === "merge_requests") && /^\d+$/.test(resourceParts[1])) {
44
+ target.kind = resourceParts[0] === "issues" ? "issue" : "merge_request"; target.number = resourceParts[1]; target.label = target.kind === "issue" ? "Issue #" + target.number : "MR !" + target.number; return target;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ var GITHUB = { name: "github", displayName: "GitHub", host: "github.com", icon: "github", cssClass: "github-link-chip", ownerPattern: /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/, repoPattern: /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,99})$/ };
50
+ var GITLAB = { name: "gitlab", displayName: "GitLab", host: "gitlab.com", icon: "gitlab", cssClass: "gitlab-link-chip", separator: "-", reserved: GITLAB_RESERVED };
51
+
52
+ function plainLabel(text) {
53
+ return String(text || "").replace(/<[^>]*>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/\s+/g, " ").trim();
54
+ }
55
+
56
+ function hasCustomLabel(target, text) {
57
+ var label = plainLabel(text);
58
+ return !!label && label !== target.url.href;
59
+ }
60
+
61
+ function accessibleLabel(target, text) {
62
+ var location = target.owner + "/" + target.repo;
63
+ var detail = target.kind === "repository" ? "" : " · " + target.label;
64
+ return hasCustomLabel(target, text) ? plainLabel(text) + " · " + location + detail : location + detail;
65
+ }
66
+
67
+ function chipMarkup(target, text) {
68
+ var custom = hasCustomLabel(target, text);
69
+ var primary = custom ? plainLabel(text) : target.owner + "/" + target.repo;
70
+ var meta = custom ? target.owner + "/" + target.repo + (target.kind === "repository" ? "" : " · " + target.label) : (target.kind === "repository" ? "" : target.label);
71
+ return iconHtml(target.icon, "github-link-icon") + '<span class="github-link-main">' + escapeHtml(primary) + '</span>' +
72
+ (meta && primary !== target.label ? '<span class="github-link-kind">' + escapeHtml(meta) + '</span>' : "");
73
+ }
74
+
75
+ function renderHostedLink(href, title, text, provider) {
76
+ var target = parseTarget(href, provider);
77
+ if (!target) return null;
78
+ return '<a class="' + provider.cssClass + ' hosted-link-chip" href="' + escapeHtml(target.url.href) + '" target="_blank" rel="noopener noreferrer"' +
79
+ (title ? ' title="' + escapeHtml(title) + '"' : ' title="Open ' + escapeHtml(accessibleLabel(target, text)) + ' on ' + provider.displayName + '"') +
80
+ ' aria-label="Open ' + escapeHtml(accessibleLabel(target, text)) + ' on ' + provider.displayName + '">' + chipMarkup(target, text) + '</a>';
81
+ }
82
+
83
+ export function githubLinkTarget(href) {
84
+ var target = parseTarget(href, GITHUB);
85
+ if (!target) return null;
86
+ return { owner: target.owner, repo: target.repo, kind: target.kind, number: target.number || null, href: target.url.href, label: target.label };
87
+ }
88
+
89
+ export function gitlabLinkTarget(href) {
90
+ var target = parseTarget(href, GITLAB);
91
+ if (!target) return null;
92
+ return { owner: target.owner, repo: target.repo, kind: target.kind, number: target.number || null, href: target.url.href, label: target.label };
93
+ }
94
+
95
+ export function renderGithubLink(href, title, text) { return renderHostedLink(href, title, text, GITHUB); }
96
+ export function renderGitlabLink(href, title, text) { return renderHostedLink(href, title, text, GITLAB); }
97
+
98
+ export function enhanceGithubLinks(root) {
99
+ if (!root || typeof document === "undefined") return;
100
+ var anchors = root.querySelectorAll("a[href]");
101
+ for (var i = 0; i < anchors.length; i++) {
102
+ var anchor = anchors[i];
103
+ if (anchor.closest("code, pre")) continue;
104
+ var target = parseTarget(anchor.getAttribute("href"), anchor.classList.contains("gitlab-link-chip") ? GITLAB : GITHUB);
105
+ if (!target) { target = parseTarget(anchor.getAttribute("href"), GITLAB); if (!target) continue; }
106
+ var text = anchor.textContent || "";
107
+ if (anchor.classList.contains("github-link-chip") || anchor.classList.contains("gitlab-link-chip")) {
108
+ anchor.setAttribute("target", "_blank"); anchor.setAttribute("rel", "noopener noreferrer"); continue;
109
+ }
110
+ anchor.classList.add("hosted-link-chip", target.cssClass); anchor.setAttribute("target", "_blank"); anchor.setAttribute("rel", "noopener noreferrer");
111
+ anchor.setAttribute("aria-label", "Open " + accessibleLabel(target, text) + " on " + (target.provider === "gitlab" ? "GitLab" : "GitHub"));
112
+ anchor.innerHTML = chipMarkup(target, text);
113
+ }
114
+ refreshIcons();
115
+ }
@@ -4,6 +4,7 @@ import { getMermaidThemeVars } from './theme.js';
4
4
  import { store } from './store.js';
5
5
  import { enhanceClayLogLinks, renderClayRecordLink } from './clay-log-links.js';
6
6
  import { renderLocalFileLink, enhanceClayFileLinks } from './clay-file-links.js';
7
+ import { renderGithubLink, renderGitlabLink } from './github-links.js';
7
8
 
8
9
  // Initialize markdown parser
9
10
  marked.use({ gfm: true, breaks: false });
@@ -11,11 +12,17 @@ marked.use({ gfm: true, breaks: false });
11
12
  // Open external links in a new tab
12
13
  marked.use({
13
14
  renderer: {
14
- link({ href, title, text }) {
15
+ link({ href, title, tokens, text }) {
15
16
  var recordLink = renderClayRecordLink(href, text);
16
17
  if (recordLink) return recordLink;
17
18
  var localLink = renderLocalFileLink(href, title, text);
18
19
  if (localLink) return localLink;
20
+ var githubText = text;
21
+ if (tokens && this.parser && this.parser.parseInline) githubText = this.parser.parseInline(tokens);
22
+ var githubLink = renderGithubLink(href, title, githubText);
23
+ if (githubLink) return githubLink;
24
+ var gitlabLink = renderGitlabLink(href, title, githubText);
25
+ if (gitlabLink) return gitlabLink;
19
26
  var isExternal = href && (href.startsWith('http://') || href.startsWith('https://'));
20
27
  var titleAttr = title ? ' title="' + title + '"' : '';
21
28
  var targetAttr = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
@@ -56,7 +63,7 @@ export function renderMarkdown(text) {
56
63
  .replace(/[\u201C\u201D]/g, '"')
57
64
  .replace(/[\u2018\u2019]/g, "'")
58
65
  .replace(/\*\*[ \t]+([^*\n]+?)[ \t]+\*\*/g, "**$1**"));
59
- return DOMPurify.sanitize(marked.parse(normalized));
66
+ return DOMPurify.sanitize(marked.parse(normalized), { ADD_ATTR: ["target", "rel"] });
60
67
  }
61
68
 
62
69
  export function enhanceClaySessionLinks(root) {
@@ -4,6 +4,8 @@
4
4
  import { getWs } from './ws-ref.js';
5
5
  import { refreshIcons } from './icons.js';
6
6
  import { setHttpMcpServers } from './app-misc.js';
7
+ import { store } from './store.js';
8
+ import { showToast } from './utils.js';
7
9
 
8
10
  var modal = null;
9
11
  var contentEl = null;
@@ -11,6 +13,60 @@ var _mcpServers = []; // { name, transport, toolCount, extensionEnabled, project
11
13
  var _extensionConnected = false;
12
14
  var _nativeHostConnected = false;
13
15
  var _extensionId = null;
16
+ var MCP_PERMISSION_TIMEOUT_MS = 10000;
17
+ var _permissionModeTimers = {};
18
+
19
+ function pendingKey(projectSlug, sessionId, requestId) {
20
+ return String(projectSlug) + ":" + String(sessionId) + ":" + String(requestId);
21
+ }
22
+
23
+ function pendingMap() {
24
+ return store.get('mcpPermissionModePendingByTarget') || {};
25
+ }
26
+
27
+ function setPendingMap(next) {
28
+ store.set({ mcpPermissionModePendingByTarget: next });
29
+ }
30
+
31
+ function findPending(requestId) {
32
+ var pending = pendingMap();
33
+ var keys = Object.keys(pending);
34
+ for (var i = 0; i < keys.length; i++) {
35
+ if (pending[keys[i]].requestId === requestId) return { key: keys[i], value: pending[keys[i]] };
36
+ }
37
+ return null;
38
+ }
39
+
40
+ function clearPendingRequest(requestId) {
41
+ var found = findPending(requestId);
42
+ if (!found) return null;
43
+ if (_permissionModeTimers[requestId]) clearTimeout(_permissionModeTimers[requestId]);
44
+ delete _permissionModeTimers[requestId];
45
+ var next = Object.assign({}, pendingMap());
46
+ delete next[found.key];
47
+ setPendingMap(next);
48
+ return found.value;
49
+ }
50
+
51
+ export function clearMcpPermissionModePending() {
52
+ var requestIds = Object.keys(_permissionModeTimers);
53
+ for (var i = 0; i < requestIds.length; i++) clearTimeout(_permissionModeTimers[requestIds[i]]);
54
+ _permissionModeTimers = {};
55
+ if (Object.keys(pendingMap()).length) setPendingMap({});
56
+ }
57
+
58
+ export function mcpPermissionModeApplicability(state) {
59
+ if (!state.permissionCapabilities || state.permissionCapabilities.mcpOverride !== true) {
60
+ return { applicable: false, reason: "AI review is unavailable for this SDK session." };
61
+ }
62
+ if (state.effectivePermissionMode === "auto" || state.effectivePermissionMode === "bypassPermissions") {
63
+ return { applicable: true, reason: "" };
64
+ }
65
+ if (!state.effectivePermissionMode && state.currentMode === "auto") {
66
+ return { applicable: false, reason: "Waiting for the SDK to confirm Auto." };
67
+ }
68
+ return { applicable: false, reason: "AI review requires an active Auto session." };
69
+ }
14
70
 
15
71
  export function initMcp() {
16
72
  modal = document.getElementById("mcp-modal");
@@ -26,6 +82,14 @@ export function initMcp() {
26
82
  if (closeBtn) closeBtn.addEventListener("click", closeMcpModal);
27
83
  if (backdrop) backdrop.addEventListener("click", closeMcpModal);
28
84
 
85
+ store.subscribe(function (state, prev) {
86
+ var changed = state.activeSessionId !== prev.activeSessionId || state.currentSlug !== prev.currentSlug ||
87
+ state.currentMode !== prev.currentMode || state.effectivePermissionMode !== prev.effectivePermissionMode ||
88
+ state.permissionCapabilities !== prev.permissionCapabilities || state.mcpPermissionModeOverrides !== prev.mcpPermissionModeOverrides ||
89
+ state.mcpPermissionModePendingByTarget !== prev.mcpPermissionModePendingByTarget || state.connected !== prev.connected;
90
+ if (changed && modal && !modal.classList.contains("hidden")) renderMcpServerList();
91
+ });
92
+
29
93
  // ESC to close
30
94
  document.addEventListener("keydown", function (e) {
31
95
  if (e.key === "Escape" && modal && !modal.classList.contains("hidden")) {
@@ -140,21 +204,47 @@ function renderMcpServerList() {
140
204
 
141
205
  // Per-server permission mode override (applies to the active session only).
142
206
  if (server.projectEnabled) {
207
+ var permissionState = store.snap();
208
+ var applicability = mcpPermissionModeApplicability(permissionState);
209
+ if (!applicability.applicable) {
210
+ var unavailable = document.createElement("span");
211
+ unavailable.className = "mcp-server-perm-reason";
212
+ unavailable.textContent = applicability.reason;
213
+ row.appendChild(unavailable);
214
+ contentEl.appendChild(row);
215
+ continue;
216
+ }
143
217
  var modeSel = document.createElement("select");
144
218
  modeSel.className = "mcp-server-perm-mode";
145
219
  modeSel.dataset.serverName = server.name;
146
- modeSel.title = "Permission mode for this server (this session only)";
220
+ modeSel.title = "AI review policy for this server (this session only)";
221
+ var optInherit = document.createElement("option");
222
+ optInherit.value = "";
223
+ optInherit.textContent = "Inherit";
147
224
  var optAsk = document.createElement("option");
148
225
  optAsk.value = "default";
149
226
  optAsk.textContent = "Ask";
150
227
  var optAuto = document.createElement("option");
151
228
  optAuto.value = "auto";
152
- optAuto.textContent = "Auto-approve";
229
+ optAuto.textContent = "AI review";
230
+ modeSel.appendChild(optInherit);
153
231
  modeSel.appendChild(optAsk);
154
232
  modeSel.appendChild(optAuto);
155
- modeSel.value = server.permissionModeOverride === "auto" ? "auto" : "default";
233
+ var overrides = store.get('mcpPermissionModeOverrides') || {};
234
+ modeSel.value = overrides[server.name] === "auto" || overrides[server.name] === "default" ? overrides[server.name] : "";
235
+ var pending = pendingMap();
236
+ var pendingKeys = Object.keys(pending);
237
+ for (var p = 0; p < pendingKeys.length; p++) {
238
+ var pendingValue = pending[pendingKeys[p]];
239
+ if (pendingValue.projectSlug === permissionState.currentSlug && pendingValue.sessionId === permissionState.activeSessionId && pendingValue.serverName === server.name) {
240
+ modeSel.value = pendingValue.mode;
241
+ modeSel.disabled = true;
242
+ break;
243
+ }
244
+ }
245
+ if (!permissionState.connected) modeSel.disabled = true;
156
246
  // The row is a <label>; keep select interactions from toggling the checkbox.
157
- modeSel.addEventListener("click", function (e) { e.stopPropagation(); e.preventDefault(); });
247
+ modeSel.addEventListener("click", function (e) { e.stopPropagation(); });
158
248
  modeSel.addEventListener("change", onPermissionModeChange);
159
249
  row.appendChild(modeSel);
160
250
  }
@@ -318,22 +408,69 @@ function onToggle(e) {
318
408
 
319
409
  function onPermissionModeChange(e) {
320
410
  var name = e.target.dataset.serverName;
321
- var mode = e.target.value; // "default" | "auto"
322
-
323
- // Remember locally so a re-render keeps the selection.
324
- for (var i = 0; i < _mcpServers.length; i++) {
325
- if (_mcpServers[i].name === name) {
326
- _mcpServers[i].permissionModeOverride = mode;
327
- break;
328
- }
329
- }
411
+ sendMcpPermissionMode(store.get('currentSlug'), store.get('activeSessionId'), name, e.target.value);
412
+ }
330
413
 
414
+ export function sendMcpPermissionMode(projectSlug, sessionId, serverName, mode) {
415
+ var state = store.snap();
416
+ var applicability = mcpPermissionModeApplicability(state);
331
417
  var ws = getWs();
332
- if (ws && ws.readyState === 1) {
333
- ws.send(JSON.stringify({
418
+ if (!state.connected || state.currentSlug !== projectSlug || state.activeSessionId !== sessionId || !applicability.applicable || !ws || ws.readyState !== 1) {
419
+ showToast(applicability.applicable ? "Reconnect before changing AI review policy." : applicability.reason, "error");
420
+ return false;
421
+ }
422
+ var pending = pendingMap();
423
+ var pendingKeys = Object.keys(pending);
424
+ for (var i = 0; i < pendingKeys.length; i++) {
425
+ var item = pending[pendingKeys[i]];
426
+ if (item.projectSlug === projectSlug && item.sessionId === sessionId && item.serverName === serverName) return false;
427
+ }
428
+ var requestId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Date.now());
429
+ var key = pendingKey(projectSlug, sessionId, requestId);
430
+ var overrides = state.mcpPermissionModeOverrides || {};
431
+ var next = Object.assign({}, pending);
432
+ next[key] = {
433
+ projectSlug: projectSlug,
434
+ sessionId: sessionId,
435
+ requestId: requestId,
436
+ serverName: serverName,
437
+ mode: mode || "",
438
+ previousMode: overrides[serverName] || ""
439
+ };
440
+ setPendingMap(next);
441
+ _permissionModeTimers[requestId] = setTimeout(function () {
442
+ if (!clearPendingRequest(requestId)) return;
443
+ showToast("Changing AI review policy timed out.", "error");
444
+ }, MCP_PERMISSION_TIMEOUT_MS);
445
+ try {
446
+ var result = ws.send(JSON.stringify({
334
447
  type: "set_mcp_permission_mode_override",
335
- serverName: name,
336
- mode: mode,
448
+ projectSlug: projectSlug,
449
+ sessionId: sessionId,
450
+ requestId: requestId,
451
+ serverName: serverName,
452
+ mode: mode || null,
337
453
  }));
454
+ if (result === false) throw new Error("WebSocket send failed");
455
+ } catch (error) {
456
+ clearPendingRequest(requestId);
457
+ showToast("Failed to send the AI review policy change.", "error");
458
+ return false;
459
+ }
460
+ return true;
461
+ }
462
+
463
+ export function handleMcpPermissionModeResult(msg) {
464
+ var found = findPending(msg.requestId);
465
+ if (!found) return;
466
+ var pending = found.value;
467
+ if (msg.projectSlug && msg.projectSlug !== pending.projectSlug) return;
468
+ if (msg.sessionId !== pending.sessionId || msg.serverName !== pending.serverName) return;
469
+ clearPendingRequest(msg.requestId);
470
+ if (pending.projectSlug !== store.get('currentSlug') || pending.sessionId !== store.get('activeSessionId')) return;
471
+ if (!msg.ok) {
472
+ showToast(msg.error || "Failed to change AI review policy.", "error");
473
+ return;
338
474
  }
475
+ store.set({ mcpPermissionModeOverrides: msg.mcpPermissionModeOverrides || {} });
339
476
  }