Skip to content

Commit c771979

Browse files
authored
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
1 parent 58c6515 commit c771979

33 files changed

Lines changed: 3030 additions & 189 deletions

agents.md renamed to AGENTS.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ This project is wired around three layers: domain (pure logic), application stat
1818
- **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only.
1919

2020
## How Things Talk
21-
- UI calls application hooks hooks call domain helpers persistence/config via infrastructure adapters.
21+
- UI calls application hooks -> hooks call domain helpers -> persistence/config via infrastructure adapters.
2222
- `App.tsx` wires hooks to components; no business logic should live in components beyond view glue.
2323
- Local storage keys are centralized in `infrastructure/config/storageKeys.ts`; avoid ad-hoc `localStorage` calls elsewhere.
2424

@@ -44,6 +44,12 @@ This project is wired around three layers: domain (pure logic), application stat
4444
- Avoid direct network/fetch in components; add a service/adaptor first.
4545
- Maintain ASCII-only unless required by existing file content.
4646

47+
## Review Boundaries
48+
- 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.
49+
- 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.
50+
- On supported first-party paths, assume Netcatty's own launcher provides required integration environment such as `NETCATTY_TOOL_CLI_DISCOVERY_FILE`.
51+
- 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.
52+
4753
---
4854

4955
## Aside Panel Design System
@@ -54,20 +60,20 @@ VaultView subpages (Hosts, Keychain, Port Forwarding, Snippets, Known Hosts) sha
5460

5561
Import from `./ui/aside-panel`:
5662
```tsx
57-
import {
58-
AsidePanel,
59-
AsidePanelHeader,
60-
AsidePanelContent,
63+
import {
64+
AsidePanel,
65+
AsidePanelHeader,
66+
AsidePanelContent,
6167
AsidePanelFooter,
6268
AsideActionMenu,
63-
AsideActionMenuItem
69+
AsideActionMenuItem
6470
} from "./ui/aside-panel";
6571
```
6672

