Compare commits

...

21 Commits

Author SHA1 Message Date
陈大猫
af074c5704 Merge pull request #578 from binaricat/fix/tool-call-duplicate-and-order
Some checks failed
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
fix: resolve tool call duplication and ordering in chat UI
2026-03-30 19:06:49 +08:00
bincxz
c60afdd8fe fix: preserve approval controls for tool calls in non-last assistant messages
When a stream error appends a new assistant message, the previous
one is no longer lastAssistantMessage. Its pending approval tool
calls were rendered as interrupted, losing approve/reject buttons.
Now they retain approval status and controls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:56:28 +08:00
bincxz
a1d05ca5b3 fix: resolve tool call duplication and ordering in chat UI
Tool calls were rendered both in the assistant message (as pending)
and in separate tool-result messages (as completed), causing
duplicates. Additionally, new pending tool calls appeared above
completed ones due to message ordering.

Fix: render completed tool calls only from tool-result messages,
and render pending tool calls after all results so they appear
at the bottom in chronological order. Unresolved tool calls from
earlier assistant messages or cancelled sessions are shown inline
as interrupted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:54:17 +08:00
陈大猫
327ca3806a Merge pull request #577 from tces1/dev
feat: add GitHub Copilot CLI agent support
2026-03-30 18:24:39 +08:00
bincxz
2f71dd3927 revert: don't override copilot acpCommand with resolved path
On Windows the resolved path may be a .cmd shim which spawn()
cannot execute without shell: true. Keep acpCommand as the bare
"copilot" from AGENT_DEFAULTS and let the system resolve it via
PATH at launch time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:16:50 +08:00
bincxz
3844edd49f fix: clean up copilot temp dir even when provider init fails
Move COPILOT_HOME temp dir cleanup before the acpProviders entry
check so it runs even if provider creation failed before the entry
was stored in the map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:57:00 +08:00
bincxz
8f97a7e81d fix: use resolved path as copilot acpCommand and add Windows home fallback
- When building managed copilot agent config, set acpCommand to the
  resolved path instead of bare "copilot" so custom paths work for
  ACP launches
- Add USERPROFILE fallback in prepareCopilotHome for Windows where
  HOME may not be set

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:48:07 +08:00
bincxz
5daf1f0d6f fix: hoist copilotConfigInfo above try block to fix ReferenceError
copilotConfigInfo was declared with let inside the try block but
referenced in the finally block for temp dir cleanup. Block scoping
caused a ReferenceError that broke list-models for Copilot agents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:38:39 +08:00
bincxz
b1a5b92ce4 fix: clean up transient copilot temp dirs and remove verbose MCP logs
- Add COPILOT_HOME cleanup in list-models finally block to prevent
  temp directory accumulation on each model fetch
- Remove verbose console.log in mcpServerBridge dispatch/connect/auth
  that fired on every MCP call for all agents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:27:18 +08:00
bincxz
c99a70831a fix: address review issues in copilot agent integration
- Fix matchesManagedAgentConfig acpCommand matching for copilot by
  using a lookup table instead of hardcoded ternary
- Remove dead nodeRuntimePath variable and unused 4th arg to
  buildMcpServerConfig
- Fix model loading useEffect double-triggering by reading
  agentModelMap via ref instead of dependency
- Add temp COPILOT_HOME cleanup in cleanupAcpProvider
- Remove dead acpForceProviderReset Set (never populated after
  stop/resume refactor)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:22:59 +08:00
bincxz
4b0468b0d2 merge: resolve conflicts with main for copilot agent support
Adapt copilot agent additions to the refactored managed agent
architecture (resolveAgentPath + buildManagedAgentState pattern).
Add copilot to ManagedAgentKey type and MANAGED_AGENT_META.
Keep main's resolveMcpServerRuntimeCommand (process.execPath +
ELECTRON_RUN_AS_NODE) over PR's runtimeCommand parameter approach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:14:45 +08:00
陈大猫
f32078f270 Merge pull request #575 from binaricat/codex/fix-codex-agent-path-and-mcp-startup
[codex] fix codex agent path detection and MCP startup
2026-03-30 17:02:06 +08:00
Eric Chan
a525c073b9 fix: matchesAgentCommand update for windows shim 2026-03-30 16:29:14 +08:00
bincxz
afceb92a55 fix: fall back to PATH search when stored CLI path is stale
When a previously stored custom path no longer exists (e.g. CLI
reinstalled to a different location), aiResolveCli now falls back
to PATH-based detection instead of returning unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:27:32 +08:00
bincxz
4822894efb refactor: eliminate circular effect dependency in managed agent consolidation
Move agent dedup/consolidation from a useEffect (that depended on
externalAgents while also setting it) into resolveAgentPath, using
setExternalAgents(prev => ...) callback form. Use a ref for
defaultAgentId to avoid dependency cycles and keep it in sync
across concurrent codex+claude resolves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:08:04 +08:00
Eric Chan
d9b51c3a50 feat: add GitHub Copilot CLI agent support 2026-03-30 15:53:08 +08:00
bincxz
15b1dba558 fix stale managed codex path reuse 2026-03-30 15:51:14 +08:00
bincxz
fd6b3930c1 fix codex managed-agent regressions 2026-03-30 15:26:44 +08:00
bincxz
53cb160a6e fix codex agent path detection and MCP startup 2026-03-30 15:04:06 +08:00
陈大猫
bb590f140d Merge pull request #574 from binaricat/fix/autocomplete-click-outside-dismiss
fix: dismiss autocomplete popup on click outside
2026-03-30 11:25:54 +08:00
bincxz
945992b80e fix: dismiss autocomplete popup on click outside
Closes #572

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:23:51 +08:00
19 changed files with 840 additions and 115 deletions

View File

@@ -1687,6 +1687,17 @@ const en: Messages = {
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.check': 'Check',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
'ai.copilot.detecting': 'Detecting...',
'ai.copilot.detected': 'Detected',
'ai.copilot.notFound': 'Not found',
'ai.copilot.path': 'Path:',
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
'ai.copilot.check': 'Check',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',

View File

@@ -1694,6 +1694,17 @@ const zhCN: Messages = {
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.check': '检查',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': '通过 ACP over stdio`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
'ai.copilot.detecting': '检测中...',
'ai.copilot.detected': '已检测到',
'ai.copilot.notFound': '未找到',
'ai.copilot.path': '路径:',
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '检查',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',

View File

@@ -21,6 +21,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AgentModelPreset,
AIPermissionMode,
AISession,
AISessionScope,
@@ -43,6 +44,20 @@ import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGa
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
agent.id,
agent.name,
agent.icon,
agent.command,
agent.acpCommand,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
return tokens.some((token) => token.includes('copilot'));
}
// -------------------------------------------------------------------
// Props
// -------------------------------------------------------------------
@@ -425,15 +440,62 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const providerDisplayName = activeProvider?.name ?? '';
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
// Agent model presets for the current external agent
const currentAgentConfig = useMemo(
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
[currentAgentId, externalAgents],
);
const isCopilotExternalAgent = useMemo(
() => isCopilotAgentConfig(currentAgentConfig),
[currentAgentConfig],
);
// Ref to read agentModelMap inside the effect without re-triggering it
// when setAgentModel updates the map (avoids double ACP spawn).
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
if (!isCopilotExternalAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
let cancelled = false;
void bridge.aiAcpListModels(
currentAgentConfig.acpCommand,
currentAgentConfig.acpArgs || [],
undefined,
undefined,
`models_${currentAgentId}`,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
const knownModelIds = new Set(result.models.map((model) => model.id));
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: result.models ?? [],
}));
const storedModelId = agentModelMapRef.current[currentAgentId];
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
if (!cancelled) {
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
}
});
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
const agentModelPresets = useMemo(
() => getAgentModelPresets(currentAgentConfig?.command),
[currentAgentConfig?.command],
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
[currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets],
);
// Per-agent model: recall last selection or use first preset as default
@@ -593,7 +655,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
model: isExternalAgent
? (selectedAgentModel || agentConfig?.name || 'external')
: (activeModelId || activeProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
});

View File

@@ -1963,6 +1963,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
onDismiss={autocompleteClosePopup}
/>,
document.body,
)

View File

@@ -11,6 +11,7 @@ type AgentLike = {
type AgentIconKey =
| 'catty'
| 'copilot'
| 'openai'
| 'claude'
| 'anthropic'
@@ -20,7 +21,7 @@ type AgentIconKey =
| 'openrouter'
| 'zed'
| 'atom'
| 'terminal'
| 'terminal'
| 'plus';
type AgentIconVisual = {
@@ -35,6 +36,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
copilot: {
src: '/ai/agents/copilot.svg',
badgeClassName: 'border-zinc-300 bg-white',
imageClassName: 'object-contain brightness-0',
},
openai: {
src: '/ai/providers/openai.svg',
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
@@ -115,6 +121,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
if (tokens.some((token) => token.includes('claude'))) {
return 'claude';
}
if (tokens.some((token) => token.includes('copilot'))) {
return 'copilot';
}
if (tokens.some((token) => token.includes('anthropic'))) {
return 'anthropic';
}
@@ -160,7 +169,8 @@ export const AgentIconBadge: React.FC<{
variant?: 'plain' | 'badge';
className?: string;
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
const iconKey = getAgentIconKey(agent);
const visual = AGENT_ICON_VISUALS[iconKey];
const badgeSize =
size === 'xs'
? 'h-4 w-4 rounded-sm'

View File

@@ -9,6 +9,10 @@ import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
import React, { useCallback, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
isSettingsManagedDiscoveredAgent,
matchesManagedAgentConfig,
} from '../../infrastructure/ai/managedAgents';
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
import AgentIconBadge from './AgentIconBadge';
import {
@@ -140,7 +144,12 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
const unconfiguredDiscovered = useMemo(
() =>
discoveredAgents.filter(
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
(da) => {
if (isSettingsManagedDiscoveredAgent(da)) {
return !externalAgents.some((ea) => matchesManagedAgentConfig(ea, da.command));
}
return !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path);
},
),
[discoveredAgents, externalAgents],
);

View File

@@ -238,8 +238,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
</MessageResponse>
)}
{/* Tool calls */}
{message.toolCalls?.map((tc) => {
{/* Pending tool calls from the *last* assistant message are rendered
after all tool-result messages (see below) for chronological order.
Unresolved tool calls from earlier or cancelled messages are shown
inline — as interrupted, or with approval controls if still pending. */}
{(message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id),
).map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
@@ -249,14 +254,12 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
: resolved === false
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
@@ -290,6 +293,33 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
);
})}
{/* Pending tool calls from the last assistant message — rendered here
(after all tool-result messages) so they appear at the bottom. */}
{lastAssistantMessage?.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
).map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
? 'pending' as const
: resolved === true
? 'approved' as const
: resolved === false
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
);
})}
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
{Array.from(pendingApprovals.entries())
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))

