Files
NetMesh/electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1237 lines
50 KiB
JavaScript

/* eslint-disable no-undef */
const { getDriver, listBackends } = require("./index.cjs");
const { buildSdkAgentEnv } = require("./env.cjs");
const { buildInjectedMcpServers } = require("./injectMcp.cjs");
const { createStreamEmitter } = require("./emit.cjs");
const { buildNetcattySkillsOpenCodePathAllowlist } = require("./netcattySkillsOpenCodePermissions.cjs");
const { getToolCliStateDir } = require("../../../cli/discoveryPath.cjs");
const tempDirBridge = require("../../tempDirBridge.cjs");
const { realpathSync } = require("node:fs");
const { createHash } = require("node:crypto");
const { CodexAppServerRuntime } = require("../codexAppServer/runtime.cjs");
const { probeCodexAppServer } = require("../codexAppServer/probe.cjs");
const { codebuddySessionManager } = require("./codebuddySessionManager.cjs");
const codebuddyDriver = require("./codebuddyDriver.cjs");
const VALID_BACKENDS = new Set(listBackends());
// Pre-flight model catalog cache. SDK listModels often spawns a CLI/server
// (~1-2s+), so cache per backend+binPath and coalesce in-flight loads.
// Always degrade to [] on error/timeout (the renderer keeps its presets).
// OpenCode is included: catalogs can drift outside Netcatty, but a short TTL
// is far cheaper than spawning a new opencode process on every panel render
// (issue #2184).
const MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
const MODEL_CACHE_MAX_ENTRIES = 32;
const MODEL_LIST_TIMEOUT_MS = 10000;
const sdkModelCache = new Map();
const sdkModelInFlight = new Map();
const {
parseSdkSessionIdentity: parseSdkSessionIdentityPayload,
normalizeSdkRuntime,
SDK_SESSION_ID_PREFIX,
} = require("../../../shared/sdkSessionIdentity.cjs");
const { isPathLikeCommand } = require("../../../shared/pathLikeCommand.cjs");
function parseSdkSessionIdentity(value) {
const parsed = parseSdkSessionIdentityPayload(value);
if (!parsed) return null;
return {
sessionId: parsed.id,
backendKey: parsed.backend,
binPath: parsed.binPath || "",
runtime: normalizeSdkRuntime(parsed.runtime),
authMode: parsed.authMode === "cli-login" ? "cli-login" : parsed.authMode === "api-key" ? "api-key" : "",
cliMode: parsed.cliMode === "ask" ? "ask" : parsed.cliMode === "agent" ? "agent" : "",
};
}
/** Resolve Grok dual runtime from explicit ctx / agent env. */
function resolveGrokRuntimeToken(env, explicit) {
return normalizeSdkRuntime(
explicit || env?.NETCATTY_GROK_RUNTIME || process.env.NETCATTY_GROK_RUNTIME || "acp",
);
}
function buildSdkSessionKey(chatSessionId, backendKey, binPath, runtime = "sdk", authMode = "", cliMode = "") {
return [
String(chatSessionId || ""),
String(backendKey || ""),
String(binPath || ""),
String(runtime || "sdk"),
String(authMode || ""),
String(cliMode || ""),
].join("\u0000");
}
function normalizeResumeAuthMode(authMode) {
return authMode === "cli-login" ? "cli-login" : authMode === "api-key" ? "api-key" : "";
}
function normalizeResumeCliMode(cliMode) {
return cliMode === "ask" ? "ask" : cliMode === "agent" ? "agent" : "";
}
// Environment that can change an SDK agent's model catalog without changing the
// binary path (especially OpenCode HOME / XDG / config overrides).
const SDK_MODEL_CACHE_ENV_KEYS = [
"HOME",
"USERPROFILE",
"XDG_CONFIG_HOME",
"OPENCODE_BIN",
"OPENCODE_CONFIG",
"OPENCODE_CONFIG_DIR",
"OPENCODE_CONFIG_CONTENT",
"CLAUDE_CODE_EXECUTABLE",
"CODEBUDDY_CODE_PATH",
"CURSOR_API_KEY",
];
function buildSdkModelCacheKey(backendKey, binPath, env, runtime = "sdk") {
const envFingerprint = SDK_MODEL_CACHE_ENV_KEYS
.map((key) => `${key}=${env?.[key] == null ? "" : String(env[key])}`)
.join("\u0000");
const envHash = createHash("sha256").update(envFingerprint).digest("hex");
return [String(backendKey || ""), String(binPath || ""), String(runtime || "sdk"), envHash].join("\u0000");
}
function pruneSdkModelCache(cache, {
now = Date.now(),
ttlMs = MODEL_CACHE_TTL_MS,
maxEntries = MODEL_CACHE_MAX_ENTRIES,
} = {}) {
for (const [key, entry] of cache) {
if (!entry || now - Number(entry.at || 0) >= ttlMs) cache.delete(key);
}
while (cache.size > maxEntries) {
const oldestKey = cache.keys().next().value;
if (oldestKey === undefined) break;
cache.delete(oldestKey);
}
}
function getSdkModelCacheEntry(cache, key, options) {
pruneSdkModelCache(cache, options);
const entry = cache.get(key);
if (!entry) return null;
// Map insertion order doubles as the LRU list.
cache.delete(key);
cache.set(key, entry);
return entry;
}
function setSdkModelCacheEntry(cache, key, entry, options) {
pruneSdkModelCache(cache, options);
cache.delete(key);
cache.set(key, entry);
pruneSdkModelCache(cache, options);
}
function shouldCacheSdkRuntimeModels(_backendKey) {
return true;
}
function normalizeSdkListModelsResult(raw) {
const rawModels = Array.isArray(raw) ? raw : raw?.models;
const currentModelId = Array.isArray(raw) ? null : raw?.currentModelId || null;
const models = Array.isArray(rawModels) ? rawModels.filter((m) => m && m.id) : [];
return { currentModelId, models };
}
function resolveSdkPromptPlacement({
backendKey,
turnPrompt,
contextualPrompt,
systemContext,
}) {
const supportsSystemContext = backendKey === "opencode" || backendKey === "codebuddy";
return {
prompt: supportsSystemContext ? turnPrompt : contextualPrompt,
systemPrompt: supportsSystemContext ? systemContext : undefined,
};
}
function deleteSdkSessionKeysForChat(sdkSessionIds, chatSessionId) {
const prefix = `${String(chatSessionId || "")}\u0000`;
for (const key of sdkSessionIds.keys()) {
if (key.startsWith(prefix)) {
sdkSessionIds.delete(key);
}
}
}
/**
* Cursor CLI --resume is sticky for ask vs agent. When permission mode flips
* (Observer ↔ Confirm/Auto), drop the inactive mode's in-memory session so a
* later switch-back cannot revive a stale thread without the intervening turns.
* Returns true when a sibling key was removed.
*/
function expireSiblingCursorCliModeSessions(sdkSessionIds, {
chatSessionId,
backendKey,
binPath,
runtime = "sdk",
authMode = "",
cliMode = "",
} = {}) {
const activeCliMode = normalizeResumeCliMode(cliMode);
if (!activeCliMode || !sdkSessionIds) return false;
const siblingCliMode = activeCliMode === "ask" ? "agent" : "ask";
const siblingKey = buildSdkSessionKey(
chatSessionId,
backendKey,
binPath,
runtime,
authMode,
siblingCliMode,
);
if (!sdkSessionIds.has(siblingKey)) return false;
sdkSessionIds.delete(siblingKey);
return true;
}
/**
* Grok ACP vs streaming-json sessions must not resume across each other.
* When the active runtime flips without restarting Netcatty, drop the inactive
* runtime's in-memory session so a switch-back cannot revive a pre-switch
* thread and skip renderer history for intervening turns (same idea as Cursor
* CLI ask/agent isolation).
*/
function expireSiblingGrokRuntimeSessions(sdkSessionIds, {
chatSessionId,
backendKey,
binPath,
runtime = "acp",
} = {}) {
if (!sdkSessionIds || backendKey !== "grok") return false;
const activeRuntime = normalizeSdkRuntime(runtime);
if (activeRuntime !== "acp" && activeRuntime !== "streaming-json") return false;
const siblingRuntime = activeRuntime === "acp" ? "streaming-json" : "acp";
const siblingKey = buildSdkSessionKey(
chatSessionId,
backendKey,
binPath,
siblingRuntime,
"",
"",
);
if (!sdkSessionIds.has(siblingKey)) return false;
sdkSessionIds.delete(siblingKey);
return true;
}
function resolveSdkResumeSessionId({
sdkSessionIds,
sdkSessionKey,
existingSessionId,
backendKey,
binPath,
runtime = "sdk",
authMode = "",
cliMode = "",
hasConfiguredCommand,
}) {
const inMemorySessionId = sdkSessionIds.get(sdkSessionKey);
if (inMemorySessionId) return inMemorySessionId;
const requestedAuthMode = normalizeResumeAuthMode(authMode);
const requestedCliMode = normalizeResumeCliMode(cliMode);
const persisted = parseSdkSessionIdentity(existingSessionId);
if (persisted) {
// Legacy identities omit authMode; treat them as api-key so CLI session
// UUIDs never resume onto the Cursor SDK path after a mode switch.
const persistedAuthMode = normalizeResumeAuthMode(persisted.authMode) || "api-key";
const effectiveRequestedAuthMode = requestedAuthMode || "api-key";
// Cursor CLI ask vs agent sessions must not resume across each other —
// --resume keeps the original Cursor execution mode sticky.
if (requestedCliMode) {
const persistedCliMode = normalizeResumeCliMode(persisted.cliMode);
if (!persistedCliMode || persistedCliMode !== requestedCliMode) return undefined;
}
return persisted.backendKey === backendKey
&& persisted.binPath === String(binPath || "")
&& persisted.runtime === runtime
&& persistedAuthMode === effectiveRequestedAuthMode
? persisted.sessionId
: undefined;
}
// Bare legacy IDs are only safe for the SDK/api-key path. CLI login always
// persists an encoded identity after the first turn.
if (requestedAuthMode === "cli-login") return undefined;
return runtime === "sdk" && existingSessionId && !hasConfiguredCommand
? existingSessionId
: undefined;
}
function withTimeout(promise, ms, abortController) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
const error = new Error(`list-models timed out after ${ms}ms`);
try { abortController?.abort?.(error); } catch {}
reject(error);
}, ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
/** Map the renderer-supplied backend value to a registry key. */
function resolveBackendKey(value) {
const key = String(value || "").trim();
return VALID_BACKENDS.has(key) ? key : null;
}
function normalizeHistoryMessages(historyMessages) {
if (!Array.isArray(historyMessages)) return [];
return historyMessages
.filter((msg) => msg && (msg.role === "user" || msg.role === "assistant"))
.map((msg) => ({
role: msg.role,
content: String(msg.content || "").trim(),
}))
.filter((msg) => msg.content.length > 0);
}
function logCursorApiKeySummary({ requestedAgentEnv, shellEnv, env }) {
const requestedKey = requestedAgentEnv?.CURSOR_API_KEY;
const shellKey = shellEnv?.CURSOR_API_KEY;
const effectiveKey = env?.CURSOR_API_KEY;
const source = requestedKey
? "settings"
: shellKey
? "environment"
: effectiveKey
? "merged-env"
: "missing";
console.info("[Cursor SDK] API key summary", {
source,
hasEffectiveKey: Boolean(effectiveKey),
});
}
function resolveRealCliPath(cliPath, realpath = realpathSync) {
if (!cliPath) return cliPath;
try { return realpath(cliPath); } catch { return cliPath; }
}
function normalizeConfiguredCommandPath(command, normalizeCliPathForPlatform) {
const raw = String(command || "").trim();
const pathLike = raw.includes("/") || raw.includes("\\") || /^[a-z]:/i.test(raw);
if (!raw || !pathLike) {
return null;
}
const normalized = typeof normalizeCliPathForPlatform === "function"
? normalizeCliPathForPlatform(raw)
: raw;
if (!normalized) {
throw new Error(`Agent CLI path not found: ${raw}`);
}
return normalized;
}
function resolveConfiguredSdkPath({
backendKey, configuredPath, realpath,
resolveClaudeCodeExecutableForSdk,
resolveCodexExecutableForSdk,
resolveCodebuddyExecutableForSdk,
}) {
const realPath = resolveRealCliPath(configuredPath, realpath);
if (backendKey === "claude" && typeof resolveClaudeCodeExecutableForSdk === "function") {
return resolveClaudeCodeExecutableForSdk(realPath) || undefined;
}
if (backendKey === "codex" && typeof resolveCodexExecutableForSdk === "function") {
return resolveCodexExecutableForSdk(realPath) || undefined;
}
if (backendKey === "codebuddy" && typeof resolveCodebuddyExecutableForSdk === "function") {
return resolveCodebuddyExecutableForSdk(realPath) || undefined;
}
return realPath;
}
function resolveSdkBackendBinPath({
backendKey, configuredCommand, shellEnv, env, resolveCliFromPath, normalizeCliPathForPlatform,
resolveSdkBinPath, resolveClaudeCodeExecutableForSdk, resolveCodexExecutableForSdk,
resolveCodebuddyExecutableForSdk, realpath = realpathSync,
}) {
const configuredPath = normalizeConfiguredCommandPath(configuredCommand, normalizeCliPathForPlatform);
if (configuredPath) {
return resolveConfiguredSdkPath({
backendKey,
configuredPath,
realpath,
resolveClaudeCodeExecutableForSdk,
resolveCodexExecutableForSdk,
resolveCodebuddyExecutableForSdk,
});
}
if (backendKey === "codebuddy") {
const configuredEnvPath = normalizeCliPathForPlatform?.(env?.CODEBUDDY_CODE_PATH);
const rawPath = configuredEnvPath || resolveCliFromPath(backendKey, shellEnv) || undefined;
if (!rawPath) return undefined;
const realPath = resolveRealCliPath(rawPath, realpath);
// On Windows the discovered path is an npm shim (codebuddy.cmd/.ps1) that the
// Agent SDK can't run through `node`; resolve it to the package's JS entry so
// it launches like on macOS/Linux. A null result means the shim is unrunnable
// and unresolvable, so fall back to the SDK's bundled CLI.
const sdkPath = typeof resolveCodebuddyExecutableForSdk === "function"
? resolveCodebuddyExecutableForSdk(realPath)
: realPath;
return sdkPath || undefined;
}
if (backendKey === "opencode") {
const configuredEnvPath = normalizeCliPathForPlatform?.(env?.OPENCODE_BIN);
const rawPath = configuredEnvPath || resolveCliFromPath(backendKey, shellEnv) || undefined;
return rawPath ? resolveRealCliPath(rawPath, realpath) : undefined;
}
return resolveSdkBinPath?.(backendKey, shellEnv) || undefined;
}
function defaultWriteAttachmentToTemp(attachment) {
if (attachment?.filePath) return attachment.filePath;
if (!attachment?.base64Data) return null;
const fs = require("node:fs");
const tempDirBridge = require("../../tempDirBridge.cjs");
const fallbackName = `ai-attachment-${Date.now()}`;
const target = tempDirBridge.getTempFilePath(attachment.filename || fallbackName);
fs.writeFileSync(target, Buffer.from(attachment.base64Data, "base64"));
return target;
}
/**
* Format renderer history into the shared SDK conversation-context section.
* Used for normal replay and as a Grok resume-fallback seed (same wording).
*/
function formatSdkHistoryReplaySection(historyMessages) {
const history = normalizeHistoryMessages(historyMessages);
if (history.length === 0) return "";
return [
"[Conversation context replay: the agent SDK may be starting from a fresh local session, so use these prior turns as context and answer only the latest user request.]",
...history.map((msg) => `${msg.role === "assistant" ? "ASSISTANT" : "USER"}: ${msg.content}`),
].join("\n");
}
function buildSdkTurnPrompt({
prompt,
historyMessages,
replayHistory,
attachments,
toolIntegrationMode,
writeAttachmentToTemp = defaultWriteAttachmentToTemp,
onStagedAttachment,
}) {
const sections = [];
if (replayHistory) {
const historySection = formatSdkHistoryReplaySection(historyMessages);
if (historySection) sections.push(historySection);
}
if (Array.isArray(attachments) && attachments.length > 0) {
const hints = [];
for (const attachment of attachments) {
if (!attachment || !attachment.base64Data || !attachment.mediaType) continue;
try {
const localPath = writeAttachmentToTemp(attachment);
if (localPath) {
const name = attachment.filename || "attachment";
hints.push(`- "${name}" (${attachment.mediaType}) is saved on the local machine at: ${localPath}`);
onStagedAttachment?.({
filename: name,
mediaType: attachment.mediaType,
filePath: localPath,
base64Data: attachment.base64Data || "",
});
}
} catch (err) {
console.error("[SDK Agent] Failed to stage attachment:", err?.message || err);
}
}
if (hints.length > 0) {
const attachmentAccessHint = toolIntegrationMode === "skills"
? "[If direct local filesystem tools are unavailable, use Netcatty's attachment list/read CLI commands described in the host context.]"
: "[If local filesystem tools are unavailable, use Netcatty's list_attachments and read_attachment MCP tools to inspect these user-supplied files.]";
sections.push(
[
"[Attached files: these paths are local to the machine running Netcatty, not remote hosts. Inspect them locally if needed.]",
attachmentAccessHint,
...hints,
].join("\n"),
);
}
}
const trimmedPrompt = String(prompt || "");
return sections.length > 0
? `${sections.join("\n\n")}\n\n${trimmedPrompt}`
: trimmedPrompt;
}
function shouldReplaySdkHistory({
backendKey,
codexRuntime,
resumeSessionId,
hasInMemorySession,
}) {
// CodeBuddy, Codex App Server, and Grok ACP resumes restore their own
// conversation history. Replaying renderer history as well duplicates every
// prior turn (Grok also re-hydrates via session/load on a fresh agent stdio
// process each turn).
if (
backendKey === "codebuddy"
|| backendKey === "grok"
|| codexRuntime === "app-server"
) {
return !resumeSessionId;
}
return !hasInMemorySession;
}
function registerSdkStreamHandlers(ctx) {
with (ctx) {
// chatSessionId -> { sessionId } for resume; controller per requestId.
const sdkActiveStreams = new Map(); // requestId -> AbortController
const sdkRequestSessions = new Map(); // requestId -> chatSessionId
const sdkRequestRuntimes = new Map(); // requestId -> { backendKey, codexRuntime }
const sdkSessionIds = new Map(); // chatSessionId -> last sessionId
const codexAppServerRuntime = new CodexAppServerRuntime({
appVersion: electronModule?.app?.getVersion?.() || "0.0.0",
sendInteractionRequest(payload, context) {
const sender = context?.sender;
if (!sender || sender.isDestroyed?.()) return false;
safeSend(sender, "netcatty:ai:codex-app-server:interaction-request", payload);
return true;
},
sendInteractionCleared(payload, context) {
const sender = context?.sender;
if (!sender || sender.isDestroyed?.()) return;
safeSend(sender, "netcatty:ai:codex-app-server:interaction-cleared", payload);
},
});
ipcMain.handle(
"netcatty:ai:sdk-agent:stream",
async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const {
requestId, chatSessionId, sdkBackend, prompt, cwd,
model, existingSessionId, toolIntegrationMode,
defaultTargetSession, userSkillsContext, agentEnv: requestedAgentEnv, agentCommand,
codexRuntime: requestedCodexRuntime, permissionMode,
// SDK 0.3.230 advanced options (passed from renderer via sdkAgentAdapter)
effort, maxTurns, maxBudgetUsd, fallbackModel,
sandbox, agents, outputFormat, enableFileCheckpointing,
traceId, parentSpanId,
} = payload;
const backendKey = resolveBackendKey(sdkBackend);
if (!backendKey) {
safeSend(event.sender, "netcatty:ai:sdk-agent:error", {
requestId, error: `Unknown SDK backend: ${sdkBackend}`,
});
return { ok: false, error: "Unknown SDK backend" };
}
const abortController = new AbortController();
sdkActiveStreams.set(requestId, abortController);
sdkRequestSessions.set(requestId, chatSessionId);
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
const emitter = createStreamEmitter({ safeSend, sender: event.sender, requestId });
try {
const shellEnv = await getShellEnv();
const effectiveMode = normalizeToolIntegrationMode(toolIntegrationMode);
setToolIntegrationMode(effectiveMode);
// Push terminal session metadata + build injected MCP (mcp mode only).
const injectedMcpServers = await buildInjectedMcpServers({
mcpServerBridge,
chatSessionId,
toolIntegrationMode: effectiveMode,
});
// NETCATTY_CLAUDE_SETTINGS is a netcatty marker carrying the claude SDK
// `settings` option (a settings.json path / inline JSON), NOT a real env
// var — pull it out so it isn't handed to the agent process as env.
const normalizedAgentEnv = normalizeAgentEnv(requestedAgentEnv);
const claudeSettings = normalizedAgentEnv.NETCATTY_CLAUDE_SETTINGS;
delete normalizedAgentEnv.NETCATTY_CLAUDE_SETTINGS;
const cursorAuthMode = normalizedAgentEnv.NETCATTY_CURSOR_AUTH_MODE === "cli-login"
? "cli-login"
: "api-key";
delete normalizedAgentEnv.NETCATTY_CURSOR_AUTH_MODE;
let cursorCliBinPath = String(normalizedAgentEnv.NETCATTY_CURSOR_CLI_BIN || "").trim() || null;
delete normalizedAgentEnv.NETCATTY_CURSOR_CLI_BIN;
if (cursorAuthMode === "cli-login") {
delete normalizedAgentEnv.CURSOR_API_KEY;
}
let env = buildSdkAgentEnv({
shellEnv,
requestedAgentEnv: normalizedAgentEnv,
withCliDiscoveryEnv,
normalizeClaudeCodeExecutableEnv: normalizeClaudeCodeExecutableEnvForSdk,
});
if (cursorAuthMode === "cli-login") {
delete env.CURSOR_API_KEY;
}
if (backendKey === "cursor") {
logCursorApiKeySummary({ requestedAgentEnv: normalizedAgentEnv, shellEnv, env });
}
const binPath = resolveSdkBackendBinPath({
backendKey,
configuredCommand: agentCommand,
shellEnv,
env,
resolveCliFromPath,
normalizeCliPathForPlatform,
resolveSdkBinPath,
resolveClaudeCodeExecutableForSdk,
resolveCodexExecutableForSdk,
resolveCodebuddyExecutableForSdk,
});
if (backendKey === "codex") {
env = addCodexExecutableEnvForSdk(env, binPath);
}
if (backendKey === "cursor" && cursorAuthMode === "cli-login") {
const looksLikeSentinel = !cursorCliBinPath
|| cursorCliBinPath === "cursor"
|| /(?:^|[/\\])cursor$/i.test(cursorCliBinPath);
if (looksLikeSentinel && typeof probeCursorCliAuth === "function") {
try {
const cliAuth = probeCursorCliAuth({ env: shellEnv });
if (cliAuth?.binPath) cursorCliBinPath = cliAuth.binPath;
} catch { /* best effort */ }
}
if (!cursorCliBinPath) {
// Only `cursor-agent` — bare `agent` collides with other CLIs (e.g. Grok).
cursorCliBinPath = await resolveCliFromPathAsync?.("cursor-agent", shellEnv)
|| null;
}
}
const codexRuntime = backendKey === "codex" && requestedCodexRuntime === "app-server"
? "app-server"
: "sdk";
// Grok ACP vs streaming-json must not share resume identity (like Codex dual runtime).
const grokRuntime = backendKey === "grok"
? resolveGrokRuntimeToken(env, env?.NETCATTY_GROK_RUNTIME)
: "sdk";
const sessionRuntime = backendKey === "grok" ? grokRuntime : codexRuntime;
sdkRequestRuntimes.set(requestId, {
backendKey,
codexRuntime,
grokRuntime,
sessionRuntime,
binPath,
toolIntegrationMode: effectiveMode,
});
const hasConfiguredCommand = isPathLikeCommand(agentCommand);
const sessionBinPath = backendKey === "cursor" && cursorAuthMode === "cli-login"
? (cursorCliBinPath || binPath)
: binPath;
const cursorSessionAuthMode = backendKey === "cursor" ? cursorAuthMode : "";
// Cursor CLI --resume keeps ask vs agent sticky; isolate session keys
// so switching Observer ↔ Confirm/Auto starts a fresh CLI thread.
const cursorCliMode = backendKey === "cursor" && cursorAuthMode === "cli-login"
? (String(permissionMode || "confirm").toLowerCase() === "observer" ? "ask" : "agent")
: "";
// Expire the inactive mode first so Obs → Conf → Obs cannot resume the
// original Ask thread (which would also skip history of Confirm turns).
if (cursorCliMode) {
expireSiblingCursorCliModeSessions(sdkSessionIds, {
chatSessionId,
backendKey,
binPath: sessionBinPath,
runtime: sessionRuntime,
authMode: cursorSessionAuthMode,
cliMode: cursorCliMode,
});
}
// ACP ↔ streaming-json: drop the other Grok runtime's in-memory id so
// switch-back does not resume a pre-switch session without intervening turns.
if (backendKey === "grok") {
expireSiblingGrokRuntimeSessions(sdkSessionIds, {
chatSessionId,
backendKey,
binPath: sessionBinPath,
runtime: sessionRuntime,
});
}
const sdkSessionKey = buildSdkSessionKey(
chatSessionId,
backendKey,
sessionBinPath,
sessionRuntime,
cursorSessionAuthMode,
cursorCliMode,
);
const hasInMemorySession = sdkSessionIds.has(sdkSessionKey);
const resumeSessionId = resolveSdkResumeSessionId({
sdkSessionIds,
sdkSessionKey,
existingSessionId,
backendKey,
binPath: sessionBinPath,
runtime: sessionRuntime,
authMode: cursorSessionAuthMode,
cliMode: cursorCliMode,
hasConfiguredCommand,
});
const stagedAttachments = [];
const replayHistory = shouldReplaySdkHistory({
backendKey,
codexRuntime,
resumeSessionId,
hasInMemorySession,
});
const turnPrompt = buildSdkTurnPrompt({
prompt,
historyMessages: payload?.historyMessages,
replayHistory,
attachments: payload?.images,
toolIntegrationMode: effectiveMode,
onStagedAttachment: (attachment) => stagedAttachments.push(attachment),
});
// Grok may fall back to session/new when resume/load fails; keep a
// history seed so that path is not history-less (Codex review #2666).
const historySeed = backendKey === "grok" && resumeSessionId && !replayHistory
? formatSdkHistoryReplaySection(payload?.historyMessages)
: "";
mcpServerBridge.updateAttachmentMetadata?.(stagedAttachments, chatSessionId);
const systemContext = buildExternalAgentSystemContext({
mode: effectiveMode,
chatSessionId,
defaultTargetSession,
userSkillsContext,
});
const contextualPrompt = buildExternalAgentContextualPrompt({
mode: effectiveMode,
prompt: turnPrompt,
chatSessionId,
defaultTargetSession,
userSkillsContext,
});
const driver = getDriver(backendKey);
const driverEmitter = {
...emitter,
sessionId(sessionId) {
if (sessionId) {
emitter.emitEvent({
type: "session-id",
sessionId,
sdkBackend: backendKey,
binPath: sessionBinPath || "",
runtime: sessionRuntime,
...(cursorSessionAuthMode ? { authMode: cursorSessionAuthMode } : {}),
...(cursorCliMode ? { cliMode: cursorCliMode } : {}),
});
}
},
};
const skillsPathAllowlist = effectiveMode === "skills" && backendKey === "opencode"
? buildNetcattySkillsOpenCodePathAllowlist({
launcherPath: NETCATTY_TOOL_LAUNCHER_PATH,
cliScriptPath: NETCATTY_TOOL_CLI_PATH,
skillPath: NETCATTY_TOOL_SKILL_PATH,
discoveryFilePath: cliDiscoveryFilePath || undefined,
cliStateDir: cliDiscoveryFilePath
? undefined
: getToolCliStateDir({ userDataDir: electronModule?.getPath?.("userData") }),
runtimeBinaryPath: process.execPath,
tempDir: tempDirBridge.getTempDir(),
extraFilePaths: stagedAttachments
.map((attachment) => attachment?.filePath)
.filter(Boolean),
})
: undefined;
const promptPlacement = resolveSdkPromptPlacement({
backendKey,
turnPrompt,
contextualPrompt,
systemContext,
});
const commonTurnContext = {
requestId,
chatSessionId,
...promptPlacement,
cwd: cwd || process.cwd(),
model: model || undefined,
permissionMode: permissionMode || "confirm",
env,
binPath,
cursorAuthMode: backendKey === "cursor" ? cursorAuthMode : undefined,
cursorCliBinPath: backendKey === "cursor" ? cursorCliBinPath : undefined,
grokRuntime: backendKey === "grok" ? grokRuntime : undefined,
historySeed: backendKey === "grok" ? historySeed : undefined,
getTempDir: () => tempDirBridge.getTempDir(),
injectedMcpServers,
claudeSettings,
toolIntegrationMode: effectiveMode,
skillsCliCommandPrefix: effectiveMode === "skills"
? getSkillsCliInvocation().commandPrefix
: undefined,
skillsPathAllowlist,
emitter: driverEmitter,
signal: abortController.signal,
abortController,
resumeSessionId,
resumeThreadId: resumeSessionId,
attachments: stagedAttachments,
sender: event.sender,
// Approval channel for codebuddy canUseTool permission handler:
// when the CLI hits a security restriction, route the decision
// through the renderer approval UI instead of throwing an error.
requestApprovalFromRenderer: mcpServerBridge.requestApprovalFromRenderer,
// SDK 0.3.230 advanced options
effort: effort || undefined,
maxTurns: maxTurns || undefined,
maxBudgetUsd: maxBudgetUsd || undefined,
fallbackModel: fallbackModel || undefined,
sandbox: sandbox || undefined,
agents: agents || undefined,
outputFormat: outputFormat || undefined,
enableFileCheckpointing: enableFileCheckpointing != null ? enableFileCheckpointing : undefined,
traceId: traceId || undefined,
parentSpanId: parentSpanId || undefined,
};
const result = codexRuntime === "app-server"
? await codexAppServerRuntime.runTurn(commonTurnContext)
: await driver.runTurn(commonTurnContext);
// Persist any new session id for resume on the next turn.
const newSessionId = result?.sessionId || result?.threadId;
if (newSessionId) sdkSessionIds.set(sdkSessionKey, newSessionId);
return { ok: true };
} catch (err) {
emitter.emitError(err?.message || String(err));
return { ok: false, error: err?.message || String(err) };
} finally {
sdkActiveStreams.delete(requestId);
sdkRequestSessions.delete(requestId);
sdkRequestRuntimes.delete(requestId);
}
},
);
ipcMain.handle("netcatty:ai:sdk-agent:list-models", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const { sdkBackend, agentEnv: requestedAgentEnv, agentCommand, codexRuntime: requestedCodexRuntime } = payload || {};
const backendKey = resolveBackendKey(sdkBackend);
if (!backendKey) return { ok: false, error: `Unknown SDK backend: ${sdkBackend}` };
try {
const driver = getDriver(backendKey);
if (typeof driver.listModels !== "function") {
return { ok: true, currentModelId: null, models: [] };
}
const shellEnv = await getShellEnv();
const normalizedAgentEnv = normalizeAgentEnv(requestedAgentEnv);
const cursorAuthMode = normalizedAgentEnv.NETCATTY_CURSOR_AUTH_MODE === "cli-login"
? "cli-login"
: "api-key";
delete normalizedAgentEnv.NETCATTY_CURSOR_AUTH_MODE;
let cursorCliBinPath = String(normalizedAgentEnv.NETCATTY_CURSOR_CLI_BIN || "").trim() || null;
delete normalizedAgentEnv.NETCATTY_CURSOR_CLI_BIN;
if (cursorAuthMode === "cli-login") {
delete normalizedAgentEnv.CURSOR_API_KEY;
}
const env = buildSdkAgentEnv({
shellEnv,
requestedAgentEnv: normalizedAgentEnv,
withCliDiscoveryEnv,
normalizeClaudeCodeExecutableEnv: normalizeClaudeCodeExecutableEnvForSdk,
});
if (cursorAuthMode === "cli-login") {
delete env.CURSOR_API_KEY;
}
const binPath = resolveSdkBackendBinPath({
backendKey,
configuredCommand: agentCommand,
shellEnv,
env,
resolveCliFromPath,
normalizeCliPathForPlatform,
resolveSdkBinPath,
resolveClaudeCodeExecutableForSdk,
resolveCodexExecutableForSdk,
resolveCodebuddyExecutableForSdk,
});
if (backendKey === "cursor" && cursorAuthMode === "cli-login") {
const looksLikeSentinel = !cursorCliBinPath
|| cursorCliBinPath === "cursor"
|| /(?:^|[/\\])cursor$/i.test(cursorCliBinPath);
if (looksLikeSentinel && typeof probeCursorCliAuth === "function") {
try {
const cliAuth = probeCursorCliAuth({ env: shellEnv });
if (cliAuth?.binPath) cursorCliBinPath = cliAuth.binPath;
} catch { /* best effort */ }
}
if (!cursorCliBinPath) {
// Only `cursor-agent` — bare `agent` collides with other CLIs (e.g. Grok).
cursorCliBinPath = await resolveCliFromPathAsync?.("cursor-agent", shellEnv)
|| null;
}
}
const codexRuntime = backendKey === "codex" && requestedCodexRuntime === "app-server"
? "app-server"
: "sdk";
// claude/copilot/opencode enumerate models via the SDK; codex has no
// catalog (its driver returns []), so the renderer falls back to curated
// presets. Cache + in-flight coalescing avoid spawn storms (#2184).
const cacheKey = buildSdkModelCacheKey(
backendKey,
cursorAuthMode === "cli-login" ? (cursorCliBinPath || binPath) : binPath,
env,
`${codexRuntime}:${cursorAuthMode}`,
);
const shouldCacheModels = shouldCacheSdkRuntimeModels(backendKey);
const cached = shouldCacheModels ? getSdkModelCacheEntry(sdkModelCache, cacheKey) : null;
if (cached) {
return { ok: true, currentModelId: cached.currentModelId || null, models: cached.models };
}
const existing = sdkModelInFlight.get(cacheKey);
if (existing) return await existing;
const loadPromise = (async () => {
const abortController = new AbortController();
try {
const raw = await withTimeout(
codexRuntime === "app-server"
? codexAppServerRuntime.listModels({ binPath, env })
: driver.listModels({
binPath,
env,
abortController,
cursorAuthMode: backendKey === "cursor" ? cursorAuthMode : undefined,
cursorCliBinPath: backendKey === "cursor" ? cursorCliBinPath : undefined,
}),
MODEL_LIST_TIMEOUT_MS,
abortController,
);
const { currentModelId, models } = normalizeSdkListModelsResult(raw);
// Do not cache degraded empty catalogs: listOpenCodeModels and
// other drivers often return [] on timeout/startup failure, and
// pinning that for TTL would block recovery (matches renderer
// sdkRuntimeModelCache behavior).
if (shouldCacheModels && (models.length > 0 || currentModelId)) {
setSdkModelCacheEntry(sdkModelCache, cacheKey, { at: Date.now(), currentModelId, models });
}
return { ok: true, currentModelId, models };
} catch (err) {
// Degrade to [] so the renderer keeps its curated presets (never empty).
console.debug(`[sdk] list-models(${backendKey}) unavailable, using curated presets`);
return {
ok: true,
currentModelId: null,
models: [],
warning: codexRuntime === "app-server" ? (err?.message || String(err)) : undefined,
};
}
})();
sdkModelInFlight.set(cacheKey, loadPromise);
try {
return await loadPromise;
} finally {
if (sdkModelInFlight.get(cacheKey) === loadPromise) {
sdkModelInFlight.delete(cacheKey);
}
}
} catch (err) {
// Degrade to [] so the renderer keeps its curated presets (never empty).
console.debug(`[sdk] list-models(${backendKey}) unavailable, using curated presets`);
return {
ok: true,
currentModelId: null,
models: [],
warning: backendKey === "codex" && requestedCodexRuntime === "app-server"
? (err?.message || String(err))
: undefined,
};
}
});
ipcMain.handle("netcatty:ai:sdk-agent:steer", async (event, payload) => {
if (!validateSender(event)) return { status: "failed", message: "Unauthorized IPC sender" };
const requestId = String(payload?.requestId || "");
const chatSessionId = String(payload?.chatSessionId || "");
const prompt = String(payload?.prompt || "");
const clientUserMessageId = String(payload?.clientUserMessageId || "");
if (!requestId || !chatSessionId || !clientUserMessageId) {
return { status: "failed", message: "Invalid Codex steer request" };
}
if (sdkRequestSessions.get(requestId) !== chatSessionId) {
return { status: "inactive" };
}
const runtime = sdkRequestRuntimes.get(requestId);
if (!runtime) return { status: "busy" };
// SDK 0.3.230 Session.send() resets the active message stream, so it
// cannot safely implement mid-turn steering.
if (runtime.backendKey === "codebuddy") {
return { status: "unsupported" };
}
if (runtime?.backendKey !== "codex" || runtime.codexRuntime !== "app-server") {
return { status: "unsupported" };
}
const stagedAttachments = [];
const steerPrompt = buildSdkTurnPrompt({
prompt,
replayHistory: false,
attachments: payload?.images,
toolIntegrationMode: runtime.toolIntegrationMode,
onStagedAttachment: (attachment) => stagedAttachments.push(attachment),
});
mcpServerBridge.updateAttachmentMetadata?.(stagedAttachments, chatSessionId);
const result = await codexAppServerRuntime.steerTurn(requestId, {
chatSessionId,
prompt: steerPrompt,
attachments: stagedAttachments,
clientUserMessageId,
});
return result.status === "inactive" && sdkActiveStreams.has(requestId)
? { status: "busy" }
: result;
});
ipcMain.handle("netcatty:ai:sdk-agent:cancel", async (event, { requestId, chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const effectiveChatSessionId = chatSessionId || sdkRequestSessions.get(requestId);
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
mcpServerBridge.cancelWorkerBackgroundJobsForSession?.(effectiveChatSessionId);
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
void mcpServerBridge.cancelSftpOpsForSession?.(effectiveChatSessionId);
await codexAppServerRuntime.cancelTurn(requestId);
const controller = sdkActiveStreams.get(requestId);
if (controller) {
controller.abort();
return { ok: true };
}
return { ok: false, error: "Stream not found" };
});
ipcMain.handle("netcatty:ai:sdk-agent:cleanup", async (event, { chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
mcpServerBridge.cancelWorkerBackgroundJobsForSession?.(chatSessionId);
// Abort any in-flight SDK turns for this chat and drop their
// request-scoped entries immediately: if a turn never settles (renderer
// crash / window closed mid-stream), the stream handler's finally never
// runs and sdkActiveStreams/sdkRequestSessions/sdkRequestRuntimes would
// leak forever. Steer then reports "inactive" for these requests, which
// is correct for a chat being torn down.
for (const [requestId, requestChatSessionId] of [...sdkRequestSessions]) {
if (requestChatSessionId !== chatSessionId) continue;
try { sdkActiveStreams.get(requestId)?.abort(); } catch { /* best effort */ }
sdkActiveStreams.delete(requestId);
sdkRequestSessions.delete(requestId);
sdkRequestRuntimes.delete(requestId);
}
deleteSdkSessionKeysForChat(sdkSessionIds, chatSessionId);
codebuddySessionManager.closeForChat(chatSessionId);
await codexAppServerRuntime.cleanupChatSession(chatSessionId);
await mcpServerBridge.cleanupScopedMetadata(chatSessionId);
return { ok: true };
});
ipcMain.handle("netcatty:ai:codex-app-server:interaction-response", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const ok = codexAppServerRuntime.respondInteraction(payload?.interactionId, payload, event.sender);
return ok ? { ok: true } : { ok: false, error: "Interaction not found" };
});
ipcMain.handle("netcatty:ai:codex-app-server:interaction-cancel-timeout", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const cancelled = codexAppServerRuntime.cancelInteractionTimeout(
payload?.interactionId,
event.sender,
) === true;
return { ok: true, cancelled };
});
ipcMain.handle("netcatty:ai:codex-app-server:status", async (event, payload) => {
if (!validateSenderOrSettings(event)) return { ok: false, available: false, error: "Unauthorized IPC sender" };
try {
const shellEnv = await getShellEnv();
let env = buildSdkAgentEnv({
shellEnv,
requestedAgentEnv: normalizeAgentEnv(payload?.agentEnv),
withCliDiscoveryEnv,
normalizeClaudeCodeExecutableEnv: normalizeClaudeCodeExecutableEnvForSdk,
});
const binPath = resolveSdkBackendBinPath({
backendKey: "codex",
configuredCommand: payload?.agentCommand,
shellEnv,
env,
resolveCliFromPath,
normalizeCliPathForPlatform,
resolveSdkBinPath,
resolveClaudeCodeExecutableForSdk,
resolveCodexExecutableForSdk,
resolveCodebuddyExecutableForSdk,
});
env = addCodexExecutableEnvForSdk(env, binPath);
const result = await probeCodexAppServer({ binPath, env });
return { ok: true, ...result };
} catch (error) {
return { ok: true, available: false, error: error?.message || String(error) };
}
});
// --- CodeBuddy SDK 0.3.230 IPC handlers ---
ipcMain.handle("netcatty:ai:sdk-agent:mcp-status", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const shellEnv = await getShellEnv();
const env = buildSdkAgentEnv({
shellEnv,
requestedAgentEnv: normalizeAgentEnv(payload?.agentEnv),
withCliDiscoveryEnv,
normalizeClaudeCodeExecutableEnv: normalizeClaudeCodeExecutableEnvForSdk,
});
const binPath = resolveSdkBackendBinPath({
backendKey: "codebuddy",
configuredCommand: payload?.agentCommand,
shellEnv, env,
resolveCliFromPath, normalizeCliPathForPlatform, resolveSdkBinPath,
resolveClaudeCodeExecutableForSdk, resolveCodexExecutableForSdk, resolveCodebuddyExecutableForSdk,
});
const status = await codebuddyDriver.getCodebuddyMcpStatus({ pathToCodebuddyCode: binPath, env });
return { ok: true, servers: status };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:sdk-agent:account-info", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const shellEnv = await getShellEnv();
const env = buildSdkAgentEnv({
shellEnv,
requestedAgentEnv: normalizeAgentEnv(payload?.agentEnv),
withCliDiscoveryEnv,
normalizeClaudeCodeExecutableEnv: normalizeClaudeCodeExecutableEnvForSdk,
});
const binPath = resolveSdkBackendBinPath({
backendKey: "codebuddy",
configuredCommand: payload?.agentCommand,
shellEnv, env,
resolveCliFromPath, normalizeCliPathForPlatform, resolveSdkBinPath,
resolveClaudeCodeExecutableForSdk, resolveCodexExecutableForSdk, resolveCodebuddyExecutableForSdk,
});
const info = await codebuddyDriver.getCodebuddyAccountInfo({ pathToCodebuddyCode: binPath, env });
return { ok: true, account: info };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:sdk-agent:elicitation-response", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const { elicitationId, action, content } = payload || {};
if (!elicitationId) return { ok: false, error: "Missing elicitationId" };
const resolved = codebuddySessionManager.resolveElicitation(elicitationId, {
action: action || "cancel",
content: content || undefined,
});
return resolved ? { ok: true } : { ok: false, error: "Elicitation not found or already resolved" };
});
ipcMain.handle("netcatty:ai:sdk-agent:plugin-install", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const result = await codebuddyDriver.codebuddyInstallPlugin(payload?.options || {});
return { ok: true, result };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:sdk-agent:plugin-enable", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const result = await codebuddyDriver.codebuddyEnablePlugin(payload?.name, payload?.marketplace);
return { ok: true, result };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:sdk-agent:plugin-disable", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const result = await codebuddyDriver.codebuddyDisablePlugin(payload?.name, payload?.marketplace);
return { ok: true, result };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:sdk-agent:marketplace-install", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const result = await codebuddyDriver.codebuddyInstallMarketplace(payload?.options || {});
return { ok: true, result };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:sdk-agent:marketplace-remove", async (event, payload) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const result = await codebuddyDriver.codebuddyRemoveMarketplace(payload?.options || {});
return { ok: true, result };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
// Expose teardown so aiBridge.cleanup() can abort active streams and close
// persistent SDK runtimes, including idle CodeBuddy V2 sessions. The
// request-scoped maps are also exposed for lifecycle tests.
ctx.sdkActiveStreams = sdkActiveStreams;
ctx.sdkRequestSessions = sdkRequestSessions;
ctx.sdkRequestRuntimes = sdkRequestRuntimes;
ctx.codexAppServerRuntime = codexAppServerRuntime;
ctx.codebuddySessionManager = codebuddySessionManager;
}
}
module.exports = {
registerSdkStreamHandlers,
resolveBackendKey,
resolveSdkBackendBinPath,
buildSdkSessionKey,
buildSdkModelCacheKey,
getSdkModelCacheEntry,
setSdkModelCacheEntry,
normalizeSdkListModelsResult,
resolveSdkPromptPlacement,
resolveSdkResumeSessionId,
expireSiblingCursorCliModeSessions,
expireSiblingGrokRuntimeSessions,
shouldCacheSdkRuntimeModels,
normalizeHistoryMessages,
formatSdkHistoryReplaySection,
buildSdkTurnPrompt,
shouldReplaySdkHistory,
};