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
This commit is contained in:
@@ -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.
|
- **UI** (`components/`, `App.tsx`): Presentation; depends on hooks and domain helpers only.
|
||||||
|
|
||||||
## How Things Talk
|
## 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.
|
- `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.
|
- 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.
|
- Avoid direct network/fetch in components; add a service/adaptor first.
|
||||||
- Maintain ASCII-only unless required by existing file content.
|
- 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
|
## Aside Panel Design System
|
||||||
@@ -1777,6 +1777,11 @@ const en: Messages = {
|
|||||||
'ai.defaultAgent': 'Default Agent',
|
'ai.defaultAgent': 'Default Agent',
|
||||||
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
|
||||||
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
'ai.defaultAgent.catty': 'Catty (Built-in)',
|
||||||
|
'ai.toolAccess.title': 'Tool Access',
|
||||||
|
'ai.toolAccess.mode': 'Netcatty Access Mode',
|
||||||
|
'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.',
|
||||||
|
'ai.toolAccess.mode.mcp': 'MCP',
|
||||||
|
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||||
|
|
||||||
// AI Chat
|
// AI Chat
|
||||||
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
|
'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 = {
|
|||||||
// AI Safety Settings
|
// AI Safety Settings
|
||||||
'ai.safety.title': 'Safety',
|
'ai.safety.title': 'Safety',
|
||||||
'ai.safety.permissionMode': 'Permission Mode',
|
'ai.safety.permissionMode': 'Permission Mode',
|
||||||
'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).',
|
'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).',
|
||||||
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
'ai.safety.permissionMode.observer': 'Observer - Read only, no actions',
|
||||||
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
'ai.safety.permissionMode.confirm': 'Confirm - Ask before actions',
|
||||||
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
|
'ai.safety.permissionMode.autonomous': 'Autonomous - Execute freely',
|
||||||
@@ -1863,7 +1868,7 @@ const en: Messages = {
|
|||||||
'ai.safety.maxIterations': 'Max Iterations',
|
'ai.safety.maxIterations': 'Max Iterations',
|
||||||
'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.',
|
'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.',
|
||||||
'ai.safety.blocklist': 'Command Blocklist',
|
'ai.safety.blocklist': 'Command Blocklist',
|
||||||
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents via MCP Server.',
|
'ai.safety.blocklist.description': 'Regex patterns to block dangerous commands. Applies to both built-in and ACP agents through Netcatty execution.',
|
||||||
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
'ai.safety.blocklist.placeholder': 'Regex pattern...',
|
||||||
'ai.safety.blocklist.reset': 'Reset to defaults',
|
'ai.safety.blocklist.reset': 'Reset to defaults',
|
||||||
'ai.safety.blocklist.add': 'Add pattern',
|
'ai.safety.blocklist.add': 'Add pattern',
|
||||||
|
|||||||
@@ -1785,6 +1785,11 @@ const zhCN: Messages = {
|
|||||||
'ai.defaultAgent': '默认 Agent',
|
'ai.defaultAgent': '默认 Agent',
|
||||||
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
|
||||||
'ai.defaultAgent.catty': 'Catty(内置)',
|
'ai.defaultAgent.catty': 'Catty(内置)',
|
||||||
|
'ai.toolAccess.title': '工具接入',
|
||||||
|
'ai.toolAccess.mode': 'Netcatty 接入模式',
|
||||||
|
'ai.toolAccess.description': '选择外部 ACP Agent 访问 Netcatty 会话的方式。MCP 会暴露内置服务器,Skills + CLI 会引导 Agent 读取本地 Skill 并调用 Netcatty CLI。',
|
||||||
|
'ai.toolAccess.mode.mcp': 'MCP',
|
||||||
|
'ai.toolAccess.mode.skills': 'Skills + CLI',
|
||||||
|
|
||||||
// AI Chat
|
// AI Chat
|
||||||
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
|
||||||
@@ -1861,7 +1866,7 @@ const zhCN: Messages = {
|
|||||||
// AI Safety Settings
|
// AI Safety Settings
|
||||||
'ai.safety.title': '安全',
|
'ai.safety.title': '安全',
|
||||||
'ai.safety.permissionMode': '权限模式',
|
'ai.safety.permissionMode': '权限模式',
|
||||||
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式通过 MCP Server 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
'ai.safety.permissionMode.description': '控制 AI 与终端的交互方式。观察者模式会通过 Netcatty 阻止所有写操作,对内置和 ACP Agent 均生效。确认模式对 ACP Agent 仅为建议性(ACP Agent 有自己的工具审批流程)。',
|
||||||
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
'ai.safety.permissionMode.observer': '观察者 - 只读,禁止操作',
|
||||||
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
'ai.safety.permissionMode.confirm': '确认 - 操作前询问',
|
||||||
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
|
'ai.safety.permissionMode.autonomous': '自主 - 自由执行',
|
||||||
@@ -1871,7 +1876,7 @@ const zhCN: Messages = {
|
|||||||
'ai.safety.maxIterations': '最大迭代次数',
|
'ai.safety.maxIterations': '最大迭代次数',
|
||||||
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
|
'ai.safety.maxIterations.description': '防止 AI 失控执行的最大工具调用循环次数。ACP Agent 可能有自己的内部迭代限制,以其为准。',
|
||||||
'ai.safety.blocklist': '命令黑名单',
|
'ai.safety.blocklist': '命令黑名单',
|
||||||
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 MCP Server 对内置和 ACP Agent 均生效。',
|
'ai.safety.blocklist.description': '用于拦截危险命令的正则表达式。通过 Netcatty 执行层对内置和 ACP Agent 均生效。',
|
||||||
'ai.safety.blocklist.placeholder': '正则表达式...',
|
'ai.safety.blocklist.placeholder': '正则表达式...',
|
||||||
'ai.safety.blocklist.reset': '恢复默认',
|
'ai.safety.blocklist.reset': '恢复默认',
|
||||||
'ai.safety.blocklist.add': '添加规则',
|
'ai.safety.blocklist.add': '添加规则',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||||
|
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
|
||||||
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
STORAGE_KEY_AI_HOST_PERMISSIONS,
|
||||||
STORAGE_KEY_AI_EXTERNAL_AGENTS,
|
STORAGE_KEY_AI_EXTERNAL_AGENTS,
|
||||||
STORAGE_KEY_AI_DEFAULT_AGENT,
|
STORAGE_KEY_AI_DEFAULT_AGENT,
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AISession,
|
AISession,
|
||||||
AIPermissionMode,
|
AIPermissionMode,
|
||||||
|
AIToolIntegrationMode,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
HostAIPermission,
|
HostAIPermission,
|
||||||
ExternalAgentConfig,
|
ExternalAgentConfig,
|
||||||
@@ -29,8 +31,17 @@ import type {
|
|||||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||||
|
|
||||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||||
|
interface AIBridge {
|
||||||
|
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
|
||||||
|
aiMcpSetPermissionMode?: (mode: AIPermissionMode) => Promise<unknown> | unknown;
|
||||||
|
aiMcpSetToolIntegrationMode?: (mode: AIToolIntegrationMode) => Promise<unknown> | unknown;
|
||||||
|
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
|
||||||
|
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
|
||||||
|
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
|
||||||
|
}
|
||||||
|
|
||||||
function getAIBridge() {
|
function getAIBridge() {
|
||||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
return (window as unknown as { netcatty?: AIBridge }).netcatty;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
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;
|
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
|
||||||
return 'confirm';
|
return 'confirm';
|
||||||
});
|
});
|
||||||
|
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(() => {
|
||||||
|
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE);
|
||||||
|
return stored === 'skills' ? 'skills' : 'mcp';
|
||||||
|
});
|
||||||
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
|
const [hostPermissions, setHostPermissionsRaw] = useState<HostAIPermission[]>(() =>
|
||||||
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
|
localStorageAdapter.read<HostAIPermission[]>(STORAGE_KEY_AI_HOST_PERMISSIONS) ?? []
|
||||||
);
|
);
|
||||||
@@ -252,7 +267,7 @@ export function useAIState() {
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||||
|
|
||||||
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;
|
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
|
||||||
nextActiveSessionIdMap[scopeKey] = nextSessionId;
|
nextActiveSessionIdMap[scopeKey] = nextSessionId;
|
||||||
if (nextSessionId !== sessionId) {
|
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[])) => {
|
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
|
||||||
setExternalAgentsRaw(prev => {
|
setExternalAgentsRaw(prev => {
|
||||||
const next = typeof value === 'function' ? value(prev) : value;
|
const next = typeof value === 'function' ? value(prev) : value;
|
||||||
@@ -396,6 +418,15 @@ export function useAIState() {
|
|||||||
}
|
}
|
||||||
break;
|
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: {
|
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
|
||||||
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
|
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
|
||||||
if (agents != null && !Array.isArray(agents)) {
|
if (agents != null && !Array.isArray(agents)) {
|
||||||
@@ -511,8 +542,17 @@ export function useAIState() {
|
|||||||
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
|
bridge?.aiMcpSetCommandTimeout?.(initialTimeout);
|
||||||
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
const initialMaxIter = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
|
||||||
bridge?.aiMcpSetMaxIterations?.(initialMaxIter);
|
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);
|
bridge?.aiMcpSetPermissionMode?.(initialPermMode);
|
||||||
|
const initialToolMode: AIToolIntegrationMode =
|
||||||
|
localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
|
||||||
|
? 'skills'
|
||||||
|
: 'mcp';
|
||||||
|
bridge?.aiMcpSetToolIntegrationMode?.(initialToolMode);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Session CRUD ──
|
// ── Session CRUD ──
|
||||||
@@ -819,6 +859,8 @@ export function useAIState() {
|
|||||||
// Permission model
|
// Permission model
|
||||||
globalPermissionMode,
|
globalPermissionMode,
|
||||||
setGlobalPermissionMode,
|
setGlobalPermissionMode,
|
||||||
|
toolIntegrationMode,
|
||||||
|
setToolIntegrationMode,
|
||||||
hostPermissions,
|
hostPermissions,
|
||||||
setHostPermissions,
|
setHostPermissions,
|
||||||
|
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
|||||||
import { useWindowControls } from '../application/state/useWindowControls';
|
import { useWindowControls } from '../application/state/useWindowControls';
|
||||||
import { useFileUpload } from '../application/state/useFileUpload';
|
import { useFileUpload } from '../application/state/useFileUpload';
|
||||||
import type {
|
import type {
|
||||||
AgentModelPreset,
|
|
||||||
AIPermissionMode,
|
AIPermissionMode,
|
||||||
|
AIToolIntegrationMode,
|
||||||
AISession,
|
AISession,
|
||||||
AISessionScope,
|
AISessionScope,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
@@ -39,7 +39,11 @@ import AgentSelector from './ai/AgentSelector';
|
|||||||
import ChatInput from './ai/ChatInput';
|
import ChatInput from './ai/ChatInput';
|
||||||
import ChatMessageList from './ai/ChatMessageList';
|
import ChatMessageList from './ai/ChatMessageList';
|
||||||
import ConversationExport from './ai/ConversationExport';
|
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 { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
|
||||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||||
@@ -89,6 +93,7 @@ interface AIChatSidePanelProps {
|
|||||||
|
|
||||||
// Agent info
|
// Agent info
|
||||||
defaultAgentId: string;
|
defaultAgentId: string;
|
||||||
|
toolIntegrationMode: AIToolIntegrationMode;
|
||||||
externalAgents: ExternalAgentConfig[];
|
externalAgents: ExternalAgentConfig[];
|
||||||
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||||
agentModelMap: Record<string, string>;
|
agentModelMap: Record<string, string>;
|
||||||
@@ -210,6 +215,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
|||||||
activeProviderId,
|
activeProviderId,
|
||||||
activeModelId,
|
activeModelId,
|
||||||
defaultAgentId,
|
defaultAgentId,
|
||||||
|
toolIntegrationMode,
|
||||||
externalAgents,
|
externalAgents,
|
||||||
setExternalAgents,
|
setExternalAgents,
|
||||||
agentModelMap,
|
agentModelMap,
|
||||||
@@ -241,6 +247,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
|||||||
|
|
||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
||||||
|
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
|
||||||
|
|
||||||
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
|
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
|
||||||
const { openSettingsWindow } = useWindowControls();
|
const { openSettingsWindow } = useWindowControls();
|
||||||
@@ -305,6 +312,29 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
|||||||
return historySessions[0] ?? null;
|
return historySessions[0] ?? null;
|
||||||
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||||
|
|
||||||
|
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||||
|
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 activeSessionId = activeSession?.id ?? activeSessionIdForScope;
|
||||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||||
|
|
||||||
@@ -440,7 +470,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
|||||||
|
|
||||||
const providerDisplayName = activeProvider?.name ?? '';
|
const providerDisplayName = activeProvider?.name ?? '';
|
||||||
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
|
||||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
|
|
||||||
|
|
||||||
// Agent model presets for the current external agent
|
// Agent model presets for the current external agent
|
||||||
const currentAgentConfig = useMemo(
|
const currentAgentConfig = useMemo(
|
||||||
@@ -452,8 +481,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
|||||||
[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);
|
const agentModelMapRef = useRef(agentModelMap);
|
||||||
agentModelMapRef.current = agentModelMap;
|
agentModelMapRef.current = agentModelMap;
|
||||||
|
|
||||||
@@ -495,7 +522,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
|||||||
|
|
||||||
const agentModelPresets = useMemo(
|
const agentModelPresets = useMemo(
|
||||||
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
|
() => 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
|
// Per-agent model: recall last selection or use first preset as default
|
||||||
@@ -677,8 +704,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
|||||||
updateExternalSessionId: updateSessionExternalSessionId,
|
updateExternalSessionId: updateSessionExternalSessionId,
|
||||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||||
terminalSessions,
|
terminalSessions,
|
||||||
|
defaultTargetSession,
|
||||||
providers,
|
providers,
|
||||||
selectedAgentModel,
|
selectedAgentModel,
|
||||||
|
toolIntegrationMode,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reportStreamError(sessionId, abortController.signal, err);
|
reportStreamError(sessionId, abortController.signal, err);
|
||||||
@@ -714,8 +743,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
|||||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||||
setStreamingForScope, setInputValue, clearFiles,
|
setStreamingForScope, setInputValue, clearFiles,
|
||||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||||
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
|
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
|
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
|
||||||
|
toolIntegrationMode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleStop = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ const SettingsAITabContainer: React.FC = () => {
|
|||||||
setActiveModelId={aiState.setActiveModelId}
|
setActiveModelId={aiState.setActiveModelId}
|
||||||
globalPermissionMode={aiState.globalPermissionMode}
|
globalPermissionMode={aiState.globalPermissionMode}
|
||||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||||
|
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||||
|
setToolIntegrationMode={aiState.setToolIntegrationMode}
|
||||||
externalAgents={aiState.externalAgents}
|
externalAgents={aiState.externalAgents}
|
||||||
setExternalAgents={aiState.setExternalAgents}
|
setExternalAgents={aiState.setExternalAgents}
|
||||||
defaultAgentId={aiState.defaultAgentId}
|
defaultAgentId={aiState.defaultAgentId}
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
|||||||
activeProviderId={aiState.activeProviderId}
|
activeProviderId={aiState.activeProviderId}
|
||||||
activeModelId={aiState.activeModelId}
|
activeModelId={aiState.activeModelId}
|
||||||
defaultAgentId={aiState.defaultAgentId}
|
defaultAgentId={aiState.defaultAgentId}
|
||||||
|
toolIntegrationMode={aiState.toolIntegrationMode}
|
||||||
externalAgents={aiState.externalAgents}
|
externalAgents={aiState.externalAgents}
|
||||||
setExternalAgents={aiState.setExternalAgents}
|
setExternalAgents={aiState.setExternalAgents}
|
||||||
agentModelMap={aiState.agentModelMap}
|
agentModelMap={aiState.agentModelMap}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|||||||
import { streamText, stepCountIs, type ModelMessage } from 'ai';
|
import { streamText, stepCountIs, type ModelMessage } from 'ai';
|
||||||
import type {
|
import type {
|
||||||
AIPermissionMode,
|
AIPermissionMode,
|
||||||
|
AIToolIntegrationMode,
|
||||||
AISession,
|
AISession,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatMessageAttachment,
|
ChatMessageAttachment,
|
||||||
@@ -137,6 +138,10 @@ export interface TerminalSessionInfo {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DefaultTargetSessionHint extends TerminalSessionInfo {
|
||||||
|
source: 'scope-target' | 'only-connected-in-scope';
|
||||||
|
}
|
||||||
|
|
||||||
/** Typed accessor for the netcatty bridge on the window object. */
|
/** Typed accessor for the netcatty bridge on the window object. */
|
||||||
export function getNetcattyBridge(): PanelBridge | undefined {
|
export function getNetcattyBridge(): PanelBridge | undefined {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@@ -242,8 +247,10 @@ export interface SendToExternalContext {
|
|||||||
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||||
terminalSessions: TerminalSessionInfo[];
|
terminalSessions: TerminalSessionInfo[];
|
||||||
|
defaultTargetSession?: DefaultTargetSessionHint;
|
||||||
providers: ProviderConfig[];
|
providers: ProviderConfig[];
|
||||||
selectedAgentModel?: string;
|
selectedAgentModel?: string;
|
||||||
|
toolIntegrationMode: AIToolIntegrationMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
@@ -635,6 +642,8 @@ export function useAIChatStreaming({
|
|||||||
context.existingSessionId,
|
context.existingSessionId,
|
||||||
context.historyMessages,
|
context.historyMessages,
|
||||||
attachedImages.length > 0 ? attachedImages : undefined,
|
attachedImages.length > 0 ? attachedImages : undefined,
|
||||||
|
context.toolIntegrationMode,
|
||||||
|
context.defaultTargetSession,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: spawn as raw process
|
// Fallback: spawn as raw process
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||||||
import type {
|
import type {
|
||||||
AIPermissionMode,
|
AIPermissionMode,
|
||||||
AIProviderId,
|
AIProviderId,
|
||||||
|
AIToolIntegrationMode,
|
||||||
ExternalAgentConfig,
|
ExternalAgentConfig,
|
||||||
ProviderConfig,
|
ProviderConfig,
|
||||||
WebSearchConfig,
|
WebSearchConfig,
|
||||||
@@ -61,6 +62,8 @@ interface SettingsAITabProps {
|
|||||||
setActiveModelId: (id: string) => void;
|
setActiveModelId: (id: string) => void;
|
||||||
globalPermissionMode: AIPermissionMode;
|
globalPermissionMode: AIPermissionMode;
|
||||||
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
|
setGlobalPermissionMode: (mode: AIPermissionMode) => void;
|
||||||
|
toolIntegrationMode: AIToolIntegrationMode;
|
||||||
|
setToolIntegrationMode: (mode: AIToolIntegrationMode) => void;
|
||||||
externalAgents: ExternalAgentConfig[];
|
externalAgents: ExternalAgentConfig[];
|
||||||
setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
setExternalAgents: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void;
|
||||||
defaultAgentId: string;
|
defaultAgentId: string;
|
||||||
@@ -138,6 +141,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
|||||||
setActiveModelId,
|
setActiveModelId,
|
||||||
globalPermissionMode,
|
globalPermissionMode,
|
||||||
setGlobalPermissionMode,
|
setGlobalPermissionMode,
|
||||||
|
toolIntegrationMode,
|
||||||
|
setToolIntegrationMode,
|
||||||
externalAgents,
|
externalAgents,
|
||||||
setExternalAgents,
|
setExternalAgents,
|
||||||
defaultAgentId,
|
defaultAgentId,
|
||||||
@@ -585,6 +590,30 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bot size={18} className="text-muted-foreground" />
|
||||||
|
<h3 className="text-base font-medium">{t('ai.toolAccess.title')}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/30 rounded-lg p-4">
|
||||||
|
<SettingRow
|
||||||
|
label={t('ai.toolAccess.mode')}
|
||||||
|
description={t('ai.toolAccess.description')}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={toolIntegrationMode}
|
||||||
|
options={[
|
||||||
|
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
|
||||||
|
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
|
||||||
|
]}
|
||||||
|
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* -- Web Search Section -- */}
|
{/* -- Web Search Section -- */}
|
||||||
<WebSearchSettings
|
<WebSearchSettings
|
||||||
webSearchConfig={webSearchConfig}
|
webSearchConfig={webSearchConfig}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ module.exports = {
|
|||||||
'electron/**/*',
|
'electron/**/*',
|
||||||
'lib/**/*.cjs',
|
'lib/**/*.cjs',
|
||||||
'!electron/.dev-config.json',
|
'!electron/.dev-config.json',
|
||||||
|
'skills/**/*',
|
||||||
'public/**/*',
|
'public/**/*',
|
||||||
'node_modules/**/*'
|
'node_modules/**/*'
|
||||||
],
|
],
|
||||||
@@ -41,7 +42,10 @@ module.exports = {
|
|||||||
'node_modules/fast-deep-equal/**/*',
|
'node_modules/fast-deep-equal/**/*',
|
||||||
'node_modules/fast-uri/**/*',
|
'node_modules/fast-uri/**/*',
|
||||||
'node_modules/json-schema-traverse/**/*',
|
'node_modules/json-schema-traverse/**/*',
|
||||||
|
'electron/cli/**/*',
|
||||||
'electron/mcp/**/*'
|
'electron/mcp/**/*'
|
||||||
|
,
|
||||||
|
'skills/**/*'
|
||||||
],
|
],
|
||||||
mac: {
|
mac: {
|
||||||
target: [
|
target: [
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const fs = require("node:fs");
|
|||||||
const { existsSync } = fs;
|
const { existsSync } = fs;
|
||||||
|
|
||||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||||
|
const { getCliLauncherPath, TOOL_CLI_DISCOVERY_ENV_VAR } = require("../cli/discoveryPath.cjs");
|
||||||
|
|
||||||
// ── Extracted modules ──
|
// ── Extracted modules ──
|
||||||
const {
|
const {
|
||||||
@@ -24,6 +25,7 @@ const {
|
|||||||
resolveClaudeAcpBinaryPath,
|
resolveClaudeAcpBinaryPath,
|
||||||
getShellEnv,
|
getShellEnv,
|
||||||
serializeStreamChunk,
|
serializeStreamChunk,
|
||||||
|
toUnpackedAsarPath,
|
||||||
} = require("./ai/shellUtils.cjs");
|
} = require("./ai/shellUtils.cjs");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -42,6 +44,133 @@ const {
|
|||||||
setCodexValidationCache,
|
setCodexValidationCache,
|
||||||
} = require("./ai/codexHelpers.cjs");
|
} = require("./ai/codexHelpers.cjs");
|
||||||
|
|
||||||
|
const DEBUG_MCP = process.env.NETCATTY_MCP_DEBUG === "1";
|
||||||
|
const NETCATTY_TOOL_SKILL_PATH = toUnpackedAsarPath(
|
||||||
|
path.resolve(__dirname, "../../skills/netcatty-tool-cli/SKILL.md"),
|
||||||
|
);
|
||||||
|
const NETCATTY_TOOL_LAUNCHER_PATH = getCliLauncherPath();
|
||||||
|
const NETCATTY_TOOL_CLI_PATH = toUnpackedAsarPath(
|
||||||
|
path.resolve(__dirname, "../cli/netcatty-tool-cli.cjs"),
|
||||||
|
);
|
||||||
|
|
||||||
|
function debugMcpLog(...args) {
|
||||||
|
if (!DEBUG_MCP) return;
|
||||||
|
console.error("[AI Bridge:debug]", ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeToolIntegrationMode(mode) {
|
||||||
|
return mode === "skills" ? "skills" : "mcp";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setToolIntegrationMode(mode) {
|
||||||
|
// Tool access mode is selected per ACP request. The TCP bridge host is shared
|
||||||
|
// by both MCP and Skills + CLI, so changing the setting must not tear down
|
||||||
|
// unrelated in-flight sessions, approvals, or background jobs.
|
||||||
|
return normalizeToolIntegrationMode(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureSkillsCliHost() {
|
||||||
|
return mcpServerBridge.getOrCreateHost();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSkillsCliInvocation() {
|
||||||
|
if (existsSync(NETCATTY_TOOL_LAUNCHER_PATH)) {
|
||||||
|
return {
|
||||||
|
commandPrefix: `"${NETCATTY_TOOL_LAUNCHER_PATH}"`,
|
||||||
|
launcherPath: NETCATTY_TOOL_LAUNCHER_PATH,
|
||||||
|
usesLauncher: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (existsSync(NETCATTY_TOOL_CLI_PATH)) {
|
||||||
|
return {
|
||||||
|
commandPrefix: `node "${NETCATTY_TOOL_CLI_PATH}"`,
|
||||||
|
launcherPath: null,
|
||||||
|
usesLauncher: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
commandPrefix: "netcatty-tool-cli",
|
||||||
|
launcherPath: null,
|
||||||
|
usesLauncher: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExternalAgentContextualPrompt({ mode, prompt, chatSessionId, defaultTargetSession }) {
|
||||||
|
if (mode === "skills") {
|
||||||
|
const { commandPrefix: cliCommandPrefix, launcherPath, usesLauncher } = getSkillsCliInvocation();
|
||||||
|
const skillHint = existsSync(NETCATTY_TOOL_SKILL_PATH)
|
||||||
|
? `The local Netcatty skill file is "${NETCATTY_TOOL_SKILL_PATH}". You do not need to read it for routine read-only requests if the host instructions here are sufficient. Only open it when the task is unusual, multi-step, or you are unsure about the workflow. `
|
||||||
|
: "";
|
||||||
|
const cliHint = usesLauncher
|
||||||
|
? (
|
||||||
|
`For this chat session, the Netcatty CLI launcher is at \`${launcherPath}\`. ` +
|
||||||
|
`Invoke that launcher directly for every Netcatty CLI call, and do not prepend \`node\`. ` +
|
||||||
|
(process.platform === "win32"
|
||||||
|
? `If your execution surface supports argv-style execution, use that launcher path as the executable and pass subcommands/flags as separate arguments. If you need a literal shell command line, invoke it as \`${cliCommandPrefix}\`. `
|
||||||
|
: `The literal shell command prefix is \`${cliCommandPrefix}\`. `)
|
||||||
|
)
|
||||||
|
: existsSync(NETCATTY_TOOL_CLI_PATH)
|
||||||
|
? `For this chat session, the exact Netcatty CLI command prefix is \`${cliCommandPrefix}\`.`
|
||||||
|
: "Use the exact Netcatty CLI command prefix provided by the host application for this chat session. ";
|
||||||
|
const scopeHint = chatSessionId
|
||||||
|
? `Always include \`--chat-session ${chatSessionId}\` on every Netcatty CLI call so you stay inside the current scoped session set. `
|
||||||
|
: "";
|
||||||
|
const defaultTargetHint = defaultTargetSession
|
||||||
|
? (
|
||||||
|
`The host has already identified the default target session for this AI panel: ` +
|
||||||
|
`sessionId="${defaultTargetSession.sessionId}", ` +
|
||||||
|
`label="${defaultTargetSession.label || ""}", ` +
|
||||||
|
`hostname="${defaultTargetSession.hostname || ""}", ` +
|
||||||
|
`protocol="${defaultTargetSession.protocol || ""}", ` +
|
||||||
|
`connected=${defaultTargetSession.connected !== false}. ` +
|
||||||
|
(defaultTargetSession.connected !== false
|
||||||
|
? `For routine requests that do not mention another session or host, use this default target directly and prefer \`${cliCommandPrefix} session --session ${defaultTargetSession.sessionId} --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` as the first call instead of starting with \`env\` discovery. Only run \`env\` when the user explicitly points to another session (for example with @), when the task is ambiguous, or when that direct session lookup fails. `
|
||||||
|
: `This default target is currently not connected, so do not execute against it directly. Fall back to \`env\` / \`session\` lookup if the user may want another available session. `)
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
const discoveryHint = defaultTargetSession?.connected !== false
|
||||||
|
? `If you do need discovery because the task is ambiguous or points to another session, start with \`${cliCommandPrefix} env --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` to discover available sessions and their IDs. `
|
||||||
|
: `Start with \`${cliCommandPrefix} env --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` to discover available sessions and their IDs. `;
|
||||||
|
|
||||||
|
return (
|
||||||
|
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
|
||||||
|
`${skillHint}` +
|
||||||
|
`${cliHint}` +
|
||||||
|
`${scopeHint}` +
|
||||||
|
`${defaultTargetHint}` +
|
||||||
|
`Use Skills + CLI instead of the "netcatty-remote-hosts" MCP server for Netcatty session access. ` +
|
||||||
|
`First classify the task: remote command execution tasks go through \`exec\`, while remote file or directory tasks go through \`sftp\`. If the user explicitly says to avoid shell or \`exec\`, do not use \`exec\`. Treat \`exec\` as the short-command path only: use it only for commands expected to finish within about 60 seconds. For builds, scans, watch mode, tail-following, ping, or anything likely to exceed that budget or stream output for an extended period, do not use plain \`exec\`; use the long-running job commands instead. ` +
|
||||||
|
`${discoveryHint}` +
|
||||||
|
`After choosing a target session ID, call \`${cliCommandPrefix} session --session <id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` before executing anything. Do not infer protocol, shell type, device type, or connection readiness from the \`env\` result alone when you are about to run a command. ` +
|
||||||
|
`For remote file operations, use the Netcatty SFTP CLI surface instead of trying to reconstruct SSH credentials or open your own SSH/SFTP connection, but only when the chosen session is SSH-backed and connected. After the required \`session --session <id>\` confirmation step, inspect the reported protocol, shell type, device type, and connected state before picking a file-operation path. For SSH-backed sessions, prefer one-off commands such as \`${cliCommandPrefix} sftp list --session <id> --remote-path <remote-path> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`, \`${cliCommandPrefix} sftp read --session <id> --remote-path <remote-path> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`, \`${cliCommandPrefix} sftp write --session <id> --remote-path <remote-path> --content <text> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`, \`${cliCommandPrefix} sftp download --session <id> --remote-path <remote-path> --local-path <local-path> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`, or \`${cliCommandPrefix} sftp upload --session <id> --local-path <local-path> --remote-path <remote-path> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\`. For local sessions, use normal local filesystem tools instead of Netcatty SFTP. For Mosh, Telnet, serial/raw, or network-device sessions, do not call SFTP; use a real SSH session, vendor CLI commands, or tell the user that the requested file transfer is unsupported on that transport. ` +
|
||||||
|
`Keep local and remote path semantics strict: \`--remote-path\` always refers to the remote host, while \`--local-path\` always refers to the local machine running Netcatty. If the user asks to download a file to a local destination such as \`/tmp\`, \`~/Downloads\`, or a desktop path, use \`sftp download\`, not \`sftp read\` or \`sftp write\`. If the user asks to create or modify a file on the remote host, use \`sftp write\` or another remote SFTP operation, not \`sftp download\`. ` +
|
||||||
|
`If you need to create or update a small text file with known content on the remote host, prefer \`${cliCommandPrefix} sftp write ...\` directly. Use \`sftp upload\` only when a real local file already exists and must be transferred to the remote host. Do not create temporary local files just to upload text that could be sent with \`sftp write\`. ` +
|
||||||
|
`Keep SFTP usage one-off and explicit: every \`sftp\` command should include both \`--session <id>\` and \`--chat-session ${chatSessionId || "<chat-session-id>"}\`. Do not open reusable SFTP handles or use \`--sftp <id>\`. ` +
|
||||||
|
`Run Netcatty CLI calls strictly one at a time. Do not issue concurrent or background Netcatty CLI commands for the same chat session, and always wait for each call to finish before starting the next one. ` +
|
||||||
|
`For simple read-only requests such as hostname, IP address, CPU info, memory info, disk usage, pwd, whoami, uname, or process checks, use the shortest possible path: one \`env\`, one \`session\`, then one \`exec\`. Prefer a single straightforward command over creating helper scripts or multi-step shell orchestration. ` +
|
||||||
|
`For long-running command tasks, start them with \`${cliCommandPrefix} job-start --session <id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""} -- <command>\`, then use \`${cliCommandPrefix} job-poll --job <job-id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` to fetch incremental output, and \`${cliCommandPrefix} job-stop --job <job-id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""}\` if the user asks to stop them. Do not poll aggressively; wait roughly 30 seconds between polls unless the output clearly justifies checking sooner. ` +
|
||||||
|
`For those simple read-only requests, do not spend time reading extra files, designing scripts, or narrating a plan unless the first direct command fails or the session metadata shows a special device type. ` +
|
||||||
|
`Do not create temporary scripts, JSON post-processing scripts, or extra wrapper commands unless the task genuinely requires logic that cannot fit cleanly in one direct command. ` +
|
||||||
|
`Avoid shell command substitution such as \`$()\` and backticks, because Netcatty safety policy may block them. Prefer straightforward command chains such as \`hostname && hostname -I && lscpu\`. ` +
|
||||||
|
`Avoid wrapping simple commands in \`sh -c\`, \`bash -c\`, or similar shell launcher patterns unless the task genuinely requires shell parsing that cannot be expressed as a direct command. ` +
|
||||||
|
`Do not spend time narrating intent before every CLI call for routine read-only checks. Execute the minimal command sequence and then report the result. ` +
|
||||||
|
`Only after that confirmation step should you call \`${cliCommandPrefix} exec --session <id> --json${chatSessionId ? ` --chat-session ${chatSessionId}` : ""} -- <command>\` for command execution. ` +
|
||||||
|
`If the user stops the run or asks to abort outstanding Netcatty work, use \`${cliCommandPrefix} cancel --chat-session ${chatSessionId || "<chat-session-id>"} --json\`, and use \`resume\` to re-enable execs for that scope if needed. ` +
|
||||||
|
`For serial/raw sessions and network device sessions (deviceType: network), commands are sent as-is without shell wrapping and exit codes are unavailable. Use vendor CLI commands directly.]\n\n${prompt}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
|
||||||
|
`Use the "netcatty-remote-hosts" MCP tools to operate only on the terminal sessions exposed by Netcatty. ` +
|
||||||
|
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
|
||||||
|
`Call get_environment first to discover available sessions and their IDs. ` +
|
||||||
|
`Use terminal_execute only for commands likely to finish within about 60 seconds. ` +
|
||||||
|
`For long-running commands such as builds, scans, follow/log streaming, watch commands, or anything likely to exceed 60 seconds on PTY-backed shell sessions, use terminal_start, then terminal_poll until completed is true. Reuse the returned nextOffset for the next poll. If terminal_poll reports outputTruncated=true, only the retained tail starting at outputBaseOffset is still available. Do not poll aggressively: wait at least about 30 seconds between polls, and increase the interval further when there is no new output, to avoid wasting tokens. As soon as completed is true, stop polling and analyze the result immediately. ` +
|
||||||
|
`Use terminal_stop if you need to interrupt a started long-running command. Note: terminal_start requires a PTY-backed session; for sessions that only support exec-channel execution (no writable PTY), use terminal_execute instead. ` +
|
||||||
|
`For serial/raw sessions and network device sessions (deviceType: network), commands are sent as-is without shell wrapping and exit codes are unavailable. Use vendor CLI commands directly.]\n\n${prompt}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { execViaPty } = require("./ai/ptyExec.cjs");
|
const { execViaPty } = require("./ai/ptyExec.cjs");
|
||||||
|
|
||||||
@@ -49,6 +178,7 @@ let sessions = null;
|
|||||||
let sftpClients = null;
|
let sftpClients = null;
|
||||||
let electronModule = null;
|
let electronModule = null;
|
||||||
let mainWebContentsId = null;
|
let mainWebContentsId = null;
|
||||||
|
let cliDiscoveryFilePath = null;
|
||||||
|
|
||||||
// Active streaming requests (for cancellation)
|
// Active streaming requests (for cancellation)
|
||||||
const activeStreams = new Map();
|
const activeStreams = new Map();
|
||||||
@@ -249,7 +379,8 @@ function init(deps) {
|
|||||||
sessions = deps.sessions;
|
sessions = deps.sessions;
|
||||||
sftpClients = deps.sftpClients;
|
sftpClients = deps.sftpClients;
|
||||||
electronModule = deps.electronModule;
|
electronModule = deps.electronModule;
|
||||||
mcpServerBridge.init({ sessions, sftpClients, electronModule });
|
cliDiscoveryFilePath = deps.cliDiscoveryFilePath || null;
|
||||||
|
mcpServerBridge.init({ sessions, sftpClients, electronModule, cliDiscoveryFilePath });
|
||||||
|
|
||||||
// Wire up main window getter for MCP approval IPC
|
// Wire up main window getter for MCP approval IPC
|
||||||
mcpServerBridge.setMainWindowGetter(() => {
|
mcpServerBridge.setMainWindowGetter(() => {
|
||||||
@@ -272,6 +403,15 @@ function init(deps) {
|
|||||||
} catch {
|
} catch {
|
||||||
// windowManager may not be available yet; will be set lazily
|
// windowManager may not be available yet; will be set lazily
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function withCliDiscoveryEnv(env) {
|
||||||
|
if (!cliDiscoveryFilePath) return env;
|
||||||
|
return {
|
||||||
|
...env,
|
||||||
|
[TOOL_CLI_DISCOVERY_ENV_VAR]: cliDiscoveryFilePath,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1167,6 +1307,7 @@ function registerHandlers(ipcMain) {
|
|||||||
return { ok: false, error: "Unauthorized IPC sender" };
|
return { ok: false, error: "Unauthorized IPC sender" };
|
||||||
}
|
}
|
||||||
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
|
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
|
||||||
|
void mcpServerBridge.cancelSftpOpsForSession?.(chatSessionId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1923,6 +2064,16 @@ function registerHandlers(ipcMain) {
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle("netcatty:ai:mcp:set-tool-integration-mode", async (event, { mode }) => {
|
||||||
|
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||||
|
const validModes = ["mcp", "skills"];
|
||||||
|
if (!validModes.includes(mode)) {
|
||||||
|
return { ok: false, error: `mode must be one of: ${validModes.join(", ")}` };
|
||||||
|
}
|
||||||
|
setToolIntegrationMode(mode);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
// ── MCP Approval response (renderer → main) ──
|
// ── MCP Approval response (renderer → main) ──
|
||||||
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved }) => {
|
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved }) => {
|
||||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||||
@@ -1951,7 +2102,7 @@ function registerHandlers(ipcMain) {
|
|||||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||||
const apiKey = resolvedProvider?.apiKey || undefined;
|
const apiKey = resolvedProvider?.apiKey || undefined;
|
||||||
|
|
||||||
const agentEnv = { ...shellEnv };
|
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
agentEnv.CODEX_API_KEY = apiKey;
|
agentEnv.CODEX_API_KEY = apiKey;
|
||||||
}
|
}
|
||||||
@@ -2031,7 +2182,7 @@ function registerHandlers(ipcMain) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images }) => {
|
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession }) => {
|
||||||
// Validate IPC sender (Issue #17)
|
// Validate IPC sender (Issue #17)
|
||||||
if (!validateSender(event)) {
|
if (!validateSender(event)) {
|
||||||
return { ok: false, error: "Unauthorized IPC sender" };
|
return { ok: false, error: "Unauthorized IPC sender" };
|
||||||
@@ -2076,6 +2227,19 @@ function registerHandlers(ipcMain) {
|
|||||||
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
|
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
|
||||||
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
|
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
|
||||||
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
|
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
|
||||||
|
const effectiveToolIntegrationMode = normalizeToolIntegrationMode(toolIntegrationMode);
|
||||||
|
debugMcpLog("ACP request start", {
|
||||||
|
requestId,
|
||||||
|
chatSessionId,
|
||||||
|
acpCommand,
|
||||||
|
acpArgs,
|
||||||
|
model,
|
||||||
|
providerId,
|
||||||
|
sessionCwd,
|
||||||
|
isCodexAgent,
|
||||||
|
isClaudeAgent,
|
||||||
|
toolIntegrationMode: effectiveToolIntegrationMode,
|
||||||
|
});
|
||||||
|
|
||||||
// Resolve API key from providerId (decrypted in main process only)
|
// Resolve API key from providerId (decrypted in main process only)
|
||||||
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
|
||||||
@@ -2108,12 +2272,35 @@ function registerHandlers(ipcMain) {
|
|||||||
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
||||||
if (shouldAbortStartup()) return { ok: true };
|
if (shouldAbortStartup()) return { ok: true };
|
||||||
|
|
||||||
// Inject Netcatty MCP server for scoped terminal-session access
|
setToolIntegrationMode(effectiveToolIntegrationMode);
|
||||||
|
if (effectiveToolIntegrationMode === "skills") {
|
||||||
|
try {
|
||||||
|
await ensureSkillsCliHost();
|
||||||
|
} catch (err) {
|
||||||
|
const message = err?.message || String(err);
|
||||||
|
safeSend(event.sender, "netcatty:ai:acp:error", {
|
||||||
|
requestId,
|
||||||
|
error: `Failed to initialize Netcatty Skills + CLI bridge.\n\nDetails: ${message}`,
|
||||||
|
});
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject Netcatty MCP server for scoped terminal-session access only when
|
||||||
|
// the user selected MCP mode. Skills mode uses the Netcatty CLI instead.
|
||||||
|
if (effectiveToolIntegrationMode === "mcp") {
|
||||||
try {
|
try {
|
||||||
const mcpPort = await mcpServerBridge.getOrCreateHost();
|
const mcpPort = await mcpServerBridge.getOrCreateHost();
|
||||||
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
|
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
|
||||||
const netcattyMcpConfig = mcpServerBridge.buildMcpServerConfig(mcpPort, scopedIds, chatSessionId);
|
const netcattyMcpConfig = mcpServerBridge.buildMcpServerConfig(mcpPort, scopedIds, chatSessionId);
|
||||||
mcpSnapshot.mcpServers.push(netcattyMcpConfig);
|
mcpSnapshot.mcpServers.push(netcattyMcpConfig);
|
||||||
|
debugMcpLog("Injected Netcatty MCP server", {
|
||||||
|
requestId,
|
||||||
|
chatSessionId,
|
||||||
|
mcpPort,
|
||||||
|
scopedIds,
|
||||||
|
mcpServerNames: mcpSnapshot.mcpServers.map(server => server.name),
|
||||||
|
});
|
||||||
if (isCopilotAgent) {
|
if (isCopilotAgent) {
|
||||||
logAcpDebug(agentLabel, "Injected Netcatty MCP server into session", {
|
logAcpDebug(agentLabel, "Injected Netcatty MCP server into session", {
|
||||||
chatSessionId,
|
chatSessionId,
|
||||||
@@ -2124,6 +2311,7 @@ function registerHandlers(ipcMain) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
|
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (shouldAbortStartup()) return { ok: true };
|
if (shouldAbortStartup()) return { ok: true };
|
||||||
|
|
||||||
// Recalculate fingerprint after injection
|
// Recalculate fingerprint after injection
|
||||||
@@ -2144,7 +2332,7 @@ function registerHandlers(ipcMain) {
|
|||||||
const resumeSessionId = providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
|
const resumeSessionId = providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
|
||||||
cleanupAcpProvider(chatSessionId);
|
cleanupAcpProvider(chatSessionId);
|
||||||
|
|
||||||
const agentEnv = { ...shellEnv };
|
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
|
||||||
if (apiKey) {
|
if (apiKey) {
|
||||||
agentEnv.CODEX_API_KEY = apiKey;
|
agentEnv.CODEX_API_KEY = apiKey;
|
||||||
}
|
}
|
||||||
@@ -2184,6 +2372,14 @@ function registerHandlers(ipcMain) {
|
|||||||
: {}),
|
: {}),
|
||||||
persistSession: true,
|
persistSession: true,
|
||||||
});
|
});
|
||||||
|
debugMcpLog("Created ACP provider", {
|
||||||
|
requestId,
|
||||||
|
chatSessionId,
|
||||||
|
resolvedCommand,
|
||||||
|
resolvedArgs,
|
||||||
|
mcpServerNames: mcpSnapshot.mcpServers.map(server => server.name),
|
||||||
|
authMethodId: isCodexAgent ? (apiKey ? "codex-api-key" : "chatgpt") : null,
|
||||||
|
});
|
||||||
|
|
||||||
if (isCopilotAgent) {
|
if (isCopilotAgent) {
|
||||||
logAcpDebug(agentLabel, "Creating ACP provider", {
|
logAcpDebug(agentLabel, "Creating ACP provider", {
|
||||||
@@ -2213,6 +2409,11 @@ function registerHandlers(ipcMain) {
|
|||||||
let modelInstance = providerEntry.provider.languageModel(model || undefined);
|
let modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||||
try {
|
try {
|
||||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||||
|
debugMcpLog("provider.initSession ok", {
|
||||||
|
requestId,
|
||||||
|
chatSessionId,
|
||||||
|
providerSessionId: providerEntry.provider.getSessionId?.() || null,
|
||||||
|
});
|
||||||
if (isCopilotAgent) {
|
if (isCopilotAgent) {
|
||||||
logAcpDebug(agentLabel, "ACP session initialized", {
|
logAcpDebug(agentLabel, "ACP session initialized", {
|
||||||
requestId,
|
requestId,
|
||||||
@@ -2223,6 +2424,11 @@ function registerHandlers(ipcMain) {
|
|||||||
}
|
}
|
||||||
if (shouldAbortStartup()) return { ok: true };
|
if (shouldAbortStartup()) return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
debugMcpLog("provider.initSession error", {
|
||||||
|
requestId,
|
||||||
|
chatSessionId,
|
||||||
|
message: err?.message || String(err),
|
||||||
|
});
|
||||||
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
|
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
|
||||||
if (!attemptedResumeSessionId || !shouldRetryFreshSession(err)) {
|
if (!attemptedResumeSessionId || !shouldRetryFreshSession(err)) {
|
||||||
throw err;
|
throw err;
|
||||||
@@ -2245,7 +2451,9 @@ function registerHandlers(ipcMain) {
|
|||||||
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
|
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
|
||||||
: acpArgs || [],
|
: acpArgs || [],
|
||||||
env: (() => {
|
env: (() => {
|
||||||
const fallbackEnv = apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv };
|
const fallbackEnv = withCliDiscoveryEnv(
|
||||||
|
apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
|
||||||
|
);
|
||||||
if (isCopilotAgent) {
|
if (isCopilotAgent) {
|
||||||
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
|
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
|
||||||
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
|
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
|
||||||
@@ -2276,6 +2484,11 @@ function registerHandlers(ipcMain) {
|
|||||||
acpProviders.set(chatSessionId, providerEntry);
|
acpProviders.set(chatSessionId, providerEntry);
|
||||||
modelInstance = providerEntry.provider.languageModel(model || undefined);
|
modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||||
|
debugMcpLog("fallback provider.initSession ok", {
|
||||||
|
requestId,
|
||||||
|
chatSessionId,
|
||||||
|
providerSessionId: providerEntry.provider.getSessionId?.() || null,
|
||||||
|
});
|
||||||
if (isCopilotAgent) {
|
if (isCopilotAgent) {
|
||||||
logAcpDebug(agentLabel, "ACP session initialized after fallback", {
|
logAcpDebug(agentLabel, "ACP session initialized after fallback", {
|
||||||
requestId,
|
requestId,
|
||||||
@@ -2294,16 +2507,13 @@ function registerHandlers(ipcMain) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepend context hint so the agent uses Netcatty MCP tools for the scoped sessions
|
// Prepend context hint so the agent uses the configured Netcatty access mode.
|
||||||
const contextualPrompt =
|
const contextualPrompt = buildExternalAgentContextualPrompt({
|
||||||
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
|
mode: effectiveToolIntegrationMode,
|
||||||
`Use the "netcatty-remote-hosts" MCP tools to operate only on the terminal sessions exposed by Netcatty. ` +
|
prompt,
|
||||||
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
|
chatSessionId,
|
||||||
`Call get_environment first to discover available sessions and their IDs. ` +
|
defaultTargetSession,
|
||||||
`Use terminal_execute only for commands likely to finish within about 60 seconds. ` +
|
});
|
||||||
`For long-running commands such as builds, scans, follow/log streaming, watch commands, or anything likely to exceed 60 seconds on PTY-backed shell sessions, use terminal_start, then terminal_poll until completed is true. Reuse the returned nextOffset for the next poll. If terminal_poll reports outputTruncated=true, only the retained tail starting at outputBaseOffset is still available. Do not poll aggressively: wait at least about 30 seconds between polls, and increase the interval further when there is no new output, to avoid wasting tokens. As soon as completed is true, stop polling and analyze the result immediately. Note: terminal_start requires a PTY-backed session; for sessions that only support exec-channel execution (no writable PTY), use terminal_execute instead. ` +
|
|
||||||
`Use terminal_stop if you need to interrupt a started long-running command. ` +
|
|
||||||
`For serial/raw sessions and network device sessions (deviceType: network), commands are sent as-is without shell wrapping and exit codes are unavailable. Use vendor CLI commands directly.]\n\n${prompt}`;
|
|
||||||
|
|
||||||
// Build message content: text + optional attachments
|
// Build message content: text + optional attachments
|
||||||
// ACP provider only supports image/* and audio/* inline via `type: "file"`.
|
// ACP provider only supports image/* and audio/* inline via `type: "file"`.
|
||||||
@@ -2401,12 +2611,18 @@ function registerHandlers(ipcMain) {
|
|||||||
const serialized = serializeStreamChunk(chunk);
|
const serialized = serializeStreamChunk(chunk);
|
||||||
if (!serialized || !serialized.type) continue;
|
if (!serialized || !serialized.type) continue;
|
||||||
|
|
||||||
if (serialized.type === "text-delta" || serialized.type === "reasoning-delta" || serialized.type === "tool-call") {
|
if (serialized.type === "text-delta" || serialized.type === "reasoning-delta" || serialized.type === "tool-call" || serialized.type === "tool-result") {
|
||||||
hasContent = true;
|
hasContent = true;
|
||||||
}
|
}
|
||||||
if (isCopilotAgent && (serialized.type === "tool-call" || serialized.type === "tool-result" || serialized.type === "error" || serialized.type === "status")) {
|
if (isCopilotAgent && (serialized.type === "tool-call" || serialized.type === "tool-result" || serialized.type === "error" || serialized.type === "status")) {
|
||||||
logAcpDebug(agentLabel, `Stream event: ${serialized.type}`, serialized);
|
logAcpDebug(agentLabel, `Stream event: ${serialized.type}`, serialized);
|
||||||
}
|
}
|
||||||
|
debugMcpLog("ACP stream event", {
|
||||||
|
requestId,
|
||||||
|
chatSessionId,
|
||||||
|
type: serialized.type,
|
||||||
|
toolName: serialized.toolName || null,
|
||||||
|
});
|
||||||
safeSend(event.sender, "netcatty:ai:acp:event", {
|
safeSend(event.sender, "netcatty:ai:acp:event", {
|
||||||
requestId,
|
requestId,
|
||||||
event: serialized,
|
event: serialized,
|
||||||
@@ -2422,6 +2638,12 @@ function registerHandlers(ipcMain) {
|
|||||||
|
|
||||||
// If stream completed with zero content, likely an auth or connection issue
|
// If stream completed with zero content, likely an auth or connection issue
|
||||||
if (!hasContent && !abortController.signal.aborted) {
|
if (!hasContent && !abortController.signal.aborted) {
|
||||||
|
debugMcpLog("ACP empty response", {
|
||||||
|
requestId,
|
||||||
|
chatSessionId,
|
||||||
|
isCodexAgent,
|
||||||
|
providerSessionId: providerEntry.provider.getSessionId?.() || null,
|
||||||
|
});
|
||||||
if (isCopilotAgent) {
|
if (isCopilotAgent) {
|
||||||
logAcpDebug(agentLabel, "Stream completed with no content", {
|
logAcpDebug(agentLabel, "Stream completed with no content", {
|
||||||
requestId,
|
requestId,
|
||||||
@@ -2439,6 +2661,7 @@ function registerHandlers(ipcMain) {
|
|||||||
: "Agent returned an empty response.",
|
: "Agent returned an empty response.",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
debugMcpLog("ACP stream done", { requestId, chatSessionId, hasContent });
|
||||||
if (!isActiveAcpRun(chatSessionId, requestId)) {
|
if (!isActiveAcpRun(chatSessionId, requestId)) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -2484,8 +2707,8 @@ function registerHandlers(ipcMain) {
|
|||||||
// launched as long-running and should keep running when the user only wants
|
// launched as long-running and should keep running when the user only wants
|
||||||
// to stop the model's polling/output. Background jobs are still cleaned up
|
// to stop the model's polling/output. Background jobs are still cleaned up
|
||||||
// when the chat session itself is deleted (see cleanupScopedMetadata).
|
// when the chat session itself is deleted (see cleanupScopedMetadata).
|
||||||
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
|
|
||||||
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
|
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
|
||||||
|
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
|
||||||
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
|
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
|
||||||
if (activeRun && activeRun.requestId === effectiveRequestId) {
|
if (activeRun && activeRun.requestId === effectiveRequestId) {
|
||||||
activeRun.cancelRequested = true;
|
activeRun.cancelRequested = true;
|
||||||
@@ -2505,6 +2728,7 @@ function registerHandlers(ipcMain) {
|
|||||||
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
|
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
|
||||||
if (effectiveChatSessionId) cancelled = true;
|
if (effectiveChatSessionId) cancelled = true;
|
||||||
if (effectiveRequestId) acpRequestSessions.delete(effectiveRequestId);
|
if (effectiveRequestId) acpRequestSessions.delete(effectiveRequestId);
|
||||||
|
void mcpServerBridge.cancelSftpOpsForSession?.(effectiveChatSessionId);
|
||||||
return cancelled ? { ok: true } : { ok: false, error: "Stream not found" };
|
return cancelled ? { ok: true } : { ok: false, error: "Stream not found" };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2512,8 +2736,9 @@ function registerHandlers(ipcMain) {
|
|||||||
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
|
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
|
||||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
|
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
|
||||||
|
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
|
||||||
cleanupAcpProvider(chatSessionId);
|
cleanupAcpProvider(chatSessionId);
|
||||||
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
|
await mcpServerBridge.cleanupScopedMetadata(chatSessionId);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2583,6 +2808,7 @@ function cleanup() {
|
|||||||
}
|
}
|
||||||
codexLoginSessions.clear();
|
codexLoginSessions.clear();
|
||||||
invalidateCodexValidationCache();
|
invalidateCodexValidationCache();
|
||||||
|
mcpServerBridge.cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, registerHandlers, cleanup };
|
module.exports = { init, registerHandlers, cleanup };
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@
|
|||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
|
const { pipeline } = require("node:stream/promises");
|
||||||
const { TextDecoder } = require("node:util");
|
const { TextDecoder } = require("node:util");
|
||||||
const SftpClient = require("ssh2-sftp-client");
|
const SftpClient = require("ssh2-sftp-client");
|
||||||
const { Client: SSHClient } = require("ssh2");
|
const { Client: SSHClient } = require("ssh2");
|
||||||
@@ -22,6 +23,7 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
|||||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||||
|
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||||
const {
|
const {
|
||||||
buildAuthHandler,
|
buildAuthHandler,
|
||||||
@@ -36,6 +38,7 @@ const {
|
|||||||
// SFTP clients storage - shared reference passed from main
|
// SFTP clients storage - shared reference passed from main
|
||||||
let sftpClients = null;
|
let sftpClients = null;
|
||||||
let electronModule = null;
|
let electronModule = null;
|
||||||
|
let sessions = null;
|
||||||
|
|
||||||
// Storage for jump host connections that need to be cleaned up
|
// Storage for jump host connections that need to be cleaned up
|
||||||
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
|
const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], socket: stream }
|
||||||
@@ -44,9 +47,39 @@ const jumpConnectionsMap = new Map(); // connId -> { connections: SSHClient[], s
|
|||||||
const activeSftpUploads = new Map(); // transferId -> { cancelled: boolean, stream: Readable }
|
const activeSftpUploads = new Map(); // transferId -> { cancelled: boolean, stream: Readable }
|
||||||
|
|
||||||
// Track requested/resolved filename encoding per SFTP session
|
// Track requested/resolved filename encoding per SFTP session
|
||||||
const sftpEncodingState = new Map(); // sftpId -> { requested: 'auto'|'utf-8'|'gb18030', resolved: 'utf-8'|'gb18030' }
|
const sftpEncodingState = new Map(); // stateKey -> { requested: 'auto'|'utf-8'|'gb18030', resolved: 'utf-8'|'gb18030' }
|
||||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||||
|
|
||||||
|
const cloneEncodingState = (value) => (
|
||||||
|
value && typeof value === "object"
|
||||||
|
? { requested: value.requested || "auto", resolved: value.resolved || "utf-8" }
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
function copySftpEncodingState(sourceKey, targetKey) {
|
||||||
|
if (!sourceKey || !targetKey || sourceKey === targetKey) return;
|
||||||
|
const state = cloneEncodingState(sftpEncodingState.get(sourceKey));
|
||||||
|
if (state) {
|
||||||
|
sftpEncodingState.set(targetKey, state);
|
||||||
|
} else {
|
||||||
|
sftpEncodingState.delete(targetKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSftpEncodingState(stateKey) {
|
||||||
|
if (!stateKey) return;
|
||||||
|
sftpEncodingState.delete(stateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSftpEncodingStateByPrefix(prefix) {
|
||||||
|
if (!prefix) return;
|
||||||
|
for (const key of sftpEncodingState.keys()) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
sftpEncodingState.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeEncoding = (encoding) => {
|
const normalizeEncoding = (encoding) => {
|
||||||
if (!encoding) return "auto";
|
if (!encoding) return "auto";
|
||||||
const normalized = String(encoding).toLowerCase();
|
const normalized = String(encoding).toLowerCase();
|
||||||
@@ -136,40 +169,89 @@ const hasSftpChannelApi = (value) =>
|
|||||||
typeof value.mkdir === "function" &&
|
typeof value.mkdir === "function" &&
|
||||||
typeof value.unlink === "function";
|
typeof value.unlink === "function";
|
||||||
|
|
||||||
const SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
|
const DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
const tryOpenSftpChannel = (client) =>
|
function createAbortError(signal, fallbackMessage = "The operation was aborted.") {
|
||||||
|
const reason = signal?.reason;
|
||||||
|
if (reason instanceof Error) {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
if (typeof reason === "string" && reason) {
|
||||||
|
return new Error(reason);
|
||||||
|
}
|
||||||
|
return new Error(fallbackMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryOpenSftpChannel = (client, options = {}) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const sshClient = client?.client;
|
const sshClient = client?.client;
|
||||||
if (!sshClient || typeof sshClient.sftp !== "function") {
|
if (!sshClient || typeof sshClient.sftp !== "function") {
|
||||||
resolve(null);
|
resolve(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let settled = false;
|
const signal = options?.signal || null;
|
||||||
const timer = setTimeout(() => {
|
const timeoutMs = Number.isFinite(options?.timeoutMs) && options.timeoutMs > 0
|
||||||
settled = true;
|
? options.timeoutMs
|
||||||
reject(new Error("SFTP channel open timed out"));
|
: DEFAULT_SFTP_CHANNEL_OPEN_TIMEOUT_MS;
|
||||||
}, SFTP_CHANNEL_OPEN_TIMEOUT_MS);
|
if (signal?.aborted) {
|
||||||
try {
|
reject(createAbortError(signal, "SFTP channel open was aborted"));
|
||||||
sshClient.sftp((err, sftp) => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
if (settled) {
|
|
||||||
// Timeout already fired — close the orphaned channel to prevent leaks
|
|
||||||
try { sftp?.end?.(); } catch { }
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (err) return reject(err);
|
let settled = false;
|
||||||
resolve(sftp || null);
|
let timer = null;
|
||||||
});
|
const cleanup = () => {
|
||||||
} catch (err) {
|
if (timer) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
if (signal) {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const closeOrphanedChannel = (sftp) => {
|
||||||
|
try { sftp?.end?.(); } catch {}
|
||||||
|
try { sftp?.close?.(); } catch {}
|
||||||
|
};
|
||||||
|
const finishReject = (err) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
cleanup();
|
||||||
reject(err);
|
reject(err);
|
||||||
|
};
|
||||||
|
const finishResolve = (sftp) => {
|
||||||
|
if (settled) {
|
||||||
|
closeOrphanedChannel(sftp);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(sftp || null);
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
finishReject(createAbortError(signal, "SFTP channel open was aborted"));
|
||||||
|
};
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
if (timeoutMs) {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
finishReject(new Error(`SFTP channel open timed out after ${timeoutMs}ms`));
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sshClient.sftp((err, sftp) => {
|
||||||
|
if (err) {
|
||||||
|
finishReject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishResolve(sftp);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
finishReject(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const getSftpChannel = async (client) => {
|
const getSftpChannel = async (client, options = {}) => {
|
||||||
if (!client) return null;
|
if (!client) return null;
|
||||||
|
|
||||||
if (hasSftpChannelApi(client.sftp)) {
|
if (hasSftpChannelApi(client.sftp)) {
|
||||||
@@ -200,7 +282,7 @@ const getSftpChannel = async (client) => {
|
|||||||
|
|
||||||
client._reopeningPromise = (async () => {
|
client._reopeningPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
const reopened = await tryOpenSftpChannel(client);
|
const reopened = await tryOpenSftpChannel(client, options);
|
||||||
if (hasSftpChannelApi(reopened)) {
|
if (hasSftpChannelApi(reopened)) {
|
||||||
client.sftp = reopened;
|
client.sftp = reopened;
|
||||||
return reopened;
|
return reopened;
|
||||||
@@ -218,8 +300,8 @@ const getSftpChannel = async (client) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const requireSftpChannel = async (client) => {
|
const requireSftpChannel = async (client, options = {}) => {
|
||||||
const sftp = await getSftpChannel(client);
|
const sftp = await getSftpChannel(client, options);
|
||||||
if (!sftp) {
|
if (!sftp) {
|
||||||
throw new Error("SFTP session lost. Please reconnect.");
|
throw new Error("SFTP session lost. Please reconnect.");
|
||||||
}
|
}
|
||||||
@@ -256,6 +338,21 @@ const unlinkAsync = (sftp, targetPath) =>
|
|||||||
sftp.unlink(targetPath, (err) => (err ? reject(err) : resolve()));
|
sftp.unlink(targetPath, (err) => (err ? reject(err) : resolve()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openFileAsync = (sftp, targetPath, flags = "w") =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
sftp.open(targetPath, flags, (err, handle) => (err ? reject(err) : resolve(handle)));
|
||||||
|
});
|
||||||
|
|
||||||
|
const writeFileChunkAsync = (sftp, handle, buffer, offset, length, position) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
sftp.write(handle, buffer, offset, length, position, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeFileAsync = (sftp, handle) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
sftp.close(handle, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
const normalizeRemotePathString = async (client, inputPath) => {
|
const normalizeRemotePathString = async (client, inputPath) => {
|
||||||
if (typeof inputPath !== "string") return inputPath;
|
if (typeof inputPath !== "string") return inputPath;
|
||||||
if (inputPath.startsWith("..")) {
|
if (inputPath.startsWith("..")) {
|
||||||
@@ -329,7 +426,8 @@ const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeRemotePathInternal = async (sftp, targetPath, encoding) => {
|
const removeRemotePathInternal = async (sftp, targetPath, encoding, signal = null) => {
|
||||||
|
throwIfAborted(signal);
|
||||||
const encodedTarget = encodePath(targetPath, encoding);
|
const encodedTarget = encodePath(targetPath, encoding);
|
||||||
let stats;
|
let stats;
|
||||||
try {
|
try {
|
||||||
@@ -338,22 +436,30 @@ const removeRemotePathInternal = async (sftp, targetPath, encoding) => {
|
|||||||
if (err && err.code === 2) return;
|
if (err && err.code === 2) return;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
|
throwIfAborted(signal);
|
||||||
const items = await readdirAsync(sftp, encodedTarget);
|
const items = await readdirAsync(sftp, encodedTarget);
|
||||||
|
throwIfAborted(signal);
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
throwIfAborted(signal);
|
||||||
const rawName =
|
const rawName =
|
||||||
item?.filenameRaw ||
|
item?.filenameRaw ||
|
||||||
(item?.filename ? Buffer.from(item.filename, "utf8") : null);
|
(item?.filename ? Buffer.from(item.filename, "utf8") : null);
|
||||||
const name = decodeName(rawName, encoding);
|
const name = decodeName(rawName, encoding);
|
||||||
if (!name || name === "." || name === "..") continue;
|
if (!name || name === "." || name === "..") continue;
|
||||||
const childPath = path.posix.join(targetPath, name);
|
const childPath = path.posix.join(targetPath, name);
|
||||||
await removeRemotePathInternal(sftp, childPath, encoding);
|
await removeRemotePathInternal(sftp, childPath, encoding, signal);
|
||||||
|
throwIfAborted(signal);
|
||||||
}
|
}
|
||||||
|
throwIfAborted(signal);
|
||||||
await rmdirAsync(sftp, encodedTarget);
|
await rmdirAsync(sftp, encodedTarget);
|
||||||
} else {
|
} else {
|
||||||
|
throwIfAborted(signal);
|
||||||
await unlinkAsync(sftp, encodedTarget);
|
await unlinkAsync(sftp, encodedTarget);
|
||||||
}
|
}
|
||||||
|
throwIfAborted(signal);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) => {
|
const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) => {
|
||||||
@@ -419,6 +525,356 @@ const { safeSend } = require("./ipcUtils.cjs");
|
|||||||
function init(deps) {
|
function init(deps) {
|
||||||
sftpClients = deps.sftpClients;
|
sftpClients = deps.sftpClients;
|
||||||
electronModule = deps.electronModule;
|
electronModule = deps.electronModule;
|
||||||
|
sessions = deps.sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRemoteSftpSupport(sessionId) {
|
||||||
|
const session = sessions?.get(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Session "${sessionId}" not found`);
|
||||||
|
}
|
||||||
|
const sshClient = session.conn || session.sshClient;
|
||||||
|
if (!sshClient || typeof sshClient.sftp !== "function") {
|
||||||
|
throw new Error("SFTP is only supported for SSH sessions with an active SSH connection.");
|
||||||
|
}
|
||||||
|
return { session, sshClient };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStagedRemotePath(remotePath) {
|
||||||
|
const isWindowsPath = isWindowsRemotePath(remotePath);
|
||||||
|
const lastSeparatorIndex = Math.max(remotePath.lastIndexOf("/"), remotePath.lastIndexOf("\\"));
|
||||||
|
const dir = lastSeparatorIndex >= 0 ? remotePath.slice(0, lastSeparatorIndex + 1) : "";
|
||||||
|
const baseName = lastSeparatorIndex >= 0 ? remotePath.slice(lastSeparatorIndex + 1) : remotePath;
|
||||||
|
const safeBaseName = baseName || "upload";
|
||||||
|
const stagedName = `.netcatty-upload-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}-${safeBaseName}.part`;
|
||||||
|
return dir ? `${dir}${stagedName}` : stagedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupRemotePath(remotePath) {
|
||||||
|
const lastSeparatorIndex = Math.max(remotePath.lastIndexOf("/"), remotePath.lastIndexOf("\\"));
|
||||||
|
const dir = lastSeparatorIndex >= 0 ? remotePath.slice(0, lastSeparatorIndex + 1) : "";
|
||||||
|
const baseName = lastSeparatorIndex >= 0 ? remotePath.slice(lastSeparatorIndex + 1) : remotePath;
|
||||||
|
const safeBaseName = baseName || "upload";
|
||||||
|
const backupName = `.netcatty-backup-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 10)}-${safeBaseName}.bak`;
|
||||||
|
return dir ? `${dir}${backupName}` : backupName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const posixRenameAsync = (sftp, fromPath, toPath) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
if (typeof sftp?.ext_openssh_rename !== "function") {
|
||||||
|
reject(new Error("POSIX rename is not supported by this SFTP channel."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sftp.ext_openssh_rename(fromPath, toPath, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function renameRemotePath(client, fromPath, toPath, backupPath = null) {
|
||||||
|
const sftp = await requireSftpChannel(client);
|
||||||
|
if (typeof sftp?.ext_openssh_rename === "function") {
|
||||||
|
try {
|
||||||
|
await posixRenameAsync(sftp, fromPath, toPath);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall back to plain rename when the OpenSSH extension is unavailable or rejected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.rename(fromPath, toPath);
|
||||||
|
return;
|
||||||
|
} catch (renameErr) {
|
||||||
|
if (!backupPath) throw renameErr;
|
||||||
|
|
||||||
|
const destinationStat = await client.stat(toPath)
|
||||||
|
.then((stat) => stat || null)
|
||||||
|
.catch(() => false);
|
||||||
|
if (!destinationStat || destinationStat.isDirectory) {
|
||||||
|
throw renameErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let movedExistingTarget = false;
|
||||||
|
try {
|
||||||
|
await client.rename(toPath, backupPath);
|
||||||
|
movedExistingTarget = true;
|
||||||
|
await client.rename(fromPath, toPath);
|
||||||
|
} catch (fallbackErr) {
|
||||||
|
if (movedExistingTarget) {
|
||||||
|
try {
|
||||||
|
await client.rename(backupPath, toPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore restore failures and surface the original fallback error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw fallbackErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movedExistingTarget) {
|
||||||
|
try {
|
||||||
|
await client.delete(backupPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore backup cleanup failures after the final file is in place.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectReadable(stream) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
stream.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
stream.once("error", reject);
|
||||||
|
stream.once("end", () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeToWritable(stream, content) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const cleanup = () => {
|
||||||
|
stream.removeListener("error", onError);
|
||||||
|
stream.removeListener("finish", onSuccess);
|
||||||
|
stream.removeListener("close", onSuccess);
|
||||||
|
};
|
||||||
|
const onError = (err) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
const onSuccess = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
stream.once("error", onError);
|
||||||
|
stream.once("finish", onSuccess);
|
||||||
|
stream.once("close", onSuccess);
|
||||||
|
stream.end(content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfAborted(signal) {
|
||||||
|
if (!signal?.aborted) return;
|
||||||
|
const reason = signal.reason;
|
||||||
|
if (reason instanceof Error) {
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
if (typeof reason === "string" && reason) {
|
||||||
|
throw new Error(reason);
|
||||||
|
}
|
||||||
|
throw new Error("The operation was aborted.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pipeStreams(source, destination, signal = null) {
|
||||||
|
if (signal) {
|
||||||
|
return await pipeline(source, destination, { signal });
|
||||||
|
}
|
||||||
|
return await pipeline(source, destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statResultFromAttrs(attrs) {
|
||||||
|
const mode = attrs?.mode || 0;
|
||||||
|
const fileTypeMask = mode & 0o170000;
|
||||||
|
return {
|
||||||
|
size: attrs?.size || 0,
|
||||||
|
modifyTime: (attrs?.mtime || 0) * 1000,
|
||||||
|
mode,
|
||||||
|
isDirectory: typeof attrs?.isDirectory === "function"
|
||||||
|
? attrs.isDirectory()
|
||||||
|
: fileTypeMask === 0o040000,
|
||||||
|
isSymbolicLink: typeof attrs?.isSymbolicLink === "function"
|
||||||
|
? attrs.isSymbolicLink()
|
||||||
|
: fileTypeMask === 0o120000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionBackedSftpClient(sessionId, sshClient) {
|
||||||
|
const client = {
|
||||||
|
client: sshClient,
|
||||||
|
sftp: null,
|
||||||
|
__netcattySessionBacked: true,
|
||||||
|
_reopeningPromise: null,
|
||||||
|
async get(remotePath) {
|
||||||
|
const sftp = await requireSftpChannel(client);
|
||||||
|
const stream = sftp.createReadStream(remotePath);
|
||||||
|
return await collectReadable(stream);
|
||||||
|
},
|
||||||
|
async put(content, remotePath, options = {}) {
|
||||||
|
const sftp = await requireSftpChannel(client);
|
||||||
|
const signal = options?.signal || null;
|
||||||
|
throwIfAborted(signal);
|
||||||
|
if (content && typeof content.pipe === "function") {
|
||||||
|
const stream = sftp.createWriteStream(remotePath);
|
||||||
|
await pipeStreams(content, stream, signal);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||||
|
const handle = await openFileAsync(sftp, remotePath, "w");
|
||||||
|
try {
|
||||||
|
let offset = 0;
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const length = Math.min(256 * 1024, buffer.length - offset);
|
||||||
|
await writeFileChunkAsync(sftp, handle, buffer, offset, length, offset);
|
||||||
|
offset += length;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await closeFileAsync(sftp, handle);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
async stat(remotePath) {
|
||||||
|
const sftp = await requireSftpChannel(client);
|
||||||
|
const attrs = await statAsync(sftp, remotePath);
|
||||||
|
return statResultFromAttrs(attrs);
|
||||||
|
},
|
||||||
|
async realPath(remotePath) {
|
||||||
|
const sftp = await requireSftpChannel(client);
|
||||||
|
return await realpathAsync(sftp, remotePath);
|
||||||
|
},
|
||||||
|
async rename(oldPath, newPath) {
|
||||||
|
const sftp = await requireSftpChannel(client);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
sftp.rename(oldPath, newPath, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async delete(remotePath, options = {}) {
|
||||||
|
const signal = options?.signal || null;
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const sftp = await requireSftpChannel(client, { signal });
|
||||||
|
throwIfAborted(signal);
|
||||||
|
await unlinkAsync(sftp, remotePath);
|
||||||
|
throwIfAborted(signal);
|
||||||
|
},
|
||||||
|
async rmdir(remotePath, recursive = false, options = {}) {
|
||||||
|
const signal = options?.signal || null;
|
||||||
|
throwIfAborted(signal);
|
||||||
|
const sftp = await requireSftpChannel(client, { signal });
|
||||||
|
if (recursive) {
|
||||||
|
const normalized = await normalizeRemotePathString(client, remotePath);
|
||||||
|
throwIfAborted(signal);
|
||||||
|
await removeRemotePathInternal(sftp, normalized, "utf-8", signal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throwIfAborted(signal);
|
||||||
|
await rmdirAsync(sftp, remotePath);
|
||||||
|
throwIfAborted(signal);
|
||||||
|
},
|
||||||
|
async chmod(remotePath, mode) {
|
||||||
|
const sftp = await requireSftpChannel(client);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
if (typeof sftp.chmod === "function") {
|
||||||
|
sftp.chmod(remotePath, mode, (err) => (err ? reject(err) : resolve()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sftp.setstat(remotePath, { mode }, (err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async end() {
|
||||||
|
try {
|
||||||
|
if (client.sftp && typeof client.sftp.end === "function") {
|
||||||
|
client.sftp.end();
|
||||||
|
} else if (client.sftp && typeof client.sftp.close === "function") {
|
||||||
|
client.sftp.close();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore channel close failures for session-backed clients.
|
||||||
|
} finally {
|
||||||
|
client.sftp = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSftpForSession(_event, payload) {
|
||||||
|
const { sessionId } = payload || {};
|
||||||
|
if (!sessionId) throw new Error("sessionId is required");
|
||||||
|
|
||||||
|
throwIfAborted(payload?.abortSignal);
|
||||||
|
const { sshClient } = ensureRemoteSftpSupport(sessionId);
|
||||||
|
const sftpId = `${sessionId}-sftp-${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
const client = createSessionBackedSftpClient(sessionId, sshClient);
|
||||||
|
try {
|
||||||
|
await requireSftpChannel(client, {
|
||||||
|
signal: payload?.abortSignal,
|
||||||
|
timeoutMs: payload?.timeoutMs,
|
||||||
|
});
|
||||||
|
throwIfAborted(payload?.abortSignal);
|
||||||
|
copySftpEncodingState(payload?.encodingStateKey, sftpId);
|
||||||
|
sftpClients.set(sftpId, client);
|
||||||
|
return { ok: true, sftpId };
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await client.end();
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup failures while discarding a one-off session-backed handle.
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadSftpToLocal(_event, payload) {
|
||||||
|
const client = sftpClients.get(payload.sftpId);
|
||||||
|
if (!client) throw new Error("SFTP session not found");
|
||||||
|
|
||||||
|
const sftp = await requireSftpChannel(client);
|
||||||
|
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||||
|
const encodedPath = encodePath(payload.remotePath, encoding);
|
||||||
|
const stagedFilePath = tempDirBridge.getTempFilePath(path.basename(payload.localPath || payload.remotePath || "download"));
|
||||||
|
throwIfAborted(payload.abortSignal);
|
||||||
|
const readStream = sftp.createReadStream(encodedPath);
|
||||||
|
const writeStream = fs.createWriteStream(stagedFilePath);
|
||||||
|
try {
|
||||||
|
await pipeStreams(readStream, writeStream, payload.abortSignal);
|
||||||
|
throwIfAborted(payload.abortSignal);
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(stagedFilePath, payload.localPath);
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.code !== "EXDEV" && err?.code !== "EEXIST" && err?.code !== "EPERM") {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await fs.promises.copyFile(stagedFilePath, payload.localPath);
|
||||||
|
await fs.promises.unlink(stagedFilePath);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(stagedFilePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore temp-file cleanup failures after a cancelled or failed download.
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { success: true, localPath: payload.localPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLocalToSftp(_event, payload) {
|
||||||
|
const client = sftpClients.get(payload.sftpId);
|
||||||
|
if (!client) throw new Error("SFTP session not found");
|
||||||
|
|
||||||
|
await requireSftpChannel(client);
|
||||||
|
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||||
|
const stagedRemotePath = buildStagedRemotePath(payload.remotePath);
|
||||||
|
const backupRemotePath = buildBackupRemotePath(payload.remotePath);
|
||||||
|
const encodedPath = encodePath(payload.remotePath, encoding);
|
||||||
|
const encodedStagedPath = encodePath(stagedRemotePath, encoding);
|
||||||
|
const encodedBackupPath = encodePath(backupRemotePath, encoding);
|
||||||
|
throwIfAborted(payload.abortSignal);
|
||||||
|
const content = fs.createReadStream(payload.localPath);
|
||||||
|
try {
|
||||||
|
await client.put(content, encodedStagedPath, { signal: payload.abortSignal });
|
||||||
|
throwIfAborted(payload.abortSignal);
|
||||||
|
await renameRemotePath(client, encodedStagedPath, encodedPath, encodedBackupPath);
|
||||||
|
} catch (err) {
|
||||||
|
try {
|
||||||
|
await client.delete(encodedStagedPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore best-effort cleanup failures for partially uploaded temp files.
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return { success: true, remotePath: payload.remotePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1635,8 +2091,9 @@ async function closeSftp(event, payload) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("SFTP close failed", err);
|
console.warn("SFTP close failed", err);
|
||||||
}
|
}
|
||||||
|
copySftpEncodingState(payload?.sftpId, payload?.encodingStateKey);
|
||||||
sftpClients.delete(payload.sftpId);
|
sftpClients.delete(payload.sftpId);
|
||||||
sftpEncodingState.delete(payload.sftpId);
|
clearSftpEncodingState(payload.sftpId);
|
||||||
|
|
||||||
// Clean up jump connections if any
|
// Clean up jump connections if any
|
||||||
const jumpData = jumpConnectionsMap.get(payload.sftpId);
|
const jumpData = jumpConnectionsMap.get(payload.sftpId);
|
||||||
@@ -1694,15 +2151,27 @@ async function deleteSftp(event, payload) {
|
|||||||
const client = sftpClients.get(payload.sftpId);
|
const client = sftpClients.get(payload.sftpId);
|
||||||
if (!client) throw new Error("SFTP session not found");
|
if (!client) throw new Error("SFTP session not found");
|
||||||
|
|
||||||
|
const signal = payload?.abortSignal || null;
|
||||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||||
|
const shouldUseFastDirectoryDelete = (
|
||||||
|
encoding === "utf-8" &&
|
||||||
|
!client.__netcattySessionBacked &&
|
||||||
|
!signal &&
|
||||||
|
!(Number.isFinite(payload?.timeoutMs) && payload.timeoutMs > 0)
|
||||||
|
);
|
||||||
|
|
||||||
if (encoding === "utf-8") {
|
if (encoding === "utf-8") {
|
||||||
await requireSftpChannel(client);
|
throwIfAborted(signal);
|
||||||
|
const sftp = await requireSftpChannel(client, { signal, timeoutMs: payload?.timeoutMs });
|
||||||
const encodedPath = encodePath(payload.path, encoding);
|
const encodedPath = encodePath(payload.path, encoding);
|
||||||
const stat = await client.stat(encodedPath);
|
const stat = statResultFromAttrs(await statAsync(sftp, encodedPath));
|
||||||
|
throwIfAborted(signal);
|
||||||
if (stat.isDirectory) {
|
if (stat.isDirectory) {
|
||||||
// For directories, try to use SSH exec for faster deletion
|
if (shouldUseFastDirectoryDelete) {
|
||||||
// The underlying ssh2 client is available as client.client
|
// Keep the SSH rm -rf fast path only for ordinary UI SFTP sessions.
|
||||||
|
// Session-backed / stop-sensitive flows must stay on the abort-aware
|
||||||
|
// recursive SFTP path so ACP Stop and command timeouts can interrupt
|
||||||
|
// large directory deletes promptly.
|
||||||
const sshClient = client.client;
|
const sshClient = client.client;
|
||||||
if (sshClient && typeof sshClient.exec === 'function') {
|
if (sshClient && typeof sshClient.exec === 'function') {
|
||||||
try {
|
try {
|
||||||
@@ -1725,19 +2194,33 @@ async function deleteSftp(event, payload) {
|
|||||||
await client.rmdir(encodedPath, true);
|
await client.rmdir(encodedPath, true);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (client.__netcattySessionBacked) {
|
||||||
|
await client.rmdir(encodedPath, true, { signal });
|
||||||
} else {
|
} else {
|
||||||
// No SSH client available, use SFTP rmdir
|
const normalizedPath = await normalizeRemotePathString(client, payload.path);
|
||||||
await client.rmdir(encodedPath, true);
|
throwIfAborted(signal);
|
||||||
|
await removeRemotePathInternal(sftp, normalizedPath, encoding, signal);
|
||||||
|
throwIfAborted(signal);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await client.delete(encodedPath);
|
if (client.__netcattySessionBacked) {
|
||||||
|
await client.delete(encodedPath, { signal });
|
||||||
|
} else {
|
||||||
|
throwIfAborted(signal);
|
||||||
|
await unlinkAsync(sftp, encodedPath);
|
||||||
|
throwIfAborted(signal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sftp = await requireSftpChannel(client);
|
throwIfAborted(signal);
|
||||||
|
const sftp = await requireSftpChannel(client, { signal, timeoutMs: payload?.timeoutMs });
|
||||||
const normalizedPath = await normalizeRemotePathString(client, payload.path);
|
const normalizedPath = await normalizeRemotePathString(client, payload.path);
|
||||||
await removeRemotePathInternal(sftp, normalizedPath, encoding);
|
throwIfAborted(signal);
|
||||||
|
await removeRemotePathInternal(sftp, normalizedPath, encoding, signal);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1798,6 +2281,8 @@ async function getSftpHomeDir(_event, payload) {
|
|||||||
const { sftpId } = payload;
|
const { sftpId } = payload;
|
||||||
const client = sftpClients.get(sftpId);
|
const client = sftpClients.get(sftpId);
|
||||||
if (!client) return { success: false, error: "SFTP session not found" };
|
if (!client) return { success: false, error: "SFTP session not found" };
|
||||||
|
const signal = payload?.abortSignal || null;
|
||||||
|
throwIfAborted(signal);
|
||||||
|
|
||||||
// Method 1: SSH exec `echo ~` (with 5s timeout to avoid hanging on
|
// Method 1: SSH exec `echo ~` (with 5s timeout to avoid hanging on
|
||||||
// hosts with blocking shell init scripts or forced commands)
|
// hosts with blocking shell init scripts or forced commands)
|
||||||
@@ -1805,28 +2290,75 @@ async function getSftpHomeDir(_event, payload) {
|
|||||||
if (sshClient && typeof sshClient.exec === "function") {
|
if (sshClient && typeof sshClient.exec === "function") {
|
||||||
let execStream = null;
|
let execStream = null;
|
||||||
try {
|
try {
|
||||||
const execPromise = new Promise((resolve, reject) => {
|
const result = await new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
let timer = null;
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
if (signal) {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const closeExecStream = () => {
|
||||||
|
try { execStream?.close?.(); } catch {}
|
||||||
|
try { execStream?.destroy?.(); } catch {}
|
||||||
|
};
|
||||||
|
const finishResolve = (value) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
const finishReject = (err) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(err);
|
||||||
|
};
|
||||||
|
const onAbort = () => {
|
||||||
|
closeExecStream();
|
||||||
|
finishReject(createAbortError(signal, "SFTP home probe was aborted"));
|
||||||
|
};
|
||||||
|
if (signal) {
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
closeExecStream();
|
||||||
|
finishReject(new Error("SFTP home probe timed out after 5000ms"));
|
||||||
|
}, 5000);
|
||||||
sshClient.exec("echo ~", (err, stream) => {
|
sshClient.exec("echo ~", (err, stream) => {
|
||||||
if (err) return reject(err);
|
if (err) {
|
||||||
|
finishReject(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (settled) {
|
||||||
|
try { stream?.close?.(); } catch {}
|
||||||
|
try { stream?.destroy?.(); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
execStream = stream;
|
execStream = stream;
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
stream.on("close", (code) => resolve({ stdout, code }));
|
stream.once("error", finishReject);
|
||||||
|
stream.on("close", (code) => finishResolve({ stdout, code }));
|
||||||
stream.on("data", (data) => { stdout += data.toString(); });
|
stream.on("data", (data) => { stdout += data.toString(); });
|
||||||
stream.stderr.on("data", () => {});
|
stream.stderr.on("data", () => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const result = await Promise.race([
|
throwIfAborted(signal);
|
||||||
execPromise,
|
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
|
|
||||||
]);
|
|
||||||
const home = result.stdout?.trim();
|
const home = result.stdout?.trim();
|
||||||
if (home && home.startsWith("/")) {
|
if (home && home.startsWith("/")) {
|
||||||
return { success: true, homeDir: home };
|
return { success: true, homeDir: home };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// Timeout or error — kill the exec channel if still open
|
// Timeout or error — kill the exec channel if still open
|
||||||
try { execStream?.close?.(); } catch {}
|
try { execStream?.close?.(); } catch {}
|
||||||
try { execStream?.destroy?.(); } catch {}
|
try { execStream?.destroy?.(); } catch {}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
// Fall through to SFTP realpath
|
// Fall through to SFTP realpath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1834,12 +2366,20 @@ async function getSftpHomeDir(_event, payload) {
|
|||||||
// Method 2: SFTP realpath('.') — skip if result is '/' for non-root users
|
// Method 2: SFTP realpath('.') — skip if result is '/' for non-root users
|
||||||
// because some SFTP servers start in '/' rather than the user's home
|
// because some SFTP servers start in '/' rather than the user's home
|
||||||
try {
|
try {
|
||||||
const sftp = await requireSftpChannel(client);
|
const sftp = await requireSftpChannel(client, {
|
||||||
|
signal,
|
||||||
|
timeoutMs: payload?.timeoutMs,
|
||||||
|
});
|
||||||
|
throwIfAborted(signal);
|
||||||
const absPath = await realpathAsync(sftp, ".");
|
const absPath = await realpathAsync(sftp, ".");
|
||||||
|
throwIfAborted(signal);
|
||||||
if (absPath && absPath !== "/") {
|
if (absPath && absPath !== "/") {
|
||||||
return { success: true, homeDir: absPath };
|
return { success: true, homeDir: absPath };
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1881,6 +2421,9 @@ module.exports = {
|
|||||||
requireSftpChannel,
|
requireSftpChannel,
|
||||||
encodePathForSession,
|
encodePathForSession,
|
||||||
ensureRemoteDirForSession,
|
ensureRemoteDirForSession,
|
||||||
|
clearSftpEncodingState,
|
||||||
|
clearSftpEncodingStateByPrefix,
|
||||||
|
openSftpForSession,
|
||||||
openSftp,
|
openSftp,
|
||||||
listSftp,
|
listSftp,
|
||||||
readSftp,
|
readSftp,
|
||||||
@@ -1889,11 +2432,14 @@ module.exports = {
|
|||||||
writeSftpBinary,
|
writeSftpBinary,
|
||||||
writeSftpBinaryWithProgress,
|
writeSftpBinaryWithProgress,
|
||||||
cancelSftpUpload,
|
cancelSftpUpload,
|
||||||
|
downloadSftpToLocal,
|
||||||
|
uploadLocalToSftp,
|
||||||
closeSftp,
|
closeSftp,
|
||||||
mkdirSftp,
|
mkdirSftp,
|
||||||
deleteSftp,
|
deleteSftp,
|
||||||
renameSftp,
|
renameSftp,
|
||||||
statSftp,
|
statSftp,
|
||||||
chmodSftp,
|
chmodSftp,
|
||||||
|
getSftpHomeDir,
|
||||||
resolveEncodingForRequest,
|
resolveEncodingForRequest,
|
||||||
};
|
};
|
||||||
|
|||||||
82
electron/cli/discoveryPath.cjs
Normal file
82
electron/cli/discoveryPath.cjs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const os = require("node:os");
|
||||||
|
const path = require("node:path");
|
||||||
|
const CLI_STATE_DIR_NAME = "netcatty-tool-cli";
|
||||||
|
const TOOL_CLI_DISCOVERY_ENV_VAR = "NETCATTY_TOOL_CLI_DISCOVERY_FILE";
|
||||||
|
const FALLBACK_APP_DATA_DIR_NAME = "netcatty";
|
||||||
|
|
||||||
|
function toUnpackedAsarPath(filePath) {
|
||||||
|
return filePath.replace(/app\.asar([\\/])/, "app.asar.unpacked$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultAppDataDirName() {
|
||||||
|
const packageJsonPaths = [
|
||||||
|
process.resourcesPath ? path.join(process.resourcesPath, "app.asar", "package.json") : null,
|
||||||
|
path.resolve(__dirname, "../../package.json"),
|
||||||
|
path.join(process.cwd(), "package.json"),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
for (const packageJsonPath of packageJsonPaths) {
|
||||||
|
try {
|
||||||
|
const packageJson = require(packageJsonPath);
|
||||||
|
if (typeof packageJson?.name === "string" && packageJson.name) {
|
||||||
|
return packageJson.name;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try the next location.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FALLBACK_APP_DATA_DIR_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultUserDataDir() {
|
||||||
|
const appDataDirName = getDefaultAppDataDirName();
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
return path.join(os.homedir(), "Library", "Application Support", appDataDirName);
|
||||||
|
}
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
const appData = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
||||||
|
return path.join(appData, appDataDirName);
|
||||||
|
}
|
||||||
|
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||||
|
return path.join(xdgConfigHome, appDataDirName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfiguredDiscoveryFilePath() {
|
||||||
|
return process.env[TOOL_CLI_DISCOVERY_ENV_VAR] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToolCliStateDir(options = {}) {
|
||||||
|
const discoveryFilePath = getConfiguredDiscoveryFilePath();
|
||||||
|
if (discoveryFilePath) {
|
||||||
|
return path.dirname(discoveryFilePath);
|
||||||
|
}
|
||||||
|
const userDataDir = typeof options.userDataDir === "string" && options.userDataDir
|
||||||
|
? options.userDataDir
|
||||||
|
: getDefaultUserDataDir();
|
||||||
|
return path.join(userDataDir, CLI_STATE_DIR_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCliDiscoveryFilePath(options = {}) {
|
||||||
|
const discoveryFilePath = getConfiguredDiscoveryFilePath();
|
||||||
|
if (discoveryFilePath) {
|
||||||
|
return discoveryFilePath;
|
||||||
|
}
|
||||||
|
return path.join(getToolCliStateDir(options), "discovery.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCliLauncherPath() {
|
||||||
|
const fileName = process.platform === "win32"
|
||||||
|
? "netcatty-tool-cli.cmd"
|
||||||
|
: "netcatty-tool-cli";
|
||||||
|
return toUnpackedAsarPath(path.join(__dirname, fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getToolCliStateDir,
|
||||||
|
getCliDiscoveryFilePath,
|
||||||
|
getCliLauncherPath,
|
||||||
|
TOOL_CLI_DISCOVERY_ENV_VAR,
|
||||||
|
};
|
||||||
29
electron/cli/netcatty-tool-cli
Executable file
29
electron/cli/netcatty-tool-cli
Executable file
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||||
|
CLI_SCRIPT="$SCRIPT_DIR/netcatty-tool-cli.cjs"
|
||||||
|
|
||||||
|
APP_BIN=""
|
||||||
|
if [ -n "${NETCATTY_CLI_ELECTRON_EXEC_PATH:-}" ] && [ -x "${NETCATTY_CLI_ELECTRON_EXEC_PATH}" ]; then
|
||||||
|
APP_BIN="${NETCATTY_CLI_ELECTRON_EXEC_PATH}"
|
||||||
|
elif [ -x "$SCRIPT_DIR/../../../../MacOS/Netcatty" ]; then
|
||||||
|
APP_BIN="$SCRIPT_DIR/../../../../MacOS/Netcatty"
|
||||||
|
elif [ -x "$SCRIPT_DIR/../../../../Netcatty" ]; then
|
||||||
|
APP_BIN="$SCRIPT_DIR/../../../../Netcatty"
|
||||||
|
elif [ -x "$SCRIPT_DIR/../../../../netcatty" ]; then
|
||||||
|
APP_BIN="$SCRIPT_DIR/../../../../netcatty"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$APP_BIN" ]; then
|
||||||
|
export ELECTRON_RUN_AS_NODE=1
|
||||||
|
exec "$APP_BIN" "$CLI_SCRIPT" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v node >/dev/null 2>&1; then
|
||||||
|
exec node "$CLI_SCRIPT" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "Failed to locate the bundled Netcatty runtime for netcatty-tool-cli." >&2
|
||||||
|
exit 1
|
||||||
690
electron/cli/netcatty-tool-cli.cjs
Normal file
690
electron/cli/netcatty-tool-cli.cjs
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const path = require("node:path");
|
||||||
|
|
||||||
|
const { connectClient, createError } = require("./netcattyRpcClient.cjs");
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
process.stdout.write(
|
||||||
|
"Netcatty Tool CLI\n\n" +
|
||||||
|
"Usage:\n" +
|
||||||
|
" netcatty-tool-cli status [--json]\n" +
|
||||||
|
" netcatty-tool-cli env --chat-session <id> [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli session --session <id> --chat-session <id> [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli exec --session <id> --chat-session <id> [--json] [--] <shell-ready-command>\n" +
|
||||||
|
" netcatty-tool-cli job-start --session <id> --chat-session <id> [--json] [--] <shell-ready-command>\n" +
|
||||||
|
" netcatty-tool-cli job-poll --job <id> --chat-session <id> [--offset <n>] [--json]\n" +
|
||||||
|
" netcatty-tool-cli job-stop --job <id> --chat-session <id> [--json]\n" +
|
||||||
|
" netcatty-tool-cli sftp list --session <id> --remote-path <remote-path> --chat-session <id> [--encoding <enc>] [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli sftp read --session <id> --remote-path <remote-path> --chat-session <id> [--encoding <enc>] [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli sftp write --session <id> --remote-path <remote-path> --content <text> --chat-session <id> [--encoding <enc>] [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli sftp download --session <id> --remote-path <remote-path> --local-path <local-path> --chat-session <id> [--encoding <enc>] [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli sftp upload --session <id> --local-path <local-path> --remote-path <remote-path> --chat-session <id> [--encoding <enc>] [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli sftp mkdir --session <id> --remote-path <remote-path> --chat-session <id> [--encoding <enc>] [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli sftp delete --session <id> --remote-path <remote-path> --chat-session <id> [--encoding <enc>] [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli sftp rename --session <id> --old-remote-path <remote-path> --new-remote-path <remote-path> --chat-session <id> [--encoding <enc>] [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli sftp stat --session <id> --remote-path <remote-path> --chat-session <id> [--encoding <enc>] [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli sftp chmod --session <id> --remote-path <remote-path> --mode <octal> --chat-session <id> [--encoding <enc>] [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli sftp home --session <id> --chat-session <id> [--json] [--scope-session <session-id> ...]\n" +
|
||||||
|
" netcatty-tool-cli cancel --chat-session <id> [--json]\n" +
|
||||||
|
" netcatty-tool-cli resume --chat-session <id> [--json]\n" +
|
||||||
|
" netcatty-tool-cli help\n\n" +
|
||||||
|
"Examples:\n" +
|
||||||
|
" netcatty-tool-cli status --json\n" +
|
||||||
|
" netcatty-tool-cli env --chat-session ai_123 --json\n" +
|
||||||
|
" netcatty-tool-cli session --session sess_123 --json --chat-session ai_123\n" +
|
||||||
|
" netcatty-tool-cli exec --session sess_123 --chat-session ai_123 --json -- \"pwd\"\n" +
|
||||||
|
" netcatty-tool-cli job-start --session sess_123 --chat-session ai_123 --json -- \"npm run dev\"\n" +
|
||||||
|
" netcatty-tool-cli job-poll --job job_123 --chat-session ai_123 --offset 0 --json\n" +
|
||||||
|
" netcatty-tool-cli sftp list --session sess_123 --remote-path /etc --chat-session ai_123 --json\n" +
|
||||||
|
" netcatty-tool-cli sftp download --session sess_123 --remote-path /etc/hosts --local-path ./hosts.txt --chat-session ai_123 --json\n\n" +
|
||||||
|
"Notes:\n" +
|
||||||
|
" - Start the Netcatty desktop app before using this CLI.\n" +
|
||||||
|
" - This CLI is intended as an internal Skills + CLI transport, not a general customer-facing shell tool.\n" +
|
||||||
|
" - `env` and `session` always require --chat-session <id>.\n" +
|
||||||
|
" - `exec` always requires both --session <id> and --chat-session <id>.\n" +
|
||||||
|
" - `job-start` always requires both --session <id> and --chat-session <id>.\n" +
|
||||||
|
" - `job-poll` and `job-stop` always require both --job <id> and --chat-session <id>.\n" +
|
||||||
|
" - Every `sftp <op>` always requires both --session <id> and --chat-session <id>, and only works on connected SSH-backed sessions.\n" +
|
||||||
|
" - After `--`, pass exactly one shell-ready command string. Preserve quoting inside that one argument.\n" +
|
||||||
|
" - `cancel` stops in-flight execs, session-backed SFTP transfers, and running jobs for that chat session, then blocks further execs until `resume`.\n",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toErrorPayload(err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: err?.code || "UNKNOWN_ERROR",
|
||||||
|
message: err?.message || String(err),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFlagValue(args, index) {
|
||||||
|
return index < args.length ? args[index] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = argv.slice(2);
|
||||||
|
const opts = {
|
||||||
|
json: false,
|
||||||
|
chatSessionId: null,
|
||||||
|
scopedSessionIds: [],
|
||||||
|
sessionId: null,
|
||||||
|
jobId: null,
|
||||||
|
offset: null,
|
||||||
|
remotePath: null,
|
||||||
|
localPath: null,
|
||||||
|
oldRemotePath: null,
|
||||||
|
newRemotePath: null,
|
||||||
|
content: null,
|
||||||
|
mode: null,
|
||||||
|
encoding: null,
|
||||||
|
command: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const positionals = [];
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const arg = args[i];
|
||||||
|
if (arg === "--") {
|
||||||
|
opts.command = args.slice(i + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (arg === "--json") {
|
||||||
|
opts.json = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--chat-session") {
|
||||||
|
opts.chatSessionId = readFlagValue(args, i + 1);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--scope-session") {
|
||||||
|
const value = readFlagValue(args, i + 1);
|
||||||
|
if (value) opts.scopedSessionIds.push(value);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--session") {
|
||||||
|
opts.sessionId = readFlagValue(args, i + 1);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--job") {
|
||||||
|
opts.jobId = readFlagValue(args, i + 1);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--offset") {
|
||||||
|
const value = readFlagValue(args, i + 1);
|
||||||
|
opts.offset = value == null ? null : Number(value);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--remote-path") {
|
||||||
|
opts.remotePath = readFlagValue(args, i + 1);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--local-path") {
|
||||||
|
opts.localPath = readFlagValue(args, i + 1);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--old-remote-path") {
|
||||||
|
opts.oldRemotePath = readFlagValue(args, i + 1);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--new-remote-path") {
|
||||||
|
opts.newRemotePath = readFlagValue(args, i + 1);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--content") {
|
||||||
|
opts.content = readFlagValue(args, i + 1);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--mode") {
|
||||||
|
opts.mode = readFlagValue(args, i + 1);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--encoding") {
|
||||||
|
opts.encoding = readFlagValue(args, i + 1);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
positionals.push(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { positionals, opts };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEnvText(ctx) {
|
||||||
|
const header = [
|
||||||
|
`Environment: ${ctx.environment || "netcatty-terminal"}`,
|
||||||
|
`Hosts: ${ctx.hostCount || 0}`,
|
||||||
|
];
|
||||||
|
if (!Array.isArray(ctx.hosts) || ctx.hosts.length === 0) {
|
||||||
|
return `${header.join("\n")}\n\nNo hosts are available in the current scope.\n`;
|
||||||
|
}
|
||||||
|
const rows = ctx.hosts.map((host) => {
|
||||||
|
const details = [
|
||||||
|
host.sessionId,
|
||||||
|
host.label || host.hostname || "(unnamed)",
|
||||||
|
host.protocol || "unknown",
|
||||||
|
host.os || host.deviceType || host.shellType || "unknown",
|
||||||
|
host.connected === false ? "disconnected" : "connected",
|
||||||
|
];
|
||||||
|
return details.join("\t");
|
||||||
|
});
|
||||||
|
return `${header.join("\n")}\n\n${rows.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExecText(result) {
|
||||||
|
const parts = [];
|
||||||
|
if (result.stdout) parts.push(result.stdout.replace(/\n$/, ""));
|
||||||
|
if (result.stderr) parts.push(`[stderr] ${result.stderr.replace(/\n$/, "")}`);
|
||||||
|
if (result.exitCode != null) parts.push(`[exit code: ${result.exitCode}]`);
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push("[no output]");
|
||||||
|
}
|
||||||
|
return `${parts.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJobText(result) {
|
||||||
|
const lines = [
|
||||||
|
`Job: ${result.jobId || ""}`,
|
||||||
|
`Session: ${result.sessionId || ""}`,
|
||||||
|
`Status: ${result.status || "unknown"}`,
|
||||||
|
];
|
||||||
|
if (result.startedAt) lines.push(`Started: ${new Date(result.startedAt).toISOString()}`);
|
||||||
|
if (result.updatedAt) lines.push(`Updated: ${new Date(result.updatedAt).toISOString()}`);
|
||||||
|
if (typeof result.exitCode === "number") lines.push(`Exit Code: ${result.exitCode}`);
|
||||||
|
if (result.error) lines.push(`Error: ${result.error}`);
|
||||||
|
const outputText = typeof result.output === "string" ? result.output : "";
|
||||||
|
if (outputText) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(outputText.replace(/\n$/, ""));
|
||||||
|
}
|
||||||
|
return `${lines.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildScopeParams(opts) {
|
||||||
|
const params = {};
|
||||||
|
if (opts.chatSessionId) {
|
||||||
|
params.chatSessionId = opts.chatSessionId;
|
||||||
|
}
|
||||||
|
if (Array.isArray(opts.scopedSessionIds) && opts.scopedSessionIds.length > 0) {
|
||||||
|
params.scopedSessionIds = opts.scopedSessionIds;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHostOrThrow(ctx, sessionId) {
|
||||||
|
const host = Array.isArray(ctx?.hosts)
|
||||||
|
? ctx.hosts.find((item) => item.sessionId === sessionId)
|
||||||
|
: null;
|
||||||
|
if (!host) {
|
||||||
|
throw createError("SESSION_NOT_FOUND", `Session "${sessionId}" is not available in the current scope.`);
|
||||||
|
}
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTargetHost(client, opts) {
|
||||||
|
const ctx = await client.call("netcatty/getContext", buildScopeParams(opts));
|
||||||
|
if (opts.sessionId) {
|
||||||
|
return findHostOrThrow(ctx, opts.sessionId);
|
||||||
|
}
|
||||||
|
throw createError(
|
||||||
|
"INVALID_ARGUMENT",
|
||||||
|
"Missing required --session <id>. Run env --json to inspect available sessions first.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSftpCapabilityError(host) {
|
||||||
|
if (!host) return "SFTP target session is unavailable.";
|
||||||
|
if (host.connected === false) {
|
||||||
|
return `Session "${host.sessionId}" is not connected. Reconnect it before using SFTP.`;
|
||||||
|
}
|
||||||
|
const protocol = String(host.protocol || "").toLowerCase();
|
||||||
|
const deviceType = String(host.deviceType || "").toLowerCase();
|
||||||
|
if (protocol === "ssh") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (protocol === "local") {
|
||||||
|
return "SFTP is not available for local sessions. Use normal local filesystem tools instead.";
|
||||||
|
}
|
||||||
|
if (protocol === "mosh") {
|
||||||
|
return "SFTP is not available for Mosh sessions. Open an SSH session for this host or use another transfer path.";
|
||||||
|
}
|
||||||
|
if (protocol === "telnet") {
|
||||||
|
return "SFTP is not available for Telnet sessions. Open an SSH session for this host or use another transfer path.";
|
||||||
|
}
|
||||||
|
if (protocol === "serial" || deviceType === "network") {
|
||||||
|
return "SFTP is not available for serial or network-device sessions. Use exec/vendor CLI commands or another transfer path.";
|
||||||
|
}
|
||||||
|
if (protocol) {
|
||||||
|
return `SFTP is not available for ${protocol} sessions. Open an SSH session for this host or use another transfer path.`;
|
||||||
|
}
|
||||||
|
return "SFTP is only available for connected SSH-backed sessions.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSessionText(host) {
|
||||||
|
const lines = [
|
||||||
|
`Session: ${host.sessionId}`,
|
||||||
|
`Label: ${host.label || "(unnamed)"}`,
|
||||||
|
`Hostname: ${host.hostname || ""}`,
|
||||||
|
`Protocol: ${host.protocol || "unknown"}`,
|
||||||
|
`OS: ${host.os || ""}`,
|
||||||
|
`Username: ${host.username || ""}`,
|
||||||
|
`Shell Type: ${host.shellType || ""}`,
|
||||||
|
`Device Type: ${host.deviceType || ""}`,
|
||||||
|
`Connected: ${host.connected === false ? "false" : "true"}`,
|
||||||
|
];
|
||||||
|
return `${lines.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatusText(status) {
|
||||||
|
const lines = [
|
||||||
|
"Netcatty Tool Status",
|
||||||
|
`Permission Mode: ${status.permissionMode || "unknown"}`,
|
||||||
|
`Command Timeout (ms): ${status.commandTimeoutMs ?? "unknown"}`,
|
||||||
|
`Max Iterations: ${status.maxIterations ?? "unknown"}`,
|
||||||
|
`Sessions: ${status.sessionCount ?? 0}`,
|
||||||
|
`Scoped Contexts: ${status.scopedContextCount ?? 0}`,
|
||||||
|
`Active Executions: ${status.activeExecutionCount ?? 0}`,
|
||||||
|
`Active Chat Execution Locks: ${status.activeChatExecutionCount ?? 0}`,
|
||||||
|
`Pending Approvals: ${status.pendingApprovalCount ?? 0}`,
|
||||||
|
`Discovery File: ${status.discoveryFilePath || "(none)"}`,
|
||||||
|
];
|
||||||
|
return `${lines.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSftpListText(entries) {
|
||||||
|
if (!Array.isArray(entries) || entries.length === 0) {
|
||||||
|
return "No entries.\n";
|
||||||
|
}
|
||||||
|
const rows = entries.map((entry) => [
|
||||||
|
entry.type || "file",
|
||||||
|
entry.name || "",
|
||||||
|
entry.size || "",
|
||||||
|
entry.permissions || "",
|
||||||
|
entry.lastModified || "",
|
||||||
|
].join("\t"));
|
||||||
|
return `Type\tName\tSize\tPermissions\tModified\n${rows.join("\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSingleCommandOrThrow(opts, commandName) {
|
||||||
|
if (!opts.command.length) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing command after --.");
|
||||||
|
}
|
||||||
|
if (opts.command.length !== 1) {
|
||||||
|
throw createError(
|
||||||
|
"INVALID_ARGUMENT",
|
||||||
|
`${commandName} expects exactly one shell-ready command string after --. Preserve quoting in a single argument instead of passing multiple tokens.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return opts.command[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBridgeCallOk(result, defaultCode, defaultMessage) {
|
||||||
|
if (!result || result.ok !== false) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const err = createError(result.code || defaultCode, result.error || defaultMessage);
|
||||||
|
err.details = result;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const { positionals, opts } = parseArgs(process.argv);
|
||||||
|
const [command, subcommand] = positionals;
|
||||||
|
|
||||||
|
if (!command || command === "help" || command === "--help" || command === "-h") {
|
||||||
|
printHelp();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
try {
|
||||||
|
client = await connectClient();
|
||||||
|
|
||||||
|
if (command === "status") {
|
||||||
|
const result = await client.call("netcatty/getStatus", {});
|
||||||
|
const output = opts.json ? JSON.stringify(result, null, 2) : formatStatusText(result);
|
||||||
|
process.stdout.write(`${output}${opts.json ? "\n" : ""}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "env") {
|
||||||
|
if (!opts.chatSessionId) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for env.");
|
||||||
|
}
|
||||||
|
const params = buildScopeParams(opts);
|
||||||
|
const result = await client.call("netcatty/getContext", params);
|
||||||
|
const output = opts.json ? JSON.stringify({ ok: true, ...result }, null, 2) : formatEnvText(result);
|
||||||
|
process.stdout.write(`${output}${opts.json ? "\n" : ""}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "session") {
|
||||||
|
if (!opts.chatSessionId) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for session.");
|
||||||
|
}
|
||||||
|
const host = await resolveTargetHost(client, opts);
|
||||||
|
const payload = { ok: true, host };
|
||||||
|
const output = opts.json ? JSON.stringify(payload, null, 2) : formatSessionText(host);
|
||||||
|
process.stdout.write(`${output}${opts.json ? "\n" : ""}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "exec") {
|
||||||
|
if (!opts.chatSessionId) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for exec.");
|
||||||
|
}
|
||||||
|
const shellCommand = getSingleCommandOrThrow(opts, "exec");
|
||||||
|
const host = await resolveTargetHost(client, opts);
|
||||||
|
const rpcParams = {
|
||||||
|
sessionId: host.sessionId,
|
||||||
|
command: shellCommand,
|
||||||
|
chatSessionId: opts.chatSessionId,
|
||||||
|
};
|
||||||
|
const result = await client.call("netcatty/exec", rpcParams);
|
||||||
|
if (result.ok === false) {
|
||||||
|
const err = createError(result.code || "EXEC_FAILED", result.error || "Command failed");
|
||||||
|
err.details = result;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (opts.json) {
|
||||||
|
process.stdout.write(`${JSON.stringify({ ok: true, ...result }, null, 2)}\n`);
|
||||||
|
} else {
|
||||||
|
process.stdout.write(formatExecText(result));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "job-start") {
|
||||||
|
if (!opts.chatSessionId) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for job-start.");
|
||||||
|
}
|
||||||
|
const shellCommand = getSingleCommandOrThrow(opts, "job-start");
|
||||||
|
const host = await resolveTargetHost(client, opts);
|
||||||
|
const result = await client.call("netcatty/jobStart", {
|
||||||
|
sessionId: host.sessionId,
|
||||||
|
command: shellCommand,
|
||||||
|
chatSessionId: opts.chatSessionId,
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
throw createError(result.code || "JOB_START_FAILED", result.error || "Failed to start long-running command");
|
||||||
|
}
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: formatJobText(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "job-poll") {
|
||||||
|
if (!opts.chatSessionId) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for job-poll.");
|
||||||
|
}
|
||||||
|
if (!opts.jobId) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --job <id> for job-poll.");
|
||||||
|
}
|
||||||
|
const offset = Number.isFinite(opts.offset) && opts.offset >= 0 ? opts.offset : 0;
|
||||||
|
const result = await client.call("netcatty/jobPoll", {
|
||||||
|
jobId: opts.jobId,
|
||||||
|
offset,
|
||||||
|
chatSessionId: opts.chatSessionId,
|
||||||
|
...buildScopeParams(opts),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
throw createError(result.code || "JOB_POLL_FAILED", result.error || "Failed to poll long-running command");
|
||||||
|
}
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: formatJobText(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "job-stop") {
|
||||||
|
if (!opts.chatSessionId) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for job-stop.");
|
||||||
|
}
|
||||||
|
if (!opts.jobId) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --job <id> for job-stop.");
|
||||||
|
}
|
||||||
|
const result = await client.call("netcatty/jobStop", {
|
||||||
|
jobId: opts.jobId,
|
||||||
|
chatSessionId: opts.chatSessionId,
|
||||||
|
...buildScopeParams(opts),
|
||||||
|
});
|
||||||
|
if (!result.ok) {
|
||||||
|
throw createError(result.code || "JOB_STOP_FAILED", result.error || "Failed to stop long-running command");
|
||||||
|
}
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: formatJobText(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "sftp") {
|
||||||
|
if (!opts.chatSessionId) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --chat-session <id> for sftp.");
|
||||||
|
}
|
||||||
|
if (!subcommand || subcommand === "help") {
|
||||||
|
printHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = await resolveTargetHost(client, opts);
|
||||||
|
const sftpCapabilityError = getSftpCapabilityError(host);
|
||||||
|
if (sftpCapabilityError) {
|
||||||
|
throw createError("SFTP_UNSUPPORTED_SESSION", sftpCapabilityError);
|
||||||
|
}
|
||||||
|
const buildSftpParams = () => {
|
||||||
|
const params = {
|
||||||
|
sessionId: host.sessionId,
|
||||||
|
chatSessionId: opts.chatSessionId,
|
||||||
|
...buildScopeParams(opts),
|
||||||
|
};
|
||||||
|
if (opts.remotePath) params.remotePath = opts.remotePath;
|
||||||
|
if (opts.localPath) params.localPath = path.resolve(opts.localPath);
|
||||||
|
if (opts.remotePath) params.path = opts.remotePath;
|
||||||
|
if (opts.oldRemotePath) params.oldPath = opts.oldRemotePath;
|
||||||
|
if (opts.newRemotePath) params.newPath = opts.newRemotePath;
|
||||||
|
if (opts.content != null) params.content = opts.content;
|
||||||
|
if (opts.mode) params.mode = opts.mode;
|
||||||
|
if (opts.encoding) params.encoding = opts.encoding;
|
||||||
|
return params;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (subcommand === "list") {
|
||||||
|
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp list.");
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/list", buildSftpParams()),
|
||||||
|
"SFTP_LIST_FAILED",
|
||||||
|
"Failed to list remote directory",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: formatSftpListText(result.entries));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "read") {
|
||||||
|
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp read.");
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/read", buildSftpParams()),
|
||||||
|
"SFTP_READ_FAILED",
|
||||||
|
"Failed to read remote file",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: `${result.content}${result.content?.endsWith("\n") ? "" : "\n"}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "write") {
|
||||||
|
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp write.");
|
||||||
|
if (opts.content == null) throw createError("INVALID_ARGUMENT", "Missing required --content <text> for sftp write.");
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/write", buildSftpParams()),
|
||||||
|
"SFTP_WRITE_FAILED",
|
||||||
|
"Failed to write remote file",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: `Wrote ${opts.remotePath}.\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "download") {
|
||||||
|
if (!opts.remotePath || !opts.localPath) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --remote-path and --local-path for sftp download.");
|
||||||
|
}
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/download", buildSftpParams()),
|
||||||
|
"SFTP_DOWNLOAD_FAILED",
|
||||||
|
"Failed to download remote file",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: `Downloaded ${opts.remotePath} -> ${opts.localPath}.\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "upload") {
|
||||||
|
if (!opts.remotePath || !opts.localPath) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --local-path and --remote-path for sftp upload.");
|
||||||
|
}
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/upload", buildSftpParams()),
|
||||||
|
"SFTP_UPLOAD_FAILED",
|
||||||
|
"Failed to upload local file",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: `Uploaded ${opts.localPath} -> ${opts.remotePath}.\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "mkdir") {
|
||||||
|
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp mkdir.");
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/mkdir", buildSftpParams()),
|
||||||
|
"SFTP_MKDIR_FAILED",
|
||||||
|
"Failed to create remote directory",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: `Created ${opts.remotePath}.\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "delete") {
|
||||||
|
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp delete.");
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/delete", buildSftpParams()),
|
||||||
|
"SFTP_DELETE_FAILED",
|
||||||
|
"Failed to delete remote path",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: `Deleted ${opts.remotePath}.\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "rename") {
|
||||||
|
if (!opts.oldRemotePath || !opts.newRemotePath) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --old-remote-path and --new-remote-path for sftp rename.");
|
||||||
|
}
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/rename", buildSftpParams()),
|
||||||
|
"SFTP_RENAME_FAILED",
|
||||||
|
"Failed to rename remote path",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: `Renamed ${opts.oldRemotePath} -> ${opts.newRemotePath}.\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "stat") {
|
||||||
|
if (!opts.remotePath) throw createError("INVALID_ARGUMENT", "Missing required --remote-path <remote-path> for sftp stat.");
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/stat", buildSftpParams()),
|
||||||
|
"SFTP_STAT_FAILED",
|
||||||
|
"Failed to stat remote path",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: `${JSON.stringify(result.stat, null, 2)}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "chmod") {
|
||||||
|
if (!opts.remotePath || !opts.mode) {
|
||||||
|
throw createError("INVALID_ARGUMENT", "Missing required --remote-path and --mode for sftp chmod.");
|
||||||
|
}
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/chmod", buildSftpParams()),
|
||||||
|
"SFTP_CHMOD_FAILED",
|
||||||
|
"Failed to chmod remote path",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: `Changed mode of ${opts.remotePath} to ${opts.mode}.\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subcommand === "home") {
|
||||||
|
const result = ensureBridgeCallOk(
|
||||||
|
await client.call("netcatty/sftp/home", buildSftpParams()),
|
||||||
|
"SFTP_HOME_FAILED",
|
||||||
|
"Failed to resolve remote home directory",
|
||||||
|
);
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(result, null, 2)}\n`
|
||||||
|
: `${result.homeDir}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (command === "cancel" || command === "resume") {
|
||||||
|
if (!opts.chatSessionId) {
|
||||||
|
throw createError("INVALID_ARGUMENT", `Missing required --chat-session <id> for ${command}.`);
|
||||||
|
}
|
||||||
|
const cancelled = command === "cancel";
|
||||||
|
const result = await client.call("netcatty/setCancelled", {
|
||||||
|
chatSessionId: opts.chatSessionId,
|
||||||
|
cancelled,
|
||||||
|
});
|
||||||
|
const payload = { ok: true, ...result };
|
||||||
|
process.stdout.write(opts.json
|
||||||
|
? `${JSON.stringify(payload, null, 2)}\n`
|
||||||
|
: `Chat session ${opts.chatSessionId} ${cancelled ? "cancelled" : "resumed"}.\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError("INVALID_ARGUMENT", `Unknown command: ${positionals.join(" ")}`);
|
||||||
|
} catch (err) {
|
||||||
|
const payload = toErrorPayload(err);
|
||||||
|
if (err?.details && typeof err.details === "object") {
|
||||||
|
payload.error = {
|
||||||
|
...payload.error,
|
||||||
|
...err.details,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
process.stderr.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client?.close?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
25
electron/cli/netcatty-tool-cli.cmd
Normal file
25
electron/cli/netcatty-tool-cli.cmd
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
set "SCRIPT_DIR=%~dp0"
|
||||||
|
set "CLI_SCRIPT=%SCRIPT_DIR%netcatty-tool-cli.cjs"
|
||||||
|
set "APP_EXE="
|
||||||
|
|
||||||
|
if defined NETCATTY_CLI_ELECTRON_EXEC_PATH if exist "%NETCATTY_CLI_ELECTRON_EXEC_PATH%" set "APP_EXE=%NETCATTY_CLI_ELECTRON_EXEC_PATH%"
|
||||||
|
if not defined APP_EXE if exist "%SCRIPT_DIR%..\..\..\..\Netcatty.exe" set "APP_EXE=%SCRIPT_DIR%..\..\..\..\Netcatty.exe"
|
||||||
|
if not defined APP_EXE if exist "%SCRIPT_DIR%..\..\..\..\netcatty.exe" set "APP_EXE=%SCRIPT_DIR%..\..\..\..\netcatty.exe"
|
||||||
|
|
||||||
|
if defined APP_EXE (
|
||||||
|
set "ELECTRON_RUN_AS_NODE=1"
|
||||||
|
"%APP_EXE%" "%CLI_SCRIPT%" %*
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
)
|
||||||
|
|
||||||
|
where node >nul 2>nul
|
||||||
|
if not errorlevel 1 (
|
||||||
|
node "%CLI_SCRIPT%" %*
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Failed to locate the bundled Netcatty runtime for netcatty-tool-cli. 1>&2
|
||||||
|
exit /b 1
|
||||||
260
electron/cli/netcattyRpcClient.cjs
Normal file
260
electron/cli/netcattyRpcClient.cjs
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const net = require("node:net");
|
||||||
|
|
||||||
|
const { getCliDiscoveryFilePath } = require("./discoveryPath.cjs");
|
||||||
|
|
||||||
|
const DEFAULT_RPC_TIMEOUT_MS = 30_000;
|
||||||
|
const DEFAULT_EXEC_TIMEOUT_MS = 60_000;
|
||||||
|
const EXEC_RPC_TIMEOUT_BUFFER_MS = 5_000;
|
||||||
|
const DEFAULT_APPROVAL_TIMEOUT_MS = 110_000;
|
||||||
|
const LONG_RUNNING_METHODS = new Set([
|
||||||
|
"netcatty/exec",
|
||||||
|
"netcatty/jobStart",
|
||||||
|
"netcatty/sftp/list",
|
||||||
|
"netcatty/sftp/read",
|
||||||
|
"netcatty/sftp/upload",
|
||||||
|
"netcatty/sftp/write",
|
||||||
|
"netcatty/sftp/download",
|
||||||
|
"netcatty/sftp/mkdir",
|
||||||
|
"netcatty/sftp/delete",
|
||||||
|
"netcatty/sftp/rename",
|
||||||
|
"netcatty/sftp/stat",
|
||||||
|
"netcatty/sftp/chmod",
|
||||||
|
"netcatty/sftp/home",
|
||||||
|
]);
|
||||||
|
const APPROVAL_WAIT_METHODS = new Set([
|
||||||
|
"netcatty/exec",
|
||||||
|
"netcatty/jobStart",
|
||||||
|
"netcatty/sftp/write",
|
||||||
|
"netcatty/sftp/download",
|
||||||
|
"netcatty/sftp/upload",
|
||||||
|
"netcatty/sftp/mkdir",
|
||||||
|
"netcatty/sftp/delete",
|
||||||
|
"netcatty/sftp/rename",
|
||||||
|
"netcatty/sftp/chmod",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function createError(code, message) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.code = code;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRpcTimeoutMs(method, bridgeCommandTimeoutMs, bridgePermissionMode, bridgeApprovalTimeoutMs) {
|
||||||
|
const execTimeoutMs = LONG_RUNNING_METHODS.has(method)
|
||||||
|
? (Number.isFinite(bridgeCommandTimeoutMs) && bridgeCommandTimeoutMs > 0
|
||||||
|
? bridgeCommandTimeoutMs
|
||||||
|
: DEFAULT_EXEC_TIMEOUT_MS)
|
||||||
|
: 0;
|
||||||
|
const approvalTimeoutMs = (bridgePermissionMode === "confirm" && APPROVAL_WAIT_METHODS.has(method))
|
||||||
|
? (Number.isFinite(bridgeApprovalTimeoutMs) && bridgeApprovalTimeoutMs > 0
|
||||||
|
? bridgeApprovalTimeoutMs
|
||||||
|
: DEFAULT_APPROVAL_TIMEOUT_MS)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (execTimeoutMs > 0 && approvalTimeoutMs > 0) {
|
||||||
|
return Math.max(
|
||||||
|
DEFAULT_RPC_TIMEOUT_MS,
|
||||||
|
approvalTimeoutMs + execTimeoutMs + EXEC_RPC_TIMEOUT_BUFFER_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (execTimeoutMs > 0) {
|
||||||
|
return Math.max(DEFAULT_RPC_TIMEOUT_MS, execTimeoutMs + EXEC_RPC_TIMEOUT_BUFFER_MS);
|
||||||
|
}
|
||||||
|
if (approvalTimeoutMs > 0) {
|
||||||
|
return Math.max(DEFAULT_RPC_TIMEOUT_MS, approvalTimeoutMs + EXEC_RPC_TIMEOUT_BUFFER_MS);
|
||||||
|
}
|
||||||
|
return DEFAULT_RPC_TIMEOUT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadDiscovery() {
|
||||||
|
const discoveryPath = getCliDiscoveryFilePath();
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(discoveryPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
throw createError(
|
||||||
|
"APP_NOT_RUNNING",
|
||||||
|
`Netcatty is not running or discovery file is missing at ${discoveryPath}. Start Netcatty first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
throw createError(
|
||||||
|
"DISCOVERY_INVALID",
|
||||||
|
`Netcatty discovery file at ${discoveryPath} is invalid JSON.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed?.port || !parsed?.token) {
|
||||||
|
throw createError(
|
||||||
|
"DISCOVERY_INVALID",
|
||||||
|
`Netcatty discovery file at ${discoveryPath} is missing required port/token fields.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectClient() {
|
||||||
|
const discovery = loadDiscovery();
|
||||||
|
const socket = await new Promise((resolve, reject) => {
|
||||||
|
const sock = net.createConnection({ host: "127.0.0.1", port: discovery.port }, () => resolve(sock));
|
||||||
|
sock.setEncoding("utf8");
|
||||||
|
sock.once("error", (err) => {
|
||||||
|
reject(createError("CONNECT_FAILED", `Failed to connect to Netcatty TCP bridge: ${err?.message || err}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let nextRpcId = 1;
|
||||||
|
let buffer = "";
|
||||||
|
const pending = new Map();
|
||||||
|
|
||||||
|
function rejectPending(id, error) {
|
||||||
|
const entry = pending.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
pending.delete(id);
|
||||||
|
clearTimeout(entry.timeoutId);
|
||||||
|
entry.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function settlePending(id, result, error) {
|
||||||
|
const entry = pending.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
pending.delete(id);
|
||||||
|
clearTimeout(entry.timeoutId);
|
||||||
|
if (error) {
|
||||||
|
entry.reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
entry.resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectAllPending(error) {
|
||||||
|
for (const id of pending.keys()) {
|
||||||
|
rejectPending(id, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on("data", (chunk) => {
|
||||||
|
buffer += chunk;
|
||||||
|
let newlineIdx;
|
||||||
|
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
|
||||||
|
const line = buffer.slice(0, newlineIdx);
|
||||||
|
buffer = buffer.slice(newlineIdx + 1);
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line);
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (msg?.id == null || !pending.has(msg.id)) continue;
|
||||||
|
if (msg.error) {
|
||||||
|
settlePending(msg.id, null, createError("RPC_ERROR", msg.error.message || JSON.stringify(msg.error)));
|
||||||
|
} else {
|
||||||
|
settlePending(msg.id, msg.result, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("error", (err) => {
|
||||||
|
rejectAllPending(
|
||||||
|
createError("CONNECTION_ERROR", `Connection to Netcatty TCP bridge failed: ${err?.message || err}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("close", () => {
|
||||||
|
rejectAllPending(createError("CONNECTION_CLOSED", "Connection to Netcatty TCP bridge closed."));
|
||||||
|
});
|
||||||
|
|
||||||
|
let bridgeCommandTimeoutMs = null;
|
||||||
|
let bridgePermissionMode = null;
|
||||||
|
let bridgeApprovalTimeoutMs = null;
|
||||||
|
|
||||||
|
async function call(method, params) {
|
||||||
|
if (socket.destroyed || !socket.writable) {
|
||||||
|
throw createError("CONNECTION_CLOSED", "Connection to Netcatty TCP bridge is closed.");
|
||||||
|
}
|
||||||
|
const id = nextRpcId++;
|
||||||
|
const timeoutMs = resolveRpcTimeoutMs(
|
||||||
|
method,
|
||||||
|
bridgeCommandTimeoutMs,
|
||||||
|
bridgePermissionMode,
|
||||||
|
bridgeApprovalTimeoutMs,
|
||||||
|
);
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
rejectPending(
|
||||||
|
id,
|
||||||
|
createError("RPC_TIMEOUT", `Timed out waiting for Netcatty RPC response to "${method}" after ${timeoutMs}ms.`),
|
||||||
|
);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
pending.set(id, { resolve, reject, timeoutId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.write(
|
||||||
|
`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`,
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
rejectPending(
|
||||||
|
id,
|
||||||
|
createError("WRITE_FAILED", `Failed to send Netcatty RPC "${method}": ${err?.message || err}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
rejectPending(
|
||||||
|
id,
|
||||||
|
createError("WRITE_FAILED", `Failed to send Netcatty RPC "${method}": ${err?.message || err}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const authResult = await call("auth/verify", { token: discovery.token });
|
||||||
|
if (!authResult?.ok) {
|
||||||
|
throw createError("AUTH_FAILED", "Failed to authenticate to Netcatty TCP bridge.");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const statusResult = await call("netcatty/getStatus", {});
|
||||||
|
if (Number.isFinite(statusResult?.commandTimeoutMs) && statusResult.commandTimeoutMs > 0) {
|
||||||
|
bridgeCommandTimeoutMs = statusResult.commandTimeoutMs;
|
||||||
|
}
|
||||||
|
if (typeof statusResult?.permissionMode === "string") {
|
||||||
|
bridgePermissionMode = statusResult.permissionMode;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(statusResult?.approvalTimeoutMs) && statusResult.approvalTimeoutMs > 0) {
|
||||||
|
bridgeApprovalTimeoutMs = statusResult.approvalTimeoutMs;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the default RPC timeout when bridge status cannot be fetched.
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
discovery,
|
||||||
|
async call(method, params) {
|
||||||
|
return await call(method, params);
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
try {
|
||||||
|
socket.end();
|
||||||
|
} catch {
|
||||||
|
// ignore shutdown errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
connectClient,
|
||||||
|
createError,
|
||||||
|
};
|
||||||
@@ -110,6 +110,7 @@ if (!app || !BrowserWindow) {
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
const fs = require("node:fs");
|
const fs = require("node:fs");
|
||||||
|
const { getCliDiscoveryFilePath } = require("./cli/discoveryPath.cjs");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
protocol?.registerSchemesAsPrivileged?.([
|
protocol?.registerSchemesAsPrivileged?.([
|
||||||
@@ -458,10 +459,12 @@ const registerBridges = (win) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Initialize bridges with shared dependencies
|
// Initialize bridges with shared dependencies
|
||||||
|
const cliDiscoveryFilePath = getCliDiscoveryFilePath({ userDataDir: app.getPath("userData") });
|
||||||
const deps = {
|
const deps = {
|
||||||
sessions,
|
sessions,
|
||||||
sftpClients,
|
sftpClients,
|
||||||
electronModule,
|
electronModule,
|
||||||
|
cliDiscoveryFilePath,
|
||||||
};
|
};
|
||||||
|
|
||||||
sshBridge.init(deps);
|
sshBridge.init(deps);
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|||||||
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
|
||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const DEBUG_MCP = process.env.NETCATTY_MCP_DEBUG === "1";
|
||||||
|
|
||||||
|
function debugLog(...args) {
|
||||||
|
if (!DEBUG_MCP) return;
|
||||||
|
process.stderr.write(`[netcatty-mcp:debug] ${args.map(arg => {
|
||||||
|
if (typeof arg === "string") return arg;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(arg);
|
||||||
|
} catch {
|
||||||
|
return String(arg);
|
||||||
|
}
|
||||||
|
}).join(" ")}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── TCP Bridge to Netcatty main process ──
|
// ── TCP Bridge to Netcatty main process ──
|
||||||
|
|
||||||
const NETCATTY_MCP_PORT = parseInt(process.env.NETCATTY_MCP_PORT, 10);
|
const NETCATTY_MCP_PORT = parseInt(process.env.NETCATTY_MCP_PORT, 10);
|
||||||
@@ -100,6 +114,7 @@ function connectTcp() {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const sock = net.createConnection({ host: "127.0.0.1", port: NETCATTY_MCP_PORT }, () => {
|
const sock = net.createConnection({ host: "127.0.0.1", port: NETCATTY_MCP_PORT }, () => {
|
||||||
tcpSocket = sock;
|
tcpSocket = sock;
|
||||||
|
debugLog("Connected to TCP bridge", { port: NETCATTY_MCP_PORT });
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
sock.setEncoding("utf-8");
|
sock.setEncoding("utf-8");
|
||||||
@@ -118,6 +133,11 @@ function connectTcp() {
|
|||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(line);
|
const msg = JSON.parse(line);
|
||||||
|
debugLog("TCP message received", {
|
||||||
|
id: msg?.id,
|
||||||
|
hasError: Boolean(msg?.error),
|
||||||
|
keys: msg ? Object.keys(msg) : [],
|
||||||
|
});
|
||||||
if (msg.id != null && pendingRequests.has(msg.id)) {
|
if (msg.id != null && pendingRequests.has(msg.id)) {
|
||||||
const { resolve: res, reject: rej } = pendingRequests.get(msg.id);
|
const { resolve: res, reject: rej } = pendingRequests.get(msg.id);
|
||||||
pendingRequests.delete(msg.id);
|
pendingRequests.delete(msg.id);
|
||||||
@@ -133,6 +153,7 @@ function connectTcp() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
sock.on("error", (err) => {
|
sock.on("error", (err) => {
|
||||||
|
debugLog("TCP socket error", { message: err?.message || String(err) });
|
||||||
reject(err);
|
reject(err);
|
||||||
// Reject all pending
|
// Reject all pending
|
||||||
for (const { reject: rej } of pendingRequests.values()) {
|
for (const { reject: rej } of pendingRequests.values()) {
|
||||||
@@ -141,6 +162,7 @@ function connectTcp() {
|
|||||||
pendingRequests.clear();
|
pendingRequests.clear();
|
||||||
});
|
});
|
||||||
sock.on("close", () => {
|
sock.on("close", () => {
|
||||||
|
debugLog("TCP socket closed");
|
||||||
// Reject all pending requests on clean close
|
// Reject all pending requests on clean close
|
||||||
for (const { reject: rej } of pendingRequests.values()) {
|
for (const { reject: rej } of pendingRequests.values()) {
|
||||||
rej(new Error("TCP connection closed"));
|
rej(new Error("TCP connection closed"));
|
||||||
@@ -159,6 +181,7 @@ function rpcCall(method, params) {
|
|||||||
const id = nextRpcId++;
|
const id = nextRpcId++;
|
||||||
pendingRequests.set(id, { resolve, reject });
|
pendingRequests.set(id, { resolve, reject });
|
||||||
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
||||||
|
debugLog("rpcCall", { id, method, params });
|
||||||
tcpSocket.write(msg);
|
tcpSocket.write(msg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -204,6 +227,16 @@ server.tool(
|
|||||||
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}, CHAT_SESSION_ID: ${CHAT_SESSION_ID}\n`);
|
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}, CHAT_SESSION_ID: ${CHAT_SESSION_ID}\n`);
|
||||||
const ctx = await rpcCall("netcatty/getContext", scopeParams);
|
const ctx = await rpcCall("netcatty/getContext", scopeParams);
|
||||||
process.stderr.write(`[netcatty-mcp] get_environment result: hostCount=${ctx.hostCount}, hosts=${JSON.stringify(ctx.hosts?.map(h => h.sessionId))}\n`);
|
process.stderr.write(`[netcatty-mcp] get_environment result: hostCount=${ctx.hostCount}, hosts=${JSON.stringify(ctx.hosts?.map(h => h.sessionId))}\n`);
|
||||||
|
debugLog("get_environment payload", {
|
||||||
|
hostCount: ctx?.hostCount,
|
||||||
|
hosts: Array.isArray(ctx?.hosts) ? ctx.hosts.map(h => ({
|
||||||
|
sessionId: h.sessionId,
|
||||||
|
hostname: h.hostname,
|
||||||
|
connected: h.connected,
|
||||||
|
protocol: h.protocol,
|
||||||
|
deviceType: h.deviceType,
|
||||||
|
})) : [],
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: "text",
|
type: "text",
|
||||||
@@ -222,12 +255,22 @@ server.tool(
|
|||||||
command: z.string().describe("The command to execute in the target session."),
|
command: z.string().describe("The command to execute in the target session."),
|
||||||
},
|
},
|
||||||
async ({ sessionId, command }) => {
|
async ({ sessionId, command }) => {
|
||||||
|
debugLog("terminal_execute called", { sessionId, command });
|
||||||
// skipBlocklist: bridge layer does session-aware blocklist (serial sessions skip shell patterns)
|
// skipBlocklist: bridge layer does session-aware blocklist (serial sessions skip shell patterns)
|
||||||
const guardErr = guardWriteOperation(command, { skipBlocklist: true });
|
const guardErr = guardWriteOperation(command, { skipBlocklist: true });
|
||||||
if (guardErr) {
|
if (guardErr) {
|
||||||
|
debugLog("terminal_execute blocked locally", { sessionId, guardErr });
|
||||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||||
}
|
}
|
||||||
const result = await rpcCall("netcatty/exec", { ...scopeParams, sessionId, command });
|
const result = await rpcCall("netcatty/exec", { ...scopeParams, sessionId, command });
|
||||||
|
debugLog("terminal_execute result", {
|
||||||
|
sessionId,
|
||||||
|
ok: result?.ok,
|
||||||
|
error: result?.error,
|
||||||
|
exitCode: result?.exitCode,
|
||||||
|
stdoutLength: result?.stdout?.length || 0,
|
||||||
|
stderrLength: result?.stderr?.length || 0,
|
||||||
|
});
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
return { content: [{ type: "text", text: `Error: ${result.error || "Command failed"}` }], isError: true };
|
return { content: [{ type: "text", text: `Error: ${result.error || "Command failed"}` }], isError: true };
|
||||||
}
|
}
|
||||||
@@ -308,10 +351,18 @@ server.tool(
|
|||||||
// ── Start ──
|
// ── Start ──
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
debugLog("Starting MCP server", {
|
||||||
|
port: NETCATTY_MCP_PORT,
|
||||||
|
hasToken: Boolean(NETCATTY_MCP_TOKEN),
|
||||||
|
scopedSessionIds: SCOPED_SESSION_IDS,
|
||||||
|
chatSessionId: CHAT_SESSION_ID,
|
||||||
|
permissionMode: PERMISSION_MODE,
|
||||||
|
});
|
||||||
await connectTcp();
|
await connectTcp();
|
||||||
|
|
||||||
// Authenticate with the TCP bridge before accepting any tool calls
|
// Authenticate with the TCP bridge before accepting any tool calls
|
||||||
const authResult = await rpcCall("auth/verify", { token: NETCATTY_MCP_TOKEN });
|
const authResult = await rpcCall("auth/verify", { token: NETCATTY_MCP_TOKEN });
|
||||||
|
debugLog("auth/verify result", authResult);
|
||||||
if (!authResult?.ok) {
|
if (!authResult?.ok) {
|
||||||
throw new Error("TCP bridge authentication failed");
|
throw new Error("TCP bridge authentication failed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1227,6 +1227,9 @@ const api = {
|
|||||||
aiMcpSetPermissionMode: async (mode) => {
|
aiMcpSetPermissionMode: async (mode) => {
|
||||||
return ipcRenderer.invoke("netcatty:ai:mcp:set-permission-mode", { mode });
|
return ipcRenderer.invoke("netcatty:ai:mcp:set-permission-mode", { mode });
|
||||||
},
|
},
|
||||||
|
aiMcpSetToolIntegrationMode: async (mode) => {
|
||||||
|
return ipcRenderer.invoke("netcatty:ai:mcp:set-tool-integration-mode", { mode });
|
||||||
|
},
|
||||||
// MCP approval gate: renderer receives approval requests from main process
|
// MCP approval gate: renderer receives approval requests from main process
|
||||||
onMcpApprovalRequest: (cb) => {
|
onMcpApprovalRequest: (cb) => {
|
||||||
const handler = (_event, payload) => cb(payload);
|
const handler = (_event, payload) => cb(payload);
|
||||||
@@ -1243,8 +1246,8 @@ const api = {
|
|||||||
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-cleared", handler);
|
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-cleared", handler);
|
||||||
},
|
},
|
||||||
// ACP streaming
|
// ACP streaming
|
||||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
|
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images, toolIntegrationMode, defaultTargetSession) => {
|
||||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { 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, toolIntegrationMode, defaultTargetSession });
|
||||||
},
|
},
|
||||||
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
|
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
|
||||||
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
|
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
|
||||||
|
|||||||
3
global.d.ts
vendored
3
global.d.ts
vendored
@@ -794,11 +794,12 @@ declare global {
|
|||||||
deviceType?: string;
|
deviceType?: string;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
|
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||||
|
aiMcpSetToolIntegrationMode?(mode: 'mcp' | 'skills'): Promise<{ ok: boolean; error?: string }>;
|
||||||
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
|
||||||
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||||
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||||
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
|
||||||
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>): Promise<{ ok: boolean; error?: string }>;
|
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>, toolIntegrationMode?: 'mcp' | 'skills', defaultTargetSession?: { sessionId: string; hostname: string; label: string; os?: string; username?: string; protocol?: string; shellType?: string; deviceType?: string; connected: boolean; source: 'scope-target' | 'only-connected-in-scope' }): Promise<{ ok: boolean; error?: string }>;
|
||||||
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
|
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
|
||||||
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
|
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
|
||||||
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;
|
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;
|
||||||
|
|||||||
@@ -6,7 +6,20 @@
|
|||||||
* and forwards stream events to the renderer via IPC.
|
* and forwards stream events to the renderer via IPC.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExternalAgentConfig } from './types';
|
import type { AIToolIntegrationMode, ExternalAgentConfig } from './types';
|
||||||
|
|
||||||
|
export interface DefaultTargetSessionHint {
|
||||||
|
sessionId: string;
|
||||||
|
hostname: string;
|
||||||
|
label: string;
|
||||||
|
os?: string;
|
||||||
|
username?: string;
|
||||||
|
protocol?: string;
|
||||||
|
shellType?: string;
|
||||||
|
deviceType?: string;
|
||||||
|
connected: boolean;
|
||||||
|
source: 'scope-target' | 'only-connected-in-scope';
|
||||||
|
}
|
||||||
|
|
||||||
export interface AcpAgentCallbacks {
|
export interface AcpAgentCallbacks {
|
||||||
onSessionId?: (sessionId: string) => void;
|
onSessionId?: (sessionId: string) => void;
|
||||||
@@ -33,6 +46,8 @@ interface AcpBridge {
|
|||||||
existingSessionId?: string,
|
existingSessionId?: string,
|
||||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||||
images?: FileAttachment[],
|
images?: FileAttachment[],
|
||||||
|
toolIntegrationMode?: AIToolIntegrationMode,
|
||||||
|
defaultTargetSession?: DefaultTargetSessionHint,
|
||||||
): Promise<{ ok: boolean; error?: string }>;
|
): Promise<{ ok: boolean; error?: string }>;
|
||||||
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||||
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
||||||
@@ -70,6 +85,8 @@ export async function runAcpAgentTurn(
|
|||||||
existingSessionId?: string,
|
existingSessionId?: string,
|
||||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||||
images?: FileAttachment[],
|
images?: FileAttachment[],
|
||||||
|
toolIntegrationMode?: AIToolIntegrationMode,
|
||||||
|
defaultTargetSession?: DefaultTargetSessionHint,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const acpBridge = bridge as unknown as AcpBridge;
|
const acpBridge = bridge as unknown as AcpBridge;
|
||||||
|
|
||||||
@@ -86,16 +103,29 @@ export async function runAcpAgentTurn(
|
|||||||
});
|
});
|
||||||
cleanupFns.push(unsubEvent);
|
cleanupFns.push(unsubEvent);
|
||||||
|
|
||||||
|
let settled = false;
|
||||||
|
let resolveDone!: () => void;
|
||||||
|
const settle = (fn?: () => void) => {
|
||||||
|
if (settled) return false;
|
||||||
|
settled = true;
|
||||||
|
fn?.();
|
||||||
|
resolveDone();
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
const donePromise = new Promise<void>((resolve) => {
|
const donePromise = new Promise<void>((resolve) => {
|
||||||
|
resolveDone = resolve;
|
||||||
const unsubDone = acpBridge.onAiAcpDone(requestId, () => {
|
const unsubDone = acpBridge.onAiAcpDone(requestId, () => {
|
||||||
|
settle(() => {
|
||||||
callbacks.onDone();
|
callbacks.onDone();
|
||||||
resolve();
|
});
|
||||||
});
|
});
|
||||||
cleanupFns.push(unsubDone);
|
cleanupFns.push(unsubDone);
|
||||||
|
|
||||||
const unsubError = acpBridge.onAiAcpError(requestId, (error: string) => {
|
const unsubError = acpBridge.onAiAcpError(requestId, (error: string) => {
|
||||||
|
settle(() => {
|
||||||
callbacks.onError(error);
|
callbacks.onError(error);
|
||||||
resolve();
|
});
|
||||||
});
|
});
|
||||||
cleanupFns.push(unsubError);
|
cleanupFns.push(unsubError);
|
||||||
});
|
});
|
||||||
@@ -107,6 +137,9 @@ export async function runAcpAgentTurn(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const onAbort = () => {
|
const onAbort = () => {
|
||||||
|
if (!settle()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
acpBridge.aiAcpCancel(requestId, chatSessionId).catch(() => {});
|
acpBridge.aiAcpCancel(requestId, chatSessionId).catch(() => {});
|
||||||
};
|
};
|
||||||
signal.addEventListener('abort', onAbort, { once: true });
|
signal.addEventListener('abort', onAbort, { once: true });
|
||||||
@@ -114,7 +147,7 @@ export async function runAcpAgentTurn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start the ACP stream in the main process
|
// Start the ACP stream in the main process
|
||||||
acpBridge.aiAcpStream(
|
void acpBridge.aiAcpStream(
|
||||||
requestId,
|
requestId,
|
||||||
chatSessionId,
|
chatSessionId,
|
||||||
config.acpCommand,
|
config.acpCommand,
|
||||||
@@ -126,9 +159,23 @@ export async function runAcpAgentTurn(
|
|||||||
existingSessionId,
|
existingSessionId,
|
||||||
historyMessages,
|
historyMessages,
|
||||||
images?.length ? images : undefined,
|
images?.length ? images : undefined,
|
||||||
).catch((err: Error) => {
|
toolIntegrationMode,
|
||||||
|
defaultTargetSession,
|
||||||
|
).then((result) => {
|
||||||
|
if (result?.ok === false) {
|
||||||
|
settle(() => {
|
||||||
|
callbacks.onError(result.error || 'Failed to start ACP stream');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch((err: Error) => {
|
||||||
|
settle(() => {
|
||||||
callbacks.onError(err.message);
|
callbacks.onError(err.message);
|
||||||
});
|
});
|
||||||
|
}).finally(() => {
|
||||||
|
if (settled) {
|
||||||
|
cleanup(cleanupFns);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Wait for done or error
|
// Wait for done or error
|
||||||
await donePromise;
|
await donePromise;
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export interface AISessionScope {
|
|||||||
|
|
||||||
// Permission model
|
// Permission model
|
||||||
export type AIPermissionMode = 'observer' | 'confirm' | 'autonomous';
|
export type AIPermissionMode = 'observer' | 'confirm' | 'autonomous';
|
||||||
|
export type AIToolIntegrationMode = 'mcp' | 'skills';
|
||||||
|
|
||||||
export interface HostAIPermission {
|
export interface HostAIPermission {
|
||||||
hostId: string;
|
hostId: string;
|
||||||
@@ -214,6 +215,7 @@ export interface AISettings {
|
|||||||
activeProviderId: string;
|
activeProviderId: string;
|
||||||
activeModelId: string;
|
activeModelId: string;
|
||||||
globalPermissionMode: AIPermissionMode;
|
globalPermissionMode: AIPermissionMode;
|
||||||
|
toolIntegrationMode: AIToolIntegrationMode;
|
||||||
externalAgents: ExternalAgentConfig[];
|
externalAgents: ExternalAgentConfig[];
|
||||||
defaultAgentId: string;
|
defaultAgentId: string;
|
||||||
commandBlocklist: string[]; // global command blocklist patterns
|
commandBlocklist: string[]; // global command blocklist patterns
|
||||||
@@ -247,6 +249,7 @@ export const DEFAULT_AI_SETTINGS: AISettings = {
|
|||||||
activeProviderId: '',
|
activeProviderId: '',
|
||||||
activeModelId: '',
|
activeModelId: '',
|
||||||
globalPermissionMode: 'confirm',
|
globalPermissionMode: 'confirm',
|
||||||
|
toolIntegrationMode: 'mcp',
|
||||||
externalAgents: [],
|
externalAgents: [],
|
||||||
defaultAgentId: 'catty',
|
defaultAgentId: 'catty',
|
||||||
commandBlocklist: [...DEFAULT_COMMAND_BLOCKLIST],
|
commandBlocklist: [...DEFAULT_COMMAND_BLOCKLIST],
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export const STORAGE_KEY_AI_PROVIDERS = 'netcatty_ai_providers_v1';
|
|||||||
export const STORAGE_KEY_AI_ACTIVE_PROVIDER = 'netcatty_ai_active_provider_v1';
|
export const STORAGE_KEY_AI_ACTIVE_PROVIDER = 'netcatty_ai_active_provider_v1';
|
||||||
export const STORAGE_KEY_AI_ACTIVE_MODEL = 'netcatty_ai_active_model_v1';
|
export const STORAGE_KEY_AI_ACTIVE_MODEL = 'netcatty_ai_active_model_v1';
|
||||||
export const STORAGE_KEY_AI_PERMISSION_MODE = 'netcatty_ai_permission_mode_v1';
|
export const STORAGE_KEY_AI_PERMISSION_MODE = 'netcatty_ai_permission_mode_v1';
|
||||||
|
export const STORAGE_KEY_AI_TOOL_INTEGRATION_MODE = 'netcatty_ai_tool_integration_mode_v1';
|
||||||
export const STORAGE_KEY_AI_HOST_PERMISSIONS = 'netcatty_ai_host_permissions_v1';
|
export const STORAGE_KEY_AI_HOST_PERMISSIONS = 'netcatty_ai_host_permissions_v1';
|
||||||
export const STORAGE_KEY_AI_EXTERNAL_AGENTS = 'netcatty_ai_external_agents_v1';
|
export const STORAGE_KEY_AI_EXTERNAL_AGENTS = 'netcatty_ai_external_agents_v1';
|
||||||
export const STORAGE_KEY_AI_DEFAULT_AGENT = 'netcatty_ai_default_agent_v1';
|
export const STORAGE_KEY_AI_DEFAULT_AGENT = 'netcatty_ai_default_agent_v1';
|
||||||
|
|||||||
3
package-lock.json
generated
3
package-lock.json
generated
@@ -60,6 +60,9 @@
|
|||||||
"zmodem.js": "^0.1.10",
|
"zmodem.js": "^0.1.10",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
|
"bin": {
|
||||||
|
"netcatty-tool-cli": "electron/cli/netcatty-tool-cli.cjs"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@tailwindcss/postcss": "^4.1.17",
|
"@tailwindcss/postcss": "^4.1.17",
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
"author": "binaricat <support@netcatty.com>",
|
"author": "binaricat <support@netcatty.com>",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
|
"bin": {
|
||||||
|
"netcatty-tool-cli": "./electron/cli/netcatty-tool-cli.cjs"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
|
"dev": "npm run lint && concurrently -k \"vite\" \"npm:dev:electron\"",
|
||||||
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
|
"dev:electron": "wait-on http-get://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 node electron/launch.cjs",
|
||||||
@@ -24,6 +27,7 @@
|
|||||||
"pack:linux-arm64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
"pack:linux-arm64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
|
||||||
"postinstall": "electron-builder install-app-deps && patch-package",
|
"postinstall": "electron-builder install-app-deps && patch-package",
|
||||||
"rebuild": "electron-builder install-app-deps",
|
"rebuild": "electron-builder install-app-deps",
|
||||||
|
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix"
|
"lint:fix": "eslint . --fix"
|
||||||
},
|
},
|
||||||
|
|||||||
40
skills/netcatty-tool-cli/SKILL.md
Normal file
40
skills/netcatty-tool-cli/SKILL.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
name: netcatty-tool-cli
|
||||||
|
description: Use this skill when an external agent needs to operate on Netcatty sessions through Skills + CLI instead of the netcatty-remote-hosts MCP server.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Netcatty Tool CLI
|
||||||
|
|
||||||
|
Use this skill for external ACP agents when Netcatty is configured for `Skills + CLI` mode.
|
||||||
|
|
||||||
|
For routine tasks, the host prompt is usually enough. Read only the reference that matches the task type.
|
||||||
|
|
||||||
|
## Router
|
||||||
|
|
||||||
|
1. Use the exact Netcatty CLI prefix provided by the host prompt.
|
||||||
|
2. Keep `--chat-session <chat-session-id>` on every Netcatty CLI call. Do not omit it.
|
||||||
|
3. Treat `--chat-session <chat-session-id>` as required for `env`, `session`, real `exec`, and every `sftp` operation. Treat `--session <session-id>` as required for `session`, `exec`, and every `sftp` operation.
|
||||||
|
4. Classify the task before choosing a command path:
|
||||||
|
- Remote command execution tasks go through the exec reference.
|
||||||
|
- Remote file or directory tasks go through the sftp reference.
|
||||||
|
- If the user explicitly says to avoid shell or `exec`, do not use `exec`.
|
||||||
|
- Treat `exec` as the short-command path only. If the command may exceed about 60 seconds, or streams output for an extended period, use the long-running job commands instead of plain `exec`.
|
||||||
|
5. If the host prompt already names a connected default target session, use that session directly for routine requests that do not mention another session or host, but still start with `session --session <id> --json --chat-session <chat-session-id>` instead of jumping straight to `exec` or `sftp`.
|
||||||
|
6. Only fall back to `env` lookup when the task is ambiguous, the user points to another session, or that direct `session` lookup fails.
|
||||||
|
|
||||||
|
## Core Rules
|
||||||
|
|
||||||
|
- Treat the host-provided CLI prefix as the only supported entrypoint for this session.
|
||||||
|
- Run Netcatty CLI commands strictly serially.
|
||||||
|
- Treat Netcatty CLI errors as authoritative.
|
||||||
|
- Never ask the user for SSH credentials, key paths, proxy settings, or jump-host details when Netcatty session access already exists.
|
||||||
|
- Do not pause to explain the plan, re-read this skill, or design scripts before trying that shortest path.
|
||||||
|
- When presenting structured results, prefer a concise table if it fits clearly.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Exec and session workflow: `references/exec.md`
|
||||||
|
- SFTP file workflow: `references/sftp.md`
|
||||||
|
- Session and device-type handling: `references/session-types.md`
|
||||||
|
- Cancel, resume, and runtime diagnostics: `references/control-commands.md`
|
||||||
|
- Error handling and authoritative failures: `references/errors.md`
|
||||||
17
skills/netcatty-tool-cli/references/control-commands.md
Normal file
17
skills/netcatty-tool-cli/references/control-commands.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Control Commands
|
||||||
|
|
||||||
|
Read this when you need diagnostics, cancellation, or to re-enable a cancelled chat scope.
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
- Runtime diagnostics:
|
||||||
|
- `<netcatty-cli-prefix> status --json`
|
||||||
|
- Cancel outstanding Netcatty work for this chat scope:
|
||||||
|
- `<netcatty-cli-prefix> cancel --chat-session <chat-session-id> --json`
|
||||||
|
- Re-enable execution for that same chat scope:
|
||||||
|
- `<netcatty-cli-prefix> resume --chat-session <chat-session-id> --json`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- `cancel` affects the current chat scope; it requests cancellation for in-flight `exec`, session-backed SFTP transfers, and running `job-start` work in that scope. Later `exec` calls in that scope stay blocked until `resume`.
|
||||||
|
- Do not issue control commands concurrently with other Netcatty CLI commands for the same chat session.
|
||||||
10
skills/netcatty-tool-cli/references/errors.md
Normal file
10
skills/netcatty-tool-cli/references/errors.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Error Handling
|
||||||
|
|
||||||
|
Read this when a Netcatty CLI call fails or returns a blocked state.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Treat Netcatty CLI errors as authoritative. Do not argue with them or try alternate launch methods.
|
||||||
|
- If Netcatty returns `COMMAND_ALREADY_RUNNING`, wait for the in-flight command to finish instead of retrying in parallel.
|
||||||
|
- Netcatty enforces scope, approvals, blocklists, and timeouts. Do not try to bypass those checks with wrappers or alternate shells.
|
||||||
|
- If a direct command fails and the failure suggests the task genuinely needs branching or parsing logic, then consider a small script. Otherwise keep commands simple.
|
||||||
31
skills/netcatty-tool-cli/references/exec.md
Normal file
31
skills/netcatty-tool-cli/references/exec.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Exec Reference
|
||||||
|
|
||||||
|
Use this reference for remote command execution tasks.
|
||||||
|
|
||||||
|
## Shortest Path
|
||||||
|
|
||||||
|
`exec` calls are internal agent transport calls. Always include both `--session <session-id>` and `--chat-session <chat-session-id>`.
|
||||||
|
After `--`, pass exactly one shell-ready command string. Preserve any quoting inside that one argument instead of splitting it into multiple tokens.
|
||||||
|
|
||||||
|
1. If the host prompt already gives a connected default target session, prefer it directly:
|
||||||
|
- `<netcatty-cli-prefix> session --session <default-session-id> --json --chat-session <chat-session-id>`
|
||||||
|
- `<netcatty-cli-prefix> exec --session <default-session-id> --json --chat-session <chat-session-id> -- <command>`
|
||||||
|
2. Otherwise:
|
||||||
|
- `<netcatty-cli-prefix> env --json --chat-session <chat-session-id>`
|
||||||
|
- Choose a `connected` session.
|
||||||
|
- `<netcatty-cli-prefix> session --session <session-id> --json --chat-session <chat-session-id>`
|
||||||
|
- `<netcatty-cli-prefix> exec --session <session-id> --json --chat-session <chat-session-id> -- <command>`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Use `exec` only for command-style tasks expected to finish within about 60 seconds, such as hostname, IP address, CPU info, memory info, disk usage, pwd, whoami, uname, or process checks.
|
||||||
|
- Use long-running jobs for builds, scans, migrations, watch mode, `tail -f`, `ping`, log-following, or anything likely to exceed that budget or stream output for an extended period.
|
||||||
|
- Long-running flow:
|
||||||
|
- `<netcatty-cli-prefix> job-start --session <session-id> --chat-session <chat-session-id> --json -- <command>`
|
||||||
|
- wait before polling unless the output clearly justifies checking sooner
|
||||||
|
- `<netcatty-cli-prefix> job-poll --job <job-id> --chat-session <chat-session-id> --offset <offset> --json`
|
||||||
|
- if the user asks to stop it: `<netcatty-cli-prefix> job-stop --job <job-id> --chat-session <chat-session-id> --json`
|
||||||
|
- Prefer one straightforward command over temporary scripts or multi-step shell orchestration.
|
||||||
|
- Avoid shell command substitution such as `$()` and backticks, because Netcatty safety policy may block them.
|
||||||
|
- Avoid wrapping simple commands in `sh -c`, `bash -c`, or similar shell launchers unless truly necessary.
|
||||||
|
- Only write a script when the task genuinely needs branching, loops, or structured parsing that cannot fit cleanly in one direct command.
|
||||||
17
skills/netcatty-tool-cli/references/session-types.md
Normal file
17
skills/netcatty-tool-cli/references/session-types.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Session Types
|
||||||
|
|
||||||
|
Read this only when the target session is not a routine shell session or when you are unsure how to execute the command safely.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Always call `session --session <id> --json --chat-session <chat-session-id>` before any `exec`.
|
||||||
|
- Do not guess protocol, shell type, device type, or connection state from the `env` payload alone.
|
||||||
|
- For normal shell sessions, pass the command after `--` so Netcatty can return `stdout`, `stderr`, and `exitCode`.
|
||||||
|
- For serial/raw sessions and sessions with `deviceType: network`, commands are sent as-is without shell wrapping.
|
||||||
|
- For serial/raw and network-device sessions, use vendor CLI commands directly and avoid pipes, redirects, subshells, and shell-only syntax.
|
||||||
|
|
||||||
|
## Decision Guide
|
||||||
|
|
||||||
|
- If the session metadata shows a normal shell: use one direct shell command.
|
||||||
|
- If the session metadata shows `protocol: serial`, `shellType: raw`, or `deviceType: network`: use device-native commands only.
|
||||||
|
- If the session is not connected: do not execute commands in it.
|
||||||
42
skills/netcatty-tool-cli/references/sftp.md
Normal file
42
skills/netcatty-tool-cli/references/sftp.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# SFTP Reference
|
||||||
|
|
||||||
|
Use this reference for remote file or directory tasks.
|
||||||
|
|
||||||
|
## Default Path
|
||||||
|
|
||||||
|
- Treat file and directory tasks as SFTP tasks by default, not shell tasks.
|
||||||
|
- If the user explicitly says to use only `sftp`, do not call `exec`.
|
||||||
|
- Every `sftp` command must include both `--session <session-id>` and `--chat-session <chat-session-id>`.
|
||||||
|
- Do not use reusable SFTP handles or `--sftp <id>`.
|
||||||
|
- After choosing a target session, first run `session --session <id> --json --chat-session <chat-session-id>` and inspect the returned metadata.
|
||||||
|
- Use SFTP only when that `session` result shows a connected SSH-backed session. For local, Mosh, Telnet, serial/raw, or network-device sessions, do not use SFTP.
|
||||||
|
- Keep path semantics strict:
|
||||||
|
- `--remote-path` always means a path on the remote host.
|
||||||
|
- `--local-path` always means a path on the local machine running Netcatty.
|
||||||
|
- If the user says "download" to a local destination such as `/tmp`, `~/Downloads`, or Desktop, use `sftp download`.
|
||||||
|
- If the user says to create or modify a file on the remote host, use `sftp write`, `sftp upload`, or another remote SFTP operation. Do not reinterpret that as a local download.
|
||||||
|
|
||||||
|
## One-Off Commands
|
||||||
|
|
||||||
|
- List a directory:
|
||||||
|
- `<netcatty-cli-prefix> sftp list --session <session-id> --remote-path <remote-path> --json --chat-session <chat-session-id>`
|
||||||
|
- Read a file:
|
||||||
|
- `<netcatty-cli-prefix> sftp read --session <session-id> --remote-path <remote-path> --json --chat-session <chat-session-id>`
|
||||||
|
- Write a small text file with known content:
|
||||||
|
- `<netcatty-cli-prefix> sftp write --session <session-id> --remote-path <remote-path> --content <text> --json --chat-session <chat-session-id>`
|
||||||
|
- Download a remote file to an existing local path:
|
||||||
|
- `<netcatty-cli-prefix> sftp download --session <session-id> --remote-path <remote-path> --local-path <local-path> --json --chat-session <chat-session-id>`
|
||||||
|
- Upload an existing local file:
|
||||||
|
- `<netcatty-cli-prefix> sftp upload --session <session-id> --local-path <local-path> --remote-path <remote-path> --json --chat-session <chat-session-id>`
|
||||||
|
- Delete a remote path:
|
||||||
|
- `<netcatty-cli-prefix> sftp delete --session <session-id> --remote-path <remote-path> --json --chat-session <chat-session-id>`
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Use `sftp write` directly for creating or updating a small text file with known content.
|
||||||
|
- Use `sftp upload` only when a real local file already exists and must be transferred.
|
||||||
|
- Use `sftp download` when the result must be saved to the local filesystem.
|
||||||
|
- Do not create temporary local files just to upload text that could be sent with `sftp write`.
|
||||||
|
- Do not use `sftp read` as a substitute for `sftp download` when the user asked for a local saved file.
|
||||||
|
- Do not use `sftp write` as a substitute for `sftp download`; writing to `/tmp/foo` with `sftp write` writes to the remote host's `/tmp`, not the local machine.
|
||||||
|
- Do not use shell commands like `cat`, `touch`, redirection, or ad hoc SCP/SSH usage for remote file tasks.
|
||||||
Reference in New Issue
Block a user