clay-server 4.1.0-beta.14 → 4.1.0-beta.15

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.
package/lib/project.js CHANGED
@@ -1598,8 +1598,8 @@ function createProjectContext(opts) {
1598
1598
  }
1599
1599
 
1600
1600
  // --- DM messages (delegated to server-level handler) ---
1601
- if (msg.type === "home_debate_question_response" || msg.type === "home_debate_control" || msg.type === "home_mate_creation_question_response" || msg.type === "default_ai_get" || msg.type === "default_ai_catalog_get" || msg.type === "default_ai_set" || msg.type === "cursor_sharing_get" || msg.type === "cursor_sharing_set") {
1602
- if (typeof opts.onDmMessage === "function") opts.onDmMessage(ws, msg);
1601
+ if (msg.type === "home_debate_question_response" || msg.type === "home_debate_control" || msg.type === "home_mate_creation_question_response" || msg.type === "default_ai_get" || msg.type === "default_ai_catalog_get" || msg.type === "default_ai_set" || msg.type === "default_vendor_get" || msg.type === "default_vendor_set" || msg.type === "cursor_sharing_get" || msg.type === "cursor_sharing_set") {
1602
+ if (typeof opts.onDmMessage === "function") opts.onDmMessage(ws, msg, slug);
1603
1603
  return;
1604
1604
  }
1605
1605
  if (msg.type === "issue_reference_resolve" || msg.type === "home_clay_ask" || msg.type === "home_clay_session_resolve" || msg.type === "home_clay_log_resolve") {
package/lib/public/app.js CHANGED
@@ -71,6 +71,7 @@ import { rememberHomePrimarySurface } from './modules/home-surface.js';
71
71
  import { initRateLimit, handleRateLimitEvent as _rlHandleRateLimitEvent, updateRateLimitUsage as _rlUpdateRateLimitUsage, handleFastModeState as _rlHandleFastModeState, resetRateLimitState } from './modules/app-rate-limit.js';
72
72
  import { initCursors, handleRemoteCursorMove as _curHandleRemoteCursorMove, handleRemoteCursorLeave as _curHandleRemoteCursorLeave, handleRemoteSelection as _curHandleRemoteSelection, clearRemoteCursors as _curClearRemoteCursors, initCursorToggle } from './modules/app-cursors.js';
73
73
  import { initDefaultAi } from './modules/default-ai.js';
74
+ import { requestDefaultVendor } from './modules/default-vendor.js';
74
75
  import { initFavicon, updateFavicon as _favUpdateFavicon, setSendBtnMode as _favSetSendBtnMode, blinkIO as _favBlinkIO, blinkSessionDot as _favBlinkSessionDot, updateCrossProjectBlink as _favUpdateCrossProjectBlink, startUrgentBlink as _favStartUrgentBlink, stopUrgentBlink as _favStopUrgentBlink, setActivity as _favSetActivity } from './modules/app-favicon.js';
75
76
  import { initHeader, closeSessionInfoPopover as _hdrCloseSessionInfoPopover, updateHistorySentinel as _hdrUpdateHistorySentinel, requestMoreHistory as _hdrRequestMoreHistory, prependOlderHistory as _hdrPrependOlderHistory } from './modules/app-header.js';
76
77
  import { initSessionActions } from './modules/session-actions.js';
@@ -427,6 +428,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
427
428
  defaultAiState: { loading: false, saving: false, refreshRequestId: null, saveRequestId: null, catalogRequestIds: {}, catalogs: {}, preference: null, selection: null, installedVendors: [], accountAvailable: true, accountId: null, serverEpoch: null, canonicalRevision: 0, error: "" },
428
429
  defaultAiDraft: { vendor: "", model: "", effort: "" },
429
430
  defaultAiDraftDirty: false,
431
+ defaultVendorState: { loading: false, saving: false, getRequestId: null, saveRequestId: null, preference: null, preferencePresent: false, installedVendors: [], accountId: null, projectSlug: null, serverEpoch: null, canonicalRevision: 0, error: "" },
430
432
 
431
433
  // dm
432
434
  dmTargetUser: null,
@@ -1024,7 +1026,7 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
1024
1026
  // the project Mate DM preference therefore hides only Mate shortcuts.
1025
1027
  if (document.body) document.body.classList.add('is-multi-user');
1026
1028
  }
1027
- if (d.user && d.user.id) { store.set({ myUserId: d.user.id }); }
1029
+ if (d.user && d.user.id) { store.set({ myUserId: d.user.id }); requestDefaultVendor(); }
1028
1030
  if (d.permissions) store.set({ permissions: d.permissions });
1029
1031
  if (d.mustChangePin) showForceChangePinOverlay();
1030
1032
  // Single-user mode: clear user strip skeletons immediately (no presence message will arrive)
@@ -423,6 +423,13 @@
423
423
  .mobile-vendor-list .mobile-session-new-vendor:active {
424
424
  background: rgba(var(--overlay-rgb), 0.1);
425
425
  }
426
+ .mobile-vendor-row { display: flex; align-items: stretch; gap: 4px; }
427
+ .mobile-vendor-row .mobile-session-new-vendor { flex: 1 1 auto; min-width: 0; }
428
+ .mobile-vendor-set-default { border: 1px solid var(--border-subtle); border-radius: 8px; background: transparent; color: var(--text-dimmer); padding: 0 8px; font-size: 11px; white-space: nowrap; }
429
+ .mobile-vendor-set-default:active { background: rgba(var(--overlay-rgb), 0.1); }
430
+ .mobile-vendor-set-default:disabled { opacity: 0.55; }
431
+ .mobile-session-new-preference-status { padding: 4px 8px 8px; color: var(--error); font-size: 11px; }
432
+ .mobile-session-new-fallback-note { color: var(--text-muted); }
426
433
  .mobile-vendor-note {
427
434
  margin-left: auto;
428
435
  font-size: 12px;
@@ -1464,6 +1464,13 @@
1464
1464
 
1465
1465
  /* --- New-session vendor picker --- */
1466
1466
  .session-new-menu { min-width: 200px; }
1467
+ .session-new-vendor-row { display: flex; align-items: stretch; }
1468
+ .session-new-vendor-row .session-new-vendor { flex: 1 1 auto; min-width: 0; }
1469
+ .session-new-set-default { border: 0; background: transparent; color: var(--text-dimmer); padding: 0 8px; font-size: 10px; cursor: pointer; }
1470
+ .session-new-set-default:hover { color: var(--text); background: rgba(var(--overlay-rgb), 0.05); }
1471
+ .session-new-set-default:disabled { opacity: 0.55; cursor: wait; }
1472
+ .session-new-preference-status { padding: 6px 10px; color: var(--error); font-size: 11px; }
1473
+ .session-new-fallback-note { color: var(--text-muted); border-bottom: 1px solid var(--border); }
1467
1474
 
1468
1475
  .session-ctx-sep {
1469
1476
  height: 1px;
@@ -17,6 +17,7 @@ import { resumeHomeChat } from './home-mate-chat.js';
17
17
  import { requestHomeSurfacePreference } from './home-surface.js';
18
18
  import { isHomeDebatesSurface } from './home-sub-surface.js';
19
19
  import { beginDefaultAiConnection, requestDefaultAi } from './default-ai.js';
20
+ import { beginDefaultVendorConnection, requestDefaultVendor } from './default-vendor.js';
20
21
 
21
22
  var reconnectTimer = null;
22
23
  var reconnectDelay = 1000;
@@ -277,6 +278,8 @@ export function connect() {
277
278
  onConnected();
278
279
  beginDefaultAiConnection();
279
280
  requestDefaultAi();
281
+ beginDefaultVendorConnection();
282
+ requestDefaultVendor();
280
283
  };
281
284
 
282
285
  newWs.onclose = function (e) {
@@ -19,6 +19,7 @@ import { refreshMobileChatSheet } from './sidebar-mobile.js';
19
19
  import { renderMateSessionList, handleMateSearchResults, updateMateSidebarProfile } from './mate-sidebar.js';
20
20
  import { openHomeChat, handleHomeMateHistory, handleHomeMateDelta, handleHomeMateSegment, handleHomeMateDone, handleHomeMateError, handleHomeMateSessionsState } from './home-mate-chat.js';
21
21
  import { handleHomeSurfaceState } from './home-surface.js';
22
+ import { handleDefaultVendorMessage } from './default-vendor.js';
22
23
  import { handleHomeMateMemoryState, handleHomeMateKnowledgeState } from './home-mate-settings.js';
23
24
  import { renderKnowledgeList, handleKnowledgeContent } from './mate-knowledge.js';
24
25
  import { renderMemoryList } from './mate-memory.js';
@@ -65,7 +66,7 @@ import { closeArticle as closeWhatsNewArticle } from './whats-new-article.js';
65
66
  import { resolvePaneSession, resolveSwitchedVendor } from './pane-session.js';
66
67
  import { selectDefaultVendorForBlankSession } from './vendor-selection.js';
67
68
  import { getModelInfoUpdate, modelEntryValue, modelEntryMatches, handleModelSelectionResult, requestVendorModels } from './model-picker.js';
68
- import { getModelEffortLevels, accumulateUsage, updateUsagePanel, accumulateContext, updateContextPanel, renderCtxPopover, updateStatusPanel } from './app-panels.js';
69
+ import { getModelEffortLevels, accumulateUsage, updateUsagePanel, accumulateContext, updateContextPanel, renderCtxPopover, updateStatusPanel, markContextUnavailable } from './app-panels.js';
69
70
  import { updateProjectList, resetClientState, showUpdateAvailable, handleRemoveProjectCheckResult, handleRemoveProjectResult, handleBrowseDirResult, handleAddProjectResult, handleCloneProgress, finishProjectSessionActivation } from './app-projects.js';
70
71
  import { updateHistorySentinel, prependOlderHistory } from './app-header.js';
71
72
  import { hideHomeHub, showHomeHub } from './app-home-hub.js';
@@ -97,6 +98,7 @@ export function processMessage(msg) {
97
98
  if (handleAutonomousRunMessage(msg)) return;
98
99
  if (handleLoopInterviewMessage(msg)) return;
99
100
  if (handleScheduledTaskMessage(msg)) return;
101
+ if (handleDefaultVendorMessage(msg)) return;
100
102
  if (msg && msg.type === "schedule_message_result") {
101
103
  handleScheduleMessageResult(msg);
102
104
  return;
@@ -226,9 +228,8 @@ export function processMessage(msg) {
226
228
  applyDeadSessionTodoCompaction();
227
229
  }
228
230
  // Restore cached rich context usage BEFORE updateContextPanel runs
229
- if (msg.contextUsage) {
230
- store.set({ richContextUsage: msg.contextUsage });
231
- }
231
+ if (msg.contextUsage && !msg.contextUsage.unavailable) store.set({ richContextUsage: msg.contextUsage });
232
+ else markContextUnavailable();
232
233
  // Restore accurate context data from the last result in full history
233
234
  if (msg.lastUsage || msg.lastModelUsage) {
234
235
  accumulateContext(msg.lastCost, msg.lastUsage, msg.lastModelUsage, msg.lastStreamInputTokens);
@@ -1273,7 +1274,8 @@ export function processMessage(msg) {
1273
1274
  case "context_usage":
1274
1275
  if (msg.sessionId != null && msg.sessionId !== store.get("activeSessionId")) break;
1275
1276
  if (msg.data && !store.get('replayingHistory')) {
1276
- store.set({ richContextUsage: msg.data });
1277
+ if (msg.data.unavailable) markContextUnavailable();
1278
+ else store.set({ richContextUsage: msg.data });
1277
1279
  // UI sync handled by store subscriber in app-panels.js
1278
1280
  }
1279
1281
  break;
@@ -10,7 +10,7 @@ import { setupModelPicker, renderModelPicker, requestVendorModels, prepareModelP
10
10
 
11
11
  // --- Module-owned state (not in store) ---
12
12
  var sessionUsage = { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 };
13
- var contextData = { contextWindow: 0, maxOutputTokens: 0, model: "-", cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0, thinking: null, costBasis: null };
13
+ var contextData = { contextWindow: 0, currentInput: null, maxOutputTokens: 0, model: "-", cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0, thinking: null, costBasis: null };
14
14
  var ctxPopoverEl = null;
15
15
  var ctxHoverTimer = null;
16
16
  var statusRefreshTimer = null;
@@ -718,9 +718,18 @@ export function contextPctClass(pct) {
718
718
 
719
719
  export function updateContextPanel() {
720
720
  if (!contextUsedEl) return;
721
- // Context window usage = input tokens only (includes cache read/write)
722
- var used = contextData.input;
723
- var win = contextData.contextWindow;
721
+ // Context occupancy is separate from generic per-turn accounting. Codex
722
+ // only supplies currentInput from a verified last-turn snapshot; rich
723
+ // context usage is more authoritative when available.
724
+ var rich = store.get('richContextUsage');
725
+ var hasRich = rich && (
726
+ (typeof rich.totalTokens === "number" && typeof rich.maxTokens === "number" && rich.maxTokens > 0) ||
727
+ (typeof rich.input_tokens === "number" && typeof rich.contextWindow === "number" && rich.contextWindow > 0)
728
+ );
729
+ var hasCurrent = typeof contextData.currentInput === "number";
730
+ var hasContext = !!(hasRich || hasCurrent);
731
+ var used = hasRich ? (typeof rich.totalTokens === "number" ? rich.totalTokens : rich.input_tokens) : (hasCurrent ? contextData.currentInput : 0);
732
+ var win = hasRich ? (typeof rich.maxTokens === "number" ? rich.maxTokens : rich.contextWindow) : (hasCurrent ? contextData.contextWindow : 0);
724
733
  var pct = win > 0 ? Math.min(100, (used / win) * 100) : 0;
725
734
  var cls = contextPctClass(pct);
726
735
  reportPaneContext({ pct: pct, used: used, win: win, cls: cls, model: contextData.model, cost: contextData.cost });
@@ -734,7 +743,7 @@ export function updateContextPanel() {
734
743
  contextMiniFill.className = "context-mini-fill" + cls;
735
744
  }
736
745
  if (contextMiniLabel) {
737
- contextMiniLabel.textContent = (win > 0 ? formatTokens(used) + "/" + formatTokens(win) : "0%");
746
+ contextMiniLabel.textContent = (win > 0 ? formatTokens(used) + "/" + formatTokens(win) : "-");
738
747
  }
739
748
  // Header bar. A split group has no meaningful group-level context usage:
740
749
  // the two members have separate windows, and the parent shell's own numbers
@@ -742,9 +751,9 @@ export function updateContextPanel() {
742
751
  // chip instead, so hide the title-bar gauge while a split is open.
743
752
  var splitOpen = !!store.get('splitPanes');
744
753
  var headerCtxEl = store.get('headerContextEl');
745
- if (headerCtxEl) headerCtxEl.classList.toggle("hidden", splitOpen);
746
- if (contextMini) contextMini.classList.toggle("split-hidden", splitOpen);
747
- if (pct > 0 && !splitOpen) {
754
+ if (headerCtxEl) headerCtxEl.classList.toggle("hidden", splitOpen || !hasContext);
755
+ if (contextMini) contextMini.classList.toggle("split-hidden", splitOpen || !hasContext);
756
+ if (hasContext && !splitOpen) {
748
757
  var statusArea = document.querySelector(".title-bar-content .status");
749
758
  var hCtxEl = store.get('headerContextEl');
750
759
  if (statusArea && !hCtxEl) {
@@ -808,7 +817,7 @@ export function accumulateContext(cost, usage, modelUsage, lastStreamInputTokens
808
817
  // when available -- result.usage.input_tokens sums all API calls in a turn,
809
818
  // inflating context usage when tools are involved.
810
819
  // Falls back to the summed value for setups that don't emit message_start.
811
- if (lastStreamInputTokens) {
820
+ if (typeof lastStreamInputTokens === "number") {
812
821
  contextData.input = lastStreamInputTokens;
813
822
  } else {
814
823
  contextData.input = (usage.input_tokens || usage.inputTokens || 0)
@@ -817,6 +826,11 @@ export function accumulateContext(cost, usage, modelUsage, lastStreamInputTokens
817
826
  contextData.output = usage.output_tokens || usage.outputTokens || 0;
818
827
  contextData.cacheRead = usage.cache_read_input_tokens || usage.cacheReadInputTokens || 0;
819
828
  contextData.cacheWrite = usage.cache_creation_input_tokens || usage.cacheCreationInputTokens || 0;
829
+ if (store.get('currentVendor') === "codex") {
830
+ contextData.currentInput = typeof lastStreamInputTokens === "number" ? lastStreamInputTokens : null;
831
+ } else {
832
+ contextData.currentInput = typeof lastStreamInputTokens === "number" ? lastStreamInputTokens : contextData.input;
833
+ }
820
834
  }
821
835
  contextData.turns++;
822
836
  if (modelUsage) {
@@ -830,6 +844,21 @@ export function accumulateContext(cost, usage, modelUsage, lastStreamInputTokens
830
844
  contextData.model = displayModel;
831
845
  contextData.contextWindow = resolveContextWindow(displayModel, mu.contextWindow);
832
846
  if (mu.maxOutputTokens) contextData.maxOutputTokens = mu.maxOutputTokens;
847
+ if (store.get('currentVendor') === "codex") {
848
+ var snapshot = mu.contextSnapshot;
849
+ if (snapshot) store.set({ richContextUsage: null });
850
+ if (snapshot && snapshot.valid === true
851
+ && typeof snapshot.inputTokens === "number"
852
+ && typeof snapshot.contextWindow === "number"
853
+ && snapshot.contextWindow > 0) {
854
+ contextData.currentInput = snapshot.inputTokens;
855
+ contextData.contextWindow = snapshot.contextWindow;
856
+ } else {
857
+ // An explicit invalid marker is authoritative: do not retain an
858
+ // occupancy value from the previous live turn or history replay.
859
+ contextData.currentInput = null;
860
+ }
861
+ }
833
862
 
834
863
  // thinkingTokens and costBasis are per-model and optional: absent on
835
864
  // older CLI builds and, for a resumed session, on the turns that ran
@@ -874,11 +903,17 @@ export function applyContextView(view) {
874
903
  }
875
904
 
876
905
  export function resetContextData() {
877
- contextData = { contextWindow: 0, maxOutputTokens: 0, model: "-", cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0, thinking: null, costBasis: null };
906
+ contextData = { contextWindow: 0, currentInput: null, maxOutputTokens: 0, model: "-", cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0, thinking: null, costBasis: null };
878
907
  store.set({ richContextUsage: null });
879
908
  // hideCtxPopover + updateContextPanel handled by store subscriber
880
909
  }
881
910
 
911
+ export function markContextUnavailable() {
912
+ contextData.currentInput = null;
913
+ store.set({ richContextUsage: null });
914
+ updateContextPanel();
915
+ }
916
+
882
917
  export function resetContext() {
883
918
  resetContextData();
884
919
  // Keep view state, just reset data
@@ -952,8 +987,8 @@ export function renderCtxPopover() {
952
987
  if (!ctxPopoverEl || !richContextUsage) return;
953
988
  var d = richContextUsage;
954
989
  var cats = d.categories || [];
955
- var total = d.totalTokens || 0;
956
- var max = d.maxTokens || 0;
990
+ var total = d.totalTokens != null ? d.totalTokens : (d.input_tokens || 0);
991
+ var max = d.maxTokens != null ? d.maxTokens : (d.contextWindow || 0);
957
992
  var pct = d.percentage != null ? d.percentage : (max > 0 ? (total / max) * 100 : 0);
958
993
 
959
994
  var html = "";
@@ -0,0 +1,116 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+ import { firstInstalledVendor } from './vendor-priority.js';
4
+
5
+ function send(message) {
6
+ var ws = getWs();
7
+ if (!ws || ws.readyState !== 1) return false;
8
+ ws.send(JSON.stringify(message));
9
+ return true;
10
+ }
11
+
12
+ var sequence = 0;
13
+
14
+ function nextRequestId(prefix) {
15
+ sequence += 1;
16
+ return prefix + Date.now() + "-" + sequence;
17
+ }
18
+
19
+ function emptyState(accountId, projectSlug) {
20
+ return { loading: false, saving: false, getRequestId: null, saveRequestId: null, preference: null, preferencePresent: false, installedVendors: [], accountId: accountId || null, projectSlug: projectSlug || null, serverEpoch: null, canonicalRevision: 0, error: "" };
21
+ }
22
+
23
+ export function requestDefaultVendor() {
24
+ var id = nextRequestId("default-vendor-");
25
+ var state = store.get("defaultVendorState") || {};
26
+ store.set({ defaultVendorState: Object.assign({}, state, { loading: true, getRequestId: id, error: "" }) });
27
+ if (!send({ type: "default_vendor_get", requestId: id })) {
28
+ store.set({ defaultVendorState: Object.assign({}, store.get("defaultVendorState") || {}, { loading: false, getRequestId: null, error: "Clay is offline. Reconnect and try again." }) });
29
+ return false;
30
+ }
31
+ return true;
32
+ }
33
+
34
+ export function beginDefaultVendorConnection() {
35
+ var state = store.get("defaultVendorState") || {};
36
+ store.set({ defaultVendorState: Object.assign({}, state, {
37
+ loading: false,
38
+ saving: false,
39
+ getRequestId: null,
40
+ saveRequestId: null,
41
+ preference: null,
42
+ preferencePresent: false,
43
+ installedVendors: [],
44
+ accountId: store.get("myUserId") || "default",
45
+ projectSlug: store.get("currentSlug") || null,
46
+ serverEpoch: null,
47
+ canonicalRevision: 0,
48
+ error: "",
49
+ }) });
50
+ }
51
+
52
+ export function saveDefaultVendor(vendor) {
53
+ var state = store.get("defaultVendorState") || {};
54
+ if (vendor !== "" && (state.installedVendors || []).indexOf(vendor) === -1) return false;
55
+ var id = nextRequestId("default-vendor-save-");
56
+ store.set({ defaultVendorState: Object.assign({}, state, { saving: true, saveRequestId: id, error: "" }) });
57
+ if (!send({ type: "default_vendor_set", requestId: id, vendor: vendor })) {
58
+ store.set({ defaultVendorState: Object.assign({}, store.get("defaultVendorState") || {}, { saving: false, saveRequestId: null, error: "Clay is offline. Reconnect and try again." }) });
59
+ return false;
60
+ }
61
+ return true;
62
+ }
63
+
64
+ export function handleDefaultVendorMessage(msg) {
65
+ if (!msg || msg.type !== "default_vendor_state") return false;
66
+ var state = store.get("defaultVendorState") || {};
67
+ var accountId = store.get("myUserId") || "default";
68
+ var projectSlug = store.get("currentSlug") || null;
69
+ var isGetReply = !!msg.requestId && msg.requestId === state.getRequestId;
70
+ var isSaveReply = !!msg.requestId && msg.requestId === state.saveRequestId;
71
+ var isCorrelatedAccountFailure = (isGetReply || isSaveReply) && msg.accountId === null && msg.accountAvailable === false;
72
+ if ((!isCorrelatedAccountFailure && msg.accountId !== accountId) || (msg.accountId === null && !isCorrelatedAccountFailure)) return true;
73
+ if (Object.prototype.hasOwnProperty.call(msg, "projectSlug") && msg.projectSlug !== projectSlug) return true;
74
+ if (msg.serverEpoch && state.serverEpoch && msg.serverEpoch !== state.serverEpoch && (!msg.requestId || (msg.requestId !== state.getRequestId && msg.requestId !== state.saveRequestId))) return true;
75
+ if (msg.canonicalRevision !== undefined && msg.canonicalRevision < (state.canonicalRevision || 0) && msg.serverEpoch === state.serverEpoch) return true;
76
+ if (msg.requestId && !isGetReply && !isSaveReply) return true;
77
+ if (msg.requestId && !isSaveReply && msg.ready === false && state.saving) return true;
78
+ var next = Object.assign({}, state, {
79
+ accountId: msg.accountId || accountId,
80
+ projectSlug: projectSlug,
81
+ serverEpoch: msg.serverEpoch || state.serverEpoch || null,
82
+ canonicalRevision: typeof msg.canonicalRevision === "number" ? msg.canonicalRevision : state.canonicalRevision || 0,
83
+ installedVendors: Array.isArray(msg.installedVendors) ? msg.installedVendors : state.installedVendors || [],
84
+ loading: isGetReply ? false : state.loading,
85
+ saving: isSaveReply ? false : state.saving,
86
+ getRequestId: isGetReply ? null : state.getRequestId,
87
+ saveRequestId: isSaveReply ? null : state.saveRequestId,
88
+ error: msg.error || ""
89
+ });
90
+ if (Object.prototype.hasOwnProperty.call(msg, "preference")) {
91
+ next.preference = msg.preference || null;
92
+ next.preferencePresent = msg.preferencePresent === true;
93
+ }
94
+ store.set({ defaultVendorState: next });
95
+ return true;
96
+ }
97
+
98
+ store.subscribe(function (state, previous) {
99
+ if (state.myUserId !== previous.myUserId || state.currentSlug !== previous.currentSlug) {
100
+ var accountId = state.myUserId || "default";
101
+ store.set({ defaultVendorState: emptyState(accountId, state.currentSlug || null) });
102
+ if (state.connected) requestDefaultVendor();
103
+ }
104
+ });
105
+
106
+ export function resolvePreferredVendor(installedVendors) {
107
+ var installed = Array.isArray(installedVendors) ? installedVendors : [];
108
+ var state = store.get("defaultVendorState") || {};
109
+ if (state.preference && installed.indexOf(state.preference) !== -1) return state.preference;
110
+ return firstInstalledVendor(installed);
111
+ }
112
+
113
+ export function defaultVendorUnavailable(installedVendors) {
114
+ var state = store.get("defaultVendorState") || {};
115
+ return !!state.preference && (installedVendors || []).indexOf(state.preference) === -1;
116
+ }
@@ -15,6 +15,7 @@ import {
15
15
  startNewSession
16
16
  } from './sidebar-sessions.js';
17
17
  import { VENDOR_AVATARS, VENDOR_NAMES, VENDOR_ORDER, VENDOR_HOMEPAGES, isExperimentalVendor } from './app-rendering.js';
18
+ import { defaultVendorUnavailable, resolvePreferredVendor, saveDefaultVendor } from './default-vendor.js';
18
19
  import {
19
20
  getCachedProjectList,
20
21
  getCachedCurrentSlug,
@@ -675,14 +676,17 @@ function renderMobileSessionsInto(container) {
675
676
  // the project's last-used vendor, plus a chevron that expands the full
676
677
  // vendor list. The list expands inline rather than as a fixed-position
677
678
  // popover because it lives inside a scrolling bottom sheet.
678
- var mobileDefaultVendor = resolveDefaultVendor();
679
+ var mobileDefaultVendor = resolvePreferredVendor(store.get('installedVendors') || []) || resolveDefaultVendor();
679
680
  var mobileInstalled = store.get('installedVendors') || [];
681
+ var mobileDefaultState = store.get('defaultVendorState') || {};
680
682
 
681
683
  var newRow = document.createElement("div");
682
684
  newRow.className = "mobile-session-new-row";
683
685
 
684
686
  var mainBtn = document.createElement("button");
685
687
  mainBtn.className = "mobile-session-new mobile-session-new-main";
688
+ mainBtn.title = "New " + (VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor) + " session";
689
+ if (defaultVendorUnavailable(mobileInstalled)) mainBtn.title += " (saved default is unavailable; using installed priority)";
686
690
  mainBtn.innerHTML = '<img src="' + (VENDOR_AVATARS[mobileDefaultVendor] || VENDOR_AVATARS.claude) +
687
691
  '" class="mobile-session-new-icon" alt=""><span>New ' + (VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor) + ' session</span>';
688
692
  mainBtn.addEventListener("click", function () {
@@ -704,12 +708,20 @@ function renderMobileSessionsInto(container) {
704
708
  });
705
709
  newRow.appendChild(chevronBtn);
706
710
 
711
+ if (defaultVendorUnavailable(mobileInstalled) && mobileDefaultState.preference) {
712
+ var mobileFallbackNote = document.createElement("div");
713
+ mobileFallbackNote.className = "mobile-session-new-preference-status mobile-session-new-fallback-note";
714
+ mobileFallbackNote.textContent = "Saved default " + (VENDOR_NAMES[mobileDefaultState.preference] || mobileDefaultState.preference) + " is unavailable. Using " + (VENDOR_NAMES[mobileDefaultVendor] || mobileDefaultVendor) + ".";
715
+ }
716
+
707
717
  // Every known vendor is listed. Uninstalled ones stay visible but disabled,
708
718
  // and tapping one opens the vendor's homepage.
709
719
  for (var vi = 0; vi < VENDOR_ORDER.length; vi++) {
710
720
  (function (vendor) {
711
721
  var isInstalled = mobileInstalled.indexOf(vendor) !== -1;
712
722
  var name = VENDOR_NAMES[vendor] || vendor;
723
+ var vendorRow = document.createElement("div");
724
+ vendorRow.className = "mobile-vendor-row";
713
725
  var vBtn = document.createElement("button");
714
726
  vBtn.className = "mobile-session-new mobile-session-new-vendor";
715
727
  if (!isInstalled) vBtn.classList.add("disabled");
@@ -726,11 +738,46 @@ function renderMobileSessionsInto(container) {
726
738
  startNewSession(vendor);
727
739
  closeMobileSheet();
728
740
  });
729
- vendorList.appendChild(vBtn);
741
+ vendorRow.appendChild(vBtn);
742
+ if (isInstalled) {
743
+ var defaultBtn = document.createElement("button");
744
+ defaultBtn.type = "button";
745
+ defaultBtn.className = "mobile-vendor-set-default";
746
+ defaultBtn.textContent = mobileDefaultState.saving ? "Saving…" : (vendor === mobileDefaultVendor && !defaultVendorUnavailable(mobileInstalled) ? "Default" : "Set default");
747
+ defaultBtn.disabled = mobileDefaultState.saving === true;
748
+ defaultBtn.setAttribute("aria-label", "Set " + name + " as default vendor");
749
+ defaultBtn.addEventListener("click", function (e) {
750
+ e.preventDefault();
751
+ e.stopPropagation();
752
+ saveDefaultVendor(vendor);
753
+ });
754
+ vendorRow.appendChild(defaultBtn);
755
+ }
756
+ vendorList.appendChild(vendorRow);
730
757
  })(VENDOR_ORDER[vi]);
731
758
  }
732
759
 
760
+ var automaticBtn = document.createElement("button");
761
+ automaticBtn.className = "mobile-session-new mobile-session-new-vendor mobile-session-new-automatic";
762
+ automaticBtn.type = "button";
763
+ automaticBtn.textContent = "Use automatic default";
764
+ automaticBtn.disabled = mobileDefaultState.saving === true;
765
+ automaticBtn.addEventListener("click", function (e) {
766
+ e.preventDefault();
767
+ e.stopPropagation();
768
+ saveDefaultVendor("");
769
+ });
770
+ vendorList.appendChild(automaticBtn);
771
+ if (mobileDefaultState.error) {
772
+ var mobileStatus = document.createElement("div");
773
+ mobileStatus.className = "mobile-session-new-preference-status";
774
+ mobileStatus.setAttribute("role", "alert");
775
+ mobileStatus.textContent = mobileDefaultState.error;
776
+ vendorList.appendChild(mobileStatus);
777
+ }
778
+
733
779
  container.appendChild(newRow);
780
+ if (mobileFallbackNote) container.appendChild(mobileFallbackNote);
734
781
  container.appendChild(vendorList);
735
782
 
736
783
  // Partition: loop sessions vs normal sessions (same logic as desktop renderSessionList)
@@ -15,6 +15,7 @@ import { refreshMobileChatSheet } from './sidebar-mobile.js';
15
15
  import { VENDOR_AVATARS, VENDOR_NAMES, VENDOR_ORDER, VENDOR_HOMEPAGES, isExperimentalVendor } from './app-rendering.js';
16
16
  import { openGroup, separateGroup } from './split-view.js';
17
17
  import { groupedSessionIds } from './split-group-helpers.js';
18
+ import { defaultVendorUnavailable, resolvePreferredVendor, saveDefaultVendor } from './default-vendor.js';
18
19
  import { openPairDialog } from './split-pair-ui.js';
19
20
  import {
20
21
  hierarchyItemMatches,
@@ -41,6 +42,7 @@ var countdownContainer = null;
41
42
 
42
43
  // --- Session context menu ---
43
44
  var sessionCtxMenu = null;
45
+ var sessionCtxMenuAnchor = null;
44
46
  var sessionCtxSessionId = null;
45
47
  var draggedSessionId = null;
46
48
  var draggedSessionBookmarked = false;
@@ -48,6 +50,18 @@ var headerSearchOpen = false;
48
50
  var armedDeleteSessionId = null;
49
51
  var armedDeleteTimer = null;
50
52
 
53
+ store.subscribe(function (state, previous) {
54
+ if (state.defaultVendorState !== previous.defaultVendorState || state.installedVendors !== previous.installedVendors) {
55
+ var reopenNewMenu = !!(sessionCtxMenu && sessionCtxMenuAnchor);
56
+ if (reopenNewMenu) closeSessionCtxMenu();
57
+ if (getSessionListEl()) renderSessionList();
58
+ if (reopenNewMenu) {
59
+ var freshAnchor = getSessionListEl().querySelector(".session-top-action.split-chevron");
60
+ if (freshAnchor) showNewSessionMenu(freshAnchor, resolveDefaultVendor());
61
+ }
62
+ }
63
+ });
64
+
51
65
  function sendSessionBookmark(sessionId, bookmarked) {
52
66
  if (getWs() && store.get('connected')) {
53
67
  getWs().send(JSON.stringify({ type: "set_session_bookmark", sessionId: sessionId, bookmarked: !!bookmarked }));
@@ -317,10 +331,7 @@ function appendSessionCloseButton(el, session) {
317
331
  // installed-vendor priority (Claude, then Codex, then the remaining vendors).
318
332
  export function resolveDefaultVendor() {
319
333
  var installed = store.get('installedVendors') || [];
320
- for (var i = 0; i < VENDOR_ORDER.length; i++) {
321
- if (installed.indexOf(VENDOR_ORDER[i]) !== -1) return VENDOR_ORDER[i];
322
- }
323
- return "claude";
334
+ return resolvePreferredVendor(installed) || "claude";
324
335
  }
325
336
 
326
337
  // Single entry point for session creation from the sidebar. Optimistically
@@ -361,6 +372,7 @@ function renderSessionTopActions() {
361
372
  mainBtn.className = "session-top-action split-main";
362
373
  mainBtn.type = "button";
363
374
  mainBtn.title = "New " + (VENDOR_NAMES[defaultVendor] || defaultVendor) + " session";
375
+ if (defaultVendorUnavailable(store.get('installedVendors') || [])) mainBtn.title += " (saved default is unavailable; using installed priority)";
364
376
  mainBtn.innerHTML = '<img src="' + (VENDOR_AVATARS[defaultVendor] || VENDOR_AVATARS.claude) +
365
377
  '" class="session-top-action-icon" alt=""><span>New session</span>';
366
378
  mainBtn.addEventListener("click", function () {
@@ -394,6 +406,15 @@ function showNewSessionMenu(anchorBtn, defaultVendor) {
394
406
  var menu = document.createElement("div");
395
407
  menu.className = "session-ctx-menu session-new-menu";
396
408
  var installed = store.get('installedVendors') || [];
409
+ var defaultVendorState = store.get('defaultVendorState') || {};
410
+ sessionCtxMenuAnchor = anchorBtn;
411
+
412
+ if (defaultVendorUnavailable(installed) && defaultVendorState.preference) {
413
+ var fallbackNote = document.createElement("div");
414
+ fallbackNote.className = "session-new-preference-status session-new-fallback-note";
415
+ fallbackNote.textContent = "Saved default " + (VENDOR_NAMES[defaultVendorState.preference] || defaultVendorState.preference) + " is unavailable. Using " + (VENDOR_NAMES[defaultVendor] || defaultVendor) + ".";
416
+ menu.appendChild(fallbackNote);
417
+ }
397
418
 
398
419
  // Every known vendor is listed. Uninstalled ones stay visible but disabled,
399
420
  // and clicking one opens the vendor's homepage so the user can install it.
@@ -401,6 +422,8 @@ function showNewSessionMenu(anchorBtn, defaultVendor) {
401
422
  (function (vendor) {
402
423
  var isInstalled = installed.indexOf(vendor) !== -1;
403
424
  var name = VENDOR_NAMES[vendor] || vendor;
425
+ var row = document.createElement("div");
426
+ row.className = "session-new-vendor-row";
404
427
  var item = document.createElement("button");
405
428
  item.className = "session-ctx-item session-new-vendor";
406
429
  if (!isInstalled) item.classList.add("disabled");
@@ -423,10 +446,46 @@ function showNewSessionMenu(anchorBtn, defaultVendor) {
423
446
  }
424
447
  startNewSession(vendor);
425
448
  });
426
- menu.appendChild(item);
449
+ row.appendChild(item);
450
+ if (isInstalled) {
451
+ var defaultBtn = document.createElement("button");
452
+ defaultBtn.type = "button";
453
+ defaultBtn.className = "session-new-set-default";
454
+ defaultBtn.textContent = defaultVendorState.saving ? "Saving…" : (vendor === defaultVendor && !defaultVendorUnavailable(installed) ? "Default" : "Set default");
455
+ defaultBtn.disabled = defaultVendorState.saving === true;
456
+ defaultBtn.setAttribute("aria-label", "Set " + name + " as default vendor");
457
+ defaultBtn.title = "Use " + name + " for New session by default";
458
+ defaultBtn.addEventListener("click", function (e) {
459
+ e.preventDefault();
460
+ e.stopPropagation();
461
+ saveDefaultVendor(vendor);
462
+ });
463
+ row.appendChild(defaultBtn);
464
+ }
465
+ menu.appendChild(row);
427
466
  })(VENDOR_ORDER[i]);
428
467
  }
429
468
 
469
+ var automatic = document.createElement("button");
470
+ automatic.className = "session-ctx-item session-new-automatic";
471
+ automatic.type = "button";
472
+ automatic.textContent = "Use automatic default";
473
+ automatic.disabled = defaultVendorState.saving === true;
474
+ automatic.title = "Use Clay's installed-vendor priority";
475
+ automatic.addEventListener("click", function (e) {
476
+ e.stopPropagation();
477
+ closeSessionCtxMenu();
478
+ saveDefaultVendor("");
479
+ });
480
+ menu.appendChild(automatic);
481
+ if (defaultVendorState.error) {
482
+ var status = document.createElement("div");
483
+ status.className = "session-new-preference-status";
484
+ status.setAttribute("role", "alert");
485
+ status.textContent = defaultVendorState.error;
486
+ menu.appendChild(status);
487
+ }
488
+
430
489
  if (installed.length > 0) {
431
490
  var pairSep = document.createElement("div");
432
491
  pairSep.className = "session-ctx-sep";
@@ -660,6 +719,7 @@ export function getExpandedLoopRuns() {
660
719
  // --- Context menu ---
661
720
 
662
721
  function closeSessionCtxMenu() {
722
+ sessionCtxMenuAnchor = null;
663
723
  if (sessionCtxMenu) {
664
724
  sessionCtxMenu.remove();
665
725
  sessionCtxMenu = null;
@@ -583,7 +583,12 @@ function attachMessageProcessor(ctx) {
583
583
  && /not logged in/i.test(previewTrimmed) && /\/login/i.test(previewTrimmed);
584
584
  // Fetch rich context usage breakdown (fire-and-forget, non-blocking)
585
585
  if (session.queryInstance && typeof session.queryInstance.getContextUsage === "function") {
586
+ var contextQuery = session.queryInstance;
587
+ var contextTurn = session.turnCount;
586
588
  contextUsage.readUsage(session, "summary").then(function(ctxUsage) {
589
+ if (session.queryInstance !== contextQuery || session.turnCount !== contextTurn) return;
590
+ // null means the shared reader suppressed a superseded response;
591
+ // only an adapter's explicit unavailable object may clear the UI.
587
592
  if (!ctxUsage) return;
588
593
  session.lastContextUsage = ctxUsage;
589
594
  sendToSession(session, { type: "context_usage", data: ctxUsage });
@@ -0,0 +1,60 @@
1
+ // Authenticated user-wide New session vendor preference service.
2
+
3
+ function attachDefaultVendorService(deps) {
4
+ var users = deps.users;
5
+ var homeChatHandler = deps.homeChatHandler;
6
+ var forEachAppClient = deps.forEachAppClient;
7
+ var revisions = {};
8
+ var epoch = deps.serverEpoch || require("crypto").randomBytes(12).toString("hex");
9
+
10
+ function multiUser() { return users.isMultiUser() === true; }
11
+ function actor(ws) {
12
+ if (!multiUser()) return { userId: "default", user: true };
13
+ var claimed = ws && ws._clayUser && ws._clayUser.id;
14
+ var user = claimed ? users.findUserById(claimed) : null;
15
+ return user ? { userId: user.id, user: user } : { userId: null, user: null };
16
+ }
17
+ function sameActor(ws, userId) { var current = actor(ws); return current.user && current.userId === userId; }
18
+ function send(ws, message) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(Object.assign({ serverEpoch: epoch }, message))); }
19
+ function revision(userId) { return revisions[userId] || 0; }
20
+ function installedVendors(ws, who) {
21
+ var found;
22
+ try { found = homeChatHandler.findMateProject(multiUser() ? who.userId : null, null, true); } catch (e) { return []; }
23
+ if (!found || !found.ctx || typeof found.ctx.getVendorModelAvailability !== "function") return [];
24
+ var socket = Object.create(ws || null);
25
+ socket._clayUser = who.user === true ? null : who.user;
26
+ var choices = found.ctx.getVendorModelAvailability(socket) || [];
27
+ return choices.filter(function (choice) { return choice && choice.installed === true; }).map(function (choice) { return choice.id; });
28
+ }
29
+ function broadcast(userId, message, except) {
30
+ forEachAppClient(function (client) { if (client === except || !sameActor(client, userId)) return; send(client, message); });
31
+ }
32
+ function handle(ws, msg, projectSlug) {
33
+ if (!msg || ["default_vendor_get", "default_vendor_set"].indexOf(msg.type) === -1) return false;
34
+ var requestId = typeof msg.requestId === "string" ? msg.requestId.slice(0, 200) : null;
35
+ var who = actor(ws);
36
+ if (!who.user) { send(ws, { type: "default_vendor_state", requestId: requestId, accountAvailable: false, accountId: null, projectSlug: projectSlug || null, installedVendors: [], preference: null, error: "Your account is no longer available." }); return true; }
37
+ var installed = installedVendors(ws, who);
38
+ if (msg.type === "default_vendor_get") {
39
+ var saved = users.getDefaultVendorPreference(who.userId);
40
+ send(ws, { type: "default_vendor_state", requestId: requestId, projectSlug: projectSlug || null, accountAvailable: true, accountId: who.userId, installedVendors: installed, preference: saved.vendor, preferencePresent: saved.present, ready: !saved.error, canonicalRevision: revision(who.userId), error: saved.error || "" });
41
+ return true;
42
+ }
43
+ if (msg.vendor !== "" && (typeof msg.vendor !== "string" || installed.indexOf(msg.vendor) === -1)) {
44
+ send(ws, { type: "default_vendor_state", requestId: requestId, projectSlug: projectSlug || null, accountAvailable: true, accountId: who.userId, installedVendors: installed, preference: users.getDefaultVendorPreference(who.userId).vendor, ready: false, canonicalRevision: revision(who.userId), error: "That vendor is not installed or authorized." });
45
+ return true;
46
+ }
47
+ var savedResult = users.setDefaultVendorPreference(who.userId, msg.vendor);
48
+ if (!savedResult.ok) { send(ws, { type: "default_vendor_state", requestId: requestId, projectSlug: projectSlug || null, accountAvailable: true, accountId: who.userId, installedVendors: installed, ready: false, error: savedResult.error }); return true; }
49
+ revisions[who.userId] = revision(who.userId) + 1;
50
+ var state = { type: "default_vendor_state", requestId: null, projectSlug: projectSlug || null, accountAvailable: true, accountId: who.userId, installedVendors: installed, preference: msg.vendor || null, preferencePresent: !!msg.vendor, ready: true, canonicalRevision: revisions[who.userId], error: "" };
51
+ var broadcastState = Object.assign({}, state);
52
+ delete broadcastState.projectSlug;
53
+ broadcast(who.userId, broadcastState, ws);
54
+ send(ws, Object.assign({}, state, { requestId: requestId }));
55
+ return true;
56
+ }
57
+ return { handleMessage: handle };
58
+ }
59
+
60
+ module.exports = { attachDefaultVendorService: attachDefaultVendorService };
package/lib/server.js CHANGED
@@ -17,6 +17,7 @@ var serverHomeChat = require("./server-home-chat");
17
17
  var shouldSuppressHomeResponse = require("./home-response-notifications").shouldSuppressHomeResponse;
18
18
  var serverHomePreferences = require("./server-home-preferences");
19
19
  var serverDefaultAi = require("./server-default-ai");
20
+ var serverDefaultVendor = require("./server-default-vendor");
20
21
  var serverCursorSharing = require("./server-cursor-sharing");
21
22
  var serverTools = require("./server-tools");
22
23
  var serverAdmin = require("./server-admin");
@@ -1067,6 +1068,7 @@ function createServer(opts) {
1067
1068
  var wss = new WebSocketServer({ noServer: true });
1068
1069
  var homePreferencesHandler = null;
1069
1070
  var defaultAiService = null;
1071
+ var defaultVendorService = null;
1070
1072
  var cursorSharingService = null;
1071
1073
 
1072
1074
  // Slug-less /ws handler: lets a user with no projects yet load the regular
@@ -1552,6 +1554,7 @@ function createServer(opts) {
1552
1554
  });
1553
1555
 
1554
1556
  defaultAiService = serverDefaultAi.attachDefaultAiService({ users: users, homeChatHandler: homeChatHandler, forEachAppClient: forEachAppClient });
1557
+ defaultVendorService = serverDefaultVendor.attachDefaultVendorService({ users: users, homeChatHandler: homeChatHandler, forEachAppClient: forEachAppClient });
1555
1558
  cursorSharingService = serverCursorSharing.attachCursorSharingService({ users: users, forEachAppClient: forEachAppClient });
1556
1559
 
1557
1560
  // --- User-installed home tools handler ---
@@ -1580,6 +1583,7 @@ function createServer(opts) {
1580
1583
 
1581
1584
  function handleDmMessage(ws, msg, routedProjectSlug) {
1582
1585
  if (cursorSharingService && cursorSharingService.handleMessage(ws, msg)) return true;
1586
+ if (defaultVendorService && defaultVendorService.handleMessage(ws, msg, routedProjectSlug)) return true;
1583
1587
  if (defaultAiService && defaultAiService.handleMessage(ws, msg)) return true;
1584
1588
  if (assignmentService.handleMessage(ws, msg, routedProjectSlug)) return true;
1585
1589
  if (dmHandler.handleMessage(ws, msg)) return true;
@@ -0,0 +1,80 @@
1
+ // Durable user-scoped New session vendor preference storage.
2
+
3
+ var PREFERENCE_KEY = "defaultVendorPreference";
4
+
5
+ function validVendor(value) {
6
+ return typeof value === "string" && /^[a-z0-9][a-z0-9_-]{0,39}$/.test(value);
7
+ }
8
+
9
+ function attachDefaultVendorPreferences(deps) {
10
+ var loadUsers = deps.loadUsers;
11
+ var saveUsers = deps.saveUsers;
12
+ var isMultiUser = deps.isMultiUser || function () { return false; };
13
+ var configModule = deps.configModule || require("./config");
14
+
15
+ function getRecord(data, userId) {
16
+ var records = Array.isArray(data.users) ? data.users : [];
17
+ for (var i = 0; i < records.length; i++) if (records[i] && records[i].id === userId) return records[i];
18
+ return null;
19
+ }
20
+
21
+ function readValue(value, source) {
22
+ if (value === undefined || value === null || value === "") return { present: false, vendor: null, source: null };
23
+ if (!validVendor(value)) return { present: true, vendor: null, source: source, error: "Default coding vendor is invalid." };
24
+ return { present: true, vendor: value, source: source };
25
+ }
26
+
27
+ function getDefaultVendor(userId) {
28
+ var data;
29
+ try { data = loadUsers(); } catch (error) { return { present: false, vendor: null, source: null, error: "Unable to read default coding vendor: " + (error.message || error) }; }
30
+ var record = getRecord(data, userId);
31
+ if (isMultiUser() && !record) return { present: false, vendor: null, source: null, error: "User not found." };
32
+ if (record && Object.prototype.hasOwnProperty.call(record, PREFERENCE_KEY)) return readValue(record[PREFERENCE_KEY], "explicit");
33
+ if (record) return { present: false, vendor: null, source: null };
34
+ if (userId === "default") {
35
+ var config;
36
+ try { config = configModule.loadConfig() || {}; } catch (configError) { return { present: false, vendor: null, source: null, error: "Unable to read default coding vendor: " + (configError.message || configError) }; }
37
+ if (Object.prototype.hasOwnProperty.call(config, PREFERENCE_KEY)) return readValue(config[PREFERENCE_KEY], "explicit");
38
+ }
39
+ return { present: false, vendor: null, source: null };
40
+ }
41
+
42
+ function setDefaultVendor(userId, vendor) {
43
+ if (vendor !== "" && !validVendor(vendor)) return { ok: false, error: "Default coding vendor is invalid." };
44
+ var data;
45
+ try { data = loadUsers(); } catch (error) { return { ok: false, error: "Unable to read default coding vendor: " + (error.message || error) }; }
46
+ var record = getRecord(data, userId);
47
+ if (isMultiUser() && !record) return { ok: false, error: "User not found." };
48
+ if (record) {
49
+ var nextData = Object.assign({}, data, { users: (data.users || []).map(function (item) {
50
+ if (item !== record) return item;
51
+ var next = Object.assign({}, item);
52
+ if (vendor) next.defaultVendorPreference = vendor;
53
+ else delete next.defaultVendorPreference;
54
+ return next;
55
+ }) });
56
+ try { saveUsers(nextData); } catch (saveError) { return { ok: false, error: "Unable to save default coding vendor: " + (saveError.message || saveError) }; }
57
+ if (!vendor && userId === "default") {
58
+ try {
59
+ var recordConfig = configModule.loadConfig() || {};
60
+ delete recordConfig[PREFERENCE_KEY];
61
+ configModule.saveConfig(Object.assign({}, recordConfig));
62
+ } catch (recordConfigError) { return { ok: false, error: "Unable to clear default coding vendor: " + (recordConfigError.message || recordConfigError) }; }
63
+ }
64
+ return { ok: true, vendor: vendor };
65
+ }
66
+ if (userId !== "default") return { ok: false, error: "User not found." };
67
+ try {
68
+ var current = configModule.loadConfig() || {};
69
+ var nextConfig = Object.assign({}, current);
70
+ if (vendor) nextConfig.defaultVendorPreference = vendor;
71
+ else delete nextConfig.defaultVendorPreference;
72
+ configModule.saveConfig(nextConfig);
73
+ return { ok: true, vendor: vendor };
74
+ } catch (configSaveError) { return { ok: false, error: "Unable to save default coding vendor: " + (configSaveError.message || configSaveError) }; }
75
+ }
76
+
77
+ return { getDefaultVendor: getDefaultVendor, setDefaultVendor: setDefaultVendor, validVendor: validVendor };
78
+ }
79
+
80
+ module.exports = { attachDefaultVendorPreferences: attachDefaultVendorPreferences, validVendor: validVendor, PREFERENCE_KEY: PREFERENCE_KEY };
package/lib/users.js CHANGED
@@ -11,6 +11,7 @@ var { attachHomeSurfacePreferences } = require("./users-home-surface-preferences
11
11
  var { attachExperimentalPreferences } = require("./users-experimental-preferences");
12
12
  var { attachScheduledTaskPreferences } = require("./users-scheduled-task-preferences");
13
13
  var { attachDefaultAiPreferences } = require("./users-default-ai-preferences");
14
+ var { attachDefaultVendorPreferences } = require("./users-default-vendor-preferences");
14
15
 
15
16
  var USERS_FILE = path.join(CONFIG_DIR, "users.json");
16
17
 
@@ -404,6 +405,7 @@ var homeSurfacePreferences = attachHomeSurfacePreferences({ loadUsers: loadUsers
404
405
  var experimentalPreferences = attachExperimentalPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
405
406
  var scheduledTaskPreferences = attachScheduledTaskPreferences({ loadUsers: loadUsers, saveUsers: saveUsers });
406
407
  var defaultAiPreferences = attachDefaultAiPreferences({ loadUsers: loadUsers, saveUsers: saveUsers, isMultiUser: auth.isMultiUser });
408
+ var defaultVendorPreferences = attachDefaultVendorPreferences({ loadUsers: loadUsers, saveUsers: saveUsers, isMultiUser: auth.isMultiUser });
407
409
 
408
410
  // Alias auth functions
409
411
  var isMultiUser = auth.isMultiUser;
@@ -464,6 +466,8 @@ var getScheduledTaskInterviewEngine = scheduledTaskPreferences.get;
464
466
  var setScheduledTaskInterviewEngine = scheduledTaskPreferences.set;
465
467
  var getDefaultAiPreference = defaultAiPreferences.getDefaultAiPreference;
466
468
  var setDefaultAiPreference = defaultAiPreferences.setDefaultAiPreference;
469
+ var getDefaultVendorPreference = defaultVendorPreferences.getDefaultVendor;
470
+ var setDefaultVendorPreference = defaultVendorPreferences.setDefaultVendor;
467
471
 
468
472
  module.exports = {
469
473
  USERS_FILE: USERS_FILE,
@@ -545,6 +549,8 @@ module.exports = {
545
549
  setScheduledTaskInterviewEngine: setScheduledTaskInterviewEngine,
546
550
  getDefaultAiPreference: getDefaultAiPreference,
547
551
  setDefaultAiPreference: setDefaultAiPreference,
552
+ getDefaultVendorPreference: getDefaultVendorPreference,
553
+ setDefaultVendorPreference: setDefaultVendorPreference,
548
554
  getDeletedBuiltinKeys: getDeletedBuiltinKeys,
549
555
  addDeletedBuiltinKey: addDeletedBuiltinKey,
550
556
  removeDeletedBuiltinKey: removeDeletedBuiltinKey,
package/lib/ws-schema.js CHANGED
@@ -723,6 +723,9 @@ var schema = {
723
723
  "default_ai_get": { direction: "c2s", handler: "lib/server-default-ai.js", description: "Read the authenticated user's shared Default AI and available providers" },
724
724
  "default_ai_catalog_get": { direction: "c2s", handler: "lib/server-default-ai.js", description: "Read one installed provider catalog for the Default AI picker" },
725
725
  "default_ai_set": { direction: "c2s", handler: "lib/server-default-ai.js", description: "Validate and persist the authenticated user's shared Default AI" },
726
+ "default_vendor_get": { direction: "c2s", handler: "lib/server-default-vendor.js", description: "Read the authenticated user's New session vendor preference" },
727
+ "default_vendor_set": { direction: "c2s", handler: "lib/server-default-vendor.js", description: "Validate and persist the authenticated user's New session vendor preference" },
728
+ "default_vendor_state": { direction: "s2c", handler: "lib/public/modules/default-vendor.js", description: "Correlated or same-user-broadcast New session vendor preference" },
726
729
  "default_ai_state": { direction: "s2c", handler: "lib/public/modules/default-ai.js", description: "Correlated or same-user-broadcast canonical Default AI state" },
727
730
  "default_ai_catalog": { direction: "s2c", handler: "lib/public/modules/default-ai.js", description: "Correlated Default AI provider catalog" },
728
731
  "home_mate_history": { direction: "s2c", handler: "lib/public/modules/home-mate-chat.js", description: "Selected mate's initial or refreshed chat history" },
@@ -82,13 +82,26 @@ function isSafeOpenCodePermission(value) {
82
82
  }
83
83
 
84
84
  function validateOpenCodeConfig(config) {
85
- if (!config || config.permission !== "ask") {
85
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
86
+ throw new Error("OpenCode resolved configuration is malformed");
87
+ }
88
+ var globalPermission = config.permission;
89
+ var globalAsk = globalPermission === "ask" || (
90
+ isSafeOpenCodePermission(globalPermission) && globalPermission["*"] === "ask"
91
+ );
92
+ if (!globalAsk) {
86
93
  throw new Error("OpenCode resolved configuration does not preserve global ask permissions");
87
94
  }
95
+ if (config.agent !== undefined && (!config.agent || typeof config.agent !== "object" || Array.isArray(config.agent))) {
96
+ throw new Error("OpenCode resolved configuration has malformed agents");
97
+ }
88
98
  var agents = config.agent || {};
89
99
  var names = Object.keys(agents);
90
100
  for (var i = 0; i < names.length; i++) {
91
- var agent = agents[names[i]] || {};
101
+ var agent = agents[names[i]];
102
+ if (!agent || typeof agent !== "object" || Array.isArray(agent)) {
103
+ throw new Error("OpenCode agent has malformed resolved configuration: " + names[i]);
104
+ }
92
105
  if (agent.permission === undefined) continue;
93
106
  if (!isSafeOpenCodePermission(agent.permission)) {
94
107
  throw new Error("OpenCode agent has unsafe resolved permissions: " + names[i]);
@@ -181,6 +181,11 @@ function flattenEvent(notification, state) {
181
181
 
182
182
  if (method === "turn/started") {
183
183
  state.turnStarted = true;
184
+ state.currentTurnId = params.turnId || params.turn_id || (params.turn && params.turn.id) || null;
185
+ state.lastInputTokens = null;
186
+ state.lastCachedTokens = null;
187
+ state.lastOutputTokens = null;
188
+ state.currentContextUsage = null;
184
189
  var userUuid = generateUuid();
185
190
  events.push({ yokeType: "turn_start", uuid: userUuid, messageType: "user" });
186
191
  return events;
@@ -194,16 +199,38 @@ function flattenEvent(notification, state) {
194
199
  if (turnStatus === "interrupted" || state.aborted) {
195
200
  events.push({ yokeType: "interrupted" });
196
201
  }
197
- var inputTokens = state.lastInputTokens !== null && state.lastInputTokens !== undefined
198
- ? state.lastInputTokens : (usage ? (usage.input_tokens || 0) : 0);
199
- var outputTokens = (usage ? (usage.output_tokens || 0) : 0) || state.lastOutputTokens || 0;
200
- var cachedTokens = (usage ? (usage.cached_input_tokens || 0) : 0) || state.lastCachedTokens || 0;
202
+ // Generic usage reporting is independent from current context occupancy.
203
+ // The latter is supplied only by a valid thread/tokenUsage snapshot.
204
+ // Some normal protocol completions omit usage even though the preceding
205
+ // token snapshot carried useful per-turn accounting. Keep that fallback
206
+ // for the generic usage panel, but never use it as context occupancy.
207
+ var inputTokens = usage && typeof usage.input_tokens === "number"
208
+ ? usage.input_tokens : (typeof state.lastInputTokens === "number" ? state.lastInputTokens : 0);
209
+ var outputTokens = usage && typeof usage.output_tokens === "number"
210
+ ? usage.output_tokens : (typeof state.lastOutputTokens === "number" ? state.lastOutputTokens : 0);
211
+ var cachedTokens = usage && typeof usage.cached_input_tokens === "number"
212
+ ? usage.cached_input_tokens : (typeof state.lastCachedTokens === "number" ? state.lastCachedTokens : 0);
201
213
  var hasTokenData = inputTokens > 0 || outputTokens > 0;
202
214
  var resultModelUsage = {};
203
215
  // Codex reports the real window in thread/tokenUsage/updated. Dropping it
204
216
  // made the client fall back to its model-name table, which has no entry
205
217
  // for codex model ids and defaulted to Claude's 200k denominator.
206
- resultModelUsage[state.model] = { contextWindow: state.modelContextWindow || null };
218
+ var contextSnapshot = { valid: false };
219
+ if (state.currentContextUsage
220
+ && typeof state.currentContextUsage.input_tokens === "number"
221
+ && typeof state.currentContextUsage.contextWindow === "number"
222
+ && state.currentContextUsage.contextWindow > 0) {
223
+ contextSnapshot = {
224
+ valid: true,
225
+ inputTokens: state.currentContextUsage.input_tokens,
226
+ contextWindow: state.currentContextUsage.contextWindow,
227
+ };
228
+ if (state.currentContextUsage.turnId) contextSnapshot.turnId = state.currentContextUsage.turnId;
229
+ }
230
+ resultModelUsage[state.model] = {
231
+ contextWindow: state.modelContextWindow || null,
232
+ contextSnapshot: contextSnapshot,
233
+ };
207
234
  var assistantUuid = generateUuid();
208
235
  events.push({
209
236
  yokeType: "result",
@@ -683,24 +710,36 @@ function flattenEvent(notification, state) {
683
710
  if (method === "thread/tokenUsage/updated") {
684
711
  var tu = params.tokenUsage;
685
712
  if (tu) {
713
+ var tokenTurnId = params.turnId || params.turn_id || tu.turnId || null;
714
+ if (tokenTurnId && state.currentTurnId && tokenTurnId !== state.currentTurnId) {
715
+ state.lastInputTokens = null;
716
+ state.lastCachedTokens = null;
717
+ state.lastOutputTokens = null;
718
+ state.currentContextUsage = null;
719
+ return events;
720
+ }
721
+ var currentWindow = null;
686
722
  var turnUsage = tu.lastTurn;
687
723
  if (turnUsage) {
688
724
  var candidateInput = typeof turnUsage.inputTokens === "number" ? turnUsage.inputTokens : null;
689
- var currentWindow = typeof tu.modelContextWindow === "number" ? tu.modelContextWindow : null;
690
- state.lastInputTokens = candidateInput !== null && (currentWindow === null || candidateInput <= currentWindow) ? candidateInput : null;
725
+ currentWindow = typeof tu.modelContextWindow === "number" && isFinite(tu.modelContextWindow) && tu.modelContextWindow > 0
726
+ ? tu.modelContextWindow : null;
727
+ state.lastInputTokens = candidateInput !== null && isFinite(candidateInput) && candidateInput >= 0 && currentWindow !== null && candidateInput <= currentWindow ? candidateInput : null;
691
728
  state.lastCachedTokens = turnUsage.cachedInputTokens || 0;
692
729
  state.lastOutputTokens = turnUsage.outputTokens || 0;
693
- state.currentContextUsage = state.lastInputTokens !== null &&
694
- (currentWindow === null || state.lastInputTokens <= currentWindow)
695
- ? { input_tokens: state.lastInputTokens, contextWindow: currentWindow }
696
- : { input_tokens: null, contextWindow: currentWindow };
730
+ if (state.lastInputTokens !== null && currentWindow !== null) {
731
+ state.currentContextUsage = { input_tokens: state.lastInputTokens, contextWindow: currentWindow };
732
+ if (tokenTurnId || state.currentTurnId) state.currentContextUsage.turnId = tokenTurnId || state.currentTurnId;
733
+ } else {
734
+ state.currentContextUsage = null;
735
+ }
697
736
  } else {
698
737
  state.lastInputTokens = null;
699
738
  state.lastCachedTokens = null;
700
739
  state.lastOutputTokens = null;
701
740
  state.currentContextUsage = null;
702
741
  }
703
- if (tu.modelContextWindow) state.modelContextWindow = tu.modelContextWindow;
742
+ if (currentWindow !== null) state.modelContextWindow = currentWindow;
704
743
  }
705
744
  return events;
706
745
  }
@@ -750,6 +789,7 @@ function createEventState(model) {
750
789
  lastCachedTokens: null,
751
790
  lastOutputTokens: null,
752
791
  currentContextUsage: null,
792
+ currentTurnId: null,
753
793
  modelContextWindow: null,
754
794
  done: false,
755
795
  aborted: false,
@@ -1288,7 +1328,10 @@ function createCodexQueryHandle(appServer, queryOpts) {
1288
1328
  },
1289
1329
 
1290
1330
  getContextUsage: function() {
1291
- return Promise.resolve(state.currentContextUsage);
1331
+ return Promise.resolve(state.currentContextUsage || {
1332
+ unavailable: true,
1333
+ reason: "current_context_unavailable",
1334
+ });
1292
1335
  },
1293
1336
 
1294
1337
  abort: function() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.1.0-beta.14",
3
+ "version": "4.1.0-beta.15",
4
4
  "description": "Self-hosted team workspace for Claude Code and Codex. Multi-user, browser-based, with persistent AI mates.",
5
5
  "bin": {
6
6
  "clay-server": "./bin/cli.js"