Merge remote-tracking branch 'origin/main' into ethie/pm-clean

Resolved toward the branch: PM provisions uv/python (main's install.ps1 uv-shim
salvage + its test and workflow steps dropped), the shim re-exec stays retired,
package.json carries no electron-builder block (afterExtract identity stamp wired
into electron-builder.config.cjs instead; after-pack.mjs keeps signing only),
Desktop workspace-deps helpers stay retired. Main's scratch-dir bootstrap
(export_scratch_tmp_env) is taken and re-run after profile resolution.
This commit is contained in:
ethernet
2026-09-19 22:57:07 -04:00
698 changed files with 18472 additions and 2011 deletions

View File

@@ -199,14 +199,14 @@ def build_acp_edit_tool_call(proposal: EditProposal):
def make_acp_edit_approval_requester(
request_permission_fn: Callable, loop: asyncio.AbstractEventLoop, session_id: str,
timeout: float = 60.0, auto_approve_getter: Callable[[], tuple[str, str | None]] | None = None,
timeout: float | None = None, auto_approve_getter: Callable[[], tuple[str, str | None]] | None = None,
send_update: Callable[[object], None] | None = None,
) -> EditApprovalRequester:
"""Return a sync requester that bridges edit proposals to ACP permissions."""
def _requester(proposal: EditProposal) -> bool:
from acp.schema import PermissionOption
from acp_adapter.permissions import await_permission
from acp_adapter.permissions import await_permission, resolve_permission_timeout
if auto_approve_getter is not None:
try:
@@ -221,7 +221,7 @@ def make_acp_edit_approval_requester(
request_permission_fn, loop, session_id, tool_call=build_acp_edit_tool_call(proposal),
options=[PermissionOption(option_id="allow_once", kind="allow_once", name="Allow edit"),
PermissionOption(option_id="deny", kind="reject_once", name="Deny")],
timeout=timeout, what="Edit approval request", send_update=send_update,
timeout=resolve_permission_timeout(timeout), what="Edit approval request", send_update=send_update,
)
outcome = getattr(response, "outcome", None)
return getattr(outcome, "outcome", None) == "selected" and getattr(outcome, "option_id", None) == "allow_once"

View File

@@ -145,6 +145,14 @@ def _run_setup_browser(assume_yes: bool = False) -> int:
return 0
def _warm_memory_provider_import(logger: logging.Logger) -> None:
"""Import ``memory.provider``'s module + numpy (no provider instance) before the ACP threads start."""
from plugins.memory import import_memory_provider_module
if not import_memory_provider_module():
logger.debug("memory provider not warmed (none configured or import failed; agent init reports that)")
def main(argv: list[str] | None = None) -> None:
"""Entry point: load env, configure logging, run the ACP agent."""
args = _parse_args(argv)
@@ -170,6 +178,15 @@ def main(argv: list[str] | None = None) -> None:
import acp
from .server import HermesACPAgent
# Windows: import the configured memory provider (and numpy) on the main thread before
# the MCP-discovery and ACP stdin-reader threads start (hermes_cli's ~150 ms
# plugin-discovery thread is the only one already running). A first-time
# native-extension import (numpy via holographic / mnemosyne / hindsight) racing another
# thread's import chain deadlocked in create_module and session/new never answered
# (#58083). After this the off-loop agent build finds the modules in sys.modules.
if sys.platform == "win32":
_warm_memory_provider_import(logger)
# MCP discovery from config.yaml runs in a background daemon thread so the ACP server is
# responsive immediately (blocking here cost 2-5 s); per-session MCP servers registered via
# asyncio.to_thread are unaffected. Metadata-only hosts can opt out of the global startup.

View File

