Files

390 lines
14 KiB
JavaScript
Raw Permalink Normal View History

"use strict";
/**
* Claude backend driver wraps @anthropic-ai/claude-agent-sdk query().
*
* - Spawns the user's system `claude` binary via an ABSOLUTE pathToClaudeCodeExecutable
* (SDK existsSync-checks it; PATH is not resolved issue #205).
* - Repairs ~/.claude.json before spawn (ensureClaudeConfig).
* - Bypasses the SDK's built-in permission system and BLOCKS built-in
* side-effect tools so the agent can only act through the injected netcatty
* MCP server (approval/scope/blocklist enforced there).
* - Translates SDK messages into the canonical renderer event protocol.
*/
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
const { ensureClaudeConfig } = require("./claudeConfig.cjs");
// Built-in tools that need interactive UI netcatty doesn't provide - they would
// hang the turn waiting for a response, so they are blocked in BOTH modes.
const UI_DISALLOWED_TOOLS = ["EnterPlanMode", "ExitPlanMode", "AskUserQuestion"];
// Whitelist Claude built-ins instead of trying to track every local-capable
// built-in tool the CLI may add over time. MCP tools remain available through
// mcpServers; this only controls Claude Code's own local-machine tools.
const MCP_MODE_TOOLS = [];
const SKILLS_MODE_TOOLS = ["Bash", "Skill"];
const CLAUDE_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
function isClaudeImageAttachment(attachment) {
return Boolean(
attachment &&
CLAUDE_IMAGE_MEDIA_TYPES.has(String(attachment.mediaType || "").toLowerCase()) &&
attachment.base64Data,
);
}
/**
* Resolve built-in tools for the active tool-integration mode.
* - "skills": only Bash + Skill so the Netcatty CLI skill can run.
* - "mcp" (default): no Claude built-in local tools, forcing remote actions
* through netcatty MCP.
*/
function claudeBuiltinTools(toolIntegrationMode) {
return toolIntegrationMode === "skills"
? [...SKILLS_MODE_TOOLS]
: [...MCP_MODE_TOOLS];
}
/** Convert neutral injectMcp configs into the SDK's keyed mcpServers map. */
function toSdkMcpServers(injectedMcpServers) {
const map = {};
for (const cfg of injectedMcpServers || []) {
if (!cfg || !cfg.name) continue;
map[cfg.name] = {
type: "stdio",
command: cfg.command,
args: cfg.args || [],
env: mcpEnvPairsToObject(cfg.env),
};
}
return map;
}
/**
* Normalize the user-supplied claude `settings` value: a settings.json path
* (string) or inline JSON ("{...}" -> object). Returns undefined when empty.
* This is INDEPENDENT of CLAUDE_CONFIG_DIR (which supplies credentials + the
* base settings layer) `settings` is an additional override the SDK merges on
* top, so the two coexist.
*/
function parseClaudeSettings(settings) {
if (settings == null) return undefined;
if (typeof settings === "object") return settings;
const str = String(settings).trim();
if (!str) return undefined;
if (str.startsWith("{")) {
try { return JSON.parse(str); } catch { return str; }
}
return str;
}
const CLAUDE_REASONING_LEVELS = new Set(["low", "medium", "high", "max"]);
function splitClaudeModelSelection(model) {
if (typeof model !== "string" || !model) {
return { model: undefined, effort: undefined };
}
const slash = model.lastIndexOf("/");
if (slash <= 0) return { model, effort: undefined };
const effort = model.slice(slash + 1);
if (!CLAUDE_REASONING_LEVELS.has(effort)) return { model, effort: undefined };
return { model: model.slice(0, slash), effort };
}
function mergeClaudeEffortSettings(settings, effort) {
if (!effort) return settings;
if (settings == null) return { effort };
if (typeof settings === "object") return { ...settings, effort };
return settings;
}
function buildClaudeQueryOptions({
cwd, model, env, pathToClaudeCodeExecutable, abortController, injectedMcpServers, settings, resume,
toolIntegrationMode,
}) {
const { model: resolvedModel, effort } = splitClaudeModelSelection(model);
const options = {
cwd,
includePartialMessages: true,
permissionMode: "bypassPermissions",
// Required companion to permissionMode:'bypassPermissions' (the SDK rejects
// the bypass without it). Netcatty blocks Claude's direct local read/write
// tools and routes remote-session actions through MCP or Skills+CLI, where
// Netcatty enforces approval/scope.
allowDangerouslySkipPermissions: true,
tools: claudeBuiltinTools(toolIntegrationMode),
disallowedTools: [...UI_DISALLOWED_TOOLS],
mcpServers: toSdkMcpServers(injectedMcpServers),
env,
abortController,
};
if (resolvedModel) options.model = resolvedModel;
if (effort) options.effort = effort;
// Resume the prior session so context carries ACROSS turns. Without this the
// SDK starts a fresh session every turn (full amnesia). The session id is
// emitted on system-init (before any turn work), so a mid-turn Stop can't lose
// it and the next turn resumes correctly. undefined => fresh session.
if (resume) options.resume = resume;
// ABSOLUTE path only (SDK does not resolve PATH). undefined => SDK auto-discovery.
if (pathToClaudeCodeExecutable) {
options.pathToClaudeCodeExecutable = pathToClaudeCodeExecutable;
}
// Optional settings.json path / inline object — additive to CLAUDE_CONFIG_DIR.
const parsedSettings = mergeClaudeEffortSettings(parseClaudeSettings(settings), effort);
if (parsedSettings !== undefined) options.settings = parsedSettings;
return options;
}
/**
* Translate one SDK message into emitter calls.
* NOTE: with includePartialMessages, streamed text arrives via stream_event;
* the consolidated assistant TEXT block is skipped to avoid duplication, but
* assistant TOOL_USE blocks are the authoritative source for tool calls.
*/
function translateClaudeMessage(message, emitter) {
if (!message || typeof message !== "object") return;
const type = message.type;
if (type === "system" && message.subtype === "init" && message.session_id) {
emitter.sessionId(message.session_id);
return;
}
if (type === "stream_event" && message.event) {
const ev = message.event;
if (ev.type === "content_block_delta" && ev.delta) {
if (ev.delta.type === "text_delta" && ev.delta.text) {
emitter.text(ev.delta.text);
} else if (ev.delta.type === "thinking_delta" && ev.delta.thinking) {
emitter.reasoning(ev.delta.thinking);
}
}
return;
}
if (type === "assistant" && message.message && Array.isArray(message.message.content)) {
for (const block of message.message.content) {
if (block?.type === "tool_use") {
emitter.toolCall(block.name, block.input || {}, block.id);
}
// text blocks intentionally skipped (already streamed via stream_event)
}
return;
}
if (type === "user" && message.message && Array.isArray(message.message.content)) {
for (const block of message.message.content) {
if (block?.type === "tool_result") {
const out = typeof block.content === "string"
? block.content
: JSON.stringify(block.content);
emitter.toolResult(block.tool_use_id, out, undefined);
}
}
return;
}
// 'result' carries final usage/cost — handled by the run loop, no per-event emit.
}
/** Classify a spawn failure. SDK wraps spawn ENOENT as a message string. */
function classifyClaudeSpawnError(error) {
const code = error && error.code;
const msg = String((error && error.message) || error || "");
const isSpawnEnoent =
code === "ENOENT" ||
/native binary not found/i.test(msg) ||
/ENOENT/i.test(msg);
return { isSpawnEnoent, message: msg };
}
function buildClaudePromptInput(prompt, attachments) {
const imageAttachments = Array.isArray(attachments)
? attachments.filter(isClaudeImageAttachment)
: [];
if (imageAttachments.length === 0) return String(prompt || "");
const content = [{ type: "text", text: String(prompt || "") }];
for (const attachment of imageAttachments) {
content.push({
type: "image",
source: {
type: "base64",
media_type: String(attachment.mediaType).toLowerCase(),
data: attachment.base64Data,
},
});
}
return (async function* claudePromptInput() {
yield {
type: "user",
message: { role: "user", content },
parent_tool_use_id: null,
};
}());
}
/**
* Run a Claude turn. Streams events via `emitter`, resolves with { sessionId }.
* @param {object} args
* @param {string} args.prompt
* @param {Array<object>} [args.attachments]
* @param {object} args.options result of buildClaudeQueryOptions
* @param {object} args.emitter createStreamEmitter(...)
* @param {Function} [args.queryFn] inject @anthropic-ai/claude-agent-sdk query (for tests)
*/
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn }) {
ensureClaudeConfig();
let query = queryFn;
if (!query) {
let sdk;
try { sdk = await import("@anthropic-ai/claude-agent-sdk"); } catch { emitter.emitError("Claude Agent SDK not installed. Run: npm install @anthropic-ai/claude-agent-sdk"); return { sessionId: null }; }
query = sdk.query;
}
const promptInput = buildClaudePromptInput(prompt, attachments);
let sessionId = null;
let hasContent = false;
try {
const stream = query({ prompt: promptInput, options });
for await (const message of stream) {
if (options.abortController?.signal?.aborted) break;
if (message?.session_id && message.session_id !== sessionId) {
sessionId = message.session_id;
}
if (
message?.type === "stream_event" ||
(message?.type === "assistant" && Array.isArray(message?.message?.content) && message.message.content.length > 0)
) {
hasContent = true;
}
translateClaudeMessage(message, emitter);
}
if (!hasContent && !options.abortController?.signal?.aborted) {
emitter.emitError(
"Claude returned an empty response. Run `claude` in a terminal to log in, " +
"or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN.",
);
return { sessionId };
}
emitter.emitDone();
return { sessionId };
} catch (error) {
const classified = classifyClaudeSpawnError(error);
if (classified.isSpawnEnoent) {
emitter.emitError(
`Claude Code binary not found or not runnable (${options.pathToClaudeCodeExecutable || "auto-discovery"}). ` +
"Install with `npm i -g @anthropic-ai/claude-code` and ensure it's on PATH.",
);
} else {
emitter.emitError(classified.message || "Claude turn failed");
}
return { sessionId };
}
}
/** Map claude-agent-sdk ModelInfo[] -> renderer preset shape {id,name,description}. */
function mapClaudeModels(models) {
if (!Array.isArray(models)) return [];
return models
.filter((m) => m && m.value)
.map((m) => ({
id: m.value,
name: m.displayName || m.value,
description: m.description,
thinkingLevels: ["low", "medium", "high", "max"],
defaultThinkingLevel: "medium",
}));
}
/**
* Fetch available Claude models via the SDK control channel. Opens a streaming
* (idle) session so no turn is billed, asks supportedModels(), then tears down.
* Returns [] on failure (the caller falls back to the UI's curated presets).
* @param {object} args
* @param {string} [args.pathToClaudeCodeExecutable]
* @param {object} [args.env]
* @param {Function} [args.queryFn] inject query() for tests
*/
async function listClaudeModels({
pathToClaudeCodeExecutable,
env,
queryFn,
abortController,
signal,
}) {
ensureClaudeConfig();
const externalSignal = signal || abortController?.signal;
if (externalSignal?.aborted) return [];
let query = queryFn;
if (!query) {
let sdk;
try { sdk = await import("@anthropic-ai/claude-agent-sdk"); } catch { return []; }
query = sdk.query;
}
const queryAbortController = new AbortController();
const forwardAbort = () => {
try { queryAbortController.abort(externalSignal?.reason); } catch {}
};
if (externalSignal) {
externalSignal.addEventListener("abort", forwardAbort, { once: true });
if (externalSignal.aborted) forwardAbort();
}
// Idle streaming input: keeps the session open (init handshake completes)
// without sending a turn, so supportedModels() resolves; then we abort.
async function* idleInput() {
await new Promise((resolve) => {
if (queryAbortController.signal.aborted) return resolve();
queryAbortController.signal.addEventListener("abort", () => resolve(), { once: true });
});
}
let q;
try {
q = query({
prompt: idleInput(),
options: {
pathToClaudeCodeExecutable,
env,
abortController: queryAbortController,
includePartialMessages: false,
},
});
const result = await Promise.race([
Promise.resolve(q.supportedModels()).then((models) => ({ type: "models", models })),
new Promise((resolve) => {
if (queryAbortController.signal.aborted) return resolve({ type: "aborted" });
queryAbortController.signal.addEventListener(
"abort",
() => resolve({ type: "aborted" }),
{ once: true },
);
}),
]);
return result.type === "models" ? mapClaudeModels(result.models) : [];
} catch {
return [];
} finally {
if (externalSignal) externalSignal.removeEventListener("abort", forwardAbort);
queryAbortController.abort();
try { void Promise.resolve(q?.return?.(undefined)).catch(() => {}); } catch { /* best effort */ }
}
}
module.exports = {
buildClaudeQueryOptions,
parseClaudeSettings,
splitClaudeModelSelection,
mergeClaudeEffortSettings,
translateClaudeMessage,
classifyClaudeSpawnError,
buildClaudePromptInput,
runClaudeTurn,
listClaudeModels,
mapClaudeModels,
claudeBuiltinTools,
UI_DISALLOWED_TOOLS,
MCP_MODE_TOOLS,
SKILLS_MODE_TOOLS,
toSdkMcpServers,
};