chore: 死代码清理与架构分层修复 (#524)

* chore: 移除死代码并修复架构分层违规

- 删除未使用的 ACP 模块 (infrastructure/ai/acp/)
- 删除未使用的 AI 组件 (ExecutionPlan, PermissionDialog)
- 将 syncPayload.ts 从 domain 移至 application 层,修复分层违规
- 移除未使用的导出 (useSecurityState, useProviderStatus, GitHubAuthState,
  getAgentCommandLabel, ImageAttachment, HotkeyActions)
- 收窄 Electron bridge module.exports,移除未使用的导出函数
- 将仅内部使用的函数/类型取消导出 (isSupportedLocale, SyncDashboard)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 二次审查清理 — 移除更多死代码和架构违规

- 移除未使用的 ConversationEmptyState 组件和类型
- 移除未使用的 PromptInputSelect 系列组件 (5 个导出)
- 移除 global.d.ts 中残留的 SMBConfig 类型和 cloudSyncSmb* 方法声明
- 移除 useAutoSync.ts 中未使用的 toast 导入 (同时修复 application→components 反向依赖)
- 清理因删除而产生的多余 import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 消除直接 localStorage 访问,提取 safeSend 共享工具

localStorage 集中化:
- 新增 storageKeys 常量: SIDE_PANEL_WIDTH, PF_RECONNECT_CANCEL, DEBUG_HOTKEYS, DEBUG_UPDATE_DEMO
- TerminalLayer/SettingsApplicationTab/App.tsx/useUpdateCheck 改用 localStorageAdapter
- CloudSyncManager 内部方法改用 localStorageAdapter
- portForwardingService 改用 localStorageAdapter + 集中 key

safeSend 去重:
- 新增 electron/bridges/ipcUtils.cjs 共享模块
- sshBridge/sftpBridge/portForwardingBridge/sshAuthHelper/aiBridge 统一引用

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 终审清理 — 移除未使用的 require 和废弃类型别名

- 移除 sftpBridge.cjs 中未使用的 require("node:net")
- 移除 aiBridge.cjs 中未使用的 require("node:path")
- 移除 types.ts 中已废弃的 ChatMessageImage 类型别名

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 修复 ESLint 错误 — 组件不再直接导入 infrastructure

- 新增 useStoredNumber hook,TerminalLayer 通过 hook 访问侧边栏宽度
- SettingsApplicationTab 的 isUpdateDemoMode 改为从 useUpdateCheck hook 传入
- 移除 useCloudSync.ts 中未使用的 CloudSyncManager 导入和 GitHubAuthState 接口

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: 提取 notification port,消除 application 层对 components 的依赖

将 toast 通知抽象为 application/notification.ts 端口,
UI 层通过 setNotify 注入实现,useAutoSync 改用 notify 接口。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
陈大猫
2026-03-26 14:14:37 +08:00
committed by GitHub
parent 90d161c1b5
commit 34f9d2a663
38 changed files with 146 additions and 1244 deletions

View File

@@ -19,10 +19,11 @@ import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import { applySyncPayload } from './domain/syncPayload';
import { applySyncPayload } from './application/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
@@ -103,8 +104,7 @@ const LazyCreateWorkspaceDialog = lazy(() =>
const IS_DEV = import.meta.env.DEV;
const HOTKEY_DEBUG =
IS_DEV &&
typeof window !== "undefined" &&
window.localStorage?.getItem("debug.hotkeys") === "1";
localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === "1";
const LazySftpView = lazy(() =>
import('./components/SftpView').then((m) => ({ default: m.SftpView })),

View File

@@ -0,0 +1,38 @@
/**
* Application-layer notification port.
*
* UI layers (e.g. toast) register their implementation via `setNotify`.
* Application code calls `notify.*` without importing any UI module.
*/
export interface NotifyOptions {
title?: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
type NotifyFn = (message: string, titleOrOptions?: string | NotifyOptions) => void;
interface Notify {
success: NotifyFn;
error: NotifyFn;
warning: NotifyFn;
info: NotifyFn;
}
const noop: NotifyFn = () => {};
let _impl: Notify = { success: noop, error: noop, warning: noop, info: noop };
/** Called once by the UI layer to wire up the real implementation. */
export function setNotify(impl: Notify): void {
_impl = impl;
}
export const notify: Notify = {
success: (...args) => _impl.success(...args),
error: (...args) => _impl.error(...args),
warning: (...args) => _impl.warning(...args),
info: (...args) => _impl.info(...args),
};

View File

@@ -16,11 +16,11 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings } from '../../domain/syncPayload';
import { collectSyncableSettings } from '../syncPayload';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { toast } from '../../components/ui/toast';
import { notify } from '../notification';
interface AutoSyncConfig {
// Data to sync
@@ -189,7 +189,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw error;
}
console.error('[AutoSync] Sync failed:', error);
toast.error(
notify.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
@@ -231,7 +231,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Don't save base or skip auto-sync — let the data-change effect
// naturally trigger an upload of the merged payload (which will
// go through syncAllProviders and save base on success).
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);

View File

@@ -24,7 +24,6 @@ import {
isProviderReadyForSync,
} from '../../domain/sync';
import {
CloudSyncManager,
getCloudSyncManager,
type SyncManagerState,
} from '../../infrastructure/services/CloudSyncManager';
@@ -103,12 +102,6 @@ export interface CloudSyncHook {
refresh: () => void;
}
export interface GitHubAuthState {
isAuthenticating: boolean;
deviceFlowState: DeviceFlowState | null;
error: string | null;
}
// ============================================================================
// Hook Implementation
// ============================================================================
@@ -472,60 +465,4 @@ export const useCloudSync = (): CloudSyncHook => {
};
};
// ============================================================================
// Convenience Hooks
// ============================================================================
/**
* Hook for just the security state (lighter weight)
*/
export const useSecurityState = () => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [securityState, setSecurityState] = useState<SecurityState>(
() => manager.getSecurityState()
);
useEffect(() => {
const unsubscribe = manager.subscribe((event) => {
if (event.type === 'SECURITY_STATE_CHANGED') {
setSecurityState(event.state);
}
});
return unsubscribe;
}, [manager]);
return {
securityState,
isUnlocked: securityState === 'UNLOCKED',
isLocked: securityState === 'LOCKED',
hasNoKey: securityState === 'NO_KEY',
};
};
/**
* Hook for provider status indicators
*/
export const useProviderStatus = (provider: CloudProvider) => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [connection, setConnection] = useState<ProviderConnection>(
() => manager.getProviderConnection(provider)
);
useEffect(() => {
const unsubscribe = manager.subscribe(() => {
setConnection(manager.getProviderConnection(provider));
});
return unsubscribe;
}, [manager, provider]);
return {
...connection,
isConnected: isProviderReadyForSync(connection),
isSyncing: connection.status === 'syncing',
hasError: connection.status === 'error',
dotColor: getSyncDotColor(connection.status),
lastSyncFormatted: formatLastSync(connection.lastSync),
};
};
export default useCloudSync;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
export interface HotkeyActions {
interface HotkeyActions {
// Tab management
switchToTab: (tabIndex: number) => void;
nextTab: () => void;

View File

@@ -0,0 +1,29 @@
import { useCallback, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for reading a number from localStorage with lazy persistence.
* Unlike useStoredString/useStoredBoolean, this hook does NOT auto-persist
* on every state change — call `persist()` explicitly when ready (e.g. on
* mouseup after a drag). This avoids flooding localStorage during
* high-frequency updates like resize drags.
*/
export const useStoredNumber = (
storageKey: string,
fallback: number,
clamp?: { min: number; max: number },
) => {
const [value, setValue] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(storageKey);
if (stored === null) return fallback;
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
return stored;
});
const persist = useCallback(
(v: number) => localStorageAdapter.writeNumber(storageKey, v),
[storageKey],
);
return [value, setValue, persist] as const;
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED, STORAGE_KEY_DEBUG_UPDATE_DEMO } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
@@ -13,8 +13,7 @@ const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
// arrives after 8s the duplicate check is avoided.
const STARTUP_CHECK_DELAY_MS = 8000;
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
const IS_UPDATE_DEMO_MODE = localStorageAdapter.readString(STORAGE_KEY_DEBUG_UPDATE_DEMO) === '1';
// Debug logging for update checks (no-op in production)
const debugLog = (..._args: unknown[]) => {};
@@ -44,6 +43,7 @@ export interface UseUpdateCheckResult {
dismissUpdate: () => void;
openReleasePage: () => void;
installUpdate: () => void;
isUpdateDemoMode: boolean;
}
/**
@@ -653,5 +653,6 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
dismissUpdate,
openReleasePage,
installUpdate,
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
};
}

View File

@@ -14,8 +14,8 @@ import type {
PortForwardingRule,
Snippet,
SSHKey,
} from './models';
import type { SyncPayload } from './sync';
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_THEME,

View File

@@ -611,7 +611,7 @@ interface SyncDashboardProps {
onClearLocalData?: () => void;
}
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onApplyPayload,
onClearLocalData,

View File

@@ -68,9 +68,10 @@ interface SettingsApplicationTabProps {
checkNow: UseUpdateCheckResult['checkNow'];
openReleasePage: UseUpdateCheckResult['openReleasePage'];
installUpdate: UseUpdateCheckResult['installUpdate'];
isUpdateDemoMode: boolean;
}
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, isUpdateDemoMode }: SettingsApplicationTabProps) {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
@@ -94,10 +95,6 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
};
}, [getApplicationInfo]);
// Check if demo mode is enabled for development testing
const isUpdateDemoMode = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {

View File

@@ -149,7 +149,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const { updateState, checkNow, installUpdate, openReleasePage, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
const isImmersive = settings.immersiveMode;
@@ -260,6 +260,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
checkNow={checkNow}
openReleasePage={openReleasePage}
installUpdate={installUpdate}
isUpdateDemoMode={isUpdateDemoMode}
/>
)}

View File

@@ -19,6 +19,8 @@ import {
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
import { useStoredString } from '../application/state/useStoredString';
import { useStoredNumber } from '../application/state/useStoredNumber';
import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKeys';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
@@ -473,10 +475,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Side panel state - per-tab tracking of which sub-panel is active
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
const [sidePanelWidth, setSidePanelWidth] = useState(() => {
const stored = window.localStorage.getItem('netcatty_side_panel_width');
return stored ? Math.max(280, Math.min(800, Number(stored))) : 420;
});
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
);
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
'netcatty_side_panel_position',
'left',
@@ -616,13 +617,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
};
const onMouseUp = () => {
sftpResizingRef.current = false;
window.localStorage.setItem('netcatty_side_panel_width', String(lastWidth));
persistSidePanelWidth(lastWidth);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}, [sidePanelWidth, sidePanelPosition]);
}, [sidePanelWidth, sidePanelPosition, setSidePanelWidth, persistSidePanelWidth]);
// Pre-compute host lookup map for O(1) access
const hostMap = useMemo(() => {

View File

@@ -1,5 +1,5 @@
import { cn } from '../../lib/utils';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import type { ComponentProps } from 'react';
import React, { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
import { ArrowDown } from 'lucide-react';
@@ -25,41 +25,6 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
/>
);
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
description?: string;
icon?: ReactNode;
}
export const ConversationEmptyState = ({
className,
title,
description,
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
className,
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();

View File

@@ -8,8 +8,6 @@
import { ArrowUp, Square, X } from 'lucide-react';
import type {
ComponentProps,
ComponentPropsWithoutRef,
ElementRef,
FormEvent,
HTMLAttributes,
KeyboardEvent,
@@ -17,13 +15,6 @@ import type {
} from 'react';
import { forwardRef, useCallback, useRef } from 'react';
import { cn } from '../../lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import {
InputGroup,
@@ -254,30 +245,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
);
PromptInputSubmit.displayName = 'PromptInputSubmit';
// ---------------------------------------------------------------------------
// PromptInputSelect (thin wrappers around the project's Select component)
// ---------------------------------------------------------------------------
export const PromptInputSelect = Select;
export const PromptInputSelectTrigger = forwardRef<
ElementRef<typeof SelectTrigger>,
ComponentPropsWithoutRef<typeof SelectTrigger>
>(({ className, ...props }, ref) => (
<SelectTrigger
ref={ref}
className={cn(
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
'text-muted-foreground/40 hover:text-muted-foreground/70',
'focus:ring-0 focus:ring-offset-0',
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
className,
)}
{...props}
/>
));
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
export const PromptInputSelectContent = SelectContent;
export const PromptInputSelectItem = SelectItem;
export const PromptInputSelectValue = SelectValue;

View File

@@ -154,13 +154,6 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
return 'terminal';
}
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
if (agent.type === 'builtin') {
return 'Built-in terminal assistant';
}
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
}
export const AgentIconBadge: React.FC<{
agent: AgentLike | 'add-more';
size?: 'xs' | 'sm' | 'md' | 'lg';

View File

@@ -1,169 +0,0 @@
/**
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
*
* Shows a numbered list of steps with status indicators, host badges,
* optional command previews, and action buttons.
*/
import {
CheckCircle2,
Circle,
Loader2,
SkipForward,
XCircle,
} from 'lucide-react';
import React from 'react';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
interface ExecutionPlanStep {
description: string;
host?: string;
command?: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
}
interface ExecutionPlanProps {
steps: ExecutionPlanStep[];
onApprove: () => void;
onModify: () => void;
onReject: () => void;
isExecuting: boolean;
}
// -------------------------------------------------------------------
// Status icon mapping
// -------------------------------------------------------------------
function StepStatusIcon({
status,
}: {
status: ExecutionPlanStep['status'];
}) {
switch (status) {
case 'pending':
return <Circle size={16} className="text-muted-foreground" />;
case 'running':
return (
<Loader2 size={16} className="text-blue-500 animate-spin" />
);
case 'completed':
return <CheckCircle2 size={16} className="text-green-500" />;
case 'failed':
return <XCircle size={16} className="text-destructive" />;
case 'skipped':
return (
<SkipForward size={16} className="text-muted-foreground/60" />
);
}
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
steps,
onApprove,
onModify,
onReject,
isExecuting,
}) => {
return (
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
{/* Header */}
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
<span className="text-sm font-medium">
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
</span>
</div>
{/* Steps list */}
<div className="divide-y divide-border/30">
{steps.map((step, index) => (
<div
key={index}
className={cn(
'flex items-start gap-3 px-3 py-2.5 transition-colors',
step.status === 'running' && 'bg-blue-500/5',
step.status === 'completed' && 'bg-green-500/5',
step.status === 'failed' && 'bg-destructive/5',
step.status === 'skipped' && 'opacity-50',
)}
>
{/* Step number + status icon */}
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
{index + 1}
</span>
<StepStatusIcon status={step.status} />
</div>
{/* Step content */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className={cn(
'text-sm',
step.status === 'skipped' && 'line-through',
)}
>
{step.description}
</span>
{step.host && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0"
>
{step.host}
</Badge>
)}
</div>
{step.command && (
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
{step.command}
</code>
)}
</div>
</div>
))}
</div>
{/* Action buttons */}
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
{isExecuting ? (
<Button
variant="destructive"
size="sm"
onClick={onReject}
>
Cancel
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={onReject}>
Cancel
</Button>
<Button variant="outline" size="sm" onClick={onModify}>
Modify Plan
</Button>
<Button size="sm" onClick={onApprove}>
Approve
</Button>
</>
)}
</div>
</div>
);
};
ExecutionPlan.displayName = 'ExecutionPlan';
export default ExecutionPlan;
export { ExecutionPlan };
export type { ExecutionPlanProps, ExecutionPlanStep };

