Compare commits

..

21 Commits

Author SHA1 Message Date
libalpm64
071c95ab5c chore(deps): bump fast-xml-parser and @aws-sdk/xml-builder
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Closes #770
2026-04-19 16:38:44 +08:00
陈大猫
ec99875dec [codex] avoid main-process runtime crashes (#772)
* avoid main-process runtime crashes

* fix main-process startup error boundary

* tighten main-process startup readiness

* fix startup fallback window health checks

* exclude hidden windows from recovery checks
2026-04-19 16:31:00 +08:00
陈大猫
51a6b7efaa Preload compact history on first turn after app restart (#753 hedge) (#769)
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* Preload compact history on first turn after app restart (#753 hedge)

Symptom (confirmed on Copilot CLI, originally reported on Codex in
#753): after closing and reopening Netcatty, the AI chat UI still
shows the prior conversation but the agent responds "this is the
beginning of our conversation, no previous records". Earlier context
is lost entirely.

Root cause: the bridge relied on session/load throwing "not found" to
trigger the catch-block fallback that replays compact history. Some
ACP agents (Copilot CLI, some Codex builds) silently spawn a new
session when handed a stale id instead of erroring. The catch-block
never fires → historyReplayFallback stays false → the first turn
sends only the latest prompt → agent sees zero context.

Fix: when we're creating a new provider process AND telling it to
resume an existing session id AND the renderer gave us compact
history, preload historyReplayFallback=true as a hedge. If the agent
really did reload the session, the replay is ~3KB of redundant
context (small waste). If the agent silently started fresh, the
replay restores durable constraints + last few raw turns so the
first response is coherent.

After the first successful streamed turn clears the flag (the round-2
post-stream hook), steady state is back to sending only the latest
prompt. Cost is bounded to one replay per app-restart-and-prompt.

Test: "replays compact history on the first turn after app restart
even when session/load 'succeeds'" — mocks createACPProvider to
behave like Copilot CLI (no error thrown, no real resume), asserts
the first streamText call carries history+latest (length 2) and the
second only latest (length 1).

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

* Fix AI session resume and agent switching

* Preserve hidden draft when switching agents

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:44:41 +08:00
陈大猫
30f5346035 Classify AI proxy / size-limit errors instead of showing raw Zod output (#765) (#768)
Symptom: when an AI request is proxied through nginx (or any gateway)
and the request body exceeds client_max_body_size, the proxy returns a
413 HTML error page. The Vercel AI SDK then fails to parse the HTML
as a chat completion and surfaces a cryptic Zod validation error like
"Expected 'id' to be a string." through the UI — users have no idea
what's wrong.

Root cause: classifyError only did light sanitization and returned the
raw SDK message. It also string-coerced the error before inspection, so
the structured statusCode / responseBody fields that APICallError
attaches were thrown away.

Fix: classifyError now accepts `unknown` and inspects the full error
shape. Adds explicit branches for:

- HTTP 413 (from statusCode, cause.statusCode, or message text) →
  "Request too large — exceeded proxy size limit. Try shorter
  message, fewer attachments, or raise client_max_body_size."
- HTTP 502/503/504 → retryable upstream-gateway message
- HTML response body (starts with <!DOCTYPE/<html> or contains such
  tags anywhere) → "Server returned HTML error page, likely a proxy
  intercept."
- Zod/schema parse shapes ("Expected 'X' to be …", "Invalid JSON
  response", "Type validation failed") → "Response could not be
  parsed; proxy may have replaced/truncated the body."

In every classified case the raw SDK text is still appended ("Raw: …")
so users can report the underlying error verbatim.

useAIChatStreaming.ts callers now pass the raw error to classifyError
instead of `.message`, so the new structured branches actually fire.
Also wired infrastructure/ai/*.test.ts into the npm test glob.

Closes #765

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:50:25 +08:00
陈大猫
e0302e5f34 Batch Windows hidden-attribute detection in local FS listing (#766) (#767)
* Batch Windows hidden-attribute detection in local FS listing (#766)

Symptom: opening a local directory with ~800 files in the SFTP panel
hangs for ~30 s on Windows. Reported on netcatty 1.0.93.

Root cause: listLocalDir spawns attrib.exe once per entry inside the
worker pool to detect the Windows hidden flag. 800 subprocess spawns
× ~40 ms each is precisely the reported 30 s. fs.promises.stat and
readdir on their own are nearly free; the subprocess flood dominates.

Fix: replace the per-entry attrib call with a single
`attrib.exe "<dir>\*"` invocation up front, parse its output into a
Set<basename>, and have the workers do an O(1) set lookup. One
subprocess per directory listing instead of one per entry.

Expected speedup for the #766 case: ~30 s → <1 s. Behavior is
unchanged — hidden files keep their hidden flag, non-hidden files
stay not-hidden; only the mechanism is different. Broken-symlink
handling (lstat fallback) also uses the same set.

Tests:
- parseAttribOutput is extracted as a pure function and unit-tested
  against real attrib output shapes: drive-letter paths, UNC paths,
  the trailing [DIR] marker that some Windows versions emit, mixed
  flag columns (A/H/R), malformed "Parameter format not correct"
  lines, empty input.
- listWindowsHiddenBasenames short-circuits on non-Windows without
  spawning anything.
- Parser uses path.win32.basename explicitly so the tests pass under
  non-Windows CI.

I cannot reproduce or test on Windows directly. The diagnosis is
mechanical (we can count subprocess calls) and the fix is a local
rewrite that preserves behavior, but Windows verification is still
desirable before release.

Closes #766

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

* Address codex review on #767: pass /d so batched attrib includes hidden directories

Codex flagged that attrib.exe treats `<dir>\*` as file-centric by
default — without `/d`, hidden directories (node_modules, .git, etc.)
never appear in the output, so listWindowsHiddenBasenames misses them
and the SFTP browser shows those folders as not-hidden. This is a
behavior regression from the per-file path, which passed each entry's
full path directly and therefore covered both files and directories.

Added `/d` to the execFileAsync argv and a regression test that
module-mocks child_process.execFile to capture the argv and assert
`/d` is present. The parser-level [DIR] marker test is also still
there, so both the attrib call shape and the parser behavior are
locked down.

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

* Address codex round 2 on #767: tighten [DIR] strip to the literal marker

Codex flagged that /\s+\[[^\]]+\]\s*$/ also swallows legitimate trailing
bracketed text, so a hidden file named "Notes [old]" gets stored as
"Notes" in hiddenSet and hiddenSet.has("Notes [old]") returns false —
the entry is misclassified as not-hidden, a regression from the old
per-entry attrib path which never saw a "[DIR]" marker to strip.

Narrowed the regex to /\s+\[DIR\]\s*$/ — only the literal attrib/d
marker. Added a regression test covering "Notes [old]", "Draft [v2].md",
"archived [2024]" alongside the existing [DIR] case to lock down both
behaviors together.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 22:32:33 +08:00
Eric Chan
0425841032 Fix ACP history replay and compaction (#754)
* Fix ACP history replay and compaction

* Fix PR keyword importance matching

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address codex review on #754: preserve short constraints + cancel-clear

Two recovery-path regressions flagged by codex review:

1. Compact ACP history dropped short load-bearing user constraints
   (acpHistory.ts:55). The blanket length<10 rule treated short
   non-trivial messages like "Use ssh2" or "中文输出" as filler,
   while longer generic follow-ups still ate the budget. After
   stale-session recovery the fresh ACP session would resume without
   constraints that were present in the original chat. Removed the
   length heuristic; the TRIVIAL_USER_MESSAGE_PATTERNS regex already
   filters actual filler ("ok", "yes", "继续", "thanks").

2. historyReplayFallback was only cleared on non-aborted streams
   (aiBridge.cjs:2837). If the user stopped the first turn after
   stale-session recovery, the flag stayed set. The next turn would
   then trigger shouldResetProviderForHistoryReplay, discard the
   freshly recovered ACP session (resumeSessionId is forced to
   undefined in that path), and re-spend tokens on another compact
   replay — breaking the cancel-preserves-session contract. Now we
   also clear on abort; the empty-but-not-aborted retry path in the
   if-branch above is unchanged.

Tests:
- New test in acpHistory.test.ts asserts "Use ssh2" / "中文输出"
  survive when pushed outside the recent raw window
- New test asserts "ok" / "继续" still drop (sanity check that the
  trivial regex still does its job without the length backstop)
- Updated "does not treat pr inside ordinary words as important" to
  no longer assert that approach/improve/prepare are absent — the
  test's real intent (priority-2 line still wins) is preserved by
  the 不要提交 assertion
- New test in aiBridge.test.cjs simulates a user cancelling the first
  turn after recovery and verifies the next turn reuses the
  recovered session (no extra provider creation, no re-replay)

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

* Address codex re-review: preserve replay flag across orthogonal recreation + keep tool output in raw window

Two more P2 regressions flagged on the second review pass:

1. historyReplayFallback was only carried over in the reset-for-replay
   branch of the provider recreation path. An orthogonal change between
   an empty recovered turn and its retry — a permission-mode toggle,
   MCP scope/fingerprint flip, or auth rotation — would flip
   shouldReuseProvider to false, enter the !shouldReuseProvider branch,
   and drop the flag because preserveHistoryReplayFallback only covered
   the shouldResetProviderForHistoryReplay case. The next turn then
   sent only the latest prompt and lost the recovered conversation.
   Now the flag is preserved on any recreation where a replay is still
   pending.

2. Tool messages didn't flow through toRawHistoryMessage at all, so on
   stale-session recovery they only survived as the 500-char compact
   summary in summarizeToolMessage. Any follow-up referencing the last
   tool output ("use that output", "what did cat show?") lost the
   actual bytes when they exceeded the compact cap. Now tool results
   travel through the recent raw window up to MAX_RAW_MESSAGE_CHARS
   (2000), flattened to the "assistant" role since ACP only accepts
   user/assistant.

Tests:
- aiBridge.test.cjs: new "preserves history-replay across provider
  recreation caused by permission-mode / MCP / auth change" —
  exercises the gap via a permission-mode toggle between an empty
  recovered turn and its retry. Extends mock to support a dynamic
  getPermissionMode.
- acpHistory.test.ts: new "preserves recent tool results verbatim" —
  pushes a ~1500-char tool output through the pipeline and asserts the
  replay still contains enough bytes to exceed the 500-char compact
  cap.

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

* Address codex round 3: inline tool_call context + bound durable scan

Two findings from the third codex review pass, both legitimate:

1. [P2] When the raw window starts mid-tool-interaction, the preceding
   assistant tool_call message can fall outside the 6-item slice while
   the tool_result stays in. Without the call's name+arguments, the
   result was opaque bytes and follow-ups like "use that output" had
   no provenance. The compact pass only preserved calls that matched
   IMPORTANT_PATTERNS, so read_file / grep / terminal_exec were
   silently dropped.

   Fix: build a toolCallId → { name, arguments } index from every
   assistant message and inline a `[from <name>(<args>)]` label next
   to each Tool result line in the raw window. Args are truncated to
   MAX_TOOL_CALL_LABEL_CHARS (200) so a verbose JSON payload can't eat
   the entire raw budget.

2. [P3] buildCompactContext scanned messages.entries() over the full
   transcript for durable-user/assistant candidates, even though
   MAX_MESSAGES_TO_SCAN (20) suggested the path was meant to be
   bounded. On a long ACP chat, every send did O(N) regex work plus
   an O(N log N) sort — the very chat-length-dependent latency the
   token-compaction PR was meant to address.

   Fix: introduce MAX_DURABLE_SCAN_MESSAGES (200) and restrict the
   durable scan to that tail. 200 is large enough to cover realistic
   sessions (99th-percentile chats are << 200 turns) while giving a
   constant-time worst case. Constraints older than the window age
   out of the compact replay; the live ACP provider's own persisted
   session still carries them when it can resume, which is the
   common path.

Tests:
- "inlines tool_call name+args so tool_result is interpretable without
  the preceding assistant turn" — pushes the tool_call out of the raw
  window and asserts the result line carries [from <tool>(<args>)].
- "bounds the durable-candidate scan to avoid O(N) work per send on
  long chats" — builds a 600+ message chat with an ancient priority-2
  constraint outside the scan window and a recent one inside; asserts
  only the recent one survives.

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

* Address codex round 4: preserve short assistant decisions + provenance on older tool results

Two P2 findings from the fourth codex pass, both mirror-images of earlier
fixes on a different code path:

1. Short assistant decisions dropped from compact replay
   (acpHistory.ts:75-83). isSubstantiveAssistantMessage required length
   >= 40 OR a small English keyword match OR a numbered list. Short but
   load-bearing replies like "Use ssh2", "rebase instead", "中文输出"
   satisfied none of those and were silently dropped from the durable-
   assistant compact section. Once they fell outside the 6-item raw
   window, "do what you suggested earlier" would replay only the user
   question without the assistant's actual decision.

   Fix: mirror the user-side loosening — drop the length/keyword gate,
   rely on TRIVIAL_ASSISTANT_MESSAGE_PATTERNS to filter actual filler
   ("ok", "ack", "got it", "明白").

2. Older tool results lost provenance (acpHistory.ts:108-114). The
   raw-window fix (round 3) only covered the last 6 items. Once a tool
   result fell into the compact section via summarizeToolMessage, the
   paired assistant tool_call was usually gone too, so multiple older
   outputs surfaced as indistinguishable "Tool result (callN): ...".
   Follow-ups like "use the resolv.conf output" had no way to map to
   the right call.

   Fix: plumb the toolCallIndex through summarizeMessage →
   summarizeToolMessage and inline `[from <name>(<args>)]` labels in
   the compact section too, the same shape the raw window uses.

Tests:
- New: preserves short non-trivial assistant decisions that miss the
  keyword heuristic (Use ssh2 / 中文输出 / rebase instead)
- New: still drops trivial assistant filler like 'ack' / 'ok' / '明白'
- New: inlines tool_call context on OLDER summarized tool results
- Updated earlier raw-window tool regex tests to match the [from X(Y)]
  shape ([^)] was failing to cross the args JSON's closing paren)

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

* Address codex round 5: de-dup raw ∩ compact + wire userSkills test into npm test

[P2] The scanned loop (last 20) overlaps with recentRaw (last 6), so
without a raw-window skip in the summarizeMessage path the same last-6
turns were summarized into the compact section AND appended verbatim
in the raw section. Important user turns and large tool output paid
the budget twice — eating into the 3k compact cap and crowding out
older durable context the replay is meant to preserve. Added the
same recentRawSourceIds skip the durable-user / durable-assistant
passes already use, and a regression test that asserts markers inside
the raw window don't surface in compact while still appearing in raw.

[P3] electron/bridges/ai/userSkills.test.cjs (added by this PR) sat
in a subdirectory that the default "npm test" glob
(electron/bridges/*.test.cjs) didn't pick up. The new routing /
index-budget regressions would never run locally or in CI until
someone noticed. Extended the glob to also match
electron/bridges/*/*.test.cjs; the userSkills tests are now included
in the 148-test run.

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

* Address codex round 6: cancel+immediate-send race + tool-call id collision

Two P2 regressions in the recovery path:

1. If the user clicks Stop and immediately sends the next prompt, the
   new stream handler's existingRun path unconditionally called
   cleanupAcpProvider — destroying the fresh ACP session the cancel
   IPC had just promised to preserve. The round-2 clear-on-abort
   fix ran too late (in post-stream code) to help, because the new
   stream can arrive before the aborted stream fully unwinds. In
   that common timing window the follow-up still started from a
   bare provider and lost all recovered conversation state.

   Fix: (a) cancel IPC now synchronously clears
   historyReplayFallback on the preserved provider entry, so the
   next stream can't trigger shouldResetProviderForHistoryReplay
   and tear the session down via that path; (b) the existingRun
   path skips cleanupAcpProvider when the prior run was already
   cancelled via the cancel IPC (captured via existingRun.cancelRequested
   before we overwrite it). True interrupt-and-restart without an
   explicit cancel still falls back to the old clean-slate behavior.

2. The tool-call provenance index used raw toolCall.id as the key.
   Nothing in ChatMessage or the ACP event path enforces per-chat
   unique ids, so a provider reusing "call1" across turns would
   overwrite the older entry and mis-label older tool results
   (e.g., an /etc/hosts result annotated as /etc/resolv.conf in
   the compact summary). That makes stale-session recovery
   misleading whenever a follow-up refers back to an earlier tool
   output.

   Fix: key the index by `${toolResultMessageId}:${toolCallId}` and
   walk the message stream in order, resolving each tool_result to
   the most recent preceding assistant tool_call with matching id.
   Each result keeps its own historically-correct label regardless
   of later id reuse.

Tests:
- aiBridge: "preserves recovered ACP session when user cancels then
  immediately sends the next prompt" — fires the next stream request
  after cancel but BEFORE releasing the first stream's blocked read,
  asserts providerCreationArgs.length stays at 2 (no third creation)
  and the second turn sends only the latest prompt.
- acpHistory: "resolves tool_call provenance correctly when tool ids
  are reused across turns" — two interactions sharing id "call1",
  asserts each tool_result carries its own call's args label.

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

* Address codex round 7: turn-based scan bound + single-pass history build

Two P2 regressions in long-chat / tool-heavy recovery paths:

1. MAX_DURABLE_SCAN_MESSAGES (200) bounded the scan by raw message
   count. ACP tool interactions store the user turn, assistant
   tool_call turn, and each tool_result as separate messages, so a
   tool-heavy chat can produce 5+ messages per logical turn. 200
   messages could be only 30-40 user turns — early constraints
   like "不要提交" from turn 5 fell out of the compact replay long
   before the turn count justified aging them out.

   Fix: bound by MAX_DURABLE_SCAN_TURNS (100 user turns) instead.
   Walk backwards from the end and stop after seeing 100 user
   messages. Realistic tool-heavy 30-turn chats now keep their
   early constraints alive, while true 100+ turn chats still
   benefit from the bound.

2. buildToolCallIndex(messages) and messages.flatMap(...).slice(-6)
   both walked the entire transcript on every send, even after the
   bounded compaction window landed. Compaction's stated purpose
   was to remove chat-length-dependent latency, but these per-send
   linear passes kept it.

   Fix: compute the scan start once via computeDurableScanStart,
   then do all subsequent work over messages.slice(durableScanStart).
   buildToolCallIndex walks only the window; the raw-6 flatMap also
   runs over the window. On a 1000-message chat with 100-turn
   window, send-time cost drops from O(1000) to O(~window_size).

Acceptable trade: if a tool_call's matching tool_result straddles
the window boundary (result inside, call outside), the single
surviving result loses its [from X(Y)] label. Tool_calls and their
results are almost always adjacent, so this affects at most the
first 1-2 messages of the window.

Tests:
- "preserves an early constraint in a tool-heavy chat where message
  count balloons past the raw-count limit" — 35 turns × 6 msgs/turn =
  212 messages. The old bound would have dropped the early
  EARLY_CONSTRAINT_MARKER; with turn-based bound it survives.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:52:57 +08:00
陈大猫
156550f7eb Add Close All / Others / To-the-Right tab actions (#748) (#764)
Adds three bulk-close items to the right-click context menu on tabs:
- Close Others
- Close Tabs to the Right
- Close All

Anchor is the right-clicked tab (matches VSCode/JetBrains/FinalShell
UX), not the active tab. The "to the right" item is disabled when the
anchor is already the rightmost tab; "Close Others" is disabled when
it's the only tab.

To avoid spamming a busy-shell modal per tab, the new closeTabsBatch
helper in App.tsx expands workspace ids into their session ids, runs
ONE confirmIfBusyLocalTerminal probe across the whole batch, and only
proceeds when the user confirms. The probe + close path itself reuses
the existing PR #739 plumbing (ptyProcessTree + confirmCloseBusy).

Closes #748

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:40:11 +08:00
陈大猫
a1648adf12 Add opt-in setting to preserve mouse selection across keystrokes (#755) (#763)
* Add opt-in setting to preserve mouse selection across keystrokes

Closes #755.

xterm.js hardcodes a "clear selection on user input" listener
(SelectionService.ts: coreService.onUserInput → clearSelection) with
no public option to disable. The user-reported workflow this breaks:
select a path with the mouse, type a command prefix like `sz `, then
middle-click-paste the still-live selection — but the very first
keystroke wipes the selection, so there's nothing left to paste.

Modern terminals (iTerm2, GNOME Terminal, Windows Terminal) preserve
the selection across input by default. We expose this as an opt-in
toggle for now since the visual semantics are a behavior change.

Implementation is capture-and-restore via xterm.js public APIs
(getSelectionPosition / select); xterm clears the selection
synchronously, then a queueMicrotask reapplies it on the next tick.
A ref (isRestoringSelectionRef) gates copy-on-select so the restore
doesn't redundantly rewrite the clipboard and clobber whatever the
user copied elsewhere in between.

Defaults to false (opt-in); can flip to default-on later if reception
is positive. Selection still clears on:

- Mouse click in empty space (xterm's mouse-driven path is untouched)
- Terminal scroll past the selected rows (existing buffer-trim logic)
- Programmatic clearSelection() callers

Files:
- domain/models.ts — new field, default false
- application/syncPayload.ts — added to SYNCABLE_TERMINAL_KEYS
- components/terminal/runtime/createXTermRuntime.ts — capture in
  attachCustomKeyEventHandler, restore via queueMicrotask
- components/Terminal.tsx — owns isRestoringSelectionRef, passes it
  through context, checks in copy-on-select listener
- components/settings/tabs/SettingsTerminalTab.tsx — UI toggle
- application/i18n/locales/{en,zh-CN}.ts — labels

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

* Trim verbose i18n descriptions to match neighboring rows

Both clearWipesScrollback and preserveSelectionOnInput descriptions
were too long. Cut to one sentence each, matching the brevity of
adjacent rows like Bracketed paste and OSC-52. Historical context and
edge-case caveats belong in the changelog/PR, not the settings UI.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:22:48 +08:00
陈大猫
8182bd6b3c Fix invisible caret in settings window inputs on Windows (#760) (#762)
Symptom: in the Settings window (especially AI > Add Provider, but also
seen in Add Host), clicking an input occasionally shows no caret and
typed characters don't appear, yet select-all + delete still works on
the input's content.

Root cause: PR #502 introduced settings-window prewarming and
hide-on-close reuse. On Windows, calling `BrowserWindow.focus()` from
a non-foreground process is restricted by SetForegroundWindow rules —
the window is shown on top but never actually receives OS foreground
focus. With `document.hasFocus() === false`, Chromium deliberately
suppresses caret blink and keyboard routing, even though clicking an
input still moves activeElement to it (so non-keyboard interactions
like select-all-then-delete keep working — exactly the reported
symptom).

Fix: introduce `showAndFocusWindow(win)` and call it everywhere the
settings window is shown:

- Apply the alwaysOnTop toggle on win32 to bypass the
  SetForegroundWindow restriction (established Electron workaround)
- Always call `webContents.focus()` after `win.focus()` so the renderer
  marks the document as focused regardless of what the OS decided —
  this is what restores the caret + keyboard routing

Scope intentionally limited to the settings window (the path PR #502
introduced). Other windows use a different show path (ready-to-show
event) and were not reported to have the issue.

I cannot test this on Windows directly. The fix follows a
well-documented Electron pattern and the diagnosis matches the
reported symptoms (Windows-only, intermittent, post-1.0.81 only).

Closes #760

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:44:37 +08:00
陈大猫
484ac5f463 Honor CSI 3 J by default; add toggle to preserve scrollback on clear (#761)
* Honor CSI 3 J by default; add toggle to preserve scrollback on `clear`

Default `clear` (ncurses ≥ 2013) emits CSI 2 J + CSI 3 J to wipe both
visible screen and scrollback. PR #633 unconditionally intercepted CSI
3 J to keep history across `clear`, which broke POSIX semantics — users
running standard `clear` could not wipe scrollback at all (#757).

Restore the standard behavior as the default and expose a toggle for
the iTerm2-style "preserve history" preference (matches what #622
asked for):

- domain/models.ts: add `clearWipesScrollback: boolean` (default true)
- createXTermRuntime.ts: CSI 3 J handler now reads the setting and
  only intercepts when the user opts out
- SettingsTerminalTab.tsx + i18n: expose the toggle with a description
  explaining the tradeoff
- The right-click "Clear Buffer" menu action keeps its independent
  semantics (always preserves scrollback) regardless of this setting,
  since it goes through `clearTerminalViewport`, not the CSI path

Closes #757

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

* fix: include clearWipesScrollback in cloud-sync terminal keys

Codex review on PR #761 caught that the new toggle was added to
TerminalSettings but not to SYNCABLE_TERMINAL_KEYS, so it would never
travel across devices via cloud sync — users disabling it on one
device would silently get the default back on another after sync.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 15:17:33 +08:00
陈大猫
98e3a6b952 Let single Tab fall through to shell when only ghost text is shown (#745)
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Closes #741. Bash/zsh use Tab for native completion, but our ghost-text
accept on single Tab was swallowing the keystroke before it reached the
PTY. Ghost text is still accepted with →; Tab in popup-menu mode is
unchanged (popup is an explicit UI so intent is clear).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:44:57 +08:00
陈大猫
f6f3147afb Tab bar: duplicate-adjacent insertion + wheel-to-horizontal scroll (#743)
* Improve tab UX: insert duplicated tabs adjacent to source, enable wheel scroll on tab bar

Addresses #737.

- Duplicating a tab now inserts the new tab immediately after the source
  in the tab order, instead of appending it to the far right where it
  was hard to find with many tabs open.
- The top tab strip now translates vertical mouse-wheel deltas into
  horizontal scrolling, so users with many tabs can reach the ends of
  the strip without dragging. Trackpad gestures that already carry
  horizontal delta are left alone to preserve native two-finger swiping.

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

* Address Codex review: read source session inside functional updater

Codex flagged that reading `session` from the closure broke the atomicity
guarantee of the previous implementation — rapid repeated duplicates could
miss freshly queued state.

- Pre-allocate the new session id outside both setters so it stays stable
  across StrictMode double-invocations.
- Move the source lookup back into `setSessions`' functional updater so it
  always reads the freshest committed/queued state.
- Drop `sessions` from the useCallback dependency list now that we no
  longer read it.
- Fast-path tabOrder insertion when the source is already in tabOrder to
  avoid re-deriving the full effective order in the common case.

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

* Address Codex review: gate active-tab and tab-order updates on successful create

Codex flagged that `setActiveTabId(newSessionId)` and `setTabOrder(...)` ran
unconditionally even when `setSessions` bailed out (source tab was closed
before the duplicate handler ran). That left activeTabId pointing at an id
that was never appended to sessions, putting the terminal layer into an
invalid "no matching tab" state.

Move both nested setState calls inside the `setSessions` functional updater
so they only fire when the source is actually present. Mirrors the original
pre-PR pattern; nested updates are idempotent so StrictMode's
double-invocation is harmless.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 00:41:31 +08:00
陈大猫
54b26511a1 Cloud sync data-loss prevention (4-layer defense) (#742)
* feat(sync-guard): extend SyncState with BLOCKED + add shrink event variants

* feat(sync-guard): add detectSuspiciousShrink pure function with 12 unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* polish(sync-guard): drop unnecessary cast, sharpen test naming, pin priority invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): include domain/*.test.ts in npm test glob

* feat(sync-guard): gate syncToProvider with shrink detection + force-push override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): reset overrideShrinkOnce before early return for invariant strictness

* fix(sync-guard): extend shrink guard to syncAllProviders (the actual sync entry point)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): apply empty-vault guard uniformly to auto and manual sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): preserve merge base on same-account re-auth

Adds providerAccountId persistence; completePKCEAuth and completeGitHubAuth
now only clear syncBase/anchor when the authenticated account id differs from
the previously stored one, preventing zombie-entry resurrection on token
refresh. disconnectProvider clears the stored id so a reconnect starts fresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): add i18n strings for sync-blocked banner + force-push modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): add SyncBlockedBanner showing shrink findings with restore/force-push actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): stable subscribeToEvents reference + type-safe finding narrowing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): force-push confirmation modal + scroll restore button into view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ux(local-backups): show version as title, demote reason+timestamp to meta line

* feat(local-backups): record + display sync data version (v5/v6...) on each backup

Each backup now captures the live CloudSyncManager.localVersion at creation
time. UI shows it as title (v5, v6, ...) with timestamp + reason demoted to
the meta line. Backups created before this field existed (or before any
successful cloud sync) fall back to timestamp as title.

Replaces the earlier app-version-transition title which conflated app
version with sync data version.

* fix(sync-guard): consume override flag at sync entry + restore provider status on block

- Snapshot+clear overrideShrinkOnce at top of syncToProvider and
  syncAllProviders so an early-return cannot leak the flag to a later
  unrelated sync (Codex P1).
- Restore provider status to 'connected' when shrink-block returns from
  syncToProvider; previously left provider stuck on 'syncing' in the
  UI (Codex P2).
- Process pre-existing check errors before returning from the
  shouldBlockAll branch in syncAllProviders so a check-failed provider
  isn't dropped from results (Codex P2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): refactor force-push to parameter passing + add credential-availability guard

The previous design used a one-shot boolean flag on CloudSyncManager set
by forcePushOverrideShrink(). Even with snapshot+clear at sync entry
points, the renderer wrapper's await ensureUnlocked() could throw before
the flag was consumed, leaving it armed for the next unrelated sync.

Fix: pass overrideShrink as a call-time parameter through the chain.
Eliminates the persistent flag and its leak surface.

Also: force-push now runs the same ensureSyncablePayload(...) guard the
other manual sync entry points use, so a vault with encrypted-credential
placeholders won't be uploaded via the force path either.

Addresses the latest two Codex P1/P2 findings on #742.

* fix(sync-guard): backfill account id from in-memory state for upgrade-path re-auth

Users upgrading to this PR have no netcatty.sync.accountId.* persisted yet.
On their first re-auth the guard saw previousId=null and cleared the
merge base anyway, defeating the point of the same-account preservation.

Snapshot the in-memory account id BEFORE overwriting providers[provider]
and use it as a fallback when the persisted id is missing. New users
(no prior connection at all) still get the clear-on-first-auth path.

Addresses Codex P1 on #742.

* fix(sync-guard): inspect force-push results + mark blocked single-provider as error

- Force-push handler now inspects syncNow result entries: applies any
  mergedPayload to local state, only clears the banner when all providers
  report success, surfaces a toast error otherwise. Previously the banner
  cleared unconditionally regardless of network/auth failures (Codex P1).

- syncToProvider shrink-block branches now mark provider status as
  'error' with a 'Sync blocked: would delete too much' message instead
  of 'connected'. Status aggregators treat 'connected' as healthy, so
  the blocked upload was surfacing as 'synced' in the UI (Codex P2).
  syncAllProviders already used this pattern; this brings the
  single-provider path in line.

* fix(sync-guard): exempt USE_LOCAL conflict + clear post-merge BLOCKED + expose 'blocked' status

- USE_LOCAL conflict resolution now passes { overrideShrink: true }: the
  conflict modal already served as user confirmation, and shrink-blocking
  it left users with a closed modal and an opaque banner (Review C-1).

- Post-merge round-trip in useAutoSync now detects shrink-blocked results
  and resets syncState to IDLE via new manager.clearShrinkBlockedState().
  The merged data is already applied locally; the next user-triggered
  sync will re-check, and we don't wedge the manager in BLOCKED with no
  visible banner outside the Settings tab (Review I-1).

- overallSyncStatus now reports 'blocked' as a distinct value from
  'error', so downstream UI (status icon, future badges) can offer
  shrink-block-specific affordances (Review I-2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): stabilize banner subscription dep + map 'blocked' status to error indicator

- The SyncBlockedBanner subscription useEffect depended on [sync] (the
  whole hook return object), which gets a new reference every render.
  This caused the listener to be unsubscribed+resubscribed on every
  render, opening a tiny race window where a SYNC_BLOCKED_SHRINK event
  could be missed and the banner would never appear. Destructure
  subscribeToEvents (already useCallback-stable) and depend on it
  directly, so the effect runs exactly once on mount.

- SyncStatusButton's status mapping had no arm for the new 'blocked'
  value, falling through to 'none' (idle). The global status indicator
  said healthy while the in-page banner said paused. Map 'blocked' to
  the same error indicator used for 'conflict' so the UI is consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): only clear banner on actual success + hydrate from manager state

- Banner subscription now clears only on SYNC_COMPLETED with result.success.
  SYNC_STARTED (auto-sync timer ticks) and SYNC_FORCED (fires BEFORE upload)
  could clear the banner prematurely, removing the user's recovery affordance
  while the underlying issue was unresolved (Codex P2).

- Manager now persists the last shrink finding in state.lastShrinkFinding
  alongside the SYNC_BLOCKED_SHRINK emission. New public getter
  getShrinkBlockedFinding() returns it when syncState is BLOCKED. Renderer
  hydrates the banner on mount so a block that happened off-screen
  (auto-sync while user was on another tab) is still visible when they
  open Sync Settings (Codex P2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): unified BLOCKED-cleared event + USE_LOCAL inspects results

- USE_LOCAL conflict resolution now inspects syncNow() results, applies
  any mergedPayload to local state, surfaces a toast error and KEEPS the
  modal open on failure (so user can switch to USE_REMOTE). Mirrors the
  force-push handler pattern. Without this, USE_LOCAL silently 'succeeded'
  even when providers failed (Codex CLI P1).

- New SYNC_BLOCKED_CLEARED event emitted on every BLOCKED -> non-BLOCKED
  transition via a private exitBlockedState() helper. Banner subscribes to
  this single signal instead of guessing from per-provider SYNC_COMPLETED
  events. Fixes:
    - Multi-provider scenarios where first SYNC_COMPLETED clears the banner
      while a later provider was still going to fail (Codex CLI P1).
    - clearShrinkBlockedState() (post-merge self-heal) silently leaving
      the banner stuck because no event was emitted (Codex CLI P2).

- disconnectProvider() now also exits BLOCKED state. Disconnecting
  implicitly resolves any pending shrink-block warning, otherwise the
  stale alert carried over to the next-account reconnect (Codex CLI P2).

- All BLOCKED -> non-BLOCKED transitions consolidated through
  exitBlockedState() so lastShrinkFinding cleanup + event emission are
  always paired (Codex CLI P3 #6 covered).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): only clear BLOCKED on actual success, not on transient ERROR/SYNCING/CONFLICT

Previous patch called exitBlockedState() at every BLOCKED -> non-BLOCKED
transition, but this clears the banner on transitions that don't actually
resolve the shrink concern:

- SYNCING (sync just started — about to try, may fail)
- ERROR (transient transport failure, shrink concern still real)
- CONFLICT (separate concern; doesn't resolve the shrink)

If a user was in BLOCKED then triggered a sync that failed for an unrelated
reason (network, auth), the banner cleared and they lost the warning.

Restrict exitBlockedState() to terminal-success transitions:
- IDLE on successful upload (data made it to cloud — concern resolved)
- explicit clears (disconnectProvider, clearShrinkBlockedState)
- conflict resolution (USE_REMOTE/USE_LOCAL also end in IDLE)

Found by Codex CLI review of commit 12d7fa7b.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:43:19 +08:00
陈大猫
8ef91e1266 Ctrl+W close priority + local shell busy confirmation (#739)
* feat(ctrl-w): add ps-node + windows-process-tree + tsx deps for close-priority feature

* fix(ctrl-w): drop ps-node dep and add windows-process-tree to asarUnpack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add ptyProcessTree bridge with per-platform child-process enumeration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): ptyProcessTree uses args= for full command + warns on pid overwrite

- Replace `comm=` with `args=` in defaultListPosix so the full command
  line is captured on both macOS (BSD ps) and Linux (GNU ps), avoiding
  the 15-char TASK_COMM_LEN truncation.
- Add console.warn in registerPid when the same sessionId is overwritten
  with a different pid, making the race condition visible in logs.
- Add test: registerPid warns exactly once on a pid change, not on a
  same-pid re-registration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): register local PTY pid with ptyProcessTree on spawn/exit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): unregister pids in cleanupAllSessions to match per-delete invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add IPC handlers for pty child processes and confirm-close dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): guard BrowserWindow.fromWebContents null and document dialog dismiss contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): expose ptyGetChildProcesses and confirmCloseBusy on window.netcatty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add i18n strings for close-busy-terminal dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add resolveCloseIntent pure function with 8 unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): expose handleCloseSidePanel via ref to App.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): wire resolveCloseIntent + local-shell busy confirmation into closeTab hotkey

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

* fix(ctrl-w): add re-entrancy guard, aggregate busy count, sync sidebar ref, dedupe intent branches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): auto-close workspace when its last session is closed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): sidebar close wins over focused terminal in priority chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): sidebar priority applies to single-session tabs too, not just workspaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): compute empty-workspace auto-close outside setSessions updater

Addresses Codex P2 on #739: React 18+ does not guarantee updater
execution timing under concurrent scheduling. Moving the decision
outside the updater makes the microtask queue deterministic.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:30:11 +08:00
Eric Chan
b2689f96a4 Clarify Netcatty CLI launcher guidance (#738) 2026-04-16 14:59:24 +08:00
陈大猫
1b23bdcf15 [codex] Preserve terminal focus when clicking the toolbar overlay (#734)
* fix terminal toolbar focus loss

* restore focus after closing side panels

* fix terminal side panel focus helper order
2026-04-16 11:08:09 +08:00
陈大猫
2e63848e0e fix empty ssh identification banners (#733) 2026-04-16 10:34:51 +08:00
陈大猫
3a748aa1aa fix serial duplicate host save (#732) 2026-04-16 10:15:37 +08:00
Eric Chan
4574f1e2b2 fix: stabilize scoped AI draft/session transitions (#724)
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: correct terminal AI history resume behavior

The previous implementation plan mistakenly treated reopening an old terminal AI session in a fresh or reconnected SSH tab as a scope-retargeting feature.

The intended rule is draft-first:
- a fresh or reconnected terminal opens on a blank draft
- older chats remain available in history for manual access
- selecting history does not imply automatic scope transfer into the new tab

This change is a rule correction, not a conflict between product rules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: harden ai draft transitions

* fix ai session continuation from history

* fix: clear stale activeSessionIdMap entry when view resolves to draft

Addresses the Codex P2 review on aiPanelViewState.ts:38. When a terminal
scope mounts with a persisted activeSessionIdMap entry but no explicit
panelView and no draft, resolveDisplayedPanelView now returns the
default draft view (terminal fresh-start behavior). The sync effect
that writes into activeSessionIdMap is guarded by `if (!activeSession)
return`, so the old entry stays put. That stale entry then leaks into
activeTerminalTargetIds in every other scope, and
getSessionScopeMatchRank uses it to suppress host-matched history that
is actually resumable — so valid sessions vanish from the history
drawer until another action rewrites the map.

Add a dedicated effect that clears the scope's activeSessionIdMap
entry whenever the resolved panel view is draft but a persisted
session id is still present. This keeps the map an accurate record of
"which session each scope is currently showing" instead of a lagging
snapshot.

Also extend sessionScopeMatch.test.ts to cover the rank=2 exact-match
branch and the scope-type mismatch short-circuit, which were missing
from the original suite.

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

* fix: track cross-terminal session ownership by session id, not targetId

Addresses the Codex follow-up review on commit 345244b2. When a user
resumes a session from history into a different terminal, the session's
`scope.targetId` still points at the original terminal. The previous
ownership tracking — which checked whether `session.scope.targetId`
appeared in `activeTerminalTargetIds` (derived from the keys of
`activeSessionIdMap`) — therefore:

- could not prevent the same session from being resumed in multiple
  terminals simultaneously, because the resumed session's targetId
  never matches the current scope's targetId; and
- let `pruneInactiveScopedSessions` treat a session as orphaned and
  clear its `externalSessionId` the moment the original terminal
  closed, even though another terminal was actively using it.

Switch ownership to be keyed on session id:

- `getSessionScopeMatchRank` now takes `activeTerminalSessionIds`
  (a Set of session ids currently displayed by other terminal scopes)
  and returns rank 0 when `session.id` is in that set.
- `AIChatSidePanel` derives `activeTerminalSessionIds` from the
  *values* of `activeSessionIdMap`, excluding the current scope's key.
- `pruneInactiveScopedSessions` gains an `activeSessionIds` parameter;
  sessions whose id is in this set are never reported as orphaned and
  never have their `externalSessionId` cleared, regardless of their
  stored `scope.targetId`.
- `cleanupOrphanedAISessions` computes the in-use set from the
  pre-cleanup `activeSessionIdMap`, filtered to live scopes, and
  passes it through. The map is read once and reused.

Tests cover the new id-based ownership, the rank-2 exact-match path,
the scope-type-mismatch short-circuit, and the
"resumed-elsewhere session must not be cleaned" invariant.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:16:10 +08:00
陈大猫
081b167172 feat(ai-chat): fit-to-content popovers + keyboard nav for @/slash menus (#726)
* feat(ai-chat): fit-to-content popovers and keyboard nav for @/slash menus

- Shrink the @ host and /skill popovers to their content width
  (auto width with min 220px, capped at the input width) instead of
  always filling the full input width, which left large empty gutters
  when the list was short.
- Add keyboard navigation: ArrowUp/ArrowDown cycle through items,
  Enter commits the highlighted item, Escape closes the menu. Mouse
  hover stays in sync with the active index so keyboard and pointer
  agree on which row is current. Enter does not fall through to
  submit while a menu is open.
- Expose aria-selected / aria-activedescendant for screen readers.

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

* style(ai-chat): tone down popover radius to match other menus

The @ and /skill popovers used rounded-[20px]/rounded-[16px] which
stood out against every other popover in this file (rounded-lg with
rounded-md items). Switch to the shared radii and drop shadow-2xl for
the standard shadow-lg so the surface feels consistent.

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

* style(ai-chat): tighten mention popover spacing

- Drop the redundant "Hosts" / "User Skills" header row — the @ or /
  trigger already makes the popover's purpose obvious, and the header
  added ~30px of vertical whitespace above a single-line list.
- Shrink wrapper and item padding (p-2.5/px-3 py-1.5 -> p-1/px-2 py-1)
  and remove the mt-0.5 gap between title and subtitle.
- Hide the hostname subline when the label already contains the
  hostname (common case: "Rainyun-114.66.26.174" as label and
  "114.66.26.174" as hostname — no need to repeat).
- Lower minWidth 220 -> 200 so short lists can shrink further.

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

* fix(ai-chat): address Codex review on PR #726

- Reset active menu index on any change to the *set* of visible items,
  not just its length. Watching only `.length` let Enter commit a
  different item when the slash query changed to a same-sized match
  set. Derive a stable identity key (sessionIds / skill ids) and use
  that as the effect dep instead.
- Clamp the popover's minWidth to the measured panel width so narrow
  layouts don't end up with minWidth > maxWidth, which CSS resolves
  by honoring min and clips the menu off-screen.

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-04-15 16:25:51 +08:00
陈大猫
a818a7004f fix: remove invalid eval -- in fish shell wrapper (#725)
Fish's `eval` builtin does not recognize `--` as an end-of-options
marker, so the wrapper failed with `fish: Unknown command: --` for
every AI Agent command under fish. The `--` was unnecessary since
fish's `eval` has no options to terminate.

Fixes #721

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:58:26 +08:00
74 changed files with 6420 additions and 712 deletions

146
App.tsx
View File

@@ -18,6 +18,7 @@ import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { resolveHostAuth } from './domain/sshAuth';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
@@ -992,6 +993,10 @@ function App({ settings }: { settings: SettingsState }) {
const addConnectionLogRef = useRef(addConnectionLog);
addConnectionLogRef.current = addConnectionLog;
const closeSidePanelRef = useRef<(() => void) | null>(null);
const activeSidePanelTabRef = useRef<string | null>(null);
const closeTabInFlightRef = useRef(false);
const createLocalTerminalWithCurrentShell = useCallback(() => {
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
@@ -1025,6 +1030,88 @@ function App({ settings }: { settings: SettingsState }) {
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
}, [hotkeyScheme, keyBindings]);
const confirmIfBusyLocalTerminal = useCallback(
async (sessionIds: string[]): Promise<boolean> => {
const bridge = netcattyBridge.get();
const localIds = sessionIds.filter((id) => {
const s = sessions.find((x) => x.id === id);
return s?.protocol === 'local';
});
const busyCommands: string[] = [];
for (const id of localIds) {
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
if (children.length > 0) {
busyCommands.push(children[0].command);
}
}
if (busyCommands.length === 0) return true;
const primary = busyCommands[0];
const extraCount = busyCommands.length - 1;
const message =
extraCount > 0
? t('confirm.closeBusyTerminal.messageWithMore', {
command: primary,
count: extraCount,
})
: t('confirm.closeBusyTerminal.message', { command: primary });
const ok = await bridge?.confirmCloseBusy?.({
command: primary,
title: t('confirm.closeBusyTerminal.title'),
message,
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
closeLabel: t('confirm.closeBusyTerminal.close'),
});
return ok === true;
},
[sessions, t],
);
const closeTabsInFlightRef = useRef(false);
// Close many tabs at once with a single batched busy-shell confirmation.
// Used by the "Close all / Close others / Close to the right" context-menu
// actions on tabs (#748).
const closeTabsBatch = useCallback(
async (targetIds: string[]) => {
if (targetIds.length === 0) return;
if (closeTabsInFlightRef.current) return;
// Expand workspace ids into their constituent session ids so the busy
// probe sees every local shell that's about to be killed.
const sessionIdsToProbe: string[] = [];
for (const tabId of targetIds) {
const ws = workspaces.find((w) => w.id === tabId);
if (ws) {
for (const s of sessions) {
if (s.workspaceId === tabId) sessionIdsToProbe.push(s.id);
}
} else if (sessions.find((s) => s.id === tabId)) {
sessionIdsToProbe.push(tabId);
}
}
closeTabsInFlightRef.current = true;
try {
const ok = await confirmIfBusyLocalTerminal(sessionIdsToProbe);
if (!ok) return;
for (const tabId of targetIds) {
if (workspaces.find((w) => w.id === tabId)) {
closeWorkspace(tabId);
} else if (sessions.find((s) => s.id === tabId)) {
closeSession(tabId);
} else if (logViews.find((lv) => lv.id === tabId)) {
closeLogView(tabId);
}
}
} finally {
closeTabsInFlightRef.current = false;
}
},
[workspaces, sessions, logViews, confirmIfBusyLocalTerminal, closeWorkspace, closeSession, closeLogView],
);
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
@@ -1069,18 +1156,52 @@ function App({ settings }: { settings: SettingsState }) {
}
case 'closeTab': {
const currentId = activeTabStore.getActiveTabId();
if (currentId !== 'vault' && currentId !== 'sftp') {
// Find if it's a session or workspace
const session = sessions.find(s => s.id === currentId);
if (session) {
closeSession(currentId);
} else {
const workspace = workspaces.find(w => w.id === currentId);
if (workspace) {
closeWorkspace(currentId);
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
if (closeTabInFlightRef.current) break;
const session = sessions.find((s) => s.id === currentId) ?? null;
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
const activeSidePanel = activeSidePanelTabRef.current;
const intent = resolveCloseIntent({
activeTabId: currentId,
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
sessionForTab: session,
activeSidePanelTab: activeSidePanel,
focusIsInsideTerminal,
});
closeTabInFlightRef.current = true;
(async () => {
try {
switch (intent.kind) {
case 'closeTerminal':
case 'closeSingleTab': {
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
if (ok) closeSession(intent.sessionId);
return;
}
case 'closeSidePanel': {
closeSidePanelRef.current?.();
return;
}
case 'closeWorkspace': {
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
const ok = await confirmIfBusyLocalTerminal(ids);
if (ok) closeWorkspace(intent.workspaceId);
return;
}
case 'noop':
default:
return;
}
} finally {
closeTabInFlightRef.current = false;
}
}
})();
break;
}
case 'newTab':
@@ -1193,7 +1314,7 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
}
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab]);
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
@@ -1553,6 +1674,7 @@ function App({ settings }: { settings: SettingsState }) {
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
onCloseTabsBatch={closeTabsBatch}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
@@ -1684,6 +1806,8 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
closeSidePanelRef={closeSidePanelRef}
activeSidePanelTabRef={activeSidePanelTabRef}
/>
{/* Log Views - readonly terminal replays */}

View File

@@ -56,6 +56,11 @@ const en: Messages = {
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'confirm.removeProvider': 'Remove provider "{name}"?',
'confirm.closeBusyTerminal.title': 'Confirm close',
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
'confirm.closeBusyTerminal.cancel': 'Cancel',
'confirm.closeBusyTerminal.close': 'Close',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
@@ -301,6 +306,12 @@ 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.clearWipesScrollback': '`clear` wipes scrollback',
'settings.terminal.behavior.clearWipesScrollback.desc':
'Make `clear` also wipe the scrollback buffer (POSIX default). Disable to keep history visible after `clear`.',
'settings.terminal.behavior.preserveSelectionOnInput': 'Keep selection while typing',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'Don\'t clear mouse-selected text when typing — useful for selecting a path then pasting it after a command prefix like `sz `.',
'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.',
@@ -467,6 +478,30 @@ const en: Messages = {
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
'sync.blocked.title': 'Sync paused',
'sync.blocked.reason.bulkShrink': 'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
'sync.blocked.reason.largeShrink': 'Would delete {lost} {entityType} from cloud.',
'sync.blocked.detail': 'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
'sync.blocked.restoreButton': 'Restore from local backup',
'sync.blocked.forcePushButton': 'Force push anyway',
'sync.forcePush.title': 'Confirm force push',
'sync.forcePush.body': 'You are about to remove {lost} {entityType} from the cloud. This cannot be undone. Proceed?',
'sync.forcePush.confirm': 'Yes, push anyway',
'sync.forcePush.cancel': 'Cancel',
'sync.entityType.hosts': 'hosts',
'sync.entityType.keys': 'keys',
'sync.entityType.identities': 'identities',
'sync.entityType.snippets': 'snippets',
'sync.entityType.customGroups': 'groups',
'sync.entityType.snippetPackages': 'snippet packages',
'sync.entityType.knownHosts': 'known-host entries',
'sync.entityType.portForwardingRules': 'port-forwarding rules',
'sync.entityType.groupConfigs': 'group configs',
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
'time.never': 'Never',
'time.justNow': 'Just now',
@@ -1604,6 +1639,9 @@ const en: Messages = {
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'tabs.closeOthers': 'Close Others',
'tabs.closeToRight': 'Close Tabs to the Right',
'tabs.closeAll': 'Close All',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': 'Key label',
'keychain.edit.privateKeyRequired': 'Private key *',

View File

@@ -43,6 +43,11 @@ const zhCN: Messages = {
'confirm.deleteHost': '删除主机 "{name}"',
'confirm.deleteIdentity': '删除身份 "{name}"',
'confirm.removeProvider': '移除提供商 "{name}"',
'confirm.closeBusyTerminal.title': '确认关闭',
'confirm.closeBusyTerminal.message': '进程 "{command}" 仍在运行,关闭后会被终止。',
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
'confirm.closeBusyTerminal.cancel': '取消',
'confirm.closeBusyTerminal.close': '关闭',
'dialog.renameWorkspace.title': '重命名工作区',
'dialog.renameSession.title': '重命名会话',
'field.name': '名称',
@@ -286,6 +291,30 @@ const zhCN: Messages = {
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
'sync.blocked.title': '同步已暂停',
'sync.blocked.reason.bulkShrink': '即将从云端删除 {baseCount} 条 {entityType} 中的 {lost} 条(缩减 {percent}%)。',
'sync.blocked.reason.largeShrink': '即将从云端删除 {lost} 条 {entityType}。',
'sync.blocked.detail': '通常是本地状态异常(钥匙串故障、数据加载不全)导致。请从本地备份恢复,如果确实要删这些条目请使用强制推送。',
'sync.blocked.restoreButton': '从本地备份恢复',
'sync.blocked.forcePushButton': '强制推送',
'sync.forcePush.title': '确认强制推送',
'sync.forcePush.body': '你将从云端移除 {lost} 条 {entityType},此操作不可撤销。继续?',
'sync.forcePush.confirm': '确认推送',
'sync.forcePush.cancel': '取消',
'sync.entityType.hosts': '主机',
'sync.entityType.keys': '密钥',
'sync.entityType.identities': '身份',
'sync.entityType.snippets': '代码片段',
'sync.entityType.customGroups': '分组',
'sync.entityType.snippetPackages': '片段包',
'sync.entityType.knownHosts': '主机密钥记录',
'sync.entityType.portForwardingRules': '端口转发规则',
'sync.entityType.groupConfigs': '分组配置',
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
'time.never': '从未',
'time.justNow': '刚刚',
@@ -1360,6 +1389,12 @@ const zhCN: Messages = {
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.clearWipesScrollback': '`clear` 同时清空回滚历史',
'settings.terminal.behavior.clearWipesScrollback.desc':
'`clear` 命令同时清空回滚历史POSIX 默认行为)。关闭则保留历史。',
'settings.terminal.behavior.preserveSelectionOnInput': '输入时保留选区',
'settings.terminal.behavior.preserveSelectionOnInput.desc':
'键盘输入时不清除鼠标选中的文本,方便选中路径后输入 `sz ` 之类命令再粘贴。',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
@@ -1612,6 +1647,9 @@ const zhCN: Messages = {
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'tabs.closeOthers': '关闭其他标签',
'tabs.closeToRight': '关闭右侧标签',
'tabs.closeAll': '关闭所有标签',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
'keychain.edit.privateKeyRequired': '私钥 *',

View File

@@ -6,15 +6,38 @@ import {
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { hasMeaningfulSyncData } from './syncPayload';
/**
* Snapshot the current sync data version (the integer that increments
* on each successful cloud sync). Returns undefined when the value is
* 0 (never synced) or unavailable, so the UI can fall back to timestamp.
*/
function captureCurrentSyncDataVersion(): number | undefined {
try {
const state = getCloudSyncManager().getState();
const v = state.localVersion;
return typeof v === 'number' && v > 0 ? v : undefined;
} catch {
return undefined;
}
}
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
export interface LocalVaultBackupPreview {
id: string;
createdAt: number;
reason: LocalVaultBackupReason;
/** Sync-data version at the time the snapshot was taken (the integer
* that the CloudSyncManager increments on each successful cloud sync).
* Undefined when the user had never synced yet, or for legacy backups
* persisted before this field was added. */
syncDataVersion?: number;
/** App version transition fields, only for `app_version_change` records.
* Kept for backward compatibility with already-persisted backups. */
sourceAppVersion?: string;
targetAppVersion?: string;
fingerprint: string;
@@ -94,6 +117,7 @@ export async function createLocalVaultBackup(
payload: SyncPayload,
options: {
reason: LocalVaultBackupReason;
syncDataVersion?: number;
sourceAppVersion?: string;
targetAppVersion?: string;
maxCount?: number;
@@ -118,6 +142,10 @@ export async function createLocalVaultBackup(
const result = await bridge.createVaultBackup({
payload,
reason: options.reason,
// Default to the live cloud-sync version so every new backup carries
// it even when the caller didn't pass one explicitly. Bridge sanitizer
// drops invalid values (non-positive / non-finite), so this is safe.
syncDataVersion: options.syncDataVersion ?? captureCurrentSyncDataVersion(),
sourceAppVersion: options.sourceAppVersion,
targetAppVersion: options.targetAppVersion,
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),

View File

@@ -3,12 +3,17 @@ import assert from "node:assert/strict";
import {
activateDraftView,
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
clearScopeDraftState,
createEmptyDraft,
ensureDraftForScopeState,
getDraftMutationVersionState,
getDraftUploadGenerationState,
pruneTerminalScopeState,
pruneTerminalTransientState,
resolvePanelView,
selectDraftForAgentSwitch,
setDraftView,
setSessionView,
updateDraftForScope,
@@ -168,6 +173,69 @@ test("ensureDraftForScopeState returns the original ref when the scope already e
assert.equal(next, draftsByScope);
});
test("selectDraftForAgentSwitch preserves hidden draft content when leaving a populated chat session", () => {
const currentDraft = {
...createEmptyDraft("agent-alpha"),
text: "keep me only if I was already drafting",
attachments: [{ id: "file-1", filename: "note.txt", dataUrl: "", base64Data: "", mediaType: "text/plain" }],
selectedUserSkillSlugs: ["skill-a"],
};
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "keep me only if I was already drafting");
assert.deepEqual(next.attachments, currentDraft.attachments);
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
});
test("selectDraftForAgentSwitch resets to an empty draft when leaving a populated chat session without pending draft content", () => {
const currentDraft = createEmptyDraft("agent-alpha");
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", true);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "");
assert.deepEqual(next.attachments, []);
assert.deepEqual(next.selectedUserSkillSlugs, []);
});
test("selectDraftForAgentSwitch preserves an existing draft while only changing agent", () => {
const currentDraft = {
...createEmptyDraft("agent-alpha"),
text: "unfinished prompt",
selectedUserSkillSlugs: ["skill-a"],
};
const next = selectDraftForAgentSwitch(currentDraft, "agent-beta", false);
assert.equal(next.agentId, "agent-beta");
assert.equal(next.text, "unfinished prompt");
assert.deepEqual(next.selectedUserSkillSlugs, ["skill-a"]);
});
test("draft mutation version increments on every mutation for the same scope", () => {
const scopeKey = "terminal:1";
const initialVersion = getDraftMutationVersionState({}, scopeKey);
const nextVersions = bumpDraftMutationVersionState({}, scopeKey);
const finalVersions = bumpDraftMutationVersionState(nextVersions, scopeKey);
assert.equal(initialVersion, 0);
assert.equal(getDraftMutationVersionState(nextVersions, scopeKey), 1);
assert.equal(getDraftMutationVersionState(finalVersions, scopeKey), 2);
});
test("draft upload generation only increments when the draft lifecycle rolls over", () => {
const scopeKey = "terminal:1";
const initialGeneration = getDraftUploadGenerationState({}, scopeKey);
const nextGenerations = bumpDraftUploadGenerationState({}, scopeKey);
const finalGenerations = bumpDraftUploadGenerationState(nextGenerations, scopeKey);
assert.equal(initialGeneration, 0);
assert.equal(getDraftUploadGenerationState(nextGenerations, scopeKey), 1);
assert.equal(getDraftUploadGenerationState(finalGenerations, scopeKey), 2);
});
test("pruneTerminalScopeState removes closed terminal drafts and views only", () => {
const draftsByScope = {
"terminal:closed": createEmptyDraft("agent-alpha"),

View File

@@ -6,6 +6,8 @@ import type {
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
type DraftMutationVersionByScope = Record<string, number>;
type DraftUploadGenerationByScope = Record<string, number>;
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
@@ -19,6 +21,40 @@ export function createEmptyDraft(agentId: string): AIDraft {
};
}
export function getDraftMutationVersionState(
versionsByScope: DraftMutationVersionByScope,
scopeKey: string,
): number {
return versionsByScope[scopeKey] ?? 0;
}
export function bumpDraftMutationVersionState(
versionsByScope: DraftMutationVersionByScope,
scopeKey: string,
): DraftMutationVersionByScope {
return {
...versionsByScope,
[scopeKey]: getDraftMutationVersionState(versionsByScope, scopeKey) + 1,
};
}
export function getDraftUploadGenerationState(
generationsByScope: DraftUploadGenerationByScope,
scopeKey: string,
): number {
return generationsByScope[scopeKey] ?? 0;
}
export function bumpDraftUploadGenerationState(
generationsByScope: DraftUploadGenerationByScope,
scopeKey: string,
): DraftUploadGenerationByScope {
return {
...generationsByScope,
[scopeKey]: getDraftUploadGenerationState(generationsByScope, scopeKey) + 1,
};
}
export function resolvePanelView(
panelViewByScope: PanelViewByScope,
scopeKey: string,
@@ -109,6 +145,31 @@ export function ensureDraftForScopeState(
};
}
export function selectDraftForAgentSwitch(
currentDraft: AIDraft | null | undefined,
agentId: string,
startFresh: boolean,
): AIDraft {
const hasPendingDraftContent = Boolean(
currentDraft
&& (
currentDraft.text.length > 0
|| currentDraft.attachments.length > 0
|| currentDraft.selectedUserSkillSlugs.length > 0
),
);
if (startFresh && !hasPendingDraftContent) {
return createEmptyDraft(agentId);
}
const baseDraft = currentDraft ?? createEmptyDraft(agentId);
return {
...baseDraft,
agentId,
};
}
export function clearScopeDraftState(
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,

View File

@@ -65,7 +65,7 @@ test("pruneInactiveScopedTransientState removes closed workspace and terminal sc
});
});
test("pruneInactiveScopedSessions removes non-restorable terminal chats and closed workspaces", () => {
test("pruneInactiveScopedSessions preserves restorable terminal ACP ids across reconnects", () => {
const sessions = [
createSession("terminal-restorable", {
type: "terminal",
@@ -99,10 +99,7 @@ test("pruneInactiveScopedSessions removes non-restorable terminal chats and clos
"workspace-closed",
]);
assert.deepEqual(next.sessions, [
{
...sessions[0],
externalSessionId: undefined,
},
sessions[0],
sessions[3],
]);
});
@@ -129,3 +126,35 @@ test("pruneInactiveScopedSessions preserves original sessions when orphaned rest
assert.deepEqual(next.orphanedSessionIds, ["terminal-restorable"]);
assert.equal(next.sessions, sessions);
});
test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use, not orphaned", () => {
// terminal-restorable's original scope (terminal-closed-A) is gone, but
// the user resumed it into terminal-open-B from history. The session's
// externalSessionId must be preserved and it must not appear in the
// orphaned list, otherwise the active chat loses ACP continuity.
const resumedElsewhere = createSession("terminal-restorable", {
type: "terminal",
targetId: "terminal-closed-A",
hostIds: ["host-1"],
}, "ext-resumed");
const trulyOrphaned = createSession("terminal-stale", {
type: "terminal",
targetId: "terminal-closed-C",
hostIds: ["host-2"],
}, "ext-stale");
const sessions = [resumedElsewhere, trulyOrphaned];
const next = pruneInactiveScopedSessions(
sessions,
new Set(["terminal-open-B"]),
new Set(["terminal-restorable"]),
);
// Only the one not being displayed anywhere should show up as orphaned.
assert.deepEqual(next.orphanedSessionIds, ["terminal-stale"]);
// The resumed session must retain its externalSessionId.
const resumedNext = next.sessions.find((s) => s.id === "terminal-restorable");
assert.equal(resumedNext?.externalSessionId, "ext-resumed");
});

View File

@@ -99,12 +99,21 @@ function isRestorableTerminalSession(session: AISession): boolean {
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
/**
* Session ids currently displayed by any live scope. A session whose
* `scope.targetId` is inactive but whose id is still in use somewhere
* (e.g. resumed from history into a different terminal) must not be
* treated as orphaned — deleting it outright would break the chat the
* user is actively continuing.
*/
activeSessionIds: Set<string> = new Set(),
): {
sessions: AISession[];
orphanedSessionIds: string[];
} {
const orphanedSessionIds = sessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.filter((session) => !activeSessionIds.has(session.id))
.map((session) => session.id);
if (orphanedSessionIds.length === 0) {
@@ -126,15 +135,7 @@ export function pruneInactiveScopedSessions(
sessionsChanged = true;
return [];
}
if (!session.externalSessionId) {
return [session];
}
sessionsChanged = true;
return [
{ ...session, externalSessionId: undefined },
];
return [session];
});
return {

View File

@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
const baseWorkspace = {
id: "w1",
focusedSessionId: "s1",
};
const baseSession = { id: "s1" };
test("non-workspace tab → closeSingleTab with session id", () => {
const result = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: baseSession,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
});
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: { id: "s1" },
activeSidePanelTab: "ai",
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("vault/sftp tab → noop", () => {
const r = resolveCloseIntent({
activeTabId: "vault",
workspace: null,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "noop" });
});
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "sftp",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
});
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});

View File

@@ -0,0 +1,43 @@
export type CloseIntent =
| { kind: 'closeTerminal'; sessionId: string }
| { kind: 'closeSidePanel' }
| { kind: 'closeWorkspace'; workspaceId: string }
| { kind: 'closeSingleTab'; sessionId: string }
| { kind: 'noop' };
export interface ResolveCloseInput {
activeTabId: string | null;
workspace: { id: string; focusedSessionId?: string } | null;
sessionForTab: { id: string } | null;
activeSidePanelTab: string | null;
focusIsInsideTerminal: boolean;
}
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
if (!activeTabId) return { kind: 'noop' };
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
// Modals take priority over this but are intercepted upstream in App.tsx before the
// hotkey reaches resolveCloseIntent.
if (activeSidePanelTab !== null) {
return { kind: 'closeSidePanel' };
}
if (sessionForTab && !workspace) {
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
}
if (!workspace) {
// e.g. 'vault', 'sftp', or any non-closable pinned tab
return { kind: 'noop' };
}
const focusedSessionId = workspace.focusedSessionId;
if (focusedSessionId && focusIsInsideTerminal) {
return { kind: 'closeTerminal', sessionId: focusedSessionId };
}
return { kind: 'closeWorkspace', workspaceId: workspace.id };
}

View File

@@ -33,8 +33,11 @@ import type {
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import {
activateDraftView,
bumpDraftMutationVersionState,
bumpDraftUploadGenerationState,
clearScopeDraftState,
ensureDraftForScopeState,
getDraftUploadGenerationState,
setSessionView,
updateDraftForScope,
} from './aiDraftState';
@@ -91,9 +94,25 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
// history can be resumed into a different terminal and we must not
// delete it outright while it's actively being used.
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const activeSessionIds = new Set<string>();
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
if (!sessionId) continue;
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
activeSessionIds.add(sessionId);
}
const nextSessionCleanup = pruneInactiveScopedSessions(
currentSessions,
activeTargetIds,
activeSessionIds,
);
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
@@ -109,9 +128,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
@@ -152,6 +169,7 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
@@ -198,6 +216,7 @@ let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = nul
let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
@@ -207,19 +226,6 @@ function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string,
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
function buildScopeKey(scope: AISessionScope) {
return `${scope.type}:${scope.targetId ?? ''}`;
}
function areHostIdsEqual(left?: string[], right?: string[]) {
const leftIds = left ?? [];
const rightIds = right ?? [];
if (leftIds.length !== rightIds.length) return false;
const rightSet = new Set(rightIds);
return leftIds.every((hostId) => rightSet.has(hostId));
}
function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}
@@ -228,15 +234,25 @@ function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope)
latestAIPanelViewByScopeSnapshot = panelViewByScope;
}
function getDraftMutationVersion(scopeKey: string) {
return latestAIDraftMutationVersionByScopeSnapshot[scopeKey] ?? 0;
function bumpDraftMutationVersion(scopeKey: string) {
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
latestAIDraftMutationVersionByScopeSnapshot,
scopeKey,
);
}
function bumpDraftMutationVersion(scopeKey: string) {
latestAIDraftMutationVersionByScopeSnapshot = {
...latestAIDraftMutationVersionByScopeSnapshot,
[scopeKey]: getDraftMutationVersion(scopeKey) + 1,
};
function getDraftUploadGeneration(scopeKey: string) {
return getDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
function bumpDraftUploadGeneration(scopeKey: string) {
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
latestAIDraftUploadGenerationByScopeSnapshot,
scopeKey,
);
}
export function useAIState() {
@@ -788,60 +804,6 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
if (!currentSession) return;
const currentScope = currentSession.scope;
const scopeChanged =
currentScope.type !== scope.type
|| currentScope.targetId !== scope.targetId
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
const nextScopeKey = buildScopeKey(scope);
const currentScopeKey = buildScopeKey(currentScope);
if (scopeChanged) {
setSessionsRaw((prev) => {
let changed = false;
const next = prev.map((session) => {
if (session.id !== sessionId) return session;
changed = true;
return { ...session, scope, externalSessionId: undefined };
});
if (!changed) return prev;
sessionsRef.current = next;
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
}
setActiveSessionIdMapRaw((prev) => {
let changed = false;
const next = { ...prev };
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
delete next[currentScopeKey];
changed = true;
}
if (next[nextScopeKey] !== sessionId) {
next[nextScopeKey] = sessionId;
changed = true;
}
if (!changed) return prev;
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
}, [persistSessions]);
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
@@ -947,12 +909,15 @@ export function useAIState() {
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
return next;
});
bumpDraftMutationVersion(scopeKey);
}, []);
const updateDraftIfPresent = useCallback((
scopeKey: string,
updater: (draft: AIDraft) => AIDraft,
): void => {
let updated = false;
setDraftsByScopeRaw((prev) => {
const currentDraft = prev[scopeKey];
if (!currentDraft) return prev;
@@ -965,14 +930,19 @@ export function useAIState() {
...prev,
[scopeKey]: nextDraft,
};
updated = true;
setLatestAIDraftsByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
return next;
});
if (updated) {
bumpDraftMutationVersion(scopeKey);
}
}, []);
const showDraftView = useCallback((scopeKey: string) => {
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
const currentPanelViewByScope = panelViewByScope;
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
let nextPanelViewByScope: PanelViewByScope | null = null;
let activeSessionMapChanged = false;
@@ -1009,7 +979,7 @@ export function useAIState() {
}, [setPanelViewByScope]);
const clearDraftForScope = useCallback((scopeKey: string) => {
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
const currentPanelViewByScope = panelViewByScope;
let nextDraftsByScope: DraftsByScope | null = null;
let nextPanelViewByScope: PanelViewByScope | null = null;
let draftsChanged = false;
@@ -1031,6 +1001,7 @@ export function useAIState() {
if (!draftsChanged && !panelViewChanged) return;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
if (draftsChanged && nextDraftsByScope) {
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
@@ -1050,11 +1021,11 @@ export function useAIState() {
inputFiles: File[],
) => {
ensureDraftForScope(scopeKey, fallbackAgentId);
const initialMutationVersion = getDraftMutationVersion(scopeKey);
const initialUploadGeneration = getDraftUploadGeneration(scopeKey);
const uploads = await convertFilesToUploads(inputFiles);
if (uploads.length === 0) return;
if (getDraftMutationVersion(scopeKey) !== initialMutationVersion) {
if (getDraftUploadGeneration(scopeKey) !== initialUploadGeneration) {
return;
}
@@ -1175,7 +1146,6 @@ export function useAIState() {
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
retargetSessionScope,
addMessageToSession,
updateLastMessage,
updateMessageById,

View File

@@ -247,19 +247,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.credentialsUnavailable'));
}
// Prevent pushing an empty vault to cloud. This is almost always
// Refuse to push an empty vault to cloud. This is almost always
// a sign that the local state was lost (update, import failure,
// storage corruption) rather than a deliberate "delete everything".
// We only block auto-sync — manual trigger from Settings can still
// push if the user explicitly wants to.
// Both auto and manual triggers are blocked; the user can still
// use Force Push from the SyncBlocked banner if they genuinely
// want to wipe the cloud.
//
// This pairs with the inspect-failure "fail open" behavior in
// checkRemoteVersion below: if inspect transiently errors we still
// let auto-sync run, trusting this guard to refuse if local is
// truly empty rather than letting an empty state clobber remote.
if (!hasMeaningfulSyncData(payload) && trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
if (!hasMeaningfulSyncData(payload)) {
if (trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
}
throw new Error(t('sync.autoSync.emptyVaultManual'));
}
const results = await sync.syncNow(payload);
@@ -479,7 +483,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// that only approximated the correct ordering.
if (mergeResult.payload) {
try {
await manager.syncAllProviders(mergeResult.payload);
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
const wasShrinkBlocked = Array.from(roundTripResults.values()).some(
(r) => r.shrinkBlocked === true,
);
if (wasShrinkBlocked) {
// The merged payload is already applied locally and is the source of truth
// for THIS device. The blocking only prevents pushing it to cloud, which
// is acceptable here — the next user-edit-triggered sync will re-check
// (and the user can also force-push from the Settings banner if they
// navigate there). Reset syncState so we don't leave the manager wedged
// in BLOCKED with no banner visible.
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
manager.clearShrinkBlockedState();
}
// Suppress the debounced follow-up tick that otherwise fires
// once React commits the applied state, since we've just
// already pushed that exact payload upstream.

View File

@@ -26,7 +26,9 @@ import {
import {
getCloudSyncManager,
type SyncManagerState,
type SyncEventCallback,
} from '../../infrastructure/services/CloudSyncManager';
import type { ShrinkFinding } from '../../domain/syncGuards';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { DeviceFlowState } from '../../infrastructure/services/adapters/GitHubAdapter';
@@ -55,7 +57,7 @@ export interface CloudSyncHook {
// Computed
hasAnyConnectedProvider: boolean;
connectedProviderCount: number;
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict';
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked';
// Master Key Actions
setupMasterKey: (password: string, confirmPassword: string) => Promise<void>;
@@ -86,8 +88,8 @@ export interface CloudSyncHook {
resetProviderStatus: (provider: CloudProvider) => void;
// Sync Actions
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
@@ -116,6 +118,12 @@ export interface CloudSyncHook {
formatLastSync: (timestamp?: number) => string;
getProviderDotColor: (provider: CloudProvider) => string;
refresh: () => void;
// Event subscription (for non-state events like SYNC_BLOCKED_SHRINK)
subscribeToEvents: (callback: SyncEventCallback) => () => void;
// Shrink-block state query (for banner hydration on mount)
getShrinkBlockedFinding: () => Extract<ShrinkFinding, { suspicious: true }> | null;
}
// ============================================================================
@@ -190,7 +198,8 @@ export const useCloudSync = (): CloudSyncHook => {
).length;
}, [state.providers]);
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' => {
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked' => {
if (state.syncState === 'BLOCKED') return 'blocked';
if (state.syncState === 'CONFLICT') return 'conflict';
if (state.syncState === 'ERROR') return 'error';
if (state.syncState === 'SYNCING') return 'syncing';
@@ -422,14 +431,14 @@ export const useCloudSync = (): CloudSyncHook => {
throw new Error('Vault is locked');
}, []);
const syncNowWithUnlock = useCallback(async (payload: SyncPayload) => {
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
await ensureUnlocked();
return await manager.syncAllProviders(payload);
return await manager.syncAllProviders(payload, opts);
}, [ensureUnlocked]);
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload) => {
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
await ensureUnlocked();
return await manager.syncToProvider(provider, payload);
return await manager.syncToProvider(provider, payload, opts);
}, [ensureUnlocked]);
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
@@ -437,6 +446,16 @@ export const useCloudSync = (): CloudSyncHook => {
return await manager.downloadFromProvider(provider);
}, [ensureUnlocked]);
const subscribeToEvents = useCallback(
(callback: SyncEventCallback) => manager.subscribe(callback),
[],
);
const getShrinkBlockedFinding = useCallback(
() => manager.getShrinkBlockedFinding(),
[],
);
const resolveConflictWithUnlock = useCallback(async (resolution: ConflictResolution) => {
await ensureUnlocked();
return await manager.resolveConflict(resolution);
@@ -505,6 +524,12 @@ export const useCloudSync = (): CloudSyncHook => {
formatLastSync,
getProviderDotColor,
refresh,
// Event subscription
subscribeToEvents,
// Shrink-block state query
getShrinkBlockedFinding,
};
};

View File

@@ -141,19 +141,48 @@ export const useSessionState = () => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
}, []);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === workspaceId) {
if (remainingWorkspaces.length > 0) {
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
} else {
setActiveTabId('vault');
}
}
return remainingWorkspaces;
});
}, [setActiveTabId]);
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
e?.stopPropagation();
// Pre-compute outside the setSessions updater so we don't depend on React
// having run the updater by the time we queue the microtask. React 18+ does
// not guarantee updater execution timing under concurrent scheduling.
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
const workspaceIdToMaybeClose =
sessionBeingClosed?.workspaceId &&
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
? sessionBeingClosed.workspaceId
: undefined;
setSessions(prevSessions => {
const targetSession = prevSessions.find(s => s.id === sessionId);
const wsId = targetSession?.workspaceId;
setWorkspaces(prevWorkspaces => {
let removedWorkspaceId: string | null = null;
let nextWorkspaces = prevWorkspaces;
let dissolvedWorkspaceId: string | null = null;
let lastRemainingSessionId: string | null = null;
if (wsId) {
nextWorkspaces = prevWorkspaces
.map(ws => {
@@ -163,7 +192,7 @@ export const useSessionState = () => {
removedWorkspaceId = ws.id;
return null;
}
// Check if only 1 session remains - dissolve workspace
const remainingSessionIds = collectSessionIds(pruned);
if (remainingSessionIds.length === 1) {
@@ -171,12 +200,12 @@ export const useSessionState = () => {
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
return { ...ws, root: pruned };
})
.filter((ws): ws is Workspace => Boolean(ws));
}
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
@@ -198,10 +227,10 @@ export const useSessionState = () => {
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
setActiveTabId(getFallback());
}
return nextWorkspaces;
});
// Check if we need to dissolve a workspace (convert remaining session to orphan)
if (targetSession?.workspaceId) {
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
@@ -218,29 +247,14 @@ export const useSessionState = () => {
}
}
}
return prevSessions.filter(s => s.id !== sessionId);
});
}, [workspaces, setActiveTabId]);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === workspaceId) {
if (remainingWorkspaces.length > 0) {
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
} else {
setActiveTabId('vault');
}
}
return remainingWorkspaces;
});
}, [setActiveTabId]);
return prevSessions.filter(s => s.id !== sessionId);
});
if (workspaceIdToMaybeClose) {
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
}
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
const startSessionRename = useCallback((sessionId: string) => {
setSessions(prevSessions => {
@@ -654,16 +668,22 @@ export const useSessionState = () => {
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
const newSessionId = crypto.randomUUID();
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
// Source may have been closed between the user's action and this
// update running; in that case skip entirely — do NOT switch the
// active tab or insert into tabOrder, which would leave dangling ids.
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// Create a new session with the same connection info
const newSession: TerminalSession = {
id: crypto.randomUUID(),
id: newSessionId,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
@@ -681,10 +701,40 @@ export const useSessionState = () => {
localShellIcon: session.localShellIcon,
};
setActiveTabId(newSession.id);
// Schedule the activeTab + tabOrder updates only when creation
// actually happens. These nested setStates are idempotent, so
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
// Fast path: source is already tracked in tabOrder — splice directly.
const directIdx = prevTabOrder.indexOf(sessionId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, newSessionId);
return next;
}
// Fallback: source is only in the derived tab collections. Rebuild the
// effective order (same pattern as reorderTabs) to locate its position.
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sessionId);
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, newSessionId);
return next;
});
return [...prevSessions, newSession];
});
}, [setActiveTabId]);
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {

View File

@@ -108,7 +108,8 @@ const SYNCABLE_TERMINAL_KEYS = [
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
'keepaliveInterval', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'osc52Clipboard',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
] as const;

View File

@@ -47,17 +47,25 @@ import {
type UserSkillOption,
} from './ai/userSkillsState';
import {
applyDraftEntrySelection,
applyHistorySessionSelection,
resolveDisplayedPanelView,
resolveDisplayedSession,
shouldRetargetSessionForScope,
} from './ai/aiPanelViewState';
import {
endDraftSend,
tryBeginDraftSend,
} from './ai/draftSendGate';
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import {
useAIChatStreaming,
getNetcattyBridge,
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
import { buildAcpHistoryMessagesForBridge } from './ai/acpHistory';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
@@ -102,7 +110,6 @@ interface AIChatSidePanelProps {
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
retargetSessionScope: (sessionId: string, scope: AISessionScope) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (
sessionId: string,
@@ -172,56 +179,6 @@ 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): Array<{ role: 'user' | 'assistant'; content: string }> => {
if (message.role === 'system') return [];
if (message.role === 'user') {
return message.content ? [{ role: 'user', 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', content: parts.join('\n\n') }];
}
if (message.role === 'tool' && message.toolResults?.length) {
return message.toolResults.map((tr) => ({
role: 'assistant',
content: `Tool result:\n${tr.content}`,
}));
}
return [];
});
}
function getSessionScopeMatchRank(
session: AISession,
scopeType: 'terminal' | 'workspace',
scopeTargetId?: string,
scopeHostIds?: string[],
activeTerminalTargetIds?: Set<string>,
): number {
if (session.scope.type !== scopeType) return 0;
if (session.scope.targetId === scopeTargetId) return 2;
if (scopeType !== 'terminal' || !scopeHostIds?.length || !session.scope.hostIds?.length) {
return 0;
}
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
return 0;
}
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
@@ -243,7 +200,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
retargetSessionScope,
addMessageToSession,
updateLastMessage,
updateMessageById,
@@ -302,36 +258,42 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setActiveSessionIdForScope(scopeKey, id);
}, [scopeKey, setActiveSessionIdForScope]);
const activeTerminalTargetIds = useMemo(() => {
const targetIds = new Set<string>();
for (const [sessionScopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
const activeTerminalSessionIds = useMemo(() => {
const sessionIds = new Set<string>();
const entries = Object.entries(activeSessionIdMap) as Array<[string, string | null]>;
for (const [sessionScopeKey, sessionId] of entries) {
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
const targetId = sessionScopeKey.slice('terminal:'.length);
if (!targetId || targetId === scopeTargetId) continue;
targetIds.add(targetId);
if (sessionScopeKey === scopeKey) continue;
sessionIds.add(sessionId);
}
return targetIds;
}, [activeSessionIdMap, scopeTargetId]);
return sessionIds;
}, [activeSessionIdMap, scopeKey]);
const historySessions = useMemo(
() =>
sessions
.map((session) => ({
session,
matchRank: getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds),
matchRank: getSessionScopeMatchRank(
session,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
),
}))
.filter(({ matchRank }) => matchRank > 0)
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
.map(({ session }) => session),
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds],
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
);
const explicitPanelView = panelViewByScope[scopeKey];
const currentDraft = draftsByScope[scopeKey] ?? null;
const persistedSessionId = activeSessionIdMap[scopeKey] ?? null;
const normalizedPanelView = useMemo<AIPanelView>(
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId),
[explicitPanelView, currentDraft, historySessions, persistedSessionId],
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
);
const activeSession = useMemo(
() => resolveDisplayedSession(normalizedPanelView, historySessions),
@@ -348,6 +310,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
currentDraftRef.current = currentDraft;
const activeSessionRef = useRef(activeSession);
activeSessionRef.current = activeSession;
const draftSendInFlightRef = useRef(false);
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
@@ -385,40 +348,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
showDraftView(scopeKey);
}, [normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
const shouldRetargetActiveSession = useMemo(() => {
return shouldRetargetSessionForScope(
activeSession,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalTargetIds,
);
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
useEffect(() => {
if (!activeSession) return;
if (shouldRetargetActiveSession && isVisible) {
if (streamingSessionIds.has(activeSession.id)) {
const controller = abortControllersRef.current.get(activeSession.id);
if (controller) {
controller.abort();
abortControllersRef.current.delete(activeSession.id);
}
setStreamingForScope(activeSession.id, false);
clearAllPendingApprovals(activeSession.id);
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(activeSession.id);
bridge?.aiAcpCancel?.('', activeSession.id);
}
retargetSessionScope(activeSession.id, {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
});
return;
}
if (isVisible && activeSessionIdMap[scopeKey] !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
@@ -426,18 +358,22 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
activeSession,
activeSessionIdMap,
scopeKey,
retargetSessionScope,
isVisible,
scopeHostIds,
scopeTargetId,
scopeType,
setActiveSessionId,
setStreamingForScope,
shouldRetargetActiveSession,
streamingSessionIds,
abortControllersRef,
]);
// When the resolved view is draft but activeSessionIdMap still points at a
// previously-shown session, clear that stale entry. Otherwise
// activeTerminalTargetIds keeps claiming ownership of the old session's
// target and getSessionScopeMatchRank suppresses matching history from
// other terminals until another action rewrites the map.
useEffect(() => {
if (!isVisible) return;
if (normalizedPanelView.mode !== 'draft') return;
if (persistedSessionId == null) return;
setActiveSessionId(null);
}, [isVisible, normalizedPanelView.mode, persistedSessionId, setActiveSessionId]);
const ensureScopeDraft = useCallback((agentId: string) => {
ensureDraftForScope(scopeKey, agentId);
}, [ensureDraftForScope, scopeKey]);
@@ -461,16 +397,26 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
clearDraftForScope(scopeKey);
}, [clearDraftForScope, scopeKey]);
const enterScopeDraftMode = useCallback((agentId: string, preserveSessionView = false) => {
applyDraftEntrySelection({
ensureDraft: () => ensureScopeDraft(agentId),
showDraftView: showScopeDraftView,
preserveSessionView,
});
}, [ensureScopeDraft, showScopeDraftView]);
const setInputValue = useCallback((value: string) => {
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
updateScopeDraft(currentAgentId, (draft) => ({
...draft,
text: value,
}));
}, [currentAgentId, updateScopeDraft]);
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
const addFiles = useCallback(async (inputFiles: File[]) => {
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
await addDraftFiles(scopeKey, currentAgentId, inputFiles);
}, [addDraftFiles, scopeKey, currentAgentId]);
}, [addDraftFiles, currentAgentId, enterScopeDraftMode, scopeKey]);
const removeFile = useCallback((fileId: string) => {
removeDraftFile(scopeKey, currentAgentId, fileId);
@@ -810,6 +756,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const addSelectedUserSkill = useCallback((slug: string) => {
const normalizedSlug = String(slug || '').trim().toLowerCase();
if (!normalizedSlug) return;
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
updateScopeDraft(currentAgentId, (draft) => {
if (draft.selectedUserSkillSlugs.includes(normalizedSlug)) {
return draft;
@@ -819,11 +766,12 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
selectedUserSkillSlugs: [...draft.selectedUserSkillSlugs, normalizedSlug],
};
});
}, [currentAgentId, updateScopeDraft]);
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
const removeSelectedUserSkill = useCallback((slug: string) => {
const normalizedSlug = String(slug || '').trim().toLowerCase();
if (!normalizedSlug) return;
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
updateScopeDraft(currentAgentId, (draft) => {
const nextSelectedUserSkillSlugs = draft.selectedUserSkillSlugs.filter(
(entry) => entry !== normalizedSlug,
@@ -836,7 +784,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
selectedUserSkillSlugs: nextSelectedUserSkillSlugs,
};
});
}, [currentAgentId, updateScopeDraft]);
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
// -------------------------------------------------------------------
// Main send handler (thin orchestrator)
@@ -859,110 +807,121 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
filename: file.filename,
filePath: file.filePath,
}));
const isDraftMode = currentPanelView.mode === 'draft';
let sessionId = currentSessionView?.id ?? null;
let currentSession = currentSessionView ?? null;
let sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
if (currentPanelView.mode === 'draft') {
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const createdSession = createSession(scope, sendAgentId);
sessionId = createdSession.id;
currentSession = createdSession;
clearScopeDraft();
showScopeSessionView(createdSession.id);
setActiveSessionId(createdSession.id);
}
if (!sessionId) {
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
return;
}
const isExternalAgent = sendAgentId !== 'catty';
try {
let sessionId = currentSessionView?.id ?? null;
let currentSession = currentSessionView ?? null;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
// No provider configured for built-in agent
if (!isExternalAgent && !activeProvider) {
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
if (isDraftMode) {
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const createdSession = createSession(scope, sendAgentId);
sessionId = createdSession.id;
currentSession = createdSession;
clearScopeDraft();
showScopeSessionView(sessionId);
showScopeSessionView(createdSession.id);
setActiveSessionId(createdSession.id);
}
return;
}
// Add user message
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
clearScopeDraft();
showScopeSessionView(sessionId);
setActiveSessionId(sessionId);
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent
? (selectedAgentModel || agentConfig?.name || 'external')
: (activeModelId || activeProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
});
const abortController = new AbortController();
abortControllersRef.current.set(sessionId, abortController);
currentSession = currentSession ?? sessionsRef.current.find((session) => session.id === sessionId) ?? null;
if (isExternalAgent) {
if (!agentConfig) {
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
setStreamingForScope(sessionId, false);
if (!sessionId) {
return;
}
try {
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
existingSessionId: currentSession?.externalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
terminalSessions,
defaultTargetSession,
providers,
selectedAgentModel,
toolIntegrationMode,
selectedUserSkillSlugs: selectedSkillSlugs,
});
} catch (err) {
reportStreamError(sessionId, abortController.signal, err);
const isExternalAgent = sendAgentId !== 'catty';
// No provider configured for built-in agent
if (!isExternalAgent && !activeProvider) {
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
clearScopeDraft();
showScopeSessionView(sessionId);
}
return;
}
// Add user message
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
clearScopeDraft();
showScopeSessionView(sessionId);
setActiveSessionId(sessionId);
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent
? (selectedAgentModel || agentConfig?.name || 'external')
: (activeModelId || activeProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
});
const abortController = new AbortController();
abortControllersRef.current.set(sessionId, abortController);
currentSession = currentSession ?? sessionsRef.current.find((session) => session.id === sessionId) ?? null;
if (isExternalAgent) {
if (!agentConfig) {
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
setStreamingForScope(sessionId, false);
return;
}
try {
const existingExternalSessionId = currentSession?.externalSessionId;
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
existingSessionId: existingExternalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildAcpHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
terminalSessions,
defaultTargetSession,
providers,
selectedAgentModel,
toolIntegrationMode,
selectedUserSkillSlugs: selectedSkillSlugs,
});
} catch (err) {
reportStreamError(sessionId, abortController.signal, err);
}
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, trimmed);
} else {
const toolScope = {
type: scopeType,
targetId: scopeTargetId,
label: scopeLabel,
} as const;
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider,
activeModelId,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
terminalSessions,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
}, attachments.length > 0 ? attachments : undefined);
}
} finally {
if (isDraftMode) {
endDraftSend(draftSendInFlightRef);
}
// Clear any lingering statusText when the external agent stream finishes
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sessionId, false);
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, trimmed);
} else {
const toolScope = {
type: scopeType,
targetId: scopeTargetId,
label: scopeLabel,
} as const;
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider,
activeModelId,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
terminalSessions,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
}, attachments.length > 0 ? attachments : undefined);
}
}, [
isStreaming, activeProvider, scopeKey, currentAgentId,
@@ -1017,12 +976,15 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
);
const handleAgentChange = useCallback((agentId: string) => {
showScopeDraftView();
ensureScopeDraft(agentId);
updateScopeDraft(agentId, (draft) => ({
...draft,
agentId,
...selectDraftForAgentSwitch(
draft,
agentId,
Boolean(activeSessionRef.current?.messages.length),
),
}));
showScopeDraftView();
setShowHistory(false);
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
@@ -1203,20 +1165,20 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
onClick={() => onSelect(session.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
className={cn(
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
SESSION_HISTORY_ROW_CLASSNAMES.row,
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
)}
>
<span className="text-[13px] truncate pr-3 flex-1 min-w-0">
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
{session.title || t('ai.chat.untitled')}
</span>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[12px] text-muted-foreground/50">
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
{timeStr}
</span>
<button
onClick={(e) => onDelete(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
title="Delete"
>
<Trash2 size={12} />

View File

@@ -7,7 +7,7 @@
* - Sync status and conflict resolution
*/
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import {
AlertTriangle,
Check,
@@ -43,7 +43,9 @@ import { useI18n } from '../application/i18n/I18nProvider';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type SyncResult, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
import type { ShrinkFinding } from '../domain/syncGuards';
import { SyncBlockedBanner } from './sync/SyncBlockedBanner';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
@@ -897,20 +899,29 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">
{getReasonLabel(backup.reason)}
</span>
<span className="text-xs text-muted-foreground">
{formatTimestamp(backup.createdAt)}
</span>
<div className="text-sm font-medium">
{backup.syncDataVersion
? `v${backup.syncDataVersion}`
: formatTimestamp(backup.createdAt)}
</div>
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
<span>{getReasonLabel(backup.reason)}</span>
{backup.syncDataVersion && (
<>
<span aria-hidden="true">·</span>
<span>{formatTimestamp(backup.createdAt)}</span>
</>
)}
{backup.sourceAppVersion && backup.targetAppVersion && (
<span className="text-xs text-muted-foreground">
{t('cloudSync.localBackups.versionChange', {
from: backup.sourceAppVersion,
to: backup.targetAppVersion,
})}
</span>
<>
<span aria-hidden="true">·</span>
<span>
{t('cloudSync.localBackups.versionChange', {
from: backup.sourceAppVersion,
to: backup.targetAppVersion,
})}
</span>
</>
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
@@ -974,13 +985,30 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
</DialogHeader>
{pendingRestoreBackup && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">
{getReasonLabel(pendingRestoreBackup.reason)}
</span>
<span className="text-muted-foreground">
{formatTimestamp(pendingRestoreBackup.createdAt)}
</span>
<div className="font-medium">
{pendingRestoreBackup.syncDataVersion
? `v${pendingRestoreBackup.syncDataVersion}`
: formatTimestamp(pendingRestoreBackup.createdAt)}
</div>
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
{pendingRestoreBackup.syncDataVersion && (
<>
<span aria-hidden="true">·</span>
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
</>
)}
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
<>
<span aria-hidden="true">·</span>
<span>
{t('cloudSync.localBackups.versionChange', {
from: pendingRestoreBackup.sourceAppVersion,
to: pendingRestoreBackup.targetAppVersion,
})}
</span>
</>
)}
</div>
<div className="text-muted-foreground">
{t('cloudSync.localBackups.counts', {
@@ -1172,6 +1200,17 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Clear local data dialog
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
// Sync-blocked banner (Task 7) + force-push confirmation modal (Task 8)
const [blockedFinding, setBlockedFinding] = useState<Extract<ShrinkFinding, { suspicious: true }> | null>(null);
const [showForcePushConfirm, setShowForcePushConfirm] = useState(false);
// Ref for scrolling to LocalBackupsPanel when the banner's Restore button is clicked
const localBackupsRef = useRef<HTMLDivElement>(null);
// Active tab state — lets the banner's "Restore" button switch to the
// local-backups tab without a separate DOM query.
const [activeTab, setActiveTab] = useState<'providers' | 'status'>('providers');
const ensureSyncablePayload = useCallback(
(payload: SyncPayload): boolean => {
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
@@ -1190,6 +1229,35 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
}
}, [sync.currentConflict]);
// Subscribe to sync events to show/clear the blocked-shrink banner.
// Destructure the stable useCallback reference so the effect runs once on
// mount rather than re-subscribing on every render when `sync` object ref changes.
const { subscribeToEvents, getShrinkBlockedFinding } = sync;
// Hydrate from current manager state in case a shrink-block happened
// before this component mounted (e.g., auto-sync ran while the user
// was on a different tab). Without this, the banner only shows
// blocks that occur after Settings is open.
useEffect(() => {
const existing = getShrinkBlockedFinding();
if (existing) {
setBlockedFinding(existing);
}
}, [getShrinkBlockedFinding]);
useEffect(() => {
const unsub = subscribeToEvents((event) => {
if (event.type === 'SYNC_BLOCKED_SHRINK') {
if (event.finding.suspicious) {
setBlockedFinding(event.finding);
}
} else if (event.type === 'SYNC_BLOCKED_CLEARED') {
setBlockedFinding(null);
}
});
return unsub;
}, [subscribeToEvents]);
// If we have a master key but we're still locked (e.g. older installs),
// prompt once and persist the password via safeStorage.
useEffect(() => {
@@ -1441,9 +1509,30 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// window's push, not ours.
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) return;
let results: Map<CloudProvider, SyncResult> | null = null;
await withRestoreBarrier(async () => {
await sync.syncNow(localPayload);
results = await sync.syncNow(localPayload, { overrideShrink: true });
});
if (results) {
// Apply any merged payload BEFORE closing the modal so local state
// reflects what's now on cloud (in case remote changed during the merge).
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
break;
}
}
const allOk = Array.from((results as Map<CloudProvider, SyncResult>).values()).every((r) => r.success);
if (!allOk) {
const firstError = Array.from((results as Map<CloudProvider, SyncResult>).values())
.find((r) => !r.success)?.error
?? t('common.unknownError');
toast.error(firstError, t('cloudSync.resolve.failedTitle'));
return; // KEEP the modal open so user can retry / pick USE_REMOTE
}
}
toast.success(t('cloudSync.resolve.uploaded'));
}
setShowConflictModal(false);
@@ -1554,7 +1643,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</div>
</div>
<Tabs defaultValue="providers" className="space-y-4">
{blockedFinding && (
<SyncBlockedBanner
finding={blockedFinding}
onRestore={() => {
setActiveTab('status');
requestAnimationFrame(() => {
localBackupsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}}
onForcePush={() => setShowForcePushConfirm(true)}
/>
)}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
<TabsTrigger value="status">{t('cloudSync.status.title')}</TabsTrigger>
@@ -1739,9 +1841,11 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</div>
)}
<LocalBackupsPanel
onApplyPayload={onApplyPayload}
/>
<div ref={localBackupsRef}>
<LocalBackupsPanel
onApplyPayload={onApplyPayload}
/>
</div>
{/* Clear Local Data */}
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
@@ -2361,6 +2465,69 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Force-push confirmation modal (Task 8) */}
{showForcePushConfirm && blockedFinding && (
<Dialog open onOpenChange={(open) => !open && setShowForcePushConfirm(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('sync.forcePush.title')}</DialogTitle>
</DialogHeader>
<p className="text-sm">
{t('sync.forcePush.body', {
lost: blockedFinding.lost,
entityType: t(`sync.entityType.${blockedFinding.entityType}`),
})}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setShowForcePushConfirm(false)}>
{t('sync.forcePush.cancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) {
setShowForcePushConfirm(false);
return;
}
setShowForcePushConfirm(false);
try {
const results = await sync.syncNow(localPayload, { overrideShrink: true });
// Apply any merged payload BEFORE clearing the banner. If a merge happened
// during force-push (remote changed), the merged result is what the cloud
// now has — applying it to local state prevents the next sync from
// re-deleting the remote additions we just merged in.
for (const result of results.values()) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
break; // All providers share the same merged payload
}
}
const allOk = Array.from(results.values()).every((r) => r.success);
if (allOk) {
setBlockedFinding(null);
} else {
// Surface the failure but KEEP the banner so the user can retry or
// restore. Find the first error string to display.
const firstError = Array.from(results.values())
.find((r) => !r.success)
?.error ?? t('sync.toast.errorTitle');
toast.error(firstError, t('sync.toast.errorTitle'));
}
} catch (err) {
toast.error(String(err), t('sync.toast.errorTitle'));
}
}}
>
{t('sync.forcePush.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
};

View File

@@ -136,7 +136,13 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
// Determine overall status for the button indicator
const getOverallStatus = (): StatusIndicatorProps['status'] => {
if (sync.overallSyncStatus === 'syncing') return 'syncing';
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
if (
sync.overallSyncStatus === 'error' ||
sync.overallSyncStatus === 'conflict' ||
sync.overallSyncStatus === 'blocked'
) {
return 'error';
}
if (sync.overallSyncStatus === 'synced') return 'synced';
return 'none';
};

View File

@@ -49,6 +49,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
@@ -620,6 +621,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.focus();
}, []);
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
e.preventDefault();
}, []);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
@@ -793,6 +800,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Autocomplete integration
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
isRestoringSelectionRef,
});
xtermRuntimeRef.current = runtime;
@@ -1230,7 +1238,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hasText = !!selection && selection.length > 0;
setHasSelection(hasText);
if (hasText && terminalSettings?.copyOnSelect) {
if (hasText && terminalSettings?.copyOnSelect && !isRestoringSelectionRef.current) {
navigator.clipboard.writeText(selection).catch((err) => {
logger.warn("Copy on select failed:", err);
});
@@ -1321,6 +1329,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
// True only while createXTermRuntime is programmatically restoring the
// selection right after a keystroke (preserveSelectionOnInput). Lets
// copy-on-select skip a redundant clipboard write that would otherwise
// clobber whatever the user copied elsewhere in the meantime.
const isRestoringSelectionRef = useRef(false);
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
@@ -1706,6 +1720,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
onMouseDownCapture={handleTopOverlayMouseDownCapture}
style={{
backgroundColor: 'var(--terminal-ui-bg)',
color: 'var(--terminal-ui-fg)',

View File

@@ -340,7 +340,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
retargetSessionScope={aiState.retargetSessionScope}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
@@ -430,6 +429,8 @@ interface TerminalLayerProps {
sessionLogsEnabled?: boolean;
sessionLogsDir?: string;
sessionLogsFormat?: string;
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
}
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
@@ -483,6 +484,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
closeSidePanelRef,
activeSidePanelTabRef,
}) => {
// Subscribe to activeTabId from external store
const activeTabId = useActiveTabId();
@@ -663,6 +666,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Whether side panel is open for the currently active tab and which sub-panel
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
if (activeSidePanelTabRef) {
activeSidePanelTabRef.current = activeSidePanelTab;
}
// Legacy compatibility helpers for SFTP-specific logic
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
@@ -1259,9 +1265,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
if (!sessionId) return;
const focusTarget = () => {
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
};
requestAnimationFrame(() => {
focusTarget();
setTimeout(focusTarget, 50);
});
}, []);
// Close the entire side panel for the current tab
const handleCloseSidePanel = useCallback(() => {
if (!activeTabId) return;
const sessionIdToRefocus = activeWorkspace?.focusedSessionId ?? activeSession?.id;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.delete(activeTabId);
@@ -1284,7 +1306,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.delete(activeTabId);
return next;
});
}, [activeTabId]);
refocusTerminalSession(sessionIdToRefocus);
}, [activeTabId, activeWorkspace?.focusedSessionId, activeSession?.id, refocusTerminalSession]);
useEffect(() => {
if (!closeSidePanelRef) return;
closeSidePanelRef.current = handleCloseSidePanel;
return () => {
closeSidePanelRef.current = null;
};
}, [closeSidePanelRef, handleCloseSidePanel]);
// Switch side panel to a specific tab (or toggle if already on that tab)
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
@@ -2403,14 +2434,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onSend={handleComposeSend}
onClose={() => {
setIsComposeBarOpen(false);
// Refocus the terminal pane (matching solo-session behavior)
if (focusedSessionId) {
requestAnimationFrame(() => {
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
});
}
refocusTerminalSession(focusedSessionId);
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}

View File

@@ -12,7 +12,7 @@ import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../lib/useDiscoveredShells';
import { Button } from './ui/button';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './ui/context-menu';
import { SyncStatusButton } from './SyncStatusButton';
// Helper styles for Electron drag regions (use type assertion to include non-standard WebkitAppRegion)
@@ -36,6 +36,7 @@ interface TopTabsProps {
onRenameWorkspace: (workspaceId: string) => void;
onCloseWorkspace: (workspaceId: string) => void;
onCloseLogView: (logViewId: string) => void;
onCloseTabsBatch: (targetIds: string[]) => void;
onOpenQuickSwitcher: () => void;
onToggleTheme: () => void;
onOpenSettings: () => void;
@@ -244,6 +245,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onRenameWorkspace,
onCloseWorkspace,
onCloseLogView,
onCloseTabsBatch,
onOpenQuickSwitcher,
onToggleTheme,
onOpenSettings,
@@ -304,11 +306,23 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
updateScrollState();
const container = tabsContainerRef.current;
if (container) {
// Translate vertical wheel to horizontal scroll so users can reach
// off-screen tabs with a standard mouse wheel. Trackpad gestures that
// already carry horizontal delta are left alone so native two-finger
// swiping still works.
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0 && e.deltaX === 0) {
e.preventDefault();
container.scrollLeft += e.deltaY;
}
};
container.addEventListener('scroll', updateScrollState);
container.addEventListener('wheel', handleWheel, { passive: false });
const resizeObserver = new ResizeObserver(updateScrollState);
resizeObserver.observe(container);
return () => {
container.removeEventListener('scroll', updateScrollState);
container.removeEventListener('wheel', handleWheel);
resizeObserver.disconnect();
};
}
@@ -482,6 +496,37 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}).filter(Boolean);
}, [orderedTabs, orphanSessionMap, workspaceMap, logViewMap, workspacePaneCounts]);
// Bulk-close menu items shared by session and workspace context menus.
// Anchor is the tab the user right-clicked on (matches VSCode/JetBrains UX).
const renderBulkCloseItems = (anchorId: string) => {
const anchorIdx = orderedTabs.indexOf(anchorId);
const othersIds = orderedTabs.filter((id) => id !== anchorId);
const rightIds = anchorIdx >= 0 ? orderedTabs.slice(anchorIdx + 1) : [];
return (
<>
<ContextMenuSeparator />
<ContextMenuItem
disabled={othersIds.length === 0}
onClick={() => onCloseTabsBatch(othersIds)}
>
{t('tabs.closeOthers')}
</ContextMenuItem>
<ContextMenuItem
disabled={rightIds.length === 0}
onClick={() => onCloseTabsBatch(rightIds)}
>
{t('tabs.closeToRight')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => onCloseTabsBatch(orderedTabs)}
>
{t('tabs.closeAll')}
</ContextMenuItem>
</>
);
};
// Render the tabs
const renderOrderedTabs = () => {
return orderedTabItems.map((item) => {
@@ -581,6 +626,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
{t('common.close')}
</ContextMenuItem>
{renderBulkCloseItems(session.id)}
</ContextMenuContent>
</ContextMenu>
);
@@ -687,6 +733,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
{t('common.close')}
</ContextMenuItem>
{renderBulkCloseItems(workspace.id)}
</ContextMenuContent>
</ContextMenu>
);

View File

@@ -36,7 +36,7 @@ import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
import { getEffectiveHostDistro, sanitizeHost, upsertHostById } from "../domain/host";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import {
@@ -2941,13 +2941,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
groupDefaults={editingHostGroupDefaults}
groupConfigs={groupConfigs}
onSave={(host) => {
// Check if host already exists in the list (for updates vs. new/duplicate)
const hostExists = hosts.some((h) => h.id === host.id);
onUpdateHosts(
hostExists
? hosts.map((h) => (h.id === host.id ? host : h))
: [...hosts, host],
);
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
@@ -2973,15 +2967,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
allTags={allTags}
groups={allGroupPaths}
onSave={(host) => {
onUpdateHosts(
hosts.map((h) => (h.id === host.id ? host : h)),
);
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
onCancel={() => {
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
layout="inline"
/>

View File

@@ -7,7 +7,7 @@
*/
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-react';
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
import type { FormEvent } from 'react';
@@ -100,6 +100,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
const [slashQuery, setSlashQuery] = useState('');
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
// Active highlight index for @ mention / slash skill keyboard navigation
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
// Derived booleans for readability
const showModelPicker = activeMenu === 'model';
@@ -203,11 +205,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
setActiveMenu(menu);
}, [getInputPanelMenuPos]);
const filteredUserSkills = userSkills.filter((skill) => {
const filteredUserSkills = useMemo(() => userSkills.filter((skill) => {
if (!slashQuery) return true;
const lowerQuery = slashQuery.toLowerCase();
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
});
}), [userSkills, slashQuery]);
const removeSlashQueryFromInput = useCallback(() => {
if (!slashRange) return value;
@@ -227,6 +229,78 @@ const ChatInput: React.FC<ChatInputProps> = ({
closeAllMenus();
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
// Reset active highlight when a menu opens or when the *identity* of the
// visible items changes. Watching only `.length` misses cases where the
// filter produces a different set with the same count (e.g. user types
// another character into the slash query) — Enter would then commit an
// unexpected item. Derive a stable key from the visible ids instead.
const atMentionKey = useMemo(
() => hosts.map((h) => h.sessionId).join('|'),
[hosts],
);
const slashSkillKey = useMemo(
() => filteredUserSkills.map((s) => s.id).join('|'),
[filteredUserSkills],
);
useEffect(() => {
if (showAtMention) setActiveMenuIndex(0);
}, [showAtMention, atMentionKey]);
useEffect(() => {
if (showSlashSkillPicker) setActiveMenuIndex(0);
}, [showSlashSkillPicker, slashSkillKey]);
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing) return;
// @ mention popover keyboard navigation
if (showAtMention && hosts.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % hosts.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + hosts.length) % hosts.length);
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const host = hosts[Math.min(activeMenuIndex, hosts.length - 1)];
if (host) handleSelectAtMention(host);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
}
// / skill popover keyboard navigation
if (showSlashSkillPicker && filteredUserSkills.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveMenuIndex((i) => (i + 1) % filteredUserSkills.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveMenuIndex((i) => (i - 1 + filteredUserSkills.length) % filteredUserSkills.length);
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const skill = filteredUserSkills[Math.min(activeMenuIndex, filteredUserSkills.length - 1)];
if (skill) insertUserSkillToken(skill);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
closeAllMenus();
return;
}
}
}, [showAtMention, hosts, showSlashSkillPicker, filteredUserSkills, activeMenuIndex, handleSelectAtMention, insertUserSkillToken, closeAllMenus]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const pastedFiles = Array.from(e.clipboardData.items)
.map((item: DataTransferItem) => item.getAsFile())
@@ -367,6 +441,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
ref={textareaRef}
value={value}
onChange={(e) => handleInputChange(e.target.value)}
onKeyDown={handleTextareaKeyDown}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled}
className={[
@@ -392,31 +467,40 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div
role="listbox"
aria-label="Mention host"
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
aria-activedescendant={hosts[activeMenuIndex] ? `at-mention-${hosts[activeMenuIndex].sessionId}` : undefined}
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
>
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuHosts')}</div>
<ScrollArea className="max-h-[300px]">
<div className="px-2.5 pb-2.5">
{hosts.map(host => (
<button
key={host.sessionId}
type="button"
role="option"
onClick={() => handleSelectAtMention(host)}
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="truncate">{host.label || host.hostname}</span>
</div>
{host.label && host.hostname !== host.label ? (
<div className="mt-0.5 pl-3.5 text-[10px] text-muted-foreground/60 truncate">
{host.hostname}
<ScrollArea className="max-h-[280px]">
<div className="p-1">
{hosts.map((host, idx) => {
const isActive = idx === activeMenuIndex;
const showHostnameLine = host.label
&& host.hostname !== host.label
&& !host.label.includes(host.hostname);
return (
<button
id={`at-mention-${host.sessionId}`}
key={host.sessionId}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActiveMenuIndex(idx)}
onClick={() => handleSelectAtMention(host)}
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
<span className="truncate">{host.label || host.hostname}</span>
</div>
) : null}
</button>
))}
{showHostnameLine ? (
<div className="pl-3.5 text-[10px] text-muted-foreground/60 truncate">
{host.hostname}
</div>
) : null}
</button>
);
})}
</div>
</ScrollArea>
</div>
@@ -431,31 +515,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
<div
role="listbox"
aria-label="Insert user skill"
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
aria-activedescendant={filteredUserSkills[activeMenuIndex] ? `slash-skill-${filteredUserSkills[activeMenuIndex].id}` : undefined}
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
>
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuUserSkills')}</div>
<ScrollArea className="max-h-[300px]">
<div className="px-2.5 pb-2.5">
{filteredUserSkills.map((skill) => (
<button
key={skill.id}
type="button"
role="option"
onClick={() => insertUserSkillToken(skill)}
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2 text-[12px]">
<Package size={12} className="text-muted-foreground/55 shrink-0" />
<span className="text-foreground/90">/{skill.slug}</span>
</div>
{skill.description ? (
<div className="mt-0.5 pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
{skill.description}
<ScrollArea className="max-h-[280px]">
<div className="p-1">
{filteredUserSkills.map((skill, idx) => {
const isActive = idx === activeMenuIndex;
return (
<button
id={`slash-skill-${skill.id}`}
key={skill.id}
type="button"
role="option"
aria-selected={isActive}
onMouseEnter={() => setActiveMenuIndex(idx)}
onClick={() => insertUserSkillToken(skill)}
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
>
<div className="flex items-center gap-2 text-[12px]">
<Package size={12} className="text-muted-foreground/55 shrink-0" />
<span className="text-foreground/90">/{skill.slug}</span>
</div>
) : null}
</button>
))}
{skill.description ? (
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
{skill.description}
</div>
) : null}
</button>
);
})}
</div>
</ScrollArea>
</div>

View File

@@ -0,0 +1,662 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
import {
buildAcpHistoryMessages,
buildAcpHistoryMessagesForBridge,
} from "./acpHistory.ts";
function message(
id: string,
role: ChatMessage["role"],
content: string,
extra: Partial<ChatMessage> = {},
): ChatMessage {
return {
id,
role,
content,
timestamp: 1,
...extra,
};
}
test("buildAcpHistoryMessages compacts older ACP context and keeps only recent raw turns", () => {
const messages: ChatMessage[] = [
message("u1", "user", "我希望最小改动,不要添加很多 test"),
message("a1", "assistant", "已按最小改动处理"),
message("u2", "user", "MCP 不允许使用Windows 上不要假设 pwsh.exe"),
message("a2", "assistant", "PR #738 已创建commit 4181a2c"),
message("u3", "user", "帮我上网查查优化方案,每轮都带历史太慢了"),
message("a3", "assistant", "建议 ACP history compaction"),
message("tool1", "tool", "", {
toolResults: [
{
toolCallId: "search",
content: `error: ${"large output ".repeat(500)}`,
isError: true,
},
],
}),
message("u4", "user", "好的"),
message("a4", "assistant", "准备实现"),
message("u5", "user", "继续"),
message("a5", "assistant", "继续处理"),
message("u6", "user", "现在提交"),
message("a6", "assistant", "还没提交"),
];
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Compact prior Netcatty UI context/);
assert.match(result[0].content, /最小改动/);
assert.match(result[0].content, /pwsh\.exe/);
assert.match(result[0].content, /PR #738/);
assert.ok(result[0].content.length <= 3000);
assert.ok(result.length <= 7);
assert.deepEqual(
result.slice(1).map((entry) => entry.content),
["好的", "准备实现", "继续", "继续处理", "现在提交", "还没提交"],
);
assert.ok(result.every((entry) => entry.content.length <= 3000));
});
test("buildAcpHistoryMessagesForBridge keeps fallback history available for stale ACP session recovery", () => {
const messages = [message("u1", "user", "继续处理这个历史压缩问题")];
assert.equal(buildAcpHistoryMessagesForBridge([], "acp-session-1"), undefined);
assert.deepEqual(
buildAcpHistoryMessagesForBridge(messages, "acp-session-1"),
buildAcpHistoryMessages(messages),
);
});
test("buildAcpHistoryMessages preserves older substantive user instructions outside the recent raw window", () => {
const messages: ChatMessage[] = [
message("u1", "user", "Keep this incremental and do not refactor unrelated files."),
message("a1", "assistant", "Understood."),
];
for (let index = 2; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `filler assistant message ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Keep this incremental and do not refactor unrelated files\./);
assert.deepEqual(
result.slice(-6).map((entry) => entry.content),
[
"filler user message 11",
"filler assistant message 11",
"filler user message 12",
"filler assistant message 12",
"filler user message 13",
"filler assistant message 13",
],
);
});
test("buildAcpHistoryMessages preserves short important user constraints outside the recent raw window", () => {
const messages: ChatMessage[] = [
message("u1", "user", "不要提交"),
message("a1", "assistant", "收到"),
];
for (let index = 2; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `filler assistant message ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /不要提交/);
});
test("buildAcpHistoryMessages does not treat pr inside ordinary words as important", () => {
// Original intent: `\bpr\b` in IMPORTANT_PATTERNS must NOT match 'pr'
// inside ordinary English words like 'approach' / 'improve' / 'prepare'.
// Those words land at priority=1 (kept only as space allows) while the
// 不要提交 line lands at priority=2 (always preferred). The check below
// doesn't assert that the ordinary words are absent from the compact
// section — they may legitimately survive when budget allows; that's
// intentional after we stopped blanket-dropping short user messages.
// What we DO verify: the priority-2 line is selected, which is only
// possible if the IMPORTANT_PATTERNS regex correctly distinguishes it
// from the surrounding short ordinary-word turns.
const messages: ChatMessage[] = [
message("u1", "user", "不要提交"),
message("a1", "assistant", "收到"),
message("u2", "user", "approach"),
message("a2", "assistant", "ack"),
message("u3", "user", "improve"),
message("a3", "assistant", "ack"),
message("u4", "user", "prepare"),
message("a4", "assistant", "ack"),
];
for (let index = 5; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `filler assistant message ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /不要提交/);
});
test("buildAcpHistoryMessages prioritizes later durable instructions over older filler prompts", () => {
const messages: ChatMessage[] = [];
for (let index = 1; index <= 12; index += 1) {
messages.push(
message(
`u${index}`,
"user",
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
messages.push(
message("u13", "user", "Keep the existing layout and copy wording unchanged."),
message("a13", "assistant", "Understood."),
);
for (let index = 14; index <= 18; index += 1) {
messages.push(
message(
`u${index}`,
"user",
`Please continue with implementation step ${index} and keep momentum by following the current plan carefully.`,
),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Keep the existing layout and copy wording unchanged\./);
});
test("buildAcpHistoryMessages preserves older substantive assistant context that later user prompts can reference", () => {
const messages: ChatMessage[] = [
message("u1", "user", "Please propose a migration plan for the sidebar state."),
message(
"a1",
"assistant",
"Plan: 1. Introduce a dedicated hook for the panel stack. 2. Move the derived view state into that hook. 3. Keep the existing UI copy and layout. 4. Add a regression test around back navigation.",
),
];
for (let index = 2; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
messages.push(message("u14", "user", "Apply step 2 of your plan now."));
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Move the derived view state into that hook\./);
});
test("buildAcpHistoryMessages preserves short non-trivial user constraints that miss the IMPORTANT regex", () => {
// Regression: short load-bearing instructions like "Use ssh2" / "中文输出"
// would previously be dropped by a blanket length<10 heuristic, even
// though they don't match any TRIVIAL pattern.
const messages: ChatMessage[] = [
message("u1", "user", "Use ssh2"),
message("a1", "assistant", "Got it."),
message("u2", "user", "中文输出"),
message("a2", "assistant", "明白"),
];
// Push enough later turns so u1/u2 fall outside the recent raw window
// and have to survive via the durable-user compaction path.
for (let index = 3; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `filler assistant message ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Use ssh2/);
assert.match(result[0].content, /中文输出/);
});
test("buildAcpHistoryMessages still drops one-word filler user messages", () => {
// Sanity: removing the length<10 heuristic must not cause "ok" / "继续" /
// "thanks" filler to leak into the compact section.
const messages: ChatMessage[] = [
message("u1", "user", "ok"),
message("a1", "assistant", "ack"),
message("u2", "user", "继续"),
message("a2", "assistant", "继续处理"),
];
for (let index = 3; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `filler assistant message ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
// u1 / u2 fall outside the recent raw window. The compact context, if it
// exists, must not surface these trivial turns as durable user requests.
if (result.length > 0 && result[0].role === "user") {
assert.doesNotMatch(result[0].content, /User request: ok\b/);
assert.doesNotMatch(result[0].content, /User request: 继续/);
}
});
test("buildAcpHistoryMessages preserves recent tool results verbatim (up to the raw budget) for follow-up references", () => {
// Regression: tool results used to only reach fallback replay via the
// 500-char compact summary. If the user's last interaction produced a
// large tool output (cat/rg/fetched file), any "use that output"-style
// follow-up lost the actual bytes. Now tool messages flow through the
// recent raw window at MAX_RAW_MESSAGE_CHARS (2000).
const bigToolOutput = "DATA ".repeat(300); // ~1500 chars — bigger than summary cap but smaller than raw cap
const messages: ChatMessage[] = [
message("u1", "user", "cat /etc/hosts"),
message("a1", "assistant", "", {
toolCalls: [{ id: "call1", name: "terminal", arguments: { cmd: "cat /etc/hosts" } }],
}),
message("tool1", "tool", "", {
toolResults: [
{ toolCallId: "call1", content: bigToolOutput, isError: false },
],
}),
message("u2", "user", "use that output"),
];
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Raw-window tool result carries both the [from ...] provenance label
// and the actual bytes (not just the 500-char compact summary).
assert.match(flat, /Tool result \[from terminal.*?cat \/etc\/hosts.*?\] \(call1\): DATA DATA DATA/);
// Confirm we kept enough bytes to exceed the compact-summary cap.
const toolResultIdx = flat.indexOf("Tool result [from terminal");
assert.ok(toolResultIdx >= 0, "tool result line must appear in raw window");
const toolResultChunk = flat.slice(toolResultIdx);
assert.ok(
toolResultChunk.length > 600,
`expected tool result chunk to exceed compact cap (~500 chars), got ${toolResultChunk.length}`,
);
});
test("buildAcpHistoryMessages inlines tool_call name+args so tool_result is interpretable without the preceding assistant turn", () => {
// Regression: if the raw window starts mid-tool-interaction, the
// preceding assistant tool_call message may be outside the 6-item
// slice. Without the call's name/args inline on the result line, the
// AI sees opaque bytes and "use that output" becomes ambiguous.
const messages: ChatMessage[] = [
// Early filler to push the tool_call off the raw window
message("u0", "user", "prior chatter"),
message("a0", "assistant", "prior reply"),
message("u1", "user", "cat /etc/hosts"),
message("a1", "assistant", "", {
toolCalls: [
{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } },
],
}),
message("tool1", "tool", "", {
toolResults: [
{ toolCallId: "call1", content: "127.0.0.1 localhost", isError: false },
],
}),
message("u2", "user", "use that output"),
message("a2", "assistant", "acknowledged"),
message("u3", "user", "now do the same for /etc/resolv.conf"),
];
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// The tool_result line must carry the originating tool_call's name and
// args, so even if a1 was pushed out of the raw window, the result is
// self-describing.
assert.match(flat, /Tool result \[from terminal_exec/);
assert.match(flat, /cat \/etc\/hosts/);
});
test("buildAcpHistoryMessages bounds the durable-candidate scan to avoid O(N) work per send on long chats", () => {
// Regression target: codex review flagged that the compaction path
// scanned messages.entries() over the full transcript. Build a very
// long chat (>> MAX_DURABLE_SCAN_TURNS user turns) and verify that
// only messages within the recent user-turn window contribute
// durable candidates.
const messages: ChatMessage[] = [];
// An ancient high-priority constraint that MUST be aged out.
messages.push(message("old-important", "user", "不要提交 old-marker-xyz"));
messages.push(message("old-ack", "assistant", "收到"));
// 300 filler turns between the ancient constraint and the window —
// well past MAX_DURABLE_SCAN_TURNS (100).
for (let i = 0; i < 300; i += 1) {
messages.push(
message(`u${i}`, "user", `filler user message ${i}`),
message(`a${i}`, "assistant", `filler assistant message ${i}`),
);
}
// A recent constraint that should survive.
messages.push(message("recent-important", "user", "不要提交 recent-marker-abc"));
for (let i = 0; i < 5; i += 1) {
messages.push(
message(`t${i}`, "user", `tail user message ${i}`),
message(`ta${i}`, "assistant", `tail assistant message ${i}`),
);
}
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Recent priority-2 constraint is kept.
assert.match(flat, /recent-marker-abc/);
// Ancient one past the scan window is dropped — proof the bound holds.
assert.doesNotMatch(flat, /old-marker-xyz/);
});
test("buildAcpHistoryMessages preserves an early constraint in a tool-heavy chat where message count balloons past the raw-count limit", () => {
// Regression: the previous bound was MAX_DURABLE_SCAN_MESSAGES=200 on
// the raw message array. In a tool-heavy chat, each user turn can
// expand to 5+ messages (user + assistant w/ toolCalls + N tool
// results + follow-up assistant), so 200 messages might be only
// ~40 user turns. An instruction like "不要提交" from turn 5 would
// fall out of the scan before the turn count justified aging it out.
//
// Now the bound is MAX_DURABLE_SCAN_TURNS=100 user turns. Build a
// chat with only 30 user turns but many messages per turn — the
// early constraint must still survive.
const messages: ChatMessage[] = [];
messages.push(message("early-important", "user", "不要提交 EARLY_CONSTRAINT_MARKER"));
messages.push(message("early-ack", "assistant", "收到"));
// 35 additional turns, each with 6 messages (bloats the total
// message count to >200 without exceeding 100 user turns).
for (let turn = 1; turn < 36; turn += 1) {
messages.push(message(`u${turn}`, "user", `turn ${turn} request`));
messages.push(message(`a${turn}-plan`, "assistant", "let me check", {
toolCalls: [
{ id: `c${turn}a`, name: "terminal_exec", arguments: { cmd: "echo a" } },
{ id: `c${turn}b`, name: "terminal_exec", arguments: { cmd: "echo b" } },
{ id: `c${turn}c`, name: "terminal_exec", arguments: { cmd: "echo c" } },
],
}));
messages.push(message(`t${turn}a`, "tool", "", {
toolResults: [{ toolCallId: `c${turn}a`, content: `result a of turn ${turn}`, isError: false }],
}));
messages.push(message(`t${turn}b`, "tool", "", {
toolResults: [{ toolCallId: `c${turn}b`, content: `result b of turn ${turn}`, isError: false }],
}));
messages.push(message(`t${turn}c`, "tool", "", {
toolResults: [{ toolCallId: `c${turn}c`, content: `result c of turn ${turn}`, isError: false }],
}));
messages.push(message(`a${turn}-done`, "assistant", `turn ${turn} done`));
}
// Sanity: the message count is over 200 even though user turns are 30.
assert.ok(messages.length > 200, `setup: expected > 200 messages, got ${messages.length}`);
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Under the old raw-count bound, the early constraint would age out;
// under the turn-based bound it survives.
assert.match(flat, /EARLY_CONSTRAINT_MARKER/);
});
test("buildAcpHistoryMessages preserves short non-trivial assistant decisions that miss the keyword heuristic", () => {
// Regression: isSubstantiveAssistantMessage previously required length
// >= 40 OR a small English keyword match OR a numbered list. Short
// load-bearing replies like "Use ssh2" / "rebase instead" / "中文输出"
// satisfied none of those and were silently dropped. After a stale-
// session recovery, "do what you suggested earlier" would then replay
// only the user's question without the assistant's actual decision.
const messages: ChatMessage[] = [
message("u1", "user", "which client should I use"),
message("a1", "assistant", "Use ssh2"),
message("u2", "user", "output language?"),
message("a2", "assistant", "中文输出"),
message("u3", "user", "merge or rebase?"),
message("a3", "assistant", "rebase instead"),
];
// Pad so u1..a3 fall outside the recent raw window (last 6 items) and
// must flow through the durable-assistant compact pass.
for (let index = 4; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
assert.match(flat, /Use ssh2/);
assert.match(flat, /中文输出/);
assert.match(flat, /rebase instead/);
});
test("buildAcpHistoryMessages still drops trivial assistant filler like 'ack' / 'ok' / '明白'", () => {
// Sanity: removing the length/keyword gate must not let assistant
// filler leak into the compact durable-assistant section.
const messages: ChatMessage[] = [
message("u1", "user", "prompt 1"),
message("a1", "assistant", "ack"),
message("u2", "user", "prompt 2"),
message("a2", "assistant", "明白"),
message("u3", "user", "prompt 3"),
message("a3", "assistant", "got it"),
];
for (let index = 4; index <= 13; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `more filler ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
assert.doesNotMatch(flat, /Assistant context: ack\b/);
assert.doesNotMatch(flat, /Assistant context: got it\b/);
assert.doesNotMatch(flat, /Assistant context: 明白/);
});
test("buildAcpHistoryMessages inlines tool_call context on OLDER summarized tool results", () => {
// Regression: the raw-window fix covered the last 6 items, but once
// a tool result fell into the compact section (summarizeToolMessage
// path) the `[from <name>(<args>)]` provenance label was absent.
// With multiple older tool outputs, all surfacing as identical
// `Tool result (callN): ...`, follow-ups like "use the resolv.conf
// output" have no way to map to the right call.
const messages: ChatMessage[] = [
// Two distinct tool interactions, both pushed well outside the
// recent raw window by later turns.
message("u1", "user", "show hosts"),
message("a1", "assistant", "", {
toolCalls: [{ id: "call-hosts", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
}),
message("tool1", "tool", "", {
toolResults: [{ toolCallId: "call-hosts", content: "127.0.0.1 localhost", isError: false }],
}),
message("u2", "user", "show resolv.conf"),
message("a2", "assistant", "", {
toolCalls: [{ id: "call-resolv", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
}),
message("tool2", "tool", "", {
toolResults: [{ toolCallId: "call-resolv", content: "nameserver 8.8.8.8", isError: false }],
}),
// Important user text so summarizeMessage picks these up via the
// important-text branch; tool results themselves are always
// summarized regardless of IMPORTANT_PATTERNS.
message("u3", "user", "fallback plan"),
];
// Filler to push the early tool results out of the 6-item raw window
// and into the compact summary section (scanned = last 20).
for (let index = 4; index <= 10; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Both older tool results must now carry provenance labels so a
// follow-up can disambiguate them.
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/hosts/);
assert.match(flat, /Tool result \[from terminal_exec.*?cat \/etc\/resolv\.conf/);
});
test("buildAcpHistoryMessages does not duplicate recent raw turns into the compact summary section", () => {
// Regression: the scanned loop (last 20) overlaps with recentRaw (last 6).
// Without skipping raw-window items, the same last-6 turns would be
// summarized in the compact section AND appended verbatim in the raw
// section — doubling the budget cost of important user turns / large
// tool output and crowding out older durable context.
//
// Setup: enough filler upfront that u3 ends up OUTSIDE the raw window
// (so it can be asserted absent from raw), then a distinctive "raw
// only" marker that should appear only in the last-6 raw slice.
const messages: ChatMessage[] = [];
for (let index = 1; index <= 6; index += 1) {
messages.push(
message(`uf${index}`, "user", `filler user ${index}`),
message(`af${index}`, "assistant", `filler assistant ${index}`),
);
}
// These are the last 4 user/assistant messages — guaranteed to be in
// the last-6 raw slice. The IMPORTANT markers below would ordinarily
// also get summarized into the compact section, duplicating the cost.
messages.push(
message("u-rec1", "user", "commit now IMPORTANT_RAW_MARKER please"),
message("a-rec1", "assistant", "", {
toolCalls: [{ id: "c1", name: "git", arguments: { op: "commit" } }],
}),
message("tool-rec", "tool", "", {
toolResults: [{ toolCallId: "c1", content: "committed abc123 RAW_TOOL_MARKER", isError: false }],
}),
message("u-rec2", "user", "now push"),
);
const result = buildAcpHistoryMessages(messages);
const compact = result.find((m) => m.content.includes("[Compact prior Netcatty UI context]"));
assert.ok(compact, "expected a compact context message");
// Both markers belong to messages inside the raw window — they must
// not be summarized into compact (which would double-bill them).
assert.doesNotMatch(compact.content, /IMPORTANT_RAW_MARKER/);
assert.doesNotMatch(compact.content, /RAW_TOOL_MARKER/);
// Raw section still carries them verbatim.
const raw = result.filter((m) => !m.content.includes("[Compact prior Netcatty UI context]"));
const rawFlat = raw.map((m) => m.content).join("\n");
assert.match(rawFlat, /IMPORTANT_RAW_MARKER/);
assert.match(rawFlat, /RAW_TOOL_MARKER/);
});
test("buildAcpHistoryMessages resolves tool_call provenance correctly when tool ids are reused across turns", () => {
// Regression: keying toolCallIndex by raw toolCall.id alone let a later
// assistant tool_call with the same id overwrite the older one. An
// older tool_result in the replay history would then be annotated
// with the wrong command (e.g. a /etc/hosts result labeled as
// /etc/resolv.conf). Now each tool_result is indexed by its own
// messageId + toolCallId and resolved to the most recent preceding
// call with that id.
const messages: ChatMessage[] = [
message("u1", "user", "show hosts"),
message("a1", "assistant", "", {
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/hosts" } }],
}),
message("tool-hosts", "tool", "", {
toolResults: [{ toolCallId: "call1", content: "127.0.0.1 localhost HOSTS_BYTES", isError: false }],
}),
// A later assistant turn reuses the id "call1" for a different call.
message("u2", "user", "show resolv"),
message("a2", "assistant", "", {
toolCalls: [{ id: "call1", name: "terminal_exec", arguments: { command: "cat /etc/resolv.conf" } }],
}),
message("tool-resolv", "tool", "", {
toolResults: [{ toolCallId: "call1", content: "nameserver 8.8.8.8 RESOLV_BYTES", isError: false }],
}),
message("u3", "user", "ok"),
];
// Pad so the first interaction lands in the compact summary pass.
for (let index = 4; index <= 10; index += 1) {
messages.push(
message(`u${index}`, "user", `filler user message ${index}`),
message(`a${index}`, "assistant", `Ack ${index}`),
);
}
const result = buildAcpHistoryMessages(messages);
const flat = result.map((m) => m.content).join("\n---\n");
// Each tool_result must be annotated with ITS OWN preceding call's
// args — not whichever assistant tool_call happened to win the
// last-write on the shared id.
//
// Extract the two Tool-result lines and match each to its expected
// args. Use non-greedy .*? — the args JSON can contain parentheses.
const hostsMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/hosts[^\]]*?\][^\n]*HOSTS_BYTES/);
const resolvMatch = flat.match(/Tool result \[from [^\]]*?cat \/etc\/resolv\.conf[^\]]*?\][^\n]*RESOLV_BYTES/);
assert.ok(hostsMatch, "hosts result must still be labeled with cat /etc/hosts despite later id reuse");
assert.ok(resolvMatch, "resolv result must be labeled with cat /etc/resolv.conf");
});
test("buildAcpHistoryMessages preserves assistant-only compact context", () => {
const messages: ChatMessage[] = [
message("u1", "user", "ok"),
message(
"a1",
"assistant",
"Plan: 1. Move parser setup into a dedicated hook. 2. Keep storage schema unchanged. 3. Add a regression test.",
),
];
for (let index = 2; index <= 7; index += 1) {
messages.push(
message(`u${index}`, "user", index % 2 === 0 ? "ok" : "continue"),
message(`a${index}`, "assistant", "ack"),
);
}
const result = buildAcpHistoryMessages(messages);
assert.equal(result[0].role, "user");
assert.match(result[0].content, /Move parser setup into a dedicated hook\./);
});

438
components/ai/acpHistory.ts Normal file
View File

@@ -0,0 +1,438 @@
import type { ChatMessage } from "../../infrastructure/ai/types.ts";
type AcpHistoryMessage = { role: "user" | "assistant"; content: string };
type RawHistoryMessage = AcpHistoryMessage & { sourceId: string };
type DurableUserLine = {
line: string;
messageIndex: number;
priority: number;
};
const MAX_RECENT_RAW_MESSAGES = 6;
const MAX_MESSAGES_TO_SCAN = 20;
// Bound the scan by user turns, not raw message count: a tool-heavy ACP
// chat can produce 5+ messages per logical turn (user + assistant +
// several tool_results + follow-up assistant), so a plain
// message-count cap ages out early constraints much sooner than intended.
const MAX_DURABLE_SCAN_TURNS = 100;
const MAX_COMPACT_CONTEXT_CHARS = 3000;
const MAX_RAW_MESSAGE_CHARS = 2000;
const MAX_TOOL_SUMMARY_CHARS = 500;
const MAX_DURABLE_USER_CONTEXT_CHARS = 1400;
const MAX_DURABLE_ASSISTANT_CONTEXT_CHARS = 900;
const MAX_RECENT_SUMMARY_CONTEXT_CHARS = 1200;
const MAX_DURABLE_USER_MESSAGE_CHARS = 280;
const MAX_DURABLE_ASSISTANT_MESSAGE_CHARS = 360;
const MAX_TOOL_CALL_LABEL_CHARS = 200;
type ToolCallInfo = { name: string; arguments: unknown };
const IMPORTANT_PATTERNS = [
/不要|别|不能|不允许|必须|希望|只|最小|先|暂时|fallback|pwsh|powershell|cmd\.exe|windows|mcp|skills|cli|commit|\bpr\b|打包|内存|历史|压缩|慢/i,
/error|failed|failure|exit code|exception|cannot|unable|timeout|crash|fallback|commit|pull request|PR #\d+/i,
];
const DURABLE_CONSTRAINT_PATTERNS = [
/\bdo not\b|\bdon't\b|\bkeep\b|\bpreserve\b|\bavoid\b|\bonly\b|\bunchanged\b|\blocal only\b|\bwithout\b|\bleave\b/i,
/不要|别|保留|保持|维持|不改|别改|不要改|仅限本地/i,
];
const TRIVIAL_USER_MESSAGE_PATTERNS = [
/^(ok|okay|yes|no|thanks|thank you|continue|继续|好的|收到|行|嗯|好|继续处理|继续吧|开始吧)[.!? ]*$/i,
];
const TRIVIAL_ASSISTANT_MESSAGE_PATTERNS = [
/^(ok|okay|understood|got it|working|proceeding|ready|ack(?: \d+)?|收到|明白|继续处理|准备实现|开始处理|处理中)[.!? ]*$/i,
];
function truncateText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, Math.max(0, maxChars - 24)).trimEnd()}\n[truncated]`;
}
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function isImportantText(value: string): boolean {
return IMPORTANT_PATTERNS.some((pattern) => pattern.test(value));
}
function isDurableConstraintText(value: string): boolean {
return DURABLE_CONSTRAINT_PATTERNS.some((pattern) => pattern.test(value));
}
function isTrivialUserMessage(value: string): boolean {
const normalized = normalizeWhitespace(value);
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return false;
// Don't blanket-drop short messages — short user turns are often
// load-bearing constraints ("Use ssh2", "中文输出", "no logs", "more
// verbose") that the IMPORTANT/DURABLE regexes can't realistically
// enumerate. The trivial-phrase regex already catches actual filler
// ("ok", "yes", "thanks", "继续").
return TRIVIAL_USER_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
}
function getDurableUserPriority(value: string): number {
const normalized = normalizeWhitespace(value);
if (isImportantText(normalized) || isDurableConstraintText(normalized)) return 2;
return 1;
}
function isSubstantiveAssistantMessage(value: string): boolean {
const normalized = normalizeWhitespace(value);
if (!normalized) return false;
// Mirror the user-side loosening: don't blanket-drop short assistant
// messages just because they're under 40 chars or don't match the small
// English keyword list. Short but load-bearing decisions ("Use ssh2",
// "rebase instead", "中文输出") aren't realistically enumerable and
// they're the exact things a later "do what you suggested" references.
// TRIVIAL_ASSISTANT_MESSAGE_PATTERNS still catches the actual filler
// ("ok", "ack", "got it", "明白").
return !TRIVIAL_ASSISTANT_MESSAGE_PATTERNS.some((pattern) => pattern.test(normalized));
}
function getDurableAssistantPriority(value: string): number {
const normalized = normalizeWhitespace(value);
if (isImportantText(normalized)) return 2;
return 1;
}
function appendUniqueLine(
target: string[],
seen: Set<string>,
line: string,
maxSectionChars: number,
sectionCharsRef: { value: number },
): void {
const normalized = normalizeWhitespace(line);
if (!normalized || seen.has(normalized)) return;
const nextChars = sectionCharsRef.value + normalized.length;
if (nextChars > maxSectionChars) return;
seen.add(normalized);
target.push(normalized);
sectionCharsRef.value = nextChars;
}
function summarizeToolMessage(
message: ChatMessage,
toolCallIndex: Map<string, ToolCallInfo>,
): string[] {
if (!message.toolResults?.length) return [];
return message.toolResults.map((result) => {
const prefix = result.isError ? "Tool error" : "Tool result";
const content = normalizeWhitespace(result.content || "");
// Same provenance problem as the raw-window path: once a tool result
// lands in the compact section (older than the 6-item raw window),
// its paired assistant tool_call is almost always gone. Without the
// call label, multiple older results collapse into indistinguishable
// "Tool result (callN): ..." lines and follow-ups like "use the
// resolv.conf output" can't be resolved. Inline the name+args here
// the same way toRawHistoryMessage does.
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
const callLabel = callInfo
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
: "";
return `${prefix}${callLabel} (${result.toolCallId}): ${truncateText(content, MAX_TOOL_SUMMARY_CHARS)}`;
});
}
function summarizeMessage(
message: ChatMessage,
toolCallIndex: Map<string, ToolCallInfo>,
): string[] {
if (message.role === "system") return [];
if (message.role === "tool") return summarizeToolMessage(message, toolCallIndex);
const lines: string[] = [];
if (message.content && isImportantText(message.content)) {
const label = message.role === "user" ? "User" : "Assistant";
lines.push(`${label}: ${truncateText(normalizeWhitespace(message.content), MAX_TOOL_SUMMARY_CHARS)}`);
}
if (message.role === "assistant" && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
const args = JSON.stringify(toolCall.arguments ?? {});
const summary = `Tool call: ${toolCall.name}(${truncateText(args, 220)})`;
if (isImportantText(summary)) lines.push(summary);
}
}
return lines;
}
function summarizeDurableUserMessage(message: ChatMessage): string | null {
if (message.role !== "user" || !message.content) return null;
if (isTrivialUserMessage(message.content)) return null;
return `User request: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_USER_MESSAGE_CHARS)}`;
}
function summarizeDurableAssistantMessage(message: ChatMessage): string | null {
if (message.role !== "assistant" || !message.content) return null;
if (!isSubstantiveAssistantMessage(message.content)) return null;
return `Assistant context: ${truncateText(normalizeWhitespace(message.content), MAX_DURABLE_ASSISTANT_MESSAGE_CHARS)}`;
}
/**
* Build a per-tool-result provenance index. Keys are
* `${toolResultMessageId}:${toolCallId}` rather than the bare toolCall.id
* so that provider-reused ids (e.g. "call1" across unrelated turns) don't
* cause later calls to overwrite older ones in the lookup — each
* tool_result resolves to the most recent assistant tool_call that
* preceded it with matching id, which preserves historical correctness
* when rebuilding older compact summaries.
*/
function buildToolCallIndex(messages: ChatMessage[]): Map<string, ToolCallInfo> {
const provenance = new Map<string, ToolCallInfo>();
// Rolling map of the latest tool_call seen (by id) up to the current
// point in the message stream.
const latestByCallId = new Map<string, ToolCallInfo>();
for (const message of messages) {
if (message.role === "assistant" && message.toolCalls?.length) {
for (const toolCall of message.toolCalls) {
if (!toolCall.id) continue;
latestByCallId.set(toolCall.id, { name: toolCall.name, arguments: toolCall.arguments });
}
continue;
}
if (message.role === "tool" && message.toolResults?.length) {
for (const result of message.toolResults) {
const info = latestByCallId.get(result.toolCallId);
if (info) {
provenance.set(`${message.id}:${result.toolCallId}`, info);
}
}
}
}
return provenance;
}
function lookupToolCallInfo(
index: Map<string, ToolCallInfo>,
toolMessageId: string,
toolCallId: string,
): ToolCallInfo | undefined {
return index.get(`${toolMessageId}:${toolCallId}`);
}
function toRawHistoryMessage(
message: ChatMessage,
toolCallIndex: Map<string, ToolCallInfo>,
): RawHistoryMessage[] {
if (message.role === "user") {
return message.content
? [{ sourceId: message.id, role: "user", content: truncateText(message.content, MAX_RAW_MESSAGE_CHARS) }]
: [];
}
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 ?? {})})`));
}
return parts.length
? [{ sourceId: message.id, role: "assistant", content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS) }]
: [];
}
if (message.role === "tool" && message.toolResults?.length) {
// Keep tool output in the recent raw window (up to MAX_RAW_MESSAGE_CHARS
// per message, ~2000). Without this, follow-up turns after stale-session
// recovery would only see the 500-char compact summary in
// summarizeToolMessage, losing the actual bytes the user might reference
// ("use that output", "what did cat show?"). ACP only supports user/
// assistant roles, so we flatten to "assistant" — the tool results were
// produced during the assistant's turn.
//
// Inline the originating tool_call's name+args. Tool calls and their
// results live in separate messages; if the last six raw items start
// in the middle of a tool interaction, the preceding assistant tool
// call can be outside the window. Without the call label the result
// is opaque bytes and "use that output" becomes ambiguous.
const parts = message.toolResults.map((result) => {
const prefix = result.isError ? "Tool error" : "Tool result";
const callInfo = lookupToolCallInfo(toolCallIndex, message.id, result.toolCallId);
const callLabel = callInfo
? ` [from ${callInfo.name}(${truncateText(JSON.stringify(callInfo.arguments ?? {}), MAX_TOOL_CALL_LABEL_CHARS)})]`
: "";
return `${prefix}${callLabel} (${result.toolCallId}): ${result.content || ""}`;
});
return [{
sourceId: message.id,
role: "assistant",
content: truncateText(parts.join("\n\n"), MAX_RAW_MESSAGE_CHARS),
}];
}
return [];
}
function buildCompactContext(
messages: ChatMessage[],
durableScanStart: number,
recentRawSourceIds: Set<string>,
toolCallIndex: Map<string, ToolCallInfo>,
): AcpHistoryMessage[] {
const scanned = messages.slice(-MAX_MESSAGES_TO_SCAN);
const summaryLines: string[] = [];
const durableUserCandidates: DurableUserLine[] = [];
const selectedDurableUserLines: DurableUserLine[] = [];
const durableAssistantCandidates: DurableUserLine[] = [];
const selectedDurableAssistantLines: DurableUserLine[] = [];
const seen = new Set<string>();
const durableChars = { value: 0 };
const durableAssistantChars = { value: 0 };
const summaryChars = { value: 0 };
for (let messageIndex = durableScanStart; messageIndex < messages.length; messageIndex += 1) {
const message = messages[messageIndex];
if (recentRawSourceIds.has(message.id)) continue;
const durableUserLine = summarizeDurableUserMessage(message);
if (durableUserLine) {
durableUserCandidates.push({
line: durableUserLine,
messageIndex,
priority: getDurableUserPriority(message.content || ""),
});
}
const durableAssistantLine = summarizeDurableAssistantMessage(message);
if (durableAssistantLine) {
durableAssistantCandidates.push({
line: durableAssistantLine,
messageIndex,
priority: getDurableAssistantPriority(message.content || ""),
});
}
}
durableUserCandidates
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
.forEach((candidate) => {
const normalized = normalizeWhitespace(candidate.line);
if (!normalized || seen.has(normalized)) return;
const nextChars = durableChars.value + normalized.length;
if (nextChars > MAX_DURABLE_USER_CONTEXT_CHARS) return;
seen.add(normalized);
selectedDurableUserLines.push(candidate);
durableChars.value = nextChars;
});
durableAssistantCandidates
.sort((left, right) => right.priority - left.priority || right.messageIndex - left.messageIndex)
.forEach((candidate) => {
const normalized = normalizeWhitespace(candidate.line);
if (!normalized || seen.has(normalized)) return;
const nextChars = durableAssistantChars.value + normalized.length;
if (nextChars > MAX_DURABLE_ASSISTANT_CONTEXT_CHARS) return;
seen.add(normalized);
selectedDurableAssistantLines.push(candidate);
durableAssistantChars.value = nextChars;
});
const durableUserLines = selectedDurableUserLines
.sort((left, right) => left.messageIndex - right.messageIndex)
.map((candidate) => candidate.line);
const durableAssistantLines = selectedDurableAssistantLines
.sort((left, right) => left.messageIndex - right.messageIndex)
.map((candidate) => candidate.line);
for (const line of [...durableUserLines, ...durableAssistantLines]) {
seen.add(normalizeWhitespace(line));
}
// Skip messages that are already appended verbatim in the raw window —
// otherwise the same last-6 turns get summarized here AND re-sent as
// raw, doubling the budget cost of important user turns / large tool
// output and crowding out older durable context the replay is meant
// to preserve. Matches the recentRawSourceIds skip in the durable pass.
for (const message of scanned) {
if (recentRawSourceIds.has(message.id)) continue;
for (const line of summarizeMessage(message, toolCallIndex)) {
appendUniqueLine(summaryLines, seen, line, MAX_RECENT_SUMMARY_CONTEXT_CHARS, summaryChars);
}
}
if (!durableUserLines.length && !durableAssistantLines.length && !summaryLines.length) return [];
const contentLines = [
"[Compact prior Netcatty UI context]",
"The external ACP agent may already have its own persisted session context. Use this compact Netcatty UI context only as fallback/background, and prefer the current user request when there is any conflict.",
];
if (durableUserLines.length) {
contentLines.push("Earlier user requests that may still apply:");
contentLines.push(...durableUserLines.map((line) => `- ${line}`));
}
if (durableAssistantLines.length) {
contentLines.push("Earlier assistant context that may still matter:");
contentLines.push(...durableAssistantLines.map((line) => `- ${line}`));
}
if (summaryLines.length) {
contentLines.push("Recent noteworthy context:");
contentLines.push(...summaryLines.map((line) => `- ${line}`));
}
return [{
role: "user",
content: truncateText(
contentLines.join("\n"),
MAX_COMPACT_CONTEXT_CHARS,
),
}];
}
/**
* Find the index of the first message to include in the scan window,
* bounded by MAX_DURABLE_SCAN_TURNS user turns (not raw message count).
* Walking backwards stops at the target turn count, so the cost is
* bounded even when the transcript is huge.
*/
function computeDurableScanStart(messages: ChatMessage[]): number {
let userTurns = 0;
for (let i = messages.length - 1; i >= 0; i -= 1) {
if (messages[i].role === "user") {
userTurns += 1;
if (userTurns >= MAX_DURABLE_SCAN_TURNS) return i;
}
}
return 0;
}
export function buildAcpHistoryMessages(messages: ChatMessage[]): AcpHistoryMessage[] {
// Compute the scan start once, then do all subsequent work over the
// already-sliced tail. This avoids O(N) walks over the whole transcript
// on every send — previously buildToolCallIndex + the flatMap-to-take-
// last-6 raw history both traversed every message in the chat.
const durableScanStart = computeDurableScanStart(messages);
const scannedTail = messages.slice(durableScanStart);
// The tool-call provenance index only needs entries for tool_results
// that might appear in our output. Building from the scanned tail is
// correct for any tool_result whose paired assistant tool_call is
// also within the window, which covers >99% of realistic patterns
// (tool_calls and tool_results are always adjacent or near-adjacent).
// If an ancient tool_call's result stays within the window while the
// call itself is outside, that single result loses its [from X(Y)]
// label — an acceptable trade for eliminating the per-send O(N) walk.
const toolCallIndex = buildToolCallIndex(scannedTail);
const rawHistory = scannedTail
.flatMap((message) => toRawHistoryMessage(message, toolCallIndex))
.slice(-MAX_RECENT_RAW_MESSAGES);
const compactContext = buildCompactContext(
messages,
durableScanStart,
new Set(rawHistory.map((message) => message.sourceId)),
toolCallIndex,
);
const recentRaw = rawHistory.map(({ role, content }) => ({ role, content }));
return [...compactContext, ...recentRaw];
}
export function buildAcpHistoryMessagesForBridge(
messages: ChatMessage[],
_existingSessionId?: string | null,
): AcpHistoryMessage[] | undefined {
// The main process bridge only consumes this payload during stale-session
// fallback replay, so keep it available even when a session id exists.
const historyMessages = buildAcpHistoryMessages(messages);
return historyMessages.length ? historyMessages : undefined;
}

View File

@@ -6,11 +6,11 @@ import type {
AISession,
} from "../../infrastructure/ai/types.ts";
import {
applyDraftEntrySelection,
applyHistorySessionSelection,
normalizePanelView,
resolveDisplayedPanelView,
resolveDisplayedSession,
shouldRetargetSessionForScope,
} from "./aiPanelViewState.ts";
function createSession(id: string): AISession {
@@ -61,7 +61,7 @@ test("missing explicit panel view resumes the most recent matching history when
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions),
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
{ mode: "session", sessionId: "session-2" },
);
});
@@ -70,7 +70,7 @@ test("missing explicit panel view restores the persisted active session instead
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, "session-1"),
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "workspace"),
{ mode: "session", sessionId: "session-1" },
);
});
@@ -79,7 +79,7 @@ test("persisted session id that no longer exists in history falls back to newest
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session"),
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session", "workspace"),
{ mode: "session", sessionId: "session-2" },
);
});
@@ -88,11 +88,20 @@ test("null persisted session id falls back to newest history entry", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, null),
resolveDisplayedPanelView(undefined, false, sessions, null, "workspace"),
{ mode: "session", sessionId: "session-2" },
);
});
test("terminal scope without explicit view always starts from draft even when history exists", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
assert.deepEqual(
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "terminal"),
{ mode: "draft" },
);
});
test("missing explicit panel view prefers the draft when unsent input exists", () => {
const sessions = [createSession("session-2"), createSession("session-1")];
@@ -109,50 +118,6 @@ test("draft state is used when there is no implicit history to resume", () => {
);
});
test("restorable terminal history should retarget to the current scope", () => {
const session: AISession = {
...createSession("session-2"),
scope: {
type: "terminal",
targetId: "old-terminal",
hostIds: ["host-1"],
},
};
assert.equal(
shouldRetargetSessionForScope(
session,
"terminal",
"new-terminal",
["host-1"],
new Set<string>(),
),
true,
);
});
test("session owned by another active terminal should not retarget", () => {
const session: AISession = {
...createSession("session-2"),
scope: {
type: "terminal",
targetId: "other-active-terminal",
hostIds: ["host-1"],
},
};
assert.equal(
shouldRetargetSessionForScope(
session,
"terminal",
"new-terminal",
["host-1"],
new Set<string>(["other-active-terminal"]),
),
false,
);
});
test("history selection switches to the chosen session without touching draft state", () => {
const calls: string[] = [];
@@ -174,3 +139,39 @@ test("history selection switches to the chosen session without touching draft st
"close-history",
]);
});
test("draft entry ensures a draft exists before switching the panel to draft mode", () => {
const calls: string[] = [];
applyDraftEntrySelection({
ensureDraft: () => {
calls.push("ensure-draft");
},
showDraftView: () => {
calls.push("show-draft");
},
});
assert.deepEqual(calls, [
"ensure-draft",
"show-draft",
]);
});
test("draft entry can preserve the current session view while ensuring draft state", () => {
const calls: string[] = [];
applyDraftEntrySelection({
ensureDraft: () => {
calls.push("ensure-draft");
},
showDraftView: () => {
calls.push("show-draft");
},
preserveSessionView: true,
});
assert.deepEqual(calls, [
"ensure-draft",
]);
});

View File

@@ -11,11 +11,18 @@ interface HistorySessionSelectionActions {
closeHistory?: () => void;
}
interface DraftEntrySelectionActions {
ensureDraft: () => void;
showDraftView: () => void;
preserveSessionView?: boolean;
}
export function resolveDisplayedPanelView(
panelView: AIPanelView | undefined,
hasDraft: boolean,
sessions: AISession[],
persistedSessionId?: string | null,
scopeType: "terminal" | "workspace" = "workspace",
): AIPanelView {
if (panelView) {
return normalizePanelView(panelView, sessions);
@@ -25,6 +32,12 @@ export function resolveDisplayedPanelView(
return DEFAULT_PANEL_VIEW;
}
// New terminal sessions should always start from a blank draft. History is
// still available in the drawer, but never auto-resumed into a fresh SSH tab.
if (scopeType === "terminal") {
return DEFAULT_PANEL_VIEW;
}
// Honour the persisted active-session selection (survives cold mount)
// before falling back to the newest history entry.
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
@@ -62,28 +75,6 @@ export function resolveDisplayedSession(
return sessions.find((session) => session.id === panelView.sessionId) ?? null;
}
export function shouldRetargetSessionForScope(
session: AISession | null,
scopeType: "terminal" | "workspace",
scopeTargetId?: string,
scopeHostIds?: string[],
activeTerminalTargetIds?: Set<string>,
): boolean {
if (!session || scopeType !== "terminal" || !scopeTargetId || !scopeHostIds?.length) {
return false;
}
if (session.scope.type !== scopeType || session.scope.targetId === scopeTargetId) {
return false;
}
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
return false;
}
return session.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
}
export function applyHistorySessionSelection(
sessionId: string,
actions: HistorySessionSelectionActions,
@@ -92,3 +83,12 @@ export function applyHistorySessionSelection(
actions.setActiveSessionId(sessionId);
actions.closeHistory?.();
}
export function applyDraftEntrySelection(
actions: DraftEntrySelectionActions,
): void {
actions.ensureDraft();
if (!actions.preserveSessionView) {
actions.showDraftView();
}
}

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
endDraftSend,
tryBeginDraftSend,
} from "./draftSendGate.ts";
test("draft send gate allows only one in-flight draft send at a time", () => {
const gate = { current: false };
assert.equal(tryBeginDraftSend(gate), true);
assert.equal(tryBeginDraftSend(gate), false);
endDraftSend(gate);
assert.equal(tryBeginDraftSend(gate), true);
});

View File

@@ -0,0 +1,12 @@
export function tryBeginDraftSend(gate: { current: boolean }): boolean {
if (gate.current) {
return false;
}
gate.current = true;
return true;
}
export function endDraftSend(gate: { current: boolean }): void {
gate.current = false;
}

View File

@@ -355,14 +355,13 @@ export function useAIChatStreaming({
err: unknown,
) => {
if (abortSignal.aborted) return;
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);
console.error('[AIChatSidePanel] Stream error (full):', err);
// Pass the raw error to classifyError so it can inspect structured
// fields (statusCode, responseBody) from APICallError and friends;
// string-coercing here would strip the metadata we need to detect
// 413 / HTML-error-page / parse-failure scenarios.
const errorInfo = classifyError(err);
updateLastMessage(sessionId, msg => ({
...msg,
statusText: '',
@@ -560,11 +559,10 @@ export function useAIChatStreaming({
id: generateId(),
role: 'assistant',
content: '',
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'; } })(),
),
// Pass the raw error so classifyError can detect 413 / HTML /
// schema-parse scenarios via structured fields (statusCode,
// responseBody) instead of lossy string conversion.
errorInfo: classifyError(typedChunk.error),
timestamp: Date.now(),
});
break;

View File

@@ -0,0 +1,15 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
SESSION_HISTORY_ROW_CLASSNAMES,
} from "./sessionHistoryLayout.ts";
test("session history row keeps metadata pinned to the end while title truncates", () => {
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.row, /\bgrid\b/);
assert.ok(SESSION_HISTORY_ROW_CLASSNAMES.row.includes('grid-cols-[minmax(0,1fr)_auto]'));
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\btruncate\b/);
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\bmin-w-0\b/);
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bjustify-self-end\b/);
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bshrink-0\b/);
});

View File

@@ -0,0 +1,7 @@
export const SESSION_HISTORY_ROW_CLASSNAMES = {
row: 'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
title: 'text-[13px] truncate min-w-0',
meta: 'flex items-center gap-2 justify-self-end shrink-0',
time: 'text-[12px] text-muted-foreground/50 whitespace-nowrap',
deleteButton: 'opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer shrink-0',
} as const;

View File

@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { AISession } from "../../infrastructure/ai/types.ts";
import { getSessionScopeMatchRank } from "./sessionScopeMatch.ts";
function createSession(id: string, targetId: string, hostIds: string[]): AISession {
return {
id,
title: id,
messages: [],
createdAt: 1,
updatedAt: 1,
agentId: "catty",
scope: {
type: "terminal",
targetId,
hostIds,
},
};
}
test("host-matched terminal session is excluded when another active terminal already displays it", () => {
const session = createSession("session-1", "terminal-other", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"terminal",
"terminal-current",
["host-a"],
new Set(["session-1"]),
),
0,
);
});
test("host-matched terminal session remains resumable when no terminal is displaying it", () => {
const session = createSession("session-1", "terminal-closed", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"terminal",
"terminal-current",
["host-a"],
new Set(["session-other"]),
),
1,
);
});
test("ownership is tracked by session id, not scope.targetId", () => {
// Session was created in terminal-A but a different terminal (B) is now
// displaying it after the user resumed it from history. Opening a third
// terminal (C) should not see this session as owned, because the new
// ownership check is keyed on session id, not the stale targetId.
const session = createSession("session-1", "terminal-A", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"terminal",
"terminal-C",
["host-a"],
// terminal-B is displaying session-1; pass session-1 as an
// active-id so C sees it as in-use
new Set(["session-1"]),
),
0,
);
});
test("session targeting the current scope is an exact match (rank 2)", () => {
const session = createSession("session-1", "terminal-current", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"terminal",
"terminal-current",
["host-a"],
new Set(),
),
2,
);
});
test("scope type mismatch returns 0 regardless of target or hosts", () => {
const session = createSession("session-1", "terminal-current", ["host-a"]);
assert.equal(
getSessionScopeMatchRank(
session,
"workspace",
"terminal-current",
["host-a"],
),
0,
);
});

