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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -78,6 +78,7 @@ function attachConnection(ctx) {
78
78
  var _mcp = ctx._mcp;
79
79
  var _notifications = ctx._notifications;
80
80
  var _splitGroups = ctx._splitGroups;
81
+ var _vendorLogin = ctx._vendorLogin;
81
82
  var hydrateImageRefs = ctx.hydrateImageRefs;
82
83
  var resolveSessionForView = ctx.resolveSessionForView;
83
84
  var broadcastClientCount = ctx.broadcastClientCount;
@@ -191,6 +192,9 @@ function attachConnection(ctx) {
191
192
  sendTo(ws, { type: "last_vendor", vendor: sm.lastVendor || "" });
192
193
  sendTo(ws, Object.assign({ type: "codex_config" }, getCodexConfig(sm)));
193
194
  sendTo(ws, { type: "term_list", terminals: tm.list() });
195
+ // Any login flow already running in this project, so a reconnecting client
196
+ // re-attaches to the existing login terminal instead of starting another.
197
+ if (_vendorLogin) _vendorLogin.sendStateTo(ws);
194
198
  // Context sources sent after session is resolved (per-session storage)
195
199
  // Send email accounts list for context sources picker
196
200
  var emailUserId = (wsUser && wsUser.id) || "default";
@@ -0,0 +1,397 @@
1
+ // Vendor login flow coordinator.
2
+ //
3
+ // Owns the "the vendor CLI is not logged in" recovery path end to end so the
4
+ // client never has to guess. One login terminal per vendor per project is the
5
+ // single source of truth: repeated auth_required events (other sessions, split
6
+ // panes, a burst of 401 retries) re-use that record instead of spawning more
7
+ // terminals.
8
+ //
9
+ // Lifecycle:
10
+ // vendor_login_start -> create (or re-use) a PTY running the vendor login
11
+ // command, reply with vendor_login_ready
12
+ // PTY output/exit -> detect a successful login, restart the vendor's
13
+ // YOKE adapter so it re-reads the credential file,
14
+ // broadcast auth_refreshed, close the terminal
15
+ // vendor_login_cancel -> user dismissed the flow; kill the terminal
16
+ //
17
+ // The adapter restart is the actual bug fix: a Codex app-server is a long-lived
18
+ // child process that reads ~/.codex/auth.json once at spawn, so without a
19
+ // restart every query after a successful login keeps hitting 401 and re-arms
20
+ // auth_required forever.
21
+
22
+ var vendorRegistry = require("./yoke/vendor-registry");
23
+
24
+ // Login CLIs print a success line and then hand the shell back, so the PTY
25
+ // itself does not exit. Watch the output stream instead. Deliberately narrow:
26
+ // "Successfully logged out" and MCP-server logins must not match.
27
+ var LOGIN_SUCCESS_PATTERNS = [
28
+ /successfully logged in(?![ \t]+to[ \t]+mcp)/i,
29
+ /logged in successfully/i,
30
+ /login successful/i,
31
+ /successfully authenticated/i,
32
+ /authentication successful/i,
33
+ /signed in successfully/i,
34
+ /signed in with your [a-z ]+ account/i,
35
+ /you(?:'re| are) (?:now )?(?:logged|signed) in/i,
36
+ ];
37
+
38
+ // Rolling window kept per flow so a success line split across PTY chunks is
39
+ // still matched, without buffering the whole login transcript.
40
+ var OUTPUT_TAIL_MAX = 4096;
41
+
42
+ // Grace period between detecting success and killing the terminal, so the user
43
+ // actually sees the confirmation line before the modal closes.
44
+ var CLOSE_AFTER_SUCCESS_MS = 2000;
45
+
46
+ var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\r/g;
47
+
48
+ function stripAnsi(text) {
49
+ return String(text).replace(ANSI_PATTERN, "");
50
+ }
51
+
52
+ function looksLikeLoginSuccess(text) {
53
+ for (var i = 0; i < LOGIN_SUCCESS_PATTERNS.length; i++) {
54
+ if (LOGIN_SUCCESS_PATTERNS[i].test(text)) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ /**
60
+ * ctx fields:
61
+ * slug, osUsers,
62
+ * sm, tm, adapters,
63
+ * send, sendTo,
64
+ * usersModule,
65
+ * getOsUserInfoForWs, getOsUserInfoForLinuxUser, getLinuxUserForSession
66
+ */
67
+ function attachVendorLogin(ctx) {
68
+ var slug = ctx.slug;
69
+ var osUsers = ctx.osUsers;
70
+
71
+ var sm = ctx.sm;
72
+ var tm = ctx.tm;
73
+ var adapters = ctx.adapters || {};
74
+
75
+ var send = ctx.send;
76
+ var sendTo = ctx.sendTo;
77
+
78
+ var usersModule = ctx.usersModule;
79
+
80
+ var getOsUserInfoForWs = ctx.getOsUserInfoForWs;
81
+ var getOsUserInfoForLinuxUser = ctx.getOsUserInfoForLinuxUser;
82
+ var getLinuxUserForSession = ctx.getLinuxUserForSession;
83
+
84
+ // vendor -> flow record. At most one live login terminal per vendor.
85
+ var _flows = Object.create(null);
86
+
87
+ function loginCommandFor(vendor) {
88
+ var info = vendorRegistry.getVendorInfo(vendor);
89
+ return (info && info.loginCommand) || "claude login";
90
+ }
91
+
92
+ function vendorDisplayName(vendor) {
93
+ var info = vendorRegistry.getVendorInfo(vendor);
94
+ return (info && info.displayName) || vendor || "Vendor";
95
+ }
96
+
97
+ function listFlows() {
98
+ var result = [];
99
+ var vendors = Object.keys(_flows);
100
+ for (var i = 0; i < vendors.length; i++) {
101
+ var flow = _flows[vendors[i]];
102
+ result.push({
103
+ vendor: flow.vendor,
104
+ terminalId: flow.terminalId,
105
+ startedAt: flow.startedAt,
106
+ completed: !!flow.completed,
107
+ });
108
+ }
109
+ return result;
110
+ }
111
+
112
+ function broadcastState() {
113
+ send({ type: "vendor_login_state", slug: slug, flows: listFlows() });
114
+ }
115
+
116
+ function sendStateTo(ws) {
117
+ sendTo(ws, { type: "vendor_login_state", slug: slug, flows: listFlows() });
118
+ }
119
+
120
+ function hasTerminalPermission(ws) {
121
+ if (!ws || !ws._clayUser) return true;
122
+ if (!usersModule || typeof usersModule.getEffectivePermissions !== "function") return true;
123
+ var perms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
124
+ return !!(perms && perms.terminal);
125
+ }
126
+
127
+ // The login terminal must write credentials to the same HOME the adapter
128
+ // will read them from. With OS-user isolation the adapter runs as the
129
+ // *session owner's* Linux user, which is not necessarily the connected
130
+ // client, so prefer the session identity when the requester is entitled to
131
+ // it. Without isolation both sides run as the daemon user and this resolves
132
+ // to null on both paths.
133
+ function resolveLoginIdentity(ws, msg) {
134
+ if (!osUsers) return { linuxUser: null, osUserInfo: null };
135
+
136
+ var sessionLinuxUser = null;
137
+ var sessionId = msg && msg.sessionId;
138
+ if (sessionId && sm && sm.sessions && typeof getLinuxUserForSession === "function") {
139
+ var session = sm.sessions.get(sessionId);
140
+ if (session) {
141
+ var requesterId = ws && ws._clayUser ? ws._clayUser.id : null;
142
+ var isOwner = !session.ownerId || (requesterId && String(session.ownerId) === String(requesterId));
143
+ var isAdmin = !!(ws && ws._clayUser && ws._clayUser.role === "admin");
144
+ if (isOwner || isAdmin) sessionLinuxUser = getLinuxUserForSession(session);
145
+ }
146
+ }
147
+
148
+ if (sessionLinuxUser && typeof getOsUserInfoForLinuxUser === "function") {
149
+ return { linuxUser: sessionLinuxUser, osUserInfo: getOsUserInfoForLinuxUser(sessionLinuxUser) };
150
+ }
151
+
152
+ var wsInfo = typeof getOsUserInfoForWs === "function" ? getOsUserInfoForWs(ws) : null;
153
+ return { linuxUser: wsInfo ? wsInfo.user : null, osUserInfo: wsInfo };
154
+ }
155
+
156
+ function isFlowTerminalAlive(flow) {
157
+ return !!(flow && typeof flow.terminalId === "number" && tm.has(flow.terminalId));
158
+ }
159
+
160
+ function discardFlow(vendor) {
161
+ var flow = _flows[vendor];
162
+ if (!flow) return null;
163
+ delete _flows[vendor];
164
+ if (flow.closeTimer) {
165
+ clearTimeout(flow.closeTimer);
166
+ flow.closeTimer = null;
167
+ }
168
+ return flow;
169
+ }
170
+
171
+ // Kill the PTY and drop it from the sidebar list. `flow.closing` keeps the
172
+ // terminal's own exit hook from re-entering finalization.
173
+ function closeFlowTerminal(flow) {
174
+ if (!flow || typeof flow.terminalId !== "number") return;
175
+ flow.closing = true;
176
+ try { tm.close(flow.terminalId); } catch (e) {}
177
+ send({ type: "term_list", terminals: tm.list() });
178
+ }
179
+
180
+ // Restart the vendor adapter so the next query spawns a process that reads
181
+ // the credentials the login just wrote. Shared adapter instances (Claude) are
182
+ // reused by every project, so tearing one down here would kill unrelated
183
+ // sessions; those runtimes pick up new credentials on their own.
184
+ function refreshVendorAuth(vendor) {
185
+ var adapter = adapters[vendor];
186
+ if (!adapter || typeof adapter.shutdown !== "function") return Promise.resolve(false);
187
+ if (adapter.shared) {
188
+ console.log("[vendor-login] " + vendor + " adapter is shared; skipping restart for " + slug);
189
+ return Promise.resolve(false);
190
+ }
191
+ // adapter.shutdown() already fans out to every per-OS-user runtime it
192
+ // created (shutdownUserRuntimes), so one call covers isolated setups too.
193
+ return Promise.resolve()
194
+ .then(function () { return adapter.shutdown(); })
195
+ .then(function () {
196
+ console.log("[vendor-login] Restarted " + vendor + " adapter for " + slug + " after login");
197
+ return true;
198
+ })
199
+ .catch(function (err) {
200
+ console.error("[vendor-login] " + vendor + " adapter restart failed for " + slug + ":",
201
+ err && err.message ? err.message : err);
202
+ return false;
203
+ });
204
+ }
205
+
206
+ function finishFlow(vendor, succeeded) {
207
+ var flow = _flows[vendor];
208
+ if (!flow || flow.finishing) return;
209
+ flow.finishing = true;
210
+ flow.completed = !!succeeded;
211
+
212
+ var refresh = succeeded ? refreshVendorAuth(vendor) : Promise.resolve(false);
213
+ refresh.then(function (restarted) {
214
+ discardFlow(vendor);
215
+ closeFlowTerminal(flow);
216
+ if (succeeded) {
217
+ send({ type: "auth_refreshed", slug: slug, vendor: vendor, adapterRestarted: !!restarted });
218
+ }
219
+ broadcastState();
220
+ });
221
+ }
222
+
223
+ function handleFlowOutput(vendor, chunk) {
224
+ var flow = _flows[vendor];
225
+ if (!flow || flow.finishing || flow.succeeded) return;
226
+ flow.outputTail = (flow.outputTail + stripAnsi(chunk)).slice(-OUTPUT_TAIL_MAX);
227
+ if (!looksLikeLoginSuccess(flow.outputTail)) return;
228
+
229
+ flow.succeeded = true;
230
+ console.log("[vendor-login] Detected successful " + vendor + " login in " + slug);
231
+ // Let the success line land on screen before the terminal disappears.
232
+ flow.closeTimer = setTimeout(function () {
233
+ finishFlow(vendor, true);
234
+ }, CLOSE_AFTER_SUCCESS_MS);
235
+ if (flow.closeTimer && typeof flow.closeTimer.unref === "function") flow.closeTimer.unref();
236
+ }
237
+
238
+ // Fires for both a PTY that exited on its own and one the user closed from
239
+ // the sidebar. Either way the flow is over; refresh auth if the command had
240
+ // already reported success.
241
+ function handleFlowExit(vendor) {
242
+ var flow = _flows[vendor];
243
+ if (!flow || flow.closing || flow.finishing) return;
244
+ finishFlow(vendor, !!flow.succeeded);
245
+ }
246
+
247
+ function startFlow(ws, vendor, msg) {
248
+ var identity = resolveLoginIdentity(ws, msg);
249
+ var command = loginCommandFor(vendor);
250
+ var flow = {
251
+ vendor: vendor,
252
+ terminalId: null,
253
+ startedAt: Date.now(),
254
+ linuxUser: identity.linuxUser,
255
+ outputTail: "",
256
+ succeeded: false,
257
+ finishing: false,
258
+ closing: false,
259
+ completed: false,
260
+ closeTimer: null,
261
+ };
262
+ // Registered before create() so the PTY's first output chunk (hooks fire
263
+ // synchronously from spawn) already finds the record.
264
+ _flows[vendor] = flow;
265
+
266
+ var terminal = null;
267
+ try {
268
+ terminal = tm.create(100, 30, identity.osUserInfo, ws, {
269
+ initialInput: command + "\n",
270
+ title: vendorDisplayName(vendor) + " login",
271
+ kind: "vendor-login",
272
+ onData: function (data) { handleFlowOutput(vendor, data); },
273
+ onExit: function () { handleFlowExit(vendor); },
274
+ });
275
+ } catch (e) {
276
+ console.error("[vendor-login] Failed to spawn " + vendor + " login terminal in " + slug + ":",
277
+ e && e.message ? e.message : e);
278
+ }
279
+
280
+ if (!terminal) {
281
+ delete _flows[vendor];
282
+ return null;
283
+ }
284
+
285
+ flow.terminalId = terminal.id;
286
+ tm.attach(terminal.id, ws);
287
+ send({ type: "term_list", terminals: tm.list() });
288
+ return flow;
289
+ }
290
+
291
+ function handleVendorLoginMessage(ws, msg) {
292
+ if (msg.type === "vendor_login_state_request") {
293
+ sendStateTo(ws);
294
+ return true;
295
+ }
296
+
297
+ if (msg.type === "vendor_login_cancel") {
298
+ var cancelVendor = String(msg.vendor || "");
299
+ var cancelled = discardFlow(cancelVendor);
300
+ if (cancelled) closeFlowTerminal(cancelled);
301
+ broadcastState();
302
+ return true;
303
+ }
304
+
305
+ if (msg.type === "vendor_login_start") {
306
+ var vendor = String(msg.vendor || "claude");
307
+ if (!vendorRegistry.getVendorInfo(vendor)) {
308
+ sendTo(ws, { type: "vendor_login_error", vendor: vendor, error: "Unknown vendor: " + vendor });
309
+ return true;
310
+ }
311
+ if (!hasTerminalPermission(ws)) {
312
+ sendTo(ws, { type: "vendor_login_error", vendor: vendor, error: "Terminal access is not permitted" });
313
+ return true;
314
+ }
315
+
316
+ var existing = _flows[vendor];
317
+ if (existing && !isFlowTerminalAlive(existing)) {
318
+ // Terminal died without its exit hook clearing the record.
319
+ discardFlow(vendor);
320
+ existing = null;
321
+ }
322
+
323
+ if (existing) {
324
+ // An auto-triggered request (a fresh auth_required from another
325
+ // session or split pane) must not pop a second prompt; it only learns
326
+ // that a flow is already running. A deliberate request re-attaches.
327
+ if (!msg.auto) {
328
+ tm.attach(existing.terminalId, ws);
329
+ sendTo(ws, {
330
+ type: "vendor_login_ready",
331
+ slug: slug,
332
+ vendor: vendor,
333
+ terminalId: existing.terminalId,
334
+ reused: true,
335
+ });
336
+ }
337
+ sendStateTo(ws);
338
+ return true;
339
+ }
340
+
341
+ var started = startFlow(ws, vendor, msg);
342
+ if (!started) {
343
+ sendTo(ws, {
344
+ type: "vendor_login_error",
345
+ vendor: vendor,
346
+ error: "Cannot create terminal (node-pty not available or limit reached)",
347
+ });
348
+ return true;
349
+ }
350
+
351
+ console.log("[vendor-login] Started " + vendor + " login terminal " + started.terminalId + " in " + slug);
352
+ sendTo(ws, {
353
+ type: "vendor_login_ready",
354
+ slug: slug,
355
+ vendor: vendor,
356
+ terminalId: started.terminalId,
357
+ reused: false,
358
+ });
359
+ broadcastState();
360
+ return true;
361
+ }
362
+
363
+ return false;
364
+ }
365
+
366
+ // The sidebar "close terminal" path goes through tm.close() directly, which
367
+ // fires our exit hook. This is the explicit entry point for callers that
368
+ // remove a terminal without going through the PTY.
369
+ function forgetTerminal(terminalId) {
370
+ var vendors = Object.keys(_flows);
371
+ for (var i = 0; i < vendors.length; i++) {
372
+ if (_flows[vendors[i]].terminalId === terminalId) {
373
+ handleFlowExit(vendors[i]);
374
+ return true;
375
+ }
376
+ }
377
+ return false;
378
+ }
379
+
380
+ function isLoginTerminal(terminalId) {
381
+ var vendors = Object.keys(_flows);
382
+ for (var i = 0; i < vendors.length; i++) {
383
+ if (_flows[vendors[i]].terminalId === terminalId) return true;
384
+ }
385
+ return false;
386
+ }
387
+
388
+ return {
389
+ handleVendorLoginMessage: handleVendorLoginMessage,
390
+ sendStateTo: sendStateTo,
391
+ listFlows: listFlows,
392
+ forgetTerminal: forgetTerminal,
393
+ isLoginTerminal: isLoginTerminal,
394
+ };
395
+ }
396
+
397
+ module.exports = { attachVendorLogin: attachVendorLogin };
package/lib/project.js CHANGED
@@ -34,6 +34,7 @@ var { attachSessions } = require("./project-sessions");
34
34
  var { attachModels } = require("./project-models");
35
35
  var { attachUserMessage } = require("./project-user-message");
36
36
  var { attachShellCommand } = require("./project-shell-command");
37
+ var { attachVendorLogin } = require("./project-vendor-login");
37
38
  var { attachConnection } = require("./project-connection");
38
39
  var { attachMcp } = require("./project-mcp");
39
40
  var { createLocalMcp } = require("./mcp-local");
@@ -1286,6 +1287,9 @@ function createProjectContext(opts) {
1286
1287
  // --- Shell command context ---
1287
1288
  if (_shellCommand.handleShellCommand(ws, msg)) return;
1288
1289
 
1290
+ // --- Vendor login flow (delegated to project-vendor-login.js) ---
1291
+ if (_vendorLogin.handleVendorLoginMessage(ws, msg)) return;
1292
+
1289
1293
  // --- Notes, terminals, context, user message (delegated to project-user-message.js) ---
1290
1294
  if (_userMessage.handleUserMessage(ws, msg)) return;
1291
1295
  }
@@ -1593,6 +1597,21 @@ function createProjectContext(opts) {
1593
1597
  getOsUserInfoForWs: getOsUserInfoForWs,
1594
1598
  });
1595
1599
 
1600
+ // --- Vendor login flow (delegated to project-vendor-login.js) ---
1601
+ var _vendorLogin = attachVendorLogin({
1602
+ slug: slug,
1603
+ osUsers: osUsers,
1604
+ sm: sm,
1605
+ tm: tm,
1606
+ adapters: adapters,
1607
+ send: send,
1608
+ sendTo: sendTo,
1609
+ usersModule: usersModule,
1610
+ getOsUserInfoForWs: getOsUserInfoForWs,
1611
+ getOsUserInfoForLinuxUser: getOsUserInfoForLinuxUser,
1612
+ getLinuxUserForSession: getLinuxUserForSession,
1613
+ });
1614
+
1596
1615
  // --- Filesystem handler (delegated to project-filesystem.js) ---
1597
1616
  var _filesystem = attachFilesystem({
1598
1617
  cwd: cwd,
@@ -1866,6 +1885,7 @@ function createProjectContext(opts) {
1866
1885
  _mcp: _mcp,
1867
1886
  _notifications: _notifications,
1868
1887
  _splitGroups: _splitGroups,
1888
+ _vendorLogin: _vendorLogin,
1869
1889
  resolveSessionForView: _sessions.resolveSessionForView,
1870
1890
  hydrateImageRefs: hydrateImageRefs,
1871
1891
  broadcastClientCount: broadcastClientCount,
@@ -37,7 +37,7 @@ import { updateSettingsStats, updateDaemonConfig, handleSetPinResult, handleKeep
37
37
  import { handleTermList, handleTermCreated, sendTerminalCommand, handleTermOutput, handleTermResized, handleTermExited, handleTermClosed } from './terminal.js';
38
38
  import { attachTuiView, detachTuiView, setTuiSuspendedView, tuiHandleTermOutput, tuiHandleTermResized, tuiHandleTermExited, tuiHandleTermClosed } from './session-tui-view.js';
39
39
  import { handleTuiTranscriptState } from './tui-grab.js';
40
- import { tuiModalHandleTermOutput, tuiModalHandleTermResized, tuiModalHandleTermExited, tuiModalHandleTermClosed, openTuiModal } from './tui-attention.js';
40
+ import { tuiModalHandleTermOutput, tuiModalHandleTermResized, tuiModalHandleTermExited, tuiModalHandleTermClosed } from './tui-attention.js';
41
41
  import { updateTerminalList, handleContextSourcesState, updateEmailAccountList, updateEmailUnreadCounts, handleEmailTestResult, handleEmailAddResult, handleEmailRemoveResult } from './context-sources.js';
42
42
  import { refreshEmailSettings } from './user-settings.js';
43
43
  import { handleNotesList, handleNoteCreated, handleNoteUpdated, handleNoteDeleted, handleNoteWritten } from './sticky-notes.js';
@@ -72,7 +72,8 @@ import { handleRemoteCursorMove, handleRemoteCursorLeave, handleRemoteSelection,
72
72
  import { showLoopBanner, updateLoopBanner, updateLoopInputVisibility, showRalphApprovalBar, updateRalphApprovalStatus, openRalphPreviewModal, showExecModal, updateExecModalStatus } from './app-loop-ui.js';
73
73
  import { showDebateSticky, showDebateConcludeConfirm, showDebateUserFloor, exitDebateFloorMode, exitDebateConcludeMode, exitDebateEndedMode, updateDebateRound, renderDebateUserFloorDone } from './app-debate-ui.js';
74
74
  import { handleSkillInstallWs } from './app-skills-install.js';
75
- import { handleNotificationsState, handleNotificationCreated, handleNotificationDismissed, handleNotificationDismissedAll, showUpdateBanner, autoStartLoginIfNeeded } from './app-notifications.js';
75
+ import { handleNotificationsState, handleNotificationCreated, handleNotificationDismissed, handleNotificationDismissedAll, showUpdateBanner } from './app-notifications.js';
76
+ import { autoStartLoginIfNeeded, handleVendorLoginReady, handleVendorLoginState, handleVendorLoginError, handleAuthRefreshed } from './vendor-login.js';
76
77
  import { handleDebatePreparing, handleDebateBriefReady, renderDebateBriefReady, handleDebateStarted, renderDebateStarted, handleDebateTurn, handleDebateActivity, handleDebateStream, handleDebateTurnDone, handleDebateCommentQueued, handleDebateCommentInjected, renderDebateCommentInjected, handleDebateResumed, handleDebateEnded, renderDebateEnded, handleDebateError, isDebateActive, renderMcpDebateProposal, renderDebateUserResume } from './debate.js';
77
78
  import { handleMentionStart, handleMentionActivity, handleMentionStream, handleMentionDone, handleMentionError, renderMentionUser, renderMentionResponse, renderUserMention } from './mention.js';
78
79
 
@@ -1299,11 +1300,28 @@ export function processMessage(msg) {
1299
1300
  appendDelta((msg.text || "Authentication required.") + "\n");
1300
1301
  setStatus("connected");
1301
1302
  if (!store.get('loopActive')) enableMainInput();
1302
- // Auto-open the login modal terminal when the session can self-login.
1303
- // No-op otherwise (the auth_required banner remains the manual path).
1303
+ // Open the login flow for this vendor. The server keeps exactly one
1304
+ // login terminal per vendor, so repeat auth_required events (other
1305
+ // sessions, split panes, a 401 retry burst) do not stack up.
1304
1306
  autoStartLoginIfNeeded(msg);
1305
1307
  break;
1306
1308
 
1309
+ case "vendor_login_ready":
1310
+ handleVendorLoginReady(msg);
1311
+ break;
1312
+
1313
+ case "vendor_login_state":
1314
+ handleVendorLoginState(msg);
1315
+ break;
1316
+
1317
+ case "vendor_login_error":
1318
+ handleVendorLoginError(msg);
1319
+ break;
1320
+
1321
+ case "auth_refreshed":
1322
+ handleAuthRefreshed(msg);
1323
+ break;
1324
+
1307
1325
  case "rate_limit":
1308
1326
  handleRateLimitEvent(msg);
1309
1327
  break;
@@ -1492,19 +1510,8 @@ export function processMessage(msg) {
1492
1510
  break;
1493
1511
 
1494
1512
  case "term_created":
1495
- // Login-modal path: the terminal was created with the login command as
1496
- // its initial input; attach the modal dialog to it (replays scrollback
1497
- // so the login URL is visible) instead of the sidebar terminal.
1498
- if (store.get('pendingLoginModal')) {
1499
- var _lm = store.get('pendingLoginModal');
1500
- store.set({ pendingLoginModal: null });
1501
- openTuiModal(msg.id, _lm.slug, {
1502
- sessionTitle: (VENDOR_NAMES[_lm.vendor] || "Claude Code") + " login",
1503
- projectName: _lm.slug,
1504
- compact: true,
1505
- });
1506
- break;
1507
- }
1513
+ // Login terminals never come through here: they are created by the
1514
+ // server-owned flow and announced with vendor_login_ready instead.
1508
1515
  handleTermCreated(msg);
1509
1516
  if (store.get('pendingTermCommand')) {
1510
1517
  var cmd = store.get('pendingTermCommand');
@@ -10,8 +10,8 @@ import { showHomeHub } from './app-home-hub.js';
10
10
  import { openHomeChat } from './home-mate-chat.js';
11
11
  import { getCachedProjects, switchProject, renderProjectList } from './app-projects.js';
12
12
  import { mateAvatarUrl, userAvatarUrl } from './avatar.js';
13
- import { openTerminal } from './terminal.js';
14
13
  import { openTuiModal } from './tui-attention.js';
14
+ import { requestVendorLogin } from './vendor-login.js';
15
15
  import { startUrgentBlink, stopUrgentBlink } from './app-favicon.js';
16
16
  import { playDoneSound, isNotifSoundEnabled } from './notifications.js';
17
17
  var notifications = [];
@@ -47,7 +47,6 @@ var pendingTuiTotal = 0;
47
47
  // (next hour) acts as a fresh ping.
48
48
  var pendingUpdateMsg = null;
49
49
  var activeAuthRequiredMsg = null;
50
- var authReminderVisible = false;
51
50
 
52
51
  // ========================================================
53
52
  // Init
@@ -91,7 +90,6 @@ function showAllBanners() {
91
90
  }
92
91
 
93
92
  if (activeAuthRequiredMsg) showAuthRequiredBanner(activeAuthRequiredMsg);
94
- if (authReminderVisible) showLoginReminderBanner();
95
93
 
96
94
  // Check if any banner actually got rendered (update/auth banners can be suppressed)
97
95
  var hasVisibleBanner = bannerContainer.children.length > 0;
@@ -266,8 +264,9 @@ function showBanner(notif, autoDismissMs) {
266
264
  removeBanner(banner);
267
265
  dismissNotif(notif.id);
268
266
  var authMeta = notif.meta || {};
269
- startLoginInModal(authMeta.loginCommand || getVendorLoginCommand(authMeta.vendor || "claude"), authMeta.vendor || "claude");
270
- showLoginReminderBanner();
267
+ // Deliberate click: re-attach to a live login terminal instead of
268
+ // spawning a second one (the server dedups per vendor).
269
+ requestVendorLogin(authMeta.vendor || "claude", { sessionId: notif.sessionId || null });
271
270
  });
272
271
  }
273
272
  }
@@ -325,118 +324,6 @@ function showBanner(notif, autoDismissMs) {
325
324
  }
326
325
  }
327
326
 
328
- // Open the login terminal as a modal dialog (the same modal used for TUI
329
- // attention), running the vendor login command. The terminal is created with
330
- // the command as its initial input, so when the modal attaches it replays the
331
- // scrollback and the user sees the login URL / prompts immediately.
332
- // Falls back to the sidebar terminal if the current project slug is unknown
333
- // (the modal needs a slug to open its own WS to the project).
334
- function currentProjectSlug() {
335
- var slug = store.get('currentSlug') || "";
336
- if (slug) return slug;
337
- // Fallback: derive from the URL (/p/<slug>/...).
338
- try {
339
- var m = (window.location.pathname || "").match(/^\/p\/([a-z0-9_-]+)/);
340
- if (m) return m[1];
341
- } catch (e) {}
342
- return "";
343
- }
344
-
345
- function startLoginInModal(loginCommand, vendor) {
346
- if (authReminderVisible) return;
347
- var ws = getWs();
348
- if (!ws || ws.readyState !== 1) return;
349
- var cmd = loginCommand || getVendorLoginCommand(vendor);
350
- var slug = currentProjectSlug();
351
- if (!slug) { startLoginCommand(cmd); return; }
352
- store.set({ pendingLoginModal: { slug: slug, vendor: vendor || "claude" } });
353
- ws.send(JSON.stringify({
354
- type: "term_create",
355
- cols: 100,
356
- rows: 30,
357
- initialCommand: cmd + "\n",
358
- }));
359
- }
360
-
361
- // Sidebar-terminal fallback (used when no slug is available for the modal).
362
- function startLoginCommand(loginCommand) {
363
- if (authReminderVisible) return;
364
- var ws = getWs();
365
- if (!ws || ws.readyState !== 1) return;
366
- var termCommand = (loginCommand || "claude login") + "\n";
367
- store.set({ pendingTermCommand: termCommand });
368
- ws.send(JSON.stringify({ type: "term_create", cols: 80, rows: 24 }));
369
- openTerminal();
370
- }
371
-
372
- // Auto-open the login modal and run the login command when the server reports
373
- // the vendor isn't logged in. The popup terminal runs as the connected user's
374
- // own OS identity (term_create -> getOsUserInfoForWs), so login always targets
375
- // the right account — we pop it unconditionally rather than gating on
376
- // canAutoLogin. Idempotent: startLoginInModal is guarded by authReminderVisible
377
- // so repeat auth_required events no-op.
378
- export function autoStartLoginIfNeeded(msg) {
379
- if (!msg) return false;
380
- if (authReminderVisible) return false;
381
- var vendor = msg.vendor || "claude";
382
- var cmd = msg.loginCommand
383
- || getVendorLoginCommand(vendor);
384
- startLoginInModal(cmd, vendor);
385
- showLoginReminderBanner();
386
- return true;
387
- }
388
-
389
- function showLoginReminderBanner() {
390
- if (!bannerContainer) return;
391
- authReminderVisible = true;
392
- var existing = bannerContainer.querySelector('[data-auth-reminder="true"]');
393
- if (existing) removeBanner(existing);
394
- var banner = document.createElement("div");
395
- banner.className = "notif-banner notif-banner-update";
396
- banner.setAttribute("data-notif-id", "_auth_reminder");
397
- banner.setAttribute("data-auth-reminder", "true");
398
- banner.innerHTML =
399
- '<div class="notif-banner-icon">' + iconHtml("check-circle") + '</div>' +
400
- '<div class="notif-banner-body">' +
401
- '<div class="notif-banner-project">CLAY</div>' +
402
- '<div class="notif-banner-title">Login started</div>' +
403
- '<div class="notif-banner-text">After the login completes, open a new session so Clay picks up the fresh auth state.</div>' +
404
- '<div class="notif-banner-actions">' +
405
- '<button class="notif-banner-new-session notif-banner-update-now">Open new session</button>' +
406
- '</div>' +
407
- '</div>' +
408
- '<button class="notif-banner-close">' + iconHtml("x") + '</button>';
409
-
410
- bannerContainer.appendChild(banner);
411
- refreshIcons();
412
-
413
- requestAnimationFrame(function () {
414
- banner.classList.add("show");
415
- });
416
-
417
- var newSessionBtn = banner.querySelector(".notif-banner-new-session");
418
- if (newSessionBtn) {
419
- newSessionBtn.addEventListener("click", function (e) {
420
- e.stopPropagation();
421
- authReminderVisible = false;
422
- var ws = getWs();
423
- if (ws && ws.readyState === 1) {
424
- ws.send(JSON.stringify({ type: "new_session" }));
425
- }
426
- removeBanner(banner);
427
- });
428
- }
429
-
430
- var closeBtn = banner.querySelector(".notif-banner-close");
431
- if (closeBtn) {
432
- closeBtn.addEventListener("click", function (e) {
433
- e.stopPropagation();
434
- authReminderVisible = false;
435
- removeBanner(banner);
436
- });
437
- }
438
- }
439
-
440
327
  export function showAuthRequiredBanner(msg) {
441
328
  if (!bannerContainer) return;
442
329
  var vendor = (msg && (msg.vendor || (msg.meta && msg.meta.vendor))) || "claude";
@@ -23,6 +23,15 @@ export function reportPaneContext(data) {
23
23
  }, window.location.origin);
24
24
  }
25
25
 
26
+ // A pane has no banner surface and no login modal of its own, so both panes
27
+ // hitting auth_required would otherwise race to start their own login flow.
28
+ // Hand the event to the parent shell, which owns the single flow.
29
+ export function forwardPaneAuthRequired(message) {
30
+ if (!store.get('paneMode') || window.parent === window) return false;
31
+ window.parent.postMessage({ type: "clay-pane-auth-required", message: message }, window.location.origin);
32
+ return true;
33
+ }
34
+
26
35
  export function forwardPaneMarkdownPresentation(message) {
27
36
  if (!store.get('paneMode') || window.parent === window) return false;
28
37
  window.parent.postMessage({ type: "clay-pane-present-markdown", message: message }, window.location.origin);
@@ -11,6 +11,7 @@ import { groupedSessionIds, findSplitGroup } from './split-group-helpers.js';
11
11
  import { showConfirm } from './app-misc.js';
12
12
  import { syncPairChrome } from './split-pair-ui.js';
13
13
  import { presentMarkdownEdit } from './filebrowser.js';
14
+ import { autoStartLoginIfNeeded } from './vendor-login.js';
14
15
 
15
16
  var host = null;
16
17
  var nativeApp = null;
@@ -276,6 +277,12 @@ function handlePaneMessage(event) {
276
277
  if (present) presentMarkdownEdit(present);
277
278
  return;
278
279
  }
280
+ // Both panes can report auth_required for their own sessions. The shell runs
281
+ // one login flow for the project; repeat events are no-ops there.
282
+ if (msg.type === "clay-pane-auth-required") {
283
+ if (msg.message) autoStartLoginIfNeeded(msg.message);
284
+ return;
285
+ }
279
286
  if (msg.type !== "clay-pane-context") return;
280
287
  var frames = host.querySelectorAll(".split-pane-frame");
281
288
  for (var i = 0; i < frames.length; i++) {
@@ -29,6 +29,10 @@ var modalWs = null;
29
29
  var modalResizeObserver = null;
30
30
  var modalKeyHandler = null;
31
31
  var modalResizeDebounce = null;
32
+ // Optional per-open callback fired once the modal is torn down, whatever
33
+ // dismissed it (Esc, backdrop click, term_closed). The login flow uses it to
34
+ // cancel a login the user walked away from.
35
+ var modalCloseHook = null;
32
36
  // Debounced fit+redraw for the modal xterm. Same rationale as
33
37
  // session-tui-view.js: collapses rapid resize events into a single
34
38
  // SIGWINCH so claude can redraw cleanly without mid-resize corruption.
@@ -139,6 +143,7 @@ export function openTuiModal(terminalId, sourceSlug, info) {
139
143
  modalTerminalId = terminalId;
140
144
  modalSourceSlug = sourceSlug;
141
145
  var infoObj = info || {};
146
+ modalCloseHook = typeof infoObj.onClose === "function" ? infoObj.onClose : null;
142
147
  setModalBreadcrumb({
143
148
  projectIcon: infoObj.projectIcon || null,
144
149
  projectName: infoObj.projectName || sourceSlug,
@@ -232,6 +237,8 @@ export function openTuiModal(terminalId, sourceSlug, info) {
232
237
 
233
238
  export function closeTuiModal() {
234
239
  if (!modalEl) return;
240
+ var closeHook = modalCloseHook;
241
+ modalCloseHook = null;
235
242
  if (modalTerminalId != null && modalWs && modalWs.readyState === 1) {
236
243
  try { modalWs.send(JSON.stringify({ type: "term_detach", id: modalTerminalId })); } catch (e) {}
237
244
  }
@@ -247,6 +254,9 @@ export function closeTuiModal() {
247
254
  document.removeEventListener("keydown", modalKeyHandler);
248
255
  modalKeyHandler = null;
249
256
  }
257
+ if (closeHook) {
258
+ try { closeHook(); } catch (e) {}
259
+ }
250
260
  }
251
261
 
252
262
  export function isTuiModalOpen() {
@@ -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
+ }
@@ -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
@@ -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) {
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.2",
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",