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
1059 lines
38 KiB
JavaScript
1059 lines
38 KiB
JavaScript
"use strict";
|
|
|
|
/**
|
|
* CodeBuddy backend driver — wraps @tencent-ai/agent-sdk query().
|
|
*
|
|
* - Spawns the user's system `codebuddy` binary (auto-discovered or via
|
|
* CODEBUDDY_CODE_PATH env var / pathToCodebuddyCode option).
|
|
* - Bypasses the SDK's built-in permission system and routes all side effects
|
|
* through the injected netcatty MCP server (approval/scope/blocklist enforced
|
|
* there).
|
|
* - Translates SDK messages into the canonical renderer event protocol.
|
|
* - Supports streaming text via includePartialMessages, multi-turn session
|
|
* resume, thinking mode config, and model listing.
|
|
*/
|
|
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
|
|
const { isLikelyNetcattyCliShellCommand } = require("./copilotDriver.cjs");
|
|
const { randomUUID } = require("node:crypto");
|
|
|
|
// 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 = ["AskUserQuestion"];
|
|
|
|
// Whitelist CodeBuddy built-in tools per tool-integration mode.
|
|
const MCP_MODE_TOOLS = [];
|
|
const SKILLS_MODE_TOOLS = ["Bash"];
|
|
|
|
const CODEBUDDY_IMAGE_MEDIA_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
|
|
|
function isCodebuddyImageAttachment(attachment) {
|
|
return Boolean(
|
|
attachment &&
|
|
CODEBUDDY_IMAGE_MEDIA_TYPES.has(String(attachment.mediaType || "").toLowerCase()) &&
|
|
attachment.base64Data,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Thinking config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse env-based thinking config into the SDK's thinking option.
|
|
* Accepts: "adaptive" | "enabled" | "enabled:16000" | "disabled" | ""
|
|
*/
|
|
function parseCodebuddyThinking(value) {
|
|
if (!value || typeof value !== "string") return undefined;
|
|
const trimmed = value.trim().toLowerCase();
|
|
if (!trimmed) return undefined;
|
|
if (trimmed === "adaptive") return { type: "adaptive" };
|
|
if (trimmed === "enabled") return { type: "enabled", budgetTokens: 16000 };
|
|
if (trimmed.startsWith("enabled:")) {
|
|
const budget = parseInt(trimmed.slice(8), 10);
|
|
if (Number.isFinite(budget) && budget > 0) return { type: "enabled", budgetTokens: budget };
|
|
return { type: "enabled", budgetTokens: 16000 };
|
|
}
|
|
if (trimmed === "disabled") return { type: "disabled" };
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Serialize thinking config to env pairs for storage/display.
|
|
*/
|
|
function buildCodebuddyThinkingEnv(thinking) {
|
|
if (!thinking || typeof thinking !== "object") return {};
|
|
if (thinking.type === "adaptive") return { NETCATTY_CODEBUDDY_THINKING: "adaptive" };
|
|
if (thinking.type === "enabled") {
|
|
const budget = thinking.budgetTokens || 16000;
|
|
return { NETCATTY_CODEBUDDY_THINKING: budget === 16000 ? "enabled" : `enabled:${budget}` };
|
|
}
|
|
if (thinking.type === "disabled") return { NETCATTY_CODEBUDDY_THINKING: "disabled" };
|
|
return {};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP servers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Convert neutral injectMcp configs into the SDK's keyed mcpServers map.
|
|
* Supports stdio (default), sse, http, and sdk (in-process) transport types (SDK 0.3.230). */
|
|
function toSdkMcpServers(injectedMcpServers) {
|
|
const map = {};
|
|
for (const cfg of injectedMcpServers || []) {
|
|
if (!cfg || !cfg.name) continue;
|
|
const transport = cfg.type || "stdio";
|
|
if (transport === "sse" || transport === "http") {
|
|
map[cfg.name] = {
|
|
type: transport,
|
|
url: cfg.url,
|
|
headers: cfg.headers || undefined,
|
|
};
|
|
} else if (transport === "sdk") {
|
|
// In-process MCP server created via createSdkMcpServer() — pass instance through.
|
|
map[cfg.name] = {
|
|
type: "sdk",
|
|
name: cfg.name,
|
|
instance: cfg.instance,
|
|
};
|
|
} else {
|
|
map[cfg.name] = {
|
|
type: "stdio",
|
|
command: cfg.command,
|
|
args: cfg.args || [],
|
|
env: mcpEnvPairsToObject(cfg.env),
|
|
};
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Create an in-process SDK MCP server with Netcatty tools.
|
|
* Uses createSdkMcpServer() + tool() from @tencent-ai/agent-sdk to avoid
|
|
* spawning a subprocess for lightweight built-in tools.
|
|
* @param {Array<{name: string, description: string, inputSchema: object, handler: Function}>} toolDefs
|
|
* @returns {Promise<object|null>} McpSdkServerResult instance or null if unavailable
|
|
*/
|
|
async function createCodebuddySdkMcpServer(toolDefs) {
|
|
let sdk;
|
|
try { sdk = await import("@tencent-ai/agent-sdk"); } catch { return null; }
|
|
if (!sdk.createSdkMcpServer || !sdk.tool) return null;
|
|
const tools = (toolDefs || []).map((def) =>
|
|
sdk.tool(def.name, def.description, def.inputSchema, def.handler),
|
|
);
|
|
return sdk.createSdkMcpServer({ name: "netcatty-builtin", tools });
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Permission handler (canUseTool)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build an SDK canUseTool permission handler based on Netcatty's permission mode.
|
|
*
|
|
* When the CodeBuddy CLI encounters a security restriction that requires a
|
|
* permission decision (instead of throwing an error), the SDK routes the
|
|
* request through this handler:
|
|
* - auto: automatically confirm execution (no user interruption)
|
|
* - confirm: forward to the Netcatty renderer approval UI and wait for the
|
|
* user's decision (approve once / always allow / reject)
|
|
* - observer: deny all restricted tool executions
|
|
*
|
|
* @param {object} args
|
|
* @param {string} args.permissionMode Netcatty permission mode ('auto'|'confirm'|'observer')
|
|
* @param {string} [args.chatSessionId] chat session scope for the approval card
|
|
* @param {Function} [args.requestApproval] (toolName, args, chatSessionId) => Promise<boolean>
|
|
* @returns {Function} SDK CanUseTool handler: (toolName, input, options) => Promise<PermissionResult>
|
|
*/
|
|
function buildCodebuddyCanUseTool({ permissionMode, chatSessionId, requestApproval }) {
|
|
return async (toolName, input, _options) => {
|
|
// Auto mode: confirm execution immediately without user interruption.
|
|
if (permissionMode === "auto") {
|
|
return { behavior: "allow" };
|
|
}
|
|
// Observer mode: deny all restricted tool executions.
|
|
if (permissionMode === "observer") {
|
|
return {
|
|
behavior: "deny",
|
|
message: `Observer mode: tool "${toolName}" execution denied.`,
|
|
};
|
|
}
|
|
// Confirm mode (default): forward to the Netcatty approval UI.
|
|
if (typeof requestApproval !== "function") {
|
|
// No approval channel available — deny to preserve confirm-mode safety guarantee.
|
|
return {
|
|
behavior: "deny",
|
|
message: `Tool "${toolName}" requires approval but no approval channel is available.`,
|
|
};
|
|
}
|
|
try {
|
|
const approved = await requestApproval(toolName, input || {}, chatSessionId);
|
|
return approved
|
|
? { behavior: "allow" }
|
|
: { behavior: "deny", message: `User denied execution of tool "${toolName}".` };
|
|
} catch {
|
|
return {
|
|
behavior: "deny",
|
|
message: `Approval request for tool "${toolName}" failed.`,
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Query options
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Resolve built-in tools for the active tool-integration mode.
|
|
*/
|
|
function codebuddyBuiltinTools(toolIntegrationMode) {
|
|
return toolIntegrationMode === "skills"
|
|
? [...SKILLS_MODE_TOOLS]
|
|
: [...MCP_MODE_TOOLS];
|
|
}
|
|
|
|
const CODEBUDDY_REASONING_LEVELS = ["low", "medium", "high", "xhigh"];
|
|
const CODEBUDDY_REASONING_LEVEL_SET = new Set(CODEBUDDY_REASONING_LEVELS);
|
|
|
|
function splitCodebuddyModelSelection(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).toLowerCase();
|
|
if (!CODEBUDDY_REASONING_LEVEL_SET.has(effort)) return { model, effort: undefined };
|
|
return { model: model.slice(0, slash), effort };
|
|
}
|
|
|
|
function attachCodebuddyThinkingLevels(preset) {
|
|
if (!preset) return preset;
|
|
return {
|
|
...preset,
|
|
thinkingLevels: [...CODEBUDDY_REASONING_LEVELS],
|
|
defaultThinkingLevel: "medium",
|
|
encodeDefaultThinking: false,
|
|
};
|
|
}
|
|
|
|
function buildCodebuddyQueryOptions({
|
|
cwd, model, env, injectedMcpServers, abortController,
|
|
resume, pathToCodebuddyCode, toolIntegrationMode, thinking,
|
|
// Phase 1: SDK 0.3.230 options
|
|
systemPrompt, effort, maxTurns, maxBudgetUsd, fallbackModel,
|
|
sandbox, agents, outputFormat, enableFileCheckpointing,
|
|
traceId, parentSpanId, persistSession, sessionId, hooks,
|
|
elicitation, canUseTool,
|
|
}) {
|
|
const builtinTools = codebuddyBuiltinTools(toolIntegrationMode);
|
|
const options = {
|
|
cwd,
|
|
includePartialMessages: true,
|
|
permissionMode: "bypassPermissions",
|
|
allowDangerouslySkipPermissions: true,
|
|
extraArgs: { "dangerously-skip-permissions": null },
|
|
mcpServers: toSdkMcpServers(injectedMcpServers),
|
|
tools: builtinTools,
|
|
// `tools` is the built-in tool whitelist. In mcp mode it is [], so CodeBuddy
|
|
// built-ins stay disabled while injected Netcatty MCP tools remain visible.
|
|
// Do not mirror that empty list into allowedTools: the SDK treats
|
|
// allowedTools as an auto-approval list, and allowedTools: [] prevents MCP
|
|
// tool calls from running under bypassPermissions.
|
|
disallowedTools: [...UI_DISALLOWED_TOOLS],
|
|
// Keep the SDK isolated from user/project settings so local hooks, plugins,
|
|
// or extra MCP servers cannot expand Netcatty's controlled tool boundary.
|
|
settingSources: [],
|
|
env,
|
|
};
|
|
const selection = splitCodebuddyModelSelection(model);
|
|
if (selection.model) options.model = selection.model;
|
|
if (abortController) options.abortController = abortController;
|
|
// Resume prior session for multi-turn context continuity.
|
|
if (resume) options.resume = resume;
|
|
// CLI executable path (auto-discovery if omitted).
|
|
if (pathToCodebuddyCode) options.pathToCodebuddyCode = pathToCodebuddyCode;
|
|
// Thinking mode from env marker or explicit param.
|
|
const thinkingConfig = thinking || parseCodebuddyThinking(env?.NETCATTY_CODEBUDDY_THINKING);
|
|
if (thinkingConfig) {
|
|
options.thinking = thinkingConfig;
|
|
}
|
|
// --- SDK 0.3.230 options ---
|
|
// System prompt: string is treated as append to default.
|
|
if (systemPrompt) {
|
|
options.systemPrompt = typeof systemPrompt === "string"
|
|
? { append: systemPrompt }
|
|
: systemPrompt;
|
|
}
|
|
// Effort level for model reasoning. Sidebar `model/effort` wins over
|
|
// the Settings-level codebuddyOptions.effort fallback.
|
|
const resolvedEffort = selection.effort || effort;
|
|
if (CODEBUDDY_REASONING_LEVEL_SET.has(resolvedEffort)) {
|
|
options.effort = resolvedEffort;
|
|
}
|
|
// Safety guardrails.
|
|
if (Number.isInteger(maxTurns) && maxTurns > 0) options.maxTurns = maxTurns;
|
|
if (Number.isFinite(maxBudgetUsd) && maxBudgetUsd > 0) {
|
|
options.maxBudgetUsd = maxBudgetUsd;
|
|
}
|
|
// Fallback model when primary is unavailable.
|
|
if (typeof fallbackModel === "string" && fallbackModel.trim()) {
|
|
options.fallbackModel = fallbackModel.trim();
|
|
}
|
|
// Sandbox settings.
|
|
if (
|
|
sandbox &&
|
|
typeof sandbox === "object" &&
|
|
!Array.isArray(sandbox) &&
|
|
sandbox.enabled === true
|
|
) {
|
|
options.sandbox = sandbox;
|
|
}
|
|
// Custom subagent definitions.
|
|
if (agents && typeof agents === "object") options.agents = agents;
|
|
// Structured output format.
|
|
if (outputFormat && typeof outputFormat === "object") options.outputFormat = outputFormat;
|
|
// File checkpointing for rollback.
|
|
if (enableFileCheckpointing === true) options.enableFileCheckpointing = true;
|
|
// Distributed tracing.
|
|
if (traceId) options.traceId = traceId;
|
|
if (parentSpanId) options.parentSpanId = parentSpanId;
|
|
// Session persistence control.
|
|
if (persistSession != null) options.persistSession = persistSession;
|
|
// Custom session ID.
|
|
if (sessionId) options.sessionId = sessionId;
|
|
// Lifecycle hooks.
|
|
if (hooks && typeof hooks === "object") options.hooks = hooks;
|
|
// Supported by @tencent-ai/agent-sdk 0.3.230 Options.elicitation.
|
|
// QueryController advertises capabilities.elicitation.form during
|
|
// initialization and routes elicitation_create control requests here.
|
|
if (elicitation && typeof elicitation === "object") options.elicitation = elicitation;
|
|
// Permission handler: when the CLI hits a security restriction, route the
|
|
// decision through Netcatty (auto-confirm / user approval / deny) instead
|
|
// of throwing an error.
|
|
if (typeof canUseTool === "function") options.canUseTool = canUseTool;
|
|
return options;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Message translation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Translate one CodeBuddy SDK message into emitter calls.
|
|
*
|
|
* With includePartialMessages enabled:
|
|
* - Streaming text/reasoning arrives via stream_event (content_block_delta).
|
|
* - The consolidated assistant TEXT block is skipped to avoid duplication,
|
|
* but assistant TOOL_USE blocks remain the authoritative source for tool calls.
|
|
*
|
|
* Without includePartialMessages (fallback):
|
|
* - Text arrives as complete TextBlock within assistant messages.
|
|
*/
|
|
function translateCodebuddyMessage(message, emitter, opts = {}) {
|
|
if (!message || typeof message !== "object") return;
|
|
const type = message.type;
|
|
|
|
if (type === "system") {
|
|
if (!opts.skipSessionId && message.session_id) emitter.sessionId(message.session_id);
|
|
if (message.message) emitter.status(message.message);
|
|
return;
|
|
}
|
|
|
|
// Streaming deltas (when includePartialMessages is enabled).
|
|
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 === "error") {
|
|
emitter.emitError(String(message.error || "CodeBuddy execution failed."));
|
|
return { terminalError: true };
|
|
}
|
|
|
|
if (type === "assistant" && message.message && Array.isArray(message.message.content)) {
|
|
if (message.error) {
|
|
emitter.emitError(String(message.error));
|
|
return { terminalError: true };
|
|
}
|
|
for (const block of message.message.content) {
|
|
if (!block || typeof block !== "object") continue;
|
|
if (block.type === "tool_use") {
|
|
emitter.toolCall(block.name, block.input || {}, block.id);
|
|
} else if (!opts.skipAssistantText && block.type === "text" && block.text) {
|
|
emitter.text(block.text);
|
|
} else if (
|
|
!opts.skipAssistantReasoning &&
|
|
block.type === "thinking" &&
|
|
block.thinking
|
|
) {
|
|
emitter.reasoning(block.thinking);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
|
|
if (type === "status" && message.message) {
|
|
emitter.status(message.message);
|
|
return;
|
|
}
|
|
|
|
// result carries final duration/cost/usage.
|
|
if (type === "result") {
|
|
const usage = message.usage;
|
|
if (usage && typeof usage === "object") {
|
|
const uncachedInputTokens = Number(usage.input_tokens) || 0;
|
|
const cachedInputTokens = Number(usage.cache_read_input_tokens) || 0;
|
|
const cacheCreationInputTokens = Number(usage.cache_creation_input_tokens) || 0;
|
|
// CodeBuddy follows Anthropic usage semantics: cache read/creation tokens
|
|
// are reported separately from input_tokens. AgentUsage expects total
|
|
// prompt tokens with cachedPromptTokens as the cached subset.
|
|
const inputTokens =
|
|
uncachedInputTokens + cachedInputTokens + cacheCreationInputTokens;
|
|
const outputTokens = Number(usage.output_tokens) || 0;
|
|
emitter.usage?.({
|
|
inputTokens,
|
|
cachedInputTokens,
|
|
outputTokens,
|
|
totalTokens: inputTokens + outputTokens,
|
|
});
|
|
}
|
|
const parts = [];
|
|
if (message.num_turns) parts.push(`${message.num_turns} turns`);
|
|
if (message.total_cost_usd > 0) parts.push(`$${message.total_cost_usd.toFixed(4)}`);
|
|
if (message.is_error && Array.isArray(message.errors) && message.errors.length > 0) {
|
|
parts.push(`errors: ${message.errors.join("; ")}`);
|
|
}
|
|
if (parts.length > 0) {
|
|
emitter.status(`CodeBuddy: ${parts.join(", ")}`);
|
|
}
|
|
if (message.is_error) {
|
|
const detail = Array.isArray(message.errors) && message.errors.length > 0
|
|
? message.errors.join("; ")
|
|
: message.subtype === "error_max_turns"
|
|
? "CodeBuddy stopped after reaching the maximum turn limit."
|
|
: message.subtype === "error_max_budget_usd"
|
|
? "CodeBuddy stopped after reaching the configured budget limit."
|
|
: "CodeBuddy execution failed.";
|
|
emitter.emitError(detail);
|
|
return { terminalError: true };
|
|
}
|
|
return { terminalError: false };
|
|
}
|
|
// tool_progress, compact_boundary — no renderer mapping.
|
|
}
|
|
|
|
function inspectCodebuddyMessageContent(message) {
|
|
const delta = message?.type === "stream_event" ? message.event?.delta : null;
|
|
const blocks = Array.isArray(message?.message?.content) ? message.message.content : [];
|
|
const streamedText = delta?.type === "text_delta" && Boolean(delta.text);
|
|
const streamedReasoning = delta?.type === "thinking_delta" && Boolean(delta.thinking);
|
|
const assistantText = message?.type === "assistant" &&
|
|
blocks.some((block) => block?.type === "text" && block.text);
|
|
const assistantReasoning = message?.type === "assistant" &&
|
|
blocks.some((block) => block?.type === "thinking" && block.thinking);
|
|
const assistantTool = message?.type === "assistant" &&
|
|
blocks.some((block) => block?.type === "tool_use");
|
|
const toolResult = message?.type === "user" &&
|
|
blocks.some((block) => block?.type === "tool_result");
|
|
return {
|
|
hasContent: streamedText || streamedReasoning ||
|
|
assistantText || assistantReasoning || assistantTool || toolResult,
|
|
hasText: streamedText || assistantText,
|
|
streamedText,
|
|
streamedReasoning,
|
|
};
|
|
}
|
|
|
|
function codebuddyResultFallbackText(message) {
|
|
if (message?.type !== "result" || message.is_error) return "";
|
|
if (typeof message.result === "string" && message.result) return message.result;
|
|
if (message.structured_output === undefined) return "";
|
|
try {
|
|
return JSON.stringify(message.structured_output, null, 2);
|
|
} catch {
|
|
return String(message.structured_output);
|
|
}
|
|
}
|
|
|
|
/** Classify a spawn failure for user-friendly error messages. */
|
|
function classifyCodebuddySpawnError(error) {
|
|
const code = error && error.code;
|
|
const msg = String((error && error.message) || error || "");
|
|
const isSpawnEnoent =
|
|
code === "ENOENT" ||
|
|
/ENOENT/i.test(msg) ||
|
|
/not found/i.test(msg);
|
|
return { isSpawnEnoent, message: msg };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prompt input (with optional image attachments)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function buildCodebuddyPromptInput(prompt, attachments) {
|
|
const imageAttachments = Array.isArray(attachments)
|
|
? attachments.filter(isCodebuddyImageAttachment)
|
|
: [];
|
|
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* codebuddyPromptInput() {
|
|
yield {
|
|
type: "user",
|
|
message: { role: "user", content },
|
|
parent_tool_use_id: null,
|
|
};
|
|
}());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Run turn
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Run a CodeBuddy 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 buildCodebuddyQueryOptions
|
|
* @param {object} args.emitter createStreamEmitter(...)
|
|
* @param {Function} [args.queryFn] inject @tencent-ai/agent-sdk query (for tests)
|
|
*/
|
|
async function runCodebuddyTurn({ prompt, attachments, options, emitter, queryFn }) {
|
|
if (options.abortController?.signal?.aborted) {
|
|
emitter.emitDone();
|
|
return { sessionId: null };
|
|
}
|
|
|
|
let query = queryFn;
|
|
if (!query) {
|
|
let sdk;
|
|
try { sdk = await import("@tencent-ai/agent-sdk"); } catch {
|
|
emitter.emitError("CodeBuddy Agent SDK not installed. Run: npm install @tencent-ai/agent-sdk");
|
|
return { sessionId: null };
|
|
}
|
|
query = sdk.query;
|
|
}
|
|
|
|
const promptInput = buildCodebuddyPromptInput(prompt, attachments);
|
|
|
|
let sessionId = null;
|
|
let hasContent = false;
|
|
let hasAssistantText = false;
|
|
let hasStreamedText = false;
|
|
let hasStreamedReasoning = false;
|
|
let hasTerminalError = false;
|
|
let resultFallbackText = "";
|
|
let queryRef = null;
|
|
let removeAbortListener = null;
|
|
try {
|
|
queryRef = query({ prompt: promptInput, options });
|
|
const signal = options.abortController?.signal;
|
|
const interruptQuery = () => {
|
|
if (typeof queryRef?.interrupt === "function") {
|
|
void queryRef.interrupt().catch((err) => {
|
|
console.debug("[CodeBuddy SDK] interrupt failed:", err?.message || err);
|
|
});
|
|
}
|
|
};
|
|
if (signal) {
|
|
if (signal.aborted) interruptQuery();
|
|
else {
|
|
signal.addEventListener("abort", interruptQuery, { once: true });
|
|
removeAbortListener = () => signal.removeEventListener("abort", interruptQuery);
|
|
}
|
|
}
|
|
for await (const message of queryRef) {
|
|
if (options.abortController?.signal?.aborted) {
|
|
if (typeof queryRef.interrupt === "function") {
|
|
try { await queryRef.interrupt(); } catch (err) {
|
|
// Best effort — surface for diagnostics without failing the turn.
|
|
console.debug("[CodeBuddy SDK] interrupt failed:", err?.message || err);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
if (message?.session_id && message.session_id !== sessionId) {
|
|
sessionId = message.session_id;
|
|
}
|
|
const contentState = inspectCodebuddyMessageContent(message);
|
|
if (contentState.hasContent) hasContent = true;
|
|
if (contentState.hasText) hasAssistantText = true;
|
|
resultFallbackText ||= codebuddyResultFallbackText(message);
|
|
const translation = translateCodebuddyMessage(
|
|
message,
|
|
emitter,
|
|
{
|
|
skipAssistantText: hasStreamedText,
|
|
skipAssistantReasoning: hasStreamedReasoning,
|
|
},
|
|
);
|
|
if (translation?.terminalError) hasTerminalError = true;
|
|
if (contentState.streamedText) hasStreamedText = true;
|
|
if (contentState.streamedReasoning) hasStreamedReasoning = true;
|
|
}
|
|
if (hasTerminalError) {
|
|
return { sessionId };
|
|
}
|
|
if (!hasAssistantText && resultFallbackText) {
|
|
emitter.text(resultFallbackText);
|
|
hasContent = true;
|
|
}
|
|
if (!hasContent && !options.abortController?.signal?.aborted) {
|
|
emitter.emitError(
|
|
"CodeBuddy returned an empty response. Run `codebuddy` in a terminal to log in, " +
|
|
"or set CODEBUDDY_API_KEY / CODEBUDDY_AUTH_TOKEN.",
|
|
);
|
|
return { sessionId };
|
|
}
|
|
emitter.emitDone();
|
|
return { sessionId };
|
|
} catch (error) {
|
|
if (options.abortController?.signal?.aborted) {
|
|
emitter.emitDone();
|
|
return { sessionId };
|
|
}
|
|
const classified = classifyCodebuddySpawnError(error);
|
|
if (classified.isSpawnEnoent) {
|
|
emitter.emitError(
|
|
"CodeBuddy CLI not found or not runnable. " +
|
|
"Install codebuddy and ensure it's on PATH, or set CODEBUDDY_CODE_PATH.",
|
|
);
|
|
} else {
|
|
emitter.emitError(classified.message || "CodeBuddy turn failed");
|
|
}
|
|
return { sessionId };
|
|
} finally {
|
|
removeAbortListener?.();
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Model listing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Map CodeBuddy SDK ModelInfo[] → renderer preset shape {id, name, description}.
|
|
*
|
|
* The SDK's ModelInfo type declares {value, displayName, description}, but the
|
|
* CLI's `supportedModels()` control response actually returns {id, name} at
|
|
* runtime (verified against codebuddy-code: e.g. {id:"glm-5.1", name:"GLM-5.1"}).
|
|
* The type and the wire shape disagree, so accept all three id keys
|
|
* (id / modelId / value); otherwise every model is filtered out and the UI
|
|
* silently falls back to curated presets even when the fetch succeeds.
|
|
*/
|
|
function mapCodebuddyModels(models) {
|
|
if (!Array.isArray(models)) return [];
|
|
return models
|
|
.map((m) => {
|
|
if (!m) return null;
|
|
const id = m.id || m.modelId || m.value;
|
|
if (!id) return null;
|
|
return attachCodebuddyThinkingLevels({
|
|
id,
|
|
name: m.name || m.displayName || id,
|
|
description: m.description,
|
|
});
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
/**
|
|
* Fetch available CodeBuddy models via the SDK control channel. Opens a
|
|
* streaming (idle) session so no turn is billed, asks supportedModels(), then
|
|
* tears down. Returns [] on failure (caller falls back to curated presets).
|
|
* @param {object} args
|
|
* @param {string} [args.pathToCodebuddyCode]
|
|
* @param {object} [args.env]
|
|
* @param {Function} [args.queryFn] inject query() for tests
|
|
*/
|
|
async function listCodebuddyModels({
|
|
pathToCodebuddyCode,
|
|
env,
|
|
queryFn,
|
|
abortController,
|
|
signal,
|
|
}) {
|
|
const externalSignal = signal || abortController?.signal;
|
|
if (externalSignal?.aborted) return [];
|
|
let query = queryFn;
|
|
if (!query) {
|
|
let sdk;
|
|
try { sdk = await import("@tencent-ai/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();
|
|
}
|
|
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: {
|
|
pathToCodebuddyCode,
|
|
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" ? mapCodebuddyModels(result.models) : [];
|
|
} catch {
|
|
return [];
|
|
} finally {
|
|
if (externalSignal) externalSignal.removeEventListener("abort", forwardAbort);
|
|
queryAbortController.abort();
|
|
try { void Promise.resolve(q?.return?.(undefined)).catch(() => {}); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hooks (SDK 0.3.230 lifecycle callbacks)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build SDK hooks option from an emitter. Registers PreToolUse / PostToolUse /
|
|
* PostToolUseFailure / SessionEnd / Notification hooks that forward events to
|
|
* the renderer via the existing emitter event channel.
|
|
* @param {object} emitter createStreamEmitter(...)
|
|
* @returns {object} hooks map suitable for Options.hooks
|
|
*/
|
|
function isAllowedCodebuddySkillsBash(command, allowedCliCommandPrefix) {
|
|
const text = String(command || "").trim();
|
|
const prefix = String(allowedCliCommandPrefix || "").trim();
|
|
if (!text || !prefix) return false;
|
|
if (text !== prefix && !text.startsWith(`${prefix} `)) return false;
|
|
return isLikelyNetcattyCliShellCommand(text);
|
|
}
|
|
|
|
function buildCodebuddyHooks(
|
|
emitter,
|
|
{ toolIntegrationMode, additionalHooks, allowedCliCommandPrefix } = {},
|
|
) {
|
|
const makeHook = (hookEventName, extract) => ([{
|
|
hooks: [async (input, _toolUseID, _opts) => {
|
|
try {
|
|
emitter.emitEvent({ type: "hook", hookEvent: hookEventName, ...extract(input) });
|
|
} catch { /* best effort — never block the turn */ }
|
|
return { continue: true };
|
|
}],
|
|
}]);
|
|
|
|
const hooks = {
|
|
PreToolUse: [{
|
|
hooks: [async (input, _toolUseID, _opts) => {
|
|
try {
|
|
emitter.emitEvent({
|
|
type: "hook",
|
|
hookEvent: "PreToolUse",
|
|
toolName: input.tool_name,
|
|
toolInput: input.tool_input,
|
|
toolUseId: input.tool_use_id,
|
|
});
|
|
} catch { /* best effort — never block the turn */ }
|
|
if (
|
|
toolIntegrationMode === "skills" &&
|
|
input.tool_name === "Bash" &&
|
|
(
|
|
input.tool_input?.run_in_background === true ||
|
|
!isAllowedCodebuddySkillsBash(
|
|
input.tool_input?.command,
|
|
allowedCliCommandPrefix,
|
|
)
|
|
)
|
|
) {
|
|
return {
|
|
continue: true,
|
|
decision: "block",
|
|
reason:
|
|
"Only Netcatty CLI commands are allowed in Skills mode. " +
|
|
"Use the netcatty-tool-cli command prefix provided by the host.",
|
|
};
|
|
}
|
|
return { continue: true };
|
|
}],
|
|
}],
|
|
PostToolUse: makeHook("PostToolUse", (input) => ({
|
|
toolName: input.tool_name,
|
|
toolInput: input.tool_input,
|
|
toolUseId: input.tool_use_id,
|
|
})),
|
|
PostToolUseFailure: makeHook("PostToolUseFailure", (input) => ({
|
|
toolName: input.tool_name,
|
|
toolInput: input.tool_input,
|
|
toolUseId: input.tool_use_id,
|
|
error: input.error,
|
|
})),
|
|
SessionEnd: makeHook("SessionEnd", (input) => ({
|
|
reason: input.reason,
|
|
})),
|
|
Notification: makeHook("Notification", (input) => ({
|
|
message: input.message,
|
|
title: input.title,
|
|
notificationType: input.notification_type,
|
|
})),
|
|
};
|
|
for (const [eventName, matchers] of Object.entries(additionalHooks || {})) {
|
|
if (!Array.isArray(matchers) || matchers.length === 0) continue;
|
|
hooks[eventName] = [...(hooks[eventName] || []), ...matchers];
|
|
}
|
|
return hooks;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Elicitation handler (SDK 0.3.230 — interactive confirmations)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Build an elicitation handler that forwards create/complete events to the
|
|
* renderer and waits for the user's decision via a pending-response map.
|
|
* @param {object} emitter createStreamEmitter(...)
|
|
* @param {Map} pendingMap Map<elicitationId, { resolve, reject, chatSessionId }> —
|
|
* shared with the IPC response handler so it can resolve waiting promises.
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.chatSessionId] chat scope, so cleanup can cancel
|
|
* pending elicitations when the chat session is torn down.
|
|
* @returns {object} ElicitationHandler
|
|
*/
|
|
function buildCodebuddyElicitation(emitter, pendingMap, { chatSessionId } = {}) {
|
|
const scopedId = (protocolElicitationId) => {
|
|
const rawId = String(protocolElicitationId || "");
|
|
if (!rawId) return "";
|
|
return chatSessionId
|
|
? `codebuddy:${encodeURIComponent(String(chatSessionId))}:${encodeURIComponent(rawId)}`
|
|
: rawId;
|
|
};
|
|
return {
|
|
async create(request, { signal } = {}) {
|
|
if (signal?.aborted) {
|
|
return { action: "cancel" };
|
|
}
|
|
const protocolElicitationId = request?._meta?.["codebuddy.ai"]?.elicitationId
|
|
|| `elicitation_${randomUUID()}`;
|
|
const elicitationId = scopedId(protocolElicitationId);
|
|
|
|
const previous = pendingMap.get(elicitationId);
|
|
if (previous) {
|
|
pendingMap.delete(elicitationId);
|
|
previous.resolve({ action: "cancel" });
|
|
}
|
|
|
|
return await new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const cleanup = () => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
if (pendingMap.get(elicitationId) === pending) {
|
|
pendingMap.delete(elicitationId);
|
|
}
|
|
};
|
|
const finish = (response) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
resolve(response);
|
|
};
|
|
const fail = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
const onAbort = () => finish({ action: "cancel" });
|
|
const pending = { resolve: finish, reject: fail, chatSessionId };
|
|
pendingMap.set(elicitationId, pending);
|
|
|
|
if (signal) {
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
if (signal.aborted) {
|
|
onAbort();
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
emitter.emitEvent({ type: "elicitation-create", elicitationId, request });
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
},
|
|
complete(notification) {
|
|
const protocolElicitationId = String(notification?.elicitationId || "");
|
|
const elicitationId = scopedId(protocolElicitationId);
|
|
const pending = elicitationId ? pendingMap.get(elicitationId) : null;
|
|
if (pending) {
|
|
pendingMap.delete(elicitationId);
|
|
pending.resolve({ action: "cancel" });
|
|
}
|
|
emitter.emitEvent({
|
|
type: "elicitation-complete",
|
|
notification: {
|
|
...notification,
|
|
elicitationId,
|
|
},
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP server status & account info (SDK 0.3.230)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Query MCP server connection status via the SDK control channel.
|
|
* Returns [] on failure.
|
|
*/
|
|
async function getCodebuddyMcpStatus({ pathToCodebuddyCode, env, queryFn }) {
|
|
let query = queryFn;
|
|
if (!query) {
|
|
let sdk;
|
|
try { sdk = await import("@tencent-ai/agent-sdk"); } catch { return []; }
|
|
query = sdk.query;
|
|
}
|
|
const abortController = new AbortController();
|
|
async function* idleInput() {
|
|
await new Promise((resolve) => {
|
|
if (abortController.signal.aborted) return resolve();
|
|
abortController.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
});
|
|
}
|
|
const q = query({
|
|
prompt: idleInput(),
|
|
options: { pathToCodebuddyCode, env, abortController, includePartialMessages: false },
|
|
});
|
|
try {
|
|
return await q.mcpServerStatus();
|
|
} catch {
|
|
return [];
|
|
} finally {
|
|
abortController.abort();
|
|
try { await q.return?.(undefined); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Query CodeBuddy account info via the SDK control channel.
|
|
* Returns null on failure.
|
|
*/
|
|
async function getCodebuddyAccountInfo({ pathToCodebuddyCode, env, queryFn }) {
|
|
let query = queryFn;
|
|
if (!query) {
|
|
let sdk;
|
|
try { sdk = await import("@tencent-ai/agent-sdk"); } catch { return null; }
|
|
query = sdk.query;
|
|
}
|
|
const abortController = new AbortController();
|
|
async function* idleInput() {
|
|
await new Promise((resolve) => {
|
|
if (abortController.signal.aborted) return resolve();
|
|
abortController.signal.addEventListener("abort", () => resolve(), { once: true });
|
|
});
|
|
}
|
|
const q = query({
|
|
prompt: idleInput(),
|
|
options: { pathToCodebuddyCode, env, abortController, includePartialMessages: false },
|
|
});
|
|
try {
|
|
return await q.accountInfo();
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
abortController.abort();
|
|
try { await q.return?.(undefined); } catch { /* best effort */ }
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Plugin management (SDK 0.3.230)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function codebuddyInstallPlugin(options) {
|
|
const sdk = await import("@tencent-ai/agent-sdk");
|
|
return sdk.installPlugin(options);
|
|
}
|
|
|
|
async function codebuddyEnablePlugin(name, marketplace) {
|
|
const sdk = await import("@tencent-ai/agent-sdk");
|
|
return sdk.enablePlugin(name, marketplace);
|
|
}
|
|
|
|
async function codebuddyDisablePlugin(name, marketplace) {
|
|
const sdk = await import("@tencent-ai/agent-sdk");
|
|
return sdk.disablePlugin(name, marketplace);
|
|
}
|
|
|
|
async function codebuddyInstallMarketplace(options) {
|
|
const sdk = await import("@tencent-ai/agent-sdk");
|
|
return sdk.installMarketplace(options);
|
|
}
|
|
|
|
async function codebuddyRemoveMarketplace(options) {
|
|
const sdk = await import("@tencent-ai/agent-sdk");
|
|
return sdk.removeMarketplace(options);
|
|
}
|
|
|
|
module.exports = {
|
|
buildCodebuddyQueryOptions,
|
|
buildCodebuddyCanUseTool,
|
|
translateCodebuddyMessage,
|
|
inspectCodebuddyMessageContent,
|
|
codebuddyResultFallbackText,
|
|
classifyCodebuddySpawnError,
|
|
buildCodebuddyPromptInput,
|
|
buildCodebuddyThinkingEnv,
|
|
parseCodebuddyThinking,
|
|
toSdkMcpServers,
|
|
createCodebuddySdkMcpServer,
|
|
runCodebuddyTurn,
|
|
listCodebuddyModels,
|
|
mapCodebuddyModels,
|
|
splitCodebuddyModelSelection,
|
|
codebuddyBuiltinTools,
|
|
buildCodebuddyHooks,
|
|
isAllowedCodebuddySkillsBash,
|
|
buildCodebuddyElicitation,
|
|
getCodebuddyMcpStatus,
|
|
getCodebuddyAccountInfo,
|
|
codebuddyInstallPlugin,
|
|
codebuddyEnablePlugin,
|
|
codebuddyDisablePlugin,
|
|
codebuddyInstallMarketplace,
|
|
codebuddyRemoveMarketplace,
|
|
UI_DISALLOWED_TOOLS,
|
|
MCP_MODE_TOOLS,
|
|
SKILLS_MODE_TOOLS,
|
|
};
|