Review finding: with multiline shortcuts on, a `\` + Enter arriving inside
a paste kept the literal backslash while a typed one removed it. The
continuation branch inserts a newline anyway, so it runs first and the
outcome no longer depends on timing.
Without bracketed paste (tmux strips it; IME/voice dictation emits keys
one at a time) each pasted newline reaches _tui_handle_enter as its own
Enter event, so a multi-line paste while the agent ran became N separate
steering messages. An Enter within 50 ms of the last buffer change is
text still arriving; insert the newline and let the buffer accumulate.
Humans type >50 ms apart, so a real submit is unaffected.
Same mechanism as #10997 (@iRonin) and #19498 (@Ralojeil1), which
targeted the removed classic-CLI handle_enter.
Refs #10994
Every inline glyph — CLI banner/status bar/response labels/goodbye, setup
and doctor boxes, gateway update prompts, WhatsApp reply prefix, TUI theme,
locale strings and the docs — used ⚕, the staff of Asclepius (medicine).
Hermes carries the Caduceus ☤. The ASCII-art logo was already correct.
Mechanical swap across 60 files (no logic change); both glyphs are
East-Asian-width Neutral so no layout shifts. Skins that set their own
`response_label` / `goodbye` are unaffected.
Direction from PR #7064 (@bixycler), the earliest of #7064 / #9611 / #15574,
redone against current main.
Fixes#9565
Follow-up to #106480. Sites that ask for a code after the password stopped
the agent cold: the login classifier excludes one-time-code fields on
purpose (a password must never land in an OTP box) and there was no tool
for the second step, so the only move was to ask in chat.
browser_vault_enter_code
Fills the one-time code the current page asks for. Two sources, same
invariant as passwords (the code goes to the page over the supervisor
socket and never enters model context):
- a TOTP seed on the login: local vault `otp_secret` (RFC 6238, stdlib,
verified against the RFC test vectors), 1Password `op item get --otp`,
Bitwarden `bw get totp`. Nobody is asked.
- no seed: the surface prompts "Verification code for {site}"; the user
types what their phone/email/app shows. Enter on empty / Skip declines
and the tool returns code_declined ("do not ask again this turn").
no_code_field tells the model the site wants a passkey / hardware key /
app approval: hand it to the user's device and wait for navigation.
Per-digit OTP boxes (maxlength=1 pattern) get one digit each in DOM order.
Surfaces
CLI: sudo-style panel, code shown as typed (not a secret worth masking,
typos must be visible), Enter submits, ESC/empty skips.
Desktop: "Verification code for {site}" card via vault.code.request /
vault.code.respond (gateway), owner-routed like the other vault prompts.
Settings → Passwords & Logins: optional "Authenticator key" field on the
add form (base32 or otpauth:// link); items with one show a "2FA auto"
badge. `hermes vault add` asks for the same optional key.
browser_vault_fill's result now says what to do next ("if the site asks
for a verification code, call browser_vault_enter_code with this handle").
Six locales.
Verified live (real model, local 2FA site that checks the TOTP; CLI PTY):
A. login saved with authenticator key → signed in through 2FA, zero
prompts, code/password absent from the transcript
B. login without key → code panel → user types code → signed in
C. panel dismissed → agent stops and explains, never asks in chat
Unit: RFC 6238 vectors, seed normalisation, mint-without-asking, per-digit
spread, decline, no-code-field; Desktop card test (owner routing, trim, Skip).
Nobody should have to learn `hermes vault add` or find a toggle before "log into GitHub" works.
- browser_vault_save_login: when the agent reaches a sign-in page with no saved login it asks the user
on THEIR surface (CLI two-step panel on the sudo modal: identifier shown, password masked; Desktop
card with labelled Email/username + Password fields). The answer goes to the encrypted vault bound to
the page origin and is filled at once; the model gets back only the handle and identifier. Declining
returns save_declined; headless sessions get prompt_unavailable. Never a password in chat.
- Vault tools ride with the browser toolset (check_browser_requirements) instead of appearing only once
the vault has items — an empty vault is exactly when save_login is needed. browser_vault_list hints
at it when empty.
- 1Password / Bitwarden are login sources as soon as their CLI is installed; `vault.<name>.enabled`
is opt-OUT only. Settings shows Detected/Locked/Unlocked/Off/Not detected with a switch only for
installed managers; `hermes vault sources` reports detection, `--disable`/`--enable` flip the opt-out.
- Desktop nav/page renamed "Passwords & Logins"; empty state tells the user they do not need to add
anything; all five locales updated. Docs rewritten from "how it works" to "say log into X".
- New per-thread SaveLoginPrompt callback (agent/vault_backends/unlock.py) installed beside the unlock
prompt on every CLI site and the gateway bridge (vault.save_login.request/respond/expire), propagated
to worker threads via tools.thread_context.
Live: CLI PTY (real model, packaged Chromium, local login server) — panel shown, identifier + masked
password typed, server received the correct password, password absent from terminal transcript and
from every file under HERMES_HOME outside vault/. Native Electron (headless, isolated HOME/HERMES_HOME,
own Vite + CDP port) — card shown, "Save & sign in", server received the password, Settings lists the
saved item, password absent from the rendered UI.
Live Ink TUI repro: with the unlock card mounted, keystrokes reached BOTH the
masked prompt and the still-focused composer, so the master password echoed
in clear text in the composer row and was queued as a message. `$isBlocked`
(which unmounts the composer for approval/sudo/secret cards) did not list the
new overlay; the pet's awaiting-input predicate had the same gap. After the
fix the raw PTY stream no longer contains the typed password.
Also tightens the classic-CLI panel copy to fit an 80-column box.
The browser vault now draws from three login sources behind one handle
shape: the local encrypted vault (vault_…), 1Password Login items (op:…)
and Bitwarden Password Manager logins (bw:…). browser_vault_list aggregates
metadata across them; browser_vault_fill routes by prefix and resolves the
password at fill time only, through the manager CLI.
External managers are locked until the user unlocks them for the current
session. The new browser_vault_unlock tool (and the fill path, implicitly)
asks the surface to show a masked master-password prompt — CLI panel
(reuses the sudo panel state), TUI/Desktop via a vault.unlock.request
blocking card. The password goes to `op signin --raw` / `bw unlock --raw`
on stdin, never argv or env; only the session token is kept, in memory,
with a 30-minute idle TTL, cleared on session close or `vault.lock`.
Headless contexts (cron, webhook, api_server, -q) can never prompt: the
manager is reported as locked with unlock=unavailable_in_this_session and
fill refuses — the same posture approvals take where nobody can answer.
Config: vault.onepassword / vault.bitwarden {enabled, binary_path, …};
a 1Password service-account token skips the prompt for headless use.
RPC: vault.sources, vault.source.set, vault.unlock, vault.lock for Settings.
Tests (2, real subprocess against a fake bw; each proven red by sabotage):
headless never prompts or spawns; unlock feeds stdin only, token never
enters os.environ, fill routes by prefix and the password only reaches the
fill script.
No runtime consumer read the proxy (terminal_tool/environments call is_interrupted()/set_interrupt()
directly); its only users were tests patching tools.interrupt._interrupt_event, which had no effect on
the code under test. tools/terminal_tool.py's own re-export of the name is owned by another worker.
run_agent.py: delete the `# noqa: F401` re-export block (agent.process_bootstrap
OpenAI/_SafeWriter/_get_proxy_*, model_tools get_tool_definitions/
handle_function_call/check_toolset_requirements, FailoverReason,
_qwen_portal_headers/_routermint_headers, session_persistence names,
estimate_request_tokens_rough, ContextCompressor + friends, jittered_backoff,
prompt_builder names, message_sanitization names, tool_dispatch_helpers
names) — 41 names run_agent never used itself — and the `_STREAM_DIAG_HEADERS`
back-compat class alias (no in-tree reader). run_agent now imports only what
it uses (get_toolset_for_tool, is_local_endpoint, coalesce/uniquify tool-call
ids, cleanup_vm/get_active_env from terminal_tool_lifecycle).
agent/*: `_ra().X` late-binds that only reached a re-export now import the
defining module directly (agent_runtime_helpers -> process_bootstrap.OpenAI,
model_tools.handle_function_call, session_persistence._safe_session_filename_component;
agent_init -> model_tools.get_tool_definitions/check_toolset_requirements,
_lazy_headers("agent.client_lifecycle", ...) for qwen/routermint;
system_prompt -> agent.prompt_builder / model_tools directly, dropping its
own _ra() shim and the `_r` parameter threading). `_ra()` stays for
run_agent-resident names (logger, AIAgent, _hermes_home, _set_interrupt, ...).
toolsets.py: remove resolve_multiple_toolsets (shim-only, restored by
34abf954bd); tests/test_toolsets.py pins the same union behavior via
resolve_toolset over each name.
providers/__init__.py: drop the OMIT_TEMPERATURE re-export (no callers via the
package); ProviderProfile stays because __init__ uses it for annotations —
2 tests repointed to providers.base.
agent/iteration_budget.py: drop the "run_agent re-exports the class"
docstring pointer; 4 tests import IterationBudget from its home.
model_tools.py (arg_coercion names), agent/tool_executor.py, and
hermes_cli/cli_session_mixin.py repoints landed via a sibling commit on this
shared worktree.
Callers repointed: gateway/run.py, hermes_cli/cli_chat_turn_mixin.py,
hermes_cli/cli_tui_mixin.py, tui_gateway/session_workdir.py,
agent/transports/codex.py (one-line imports) + comment pointers in
tools/file_state.py, tools/schema_sanitizer.py, scripts/tool_search_livetest.py.
Tests: patch("run_agent.X") / monkeypatch.setattr(run_agent, "X") /
`from run_agent import X` -> defining module across 99 test files.
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
326 methods lifted by AST (bodies identical; ast.dump-verified) into
hermes_cli/cli_{tui,status_bar,voice,model_switch,session,stream,modal,
terminal,info,loops}_mixin.py. cli.py-internal symbols resolve via lazy
'from cli import ...' inside each method (no import cycle; patch('cli.X')
keeps working). The three 'global' writers (_skill_commands, _cli_wake_owner)
now write the cli module attribute explicitly so the origin's readers still
see them. Dropped imports left unused in cli.py; kept display_hermes_home /
build_welcome_banner as re-exports (mixins + tests resolve them via cli).
Repointed two AST change-detector tests to cli_tui_mixin.py; one test
fixture now keeps 'cli' in sys.modules across its patch.dict scope.