Phase 005: vendor ArkScope, land the Arco style baseline, and fix bridge interpreter resolution

- Vendor ArkScope decorative layers (ScrambleTitle / IsometricWave) with provenance notes; decorative-only per AGENTS.md red lines
- Migrate theme tokens to Arco, register the Arco theme, and add hook adapter composables (useMessage / useDialog / useNotification) with matching signatures
- Sample pages: LoginView and AppSidebar on the new baseline; docs/arco-mapping.md and docs/ui-baseline.md land
- agent-bridge: prefer the PM-selected venv from facts.json over legacy in-tree venvs (legacy lacks the mcp extra, so every MCP server showed 0 tools / not connected)
- agent-bridge python (vendored, deliberate deviation): MCP discovery import fallback and pyyaml -> hermes_yaml so plugins and MCP work under the PM env; install the mcp extra via `hermes pm install --extra mcp`
- Chat-chain fragment: docs/chat-chain-changes/2026-10-17-phase005-arkscope-baseline.md

Verified: harness:check, test (2020 passed), test:e2e (30 passed), build all green; manual: plugins 100/0 warnings, MCP 6/6 servers connected with 104 tools.
This commit is contained in:
2026-10-17 09:45:30 +08:00
parent 4701bf0ad0
commit 4baccd5d88
106 changed files with 1493 additions and 221 deletions
@@ -75,9 +75,12 @@ python packages/server/src/services/hermes/agent-bridge/python/hermes_bridge.py
If no source checkout containing `run_agent.py` is found, the bridge falls back
to importing `run_agent` from the Python environment. This supports package
installs such as `pip install hermes-agent`. The Node manager prefers the source
checkout's virtualenv when a checkout exists, then the Python interpreter from
the installed `hermes` command, then the system Python.
installs such as `pip install hermes-agent`. The Node manager resolves the
interpreter in this order: an explicit `HERMES_AGENT_BRIDGE_PYTHON`, the
PM-selected venv recorded in `<hermesHome>/installs/<key>/facts.json` (the
environment hermes itself runs in, including extras such as `mcp`), the
checkout's in-tree `.venv`/`venv` trees (legacy fallbacks), the Python
interpreter from the installed `hermes` command, `uv run`, then system Python.
The socket transport uses Python and Node standard libraries. No ZMQ dependency
is required.
@@ -1,5 +1,6 @@
import { execFileSync, spawn, type ChildProcess } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import { createHash } from 'crypto'
import { existsSync, readFileSync, realpathSync } from 'fs'
import { createConnection, createServer } from 'net'
import { dirname, isAbsolute, join, resolve } from 'path'
import { logger } from '../../logger'
@@ -103,18 +104,22 @@ export function buildAgentBridgeProcessEnv(endpoint: string, hermesHome: string
function pathCandidates(agentRoot?: string): string[] {
if (!agentRoot) return []
// Legacy in-tree virtualenvs. PM-managed installs live under
// `<hermesHome>/installs/<key>/environments/.../venv` (see
// pmSelectedVenvPython below); these trees predate PM and may be a bare
// interpreter, so they are only a fallback.
return process.platform === 'win32'
? [
join(agentRoot, 'venv', 'Scripts', 'python.exe'),
join(agentRoot, 'venv', 'Scripts', 'python3.exe'),
join(agentRoot, '.venv', 'Scripts', 'python.exe'),
join(agentRoot, '.venv', 'Scripts', 'python3.exe'),
join(agentRoot, 'venv', 'Scripts', 'python.exe'),
join(agentRoot, 'venv', 'Scripts', 'python3.exe'),
]
: [
join(agentRoot, 'venv', 'bin', 'python3'),
join(agentRoot, 'venv', 'bin', 'python'),
join(agentRoot, '.venv', 'bin', 'python3'),
join(agentRoot, '.venv', 'bin', 'python'),
join(agentRoot, 'venv', 'bin', 'python3'),
join(agentRoot, 'venv', 'bin', 'python'),
]
}
@@ -218,6 +223,41 @@ function firstExistingExecutable(candidates: string[]): string | undefined {
return undefined
}
// PM (hermes's package manager) records the selected venv in
// `<hermesHome>/installs/<installKey>/facts.json`, where installKey is
// sha256(resolved agentRoot)[:16] (see pm/environments.py). That venv carries
// hermes's extras (mcp, ruamel, ...); the in-tree `.venv`/`venv` trees predate
// PM and often lack them.
function pmSelectedVenvPython(agentRoot: string | undefined, hermesHome: string): string | undefined {
if (!agentRoot) return undefined
let resolvedRoot = agentRoot
try {
resolvedRoot = realpathSync(agentRoot)
} catch {}
const installKey = createHash('sha256').update(resolve(resolvedRoot)).digest('hex').slice(0, 16)
const factsCandidates = [
join(hermesHome, 'installs', installKey, 'facts.json'),
join(process.env.HERMES_HOME?.trim() || join(process.env.HOME || '', '.hermes'), 'installs', installKey, 'facts.json'),
]
for (const factsPath of factsCandidates) {
try {
if (!existsSync(factsPath)) continue
const facts = JSON.parse(readFileSync(factsPath, 'utf8')) as {
packages?: { venv?: { environment?: string } }
}
const environment = facts?.packages?.venv?.environment
if (!environment) continue
const python = firstExistingExecutable(
process.platform === 'win32'
? [join(environment, 'Scripts', 'python.exe'), join(environment, 'Scripts', 'python3.exe')]
: [join(environment, 'bin', 'python3'), join(environment, 'bin', 'python')],
)
if (python) return python
} catch {}
}
return undefined
}
function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): string | undefined {
const candidates = [
explicit,
@@ -242,6 +282,11 @@ export function resolveAgentBridgeCommand(options: AgentBridgeManagerOptions = {
return { command: explicitPython, argsPrefix: [], agentRoot, hermesHome }
}
const pmPython = pmSelectedVenvPython(agentRoot, hermesHome)
if (pmPython) {
return { command: pmPython, argsPrefix: [], agentRoot, hermesHome }
}
const venvPython = firstExistingExecutable(pathCandidates(agentRoot))
if (venvPython) {
return { command: venvPython, argsPrefix: [], agentRoot, hermesHome }
@@ -825,7 +825,11 @@ def _load_enabled_toolsets() -> list[str] | None:
def _discover_bridge_mcp_tools() -> list[str]:
_ensure_agent_imports()
try:
from tools.mcp_tool import discover_mcp_tools
try:
from tools.mcp_tool import discover_mcp_tools
except ImportError:
# hermes-agent split tools.mcp_tool; discovery lives in its own module now.
from tools.mcp_tool_discovery import discover_mcp_tools
tools = discover_mcp_tools()
return list(tools) if isinstance(tools, list) else []
@@ -237,7 +237,9 @@ class BridgeServer:
def _read_mcp_config(self, profile=None):
"""Read config.yaml for the given profile."""
import yaml
# hermes ships `hermes_yaml` (ruamel, YAML 1.1 policy) and has no pyyaml
# dependency, so read config with the same parser hermes itself uses.
import hermes_yaml as yaml
config_path = _profile_home(profile) / "config.yaml"
try:
with open(config_path, encoding="utf-8") as f:
@@ -247,7 +249,6 @@ class BridgeServer:
def _save_mcp_config(self, cfg, profile=None):
"""Save config.yaml for the given profile using atomic write."""
import yaml
from utils import atomic_yaml_write
config_path = _profile_home(profile) / "config.yaml"
config_path.parent.mkdir(parents=True, exist_ok=True)
@@ -269,9 +270,27 @@ class BridgeServer:
_restore_profile_env(original)
threading.Thread(target=_bg, daemon=True).start()
@staticmethod
def _mcp_registry():
"""Import MCP registry symbols from whichever module owns them.
hermes-agent split ``tools.mcp_tool`` into a module family: discovery and
loop helpers moved to ``tools.mcp_tool_discovery`` / ``tools.mcp_tool_loop``
while the ``_servers`` ledger and ``_lock`` stay at the origin module.
Raises ImportError when no MCP tool module is importable at all.
"""
from tools.mcp_tool import _lock, _servers
try:
from tools.mcp_tool import _run_on_mcp_loop, discover_mcp_tools, register_mcp_servers
except ImportError:
from tools.mcp_tool_discovery import discover_mcp_tools, register_mcp_servers
from tools.mcp_tool_loop import _run_on_mcp_loop
return discover_mcp_tools, register_mcp_servers, _run_on_mcp_loop, _servers, _lock
def _shutdown_all_mcp_servers(self) -> int:
try:
from tools.mcp_tool import _run_on_mcp_loop, _servers, _lock
_, _, _run_on_mcp_loop, _servers, _lock = self._mcp_registry()
except ImportError:
return 0
with _lock:
@@ -281,7 +300,7 @@ class BridgeServer:
def _handle_mcp_action(self, action: str, req: dict[str, Any], profile: str | None = None) -> dict[str, Any]:
"""Handle MCP management actions in worker process."""
try:
from tools.mcp_tool import discover_mcp_tools, register_mcp_servers, _run_on_mcp_loop, _servers, _lock
discover_mcp_tools, register_mcp_servers, _run_on_mcp_loop, _servers, _lock = self._mcp_registry()
except ImportError:
return {"error": "MCP tool module not available", "ok": False}
+12 -4
View File
@@ -97,16 +97,25 @@ def coerce_list(value):
return value if isinstance(value, list) else []
def read_manifest_list(plugin_path, *keys):
def load_yaml_text(text):
# Hermes has no pyyaml dependency: hermes_yaml (ruamel, YAML 1.1) is the
# parser hermes itself uses, so fall back to it when pyyaml is absent.
try:
import yaml
except ImportError:
import hermes_yaml as yaml
return yaml.safe_load(text)
def read_manifest_list(plugin_path, *keys):
try:
plugin_dir = Path(plugin_path)
manifest_file = plugin_dir / "plugin.yaml"
if not manifest_file.exists():
manifest_file = plugin_dir / "plugin.yml"
if not manifest_file.exists():
return []
data = yaml.safe_load(manifest_file.read_text(encoding="utf-8")) or {}
data = load_yaml_text(manifest_file.read_text(encoding="utf-8")) or {}
for key in keys:
value = data.get(key)
if isinstance(value, list):
@@ -128,11 +137,10 @@ def read_config_file(home):
if not home:
return {}
try:
import yaml
path = Path(home) / "config.yaml"
if not path.exists():
return {}
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
return load_yaml_text(path.read_text(encoding="utf-8")) or {}
except Exception as exc:
warnings.append(f"plugin config at {home}: {exc}")
return {}