Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

This commit is contained in:
Brooklyn Nicholson 2026-05-12 00:25:20 -04:00
commit 7dd7703f64
5 changed files with 348 additions and 10 deletions

View file

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

View file

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

View file

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

View file

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

View file

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