6773
### Basic Usage
6874
```tsx
69-
<AsidePanel
70-
open={isOpen}
75+
<AsidePanel
76+
open={isOpen}
7177
onClose={handleClose}
7278
title="Panel Title"
7379
subtitle="Optional subtitle"

application/i18n/locales/en.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1777,6 +1777,11 @@ const en: Messages = {
17771777
'ai.defaultAgent': 'Default Agent',
17781778
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
17791779
'ai.defaultAgent.catty': 'Catty (Built-in)',
1780+
'ai.toolAccess.title': 'Tool Access',
1781+
'ai.toolAccess.mode': 'Netcatty Access Mode',
1782+
'ai.toolAccess.description': 'Choose how external ACP agents access Netcatty sessions. MCP exposes the built-in server, while Skills + CLI points agents to the local Netcatty skill and CLI commands.',
1783+
'ai.toolAccess.mode.mcp': 'MCP',
1784+
'ai.toolAccess.mode.skills': 'Skills + CLI',
17801785

17811786
// AI Chat
17821787
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
@@ -1853,7 +1858,7 @@ const en: Messages = {
18531858
// AI Safety Settings
18541859
'ai.safety.title': 'Safety',
18551860
'ai.safety.permissionMode': 'Permission Mode',
1856-
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations via MCP Server, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
1861+
'ai.safety.permissionMode.description': 'Controls how the AI interacts with your terminals. Observer mode blocks all write operations through Netcatty, enforced for both built-in and ACP agents. Confirm mode is advisory for ACP agents (they control their own tool approval flow).',
18571862
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
18581863
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
18591864
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
@@ -1863,7 +1868,7 @@ const en: Messages = {
18631868
'ai.safety.maxIterations': 'Max Iterations',
18641869
'ai.safety.maxIterations.description': 'Maximum number of AI tool-use loops to prevent runaway execution. ACP agents may have their own internal iteration limits that take precedence.',
18651870
'ai.safety.blocklist': 'Command Blocklist',
1866-
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
1871+
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
18671872
'ai.safety.blocklist.placeholder': 'Regex pattern...',
18681873
'ai.safety.blocklist.reset': 'Reset to defaults',
18691874
'ai.safety.blocklist.add': 'Add pattern',

application/i18n/locales/zh-CN.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1785,6 +1785,11 @@ const zhCN: Messages = {
17851785
'ai.defaultAgent': '默认 Agent',
17861786
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
17871787
'ai.defaultAgent.catty': 'Catty(内置)',
1788+
'ai.toolAccess.title': '工具接入',
1789+
'ai.toolAccess.mode': 'Netcatty 接入模式',
1790+
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
1791+
'ai.toolAccess.mode.mcp': 'MCP',
1792+
'ai.toolAccess.mode.skills': 'Skills + CLI',
17881793

17891794
// AI Chat
17901795
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
@@ -1861,7 +1866,7 @@ const zhCN: Messages = {
18611866
// AI Safety Settings
18621867
'ai.safety.title': '安全',
18631868
'ai.safety.permissionMode': '权限模式',
1864-
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
1869+
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式会通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
18651870
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
18661871
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
18671872
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
@@ -1871,7 +1876,7 @@ const zhCN: Messages = {
18711876
'ai.safety.maxIterations': '最大迭代次数',
18721877
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
18731878
'ai.safety.blocklist': '命令黑名单',
1874-
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
1879+
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
18751880
'ai.safety.blocklist.placeholder': '正则表达式...',
18761881
'ai.safety.blocklist.reset': '恢复默认',
18771882
'ai.safety.blocklist.add': '添加规则',

application/state/useAIState.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
STORAGE_KEY_AI_ACTIVE_PROVIDER,
66
STORAGE_KEY_AI_ACTIVE_MODEL,
77
STORAGE_KEY_AI_PERMISSION_MODE,
8+
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
89
STORAGE_KEY_AI_HOST_PERMISSIONS,
910
STORAGE_KEY_AI_EXTERNAL_AGENTS,
1011
STORAGE_KEY_AI_DEFAULT_AGENT,
@@ -19,6 +20,7 @@ import {
1920
import type {
2021
AISession,
2122
AIPermissionMode,
23+
AIToolIntegrationMode,
2224
ProviderConfig,
2325
HostAIPermission,
2426
ExternalAgentConfig,
@@ -29,8 +31,17 @@ import type {
2931
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
3032

3133
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
34+
interface AIBridge {
35+
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
36+
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
37+
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
38+
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
39+
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
40+
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
41+
}
42+
3243
function getAIBridge() {
33-
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
44+
return (window as unknown as { netcatty?: AIBridge }).netcatty;
3445
}
3546

3647
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
@@ -192,6 +203,10 @@ export function useAIState() {
192203
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
193204
return 'confirm';
194205
});
206+
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(() => {
207+
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
208+
return stored === 'skills' ? 'skills' : 'mcp';
209+
});
195210
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
196211
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
197212
);
@@ -252,7 +267,7 @@ export function useAIState() {
252267
let changed = false;
253268
const nextActiveSessionIdMap: Record<string, string | null> = {};
254269

255-
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
270+
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap) as Array<[string, string | null]>) {
256271
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
257272
nextActiveSessionIdMap[scopeKey] = nextSessionId;
258273
if (nextSessionId !== sessionId) {
@@ -330,6 +345,13 @@ export function useAIState() {
330345
});
331346
}, []);
332347

348+
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
349+
setToolIntegrationModeRaw(mode);
350+
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
351+
const bridge = getAIBridge();
352+
bridge?.aiMcpSetToolIntegrationMode?.(mode);
353+
}, []);
354+
333355
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
334356
setExternalAgentsRaw(prev => {
335357
const next = typeof value === 'function' ? value(prev) : value;
@@ -396,6 +418,15 @@ export function useAIState() {
396418
}
397419
break;
398420
}
421+
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
422+
{
423+
const mode = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
424+
? 'skills'
425+
: 'mcp';
426+
setToolIntegrationModeRaw(mode);
427+
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
428+
}
429+
break;
399430
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
400431
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
401432
if (agents != null && !Array.isArray(agents)) {
@@ -511,8 +542,17 @@ export function useAIState() {
511542
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
512543
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
513544
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
514-
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
545+
const storedPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
546+
const initialPermMode: AIPermissionMode =
547+
storedPermMode === 'observer' || storedPermMode === 'confirm' || storedPermMode === 'autonomous'
548+
? storedPermMode
549+
: 'confirm';
515550
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
551+
const initialToolMode: AIToolIntegrationMode =
552+
localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
553+
? 'skills'
554+
: 'mcp';
555+
bridge?.aiMcpSetToolIntegrationMode?.(initialToolMode);
516556
}, []);
517557

518558
// ── Session CRUD ──
@@ -819,6 +859,8 @@ export function useAIState() {
819859
// Permission model
820860
globalPermissionMode,
821861
setGlobalPermissionMode,
862+
toolIntegrationMode,
863+
setToolIntegrationMode,
822864
hostPermissions,
823865
setHostPermissions,
824866

components/AIChatSidePanel.tsx

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ import { useI18n } from '../application/i18n/I18nProvider';
2121
import { useWindowControls } from '../application/state/useWindowControls';
2222
import { useFileUpload } from '../application/state/useFileUpload';
2323
import type {
24-
AgentModelPreset,
2524
AIPermissionMode,
25+
AIToolIntegrationMode,
2626
AISession,
2727
AISessionScope,
2828
ChatMessage,
@@ -39,7 +39,11 @@ import AgentSelector from './ai/AgentSelector';
3939
import ChatInput from './ai/ChatInput';
4040
import ChatMessageList from './ai/ChatMessageList';
4141
import ConversationExport from './ai/ConversationExport';
42-
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
42+
import {
43+
useAIChatStreaming,
44+
getNetcattyBridge,
45+
type DefaultTargetSessionHint,
46+
} from './ai/hooks/useAIChatStreaming';
4347
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
4448
import { useConversationExport } from './ai/hooks/useConversationExport';
4549
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
@@ -89,6 +93,7 @@ interface AIChatSidePanelProps {
8993

9094
// Agent info
9195
defaultAgentId: string;
96+
toolIntegrationMode: AIToolIntegrationMode;
9297
externalAgents: ExternalAgentConfig[];
9398
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
9499
agentModelMap: Record<string, string>;
@@ -210,6 +215,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
210215
activeProviderId,
211216
activeModelId,
212217
defaultAgentId,
218+
toolIntegrationMode,
213219
externalAgents,
214220
setExternalAgents,
215221
agentModelMap,
@@ -241,6 +247,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
241247

242248
const [showHistory, setShowHistory] = useState(false);
243249
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
250+
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
244251

245252
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
246253
const { openSettingsWindow } = useWindowControls();
@@ -305,6 +312,29 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
305312
return historySessions[0] ?? null;
306313
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
307314

315+
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
316+
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
317+
318+
if (scopeType === 'terminal' && scopeTargetId) {
319+
const target = terminalSessions.find((session) => session.sessionId === scopeTargetId);
320+
if (target) {
321+
return {
322+
...target,
323+
source: 'scope-target',
324+
};
325+
}
326+
}
327+
328+
if (connectedSessions.length === 1) {
329+
return {
330+
...connectedSessions[0],
331+
source: 'only-connected-in-scope',
332+
};
333+
}
334+
335+
return undefined;
336+
}, [terminalSessions, scopeType, scopeTargetId]);
337+
308338
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
309339
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
310340

@@ -440,7 +470,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
440470

441471
const providerDisplayName = activeProvider?.name ?? '';
442472
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
443-
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
444473

445474
// Agent model presets for the current external agent
446475
const currentAgentConfig = useMemo(
@@ -452,8 +481,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
452481
[currentAgentConfig],
453482
);
454483

455-
// Ref to read agentModelMap inside the effect without re-triggering it
456-
// when setAgentModel updates the map (avoids double ACP spawn).
457484
const agentModelMapRef = useRef(agentModelMap);
458485
agentModelMapRef.current = agentModelMap;
459486

@@ -495,7 +522,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
495522

496523
const agentModelPresets = useMemo(
497524
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
498-
[currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets],
525+
[currentAgentConfig?.command, currentAgentId, runtimeAgentModelPresets],
499526
);
500527

501528
// Per-agent model: recall last selection or use first preset as default
@@ -677,8 +704,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
677704
updateExternalSessionId: updateSessionExternalSessionId,
678705
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
679706
terminalSessions,
707+
defaultTargetSession,
680708
providers,
681709
selectedAgentModel,
710+
toolIntegrationMode,
682711
});
683712
} catch (err) {
684713
reportStreamError(sessionId, abortController.signal, err);
@@ -714,8 +743,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
714743
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
715744
setStreamingForScope, setInputValue, clearFiles,
716745
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
717-
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
746+
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
718747
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
748+
toolIntegrationMode,
719749
]);
720750

721751
const handleStop = useCallback(() => {

components/SettingsPage.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ const SettingsAITabContainer: React.FC = () => {
8686
setActiveModelId={aiState.setActiveModelId}
8787
globalPermissionMode={aiState.globalPermissionMode}
8888
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
89+
toolIntegrationMode={aiState.toolIntegrationMode}
90+
setToolIntegrationMode={aiState.setToolIntegrationMode}
8991
externalAgents={aiState.externalAgents}
9092
setExternalAgents={aiState.setExternalAgents}
9193
defaultAgentId={aiState.defaultAgentId}

components/TerminalLayer.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
313313
activeProviderId={aiState.activeProviderId}
314314
activeModelId={aiState.activeModelId}
315315
defaultAgentId={aiState.defaultAgentId}
316+
toolIntegrationMode={aiState.toolIntegrationMode}
316317
externalAgents={aiState.externalAgents}
317318
setExternalAgents={aiState.setExternalAgents}
318319
agentModelMap={aiState.agentModelMap}

0 commit comments

Comments
 (0)