Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be7aa4ae52 | ||
|
|
f4872099bd | ||
|
|
4e2089d7e2 | ||
|
|
5f28320c57 | ||
|
|
4e26852482 | ||
|
|
c4fb19cafb | ||
|
|
09e6526142 | ||
|
|
7ce110c3fb | ||
|
|
667ee18ed3 | ||
|
|
f969b1b73d | ||
|
|
58a4bf892a | ||
|
|
5052a8231f | ||
|
|
13c9cf16fd | ||
|
|
63558b5301 | ||
|
|
c2b4d43531 | ||
|
|
4d5c0eed69 | ||
|
|
3ad710e5da | ||
|
|
d2e5a26317 | ||
|
|
4f1eb4a8a9 | ||
|
|
e35bb708a2 | ||
|
|
cd2631428e | ||
|
|
09af399543 | ||
|
|
db9970d040 | ||
|
|
3d4fbf8763 | ||
|
|
9387590696 | ||
|
|
74a04f1d8e | ||
|
|
3c258b0f19 | ||
|
|
6303eef3a2 | ||
|
|
a9a648039f | ||
|
|
1d4ec7afb9 | ||
|
|
a1899951e0 | ||
|
|
d84668aa0f | ||
|
|
68d0f4574c | ||
|
|
bedf59bb48 | ||
|
|
793ea94078 |
3
.gitignore
vendored
@@ -37,6 +37,9 @@ coverage
|
||||
|
||||
# Claude Code
|
||||
/.claude/
|
||||
|
||||
# Codex
|
||||
/.codex/
|
||||
/CLAUDE.md
|
||||
|
||||
# AI / Superpowers generated docs (local only)
|
||||
|
||||
30
App.tsx
@@ -185,6 +185,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
@@ -1067,31 +1068,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
});
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
|
||||
|
||||
// Auto-save session log if enabled
|
||||
if (sessionLogsEnabled && sessionLogsDir && data) {
|
||||
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.autoSaveSessionLog) {
|
||||
bridge.autoSaveSessionLog({
|
||||
terminalData: data,
|
||||
hostLabel: matchingLog.hostLabel,
|
||||
hostname: matchingLog.hostname,
|
||||
hostId: matchingLog.hostId,
|
||||
startTime: matchingLog.startTime,
|
||||
format: sessionLogsFormat,
|
||||
directory: sessionLogsDir,
|
||||
}).then(result => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
|
||||
}).catch(err => {
|
||||
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// Auto-save is now handled by real-time streaming in the main process
|
||||
// via sessionLogStreamManager. No renderer-side fallback needed.
|
||||
} else {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
|
||||
}
|
||||
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
|
||||
}, [sessions, connectionLogs, updateConnectionLog]);
|
||||
|
||||
// Check if host has multiple protocols enabled
|
||||
const hasMultipleProtocols = useCallback((host: Host) => {
|
||||
@@ -1324,8 +1306,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sftpAutoSync={sftpAutoSync}
|
||||
sftpShowHiddenFiles={sftpShowHiddenFiles}
|
||||
sftpUseCompressedUpload={sftpUseCompressedUpload}
|
||||
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
- [ビルドとパッケージ](#ビルドとパッケージ)
|
||||
- [技術スタック](#技術スタック)
|
||||
- [コントリビューション](#コントリビューション)
|
||||
- [コントリビューター](#コントリビューター)
|
||||
- [Star 履歴](#star-履歴)
|
||||
- [ライセンス](#ライセンス)
|
||||
|
||||
---
|
||||
@@ -110,37 +112,37 @@
|
||||
<a name="デモ"></a>
|
||||
# デモ
|
||||
|
||||
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
動画で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
|
||||
### Vault ビュー:グリッド / リスト / ツリー
|
||||
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
### 分割ターミナル + セッション管理
|
||||
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
|
||||
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
### ドラッグでアップロード
|
||||
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
### カスタムテーマ
|
||||
テーマを調整して自分の好みに合わせた見た目に。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
### キーワードハイライト
|
||||
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
---
|
||||
|
||||
@@ -196,6 +198,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
|
||||
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
|
||||
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -305,6 +308,17 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
|
||||
---
|
||||
|
||||
<a name="コントリビューター"></a>
|
||||
# コントリビューター
|
||||
|
||||
貢献してくださったすべての方に感謝します!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="ライセンス"></a>
|
||||
# ライセンス
|
||||
|
||||
@@ -312,6 +326,19 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
|
||||
---
|
||||
|
||||
<a name="star-履歴"></a>
|
||||
# Star 履歴
|
||||
|
||||
<a href="https://star-history.com/#binaricat/Netcatty&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
❤️ を込めて作成 by <a href="https://ko-fi.com/binaricat">binaricat</a>
|
||||
</p>
|
||||
|
||||
50
README.md
@@ -59,6 +59,8 @@
|
||||
- [Build & Package](#build--package)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Contributing](#contributing)
|
||||
- [Contributors](#contributors)
|
||||
- [Star History](#star-history)
|
||||
- [License](#license)
|
||||
|
||||
---
|
||||
@@ -111,37 +113,53 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
<a name="demos"></a>
|
||||
# Demos
|
||||
|
||||
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
|
||||
### Vault views: grid / list / tree
|
||||
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
|
||||
|
||||
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
|
||||
|
||||
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -197,6 +215,7 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
|
||||
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
<a name="getting-started"></a>
|
||||
@@ -309,7 +328,9 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
|
||||
Thanks to all the people who contribute!
|
||||
|
||||
See: https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
@@ -320,6 +341,19 @@ This project is licensed under the **GPL-3.0 License** - see the [LICENSE](LICEN
|
||||
|
||||
---
|
||||
|
||||
<a name="star-history"></a>
|
||||
# Star History
|
||||
|
||||
<a href="https://star-history.com/#binaricat/Netcatty&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
Made with ❤️ by <a href="https://ko-fi.com/binaricat">binaricat</a>
|
||||
</p>
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
- [构建与打包](#构建与打包)
|
||||
- [技术栈](#技术栈)
|
||||
- [参与贡献](#参与贡献)
|
||||
- [贡献者](#贡献者)
|
||||
- [Star 历史](#star-历史)
|
||||
- [开源协议](#开源协议)
|
||||
|
||||
---
|
||||
@@ -111,37 +113,37 @@
|
||||
<a name="演示"></a>
|
||||
# 演示
|
||||
|
||||
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
|
||||
### Vault 视图:网格 / 列表 / 树形
|
||||
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
|
||||
|
||||
### 分屏终端 + 会话管理
|
||||
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
|
||||
|
||||
### SFTP:拖拽 + 内置编辑器
|
||||
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
|
||||
|
||||
### 拖拽文件上传
|
||||
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
|
||||
|
||||
### 自定义主题
|
||||
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
|
||||
|
||||
### 关键词高亮
|
||||
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
|
||||
|
||||

|
||||
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
|
||||
|
||||
---
|
||||
|
||||
@@ -197,6 +199,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
|
||||
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
|
||||
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
|
||||
</p>
|
||||
|
||||
<a name="快速开始"></a>
|
||||
@@ -309,7 +312,9 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
感谢所有参与贡献的人!
|
||||
|
||||
查看:https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
@@ -320,6 +325,19 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="star-历史"></a>
|
||||
# Star 历史
|
||||
|
||||
<a href="https://star-history.com/#binaricat/Netcatty&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
用 ❤️ 制作,作者 <a href="https://ko-fi.com/binaricat">binaricat</a>
|
||||
</p>
|
||||
|
||||
@@ -745,6 +745,13 @@ const en: Messages = {
|
||||
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
|
||||
'settings.sftp.autoSync.enable': 'Enable auto-sync',
|
||||
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
|
||||
|
||||
// Settings > SFTP Auto Open Sidebar
|
||||
'settings.sftp.autoOpenSidebar': 'Auto-open sidebar on connect',
|
||||
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
|
||||
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
|
||||
|
||||
'sftp.autoSync.success': 'File synced to remote: {fileName}',
|
||||
'sftp.autoSync.error': 'Failed to sync file: {error}',
|
||||
|
||||
|
||||
@@ -1060,6 +1060,13 @@ const zhCN: Messages = {
|
||||
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
|
||||
'settings.sftp.autoSync.enable': '启用自动同步',
|
||||
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
|
||||
|
||||
// Settings > SFTP 自动打开侧栏
|
||||
'settings.sftp.autoOpenSidebar': '连接时自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.desc': '连接到主机时自动打开 SFTP 文件浏览器侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
|
||||
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
|
||||
|
||||
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
|
||||
'sftp.autoSync.error': '同步文件失败:{error}',
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
}
|
||||
|
||||
function cleanupAcpSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
for (const sessionId of sessionIds) {
|
||||
void bridge.aiAcpCleanup(sessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
@@ -376,6 +384,7 @@ export function useAIState() {
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
cleanupAcpSessions([sessionId]);
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
@@ -394,6 +403,10 @@ export function useAIState() {
|
||||
}, [persistSessions]);
|
||||
|
||||
const deleteSessionsByTarget = useCallback((scopeType: 'terminal' | 'workspace', targetId: string) => {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
|
||||
.map(s => s.id);
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
if (persistTimerRef.current) {
|
||||
clearTimeout(persistTimerRef.current);
|
||||
persistTimerRef.current = null;
|
||||
@@ -420,6 +433,18 @@ export function useAIState() {
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => (
|
||||
s.id === sessionId
|
||||
? { ...s, externalSessionId, updatedAt: Date.now() }
|
||||
: s
|
||||
));
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
@@ -484,6 +509,10 @@ export function useAIState() {
|
||||
}, [persistSessions]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
|
||||
.map(s => s.id);
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
// Keep sessions without a targetId (global scope)
|
||||
@@ -572,6 +601,7 @@ export function useAIState() {
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
|
||||
@@ -52,11 +52,13 @@ interface SyncNowOptions {
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const { t } = useI18n();
|
||||
const sync = useCloudSync();
|
||||
const { onApplyPayload } = config;
|
||||
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastSyncedDataRef = useRef<string>('');
|
||||
const hasCheckedRemoteRef = useRef(false);
|
||||
const isInitializedRef = useRef(false);
|
||||
const isSyncRunningRef = useRef(false);
|
||||
const skipNextSyncRef = useRef(false);
|
||||
|
||||
const getSyncSnapshot = useCallback(() => {
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
@@ -162,6 +164,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
|
||||
// Apply merged payloads first (before checking for failures) so local
|
||||
// state gets updated even when some providers failed
|
||||
for (const result of results.values()) {
|
||||
if (result.mergedPayload) {
|
||||
onApplyPayload(result.mergedPayload);
|
||||
skipNextSyncRef.current = true;
|
||||
break; // All providers share the same merged payload
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of results.values()) {
|
||||
if (!result.success) {
|
||||
if (result.conflictDetected) {
|
||||
@@ -184,7 +196,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
} finally {
|
||||
isSyncRunningRef.current = false;
|
||||
}
|
||||
}, [sync, buildPayload, getDataHash, t]);
|
||||
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
@@ -207,18 +219,26 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
try {
|
||||
console.log('[AutoSync] Checking remote version...');
|
||||
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
|
||||
const base = await manager.loadSyncBase(connectedProvider);
|
||||
const remotePayload = await sync.downloadFromProvider(connectedProvider);
|
||||
|
||||
|
||||
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
|
||||
console.log('[AutoSync] Remote is newer, applying...');
|
||||
config.onApplyPayload(remotePayload);
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const localPayload = buildPayload();
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
console.log('[AutoSync] Remote is newer, merged:', mergeResult.summary);
|
||||
config.onApplyPayload(mergeResult.payload);
|
||||
// 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'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Don't show error toast for initial check - it's not critical
|
||||
}
|
||||
}, [sync, config, t]);
|
||||
}, [sync, config, buildPayload, t]);
|
||||
|
||||
// Debounced auto-sync when data changes
|
||||
useEffect(() => {
|
||||
@@ -235,7 +255,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
const currentHash = getDataHash();
|
||||
|
||||
|
||||
// After a merge, onApplyPayload changes local state which triggers
|
||||
// this effect. Skip that cycle and just update the hash baseline.
|
||||
if (skipNextSyncRef.current) {
|
||||
skipNextSyncRef.current = false;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if data hasn't changed
|
||||
if (currentHash === lastSyncedDataRef.current) {
|
||||
return;
|
||||
|
||||
79
application/state/useFileUpload.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* useFileUpload - Handle file paste/drop with base64 conversion
|
||||
*
|
||||
* Supports images, PDFs, and other document types.
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import { getPathForFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
|
||||
filePath?: string; // original filesystem path (Electron only)
|
||||
}
|
||||
|
||||
/** Reject only known binary blobs that AI models can't process */
|
||||
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
||||
|
||||
function isSupportedFile(file: File): boolean {
|
||||
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
|
||||
if (!file.type) return true;
|
||||
return !REJECTED_MIME_PREFIXES.some(prefix => file.type.startsWith(prefix));
|
||||
}
|
||||
|
||||
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.split(',')[1] || '';
|
||||
resolve({ dataUrl, base64 });
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileUpload() {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
|
||||
const addFiles = useCallback(async (inputFiles: File[]) => {
|
||||
const supported = inputFiles.filter(isSupportedFile);
|
||||
if (supported.length === 0) return;
|
||||
|
||||
const newFiles: UploadedFile[] = await Promise.all(
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useFileUpload] Failed to convert:', err);
|
||||
}
|
||||
const filePath = getPathForFile(file);
|
||||
return { id, filename, dataUrl, base64Data, mediaType, filePath };
|
||||
}),
|
||||
);
|
||||
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setFiles([]);
|
||||
}, []);
|
||||
|
||||
return { files, addFiles, removeFile, clearFiles };
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* useImageUpload - Handle image paste/drop with base64 conversion
|
||||
*
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface UploadedImage {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:image/...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png"
|
||||
}
|
||||
|
||||
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.split(',')[1] || '';
|
||||
resolve({ dataUrl, base64 });
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function useImageUpload() {
|
||||
const [images, setImages] = useState<UploadedImage[]>([]);
|
||||
|
||||
const addImages = useCallback(async (files: File[]) => {
|
||||
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
const newImages: UploadedImage[] = await Promise.all(
|
||||
imageFiles.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `screenshot-${Date.now()}.png`;
|
||||
const mediaType = file.type || 'image/png';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useImageUpload] Failed to convert:', err);
|
||||
}
|
||||
return { id, filename, dataUrl, base64Data, mediaType };
|
||||
}),
|
||||
);
|
||||
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((id: string) => {
|
||||
setImages((prev) => prev.filter((i) => i.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearImages = useCallback(() => {
|
||||
setImages([]);
|
||||
}, []);
|
||||
|
||||
return { images, addImages, removeImage, clearImages };
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
@@ -63,6 +64,7 @@ const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
@@ -231,6 +233,10 @@ export const useSettingsState = () => {
|
||||
if (stored === 'false' || stored === 'disabled') return false;
|
||||
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
|
||||
});
|
||||
const [sftpAutoOpenSidebar, setSftpAutoOpenSidebar] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
|
||||
});
|
||||
|
||||
// Editor Settings
|
||||
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
|
||||
@@ -393,6 +399,8 @@ export const useSettingsState = () => {
|
||||
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
|
||||
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
@@ -529,6 +537,9 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -694,6 +705,13 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-open sidebar setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoOpenSidebar) {
|
||||
setSftpAutoOpenSidebar(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
@@ -712,7 +730,7 @@ export const useSettingsState = () => {
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -797,6 +815,12 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
|
||||
}, [sftpUseCompressedUpload, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-open sidebar setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
|
||||
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
|
||||
|
||||
// Persist Session Logs settings
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
|
||||
@@ -1019,6 +1043,8 @@ export const useSettingsState = () => {
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
setSftpAutoOpenSidebar,
|
||||
// Editor Settings
|
||||
editorWordWrap,
|
||||
setEditorWordWrap: useCallback((enabled: boolean) => {
|
||||
@@ -1052,7 +1078,7 @@ export const useSettingsState = () => {
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
customThemes,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ export const useTerminalBackend = () => {
|
||||
return bridge.onSessionData(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number }) => void) => {
|
||||
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSessionExit) throw new Error("onSessionExit unavailable");
|
||||
return bridge.onSessionExit(sessionId, cb);
|
||||
|
||||
@@ -20,7 +20,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '../lib/utils';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useImageUpload } from '../application/state/useImageUpload';
|
||||
import { useFileUpload } from '../application/state/useFileUpload';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
@@ -42,6 +42,7 @@ import ConversationExport from './ai/ConversationExport';
|
||||
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
|
||||
import { useToolApproval } from './ai/hooks/useToolApproval';
|
||||
import { useConversationExport } from './ai/hooks/useConversationExport';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Props
|
||||
@@ -55,6 +56,7 @@ interface AIChatSidePanelProps {
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
@@ -102,6 +104,11 @@ interface AIChatSidePanelProps {
|
||||
username?: string;
|
||||
connected: boolean;
|
||||
}>;
|
||||
resolveExecutorContext?: (scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => ExecutorContext;
|
||||
|
||||
// Visibility
|
||||
isVisible?: boolean;
|
||||
@@ -115,6 +122,35 @@ function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
|
||||
return messages.flatMap((message) => {
|
||||
if (message.role === 'system') return [];
|
||||
|
||||
if (message.role === 'user') {
|
||||
return message.content ? [{ role: 'user' as const, content: message.content }] : [];
|
||||
}
|
||||
|
||||
if (message.role === 'assistant') {
|
||||
const parts: string[] = [];
|
||||
if (message.content) parts.push(message.content);
|
||||
if (message.toolCalls?.length) {
|
||||
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
|
||||
}
|
||||
if (!parts.length) return [];
|
||||
return [{ role: 'assistant' as const, content: parts.join('\n\n') }];
|
||||
}
|
||||
|
||||
if (message.role === 'tool' && message.toolResults?.length) {
|
||||
return message.toolResults.map((tr) => ({
|
||||
role: 'assistant' as const,
|
||||
content: `Tool result:\n${tr.content}`,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
@@ -126,6 +162,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
createSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
@@ -147,6 +184,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
scopeHostIds,
|
||||
scopeLabel,
|
||||
terminalSessions = [],
|
||||
resolveExecutorContext,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -164,8 +202,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
||||
|
||||
const { images, addImages, removeImage, clearImages } = useImageUpload();
|
||||
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
const terminalSessionsRef = useRef(terminalSessions);
|
||||
terminalSessionsRef.current = terminalSessions;
|
||||
const resolveExecutorContextRef = useRef(resolveExecutorContext);
|
||||
resolveExecutorContextRef.current = resolveExecutorContext;
|
||||
|
||||
// ── Streaming hook ──
|
||||
const {
|
||||
@@ -242,16 +284,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
|
||||
|
||||
// Abort all active streams and clean up on unmount
|
||||
// Preserve active streams across tab switches. The panel is conditionally
|
||||
// mounted per tab, so unmounting here should not cancel in-flight work.
|
||||
useEffect(() => {
|
||||
const controllers = abortControllersRef.current;
|
||||
return () => {
|
||||
controllers.forEach(c => c.abort());
|
||||
controllers.clear();
|
||||
// Clear pending approval (clears timeout too via setPendingApproval)
|
||||
setPendingApproval(null);
|
||||
// no-op: stream lifecycle is managed by explicit stop/delete actions
|
||||
};
|
||||
}, [abortControllersRef, setPendingApproval]);
|
||||
}, []);
|
||||
|
||||
// Agent discovery
|
||||
const {
|
||||
@@ -368,8 +407,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
|
||||
const inputValueRef = useRef(inputValue);
|
||||
inputValueRef.current = inputValue;
|
||||
const imagesRef = useRef(images);
|
||||
imagesRef.current = images;
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
|
||||
/** Auto-title a session from the first user message if untitled. */
|
||||
const autoTitleSession = useCallback((sessionId: string, text: string) => {
|
||||
@@ -379,6 +418,20 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [updateSessionTitle]);
|
||||
|
||||
const buildExecutorContextForScope = useCallback((scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}): ExecutorContext => {
|
||||
const resolved = resolveExecutorContextRef.current?.(scope);
|
||||
if (resolved) return resolved;
|
||||
return {
|
||||
sessions: terminalSessionsRef.current,
|
||||
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
|
||||
workspaceName: scope.type === 'workspace' ? scope.label : undefined,
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
@@ -412,16 +465,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const sessionId = ensureSession();
|
||||
|
||||
// Capture images before clearing
|
||||
const attachedImages = imagesRef.current.map(img => ({ base64Data: img.base64Data, mediaType: img.mediaType, filename: img.filename }));
|
||||
const attachments = filesRef.current.map(f => ({ base64Data: f.base64Data, mediaType: f.mediaType, filename: f.filename, filePath: f.filePath }));
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachedImages.length > 0 ? { images: attachedImages } : {}),
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setInputValue('');
|
||||
clearImages();
|
||||
clearFiles();
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
@@ -444,7 +497,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: currentSession?.externalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
terminalSessions,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
@@ -458,6 +514,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
@@ -468,18 +529,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
setPendingApproval,
|
||||
autoTitleSession,
|
||||
});
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, setInputValue, clearImages,
|
||||
setStreamingForScope, setInputValue, clearFiles,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, setPendingApproval,
|
||||
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope, setPendingApproval,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -492,7 +554,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
updateLastMessage(activeSessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
executionStatus: msg.executionStatus === 'running' ? 'completed' : msg.executionStatus,
|
||||
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
|
||||
}));
|
||||
// Also clear any pending approval (clears timeout too via setPendingApproval)
|
||||
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
|
||||
@@ -516,8 +578,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const handleDeleteSession = useCallback(
|
||||
(e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
const bridge = getNetcattyBridge();
|
||||
void bridge?.aiAcpCleanup?.(sessionId).catch(() => {});
|
||||
deleteSession(sessionId, scopeKey);
|
||||
// Active session clearing is handled by deleteSession with scopeKey
|
||||
},
|
||||
@@ -594,19 +654,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
})}
|
||||
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
|
||||
terminalSessions,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
webSearchConfig,
|
||||
@@ -655,9 +707,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
modelPresets={agentModelPresets}
|
||||
selectedModelId={selectedAgentModel}
|
||||
onModelSelect={handleAgentModelSelect}
|
||||
images={images}
|
||||
onAddImages={addImages}
|
||||
onRemoveImage={removeImage}
|
||||
files={files}
|
||||
onAddFiles={addFiles}
|
||||
onRemoveFile={removeFile}
|
||||
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
|
||||
permissionMode={globalPermissionMode}
|
||||
onPermissionModeChange={setGlobalPermissionMode}
|
||||
|
||||
@@ -978,6 +978,10 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
const result = await sync.syncToProvider(provider, payload);
|
||||
|
||||
if (result.success) {
|
||||
// Apply merged data if a three-way merge happened
|
||||
if (result.mergedPayload && onApplyPayload) {
|
||||
onApplyPayload(result.mergedPayload);
|
||||
}
|
||||
toast.success(t('cloudSync.sync.success', { provider }));
|
||||
} else if (result.conflictDetected) {
|
||||
// Conflict modal will show automatically
|
||||
|
||||
@@ -61,16 +61,6 @@ interface TreeNodeProps {
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
// Helper function to recursively count all hosts in a node and its children
|
||||
const countAllHostsInNode = (node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
if (node.children) {
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
@@ -100,7 +90,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const isManaged = managedGroupPaths?.has(node.path) ?? false;
|
||||
const hostsCountInNode = useMemo(() => countAllHostsInNode(node), [node]);
|
||||
const hostsCountInNode = node.totalHostCount ?? node.hosts.length;
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
|
||||
@@ -621,7 +621,7 @@ echo $3 >> "$FILE"`);
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="start" alignToParent>
|
||||
<DropdownContent className="w-48" align="start" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
|
||||
@@ -254,25 +254,25 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
const RENDER_LIMIT = 100; // Limit rendered items for performance
|
||||
|
||||
// Define handleScanSystem before useEffect that depends on it
|
||||
const handleScanSystem = useCallback(async () => {
|
||||
const handleScanSystem = useCallback(async (silent = false) => {
|
||||
setIsScanning(true);
|
||||
try {
|
||||
const content = await readKnownHosts();
|
||||
if (content === undefined) {
|
||||
toast.error(
|
||||
if (!silent) toast.error(
|
||||
t("knownHosts.toast.scanUnavailable"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!content) {
|
||||
toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
|
||||
if (!silent) toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseKnownHostsFile(content);
|
||||
if (parsed.length === 0) {
|
||||
toast.info(
|
||||
if (!silent) toast.info(
|
||||
t("knownHosts.toast.scanNoEntries"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
@@ -288,16 +288,16 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
|
||||
if (newHosts.length > 0) {
|
||||
onImportFromFile(newHosts);
|
||||
toast.success(
|
||||
if (!silent) toast.success(
|
||||
t("knownHosts.toast.scanImported", { count: newHosts.length }),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
} else {
|
||||
toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
|
||||
if (!silent) toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Failed to scan system known_hosts:", err);
|
||||
toast.error(
|
||||
if (!silent) toast.error(
|
||||
err instanceof Error ? err.message : t("knownHosts.toast.scanFailed"),
|
||||
t("vault.nav.knownHosts"),
|
||||
);
|
||||
@@ -307,13 +307,12 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
}
|
||||
}, [knownHosts, onRefresh, onImportFromFile, readKnownHosts, t]);
|
||||
|
||||
// Auto-scan on first mount
|
||||
// Auto-scan on first mount (silent — don't show toasts for missing known_hosts)
|
||||
useEffect(() => {
|
||||
if (!hasScannedRef.current) {
|
||||
hasScannedRef.current = true;
|
||||
// Delay scan slightly to not block initial render
|
||||
const timer = setTimeout(() => {
|
||||
handleScanSystem();
|
||||
handleScanSystem(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
@@ -515,7 +514,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 px-3 text-xs"
|
||||
onClick={handleScanSystem}
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -572,7 +571,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleScanSystem}
|
||||
onClick={() => handleScanSystem()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<RefreshCw
|
||||
|
||||
@@ -124,7 +124,7 @@ interface TerminalProps {
|
||||
keyBindings?: KeyBinding[];
|
||||
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
|
||||
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
|
||||
onSessionExit?: (sessionId: string) => void;
|
||||
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
onCloseSession?: (sessionId: string) => void;
|
||||
@@ -152,6 +152,8 @@ interface TerminalProps {
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}
|
||||
|
||||
// Helper function to format network speed (bytes/sec) to human-readable format
|
||||
@@ -209,6 +211,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onBroadcastInput,
|
||||
sessionLog,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
const CONNECTION_TIMEOUT = 120000;
|
||||
@@ -247,8 +250,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Host-level toggle: undefined = inherit global, true/false = explicit override
|
||||
const hostEnabled = host?.keywordHighlightEnabled;
|
||||
|
||||
// If host explicitly disabled highlighting, disable everything for this terminal
|
||||
const effectiveGlobalEnabled = hostEnabled === false ? false : globalEnabled;
|
||||
// Global and host-level highlights are independent:
|
||||
// global toggle controls global rules, host toggle controls host-specific rules
|
||||
const effectiveGlobalEnabled = globalEnabled;
|
||||
const effectiveHostEnabled = hostEnabled ?? false;
|
||||
|
||||
const mergedRules = [
|
||||
@@ -486,6 +490,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onTerminalDataCapture,
|
||||
onOsDetected,
|
||||
onCommandExecuted,
|
||||
sessionLog,
|
||||
});
|
||||
sessionStartersRef.current = sessionStarters;
|
||||
|
||||
@@ -542,7 +547,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled;
|
||||
const effectiveGlobalEnabled = hostEnabled === false ? false : globalEnabled;
|
||||
const effectiveGlobalEnabled = globalEnabled;
|
||||
const effectiveHostEnabled = hostEnabled ?? false;
|
||||
const mergedRules = [
|
||||
...(effectiveGlobalEnabled ? globalRules : []),
|
||||
|
||||
@@ -108,8 +108,13 @@ interface TerminalLayerProps {
|
||||
sftpAutoSync: boolean;
|
||||
sftpShowHiddenFiles: boolean;
|
||||
sftpUseCompressedUpload: boolean;
|
||||
sftpAutoOpenSidebar: boolean;
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (value: boolean) => void;
|
||||
// Session log settings for real-time streaming
|
||||
sessionLogsEnabled?: boolean;
|
||||
sessionLogsDir?: string;
|
||||
sessionLogsFormat?: string;
|
||||
}
|
||||
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
@@ -153,8 +158,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
sftpAutoOpenSidebar,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -167,13 +176,68 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onCloseSession(sessionId);
|
||||
}, [onCloseSession]);
|
||||
|
||||
const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar);
|
||||
sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar;
|
||||
|
||||
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
onUpdateSessionStatus(sessionId, status);
|
||||
|
||||
// Auto-open SFTP sidebar when a remote host connects (if setting enabled)
|
||||
if (status === 'connected' && sftpAutoOpenSidebarRef.current) {
|
||||
const session = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!session) return;
|
||||
// Only auto-open for SSH/Mosh (SFTP requires SSH); skip local/unset protocol
|
||||
const proto = session.protocol;
|
||||
if (proto !== 'ssh' && proto !== 'mosh') return;
|
||||
|
||||
const host = hostsRef.current.find(h => h.id === session.hostId);
|
||||
|
||||
// Determine the tab ID (workspace or solo session)
|
||||
const tabId = session.workspaceId || sessionId;
|
||||
|
||||
// Only open if the sidebar is not already open for this tab
|
||||
if (sidePanelOpenTabsRef.current.has(tabId)) return;
|
||||
|
||||
const hostWithOverrides: Host = host
|
||||
? {
|
||||
...host,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
port: session.port ?? host.port,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
}
|
||||
: {
|
||||
// Quick Connect / temporary session — build minimal host from session data
|
||||
id: session.hostId || sessionId,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
port: session.port ?? 22,
|
||||
protocol: proto,
|
||||
label: session.label || session.hostname,
|
||||
} as Host;
|
||||
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, 'sftp');
|
||||
return next;
|
||||
});
|
||||
setSftpHostForTab(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(tabId, hostWithOverrides);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [onUpdateSessionStatus]);
|
||||
|
||||
const handleSessionExit = useCallback((sessionId: string) => {
|
||||
onUpdateSessionStatus(sessionId, 'disconnected');
|
||||
}, [onUpdateSessionStatus]);
|
||||
const handleSessionExit = useCallback((sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => {
|
||||
// Auto-close the tab/session when the user actively exited (e.g. typed `exit`)
|
||||
// reason === "exited" means the remote process/shell exited normally (stream-level close),
|
||||
// as opposed to network errors, timeouts, or connection-level drops
|
||||
if (evt.reason === "exited") {
|
||||
onCloseSession(sessionId);
|
||||
} else {
|
||||
onUpdateSessionStatus(sessionId, 'disconnected');
|
||||
}
|
||||
}, [onUpdateSessionStatus, onCloseSession]);
|
||||
|
||||
const handleOsDetected = useCallback((hostId: string, distro: string) => {
|
||||
onUpdateHostDistro(hostId, distro);
|
||||
@@ -237,6 +301,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
activeTabIdRef.current = activeTabId;
|
||||
const activeWorkspaceRef = useRef(activeWorkspace);
|
||||
activeWorkspaceRef.current = activeWorkspace;
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
const workspacesRef = useRef(workspaces);
|
||||
workspacesRef.current = workspaces;
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
|
||||
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
|
||||
|
||||
@@ -929,15 +999,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const aiState = useAIState();
|
||||
const { cleanupOrphanedSessions } = aiState;
|
||||
|
||||
// On mount: clean up orphaned AI sessions after a short delay
|
||||
// (allows sessions/workspaces to fully initialize)
|
||||
const hasCleanedUpRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (hasCleanedUpRef.current) return;
|
||||
// Guard: wait until both sessions AND workspaces have loaded to avoid
|
||||
// racing with partial state (e.g. sessions loaded but workspaces not yet).
|
||||
if (sessions.length === 0 || workspaces.length === 0) return;
|
||||
hasCleanedUpRef.current = true;
|
||||
const activeIds = new Set<string>();
|
||||
for (const s of sessions) activeIds.add(s.id);
|
||||
for (const w of workspaces) activeIds.add(w.id);
|
||||
@@ -966,6 +1028,44 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return result;
|
||||
}, [sessions, hosts, activeWorkspace, activeSession]);
|
||||
|
||||
const resolveAIExecutorContext = useCallback((scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => {
|
||||
const latestWorkspaces = workspacesRef.current;
|
||||
const latestSessions = sessionsRef.current;
|
||||
const latestHosts = hostsRef.current;
|
||||
const sessionIds = scope.type === 'workspace'
|
||||
? (() => {
|
||||
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
|
||||
return workspace?.root ? collectSessionIds(workspace.root) : [];
|
||||
})()
|
||||
: scope.targetId ? [scope.targetId] : [];
|
||||
|
||||
const workspaceName = scope.type === 'workspace'
|
||||
? latestWorkspaces.find((w) => w.id === scope.targetId)?.title ?? scope.label
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
sessions: sessionIds.map((sid) => {
|
||||
const session = latestSessions.find((s) => s.id === sid);
|
||||
const host = session?.hostId ? latestHosts.find((h) => h.id === session.hostId) : undefined;
|
||||
return {
|
||||
sessionId: sid,
|
||||
hostId: session?.hostId || '',
|
||||
hostname: host?.hostname || '',
|
||||
label: host?.label || session?.hostLabel || '',
|
||||
os: host?.os,
|
||||
username: host?.username,
|
||||
connected: session?.status === 'connected',
|
||||
};
|
||||
}),
|
||||
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
|
||||
workspaceName,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
@@ -1333,6 +1433,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
@@ -1358,8 +1459,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}).filter((id): id is string => !!id)
|
||||
: activeSession?.hostId ? [activeSession.hostId] : []
|
||||
}
|
||||
scopeLabel={activeWorkspace?.name ?? activeSession?.label ?? ''}
|
||||
scopeLabel={activeWorkspace?.title ?? activeSession?.hostLabel ?? ''}
|
||||
terminalSessions={aiTerminalSessions}
|
||||
resolveExecutorContext={resolveAIExecutorContext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -1512,6 +1614,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onToggleComposeBar={inActiveWorkspace ? handleToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
|
||||
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1613,6 +1716,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onHotkeyAction === next.onHotkeyAction &&
|
||||
|
||||
@@ -689,6 +689,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
],
|
||||
);
|
||||
|
||||
const countAllHostsInNode = useCallback((node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
node.totalHostCount = count;
|
||||
return count;
|
||||
}, []);
|
||||
|
||||
const buildGroupTree = useMemo<Record<string, GroupNode>>(() => {
|
||||
const root: Record<string, GroupNode> = {};
|
||||
const insertPath = (path: string, host?: Host) => {
|
||||
@@ -712,8 +721,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
};
|
||||
customGroups.forEach((path) => insertPath(path));
|
||||
hosts.forEach((host) => insertPath(host.group || "General", host));
|
||||
|
||||
Object.values(root).forEach(countAllHostsInNode);
|
||||
|
||||
return root;
|
||||
}, [hosts, customGroups]);
|
||||
}, [hosts, customGroups, countAllHostsInNode]);
|
||||
|
||||
// Generate all possible group paths from the tree (including all intermediate nodes)
|
||||
const allGroupPaths = useMemo(() => {
|
||||
@@ -896,19 +908,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
insertPath(host.group, host);
|
||||
}
|
||||
});
|
||||
return root;
|
||||
}, [treeViewHosts, customGroups]);
|
||||
|
||||
// Helper function to recursively count all hosts in a node and its children
|
||||
const countAllHostsInNode = (node: GroupNode): number => {
|
||||
let count = node.hosts.length;
|
||||
if (node.children) {
|
||||
Object.values(node.children).forEach((child) => {
|
||||
count += countAllHostsInNode(child);
|
||||
});
|
||||
}
|
||||
return count;
|
||||
};
|
||||
Object.values(root).forEach(countAllHostsInNode);
|
||||
|
||||
return root;
|
||||
}, [treeViewHosts, customGroups, countAllHostsInNode]);
|
||||
|
||||
// Create tree view specific group tree that excludes ungrouped hosts
|
||||
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
|
||||
@@ -1749,7 +1753,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: countAllHostsInNode(node) })}
|
||||
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,7 +26,7 @@ export const MessageContent = ({ children, className, ...props }: MessageContent
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-4 group-[.is-user]:py-2.5',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
|
||||
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle, Slash } from 'lucide-react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -9,13 +9,16 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
isLoading?: boolean;
|
||||
isInterrupted?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCall = ({ name, args, result, isError, isLoading, className, ...props }: ToolCallProps) => {
|
||||
export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted, className, ...props }: ToolCallProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const statusIcon = isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
) : isInterrupted ? (
|
||||
<Slash size={12} className="text-muted-foreground/55" />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className="text-red-400/70" />
|
||||
) : result !== undefined ? (
|
||||
@@ -58,6 +61,14 @@ export const ToolCall = ({ name, args, result, isError, isLoading, className, ..
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{isInterrupted && result === undefined && (
|
||||
<div className="px-3 py-2 border-t border-border/20">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Status</div>
|
||||
<div className="text-[11px] text-muted-foreground/50">
|
||||
Interrupted
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { UploadedImage } from '../../application/state/useImageUpload';
|
||||
import type { UploadedFile } from '../../application/state/useFileUpload';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
@@ -40,12 +40,12 @@ interface ChatInputProps {
|
||||
selectedModelId?: string;
|
||||
/** Callback when user selects a model */
|
||||
onModelSelect?: (modelId: string) => void;
|
||||
/** Attached images */
|
||||
images?: UploadedImage[];
|
||||
/** Callback to add images (paste/drop) */
|
||||
onAddImages?: (files: File[]) => void;
|
||||
/** Callback to remove an image */
|
||||
onRemoveImage?: (id: string) => void;
|
||||
/** Attached files (images, PDFs, etc.) */
|
||||
files?: UploadedFile[];
|
||||
/** Callback to add files (paste/drop) */
|
||||
onAddFiles?: (files: File[]) => void;
|
||||
/** Callback to remove a file */
|
||||
onRemoveFile?: (id: string) => void;
|
||||
/** Available hosts for @ mention */
|
||||
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
|
||||
/** Permission mode (only shown for Catty Agent) */
|
||||
@@ -68,9 +68,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
modelPresets = [],
|
||||
selectedModelId,
|
||||
onModelSelect,
|
||||
images = [],
|
||||
onAddImages,
|
||||
onRemoveImage,
|
||||
files = [],
|
||||
onAddFiles,
|
||||
onRemoveFile,
|
||||
hosts = [],
|
||||
permissionMode,
|
||||
onPermissionModeChange,
|
||||
@@ -134,23 +134,22 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
}, [value, onChange, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const files = Array.from(e.clipboardData.items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean) as File[];
|
||||
if (files.length > 0) {
|
||||
if (pastedFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
onAddImages?.(files);
|
||||
onAddFiles?.(pastedFiles);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
}, [onAddFiles]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (files.length > 0) {
|
||||
onAddImages?.(files);
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
if (droppedFiles.length > 0) {
|
||||
onAddFiles?.(droppedFiles);
|
||||
}
|
||||
}, [onAddImages]);
|
||||
}, [onAddFiles]);
|
||||
|
||||
const defaultPlaceholder = agentName
|
||||
? t('ai.chat.placeholder').replace('{agent}', agentName)
|
||||
@@ -183,19 +182,23 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
return (
|
||||
<div className="shrink-0 px-4 pb-4">
|
||||
<PromptInput onSubmit={handleSubmit}>
|
||||
{/* Image attachment chips */}
|
||||
{images.length > 0 && (
|
||||
{/* File attachment chips */}
|
||||
{files.length > 0 && (
|
||||
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
|
||||
{images.map((img) => (
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={img.id}
|
||||
key={file.id}
|
||||
className="inline-flex items-center gap-1 h-6 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group"
|
||||
>
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[80px]">{img.filename}</span>
|
||||
{file.mediaType.startsWith('image/') ? (
|
||||
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
) : (
|
||||
<FileText size={11} className="text-muted-foreground/60 shrink-0" />
|
||||
)}
|
||||
<span className="truncate max-w-[80px]">{file.filename}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveImage?.(img.id)}
|
||||
onClick={() => onRemoveFile?.(file.id)}
|
||||
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer"
|
||||
>
|
||||
<X size={8} />
|
||||
@@ -213,7 +216,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.length) {
|
||||
onAddImages?.(Array.from(e.target.files));
|
||||
onAddFiles?.(Array.from(e.target.files));
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { AlertCircle, FileText } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatMessage } from '../../infrastructure/ai/types';
|
||||
@@ -30,6 +30,21 @@ interface ChatMessageListProps {
|
||||
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
|
||||
const { t } = useI18n();
|
||||
const visibleMessages = messages.filter(m => m.role !== 'system');
|
||||
const resolvedToolCallIds = new Set(
|
||||
visibleMessages
|
||||
.filter((m) => m.role === 'tool')
|
||||
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
|
||||
);
|
||||
|
||||
// Build a map from toolCallId → toolName for display
|
||||
const toolCallNames = new Map<string, string>();
|
||||
for (const m of visibleMessages) {
|
||||
if (m.role === 'assistant' && m.toolCalls) {
|
||||
for (const tc of m.toolCalls) {
|
||||
toolCallNames.set(tc.id, tc.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleMessages.length === 0 && !isStreaming) {
|
||||
return (
|
||||
@@ -53,7 +68,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
{message.toolResults?.map((tr) => (
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={tr.toolCallId}
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
@@ -78,16 +93,26 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User images */}
|
||||
{isUser && message.images && message.images.length > 0 && (
|
||||
{/* User attachments (images, files) — fallback to legacy `images` field */}
|
||||
{isUser && (message.attachments ?? message.images)?.length && (
|
||||
<div className="flex gap-1.5 flex-wrap mb-1">
|
||||
{message.images.map((img, i) => (
|
||||
<img
|
||||
key={img.filename ? `${img.filename}-${i}` : `img-${message.id}-${i}`}
|
||||
src={`data:${img.mediaType};base64,${img.base64Data}`}
|
||||
alt={img.filename || 'image'}
|
||||
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
|
||||
/>
|
||||
{(message.attachments ?? message.images)!.map((att, i) => (
|
||||
att.mediaType.startsWith('image/') ? (
|
||||
<img
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
src={`data:${att.mediaType};base64,${att.base64Data}`}
|
||||
alt={att.filename || 'image'}
|
||||
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
|
||||
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
|
||||
>
|
||||
<FileText size={12} className="text-muted-foreground/60 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">{att.filename || 'file'}</span>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -107,6 +132,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
name={tc.name}
|
||||
args={tc.arguments}
|
||||
isLoading={isThisStreaming && message.executionStatus === 'running'}
|
||||
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -133,7 +159,9 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-destructive font-medium">{message.errorInfo.message}</p>
|
||||
<p className="text-destructive font-medium whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||
{message.errorInfo.message}
|
||||
</p>
|
||||
{message.errorInfo.retryable && (
|
||||
<p className="text-muted-foreground text-xs mt-1">{t('ai.chat.retryHint')}</p>
|
||||
)}
|
||||
|
||||
@@ -10,12 +10,13 @@
|
||||
* - Error reporting
|
||||
*/
|
||||
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { streamText, stepCountIs, type ModelMessage } from 'ai';
|
||||
import type {
|
||||
AIPermissionMode,
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
ExternalAgentConfig,
|
||||
ProviderConfig,
|
||||
WebSearchConfig,
|
||||
@@ -24,10 +25,10 @@ import { isWebSearchReady } from '../../../infrastructure/ai/types';
|
||||
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
|
||||
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
|
||||
import type { NetcattyBridge } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
|
||||
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
|
||||
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
|
||||
import { classifyError, sanitizeErrorMessage } from '../../../infrastructure/ai/errorClassifier';
|
||||
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
|
||||
@@ -135,12 +136,29 @@ export interface PendingApprovalContext {
|
||||
model: ReturnType<typeof createModelFromConfig>;
|
||||
systemPrompt: string;
|
||||
tools: ReturnType<typeof createCattyTools>;
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeLabel?: string;
|
||||
getExecutorContext: () => ExecutorContext;
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
const sharedStreamingSessionIds = new Set<string>();
|
||||
const sharedAbortControllers = new Map<string, AbortController>();
|
||||
const streamingSubscribers = new Set<() => void>();
|
||||
|
||||
function emitStreamingStoreChange(): void {
|
||||
streamingSubscribers.forEach(listener => {
|
||||
try {
|
||||
listener();
|
||||
} catch (err) {
|
||||
console.error('[AIChatStreaming] Failed to notify streaming subscriber:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
@@ -182,6 +200,7 @@ export interface UseAIChatStreamingReturn {
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => Promise<void>;
|
||||
/** Send a message to an external agent (ACP or raw process). */
|
||||
sendToExternalAgent: (
|
||||
@@ -207,12 +226,16 @@ export interface SendToCattyContext {
|
||||
commandBlocklist?: string[];
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
getExecutorContext?: () => ExecutorContext;
|
||||
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
|
||||
autoTitleSession: (sessionId: string, text: string) => void;
|
||||
}
|
||||
|
||||
/** Context values needed by sendToExternalAgent that change frequently. */
|
||||
export interface SendToExternalContext {
|
||||
existingSessionId?: string;
|
||||
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
providers: ProviderConfig[];
|
||||
selectedAgentModel?: string;
|
||||
@@ -229,17 +252,34 @@ export function useAIChatStreaming({
|
||||
updateMessageById,
|
||||
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
|
||||
// Per-session streaming state (keyed by sessionId)
|
||||
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(new Set());
|
||||
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
|
||||
() => new Set(sharedStreamingSessionIds),
|
||||
);
|
||||
useEffect(() => {
|
||||
const syncFromStore = () => {
|
||||
setStreamingSessions(new Set(sharedStreamingSessionIds));
|
||||
};
|
||||
streamingSubscribers.add(syncFromStore);
|
||||
syncFromStore();
|
||||
return () => {
|
||||
streamingSubscribers.delete(syncFromStore);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setStreamingForScope = useCallback((key: string, val: boolean) => {
|
||||
setStreamingSessions(prev => {
|
||||
const next = new Set(prev);
|
||||
if (val) next.add(key); else next.delete(key);
|
||||
return next;
|
||||
});
|
||||
const hadKey = sharedStreamingSessionIds.has(key);
|
||||
if (val) {
|
||||
sharedStreamingSessionIds.add(key);
|
||||
} else {
|
||||
sharedStreamingSessionIds.delete(key);
|
||||
}
|
||||
if (hadKey !== val) {
|
||||
emitStreamingStoreChange();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Per-scope abort controllers
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(sharedAbortControllers);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// reportStreamError
|
||||
@@ -259,8 +299,6 @@ export function useAIChatStreaming({
|
||||
// Log the full unsanitized error for debugging
|
||||
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
|
||||
const errorInfo = classifyError(errorStr);
|
||||
// Sanitize the displayed message to avoid leaking paths, keys, or other sensitive info
|
||||
errorInfo.message = sanitizeErrorMessage(errorInfo.message);
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
statusText: '',
|
||||
@@ -556,20 +594,25 @@ export function useAIChatStreaming({
|
||||
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
|
||||
}));
|
||||
},
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>) => {
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({
|
||||
...msg,
|
||||
toolCalls: [...(msg.toolCalls || []), { id: `tc_${Date.now()}`, name: toolName, arguments: args }],
|
||||
toolCalls: [...(msg.toolCalls || []), { id: toolCallId || `tc_${Date.now()}`, name: toolName, arguments: args }],
|
||||
executionStatus: 'running',
|
||||
statusText: undefined,
|
||||
}));
|
||||
},
|
||||
onToolResult: (toolCallId: string, result: string) => {
|
||||
updateLastMessage(sessionId, msg =>
|
||||
msg.role === 'assistant' && msg.executionStatus === 'running'
|
||||
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
|
||||
);
|
||||
onToolResult: (toolCallId: string, result: string, toolName?: string) => {
|
||||
updateLastMessage(sessionId, msg => {
|
||||
if (msg.role !== 'assistant' || msg.executionStatus !== 'running') return msg;
|
||||
// Only patch tool call name if the existing name is missing/generic
|
||||
// (don't overwrite a good name from onToolCall with a wrapper name from tool-result)
|
||||
const updatedToolCalls = toolName && !toolName.includes('acp_provider_agent_dynamic_tool') && msg.toolCalls
|
||||
? msg.toolCalls.map(tc => tc.id === toolCallId && !tc.name ? { ...tc, name: toolName } : tc)
|
||||
: msg.toolCalls;
|
||||
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
|
||||
});
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'tool', content: '',
|
||||
toolResults: [{ toolCallId, content: result, isError: false }],
|
||||
@@ -581,6 +624,9 @@ export function useAIChatStreaming({
|
||||
maybeCreateAssistantMsg();
|
||||
updateLastMessage(sessionId, msg => ({ ...msg, statusText: message }));
|
||||
},
|
||||
onSessionId: (externalSessionId: string) => {
|
||||
context.updateExternalSessionId?.(sessionId, externalSessionId);
|
||||
},
|
||||
onError: (error: string) => {
|
||||
reportStreamError(sessionId, abortController.signal, error);
|
||||
setStreamingForScope(sessionId, false);
|
||||
@@ -590,6 +636,8 @@ export function useAIChatStreaming({
|
||||
abortController.signal,
|
||||
agentProviderId,
|
||||
context.selectedAgentModel,
|
||||
context.existingSessionId,
|
||||
context.historyMessages,
|
||||
attachedImages.length > 0 ? attachedImages : undefined,
|
||||
);
|
||||
} else {
|
||||
@@ -627,13 +675,21 @@ export function useAIChatStreaming({
|
||||
currentSession: AISession | undefined,
|
||||
assistantMsgId: string,
|
||||
context: SendToCattyContext,
|
||||
attachments?: ChatMessageAttachment[],
|
||||
) => {
|
||||
const bridge = getNetcattyBridge();
|
||||
const tools = createCattyTools(bridge, {
|
||||
const getExecutorContext = context.getExecutorContext ?? (() => ({
|
||||
sessions: context.terminalSessions,
|
||||
workspaceId: context.scopeTargetId,
|
||||
workspaceName: context.scopeLabel,
|
||||
}, context.commandBlocklist, context.globalPermissionMode, context.webSearchConfig ?? undefined);
|
||||
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
|
||||
workspaceName: context.scopeType === 'workspace' ? context.scopeLabel : undefined,
|
||||
}));
|
||||
const tools = createCattyTools(
|
||||
bridge,
|
||||
getExecutorContext,
|
||||
context.commandBlocklist,
|
||||
context.globalPermissionMode,
|
||||
context.webSearchConfig ?? undefined,
|
||||
);
|
||||
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
|
||||
@@ -693,7 +749,22 @@ export function useAIChatStreaming({
|
||||
const sdkMessages: Array<ModelMessage> = [];
|
||||
for (const m of allMessages) {
|
||||
if (m.role === 'user') {
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
// Build multimodal content when attachments are present (fallback to legacy `images` field)
|
||||
const messageAttachments = m.attachments ?? m.images;
|
||||
if (messageAttachments?.length) {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
|
||||
parts.push({ type: 'text', text: m.content });
|
||||
for (const att of messageAttachments) {
|
||||
if (att.mediaType.startsWith('image/')) {
|
||||
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
|
||||
} else {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
} else {
|
||||
sdkMessages.push({ role: 'user', content: m.content });
|
||||
}
|
||||
} else if (m.role === 'assistant') {
|
||||
if (m.toolCalls?.length) {
|
||||
// Only include tool calls that have matching results
|
||||
@@ -732,13 +803,36 @@ export function useAIChatStreaming({
|
||||
});
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
// Build the current user message — include attachments as multimodal content
|
||||
if (attachments?.length) {
|
||||
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
|
||||
parts.push({ type: 'text', text: trimmed });
|
||||
for (const att of attachments) {
|
||||
if (att.mediaType.startsWith('image/')) {
|
||||
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
|
||||
} else {
|
||||
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
|
||||
}
|
||||
}
|
||||
sdkMessages.push({ role: 'user', content: parts });
|
||||
} else {
|
||||
sdkMessages.push({ role: 'user', content: trimmed });
|
||||
}
|
||||
|
||||
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
|
||||
|
||||
if (approvalInfo) {
|
||||
context.setPendingApproval({
|
||||
sessionId, scopeKey: sendScopeKey, sdkMessages, approvalInfo, model, systemPrompt, tools,
|
||||
sessionId,
|
||||
scopeKey: sendScopeKey,
|
||||
sdkMessages,
|
||||
approvalInfo,
|
||||
model,
|
||||
systemPrompt,
|
||||
tools,
|
||||
scopeType: context.scopeType,
|
||||
scopeLabel: context.scopeLabel,
|
||||
getExecutorContext,
|
||||
});
|
||||
return; // Keep streaming flag — waiting for user approval
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import { classifyError } from '../../../infrastructure/ai/errorClassifier';
|
||||
import type {
|
||||
ApprovalInfo,
|
||||
PendingApprovalContext,
|
||||
TerminalSessionInfo,
|
||||
} from './useAIChatStreaming';
|
||||
import { getNetcattyBridge } from './useAIChatStreaming';
|
||||
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
|
||||
@@ -31,6 +30,9 @@ function generateId(): string {
|
||||
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
let sharedPendingApprovalContext: PendingApprovalContext | null = null;
|
||||
let sharedPendingApprovalTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Hook parameters
|
||||
// -------------------------------------------------------------------
|
||||
@@ -72,10 +74,6 @@ export interface UseToolApprovalReturn {
|
||||
|
||||
/** Context values needed by handleApprovalResponse that change frequently. */
|
||||
export interface ToolApprovalContext {
|
||||
terminalSessions: TerminalSessionInfo[];
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeLabel?: string;
|
||||
globalPermissionMode: AIPermissionMode;
|
||||
commandBlocklist?: string[];
|
||||
webSearchConfig?: WebSearchConfig | null;
|
||||
@@ -95,23 +93,23 @@ export function useToolApproval({
|
||||
t,
|
||||
}: UseToolApprovalParams): UseToolApprovalReturn {
|
||||
// Pending approval context — stores SDK state needed to resume after user approves/rejects
|
||||
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(null);
|
||||
|
||||
// Timeout ID for auto-clearing stale pending approval (Issue #14)
|
||||
const pendingApprovalTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(sharedPendingApprovalContext);
|
||||
pendingApprovalContextRef.current = sharedPendingApprovalContext;
|
||||
|
||||
/** Set pending approval context with a 5-minute auto-clear timeout. */
|
||||
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
|
||||
// Clear any existing timeout
|
||||
if (pendingApprovalTimeoutRef.current) {
|
||||
clearTimeout(pendingApprovalTimeoutRef.current);
|
||||
pendingApprovalTimeoutRef.current = null;
|
||||
if (sharedPendingApprovalTimeout) {
|
||||
clearTimeout(sharedPendingApprovalTimeout);
|
||||
sharedPendingApprovalTimeout = null;
|
||||
}
|
||||
sharedPendingApprovalContext = ctx;
|
||||
pendingApprovalContextRef.current = ctx;
|
||||
if (ctx) {
|
||||
pendingApprovalTimeoutRef.current = setTimeout(() => {
|
||||
sharedPendingApprovalTimeout = setTimeout(() => {
|
||||
// Auto-clear after 5 minutes if user never responds
|
||||
if (pendingApprovalContextRef.current?.sessionId === ctx.sessionId) {
|
||||
if (sharedPendingApprovalContext?.sessionId === ctx.sessionId) {
|
||||
sharedPendingApprovalContext = null;
|
||||
pendingApprovalContextRef.current = null;
|
||||
setStreamingForScope(ctx.sessionId, false);
|
||||
abortControllersRef.current.get(ctx.sessionId)?.abort();
|
||||
@@ -129,7 +127,7 @@ export function useToolApproval({
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
pendingApprovalTimeoutRef.current = null;
|
||||
sharedPendingApprovalTimeout = null;
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
}
|
||||
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
|
||||
@@ -143,7 +141,16 @@ export function useToolApproval({
|
||||
const ctx = pendingApprovalContextRef.current;
|
||||
if (!ctx) return;
|
||||
// Destructure all needed values BEFORE clearing the ref to avoid race conditions
|
||||
const { sessionId: sid, scopeKey: sk, sdkMessages, approvalInfo, model: ctxModel } = ctx;
|
||||
const {
|
||||
sessionId: sid,
|
||||
scopeKey: sk,
|
||||
sdkMessages,
|
||||
approvalInfo,
|
||||
model: ctxModel,
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
getExecutorContext,
|
||||
} = ctx;
|
||||
// Clear pending approval (and its timeout) via setPendingApproval
|
||||
setPendingApproval(null);
|
||||
|
||||
@@ -215,16 +222,20 @@ export function useToolApproval({
|
||||
|
||||
try {
|
||||
// Rebuild tools and system prompt with the latest permission mode to prevent
|
||||
// stale closure issues (e.g. user changed permission mode during approval wait)
|
||||
// stale settings, while keeping the original AI scope pinned to its workspace/session.
|
||||
const bridge = getNetcattyBridge();
|
||||
const freshTools = createCattyTools(bridge, {
|
||||
sessions: approvalContext.terminalSessions,
|
||||
workspaceId: approvalContext.scopeTargetId,
|
||||
workspaceName: approvalContext.scopeLabel,
|
||||
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode, approvalContext.webSearchConfig ?? undefined);
|
||||
const freshExecutorContext = getExecutorContext();
|
||||
const freshTools = createCattyTools(
|
||||
bridge,
|
||||
getExecutorContext,
|
||||
approvalContext.commandBlocklist,
|
||||
approvalContext.globalPermissionMode,
|
||||
approvalContext.webSearchConfig ?? undefined,
|
||||
);
|
||||
const freshSystemPrompt = buildSystemPrompt({
|
||||
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
|
||||
hosts: approvalContext.terminalSessions.map(s => ({
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
hosts: freshExecutorContext.sessions.map(s => ({
|
||||
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
|
||||
os: s.os, username: s.username, connected: s.connected,
|
||||
})),
|
||||
@@ -243,6 +254,9 @@ export function useToolApproval({
|
||||
model: ctxModel,
|
||||
systemPrompt: freshSystemPrompt,
|
||||
tools: freshTools,
|
||||
scopeType,
|
||||
scopeLabel,
|
||||
getExecutorContext,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const getOpenerLabel = (
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload } = useSettingsState();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
|
||||
@@ -253,6 +253,46 @@ export default function SettingsFileAssociationsTab() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auto-open sidebar section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.autoOpenSidebar')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoOpenSidebar.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpAutoOpenSidebar(!sftpAutoOpenSidebar)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpAutoOpenSidebar
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpAutoOpenSidebar
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpAutoOpenSidebar && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.autoOpenSidebar.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.autoOpenSidebar.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File associations section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
|
||||
|
||||
@@ -41,7 +41,7 @@ type TerminalBackendApi = {
|
||||
onSessionData: (sessionId: string, cb: (data: string) => void) => () => void;
|
||||
onSessionExit: (
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number }) => void,
|
||||
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
|
||||
) => () => void;
|
||||
onChainProgress: (
|
||||
cb: (hop: number, total: number, label: string, status: string) => void,
|
||||
@@ -64,6 +64,12 @@ type ChainProgressState = {
|
||||
currentHostLabel: string;
|
||||
} | null;
|
||||
|
||||
export type SessionLogConfig = {
|
||||
enabled: boolean;
|
||||
directory: string;
|
||||
format: string;
|
||||
};
|
||||
|
||||
export type TerminalSessionStartersContext = {
|
||||
host: Host;
|
||||
keys: SSHKey[];
|
||||
@@ -76,6 +82,7 @@ export type TerminalSessionStartersContext = {
|
||||
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
|
||||
terminalBackend: TerminalBackendApi;
|
||||
serialConfig?: SerialConfig;
|
||||
sessionLog?: SessionLogConfig;
|
||||
isVisibleRef?: RefObject<boolean>;
|
||||
pendingOutputScrollRef?: RefObject<boolean>;
|
||||
|
||||
@@ -100,7 +107,7 @@ export type TerminalSessionStartersContext = {
|
||||
t?: (key: string) => string;
|
||||
|
||||
onSessionAttached?: (sessionId: string) => void;
|
||||
onSessionExit?: (sessionId: string) => void;
|
||||
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
onCommandExecuted?: (
|
||||
@@ -213,7 +220,7 @@ const attachSessionToTerminal = (
|
||||
}
|
||||
}
|
||||
|
||||
ctx.onSessionExit?.(ctx.sessionId);
|
||||
ctx.onSessionExit?.(ctx.sessionId, evt);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -456,6 +463,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -609,6 +617,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
rows: term.rows,
|
||||
charset: ctx.host.charset,
|
||||
env: telnetEnv,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
|
||||
attachSessionToTerminal(ctx, term, id, {
|
||||
@@ -650,6 +659,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
rows: term.rows,
|
||||
charset: ctx.host.charset,
|
||||
env: moshEnv,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
|
||||
attachSessionToTerminal(ctx, term, id, {
|
||||
@@ -708,6 +718,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
env: {
|
||||
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||||
},
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
|
||||
ctx.sessionRef.current = id;
|
||||
@@ -754,7 +765,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
}
|
||||
|
||||
ctx.onSessionExit?.(ctx.sessionId);
|
||||
ctx.onSessionExit?.(ctx.sessionId, evt);
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
@@ -787,6 +798,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
stopBits: ctx.serialConfig.stopBits,
|
||||
parity: ctx.serialConfig.parity,
|
||||
flowControl: ctx.serialConfig.flowControl,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
});
|
||||
|
||||
// Serial connection is established immediately when session starts
|
||||
|
||||
@@ -169,6 +169,8 @@ export interface GroupNode {
|
||||
path: string;
|
||||
children: Record<string, GroupNode>;
|
||||
hosts: Host[];
|
||||
/** Pre-computed total host count including all descendants. Set during tree construction. */
|
||||
totalHostCount?: number;
|
||||
}
|
||||
|
||||
export interface SyncConfig {
|
||||
|
||||
@@ -31,9 +31,10 @@ export type SyncState =
|
||||
/**
|
||||
* Conflict Resolution Strategy
|
||||
*/
|
||||
export type ConflictResolution =
|
||||
| 'USE_REMOTE' // Download cloud data, overwrite local
|
||||
| 'USE_LOCAL'; // Upload local data, overwrite cloud
|
||||
export type ConflictResolution =
|
||||
| 'USE_REMOTE' // Download cloud data, overwrite local
|
||||
| 'USE_LOCAL' // Upload local data, overwrite cloud
|
||||
| 'AUTO_MERGED'; // Three-way merge was applied automatically
|
||||
|
||||
// ============================================================================
|
||||
// Cloud Provider Types
|
||||
@@ -196,8 +197,9 @@ export interface SyncPayload {
|
||||
sftpAutoSync?: boolean;
|
||||
sftpShowHiddenFiles?: boolean;
|
||||
sftpUseCompressedUpload?: boolean;
|
||||
sftpAutoOpenSidebar?: boolean;
|
||||
};
|
||||
|
||||
|
||||
// Sync metadata
|
||||
syncedAt: number; // When this payload was created
|
||||
}
|
||||
@@ -275,10 +277,12 @@ export interface UnlockedMasterKey {
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
provider: CloudProvider;
|
||||
action: 'upload' | 'download' | 'none';
|
||||
action: 'upload' | 'download' | 'merge' | 'none';
|
||||
version?: number;
|
||||
error?: string;
|
||||
conflictDetected?: boolean;
|
||||
/** Present when action === 'merge'; caller should apply this to update local state */
|
||||
mergedPayload?: import('./sync').SyncPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,7 +316,7 @@ export interface SyncHistoryEntry {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
provider: CloudProvider;
|
||||
action: 'upload' | 'download' | 'conflict_resolved';
|
||||
action: 'upload' | 'download' | 'merge' | 'conflict_resolved';
|
||||
success: boolean;
|
||||
localVersion: number;
|
||||
remoteVersion?: number;
|
||||
@@ -405,6 +409,7 @@ export const SYNC_STORAGE_KEYS = {
|
||||
PROVIDER_S3: 'netcatty_provider_s3_v1',
|
||||
PROVIDER_SMB: 'netcatty_provider_smb_v1',
|
||||
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
|
||||
SYNC_BASE_PAYLOAD: 'netcatty_sync_base_payload_v1',
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
|
||||
432
domain/syncMerge.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Three-Way Merge for Cloud Sync Payloads
|
||||
*
|
||||
* Implements a Git-style three-way merge using a stored "base" snapshot
|
||||
* (the last successfully synced payload) to detect per-entity changes
|
||||
* on both the local and remote sides.
|
||||
*
|
||||
* Algorithm:
|
||||
* For each entity (identified by `id`):
|
||||
* - Only in local → local addition → keep
|
||||
* - Only in remote → remote addition → keep
|
||||
* - In base, removed locally → local deletion → remove (unless remote modified)
|
||||
* - In base, removed remotely → remote deletion → remove (unless local modified)
|
||||
* - Modified only locally → keep local version
|
||||
* - Modified only remotely → keep remote version
|
||||
* - Modified on both sides → prefer local (conflict logged)
|
||||
*
|
||||
* When no base is available (first sync), falls back to a set-union
|
||||
* merge by entity ID, preferring local for duplicates.
|
||||
*/
|
||||
|
||||
import type { SyncPayload } from './sync';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MergeSummary {
|
||||
added: { local: number; remote: number };
|
||||
deleted: { local: number; remote: number };
|
||||
modified: { local: number; remote: number; conflicts: number };
|
||||
}
|
||||
|
||||
export interface MergeResult {
|
||||
payload: SyncPayload;
|
||||
/** True when both sides modified the same entity (resolved by preferring local) */
|
||||
hadConflicts: boolean;
|
||||
summary: MergeSummary;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Deterministic JSON string for content comparison.
|
||||
* Sorts object keys to avoid false diffs from key ordering.
|
||||
*/
|
||||
function fingerprint(value: unknown): string {
|
||||
return JSON.stringify(value, (_key, v) => {
|
||||
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
||||
return Object.keys(v).sort().reduce<Record<string, unknown>>((acc, k) => {
|
||||
acc[k] = (v as Record<string, unknown>)[k];
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entity-array merge (hosts, keys, identities, snippets, etc.)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EntityMergeResult<T> {
|
||||
merged: T[];
|
||||
conflicts: number;
|
||||
added: { local: number; remote: number };
|
||||
deleted: { local: number; remote: number };
|
||||
modified: { local: number; remote: number };
|
||||
}
|
||||
|
||||
function mergeEntityArrays<T extends { id: string }>(
|
||||
base: T[],
|
||||
local: T[],
|
||||
remote: T[],
|
||||
): EntityMergeResult<T> {
|
||||
const baseMap = new Map(base.map((e) => [e.id, e]));
|
||||
const localMap = new Map(local.map((e) => [e.id, e]));
|
||||
const remoteMap = new Map(remote.map((e) => [e.id, e]));
|
||||
|
||||
const allIds = new Set([
|
||||
...baseMap.keys(),
|
||||
...localMap.keys(),
|
||||
...remoteMap.keys(),
|
||||
]);
|
||||
|
||||
const merged: T[] = [];
|
||||
let conflicts = 0;
|
||||
const added = { local: 0, remote: 0 };
|
||||
const deleted = { local: 0, remote: 0 };
|
||||
const modified = { local: 0, remote: 0 };
|
||||
|
||||
for (const id of allIds) {
|
||||
const baseItem = baseMap.get(id);
|
||||
const localItem = localMap.get(id);
|
||||
const remoteItem = remoteMap.get(id);
|
||||
|
||||
const inBase = baseItem !== undefined;
|
||||
const inLocal = localItem !== undefined;
|
||||
const inRemote = remoteItem !== undefined;
|
||||
|
||||
if (!inBase && inLocal && !inRemote) {
|
||||
// Local addition
|
||||
merged.push(localItem);
|
||||
added.local++;
|
||||
} else if (!inBase && !inLocal && inRemote) {
|
||||
// Remote addition
|
||||
merged.push(remoteItem);
|
||||
added.remote++;
|
||||
} else if (!inBase && inLocal && inRemote) {
|
||||
// Both added same ID — prefer local
|
||||
merged.push(localItem);
|
||||
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
|
||||
conflicts++;
|
||||
}
|
||||
} else if (inBase && inLocal && inRemote) {
|
||||
// Exists in all three — compare changes
|
||||
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
|
||||
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
|
||||
|
||||
if (!localChanged && !remoteChanged) {
|
||||
merged.push(baseItem);
|
||||
} else if (localChanged && !remoteChanged) {
|
||||
merged.push(localItem);
|
||||
modified.local++;
|
||||
} else if (!localChanged && remoteChanged) {
|
||||
merged.push(remoteItem);
|
||||
modified.remote++;
|
||||
} else {
|
||||
// Both changed — prefer local
|
||||
merged.push(localItem);
|
||||
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
|
||||
conflicts++;
|
||||
}
|
||||
modified.local++;
|
||||
modified.remote++;
|
||||
}
|
||||
} else if (inBase && !inLocal && inRemote) {
|
||||
// Local deleted
|
||||
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
|
||||
if (remoteChanged) {
|
||||
// Remote modified + local deleted → keep modification (safer)
|
||||
merged.push(remoteItem);
|
||||
conflicts++;
|
||||
} else {
|
||||
deleted.local++;
|
||||
}
|
||||
} else if (inBase && inLocal && !inRemote) {
|
||||
// Remote deleted
|
||||
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
|
||||
if (localChanged) {
|
||||
// Local modified + remote deleted → keep modification (safer)
|
||||
merged.push(localItem);
|
||||
conflicts++;
|
||||
} else {
|
||||
deleted.remote++;
|
||||
}
|
||||
}
|
||||
// inBase && !inLocal && !inRemote → both deleted → gone
|
||||
}
|
||||
|
||||
return { merged, conflicts, added, deleted, modified };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String-array merge (customGroups, snippetPackages)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mergeStringArrays(
|
||||
base: string[],
|
||||
local: string[],
|
||||
remote: string[],
|
||||
): string[] {
|
||||
const baseSet = new Set(base);
|
||||
const localSet = new Set(local);
|
||||
const remoteSet = new Set(remote);
|
||||
|
||||
const result = new Set<string>();
|
||||
|
||||
// Start with base items, then apply additions/deletions
|
||||
const allValues = new Set([...baseSet, ...localSet, ...remoteSet]);
|
||||
|
||||
for (const value of allValues) {
|
||||
const inBase = baseSet.has(value);
|
||||
const inLocal = localSet.has(value);
|
||||
const inRemote = remoteSet.has(value);
|
||||
|
||||
if (!inBase) {
|
||||
// Addition — keep if either side added it
|
||||
if (inLocal || inRemote) result.add(value);
|
||||
} else {
|
||||
// Was in base — keep unless both sides deleted
|
||||
const localDeleted = !inLocal;
|
||||
const remoteDeleted = !inRemote;
|
||||
if (localDeleted && remoteDeleted) {
|
||||
// Both deleted — gone
|
||||
} else if (localDeleted || remoteDeleted) {
|
||||
// Only one side deleted — honour the deletion
|
||||
// (If the other side didn't touch it, it's still in their set from base)
|
||||
} else {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...result];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings merge (flat key-value)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SettingsObj = NonNullable<SyncPayload['settings']>;
|
||||
|
||||
/** Check if an array contains objects with `id` fields (for entity merge). */
|
||||
function isIdArray(arr: unknown[]): boolean {
|
||||
return arr.length > 0 && typeof arr[0] === 'object' && arr[0] !== null && 'id' in arr[0];
|
||||
}
|
||||
|
||||
/** Recursively merge two plain objects against a base using three-way logic. */
|
||||
function mergeSettingsDeep(
|
||||
base: Record<string, unknown>,
|
||||
local: Record<string, unknown>,
|
||||
remote: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const allKeys = new Set([
|
||||
...Object.keys(base),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
]);
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const key of allKeys) {
|
||||
const bVal = base[key];
|
||||
const lVal = local[key];
|
||||
const rVal = remote[key];
|
||||
const lChanged = fingerprint(lVal) !== fingerprint(bVal);
|
||||
const rChanged = fingerprint(rVal) !== fingerprint(bVal);
|
||||
|
||||
if (!lChanged && !rChanged) {
|
||||
if (bVal !== undefined) merged[key] = bVal;
|
||||
} else if (lChanged && !rChanged) {
|
||||
if (lVal !== undefined) merged[key] = lVal;
|
||||
} else if (!lChanged && rChanged) {
|
||||
if (rVal !== undefined) merged[key] = rVal;
|
||||
} else {
|
||||
// Both changed — recurse if both are plain objects, else prefer local
|
||||
if (
|
||||
lVal && rVal &&
|
||||
typeof lVal === 'object' && !Array.isArray(lVal) &&
|
||||
typeof rVal === 'object' && !Array.isArray(rVal)
|
||||
) {
|
||||
merged[key] = mergeSettingsDeep(
|
||||
(bVal && typeof bVal === 'object' && !Array.isArray(bVal) ? bVal : {}) as Record<string, unknown>,
|
||||
lVal as Record<string, unknown>,
|
||||
rVal as Record<string, unknown>,
|
||||
);
|
||||
} else if (lVal !== undefined) {
|
||||
merged[key] = lVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function mergeSettings(
|
||||
base: SettingsObj | undefined,
|
||||
local: SettingsObj | undefined,
|
||||
remote: SettingsObj | undefined,
|
||||
): SettingsObj | undefined {
|
||||
if (!local && !remote) return undefined;
|
||||
if (!local) return remote;
|
||||
if (!remote) return local;
|
||||
|
||||
const b = base ?? {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(b),
|
||||
...Object.keys(local),
|
||||
...Object.keys(remote),
|
||||
]);
|
||||
|
||||
const merged: Record<string, unknown> = {};
|
||||
|
||||
for (const key of allKeys) {
|
||||
const bVal = (b as Record<string, unknown>)[key];
|
||||
const lVal = (local as Record<string, unknown>)[key];
|
||||
const rVal = (remote as Record<string, unknown>)[key];
|
||||
|
||||
const lChanged = fingerprint(lVal) !== fingerprint(bVal);
|
||||
const rChanged = fingerprint(rVal) !== fingerprint(bVal);
|
||||
|
||||
if (!lChanged && !rChanged) {
|
||||
if (bVal !== undefined) merged[key] = bVal;
|
||||
} else if (lChanged && !rChanged) {
|
||||
if (lVal !== undefined) merged[key] = lVal;
|
||||
} else if (!lChanged && rChanged) {
|
||||
if (rVal !== undefined) merged[key] = rVal;
|
||||
} else {
|
||||
// Both changed — deep merge if both are plain objects, else prefer local
|
||||
if (
|
||||
lVal && rVal &&
|
||||
typeof lVal === 'object' && !Array.isArray(lVal) &&
|
||||
typeof rVal === 'object' && !Array.isArray(rVal)
|
||||
) {
|
||||
merged[key] = mergeSettingsDeep(
|
||||
(bVal && typeof bVal === 'object' && !Array.isArray(bVal) ? bVal : {}) as Record<string, unknown>,
|
||||
lVal as Record<string, unknown>,
|
||||
rVal as Record<string, unknown>,
|
||||
);
|
||||
} else if (
|
||||
Array.isArray(lVal) && Array.isArray(rVal) &&
|
||||
(isIdArray(lVal) || isIdArray(rVal) || isIdArray(Array.isArray(bVal) ? bVal as unknown[] : []))
|
||||
) {
|
||||
// Array of objects with `id` (e.g. customTerminalThemes) — entity merge
|
||||
const bArr = Array.isArray(bVal) ? bVal as Array<{ id: string }> : [];
|
||||
const result = mergeEntityArrays(bArr, lVal as Array<{ id: string }>, rVal as Array<{ id: string }>);
|
||||
merged[key] = result.merged;
|
||||
} else if (lVal !== undefined) {
|
||||
merged[key] = lVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main merge function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Three-way merge of sync payloads.
|
||||
*
|
||||
* @param base - The last successfully synced payload (null if unavailable)
|
||||
* @param local - The current device's data
|
||||
* @param remote - The other device's data (downloaded from cloud)
|
||||
*/
|
||||
export function mergeSyncPayloads(
|
||||
base: SyncPayload | null,
|
||||
local: SyncPayload,
|
||||
remote: SyncPayload,
|
||||
): MergeResult {
|
||||
const emptyBase: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
knownHosts: [],
|
||||
portForwardingRules: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
};
|
||||
const b = base ?? emptyBase;
|
||||
|
||||
const summary: MergeSummary = {
|
||||
added: { local: 0, remote: 0 },
|
||||
deleted: { local: 0, remote: 0 },
|
||||
modified: { local: 0, remote: 0, conflicts: 0 },
|
||||
};
|
||||
|
||||
// Merge each entity type
|
||||
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? []);
|
||||
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
|
||||
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
|
||||
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
|
||||
const knownHostsRaw = mergeEntityArrays(b.knownHosts ?? [], local.knownHosts ?? [], remote.knownHosts ?? []);
|
||||
// Deduplicate known hosts by (hostname, port, keyType) since IDs are random per device
|
||||
const knownHostSeen = new Set<string>();
|
||||
const knownHosts = {
|
||||
...knownHostsRaw,
|
||||
merged: knownHostsRaw.merged.filter((kh) => {
|
||||
const entry = kh as unknown as { hostname: string; port: number; keyType: string };
|
||||
const fp = `${entry.hostname}:${entry.port}:${entry.keyType}`;
|
||||
if (knownHostSeen.has(fp)) return false;
|
||||
knownHostSeen.add(fp);
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
const portForwardingRules = mergeEntityArrays(
|
||||
b.portForwardingRules ?? [],
|
||||
local.portForwardingRules ?? [],
|
||||
remote.portForwardingRules ?? [],
|
||||
);
|
||||
|
||||
// Aggregate stats
|
||||
const entityResults = [hosts, keys, identities, snippets, knownHosts, portForwardingRules];
|
||||
for (const r of entityResults) {
|
||||
summary.added.local += r.added.local;
|
||||
summary.added.remote += r.added.remote;
|
||||
summary.deleted.local += r.deleted.local;
|
||||
summary.deleted.remote += r.deleted.remote;
|
||||
summary.modified.local += r.modified.local;
|
||||
summary.modified.remote += r.modified.remote;
|
||||
summary.modified.conflicts += r.conflicts;
|
||||
}
|
||||
|
||||
// Merge string arrays
|
||||
const customGroups = mergeStringArrays(
|
||||
b.customGroups ?? [],
|
||||
local.customGroups ?? [],
|
||||
remote.customGroups ?? [],
|
||||
);
|
||||
const snippetPackages = mergeStringArrays(
|
||||
b.snippetPackages ?? [],
|
||||
local.snippetPackages ?? [],
|
||||
remote.snippetPackages ?? [],
|
||||
);
|
||||
|
||||
// Merge settings
|
||||
const settings = mergeSettings(b.settings, local.settings, remote.settings);
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: hosts.merged,
|
||||
keys: keys.merged,
|
||||
identities: identities.merged,
|
||||
snippets: snippets.merged,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts: knownHosts.merged,
|
||||
portForwardingRules: portForwardingRules.merged,
|
||||
settings,
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
|
||||
return {
|
||||
payload,
|
||||
hadConflicts: summary.modified.conflicts > 0,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
@@ -153,6 +154,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
|
||||
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
|
||||
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
@@ -211,6 +214,7 @@ export function applySyncableSettings(settings: NonNullable<SyncPayload['setting
|
||||
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -156,7 +156,10 @@ function execViaPty(ptyStream, command, options) {
|
||||
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
|
||||
*/
|
||||
function execViaChannel(sshClient, command, options) {
|
||||
const { timeoutMs = 60000 } = options || {};
|
||||
const {
|
||||
timeoutMs = 60000,
|
||||
trackForCancellation = null,
|
||||
} = options || {};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
sshClient.exec(command, (err, execStream) => {
|
||||
@@ -168,27 +171,40 @@ function execViaChannel(sshClient, command, options) {
|
||||
resolve({ ok: false, error: 'Failed to create exec stream', exitCode: 1 });
|
||||
return;
|
||||
}
|
||||
const marker = `__NCMCP_CH_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let finished = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
resolve({ ok: false, stdout, stderr, exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
|
||||
}, timeoutMs);
|
||||
execStream.on("data", (data) => { stdout += data.toString(); });
|
||||
execStream.stderr.on("data", (data) => { stderr += data.toString(); });
|
||||
execStream.on("close", (code) => {
|
||||
const finish = (result) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
const timeoutId = setTimeout(() => {
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
finish({ ok: false, stdout, stderr, exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
|
||||
}, timeoutMs);
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
},
|
||||
});
|
||||
}
|
||||
execStream.on("data", (data) => { stdout += data.toString(); });
|
||||
execStream.stderr.on("data", (data) => { stderr += data.toString(); });
|
||||
execStream.on("close", (code) => {
|
||||
// code is null when SSH disconnects or process is signal-terminated
|
||||
if (code == null) {
|
||||
resolve({ ok: false, stdout, stderr, exitCode: -1, error: "Command terminated unexpectedly (connection lost or signal)" });
|
||||
finish({ ok: false, stdout, stderr, exitCode: -1, error: "Command terminated unexpectedly (connection lost or signal)" });
|
||||
} else {
|
||||
resolve({ ok: code === 0, stdout, stderr, exitCode: code });
|
||||
finish({ ok: code === 0, stdout, stderr, exitCode: code });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,13 +154,25 @@ function serializeStreamChunk(chunk) {
|
||||
return { type: "reasoning-start", id: chunk.id ?? undefined };
|
||||
case "reasoning-end":
|
||||
return { type: "reasoning-end", id: chunk.id ?? undefined };
|
||||
case "tool-call":
|
||||
case "tool-call": {
|
||||
// ACP wraps all tools as "acp.acp_provider_agent_dynamic_tool" —
|
||||
// the real tool name and args are inside chunk.args
|
||||
const isAcpWrapper = chunk.toolName === "acp.acp_provider_agent_dynamic_tool";
|
||||
const acpInput = isAcpWrapper ? chunk.input : null;
|
||||
let realToolName = isAcpWrapper ? (acpInput?.toolName || chunk.toolName) : chunk.toolName;
|
||||
const realArgs = isAcpWrapper ? (acpInput?.args || chunk.args) : chunk.args;
|
||||
const realToolCallId = isAcpWrapper ? (acpInput?.toolCallId || chunk.toolCallId) : chunk.toolCallId;
|
||||
// Simplify MCP tool names: "mcp__netcatty-remote-hosts__get_environment" → "get_environment"
|
||||
if (realToolName && realToolName.includes("__")) {
|
||||
realToolName = realToolName.split("__").pop();
|
||||
}
|
||||
return {
|
||||
type: "tool-call",
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
args: chunk.args,
|
||||
toolCallId: realToolCallId,
|
||||
toolName: realToolName,
|
||||
args: realArgs,
|
||||
};
|
||||
}
|
||||
case "tool-result":
|
||||
return {
|
||||
type: "tool-result",
|
||||
|
||||
@@ -56,6 +56,9 @@ const MAX_CONCURRENT_AGENTS = 5;
|
||||
// ACP providers (module-level so cleanup() can access them)
|
||||
const acpProviders = new Map();
|
||||
const acpActiveStreams = new Map();
|
||||
const acpRequestSessions = new Map();
|
||||
const acpForceProviderReset = new Set();
|
||||
const acpChatRuns = new Map();
|
||||
|
||||
// ── Provider registry (synced from renderer, keys stay encrypted) ──
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
@@ -137,6 +140,8 @@ function injectApiKeyIntoRequest(url, headers, providerId) {
|
||||
function cleanupAcpProvider(chatSessionId) {
|
||||
const entry = acpProviders.get(chatSessionId);
|
||||
if (!entry) return;
|
||||
const rootPid = entry.provider?.model?.agentProcess?.pid;
|
||||
const childPids = getChildProcessTreePids(rootPid);
|
||||
try {
|
||||
if (typeof entry.provider.forceCleanup === "function") {
|
||||
entry.provider.forceCleanup();
|
||||
@@ -146,9 +151,75 @@ function cleanupAcpProvider(chatSessionId) {
|
||||
} catch (err) {
|
||||
console.warn("[ACP] Provider cleanup failed for session", chatSessionId, err?.message || err);
|
||||
}
|
||||
killTrackedProcessTree(rootPid, childPids);
|
||||
acpProviders.delete(chatSessionId);
|
||||
}
|
||||
|
||||
function isActiveAcpRun(chatSessionId, requestId) {
|
||||
const activeRun = acpChatRuns.get(chatSessionId);
|
||||
return Boolean(activeRun && activeRun.requestId === requestId);
|
||||
}
|
||||
|
||||
function isUnsupportedLoadSessionError(err) {
|
||||
const message = String(err?.message || err || "").toLowerCase();
|
||||
return message.includes("method not found") && message.includes("session/load");
|
||||
}
|
||||
|
||||
function getChildProcessTreePids(rootPid) {
|
||||
if (!Number.isInteger(rootPid) || rootPid <= 0) return [];
|
||||
if (process.platform === "win32") return [];
|
||||
|
||||
const discovered = new Set();
|
||||
const queue = [rootPid];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const pid = queue.shift();
|
||||
if (!Number.isInteger(pid) || pid <= 0) continue;
|
||||
try {
|
||||
const output = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf8" }).trim();
|
||||
if (!output) continue;
|
||||
for (const line of output.split(/\s+/)) {
|
||||
const childPid = Number(line);
|
||||
if (!Number.isInteger(childPid) || childPid <= 0 || discovered.has(childPid)) continue;
|
||||
discovered.add(childPid);
|
||||
queue.push(childPid);
|
||||
}
|
||||
} catch {
|
||||
// No child processes or pgrep unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(discovered);
|
||||
}
|
||||
|
||||
function killTrackedProcessTree(rootPid, childPids) {
|
||||
if (process.platform === "win32") {
|
||||
if (Number.isInteger(rootPid) && rootPid > 0) {
|
||||
try {
|
||||
execFileSync("taskkill", ["/PID", String(rootPid), "/T", "/F"], { stdio: "ignore" });
|
||||
} catch {
|
||||
// Ignore kill failures; the process may have already exited.
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const pids = [...(Array.isArray(childPids) ? childPids : [])];
|
||||
if (Number.isInteger(rootPid) && rootPid > 0) {
|
||||
pids.push(rootPid);
|
||||
}
|
||||
|
||||
// Kill children before the wrapper so orphaned grandchildren do not survive.
|
||||
for (const pid of pids.reverse()) {
|
||||
if (!Number.isInteger(pid) || pid <= 0) continue;
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// Ignore kill failures; the process may have already exited.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely send an IPC message to a renderer, guarding against destroyed senders.
|
||||
*/
|
||||
@@ -415,6 +486,10 @@ function registerHandlers(ipcMain) {
|
||||
// Track temporarily added entries so cleanup can distinguish them from synced ones
|
||||
const tempAllowedHosts = new Set();
|
||||
const tempAllowedPorts = new Set();
|
||||
// Track temporarily added HTTP hosts (for rebuild restoration)
|
||||
const tempHttpHosts = new Set();
|
||||
// Track active expiry timers per host to avoid duplicate/premature expiry
|
||||
const hostExpiryTimers = new Map();
|
||||
|
||||
/** Check if a host is owned by a currently synced provider config */
|
||||
function isHostInProviderConfigs(host) {
|
||||
@@ -424,6 +499,17 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Check if a host is owned by a provider config that uses http:// */
|
||||
function isHttpHostInProviderConfigs(host) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
const p = new URL(config.baseURL);
|
||||
if (p.hostname === host && p.protocol === "http:") return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Check if a localhost port is owned by a currently synced provider config */
|
||||
function isPortInProviderConfigs(port) {
|
||||
for (const config of providerConfigs) {
|
||||
@@ -457,17 +543,36 @@ function registerHandlers(ipcMain) {
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
}
|
||||
} else {
|
||||
if (!providerFetchHosts.has(host)) {
|
||||
const isNewHost = !providerFetchHosts.has(host);
|
||||
if (isNewHost) {
|
||||
providerFetchHosts.add(host);
|
||||
tempAllowedHosts.add(host);
|
||||
setTimeout(() => {
|
||||
// Only remove if not owned by a synced provider config
|
||||
if (!isHostInProviderConfigs(host)) {
|
||||
providerFetchHosts.delete(host);
|
||||
}
|
||||
tempAllowedHosts.delete(host);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
}
|
||||
// Always track in tempAllowedHosts so rebuild can restore to providerFetchHosts
|
||||
// even if the original persistent source (e.g. HTTPS provider) is removed mid-TTL
|
||||
tempAllowedHosts.add(host);
|
||||
if (parsed.protocol === "http:") {
|
||||
providerHttpHosts.add(host);
|
||||
if (!isHttpHostInProviderConfigs(host)) tempHttpHosts.add(host);
|
||||
}
|
||||
// Always (re-)schedule expiry timer to clean up temp entries
|
||||
const existing = hostExpiryTimers.get(host);
|
||||
if (existing) clearTimeout(existing);
|
||||
const timer = setTimeout(() => {
|
||||
hostExpiryTimers.delete(host);
|
||||
// Check if host is still needed by a provider config or web search
|
||||
const isWebSearchHost = webSearchApiHost && (() => {
|
||||
try { return new URL(webSearchApiHost).hostname === host; } catch { return false; }
|
||||
})();
|
||||
if (!isHostInProviderConfigs(host) && !isWebSearchHost) {
|
||||
providerFetchHosts.delete(host);
|
||||
providerHttpHosts.delete(host);
|
||||
} else if (!isHttpHostInProviderConfigs(host)) {
|
||||
providerHttpHosts.delete(host);
|
||||
}
|
||||
tempAllowedHosts.delete(host);
|
||||
tempHttpHosts.delete(host);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
hostExpiryTimers.set(host, timer);
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
@@ -489,6 +594,8 @@ function registerHandlers(ipcMain) {
|
||||
]);
|
||||
// Dynamically populated from configured provider baseURLs
|
||||
const providerFetchHosts = new Set();
|
||||
// Subset of providerFetchHosts where the provider baseURL explicitly uses http://
|
||||
const providerHttpHosts = new Set();
|
||||
|
||||
/**
|
||||
* Rebuild the dynamic host allowlist from the current providerConfigs.
|
||||
@@ -496,11 +603,13 @@ function registerHandlers(ipcMain) {
|
||||
*/
|
||||
function rebuildProviderFetchHosts() {
|
||||
providerFetchHosts.clear();
|
||||
providerHttpHosts.clear();
|
||||
// Reset localhost ports to built-in defaults, then add provider-configured ones
|
||||
ALLOWED_LOCALHOST_PORTS.clear();
|
||||
for (const port of BUILTIN_LOCALHOST_PORTS) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
// Re-add any still-active temporary entries so a sync doesn't wipe them
|
||||
for (const host of tempAllowedHosts) providerFetchHosts.add(host);
|
||||
for (const host of tempHttpHosts) providerHttpHosts.add(host);
|
||||
for (const port of tempAllowedPorts) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
@@ -513,6 +622,7 @@ function registerHandlers(ipcMain) {
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
} else {
|
||||
providerFetchHosts.add(host);
|
||||
if (parsed.protocol === "http:") providerHttpHosts.add(host);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL in config — skip
|
||||
@@ -598,16 +708,16 @@ function registerHandlers(ipcMain) {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
return ALLOWED_LOCALHOST_PORTS.has(port);
|
||||
}
|
||||
// Require HTTPS for remote hosts; allow HTTP only for the configured web search apiHost
|
||||
// (e.g. self-hosted SearXNG at http://searxng.lan:8080 or http://192.168.x.x)
|
||||
if (parsed.protocol !== "https:") {
|
||||
if (!webSearchApiHost) return false;
|
||||
try {
|
||||
const wsHost = new URL(webSearchApiHost).hostname;
|
||||
if (parsed.hostname !== wsHost) return false;
|
||||
} catch {
|
||||
return false;
|
||||
// Only allow http: and https: schemes for remote hosts
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
||||
// For HTTP, only allow providers explicitly configured with http:// or the web search apiHost
|
||||
if (parsed.protocol === "http:") {
|
||||
const isProviderHost = providerHttpHosts.has(parsed.hostname);
|
||||
let isWebSearchHost = false;
|
||||
if (webSearchApiHost) {
|
||||
try { isWebSearchHost = new URL(webSearchApiHost).hostname === parsed.hostname; } catch { }
|
||||
}
|
||||
if (!isProviderHost && !isWebSearchHost) return false;
|
||||
}
|
||||
// Check built-in + provider-configured host allowlist
|
||||
if (BUILTIN_FETCH_HOSTS.has(parsed.hostname)) return true;
|
||||
@@ -630,16 +740,12 @@ function registerHandlers(ipcMain) {
|
||||
const resolvedUrl = patched.url;
|
||||
const resolvedHeaders = patched.headers;
|
||||
|
||||
// Validate URL: only allow HTTP(S) schemes; require HTTPS for non-localhost
|
||||
// Validate URL: only allow HTTP(S) schemes
|
||||
try {
|
||||
const parsed = new URL(resolvedUrl);
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return { ok: false, error: "Only HTTP(S) URLs are allowed" };
|
||||
}
|
||||
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
|
||||
if (parsed.protocol === "http:" && !isLocalhost) {
|
||||
return { ok: false, error: "HTTP is only allowed for localhost" };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid URL" };
|
||||
}
|
||||
@@ -1545,12 +1651,14 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// ── ACP (Agent Client Protocol) streaming ──
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, images }) => {
|
||||
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
let abortController = null;
|
||||
try {
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
|
||||
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
|
||||
const { streamText, stepCountIs } = require("ai");
|
||||
|
||||
@@ -1601,9 +1709,22 @@ function registerHandlers(ipcMain) {
|
||||
mcpSnapshot.fingerprint = getCodexMcpFingerprint(mcpSnapshot.mcpServers);
|
||||
|
||||
const currentPermissionMode = mcpServerBridge.getPermissionMode();
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
existingRun.cancelRequested = true;
|
||||
const existingController = acpActiveStreams.get(existingRun.requestId);
|
||||
if (existingController) {
|
||||
existingController.abort();
|
||||
acpActiveStreams.delete(existingRun.requestId);
|
||||
}
|
||||
acpRequestSessions.delete(existingRun.requestId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
|
||||
let providerEntry = acpProviders.get(chatSessionId);
|
||||
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
|
||||
const shouldReuseProvider = Boolean(
|
||||
!shouldForceProviderReset &&
|
||||
providerEntry &&
|
||||
providerEntry.acpCommand === acpCommand &&
|
||||
providerEntry.cwd === sessionCwd &&
|
||||
@@ -1613,6 +1734,7 @@ function registerHandlers(ipcMain) {
|
||||
);
|
||||
|
||||
if (!shouldReuseProvider) {
|
||||
const resumeSessionId = providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
|
||||
const agentEnv = { ...shellEnv };
|
||||
@@ -1632,6 +1754,7 @@ function registerHandlers(ipcMain) {
|
||||
cwd: sessionCwd,
|
||||
mcpServers: mcpSnapshot.mcpServers,
|
||||
},
|
||||
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
: {}),
|
||||
@@ -1645,12 +1768,64 @@ function registerHandlers(ipcMain) {
|
||||
authFingerprint,
|
||||
mcpFingerprint: mcpSnapshot.fingerprint,
|
||||
permissionMode: currentPermissionMode,
|
||||
historyReplayFallback: false,
|
||||
};
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
}
|
||||
acpForceProviderReset.delete(chatSessionId);
|
||||
|
||||
const abortController = new AbortController();
|
||||
let modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
try {
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
} catch (err) {
|
||||
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
|
||||
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
|
||||
const fallbackProvider = createACPProvider({
|
||||
command: isCodexAgent
|
||||
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
|
||||
: acpCommand,
|
||||
args: acpArgs || [],
|
||||
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
|
||||
session: {
|
||||
cwd: sessionCwd,
|
||||
mcpServers: mcpSnapshot.mcpServers,
|
||||
},
|
||||
...(isCodexAgent
|
||||
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
|
||||
: {}),
|
||||
persistSession: true,
|
||||
});
|
||||
|
||||
providerEntry = {
|
||||
provider: fallbackProvider,
|
||||
acpCommand,
|
||||
cwd: sessionCwd,
|
||||
authFingerprint,
|
||||
mcpFingerprint: mcpSnapshot.fingerprint,
|
||||
permissionMode: currentPermissionMode,
|
||||
historyReplayFallback: Array.isArray(historyMessages) && historyMessages.length > 0,
|
||||
};
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
}
|
||||
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
|
||||
if (activeProviderSessionId) {
|
||||
safeSend(event.sender, "netcatty:ai:acp:event", {
|
||||
requestId,
|
||||
event: { type: "session-id", sessionId: activeProviderSessionId },
|
||||
});
|
||||
}
|
||||
|
||||
abortController = new AbortController();
|
||||
acpActiveStreams.set(requestId, abortController);
|
||||
acpRequestSessions.set(requestId, chatSessionId);
|
||||
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
|
||||
|
||||
// Prepend context hint so the agent uses MCP tools for remote hosts
|
||||
const contextualPrompt =
|
||||
@@ -1662,29 +1837,70 @@ function registerHandlers(ipcMain) {
|
||||
`Use terminal_send_input only to respond to an interactive prompt that is already running; it does not read back the updated terminal output. ` +
|
||||
`Do NOT use local shell execution.]\n\n${prompt}`;
|
||||
|
||||
// Build message content: text + optional images
|
||||
function buildMessageContent(text, imgs) {
|
||||
const content = [{ type: "text", text }];
|
||||
if (Array.isArray(imgs)) {
|
||||
for (const img of imgs) {
|
||||
if (!img.base64Data || !img.mediaType) continue;
|
||||
// Build message content: text + optional attachments
|
||||
// ACP provider only supports image/* and audio/* inline via `type: "file"`.
|
||||
// For other file types (PDF, text, etc.), tell the agent the original file
|
||||
// path so it can read it directly — ACP agents have local file access.
|
||||
function buildMessageContent(text, attachments) {
|
||||
if (!Array.isArray(attachments) || attachments.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const content = [];
|
||||
const fileHints = [];
|
||||
|
||||
for (const att of attachments) {
|
||||
if (!att.base64Data || !att.mediaType) continue;
|
||||
|
||||
if (att.mediaType.startsWith("image/")) {
|
||||
// Images: pass inline as ACP-compatible file parts
|
||||
content.push({
|
||||
type: "file",
|
||||
mediaType: img.mediaType,
|
||||
data: img.base64Data,
|
||||
...(img.filename ? { filename: img.filename } : {}),
|
||||
mediaType: att.mediaType,
|
||||
data: att.base64Data,
|
||||
...(att.filename ? { filename: att.filename } : {}),
|
||||
});
|
||||
} else if (att.filePath) {
|
||||
// Non-image files with a known local path: tell the agent to read it
|
||||
fileHints.push(`[Attached file "${att.filename || "file"}" is on the LOCAL machine (not a remote server), path: ${att.filePath} — read it locally]`);
|
||||
} else {
|
||||
// Pasted/virtual files without a path: save to managed temp dir so the agent can read them
|
||||
try {
|
||||
const fs = require("node:fs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const safeName = att.filename || `file-${Date.now()}`;
|
||||
const tempPath = tempDirBridge.getTempFilePath(safeName);
|
||||
fs.writeFileSync(tempPath, Buffer.from(att.base64Data, "base64"));
|
||||
fileHints.push(`[Attached file "${att.filename || safeName}" is on the LOCAL machine (not a remote server), path: ${tempPath} — read it locally]`);
|
||||
} catch (err) {
|
||||
console.error("[ACP] Failed to save pasted attachment to temp:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fullText = fileHints.length > 0
|
||||
? fileHints.join("\n") + "\n\n" + text
|
||||
: text;
|
||||
|
||||
content.unshift({ type: "text", text: fullText });
|
||||
return content;
|
||||
}
|
||||
|
||||
const latestPromptMessage = {
|
||||
role: "user",
|
||||
content: buildMessageContent(contextualPrompt, images),
|
||||
};
|
||||
|
||||
const result = streamText({
|
||||
model: providerEntry.provider.languageModel(model || undefined),
|
||||
messages: [{
|
||||
role: "user",
|
||||
content: buildMessageContent(contextualPrompt, images),
|
||||
}],
|
||||
model: modelInstance,
|
||||
messages: providerEntry.historyReplayFallback
|
||||
? [
|
||||
...(Array.isArray(historyMessages)
|
||||
? historyMessages.map((msg) => ({ role: msg.role, content: msg.content }))
|
||||
: []),
|
||||
latestPromptMessage,
|
||||
]
|
||||
: [latestPromptMessage],
|
||||
tools: providerEntry.provider.tools,
|
||||
stopWhen: stepCountIs(mcpServerBridge.getMaxIterations ? mcpServerBridge.getMaxIterations() : 20),
|
||||
abortSignal: abortController.signal,
|
||||
@@ -1698,6 +1914,7 @@ function registerHandlers(ipcMain) {
|
||||
if (stallTimer) clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => {
|
||||
if (!abortController.signal.aborted) {
|
||||
if (!isActiveAcpRun(chatSessionId, requestId)) return;
|
||||
safeSend(event.sender, "netcatty:ai:acp:event", {
|
||||
requestId,
|
||||
event: { type: "status", message: "Waiting for response from agent..." },
|
||||
@@ -1710,6 +1927,7 @@ function registerHandlers(ipcMain) {
|
||||
while (true) {
|
||||
const { done, value: chunk } = await reader.read();
|
||||
if (done || abortController.signal.aborted) break;
|
||||
if (!isActiveAcpRun(chatSessionId, requestId)) break;
|
||||
resetStallTimer();
|
||||
try {
|
||||
const serialized = serializeStreamChunk(chunk);
|
||||
@@ -1733,6 +1951,9 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// If stream completed with zero content, likely an auth or connection issue
|
||||
if (!hasContent && !abortController.signal.aborted) {
|
||||
if (!isActiveAcpRun(chatSessionId, requestId)) {
|
||||
return { ok: true };
|
||||
}
|
||||
safeSend(event.sender, "netcatty:ai:acp:error", {
|
||||
requestId,
|
||||
error: isCodexAgent
|
||||
@@ -1740,6 +1961,9 @@ function registerHandlers(ipcMain) {
|
||||
: "Agent returned an empty response.",
|
||||
});
|
||||
} else {
|
||||
if (!isActiveAcpRun(chatSessionId, requestId)) {
|
||||
return { ok: true };
|
||||
}
|
||||
safeSend(event.sender, "netcatty:ai:acp:done", { requestId });
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -1761,27 +1985,53 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
} finally {
|
||||
acpActiveStreams.delete(requestId);
|
||||
acpRequestSessions.delete(requestId);
|
||||
const activeRun = acpChatRuns.get(chatSessionId);
|
||||
if (activeRun?.requestId === requestId) {
|
||||
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
acpChatRuns.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId }) => {
|
||||
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId, chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// Cancel any active PTY executions (send Ctrl+C)
|
||||
mcpServerBridge.cancelAllPtyExecs();
|
||||
const effectiveChatSessionId = chatSessionId || acpRequestSessions.get(requestId);
|
||||
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
|
||||
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
|
||||
if (activeRun && activeRun.requestId === requestId) {
|
||||
activeRun.cancelRequested = true;
|
||||
}
|
||||
const controller = acpActiveStreams.get(requestId);
|
||||
let cancelled = false;
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
acpActiveStreams.delete(requestId);
|
||||
return { ok: true };
|
||||
cancelled = true;
|
||||
}
|
||||
return { ok: false, error: "Stream not found" };
|
||||
if (effectiveChatSessionId) {
|
||||
acpForceProviderReset.add(effectiveChatSessionId);
|
||||
cleanupAcpProvider(effectiveChatSessionId);
|
||||
}
|
||||
// Preserve the ACP provider session on stop so the next user message can
|
||||
// continue within the same persisted conversation context. Full provider
|
||||
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
|
||||
if (effectiveChatSessionId) cancelled = true;
|
||||
acpRequestSessions.delete(requestId);
|
||||
return cancelled ? { ok: true } : { ok: false, error: "Stream not found" };
|
||||
});
|
||||
|
||||
// Cleanup a specific ACP session (when chat session is deleted)
|
||||
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
|
||||
acpForceProviderReset.delete(chatSessionId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
|
||||
return { ok: true };
|
||||
|
||||
@@ -161,7 +161,17 @@ async function renameLocalFile(event, payload) {
|
||||
* Create a local directory
|
||||
*/
|
||||
async function mkdirLocal(event, payload) {
|
||||
await fs.promises.mkdir(payload.path, { recursive: true });
|
||||
try {
|
||||
await fs.promises.mkdir(payload.path, { recursive: true });
|
||||
} catch (err) {
|
||||
// On Windows, mkdir on drive roots (e.g. "E:\") throws EPERM.
|
||||
// If the directory already exists, that's fine — ignore the error.
|
||||
try {
|
||||
const stat = await fs.promises.stat(payload.path);
|
||||
if (stat.isDirectory()) return true;
|
||||
} catch { /* stat failed, re-throw original */ }
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ let permissionMode = "confirm";
|
||||
|
||||
// Track active PTY executions for cancellation
|
||||
const activePtyExecs = new Map(); // marker → { ptyStream, cleanup }
|
||||
const cancelledChatSessions = new Set();
|
||||
|
||||
function cancelAllPtyExecs() {
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
@@ -116,6 +117,19 @@ function getPermissionMode() {
|
||||
return permissionMode;
|
||||
}
|
||||
|
||||
function setChatSessionCancelled(chatSessionId, cancelled) {
|
||||
if (!chatSessionId) return;
|
||||
if (cancelled) {
|
||||
cancelledChatSessions.add(chatSessionId);
|
||||
} else {
|
||||
cancelledChatSessions.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
function isChatSessionCancelled(chatSessionId) {
|
||||
return Boolean(chatSessionId && cancelledChatSessions.has(chatSessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register metadata for terminal sessions (called from renderer via IPC).
|
||||
* Metadata is stored per-scope (chatSessionId) so different AI chat sessions
|
||||
@@ -336,6 +350,10 @@ async function dispatch(method, params) {
|
||||
return { ok: false, error: `Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "autonomous" in Settings → AI → Safety to allow this action.` };
|
||||
}
|
||||
|
||||
if (WRITE_METHODS.has(method) && isChatSessionCancelled(params?.chatSessionId)) {
|
||||
return { ok: false, error: "Operation cancelled: the ACP session was stopped." };
|
||||
}
|
||||
|
||||
// Scope validation for session-targeted operations
|
||||
if (method !== "netcatty/getContext" && params?.sessionId) {
|
||||
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId);
|
||||
@@ -382,20 +400,19 @@ async function dispatch(method, params) {
|
||||
function handleGetContext(params) {
|
||||
if (!sessions) return { hosts: [], instructions: "No sessions available." };
|
||||
|
||||
// Scope resolution: use explicit scopedSessionIds from MCP server env var (per-process, set at spawn).
|
||||
// If scopedSessionIds is provided but empty, that means "no access" (not "all access").
|
||||
// Only fall back to unscoped (show all) when scopedSessionIds is not provided at all.
|
||||
const hasScopeParam = params?.scopedSessionIds != null;
|
||||
const scopedIds = hasScopeParam
|
||||
? new Set(params.scopedSessionIds)
|
||||
: null;
|
||||
|
||||
// chatSessionId may be passed via env for per-scope metadata lookup
|
||||
const chatSessionId = params?.chatSessionId || null;
|
||||
const explicitScopedIds = Array.isArray(params?.scopedSessionIds)
|
||||
? params.scopedSessionIds
|
||||
: null;
|
||||
const resolvedScopedIds = explicitScopedIds ?? (chatSessionId ? getScopedSessionIds(chatSessionId) : null);
|
||||
const hasScopedContext = explicitScopedIds !== null || chatSessionId !== null;
|
||||
const scopedIds = resolvedScopedIds ? new Set(resolvedScopedIds) : null;
|
||||
|
||||
const hosts = [];
|
||||
// When scope param is provided (even if empty Set), enforce it strictly
|
||||
if (hasScopeParam && scopedIds.size === 0) {
|
||||
// When a scoped context exists but currently resolves to zero sessions, treat
|
||||
// it as "no access" rather than falling back to all sessions.
|
||||
if (hasScopedContext && (!resolvedScopedIds || resolvedScopedIds.length === 0)) {
|
||||
return {
|
||||
environment: "netcatty-terminal",
|
||||
description: "No hosts are available in the current scope.",
|
||||
@@ -458,7 +475,10 @@ function handleExec(params) {
|
||||
|
||||
// If no PTY stream, fall back to exec channel (invisible to terminal)
|
||||
if (!ptyStream || typeof ptyStream.write !== "function") {
|
||||
return execViaChannel(sshClient, command, { timeoutMs: commandTimeoutMs });
|
||||
return execViaChannel(sshClient, command, {
|
||||
timeoutMs: commandTimeoutMs,
|
||||
trackForCancellation: activePtyExecs,
|
||||
});
|
||||
}
|
||||
|
||||
// Execute via PTY stream so user sees the command in the terminal
|
||||
@@ -755,7 +775,9 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
env.push({ name: "NETCATTY_MCP_TOKEN", value: authToken });
|
||||
}
|
||||
|
||||
if (effectiveIds && effectiveIds.length > 0) {
|
||||
// When chatSessionId is present, the MCP subprocess resolves scope dynamically
|
||||
// through main-process metadata, so avoid freezing session IDs at spawn time.
|
||||
if (!chatSessionId && effectiveIds && effectiveIds.length > 0) {
|
||||
env.push({ name: "NETCATTY_MCP_SESSION_IDS", value: effectiveIds.join(",") });
|
||||
}
|
||||
|
||||
@@ -781,6 +803,7 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
|
||||
function cleanupScopedMetadata(chatSessionId) {
|
||||
if (chatSessionId) {
|
||||
scopedMetadata.delete(chatSessionId);
|
||||
cancelledChatSessions.delete(chatSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -802,6 +825,7 @@ module.exports = {
|
||||
getMaxIterations,
|
||||
setPermissionMode,
|
||||
getPermissionMode,
|
||||
setChatSessionCancelled,
|
||||
checkCommandSafety,
|
||||
updateSessionMetadata,
|
||||
getScopedSessionIds,
|
||||
|
||||
207
electron/bridges/sessionLogStreamManager.cjs
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Session Log Stream Manager - Manages real-time log write streams per session
|
||||
* Writes terminal data to files in real-time instead of only on session close.
|
||||
* Fixes issue #394 where session logs only capture ~55 lines.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { stripAnsi, terminalDataToHtml } = require("./sessionLogsBridge.cjs");
|
||||
|
||||
// Active log streams keyed by sessionId
|
||||
const activeStreams = new Map();
|
||||
|
||||
// Buffer flush interval (ms)
|
||||
const FLUSH_INTERVAL = 500;
|
||||
// Max buffer size before immediate flush (bytes)
|
||||
const MAX_BUFFER_SIZE = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Start a log stream for a session.
|
||||
* Creates the log file and opens a write stream.
|
||||
* @param {string} sessionId
|
||||
* @param {{ hostLabel: string, hostname: string, directory: string, format: string, startTime?: number }} opts
|
||||
*/
|
||||
function startStream(sessionId, opts) {
|
||||
if (activeStreams.has(sessionId)) {
|
||||
console.warn(`[SessionLogStream] Stream already active for ${sessionId}, stopping old one`);
|
||||
stopStream(sessionId);
|
||||
}
|
||||
|
||||
const { hostLabel, hostname, directory, format, startTime } = opts;
|
||||
if (!directory) {
|
||||
console.warn("[SessionLogStream] No directory specified, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build file path: directory / hostSubdir / timestamp.ext
|
||||
const safeHostLabel = (hostLabel || hostname || "unknown").replace(/[^a-zA-Z0-9-_]/g, "_");
|
||||
const hostDir = path.join(directory, safeHostLabel);
|
||||
fs.mkdirSync(hostDir, { recursive: true });
|
||||
|
||||
const date = new Date(startTime || Date.now());
|
||||
const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
||||
// For html format, write raw data to a temp file during streaming,
|
||||
// then convert on stopStream.
|
||||
const isHtml = format === "html";
|
||||
const ext = isHtml ? "log.tmp" : format === "raw" ? "log" : "txt";
|
||||
const fileName = `${dateStr}.${ext}`;
|
||||
const filePath = path.join(hostDir, fileName);
|
||||
|
||||
const writeStream = fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" });
|
||||
|
||||
writeStream.on("error", (err) => {
|
||||
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
|
||||
// Disable this stream on error to avoid cascading failures
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (entry) {
|
||||
entry.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
const entry = {
|
||||
writeStream,
|
||||
filePath,
|
||||
hostDir,
|
||||
format,
|
||||
isHtml,
|
||||
hostLabel: hostLabel || hostname || "unknown",
|
||||
startTime: startTime || Date.now(),
|
||||
buffer: "",
|
||||
flushTimer: null,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
// Start periodic flush
|
||||
entry.flushTimer = setInterval(() => {
|
||||
flushBuffer(entry);
|
||||
}, FLUSH_INTERVAL);
|
||||
|
||||
activeStreams.set(sessionId, entry);
|
||||
console.log(`[SessionLogStream] Started stream for ${sessionId} -> ${filePath}`);
|
||||
} catch (err) {
|
||||
console.error(`[SessionLogStream] Failed to start stream for ${sessionId}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered data to the write stream.
|
||||
* @param {object} entry - The stream entry
|
||||
*/
|
||||
function flushBuffer(entry) {
|
||||
if (!entry || entry.disabled || entry.buffer.length === 0) return;
|
||||
|
||||
try {
|
||||
const data = entry.buffer;
|
||||
entry.buffer = "";
|
||||
|
||||
if (entry.isHtml) {
|
||||
// For HTML format, write raw data during streaming; convert on close
|
||||
entry.writeStream.write(data);
|
||||
} else if (entry.format === "raw") {
|
||||
entry.writeStream.write(data);
|
||||
} else {
|
||||
// txt format: strip ANSI codes
|
||||
entry.writeStream.write(stripAnsi(data));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SessionLogStream] Flush error:", err.message);
|
||||
entry.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append data to the session's log buffer.
|
||||
* Data is flushed periodically or when the buffer exceeds MAX_BUFFER_SIZE.
|
||||
* @param {string} sessionId
|
||||
* @param {string} dataChunk - Decoded terminal data string
|
||||
*/
|
||||
function appendData(sessionId, dataChunk) {
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (!entry || entry.disabled) return;
|
||||
|
||||
entry.buffer += dataChunk;
|
||||
|
||||
// Immediate flush if buffer is large
|
||||
if (entry.buffer.length >= MAX_BUFFER_SIZE) {
|
||||
flushBuffer(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the log stream for a session.
|
||||
* Flushes remaining data, closes the write stream, and finalizes the file.
|
||||
* @param {string} sessionId
|
||||
* @returns {Promise<string|null>} The final file path, or null if no stream was active
|
||||
*/
|
||||
async function stopStream(sessionId) {
|
||||
const entry = activeStreams.get(sessionId);
|
||||
if (!entry) return null;
|
||||
activeStreams.delete(sessionId);
|
||||
|
||||
// Stop periodic flush
|
||||
if (entry.flushTimer) {
|
||||
clearInterval(entry.flushTimer);
|
||||
entry.flushTimer = null;
|
||||
}
|
||||
|
||||
// Flush remaining buffer
|
||||
flushBuffer(entry);
|
||||
|
||||
// Close the write stream and wait for it to finish
|
||||
await new Promise((resolve) => {
|
||||
entry.writeStream.end(resolve);
|
||||
});
|
||||
|
||||
let finalPath = entry.filePath;
|
||||
|
||||
// For HTML format: read the temp raw file and convert to HTML
|
||||
if (entry.isHtml && !entry.disabled) {
|
||||
try {
|
||||
const rawData = await fs.promises.readFile(entry.filePath, "utf8");
|
||||
const htmlContent = terminalDataToHtml(rawData, entry.hostLabel, entry.startTime);
|
||||
const htmlPath = entry.filePath.replace(/\.log\.tmp$/, ".html");
|
||||
await fs.promises.writeFile(htmlPath, htmlContent, "utf8");
|
||||
// Remove temp file
|
||||
try {
|
||||
await fs.promises.unlink(entry.filePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
finalPath = htmlPath;
|
||||
} catch (err) {
|
||||
console.error(`[SessionLogStream] HTML conversion failed for ${sessionId}:`, err.message);
|
||||
// Keep the raw temp file as fallback
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SessionLogStream] Stopped stream for ${sessionId} -> ${finalPath}`);
|
||||
return finalPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session has an active log stream.
|
||||
* @param {string} sessionId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasStream(sessionId) {
|
||||
return activeStreams.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all active streams (called on app quit).
|
||||
*/
|
||||
async function cleanupAll() {
|
||||
console.log(`[SessionLogStream] Cleaning up ${activeStreams.size} active streams`);
|
||||
const ids = [...activeStreams.keys()];
|
||||
await Promise.allSettled(ids.map(id => stopStream(id)));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startStream,
|
||||
appendData,
|
||||
stopStream,
|
||||
hasStream,
|
||||
cleanupAll,
|
||||
};
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
getSshAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
@@ -977,6 +978,17 @@ async function startSSHSession(event, options) {
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || options.hostname || '',
|
||||
hostname: options.hostname || '',
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || 'txt',
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// Data buffering for reduced IPC overhead
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
@@ -1009,12 +1021,29 @@ async function startSSHSession(event, options) {
|
||||
|
||||
stream.on("data", (data) => {
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
bufferData(decoder.write(data));
|
||||
const decoded = decoder.write(data);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
});
|
||||
|
||||
stream.stderr?.on("data", (data) => {
|
||||
const decoder = getSessionDecoder(sessionId, "stderr");
|
||||
bufferData(decoder.write(data));
|
||||
const decoded = decoder.write(data);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
});
|
||||
|
||||
// Capture the real exit code from the remote process.
|
||||
// "exit" fires when the remote shell/process exits normally;
|
||||
// "close" fires whenever the channel closes (could be network drop).
|
||||
// Only treat it as user-initiated exit if "exit" fired with a numeric
|
||||
// code and no signal. Signal terminations (e.g. server kill, idle
|
||||
// timeout) have code=null and signal set — those are not user exits.
|
||||
let streamExitCode = 0;
|
||||
let streamExited = false;
|
||||
stream.on("exit", (code, signal) => {
|
||||
streamExitCode = typeof code === "number" ? code : 0;
|
||||
streamExited = typeof code === "number" && !signal;
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
@@ -1023,8 +1052,9 @@ async function startSSHSession(event, options) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
flushBuffer();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1072,7 +1102,8 @@ async function startSSHSession(event, options) {
|
||||
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
|
||||
}
|
||||
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1086,7 +1117,8 @@ async function startSSHSession(event, options) {
|
||||
console.error(`${logPrefix} ${options.hostname} connection timeout`);
|
||||
const err = new Error(`Connection timeout to ${options.hostname}`);
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
@@ -1098,7 +1130,8 @@ async function startSSHSession(event, options) {
|
||||
|
||||
conn.once("close", () => {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
|
||||
@@ -11,6 +11,8 @@ const { StringDecoder } = require("node:string_decoder");
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
let electronModule = null;
|
||||
@@ -213,18 +215,35 @@ function startLocalSession(event, payload) {
|
||||
webContentsId: event.sender.id,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (payload?.sessionLog?.enabled && payload?.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: "Local",
|
||||
hostname: "localhost",
|
||||
directory: payload.sessionLog.directory,
|
||||
format: payload.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
proc.onData((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
|
||||
|
||||
proc.onExit((evt) => {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, ...evt });
|
||||
// Signal present = killed externally (show disconnected UI).
|
||||
// No signal = process exited normally, even with non-zero code
|
||||
// (e.g. user typed `exit` after a failed command), so auto-close.
|
||||
const reason = evt.signal ? "error" : "exited";
|
||||
contents?.send("netcatty:exit", { sessionId, ...evt, reason });
|
||||
});
|
||||
|
||||
|
||||
return { sessionId };
|
||||
}
|
||||
|
||||
@@ -386,6 +405,17 @@ async function startTelnetSession(event, options) {
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || hostname,
|
||||
hostname,
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
resolve({ sessionId });
|
||||
});
|
||||
|
||||
@@ -412,6 +442,7 @@ async function startTelnetSession(event, options) {
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -419,14 +450,15 @@ async function startTelnetSession(event, options) {
|
||||
socket.on('error', (err) => {
|
||||
console.error(`[Telnet] Socket error: ${err.message}`);
|
||||
clearTimeout(connectTimeout);
|
||||
|
||||
|
||||
if (!connected) {
|
||||
reject(new Error(`Failed to connect: ${err.message}`));
|
||||
} else {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
}
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
@@ -435,11 +467,12 @@ async function startTelnetSession(event, options) {
|
||||
socket.on('close', (hadError) => {
|
||||
console.log(`[Telnet] Connection closed${hadError ? ' with error' : ''}`);
|
||||
clearTimeout(connectTimeout);
|
||||
|
||||
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0 });
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
|
||||
}
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
@@ -515,15 +548,29 @@ async function startMoshSession(event, options) {
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || options.hostname,
|
||||
hostname: options.hostname,
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
proc.onData((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
|
||||
proc.onExit((evt) => {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, ...evt });
|
||||
// Mosh non-zero exit typically means connection/auth failure — show error UI
|
||||
contents?.send("netcatty:exit", { sessionId, ...evt, reason: evt.exitCode === 0 ? "exited" : "error" });
|
||||
});
|
||||
|
||||
return { sessionId };
|
||||
@@ -602,6 +649,17 @@ async function startSerialSession(event, options) {
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
|
||||
sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: options.label || portPath,
|
||||
hostname: portPath,
|
||||
directory: options.sessionLog.directory,
|
||||
format: options.sessionLog.format || "txt",
|
||||
startTime: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const serialDecoder = new StringDecoder('latin1');
|
||||
|
||||
serialPort.on('data', (data) => {
|
||||
@@ -609,20 +667,23 @@ async function startSerialSession(event, options) {
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
});
|
||||
|
||||
serialPort.on('error', (err) => {
|
||||
console.error(`[Serial] Port error: ${err.message}`);
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
serialPort.on('close', () => {
|
||||
console.log(`[Serial] Port closed`);
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,24 @@ const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
|
||||
|
||||
/**
|
||||
* Safely ensure a local directory exists.
|
||||
* On Windows, `mkdir("E:\\", { recursive: true })` throws EPERM for drive roots.
|
||||
* We catch that and verify the directory already exists before re-throwing.
|
||||
*/
|
||||
async function ensureLocalDir(dir) {
|
||||
try {
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
} catch (err) {
|
||||
// If the directory already exists, ignore the error (covers EPERM on drive roots)
|
||||
try {
|
||||
const stat = await fs.promises.stat(dir);
|
||||
if (stat.isDirectory()) return;
|
||||
} catch { /* stat failed, re-throw original */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transfer performance tuning ──────────────────────────────────────────────
|
||||
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
|
||||
// dramatically improving throughput over sequential stream piping.
|
||||
@@ -430,14 +448,14 @@ async function startTransfer(event, payload, onProgress) {
|
||||
if (!client) throw new Error("Source SFTP session not found");
|
||||
|
||||
const dir = path.dirname(targetPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await ensureLocalDir(dir);
|
||||
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
await downloadFile(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
|
||||
|
||||
} else if (sourceType === 'local' && targetType === 'local') {
|
||||
const dir = path.dirname(targetPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await ensureLocalDir(dir);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const readStream = fs.createReadStream(sourcePath, { highWaterMark: TRANSFER_CHUNK_SIZE });
|
||||
|
||||
@@ -79,6 +79,7 @@ const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
|
||||
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
|
||||
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
|
||||
const sessionLogStreamManager = require("./bridges/sessionLogStreamManager.cjs");
|
||||
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const credentialBridge = require("./bridges/credentialBridge.cjs");
|
||||
@@ -849,6 +850,11 @@ if (!gotLock) {
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
sessionLogStreamManager.cleanupAll();
|
||||
} catch (err) {
|
||||
console.warn("Error during session log stream cleanup:", err);
|
||||
}
|
||||
try {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
} catch (err) {
|
||||
|
||||
@@ -168,8 +168,13 @@ const server = new McpServer({
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
// Scope params shared by all tool calls (includes chatSessionId for metadata isolation)
|
||||
const scopeParams = { scopedSessionIds: SCOPED_SESSION_IDS, chatSessionId: CHAT_SESSION_ID };
|
||||
// Scope params shared by all tool calls.
|
||||
// When chatSessionId is present, let the main process resolve the current
|
||||
// workspace membership dynamically so mid-session workspace changes are visible
|
||||
// without restarting the MCP subprocess.
|
||||
const scopeParams = CHAT_SESSION_ID
|
||||
? { chatSessionId: CHAT_SESSION_ID }
|
||||
: { scopedSessionIds: SCOPED_SESSION_IDS, chatSessionId: CHAT_SESSION_ID };
|
||||
|
||||
// Resource: environment context
|
||||
server.resource(
|
||||
@@ -194,7 +199,7 @@ server.tool(
|
||||
"Get information about the current Netcatty workspace: all connected remote hosts, their session IDs, OS, and connection status. Call this first to discover available hosts before executing commands.",
|
||||
{},
|
||||
async () => {
|
||||
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}\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);
|
||||
process.stderr.write(`[netcatty-mcp] get_environment result: hostCount=${ctx.hostCount}, hosts=${JSON.stringify(ctx.hosts?.map(h => h.sessionId))}\n`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(ctx, null, 2) }] };
|
||||
@@ -214,7 +219,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/exec", { sessionId, command });
|
||||
const result = await rpcCall("netcatty/exec", { ...scopeParams, sessionId, command });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error || "Command failed"}` }], isError: true };
|
||||
}
|
||||
@@ -239,7 +244,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/terminalWrite", { sessionId, input });
|
||||
const result = await rpcCall("netcatty/terminalWrite", { ...scopeParams, sessionId, input });
|
||||
if (!result.ok) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -256,7 +261,7 @@ server.tool(
|
||||
path: z.string().describe("The absolute path of the remote directory to list."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpList", { sessionId, path });
|
||||
const result = await rpcCall("netcatty/sftpList", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -275,7 +280,7 @@ server.tool(
|
||||
},
|
||||
async ({ sessionId, path, maxBytes }) => {
|
||||
const safeMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(maxBytes) || 10000));
|
||||
const result = await rpcCall("netcatty/sftpRead", { sessionId, path, maxBytes: safeMaxBytes });
|
||||
const result = await rpcCall("netcatty/sftpRead", { ...scopeParams, sessionId, path, maxBytes: safeMaxBytes });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -297,7 +302,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpWrite", { sessionId, path, content });
|
||||
const result = await rpcCall("netcatty/sftpWrite", { ...scopeParams, sessionId, path, content });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -318,7 +323,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpMkdir", { sessionId, path });
|
||||
const result = await rpcCall("netcatty/sftpMkdir", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -339,7 +344,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRemove", { sessionId, path });
|
||||
const result = await rpcCall("netcatty/sftpRemove", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -361,7 +366,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/sftpRename", { sessionId, oldPath, newPath });
|
||||
const result = await rpcCall("netcatty/sftpRename", { ...scopeParams, sessionId, oldPath, newPath });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -378,7 +383,7 @@ server.tool(
|
||||
path: z.string().describe("The absolute path to stat."),
|
||||
},
|
||||
async ({ sessionId, path }) => {
|
||||
const result = await rpcCall("netcatty/sftpStat", { sessionId, path });
|
||||
const result = await rpcCall("netcatty/sftpStat", { ...scopeParams, sessionId, path });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
@@ -401,7 +406,7 @@ server.tool(
|
||||
if (guardErr) {
|
||||
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
|
||||
}
|
||||
const result = await rpcCall("netcatty/multiExec", { sessionIds, command, mode, stopOnError });
|
||||
const result = await rpcCall("netcatty/multiExec", { ...scopeParams, sessionIds, command, mode, stopOnError });
|
||||
if (result.error) {
|
||||
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
|
||||
}
|
||||
|
||||
@@ -1065,11 +1065,11 @@ const api = {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:set-permission-mode", { mode });
|
||||
},
|
||||
// ACP streaming
|
||||
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, images) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, images });
|
||||
aiAcpStream: async (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 });
|
||||
},
|
||||
aiAcpCancel: async (requestId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId });
|
||||
aiAcpCancel: async (requestId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
|
||||
},
|
||||
aiAcpCleanup: async (chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:acp:cleanup", { chatSessionId });
|
||||
|
||||
13
global.d.ts
vendored
@@ -76,6 +76,8 @@ declare global {
|
||||
legacyAlgorithms?: boolean;
|
||||
// Use sudo for SFTP server
|
||||
sudo?: boolean;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}
|
||||
|
||||
interface SftpStatResult {
|
||||
@@ -143,6 +145,7 @@ declare global {
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
startMoshSession?(options: {
|
||||
sessionId?: string;
|
||||
@@ -155,8 +158,9 @@ declare global {
|
||||
rows?: number;
|
||||
charset?: string;
|
||||
env?: Record<string, string>;
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
|
||||
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
|
||||
startSerialSession?(options: {
|
||||
sessionId?: string;
|
||||
path: string;
|
||||
@@ -165,6 +169,7 @@ declare global {
|
||||
stopBits?: 1 | 1.5 | 2;
|
||||
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
|
||||
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}): Promise<string>;
|
||||
listSerialPorts?(): Promise<Array<{
|
||||
path: string;
|
||||
@@ -244,7 +249,7 @@ declare global {
|
||||
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
|
||||
onSessionExit(
|
||||
sessionId: string,
|
||||
cb: (evt: { exitCode?: number; signal?: number }) => void
|
||||
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void
|
||||
): () => void;
|
||||
onAuthFailed?(
|
||||
sessionId: string,
|
||||
@@ -689,8 +694,8 @@ declare global {
|
||||
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiCloseAgentStdin?(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): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCancel?(requestId: 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 }>;
|
||||
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;
|
||||
onAiAcpDone?(requestId: string, cb: () => void): () => void;
|
||||
|
||||
@@ -9,11 +9,12 @@
|
||||
import type { ExternalAgentConfig } from './types';
|
||||
|
||||
export interface AcpAgentCallbacks {
|
||||
onSessionId?: (sessionId: string) => void;
|
||||
onTextDelta: (text: string) => void;
|
||||
onThinkingDelta: (text: string) => void;
|
||||
onThinkingDone: () => void;
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>) => void;
|
||||
onToolResult: (toolCallId: string, result: string) => void;
|
||||
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => void;
|
||||
onToolResult: (toolCallId: string, result: string, toolName?: string) => void;
|
||||
onStatus?: (message: string) => void;
|
||||
onError: (error: string) => void;
|
||||
onDone: () => void;
|
||||
@@ -29,9 +30,11 @@ interface AcpBridge {
|
||||
cwd?: string,
|
||||
providerId?: string,
|
||||
model?: string,
|
||||
existingSessionId?: string,
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
images?: ImageAttachment[],
|
||||
): Promise<{ ok: boolean; error?: string }>;
|
||||
aiAcpCancel(requestId: string): Promise<{ ok: boolean }>;
|
||||
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
|
||||
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
|
||||
onAiAcpDone(requestId: string, cb: () => void): () => void;
|
||||
onAiAcpError(requestId: string, cb: (error: string) => void): () => void;
|
||||
@@ -47,12 +50,16 @@ interface StreamEvent {
|
||||
* Sends the prompt to the main process which runs streamText() with the ACP provider.
|
||||
* Stream events are forwarded back via IPC.
|
||||
*/
|
||||
export interface ImageAttachment {
|
||||
export interface FileAttachment {
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filename?: string;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
/** @deprecated Use FileAttachment instead */
|
||||
export type ImageAttachment = FileAttachment;
|
||||
|
||||
export async function runAcpAgentTurn(
|
||||
bridge: Record<string, (...args: unknown[]) => unknown>,
|
||||
requestId: string,
|
||||
@@ -63,6 +70,8 @@ export async function runAcpAgentTurn(
|
||||
signal?: AbortSignal,
|
||||
providerId?: string,
|
||||
model?: string,
|
||||
existingSessionId?: string,
|
||||
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
|
||||
images?: ImageAttachment[],
|
||||
): Promise<void> {
|
||||
const acpBridge = bridge as unknown as AcpBridge;
|
||||
@@ -101,7 +110,7 @@ export async function runAcpAgentTurn(
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
acpBridge.aiAcpCancel(requestId).catch(() => {});
|
||||
acpBridge.aiAcpCancel(requestId, chatSessionId).catch(() => {});
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
|
||||
@@ -117,6 +126,8 @@ export async function runAcpAgentTurn(
|
||||
undefined, // cwd
|
||||
providerId,
|
||||
model,
|
||||
existingSessionId,
|
||||
historyMessages,
|
||||
images?.length ? images : undefined,
|
||||
).catch((err: Error) => {
|
||||
callbacks.onError(err.message);
|
||||
@@ -160,16 +171,18 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
|
||||
case 'tool-call': {
|
||||
const toolName = (event.toolName as string) || 'unknown';
|
||||
const input = (event.input as Record<string, unknown>) || {};
|
||||
callbacks.onToolCall(toolName, input);
|
||||
const toolCallId = (event.toolCallId as string) || undefined;
|
||||
callbacks.onToolCall(toolName, input, toolCallId);
|
||||
break;
|
||||
}
|
||||
case 'tool-result': {
|
||||
const toolCallId = (event.toolCallId as string) || '';
|
||||
const toolName = (event.toolName as string) || undefined;
|
||||
const output = event.output ?? event.result;
|
||||
const result = typeof output === 'string'
|
||||
? output
|
||||
: JSON.stringify(output);
|
||||
callbacks.onToolResult(toolCallId, result);
|
||||
callbacks.onToolResult(toolCallId, result, toolName);
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
@@ -177,6 +190,11 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
|
||||
if (msg) callbacks.onStatus?.(msg);
|
||||
break;
|
||||
}
|
||||
case 'session-id': {
|
||||
const sessionId = (event.sessionId as string) || '';
|
||||
if (sessionId) callbacks.onSessionId?.(sessionId);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
callbacks.onError(String(event.error || 'Unknown error'));
|
||||
break;
|
||||
|
||||
@@ -1,47 +1,15 @@
|
||||
import type { ChatMessage } from './types';
|
||||
|
||||
/**
|
||||
* Classifies a raw error string into structured error info for display.
|
||||
* Convert a raw error string into display-safe error info.
|
||||
*
|
||||
* Intentionally avoids keyword-based "root cause" attribution because upstream
|
||||
* providers often return generic 4xx/5xx text that would be misclassified.
|
||||
* We show the sanitized upstream message directly instead.
|
||||
*/
|
||||
export function classifyError(error: string): NonNullable<ChatMessage['errorInfo']> {
|
||||
const lower = error.toLowerCase();
|
||||
|
||||
// Network errors
|
||||
if (lower.includes('econnrefused') || lower.includes('enotfound') || lower.includes('enetunreach') || lower.includes('fetch failed') || lower.includes('network')) {
|
||||
return { type: 'network', message: 'Network connection failed. Please check your internet connection and API endpoint.', retryable: true };
|
||||
}
|
||||
|
||||
// Timeout
|
||||
if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('econnreset') || lower.includes('socket hang up')) {
|
||||
return { type: 'timeout', message: 'Request timed out. The server may be overloaded or unreachable.', retryable: true };
|
||||
}
|
||||
|
||||
// Auth errors
|
||||
if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('invalid api key') || lower.includes('authentication')) {
|
||||
return { type: 'auth', message: 'Authentication failed. Please check your API key in Settings \u2192 AI.', retryable: false };
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
if (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests')) {
|
||||
return { type: 'provider', message: 'Rate limit exceeded. Please wait a moment before retrying.', retryable: true };
|
||||
}
|
||||
|
||||
// Provider errors (5xx)
|
||||
if (/\b5\d{2}\b/.test(error) || lower.includes('server error') || lower.includes('internal error')) {
|
||||
return { type: 'provider', message: sanitizeErrorMessage(error), retryable: true };
|
||||
}
|
||||
|
||||
// Model not found
|
||||
if (lower.includes('model not found') || lower.includes('does not exist') || lower.includes('404')) {
|
||||
return { type: 'provider', message: 'Model not found. Please check your model selection in Settings \u2192 AI.', retryable: false };
|
||||
}
|
||||
|
||||
// Command blocked
|
||||
if (lower.includes('blocked by safety')) {
|
||||
return { type: 'agent', message: sanitizeErrorMessage(error), retryable: false };
|
||||
}
|
||||
|
||||
return { type: 'unknown', message: sanitizeErrorMessage(error), retryable: true };
|
||||
const message = sanitizeErrorMessage(error).trim() || 'Unknown error';
|
||||
return { type: 'unknown', message, retryable: false };
|
||||
}
|
||||
|
||||
const MAX_ERROR_MESSAGE_LENGTH = 500;
|
||||
|
||||
@@ -86,6 +86,20 @@ function extractHeaders(headers?: HeadersInit): Record<string, string> {
|
||||
/** Placeholder API key used by the renderer; main process replaces it with the real key. */
|
||||
export const API_KEY_PLACEHOLDER = '__IPC_SECURED__';
|
||||
|
||||
function toSafeStatusText(message: string, fallback: string): string {
|
||||
const normalized = message
|
||||
.replace(/[\r\n\t]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!normalized) return fallback;
|
||||
const byteStringSafe = Array.from(normalized, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code < 0x20 || code === 0x7f || code > 0xff) return '?';
|
||||
return char;
|
||||
}).join('');
|
||||
return byteStringSafe.slice(0, 120) || fallback;
|
||||
}
|
||||
|
||||
export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.fetch {
|
||||
return async (
|
||||
input: string | URL | Request,
|
||||
@@ -182,7 +196,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
|
||||
const jsonBody = JSON.stringify({ error: { message: errorMessage } });
|
||||
return new Response(jsonBody, {
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
statusText: toSafeStatusText(errorMessage, 'Bad Gateway'),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -198,7 +212,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
|
||||
const jsonBody = JSON.stringify({ error: { message: errorDetail } });
|
||||
return new Response(jsonBody, {
|
||||
status: statusCode,
|
||||
statusText: `Error ${statusCode}`,
|
||||
statusText: toSafeStatusText(errorDetail, `Error ${statusCode}`),
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
|
||||
import type { NetcattyBridge } from '../cattyAgent/executor';
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import type { WebSearchConfig } from '../types';
|
||||
import { isWebSearchReady } from '../types';
|
||||
@@ -30,7 +30,7 @@ function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
|
||||
*/
|
||||
export function createCattyTools(
|
||||
bridge: NetcattyBridge,
|
||||
context: ExecutorContext,
|
||||
context: ToolDeps['context'],
|
||||
commandBlocklist?: string[],
|
||||
permissionMode: AIPermissionMode = 'confirm',
|
||||
webSearchConfig?: WebSearchConfig,
|
||||
|
||||
@@ -27,7 +27,7 @@ export type ToolExecResult<T = unknown> =
|
||||
|
||||
export interface ToolDeps {
|
||||
bridge: NetcattyBridge;
|
||||
context: ExecutorContext;
|
||||
context: ExecutorContext | (() => ExecutorContext);
|
||||
commandBlocklist?: string[];
|
||||
permissionMode: AIPermissionMode;
|
||||
webSearchConfig?: WebSearchConfig;
|
||||
@@ -37,11 +37,16 @@ export interface ToolDeps {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function validSessionIds(ctx: ExecutorContext): Set<string> {
|
||||
return new Set(ctx.sessions.map(s => s.sessionId));
|
||||
function resolveContext(ctx: ToolDeps['context']): ExecutorContext {
|
||||
return typeof ctx === 'function' ? ctx() : ctx;
|
||||
}
|
||||
|
||||
function validateSessionScope(ctx: ExecutorContext, sessionId: string): string | null {
|
||||
function validSessionIds(ctx: ToolDeps['context']): Set<string> {
|
||||
const resolved = resolveContext(ctx);
|
||||
return new Set(resolved.sessions.map(s => s.sessionId));
|
||||
}
|
||||
|
||||
function validateSessionScope(ctx: ToolDeps['context'], sessionId: string): string | null {
|
||||
const ids = validSessionIds(ctx);
|
||||
if (!ids.has(sessionId)) {
|
||||
return `Session "${sessionId}" is not in the current scope. Available sessions: ${[...ids].join(', ')}`;
|
||||
@@ -110,7 +115,7 @@ export function executeWorkspaceGetInfo(
|
||||
connected: boolean;
|
||||
}>;
|
||||
}> {
|
||||
const { context } = deps;
|
||||
const context = resolveContext(deps.context);
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
@@ -132,7 +137,7 @@ export function executeWorkspaceGetSessionInfo(
|
||||
deps: ToolDeps,
|
||||
args: { sessionId: string },
|
||||
): ToolExecResult<ExecutorContext['sessions'][number]> {
|
||||
const { context } = deps;
|
||||
const context = resolveContext(deps.context);
|
||||
const session = context.sessions.find(s => s.sessionId === args.sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: `Session not found: ${args.sessionId}` };
|
||||
|
||||
@@ -23,17 +23,23 @@ export interface ModelInfo {
|
||||
}
|
||||
|
||||
// Chat types
|
||||
export interface ChatMessageImage {
|
||||
export interface ChatMessageAttachment {
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filename?: string;
|
||||
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';
|
||||
content: string;
|
||||
images?: ChatMessageImage[];
|
||||
attachments?: ChatMessageAttachment[];
|
||||
/** @deprecated Use attachments instead. Kept for backward compatibility with persisted sessions. */
|
||||
images?: ChatMessageAttachment[];
|
||||
thinking?: string;
|
||||
thinkingDurationMs?: number;
|
||||
toolCalls?: ToolCall[];
|
||||
@@ -48,7 +54,7 @@ export interface ChatMessage {
|
||||
};
|
||||
/** Transient status text shown with shimmer effect (e.g. "Waiting for response...") */
|
||||
statusText?: string;
|
||||
executionStatus?: 'pending' | 'approved' | 'rejected' | 'running' | 'completed' | 'failed';
|
||||
executionStatus?: 'pending' | 'approved' | 'rejected' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
pendingApproval?: {
|
||||
approvalId: string;
|
||||
toolCallId: string;
|
||||
@@ -100,6 +106,7 @@ export interface AISession {
|
||||
agentId: string;
|
||||
scope: AISessionScope;
|
||||
messages: ChatMessage[];
|
||||
externalSessionId?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_clic
|
||||
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
|
||||
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
|
||||
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
|
||||
export const STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR = 'netcatty_sftp_auto_open_sidebar_v1';
|
||||
|
||||
// Editor Settings
|
||||
export const STORAGE_KEY_EDITOR_WORD_WRAP = 'netcatty_editor_word_wrap_v1';
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
decryptProviderSecrets,
|
||||
encryptProviderSecrets,
|
||||
} from '../persistence/secureFieldAdapter';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
|
||||
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
|
||||
|
||||
@@ -256,6 +257,15 @@ export class CloudSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
private removeFromStorage(key: string): void {
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore storage removal failures
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Cross-window sync (Electron settings window, etc.)
|
||||
// ==========================================================================
|
||||
@@ -757,6 +767,8 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
await this.saveProviderConnection('github', this.state.providers.github);
|
||||
// Clear merge base when (re)authenticating to a potentially different account
|
||||
this.removeFromStorage(this.syncBaseKey('github'));
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: 'github',
|
||||
@@ -810,6 +822,8 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Clear merge base when (re)authenticating to a potentially different account
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -846,6 +860,8 @@ export class CloudSyncManager {
|
||||
};
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Clear merge base when (re)configuring to a different endpoint/bucket
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -874,6 +890,9 @@ export class CloudSyncManager {
|
||||
};
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Clear the merge base for this provider so reconnecting to a different
|
||||
// account/resource doesn't reuse an unrelated snapshot
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
|
||||
}
|
||||
|
||||
@@ -1081,30 +1100,81 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
if (checkResult.conflict && checkResult.remoteFile) {
|
||||
const remoteFile = checkResult.remoteFile;
|
||||
// Remote is newer - conflict
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
// Remote is newer — attempt three-way merge instead of blocking
|
||||
try {
|
||||
const remotePayload = await EncryptionService.decryptPayload(
|
||||
checkResult.remoteFile,
|
||||
this.masterPassword,
|
||||
);
|
||||
const base = await this.loadSyncBase(provider);
|
||||
const mergeResult = mergeSyncPayloads(base, payload, remotePayload);
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
console.log('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
};
|
||||
// Encrypt and upload merged payload
|
||||
const mergedSyncedFile = await EncryptionService.encryptPayload(
|
||||
mergeResult.payload,
|
||||
this.masterPassword,
|
||||
this.state.deviceId,
|
||||
this.state.deviceName,
|
||||
packageJson.version,
|
||||
checkResult.remoteFile.meta.version, // base on remote version
|
||||
);
|
||||
|
||||
const uploadResult = await this.uploadToProvider(provider, adapter, mergedSyncedFile);
|
||||
|
||||
if (uploadResult.success) {
|
||||
await this.saveSyncBase(mergeResult.payload, provider);
|
||||
this.state.syncState = 'IDLE';
|
||||
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'merge',
|
||||
success: true,
|
||||
localVersion: mergedSyncedFile.meta.version,
|
||||
remoteVersion: checkResult.remoteFile.meta.version,
|
||||
deviceName: this.state.deviceName,
|
||||
});
|
||||
|
||||
return {
|
||||
...uploadResult,
|
||||
action: 'merge',
|
||||
mergedPayload: mergeResult.payload,
|
||||
};
|
||||
}
|
||||
|
||||
// Upload after merge failed — set ERROR so sync isn't stuck in SYNCING
|
||||
this.state.syncState = 'ERROR';
|
||||
this.state.lastError = uploadResult.error || 'Upload failed after merge';
|
||||
return uploadResult;
|
||||
} catch (mergeError) {
|
||||
// Merge failed — fall back to conflict UI
|
||||
console.error('[CloudSyncManager] Merge failed, falling back to conflict UI', mergeError);
|
||||
const remoteFile = checkResult.remoteFile;
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Encrypt
|
||||
@@ -1121,6 +1191,7 @@ export class CloudSyncManager {
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile);
|
||||
|
||||
if (result.success) {
|
||||
await this.saveSyncBase(payload, provider);
|
||||
this.state.syncState = 'IDLE';
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
@@ -1182,6 +1253,7 @@ export class CloudSyncManager {
|
||||
this.state.remoteVersion = remoteFile.meta.version;
|
||||
this.state.remoteUpdatedAt = remoteFile.meta.updatedAt;
|
||||
this.saveSyncConfig();
|
||||
await this.saveSyncBase(payload, provider);
|
||||
this.notifyStateChange(); // Notify UI of state change
|
||||
|
||||
// Add to sync history
|
||||
@@ -1240,8 +1312,10 @@ export class CloudSyncManager {
|
||||
/**
|
||||
* Sync to all connected providers
|
||||
*/
|
||||
async syncAllProviders(payload?: SyncPayload): Promise<Map<CloudProvider, SyncResult>> {
|
||||
async syncAllProviders(inputPayload?: SyncPayload): Promise<Map<CloudProvider, SyncResult>> {
|
||||
const results = new Map<CloudProvider, SyncResult>();
|
||||
let payload = inputPayload;
|
||||
let wasMerged = false;
|
||||
|
||||
if (!payload) {
|
||||
// Caller should provide payload from app state
|
||||
@@ -1293,58 +1367,85 @@ export class CloudSyncManager {
|
||||
|
||||
const checkResults = await Promise.all(checkTasks);
|
||||
|
||||
// 2. Analyze Results & Handle Conflicts
|
||||
const conflict = checkResults.find((r) => !r.error && r.check?.conflict);
|
||||
// 2. Analyze Results & Handle Conflicts — merge ALL conflicting providers
|
||||
const conflicts = checkResults.filter((r) => !r.error && r.check?.conflict && r.check?.remoteFile);
|
||||
|
||||
if (conflict && conflict.check?.remoteFile) {
|
||||
const { provider, check } = conflict;
|
||||
const remoteFile = check.remoteFile!;
|
||||
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider: provider as CloudProvider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
|
||||
// Populate results
|
||||
for (const r of checkResults) {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
} else if (r.provider === provider) {
|
||||
results.set(provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: provider as CloudProvider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
});
|
||||
} else {
|
||||
// Others are reset to connected
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'connected');
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: true, // Should we mark as success if skipped?
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
});
|
||||
if (conflicts.length > 0) {
|
||||
// Three-way merge: incorporate remote data from every conflicting provider
|
||||
try {
|
||||
let merged = payload;
|
||||
for (const c of conflicts) {
|
||||
const providerBase = await this.loadSyncBase(c.provider as CloudProvider);
|
||||
const remotePayload = await EncryptionService.decryptPayload(
|
||||
c.check!.remoteFile!,
|
||||
this.masterPassword,
|
||||
);
|
||||
const result = mergeSyncPayloads(providerBase, merged, remotePayload);
|
||||
merged = result.payload;
|
||||
}
|
||||
const mergeResult = { payload: merged };
|
||||
|
||||
console.log('[CloudSyncManager] syncAll: three-way merge completed');
|
||||
|
||||
// Replace payload with merged payload for upload to all providers
|
||||
payload = mergeResult.payload;
|
||||
wasMerged = true;
|
||||
|
||||
// Re-classify: all providers (including the conflicting one) should now upload
|
||||
// Clear the conflict check result so all go through the upload path
|
||||
for (const r of checkResults) {
|
||||
if (r.check) r.check.conflict = false;
|
||||
}
|
||||
} catch (mergeError) {
|
||||
// Merge failed — fall back to conflict UI
|
||||
console.error('[CloudSyncManager] syncAll: merge failed', mergeError);
|
||||
const { provider, check } = conflicts[0];
|
||||
const remoteFile = check!.remoteFile!;
|
||||
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider: provider as CloudProvider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
|
||||
for (const r of checkResults) {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
} else if (r.provider === conflicts[0].provider) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
});
|
||||
} else {
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'connected');
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: true,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// 3. Encrypt Once
|
||||
@@ -1370,6 +1471,15 @@ export class CloudSyncManager {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Use the highest version as base: either local or any remote that was merged
|
||||
let baseVersion = this.state.localVersion;
|
||||
if (wasMerged) {
|
||||
for (const c of conflicts) {
|
||||
const rv = c.check?.remoteFile?.meta?.version ?? 0;
|
||||
if (rv > baseVersion) baseVersion = rv;
|
||||
}
|
||||
}
|
||||
|
||||
let syncedFile: SyncedFile;
|
||||
try {
|
||||
syncedFile = await EncryptionService.encryptPayload(
|
||||
@@ -1378,7 +1488,7 @@ export class CloudSyncManager {
|
||||
this.state.deviceId,
|
||||
this.state.deviceName,
|
||||
packageJson.version,
|
||||
this.state.localVersion
|
||||
baseVersion
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = String(error);
|
||||
@@ -1411,6 +1521,22 @@ export class CloudSyncManager {
|
||||
const hasSuccess = Array.from(results.values()).some((r) => r.success);
|
||||
if (hasSuccess) {
|
||||
this.state.syncState = 'IDLE';
|
||||
// Save base per provider that successfully uploaded
|
||||
if (payload) {
|
||||
for (const [p, r] of results) {
|
||||
if (r.success) await this.saveSyncBase(payload, p);
|
||||
}
|
||||
}
|
||||
|
||||
// If a merge happened, attach the merged payload to successful results
|
||||
// so callers can apply remote additions to local state
|
||||
if (wasMerged && payload) {
|
||||
for (const [p, r] of results) {
|
||||
if (r.success) {
|
||||
results.set(p, { ...r, action: 'merge', mergedPayload: payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
// lastError is set by uploadToProvider
|
||||
@@ -1494,6 +1620,60 @@ export class CloudSyncManager {
|
||||
});
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Sync Base (three-way merge snapshot)
|
||||
// ==========================================================================
|
||||
|
||||
private syncBaseKey(provider?: CloudProvider): string {
|
||||
const suffix = provider ? `_${provider}` : '';
|
||||
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${suffix}`;
|
||||
}
|
||||
|
||||
async saveSyncBase(payload: SyncPayload, provider?: CloudProvider): Promise<void> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key) return;
|
||||
try {
|
||||
const data = new TextEncoder().encode(JSON.stringify(payload));
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
|
||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(encrypted), iv.length);
|
||||
// Encode in chunks to avoid stack overflow with large buffers
|
||||
let binary = '';
|
||||
const CHUNK = 8192;
|
||||
for (let i = 0; i < combined.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...combined.subarray(i, i + CHUNK));
|
||||
}
|
||||
this.saveToStorage(this.syncBaseKey(provider), btoa(binary));
|
||||
} catch {
|
||||
console.warn('[CloudSyncManager] Failed to save sync base');
|
||||
}
|
||||
}
|
||||
|
||||
async loadSyncBase(provider?: CloudProvider): Promise<SyncPayload | null> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key) return null;
|
||||
try {
|
||||
const encoded = this.loadFromStorage<string>(this.syncBaseKey(provider));
|
||||
if (!encoded || typeof encoded !== 'string') return null;
|
||||
const combined = Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
|
||||
const iv = combined.slice(0, 12);
|
||||
const ciphertext = combined.slice(12);
|
||||
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
|
||||
return JSON.parse(new TextDecoder().decode(decrypted));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearSyncBase(): void {
|
||||
this.removeFromStorage(SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD);
|
||||
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
|
||||
this.removeFromStorage(this.syncBaseKey(p));
|
||||
}
|
||||
}
|
||||
|
||||
private addSyncHistoryEntry(entry: Omit<SyncHistoryEntry, 'id'>): void {
|
||||
const newEntry: SyncHistoryEntry = {
|
||||
...entry,
|
||||
@@ -1521,6 +1701,7 @@ export class CloudSyncManager {
|
||||
this.state.syncHistory = [];
|
||||
this.saveSyncConfig();
|
||||
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, []);
|
||||
this.clearSyncBase();
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 4.8 MiB |
BIN
screenshots/gifs/custom-highlight.mp4
Normal file
|
Before Width: | Height: | Size: 4.2 MiB |
BIN
screenshots/gifs/custom-themes.mp4
Normal file
|
Before Width: | Height: | Size: 1.5 MiB |
BIN
screenshots/gifs/drag-file-upload.mp4
Normal file
|
Before Width: | Height: | Size: 2.7 MiB |
BIN
screenshots/gifs/dual-terminal--split-manage.mp4
Normal file
|
Before Width: | Height: | Size: 1.8 MiB |
BIN
screenshots/gifs/gird-list-tre-views.mp4
Normal file
BIN
screenshots/gifs/main-window-light.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 6.1 MiB |
BIN
screenshots/gifs/sftpview-with-drag-and-built-in-editor.mp4
Normal file
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |