Compare commits
148
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d70a88749 | ||
|
|
d011871d9f | ||
|
|
f8adefdebf | ||
|
|
dbbd1d4d05 | ||
|
|
e687292eb4 | ||
|
|
c4066091ca | ||
|
|
50ad191a8b | ||
|
|
520b59db16 | ||
|
|
4b073d0906 | ||
|
|
dbf2470d46 | ||
|
|
9fb83eaa2f | ||
|
|
0337658904 | ||
|
|
b58ff93459 | ||
|
|
2130ef68b3 | ||
|
|
637cf94bed | ||
|
|
9351cbafab | ||
|
|
18ead88273 | ||
|
|
dba6380ca6 | ||
|
|
ba622d44e4 | ||
|
|
2c1aaa9cba | ||
|
|
8bb60ff039 | ||
|
|
bddab61bcb | ||
|
|
d1f23bb2d5 | ||
|
|
54318c65b0 | ||
|
|
c1927d2342 | ||
|
|
3705625b74 | ||
|
|
3dcfbbfc49 | ||
|
|
3b983e7791 | ||
|
|
0d25cae041 | ||
|
|
e79e44af79 | ||
|
|
fdf48c63c8 | ||
|
|
0646656884 | ||
|
|
92179352fb | ||
|
|
e9b26c7c8b | ||
|
|
84e4b4b9a5 | ||
|
|
314af28e86 | ||
|
|
b3aef57f21 | ||
|
|
4e4d27875f | ||
|
|
c3420d91ad | ||
|
|
0c2e81df00 | ||
|
|
a46462ec65 | ||
|
|
b23184cad4 | ||
|
|
52ae9d9f02 | ||
|
|
1e5ff4a577 | ||
|
|
6a8dda171c | ||
|
|
e0f6a35ac6 | ||
|
|
b5f8996ccc | ||
|
|
714183530b | ||
|
|
ab98818e5b | ||
|
|
d66bac5a1a | ||
|
|
300371c3f2 | ||
|
|
f4531feee8 | ||
|
|
6d2732e786 | ||
|
|
aa424e51ac | ||
|
|
732ababa1a | ||
|
|
421226e404 | ||
|
|
37561c214b | ||
|
|
4615e08d3d | ||
|
|
5e9d7a7661 | ||
|
|
639c1e3636 | ||
|
|
1e3b3dfabb | ||
|
|
09a6a2ddd7 | ||
|
|
d3992d1a28 | ||
|
|
1db79bfe1e | ||
|
|
d6c11a4575 | ||
|
|
3f1758d2e4 | ||
|
|
cf49630379 | ||
|
|
9fd3d5cf85 | ||
|
|
a1cb84aca9 | ||
|
|
754154a9c2 | ||
|
|
1866518574 | ||
|
|
d7f42e368e | ||
|
|
630318e958 | ||
|
|
8f89c4615f | ||
|
|
083d8b2d60 | ||
|
|
6a0cc9bf92 | ||
|
|
2ee7abf271 | ||
|
|
55fb422f6f | ||
|
|
91db0ab420 | ||
|
|
3a0f6ac3d4 | ||
|
|
5b4e431e8c | ||
|
|
6e7033bb4c | ||
|
|
e88116256c | ||
|
|
2f510ca8e0 | ||
|
|
c78b3e1d3c | ||
|
|
761b744abb | ||
|
|
c9094f5e5f | ||
|
|
89d380261d | ||
|
|
b0efe1d64b | ||
|
|
96fd9d4979 | ||
|
|
021d1034d0 | ||
|
|
abcf996b1f | ||
|
|
c6d27addf7 | ||
|
|
5916248dc0 | ||
|
|
550b72dd87 | ||
|
|
4129092fda | ||
|
|
8e4c447e5f | ||
|
|
9b1e0d6f70 | ||
|
|
395ed91891 | ||
|
|
a38003be3d | ||
|
|
365813a72b | ||
|
|
ae94ed1728 | ||
|
|
9c9d9113a8 | ||
|
|
de80d28f38 | ||
|
|
a77efada5f | ||
|
|
55b83c3d99 | ||
|
|
a706a349b5 | ||
|
|
094aa85c37 | ||
|
|
cef00ae602 | ||
|
|
74744795af | ||
|
|
399b8ee5f0 | ||
|
|
47d5177a7d | ||
|
|
74239b4942 | ||
|
|
b000e05b11 | ||
|
|
cd030f5f40 | ||
|
|
81647458c7 | ||
|
|
9b2a64fa6a | ||
|
|
47518bc913 | ||
|
|
cfaa46fcae | ||
|
|
56be1a63a3 | ||
|
|
9c264555b0 | ||
|
|
87ac7cac13 | ||
|
|
64da518db4 | ||
|
|
ed1e2533b7 | ||
|
|
2284147044 | ||
|
|
9e360681f8 | ||
|
|
fd1e7c2bc3 | ||
|
|
7230fcb7f2 | ||
|
|
728612c29c | ||
|
|
4219a91df5 | ||
|
|
a3fca26c56 | ||
|
|
5e06c9ffef | ||
|
|
cb13723f53 | ||
|
|
8cb1908e18 | ||
|
|
8b6a8f667d | ||
|
|
b31c6c33b2 | ||
|
|
e9c1e757fe | ||
|
|
3d029a53ec | ||
|
|
400e6e43ca | ||
|
|
b99c6c4277 | ||
|
|
2b89afec79 | ||
|
|
c3055d6185 | ||
|
|
f96eb857a5 | ||
|
|
d55304c39f | ||
|
|
ecd4679d8c | ||
|
|
9d61076f88 | ||
|
|
ccacfdbd6d | ||
|
|
9f1c16a7fb |
@@ -59,12 +59,22 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Always rebuild — the file isn't committed (gitignored), so a
|
||||
# fresh checkout starts without it and we want the freshest crawl
|
||||
# in every deploy. Failure is non-fatal: extract-skills.py will
|
||||
# fall back to the legacy snapshot cache and the Skills Hub page
|
||||
# still renders, just without the latest community catalog.
|
||||
python3 scripts/build_skills_index.py || echo "Skills index build failed (non-fatal)"
|
||||
# Rebuild the unified catalog. The file is gitignored, so a fresh
|
||||
# checkout starts without it and we want the freshest crawl in
|
||||
# every deploy.
|
||||
#
|
||||
# This MUST be fatal. build_skills_index.py runs a health check and
|
||||
# exits non-zero WITHOUT writing the output file when a source
|
||||
# collapses (e.g. a GitHub API rate limit zeroes the github /
|
||||
# claude-marketplace / well-known taps all at once). Letting the
|
||||
# deploy continue would either (a) ship a degenerate index missing
|
||||
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
|
||||
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
|
||||
# local-only catalog. Failing here keeps the last good deployment
|
||||
# live (GitHub Pages serves the previous build) instead of
|
||||
# publishing a broken catalog. Re-run the workflow once the
|
||||
# transient rate limit clears.
|
||||
python3 scripts/build_skills_index.py
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
@@ -53,7 +53,7 @@ If you already have Git installed, the installer detects it and uses that instea
|
||||
|
||||
> **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies.
|
||||
>
|
||||
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively).
|
||||
> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux.
|
||||
|
||||
After installation:
|
||||
|
||||
|
||||
@@ -169,6 +169,7 @@ def init_agent(
|
||||
save_trajectories: bool = False,
|
||||
verbose_logging: bool = False,
|
||||
quiet_mode: bool = False,
|
||||
tool_progress_mode: str = "all",
|
||||
ephemeral_system_prompt: str = None,
|
||||
log_prefix_chars: int = 100,
|
||||
log_prefix: str = "",
|
||||
@@ -280,6 +281,7 @@ def init_agent(
|
||||
agent.save_trajectories = save_trajectories
|
||||
agent.verbose_logging = verbose_logging
|
||||
agent.quiet_mode = quiet_mode
|
||||
agent.tool_progress_mode = tool_progress_mode
|
||||
agent.ephemeral_system_prompt = ephemeral_system_prompt
|
||||
agent.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
|
||||
agent._user_id = user_id # Platform user identifier (gateway sessions)
|
||||
|
||||
@@ -821,6 +821,7 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
logger.debug("Keychain: security command not available or timed out")
|
||||
@@ -1163,7 +1164,10 @@ def run_oauth_setup_token() -> Optional[str]:
|
||||
"Install it with: npm install -g @anthropic-ai/claude-code"
|
||||
)
|
||||
|
||||
# Run interactively — stdin/stdout/stderr inherited so user can interact
|
||||
# Run interactively — stdin/stdout/stderr inherited so the user can
|
||||
# complete the OAuth login prompt. Must keep inherited stdin; the TUI-EOF
|
||||
# concern does not apply to an interactive login the user explicitly
|
||||
# invokes. noqa: subprocess-stdin
|
||||
try:
|
||||
subprocess.run([claude_path, "setup-token"])
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
@@ -2301,3 +2305,43 @@ def build_anthropic_kwargs(
|
||||
kwargs["extra_headers"] = {"anthropic-beta": ",".join(betas)}
|
||||
|
||||
return kwargs
|
||||
|
||||
|
||||
# Keys that belong exclusively to the OpenAI Responses / Codex API shape.
|
||||
# The Anthropic Messages SDK (``messages.create()`` / ``messages.stream()``)
|
||||
# raises ``TypeError: ... got an unexpected keyword argument`` on any of them.
|
||||
_RESPONSES_ONLY_KWARGS = frozenset(
|
||||
{"instructions", "input", "store", "parallel_tool_calls"}
|
||||
)
|
||||
|
||||
|
||||
def sanitize_anthropic_kwargs(api_kwargs: Any, *, log_prefix: str = "") -> Any:
|
||||
"""Drop Responses-API-only keys before an Anthropic Messages SDK call.
|
||||
|
||||
Defensive boundary guard for #31673: under rare api_mode-flip races
|
||||
(e.g. a concurrent auxiliary call mutating a shared agent between the
|
||||
kwargs build and the stream dispatch), a Responses-shaped payload
|
||||
carrying ``instructions=`` can reach ``messages.stream()`` /
|
||||
``messages.create()``. The Anthropic SDK rejects it with a
|
||||
non-retryable ``TypeError`` that nukes the whole turn and propagates
|
||||
the entire fallback chain.
|
||||
|
||||
Mutates ``api_kwargs`` in place and returns it. When a foreign key is
|
||||
present we log a WARNING so the underlying race stays visible in the
|
||||
wild instead of being silently papered over.
|
||||
"""
|
||||
if not isinstance(api_kwargs, dict):
|
||||
return api_kwargs
|
||||
leaked = _RESPONSES_ONLY_KWARGS.intersection(api_kwargs)
|
||||
if leaked:
|
||||
for _key in leaked:
|
||||
api_kwargs.pop(_key, None)
|
||||
logger.warning(
|
||||
"%sStripped Responses-only kwarg(s) %s from an Anthropic Messages "
|
||||
"call (api_mode flip race — see #31673). The call will proceed; "
|
||||
"this breadcrumb means a kwargs build ran under a Responses "
|
||||
"api_mode while dispatch ran under anthropic_messages.",
|
||||
log_prefix,
|
||||
sorted(leaked),
|
||||
)
|
||||
return api_kwargs
|
||||
|
||||
@@ -1986,6 +1986,58 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
"(possible upstream error or malformed SSE response)."
|
||||
)
|
||||
|
||||
# A stream that delivered a tool call but only partial/unparseable
|
||||
# JSON args splits into two very different cases:
|
||||
#
|
||||
# 1. Provider sent finish_reason="length" → a genuine output-cap
|
||||
# truncation. Boosting max_tokens on retry is the right move.
|
||||
#
|
||||
# 2. Provider sent NO finish_reason (the SSE simply stopped after
|
||||
# the opening "{" with no terminator and no [DONE]) → the
|
||||
# upstream dropped/stalled the connection mid tool-call. This
|
||||
# is NOT an output cap — the model never reported hitting one.
|
||||
# Some dedicated endpoints (e.g. NVIDIA Nemotron Ultra on the
|
||||
# Nous dedicated endpoint) stall for minutes during large
|
||||
# tool-arg generation, then close the stream cleanly without a
|
||||
# finish_reason. Stamping "length" here sends it down the
|
||||
# max_tokens-boost truncation path, which retries 3× to no
|
||||
# effect and finally reports the misleading "Response truncated
|
||||
# due to output length limit" — the red herring this guards
|
||||
# against. Route it through the partial-stream-stub path
|
||||
# instead so the loop reports an honest mid-tool-call stream
|
||||
# drop and fails fast rather than escalating output budget.
|
||||
_tool_args_dropped_no_finish = has_truncated_tool_args and finish_reason is None
|
||||
if _tool_args_dropped_no_finish:
|
||||
_dropped_names = [
|
||||
(tool_calls_acc[idx]["function"]["name"] or "?")
|
||||
for idx in sorted(tool_calls_acc)
|
||||
]
|
||||
logger.warning(
|
||||
"Stream ended with no finish_reason while a tool call's "
|
||||
"arguments were still incomplete (tools=%s); treating as a "
|
||||
"mid-tool-call stream drop, not an output-length truncation.",
|
||||
_dropped_names,
|
||||
)
|
||||
full_reasoning = "".join(reasoning_parts) or None
|
||||
mock_message = SimpleNamespace(
|
||||
role=role,
|
||||
content=full_content,
|
||||
tool_calls=None,
|
||||
reasoning_content=full_reasoning,
|
||||
)
|
||||
mock_choice = SimpleNamespace(
|
||||
index=0,
|
||||
message=mock_message,
|
||||
finish_reason=FINISH_REASON_LENGTH,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id=PARTIAL_STREAM_STUB_ID,
|
||||
model=model_name,
|
||||
choices=[mock_choice],
|
||||
usage=usage_obj,
|
||||
_dropped_tool_names=_dropped_names or None,
|
||||
)
|
||||
|
||||
effective_finish_reason = finish_reason or "stop"
|
||||
if has_truncated_tool_args:
|
||||
effective_finish_reason = "length"
|
||||
@@ -2024,6 +2076,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
# Per-attempt diagnostic dict for the retry block to consume.
|
||||
_diag = agent._stream_diag_init()
|
||||
request_client_holder["diag"] = _diag
|
||||
# Defensive: strip Responses-only kwargs (instructions, input, ...)
|
||||
# that can leak in under an api_mode-flip race. The Anthropic SDK
|
||||
# raises a non-retryable TypeError on them, killing the turn. See
|
||||
# #31673 / sanitize_anthropic_kwargs().
|
||||
from agent.anthropic_adapter import sanitize_anthropic_kwargs
|
||||
sanitize_anthropic_kwargs(
|
||||
api_kwargs, log_prefix=getattr(agent, "log_prefix", "")
|
||||
)
|
||||
# Use the Anthropic SDK's streaming context manager
|
||||
with agent._anthropic_client.messages.stream(**api_kwargs) as stream:
|
||||
# The Anthropic SDK exposes the raw httpx response on
|
||||
|
||||
@@ -290,6 +290,7 @@ def _expand_git_reference(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"{ref.raw}: git command timed out (30s)", None
|
||||
@@ -482,6 +483,7 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
+15
-372
@@ -4196,383 +4196,26 @@ def run_conversation(
|
||||
messages.append({"role": "assistant", "content": final_response})
|
||||
break
|
||||
|
||||
if final_response is None and (
|
||||
api_call_count >= agent.max_iterations
|
||||
or agent.iteration_budget.remaining <= 0
|
||||
):
|
||||
# Budget exhausted — ask the model for a summary via one extra
|
||||
# API call with tools stripped. _handle_max_iterations injects a
|
||||
# user message and makes a single toolless request.
|
||||
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
|
||||
agent._emit_status(
|
||||
f"⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
|
||||
"— asking model to summarise"
|
||||
)
|
||||
if not agent.quiet_mode:
|
||||
agent._safe_print(
|
||||
f"\n⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
|
||||
"— requesting summary..."
|
||||
)
|
||||
final_response = agent._handle_max_iterations(messages, api_call_count)
|
||||
|
||||
# If running as a kanban worker, signal the dispatcher that the
|
||||
# worker could not complete (rather than treating it as a
|
||||
# protocol violation). The agent loop strips tools before calling
|
||||
# _handle_max_iterations, so the model cannot call kanban_block
|
||||
# itself — we must do it on its behalf.
|
||||
#
|
||||
# We route through ``_record_task_failure(outcome="timed_out")``
|
||||
# rather than ``kanban_block`` so this counts toward the
|
||||
# ``consecutive_failures`` counter and the dispatcher's
|
||||
# ``failure_limit`` circuit breaker (#29747 gap 2). Without this,
|
||||
# a task whose worker keeps exhausting its budget would block
|
||||
# silently each run, get auto-promoted by the operator (or never
|
||||
# surface), and re-block in an endless loop with no signal.
|
||||
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
|
||||
if _kanban_task:
|
||||
try:
|
||||
from hermes_cli import kanban_db as _kb
|
||||
_conn = _kb.connect()
|
||||
try:
|
||||
_kb._record_task_failure(
|
||||
_conn,
|
||||
_kanban_task,
|
||||
error=(
|
||||
f"Iteration budget exhausted "
|
||||
f"({api_call_count}/{agent.max_iterations}) — "
|
||||
"task could not complete within the allowed "
|
||||
"iterations"
|
||||
),
|
||||
outcome="timed_out",
|
||||
release_claim=True,
|
||||
end_run=True,
|
||||
event_payload_extra={
|
||||
"budget_used": api_call_count,
|
||||
"budget_max": agent.max_iterations,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"recorded budget-exhausted failure for task %s (%d/%d)",
|
||||
_kanban_task, api_call_count, agent.max_iterations,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to record budget-exhausted failure for task %s",
|
||||
_kanban_task,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Determine if conversation completed successfully
|
||||
completed = (
|
||||
final_response is not None
|
||||
and api_call_count < agent.max_iterations
|
||||
and not failed
|
||||
)
|
||||
|
||||
# Save trajectory if enabled. ``user_message`` may be a multimodal
|
||||
# list of parts; the trajectory format wants a plain string.
|
||||
agent._save_trajectory(messages, _summarize_user_message_for_log(user_message), completed)
|
||||
|
||||
# Clean up VM and browser for this task after conversation completes
|
||||
agent._cleanup_task_resources(effective_task_id)
|
||||
|
||||
# Persist session to both JSON log and SQLite only after private retry
|
||||
# scaffolding has been removed. Otherwise a later user "continue" turn
|
||||
# can replay assistant("(empty)") / recovery nudges and fall into the
|
||||
# same empty-response loop again.
|
||||
agent._drop_trailing_empty_response_scaffolding(messages)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
|
||||
# ── Turn-exit diagnostic log ─────────────────────────────────────
|
||||
# Always logged at INFO so agent.log captures WHY every turn ended.
|
||||
# When the last message is a tool result (agent was mid-work), log
|
||||
# at WARNING — this is the "just stops" scenario users report.
|
||||
_last_msg_role = messages[-1].get("role") if messages else None
|
||||
_last_tool_name = None
|
||||
if _last_msg_role == "tool":
|
||||
# Walk back to find the assistant message with the tool call
|
||||
for _m in reversed(messages):
|
||||
if _m.get("role") == "assistant" and _m.get("tool_calls"):
|
||||
_tcs = _m["tool_calls"]
|
||||
if _tcs and isinstance(_tcs[0], dict):
|
||||
_last_tool_name = _tcs[-1].get("function", {}).get("name")
|
||||
break
|
||||
|
||||
_turn_tool_count = sum(
|
||||
1 for m in messages
|
||||
if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls")
|
||||
)
|
||||
_resp_len = len(final_response) if final_response else 0
|
||||
_budget_used = agent.iteration_budget.used if agent.iteration_budget else 0
|
||||
_budget_max = agent.iteration_budget.max_total if agent.iteration_budget else 0
|
||||
|
||||
_diag_msg = (
|
||||
"Turn ended: reason=%s model=%s api_calls=%d/%d budget=%d/%d "
|
||||
"tool_turns=%d last_msg_role=%s response_len=%d session=%s"
|
||||
)
|
||||
_diag_args = (
|
||||
_turn_exit_reason, agent.model, api_call_count, agent.max_iterations,
|
||||
_budget_used, _budget_max,
|
||||
_turn_tool_count, _last_msg_role, _resp_len,
|
||||
agent.session_id or "none",
|
||||
)
|
||||
|
||||
if _last_msg_role == "tool" and not interrupted:
|
||||
# Agent was mid-work — this is the "just stops" case.
|
||||
logger.warning(
|
||||
"Turn ended with pending tool result (agent may appear stuck). "
|
||||
+ _diag_msg + " last_tool=%s",
|
||||
*_diag_args, _last_tool_name,
|
||||
)
|
||||
else:
|
||||
logger.info(_diag_msg, *_diag_args)
|
||||
|
||||
# File-mutation verifier footer.
|
||||
# If one or more ``write_file`` / ``patch`` calls failed during this
|
||||
# turn and were never superseded by a successful write to the same
|
||||
# path, append an advisory footer to the assistant response. This
|
||||
# catches the specific case — reported by Ben Eng (#15524-adjacent)
|
||||
# — where a model issues a batch of parallel patches, half of them
|
||||
# fail with "Could not find old_string", and the model summarises
|
||||
# the turn claiming every file was edited. The user then has to
|
||||
# manually run ``git status`` to catch the lie. With this footer
|
||||
# the truth is surfaced on every turn, so over-claiming is
|
||||
# structurally impossible past the model.
|
||||
#
|
||||
# Gate: only applied when a real text response exists for this
|
||||
# turn and the user didn't interrupt. Empty/interrupted turns
|
||||
# already have other surface text that shouldn't be augmented.
|
||||
if final_response and not interrupted:
|
||||
try:
|
||||
_failed = getattr(agent, "_turn_failed_file_mutations", None) or {}
|
||||
if _failed and agent._file_mutation_verifier_enabled():
|
||||
footer = agent._format_file_mutation_failure_footer(_failed)
|
||||
if footer:
|
||||
final_response = final_response.rstrip() + "\n\n" + footer
|
||||
except Exception as _ver_err:
|
||||
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
|
||||
|
||||
# Turn-completion explainer.
|
||||
# When a turn ends abnormally after substantive work — empty content
|
||||
# after retries, a partial/truncated stream, a still-pending tool
|
||||
# result, or an iteration/budget limit — the user otherwise gets a
|
||||
# blank or fragmentary response box with no consolidated reason why
|
||||
# the agent stopped (#34452). Surface a single user-visible
|
||||
# explanation derived from ``_turn_exit_reason``, mirroring the
|
||||
# file-mutation verifier footer pattern above.
|
||||
#
|
||||
# Gate carefully so healthy turns stay quiet:
|
||||
# - ``text_response(...)`` exits never produce an explanation
|
||||
# (handled inside the formatter), so a terse ``Done.`` is silent.
|
||||
# - We only ACT when there is no genuinely usable reply this turn:
|
||||
# an empty response, the "(empty)" terminal sentinel, or a
|
||||
# suspiciously short partial fragment with no terminating
|
||||
# punctuation (e.g. "The"). A real short answer keeps its text.
|
||||
if not interrupted:
|
||||
try:
|
||||
if agent._turn_completion_explainer_enabled():
|
||||
_stripped = (final_response or "").strip()
|
||||
_is_empty_terminal = _stripped == "" or _stripped == "(empty)"
|
||||
# A short fragment that is not a normal text_response exit
|
||||
# and lacks sentence-ending punctuation is treated as a
|
||||
# truncated partial (the "The" case from #34452).
|
||||
_is_partial_fragment = (
|
||||
not _is_empty_terminal
|
||||
and not str(_turn_exit_reason).startswith("text_response")
|
||||
and len(_stripped) <= 24
|
||||
and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"}
|
||||
)
|
||||
if _is_empty_terminal or _is_partial_fragment:
|
||||
_explanation = agent._format_turn_completion_explanation(
|
||||
_turn_exit_reason
|
||||
)
|
||||
if _explanation:
|
||||
if _is_empty_terminal:
|
||||
# Replace the bare "(empty)"/blank sentinel with
|
||||
# the actionable explanation.
|
||||
final_response = _explanation
|
||||
else:
|
||||
# Keep the partial fragment, append the reason so
|
||||
# the user sees both what arrived and why it
|
||||
# stopped.
|
||||
final_response = (
|
||||
_stripped + "\n\n" + _explanation
|
||||
)
|
||||
except Exception as _exp_err:
|
||||
logger.debug("turn-completion explainer failed: %s", _exp_err)
|
||||
|
||||
_response_transformed = False
|
||||
|
||||
# Plugin hook: transform_llm_output
|
||||
# Fired once per turn after the tool-calling loop completes.
|
||||
# Plugins can transform the LLM's output text before it's returned.
|
||||
# First hook to return a string wins; None/empty return leaves text unchanged.
|
||||
if final_response and not interrupted:
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
_transform_results = _invoke_hook(
|
||||
"transform_llm_output",
|
||||
response_text=final_response,
|
||||
session_id=agent.session_id or "",
|
||||
model=agent.model,
|
||||
platform=getattr(agent, "platform", None) or "",
|
||||
)
|
||||
for _hook_result in _transform_results:
|
||||
if isinstance(_hook_result, str) and _hook_result:
|
||||
final_response = _hook_result
|
||||
_response_transformed = True
|
||||
break # First non-empty string wins
|
||||
except Exception as exc:
|
||||
logger.warning("transform_llm_output hook failed: %s", exc)
|
||||
|
||||
# Plugin hook: post_llm_call
|
||||
# Fired once per turn after the tool-calling loop completes.
|
||||
# Plugins can use this to persist conversation data (e.g. sync
|
||||
# to an external memory system).
|
||||
if final_response and not interrupted:
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
_invoke_hook(
|
||||
"post_llm_call",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
user_message=original_user_message,
|
||||
assistant_response=final_response,
|
||||
conversation_history=list(messages),
|
||||
model=agent.model,
|
||||
platform=getattr(agent, "platform", None) or "",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("post_llm_call hook failed: %s", exc)
|
||||
|
||||
# Extract reasoning from the CURRENT turn only. Walk backwards
|
||||
# but stop at the user message that started this turn — anything
|
||||
# earlier is from a prior turn and must not leak into the reasoning
|
||||
# box (confusing stale display; #17055). Within the current turn
|
||||
# we still want the *most recent* non-empty reasoning: many
|
||||
# providers (Claude thinking, DeepSeek v4, Codex Responses) emit
|
||||
# reasoning on the tool-call step and leave the final-answer step
|
||||
# with reasoning=None, so picking only the last assistant would
|
||||
# silently drop legitimate same-turn reasoning.
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break # turn boundary — don't cross into prior turns
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
||||
# Build result with interrupt info if applicable
|
||||
result = {
|
||||
"final_response": final_response,
|
||||
"last_reasoning": last_reasoning,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": completed,
|
||||
"turn_exit_reason": _turn_exit_reason,
|
||||
"failed": failed,
|
||||
"partial": False, # True only when stopped due to invalid tool calls
|
||||
"interrupted": interrupted,
|
||||
"response_transformed": _response_transformed,
|
||||
"response_previewed": getattr(agent, "_response_was_previewed", False),
|
||||
"model": agent.model,
|
||||
"provider": agent.provider,
|
||||
"base_url": agent.base_url,
|
||||
"input_tokens": agent.session_input_tokens,
|
||||
"output_tokens": agent.session_output_tokens,
|
||||
"cache_read_tokens": agent.session_cache_read_tokens,
|
||||
"cache_write_tokens": agent.session_cache_write_tokens,
|
||||
"reasoning_tokens": agent.session_reasoning_tokens,
|
||||
"prompt_tokens": agent.session_prompt_tokens,
|
||||
"completion_tokens": agent.session_completion_tokens,
|
||||
"total_tokens": agent.session_total_tokens,
|
||||
"last_prompt_tokens": getattr(agent.context_compressor, "last_prompt_tokens", 0) or 0,
|
||||
"estimated_cost_usd": agent.session_estimated_cost_usd,
|
||||
"cost_status": agent.session_cost_status,
|
||||
"cost_source": agent.session_cost_source,
|
||||
"session_id": agent.session_id,
|
||||
}
|
||||
if agent._tool_guardrail_halt_decision is not None:
|
||||
result["guardrail"] = agent._tool_guardrail_halt_decision.to_metadata()
|
||||
# If a /steer landed after the final assistant turn (no more tool
|
||||
# batches to drain into), hand it back to the caller so it can be
|
||||
# delivered as the next user turn instead of being silently lost.
|
||||
_leftover_steer = agent._drain_pending_steer()
|
||||
if _leftover_steer:
|
||||
result["pending_steer"] = _leftover_steer
|
||||
agent._response_was_previewed = False
|
||||
|
||||
# Include interrupt message if one triggered the interrupt
|
||||
if interrupted and agent._interrupt_message:
|
||||
result["interrupt_message"] = agent._interrupt_message
|
||||
|
||||
# Clear interrupt state after handling
|
||||
agent.clear_interrupt()
|
||||
|
||||
# Clear stream callback so it doesn't leak into future calls
|
||||
agent._stream_callback = None
|
||||
|
||||
# Check skill trigger NOW — based on how many tool iterations THIS turn used.
|
||||
_should_review_skills = False
|
||||
if (agent._skill_nudge_interval > 0
|
||||
and agent._iters_since_skill >= agent._skill_nudge_interval
|
||||
and "skill_manage" in agent.valid_tool_names):
|
||||
_should_review_skills = True
|
||||
agent._iters_since_skill = 0
|
||||
|
||||
# External memory provider: sync the completed turn + queue next prefetch.
|
||||
agent._sync_external_memory_for_turn(
|
||||
original_user_message=original_user_message,
|
||||
# Post-loop turn finalization extracted to agent/turn_finalizer.finalize_turn
|
||||
# (god-file decomposition Phase 1 step 4). Behavior-neutral: the assembled
|
||||
# result dict is returned exactly as before.
|
||||
from agent.turn_finalizer import finalize_turn
|
||||
return finalize_turn(
|
||||
agent,
|
||||
final_response=final_response,
|
||||
api_call_count=api_call_count,
|
||||
interrupted=interrupted,
|
||||
failed=failed,
|
||||
messages=messages,
|
||||
conversation_history=conversation_history,
|
||||
effective_task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
user_message=user_message,
|
||||
original_user_message=original_user_message,
|
||||
_should_review_memory=_should_review_memory,
|
||||
_turn_exit_reason=_turn_exit_reason,
|
||||
)
|
||||
|
||||
# Background memory/skill review — runs AFTER the response is delivered
|
||||
# so it never competes with the user's task for model attention.
|
||||
if final_response and not interrupted and (_should_review_memory or _should_review_skills):
|
||||
try:
|
||||
agent._spawn_background_review(
|
||||
messages_snapshot=list(messages),
|
||||
review_memory=_should_review_memory,
|
||||
review_skills=_should_review_skills,
|
||||
)
|
||||
except Exception:
|
||||
pass # Background review is best-effort
|
||||
|
||||
# Note: Memory provider on_session_end() + shutdown_all() are NOT
|
||||
# called here — run_conversation() is called once per user message in
|
||||
# multi-turn sessions. Shutting down after every turn would kill the
|
||||
# provider before the second message. Actual session-end cleanup is
|
||||
# handled by the CLI (atexit / /reset) and gateway (session expiry /
|
||||
# _reset_session).
|
||||
|
||||
# Plugin hook: on_session_end
|
||||
# Fired at the very end of every run_conversation call.
|
||||
# Plugins can use this for cleanup, flushing buffers, etc.
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
_invoke_hook(
|
||||
"on_session_end",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
completed=completed,
|
||||
interrupted=interrupted,
|
||||
model=agent.model,
|
||||
platform=getattr(agent, "platform", None) or "",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("on_session_end hook failed: %s", exc)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
||||
__all__ = ["run_conversation"]
|
||||
|
||||
@@ -91,6 +91,7 @@ AUTH_TYPE_OAUTH = "oauth"
|
||||
AUTH_TYPE_API_KEY = "api_key"
|
||||
|
||||
SOURCE_MANUAL = "manual"
|
||||
SOURCE_MANUAL_DEVICE_CODE = f"{SOURCE_MANUAL}:device_code"
|
||||
|
||||
STRATEGY_FILL_FIRST = "fill_first"
|
||||
STRATEGY_ROUND_ROBIN = "round_robin"
|
||||
|
||||
@@ -262,6 +262,7 @@ def _install_npm(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
@@ -310,6 +311,7 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
|
||||
text=True,
|
||||
timeout=600,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
@@ -347,6 +349,7 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.warning(
|
||||
|
||||
@@ -274,6 +274,7 @@ def _platform_asset_name() -> str:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if "musl" in (res.stdout + res.stderr).lower():
|
||||
libc = "musl"
|
||||
@@ -525,6 +526,7 @@ def _run_bws_list(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_BWS_RUN_TIMEOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise RuntimeError(
|
||||
|
||||
@@ -74,6 +74,7 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
|
||||
text=True,
|
||||
timeout=max(1, int(timeout)),
|
||||
check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"[inline-shell timeout after {timeout}s: {command}]"
|
||||
|
||||
@@ -702,7 +702,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
||||
if agent._should_emit_quiet_tool_messages():
|
||||
cute_msg = _get_cute_tool_message_impl(name, args, tool_duration, result=function_result)
|
||||
agent._safe_print(f" {cute_msg}")
|
||||
elif not agent.quiet_mode:
|
||||
elif getattr(agent, "tool_progress_mode", "all") != "off":
|
||||
_preview_str = _multimodal_text_summary(function_result)
|
||||
if agent.verbose_logging:
|
||||
print(f" ✅ Tool {i+1} completed in {tool_duration:.2f}s")
|
||||
|
||||
@@ -378,6 +378,7 @@ def check_codex_binary(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return False, (
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Post-loop turn finalization for ``run_conversation``.
|
||||
|
||||
Extracted from ``agent/conversation_loop.py`` as part of the god-file
|
||||
decomposition campaign (``~/.hermes/plans/god-file-decomposition.md``, Phase 1
|
||||
step 4 — the post-loop ``TurnFinalizer`` seam). ``run_conversation``'s tail
|
||||
(everything after the main tool-calling ``while`` loop) is lifted here verbatim:
|
||||
budget-exhaustion summary, trajectory save, session persist, turn diagnostics,
|
||||
response transforms, result-dict assembly, steer drain, and the memory/skill
|
||||
review trigger.
|
||||
|
||||
Behavior-neutral: the body is moved unchanged. All ``agent.*`` side effects fire
|
||||
exactly as before; only the post-loop *locals* are passed in as keyword args, and
|
||||
the assembled ``result`` dict is returned to ``run_conversation`` which returns it
|
||||
to the caller. The function is synchronous with a single return — mirroring the
|
||||
region it replaces (no awaits, no early returns).
|
||||
|
||||
Module ``logger`` is imported lazily inside the body (``from
|
||||
agent.conversation_loop import logger``) so this module never imports
|
||||
``agent.conversation_loop`` at import time -> no import cycle, and the log records
|
||||
keep the exact logger name (``"agent.conversation_loop"``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from agent.codex_responses_adapter import _summarize_user_message_for_log
|
||||
|
||||
|
||||
def finalize_turn(
|
||||
agent,
|
||||
*,
|
||||
final_response,
|
||||
api_call_count,
|
||||
interrupted,
|
||||
failed,
|
||||
messages,
|
||||
conversation_history,
|
||||
effective_task_id,
|
||||
turn_id,
|
||||
user_message,
|
||||
original_user_message,
|
||||
_should_review_memory,
|
||||
_turn_exit_reason,
|
||||
):
|
||||
"""Run the post-loop finalization and return the turn ``result`` dict.
|
||||
|
||||
Lifted verbatim from ``run_conversation`` (the region after the main agent
|
||||
loop). See module docstring.
|
||||
"""
|
||||
from agent.conversation_loop import logger
|
||||
|
||||
if final_response is None and (
|
||||
api_call_count >= agent.max_iterations
|
||||
or agent.iteration_budget.remaining <= 0
|
||||
):
|
||||
# Budget exhausted — ask the model for a summary via one extra
|
||||
# API call with tools stripped. _handle_max_iterations injects a
|
||||
# user message and makes a single toolless request.
|
||||
_turn_exit_reason = f"max_iterations_reached({api_call_count}/{agent.max_iterations})"
|
||||
agent._emit_status(
|
||||
f"⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
|
||||
"— asking model to summarise"
|
||||
)
|
||||
if not agent.quiet_mode:
|
||||
agent._safe_print(
|
||||
f"\n⚠️ Iteration budget exhausted ({api_call_count}/{agent.max_iterations}) "
|
||||
"— requesting summary..."
|
||||
)
|
||||
final_response = agent._handle_max_iterations(messages, api_call_count)
|
||||
|
||||
# If running as a kanban worker, signal the dispatcher that the
|
||||
# worker could not complete (rather than treating it as a
|
||||
# protocol violation). The agent loop strips tools before calling
|
||||
# _handle_max_iterations, so the model cannot call kanban_block
|
||||
# itself — we must do it on its behalf.
|
||||
#
|
||||
# We route through ``_record_task_failure(outcome="timed_out")``
|
||||
# rather than ``kanban_block`` so this counts toward the
|
||||
# ``consecutive_failures`` counter and the dispatcher's
|
||||
# ``failure_limit`` circuit breaker (#29747 gap 2). Without this,
|
||||
# a task whose worker keeps exhausting its budget would block
|
||||
# silently each run, get auto-promoted by the operator (or never
|
||||
# surface), and re-block in an endless loop with no signal.
|
||||
_kanban_task = os.environ.get("HERMES_KANBAN_TASK")
|
||||
if _kanban_task:
|
||||
try:
|
||||
from hermes_cli import kanban_db as _kb
|
||||
_conn = _kb.connect()
|
||||
try:
|
||||
_kb._record_task_failure(
|
||||
_conn,
|
||||
_kanban_task,
|
||||
error=(
|
||||
f"Iteration budget exhausted "
|
||||
f"({api_call_count}/{agent.max_iterations}) — "
|
||||
"task could not complete within the allowed "
|
||||
"iterations"
|
||||
),
|
||||
outcome="timed_out",
|
||||
release_claim=True,
|
||||
end_run=True,
|
||||
event_payload_extra={
|
||||
"budget_used": api_call_count,
|
||||
"budget_max": agent.max_iterations,
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"recorded budget-exhausted failure for task %s (%d/%d)",
|
||||
_kanban_task, api_call_count, agent.max_iterations,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to record budget-exhausted failure for task %s",
|
||||
_kanban_task,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Determine if conversation completed successfully
|
||||
completed = (
|
||||
final_response is not None
|
||||
and api_call_count < agent.max_iterations
|
||||
and not failed
|
||||
)
|
||||
|
||||
# Save trajectory if enabled. ``user_message`` may be a multimodal
|
||||
# list of parts; the trajectory format wants a plain string.
|
||||
agent._save_trajectory(messages, _summarize_user_message_for_log(user_message), completed)
|
||||
|
||||
# Clean up VM and browser for this task after conversation completes
|
||||
agent._cleanup_task_resources(effective_task_id)
|
||||
|
||||
# Persist session to both JSON log and SQLite only after private retry
|
||||
# scaffolding has been removed. Otherwise a later user "continue" turn
|
||||
# can replay assistant("(empty)") / recovery nudges and fall into the
|
||||
# same empty-response loop again.
|
||||
agent._drop_trailing_empty_response_scaffolding(messages)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
|
||||
# ── Turn-exit diagnostic log ─────────────────────────────────────
|
||||
# Always logged at INFO so agent.log captures WHY every turn ended.
|
||||
# When the last message is a tool result (agent was mid-work), log
|
||||
# at WARNING — this is the "just stops" scenario users report.
|
||||
_last_msg_role = messages[-1].get("role") if messages else None
|
||||
_last_tool_name = None
|
||||
if _last_msg_role == "tool":
|
||||
# Walk back to find the assistant message with the tool call
|
||||
for _m in reversed(messages):
|
||||
if _m.get("role") == "assistant" and _m.get("tool_calls"):
|
||||
_tcs = _m["tool_calls"]
|
||||
if _tcs and isinstance(_tcs[0], dict):
|
||||
_last_tool_name = _tcs[-1].get("function", {}).get("name")
|
||||
break
|
||||
|
||||
_turn_tool_count = sum(
|
||||
1 for m in messages
|
||||
if isinstance(m, dict) and m.get("role") == "assistant" and m.get("tool_calls")
|
||||
)
|
||||
_resp_len = len(final_response) if final_response else 0
|
||||
_budget_used = agent.iteration_budget.used if agent.iteration_budget else 0
|
||||
_budget_max = agent.iteration_budget.max_total if agent.iteration_budget else 0
|
||||
|
||||
_diag_msg = (
|
||||
"Turn ended: reason=%s model=%s api_calls=%d/%d budget=%d/%d "
|
||||
"tool_turns=%d last_msg_role=%s response_len=%d session=%s"
|
||||
)
|
||||
_diag_args = (
|
||||
_turn_exit_reason, agent.model, api_call_count, agent.max_iterations,
|
||||
_budget_used, _budget_max,
|
||||
_turn_tool_count, _last_msg_role, _resp_len,
|
||||
agent.session_id or "none",
|
||||
)
|
||||
|
||||
if _last_msg_role == "tool" and not interrupted:
|
||||
# Agent was mid-work — this is the "just stops" case.
|
||||
logger.warning(
|
||||
"Turn ended with pending tool result (agent may appear stuck). "
|
||||
+ _diag_msg + " last_tool=%s",
|
||||
*_diag_args, _last_tool_name,
|
||||
)
|
||||
else:
|
||||
logger.info(_diag_msg, *_diag_args)
|
||||
|
||||
# File-mutation verifier footer.
|
||||
# If one or more ``write_file`` / ``patch`` calls failed during this
|
||||
# turn and were never superseded by a successful write to the same
|
||||
# path, append an advisory footer to the assistant response. This
|
||||
# catches the specific case — reported by Ben Eng (#15524-adjacent)
|
||||
# — where a model issues a batch of parallel patches, half of them
|
||||
# fail with "Could not find old_string", and the model summarises
|
||||
# the turn claiming every file was edited. The user then has to
|
||||
# manually run ``git status`` to catch the lie. With this footer
|
||||
# the truth is surfaced on every turn, so over-claiming is
|
||||
# structurally impossible past the model.
|
||||
#
|
||||
# Gate: only applied when a real text response exists for this
|
||||
# turn and the user didn't interrupt. Empty/interrupted turns
|
||||
# already have other surface text that shouldn't be augmented.
|
||||
if final_response and not interrupted:
|
||||
try:
|
||||
_failed = getattr(agent, "_turn_failed_file_mutations", None) or {}
|
||||
if _failed and agent._file_mutation_verifier_enabled():
|
||||
footer = agent._format_file_mutation_failure_footer(_failed)
|
||||
if footer:
|
||||
final_response = final_response.rstrip() + "\n\n" + footer
|
||||
except Exception as _ver_err:
|
||||
logger.debug("file-mutation verifier footer failed: %s", _ver_err)
|
||||
|
||||
# Turn-completion explainer.
|
||||
# When a turn ends abnormally after substantive work — empty content
|
||||
# after retries, a partial/truncated stream, a still-pending tool
|
||||
# result, or an iteration/budget limit — the user otherwise gets a
|
||||
# blank or fragmentary response box with no consolidated reason why
|
||||
# the agent stopped (#34452). Surface a single user-visible
|
||||
# explanation derived from ``_turn_exit_reason``, mirroring the
|
||||
# file-mutation verifier footer pattern above.
|
||||
#
|
||||
# Gate carefully so healthy turns stay quiet:
|
||||
# - ``text_response(...)`` exits never produce an explanation
|
||||
# (handled inside the formatter), so a terse ``Done.`` is silent.
|
||||
# - We only ACT when there is no genuinely usable reply this turn:
|
||||
# an empty response, the "(empty)" terminal sentinel, or a
|
||||
# suspiciously short partial fragment with no terminating
|
||||
# punctuation (e.g. "The"). A real short answer keeps its text.
|
||||
if not interrupted:
|
||||
try:
|
||||
if agent._turn_completion_explainer_enabled():
|
||||
_stripped = (final_response or "").strip()
|
||||
_is_empty_terminal = _stripped == "" or _stripped == "(empty)"
|
||||
# A short fragment that is not a normal text_response exit
|
||||
# and lacks sentence-ending punctuation is treated as a
|
||||
# truncated partial (the "The" case from #34452).
|
||||
_is_partial_fragment = (
|
||||
not _is_empty_terminal
|
||||
and not str(_turn_exit_reason).startswith("text_response")
|
||||
and len(_stripped) <= 24
|
||||
and _stripped[-1:] not in {".", "!", "?", "。", "!", "?", "`", ")"}
|
||||
)
|
||||
if _is_empty_terminal or _is_partial_fragment:
|
||||
_explanation = agent._format_turn_completion_explanation(
|
||||
_turn_exit_reason
|
||||
)
|
||||
if _explanation:
|
||||
if _is_empty_terminal:
|
||||
# Replace the bare "(empty)"/blank sentinel with
|
||||
# the actionable explanation.
|
||||
final_response = _explanation
|
||||
else:
|
||||
# Keep the partial fragment, append the reason so
|
||||
# the user sees both what arrived and why it
|
||||
# stopped.
|
||||
final_response = (
|
||||
_stripped + "\n\n" + _explanation
|
||||
)
|
||||
except Exception as _exp_err:
|
||||
logger.debug("turn-completion explainer failed: %s", _exp_err)
|
||||
|
||||
_response_transformed = False
|
||||
|
||||
# Plugin hook: transform_llm_output
|
||||
# Fired once per turn after the tool-calling loop completes.
|
||||
# Plugins can transform the LLM's output text before it's returned.
|
||||
# First hook to return a string wins; None/empty return leaves text unchanged.
|
||||
if final_response and not interrupted:
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
_transform_results = _invoke_hook(
|
||||
"transform_llm_output",
|
||||
response_text=final_response,
|
||||
session_id=agent.session_id or "",
|
||||
model=agent.model,
|
||||
platform=getattr(agent, "platform", None) or "",
|
||||
)
|
||||
for _hook_result in _transform_results:
|
||||
if isinstance(_hook_result, str) and _hook_result:
|
||||
final_response = _hook_result
|
||||
_response_transformed = True
|
||||
break # First non-empty string wins
|
||||
except Exception as exc:
|
||||
logger.warning("transform_llm_output hook failed: %s", exc)
|
||||
|
||||
# Plugin hook: post_llm_call
|
||||
# Fired once per turn after the tool-calling loop completes.
|
||||
# Plugins can use this to persist conversation data (e.g. sync
|
||||
# to an external memory system).
|
||||
if final_response and not interrupted:
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
_invoke_hook(
|
||||
"post_llm_call",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
user_message=original_user_message,
|
||||
assistant_response=final_response,
|
||||
conversation_history=list(messages),
|
||||
model=agent.model,
|
||||
platform=getattr(agent, "platform", None) or "",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("post_llm_call hook failed: %s", exc)
|
||||
|
||||
# Extract reasoning from the CURRENT turn only. Walk backwards
|
||||
# but stop at the user message that started this turn — anything
|
||||
# earlier is from a prior turn and must not leak into the reasoning
|
||||
# box (confusing stale display; #17055). Within the current turn
|
||||
# we still want the *most recent* non-empty reasoning: many
|
||||
# providers (Claude thinking, DeepSeek v4, Codex Responses) emit
|
||||
# reasoning on the tool-call step and leave the final-answer step
|
||||
# with reasoning=None, so picking only the last assistant would
|
||||
# silently drop legitimate same-turn reasoning.
|
||||
last_reasoning = None
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "user":
|
||||
break # turn boundary — don't cross into prior turns
|
||||
if msg.get("role") == "assistant" and msg.get("reasoning"):
|
||||
last_reasoning = msg["reasoning"]
|
||||
break
|
||||
|
||||
# Build result with interrupt info if applicable
|
||||
result = {
|
||||
"final_response": final_response,
|
||||
"last_reasoning": last_reasoning,
|
||||
"messages": messages,
|
||||
"api_calls": api_call_count,
|
||||
"completed": completed,
|
||||
"turn_exit_reason": _turn_exit_reason,
|
||||
"failed": failed,
|
||||
"partial": False, # True only when stopped due to invalid tool calls
|
||||
"interrupted": interrupted,
|
||||
"response_transformed": _response_transformed,
|
||||
"response_previewed": getattr(agent, "_response_was_previewed", False),
|
||||
"model": agent.model,
|
||||
"provider": agent.provider,
|
||||
"base_url": agent.base_url,
|
||||
"input_tokens": agent.session_input_tokens,
|
||||
"output_tokens": agent.session_output_tokens,
|
||||
"cache_read_tokens": agent.session_cache_read_tokens,
|
||||
"cache_write_tokens": agent.session_cache_write_tokens,
|
||||
"reasoning_tokens": agent.session_reasoning_tokens,
|
||||
"prompt_tokens": agent.session_prompt_tokens,
|
||||
"completion_tokens": agent.session_completion_tokens,
|
||||
"total_tokens": agent.session_total_tokens,
|
||||
"last_prompt_tokens": getattr(agent.context_compressor, "last_prompt_tokens", 0) or 0,
|
||||
"estimated_cost_usd": agent.session_estimated_cost_usd,
|
||||
"cost_status": agent.session_cost_status,
|
||||
"cost_source": agent.session_cost_source,
|
||||
"session_id": agent.session_id,
|
||||
}
|
||||
if agent._tool_guardrail_halt_decision is not None:
|
||||
result["guardrail"] = agent._tool_guardrail_halt_decision.to_metadata()
|
||||
# If a /steer landed after the final assistant turn (no more tool
|
||||
# batches to drain into), hand it back to the caller so it can be
|
||||
# delivered as the next user turn instead of being silently lost.
|
||||
_leftover_steer = agent._drain_pending_steer()
|
||||
if _leftover_steer:
|
||||
result["pending_steer"] = _leftover_steer
|
||||
agent._response_was_previewed = False
|
||||
|
||||
# Include interrupt message if one triggered the interrupt
|
||||
if interrupted and agent._interrupt_message:
|
||||
result["interrupt_message"] = agent._interrupt_message
|
||||
|
||||
# Clear interrupt state after handling
|
||||
agent.clear_interrupt()
|
||||
|
||||
# Clear stream callback so it doesn't leak into future calls
|
||||
agent._stream_callback = None
|
||||
|
||||
# Check skill trigger NOW — based on how many tool iterations THIS turn used.
|
||||
_should_review_skills = False
|
||||
if (agent._skill_nudge_interval > 0
|
||||
and agent._iters_since_skill >= agent._skill_nudge_interval
|
||||
and "skill_manage" in agent.valid_tool_names):
|
||||
_should_review_skills = True
|
||||
agent._iters_since_skill = 0
|
||||
|
||||
# External memory provider: sync the completed turn + queue next prefetch.
|
||||
agent._sync_external_memory_for_turn(
|
||||
original_user_message=original_user_message,
|
||||
final_response=final_response,
|
||||
interrupted=interrupted,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
# Background memory/skill review — runs AFTER the response is delivered
|
||||
# so it never competes with the user's task for model attention.
|
||||
if final_response and not interrupted and (_should_review_memory or _should_review_skills):
|
||||
try:
|
||||
agent._spawn_background_review(
|
||||
messages_snapshot=list(messages),
|
||||
review_memory=_should_review_memory,
|
||||
review_skills=_should_review_skills,
|
||||
)
|
||||
except Exception:
|
||||
pass # Background review is best-effort
|
||||
|
||||
# Note: Memory provider on_session_end() + shutdown_all() are NOT
|
||||
# called here — run_conversation() is called once per user message in
|
||||
# multi-turn sessions. Shutting down after every turn would kill the
|
||||
# provider before the second message. Actual session-end cleanup is
|
||||
# handled by the CLI (atexit / /reset) and gateway (session expiry /
|
||||
# _reset_session).
|
||||
|
||||
# Plugin hook: on_session_end
|
||||
# Fired at the very end of every run_conversation call.
|
||||
# Plugins can use this for cleanup, flushing buffers, etc.
|
||||
try:
|
||||
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||
_invoke_hook(
|
||||
"on_session_end",
|
||||
session_id=agent.session_id,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
completed=completed,
|
||||
interrupted=interrupted,
|
||||
model=agent.model,
|
||||
platform=getattr(agent, "platform", None) or "",
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("on_session_end hook failed: %s", exc)
|
||||
|
||||
return result
|
||||
@@ -119,6 +119,20 @@ if (REMOTE_DISPLAY_REASON) {
|
||||
`[hermes] remote display detected (${REMOTE_DISPLAY_REASON}); disabling GPU hardware acceleration to prevent flicker`
|
||||
)
|
||||
}
|
||||
|
||||
// Keep the renderer running at full speed while the window is in the background
|
||||
// or occluded. The chat transcript streams to screen through a
|
||||
// requestAnimationFrame-gated flush; Chromium pauses rAF (and clamps timers)
|
||||
// for backgrounded/occluded renderers, so without these the live answer stalls
|
||||
// whenever the window loses focus (switching to your editor mid-turn, detached
|
||||
// devtools, another window covering it) and only paints on refocus or refresh.
|
||||
// `backgroundThrottling: false` on the BrowserWindow covers the blurred case;
|
||||
// these process-level switches additionally stop Chromium from backgrounding or
|
||||
// occlusion-throttling the renderer. Must run before app `ready`.
|
||||
app.commandLine.appendSwitch('disable-renderer-backgrounding')
|
||||
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows')
|
||||
app.commandLine.appendSwitch('disable-background-timer-throttling')
|
||||
|
||||
const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..')
|
||||
|
||||
// Build-time install stamp -- the git ref this .exe was built against.
|
||||
@@ -3899,10 +3913,12 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
||||
const scoped = key ? config.profiles?.[key] || null : null
|
||||
const block = key ? scoped || {} : config.remote || {}
|
||||
|
||||
const envOverride = key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
|
||||
const remoteToken = decryptDesktopSecret(block.token)
|
||||
const authMode = normAuthMode(block.authMode)
|
||||
const remoteUrl = String(block.url || '')
|
||||
const mode = (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
|
||||
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
|
||||
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
|
||||
|
||||
let remoteOauthConnected = false
|
||||
if (authMode === 'oauth' && remoteUrl) {
|
||||
@@ -3928,7 +3944,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
||||
remoteTokenSet: Boolean(remoteToken),
|
||||
// The env override only forces the global/primary connection; a per-profile
|
||||
// scope is never overridden by HERMES_DESKTOP_REMOTE_URL.
|
||||
envOverride: key ? false : Boolean(process.env.HERMES_DESKTOP_REMOTE_URL)
|
||||
envOverride
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4687,7 +4703,16 @@ function createWindow() {
|
||||
webviewTag: true,
|
||||
sandbox: true,
|
||||
nodeIntegration: false,
|
||||
devTools: true
|
||||
devTools: true,
|
||||
// Keep timers + requestAnimationFrame running at full speed when the
|
||||
// window is blurred/occluded. The chat transcript streams to the screen
|
||||
// through a requestAnimationFrame-gated flush (useSessionStateCache),
|
||||
// so with Chromium's default background throttling the live answer
|
||||
// stalls whenever this window isn't focused (e.g. you switch to your
|
||||
// editor mid-turn, or open detached devtools) and only appears once you
|
||||
// refocus or refresh. A streaming chat app must render in the
|
||||
// background, so opt out — matching the secondary windows above.
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
CRON_SECTION_LIMIT,
|
||||
getRecentlySettledSessionIds,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
@@ -130,12 +131,18 @@ function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
||||
}
|
||||
|
||||
// Rows a session refresh must preserve even if the aggregator omits them:
|
||||
// in-flight first turns (message_count 0), pinned rows aged off the page, and
|
||||
// the actively-viewed chat (its "working" flag clears a beat before the
|
||||
// aggregator sees the persisted row). Pass `scope` to only keep the active row
|
||||
// when it belongs to the profile being paged.
|
||||
// in-flight first turns (message_count 0), pinned rows aged off the page, the
|
||||
// actively-viewed chat (its "working" flag clears a beat before the aggregator
|
||||
// sees the persisted row), and sessions whose turn just settled (same race, but
|
||||
// for a chat the user has already navigated away from). Pass `scope` to only
|
||||
// keep the active row when it belongs to the profile being paged.
|
||||
function sessionsToKeep(scope?: string): Set<string> {
|
||||
const keep = new Set<string>([...$workingSessionIds.get(), ...$pinnedSessionIds.get()])
|
||||
const keep = new Set<string>([
|
||||
...$workingSessionIds.get(),
|
||||
...$pinnedSessionIds.get(),
|
||||
...getRecentlySettledSessionIds()
|
||||
])
|
||||
|
||||
const active = $selectedStoredSessionId.get()
|
||||
|
||||
if (active) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
upsertToolPart
|
||||
} from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setClarifyRequest } from '@/store/clarify'
|
||||
@@ -613,6 +614,9 @@ export function useMessageStream({
|
||||
(event: RpcEvent) => {
|
||||
const payload = event.payload as GatewayEventPayload | undefined
|
||||
const explicitSid = event.session_id || ''
|
||||
if (!explicitSid && gatewayEventRequiresSessionId(event.type)) {
|
||||
return
|
||||
}
|
||||
const sessionId = explicitSid || activeSessionIdRef.current
|
||||
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useEffect } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $sessions, setSessions } from '@/store/session'
|
||||
import { $connection } from '@/store/session'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { usePromptActions } from './use-prompt-actions'
|
||||
@@ -42,7 +44,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
|
||||
interface HarnessHandle {
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
|
||||
submitText: (
|
||||
text: string,
|
||||
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
function Harness({
|
||||
@@ -314,3 +319,92 @@ describe('usePromptActions steerPrompt', () => {
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions file attachment sync', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$connection.set(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function fileAttachment(): ComposerAttachment {
|
||||
return {
|
||||
id: 'file:report.txt',
|
||||
kind: 'file',
|
||||
label: 'report.txt',
|
||||
path: '/Users/alice/Downloads/report.txt',
|
||||
refText: '@file:`/Users/alice/Downloads/report.txt`'
|
||||
}
|
||||
}
|
||||
|
||||
it('uploads file bytes via file.attach on a remote gateway and submits the rewritten ref', async () => {
|
||||
// Remote gateway can't read the client-disk path, so the desktop must upload
|
||||
// the bytes and submit the workspace-relative ref the gateway hands back —
|
||||
// not the original /Users/... path (which would dead-end as "outside the
|
||||
// allowed workspace").
|
||||
$connection.set({ mode: 'remote' } as never)
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: { readFileDataUrl: vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') }
|
||||
})
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'file.attach') {
|
||||
return {
|
||||
attached: true,
|
||||
path: '/remote/work/.hermes/desktop-attachments/report.txt',
|
||||
ref_text: '@file:.hermes/desktop-attachments/report.txt',
|
||||
uploaded: true
|
||||
} as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(calls.map(c => c.method)).toEqual(['file.attach', 'prompt.submit'])
|
||||
expect(calls[0]?.params).toMatchObject({
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
path: '/Users/alice/Downloads/report.txt',
|
||||
name: 'report.txt',
|
||||
data_url: 'data:text/plain;base64,aGVsbG8='
|
||||
})
|
||||
expect(calls[1]?.params).toEqual({
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
|
||||
$connection.set({ mode: 'local' } as never)
|
||||
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
if (method === 'file.attach') {
|
||||
return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
|
||||
|
||||
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(calls[0]?.method).toBe('file.attach')
|
||||
// Local mode sends no data_url — the gateway shares this disk.
|
||||
expect(calls[0]?.params).not.toHaveProperty('data_url')
|
||||
expect(calls[1]).toEqual({
|
||||
method: 'prompt.submit',
|
||||
params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
|
||||
import type {
|
||||
ClientSessionState,
|
||||
FileAttachResponse,
|
||||
ImageAttachResponse,
|
||||
SessionSteerResponse,
|
||||
SessionTitleResponse,
|
||||
@@ -103,6 +104,20 @@ async function readImageForRemoteAttach(
|
||||
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
|
||||
}
|
||||
|
||||
// Read a non-image file as a data URL for upload via file.attach. Returns null
|
||||
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
|
||||
async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
|
||||
const reader = window.hermesDesktop?.readFileDataUrl
|
||||
|
||||
if (!reader) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dataUrl = await reader(filePath)
|
||||
|
||||
return dataUrl || null
|
||||
}
|
||||
|
||||
interface PromptActionsOptions {
|
||||
activeSessionId: string | null
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
@@ -212,62 +227,114 @@ export function usePromptActions({
|
||||
[selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
|
||||
const syncImageAttachmentsForSubmit = useCallback(
|
||||
const syncAttachmentsForSubmit = useCallback(
|
||||
async (
|
||||
sessionId: string,
|
||||
attachments: ComposerAttachment[],
|
||||
options: { updateComposerAttachments?: boolean } = {}
|
||||
) => {
|
||||
): Promise<ComposerAttachment[]> => {
|
||||
const updateComposerAttachments = options.updateComposerAttachments ?? true
|
||||
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
|
||||
const remote = $connection.get()?.mode === 'remote'
|
||||
const synced: ComposerAttachment[] = []
|
||||
|
||||
for (const attachment of images) {
|
||||
if (attachment.attachedSessionId === sessionId) {
|
||||
for (const attachment of attachments) {
|
||||
// Already-synced or pathless refs (terminal, url, etc.) pass through.
|
||||
if (!attachment.path || attachment.attachedSessionId === sessionId) {
|
||||
synced.push(attachment)
|
||||
continue
|
||||
}
|
||||
|
||||
let result: ImageAttachResponse
|
||||
if (attachment.kind === 'image') {
|
||||
let result: ImageAttachResponse
|
||||
|
||||
if (remote) {
|
||||
// The gateway is on another machine — it can't read attachment.path
|
||||
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
|
||||
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
|
||||
if (remote) {
|
||||
// The gateway is on another machine — it can't read attachment.path
|
||||
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
|
||||
const payload = await readImageForRemoteAttach(attachment.path)
|
||||
|
||||
if (!payload) {
|
||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
||||
throw new Error(`Could not read ${label}`)
|
||||
if (!payload) {
|
||||
const label = attachment.label || pathLabel(attachment.path)
|
||||
throw new Error(`Could not read ${label}`)
|
||||
}
|
||||
|
||||
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
|
||||
session_id: sessionId,
|
||||
content_base64: payload.contentBase64,
|
||||
filename: payload.filename
|
||||
})
|
||||
} else {
|
||||
result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
}
|
||||
|
||||
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
|
||||
session_id: sessionId,
|
||||
content_base64: payload.contentBase64,
|
||||
filename: payload.filename
|
||||
})
|
||||
} else {
|
||||
result = await requestGateway<ImageAttachResponse>('image.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path
|
||||
})
|
||||
}
|
||||
if (!result.attached) {
|
||||
const label = attachment.label || pathLabel(attachment.path)
|
||||
throw new Error(result.message || `Could not attach ${label}`)
|
||||
}
|
||||
|
||||
if (!result.attached) {
|
||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
||||
throw new Error(result.message || `Could not attach ${label}`)
|
||||
}
|
||||
|
||||
const attachedPath = result.path || attachment.path
|
||||
|
||||
if (updateComposerAttachments) {
|
||||
addComposerAttachment({
|
||||
const attachedPath = result.path || attachment.path
|
||||
const nextAttachment: ComposerAttachment = {
|
||||
...attachment,
|
||||
id: attachment.id,
|
||||
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
|
||||
path: attachedPath,
|
||||
attachedSessionId: sessionId
|
||||
})
|
||||
}
|
||||
|
||||
if (updateComposerAttachments) {
|
||||
addComposerAttachment(nextAttachment)
|
||||
}
|
||||
|
||||
synced.push(nextAttachment)
|
||||
continue
|
||||
}
|
||||
|
||||
if (attachment.kind === 'file') {
|
||||
// Non-image file refs are @file: paths the gateway reads with its file
|
||||
// tools. On a remote gateway the desktop path doesn't exist there, so
|
||||
// upload the bytes; the gateway stages them into the session workspace
|
||||
// and hands back a workspace-relative ref that actually resolves.
|
||||
// Local mode can pass the path directly (gateway shares this disk).
|
||||
const dataUrl = remote ? await readFileDataUrlForAttach(attachment.path) : null
|
||||
|
||||
if (remote && !dataUrl) {
|
||||
const label = attachment.label || pathLabel(attachment.path)
|
||||
throw new Error(`Could not read ${label}`)
|
||||
}
|
||||
|
||||
const result = await requestGateway<FileAttachResponse>('file.attach', {
|
||||
session_id: sessionId,
|
||||
path: attachment.path,
|
||||
name: attachment.label || pathLabel(attachment.path),
|
||||
...(dataUrl ? { data_url: dataUrl } : {})
|
||||
})
|
||||
|
||||
if (!result.attached || !result.ref_text) {
|
||||
const label = attachment.label || pathLabel(attachment.path)
|
||||
throw new Error(result.message || `Could not attach ${label}`)
|
||||
}
|
||||
|
||||
const nextAttachment: ComposerAttachment = {
|
||||
...attachment,
|
||||
id: attachment.id,
|
||||
refText: result.ref_text,
|
||||
attachedSessionId: sessionId
|
||||
}
|
||||
|
||||
if (updateComposerAttachments) {
|
||||
addComposerAttachment(nextAttachment)
|
||||
}
|
||||
|
||||
synced.push(nextAttachment)
|
||||
continue
|
||||
}
|
||||
|
||||
synced.push(attachment)
|
||||
}
|
||||
|
||||
return synced
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
@@ -278,35 +345,42 @@ export function usePromptActions({
|
||||
const usingComposerAttachments = !options?.attachments
|
||||
const attachments = options?.attachments ?? $composerAttachments.get()
|
||||
|
||||
const contextRefs = attachments
|
||||
.map(a => a.refText)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
|
||||
const hasImage = attachments.some(a => a.kind === 'image')
|
||||
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
|
||||
|
||||
const text =
|
||||
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
||||
(hasImage ? 'What do you see in this image?' : '')
|
||||
// Refs are recomputed after sync (file.attach rewrites @file: refs to
|
||||
// workspace-relative paths the remote gateway can resolve). Seed the
|
||||
// optimistic message with the pre-sync refs, then rewrite once synced.
|
||||
let attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
|
||||
const buildContextText = (atts: ComposerAttachment[]): string => {
|
||||
const contextRefs = atts
|
||||
.map(a => a.refText)
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return (
|
||||
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
||||
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
|
||||
)
|
||||
}
|
||||
|
||||
// Queue drains fire on the busy→false settle edge, where busyRef (synced
|
||||
// from $busy by a separate effect) may still read true — honoring it would
|
||||
// bounce the drained send. The drain lock serializes them; the user path
|
||||
// keeps the guard so a stray Enter mid-turn can't double-submit.
|
||||
if (!text || (!options?.fromQueue && busyRef.current)) {
|
||||
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
|
||||
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
const buildUserMessage = (): ChatMessage => ({
|
||||
id: optimisticId,
|
||||
role: 'user',
|
||||
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
|
||||
attachmentRefs
|
||||
}
|
||||
})
|
||||
|
||||
const releaseBusy = () => {
|
||||
setMutableRef(busyRef, false)
|
||||
@@ -323,7 +397,7 @@ export function usePromptActions({
|
||||
...state,
|
||||
messages: state.messages.some(m => m.id === optimisticId)
|
||||
? state.messages
|
||||
: [...state.messages, userMessage],
|
||||
: [...state.messages, buildUserMessage()],
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
pendingBranchGroup: null,
|
||||
@@ -336,6 +410,18 @@ export function usePromptActions({
|
||||
selectedStoredSessionIdRef.current
|
||||
)
|
||||
|
||||
// After sync rewrites refs, refresh the optimistic message in place so the
|
||||
// transcript shows the resolved @file: ref rather than the local path.
|
||||
const rewriteOptimistic = (sid: string) =>
|
||||
updateSessionState(
|
||||
sid,
|
||||
state => ({
|
||||
...state,
|
||||
messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))
|
||||
}),
|
||||
selectedStoredSessionIdRef.current
|
||||
)
|
||||
|
||||
const dropOptimistic = (sid: null | string) => {
|
||||
if (!sid) {
|
||||
setMessages(current => current.filter(m => m.id !== optimisticId))
|
||||
@@ -366,7 +452,7 @@ export function usePromptActions({
|
||||
if (sessionId) {
|
||||
seedOptimistic(sessionId)
|
||||
} else {
|
||||
setMessages(current => [...current, userMessage])
|
||||
setMessages(current => [...current, buildUserMessage()])
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -392,9 +478,14 @@ export function usePromptActions({
|
||||
}
|
||||
|
||||
try {
|
||||
await syncImageAttachmentsForSubmit(sessionId, attachments, {
|
||||
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
|
||||
updateComposerAttachments: usingComposerAttachments
|
||||
})
|
||||
// Rewrite the optimistic message + prompt text with the synced refs so
|
||||
// the gateway receives @file: paths that resolve in its workspace.
|
||||
attachmentRefs = syncedAttachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
|
||||
rewriteOptimistic(sessionId)
|
||||
const text = buildContextText(syncedAttachments)
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||
|
||||
if (usingComposerAttachments) {
|
||||
@@ -442,7 +533,7 @@ export function usePromptActions({
|
||||
createBackendSessionForSend,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
syncImageAttachmentsForSubmit,
|
||||
syncAttachmentsForSubmit,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile'
|
||||
import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { useTheme } from '@/themes/context'
|
||||
import { BUILTIN_THEMES } from '@/themes/presets'
|
||||
@@ -57,8 +58,17 @@ export function AppearanceSettings() {
|
||||
const { t, isSavingLocale } = useI18n()
|
||||
const { themeName, mode, availableThemes, setTheme, setMode } = useTheme()
|
||||
const toolViewMode = useStore($toolViewMode)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
|
||||
// Themes save per profile. Surface that only when the user actually has more
|
||||
// than one profile (single-profile installs never see the distinction).
|
||||
const showProfileNote = profiles.length > 1
|
||||
|
||||
const activeProfileName =
|
||||
profiles.find(profile => normalizeProfileKey(profile.name) === activeProfileKey)?.name ?? activeProfileKey
|
||||
|
||||
const modeOptions = MODE_OPTIONS.map(({ id, icon }) => ({ icon, id, label: t.settings.modeOptions[id].label }))
|
||||
|
||||
const toolOptions = [
|
||||
@@ -98,43 +108,50 @@ export function AppearanceSettings() {
|
||||
|
||||
<ListRow
|
||||
below={
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
<>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{availableThemes.map(theme => {
|
||||
const active = themeName === theme.name
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-2 text-left transition hover:bg-(--chrome-action-hover)',
|
||||
active && 'border-(--ui-stroke-secondary) bg-(--ui-bg-tertiary)'
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
key={theme.name}
|
||||
onClick={() => {
|
||||
triggerHaptic('crisp')
|
||||
setTheme(theme.name)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ThemePreview name={theme.name} />
|
||||
<div className="mt-3 flex items-start justify-between gap-3 px-1">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
{theme.label}
|
||||
</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{theme.description}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<span className="mt-0.5 grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground">
|
||||
<Check className="size-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{a.themeProfileNote(activeProfileName)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
description={a.themeDesc}
|
||||
title={a.themeTitle}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { $previewServerRestartStatus } from '@/store/preview'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
$connection,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentProvider,
|
||||
@@ -40,7 +41,14 @@ import {
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { $subagentsBySession, activeSubagentCount } from '@/store/subagents'
|
||||
import { $desktopVersion, $updateApply, $updateStatus, setUpdateOverlayOpen } from '@/store/updates'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateStatus,
|
||||
$desktopVersion,
|
||||
$updateApply,
|
||||
$updateStatus,
|
||||
openUpdateOverlayFor
|
||||
} from '@/store/updates'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
|
||||
import { CRON_ROUTE } from '../../routes'
|
||||
@@ -97,7 +105,10 @@ export function useStatusbarItems({
|
||||
const subagentsBySession = useStore($subagentsBySession)
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
const backendUpdateStatus = useStore($backendUpdateStatus)
|
||||
const backendUpdateApply = useStore($backendUpdateApply)
|
||||
const desktopVersion = useStore($desktopVersion)
|
||||
const connection = useStore($connection)
|
||||
|
||||
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
|
||||
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
|
||||
@@ -194,18 +205,19 @@ export function useStatusbarItems({
|
||||
? 'text-amber-600 hover:text-amber-600'
|
||||
: 'text-destructive hover:text-destructive'
|
||||
|
||||
const versionItem = useMemo<StatusbarItem>(() => {
|
||||
const clientVersionItem = useMemo<StatusbarItem>(() => {
|
||||
const appVersion = desktopVersion?.appVersion
|
||||
const sha = updateStatus?.currentSha?.slice(0, 7) ?? null
|
||||
const behind = updateStatus?.behind ?? 0
|
||||
const applying = updateApply.applying || updateApply.stage === 'restart'
|
||||
const base = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
|
||||
const remote = connection?.mode === 'remote'
|
||||
|
||||
const version = appVersion ? `v${appVersion}` : (sha ?? copy.unknown)
|
||||
const base = remote ? copy.clientLabel(appVersion ?? sha ?? copy.unknown) : version
|
||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||
|
||||
const label = applying
|
||||
? updateApply.stage === 'restart'
|
||||
? `${base} · ${copy.restart}`
|
||||
: `${base} · ${copy.update}`
|
||||
? `${base} · ${updateApply.stage === 'restart' ? copy.restart : copy.update}`
|
||||
: `${base}${behindHint}`
|
||||
|
||||
const tooltip = [
|
||||
@@ -220,17 +232,18 @@ export function useStatusbarItems({
|
||||
|
||||
return {
|
||||
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
|
||||
detail: appVersion && sha && !applying ? sha : undefined,
|
||||
detail: appVersion && sha && !applying && !remote ? sha : undefined,
|
||||
hidden: !appVersion && !sha,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version',
|
||||
id: 'version-client',
|
||||
label,
|
||||
onSelect: () => setUpdateOverlayOpen(true),
|
||||
onSelect: () => openUpdateOverlayFor('client'),
|
||||
title: tooltip || undefined,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
desktopVersion?.appVersion,
|
||||
connection?.mode,
|
||||
copy,
|
||||
updateApply.applying,
|
||||
updateApply.message,
|
||||
@@ -240,6 +253,50 @@ export function useStatusbarItems({
|
||||
updateStatus?.currentSha
|
||||
])
|
||||
|
||||
const backendVersionItem = useMemo<StatusbarItem | null>(() => {
|
||||
if (connection?.mode !== 'remote') {
|
||||
return null
|
||||
}
|
||||
|
||||
const backendVersion = statusSnapshot?.version
|
||||
const behind = backendUpdateStatus?.behind ?? 0
|
||||
const applying = backendUpdateApply.applying || backendUpdateApply.stage === 'restart'
|
||||
|
||||
const base = copy.backendLabel(backendVersion ?? copy.unknown)
|
||||
const behindHint = !applying && behind > 0 ? ` (+${behind})` : ''
|
||||
|
||||
const label = applying
|
||||
? `${base} · ${backendUpdateApply.stage === 'restart' ? copy.restart : copy.update}`
|
||||
: `${base}${behindHint}`
|
||||
|
||||
const tooltip = [
|
||||
applying ? backendUpdateApply.message || copy.updateInProgress : null,
|
||||
!applying && behind > 0 && copy.commitsBehind(behind, 'main'),
|
||||
backendVersion && copy.backendVersion(backendVersion)
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return {
|
||||
className: !applying && behind > 0 ? 'text-primary hover:text-primary' : undefined,
|
||||
hidden: !backendVersion,
|
||||
icon: applying ? <Loader2 className="size-3 animate-spin" /> : <Hash className="size-3" />,
|
||||
id: 'version-backend',
|
||||
label,
|
||||
onSelect: () => openUpdateOverlayFor('backend'),
|
||||
title: tooltip || undefined,
|
||||
variant: 'action'
|
||||
}
|
||||
}, [
|
||||
connection?.mode,
|
||||
statusSnapshot?.version,
|
||||
backendUpdateStatus?.behind,
|
||||
backendUpdateApply.applying,
|
||||
backendUpdateApply.message,
|
||||
backendUpdateApply.stage,
|
||||
copy
|
||||
])
|
||||
|
||||
const coreLeftStatusbarItems = useMemo<readonly StatusbarItem[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -385,7 +442,8 @@ export function useStatusbarItems({
|
||||
variant: 'action' as const
|
||||
})
|
||||
},
|
||||
versionItem
|
||||
clientVersionItem,
|
||||
...(backendVersionItem ? [backendVersionItem] : [])
|
||||
],
|
||||
[
|
||||
busy,
|
||||
@@ -401,7 +459,8 @@ export function useStatusbarItems({
|
||||
showYoloToggle,
|
||||
toggleYolo,
|
||||
turnStartedAt,
|
||||
versionItem,
|
||||
clientVersionItem,
|
||||
backendVersionItem,
|
||||
yoloActive
|
||||
]
|
||||
)
|
||||
|
||||
@@ -27,6 +27,20 @@ export interface ImageDetachResponse {
|
||||
count?: number
|
||||
}
|
||||
|
||||
export interface FileAttachResponse {
|
||||
attached?: boolean
|
||||
message?: string
|
||||
// Gateway-side absolute path the file was staged to.
|
||||
path?: string
|
||||
// Workspace-relative path used to build ref_text.
|
||||
ref_path?: string
|
||||
// Rewritten @file: ref that resolves on the gateway (workspace-relative).
|
||||
ref_text?: string
|
||||
// True when bytes/host file were copied into the session workspace.
|
||||
uploaded?: boolean
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface SlashExecResponse {
|
||||
output?: string
|
||||
warning?: string
|
||||
|
||||
@@ -12,12 +12,19 @@ import { useI18n } from '@/i18n'
|
||||
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
|
||||
import { AlertCircle, Check, CheckCircle2, Copy, Terminal } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy'
|
||||
import {
|
||||
$backendUpdateApply,
|
||||
$backendUpdateChecking,
|
||||
$backendUpdateStatus,
|
||||
$updateApply,
|
||||
$updateChecking,
|
||||
$updateOverlayOpen,
|
||||
$updateOverlayTarget,
|
||||
$updateStatus,
|
||||
applyBackendUpdate,
|
||||
applyUpdates,
|
||||
checkBackendUpdates,
|
||||
checkUpdates,
|
||||
resetUpdateApplyState,
|
||||
setUpdateOverlayOpen,
|
||||
@@ -30,15 +37,27 @@ function totalItems(groups: readonly CommitGroup[]) {
|
||||
|
||||
export function UpdatesOverlay() {
|
||||
const open = useStore($updateOverlayOpen)
|
||||
const status = useStore($updateStatus)
|
||||
const checking = useStore($updateChecking)
|
||||
const apply = useStore($updateApply)
|
||||
const target = useStore($updateOverlayTarget)
|
||||
|
||||
const clientStatus = useStore($updateStatus)
|
||||
const clientChecking = useStore($updateChecking)
|
||||
const clientApply = useStore($updateApply)
|
||||
const backendStatus = useStore($backendUpdateStatus)
|
||||
const backendChecking = useStore($backendUpdateChecking)
|
||||
const backendApply = useStore($backendUpdateApply)
|
||||
|
||||
const isBackend = target === 'backend'
|
||||
const status = isBackend ? backendStatus : clientStatus
|
||||
const checking = isBackend ? backendChecking : clientChecking
|
||||
const apply = isBackend ? backendApply : clientApply
|
||||
const check = isBackend ? checkBackendUpdates : checkUpdates
|
||||
const install = isBackend ? applyBackendUpdate : applyUpdates
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !status && !checking) {
|
||||
void checkUpdates()
|
||||
void check()
|
||||
}
|
||||
}, [checking, open, status])
|
||||
}, [check, checking, open, status])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
|
||||
@@ -64,7 +83,7 @@ export function UpdatesOverlay() {
|
||||
}
|
||||
|
||||
const handleInstall = () => {
|
||||
void applyUpdates()
|
||||
void install()
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -73,7 +92,7 @@ export function UpdatesOverlay() {
|
||||
className="max-w-sm overflow-hidden border-border/70 p-0 gap-0"
|
||||
showCloseButton={phase !== 'applying'}
|
||||
>
|
||||
{phase === 'applying' && <ApplyingView apply={apply} />}
|
||||
{phase === 'applying' && <ApplyingView apply={apply} isBackend={isBackend} />}
|
||||
|
||||
{phase === 'manual' && (
|
||||
<ManualView command={apply.command ?? 'hermes update'} onDone={() => handleClose(false)} />
|
||||
@@ -90,8 +109,9 @@ export function UpdatesOverlay() {
|
||||
commits={status?.commits ?? []}
|
||||
onInstall={handleInstall}
|
||||
onLater={() => handleClose(false)}
|
||||
onRetryCheck={() => void checkUpdates()}
|
||||
onRetryCheck={() => void check()}
|
||||
status={status}
|
||||
target={target}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -106,7 +126,8 @@ function IdleView({
|
||||
onInstall,
|
||||
onLater,
|
||||
onRetryCheck,
|
||||
status
|
||||
status,
|
||||
target
|
||||
}: {
|
||||
behind: number
|
||||
checking: boolean
|
||||
@@ -115,6 +136,7 @@ function IdleView({
|
||||
onLater: () => void
|
||||
onRetryCheck: () => void
|
||||
status: DesktopUpdateStatus | null
|
||||
target: UpdateTarget
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
@@ -167,7 +189,7 @@ function IdleView({
|
||||
if (behind === 0) {
|
||||
return (
|
||||
<CenteredStatus
|
||||
body={u.latestBody}
|
||||
body={target === 'backend' ? u.latestBodyBackend : u.latestBody}
|
||||
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
|
||||
title={u.allSetTitle}
|
||||
/>
|
||||
@@ -178,14 +200,20 @@ function IdleView({
|
||||
const shownItems = totalItems(groups)
|
||||
const remaining = Math.max(0, behind - shownItems)
|
||||
|
||||
// Name what's being updated. In remote mode the overlay acts on the connected
|
||||
// backend, not the local client — say so. When there are no commit rows to
|
||||
// show (e.g. pip/non-git backend), degrade to honest "no release notes" copy
|
||||
// instead of generic filler.
|
||||
const { title, body } = resolveUpdateCopy({ target, shownItems, copy: u })
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<BrandMark className="size-16" />
|
||||
|
||||
<DialogTitle className="text-center text-xl">{u.availableTitle}</DialogTitle>
|
||||
<DialogTitle className="text-center text-xl">{title}</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
{u.availableBody}
|
||||
{body}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
@@ -281,10 +309,11 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
|
||||
)
|
||||
}
|
||||
|
||||
function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
||||
function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend: boolean }) {
|
||||
const { t } = useI18n()
|
||||
const u = t.updates
|
||||
const label = u.stages[apply.stage as DesktopUpdateStage] ?? u.stages.idle
|
||||
const body = isBackend ? u.applyingBodyBackend : u.applyingBody
|
||||
|
||||
const percent =
|
||||
typeof apply.percent === 'number' && Number.isFinite(apply.percent)
|
||||
@@ -298,7 +327,7 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
|
||||
|
||||
<DialogTitle className="text-center text-xl">{label}</DialogTitle>
|
||||
<DialogDescription className="text-center text-sm">
|
||||
{u.applyingBody}
|
||||
{body}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ import type { HermesGateway } from '@/hermes'
|
||||
import { useResizeObserver } from '@/hooks/use-resize-observer'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { LinkifiedText } from '@/lib/external-link'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon } from '@/lib/icons'
|
||||
import { extractPreviewTargets } from '@/lib/preview-targets'
|
||||
@@ -919,7 +920,7 @@ const SystemMessage: FC = () => {
|
||||
>
|
||||
<span className="font-mono text-muted-foreground/55">{slashStatus.groups.command}</span>
|
||||
<span className="mx-1.5 text-muted-foreground/35">·</span>
|
||||
<span className="whitespace-pre-wrap">{slashStatus.groups.output.trim()}</span>
|
||||
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={slashStatus.groups.output.trim()} />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
@@ -930,7 +931,7 @@ const SystemMessage: FC = () => {
|
||||
data-role="system"
|
||||
data-slot="aui_system-message-root"
|
||||
>
|
||||
<span className="whitespace-pre-wrap">{text}</span>
|
||||
<LinkifiedText className="whitespace-pre-wrap" explicitOnly pretty={false} text={text} />
|
||||
</MessagePrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
BackendUpdateCheckResponse,
|
||||
ConfigSchemaResponse,
|
||||
CronJob,
|
||||
CronJobCreatePayload,
|
||||
@@ -53,6 +54,7 @@ export type {
|
||||
AnalyticsSkillEntry,
|
||||
AnalyticsSkillsSummary,
|
||||
AnalyticsTotals,
|
||||
BackendUpdateCheckResponse,
|
||||
AudioSpeakResponse,
|
||||
AudioTranscriptionResponse,
|
||||
AuxiliaryModelsResponse,
|
||||
@@ -686,6 +688,15 @@ export function updateHermes(): Promise<ActionResponse> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Query the connected backend's own update state. In remote mode this is the
|
||||
* authoritative source for the backend's behind-count + "what's changed",
|
||||
* distinct from the Electron client clone's git state. */
|
||||
export function checkHermesUpdate(force = false): Promise<BackendUpdateCheckResponse> {
|
||||
return window.hermesDesktop.api<BackendUpdateCheckResponse>({
|
||||
path: `/api/hermes/update/check${force ? '?force=true' : ''}`
|
||||
})
|
||||
}
|
||||
|
||||
export function getActionStatus(name: string, lines = 200): Promise<ActionStatusResponse> {
|
||||
return window.hermesDesktop.api<ActionStatusResponse>({
|
||||
path: `/api/actions/${encodeURIComponent(name)}/status?lines=${Math.max(1, lines)}`
|
||||
|
||||
@@ -292,7 +292,8 @@ export const en: Translations = {
|
||||
technical: 'Technical',
|
||||
technicalDesc: 'Include raw tool args/results and low-level details.',
|
||||
themeTitle: 'Theme',
|
||||
themeDesc: 'Desktop palettes only. The selected mode is applied on top.'
|
||||
themeDesc: 'Desktop palettes only. The selected mode is applied on top.',
|
||||
themeProfileNote: profile => `Saved for the ${profile} profile — each profile keeps its own theme.`
|
||||
},
|
||||
fieldLabels: FIELD_LABELS,
|
||||
fieldDescriptions: FIELD_DESCRIPTIONS,
|
||||
@@ -1237,9 +1238,13 @@ export const en: Translations = {
|
||||
unsupportedMessage: 'This version of Hermes can’t update itself from inside the app.',
|
||||
connectionRetry: 'Check your connection and try again.',
|
||||
latestBody: 'You’re running the latest version.',
|
||||
latestBodyBackend: 'The backend is running the latest version.',
|
||||
allSetTitle: 'You’re all set',
|
||||
availableTitle: 'New update available',
|
||||
availableBody: 'A new version of Hermes is ready to install.',
|
||||
availableTitleBackend: 'Backend update available',
|
||||
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
|
||||
availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.',
|
||||
updateNow: 'Update now',
|
||||
maybeLater: 'Maybe later',
|
||||
moreChanges: count => `+ ${count} more change${count === 1 ? '' : 's'} included.`,
|
||||
@@ -1250,10 +1255,19 @@ export const en: Translations = {
|
||||
copied: 'Copied',
|
||||
done: 'Done',
|
||||
applyingBody: 'The Hermes updater will take over in its own window and reopen Hermes when it’s done.',
|
||||
applyingBodyBackend: 'The remote backend is applying the update and will restart. Hermes reconnects automatically when it’s back.',
|
||||
applyingClose: 'Hermes will close to apply the update.',
|
||||
errorTitle: 'Update didn’t finish',
|
||||
errorBody: 'No worries — nothing was lost. You can try again now.',
|
||||
notNow: 'Not now'
|
||||
notNow: 'Not now',
|
||||
applyStatus: {
|
||||
preparing: 'Updating backend…',
|
||||
pulling: 'Backend updating…',
|
||||
restarting: 'Backend restarting to load the update…',
|
||||
notAvailable: 'Update not available for this backend.',
|
||||
failed: 'Backend update failed.',
|
||||
noReturn: 'Backend didn’t come back online. The update may not have completed — check the backend host.'
|
||||
}
|
||||
},
|
||||
|
||||
install: {
|
||||
@@ -1439,6 +1453,9 @@ export const en: Translations = {
|
||||
updateInProgress: 'Update in progress',
|
||||
commitsBehind: (count, branch) => `${count} commit${count === 1 ? '' : 's'} behind ${branch}`,
|
||||
desktopVersion: version => `Hermes Desktop v${version}`,
|
||||
backendVersion: version => `Backend v${version}`,
|
||||
clientLabel: version => `client v${version}`,
|
||||
backendLabel: version => `backend v${version}`,
|
||||
commit: sha => `commit ${sha}`,
|
||||
branch: branch => `branch ${branch}`,
|
||||
closeCommandCenter: 'Close Command Center',
|
||||
|
||||
@@ -215,7 +215,8 @@ export const ja = defineLocale({
|
||||
technical: 'テクニカル',
|
||||
technicalDesc: '生のツール引数、結果、低レベルの詳細を含めます。',
|
||||
themeTitle: 'テーマ',
|
||||
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。'
|
||||
themeDesc: 'デスクトップ専用のパレットです。選択したモードの上に適用されます。',
|
||||
themeProfileNote: profile => `「${profile}」プロファイルに保存されます。プロファイルごとに個別のテーマを保持します。`
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: 'デフォルトモデル',
|
||||
@@ -1378,9 +1379,13 @@ export const ja = defineLocale({
|
||||
unsupportedMessage: 'このバージョンの Hermes はアプリ内から自分を更新できません。',
|
||||
connectionRetry: '接続を確認してもう一度試してください。',
|
||||
latestBody: '最新バージョンを実行しています。',
|
||||
latestBodyBackend: 'バックエンドは最新バージョンを実行しています。',
|
||||
allSetTitle: '準備完了',
|
||||
availableTitle: '新しい更新が利用可能',
|
||||
availableBody: '新しいバージョンの Hermes をインストールする準備ができています。',
|
||||
availableTitleBackend: 'バックエンドの更新があります',
|
||||
availableBodyBackend: '接続中の Hermes バックエンドの新しいバージョンをインストールできます。',
|
||||
availableBodyNoChangelog: '新しいバージョンを利用できます。このインストール形式ではリリースノートは表示できません。',
|
||||
updateNow: '今すぐ更新',
|
||||
maybeLater: '後で',
|
||||
moreChanges: count => `さらに ${count} 件の変更が含まれています。`,
|
||||
@@ -1392,10 +1397,19 @@ export const ja = defineLocale({
|
||||
copied: 'コピーしました',
|
||||
done: '完了',
|
||||
applyingBody: 'Hermes アップデーターが独自のウィンドウで引き継ぎ、完了後に Hermes を再度開きます。',
|
||||
applyingBodyBackend: 'リモートバックエンドが更新を適用して再起動します。復帰すると Hermes が自動的に再接続します。',
|
||||
applyingClose: 'Hermes は更新を適用するために閉じます。',
|
||||
errorTitle: '更新が完了しませんでした',
|
||||
errorBody: 'ご安心ください。何も失われていません。今すぐ再試行できます。',
|
||||
notNow: '今は後で'
|
||||
notNow: '今は後で',
|
||||
applyStatus: {
|
||||
preparing: 'バックエンドを更新しています…',
|
||||
pulling: 'バックエンドを更新中…',
|
||||
restarting: 'バックエンドが更新を読み込むため再起動しています…',
|
||||
notAvailable: 'このバックエンドでは更新を利用できません。',
|
||||
failed: 'バックエンドの更新に失敗しました。',
|
||||
noReturn: 'バックエンドがオンラインに戻りませんでした。更新が完了していない可能性があります。バックエンドホストを確認してください。'
|
||||
}
|
||||
},
|
||||
|
||||
install: {
|
||||
@@ -1582,6 +1596,9 @@ export const ja = defineLocale({
|
||||
updateInProgress: '更新中',
|
||||
commitsBehind: (count, branch) => `${branch} より ${count} コミット遅れています`,
|
||||
desktopVersion: version => `Hermes Desktop v${version}`,
|
||||
backendVersion: version => `バックエンド v${version}`,
|
||||
clientLabel: version => `クライアント v${version}`,
|
||||
backendLabel: version => `バックエンド v${version}`,
|
||||
commit: sha => `コミット ${sha}`,
|
||||
branch: branch => `ブランチ ${branch}`,
|
||||
closeCommandCenter: 'コマンドセンターを閉じる',
|
||||
|
||||
@@ -219,6 +219,7 @@ export interface Translations {
|
||||
technicalDesc: string
|
||||
themeTitle: string
|
||||
themeDesc: string
|
||||
themeProfileNote: (profile: string) => string
|
||||
}
|
||||
fieldLabels: Record<string, string>
|
||||
fieldDescriptions: Record<string, string>
|
||||
@@ -937,9 +938,13 @@ export interface Translations {
|
||||
unsupportedMessage: string
|
||||
connectionRetry: string
|
||||
latestBody: string
|
||||
latestBodyBackend: string
|
||||
allSetTitle: string
|
||||
availableTitle: string
|
||||
availableBody: string
|
||||
availableTitleBackend: string
|
||||
availableBodyBackend: string
|
||||
availableBodyNoChangelog: string
|
||||
updateNow: string
|
||||
maybeLater: string
|
||||
moreChanges: (count: number) => string
|
||||
@@ -950,10 +955,19 @@ export interface Translations {
|
||||
copied: string
|
||||
done: string
|
||||
applyingBody: string
|
||||
applyingBodyBackend: string
|
||||
applyingClose: string
|
||||
errorTitle: string
|
||||
errorBody: string
|
||||
notNow: string
|
||||
applyStatus: {
|
||||
preparing: string
|
||||
pulling: string
|
||||
restarting: string
|
||||
notAvailable: string
|
||||
failed: string
|
||||
noReturn: string
|
||||
}
|
||||
}
|
||||
|
||||
install: {
|
||||
@@ -1111,6 +1125,9 @@ export interface Translations {
|
||||
updateInProgress: string
|
||||
commitsBehind: (count: number, branch: string) => string
|
||||
desktopVersion: (version: string) => string
|
||||
backendVersion: (version: string) => string
|
||||
clientLabel: (version: string) => string
|
||||
backendLabel: (version: string) => string
|
||||
commit: (sha: string) => string
|
||||
branch: (branch: string) => string
|
||||
closeCommandCenter: string
|
||||
|
||||
@@ -209,7 +209,8 @@ export const zhHant = defineLocale({
|
||||
technical: '技術',
|
||||
technicalDesc: '包含原始工具參數、結果與底層細節。',
|
||||
themeTitle: '主題',
|
||||
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。'
|
||||
themeDesc: '僅限桌面端的調色盤。所選模式會套用在其上。',
|
||||
themeProfileNote: profile => `已為「${profile}」設定檔儲存——每個設定檔保留各自的主題。`
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: '預設模型',
|
||||
@@ -1344,9 +1345,13 @@ export const zhHant = defineLocale({
|
||||
unsupportedMessage: '此版本的 Hermes 無法在應用程式內自行更新。',
|
||||
connectionRetry: '請檢查網路連線後重試。',
|
||||
latestBody: '您正在執行最新版本。',
|
||||
latestBodyBackend: '後端正在執行最新版本。',
|
||||
allSetTitle: '已是最新版本',
|
||||
availableTitle: '有可用更新',
|
||||
availableBody: '新版 Hermes 已可安裝。',
|
||||
availableTitleBackend: '後端有可用更新',
|
||||
availableBodyBackend: '已連接的 Hermes 後端有新版本可安裝。',
|
||||
availableBodyNoChangelog: '已有新版本可用。此安裝方式無法顯示更新日誌。',
|
||||
updateNow: '立即更新',
|
||||
maybeLater: '稍後再說',
|
||||
moreChanges: count => `另有 ${count} 項變更。`,
|
||||
@@ -1357,10 +1362,19 @@ export const zhHant = defineLocale({
|
||||
copied: '已複製',
|
||||
done: '完成',
|
||||
applyingBody: 'Hermes 更新程式會在自己的視窗中接管,並在完成後重新開啟 Hermes。',
|
||||
applyingBodyBackend: '遠端後端正在套用更新並將重新啟動。恢復後 Hermes 會自動重新連線。',
|
||||
applyingClose: 'Hermes 將關閉以套用更新。',
|
||||
errorTitle: '更新未完成',
|
||||
errorBody: '沒有資料遺失。您可以現在重試。',
|
||||
notNow: '暫不'
|
||||
notNow: '暫不',
|
||||
applyStatus: {
|
||||
preparing: '正在更新後端…',
|
||||
pulling: '後端更新中…',
|
||||
restarting: '後端正在重新啟動以載入更新…',
|
||||
notAvailable: '此後端無法更新。',
|
||||
failed: '後端更新失敗。',
|
||||
noReturn: '後端未恢復連線。更新可能未完成——請檢查後端主機。'
|
||||
}
|
||||
},
|
||||
|
||||
install: {
|
||||
@@ -1543,6 +1557,9 @@ export const zhHant = defineLocale({
|
||||
updateInProgress: '更新中',
|
||||
commitsBehind: (count, branch) => `落後 ${branch} ${count} 個提交`,
|
||||
desktopVersion: version => `Hermes Desktop v${version}`,
|
||||
backendVersion: version => `後端 v${version}`,
|
||||
clientLabel: version => `用戶端 v${version}`,
|
||||
backendLabel: version => `後端 v${version}`,
|
||||
commit: sha => `提交 ${sha}`,
|
||||
branch: branch => `分支 ${branch}`,
|
||||
closeCommandCenter: '關閉命令中心',
|
||||
|
||||
@@ -287,7 +287,8 @@ export const zh: Translations = {
|
||||
technical: '技术',
|
||||
technicalDesc: '包含原始工具参数/结果及底层细节。',
|
||||
themeTitle: '主题',
|
||||
themeDesc: '仅桌面端调色板。所选模式叠加其上。'
|
||||
themeDesc: '仅桌面端调色板。所选模式叠加其上。',
|
||||
themeProfileNote: profile => `已为「${profile}」配置文件保存——每个配置文件保留各自的主题。`
|
||||
},
|
||||
fieldLabels: defineFieldCopy({
|
||||
model: '默认模型',
|
||||
@@ -1424,9 +1425,13 @@ export const zh: Translations = {
|
||||
unsupportedMessage: '此版本的 Hermes 无法在应用内自行更新。',
|
||||
connectionRetry: '请检查网络连接后重试。',
|
||||
latestBody: '你正在运行最新版本。',
|
||||
latestBodyBackend: '后端正在运行最新版本。',
|
||||
allSetTitle: '已是最新',
|
||||
availableTitle: '有可用更新',
|
||||
availableBody: '新版 Hermes 已可安装。',
|
||||
availableTitleBackend: '后端有可用更新',
|
||||
availableBodyBackend: '已连接的 Hermes 后端有新版本可安装。',
|
||||
availableBodyNoChangelog: '已有新版本可用。此安装方式无法显示更新日志。',
|
||||
updateNow: '立即更新',
|
||||
maybeLater: '稍后再说',
|
||||
moreChanges: count => `另有 ${count} 项更改。`,
|
||||
@@ -1437,10 +1442,19 @@ export const zh: Translations = {
|
||||
copied: '已复制',
|
||||
done: '完成',
|
||||
applyingBody: 'Hermes 更新器会在自己的窗口中接管,并在完成后重新打开 Hermes。',
|
||||
applyingBodyBackend: '远程后端正在应用更新并将重启。恢复后 Hermes 会自动重新连接。',
|
||||
applyingClose: 'Hermes 将关闭以应用更新。',
|
||||
errorTitle: '更新未完成',
|
||||
errorBody: '没有数据丢失。你可以现在重试。',
|
||||
notNow: '暂不'
|
||||
notNow: '暂不',
|
||||
applyStatus: {
|
||||
preparing: '正在更新后端…',
|
||||
pulling: '后端更新中…',
|
||||
restarting: '后端正在重启以加载更新…',
|
||||
notAvailable: '此后端无法更新。',
|
||||
failed: '后端更新失败。',
|
||||
noReturn: '后端未恢复在线。更新可能未完成——请检查后端主机。'
|
||||
}
|
||||
},
|
||||
|
||||
install: {
|
||||
@@ -1620,6 +1634,9 @@ export const zh: Translations = {
|
||||
updateInProgress: '正在更新',
|
||||
commitsBehind: (count, branch) => `落后 ${branch} ${count} 个提交`,
|
||||
desktopVersion: version => `Hermes Desktop v${version}`,
|
||||
backendVersion: version => `后端 v${version}`,
|
||||
clientLabel: version => `客户端 v${version}`,
|
||||
backendLabel: version => `后端 v${version}`,
|
||||
commit: sha => `提交 ${sha}`,
|
||||
branch: branch => `分支 ${branch}`,
|
||||
closeCommandCenter: '关闭命令中心',
|
||||
|
||||
@@ -165,4 +165,31 @@ describe('external link helpers', () => {
|
||||
'https://expedia.com/things-to-do/puerto-rico-el-yunque-rainforest-adventure'
|
||||
)
|
||||
})
|
||||
|
||||
it('explicitOnly skips bare filename/domain tokens and only links explicit URLs', () => {
|
||||
installDesktopBridge()
|
||||
|
||||
render(
|
||||
<LinkifiedText
|
||||
explicitOnly
|
||||
pretty={false}
|
||||
text={'Report https://paste.rs/abc\nagent.log https://paste.rs/def\nerrors.log'}
|
||||
/>
|
||||
)
|
||||
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links.map(a => a.getAttribute('href'))).toEqual(['https://paste.rs/abc', 'https://paste.rs/def'])
|
||||
// Bare filename-shaped tokens stay as plain text, not links.
|
||||
expect(screen.queryByText(content => content.includes('agent.log'))).toBeTruthy()
|
||||
expect(links.some(a => (a.textContent ?? '').includes('.log'))).toBe(false)
|
||||
})
|
||||
|
||||
it('without explicitOnly, bare filename tokens are still linkified (default behavior)', () => {
|
||||
installDesktopBridge()
|
||||
|
||||
render(<LinkifiedText pretty={false} text="open agent.log please" />)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'agent.log' })
|
||||
expect(link.getAttribute('href')).toBe('https://agent.log')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,12 @@ const titleSubs = new Map<string, Set<(value: string) => void>>()
|
||||
const URL_RE =
|
||||
/(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]|[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?:\/[^\s<>"'`.,;:!?)]*)?/gi
|
||||
|
||||
// Explicit-scheme / www. URLs only — no bare-domain matching. Used where the
|
||||
// surrounding text is full of filename-shaped tokens (e.g. `agent.log`,
|
||||
// `errors.log` in a /debug report) that the bare-domain branch of URL_RE would
|
||||
// otherwise mistake for domains and linkify.
|
||||
const EXPLICIT_URL_RE = /(?:https?:\/\/|www\.)[^\s<>"'`]+[^\s<>"'`.,;:!?)]/gi
|
||||
|
||||
const DOMAIN_RE = /^(?:www\.)?[a-z0-9](?:[a-z0-9-]*\.)+[a-z]{2,}(?::\d+)?(?:[/?#][^\s]*)?$/i
|
||||
const SKIP_PROTO_RE = /^(?:file|data|mailto|javascript|blob|chrome|about|hermes):/i
|
||||
const LOCAL_HOST_RE = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?$/i
|
||||
@@ -261,13 +267,14 @@ interface LinkifiedTextProps {
|
||||
className?: string
|
||||
text: string
|
||||
pretty?: boolean
|
||||
explicitOnly?: boolean
|
||||
}
|
||||
|
||||
export function LinkifiedText({ className, pretty = true, text }: LinkifiedTextProps) {
|
||||
export function LinkifiedText({ className, explicitOnly = false, pretty = true, text }: LinkifiedTextProps) {
|
||||
const nodes: ReactNode[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const match of text.matchAll(URL_RE)) {
|
||||
for (const match of text.matchAll(explicitOnly ? EXPLICIT_URL_RE : URL_RE)) {
|
||||
const raw = match[0]
|
||||
const url = normalizeExternalUrl(raw)
|
||||
const index = match.index ?? 0
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { gatewayEventRequiresSessionId } from './gateway-events'
|
||||
|
||||
describe('gateway event routing', () => {
|
||||
it('drops only unscoped subagent events (genuinely background work)', () => {
|
||||
expect(gatewayEventRequiresSessionId('subagent.progress')).toBe(true)
|
||||
expect(gatewayEventRequiresSessionId('subagent.start')).toBe(true)
|
||||
})
|
||||
|
||||
it('attributes unscoped foreground turn events to the active chat', () => {
|
||||
// These must NOT be dropped when unscoped — they are the focused turn's own
|
||||
// output, and dropping them loses the live response until a refetch (#42178).
|
||||
expect(gatewayEventRequiresSessionId('message.delta')).toBe(false)
|
||||
expect(gatewayEventRequiresSessionId('message.complete')).toBe(false)
|
||||
expect(gatewayEventRequiresSessionId('reasoning.delta')).toBe(false)
|
||||
expect(gatewayEventRequiresSessionId('tool.start')).toBe(false)
|
||||
expect(gatewayEventRequiresSessionId('approval.request')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows global events to remain unscoped', () => {
|
||||
expect(gatewayEventRequiresSessionId('gateway.ready')).toBe(false)
|
||||
expect(gatewayEventRequiresSessionId('preview.restart.progress')).toBe(false)
|
||||
expect(gatewayEventRequiresSessionId('session.info')).toBe(false)
|
||||
expect(gatewayEventRequiresSessionId(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,22 @@ function asRecord(payload: unknown): Record<string, unknown> {
|
||||
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an unscoped event (no `session_id`) must be dropped rather than
|
||||
* attributed to the focused chat.
|
||||
*
|
||||
* Only `subagent.*` qualifies: it describes background/async work that must
|
||||
* never attach to whichever chat happens to be focused. Every other scoped
|
||||
* event — message/reasoning/thinking/tool/status/prompt — is, when unscoped,
|
||||
* the active turn's own output. The gateway always stamps a *background*
|
||||
* session's events with that session's id, so a missing id can only mean "the
|
||||
* focused turn". #42178 dropped those too, which silently swallowed the live
|
||||
* answer; it then reappeared only after a transcript refetch (manual refresh).
|
||||
*/
|
||||
export function gatewayEventRequiresSessionId(eventType: string | undefined): boolean {
|
||||
return eventType?.startsWith('subagent.') ?? false
|
||||
}
|
||||
|
||||
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
|
||||
if (event.type !== 'tool.complete') {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveUpdateCopy } from './update-copy'
|
||||
|
||||
const copy = {
|
||||
availableTitle: 'New update available',
|
||||
availableBody: 'A new version of Hermes is ready to install.',
|
||||
availableTitleBackend: 'Backend update available',
|
||||
availableBodyBackend: 'A newer version of the connected Hermes backend is ready to install.',
|
||||
availableBodyNoChangelog: 'A newer version is ready. Release notes aren’t available for this install type.'
|
||||
}
|
||||
|
||||
describe('resolveUpdateCopy', () => {
|
||||
it('client target with commits: client title + client body', () => {
|
||||
const r = resolveUpdateCopy({ target: 'client', shownItems: 5, copy })
|
||||
expect(r.title).toBe('New update available')
|
||||
expect(r.body).toBe('A new version of Hermes is ready to install.')
|
||||
})
|
||||
|
||||
it('backend target with commits: names the backend in title and body', () => {
|
||||
const r = resolveUpdateCopy({ target: 'backend', shownItems: 5, copy })
|
||||
expect(r.title).toBe('Backend update available')
|
||||
expect(r.body).toContain('backend')
|
||||
})
|
||||
|
||||
it('no changelog (pip/non-git backend): degrades honestly, still names backend target in title', () => {
|
||||
const r = resolveUpdateCopy({ target: 'backend', shownItems: 0, copy })
|
||||
expect(r.title).toBe('Backend update available')
|
||||
// Body must NOT pretend there are notes — it states they're unavailable.
|
||||
expect(r.body).toBe(copy.availableBodyNoChangelog)
|
||||
})
|
||||
|
||||
it('no changelog on client: same honest degrade', () => {
|
||||
const r = resolveUpdateCopy({ target: 'client', shownItems: 0, copy })
|
||||
expect(r.title).toBe('New update available')
|
||||
expect(r.body).toBe(copy.availableBodyNoChangelog)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Pure copy-selection for the updates overlay's "available" state.
|
||||
*
|
||||
* Names the update target (client vs the connected backend in remote mode) and
|
||||
* degrades honestly when there's no commit changelog to show (e.g. a pip /
|
||||
* non-git backend where `git log` yields nothing) instead of generic filler.
|
||||
*
|
||||
* Extracted from updates-overlay.tsx so the wording logic is unit-testable.
|
||||
*/
|
||||
|
||||
export type UpdateTarget = 'client' | 'backend'
|
||||
|
||||
export interface UpdateCopyStrings {
|
||||
availableTitle: string
|
||||
availableBody: string
|
||||
availableTitleBackend: string
|
||||
availableBodyBackend: string
|
||||
availableBodyNoChangelog: string
|
||||
}
|
||||
|
||||
export interface ResolveUpdateCopyInput {
|
||||
target: UpdateTarget
|
||||
/** Number of commit rows actually shown in the changelog. 0 → no notes. */
|
||||
shownItems: number
|
||||
copy: UpdateCopyStrings
|
||||
}
|
||||
|
||||
export interface UpdateCopyResult {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export function resolveUpdateCopy({ target, shownItems, copy }: ResolveUpdateCopyInput): UpdateCopyResult {
|
||||
const title = target === 'backend' ? copy.availableTitleBackend : copy.availableTitle
|
||||
|
||||
const body =
|
||||
shownItems === 0
|
||||
? copy.availableBodyNoChangelog
|
||||
: target === 'backend'
|
||||
? copy.availableBodyBackend
|
||||
: copy.availableBody
|
||||
|
||||
return { title, body }
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { $attentionSessionIds, mergeSessionPage, sessionPinId, setSessionAttention } from './session'
|
||||
import {
|
||||
$attentionSessionIds,
|
||||
$workingSessionIds,
|
||||
getRecentlySettledSessionIds,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setSessionAttention,
|
||||
setSessionWorking
|
||||
} from './session'
|
||||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
archived: false,
|
||||
@@ -129,3 +137,61 @@ describe('mergeSessionPage', () => {
|
||||
expect(merged.map(s => s.id)).toEqual(['tip', 'other'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRecentlySettledSessionIds', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
$workingSessionIds.set([])
|
||||
|
||||
// Drain anything left in the grace map so tests stay isolated.
|
||||
for (const id of getRecentlySettledSessionIds(Number.MAX_SAFE_INTEGER)) {
|
||||
void id
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a session for the grace window after its turn settles, then drops it', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
$workingSessionIds.set([])
|
||||
|
||||
// A turn starts then ends: the working→idle transition grants grace.
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
|
||||
|
||||
// Still inside the window.
|
||||
vi.setSystemTime(29_000)
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
|
||||
|
||||
// Past the window: the entry is pruned on read.
|
||||
vi.setSystemTime(31_000)
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('does not grant grace when the session was never working (idle re-asserts)', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
$workingSessionIds.set([])
|
||||
|
||||
// updateSessionState re-asserts `false` for idle sessions on every tick;
|
||||
// these must not pin an idle chat into the keep-set indefinitely.
|
||||
setSessionWorking('idle', false)
|
||||
setSessionWorking('idle', false)
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears the grace timer when the session goes busy again', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
$workingSessionIds.set([])
|
||||
|
||||
setSessionWorking('s2', true)
|
||||
setSessionWorking('s2', false)
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
|
||||
|
||||
// A new turn for the same session is "working" again — drop it from the
|
||||
// settled set so it's tracked as working, not recently-finished.
|
||||
setSessionWorking('s2', true)
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,6 +202,47 @@ function clearSessionWatchdog(sessionId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// A session's "working" flag clears the instant its turn ends, but the
|
||||
// cross-profile aggregator (listSessions with min_messages=1) only sees the
|
||||
// just-persisted first turn a beat later. The active chat is shielded from that
|
||||
// race by sessionsToKeep(), but a brand-new session that finished *while you
|
||||
// were viewing a different chat* is, at the next refresh, neither working,
|
||||
// pinned, nor active — so mergeSessionPage() evicts it. Nothing re-fetches
|
||||
// afterward, so it stays gone until the app restarts. (Repro: start a new chat,
|
||||
// then click another session before the first reply lands.)
|
||||
//
|
||||
// To bridge that window we keep a session in the merge keep-set for a short
|
||||
// grace period after its turn settles, giving the aggregator time to catch up.
|
||||
// Entries auto-expire, so this never accumulates and can't resurrect a deleted
|
||||
// session (mergeSessionPage only revives rows still present in the in-memory
|
||||
// list, which optimistic delete/archive already drops).
|
||||
const SESSION_SETTLE_GRACE_MS = 30 * 1000
|
||||
const settledSessionExpiry = new Map<string, number>()
|
||||
|
||||
function markSessionSettled(sessionId: string) {
|
||||
settledSessionExpiry.set(sessionId, Date.now() + SESSION_SETTLE_GRACE_MS)
|
||||
}
|
||||
|
||||
function clearSessionSettled(sessionId: string) {
|
||||
settledSessionExpiry.delete(sessionId)
|
||||
}
|
||||
|
||||
/** Stored ids of sessions whose turn ended within the grace window. Prunes
|
||||
* expired entries as it reads, so it stays bounded without a timer. */
|
||||
export function getRecentlySettledSessionIds(now: number = Date.now()): string[] {
|
||||
const live: string[] = []
|
||||
|
||||
for (const [id, expiry] of settledSessionExpiry) {
|
||||
if (expiry > now) {
|
||||
live.push(id)
|
||||
} else {
|
||||
settledSessionExpiry.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
return live
|
||||
}
|
||||
|
||||
/** Call when a streaming event for a session lands. Refreshes the watchdog
|
||||
* so the session keeps its "working" status as long as data keeps coming. */
|
||||
export function noteSessionActivity(sessionId: string | null | undefined) {
|
||||
@@ -243,13 +284,24 @@ export function setSessionWorking(sessionId: string | null | undefined, working:
|
||||
return
|
||||
}
|
||||
|
||||
const wasWorking = $workingSessionIds.get().includes(sessionId)
|
||||
|
||||
toggleMembership(setWorkingSessionIds, sessionId, working)
|
||||
|
||||
// Bookend the watchdog: arm on enter, disarm on leave. A later
|
||||
// noteSessionActivity() from a streaming event refreshes the timer.
|
||||
if (working) {
|
||||
clearSessionSettled(sessionId)
|
||||
armSessionWatchdog(sessionId)
|
||||
} else {
|
||||
clearSessionWatchdog(sessionId)
|
||||
|
||||
// Only grant grace on a real working→idle transition (updateSessionState
|
||||
// re-asserts `false` on every state tick, which must not keep extending the
|
||||
// window). This keeps the just-finished session visible long enough for the
|
||||
// aggregator to return its now-persisted row.
|
||||
if (wasWorking) {
|
||||
markSessionSettled(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { DesktopUpdateStatus } from '@/global'
|
||||
|
||||
@@ -23,7 +23,18 @@ vi.mock('@/store/notifications', () => ({
|
||||
dismissNotification: (...args: unknown[]) => dismissSpy(...args)
|
||||
}))
|
||||
|
||||
const { maybeNotifyUpdateAvailable } = await import('./updates')
|
||||
const checkHermesUpdateSpy = vi.fn()
|
||||
const updateHermesSpy = vi.fn()
|
||||
const getActionStatusSpy = vi.fn()
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
checkHermesUpdate: (...args: unknown[]) => checkHermesUpdateSpy(...args),
|
||||
updateHermes: (...args: unknown[]) => updateHermesSpy(...args),
|
||||
getActionStatus: (...args: unknown[]) => getActionStatusSpy(...args)
|
||||
}))
|
||||
|
||||
const { maybeNotifyUpdateAvailable, checkBackendUpdates, $backendUpdateStatus, applyBackendUpdate, $backendUpdateApply } = await import('./updates')
|
||||
const { setConnection } = await import('./session')
|
||||
|
||||
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
|
||||
supported: true,
|
||||
@@ -75,3 +86,114 @@ describe('maybeNotifyUpdateAvailable', () => {
|
||||
expect(notifySpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkBackendUpdates', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
notifySpy.mockClear()
|
||||
checkHermesUpdateSpy.mockReset()
|
||||
$backendUpdateStatus.set(null)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const setRemote = (on: boolean) =>
|
||||
setConnection({
|
||||
baseUrl: 'http://box:9119',
|
||||
isFullscreen: false,
|
||||
mode: on ? 'remote' : 'local',
|
||||
nativeOverlayWidth: 0,
|
||||
token: 't',
|
||||
wsUrl: 'ws://box:9119',
|
||||
logs: [],
|
||||
windowButtonPosition: null
|
||||
})
|
||||
|
||||
it('maps the backend /update/check onto the backend status, including commits', async () => {
|
||||
setRemote(true)
|
||||
checkHermesUpdateSpy.mockResolvedValue({
|
||||
install_method: 'git',
|
||||
current_version: '0.16.0',
|
||||
behind: 2,
|
||||
update_available: true,
|
||||
can_apply: true,
|
||||
update_command: 'hermes update',
|
||||
message: null,
|
||||
commits: [{ sha: 'abc1234', summary: 'feat: x', author: 'a', at: 1 }]
|
||||
})
|
||||
|
||||
const result = await checkBackendUpdates()
|
||||
|
||||
expect(checkHermesUpdateSpy).toHaveBeenCalled()
|
||||
expect(result?.behind).toBe(2)
|
||||
expect(result?.commits?.[0]?.sha).toBe('abc1234')
|
||||
expect(result?.supported).toBe(true)
|
||||
expect($backendUpdateStatus.get()?.commits?.[0]?.summary).toBe('feat: x')
|
||||
})
|
||||
|
||||
it('honours can_apply=false (docker/nix): not supported, carries message', async () => {
|
||||
setRemote(true)
|
||||
checkHermesUpdateSpy.mockResolvedValue({
|
||||
install_method: 'docker',
|
||||
current_version: '0.16.0',
|
||||
behind: null,
|
||||
update_available: false,
|
||||
can_apply: false,
|
||||
update_command: 'docker pull ...',
|
||||
message: 'Docker images are immutable.'
|
||||
})
|
||||
|
||||
const result = await checkBackendUpdates()
|
||||
|
||||
expect(result?.supported).toBe(false)
|
||||
expect(result?.message).toBe('Docker images are immutable.')
|
||||
})
|
||||
|
||||
it('is a no-op in local mode (backend check only runs when remote)', async () => {
|
||||
setRemote(false)
|
||||
await checkBackendUpdates()
|
||||
expect(checkHermesUpdateSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyBackendUpdate recovery', () => {
|
||||
beforeEach(() => {
|
||||
storage.clear()
|
||||
checkHermesUpdateSpy.mockReset()
|
||||
updateHermesSpy.mockReset()
|
||||
getActionStatusSpy.mockReset()
|
||||
$backendUpdateApply.set({ applying: false, stage: 'idle', message: '', percent: null, error: null, command: null, log: [] })
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('waits for the backend to return after the restart drops the connection, then clears the overlay', async () => {
|
||||
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
|
||||
getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
checkHermesUpdateSpy.mockResolvedValue({ install_method: 'git', current_version: '0.16.0', behind: 0, update_available: false, can_apply: true, update_command: 'hermes update', message: null })
|
||||
|
||||
const promise = applyBackendUpdate()
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
const result = await promise
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect($backendUpdateApply.get().stage).toBe('idle')
|
||||
expect($backendUpdateApply.get().applying).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces an error when the backend never comes back after the restart', async () => {
|
||||
updateHermesSpy.mockResolvedValue({ ok: true, name: 'update', pid: 1 })
|
||||
getActionStatusSpy.mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
checkHermesUpdateSpy.mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
|
||||
const promise = applyBackendUpdate()
|
||||
await vi.advanceTimersByTimeAsync(70000)
|
||||
const result = await promise
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect($backendUpdateApply.get().stage).toBe('error')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -13,9 +13,12 @@ import type {
|
||||
DesktopUpdateStatus,
|
||||
DesktopVersionInfo
|
||||
} from '@/global'
|
||||
import { checkHermesUpdate, getActionStatus, updateHermes } from '@/hermes'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { dismissNotification, notify } from '@/store/notifications'
|
||||
import { $connection } from '@/store/session'
|
||||
import type { BackendUpdateCheckResponse } from '@/types/hermes'
|
||||
|
||||
export interface UpdateApplyState {
|
||||
applying: boolean
|
||||
@@ -45,8 +48,24 @@ export const $updateChecking = atom<boolean>(false)
|
||||
export const $updateOverlayOpen = atom<boolean>(false)
|
||||
export const $updateStatus = atom<DesktopUpdateStatus | null>(null)
|
||||
|
||||
// Client and backend are independently updatable; each keeps its own state.
|
||||
export const $backendUpdateStatus = atom<DesktopUpdateStatus | null>(null)
|
||||
export const $backendUpdateApply = atom<UpdateApplyState>(IDLE)
|
||||
export const $backendUpdateChecking = atom<boolean>(false)
|
||||
|
||||
export type UpdateTarget = 'client' | 'backend'
|
||||
export const $updateOverlayTarget = atom<UpdateTarget>('client')
|
||||
|
||||
export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(open)
|
||||
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
|
||||
export const openUpdateOverlayFor = (target: UpdateTarget) => {
|
||||
$updateOverlayTarget.set(target)
|
||||
$updateOverlayOpen.set(true)
|
||||
void (target === 'backend' ? checkBackendUpdates() : checkUpdates())
|
||||
}
|
||||
export const resetUpdateApplyState = () => {
|
||||
$updateApply.set(IDLE)
|
||||
$backendUpdateApply.set(IDLE)
|
||||
}
|
||||
|
||||
const UPDATE_TOAST_ID = 'desktop-update-available'
|
||||
// Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits
|
||||
@@ -69,7 +88,8 @@ function isUpdateToastSnoozed(): boolean {
|
||||
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
|
||||
// against. The backend reports its own value in session runtime info; a lower
|
||||
// value (or none — a pre-GUI checkout) means GUI<->backend skew.
|
||||
const REQUIRED_BACKEND_CONTRACT = 1
|
||||
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
|
||||
const REQUIRED_BACKEND_CONTRACT = 2
|
||||
const SKEW_TOAST_ID = 'backend-contract-skew'
|
||||
|
||||
/**
|
||||
@@ -86,7 +106,7 @@ export function reportBackendContract(contract: number | undefined): void {
|
||||
}
|
||||
|
||||
notify({
|
||||
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyUpdates() },
|
||||
action: { label: translateNow('notifications.updateHermes'), onClick: () => void applyBackendUpdate() },
|
||||
durationMs: 0,
|
||||
id: SKEW_TOAST_ID,
|
||||
kind: 'warning',
|
||||
@@ -137,13 +157,8 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the updates dialog and kicks off a fresh check so the user always
|
||||
* sees current state, even if a stale status is cached from earlier.
|
||||
*/
|
||||
export function openUpdatesWindow(): void {
|
||||
$updateOverlayOpen.set(true)
|
||||
void checkUpdates()
|
||||
openUpdateOverlayFor(isRemoteMode() ? 'backend' : 'client')
|
||||
}
|
||||
|
||||
/** Re-read the running app's version from the Electron main process and
|
||||
@@ -174,6 +189,52 @@ export async function refreshDesktopVersion(): Promise<DesktopVersionInfo | null
|
||||
}
|
||||
}
|
||||
|
||||
function isRemoteMode(): boolean {
|
||||
return $connection.get()?.mode === 'remote'
|
||||
}
|
||||
|
||||
function mapBackendCheck(res: BackendUpdateCheckResponse): DesktopUpdateStatus {
|
||||
const behind = res.behind ?? 0
|
||||
|
||||
return {
|
||||
supported: res.can_apply,
|
||||
message: res.message ?? undefined,
|
||||
behind: behind > 0 ? behind : 0,
|
||||
targetSha: res.update_available ? `backend:${res.current_version}` : undefined,
|
||||
commits: res.commits,
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkBackendUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
if (!isRemoteMode() || $backendUpdateChecking.get()) {
|
||||
return $backendUpdateStatus.get()
|
||||
}
|
||||
|
||||
$backendUpdateChecking.set(true)
|
||||
|
||||
try {
|
||||
const status = mapBackendCheck(await checkHermesUpdate(true))
|
||||
$backendUpdateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status)
|
||||
|
||||
return status
|
||||
} catch (error) {
|
||||
const fallback: DesktopUpdateStatus = {
|
||||
supported: $backendUpdateStatus.get()?.supported ?? true,
|
||||
error: 'check-failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
fetchedAt: Date.now()
|
||||
}
|
||||
|
||||
$backendUpdateStatus.set(fallback)
|
||||
|
||||
return fallback
|
||||
} finally {
|
||||
$backendUpdateChecking.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
const bridge = window.hermesDesktop?.updates
|
||||
|
||||
@@ -187,9 +248,6 @@ export async function checkUpdates(): Promise<DesktopUpdateStatus | null> {
|
||||
const status = await bridge.check()
|
||||
$updateStatus.set(status)
|
||||
maybeNotifyUpdateAvailable(status)
|
||||
// The update check pulls the latest hermes_cli + bundled package metadata
|
||||
// into place. Re-read the running version so About reflects the now-fresh
|
||||
// checkout rather than the one captured at process start.
|
||||
void refreshDesktopVersion()
|
||||
|
||||
return status
|
||||
@@ -247,6 +305,107 @@ export async function applyUpdates(opts: DesktopUpdateApplyOptions = {}): Promis
|
||||
}
|
||||
}
|
||||
|
||||
const BACKEND_RETURN_POLL_MS = 1500
|
||||
const BACKEND_RETURN_MAX_ATTEMPTS = 40
|
||||
|
||||
async function waitForBackendReturn(): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < BACKEND_RETURN_MAX_ATTEMPTS; attempt += 1) {
|
||||
await new Promise(resolve => globalThis.setTimeout(resolve, BACKEND_RETURN_POLL_MS))
|
||||
try {
|
||||
await checkHermesUpdate()
|
||||
|
||||
return true
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function finishBackendApply(returned: boolean): DesktopUpdateApplyResult {
|
||||
if (returned) {
|
||||
$backendUpdateApply.set(IDLE)
|
||||
setUpdateOverlayOpen(false)
|
||||
void checkBackendUpdates()
|
||||
|
||||
return { ok: true, message: 'Backend update applied.' }
|
||||
}
|
||||
|
||||
$backendUpdateApply.set({
|
||||
...$backendUpdateApply.get(),
|
||||
applying: false,
|
||||
stage: 'error',
|
||||
error: 'apply-failed',
|
||||
message: translateNow('updates.applyStatus.noReturn')
|
||||
})
|
||||
|
||||
return { ok: false, error: 'apply-failed', message: 'Backend did not come back online.' }
|
||||
}
|
||||
|
||||
export async function applyBackendUpdate(): Promise<DesktopUpdateApplyResult> {
|
||||
dismissNotification(UPDATE_TOAST_ID)
|
||||
$backendUpdateApply.set({ ...IDLE, applying: true, stage: 'prepare', message: translateNow('updates.applyStatus.preparing') })
|
||||
|
||||
try {
|
||||
const started = await updateHermes()
|
||||
|
||||
if (!started.ok) {
|
||||
const message = (started as { message?: string }).message || translateNow('updates.applyStatus.notAvailable')
|
||||
const command = (started as { update_command?: string }).update_command || 'hermes update'
|
||||
$backendUpdateApply.set({ ...IDLE, applying: false, stage: 'manual', message, command })
|
||||
|
||||
return { ok: false, error: 'manual', manual: true, message, command }
|
||||
}
|
||||
|
||||
$backendUpdateApply.set({ ...IDLE, applying: true, stage: 'pull', message: translateNow('updates.applyStatus.pulling') })
|
||||
|
||||
let last: Awaited<ReturnType<typeof getActionStatus>> | null = null
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
await new Promise(resolve => globalThis.setTimeout(resolve, 1500))
|
||||
try {
|
||||
last = await getActionStatus(started.name, 200)
|
||||
} catch {
|
||||
// The dashboard restarts mid-update, dropping this connection — expected, not a failure.
|
||||
$backendUpdateApply.set({
|
||||
...$backendUpdateApply.get(),
|
||||
applying: true,
|
||||
stage: 'restart',
|
||||
message: translateNow('updates.applyStatus.restarting')
|
||||
})
|
||||
|
||||
return finishBackendApply(await waitForBackendReturn())
|
||||
}
|
||||
|
||||
if (last && !last.running) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const ok = !!last && (last.exit_code ?? 1) === 0
|
||||
if (ok) {
|
||||
$backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: true, stage: 'restart', message: translateNow('updates.applyStatus.restarting') })
|
||||
|
||||
return finishBackendApply(await waitForBackendReturn())
|
||||
}
|
||||
|
||||
$backendUpdateApply.set({
|
||||
...$backendUpdateApply.get(),
|
||||
applying: false,
|
||||
stage: 'error',
|
||||
error: 'apply-failed',
|
||||
message: translateNow('updates.applyStatus.failed')
|
||||
})
|
||||
|
||||
return { ok: false, error: 'apply-failed', message: 'Backend update failed.' }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
$backendUpdateApply.set({ ...$backendUpdateApply.get(), applying: false, stage: 'error', error: 'apply-failed', message })
|
||||
|
||||
return { ok: false, error: 'apply-failed', message }
|
||||
}
|
||||
}
|
||||
|
||||
function ingestProgress(payload: DesktopUpdateProgress): void {
|
||||
const current = $updateApply.get()
|
||||
const log = [...current.log, { stage: payload.stage, message: payload.message, at: payload.at }].slice(-50)
|
||||
@@ -267,6 +426,8 @@ function ingestProgress(payload: DesktopUpdateProgress): void {
|
||||
let pollerStarted = false
|
||||
let backgroundTimer: ReturnType<typeof setInterval> | null = null
|
||||
let lastFocusAt = 0
|
||||
let connectionUnsub: (() => void) | null = null
|
||||
let lastConnectionMode: string | undefined
|
||||
|
||||
/** Wire up background polling + progress streaming. Idempotent. */
|
||||
export function startUpdatePoller(): void {
|
||||
@@ -282,11 +443,28 @@ export function startUpdatePoller(): void {
|
||||
|
||||
pollerStarted = true
|
||||
void checkUpdates()
|
||||
void checkBackendUpdates()
|
||||
void refreshDesktopVersion()
|
||||
bridge.onProgress(ingestProgress)
|
||||
|
||||
// The poller starts at mount, before the gateway connects — so the first
|
||||
// backend check above sees mode≠remote and no-ops. Re-check once the
|
||||
// connection resolves to remote.
|
||||
connectionUnsub = $connection.subscribe(conn => {
|
||||
if (conn?.mode === lastConnectionMode) {
|
||||
return
|
||||
}
|
||||
lastConnectionMode = conn?.mode
|
||||
if (conn?.mode === 'remote') {
|
||||
void checkBackendUpdates()
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('focus', onFocus)
|
||||
backgroundTimer = setInterval(() => void checkUpdates(), 30 * 60 * 1000)
|
||||
backgroundTimer = setInterval(() => {
|
||||
void checkUpdates()
|
||||
void checkBackendUpdates()
|
||||
}, 30 * 60 * 1000)
|
||||
}
|
||||
|
||||
export function stopUpdatePoller(): void {
|
||||
@@ -295,6 +473,9 @@ export function stopUpdatePoller(): void {
|
||||
backgroundTimer = null
|
||||
}
|
||||
|
||||
connectionUnsub?.()
|
||||
connectionUnsub = null
|
||||
lastConnectionMode = undefined
|
||||
window.removeEventListener('focus', onFocus)
|
||||
pollerStarted = false
|
||||
}
|
||||
@@ -308,8 +489,6 @@ function onFocus() {
|
||||
|
||||
lastFocusAt = now
|
||||
void checkUpdates()
|
||||
// Cheap and safe to re-read on every (throttled) focus: the user may have
|
||||
// updated Hermes from another window/CLI between focuses, and About should
|
||||
// catch up without forcing a restart.
|
||||
void checkBackendUpdates()
|
||||
void refreshDesktopVersion()
|
||||
}
|
||||
|
||||
@@ -9,15 +9,28 @@
|
||||
* The two are persisted independently. Shift+X toggles light/dark.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
|
||||
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
|
||||
// Legacy global skin (pre per-profile themes). Still the inheritance fallback
|
||||
// for any profile without its own assignment, so single-profile users and old
|
||||
// installs are unaffected.
|
||||
const SKIN_KEY = 'hermes-desktop-theme-v2'
|
||||
const MODE_KEY = 'hermes-desktop-mode-v1'
|
||||
// Per-profile skin + light/dark mode assignments: { [profileKey]: value }. A
|
||||
// profile inherits the global default until it's given its own appearance.
|
||||
const PROFILE_SKINS_KEY = 'hermes-desktop-profile-themes-v1'
|
||||
const PROFILE_MODES_KEY = 'hermes-desktop-profile-modes-v1'
|
||||
// Last active profile, recorded so the boot-time paint can pick that profile's
|
||||
// theme before the gateway reports which profile actually launched.
|
||||
const LAST_PROFILE_KEY = 'hermes-desktop-active-profile-v1'
|
||||
const RETIRED_SKINS = new Set(['nous-light', 'default', 'gold'])
|
||||
|
||||
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||
@@ -27,9 +40,36 @@ const INJECTED_FONT_URLS = new Set<string>()
|
||||
const resolveMode = (mode: ThemeMode, systemDark = matchesQuery('(prefers-color-scheme: dark)')): 'light' | 'dark' =>
|
||||
mode === 'system' ? (systemDark ? 'dark' : 'light') : mode
|
||||
|
||||
const normalizeSkin = (name: string | null | undefined): string =>
|
||||
const normalizeSkin = (name: string | null): string =>
|
||||
name && BUILTIN_THEMES[name] && !RETIRED_SKINS.has(name) ? name : DEFAULT_SKIN_NAME
|
||||
|
||||
const normalizeMode = (value: string | null): ThemeMode =>
|
||||
value === 'light' || value === 'dark' || value === 'system' ? value : 'light'
|
||||
|
||||
// ─── Per-profile appearance persistence ─────────────────────────────────────
|
||||
// Skin and mode are each stored per profile. "default" isn't a real profile —
|
||||
// it *is* the legacy global slot, so it reads/writes the global directly. Named
|
||||
// profiles get their own entry and fall back to that global until assigned, so
|
||||
// unassigned profiles and pre-per-profile installs stay on the global value.
|
||||
const profilePref = <T extends string>(record: string, legacy: string, normalize: (v: string | null) => T) => ({
|
||||
resolve: (profile: string): T => normalize(storedStringRecord(record)[profile] ?? storedString(legacy)),
|
||||
assign: (profile: string, value: T): void => {
|
||||
if (profile === 'default') {
|
||||
persistString(legacy, value)
|
||||
} else {
|
||||
persistStringRecord(record, { ...storedStringRecord(record), [profile]: value })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const skinPref = profilePref(PROFILE_SKINS_KEY, SKIN_KEY, normalizeSkin)
|
||||
export const modePref = profilePref(PROFILE_MODES_KEY, MODE_KEY, normalizeMode)
|
||||
|
||||
// Last active profile — lets the boot paint pick its appearance before the
|
||||
// gateway reports which profile actually launched.
|
||||
const readBootProfileKey = () => normalizeProfileKey(storedString(LAST_PROFILE_KEY))
|
||||
const rememberActiveProfileKey = (profile: string) => persistString(LAST_PROFILE_KEY, profile)
|
||||
|
||||
// ─── Color math (for synthesised light variants of dark-only skins) ────────
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] | null {
|
||||
@@ -231,12 +271,13 @@ function applyTheme(theme: DesktopTheme, mode: 'light' | 'dark') {
|
||||
}
|
||||
}
|
||||
|
||||
// Boot-time paint to avoid a flash before <ThemeProvider> mounts.
|
||||
// Boot-time paint to avoid a flash before <ThemeProvider> mounts. Use the last
|
||||
// active profile's appearance so a non-default profile relaunch paints its own
|
||||
// skin + light/dark mode.
|
||||
if (typeof window !== 'undefined') {
|
||||
const skin = normalizeSkin(window.localStorage.getItem(SKIN_KEY))
|
||||
const mode = (window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light'
|
||||
const resolved = resolveMode(mode)
|
||||
applyTheme(deriveTheme(skin, resolved), resolved)
|
||||
const profile = readBootProfileKey()
|
||||
const resolved = resolveMode(modePref.resolve(profile))
|
||||
applyTheme(deriveTheme(skinPref.resolve(profile), resolved), resolved)
|
||||
}
|
||||
|
||||
// ─── Context ────────────────────────────────────────────────────────────────
|
||||
@@ -264,29 +305,46 @@ const ThemeContext = createContext<ThemeContextValue>({
|
||||
})
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
// Skin + mode are assigned per profile; the active profile drives which
|
||||
// appearance shows. Single-profile users only ever see "default", so their
|
||||
// behavior is unchanged.
|
||||
const profileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
|
||||
const [themeName, setThemeNameState] = useState(() =>
|
||||
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : normalizeSkin(window.localStorage.getItem(SKIN_KEY))
|
||||
typeof window === 'undefined' ? DEFAULT_SKIN_NAME : skinPref.resolve(readBootProfileKey())
|
||||
)
|
||||
|
||||
const [mode, setModeState] = useState<ThemeMode>(() =>
|
||||
typeof window === 'undefined' ? 'light' : ((window.localStorage.getItem(MODE_KEY) as ThemeMode) ?? 'light')
|
||||
typeof window === 'undefined' ? 'light' : modePref.resolve(readBootProfileKey())
|
||||
)
|
||||
|
||||
// Follow profile switches: paint the profile's assigned skin + mode and
|
||||
// remember it for the next boot's first paint.
|
||||
useEffect(() => {
|
||||
rememberActiveProfileKey(profileKey)
|
||||
setThemeNameState(skinPref.resolve(profileKey))
|
||||
setModeState(modePref.resolve(profileKey))
|
||||
}, [profileKey])
|
||||
|
||||
const systemDark = useMediaQuery('(prefers-color-scheme: dark)')
|
||||
const resolvedMode = resolveMode(mode, systemDark)
|
||||
const activeTheme = useMemo(() => deriveTheme(themeName, resolvedMode), [themeName, resolvedMode])
|
||||
|
||||
useEffect(() => applyTheme(activeTheme, resolvedMode), [activeTheme, resolvedMode])
|
||||
|
||||
// Assign to whichever profile is live right now (read fresh so the callbacks
|
||||
// stay stable across profile switches).
|
||||
const liveProfile = () => normalizeProfileKey($activeGatewayProfile.get())
|
||||
|
||||
const setTheme = useCallback((name: string) => {
|
||||
const next = normalizeSkin(name)
|
||||
setThemeNameState(next)
|
||||
window.localStorage.setItem(SKIN_KEY, next)
|
||||
skinPref.assign(liveProfile(), next)
|
||||
}, [])
|
||||
|
||||
const setMode = useCallback((next: ThemeMode) => {
|
||||
setModeState(next)
|
||||
window.localStorage.setItem(MODE_KEY, next)
|
||||
modePref.assign(liveProfile(), next)
|
||||
}, [])
|
||||
|
||||
// The light/dark toggle (Shift+X by default) is owned by the keybind runtime
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { modePref, skinPref } from './context'
|
||||
import { DEFAULT_SKIN_NAME } from './presets'
|
||||
|
||||
// Skin and mode share one per-profile contract, so assert it once over both.
|
||||
interface Pref {
|
||||
resolve: (profile: string) => string
|
||||
assign: (profile: string, value: string) => void
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{ name: 'skin', pref: skinPref as unknown as Pref, fallback: DEFAULT_SKIN_NAME, a: 'ember', b: 'midnight', junk: 'nope' },
|
||||
{ name: 'mode', pref: modePref as unknown as Pref, fallback: 'light', a: 'dark', b: 'system', junk: 'dusk' }
|
||||
]
|
||||
|
||||
describe.each(cases)('per-profile $name', ({ pref, fallback, a, b, junk }) => {
|
||||
beforeEach(() => window.localStorage.clear())
|
||||
|
||||
it('falls back to the default when unassigned', () => {
|
||||
expect(pref.resolve('default')).toBe(fallback)
|
||||
expect(pref.resolve('work')).toBe(fallback)
|
||||
})
|
||||
|
||||
it('keeps each profile on its own value', () => {
|
||||
pref.assign('work', a)
|
||||
pref.assign('default', b)
|
||||
expect(pref.resolve('work')).toBe(a)
|
||||
expect(pref.resolve('default')).toBe(b)
|
||||
})
|
||||
|
||||
it('lets unassigned profiles inherit the default profile as the global fallback', () => {
|
||||
pref.assign('default', a)
|
||||
expect(pref.resolve('never-themed')).toBe(a)
|
||||
})
|
||||
|
||||
it('normalizes an unknown stored value back to the default', () => {
|
||||
pref.assign('work', junk)
|
||||
expect(pref.resolve('work')).toBe(fallback)
|
||||
})
|
||||
})
|
||||
@@ -596,6 +596,27 @@ export interface ActionStatusResponse {
|
||||
running: boolean
|
||||
}
|
||||
|
||||
export interface BackendUpdateCommit {
|
||||
sha: string
|
||||
summary: string
|
||||
author: string
|
||||
at: number
|
||||
}
|
||||
|
||||
/** Shape of `GET /api/hermes/update/check` — the backend's own update state.
|
||||
* Used by the desktop's remote update overlay so the backend version (not the
|
||||
* Electron client clone) drives "what's changed + Install" in remote mode. */
|
||||
export interface BackendUpdateCheckResponse {
|
||||
install_method: string
|
||||
current_version: string
|
||||
behind: number | null
|
||||
update_available: boolean
|
||||
can_apply: boolean
|
||||
update_command: string | null
|
||||
message: string | null
|
||||
commits?: BackendUpdateCommit[]
|
||||
}
|
||||
|
||||
export interface AuxiliaryTaskAssignment {
|
||||
base_url: string
|
||||
model: string
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2023"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
@@ -528,6 +528,15 @@ session_reset:
|
||||
idle_minutes: 1440 # Inactivity timeout in minutes (default: 1440 = 24 hours)
|
||||
at_hour: 4 # Daily reset hour, 0-23 local time (default: 4 AM)
|
||||
|
||||
# Maximum number of simultaneously active chat sessions across CLI, TUI,
|
||||
# dashboard chat, and messaging gateway. Set to null, 0, or omit to allow
|
||||
# unlimited concurrent sessions. When the limit is reached, new sessions get a
|
||||
# clean error while existing active sessions keep their normal behavior. This
|
||||
# top-level key takes precedence over gateway.max_concurrent_sessions. The cap
|
||||
# is a best-effort single-host/profile runtime guard; Hermes fails open if the
|
||||
# local runtime lease registry cannot be read or locked.
|
||||
max_concurrent_sessions: null
|
||||
|
||||
# When true, group/channel chats use one session per participant when the platform
|
||||
# provides a user ID. This is the secure default and prevents users in the same
|
||||
# room from sharing context, interrupts, and token costs. Set false only if you
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
"""User-authorization methods for ``GatewayRunner``.
|
||||
|
||||
Extracted from ``gateway/run.py`` as part of the god-file decomposition campaign
|
||||
(``~/.hermes/plans/god-file-decomposition.md``, Phase 3 mechanical mixin lifts).
|
||||
This mixin holds the inbound-message authorization cluster: whether a user/chat
|
||||
is allowed to talk to the agent, the per-adapter DM policy, and the
|
||||
unauthorized-DM behavior.
|
||||
|
||||
Behavior-neutral: every method is lifted verbatim from ``GatewayRunner``.
|
||||
``self.*`` calls resolve unchanged via the MRO. Neutral dependencies import at
|
||||
module top; the module-level ``logger`` is imported lazily inside the one method
|
||||
that uses it (``from gateway.run import logger`` resolves at call time, when
|
||||
``gateway.run`` is fully loaded) so this module never imports ``gateway.run`` at
|
||||
import time -> no import cycle. The lazy import preserves the exact logger name
|
||||
(``"gateway.run"``) so log records are unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.session import SessionSource
|
||||
from gateway.whatsapp_identity import (
|
||||
expand_whatsapp_aliases as _expand_whatsapp_auth_aliases,
|
||||
normalize_whatsapp_identifier as _normalize_whatsapp_identifier,
|
||||
)
|
||||
|
||||
|
||||
class GatewayAuthorizationMixin:
|
||||
"""User/chat authorization methods for ``GatewayRunner``."""
|
||||
|
||||
def _adapter_enforces_own_access_policy(self, platform: Optional[Platform]) -> bool:
|
||||
"""Whether the adapter for *platform* gates access at intake itself.
|
||||
|
||||
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
|
||||
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
|
||||
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
message is dispatched to the gateway, so a message that reaches
|
||||
``_is_user_authorized`` has already been authorized by the adapter.
|
||||
Defaults to ``False`` when the adapter is unknown or doesn't expose
|
||||
the flag.
|
||||
"""
|
||||
if not platform:
|
||||
return False
|
||||
# Some test helpers build a bare GatewayRunner via object.__new__ and
|
||||
# never set ``adapters``; treat a missing/empty map as "no adapter"
|
||||
# rather than raising (see pitfalls.md #17).
|
||||
adapters = getattr(self, "adapters", None)
|
||||
if not adapters:
|
||||
return False
|
||||
adapter = adapters.get(platform)
|
||||
if adapter is None:
|
||||
return False
|
||||
return bool(getattr(adapter, "enforces_own_access_policy", False))
|
||||
|
||||
def _adapter_dm_policy(self, platform: Optional[Platform]) -> str:
|
||||
"""Best-effort read of an own-policy adapter's effective DM policy.
|
||||
|
||||
Returns the lowercased ``dm_policy`` (``"open"`` / ``"allowlist"`` /
|
||||
``"disabled"`` / ``"pairing"``) for *platform*, or ``""`` when unknown.
|
||||
Prefers the live adapter's resolved ``_dm_policy`` — which already folds
|
||||
in both ``config.extra`` and the ``<PLATFORM>_DM_POLICY`` env var (the
|
||||
env var is not always bridged back into ``config.extra``) — and falls
|
||||
back to ``config.extra`` for bare runners built without a live adapter.
|
||||
|
||||
Used by ``_is_user_authorized`` to carve ``dm_policy: pairing`` out of
|
||||
the adapter-trust shortcut: in pairing mode the adapter forwards the DM
|
||||
so the gateway can run its pairing handshake, so "reached the gateway"
|
||||
must not be read as "authorized".
|
||||
"""
|
||||
if not platform:
|
||||
return ""
|
||||
adapters = getattr(self, "adapters", None) or {}
|
||||
adapter = adapters.get(platform)
|
||||
policy = getattr(adapter, "_dm_policy", None) if adapter is not None else None
|
||||
if policy is None:
|
||||
config = getattr(self, "config", None)
|
||||
platform_cfg = (
|
||||
config.platforms.get(platform)
|
||||
if config is not None and hasattr(config, "platforms")
|
||||
else None
|
||||
)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
policy = extra.get("dm_policy")
|
||||
return str(policy or "").strip().lower()
|
||||
|
||||
def _is_user_authorized(self, source: SessionSource) -> bool:
|
||||
"""
|
||||
Check if a user is authorized to use the bot.
|
||||
|
||||
Checks in order:
|
||||
1. Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||
2. Environment variable allowlists (TELEGRAM_ALLOWED_USERS, etc.)
|
||||
3. DM pairing approved list
|
||||
4. Global allow-all (GATEWAY_ALLOW_ALL_USERS=true)
|
||||
5. Default: deny
|
||||
"""
|
||||
from gateway.run import logger
|
||||
# Home Assistant events are system-generated (state changes), not
|
||||
# user-initiated messages. The HASS_TOKEN already authenticates the
|
||||
# connection, so HA events are always authorized.
|
||||
# Webhook events are authenticated via HMAC signature validation in
|
||||
# the adapter itself — no user allowlist applies.
|
||||
if source.platform in {Platform.HOMEASSISTANT, Platform.WEBHOOK}:
|
||||
return True
|
||||
|
||||
user_id = source.user_id
|
||||
|
||||
# Telegram (and similar) authorize entire group/forum/channel chats
|
||||
# by chat ID via TELEGRAM_GROUP_ALLOWED_CHATS / QQ_GROUP_ALLOWED_USERS.
|
||||
# That allowlist is chat-scoped, so it must work even when
|
||||
# source.user_id is None — Telegram emits anonymous-admin posts,
|
||||
# sender_chat traffic, and channel broadcasts with no `from_user`,
|
||||
# and an operator who explicitly listed the chat expects those to
|
||||
# be honored. Run this check before the no-user-id guard below so
|
||||
# documented behavior matches reality
|
||||
# (website/docs/reference/environment-variables.md,
|
||||
# website/docs/user-guide/messaging/telegram.md).
|
||||
if source.chat_type in {"group", "forum", "channel"} and source.chat_id:
|
||||
chat_allowlist_env = {
|
||||
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS",
|
||||
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
|
||||
}.get(source.platform, "")
|
||||
if chat_allowlist_env:
|
||||
raw_chat_allowlist = os.getenv(chat_allowlist_env, "").strip()
|
||||
if raw_chat_allowlist:
|
||||
allowed_group_ids = {
|
||||
cid.strip()
|
||||
for cid in raw_chat_allowlist.split(",")
|
||||
if cid.strip()
|
||||
}
|
||||
if "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
|
||||
return True
|
||||
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
platform_env_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
|
||||
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
|
||||
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
|
||||
Platform.SLACK: "SLACK_ALLOWED_USERS",
|
||||
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
|
||||
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
|
||||
Platform.SMS: "SMS_ALLOWED_USERS",
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOWED_USERS",
|
||||
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
|
||||
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOWED_USERS",
|
||||
Platform.YUANBAO: "YUANBAO_ALLOWED_USERS",
|
||||
}
|
||||
platform_group_user_env_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_USERS",
|
||||
}
|
||||
platform_group_chat_env_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_GROUP_ALLOWED_CHATS",
|
||||
Platform.QQBOT: "QQ_GROUP_ALLOWED_USERS",
|
||||
}
|
||||
platform_allow_all_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
||||
Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS",
|
||||
Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS",
|
||||
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
|
||||
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
|
||||
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
|
||||
Platform.SMS: "SMS_ALLOW_ALL_USERS",
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOW_ALL_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOW_ALL_USERS",
|
||||
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOW_ALL_USERS",
|
||||
Platform.WEIXIN: "WEIXIN_ALLOW_ALL_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOW_ALL_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOW_ALL_USERS",
|
||||
Platform.YUANBAO: "YUANBAO_ALLOW_ALL_USERS",
|
||||
}
|
||||
# Bots admitted by {PLATFORM}_ALLOW_BOTS bypass the human allowlist (#4466).
|
||||
platform_allow_bots_map = {
|
||||
Platform.DISCORD: "DISCORD_ALLOW_BOTS",
|
||||
Platform.FEISHU: "FEISHU_ALLOW_BOTS",
|
||||
}
|
||||
|
||||
# Plugin platforms: check the registry for auth env var names
|
||||
if source.platform not in platform_env_map:
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
entry = platform_registry.get(source.platform.value)
|
||||
if entry:
|
||||
if entry.allowed_users_env:
|
||||
platform_env_map[source.platform] = entry.allowed_users_env
|
||||
if entry.allow_all_env:
|
||||
platform_allow_all_map[source.platform] = entry.allow_all_env
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Per-platform allow-all flag (e.g., DISCORD_ALLOW_ALL_USERS=true)
|
||||
platform_allow_all_var = platform_allow_all_map.get(source.platform, "")
|
||||
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}:
|
||||
return True
|
||||
|
||||
if getattr(source, "is_bot", False):
|
||||
allow_bots_var = platform_allow_bots_map.get(source.platform)
|
||||
if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
|
||||
return True
|
||||
|
||||
# Check pairing store (always checked, regardless of allowlists)
|
||||
platform_name = source.platform.value if source.platform else ""
|
||||
if self.pairing_store.is_approved(platform_name, user_id):
|
||||
return True
|
||||
|
||||
# Check platform-specific and global allowlists
|
||||
platform_allowlist = os.getenv(platform_env_map.get(source.platform, ""), "").strip()
|
||||
group_user_allowlist = ""
|
||||
group_chat_allowlist = ""
|
||||
if source.chat_type in {"group", "forum"}:
|
||||
group_user_allowlist = os.getenv(platform_group_user_env_map.get(source.platform, ""), "").strip()
|
||||
group_chat_allowlist = os.getenv(platform_group_chat_env_map.get(source.platform, ""), "").strip()
|
||||
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
|
||||
|
||||
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
|
||||
# No env allowlists configured. Adapters that own their own
|
||||
# config-driven access policy (dm_policy / group_policy /
|
||||
# allow_from / group_allow_from) already gated this message at
|
||||
# intake — it would not have reached the gateway otherwise — so
|
||||
# honor that decision instead of falling through to the
|
||||
# env-only default-deny below, which would silently break
|
||||
# `dm_policy: open` and config-only allowlists. (#34515)
|
||||
if self._adapter_enforces_own_access_policy(source.platform):
|
||||
# Exception: `dm_policy: pairing` does NOT authorize at intake.
|
||||
# The adapter forwards the DM precisely so the gateway can run
|
||||
# its pairing handshake (issue a code, consult the pairing
|
||||
# store). The pairing-store approval check above already ran and
|
||||
# returned False for this sender, so blanket-trusting the
|
||||
# adapter here would silently turn pairing mode into open
|
||||
# access. Fall through to default-deny so the unpaired sender is
|
||||
# offered a pairing code instead. (Pairing is DM-only; group
|
||||
# traffic keeps the adapter-trust path.)
|
||||
if not (
|
||||
source.chat_type == "dm"
|
||||
and self._adapter_dm_policy(source.platform) == "pairing"
|
||||
):
|
||||
return True
|
||||
# No allowlists configured -- check global allow-all flag
|
||||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
|
||||
# Telegram can optionally authorize group traffic by chat ID.
|
||||
# Keep this separate from TELEGRAM_GROUP_ALLOWED_USERS, which gates
|
||||
# the sender user ID for group/forum messages.
|
||||
if group_chat_allowlist and source.chat_type in {"group", "forum"} and source.chat_id:
|
||||
allowed_group_ids = {
|
||||
chat_id.strip() for chat_id in group_chat_allowlist.split(",") if chat_id.strip()
|
||||
}
|
||||
if "*" in allowed_group_ids or source.chat_id in allowed_group_ids:
|
||||
return True
|
||||
|
||||
# Backward-compat shim for #15027: prior to PR #17686,
|
||||
# TELEGRAM_GROUP_ALLOWED_USERS was (mis)used as a chat-ID allowlist.
|
||||
# Values starting with "-" are Telegram chat IDs, not user IDs, so if
|
||||
# users still have those in TELEGRAM_GROUP_ALLOWED_USERS we honor them
|
||||
# as chat IDs and warn once. The correct var is now
|
||||
# TELEGRAM_GROUP_ALLOWED_CHATS.
|
||||
if (
|
||||
source.platform == Platform.TELEGRAM
|
||||
and group_user_allowlist
|
||||
and source.chat_type in {"group", "forum"}
|
||||
and source.chat_id
|
||||
):
|
||||
legacy_chat_ids = {
|
||||
v.strip()
|
||||
for v in group_user_allowlist.split(",")
|
||||
if v.strip().startswith("-")
|
||||
}
|
||||
if legacy_chat_ids:
|
||||
if not getattr(self, "_warned_telegram_group_users_legacy", False):
|
||||
logger.warning(
|
||||
"TELEGRAM_GROUP_ALLOWED_USERS contains chat-ID-shaped values "
|
||||
"(%s). Treating them as chat IDs for backward compatibility. "
|
||||
"Move chat IDs to TELEGRAM_GROUP_ALLOWED_CHATS — the _USERS var "
|
||||
"is now for sender user IDs.",
|
||||
",".join(sorted(legacy_chat_ids)),
|
||||
)
|
||||
self._warned_telegram_group_users_legacy = True
|
||||
if source.chat_id in legacy_chat_ids:
|
||||
return True
|
||||
|
||||
# Check if user is in any allowlist. In group/forum chats,
|
||||
# TELEGRAM_GROUP_ALLOWED_USERS is the scoped allowlist and should not
|
||||
# imply DM access; TELEGRAM_ALLOWED_USERS remains the platform-wide
|
||||
# allowlist and still works everywhere for backward compatibility.
|
||||
allowed_ids = set()
|
||||
if platform_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in platform_allowlist.split(",") if uid.strip())
|
||||
if group_user_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in group_user_allowlist.split(",") if uid.strip())
|
||||
if global_allowlist:
|
||||
allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip())
|
||||
|
||||
# "*" in any allowlist means allow everyone (consistent with
|
||||
# SIGNAL_GROUP_ALLOWED_USERS precedent)
|
||||
if "*" in allowed_ids:
|
||||
return True
|
||||
|
||||
check_ids = {user_id}
|
||||
if "@" in user_id:
|
||||
check_ids.add(user_id.split("@")[0])
|
||||
|
||||
# WhatsApp: resolve phone↔LID aliases from bridge session mapping files
|
||||
if source.platform == Platform.WHATSAPP:
|
||||
normalized_allowed_ids = set()
|
||||
for allowed_id in allowed_ids:
|
||||
normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id))
|
||||
if normalized_allowed_ids:
|
||||
allowed_ids = normalized_allowed_ids
|
||||
|
||||
check_ids.update(_expand_whatsapp_auth_aliases(user_id))
|
||||
normalized_user_id = _normalize_whatsapp_identifier(user_id)
|
||||
if normalized_user_id:
|
||||
check_ids.add(normalized_user_id)
|
||||
|
||||
# SimpleX: SIMPLEX_ALLOWED_USERS accepts either the numeric contactId
|
||||
# or the contact's display name. The adapter sets user_id=contactId for
|
||||
# stability across renames, but the SimpleX UI never surfaces the
|
||||
# numeric id — operators only see display names, so that's what they
|
||||
# naturally put in the env var. Match both so the allowlist works
|
||||
# regardless of which form was chosen.
|
||||
# Plugin platform: compare by value since Platform.SIMPLEX is not a
|
||||
# hardcoded enum member (it's a dynamic plugin platform).
|
||||
if (
|
||||
source.platform is not None
|
||||
and source.platform.value == "simplex"
|
||||
and source.user_name
|
||||
):
|
||||
check_ids.add(source.user_name)
|
||||
|
||||
return bool(check_ids & allowed_ids)
|
||||
|
||||
def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str:
|
||||
"""Return how unauthorized DMs should be handled for a platform.
|
||||
|
||||
Resolution order:
|
||||
1. Explicit per-platform ``unauthorized_dm_behavior`` in config — always wins.
|
||||
2. Explicit global ``unauthorized_dm_behavior`` in config — wins when no per-platform.
|
||||
3. When an allowlist (``PLATFORM_ALLOWED_USERS``,
|
||||
``PLATFORM_GROUP_ALLOWED_USERS`` / ``PLATFORM_GROUP_ALLOWED_CHATS``,
|
||||
or ``GATEWAY_ALLOWED_USERS``) is configured, default to ``"ignore"`` —
|
||||
the allowlist signals that the owner has deliberately restricted
|
||||
access; spamming unknown contacts with pairing codes is both noisy
|
||||
and a potential info-leak. (#9337)
|
||||
4. No allowlist and no explicit config → ``"pair"`` (open-gateway default).
|
||||
"""
|
||||
config = getattr(self, "config", None)
|
||||
|
||||
# Check for an explicit per-platform override first.
|
||||
if config and hasattr(config, "get_unauthorized_dm_behavior") and platform:
|
||||
platform_cfg = config.platforms.get(platform) if hasattr(config, "platforms") else None
|
||||
if platform_cfg and "unauthorized_dm_behavior" in getattr(platform_cfg, "extra", {}):
|
||||
# Operator explicitly configured behavior for this platform — respect it.
|
||||
return config.get_unauthorized_dm_behavior(platform)
|
||||
|
||||
# Check for an explicit global config override.
|
||||
if config and hasattr(config, "unauthorized_dm_behavior"):
|
||||
if config.unauthorized_dm_behavior != "pair": # non-default → explicit override
|
||||
return config.unauthorized_dm_behavior
|
||||
|
||||
# Config-driven dm_policy (WeCom / Weixin / Yuanbao / QQBot). An
|
||||
# allowlist or disabled DM policy means the operator restricted access,
|
||||
# so unauthorized DMs should be dropped silently rather than answered
|
||||
# with a pairing code. An explicit pairing policy opts back into codes.
|
||||
if platform and config and hasattr(config, "platforms"):
|
||||
platform_cfg = config.platforms.get(platform)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
dm_policy = str(extra.get("dm_policy") or "").strip().lower()
|
||||
if dm_policy == "pairing":
|
||||
return "pair"
|
||||
if dm_policy in {"allowlist", "disabled"}:
|
||||
return "ignore"
|
||||
|
||||
# No explicit override. Fall back to allowlist-aware default:
|
||||
# if any allowlist is configured for this platform, silently drop
|
||||
# unauthorized messages instead of sending pairing codes.
|
||||
if platform:
|
||||
platform_env_map = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
|
||||
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
|
||||
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
|
||||
Platform.SLACK: "SLACK_ALLOWED_USERS",
|
||||
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
|
||||
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
|
||||
Platform.SMS: "SMS_ALLOWED_USERS",
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
||||
Platform.FEISHU: "FEISHU_ALLOWED_USERS",
|
||||
Platform.WECOM: "WECOM_ALLOWED_USERS",
|
||||
Platform.WECOM_CALLBACK: "WECOM_CALLBACK_ALLOWED_USERS",
|
||||
Platform.WEIXIN: "WEIXIN_ALLOWED_USERS",
|
||||
Platform.BLUEBUBBLES: "BLUEBUBBLES_ALLOWED_USERS",
|
||||
Platform.QQBOT: "QQ_ALLOWED_USERS",
|
||||
}
|
||||
platform_group_env_map = {
|
||||
Platform.TELEGRAM: (
|
||||
"TELEGRAM_GROUP_ALLOWED_USERS",
|
||||
"TELEGRAM_GROUP_ALLOWED_CHATS",
|
||||
),
|
||||
Platform.QQBOT: ("QQ_GROUP_ALLOWED_USERS",),
|
||||
}
|
||||
if os.getenv(platform_env_map.get(platform, ""), "").strip():
|
||||
return "ignore"
|
||||
for env_key in platform_group_env_map.get(platform, ()):
|
||||
if os.getenv(env_key, "").strip():
|
||||
return "ignore"
|
||||
|
||||
if os.getenv("GATEWAY_ALLOWED_USERS", "").strip():
|
||||
return "ignore"
|
||||
|
||||
return "pair"
|
||||
@@ -56,6 +56,42 @@ def _coerce_int(value: Any, default: int) -> int:
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_optional_positive_int(value: Any, key: str) -> Optional[int]:
|
||||
"""Coerce an optional positive integer config value.
|
||||
|
||||
``None``/0/negative disable the setting. Malformed values are ignored with
|
||||
a warning so a typo never prevents the gateway from starting.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
logger.warning(
|
||||
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
|
||||
key,
|
||||
value,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, float):
|
||||
if not value.is_integer():
|
||||
raise ValueError(value)
|
||||
parsed = int(value)
|
||||
elif isinstance(value, str):
|
||||
parsed = int(value.strip(), 10)
|
||||
else:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
|
||||
key,
|
||||
value,
|
||||
)
|
||||
return None
|
||||
if parsed <= 0:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
|
||||
"""Normalize unauthorized DM behavior to a supported value."""
|
||||
if isinstance(value, str):
|
||||
@@ -495,6 +531,7 @@ class GatewayConfig:
|
||||
# Session isolation in shared chats
|
||||
group_sessions_per_user: bool = True # Isolate group/channel sessions per participant when user IDs are available
|
||||
thread_sessions_per_user: bool = False # When False (default), threads are shared across all participants
|
||||
max_concurrent_sessions: Optional[int] = None # Positive int caps simultaneous active chat sessions
|
||||
|
||||
# Unauthorized DM policy
|
||||
unauthorized_dm_behavior: str = "pair" # "pair" or "ignore"
|
||||
@@ -600,6 +637,7 @@ class GatewayConfig:
|
||||
"stt_enabled": self.stt_enabled,
|
||||
"group_sessions_per_user": self.group_sessions_per_user,
|
||||
"thread_sessions_per_user": self.thread_sessions_per_user,
|
||||
"max_concurrent_sessions": self.max_concurrent_sessions,
|
||||
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
|
||||
"streaming": self.streaming.to_dict(),
|
||||
"session_store_max_age_days": self.session_store_max_age_days,
|
||||
@@ -645,6 +683,17 @@ class GatewayConfig:
|
||||
|
||||
group_sessions_per_user = data.get("group_sessions_per_user")
|
||||
thread_sessions_per_user = data.get("thread_sessions_per_user")
|
||||
nested_gateway = data.get("gateway") if isinstance(data.get("gateway"), dict) else {}
|
||||
if "max_concurrent_sessions" in data:
|
||||
max_concurrent_raw = data.get("max_concurrent_sessions")
|
||||
max_concurrent_key = "max_concurrent_sessions"
|
||||
else:
|
||||
max_concurrent_raw = nested_gateway.get("max_concurrent_sessions")
|
||||
max_concurrent_key = "gateway.max_concurrent_sessions"
|
||||
max_concurrent_sessions = _coerce_optional_positive_int(
|
||||
max_concurrent_raw,
|
||||
max_concurrent_key,
|
||||
)
|
||||
unauthorized_dm_behavior = _normalize_unauthorized_dm_behavior(
|
||||
data.get("unauthorized_dm_behavior"),
|
||||
"pair",
|
||||
@@ -671,6 +720,7 @@ class GatewayConfig:
|
||||
stt_enabled=_coerce_bool(stt_enabled, True),
|
||||
group_sessions_per_user=_coerce_bool(group_sessions_per_user, True),
|
||||
thread_sessions_per_user=_coerce_bool(thread_sessions_per_user, False),
|
||||
max_concurrent_sessions=max_concurrent_sessions,
|
||||
unauthorized_dm_behavior=unauthorized_dm_behavior,
|
||||
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
|
||||
session_store_max_age_days=session_store_max_age_days,
|
||||
@@ -761,6 +811,13 @@ def load_gateway_config() -> GatewayConfig:
|
||||
if "thread_sessions_per_user" in yaml_cfg:
|
||||
gw_data["thread_sessions_per_user"] = yaml_cfg["thread_sessions_per_user"]
|
||||
|
||||
gateway_section = yaml_cfg.get("gateway")
|
||||
if isinstance(gateway_section, dict) and "max_concurrent_sessions" in gateway_section:
|
||||
gw_data["max_concurrent_sessions"] = gateway_section["max_concurrent_sessions"]
|
||||
|
||||
if "max_concurrent_sessions" in yaml_cfg:
|
||||
gw_data["max_concurrent_sessions"] = yaml_cfg["max_concurrent_sessions"]
|
||||
|
||||
streaming_cfg = yaml_cfg.get("streaming")
|
||||
if not isinstance(streaming_cfg, dict):
|
||||
# Fall back to nested gateway.streaming written by
|
||||
|
||||
@@ -3510,35 +3510,46 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _run():
|
||||
agent = self._create_agent(
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
session_id=session_id,
|
||||
stream_delta_callback=stream_delta_callback,
|
||||
tool_progress_callback=tool_progress_callback,
|
||||
tool_start_callback=tool_start_callback,
|
||||
tool_complete_callback=tool_complete_callback,
|
||||
gateway_session_key=gateway_session_key,
|
||||
from gateway.session_context import clear_session_vars, set_session_vars
|
||||
|
||||
tokens = set_session_vars(
|
||||
platform="api_server",
|
||||
chat_id=session_id or "",
|
||||
session_key=gateway_session_key or session_id or "",
|
||||
session_id=session_id or "",
|
||||
)
|
||||
if agent_ref is not None:
|
||||
agent_ref[0] = agent
|
||||
effective_task_id = session_id or str(uuid.uuid4())
|
||||
result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
usage = {
|
||||
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
||||
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
|
||||
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
|
||||
}
|
||||
# Include the effective session ID in the result so callers
|
||||
# (e.g. X-Hermes-Session-Id header) can track compression-
|
||||
# triggered session rotations. (#16938)
|
||||
_eff_sid = getattr(agent, "session_id", session_id)
|
||||
if isinstance(_eff_sid, str) and _eff_sid:
|
||||
result["session_id"] = _eff_sid
|
||||
return result, usage
|
||||
try:
|
||||
agent = self._create_agent(
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
session_id=session_id,
|
||||
stream_delta_callback=stream_delta_callback,
|
||||
tool_progress_callback=tool_progress_callback,
|
||||
tool_start_callback=tool_start_callback,
|
||||
tool_complete_callback=tool_complete_callback,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
if agent_ref is not None:
|
||||
agent_ref[0] = agent
|
||||
effective_task_id = session_id or str(uuid.uuid4())
|
||||
result = agent.run_conversation(
|
||||
user_message=user_message,
|
||||
conversation_history=conversation_history,
|
||||
task_id=effective_task_id,
|
||||
)
|
||||
usage = {
|
||||
"input_tokens": getattr(agent, "session_prompt_tokens", 0) or 0,
|
||||
"output_tokens": getattr(agent, "session_completion_tokens", 0) or 0,
|
||||
"total_tokens": getattr(agent, "session_total_tokens", 0) or 0,
|
||||
}
|
||||
# Include the effective session ID in the result so callers
|
||||
# (e.g. X-Hermes-Session-Id header) can track compression-
|
||||
# triggered session rotations. (#16938)
|
||||
_eff_sid = getattr(agent, "session_id", session_id)
|
||||
if isinstance(_eff_sid, str) and _eff_sid:
|
||||
result["session_id"] = _eff_sid
|
||||
return result, usage
|
||||
finally:
|
||||
clear_session_vars(tokens)
|
||||
|
||||
return await loop.run_in_executor(None, _run)
|
||||
|
||||
|
||||
@@ -1795,9 +1795,12 @@ class BasePlatformAdapter(ABC):
|
||||
|
||||
# Whether this platform renders triple-backtick fenced code blocks (i.e.
|
||||
# ``format_message`` translates/preserves markdown fences into a real code
|
||||
# block). Drives presentation choices like rendering a ``terminal`` tool
|
||||
# call's command as a ```bash block instead of a flat preview line.
|
||||
# block). Capability flag for markdown-aware presentation choices.
|
||||
# Default False (plain-text platforms); markdown-rendering adapters set True.
|
||||
# Tool-progress uses this to render a terminal command as a bare fenced code
|
||||
# block (no language tag — Slack mrkdwn would print the tag as a literal
|
||||
# first code line). Plain-text platforms fall back to the short truncated
|
||||
# preview (see gateway/run.py progress_callback).
|
||||
supports_code_blocks: bool = False
|
||||
|
||||
def __init__(self, config: PlatformConfig, platform: Platform):
|
||||
|
||||
@@ -181,6 +181,8 @@ def _strip_mdv2(text: str) -> str:
|
||||
"""
|
||||
# Remove escape backslashes before special characters
|
||||
cleaned = re.sub(r'\\([_*\[\]()~`>#\+\-=|{}.!\\])', r'\1', text)
|
||||
# Remove standard markdown bold (**text** → text) BEFORE MarkdownV2 bold
|
||||
cleaned = re.sub(r'\*\*([^*]+)\*\*', r'\1', cleaned)
|
||||
# Remove MarkdownV2 bold markers that format_message converted from **bold**
|
||||
cleaned = re.sub(r'\*([^*]+)\*', r'\1', cleaned)
|
||||
# Remove MarkdownV2 italic markers that format_message converted from *italic*
|
||||
@@ -2208,11 +2210,17 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# "Message is not modified" is a no-op, not an error
|
||||
if "not modified" in str(fmt_err).lower():
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
# Fallback: retry without markdown formatting
|
||||
# Fallback: strip MarkdownV2 escapes and retry as clean plain text
|
||||
logger.warning(
|
||||
"[%s] MarkdownV2 edit failed, falling back to plain text: %s",
|
||||
self.name,
|
||||
fmt_err,
|
||||
)
|
||||
_plain = _strip_mdv2(content) if content else content
|
||||
await self._bot.edit_message_text(
|
||||
chat_id=int(chat_id),
|
||||
message_id=int(message_id),
|
||||
text=content,
|
||||
text=_plain,
|
||||
)
|
||||
return SendResult(success=True, message_id=message_id)
|
||||
except Exception as e:
|
||||
|
||||
+423
-456
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,7 @@ def set_session_vars(
|
||||
user_id: str = "",
|
||||
user_name: str = "",
|
||||
session_key: str = "",
|
||||
session_id: str = "",
|
||||
message_id: str = "",
|
||||
cwd: str = "",
|
||||
) -> list:
|
||||
@@ -127,6 +128,7 @@ def set_session_vars(
|
||||
_SESSION_USER_ID.set(user_id),
|
||||
_SESSION_USER_NAME.set(user_name),
|
||||
_SESSION_KEY.set(session_key),
|
||||
_SESSION_ID.set(session_id),
|
||||
_SESSION_MESSAGE_ID.set(message_id),
|
||||
]
|
||||
try:
|
||||
@@ -157,6 +159,7 @@ def clear_session_vars(tokens: list) -> None:
|
||||
_SESSION_USER_ID,
|
||||
_SESSION_USER_NAME,
|
||||
_SESSION_KEY,
|
||||
_SESSION_ID,
|
||||
_SESSION_MESSAGE_ID,
|
||||
):
|
||||
var.set("")
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Cross-process active chat session leases.
|
||||
|
||||
The session database records persisted conversations. This module records
|
||||
currently open chat surfaces, including idle CLI/TUI sessions that have not
|
||||
written a transcript row yet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def coerce_max_concurrent_sessions(value: Any, key: str = "max_concurrent_sessions") -> Optional[int]:
|
||||
"""Return a positive integer cap, or None when disabled/invalid."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
logger.warning(
|
||||
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
|
||||
key,
|
||||
value,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, float):
|
||||
if not value.is_integer():
|
||||
raise ValueError(value)
|
||||
parsed = int(value)
|
||||
elif isinstance(value, str):
|
||||
parsed = int(value.strip(), 10)
|
||||
else:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Ignoring invalid %s=%r (expected a positive integer; 0/null disables)",
|
||||
key,
|
||||
value,
|
||||
)
|
||||
return None
|
||||
if parsed <= 0:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def resolve_max_concurrent_sessions(config: Any) -> Optional[int]:
|
||||
"""Resolve top-level max_concurrent_sessions with gateway.* fallback."""
|
||||
raw: Any = None
|
||||
key = "max_concurrent_sessions"
|
||||
if isinstance(config, dict):
|
||||
if "max_concurrent_sessions" in config:
|
||||
raw = config.get("max_concurrent_sessions")
|
||||
else:
|
||||
gateway_cfg = config.get("gateway")
|
||||
if isinstance(gateway_cfg, dict):
|
||||
raw = gateway_cfg.get("max_concurrent_sessions")
|
||||
key = "gateway.max_concurrent_sessions"
|
||||
else:
|
||||
raw = getattr(config, "max_concurrent_sessions", None)
|
||||
return coerce_max_concurrent_sessions(raw, key=key)
|
||||
|
||||
|
||||
def active_session_limit_message(active_count: int, max_sessions: int) -> str:
|
||||
return (
|
||||
f"Hermes is at the active session limit ({active_count}/{max_sessions}). "
|
||||
"Try again when another session finishes."
|
||||
)
|
||||
|
||||
|
||||
def _state_dir() -> Path:
|
||||
return get_hermes_home() / "runtime"
|
||||
|
||||
|
||||
def _state_path() -> Path:
|
||||
return _state_dir() / "active_sessions.json"
|
||||
|
||||
|
||||
def _lock_path() -> Path:
|
||||
return _state_dir() / "active_sessions.lock"
|
||||
|
||||
|
||||
class _FileLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self._fh = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._fh = open(self.path, "a+b")
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import msvcrt
|
||||
|
||||
self._fh.seek(0)
|
||||
msvcrt.locking(self._fh.fileno(), msvcrt.LK_LOCK, 1)
|
||||
except Exception as exc:
|
||||
self._fh.close()
|
||||
self._fh = None
|
||||
raise RuntimeError("active session file lock unavailable") from exc
|
||||
else:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX)
|
||||
except Exception as exc:
|
||||
self._fh.close()
|
||||
self._fh = None
|
||||
raise RuntimeError("active session file lock unavailable") from exc
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
if self._fh is None:
|
||||
return
|
||||
if os.name == "nt":
|
||||
try:
|
||||
import msvcrt
|
||||
|
||||
self._fh.seek(0)
|
||||
msvcrt.locking(self._fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._fh.close()
|
||||
finally:
|
||||
self._fh = None
|
||||
|
||||
|
||||
def _read_entries(path: Path) -> list[dict[str, Any]]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
except Exception:
|
||||
logger.warning("Ignoring corrupt active session registry at %s", path)
|
||||
return []
|
||||
entries = data.get("entries") if isinstance(data, dict) else data
|
||||
if not isinstance(entries, list):
|
||||
return []
|
||||
return [entry for entry in entries if isinstance(entry, dict)]
|
||||
|
||||
|
||||
def _write_entries(path: Path, entries: list[dict[str, Any]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump({"entries": entries}, fh, sort_keys=True)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _process_start_time(pid: int) -> Optional[float]:
|
||||
# Pair pid with process create_time when psutil can read it, so a recycled
|
||||
# pid does not keep a stale lease alive indefinitely.
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
|
||||
return float(psutil.Process(pid).create_time())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _optional_float(value: Any) -> Optional[float]:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _pid_alive(pid: Any, process_start_time: Any = None) -> bool:
|
||||
try:
|
||||
pid_int = int(pid)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if pid_int <= 0:
|
||||
return False
|
||||
try:
|
||||
from gateway.status import _pid_exists
|
||||
|
||||
exists = bool(_pid_exists(pid_int))
|
||||
except Exception:
|
||||
return False
|
||||
if not exists:
|
||||
return False
|
||||
expected_start = _optional_float(process_start_time)
|
||||
if expected_start is None:
|
||||
return True
|
||||
current_start = _process_start_time(pid_int)
|
||||
if current_start is None:
|
||||
return True
|
||||
return abs(current_start - expected_start) < 0.001
|
||||
|
||||
|
||||
def _prune_dead(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
entry
|
||||
for entry in entries
|
||||
if _pid_alive(entry.get("pid"), entry.get("process_start_time"))
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActiveSessionLease:
|
||||
lease_id: str
|
||||
session_id: str
|
||||
surface: str
|
||||
enabled: bool = True
|
||||
released: bool = False
|
||||
|
||||
def release(self) -> None:
|
||||
if self.released or not self.enabled:
|
||||
return
|
||||
release_active_session(self)
|
||||
|
||||
|
||||
def try_acquire_active_session(
|
||||
*,
|
||||
session_id: str,
|
||||
surface: str,
|
||||
config: Any,
|
||||
metadata: Optional[dict[str, Any]] = None,
|
||||
) -> tuple[Optional[ActiveSessionLease], Optional[str]]:
|
||||
"""Acquire an active-session slot.
|
||||
|
||||
Returns ``(lease, None)`` on success. When the cap is disabled, the lease is
|
||||
a no-op object so callers can unconditionally call ``release()``.
|
||||
"""
|
||||
max_sessions = resolve_max_concurrent_sessions(config)
|
||||
lease_id = uuid.uuid4().hex
|
||||
if max_sessions is None:
|
||||
return ActiveSessionLease(
|
||||
lease_id=lease_id,
|
||||
session_id=session_id,
|
||||
surface=surface,
|
||||
enabled=False,
|
||||
), None
|
||||
|
||||
now = time.time()
|
||||
entry = {
|
||||
"lease_id": lease_id,
|
||||
"session_id": str(session_id),
|
||||
"surface": str(surface),
|
||||
"pid": os.getpid(),
|
||||
"process_start_time": _process_start_time(os.getpid()),
|
||||
"started_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if metadata:
|
||||
entry["metadata"] = {
|
||||
str(k): v for k, v in metadata.items() if isinstance(k, str)
|
||||
}
|
||||
|
||||
state_path = _state_path()
|
||||
with _FileLock(_lock_path()):
|
||||
raw_entries = _read_entries(state_path)
|
||||
entries = _prune_dead(raw_entries)
|
||||
pruned = len(raw_entries) - len(entries)
|
||||
if pruned:
|
||||
logger.info("Pruned %d stale active session lease(s)", pruned)
|
||||
active_count = len(entries)
|
||||
if active_count >= max_sessions:
|
||||
_write_entries(state_path, entries)
|
||||
logger.info(
|
||||
"Active session limit reached: active=%d max=%d surface=%s",
|
||||
active_count,
|
||||
max_sessions,
|
||||
surface,
|
||||
)
|
||||
return None, active_session_limit_message(active_count, max_sessions)
|
||||
entries.append(entry)
|
||||
_write_entries(state_path, entries)
|
||||
|
||||
return ActiveSessionLease(
|
||||
lease_id=lease_id,
|
||||
session_id=str(session_id),
|
||||
surface=str(surface),
|
||||
), None
|
||||
|
||||
|
||||
def release_active_session(lease: ActiveSessionLease) -> None:
|
||||
state_path = _state_path()
|
||||
try:
|
||||
with _FileLock(_lock_path()):
|
||||
entries = _prune_dead(_read_entries(state_path))
|
||||
kept = [
|
||||
entry
|
||||
for entry in entries
|
||||
if str(entry.get("lease_id") or "") != lease.lease_id
|
||||
]
|
||||
if len(kept) != len(entries):
|
||||
_write_entries(state_path, kept)
|
||||
finally:
|
||||
lease.released = True
|
||||
|
||||
|
||||
def active_session_registry_snapshot() -> list[dict[str, Any]]:
|
||||
"""Return the pruned active-session registry for diagnostics/tests."""
|
||||
state_path = _state_path()
|
||||
with _FileLock(_lock_path()):
|
||||
entries = _prune_dead(_read_entries(state_path))
|
||||
_write_entries(state_path, entries)
|
||||
return entries
|
||||
+91
-17
@@ -1182,6 +1182,24 @@ def _store_provider_state(
|
||||
auth_store["active_provider"] = provider_id
|
||||
|
||||
|
||||
def mark_provider_active_if_unset(provider_id: str) -> None:
|
||||
"""Set ``active_provider`` to *provider_id* only when none is set yet.
|
||||
|
||||
Used by ``hermes auth add`` OAuth paths that create credential-pool
|
||||
entries directly (no singleton ``providers.<id>`` block). Adding the
|
||||
very first credential for a provider should make it the active provider
|
||||
so the setup wizard's ``_model_section_has_credentials()`` check (which
|
||||
consults ``get_active_provider()``) does not report "No inference
|
||||
provider configured". Subsequent adds for an already-active setup leave
|
||||
the user's chosen active provider untouched.
|
||||
"""
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
if not (auth_store.get("active_provider") or "").strip():
|
||||
auth_store["active_provider"] = provider_id
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
def is_known_auth_provider(provider_id: str) -> bool:
|
||||
normalized = (provider_id or "").strip().lower()
|
||||
return normalized in PROVIDER_REGISTRY or normalized in SERVICE_PROVIDER_NAMES
|
||||
@@ -1561,6 +1579,21 @@ def resolve_provider(
|
||||
if has_usable_secret(os.getenv("OPENAI_API_KEY")) or has_usable_secret(os.getenv("OPENROUTER_API_KEY")):
|
||||
return "openrouter"
|
||||
|
||||
# Auto-detect an OpenRouter credential added via `hermes auth add openrouter`
|
||||
# (manual pool entry, no env var). Without this, a key that only lives in
|
||||
# the credential pool is invisible to auto-detection — the user sees
|
||||
# `hermes auth list` showing the credential while requests go out with no
|
||||
# Authorization header ("HTTP 401: Missing Authentication header"). The
|
||||
# env-var check above only covers keys exported as OPENROUTER_API_KEY /
|
||||
# OPENAI_API_KEY. See issue #42130.
|
||||
try:
|
||||
from agent.credential_pool import load_pool as _load_pool
|
||||
|
||||
if _load_pool("openrouter").has_credentials():
|
||||
return "openrouter"
|
||||
except Exception as e:
|
||||
logger.debug("Could not check OpenRouter credential pool: %s", e)
|
||||
|
||||
# Auto-detect API-key providers by checking their env vars
|
||||
for pid, pconfig in PROVIDER_REGISTRY.items():
|
||||
if pconfig.auth_type != "api_key":
|
||||
@@ -3340,6 +3373,7 @@ def _sync_codex_pool_entries(
|
||||
auth_store: Dict[str, Any],
|
||||
tokens: Dict[str, str],
|
||||
last_refresh: Optional[str],
|
||||
previous_singleton_tokens: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
"""Mirror a fresh Codex re-auth into the credential_pool OAuth entries.
|
||||
|
||||
@@ -3355,24 +3389,34 @@ def _sync_codex_pool_entries(
|
||||
OAuth flow when the user logged in via ``hermes setup`` / the model
|
||||
picker. Always synced with the fresh tokens.
|
||||
* ``manual:device_code`` — entries created by ``hermes auth add openai-codex``
|
||||
that use the same device-code OAuth mechanism. An interactive re-auth
|
||||
proves the user owns the ChatGPT account, so it is safe (and expected)
|
||||
to refresh these entries too. Without this, a user who once ran the
|
||||
``hermes auth add`` workaround for #33000 would silently leave that
|
||||
manual entry stale on every subsequent re-auth, recreating the issue
|
||||
reported in #33538.
|
||||
that use the same device-code OAuth mechanism. ONLY synced if the
|
||||
entry's existing access_token matches the *previous* singleton
|
||||
access_token (i.e. the entry is a legacy singleton-alias from the
|
||||
#33000 workaround era). Manual entries whose tokens never matched the
|
||||
singleton represent INDEPENDENT accounts added via
|
||||
``hermes auth add openai-codex`` and must not be overwritten by a
|
||||
re-auth that targeted a different account (regression for #39236).
|
||||
|
||||
The original #33538 fix refreshed every ``manual:device_code`` entry
|
||||
unconditionally. That worked when ``manual:device_code`` only meant
|
||||
"legacy alias of the singleton", but the same source string is now
|
||||
also produced by independent-account additions, and the broad sync
|
||||
silently clobbered distinct accounts with the latest-authenticated
|
||||
token pair. The access_token-match check distinguishes the two cases
|
||||
without changing the source-string contract.
|
||||
|
||||
What does NOT get refreshed:
|
||||
|
||||
* ``manual:api_key`` and any other non-device-code manual sources — those
|
||||
are independent credentials (an explicit API key, a different ChatGPT
|
||||
account, etc.) and must not be overwritten by a single re-auth.
|
||||
* ``manual:device_code`` entries whose access_token does NOT match the
|
||||
previous singleton — see above; these are independent accounts.
|
||||
|
||||
Error markers (``last_status``, ``last_error_*``) are also cleared on
|
||||
every device-code-backed entry — even those whose tokens we did not
|
||||
rewrite — so that an interactive re-auth gives every relevant pool entry
|
||||
a fresh selection chance instead of leaving them marked unhealthy from a
|
||||
pre-re-auth 401.
|
||||
Error markers (``last_status``, ``last_error_*``) are cleared ONLY on
|
||||
entries that actually had their tokens rewritten by this re-auth.
|
||||
Independent entries keep their own error state (their 401/429 markers
|
||||
belong to that account's own auth flow, not this re-auth).
|
||||
"""
|
||||
access_token = tokens.get("access_token")
|
||||
if not access_token:
|
||||
@@ -3384,15 +3428,34 @@ def _sync_codex_pool_entries(
|
||||
entries = pool.get("openai-codex")
|
||||
if not isinstance(entries, list):
|
||||
return
|
||||
# Sources whose tokens should be rewritten by a fresh Codex device-code
|
||||
# OAuth re-auth. ``manual:api_key`` and unknown sources are intentionally
|
||||
# excluded — they represent independent credentials.
|
||||
REFRESHABLE_SOURCES = {"device_code", "manual:device_code"}
|
||||
# Previous singleton access_token (before this re-auth overwrote it) —
|
||||
# used to distinguish legacy singleton-aliases from independent accounts.
|
||||
# When None or empty, no manual entry can be treated as an alias (which
|
||||
# is the right default for first-ever-save or a freshly initialized
|
||||
# auth.json).
|
||||
prev_at = None
|
||||
if isinstance(previous_singleton_tokens, dict):
|
||||
prev_at = previous_singleton_tokens.get("access_token") or None
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
source = entry.get("source")
|
||||
if source not in REFRESHABLE_SOURCES:
|
||||
if source == "device_code":
|
||||
# Singleton-seeded mirror — always refresh.
|
||||
refresh_this_entry = True
|
||||
elif source == "manual:device_code":
|
||||
# Refresh only if this entry's existing access_token matches the
|
||||
# previous singleton access_token (i.e. it is a true alias of the
|
||||
# singleton from the #33000 workaround era). An entry with its
|
||||
# own distinct token material is an independent account and must
|
||||
# be left alone (#39236).
|
||||
refresh_this_entry = bool(
|
||||
prev_at and entry.get("access_token") == prev_at
|
||||
)
|
||||
else:
|
||||
# ``manual:api_key`` and any future non-device-code sources.
|
||||
refresh_this_entry = False
|
||||
if not refresh_this_entry:
|
||||
continue
|
||||
entry["access_token"] = access_token
|
||||
if refresh_token:
|
||||
@@ -3414,13 +3477,24 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
|
||||
with _auth_store_lock():
|
||||
auth_store = _load_auth_store()
|
||||
state = _load_provider_state(auth_store, "openai-codex") or {}
|
||||
# Capture the previous singleton tokens BEFORE overwriting them. The
|
||||
# pool-sync step uses this to distinguish legacy singleton-aliases
|
||||
# (which should be refreshed) from independent accounts that
|
||||
# ``hermes auth add openai-codex`` created (which must not be
|
||||
# overwritten — see #39236).
|
||||
previous_singleton_tokens = state.get("tokens") if isinstance(state.get("tokens"), dict) else None
|
||||
state["tokens"] = tokens
|
||||
state["last_refresh"] = last_refresh
|
||||
state["auth_mode"] = "chatgpt"
|
||||
if label and str(label).strip():
|
||||
state["label"] = str(label).strip()
|
||||
_save_provider_state(auth_store, "openai-codex", state)
|
||||
_sync_codex_pool_entries(auth_store, tokens, last_refresh)
|
||||
_sync_codex_pool_entries(
|
||||
auth_store,
|
||||
tokens,
|
||||
last_refresh,
|
||||
previous_singleton_tokens=previous_singleton_tokens,
|
||||
)
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from agent.credential_pool import (
|
||||
AUTH_TYPE_OAUTH,
|
||||
CUSTOM_POOL_PREFIX,
|
||||
SOURCE_MANUAL,
|
||||
SOURCE_MANUAL_DEVICE_CODE,
|
||||
STATUS_EXHAUSTED,
|
||||
STRATEGY_FILL_FIRST,
|
||||
STRATEGY_ROUND_ROBIN,
|
||||
@@ -312,15 +313,35 @@ def auth_add_command(args) -> None:
|
||||
creds["tokens"]["access_token"],
|
||||
_oauth_default_label(provider, len(pool.entries()) + 1),
|
||||
)
|
||||
auth_mod._save_codex_tokens(
|
||||
creds["tokens"],
|
||||
last_refresh=creds.get("last_refresh"),
|
||||
# Add a distinct, self-contained pool entry per account (matching the
|
||||
# xai-oauth / google-gemini-cli / qwen-oauth patterns) instead of
|
||||
# routing through the singleton ``_save_codex_tokens`` save path.
|
||||
# The singleton round-trip collapsed every added account into the
|
||||
# latest login: a second ``hermes auth add openai-codex`` overwrote
|
||||
# the first account's singleton-mirrored ``device_code`` entry rather
|
||||
# than creating an independent one (#39236). ``manual:device_code``
|
||||
# entries refresh from their own token pair, so they need no singleton
|
||||
# shadow.
|
||||
entry = PooledCredential(
|
||||
provider=provider,
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label=label,
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=SOURCE_MANUAL_DEVICE_CODE,
|
||||
access_token=creds["tokens"]["access_token"],
|
||||
refresh_token=creds["tokens"].get("refresh_token"),
|
||||
base_url=creds.get("base_url"),
|
||||
last_refresh=creds.get("last_refresh"),
|
||||
)
|
||||
pool = load_pool(provider)
|
||||
entry = next((item for item in pool.entries() if item.source == "device_code"), None)
|
||||
shown_label = entry.label if entry is not None else label
|
||||
print(f'Saved {provider} OAuth device-code credentials: "{shown_label}"')
|
||||
first_credential = not pool.entries()
|
||||
pool.add_entry(entry)
|
||||
# Adding the first Codex credential should make it the active provider
|
||||
# (the old singleton save path did this implicitly via
|
||||
# _save_provider_state). Subsequent adds leave the active provider as-is.
|
||||
if first_credential:
|
||||
auth_mod.mark_provider_active_if_unset(provider)
|
||||
print(f'Added {provider} OAuth credential #{len(pool.entries())}: "{entry.label}"')
|
||||
return
|
||||
|
||||
if provider == "xai-oauth":
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
"""Agent-construction and session-resume display methods for ``HermesCLI``.
|
||||
|
||||
Extracted from ``cli.py`` as part of the god-file decomposition campaign
|
||||
(``~/.hermes/plans/god-file-decomposition.md``, Phase 4 step 2). This mixin holds
|
||||
the agent lifecycle/setup cluster: runtime-credential resolution, per-turn agent
|
||||
config, first-use agent construction, and resumed-session preload + history recap.
|
||||
|
||||
Behavior-neutral: every method is lifted verbatim from ``HermesCLI``. ``self.*``
|
||||
calls resolve unchanged via the MRO. Neutral dependencies are imported at module
|
||||
top level; ``cli.py``-internal helpers/constants are imported lazily inside each
|
||||
method (``from cli import ...`` resolves at call time, when ``cli`` is fully
|
||||
loaded) so this module never imports ``cli`` at import time -> no import cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from rich.markup import escape as _escape
|
||||
|
||||
|
||||
class CLIAgentSetupMixin:
|
||||
"""Agent construction + session-resume display methods for ``HermesCLI``."""
|
||||
|
||||
def _ensure_runtime_credentials(self) -> bool:
|
||||
"""
|
||||
Ensure runtime credentials are resolved before agent use.
|
||||
Re-resolves provider credentials so key rotation and token refresh
|
||||
are picked up without restarting the CLI.
|
||||
Returns True if credentials are ready, False on auth failure.
|
||||
"""
|
||||
from cli import ChatConsole, _cprint, logger
|
||||
from hermes_cli.runtime_provider import (
|
||||
resolve_runtime_provider,
|
||||
format_runtime_provider_error,
|
||||
)
|
||||
|
||||
_primary_exc = None
|
||||
runtime = None
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
requested=self.requested_provider,
|
||||
explicit_api_key=self._explicit_api_key,
|
||||
explicit_base_url=self._explicit_base_url,
|
||||
)
|
||||
except Exception as exc:
|
||||
_primary_exc = exc
|
||||
|
||||
# Primary provider auth failed — try fallback providers before giving up.
|
||||
if runtime is None and _primary_exc is not None:
|
||||
from hermes_cli.auth import AuthError
|
||||
if isinstance(_primary_exc, AuthError):
|
||||
_fb_chain = self._fallback_model if isinstance(self._fallback_model, list) else []
|
||||
for _fb in _fb_chain:
|
||||
_fb_provider = (_fb.get("provider") or "").strip().lower()
|
||||
_fb_model = (_fb.get("model") or "").strip()
|
||||
if not _fb_provider or not _fb_model:
|
||||
continue
|
||||
try:
|
||||
runtime = resolve_runtime_provider(requested=_fb_provider)
|
||||
logger.warning(
|
||||
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
|
||||
_primary_exc, _fb_provider, _fb_model,
|
||||
)
|
||||
_cprint(f"⚠️ Primary auth failed — switching to fallback: {_fb_provider} / {_fb_model}")
|
||||
self.requested_provider = _fb_provider
|
||||
self.model = _fb_model
|
||||
_primary_exc = None
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if runtime is None:
|
||||
message = format_runtime_provider_error(_primary_exc) if _primary_exc else "Provider resolution failed."
|
||||
ChatConsole().print(f"[bold red]{message}[/]")
|
||||
return False
|
||||
|
||||
api_key = runtime.get("api_key")
|
||||
base_url = runtime.get("base_url")
|
||||
resolved_provider = runtime.get("provider", "openrouter")
|
||||
resolved_api_mode = runtime.get("api_mode", self.api_mode)
|
||||
resolved_acp_command = runtime.get("command")
|
||||
resolved_acp_args = list(runtime.get("args") or [])
|
||||
resolved_credential_pool = runtime.get("credential_pool")
|
||||
# A callable api_key is a bearer-token provider (Azure Foundry
|
||||
# Entra ID — ``azure_identity_adapter.build_token_provider``).
|
||||
# The OpenAI SDK accepts ``Callable[[], str]`` for ``api_key`` and
|
||||
# invokes it before every request. Skip the string-only validation
|
||||
# and placeholder substitution for callables.
|
||||
_is_callable_provider = callable(api_key) and not isinstance(api_key, str)
|
||||
if not _is_callable_provider and (not isinstance(api_key, str) or not api_key):
|
||||
# Custom / local endpoints (llama.cpp, ollama, vLLM, etc.) often
|
||||
# don't require authentication. When a base_url IS configured but
|
||||
# no API key was found, use a placeholder so the OpenAI SDK
|
||||
# doesn't reject the request and local servers just ignore it.
|
||||
_source = runtime.get("source", "")
|
||||
_has_custom_base = isinstance(base_url, str) and base_url and "openrouter.ai" not in base_url
|
||||
if _has_custom_base:
|
||||
api_key = "no-key-required"
|
||||
logger.debug(
|
||||
"No API key for custom endpoint %s (source=%s), "
|
||||
"using placeholder — local servers typically ignore auth",
|
||||
base_url, _source,
|
||||
)
|
||||
else:
|
||||
print("\n⚠️ Provider resolver returned an empty API key. "
|
||||
"Set OPENROUTER_API_KEY or run: hermes setup")
|
||||
return False
|
||||
if not isinstance(base_url, str) or not base_url:
|
||||
print("\n⚠️ Provider resolver returned an empty base URL. "
|
||||
"Check your provider config or run: hermes setup")
|
||||
return False
|
||||
|
||||
credentials_changed = api_key != self.api_key or base_url != self.base_url
|
||||
routing_changed = (
|
||||
resolved_provider != self.provider
|
||||
or resolved_api_mode != self.api_mode
|
||||
or resolved_acp_command != self.acp_command
|
||||
or resolved_acp_args != self.acp_args
|
||||
)
|
||||
self.provider = resolved_provider
|
||||
self.api_mode = resolved_api_mode
|
||||
self.acp_command = resolved_acp_command
|
||||
self.acp_args = resolved_acp_args
|
||||
self._credential_pool = resolved_credential_pool
|
||||
self._provider_source = runtime.get("source")
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
# When a custom_provider entry carries an explicit `model` field,
|
||||
# use it as the effective model name. Without this, running
|
||||
# `hermes chat --model <provider-name>` sends the provider name
|
||||
# (e.g. "my-provider") as the model string to the API instead of
|
||||
# the configured model (e.g. "qwen3.6-plus"), causing 400 errors.
|
||||
runtime_model = runtime.get("model")
|
||||
if runtime_model and isinstance(runtime_model, str):
|
||||
# Only use runtime model if: model is unset, or model equals provider name
|
||||
should_use_runtime_model = (
|
||||
not self.model or # No model configured yet
|
||||
self.model == self.provider or # Model is the provider slug
|
||||
self.model == runtime.get("name") # Model matches provider display name
|
||||
)
|
||||
if should_use_runtime_model:
|
||||
self.model = runtime_model
|
||||
|
||||
# If model is still empty (e.g. user ran `hermes auth add openai-codex`
|
||||
# without `hermes model`), fall back to the provider's first catalog
|
||||
# model so the API call doesn't fail with "model must be non-empty".
|
||||
if not self.model and resolved_provider:
|
||||
try:
|
||||
from hermes_cli.models import get_default_model_for_provider
|
||||
_default = get_default_model_for_provider(resolved_provider)
|
||||
if _default:
|
||||
self.model = _default
|
||||
logger.info(
|
||||
"No model configured — defaulting to %s for provider %s",
|
||||
_default, resolved_provider,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Normalize model for the resolved provider (e.g. swap non-Codex
|
||||
# models when provider is openai-codex). Fixes #651.
|
||||
model_changed = self._normalize_model_for_provider(resolved_provider)
|
||||
|
||||
# AIAgent/OpenAI client holds auth at init time, so rebuild if key,
|
||||
# routing, or the effective model changed.
|
||||
if (credentials_changed or routing_changed or model_changed) and self.agent is not None:
|
||||
self.agent = None
|
||||
self._active_agent_route_signature = None
|
||||
|
||||
return True
|
||||
|
||||
def _resolve_turn_agent_config(self, user_message: str) -> dict:
|
||||
"""Build the effective model/runtime config for a single user turn.
|
||||
|
||||
Always uses the session's primary model/provider. If the user has
|
||||
toggled `/fast` on and the current model supports Priority
|
||||
Processing / Anthropic fast mode, attach `request_overrides` so the
|
||||
API call is marked accordingly.
|
||||
"""
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
|
||||
runtime = {
|
||||
"api_key": self.api_key,
|
||||
"base_url": self.base_url,
|
||||
"provider": self.provider,
|
||||
"api_mode": self.api_mode,
|
||||
"command": self.acp_command,
|
||||
"args": list(self.acp_args or []),
|
||||
"credential_pool": getattr(self, "_credential_pool", None),
|
||||
}
|
||||
route = {
|
||||
"model": self.model,
|
||||
"runtime": runtime,
|
||||
"signature": (
|
||||
self.model,
|
||||
runtime["provider"],
|
||||
runtime["base_url"],
|
||||
runtime["api_mode"],
|
||||
runtime["command"],
|
||||
tuple(runtime["args"]),
|
||||
),
|
||||
}
|
||||
|
||||
service_tier = getattr(self, "service_tier", None)
|
||||
if not service_tier:
|
||||
route["request_overrides"] = None
|
||||
return route
|
||||
|
||||
try:
|
||||
overrides = resolve_fast_mode_overrides(route["model"])
|
||||
except Exception:
|
||||
overrides = None
|
||||
route["request_overrides"] = overrides
|
||||
return route
|
||||
|
||||
def _init_agent(self, *, model_override: str = None, runtime_override: dict = None, request_overrides: dict | None = None) -> bool:
|
||||
"""
|
||||
Initialize the agent on first use.
|
||||
When resuming a session, restores conversation history from SQLite.
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
from cli import AIAgent, ChatConsole, _DIM, _RST, _accent_hex, _cprint, _prepare_deferred_agent_startup, logger
|
||||
if self.agent is not None:
|
||||
return True
|
||||
|
||||
_prepare_deferred_agent_startup()
|
||||
self._install_tool_callbacks()
|
||||
self._ensure_tirith_security()
|
||||
|
||||
if not self._ensure_runtime_credentials():
|
||||
return False
|
||||
|
||||
from hermes_cli.mcp_startup import wait_for_mcp_discovery
|
||||
|
||||
wait_for_mcp_discovery()
|
||||
|
||||
# Initialize SQLite session store for CLI sessions (if not already done in __init__)
|
||||
if self._session_db is None:
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
self._session_db = SessionDB()
|
||||
except Exception as e:
|
||||
logger.warning("SQLite session store not available — session will NOT be indexed: %s", e)
|
||||
|
||||
# If resuming, validate the session exists and load its history.
|
||||
# _preload_resumed_session() may have already loaded it (called from
|
||||
# run() for immediate display). In that case, conversation_history
|
||||
# is non-empty and we skip the DB round-trip.
|
||||
if self._resumed and self._session_db and not self.conversation_history:
|
||||
session_meta = self._session_db.get_session(self.session_id)
|
||||
# In quiet mode (`hermes chat -Q` / --quiet, surfaced via
|
||||
# tool_progress_mode == "off"), resume status lines go to stderr
|
||||
# so stdout stays machine-readable for automation wrappers that
|
||||
# do `$(hermes chat -Q --resume <id> -q "...")`. Without this,
|
||||
# the resume banner pollutes captured stdout. See #11793.
|
||||
_quiet_mode = getattr(self, "tool_progress_mode", "full") == "off"
|
||||
if not session_meta:
|
||||
if _quiet_mode:
|
||||
print(f"Session not found: {self.session_id}", file=sys.stderr)
|
||||
print(
|
||||
"Use a session ID from a previous CLI run (hermes sessions list).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
_cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
|
||||
_cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}")
|
||||
return False
|
||||
# If the requested session is the (empty) head of a compression
|
||||
# chain, walk to the descendant that actually holds the messages.
|
||||
# See #15000 and SessionDB.resolve_resume_session_id.
|
||||
try:
|
||||
resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
|
||||
except Exception:
|
||||
resolved_id = self.session_id
|
||||
if resolved_id and resolved_id != self.session_id:
|
||||
ChatConsole().print(
|
||||
f"[dim]Session {_escape(self.session_id)} was compressed into "
|
||||
f"{_escape(resolved_id)}; resuming the descendant with your "
|
||||
f"transcript.[/dim]"
|
||||
)
|
||||
self.session_id = resolved_id
|
||||
resolved_meta = self._session_db.get_session(self.session_id)
|
||||
if resolved_meta:
|
||||
session_meta = resolved_meta
|
||||
restored = self._session_db.get_messages_as_conversation(self.session_id)
|
||||
if restored:
|
||||
restored = [m for m in restored if m.get("role") != "session_meta"]
|
||||
self.conversation_history = restored
|
||||
msg_count = len([m for m in restored if m.get("role") == "user"])
|
||||
title_part = ""
|
||||
if session_meta.get("title"):
|
||||
title_part = f" \"{session_meta['title']}\""
|
||||
if _quiet_mode:
|
||||
print(
|
||||
f"↻ Resumed session {self.session_id}{title_part} "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
||||
f"{len(restored)} total messages)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
ChatConsole().print(
|
||||
f"[bold {_accent_hex()}]↻ Resumed session[/] "
|
||||
f"[bold]{_escape(self.session_id)}[/]"
|
||||
f"[bold {_accent_hex()}]{_escape(title_part)}[/] "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)"
|
||||
)
|
||||
self._restore_session_cwd(session_meta, quiet=_quiet_mode)
|
||||
else:
|
||||
if _quiet_mode:
|
||||
print(
|
||||
f"Session {self.session_id} found but has no messages. Starting fresh.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
ChatConsole().print(
|
||||
f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]"
|
||||
)
|
||||
# Re-open the session (clear ended_at so it's active again)
|
||||
try:
|
||||
self._session_db._conn.execute(
|
||||
"UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?",
|
||||
(self.session_id,),
|
||||
)
|
||||
self._session_db._conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
runtime = runtime_override or {
|
||||
"api_key": self.api_key,
|
||||
"base_url": self.base_url,
|
||||
"provider": self.provider,
|
||||
"api_mode": self.api_mode,
|
||||
"command": self.acp_command,
|
||||
"args": list(self.acp_args or []),
|
||||
"credential_pool": getattr(self, "_credential_pool", None),
|
||||
}
|
||||
effective_model = model_override or self.model
|
||||
self.agent = AIAgent(
|
||||
model=effective_model,
|
||||
api_key=runtime.get("api_key"),
|
||||
base_url=runtime.get("base_url"),
|
||||
provider=runtime.get("provider"),
|
||||
api_mode=runtime.get("api_mode"),
|
||||
acp_command=runtime.get("command"),
|
||||
acp_args=runtime.get("args"),
|
||||
credential_pool=runtime.get("credential_pool"),
|
||||
max_tokens=self.max_tokens,
|
||||
max_iterations=self.max_turns,
|
||||
enabled_toolsets=self.enabled_toolsets,
|
||||
disabled_toolsets=self.disabled_toolsets,
|
||||
verbose_logging=self.verbose,
|
||||
quiet_mode=not self.verbose,
|
||||
tool_progress_mode=getattr(self, "tool_progress_mode", "all"),
|
||||
ephemeral_system_prompt=self.system_prompt if self.system_prompt else None,
|
||||
prefill_messages=self.prefill_messages or None,
|
||||
reasoning_config=self.reasoning_config,
|
||||
service_tier=self.service_tier,
|
||||
request_overrides=request_overrides,
|
||||
providers_allowed=self._providers_only,
|
||||
providers_ignored=self._providers_ignore,
|
||||
providers_order=self._providers_order,
|
||||
provider_sort=self._provider_sort,
|
||||
provider_require_parameters=self._provider_require_params,
|
||||
provider_data_collection=self._provider_data_collection,
|
||||
openrouter_min_coding_score=self._openrouter_min_coding_score,
|
||||
session_id=self.session_id,
|
||||
platform="cli",
|
||||
session_db=self._session_db,
|
||||
clarify_callback=self._clarify_callback,
|
||||
reasoning_callback=self._current_reasoning_callback(),
|
||||
|
||||
fallback_model=self._fallback_model,
|
||||
thinking_callback=self._on_thinking,
|
||||
checkpoints_enabled=self.checkpoints_enabled,
|
||||
checkpoint_max_snapshots=self.checkpoint_max_snapshots,
|
||||
checkpoint_max_total_size_mb=self.checkpoint_max_total_size_mb,
|
||||
checkpoint_max_file_size_mb=self.checkpoint_max_file_size_mb,
|
||||
pass_session_id=self.pass_session_id,
|
||||
skip_context_files=self.ignore_rules,
|
||||
skip_memory=self.ignore_rules,
|
||||
tool_progress_callback=self._on_tool_progress,
|
||||
tool_start_callback=self._on_tool_start if self._inline_diffs_enabled else None,
|
||||
tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None,
|
||||
stream_delta_callback=self._stream_delta if self.streaming_enabled else None,
|
||||
tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None,
|
||||
notice_callback=self._on_notice,
|
||||
notice_clear_callback=self._on_notice_clear,
|
||||
)
|
||||
# Store reference for atexit memory provider shutdown
|
||||
global _active_agent_ref
|
||||
_active_agent_ref = self.agent
|
||||
# Route agent status output through prompt_toolkit so ANSI escape
|
||||
# sequences aren't garbled by patch_stdout's StdoutProxy (#2262).
|
||||
self.agent._print_fn = _cprint
|
||||
# Hydrate credits notices at session OPEN (parity with the TUI), so a
|
||||
# depletion / usage-band warning shows before the first message. The
|
||||
# notice_callback is bound above → _on_notice renders the line. Idempotent
|
||||
# + fail-open inside the helper; harmless for non-Nous providers.
|
||||
try:
|
||||
from agent.credits_tracker import seed_credits_at_session_start
|
||||
|
||||
seed_credits_at_session_start(self.agent)
|
||||
except Exception:
|
||||
pass
|
||||
self._active_agent_route_signature = (
|
||||
effective_model,
|
||||
runtime.get("provider"),
|
||||
runtime.get("base_url"),
|
||||
runtime.get("api_mode"),
|
||||
runtime.get("command"),
|
||||
tuple(runtime.get("args") or ()),
|
||||
)
|
||||
|
||||
# Force-create DB row on /title intent, then apply title.
|
||||
if self._pending_title and self._session_db and self.agent:
|
||||
try:
|
||||
self.agent._ensure_db_session()
|
||||
if self.agent._session_db_created:
|
||||
self._session_db.set_session_title(self.session_id, self._pending_title)
|
||||
_cprint(f" Session title applied: {self._pending_title}")
|
||||
self._pending_title = None
|
||||
# else: row creation failed transiently — keep _pending_title for retry
|
||||
except (ValueError, Exception) as e:
|
||||
_cprint(f" Could not apply pending title: {e}")
|
||||
# Keep _pending_title so it can be retried after row creation succeeds
|
||||
return True
|
||||
except Exception as e:
|
||||
ChatConsole().print(f"[bold red]Failed to initialize agent: {e}[/]")
|
||||
return False
|
||||
|
||||
def _preload_resumed_session(self) -> bool:
|
||||
"""Load a resumed session's history from the DB early (before first chat).
|
||||
|
||||
Called from run() so the conversation history is available for display
|
||||
before the user sends their first message. Sets
|
||||
``self.conversation_history`` and prints the one-liner status. Returns
|
||||
True if history was loaded, False otherwise.
|
||||
|
||||
The corresponding block in ``_init_agent()`` checks whether history is
|
||||
already populated and skips the DB round-trip.
|
||||
"""
|
||||
from cli import _accent_hex
|
||||
if not self._resumed or not self._session_db:
|
||||
return False
|
||||
|
||||
session_meta = self._session_db.get_session(self.session_id)
|
||||
if not session_meta:
|
||||
self._console_print(
|
||||
f"[bold red]Session not found: {self.session_id}[/]"
|
||||
)
|
||||
self._console_print(
|
||||
"[dim]Use a session ID from a previous CLI run "
|
||||
"(hermes sessions list).[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
# If the requested session is the (empty) head of a compression chain,
|
||||
# walk to the descendant that actually holds the messages. See #15000.
|
||||
try:
|
||||
resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
|
||||
except Exception:
|
||||
resolved_id = self.session_id
|
||||
if resolved_id and resolved_id != self.session_id:
|
||||
self._console_print(
|
||||
f"[dim]Session {self.session_id} was compressed into "
|
||||
f"{resolved_id}; resuming the descendant with your transcript.[/]"
|
||||
)
|
||||
self.session_id = resolved_id
|
||||
resolved_meta = self._session_db.get_session(self.session_id)
|
||||
if resolved_meta:
|
||||
session_meta = resolved_meta
|
||||
|
||||
restored = self._session_db.get_messages_as_conversation(self.session_id)
|
||||
if restored:
|
||||
restored = [m for m in restored if m.get("role") != "session_meta"]
|
||||
self.conversation_history = restored
|
||||
msg_count = len([m for m in restored if m.get("role") == "user"])
|
||||
title_part = ""
|
||||
if session_meta.get("title"):
|
||||
title_part = f' "{session_meta["title"]}"'
|
||||
accent_color = _accent_hex()
|
||||
self._console_print(
|
||||
f"[{accent_color}]↻ Resumed session [bold]{self.session_id}[/bold]"
|
||||
f"{title_part} "
|
||||
f"({msg_count} user message{'s' if msg_count != 1 else ''}, "
|
||||
f"{len(restored)} total messages)[/]"
|
||||
)
|
||||
self._restore_session_cwd(session_meta)
|
||||
else:
|
||||
accent_color = _accent_hex()
|
||||
self._console_print(
|
||||
f"[{accent_color}]Session {self.session_id} found but has no "
|
||||
f"messages. Starting fresh.[/]"
|
||||
)
|
||||
return False
|
||||
|
||||
# Re-open the session (clear ended_at so it's active again)
|
||||
try:
|
||||
self._session_db._conn.execute(
|
||||
"UPDATE sessions SET ended_at = NULL, end_reason = NULL "
|
||||
"WHERE id = ?",
|
||||
(self.session_id,),
|
||||
)
|
||||
self._session_db._conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
def _display_resumed_history(self):
|
||||
"""Render a compact recap of previous conversation messages.
|
||||
|
||||
Uses Rich markup with dim/muted styling so the recap is visually
|
||||
distinct from the active conversation. Caps the display at the
|
||||
last ``MAX_DISPLAY_EXCHANGES`` user/assistant exchanges and shows
|
||||
an indicator for earlier hidden messages.
|
||||
"""
|
||||
from cli import CLI_CONFIG, _record_output_history_entry, _strip_reasoning_tags, _suspend_output_history
|
||||
if not self.conversation_history:
|
||||
return
|
||||
|
||||
# Check config: resume_display setting
|
||||
if self.resume_display == "minimal":
|
||||
return
|
||||
|
||||
# Read limits from config (with hardcoded defaults)
|
||||
_disp = CLI_CONFIG.get("display", {})
|
||||
MAX_DISPLAY_EXCHANGES = int(_disp.get("resume_exchanges", 10))
|
||||
MAX_USER_LEN = int(_disp.get("resume_max_user_chars", 300))
|
||||
MAX_ASST_LEN = int(_disp.get("resume_max_assistant_chars", 200))
|
||||
MAX_ASST_LINES = int(_disp.get("resume_max_assistant_lines", 3))
|
||||
SKIP_TOOL_ONLY = _disp.get("resume_skip_tool_only", True)
|
||||
|
||||
# Collect displayable entries (skip system, tool-result messages)
|
||||
entries = [] # list of (role, display_text)
|
||||
_last_asst_idx = None # index of last assistant entry
|
||||
_last_asst_full = None # un-truncated display text for last assistant
|
||||
for msg in self.conversation_history:
|
||||
role = msg.get("role", "")
|
||||
content = msg.get("content")
|
||||
tool_calls = msg.get("tool_calls") or []
|
||||
|
||||
if role == "system":
|
||||
continue
|
||||
if role == "tool":
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
text = "" if content is None else str(content)
|
||||
# Handle multimodal content (list of dicts)
|
||||
if isinstance(content, list):
|
||||
parts = []
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "text":
|
||||
parts.append(part.get("text", ""))
|
||||
elif isinstance(part, dict) and part.get("type") == "image_url":
|
||||
parts.append("[image]")
|
||||
text = " ".join(parts)
|
||||
if len(text) > MAX_USER_LEN:
|
||||
text = text[:MAX_USER_LEN] + "..."
|
||||
entries.append(("user", text))
|
||||
|
||||
elif role == "assistant":
|
||||
text = "" if content is None else str(content)
|
||||
text = _strip_reasoning_tags(text)
|
||||
parts = []
|
||||
full_parts = [] # un-truncated version
|
||||
if text:
|
||||
full_parts.append(text)
|
||||
lines = text.splitlines()
|
||||
if len(lines) > MAX_ASST_LINES:
|
||||
text = "\n".join(lines[:MAX_ASST_LINES]) + " ..."
|
||||
if len(text) > MAX_ASST_LEN:
|
||||
text = text[:MAX_ASST_LEN] + "..."
|
||||
parts.append(text)
|
||||
if tool_calls:
|
||||
tc_count = len(tool_calls)
|
||||
# Extract tool names
|
||||
names = []
|
||||
for tc in tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
name = fn.get("name", "unknown") if isinstance(fn, dict) else "unknown"
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
names_str = ", ".join(names[:4])
|
||||
if len(names) > 4:
|
||||
names_str += ", ..."
|
||||
noun = "call" if tc_count == 1 else "calls"
|
||||
tc_summary = f"[{tc_count} tool {noun}: {names_str}]"
|
||||
parts.append(tc_summary)
|
||||
full_parts.append(tc_summary)
|
||||
if not parts:
|
||||
# Skip pure-reasoning messages that have no visible output
|
||||
continue
|
||||
# Skip tool-call-only entries when SKIP_TOOL_ONLY is enabled
|
||||
has_text = bool(text)
|
||||
if SKIP_TOOL_ONLY and not has_text and tool_calls:
|
||||
continue
|
||||
entries.append(("assistant", " ".join(parts)))
|
||||
_last_asst_idx = len(entries) - 1
|
||||
_last_asst_full = " ".join(full_parts)
|
||||
|
||||
if not entries:
|
||||
return
|
||||
|
||||
# Determine if we need to truncate
|
||||
skipped = 0
|
||||
if len(entries) > MAX_DISPLAY_EXCHANGES * 2:
|
||||
skipped = len(entries) - MAX_DISPLAY_EXCHANGES * 2
|
||||
entries = entries[skipped:]
|
||||
|
||||
# Replace last assistant entry with full (un-truncated) text
|
||||
# so the user can see where they left off without wasting tokens.
|
||||
if _last_asst_idx is not None and _last_asst_full:
|
||||
adj_idx = _last_asst_idx - skipped
|
||||
if 0 <= adj_idx < len(entries):
|
||||
entries[adj_idx] = ("assistant_last", _last_asst_full)
|
||||
|
||||
# Build the display using Rich
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
|
||||
try:
|
||||
from hermes_cli.skin_engine import get_active_skin
|
||||
_skin = get_active_skin()
|
||||
_history_text_c = _skin.get_color("banner_text", "#FFF8DC")
|
||||
_session_label_c = _skin.get_color("session_label", "#DAA520")
|
||||
_session_border_c = _skin.get_color("session_border", "#8B8682")
|
||||
_assistant_label_c = _skin.get_color("ui_ok", "#8FBC8F")
|
||||
except Exception:
|
||||
_history_text_c = "#FFF8DC"
|
||||
_session_label_c = "#DAA520"
|
||||
_session_border_c = "#8B8682"
|
||||
_assistant_label_c = "#8FBC8F"
|
||||
|
||||
lines = Text()
|
||||
if skipped:
|
||||
lines.append(
|
||||
f" ... {skipped} earlier messages ...\n\n",
|
||||
style="dim italic",
|
||||
)
|
||||
|
||||
for i, (role, text) in enumerate(entries):
|
||||
if role == "user":
|
||||
lines.append(" ● You: ", style=f"dim bold {_session_label_c}")
|
||||
# Show first line inline, indent rest
|
||||
msg_lines = text.splitlines()
|
||||
lines.append(msg_lines[0] + "\n", style="dim")
|
||||
for ml in msg_lines[1:]:
|
||||
lines.append(f" {ml}\n", style="dim")
|
||||
elif role == "assistant_last":
|
||||
# Last assistant response shown in full, non-dim
|
||||
lines.append(" ◆ Hermes: ", style=f"bold {_assistant_label_c}")
|
||||
msg_lines = text.splitlines()
|
||||
lines.append(msg_lines[0] + "\n", style="")
|
||||
for ml in msg_lines[1:]:
|
||||
lines.append(f" {ml}\n", style="")
|
||||
else:
|
||||
lines.append(" ◆ Hermes: ", style=f"dim bold {_assistant_label_c}")
|
||||
msg_lines = text.splitlines()
|
||||
lines.append(msg_lines[0] + "\n", style="dim")
|
||||
for ml in msg_lines[1:]:
|
||||
lines.append(f" {ml}\n", style="dim")
|
||||
if i < len(entries) - 1:
|
||||
lines.append("") # small gap
|
||||
|
||||
panel = Panel(
|
||||
lines,
|
||||
title=f"[dim {_session_label_c}]Previous Conversation[/]",
|
||||
border_style=f"dim {_session_border_c}",
|
||||
padding=(0, 1),
|
||||
style=_history_text_c,
|
||||
)
|
||||
_record_output_history_entry(lambda: self._render_resume_history_panel_lines(panel))
|
||||
with _suspend_output_history():
|
||||
self._console_print(panel)
|
||||
+95
-30
@@ -13,6 +13,7 @@ This module provides:
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
@@ -805,6 +806,9 @@ DEFAULT_CONFIG = {
|
||||
"fallback_providers": [],
|
||||
"credential_pool_strategies": {},
|
||||
"toolsets": ["hermes-cli"],
|
||||
# Global active chat session cap across CLI, TUI/dashboard, and messaging.
|
||||
# None/0 = unbounded.
|
||||
"max_concurrent_sessions": None,
|
||||
"agent": {
|
||||
"max_turns": 90,
|
||||
# Inactivity timeout for gateway agent execution (seconds).
|
||||
@@ -5149,6 +5153,94 @@ def load_config_readonly() -> Dict[str, Any]:
|
||||
return _load_config_impl(want_deepcopy=False)
|
||||
|
||||
|
||||
TERMINAL_CONFIG_ENV_MAP = {
|
||||
"backend": "TERMINAL_ENV",
|
||||
"modal_mode": "TERMINAL_MODAL_MODE",
|
||||
"cwd": "TERMINAL_CWD",
|
||||
"timeout": "TERMINAL_TIMEOUT",
|
||||
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
|
||||
"docker_image": "TERMINAL_DOCKER_IMAGE",
|
||||
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
|
||||
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||
"modal_image": "TERMINAL_MODAL_IMAGE",
|
||||
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
|
||||
"ssh_host": "TERMINAL_SSH_HOST",
|
||||
"ssh_user": "TERMINAL_SSH_USER",
|
||||
"ssh_port": "TERMINAL_SSH_PORT",
|
||||
"ssh_key": "TERMINAL_SSH_KEY",
|
||||
"container_cpu": "TERMINAL_CONTAINER_CPU",
|
||||
"container_memory": "TERMINAL_CONTAINER_MEMORY",
|
||||
"container_disk": "TERMINAL_CONTAINER_DISK",
|
||||
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
|
||||
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
|
||||
"docker_env": "TERMINAL_DOCKER_ENV",
|
||||
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
|
||||
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||||
}
|
||||
|
||||
|
||||
def _terminal_env_value(value: Any) -> str:
|
||||
if isinstance(value, (list, dict)):
|
||||
return json.dumps(value)
|
||||
return str(value)
|
||||
|
||||
|
||||
def terminal_config_env_var_for_key(key: str) -> Optional[str]:
|
||||
"""Return the env var mirrored by a ``terminal.*`` config key."""
|
||||
prefix = "terminal."
|
||||
if not key.startswith(prefix):
|
||||
return None
|
||||
return TERMINAL_CONFIG_ENV_MAP.get(key[len(prefix):])
|
||||
|
||||
|
||||
def apply_terminal_config_to_env(
|
||||
*,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
override: Optional[bool] = None,
|
||||
) -> Dict[str, str]:
|
||||
"""Bridge ``terminal.*`` config into the env vars terminal tools read.
|
||||
|
||||
``tools.terminal_tool`` is intentionally environment-driven because it also
|
||||
runs in child processes (TUI, dashboard PTY, gateway workers). This helper
|
||||
gives those child-process launch paths the same config bridge as classic
|
||||
CLI without importing ``cli.py`` and paying for its startup side effects.
|
||||
|
||||
When the user config contains a ``terminal`` section, config.yaml is
|
||||
authoritative and overrides existing env values. Otherwise defaults only
|
||||
backfill missing env vars so exported/.env values keep working.
|
||||
"""
|
||||
target = os.environ if env is None else env
|
||||
|
||||
raw_config = read_raw_config()
|
||||
file_has_terminal_config = isinstance(raw_config.get("terminal"), dict)
|
||||
should_override = file_has_terminal_config if override is None else override
|
||||
|
||||
cfg = config if config is not None else load_config_readonly()
|
||||
terminal_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {}
|
||||
if not isinstance(terminal_cfg, dict):
|
||||
return target
|
||||
|
||||
for cfg_key, env_var in TERMINAL_CONFIG_ENV_MAP.items():
|
||||
if cfg_key not in terminal_cfg:
|
||||
continue
|
||||
value = terminal_cfg[cfg_key]
|
||||
if cfg_key == "cwd":
|
||||
raw_cwd = str(value or "").strip()
|
||||
if raw_cwd in {".", "auto", "cwd"}:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
value = os.path.expanduser(value)
|
||||
if should_override or env_var not in target:
|
||||
target[env_var] = _terminal_env_value(value)
|
||||
return target
|
||||
|
||||
|
||||
def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
|
||||
with _CONFIG_LOCK:
|
||||
ensure_hermes_home()
|
||||
@@ -6037,36 +6129,9 @@ def set_config_value(key: str, value: str):
|
||||
|
||||
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
|
||||
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
|
||||
_config_to_env_sync = {
|
||||
"terminal.backend": "TERMINAL_ENV",
|
||||
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
|
||||
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
|
||||
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
|
||||
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
|
||||
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
|
||||
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
|
||||
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
|
||||
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
|
||||
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
|
||||
# JSON-valued keys (terminal_tool parses these via json.loads). The user
|
||||
# passes JSON on the CLI, so str(value) below already yields valid JSON —
|
||||
# same as terminal.docker_env. cli.py and gateway/run.py bridge these too.
|
||||
"terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES",
|
||||
"terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
|
||||
# terminal.cwd intentionally excluded — CLI resolves at runtime,
|
||||
# gateway bridges it in gateway/run.py. Persisting to .env causes
|
||||
# stale values to poison child processes.
|
||||
"terminal.timeout": "TERMINAL_TIMEOUT",
|
||||
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
|
||||
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
|
||||
"terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
|
||||
"terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
|
||||
"terminal.container_disk": "TERMINAL_CONTAINER_DISK",
|
||||
"terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
|
||||
}
|
||||
if key in _config_to_env_sync:
|
||||
save_env_value(_config_to_env_sync[key], str(value))
|
||||
env_var = terminal_config_env_var_for_key(key)
|
||||
if env_var and key != "terminal.cwd":
|
||||
save_env_value(env_var, _terminal_env_value(value))
|
||||
|
||||
print(f"✓ Set {key} = {value} in {config_path}")
|
||||
|
||||
|
||||
@@ -94,19 +94,36 @@ def _register_self_hosted_client(
|
||||
*,
|
||||
access_token: str,
|
||||
portal_base_url: str,
|
||||
name: str,
|
||||
name: Optional[str],
|
||||
custom_redirect_uri: Optional[str],
|
||||
existing_client_id: Optional[str] = None,
|
||||
timeout: float = 15.0,
|
||||
) -> dict:
|
||||
"""POST to the portal's self-hosted-client endpoint and return the JSON body.
|
||||
|
||||
When ``existing_client_id`` is provided (the client_id this install
|
||||
persisted on a prior run), it is sent so the portal updates that existing
|
||||
dashboard record in place instead of minting a duplicate — this is what
|
||||
makes re-running ``hermes dashboard register`` idempotent. The portal
|
||||
falls back to creating a fresh client if the id no longer resolves to a row
|
||||
in the caller's org (stale/deleted), so passing it is always safe.
|
||||
|
||||
``name`` may be ``None`` on the idempotent update path (re-run without an
|
||||
explicit ``--name``): omitting it tells the portal to keep the name it
|
||||
already stored rather than overwriting it. It is required on the create
|
||||
path; the caller guarantees a value there.
|
||||
|
||||
Raises RuntimeError with a user-facing message on any non-2xx response or
|
||||
transport failure.
|
||||
"""
|
||||
url = f"{portal_base_url.rstrip('/')}/api/oauth/self-hosted-client"
|
||||
body: dict[str, str] = {"name": name}
|
||||
body: dict[str, str] = {}
|
||||
if name:
|
||||
body["name"] = name
|
||||
if custom_redirect_uri:
|
||||
body["custom_redirect_uri"] = custom_redirect_uri
|
||||
if existing_client_id:
|
||||
body["client_id"] = existing_client_id
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
@@ -165,16 +182,20 @@ def _print_post_register_hint(
|
||||
portal_base_url: str,
|
||||
custom_redirect_uri: Optional[str],
|
||||
wrote_portal_url: bool,
|
||||
public_url: str = "",
|
||||
) -> None:
|
||||
"""Print the success summary + the gate-engagement caveat."""
|
||||
from hermes_cli.config import get_env_path
|
||||
|
||||
env_path = get_env_path()
|
||||
_cid = client_id
|
||||
print()
|
||||
print(f" Wrote to {env_path}:")
|
||||
print(f" HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
|
||||
print(" HERMES_DASHBOARD_OAUTH_CLIENT_ID=" + str(_cid))
|
||||
if wrote_portal_url:
|
||||
print(f" HERMES_DASHBOARD_PORTAL_URL={portal_base_url}")
|
||||
print(" HERMES_DASHBOARD_PORTAL_URL=" + str(portal_base_url))
|
||||
if public_url:
|
||||
print(" HERMES_DASHBOARD_PUBLIC_URL=" + str(public_url))
|
||||
print()
|
||||
print(
|
||||
" Heads up — Nous login only *engages* on a non-loopback bind. A plain\n"
|
||||
@@ -240,12 +261,48 @@ def cmd_dashboard_register(args) -> None:
|
||||
|
||||
# Portal override: explicit --portal-url flag wins, else the
|
||||
# HERMES_DASHBOARD_PORTAL_URL env var, else the stored login's portal.
|
||||
#
|
||||
# We track whether a custom URL was *explicitly supplied* (flag or env)
|
||||
# separately from the resolved value. An explicit custom URL is an
|
||||
# intentional choice the user wants to persist (and update in place if it
|
||||
# already exists in .env); a portal merely inferred from the stored login
|
||||
# keeps the older, more conservative write-only-if-absent behaviour so we
|
||||
# don't clutter .env for the common production case.
|
||||
portal_override = getattr(args, "portal_url", None) or os.environ.get(
|
||||
"HERMES_DASHBOARD_PORTAL_URL"
|
||||
)
|
||||
custom_portal_supplied = bool(
|
||||
isinstance(portal_override, str) and portal_override.strip()
|
||||
)
|
||||
portal_base_url = _resolve_portal_base_url(portal_override)
|
||||
|
||||
name = getattr(args, "name", None) or _generate_dashboard_name()
|
||||
# Idempotency: if this install already registered a dashboard, we hold its
|
||||
# client_id locally (HERMES_DASHBOARD_OAUTH_CLIENT_ID). Re-send it so the
|
||||
# portal UPDATES that existing record instead of creating a duplicate. No
|
||||
# stored client_id -> this is a first registration -> create a fresh one
|
||||
# (the original behavior). This mirrors the portal's rule: no client id =
|
||||
# new dashboard; client id present = the stable key of the row to modify.
|
||||
existing_client_id = None
|
||||
try:
|
||||
existing_client_id = get_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID")
|
||||
except Exception:
|
||||
existing_client_id = None
|
||||
if isinstance(existing_client_id, str):
|
||||
existing_client_id = existing_client_id.strip() or None
|
||||
else:
|
||||
existing_client_id = None
|
||||
|
||||
explicit_name = getattr(args, "name", None)
|
||||
# Auto-generate a random name ONLY for a first registration. On a re-run
|
||||
# (we hold a client_id) without an explicit --name, keep the name the
|
||||
# portal already stored rather than churning it to a new random value
|
||||
# every time — so leave `name` unset and let the portal preserve it.
|
||||
if explicit_name:
|
||||
name = explicit_name
|
||||
elif existing_client_id:
|
||||
name = None
|
||||
else:
|
||||
name = _generate_dashboard_name()
|
||||
custom_redirect_uri = getattr(args, "redirect_uri", None)
|
||||
|
||||
# 2. Register with the portal.
|
||||
@@ -255,20 +312,26 @@ def cmd_dashboard_register(args) -> None:
|
||||
portal_base_url=portal_base_url,
|
||||
name=name,
|
||||
custom_redirect_uri=custom_redirect_uri,
|
||||
existing_client_id=existing_client_id,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"✗ Registration failed: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
client_id = str(result["client_id"])
|
||||
registered_name = str(result.get("name") or name)
|
||||
registered_name = str(result.get("name") or name or "")
|
||||
|
||||
print(f'✓ Registered dashboard "{registered_name}"')
|
||||
# Distinguish create vs update for the user: the portal echoes back the
|
||||
# same client_id we sent when it updated in place.
|
||||
updated_existing = bool(
|
||||
existing_client_id and client_id == existing_client_id
|
||||
)
|
||||
if updated_existing:
|
||||
print(f'✓ Updated dashboard "{registered_name}"')
|
||||
else:
|
||||
print(f'✓ Registered dashboard "{registered_name}"')
|
||||
|
||||
# 3. Write env vars idempotently. Always set the client_id. Only set the
|
||||
# portal URL when it isn't already configured (env or config) AND differs
|
||||
# from the production default, so we don't clutter .env for the common case
|
||||
# but DO persist a non-default portal (e.g. a preview deploy used in dev).
|
||||
# 3. Write env vars idempotently. Always set the client_id.
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID", client_id)
|
||||
except Exception as exc:
|
||||
@@ -276,6 +339,18 @@ def cmd_dashboard_register(args) -> None:
|
||||
print(f" Set it manually: HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
|
||||
sys.exit(1)
|
||||
|
||||
# Persist the portal URL. Two cases:
|
||||
# a) The user explicitly supplied a custom portal (--portal-url flag or
|
||||
# HERMES_DASHBOARD_PORTAL_URL env). That's an intentional choice we
|
||||
# always persist so it survives across sessions — overwriting any
|
||||
# existing entry in place (save_env_value updates a matching key
|
||||
# rather than appending a duplicate). This is true even when it equals
|
||||
# the production default: the user asked for it explicitly.
|
||||
# b) No custom portal was supplied. Keep the older conservative behaviour:
|
||||
# only write a portal inferred from the stored login when it isn't
|
||||
# already configured AND differs from the production default, so we
|
||||
# don't clutter .env for the common production case and don't alter an
|
||||
# existing entry unexpectedly.
|
||||
wrote_portal_url = False
|
||||
default_portal = "https://portal.nousresearch.com"
|
||||
existing_portal = None
|
||||
@@ -283,7 +358,15 @@ def cmd_dashboard_register(args) -> None:
|
||||
existing_portal = get_env_value("HERMES_DASHBOARD_PORTAL_URL")
|
||||
except Exception:
|
||||
existing_portal = None
|
||||
if not existing_portal and portal_base_url.rstrip("/") != default_portal:
|
||||
|
||||
if custom_portal_supplied:
|
||||
should_write_portal = existing_portal != portal_base_url
|
||||
else:
|
||||
should_write_portal = (
|
||||
not existing_portal and portal_base_url.rstrip("/") != default_portal
|
||||
)
|
||||
|
||||
if should_write_portal:
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_PORTAL_URL", portal_base_url)
|
||||
wrote_portal_url = True
|
||||
@@ -291,10 +374,54 @@ def cmd_dashboard_register(args) -> None:
|
||||
# Non-fatal: the client_id is the load-bearing value.
|
||||
pass
|
||||
|
||||
# Persist the dashboard public URL derived from the OAuth redirect URI.
|
||||
#
|
||||
# --redirect-uri is the full public HTTPS callback the user registered with
|
||||
# the portal, e.g. https://hermes.example.com/auth/callback. At serve time
|
||||
# the dashboard auth layer (dashboard_auth/routes._redirect_uri) reconstructs
|
||||
# that same callback by taking HERMES_DASHBOARD_PUBLIC_URL and appending
|
||||
# "/auth/callback" verbatim. So the value the runtime actually consumes is
|
||||
# the ORIGIN (scheme://host[:port]), not the full callback path — persisting
|
||||
# the raw redirect URI would double up the path. We derive the origin from
|
||||
# the supplied redirect URI and persist it as HERMES_DASHBOARD_PUBLIC_URL so
|
||||
# the operator doesn't have to re-supply it and the public-URL override is
|
||||
# actually wired (the gate engages and the callback round-trips correctly).
|
||||
#
|
||||
# Like the portal URL, an explicitly supplied value is always written
|
||||
# (updating an existing entry in place rather than appending a duplicate),
|
||||
# a no-op when it already matches, and never written on a localhost-only
|
||||
# install (no --redirect-uri).
|
||||
wrote_public_url = False
|
||||
public_url = ""
|
||||
if custom_redirect_uri:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(custom_redirect_uri)
|
||||
if parsed.scheme in ("http", "https") and parsed.netloc:
|
||||
public_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
except Exception:
|
||||
public_url = ""
|
||||
|
||||
if public_url:
|
||||
existing_public_url = None
|
||||
try:
|
||||
existing_public_url = get_env_value("HERMES_DASHBOARD_PUBLIC_URL")
|
||||
except Exception:
|
||||
existing_public_url = None
|
||||
if existing_public_url != public_url:
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_PUBLIC_URL", public_url)
|
||||
wrote_public_url = True
|
||||
except Exception:
|
||||
# Non-fatal: the client_id is the load-bearing value.
|
||||
pass
|
||||
|
||||
# 4. Hint.
|
||||
_print_post_register_hint(
|
||||
client_id=client_id,
|
||||
portal_base_url=portal_base_url,
|
||||
custom_redirect_uri=custom_redirect_uri,
|
||||
wrote_portal_url=wrote_portal_url,
|
||||
public_url=public_url if wrote_public_url else "",
|
||||
)
|
||||
|
||||
+12
-4
@@ -738,11 +738,14 @@ def run_doctor(args):
|
||||
issues,
|
||||
)
|
||||
|
||||
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them
|
||||
# Warn if model is set to a provider-prefixed name on a provider that doesn't use them.
|
||||
# Vendor/model slugs are valid on aggregator-style providers and on any custom
|
||||
# provider — bare "custom" or a named "custom:<name>" that fronts an OpenAI-compatible
|
||||
# aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.
|
||||
provider_for_policy = runtime_provider or catalog_provider
|
||||
provider_policy_id = str(provider_for_policy or "").strip().lower()
|
||||
providers_accepting_vendor_slugs = {
|
||||
"openrouter",
|
||||
"custom",
|
||||
"auto",
|
||||
"kilocode",
|
||||
"opencode-zen",
|
||||
@@ -750,11 +753,16 @@ def run_doctor(args):
|
||||
"lmstudio",
|
||||
"nous",
|
||||
}
|
||||
provider_accepts_vendor_slug = (
|
||||
provider_policy_id in providers_accepting_vendor_slugs
|
||||
or provider_policy_id == "custom"
|
||||
or provider_policy_id.startswith("custom:")
|
||||
)
|
||||
if (
|
||||
default_model
|
||||
and "/" in default_model
|
||||
and provider_for_policy
|
||||
and provider_for_policy not in providers_accepting_vendor_slugs
|
||||
and provider_policy_id
|
||||
and not provider_accepts_vendor_slug
|
||||
):
|
||||
check_warn(
|
||||
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
|
||||
|
||||
@@ -318,6 +318,17 @@ def run_dump(args):
|
||||
display = _redact(val)
|
||||
else:
|
||||
display = "set" if val else "not set"
|
||||
# A credential added via `hermes auth add openrouter` lives in the
|
||||
# credential pool, not as an env var — surface it so the dump doesn't
|
||||
# misleadingly read "not set" while `hermes auth list` shows it (#42130).
|
||||
if not val and label == "openrouter":
|
||||
try:
|
||||
from agent.credential_pool import load_pool as _load_pool
|
||||
|
||||
if _load_pool("openrouter").has_credentials():
|
||||
display = "set (auth pool)"
|
||||
except Exception:
|
||||
pass
|
||||
lines.append(f" {label:<20} {display}")
|
||||
|
||||
# Features summary
|
||||
|
||||
+161
-2607
File diff suppressed because it is too large
Load Diff
@@ -356,6 +356,37 @@ def get_curated_nous_models() -> list[str] | None:
|
||||
return out or None
|
||||
|
||||
|
||||
def seed_cache_from_checkout(project_root: "Path | str") -> bool:
|
||||
"""Overwrite the disk cache with the catalog shipped in a local checkout.
|
||||
|
||||
``hermes update`` pulls the latest repo, so the freshly-pulled
|
||||
``website/static/api/model-catalog.json`` IS the newest catalog — no
|
||||
network round-trip needed. Copying it straight over the disk cache keeps
|
||||
the model picker current even when the remote manifest fetch is bot-gated
|
||||
or the Portal hiccups.
|
||||
|
||||
Reads the shipped manifest, validates it against the schema, and writes it
|
||||
to ``~/.hermes/cache/model_catalog.json`` via the same atomic writer the
|
||||
network path uses. Returns ``True`` on success, ``False`` if the file is
|
||||
missing, malformed, or fails validation (caller should treat a ``False``
|
||||
as non-fatal — the network fetch path still applies on the next picker
|
||||
open).
|
||||
"""
|
||||
src = Path(project_root) / "website" / "static" / "api" / "model-catalog.json"
|
||||
try:
|
||||
with open(src, encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.debug("model catalog seed from checkout skipped (%s): %s", src, exc)
|
||||
return False
|
||||
if not _validate_manifest(data):
|
||||
logger.debug("model catalog seed from checkout skipped: invalid manifest at %s", src)
|
||||
return False
|
||||
_write_disk_cache(data)
|
||||
reset_cache() # drop the in-process copy so the next read picks up the seed
|
||||
return True
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Clear the in-process cache. Used by tests and ``hermes model --refresh``."""
|
||||
global _catalog_cache, _catalog_cache_source_mtime
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+85
-5
@@ -73,8 +73,10 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
|
||||
# Free tier
|
||||
("openrouter/elephant-alpha", "free"),
|
||||
("openrouter/owl-alpha", "free"),
|
||||
("poolside/laguna-m.1:free", "free"),
|
||||
("tencent/hy3-preview:free", "free"),
|
||||
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
|
||||
("nvidia/nemotron-3-ultra-550b-a55b:free", "free"),
|
||||
("inclusionai/ring-2.6-1t:free", "free"),
|
||||
]
|
||||
|
||||
@@ -764,6 +766,64 @@ _NOUS_RECOMMENDED_CACHE_TTL: int = 600 # seconds (10 minutes)
|
||||
_nous_recommended_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
||||
|
||||
|
||||
def _nous_recommended_disk_path() -> "Path":
|
||||
"""Disk path for the persisted recommended-models cache."""
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "cache" / "nous_recommended_cache.json"
|
||||
|
||||
|
||||
def _read_nous_recommended_disk(base: str) -> dict[str, Any] | None:
|
||||
"""Return the last-known-good payload for ``base`` from disk, or None.
|
||||
|
||||
The disk file is a JSON object keyed by portal base URL so staging and
|
||||
prod don't collide:
|
||||
``{"<base>": {"data": {...}, "ts": <epoch_seconds>}}``.
|
||||
"""
|
||||
try:
|
||||
with open(_nous_recommended_disk_path(), encoding="utf-8") as fh:
|
||||
blob = json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(blob, dict):
|
||||
return None
|
||||
entry = blob.get(base)
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
data = entry.get("data")
|
||||
return data if isinstance(data, dict) and data else None
|
||||
|
||||
|
||||
def _write_nous_recommended_disk(base: str, data: dict[str, Any]) -> None:
|
||||
"""Persist ``data`` as the last-known-good payload for ``base``.
|
||||
|
||||
Merges into any existing per-base map, then writes atomically. Failures
|
||||
are non-fatal (logged at debug) — the in-process cache still works.
|
||||
"""
|
||||
if not data:
|
||||
return
|
||||
path = _nous_recommended_disk_path()
|
||||
try:
|
||||
try:
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
blob = json.load(fh)
|
||||
if not isinstance(blob, dict):
|
||||
blob = {}
|
||||
except (OSError, json.JSONDecodeError):
|
||||
blob = {}
|
||||
blob[base] = {"data": data, "ts": time.time()}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(blob, fh, indent=2)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, path)
|
||||
except OSError as exc:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
"nous recommended-models disk cache write failed: %s", exc
|
||||
)
|
||||
|
||||
|
||||
def fetch_nous_recommended_models(
|
||||
portal_base_url: str = "",
|
||||
timeout: float = 5.0,
|
||||
@@ -774,12 +834,19 @@ def fetch_nous_recommended_models(
|
||||
|
||||
Hits ``<portal>/api/nous/recommended-models``. The endpoint is public —
|
||||
no auth is required. Results are cached per portal URL for
|
||||
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds; pass ``force_refresh=True`` to
|
||||
bypass the cache.
|
||||
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds in process; pass
|
||||
``force_refresh=True`` to bypass the in-process cache.
|
||||
|
||||
Returns the parsed JSON dict on success, or ``{}`` on any failure
|
||||
(network, parse, non-2xx). Callers must treat missing/null fields as
|
||||
"no recommendation" and fall back to their own default.
|
||||
A successful live fetch is also persisted to a per-base disk cache
|
||||
(``$HERMES_HOME/cache/nous_recommended_cache.json``) as last-known-good.
|
||||
When the live fetch fails (network, parse, non-2xx) and the in-process
|
||||
cache is empty, the disk copy is returned instead of ``{}`` — so a
|
||||
transient Portal hiccup no longer silently drops the free/paid model
|
||||
recommendations from the picker. Self-heals on the next successful fetch.
|
||||
|
||||
Returns the parsed JSON dict, or ``{}`` only when neither the network nor
|
||||
any cache layer can supply data. Callers must treat missing/null fields
|
||||
as "no recommendation" and fall back to their own default.
|
||||
"""
|
||||
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
|
||||
now = time.monotonic()
|
||||
@@ -802,6 +869,19 @@ def fetch_nous_recommended_models(
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
if data:
|
||||
# Live fetch succeeded — refresh both cache layers.
|
||||
_nous_recommended_cache[base] = (data, now)
|
||||
_write_nous_recommended_disk(base, data)
|
||||
return data
|
||||
|
||||
# Live fetch failed. Fall back to the last-known-good disk copy so a
|
||||
# transient Portal hiccup doesn't drop the recommendations entirely.
|
||||
disk = _read_nous_recommended_disk(base)
|
||||
if disk:
|
||||
_nous_recommended_cache[base] = (disk, now)
|
||||
return disk
|
||||
|
||||
_nous_recommended_cache[base] = (data, now)
|
||||
return data
|
||||
|
||||
|
||||
+138
-87
@@ -649,29 +649,62 @@ def _save_enabled_set(enabled: set) -> None:
|
||||
save_config(config)
|
||||
|
||||
|
||||
def _resolve_plugin_key(name: str) -> Optional[str]:
|
||||
"""Resolve a user-supplied plugin identifier to its canonical registry key.
|
||||
|
||||
Accepts either the bare manifest name (``nemo_relay``), the directory
|
||||
name, or the full path-derived key (``observability/nemo_relay``) and
|
||||
returns the canonical key the loader gates on (``manifest.key`` or, for a
|
||||
flat plugin, the bare name). Returns ``None`` when no plugin matches.
|
||||
|
||||
This is the single normalization point so ``hermes plugins enable`` /
|
||||
``disable`` write the same key that ``PluginManager`` matches against —
|
||||
nested category plugins (e.g. ``observability/nemo_relay``) included.
|
||||
"""
|
||||
entries = _discover_all_plugins()
|
||||
# 1. Exact match on canonical key or manifest name — always unambiguous.
|
||||
for entry in entries:
|
||||
# entry = (name, version, description, source, dir_path, key)
|
||||
if name == entry[5] or name == entry[0]:
|
||||
return entry[5]
|
||||
# 2. Fall back to a bare leaf-name match (e.g. "nemo_relay" ->
|
||||
# "observability/nemo_relay"), but only when it resolves to exactly one
|
||||
# plugin so we never silently pick the wrong same-named nested plugin.
|
||||
leaf_matches = [entry[5] for entry in entries if name == entry[5].split("/")[-1]]
|
||||
if len(leaf_matches) == 1:
|
||||
return leaf_matches[0]
|
||||
return None
|
||||
|
||||
|
||||
def cmd_enable(name: str) -> None:
|
||||
"""Add a plugin to the enabled allow-list (and remove it from disabled)."""
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
# Discover the plugin — check installed (user) AND bundled.
|
||||
if not _plugin_exists(name):
|
||||
# Discover the plugin — check installed (user) AND bundled, including
|
||||
# nested category plugins — and normalize to its canonical registry key.
|
||||
key = _resolve_plugin_key(name)
|
||||
if key is None:
|
||||
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
if name in enabled and name not in disabled:
|
||||
console.print(f"[dim]Plugin '{name}' is already enabled.[/dim]")
|
||||
if key in enabled and key not in disabled:
|
||||
console.print(f"[dim]Plugin '{key}' is already enabled.[/dim]")
|
||||
return
|
||||
|
||||
enabled.add(name)
|
||||
disabled.discard(name)
|
||||
enabled.add(key)
|
||||
disabled.discard(key)
|
||||
# Drop any legacy bare-name entry so the two don't drift out of sync.
|
||||
bare = key.split("/")[-1]
|
||||
if bare != key:
|
||||
disabled.discard(bare)
|
||||
_save_enabled_set(enabled)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(
|
||||
f"[green]✓[/green] Plugin [bold]{name}[/bold] enabled. "
|
||||
f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. "
|
||||
"Takes effect on next session."
|
||||
)
|
||||
|
||||
@@ -681,111 +714,129 @@ def cmd_disable(name: str) -> None:
|
||||
from rich.console import Console
|
||||
|
||||
console = Console()
|
||||
if not _plugin_exists(name):
|
||||
key = _resolve_plugin_key(name)
|
||||
if key is None:
|
||||
console.print(f"[red]Plugin '{name}' is not installed or bundled.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
enabled = _get_enabled_set()
|
||||
disabled = _get_disabled_set()
|
||||
|
||||
if name not in enabled and name in disabled:
|
||||
console.print(f"[dim]Plugin '{name}' is already disabled.[/dim]")
|
||||
if key not in enabled and key in disabled:
|
||||
console.print(f"[dim]Plugin '{key}' is already disabled.[/dim]")
|
||||
return
|
||||
|
||||
enabled.discard(name)
|
||||
disabled.add(name)
|
||||
enabled.discard(key)
|
||||
# Drop any legacy bare-name entry from the allow-list too, so a stale
|
||||
# bare name can't keep a nested plugin loading after an explicit disable.
|
||||
bare = key.split("/")[-1]
|
||||
if bare != key:
|
||||
enabled.discard(bare)
|
||||
disabled.add(key)
|
||||
_save_enabled_set(enabled)
|
||||
_save_disabled_set(disabled)
|
||||
console.print(
|
||||
f"[yellow]\u2298[/yellow] Plugin [bold]{name}[/bold] disabled. "
|
||||
f"[yellow]\u2298[/yellow] Plugin [bold]{key}[/bold] disabled. "
|
||||
"Takes effect on next session."
|
||||
)
|
||||
|
||||
|
||||
def _plugin_exists(name: str) -> bool:
|
||||
"""Return True if a plugin with *name* is installed (user) or bundled."""
|
||||
# Installed: directory name or manifest name match in user plugins dir
|
||||
user_dir = _plugins_dir()
|
||||
if user_dir.is_dir():
|
||||
if (user_dir / name).is_dir():
|
||||
return True
|
||||
for child in user_dir.iterdir():
|
||||
if not child.is_dir():
|
||||
continue
|
||||
manifest = _read_manifest(child)
|
||||
if manifest.get("name") == name:
|
||||
return True
|
||||
# Bundled: <repo>/plugins/<name>/ (or HERMES_BUNDLED_PLUGINS on Nix).
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
if repo_plugins.is_dir():
|
||||
candidate = repo_plugins / name
|
||||
if candidate.is_dir() and (
|
||||
(candidate / "plugin.yaml").exists()
|
||||
or (candidate / "plugin.yml").exists()
|
||||
):
|
||||
return True
|
||||
return False
|
||||
"""Return True if a plugin with *name* (bare name or key) exists."""
|
||||
return _resolve_plugin_key(name) is not None
|
||||
|
||||
|
||||
def _discover_all_plugins() -> list:
|
||||
"""Return a list of (name, version, description, source, dir_path) for
|
||||
every plugin the loader can see — user + bundled + project.
|
||||
def _read_manifest_info(d: Path, prefix: str):
|
||||
"""Read a plugin.yaml manifest and return (name, version, description, key).
|
||||
|
||||
Matches the ordering/dedup of ``PluginManager.discover_and_load``:
|
||||
bundled first, then user, then project; user overrides bundled on
|
||||
name collision.
|
||||
Returns None if no manifest file exists.
|
||||
"""
|
||||
manifest_file = d / "plugin.yaml"
|
||||
if not manifest_file.exists():
|
||||
manifest_file = d / "plugin.yml"
|
||||
if not manifest_file.exists():
|
||||
return None
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
name = d.name
|
||||
version = ""
|
||||
description = ""
|
||||
if yaml:
|
||||
try:
|
||||
with open(manifest_file, encoding="utf-8") as f:
|
||||
manifest = yaml.safe_load(f) or {}
|
||||
name = manifest.get("name", d.name)
|
||||
version = manifest.get("version", "")
|
||||
description = manifest.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
key = f"{prefix}/{d.name}" if prefix else name
|
||||
return name, version, description, key
|
||||
|
||||
seen: dict = {} # name -> (name, version, description, source, path)
|
||||
|
||||
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
for base, source in ((repo_plugins, "bundled"), (_plugins_dir(), "user")):
|
||||
if not base.is_dir():
|
||||
def _scan_level(
|
||||
base: Path,
|
||||
source: str,
|
||||
skip_names: set,
|
||||
prefix: str,
|
||||
depth: int,
|
||||
seen: dict,
|
||||
) -> None:
|
||||
"""Recursive directory scan matching PluginManager._scan_directory_level.
|
||||
|
||||
Populates *seen* with key -> (name, version, description, source, dir, key).
|
||||
"""
|
||||
if not base.is_dir():
|
||||
return
|
||||
for d in sorted(base.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
for d in sorted(base.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
if source == "bundled" and d.name in {"memory", "context_engine"}:
|
||||
continue
|
||||
manifest_file = d / "plugin.yaml"
|
||||
if not manifest_file.exists():
|
||||
manifest_file = d / "plugin.yml"
|
||||
if not manifest_file.exists():
|
||||
continue
|
||||
name = d.name
|
||||
version = ""
|
||||
description = ""
|
||||
if yaml:
|
||||
try:
|
||||
with open(manifest_file, encoding="utf-8") as f:
|
||||
manifest = yaml.safe_load(f) or {}
|
||||
name = manifest.get("name", d.name)
|
||||
version = manifest.get("version", "")
|
||||
description = manifest.get("description", "")
|
||||
except Exception:
|
||||
pass
|
||||
# User plugins override bundled on name collision.
|
||||
if name in seen and source == "bundled":
|
||||
if depth == 0 and skip_names and d.name in skip_names:
|
||||
continue
|
||||
info = _read_manifest_info(d, prefix)
|
||||
if info is not None:
|
||||
name, version, description, key = info
|
||||
if key in seen and source == "bundled":
|
||||
continue
|
||||
src_label = source
|
||||
if source == "user" and (d / ".git").exists():
|
||||
src_label = "git"
|
||||
seen[name] = (name, version, description, src_label, d)
|
||||
seen[key] = (name, version, description, src_label, d, key)
|
||||
continue
|
||||
if depth >= 1:
|
||||
continue
|
||||
sub_prefix = f"{prefix}/{d.name}" if prefix else d.name
|
||||
_scan_level(d, source, set(), sub_prefix, depth + 1, seen)
|
||||
|
||||
|
||||
def _discover_all_plugins() -> list:
|
||||
"""Return a list of (name, version, description, source, dir_path, key) for
|
||||
every plugin the loader can see — user + bundled + project.
|
||||
|
||||
Matches the ordering/dedup of ``PluginManager.discover_and_load``:
|
||||
bundled first, then user, then project; user overrides bundled on
|
||||
key collision.
|
||||
"""
|
||||
seen: dict = {} # key -> (name, version, description, source, path, key)
|
||||
|
||||
# Bundled (<repo>/plugins/<name>/), excluding memory/ and context_engine/
|
||||
from hermes_cli.plugins import get_bundled_plugins_dir
|
||||
repo_plugins = get_bundled_plugins_dir()
|
||||
for base, source, skip in (
|
||||
(repo_plugins, "bundled", {"memory", "context_engine"}),
|
||||
(_plugins_dir(), "user", set()),
|
||||
):
|
||||
_scan_level(base, source, skip, "", 0, seen)
|
||||
return list(seen.values())
|
||||
|
||||
|
||||
def _plugin_status(name: str, enabled: set, disabled: set) -> str:
|
||||
"""Return the user-facing activation state for a plugin name."""
|
||||
if name in disabled:
|
||||
def _plugin_status(name: str, enabled: set, disabled: set, key: str = "") -> str:
|
||||
"""Return the user-facing activation state for a plugin name or key."""
|
||||
if name in disabled or key in disabled:
|
||||
return "disabled"
|
||||
if name in enabled:
|
||||
if name in enabled or key in enabled:
|
||||
return "enabled"
|
||||
return "not enabled"
|
||||
|
||||
@@ -798,7 +849,7 @@ def _filter_plugin_entries(entries: list, args: Any, enabled: set, disabled: set
|
||||
if getattr(args, "enabled", False):
|
||||
filtered = [
|
||||
entry for entry in filtered
|
||||
if _plugin_status(entry[0], enabled, disabled) == "enabled"
|
||||
if _plugin_status(entry[0], enabled, disabled, key=entry[5]) == "enabled"
|
||||
]
|
||||
return filtered
|
||||
|
||||
@@ -823,19 +874,19 @@ def cmd_list(args: Any | None = None) -> None:
|
||||
payload = [
|
||||
{
|
||||
"name": name,
|
||||
"status": _plugin_status(name, enabled, disabled),
|
||||
"status": _plugin_status(name, enabled, disabled, key=key),
|
||||
"version": str(version),
|
||||
"description": description,
|
||||
"source": source,
|
||||
}
|
||||
for name, version, description, source, _dir in entries
|
||||
for name, version, description, source, _dir, key in entries
|
||||
]
|
||||
print(json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
if getattr(args, "plain", False):
|
||||
for name, version, _description, source, _dir in entries:
|
||||
status = _plugin_status(name, enabled, disabled)
|
||||
for name, version, _description, source, _dir, key in entries:
|
||||
status = _plugin_status(name, enabled, disabled, key=key)
|
||||
print(f"{status:12} {source:8} {str(version):8} {name}")
|
||||
return
|
||||
|
||||
@@ -850,8 +901,8 @@ def cmd_list(args: Any | None = None) -> None:
|
||||
table.add_column("Description")
|
||||
table.add_column("Source", style="dim")
|
||||
|
||||
for name, version, description, source, _dir in entries:
|
||||
status_name = _plugin_status(name, enabled, disabled)
|
||||
for name, version, description, source, _dir, key in entries:
|
||||
status_name = _plugin_status(name, enabled, disabled, key=key)
|
||||
if status_name == "disabled":
|
||||
status = "[red]disabled[/red]"
|
||||
elif status_name == "enabled":
|
||||
@@ -1051,14 +1102,14 @@ def cmd_toggle() -> None:
|
||||
plugin_labels = []
|
||||
plugin_selected = set()
|
||||
|
||||
for i, (name, _version, description, source, _d) in enumerate(entries):
|
||||
for i, (name, _version, description, source, _d, key) in enumerate(entries):
|
||||
label = f"{name} \u2014 {description}" if description else name
|
||||
if source == "bundled":
|
||||
label = f"{label} [bundled]"
|
||||
plugin_names.append(name)
|
||||
plugin_labels.append(label)
|
||||
# Selected (enabled) when in enabled-set AND not in disabled-set
|
||||
if name in enabled_set and name not in disabled_set:
|
||||
if (name in enabled_set or key in enabled_set) and name not in disabled_set and key not in disabled_set:
|
||||
plugin_selected.add(i)
|
||||
|
||||
# -- Provider categories --
|
||||
@@ -1641,7 +1692,7 @@ def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
|
||||
def dashboard_remove_user_plugin(name: str) -> dict[str, Any]:
|
||||
"""Delete a plugin tree under ``~/.hermes/plugins/`` only."""
|
||||
plugins_dir = _plugins_dir()
|
||||
for n, _ver, _d, src, _path in _discover_all_plugins():
|
||||
for n, _ver, _d, src, _path, _key in _discover_all_plugins():
|
||||
if n == name and src == "bundled":
|
||||
return {"ok": False, "error": "Bundled plugins cannot be removed from the dashboard."}
|
||||
|
||||
|
||||
@@ -492,7 +492,10 @@ def get_label(provider_id: str) -> str:
|
||||
|
||||
def is_aggregator(provider: str) -> bool:
|
||||
"""Return True when the provider is a multi-model aggregator."""
|
||||
pdef = get_provider(provider)
|
||||
provider_norm = normalize_provider(provider or "")
|
||||
if provider_norm.startswith("custom:"):
|
||||
return True
|
||||
pdef = get_provider(provider_norm)
|
||||
return pdef.is_aggregator if pdef else False
|
||||
|
||||
|
||||
|
||||
@@ -250,13 +250,23 @@ class PtyBridge:
|
||||
return
|
||||
self._closed = True
|
||||
|
||||
try:
|
||||
pgid = os.getpgid(self._proc.pid) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
|
||||
except Exception:
|
||||
pgid = None
|
||||
|
||||
# SIGHUP is the conventional "your terminal went away" signal.
|
||||
# We escalate if the child ignores it.
|
||||
# Send it to the whole foreground process group, not just the PTY
|
||||
# leader: the dashboard TUI starts helper children such as the Python
|
||||
# slash worker, and killing only the leader can strand those helpers.
|
||||
for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL): # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
|
||||
if not self._proc.isalive():
|
||||
break
|
||||
try:
|
||||
self._proc.kill(sig)
|
||||
if pgid is not None:
|
||||
os.killpg(pgid, sig) # windows-footgun: ok — POSIX-only module (imports fcntl/termios/ptyprocess at top)
|
||||
else:
|
||||
self._proc.kill(sig)
|
||||
except Exception:
|
||||
pass
|
||||
deadline = time.monotonic() + 0.5
|
||||
|
||||
+102
-16
@@ -1405,6 +1405,54 @@ async def update_hermes():
|
||||
}
|
||||
|
||||
|
||||
def _recent_upstream_commits(n: int = 20) -> List[Dict[str, Any]]:
|
||||
"""Commits the local checkout is behind ``origin/main`` by, newest first.
|
||||
|
||||
Logs the SAME range the behind-count uses (``HEAD..origin/main`` — see
|
||||
``banner._check_via_local_git``), NOT the branch's ``@{upstream}``. On a
|
||||
feature-branch checkout ``@{upstream}`` is the branch's own tip (zero
|
||||
commits), which would leave the changelog empty even though the count is
|
||||
non-zero. Pinning to ``origin/main`` keeps count and changelog consistent.
|
||||
|
||||
Best-effort: returns [] if not a git checkout, origin/main is unreachable,
|
||||
or git is unavailable. Never raises into the request path.
|
||||
"""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"-C",
|
||||
str(PROJECT_ROOT),
|
||||
"log",
|
||||
"--format=%H%x1f%s%x1f%an%x1f%ct",
|
||||
"HEAD..origin/main",
|
||||
f"-n{int(n)}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return []
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for line in out.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = (line.split("\x1f") + ["", "", "", "0"])[:4]
|
||||
sha, summary, author, at = parts
|
||||
rows.append(
|
||||
{
|
||||
"sha": sha[:7],
|
||||
"summary": summary,
|
||||
"author": author,
|
||||
"at": int(at or 0),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@app.get("/api/hermes/update/check")
|
||||
async def check_hermes_update(force: bool = False):
|
||||
"""Report whether a Hermes update is available, without applying it.
|
||||
@@ -1425,6 +1473,11 @@ async def check_hermes_update(force: bool = False):
|
||||
user must update out-of-band
|
||||
update_command: the recommended command for this install method
|
||||
message: human-readable guidance for non-applyable methods
|
||||
commits: for git/pip installs that are behind, a list of the commits
|
||||
the local checkout is behind upstream by — each
|
||||
{sha, summary, author, at}. Absent/empty otherwise. The
|
||||
desktop's remote update overlay renders this as "what's
|
||||
changed". Additive: existing consumers ignore it.
|
||||
"""
|
||||
install_method = detect_install_method(PROJECT_ROOT)
|
||||
update_command = recommended_update_command_for_method(install_method)
|
||||
@@ -1467,6 +1520,11 @@ async def check_hermes_update(force: bool = False):
|
||||
payload["message"] = "You're on the latest version."
|
||||
else:
|
||||
payload["update_available"] = True
|
||||
# Enrich with the actual commits we're behind by, so the desktop's
|
||||
# remote update overlay can show "what's changed". git/pip only;
|
||||
# best-effort (empty list on any failure).
|
||||
if install_method in ("git", "pip"):
|
||||
payload["commits"] = await asyncio.to_thread(_recent_upstream_commits)
|
||||
|
||||
return payload
|
||||
|
||||
@@ -8230,20 +8288,32 @@ async def get_models_analytics(days: int = 30):
|
||||
# though uvicorn binds to 127.0.0.1.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native
|
||||
# Windows the import raises; catch and leave PtyBridge=None so the rest of
|
||||
# the dashboard (sessions, jobs, metrics, config editor) still loads and the
|
||||
# /api/pty endpoint cleanly refuses with a WSL-suggested message.
|
||||
try:
|
||||
from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
|
||||
_PTY_BRIDGE_AVAILABLE = True
|
||||
except ImportError as _pty_import_err: # pragma: no cover - Windows-only path
|
||||
PtyBridge = None # type: ignore[assignment]
|
||||
_PTY_BRIDGE_AVAILABLE = False
|
||||
# PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows
|
||||
# uses win_pty_bridge (pywinpty/ConPTY, already a declared dependency). Both
|
||||
# expose the same public surface — spawn/read/write/resize/close/is_available —
|
||||
# so the /api/pty WebSocket handler needs no platform guards.
|
||||
if sys.platform.startswith("win"):
|
||||
try:
|
||||
from hermes_cli.win_pty_bridge import WinPtyBridge as PtyBridge, PtyUnavailableError
|
||||
_PTY_BRIDGE_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover - pywinpty missing
|
||||
PtyBridge = None # type: ignore[assignment]
|
||||
_PTY_BRIDGE_AVAILABLE = False
|
||||
|
||||
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
|
||||
"""Stub on platforms where pty_bridge can't be imported."""
|
||||
pass
|
||||
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
|
||||
"""Stub when win_pty_bridge cannot be imported."""
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError
|
||||
_PTY_BRIDGE_AVAILABLE = True
|
||||
except ImportError: # pragma: no cover - dev env without ptyprocess
|
||||
PtyBridge = None # type: ignore[assignment]
|
||||
_PTY_BRIDGE_AVAILABLE = False
|
||||
|
||||
class PtyUnavailableError(RuntimeError): # type: ignore[no-redef]
|
||||
"""Stub on platforms where pty_bridge can't be imported."""
|
||||
pass
|
||||
|
||||
_RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]")
|
||||
_PTY_READ_CHUNK_TIMEOUT = 0.2
|
||||
@@ -8503,6 +8573,11 @@ def _resolve_chat_argv(
|
||||
|
||||
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
|
||||
env = os.environ.copy()
|
||||
try:
|
||||
from hermes_cli.config import apply_terminal_config_to_env
|
||||
apply_terminal_config_to_env(env=env)
|
||||
except Exception:
|
||||
_log.debug("Failed to apply terminal config bridge for dashboard chat", exc_info=True)
|
||||
env.setdefault("NODE_ENV", "production")
|
||||
# Browser-embedded chat should prefer stable wheel-based scrollback over
|
||||
# native terminal mouse tracking. When mouse tracking is enabled, wheel
|
||||
@@ -9575,10 +9650,16 @@ def _merged_plugins_hub() -> Dict[str, Any]:
|
||||
plugins_root_resolved = (get_hermes_home() / "plugins").resolve()
|
||||
rows: List[Dict[str, Any]] = []
|
||||
|
||||
for name, version, description, source, dir_str in _discover_all_plugins():
|
||||
if name in disabled_set:
|
||||
for name, version, description, source, dir_str, key in _discover_all_plugins():
|
||||
# Both the path-derived key (nested category plugins) and the bare
|
||||
# manifest name count for enabled/disabled state, matching the runtime
|
||||
# loader's back-compat lookup.
|
||||
aliases = {name}
|
||||
if key:
|
||||
aliases.add(key)
|
||||
if aliases & disabled_set:
|
||||
runtime_status = "disabled"
|
||||
elif name in enabled_set:
|
||||
elif aliases & enabled_set:
|
||||
runtime_status = "enabled"
|
||||
else:
|
||||
runtime_status = "inactive"
|
||||
@@ -10078,4 +10159,9 @@ def start_server(
|
||||
uvicorn.run(
|
||||
app, host=host, port=port, log_level="warning",
|
||||
proxy_headers=bool(app.state.auth_required),
|
||||
# Detect half-open WS connections (reverse-proxy 524, dropped tunnels)
|
||||
# within ~20-40s so WebSocketDisconnect fires the disconnect→reap path.
|
||||
# 20s stays under Cloudflare Tunnel's idle timeout, keeping it warm.
|
||||
ws_ping_interval=20.0,
|
||||
ws_ping_timeout=20.0,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Windows ConPTY bridge for the `hermes dashboard` chat tab.
|
||||
|
||||
Drop-in counterpart to ``hermes_cli.pty_bridge.PtyBridge`` for native
|
||||
Windows. Mirrors the exact public surface the ``/api/pty`` WebSocket
|
||||
handler in ``hermes_cli.web_server`` consumes: ``spawn``, ``read``,
|
||||
``write``, ``resize``, ``close``, ``is_available``, plus the
|
||||
``PtyUnavailableError`` type.
|
||||
|
||||
Backed by ``pywinpty`` (already a declared win32 dependency in
|
||||
pyproject.toml) instead of ``ptyprocess``/``fcntl``/``termios``, none of
|
||||
which exist on native Windows. The read/write/terminate calls here match
|
||||
the working winpty usage already shipping in ``tools/process_registry.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Optional, Sequence
|
||||
|
||||
try:
|
||||
from winpty import PtyProcess # type: ignore
|
||||
_PTY_AVAILABLE = sys.platform.startswith("win")
|
||||
except ImportError: # pragma: no cover - non-Windows or pywinpty missing
|
||||
PtyProcess = None # type: ignore
|
||||
_PTY_AVAILABLE = False
|
||||
|
||||
|
||||
__all__ = ["WinPtyBridge", "PtyUnavailableError"]
|
||||
|
||||
|
||||
# Same clamp ceiling as the POSIX bridge: a broken winsize probe must never
|
||||
# reach the resize call. ConPTY tolerates large values better than ioctl,
|
||||
# but we keep parity to avoid layout surprises.
|
||||
_MIN_DIMENSION = 1
|
||||
_MAX_COLS = 2000
|
||||
_MAX_ROWS = 1000
|
||||
|
||||
|
||||
def _clamp(value: int, maximum: int) -> int:
|
||||
try:
|
||||
n = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return _MIN_DIMENSION
|
||||
if n < _MIN_DIMENSION:
|
||||
return _MIN_DIMENSION
|
||||
if n > maximum:
|
||||
return maximum
|
||||
return n
|
||||
|
||||
|
||||
class PtyUnavailableError(RuntimeError):
|
||||
"""Raised when a PTY cannot be created on this platform."""
|
||||
|
||||
|
||||
class WinPtyBridge:
|
||||
"""pywinpty-backed bridge with the same interface as ``PtyBridge``.
|
||||
|
||||
``web_server`` calls :meth:`read` inside ``run_in_executor``, so a
|
||||
blocking/polling read here never stalls the event loop. ConPTY exposes
|
||||
no selectable fd, so we poll with a short sleep instead of ``select``.
|
||||
"""
|
||||
|
||||
def __init__(self, proc: "PtyProcess") -> None: # type: ignore[name-defined]
|
||||
self._proc = proc
|
||||
self._closed = False
|
||||
|
||||
# -- lifecycle --------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
return bool(_PTY_AVAILABLE)
|
||||
|
||||
@classmethod
|
||||
def spawn(
|
||||
cls,
|
||||
argv: Sequence[str],
|
||||
*,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[dict] = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
) -> "WinPtyBridge":
|
||||
if not _PTY_AVAILABLE:
|
||||
if PtyProcess is None:
|
||||
raise PtyUnavailableError(
|
||||
"pywinpty is not installed. Install with: pip install pywinpty"
|
||||
)
|
||||
raise PtyUnavailableError("ConPTY is unavailable on this platform.")
|
||||
spawn_env = (os.environ.copy() if env is None else dict(env))
|
||||
if not spawn_env.get("TERM"):
|
||||
spawn_env["TERM"] = "xterm-256color"
|
||||
# pywinpty mirrors ptyprocess: dimensions=(rows, cols).
|
||||
# This call shape is the one already used in tools/process_registry.py.
|
||||
proc = PtyProcess.spawn( # type: ignore[union-attr]
|
||||
list(argv),
|
||||
cwd=cwd,
|
||||
env=spawn_env,
|
||||
dimensions=(rows, cols),
|
||||
)
|
||||
return cls(proc)
|
||||
|
||||
@property
|
||||
def pid(self) -> int:
|
||||
return int(self._proc.pid)
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
if self._closed:
|
||||
return False
|
||||
try:
|
||||
return bool(self._proc.isalive())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# -- I/O --------------------------------------------------------------
|
||||
|
||||
def read(self, timeout: float = 0.2) -> Optional[bytes]:
|
||||
"""Up to 64 KiB of child output.
|
||||
|
||||
Returns bytes, ``b""`` when nothing is available this tick, or
|
||||
``None`` once the child has exited (EOF).
|
||||
"""
|
||||
if self._closed:
|
||||
return None
|
||||
try:
|
||||
data = self._proc.read(65536) # pywinpty returns str
|
||||
except EOFError:
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
if not data:
|
||||
# No fd to select on; poll politely so the executor thread
|
||||
# doesn't pin a core while the TUI is idle.
|
||||
time.sleep(min(timeout, 0.02))
|
||||
return b""
|
||||
if isinstance(data, bytes):
|
||||
return data
|
||||
# NOTE: pywinpty decodes internally, so a multibyte UTF-8 sequence
|
||||
# can in theory split across reads. xterm.js tolerates the rare
|
||||
# replacement char; this is the one fidelity tradeoff vs the POSIX
|
||||
# raw-fd path.
|
||||
return data.encode("utf-8", errors="replace")
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
if self._closed or not data:
|
||||
return
|
||||
try:
|
||||
# The dashboard sends raw keystroke bytes; pywinpty.write wants text.
|
||||
self._proc.write(data.decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def resize(self, cols: int, rows: int) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
cols = _clamp(cols, _MAX_COLS)
|
||||
rows = _clamp(rows, _MAX_ROWS)
|
||||
try:
|
||||
self._proc.setwinsize(rows, cols) # pywinpty: (rows, cols)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- teardown ---------------------------------------------------------
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
self._proc.terminate(force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "WinPtyBridge":
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc) -> None:
|
||||
self.close()
|
||||
@@ -253,6 +253,14 @@ _LEGACY_TOOLSET_MAP = {
|
||||
# daemon start/stop, env var changes, etc.) on a 30 s horizon.
|
||||
_tool_defs_cache: Dict[tuple, List[Dict[str, Any]]] = {}
|
||||
|
||||
# Hard cap on memoized get_tool_definitions() results. A long-lived Gateway
|
||||
# process sees many distinct toolset/config fingerprints over its lifetime
|
||||
# (per-session toolset sets, config edits, kanban-task toggles); without a
|
||||
# bound the cache grows unboundedly. 8 comfortably covers the warm working
|
||||
# set (the handful of distinct platform/toolset combos a gateway actually
|
||||
# serves) while keeping the cap small. (#19251)
|
||||
_TOOL_DEFS_CACHE_MAX = 8
|
||||
|
||||
|
||||
def _clear_tool_defs_cache() -> None:
|
||||
"""Drop memoized get_tool_definitions() results. Called when dynamic
|
||||
@@ -329,6 +337,11 @@ def get_tool_definitions(
|
||||
# agent inits and providers that enforce unique tool names
|
||||
# (DeepSeek, Xiaomi MiMo, Moonshot Kimi) reject the request with
|
||||
# HTTP 400. Mirrors the cache-hit path above. (issue #17335)
|
||||
# Bound the cache with LRU eviction so a long-lived Gateway process
|
||||
# doesn't accumulate entries unboundedly across the many distinct
|
||||
# toolset/config fingerprints it sees over its lifetime (#19251).
|
||||
if len(_tool_defs_cache) >= _TOOL_DEFS_CACHE_MAX:
|
||||
_tool_defs_cache.pop(next(iter(_tool_defs_cache))) # evict oldest
|
||||
_tool_defs_cache[cache_key] = result
|
||||
return list(result)
|
||||
return result
|
||||
|
||||
+17
-18
@@ -73,25 +73,24 @@ in
|
||||
|
||||
patchPhase = ''
|
||||
runHook prePatch
|
||||
# Normalize trailing newlines on the root lockfile so source and
|
||||
# npm-deps always match, regardless of what fetchNpmDeps preserves.
|
||||
sed -i -z 's/\\n*$/\\n/' package-lock.json
|
||||
|
||||
# prefetch-npm-deps stores a *normalized* package-lock.json in the deps
|
||||
# cache: newer npm writes advisory fields (engines/os/cpu/funding/bin/…)
|
||||
# into lockfile entries, and prefetch strips the ones that don't affect
|
||||
# which tarballs are fetched. npmConfigHook then does a byte-for-byte
|
||||
# diff of the source lockfile against the cache's copy and fails on
|
||||
# those purely-cosmetic differences — this is what breaks cold builds
|
||||
# on a nixpkgs whose prefetch-npm-deps strips fields the committed
|
||||
# lockfile carries.
|
||||
#
|
||||
# Adopt the cache's own normalized lockfile as the source so the
|
||||
# consistency check is trivially satisfied. The resolved dependency set
|
||||
# (version/resolved/integrity/dependencies) is byte-identical either
|
||||
# way — fetchNpmDeps derived the cache *from* this lockfile — so `npm
|
||||
# ci` installs exactly the same tree; only advisory metadata is dropped.
|
||||
# Genuine drift is still caught upstream: a changed lockfile that didn't
|
||||
# get its npmDepsHash refreshed fails the fixed-output hash check before
|
||||
# this phase ever runs.
|
||||
cp --no-preserve=mode,ownership ${npmDeps}/package-lock.json package-lock.json
|
||||
# Make npmConfigHook's byte-for-byte diff newline-agnostic by
|
||||
# replacing its hardcoded /nix/store/.../diff with a wrapper that
|
||||
# normalizes trailing newlines on both sides before comparing.
|
||||
mkdir -p "$TMPDIR/bin"
|
||||
cat > "$TMPDIR/bin/diff" << DIFFWRAP
|
||||
#!/bin/sh
|
||||
f1=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$1" > "\\$f1"
|
||||
f2=\\$(mktemp) && sed -z 's/\\n*$/\\n/' "\\$2" > "\\$f2"
|
||||
${pkgs.diffutils}/bin/diff "\\$f1" "\\$f2" && rc=0 || rc=\\$?
|
||||
rm -f "\\$f1" "\\$f2"
|
||||
exit \\$rc
|
||||
DIFFWRAP
|
||||
chmod +x "$TMPDIR/bin/diff"
|
||||
export PATH="$TMPDIR/bin:$PATH"
|
||||
|
||||
runHook postPatch
|
||||
'';
|
||||
|
||||
+12
-3
@@ -49,6 +49,12 @@
|
||||
|
||||
configMergeScript = pkgs.callPackage ./configMergeScript.nix { };
|
||||
|
||||
# config.yaml mode: group-writable (0660) when interactive users share this
|
||||
# HERMES_HOME via addToSystemPackages, so they can save settings through the
|
||||
# CLI/TUI without hitting EACCES; otherwise group-read-only (0640). Secrets
|
||||
# (.env) stay 0640 regardless — see below.
|
||||
configYamlMode = if cfg.addToSystemPackages then "0660" else "0640";
|
||||
|
||||
# Generate .env from non-secret environment attrset
|
||||
envFileContent = lib.concatStringsSep "\n" (
|
||||
lib.mapAttrsToList (k: v: "${k}=${v}") cfg.environment
|
||||
@@ -728,7 +734,8 @@
|
||||
chmod 0750 ${cfg.stateDir}/home
|
||||
|
||||
# Create subdirs, set setgid + group-writable, migrate existing files.
|
||||
# Nix-managed files (config.yaml, .env, .managed) stay 0640/0644.
|
||||
# Nix-managed .env/.managed stay 0640/0644; config.yaml uses
|
||||
# configYamlMode (0660 under addToSystemPackages, else 0640).
|
||||
find ${cfg.stateDir}/.hermes -maxdepth 1 \
|
||||
\( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" -o -name "SOUL.md" \) \
|
||||
-exec chmod g+rw {} + 2>/dev/null || true
|
||||
@@ -743,12 +750,14 @@
|
||||
# Merge Nix settings into existing config.yaml.
|
||||
# Preserves user-added keys (skills, streaming, etc.); Nix keys win.
|
||||
# If configFile is user-provided (not generated), overwrite instead of merge.
|
||||
# Mode is configYamlMode (0660 under addToSystemPackages so interactive
|
||||
# hermes-group users can save settings via the CLI/TUI, else 0640).
|
||||
${if cfg.configFile != null then ''
|
||||
install -o ${cfg.user} -g ${cfg.group} -m 0640 -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
|
||||
install -o ${cfg.user} -g ${cfg.group} -m ${configYamlMode} -D ${configFile} ${cfg.stateDir}/.hermes/config.yaml
|
||||
'' else ''
|
||||
${configMergeScript} ${generatedConfigFile} ${cfg.stateDir}/.hermes/config.yaml
|
||||
chown ${cfg.user}:${cfg.group} ${cfg.stateDir}/.hermes/config.yaml
|
||||
chmod 0640 ${cfg.stateDir}/.hermes/config.yaml
|
||||
chmod ${configYamlMode} ${cfg.stateDir}/.hermes/config.yaml
|
||||
''}
|
||||
|
||||
# Managed mode marker (so interactive shells also detect NixOS management)
|
||||
|
||||
@@ -86,6 +86,7 @@ class AudioBridge:
|
||||
["pactl", "unload-module", str(mod_id)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except Exception:
|
||||
# Best-effort teardown — never raise from here.
|
||||
@@ -111,6 +112,7 @@ class AudioBridge:
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise RuntimeError(
|
||||
@@ -135,6 +137,7 @@ class AudioBridge:
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
# Roll back the null-sink we just created so we don't leak it.
|
||||
@@ -142,6 +145,7 @@ class AudioBridge:
|
||||
["pactl", "unload-module", str(sink_mod_id)],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"pactl load-module virtual-source failed: {exc.stderr or exc}"
|
||||
|
||||
@@ -94,6 +94,7 @@ def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True,
|
||||
timeout=timeout, cwd=effective_cwd, env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
stdout = result.stdout.strip()
|
||||
stderr = result.stderr.strip()
|
||||
|
||||
@@ -695,6 +695,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
subprocess.run(
|
||||
[uv_path, "pip", "install", "--python", sys.executable, "--quiet", "--upgrade"] + deps_to_install,
|
||||
check=True, timeout=120, capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
print(" ✓ Dependencies up to date")
|
||||
except Exception as e:
|
||||
@@ -1101,6 +1102,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
||||
[uv_path, "pip", "install", "--python", sys.executable,
|
||||
"--quiet", "--upgrade", f"hindsight-client>={_MIN_CLIENT_VERSION}"],
|
||||
check=True, timeout=120, capture_output=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
logger.info("hindsight-client upgraded to >=%s", _MIN_CLIENT_VERSION)
|
||||
except Exception as e:
|
||||
|
||||
@@ -416,6 +416,7 @@ def _ensure_sdk_installed() -> bool:
|
||||
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print(" Installed.\n")
|
||||
|
||||
+109
-99
@@ -22,6 +22,7 @@ from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.profiles import _get_default_hermes_home
|
||||
from plugins.plugin_utils import SingletonSlot
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -627,6 +628,7 @@ class HonchoClientConfig:
|
||||
root = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
capture_output=True, text=True, cwd=cwd, timeout=5,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
if root.returncode == 0:
|
||||
return Path(root.stdout.strip()).name
|
||||
@@ -737,7 +739,7 @@ class HonchoClientConfig:
|
||||
return self.workspace_id
|
||||
|
||||
|
||||
_honcho_client: Honcho | None = None
|
||||
_honcho_client_slot: SingletonSlot = SingletonSlot()
|
||||
|
||||
|
||||
def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
@@ -745,11 +747,14 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
|
||||
When no config is provided, attempts to load ~/.honcho/config.json
|
||||
first, falling back to environment variables.
|
||||
"""
|
||||
global _honcho_client
|
||||
|
||||
if _honcho_client is not None:
|
||||
return _honcho_client
|
||||
Thread-safe: the client is built exactly once even under concurrent
|
||||
first calls (double-checked locking via ``SingletonSlot``), so racing
|
||||
threads can't each construct a client and leak the loser's connection.
|
||||
"""
|
||||
cached = _honcho_client_slot.peek()
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if config is None:
|
||||
config = HonchoClientConfig.from_global_config()
|
||||
@@ -762,111 +767,116 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
"For local instances, set HONCHO_BASE_URL instead."
|
||||
)
|
||||
|
||||
# Lazy-install the honcho SDK on demand. ensure() honors
|
||||
# security.allow_lazy_installs (default true). On failure we surface
|
||||
# the original ImportError-shape message so existing callers still get
|
||||
# the "go run hermes honcho setup" hint they used to.
|
||||
try:
|
||||
from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure
|
||||
_lazy_ensure("memory.honcho", prompt=False)
|
||||
except ImportError:
|
||||
# lazy_deps module missing — fall through to the raw import below.
|
||||
pass
|
||||
except Exception:
|
||||
# FeatureUnavailable or unexpected error. Don't crash here; let the
|
||||
# actual import attempt produce the canonical error message.
|
||||
pass
|
||||
|
||||
try:
|
||||
from honcho import Honcho
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"honcho-ai is required for Honcho integration. "
|
||||
"Install it with: pip install honcho-ai "
|
||||
"(or run `hermes honcho setup` to configure)."
|
||||
)
|
||||
|
||||
# Allow config.yaml honcho.base_url to override the SDK's environment
|
||||
# mapping, enabling remote self-hosted Honcho deployments without
|
||||
# requiring the server to live on localhost.
|
||||
resolved_base_url = config.base_url
|
||||
resolved_timeout = config.timeout
|
||||
if not resolved_base_url or resolved_timeout is None:
|
||||
# Everything below is the expensive part the issue flags: lazy SDK
|
||||
# install, config resolution, and client construction. Run it inside the
|
||||
# slot's factory so it executes exactly once even when several threads
|
||||
# race the first call — the slot's double-checked lock serializes them and
|
||||
# the losers get the winner's client instead of building their own.
|
||||
def _build() -> "Honcho":
|
||||
# Lazy-install the honcho SDK on demand. ensure() honors
|
||||
# security.allow_lazy_installs (default true). On failure we surface
|
||||
# the original ImportError-shape message so existing callers still get
|
||||
# the "go run hermes honcho setup" hint they used to.
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
hermes_cfg = load_config()
|
||||
honcho_cfg = hermes_cfg.get("honcho", {})
|
||||
if isinstance(honcho_cfg, dict):
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = honcho_cfg.get("base_url", "").strip() or None
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _resolve_optional_float(
|
||||
honcho_cfg.get("timeout"),
|
||||
honcho_cfg.get("request_timeout"),
|
||||
)
|
||||
from tools.lazy_deps import FeatureUnavailable, ensure as _lazy_ensure
|
||||
_lazy_ensure("memory.honcho", prompt=False)
|
||||
except ImportError:
|
||||
# lazy_deps module missing — fall through to the raw import below.
|
||||
pass
|
||||
except Exception:
|
||||
# FeatureUnavailable or unexpected error. Don't crash here; let the
|
||||
# actual import attempt produce the canonical error message.
|
||||
pass
|
||||
|
||||
# Fall back to the default so an unconfigured install cannot hang
|
||||
# indefinitely on a stalled Honcho request.
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _DEFAULT_HTTP_TIMEOUT
|
||||
try:
|
||||
from honcho import Honcho
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"honcho-ai is required for Honcho integration. "
|
||||
"Install it with: pip install honcho-ai "
|
||||
"(or run `hermes honcho setup` to configure)."
|
||||
)
|
||||
|
||||
if resolved_base_url:
|
||||
logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", resolved_base_url, config.workspace_id)
|
||||
else:
|
||||
logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id)
|
||||
# Allow config.yaml honcho.base_url to override the SDK's environment
|
||||
# mapping, enabling remote self-hosted Honcho deployments without
|
||||
# requiring the server to live on localhost.
|
||||
resolved_base_url = config.base_url
|
||||
resolved_timeout = config.timeout
|
||||
if not resolved_base_url or resolved_timeout is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
hermes_cfg = load_config()
|
||||
honcho_cfg = hermes_cfg.get("honcho", {})
|
||||
if isinstance(honcho_cfg, dict):
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = honcho_cfg.get("base_url", "").strip() or None
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _resolve_optional_float(
|
||||
honcho_cfg.get("timeout"),
|
||||
honcho_cfg.get("request_timeout"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Local Honcho instances don't require an API key, but the SDK
|
||||
# expects a non-empty string. Use a placeholder for local URLs.
|
||||
# For local: only use config.api_key if the host block explicitly
|
||||
# sets apiKey (meaning the user wants local auth). Otherwise skip
|
||||
# the stored key -- it's likely a cloud key that would break local.
|
||||
_is_local = resolved_base_url and (
|
||||
"localhost" in resolved_base_url
|
||||
or "127.0.0.1" in resolved_base_url
|
||||
or "::1" in resolved_base_url
|
||||
)
|
||||
if _is_local:
|
||||
# Check if the host block has its own apiKey (explicit local auth).
|
||||
# Auth-skipping is loopback-only: a stored key is likely a cloud key
|
||||
# that would break a no-auth local server, so we substitute the SDK's
|
||||
# required-non-empty placeholder unless the host block opts in.
|
||||
_raw = config.raw or {}
|
||||
_host_block = (_raw.get("hosts") or {}).get(config.host, {})
|
||||
_host_has_key = bool(_host_block.get("apiKey"))
|
||||
effective_api_key = config.api_key if _host_has_key else "local"
|
||||
else:
|
||||
effective_api_key = config.api_key
|
||||
# Fall back to the default so an unconfigured install cannot hang
|
||||
# indefinitely on a stalled Honcho request.
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _DEFAULT_HTTP_TIMEOUT
|
||||
|
||||
# The Honcho SDK's route builders (e.g. routes.workspaces()) already
|
||||
# include the version prefix (e.g. "/v3/workspaces"). When a user-supplied
|
||||
# base_url already ends in a version segment (e.g.
|
||||
# "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating
|
||||
# the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure
|
||||
# routing concern independent of host, so strip a trailing version segment
|
||||
# from ANY base_url — loopback, LAN, custom domain, or cloud alike. The
|
||||
# SDK then appends its own versioned paths correctly.
|
||||
if resolved_base_url:
|
||||
import re as _re
|
||||
resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/")
|
||||
if resolved_base_url:
|
||||
logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", resolved_base_url, config.workspace_id)
|
||||
else:
|
||||
logger.info("Initializing Honcho client (host: %s, workspace: %s)", config.host, config.workspace_id)
|
||||
|
||||
kwargs: dict = {
|
||||
"workspace_id": config.workspace_id,
|
||||
"api_key": effective_api_key,
|
||||
"environment": config.environment,
|
||||
}
|
||||
if resolved_base_url:
|
||||
kwargs["base_url"] = resolved_base_url
|
||||
if resolved_timeout is not None:
|
||||
kwargs["timeout"] = resolved_timeout
|
||||
# Local Honcho instances don't require an API key, but the SDK
|
||||
# expects a non-empty string. Use a placeholder for local URLs.
|
||||
# For local: only use config.api_key if the host block explicitly
|
||||
# sets apiKey (meaning the user wants local auth). Otherwise skip
|
||||
# the stored key -- it's likely a cloud key that would break local.
|
||||
_is_local = resolved_base_url and (
|
||||
"localhost" in resolved_base_url
|
||||
or "127.0.0.1" in resolved_base_url
|
||||
or "::1" in resolved_base_url
|
||||
)
|
||||
if _is_local:
|
||||
# Check if the host block has its own apiKey (explicit local auth).
|
||||
# Auth-skipping is loopback-only: a stored key is likely a cloud key
|
||||
# that would break a no-auth local server, so we substitute the SDK's
|
||||
# required-non-empty placeholder unless the host block opts in.
|
||||
_raw = config.raw or {}
|
||||
_host_block = (_raw.get("hosts") or {}).get(config.host, {})
|
||||
_host_has_key = bool(_host_block.get("apiKey"))
|
||||
effective_api_key = config.api_key if _host_has_key else "local"
|
||||
else:
|
||||
effective_api_key = config.api_key
|
||||
|
||||
_honcho_client = Honcho(**kwargs)
|
||||
# The Honcho SDK's route builders (e.g. routes.workspaces()) already
|
||||
# include the version prefix (e.g. "/v3/workspaces"). When a user-supplied
|
||||
# base_url already ends in a version segment (e.g.
|
||||
# "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating
|
||||
# the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure
|
||||
# routing concern independent of host, so strip a trailing version segment
|
||||
# from ANY base_url — loopback, LAN, custom domain, or cloud alike. The
|
||||
# SDK then appends its own versioned paths correctly.
|
||||
if resolved_base_url:
|
||||
import re as _re
|
||||
resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/")
|
||||
|
||||
return _honcho_client
|
||||
kwargs: dict = {
|
||||
"workspace_id": config.workspace_id,
|
||||
"api_key": effective_api_key,
|
||||
"environment": config.environment,
|
||||
}
|
||||
if resolved_base_url:
|
||||
kwargs["base_url"] = resolved_base_url
|
||||
if resolved_timeout is not None:
|
||||
kwargs["timeout"] = resolved_timeout
|
||||
|
||||
return Honcho(**kwargs)
|
||||
|
||||
return _honcho_client_slot.get(_build)
|
||||
|
||||
|
||||
def reset_honcho_client() -> None:
|
||||
"""Reset the Honcho client singleton (useful for testing)."""
|
||||
global _honcho_client
|
||||
_honcho_client = None
|
||||
_honcho_client_slot.reset()
|
||||
|
||||
@@ -837,8 +837,16 @@ def on_post_llm_call(*, task_id: str = "", session_id: str = "", provider: str =
|
||||
if output.get("tool_calls"):
|
||||
state.turn_tool_calls.extend(output["tool_calls"])
|
||||
|
||||
# Extract usage: prefer response object, fall back to usage dict from post_api_request
|
||||
if response is not None:
|
||||
# Extract usage: prefer a real response object that carries usage, else
|
||||
# fall back to the usage summary dict from post_api_request.
|
||||
#
|
||||
# post_api_request passes `response` as a SANITIZED dict (no ``.usage``
|
||||
# attribute) alongside a separate `usage` summary dict. Gating on
|
||||
# ``response is not None`` here took the response-object path on that dict,
|
||||
# where ``getattr(response, "usage", None)`` is always None — so usage and
|
||||
# cost were silently dropped for every gateway turn. Gate on a real
|
||||
# ``.usage`` attribute instead so the usage-dict fallback below is reached.
|
||||
if getattr(response, "usage", None) is not None:
|
||||
usage_details, cost_details = _usage_and_cost(
|
||||
response,
|
||||
provider=provider,
|
||||
|
||||
@@ -163,7 +163,11 @@ agent_version = "local"
|
||||
|
||||
When `HERMES_NEMO_RELAY_PLUGINS_TOML` is set and initializes successfully, NeMo
|
||||
Relay owns exporter lifecycle through that config. The direct
|
||||
`HERMES_NEMO_RELAY_ATOF_*` fallback setup is skipped.
|
||||
`HERMES_NEMO_RELAY_ATOF_*` fallback setup is skipped. If the same
|
||||
`plugins.toml` observability config enables `atif`, the direct
|
||||
`HERMES_NEMO_RELAY_ATIF_*` fallback setup is also skipped so Hermes does not
|
||||
double-export trajectories on teardown. If `plugins.toml` initialization fails,
|
||||
Hermes keeps the direct env-var fallbacks active for that run.
|
||||
|
||||
To enable NeMo Relay managed execution intercepts for provider and tool calls,
|
||||
include an adaptive component in the same `plugins.toml`:
|
||||
@@ -173,8 +177,8 @@ include an adaptive component in the same `plugins.toml`:
|
||||
kind = "adaptive"
|
||||
enabled = true
|
||||
|
||||
[components.config]
|
||||
mode = "route"
|
||||
[components.config.tool_parallelism]
|
||||
mode = "observe_only"
|
||||
```
|
||||
|
||||
When the adaptive component is enabled and the installed NeMo Relay runtime
|
||||
@@ -182,15 +186,16 @@ exposes `llm.execute(...)` / `tools.execute(...)`, Hermes routes LLM and tool
|
||||
execution through those middleware boundaries. The observer hooks still emit
|
||||
session, turn, approval, and subagent marks; the plugin skips its manual
|
||||
`llm.call` and `tools.call` spans for executions that are already managed by
|
||||
NeMo Relay.
|
||||
NeMo Relay. `tool_parallelism.mode = "observe_only"` keeps tool scheduling
|
||||
observational while still wrapping the real execution boundary.
|
||||
|
||||
For the full generic Hermes middleware contract, see
|
||||
[`docs/middleware/README.md`](../../../docs/middleware/README.md).
|
||||
|
||||
## Canonical Local Examples
|
||||
|
||||
The examples below use the official `nemo-relay==0.3` distribution and a local
|
||||
Ollama model served through the OpenAI-compatible API.
|
||||
The observe-only examples in this section use the official `nemo-relay==0.3`
|
||||
distribution and a local Ollama model served through the OpenAI-compatible API.
|
||||
|
||||
```bash
|
||||
pip install "nemo-relay==0.3"
|
||||
@@ -404,8 +409,8 @@ version = 1
|
||||
kind = "adaptive"
|
||||
enabled = true
|
||||
|
||||
[components.config]
|
||||
mode = "route"
|
||||
[components.config.tool_parallelism]
|
||||
mode = "observe_only"
|
||||
```
|
||||
|
||||
Enable it for Hermes:
|
||||
@@ -438,11 +443,12 @@ for the same execution.
|
||||
### Local Adaptive E2E
|
||||
|
||||
This example enables both NeMo Relay observability export and adaptive execution
|
||||
middleware for a local Hermes run.
|
||||
middleware for a local Hermes run. This path requires a NeMo Relay runtime that
|
||||
supports `[components.config.tool_parallelism]`; the `nemo-relay==0.3`
|
||||
install used by the earlier observability-only examples does not support this
|
||||
adaptive config.
|
||||
|
||||
```bash
|
||||
pip install "nemo-relay==0.3"
|
||||
|
||||
export HERMES_HOME=/tmp/hermes-middleware-test/hermes-home
|
||||
mkdir -p "$HERMES_HOME" /tmp/hermes-middleware-test/nemo-relay
|
||||
|
||||
@@ -484,8 +490,8 @@ agent_version = "local"
|
||||
kind = "adaptive"
|
||||
enabled = true
|
||||
|
||||
[components.config]
|
||||
mode = "route"
|
||||
[components.config.tool_parallelism]
|
||||
mode = "observe_only"
|
||||
TOML
|
||||
|
||||
export HERMES_NEMO_RELAY_PLUGINS_TOML=/tmp/hermes-middleware-test/nemo-relay/plugins.toml
|
||||
@@ -510,8 +516,8 @@ middleware_execution_ok
|
||||
Expected ATOF shape:
|
||||
|
||||
```jsonl
|
||||
{"kind":"scope","category":"llm","name":"custom","scope_category":"start","metadata":{"session_id":"middleware-demo-session"},"data":{"mode":"route"}}
|
||||
{"kind":"scope","category":"tool","name":"terminal","scope_category":"start","metadata":{"session_id":"middleware-demo-session","tool_call_id":"call_terminal"},"data":{"mode":"route"}}
|
||||
{"kind":"scope","category":"llm","name":"custom","scope_category":"start","metadata":{"session_id":"middleware-demo-session"},"data":{"mode":"observe_only"}}
|
||||
{"kind":"scope","category":"tool","name":"terminal","scope_category":"start","metadata":{"session_id":"middleware-demo-session","tool_call_id":"call_terminal"},"data":{"mode":"observe_only"}}
|
||||
{"kind":"scope","category":"tool","name":"terminal","scope_category":"end","metadata":{"session_id":"middleware-demo-session","tool_call_id":"call_terminal","status":"ok"},"data":"{\"output\":\"middleware_execution_ok\",\"exit_code\":0,\"error\":null}"}
|
||||
```
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class _Settings:
|
||||
plugins_toml_path: str = ""
|
||||
plugins_config: dict[str, Any] | None = None
|
||||
adaptive_enabled: bool = False
|
||||
adaptive_mode: str = "observe"
|
||||
adaptive_mode: str = "observe_only"
|
||||
atof_enabled: bool = False
|
||||
atof_output_directory: str = ""
|
||||
atof_filename: str = "hermes-atof.jsonl"
|
||||
@@ -65,9 +65,11 @@ class _Runtime:
|
||||
self.sessions: dict[str, _SessionState] = {}
|
||||
self.subagent_parents: dict[str, _SubagentParent] = {}
|
||||
self.atof_exporter: Any = None
|
||||
self._atof_subscriber_name = "hermes.nemo_relay.atof"
|
||||
self._plugin_config_initialized = self._configure_plugins_toml()
|
||||
self._plugin_config_needs_reinit = False
|
||||
if not self._plugin_config_initialized:
|
||||
self._configure_atof()
|
||||
self._activate_direct_fallbacks()
|
||||
|
||||
def _configure_plugins_toml(self) -> bool:
|
||||
if not self.settings.plugins_config:
|
||||
@@ -78,17 +80,45 @@ class _Runtime:
|
||||
return False
|
||||
try:
|
||||
self._ensure_plugin_config_output_dirs(self.settings.plugins_config)
|
||||
result = initialize(self.settings.plugins_config)
|
||||
if inspect.isawaitable(result):
|
||||
asyncio.run(result)
|
||||
_resolve_awaitable(initialize(self.settings.plugins_config))
|
||||
return True
|
||||
except RuntimeError:
|
||||
logger.debug("NeMo Relay plugins.toml init skipped inside a running event loop")
|
||||
return False
|
||||
except Exception as exc:
|
||||
logger.debug("NeMo Relay plugins.toml init failed: %s", exc, exc_info=True)
|
||||
return False
|
||||
|
||||
def _clear_plugins_toml(self) -> None:
|
||||
if not self._plugin_config_initialized:
|
||||
return
|
||||
plugin_mod = getattr(self.nemo_relay, "plugin", None)
|
||||
clear = getattr(plugin_mod, "clear", None)
|
||||
if not callable(clear):
|
||||
return
|
||||
try:
|
||||
_resolve_awaitable(clear())
|
||||
finally:
|
||||
self._plugin_config_initialized = False
|
||||
self._plugin_config_needs_reinit = bool(self.settings.plugins_config)
|
||||
|
||||
def _activate_direct_fallbacks(self) -> None:
|
||||
self._plugin_config_needs_reinit = False
|
||||
self._configure_atof()
|
||||
|
||||
def _maybe_reinitialize_plugins_toml(self) -> None:
|
||||
if not self._plugin_config_needs_reinit or self._plugin_config_initialized:
|
||||
return
|
||||
self._plugin_config_initialized = self._configure_plugins_toml()
|
||||
if not self._plugin_config_initialized:
|
||||
self._activate_direct_fallbacks()
|
||||
return
|
||||
self._clear_atof()
|
||||
self._plugin_config_needs_reinit = False
|
||||
|
||||
def _plugins_toml_owns_exporter(self, exporter_name: str) -> bool:
|
||||
return self._plugin_config_initialized and _observability_exporter_enabled(
|
||||
self.settings.plugins_config,
|
||||
exporter_name,
|
||||
)
|
||||
|
||||
def _ensure_plugin_config_output_dirs(self, config: dict[str, Any]) -> None:
|
||||
for component in config.get("components", []):
|
||||
if not isinstance(component, dict):
|
||||
@@ -109,7 +139,7 @@ class _Runtime:
|
||||
Path(output_directory).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _configure_atof(self) -> None:
|
||||
if not self.settings.atof_enabled:
|
||||
if not self.settings.atof_enabled or self.atof_exporter is not None:
|
||||
return
|
||||
config = self.nemo_relay.AtofExporterConfig()
|
||||
if self.settings.atof_output_directory:
|
||||
@@ -121,16 +151,28 @@ class _Runtime:
|
||||
else:
|
||||
config.mode = self.nemo_relay.AtofExporterMode.Append
|
||||
self.atof_exporter = self.nemo_relay.AtofExporter(config)
|
||||
self.atof_exporter.register("hermes.nemo_relay.atof")
|
||||
self.atof_exporter.register(self._atof_subscriber_name)
|
||||
|
||||
def _clear_atof(self) -> None:
|
||||
if self.atof_exporter is None:
|
||||
return
|
||||
deregister = getattr(self.atof_exporter, "deregister", None)
|
||||
if callable(deregister):
|
||||
try:
|
||||
deregister(self._atof_subscriber_name)
|
||||
except Exception:
|
||||
logger.debug("NeMo Relay ATOF deregister failed", exc_info=True)
|
||||
self.atof_exporter = None
|
||||
|
||||
def ensure_session(self, kwargs: dict[str, Any]) -> _SessionState:
|
||||
self._maybe_reinitialize_plugins_toml()
|
||||
session_id = _session_id(kwargs)
|
||||
state = self.sessions.get(session_id)
|
||||
if state is not None:
|
||||
return state
|
||||
|
||||
state = _SessionState(session_id=session_id)
|
||||
if self.settings.atif_enabled:
|
||||
if self.settings.atif_enabled and not self._plugins_toml_owns_exporter("atif"):
|
||||
state.atif_exporter = self.nemo_relay.AtifExporter(
|
||||
session_id,
|
||||
self.settings.atif_agent_name,
|
||||
@@ -189,6 +231,13 @@ class _Runtime:
|
||||
state.atif_exporter.deregister(state.atif_subscriber_name)
|
||||
except Exception:
|
||||
logger.debug("NeMo Relay ATIF deregister failed", exc_info=True)
|
||||
if self._plugin_config_initialized and not self.sessions:
|
||||
try:
|
||||
self._clear_plugins_toml()
|
||||
except Exception:
|
||||
logger.debug("NeMo Relay plugins.toml clear failed", exc_info=True)
|
||||
elif self.settings.plugins_config and not self.sessions:
|
||||
self._plugin_config_needs_reinit = True
|
||||
|
||||
def mark(self, name: str, kwargs: dict[str, Any]) -> None:
|
||||
state = self.ensure_session(kwargs)
|
||||
@@ -611,11 +660,29 @@ def _enabled_component_config(
|
||||
|
||||
def _adaptive_mode(config: dict[str, Any] | None) -> str:
|
||||
if not isinstance(config, dict):
|
||||
return "observe"
|
||||
return "observe_only"
|
||||
tool_parallelism = config.get("tool_parallelism")
|
||||
if isinstance(tool_parallelism, dict):
|
||||
mode = tool_parallelism.get("mode")
|
||||
if isinstance(mode, str) and mode.strip():
|
||||
return mode.strip()
|
||||
mode = config.get("mode")
|
||||
if isinstance(mode, str) and mode.strip():
|
||||
return mode.strip()
|
||||
return "observe"
|
||||
return "observe_only"
|
||||
|
||||
|
||||
def _observability_exporter_enabled(
|
||||
plugins_config: dict[str, Any] | None,
|
||||
exporter_name: str,
|
||||
) -> bool:
|
||||
observability_config = _enabled_component_config(plugins_config, "observability")
|
||||
if not isinstance(observability_config, dict):
|
||||
return False
|
||||
exporter_config = observability_config.get(exporter_name)
|
||||
if not isinstance(exporter_config, dict):
|
||||
return False
|
||||
return exporter_config.get("enabled", True) is not False
|
||||
|
||||
|
||||
def _env(name: str) -> str:
|
||||
|
||||
@@ -520,6 +520,7 @@ class VoiceReceiver:
|
||||
],
|
||||
check=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -320,6 +320,7 @@ def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]:
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
logger.warning("decode_to_pcm failed for %s: %s", path, e)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Photon iMessage platform plugin
|
||||
|
||||
This plugin connects Hermes Agent to iMessage (and other Spectrum
|
||||
interfaces) through [Photon][photon] — a managed service that handles
|
||||
iMessage line allocation, delivery, and abuse-prevention so users don't
|
||||
have to run their own Mac relay.
|
||||
|
||||
The free tier uses Photon's shared iMessage line pool and is the path we
|
||||
recommend for everyone who doesn't already pay for a dedicated number.
|
||||
|
||||
## Architecture
|
||||
|
||||
Like Discord and Slack, Photon is a **persistent-connection** channel — no
|
||||
public URL, no webhook, no signing secret. The `spectrum-ts` SDK holds a
|
||||
long-lived **gRPC stream** to Photon for both directions. Because the SDK is
|
||||
TypeScript-only, Hermes runs it inside a small supervised Node sidecar and
|
||||
talks to it over loopback.
|
||||
|
||||
```
|
||||
gRPC (spectrum-ts)
|
||||
┌─────────────────────────┐ ◄───────────────► ┌──────────────────────┐
|
||||
│ Photon Spectrum cloud │ app.messages │ Node sidecar │
|
||||
│ (iMessage line owner) │ space.send() │ (plugins/…/sidecar) │
|
||||
└─────────────────────────┘ └──────────┬───────────┘
|
||||
GET /inbound (NDJSON) │ ▲ POST /send
|
||||
inbound events ▼ │ /typing
|
||||
┌──────────────────────┐
|
||||
│ PhotonAdapter │
|
||||
│ (Python, in gateway) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
- **Inbound**: the sidecar consumes the SDK's `app.messages` gRPC stream,
|
||||
normalizes each message, and streams it to the adapter over a loopback
|
||||
`GET /inbound` (NDJSON). The adapter dedupes on `messageId` and dispatches
|
||||
a `MessageEvent` to the gateway. It reconnects automatically if the stream
|
||||
drops; the sidecar owns the gRPC reconnect to Photon.
|
||||
- **Outbound**: `send` / `send_typing` are loopback POSTs to the sidecar,
|
||||
authenticated with a shared `X-Hermes-Sidecar-Token`.
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
# One-shot setup: device login (opens browser) + project + user + sidecar deps
|
||||
hermes photon setup --phone +15551234567
|
||||
|
||||
# Start the gateway
|
||||
hermes gateway start --platform photon
|
||||
```
|
||||
|
||||
`hermes photon setup` does, in order:
|
||||
|
||||
1. **Device login** (RFC 8628, `client_id=photon-cli`) — opens
|
||||
`https://app.photon.codes/` for approval and stores the bearer token.
|
||||
2. **Find or create** the `Hermes Agent` project on the Photon dashboard.
|
||||
3. **Enable Spectrum**, read the project's `spectrumProjectId`, rotate the
|
||||
project secret, and persist both.
|
||||
4. **Register your phone number** as a Spectrum user (idempotent — skipped if
|
||||
a user with that number already exists).
|
||||
5. **Print the assigned iMessage line** — the number you text to reach your
|
||||
agent.
|
||||
6. **Install the sidecar deps** (`spectrum-ts`).
|
||||
|
||||
There is no separate `login` command; like every other Hermes channel,
|
||||
onboarding goes through one setup surface. Re-running `setup` reuses an
|
||||
existing token/project, so it's safe to run again to finish a partial setup.
|
||||
Run `hermes photon status` to see what's configured.
|
||||
|
||||
## Credentials
|
||||
|
||||
Runtime SDK credentials live in `~/.hermes/.env` (the same place every other
|
||||
channel keeps its token), and the adapter reads them from the environment:
|
||||
|
||||
```bash
|
||||
PHOTON_PROJECT_ID=<spectrumProjectId> # the SDK's projectId
|
||||
PHOTON_PROJECT_SECRET=<projectSecret>
|
||||
```
|
||||
|
||||
Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"credential_pool": {
|
||||
"photon": [
|
||||
{ "access_token": "<device-bearer>", "issued_at": ... }
|
||||
],
|
||||
"photon_project": [
|
||||
{
|
||||
"dashboard_project_id": "<dashboard id>",
|
||||
"spectrum_project_id": "<spectrumProjectId>",
|
||||
"project_secret": "<projectSecret>",
|
||||
"name": "Hermes Agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Note on ids.** A Photon project has two identifiers: the dashboard `id`
|
||||
> (used for management API calls) and the `spectrumProjectId` (what the SDK
|
||||
> authenticates with). `PHOTON_PROJECT_ID` is the **spectrum** id.
|
||||
|
||||
## Configuration knobs
|
||||
|
||||
All env vars are documented in `plugin.yaml`. The most important:
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
|---------------------------|----------------------------|--------------------------------------|
|
||||
| `PHOTON_PROJECT_ID` | from .env / auth.json | Spectrum project id (SDK `projectId`)|
|
||||
| `PHOTON_PROJECT_SECRET` | from .env / auth.json | Project secret |
|
||||
| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for the sidecar |
|
||||
| `PHOTON_SIDECAR_AUTOSTART`| true | Spawn the sidecar on connect |
|
||||
| `PHOTON_DASHBOARD_HOST` | https://app.photon.codes | Dashboard API host |
|
||||
| `PHOTON_SPECTRUM_HOST` | https://spectrum.photon.codes | Spectrum API host |
|
||||
| `PHOTON_HOME_CHANNEL` | your number (set by setup) | Default space for cron delivery — a space id, or a bare E.164 number (resolved to a DM) |
|
||||
| `PHOTON_ALLOWED_USERS` | your number (set by setup) | Comma-separated E.164 allowlist |
|
||||
| `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word |
|
||||
| `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` | 20 MB | Max inbound attachment size the sidecar reads & inlines |
|
||||
|
||||
## Attachments & limitations
|
||||
|
||||
- **Inbound attachments and voice notes are downloaded.** The sidecar reads
|
||||
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
|
||||
adapter caches them to the shared media cache and populates `media_urls` /
|
||||
`media_types`, so the agent sees the real image/file or can transcribe the
|
||||
voice note — parity with the BlueBubbles iMessage channel. Media larger than
|
||||
`PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or any byte read that
|
||||
fails, falls back to a text marker (`[Photon attachment received: …]` or
|
||||
`[Photon voice received: …]`) so the agent still knows something arrived.
|
||||
- **Outbound attachments are supported.** Images, voice notes, video, and
|
||||
documents are sent via `space.send(attachment(...))` /
|
||||
`space.send(voice(...))` through the sidecar's `/send-attachment`
|
||||
endpoint; a caption is delivered as a separate text bubble after the media.
|
||||
- **Reactions, message effects, polls** — supported by `spectrum-ts` but not
|
||||
yet exposed; the sidecar is the natural place to add them.
|
||||
|
||||
[photon]: https://photon.codes/
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Photon Spectrum (iMessage) platform plugin entry point."""
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
``hermes photon ...`` CLI subcommands — registered by the plugin via
|
||||
``ctx.register_cli_command()``.
|
||||
|
||||
Subcommands:
|
||||
|
||||
setup full first-time setup (device login + project + user + sidecar)
|
||||
status show login + project + sidecar dep state
|
||||
install-sidecar npm install inside plugins/platforms/photon/sidecar/
|
||||
|
||||
The device-code login runs automatically as the first step of ``setup``;
|
||||
there is no standalone ``login`` verb (matching how every other Hermes
|
||||
gateway channel onboards through a single setup surface).
|
||||
|
||||
Photon uses the spectrum-ts gRPC stream for inbound — there is no webhook
|
||||
to register, so there are no webhook subcommands.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
from . import auth as photon_auth
|
||||
|
||||
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# argparse wiring
|
||||
|
||||
def register_cli(parser: argparse.ArgumentParser) -> None:
|
||||
"""Wire up `hermes photon ...` subcommands."""
|
||||
subs = parser.add_subparsers(dest="photon_command", required=False)
|
||||
|
||||
p_setup = subs.add_parser(
|
||||
"setup",
|
||||
help="First-time setup (device login + project + user + sidecar)",
|
||||
)
|
||||
p_setup.add_argument("--project-name", default=None,
|
||||
help="Project name (default: 'Hermes Agent')")
|
||||
p_setup.add_argument("--phone", default=None,
|
||||
help="Your E.164 phone number (e.g. +15551234567)")
|
||||
p_setup.add_argument("--first-name", default=None)
|
||||
p_setup.add_argument("--last-name", default=None)
|
||||
p_setup.add_argument("--email", default=None)
|
||||
p_setup.add_argument("--no-browser", action="store_true",
|
||||
help="Don't try to open a browser for device login; print the URL only")
|
||||
p_setup.add_argument("--skip-sidecar-install", action="store_true",
|
||||
help="Skip `npm install` inside the sidecar directory")
|
||||
|
||||
subs.add_parser("status", help="Show login + project + sidecar dep state")
|
||||
subs.add_parser("install-sidecar", help="Run npm install inside the sidecar directory")
|
||||
|
||||
parser.set_defaults(func=dispatch)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatch
|
||||
|
||||
def dispatch(args: argparse.Namespace) -> int:
|
||||
sub = getattr(args, "photon_command", None)
|
||||
if sub is None:
|
||||
# No subcommand given — show status by default.
|
||||
return _cmd_status(args)
|
||||
if sub == "setup":
|
||||
return _cmd_setup(args)
|
||||
if sub == "status":
|
||||
return _cmd_status(args)
|
||||
if sub == "install-sidecar":
|
||||
return _cmd_install_sidecar(args)
|
||||
print(f"unknown subcommand: {sub}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subcommand handlers
|
||||
|
||||
def _run_device_login(args: argparse.Namespace) -> int:
|
||||
"""Run the RFC 8628 device-code login flow and persist the token.
|
||||
|
||||
Internal helper — invoked as the first step of ``setup``. There is
|
||||
no standalone ``hermes photon login`` command; Photon onboards
|
||||
through the single ``setup`` surface like every other channel.
|
||||
"""
|
||||
def _print_code(code):
|
||||
target = code.verification_uri_complete or code.verification_uri
|
||||
print()
|
||||
print("┌─ Photon device login ────────────────────────────────────────")
|
||||
print(f"│ Open this URL: {target}")
|
||||
print(f"│ Enter the code: {code.user_code}")
|
||||
print("│ (waiting for approval — Ctrl-C to cancel)")
|
||||
print("└──────────────────────────────────────────────────────────────")
|
||||
print()
|
||||
|
||||
try:
|
||||
token = photon_auth.login_device_flow(
|
||||
open_browser=not args.no_browser,
|
||||
on_user_code=_print_code,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"login failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
# Don't print any portion of the token — even a prefix can help a
|
||||
# shoulder-surfer or accidentally leak into a screen recording.
|
||||
_ = token
|
||||
print(f"✓ logged in — token saved to {photon_auth._auth_json_path()}")
|
||||
return 0
|
||||
|
||||
|
||||
def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
# 1. Login (skip if we already have a token).
|
||||
token = photon_auth.load_photon_token()
|
||||
if not token:
|
||||
print("[1/5] No Photon token found — running device login...")
|
||||
rc = _run_device_login(args)
|
||||
if rc != 0:
|
||||
return rc
|
||||
token = photon_auth.load_photon_token()
|
||||
if not token:
|
||||
print("login completed but token was not stored", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
print("[1/5] Reusing existing Photon token")
|
||||
|
||||
# 2. Find or create the "Hermes Agent" project.
|
||||
name = args.project_name or photon_auth.DEFAULT_PROJECT_NAME
|
||||
dashboard_id = photon_auth.load_dashboard_project_id()
|
||||
try:
|
||||
if dashboard_id:
|
||||
print("[2/5] Reusing configured Photon project")
|
||||
else:
|
||||
existing = photon_auth.find_project_by_name(token, name)
|
||||
if existing and existing.get("id"):
|
||||
dashboard_id = existing["id"]
|
||||
print(f"[2/5] Found existing project '{name}'")
|
||||
else:
|
||||
print(f"[2/5] Creating Photon project '{name}'...")
|
||||
created = photon_auth.create_project(token, name=name)
|
||||
dashboard_id = created.get("id")
|
||||
print(" ✓ project created")
|
||||
except Exception as e:
|
||||
print(f"project setup failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
if not dashboard_id:
|
||||
print("could not resolve a Photon project id", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 3. Enable Spectrum, fetch the spectrum project id, rotate the secret,
|
||||
# and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json).
|
||||
try:
|
||||
print("[3/5] Enabling Spectrum and provisioning credentials...")
|
||||
proj = photon_auth.ensure_spectrum_enabled(token, dashboard_id)
|
||||
spectrum_id = proj.get("spectrumProjectId")
|
||||
if not spectrum_id:
|
||||
print("spectrum provisioning failed: no spectrum project id", file=sys.stderr)
|
||||
return 1
|
||||
spectrum_id = str(spectrum_id)
|
||||
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
|
||||
photon_auth.store_project_credentials(
|
||||
spectrum_project_id=spectrum_id,
|
||||
project_secret=secret,
|
||||
dashboard_project_id=dashboard_id,
|
||||
name=name,
|
||||
)
|
||||
# spectrum_id is an opaque non-secret id; safe to show.
|
||||
print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved")
|
||||
except Exception as e:
|
||||
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
# 4. Register the operator's phone number as a Spectrum user (idempotent).
|
||||
phone = args.phone or _prompt(
|
||||
color(
|
||||
"[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ",
|
||||
Colors.CYAN,
|
||||
)
|
||||
)
|
||||
agent_number = None
|
||||
registered_phone = None
|
||||
registered_user_id = None
|
||||
if not phone:
|
||||
print(" Skipped user registration (no phone given). Re-run with --phone later.")
|
||||
else:
|
||||
# Name/email are optional and never prompted for — pass --first-name /
|
||||
# --email if you want them sent to the dashboard.
|
||||
first_name = args.first_name
|
||||
email = args.email
|
||||
try:
|
||||
user, created = photon_auth.register_user_if_absent(
|
||||
spectrum_id, secret,
|
||||
phone_number=phone,
|
||||
first_name=first_name,
|
||||
last_name=args.last_name,
|
||||
email=email,
|
||||
)
|
||||
except ValueError as e:
|
||||
print(f" invalid phone number: {e}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception as e:
|
||||
print(f" user registration failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
print(" ✓ phone registered" if created else " ✓ phone already registered")
|
||||
registered_phone = phone
|
||||
registered_user_id = user.get("id")
|
||||
# The number to text the agent is the user's assigned iMessage line
|
||||
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
|
||||
# no dedicated entry in /lines, so this per-user field is the source of
|
||||
# truth — and we already have it from the (reused) user object.
|
||||
agent_number = photon_auth.user_assigned_line(user)
|
||||
# Allowlist the operator and make their DM the cron home channel —
|
||||
# otherwise the gateway denies their own inbound messages
|
||||
# ("Unauthorized user") and has no default space for cron delivery.
|
||||
_autoconfigure_access(phone)
|
||||
|
||||
# 5. Surface the agent's iMessage number (the number to text the agent).
|
||||
if not agent_number:
|
||||
# No per-user assignment — fall back to a dedicated line if the project
|
||||
# has one provisioned in its line inventory.
|
||||
try:
|
||||
line = photon_auth.get_imessage_line(token, dashboard_id)
|
||||
if line:
|
||||
agent_number = line.get("phoneNumber")
|
||||
except Exception as e:
|
||||
print(f" (could not fetch the assigned line: {e})", file=sys.stderr)
|
||||
if agent_number:
|
||||
print()
|
||||
print(color("┌─ Your agent's iMessage number ───────────────────────────────", Colors.GREEN))
|
||||
print(
|
||||
color("│ 📱 ", Colors.GREEN)
|
||||
+ color(str(agent_number), Colors.GREEN, Colors.BOLD)
|
||||
)
|
||||
print(color("│ Text this number from your phone to talk to your agent.", Colors.GREEN))
|
||||
print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN))
|
||||
else:
|
||||
print(" No iMessage line assigned yet — check the Photon dashboard.")
|
||||
if registered_phone:
|
||||
try:
|
||||
photon_auth.store_user_numbers(
|
||||
phone_number=registered_phone,
|
||||
assigned_phone_number=agent_number,
|
||||
user_id=str(registered_user_id) if registered_user_id else None,
|
||||
dashboard_project_id=dashboard_id,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
|
||||
|
||||
# 6. Sidecar deps (spectrum-ts).
|
||||
if args.skip_sidecar_install:
|
||||
print("[5/5] Skipping sidecar npm install (--skip-sidecar-install)")
|
||||
else:
|
||||
print("[5/5] Installing Node sidecar deps (spectrum-ts)...")
|
||||
rc = _install_sidecar()
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
print()
|
||||
print("✓ Photon setup complete.")
|
||||
print(" Start the gateway: hermes gateway start --platform photon")
|
||||
return 0
|
||||
|
||||
|
||||
def _autoconfigure_access(phone: str) -> None:
|
||||
"""Allowlist the operator and set their DM as the cron home channel.
|
||||
|
||||
Writes ``PHOTON_ALLOWED_USERS`` (so the gateway authorizes the operator's
|
||||
own inbound messages instead of denying them) and ``PHOTON_HOME_CHANNEL``
|
||||
(the default space for cron delivery) to the operator's E.164 number. Each
|
||||
is only filled when unset, so a hand-tuned allowlist / home channel is
|
||||
never clobbered on a re-run.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
except ImportError:
|
||||
return
|
||||
for key, label in (
|
||||
("PHOTON_ALLOWED_USERS", "allowlisted your number"),
|
||||
("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel"),
|
||||
):
|
||||
try:
|
||||
if get_env_value(key):
|
||||
print(f" {key} already set — leaving it as-is.")
|
||||
continue
|
||||
save_env_value(key, phone)
|
||||
print(f" ✓ {label} ({key})")
|
||||
except Exception as e:
|
||||
print(f" could not set {key}: {e}", file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_status(_args: argparse.Namespace) -> int:
|
||||
_refresh_status_numbers()
|
||||
# Defer the credential rows to auth.print_credential_summary — its emit
|
||||
# callback is the only sink that sees credential-derived strings, so
|
||||
# cli.py keeps zero taint flow according to CodeQL.
|
||||
photon_auth.print_credential_summary(print)
|
||||
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
|
||||
sidecar_installed = (_SIDECAR_DIR / "node_modules").exists()
|
||||
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
|
||||
print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}")
|
||||
return 0
|
||||
|
||||
|
||||
def _refresh_status_numbers() -> None:
|
||||
phone, assigned = photon_auth.load_user_numbers()
|
||||
if phone and assigned:
|
||||
return
|
||||
spectrum_id, project_secret = photon_auth.load_project_credentials()
|
||||
if not spectrum_id or not project_secret:
|
||||
return
|
||||
try:
|
||||
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
|
||||
except Exception as e:
|
||||
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
|
||||
|
||||
|
||||
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
|
||||
return _install_sidecar()
|
||||
|
||||
|
||||
def _install_sidecar() -> int:
|
||||
npm = shutil.which("npm") or "npm"
|
||||
if not shutil.which(npm):
|
||||
print(
|
||||
"npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) "
|
||||
"and re-run.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
# Always pull the newest published spectrum-ts so every setup runs against
|
||||
# the latest SDK. `spectrum-ts@latest` bumps package.json + package-lock.json
|
||||
# to the current release before installing — a plain `npm install` would
|
||||
# stay pinned to whatever the committed lockfile already resolved.
|
||||
print(f" $ cd {_SIDECAR_DIR} && {npm} install spectrum-ts@latest")
|
||||
proc = subprocess.run( # noqa: S603
|
||||
[npm, "install", "spectrum-ts@latest"],
|
||||
cwd=str(_SIDECAR_DIR),
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
print("npm install failed", file=sys.stderr)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway-setup entry point
|
||||
#
|
||||
# `hermes gateway setup` discovers platforms via the registry and calls each
|
||||
# entry's zero-arg ``setup_fn``. Photon registers this function so it appears
|
||||
# in the unified setup wizard alongside every other channel — same onboarding
|
||||
# surface, no Photon-specific detour. It runs the identical device-login +
|
||||
# project + user + sidecar flow as ``hermes photon setup`` with interactive
|
||||
# defaults (phone is prompted when stdin is a TTY).
|
||||
|
||||
def gateway_setup() -> None:
|
||||
"""Run Photon first-time setup from the `hermes gateway setup` wizard."""
|
||||
args = argparse.Namespace(
|
||||
photon_command="setup",
|
||||
project_name=None,
|
||||
phone=None,
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=False,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
_cmd_setup(args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Small interactive helpers
|
||||
|
||||
def _prompt(prompt: str, *, secret: bool = False) -> str:
|
||||
if not sys.stdin.isatty():
|
||||
return ""
|
||||
try:
|
||||
if secret:
|
||||
return getpass.getpass(prompt).strip()
|
||||
return input(prompt).strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return ""
|
||||
@@ -0,0 +1,76 @@
|
||||
name: photon-platform
|
||||
label: iMessage via Photon
|
||||
kind: platform
|
||||
version: 0.2.0
|
||||
description: >
|
||||
Photon Spectrum gateway adapter for Hermes Agent.
|
||||
Connects to iMessage (and other Spectrum interfaces) through Photon's
|
||||
managed Spectrum platform. Both directions run over the `spectrum-ts`
|
||||
SDK's long-lived gRPC stream via a small supervised Node sidecar —
|
||||
inbound messages arrive on the SDK's `app.messages` stream (no webhook,
|
||||
no public URL, no signing secret), and outbound messages are sent over
|
||||
the same sidecar.
|
||||
|
||||
The plugin ships with a `hermes photon` CLI for the one-time device
|
||||
login + project + user setup. Runtime credentials are written to
|
||||
``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = the Spectrum project id,
|
||||
``PHOTON_PROJECT_SECRET``) like every other channel, with management
|
||||
metadata (device token, dashboard project id) in ``~/.hermes/auth.json``.
|
||||
Photon's free shared-line model lets users get started without a paid plan.
|
||||
author: NousResearch
|
||||
requires_env:
|
||||
- name: PHOTON_PROJECT_ID
|
||||
description: "Spectrum project id (the project's spectrumProjectId; set by `hermes photon setup`)"
|
||||
prompt: "Photon Spectrum project id"
|
||||
url: "https://app.photon.codes/"
|
||||
password: false
|
||||
- name: PHOTON_PROJECT_SECRET
|
||||
description: "Project secret paired with the Spectrum project id (set by `hermes photon setup`)"
|
||||
prompt: "Photon project secret"
|
||||
url: "https://app.photon.codes/"
|
||||
password: true
|
||||
optional_env:
|
||||
- name: PHOTON_SIDECAR_PORT
|
||||
description: "Loopback port for the Node sidecar control + inbound channel (default 8789)"
|
||||
prompt: "Sidecar control port"
|
||||
password: false
|
||||
- name: PHOTON_SIDECAR_AUTOSTART
|
||||
description: "Spawn the Node sidecar on connect (true/false, default true)"
|
||||
prompt: "Auto-start the sidecar?"
|
||||
password: false
|
||||
- name: PHOTON_NODE_BIN
|
||||
description: "Path to the node binary (default: shutil.which('node'))"
|
||||
prompt: "Node executable path"
|
||||
password: false
|
||||
- name: PHOTON_DASHBOARD_HOST
|
||||
description: "Photon Dashboard API host (default https://app.photon.codes)"
|
||||
prompt: "Dashboard host"
|
||||
password: false
|
||||
- name: PHOTON_SPECTRUM_HOST
|
||||
description: "Photon Spectrum API host (default https://spectrum.photon.codes)"
|
||||
prompt: "Spectrum API host"
|
||||
password: false
|
||||
- name: PHOTON_ALLOWED_USERS
|
||||
description: "Comma-separated E.164 phone numbers allowed to talk to the bot"
|
||||
prompt: "Allowed users (comma-separated)"
|
||||
password: false
|
||||
- name: PHOTON_ALLOW_ALL_USERS
|
||||
description: "Allow any sender to trigger the bot (dev only — disables allowlist)"
|
||||
prompt: "Allow all users? (true/false)"
|
||||
password: false
|
||||
- name: PHOTON_REQUIRE_MENTION
|
||||
description: "Ignore group-chat messages unless they match a mention wake word (true/false, default false)"
|
||||
prompt: "Require a mention in group chats?"
|
||||
password: false
|
||||
- name: PHOTON_MENTION_PATTERNS
|
||||
description: "Mention wake-word regexes for group chats (JSON list or comma/newline-separated; defaults to Hermes wake words)"
|
||||
prompt: "Group mention patterns"
|
||||
password: false
|
||||
- name: PHOTON_HOME_CHANNEL
|
||||
description: "Default Photon target for cron / notification delivery: Spectrum space id, DM GUID, or bare E.164 phone number"
|
||||
prompt: "Home Photon target"
|
||||
password: false
|
||||
- name: PHOTON_HOME_CHANNEL_NAME
|
||||
description: "Human label for the home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
@@ -0,0 +1,52 @@
|
||||
# Photon sidecar
|
||||
|
||||
Small Node helper that bridges Hermes Agent to Photon's Spectrum SDK
|
||||
(`spectrum-ts`). Hermes is Python; Photon has no public HTTP
|
||||
send-message endpoint today; replies therefore go through this sidecar.
|
||||
|
||||
The sidecar:
|
||||
|
||||
- runs `Spectrum({ projectId, projectSecret, providers: [imessage.config()] })`
|
||||
- exposes a loopback-only HTTP control channel for the Python adapter
|
||||
to push send/typing requests (auth via `X-Hermes-Sidecar-Token`)
|
||||
- drains the inbound message stream so `spectrum-ts` keeps its
|
||||
reconnect/heartbeat machinery alive (real inbound delivery is via
|
||||
Photon's signed webhook hitting our Python aiohttp server)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd plugins/platforms/photon/sidecar
|
||||
npm install
|
||||
```
|
||||
|
||||
The Hermes plugin's `hermes photon setup` command runs `npm install`
|
||||
here automatically.
|
||||
|
||||
## Run standalone
|
||||
|
||||
For debugging:
|
||||
|
||||
```bash
|
||||
PHOTON_PROJECT_ID=... PHOTON_PROJECT_SECRET=... \
|
||||
PHOTON_SIDECAR_PORT=8789 PHOTON_SIDECAR_TOKEN=$(openssl rand -hex 16) \
|
||||
node index.mjs
|
||||
```
|
||||
|
||||
In normal use, the Python adapter supervises this process — start,
|
||||
restart on crash, kill on shutdown — and never asks the user to run
|
||||
it by hand.
|
||||
|
||||
## Why a sidecar at all?
|
||||
|
||||
Photon publishes webhooks (inbound) but their docs state explicitly:
|
||||
|
||||
> Pass `space.id` to `Space.send(...)` from a separate `spectrum-ts`
|
||||
> SDK instance to reply. No public HTTP send endpoint exists today.
|
||||
|
||||
— https://photon.codes/docs/webhooks/events
|
||||
|
||||
When Photon ships an HTTP send endpoint, the plan is to retire this
|
||||
sidecar entirely and call it directly from Python. The plugin's
|
||||
outbound code path is already isolated behind a single helper
|
||||
(`_sidecar_send` in `adapter.py`) to make that swap a one-file change.
|
||||
@@ -0,0 +1,498 @@
|
||||
// Hermes Agent — Photon Spectrum sidecar
|
||||
//
|
||||
// Spawned by `plugins/platforms/photon/adapter.py` to bridge BOTH directions
|
||||
// of messaging to Photon's Spectrum platform via the `spectrum-ts` SDK (the
|
||||
// SDK is TypeScript-only, so a Node sidecar is unavoidable — there is no
|
||||
// Python SDK and no public HTTP message API).
|
||||
//
|
||||
// Inbound (gRPC -> Hermes): the SDK's `app.messages` async iterator is a
|
||||
// long-lived gRPC stream. We serialize each `[space, message]` to a
|
||||
// normalized JSON event and stream it to the Python adapter over a
|
||||
// loopback `GET /inbound` (NDJSON). We pause pulling from the stream while
|
||||
// no consumer is attached so a backlog isn't pulled-and-lost before the
|
||||
// gateway connects.
|
||||
// Outbound (Hermes -> gRPC): `/send` drives `space.send(...)`; `/typing`
|
||||
// sends the documented `typing("start" | "stop")` content builder.
|
||||
//
|
||||
// Protocol (all requests require `X-Hermes-Sidecar-Token: ${TOKEN}`):
|
||||
// - GET /inbound -> 200 NDJSON stream; one JSON event per line, blank
|
||||
// lines are heartbeats. One consumer at a time.
|
||||
// - POST /healthz -> {"ok": true}
|
||||
// - POST /send -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "text": "..."}
|
||||
// - POST /send-attachment -> {"ok": true, "messageId": "..."}
|
||||
// body: {"spaceId": "...", "path": "...", "name": "..." | null,
|
||||
// "mimeType": "..." | null, "caption": "..." | null,
|
||||
// "kind": "attachment" | "voice"}
|
||||
// - POST /typing -> {"ok": true}
|
||||
// body: {"spaceId": "...", "state": "start" | "stop"}
|
||||
// - POST /shutdown -> {"ok": true}; then process exits
|
||||
//
|
||||
// On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before
|
||||
// exiting. Logs go to stderr; Python supervises restart.
|
||||
//
|
||||
// Env vars (required):
|
||||
// PHOTON_PROJECT_ID (== the project's spectrumProjectId)
|
||||
// PHOTON_PROJECT_SECRET
|
||||
// PHOTON_SIDECAR_PORT
|
||||
// PHOTON_SIDECAR_TOKEN
|
||||
// Optional:
|
||||
// PHOTON_SIDECAR_BIND (default 127.0.0.1)
|
||||
|
||||
import http from "node:http";
|
||||
import { once } from "node:events";
|
||||
|
||||
const projectId = process.env.PHOTON_PROJECT_ID;
|
||||
const projectSecret = process.env.PHOTON_PROJECT_SECRET;
|
||||
const port = parseInt(process.env.PHOTON_SIDECAR_PORT || "8789", 10);
|
||||
const bind = process.env.PHOTON_SIDECAR_BIND || "127.0.0.1";
|
||||
const sharedToken = process.env.PHOTON_SIDECAR_TOKEN;
|
||||
|
||||
// Inbound binary content is read into memory and base64-inlined on the NDJSON
|
||||
// event so the Python adapter can cache the real bytes (and the agent can see
|
||||
// images / transcribe voice). Cap the size we inline — above it we forward
|
||||
// metadata only and the adapter surfaces a text marker, so one large clip can't
|
||||
// balloon a single NDJSON line. Override via PHOTON_MAX_INLINE_ATTACHMENT_BYTES.
|
||||
const MAX_INLINE_ATTACHMENT_BYTES =
|
||||
Number(process.env.PHOTON_MAX_INLINE_ATTACHMENT_BYTES) || 20 * 1024 * 1024;
|
||||
const DM_CHAT_GUID_RE = /^any;-;(\+\d{6,})$/;
|
||||
const E164_RE = /^\+\d{6,}$/;
|
||||
const MAX_KNOWN_SPACES = 2048;
|
||||
|
||||
if (!projectId || !projectSecret || !sharedToken) {
|
||||
console.error(
|
||||
"photon-sidecar: PHOTON_PROJECT_ID, PHOTON_PROJECT_SECRET and " +
|
||||
"PHOTON_SIDECAR_TOKEN must all be set."
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Lazy-load spectrum-ts so a missing install fails with a clear message
|
||||
// instead of a cryptic module-resolution error during import.
|
||||
let Spectrum, imessage, attachment, voice, spectrumText, spectrumTyping;
|
||||
try {
|
||||
({
|
||||
Spectrum,
|
||||
attachment,
|
||||
voice,
|
||||
text: spectrumText,
|
||||
typing: spectrumTyping,
|
||||
} = await import("spectrum-ts"));
|
||||
({ imessage } = await import("spectrum-ts/providers/imessage"));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: spectrum-ts is not installed. Run `npm install` " +
|
||||
"inside plugins/platforms/photon/sidecar/. Original error: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
const app = await Spectrum({
|
||||
projectId,
|
||||
projectSecret,
|
||||
providers: [imessage.config()],
|
||||
options: { flattenGroups: true },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound: forward `app.messages` (gRPC stream) to the Python consumer.
|
||||
|
||||
// At most one Python consumer is attached at a time (the gateway adapter).
|
||||
let consumerRes = null;
|
||||
let consumerWaiters = [];
|
||||
const knownSpaces = new Map();
|
||||
|
||||
function rememberKnownSpace(id, space) {
|
||||
if (!id || typeof id !== "string" || !space) return;
|
||||
if (knownSpaces.has(id)) knownSpaces.delete(id);
|
||||
knownSpaces.set(id, space);
|
||||
if (knownSpaces.size > MAX_KNOWN_SPACES) {
|
||||
const oldest = knownSpaces.keys().next().value;
|
||||
if (oldest) knownSpaces.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function phoneTargetFromSpaceId(spaceId) {
|
||||
if (typeof spaceId !== "string") return null;
|
||||
if (E164_RE.test(spaceId)) return spaceId;
|
||||
const dmGuid = spaceId.match(DM_CHAT_GUID_RE);
|
||||
return dmGuid ? dmGuid[1] : null;
|
||||
}
|
||||
|
||||
function rememberInboundSpace(space, message) {
|
||||
const msgSpace = message?.space || {};
|
||||
const ids = [space?.id, msgSpace.id];
|
||||
for (const id of ids) {
|
||||
rememberKnownSpace(id, space);
|
||||
const phone = phoneTargetFromSpaceId(id);
|
||||
if (phone) rememberKnownSpace(phone, space);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForConsumer() {
|
||||
if (consumerRes) return Promise.resolve();
|
||||
return new Promise((resolve) => consumerWaiters.push(resolve));
|
||||
}
|
||||
|
||||
function setConsumer(res) {
|
||||
consumerRes = res;
|
||||
const waiters = consumerWaiters;
|
||||
consumerWaiters = [];
|
||||
for (const resolve of waiters) resolve();
|
||||
}
|
||||
|
||||
function clearConsumer(res) {
|
||||
if (consumerRes === res) consumerRes = null;
|
||||
}
|
||||
|
||||
// Write one NDJSON line to the active consumer. Blocks until a consumer is
|
||||
// connected; if the write fails (consumer vanished mid-flight) we wait for a
|
||||
// new consumer and retry, so a message is never silently dropped here.
|
||||
async function deliver(line) {
|
||||
for (;;) {
|
||||
await waitForConsumer();
|
||||
const res = consumerRes;
|
||||
if (!res) continue;
|
||||
try {
|
||||
const flushed = res.write(line + "\n");
|
||||
if (!flushed) await once(res, "drain");
|
||||
return;
|
||||
} catch {
|
||||
clearConsumer(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeBinaryContent(content) {
|
||||
const meta = {
|
||||
type: content.type,
|
||||
id: content.id ?? null,
|
||||
name: content.name ?? null,
|
||||
mimeType: content.mimeType ?? null,
|
||||
size: typeof content.size === "number" ? content.size : null,
|
||||
};
|
||||
if (content.type === "voice" && typeof content.duration === "number") {
|
||||
meta.duration = content.duration;
|
||||
}
|
||||
|
||||
// Read the bytes eagerly and base64-inline them as `data` so the Python
|
||||
// adapter can cache the real file (the agent then sees images and can run
|
||||
// STT on voice notes). Spectrum content objects may not outlive this stream
|
||||
// iteration, so a lazy/on-demand fetch isn't safe. Over-cap content (when
|
||||
// size is known up front) is forwarded as metadata only and the adapter falls
|
||||
// back to a text marker. A read failure must never break the inbound loop.
|
||||
const label = `${content.type} ${meta.name ?? meta.id ?? "(unnamed)"}`;
|
||||
if (meta.size !== null && meta.size > MAX_INLINE_ATTACHMENT_BYTES) {
|
||||
console.error(
|
||||
`photon-sidecar: ${label} (${meta.size} bytes) ` +
|
||||
`exceeds inline cap ${MAX_INLINE_ATTACHMENT_BYTES}; forwarding metadata only`
|
||||
);
|
||||
return meta;
|
||||
}
|
||||
if (typeof content.read === "function") {
|
||||
try {
|
||||
const buf = await content.read();
|
||||
// Guard the case where size was unknown but the bytes turn out to be
|
||||
// over the cap.
|
||||
if (buf && buf.length > MAX_INLINE_ATTACHMENT_BYTES) {
|
||||
console.error(
|
||||
`photon-sidecar: ${label} (${buf.length} bytes) ` +
|
||||
`exceeds inline cap after read; forwarding metadata only`
|
||||
);
|
||||
return meta;
|
||||
}
|
||||
meta.data = Buffer.from(buf).toString("base64");
|
||||
meta.encoding = "base64";
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`photon-sidecar: failed to read ${content.type} bytes ` +
|
||||
"(forwarding metadata only): " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
async function normalizeContent(content) {
|
||||
if (!content || typeof content !== "object") {
|
||||
return { type: "unknown" };
|
||||
}
|
||||
if (content.type === "text") {
|
||||
return { type: "text", text: content.text || "" };
|
||||
}
|
||||
if (content.type === "attachment" || content.type === "voice") {
|
||||
return await normalizeBinaryContent(content);
|
||||
}
|
||||
return { type: content.type || "unknown" };
|
||||
}
|
||||
|
||||
async function normalizeEvent(space, message) {
|
||||
try {
|
||||
const msgSpace = message.space || {};
|
||||
const ts = message.timestamp;
|
||||
return {
|
||||
messageId: message.id ?? null,
|
||||
platform: message.platform || space.__platform || "iMessage",
|
||||
space: {
|
||||
id: space.id ?? msgSpace.id ?? null,
|
||||
// iMessage spaces carry `type` ("dm"|"group") and `phone` directly.
|
||||
type: space.type ?? msgSpace.type ?? "dm",
|
||||
phone: space.phone ?? msgSpace.phone ?? null,
|
||||
},
|
||||
sender: { id: message.sender ? message.sender.id : null },
|
||||
content: await normalizeContent(message.content),
|
||||
timestamp:
|
||||
ts instanceof Date ? ts.toISOString() : ts ? String(ts) : null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: failed to normalize inbound message: " + String(e)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for await (const [space, message] of app.messages) {
|
||||
// Only forward inbound messages (ignore our own outbound echoes).
|
||||
if (message && message.direction && message.direction !== "inbound") {
|
||||
continue;
|
||||
}
|
||||
rememberInboundSpace(space, message);
|
||||
const event = await normalizeEvent(space, message);
|
||||
if (!event) continue;
|
||||
await deliver(JSON.stringify(event));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: inbound stream errored: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP control + inbound server (loopback only).
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
for await (const chunk of req) chunks.push(chunk);
|
||||
const raw = Buffer.concat(chunks).toString("utf-8");
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error("invalid JSON body");
|
||||
}
|
||||
}
|
||||
|
||||
function unauthorized(res) {
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
|
||||
}
|
||||
|
||||
function badRequest(res, msg) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: false, error: msg }));
|
||||
}
|
||||
|
||||
function serverError(res) {
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
// Don't leak stack traces or raw exception text to the caller — even
|
||||
// though we listen on loopback, the supervisor logs the real error
|
||||
// and the client only needs a generic failure signal.
|
||||
res.end(JSON.stringify({ ok: false, error: "internal sidecar error" }));
|
||||
}
|
||||
|
||||
function ok(res, data) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ ok: true, ...data }));
|
||||
}
|
||||
|
||||
function handleInbound(req, res) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/x-ndjson");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
// One consumer at a time — a fresh connection (e.g. after a reconnect)
|
||||
// supersedes the previous one.
|
||||
if (consumerRes && consumerRes !== res) {
|
||||
try {
|
||||
consumerRes.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setConsumer(res);
|
||||
// Heartbeat keeps the socket warm through idle periods and lets the Python
|
||||
// side detect a dead pipe promptly.
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
res.write("\n");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 25000);
|
||||
const cleanup = () => {
|
||||
clearInterval(heartbeat);
|
||||
clearConsumer(res);
|
||||
};
|
||||
req.on("close", cleanup);
|
||||
req.on("aborted", cleanup);
|
||||
res.on("error", cleanup);
|
||||
}
|
||||
|
||||
async function resolveSpace(spaceId) {
|
||||
const cached = knownSpaces.get(spaceId);
|
||||
if (cached) return cached;
|
||||
|
||||
const phoneTarget = phoneTargetFromSpaceId(spaceId);
|
||||
// A bare E.164 phone number addresses a DM. Resolve the user, then the (DM)
|
||||
// space — `imessage(app).user(phone)` -> `im.space(user)` — so callers can
|
||||
// pass just "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of
|
||||
// an opaque inbound space id. Photon also represents DM chat ids as
|
||||
// `any;-;+1...`; normalize those through the same path so replies to inbound
|
||||
// DMs still resolve after Python stores the inbound `space.id`.
|
||||
if (phoneTarget && imessage) {
|
||||
try {
|
||||
const im = imessage(app);
|
||||
const user = await im.user(phoneTarget);
|
||||
const space = await im.space(user);
|
||||
rememberKnownSpace(spaceId, space);
|
||||
rememberKnownSpace(phoneTarget, space);
|
||||
rememberKnownSpace(space?.id, space);
|
||||
return space;
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: phone->DM resolution failed: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
// No cache hit and not a phone/DM target. spectrum-ts exposes no API to
|
||||
// rehydrate an arbitrary opaque space id: a Space is only obtained from the
|
||||
// inbound `[space, message]` stream (cached above in `knownSpaces`) or
|
||||
// reconstructed for a DM from its phone number. So a group space whose cache
|
||||
// entry was lost — e.g. after a sidecar restart with no fresh inbound message
|
||||
// in that group — cannot be resolved here; a new inbound message in the group
|
||||
// re-warms the cache. DMs are unaffected (reconstructed from the phone).
|
||||
throw new Error(`unable to resolve space id ${spaceId}`);
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (req.headers["x-hermes-sidecar-token"] !== sharedToken) {
|
||||
return unauthorized(res);
|
||||
}
|
||||
// Long-lived inbound NDJSON stream.
|
||||
if (req.method === "GET" && req.url === "/inbound") {
|
||||
return handleInbound(req, res);
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
return res.end();
|
||||
}
|
||||
try {
|
||||
if (req.url === "/healthz") {
|
||||
return ok(res, {});
|
||||
}
|
||||
if (req.url === "/shutdown") {
|
||||
ok(res, {});
|
||||
setTimeout(() => process.kill(process.pid, "SIGTERM"), 50);
|
||||
return;
|
||||
}
|
||||
const body = await readBody(req);
|
||||
if (req.url === "/send") {
|
||||
const { spaceId, text } = body || {};
|
||||
if (!spaceId || typeof text !== "string") {
|
||||
return badRequest(res, "spaceId and text are required");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
const result = await space.send(spectrumText(text));
|
||||
return ok(res, { messageId: result?.id || null });
|
||||
}
|
||||
if (req.url === "/send-attachment") {
|
||||
const { spaceId, path, name, mimeType, caption, kind } =
|
||||
body || {};
|
||||
if (!spaceId || typeof path !== "string" || !path) {
|
||||
return badRequest(res, "spaceId and path are required");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
|
||||
// spectrum-ts infers name + MIME from the file extension; pass
|
||||
// overrides only when Hermes supplied them so a known-good
|
||||
// inference isn't clobbered with an empty string.
|
||||
const opts = {};
|
||||
if (name) opts.name = name;
|
||||
if (mimeType) opts.mimeType = mimeType;
|
||||
const builder =
|
||||
kind === "voice"
|
||||
? voice(path, Object.keys(opts).length ? opts : undefined)
|
||||
: attachment(path, Object.keys(opts).length ? opts : undefined);
|
||||
|
||||
const result = await space.send(builder);
|
||||
|
||||
// iMessage delivers the caption as a separate bubble; send it
|
||||
// after the media so the attachment renders first.
|
||||
if (caption && typeof caption === "string") {
|
||||
try {
|
||||
await space.send(spectrumText(caption));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: attachment sent but caption failed: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
}
|
||||
}
|
||||
return ok(res, { messageId: result?.id || null });
|
||||
}
|
||||
if (req.url === "/typing") {
|
||||
const { spaceId, state = "start" } = body || {};
|
||||
if (!spaceId) return badRequest(res, "spaceId is required");
|
||||
if (state !== "start" && state !== "stop") {
|
||||
return badRequest(res, "state must be start or stop");
|
||||
}
|
||||
const space = await resolveSpace(spaceId);
|
||||
await space.send(spectrumTyping(state));
|
||||
return ok(res, {});
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
return res.end(JSON.stringify({ ok: false, error: "not found" }));
|
||||
} catch (e) {
|
||||
console.error(
|
||||
"photon-sidecar: handler error: " +
|
||||
(e && e.stack ? e.stack : String(e))
|
||||
);
|
||||
// serverError() intentionally returns a generic message — see its
|
||||
// body for the rationale.
|
||||
return serverError(res);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, bind, () => {
|
||||
console.error(`photon-sidecar: listening on ${bind}:${port}`);
|
||||
});
|
||||
|
||||
async function shutdown(signal) {
|
||||
console.error(`photon-sidecar: received ${signal}, stopping...`);
|
||||
try {
|
||||
await Promise.race([
|
||||
app.stop(),
|
||||
new Promise((resolve) => setTimeout(resolve, 3000)),
|
||||
]);
|
||||
} catch (e) {
|
||||
console.error("photon-sidecar: app.stop() failed: " + String(e));
|
||||
}
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(1), 500).unref();
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
||||
+1781
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@hermes-agent/photon-sidecar",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
|
||||
"type": "module",
|
||||
"main": "index.mjs",
|
||||
"scripts": {
|
||||
"start": "node index.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.17"
|
||||
},
|
||||
"dependencies": {
|
||||
"spectrum-ts": "^1.18.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
name: simplex-platform
|
||||
label: SimpleX Chat
|
||||
kind: platform
|
||||
version: 1.0.0
|
||||
version: 1.1.0
|
||||
description: >
|
||||
SimpleX Chat gateway adapter for Hermes Agent.
|
||||
Connects to a local simplex-chat daemon via WebSocket and relays
|
||||
@@ -9,7 +9,7 @@ description: >
|
||||
SimpleX is decentralised and assigns no persistent user IDs —
|
||||
every contact is an opaque internal ID generated at connection
|
||||
time, making it one of the most private messengers available.
|
||||
author: Mibayy
|
||||
author: Mibayy, jooray
|
||||
# ``requires_env`` and ``optional_env`` entries are surfaced in the
|
||||
# ``hermes config`` UI via the platform-plugin env var injector in
|
||||
# ``hermes_cli/config.py``.
|
||||
@@ -27,6 +27,18 @@ optional_env:
|
||||
description: "Allow any contact to talk to the bot (dev only — disables allowlist)"
|
||||
prompt: "Allow all contacts? (true/false)"
|
||||
password: false
|
||||
- name: SIMPLEX_AUTO_ACCEPT
|
||||
description: "Auto-accept incoming contact requests (default: true)"
|
||||
prompt: "Auto-accept contact requests? (true/false)"
|
||||
password: false
|
||||
- name: SIMPLEX_GROUP_ALLOWED
|
||||
description: >-
|
||||
Comma-separated SimpleX group IDs the bot should participate in, or
|
||||
'*' to allow any group. Omit to ignore group messages entirely
|
||||
(safer default — a bot in a group otherwise processes every
|
||||
member's traffic).
|
||||
prompt: "Allowed group IDs (comma-separated, or '*' for any)"
|
||||
password: false
|
||||
- name: SIMPLEX_HOME_CHANNEL
|
||||
description: "Default contact/group ID for cron / notification delivery"
|
||||
prompt: "Home channel contact/group ID (or empty)"
|
||||
@@ -35,3 +47,10 @@ optional_env:
|
||||
description: "Human label for the home channel (defaults to the ID)"
|
||||
prompt: "Home channel display name (or empty)"
|
||||
password: false
|
||||
- name: HERMES_SIMPLEX_TEXT_BATCH_DELAY
|
||||
description: >-
|
||||
Quiet-period seconds (default: 0.8) used to concatenate rapid-fire
|
||||
inbound text messages into a single MessageEvent — same pattern as
|
||||
Telegram's text batching.
|
||||
prompt: "Text batch flush delay in seconds (default 0.8)"
|
||||
password: false
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Shared concurrency helpers for plugin authors.
|
||||
|
||||
The most common plugin footgun is the lazy process-wide singleton:
|
||||
|
||||
_client = None
|
||||
|
||||
def get_client():
|
||||
global _client
|
||||
if _client is not None:
|
||||
return _client
|
||||
_client = ExpensiveClient(...) # <-- TOCTOU: two threads both run this
|
||||
return _client
|
||||
|
||||
When two threads call ``get_client()`` before the singleton is set, both pass
|
||||
the ``is not None`` guard, both run the expensive initialization, and the
|
||||
second write clobbers the first — leaking whatever resource the first client
|
||||
opened (connections, file handles, background threads).
|
||||
|
||||
Multi-threaded agent sessions share one process (delegated tool calls,
|
||||
background workers, the self-improvement fork), so this race is reachable in
|
||||
practice. Rather than make every plugin author remember to hand-roll
|
||||
double-checked locking, this module gives them two thread-safe primitives:
|
||||
|
||||
* :func:`lazy_singleton` — decorator for the zero-arg accessor case.
|
||||
* :class:`SingletonSlot` — manual slot for accessors that build different
|
||||
instances depending on a config/key argument.
|
||||
|
||||
Both are import-light (stdlib ``threading`` only) so any plugin can import
|
||||
them without dragging in heavyweight host modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import threading
|
||||
from typing import Callable, Generic, Optional, TypeVar
|
||||
|
||||
__all__ = ["lazy_singleton", "SingletonSlot"]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def lazy_singleton(factory: Callable[[], T]) -> Callable[[], T]:
|
||||
"""Wrap a zero-argument factory into a thread-safe lazy singleton accessor.
|
||||
|
||||
The wrapped callable returns the same instance on every call; the factory
|
||||
runs exactly once even under concurrent first calls, using double-checked
|
||||
locking. A ``.reset()`` attribute is attached for tests/teardown.
|
||||
|
||||
Example::
|
||||
|
||||
@lazy_singleton
|
||||
def get_client():
|
||||
return ExpensiveClient(load_config())
|
||||
|
||||
client = get_client() # built once, safe across threads
|
||||
get_client.reset() # drop the instance (next call rebuilds)
|
||||
|
||||
Note: if the factory raises, no instance is cached and the next call
|
||||
retries (the lock is released either way).
|
||||
"""
|
||||
lock = threading.Lock()
|
||||
box: list = [] # one-element [instance]; empty == not yet built
|
||||
|
||||
@functools.wraps(factory)
|
||||
def accessor() -> T:
|
||||
if box:
|
||||
return box[0]
|
||||
with lock:
|
||||
if box: # re-check inside the lock
|
||||
return box[0]
|
||||
instance = factory()
|
||||
box.append(instance)
|
||||
return instance
|
||||
|
||||
def reset() -> None:
|
||||
with lock:
|
||||
box.clear()
|
||||
|
||||
accessor.reset = reset # type: ignore[attr-defined]
|
||||
return accessor
|
||||
|
||||
|
||||
class SingletonSlot(Generic[T]):
|
||||
"""Thread-safe lazy slot for accessors that take a build argument.
|
||||
|
||||
Use this when the cached instance depends on a config/key passed to the
|
||||
accessor (so a bare zero-arg :func:`lazy_singleton` doesn't fit). The slot
|
||||
caches the first successfully-built instance and ignores the argument on
|
||||
subsequent calls — matching the established "first config wins" singleton
|
||||
semantics most plugins already rely on.
|
||||
|
||||
Example::
|
||||
|
||||
_slot: SingletonSlot[Honcho] = SingletonSlot()
|
||||
|
||||
def get_honcho_client(config=None):
|
||||
return _slot.get(lambda: Honcho(**resolve(config)))
|
||||
|
||||
def reset_honcho_client():
|
||||
_slot.reset()
|
||||
|
||||
The factory runs at most once even under concurrent first calls. If the
|
||||
factory raises, nothing is cached and the next call retries.
|
||||
"""
|
||||
|
||||
__slots__ = ("_lock", "_value", "_set")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._value: Optional[T] = None
|
||||
self._set = False
|
||||
|
||||
def get(self, factory: Callable[[], T]) -> T:
|
||||
# Fast path: already built, no lock needed (a set bool + ref read is
|
||||
# atomic under CPython's GIL).
|
||||
if self._set:
|
||||
return self._value # type: ignore[return-value]
|
||||
with self._lock:
|
||||
if self._set: # re-check inside the lock
|
||||
return self._value # type: ignore[return-value]
|
||||
value = factory()
|
||||
self._value = value
|
||||
self._set = True
|
||||
return value
|
||||
|
||||
def peek(self) -> Optional[T]:
|
||||
"""Return the cached instance without building it (None if unset)."""
|
||||
return self._value if self._set else None
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop the cached instance so the next ``get()`` rebuilds it."""
|
||||
with self._lock:
|
||||
self._value = None
|
||||
self._set = False
|
||||
@@ -291,6 +291,7 @@ def _build_payload(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_fal_client: Any = None
|
||||
_fal_client_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_fal_client() -> Any:
|
||||
@@ -298,13 +299,19 @@ def _load_fal_client() -> Any:
|
||||
|
||||
Delegates the actual import to :func:`tools.fal_common.import_fal_client`
|
||||
so the ``lazy_deps`` ensure-install handling stays in one place.
|
||||
|
||||
Thread-safe via double-checked locking: concurrent first calls import
|
||||
the SDK exactly once instead of each racing thread re-running the import.
|
||||
"""
|
||||
global _fal_client
|
||||
if _fal_client is not None:
|
||||
return _fal_client
|
||||
from tools.fal_common import import_fal_client
|
||||
_fal_client = import_fal_client()
|
||||
return _fal_client
|
||||
with _fal_client_lock:
|
||||
if _fal_client is not None: # re-check inside the lock
|
||||
return _fal_client
|
||||
from tools.fal_common import import_fal_client
|
||||
_fal_client = import_fal_client()
|
||||
return _fal_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+5
-5
@@ -104,7 +104,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
# Native Anthropic provider — only needed when provider=anthropic (not via
|
||||
# OpenRouter or other aggregators).
|
||||
anthropic = ["anthropic==0.86.0"]
|
||||
anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452
|
||||
# Web search backends — each only loaded when the user picks it as their
|
||||
# search provider (configured via `hermes tools` or config.yaml).
|
||||
exa = ["exa-py==2.10.2"]
|
||||
@@ -119,9 +119,9 @@ modal = ["modal==1.3.4"]
|
||||
daytona = ["daytona==0.155.0"]
|
||||
hindsight = ["hindsight-client==0.6.1"]
|
||||
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710
|
||||
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
|
||||
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.4", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525
|
||||
cron = [] # croniter is now a core dependency; this extra kept for back-compat
|
||||
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"]
|
||||
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"]
|
||||
matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"]
|
||||
# WeCom callback-mode adapter — parses untrusted XML POST bodies from
|
||||
# WeCom-controlled callback endpoints, so we use defusedxml (drop-in
|
||||
@@ -160,8 +160,8 @@ vision = []
|
||||
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
|
||||
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
|
||||
nemo-relay = ["nemo-relay==0.3"]
|
||||
homeassistant = ["aiohttp==3.13.3"]
|
||||
sms = ["aiohttp==3.13.3"]
|
||||
homeassistant = ["aiohttp==3.13.4"]
|
||||
sms = ["aiohttp==3.13.4"]
|
||||
# Computer use — macOS background desktop control via cua-driver (MCP stdio).
|
||||
# The cua-driver binary itself is installed via `hermes tools` post-setup
|
||||
# (curl install script); this extra just pins the MCP client used to talk
|
||||
|
||||
@@ -358,6 +358,7 @@ class AIAgent:
|
||||
save_trajectories: bool = False,
|
||||
verbose_logging: bool = False,
|
||||
quiet_mode: bool = False,
|
||||
tool_progress_mode: str = "all",
|
||||
ephemeral_system_prompt: str = None,
|
||||
log_prefix_chars: int = 100,
|
||||
log_prefix: str = "",
|
||||
@@ -430,6 +431,7 @@ class AIAgent:
|
||||
save_trajectories=save_trajectories,
|
||||
verbose_logging=verbose_logging,
|
||||
quiet_mode=quiet_mode,
|
||||
tool_progress_mode=tool_progress_mode,
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
log_prefix_chars=log_prefix_chars,
|
||||
log_prefix=log_prefix,
|
||||
@@ -3087,6 +3089,17 @@ class AIAgent:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 6. Free conversation history. Mirrors _release_evicted_agent_soft's
|
||||
# soft-eviction clear — close() is the hard teardown for true session
|
||||
# boundaries (/new, /reset, session expiry), so the message list won't
|
||||
# be reused. Drops the reference proactively rather than waiting for
|
||||
# the agent object itself to be collected, which matters when a caller
|
||||
# still holds the closed agent (e.g. a draining background task).
|
||||
try:
|
||||
self._session_messages = []
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _hydrate_todo_store(self, history: List[Dict[str, Any]]) -> None:
|
||||
"""
|
||||
Recover todo state from conversation history.
|
||||
@@ -3904,6 +3917,13 @@ class AIAgent:
|
||||
def _anthropic_messages_create(self, api_kwargs: dict):
|
||||
if self.api_mode == "anthropic_messages":
|
||||
self._try_refresh_anthropic_client_credentials()
|
||||
# Defensive: strip Responses-only kwargs that can leak in under an
|
||||
# api_mode-flip race (the Anthropic SDK raises a non-retryable
|
||||
# TypeError on them). See #31673.
|
||||
from agent.anthropic_adapter import sanitize_anthropic_kwargs
|
||||
sanitize_anthropic_kwargs(
|
||||
api_kwargs, log_prefix=getattr(self, "log_prefix", "")
|
||||
)
|
||||
return self._anthropic_client.messages.create(**api_kwargs)
|
||||
|
||||
def _rebuild_anthropic_client(self) -> None:
|
||||
|
||||
@@ -297,6 +297,21 @@ def main():
|
||||
# Batch resolve GitHub paths for skills.sh entries
|
||||
all_skills = batch_resolve_paths(all_skills, auth)
|
||||
|
||||
# Collect which sources hit a GitHub API rate limit during the crawl.
|
||||
# github / claude-marketplace / well-known all read api.github.com, so a
|
||||
# rate-limited token zeroes all three at once — surfaced below so the
|
||||
# failure message names the real cause instead of "source returned 0".
|
||||
rate_limited_sources = {
|
||||
name for name, source in sources.items()
|
||||
if getattr(source, "is_rate_limited", False)
|
||||
}
|
||||
if rate_limited_sources:
|
||||
print(
|
||||
" WARNING: GitHub API rate limit hit for: "
|
||||
+ ", ".join(sorted(rate_limited_sources)),
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Deduplicate by identifier
|
||||
seen: dict[str, dict] = {}
|
||||
for skill in all_skills:
|
||||
@@ -311,25 +326,9 @@ def main():
|
||||
"browse-sh": 5, "claude-marketplace": 6, "lobehub": 7}
|
||||
deduped.sort(key=lambda s: (source_order.get(s["source"], 99), s["name"]))
|
||||
|
||||
# Build index
|
||||
index = {
|
||||
"version": INDEX_VERSION,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"skill_count": len(deduped),
|
||||
"skills": deduped,
|
||||
}
|
||||
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
elapsed = time.time() - overall_start
|
||||
file_size = os.path.getsize(OUTPUT_PATH)
|
||||
print(f"\nDone! {len(deduped)} skills indexed in {elapsed:.0f}s")
|
||||
print(f"Output: {OUTPUT_PATH} ({file_size / 1024:.0f} KB)")
|
||||
|
||||
from collections import Counter
|
||||
by_source = Counter(s["source"] for s in deduped)
|
||||
print(f"\nCrawled {len(deduped)} skills in {time.time() - overall_start:.0f}s")
|
||||
for src, count in sorted(by_source.items(), key=lambda x: -x[1]):
|
||||
resolved = sum(1 for s in deduped
|
||||
if s["source"] == src and s.get("resolved_github_id"))
|
||||
@@ -380,14 +379,46 @@ def main():
|
||||
)
|
||||
for line in health_errors:
|
||||
print(line, file=sys.stderr)
|
||||
if rate_limited_sources:
|
||||
print(
|
||||
"\nGitHub API rate limit was hit during this crawl for: "
|
||||
+ ", ".join(sorted(rate_limited_sources))
|
||||
+ ". This is the usual cause of an all-GitHub-tap collapse "
|
||||
"(github / claude-marketplace / well-known dropping to zero "
|
||||
"together). Re-run with a higher-quota GITHUB_TOKEN.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"\nIf the drop is expected (e.g. a hub is genuinely shutting "
|
||||
"down), lower the floor in scripts/build_skills_index.py "
|
||||
"EXPECTED_FLOORS in the same PR.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# IMPORTANT: do NOT write OUTPUT_PATH on failure. The index file is
|
||||
# gitignored, so a fresh deploy checkout has no copy on disk — leaving
|
||||
# it absent lets website/scripts/extract-skills.py fall back to the
|
||||
# legacy snapshot cache (or skip the unified index) instead of reading
|
||||
# a degenerate file. Writing-then-exiting-2 was the bug that shipped an
|
||||
# index with every GitHub-API source dropped to zero: deploy-site.yml
|
||||
# swallows the exit code with `|| echo non-fatal`, and the partial file
|
||||
# was already on disk for extract-skills to pick up.
|
||||
sys.exit(2)
|
||||
|
||||
# Healthy — only now write the index out for the docs build to consume.
|
||||
index = {
|
||||
"version": INDEX_VERSION,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"skill_count": len(deduped),
|
||||
"skills": deduped,
|
||||
}
|
||||
os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
|
||||
with open(OUTPUT_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(index, f, separators=(",", ":"), ensure_ascii=False)
|
||||
file_size = os.path.getsize(OUTPUT_PATH)
|
||||
print(f"\nDone! {len(deduped)} skills indexed in "
|
||||
f"{time.time() - overall_start:.0f}s")
|
||||
print(f"Output: {OUTPUT_PATH} ({file_size / 1024:.0f} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user