diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py
index 1c7dd9f7497..cfc44e5f2a6 100644
--- a/agent/auxiliary_client.py
+++ b/agent/auxiliary_client.py
@@ -1272,12 +1272,40 @@ def _resolve_nous_runtime_api(*, force_refresh: bool = False) -> Optional[tuple[
def _resolve_xai_oauth_for_aux() -> Optional[Tuple[str, str]]:
"""Resolve a fresh xAI OAuth (api_key, base_url) for auxiliary clients.
- Routes through ``hermes_cli.auth``'s runtime resolver so the auto-refresh
- path is shared with the main agent, instead of relying on whatever raw
- tokens happen to be sitting in auth.json or the credential pool. Returns
- ``None`` if the user is not authenticated with xAI Grok OAuth (so
- ``_resolve_auto`` Step 1 falls through to the next provider in the chain).
+ Prefer the credential pool, matching the main runtime/provider status
+ path. Some xAI OAuth logins live only as pool entries; falling straight
+ to the singleton auth-store resolver would make auxiliary tasks such as
+ compression report "no provider configured" even though ``hermes auth
+ status`` shows xAI OAuth as logged in.
+
+ Falls back to ``hermes_cli.auth``'s singleton runtime resolver for older
+ auth-store-only logins. Returns ``None`` if the user is not authenticated
+ with xAI Grok OAuth.
"""
+ try:
+ from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
+
+ pool = load_pool("xai-oauth")
+ if pool and pool.has_credentials():
+ entry = pool.select()
+ if entry is not None:
+ api_key = str(
+ getattr(entry, "runtime_api_key", None)
+ or getattr(entry, "access_token", "")
+ or ""
+ ).strip()
+ base_url = str(
+ os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/")
+ or os.getenv("XAI_BASE_URL", "").strip().rstrip("/")
+ or getattr(entry, "runtime_base_url", None)
+ or getattr(entry, "base_url", None)
+ or DEFAULT_XAI_OAUTH_BASE_URL
+ ).strip().rstrip("/")
+ if api_key and base_url:
+ return api_key, base_url
+ except Exception as exc:
+ logger.debug("Auxiliary xAI OAuth pool credential resolution failed: %s", exc)
+
try:
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
diff --git a/cli.py b/cli.py
index 50e7a8c8ce9..241d41e9fcd 100644
--- a/cli.py
+++ b/cli.py
@@ -11736,11 +11736,13 @@ class HermesCLI:
# Ensure tirith security scanner is available (downloads if needed).
# Warn the user if tirith is enabled in config but not available,
- # so they know command security scanning is degraded.
+ # so they know command security scanning is degraded. Suppressed
+ # on platforms where tirith ships no binary (Windows etc.) — the
+ # user can't act on it and pattern-matching guards still run.
try:
- from tools.tirith_security import ensure_installed
+ from tools.tirith_security import ensure_installed, is_platform_supported
tirith_path = ensure_installed(log_failures=False)
- if tirith_path is None:
+ if tirith_path is None and is_platform_supported():
security_cfg = self.config.get("security", {}) or {}
tirith_enabled = security_cfg.get("tirith_enabled", True)
if tirith_enabled:
diff --git a/run_agent.py b/run_agent.py
index 85c1128d68e..b3cde9eb1ea 100644
--- a/run_agent.py
+++ b/run_agent.py
@@ -3237,11 +3237,20 @@ class AIAgent:
except Exception:
_aux_cfg_provider = ""
if client is None or not aux_model:
- msg = (
- "⚠ No auxiliary LLM provider configured — context "
- "compression will drop middle turns without a summary. "
- "Run `hermes setup` or set OPENROUTER_API_KEY."
- )
+ if _aux_cfg_provider and _aux_cfg_provider != "auto":
+ msg = (
+ "⚠ Configured auxiliary compression provider "
+ f"'{_aux_cfg_provider}' is unavailable — context "
+ "compression will drop middle turns without a summary. "
+ "Check auxiliary.compression in config.yaml and "
+ "reauthenticate that provider."
+ )
+ else:
+ msg = (
+ "⚠ No auxiliary LLM provider configured — context "
+ "compression will drop middle turns without a summary. "
+ "Run `hermes setup` or set OPENROUTER_API_KEY."
+ )
self._compression_warning = msg
self._emit_status(msg)
logger.warning(
diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py
index 9dd85762956..96f5802f839 100644
--- a/tests/agent/test_auxiliary_client.py
+++ b/tests/agent/test_auxiliary_client.py
@@ -26,6 +26,7 @@ from agent.auxiliary_client import (
_normalize_aux_provider,
_try_payment_fallback,
_resolve_auto,
+ _resolve_xai_oauth_for_aux,
_CodexCompletionsAdapter,
)
@@ -221,6 +222,77 @@ class TestReadCodexAccessToken:
assert result == "plain-token-no-jwt"
+class TestResolveXaiOAuthForAux:
+ def test_uses_pool_backed_credentials_without_singleton(self, tmp_path, monkeypatch):
+ """Auxiliary xAI OAuth must see pool-only credentials.
+
+ ``hermes auth status`` already reports these as logged in; compression
+ should not fall through to "no auxiliary provider configured" just
+ because the singleton auth-store entry is absent.
+ """
+ from agent.credential_pool import AUTH_TYPE_OAUTH, PooledCredential, load_pool
+ from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
+
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir(parents=True, exist_ok=True)
+ (hermes_home / "auth.json").write_text(json.dumps({
+ "version": 1,
+ "providers": {},
+ }))
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.delenv("HERMES_XAI_BASE_URL", raising=False)
+ monkeypatch.delenv("XAI_BASE_URL", raising=False)
+
+ pool = load_pool("xai-oauth")
+ pool.add_entry(PooledCredential(
+ provider="xai-oauth",
+ id="xai123",
+ label="pool-only",
+ auth_type=AUTH_TYPE_OAUTH,
+ priority=0,
+ source="manual:xai_pkce",
+ access_token="pool-access-token",
+ refresh_token="pool-refresh-token",
+ base_url=DEFAULT_XAI_OAUTH_BASE_URL,
+ ))
+
+ assert _resolve_xai_oauth_for_aux() == (
+ "pool-access-token",
+ DEFAULT_XAI_OAUTH_BASE_URL,
+ )
+
+ def test_pool_backed_credentials_honor_base_url_env_override(self, tmp_path, monkeypatch):
+ from agent.credential_pool import AUTH_TYPE_OAUTH, PooledCredential, load_pool
+ from hermes_cli.auth import DEFAULT_XAI_OAUTH_BASE_URL
+
+ hermes_home = tmp_path / "hermes"
+ hermes_home.mkdir(parents=True, exist_ok=True)
+ (hermes_home / "auth.json").write_text(json.dumps({
+ "version": 1,
+ "providers": {},
+ }))
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.setenv("HERMES_XAI_BASE_URL", "https://example.x.ai/v1/")
+
+ pool = load_pool("xai-oauth")
+ pool.add_entry(PooledCredential(
+ provider="xai-oauth",
+ id="xai456",
+ label="pool-only",
+ auth_type=AUTH_TYPE_OAUTH,
+ priority=0,
+ source="manual:xai_pkce",
+ access_token="pool-access-token",
+ refresh_token="pool-refresh-token",
+ base_url=DEFAULT_XAI_OAUTH_BASE_URL,
+ ))
+
+ assert _resolve_xai_oauth_for_aux() == (
+ "pool-access-token",
+ "https://example.x.ai/v1",
+ )
+
+
class TestAnthropicOAuthFlag:
"""Test that OAuth tokens get is_oauth=True in auxiliary Anthropic client."""
diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py
index ecaf4f4e639..afeb14f9458 100644
--- a/tests/tools/test_tirith_security.py
+++ b/tests/tools/test_tirith_security.py
@@ -333,6 +333,103 @@ class TestEnsureInstalled:
_tirith_mod._resolved_path = None
+# ---------------------------------------------------------------------------
+# Unsupported platform (Windows etc.) — silent fast-path everywhere
+# ---------------------------------------------------------------------------
+
+class TestUnsupportedPlatform:
+ """When _detect_target() returns None (no tirith binary for this OS+arch),
+ the entire subsystem must stay silent: no PATH probes, no download thread,
+ no disk failure marker, no spawn attempts, no CLI banner. Pattern-matching
+ guards still cover the gap; tirith content scanning is just absent."""
+
+ def test_is_platform_supported_true_on_linux_x86_64(self):
+ with patch("tools.tirith_security.platform.system", return_value="Linux"), \
+ patch("tools.tirith_security.platform.machine", return_value="x86_64"):
+ assert _tirith_mod.is_platform_supported() is True
+
+ def test_is_platform_supported_true_on_darwin_arm64(self):
+ with patch("tools.tirith_security.platform.system", return_value="Darwin"), \
+ patch("tools.tirith_security.platform.machine", return_value="arm64"):
+ assert _tirith_mod.is_platform_supported() is True
+
+ def test_is_platform_supported_false_on_windows(self):
+ with patch("tools.tirith_security.platform.system", return_value="Windows"), \
+ patch("tools.tirith_security.platform.machine", return_value="AMD64"):
+ assert _tirith_mod.is_platform_supported() is False
+
+ def test_is_platform_supported_false_on_unknown_arch(self):
+ with patch("tools.tirith_security.platform.system", return_value="Linux"), \
+ patch("tools.tirith_security.platform.machine", return_value="riscv64"):
+ assert _tirith_mod.is_platform_supported() is False
+
+ @patch("tools.tirith_security._load_security_config")
+ def test_ensure_installed_unsupported_returns_none_no_thread(self, mock_cfg):
+ """Windows: don't start a background install thread, don't write a
+ failure marker — just cache the verdict and return None."""
+ mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
+ "tirith_timeout": 5, "tirith_fail_open": True}
+ _tirith_mod._resolved_path = None
+ with patch("tools.tirith_security.is_platform_supported", return_value=False), \
+ patch("tools.tirith_security.threading.Thread") as MockThread, \
+ patch("tools.tirith_security._mark_install_failed") as mock_mark, \
+ patch("tools.tirith_security.shutil.which") as mock_which:
+ result = ensure_installed()
+ assert result is None
+ MockThread.assert_not_called()
+ mock_mark.assert_not_called()
+ mock_which.assert_not_called()
+ assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
+ assert _tirith_mod._install_failure_reason == "unsupported_platform"
+
+ @patch("tools.tirith_security._load_security_config")
+ def test_check_command_security_unsupported_allows_silently(self, mock_cfg):
+ """Windows: skip the resolver and spawn entirely — return allow with
+ an empty summary so callers can't accidentally surface 'tirith
+ unavailable' messaging to the user."""
+ mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
+ "tirith_timeout": 5, "tirith_fail_open": True}
+ with patch("tools.tirith_security.is_platform_supported", return_value=False), \
+ patch("tools.tirith_security.subprocess.run") as mock_run, \
+ patch("tools.tirith_security._resolve_tirith_path") as mock_resolve:
+ result = check_command_security("rm -rf /")
+ assert result == {"action": "allow", "findings": [], "summary": ""}
+ mock_run.assert_not_called()
+ mock_resolve.assert_not_called()
+
+ @patch("tools.tirith_security._load_security_config")
+ def test_resolve_path_unsupported_caches_failure_without_probing(self, mock_cfg):
+ """The per-command resolver must also short-circuit on Windows so
+ long-running gateways don't churn through `shutil.which` and disk
+ I/O for every scanned command."""
+ mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
+ "tirith_timeout": 5, "tirith_fail_open": True}
+ _tirith_mod._resolved_path = None
+ with patch("tools.tirith_security.is_platform_supported", return_value=False), \
+ patch("tools.tirith_security.shutil.which") as mock_which:
+ result = _tirith_mod._resolve_tirith_path("tirith")
+ assert result == "tirith"
+ mock_which.assert_not_called()
+ assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
+ assert _tirith_mod._install_failure_reason == "unsupported_platform"
+
+ @patch("tools.tirith_security._load_security_config")
+ def test_explicit_path_still_honored_on_unsupported_platform(self, mock_cfg):
+ """If a user explicitly configured a tirith_path (e.g. they built it
+ themselves under WSL), the unsupported-platform short-circuit must
+ NOT override that — explicit config wins."""
+ mock_cfg.return_value = {"tirith_enabled": True,
+ "tirith_path": "/opt/custom/tirith",
+ "tirith_timeout": 5, "tirith_fail_open": True}
+ _tirith_mod._resolved_path = None
+ with patch("tools.tirith_security.is_platform_supported", return_value=False), \
+ patch("os.path.isfile", return_value=True), \
+ patch("os.access", return_value=True):
+ result = _tirith_mod._resolve_tirith_path("/opt/custom/tirith")
+ assert result == "/opt/custom/tirith"
+ assert _tirith_mod._resolved_path == "/opt/custom/tirith"
+
+
# ---------------------------------------------------------------------------
# Failed download caches the miss (Finding #1)
# ---------------------------------------------------------------------------
diff --git a/tools/tirith_security.py b/tools/tirith_security.py
index 1c79892f424..b45d7d29213 100644
--- a/tools/tirith_security.py
+++ b/tools/tirith_security.py
@@ -214,7 +214,12 @@ def _hermes_bin_dir() -> str:
def _detect_target() -> str | None:
- """Return the Rust target triple for the current platform, or None."""
+ """Return the Rust target triple for the current platform, or None.
+
+ Windows is intentionally unsupported — tirith does not ship a Windows
+ build. Callers should treat `None` as "this platform will never have
+ tirith" and silently fall back to pattern-matching guards.
+ """
system = platform.system()
machine = platform.machine().lower()
@@ -236,6 +241,16 @@ def _detect_target() -> str | None:
return f"{arch}-{plat}"
+def is_platform_supported() -> bool:
+ """True when tirith ships a prebuilt binary for this OS+arch.
+
+ Used by callers (CLI banner, etc.) to distinguish "tirith failed to
+ install" from "tirith was never going to install here" — the latter
+ is silent because there is nothing the user can do about it.
+ """
+ return _detect_target() is not None
+
+
def _download_file(url: str, dest: str, timeout: int = 10):
"""Download a URL to a local file."""
req = urllib.request.Request(url)
@@ -448,6 +463,15 @@ def _resolve_tirith_path(configured_path: str) -> str:
explicit = _is_explicit_path(configured_path)
install_failed = _resolved_path is _INSTALL_FAILED
+ # Platform has no tirith build (Windows etc.). Cache the verdict and
+ # return the unexpanded configured path — the spawn loop will fail-open
+ # via the dedupe'd OSError handler, but only after the first call; on
+ # subsequent calls the fast-path above short-circuits before spawning.
+ if not explicit and not is_platform_supported():
+ _resolved_path = _INSTALL_FAILED
+ _install_failure_reason = "unsupported_platform"
+ return expanded
+
# Explicit path: check it and stop. Never auto-download a replacement.
if explicit:
if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
@@ -574,6 +598,14 @@ def ensure_installed(*, log_failures: bool = True):
return path
return None
+ # Platform has no tirith build (e.g. Windows) — don't probe PATH,
+ # don't start a download thread, don't write a disk failure marker.
+ # Pattern-matching guards still run; this path stays silent.
+ if not is_platform_supported():
+ _resolved_path = _INSTALL_FAILED
+ _install_failure_reason = "unsupported_platform"
+ return None
+
configured_path = cfg["tirith_path"]
explicit = _is_explicit_path(configured_path)
expanded = os.path.expanduser(configured_path)
@@ -659,6 +691,12 @@ def check_command_security(command: str) -> dict:
if not cfg["tirith_enabled"]:
return {"action": "allow", "findings": [], "summary": ""}
+ # Unsupported platform (Windows etc.) — tirith has no binary here and
+ # never will. Skip the resolver entirely so we don't even try to spawn.
+ # Pattern-matching guards still run via the rest of approval.py.
+ if not is_platform_supported():
+ return {"action": "allow", "findings": [], "summary": ""}
+
tirith_path = _resolve_tirith_path(cfg["tirith_path"])
timeout = cfg["tirith_timeout"]
fail_open = cfg["tirith_fail_open"]
diff --git a/ui-tui/src/__tests__/approvalAction.test.ts b/ui-tui/src/__tests__/approvalAction.test.ts
new file mode 100644
index 00000000000..851b5093448
--- /dev/null
+++ b/ui-tui/src/__tests__/approvalAction.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+
+import { approvalAction } from '../components/prompts.js'
+
+describe('approvalAction — pure key dispatch for ApprovalPrompt', () => {
+ it('maps Esc to deny — parity with global Ctrl+C cancellation', () => {
+ expect(approvalAction('', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
+ expect(approvalAction('', { escape: true }, 2)).toEqual({ kind: 'choose', choice: 'deny' })
+ })
+
+ it('maps number keys 1..4 to once/session/always/deny in registration order', () => {
+ expect(approvalAction('1', {}, 0)).toEqual({ kind: 'choose', choice: 'once' })
+ expect(approvalAction('2', {}, 0)).toEqual({ kind: 'choose', choice: 'session' })
+ expect(approvalAction('3', {}, 0)).toEqual({ kind: 'choose', choice: 'always' })
+ expect(approvalAction('4', {}, 0)).toEqual({ kind: 'choose', choice: 'deny' })
+ })
+
+ it('ignores out-of-range numbers', () => {
+ expect(approvalAction('0', {}, 1)).toEqual({ kind: 'noop' })
+ expect(approvalAction('5', {}, 1)).toEqual({ kind: 'noop' })
+ expect(approvalAction('9', {}, 1)).toEqual({ kind: 'noop' })
+ })
+
+ it('confirms the current selection on Enter', () => {
+ expect(approvalAction('', { return: true }, 0)).toEqual({ kind: 'choose', choice: 'once' })
+ expect(approvalAction('', { return: true }, 3)).toEqual({ kind: 'choose', choice: 'deny' })
+ })
+
+ it('moves selection up/down within bounds', () => {
+ expect(approvalAction('', { upArrow: true }, 2)).toEqual({ kind: 'move', delta: -1 })
+ expect(approvalAction('', { downArrow: true }, 1)).toEqual({ kind: 'move', delta: 1 })
+ })
+
+ it('clamps selection movement at the edges', () => {
+ expect(approvalAction('', { upArrow: true }, 0)).toEqual({ kind: 'noop' })
+ expect(approvalAction('', { downArrow: true }, 3)).toEqual({ kind: 'noop' })
+ })
+
+ it('Esc beats numeric/return — denying is always the first interpretation', () => {
+ // If a terminal somehow delivers Esc + a digit in the same event, deny
+ // wins. Documents the precedence so a future refactor doesn't flip it.
+ expect(approvalAction('1', { escape: true }, 0)).toEqual({ kind: 'choose', choice: 'deny' })
+ expect(approvalAction('', { escape: true, return: true }, 1)).toEqual({ kind: 'choose', choice: 'deny' })
+ })
+
+ it('returns noop for unrelated keystrokes (printable letters etc.)', () => {
+ expect(approvalAction('a', {}, 0)).toEqual({ kind: 'noop' })
+ expect(approvalAction(' ', {}, 0)).toEqual({ kind: 'noop' })
+ })
+})
diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts
index 066292abfa5..0d3fd69c1ed 100644
--- a/ui-tui/src/__tests__/useInputHandlers.test.ts
+++ b/ui-tui/src/__tests__/useInputHandlers.test.ts
@@ -1,6 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
-import { applyVoiceRecordResponse } from '../app/useInputHandlers.js'
+import { applyVoiceRecordResponse, shouldFallThroughForScroll } from '../app/useInputHandlers.js'
+
+const baseKey = {
+ downArrow: false,
+ pageDown: false,
+ pageUp: false,
+ shift: false,
+ upArrow: false,
+ wheelDown: false,
+ wheelUp: false
+}
+
+describe('shouldFallThroughForScroll — keep transcript scrolling alive during prompt overlays', () => {
+ it('falls through for wheel scrolls', () => {
+ expect(shouldFallThroughForScroll({ ...baseKey, wheelUp: true })).toBe(true)
+ expect(shouldFallThroughForScroll({ ...baseKey, wheelDown: true })).toBe(true)
+ })
+
+ it('falls through for PageUp / PageDown', () => {
+ expect(shouldFallThroughForScroll({ ...baseKey, pageUp: true })).toBe(true)
+ expect(shouldFallThroughForScroll({ ...baseKey, pageDown: true })).toBe(true)
+ })
+
+ it('falls through for Shift+ArrowUp / Shift+ArrowDown', () => {
+ expect(shouldFallThroughForScroll({ ...baseKey, shift: true, upArrow: true })).toBe(true)
+ expect(shouldFallThroughForScroll({ ...baseKey, shift: true, downArrow: true })).toBe(true)
+ })
+
+ it('does NOT fall through for plain arrows — those drive in-prompt selection', () => {
+ expect(shouldFallThroughForScroll({ ...baseKey, upArrow: true })).toBe(false)
+ expect(shouldFallThroughForScroll({ ...baseKey, downArrow: true })).toBe(false)
+ })
+
+ it('does NOT fall through for plain Shift — without an arrow it is a no-op', () => {
+ expect(shouldFallThroughForScroll({ ...baseKey, shift: true })).toBe(false)
+ })
+
+ it('does NOT fall through for unrelated state (no scroll keys held)', () => {
+ expect(shouldFallThroughForScroll(baseKey)).toBe(false)
+ })
+})
describe('applyVoiceRecordResponse', () => {
it('reverts optimistic REC state when the gateway reports voice busy', () => {
diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts
index ce25af70edd..59de48a310d 100644
--- a/ui-tui/src/app/useInputHandlers.ts
+++ b/ui-tui/src/app/useInputHandlers.ts
@@ -23,6 +23,42 @@ import { getUiState } from './uiStore.js'
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
+/**
+ * Approval / clarify / confirm overlays mount their own `useInput` handlers
+ * for the in-prompt keys (arrows, numbers, Enter, sometimes Esc). The global
+ * input handler used to early-return for any other key while one of those
+ * overlays was up, which silently disabled transcript scrolling — the user
+ * couldn't read context above the prompt that the prompt itself was asking
+ * about. Returns true when the key is a transcript-scroll input that should
+ * fall through to the global scroll handlers even while a prompt is active.
+ *
+ * Modifier-held wheel (precision mode) is included — a user who wants to
+ * scroll a single line at a time during a prompt expects it to work.
+ */
+export function shouldFallThroughForScroll(key: {
+ downArrow: boolean
+ pageDown: boolean
+ pageUp: boolean
+ shift: boolean
+ upArrow: boolean
+ wheelDown: boolean
+ wheelUp: boolean
+}): boolean {
+ if (key.wheelUp || key.wheelDown) {
+ return true
+ }
+
+ if (key.pageUp || key.pageDown) {
+ return true
+ }
+
+ if (key.shift && (key.upArrow || key.downArrow)) {
+ return true
+ }
+
+ return false
+}
+
export function applyVoiceRecordResponse(
response: null | VoiceRecordResponse,
starting: boolean,
@@ -224,7 +260,18 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
// handlers must receive keystrokes (arrow keys, numbers, Enter). Only
// intercept Ctrl+C here so the user can deny/dismiss — all other keys
// fall through to the component-level handlers.
- if (overlay.approval || overlay.clarify || overlay.confirm) {
+ //
+ // Scroll inputs (wheel / PageUp / PageDown / Shift+↑↓) are special:
+ // they must reach the transcript scroll handlers below even with a
+ // prompt up. Long-thread context the prompt is asking about often
+ // lives above the visible viewport, and being unable to read it while
+ // answering felt like the prompt had locked the entire UI. Explicitly
+ // skip the prompt-overlay early-return for scroll keys so they fall
+ // through to the wheel / PageUp / Shift+arrow handlers below.
+ const promptOverlay = overlay.approval || overlay.clarify || overlay.confirm
+ const fallThroughForScroll = promptOverlay && shouldFallThroughForScroll(key)
+
+ if (promptOverlay && !fallThroughForScroll) {
if (isCtrl(key, ch, 'c')) {
cancelOverlayFromCtrlC()
}
@@ -298,7 +345,13 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
patchOverlayState({ picker: false })
}
- return
+ // When a prompt overlay is up and the user pressed a scroll key, fall
+ // through to the global scroll handlers below instead of returning.
+ // Otherwise nothing above this comment matched, and there's nothing
+ // useful to do for an arbitrary key while blocked.
+ if (!fallThroughForScroll) {
+ return
+ }
}
if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) {
diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx
index e9d42485d9b..3dfd31be869 100644
--- a/ui-tui/src/components/prompts.tsx
+++ b/ui-tui/src/components/prompts.tsx
@@ -11,28 +11,65 @@ const OPTS = ['once', 'session', 'always', 'deny'] as const
const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const
const CMD_PREVIEW_LINES = 10
+type ApprovalKey = {
+ downArrow?: boolean
+ escape?: boolean
+ return?: boolean
+ upArrow?: boolean
+}
+
+type ApprovalAction =
+ | { kind: 'choose'; choice: (typeof OPTS)[number] }
+ | { kind: 'move'; delta: -1 | 1 }
+ | { kind: 'noop' }
+
+/**
+ * Pure key-dispatch for the approval prompt — exported so the regression
+ * matrix (Esc, Ctrl+C-equivalent, number keys, Enter, ↑↓) is testable
+ * without mounting React + Ink + a fake stdin. The component just maps the
+ * action onto its own state setters.
+ *
+ * Esc and number keys both terminate the prompt; Esc maps to deny (parity
+ * with the global Ctrl+C handler that already calls cancelOverlayFromCtrlC
+ * for approvals). Numbers 1..OPTS.length pick the labelled choice. Enter
+ * confirms the current selection. ↑/↓ moves the selection within bounds.
+ */
+export function approvalAction(ch: string, key: ApprovalKey, sel: number): ApprovalAction {
+ if (key.escape) {
+ return { kind: 'choose', choice: 'deny' }
+ }
+
+ const n = parseInt(ch, 10)
+
+ if (n >= 1 && n <= OPTS.length) {
+ return { kind: 'choose', choice: OPTS[n - 1]! }
+ }
+
+ if (key.return) {
+ return { kind: 'choose', choice: OPTS[sel]! }
+ }
+
+ if (key.upArrow && sel > 0) {
+ return { kind: 'move', delta: -1 }
+ }
+
+ if (key.downArrow && sel < OPTS.length - 1) {
+ return { kind: 'move', delta: 1 }
+ }
+
+ return { kind: 'noop' }
+}
+
export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
const [sel, setSel] = useState(0)
useInput((ch, key) => {
- if (key.upArrow && sel > 0) {
- setSel(s => s - 1)
- }
+ const action = approvalAction(ch, key, sel)
- if (key.downArrow && sel < OPTS.length - 1) {
- setSel(s => s + 1)
- }
-
- const n = parseInt(ch, 10)
-
- if (n >= 1 && n <= OPTS.length) {
- onChoice(OPTS[n - 1]!)
-
- return
- }
-
- if (key.return) {
- onChoice(OPTS[sel]!)
+ if (action.kind === 'choose') {
+ onChoice(action.choice)
+ } else if (action.kind === 'move') {
+ setSel(s => s + action.delta)
}
})
@@ -71,7 +108,7 @@ export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) {
))}
- ↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny
+ ↑/↓ select · Enter confirm · 1-4 quick pick · Esc/Ctrl+C deny
)
}
diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md
index fca8a99a248..2a48deb2448 100644
--- a/website/docs/user-guide/security.md
+++ b/website/docs/user-guide/security.md
@@ -537,6 +537,8 @@ security:
When `tirith_fail_open` is `true` (default), commands proceed if tirith is not installed or times out. Set to `false` in high-security environments to block commands when tirith is unavailable.
+Tirith ships prebuilt binaries for Linux (x86_64 / aarch64) and macOS (x86_64 / arm64). On platforms with no prebuilt binary (Windows, etc.), tirith is silently skipped — pattern-matching guards still run, and the CLI does not surface an "unavailable" banner. To use tirith on Windows, run Hermes under WSL.
+
Tirith's verdict integrates with the approval flow: safe commands pass through, while both suspicious and blocked commands trigger user approval with the full tirith findings (severity, title, description, safer alternatives). Users can approve or deny — the default choice is deny to keep unattended scenarios secure.
### Context File Injection Protection
diff --git a/website/scripts/generate-skill-docs.py b/website/scripts/generate-skill-docs.py
index 2a0694a61c8..c932f01e1bc 100755
--- a/website/scripts/generate-skill-docs.py
+++ b/website/scripts/generate-skill-docs.py
@@ -602,7 +602,7 @@ def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) ->
else:
optional[meta["category"]].append(meta)
- def cat_section(bucket: dict[str, list[dict[str, Any]]]) -> list[dict]:
+ def cat_section(bucket: dict[str, list[dict[str, Any]]], source: str) -> list[dict]:
result = []
for category in sorted(bucket):
items = sorted(bucket[category], key=lambda m: m["slug"])
@@ -610,6 +610,13 @@ def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) ->
{
"type": "category",
"label": category,
+ # Docusaurus generates a translation key from the label by
+ # default (e.g. sidebar.docs.category.productivity). When
+ # the same category name appears under both Bundled and
+ # Optional, the duplicate keys break i18n extraction and
+ # fail the build. Scope each category by source to keep
+ # the keys unique.
+ "key": f"skills-{source}-{category}",
"collapsed": True,
"items": [sidebar_doc_id(m) for m in items],
}
@@ -617,8 +624,8 @@ def build_sidebar_items(entries: list[tuple[dict[str, Any], dict[str, Any]]]) ->
return result
return {
- "bundled_categories": cat_section(bundled),
- "optional_categories": cat_section(optional),
+ "bundled_categories": cat_section(bundled, "bundled"),
+ "optional_categories": cat_section(optional, "optional"),
}
@@ -633,6 +640,8 @@ def _render_sidebar_item(item: Any, indent: int) -> list[str]:
lines.append(f"{pad}{{")
lines.append(f"{pad} type: 'category',")
lines.append(f"{pad} label: '{item['label']}',")
+ if item.get("key"):
+ lines.append(f"{pad} key: '{item['key']}',")
if item.get("collapsed", True):
lines.append(f"{pad} collapsed: true,")
lines.append(f"{pad} items: [")
diff --git a/website/sidebars.ts b/website/sidebars.ts
index fe7b741eb2e..52ed452d046 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -121,6 +121,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'apple',
+ key: 'skills-bundled-apple',
collapsed: true,
items: [
'user-guide/skills/bundled/apple/apple-apple-notes',
@@ -133,6 +134,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'autonomous-ai-agents',
+ key: 'skills-bundled-autonomous-ai-agents',
collapsed: true,
items: [
'user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-claude-code',
@@ -144,6 +146,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'creative',
+ key: 'skills-bundled-creative',
collapsed: true,
items: [
'user-guide/skills/bundled/creative/creative-architecture-diagram',
@@ -170,6 +173,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'data-science',
+ key: 'skills-bundled-data-science',
collapsed: true,
items: [
'user-guide/skills/bundled/data-science/data-science-jupyter-live-kernel',
@@ -178,6 +182,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'devops',
+ key: 'skills-bundled-devops',
collapsed: true,
items: [
'user-guide/skills/bundled/devops/devops-kanban-orchestrator',
@@ -188,6 +193,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'dogfood',
+ key: 'skills-bundled-dogfood',
collapsed: true,
items: [
'user-guide/skills/bundled/dogfood/dogfood-dogfood',
@@ -196,6 +202,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'email',
+ key: 'skills-bundled-email',
collapsed: true,
items: [
'user-guide/skills/bundled/email/email-himalaya',
@@ -204,6 +211,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'gaming',
+ key: 'skills-bundled-gaming',
collapsed: true,
items: [
'user-guide/skills/bundled/gaming/gaming-minecraft-modpack-server',
@@ -213,6 +221,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'github',
+ key: 'skills-bundled-github',
collapsed: true,
items: [
'user-guide/skills/bundled/github/github-codebase-inspection',
@@ -226,6 +235,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'mcp',
+ key: 'skills-bundled-mcp',
collapsed: true,
items: [
'user-guide/skills/bundled/mcp/mcp-native-mcp',
@@ -234,6 +244,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'media',
+ key: 'skills-bundled-media',
collapsed: true,
items: [
'user-guide/skills/bundled/media/media-gif-search',
@@ -246,6 +257,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'mlops',
+ key: 'skills-bundled-mlops',
collapsed: true,
items: [
'user-guide/skills/bundled/mlops/mlops-models-audiocraft',
@@ -262,6 +274,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'note-taking',
+ key: 'skills-bundled-note-taking',
collapsed: true,
items: [
'user-guide/skills/bundled/note-taking/note-taking-obsidian',
@@ -270,6 +283,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'productivity',
+ key: 'skills-bundled-productivity',
collapsed: true,
items: [
'user-guide/skills/bundled/productivity/productivity-airtable',
@@ -286,6 +300,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'red-teaming',
+ key: 'skills-bundled-red-teaming',
collapsed: true,
items: [
'user-guide/skills/bundled/red-teaming/red-teaming-godmode',
@@ -294,6 +309,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'research',
+ key: 'skills-bundled-research',
collapsed: true,
items: [
'user-guide/skills/bundled/research/research-arxiv',
@@ -306,6 +322,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'smart-home',
+ key: 'skills-bundled-smart-home',
collapsed: true,
items: [
'user-guide/skills/bundled/smart-home/smart-home-openhue',
@@ -314,6 +331,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'social-media',
+ key: 'skills-bundled-social-media',
collapsed: true,
items: [
'user-guide/skills/bundled/social-media/social-media-xurl',
@@ -322,6 +340,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'software-development',
+ key: 'skills-bundled-software-development',
collapsed: true,
items: [
'user-guide/skills/bundled/software-development/software-development-debugging-hermes-tui-commands',
@@ -340,6 +359,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'yuanbao',
+ key: 'skills-bundled-yuanbao',
collapsed: true,
items: [
'user-guide/skills/bundled/yuanbao/yuanbao-yuanbao',
@@ -355,6 +375,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'autonomous-ai-agents',
+ key: 'skills-optional-autonomous-ai-agents',
collapsed: true,
items: [
'user-guide/skills/optional/autonomous-ai-agents/autonomous-ai-agents-blackbox',
@@ -364,6 +385,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'blockchain',
+ key: 'skills-optional-blockchain',
collapsed: true,
items: [
'user-guide/skills/optional/blockchain/blockchain-evm',
@@ -374,6 +396,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'communication',
+ key: 'skills-optional-communication',
collapsed: true,
items: [
'user-guide/skills/optional/communication/communication-one-three-one-rule',
@@ -382,6 +405,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'creative',
+ key: 'skills-optional-creative',
collapsed: true,
items: [
'user-guide/skills/optional/creative/creative-blender-mcp',
@@ -394,6 +418,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'devops',
+ key: 'skills-optional-devops',
collapsed: true,
items: [
'user-guide/skills/optional/devops/devops-cli',
@@ -404,6 +429,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'dogfood',
+ key: 'skills-optional-dogfood',
collapsed: true,
items: [
'user-guide/skills/optional/dogfood/dogfood-adversarial-ux-test',
@@ -412,6 +438,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'email',
+ key: 'skills-optional-email',
collapsed: true,
items: [
'user-guide/skills/optional/email/email-agentmail',
@@ -420,6 +447,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'finance',
+ key: 'skills-optional-finance',
collapsed: true,
items: [
'user-guide/skills/optional/finance/finance-3-statement-model',
@@ -435,6 +463,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'health',
+ key: 'skills-optional-health',
collapsed: true,
items: [
'user-guide/skills/optional/health/health-fitness-nutrition',
@@ -444,6 +473,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'mcp',
+ key: 'skills-optional-mcp',
collapsed: true,
items: [
'user-guide/skills/optional/mcp/mcp-fastmcp',
@@ -453,6 +483,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'migration',
+ key: 'skills-optional-migration',
collapsed: true,
items: [
'user-guide/skills/optional/migration/migration-openclaw-migration',
@@ -461,6 +492,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'mlops',
+ key: 'skills-optional-mlops',
collapsed: true,
items: [
'user-guide/skills/optional/mlops/mlops-accelerate',
@@ -496,6 +528,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'productivity',
+ key: 'skills-optional-productivity',
collapsed: true,
items: [
'user-guide/skills/optional/productivity/productivity-canvas',
@@ -510,6 +543,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'research',
+ key: 'skills-optional-research',
collapsed: true,
items: [
'user-guide/skills/optional/research/research-bioinformatics',
@@ -526,6 +560,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'security',
+ key: 'skills-optional-security',
collapsed: true,
items: [
'user-guide/skills/optional/security/security-1password',
@@ -536,6 +571,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'software-development',
+ key: 'skills-optional-software-development',
collapsed: true,
items: [
'user-guide/skills/optional/software-development/software-development-rest-graphql-debug',
@@ -544,6 +580,7 @@ const sidebars: SidebarsConfig = {
{
type: 'category',
label: 'web-development',
+ key: 'skills-optional-web-development',
collapsed: true,
items: [
'user-guide/skills/optional/web-development/web-development-page-agent',