Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions lib/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -907,13 +907,20 @@ function createProjectContext(opts) {
slug: slug,
});
} else if ((!sm.modelsByVendor || !sm.modelsByVendor[msg.vendor]) && typeof vendorAdapter.init === "function") {
await vendorAdapter.init({
cwd: cwd,
clayPort: serverPort,
clayTls: serverTls,
clayAuthToken: serverAuthToken,
slug: slug,
});
// Init warms the adapter, but a slow/failed init must not block
// model listing (e.g. Codex models are a fixed list). Keep going
// to supportedModels() even if init throws.
try {
await vendorAdapter.init({
cwd: cwd,
clayPort: serverPort,
clayTls: serverTls,
clayAuthToken: serverAuthToken,
slug: slug,
});
} catch (e) {
console.error("[project] " + msg.vendor + " init failed (continuing to model list):", e.message || e);
}
}
if (vendorAdapter) {
sm.availableVendors = Object.keys(adapters);
Expand Down
14 changes: 12 additions & 2 deletions lib/public/modules/app-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,18 @@ export function processMessage(msg) {
if (_modelVal && typeof _modelVal === "object") _modelVal = _modelVal.value || _modelVal.displayName || "";
var _miUpdate = { currentModels: msg.models || [] };
if (Object.prototype.hasOwnProperty.call(msg, "model")) {
if (store.get('vendorSelectionLocked') && store.get('currentModel')) {
// Keep the user's existing selection; only update models list
// Keep the user's existing selection only if it actually belongs to
// this vendor's model list. Otherwise (e.g. switching to a Codex
// session while currentModel is still a Claude model) snap to the
// server-provided default so the chip doesn't show a cross-vendor
// model.
var _curModel = store.get('currentModel');
var _curInList = !!_curModel && (msg.models || []).some(function (m) {
var v = typeof m === "string" ? m : (m && m.value);
return v === _curModel;
});
if (store.get('vendorSelectionLocked') && _curInList) {
// Keep the user's existing (valid) selection; only update models list
} else {
_miUpdate.currentModel = _modelVal || "";
}
Expand Down
87 changes: 53 additions & 34 deletions lib/sdk-message-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,50 @@ function attachMessageProcessor(ctx) {
sm.sendToSession(session, obj);
}

// Emit the "not logged in" signal (WS message + notification) so the client
// auto-opens the login modal. Shared by the Claude path (login-prompt text
// detection) and the Codex path (app-server unauthorized error). Deduped
// per session to avoid spamming when multiple auth errors arrive in a burst.
function emitAuthRequired(session) {
var now = Date.now();
if (session._lastAuthEmit && (now - session._lastAuthEmit) < 5000) return;
session._lastAuthEmit = now;

var authUser = session.ownerId ? usersModule.findUserById(session.ownerId) : null;
var authLinuxUser = authUser && authUser.linuxUser ? authUser.linuxUser : null;
var canAutoLogin = !usersModule.isMultiUser()
|| !!authLinuxUser
|| (authUser && authUser.role === "admin");
var authTitle = session.vendor === "codex" ? "Codex is not logged in." : "Claude Code is not logged in.";
var loginCommand = session.vendor === "codex"
? "codex login --device-auth"
: "claude login";
var _nmLogin = getNotificationsModule();
sendAndRecord(session, {
type: "auth_required",
text: authTitle,
vendor: session.vendor || "claude",
loginCommand: loginCommand,
linuxUser: authLinuxUser,
canAutoLogin: canAutoLogin,
});
if (_nmLogin) {
_nmLogin.notify("auth_required", {
title: authTitle,
body: "Open a terminal, then click the URL and follow the instructions.",
slug: slug,
sessionId: session.localId,
ownerId: session.ownerId || null,
vendor: session.vendor || "claude",
loginCommand: loginCommand,
linuxUser: authLinuxUser,
canAutoLogin: canAutoLogin,
});
}
// Reset CLI session so the next query starts fresh with new auth.
session.cliSessionId = null;
}

function getModelsForVendor(vendor) {
if (vendor && sm.modelsByVendor && sm.modelsByVendor[vendor]) return sm.modelsByVendor[vendor];
return sm.availableModels || [];
Expand Down Expand Up @@ -447,40 +491,7 @@ function attachMessageProcessor(ctx) {
// Detect "Not logged in / Please run /login" from SDK.
// This is a short canned response with zero cost, not actual AI output.
if (isLoginPrompt) {
var authUser = session.ownerId ? usersModule.findUserById(session.ownerId) : null;
var authLinuxUser = authUser && authUser.linuxUser ? authUser.linuxUser : null;
var canAutoLogin = !usersModule.isMultiUser()
|| !!authLinuxUser
|| (authUser && authUser.role === "admin");
var authTitle = session.vendor === "codex" ? "Codex is not logged in." : "Claude Code is not logged in.";
var loginCommand = session.vendor === "codex"
? "codex login --device-auth"
: "claude login";
var _nmLogin = getNotificationsModule();
var authMsg = {
type: "auth_required",
text: authTitle,
vendor: session.vendor || "claude",
loginCommand: loginCommand,
linuxUser: authLinuxUser,
canAutoLogin: canAutoLogin,
};
sendAndRecord(session, authMsg);
if (_nmLogin) {
_nmLogin.notify("auth_required", {
title: authTitle,
body: "Open a terminal, then click the URL and follow the instructions.",
slug: slug,
sessionId: session.localId,
ownerId: session.ownerId || null,
vendor: session.vendor || "claude",
loginCommand: loginCommand,
linuxUser: authLinuxUser,
canAutoLogin: canAutoLogin,
});
}
// Reset CLI session so next query starts fresh with new auth
session.cliSessionId = null;
emitAuthRequired(session);
}
sendAndRecord(session, { type: "done", code: 0 });
var _donePreviewText = (session.responsePreview || "").replace(/\s+/g, " ").trim();
Expand Down Expand Up @@ -751,6 +762,14 @@ function attachMessageProcessor(ctx) {
message: parsed.message || "",
});

} else if (parsed.yokeType === "auth_required") {
// Vendor adapter signalled the session isn't authenticated (e.g. Codex
// app-server returned an unauthorized/token-revoked error). Trigger the
// same login flow as the Claude login-prompt path.
session.isProcessing = false;
onProcessingChanged();
emitAuthRequired(session);

} else if (parsed.yokeType === "model_refusal") {
// Model declined the request. "fallback" => the CLI retried on another
// model; "no_fallback" => the turn ended with a refusal.
Expand Down
74 changes: 57 additions & 17 deletions lib/yoke/adapters/codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,16 @@ function normalizePlanStatus(status) {
return "pending";
}

// Detect Codex "not logged in" errors. Codex surfaces auth failures several
// ways depending on transport: a clean error event with
// codexErrorInfo:"unauthorized", or a turn/failed / item error whose message
// carries a 401 / token-revoked / missing-bearer / "sign in again" string.
// Callers map a match to the neutral auth_required yokeType.
function isCodexAuthError(text, errObj) {
if (errObj && errObj.codexErrorInfo === "unauthorized") return true;
return /sign in again|token[_ ]?revoked|invalidated oauth|missing bearer|unauthorized|\b401\b/i.test(String(text || ""));
}

function extractPromptSuggestion(params) {
if (!params) return "";
if (typeof params.suggestion === "string") return params.suggestion;
Expand Down Expand Up @@ -226,9 +236,14 @@ function flattenEvent(notification, state) {
}

if (method === "turn/failed") {
var tfMsg = params.error ? params.error.message : "Turn failed";
if (isCodexAuthError(tfMsg, params.error)) {
events.push({ yokeType: "auth_required", vendor: "codex" });
return events;
}
events.push({
yokeType: "error",
text: params.error ? params.error.message : "Turn failed",
text: tfMsg,
});
return events;
}
Expand Down Expand Up @@ -525,9 +540,14 @@ function flattenEvent(notification, state) {

// Error item
if (item.type === "error") {
var ieMsg = item.message || "Unknown error";
if (isCodexAuthError(ieMsg, item)) {
events.push({ yokeType: "auth_required", vendor: "codex" });
return events;
}
events.push({
yokeType: "error",
text: item.message || "Unknown error",
text: ieMsg,
});
return events;
}
Expand All @@ -551,6 +571,20 @@ function flattenEvent(notification, state) {
return events;
}

// Top-level error event. Codex signals "not logged in" via an unauthorized /
// token-revoked error (not a login-prompt message like Claude), so map it to
// the neutral auth_required yokeType to drive the login flow.
if (method === "error" && params && params.error) {
var cErr = params.error;
var cErrMsg = cErr.message || "Codex error";
if (isCodexAuthError(cErrMsg, cErr)) {
events.push({ yokeType: "auth_required", vendor: "codex" });
return events;
}
events.push({ yokeType: "error", text: cErrMsg });
return events;
}

// Unknown event type - pass through
console.log("[yoke/codex] UNHANDLED event:", method, JSON.stringify(params).substring(0, 200));
events.push({
Expand Down Expand Up @@ -922,10 +956,10 @@ function createCodexQueryHandle(appServer, queryOpts) {
if (!isCancelled() && e.name !== "AbortError") {
console.error("[yoke/codex] runQueryLoop error:", e.message || e);
console.error("[yoke/codex] stack:", e.stack || "(no stack)");
pushEvent({
yokeType: "error",
text: e.message || String(e),
});
var loopErrMsg = e.message || String(e);
pushEvent(isCodexAuthError(loopErrMsg)
? { yokeType: "auth_required", vendor: "codex" }
: { yokeType: "error", text: loopErrMsg });
}
}

Expand Down Expand Up @@ -1047,7 +1081,19 @@ function createCodexAdapter(opts) {
var _cwd = (opts && opts.cwd) || process.cwd();
var _slug = (opts && opts.slug) || "";
var _defaultInitOpts = Object.assign({}, opts || {});
var _cachedModels = [];
// Codex models are a fixed list (the app-server doesn't enumerate them), so
// model listing must not depend on a successful app-server init — otherwise a
// slow/failed `initialize` leaves the picker empty and the chip shows the
// previous vendor's model.
var CODEX_MODELS = [
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.2",
];
var _cachedModels = CODEX_MODELS.slice();
var _appServer = null;
var _initPromise = null;
var _shutdownPromise = null;
Expand Down Expand Up @@ -1297,16 +1343,9 @@ function createCodexAdapter(opts) {
throw createShutdownError();
}

console.log("[codex] App-server initialized, models: gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex, gpt-5.3-codex-spark, gpt-5.2");
console.log("[codex] App-server initialized, models: " + CODEX_MODELS.join(", "));

_cachedModels = [
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.2",
];
_cachedModels = CODEX_MODELS.slice();

// Discover skills: built-in Codex skills + Claude skills
var skillNames = [];
Expand Down Expand Up @@ -1361,7 +1400,8 @@ function createCodexAdapter(opts) {
},

supportedModels: function() {
return Promise.resolve(_cachedModels.slice());
// Fixed list; return it without requiring a live app-server init.
return Promise.resolve(CODEX_MODELS.slice());
},

createToolServer: function(def) {
Expand Down
18 changes: 18 additions & 0 deletions lib/yoke/codex-app-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ CodexAppServer.prototype.start = function() {
while (lines.length > 1) {
var line = lines.shift();
if (line.trim()) console.log("[codex-app-server stderr]", line);
self._maybeSignalAuthError(line);
}
self._stderrBuf = lines[0] || "";
});
Expand Down Expand Up @@ -222,6 +223,23 @@ CodexAppServer.prototype._handleMessage = function(msg) {
}
};

// The definitive "not logged in" signal from Codex is a 401 on its own
// responses endpoint, which only appears on stderr (not as a JSON-RPC error).
// When we see it, synthesize an error event so the adapter maps it to the
// neutral auth_required yokeType. Deduped so a burst of 401 retries collapses.
CodexAppServer.prototype._maybeSignalAuthError = function(line) {
if (!this.eventHandler || !line || this._authSignalSent) return;
var isAuth = /missing bearer|token[_ ]?revoked|invalidated oauth|please sign in again/i.test(line)
|| (/\b401\b/.test(line) && /unauthorized|responses|openai\.com|chatgpt\.com/i.test(line));
if (!isAuth) return;
this._authSignalSent = true;
var self = this;
setTimeout(function () { self._authSignalSent = false; }, 15000);
try {
this.eventHandler({ method: "error", params: { error: { codexErrorInfo: "unauthorized", message: line } } });
} catch (e) {}
};

// Send a JSON-RPC request (expects a response)
CodexAppServer.prototype.send = function(method, params, timeoutMs) {
var self = this;
Expand Down
Loading