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
-38
@@ -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).
|
||||
@@ -2932,14 +2936,6 @@ OPTIONAL_ENV_VARS = {
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"APIFY_API_TOKEN": {
|
||||
"description": "Apify API token for Actor execution tools (apify_discover, apify_start, apify_collect)",
|
||||
"prompt": "Apify API token",
|
||||
"url": "https://apify.com/account/integrations",
|
||||
"tools": ["apify_discover", "apify_start", "apify_collect"],
|
||||
"password": True,
|
||||
"category": "tool",
|
||||
},
|
||||
"SEARXNG_URL": {
|
||||
"description": "URL of your SearXNG instance for free self-hosted web search",
|
||||
"prompt": "SearXNG URL (e.g. http://localhost:8080)",
|
||||
@@ -5157,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()
|
||||
@@ -6045,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
|
||||
|
||||
@@ -80,7 +80,6 @@ CONFIGURABLE_TOOLSETS = [
|
||||
("discord_admin", "🛡️ Discord Server Admin", "list channels/roles, pin, assign roles"),
|
||||
("yuanbao", "🤖 Yuanbao", "group info, member queries, DM"),
|
||||
("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"),
|
||||
("apify", "🎭 Apify Actors", "discover, start, and collect Actor runs (requires APIFY_API_TOKEN)"),
|
||||
]
|
||||
|
||||
|
||||
@@ -113,7 +112,7 @@ def gui_toolset_label(label: str) -> str:
|
||||
# `hermes tools` → X (Twitter) Search setup walks users through credential
|
||||
# setup. The tool's check_fn means the schema still won't appear to the
|
||||
# model if the credential later goes missing or expires.
|
||||
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search", "apify"}
|
||||
_DEFAULT_OFF_TOOLSETS = {"moa", "homeassistant", "spotify", "discord", "discord_admin", "video", "video_gen", "x_search"}
|
||||
|
||||
|
||||
def _xai_credentials_present() -> bool:
|
||||
@@ -563,27 +562,6 @@ TOOL_CATEGORIES = {
|
||||
},
|
||||
],
|
||||
},
|
||||
"apify": {
|
||||
"name": "Apify Actors",
|
||||
"icon": "🕷️",
|
||||
"providers": [
|
||||
{
|
||||
"name": "Apify",
|
||||
"badge": "paid",
|
||||
"tag": (
|
||||
"Run any Actor from the Apify Store — social media, "
|
||||
"Google Maps, e-commerce, and more."
|
||||
),
|
||||
"env_vars": [
|
||||
{
|
||||
"key": "APIFY_API_TOKEN",
|
||||
"prompt": "Apify API token",
|
||||
"url": "https://apify.com/account/integrations",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Simple env-var requirements for toolsets NOT in TOOL_CATEGORIES.
|
||||
|
||||
+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)
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
# Apify Actor Tools
|
||||
|
||||
Bundled plugin that brings [Apify](https://apify.com/) Actors into Hermes. Apify
|
||||
hosts 20,000+ ready-made Actors for web automation and data extraction
|
||||
(Instagram, YouTube, Google Maps, LinkedIn, Amazon, and more). The agent can
|
||||
discover the right Actor, inspect its input schema, run it, and collect
|
||||
structured results.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it does |
|
||||
|------|--------------|
|
||||
| `apify_discover` | Search the Apify Store by keyword, or fetch a specific Actor's input schema + README by `actor_id`. |
|
||||
| `apify_start` | Fire-and-forget batch Actor starts (up to 10 per call). Returns run refs immediately so the agent keeps reasoning while Actors run. |
|
||||
| `apify_collect` | Poll run statuses and return completed dataset results, wrapped in `EXTERNAL_UNTRUSTED_CONTENT` markers. Supports `limit` / `may_have_more` pagination. |
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create an Apify account and get a token at
|
||||
<https://apify.com/account/integrations>.
|
||||
2. Run `hermes tools`, open **Apify Actors**, enable the toolset, and paste your
|
||||
token. (The token is stored in `~/.hermes/.env` as `APIFY_API_TOKEN` and is
|
||||
never sent to the model.)
|
||||
|
||||
The `apify` toolset is **off by default**. The three tools register on startup
|
||||
but stay invisible to the model until `APIFY_API_TOKEN` is set (runtime
|
||||
`check_fn` gate).
|
||||
|
||||
## Architecture
|
||||
|
||||
This is a bundled `kind: backend` plugin (auto-loads, no opt-in), modeled on the
|
||||
Spotify plugin. Tool registration goes through the plugin API
|
||||
(`ctx.register_tool()`), so the Apify tools never enter `_HERMES_CORE_TOOLS`. The
|
||||
`apify-client` SDK is installed on demand via `tools.lazy_deps` (`search.apify`)
|
||||
the first time an Actor runs.
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `__init__.py` | `register(ctx)` — wires the three tools via `ctx.register_tool()`. |
|
||||
| `tools.py` | Handlers + JSON schemas. |
|
||||
| `client.py` | Lazy `apify-client` import, token validation, module-level cache. |
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Apify Actor execution plugin — bundled, auto-loaded.
|
||||
|
||||
Registers three tools (``apify_discover``, ``apify_start``, ``apify_collect``)
|
||||
into the ``apify`` toolset. Each tool is gated by ``_check_token()`` — when the
|
||||
user has not set ``APIFY_API_TOKEN`` the tools stay registered (so they appear
|
||||
in ``hermes tools``) but the runtime check prevents dispatch.
|
||||
|
||||
Why a plugin instead of top-level ``tools/`` files?
|
||||
|
||||
- ``plugins/`` is where third-party service integrations live (see
|
||||
``plugins/spotify/`` for the same pattern — optional SaaS, token-gated,
|
||||
default-off toolset). ``tools/`` is reserved for foundational capabilities
|
||||
(terminal, read_file, web_search, etc.).
|
||||
- Bundled + ``kind: backend`` auto-loads on startup just like the Spotify
|
||||
plugin — no user opt-in needed, no ``plugins.enabled`` config.
|
||||
- Keeps the three Apify tools out of ``_HERMES_CORE_TOOLS`` in ``toolsets.py``;
|
||||
the plugin loader registers them via ``ctx.register_tool()``.
|
||||
|
||||
The ``apify`` toolset is default-off (``_DEFAULT_OFF_TOOLSETS`` in
|
||||
``hermes_cli/tools_config.py``) and the ``APIFY_API_TOKEN`` setup UX is wired
|
||||
through ``hermes tools`` (``TOOL_CATEGORIES``) and ``OPTIONAL_ENV_VARS``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
from plugins.apify.tools import (
|
||||
_COLLECT_SCHEMA,
|
||||
_DISCOVER_SCHEMA,
|
||||
_START_SCHEMA,
|
||||
_check_token,
|
||||
_collect_handler,
|
||||
_discover_handler,
|
||||
_start_handler,
|
||||
)
|
||||
|
||||
|
||||
async def _collect_handler_str(args: Dict[str, Any], **_kw: Any) -> str:
|
||||
return json.dumps(await _collect_handler(args), default=str)
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Register the Apify Actor tools. Called once by the plugin loader."""
|
||||
ctx.register_tool(
|
||||
name="apify_discover",
|
||||
toolset="apify",
|
||||
schema=_DISCOVER_SCHEMA,
|
||||
handler=lambda args, **kw: json.dumps(_discover_handler(args), default=str),
|
||||
check_fn=_check_token,
|
||||
requires_env=["APIFY_API_TOKEN"],
|
||||
emoji="🔍",
|
||||
)
|
||||
ctx.register_tool(
|
||||
name="apify_start",
|
||||
toolset="apify",
|
||||
schema=_START_SCHEMA,
|
||||
handler=lambda args, **kw: json.dumps(_start_handler(args), default=str),
|
||||
check_fn=_check_token,
|
||||
requires_env=["APIFY_API_TOKEN"],
|
||||
emoji="▶️",
|
||||
)
|
||||
ctx.register_tool(
|
||||
name="apify_collect",
|
||||
toolset="apify",
|
||||
schema=_COLLECT_SCHEMA,
|
||||
handler=_collect_handler_str,
|
||||
check_fn=_check_token,
|
||||
requires_env=["APIFY_API_TOKEN"],
|
||||
is_async=True,
|
||||
emoji="📦",
|
||||
)
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Shared Apify SDK client — lazy import, token validation, and cache.
|
||||
|
||||
Used by the Apify Actor execution tools (plugins/apify/tools.py). The
|
||||
``apify-client`` SDK is installed on demand via ``tools.lazy_deps`` so the
|
||||
dependency is only pulled when the user actually enables the plugin and runs
|
||||
an Actor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Optional
|
||||
|
||||
# Sent with every request so Apify can attribute traffic to this integration.
|
||||
_HERMES_HEADERS = {"x-apify-integration-platform": "hermes-agent"}
|
||||
|
||||
_CLIENT_CLS: Optional[type] = None
|
||||
_CLIENT: Optional[Any] = None
|
||||
_CLIENT_CONFIG: Optional[Any] = None
|
||||
|
||||
|
||||
def _load_client_cls() -> type:
|
||||
global _CLIENT_CLS
|
||||
if _CLIENT_CLS is None:
|
||||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("search.apify", prompt=False)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ImportError(str(exc))
|
||||
from apify_client import ApifyClient
|
||||
_CLIENT_CLS = ApifyClient
|
||||
return _CLIENT_CLS
|
||||
|
||||
|
||||
def check_apify_api_key() -> bool:
|
||||
"""Return True when APIFY_API_TOKEN is configured."""
|
||||
return bool(os.getenv("APIFY_API_TOKEN", "").strip())
|
||||
|
||||
|
||||
def get_apify_client() -> Any:
|
||||
"""Return a cached ApifyClient built from APIFY_API_TOKEN.
|
||||
|
||||
Raises ValueError when the token is not set.
|
||||
"""
|
||||
global _CLIENT, _CLIENT_CONFIG
|
||||
api_token = os.getenv("APIFY_API_TOKEN", "").strip()
|
||||
if not api_token:
|
||||
raise ValueError(
|
||||
"Apify tools are not configured. "
|
||||
"Set APIFY_API_TOKEN (get one at https://apify.com/account/integrations)."
|
||||
)
|
||||
client_config = ("direct", api_token)
|
||||
if _CLIENT is not None and _CLIENT_CONFIG == client_config:
|
||||
return _CLIENT
|
||||
_CLIENT = _load_client_cls()(token=api_token, headers=_HERMES_HEADERS)
|
||||
_CLIENT_CONFIG = client_config
|
||||
return _CLIENT
|
||||
|
||||
|
||||
def _reset_client_for_tests() -> None:
|
||||
"""Drop cached client so tests can re-instantiate cleanly."""
|
||||
global _CLIENT, _CLIENT_CONFIG
|
||||
_CLIENT = None
|
||||
_CLIENT_CONFIG = None
|
||||
@@ -1,9 +0,0 @@
|
||||
name: apify
|
||||
version: 1.0.0
|
||||
description: "Apify Actor execution — 3 tools (discover, start, collect) for running any of 20,000+ Actors from the Apify Store (web automation, data extraction, social media, maps, e-commerce). Gated on APIFY_API_TOKEN. Toolset is default-off; enable via `hermes tools` → Apify Actors."
|
||||
author: JanHranicky
|
||||
kind: backend
|
||||
provides_tools:
|
||||
- apify_discover
|
||||
- apify_start
|
||||
- apify_collect
|
||||
@@ -1,390 +0,0 @@
|
||||
"""Apify Actor execution tools — discover, start, collect.
|
||||
|
||||
Handlers and schemas for the three Apify tools. Registration happens in
|
||||
``plugins/apify/__init__.py`` via ``ctx.register_tool()`` (the plugin API),
|
||||
not via direct ``registry.register()`` calls.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TERMINAL_STATUSES = {"SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"}
|
||||
_MAX_BATCH_RUNS = 10
|
||||
_COLLECT_DEFAULT_LIMIT = 100
|
||||
|
||||
|
||||
def _attr(obj: Any, key: str, default: Any = None) -> Any:
|
||||
"""Get attribute or dict key from SDK response objects (apify_client returns either)."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
if obj is not None and hasattr(obj, key):
|
||||
return getattr(obj, key)
|
||||
return default
|
||||
|
||||
|
||||
def _get_client() -> Any:
|
||||
from plugins.apify.client import get_apify_client
|
||||
return get_apify_client()
|
||||
|
||||
|
||||
def _check_token() -> bool:
|
||||
from plugins.apify.client import check_apify_api_key
|
||||
return check_apify_api_key()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _discover_handler(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from tools.interrupt import is_interrupted
|
||||
if is_interrupted():
|
||||
return {"error": "Interrupted"}
|
||||
|
||||
query = (_attr(args, "query") or "").strip() or None
|
||||
actor_id = (_attr(args, "actor_id") or "").strip() or None
|
||||
|
||||
if not query and not actor_id:
|
||||
return {
|
||||
"error": (
|
||||
"Provide exactly one of 'query' (to search the Apify Store) "
|
||||
"or 'actor_id' (to fetch an Actor's input schema)."
|
||||
)
|
||||
}
|
||||
|
||||
client = _get_client()
|
||||
|
||||
if actor_id:
|
||||
try:
|
||||
actor_info = client.actor(actor_id).get()
|
||||
if actor_info is None:
|
||||
return {
|
||||
"error": (
|
||||
f"Actor '{actor_id}' not found. "
|
||||
"Check the ID format: username~actor-name."
|
||||
)
|
||||
}
|
||||
|
||||
input_schema: Any = None
|
||||
readme: Any = None
|
||||
|
||||
build_detail = client.actor(actor_id).default_build().get()
|
||||
if build_detail is not None:
|
||||
actor_def = _attr(build_detail, "actorDefinition") or {}
|
||||
raw_schema = _attr(actor_def, "input")
|
||||
if raw_schema:
|
||||
input_schema = json.dumps(raw_schema)
|
||||
else:
|
||||
fallback = _attr(build_detail, "inputSchema")
|
||||
if fallback:
|
||||
input_schema = str(fallback)
|
||||
|
||||
raw_readme = _attr(actor_def, "readme") or _attr(build_detail, "readme")
|
||||
if raw_readme:
|
||||
readme = str(raw_readme)[:3000]
|
||||
|
||||
username = _attr(actor_info, "username", "")
|
||||
name = _attr(actor_info, "name", "")
|
||||
title = _attr(actor_info, "title", "") or name
|
||||
return {
|
||||
"actor_id": f"{username}~{name}",
|
||||
"name": name,
|
||||
"title": title,
|
||||
"username": username,
|
||||
"description": _attr(actor_info, "description", ""),
|
||||
"input_schema": input_schema,
|
||||
"readme": readme,
|
||||
"tip": (
|
||||
f"Use apify_start with actor_id='{username}~{name}' "
|
||||
"and an input matching the input_schema above."
|
||||
),
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("apify_discover schema fetch error for %s: %s", actor_id, exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
# Store search
|
||||
try:
|
||||
result = client.store().list(search=query, limit=10, sort_by="relevance")
|
||||
items = _attr(result, "items") or []
|
||||
actors: List[Dict[str, Any]] = []
|
||||
for item in items:
|
||||
stats = _attr(item, "stats") or {}
|
||||
name = _attr(item, "name", "")
|
||||
username = _attr(item, "username", "")
|
||||
title = _attr(item, "title") or name
|
||||
desc = (_attr(item, "description") or "")[:200]
|
||||
run_count = _attr(stats, "totalRuns", 0) or 0
|
||||
rating = _attr(stats, "averageRating")
|
||||
actors.append({
|
||||
"actor_id": f"{username}~{name}",
|
||||
"name": name,
|
||||
"title": title,
|
||||
"username": username,
|
||||
"description": desc,
|
||||
"run_count": run_count,
|
||||
"rating": rating,
|
||||
})
|
||||
return {"actors": actors}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("apify_discover store search error for '%s': %s", query, exc)
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def _start_handler(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from tools.interrupt import is_interrupted
|
||||
if is_interrupted():
|
||||
return {"error": "Interrupted"}
|
||||
|
||||
run_specs = args.get("runs") or []
|
||||
if not run_specs:
|
||||
return {"error": "Provide at least one run spec in 'runs'."}
|
||||
if len(run_specs) > _MAX_BATCH_RUNS:
|
||||
return {"error": f"Batch too large: {len(run_specs)} runs requested, maximum is {_MAX_BATCH_RUNS}."}
|
||||
|
||||
client = _get_client()
|
||||
started: List[Dict[str, Any]] = []
|
||||
errors: List[Dict[str, Any]] = []
|
||||
|
||||
for spec in run_specs:
|
||||
from tools.interrupt import is_interrupted
|
||||
if is_interrupted():
|
||||
break
|
||||
|
||||
actor_id = (spec.get("actor_id") or "").strip()
|
||||
run_input = spec.get("input") or {}
|
||||
label = spec.get("label")
|
||||
|
||||
if not actor_id:
|
||||
errors.append({"error": "Missing 'actor_id' in run spec."})
|
||||
continue
|
||||
|
||||
try:
|
||||
run = client.actor(actor_id).start(run_input=run_input)
|
||||
entry: Dict[str, Any] = {
|
||||
"run_id": _attr(run, "id"),
|
||||
"actor_id": actor_id,
|
||||
"dataset_id": _attr(run, "default_dataset_id"),
|
||||
"status": _attr(run, "status"),
|
||||
}
|
||||
if label:
|
||||
entry["label"] = label
|
||||
started.append(entry)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("apify_start error for %s: %s", actor_id, exc)
|
||||
err: Dict[str, Any] = {"actor_id": actor_id, "error": str(exc)}
|
||||
if label:
|
||||
err["label"] = label
|
||||
errors.append(err)
|
||||
|
||||
result: Dict[str, Any] = {"runs": started}
|
||||
if errors:
|
||||
result["errors"] = errors
|
||||
return result
|
||||
|
||||
|
||||
async def _collect_handler(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
from tools.interrupt import is_interrupted
|
||||
if is_interrupted():
|
||||
return {"error": "Interrupted"}
|
||||
|
||||
run_refs = args.get("runs") or []
|
||||
if not run_refs:
|
||||
return {"error": "Provide 'runs' array (from apify_start)."}
|
||||
|
||||
limit = int(args.get("limit") or _COLLECT_DEFAULT_LIMIT)
|
||||
client = _get_client()
|
||||
|
||||
async def _check_run(ref: Dict[str, Any]) -> Dict[str, Any]:
|
||||
run_id = ref.get("run_id", "")
|
||||
actor_id = ref.get("actor_id", "")
|
||||
dataset_id = ref.get("dataset_id", "")
|
||||
label = ref.get("label")
|
||||
|
||||
base: Dict[str, Any] = {
|
||||
"run_id": run_id,
|
||||
"actor_id": actor_id,
|
||||
"dataset_id": dataset_id,
|
||||
}
|
||||
if label:
|
||||
base["label"] = label
|
||||
|
||||
try:
|
||||
run_info = await asyncio.to_thread(client.run(run_id).get)
|
||||
|
||||
if run_info is None:
|
||||
return {**base, "_type": "error", "error": "Run not found."}
|
||||
|
||||
status = _attr(run_info, "status", "UNKNOWN")
|
||||
base["status"] = status
|
||||
|
||||
if status not in _TERMINAL_STATUSES:
|
||||
return {**base, "_type": "pending"}
|
||||
|
||||
if status != "SUCCEEDED":
|
||||
return {**base, "_type": "error", "error": f"Run ended with status: {status}"}
|
||||
|
||||
# SUCCEEDED — fetch dataset and wrap as external content
|
||||
dataset_result = await asyncio.to_thread(
|
||||
client.dataset(dataset_id).list_items, limit=limit
|
||||
)
|
||||
items = list(_attr(dataset_result, "items") or [])
|
||||
may_have_more = len(items) == limit
|
||||
if may_have_more:
|
||||
logger.warning(
|
||||
"apify_collect run %s: fetched %d items (hit limit=%d) — "
|
||||
"dataset may have more; re-call with a higher limit if needed",
|
||||
run_id, len(items), limit,
|
||||
)
|
||||
raw = json.dumps(items, indent=2, default=str)
|
||||
if len(raw) > 50_000:
|
||||
raw = raw[:50_000] + "\n\n[…truncated]"
|
||||
wrapped = (
|
||||
"<<<EXTERNAL_UNTRUSTED_CONTENT>>>\n"
|
||||
+ raw
|
||||
+ "\n<<<END_EXTERNAL_UNTRUSTED_CONTENT>>>"
|
||||
)
|
||||
result: Dict[str, Any] = {
|
||||
**base,
|
||||
"_type": "completed",
|
||||
"result_count": len(items),
|
||||
"data": wrapped,
|
||||
}
|
||||
if may_have_more:
|
||||
result["may_have_more"] = True
|
||||
return result
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("apify_collect error for run %s: %s", run_id, exc)
|
||||
return {**base, "_type": "error", "error": str(exc)}
|
||||
|
||||
raw_results = await asyncio.gather(*[_check_run(ref) for ref in run_refs])
|
||||
|
||||
completed: List[Dict[str, Any]] = []
|
||||
pending: List[Dict[str, Any]] = []
|
||||
errors: List[Dict[str, Any]] = []
|
||||
|
||||
for r in raw_results:
|
||||
t = r.pop("_type", "error")
|
||||
if t == "pending":
|
||||
pending.append(r)
|
||||
elif t == "error":
|
||||
errors.append(r)
|
||||
else:
|
||||
completed.append(r)
|
||||
|
||||
return {
|
||||
"all_done": len(pending) == 0,
|
||||
"completed": completed,
|
||||
"pending": pending,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_DISCOVER_SCHEMA: Dict[str, Any] = {
|
||||
"name": "apify_discover",
|
||||
"description": (
|
||||
"Search the Apify Store for Actors by keyword, or fetch an Actor's "
|
||||
"input schema and README. Provide 'query' to search, or 'actor_id' "
|
||||
"to inspect a specific Actor. Actor IDs use tilde: username~actor-name."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Keywords to search the Apify Store (e.g. 'instagram scraper').",
|
||||
},
|
||||
"actor_id": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Actor ID to fetch its input schema and README "
|
||||
"(e.g. 'apify~google-search-scraper')."
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_START_SCHEMA: Dict[str, Any] = {
|
||||
"name": "apify_start",
|
||||
"description": (
|
||||
"Start one or more Apify Actor runs. Returns run references immediately "
|
||||
f"(fire-and-forget). Pass the returned run refs to apify_collect to get results. "
|
||||
f"Maximum {_MAX_BATCH_RUNS} runs per call."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"runs": {
|
||||
"type": "array",
|
||||
"description": f"List of Actor runs to start (max {_MAX_BATCH_RUNS}).",
|
||||
"maxItems": _MAX_BATCH_RUNS,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"actor_id": {
|
||||
"type": "string",
|
||||
"description": "Actor ID (username~actor-name).",
|
||||
},
|
||||
"input": {
|
||||
"type": "object",
|
||||
"description": "Actor input parameters.",
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Optional label to identify this run in results.",
|
||||
},
|
||||
},
|
||||
"required": ["actor_id"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["runs"],
|
||||
},
|
||||
}
|
||||
|
||||
_COLLECT_SCHEMA: Dict[str, Any] = {
|
||||
"name": "apify_collect",
|
||||
"description": (
|
||||
"Poll the status of Apify Actor runs started with apify_start. "
|
||||
"Returns completed results, still-running refs, and errors. "
|
||||
"Re-call with the same run refs until all_done is true."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"runs": {
|
||||
"type": "array",
|
||||
"description": "Run references returned by apify_start.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"run_id": {"type": "string"},
|
||||
"actor_id": {"type": "string"},
|
||||
"dataset_id": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
},
|
||||
"required": ["run_id", "actor_id", "dataset_id"],
|
||||
},
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
f"Max dataset items to fetch per run (default {_COLLECT_DEFAULT_LIMIT}). "
|
||||
"Increase if may_have_more is true in a previous response."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["runs"],
|
||||
},
|
||||
}
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user