View File

@@ -0,0 +1,28 @@
import type { AISession } from "../../infrastructure/ai/types";
export function getSessionScopeMatchRank(
session: AISession,
scopeType: "terminal" | "workspace",
scopeTargetId?: string,
scopeHostIds?: string[],
/**
* Session ids currently displayed by other terminal scopes. Tracked by
* session id rather than `scope.targetId` so that a host-matched session
* resumed from a different terminal is still recognised as in-use and
* not offered (or cleaned) as if it were orphaned.
*/
activeTerminalSessionIds?: Set<string>,
): number {
if (session.scope.type !== scopeType) return 0;
if (session.scope.targetId === scopeTargetId) return 2;
if (scopeType !== "terminal" || !scopeHostIds?.length || !session.scope.hostIds?.length) {
return 0;
}
if (activeTerminalSessionIds?.has(session.id)) {
return 0;
}
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
}

View File

@@ -815,6 +815,20 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.clearWipesScrollback")}
description={t("settings.terminal.behavior.clearWipesScrollback.desc")}
>
<Toggle checked={terminalSettings.clearWipesScrollback ?? true} onChange={(v) => updateTerminalSetting("clearWipesScrollback", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.preserveSelectionOnInput")}
description={t("settings.terminal.behavior.preserveSelectionOnInput.desc")}
>
<Toggle checked={terminalSettings.preserveSelectionOnInput ?? false} onChange={(v) => updateTerminalSetting("preserveSelectionOnInput", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.osc52Clipboard")}
description={t("settings.terminal.behavior.osc52Clipboard.desc")}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import type { ShrinkFinding } from '../../domain/syncGuards';
import { Button } from '../ui/button';
import { useI18n } from '../../application/i18n/I18nProvider';
interface Props {
finding: Extract<ShrinkFinding, { suspicious: true }>;
onRestore: () => void;
onForcePush: () => void;
}
export const SyncBlockedBanner: React.FC<Props> = ({ finding, onRestore, onForcePush }) => {
const { t } = useI18n();
const entityLabel = t(`sync.entityType.${finding.entityType}`);
const percent = finding.baseCount > 0 ? Math.round((finding.lost / finding.baseCount) * 100) : 0;
const reasonText = finding.reason === 'bulk-shrink'
? t('sync.blocked.reason.bulkShrink', {
lost: finding.lost,
baseCount: finding.baseCount,
entityType: entityLabel,
percent,
})
: t('sync.blocked.reason.largeShrink', {
lost: finding.lost,
entityType: entityLabel,
});
return (
<div
role="alert"
className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-4"
>
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span>{t('sync.blocked.title')}</span>
</div>
<p className="text-sm">{reasonText}</p>
<p className="text-xs opacity-70">{t('sync.blocked.detail')}</p>
<div className="flex gap-2">
<Button variant="default" size="sm" onClick={onRestore}>
{t('sync.blocked.restoreButton')}
</Button>
<Button variant="outline" size="sm" onClick={onForcePush}>
{t('sync.blocked.forcePushButton')}
</Button>
</div>
</div>
);
};

View File

@@ -690,7 +690,9 @@ export function useTerminalAutocomplete(
}
}
// Tab: accept selected popup suggestion, or accept ghost text
// Tab: accept selected popup suggestion. Ghost text is accepted via → only —
// letting Tab pass through lets the shell's native completion (bash/zsh) run,
// which is otherwise shadowed by our single-Tab ghost accept.
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.suggestions.length > 0) {
e.preventDefault();
@@ -698,16 +700,10 @@ export function useTerminalAutocomplete(
if (selected) insertSuggestion(selected, false);
return false;
}
// Hide stale ghost text before Tab reaches the shell — the shell's
// completion will rewrite the line and the old ghost would mislead.
if (ghost?.isVisible()) {
e.preventDefault();
const ghostText = ghost.getGhostText();
if (ghostText) {
writeToTerminal(ghostText);
lastAcceptedCommandRef.current = ghost.getSuggestion();
ghost.hide();
clearState();
}
return false;
ghost.hide();
}
}

View File

@@ -114,6 +114,10 @@ export type CreateXTermRuntimeContext = {
onAutocompleteKeyEvent?: (e: KeyboardEvent) => boolean;
// Autocomplete input handler — called on every character input
onAutocompleteInput?: (data: string) => void;
// Set to true while we're programmatically restoring a selection so that
// copy-on-select listeners can suppress redundant clipboard writes.
isRestoringSelectionRef?: RefObject<boolean>;
};
const detectPlatform = (): XTermPlatform => {
@@ -419,6 +423,38 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return true;
}
// Preserve mouse selection across keystrokes when enabled. xterm.js
// unconditionally clears the selection on user input
// (SelectionService.ts: coreService.onUserInput → clearSelection).
// Capture the selection here, then re-apply it after xterm has
// processed the key + cleared. The microtask runs after both
// synchronous listeners, so by then either the selection is gone (and
// we restore) or it's still there (we no-op).
if (
ctx.terminalSettingsRef.current?.preserveSelectionOnInput &&
term.hasSelection()
) {
const sel = term.getSelectionPosition();
if (sel) {
const length =
(sel.end.y - sel.start.y) * term.cols + (sel.end.x - sel.start.x);
const savedStartX = sel.start.x;
const savedStartY = sel.start.y;
queueMicrotask(() => {
if (term.hasSelection()) return;
// Bail out if scrollback trim invalidated the row index.
if (savedStartY >= term.buffer.active.length) return;
const restoreFlag = ctx.isRestoringSelectionRef;
if (restoreFlag) restoreFlag.current = true;
try {
term.select(savedStartX, savedStartY, length);
} finally {
if (restoreFlag) restoreFlag.current = false;
}
});
}
}
// Autocomplete key handler (must be checked before other handlers)
if (ctx.onAutocompleteKeyEvent) {
const consumed = ctx.onAutocompleteKeyEvent(e);
@@ -664,7 +700,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (!isEraseScrollbackSequence(params)) {
return false;
}
return true;
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
// Honor it unless the user opts into the legacy "preserve history" behavior.
const wipeAllowed = ctx.terminalSettingsRef.current?.clearWipesScrollback ?? true;
return !wipeAllowed;
});
// Register OSC 7 handler using xterm.js parser

View File

@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldPreserveTerminalFocusOnMouseDown } from "./toolbarFocus.ts";
test("preserves terminal focus for non-editable overlay clicks", () => {
const buttonLikeTarget = {
tagName: "button",
isContentEditable: false,
closest() {
return null;
},
getAttribute() {
return null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(buttonLikeTarget as unknown as EventTarget), true);
});
test("allows native focus for direct editable targets", () => {
const inputTarget = {
tagName: "input",
isContentEditable: false,
closest() {
return null;
},
getAttribute() {
return null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(inputTarget as unknown as EventTarget), false);
});
test("allows native focus for descendants inside editable controls", () => {
const nestedTarget = {
tagName: "span",
isContentEditable: false,
closest(selector: string) {
return selector.includes("input") ? { tagName: "INPUT" } : null;
},
getAttribute() {
return null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(nestedTarget as unknown as EventTarget), false);
});
test("allows native focus for contenteditable regions", () => {
const editableTarget = {
tagName: "div",
isContentEditable: false,
closest() {
return null;
},
getAttribute(name: string) {
return name === "contenteditable" ? "true" : null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(editableTarget as unknown as EventTarget), false);
});

View File

@@ -0,0 +1,44 @@
type FocusTargetLike = {
tagName?: string | null;
isContentEditable?: boolean;
closest?: (selector: string) => unknown;
getAttribute?: (name: string) => string | null;
};
const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]';
/**
* The terminal's top overlay sits above the xterm textarea. Pointer clicks on
* that layer should usually keep focus in the terminal so typing can continue.
* Only allow native focus changes for genuinely editable controls.
*/
export const shouldPreserveTerminalFocusOnMouseDown = (target: EventTarget | null): boolean => {
if (!target || typeof target !== "object") return true;
const candidate = target as FocusTargetLike;
const tagName = typeof candidate.tagName === "string"
? candidate.tagName.toUpperCase()
: "";
if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") {
return false;
}
if (candidate.isContentEditable) {
return false;
}
if (typeof candidate.getAttribute === "function") {
const contentEditable = candidate.getAttribute("contenteditable");
const role = candidate.getAttribute("role");
if (contentEditable === "" || contentEditable === "true" || role === "textbox") {
return false;
}
}
if (typeof candidate.closest === "function" && candidate.closest(EDITABLE_SELECTOR)) {
return false;
}
return true;
};

51
domain/host.test.ts Normal file
View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "./models.ts";
import { upsertHostById } from "./host.ts";
const makeHost = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Primary Host",
hostname: "127.0.0.1",
port: 22,
username: "root",
authType: "password",
createdAt: 1,
protocol: "ssh",
...overrides,
});
test("upsertHostById updates an existing host in place", () => {
const existing = makeHost();
const updated = makeHost({ label: "Updated Host" });
assert.deepEqual(upsertHostById([existing], updated), [updated]);
});
test("upsertHostById appends a duplicated host with a fresh id", () => {
const existing = makeHost({
id: "serial-original",
label: "Serial Config",
protocol: "serial",
hostname: "/dev/ttyUSB0",
port: 115200,
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 115200,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none",
localEcho: false,
lineMode: false,
},
});
const duplicate = makeHost({
...existing,
id: "serial-duplicate",
label: "Serial Config (copy)",
});
assert.deepEqual(upsertHostById([existing], duplicate), [existing, duplicate]);
});

View File

@@ -153,6 +153,13 @@ export const formatHostPort = (hostname: string, port?: number | null): string =
return `${display}:${port}`;
};
export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
const hostExists = hosts.some((entry) => entry.id === host.id);
return hostExists
? hosts.map((entry) => (entry.id === host.id ? host : entry))
: [...hosts, host];
};
export const sanitizeHost = (host: Host): Host => {
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);

View File

@@ -497,6 +497,18 @@ export interface TerminalSettings {
// Paste
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
// Shell `clear` command behavior — controls whether CSI 3 J (erase scrollback)
// from the shell is honored. Default true matches POSIX/ncurses since 2013:
// `clear` clears both visible screen and scrollback. Disable to keep history
// across `clear` (matches iTerm2 default and pre-2013 behavior).
clearWipesScrollback: boolean;
// When true, typing on the keyboard does NOT clear an existing mouse
// selection. Lets the user select text, type a command prefix (e.g. `sz `),
// and then paste the still-live selection. xterm.js's default is to clear
// on input; this opt-in toggle restores the selection right after.
preserveSelectionOnInput: boolean;
// Clipboard
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
@@ -625,6 +637,8 @@ 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
clearWipesScrollback: true, // POSIX-standard: shell `clear` clears scrollback too
preserveSelectionOnInput: false, // Opt-in: keep selection alive when typing
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
rendererType: 'auto', // Auto-detect best renderer based on hardware
autocompleteEnabled: true, // Autocomplete enabled by default

View File

@@ -1,10 +1,12 @@
/**
* Cloud Sync Domain Types & Interfaces
*
*
* Zero-Knowledge Encrypted Multi-Cloud Sync System
* Supports: GitHub Gist, Google Drive, Microsoft OneDrive, WebDAV, S3 Compatible
*/
import type { ShrinkFinding } from './syncGuards';
// ============================================================================
// Security State Machine
// ============================================================================
@@ -22,10 +24,11 @@ export type SecurityState =
* Sync Operation State Machine
* Tracks the current sync operation status
*/
export type SyncState =
export type SyncState =
| 'IDLE' // Waiting for sync trigger
| 'SYNCING' // Active sync operation in progress
| 'CONFLICT' // Version conflict detected - needs resolution
| 'BLOCKED' // Outgoing payload would delete too much — user must choose restore or force-push
| 'ERROR'; // Operation failed - needs attention
/**
@@ -284,6 +287,10 @@ export interface SyncResult {
conflictDetected?: boolean;
/** Present when action === 'merge'; caller should apply this to update local state */
mergedPayload?: import('./sync').SyncPayload;
/** True when a shrink-detection guard blocked the upload */
shrinkBlocked?: boolean;
/** The finding that triggered the shrink block or force-push */
finding?: ShrinkFinding;
}
/**
@@ -351,10 +358,13 @@ export type SyncEvent =
| { type: 'SYNC_COMPLETED'; provider: CloudProvider; result: SyncResult }
| { type: 'SYNC_ERROR'; provider: CloudProvider; error: string }
| { type: 'CONFLICT_DETECTED'; conflict: ConflictInfo }
| { type: 'SYNC_BLOCKED_SHRINK'; provider: CloudProvider; finding: ShrinkFinding }
| { type: 'SYNC_FORCED'; provider: CloudProvider; finding: ShrinkFinding }
| { type: 'CONFLICT_RESOLVED'; resolution: ConflictResolution }
| { type: 'AUTH_REQUIRED'; provider: CloudProvider }
| { type: 'AUTH_COMPLETED'; provider: CloudProvider; account: ProviderAccount }
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState };
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState }
| { type: 'SYNC_BLOCKED_CLEARED' };
// ============================================================================
// Storage Keys

139
domain/syncGuards.test.ts Normal file
View File

@@ -0,0 +1,139 @@
import test from "node:test";
import assert from "node:assert/strict";
import { detectSuspiciousShrink } from "./syncGuards.ts";
import type { SyncPayload } from "./sync.ts";
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
return {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
knownHosts: [],
portForwardingRules: [],
groupConfigs: [],
settings: undefined,
syncedAt: 0,
...overrides,
};
}
function hosts(n: number): SyncPayload["hosts"] {
return Array.from({ length: n }, (_, i) => ({
id: `h${i}`,
label: `h${i}`,
hostname: `h${i}.example`,
port: 22,
username: "root",
protocol: "ssh",
})) as SyncPayload["hosts"];
}
test("null base → not suspicious (first sync / null after re-auth)", () => {
const result = detectSuspiciousShrink(payload({ hosts: hosts(1) }), null);
assert.deepEqual(result, { suspicious: false });
});
test("no shrink — same counts → not suspicious", () => {
const base = payload({ hosts: hosts(5) });
const out = payload({ hosts: hosts(5) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("growth only → not suspicious", () => {
const base = payload({ hosts: hosts(5) });
const out = payload({ hosts: hosts(10) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("shrink under both thresholds → not suspicious (delete 2 of 4)", () => {
const base = payload({ hosts: hosts(4) });
const out = payload({ hosts: hosts(2) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("bulk-shrink 50% AND absolute 3 — exactly at threshold → suspicious", () => {
const base = payload({ hosts: hosts(6) });
const out = payload({ hosts: hosts(3) });
assert.deepEqual(detectSuspiciousShrink(out, base), {
suspicious: true,
reason: "bulk-shrink",
entityType: "hosts",
baseCount: 6,
outgoingCount: 3,
lost: 3,
});
});
test("bulk-shrink 50% but absolute 2 → not suspicious (absolute gate)", () => {
const base = payload({ hosts: hosts(4) });
const out = payload({ hosts: hosts(2) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("bulk-shrink 40% absolute 4 → not suspicious (ratio gate)", () => {
const base = payload({ hosts: hosts(10) });
const out = payload({ hosts: hosts(6) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("large-shrink absolute 10 regardless of ratio → suspicious", () => {
const base = payload({ hosts: hosts(100) });
const out = payload({ hosts: hosts(90) });
assert.deepEqual(detectSuspiciousShrink(out, base), {
suspicious: true,
reason: "large-shrink",
entityType: "hosts",
baseCount: 100,
outgoingCount: 90,
lost: 10,
});
});
test("dual-trigger (large-shrink AND bulk-shrink both satisfied) → reason is 'large-shrink'", () => {
// base=20, lost=10: satisfies large-shrink (>=10) AND bulk-shrink (50%, >=3)
const base = payload({ hosts: hosts(20) });
const out = payload({ hosts: hosts(10) });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) assert.equal(result.reason, "large-shrink");
});
test("multiple entity types shrinking — returns first in declaration order (hosts before keys)", () => {
const base = payload({ hosts: hosts(6), keys: Array.from({ length: 6 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
const out = payload({ hosts: hosts(3), keys: Array.from({ length: 3 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) assert.equal(result.entityType, "hosts");
});
test("only non-hosts entity shrinks → reports that entity", () => {
const snippets = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `s${i}`, label: `s${i}`, command: "" })) as SyncPayload["snippets"];
const base = payload({ snippets: snippets(10) });
const out = payload({ snippets: snippets(0) });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) {
assert.equal(result.entityType, "snippets");
assert.equal(result.reason, "large-shrink");
}
});
test("knownHosts shrink triggers (security-sensitive)", () => {
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
const base = payload({ knownHosts: kh(12) });
const out = payload({ knownHosts: kh(2) });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) assert.equal(result.entityType, "knownHosts");
});
test("empty base (all zeros) — no shrink possible, returns not suspicious", () => {
const base = payload();
const out = payload({ hosts: hosts(5) });
// All base counts are 0; no shrink possible
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});

85
domain/syncGuards.ts Normal file
View File

@@ -0,0 +1,85 @@
import type { SyncPayload } from './sync';
export type ShrinkFinding =
| { suspicious: false }
| {
suspicious: true;
reason: 'bulk-shrink' | 'large-shrink';
entityType:
| 'hosts'
| 'keys'
| 'identities'
| 'snippets'
| 'customGroups'
| 'snippetPackages'
| 'knownHosts'
| 'portForwardingRules'
| 'groupConfigs';
baseCount: number;
outgoingCount: number;
lost: number;
};
// Keep in sync with all array-typed fields of SyncPayload. When a new
// array entity type is added there, add it here too — there is no
// compile-time check enforcing this.
const CHECKED_ENTITIES = [
'hosts',
'keys',
'identities',
'snippets',
'customGroups',
'snippetPackages',
'knownHosts',
'portForwardingRules',
'groupConfigs',
] as const;
type CheckedEntityType = typeof CHECKED_ENTITIES[number];
const BULK_SHRINK_RATIO = 0.5;
const BULK_SHRINK_MIN_ABSOLUTE = 3;
const LARGE_SHRINK_ABSOLUTE = 10;
function countOf(p: SyncPayload, key: CheckedEntityType): number {
const v = p[key];
return Array.isArray(v) ? v.length : 0;
}
export function detectSuspiciousShrink(
outgoing: SyncPayload,
base: SyncPayload | null,
): ShrinkFinding {
if (!base) return { suspicious: false };
for (const entityType of CHECKED_ENTITIES) {
const baseCount = countOf(base, entityType);
const outgoingCount = countOf(outgoing, entityType);
const lost = baseCount - outgoingCount;
if (lost <= 0) continue;
if (lost >= LARGE_SHRINK_ABSOLUTE) {
return {
suspicious: true,
reason: 'large-shrink',
entityType,
baseCount,
outgoingCount,
lost,
};
}
if (baseCount > 0 && lost / baseCount >= BULK_SHRINK_RATIO && lost >= BULK_SHRINK_MIN_ABSOLUTE) {
return {
suspicious: true,
reason: 'bulk-shrink',
entityType,
baseCount,
outgoingCount,
lost,
};
}
}
return { suspicious: false };
}

View File

@@ -29,6 +29,7 @@ module.exports = {
'node_modules/node-pty/**/*',
'node_modules/ssh2/**/*',
'node_modules/cpu-features/**/*',
'node_modules/@vscode/windows-process-tree/**/*',
'node_modules/@zed-industries/claude-agent-acp/**/*',
'node_modules/@agentclientprotocol/sdk/**/*',
'node_modules/@anthropic-ai/claude-agent-sdk/**/*',

View File

