clay-server 3.4.0-beta.10 → 3.4.0-beta.12

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.
@@ -0,0 +1,216 @@
1
+ var yoke = require("./yoke");
2
+
3
+ function modelEntryValue(entry) {
4
+ if (!entry) return "";
5
+ if (typeof entry === "string") return entry;
6
+ return entry.value || entry.id || "";
7
+ }
8
+
9
+ function modelEntryMatches(entry, value) {
10
+ if (!entry || !value) return false;
11
+ if (typeof entry === "string") return entry === value;
12
+ return entry.value === value || entry.id === value || entry.resolvedModel === value;
13
+ }
14
+
15
+ function attachModels(ctx) {
16
+ var cwd = ctx.cwd;
17
+ var slug = ctx.slug;
18
+ var sm = ctx.sm;
19
+ var sdk = ctx.sdk;
20
+ var adapters = ctx.adapters;
21
+ var sendTo = ctx.sendTo;
22
+ var getSessionForWs = ctx.getSessionForWs;
23
+ var getLinuxUserForWs = ctx.getLinuxUserForWs;
24
+ var serverPort = ctx.serverPort;
25
+ var serverTls = ctx.serverTls;
26
+ var serverAuthToken = ctx.serverAuthToken;
27
+
28
+ async function loadVendorModels(ws, msg) {
29
+ var vendor = msg.vendor || "";
30
+ var requestId = msg.requestId || null;
31
+ var loadError = null;
32
+ var vendorAdapter = null;
33
+
34
+ if (!vendor) {
35
+ sendTo(ws, {
36
+ type: "model_info",
37
+ vendor: vendor,
38
+ requestId: requestId,
39
+ modelStatus: "error",
40
+ error: "No vendor was selected.",
41
+ model: "",
42
+ models: [],
43
+ });
44
+ return;
45
+ }
46
+
47
+ try {
48
+ var modelLinuxUser = getLinuxUserForWs(ws);
49
+ vendorAdapter = adapters[vendor] || null;
50
+ if (!vendorAdapter) {
51
+ vendorAdapter = await yoke.lazyCreateAdapter(adapters, vendor, {
52
+ cwd: cwd,
53
+ linuxUser: modelLinuxUser || undefined,
54
+ clayPort: serverPort,
55
+ clayTls: serverTls,
56
+ clayAuthToken: serverAuthToken,
57
+ slug: slug,
58
+ });
59
+ }
60
+
61
+ var cachedModels = (sm.modelsByVendor && sm.modelsByVendor[vendor]) || [];
62
+ var cachedCapabilities = sm.capabilitiesByVendor && sm.capabilitiesByVendor[vendor];
63
+ var needsReadyMetadata = !cachedCapabilities || cachedModels.length === 0;
64
+ if (vendorAdapter && needsReadyMetadata && typeof vendorAdapter.init === "function") {
65
+ try {
66
+ var readyResult = await vendorAdapter.init({
67
+ cwd: cwd,
68
+ linuxUser: modelLinuxUser || undefined,
69
+ clayPort: serverPort,
70
+ clayTls: serverTls,
71
+ clayAuthToken: serverAuthToken,
72
+ slug: slug,
73
+ });
74
+ sm.capabilitiesByVendor = sm.capabilitiesByVendor || {};
75
+ sm.capabilitiesByVendor[vendor] = readyResult.capabilities || {};
76
+ sm.modelsByVendor = sm.modelsByVendor || {};
77
+ if (Array.isArray(readyResult.models) && readyResult.models.length > 0) {
78
+ sm.modelsByVendor[vendor] = readyResult.models;
79
+ }
80
+ } catch (e) {
81
+ loadError = e;
82
+ console.error("[project-models] " + vendor + " init failed:", e.message || e);
83
+ }
84
+ }
85
+
86
+ if (vendorAdapter) {
87
+ sm.availableVendors = Object.keys(adapters);
88
+ sm.modelsByVendor = sm.modelsByVendor || {};
89
+ var currentModels = sm.modelsByVendor[vendor] || [];
90
+ if (currentModels.length === 0 && typeof vendorAdapter.supportedModels === "function") {
91
+ try {
92
+ var supported = await vendorAdapter.supportedModels();
93
+ if (Array.isArray(supported) && supported.length > 0) {
94
+ sm.modelsByVendor[vendor] = supported;
95
+ loadError = null;
96
+ }
97
+ } catch (e) {
98
+ if (!loadError) loadError = e;
99
+ console.error("[project-models] " + vendor + " model listing failed:", e.message || e);
100
+ }
101
+ }
102
+ } else {
103
+ loadError = new Error("The vendor adapter is unavailable.");
104
+ }
105
+ } catch (e) {
106
+ loadError = e;
107
+ console.error("[project-models] model loading failed for " + vendor + ":", e.message || e);
108
+ }
109
+
110
+ var vendorModels = (sm.modelsByVendor && sm.modelsByVendor[vendor]) || [];
111
+ var modelToSend = modelEntryValue(vendorModels[0]);
112
+ if (sm.currentModel) {
113
+ for (var i = 0; i < vendorModels.length; i++) {
114
+ if (modelEntryMatches(vendorModels[i], sm.currentModel)) {
115
+ modelToSend = modelEntryValue(vendorModels[i]);
116
+ break;
117
+ }
118
+ }
119
+ }
120
+ var vendorInfo = yoke.getVendorInfo(vendor);
121
+ var displayName = vendorInfo ? vendorInfo.displayName : vendor;
122
+ var status = vendorModels.length > 0 ? "ready" : (loadError ? "error" : "empty");
123
+ var errorText = "";
124
+ if (status === "error") {
125
+ errorText = "Could not load " + displayName + " models: " + (loadError.message || loadError);
126
+ } else if (status === "empty") {
127
+ errorText = displayName + " returned no models. Check CLI authentication and retry.";
128
+ }
129
+ sendTo(ws, {
130
+ type: "model_info",
131
+ model: modelToSend,
132
+ models: vendorModels,
133
+ vendor: vendor,
134
+ capabilities: (sm.capabilitiesByVendor && sm.capabilitiesByVendor[vendor]) || {},
135
+ availableVendors: sm.availableVendors || [],
136
+ installedVendors: sm.installedVendors || [],
137
+ requestId: requestId,
138
+ modelStatus: status,
139
+ error: errorText,
140
+ });
141
+ }
142
+
143
+ async function selectModel(ws, msg) {
144
+ var session = getSessionForWs(ws);
145
+ if (!session) {
146
+ sendTo(ws, { type: "model_selection_result", requestId: msg.requestId || null, ok: false, error: "No active session." });
147
+ return;
148
+ }
149
+ var sessionVendor = session.vendor || sm.defaultVendor || "claude";
150
+ if (msg.vendor && msg.vendor !== sessionVendor) {
151
+ sendTo(ws, {
152
+ type: "model_selection_result",
153
+ requestId: msg.requestId || null,
154
+ ok: false,
155
+ error: "The session vendor changed before the model selection completed.",
156
+ });
157
+ return;
158
+ }
159
+ var knownModels = (sm.modelsByVendor && sm.modelsByVendor[sessionVendor]) || [];
160
+ if (knownModels.length > 0) {
161
+ var known = false;
162
+ for (var i = 0; i < knownModels.length; i++) {
163
+ if (modelEntryMatches(knownModels[i], msg.model)) {
164
+ known = true;
165
+ break;
166
+ }
167
+ }
168
+ if (!known) {
169
+ sendTo(ws, {
170
+ type: "model_selection_result",
171
+ requestId: msg.requestId || null,
172
+ ok: false,
173
+ error: "That model is not available for the selected vendor.",
174
+ });
175
+ return;
176
+ }
177
+ }
178
+ var result = await sdk.setModel(session, msg.model);
179
+ sendTo(ws, {
180
+ type: "model_selection_result",
181
+ requestId: msg.requestId || null,
182
+ ok: !!(result && result.ok),
183
+ model: result && result.model ? result.model : msg.model,
184
+ vendor: sessionVendor,
185
+ error: result && result.error ? result.error : "",
186
+ });
187
+ }
188
+
189
+ function handleMessage(ws, msg) {
190
+ if (msg.type === "get_vendor_models") {
191
+ loadVendorModels(ws, msg).catch(function(e) {
192
+ console.error("[project-models] Unexpected model loading failure:", e.message || e);
193
+ });
194
+ return true;
195
+ }
196
+ if (msg.type === "set_model" && msg.model) {
197
+ selectModel(ws, msg).catch(function(e) {
198
+ sendTo(ws, { type: "model_selection_result", requestId: msg.requestId || null, ok: false, error: e.message || String(e) });
199
+ });
200
+ return true;
201
+ }
202
+ return false;
203
+ }
204
+
205
+ return {
206
+ handleMessage: handleMessage,
207
+ loadVendorModels: loadVendorModels,
208
+ selectModel: selectModel,
209
+ };
210
+ }
211
+
212
+ module.exports = {
213
+ attachModels: attachModels,
214
+ modelEntryMatches: modelEntryMatches,
215
+ modelEntryValue: modelEntryValue,
216
+ };
@@ -958,14 +958,6 @@ function attachSessions(ctx) {
958
958
  return true;
959
959
  }
960
960
 
961
- if (msg.type === "set_model" && msg.model) {
962
- var session = getSessionForWs(ws);
963
- if (session) {
964
- sdk.setModel(session, msg.model);
965
- }
966
- return true;
967
- }
968
-
969
961
  if (msg.type === "reload_skills") {
970
962
  var session = getSessionForWs(ws);
971
963
  if (session && sdk.reloadSkills) {
package/lib/project.js CHANGED
@@ -27,6 +27,7 @@ var { attachImage } = require("./project-image");
27
27
  var { attachKnowledge } = require("./project-knowledge");
28
28
  var { attachFilesystem } = require("./project-filesystem");
29
29
  var { attachSessions } = require("./project-sessions");
30
+ var { attachModels } = require("./project-models");
30
31
  var { attachUserMessage } = require("./project-user-message");
31
32
  var { attachShellCommand } = require("./project-shell-command");
32
33
  var { attachConnection } = require("./project-connection");
@@ -1053,79 +1054,6 @@ function createProjectContext(opts) {
1053
1054
  return;
1054
1055
  }
1055
1056
 
1056
- // --- Vendor model switching ---
1057
- if (msg.type === "get_vendor_models") {
1058
- (async function() {
1059
- if (msg.vendor) {
1060
- try {
1061
- var modelLinuxUser = getLinuxUserForWs(ws);
1062
- var vendorAdapter = adapters[msg.vendor] || null;
1063
- if (!vendorAdapter) {
1064
- vendorAdapter = await yoke.lazyCreateAdapter(adapters, msg.vendor, {
1065
- cwd: cwd,
1066
- linuxUser: modelLinuxUser || undefined,
1067
- clayPort: serverPort,
1068
- clayTls: serverTls,
1069
- clayAuthToken: serverAuthToken,
1070
- slug: slug,
1071
- });
1072
- }
1073
- var needsReadyMetadata = !sm.capabilitiesByVendor || !sm.capabilitiesByVendor[msg.vendor]
1074
- || !sm.modelsByVendor || !sm.modelsByVendor[msg.vendor];
1075
- if (vendorAdapter && needsReadyMetadata && typeof vendorAdapter.init === "function") {
1076
- // Init warms the adapter, but a slow/failed init must not block
1077
- // model listing (e.g. Codex models are a fixed list). Keep going
1078
- // to supportedModels() even if init throws.
1079
- try {
1080
- var readyResult = await vendorAdapter.init({
1081
- cwd: cwd,
1082
- linuxUser: modelLinuxUser || undefined,
1083
- clayPort: serverPort,
1084
- clayTls: serverTls,
1085
- clayAuthToken: serverAuthToken,
1086
- slug: slug,
1087
- });
1088
- sm.capabilitiesByVendor = sm.capabilitiesByVendor || {};
1089
- sm.capabilitiesByVendor[msg.vendor] = readyResult.capabilities || {};
1090
- sm.modelsByVendor = sm.modelsByVendor || {};
1091
- if (Array.isArray(readyResult.models)) sm.modelsByVendor[msg.vendor] = readyResult.models;
1092
- } catch (e) {
1093
- console.error("[project] " + msg.vendor + " init failed (continuing to model list):", e.message || e);
1094
- }
1095
- }
1096
- if (vendorAdapter) {
1097
- sm.availableVendors = Object.keys(adapters);
1098
- sm.modelsByVendor = sm.modelsByVendor || {};
1099
- if (!sm.modelsByVendor[msg.vendor] && typeof vendorAdapter.supportedModels === "function") {
1100
- sm.modelsByVendor[msg.vendor] = await vendorAdapter.supportedModels();
1101
- }
1102
- }
1103
- } catch (e) {
1104
- console.error("[project] get_vendor_models lazy init failed for " + msg.vendor + ":", e.message || e);
1105
- }
1106
- }
1107
- var vendorModels = (sm.modelsByVendor && sm.modelsByVendor[msg.vendor]) || [];
1108
- var firstModel = vendorModels[0] || "";
1109
- // model value can be string or {value, displayName} object
1110
- var defaultModel = typeof firstModel === "string" ? firstModel : (firstModel.value || "");
1111
- // Preserve the user's current model selection if it belongs to this
1112
- // vendor, rather than always snapping back to the vendor's default.
1113
- var modelToSend = defaultModel;
1114
- if (sm.currentModel) {
1115
- for (var mi = 0; mi < vendorModels.length; mi++) {
1116
- var mv = typeof vendorModels[mi] === "string" ? vendorModels[mi] : (vendorModels[mi].value || "");
1117
- if (mv === sm.currentModel) {
1118
- modelToSend = sm.currentModel;
1119
- break;
1120
- }
1121
- }
1122
- }
1123
- var vendorCapabilities = (sm.capabilitiesByVendor && sm.capabilitiesByVendor[msg.vendor]) || {};
1124
- sendTo(ws, { type: "model_info", model: modelToSend, models: vendorModels, vendor: msg.vendor, capabilities: vendorCapabilities, availableVendors: sm.availableVendors || [], installedVendors: sm.installedVendors || [] });
1125
- })();
1126
- return;
1127
- }
1128
-
1129
1057
  // --- Debate ---
1130
1058
  if (msg.type === "debate_start") {
1131
1059
  handleDebateStart(ws, msg);
@@ -1174,6 +1102,7 @@ function createProjectContext(opts) {
1174
1102
  // --- Sessions, config, project mgmt (delegated to project-sessions.js) ---
1175
1103
  if (_sessionPair.handleMessage(ws, msg)) return;
1176
1104
  if (_splitGroups.handleMessage(ws, msg)) return;
1105
+ if (_models.handleMessage(ws, msg)) return;
1177
1106
  if (_sessions.handleSessionsMessage(ws, msg)) return;
1178
1107
 
1179
1108
  // --- Filesystem, settings, env (delegated to project-filesystem.js) ---
@@ -1386,6 +1315,20 @@ function createProjectContext(opts) {
1386
1315
  _notifications: _notifications,
1387
1316
  });
1388
1317
 
1318
+ var _models = attachModels({
1319
+ cwd: cwd,
1320
+ slug: slug,
1321
+ sm: sm,
1322
+ sdk: sdk,
1323
+ adapters: adapters,
1324
+ sendTo: sendTo,
1325
+ getSessionForWs: getSessionForWs,
1326
+ getLinuxUserForWs: getLinuxUserForWs,
1327
+ serverPort: serverPort,
1328
+ serverTls: serverTls,
1329
+ serverAuthToken: serverAuthToken,
1330
+ });
1331
+
1389
1332
  // --- User message handler (delegated to project-user-message.js) ---
1390
1333
  var _userMessage = attachUserMessage({
1391
1334
  cwd: cwd,
package/lib/public/app.js CHANGED
@@ -339,6 +339,12 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
339
339
  markdownSlideLevelExplicit: false,
340
340
  currentModel: "",
341
341
  currentModels: [],
342
+ modelListVendor: "",
343
+ modelListStatus: "idle",
344
+ modelListError: "",
345
+ modelRequestId: "",
346
+ modelSelectionPending: null,
347
+ modelSelectionError: "",
342
348
  // Project's last-used vendor; seeds the sidebar's "New session" button.
343
349
  lastVendor: "",
344
350
  // Static adapter metadata sent before any vendor is initialized.
@@ -702,6 +702,41 @@ body.dm-mode #header-add-worker-btn { display: none; }
702
702
  color: var(--text-dimmer);
703
703
  font-style: italic;
704
704
  }
705
+ .config-model-state {
706
+ display: flex;
707
+ align-items: center;
708
+ gap: 10px;
709
+ min-width: 280px;
710
+ padding: 10px 14px;
711
+ color: var(--text-secondary);
712
+ font-size: 12px;
713
+ white-space: normal;
714
+ }
715
+ .config-model-state > div { flex: 1; min-width: 0; }
716
+ .config-model-state-title { color: var(--text); font-weight: 600; }
717
+ .config-model-state-detail { margin-top: 2px; color: var(--text-muted); line-height: 1.35; }
718
+ .config-model-spinner {
719
+ width: 14px;
720
+ height: 14px;
721
+ flex: 0 0 auto;
722
+ border: 2px solid var(--border);
723
+ border-top-color: var(--accent);
724
+ border-radius: 50%;
725
+ animation: config-model-spin 0.8s linear infinite;
726
+ }
727
+ @keyframes config-model-spin { to { transform: rotate(360deg); } }
728
+ .config-model-retry {
729
+ flex: 0 0 auto;
730
+ border: 1px solid var(--border);
731
+ border-radius: 5px;
732
+ padding: 4px 8px;
733
+ background: var(--surface-raised);
734
+ color: var(--text-secondary);
735
+ font: inherit;
736
+ cursor: pointer;
737
+ }
738
+ .config-model-retry:hover { color: var(--text); border-color: var(--text-muted); }
739
+ .config-radio-item.pending { color: var(--accent); opacity: 0.75; }
705
740
 
706
741
  /* Segmented control for effort */
707
742
  .config-segmented {
@@ -53,6 +53,7 @@ import { handleWhatsNewState, handleWhatsNewSeenResult, setKnownEntries as setWh
53
53
  import { closeArticle as closeWhatsNewArticle } from './whats-new-article.js';
54
54
  import { resolvePaneSession, resolveSwitchedVendor } from './pane-session.js';
55
55
  import { selectDefaultVendorForBlankSession } from './vendor-selection.js';
56
+ import { getModelInfoUpdate, modelEntryMatches, handleModelSelectionResult } from './model-picker.js';
56
57
  import { getModelEffortLevels, accumulateUsage, updateUsagePanel, accumulateContext, updateContextPanel, renderCtxPopover, updateStatusPanel } from './app-panels.js';
57
58
  import { updateProjectList, resetClientState, showUpdateAvailable, handleRemoveProjectCheckResult, handleRemoveProjectResult, handleBrowseDirResult, handleAddProjectResult, handleCloneProgress } from './app-projects.js';
58
59
  import { updateHistorySentinel, prependOlderHistory } from './app-header.js';
@@ -431,12 +432,12 @@ export function processMessage(msg) {
431
432
  // currentModels with the wrong vendor's list and trigger app-panels
432
433
  // to request models for the "wrong" vendor, which feeds back into a
433
434
  // ping-pong loop of vendor flapping. See issue #336.
434
- var _curV = store.get('currentVendor');
435
- if (msg.vendor && _curV && msg.vendor !== _curV) break;
435
+ var _modelLoadUpdate = getModelInfoUpdate(msg);
436
+ if (!_modelLoadUpdate) break;
436
437
 
437
438
  var _modelVal = msg.model;
438
439
  if (_modelVal && typeof _modelVal === "object") _modelVal = _modelVal.value || _modelVal.displayName || "";
439
- var _miUpdate = { currentModels: msg.models || [] };
440
+ var _miUpdate = Object.assign({ currentModels: msg.models || [] }, _modelLoadUpdate);
440
441
  if (Object.prototype.hasOwnProperty.call(msg, "model")) {
441
442
  // Keep the user's existing selection only if it actually belongs to
442
443
  // this vendor's model list. Otherwise (e.g. switching to a Codex
@@ -445,8 +446,7 @@ export function processMessage(msg) {
445
446
  // model.
446
447
  var _curModel = store.get('currentModel');
447
448
  var _curInList = !!_curModel && (msg.models || []).some(function (m) {
448
- var v = typeof m === "string" ? m : (m && m.value);
449
- return v === _curModel;
449
+ return modelEntryMatches(m, _curModel);
450
450
  });
451
451
  if (store.get('vendorSelectionLocked') && _curInList) {
452
452
  // Keep the user's existing (valid) selection; only update models list
@@ -463,6 +463,10 @@ export function processMessage(msg) {
463
463
  break;
464
464
  }
465
465
 
466
+ case "model_selection_result":
467
+ handleModelSelectionResult(msg);
468
+ break;
469
+
466
470
  case "config_state": {
467
471
  var _cs = {};
468
472
  if (msg.model) _cs.currentModel = msg.model;
@@ -7,6 +7,7 @@ import { store } from './store.js';
7
7
  import { getWs } from './ws-ref.js';
8
8
  import { VENDOR_NAMES, isExperimentalVendor } from './app-rendering.js';
9
9
  import { reportPaneContext } from './pane-bridge.js';
10
+ import { setupModelPicker, renderModelPicker, requestVendorModels, prepareModelPickerOpen, modelDisplayName } from './model-picker.js';
10
11
 
11
12
  // --- Module-owned state (not in store) ---
12
13
  var sessionUsage = { cost: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, turns: 0 };
@@ -20,7 +21,6 @@ var configChipWrap = null;
20
21
  var configChip = null;
21
22
  var configChipLabel = null;
22
23
  var configPopover = null;
23
- var configModelList = null;
24
24
  var configModeList = null;
25
25
  var configEffortSection = null;
26
26
  var configEffortBar = null;
@@ -132,18 +132,6 @@ export function setContextData(v) { contextData = v; }
132
132
 
133
133
  // --- Internal helpers ---
134
134
 
135
- function modelDisplayName(value, models) {
136
- if (!value) return "";
137
- if (models) {
138
- for (var i = 0; i < models.length; i++) {
139
- var m = models[i];
140
- if (typeof m === "string") { if (m === value) return m; }
141
- else if (m.value === value && m.displayName) return m.displayName;
142
- }
143
- }
144
- return value;
145
- }
146
-
147
135
  function modeDisplayName(value) {
148
136
  for (var i = 0; i < MODE_OPTIONS.length; i++) {
149
137
  if (MODE_OPTIONS[i].value === value) return MODE_OPTIONS[i].label;
@@ -198,65 +186,6 @@ function hasBeta(name) {
198
186
  return false;
199
187
  }
200
188
 
201
- function rebuildModelList() {
202
- if (!configModelList) return;
203
- // Picker lock policy is capability-based (yoke midSessionModelSwitch):
204
- // Claude -> free. The SDK supports mid-thread swaps on a live query
205
- // (query.setModel: "Change the model used for subsequent
206
- // responses"), and sdk-bridge already routes set_model to
207
- // queryInstance.setModel. Cache re-priming on switch is a
208
- // cost, not a breakage.
209
- // Codex / Kiro -> locked once the session has history: their threads
210
- // bind the model at creation and a mid-thread change would be
211
- // silently ignored. Picks made before the first message still
212
- // work everywhere (sdk-bridge stores into sm.currentModel when
213
- // there's no active queryInstance).
214
- var modelSection = configModelList.parentElement;
215
- var s = store.snap();
216
- var vendor = s.currentVendor || "claude";
217
- if (modelSection) modelSection.style.display = "";
218
- configModelList.innerHTML = "";
219
-
220
- var caps = s.vendorCapabilities || {};
221
- var lockedForGui = s.activeSessionMode === "gui" && !!s.sessionHasHistory && !caps.midSessionModelSwitch;
222
-
223
- var list = s.currentModels.length > 0 ? s.currentModels : (s.currentModel ? [{ value: s.currentModel, displayName: s.currentModel }] : []);
224
- for (var i = 0; i < list.length; i++) {
225
- var item = list[i];
226
- // Support both object { value, displayName } and plain string formats
227
- var value = typeof item === "string" ? item : (item.value || "");
228
- var label = typeof item === "string" ? item : (item.displayName || value);
229
- var btn = document.createElement("button");
230
- btn.className = "config-radio-item";
231
- if (value === s.currentModel) btn.classList.add("active");
232
- if (lockedForGui) btn.classList.add("locked");
233
- btn.dataset.model = value;
234
- btn.textContent = label;
235
- if (lockedForGui) {
236
- btn.disabled = true;
237
- btn.title = "This vendor binds the model when the session starts. Start a new session to change it.";
238
- } else {
239
- btn.addEventListener("click", function () {
240
- var model = this.dataset.model;
241
- var ws = getWs();
242
- if (ws && ws.readyState === 1) {
243
- ws.send(JSON.stringify({ type: "set_model", model: model }));
244
- }
245
- configPopover.classList.add("hidden");
246
- configChip.classList.remove("active");
247
- });
248
- }
249
- configModelList.appendChild(btn);
250
- }
251
-
252
- if (lockedForGui) {
253
- var hint = document.createElement("div");
254
- hint.className = "config-model-hint";
255
- hint.textContent = "Locked after first message. Start a new session to change models.";
256
- configModelList.appendChild(hint);
257
- }
258
- }
259
-
260
189
  function rebuildModeList() {
261
190
  if (!configModeList) return;
262
191
  configModeList.innerHTML = "";
@@ -422,7 +351,7 @@ export function initPanels() {
422
351
  configChip = $("config-chip");
423
352
  configChipLabel = $("config-chip-label");
424
353
  configPopover = $("config-popover");
425
- configModelList = $("config-model-list");
354
+ setupModelPicker();
426
355
  configModeList = $("config-mode-list");
427
356
  configEffortSection = $("config-effort-section");
428
357
  configEffortBar = $("config-effort-bar");
@@ -487,7 +416,7 @@ export function initPanels() {
487
416
  if (vendor === (store.get('currentVendor') || "claude")) return;
488
417
  var installed = store.get('installedVendors') || [];
489
418
  if (installed.indexOf(vendor) === -1) return;
490
- store.set({ currentVendor: vendor, currentModel: "", currentModels: [], vendorSelectionLocked: true, sessionVendorBound: true });
419
+ store.set({ currentVendor: vendor, currentModel: "", currentModels: [], vendorCapabilities: {}, vendorSelectionLocked: true, sessionVendorBound: true });
491
420
  var ws = getWs();
492
421
  if (ws) ws.send(JSON.stringify({ type: "set_vendor", vendor: vendor }));
493
422
  }
@@ -528,8 +457,7 @@ export function initPanels() {
528
457
 
529
458
  // Vendor changed -> switch model list and current model to match
530
459
  if (state.currentVendor !== prev.currentVendor && state.currentVendor) {
531
- var ws = getWs();
532
- if (ws) ws.send(JSON.stringify({ type: "get_vendor_models", vendor: state.currentVendor }));
460
+ requestVendorModels(state.currentVendor, true);
533
461
  }
534
462
 
535
463
  // config chip
@@ -539,6 +467,12 @@ export function initPanels() {
539
467
  state.currentBetas !== prev.currentBetas ||
540
468
  state.currentThinking !== prev.currentThinking ||
541
469
  state.currentVendor !== prev.currentVendor ||
470
+ state.currentModels !== prev.currentModels ||
471
+ state.vendorCapabilities !== prev.vendorCapabilities ||
472
+ state.modelListStatus !== prev.modelListStatus ||
473
+ state.modelListError !== prev.modelListError ||
474
+ state.modelSelectionPending !== prev.modelSelectionPending ||
475
+ state.modelSelectionError !== prev.modelSelectionError ||
542
476
  state.codexApproval !== prev.codexApproval ||
543
477
  state.codexSandbox !== prev.codexSandbox ||
544
478
  state.codexWebSearch !== prev.codexWebSearch ||
@@ -639,6 +573,7 @@ export function initPanels() {
639
573
  e.stopPropagation();
640
574
  var wasHidden = configPopover.classList.toggle("hidden");
641
575
  configChip.classList.toggle("active", !wasHidden);
576
+ if (!wasHidden) prepareModelPickerOpen();
642
577
  });
643
578
  }
644
579
 
@@ -692,7 +627,7 @@ export function updateConfigChip() {
692
627
  var s = store.snap();
693
628
  var vendor = s.currentVendor || "claude";
694
629
  configChipLabel.textContent = modelDisplayName(s.currentModel, s.currentModels);
695
- rebuildModelList();
630
+ renderModelPicker();
696
631
  rebuildModeList();
697
632
  rebuildEffortBar();
698
633
 
@@ -5,6 +5,7 @@ import { escapeHtml, showToast } from './utils.js';
5
5
  import { refreshIcons } from './icons.js';
6
6
  import { parseEmojis } from './markdown.js';
7
7
  import { store } from './store.js';
8
+ import { resetModelPickerState } from './model-picker.js';
8
9
  import { getWs, setWs } from './ws-ref.js';
9
10
  import { getMessagesEl, getStatusDot } from './dom-refs.js';
10
11
  import { userAvatarUrl } from './avatar.js';
@@ -363,6 +364,7 @@ export function renderProjectList() {
363
364
  }
364
365
 
365
366
  export function resetClientState() {
367
+ resetModelPickerState();
366
368
  if (isSearchOpen()) closeSearch();
367
369
  getMessagesEl().innerHTML = "";
368
370
  store.set({ currentMsgEl: null });
@@ -0,0 +1,275 @@
1
+ import { store } from './store.js';
2
+ import { getWs } from './ws-ref.js';
3
+
4
+ var configModelList = null;
5
+ var configPopover = null;
6
+ var configChip = null;
7
+ var requestCounter = 0;
8
+ var selectionCounter = 0;
9
+ var requestTimer = null;
10
+
11
+ export function modelEntryValue(entry) {
12
+ if (!entry) return "";
13
+ if (typeof entry === "string") return entry;
14
+ return entry.value || entry.id || "";
15
+ }
16
+
17
+ export function modelEntryMatches(entry, value) {
18
+ if (!entry || !value) return false;
19
+ if (typeof entry === "string") return entry === value;
20
+ return entry.value === value || entry.id === value || entry.resolvedModel === value;
21
+ }
22
+
23
+ export function modelDisplayName(value, models) {
24
+ if (!value) return "";
25
+ var list = models || [];
26
+ for (var i = 0; i < list.length; i++) {
27
+ if (modelEntryMatches(list[i], value)) {
28
+ return typeof list[i] === "string" ? list[i] : (list[i].displayName || modelEntryValue(list[i]));
29
+ }
30
+ }
31
+ return value;
32
+ }
33
+
34
+ function vendorDisplayName(vendor) {
35
+ var info = store.get('vendorInfo') || {};
36
+ return info[vendor] && info[vendor].displayName ? info[vendor].displayName : vendor;
37
+ }
38
+
39
+ function clearRequestTimer() {
40
+ if (!requestTimer) return;
41
+ clearTimeout(requestTimer);
42
+ requestTimer = null;
43
+ }
44
+
45
+ export function setupModelPicker() {
46
+ configModelList = document.getElementById("config-model-list");
47
+ configPopover = document.getElementById("config-popover");
48
+ configChip = document.getElementById("config-chip");
49
+ }
50
+
51
+ export function resetModelPickerState() {
52
+ clearRequestTimer();
53
+ store.set({
54
+ modelListVendor: "",
55
+ modelListStatus: "idle",
56
+ modelListError: "",
57
+ modelRequestId: "",
58
+ modelSelectionPending: null,
59
+ modelSelectionError: "",
60
+ });
61
+ }
62
+
63
+ export function requestVendorModels(vendor, force) {
64
+ if (!vendor) return null;
65
+ var s = store.snap();
66
+ if (!force && s.modelListVendor === vendor) {
67
+ if (s.modelListStatus === "loading") return s.modelRequestId || null;
68
+ if (s.modelListStatus === "ready" && s.currentModels && s.currentModels.length > 0) return null;
69
+ }
70
+
71
+ var ws = getWs();
72
+ if (!ws || ws.readyState !== 1) {
73
+ store.set({
74
+ modelListVendor: vendor,
75
+ modelListStatus: "error",
76
+ modelListError: "The connection is not ready. Reconnect and retry.",
77
+ });
78
+ return null;
79
+ }
80
+
81
+ requestCounter++;
82
+ var requestId = "models-" + Date.now().toString(36) + "-" + requestCounter;
83
+ clearRequestTimer();
84
+ store.set({
85
+ modelListVendor: vendor,
86
+ modelListStatus: "loading",
87
+ modelListError: "",
88
+ modelRequestId: requestId,
89
+ });
90
+ ws.send(JSON.stringify({ type: "get_vendor_models", vendor: vendor, requestId: requestId }));
91
+ requestTimer = setTimeout(function() {
92
+ var current = store.snap();
93
+ if (current.modelRequestId !== requestId || current.modelListStatus !== "loading") return;
94
+ store.set({
95
+ modelListStatus: "error",
96
+ modelListError: vendorDisplayName(vendor) + " model loading timed out. Retry to try again.",
97
+ });
98
+ }, 35000);
99
+ return requestId;
100
+ }
101
+
102
+ export function prepareModelPickerOpen() {
103
+ var s = store.snap();
104
+ var vendor = s.currentVendor || "claude";
105
+ var needsModels = !s.currentModels || s.currentModels.length === 0;
106
+ var wrongVendor = s.modelListVendor !== vendor;
107
+ var retryable = s.modelListStatus === "error" || s.modelListStatus === "empty" || s.modelListStatus === "idle";
108
+ requestVendorModels(vendor, needsModels || wrongVendor || retryable);
109
+ }
110
+
111
+ export function getModelInfoUpdate(msg) {
112
+ var vendor = msg.vendor || store.get('currentVendor') || "claude";
113
+ var currentVendor = store.get('currentVendor');
114
+ if (currentVendor && vendor !== currentVendor) return null;
115
+
116
+ var s = store.snap();
117
+ if (msg.requestId && s.modelRequestId && msg.requestId !== s.modelRequestId) return null;
118
+
119
+ var models = Array.isArray(msg.models) ? msg.models : [];
120
+ var unsolicitedEmptyWhileLoading = s.modelListStatus === "loading"
121
+ && s.modelListVendor === vendor
122
+ && !msg.requestId
123
+ && !msg.modelStatus
124
+ && !msg.error
125
+ && models.length === 0;
126
+ if (unsolicitedEmptyWhileLoading) {
127
+ return {
128
+ modelListVendor: vendor,
129
+ modelListStatus: "loading",
130
+ modelListError: "",
131
+ };
132
+ }
133
+
134
+ clearRequestTimer();
135
+ var status = msg.modelStatus || (msg.error ? "error" : (models.length > 0 ? "ready" : "empty"));
136
+ return {
137
+ modelListVendor: vendor,
138
+ modelListStatus: status,
139
+ modelListError: msg.error || "",
140
+ modelRequestId: "",
141
+ };
142
+ }
143
+
144
+ export function requestModelSelection(model) {
145
+ var ws = getWs();
146
+ if (!ws || ws.readyState !== 1) {
147
+ store.set({ modelSelectionError: "The connection is not ready. Reconnect before selecting a model." });
148
+ return;
149
+ }
150
+ var s = store.snap();
151
+ selectionCounter++;
152
+ var requestId = "model-select-" + Date.now().toString(36) + "-" + selectionCounter;
153
+ store.set({
154
+ currentModel: model,
155
+ modelSelectionPending: { requestId: requestId, model: model, previousModel: s.currentModel || "" },
156
+ modelSelectionError: "",
157
+ });
158
+ ws.send(JSON.stringify({
159
+ type: "set_model",
160
+ model: model,
161
+ vendor: s.currentVendor || "claude",
162
+ requestId: requestId,
163
+ }));
164
+ }
165
+
166
+ export function handleModelSelectionResult(msg) {
167
+ var pending = store.get('modelSelectionPending');
168
+ if (!pending || (msg.requestId && msg.requestId !== pending.requestId)) return false;
169
+ if (msg.ok) {
170
+ store.set({
171
+ currentModel: msg.model || pending.model,
172
+ modelSelectionPending: null,
173
+ modelSelectionError: "",
174
+ });
175
+ if (configPopover) configPopover.classList.add("hidden");
176
+ if (configChip) configChip.classList.remove("active");
177
+ } else {
178
+ store.set({
179
+ currentModel: pending.previousModel,
180
+ modelSelectionPending: null,
181
+ modelSelectionError: msg.error || "The model could not be selected.",
182
+ });
183
+ }
184
+ return true;
185
+ }
186
+
187
+ function appendState(title, detail, retry) {
188
+ var stateEl = document.createElement("div");
189
+ stateEl.className = "config-model-state";
190
+ if (title === "Loading models…") {
191
+ var spinner = document.createElement("span");
192
+ spinner.className = "config-model-spinner";
193
+ spinner.setAttribute("aria-hidden", "true");
194
+ stateEl.appendChild(spinner);
195
+ }
196
+ var textWrap = document.createElement("div");
197
+ var titleEl = document.createElement("div");
198
+ titleEl.className = "config-model-state-title";
199
+ titleEl.textContent = title;
200
+ textWrap.appendChild(titleEl);
201
+ if (detail) {
202
+ var detailEl = document.createElement("div");
203
+ detailEl.className = "config-model-state-detail";
204
+ detailEl.textContent = detail;
205
+ textWrap.appendChild(detailEl);
206
+ }
207
+ stateEl.appendChild(textWrap);
208
+ if (retry) {
209
+ var retryBtn = document.createElement("button");
210
+ retryBtn.type = "button";
211
+ retryBtn.className = "config-model-retry";
212
+ retryBtn.textContent = "Retry";
213
+ retryBtn.addEventListener("click", function() {
214
+ requestVendorModels(store.get('currentVendor') || "claude", true);
215
+ });
216
+ stateEl.appendChild(retryBtn);
217
+ }
218
+ configModelList.appendChild(stateEl);
219
+ }
220
+
221
+ export function renderModelPicker() {
222
+ if (!configModelList) return;
223
+ var s = store.snap();
224
+ var vendor = s.currentVendor || "claude";
225
+ var status = s.modelListVendor === vendor ? s.modelListStatus : "idle";
226
+ configModelList.innerHTML = "";
227
+
228
+ if (status === "loading") {
229
+ appendState("Loading models…", "Starting " + vendorDisplayName(vendor) + " and requesting its model catalog.", false);
230
+ return;
231
+ }
232
+ if (status === "error") {
233
+ appendState("Couldn’t load models", s.modelListError || "An unexpected error occurred.", true);
234
+ return;
235
+ }
236
+ if (status === "empty" || !s.currentModels || s.currentModels.length === 0) {
237
+ appendState("No models available", s.modelListError || "The vendor returned an empty model catalog.", true);
238
+ return;
239
+ }
240
+
241
+ var caps = s.vendorCapabilities || {};
242
+ var locked = s.activeSessionMode === "gui" && !!s.sessionHasHistory && !caps.midSessionModelSwitch;
243
+ for (var i = 0; i < s.currentModels.length; i++) {
244
+ var item = s.currentModels[i];
245
+ var value = modelEntryValue(item);
246
+ if (!value) continue;
247
+ var button = document.createElement("button");
248
+ button.className = "config-radio-item";
249
+ if (modelEntryMatches(item, s.currentModel)) button.classList.add("active");
250
+ if (locked) button.classList.add("locked");
251
+ button.dataset.model = value;
252
+ button.textContent = typeof item === "string" ? item : (item.displayName || value);
253
+ if (s.modelSelectionPending && s.modelSelectionPending.model === value) {
254
+ button.classList.add("pending");
255
+ button.textContent += " · Selecting…";
256
+ }
257
+ button.disabled = locked || !!s.modelSelectionPending;
258
+ if (locked) {
259
+ button.title = "This vendor binds the model when the session starts. Start a new session to change it.";
260
+ } else {
261
+ button.addEventListener("click", function() { requestModelSelection(this.dataset.model); });
262
+ }
263
+ configModelList.appendChild(button);
264
+ }
265
+
266
+ if (s.modelSelectionError) {
267
+ appendState("Model selection failed", s.modelSelectionError, false);
268
+ }
269
+ if (locked) {
270
+ var hint = document.createElement("div");
271
+ hint.className = "config-model-hint";
272
+ hint.textContent = "Locked after first message. Start a new session to change models.";
273
+ configModelList.appendChild(hint);
274
+ }
275
+ }
package/lib/sdk-bridge.js CHANGED
@@ -1931,10 +1931,12 @@ function createSDKBridge(opts) {
1931
1931
  session.model = model;
1932
1932
  sm.saveSessionFile(session);
1933
1933
  sm.currentModel = model;
1934
- // Don't send vendor here: session vendor not yet bound, let client keep its selection
1935
- sendModelInfoForVendor(null, model);
1934
+ // Confirm against the session vendor so a non-default vendor does not
1935
+ // receive model metadata labeled as the project's default adapter.
1936
+ var pendingVendor = session.vendor || (adapter && adapter.vendor) || "claude";
1937
+ sendModelInfoForVendor(pendingVendor, model);
1936
1938
  sendToSession(session, { type: "config_state", model: sm.currentModel, mode: session.permissionMode || sm.currentPermissionMode || "default", effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1937
- return;
1939
+ return { ok: true, model: model };
1938
1940
  }
1939
1941
  try {
1940
1942
  await session.queryInstance.setModel(model);
@@ -1944,8 +1946,10 @@ function createSDKBridge(opts) {
1944
1946
  var sessionVendor = session.vendor || (adapter && adapter.vendor) || "claude";
1945
1947
  sendModelInfoForVendor(sessionVendor, model);
1946
1948
  sendToSession(session, { type: "config_state", model: sm.currentModel, mode: session.permissionMode || sm.currentPermissionMode || "default", effort: session.effort || sm.currentEffort || "medium", betas: sm.currentBetas || [] });
1949
+ return { ok: true, model: model };
1947
1950
  } catch (e) {
1948
1951
  send({ type: "error", text: "Failed to switch model: " + (e.message || e) });
1952
+ return { ok: false, model: session.model || "", error: e.message || String(e) };
1949
1953
  }
1950
1954
  }
1951
1955
 
package/lib/ws-schema.js CHANGED
@@ -175,7 +175,8 @@ var schema = {
175
175
  // -----------------------------------------------------------------------
176
176
  // Model / config
177
177
  // -----------------------------------------------------------------------
178
- "set_model": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set the model for the current session" },
178
+ "set_model": { direction: "c2s", handler: "lib/project-models.js", description: "Set the model for the current session" },
179
+ "get_vendor_models": { direction: "c2s", handler: "lib/project-models.js", description: "Load a vendor model catalog" },
179
180
  "set_server_default_model": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set the server-wide default model" },
180
181
  "set_project_default_model": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set the project default model" },
181
182
  "set_permission_mode": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set permission mode for the current session" },
@@ -192,6 +193,7 @@ var schema = {
192
193
  "set_betas": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set beta feature flags" },
193
194
  "set_thinking": { direction: "c2s", handler: "lib/project-sessions.js", description: "Set thinking mode and budget" },
194
195
  "model_info": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Current model and available models list" },
196
+ "model_selection_result": { direction: "s2c", handler: "lib/public/modules/model-picker.js", description: "Acknowledgement or error for a model selection" },
195
197
  "config_state": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Full config state: model, mode, effort, betas, thinking" },
196
198
  "slash_commands": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Available slash commands list" },
197
199
  "fast_mode_state": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Fast mode toggle state" },
package/lib/yoke/index.js CHANGED
@@ -346,7 +346,9 @@ function createAdapters(opts) {
346
346
  // Claude Code supports multiple auth modes (OAuth, Bedrock, Vertex, API key)
347
347
  // that `claude auth status` does not always detect. Runtime auth failures are
348
348
  // handled downstream via query-level error detection.
349
- var installed = checkInstalled();
349
+ // Tests can supply a fixed installation snapshot without probing or spawning
350
+ // vendor binaries; production callers always use the cached host scan.
351
+ var installed = opts._installed || checkInstalled();
350
352
  var auth = { claude: false, codex: false, antigravity: false, opencode: false, kimi: false, grok: false, copilot: false, qwen: false, junie: false, kiro: false };
351
353
  var adapters = {};
352
354
 
@@ -363,10 +365,10 @@ function createAdapters(opts) {
363
365
  // per-project teardown never shuts it down on behalf of one project
364
366
  // (see destroy() in lib/project.js).
365
367
  _sharedClaudeAdapter.shared = true;
368
+ console.log("[yoke] Shared adapter created: claude");
366
369
  }
367
370
  adapters.claude = _sharedClaudeAdapter;
368
371
  auth.claude = true;
369
- console.log("[yoke] Adapter created: claude");
370
372
  } catch (e) {
371
373
  console.error("[yoke] Failed to create adapter for claude:", e.message);
372
374
  }
@@ -376,7 +378,6 @@ function createAdapters(opts) {
376
378
  try {
377
379
  adapters.codex = createAdapter({ vendor: "codex", cwd: opts.cwd, slug: opts.slug, osUsers: !!opts.osUsers });
378
380
  auth.codex = true;
379
- console.log("[yoke] Adapter created: codex");
380
381
  } catch (e) {
381
382
  console.error("[yoke] Failed to create adapter for codex:", e.message);
382
383
  }
@@ -390,7 +391,6 @@ function createAdapters(opts) {
390
391
  try {
391
392
  adapters[subprocessVendor] = createAdapter({ vendor: subprocessVendor, cwd: opts.cwd, slug: opts.slug });
392
393
  auth[subprocessVendor] = true;
393
- console.log("[yoke] Adapter created: " + subprocessVendor);
394
394
  } catch (e) {
395
395
  console.error("[yoke] Failed to create adapter for " + subprocessVendor + ":", e.message);
396
396
  }
@@ -404,7 +404,6 @@ function createAdapters(opts) {
404
404
  try {
405
405
  adapters.kiro = createAdapter({ vendor: "kiro", cwd: opts.cwd, slug: opts.slug });
406
406
  auth.kiro = true;
407
- console.log("[yoke] Adapter created: kiro");
408
407
  } catch (e) {
409
408
  console.error("[yoke] Failed to create adapter for kiro:", e.message);
410
409
  }
@@ -412,6 +411,12 @@ function createAdapters(opts) {
412
411
  console.log("[yoke] " + kiroInfo.displayName + " adapter disabled: OS-user isolation requires per-user spawning");
413
412
  }
414
413
 
414
+ var registeredVendors = Object.keys(adapters);
415
+ if (registeredVendors.length > 0) {
416
+ var registrationScope = opts.slug ? " for " + opts.slug : "";
417
+ console.log("[yoke] Adapters registered" + registrationScope + ": " + registeredVendors.join(", "));
418
+ }
419
+
415
420
  return { adapters: adapters, auth: auth };
416
421
  }
417
422
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "3.4.0-beta.10",
3
+ "version": "3.4.0-beta.12",
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",