View File

@@ -1,200 +0,0 @@
/**
* PermissionDialog - Modal for AI agent tool call permission requests.
*
* Shown when the agent needs user approval to execute a tool call.
* Displays tool name, arguments, recommendation, and approve/reject actions.
*/
import { ShieldAlert } from 'lucide-react';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
interface PermissionDialogProps {
open: boolean;
toolCall: { name: string; arguments: Record<string, unknown> } | null;
recommendation: 'allow' | 'confirm' | 'deny';
onApprove: () => void;
onReject: () => void;
onDismiss: () => void;
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const PermissionDialog: React.FC<PermissionDialogProps> = ({
open,
toolCall,
recommendation,
onApprove,
onReject,
onDismiss,
}) => {
const { t } = useI18n();
const isDenied = recommendation === 'deny';
// Keyboard shortcuts: Enter to approve, Escape to reject
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isDenied) {
e.preventDefault();
onApprove();
} else if (e.key === 'Escape') {
e.preventDefault();
onReject();
}
},
[isDenied, onApprove, onReject],
);
// Format arguments as readable code block content
let formattedArgs = '';
if (toolCall) {
try {
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
} catch {
formattedArgs = String(toolCall.arguments);
}
}
// Extract host/session info from arguments if present
const sessionId =
toolCall?.arguments?.sessionId as string | undefined;
const sessionIds =
toolCall?.arguments?.sessionIds as string[] | undefined;
const recommendationBadge = () => {
switch (recommendation) {
case 'allow':
return (
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
{t('ai.chat.recommendAllow')}
</Badge>
);
case 'confirm':
return (
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
{t('ai.chat.recommendConfirm')}
</Badge>
);
case 'deny':
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldAlert
size={20}
className={cn(
isDenied ? 'text-destructive' : 'text-yellow-500',
)}
/>
{t('ai.chat.permissionRequired')}
</DialogTitle>
<DialogDescription>
{t('ai.chat.permissionDescription')}
</DialogDescription>
</DialogHeader>
{toolCall && (
<div className="space-y-3">
{/* Tool name and recommendation */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
{toolCall.name}
</code>
</div>
{recommendationBadge()}
</div>
{/* Target session(s) */}
{(sessionId || sessionIds) && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
{sessionId && (
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{sessionId}
</code>
)}
{sessionIds && (
<div className="flex flex-wrap gap-1">
{sessionIds.map((id) => (
<code
key={id}
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
>
{id}
</code>
))}
</div>
)}
</div>
)}
{/* Arguments code block */}
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
{formattedArgs}
</pre>
</div>
{/* Deny warning */}
{isDenied && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
<p className="text-sm text-destructive">
{t('ai.chat.commandBlocked')}
</p>
</div>
)}
</div>
)}
<DialogFooter>
{isDenied ? (
<Button variant="destructive" onClick={onReject} className="w-full">
{t('ai.chat.reject')}
</Button>
) : (
<>
<Button
variant="outline"
onClick={onReject}
className="border-destructive/30 text-destructive hover:bg-destructive/10"
>
{t('ai.chat.reject')}
</Button>
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
PermissionDialog.displayName = 'PermissionDialog';
export default PermissionDialog;
export { PermissionDialog };
export type { PermissionDialogProps };

View File

@@ -1,8 +1,8 @@
import React, { useCallback } from "react";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
import type { SyncableVaultData } from "../../../domain/syncPayload";
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";

View File

@@ -1,5 +1,6 @@
import { AlertCircle, AlertTriangle, CheckCircle, Info, X } from 'lucide-react';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { setNotify } from '../../application/notification';
import { cn } from '../../lib/utils';
export type ToastType = 'success' | 'error' | 'warning' | 'info';
@@ -96,6 +97,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
// Register global toast function
useEffect(() => {
globalShowToast = showToast;
setNotify(toast);
return () => {
globalShowToast = null;
};

View File

@@ -10,7 +10,6 @@ const http = require("node:http");
const { URL } = require("node:url");
const { spawn, execFileSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const path = require("node:path");
const mcpServerBridge = require("./mcpServerBridge.cjs");
@@ -224,14 +223,7 @@ function killTrackedProcessTree(rootPid, childPids) {
}
}
/**
* Safely send an IPC message to a renderer, guarding against destroyed senders.
*/
function safeSend(sender, channel, ...args) {
if (sender && !sender.isDestroyed()) {
sender.send(channel, ...args);
}
}
const { safeSend } = require("./ipcUtils.cjs");
function init(deps) {
sessions = deps.sessions;

View File

@@ -551,6 +551,4 @@ function registerHandlers(ipcMain) {
module.exports = {
init,
registerHandlers,
checkTarAvailable,
checkRemoteTarAvailable,
};

View File

@@ -380,10 +380,5 @@ function cleanup() {
module.exports = {
init,
registerHandlers,
startWatching,
stopWatching,
stopWatchersForSession,
listWatchers,
registerTempFile,
cleanup,
};

View File

@@ -726,14 +726,6 @@ function cleanup() {
module.exports = {
init,
registerHandlers,
registerGlobalHotkey,
unregisterGlobalHotkey,
setCloseToTray,
isCloseToTrayEnabled,
handleWindowClose,
toggleWindowVisibility,
getHotkeyStatus,
setTrayMenuData,
updateTrayMenu,
cleanup,
};

View File

@@ -0,0 +1,20 @@
/**
* Shared IPC utilities for bridge modules.
*/
/**
* Safely send an IPC message to a renderer, guarding against destroyed senders.
* @param {Electron.WebContents} sender
* @param {string} channel
* @param {...unknown} args
*/
function safeSend(sender, channel, ...args) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, ...args);
} catch {
// Ignore destroyed webContents during shutdown / HMR reload.
}
}
module.exports = { safeSend };

View File

@@ -35,17 +35,7 @@ function isTunnelCancelled(tunnelState) {
return Boolean(tunnelState?.cancelled);
}
/**
* Send message to renderer safely
*/
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
const { safeSend } = require("./ipcUtils.cjs");
/**
* Start a port forwarding tunnel

View File

@@ -6,7 +6,6 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const net = require("node:net");
const { TextDecoder } = require("node:util");
const SftpClient = require("ssh2-sftp-client");
const { Client: SSHClient } = require("ssh2");
@@ -412,17 +411,7 @@ function buildSftpAlgorithms(legacyEnabled) {
return algorithms;
}
/**
* Send message to renderer safely
*/
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
const { safeSend } = require("./ipcUtils.cjs");
/**
* Initialize the SFTP bridge with dependencies

View File

@@ -565,17 +565,7 @@ function createKeyboardInteractiveHandler(options) {
};
}
/**
* Send message to renderer safely
*/
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
const { safeSend } = require("./ipcUtils.cjs");
/**
* Apply auth configuration to connection options

View File

@@ -361,14 +361,7 @@ function resolveLangFromCharset(charset) {
return trimmed;
}
function safeSend(sender, channel, payload) {
try {
if (!sender || sender.isDestroyed()) return;
sender.send(channel, payload);
} catch {
// Ignore destroyed webContents during shutdown.
}
}
const { safeSend } = require("./ipcUtils.cjs");
/**
* Initialize the SSH bridge with dependencies
@@ -2281,16 +2274,4 @@ module.exports = {
init,
registerHandlers,
connectThroughChain,
createProxySocket,
startSSHSession,
execCommand,
getSessionPwd,
getServerStats,
generateKeyPair,
checkWindowsSshAgent,
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
isKeyEncrypted,
findAllDefaultPrivateKeys,
isKeyEncrypted,
};

10
global.d.ts vendored
View File

@@ -1,5 +1,5 @@
import type { RemoteFile, SftpFilenameEncoding } from "./types";
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
import type { S3Config, SyncedFile, WebDAVConfig } from "./domain/sync";
declare module "*.cjs" {
const value: Record<string, unknown>;
@@ -440,14 +440,6 @@ declare global {
cloudSyncS3Download?(config: S3Config): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncS3Delete?(config: S3Config): Promise<{ ok: true }>;
cloudSyncSmbInitialize?(config: SMBConfig): Promise<{ resourceId: string | null }>;
cloudSyncSmbUpload?(
config: SMBConfig,
syncedFile: SyncedFile
): Promise<{ resourceId: string }>;
cloudSyncSmbDownload?(config: SMBConfig): Promise<{ syncedFile: SyncedFile | null }>;
cloudSyncSmbDelete?(config: SMBConfig): Promise<{ ok: true }>;
// Port Forwarding
startPortForward?(options: PortForwardOptions): Promise<PortForwardResult>;
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;

View File

@@ -1,398 +0,0 @@
import type {
JsonRpcRequest,
JsonRpcResponse,
JsonRpcNotification,
JsonRpcMessage,
InitializeParams,
InitializeResult,
SessionCreateParams,
PromptParams,
SessionUpdateParams,
PermissionRequestParams,
AgentCapabilities,
} from './protocol';
import { ACP_METHODS } from './protocol';
import type { ExternalAgentConfig } from '../types';
type EventHandler<T = unknown> = (params: T) => void;
// ── Lightweight runtime type guards ──
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function isPermissionRequestParams(v: unknown): v is PermissionRequestParams {
if (!isRecord(v)) return false;
if (typeof v.sessionId !== 'string') return false;
if (!isRecord(v.toolCall)) return false;
if (typeof v.toolCall.name !== 'string') return false;
return true;
}
function isSessionUpdateParams(v: unknown): v is SessionUpdateParams {
if (!isRecord(v)) return false;
if (typeof v.sessionId !== 'string') return false;
if (typeof v.type !== 'string') return false;
return true;
}
function isJsonRpcError(v: unknown): v is { code: number; message: string } {
if (!isRecord(v)) return false;
if (typeof v.code !== 'number') return false;
if (typeof v.message !== 'string') return false;
return true;
}
/**
* Bridge interface to the Electron main process for agent management
*/
interface AgentBridge {
aiSpawnAgent(agentId: string, command: string, args?: string[], env?: Record<string, string>): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
}
/**
* ACP Client - manages a single external agent connection over JSON-RPC 2.0 / NDJSON stdio.
*/
export class ACPClient {
private agentId: string;
private config: ExternalAgentConfig;
private bridge: AgentBridge;
private nextId = 1;
private pendingRequests = new Map<number | string, {
resolve: (result: unknown) => void;
reject: (error: Error) => void;
}>();
private buffer = '';
private cleanupFns: (() => void)[] = [];
private agentCapabilities: AgentCapabilities | null = null;
private _isConnected = false;
// Event handlers
private onSessionUpdate: EventHandler<SessionUpdateParams> | null = null;
private onPermissionRequest: EventHandler<PermissionRequestParams> | null = null;
private onStderr: EventHandler<string> | null = null;
private onExit: EventHandler<number> | null = null;
constructor(config: ExternalAgentConfig, bridge: AgentBridge) {
this.agentId = `acp_${config.id}_${Date.now()}`;
this.config = config;
this.bridge = bridge;
}
get isConnected() { return this._isConnected; }
get capabilities() { return this.agentCapabilities; }
/** Set event handlers */
on(event: 'session_update', handler: EventHandler<SessionUpdateParams>): this;
on(event: 'permission_request', handler: EventHandler<PermissionRequestParams>): this;
on(event: 'stderr', handler: EventHandler<string>): this;
on(event: 'exit', handler: EventHandler<number>): this;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
on(event: string, handler: EventHandler<any>): this {
switch (event) {
case 'session_update': this.onSessionUpdate = handler as EventHandler<SessionUpdateParams>; break;
case 'permission_request': this.onPermissionRequest = handler as EventHandler<PermissionRequestParams>; break;
case 'stderr': this.onStderr = handler as EventHandler<string>; break;
case 'exit': this.onExit = handler as EventHandler<number>; break;
}
return this;
}
/** Start the agent process and perform ACP initialization handshake */
async connect(): Promise<InitializeResult> {
// Spawn the agent process
const result = await this.bridge.aiSpawnAgent(
this.agentId,
this.config.command,
this.config.args,
this.config.env,
);
if (!result.ok) {
throw new Error(`Failed to spawn agent: ${result.error}`);
}
// Listen for stdout (NDJSON messages)
const unsubStdout = this.bridge.onAiAgentStdout(this.agentId, (data) => {
this.handleStdoutData(data);
});
this.cleanupFns.push(unsubStdout);
// Listen for stderr (logging)
const unsubStderr = this.bridge.onAiAgentStderr(this.agentId, (data) => {
this.onStderr?.(data);
});
this.cleanupFns.push(unsubStderr);
// Listen for exit
const unsubExit = this.bridge.onAiAgentExit(this.agentId, (code) => {
this._isConnected = false;
this.onExit?.(code);
// Reject all pending requests
for (const [, pending] of this.pendingRequests) {
pending.reject(new Error(`Agent exited with code ${code}`));
}
this.pendingRequests.clear();
});
this.cleanupFns.push(unsubExit);
// Send initialize request
const initParams: InitializeParams = {
clientInfo: { name: 'netcatty', version: '1.0.0' },
capabilities: {
terminal: { create: true, output: true, waitForExit: true, kill: true },
fileSystem: { read: true, write: true },
permissions: { requestPermission: true },
},
};
const initResult = await this.sendRequest<InitializeResult>(ACP_METHODS.INITIALIZE, initParams);
this.agentCapabilities = initResult.capabilities;
this._isConnected = true;
return initResult;
}
/** Create a new session */
async createSession(params?: SessionCreateParams): Promise<{ sessionId: string }> {
return this.sendRequest(ACP_METHODS.SESSION_CREATE, params || {});
}
/** Send a prompt to the agent */
async prompt(params: PromptParams): Promise<void> {
return this.sendRequest(ACP_METHODS.SESSION_PROMPT, params);
}
/** Cancel the current operation */
async cancel(sessionId: string): Promise<void> {
return this.sendRequest(ACP_METHODS.SESSION_CANCEL, { sessionId });
}
/** Respond to a permission request */
respondPermission(requestId: number | string, approved: boolean): void {
this.sendResponse(requestId, { approved });
}
/** Respond to a terminal create request */
respondTerminalCreate(requestId: number | string, terminalId: string): void {
this.sendResponse(requestId, { terminalId });
}
/** Respond to a file read request */
respondFileRead(requestId: number | string, content: string): void {
this.sendResponse(requestId, { content });
}
/** Respond to a file write request */
respondFileWrite(requestId: number | string, success: boolean): void {
this.sendResponse(requestId, { success });
}
/** Disconnect and kill the agent process */
async disconnect(): Promise<void> {
this._isConnected = false;
for (const cleanup of this.cleanupFns) {
try { cleanup(); } catch { /* ignore cleanup errors */ }
}
this.cleanupFns = [];
await this.bridge.aiKillAgent(this.agentId);
// Reject all pending requests before clearing
for (const [, pending] of this.pendingRequests) {
pending.reject(new Error('Agent disconnected'));
}
this.pendingRequests.clear();
}
// ── Private methods ──
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async sendRequest<T = unknown>(method: string, params?: Record<string, any>): Promise<T> {
const id = this.nextId++;
const request: JsonRpcRequest = {
jsonrpc: '2.0',
id,
method,
params,
};
return new Promise<T>((resolve, reject) => {
// Track timeout so we can clear it when the request resolves
const timeoutId = setTimeout(() => {
if (this.pendingRequests.has(id)) {
this.pendingRequests.delete(id);
reject(new Error(`Request timeout: ${method}`));
}
}, 30000);
this.pendingRequests.set(id, {
resolve: (result: unknown) => {
clearTimeout(timeoutId);
(resolve as (result: unknown) => void)(result);
},
reject: (error: Error) => {
clearTimeout(timeoutId);
reject(error);
},
});
const line = JSON.stringify(request) + '\n';
this.bridge.aiWriteToAgent(this.agentId, line).catch((err) => {
clearTimeout(timeoutId);
this.pendingRequests.delete(id);
reject(err);
});
});
}
private sendResponse(id: number | string, result: unknown): void {
const response: JsonRpcResponse = { jsonrpc: '2.0', id, result };
const line = JSON.stringify(response) + '\n';
this.bridge.aiWriteToAgent(this.agentId, line).catch((err) => {
console.error('[ACP] Failed to send response:', err);
});
}
private sendErrorResponse(id: number | string, code: number, message: string): void {
const response: JsonRpcResponse = {
jsonrpc: '2.0',
id,
error: { code, message },
};
const line = JSON.stringify(response) + '\n';
this.bridge.aiWriteToAgent(this.agentId, line).catch(() => { /* best-effort */ });
}
/** Max NDJSON buffer size (10 MB) to prevent unbounded memory growth */
private static readonly MAX_BUFFER_SIZE = 10 * 1024 * 1024;
private handleStdoutData(data: string): void {
this.buffer += data;
// Guard against unbounded buffer growth
if (this.buffer.length > ACPClient.MAX_BUFFER_SIZE) {
console.warn(`[ACP] NDJSON buffer exceeded ${ACPClient.MAX_BUFFER_SIZE} bytes, clearing buffer`);
this.buffer = '';
return;
}
const lines = this.buffer.split('\n');
this.buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const message = JSON.parse(trimmed) as JsonRpcMessage;
this.handleMessage(message);
} catch {
// Skip non-JSON lines (agent may print logs to stdout)
}
}
}
private handleMessage(message: JsonRpcMessage): void {
// Response to our request
if ('id' in message && ('result' in message || 'error' in message)) {
const response = message as JsonRpcResponse;
const pending = this.pendingRequests.get(response.id);
if (pending) {
this.pendingRequests.delete(response.id);
if (response.error) {
const errMsg = isJsonRpcError(response.error)
? response.error.message
: JSON.stringify(response.error);
pending.reject(new Error(errMsg));
} else {
pending.resolve(response.result);
}
}
return;
}
// Request from agent (needs our response)
if ('id' in message && 'method' in message) {
const request = message as JsonRpcRequest;
this.handleAgentRequest(request);
return;
}
// Notification from agent (no response needed)
if ('method' in message && !('id' in message)) {
const notification = message as JsonRpcNotification;
this.handleAgentNotification(notification);
}
}
private handleAgentRequest(request: JsonRpcRequest): void {
switch (request.method) {
case ACP_METHODS.REQUEST_PERMISSION: {
if (!isPermissionRequestParams(request.params)) {
this.sendErrorResponse(request.id, -32602, 'Invalid permission request params');
break;
}
if (this.onPermissionRequest) {
this.onPermissionRequest({
...request.params,
// Attach the request ID so the handler can respond via respondPermission()
_requestId: request.id,
} as PermissionRequestParams & { _requestId: number | string });
} else {
this.sendErrorResponse(request.id, -32603, 'Permission request handler not configured');
}
break;
}
case ACP_METHODS.TERMINAL_CREATE:
case ACP_METHODS.TERMINAL_WAIT_EXIT:
case ACP_METHODS.TERMINAL_KILL:
case ACP_METHODS.FS_READ:
case ACP_METHODS.FS_WRITE:
// Surface as tool_call so the UI layer can handle and respond
this.onSessionUpdate?.({
sessionId: String(request.params?.sessionId || ''),
type: 'tool_call',
toolCall: {
id: String(request.id),
name: request.method,
arguments: (request.params as Record<string, unknown>) || {},
},
});
break;
default:
// Unknown method - respond with JSON-RPC method-not-found error
this.sendErrorResponse(request.id, -32601, `Method not found: ${request.method}`);
}
}
private handleAgentNotification(notification: JsonRpcNotification): void {
switch (notification.method) {
case ACP_METHODS.SESSION_UPDATE:
if (isSessionUpdateParams(notification.params)) {
this.onSessionUpdate?.(notification.params);
}
break;
case ACP_METHODS.TERMINAL_OUTPUT:
// Surface terminal output as a session update with tool_result type
this.onSessionUpdate?.({
sessionId: String(notification.params?.sessionId || ''),
type: 'tool_result',
toolResult: {
toolCallId: String(notification.params?.terminalId || ''),
content: String(notification.params?.data || ''),
},
});
break;
default:
// Ignore unknown notifications
break;
}
}
}

View File

@@ -1,93 +0,0 @@
import { ACPClient } from './client';
import type { ExternalAgentConfig } from '../types';
import type { SessionUpdateParams, PermissionRequestParams, InitializeResult } from './protocol';
interface AgentBridge {
aiSpawnAgent(agentId: string, command: string, args?: string[], env?: Record<string, string>): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent(agentId: string): Promise<{ ok: boolean; error?: string }>;
onAiAgentStdout(agentId: string, cb: (data: string) => void): () => void;
onAiAgentStderr(agentId: string, cb: (data: string) => void): () => void;
onAiAgentExit(agentId: string, cb: (code: number) => void): () => void;
}
export interface ACPManagerCallbacks {
onSessionUpdate: (agentConfigId: string, params: SessionUpdateParams) => void;
onPermissionRequest: (agentConfigId: string, params: PermissionRequestParams) => void;
onAgentError: (agentConfigId: string, error: string) => void;
onAgentExit: (agentConfigId: string, code: number) => void;
}
/**
* Manages multiple ACP agent connections.
*/
export class ACPManager {
private clients = new Map<string, ACPClient>();
private bridge: AgentBridge;
private callbacks: ACPManagerCallbacks;
constructor(bridge: AgentBridge, callbacks: ACPManagerCallbacks) {
this.bridge = bridge;
this.callbacks = callbacks;
}
/** Connect to an external agent */
async connect(config: ExternalAgentConfig): Promise<InitializeResult> {
if (this.clients.has(config.id)) {
await this.disconnect(config.id);
}
const client = new ACPClient(config, this.bridge);
client
.on('session_update', (params) => {
this.callbacks.onSessionUpdate(config.id, params);
})
.on('permission_request', (params) => {
this.callbacks.onPermissionRequest(config.id, params);
})
.on('stderr', (data) => {
this.callbacks.onAgentError(config.id, data);
})
.on('exit', (code) => {
this.clients.delete(config.id);
this.callbacks.onAgentExit(config.id, code);
});
const result = await client.connect();
this.clients.set(config.id, client);
return result;
}
/** Get a connected client */
getClient(configId: string): ACPClient | undefined {
return this.clients.get(configId);
}
/** Check if an agent is connected */
isConnected(configId: string): boolean {
return this.clients.get(configId)?.isConnected ?? false;
}
/** Disconnect a specific agent */
async disconnect(configId: string): Promise<void> {
const client = this.clients.get(configId);
if (client) {
await client.disconnect();
this.clients.delete(configId);
}
}
/** Disconnect all agents */
async disconnectAll(): Promise<void> {
const promises = Array.from(this.clients.keys()).map(id => this.disconnect(id));
await Promise.allSettled(promises);
}
/** Get list of connected agent IDs */
getConnectedAgentIds(): string[] {
return Array.from(this.clients.entries())
.filter(([, client]) => client.isConnected)
.map(([id]) => id);
}
}

View File

@@ -1,94 +0,0 @@
// JSON-RPC 2.0 base types
export interface JsonRpcRequest {
jsonrpc: '2.0';
id: number | string;
method: string;
params?: Record<string, unknown>;
}
export interface JsonRpcResponse {
jsonrpc: '2.0';
id: number | string;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
export interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: Record<string, unknown>;
}
export type JsonRpcMessage = JsonRpcRequest | JsonRpcResponse | JsonRpcNotification;
// ACP-specific types
/** Capabilities that the client (Netcatty) declares it supports */
export interface ClientCapabilities {
fileSystem?: { read?: boolean; write?: boolean };
terminal?: { create?: boolean; output?: boolean; waitForExit?: boolean; kill?: boolean };
permissions?: { requestPermission?: boolean };
}
/** Capabilities that the agent declares it supports */
export interface AgentCapabilities {
streaming?: boolean;
tools?: string[];
}
/** ACP initialize params */
export interface InitializeParams {
clientInfo: { name: string; version: string };
capabilities: ClientCapabilities;
}
/** ACP initialize result */
export interface InitializeResult {
agentInfo: { name: string; version: string };
capabilities: AgentCapabilities;
}
/** ACP session create params */
export interface SessionCreateParams {
sessionId?: string;
context?: Record<string, unknown>;
}
/** ACP prompt params - send a user message */
export interface PromptParams {
sessionId: string;
message: string;
context?: Record<string, unknown>;
}
/** ACP session update events (streamed as notifications) */
export interface SessionUpdateParams {
sessionId: string;
type: 'text' | 'tool_call' | 'tool_result' | 'thinking' | 'error' | 'done';
content?: string;
toolCall?: { id: string; name: string; arguments: Record<string, unknown> };
toolResult?: { toolCallId: string; content: string; isError?: boolean };
}
/** ACP permission request */
export interface PermissionRequestParams {
sessionId: string;
toolCall: { name: string; arguments: Record<string, unknown> };
description?: string;
}
// ACP method names
export const ACP_METHODS = {
INITIALIZE: 'initialize',
SESSION_CREATE: 'session/create',
SESSION_PROMPT: 'session/prompt',
SESSION_CANCEL: 'session/cancel',
SESSION_UPDATE: 'session/update', // notification from agent
REQUEST_PERMISSION: 'session/request_permission', // request from agent
TERMINAL_CREATE: 'terminal/create', // request from agent
TERMINAL_OUTPUT: 'terminal/output', // notification from agent
TERMINAL_WAIT_EXIT: 'terminal/waitForExit', // request from agent
TERMINAL_KILL: 'terminal/kill', // request from agent
FS_READ: 'fs/readTextFile', // request from agent
FS_WRITE: 'fs/writeTextFile', // request from agent
} as const;

View File

@@ -32,7 +32,7 @@ interface AcpBridge {
model?: string,
existingSessionId?: string,
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
images?: ImageAttachment[],
images?: FileAttachment[],
): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
@@ -57,9 +57,6 @@ export interface FileAttachment {
filePath?: string;
}
/** @deprecated Use FileAttachment instead */
export type ImageAttachment = FileAttachment;
export async function runAcpAgentTurn(
bridge: Record<string, (...args: unknown[]) => unknown>,
requestId: string,
@@ -72,7 +69,7 @@ export async function runAcpAgentTurn(
model?: string,
existingSessionId?: string,
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
images?: ImageAttachment[],
images?: FileAttachment[],
): Promise<void> {
const acpBridge = bridge as unknown as AcpBridge;

View File

@@ -30,9 +30,6 @@ export interface ChatMessageAttachment {
filePath?: string; // original filesystem path (for ACP agents to read directly)
}
/** @deprecated Use ChatMessageAttachment instead */
export type ChatMessageImage = ChatMessageAttachment;
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system' | 'tool';

View File

@@ -11,7 +11,7 @@ export const SUPPORTED_UI_LOCALES: LocaleOption[] = [
{ id: 'zh-CN', label: '简体中文' },
];
export const isSupportedLocale = (locale: string): boolean => {
const isSupportedLocale = (locale: string): boolean => {
return SUPPORTED_UI_LOCALES.some((l) => l.id === locale);
};

View File

@@ -93,3 +93,13 @@ export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_v1';
// Immersive Mode
export const STORAGE_KEY_IMMERSIVE_MODE = 'netcatty_immersive_mode_v1';
// Side Panel
export const STORAGE_KEY_SIDE_PANEL_WIDTH = 'netcatty_side_panel_width';
// Port Forwarding (transient cross-window broadcast key)
export const STORAGE_KEY_PF_RECONNECT_CANCEL = '__netcatty_pf_cancel_reconnect';
// Debug Flags (no _v1 suffix — developer-only, not persisted data)
export const STORAGE_KEY_DEBUG_HOTKEYS = 'debug.hotkeys';
export const STORAGE_KEY_DEBUG_UPDATE_DEMO = 'debug.updateDemo';

View File

@@ -36,6 +36,7 @@ import {
import packageJson from '../../package.json';
import { EncryptionService } from './EncryptionService';
import { createAdapter, type CloudAdapter } from './adapters';
import { localStorageAdapter } from '../persistence/localStorageAdapter';
import type { GitHubAdapter } from './adapters/GitHubAdapter';
import type { GoogleDriveAdapter } from './adapters/GoogleDriveAdapter';
import type { OneDriveAdapter } from './adapters/OneDriveAdapter';
@@ -239,31 +240,15 @@ export class CloudSyncManager {
}
private loadFromStorage<T>(key: string): T | null {
try {
// eslint-disable-next-line no-restricted-globals
const data = localStorage.getItem(key);
return data ? JSON.parse(data) : null;
} catch {
return null;
}
return localStorageAdapter.read<T>(key);
}
private saveToStorage(key: string, value: unknown): void {
try {
// eslint-disable-next-line no-restricted-globals
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error('Failed to save to storage:', e);
}
localStorageAdapter.write(key, value);
}
private removeFromStorage(key: string): void {
try {
// eslint-disable-next-line no-restricted-globals
localStorage.removeItem(key);
} catch {
// ignore storage removal failures
}
localStorageAdapter.remove(key);
}
// ==========================================================================

View File

@@ -8,6 +8,8 @@ import { Host, Identity, PortForwardingRule, SSHKey } from '../../domain/models'
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
import { resolveHostAuth } from '../../domain/sshAuth';
import { logger } from '../../lib/logger';
import { localStorageAdapter } from '../persistence/localStorageAdapter';
import { STORAGE_KEY_PF_RECONNECT_CANCEL } from '../config/storageKeys';
import { netcattyBridge } from './netcattyBridge';
export interface PortForwardingConnection {
@@ -57,14 +59,12 @@ export const clearReconnectTimer = (ruleId: string): void => {
// Cross-window reconnect cancellation via localStorage broadcast.
// When one window deletes/replaces a rule, it writes to this key so
// other windows (with pending reconnect timers) can cancel them.
const RECONNECT_CANCEL_KEY = '__netcatty_pf_cancel_reconnect';
const broadcastReconnectCancel = (ruleId: string): void => {
try {
// Write then immediately remove so the storage event fires on
// other windows without leaving stale data.
window.localStorage.setItem(RECONNECT_CANCEL_KEY, ruleId);
window.localStorage.removeItem(RECONNECT_CANCEL_KEY);
localStorageAdapter.writeString(STORAGE_KEY_PF_RECONNECT_CANCEL, ruleId);
localStorageAdapter.remove(STORAGE_KEY_PF_RECONNECT_CANCEL);
} catch {
// localStorage may be unavailable in some contexts
}
@@ -77,7 +77,7 @@ const broadcastReconnectCancel = (ruleId: string): void => {
*/
export const initReconnectCancelListener = (): (() => void) => {
const handler = (e: StorageEvent) => {
if (e.key !== RECONNECT_CANCEL_KEY || !e.newValue) return;
if (e.key !== STORAGE_KEY_PF_RECONNECT_CANCEL || !e.newValue) return;
const ruleId = e.newValue;
clearReconnectTimer(ruleId);