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
390 lines
14 KiB
JavaScript
390 lines
14 KiB
JavaScript
"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,
|
|
};
|