View File

@@ -112,6 +112,13 @@ export interface PanelBridge extends NetcattyBridge {
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiAcpListModels?: (
acpCommand: string,
acpArgs?: string[],
cwd?: string,
providerId?: string,
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}

View File

@@ -8,7 +8,7 @@
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AIPermissionMode,
AIProviderId,
@@ -16,8 +16,12 @@ import type {
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
} from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Select, SettingRow } from "../settings-ui";
@@ -38,6 +42,7 @@ import { ProviderCard } from "./ai/ProviderCard";
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { CopilotCliCard } from "./ai/CopilotCliCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
@@ -70,6 +75,54 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: storedPath ? prevAgents : otherAgents,
defaultAgentId: storedPath
? defaultAgentId
: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
// ---------------------------------------------------------------------------
// Main Tab Component
// ---------------------------------------------------------------------------
@@ -113,58 +166,44 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
copilot: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = {
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
};
}
const {
discoveredAgents,
isDiscovering,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
// Derive path info from discovery results
useEffect(() => {
if (isDiscovering) return;
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
defaultAgentIdRef.current = defaultAgentId;
const codex = discoveredAgents.find((a) => a.command === "codex");
setCodexPathInfo(
codex
? { path: codex.path, version: codex.version, available: true }
: { path: null, version: null, available: false },
);
const claude = discoveredAgents.find((a) => a.command === "claude");
setClaudePathInfo(
claude
? { path: claude.path, version: claude.version, available: true }
: { path: null, version: null, available: false },
);
}, [isDiscovering, discoveredAgents]);
// Auto-register discovered agents in externalAgents
useEffect(() => {
if (isDiscovering || discoveredAgents.length === 0) return;
setExternalAgents((prev) => {
const agentsToRegister: ExternalAgentConfig[] = [];
for (const da of discoveredAgents) {
if (da.command !== "codex" && da.command !== "claude") continue;
const agentId = `discovered_${da.command}`;
if (prev.some((ea) => ea.id === agentId)) continue;
agentsToRegister.push(enableAgent(da));
}
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
});
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
const resolveAgentPath = useCallback(async (
agentKey: ManagedAgentKey,
customPath = "",
) => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return;
if (!bridge?.aiResolveCli) return null;
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
const setInfo = agentKey === "codex"
? setCodexPathInfo
: agentKey === "claude"
? setClaudePathInfo
: setCopilotPathInfo;
const setResolving = agentKey === "codex"
? setIsResolvingCodex
: agentKey === "claude"
? setIsResolvingClaude
: setIsResolvingCopilot;
setResolving(true);
try {
@@ -174,32 +213,48 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
});
setInfo(result);
// Register/update in externalAgents if valid
if (result.available && result.path) {
const agentId = `discovered_${agentKey}`;
const defaults = AGENT_DEFAULTS[agentKey];
setExternalAgents((prev) => {
const idx = prev.findIndex((a) => a.id === agentId);
const config: ExternalAgentConfig = {
id: agentId,
command: result.path!,
enabled: true,
...defaults,
};
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], command: result.path! };
return updated;
}
return [...prev, config];
});
// Consolidate managed agent entries using the callback form of
// setExternalAgents so we never depend on externalAgents directly.
// All three agents resolve concurrently on mount — React runs
// state updater callbacks sequentially, so updating the ref inside
// ensures later calls see earlier defaultAgentId changes.
let nextDefaultId: string | null = null;
setExternalAgents((prev) => {
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
if (state.defaultAgentId !== defaultAgentIdRef.current) {
nextDefaultId = state.defaultAgentId;
defaultAgentIdRef.current = state.defaultAgentId;
}
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
});
if (nextDefaultId !== null) {
setDefaultAgentId(nextDefaultId);
}
return result;
} catch (err) {
console.error("Path resolution failed:", err);
return null;
} finally {
setResolving(false);
}
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
}, [setExternalAgents, setDefaultAgentId]);
useEffect(() => {
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
}, [resolveAgentPath]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
const customPath = agentKey === "codex"
? codexCustomPath
: agentKey === "claude"
? claudeCustomPath
: copilotCustomPath;
await resolveAgentPath(agentKey, customPath);
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
// Add a new provider from preset
const handleAddProvider = useCallback(
@@ -457,7 +512,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<CodexConnectionCard
pathInfo={codexPathInfo}
isResolvingPath={isDiscovering || isResolvingCodex}
isResolvingPath={isResolvingCodex}
customPath={codexCustomPath}
onCustomPathChange={setCodexCustomPath}
onRecheckPath={() => void handleCheckCustomPath("codex")}
@@ -483,13 +538,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<ClaudeCodeCard
pathInfo={claudePathInfo}
isResolvingPath={isDiscovering || isResolvingClaude}
isResolvingPath={isResolvingClaude}
customPath={claudeCustomPath}
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
/>
</div>
{/* -- GitHub Copilot CLI Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="copilot" size="sm" />
<h3 className="text-base font-medium">{t('ai.copilot.title')}</h3>
</div>
<CopilotCliCard
pathInfo={copilotPathInfo}
isResolvingPath={isResolvingCopilot}
customPath={copilotCustomPath}
onCustomPathChange={setCopilotCustomPath}
onRecheckPath={() => void handleCheckCustomPath("copilot")}
/>
</div>
{/* -- Default Agent Section -- */}
{agentOptions.length > 1 && (
<div className="space-y-4">
@@ -507,7 +578,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
value={defaultAgentId}
options={agentOptions}
onChange={setDefaultAgentId}
className="w-48"
className="w-64"
/>
</SettingRow>
</div>

View File

@@ -0,0 +1,87 @@
import React from "react";
import { RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const CopilotCliCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const statusText = isResolvingPath
? t('ai.copilot.detecting')
: found
? t('ai.copilot.detected')
: t('ai.copilot.notFound');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="copilot" size="sm" />
<span className="text-sm font-medium">{t('ai.copilot.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-2 leading-5">
{t('ai.copilot.description')}
</p>
</div>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>
</div>
{found ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.copilot.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
) : !isResolvingPath ? (
<div className="space-y-2">
<p className="text-xs text-amber-500">
{t('ai.copilot.notFoundHint')}
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.copilot.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.copilot.check')}
</Button>
</div>
</div>
) : null}
</div>
);
};

View File

@@ -20,7 +20,8 @@ export const ProviderIconBadge: React.FC<{
aria-hidden="true"
draggable={false}
className={cn(
"object-contain brightness-0 invert",
"object-contain",
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
size === "sm" ? "w-3 h-3" : "w-4 h-4",
)}
/>

View File

@@ -5,4 +5,5 @@ export { ProviderCard } from "./ProviderCard";
export { AddProviderDropdown } from "./AddProviderDropdown";
export { CodexConnectionCard } from "./CodexConnectionCard";
export { ClaudeCodeCard } from "./ClaudeCodeCard";
export { CopilotCliCard } from "./CopilotCliCard";
export { SafetySettings } from "./SafetySettings";

View File

@@ -82,6 +82,13 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
acpCommand: "claude-agent-acp",
acpArgs: [],
},
copilot: {
name: "GitHub Copilot CLI",
args: ["-p", "{prompt}"],
icon: "copilot",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
},
};
// ---------------------------------------------------------------------------
@@ -108,12 +115,13 @@ export function normalizeCodexBridgeError(error: unknown): string {
// Provider icon helper
// ---------------------------------------------------------------------------
export type SettingsIconId = AIProviderId | "claude";
export type SettingsIconId = AIProviderId | "claude" | "copilot";
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
openai: "/ai/providers/openai.svg",
anthropic: "/ai/providers/anthropic.svg",
claude: "/ai/agents/claude.svg",
copilot: "/ai/agents/copilot.svg",
google: "/ai/providers/google.svg",
ollama: "/ai/providers/ollama.svg",
openrouter: "/ai/providers/openrouter.svg",
@@ -124,6 +132,7 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
openai: "bg-emerald-600",
anthropic: "bg-orange-600",
claude: "bg-orange-600",
copilot: "border border-zinc-300 bg-white",
google: "bg-blue-600",
ollama: "bg-purple-600",
openrouter: "bg-pink-600",

View File

@@ -48,6 +48,8 @@ interface AutocompletePopupProps {
onRequestReposition?: () => void;
/** Offset from top of container to terminal content area (toolbar + search bar) */
searchBarOffset?: number;
/** Called when user clicks outside the popup to dismiss it */
onDismiss?: () => void;
}
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
@@ -105,7 +107,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
containerRef,
onRequestReposition,
searchBarOffset: _searchBarOffset = 30,
onDismiss,
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
const [hoveredIndex, setHoveredIndex] = useState(-1);
@@ -148,6 +152,18 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
};
}, [containerRef, onRequestReposition, visible]);
// Dismiss popup when clicking outside
useEffect(() => {
if (!visible || !onDismiss) return;
const handlePointerDown = (e: PointerEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
onDismiss();
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [visible, onDismiss]);
if (!visible || suggestions.length === 0) return null;
const bg = themeColors?.background ?? "#1e1e2e";
@@ -217,6 +233,7 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
return (
<div
ref={wrapperRef}
style={{
position: "fixed",
left: `${clampedLeft}px`,

View File

@@ -7,9 +7,11 @@
const https = require("node:https");
const http = require("node:http");
const path = require("node:path");
const { URL } = require("node:url");
const { spawn, execFileSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const fs = require("node:fs");
const { existsSync } = fs;
const mcpServerBridge = require("./mcpServerBridge.cjs");
@@ -60,7 +62,6 @@ const acpProviders = new Map();
const acpActiveStreams = new Map();
const acpRequestSessions = new Map();
const acpPendingCancelRequests = new Set();
const acpForceProviderReset = new Set();
const acpChatRuns = new Map();
// ── Provider registry (synced from renderer, keys stay encrypted) ──
@@ -141,21 +142,39 @@ function injectApiKeyIntoRequest(url, headers, providerId) {
}
function cleanupAcpProvider(chatSessionId) {
// Clean up temporary COPILOT_HOME directory regardless of whether a
// provider entry exists — prepareCopilotHome may have succeeded before
// provider creation failed.
try {
const tempDirBridge = require("./tempDirBridge.cjs");
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
if (existsSync(tempCopilotHome)) {
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
}
} catch {
// Best-effort cleanup
}
const entry = acpProviders.get(chatSessionId);
if (!entry) return;
const rootPid = entry.provider?.model?.agentProcess?.pid;
cleanupAcpProviderInstance(entry.provider, chatSessionId);
acpProviders.delete(chatSessionId);
}
function cleanupAcpProviderInstance(provider, chatSessionId = "transient") {
if (!provider) return;
const rootPid = provider?.model?.agentProcess?.pid;
const childPids = getChildProcessTreePids(rootPid);
try {
if (typeof entry.provider.forceCleanup === "function") {
entry.provider.forceCleanup();
} else if (typeof entry.provider.cleanup === "function") {
entry.provider.cleanup();
if (typeof provider.forceCleanup === "function") {
provider.forceCleanup();
} else if (typeof provider.cleanup === "function") {
provider.cleanup();
}
} catch (err) {
console.warn("[ACP] Provider cleanup failed for session", chatSessionId, err?.message || err);
}
killTrackedProcessTree(rootPid, childPids);
acpProviders.delete(chatSessionId);
}
function isActiveAcpRun(chatSessionId, requestId) {
@@ -163,9 +182,10 @@ function isActiveAcpRun(chatSessionId, requestId) {
return Boolean(activeRun && activeRun.requestId === requestId);
}
function isUnsupportedLoadSessionError(err) {
function shouldRetryFreshSession(err) {
const message = String(err?.message || err || "").toLowerCase();
return message.includes("method not found") && message.includes("session/load");
return (message.includes("method not found") && message.includes("session/load"))
|| (message.includes("resource not found") && message.includes("session") && message.includes("not found"));
}
function getChildProcessTreePids(rootPid) {
@@ -302,6 +322,127 @@ function _validateSenderImpl(event, allowSettings) {
}
}
function summarizeMcpServersForDebug(mcpServers) {
if (!Array.isArray(mcpServers)) return [];
return mcpServers.map((server) => ({
name: server?.name || "",
type: server?.type || "",
command: server?.command || "",
args: Array.isArray(server?.args) ? server.args : [],
hasEnv: Array.isArray(server?.env) ? server.env.length > 0 : false,
url: server?.url || "",
}));
}
function logAcpDebug(agentLabel, message, details) {
const prefix = `[ACP DEBUG][${agentLabel}]`;
if (details === undefined) {
console.log(prefix, message);
return;
}
try {
console.log(prefix, message, JSON.stringify(details));
} catch {
console.log(prefix, message, details);
}
}
function normalizeAgentCommandName(command) {
if (typeof command !== "string" || !command) return "";
return path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/i, "");
}
function matchesAgentCommand(command, expectedName) {
if (typeof command !== "string" || typeof expectedName !== "string") return false;
if (command.toLowerCase() === expectedName.toLowerCase()) return true;
return normalizeAgentCommandName(command) === normalizeAgentCommandName(expectedName);
}
function envPairsToObject(entries) {
if (!Array.isArray(entries)) return {};
const result = {};
for (const entry of entries) {
if (!entry || typeof entry.name !== "string") continue;
result[entry.name] = entry.value == null ? "" : String(entry.value);
}
return result;
}
function mapMcpServerToCopilotConfig(server) {
if (!server || typeof server !== "object" || !server.name) return null;
if (server.type === "stdio" || server.type === "local") {
return {
type: "local",
command: server.command || "",
args: Array.isArray(server.args) ? server.args : [],
env: envPairsToObject(server.env),
tools: ["*"],
};
}
if (server.type === "http" || server.type === "sse") {
return {
type: server.type,
url: server.url || "",
headers: envPairsToObject(server.headers),
tools: ["*"],
};
}
return null;
}
function safeReadJson(filePath) {
try {
if (!existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function prepareCopilotHome(shellEnv, mcpServers, chatSessionId) {
const tempDirBridge = require("./tempDirBridge.cjs");
const homeDir = shellEnv.HOME || process.env.HOME || process.env.USERPROFILE || "";
const realCopilotHome = shellEnv.COPILOT_HOME || path.join(homeDir, ".copilot");
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
try {
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
} catch {
// Ignore cleanup failures; mkdir/copy below will surface real issues if any.
}
fs.mkdirSync(tempCopilotHome, { recursive: true });
if (realCopilotHome && existsSync(realCopilotHome)) {
fs.cpSync(realCopilotHome, tempCopilotHome, { recursive: true });
}
const configPath = path.join(tempCopilotHome, "mcp-config.json");
const baseConfig = safeReadJson(configPath) || { mcpServers: {} };
const mergedServers = { ...(baseConfig.mcpServers || {}) };
for (const server of Array.isArray(mcpServers) ? mcpServers : []) {
const mapped = mapMcpServerToCopilotConfig(server);
if (!mapped) continue;
mergedServers[server.name] = mapped;
}
fs.writeFileSync(
configPath,
JSON.stringify({ ...baseConfig, mcpServers: mergedServers }, null, 2),
{ mode: 0o600 },
);
return {
copilotHome: tempCopilotHome,
configPath,
serverNames: Object.keys(mergedServers),
};
}
/**
* Make a streaming HTTP request and forward SSE events back to renderer
*/
@@ -1253,6 +1394,15 @@ function registerHandlers(ipcMain) {
args: ["exec", "--full-auto", "--json", "{prompt}"],
resolveAcp: resolveCodexAcpBinaryPath,
},
{
command: "copilot",
name: "GitHub Copilot CLI",
icon: "copilot",
description: "GitHub's coding agent CLI",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
args: ["-p", "{prompt}"],
},
];
const shellEnv = await getShellEnv();
@@ -1309,6 +1459,7 @@ function registerHandlers(ipcMain) {
const { resolveAcp: _unused, ...agentInfo } = agent;
agents.push({
...agentInfo,
acpCommand: agent.command === "copilot" ? resolvedPath : agentInfo.acpCommand,
path: resolvedPath,
version,
available: true,
@@ -1327,7 +1478,9 @@ function registerHandlers(ipcMain) {
if (customPath) {
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
resolvedPath = normalizeCliPathForPlatform(customPath);
// Fall back to PATH search if the stored path no longer exists
// (e.g. CLI reinstalled to a different location).
resolvedPath = normalizeCliPathForPlatform(customPath) || resolveCliFromPath(command, shellEnv);
} else {
resolvedPath = resolveCliFromPath(command, shellEnv);
}
@@ -1521,6 +1674,7 @@ function registerHandlers(ipcMain) {
const ALLOWED_AGENT_COMMANDS = new Set([
"claude", "claude-agent-acp",
"codex", "codex-acp",
"copilot",
]);
// Spawn an external agent process
@@ -1730,6 +1884,102 @@ function registerHandlers(ipcMain) {
// ── ACP (Agent Client Protocol) streaming ──
ipcMain.handle("netcatty:ai:acp:list-models", async (event, { acpCommand, acpArgs, cwd, providerId, chatSessionId }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
let provider = null;
let copilotConfigInfo = null;
try {
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
const shellEnv = await getShellEnv();
const sessionCwd = cwd || process.cwd();
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
const apiKey = resolvedProvider?.apiKey || undefined;
const agentEnv = { ...shellEnv };
if (apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, [], chatSessionId || `models_${Date.now()}`);
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
}
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const resolvedCommand = isCodexAgent
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
: claudeAcp
? claudeAcp.command
: acpCommand;
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
provider = createACPProvider({
command: resolvedCommand,
args: resolvedArgs,
env: agentEnv,
session: {
cwd: sessionCwd,
mcpServers: [],
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
});
const sessionInfo = await provider.initSession();
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
? sessionInfo.models.availableModels
: [];
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Fetched session models", {
chatSessionId: chatSessionId || null,
currentModelId: sessionInfo?.models?.currentModelId || null,
availableModelIds: availableModels.map((modelInfo) => modelInfo?.modelId).filter(Boolean),
copilotHome: copilotConfigInfo?.copilotHome || null,
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
});
}
return {
ok: true,
currentModelId: sessionInfo?.models?.currentModelId || null,
models: availableModels.map((modelInfo) => ({
id: modelInfo?.modelId,
name: modelInfo?.name || modelInfo?.displayName || modelInfo?.modelId,
description: modelInfo?.description || undefined,
})).filter((modelInfo) => Boolean(modelInfo.id)),
};
} catch (err) {
console.error("[ACP] Failed to list models:", err?.message || err);
return { ok: false, error: err?.message || String(err) };
} finally {
try {
cleanupAcpProviderInstance(provider, chatSessionId || "transient-model-list");
} catch {
// Ignore cleanup failures for transient model-discovery providers.
}
// Clean up transient COPILOT_HOME created for model listing
if (copilotConfigInfo?.copilotHome) {
try {
fs.rmSync(copilotConfigInfo.copilotHome, { recursive: true, force: true });
} catch { /* best-effort */ }
}
}
});
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
@@ -1771,8 +2021,10 @@ function registerHandlers(ipcMain) {
const shellEnv = await getShellEnv();
if (shouldAbortStartup()) return { ok: true };
const sessionCwd = cwd || process.cwd();
const isCodexAgent = acpCommand === "codex-acp";
const isClaudeAgent = acpCommand === "claude-agent-acp";
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
// Resolve API key from providerId (decrypted in main process only)
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
@@ -1811,6 +2063,13 @@ function registerHandlers(ipcMain) {
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
const netcattyMcpConfig = mcpServerBridge.buildMcpServerConfig(mcpPort, scopedIds, chatSessionId);
mcpSnapshot.mcpServers.push(netcattyMcpConfig);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Injected Netcatty MCP server into session", {
chatSessionId,
scopedIds,
injectedServer: summarizeMcpServersForDebug([netcattyMcpConfig])[0],
});
}
} catch (err) {
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
}
@@ -1821,9 +2080,7 @@ function registerHandlers(ipcMain) {
const currentPermissionMode = mcpServerBridge.getPermissionMode();
let providerEntry = acpProviders.get(chatSessionId);
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
const shouldReuseProvider = Boolean(
!shouldForceProviderReset &&
providerEntry &&
providerEntry.acpCommand === acpCommand &&
providerEntry.cwd === sessionCwd &&
@@ -1840,6 +2097,11 @@ function registerHandlers(ipcMain) {
if (apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
let copilotConfigInfo = null;
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
}
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const resolvedCommand = isCodexAgent
@@ -1850,6 +2112,7 @@ function registerHandlers(ipcMain) {
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
const sessionMcpServers = isCopilotAgent ? [] : mcpSnapshot.mcpServers;
const provider = createACPProvider({
command: resolvedCommand,
@@ -1857,15 +2120,31 @@ function registerHandlers(ipcMain) {
env: agentEnv,
session: {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
mcpServers: sessionMcpServers,
},
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
persistSession: true,
});
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Creating ACP provider", {
requestId,
chatSessionId,
cwd: sessionCwd,
resolvedCommand,
resolvedArgs,
sessionMcpServers: summarizeMcpServersForDebug(sessionMcpServers),
copilotHome: copilotConfigInfo?.copilotHome || null,
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
copilotMcpServerNames: copilotConfigInfo?.serverNames || [],
});
}
providerEntry = {
provider,
acpCommand,
@@ -1877,15 +2156,21 @@ function registerHandlers(ipcMain) {
};
acpProviders.set(chatSessionId, providerEntry);
}
acpForceProviderReset.delete(chatSessionId);
let modelInstance = providerEntry.provider.languageModel(model || undefined);
try {
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "ACP session initialized", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
toolNames: Object.keys(providerEntry.provider.tools || {}),
});
}
if (shouldAbortStartup()) return { ok: true };
} catch (err) {
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
if (!attemptedResumeSessionId || !shouldRetryFreshSession(err)) {
throw err;
}
@@ -1901,13 +2186,22 @@ function registerHandlers(ipcMain) {
args: fallbackClaudeAcp
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [],
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
env: (() => {
const fallbackEnv = apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv };
if (isCopilotAgent) {
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
}
return fallbackEnv;
})(),
session: {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
persistSession: true,
});
@@ -1924,6 +2218,14 @@ function registerHandlers(ipcMain) {
acpProviders.set(chatSessionId, providerEntry);
modelInstance = providerEntry.provider.languageModel(model || undefined);
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "ACP session initialized after fallback", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
toolNames: Object.keys(providerEntry.provider.tools || {}),
});
}
if (shouldAbortStartup()) return { ok: true };
}
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
@@ -2042,6 +2344,9 @@ function registerHandlers(ipcMain) {
if (serialized.type === "text-delta" || serialized.type === "reasoning-delta" || serialized.type === "tool-call") {
hasContent = true;
}
if (isCopilotAgent && (serialized.type === "tool-call" || serialized.type === "tool-result" || serialized.type === "error" || serialized.type === "status")) {
logAcpDebug(agentLabel, `Stream event: ${serialized.type}`, serialized);
}
safeSend(event.sender, "netcatty:ai:acp:event", {
requestId,
event: serialized,
@@ -2057,6 +2362,13 @@ function registerHandlers(ipcMain) {
// If stream completed with zero content, likely an auth or connection issue
if (!hasContent && !abortController.signal.aborted) {
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Stream completed with no content", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
});
}
if (!isActiveAcpRun(chatSessionId, requestId)) {
return { ok: true };
}
@@ -2095,9 +2407,6 @@ function registerHandlers(ipcMain) {
acpPendingCancelRequests.delete(requestId);
const activeRun = acpChatRuns.get(chatSessionId);
if (activeRun?.requestId === requestId) {
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
cleanupAcpProvider(chatSessionId);
}
acpChatRuns.delete(chatSessionId);
}
}
@@ -2127,10 +2436,6 @@ function registerHandlers(ipcMain) {
acpPendingCancelRequests.add(effectiveRequestId);
cancelled = true;
}
if (effectiveChatSessionId) {
acpForceProviderReset.add(effectiveChatSessionId);
cleanupAcpProvider(effectiveChatSessionId);
}
// Preserve the ACP provider session on stop so the next user message can
// continue within the same persisted conversation context. Full provider
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
@@ -2143,7 +2448,6 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
acpForceProviderReset.delete(chatSessionId);
cleanupAcpProvider(chatSessionId);
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
return { ok: true };

View File

@@ -380,6 +380,7 @@ async function handleMessage(socket, line) {
if (!socket.destroyed) socket.write(response);
return;
}
console.warn("[MCP Bridge] auth/verify failed or unexpected first method", method);
// Wrong token or wrong method — reject and close
const response = JSON.stringify({
jsonrpc: "2.0",
@@ -629,6 +630,22 @@ function handleExec(params) {
// ── MCP Server Config Builder ──
function resolveMcpServerRuntimeCommand() {
const runtimeCommand = process.execPath;
const runtimeEnv = [];
if (runtimeCommand && existsSync(runtimeCommand)) {
const basename = path.basename(runtimeCommand).toLowerCase();
const isNodeBinary = basename === "node" || basename.startsWith("node.");
if (!isNodeBinary) {
runtimeEnv.push({ name: "ELECTRON_RUN_AS_NODE", value: "1" });
}
return { command: runtimeCommand, env: runtimeEnv };
}
return { command: "node", env: runtimeEnv };
}
function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
// Use provided scoped IDs, or resolve from chatSessionId, or fall back
const effectiveIds = (scopedSessionIds && scopedSessionIds.length > 0)
@@ -638,8 +655,10 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
const runtimePath = toUnpackedAsarPath(
path.join(__dirname, "..", "mcp", "netcatty-mcp-server.cjs"),
);
const runtime = resolveMcpServerRuntimeCommand();
const env = [
...runtime.env,
{ name: "NETCATTY_MCP_PORT", value: String(port) },
];
@@ -664,7 +683,7 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
return {
name: "netcatty-remote-hosts",
type: "stdio",
command: "node",
command: runtime.command,
args: [runtimePath],
env,
};

View File

@@ -1207,6 +1207,9 @@ const api = {
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images });
},
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
},
aiAcpCancel: async (requestId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
},

View File

@@ -0,0 +1,69 @@
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; acpCommand: string }> = {
codex: { commandNames: ['codex', 'codex-acp'], acpCommand: 'codex-acp' },
claude: { commandNames: ['claude', 'claude-agent-acp'], acpCommand: 'claude-agent-acp' },
copilot: { commandNames: ['copilot'], acpCommand: 'copilot' },
};
function getCommandBasename(command: string | undefined): string {
const normalized = String(command || '').trim();
if (!normalized) return '';
const parts = normalized.split(/[\\/]/);
return (parts.pop() || '').toLowerCase();
}
function isPathLikeCommand(command: string | undefined): boolean {
const normalized = String(command || '').trim();
return normalized.includes('/') || normalized.includes('\\');
}
function matchesPrimaryCliBasename(command: string | undefined, agentKey: ManagedAgentKey): boolean {
const basename = getCommandBasename(command);
return basename === agentKey || basename.startsWith(`${agentKey}.`);
}
export function isSettingsManagedDiscoveredAgent(
agent: Pick<DiscoveredAgent, 'command'>,
): agent is Pick<DiscoveredAgent, 'command'> & { command: ManagedAgentKey } {
return agent.command === 'codex' || agent.command === 'claude' || agent.command === 'copilot';
}
export function matchesManagedAgentConfig(
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'acpCommand'>,
agentKey: ManagedAgentKey,
): boolean {
const meta = MANAGED_AGENT_META[agentKey];
const basename = getCommandBasename(agent.command);
return (
agent.id === `discovered_${agentKey}` ||
agent.acpCommand === meta.acpCommand ||
meta.commandNames.some((commandName) => basename === commandName || basename.startsWith(`${commandName}.`))
);
}
export function getManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): string | null {
const managedId = `discovered_${agentKey}`;
const preferredAgent = agents.find(
(agent) =>
agent.id === managedId &&
isPathLikeCommand(agent.command) &&
matchesPrimaryCliBasename(agent.command, agentKey),
);
if (preferredAgent) {
return preferredAgent.command;
}
const fallbackAgent = agents.find(
(agent) =>
matchesManagedAgentConfig(agent, agentKey) &&
isPathLikeCommand(agent.command) &&
matchesPrimaryCliBasename(agent.command, agentKey),
);
return fallbackAgent?.command ?? null;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96"><path d="M95.667 67.954C92.225 73.933 72.24 88.04 47.997 88.04 23.754 88.04 3.769 73.933.328 67.954c-.216-.375-.307-.796-.328-1.226V55.661c.019-.371.089-.736.226-1.081 1.489-3.738 5.386-9.166 10.417-10.623.667-1.712 1.655-4.215 2.576-6.062-.154-1.414-.208-2.872-.208-4.345 0-5.322 1.128-9.99 4.527-13.466 1.587-1.623 3.557-2.869 5.893-3.805 5.595-4.545 13.563-8.369 24.48-8.369s19.057 3.824 24.652 8.369c2.337.936 4.306 2.182 5.894 3.805 3.399 3.476 4.527 8.144 4.527 13.466 0 1.473-.054 2.931-.208 4.345.921 1.847 1.909 4.35 2.576 6.062 5.03 1.457 8.928 6.885 10.417 10.623.163.41.231.848.231 1.289v10.644c0 .504-.081 1.004-.333 1.441ZM48.686 43.993l-.3.001-1.077-.001c-.423.709-.894 1.39-1.418 2.035-3.078 3.787-7.672 5.964-14.026 5.964-6.897 0-11.952-1.435-15.123-5.032a7.886 7.886 0 0 1-.342-.419l-.39.419v26.326c5.737 3.118 18.05 8.713 31.987 8.713 13.938 0 26.251-5.595 31.988-8.713V46.96l-.39-.419s-.132.181-.342.419c-3.171 3.597-8.226 5.032-15.123 5.032-6.354 0-10.949-2.177-14.026-5.964a17.178 17.178 0 0 1-1.418-2.034h-.066l.066-.001Zm-3.94-11.733c.17-1.326.251-2.513.253-3.573v-.084c-.005-3.077-.678-5.079-1.752-6.308-1.365-1.562-4.184-2.758-10.127-2.115-6.021.652-9.386 2.146-11.294 4.098-1.847 1.889-2.818 4.715-2.818 9.272 0 4.842.698 7.703 2.232 9.443 1.459 1.655 4.332 3.001 10.625 3.001 4.837 0 7.603-1.573 9.371-3.749 1.899-2.336 2.967-5.759 3.51-9.985Zm6.503 0c.543 4.226 1.611 7.649 3.51 9.985 1.768 2.176 4.533 3.749 9.371 3.749 6.292 0 9.165-1.346 10.624-3.001 1.535-1.74 2.232-4.601 2.232-9.443 0-4.557-.97-7.383-2.817-9.272-1.908-1.952-5.274-3.446-11.294-4.098-5.943-.643-8.763.553-10.127 2.115-1.074 1.229-1.747 3.231-1.752 6.308v.084c.002 1.06.083 2.247.253 3.573Zm-2.563 11.734h.066l-.066-.001v.001Z"></path><path d="M38.5 55.75a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Zm19 0a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Z"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB