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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,287 @@
1
+ // vendor-login.js - Vendor login (auth_required) recovery flow.
2
+ //
3
+ // The server owns the login terminal (see lib/project-vendor-login.js): exactly
4
+ // one per vendor per project, tracked server-side, killed once the login lands.
5
+ // This module is the client half of that contract - it asks for the flow, opens
6
+ // the modal on whatever terminal the server hands back, and reflects the flow's
7
+ // state in a banner.
8
+ //
9
+ // Nothing here spawns a terminal speculatively. Every auth_required after the
10
+ // first is a no-op while a flow is live, which is what stops the login-terminal
11
+ // pile-up and the endless auth_required -> login -> 401 loop.
12
+
13
+ import { store } from './store.js';
14
+ import { getWs } from './ws-ref.js';
15
+ import { refreshIcons, iconHtml } from './icons.js';
16
+ import { openTuiModal, closeTuiModal, getTuiModalTerminalId } from './tui-attention.js';
17
+ import { forwardPaneAuthRequired } from './pane-bridge.js';
18
+
19
+ // vendor -> { terminalId, startedAt } as last broadcast by the server.
20
+ var activeFlows = Object.create(null);
21
+ // vendor -> true between sending vendor_login_start and hearing back.
22
+ var pendingStarts = Object.create(null);
23
+ // Vendor whose login terminal the modal is currently showing.
24
+ var modalVendor = null;
25
+ // Set while this module tears the modal down itself, so the modal's close hook
26
+ // does not read a programmatic close as "the user gave up".
27
+ var closingModalInternally = false;
28
+
29
+ function vendorDisplayName(vendor) {
30
+ var vendors = store.get('vendorInfo') || {};
31
+ var info = vendors[vendor];
32
+ if (info && info.displayName) return info.displayName;
33
+ return vendor === "codex" ? "Codex" : "Claude Code";
34
+ }
35
+
36
+ function currentProjectSlug() {
37
+ var slug = store.get('currentSlug') || "";
38
+ if (slug) return slug;
39
+ // Fallback: derive from the URL (/p/<slug>/...).
40
+ try {
41
+ var m = (window.location.pathname || "").match(/^\/p\/([a-z0-9_-]+)/);
42
+ if (m) return m[1];
43
+ } catch (e) {}
44
+ return "";
45
+ }
46
+
47
+ // The banner surface belongs to the notification center. Query it rather than
48
+ // importing app-notifications so the dependency stays one-directional
49
+ // (app-notifications -> vendor-login).
50
+ function bannerContainer() {
51
+ return document.querySelector(".notif-banner-container");
52
+ }
53
+
54
+ function removeBannerEl(el) {
55
+ if (!el) return;
56
+ el.classList.remove("show");
57
+ setTimeout(function () {
58
+ if (el.parentNode) el.parentNode.removeChild(el);
59
+ }, 300);
60
+ }
61
+
62
+ function clearLoginBanners() {
63
+ var container = bannerContainer();
64
+ if (!container) return;
65
+ var existing = container.querySelectorAll('[data-vendor-login="true"]');
66
+ for (var i = 0; i < existing.length; i++) removeBannerEl(existing[i]);
67
+ }
68
+
69
+ function showLoginBanner(opts) {
70
+ var container = bannerContainer();
71
+ if (!container) return null;
72
+ clearLoginBanners();
73
+
74
+ var banner = document.createElement("div");
75
+ banner.className = "notif-banner notif-banner-update";
76
+ banner.setAttribute("data-notif-id", "_vendor_login");
77
+ banner.setAttribute("data-vendor-login", "true");
78
+ banner.innerHTML =
79
+ '<div class="notif-banner-icon">' + iconHtml(opts.icon || "check-circle") + '</div>' +
80
+ '<div class="notif-banner-body">' +
81
+ '<div class="notif-banner-project">CLAY</div>' +
82
+ '<div class="notif-banner-title">' + opts.title + '</div>' +
83
+ '<div class="notif-banner-text">' + opts.text + '</div>' +
84
+ '</div>' +
85
+ '<button class="notif-banner-close">' + iconHtml("x") + '</button>';
86
+
87
+ container.appendChild(banner);
88
+ refreshIcons();
89
+ requestAnimationFrame(function () { banner.classList.add("show"); });
90
+
91
+ var closeBtn = banner.querySelector(".notif-banner-close");
92
+ if (closeBtn) {
93
+ closeBtn.addEventListener("click", function (e) {
94
+ e.stopPropagation();
95
+ removeBannerEl(banner);
96
+ if (opts.onDismiss) opts.onDismiss();
97
+ });
98
+ }
99
+ if (typeof opts.autoDismissMs === "number") {
100
+ setTimeout(function () { removeBannerEl(banner); }, opts.autoDismissMs);
101
+ }
102
+ return banner;
103
+ }
104
+
105
+ function showLoginStartedBanner(vendor) {
106
+ showLoginBanner({
107
+ icon: "check-circle",
108
+ title: vendorDisplayName(vendor) + " login started",
109
+ text: "Finish the sign-in in the terminal. Clay reloads the credentials on its own, so your sessions keep working - no new session needed.",
110
+ onDismiss: function () { cancelVendorLogin(vendor); },
111
+ });
112
+ }
113
+
114
+ function showLoginCompleteBanner(vendor) {
115
+ showLoginBanner({
116
+ icon: "check-circle",
117
+ title: "Signed in to " + vendorDisplayName(vendor),
118
+ text: "Credentials reloaded. Send your next message to continue.",
119
+ autoDismissMs: 6000,
120
+ });
121
+ }
122
+
123
+ function showLoginErrorBanner(vendor, error) {
124
+ showLoginBanner({
125
+ icon: "alert-triangle",
126
+ title: vendorDisplayName(vendor) + " login could not start",
127
+ text: error || "The login terminal could not be created.",
128
+ autoDismissMs: 8000,
129
+ });
130
+ }
131
+
132
+ function closeModalInternally() {
133
+ modalVendor = null;
134
+ closingModalInternally = true;
135
+ try {
136
+ closeTuiModal();
137
+ } finally {
138
+ closingModalInternally = false;
139
+ }
140
+ }
141
+
142
+ function closeModalForVendor(vendor, terminalId) {
143
+ if (modalVendor !== vendor) return;
144
+ var openId = getTuiModalTerminalId();
145
+ if (typeof terminalId === "number" && openId != null && openId !== terminalId) return;
146
+ closeModalInternally();
147
+ }
148
+
149
+ // ========================================================
150
+ // Outgoing
151
+ // ========================================================
152
+
153
+ /**
154
+ * Ask the server to start (or hand back) the login terminal for a vendor.
155
+ * opts.auto marks a request triggered by an auth_required event; those never
156
+ * open a second prompt while a flow is already running.
157
+ */
158
+ export function requestVendorLogin(vendor, opts) {
159
+ var options = opts || {};
160
+ var target = vendor || "claude";
161
+ if (options.auto && (activeFlows[target] || pendingStarts[target])) return false;
162
+
163
+ var ws = getWs();
164
+ if (!ws || ws.readyState !== 1) return false;
165
+
166
+ pendingStarts[target] = true;
167
+ ws.send(JSON.stringify({
168
+ type: "vendor_login_start",
169
+ vendor: target,
170
+ auto: !!options.auto,
171
+ sessionId: options.sessionId || store.get('activeSessionId') || null,
172
+ }));
173
+ showLoginStartedBanner(target);
174
+ return true;
175
+ }
176
+
177
+ export function cancelVendorLogin(vendor) {
178
+ var target = vendor || modalVendor;
179
+ if (!target) return;
180
+ delete pendingStarts[target];
181
+ delete activeFlows[target];
182
+ clearLoginBanners();
183
+ if (modalVendor === target) closeModalInternally();
184
+ var ws = getWs();
185
+ if (ws && ws.readyState === 1) {
186
+ ws.send(JSON.stringify({ type: "vendor_login_cancel", vendor: target }));
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Auto-open the login flow when the server reports the vendor isn't logged in.
192
+ *
193
+ * Split panes have no banner surface of their own (the parent shell owns the
194
+ * single visible one), so a pane hands the event up instead of running two
195
+ * competing flows.
196
+ */
197
+ export function autoStartLoginIfNeeded(msg) {
198
+ if (!msg) return false;
199
+ if (store.get('paneMode')) {
200
+ return forwardPaneAuthRequired({
201
+ vendor: msg.vendor || "claude",
202
+ sessionId: store.get('activeSessionId') || null,
203
+ });
204
+ }
205
+ var vendor = msg.vendor || "claude";
206
+ if (activeFlows[vendor] || pendingStarts[vendor]) return false;
207
+ return requestVendorLogin(vendor, { auto: true, sessionId: msg.sessionId || null });
208
+ }
209
+
210
+ // ========================================================
211
+ // Incoming
212
+ // ========================================================
213
+
214
+ export function handleVendorLoginReady(msg) {
215
+ if (!msg || typeof msg.terminalId !== "number") return;
216
+ var vendor = msg.vendor || "claude";
217
+ delete pendingStarts[vendor];
218
+ activeFlows[vendor] = { terminalId: msg.terminalId, startedAt: Date.now() };
219
+
220
+ var slug = msg.slug || currentProjectSlug();
221
+ if (!slug) return;
222
+ modalVendor = vendor;
223
+ openTuiModal(msg.terminalId, slug, {
224
+ sessionTitle: vendorDisplayName(vendor) + " login",
225
+ projectName: slug,
226
+ compact: true,
227
+ // Dismissing the modal abandons the login: kill the terminal server-side
228
+ // so it never lingers in the sidebar terminal list.
229
+ onClose: function () {
230
+ if (closingModalInternally) return;
231
+ modalVendor = null;
232
+ cancelVendorLogin(vendor);
233
+ },
234
+ });
235
+ }
236
+
237
+ export function handleVendorLoginState(msg) {
238
+ if (!msg) return;
239
+ var next = Object.create(null);
240
+ var flows = msg.flows || [];
241
+ for (var i = 0; i < flows.length; i++) {
242
+ next[flows[i].vendor] = { terminalId: flows[i].terminalId, startedAt: flows[i].startedAt };
243
+ delete pendingStarts[flows[i].vendor];
244
+ }
245
+
246
+ // A flow the server dropped without an auth_refreshed (cancelled elsewhere,
247
+ // terminal killed from the sidebar) must not leave a stale modal behind.
248
+ var previous = Object.keys(activeFlows);
249
+ for (var p = 0; p < previous.length; p++) {
250
+ if (!next[previous[p]] && modalVendor === previous[p]) {
251
+ closeModalInternally();
252
+ clearLoginBanners();
253
+ }
254
+ }
255
+ activeFlows = next;
256
+ }
257
+
258
+ export function handleAuthRefreshed(msg) {
259
+ if (!msg) return;
260
+ var vendor = msg.vendor || "claude";
261
+ var flow = activeFlows[vendor];
262
+ delete pendingStarts[vendor];
263
+ delete activeFlows[vendor];
264
+ closeModalForVendor(vendor, flow ? flow.terminalId : undefined);
265
+ clearLoginBanners();
266
+ showLoginCompleteBanner(vendor);
267
+ }
268
+
269
+ export function handleVendorLoginError(msg) {
270
+ if (!msg) return;
271
+ var vendor = msg.vendor || "claude";
272
+ delete pendingStarts[vendor];
273
+ delete activeFlows[vendor];
274
+ if (modalVendor === vendor) closeModalInternally();
275
+ clearLoginBanners();
276
+ showLoginErrorBanner(vendor, msg.error);
277
+ }
278
+
279
+ export function requestVendorLoginState() {
280
+ var ws = getWs();
281
+ if (!ws || ws.readyState !== 1) return;
282
+ ws.send(JSON.stringify({ type: "vendor_login_state_request" }));
283
+ }
284
+
285
+ export function getActiveLoginFlow(vendor) {
286
+ return activeFlows[vendor] || null;
287
+ }
@@ -1,5 +1,6 @@
1
1
  var usersModule = require("./users");
2
2
  var yoke = require("./yoke");
3
+ var backgroundTaskTiming = require("./background-task-timing");
3
4
 
4
5
  function attachMessageProcessor(ctx) {
5
6
  var sm = ctx.sm;
@@ -686,8 +687,15 @@ function attachMessageProcessor(ctx) {
686
687
  }
687
688
 
688
689
  } else if (parsed.yokeType === "background_tasks_changed") {
689
- var previousBackgroundTaskCount = (session.activeBackgroundTasks || []).length;
690
- var activeBackgroundTasks = Array.isArray(parsed.tasks) ? parsed.tasks : [];
690
+ var previousBackgroundTasks = session.activeBackgroundTasks || [];
691
+ var previousBackgroundTaskCount = previousBackgroundTasks.length;
692
+ // Vendors re-send the whole list on every change, so carry each task's
693
+ // first-seen time forward; otherwise the composer's elapsed timer would
694
+ // restart whenever any other task starts or finishes.
695
+ var activeBackgroundTasks = backgroundTaskTiming.mergeStartTimes(
696
+ previousBackgroundTasks,
697
+ Array.isArray(parsed.tasks) ? parsed.tasks : []
698
+ );
691
699
  session.activeBackgroundTasks = activeBackgroundTasks;
692
700
  sendToSession(session, { type: "active_background_tasks", tasks: activeBackgroundTasks });
693
701
  if (previousBackgroundTaskCount !== activeBackgroundTasks.length) {
@@ -137,7 +137,17 @@ function seedBuiltInCapsules(ctx) {
137
137
  for (var ri = 0; ri < REMOVED_BUILTIN_IDS.length; ri++) {
138
138
  fs.rmSync(path.join(root, REMOVED_BUILTIN_IDS[ri]), { recursive: true, force: true });
139
139
  }
140
- var entries = fs.readdirSync(CAPSULES_ROOT, { withFileTypes: true });
140
+ // Clay ships no built-in Capsules any more, so lib/capsules/ is absent from a
141
+ // fresh checkout and from the npm tarball (neither git nor npm carries an
142
+ // empty directory). Seeding still owns the removed-built-in migration above
143
+ // and the marker below, so an absent root means "nothing to copy" rather than
144
+ // a failure. Anything other than a missing root is still a real error.
145
+ var entries = [];
146
+ try {
147
+ entries = fs.readdirSync(CAPSULES_ROOT, { withFileTypes: true });
148
+ } catch (e) {
149
+ if (e.code !== "ENOENT") throw e;
150
+ }
141
151
  var seeded = [];
142
152
  for (var i = 0; i < entries.length; i++) {
143
153
  if (!entries[i].isDirectory()) continue;
package/lib/ws-schema.js CHANGED
@@ -171,6 +171,13 @@ var schema = {
171
171
  // Auth
172
172
  // -----------------------------------------------------------------------
173
173
  "auth_required": { direction: "s2c", handler: "lib/public/modules/app-messages.js", description: "Authentication required (e.g. API key needed)" },
174
+ "vendor_login_start": { direction: "c2s", handler: "lib/project-vendor-login.js", description: "Start (or re-attach to) the per-vendor login terminal" },
175
+ "vendor_login_cancel": { direction: "c2s", handler: "lib/project-vendor-login.js", description: "Dismiss the login flow and kill its terminal" },
176
+ "vendor_login_state_request": { direction: "c2s", handler: "lib/project-vendor-login.js", description: "Ask for the project's active login flows" },
177
+ "vendor_login_ready": { direction: "s2c", handler: "lib/public/modules/vendor-login.js", description: "Login terminal is available; open/re-attach the modal to it" },
178
+ "vendor_login_state": { direction: "s2c", handler: "lib/public/modules/vendor-login.js", description: "Active login flows for this project (one per vendor)" },
179
+ "vendor_login_error": { direction: "s2c", handler: "lib/public/modules/vendor-login.js", description: "The login flow could not be started" },
180
+ "auth_refreshed": { direction: "s2c", handler: "lib/public/modules/vendor-login.js", description: "Login completed and the vendor adapter was restarted" },
174
181
 
175
182
  // -----------------------------------------------------------------------
176
183
  // Rate limiting / scheduling
@@ -400,11 +400,16 @@ function normalizeBackgroundTasks(tasks) {
400
400
  var nativeType = task && task.task_type;
401
401
  var taskType = nativeType === "local_bash" ? "shell"
402
402
  : nativeType === "local_agent" ? "agent" : "other";
403
- return {
403
+ var normalized = {
404
404
  task_id: task && task.task_id,
405
405
  task_type: taskType,
406
406
  description: (task && task.description) || "",
407
407
  };
408
+ // The SDK does not report a start time today. Forward one if a future
409
+ // version adds it; otherwise the session stamps first-seen time.
410
+ var startedAt = task && (task.started_at || task.startedAt || task.created_at);
411
+ if (startedAt) normalized.started_at = startedAt;
412
+ return normalized;
408
413
  });
409
414
  }
410
415
 
@@ -1312,15 +1312,14 @@ function createCodexAdapter(opts) {
1312
1312
  // model listing must not depend on a successful app-server init — otherwise a
1313
1313
  // slow/failed `initialize` leaves the picker empty and the chip shows the
1314
1314
  // previous vendor's model.
1315
+ // Kept in sync with what `model/list` reports for the bundled codex build
1316
+ // (0.152.1). Models the CLI no longer offers are rejected at turn/start, so
1317
+ // listing them would only surface a runtime failure in the model picker.
1315
1318
  var CODEX_MODELS = [
1316
1319
  "gpt-5.6-terra",
1317
1320
  "gpt-5.6-sol",
1318
1321
  "gpt-5.6-luna",
1319
1322
  "gpt-5.5",
1320
- "gpt-5.4",
1321
- "gpt-5.4-mini",
1322
- "gpt-5.3-codex",
1323
- "gpt-5.3-codex-spark",
1324
1323
  "gpt-5.2",
1325
1324
  ];
1326
1325
  var _cachedModels = CODEX_MODELS.slice();
@@ -13,7 +13,13 @@ var { buildUserEnv } = require("../build-user-env");
13
13
  var { wrapSpawnAsUser } = require("../os-users");
14
14
 
15
15
  // --- Find the codex binary path ---
16
- // Mirrors the logic from @openai/codex-sdk findCodexPath()
16
+ // Mirrors the logic from @openai/codex-sdk findCodexPath().
17
+ //
18
+ // The bundled @openai/codex platform package is the default. Set the
19
+ // CLAY_CODEX_PATH env var to an existing executable to run a different codex
20
+ // build (local dev build, system install, pinned older release); the chosen
21
+ // binary is logged on every resolution. An unset or non-existent
22
+ // CLAY_CODEX_PATH silently falls back to the bundled resolution.
17
23
 
18
24
  var PLATFORM_PACKAGE_BY_TARGET = {
19
25
  "aarch64-apple-darwin": "@openai/codex-darwin-arm64",
@@ -39,6 +45,15 @@ function getTargetTriple() {
39
45
  }
40
46
 
41
47
  function findCodexPath() {
48
+ var override = process.env.CLAY_CODEX_PATH;
49
+ if (override) {
50
+ if (fs.existsSync(override)) {
51
+ console.log("[codex-app-server] Using CLAY_CODEX_PATH binary:", override);
52
+ return override;
53
+ }
54
+ console.warn("[codex-app-server] CLAY_CODEX_PATH does not exist, falling back to bundled codex:", override);
55
+ }
56
+
42
57
  var triple = getTargetTriple();
43
58
  if (!triple) throw new Error("Unsupported platform: " + process.platform + "/" + process.arch);
44
59
 
@@ -59,7 +74,10 @@ function findCodexPath() {
59
74
  path.join(vendorRoot, triple, "codex", binaryName),
60
75
  ];
61
76
  for (var i = 0; i < candidates.length; i++) {
62
- if (fs.existsSync(candidates[i])) return candidates[i];
77
+ if (fs.existsSync(candidates[i])) {
78
+ console.log("[codex-app-server] Using bundled codex binary:", candidates[i]);
79
+ return candidates[i];
80
+ }
63
81
  }
64
82
  throw new Error("codex binary not found in any known layout under " + path.join(vendorRoot, triple));
65
83
  } catch (e) {
@@ -19,11 +19,17 @@ function mapTerminals(result) {
19
19
  var terminal = terminals[i] || {};
20
20
  var taskId = terminal.id || terminal.terminalId || terminal.taskId || "";
21
21
  if (!taskId || !isLiveTerminal(terminal)) continue;
22
- tasks.push({
22
+ var task = {
23
23
  task_id: String(taskId),
24
24
  task_type: "shell",
25
25
  description: terminal.commandLine || terminal.command || terminal.name || "",
26
- });
26
+ };
27
+ // Forward the terminal's own start time when the app-server reports one,
28
+ // so elapsed time survives a Clay restart. Falls back to first-seen
29
+ // stamping in background-task-timing.js when absent.
30
+ var startedAt = terminal.startedAt || terminal.started_at || terminal.createdAt || terminal.created_at;
31
+ if (startedAt) task.started_at = startedAt;
32
+ tasks.push(task);
27
33
  }
28
34
  return tasks;
29
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clay-server",
3
- "version": "4.0.0-beta.1",
3
+ "version": "4.0.0-beta.3",
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",
@@ -51,7 +51,7 @@
51
51
  "dependencies": {
52
52
  "@anthropic-ai/claude-agent-sdk": "^0.3.241",
53
53
  "@lydell/node-pty": "^1.2.0-beta.3",
54
- "@openai/codex": "^0.147.0",
54
+ "@openai/codex": "^0.152.1",
55
55
  "@seald-io/nedb": "^4.1.2",
56
56
  "imapflow": "^1.3.1",
57
57
  "nodemailer": "^9.0.5",