Compare commits

...

37 Commits

Author SHA1 Message Date
陈大猫
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
陈大猫
ccfa2d4dd0 fix: non-zero exit code is not a failure, include output on real errors (#377)
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: treat non-zero exit code as success and include output on failure

- Non-zero exit codes (e.g. grep returning 1, ls on missing file) are
  valid command results, not execution failures. Changed execViaPty and
  execViaChannel to always return ok:true when the command actually ran.
- ok:false is now reserved for real failures: timeout, session gone,
  stream not writable, etc.
- When ok:false, include any partial stdout/stderr in the error message
  so the user and LLM can see what happened before the failure.

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

* fix: return stdout+exitCode for all completed commands, clean up dead code

- ptyExec: preserve original ok semantics (non-zero = ok:false) so MCP
  server bridge callers (handleMultiExec, stopOnError) still work
- execViaChannel: null exit code (SSH disconnect) returns ok:false
- toolExecutors: Catty Agent always returns stdout+exitCode to the LLM
  regardless of exit code, only treats real failures (timeout, disconnect)
  as errors — with partial output included
- Remove dead code: executeTerminalSendInput, executeSftp*, executeMultiHost
- Clean up unused imports, bridge interface, ExecutorContext

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 19:53:23 +08:00
陈大猫
7c5478b2a5 refactor: remove SFTP tools from AI agent (#376)
Remove sftp_list_directory, sftp_read_file, and sftp_write_file tools.
The AI can use terminal_execute with standard shell commands (ls, cat,
tee, etc.) which is more flexible, supports sudo/pipes/redirects, and
reduces tool choice complexity for the LLM.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:59:47 +08:00
陈大猫
338ba94d42 feat: add paste-only option for snippets (no auto-execute) (#375)
* feat: add "paste only" option for snippets (no auto-execute)

Add a noAutoRun flag to snippets that pastes the command into the
terminal without appending a carriage return, so users can review
and edit before manually pressing Enter.

Applies to all snippet execution paths: snippet runner (new session),
keyboard shortcut, and startup command.

Closes #371

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

* fix: use clearer wording "仅粘贴" instead of "仅上屏"

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

* fix: skip onCommandExecuted for paste-only shortcut snippets

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

* fix: persist noAutoRun on save and apply to Scripts panel clicks

- Include noAutoRun in handleSubmit serialization (was being lost)
- Pass noAutoRun through ScriptsSidePanel click handler to TerminalLayer
  so paste-only snippets work from the Scripts panel too

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 18:09:17 +08:00
陈大猫
b7b2e91fab fix: show real error message instead of [object Object] (#373)
* fix: show real error message instead of [object Object]

When an error object (not a string or Error instance) reaches the
error display path, String(obj) produces "[object Object]". Now
extract .message from error-like objects, or JSON.stringify as fallback.

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

* fix: guard JSON.stringify fallback against undefined return

JSON.stringify(undefined) returns undefined (not a string), which would
crash classifyError().toLowerCase(). Add ?? 'Unknown error' fallback.

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

* fix: use non-throwing fallback for error serialization

JSON.stringify can throw on circular objects or BigInt values. Wrap in
try-catch to avoid losing the original error and leaving the stream
stuck in a streaming state.

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 17:23:05 +08:00
yuzifu
cd723000fc fix: show host count in tree view (#364)
* fix: show host count in tree view

* update show host count in tree view

* perf: memoize subtree host count to avoid repeated traversals

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

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:00:31 +08:00
陈大猫
fff031eb25 fix: remove multi_host_execute and fix MissingToolResultsError (#372)
Remove multi_host_execute tool — the AI can call terminal_execute for
each host individually, which is simpler, more reliable, and avoids
the hang issue where parallel remote commands block the stream.

Fix AI_MissingToolResultsError that occurs after user stops a stream
mid-tool-execution: when building SDK messages, skip orphaned tool
calls that have no matching tool result instead of including them
(which causes the SDK to reject the next message).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:29:57 +08:00
yuzifu
2f1fd399cf fix: avoid repeated sync (#370)
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-03-17 16:17:04 +08:00
陈大猫
43c4d4c430 fix: open settings window on the same display as the main window (#367)
Use Electron's screen.getDisplayMatching() to find which display the
main window is on, then center the settings window on that display's
work area. Previously the settings window used Electron's default
placement which could open on the primary display even when the main
window was on an external monitor.

Ref #294

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:05:35 +08:00
陈大猫
835a1231a6 feat: add skip TLS verification option for self-hosted AI providers (#369)
* feat: add skip TLS verification option for AI providers

Self-hosted AI endpoints (vLLM, text-generation-webui, etc.) often use
self-signed TLS certificates which Node.js rejects by default, causing
502 Bad Gateway errors. Add a per-provider "Skip TLS certificate
verification" checkbox that sets rejectUnauthorized=false on both
streaming and non-streaming requests.

Ref #294

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

* fix: surface real error message instead of generic 502 Bad Gateway

- Pass the actual bridge error message in statusText so Vercel AI SDK
  shows the real cause (e.g. "HTTP is only allowed for localhost",
  "URL host is not in the allowed list", TLS errors)
- Show real error details for 5xx provider errors instead of generic
  "The AI provider returned a server error" message

Previously all connection-level errors were masked as "Bad Gateway"
making it impossible for users to diagnose configuration issues.

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

* fix: pass server error body details through to the user

- Read HTTP error response body before resolving (was resolving before
  body was read, losing the error detail)
- Parse OpenAI-compatible JSON error format to extract error.message
- Return error Response with body+statusText for non-2xx instead of
  empty stream, so Vercel AI SDK shows the real server error
- Now users see e.g. "502 model not loaded" instead of just "Bad Gateway"

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

* fix: widen link modifier key dropdown to prevent text wrapping

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

* Revert "fix: widen link modifier key dropdown to prevent text wrapping"

This reverts commit 1f756863910d7450c6ffd8c373ef156e90adcce7.

* fix: apply skipTLSVerify to model listing requests

ModelSelector.aiFetch() didn't pass providerId, so the provider-level
skipTLSVerify was not applied when refreshing/listing models. Add
skipTLSVerify as a direct parameter alongside the provider lookup.

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

* fix: keep error detail in Response body, not statusText

statusText only accepts single-line Latin-1 — multiline or non-ASCII
error messages from self-hosted gateways would throw TypeError before
the AI SDK could read them. Move detailed error to body instead.

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

* fix: return JSON error body for AI SDK compatibility, fix FetchBridge type

- Wrap error responses in OpenAI-compatible JSON format so Vercel AI
  SDK's failedResponseHandler extracts the message correctly instead
  of showing a blank error
- Update FetchBridge type to match the expanded aiFetch parameter list

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

* fix: add ASCII statusText fallback for non-OpenAI SDK providers

Anthropic/Google SDKs fall back to Response.statusText when they can't
parse the error body. Add safe ASCII statusText alongside the JSON body.

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 16:05:09 +08:00
陈大猫
cd512d0800 fix: host-level keyword highlight toggle now overrides global setting (#368)
When a host explicitly disables keyword highlighting, global rules are
no longer applied to that terminal. Previously the OR logic
(globalEnabled || hostEnabled) meant per-host disable had no effect
when global highlighting was enabled.

Now: hostEnabled=false suppresses global rules; hostEnabled=undefined
inherits global setting (backward compatible).

Ref #294

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:38:59 +08:00
陈大猫
0c5ae13692 fix: widen settings dropdown selects to prevent text wrapping (#366)
Log Format "Plain Text (.txt)" and Link modifier key "None (click
directly)" were wrapping to two lines due to narrow widths.

Closes #294 (dropdown text wrapping)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:36:14 +08:00
陈大猫
6727248924 feat: add web search & URL fetch tools for AI agent (#365)
* feat: add web search and URL fetch tools for AI agent

Add web_search and url_fetch tools to Catty Agent, allowing the AI to
search the internet for current information and fetch webpage content.

- Support 5 search providers: Tavily, Exa, Bocha, Zhipu, SearXNG
- Settings UI with provider selection, API key encryption, and config
- web_search is conditional on config; url_fetch is always available
- Both tools are read-only and work in all permission modes (incl. observer)
- aiFetch skipHostCheck for AI tool requests to arbitrary URLs
- System prompt guidelines for when to use search/fetch
- i18n support (en + zh-CN)

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

* fix: address code review findings (SSRF, key exposure, state race)

- P1: Restore SSRF protection when skipHostCheck is true — still block
  localhost, RFC1918, link-local, and cloud metadata endpoints; only
  skip the domain allowlist for public HTTPS hosts
- P2: Move web search API key decryption to main process via dedicated
  IPC handler, matching the existing provider key security model
- P2: Use configRef to avoid stale closure in async settings callbacks
  that could overwrite newer user changes

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

* fix: address second review — DNS rebinding, url_fetch approval, maxResults

- P1: url_fetch now requires approval in confirm mode (outbound GET is
  a side effect that could exfiltrate data via query strings)
- P1: Add DNS resolution check when skipHostCheck is set — resolve
  hostname and reject if any IP is private/loopback/link-local, blocking
  DNS rebinding attacks against internal services
- P2: Slice search results after provider call to enforce maxResults
  consistently (Zhipu and SearXNG ignore the limit parameter)

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

* fix: address third review — localhost/IPv6 SSRF, API key blur race

- P1: Block localhost/loopback when skipHostCheck is enabled — restructure
  isAllowedFetchUrl to check private hosts first in the skipHostCheck path,
  preventing access to local services on allowlisted ports
- P1: Handle IPv6 private ranges (fc00::/7, fe80::/10, ::ffff: mapped),
  strip brackets from URL.hostname, block [::1] and fd00:: addresses
- P2: Guard handleApiKeyBlur against provider change during async
  encryption — skip stale write if provider switched while encrypting

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

* fix: address fourth review — main-process key isolation, SearXNG compat

- P1: Replace aiWebSearchDecryptKey IPC with __WEB_SEARCH_KEY__ placeholder
  pattern — renderer never sees plaintext keys; main process replaces
  placeholder in headers before HTTP request, matching provider key flow
- P1: Search API requests use normal allowlist path (not skipHostCheck),
  so SearXNG on localhost/HTTP/private networks works via aiSyncWebSearch;
  only url_fetch uses skipHostCheck for arbitrary public HTTPS URLs
- P2: Remove needsApproval from url_fetch — treat as read-only like
  sftp_read_file, consistent with observer mode allowlist

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

* fix: address fifth review — private LAN providers, maxResults default

- P1: Allow private-IP hosts that are explicitly in the provider/search
  allowlist (e.g. https://192.168.x.x model providers or SearXNG)
- P2: Remove .default(5) from web_search maxResults schema so the user's
  configured maxResults setting is used when the model omits the param

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

* fix: address sixth review — HTTPS scope, config gate, redirects

- P2: Scope HTTP exception to private/LAN IPs only — remote allowlisted
  hosts still require HTTPS to protect API keys in transit
- P2: Gate web_search tool on complete config (API key for providers that
  require it, apiHost for SearXNG) to avoid advertising a broken tool
- P2: Add redirect following (up to 5 hops) to aiFetch for url_fetch —
  handles 301/302/307 for short links, www canonicalization, etc.

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

* fix: address seventh review — redirect SSRF, decrypt race, HTTPS-only

- P1: Revalidate each redirect hop against SSRF guards (allowlist check
  + DNS resolution) before following, preventing open-redirect SSRF
- P2: Add sequence counter to API key decryption effect — stale promise
  results from a previous provider are discarded on provider switch
- P3: Restrict url_fetch to HTTPS-only URLs, matching the skipHostCheck
  policy that already rejects HTTP in the bridge

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

* fix: address eighth review — OS resolver, allowlisted HTTP hosts

- P1: Use dns.lookup (OS resolver) instead of dns.resolve4/6 for private
  IP checks — matches what http.request actually connects to, respects
  /etc/hosts, mDNS, and other local resolver sources
- P2: Allow HTTP for any explicitly allowlisted host (not just literal
  private IPs), so self-hosted SearXNG at http://searxng.lan works

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

* fix: address ninth review — HTTP scope, blur ordering, decrypt flag

- P1: Narrow HTTP exception to web search apiHost only — AI provider
  endpoints remain HTTPS-only to protect credentials in transit
- P2: Add blur sequence counter to prevent out-of-order encryption
  results from overwriting newer API key saves
- P2: Reset isDecrypting flag when cancelling decrypt on provider switch,
  preventing permanently disabled API key input

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

* fix: address tenth review — DNS pinning, prompt/tool alignment

- P1: Pin validated DNS result to the HTTP request via custom lookup
  function, preventing TOCTOU/DNS-rebinding between validation and
  actual connection
- P2: Extract isWebSearchReady() helper and use it consistently in
  both tool registration and system prompt, so the model isn't told
  web search is available when config is incomplete

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

* fix: address eleventh review — single DNS lookup, redirect pinning, CGNAT

- P1: Combine DNS validation and pinning into a single lookup call,
  eliminating the TOCTOU window between hasPrivateResolution and pinnedLookup
- P1: Pin DNS for redirect targets too — resolve/validate/pin in one step
  before following each redirect hop
- P2: Add 100.64.0.0/10 (CGNAT) to private IP ranges for Tailscale and
  similar CGNAT-addressed internal services

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

* fix: address twelfth review — apiHost validation, sync on enable

- P2: Validate apiHost is a well-formed URL in isWebSearchReady(),
  preventing tool exposure when user enters a malformed host
- P2: Add webSearchConfig.enabled to sync effect deps so the main
  process gets updated immediately when the toggle changes

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

* fix: remove DNS-level SSRF checks that break fakedns/proxy environments

DNS resolution validation (dns.lookup + IP pinning) breaks in proxy
environments where fakedns resolves all domains to LAN addresses.
Revert to hostname-level checks only (blocking localhost, 127.0.0.1,
metadata endpoints, etc.) which are sufficient without false positives.

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

* fix: resolve empty catch block lint warning

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 14:19:29 +08:00
陈大猫
0eee7bf95a Merge pull request #363 from binaricat/feat/osc52-clipboard
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
feat: add OSC-52 clipboard support
2026-03-16 22:04:39 +08:00
bincxz
b2406ec8a5 fix: auto-reject OSC-52 prompt for hidden tabs and restore focus
- Reject clipboard read requests when terminal is not visible (background
  tab), preventing invisible prompts that block remote programs
- Restore terminal focus after user responds to the prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:53:52 +08:00
bincxz
5fde9c2d61 fix: improve OSC-52 prompt UX
- Reject concurrent read requests instead of overwriting resolver
- Add autoFocus to Allow button for keyboard accessibility
- Support Escape key to deny the prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:49:47 +08:00
bincxz
06a6a0ac12 feat: add 'prompt' mode for OSC-52 clipboard reads
Add a fourth option 'Write + Prompt on Read' that allows clipboard
writes but shows a confirmation dialog before granting read access.
This lets users benefit from remote copy (tmux/vim) while maintaining
control over clipboard reads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:42:22 +08:00
bincxz
024e60ead1 fix: reject unsupported OSC-52 selection targets
Only handle clipboard target ('c'); silently ignore unsupported targets
like 'p' (PRIMARY selection) which Electron cannot access, rather than
incorrectly mapping them to the system clipboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:24:49 +08:00
bincxz
fe71790f0a fix: add osc52Clipboard to syncable terminal settings
Ensures the OSC-52 clipboard preference is preserved across cloud sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:18:54 +08:00
bincxz
9371b3d01b fix: use Electron bridge for OSC-52 read and chunk base64 encoding
- Fall back to netcattyBridge.readClipboardText() for clipboard reads
  since navigator.clipboard.readText() may be unavailable in Electron
- Chunk String.fromCharCode() calls in 8KB batches to avoid stack
  overflow on large clipboard contents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:14:25 +08:00
bincxz
5a1d279efd fix: add OSC-52 settings, UTF-8 support, and clipboard read
- Add osc52Clipboard setting (off/write-only/read-write), default write-only
- Fix UTF-8 decoding: use TextDecoder instead of atob for non-ASCII content
- Support clipboard read requests when mode is read-write
- Add settings UI with Select dropdown and i18n (en + zh-CN)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:08:11 +08:00
bincxz
8b0cbf02c3 feat: add OSC-52 clipboard support for terminal
Register an OSC-52 handler on the xterm parser to allow remote programs
(e.g. tmux, vim, neovim) to write to the local system clipboard via
escape sequences. Read requests are ignored for security.

Closes #362

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:52:29 +08:00
陈大猫
d19fe45a14 Merge pull request #361 from binaricat/fix/win-ssh-agent-pipe-detect
fix: use net.connect() for Windows SSH agent pipe detection
2026-03-16 20:40:26 +08:00
bincxz
344946b096 fix: use net.connect() for Windows SSH agent pipe detection
fs.statSync() is unreliable for Windows named pipes — it returns EBUSY
even when the pipe is fully usable, causing ssh-agent to appear
unavailable. Replaced with net.connect() which is the authoritative
check for named pipe connectivity.

Fixes #360

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:33:58 +08:00
陈大猫
fcd15707d2 Merge pull request #359 from binaricat/fix/auth-split-button
fix: split auth button for clear save/no-save options
2026-03-16 20:07:46 +08:00
bincxz
42c82e46ea fix: split auth button so "continue without save" is clearly separated
The auth dialog's "Continue and Save" button had a dropdown arrow embedded
inside it, but clicking anywhere on the button (including the arrow)
triggered save. Users expected the arrow to offer a no-save option but
couldn't discover it. Refactored to a proper split button: left side
triggers "Continue and Save", right arrow opens a dropdown with
"Continue" (without saving).

Refs #356

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:55:04 +08:00
陈大猫
0e1c3b621a Merge pull request #358 from binaricat/fix/snippet-package-rename
fix: snippet package rename losing snippets and blocking case changes
2026-03-16 19:45:31 +08:00
bincxz
3cd3bbaaf7 fix: snippet package rename losing snippets and blocking case changes
Two bugs in snippet package management:

1. Renaming a package with only case changes (e.g. Speedtest → speedtest)
   was rejected as duplicate because the case-insensitive check didn't
   exclude the package being renamed.

2. Renaming/moving/deleting a package caused its snippets to disappear
   because forEach(onSave) called the state updater multiple times with
   a stale closure, each call overwriting the previous. Only the last
   snippet's update survived. Fixed by adding onBulkSave prop that
   passes the entire updated array in one call.

Fixes #357

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:41:27 +08:00
陈大猫
8bfb50fcbb Merge pull request #355 from yuzifu/fix-distro-detect
fix distro detect
2026-03-16 19:30:54 +08:00
bincxz
c39ef879c3 fix: use effective passphrase for distro detection probe
The distro detection was using the stored key passphrase instead of the
runtime-resolved passphrase, causing silent failures when users retry
with a manually entered passphrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:22:20 +08:00
陈大猫
b3d5785477 fix: allow settings window as trusted IPC sender (#354)
* fix: allow settings window as trusted IPC sender

The settings window runs in a separate BrowserWindow with its own
webContents id. validateSender() only checked the main window id,
causing "Unauthorized IPC sender" errors when fetching AI model
lists from the settings page.

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

* fix: add validateSender to all remaining AI IPC handlers

15 handlers in aiBridge were missing sender validation, allowing
potential unauthorized IPC calls. Now every netcatty:ai:* handler
consistently validates the sender against trusted windows.

Affected handlers: chat:cancel, agents:discover, resolve-cli,
codex:get-integration, codex:start-login, codex:get-login-session,
codex:cancel-login, codex:logout, mcp:update-sessions,
mcp:set-command-blocklist, mcp:set-command-timeout,
mcp:set-max-iterations, mcp:set-permission-mode, acp:cancel,
acp:cleanup.

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

* fix: scope settings window trust to config-only IPC handlers

Per code review feedback: the previous commit allowed the settings
window to access ALL AI IPC handlers including high-risk ones like
exec, terminal:write, and agent:spawn.

Split into two validators:
- validateSender(): main window only (exec, terminal, agent, stream)
- validateSenderOrSettings(): main + settings (fetch, sync, codex
  login, MCP config, agent discovery)

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

* fix: refresh main window id on recreation and allow settings fetch

Two fixes from code review:

1. Always resolve mainWebContentsId from windowManager instead of
   caching it once, so a recreated main window is recognized.

2. Skip static host allowlist for settings window ai:fetch calls,
   since the settings UI lets users configure custom provider URLs
   that haven't been synced to providerFetchHosts yet. Basic URL
   safety (HTTPS-only, no file:// schemes) is still enforced.

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

* fix: enforce HTTPS/port safety for settings window fetch requests

Per review: previous commit skipped isAllowedFetchUrl entirely for
settings window, which removed SSRF protection. Now settings window
fetches still bypass the static host allowlist (since the user is
configuring new providers) but enforce the same safety rules:
- Remote hosts must use HTTPS
- Localhost must use known ports

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

* fix: sync provider config before fetching models in settings

Instead of bypassing the URL allowlist for settings window fetches
(which weakens SSRF protection), have ModelSelector sync the current
provider's baseURL to the backend allowlist before fetching models.
This keeps the full URL safety checks intact while allowing settings
to test custom provider endpoints.

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

* fix: use dedicated allowlist handler instead of syncing providers

Replace the approach of calling aiSyncProviders (which overwrites
the shared providerConfigs) with a new lightweight IPC handler
netcatty:ai:allowlist:add-host that only adds a host to the fetch
allowlist without affecting provider configs or API key resolution.

This preserves the SSRF protection while allowing settings to test
custom provider URLs that haven't been synced from the main window.

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

* fix: auto-expire temporary allowlist entries after 30 seconds

Temporary hosts added via allowlist:add-host now auto-remove after
30s to prevent permanently expanding the SSRF boundary. Built-in
ports and hosts re-added by provider sync are preserved.

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

* fix: prevent temp allowlist cleanup from removing synced providers

The setTimeout cleanup now checks whether the host/port belongs to
a currently synced provider config before removing it. This prevents
the scenario where a user saves a provider within the 30s TTL window
and then loses access when the timer fires.

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

* fix: preserve temp allowlist entries across provider sync rebuilds

rebuildProviderFetchHosts() clears and rebuilds the allowlist from
providerConfigs, which would wipe temporary entries added by
allowlist:add-host. Now re-adds active temp entries after rebuild
to prevent race conditions between settings model listing and
provider sync from the main window.

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-16 19:11:42 +08:00
yuzifu
05de49f7da fix distro detect
Support distro detection with passphrase keys
2026-03-16 17:32:33 +08:00
59 changed files with 2791 additions and 815 deletions

View File

@@ -270,6 +270,17 @@ const en: Messages = {
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
'settings.terminal.behavior.osc52Clipboard.off': 'Disabled',
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Write only',
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Read & Write',
'settings.terminal.behavior.osc52Clipboard.prompt': 'Write + Prompt on Read',
'terminal.osc52.readPrompt.title': 'Clipboard Read Request',
'terminal.osc52.readPrompt.desc': 'A remote program is requesting to read your clipboard. Allow?',
'terminal.osc52.readPrompt.allow': 'Allow',
'terminal.osc52.readPrompt.deny': 'Deny',
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
@@ -1407,6 +1418,7 @@ const en: Messages = {
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
// Snippet Shortkey
'snippets.field.shortkey': 'Keyboard Shortcut',
'snippets.shortkey.placeholder': 'Click to set shortcut',
@@ -1502,6 +1514,7 @@ const en: Messages = {
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
'ai.providers.refreshModels': 'Refresh models',
@@ -1607,6 +1620,21 @@ const en: Messages = {
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
// AI Web Search
'ai.webSearch.title': 'Web Search',
'ai.webSearch.enable': 'Enable Web Search',
'ai.webSearch.enable.description': 'Allow the AI agent to search the web for current information.',
'ai.webSearch.provider': 'Search Provider',
'ai.webSearch.provider.description': 'Choose a web search API provider.',
'ai.webSearch.apiKey': 'API Key',
'ai.webSearch.apiKey.description': 'API key for the selected search provider.',
'ai.webSearch.apiKey.placeholder': 'Enter API key...',
'ai.webSearch.apiHost': 'API Host',
'ai.webSearch.apiHost.description': 'Custom API endpoint. Leave default unless you use a proxy.',
'ai.webSearch.apiHost.searxngDescription': 'URL of your SearXNG instance (required).',
'ai.webSearch.maxResults': 'Max Results',
'ai.webSearch.maxResults.description': 'Maximum number of search results to return (1-20).',
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',

View File

@@ -1146,6 +1146,17 @@ const zhCN: Messages = {
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
'settings.terminal.behavior.osc52Clipboard.off': '关闭',
'settings.terminal.behavior.osc52Clipboard.writeOnly': '仅写入',
'settings.terminal.behavior.osc52Clipboard.readWrite': '读写',
'settings.terminal.behavior.osc52Clipboard.prompt': '写入 + 读取时询问',
'terminal.osc52.readPrompt.title': '剪贴板读取请求',
'terminal.osc52.readPrompt.desc': '远程程序正在请求读取您的剪贴板,是否允许?',
'terminal.osc52.readPrompt.allow': '允许',
'terminal.osc52.readPrompt.deny': '拒绝',
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
@@ -1422,6 +1433,7 @@ const zhCN: Messages = {
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
// Snippet Shortkey
'snippets.field.shortkey': '快捷键',
'snippets.shortkey.placeholder': '点击设置快捷键',
@@ -1517,6 +1529,7 @@ const zhCN: Messages = {
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
'ai.providers.refreshModels': '刷新模型列表',
@@ -1622,6 +1635,21 @@ const zhCN: Messages = {
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
// AI Web Search
'ai.webSearch.title': '网络搜索',
'ai.webSearch.enable': '启用网络搜索',
'ai.webSearch.enable.description': '允许 AI 代理搜索互联网获取最新信息。',
'ai.webSearch.provider': '搜索供应商',
'ai.webSearch.provider.description': '选择一个网络搜索 API 供应商。',
'ai.webSearch.apiKey': 'API 密钥',
'ai.webSearch.apiKey.description': '所选搜索供应商的 API 密钥。',
'ai.webSearch.apiKey.placeholder': '输入 API 密钥...',
'ai.webSearch.apiHost': 'API 地址',
'ai.webSearch.apiHost.description': '自定义 API 端点。除非使用代理,否则保持默认值。',
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 实例的 URL必填。',
'ai.webSearch.maxResults': '最大结果数',
'ai.webSearch.maxResults.description': '搜索返回的最大结果数1-20。',
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '权限模式',

View File

@@ -13,6 +13,7 @@ import {
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
import type {
AISession,
@@ -22,6 +23,7 @@ import type {
ExternalAgentConfig,
ChatMessage,
AISessionScope,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
@@ -30,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;
@@ -114,6 +124,11 @@ export function useAIState() {
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
);
// ── Web Search Config ──
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
}, []);
@@ -126,6 +141,15 @@ export function useAIState() {
});
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
} else {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
}
}, []);
// ── Persist helpers ──
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw(prev => {
@@ -282,6 +306,9 @@ export function useAIState() {
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
break;
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
}
} catch (err) {
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
@@ -357,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;
@@ -375,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;
@@ -401,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;
@@ -465,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)
@@ -541,6 +589,10 @@ export function useAIState() {
agentModelMap,
setAgentModel,
// Web search
webSearchConfig,
setWebSearchConfig,
// Sessions (per-scope active session)
sessions,
activeSessionIdMap,
@@ -549,6 +601,7 @@ export function useAIState() {
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,

View File

@@ -52,10 +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;
@@ -114,6 +117,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const syncNow = useCallback(async (options?: SyncNowOptions) => {
const trigger: SyncTrigger = options?.trigger ?? 'auto';
isSyncRunningRef.current = true;
try {
// Get fresh state directly from CloudSyncManager singleton
let state = manager.getState();
@@ -160,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) {
@@ -179,8 +193,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
} 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 () => {
@@ -203,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(() => {
@@ -231,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;
@@ -239,7 +271,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Wait for the current sync to finish, then this effect will re-run
// because sync.isSyncing changed.
if (sync.isSyncing) {
if (sync.isSyncing || isSyncRunningRef.current) {
return;
}

View File

@@ -569,6 +569,7 @@ export const useSessionState = () => {
workspaceId: workspace.id,
// Store the command to run after connection
startupCommand: snippet.command,
noAutoRun: snippet.noAutoRun,
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);

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

@@ -29,6 +29,7 @@ import type {
DiscoveredAgent,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../infrastructure/ai/types';
import { getAgentModelPresets } from '../infrastructure/ai/types';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
@@ -41,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
@@ -54,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,
@@ -82,6 +85,9 @@ interface AIChatSidePanelProps {
commandBlocklist?: string[];
maxIterations?: number;
// Web search
webSearchConfig?: WebSearchConfig | null;
// Context
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
@@ -111,6 +117,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
// -------------------------------------------------------------------
@@ -122,6 +157,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
createSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,
@@ -137,6 +173,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setGlobalPermissionMode,
commandBlocklist,
maxIterations = 20,
webSearchConfig,
scopeType,
scopeTargetId,
scopeHostIds,
@@ -161,6 +198,14 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const { images, addImages, removeImage, clearImages } = useImageUpload();
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
terminalSessionsRef.current = terminalSessions;
const scopeTypeRef = useRef(scopeType);
scopeTypeRef.current = scopeType;
const scopeTargetIdRef = useRef(scopeTargetId);
scopeTargetIdRef.current = scopeTargetId;
const scopeLabelRef = useRef(scopeLabel);
scopeLabelRef.current = scopeLabel;
// ── Streaming hook ──
const {
@@ -227,16 +272,23 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}, [providers]);
// Abort all active streams and clean up on unmount
// Sync web search config to main process (allowlist + encrypted API key for server-side decryption).
// Note: This is fire-and-forget; if the first search fires before sync completes, it will fail
// with a clear error and succeed on retry. Making this blocking would require async tool creation.
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiSyncWebSearch) {
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
}
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
// 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 {
@@ -364,6 +416,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}, [updateSessionTitle]);
const getExecutorContext = useCallback((): ExecutorContext => ({
sessions: terminalSessionsRef.current,
workspaceId: scopeTypeRef.current === 'workspace' ? scopeTargetIdRef.current : undefined,
workspaceName: scopeTypeRef.current === 'workspace' ? scopeLabelRef.current : undefined,
}), []);
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSessionId) return activeSessionId;
@@ -430,6 +488,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
try {
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
existingSessionId: currentSession?.externalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
terminalSessions,
providers,
selectedAgentModel,
@@ -452,6 +513,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
globalPermissionMode,
commandBlocklist,
terminalSessions,
webSearchConfig,
getExecutorContext,
setPendingApproval,
autoTitleSession,
});
@@ -462,8 +525,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, setInputValue, clearImages,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, providers, selectedAgentModel,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, setPendingApproval,
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, getExecutorContext, setPendingApproval,
]);
const handleStop = useCallback(() => {
@@ -476,7 +539,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) {
@@ -500,8 +563,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
},
@@ -584,6 +645,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
scopeLabel,
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
terminalSessions,
@@ -592,6 +654,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
scopeLabel,
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
/>

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,6 +61,17 @@ 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,
depth,
@@ -89,6 +100,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 childNodes = useMemo(() => {
if (!node.children) return [];
@@ -171,7 +183,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
)}
{(node.hosts.length > 0 || hasChildren) && (
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
{node.hosts.length}
{hostsCountInNode}
</span>
)}
</div>

View File

@@ -16,7 +16,7 @@ import { ScrollArea } from './ui/scroll-area';
interface ScriptsSidePanelProps {
snippets: Snippet[];
packages: string[];
onSnippetClick: (command: string) => void;
onSnippetClick: (command: string, noAutoRun?: boolean) => void;
isVisible?: boolean;
}
@@ -115,8 +115,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
});
}, [selectedPackage]);
const handleSnippetClick = useCallback((command: string) => {
onSnippetClick(command);
const handleSnippetClick = useCallback((command: string, noAutoRun?: boolean) => {
onSnippetClick(command, noAutoRun);
}, [onSnippetClick]);
if (!isVisible) return null;
@@ -196,7 +196,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
{displayedSnippets.map((s) => (
<button
key={s.id}
onClick={() => handleSnippetClick(s.command)}
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
>
<span className="text-xs font-medium truncate">{s.label}</span>

View File

@@ -284,6 +284,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</React.Suspense>
</AITabErrorBoundary>

View File

@@ -28,6 +28,7 @@ interface SnippetsManagerProps {
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
onSave: (snippet: Snippet) => void;
onBulkSave: (snippets: Snippet[]) => void;
onDelete: (id: string) => void;
onPackagesChange: (packages: string[]) => void;
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
@@ -51,6 +52,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
hotkeyScheme,
keyBindings,
onSave,
onBulkSave,
onDelete,
onPackagesChange,
onRunSnippet,
@@ -300,6 +302,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
package: editingSnippet.package || '',
targets: targetSelection,
shortkey: editingSnippet.shortkey,
noAutoRun: editingSnippet.noAutoRun,
});
setRightPanelMode('none');
}
@@ -486,11 +489,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
// Update packages first, then save snippets
onPackagesChange(keep);
// Only save snippets that were actually modified
const modifiedSnippets = updatedSnippets.filter((s, index) =>
s.package !== snippets[index].package
);
modifiedSnippets.forEach(onSave);
// Bulk-save all snippets to avoid stale-closure overwrites
onBulkSave(updatedSnippets);
// Reset selected package if it was deleted
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
@@ -527,7 +527,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
onBulkSave(updatedSnippets);
if (selectedPackage === source) setSelectedPackage(newPath);
};
@@ -568,8 +568,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
return;
}
// Validate: duplicate (case-insensitive)
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
// Validate: duplicate (case-insensitive), excluding the package being renamed
const existingPackage = packages.find(p => p !== renamingPackagePath && p.toLowerCase() === newPath.toLowerCase());
if (existingPackage) {
setRenameError(t('snippets.renameDialog.error.duplicate'));
return;
@@ -595,7 +595,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
onBulkSave(updatedSnippets);
// Update selected package if it was renamed
if (selectedPackage === renamingPackagePath) {
@@ -792,6 +792,17 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
/>
</Card>
{/* No Auto Run */}
<label className="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
checked={editingSnippet.noAutoRun ?? false}
onChange={(e) => setEditingSnippet({ ...editingSnippet, noAutoRun: e.target.checked || undefined })}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
</label>
{/* Shortkey */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">

View File

@@ -4,7 +4,7 @@ import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
// flushSync removed - no longer needed
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
@@ -118,12 +118,13 @@ interface TerminalProps {
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
noAutoRun?: boolean;
serialConfig?: SerialConfig;
hotkeyScheme?: "disabled" | "mac" | "pc";
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;
@@ -184,6 +185,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings,
sessionId,
startupCommand,
noAutoRun,
serialConfig,
hotkeyScheme = "disabled",
keyBindings = [],
@@ -238,22 +240,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (xtermRuntimeRef.current) {
// Merge global rules with host-level rules
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
// Check if highlighting is enabled at either global or host level
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
// Host-level toggle: undefined = inherit global, true/false = explicit override
const hostEnabled = host?.keywordHighlightEnabled;
// 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;
// Merge rules: include only rules from enabled sources
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
...(effectiveGlobalEnabled ? globalRules : []),
...(effectiveHostEnabled ? hostRules : [])
];
// Enable highlighting if either global or host-level is enabled
const isEnabled = globalEnabled || hostEnabled;
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
}
@@ -371,6 +374,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
const pendingConnectionRef = useRef<(() => void) | null>(null);
// OSC-52 clipboard read prompt
const [osc52ReadPromptVisible, setOsc52ReadPromptVisible] = useState(false);
const osc52ReadResolverRef = useRef<((allowed: boolean) => void) | null>(null);
const handleOsc52ReadRequest = useCallback((): Promise<boolean> => {
// Reject if terminal is not visible (background tab) — user can't see the prompt
if (!isVisibleRef.current) return Promise.resolve(false);
// Reject if another prompt is already pending (avoid resolver overwrite)
if (osc52ReadResolverRef.current) return Promise.resolve(false);
return new Promise((resolve) => {
osc52ReadResolverRef.current = resolve;
setOsc52ReadPromptVisible(true);
});
}, []);
const handleOsc52ReadResponse = useCallback((allowed: boolean) => {
setOsc52ReadPromptVisible(false);
osc52ReadResolverRef.current?.(allowed);
osc52ReadResolverRef.current = null;
// Restore focus to terminal
termRef.current?.focus();
}, []);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
@@ -427,6 +451,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
resolvedChainHosts,
sessionId,
startupCommand,
noAutoRun,
terminalSettings,
terminalSettingsRef,
terminalBackend,
@@ -502,6 +527,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
onOsc52ReadRequest: handleOsc52ReadRequest,
});
xtermRuntimeRef.current = runtime;
@@ -516,12 +542,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled;
const effectiveGlobalEnabled = globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
...(effectiveGlobalEnabled ? globalRules : []),
...(effectiveHostEnabled ? hostRules : [])
];
const isEnabled = globalEnabled || hostEnabled;
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
const term = runtime.term;
@@ -1678,6 +1706,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
)}
{/* OSC-52 clipboard read prompt */}
{osc52ReadPromptVisible && (
<div
className="absolute inset-0 z-40 flex items-center justify-center bg-background/60"
onKeyDown={(e) => {
if (e.key === 'Escape') handleOsc52ReadResponse(false);
}}
>
<div className="rounded-lg border bg-card p-4 shadow-lg max-w-sm space-y-3">
<p className="text-sm font-medium">{t("terminal.osc52.readPrompt.title")}</p>
<p className="text-sm text-muted-foreground">{t("terminal.osc52.readPrompt.desc")}</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => handleOsc52ReadResponse(false)}>
{t("terminal.osc52.readPrompt.deny")}
</Button>
<Button size="sm" autoFocus onClick={() => handleOsc52ReadResponse(true)}>
{t("terminal.osc52.readPrompt.allow")}
</Button>
</div>
</div>
</div>
)}
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
{status !== "connected" && !needsHostKeyVerification && !(
(isLocalConnection || isSerialConnection) && status === "connecting"

View File

@@ -171,9 +171,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onUpdateSessionStatus(sessionId, status);
}, [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);
@@ -864,10 +871,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [handleOpenAI]);
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((command: string) => {
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
if (!sessionId) return;
const payload = `${command}\r`;
const payload = noAutoRun ? command : `${command}\r`;
terminalBackend.writeToSession(sessionId, payload);
// Re-focus the terminal so the user can interact immediately
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
@@ -929,15 +936,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);
@@ -1333,6 +1332,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}
@@ -1348,6 +1348,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
commandBlocklist={aiState.commandBlocklist}
maxIterations={aiState.maxIterations}
webSearchConfig={aiState.webSearchConfig}
scopeType={activeWorkspace ? 'workspace' : 'terminal'}
scopeTargetId={activeWorkspace?.id ?? activeSession?.id}
scopeHostIds={activeWorkspace?.root
@@ -1357,7 +1358,7 @@ 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}
/>
</div>
@@ -1487,6 +1488,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}
noAutoRun={session.noAutoRun}
serialConfig={session.serialConfig}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}

View File

@@ -2201,6 +2201,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
: [...snippets, s],
)
}
onBulkSave={onUpdateSnippets}
onDelete={(id) =>
onUpdateSnippets(snippets.filter((s) => s.id !== id))
}

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

@@ -30,6 +30,11 @@ 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) ?? []),
);
if (visibleMessages.length === 0 && !isStreaming) {
return (
@@ -107,6 +112,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)}
/>
))}

View File

@@ -10,7 +10,7 @@
* - 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,
@@ -18,11 +18,13 @@ import type {
ChatMessage,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
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';
@@ -93,6 +95,7 @@ type StreamChunk =
export interface PanelBridge extends NetcattyBridge {
credentialsDecrypt?: (value: string) => Promise<string>;
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
@@ -138,6 +141,20 @@ 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
// -------------------------------------------------------------------
@@ -203,12 +220,17 @@ export interface SendToCattyContext {
globalPermissionMode: AIPermissionMode;
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;
@@ -225,17 +247,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
@@ -247,7 +286,11 @@ export function useAIChatStreaming({
err: unknown,
) => {
if (abortSignal.aborted) return;
const errorStr = err instanceof Error ? err.message : String(err);
let errorStr: string;
if (err instanceof Error) errorStr = err.message;
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
else if (typeof err === 'string') errorStr = err;
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
// Log the full unsanitized error for debugging
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
const errorInfo = classifyError(errorStr);
@@ -460,7 +503,11 @@ export function useAIChatStreaming({
id: generateId(),
role: 'assistant',
content: '',
errorInfo: classifyError(String(typedChunk.error)),
errorInfo: classifyError(
typedChunk.error instanceof Error ? typedChunk.error.message
: typeof typedChunk.error === 'string' ? typedChunk.error
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
),
timestamp: Date.now(),
});
break;
@@ -569,6 +616,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);
@@ -578,6 +628,8 @@ export function useAIChatStreaming({
abortController.signal,
agentProviderId,
context.selectedAgentModel,
context.existingSessionId,
context.historyMessages,
attachedImages.length > 0 ? attachedImages : undefined,
);
} else {
@@ -617,11 +669,18 @@ export function useAIChatStreaming({
context: SendToCattyContext,
) => {
const bridge = getNetcattyBridge();
const tools = createCattyTools(bridge, {
const toolContext = context.getExecutorContext ?? (() => ({
sessions: context.terminalSessions,
workspaceId: context.scopeTargetId,
workspaceName: context.scopeLabel,
}, context.commandBlocklist, context.globalPermissionMode);
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
workspaceName: context.scopeType === 'workspace' ? context.scopeLabel : undefined,
}));
const tools = createCattyTools(
bridge,
toolContext,
context.commandBlocklist,
context.globalPermissionMode,
context.webSearchConfig ?? undefined,
);
const systemPrompt = buildSystemPrompt({
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
@@ -630,6 +689,7 @@ export function useAIChatStreaming({
os: s.os, username: s.username, connected: s.connected,
})),
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
});
// Guard: activeProvider must exist for Catty agent path
@@ -656,13 +716,35 @@ export function useAIChatStreaming({
try {
// Issue #5: Build SDK messages including tool-call and tool-result messages
// so the LLM maintains full conversation context
const allMessages = currentSession?.messages ?? [];
// Collect all tool call IDs that have a corresponding tool result,
// so we can skip orphaned tool calls (e.g. from user stopping mid-execution)
const resolvedToolCallIds = new Set<string>();
for (const m of allMessages) {
if (m.role === 'tool' && m.toolResults) {
for (const tr of m.toolResults) resolvedToolCallIds.add(tr.toolCallId);
}
}
const findToolName = (toolCallId: string): string => {
for (const prev of allMessages) {
if (prev.role === 'assistant' && prev.toolCalls) {
const tc = prev.toolCalls.find(t => t.id === toolCallId);
if (tc) return tc.name;
}
}
return 'unknown';
};
const sdkMessages: Array<ModelMessage> = [];
for (const m of (currentSession?.messages ?? [])) {
for (const m of allMessages) {
if (m.role === 'user') {
sdkMessages.push({ role: 'user', content: m.content });
} else if (m.role === 'assistant') {
if (m.toolCalls?.length) {
// Build assistant content parts: text + tool calls
// Only include tool calls that have matching results
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
const contentParts: Array<
{ type: 'text'; text: string } |
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
@@ -670,7 +752,7 @@ export function useAIChatStreaming({
if (m.content) {
contentParts.push({ type: 'text' as const, text: m.content });
}
for (const tc of m.toolCalls) {
for (const tc of resolvedCalls) {
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
@@ -678,23 +760,14 @@ export function useAIChatStreaming({
input: tc.arguments ?? {},
});
}
sdkMessages.push({ role: 'assistant', content: contentParts });
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
sdkMessages.push({ role: 'assistant', content: contentParts.length === 1 && contentParts[0].type === 'text' ? (contentParts[0] as { type: 'text'; text: string }).text : contentParts });
}
} else if (m.content) {
sdkMessages.push({ role: 'assistant', content: m.content });
}
} else if (m.role === 'tool' && m.toolResults?.length) {
// Map tool results to SDK tool message format
// Gemini requires functionResponse.name to be non-empty,
// so we look up the toolName from the preceding assistant tool calls.
const findToolName = (toolCallId: string): string => {
for (const prev of currentSession?.messages ?? []) {
if (prev.role === 'assistant' && prev.toolCalls) {
const tc = prev.toolCalls.find(t => t.id === toolCallId);
if (tc) return tc.name;
}
}
return 'unknown';
};
sdkMessages.push({
role: 'tool',
content: m.toolResults.map(tr => ({

View File

@@ -13,7 +13,9 @@ import type { ModelMessage } from 'ai';
import type {
AIPermissionMode,
ChatMessage,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
@@ -29,6 +31,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
// -------------------------------------------------------------------
@@ -76,6 +81,7 @@ export interface ToolApprovalContext {
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
webSearchConfig?: WebSearchConfig | null;
}
// -------------------------------------------------------------------
@@ -92,23 +98,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();
@@ -126,7 +132,7 @@ export function useToolApproval({
timestamp: Date.now(),
});
}
pendingApprovalTimeoutRef.current = null;
sharedPendingApprovalTimeout = null;
}, 5 * 60 * 1000); // 5 minutes
}
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
@@ -216,9 +222,9 @@ export function useToolApproval({
const bridge = getNetcattyBridge();
const freshTools = createCattyTools(bridge, {
sessions: approvalContext.terminalSessions,
workspaceId: approvalContext.scopeTargetId,
workspaceName: approvalContext.scopeLabel,
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode);
workspaceId: approvalContext.scopeType === 'workspace' ? approvalContext.scopeTargetId : undefined,
workspaceName: approvalContext.scopeType === 'workspace' ? approvalContext.scopeLabel : undefined,
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode, approvalContext.webSearchConfig ?? undefined);
const freshSystemPrompt = buildSystemPrompt({
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
hosts: approvalContext.terminalSessions.map(s => ({
@@ -226,6 +232,7 @@ export function useToolApproval({
os: s.os, username: s.username, connected: s.connected,
})),
permissionMode: approvalContext.globalPermissionMode,
webSearchEnabled: isWebSearchReady(approvalContext.webSearchConfig),
});
const newApprovalInfo = await processCattyStream(sid, ctxModel, freshSystemPrompt, freshTools, resumeMessages as unknown as ModelMessage[], abortController.signal, newAssistantMsgId);

View File

@@ -14,6 +14,7 @@ import type {
AIProviderId,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
@@ -38,6 +39,7 @@ import { AddProviderDropdown } from "./ai/AddProviderDropdown";
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
// ---------------------------------------------------------------------------
// Props
@@ -64,6 +66,8 @@ interface SettingsAITabProps {
setCommandTimeout: (value: number) => void;
maxIterations: number;
setMaxIterations: (value: number) => void;
webSearchConfig: WebSearchConfig | null;
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
// ---------------------------------------------------------------------------
@@ -91,6 +95,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCommandTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
}) => {
const { t } = useI18n();
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
@@ -508,6 +514,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</div>
)}
{/* -- Web Search Section -- */}
<WebSearchSettings
webSearchConfig={webSearchConfig}
setWebSearchConfig={setWebSearchConfig}
/>
{/* -- Safety Section -- */}
<SafetySettings
globalPermissionMode={globalPermissionMode}

View File

@@ -597,7 +597,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
value={sessionLogsFormat}
options={formatOptions}
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
className="w-32"
className="w-44"
disabled={!sessionLogsEnabled}
/>
</SettingRow>

View File

@@ -575,6 +575,23 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.osc52Clipboard")}
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
>
<Select
value={terminalSettings.osc52Clipboard ?? 'write-only'}
options={[
{ value: "off", label: t("settings.terminal.behavior.osc52Clipboard.off") },
{ value: "write-only", label: t("settings.terminal.behavior.osc52Clipboard.writeOnly") },
{ value: "read-write", label: t("settings.terminal.behavior.osc52Clipboard.readWrite") },
{ value: "prompt", label: t("settings.terminal.behavior.osc52Clipboard.prompt") },
]}
onChange={(v) => updateTerminalSetting("osc52Clipboard", v as "off" | "write-only" | "read-write" | "prompt")}
className="w-40"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.scrollOnInput")}
description={t("settings.terminal.behavior.scrollOnInput.desc")}
@@ -616,7 +633,7 @@ export default function SettingsTerminalTab(props: {
{ value: "meta", label: t("settings.terminal.behavior.linkModifier.meta") },
]}
onChange={(v) => updateTerminalSetting("linkModifier", v as LinkModifier)}
className="w-40"
className="w-48"
/>
</SettingRow>
</div>

View File

@@ -15,7 +15,8 @@ export const ModelSelector: React.FC<{
placeholder?: string;
apiKey?: string;
providerId?: AIProviderId;
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId }) => {
skipTLSVerify?: boolean;
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, skipTLSVerify }) => {
const { t } = useI18n();
const [models, setModels] = useState<FetchedModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -35,6 +36,11 @@ export const ModelSelector: React.FC<{
setIsLoading(true);
setError(null);
try {
// Temporarily allow the provider's host in the backend fetch allowlist
// so model listing works for URLs not yet synced from the main window.
if (bridge.aiAllowlistAddHost && baseURL) {
await bridge.aiAllowlistAddHost(baseURL);
}
const url = `${baseURL.replace(/\/+$/, "")}${modelsEndpoint}`;
const headers: Record<string, string> = {};
if (apiKey) {
@@ -45,7 +51,7 @@ export const ModelSelector: React.FC<{
headers["Authorization"] = `Bearer ${apiKey}`;
}
}
const result = await bridge.aiFetch(url, "GET", headers);
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
setError(`Failed to fetch models (${result.error || "unknown error"})`);
return;
@@ -63,7 +69,7 @@ export const ModelSelector: React.FC<{
} finally {
setIsLoading(false);
}
}, [baseURL, modelsEndpoint, apiKey, providerId]);
}, [baseURL, modelsEndpoint, apiKey, providerId, skipTLSVerify]);
// Auto-fetch when dropdown first opens
useEffect(() => {

View File

@@ -19,6 +19,7 @@ export const ProviderConfigForm: React.FC<{
apiKey: "",
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
defaultModel: provider.defaultModel ?? "",
skipTLSVerify: provider.skipTLSVerify ?? false,
});
const isCustom = provider.providerId === "custom";
const [showApiKey, setShowApiKey] = useState(false);
@@ -46,6 +47,7 @@ export const ProviderConfigForm: React.FC<{
const updates: Partial<ProviderConfig> = {
baseURL: form.baseURL || undefined,
defaultModel: form.defaultModel || undefined,
skipTLSVerify: form.skipTLSVerify || undefined,
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
};
@@ -120,9 +122,21 @@ export const ProviderConfigForm: React.FC<{
modelsEndpoint={preset?.modelsEndpoint}
apiKey={form.apiKey}
providerId={provider.providerId}
skipTLSVerify={form.skipTLSVerify}
/>
</div>
{/* Skip TLS Verification */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.skipTLSVerify}
onChange={(e) => setForm((prev) => ({ ...prev, skipTLSVerify: e.target.checked }))}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
</label>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<Button variant="default" size="sm" onClick={() => void handleSave()}>

View File

@@ -0,0 +1,220 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Globe, Eye, EyeOff } from "lucide-react";
import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types";
import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Select, SettingRow } from "../../settings-ui";
const SEARCH_ICON_PATHS: Record<WebSearchProviderId, string> = {
tavily: "/ai/search/tavily.svg",
exa: "/ai/search/exa.png",
bocha: "/ai/search/bocha.webp",
zhipu: "/ai/search/zhipu.png",
searxng: "/ai/search/searxng.svg",
};
const SearchProviderIcon: React.FC<{ providerId: WebSearchProviderId }> = ({ providerId }) => (
<img
src={SEARCH_ICON_PATHS[providerId]}
alt=""
className="w-4 h-4 shrink-0"
/>
);
const PROVIDER_OPTIONS: Array<{ value: WebSearchProviderId; label: string; icon: React.ReactNode }> = Object.entries(
WEB_SEARCH_PROVIDER_PRESETS,
).map(([id, preset]) => ({
value: id as WebSearchProviderId,
label: preset.name,
icon: <SearchProviderIcon providerId={id as WebSearchProviderId} />,
}));
export const WebSearchSettings: React.FC<{
webSearchConfig: WebSearchConfig | null;
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}> = ({ webSearchConfig, setWebSearchConfig }) => {
const { t } = useI18n();
const [apiKeyInput, setApiKeyInput] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const config = useMemo(() => webSearchConfig ?? {
providerId: "tavily" as WebSearchProviderId,
enabled: false,
maxResults: 5,
}, [webSearchConfig]);
// Ref to always read the latest config in async callbacks (avoids stale closure)
const configRef = useRef(config);
configRef.current = config;
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
// Decrypt API key on mount or when provider changes (with cancellation guard)
const decryptSeqRef = useRef(0);
useEffect(() => {
if (config.apiKey) {
const seq = ++decryptSeqRef.current;
setIsDecrypting(true);
decryptField(config.apiKey)
.then((decrypted) => {
if (decryptSeqRef.current === seq) setApiKeyInput(decrypted ?? "");
})
.catch(() => {
if (decryptSeqRef.current === seq) setApiKeyInput(config.apiKey ?? "");
})
.finally(() => {
if (decryptSeqRef.current === seq) setIsDecrypting(false);
});
} else {
decryptSeqRef.current++;
setApiKeyInput("");
setIsDecrypting(false);
}
}, [config.apiKey, config.providerId]);
const updateConfig = useCallback(
(updates: Partial<WebSearchConfig>) => {
setWebSearchConfig({ ...configRef.current, ...updates });
},
[setWebSearchConfig],
);
const handleProviderChange = useCallback(
(val: string) => {
const providerId = val as WebSearchProviderId;
const newPreset = WEB_SEARCH_PROVIDER_PRESETS[providerId];
setWebSearchConfig({
...configRef.current,
providerId,
apiKey: undefined,
apiHost: newPreset.defaultApiHost || undefined,
});
setApiKeyInput("");
},
[setWebSearchConfig],
);
// Sequence counter for blur saves — prevents out-of-order encryption results
const blurSeqRef = useRef(0);
const handleApiKeyBlur = useCallback(async () => {
if (!apiKeyInput.trim()) {
blurSeqRef.current++;
updateConfig({ apiKey: undefined });
return;
}
const seq = ++blurSeqRef.current;
const providerAtBlur = configRef.current.providerId;
const encrypted = await encryptField(apiKeyInput.trim());
// Only apply if this is still the latest blur and provider hasn't changed
if (blurSeqRef.current === seq && configRef.current.providerId === providerAtBlur) {
updateConfig({ apiKey: encrypted });
}
}, [apiKeyInput, updateConfig]);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("ai.webSearch.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-1">
{/* Enable/Disable */}
<SettingRow
label={t("ai.webSearch.enable")}
description={t("ai.webSearch.enable.description")}
>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => updateConfig({ enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-muted-foreground/20 peer-focus-visible:ring-2 peer-focus-visible:ring-ring rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:border-gray-300 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
</label>
</SettingRow>
{/* Provider */}
<SettingRow
label={t("ai.webSearch.provider")}
description={t("ai.webSearch.provider.description")}
>
<Select
value={config.providerId}
options={PROVIDER_OPTIONS}
onChange={handleProviderChange}
className="w-48"
/>
</SettingRow>
{/* API Key (hidden for SearXNG) */}
{preset.requiresApiKey && (
<SettingRow
label={t("ai.webSearch.apiKey")}
description={t("ai.webSearch.apiKey.description")}
>
<div className="flex items-center gap-1.5">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : apiKeyInput}
placeholder={isDecrypting ? t("ai.providers.apiKey.decrypting") : t("ai.webSearch.apiKey.placeholder")}
onChange={(e) => setApiKeyInput(e.target.value)}
onBlur={() => void handleApiKeyBlur()}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isDecrypting}
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</SettingRow>
)}
{/* API Host */}
<SettingRow
label={t("ai.webSearch.apiHost")}
description={
config.providerId === "searxng"
? t("ai.webSearch.apiHost.searxngDescription")
: t("ai.webSearch.apiHost.description")
}
>
<input
type="text"
value={config.apiHost ?? preset.defaultApiHost}
onChange={(e) => updateConfig({ apiHost: e.target.value || undefined })}
placeholder={preset.defaultApiHost || "https://..."}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
{/* Max Results */}
<SettingRow
label={t("ai.webSearch.maxResults")}
description={t("ai.webSearch.maxResults.description")}
>
<input
type="number"
value={config.maxResults ?? 5}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val >= 1 && val <= 20) {
updateConfig({ maxResults: val });
}
}}
min={1}
max={20}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
</div>
</div>
);
};

View File

@@ -41,6 +41,7 @@ export interface ProviderFormState {
apiKey: string;
baseURL: string;
defaultModel: string;
skipTLSVerify: boolean;
}
export interface FetchedModel {
@@ -49,7 +50,8 @@ export interface FetchedModel {
}
export interface FetchBridge {
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string) => Promise<{ ok: boolean; data: string; error?: string }>;
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; data: string; error?: string }>;
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
}
export interface NetcattyAiBridge {

View File

@@ -10,6 +10,7 @@ import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
export type TerminalAuthMethod = 'password' | 'key' | 'certificate';
@@ -265,25 +266,34 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
<Button variant="secondary" onClick={onCancel}>
{t("common.close")}
</Button>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button disabled={!isValid} onClick={onSubmit}>
{t("terminal.auth.continueSave")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1 z-50" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={onSubmitWithoutSave ?? onSubmit}
<Dropdown>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
disabled={!isValid}
onClick={onSubmit}
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
>
{t("terminal.auth.continueSave")}
</Button>
<DropdownTrigger asChild>
<Button
disabled={!isValid}
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
>
{t("common.continue")}
</button>
</PopoverContent>
</Popover>
</div>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44 p-1 z-50" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={onSubmitWithoutSave ?? onSubmit}
disabled={!isValid}
>
{t("common.continue")}
</button>
</DropdownContent>
</Dropdown>
</div>
</>
);

View File

@@ -11,6 +11,9 @@ import {
} from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
/** Timeout of distro detection task */
const DISTRO_DETECT_TIMEOUT = 8000; // ms
type TerminalBackendApi = {
backendAvailable: () => boolean;
telnetAvailable: () => boolean;
@@ -38,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,
@@ -68,6 +71,7 @@ export type TerminalSessionStartersContext = {
resolvedChainHosts: Host[];
sessionId: string;
startupCommand?: string;
noAutoRun?: boolean;
terminalSettings?: TerminalSettings;
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
terminalBackend: TerminalBackendApi;
@@ -96,7 +100,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?: (
@@ -209,13 +213,13 @@ const attachSessionToTerminal = (
}
}
ctx.onSessionExit?.(ctx.sessionId);
ctx.onSessionExit?.(ctx.sessionId, evt);
});
};
const runDistroDetection = async (
ctx: TerminalSessionStartersContext,
auth: { username: string; password?: string; key?: SSHKey },
auth: { username: string; password?: string; key?: SSHKey; passphrase?: string },
) => {
if (!ctx.terminalBackend.execAvailable()) return;
try {
@@ -225,8 +229,9 @@ const runDistroDetection = async (
port: ctx.host.port || 22,
password: auth.password,
privateKey: auth.key?.privateKey,
passphrase: auth.passphrase ?? auth.key?.passphrase,
command: "cat /etc/os-release 2>/dev/null || uname -a",
timeout: 8000,
timeout: DISTRO_DETECT_TIMEOUT,
});
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
@@ -535,8 +540,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// Guard against stale timers: if the session changed (e.g. user
// clicked Start Over quickly), skip to avoid double execution
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
if (ctx.onCommandExecuted) {
const suffix = ctx.noAutoRun ? '' : '\r';
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`);
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
@@ -573,6 +579,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
username: effectiveUsername,
password: usedPassword,
key: usedKey,
passphrase: effectivePassphrase,
}),
600,
);
@@ -656,8 +663,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const scheduledSessionId = id;
setTimeout(() => {
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
if (ctx.onCommandExecuted) {
const suffix = ctx.noAutoRun ? '' : '\r';
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}${suffix}`);
if (!ctx.noAutoRun && ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
}
}, 600);
@@ -746,7 +754,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);

View File

@@ -94,6 +94,9 @@ export type CreateXTermRuntimeContext = {
// Callback when shell reports CWD change via OSC 7
onCwdChange?: (cwd: string) => void;
// Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
onOsc52ReadRequest?: () => Promise<boolean>;
};
const detectPlatform = (): XTermPlatform => {
@@ -384,12 +387,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
e.preventDefault();
e.stopPropagation();
// Send the snippet command to the terminal
const payload = `${normalizeLineEndings(snippet.command)}\r`;
const payload = snippet.noAutoRun
? normalizeLineEndings(snippet.command)
: `${normalizeLineEndings(snippet.command)}\r`;
ctx.terminalBackend.writeToSession(id, payload);
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
}
if (ctx.onCommandExecuted) {
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
const cmd = snippet.command.trim();
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
ctx.commandBufferRef.current = "";
@@ -614,6 +619,78 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return true; // Indicate we handled the sequence
});
// OSC 52 — clipboard integration
// Format: 52;<target>;<base64-data> (write) or 52;<target>;? (query/read)
// <target> is typically "c" (clipboard) or "p" (primary selection)
// Controlled by terminalSettings.osc52Clipboard: 'off' | 'write-only' | 'read-write'
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
const settings = ctx.terminalSettingsRef.current;
const mode = settings?.osc52Clipboard ?? 'write-only';
if (mode === 'off') return true;
try {
const semi = data.indexOf(';');
if (semi < 0) return true;
const target = data.substring(0, semi);
// Only handle clipboard target ('c'); reject unsupported targets like 'p' (PRIMARY)
if (target !== 'c' && target !== '') return true;
const payload = data.substring(semi + 1);
if (payload === '?') {
// Read request — allowed in read-write mode, or prompt user in prompt mode
if (mode !== 'read-write' && mode !== 'prompt') {
logger.debug('[XTerm] OSC 52 read request ignored (mode:', mode, ')');
return true;
}
const sessionId = ctx.sessionRef.current;
if (!sessionId) return true;
// Use Electron bridge as primary, fall back to navigator.clipboard
const readClipboard = async (): Promise<string> => {
try {
const bridge = netcattyBridge.get();
if (bridge?.readClipboardText) return await bridge.readClipboardText();
} catch { /* fall through to navigator.clipboard */ }
return navigator.clipboard.readText();
};
const doRead = async () => {
// In prompt mode, ask user first
if (mode === 'prompt') {
const allowed = ctx.onOsc52ReadRequest ? await ctx.onOsc52ReadRequest() : false;
if (!allowed) {
logger.debug('[XTerm] OSC 52 read denied by user');
return;
}
}
const text = await readClipboard();
// Chunked base64 encoding to avoid stack overflow on large payloads
const bytes = new TextEncoder().encode(text);
let binary = '';
for (let i = 0; i < bytes.length; i += 8192) {
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
}
const b64 = btoa(binary);
ctx.terminalBackend.writeToSession(sessionId, `\x1b]52;${target};${b64}\x07`);
};
doRead().catch((err) => {
logger.warn('[XTerm] OSC 52 clipboard read failed:', err);
});
return true;
}
// Write: payload is base64-encoded UTF-8 text
const binary = atob(payload);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const text = new TextDecoder().decode(bytes);
navigator.clipboard.writeText(text).catch((err) => {
logger.warn('[XTerm] OSC 52 clipboard write failed:', err);
});
logger.debug('[XTerm] OSC 52 clipboard write', { length: text.length });
} catch (err) {
logger.warn('[XTerm] Failed to handle OSC 52:', err);
}
return true;
});
let resizeTimeout: NodeJS.Timeout | null = null;
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
term.onResize(({ cols, rows }) => {
@@ -639,6 +716,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
cleanupMiddleClick?.();
keywordHighlighter.dispose();
osc7Disposable.dispose();
osc52Disposable.dispose();
try {
term.dispose();
} catch (err) {

View File

@@ -149,6 +149,7 @@ export interface Snippet {
package?: string; // package path
targets?: string[]; // host ids
shortkey?: string; // Keyboard shortcut to send this snippet in terminal (e.g., "F1", "Ctrl + F1")
noAutoRun?: boolean; // If true, paste command without executing (no trailing Enter)
}
export interface TerminalLine {
@@ -434,6 +435,9 @@ export interface TerminalSettings {
// Paste
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
// Clipboard
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
// Rendering
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
}
@@ -541,6 +545,7 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
showServerStats: true, // Show server stats by default
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
disableBracketedPaste: false, // Bracketed paste enabled by default
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
rendererType: 'auto', // Auto-detect best renderer based on hardware
};
@@ -582,6 +587,7 @@ export interface TerminalSession {
status: 'connecting' | 'connected' | 'disconnected';
workspaceId?: string;
startupCommand?: string; // Command to run after connection (for snippet runner)
noAutoRun?: boolean; // If true, paste command without auto-executing
// Connection-time protocol overrides (used instead of looking up from hosts)
protocol?: 'ssh' | 'telnet' | 'local' | 'serial';
port?: number;

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
@@ -275,10 +276,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 +315,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 +408,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

@@ -75,7 +75,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
] as const;
/**

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) => {
@@ -165,26 +168,44 @@ function execViaChannel(sshClient, command, options) {
return;
}
if (!execStream) {
resolve({ ok: false, output: 'Failed to create exec stream', exitCode: 1 });
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);
resolve({ ok: code === 0, stdout, stderr, exitCode: code });
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) {
finish({ ok: false, stdout, stderr, exitCode: -1, error: "Command terminated unexpectedly (connection lost or signal)" });
} else {
finish({ ok: code === 0, stdout, stderr, exitCode: code });
}
});
});
});

View File

@@ -56,10 +56,16 @@ 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:";
let providerConfigs = [];
// Web search config (synced from renderer — apiKey stays encrypted, decrypted on use)
let webSearchApiHost = null;
let webSearchApiKeyEncrypted = null;
/**
* Decrypt an API key using Electron's safeStorage.
@@ -94,8 +100,17 @@ function resolveProviderApiKey(providerId) {
};
}
/** Check if TLS verification should be skipped for a given provider. */
function shouldSkipTLSVerify(providerId) {
if (!providerId) return false;
const config = providerConfigs.find(p => p.id === providerId);
return config?.skipTLSVerify === true;
}
/** Placeholder token used by the renderer to avoid sending real API keys over IPC. */
const API_KEY_PLACEHOLDER = "__IPC_SECURED__";
/** Placeholder for web search API key — replaced in main process before HTTP request. */
const WEB_SEARCH_KEY_PLACEHOLDER = "__WEB_SEARCH_KEY__";
/**
* Replace the API key placeholder in HTTP headers and URL with the real decrypted key.
@@ -125,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();
@@ -134,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.
*/
@@ -165,25 +248,51 @@ function init(deps) {
}
/**
* Validate that an IPC event sender is the main window's webContents.
* Validate that an IPC event sender is the main window.
* Returns true if valid, false otherwise.
*/
function validateSender(event) {
// Lazily resolve mainWebContentsId if not yet set
if (mainWebContentsId == null) {
try {
const windowManager = require("./windowManager.cjs");
const mainWin = windowManager.getMainWindow?.();
if (mainWin && !mainWin.isDestroyed?.()) {
mainWebContentsId = mainWin.webContents?.id ?? null;
}
} catch {
// Cannot resolve — reject for safety
return false;
return _validateSenderImpl(event, false);
}
/**
* Validate that an IPC event sender is a trusted window (main or settings).
* Use this for handlers that the settings window legitimately needs access to
* (e.g. model listing, provider sync, Codex login, agent discovery).
*/
function validateSenderOrSettings(event) {
return _validateSenderImpl(event, true);
}
function _validateSenderImpl(event, allowSettings) {
try {
const windowManager = require("./windowManager.cjs");
// Always resolve the current main window id to handle window recreation
const mainWin = windowManager.getMainWindow?.();
if (mainWin && !mainWin.isDestroyed?.()) {
mainWebContentsId = mainWin.webContents?.id ?? null;
}
const senderId = event.sender?.id;
if (senderId == null) return false;
// Allow main window
if (mainWebContentsId != null && senderId === mainWebContentsId) return true;
// Allow settings window only for designated handlers
if (allowSettings) {
const settingsWin = windowManager.getSettingsWindow?.();
if (settingsWin && !settingsWin.isDestroyed?.()) {
if (senderId === settingsWin.webContents?.id) return true;
}
}
return false;
} catch {
// Cannot resolve — reject for safety
return false;
}
if (mainWebContentsId == null) return false;
return event.sender?.id === mainWebContentsId;
}
/**
@@ -195,7 +304,7 @@ function validateSender(event) {
* renderer can construct a Response with the real status. Data continues to
* flow via stream:data / stream:end / stream:error IPC events.
*/
function streamRequest(url, options, event, requestId) {
function streamRequest(url, options, event, requestId, skipTLS) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const isHttps = parsedUrl.protocol === "https:";
@@ -214,29 +323,37 @@ function streamRequest(url, options, event, requestId) {
return;
}
const req = lib.request(
parsedUrl,
{
const reqOpts = {
method: options.method || "POST",
headers: options.headers || {},
timeout: 120000, // 2 min connection timeout
},
};
if (skipTLS && isHttps) reqOpts.rejectUnauthorized = false;
const req = lib.request(parsedUrl, reqOpts,
(res) => {
const statusCode = res.statusCode || 0;
const statusText = res.statusMessage || "";
if (statusCode < 200 || statusCode >= 300) {
// Resolve immediately with error status so the renderer sees it
resolve({ statusCode, statusText });
// Read the error body before resolving so we can include it in the response
let errorBody = "";
res.on("data", (chunk) => { errorBody += chunk.toString(); });
res.on("end", () => {
// Try to extract error message from JSON response (OpenAI-compatible format)
let errorDetail = statusText;
try {
const parsed = JSON.parse(errorBody);
errorDetail = parsed?.error?.message || parsed?.message || parsed?.detail || errorBody.slice(0, 500);
} catch {
if (errorBody.trim()) errorDetail = errorBody.slice(0, 500);
}
safeSend(event.sender, "netcatty:ai:stream:error", {
requestId,
error: `HTTP ${statusCode}: ${errorBody}`,
error: `HTTP ${statusCode}: ${errorDetail}`,
});
activeStreams.delete(requestId);
resolve({ statusCode, statusText: `${statusCode} ${errorDetail}` });
});
return;
}
@@ -331,7 +448,7 @@ function streamRequest(url, options, event, requestId) {
function registerHandlers(ipcMain) {
// ── Provider config sync (renderer → main, keys stay encrypted) ──
ipcMain.handle("netcatty:ai:sync-providers", async (event, { providers }) => {
if (!validateSender(event)) return { ok: false };
if (!validateSenderOrSettings(event)) return { ok: false };
if (Array.isArray(providers)) {
providerConfigs = providers;
rebuildProviderFetchHosts();
@@ -339,12 +456,107 @@ function registerHandlers(ipcMain) {
return { ok: true };
});
// ── Web search config sync (renderer → main, for fetch allowlist + key decryption) ──
ipcMain.handle("netcatty:ai:sync-web-search", async (event, { apiHost, apiKey }) => {
if (!validateSenderOrSettings(event)) return { ok: false };
webSearchApiHost = typeof apiHost === "string" ? apiHost : null;
webSearchApiKeyEncrypted = typeof apiKey === "string" ? apiKey : null;
rebuildProviderFetchHosts();
return { ok: true };
});
/**
* Inject the decrypted web search API key into request headers.
* Replaces __WEB_SEARCH_KEY__ placeholder, similar to __IPC_SECURED__ for providers.
*/
function injectWebSearchKeyIntoHeaders(headers) {
if (!webSearchApiKeyEncrypted || !headers) return headers;
const realKey = decryptApiKeyValue(webSearchApiKeyEncrypted);
if (!realKey) return headers;
const patched = {};
for (const [k, v] of Object.entries(headers)) {
patched[k] = typeof v === "string" ? v.replace(WEB_SEARCH_KEY_PLACEHOLDER, realKey) : v;
}
return patched;
}
// Temporarily add a host to the fetch allowlist (used by settings model listing).
// Entries are auto-removed after 30 seconds unless they belong to a synced provider.
const TEMP_ALLOWLIST_TTL = 30_000;
// Track temporarily added entries so cleanup can distinguish them from synced ones
const tempAllowedHosts = new Set();
const tempAllowedPorts = new Set();
/** Check if a host is owned by a currently synced provider config */
function isHostInProviderConfigs(host) {
for (const config of providerConfigs) {
if (!config.baseURL) continue;
try { if (new URL(config.baseURL).hostname === host) 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) {
if (!config.baseURL) continue;
try {
const p = new URL(config.baseURL);
if ((p.hostname === "localhost" || p.hostname === "127.0.0.1") &&
Number(p.port || (p.protocol === "https:" ? 443 : 80)) === port) return true;
} catch {}
}
return false;
}
ipcMain.handle("netcatty:ai:allowlist:add-host", async (event, { baseURL }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (typeof baseURL !== "string") return { ok: false, error: "baseURL must be a string" };
try {
const parsed = new URL(baseURL);
const host = parsed.hostname;
if (host === "localhost" || host === "127.0.0.1") {
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
if (!ALLOWED_LOCALHOST_PORTS.has(port)) {
ALLOWED_LOCALHOST_PORTS.add(port);
tempAllowedPorts.add(port);
setTimeout(() => {
// Only remove if still temporary (not built-in and not synced by a provider)
if (!BUILTIN_LOCALHOST_PORTS.includes(port) && !isPortInProviderConfigs(port)) {
ALLOWED_LOCALHOST_PORTS.delete(port);
}
tempAllowedPorts.delete(port);
}, TEMP_ALLOWLIST_TTL);
}
} else {
if (!providerFetchHosts.has(host)) {
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);
}
}
return { ok: true };
} catch {
return { ok: false, error: "Invalid URL" };
}
});
// URL allowlist: only permit requests to known AI provider domains + HTTPS
const BUILTIN_FETCH_HOSTS = new Set([
"api.openai.com",
"api.anthropic.com",
"generativelanguage.googleapis.com",
"openrouter.ai",
// Web search providers
"api.tavily.com",
"api.exa.ai",
"api.bochaai.com",
"open.bigmodel.cn",
]);
// Dynamically populated from configured provider baseURLs
const providerFetchHosts = new Set();
@@ -358,6 +570,9 @@ function registerHandlers(ipcMain) {
// 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 port of tempAllowedPorts) ALLOWED_LOCALHOST_PORTS.add(port);
for (const config of providerConfigs) {
if (!config.baseURL) continue;
try {
@@ -374,6 +589,19 @@ function registerHandlers(ipcMain) {
// Invalid URL in config — skip
}
}
// Add web search apiHost if configured (e.g. SearXNG self-hosted instance)
if (webSearchApiHost) {
try {
const parsed = new URL(webSearchApiHost);
const host = parsed.hostname;
if (host === "localhost" || host === "127.0.0.1") {
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
ALLOWED_LOCALHOST_PORTS.add(port);
} else {
providerFetchHosts.add(host);
}
} catch {}
}
}
// Allowed localhost ports to prevent SSRF (Issue #9)
@@ -389,16 +617,69 @@ function registerHandlers(ipcMain) {
8888, // Common local dev
];
const ALLOWED_LOCALHOST_PORTS = new Set(BUILTIN_LOCALHOST_PORTS);
function isAllowedFetchUrl(urlString) {
// RFC1918 / link-local / loopback / IPv6 private ranges — used by SSRF guard
function isPrivateIp(ip) {
if (!ip) return false;
// Strip IPv6 brackets that URL.hostname may include
const cleaned = ip.replace(/^\[|\]$/g, "");
if (cleaned === "::1" || cleaned === "0.0.0.0" || cleaned === "::") return true;
// IPv6 private ranges: fc00::/7 (unique local), fe80::/10 (link-local), ::ffff:127.x (mapped loopback)
const lower = cleaned.toLowerCase();
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // fc00::/7
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // fe80::/10
if (lower.startsWith("::ffff:")) {
// IPv4-mapped IPv6 — extract IPv4 portion and check
const v4 = lower.slice(7);
return isPrivateIp(v4);
}
// IPv4
const parts = cleaned.split(".");
if (parts.length === 4 && parts.every(p => /^\d+$/.test(p))) {
const [a, b] = parts.map(Number);
if (a === 10) return true; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 127) return true; // 127.0.0.0/8
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 CGNAT (Tailscale etc.)
if (a === 0) return true; // 0.0.0.0/8
}
return false;
}
function isPrivateHost(hostname) {
if (hostname === "localhost") return true;
// metadata endpoints (AWS, GCP, Azure)
if (hostname === "metadata.google.internal") return true;
return isPrivateIp(hostname);
}
function isAllowedFetchUrl(urlString, skipHostCheck) {
try {
const parsed = new URL(urlString);
// Allow localhost/127.0.0.1 only on known ports (e.g. Ollama)
// Always block private/internal hosts when skipHostCheck is set (SSRF protection)
if (skipHostCheck) {
if (isPrivateHost(parsed.hostname)) return false;
// Require HTTPS for skipHostCheck requests
if (parsed.protocol !== "https:") return false;
return true;
}
// Allow localhost/127.0.0.1 only on known ports (e.g. Ollama) — normal fetch path only
if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
return ALLOWED_LOCALHOST_PORTS.has(port);
}
// Require HTTPS for remote hosts
if (parsed.protocol !== "https:") return false;
// 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;
}
}
// Check built-in + provider-configured host allowlist
if (BUILTIN_FETCH_HOSTS.has(parsed.hostname)) return true;
if (providerFetchHosts.has(parsed.hostname)) return true;
@@ -439,7 +720,8 @@ function registerHandlers(ipcMain) {
return { ok: false, error: "URL host is not in the allowed list" };
}
const { statusCode, statusText } = await streamRequest(resolvedUrl, { method: "POST", headers: resolvedHeaders, body }, event, requestId);
const skipTLS = shouldSkipTLSVerify(providerId);
const { statusCode, statusText } = await streamRequest(resolvedUrl, { method: "POST", headers: resolvedHeaders, body }, event, requestId, skipTLS);
return { ok: true, statusCode, statusText };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
@@ -447,7 +729,8 @@ function registerHandlers(ipcMain) {
});
// Cancel an active stream
ipcMain.handle("netcatty:ai:chat:cancel", async (_event, { requestId }) => {
ipcMain.handle("netcatty:ai:chat:cancel", async (event, { requestId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const controller = activeStreams.get(requestId);
if (controller) {
controller.abort();
@@ -458,16 +741,17 @@ function registerHandlers(ipcMain) {
});
// Non-streaming request (for model listing, validation, etc.)
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify }) => {
// Validate IPC sender — settings window needs this for model listing
if (!validateSenderOrSettings(event)) {
return { ok: false, status: 0, data: "", error: "Unauthorized IPC sender" };
}
// Inject real API key if providerId is given (replaces placeholder in headers/URL)
const patched = injectApiKeyIntoRequest(url, headers, providerId);
const resolvedUrl = patched.url;
const resolvedHeaders = patched.headers;
// Also inject web search API key if placeholder is present
const resolvedHeaders = injectWebSearchKeyIntoHeaders(patched.headers);
// Validate URL: block non-HTTP(S) schemes and internal network access
try {
@@ -480,53 +764,71 @@ function registerHandlers(ipcMain) {
return { ok: false, status: 0, data: "", error: "Invalid URL" };
}
// Check URL against allowed hosts (server-side allowlist only)
if (!isAllowedFetchUrl(resolvedUrl)) {
// Check URL against allowed hosts; skipHostCheck allows public HTTPS but still blocks private/internal
if (!isAllowedFetchUrl(resolvedUrl, !!skipHostCheck)) {
return { ok: false, status: 0, data: "", error: "URL host is not in the allowed list" };
}
return new Promise((resolve) => {
const parsedUrl = new URL(resolvedUrl);
const isHttps = parsedUrl.protocol === "https:";
const lib = isHttps ? https : http;
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB safety limit
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB safety limit
const MAX_REDIRECTS = followRedirects ? 5 : 0;
const req = lib.request(
parsedUrl,
{ method: method || "GET", headers: resolvedHeaders || {}, timeout: 30000 },
(res) => {
let data = "";
let totalSize = 0;
res.on("data", (chunk) => {
totalSize += chunk.length;
if (totalSize > MAX_RESPONSE_SIZE) {
req.destroy();
resolve({ ok: false, status: 0, data: "", error: "Response body exceeded maximum size (10MB)" });
function doFetch(fetchUrl, redirectsLeft) {
return new Promise((resolve) => {
const parsedUrl = new URL(fetchUrl);
const isHttps = parsedUrl.protocol === "https:";
const lib = isHttps ? https : http;
const fetchOpts = { method: method || "GET", headers: resolvedHeaders || {}, timeout: 30000 };
if ((skipTLSVerify || shouldSkipTLSVerify(providerId)) && isHttps) fetchOpts.rejectUnauthorized = false;
const req = lib.request(parsedUrl, fetchOpts,
(res) => {
// Handle redirects
if (redirectsLeft > 0 && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const location = new URL(res.headers.location, fetchUrl).href;
res.resume(); // drain the response
// Revalidate the redirect target hostname (blocks localhost/metadata etc.)
if (!isAllowedFetchUrl(location, !!skipHostCheck)) {
resolve({ ok: false, status: 0, data: "", error: "Redirect target is not allowed" });
return;
}
resolve(doFetch(location, redirectsLeft - 1));
return;
}
data += chunk.toString();
});
res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
data,
let data = "";
let totalSize = 0;
res.on("data", (chunk) => {
totalSize += chunk.length;
if (totalSize > MAX_RESPONSE_SIZE) {
req.destroy();
resolve({ ok: false, status: 0, data: "", error: "Response body exceeded maximum size (10MB)" });
return;
}
data += chunk.toString();
});
});
}
);
res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
data,
});
});
}
);
req.on("error", (err) => {
resolve({ ok: false, status: 0, data: "", error: err.message });
});
req.on("timeout", () => {
req.destroy();
resolve({ ok: false, status: 0, data: "", error: "Request timeout" });
});
req.on("error", (err) => {
resolve({ ok: false, status: 0, data: "", error: err.message });
});
req.on("timeout", () => {
req.destroy();
resolve({ ok: false, status: 0, data: "", error: "Request timeout" });
});
if (body) req.write(body);
req.end();
});
if (body) req.write(body);
req.end();
});
}
return doFetch(resolvedUrl, MAX_REDIRECTS);
});
// Execute a command on a terminal session (for Catty Agent)
@@ -840,7 +1142,8 @@ function registerHandlers(ipcMain) {
}
// Discover external agents from PATH, plus the bundled Codex CLI if present.
ipcMain.handle("netcatty:ai:agents:discover", async () => {
ipcMain.handle("netcatty:ai:agents:discover", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const agents = [];
const knownAgents = [
{
@@ -909,7 +1212,8 @@ function registerHandlers(ipcMain) {
});
// Resolve a CLI binary path (auto-detect or validate custom path)
ipcMain.handle("netcatty:ai:resolve-cli", async (_event, { command, customPath }) => {
ipcMain.handle("netcatty:ai:resolve-cli", async (event, { command, customPath }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const shellEnv = await getShellEnv();
let resolvedPath = null;
@@ -937,7 +1241,8 @@ function registerHandlers(ipcMain) {
return { path: resolvedPath, version, available: true };
});
ipcMain.handle("netcatty:ai:codex:get-integration", async () => {
ipcMain.handle("netcatty:ai:codex:get-integration", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const result = await runCodexCli(["login", "status"]);
const rawOutput = [result.stdout, result.stderr]
@@ -987,7 +1292,8 @@ function registerHandlers(ipcMain) {
}
});
ipcMain.handle("netcatty:ai:codex:start-login", async () => {
ipcMain.handle("netcatty:ai:codex:start-login", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const existingSession = getActiveCodexLoginSession();
if (existingSession) {
return { ok: true, session: toCodexLoginSessionResponse(existingSession) };
@@ -1051,7 +1357,8 @@ function registerHandlers(ipcMain) {
}
});
ipcMain.handle("netcatty:ai:codex:get-login-session", async (_event, { sessionId }) => {
ipcMain.handle("netcatty:ai:codex:get-login-session", async (event, { sessionId }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const session = codexLoginSessions.get(sessionId);
if (!session) {
return { ok: false, error: "Codex login session not found" };
@@ -1059,7 +1366,8 @@ function registerHandlers(ipcMain) {
return { ok: true, session: toCodexLoginSessionResponse(session) };
});
ipcMain.handle("netcatty:ai:codex:cancel-login", async (_event, { sessionId }) => {
ipcMain.handle("netcatty:ai:codex:cancel-login", async (event, { sessionId }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const session = codexLoginSessions.get(sessionId);
if (!session) {
return { ok: true, found: false };
@@ -1075,7 +1383,8 @@ function registerHandlers(ipcMain) {
return { ok: true, found: true, session: toCodexLoginSessionResponse(session) };
});
ipcMain.handle("netcatty:ai:codex:logout", async () => {
ipcMain.handle("netcatty:ai:codex:logout", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const logoutResult = await runCodexCli(["logout"]);
invalidateCodexValidationCache();
@@ -1249,12 +1558,14 @@ function registerHandlers(ipcMain) {
// ── MCP Server session metadata ──
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (_event, { sessions: sessionList, chatSessionId }) => {
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.updateSessionMetadata(sessionList || [], chatSessionId);
return { ok: true };
});
ipcMain.handle("netcatty:ai:mcp:set-command-blocklist", async (_event, { blocklist }) => {
ipcMain.handle("netcatty:ai:mcp:set-command-blocklist", async (event, { blocklist }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
// Validate: must be an array of strings, each a valid regex pattern
if (!Array.isArray(blocklist)) {
return { ok: false, error: "blocklist must be an array" };
@@ -1273,7 +1584,8 @@ function registerHandlers(ipcMain) {
return { ok: true };
});
ipcMain.handle("netcatty:ai:mcp:set-command-timeout", async (_event, { timeout }) => {
ipcMain.handle("netcatty:ai:mcp:set-command-timeout", async (event, { timeout }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const value = Number(timeout);
if (!Number.isFinite(value) || value < 1 || value > 3600) {
return { ok: false, error: "timeout must be a number between 1 and 3600" };
@@ -1282,7 +1594,8 @@ function registerHandlers(ipcMain) {
return { ok: true };
});
ipcMain.handle("netcatty:ai:mcp:set-max-iterations", async (_event, { maxIterations }) => {
ipcMain.handle("netcatty:ai:mcp:set-max-iterations", async (event, { maxIterations }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const value = Number(maxIterations);
if (!Number.isFinite(value) || value < 1 || value > 100) {
return { ok: false, error: "maxIterations must be a number between 1 and 100" };
@@ -1291,7 +1604,8 @@ function registerHandlers(ipcMain) {
return { ok: true };
});
ipcMain.handle("netcatty:ai:mcp:set-permission-mode", async (_event, { mode }) => {
ipcMain.handle("netcatty:ai:mcp:set-permission-mode", async (event, { mode }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const validModes = ["observer", "confirm", "autonomous"];
if (!validModes.includes(mode)) {
return { ok: false, error: `mode must be one of: ${validModes.join(", ")}` };
@@ -1302,12 +1616,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");
@@ -1358,9 +1674,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 &&
@@ -1370,6 +1699,7 @@ function registerHandlers(ipcMain) {
);
if (!shouldReuseProvider) {
const resumeSessionId = providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
cleanupAcpProvider(chatSessionId);
const agentEnv = { ...shellEnv };
@@ -1389,6 +1719,7 @@ function registerHandlers(ipcMain) {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
},
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: {}),
@@ -1402,12 +1733,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 =
@@ -1436,12 +1819,21 @@ function registerHandlers(ipcMain) {
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,
@@ -1455,6 +1847,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..." },
@@ -1467,6 +1860,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);
@@ -1490,6 +1884,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
@@ -1497,6 +1894,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) {
@@ -1518,25 +1918,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 }) => {
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

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

@@ -123,36 +123,46 @@ async function findAllDefaultPrivateKeys(options = {}) {
return results.filter(Boolean);
}
/**
* Check if a Windows named pipe exists (non-blocking).
* Works for OpenSSH Agent, Bitwarden SSH Agent, 1Password, etc.
*/
function windowsPipeExists(pipePath) {
try {
fs.statSync(pipePath);
return true;
} catch {
return false;
}
}
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
/**
* Check if a Windows named pipe is connectable.
* fs.statSync is unreliable for named pipes (returns EBUSY even when the
* pipe is usable), so we attempt an actual net.connect() which is the
* authoritative check.
* @param {string} pipePath
* @param {number} [timeoutMs=1000]
* @returns {Promise<boolean>}
*/
function windowsPipeConnectable(pipePath, timeoutMs = 1000) {
const net = require("net");
return new Promise((resolve) => {
const socket = net.connect(pipePath);
let settled = false;
const finish = (ok) => {
if (settled) return;
settled = true;
try { socket.destroy(); } catch {}
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
/**
* Check if an SSH agent is available on Windows.
* Instead of checking the OpenSSH Authentication Agent *service*, we probe
* the well-known named pipe directly. This supports any agent that provides
* the pipe — Bitwarden, 1Password, gpg-agent, etc.
* Probes the well-known named pipe via net.connect(). This supports any
* agent that provides the pipe — Bitwarden, 1Password, gpg-agent, etc.
* @returns {Promise<boolean>}
*/
function checkWindowsSshAgentRunning() {
return new Promise((resolve) => {
if (process.platform !== "win32") {
resolve(true);
return;
}
resolve(windowsPipeExists(WIN_SSH_AGENT_PIPE));
});
if (process.platform !== "win32") {
return Promise.resolve(true);
}
return windowsPipeConnectable(WIN_SSH_AGENT_PIPE);
}
/**

View File

@@ -143,29 +143,33 @@ async function findAllDefaultPrivateKeys() {
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
/**
* Check if an SSH agent is available on Windows by probing the well-known
* named pipe. This detects any agent that provides the pipe — OpenSSH Agent
* service, Bitwarden, 1Password, gpg-agent, etc.
* Check if an SSH agent is available on Windows by connecting to the
* well-known named pipe. fs.statSync is unreliable for named pipes (returns
* EBUSY even when usable), so we use net.connect() as the authoritative check.
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
*/
function checkWindowsSshAgent() {
if (process.platform !== "win32") {
return Promise.resolve({ running: true, startupType: null, error: null });
}
const net = require("net");
return new Promise((resolve) => {
if (process.platform !== "win32") {
resolve({ running: true, startupType: null, error: null });
return;
}
let pipeExists = false;
try {
fs.statSync(WIN_SSH_AGENT_PIPE);
pipeExists = true;
} catch {
// pipe not found
}
resolve({
running: pipeExists,
startupType: pipeExists ? "running" : "stopped",
error: pipeExists ? null : "SSH Agent pipe not found",
});
const socket = net.connect(WIN_SSH_AGENT_PIPE);
let settled = false;
const finish = (ok, error) => {
if (settled) return;
settled = true;
try { socket.destroy(); } catch {}
resolve({
running: ok,
startupType: ok ? "running" : "stopped",
error: ok ? null : (error || "SSH Agent pipe not connectable"),
});
};
socket.setTimeout(1000);
socket.once("connect", () => finish(true, null));
socket.once("timeout", () => finish(false, "SSH Agent pipe connect timeout"));
socket.once("error", (err) => finish(false, err.message));
});
}
@@ -1013,6 +1017,19 @@ async function startSSHSession(event, options) {
bufferData(decoder.write(data));
});
// 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", () => {
// Flush any remaining data before close
if (flushTimeout) {
@@ -1020,7 +1037,7 @@ async function startSSHSession(event, options) {
}
flushBuffer();
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);
@@ -1068,7 +1085,7 @@ 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" });
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1082,7 +1099,7 @@ 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" });
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1094,7 +1111,7 @@ 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" });
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);

View File

@@ -222,9 +222,13 @@ function startLocalSession(event, payload) {
proc.onExit((evt) => {
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 };
}
@@ -426,7 +430,7 @@ async function startTelnetSession(event, options) {
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 +439,11 @@ async function startTelnetSession(event, options) {
socket.on('close', (hadError) => {
console.log(`[Telnet] Connection closed${hadError ? ' with error' : ''}`);
clearTimeout(connectTimeout);
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);
});
@@ -523,7 +527,8 @@ async function startMoshSession(event, options) {
proc.onExit((evt) => {
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 };
@@ -615,14 +620,14 @@ async function startSerialSession(event, options) {
serialPort.on('error', (err) => {
console.error(`[Serial] Port error: ${err.message}`);
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`);
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

@@ -907,9 +907,23 @@ async function openSettingsWindow(electronModule, options) {
const backgroundColor = frontendBackground || "#1a1a1a";
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
// Center the settings window on the same display as the main window
const settingsWidth = 980;
const settingsHeight = 720;
let settingsX, settingsY;
if (mainWindow && !mainWindow.isDestroyed()) {
const { screen } = electronModule;
const mainBounds = mainWindow.getBounds();
const display = screen.getDisplayMatching(mainBounds);
const { x: dx, y: dy, width: dw, height: dh } = display.workArea;
settingsX = Math.round(dx + (dw - settingsWidth) / 2);
settingsY = Math.round(dy + (dh - settingsHeight) / 2);
}
const win = new BrowserWindow({
width: 980,
height: 720,
width: settingsWidth,
height: settingsHeight,
...(settingsX !== undefined && settingsY !== undefined ? { x: settingsX, y: settingsY } : {}),
minWidth: 820,
minHeight: 600,
backgroundColor,

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

@@ -994,14 +994,20 @@ const api = {
aiSyncProviders: async (providers) => {
return ipcRenderer.invoke("netcatty:ai:sync-providers", { providers });
},
aiSyncWebSearch: async (apiHost, apiKey) => {
return ipcRenderer.invoke("netcatty:ai:sync-web-search", { apiHost, apiKey });
},
aiChatStream: async (requestId, url, headers, body, providerId) => {
return ipcRenderer.invoke("netcatty:ai:chat:stream", { requestId, url, headers, body, providerId });
},
aiChatCancel: async (requestId) => {
return ipcRenderer.invoke("netcatty:ai:chat:cancel", { requestId });
},
aiFetch: async (url, method, headers, body, providerId) => {
return ipcRenderer.invoke("netcatty:ai:fetch", { url, method, headers, body, providerId });
aiFetch: async (url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify) => {
return ipcRenderer.invoke("netcatty:ai:fetch", { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify });
},
aiAllowlistAddHost: async (baseURL) => {
return ipcRenderer.invoke("netcatty:ai:allowlist:add-host", { baseURL });
},
aiExec: async (sessionId, command) => {
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command });
@@ -1059,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 });

8
global.d.ts vendored
View File

@@ -189,6 +189,7 @@ declare global {
port?: number;
password?: string;
privateKey?: string;
passphrase?: string;
command: string;
timeout?: number;
enableKeyboardInteractive?: boolean;
@@ -243,7 +244,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,
@@ -617,6 +618,7 @@ declare global {
aiChatStream?(requestId: string, url: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; statusCode?: number; statusText?: string; error?: string }>;
aiChatCancel?(requestId: string): Promise<boolean>;
aiFetch?(url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; status: number; data: string; error?: string }>;
aiAllowlistAddHost?(baseURL: string): Promise<{ ok: boolean; error?: string }>;
aiExec?(sessionId: string, command: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
aiTerminalWrite?(sessionId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiDiscoverAgents?(): Promise<Array<{
@@ -687,8 +689,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,6 +9,7 @@
import type { ExternalAgentConfig } from './types';
export interface AcpAgentCallbacks {
onSessionId?: (sessionId: string) => void;
onTextDelta: (text: string) => void;
onThinkingDelta: (text: string) => void;
onThinkingDone: () => 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;
@@ -63,6 +66,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 +106,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 +122,8 @@ export async function runAcpAgentTurn(
undefined, // cwd
providerId,
model,
existingSessionId,
historyMessages,
images?.length ? images : undefined,
).catch((err: Error) => {
callbacks.onError(err.message);
@@ -177,6 +184,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,13 +1,10 @@
import type { ToolCall, ToolResult, AIPermissionMode } from '../types';
import type { ToolCall, ToolResult, AIPermissionMode, WebSearchConfig } from '../types';
import {
executeTerminalExecute,
executeTerminalSendInput,
executeSftpListDirectory,
executeSftpReadFile,
executeSftpWriteFile,
executeWorkspaceGetInfo,
executeWorkspaceGetSessionInfo,
executeMultiHostExecute,
executeWebSearch,
executeUrlFetch,
type ToolDeps,
type ToolExecResult,
} from '../shared/toolExecutors';
@@ -27,26 +24,6 @@ export interface NetcattyBridge {
exitCode?: number;
error?: string;
}>;
aiTerminalWrite(
sessionId: string,
data: string,
): Promise<{ ok: boolean; error?: string }>;
listSftp(
sftpId: string,
path: string,
encoding?: string,
): Promise<unknown>;
readSftp(
sftpId: string,
path: string,
encoding?: string,
): Promise<string>;
writeSftp(
sftpId: string,
path: string,
content: string,
encoding?: string,
): Promise<void>;
}
// Workspace context provided to the executor
@@ -60,7 +37,6 @@ export interface ExecutorContext {
os?: string;
username?: string;
connected: boolean;
sftpId?: string; // If SFTP is open for this session
}>;
// Workspace info
workspaceId?: string;
@@ -90,22 +66,6 @@ function toToolResult(toolCallId: string, r: ToolExecResult): ToolResult {
.join('\n\n');
return { toolCallId, content: output || 'Command completed (no output)' };
}
// For terminal_send_input
if (typeof r.data === 'object' && r.data !== null && 'sent' in r.data) {
return { toolCallId, content: `Sent input to terminal: ${JSON.stringify((r.data as { sent: string }).sent)}` };
}
// For sftp_list_directory with output fallback
if (typeof r.data === 'object' && r.data !== null && 'output' in r.data && !('files' in r.data)) {
return { toolCallId, content: (r.data as { output: string }).output };
}
// For sftp_read_file
if (typeof r.data === 'object' && r.data !== null && 'content' in r.data) {
return { toolCallId, content: (r.data as { content: string }).content };
}
// For sftp_write_file
if (typeof r.data === 'object' && r.data !== null && 'written' in r.data) {
return { toolCallId, content: `File written: ${(r.data as { written: string }).written}` };
}
// Default: JSON-serialize the data
return { toolCallId, content: JSON.stringify(r.data, null, 2) };
}
@@ -119,6 +79,7 @@ export function createToolExecutor(
context: ExecutorContext,
commandBlocklist?: string[],
permissionMode: AIPermissionMode = 'confirm',
webSearchConfig?: WebSearchConfig,
): (toolCall: ToolCall) => Promise<ToolResult> {
return async (toolCall: ToolCall): Promise<ToolResult> => {
if (!bridge) {
@@ -129,7 +90,7 @@ export function createToolExecutor(
};
}
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode };
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
const args = toolCall.arguments;
try {
@@ -142,40 +103,6 @@ export function createToolExecutor(
return toToolResult(toolCall.id, r);
}
case 'terminal_send_input': {
const r = await executeTerminalSendInput(deps, {
sessionId: String(args.sessionId || ''),
input: String(args.input || ''),
});
return toToolResult(toolCall.id, r);
}
case 'sftp_list_directory': {
const r = await executeSftpListDirectory(deps, {
sessionId: String(args.sessionId || ''),
path: String(args.path || '/'),
});
return toToolResult(toolCall.id, r);
}
case 'sftp_read_file': {
const r = await executeSftpReadFile(deps, {
sessionId: String(args.sessionId || ''),
path: String(args.path || ''),
maxBytes: Number(args.maxBytes) || 10000,
});
return toToolResult(toolCall.id, r);
}
case 'sftp_write_file': {
const r = await executeSftpWriteFile(deps, {
sessionId: String(args.sessionId || ''),
path: String(args.path || ''),
content: String(args.content || ''),
});
return toToolResult(toolCall.id, r);
}
case 'workspace_get_info': {
const r = executeWorkspaceGetInfo(deps);
return toToolResult(toolCall.id, r);
@@ -188,12 +115,18 @@ export function createToolExecutor(
return toToolResult(toolCall.id, r);
}
case 'multi_host_execute': {
const r = await executeMultiHostExecute(deps, {
sessionIds: (args.sessionIds as string[]) || [],
command: String(args.command || ''),
mode: String(args.mode || 'parallel'),
stopOnError: Boolean(args.stopOnError),
case 'web_search': {
const r = await executeWebSearch(deps, {
query: String(args.query || ''),
maxResults: Number(args.maxResults) || 5,
});
return toToolResult(toolCall.id, r);
}
case 'url_fetch': {
const r = await executeUrlFetch(deps, {
url: String(args.url || ''),
maxLength: Number(args.maxLength) || 50000,
});
return toToolResult(toolCall.id, r);
}

View File

@@ -10,10 +10,11 @@ export interface SystemPromptContext {
connected: boolean;
}>;
permissionMode: 'observer' | 'confirm' | 'autonomous';
webSearchEnabled?: boolean;
}
export function buildSystemPrompt(context: SystemPromptContext): string {
const { scopeType, scopeLabel, hosts, permissionMode } = context;
const { scopeType, scopeLabel, hosts, permissionMode, webSearchEnabled } = context;
const scopeDescription = buildScopeDescription(scopeType, scopeLabel);
const hostList = buildHostList(hosts);
@@ -37,7 +38,7 @@ ${permissionRules}
1. **Plan before acting.** When a task involves multiple steps, present a brief numbered plan to the user before executing. Wait for acknowledgment on complex or risky operations.
2. **Use the right tool.** For operations that target multiple hosts, prefer \`multi_host_execute\` over calling \`terminal_execute\` repeatedly. For normal shell commands, use \`terminal_execute\` so you receive the command output. Use \`terminal_send_input\` only when responding to an interactive prompt that is already running in the terminal. \`terminal_send_input\` writes keystrokes but does not read back the updated terminal screen.
2. **Use the right tool.** For normal shell commands, use \`terminal_execute\` so you receive the command output. When operating on multiple hosts, call \`terminal_execute\` for each host.
3. **Never execute dangerous commands.** Commands matching the blocklist (e.g. \`rm -rf /\`, \`mkfs\`, \`dd\` to disk devices, \`shutdown\`, fork bombs, recursive chmod 777 on root) are strictly forbidden and will be automatically denied. Do not attempt to bypass these restrictions.
@@ -49,7 +50,11 @@ ${permissionRules}
7. **Respect connection status.** Only attempt operations on hosts that are currently connected. If a host is disconnected, inform the user and suggest reconnecting.
8. **Be careful with file operations.** When writing files via SFTP, confirm the target path with the user if there is any ambiguity. Always prefer appending or targeted edits over full file overwrites when possible.`;
8. **Be careful with file operations.** When writing files via shell commands, confirm the target path with the user if there is any ambiguity. Always prefer appending or targeted edits over full file overwrites when possible.
9. **Fetch URLs when provided.** When the user shares a URL or asks you to read a webpage, use \`url_fetch\` to retrieve its content.${webSearchEnabled ? `
10. **Search proactively.** You have access to \`web_search\`. Use it whenever you encounter something you are unsure about, don't fully understand, or need to verify — including unfamiliar commands, tools, error messages, configuration syntax, or any factual claims. Don't guess; search first. Also use it when the user asks about current events or recent information. Cite sources when presenting search results.` : ''}`;
}
function buildScopeDescription(
@@ -98,9 +103,9 @@ function buildPermissionRules(
case 'observer':
return [
'You are in **observer** mode. You may only perform read-only operations:',
'- Listing directories (`sftp_list_directory`)',
'- Reading files (`sftp_read_file`)',
'- Getting workspace and session info (`workspace_get_info`, `workspace_get_session_info`)',
'- Fetching URLs (`url_fetch`)',
'- Searching the web (`web_search`)',
'',
'All write and execute operations are denied. If the user asks you to run a command or modify a file, explain that observer mode does not allow it and suggest switching to confirm or autonomous mode.',
].join('\n');
@@ -108,9 +113,7 @@ function buildPermissionRules(
case 'confirm':
return [
'You are in **confirm** mode. Every write or execute operation requires explicit user approval before it runs:',
'- Command execution (`terminal_execute`, `multi_host_execute`)',
'- Sending terminal input (`terminal_send_input`)',
'- Writing files (`sftp_write_file`)',
'- Command execution (`terminal_execute`)',
'',
'Read-only operations are allowed without confirmation. When proposing a command, clearly state what it will do so the user can make an informed decision.',
].join('\n');

View File

@@ -28,7 +28,7 @@ export function classifyError(error: string): NonNullable<ChatMessage['errorInfo
// Provider errors (5xx)
if (/\b5\d{2}\b/.test(error) || lower.includes('server error') || lower.includes('internal error')) {
return { type: 'provider', message: 'The AI provider returned a server error. Please try again later.', retryable: true };
return { type: 'provider', message: sanitizeErrorMessage(error), retryable: true };
}
// Model not found

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,
@@ -178,9 +192,28 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
if (!result.ok) {
cleanup();
return new Response(result.error || 'Stream request failed', {
const errorMessage = result.error || 'Stream request failed';
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' },
});
}
// If the server returned a non-2xx status, return the error details
// as a JSON body in OpenAI-compatible format so the AI SDK's
// failedResponseHandler can extract the message properly.
// Also set a safe ASCII statusText as fallback for non-OpenAI SDK providers.
const statusCode = result.statusCode ?? 200;
if (statusCode < 200 || statusCode >= 300) {
cleanup();
const errorDetail = result.statusText || `HTTP ${statusCode}`;
const jsonBody = JSON.stringify({ error: { message: errorDetail } });
return new Response(jsonBody, {
status: statusCode,
statusText: toSafeStatusText(errorDetail, `Error ${statusCode}`),
headers: { 'content-type': 'application/json' },
});
}
@@ -191,7 +224,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
});
return new Response(stream, {
status: result.statusCode ?? 200,
status: statusCode,
statusText: result.statusText ?? 'OK',
headers: { 'content-type': 'text/event-stream' },
});

View File

@@ -1,16 +1,15 @@
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';
import {
executeTerminalExecute,
executeTerminalSendInput,
executeSftpListDirectory,
executeSftpReadFile,
executeSftpWriteFile,
executeWorkspaceGetInfo,
executeWorkspaceGetSessionInfo,
executeMultiHostExecute,
executeWebSearch,
executeUrlFetch,
type ToolDeps,
type ToolExecResult,
} from '../shared/toolExecutors';
@@ -31,12 +30,13 @@ 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,
) {
const writeToolNeedsApproval = permissionMode === 'confirm';
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode };
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
return {
terminal_execute: tool({
@@ -53,73 +53,6 @@ export function createCattyTools(
},
}),
terminal_send_input: tool({
description:
'Send raw input to a terminal session. Use this for interactive programs that ' +
'require input such as y/n prompts, passwords, ctrl+c (\\x03), ctrl+d (\\x04), ' +
'or any other keyboard input. This tool only sends input; it does not return ' +
'the updated terminal output. For normal shell commands, use terminal_execute instead.',
inputSchema: z.object({
sessionId: z.string().describe('The terminal session ID to send input to.'),
input: z
.string()
.describe(
'The raw input string to send. Use escape sequences for special keys ' +
'(e.g. "\\x03" for ctrl+c, "\\n" for enter).',
),
}),
needsApproval: writeToolNeedsApproval,
execute: async ({ sessionId, input }) => {
return unwrap(await executeTerminalSendInput(deps, { sessionId, input }));
},
}),
sftp_list_directory: tool({
description:
'List the contents of a directory on the remote host via SFTP. Returns file names, ' +
'sizes, types, and modification timestamps.',
inputSchema: z.object({
sessionId: z.string().describe('The session ID for the SFTP connection.'),
path: z.string().describe('The absolute path of the remote directory to list.'),
}),
execute: async ({ sessionId, path }) => {
return unwrap(await executeSftpListDirectory(deps, { sessionId, path }));
},
}),
sftp_read_file: tool({
description:
'Read the content of a file on the remote host via SFTP. Returns the file content ' +
'as text, truncated to maxBytes if the file is large.',
inputSchema: z.object({
sessionId: z.string().describe('The session ID for the SFTP connection.'),
path: z.string().describe('The absolute path of the remote file to read.'),
maxBytes: z
.number()
.optional()
.default(10000)
.describe('Maximum number of bytes to read from the file. Defaults to 10000.'),
}),
execute: async ({ sessionId, path, maxBytes }) => {
return unwrap(await executeSftpReadFile(deps, { sessionId, path, maxBytes }));
},
}),
sftp_write_file: tool({
description:
'Write content to a file on the remote host via SFTP. Creates the file if it does ' +
'not exist, or overwrites it if it does.',
inputSchema: z.object({
sessionId: z.string().describe('The session ID for the SFTP connection.'),
path: z.string().describe('The absolute path of the remote file to write.'),
content: z.string().describe('The text content to write to the file.'),
}),
needsApproval: writeToolNeedsApproval,
execute: async ({ sessionId, path, content }) => {
return unwrap(await executeSftpWriteFile(deps, { sessionId, path, content }));
},
}),
workspace_get_info: tool({
description:
'Get information about the current workspace, including all configured hosts ' +
@@ -142,36 +75,40 @@ export function createCattyTools(
},
}),
multi_host_execute: tool({
description:
'Execute a command on multiple hosts simultaneously or sequentially. ' +
'Use this for batch operations such as checking status across a fleet, ' +
'deploying updates, or running maintenance tasks on multiple servers.',
inputSchema: z.object({
sessionIds: z
.array(z.string())
.describe('Array of session IDs to execute the command on.'),
command: z.string().describe('The shell command to execute on each host.'),
mode: z
.enum(['parallel', 'sequential'])
.optional()
.default('parallel')
.describe(
'Execution mode. "parallel" runs on all hosts at once, ' +
'"sequential" runs one at a time. Defaults to "parallel".',
),
stopOnError: z
.boolean()
.optional()
.default(false)
.describe(
'If true and mode is "sequential", stop executing on remaining hosts ' +
'when a command fails. Defaults to false.',
),
// -- Web Search (conditional on fully configured webSearchConfig) --
...(isWebSearchReady(webSearchConfig) ? {
web_search: tool({
description:
'Search the web for current information. Use this when the user asks about recent events, ' +
'news, or facts you are unsure about. Returns a list of search results with titles, URLs, and content snippets.',
inputSchema: z.object({
query: z.string().describe('The search query to look up on the web.'),
maxResults: z
.number()
.optional()
.describe('Maximum number of search results to return. If omitted, uses the configured default.'),
}),
execute: async ({ query, maxResults }) => {
return unwrap(await executeWebSearch(deps, { query, maxResults }));
},
}),
needsApproval: writeToolNeedsApproval,
execute: async ({ sessionIds, command, mode, stopOnError }) => {
return unwrap(await executeMultiHostExecute(deps, { sessionIds, command, mode, stopOnError }));
} : {}),
// -- URL Fetch (always available, read-only like sftp_read_file) --
url_fetch: tool({
description:
'Fetch and read the content of a web URL. Use this when the user provides a URL and wants ' +
'you to read or summarize its content. Returns the page content as text.',
inputSchema: z.object({
url: z.string().describe('The HTTPS URL to fetch. Must start with https://.'),
maxLength: z
.number()
.optional()
.default(50000)
.describe('Maximum number of characters to return. Defaults to 50000.'),
}),
execute: async ({ url, maxLength }) => {
return unwrap(await executeUrlFetch(deps, { url, maxLength }));
},
}),
};

View File

@@ -8,10 +8,9 @@
*/
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
import type { AIPermissionMode } from '../types';
import type { AIPermissionMode, WebSearchConfig } from '../types';
import { checkCommandSafety } from '../cattyAgent/safety';
import { shellQuote } from '../shellQuote';
import { limitConcurrency } from '../concurrency';
import { executeWebSearchProvider } from './webSearchProviders';
// ---------------------------------------------------------------------------
// Shared result types
@@ -28,20 +27,26 @@ export type ToolExecResult<T = unknown> =
export interface ToolDeps {
bridge: NetcattyBridge;
context: ExecutorContext;
context: ExecutorContext | (() => ExecutorContext);
commandBlocklist?: string[];
permissionMode: AIPermissionMode;
webSearchConfig?: WebSearchConfig;
}
// ---------------------------------------------------------------------------
// 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(', ')}`;
@@ -78,9 +83,14 @@ export async function executeTerminalExecute(
}
const result = await bridge.aiExec(sessionId, command);
if (!result.ok) {
return { ok: false, error: result.error || 'Command failed' };
// Real execution failures (timeout, disconnect, no stream) have an `error` field
if (!result.ok && result.error) {
const parts = [result.error];
if (result.stdout) parts.push(`Partial output:\n${result.stdout}`);
if (result.stderr) parts.push(`Stderr:\n${result.stderr}`);
return { ok: false, error: parts.join('\n\n') };
}
// Command ran (even if exit code is non-zero) — always return stdout+exitCode for LLM to judge
return {
ok: true,
data: {
@@ -91,124 +101,6 @@ export async function executeTerminalExecute(
};
}
export async function executeTerminalSendInput(
deps: ToolDeps,
args: { sessionId: string; input: string },
): Promise<ToolExecResult<{ sent: string }>> {
const { bridge, context, commandBlocklist, permissionMode } = deps;
const { sessionId, input } = args;
if (!sessionId || !input) {
return { ok: false, error: 'Missing sessionId or input' };
}
const scopeErr = validateSessionScope(context, sessionId);
if (scopeErr) return { ok: false, error: scopeErr };
if (isObserver(permissionMode)) {
return { ok: false, error: 'Observer mode: terminal input is disabled. Switch to Confirm or Auto mode.' };
}
const safety = checkCommandSafety(input, commandBlocklist);
if (safety.blocked) {
return { ok: false, error: `Input blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
}
const result = await bridge.aiTerminalWrite(sessionId, input);
if (!result.ok) {
return { ok: false, error: result.error || 'Failed to send input' };
}
return { ok: true, data: { sent: input } };
}
export async function executeSftpListDirectory(
deps: ToolDeps,
args: { sessionId: string; path: string },
): Promise<ToolExecResult<{ files?: unknown; output?: string }>> {
const { bridge, context } = deps;
const { sessionId, path } = args;
const scopeErr = validateSessionScope(context, sessionId);
if (scopeErr) return { ok: false, error: scopeErr };
const session = context.sessions.find(s => s.sessionId === sessionId);
if (!session?.sftpId) {
// Fallback: use terminal exec with ls
const result = await bridge.aiExec(sessionId, `ls -la ${shellQuote(path)}`);
if (!result.ok) {
return { ok: false, error: result.error || 'Failed to list directory' };
}
return { ok: true, data: { output: result.stdout || '(empty directory)' } };
}
const files = await bridge.listSftp(session.sftpId, path);
return { ok: true, data: { files } };
}
export async function executeSftpReadFile(
deps: ToolDeps,
args: { sessionId: string; path: string; maxBytes?: number },
): Promise<ToolExecResult<{ content: string }>> {
const { bridge, context } = deps;
const { sessionId, path } = args;
if (!sessionId || !path) {
return { ok: false, error: 'Missing sessionId or path' };
}
const scopeErr = validateSessionScope(context, sessionId);
if (scopeErr) return { ok: false, error: scopeErr };
const session = context.sessions.find(s => s.sessionId === sessionId);
if (!session?.sftpId) {
const clampedMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(args.maxBytes) || 10000));
const result = await bridge.aiExec(sessionId, `head -c ${clampedMaxBytes} ${shellQuote(path)}`);
if (!result.ok) {
return { ok: false, error: result.error || 'Failed to read file' };
}
return { ok: true, data: { content: result.stdout || '(empty file)' } };
}
let content = await bridge.readSftp(session.sftpId, path);
const maxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(args.maxBytes) || 10000));
if (content && content.length > maxBytes) {
content = content.slice(0, maxBytes);
}
return { ok: true, data: { content: content || '(empty file)' } };
}
export async function executeSftpWriteFile(
deps: ToolDeps,
args: { sessionId: string; path: string; content: string },
): Promise<ToolExecResult<{ written: string }>> {
const { bridge, context, permissionMode } = deps;
const { sessionId, path, content } = args;
if (!sessionId || !path) {
return { ok: false, error: 'Missing sessionId or path' };
}
const scopeErr = validateSessionScope(context, sessionId);
if (scopeErr) return { ok: false, error: scopeErr };
if (isObserver(permissionMode)) {
return { ok: false, error: 'Observer mode: file writing is disabled. Switch to Confirm or Auto mode.' };
}
const session = context.sessions.find(s => s.sessionId === sessionId);
if (!session?.sftpId) {
// Fallback: base64 encoding to avoid heredoc injection
const b64 = typeof btoa === 'function'
? btoa(unescape(encodeURIComponent(content)))
: Buffer.from(content, 'utf-8').toString('base64');
const result = await bridge.aiExec(
sessionId,
`echo ${shellQuote(b64)} | base64 -d > ${shellQuote(path)}`,
);
if (!result.ok) {
return { ok: false, error: result.error || 'Failed to write file' };
}
return { ok: true, data: { written: path } };
}
await bridge.writeSftp(session.sftpId, path, content);
return { ok: true, data: { written: path } };
}
export function executeWorkspaceGetInfo(
deps: ToolDeps,
): ToolExecResult<{
@@ -223,7 +115,7 @@ export function executeWorkspaceGetInfo(
connected: boolean;
}>;
}> {
const { context } = deps;
const context = resolveContext(deps.context);
return {
ok: true,
data: {
@@ -245,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}` };
@@ -253,70 +145,75 @@ export function executeWorkspaceGetSessionInfo(
return { ok: true, data: session };
}
export async function executeMultiHostExecute(
// ---------------------------------------------------------------------------
// Web Search & URL Fetch (read-only, no permission check needed)
// ---------------------------------------------------------------------------
export async function executeWebSearch(
deps: ToolDeps,
args: {
sessionIds: string[];
command: string;
mode?: string;
stopOnError?: boolean;
},
): Promise<ToolExecResult<{ results: Record<string, { ok: boolean; output: string }> }>> {
const { bridge, context, commandBlocklist, permissionMode } = deps;
const { sessionIds, command, mode = 'parallel', stopOnError = false } = args;
args: { query: string; maxResults?: number },
): Promise<ToolExecResult<{ results: Array<{ title: string; url: string; content: string }> }>> {
const { bridge, webSearchConfig } = deps;
if (sessionIds.length === 0 || !command) {
return { ok: false, error: 'Missing sessionIds or command' };
if (!webSearchConfig?.enabled) {
return { ok: false, error: 'Web search is not enabled. Please configure a search provider in Settings → AI.' };
}
if (!args.query) {
return { ok: false, error: 'Missing search query' };
}
const currentValidIds = validSessionIds(context);
const outOfScope = sessionIds.filter(sid => !currentValidIds.has(sid));
if (outOfScope.length > 0) {
return {
ok: false,
error: `Sessions not in current scope: ${outOfScope.join(', ')}. Available sessions: ${[...currentValidIds].join(', ')}`,
};
try {
const maxResults = Math.max(1, Math.min(20, args.maxResults ?? webSearchConfig.maxResults ?? 5));
const results = await executeWebSearchProvider(bridge, webSearchConfig, args.query, maxResults);
// Enforce maxResults after provider normalization (some providers ignore the limit)
return { ok: true, data: { results: results.slice(0, maxResults) } };
} catch (err) {
return { ok: false, error: `Web search failed: ${err instanceof Error ? err.message : String(err)}` };
}
}
interface BridgeFetchResponse {
ok: boolean;
status?: number;
data?: string;
error?: string;
}
export async function executeUrlFetch(
deps: ToolDeps,
args: { url: string; maxLength?: number },
): Promise<ToolExecResult<{ url: string; content: string; status: number }>> {
const { bridge } = deps;
const { url } = args;
if (!url || !url.startsWith('https://')) {
return { ok: false, error: 'Invalid URL. Must start with https://' };
}
const aiFetch = (bridge as unknown as Record<string, (...a: unknown[]) => Promise<unknown>>).aiFetch;
if (!aiFetch) {
return { ok: false, error: 'aiFetch is not available on the bridge' };
}
try {
// skipHostCheck=true, followRedirects=true: url_fetch targets user-provided URLs
const resp = await aiFetch(url, 'GET', {
'User-Agent': 'Netcatty-AI/1.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7',
}, undefined, undefined, true, true) as BridgeFetchResponse;
if (!resp.ok) {
return { ok: false, error: resp.error || `HTTP ${resp.status}` };
}
const maxLength = Math.max(1, Math.min(200000, args.maxLength ?? 50000));
let content = resp.data || '';
if (content.length > maxLength) {
content = content.slice(0, maxLength) + '\n\n[Content truncated]';
}
return { ok: true, data: { url, content, status: resp.status || 200 } };
} catch (err) {
return { ok: false, error: `URL fetch failed: ${err instanceof Error ? err.message : String(err)}` };
}
if (isObserver(permissionMode)) {
return { ok: false, error: 'Observer mode: command execution is disabled. Switch to Confirm or Auto mode.' };
}
const safety = checkCommandSafety(command, commandBlocklist);
if (safety.blocked) {
return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
}
const results: Record<string, { ok: boolean; output: string }> = {};
if (mode === 'sequential') {
for (const sid of sessionIds) {
const session = context.sessions.find(s => s.sessionId === sid);
const label = session?.label || sid;
const result = await bridge.aiExec(sid, command);
results[label] = {
ok: result.ok,
output: result.ok
? result.stdout || '(no output)'
: `Error: ${result.error || result.stderr || 'Failed'}`,
};
if (!result.ok && stopOnError) break;
}
} else {
const tasks = sessionIds.map((sid) => () => {
const session = context.sessions.find(s => s.sessionId === sid);
const label = session?.label || sid;
return bridge.aiExec(sid, command).then(result => ({
label,
ok: result.ok,
output: result.ok
? result.stdout || '(no output)'
: `Error: ${result.error || result.stderr || 'Failed'}`,
}));
});
const resolved = await limitConcurrency(tasks, 10);
for (const r of resolved) {
results[r.label] = { ok: r.ok, output: r.output };
}
}
return { ok: true, data: { results } };
}

View File

@@ -0,0 +1,214 @@
/**
* Web search provider implementations.
*
* Each provider function normalises its API response into a common
* `{ results: Array<{ title, url, content }> }` shape so callers don't need
* to know about provider-specific quirks.
*
* All HTTP requests go through `bridge.aiFetch()` to avoid CORS issues in the
* renderer process.
*/
import type { NetcattyBridge } from '../cattyAgent/executor';
import type { WebSearchConfig } from '../types';
import { WEB_SEARCH_PROVIDER_PRESETS } from '../types';
export interface WebSearchResult {
title: string;
url: string;
content: string;
}
interface BridgeFetchResponse {
ok: boolean;
status?: number;
data?: string;
error?: string;
}
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
function resolveApiHost(config: WebSearchConfig): string {
return config.apiHost || WEB_SEARCH_PROVIDER_PRESETS[config.providerId].defaultApiHost;
}
async function fetchJson(
bridge: NetcattyBridge,
url: string,
method: string,
headers: Record<string, string>,
body?: string,
): Promise<unknown> {
const aiFetch = (bridge as unknown as Record<string, (...args: unknown[]) => Promise<unknown>>).aiFetch;
if (!aiFetch) throw new Error('aiFetch is not available on the bridge');
// Search API hosts are added to the allowlist via aiSyncWebSearch, no skipHostCheck needed
const resp = await aiFetch(url, method, headers, body) as BridgeFetchResponse;
if (!resp.ok) throw new Error(resp.error || `HTTP ${resp.status}`);
return JSON.parse(resp.data || '{}');
}
// ---------------------------------------------------------------------------
// Tavily
// ---------------------------------------------------------------------------
async function searchTavily(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
maxResults: number,
): Promise<WebSearchResult[]> {
const host = resolveApiHost(config);
const data = await fetchJson(bridge, `${host}/search`, 'POST', {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
}, JSON.stringify({
query,
max_results: maxResults,
search_depth: 'basic',
})) as { results?: Array<{ title?: string; url?: string; content?: string }> };
return (data.results || []).map(r => ({
title: r.title || '',
url: r.url || '',
content: r.content || '',
}));
}
// ---------------------------------------------------------------------------
// Exa
// ---------------------------------------------------------------------------
async function searchExa(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
maxResults: number,
): Promise<WebSearchResult[]> {
const host = resolveApiHost(config);
const data = await fetchJson(bridge, `${host}/search`, 'POST', {
'Content-Type': 'application/json',
'x-api-key': config.apiKey || '',
}, JSON.stringify({
query,
numResults: maxResults,
contents: { text: true },
})) as { results?: Array<{ title?: string; url?: string; text?: string }> };
return (data.results || []).map(r => ({
title: r.title || '',
url: r.url || '',
content: r.text || '',
}));
}
// ---------------------------------------------------------------------------
// Bocha
// ---------------------------------------------------------------------------
async function searchBocha(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
maxResults: number,
): Promise<WebSearchResult[]> {
const host = resolveApiHost(config);
const data = await fetchJson(bridge, `${host}/v1/web-search`, 'POST', {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
}, JSON.stringify({
query,
count: maxResults,
summary: true,
})) as { webPages?: { value?: Array<{ name?: string; url?: string; snippet?: string; summary?: string }> } };
return (data.webPages?.value || []).map(r => ({
title: r.name || '',
url: r.url || '',
content: r.summary || r.snippet || '',
}));
}
// ---------------------------------------------------------------------------
// Zhipu
// ---------------------------------------------------------------------------
async function searchZhipu(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
_maxResults: number,
): Promise<WebSearchResult[]> {
const host = resolveApiHost(config);
const data = await fetchJson(bridge, `${host}/web_search`, 'POST', {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
}, JSON.stringify({
search_query: query,
search_engine: 'search_std',
})) as { search_result?: Array<{ title?: string; link?: string; content?: string }> };
return (data.search_result || []).map(r => ({
title: r.title || '',
url: r.link || '',
content: r.content || '',
}));
}
// ---------------------------------------------------------------------------
// SearXNG
// ---------------------------------------------------------------------------
async function searchSearxng(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
_maxResults: number,
): Promise<WebSearchResult[]> {
const host = resolveApiHost(config);
if (!host) throw new Error('SearXNG requires an API Host to be configured');
const url = `${host}/search?q=${encodeURIComponent(query)}&format=json`;
const data = await fetchJson(bridge, url, 'GET', {}) as {
results?: Array<{ title?: string; url?: string; content?: string }>;
};
return (data.results || []).map(r => ({
title: r.title || '',
url: r.url || '',
content: r.content || '',
}));
}
// ---------------------------------------------------------------------------
// Dispatcher
// ---------------------------------------------------------------------------
const PROVIDER_SEARCH_FNS: Record<string, typeof searchTavily> = {
tavily: searchTavily,
exa: searchExa,
bocha: searchBocha,
zhipu: searchZhipu,
searxng: searchSearxng,
};
/**
* Placeholder token for the web search API key.
* The renderer sends this in HTTP headers; the main process replaces it
* with the real decrypted key before the request is sent, so plaintext
* keys never enter the renderer.
*/
const WEB_SEARCH_KEY_PLACEHOLDER = '__WEB_SEARCH_KEY__';
export async function executeWebSearchProvider(
bridge: NetcattyBridge,
config: WebSearchConfig,
query: string,
maxResults: number,
): Promise<WebSearchResult[]> {
const fn = PROVIDER_SEARCH_FNS[config.providerId];
if (!fn) throw new Error(`Unsupported web search provider: ${config.providerId}`);
// Use placeholder — main process replaces with real decrypted key before HTTP request
const safeConfig = { ...config, apiKey: WEB_SEARCH_KEY_PLACEHOLDER };
return fn(bridge, safeConfig, query, maxResults);
}

View File

@@ -10,6 +10,7 @@ export interface ProviderConfig {
defaultModel?: string;
customHeaders?: Record<string, string>;
enabled: boolean;
skipTLSVerify?: boolean; // skip TLS certificate verification (for self-signed certs)
}
export interface ModelInfo {
@@ -47,7 +48,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;
@@ -99,6 +100,7 @@ export interface AISession {
agentId: string;
scope: AISessionScope;
messages: ChatMessage[];
externalSessionId?: string;
createdAt: number;
updatedAt: number;
}
@@ -162,6 +164,38 @@ export interface DiscoveredAgent {
acpArgs?: string[];
}
// Web Search types
export type WebSearchProviderId = 'tavily' | 'exa' | 'bocha' | 'zhipu' | 'searxng';
export interface WebSearchConfig {
providerId: WebSearchProviderId;
apiKey?: string; // enc:v1: encrypted via credentialBridge
apiHost?: string; // custom API endpoint (required for SearXNG)
enabled: boolean;
maxResults?: number; // default 5
}
export const WEB_SEARCH_PROVIDER_PRESETS: Record<WebSearchProviderId, { name: string; defaultApiHost: string; requiresApiKey: boolean }> = {
tavily: { name: 'Tavily', defaultApiHost: 'https://api.tavily.com', requiresApiKey: true },
exa: { name: 'Exa', defaultApiHost: 'https://api.exa.ai', requiresApiKey: true },
bocha: { name: 'Bocha', defaultApiHost: 'https://api.bochaai.com', requiresApiKey: true },
zhipu: { name: 'Zhipu', defaultApiHost: 'https://open.bigmodel.cn/api/paas/v4', requiresApiKey: true },
searxng: { name: 'SearXNG', defaultApiHost: '', requiresApiKey: false },
};
/** Check if a WebSearchConfig is fully configured and ready to use. */
export function isWebSearchReady(config?: WebSearchConfig | null): boolean {
if (!config?.enabled) return false;
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
if (preset?.requiresApiKey && !config.apiKey) return false;
if (config.providerId === 'searxng' && !config.apiHost) return false;
// Validate apiHost is a well-formed URL if provided
if (config.apiHost) {
try { new URL(config.apiHost); } catch { return false; }
}
return true;
}
// AI Settings (stored in localStorage)
export interface AISettings {
providers: ProviderConfig[];
@@ -173,6 +207,7 @@ export interface AISettings {
commandBlocklist: string[]; // global command blocklist patterns
commandTimeout: number; // seconds, default 60
maxIterations: number; // doom loop prevention, default 20
webSearchConfig?: WebSearchConfig;
}
export const DEFAULT_COMMAND_BLOCKLIST = [

View File

@@ -87,3 +87,4 @@ export const STORAGE_KEY_AI_COMMAND_TIMEOUT = 'netcatty_ai_command_timeout_v1';
export const STORAGE_KEY_AI_MAX_ITERATIONS = 'netcatty_ai_max_iterations_v1';
export const STORAGE_KEY_AI_SESSIONS = 'netcatty_ai_sessions_v1';
export const STORAGE_KEY_AI_AGENT_MODEL_MAP = 'netcatty_ai_agent_model_map_v1';
export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_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();
}

BIN
public/ai/search/bocha.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
public/ai/search/exa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 92 92"><g transform="translate(-40.921 -17.417)"><circle cx="75.921" cy="53.903" r="30" style="fill:none;stroke:#3050ff;stroke-width:10"/><path d="M67.515 37.915a18 18 0 0 1 21.051 3.313 18 18 0 0 1 3.138 21.078" style="fill:none;stroke:#3050ff;stroke-width:5"/><rect width="18.846" height="39.963" x="3.706" y="122.09" ry="0" style="fill:#3050ff" transform="rotate(-46.235)"/></g></svg>

After

Width:  |  Height:  |  Size: 441 B

View File

@@ -0,0 +1,3 @@
<svg viewBox="0 0 56 56" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M39.5137 0C45.2842 0 48.17 0 50.374 1.12305C52.3127 2.11089 53.8892 3.68731 54.877 5.62598C56 7.82995 56 10.7153 56 16.4854V39.5146C56 45.2847 56 48.17 54.877 50.374C53.8891 52.3127 52.3127 53.8891 50.374 54.877C48.17 56 45.2842 56 39.5137 56H16.4854C10.7148 56 7.82905 56 5.625 54.877C3.68646 53.8891 2.11082 52.3126 1.12305 50.374C0 48.17 0 45.2849 0 39.5146V16.4854C0 10.7151 0 7.82999 1.12305 5.62598C2.11082 3.68739 3.68646 2.11089 5.625 1.12305C7.82905 0 10.7148 0 16.4854 0H39.5137ZM23.8105 30.958C23.5077 30.9581 23.2076 31.0175 22.9277 31.1338C22.6478 31.2502 22.393 31.4216 22.1787 31.6367L17.7705 36.0625L16.5986 34.8867C15.7377 34.0228 14.2649 34.4498 13.9971 35.6426L12.3271 43.0713C12.2686 43.3267 12.2752 43.593 12.3477 43.8447C12.4199 44.0956 12.555 44.3246 12.7393 44.5088L12.7383 44.5107C12.922 44.6967 13.1498 44.8324 13.4004 44.9053C13.6513 44.9782 13.9173 44.9856 14.1719 44.9268L21.5713 43.25C22.7588 42.9812 23.1851 41.502 22.3242 40.6377L21.1523 39.4619L25.5615 35.0371C25.9943 34.6025 26.2373 34.012 26.2373 33.3975C26.2372 32.783 25.9942 32.1934 25.5615 31.7588L25.5029 31.6992L25.5049 31.6982L25.4434 31.6367C25.229 31.4215 24.9744 31.2503 24.6943 31.1338C24.4144 31.0174 24.1136 30.958 23.8105 30.958ZM39.7139 28.1689C38.6842 27.5158 37.3429 28.2597 37.3428 29.4824V31.1445H27.8955C28.2111 31.7502 28.3916 32.439 28.3916 33.1699C28.3915 34.2266 28.0177 35.196 27.3965 35.9521H37.3418V37.6143C37.342 38.837 38.6843 39.58 39.7139 38.9268L46.1279 34.8613C46.6077 34.5556 46.8476 34.0509 46.8477 33.5469C46.847 33.0436 46.6067 32.5399 46.126 32.2354L39.7139 28.1689ZM24.0391 10.4062C23.778 10.4051 23.5207 10.4712 23.292 10.5977C23.063 10.7243 22.869 10.9083 22.7305 11.1309L18.6807 17.5684H18.6787C18.028 18.602 18.7694 19.9499 19.9873 19.9502H21.6436V29.5137C22.3307 29.0592 23.1537 28.794 24.0381 28.7939C24.9228 28.794 25.7453 29.0599 26.4326 29.5146V19.9502H28.0898C29.3077 19.9501 30.047 18.6028 29.3975 17.5684L25.3457 11.1309C25.0415 10.6489 24.5406 10.4068 24.0391 10.4062Z" fill="#468BFF"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
public/ai/search/zhipu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB