Files
Netcatty/electron/bridges/aiBridge/sdk/sdkStreamHandlers.cjs
2026-06-11 17:07:11 +08:00

347 lines
14 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 { realpathSync } = require("node:fs");
const VALID_BACKENDS = new Set(listBackends());
// Pre-flight model catalog cache. claude/copilot enumerate models via the SDK
// (supportedModels / listModels); spawning the CLI is ~1-2s, so cache per backend
// and always degrade to [] on error/timeout (the renderer keeps its presets).
const MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
const MODEL_LIST_TIMEOUT_MS = 10000;
const sdkModelCache = new Map();
function withTimeout(promise, ms) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`list-models timed out after ${ms}ms`)), 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 resolveSdkBackendBinPath({
backendKey, shellEnv, env, resolveCliFromPath, normalizeCliPathForPlatform, resolveSdkBinPath, realpath = realpathSync,
}) {
if (backendKey === "codebuddy") {
const configuredPath = normalizeCliPathForPlatform?.(env?.CODEBUDDY_CODE_PATH);
if (configuredPath) return resolveRealCliPath(configuredPath, realpath);
const resolvedPath = resolveCliFromPath(backendKey, shellEnv) || undefined;
return resolveRealCliPath(resolvedPath, realpath);
}
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;
}
function buildSdkTurnPrompt({
prompt,
historyMessages,
replayHistory,
attachments,
writeAttachmentToTemp = defaultWriteAttachmentToTemp,
onStagedAttachment,
}) {
const sections = [];
const history = replayHistory ? normalizeHistoryMessages(historyMessages) : [];
if (history.length > 0) {
sections.push(
[
"[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"),
);
}
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) {
sections.push(
[
"[Attached files: these paths are local to the machine running Netcatty, not remote hosts. Inspect them locally if needed.]",
"[If local filesystem tools are unavailable, use Netcatty's list_attachments and read_attachment MCP tools to inspect these user-supplied files.]",
...hints,
].join("\n"),
);
}
}
const trimmedPrompt = String(prompt || "");
return sections.length > 0
? `${sections.join("\n\n")}\n\n${trimmedPrompt}`
: trimmedPrompt;
}
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 sdkSessionIds = new Map(); // chatSessionId -> last sessionId
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,
} = 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;
let env = buildSdkAgentEnv({
shellEnv,
requestedAgentEnv: normalizedAgentEnv,
withCliDiscoveryEnv,
normalizeClaudeCodeExecutableEnv: normalizeClaudeCodeExecutableEnvForSdk,
});
if (backendKey === "cursor") {
logCursorApiKeySummary({ requestedAgentEnv: normalizedAgentEnv, shellEnv, env });
}
const binPath = resolveSdkBackendBinPath({
backendKey,
shellEnv,
env,
resolveCliFromPath,
normalizeCliPathForPlatform,
resolveSdkBinPath,
});
if (backendKey === "codex") {
env = addCodexExecutableEnvForSdk(env, binPath);
}
const hasInMemorySession = sdkSessionIds.has(chatSessionId);
const resumeSessionId = sdkSessionIds.get(chatSessionId) || existingSessionId || undefined;
const stagedAttachments = [];
const turnPrompt = buildSdkTurnPrompt({
prompt,
historyMessages: payload?.historyMessages,
replayHistory: !hasInMemorySession,
attachments: payload?.images,
onStagedAttachment: (attachment) => stagedAttachments.push(attachment),
});
mcpServerBridge.updateAttachmentMetadata?.(stagedAttachments, chatSessionId);
const contextualPrompt = buildExternalAgentContextualPrompt({
mode: effectiveMode,
prompt: turnPrompt,
chatSessionId,
defaultTargetSession,
userSkillsContext,
});
const driver = getDriver(backendKey);
const result = await driver.runTurn({
prompt: contextualPrompt,
cwd: cwd || process.cwd(),
model: model || undefined,
env,
binPath,
injectedMcpServers,
claudeSettings,
toolIntegrationMode: effectiveMode,
emitter,
signal: abortController.signal,
abortController,
resumeSessionId,
attachments: stagedAttachments,
});
// Persist any new session id for resume on the next turn.
const newSessionId = result?.sessionId || result?.threadId;
if (newSessionId) sdkSessionIds.set(chatSessionId, 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);
}
},
);
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 } = payload || {};
const backendKey = resolveBackendKey(sdkBackend);
if (!backendKey) return { ok: false, error: `Unknown SDK backend: ${sdkBackend}` };
// claude/copilot enumerate models via the SDK; codex has no catalog (its
// driver returns []), so the renderer falls back to curated presets.
const cached = sdkModelCache.get(backendKey);
if (cached && Date.now() - cached.at < MODEL_CACHE_TTL_MS) {
return { ok: true, currentModelId: null, models: cached.models };
}
try {
const driver = getDriver(backendKey);
if (typeof driver.listModels !== "function") {
return { ok: true, currentModelId: null, models: [] };
}
const shellEnv = await getShellEnv();
const env = buildSdkAgentEnv({
shellEnv,
requestedAgentEnv: normalizeAgentEnv(requestedAgentEnv),
withCliDiscoveryEnv,
normalizeClaudeCodeExecutableEnv: normalizeClaudeCodeExecutableEnvForSdk,
});
const binPath = resolveSdkBackendBinPath({
backendKey,
shellEnv,
env,
resolveCliFromPath,
normalizeCliPathForPlatform,
resolveSdkBinPath,
});
const raw = await withTimeout(driver.listModels({ binPath, env }), MODEL_LIST_TIMEOUT_MS);
const models = Array.isArray(raw) ? raw.filter((m) => m && m.id) : [];
sdkModelCache.set(backendKey, { at: Date.now(), models });
return { ok: true, currentModelId: null, 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: [] };
}
});
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.clearPendingApprovals(effectiveChatSessionId);
void mcpServerBridge.cancelSftpOpsForSession?.(effectiveChatSessionId);
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);
sdkSessionIds.delete(chatSessionId);
await mcpServerBridge.cleanupScopedMetadata(chatSessionId);
return { ok: true };
});
// Expose teardown so aiBridge.cleanup() can abort active SDK streams.
ctx.sdkActiveStreams = sdkActiveStreams;
}
}
module.exports = {
registerSdkStreamHandlers,
resolveBackendKey,
resolveSdkBackendBinPath,
normalizeHistoryMessages,
buildSdkTurnPrompt,
};