mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
This commit is contained in:
commit
7dd7703f64
5 changed files with 348 additions and 10 deletions
25
run_agent.py
25
run_agent.py
|
|
@ -3465,6 +3465,15 @@ class AIAgent:
|
|||
return True, True
|
||||
if (is_openrouter or is_nous_portal) and is_claude:
|
||||
return True, False
|
||||
# Nous Portal Qwen (e.g. qwen3.6-plus) takes the same envelope-layout
|
||||
# cache_control path as Portal Claude. Portal proxies to OpenRouter
|
||||
# and the upstream Qwen route accepts cache_control markers; without
|
||||
# this branch the alibaba-family check below only matches
|
||||
# provider=opencode/alibaba and Portal traffic falls through to
|
||||
# (False, False), serving 0% cache hits and re-billing the full
|
||||
# prompt on every turn.
|
||||
if is_nous_portal and "qwen" in model_lower:
|
||||
return True, False
|
||||
if is_anthropic_wire and is_claude:
|
||||
# Third-party Anthropic-compatible gateway.
|
||||
return True, True
|
||||
|
|
@ -3540,7 +3549,19 @@ class AIAgent:
|
|||
eff_api_mode = api_mode if api_mode is not None else (self.api_mode or "")
|
||||
eff_model = (model if model is not None else self.model) or ""
|
||||
|
||||
if "claude" not in eff_model.lower():
|
||||
model_lower = eff_model.lower()
|
||||
is_claude = "claude" in model_lower
|
||||
is_nous_portal = "nousresearch" in eff_base_url.lower()
|
||||
|
||||
# Nous Portal: Claude AND Qwen both get long-lived caching.
|
||||
# Portal proxies to OpenRouter with identical cache_control
|
||||
# semantics; any model on Portal that accepts envelope-layout
|
||||
# markers via _anthropic_prompt_cache_policy also benefits from
|
||||
# the documented 1h cross-session TTL.
|
||||
if is_nous_portal and (is_claude or "qwen" in model_lower):
|
||||
return True
|
||||
|
||||
if not is_claude:
|
||||
return False
|
||||
|
||||
# Native Anthropic + Anthropic OAuth subscription
|
||||
|
|
@ -3554,7 +3575,7 @@ class AIAgent:
|
|||
|
||||
# Nous Portal — front-ends OpenRouter behind the scenes; identical
|
||||
# wire format and cache_control semantics.
|
||||
if "nousresearch" in eff_base_url.lower():
|
||||
if is_nous_portal:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -257,6 +257,40 @@ class TestQwenAlibabaFamily:
|
|||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
def test_qwen_on_nous_portal_caches_with_envelope_layout(self):
|
||||
# Nous Portal Qwen takes the same envelope-layout cache_control
|
||||
# path as Portal Claude. Without this, Portal-routed qwen3.6-plus
|
||||
# falls through to the alibaba-family check (which only matches
|
||||
# provider=opencode/alibaba) and serves 0% cache hits.
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="qwen3.6-plus",
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, False)
|
||||
|
||||
def test_qwen_vendored_slug_on_nous_portal_caches(self):
|
||||
# Same path but with the vendored slug form Portal sometimes uses.
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="qwen/qwen3.6-plus",
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, False)
|
||||
|
||||
def test_non_qwen_non_claude_on_nous_portal_does_not_cache(self):
|
||||
# Portal scope is narrow: Claude OR Qwen only. Other models
|
||||
# routed through Portal keep their existing fall-through behavior.
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="openai/gpt-5.4",
|
||||
)
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
|
||||
class TestExplicitOverrides:
|
||||
"""Policy accepts keyword overrides for switch_model / fallback activation."""
|
||||
|
|
@ -338,6 +372,37 @@ class TestSupportsLongLivedAnthropicCache:
|
|||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is True
|
||||
|
||||
def test_nous_portal_qwen_supported(self):
|
||||
# Portal Qwen rides the same OpenRouter-equivalent transport as
|
||||
# Portal Claude; long-lived (1h cross-session) cache_control
|
||||
# markers apply identically.
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="qwen3.6-plus",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is True
|
||||
|
||||
def test_nous_portal_qwen_vendored_slug_supported(self):
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="qwen/qwen3.6-plus",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is True
|
||||
|
||||
def test_nous_portal_non_claude_non_qwen_rejected(self):
|
||||
# Portal long-lived cache scope mirrors policy: Claude or Qwen only.
|
||||
agent = _make_agent(
|
||||
provider="nous",
|
||||
base_url="https://inference-api.nousresearch.com/v1",
|
||||
api_mode="chat_completions",
|
||||
model="openai/gpt-5.4",
|
||||
)
|
||||
assert agent._supports_long_lived_anthropic_cache() is False
|
||||
|
||||
def test_openrouter_non_claude_rejected(self):
|
||||
agent = _make_agent(
|
||||
provider="openrouter",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { shouldEmitClipboardSequence } from './osc.js'
|
||||
import { env, supportsOsc52Clipboard } from '../../utils/env.js'
|
||||
|
||||
import { shouldEmitClipboardSequence, shouldUseNativeClipboard } from './osc.js'
|
||||
|
||||
describe('shouldEmitClipboardSequence', () => {
|
||||
it('suppresses local multiplexer clipboard OSC by default', () => {
|
||||
|
|
@ -49,3 +51,141 @@ describe('shouldEmitClipboardSequence', () => {
|
|||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsOsc52Clipboard', () => {
|
||||
// Terminals known to correctly implement OSC 52. On these, setClipboard()
|
||||
// skips the native-tool safety net (wl-copy/xclip/pbcopy) to avoid racing
|
||||
// the terminal's own clipboard write. Values must match what
|
||||
// detectTerminal() in utils/env.ts returns — TERM=xterm-ghostty normalises
|
||||
// to 'ghostty', TERM_PROGRAM=WezTerm stays 'WezTerm', etc.
|
||||
it.each(['ghostty', 'kitty', 'WezTerm', 'windows-terminal', 'vscode'])(
|
||||
'returns true for allowlisted terminal %s',
|
||||
terminal => {
|
||||
expect(supportsOsc52Clipboard(terminal)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
// Intentionally conservative — iTerm2 disables OSC 52 by default; Alacritty
|
||||
// and GNOME Terminal detection is unreliable; xterm/Terminal.app lack
|
||||
// reliable OSC 52. These keep the existing native-safety-net behaviour.
|
||||
it.each(['iTerm.app', 'alacritty', 'Apple_Terminal', 'xterm', 'tmux', 'screen', 'cursor', 'WarpTerminal', ''])(
|
||||
'returns false for non-allowlisted terminal %s',
|
||||
terminal => {
|
||||
expect(supportsOsc52Clipboard(terminal)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('returns false when terminal is null (detection failed)', () => {
|
||||
expect(supportsOsc52Clipboard(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults to the module-level detected terminal when no argument is passed', () => {
|
||||
// With no argument, uses env.terminal detected at module load. We don't
|
||||
// know what that is in CI, but the call must return a boolean (not throw)
|
||||
// and the result must match calling with env.terminal explicitly.
|
||||
expect(typeof supportsOsc52Clipboard()).toBe('boolean')
|
||||
expect(supportsOsc52Clipboard()).toBe(supportsOsc52Clipboard(env.terminal))
|
||||
})
|
||||
})
|
||||
|
||||
// shouldUseNativeClipboard() encodes the gating logic that setClipboard()
|
||||
// uses to decide whether to fire copyNative(). Testing it directly (rather
|
||||
// than mocking copyNative inside setClipboard) matches the package's
|
||||
// existing style — tests pass env/terminal as arguments instead of using
|
||||
// vi.mock — and gives broader coverage of the env x terminal matrix.
|
||||
describe('shouldUseNativeClipboard', () => {
|
||||
it('returns false over SSH (native would write to remote clipboard)', () => {
|
||||
// Over SSH the user's terminal is on the local end of the pty;
|
||||
// pbcopy/wl-copy/xclip on the remote machine would write to the wrong
|
||||
// clipboard. OSC 52 is the right path. Existing behaviour, preserved.
|
||||
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'xterm')).toBe(false)
|
||||
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'ghostty')).toBe(false)
|
||||
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true on plain local terminals (existing behaviour)', () => {
|
||||
// Non-allowlisted terminals — xterm, GNOME Terminal, Apple_Terminal,
|
||||
// alacritty (detection unreliable), iTerm2 (OSC 52 off by default).
|
||||
// These keep the native safety net firing. This is the bulk of the
|
||||
// existing user base; behaviour must not regress.
|
||||
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'xterm')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'iTerm.app')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'Apple_Terminal')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'alacritty')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, null)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false on allowlisted local terminals (the race-fix case)', () => {
|
||||
// Ghostty / kitty / WezTerm / Windows Terminal / VS Code — OSC 52
|
||||
// alone is reliable, native fallback racing it can corrupt the
|
||||
// clipboard (the wl-copy on Wayland symptom this PR fixes).
|
||||
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'ghostty')).toBe(false)
|
||||
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'kitty')).toBe(false)
|
||||
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'WezTerm')).toBe(false)
|
||||
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'windows-terminal')).toBe(false)
|
||||
expect(shouldUseNativeClipboard({} as NodeJS.ProcessEnv, 'vscode')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true inside tmux even on allowlisted outer terminal', () => {
|
||||
// detectTerminal() prefers TERM_PROGRAM over TMUX, so a tmux session
|
||||
// inside Ghostty reports terminal='ghostty'. But setClipboard() goes
|
||||
// through tmux load-buffer there, not raw OSC 52 — the wl-copy race
|
||||
// doesn't apply. Native is still useful since tmux's outer-terminal
|
||||
// forwarding depends on `set -g set-clipboard` + `allow-passthrough`.
|
||||
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'kitty')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'WezTerm')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({ TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'vscode')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true inside GNU screen even on allowlisted outer terminal', () => {
|
||||
// Same reasoning as TMUX — STY indicates we're inside screen, which
|
||||
// has its own escape-sequence handling and we don't emit raw OSC 52.
|
||||
expect(shouldUseNativeClipboard({ STY: '1234.pts-0.host' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({ STY: '1234.pts-0.host' } as NodeJS.ProcessEnv, 'kitty')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when OSC 52 emission is disabled via HERMES_TUI_FORCE_OSC52=0', () => {
|
||||
// If we suppress OSC 52 (user override) AND skip native, the clipboard
|
||||
// write becomes a no-op. So when OSC 52 is off, native is the only
|
||||
// remaining path — keep it on regardless of terminal allowlist.
|
||||
expect(shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '0' } as NodeJS.ProcessEnv, 'ghostty')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '0' } as NodeJS.ProcessEnv, 'kitty')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({ HERMES_TUI_CLIPBOARD_OSC52: '0' } as NodeJS.ProcessEnv, 'WezTerm')).toBe(true)
|
||||
expect(shouldUseNativeClipboard({ HERMES_TUI_COPY_OSC52: 'no' } as NodeJS.ProcessEnv, 'vscode')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true under TMUX even with HERMES_TUI_FORCE_OSC52=1 on an allowlisted terminal (tmux load-buffer path)', () => {
|
||||
// FORCE_OSC52=1 is the user explicitly opting INTO OSC 52 (e.g. they
|
||||
// have tmux set up for passthrough). On an allowlisted terminal the
|
||||
// race-avoidance still applies.
|
||||
expect(
|
||||
shouldUseNativeClipboard({ HERMES_TUI_FORCE_OSC52: '1', TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'ghostty')
|
||||
// TMUX guard wins — native still fires because we're going through
|
||||
// tmux load-buffer, not raw OSC 52 to the terminal.
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('SSH_CONNECTION takes precedence over allowlisted terminal', () => {
|
||||
// Even on Ghostty, if we're SSH'd in we shouldn't run pbcopy on the
|
||||
// remote machine — the user's clipboard is on the other end.
|
||||
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1' } as NodeJS.ProcessEnv, 'ghostty')).toBe(false)
|
||||
})
|
||||
|
||||
it('SSH_CONNECTION takes precedence over TMUX', () => {
|
||||
// Combined: SSH'd in and inside tmux on the remote. SSH_CONNECTION
|
||||
// gate fires first, native stays off (we use OSC 52 to reach the
|
||||
// local terminal).
|
||||
expect(shouldUseNativeClipboard({ SSH_CONNECTION: '1', TMUX: '/tmp/t,1,0' } as NodeJS.ProcessEnv, 'xterm')).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults env to process.env and terminal to the module-detected terminal when no args passed', () => {
|
||||
// Smoke test: no args is a valid call shape for the convenience seam.
|
||||
// shouldUseNativeClipboard() defaults `terminal` to envModule.terminal
|
||||
// (the module-level detected terminal), not null. Returns a boolean
|
||||
// without throwing.
|
||||
expect(typeof shouldUseNativeClipboard()).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { Buffer } from 'buffer'
|
||||
|
||||
import { env } from '../../utils/env.js'
|
||||
import { env as envModule, supportsOsc52Clipboard } from '../../utils/env.js'
|
||||
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
|
||||
|
||||
import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js'
|
||||
|
|
@ -20,7 +20,7 @@ export const ST = ESC + '\\'
|
|||
/** Generate an OSC sequence: ESC ] p1;p2;...;pN <terminator>
|
||||
* Uses ST terminator for Kitty (avoids beeps), BEL for others */
|
||||
export function osc(...parts: (string | number)[]): string {
|
||||
const terminator = env.terminal === 'kitty' ? ST : BEL
|
||||
const terminator = envModule.terminal === 'kitty' ? ST : BEL
|
||||
|
||||
return `${OSC_PREFIX}${parts.join(SEP)}${terminator}`
|
||||
}
|
||||
|
|
@ -102,6 +102,77 @@ export function shouldEmitClipboardSequence(env: NodeJS.ProcessEnv = process.env
|
|||
return !!env['SSH_CONNECTION'] || (!env['TMUX'] && !env['STY'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether setClipboard() should also fire the native clipboard tool
|
||||
* (pbcopy / wl-copy / xclip / xsel / clip.exe) as a safety net alongside
|
||||
* OSC 52 / tmux load-buffer.
|
||||
*
|
||||
* The default is "yes, native fires" — it's the historical safety net for
|
||||
* terminals where OSC 52 may not work (iTerm2 disables OSC 52 by default,
|
||||
* Apple_Terminal / GNOME Terminal / xterm coverage is patchy). The two
|
||||
* cases where we suppress it:
|
||||
*
|
||||
* 1. SSH session: native tools would write to the *remote* machine's
|
||||
* clipboard. OSC 52 (which travels back over the pty to the user's
|
||||
* local terminal) is the right path. Existing behaviour.
|
||||
*
|
||||
* 2. Allowlisted OSC-52-capable terminal AND we're actually going to
|
||||
* emit an OSC 52 sequence AND we're not inside tmux/screen. On these
|
||||
* terminals (Ghostty / kitty / WezTerm / Windows Terminal / VS Code)
|
||||
* the OSC 52 write is reliable on its own, and racing it with a
|
||||
* native tool is destructive — wl-copy on Wayland in particular
|
||||
* wipes the clipboard during its existence-probe and forks a daemon
|
||||
* that races the terminal's own write (~30% empty-clipboard rate
|
||||
* reported on Ghostty + Wayland; symptom: ctrl+shift+c works on the
|
||||
* 3rd attempt).
|
||||
*
|
||||
* The TMUX/STY guard is important: detectTerminal() in utils/env.ts
|
||||
* prefers TERM_PROGRAM over TMUX, so a tmux session inside Ghostty
|
||||
* reports terminal='ghostty'. But inside tmux setClipboard() doesn't
|
||||
* emit raw OSC 52 — it goes through tmux load-buffer (which loads
|
||||
* the tmux paste buffer and, with -w, asks tmux to forward an OSC 52
|
||||
* to the OUTER terminal via its own emission path). The native
|
||||
* safety net is still useful there because tmux load-buffer's
|
||||
* outer-terminal forwarding depends on `set -g set-clipboard` and
|
||||
* `allow-passthrough`, which many users don't have configured.
|
||||
*
|
||||
* The OSC-52-will-emit guard matters too: if the user has set
|
||||
* HERMES_TUI_FORCE_OSC52=0, no OSC 52 sequence will be written. If
|
||||
* we ALSO skip native, the clipboard write becomes a no-op. So skip
|
||||
* native only when OSC 52 will actually carry the data.
|
||||
*/
|
||||
export function shouldUseNativeClipboard(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
terminal: string | null = envModule.terminal
|
||||
): boolean {
|
||||
// Over SSH the native tools would write to the wrong machine's clipboard.
|
||||
if (env.SSH_CONNECTION) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Inside tmux/screen, OSC 52 is normally suppressed and we rely on
|
||||
// tmux load-buffer instead — so the wl-copy/OSC-52 race usually doesn't
|
||||
// apply. Even when HERMES_TUI_FORCE_OSC52=1 forces a tmux-passthrough
|
||||
// OSC 52 emission, we keep native enabled as a safety net: tmux's
|
||||
// outer-terminal forwarding depends on `allow-passthrough` in the
|
||||
// user's tmux config, so a forced OSC 52 may silently never reach the
|
||||
// host terminal. Native (pbcopy/wl-copy/xclip) covers that gap.
|
||||
if (env.TMUX || env.STY) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If OSC 52 won't actually emit (user override or env state), the
|
||||
// native tool is the only path left — keep it on.
|
||||
if (!shouldEmitClipboardSequence(env)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// OSC 52 is going to emit AND the terminal is in the allowlist of
|
||||
// terminals where OSC 52 alone is reliable: skip native to avoid the
|
||||
// wl-copy race documented above.
|
||||
return !supportsOsc52Clipboard(terminal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a payload in tmux's DCS passthrough: ESC P tmux ; <payload> ESC \
|
||||
* tmux forwards the payload to the outer terminal, bypassing its own parser.
|
||||
|
|
@ -193,11 +264,27 @@ export async function setClipboard(text: string): Promise<ClipboardResult> {
|
|||
// AFTER awaiting tmux load-buffer, adding ~50-100ms of subprocess latency
|
||||
// before pbcopy even started — fast cmd+tab → paste would beat it
|
||||
// (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829).
|
||||
// Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY
|
||||
// forever but SSH_CONNECTION is in tmux's default update-environment and
|
||||
// clears on local attach. Fire-and-forget, but `copyNativeAttempted`
|
||||
// tells us whether ANY native path will be tried on this platform.
|
||||
const nativeAttempted = !process.env['SSH_CONNECTION'] && copyNative(text)
|
||||
// Skipped entirely on terminals with first-class OSC 52 support (see
|
||||
// `shouldUseNativeClipboard()` above): running wl-copy/xclip/pbcopy in
|
||||
// parallel with OSC 52 on those terminals can corrupt the clipboard.
|
||||
// wl-copy on Wayland is the worst offender — `probeLinuxCopy()` runs it
|
||||
// with empty stdin to check if the binary exists (which destructively
|
||||
// wipes the clipboard), and the subsequent real invocation forks a
|
||||
// background daemon that races the terminal's own OSC 52 write plus its
|
||||
// own prior daemon's SIGTERM. On Ghostty + Wayland this produced a ~30%
|
||||
// clipboard-empty rate (symptom: user had to press ctrl+shift+c three
|
||||
// times before the selection landed). Native still fires inside
|
||||
// tmux/screen — we primarily rely on tmux load-buffer there rather
|
||||
// than raw OSC 52, so the wl-copy race usually doesn't apply, and
|
||||
// native is kept as a safety net because tmux passthrough forwarding
|
||||
// depends on the user's `allow-passthrough` config (note: when
|
||||
// HERMES_TUI_FORCE_OSC52=1 we DO additionally emit a tmux-passthrough
|
||||
// OSC 52, but it can be silently dropped without that setting).
|
||||
// Native also fires when the user has disabled OSC 52 emission via
|
||||
// HERMES_TUI_FORCE_OSC52=0 (otherwise the clipboard write becomes a
|
||||
// complete no-op). Fire-and-forget, but `nativeAttempted` tells us
|
||||
// whether ANY native path will be tried.
|
||||
const nativeAttempted = shouldUseNativeClipboard(process.env, envModule.terminal) && copyNative(text)
|
||||
|
||||
const tmuxBufferLoaded = await tmuxLoadBuffer(text)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,3 +39,28 @@ function detectTerminal(): TerminalName {
|
|||
export const env = {
|
||||
terminal: detectTerminal()
|
||||
}
|
||||
|
||||
// Terminals known to correctly implement OSC 52 clipboard writes
|
||||
// (ESC ] 52 ; c ; <b64> BEL/ST — osc() in ink/termio/osc.ts emits BEL
|
||||
// for most terminals and ST for kitty). When detected, setClipboard() skips the
|
||||
// native-tool safety net entirely — running wl-copy/xclip/pbcopy in
|
||||
// parallel with OSC 52 races the terminal's own clipboard write and can
|
||||
// corrupt it (e.g. wl-copy on Wayland holds the selection in a background
|
||||
// daemon; stacking two writes within ~30ms triggers a SIGTERM race).
|
||||
// Intentionally conservative: terminals with known flaky or disabled-by-
|
||||
// default OSC 52 (iTerm2 disables OSC 52 by default; Alacritty detection
|
||||
// is unreliable) are not on this list. Users on those terminals keep the
|
||||
// existing behaviour (native safety net fires alongside OSC 52).
|
||||
//
|
||||
// Lives here in utils/env.ts (rather than ink/terminal.ts) so that
|
||||
// ink/termio/osc.ts can import it without creating a circular dependency:
|
||||
// ink/terminal.ts already imports `link` from ink/termio/osc.ts.
|
||||
const OSC52_CAPABLE_TERMINALS = ['ghostty', 'kitty', 'WezTerm', 'windows-terminal', 'vscode']
|
||||
|
||||
/** True if this terminal is known to correctly handle OSC 52 clipboard
|
||||
* writes, so setClipboard() can skip the native-tool safety net.
|
||||
* Accepts an optional terminal name for testability; defaults to the
|
||||
* module-level `env.terminal` detected at startup. */
|
||||
export function supportsOsc52Clipboard(terminal: string | null = env.terminal): boolean {
|
||||
return OSC52_CAPABLE_TERMINALS.includes(terminal ?? '')
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue