Skip to content

Commit b260799

Browse files
committed
Add Skills + CLI external agent workflow
1 parent 268b698 commit b260799

30 files changed

Lines changed: 1901 additions & 82 deletions

.github/workflows/sync.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Sync upstream
2+
3+
on:
4+
schedule:
5+
- cron: '0 */2 * * *' # 每2小时执行一次
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
sync:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Checkout fork
17+
uses: actions/checkout@v4
18+
with:
19+
fetch-depth: 0
20+
ref: main
21+
22+
- name: Configure git
23+
run: |
24+
git config user.name "GitHub Actions"
25+
git config user.email "actions@github.com"
26+
27+
- name: Add upstream repo
28+
run: |
29+
git remote add upstream https://github.com/binaricat/Netcatty.git || true
30+
31+
- name: Fetch upstream
32+
run: git fetch upstream
33+
34+
- name: Merge upstream/main into main
35+
run: git merge upstream/main --no-edit
36+
37+
- name: Push changes
38+
run: git push origin main

application/i18n/locales/en.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1739,6 +1739,11 @@ const en: Messages = {
17391739
'ai.defaultAgent': 'Default Agent',
17401740
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
17411741
'ai.defaultAgent.catty': 'Catty (Built-in)',
1742+
'ai.toolAccess.title': 'Tool Access',
1743+
'ai.toolAccess.mode': 'Netcatty Access Mode',
1744+
'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. Skills + CLI currently requires a working Node.js runtime on the host.',
1745+
'ai.toolAccess.mode.mcp': 'MCP',
1746+
'ai.toolAccess.mode.skills': 'Skills + CLI',
17421747

17431748
// AI Chat
17441749
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
@@ -1815,7 +1820,7 @@ const en: Messages = {
18151820
// AI Safety Settings
18161821
'ai.safety.title': 'Safety',
18171822
'ai.safety.permissionMode': 'Permission Mode',
1818-
'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).',
1823+
'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).',
18191824
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
18201825
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
18211826
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
@@ -1825,7 +1830,7 @@ const en: Messages = {
18251830
'ai.safety.maxIterations': 'Max Iterations',
18261831
'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.',
18271832
'ai.safety.blocklist': 'Command Blocklist',
1828-
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
1833+
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
18291834
'ai.safety.blocklist.placeholder': 'Regex pattern...',
18301835
'ai.safety.blocklist.reset': 'Reset to defaults',
18311836
'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
@@ -1747,6 +1747,11 @@ const zhCN: Messages = {
17471747
'ai.defaultAgent': '默认 Agent',
17481748
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
17491749
'ai.defaultAgent.catty': 'Catty(内置)',
1750+
'ai.toolAccess.title': '工具接入',
1751+
'ai.toolAccess.mode': 'Netcatty 接入模式',
1752+
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。当前 Skills + CLI 依赖宿主机器上可用的 Node.js 运行时。',
1753+
'ai.toolAccess.mode.mcp': 'MCP',
1754+
'ai.toolAccess.mode.skills': 'Skills + CLI',
17501755

17511756
// AI Chat
17521757
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
@@ -1823,7 +1828,7 @@ const zhCN: Messages = {
18231828
// AI Safety Settings
18241829
'ai.safety.title': '安全',
18251830
'ai.safety.permissionMode': '权限模式',
1826-
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
1831+
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式会通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
18271832
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
18281833
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
18291834
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
@@ -1833,7 +1838,7 @@ const zhCN: Messages = {
18331838
'ai.safety.maxIterations': '最大迭代次数',
18341839
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
18351840
'ai.safety.blocklist': '命令黑名单',
1836-
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
1841+
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
18371842
'ai.safety.blocklist.placeholder': '正则表达式...',
18381843
'ai.safety.blocklist.reset': '恢复默认',
18391844
'ai.safety.blocklist.add': '添加规则',

application/state/useAIState.ts

Lines changed: 35 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,16 @@ 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+
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
38+
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
39+
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
40+
}
41+
3242
function getAIBridge() {
33-
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
43+
return (window as unknown as { netcatty?: AIBridge }).netcatty;
3444
}
3545

3646
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
@@ -192,6 +202,10 @@ export function useAIState() {
192202
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
193203
return 'confirm';
194204
});
205+
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(() => {
206+
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
207+
return stored === 'skills' ? 'skills' : 'mcp';
208+
});
195209
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
196210
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
197211
);
@@ -252,7 +266,7 @@ export function useAIState() {
252266
let changed = false;
253267
const nextActiveSessionIdMap: Record<string, string | null> = {};
254268

255-
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
269+
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap) as Array<[string, string | null]>) {
256270
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
257271
nextActiveSessionIdMap[scopeKey] = nextSessionId;
258272
if (nextSessionId !== sessionId) {
@@ -330,6 +344,11 @@ export function useAIState() {
330344
});
331345
}, []);
332346

347+
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
348+
setToolIntegrationModeRaw(mode);
349+
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
350+
}, []);
351+
333352
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
334353
setExternalAgentsRaw(prev => {
335354
const next = typeof value === 'function' ? value(prev) : value;
@@ -396,6 +415,13 @@ export function useAIState() {
396415
}
397416
break;
398417
}
418+
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
419+
setToolIntegrationModeRaw(
420+
localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
421+
? 'skills'
422+
: 'mcp',
423+
);
424+
break;
399425
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
400426
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
401427
if (agents != null && !Array.isArray(agents)) {
@@ -511,7 +537,11 @@ export function useAIState() {
511537
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
512538
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
513539
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
514-
const initialPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE) ?? 'confirm';
540+
const storedPermMode = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
541+
const initialPermMode: AIPermissionMode =
542+
storedPermMode === 'observer' || storedPermMode === 'confirm' || storedPermMode === 'autonomous'
543+
? storedPermMode
544+
: 'confirm';
515545
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
516546
}, []);
517547

@@ -819,6 +849,8 @@ export function useAIState() {
819849
// Permission model
820850
globalPermissionMode,
821851
setGlobalPermissionMode,
852+
toolIntegrationMode,
853+
setToolIntegrationMode,
822854
hostPermissions,
823855
setHostPermissions,
824856

components/AIChatSidePanel.tsx

Lines changed: 41 additions & 49 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,
@@ -305,6 +311,29 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
305311
return historySessions[0] ?? null;
306312
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
307313

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

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

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

445473
// Agent model presets for the current external agent
446474
const currentAgentConfig = useMemo(
@@ -451,51 +479,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
451479
() => isCopilotAgentConfig(currentAgentConfig),
452480
[currentAgentConfig],
453481
);
454-
455-
// Ref to read agentModelMap inside the effect without re-triggering it
456-
// when setAgentModel updates the map (avoids double ACP spawn).
457-
const agentModelMapRef = useRef(agentModelMap);
458-
agentModelMapRef.current = agentModelMap;
459-
460-
useEffect(() => {
461-
if (!currentAgentConfig?.acpCommand) return;
462-
if (!isCopilotExternalAgent) return;
463-
464-
const bridge = getNetcattyBridge();
465-
if (!bridge?.aiAcpListModels) return;
466-
467-
let cancelled = false;
468-
void bridge.aiAcpListModels(
469-
currentAgentConfig.acpCommand,
470-
currentAgentConfig.acpArgs || [],
471-
undefined,
472-
undefined,
473-
`models_${currentAgentId}`,
474-
).then((result) => {
475-
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
476-
const knownModelIds = new Set(result.models.map((model) => model.id));
477-
setRuntimeAgentModelPresets((prev) => ({
478-
...prev,
479-
[currentAgentId]: result.models ?? [],
480-
}));
481-
const storedModelId = agentModelMapRef.current[currentAgentId];
482-
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
483-
setAgentModel(currentAgentId, result.currentModelId);
484-
}
485-
}).catch((err) => {
486-
if (!cancelled) {
487-
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
488-
}
489-
});
490-
491-
return () => {
492-
cancelled = true;
493-
};
494-
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
495-
496482
const agentModelPresets = useMemo(
497-
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
498-
[currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets],
483+
() => getAgentModelPresets(currentAgentConfig?.command),
484+
[currentAgentConfig?.command],
499485
);
500486

501487
// Per-agent model: recall last selection or use first preset as default
@@ -504,6 +490,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
504490
if (stored && agentModelPresets.some(p => stored === p.id || stored.startsWith(p.id + '/'))) {
505491
return stored;
506492
}
493+
if (isCopilotExternalAgent) {
494+
return undefined;
495+
}
507496
// Default to first preset; for models with thinking levels, use the default level
508497
if (agentModelPresets.length > 0) {
509498
const first = agentModelPresets[0];
@@ -513,7 +502,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
513502
return first.id;
514503
}
515504
return undefined;
516-
}, [currentAgentId, agentModelMap, agentModelPresets]);
505+
}, [currentAgentId, agentModelMap, agentModelPresets, isCopilotExternalAgent]);
517506

518507
const handleAgentModelSelect = useCallback((modelId: string) => {
519508
setAgentModel(currentAgentId, modelId);
@@ -677,8 +666,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
677666
updateExternalSessionId: updateSessionExternalSessionId,
678667
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
679668
terminalSessions,
669+
defaultTargetSession,
680670
providers,
681671
selectedAgentModel,
672+
toolIntegrationMode,
682673
});
683674
} catch (err) {
684675
reportStreamError(sessionId, abortController.signal, err);
@@ -714,8 +705,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
714705
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
715706
setStreamingForScope, setInputValue, clearFiles,
716707
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
717-
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
708+
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
718709
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
710+
toolIntegrationMode,
719711
]);
720712

721713
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)