Compare commits

...

35 Commits

Author SHA1 Message Date
bincxz
be7aa4ae52 fix: resolve eslint warnings in App.tsx and VaultView.tsx
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Remove unused sessionLog deps from useCallback in App.tsx
- Wrap countAllHostsInNode in useCallback and add to useMemo deps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:57:19 +08:00
陈大猫
f4872099bd fix: real-time session logging via main process streams (#403)
* fix: implement real-time session logging via main process streams

Fixes #394. Session logs previously only captured ~55 lines (the
xterm serialize buffer) and were written only on session close. This
change intercepts terminal data in the main process and writes it to
a file stream in real-time, capturing the complete session output.

- Add sessionLogStreamManager.cjs: manages per-session write streams
  with 500ms/64KB flush, supports txt/raw/html formats
- sshBridge: start stream on shell open, append on data, stop on close
- terminalBridge: same for local, telnet, mosh, serial sessions
- Thread sessionLog config from renderer settings through IPC options
- Skip old renderer-side auto-save when streaming is active
- Cleanup all streams on app quit

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

* fix: remove stale renderer-side auto-save and async HTML finalization

- Remove dead renderer-side auto-save code (main process handles it)
- Make stopStream async, await writeStream finish before HTML conversion
- Use fs.promises for HTML read/write/unlink

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:44:54 +08:00
陈大猫
4e2089d7e2 feat: add option to auto-open sidebar on host connect (#401)
* feat: add option to auto-open sidebar on host connect

Closes #396

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

* fix: only auto-open SFTP sidebar for SSH/Mosh connections

Use allowlist (ssh, mosh) instead of blocklist so telnet and other
non-SSH protocols don't trigger SFTP sidebar which would fail.

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

* fix: support auto-open SFTP for Quick Connect / temporary sessions

Build a minimal Host from session data when hostId is not in the vault,
so Quick Connect sessions also trigger auto-open SFTP sidebar.

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

* fix: sync SFTP auto-open sidebar setting across windows via IPC

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

* fix: skip local terminals and preserve username for temp sessions

- Don't fallback protocol to 'ssh' so local terminals are excluded
- Include session.username in synthesized Host for Quick Connect

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:12:53 +08:00
陈大猫
5f28320c57 fix: suppress known_hosts toast on auto-scan at startup (#402)
* fix: suppress known_hosts toast on auto-scan at startup

The auto-scan on first mount now runs silently — no toasts for missing
known_hosts file, no entries, or no new hosts. Users still see toasts
when manually clicking "Scan System".

Closes #398

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

* fix: wrap onClick handlers to avoid passing event as silent flag

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:44:08 +08:00
陈大猫
4e26852482 feat: support multimodal attachments in AI chat (#397)
* feat: support multimodal attachments (images, PDFs, files) in AI chat

Previously uploaded images were displayed in the UI but never sent to
the AI model, and non-image files (PDF, text) were silently rejected.

- Rename useImageUpload → useFileUpload; accept image/*, PDF, and text/*
- Rename ChatMessageImage → ChatMessageAttachment with filePath support
- Build multimodal SDK messages (ImagePart/FilePart) for Catty Agent
- Fix ACP agent path: images inline, non-image files via local path hint
  so ACP agents (Claude Code, etc.) read them with native file access
- Use Electron webUtils.getPathForFile() for reliable file path capture
- Compact user message bubble padding

Closes #294 (AI file upload issues)

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

* fix: show real tool names in AI chat instead of ACP wrapper names

- Unwrap ACP dynamic tool calls in serializeStreamChunk to extract
  real tool name, args, and toolCallId from chunk.input
- Simplify MCP tool name prefixes (mcp__server__tool → tool)
- Pass toolCallId from ACP tool-call events to match tool results
- Prevent onToolResult from overwriting correct names with wrapper name
- Build toolCallNames map in ChatMessageList for tool result display

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

* fix: backward-compatible fallback for legacy `images` field in chat messages

Persisted sessions may still have `images` instead of `attachments`.
Add `?? m.images` fallback in SDK message builder and renderer so
historical image attachments are not silently dropped after upgrade.

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

* fix: broaden file type support and handle pasted files without path

- Accept all file types except video/audio (instead of allowlist)
  so .json, .yaml, .sh, etc. are not silently rejected
- For ACP agents, save pasted/virtual files (no filePath) to temp
  directory so the agent can still read them

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

* fix: use managed temp dir for pasted ACP attachments

Use tempDirBridge.getTempFilePath() instead of manual os.tmpdir() path
so pasted file attachments are tracked by the app's cleanup system.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:45:50 +08:00
yuzifu
c4fb19cafb update supported distros (#395) 2026-03-19 09:31:22 +08:00
bincxz
09e6526142 Remove GIFs, align zh-CN and ja-JP READMEs with main
- Delete all GIF files (replaced by mp4/user-attachments)
- Update demo sections to use GitHub video attachments
- Add contributor avatars via contrib.rocks
- Add Star History chart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:59:42 +08:00
陈大猫
7ce110c3fb Update asset links in README.md
Updated asset links for various features in the README.
2026-03-19 01:52:27 +08:00
bincxz
667ee18ed3 Compress demo mp4 files (~52MB → ~2.5MB)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:50:23 +08:00
陈大猫
f969b1b73d Add links for SFTP and drag file upload sections
Updated README to include links for SFTP and drag file upload.
2026-03-19 01:43:47 +08:00
陈大猫
58a4bf892a Update video references in README.md
Replaced video tags with links to video assets for better accessibility.
2026-03-19 01:39:38 +08:00
bincxz
5052a8231f Improve README: mp4 demos, contributor avatars, star history
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:34:00 +08:00
bincxz
13c9cf16fd Update screenshots and add demo GIFs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:26:16 +08:00
陈大猫
63558b5301 Remove HTTP localhost-only restriction for AI requests (#393)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Remove the restriction that blocked non-localhost HTTP URLs for AI
provider requests. Users with HTTP-based AI services on internal
networks can now configure http:// provider base URLs.

Security measures:
- Only providers explicitly configured with http:// are allowed over HTTP
- HTTPS-configured providers cannot be silently downgraded
- Temporary HTTP permissions expire after 30s TTL
- Non-http/https schemes are explicitly rejected
- webSearchApiHost entries preserved from accidental expiry

Fixes #392
2026-03-18 19:57:47 +08:00
陈大猫
c2b4d43531 Merge pull request #391 from binaricat/fix/sftp-download-windows-drive-root 2026-03-18 16:11:10 +08:00
bincxz
4d5c0eed69 Fix SFTP download failing on Windows drive root paths
On Windows, `fs.promises.mkdir("E:\", { recursive: true })` throws
EPERM for drive root directories. When users save SFTP downloads to a
drive root (e.g. E:\file.txt), `path.dirname` returns "E:\" and the
subsequent mkdir fails. Fix by catching the error and verifying the
directory already exists before re-throwing.

Fixes #390

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:06:23 +08:00
bincxz
3ad710e5da Fix AI error message wrapping
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-03-18 13:38:30 +08:00
陈大猫
d2e5a26317 Merge pull request #374 from yuzifu/fix-host-count-in-tree-view
Fix host count in tree view
2026-03-18 13:30:42 +08:00
陈大猫
4f1eb4a8a9 Merge pull request #389 from binaricat/codex/show-raw-ai-errors
Show raw AI errors instead of inferred causes
2026-03-18 13:26:41 +08:00
bincxz
e35bb708a2 Show raw AI errors instead of inferred causes 2026-03-18 13:00:27 +08:00
陈大猫
cd2631428e Fix AI scope leaking across tab switches (#388)
* Fix AI scope leaking across tab switches

* Keep AI executor context live across resumes
2026-03-18 11:56:28 +08:00
yuzifu
09af399543 fix: import import certificate icon size too small (#387)
fix icon small when dropdown item text is too long

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-03-18 10:07:07 +08:00
陈大猫
db9970d040 fix: surface streaming provider errors in chat (#386)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: surface streaming provider errors in chat

* fix: sanitize streaming status text as ByteString
2026-03-18 03:44:59 +08:00
陈大猫
3d4fbf8763 fix: keep workspace MCP scope in sync (#385)
* fix: keep workspace MCP scope in sync

* fix: refresh catty workspace tool context

* fix: preserve AI stream state across tab switches

* fix: align ACP stop and resume with 1code semantics

* fix: harden ACP resume fallback for unsupported agents
2026-03-18 03:33:00 +08:00
陈大猫
9387590696 Fix ACP stop cleanup and cancel state (#384)
* Fix ACP stop cleanup and cancel state

* Block ACP tool writes after stop

* Kill ACP child processes on cleanup

* Cleanup ACP sessions when tabs disappear
2026-03-18 02:24:36 +08:00
陈大猫
74a04f1d8e feat: three-way merge for cloud sync (#381)
Implements automatic three-way merge for cloud sync, replacing the
binary USE_REMOTE/USE_LOCAL conflict resolution. Same principle as
Git's merge algorithm.

After every successful sync, a "base snapshot" is saved (encrypted
with AES-256-GCM using the derived master key). When a conflict is
detected, the system performs per-entity merge by ID:
- Items added on one side → included
- Items deleted on one side (unchanged on other) → removed
- Items modified on one side only → take that version
- Both sides modified same item → prefer local
- One side deleted + other modified → keep modification

Additional improvements:
- Per-provider sync base to prevent cross-provider contamination
- Deep merge for nested settings (terminalSettings, customKeyBindings)
- Entity merge for array-valued settings (customTerminalThemes)
- KnownHost deduplication by (hostname, port, keyType)
- Chunked base encoding to avoid stack overflow on large vaults
- Base cleared on provider disconnect/reconnect
- Correct version numbering after multi-provider merge

Closes #378

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:12:49 +08:00
陈大猫
3c258b0f19 feat: auto-close tab when user actively exits session (#380)
* feat: auto-close tab when user actively exits a session

When a user intentionally exits a session (e.g. typing `exit`, `logout`,
or Ctrl+D), the tab is now automatically closed instead of showing the
"Start Over" disconnected page. This matches the behavior of macOS
Terminal and other popular terminal emulators.

Network errors, timeouts, and server-initiated disconnects still show
the disconnected page with the Start Over option, so users can reconnect.

In workspace mode, only the individual terminal pane is closed, not the
entire workspace.

Implementation:
- Backend bridges now include a `reason` field in exit events to
  distinguish stream-level exits ("exited") from connection errors
  ("error"), timeouts ("timeout"), and connection closes ("closed")
- SSH bridge captures real exit code from stream "exit" event instead
  of hardcoding 0
- Frontend auto-closes session only when reason is "exited"

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

* fix: address review feedback for auto-close feature

1. Pass exit event to onSessionExit in local shell path (line 757)
   to prevent undefined access when checking evt.reason

2. Change Telnet socket close reason from "exited" to "closed" since
   a clean socket close can also be server-initiated (idle timeout,
   remote shutdown), not just user exit

3. Change Serial port close reason from "exited" to "closed" since
   port close can be from device disconnect, not user action

Only SSH stream close and local/mosh process exit (node-pty onExit)
now use reason "exited", which correctly represents user-initiated exits.

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

* fix: only mark SSH exit as "exited" when stream exit event fired

ssh2's stream "close" event fires whenever the channel closes, not
only on normal shell exit. If the network drops and the channel closes
without a preceding "exit" event, the reason was incorrectly set to
"exited", causing the tab to auto-close instead of showing the
disconnected/Start Over page.

Now tracks whether stream "exit" actually fired via a flag, and only
uses reason "exited" in that case. Otherwise falls back to "closed".

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

* fix: classify mosh non-zero exits as errors

Mosh process exiting with a non-zero code typically indicates a
connection or auth failure. Mark these as reason "error" so the
disconnected/Start Over UI is shown instead of auto-closing the tab.

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

* fix: treat SSH signal-terminated exits as disconnects

ssh2's stream "exit" event also fires for signal terminations (e.g.
SIGHUP from server idle timeout, SIGTERM from admin kill), where code
is null and signal is set. These are not user-initiated exits and
should show the disconnected/Start Over page.

Now only sets streamExited=true when there's a numeric exit code and
no signal present.

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

* fix: distinguish abnormal local PTY exits from user exits

Local shell terminated by signal or crashing on startup should show
the disconnected UI, not auto-close the tab. Now only marks as
reason "exited" when exitCode is 0 and no signal, matching the same
logic used for mosh.

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

* fix: use signal presence to distinguish local shell exit reason

For local shells, non-zero exit codes are common in user-initiated
exits (e.g. typing `exit` after a failed command returns that
command's exit code). Use signal presence instead: signal means the
process was killed externally (show disconnected UI), no signal
means normal process exit (auto-close tab).

Mosh keeps exitCode-based logic since non-zero there indicates
connection/auth failure, not user exit.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:45:56 +08:00
陈大猫
6303eef3a2 fix: make global and host-level keyword highlight independent (#379) 2026-03-17 22:59:02 +08:00
yuzifu
a9a648039f Merge branch 'main' into fix-host-count-in-tree-view 2026-03-17 21:53:30 +08:00
yuzifu
1d4ec7afb9 Merge remote-tracking branch 'origin/fix-host-count-in-tree-view' into fix-host-count-in-tree-view 2026-03-17 17:25:00 +08:00
yuzifu
a1899951e0 fix: show hosts count(update)
Avoid recalculating the number of hosts during re-rendering
2026-03-17 17:24:16 +08:00
bincxz
d84668aa0f perf: memoize subtree host count to avoid repeated traversals
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:52:41 +08:00
yuzifu
68d0f4574c update show hosts count in tree view 2026-03-17 16:40:45 +08:00
yuzifu
bedf59bb48 update show host count in tree view 2026-03-17 10:17:57 +08:00
yuzifu
793ea94078 fix: show host count in tree view 2026-03-17 09:16:01 +08:00
73 changed files with 2378 additions and 555 deletions

3
.gitignore vendored
View File

@@ -37,6 +37,9 @@ coverage
# Claude Code
/.claude/
# Codex
/.codex/
/CLAUDE.md
# AI / Superpowers generated docs (local only)

30
App.tsx
View File

@@ -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 */}

View File

@@ -59,6 +59,8 @@
- [ビルドとパッケージ](#ビルドとパッケージ)
- [技術スタック](#技術スタック)
- [コントリビューション](#コントリビューション)
- [コントリビューター](#コントリビューター)
- [Star 履歴](#star-履歴)
- [ライセンス](#ライセンス)
---
@@ -110,37 +112,37 @@
<a name="デモ"></a>
# デモ
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`
動画で機能をさっと確認できます(素材は `screenshots/gifs/`
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
![Vault ビュー:グリッド/リスト/ツリー](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
![分割ターミナル + セッション管理](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTPドラッグドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
![SFTPドラッグドロップ + 内蔵エディタ](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
![ドラッグでアップロード](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
![カスタムテーマ](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
![キーワードハイライト](screenshots/gifs/custom-highlight.gif)
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>

View File

@@ -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.
![Vault views: grid/list/tree](screenshots/gifs/gird-list-tre-views.gif)
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.
![Split terminals + session management](screenshots/gifs/dual-terminal--split-manage.gif)
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.
![SFTP: drag & drop + built-in editor](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
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.
![Drag file upload](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
![Custom themes](screenshots/gifs/custom-themes.gif)
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.
![Keyword highlighting](screenshots/gifs/custom-highlight.gif)
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>

View File

@@ -59,6 +59,8 @@
- [构建与打包](#构建与打包)
- [技术栈](#技术栈)
- [参与贡献](#参与贡献)
- [贡献者](#贡献者)
- [Star 历史](#star-历史)
- [开源协议](#开源协议)
---
@@ -111,37 +113,37 @@
<a name="演示"></a>
# 演示
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
![Vault 视图:网格/列表/树形](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
![分屏终端 + 会话管理](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
![SFTP拖拽 + 内置编辑器](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
![拖拽文件上传](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
![自定义主题](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
![关键词高亮](screenshots/gifs/custom-highlight.gif)
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>

View File

@@ -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}',

View File

@@ -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}',

View File

@@ -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,

View File

@@ -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;

View 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 };
}

View File

@@ -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 };
}

View File

@@ -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,
]),
};

View File

@@ -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);

View File

@@ -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}

View File

@@ -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

View File

@@ -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 [];

View File

@@ -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"

View File

@@ -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

View File

@@ -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 : []),

View File

@@ -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 &&

View File

@@ -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>

View File

@@ -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,
)}

View File

@@ -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>

View File

@@ -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 = '';
}
}}

View File

@@ -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>
)}

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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')} />

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
View 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,
};
}

View File

@@ -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));
}
// ---------------------------------------------------------------------------

View File

@@ -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 });
}
});
});

View File

@@ -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",

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -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,

View 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,
};

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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 });

View File

@@ -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) {

View File

@@ -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 };
}

View File

@@ -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
View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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' },
});
}

View File

@@ -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,

View File

@@ -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}` };

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB