Merge remote-tracking branch 'origin/main' into fix/egress-review-fixes

This commit is contained in:
Teknium 2026-06-14 13:13:53 -07:00
commit fb5649f2e8
No known key found for this signature in database
749 changed files with 68114 additions and 8224 deletions

341
cli.py
View file

@ -394,7 +394,7 @@ def load_cli_config() -> Dict[str, Any]:
"terminal": {
"env_type": "local",
"cwd": ".", # "." is resolved to os.getcwd() at runtime
"timeout": 60,
"home_mode": "auto",
"lifetime_seconds": 300,
"docker_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"docker_forward_env": [],
@ -456,6 +456,9 @@ def load_cli_config() -> Dict[str, Any]:
"busy_input_mode": "interrupt",
"persistent_output": True,
"persistent_output_max_lines": 200,
# Print a one-line summary of resolved modal prompts (approval /
# clarify) into scrollback so the decision survives the repaint.
"persist_prompts": True,
"skin": "default",
},
@ -586,6 +589,7 @@ def load_cli_config() -> Dict[str, Any]:
"env_type": "TERMINAL_ENV",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"home_mode": "TERMINAL_HOME_MODE",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
@ -2824,6 +2828,53 @@ def _strip_leaked_terminal_responses(text: str) -> str:
return cleaned
def _estimate_tui_input_height(
lines: list[str] | tuple[str, ...],
prompt_text: str,
terminal_columns: int,
*,
max_height: int = 8,
) -> int:
"""Estimate classic prompt_toolkit input rows using live terminal cells.
The TextArea prompt is injected with prompt_toolkit's BeforeInput
processor, which means it consumes cells only on logical line 0. After a
narrow resize, that first row can leave only one input cell beside an icon
prompt such as ``⚔ ``, while continuation rows use the full terminal width.
Never substitute a fake wide fallback here: under- or over-allocating the
TextArea height leaves stale prompt/input cells visible at the bottom of the
terminal.
"""
try:
from prompt_toolkit.utils import get_cwidth
except Exception:
get_cwidth = lambda value: len(value or "") # type: ignore[assignment]
try:
columns = int(terminal_columns or 0)
except (TypeError, ValueError):
columns = 0
columns = max(1, columns)
prompt_width = max(0, get_cwidth(prompt_text or ""))
visual_lines = 0
for index, line in enumerate(lines or [""]):
# prompt_toolkit's TextArea injects ``prompt`` via BeforeInput, which
# applies only to logical line 0. Wrapped continuation rows, and later
# logical lines, use the full terminal width. Count the display cells
# after that same transformation rather than subtracting the prompt from
# every wrapped row.
line_width = get_cwidth(line or "")
display_width = line_width + (prompt_width if index == 0 else 0)
if display_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-display_width // columns))
return min(max(visual_lines, 1), max(1, int(max_height or 1)))
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
"""Collect local image attachments for single-query CLI flows."""
message = query or ""
@ -3426,6 +3477,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# frozen when the agent thread completes, displayed in the status bar.
self._prompt_start_time: Optional[float] = None # time.time() when turn started
self._prompt_duration: float = 0.0 # frozen duration of last completed turn
self._last_turn_finished_at: Optional[float] = None # time.time() when the last agent loop finished
# Initialize SQLite session store early so /title works before first message
self._session_db = None
try:
@ -3503,6 +3555,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# the next submitted input, whether it's the selection or anything
# else). See #34584.
self._pending_resume_sessions = None
# One-shot agent seed set by a slash handler (e.g. /blueprint <name>)
# that wants its output run as the next agent turn. Consumed and cleared
# by the interactive loop immediately after process_command() returns.
self._pending_agent_seed = None
self._secret_state = None
self._secret_deadline = 0
self._spinner_text: str = "" # thinking spinner text for TUI
@ -3681,9 +3737,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
startup UI and ``_replay_output_history`` cannot reconstruct it
(the banner was never added to ``_OUTPUT_HISTORY``).
Instead we just reset prompt_toolkit's renderer cache so the next
incremental redraw starts from a clean slate, then let
``original_on_resize`` recalculate layout for the new size.
Let prompt_toolkit's own resize path run with its renderer cursor
cache intact. Its Application._on_resize() starts with
renderer.erase(leave_alternate_screen=False), which needs the cached
cursor position to move back to the live prompt origin before
erase_down(). Resetting the renderer before that erase loses the
origin and can leave stale prompt glyphs after a narrow resize.
We also flag ``_status_bar_suppressed_after_resize`` so the dynamic
status bar and input separator rules stay hidden until the next user
@ -3694,14 +3753,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
next prompt restores the bar cleanly.
"""
self._status_bar_suppressed_after_resize = True
try:
app.renderer.reset(leave_alternate_screen=False)
except Exception:
pass
try:
app.invalidate()
except Exception:
pass
original_on_resize()
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
@ -3812,6 +3863,19 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
emoji = "⏱" if live else "⏲"
return f"{emoji} {time_str}"
@staticmethod
def _format_idle_since(last_finished_at: Optional[float], turn_live: bool) -> str:
"""Format time since the last final agent response for the status bar.
Returns an empty string while a turn is live (the per-prompt elapsed
timer covers that case) or before the first turn has completed.
Compact read-out: ``✓ 42s`` / ``✓ 3m`` / ``✓ 1h 12m``.
"""
if turn_live or last_finished_at is None:
return ""
idle = max(0.0, time.time() - last_finished_at)
return f"✓ {format_duration_compact(idle)}"
def _get_status_bar_snapshot(self) -> Dict[str, Any]:
# Prefer the agent's model name — it updates on fallback.
# self.model reflects the originally configured model and never
@ -3835,6 +3899,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
getattr(self, "_prompt_duration", 0.0),
live=getattr(self, "_prompt_start_time", None) is not None,
),
"idle_since": self._format_idle_since(
getattr(self, "_last_turn_finished_at", None),
turn_live=getattr(self, "_prompt_start_time", None) is not None,
),
"context_tokens": 0,
"context_length": None,
"context_percent": None,
@ -4146,6 +4214,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
prompt_elapsed = snapshot.get("prompt_elapsed")
if prompt_elapsed:
parts.append(prompt_elapsed)
idle_since = snapshot.get("idle_since")
if idle_since:
parts.append(idle_since)
if yolo_active:
parts.append("⚠ YOLO")
return self._trim_status_bar_text(" │ ".join(parts), width)
@ -4247,6 +4318,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if prompt_elapsed:
frags.append(("class:status-bar-dim", " │ "))
frags.append(("class:status-bar-dim", prompt_elapsed))
# Position 8: idle time since the last final agent response
idle_since = snapshot.get("idle_since")
if idle_since:
frags.append(("class:status-bar-dim", " │ "))
frags.append(("class:status-bar-dim", idle_since))
if yolo_active:
frags.append(("class:status-bar-dim", " │ "))
frags.append(("class:status-bar-yolo", "⚠ YOLO"))
@ -5552,6 +5628,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
f"{_escape(desc)} [dim]({skill_count} skills)[/]"
)
quick_commands = self.config.get("quick_commands", {})
if quick_commands:
_cprint(f"\n ⚡ {_BOLD}Quick Commands{_RST} ({len(quick_commands)} configured):")
for name, qcmd in sorted(quick_commands.items()):
desc = qcmd.get("description", qcmd.get("type", ""))
ChatConsole().print(
f" [bold {_accent_hex()}]{('/' + name):<22}[/] [dim]-[/] {_escape(desc)}"
)
_cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}")
_cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}")
_cprint(f" {_DIM}Draft editor: Ctrl+G (Alt+G in VSCode/Cursor){_RST}")
@ -5821,6 +5906,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
except Exception:
pass
def _discard_session_if_empty(self, session_id: Optional[str]) -> bool:
"""Drop a just-ended session row when it never gained content.
Starting the CLI and immediately quitting (or rotating with /new,
/clear) used to leave an empty untitled row behind that clutters
``/resume`` and ``hermes sessions list``. Delegates the
check-and-delete to ``SessionDB.delete_session_if_empty``, which
only removes rows with no messages, no title, and no child
sessions. Ported from google-gemini/gemini-cli#27770.
"""
if not self._session_db or not session_id:
return False
# In-memory transcript is authoritative: if this CLI object holds
# conversation messages (flushed to the DB or not), the session is
# not empty. Protects against pruning a real conversation whose DB
# flush failed or hasn't happened yet.
if getattr(self, "conversation_history", None):
return False
try:
from hermes_constants import get_hermes_home as _ghh
return self._session_db.delete_session_if_empty(
session_id, sessions_dir=_ghh() / "sessions"
)
except Exception:
logger.debug(
"Could not prune empty session %s", session_id, exc_info=True
)
return False
def new_session(self, silent=False, title=None):
"""Start a fresh session with a new session ID and cleared agent state."""
if self.agent and self.conversation_history:
@ -5837,6 +5951,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._session_db.end_session(old_session_id, "new_session")
except Exception:
pass
# Don't let immediately-rotated empty sessions pile up in
# /resume and `hermes sessions list` (gemini-cli#27770 port).
self._discard_session_if_empty(old_session_id)
self.session_start = datetime.now()
timestamp_str = self.session_start.strftime("%Y%m%d_%H%M%S")
@ -7342,6 +7459,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self.save_conversation()
elif canonical == "cron":
self._handle_cron_command(cmd_original)
elif canonical == "suggestions":
self._handle_suggestions_command(cmd_original)
elif canonical == "blueprint":
self._handle_blueprint_command(cmd_original)
elif canonical == "curator":
self._handle_curator_command(cmd_original)
elif canonical == "kanban":
@ -7377,6 +7498,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._manual_compress(cmd_original)
elif canonical == "usage":
self._show_usage()
elif canonical == "credits":
self._show_credits()
elif canonical == "insights":
self._show_insights(cmd_original)
elif canonical == "copy":
@ -8278,6 +8401,86 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
print(f" {line}")
return True
def _show_credits(self):
"""`/credits` — focused Nous credit balance + top-up handoff.
Interactive CLI: balance block + identity line + a 3-button panel
(Open top-up / Copy link / Cancel). Non-interactive contexts — the TUI
slash-worker subprocess and any place without a live prompt_toolkit app
(``self._app is None``) — render a text variant (balance + tappable
top-up URL), because the modal would try to read the RPC stdin and crash
the worker. The terminal never confirms or polls payment (billing phase
2a). Fail-open: a portal hiccup or logged-out account degrades to a clear
message, never a crash.
"""
from agent.account_usage import build_credits_view
view = build_credits_view()
if not view.logged_in:
print()
print(f" 💳 {_DIM}Not logged into Nous Portal.{_RST}")
print(" Run `hermes portal` to log in, then /credits.")
return
print()
print(" 💳 Nous credits")
print(f" {'─' * 41}")
for line in view.balance_lines:
# Drop the helper's own "📈 Nous credits" header — we print our own.
if line.lstrip().startswith("📈"):
continue
print(f" {line}")
print(f" {'─' * 41}")
if view.identity_line:
print(f" {view.identity_line}")
if not view.topup_url:
return
# Non-interactive (TUI slash-worker, piped, no live app): the
# prompt_toolkit modal can't run here — it would read the worker's
# JSON-RPC stdin and crash the command. Render the text variant: the
# tappable URL IS the affordance, same as the messaging surfaces.
if not getattr(self, "_app", None):
print()
print(f" Top up: {view.topup_url}")
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
return
choices = [
("open", "Open top-up in browser", "launch the portal billing page"),
("copy", "Copy link", "copy the top-up URL to your clipboard"),
("cancel", "Cancel", "do nothing"),
]
raw = self._prompt_text_input_modal(
title="💳 Add credits?",
detail=f"Top-up page:\n{view.topup_url}",
choices=choices,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
if choice == "open":
opened = False
try:
import webbrowser
opened = webbrowser.open(view.topup_url)
except Exception:
opened = False
if not opened:
print(f" Open this URL to top up: {view.topup_url}")
print()
print(" Complete your top-up in the browser — credits will appear in /credits shortly.")
elif choice == "copy":
try:
self._write_osc52_clipboard(view.topup_url)
print(f" 📋 Copied: {view.topup_url}")
except Exception:
print(f" Top-up URL: {view.topup_url}")
else:
print(" 🟡 Cancelled. No credits added.")
def _show_insights(self, command: str = "/insights"):
"""Show usage insights and analytics from session history."""
# Parse optional --days flag
@ -9290,6 +9493,25 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
for line in reqs["details"].split("\n"):
_cprint(f" {line}")
def _persist_prompt_summary(self, icon: str, label: str, detail: str, outcome: str) -> None:
"""Print a one-line scrollback summary of a resolved modal prompt.
Modal panels (approval / clarify) live in the prompt_toolkit layout and
vanish on the next repaint, so the question and the decision leave no
trace in the terminal scrollback. When display.persist_prompts is on
(default), emit a dim single line after the prompt resolves so the
decision survives in chat history.
"""
if not CLI_CONFIG.get("display", {}).get("persist_prompts", True):
return
detail = " ".join(detail.split())
if len(detail) > 120:
detail = detail[:119] + "…"
outcome = " ".join(outcome.split())
if len(outcome) > 120:
outcome = outcome[:119] + "…"
_cprint(f"\n{_DIM}{icon} {label}: {detail} → {outcome}{_RST}")
def _clarify_callback(self, question, choices):
"""
Platform callback for the clarify tool. Called from the agent thread.
@ -9329,6 +9551,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
try:
result = response_queue.get(timeout=1)
self._clarify_deadline = 0
self._persist_prompt_summary("?", "Clarify", question, str(result))
return result
except queue.Empty:
remaining = self._clarify_deadline - _time.monotonic()
@ -9442,6 +9665,16 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._approval_state = None
self._approval_deadline = 0
self._paint_now()
_outcome_labels = {
"once": "allowed once",
"session": "allowed for session",
"always": "added to allowlist",
"deny": "denied",
}
self._persist_prompt_summary(
"⚠", "Approval", command,
_outcome_labels.get(result, str(result)),
)
return result
except queue.Empty:
remaining = self._approval_deadline - _time.monotonic()
@ -9558,7 +9791,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
show_full = state.get("show_full", False)
title = "⚠️ Dangerous Command"
cmd_display = command if show_full or len(command) <= 70 else command[:70] + '...'
cmd_display = command
choice_labels = {
"once": "Allow once",
"session": "Allow for this session",
@ -9582,6 +9815,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# Pre-wrap the mandatory content — command + choices must always render.
cmd_wrapped = _wrap_panel_text(cmd_display, inner_text_width)
if not show_full and "view" in choices and len(cmd_wrapped) > 4:
cmd_wrapped = cmd_wrapped[:3] + _wrap_panel_text(
"… (choose Show full command)",
inner_text_width,
)
# (choice_index, wrapped_line) so we can re-apply selected styling below
choice_wrapped: list[tuple[int, str]] = []
@ -9631,7 +9869,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
max_cmd_rows = max(1, available - chrome_rows - len(choice_wrapped))
if len(cmd_wrapped) > max_cmd_rows:
keep = max(1, max_cmd_rows - 1) if max_cmd_rows > 1 else 1
cmd_wrapped = cmd_wrapped[:keep] + ["… (command truncated — use /logs or /debug for full text)"]
cmd_wrapped = cmd_wrapped[:keep] + _wrap_panel_text(
"… (command truncated — use /logs or /debug for full text)",
inner_text_width,
)
# Allocate any remaining rows to description. The extra -1 in full mode
# accounts for the blank separator between choices and description.
@ -10125,6 +10366,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
if self._prompt_start_time is not None:
self._prompt_duration = max(0.0, time.time() - self._prompt_start_time)
self._prompt_start_time = None
# Record when this agent loop finished so the status bar can show
# idle time since the last final response.
self._last_turn_finished_at = time.time()
# Proactively clean up async clients whose event loop is dead.
# The agent thread may have created AsyncOpenAI clients bound
@ -11807,26 +12051,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
def _input_height():
try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document
prompt_width = max(2, get_cwidth(self._get_tui_prompt_text()))
try:
available_width = get_app().output.get_size().columns - prompt_width
terminal_columns = get_app().output.get_size().columns
except Exception:
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
for line in doc.lines:
# Each logical line takes at least 1 visual row; long lines wrap.
# Use prompt_toolkit's cell width so CJK wide characters count as 2.
line_width = get_cwidth(line)
if line_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-line_width // available_width)) # ceil division
return min(max(visual_lines, 1), 8)
terminal_columns = shutil.get_terminal_size((80, 24)).columns
return _estimate_tui_input_height(
doc.lines,
self._get_tui_prompt_text(),
terminal_columns,
)
except Exception:
return 1
@ -12568,6 +12803,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
style=style,
full_screen=False,
mouse_support=False,
# The status bar contains wall-clock read-outs (live prompt elapsed
# and idle-since-last-turn). Once a turn finishes there may be no
# further events to invalidate the app, so prompt_toolkit would keep
# rendering the first post-turn value (usually ``✓ 0s``) forever.
# A low-rate refresh keeps the clock honest without reintroducing a
# custom repaint thread or touching conversation state.
refresh_interval=1.0,
# Erase the live bottom chrome (status bar, input box, separator
# rules) on exit instead of freezing a final copy into scrollback.
# Without this, prompt_toolkit's render_as_done teardown repaints
@ -12761,7 +13003,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
# session. Without this guard a KeyboardInterrupt unwinds
# to the outer prompt_toolkit loop and the session dies.
_cprint("\n[dim]Command interrupted.[/dim]")
continue
continue
# A slash handler may set a one-shot pending seed (e.g.
# /blueprint <name>) to be run as the next agent turn.
# If present, fall through to the chat path with the seed
# as the user message instead of looping back to idle.
_seed = getattr(self, "_pending_agent_seed", None)
if _seed:
self._pending_agent_seed = None
user_input = _seed
else:
continue
# Expand paste references back to full content
_paste_ref_re = re.compile(r'\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]')
@ -13078,6 +13330,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
self._session_db.end_session(self.agent.session_id, "cli_close")
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not close session in DB: %s", e)
# Started-and-immediately-quit sessions never gained content;
# drop the empty row so /resume and `hermes sessions list`
# stay clean (gemini-cli#27770 port). No-op for resumed or
# titled sessions and anything with messages or children.
if not getattr(self, '_delete_session_on_exit', False):
try:
self._discard_session_if_empty(self.agent.session_id)
except (Exception, KeyboardInterrupt) as e:
logger.debug("Could not prune empty session: %s", e)
# /exit --delete: also remove the current session's transcripts
# and SQLite history. Ported from google-gemini/gemini-cli#19332.
if getattr(self, '_delete_session_on_exit', False):
@ -13340,9 +13601,21 @@ def main(
else:
toolsets_list.append(str(t))
else:
# Use the shared resolver so MCP servers are included at runtime
from hermes_cli.tools_config import _get_platform_tools
toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
# Coding posture (base Hermes): with no explicit --toolsets, collapse
# to the coding toolset (+ enabled MCP servers) when sitting in a code
# workspace. See agent/coding_context.py.
_coding = None
try:
from agent.coding_context import coding_selection
_coding = coding_selection(platform="cli", config=CLI_CONFIG)
except Exception:
_coding = None
if _coding is not None:
toolsets_list = _coding
else:
# Use the shared resolver so MCP servers are included at runtime
from hermes_cli.tools_config import _get_platform_tools
toolsets_list = sorted(_get_platform_tools(CLI_CONFIG, "cli"))
parsed_skills = _parse_skills_argument(skills)