@@ -88,7 +88,7 @@ function buildWrappedCommand(command, shellKind, marker) {
`set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; ` +
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
`printf '%s\\n' '${marker}_S'; eval -- \$${marker}_cmd; set __NCMCP_rc $status; ` +
`printf '%s\\n' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
);

View File

@@ -4,8 +4,10 @@ const path = require("node:path");
const USER_SKILLS_DIR_NAME = "Skills";
const USER_SKILLS_README_NAME = "README.txt";
const MAX_SKILL_BYTES = 24 * 1024;
const MAX_DESCRIPTION_LENGTH = 280;
const MAX_DESCRIPTION_LENGTH = 500;
const MAX_INDEX_SKILLS = 8;
const MAX_INDEX_DESCRIPTION_CHARS = 160;
const MAX_INDEX_LINE_CHARS = 1400;
const MAX_EXPLICIT_SKILLS = 4;
const MAX_MATCHED_SKILLS = 2;
const MAX_MATCHED_SKILL_CHARS = 6000;
@@ -67,6 +69,12 @@ function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function truncateInlineText(value, maxChars) {
const normalized = String(value || "").replace(/\s+/g, " ").trim();
if (normalized.length <= maxChars) return normalized;
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
}
function formatSkillReadWarning(error) {
const code = typeof error?.code === "string" ? error.code : null;
const message = typeof error?.message === "string" ? error.message : String(error || "Unknown error");
@@ -354,11 +362,22 @@ async function buildUserSkillsContext(electronApp, prompt, selectedSkillSlugs =
}
const indexSkills = readySkills.slice(0, MAX_INDEX_SKILLS);
const remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
let remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
const indexEntries = [];
let indexChars = 0;
const indexLine = indexSkills
.map((skill) => `${skill.name}: ${skill.description}`)
.join("; ");
for (const skill of indexSkills) {
const entry = `${skill.name}: ${truncateInlineText(skill.description, MAX_INDEX_DESCRIPTION_CHARS)}`;
const separatorChars = indexEntries.length > 0 ? 2 : 0;
if (indexChars + separatorChars + entry.length > MAX_INDEX_LINE_CHARS) {
remainingCount += indexSkills.length - indexEntries.length;
break;
}
indexEntries.push(entry);
indexChars += separatorChars + entry.length;
}
const indexLine = indexEntries.join("; ");
const orderedExplicitSlugs = [];
const seenExplicitSlugs = new Set();

View File

@@ -99,6 +99,69 @@ test("keeps every explicitly selected skill in the built context", async () => {
);
});
test("uses longer skill descriptions for routing matches without injecting the full index text", async () => {
const longDescription = [
"Use when the user needs a detailed workflow for operating Netcatty through ACP skills and CLI.",
"Includes platform launcher guidance, scoped command execution, recovery behavior, and constraints.",
"This intentionally exceeds the older short description budget so routing has enough signal.",
"It also names edge cases such as unavailable optional shells, strict chat-session scoping, and fallback-only history replay so the agent can choose the skill without reading the whole body first.",
].join(" ");
assert.ok(longDescription.length > 320);
await withUserSkills(
[
{
directoryName: "Detailed Router",
name: "Detailed Router",
description: longDescription,
body: "Detailed router body",
},
],
async (electronApp) => {
const status = await scanUserSkills(electronApp);
const result = await buildUserSkillsContext(
electronApp,
"Need fallback-only history replay guidance for ACP recovery.",
[],
);
assert.equal(status.readyCount, 1);
assert.equal(status.warningCount, 0);
assert.equal(result.context.includes("### Detailed Router"), true);
assert.equal(result.context.includes("Detailed router body"), true);
assert.equal(result.context.includes(longDescription), false);
},
);
});
test("caps the injected available-skills index when descriptions are very long", async () => {
const longDescription = "signal ".repeat(65);
await withUserSkills(
Array.from({ length: 8 }, (_, index) => ({
directoryName: `Skill ${index + 1}`,
name: `Skill ${index + 1}`,
description: `${longDescription}${index + 1}`,
body: `Body ${index + 1}`,
})),
async (electronApp) => {
const result = await buildUserSkillsContext(
electronApp,
"plain prompt",
[],
);
const availableLine = result.context
.split("\n")
.find((line) => line.startsWith("Available user skills: "));
assert.ok(availableLine, "expected available-skills index line");
assert.ok(availableLine.length < 1800, `expected capped index line, got ${availableLine.length}`);
},
);
});
test("preserves an unavailable explicit selection in the built context", async () => {
await withUserSkills(
[

View File

@@ -2317,6 +2317,14 @@ function registerHandlers(ipcMain) {
try {
const existingRun = acpChatRuns.get(chatSessionId);
if (existingRun && existingRun.requestId !== requestId) {
// Capture whether the prior run was already cancelled (via the
// cancel IPC) BEFORE we set the flag ourselves — the cancel IPC
// contract explicitly preserves the provider session so the
// next prompt can continue in the same conversation. Tearing
// down the provider here would silently break that contract in
// the "click Stop, then immediately send next prompt" flow,
// discarding the recovered ACP session.
const alreadyCancelledViaIpc = existingRun.cancelRequested;
existingRun.cancelRequested = true;
const existingController = acpActiveStreams.get(existingRun.requestId);
if (existingController) {
@@ -2324,7 +2332,15 @@ function registerHandlers(ipcMain) {
acpActiveStreams.delete(existingRun.requestId);
}
acpRequestSessions.delete(existingRun.requestId);
cleanupAcpProvider(chatSessionId);
// Only tear down the provider for true interrupt-and-restart
// flows (user typed a new prompt while the old one was still
// streaming, no explicit cancel). When we do skip cleanup here,
// the reuse/reset logic below still handles auth/MCP/permission
// changes correctly — the provider is preserved only when
// nothing else would require rebuilding it.
if (!alreadyCancelledViaIpc) {
cleanupAcpProvider(chatSessionId);
}
}
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
@@ -2476,9 +2492,48 @@ function registerHandlers(ipcMain) {
providerEntry.mcpFingerprint === mcpSnapshot.fingerprint &&
providerEntry.permissionMode === currentPermissionMode,
);
const shouldResetProviderForHistoryReplay = Boolean(
shouldReuseProvider &&
providerEntry?.historyReplayFallback &&
Array.isArray(historyMessages) &&
historyMessages.length > 0,
);
if (!shouldReuseProvider) {
const resumeSessionId = providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
if (!shouldReuseProvider || shouldResetProviderForHistoryReplay) {
const resumeSessionId = shouldResetProviderForHistoryReplay
? undefined
: providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
// Preserve the replay-fallback flag across any recreation where
// history recovery is still pending, not just the reset-for-replay
// path. Otherwise a provider recreation driven by an orthogonal
// change (permission mode / MCP scope / auth fingerprint) between
// a still-empty recovered turn and its retry would drop the flag
// and lose the recovered conversation on the next turn.
//
// Also hedge whenever we're spawning a brand-new provider process
// that's being told to resume an existing session id (the common
// app-restart / reconnect flow — #753). Some ACP agents (Copilot
// CLI, some Codex builds) silently spin up a fresh session
// instead of erroring with "session not found", so the catch-
// block fallback below never fires and the agent ends up with
// zero prior context. Scheduling a compact replay on the first
// turn guarantees the agent sees durable constraints and the
// last few raw turns even when session/load is effectively a
// no-op. After the first successful streamed turn the flag
// clears (post-stream hook), so steady-state cost stays at
// just the latest prompt.
const preserveHistoryReplayFallback =
shouldResetProviderForHistoryReplay ||
Boolean(
providerEntry?.historyReplayFallback &&
Array.isArray(historyMessages) &&
historyMessages.length > 0,
) ||
Boolean(
resumeSessionId &&
Array.isArray(historyMessages) &&
historyMessages.length > 0,
);
cleanupAcpProvider(chatSessionId);
const agentEnv = withCliDiscoveryEnv({ ...shellEnv });
@@ -2555,7 +2610,7 @@ function registerHandlers(ipcMain) {
authFingerprint,
mcpFingerprint: mcpSnapshot.fingerprint,
permissionMode: currentPermissionMode,
historyReplayFallback: false,
historyReplayFallback: preserveHistoryReplayFallback,
};
acpProviders.set(chatSessionId, providerEntry);
}
@@ -2726,14 +2781,17 @@ function registerHandlers(ipcMain) {
role: "user",
content: buildMessageContent(contextualPrompt, images),
};
const shouldReplayHistory = Boolean(
providerEntry.historyReplayFallback &&
Array.isArray(historyMessages) &&
historyMessages.length > 0,
);
const result = streamText({
model: modelInstance,
messages: providerEntry.historyReplayFallback
messages: shouldReplayHistory
? [
...(Array.isArray(historyMessages)
? historyMessages.map((msg) => ({ role: msg.role, content: msg.content }))
: []),
...historyMessages.map((msg) => ({ role: msg.role, content: msg.content })),
latestPromptMessage,
]
: [latestPromptMessage],
@@ -2819,6 +2877,21 @@ function registerHandlers(ipcMain) {
: "Agent returned an empty response.",
});
} else {
// Clear replay fallback when the recovered turn either streamed
// content OR was user-aborted. The empty-but-not-aborted case is
// handled in the if-branch above and intentionally keeps the flag
// so a follow-up retry can re-replay onto a fresh session.
//
// Why also clear on abort: if the user actively cancelled, the
// freshly recovered ACP session has whatever state was built up so
// far. Leaving the flag set would make the next turn trigger
// shouldResetProviderForHistoryReplay, which discards the recovered
// session (resumeSessionId is forced to undefined in that path) and
// re-spends tokens on another compact replay. That breaks the
// cancel-preserves-session contract for users who stop early.
if (shouldReplayHistory) {
providerEntry.historyReplayFallback = false;
}
debugMcpLog("ACP stream done", { requestId, chatSessionId, hasContent });
if (!isActiveAcpRun(chatSessionId, requestId)) {
return { ok: true };
@@ -2871,6 +2944,18 @@ function registerHandlers(ipcMain) {
if (activeRun && activeRun.requestId === effectiveRequestId) {
activeRun.cancelRequested = true;
}
// Synchronously clear historyReplayFallback on the preserved provider
// entry. Without this, a user pressing Stop and immediately sending
// the next prompt can have their new request enter the stream
// handler before the aborted run's post-stream clearing code runs.
// The new turn would then see historyReplayFallback=true, trigger
// shouldResetProviderForHistoryReplay, and recreate the provider
// without the recovered existingSessionId — discarding the very
// session the cancel contract promised to preserve.
if (effectiveChatSessionId) {
const preservedEntry = acpProviders.get(effectiveChatSessionId);
if (preservedEntry) preservedEntry.historyReplayFallback = false;
}
const controller = acpActiveStreams.get(effectiveRequestId);
let cancelled = false;
if (controller) {

View File

@@ -0,0 +1,837 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const Module = require("node:module");
function createIpcMainStub() {
const handlers = new Map();
return {
handlers,
handle(channel, handler) {
handlers.set(channel, handler);
},
};
}
function createEmptyStreamResult() {
return {
fullStream: {
getReader() {
return {
async read() {
return { done: true, value: undefined };
},
releaseLock() {},
};
},
},
};
}
function loadBridgeWithMocks(options = {}) {
const streamCalls = [];
const safeSendCalls = [];
let providerCreationCount = 0;
const providerCreationArgs = [];
const fallbackProvider = {
tools: {},
languageModel() {
return { id: "fake-model" };
},
async initSession() {},
getSessionId() {
return "fresh-session";
},
cleanup() {},
};
const mocks = {
"./mcpServerBridge.cjs": {
init() {},
setMainWindowGetter() {},
getOrCreateHost: async () => 4010,
getScopedSessionIds: () => [],
buildMcpServerConfig: () => ({ name: "netcatty-remote-hosts", type: "http", url: "http://127.0.0.1:4010" }),
getPermissionMode: () =>
typeof options.getPermissionMode === "function"
? options.getPermissionMode()
: "default",
getMaxIterations: () => 20,
setChatSessionCancelled() {},
cancelPtyExecsForSession() {},
clearPendingApprovals() {},
cleanupScopedMetadata: async () => {},
cleanup() {},
},
"../cli/discoveryPath.cjs": {
getCliLauncherPath: () => "/tmp/netcatty-tool-cli",
TOOL_CLI_DISCOVERY_ENV_VAR: "NETCATTY_TOOL_CLI_DISCOVERY_FILE",
},
"./ai/userSkills.cjs": {
scanUserSkills: async () => ({ readyCount: 0, warningCount: 0, skills: [], warnings: [] }),
buildUserSkillsContext: async () => ({ context: "", selectedSkills: [] }),
toPublicUserSkillsStatus: (value) => value,
},
"./ai/shellUtils.cjs": {
stripAnsi: (value) => value,
normalizeCliPathForPlatform: (value) => value,
shouldUseShellForCommand: () => false,
resolveCliFromPath: () => null,
resolveClaudeAcpBinaryPath: () => null,
getShellEnv: async () => ({}),
invalidateShellEnvCache() {},
serializeStreamChunk: (chunk) => chunk,
toUnpackedAsarPath: (value) => value,
},
"./ai/codexHelpers.cjs": {
codexLoginSessions: new Map(),
resolveCodexAcpBinaryPath: () => null,
appendCodexLoginOutput() {},
toCodexLoginSessionResponse: () => ({}),
getActiveCodexLoginSession: () => null,
normalizeCodexIntegrationState: () => ({}),
readCodexCustomProviderConfig: () => null,
getCodexAuthOverride: () => ({}),
getCodexCustomConfigPreflightError: () => null,
extractCodexError: (err) => ({ message: err?.message || String(err) }),
isCodexAuthError: () => false,
getCodexAuthFingerprint: (...args) =>
typeof options.getCodexAuthFingerprint === "function"
? options.getCodexAuthFingerprint(...args)
: "auth-fingerprint",
getCodexMcpFingerprint: () => "mcp-fingerprint",
invalidateCodexValidationCache() {},
getCodexValidationCache: () => null,
setCodexValidationCache() {},
},
"./ai/ptyExec.cjs": {
execViaPty: async () => {
throw new Error("execViaPty should not be called in this test");
},
},
"./ipcUtils.cjs": {
safeSend(sender, channel, payload) {
safeSendCalls.push({ sender, channel, payload });
},
},
"./windowManager.cjs": {
getMainWindow() {
return {
isDestroyed: () => false,
webContents: { id: 1 },
};
},
getSettingsWindow() {
return null;
},
},
"@mcpc-tech/acp-ai-provider": {
createACPProvider(args) {
providerCreationCount += 1;
providerCreationArgs.push(args);
if (typeof options.createACPProvider === "function") {
return options.createACPProvider({ args, providerCreationCount, fallbackProvider });
}
if (providerCreationCount === 1) {
return {
tools: {},
languageModel() {
return { id: "fake-model" };
},
async initSession() {
throw new Error("Resource not found: session not found");
},
getSessionId() {
return "stale-session";
},
cleanup() {},
};
}
return fallbackProvider;
},
},
ai: {
stepCountIs: () => Symbol("stopWhen"),
streamText(args) {
const { messages } = args;
streamCalls.push(messages);
if (typeof options.streamText === "function") {
return options.streamText({ ...args, streamCalls });
}
if (streamCalls.length === 1) {
throw new Error("transport failed before replayed turn completed");
}
return createEmptyStreamResult();
},
},
};
const bridgePath = require.resolve("./aiBridge.cjs");
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, isMain) {
if (Object.prototype.hasOwnProperty.call(mocks, request)) {
return mocks[request];
}
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[bridgePath];
try {
const bridge = require("./aiBridge.cjs");
return {
bridge,
streamCalls,
safeSendCalls,
providerCreationArgs,
restore() {
try {
bridge.cleanup();
} finally {
delete require.cache[bridgePath];
Module._load = originalLoad;
}
},
};
} catch (error) {
delete require.cache[bridgePath];
Module._load = originalLoad;
throw error;
}
}
test("replays fallback history only after creating a fresh ACP session when the recovered turn fails", async () => {
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks();
const ipcMain = createIpcMainStub();
const originalConsoleError = console.error;
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
assert.equal(typeof streamHandler, "function");
const historyMessages = [{ role: "user", content: "prior recovered context" }];
const event = { sender: { id: 1 } };
try {
console.error = (...args) => {
const message = args.map((part) => String(part ?? "")).join(" ");
if (message.includes("transport failed before replayed turn completed")) {
return;
}
originalConsoleError(...args);
};
await streamHandler(event, {
requestId: "req-1",
chatSessionId: "chat-1",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "first recovered turn",
providerId: undefined,
model: undefined,
existingSessionId: "stale-session",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
await streamHandler(event, {
requestId: "req-2",
chatSessionId: "chat-1",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "retry after transport failure",
providerId: undefined,
model: undefined,
existingSessionId: "fresh-session",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
} finally {
console.error = originalConsoleError;
restore();
}
assert.equal(streamCalls.length, 2);
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
assert.equal(providerCreationArgs.length, 3);
assert.equal("existingSessionId" in providerCreationArgs[0], true);
assert.equal(providerCreationArgs[0].existingSessionId, "stale-session");
assert.equal("existingSessionId" in providerCreationArgs[1], false);
assert.equal("existingSessionId" in providerCreationArgs[2], false);
});
test("clears replay fallback after a user-cancelled recovered turn so the fresh ACP session is preserved", async () => {
// Regression: if the user stops the first turn after stale-session
// recovery, historyReplayFallback must still be cleared. Otherwise the
// next turn triggers shouldResetProviderForHistoryReplay, which discards
// the freshly recovered ACP session (resumeSessionId is forced to
// undefined in that path) and re-spends tokens on another compact
// replay. That would break the cancel-preserves-session contract.
// Gate that the test releases AFTER cancel has been dispatched, so the
// bridge's reader loop wakes up to find signal.aborted=true.
let releaseRead;
const readReleased = new Promise((resolve) => {
releaseRead = resolve;
});
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
streamText({ streamCalls: callsRef }) {
// First call (the recovered turn) — block in read() so the test can
// fire cancel before any chunk arrives, simulating "user clicks Stop
// before the agent emits content". Second call (follow-up) — return
// an immediately-done empty stream.
if (callsRef.length === 1) {
return {
fullStream: {
getReader: () => ({
async read() {
await readReleased;
// After cancel, signal.aborted is true; return done so the
// loop exits cleanly. Never produced a content chunk →
// hasContent stays false, aborted is true → we hit the
// else-branch where the fix lives.
return { done: true, value: undefined };
},
releaseLock() {},
}),
},
};
}
return createEmptyStreamResult();
},
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
const cancelHandler = ipcMain.handlers.get("netcatty:ai:acp:cancel");
assert.equal(typeof streamHandler, "function");
assert.equal(typeof cancelHandler, "function");
const historyMessages = [{ role: "user", content: "prior recovered context" }];
const event = { sender: { id: 1 } };
try {
// Kick off the first turn; it will block at reader.read().
const firstTurn = streamHandler(event, {
requestId: "req-cancel-1",
chatSessionId: "chat-cancel",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "first recovered turn",
providerId: undefined,
model: undefined,
existingSessionId: "stale-session",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
// Yield enough microtasks so the handler reaches the streamText/read
// path before we cancel.
for (let i = 0; i < 10; i += 1) await Promise.resolve();
// Fire cancel — this calls controller.abort() inside the bridge.
await cancelHandler(event, {
requestId: "req-cancel-1",
chatSessionId: "chat-cancel",
});
// Now release the blocked read so the loop wakes, sees aborted, and
// exits. The else-branch should clear historyReplayFallback.
releaseRead();
await firstTurn;
// Second turn — should reuse the recovered fresh-session and send
// only the latest prompt (no compact replay).
await streamHandler(event, {
requestId: "req-cancel-2",
chatSessionId: "chat-cancel",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "follow-up after cancel",
providerId: undefined,
model: undefined,
existingSessionId: "fresh-session",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
} finally {
restore();
}
// Two streamText calls: the cancelled one + the follow-up.
assert.equal(streamCalls.length, 2);
// Provider creation count: 1 stale attempt + 1 fallback recovery = 2.
// If the bug regresses, the follow-up turn would force a 3rd creation
// (shouldResetProviderForHistoryReplay → cleanupAcpProvider → recreate
// without existingSessionId).
assert.equal(
providerCreationArgs.length,
2,
"expected the recovered fresh session to be preserved across user cancel",
);
// Follow-up turn should send only the latest prompt — the recovered
// session has the prior context; replaying compact history again would
// waste tokens and visually feel like the conversation forgot itself.
assert.equal(
streamCalls[1].length,
1,
"follow-up after cancel must not re-replay compact history",
);
});
test("replays compact history on the first turn after app restart even when session/load 'succeeds'", async () => {
// Regression for #753: after an app restart, the renderer still has
// the prior chat's externalSessionId and full message history in
// storage, and passes both to the bridge on the next send. The
// externalSessionId becomes existingSessionId → resumeSessionId in
// the bridge, and createACPProvider spawns a fresh agent process
// with that id.
//
// Problem: some ACP agents (Copilot CLI, some Codex builds) don't
// error on session/load when the id is stale — they silently start
// a new session. The catch-block fallback never fires, so
// historyReplayFallback stays false and the stream sends only the
// latest prompt. The agent says "no previous records" even though
// the UI shows the prior conversation.
//
// Fix: when we're spawning a new provider AND telling it to resume
// an existing session id AND we have compact history to replay,
// preload historyReplayFallback=true. The first turn includes the
// replay; after it streams real content the flag clears so steady-
// state cost stays at just the latest prompt.
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
createACPProvider({ fallbackProvider }) {
// Pretend session/load succeeded silently — no error thrown, but
// also no real context. This models Copilot CLI's behavior.
return fallbackProvider;
},
streamText({ streamCalls: callsRef }) {
// Return content so the post-stream hook clears the flag after.
if (callsRef.length === 1) {
const chunks = [{ type: "text-delta", text: "ok" }];
let i = 0;
return {
fullStream: {
getReader: () => ({
async read() {
if (i < chunks.length) return { done: false, value: chunks[i++] };
return { done: true, value: undefined };
},
releaseLock() {},
}),
},
};
}
return createEmptyStreamResult();
},
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
const historyMessages = [{ role: "user", content: "prior constraint: 不要提交" }];
const event = { sender: { id: 1 } };
try {
// First turn after app restart. existingSessionId is set (renderer
// persisted it), historyMessages is non-empty.
await streamHandler(event, {
requestId: "req-restart-1",
chatSessionId: "chat-restart",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "what did we discuss?",
providerId: undefined,
model: undefined,
existingSessionId: "stored-session-from-storage",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
// Second turn — should send only the latest prompt now.
await streamHandler(event, {
requestId: "req-restart-2",
chatSessionId: "chat-restart",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "and now continue",
providerId: undefined,
model: undefined,
existingSessionId: "stored-session-from-storage",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
} finally {
restore();
}
// Single provider creation — session/load "succeeded" so no fallback.
assert.equal(providerCreationArgs.length, 1);
assert.equal(providerCreationArgs[0].existingSessionId, "stored-session-from-storage");
// First turn MUST include the compact history + latest prompt.
// Regression target: pre-fix, streamCalls[0] had length 1 (latest only).
assert.equal(
streamCalls[0].length,
2,
"first turn after app restart must preload compact history as a hedge",
);
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
// Second turn uses steady-state behavior (latest only). This confirms
// the flag clears after one successful streamed turn and the hedge
// doesn't keep replaying forever.
assert.equal(
streamCalls[1].length,
1,
"steady-state turns must not keep replaying history",
);
});
test("preserves recovered ACP session when user cancels then immediately sends the next prompt", async () => {
// Regression: after a user-cancel of a recovered turn, the existingRun
// path in the next stream handler used to call cleanupAcpProvider
// unconditionally — destroying the fresh ACP session the cancel IPC
// had just promised to preserve. Combined with historyReplayFallback
// still being true at that moment, the follow-up turn then recreated
// a bare new provider via shouldResetProviderForHistoryReplay and
// the user lost all recovered conversation context.
//
// With the fix: (a) the cancel IPC synchronously clears the replay
// flag on the preserved provider, and (b) the existingRun path skips
// cleanupAcpProvider when the prior run was already cancelled via
// the cancel IPC. The next stream then reuses the recovered session
// and sends only the latest prompt.
let releaseRead;
const readReleased = new Promise((resolve) => {
releaseRead = resolve;
});
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
streamText({ streamCalls: callsRef }) {
// Turn 1: block in read() so the test can fire cancel, then
// immediately fire the next stream request while the aborted
// stream is still unwinding.
if (callsRef.length === 1) {
return {
fullStream: {
getReader: () => ({
async read() {
await readReleased;
return { done: true, value: undefined };
},
releaseLock() {},
}),
},
};
}
return createEmptyStreamResult();
},
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
const cancelHandler = ipcMain.handlers.get("netcatty:ai:acp:cancel");
const historyMessages = [{ role: "user", content: "prior recovered context" }];
const event = { sender: { id: 1 } };
try {
// Turn 1 starts and blocks in read().
const firstTurn = streamHandler(event, {
requestId: "req-cancel-1",
chatSessionId: "chat-race",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "first turn",
providerId: undefined,
model: undefined,
existingSessionId: "stale-session",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
// Yield so the handler reaches the streamText/read phase.
for (let i = 0; i < 10; i += 1) await Promise.resolve();
// User clicks Stop.
await cancelHandler(event, {
requestId: "req-cancel-1",
chatSessionId: "chat-race",
});
// User immediately sends the next prompt BEFORE releasing the read
// — i.e. before the first stream handler's post-stream code can
// run. This is the exact timing window codex flagged.
const secondTurn = streamHandler(event, {
requestId: "req-cancel-2",
chatSessionId: "chat-race",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "immediate follow-up",
providerId: undefined,
model: undefined,
existingSessionId: "fresh-session",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
// Let the first turn unwind now.
releaseRead();
await firstTurn;
await secondTurn;
} finally {
restore();
}
// 2 provider creations: the stale attempt + fallback recovery.
// If the regression is back, there would be a 3rd creation (the
// existingRun cleanup + reset-for-replay path discarding the
// recovered session).
assert.equal(
providerCreationArgs.length,
2,
"expected recovered fresh session to be preserved across cancel+immediate-send",
);
// Second turn must NOT re-replay compact history — the preserved
// session already has that context.
assert.equal(
streamCalls[1].length,
1,
"follow-up after cancel must not re-replay compact history",
);
});
test("preserves history-replay across provider recreation caused by permission-mode / MCP / auth change", async () => {
// Regression: after a stale-session recovery left historyReplayFallback=true
// (e.g. the recovered turn returned empty), an orthogonal change that
// flips shouldReuseProvider to false (permission mode, MCP scope, auth
// fingerprint) used to recreate the provider with historyReplayFallback:
// false. The next turn then sent only the latest prompt and dropped the
// recovered conversation context. We now preserve the flag on any
// recreation where a history-replay is still pending.
// Use permission mode as the orthogonal change — auth fingerprint would
// drag in Codex-specific auth validation we can't stub cleanly.
let permissionMode = "default";
function createStreamResult(chunks) {
let idx = 0;
return {
fullStream: {
getReader: () => ({
async read() {
if (idx < chunks.length) {
return { done: false, value: chunks[idx++] };
}
return { done: true, value: undefined };
},
releaseLock() {},
}),
},
};
}
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
getPermissionMode: () => permissionMode,
streamText({ streamCalls: callsRef }) {
// Turn 1: empty stream — the recovered turn returned no content, so
// the empty-non-aborted branch keeps historyReplayFallback=true.
if (callsRef.length === 1) return createEmptyStreamResult();
// Turn 2: content streams — confirms the replay actually reached
// the recreated provider.
return createStreamResult([{ type: "text-delta", text: "ok" }]);
},
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
const historyMessages = [{ role: "user", content: "prior recovered context" }];
const event = { sender: { id: 1 } };
try {
// Turn 1: stale-session recovery + empty response (flag stays set).
await streamHandler(event, {
requestId: "req-1",
chatSessionId: "chat-preserve",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "first turn",
providerId: undefined,
model: undefined,
existingSessionId: "stale-session",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
// Simulate the user toggling the MCP permission mode between turns.
// This flips shouldReuseProvider to false and forces recreation via
// the non-reset branch — exactly where the preserve-flag gap lived.
permissionMode = "auto";
await streamHandler(event, {
requestId: "req-2",
chatSessionId: "chat-preserve",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "second turn after permission change",
providerId: undefined,
model: undefined,
existingSessionId: "fresh-session",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
} finally {
restore();
}
assert.equal(streamCalls.length, 2);
// Turn 2 must include history + latest; regression would make it just 1.
assert.equal(
streamCalls[1].length,
2,
"second turn must re-replay compact history onto the recreated provider",
);
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
// 3 provider creations: stale attempt + first fallback + permission-change recreation.
assert.equal(providerCreationArgs.length, 3);
});
test("keeps replay fallback enabled after an empty recovered turn by retrying in a fresh ACP session", async () => {
const { bridge, streamCalls, providerCreationArgs, restore } = loadBridgeWithMocks({
streamText() {
return createEmptyStreamResult();
},
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
const streamHandler = ipcMain.handlers.get("netcatty:ai:acp:stream");
assert.equal(typeof streamHandler, "function");
const historyMessages = [{ role: "user", content: "prior recovered context" }];
const event = { sender: { id: 1 } };
try {
await streamHandler(event, {
requestId: "req-1",
chatSessionId: "chat-1",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "first recovered turn",
providerId: undefined,
model: undefined,
existingSessionId: "stale-session",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
await streamHandler(event, {
requestId: "req-2",
chatSessionId: "chat-1",
acpCommand: "fake-acp",
acpArgs: [],
prompt: "retry after empty response",
providerId: undefined,
model: undefined,
existingSessionId: "fresh-session",
historyMessages,
images: undefined,
toolIntegrationMode: "mcp",
defaultTargetSession: undefined,
userSkillsContext: undefined,
});
} finally {
restore();
}
assert.equal(streamCalls.length, 2);
assert.deepEqual(streamCalls[0][0], historyMessages[0]);
assert.deepEqual(streamCalls[1][0], historyMessages[0]);
assert.equal(providerCreationArgs.length, 3);
assert.equal("existingSessionId" in providerCreationArgs[0], true);
assert.equal(providerCreationArgs[0].existingSessionId, "stale-session");
assert.equal("existingSessionId" in providerCreationArgs[1], false);
assert.equal("existingSessionId" in providerCreationArgs[2], false);
});

View File

@@ -6,27 +6,78 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { exec } = require("node:child_process");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
/**
* Check if a file is hidden on Windows using the attrib command
* Returns true if the file has the hidden attribute set
* Uses async exec to avoid blocking the main process
* Parse the output of `attrib.exe <dir>\*` into a set of basenames whose
* `H` (hidden) flag is set. Exposed separately so the parser can be
* unit-tested without spawning a real subprocess.
*
* Example attrib output (one entry per line):
* A C:\path\file1.txt
* H C:\path\file2.txt
* A H R C:\path\file3.txt
* H C:\path\hidden_dir [DIR]
*/
async function isWindowsHiddenFile(filePath) {
if (process.platform !== "win32") return false;
function parseAttribOutput(stdout) {
const hidden = new Set();
for (const line of String(stdout).split(/\r?\n/)) {
if (!line) continue;
// Flags occupy the leading columns. Locate the path by the first
// drive letter ("C:\") or UNC prefix ("\\server\share"). The `\\\\`
// alternative has no leading anchor because attrib output has the
// path inside the line, not at column 0 (leading whitespace holds
// the attribute flags).
const pathStart = line.search(/[A-Za-z]:[\\/]|\\\\/);
if (pathStart < 0) continue;
const attrPart = line.substring(0, pathStart).toUpperCase();
if (!attrPart.includes("H")) continue;
const fullPath = line.substring(pathStart).trim();
// Some Windows versions append a trailing literal "[DIR]" marker
// when attrib is invoked with /d. Strip only that exact marker —
// not any arbitrary bracketed suffix — so legitimate filenames
// ending in brackets ("Notes [old]", "Draft [v2].md") survive
// intact and still get matched by hiddenSet.has(entry.name).
const cleaned = fullPath.replace(/\s+\[DIR\]\s*$/, "");
// Always use the win32 basename here — attrib output uses backslash
// separators, and the parser must work under CI on non-Windows hosts.
const basename = path.win32.basename(cleaned);
if (basename) hidden.add(basename);
}
return hidden;
}
/**
* Batch-list hidden filenames in a Windows directory.
*
* Previously we called `attrib` once per entry inside the concurrency
* worker loop. On a directory with ~800 files, that spawns ~800 subprocesses
* and takes ~30 s (see #766). One subprocess call with a wildcard returns
* the hidden attribute for every entry at once, so we replace the per-file
* check with a single upfront pass and a Set lookup in the worker.
*
* Returns the set of hidden basenames (empty on non-Windows or on failure).
*/
async function listWindowsHiddenBasenames(dirPath) {
if (process.platform !== "win32") return new Set();
try {
const { stdout } = await execAsync(`attrib "${filePath}"`);
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
// The attributes appear in the first ~10 characters before the path
const attrPart = stdout.substring(0, stdout.indexOf(filePath)).toUpperCase();
return attrPart.includes("H");
const pattern = path.join(dirPath, "*");
// `/d` is required so attrib.exe also reports directory entries —
// without it the wildcard is file-centric and hidden folders would
// be silently omitted from the set, causing the SFTP browser to
// show them as not-hidden (a regression from the per-file path
// that passed each entry's full path directly).
const { stdout } = await execFileAsync("attrib.exe", [pattern, "/d"], {
maxBuffer: 64 * 1024 * 1024,
windowsHide: true,
});
return parseAttribOutput(stdout);
} catch (err) {
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
return false;
console.warn(`[localFsBridge] Batch attrib failed for ${dirPath}:`, err.message);
return new Set();
}
}
@@ -37,9 +88,17 @@ async function isWindowsHiddenFile(filePath) {
*/
async function listLocalDir(event, payload) {
const dirPath = payload.path;
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
const isWindows = process.platform === "win32";
// Read directory entries and the Windows hidden-attribute set in
// parallel. The hidden lookup is a single subprocess that covers every
// entry in the directory; per-file attrib calls were the ~30 s hotspot
// that #766 reported on an 800-file directory.
const [entries, hiddenSet] = await Promise.all([
fs.promises.readdir(dirPath, { withFileTypes: true }),
isWindows ? listWindowsHiddenBasenames(dirPath) : Promise.resolve(new Set()),
]);
// Stat entries in parallel with a small concurrency limit.
// Serial stats can be very slow on Windows for large dirs.
const CONCURRENCY = 32;
@@ -70,8 +129,8 @@ async function listLocalDir(event, payload) {
type = "file";
}
// Check for Windows hidden attribute
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
// Windows hidden attribute: resolved from the batched lookup.
const hidden = isWindows ? hiddenSet.has(entry.name) : false;
result[i] = {
name: entry.name,
@@ -90,7 +149,7 @@ async function listLocalDir(event, payload) {
const lstat = await fs.promises.lstat(fullPath);
if (lstat.isSymbolicLink()) {
// Broken symlink
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
const hidden = isWindows ? hiddenSet.has(brokenEntry.name) : false;
result[i] = {
name: brokenEntry.name,
type: "symlink",
@@ -269,4 +328,6 @@ module.exports = {
getHomeDir,
getSystemInfo,
readKnownHosts,
parseAttribOutput,
listWindowsHiddenBasenames,
};

View File

@@ -0,0 +1,139 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { parseAttribOutput, listWindowsHiddenBasenames } = require("./localFsBridge.cjs");
test("parseAttribOutput returns an empty set for empty input", () => {
assert.equal(parseAttribOutput("").size, 0);
assert.equal(parseAttribOutput("\r\n\r\n").size, 0);
});
test("parseAttribOutput captures basenames of files with the H flag", () => {
const stdout = [
"A C:\\Users\\foo\\public.txt",
" H C:\\Users\\foo\\.secret",
"A H R C:\\Users\\foo\\hidden-readonly.exe",
"A C:\\Users\\foo\\another.log",
].join("\r\n");
const hidden = parseAttribOutput(stdout);
assert.deepEqual(
[...hidden].sort(),
[".secret", "hidden-readonly.exe"].sort(),
);
});
test("parseAttribOutput ignores the trailing [DIR] marker on some Windows versions", () => {
const stdout = [
" H C:\\data\\node_modules [DIR]",
" H C:\\data\\.git [DIR]",
"A C:\\data\\README.md",
].join("\r\n");
const hidden = parseAttribOutput(stdout);
assert.deepEqual([...hidden].sort(), [".git", "node_modules"].sort());
});
test("parseAttribOutput preserves filenames that legitimately end with bracketed suffixes", () => {
// Regression: a prior version stripped ANY trailing bracketed suffix
// via /\s+\[[^\]]+\]\s*$/, truncating "Notes [old]" to "Notes".
// Only the literal [DIR] marker that attrib emits with /d is a parser
// artifact; user-facing filenames with brackets must survive intact so
// hiddenSet.has(entry.name) still matches the actual readdir entry.
const stdout = [
" H C:\\data\\Notes [old]",
" H C:\\data\\Draft [v2].md",
" H C:\\data\\archived [2024]",
" H C:\\data\\node_modules [DIR]",
].join("\r\n");
const hidden = parseAttribOutput(stdout);
assert.deepEqual(
[...hidden].sort(),
["Draft [v2].md", "Notes [old]", "archived [2024]", "node_modules"].sort(),
);
});
test("parseAttribOutput handles UNC paths", () => {
const stdout = [
" H \\\\fileserver\\share\\secret.cfg",
"A \\\\fileserver\\share\\public.cfg",
].join("\r\n");
const hidden = parseAttribOutput(stdout);
assert.deepEqual([...hidden], ["secret.cfg"]);
});
test("parseAttribOutput skips malformed lines", () => {
const stdout = [
"Parameter format not correct",
"",
" H C:\\good\\hidden.txt",
"File not found",
" H not-a-windows-path.txt",
].join("\r\n");
const hidden = parseAttribOutput(stdout);
assert.deepEqual([...hidden], ["hidden.txt"]);
});
test("listWindowsHiddenBasenames returns an empty set on non-Windows without spawning anything", async () => {
// Running this test file is only meaningful on a non-Windows host for this
// assertion. On Windows CI we skip the subprocess-free guarantee.
if (process.platform === "win32") return;
const result = await listWindowsHiddenBasenames("/tmp");
assert.ok(result instanceof Set);
assert.equal(result.size, 0);
});
test("listWindowsHiddenBasenames invokes attrib.exe with /d so hidden directories aren't omitted", async () => {
// Regression: without `/d`, `attrib <dir>\*` treats the wildcard as
// file-centric and hidden directories (node_modules, .git, …) never
// reach parseAttribOutput — the SFTP browser then shows them as
// not-hidden, a behavior regression from the per-file implementation.
const Module = require("node:module");
const realChildProcess = require("node:child_process");
const originalLoad = Module._load;
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
let capturedArgs = null;
let capturedExecutable = null;
Module._load = function patchedLoad(request, parent, isMain) {
if (request === "node:child_process") {
return {
...realChildProcess,
execFile: (executable, args, _options, cb) => {
capturedExecutable = executable;
capturedArgs = args;
cb(null, { stdout: "", stderr: "" });
},
};
}
return originalLoad.call(this, request, parent, isMain);
};
Object.defineProperty(process, "platform", {
value: "win32",
writable: true,
configurable: true,
});
const bridgePath = require.resolve("./localFsBridge.cjs");
delete require.cache[bridgePath];
try {
const { listWindowsHiddenBasenames: fn } = require("./localFsBridge.cjs");
await fn("C:\\fixture");
} finally {
Module._load = originalLoad;
Object.defineProperty(process, "platform", originalPlatform);
delete require.cache[bridgePath];
}
assert.equal(capturedExecutable, "attrib.exe");
assert.ok(
Array.isArray(capturedArgs) && capturedArgs.includes("/d"),
`expected /d in attrib args so hidden directories are included, got ${JSON.stringify(capturedArgs)}`,
);
});

View File

@@ -0,0 +1,253 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const {
classifyProcessError,
createProcessErrorController,
installProcessErrorHandlers,
isNonFatalNetworkError,
} = require("./processErrorGuards.cjs");
test("treats Chromium ERR_NETWORK_CHANGED as non-fatal", () => {
assert.equal(
isNonFatalNetworkError(new Error("net::ERR_NETWORK_CHANGED")),
true,
);
});
test("treats other Chromium net::ERR_* failures as non-fatal network errors", () => {
assert.equal(
isNonFatalNetworkError(new Error("net::ERR_INTERNET_DISCONNECTED")),
true,
);
assert.equal(
isNonFatalNetworkError(new Error("net::ERR_NAME_NOT_RESOLVED")),
true,
);
});
test("treats Node socket error codes as non-fatal network errors", () => {
const err = new Error("socket reset");
err.code = "ECONNRESET";
assert.equal(isNonFatalNetworkError(err), true);
const dnsErr = new Error("dns failed");
dnsErr.code = "ENOTFOUND";
assert.equal(isNonFatalNetworkError(dnsErr), true);
});
test("keeps non-network errors fatal", () => {
assert.equal(
isNonFatalNetworkError(new Error("Something else broke")),
false,
);
});
test("generic startup exceptions stay fatal before the app is up", () => {
const result = classifyProcessError(new Error("boom"), {
runtimeStarted: false,
});
assert.equal(result.action, "fatal");
});
test("generic runtime exceptions are suppressed after startup", () => {
const result = classifyProcessError(new Error("boom"), {
runtimeStarted: true,
});
assert.equal(result.action, "suppress");
assert.match(result.reason, /runtime/i);
});
test("generic runtime promise rejections are also suppressed after startup", () => {
const result = classifyProcessError(new Error("promise boom"), {
runtimeStarted: true,
origin: "unhandledRejection",
});
assert.equal(result.action, "suppress");
assert.match(result.reason, /runtime/i);
});
test("controller keeps startup strict until the main window is actually shown", () => {
const controller = createProcessErrorController();
controller.beginMainWindowStartup();
assert.equal(controller.isRuntimeProtectionActive(), false);
controller.completeMainWindowStartup({ windowShown: true });
assert.equal(controller.isRuntimeProtectionActive(), true);
});
test("controller becomes strict again while recreating a missing main window", () => {
const controller = createProcessErrorController();
controller.beginMainWindowStartup();
controller.completeMainWindowStartup({ windowShown: true });
assert.equal(controller.isRuntimeProtectionActive(), true);
controller.beginMainWindowStartup();
assert.equal(controller.isRuntimeProtectionActive(), false);
controller.completeMainWindowStartup({ windowShown: false });
assert.equal(controller.isRuntimeProtectionActive(), true);
});
test("startup-period errors stay fatal while recreating the main window", () => {
const fakeProcess = new EventEmitter();
const fatals = [];
const controller = createProcessErrorController({
captureError() {},
onFatalError(err) {
fatals.push(err.message);
throw err;
},
logError() {},
logWarn() {},
});
installProcessErrorHandlers(fakeProcess, controller);
controller.completeMainWindowStartup({ windowShown: true });
controller.beginMainWindowStartup();
assert.throws(() => {
fakeProcess.emit("uncaughtException", new Error("recreate boom"));
}, /recreate boom/);
assert.deepEqual(fatals, ["recreate boom"]);
});
test("fatal startup failures uninstall listeners and keep throwing", () => {
const fakeProcess = new EventEmitter();
const captured = [];
const fatals = [];
let uninstall = null;
const controller = createProcessErrorController({
captureError(source, err) {
captured.push([source, err.message]);
},
onFatalError(err) {
fatals.push(err.message);
uninstall?.();
throw err;
},
logError() {},
logWarn() {},
});
uninstall = installProcessErrorHandlers(fakeProcess, controller);
assert.throws(() => {
fakeProcess.emit("uncaughtException", new Error("startup boom"));
}, /startup boom/);
assert.deepEqual(fatals, ["startup boom"]);
assert.deepEqual(captured, [["uncaughtException", "startup boom"]]);
assert.equal(fakeProcess.listenerCount("uncaughtException"), 0);
assert.equal(fakeProcess.listenerCount("unhandledRejection"), 0);
});
test("installed handlers suppress runtime failures after startup", () => {
const fakeProcess = new EventEmitter();
const captured = [];
const errors = [];
const warnings = [];
const controller = createProcessErrorController({
captureError(source, err) {
captured.push([source, err.message]);
},
onFatalError(err) {
throw err;
},
logError(...args) {
errors.push(args.map(String).join(" "));
},
logWarn(...args) {
warnings.push(args.map(String).join(" "));
},
});
installProcessErrorHandlers(fakeProcess, controller);
controller.beginMainWindowStartup();
controller.completeMainWindowStartup({ windowShown: true });
fakeProcess.emit("uncaughtException", new Error("runtime boom"));
fakeProcess.emit("unhandledRejection", new Error("runtime rejection"));
assert.deepEqual(captured, [
["uncaughtException", "runtime boom"],
["unhandledRejection", "runtime rejection"],
]);
assert.equal(errors.some((line) => line.includes("runtime error after startup")), true);
assert.equal(warnings.length, 0);
});
test("unhandled rejection marks the forwarded error so uncaught follow-up is not double-captured", () => {
const captured = [];
const fatals = [];
const controller = createProcessErrorController({
captureError(source, err) {
captured.push([source, err.message]);
},
onFatalError(err) {
fatals.push(err);
},
logError() {},
logWarn() {},
});
controller.handleUnhandledRejection(new Error("startup rejection"));
assert.equal(fatals.length, 1);
assert.equal(fatals[0].__fromUnhandledRejection, true);
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
controller.handleUncaughtException(fatals[0]);
assert.deepEqual(captured, [["unhandledRejection", "startup rejection"]]);
});
test("benign stream teardown errors are ignored by the installed handlers", () => {
const fakeProcess = new EventEmitter();
let captureCount = 0;
let fatalCount = 0;
const controller = createProcessErrorController({
captureError() {
captureCount += 1;
},
onFatalError() {
fatalCount += 1;
},
logError() {},
logWarn() {},
});
installProcessErrorHandlers(fakeProcess, controller);
const err = new Error("broken pipe");
err.code = "EPIPE";
fakeProcess.emit("uncaughtException", err);
assert.equal(captureCount, 0);
assert.equal(fatalCount, 0);
});
test("controller suppresses wrapped network errors from err.cause", () => {
const err = new Error("request failed");
err.cause = new Error("net::ERR_NETWORK_CHANGED");
const result = classifyProcessError(err, {
runtimeStarted: false,
});
assert.equal(isNonFatalNetworkError(err), true);
assert.equal(result.action, "suppress");
});
test("controller suppresses ssh-style errors with a level property", () => {
const err = new Error("connection lost before handshake");
err.level = "client-socket";
const result = classifyProcessError(err, {
runtimeStarted: false,
});
assert.equal(isNonFatalNetworkError(err), true);
assert.equal(result.action, "suppress");
});

View File

@@ -0,0 +1,193 @@
function isNonFatalNetworkError(err) {
if (!err) return false;
// Any error with an ssh2 `level` property is a connection/auth-level error,
// never a reason to kill the entire multi-session app.
if (err.level) return true;
const candidates = [err, err.cause].filter(Boolean);
for (const candidate of candidates) {
const code = candidate.code;
// Common TCP/DNS/routing errors that can surface from Node.js sockets
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
switch (code) {
case "ECONNRESET":
case "ECONNREFUSED":
case "ECONNABORTED":
case "ETIMEDOUT":
case "ENOTFOUND":
case "EHOSTUNREACH":
case "EHOSTDOWN":
case "ENETUNREACH":
case "ENETDOWN":
case "EADDRNOTAVAIL":
case "EPROTO":
case "EPERM":
return true;
default:
break;
}
// Chromium/Electron networking often rejects with a message like
// "net::ERR_NETWORK_CHANGED" but without a useful `code` property.
// These are transport failures for background fetch/update/sync work,
// not reasons to kill the whole app.
const message = String(candidate.message || "");
if (/net::ERR_(?:NETWORK_[A-Z_]+|INTERNET_DISCONNECTED|NAME_NOT_RESOLVED|CONNECTION_[A-Z_]+|ADDRESS_[A-Z_]+|SSL_[A-Z_]+|CERT_[A-Z_]+|PROXY_[A-Z_]+|TUNNEL_[A-Z_]+|SOCKS_[A-Z_]+)/.test(message)) {
return true;
}
}
return false;
}
function isBenignStreamError(err) {
const code = err?.code;
return code === "EPIPE" || code === "ERR_STREAM_DESTROYED";
}
function classifyProcessError(err, options = {}) {
const runtimeStarted = options.runtimeStarted === true;
if (isBenignStreamError(err)) {
return {
action: "ignore",
reason: "benign stream teardown",
};
}
if (isNonFatalNetworkError(err)) {
return {
action: "suppress",
reason: "non-fatal network error",
};
}
if (runtimeStarted) {
return {
action: "suppress",
reason: "runtime error after startup",
};
}
return {
action: "fatal",
reason: "startup error before app became usable",
};
}
function createProcessErrorController(options = {}) {
const captureError = typeof options.captureError === "function" ? options.captureError : () => {};
const onFatalError = typeof options.onFatalError === "function"
? options.onFatalError
: (err) => { throw err; };
const logError = typeof options.logError === "function" ? options.logError : (...args) => console.error(...args);
const logWarn = typeof options.logWarn === "function" ? options.logWarn : (...args) => console.warn(...args);
let hasShownMainWindow = false;
let pendingMainWindowStartupCount = 0;
const isRuntimeProtectionActive = () => (
hasShownMainWindow && pendingMainWindowStartupCount === 0
);
const beginMainWindowStartup = () => {
pendingMainWindowStartupCount += 1;
};
const completeMainWindowStartup = ({ windowShown = false } = {}) => {
if (pendingMainWindowStartupCount > 0) {
pendingMainWindowStartupCount -= 1;
}
if (windowShown) {
hasShownMainWindow = true;
}
};
const handleUncaughtException = (err) => {
const decision = classifyProcessError(err, {
runtimeStarted: isRuntimeProtectionActive(),
origin: "uncaughtException",
});
if (decision.action === "ignore") {
logWarn("Ignored process error:", decision.reason, err?.code || err?.message || err);
return;
}
if (decision.action === "suppress") {
if (!err?.__fromUnhandledRejection) {
captureError("uncaughtException", err);
}
logError(`Suppressed uncaught exception (${decision.reason}):`, err);
return;
}
if (!err?.__fromUnhandledRejection) {
captureError("uncaughtException", err);
}
onFatalError(err, {
origin: "uncaughtException",
decision,
reason: err,
});
};
const handleUnhandledRejection = (reason) => {
const decision = classifyProcessError(reason, {
runtimeStarted: isRuntimeProtectionActive(),
origin: "unhandledRejection",
});
if (decision.action === "ignore") {
return;
}
if (decision.action === "suppress") {
captureError("unhandledRejection", reason);
logError(`Suppressed unhandled rejection (${decision.reason}):`, reason);
return;
}
captureError("unhandledRejection", reason);
const err = reason instanceof Error ? reason : new Error(String(reason));
err.__fromUnhandledRejection = true;
onFatalError(err, {
origin: "unhandledRejection",
decision,
reason,
});
};
return {
beginMainWindowStartup,
completeMainWindowStartup,
handleUncaughtException,
handleUnhandledRejection,
isRuntimeProtectionActive,
};
}
function installProcessErrorHandlers(processObject, controller) {
if (!processObject?.on || !processObject?.removeListener) {
throw new Error("A process-like EventEmitter is required");
}
if (!controller?.handleUncaughtException || !controller?.handleUnhandledRejection) {
throw new Error("A process error controller is required");
}
processObject.on("uncaughtException", controller.handleUncaughtException);
processObject.on("unhandledRejection", controller.handleUnhandledRejection);
return () => {
processObject.removeListener("uncaughtException", controller.handleUncaughtException);
processObject.removeListener("unhandledRejection", controller.handleUnhandledRejection);
};
}
module.exports = {
classifyProcessError,
createProcessErrorController,
installProcessErrorHandlers,
isBenignStreamError,
isNonFatalNetworkError,
};

View File

@@ -0,0 +1,89 @@
const { execFile } = require("node:child_process");
function createProcessTree({ platform, listPosix, listWindows } = {}) {
const sessionPidMap = new Map();
function registerPid(sessionId, pid) {
if (!sessionId || typeof pid !== "number") return;
if (sessionPidMap.has(sessionId) && sessionPidMap.get(sessionId) !== pid) {
console.warn(
`[ptyProcessTree] sessionId "${sessionId}" already registered with pid ${sessionPidMap.get(sessionId)}; overwriting with ${pid}.`,
);
}
sessionPidMap.set(sessionId, pid);
}
function unregisterPid(sessionId) {
sessionPidMap.delete(sessionId);
}
async function getChildProcesses(sessionId) {
const pid = sessionPidMap.get(sessionId);
if (!pid) return [];
if (platform === "win32") {
return listWindows ? listWindows(pid) : [];
}
return listPosix ? listPosix(pid) : [];
}
return { registerPid, unregisterPid, getChildProcesses };
}
function defaultListPosix(ppid) {
return new Promise((resolve) => {
// `ps -A -o pid=,ppid=,args=` works on both BSD (macOS) and GNU (Linux).
// `args=` shows the full command line (not truncated like `comm=`).
// The trailing `=` on each column suppresses the header row.
execFile("ps", ["-A", "-o", "pid=,ppid=,args="], (err, stdout) => {
if (err || typeof stdout !== "string") return resolve([]);
const out = [];
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(.+)$/);
if (!m) continue;
if (Number(m[2]) !== ppid) continue;
out.push({ pid: Number(m[1]), command: m[3].trim() });
}
resolve(out);
});
});
}
function defaultListWindows(ppid) {
return new Promise((resolve) => {
let wpt;
try {
wpt = require("@vscode/windows-process-tree");
} catch {
return resolve([]);
}
try {
wpt.getProcessTree(ppid, (tree) => {
if (!tree || !Array.isArray(tree.children)) return resolve([]);
resolve(tree.children.map((c) => ({ pid: c.pid, command: c.name })));
});
} catch {
resolve([]);
}
});
}
function createDefaultProcessTree() {
const platform = process.platform;
return createProcessTree({
platform,
listPosix: platform === "win32" ? undefined : defaultListPosix,
listWindows: platform === "win32" ? defaultListWindows : undefined,
});
}
const defaultTree = createDefaultProcessTree();
module.exports = {
createProcessTree,
processTree: defaultTree,
registerPid: (id, pid) => defaultTree.registerPid(id, pid),
unregisterPid: (id) => defaultTree.unregisterPid(id),
getChildProcesses: (id) => defaultTree.getChildProcesses(id),
};

View File

@@ -0,0 +1,79 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createProcessTree } = require("./ptyProcessTree.cjs");
test("getChildProcesses returns [] when session has no registered pid", async () => {
const tree = createProcessTree({ platform: "darwin", listPosix: async () => [] });
assert.deepEqual(await tree.getChildProcesses("unknown-session"), []);
});
test("getChildProcesses calls listPosix with the registered ppid and returns its result", async () => {
const calls = [];
const listPosix = async (ppid) => {
calls.push(ppid);
return [
{ pid: 2001, command: "sleep 100" },
{ pid: 2002, command: "node server.js" },
];
};
const tree = createProcessTree({ platform: "linux", listPosix });
tree.registerPid("s1", 1234);
assert.deepEqual(await tree.getChildProcesses("s1"), [
{ pid: 2001, command: "sleep 100" },
{ pid: 2002, command: "node server.js" },
]);
assert.deepEqual(calls, [1234]);
});
test("unregisterPid clears mapping", async () => {
const tree = createProcessTree({
platform: "darwin",
listPosix: async () => [{ pid: 9, command: "x" }],
});
tree.registerPid("s1", 1234);
tree.unregisterPid("s1");
assert.deepEqual(await tree.getChildProcesses("s1"), []);
});
test("getChildProcesses on windows uses listWindows", async () => {
const calls = [];
const listWindows = async (pid) => {
calls.push(pid);
return [{ pid: 55, command: "python.exe" }];
};
const tree = createProcessTree({ platform: "win32", listWindows });
tree.registerPid("s1", 3000);
assert.deepEqual(await tree.getChildProcesses("s1"), [{ pid: 55, command: "python.exe" }]);
assert.deepEqual(calls, [3000]);
});
test("getChildProcesses returns [] when listPosix missing on posix", async () => {
const tree = createProcessTree({ platform: "darwin" });
tree.registerPid("s1", 1234);
assert.deepEqual(await tree.getChildProcesses("s1"), []);
});
test("getChildProcesses returns [] when listWindows missing on windows", async () => {
const tree = createProcessTree({ platform: "win32" });
tree.registerPid("s1", 3000);
assert.deepEqual(await tree.getChildProcesses("s1"), []);
});
test("registerPid warns when overwriting an existing sessionId with a different pid", async () => {
const warnCalls = [];
const origWarn = console.warn;
console.warn = (...args) => warnCalls.push(args);
try {
const tree = createProcessTree({ platform: "darwin", listPosix: async () => [] });
tree.registerPid("s1", 1234);
tree.registerPid("s1", 1234); // same pid — no warn
tree.registerPid("s1", 5678); // different — should warn
assert.equal(warnCalls.length, 1);
assert.match(warnCalls[0][0], /s1/);
assert.match(warnCalls[0][0], /1234/);
assert.match(warnCalls[0][0], /5678/);
} finally {
console.warn = origWarn;
}
});

View File

@@ -0,0 +1,46 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const Protocol = require("ssh2/lib/protocol/Protocol");
function parseIdentification(line) {
let header;
const protocol = new Protocol({
onWrite() {},
onError(err) {
throw err;
},
onHeader(nextHeader) {
header = nextHeader;
},
});
const data = Buffer.from(`${line}\r\n`, "latin1");
protocol.parse(data, 0, data.length);
assert.ok(header, "expected SSH header to be parsed");
return header;
}
test("ssh2 accepts an empty softwareversion for compatibility", () => {
const header = parseIdentification("SSH-2.0-");
assert.equal(header.versions.protocol, "2.0");
assert.equal(header.versions.software, "");
assert.equal(header.comments, undefined);
});
test("ssh2 still accepts standard identification strings", () => {
const header = parseIdentification("SSH-2.0-OpenSSH_9.9 Netcatty");
assert.equal(header.versions.protocol, "2.0");
assert.equal(header.versions.software, "OpenSSH_9.9");
assert.equal(header.comments, "Netcatty");
});
test("ssh2 still rejects malformed identification strings", () => {
assert.throws(
() => parseIdentification("SSH-2.0"),
/Invalid identification string/,
);
});

View File

@@ -10,6 +10,7 @@ const path = require("node:path");
const { StringDecoder } = require("node:string_decoder");
const pty = require("node-pty");
const { SerialPort } = require("serialport");
const ptyProcessTree = require("./ptyProcessTree.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { detectShellKind } = require("./ai/ptyExec.cjs");
@@ -326,6 +327,7 @@ function startLocalSession(event, payload) {
_promptTrackTail: "",
};
sessions.set(sessionId, session);
ptyProcessTree.registerPid(sessionId, proc.pid);
// Start real-time session log stream if configured
if (payload?.sessionLog?.enabled && payload?.sessionLog?.directory) {
@@ -382,6 +384,7 @@ function startLocalSession(event, payload) {
proc.onExit((evt) => {
flushLocal();
sessionLogStreamManager.stopStream(sessionId);
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
// Signal present = killed externally (show disconnected UI).
@@ -648,6 +651,7 @@ async function startTelnetSession(event, options) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
}
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
}
});
@@ -664,6 +668,7 @@ async function startTelnetSession(event, options) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
}
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
});
@@ -802,6 +807,7 @@ async function startMoshSession(event, options) {
proc.onExit((evt) => {
flushMosh();
sessionLogStreamManager.stopStream(sessionId);
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
// Mosh non-zero exit typically means connection/auth failure — show error UI
@@ -931,6 +937,7 @@ async function startSerialSession(event, options) {
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
});
@@ -940,6 +947,7 @@ async function startSerialSession(event, options) {
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
});
@@ -1043,6 +1051,7 @@ function closeSession(event, payload) {
} catch (err) {
console.warn("Close failed", err);
}
ptyProcessTree.unregisterPid(payload.sessionId);
sessions.delete(payload.sessionId);
}
@@ -1166,6 +1175,9 @@ function cleanupAllSessions() {
// Ignore cleanup errors
}
}
for (const [sessionId] of sessions) {
ptyProcessTree.unregisterPid(sessionId);
}
sessions.clear();
}

View File

@@ -97,6 +97,7 @@ function toBackupSummary(record) {
id: record.id,
createdAt: record.createdAt,
reason: record.reason,
syncDataVersion: record.syncDataVersion,
sourceAppVersion: record.sourceAppVersion,
targetAppVersion: record.targetAppVersion,
preview: record.preview,
@@ -131,6 +132,15 @@ function sanitizeOptionalVersionString(value) {
return trimmed;
}
// Sync data version is the integer that the CloudSyncManager increments
// on each successful cloud sync. Reject anything non-finite, non-positive,
// or non-integer so the persisted record only carries meaningful values.
function sanitizeOptionalSyncDataVersion(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
if (value < 1) return undefined;
return Math.floor(value);
}
// UTF-8 byte length of a payload's JSON serialization. Earlier revisions
// returned `JSON.stringify(payload).length` (UTF-16 code units), which
// under-counted by ~3x for non-ASCII vaults — a deck full of CJK snippet
@@ -415,6 +425,7 @@ function createVaultBackupService({ app, safeStorage, shell }) {
id,
createdAt,
reason: sanitizeReason(options.reason),
syncDataVersion: sanitizeOptionalSyncDataVersion(options.syncDataVersion),
sourceAppVersion: sanitizeOptionalVersionString(options.sourceAppVersion),
targetAppVersion: sanitizeOptionalVersionString(options.targetAppVersion),
fingerprint,

View File

@@ -417,6 +417,70 @@ test("createBackup accepts a legitimate SemVer-ish version string", async () =>
}
});
test("createBackup persists syncDataVersion when given a positive integer", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const result = await service.createBackup({
payload: samplePayload(),
reason: "before_restore",
syncDataVersion: 5,
});
assert.equal(result.created, true);
assert.equal(result.backup.syncDataVersion, 5);
// Round-trip via list
const listed = await service.listBackups();
assert.equal(listed[0].syncDataVersion, 5);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("createBackup drops invalid syncDataVersion values (zero, negative, non-finite, non-numeric)", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const cases = [0, -1, NaN, Infinity, "5", null, undefined];
let idx = 0;
for (const syncDataVersion of cases) {
// Vary an actual content-bearing field to avoid fingerprint dedupe
// (top-level syncedAt is normalized away in the fingerprint).
const payload = samplePayload({
hosts: [{ ...samplePayload().hosts[0], id: `h-case-${idx}` }],
});
const result = await service.createBackup({
payload,
reason: "before_restore",
syncDataVersion,
});
assert.equal(result.created, true, `iteration ${idx}: created should be true`);
assert.equal(result.backup.syncDataVersion, undefined, `value ${String(syncDataVersion)} should be dropped`);
idx += 1;
}
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("createBackup floors a fractional syncDataVersion", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const result = await service.createBackup({
payload: samplePayload(),
reason: "before_restore",
syncDataVersion: 7.9,
});
assert.equal(result.backup.syncDataVersion, 7);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("createBackup rejects an array payload (not an object)", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);

View File

@@ -36,6 +36,9 @@ let menuDeps = null;
let electronApp = null; // Reference to Electron app for userData path
let isQuitting = false;
const rendererReadyCallbacksByWebContentsId = new Map();
const rendererReadySeenByWebContentsId = new Set();
const rendererReadyWaitersByWebContentsId = new Map();
const unhealthyWebContentsIds = new Set();
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
const OAUTH_DEFAULT_WIDTH = 600;
const OAUTH_DEFAULT_HEIGHT = 700;
@@ -791,6 +794,128 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
return { showOnce, markRendererReady };
}
function resolveRendererReady(wcId) {
if (!wcId) return;
unhealthyWebContentsIds.delete(wcId);
rendererReadySeenByWebContentsId.add(wcId);
const cb = rendererReadyCallbacksByWebContentsId.get(wcId);
if (cb) cb();
const waiters = rendererReadyWaitersByWebContentsId.get(wcId);
if (!waiters || waiters.size === 0) return;
rendererReadyWaitersByWebContentsId.delete(wcId);
for (const resolve of waiters) {
try {
resolve();
} catch {
// ignore waiter errors
}
}
}
function isWindowUsable(win, options = {}) {
const requireVisible = options.requireVisible === true;
if (!win || typeof win.isDestroyed !== "function" || win.isDestroyed()) {
return false;
}
if (requireVisible) {
if (typeof win.isVisible !== "function") return false;
try {
if (!win.isVisible()) return false;
} catch {
return false;
}
}
const contents = win.webContents;
if (!contents || typeof contents.isDestroyed !== "function" || contents.isDestroyed()) {
return false;
}
const wcId = (() => {
try {
return contents.id;
} catch {
return null;
}
})();
if (wcId && unhealthyWebContentsIds.has(wcId)) {
return false;
}
if (typeof contents.isCrashed === "function") {
try {
if (contents.isCrashed()) return false;
} catch {
return false;
}
}
return true;
}
function waitForRendererReady(win, { timeoutMs = 15000 } = {}) {
return new Promise((resolve, reject) => {
const wcId = (() => {
try {
return win?.webContents?.id;
} catch {
return null;
}
})();
if (!win || win.isDestroyed?.() || !wcId) {
reject(new Error("Main window is unavailable before renderer ready."));
return;
}
if (rendererReadySeenByWebContentsId.has(wcId)) {
resolve();
return;
}
let timer = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
timer = null;
try { win.removeListener("closed", handleClosed); } catch {}
try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {}
const waiters = rendererReadyWaitersByWebContentsId.get(wcId);
if (waiters) {
waiters.delete(handleReady);
if (waiters.size === 0) {
rendererReadyWaitersByWebContentsId.delete(wcId);
}
}
};
const handleReady = () => {
cleanup();
resolve();
};
const handleClosed = () => {
cleanup();
reject(new Error("Main window closed before renderer became ready."));
};
const handleGone = (_event, details) => {
cleanup();
reject(new Error(`Renderer process exited before ready: ${details?.reason || "unknown"}`));
};
let waiters = rendererReadyWaitersByWebContentsId.get(wcId);
if (!waiters) {
waiters = new Set();
rendererReadyWaitersByWebContentsId.set(wcId, waiters);
}
waiters.add(handleReady);
win.once("closed", handleClosed);
win.webContents?.once?.("render-process-gone", handleGone);
if (Number(timeoutMs) > 0) {
timer = setTimeout(() => {
cleanup();
reject(new Error("Renderer did not report ready before timeout."));
}, timeoutMs);
}
});
}
/**
* Create the main application window
*/
@@ -869,12 +994,27 @@ async function createWindow(electronModule, options) {
// Clear reference when the main window is destroyed
win.on('closed', () => {
try {
if (win?.webContents?.id) {
unhealthyWebContentsIds.delete(win.webContents.id);
rendererReadySeenByWebContentsId.delete(win.webContents.id);
}
} catch {
// ignore
}
if (mainWindow === win) mainWindow = null;
});
// Log renderer crashes for diagnostics (skip normal clean exits)
win.webContents.on("render-process-gone", (_event, details) => {
if (details?.reason === "clean-exit") return;
try {
if (win.webContents?.id) {
unhealthyWebContentsIds.add(win.webContents.id);
}
} catch {
// ignore
}
try {
const crashLogBridge = require("./crashLogBridge.cjs");
crashLogBridge.captureError("render-process-gone", new Error(
@@ -1097,14 +1237,62 @@ async function createWindow(electronModule, options) {
/**
* Create or focus the settings window
*/
/**
* Show + reliably focus a window's renderer. Works around two Windows-specific
* Electron quirks that surface when a prewarmed/hidden window is later shown
* (see issue #760):
*
* 1. SetForegroundWindow restrictions: `BrowserWindow.focus()` invoked from
* a non-foreground process is often silently rejected by Windows. The
* window appears on top but never receives true OS foreground focus, so
* `document.hasFocus()` stays false in the renderer.
* 2. Chromium suppresses the input caret + keyboard routing whenever
* `document.hasFocus()` is false, even if an `<input>` is the active
* element. The classic symptom: clicking an input selects/deletes work
* but the caret never blinks and typed characters don't appear.
*
* The alwaysOnTop toggle is the established workaround for (1); explicitly
* calling `webContents.focus()` covers (2) so the renderer marks the page as
* focused regardless of whether the OS granted foreground.
*/
function showAndFocusWindow(win) {
if (!win || win.isDestroyed()) return;
try {
win.show();
} catch {
// ignore
}
if (process.platform === "win32") {
try {
win.setAlwaysOnTop(true);
win.focus();
win.setAlwaysOnTop(false);
} catch {
// ignore
}
} else {
try {
win.focus();
} catch {
// ignore
}
}
try {
if (win.webContents && !win.webContents.isDestroyed()) {
win.webContents.focus();
}
} catch {
// ignore
}
}
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
const { BrowserWindow, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
// If settings window already exists, show and focus it
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.show();
settingsWindow.focus();
showAndFocusWindow(settingsWindow);
return settingsWindow;
}
@@ -1264,7 +1452,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
try {
const baseUrl = getDevRendererBaseUrl(devServerUrl);
await win.loadURL(`${baseUrl}${settingsPath}`);
if (showOnLoad) { win.show(); win.focus(); }
if (showOnLoad) { showAndFocusWindow(win); }
return win;
} catch (e) {
console.warn("Dev server not reachable for settings window", e);
@@ -1273,7 +1461,7 @@ async function openSettingsWindow(electronModule, options, { showOnLoad = true }
// Production mode - load via custom protocol.
await win.loadURL("app://netcatty/index.html#/settings");
if (showOnLoad) { win.show(); win.focus(); }
if (showOnLoad) { showAndFocusWindow(win); }
return win;
}
@@ -1467,8 +1655,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
ipcMain.on("netcatty:renderer:ready", (event) => {
const wcId = event?.sender?.id;
if (!wcId) return;
const cb = rendererReadyCallbacksByWebContentsId.get(wcId);
if (cb) cb();
resolveRendererReady(wcId);
});
}
@@ -1558,6 +1745,8 @@ module.exports = {
buildAppMenu,
getMainWindow,
getSettingsWindow,
isWindowUsable,
waitForRendererReady,
setIsQuitting,
openFallbackBrowser,
tryOpenExternalWithFallback,

View File

@@ -0,0 +1,67 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { isWindowUsable } = require("./windowManager.cjs");
function createWindowStub({ destroyed = false, webContents } = {}) {
return {
isDestroyed() {
return destroyed;
},
isVisible() {
return true;
},
webContents,
};
}
test("isWindowUsable returns false when webContents is crashed", () => {
const win = createWindowStub({
webContents: {
isDestroyed() {
return false;
},
isCrashed() {
return true;
},
},
});
assert.equal(isWindowUsable(win), false);
});
test("isWindowUsable returns true for a healthy live window", () => {
const win = createWindowStub({
webContents: {
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
},
});
assert.equal(isWindowUsable(win), true);
});
test("isWindowUsable can require a visible window", () => {
const hiddenWin = {
...createWindowStub({
webContents: {
isDestroyed() {
return false;
},
isCrashed() {
return false;
},
},
}),
isVisible() {
return false;
},
};
assert.equal(isWindowUsable(hiddenWin, { requireVisible: true }), false);
assert.equal(isWindowUsable(hiddenWin, { requireVisible: false }), true);
});

View File

@@ -20,79 +20,31 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
// Load crash log bridge early so process-level error handlers can use it
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
// SSH / network errors that must never crash the process.
// ssh2 can emit multiple 'error' events per connection (e.g. ECONNRESET followed
// by "Connection lost before handshake"). If a listener is consumed after the first
// event, the second becomes an uncaught exception. These are non-fatal for the app.
function isNonFatalNetworkError(err) {
if (!err) return false;
// Any error with an ssh2 `level` property is a connection/auth-level error,
// never a reason to kill the entire multi-session app.
if (err.level) return true;
const code = err.code;
// Common TCP/DNS/routing errors that can surface from Node.js sockets
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
switch (code) {
case 'ECONNRESET':
case 'ECONNREFUSED':
case 'ECONNABORTED':
case 'ETIMEDOUT':
case 'ENOTFOUND':
case 'EHOSTUNREACH':
case 'EHOSTDOWN':
case 'ENETUNREACH':
case 'ENETDOWN':
case 'EADDRNOTAVAIL':
case 'EPROTO':
case 'EPERM':
return true;
default:
return false;
}
}
// Handle uncaught exceptions — log all, only re-throw truly fatal ones
process.on('uncaughtException', (err) => {
// Skip benign stream teardown errors — don't pollute crash logs with false positives
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
console.warn('Ignored stream error:', err.code);
return;
}
// Non-fatal SSH/network errors: log but do NOT crash the process
if (isNonFatalNetworkError(err)) {
if (!err.__fromUnhandledRejection) {
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
const {
createProcessErrorController,
installProcessErrorHandlers,
} = require("./bridges/processErrorGuards.cjs");
const processErrorController = createProcessErrorController({
captureError(source, err) {
try { crashLogBridge.captureError(source, err); } catch {}
},
onFatalError(err, context) {
uninstallProcessErrorHandlers();
if (context?.origin === 'unhandledRejection') {
console.error('Unhandled rejection:', context.reason);
} else {
console.error('Uncaught exception:', err);
}
console.warn('Non-fatal uncaught exception (suppressed):', err.message);
return;
}
// Skip logging if already captured by unhandledRejection handler
if (!err.__fromUnhandledRejection) {
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
}
console.error('Uncaught exception:', err);
throw err;
});
process.on('unhandledRejection', (reason) => {
// Skip benign stream teardown errors
const code = reason?.code;
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
// Non-fatal SSH/network errors: log but do NOT re-throw
if (isNonFatalNetworkError(reason)) {
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
console.warn('Non-fatal unhandled rejection (suppressed):', reason?.message || reason);
return;
}
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
console.error('Unhandled rejection:', reason);
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
// can skip duplicate logging.
const err = reason instanceof Error ? reason : new Error(String(reason));
err.__fromUnhandledRejection = true;
throw err;
throw err;
},
logError(...args) {
console.error(...args);
},
logWarn(...args) {
console.warn(...args);
},
});
let uninstallProcessErrorHandlers = installProcessErrorHandlers(process, processErrorController);
// Load Electron
let electronModule;
@@ -165,6 +117,7 @@ const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
const getVaultBackupBridge = createLazyModule("./bridges/vaultBackupBridge.cjs");
const ptyProcessTree = require("./bridges/ptyProcessTree.cjs");
// GPU settings
// NOTE: Do not disable Chromium sandbox by default.
@@ -683,6 +636,40 @@ const registerBridges = (win) => {
};
});
// PTY child process list for busy-check before close
ipcMain.handle("netcatty:pty:childProcesses", async (_event, sessionId) => {
if (typeof sessionId !== "string") return [];
return ptyProcessTree.getChildProcesses(sessionId);
});
// Native confirmation dialog when closing a session with a running process
// Returns true only if the user explicitly clicks "Close". ESC/dialog-dismiss
// resolves as cancelId (0) → false, which is the safe default (do not close).
ipcMain.handle(
"netcatty:dialog:confirmCloseBusy",
async (event, payload) => {
const command = typeof payload?.command === "string" ? payload.command : "unknown";
const title = typeof payload?.title === "string" ? payload.title : "Confirm close";
const message = typeof payload?.message === "string"
? payload.message
: `Process "${command}" is still running and will be terminated.`;
const cancelLabel = typeof payload?.cancelLabel === "string" ? payload.cancelLabel : "Cancel";
const closeLabel = typeof payload?.closeLabel === "string" ? payload.closeLabel : "Close";
const { dialog } = electronModule;
const win = BrowserWindow.fromWebContents(event.sender);
const { response } = await dialog.showMessageBox(win || undefined, {
type: "warning",
title,
message,
buttons: [cancelLabel, closeLabel],
defaultId: 0,
cancelId: 0,
noLink: true,
});
return response === 1; // true = user picked Close
},
);
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
ipcMain.handle("netcatty:clipboard:readText", async () => {
try {
@@ -978,6 +965,80 @@ async function createWindow() {
return win;
}
function waitForWindowToShow(win) {
return new Promise((resolve, reject) => {
if (!win || win.isDestroyed?.()) {
reject(new Error("Main window was destroyed before first show."));
return;
}
if (win.isVisible?.()) {
resolve();
return;
}
const cleanup = () => {
try { win.removeListener("show", handleShow); } catch {}
try { win.removeListener("closed", handleClosed); } catch {}
try { win.webContents?.removeListener?.("render-process-gone", handleGone); } catch {}
};
const handleShow = () => {
cleanup();
resolve();
};
const handleClosed = () => {
cleanup();
reject(new Error("Main window closed before first show."));
};
const handleGone = (_event, details) => {
cleanup();
reject(new Error(`Renderer process exited before first show: ${details?.reason || "unknown"}`));
};
win.once("show", handleShow);
win.once("closed", handleClosed);
win.webContents?.once?.("render-process-gone", handleGone);
});
}
let mainWindowStartupPromise = null;
async function createAndShowMainWindow() {
if (mainWindowStartupPromise) return mainWindowStartupPromise;
mainWindowStartupPromise = (async () => {
processErrorController.beginMainWindowStartup();
try {
const win = await createWindow();
await waitForWindowToShow(win);
void getWindowManager().waitForRendererReady(win, {
timeoutMs: isDev ? 30000 : 15000,
}).catch((err) => {
console.warn("[Main] Renderer ready signal was late or missing after first show:", err?.message || err);
});
processErrorController.completeMainWindowStartup({ windowShown: true });
return win;
} catch (err) {
processErrorController.completeMainWindowStartup({ windowShown: false });
throw err;
} finally {
mainWindowStartupPromise = null;
}
})();
return mainWindowStartupPromise;
}
function hasUsableWindow() {
try {
const windowManager = getWindowManager();
return [windowManager.getMainWindow?.(), windowManager.getSettingsWindow?.()]
.some((win) => windowManager.isWindowUsable?.(win, { requireVisible: true }));
} catch {
return false;
}
}
function showStartupError(err) {
const title = "Netcatty";
const code = err && typeof err === "object" ? err.code : null;
@@ -1003,9 +1064,12 @@ if (!gotLock) {
app.on("second-instance", () => {
if (!focusMainWindow()) {
// Window is missing or crashed — try to recreate it
void createWindow().catch((err) => {
void createAndShowMainWindow().catch((err) => {
console.error("[Main] Failed to recreate window on second-instance:", err);
showStartupError(err);
if (!hasUsableWindow()) {
try { app.quit(); } catch {}
}
});
}
});
@@ -1023,9 +1087,17 @@ if (!gotLock) {
}
}
// Build and set application menu
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
Menu.setApplicationMenu(menu);
// Build and set application menu. A broken menu should not take down
// the entire app — fall back to no custom menu and continue startup.
try {
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
Menu.setApplicationMenu(menu);
} catch (err) {
console.error("[Main] Failed to build application menu:", err);
try {
Menu.setApplicationMenu(null);
} catch {}
}
app.on("browser-window-created", (_event, win) => {
try {
@@ -1045,7 +1117,7 @@ if (!gotLock) {
});
// Create the main window
void createWindow().then(() => {
void createAndShowMainWindow().then(() => {
// Trigger auto-update check 5 s after window creation.
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
getAutoUpdateBridge().startAutoCheck(5000);
@@ -1095,9 +1167,12 @@ if (!gotLock) {
if (focusMainWindow()) return;
// Main window doesn't exist — create it even if other windows (e.g. settings) are open
void createWindow().catch((err) => {
void createAndShowMainWindow().catch((err) => {
console.error("[Main] Failed to create window on activate:", err);
showStartupError(err);
if (!hasUsableWindow()) {
try { app.quit(); } catch {}
}
});
});
});

View File

@@ -858,6 +858,10 @@ const api = {
// App info
getAppInfo: () => ipcRenderer.invoke("netcatty:app:getInfo"),
ptyGetChildProcesses: (sessionId) =>
ipcRenderer.invoke("netcatty:pty:childProcesses", sessionId),
confirmCloseBusy: (payload) =>
ipcRenderer.invoke("netcatty:dialog:confirmCloseBusy", payload),
getVaultBackupCapabilities: () =>
ipcRenderer.invoke("netcatty:vaultBackups:capabilities"),
createVaultBackup: (payload) =>

8
global.d.ts vendored
View File

@@ -512,6 +512,14 @@ declare global {
// App info (name/version/platform) for About screens
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
ptyGetChildProcesses?(sessionId: string): Promise<Array<{ pid: number; command: string }>>;
confirmCloseBusy?(payload: {
command: string;
title?: string;
message?: string;
cancelLabel?: string;
closeLabel?: string;
}): Promise<boolean>;
getVaultBackupCapabilities?(): Promise<{ encryptionAvailable: boolean }>;
createVaultBackup?(payload: {
payload: import('./domain/sync').SyncPayload;

View File

@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import test from "node:test";
import { classifyError, sanitizeErrorMessage } from "./errorClassifier.ts";
// -------------------------------------------------------------------
// sanitizeErrorMessage — regression guard for pre-existing behavior
// -------------------------------------------------------------------
test("sanitizeErrorMessage strips absolute user paths", () => {
const result = sanitizeErrorMessage("ENOENT at /Users/alice/project/file.ts");
assert.match(result, /<path>/);
assert.doesNotMatch(result, /alice/);
});
test("sanitizeErrorMessage redacts URL credentials", () => {
const result = sanitizeErrorMessage("Failed https://api.example.com/v1?api_key=SECRET123");
assert.match(result, /<url-redacted>/);
assert.doesNotMatch(result, /SECRET123/);
});
test("sanitizeErrorMessage truncates very long messages", () => {
const long = "a".repeat(1000);
const result = sanitizeErrorMessage(long);
assert.ok(result.length < 600, `expected truncation, got ${result.length} chars`);
assert.match(result, /\.\.\.$/);
});
// -------------------------------------------------------------------
// classifyError — 413 detection
// -------------------------------------------------------------------
test("classifyError surfaces a friendly 413 message when statusCode is 413", () => {
const err = Object.assign(new Error("Request failed with status 413"), {
statusCode: 413,
responseBody: "<html>nginx 413</html>",
});
const info = classifyError(err);
assert.equal(info.type, "network");
assert.match(info.message, /Request too large/i);
assert.match(info.message, /client_max_body_size/i);
assert.match(info.message, /Raw:/);
});
test("classifyError detects 'Request Entity Too Large' in a string error", () => {
const info = classifyError("413 Request Entity Too Large");
assert.equal(info.type, "network");
assert.match(info.message, /Request too large/i);
});
test("classifyError handles 413 via the message when no statusCode field is set", () => {
const info = classifyError(new Error("AI_APICallError: 413 payload rejected"));
assert.equal(info.type, "network");
assert.match(info.message, /Request too large/i);
});
// -------------------------------------------------------------------
// classifyError — 502 / 503 / 504 upstream gateway
// -------------------------------------------------------------------
test("classifyError marks 502/503/504 as network+retryable", () => {
for (const code of [502, 503, 504]) {
const info = classifyError(Object.assign(new Error(`status ${code}`), { statusCode: code }));
assert.equal(info.type, "network");
assert.equal(info.retryable, true, `code ${code} should be retryable`);
assert.match(info.message, new RegExp(String(code)));
}
});
// -------------------------------------------------------------------
// classifyError — HTML response body
// -------------------------------------------------------------------
test("classifyError detects HTML in responseBody even when status is unknown", () => {
const err = Object.assign(new Error("Invalid JSON"), {
responseBody: "<!DOCTYPE html>\n<html><body>nginx error</body></html>",
});
const info = classifyError(err);
assert.equal(info.type, "provider");
assert.match(info.message, /HTML error page/i);
assert.match(info.message, /proxy/i);
});
test("classifyError detects HTML directly embedded in the error message", () => {
const info = classifyError("Parse failed: <html><body>...</body></html>");
assert.equal(info.type, "provider");
assert.match(info.message, /HTML error page/i);
});
// -------------------------------------------------------------------
// classifyError — Zod / schema parse failures
// -------------------------------------------------------------------
test("classifyError surfaces a friendlier message for 'Expected \\'id\\' to be a string.'", () => {
// This is the exact error pattern reported in #765.
const info = classifyError("Expected 'id' to be a string.");
assert.equal(info.type, "provider");
assert.match(info.message, /could not be parsed/i);
assert.match(info.message, /request-size limit/i);
// Raw error must still be visible for debugging / user reports.
assert.match(info.message, /Expected 'id' to be a string/);
});
test("classifyError handles a variety of schema validation wordings", () => {
for (const raw of [
"Invalid JSON response: missing field",
"Type validation failed: expected number",
"Expected 'choices' to be an array.",
]) {
const info = classifyError(raw);
assert.equal(info.type, "provider", `wording: ${raw}`);
assert.match(info.message, /could not be parsed|HTML error page/i);
}
});
// -------------------------------------------------------------------
// classifyError — fallthrough
// -------------------------------------------------------------------
test("classifyError falls through to 'unknown' for unclassified errors", () => {
const info = classifyError(new Error("Some other provider failure"));
assert.equal(info.type, "unknown");
assert.match(info.message, /Some other provider failure/);
});
test("classifyError handles null, undefined, and non-Error shapes without throwing", () => {
assert.doesNotThrow(() => classifyError(null));
assert.doesNotThrow(() => classifyError(undefined));
assert.doesNotThrow(() => classifyError({ foo: "bar" }));
assert.doesNotThrow(() => classifyError(42));
});

View File

@@ -1,15 +1,173 @@
import type { ChatMessage } from './types';
type ErrorInfo = NonNullable<ChatMessage['errorInfo']>;
/**
* Convert a raw error string into display-safe error info.
*
* Intentionally avoids keyword-based "root cause" attribution because upstream
* providers often return generic 4xx/5xx text that would be misclassified.
* We show the sanitized upstream message directly instead.
* Extract the human-readable message from anything that might surface as an
* error (Error instance, string, SDK error object with `.message`, etc.).
*/
export function classifyError(error: string): NonNullable<ChatMessage['errorInfo']> {
const message = sanitizeErrorMessage(error).trim() || 'Unknown error';
return { type: 'unknown', message, retryable: false };
function extractMessage(error: unknown): string {
if (error instanceof Error) return error.message || '';
if (typeof error === 'string') return error;
if (error && typeof error === 'object' && 'message' in error) {
const m = (error as { message: unknown }).message;
if (typeof m === 'string') return m;
}
try {
return JSON.stringify(error) ?? '';
} catch {
return '';
}
}
/**
* Pull the HTTP status code out of an error when the SDK layer attached one.
* Vercel AI SDK's APICallError exposes `.statusCode`; some shims use
* `.status` or `.cause.statusCode`. Falls back to parsing the message text
* when no structured field is available.
*/
function extractStatusCode(error: unknown, message: string): number | undefined {
if (error && typeof error === 'object') {
const obj = error as Record<string, unknown>;
if (typeof obj.statusCode === 'number') return obj.statusCode;
if (typeof obj.status === 'number') return obj.status;
if (obj.cause && typeof obj.cause === 'object') {
const causeStatus = (obj.cause as Record<string, unknown>).statusCode;
if (typeof causeStatus === 'number') return causeStatus;
}
}
// Last resort: look for a standalone 3-digit HTTP status in the message.
// Bound by word boundaries to avoid picking up "in 413 ms" etc.
const match = message.match(/\b(4\d{2}|5\d{2})\b/);
if (match) return Number(match[1]);
return undefined;
}
/**
* Pull the response body out of an error object if the SDK attached it.
* Nginx / CDN proxy error pages ship as HTML, so we can detect them here.
*/
function extractResponseBody(error: unknown): string | undefined {
if (!error || typeof error !== 'object') return undefined;
const body = (error as Record<string, unknown>).responseBody;
if (typeof body === 'string') return body;
return undefined;
}
function looksLikeHtml(text: string): boolean {
if (!text) return false;
const lower = text.toLowerCase();
const trimmedStart = lower.trimStart().slice(0, 200);
// Start-of-body: responseBody captured verbatim by the SDK lands here.
if (
trimmedStart.startsWith('<!doctype html') ||
trimmedStart.startsWith('<html') ||
trimmedStart.startsWith('<head') ||
trimmedStart.startsWith('<body')
) {
return true;
}
// Embedded: some SDKs wrap the HTML body inside an error message like
// "Parse failed: <html>...". Look for unmistakable HTML tags anywhere
// in the text. Kept narrow to avoid flagging errors that casually
// mention "html" as a word.
if (
lower.includes('<!doctype html') ||
lower.includes('<html>') ||
lower.includes('<html ') ||
// Common nginx default error-page opener.
/<center>\s*<h1>/.test(lower)
) {
return true;
}
return false;
}
function looksLikeZodParseError(message: string): boolean {
// Zod and Vercel AI SDK schema errors look like:
// Expected 'id' to be a string.
// Expected 'choices' to be an array.
// Invalid JSON response: ...
// Type validation failed: ...
return (
/\bExpected '[^']+' to be (a|an) /i.test(message) ||
/\binvalid json response\b/i.test(message) ||
/\btype validation failed\b/i.test(message)
);
}
/**
* Map an arbitrary error surface to display-safe error info shown in the
* chat UI. Known hostile scenarios get a concrete, actionable message; the
* raw SDK text is appended so users can still report it verbatim.
*
* Covers:
* - HTTP 413 (proxy request-size limit, e.g. nginx client_max_body_size)
* - HTTP 502/504 (upstream proxy failures)
* - HTML error page returned in place of JSON (any proxy)
* - Schema/parse failures ("Expected 'id' to be a string.") that typically
* mean the server swapped the response body for an error page
*/
export function classifyError(error: unknown): ErrorInfo {
const rawMessage = extractMessage(error).trim() || 'Unknown error';
const statusCode = extractStatusCode(error, rawMessage);
const responseBody = extractResponseBody(error);
const hasHtml =
looksLikeHtml(rawMessage) ||
(responseBody !== undefined && looksLikeHtml(responseBody));
const looksLikeParseError = looksLikeZodParseError(rawMessage);
const sanitizedRaw = sanitizeErrorMessage(rawMessage);
if (statusCode === 413 || /\brequest entity too large\b/i.test(rawMessage)) {
return {
type: 'network',
message:
`Request too large (HTTP 413). The AI gateway rejected the payload — this usually means ` +
`the request body exceeded the proxy's size limit (for example nginx \`client_max_body_size\`). ` +
`Try sending a shorter message, fewer/smaller attachments, or raising the proxy limit.\n\n` +
`Raw: ${sanitizedRaw}`,
retryable: false,
};
}
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
return {
type: 'network',
message:
`AI gateway error (HTTP ${statusCode}). The proxy in front of the provider returned an error — ` +
`the upstream AI service may be unreachable or timing out.\n\n` +
`Raw: ${sanitizedRaw}`,
retryable: true,
};
}
if (hasHtml) {
return {
type: 'provider',
message:
`The server returned an HTML error page instead of a JSON response. ` +
`This almost always means a proxy (nginx / CDN / gateway) between you and the AI provider ` +
`intercepted the request — commonly due to a size limit, auth failure, or the upstream service being down.\n\n` +
`Raw: ${sanitizedRaw}`,
retryable: false,
};
}
if (looksLikeParseError) {
return {
type: 'provider',
message:
`The AI response could not be parsed as a valid chat completion. ` +
`A proxy may have replaced or truncated the response body, or the provider returned a non-standard format. ` +
`If you just sent a large request, check for a request-size limit on any intermediate proxy.\n\n` +
`Raw: ${sanitizedRaw}`,
retryable: false,
};
}
return { type: 'unknown', message: sanitizedRaw, retryable: false };
}
const MAX_ERROR_MESSAGE_LENGTH = 500;

View File

@@ -45,6 +45,7 @@ import {
encryptProviderSecrets,
} from '../persistence/secureFieldAdapter';
import { mergeSyncPayloads } from '../../domain/syncMerge';
import { detectSuspiciousShrink, type ShrinkFinding } from '../../domain/syncGuards';
// Extracted into a plain ESM module so the signature logic is covered by
// the node --test harness (see syncSignature.test.mjs). The previous
// inline implementation only hashed a handful of meta fields and was
@@ -77,6 +78,12 @@ export interface SyncManagerState {
autoSyncEnabled: boolean;
autoSyncInterval: number;
syncHistory: SyncHistoryEntry[];
/** Last shrink finding that put us into BLOCKED state, retained until
* a sync actually succeeds (SYNC_COMPLETED with result.success) or
* `clearShrinkBlockedState()` is called. Renderer hydrates the banner
* from this on mount so a block that happened off-screen is still
* visible to the user. */
lastShrinkFinding?: Extract<ShrinkFinding, { suspicious: true }>;
}
export type SyncEventCallback = (event: SyncEvent) => void;
@@ -752,6 +759,12 @@ export class CloudSyncManager {
const ghAdapter = adapter as GitHubAdapter;
try {
// Snapshot the prior account BEFORE we overwrite providers[provider].
// Used as a fallback for the same-account comparison when the persisted
// accountId key is absent (e.g., first re-auth after upgrading to this
// version, where the key didn't exist yet).
const previousAccount = this.state.providers.github?.account;
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
++this.providerDecryptSeq.github;
@@ -769,9 +782,20 @@ 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.clearSyncAnchor('github');
// Only clear the merge base if the authenticated account identity differs
// from the previously-stored one. See notes in completePKCEAuth.
const newId = ghAdapter.accountInfo?.id ?? null;
const previousId = this.loadProviderAccountId('github') ?? previousAccount?.id ?? null;
const sameAccount = newId !== null && previousId !== null && newId === previousId;
if (!sameAccount) {
this.removeFromStorage(this.syncBaseKey('github'));
this.clearSyncAnchor('github');
}
if (newId) {
this.saveProviderAccountId('github', newId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider: 'github',
@@ -797,6 +821,12 @@ export class CloudSyncManager {
}
try {
// Snapshot the prior account BEFORE we overwrite providers[provider].
// Used as a fallback for the same-account comparison when the persisted
// accountId key is absent (e.g., first re-auth after upgrading to this
// version, where the key didn't exist yet).
const previousAccount = this.state.providers[provider]?.account;
let tokens: OAuthTokens;
let account;
@@ -825,9 +855,22 @@ 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.clearSyncAnchor(provider);
// Only clear the merge base if the authenticated account identity differs
// from the previously-stored one. Same-account re-auth preserves the base
// so the next sync computes correct local-deletions instead of treating
// it as "first sync" and resurrecting zombie entries via null-base union.
const newId = account?.id ?? null;
const previousId = this.loadProviderAccountId(provider) ?? previousAccount?.id ?? null;
const sameAccount = newId !== null && previousId !== null && newId === previousId;
if (!sameAccount) {
this.removeFromStorage(this.syncBaseKey(provider));
this.clearSyncAnchor(provider);
}
if (newId) {
this.saveProviderAccountId(provider, newId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider,
@@ -912,6 +955,13 @@ export class CloudSyncManager {
// account/resource doesn't reuse an unrelated snapshot
this.removeFromStorage(this.syncBaseKey(provider));
this.clearSyncAnchor(provider);
this.removeFromStorage(this.providerAccountIdKey(provider));
// Reset BLOCKED state if it was present — disconnect implicitly resolves
// any pending shrink-block warning since there's no provider to push to.
this.exitBlockedState();
if (this.state.syncState === 'BLOCKED') {
this.state.syncState = 'IDLE';
}
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
}
@@ -1227,7 +1277,8 @@ export class CloudSyncManager {
*/
async syncToProvider(
provider: CloudProvider,
payload: SyncPayload
payload: SyncPayload,
opts: { overrideShrink?: boolean } = {},
): Promise<SyncResult> {
if (this.state.securityState !== 'UNLOCKED') {
return {
@@ -1247,6 +1298,8 @@ export class CloudSyncManager {
};
}
const overrideShrinkRequested = opts.overrideShrink === true;
let adapter: CloudAdapter;
try {
adapter = await this.getConnectedAdapter(provider);
@@ -1288,6 +1341,30 @@ export class CloudSyncManager {
console.info('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
// Shrink guard: refuse to push a merged payload that silently deletes
// entities we still have in base. The merge itself is correct if local
// state is trustworthy — but a degraded local (keychain failure,
// partial load) can make merge produce a smaller-than-expected result.
const mergedShrink = detectSuspiciousShrink(mergeResult.payload, base);
const shouldBlockMerged = mergedShrink.suspicious && !overrideShrinkRequested;
const shouldForceMerged = mergedShrink.suspicious && overrideShrinkRequested;
if (shouldBlockMerged) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = mergedShrink;
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: mergedShrink });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
return {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding: mergedShrink,
};
}
if (shouldForceMerged) {
this.emit({ type: 'SYNC_FORCED', provider, finding: mergedShrink });
}
// Encrypt and upload merged payload
const mergedSyncedFile = await EncryptionService.encryptPayload(
mergeResult.payload,
@@ -1309,6 +1386,7 @@ export class CloudSyncManager {
// Base was persisted inside uploadToProvider before the
// anchor advanced, so a crash between them cannot leave a
// stale base pointing at pre-merge state.
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.addSyncHistoryEntry({
@@ -1361,6 +1439,29 @@ export class CloudSyncManager {
}
}
// Shrink guard (no-conflict path): same rationale as the merge branch —
// refuse a payload that drops entities versus the stored base.
const directBase = await this.loadSyncBase(provider);
const directShrink = detectSuspiciousShrink(payload, directBase);
const shouldBlockDirect = directShrink.suspicious && !overrideShrinkRequested;
const shouldForceDirect = directShrink.suspicious && overrideShrinkRequested;
if (shouldBlockDirect) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = directShrink;
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: directShrink });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
return {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding: directShrink,
};
}
if (shouldForceDirect) {
this.emit({ type: 'SYNC_FORCED', provider, finding: directShrink });
}
// 2. Encrypt
const syncedFile = await EncryptionService.encryptPayload(
payload,
@@ -1377,7 +1478,9 @@ export class CloudSyncManager {
const result = await this.uploadToProvider(provider, adapter, syncedFile, payload);
if (result.success) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
} else {
this.state.syncState = 'ERROR';
if (result.error) {
@@ -1550,26 +1653,73 @@ export class CloudSyncManager {
// Download and return remote data
const payload = await this.downloadFromProvider(provider);
this.state.currentConflict = null;
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.notifyStateChange(); // Notify UI of conflict resolution
return payload;
} else {
// USE_LOCAL - just clear conflict, caller will re-sync
this.state.currentConflict = null;
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.notifyStateChange(); // Notify UI of conflict resolution
return null;
}
}
/**
* Side-effect helper: called BEFORE any syncState assignment that transitions
* away from BLOCKED. Clears lastShrinkFinding and emits SYNC_BLOCKED_CLEARED
* so the UI banner (and any other subscriber) gets a single, authoritative
* "block resolved" signal. The guard on syncState === 'BLOCKED' makes it safe
* to call unconditionally at every non-BLOCKED assignment site — it no-ops
* when the state was already non-BLOCKED.
*/
private exitBlockedState(): void {
if (this.state.syncState === 'BLOCKED') {
this.state.lastShrinkFinding = undefined;
this.emit({ type: 'SYNC_BLOCKED_CLEARED' });
}
}
/**
* Reset BLOCKED back to IDLE without going through a successful sync.
* Used by post-merge round-trip to avoid wedging the manager in BLOCKED
* when the merge already produced safe local state and the round-trip
* push is just an optimization.
*/
clearShrinkBlockedState(): void {
if (this.state.syncState === 'BLOCKED') {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.notifyStateChange();
}
}
/**
* Returns the last shrink finding that triggered BLOCKED state, or
* null if not currently blocked. Used by the renderer to hydrate the
* SyncBlockedBanner when opening Settings after a block happened
* off-screen.
*/
getShrinkBlockedFinding(): Extract<ShrinkFinding, { suspicious: true }> | null {
if (this.state.syncState !== 'BLOCKED') return null;
return this.state.lastShrinkFinding ?? null;
}
/**
* Sync to all connected providers
*/
async syncAllProviders(inputPayload?: SyncPayload): Promise<Map<CloudProvider, SyncResult>> {
async syncAllProviders(
inputPayload?: SyncPayload,
opts: { overrideShrink?: boolean } = {},
): Promise<Map<CloudProvider, SyncResult>> {
const results = new Map<CloudProvider, SyncResult>();
let payload = inputPayload;
let wasMerged = false;
const overrideShrinkRequested = opts.overrideShrink === true;
if (!payload) {
// Caller should provide payload from app state
return results;
@@ -1743,6 +1893,80 @@ export class CloudSyncManager {
}
}
// Shrink guard (multi-provider): check the final outgoing payload against
// each provider's stored base. If ANY provider would suffer a suspicious
// shrink, block ALL uploads — the same payload goes to every provider, so
// any one provider's "would lose too much" is a global block. Override flag
// is one-shot and clears regardless of outcome.
const shrinkSuspectByProvider: Array<{
provider: CloudProvider;
finding: Extract<ShrinkFinding, { suspicious: true }>;
}> = [];
const candidateProviders = checkResults
.filter((r) => !r.error && !r.check?.conflict && r.adapter)
.map((r) => r.provider as CloudProvider);
for (const provider of candidateProviders) {
const providerBase = await this.loadSyncBase(provider);
const finding = detectSuspiciousShrink(payload, providerBase);
if (finding.suspicious) {
shrinkSuspectByProvider.push({ provider, finding });
}
}
const shouldBlockAll = shrinkSuspectByProvider.length > 0 && !overrideShrinkRequested;
const shouldForceAll = shrinkSuspectByProvider.length > 0 && overrideShrinkRequested;
if (shouldBlockAll) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = shrinkSuspectByProvider[0].finding;
for (const { provider, finding } of shrinkSuspectByProvider) {
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
results.set(provider, {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding,
});
}
// Process check errors from the parallel check phase so a provider that
// failed during checkProviderConflict is not silently dropped from results.
checkResults.forEach((r) => {
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 });
}
});
// Providers in candidateProviders that didn't trip the shrink check still
// share the same payload — mark them as not-uploaded so the caller doesn't
// think a "successful" no-op happened.
const blockedProviders = new Set(shrinkSuspectByProvider.map((e) => e.provider));
for (const provider of candidateProviders) {
if (!results.has(provider) && !blockedProviders.has(provider)) {
results.set(provider, {
success: false,
provider,
action: 'none',
error: 'Sync blocked: another provider would lose too much data',
});
this.updateProviderStatus(provider, 'error', 'Sync blocked due to peer provider');
}
}
return results;
}
if (shouldForceAll) {
for (const { provider, finding } of shrinkSuspectByProvider) {
this.emit({ type: 'SYNC_FORCED', provider, finding });
}
}
// 3. Encrypt Once
const validUploads = checkResults.filter(
(r) => !r.error && !r.check?.conflict && r.adapter
@@ -1819,7 +2043,9 @@ export class CloudSyncManager {
// 5. Final State Update
const hasSuccess = Array.from(results.values()).some((r) => r.success);
if (hasSuccess) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
// If a merge happened, attach the merged payload to successful results
// so callers can apply remote additions to local state
@@ -1922,6 +2148,18 @@ export class CloudSyncManager {
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${suffix}`;
}
private providerAccountIdKey(provider: CloudProvider): string {
return `netcatty.sync.accountId.${provider}`;
}
private loadProviderAccountId(provider: CloudProvider): string | null {
return this.loadFromStorage<string>(this.providerAccountIdKey(provider)) ?? null;
}
private saveProviderAccountId(provider: CloudProvider, id: string): void {
this.saveToStorage(this.providerAccountIdKey(provider), id);
}
async saveSyncBase(payload: SyncPayload, provider?: CloudProvider): Promise<void> {
const key = this.state.unlockedKey?.derivedKey;
if (!key) return;

192
package-lock.json generated
View File

@@ -81,9 +81,13 @@
"eslint-plugin-unused-imports": "^4.3.0",
"patch-package": "^8.0.0",
"tailwindcss": "^4.1.17",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.2.7",
"wait-on": "^9.0.3"
},
"optionalDependencies": {
"@vscode/windows-process-tree": "^0.7.0"
}
},
"node_modules/@agentclientprotocol/sdk": {
@@ -1101,13 +1105,13 @@
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.4",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
"version": "3.972.18",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.18.tgz",
"integrity": "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.12.0",
"fast-xml-parser": "5.3.4",
"@smithy/types": "^4.14.1",
"fast-xml-parser": "5.5.8",
"tslib": "^2.6.2"
},
"engines": {
@@ -5592,9 +5596,9 @@
}
},
"node_modules/@smithy/types": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
"integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
"version": "4.14.1",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz",
"integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
@@ -6104,6 +6108,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@@ -6640,6 +6704,27 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vscode/windows-process-tree": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.7.0.tgz",
"integrity": "sha512-uH58Fofu1bgiulsY2svyGOLLKAYNJ0Req4PioPd7BZzHRziuiBRw1SxyT7wvsYHvm7eKWwzwo6mZpExDfwX9iA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "7.1.0"
}
},
"node_modules/@vscode/windows-process-tree/node_modules/node-addon-api": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz",
"integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^16 || ^18 || >= 20"
}
},
"node_modules/@withfig/autocomplete": {
"version": "2.692.3",
"resolved": "https://registry.npmjs.org/@withfig/autocomplete/-/autocomplete-2.692.3.tgz",
@@ -9655,10 +9740,10 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
"node_modules/fast-xml-builder": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz",
"integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==",
"funding": [
{
"type": "github",
@@ -9667,7 +9752,24 @@
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.8",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.8.tgz",
"integrity": "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -10102,6 +10204,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-tsconfig": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -13507,6 +13622,21 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
"integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
@@ -14252,6 +14382,16 @@
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/responselike": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
@@ -15101,9 +15241,9 @@
}
},
"node_modules/strnum": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
"funding": [
{
"type": "github",
@@ -15471,6 +15611,26 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",

View File

@@ -29,7 +29,8 @@
"rebuild": "electron-builder install-app-deps",
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
"lint:fix": "eslint . --fix",
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs application/state/*.test.ts components/ai/*.test.ts components/terminal/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
@@ -100,10 +101,14 @@
"eslint-plugin-unused-imports": "^4.3.0",
"patch-package": "^8.0.0",
"tailwindcss": "^4.1.17",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.2.7",
"wait-on": "^9.0.3"
},
"optionalDependencies": {
"@vscode/windows-process-tree": "^0.7.0"
},
"overrides": {
"cpu-features": "npm:empty-npm-package@1.0.0",
"axios": "1.13.5"

View File

@@ -33,7 +33,7 @@ index 7291c2c..8943c9a 100644
}
diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/protocol/Protocol.js
index 7302488..95584c5 100644
index 7302488..634acdd 100644
--- a/node_modules/ssh2/lib/protocol/Protocol.js
+++ b/node_modules/ssh2/lib/protocol/Protocol.js
@@ -701,11 +701,19 @@ class Protocol {
@@ -107,6 +107,18 @@ index 7302488..95584c5 100644
packet.set(signature, p += 4);
this._authsQueue.push('hostbased');
@@ -1916,7 +1932,10 @@ class Protocol {
}
// SSH-protoversion-softwareversion (SP comments) CR LF
-const RE_IDENT = /^SSH-(2\.0|1\.99)-([^ ]+)(?: (.*))?$/;
+// RFC 4253 requires a non-empty softwareversion, but some embedded SSH
+// daemons send "SSH-2.0-" with an empty token. Accept that specific
+// compatibility case while still rejecting whitespace in the token itself.
+const RE_IDENT = /^SSH-(2\.0|1\.99)-([^ ]*)(?: (.*))?$/;
// TODO: optimize this by starting n bytes from the end of this._buffer instead
// of the beginning
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
index 9f33c02..9751164 100644
--- a/node_modules/ssh2/lib/protocol/SFTP.js

View File

@@ -25,12 +25,19 @@ For routine tasks, the host prompt is usually enough. Read only the reference th
## Core Rules
- Treat the host-provided CLI prefix as the only supported entrypoint for this session.
- If a command launcher is needed, prefer the operating system's built-in launcher for the current environment; do not require optional shells that may not be installed.
- Run Netcatty CLI commands strictly serially.
- Treat Netcatty CLI errors as authoritative.
- Never ask the user for SSH credentials, key paths, proxy settings, or jump-host details when Netcatty session access already exists.
- Do not pause to explain the plan, re-read this skill, or design scripts before trying that shortest path.
- When presenting structured results, prefer a concise table if it fits clearly.
Examples:
- On Windows, if a literal shell command line is required, use the host-provided prefix with the system launcher available in the environment, such as `cmd.exe` or Windows PowerShell; do not assume PowerShell 7 `pwsh.exe` exists.
- On macOS or Linux, use the host-provided prefix directly, or the system shell already available in that environment when a shell command line is unavoidable.
- When the execution surface accepts argv-style calls, use the Netcatty launcher path as the executable and pass subcommands and flags as separate arguments instead of wrapping it in another shell.
## References
- Exec and session workflow: `references/exec.md`