Merge remote-tracking branch 'origin/codebuddy' into codebuddy

This commit is contained in:
lengyuqu
2026-06-08 19:02:54 +08:00
285 changed files with 18918 additions and 6494 deletions

View File

@@ -1,183 +0,0 @@
/**
* Agent Output Parser
*
* Parses JSON Lines output from `codex exec --json` and similar structured
* agent output into display-friendly text segments.
*/
export interface AgentOutputSegment {
type: 'thinking' | 'text' | 'command' | 'command_output' | 'file_change' | 'plan' | 'error' | 'usage';
content: string;
}
/**
* Try to parse a single line of agent output.
* Returns structured segment(s) if it's a recognized JSON event,
* or null if it's not JSON / not recognized (caller should treat as plain text).
*/
export function parseAgentJsonLine(line: string): AgentOutputSegment[] | null {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('{')) return null;
let event: Record<string, unknown>;
try {
event = JSON.parse(trimmed);
} catch {
return null;
}
if (!event.type) return null;
const type = event.type as string;
const item = event.item as Record<string, unknown> | undefined;
// thread.started / turn.started — skip silently
if (type === 'thread.started' || type === 'turn.started') {
return [];
}
// turn.completed — show token usage
if (type === 'turn.completed') {
const usage = event.usage as { input_tokens?: number; output_tokens?: number } | undefined;
if (usage) {
return [{
type: 'usage',
content: `tokens: ${usage.input_tokens ?? '?'} in / ${usage.output_tokens ?? '?'} out`,
}];
}
return [];
}
// error
if (type === 'error' || type === 'turn.failed') {
const msg = (event.message as string)
|| ((event.error as Record<string, unknown>)?.message as string)
|| JSON.stringify(event);
return [{ type: 'error', content: msg }];
}
// item events
if (type.startsWith('item.') && item) {
return parseItemEvent(type, item);
}
return null;
}
function parseItemEvent(
eventType: string,
item: Record<string, unknown>,
): AgentOutputSegment[] {
const itemType = item.type as string;
// reasoning (thinking)
if (itemType === 'reasoning') {
if (eventType !== 'item.completed') return [];
const text = item.text as string || '';
if (!text.trim()) return [];
return [{ type: 'thinking', content: text }];
}
// agent_message (final response text)
if (itemType === 'agent_message') {
if (eventType !== 'item.completed') return [];
const text = item.text as string || '';
if (!text.trim()) return [];
return [{ type: 'text', content: text }];
}
// command_execution
if (itemType === 'command_execution') {
const segments: AgentOutputSegment[] = [];
const command = item.command as string || '';
const output = item.aggregated_output as string || '';
const exitCode = item.exit_code as number | null;
if (eventType === 'item.started' && command) {
segments.push({ type: 'command', content: command });
}
if (eventType === 'item.completed') {
if (command) {
segments.push({ type: 'command', content: command });
}
if (output.trim()) {
segments.push({ type: 'command_output', content: output.trim() });
}
if (exitCode !== null && exitCode !== 0) {
segments.push({ type: 'error', content: `exit code: ${exitCode}` });
}
}
return segments;
}
// file_change
if (itemType === 'file_change') {
if (eventType !== 'item.completed') return [];
const changes = item.changes as Array<{ path: string; kind: string }> | undefined;
if (!changes?.length) return [];
const lines = changes.map(c => `${c.kind}: ${c.path}`).join('\n');
return [{ type: 'file_change', content: lines }];
}
// todo_list / plan
if (itemType === 'todo_list') {
const items = item.items as Array<{ text: string; completed: boolean }> | undefined;
if (!items?.length) return [];
const lines = items.map(t => `${t.completed ? '✓' : '○'} ${t.text}`).join('\n');
return [{ type: 'plan', content: lines }];
}
// mcp_tool_call
if (itemType === 'mcp_tool_call') {
const tool = item.tool as string || 'unknown';
const server = item.server as string || '';
if (eventType === 'item.started') {
return [{ type: 'command', content: `[MCP] ${server}/${tool}` }];
}
if (eventType === 'item.completed') {
const result = item.result as Record<string, unknown> | null;
const error = item.error as string | null;
if (error) {
return [{ type: 'error', content: `MCP ${tool}: ${error}` }];
}
if (result) {
const content = (result.content as Array<{ text?: string }>) || [];
const text = content.map(c => c.text || '').filter(Boolean).join('\n');
if (text) return [{ type: 'command_output', content: text }];
}
}
return [];
}
return [];
}
/**
* Format AgentOutputSegments into markdown text for display.
*/
export function formatSegmentsAsMarkdown(segments: AgentOutputSegment[]): string {
return segments.map(seg => {
switch (seg.type) {
case 'thinking':
return `> **Thinking:** ${seg.content}\n\n`;
case 'text':
return seg.content + '\n\n';
case 'command':
return `\`\`\`bash\n$ ${seg.content}\n\`\`\`\n\n`;
case 'command_output':
return `\`\`\`\n${seg.content}\n\`\`\`\n\n`;
case 'file_change':
return `**Files changed:**\n\`\`\`\n${seg.content}\n\`\`\`\n\n`;
case 'plan':
return `**Plan:**\n${seg.content}\n\n`;
case 'error':
return `**Error:** ${seg.content}\n\n`;
case 'usage':
return `---\n*${seg.content}*\n`;
default:
return seg.content;
}
}).join('');
}

View File

@@ -0,0 +1,289 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { ModelMessage } from "ai";
import {
buildCompactedMessages,
estimateModelMessagesTokens,
estimateUnknownTokens,
findSafeCompactionSplitIndex,
formatMessagesForCompaction,
prepareContextCompaction,
resolveContextWindow,
shouldCompactContext,
} from "./contextCompaction.ts";
test("shouldCompactContext waits until the prompt approaches the context window", () => {
assert.equal(shouldCompactContext({ promptTokens: 70, contextWindow: 100 }), false);
assert.equal(shouldCompactContext({ promptTokens: 85, contextWindow: 100 }), true);
});
test("findSafeCompactionSplitIndex keeps recent messages intact", () => {
const messages: ModelMessage[] = [
{ role: "user", content: "old 1" },
{ role: "assistant", content: "old 2" },
{ role: "user", content: "recent 1" },
{ role: "assistant", content: "recent 2" },
];
assert.equal(findSafeCompactionSplitIndex(messages, 2), 2);
});
test("findSafeCompactionSplitIndex avoids orphaning a tool result", () => {
const messages: ModelMessage[] = [
{ role: "user", content: "old" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "run_command",
input: { command: "pwd" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "run_command",
output: { type: "text", value: "/tmp" },
},
],
},
{ role: "user", content: "recent" },
{ role: "assistant", content: "answer" },
];
assert.equal(findSafeCompactionSplitIndex(messages, 3), 1);
});
test("buildCompactedMessages places the summary before recent messages", () => {
const recentMessages: ModelMessage[] = [
{ role: "user", content: "what next?" },
];
const compacted = buildCompactedMessages({
summary: "Earlier work is summarized here.",
recentMessages,
});
assert.deepEqual(compacted, [
{
role: "user",
content: "[Previous conversation summary]\n\nEarlier work is summarized here.\n\n[Continue with the recent messages below.]",
},
{
role: "assistant",
content: "I understand the previous conversation summary and will continue from the recent messages.",
},
{ role: "user", content: "what next?" },
]);
});
test("prepareContextCompaction summarizes old messages and returns compacted context", async () => {
const messages: ModelMessage[] = [
{ role: "user", content: "old ".repeat(40) },
{ role: "assistant", content: "older ".repeat(40) },
{ role: "user", content: "recent question" },
{ role: "assistant", content: "recent answer" },
];
const result = await prepareContextCompaction({
messages,
contextWindow: 100,
protectRecentMessages: 2,
summarize: async (messagesToSummarize) => {
assert.deepEqual(messagesToSummarize, messages.slice(0, 2));
return "Summary of old messages.";
},
});
assert.equal(result.didCompact, true);
assert.equal(result.summary, "Summary of old messages.");
assert.deepEqual(result.messages.slice(-2), messages.slice(-2));
});
test("prepareContextCompaction includes reserved request tokens in the compaction check", async () => {
const messages: ModelMessage[] = [
{ role: "user", content: "short prompt" },
{ role: "assistant", content: "short answer" },
{ role: "user", content: "recent question" },
];
const result = await prepareContextCompaction({
messages,
contextWindow: 40,
reservedTokens: estimateUnknownTokens("large system prompt ".repeat(20)),
protectRecentMessages: 1,
summarize: async (messagesToSummarize) => {
assert.deepEqual(messagesToSummarize, messages.slice(0, 2));
return "System prompt forced compaction.";
},
});
assert.equal(result.didCompact, true);
assert.equal(result.summary, "System prompt forced compaction.");
});
test("prepareContextCompaction summarizes older tool results instead of dropping them first", async () => {
const messages: ModelMessage[] = [
{ role: "user", content: "check disk usage" },
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-1",
toolName: "run_command",
input: { command: "df -h" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "run_command",
output: { type: "text", value: "/dev/disk1 81% full" },
},
],
},
{ role: "assistant", content: "Disk is 81% full." },
{ role: "user", content: "old follow-up ".repeat(80) },
{ role: "assistant", content: "old answer ".repeat(80) },
{ role: "user", content: "recent question" },
{ role: "assistant", content: "recent answer" },
];
const result = await prepareContextCompaction({
messages,
contextWindow: 120,
protectRecentMessages: 2,
summarize: async (messagesToSummarize) => {
assert.match(formatMessagesForCompaction(messagesToSummarize), /81% full/);
return "Earlier disk check showed /dev/disk1 was 81% full.";
},
});
assert.equal(result.didCompact, true);
assert.match(result.messages[0]?.content as string, /81% full/);
});
test("formatMessagesForCompaction redacts image and file payloads", () => {
const imagePayload = "iVBORw0KGgo".repeat(200);
const filePayload = "JVBERi0xLjQK".repeat(200);
const formatted = formatMessagesForCompaction([
{
role: "user",
content: [
{ type: "text", text: "Please inspect these attachments." },
{
type: "image",
image: imagePayload,
mediaType: "image/png",
},
{
type: "file",
data: filePayload,
filename: "report.pdf",
mediaType: "application/pdf",
},
],
},
]);
assert.match(formatted, /Please inspect these attachments/);
assert.match(formatted, /redacted image payload/);
assert.match(formatted, /mediaType=image\/png/);
assert.match(formatted, /redacted file payload/);
assert.match(formatted, /filename=report\.pdf/);
assert.doesNotMatch(formatted, new RegExp(imagePayload.slice(0, 40)));
assert.doesNotMatch(formatted, new RegExp(filePayload.slice(0, 40)));
});
test("formatMessagesForCompaction keeps non-attachment data fields", () => {
const formatted = formatMessagesForCompaction([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "read_json",
output: {
type: "json",
value: {
data: {
host: "prod-1",
status: "healthy",
},
},
},
},
],
},
]);
assert.match(formatted, /prod-1/);
assert.match(formatted, /healthy/);
assert.doesNotMatch(formatted, /redacted data payload/);
});
test("estimateModelMessagesTokens counts multimodal and tool content", () => {
const tokens = estimateModelMessagesTokens([
{ role: "user", content: [{ type: "text", text: "hello world" }] },
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "run_command",
output: { type: "text", value: "result text" },
},
],
},
]);
assert.ok(tokens >= 5);
});
test("resolveContextWindow prefers manual override, then fetched model metadata, then default", () => {
assert.equal(
resolveContextWindow({
provider: {
contextWindow: 262144,
modelContextWindows: { "qwen/test": 131072 },
},
modelId: "qwen/test",
defaultContextWindow: 128000,
}),
262144,
);
assert.equal(
resolveContextWindow({
provider: {
modelContextWindows: { "qwen/test": 131072 },
},
modelId: "qwen/test",
defaultContextWindow: 128000,
}),
131072,
);
assert.equal(
resolveContextWindow({
provider: {},
modelId: "unknown",
defaultContextWindow: 128000,
}),
128000,
);
});

View File

@@ -0,0 +1,316 @@
import type { ModelMessage } from "ai";
import type { ProviderConfig } from "./types";
const DEFAULT_COMPACTION_RATIO = 0.85;
const TOKEN_CHARS = 4;
const REDACTED_PAYLOAD_PREVIEW_CHARS = 80;
export const DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000;
export const DEFAULT_PROTECT_RECENT_MESSAGES = 10;
export const CONTEXT_COMPACTION_SYSTEM_PROMPT = `You are summarizing a long Netcatty agent conversation so it can continue without exceeding the model context window.
Create a concise but complete summary that preserves:
- the user's current goal and requirements
- important decisions and constraints
- terminal hosts, paths, commands, files, errors, and results that still matter
- what has already been tried
- unresolved tasks or blockers
Do not add new advice. Only summarize what happened.`;
export interface ShouldCompactContextInput {
promptTokens: number;
contextWindow: number;
thresholdRatio?: number;
}
export interface PrepareContextCompactionInput {
messages: ModelMessage[];
contextWindow?: number;
reservedTokens?: number;
thresholdRatio?: number;
protectRecentMessages?: number;
summarize: (messagesToSummarize: ModelMessage[]) => Promise<string>;
}
export interface PrepareContextCompactionResult {
messages: ModelMessage[];
summary?: string;
didCompact: boolean;
}
export interface ResolveContextWindowInput {
provider?: Pick<ProviderConfig, "contextWindow" | "modelContextWindows"> | null;
modelId?: string | null;
defaultContextWindow?: number;
}
export function shouldCompactContext({
promptTokens,
contextWindow,
thresholdRatio = DEFAULT_COMPACTION_RATIO,
}: ShouldCompactContextInput): boolean {
if (contextWindow <= 0) return false;
return promptTokens >= contextWindow * thresholdRatio;
}
export function resolveContextWindow({
provider,
modelId,
defaultContextWindow = DEFAULT_CONTEXT_WINDOW_TOKENS,
}: ResolveContextWindowInput): number {
const manual = sanitizeContextWindow(provider?.contextWindow);
if (manual != null) return manual;
const discovered = modelId ? sanitizeContextWindow(provider?.modelContextWindows?.[modelId]) : null;
if (discovered != null) return discovered;
return defaultContextWindow;
}
export function sanitizeContextWindow(value: unknown): number | undefined {
const num = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(num) || num <= 0) return undefined;
return Math.max(1, Math.round(num));
}
export function estimateModelMessagesTokens(messages: ModelMessage[]): number {
const chars = messages.reduce((total, message) => {
return total + estimateUnknownChars(message.role) + estimateUnknownChars(message.content);
}, 0);
return Math.ceil(chars / TOKEN_CHARS);
}
export function estimateUnknownTokens(value: unknown): number {
return Math.ceil(estimateUnknownChars(value) / TOKEN_CHARS);
}
export function findSafeCompactionSplitIndex(
messages: ModelMessage[],
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
): number {
let splitAt = Math.max(0, messages.length - protectRecentMessages);
while (splitAt > 0 && startsWithToolResult(messages[splitAt])) {
splitAt -= 1;
}
while (splitAt > 0 && endsWithToolCall(messages[splitAt - 1])) {
splitAt -= 1;
}
return splitAt;
}
export function buildCompactedMessages({
summary,
recentMessages,
}: {
summary: string;
recentMessages: ModelMessage[];
}): ModelMessage[] {
return [
{
role: "user",
content: `[Previous conversation summary]\n\n${summary.trim()}\n\n[Continue with the recent messages below.]`,
},
{
role: "assistant",
content: "I understand the previous conversation summary and will continue from the recent messages.",
},
...recentMessages,
];
}
export async function prepareContextCompaction({
messages,
contextWindow = DEFAULT_CONTEXT_WINDOW_TOKENS,
reservedTokens = 0,
thresholdRatio,
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
summarize,
}: PrepareContextCompactionInput): Promise<PrepareContextCompactionResult> {
const promptTokens = estimateModelMessagesTokens(messages) + Math.max(0, Math.ceil(reservedTokens));
if (!shouldCompactContext({ promptTokens, contextWindow, thresholdRatio })) {
return { messages, didCompact: false };
}
const splitAt = findSafeCompactionSplitIndex(messages, protectRecentMessages);
const oldMessages = messages.slice(0, splitAt);
const recentMessages = messages.slice(splitAt);
if (oldMessages.length === 0) {
return { messages, didCompact: false };
}
const summary = (await summarize(oldMessages)).trim();
if (!summary) {
return { messages, didCompact: false };
}
return {
messages: buildCompactedMessages({ summary, recentMessages }),
summary,
didCompact: true,
};
}
export function formatMessagesForCompaction(messages: ModelMessage[]): string {
return messages
.map((message, index) => {
return `<message index="${index + 1}" role="${escapeXml(String(message.role))}">\n${escapeXml(formatMessageContent(message.content))}\n</message>`;
})
.join("\n\n");
}
export function keepRecentContextMessages(
messages: ModelMessage[],
protectRecentMessages = DEFAULT_PROTECT_RECENT_MESSAGES,
): ModelMessage[] {
const splitAt = findSafeCompactionSplitIndex(messages, protectRecentMessages);
return messages.slice(splitAt);
}
function estimateUnknownChars(value: unknown): number {
if (value == null) return 0;
if (typeof value === "string") return value.length;
if (typeof value === "number" || typeof value === "boolean") return String(value).length;
if (Array.isArray(value)) return value.reduce((total, part) => total + estimateUnknownChars(part), 0);
if (typeof value === "object") {
const record = value as Record<string, unknown>;
let total = 0;
for (const [key, entry] of Object.entries(record)) {
total += key.length + estimateUnknownChars(entry);
}
return total;
}
return String(value).length;
}
function startsWithToolResult(message: ModelMessage | undefined): boolean {
if (!message || message.role !== "tool") return false;
if (!Array.isArray(message.content)) return true;
return message.content.some((part) => {
return part && typeof part === "object" && (part as { type?: string }).type === "tool-result";
});
}
function endsWithToolCall(message: ModelMessage | undefined): boolean {
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return false;
return message.content.some((part) => {
return part && typeof part === "object" && (part as { type?: string }).type === "tool-call";
});
}
function formatMessageContent(content: ModelMessage["content"]): string {
if (typeof content === "string") return content;
return JSON.stringify(sanitizeContentForCompaction(content), null, 2);
}
function sanitizeContentForCompaction(content: Exclude<ModelMessage["content"], string>): unknown {
if (!Array.isArray(content)) return sanitizeUnknownForCompaction(content);
return content.map((part) => sanitizeContentPartForCompaction(part));
}
function sanitizeContentPartForCompaction(part: unknown): unknown {
if (!isRecord(part)) return sanitizeUnknownForCompaction(part);
if (part.type === "image") {
const sanitized = sanitizeRecordForCompaction(part);
return {
...sanitized,
image: describeRedactedPayload(part.image, {
label: "image",
mediaType: typeof part.mediaType === "string" ? part.mediaType : undefined,
}),
};
}
if (part.type === "file") {
const sanitized = sanitizeRecordForCompaction(part);
return {
...sanitized,
data: describeRedactedPayload(part.data, {
label: "file",
mediaType: typeof part.mediaType === "string" ? part.mediaType : undefined,
filename: typeof part.filename === "string" ? part.filename : undefined,
}),
};
}
return sanitizeUnknownForCompaction(part);
}
function sanitizeRecordForCompaction(value: Record<string, unknown>): Record<string, unknown> {
const sanitized: Record<string, unknown> = {};
for (const [entryKey, entryValue] of Object.entries(value)) {
sanitized[entryKey] = sanitizeUnknownForCompaction(entryValue, entryKey);
}
return sanitized;
}
function sanitizeUnknownForCompaction(value: unknown, key?: string): unknown {
if (value == null) return value;
if (typeof value === "string") {
if (key === "base64Data" || key === "dataUrl" || key === "file_data") {
return describeRedactedPayload(value, { label: key });
}
return value;
}
if (typeof value === "number" || typeof value === "boolean") return value;
if (value instanceof URL) return value.toString();
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
return describeRedactedPayload(value, { label: key ?? "binary" });
}
if (Array.isArray(value)) return value.map((part) => sanitizeUnknownForCompaction(part));
if (isRecord(value)) return sanitizeRecordForCompaction(value);
return String(value);
}
function describeRedactedPayload(
value: unknown,
{
label,
filename,
mediaType,
}: {
label: string;
filename?: string;
mediaType?: string;
},
): string {
const details = [
filename ? `filename=${filename}` : undefined,
mediaType ? `mediaType=${mediaType}` : undefined,
describePayloadSize(value),
typeof value === "string" ? describeStringPreview(value) : undefined,
].filter(Boolean);
return `[redacted ${label} payload${details.length ? `: ${details.join(", ")}` : ""}]`;
}
function describePayloadSize(value: unknown): string {
if (typeof value === "string") return `${value.length} chars`;
if (value instanceof ArrayBuffer) return `${value.byteLength} bytes`;
if (ArrayBuffer.isView(value)) return `${value.byteLength} bytes`;
if (value instanceof URL) return "url";
return typeof value;
}
function describeStringPreview(value: string): string | undefined {
if (!value.startsWith("data:")) return undefined;
const commaIndex = value.indexOf(",");
const header = commaIndex >= 0 ? value.slice(0, commaIndex) : value.slice(0, REDACTED_PAYLOAD_PREVIEW_CHARS);
return `source=${header.slice(0, REDACTED_PAYLOAD_PREVIEW_CHARS)}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}

View File

@@ -1,235 +0,0 @@
import type {
ExternalAgentConfig,
} from './types';
import { parseAgentJsonLine, formatSegmentsAsMarkdown } from './agentOutputParser';
/** Callbacks for streaming external agent output */
export interface ExternalAgentCallbacks {
onTextDelta: (text: string) => void;
onError: (error: string) => void;
onDone: () => void;
}
/**
* Bridge interface matching the agent-related methods from window.netcatty
*/
interface AgentBridge {
aiSpawnAgent(
agentId: string,
command: string,
args?: string[],
env?: Record<string, string>,
options?: { closeStdin?: boolean },
): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiCloseAgentStdin(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
}
const PROMPT_PLACEHOLDER = '{prompt}';
/**
* Build the final command and args for an external agent.
*/
function buildAgentInvocation(
config: ExternalAgentConfig,
userMessage: string,
): { command: string; args: string[]; useStdin: boolean; jsonMode: boolean } {
const command = config.command;
const templateArgs = config.args || [];
const hasPlaceholder = templateArgs.some(a => a.includes(PROMPT_PLACEHOLDER));
const jsonMode = templateArgs.includes('--json');
if (hasPlaceholder) {
const args = templateArgs.map(a =>
a === PROMPT_PLACEHOLDER ? userMessage : a.replaceAll(PROMPT_PLACEHOLDER, userMessage),
);
return { command, args, useStdin: false, jsonMode };
}
return { command, args: [...templateArgs], useStdin: true, jsonMode };
}
/**
* Creates a stdout handler that parses JSON Lines (for --json mode agents)
* and converts structured events to formatted markdown text.
*
* Handles partial lines since stdout chunks can split mid-line.
*/
function createJsonLinesHandler(onText: (text: string) => void): (data: string) => void {
let lineBuffer = '';
// Track seen item IDs to avoid duplicating command blocks
// (item.started shows the command, item.completed shows command + output)
const seenCommands = new Set<string>();
return (data: string) => {
lineBuffer += data;
const lines = lineBuffer.split('\n');
// Keep the last (possibly incomplete) line in the buffer
lineBuffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
const segments = parseAgentJsonLine(line);
if (segments === null) {
// Not JSON — pass through as plain text
onText(line + '\n');
continue;
}
if (segments.length === 0) continue;
// Deduplicate command_execution: skip started if we'll get completed
const filtered = segments.filter(seg => {
if (seg.type === 'command') {
if (seenCommands.has(seg.content)) return false;
seenCommands.add(seg.content);
}
return true;
});
if (filtered.length > 0) {
const markdown = formatSegmentsAsMarkdown(filtered);
onText(markdown);
}
}
};
}
/**
* Start an external agent and send a message through it.
*/
export async function runExternalAgentTurn(
config: ExternalAgentConfig,
userMessage: string,
callbacks: ExternalAgentCallbacks,
bridge: AgentBridge | undefined,
signal?: AbortSignal,
): Promise<void> {
if (!bridge) {
callbacks.onError('Bridge not available');
return;
}
const agentId = `ext_${config.id}_${Date.now()}`;
const { command, args, useStdin, jsonMode } = buildAgentInvocation(config, userMessage);
const cleanupFns: (() => void)[] = [];
let done = false;
const finish = () => {
if (done) return;
done = true;
for (const fn of cleanupFns) {
try { fn(); } catch { /* cleanup */ }
}
callbacks.onDone();
};
// ── Set up event listeners BEFORE spawning to avoid race condition ──
// For JSON mode, parse structured events; otherwise, pass through raw text
const stdoutHandler = jsonMode
? createJsonLinesHandler((text) => { if (!done) callbacks.onTextDelta(text); })
: (data: string) => { if (!done) callbacks.onTextDelta(data); };
const unsubStdout = bridge.onAiAgentStdout(agentId, stdoutHandler);
cleanupFns.push(unsubStdout);
// Collect stderr
let stderrBuffer = '';
const unsubStderr = bridge.onAiAgentStderr(agentId, (data) => {
stderrBuffer += data;
});
cleanupFns.push(unsubStderr);
let resolveExit: (code: number | null) => void;
const exitPromise = new Promise<number | null>((resolve) => {
resolveExit = resolve;
const unsubExit = bridge.onAiAgentExit(agentId, (code) => {
resolve(code);
});
cleanupFns.push(unsubExit);
});
// Handle abort
if (signal) {
if (signal.aborted) {
finish();
return;
}
const onAbort = () => {
bridge.aiKillAgent(agentId).catch(() => {});
callbacks.onError('Cancelled');
resolveExit(null);
finish();
};
signal.addEventListener('abort', onAbort, { once: true });
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
}
// ── Spawn the process ──
const result = await bridge.aiSpawnAgent(
agentId,
command,
args,
config.env,
{ closeStdin: !useStdin },
);
if (!result.ok) {
callbacks.onError(`Failed to start ${config.name}: ${result.error}`);
finish();
return;
}
// Send the user message via stdin if needed, then close stdin (EOF)
if (useStdin) {
try {
await bridge.aiWriteToAgent(agentId, userMessage + '\n');
await bridge.aiCloseAgentStdin(agentId);
} catch (err) {
callbacks.onError(`Failed to write to agent: ${err}`);
finish();
return;
}
}
// Timeout after 5 minutes
const timeout = setTimeout(() => {
if (!done) {
bridge.aiKillAgent(agentId).catch(() => {});
callbacks.onError('Agent timeout (5 minutes)');
resolveExit(null);
finish();
}
}, 300000);
cleanupFns.push(() => clearTimeout(timeout));
// Wait for the process to exit
const exitCode = await exitPromise;
// If process exited with error and no stdout was received, report stderr
if (exitCode !== 0 && stderrBuffer.trim() && !done) {
callbacks.onError(stderrBuffer.trim());
}
finish();
}
/**
* Kill a running external agent session
*/
export async function killExternalAgent(
agentId: string,
bridge: AgentBridge | undefined,
): Promise<void> {
if (bridge) {
await bridge.aiKillAgent(agentId).catch(() => {});
}
}

View File

@@ -1,8 +1,8 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { matchesManagedAgentConfig } from './managedAgents';
import { getExternalAgentSdkBackend, matchesManagedAgentConfig } from './managedAgents';
test('managed Claude matching ignores claude-agent-acp command-only configs', () => {
test('managed Claude matching ignores legacy adapter command-only configs', () => {
assert.equal(
matchesManagedAgentConfig(
{
@@ -16,7 +16,7 @@ test('managed Claude matching ignores claude-agent-acp command-only configs', ()
);
});
test('managed Claude matching ignores claude-agent-acp adapter configs', () => {
test('managed Claude matching ignores legacy adapter configs', () => {
assert.equal(
matchesManagedAgentConfig(
{
@@ -29,3 +29,28 @@ test('managed Claude matching ignores claude-agent-acp adapter configs', () => {
false,
);
});
test('codex managed config no longer matches legacy adapter backend values', () => {
assert.equal(
matchesManagedAgentConfig({ id: 'x', command: 'codex', sdkBackend: 'codex' }, 'codex'),
true,
);
assert.equal(
matchesManagedAgentConfig({ id: 'x', command: 'other', acpCommand: 'codex-acp' }, 'codex'),
false,
);
});
test('claude managed config matches by sdk backend value', () => {
assert.equal(
matchesManagedAgentConfig({ id: 'discovered_claude', command: 'claude', sdkBackend: 'claude' }, 'claude'),
true,
);
});
test('legacy backend field is still accepted for saved settings', () => {
assert.equal(
getExternalAgentSdkBackend({ acpCommand: 'codex' }),
'codex',
);
});

View File

@@ -2,11 +2,11 @@ import type { DiscoveredAgent, ExternalAgentConfig } from './types';
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot' | 'codebuddy';
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; acpCommand: string }> = {
codex: { commandNames: ['codex', 'codex-acp'], acpCommand: 'codex-acp' },
claude: { commandNames: ['claude'], acpCommand: 'claude-agent-acp' },
copilot: { commandNames: ['copilot'], acpCommand: 'copilot' },
codebuddy: { commandNames: ['codebuddy'], acpCommand: 'codebuddy' },
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; sdkBackend: string }> = {
codex: { commandNames: ['codex'], sdkBackend: 'codex' },
claude: { commandNames: ['claude'], sdkBackend: 'claude' },
copilot: { commandNames: ['copilot'], sdkBackend: 'copilot' },
codebuddy: { commandNames: ['codebuddy'], sdkBackend: 'codebuddy' },
};
function getCommandBasename(command: string | undefined): string {
@@ -33,7 +33,7 @@ export function isSettingsManagedDiscoveredAgent(
}
export function matchesManagedAgentConfig(
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'acpCommand'>,
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'sdkBackend' | 'acpCommand'>,
agentKey: ManagedAgentKey,
): boolean {
const meta = MANAGED_AGENT_META[agentKey];
@@ -47,11 +47,17 @@ export function matchesManagedAgentConfig(
}
return (
agent.id === `discovered_${agentKey}` ||
getCommandBasename(agent.acpCommand) === meta.acpCommand ||
getExternalAgentSdkBackend(agent) === meta.sdkBackend ||
meta.commandNames.some((commandName) => basename === commandName || basename.startsWith(`${commandName}.`))
);
}
export function getExternalAgentSdkBackend(
agent: Pick<ExternalAgentConfig, 'sdkBackend' | 'acpCommand'> | undefined,
): string | undefined {
return agent?.sdkBackend || agent?.acpCommand || undefined;
}
export function getManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,

View File

@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveProviderStyle } from "./types";
import { PROVIDER_PRESETS, resolveProviderStyle } from "./types";
test("resolveProviderStyle prefers an explicit style override", () => {
assert.equal(resolveProviderStyle({ providerId: "custom", style: "anthropic" }), "anthropic");
@@ -17,7 +17,34 @@ test("resolveProviderStyle falls back to providerId for google", () => {
});
test("resolveProviderStyle treats every other providerId as the OpenAI-compatible family", () => {
for (const providerId of ["openai", "ollama", "openrouter", "custom"] as const) {
for (const providerId of ["openai", "ollama", "openrouter", "qwen", "deepseek", "kimi", "zhipu", "doubao", "mimo", "custom"] as const) {
assert.equal(resolveProviderStyle({ providerId }), "openai", `expected openai for ${providerId}`);
}
});
test("domestic provider presets include editable OpenAI-compatible base URLs", () => {
assert.equal(PROVIDER_PRESETS.qwen.defaultBaseURL, "https://dashscope.aliyuncs.com/compatible-mode/v1");
assert.equal(PROVIDER_PRESETS.deepseek.defaultBaseURL, "https://api.deepseek.com/v1");
assert.equal(PROVIDER_PRESETS.kimi.defaultBaseURL, "https://api.moonshot.ai/v1");
assert.equal(PROVIDER_PRESETS.zhipu.defaultBaseURL, "https://open.bigmodel.cn/api/paas/v4");
assert.equal(PROVIDER_PRESETS.doubao.defaultBaseURL, "https://ark.cn-beijing.volces.com/api/v3");
assert.equal(PROVIDER_PRESETS.mimo.defaultBaseURL, "https://api.xiaomimimo.com/v1");
});
test("openrouter keeps dynamic model discovery instead of a static preset list", () => {
assert.equal(PROVIDER_PRESETS.openrouter.defaultBaseURL, "https://openrouter.ai/api/v1");
assert.equal(PROVIDER_PRESETS.openrouter.modelsEndpoint, "/models");
assert.equal(PROVIDER_PRESETS.openrouter.defaultModels, undefined);
});
test("domestic provider presets expose provider-specific model suggestions", () => {
assert.equal(PROVIDER_PRESETS.qwen.defaultModels?.[0], "qwen3.7-plus");
assert.ok(PROVIDER_PRESETS.qwen.defaultModels?.includes("qwen3.7-max"));
assert.equal(PROVIDER_PRESETS.deepseek.defaultModels?.[0], "deepseek-v4-flash");
assert.ok(PROVIDER_PRESETS.kimi.defaultModels?.includes("kimi-k2.6"));
assert.ok(PROVIDER_PRESETS.zhipu.defaultModels?.includes("glm-5.1"));
assert.ok(PROVIDER_PRESETS.doubao.defaultModels?.includes("doubao-seed-2-0-pro-260215"));
assert.ok(!PROVIDER_PRESETS.doubao.defaultModels?.some((model) => model.startsWith("ep-")));
assert.ok(PROVIDER_PRESETS.mimo.defaultModels?.includes("mimo-v2.5-pro"));
assert.equal(PROVIDER_PRESETS.custom.defaultModels, undefined);
});

View File

@@ -1,11 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { formatAcpErrorForDisplay, runAcpAgentTurn } from './acpAgentAdapter';
import type { AcpAgentCallbacks } from './acpAgentAdapter';
import { formatSdkAgentErrorForDisplay, runSdkAgentTurn } from './sdkAgentAdapter';
import type { SdkAgentCallbacks } from './sdkAgentAdapter';
import type { ExternalAgentConfig } from './types';
function createCallbacks(errors: string[]): AcpAgentCallbacks {
function createCallbacks(errors: string[]): SdkAgentCallbacks {
return {
onTextDelta: () => {},
onThinkingDelta: () => {},
@@ -17,18 +17,17 @@ function createCallbacks(errors: string[]): AcpAgentCallbacks {
};
}
const acpConfig: ExternalAgentConfig = {
const sdkConfig: ExternalAgentConfig = {
id: 'agent',
name: 'Agent',
command: 'agent',
enabled: true,
acpCommand: 'agent-acp',
acpArgs: [],
sdkBackend: 'codex',
};
test('formatAcpErrorForDisplay preserves nested ACP error messages', () => {
test('formatSdkAgentErrorForDisplay preserves nested SDK agent error messages', () => {
assert.equal(
formatAcpErrorForDisplay({
formatSdkAgentErrorForDisplay({
error: {
code: 'invalid_model',
message: 'Model is not available',
@@ -38,27 +37,27 @@ test('formatAcpErrorForDisplay preserves nested ACP error messages', () => {
);
});
test('formatAcpErrorForDisplay stringifies unknown objects instead of [object Object]', () => {
test('formatSdkAgentErrorForDisplay stringifies unknown objects instead of [object Object]', () => {
assert.equal(
formatAcpErrorForDisplay({ status: 502, detail: 'Proxy failed' }),
formatSdkAgentErrorForDisplay({ status: 502, detail: 'Proxy failed' }),
'{"status":502,"detail":"Proxy failed"}',
);
});
test('formatAcpErrorForDisplay handles circular errors', () => {
test('formatSdkAgentErrorForDisplay handles circular errors', () => {
const error: Record<string, unknown> = { status: 500 };
error.self = error;
assert.equal(
formatAcpErrorForDisplay(error),
formatSdkAgentErrorForDisplay(error),
'{"status":500,"self":"[Circular]"}',
);
});
test('runAcpAgentTurn formats structured startup errors', async () => {
test('runSdkAgentTurn formats structured startup errors', async () => {
const errors: string[] = [];
const bridge: Record<string, (...args: unknown[]) => unknown> = {
aiAcpStream: async () => ({
aiSdkAgentStream: async () => ({
ok: false,
error: {
error: {
@@ -67,17 +66,17 @@ test('runAcpAgentTurn formats structured startup errors', async () => {
},
},
}),
aiAcpCancel: async () => ({ ok: true }),
onAiAcpEvent: () => () => {},
onAiAcpDone: () => () => {},
onAiAcpError: () => () => {},
aiSdkAgentCancel: async () => ({ ok: true }),
onAiSdkAgentEvent: () => () => {},
onAiSdkAgentDone: () => () => {},
onAiSdkAgentError: () => () => {},
};
await runAcpAgentTurn(
await runSdkAgentTurn(
bridge,
'request-1',
'chat-1',
acpConfig,
sdkConfig,
'hello',
createCallbacks(errors),
);
@@ -85,30 +84,30 @@ test('runAcpAgentTurn formats structured startup errors', async () => {
assert.deepEqual(errors, ['Model is not available']);
});
test('runAcpAgentTurn forwards configured ACP environment', async () => {
test('runSdkAgentTurn forwards configured SDK agent environment', async () => {
let streamArgs: unknown[] = [];
let done: (() => void) | null = null;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
aiAcpStream: async (...args: unknown[]) => {
aiSdkAgentStream: async (...args: unknown[]) => {
streamArgs = args;
queueMicrotask(() => done?.());
return { ok: true };
},
aiAcpCancel: async () => ({ ok: true }),
onAiAcpEvent: () => () => {},
onAiAcpDone: (_requestId: unknown, cb: unknown) => {
aiSdkAgentCancel: async () => ({ ok: true }),
onAiSdkAgentEvent: () => () => {},
onAiSdkAgentDone: (_requestId: unknown, cb: unknown) => {
done = cb as () => void;
return () => {};
},
onAiAcpError: () => () => {},
onAiSdkAgentError: () => () => {},
};
await runAcpAgentTurn(
await runSdkAgentTurn(
bridge,
'request-env',
'chat-env',
{
...acpConfig,
...sdkConfig,
env: { CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude' },
},
'hello',
@@ -118,13 +117,14 @@ test('runAcpAgentTurn forwards configured ACP environment', async () => {
assert.deepEqual(streamArgs.at(-1), {
CLAUDE_CODE_EXECUTABLE: '/opt/homebrew/bin/claude',
});
assert.equal(streamArgs[2], 'codex');
});
test('runAcpAgentTurn formats structured async error events', async () => {
test('runSdkAgentTurn formats structured async error events', async () => {
const errors: string[] = [];
let onError: ((error: unknown) => void) | null = null;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
aiAcpStream: async () => {
aiSdkAgentStream: async () => {
queueMicrotask(() => {
onError?.({
data: {
@@ -136,20 +136,20 @@ test('runAcpAgentTurn formats structured async error events', async () => {
});
return { ok: true };
},
aiAcpCancel: async () => ({ ok: true }),
onAiAcpEvent: () => () => {},
onAiAcpDone: () => () => {},
onAiAcpError: (_requestId: unknown, cb: unknown) => {
aiSdkAgentCancel: async () => ({ ok: true }),
onAiSdkAgentEvent: () => () => {},
onAiSdkAgentDone: () => () => {},
onAiSdkAgentError: (_requestId: unknown, cb: unknown) => {
onError = cb as (error: unknown) => void;
return () => {};
},
};
await runAcpAgentTurn(
await runSdkAgentTurn(
bridge,
'request-2',
'chat-1',
acpConfig,
sdkConfig,
'hello',
createCallbacks(errors),
);
@@ -157,11 +157,11 @@ test('runAcpAgentTurn formats structured async error events', async () => {
assert.deepEqual(errors, ['Proxy failed']);
});
test('runAcpAgentTurn formats structured stream error events', async () => {
test('runSdkAgentTurn formats structured stream error events', async () => {
const errors: string[] = [];
let onEvent: ((event: unknown) => void) | null = null;
const bridge: Record<string, (...args: unknown[]) => unknown> = {
aiAcpStream: async () => {
aiSdkAgentStream: async () => {
queueMicrotask(() => {
onEvent?.({
type: 'error',
@@ -174,20 +174,20 @@ test('runAcpAgentTurn formats structured stream error events', async () => {
});
return { ok: true };
},
aiAcpCancel: async () => ({ ok: true }),
onAiAcpEvent: (_requestId: unknown, cb: unknown) => {
aiSdkAgentCancel: async () => ({ ok: true }),
onAiSdkAgentEvent: (_requestId: unknown, cb: unknown) => {
onEvent = cb as (event: unknown) => void;
return () => {};
},
onAiAcpDone: () => () => {},
onAiAcpError: () => () => {},
onAiSdkAgentDone: () => () => {},
onAiSdkAgentError: () => () => {},
};
await runAcpAgentTurn(
await runSdkAgentTurn(
bridge,
'request-3',
'chat-1',
acpConfig,
sdkConfig,
'hello',
createCallbacks(errors),
);

View File

@@ -1,12 +1,12 @@
/**
* ACP Agent Adapter
* SDK Agent Adapter
*
* Bridges external agents that support the Agent Client Protocol (ACP)
* through IPC. The main process runs `createACPProvider` + `streamText`,
* and forwards stream events to the renderer via IPC.
* Bridges managed external agents through IPC. The main process runs the
* official SDK drivers and forwards stream events to the renderer.
*/
import type { AIToolIntegrationMode, ExternalAgentConfig } from './types';
import { getExternalAgentSdkBackend } from './managedAgents';
export interface DefaultTargetSessionHint {
sessionId: string;
@@ -21,7 +21,7 @@ export interface DefaultTargetSessionHint {
source: 'scope-target' | 'only-connected-in-scope';
}
export interface AcpAgentCallbacks {
export interface SdkAgentCallbacks {
onSessionId?: (sessionId: string) => void;
onTextDelta: (text: string) => void;
onThinkingDelta: (text: string) => void;
@@ -33,12 +33,11 @@ export interface AcpAgentCallbacks {
onDone: () => void;
}
interface AcpBridge {
aiAcpStream(
interface SdkAgentBridge {
aiSdkAgentStream(
requestId: string,
chatSessionId: string,
acpCommand: string,
acpArgs: string[],
sdkBackend: string,
prompt: string,
cwd?: string,
providerId?: string,
@@ -51,10 +50,10 @@ interface AcpBridge {
userSkillsContext?: string,
agentEnv?: Record<string, string>,
): Promise<{ ok: boolean; error?: unknown }>;
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
onAiAcpDone(requestId: string, cb: () => void): () => void;
onAiAcpError(requestId: string, cb: (error: unknown) => void): () => void;
aiSdkAgentCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
onAiSdkAgentEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
onAiSdkAgentDone(requestId: string, cb: () => void): () => void;
onAiSdkAgentError(requestId: string, cb: (error: unknown) => void): () => void;
}
interface StreamEvent {
@@ -63,8 +62,8 @@ interface StreamEvent {
}
/**
* Run an ACP agent turn.
* Sends the prompt to the main process which runs streamText() with the ACP provider.
* Run one managed SDK agent turn.
* Sends the prompt to the main process and listens for streamed events.
* Stream events are forwarded back via IPC.
*/
export interface FileAttachment {
@@ -92,7 +91,7 @@ function safeJsonStringify(value: unknown): string | null {
}
}
function formatAcpErrorValue(error: unknown, seen = new WeakSet<object>()): string {
function formatSdkAgentErrorValue(error: unknown, seen = new WeakSet<object>()): string {
if (error == null) return '';
if (typeof error === 'string') return error;
if (typeof error === 'number' || typeof error === 'boolean') return String(error);
@@ -116,7 +115,7 @@ function formatAcpErrorValue(error: unknown, seen = new WeakSet<object>()): stri
];
for (const candidate of candidates) {
const message = formatAcpErrorValue(candidate, seen).trim();
const message = formatSdkAgentErrorValue(candidate, seen).trim();
if (message && message !== '{}') {
return message;
}
@@ -125,17 +124,17 @@ function formatAcpErrorValue(error: unknown, seen = new WeakSet<object>()): stri
return safeJsonStringify(error) || String(error);
}
export function formatAcpErrorForDisplay(error: unknown): string {
return formatAcpErrorValue(error).trim() || 'Unknown error';
export function formatSdkAgentErrorForDisplay(error: unknown): string {
return formatSdkAgentErrorValue(error).trim() || 'Unknown error';
}
export async function runAcpAgentTurn(
export async function runSdkAgentTurn(
bridge: Record<string, (...args: unknown[]) => unknown>,
requestId: string,
chatSessionId: string,
config: ExternalAgentConfig,
prompt: string,
callbacks: AcpAgentCallbacks,
callbacks: SdkAgentCallbacks,
signal?: AbortSignal,
providerId?: string,
model?: string,
@@ -146,10 +145,11 @@ export async function runAcpAgentTurn(
defaultTargetSession?: DefaultTargetSessionHint,
userSkillsContext?: string,
): Promise<void> {
const acpBridge = bridge as unknown as AcpBridge;
const sdkBridge = bridge as unknown as SdkAgentBridge;
const sdkBackend = getExternalAgentSdkBackend(config);
if (!config.acpCommand) {
callbacks.onError('Agent does not support ACP protocol');
if (!sdkBackend) {
callbacks.onError('Agent has no SDK backend configured');
return;
}
@@ -168,7 +168,7 @@ export async function runAcpAgentTurn(
};
// Set up event listeners before starting stream
const unsubEvent = acpBridge.onAiAcpEvent(requestId, (event: StreamEvent) => {
const unsubEvent = sdkBridge.onAiSdkAgentEvent(requestId, (event: StreamEvent) => {
const streamFailed = handleStreamEvent(event, callbacks);
if (streamFailed) {
settle();
@@ -176,16 +176,16 @@ export async function runAcpAgentTurn(
});
cleanupFns.push(unsubEvent);
const unsubDone = acpBridge.onAiAcpDone(requestId, () => {
const unsubDone = sdkBridge.onAiSdkAgentDone(requestId, () => {
settle(() => {
callbacks.onDone();
});
});
cleanupFns.push(unsubDone);
const unsubError = acpBridge.onAiAcpError(requestId, (error: unknown) => {
const unsubError = sdkBridge.onAiSdkAgentError(requestId, (error: unknown) => {
settle(() => {
callbacks.onError(formatAcpErrorForDisplay(error));
callbacks.onError(formatSdkAgentErrorForDisplay(error));
});
});
cleanupFns.push(unsubError);
@@ -200,18 +200,17 @@ export async function runAcpAgentTurn(
if (!settle()) {
return;
}
acpBridge.aiAcpCancel(requestId, chatSessionId).catch(() => {});
sdkBridge.aiSdkAgentCancel(requestId, chatSessionId).catch(() => {});
};
signal.addEventListener('abort', onAbort, { once: true });
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
}
// Start the ACP stream in the main process
void acpBridge.aiAcpStream(
// Start the SDK stream in the main process
void sdkBridge.aiSdkAgentStream(
requestId,
chatSessionId,
config.acpCommand,
config.acpArgs || [],
sdkBackend,
prompt,
undefined, // cwd
providerId,
@@ -228,14 +227,14 @@ export async function runAcpAgentTurn(
settle(() => {
callbacks.onError(
result.error == null
? 'Failed to start ACP stream'
: formatAcpErrorForDisplay(result.error),
? 'Failed to start SDK agent stream'
: formatSdkAgentErrorForDisplay(result.error),
);
});
}
}).catch((err: unknown) => {
settle(() => {
callbacks.onError(formatAcpErrorForDisplay(err));
callbacks.onError(formatSdkAgentErrorForDisplay(err));
});
}).finally(() => {
if (settled) {
@@ -258,7 +257,7 @@ function cleanup(fns: (() => void)[]) {
* Handle a single stream event from the AI SDK fullStream.
* Events come from `streamText().fullStream` in the main process.
*/
function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks): boolean {
function handleStreamEvent(event: StreamEvent, callbacks: SdkAgentCallbacks): boolean {
switch (event.type) {
case 'text-delta': {
const text = (event.textDelta as string) || (event.delta as string) || '';
@@ -280,9 +279,9 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks): bo
}
case 'tool-call': {
const toolName = (event.toolName as string) || 'unknown';
// The Electron bridge serializes tool args as `args` (see
// shellUtils.cjs serializeStreamChunk), while direct AI SDK paths
// use `input`. Read both so either source works.
// The Electron bridge emits tool args as `args` (see sdk/emit.cjs
// toolCall), while direct AI SDK paths use `input`. Read both so
// either source works.
const input =
(event.input as Record<string, unknown>) ||
(event.args as Record<string, unknown>) ||
@@ -312,7 +311,7 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks): bo
return false;
}
case 'error': {
callbacks.onError(formatAcpErrorForDisplay(event.error));
callbacks.onError(formatSdkAgentErrorForDisplay(event.error));
return true;
}
// step-start, step-finish, etc. — ignore silently

View File

@@ -5,7 +5,7 @@
* a Promise that resolves when the user approves/rejects from the UI, or after
* a timeout (default 5 minutes) to prevent indefinite hangs.
*
* Also supports MCP/ACP tool calls from the Electron main process:
* Also supports MCP/SDK-agent tool calls from the Electron main process:
* the main process sends an IPC approval request, and we route it
* through the same listener/UI system. MCP approvals are stored in
* the same pendingApprovals map so they survive ChatMessageList
@@ -185,7 +185,7 @@ export function clearAllPendingApprovals(chatSessionId?: string): void {
}
/**
* Set up a bridge to receive MCP/ACP approval requests from the Electron main process.
* Set up a bridge to receive MCP/SDK-agent approval requests from the Electron main process.
* Subscribes to IPC events and stores them in the same pendingApprovals map,
* so the same ToolCall UI handles both SDK and MCP approvals, and approvals
* survive ChatMessageList unmount/remount cycles via replayPendingApprovals().

View File

@@ -2,7 +2,19 @@
import defaultCommandBlocklist from '../../lib/commandBlocklist.json';
import type { ProviderContinuation } from './providerContinuation';
export type AIProviderId = 'openai' | 'anthropic' | 'google' | 'ollama' | 'openrouter' | 'custom';
export type AIProviderId =
| 'openai'
| 'anthropic'
| 'google'
| 'ollama'
| 'openrouter'
| 'qwen'
| 'deepseek'
| 'kimi'
| 'zhipu'
| 'doubao'
| 'mimo'
| 'custom';
/**
* Wire-protocol family for a provider. Three are supported because every
@@ -36,6 +48,10 @@ export interface ProviderConfig {
customHeaders?: Record<string, string>;
enabled: boolean;
skipTLSVerify?: boolean; // skip TLS certificate verification (for self-signed certs)
/** User override for the model context window, in tokens. Wins over discovered model metadata. */
contextWindow?: number;
/** Context windows discovered from provider model-list metadata, keyed by model id. */
modelContextWindows?: Record<string, number>;
advancedParams?: ProviderAdvancedParams;
}
@@ -66,7 +82,10 @@ export interface ChatMessageAttachment {
base64Data: string;
mediaType: string;
filename?: string;
filePath?: string; // original filesystem path (for ACP agents to read directly)
filePath?: string; // original filesystem path, when available
terminalSelection?: boolean;
previewText?: string;
lineCount?: number;
}
export interface UploadedFile {
@@ -76,6 +95,9 @@ export interface UploadedFile {
base64Data: string;
mediaType: string;
filePath?: string;
terminalSelection?: boolean;
previewText?: string;
lineCount?: number;
}
export interface AIDraft {
@@ -200,7 +222,7 @@ export interface AgentInfo {
available: boolean;
}
// External Agent (ACP) config
// External agent config. Managed agents route through official SDK backends.
export interface ExternalAgentConfig {
id: string;
name: string;
@@ -209,8 +231,11 @@ export interface ExternalAgentConfig {
env?: Record<string, string>;
icon?: string;
enabled: boolean;
/** ACP command (e.g. 'codex-acp', 'claude-agent-acp', 'gemini --experimental-acp') */
/** SDK backend key for managed agents (claude|codex|copilot). */
sdkBackend?: string;
/** @deprecated Legacy persisted field from the pre-SDK migration. Read only for compatibility. */
acpCommand?: string;
/** @deprecated Legacy persisted field from the pre-SDK migration. */
acpArgs?: string[];
/** Internal: disabled only because the managed CLI was unavailable. */
autoDisabledUntilAvailable?: boolean;
@@ -226,9 +251,16 @@ export interface DiscoveredAgent {
path: string;
version: string;
available: boolean;
/** ACP command if agent supports ACP protocol */
/** @deprecated Legacy discovery field from the pre-SDK migration. */
acpCommand?: string;
acpArgs?: string[];
/** SDK backend key (claude|codex|copilot) — the post-migration routing value. */
sdkBackend?: 'claude' | 'codex' | 'copilot';
/** Absolute resolved CLI path (preferred over `path`). */
binPath?: string;
installed?: boolean;
authenticated?: boolean;
authSource?: string | null;
}
// Web Search types
@@ -295,13 +327,95 @@ export const DEFAULT_AI_SETTINGS: AISettings = {
maxIterations: 20,
};
export interface ProviderPreset {
name: string;
defaultBaseURL: string;
modelsEndpoint?: string;
defaultModels?: readonly string[];
}
// Provider presets for quick setup
export const PROVIDER_PRESETS: Record<AIProviderId, { name: string; defaultBaseURL: string; modelsEndpoint?: string }> = {
export const PROVIDER_PRESETS: Record<AIProviderId, ProviderPreset> = {
openai: { name: 'OpenAI', defaultBaseURL: 'https://api.openai.com/v1', modelsEndpoint: '/models' },
anthropic: { name: 'Anthropic', defaultBaseURL: 'https://api.anthropic.com', modelsEndpoint: '/v1/models' },
google: { name: 'Google AI', defaultBaseURL: 'https://generativelanguage.googleapis.com/v1beta' },
ollama: { name: 'Ollama', defaultBaseURL: 'http://localhost:11434/v1', modelsEndpoint: '/models' },
openrouter: { name: 'OpenRouter', defaultBaseURL: 'https://openrouter.ai/api/v1', modelsEndpoint: '/models' },
qwen: {
name: 'Qwen',
defaultBaseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
modelsEndpoint: '/models',
defaultModels: [
'qwen3.7-plus',
'qwen3.7-max',
'qwen3.6-plus',
'qwen3.6-flash',
'qwen3.6-max-preview',
'qwen3.5-plus',
'qwen3-coder-plus',
'qwen3-coder-flash',
'qwen-plus',
'qwen-plus-latest',
],
},
deepseek: {
name: 'DeepSeek',
defaultBaseURL: 'https://api.deepseek.com/v1',
modelsEndpoint: '/models',
defaultModels: [
'deepseek-v4-flash',
'deepseek-v4-pro',
'deepseek-chat',
'deepseek-reasoner',
],
},
kimi: {
name: 'Kimi',
defaultBaseURL: 'https://api.moonshot.ai/v1',
modelsEndpoint: '/models',
defaultModels: [
'kimi-k2.6',
'kimi-k2.5',
'moonshot-v1-128k',
'moonshot-v1-32k',
'moonshot-v1-8k',
],
},
zhipu: {
name: 'Zhipu',
defaultBaseURL: 'https://open.bigmodel.cn/api/paas/v4',
modelsEndpoint: '/models',
defaultModels: [
'glm-5.1',
'glm-5',
'glm-5-turbo',
'glm-4.7',
'glm-4.7-flash',
'glm-4.6',
'glm-4.5',
'glm-4.5-air',
],
},
doubao: {
name: 'Doubao',
defaultBaseURL: 'https://ark.cn-beijing.volces.com/api/v3',
modelsEndpoint: '/models',
defaultModels: [
'doubao-seed-2-0-pro-260215',
'doubao-seed-2-0-lite-260215',
'doubao-seed-2-0-mini-260215',
'doubao-seed-2-0-code-preview-260215',
],
},
mimo: {
name: 'Xiaomi MiMo',
defaultBaseURL: 'https://api.xiaomimimo.com/v1',
modelsEndpoint: '/models',
defaultModels: [
'mimo-v2.5-pro',
'mimo-v2.5',
],
},
custom: { name: 'Custom', defaultBaseURL: '' },
};
@@ -320,14 +434,17 @@ export const CLAUDE_MODEL_PRESETS: AgentModelPreset[] = [
{ id: 'haiku', name: 'Haiku 4.5', description: 'Fastest' },
];
// Curated codex model list (codex-sdk has no enumeration API). Mirrors the
// craft agent's `openai-codex` set. The codex driver splits "<id>/<effort>"
// into model + modelReasoningEffort, so thinkingLevels work via codex-sdk.
export const CODEX_MODEL_PRESETS: AgentModelPreset[] = [
{ id: 'gpt-5.4', name: 'GPT 5.4', description: 'Latest', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5.3-codex', name: 'Codex 5.3', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5.2-codex', name: 'Codex 5.2', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5.1-codex-max', name: 'Codex 5.1 Max', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5.1-codex-mini', name: 'Codex 5.1 Mini', description: 'Fast', thinkingLevels: ['medium', 'high'] },
{ id: 'o3', name: 'o3', description: 'Reasoning' },
{ id: 'gpt-5.5', name: 'GPT-5.5', description: 'Latest', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5.2', name: 'GPT-5.2', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5.1', name: 'GPT-5.1', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'gpt-5', name: 'GPT-5', thinkingLevels: ['low', 'medium', 'high', 'xhigh'] },
{ id: 'o4-mini', name: 'o4-mini', description: 'Fast reasoning' },
{ id: 'o3', name: 'o3', description: 'Reasoning' },
{ id: 'gpt-4o', name: 'GPT-4o' },
];
export function getAgentModelPresets(agentCommand?: string): AgentModelPreset[] {

View File

@@ -106,6 +106,7 @@ export const STORAGE_KEY_EDITOR_WORD_WRAP = 'netcatty_editor_word_wrap_v1';
export const STORAGE_KEY_SESSION_LOGS_ENABLED = 'netcatty_session_logs_enabled_v1';
export const STORAGE_KEY_SESSION_LOGS_DIR = 'netcatty_session_logs_dir_v1';
export const STORAGE_KEY_SESSION_LOGS_FORMAT = 'netcatty_session_logs_format_v1';
export const STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED = 'netcatty_session_logs_timestamps_enabled_v1';
export const STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED = 'netcatty_ssh_debug_logs_enabled_v1';
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
@@ -118,7 +119,7 @@ export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
export const STORAGE_KEY_WINDOW_OPACITY = 'netcatty_window_opacity_v1';
// Custom Terminal Themes
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';

View File

@@ -8,6 +8,7 @@ import { Host, Identity, PortForwardingRule, SSHKey, TerminalSettings } from '..
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
import { resolveBridgeKeyAuth, resolveHostAuth } from '../../domain/sshAuth';
import { resolveHostKeepalive } from '../../domain/host';
import { hasUsableProxyConfig } from '../../domain/proxyProfiles';
// Fallback matching DEFAULT_TERMINAL_SETTINGS so older call sites that don't
// thread terminalSettings still get the cloud-friendly defaults.
@@ -396,6 +397,7 @@ export const startPortForward = async (
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
command: host.proxyConfig.command,
username: host.proxyConfig.username,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
@@ -417,7 +419,7 @@ export const startPortForward = async (
}
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
hasUsableProxyConfig(jumpHost.proxyConfig);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
@@ -460,11 +462,12 @@ export const startPortForward = async (
keyId: jumpResolved.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
proxy: hasUsableProxyConfig(jumpHost.proxyConfig)
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
command: jumpHost.proxyConfig.command,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}