From c771979178192258edfe8fdffa3e27c882698ae4 Mon Sep 17 00:00:00 2001 From: Eric Chan Date: Fri, 10 Apr 2026 18:41:53 +0800 Subject: [PATCH] Add Skills + CLI mode for external agents (#599) * Add Skills + CLI external agent workflow * feat: add Skills + CLI transport for ACP agents * chore: remove branch-local compatibility shims --- agents.md => AGENTS.md | 22 +- application/i18n/locales/en.ts | 9 +- application/i18n/locales/zh-CN.ts | 9 +- application/state/useAIState.ts | 48 +- components/AIChatSidePanel.tsx | 44 +- components/SettingsPage.tsx | 2 + components/TerminalLayer.tsx | 1 + components/ai/hooks/useAIChatStreaming.ts | 9 + components/settings/tabs/SettingsAITab.tsx | 29 + electron-builder.config.cjs | 4 + electron/bridges/aiBridge.cjs | 288 ++++++- electron/bridges/mcpServerBridge.cjs | 713 ++++++++++++++++-- electron/bridges/sftpBridge.cjs | 670 ++++++++++++++-- electron/cli/discoveryPath.cjs | 82 ++ electron/cli/netcatty-tool-cli | 29 + electron/cli/netcatty-tool-cli.cjs | 690 +++++++++++++++++ electron/cli/netcatty-tool-cli.cmd | 25 + electron/cli/netcattyRpcClient.cjs | 260 +++++++ electron/main.cjs | 3 + electron/mcp/netcatty-mcp-server.cjs | 51 ++ electron/preload.cjs | 7 +- global.d.ts | 3 +- infrastructure/ai/acpAgentAdapter.ts | 63 +- infrastructure/ai/types.ts | 3 + infrastructure/config/storageKeys.ts | 1 + package-lock.json | 3 + package.json | 4 + skills/netcatty-tool-cli/SKILL.md | 40 + .../references/control-commands.md | 17 + skills/netcatty-tool-cli/references/errors.md | 10 + skills/netcatty-tool-cli/references/exec.md | 31 + .../references/session-types.md | 17 + skills/netcatty-tool-cli/references/sftp.md | 42 ++ 33 files changed, 3035 insertions(+), 194 deletions(-) rename agents.md => AGENTS.md (89%) create mode 100644 electron/cli/discoveryPath.cjs create mode 100755 electron/cli/netcatty-tool-cli create mode 100644 electron/cli/netcatty-tool-cli.cjs create mode 100644 electron/cli/netcatty-tool-cli.cmd create mode 100644 electron/cli/netcattyRpcClient.cjs create mode 100644 skills/netcatty-tool-cli/SKILL.md create mode 100644 skills/netcatty-tool-cli/references/control-commands.md create mode 100644 skills/netcatty-tool-cli/references/errors.md create mode 100644 skills/netcatty-tool-cli/references/exec.md create mode 100644 skills/netcatty-tool-cli/references/session-types.md create mode 100644 skills/netcatty-tool-cli/references/sftp.md diff --git a/agents.md b/AGENTS.md similarity index 89% rename from agents.md rename to AGENTS.md index 978ee25f..4640bd4f 100644 --- a/agents.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ This project is wired around three layers: domain (pure logic), application stat - **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only. ## How Things Talk -- UI calls application hooks → hooks call domain helpers → persistence/config via infrastructure adapters. +- UI calls application hooks -> hooks call domain helpers -> persistence/config via infrastructure adapters. - `App.tsx` wires hooks to components; no business logic should live in components beyond view glue. - Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere. @@ -44,6 +44,12 @@ This project is wired around three layers: domain (pure logic), application stat - Avoid direct network/fetch in components; add a service/adaptor first. - Maintain ASCII-only unless required by existing file content. +## Review Boundaries +- Treat `electron/cli/*`, `netcatty-tool-cli`, the CLI discovery file, and the local TCP bridge as internal Netcatty integration surfaces unless a task explicitly says otherwise. +- Do not review those surfaces as public APIs by default, and do not assume they must support third-party callers, manual launches, or non-Netcatty agents. +- On supported first-party paths, assume Netcatty's own launcher provides required integration environment such as `NETCATTY_TOOL_CLI_DISCOVERY_FILE`. +- If a review concern depends on external exposure, third-party compatibility, or public API stability, call it out as out of scope unless the task explicitly includes that contract. + --- ## Aside Panel Design System @@ -54,20 +60,20 @@ VaultView subpages (Hosts, Keychain, Port Forwarding, Snippets, Known Hosts) sha Import from `./ui/aside-panel`: ```tsx -import { - AsidePanel, - AsidePanelHeader, - AsidePanelContent, +import { + AsidePanel, + AsidePanelHeader, + AsidePanelContent, AsidePanelFooter, AsideActionMenu, - AsideActionMenuItem + AsideActionMenuItem } from "./ui/aside-panel"; ``` ### Basic Usage ```tsx - Promise<{ ok: boolean }>; + aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise | unknown; + aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise | unknown; + aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise | unknown; + aiMcpSetCommandTimeout?: (timeout: number) => Promise | unknown; + aiMcpSetMaxIterations?: (maxIterations: number) => Promise | unknown; +} + function getAIBridge() { - return (window as unknown as { netcatty?: Record unknown> }).netcatty; + return (window as unknown as { netcatty?: AIBridge }).netcatty; } const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed'; @@ -192,6 +203,10 @@ export function useAIState() { if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored; return 'confirm'; }); + const [toolIntegrationMode, setToolIntegrationModeRaw] = useState(() => { + const stored = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE); + return stored === 'skills' ? 'skills' : 'mcp'; + }); const [hostPermissions, setHostPermissionsRaw] = useState(() => localStorageAdapter.read(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? [] ); @@ -252,7 +267,7 @@ export function useAIState() { let changed = false; const nextActiveSessionIdMap: Record = {}; - for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) { + for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap) as Array<[string, string | null]>) { const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null; nextActiveSessionIdMap[scopeKey] = nextSessionId; if (nextSessionId !== sessionId) { @@ -330,6 +345,13 @@ export function useAIState() { }); }, []); + const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => { + setToolIntegrationModeRaw(mode); + localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode); + const bridge = getAIBridge(); + bridge?.aiMcpSetToolIntegrationMode?.(mode); + }, []); + const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => { setExternalAgentsRaw(prev => { const next = typeof value === 'function' ? value(prev) : value; @@ -396,6 +418,15 @@ export function useAIState() { } break; } + case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE: + { + const mode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills' + ? 'skills' + : 'mcp'; + setToolIntegrationModeRaw(mode); + getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode); + } + break; case STORAGE_KEY_AI_EXTERNAL_AGENTS: { const agents = localStorageAdapter.read(STORAGE_KEY_AI_EXTERNAL_AGENTS); if (agents != null && !Array.isArray(agents)) { @@ -511,8 +542,17 @@ export function useAIState() { bridge?.aiMcpSetCommandTimeout?.(initialTimeout); const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20; bridge?.aiMcpSetMaxIterations?.(initialMaxIter); - const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm'; + const storedPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE); + const initialPermMode: AIPermissionMode = + storedPermMode === 'observer' || storedPermMode === 'confirm' || storedPermMode === 'autonomous' + ? storedPermMode + : 'confirm'; bridge?.aiMcpSetPermissionMode?.(initialPermMode); + const initialToolMode: AIToolIntegrationMode = + localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills' + ? 'skills' + : 'mcp'; + bridge?.aiMcpSetToolIntegrationMode?.(initialToolMode); }, []); // ── Session CRUD ── @@ -819,6 +859,8 @@ export function useAIState() { // Permission model globalPermissionMode, setGlobalPermissionMode, + toolIntegrationMode, + setToolIntegrationMode, hostPermissions, setHostPermissions, diff --git a/components/AIChatSidePanel.tsx b/components/AIChatSidePanel.tsx index 261da0fd..f8bbf50f 100644 --- a/components/AIChatSidePanel.tsx +++ b/components/AIChatSidePanel.tsx @@ -21,8 +21,8 @@ import { useI18n } from '../application/i18n/I18nProvider'; import { useWindowControls } from '../application/state/useWindowControls'; import { useFileUpload } from '../application/state/useFileUpload'; import type { - AgentModelPreset, AIPermissionMode, + AIToolIntegrationMode, AISession, AISessionScope, ChatMessage, @@ -39,7 +39,11 @@ import AgentSelector from './ai/AgentSelector'; import ChatInput from './ai/ChatInput'; import ChatMessageList from './ai/ChatMessageList'; import ConversationExport from './ai/ConversationExport'; -import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming'; +import { + useAIChatStreaming, + getNetcattyBridge, + type DefaultTargetSessionHint, +} from './ai/hooks/useAIChatStreaming'; import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate'; import { useConversationExport } from './ai/hooks/useConversationExport'; import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor'; @@ -89,6 +93,7 @@ interface AIChatSidePanelProps { // Agent info defaultAgentId: string; + toolIntegrationMode: AIToolIntegrationMode; externalAgents: ExternalAgentConfig[]; setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void; agentModelMap: Record; @@ -210,6 +215,7 @@ const AIChatSidePanelInner: React.FC = ({ activeProviderId, activeModelId, defaultAgentId, + toolIntegrationMode, externalAgents, setExternalAgents, agentModelMap, @@ -241,6 +247,7 @@ const AIChatSidePanelInner: React.FC = ({ const [showHistory, setShowHistory] = useState(false); const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId); + const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState>>({}); const { files, addFiles, removeFile, clearFiles } = useFileUpload(); const { openSettingsWindow } = useWindowControls(); @@ -305,6 +312,29 @@ const AIChatSidePanelInner: React.FC = ({ return historySessions[0] ?? null; }, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]); + const defaultTargetSession = useMemo(() => { + const connectedSessions = terminalSessions.filter((session) => session.connected !== false); + + if (scopeType === 'terminal' && scopeTargetId) { + const target = terminalSessions.find((session) => session.sessionId === scopeTargetId); + if (target) { + return { + ...target, + source: 'scope-target', + }; + } + } + + if (connectedSessions.length === 1) { + return { + ...connectedSessions[0], + source: 'only-connected-in-scope', + }; + } + + return undefined; + }, [terminalSessions, scopeType, scopeTargetId]); + const activeSessionId = activeSession?.id ?? activeSessionIdForScope; const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false; @@ -440,7 +470,6 @@ const AIChatSidePanelInner: React.FC = ({ const providerDisplayName = activeProvider?.name ?? ''; const modelDisplayName = activeModelId || activeProvider?.defaultModel || ''; - const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState>({}); // Agent model presets for the current external agent const currentAgentConfig = useMemo( @@ -452,8 +481,6 @@ const AIChatSidePanelInner: React.FC = ({ [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; @@ -495,7 +522,7 @@ const AIChatSidePanelInner: React.FC = ({ const agentModelPresets = useMemo( () => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command), - [currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets], + [currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets], ); // Per-agent model: recall last selection or use first preset as default @@ -677,8 +704,10 @@ const AIChatSidePanelInner: React.FC = ({ updateExternalSessionId: updateSessionExternalSessionId, historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []), terminalSessions, + defaultTargetSession, providers, selectedAgentModel, + toolIntegrationMode, }); } catch (err) { reportStreamError(sessionId, abortController.signal, err); @@ -714,8 +743,9 @@ const AIChatSidePanelInner: React.FC = ({ ensureSession, addMessageToSession, updateMessageById, updateLastMessage, setStreamingForScope, setInputValue, clearFiles, sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t, - abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId, + abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId, scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope, + toolIntegrationMode, ]); const handleStop = useCallback(() => { diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index 996e3ff7..594e930e 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -86,6 +86,8 @@ const SettingsAITabContainer: React.FC = () => { setActiveModelId={aiState.setActiveModelId} globalPermissionMode={aiState.globalPermissionMode} setGlobalPermissionMode={aiState.setGlobalPermissionMode} + toolIntegrationMode={aiState.toolIntegrationMode} + setToolIntegrationMode={aiState.setToolIntegrationMode} externalAgents={aiState.externalAgents} setExternalAgents={aiState.setExternalAgents} defaultAgentId={aiState.defaultAgentId} diff --git a/components/TerminalLayer.tsx b/components/TerminalLayer.tsx index 4a7403f6..d00d9db2 100644 --- a/components/TerminalLayer.tsx +++ b/components/TerminalLayer.tsx @@ -313,6 +313,7 @@ const AIChatPanelsHostInner: React.FC = ({ activeProviderId={aiState.activeProviderId} activeModelId={aiState.activeModelId} defaultAgentId={aiState.defaultAgentId} + toolIntegrationMode={aiState.toolIntegrationMode} externalAgents={aiState.externalAgents} setExternalAgents={aiState.setExternalAgents} agentModelMap={aiState.agentModelMap} diff --git a/components/ai/hooks/useAIChatStreaming.ts b/components/ai/hooks/useAIChatStreaming.ts index c2abbee0..616de0e7 100644 --- a/components/ai/hooks/useAIChatStreaming.ts +++ b/components/ai/hooks/useAIChatStreaming.ts @@ -14,6 +14,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { streamText, stepCountIs, type ModelMessage } from 'ai'; import type { AIPermissionMode, + AIToolIntegrationMode, AISession, ChatMessage, ChatMessageAttachment, @@ -137,6 +138,10 @@ export interface TerminalSessionInfo { connected: boolean; } +export interface DefaultTargetSessionHint extends TerminalSessionInfo { + source: 'scope-target' | 'only-connected-in-scope'; +} + /** Typed accessor for the netcatty bridge on the window object. */ export function getNetcattyBridge(): PanelBridge | undefined { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -242,8 +247,10 @@ export interface SendToExternalContext { updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void; historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>; terminalSessions: TerminalSessionInfo[]; + defaultTargetSession?: DefaultTargetSessionHint; providers: ProviderConfig[]; selectedAgentModel?: string; + toolIntegrationMode: AIToolIntegrationMode; } // ------------------------------------------------------------------- @@ -635,6 +642,8 @@ export function useAIChatStreaming({ context.existingSessionId, context.historyMessages, attachedImages.length > 0 ? attachedImages : undefined, + context.toolIntegrationMode, + context.defaultTargetSession, ); } else { // Fallback: spawn as raw process diff --git a/components/settings/tabs/SettingsAITab.tsx b/components/settings/tabs/SettingsAITab.tsx index f3710f4c..3b58c79d 100644 --- a/components/settings/tabs/SettingsAITab.tsx +++ b/components/settings/tabs/SettingsAITab.tsx @@ -12,6 +12,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" import type { AIPermissionMode, AIProviderId, + AIToolIntegrationMode, ExternalAgentConfig, ProviderConfig, WebSearchConfig, @@ -61,6 +62,8 @@ interface SettingsAITabProps { setActiveModelId: (id: string) => void; globalPermissionMode: AIPermissionMode; setGlobalPermissionMode: (mode: AIPermissionMode) => void; + toolIntegrationMode: AIToolIntegrationMode; + setToolIntegrationMode: (mode: AIToolIntegrationMode) => void; externalAgents: ExternalAgentConfig[]; setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void; defaultAgentId: string; @@ -138,6 +141,8 @@ const SettingsAITab: React.FC = ({ setActiveModelId, globalPermissionMode, setGlobalPermissionMode, + toolIntegrationMode, + setToolIntegrationMode, externalAgents, setExternalAgents, defaultAgentId, @@ -585,6 +590,30 @@ const SettingsAITab: React.FC = ({ )} +
+
+ +

{t('ai.toolAccess.title')}

+
+ +
+ +