@@ -114,12 +114,24 @@ def await_permission(
return response, timed_out
def resolve_permission_timeout(timeout: float | None) -> float:
"""``None`` → the user's ``approvals.timeout`` (same knob as CLI/gateway prompts, default
300 s). The ACP bridges used to hardcode 60 s, so a host whose approval card was still
waiting saw Hermes self-deny under it (#73403)."""
if timeout is not None:
return float(timeout)
from tools.approval_context import _get_approval_timeout
return float(_get_approval_timeout())
def make_approval_callback(request_permission_fn: Callable, loop: asyncio.AbstractEventLoop,
session_id: str, timeout: float = 60.0,
session_id: str, timeout: float | None = None,
send_update: Callable[[object], None] | None = None) -> Callable[..., str]:
"""Return a Hermes approval callback (``command, description, **kw`` as used by
``tools.approval.prompt_dangerous_approval()``) that bridges to the ACP
connection's ``request_permission`` coroutine on ``loop``; auto-denies after ``timeout`` s."""
connection's ``request_permission`` coroutine on ``loop``; auto-denies after ``timeout`` s
(``None`` → ``approvals.timeout``, read per request)."""
def _callback(command: str, description: str, *, allow_permanent: bool = True,
allow_session: bool = True, smart_denied: bool = False, **_: object) -> str:
@@ -127,7 +139,8 @@ def make_approval_callback(request_permission_fn: Callable, loop: asyncio.Abstra
smart_denied=smart_denied)
response, timed_out = await_permission(
request_permission_fn, loop, session_id, tool_call=_build_permission_tool_call(command, description),
options=options, timeout=timeout, what="Permission request", send_update=send_update,
options=options, timeout=resolve_permission_timeout(timeout), what="Permission request",
send_update=send_update,
)
if timed_out:
# Distinct from an explicit deny: tools.approval reports "timed out

View File

@@ -238,7 +238,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
"accept_edits": (
"workspace_session",
"Accept Edits",
"Auto-allow workspace and /tmp edits; still asks for sensitive paths.",
"Auto-allow workspace and temp-dir edits; still asks for sensitive paths.",
),
"dont_ask": (
"session", "Don't Ask", "Auto-allow file edits for this session except sensitive paths."
@@ -601,14 +601,16 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
logger.info(log, *log_args)
async def new_session(self, cwd: str, mcp_servers: list | None = None, **kwargs: Any) -> NewSessionResponse:
state = self.session_manager.create_session(cwd=cwd)
# Agent construction (config, memory-provider import, SessionDB) is slow and fully
# blocking; inline it froze the loop serving every JSON-RPC request (#58083).
state = await asyncio.to_thread(self.session_manager.create_session, cwd=cwd)
await self._attach_session_mcp(state, mcp_servers, "New session %s (cwd=%s)", state.session_id, cwd)
return NewSessionResponse(session_id=state.session_id, **await self._session_response_fields(state))
async def load_session(
self, cwd: str, session_id: str, mcp_servers: list | None = None, **kwargs: Any
) -> LoadSessionResponse | None:
state = self.session_manager.update_cwd(session_id, cwd)
state = await asyncio.to_thread(self.session_manager.update_cwd, session_id, cwd)
if state is None:
logger.warning("load_session: session %s not found", session_id)
return None
@@ -618,15 +620,17 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
async def resume_session(
self, cwd: str, session_id: str, mcp_servers: list | None = None, **kwargs: Any
) -> ResumeSessionResponse:
state = self.session_manager.update_cwd(session_id, cwd)
state = await asyncio.to_thread(self.session_manager.update_cwd, session_id, cwd)
if state is None:
logger.warning("resume_session: session %s not found, creating new", session_id)
state = self.session_manager.create_session(cwd=cwd)
state = await asyncio.to_thread(self.session_manager.create_session, cwd=cwd)
await self._attach_session_mcp(state, mcp_servers, "Resumed session %s", state.session_id)
return ResumeSessionResponse(**await self._session_response_fields(state, "resume"))
async def cancel(self, session_id: str, **kwargs: Any) -> None:
state = self.session_manager.get_session(session_id)
# get_session restores a not-in-memory id from the DB (full AIAgent build) and waits
# on the restore lock — off the loop, like new/load/resume/fork (#58083).
state = await asyncio.to_thread(self.session_manager.get_session, session_id)
if not (state and state.cancel_event):
return
with state.runtime_lock:
@@ -649,7 +653,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
async def fork_session(
self, cwd: str, session_id: str, mcp_servers: list | None = None, **kwargs: Any
) -> ForkSessionResponse:
state = self.session_manager.fork_session(session_id, cwd=cwd)
state = await asyncio.to_thread(self.session_manager.fork_session, session_id, cwd=cwd)
if state is None:
logger.info("Forked session %s -> %s", session_id, "")
return ForkSessionResponse(session_id="")
@@ -799,7 +803,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
async def prompt(self, prompt: list[PromptBlock], session_id: str, **kwargs: Any) -> PromptResponse:
"""Run Hermes on the user's prompt and stream events back to the editor."""
state = self.session_manager.get_session(session_id)
state = await asyncio.to_thread(self.session_manager.get_session, session_id)
if state is None:
logger.error("prompt: session %s not found", session_id)
return PromptResponse(stop_reason="refusal")
@@ -994,7 +998,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) -> SetSessionModelResponse | None:
"""Switch the model for a session (called by ACP protocol)."""
state = self.session_manager.get_session(session_id)
state = await asyncio.to_thread(self.session_manager.get_session, session_id)
if state:
# switch_model() does synchronous network I/O (models.dev, custom-endpoint probes,
# ~10 s cold) — off the loop, like the gateway, so other ACP sessions keep flowing.
@@ -1009,7 +1013,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
async def set_session_mode(self, mode_id: str, session_id: str, **kwargs: Any) -> SetSessionModeResponse | None:
"""Persist the editor-requested mode so ACP clients do not fail on mode switches."""
state = self.session_manager.get_session(session_id)
state = await asyncio.to_thread(self.session_manager.get_session, session_id)
if state is None:
logger.warning("Session %s: mode switch requested for missing session", session_id)
return None
@@ -1025,7 +1029,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent):
self, config_id: str, session_id: str, value: str, **kwargs: Any
) -> SetSessionConfigOptionResponse | None:
"""Accept ACP config option updates even when Hermes has no typed ACP config surface yet."""
state = self.session_manager.get_session(session_id)
state = await asyncio.to_thread(self.session_manager.get_session, session_id)
if state is None:
logger.warning("Session %s: config update requested for missing session", session_id)
return None

View File

@@ -159,6 +159,9 @@ class SessionManager:
the runtime provider config. ``db``: SessionDB; default lazily opens ``~/.hermes/state.db``."""
self._sessions: Dict[str, SessionState] = {}
self._lock = threading.Lock()
# Serializes DB restores: session construction runs off the event loop, so two
# overlapping session/load for one id must share a single agent build.
self._restore_lock = threading.Lock()
self._agent_factory = agent_factory
self._db_instance = db # None → lazy-init on first use
@@ -178,7 +181,12 @@ class SessionManager:
a process restart) when it is not in memory; ``None`` if unknown."""
with self._lock:
state = self._sessions.get(session_id)
return state if state is not None else self._restore(session_id)
if state is not None:
return state
with self._restore_lock:
with self._lock:
state = self._sessions.get(session_id) # a concurrent restore may have installed it
return state if state is not None else self._restore(session_id)
def fork_session(self, session_id: str, cwd: str = ".") -> Optional[SessionState]:
"""Deep-copy a session's history into a new session."""
@@ -383,6 +391,7 @@ class SessionManager:
from run_agent import AIAgent
from hermes_cli.config import load_config
from hermes_cli.runtime_provider import resolve_runtime_provider
from hermes_constants import resolve_reasoning_config
config = load_config()
model_cfg = config.get("model")
@@ -403,6 +412,10 @@ class SessionManager:
"disabled_toolsets": list(disabled_toolsets) if disabled_toolsets is not None else None,
"model": model or default_model,
"cwd": cwd,
# Same chokepoint as the CLI/gateway/TUI/cron: without it ``agent.reasoning_effort: none`` never
# reaches an ACP session and the transport applies its default effort (a 400 on non-reasoning
# models). Resolved against the session's model so per-model overrides apply.
"reasoning_config": resolve_reasoning_config(config, model or default_model),
}
try:
runtime = resolve_runtime_provider(
@@ -410,6 +423,7 @@ class SessionManager:
kwargs.update({
"provider": runtime.get("provider"), "api_mode": api_mode or runtime.get("api_mode"),
"base_url": base_url or runtime.get("base_url"), "api_key": runtime.get("api_key"),
"credential_pool": runtime.get("credential_pool"),
"command": runtime.get("command"), "args": list(runtime.get("args") or []),
})
except Exception: