Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3645ee221c | ||
|
|
8d2e0127b3 | ||
|
|
e71d746820 | ||
|
|
5508f4bc54 | ||
|
|
b2043cf157 | ||
|
|
dca11b6650 | ||
|
|
ee1a744ace |
+61
-30
@@ -38,9 +38,10 @@ session (deferred), the same contract as ``/skills install`` vs ``--now``.
|
||||
|
||||
Activation (config ``agent.coding_context``):
|
||||
|
||||
* ``auto`` (default) — posture (brief + snapshot) on an interactive coding
|
||||
surface sitting in a code workspace (git repo or recognised project root).
|
||||
Prompt-only; toolsets untouched.
|
||||
* ``auto`` (default) — posture (brief + snapshot + names-only demotion of
|
||||
non-coding skill categories) on an interactive coding surface sitting in
|
||||
a code workspace (git repo or recognised project root). Prompt-only;
|
||||
toolsets untouched, no skill is ever hidden.
|
||||
* ``focus`` — like ``auto``, but additionally collapses the toolset to the
|
||||
``coding`` set + enabled MCP servers. Explicit opt-in for a lean schema.
|
||||
* ``on`` — force the posture anywhere (incl. non-workspaces). Prompt-only.
|
||||
@@ -97,10 +98,26 @@ _GIT_TIMEOUT = 2.5
|
||||
|
||||
|
||||
# Per-model edit-format steering. Matching the edit tool format to how a model
|
||||
# was trained reduces mistakes and wasted reasoning (OpenAI/Codex handle
|
||||
# patch-style diffs best; Anthropic models — and most open-weight coding
|
||||
# models, whose RL scaffolds use str_replace-style editors — do best with
|
||||
# string-replacement). Our `patch` tool exposes both: mode="patch" (V4A
|
||||
# was trained reduces mistakes and wasted reasoning. Documented sources,
|
||||
# verified against current first-party agent source (May–Jun 2026):
|
||||
# - GPT/Codex → V4A patch: Codex CLI's ONLY file-edit tool is apply_patch and
|
||||
# its grammar (codex-rs/core/src/tools/handlers/apply_patch.lark) is exactly
|
||||
# the V4A format; the GPT-5.1/5.2(-codex) prompts instruct "Use the
|
||||
# `apply_patch` tool to edit files", and OpenAI gates the tool per model via
|
||||
# ModelInfo.apply_patch_tool_type. No str_replace editor exists in Codex.
|
||||
# (Earlier doc: the GPT-4.1 prompting guide ships apply_patch/V4A —
|
||||
# https://developers.openai.com/cookbook/examples/gpt4-1_prompting_guide)
|
||||
# - Claude → str_replace: Claude Code's FileEditTool is exact string
|
||||
# replacement (old_string/new_string/replace_all, unique-match semantics) —
|
||||
# current Claude models are RL'd against str_replace editing in their own
|
||||
# first-party harness. Also Anthropic's API text-editor tool
|
||||
# (`str_replace_based_edit_tool`) is schema-less: the schema is built into
|
||||
# the model.
|
||||
# https://platform.claude.com/docs/en/agents-and-tools/tool-use/text-editor-tool
|
||||
# - Open-weight coding models → str_replace: the dominant open RL/agentic
|
||||
# scaffolds (SWE-agent, OpenHands ACI) use str_replace-style editors, and
|
||||
# Qwen Code / Gemini CLI ship old_string/new_string `replace` tools.
|
||||
# Our `patch` tool exposes both: mode="patch" (V4A
|
||||
# multi-file) and mode="replace" (find-and-swap). We nudge each family toward
|
||||
# its native format. Unknown families get nothing (the brief's neutral wording
|
||||
# stands). Substrings match the model id; aligned with TOOL_USE_ENFORCEMENT_MODELS.
|
||||
@@ -212,11 +229,13 @@ class ContextProfile:
|
||||
``model_hint`` — routing preference key for smart model routing
|
||||
(extension seam; not yet consumed by the router).
|
||||
``memory_policy``— memory namespace/weighting hint (extension seam).
|
||||
``hidden_skill_categories`` — skill categories pruned from the system-prompt
|
||||
skill index while this posture is active. Discovery-only:
|
||||
nothing is disabled — ``skills_list`` still returns the
|
||||
full catalog and ``skill_view`` loads anything. Deny-list
|
||||
semantics so unknown/custom categories stay visible.
|
||||
``compact_skill_categories`` — skill categories DEMOTED to names-only in
|
||||
the system-prompt skill index while this posture is
|
||||
active. Never hidden: every skill name stays visible
|
||||
(so memory-anchored recall keeps working) — only the
|
||||
descriptions are dropped to cut index noise. Deny-list
|
||||
semantics so unknown/custom categories keep full
|
||||
entries.
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -224,14 +243,14 @@ class ContextProfile:
|
||||
guidance: str = ""
|
||||
model_hint: Optional[str] = None
|
||||
memory_policy: str = "default"
|
||||
hidden_skill_categories: tuple[str, ...] = ()
|
||||
compact_skill_categories: tuple[str, ...] = ()
|
||||
|
||||
|
||||
# Skill categories that are clearly not part of a coding workflow. Hidden from
|
||||
# the prompt's skill index in the coding posture (deny-list — anything not
|
||||
# listed here, incl. custom user categories, stays visible). Coding-adjacent
|
||||
# categories (devops, github, mcp, data-science, diagramming, research,
|
||||
# security, …) are intentionally absent.
|
||||
# Skill categories that are clearly not part of a coding workflow. Demoted to
|
||||
# names-only in the prompt's skill index while the coding posture is active
|
||||
# (deny-list — anything not listed here, incl. custom user categories, keeps
|
||||
# full entries). Coding-adjacent categories (devops, github, mcp,
|
||||
# data-science, diagramming, research, security, …) are intentionally absent.
|
||||
_NON_CODING_SKILL_CATEGORIES = (
|
||||
"apple", "communication", "cooking", "creative", "email", "finance",
|
||||
"gaming", "gifs", "health", "media", "music", "note-taking",
|
||||
@@ -247,7 +266,7 @@ CODING_PROFILE = ContextProfile(
|
||||
guidance=CODING_AGENT_GUIDANCE,
|
||||
model_hint="coding",
|
||||
memory_policy="project",
|
||||
hidden_skill_categories=_NON_CODING_SKILL_CATEGORIES,
|
||||
compact_skill_categories=_NON_CODING_SKILL_CATEGORIES,
|
||||
)
|
||||
|
||||
_PROFILES: dict[str, ContextProfile] = {
|
||||
@@ -432,9 +451,20 @@ class RuntimeMode:
|
||||
blocks.append(workspace)
|
||||
return blocks
|
||||
|
||||
def hidden_skill_categories(self) -> frozenset[str]:
|
||||
"""Skill categories to prune from the prompt's skill index (may be empty)."""
|
||||
return frozenset(self.profile.hidden_skill_categories)
|
||||
def compact_skill_categories(self) -> frozenset[str]:
|
||||
"""Skill categories to demote to names-only in the prompt's skill index.
|
||||
|
||||
Demoted — never hidden. An earlier revision fully pruned these
|
||||
categories from the index, which caused silent capability loss in a
|
||||
real workflow: agent-created skills are the model's accumulated
|
||||
project memory (server-ops runbooks, learned pitfalls, …), and models
|
||||
do not reliably reach for ``skills_list`` to rediscover what the
|
||||
index stopped showing them. Names-only keeps every skill loadable on
|
||||
recall while still cutting the description noise from the index.
|
||||
"""
|
||||
if not self.is_coding:
|
||||
return frozenset()
|
||||
return frozenset(self.profile.compact_skill_categories)
|
||||
|
||||
|
||||
def resolve_runtime_mode(
|
||||
@@ -512,20 +542,21 @@ def coding_system_blocks(
|
||||
).system_blocks()
|
||||
|
||||
|
||||
def coding_hidden_skill_categories(
|
||||
def coding_compact_skill_categories(
|
||||
*,
|
||||
platform: Optional[str] = None,
|
||||
cwd: Optional[str | Path] = None,
|
||||
config: Optional[dict[str, Any]] = None,
|
||||
) -> frozenset[str]:
|
||||
"""Skill categories the active posture prunes from the prompt's skill index.
|
||||
"""Skill categories the active posture demotes to names-only in the index.
|
||||
|
||||
Empty outside the coding posture. Discovery-only: hidden skills remain
|
||||
loadable via ``skills_list`` / ``skill_view``.
|
||||
Empty outside the coding posture. Demoted — never hidden: every skill
|
||||
name stays in the index and remains loadable via ``skill_view`` /
|
||||
``skills_list``; only descriptions are dropped.
|
||||
"""
|
||||
return resolve_runtime_mode(
|
||||
platform=platform, cwd=cwd, config=config
|
||||
).hidden_skill_categories()
|
||||
).compact_skill_categories()
|
||||
|
||||
|
||||
def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
|
||||
@@ -642,9 +673,9 @@ def _project_facts(root: Path) -> list[str]:
|
||||
deduped = list(dict.fromkeys(verify))[:_MAX_VERIFY_COMMANDS]
|
||||
facts.append(f"- Verify: {'; '.join(deduped)}")
|
||||
|
||||
context_files = [c for c in _CONTEXT_FILES if (root / c).is_file()]
|
||||
if context_files:
|
||||
facts.append(f"- Context files: {', '.join(context_files)}")
|
||||
# Note: context files (AGENTS.md / CLAUDE.md / .cursorrules) are NOT listed
|
||||
# here — their full contents are already injected into the system prompt as
|
||||
# the Project Context block, so naming them again is redundant.
|
||||
|
||||
return facts
|
||||
|
||||
|
||||
+33
-27
@@ -1101,7 +1101,7 @@ def _skill_should_show(
|
||||
def build_skills_system_prompt(
|
||||
available_tools: "set[str] | None" = None,
|
||||
available_toolsets: "set[str] | None" = None,
|
||||
hidden_categories: "frozenset[str] | None" = None,
|
||||
compact_categories: "frozenset[str] | None" = None,
|
||||
) -> str:
|
||||
"""Build a compact skill index for the system prompt.
|
||||
|
||||
@@ -1117,11 +1117,11 @@ def build_skills_system_prompt(
|
||||
are read-only — they appear in the index but new skills are always created
|
||||
in the local dir. Local skills take precedence when names collide.
|
||||
|
||||
``hidden_categories`` (e.g. from the coding posture — see
|
||||
agent/coding_context.py) prunes whole categories from the rendered index.
|
||||
Discovery-only: the snapshot stores everything, ``skills_list`` /
|
||||
``skill_view`` still reach every skill, and a footer note tells the model
|
||||
the full catalog exists.
|
||||
``compact_categories`` (e.g. from the coding posture — see
|
||||
agent/coding_context.py) demotes whole categories to a names-only line in
|
||||
the rendered index. Nothing is ever hidden: every skill name stays
|
||||
visible and loadable via ``skill_view`` / ``skills_list``; only the
|
||||
descriptions are dropped, and a footer note explains the demotion.
|
||||
"""
|
||||
skills_dir = get_skills_dir()
|
||||
external_dirs = get_all_skills_dirs()[1:] # skip local (index 0)
|
||||
@@ -1146,7 +1146,7 @@ def build_skills_system_prompt(
|
||||
tuple(sorted(str(ts) for ts in (available_toolsets or set()))),
|
||||
_platform_hint,
|
||||
tuple(sorted(disabled)),
|
||||
tuple(sorted(hidden_categories or ())),
|
||||
tuple(sorted(compact_categories or ())),
|
||||
)
|
||||
with _SKILLS_PROMPT_CACHE_LOCK:
|
||||
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
|
||||
@@ -1280,38 +1280,44 @@ def build_skills_system_prompt(
|
||||
except Exception as e:
|
||||
logger.debug("Could not read external skill description %s: %s", desc_file, e)
|
||||
|
||||
# Posture-driven category pruning (e.g. non-coding skills while pairing on
|
||||
# code). Match on the top-level category segment so nested categories
|
||||
# ("social-media/twitter") are pruned with their parent.
|
||||
# Posture-driven category demotion (e.g. non-coding skills while pairing
|
||||
# on code). Demoted categories stay in the index as a single names-only
|
||||
# line — descriptions are dropped to cut noise, but every skill name
|
||||
# remains visible so memory-anchored recall ("load <name>") keeps working.
|
||||
# NEVER remove entries entirely: agent-created skills are the model's
|
||||
# project memory, and models don't reach for skills_list to rediscover
|
||||
# what the index stops showing them. Match on the top-level category
|
||||
# segment so nested categories ("social-media/twitter") are demoted with
|
||||
# their parent.
|
||||
demoted = frozenset(
|
||||
cat for cat in skills_by_category
|
||||
if cat.split("/", 1)[0] in (compact_categories or frozenset())
|
||||
)
|
||||
|
||||
hidden_note = ""
|
||||
if hidden_categories:
|
||||
before = sum(len(v) for v in skills_by_category.values())
|
||||
skills_by_category = {
|
||||
cat: entries
|
||||
for cat, entries in skills_by_category.items()
|
||||
if cat.split("/", 1)[0] not in hidden_categories
|
||||
}
|
||||
pruned = before - sum(len(v) for v in skills_by_category.values())
|
||||
if pruned:
|
||||
hidden_note = (
|
||||
f"\n(Note: {pruned} skill(s) in categories unrelated to the "
|
||||
"current coding context are not listed here. The full catalog "
|
||||
"is available via skills_list if the user asks for something "
|
||||
"outside this list.)"
|
||||
)
|
||||
if demoted:
|
||||
hidden_note = (
|
||||
"\n(Categories marked [names only] are outside the current coding "
|
||||
"context, so their descriptions are omitted — the skills work "
|
||||
"normally and load with skill_view(name) as usual.)"
|
||||
)
|
||||
|
||||
if not skills_by_category:
|
||||
result = ""
|
||||
else:
|
||||
index_lines = []
|
||||
for category in sorted(skills_by_category.keys()):
|
||||
# Deduplicate and sort skills within each category
|
||||
seen = set()
|
||||
if category in demoted:
|
||||
names = sorted({name for name, _ in skills_by_category[category]})
|
||||
index_lines.append(f" {category} [names only]: {', '.join(names)}")
|
||||
continue
|
||||
cat_desc = category_descriptions.get(category, "")
|
||||
if cat_desc:
|
||||
index_lines.append(f" {category}: {cat_desc}")
|
||||
else:
|
||||
index_lines.append(f" {category}:")
|
||||
# Deduplicate and sort skills within each category
|
||||
seen = set()
|
||||
for name, desc in sorted(skills_by_category[category], key=lambda x: x[0]):
|
||||
if name in seen:
|
||||
continue
|
||||
|
||||
@@ -191,21 +191,22 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
|
||||
)
|
||||
if toolset
|
||||
}
|
||||
# Coding posture prunes non-coding skill categories from the index
|
||||
# (discovery-only — skills_list/skill_view still reach everything).
|
||||
_hidden_cats = frozenset()
|
||||
# Coding posture demotes non-coding skill categories to names-only in
|
||||
# the index (never hidden — skill_view/skills_list reach everything,
|
||||
# and every name stays visible for memory-anchored recall).
|
||||
_compact_cats = frozenset()
|
||||
try:
|
||||
from agent.coding_context import coding_hidden_skill_categories
|
||||
from agent.coding_context import coding_compact_skill_categories
|
||||
|
||||
_hidden_cats = coding_hidden_skill_categories(
|
||||
_compact_cats = coding_compact_skill_categories(
|
||||
platform=agent.platform, cwd=resolve_context_cwd()
|
||||
)
|
||||
except Exception:
|
||||
_hidden_cats = frozenset()
|
||||
_compact_cats = frozenset()
|
||||
skills_prompt = _r.build_skills_system_prompt(
|
||||
available_tools=agent.valid_tool_names,
|
||||
available_toolsets=avail_toolsets,
|
||||
hidden_categories=_hidden_cats or None,
|
||||
compact_categories=_compact_cats or None,
|
||||
)
|
||||
else:
|
||||
skills_prompt = ""
|
||||
|
||||
@@ -249,32 +249,6 @@ function resolveHermesHome() {
|
||||
}
|
||||
|
||||
const HERMES_HOME = resolveHermesHome()
|
||||
|
||||
// Read a profile's gateway_http.json file and return its contents as an object,
|
||||
// or null if the file doesn't exist, is stale (PID not alive), or is corrupted.
|
||||
// This is the JS mirror of gateway/status.py::read_gateway_http_info.
|
||||
function readGatewayHttpInfo(profile) {
|
||||
try {
|
||||
const profileHome = (!profile || profile === 'default')
|
||||
? HERMES_HOME
|
||||
: path.join(HERMES_HOME, 'profiles', profile)
|
||||
const infoPath = path.join(profileHome, 'gateway_http.json')
|
||||
if (!fileExists(infoPath)) return null
|
||||
const data = JSON.parse(fs.readFileSync(infoPath, 'utf8'))
|
||||
if (!data || !data.port || !data.token || !data.base_url) return null
|
||||
// Stale check: is the PID still alive?
|
||||
if (data.pid) {
|
||||
try {
|
||||
process.kill(data.pid, 0) // throws if not alive
|
||||
} catch {
|
||||
return null // stale — gateway crashed without cleanup
|
||||
}
|
||||
}
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
// ACTIVE_HERMES_ROOT — the canonical mutable Hermes install. Same path
|
||||
// install.ps1 / install.sh use, so a desktop-only user and a CLI-only user end
|
||||
// up with identical layouts and can share one install.
|
||||
@@ -1818,15 +1792,14 @@ async function applyUpdatesPosixInApp() {
|
||||
PATH: [extraPath, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
}
|
||||
|
||||
// `hermes update` reaps stale `hermes dashboard` and `hermes gateway run`
|
||||
// backends (a code update leaves the running process serving old Python
|
||||
// against the freshly-updated JS bundle). But OUR backend is one of those
|
||||
// processes, and killing it mid-update produces the boot→kill→crash loop
|
||||
// in #37532 — the desktop already restarts its own backend via the
|
||||
// rebuild+relaunch below, so the reap must spare it. Hand the live
|
||||
// backend's PID to the update process; _kill_stale_dashboard_processes
|
||||
// reads HERMES_DESKTOP_CHILD_PID and excludes it while still reaping
|
||||
// any genuinely-orphaned backends. (#37532)
|
||||
// `hermes update` reaps stale `hermes dashboard` backends (a code update
|
||||
// leaves the running process serving old Python against the freshly-updated
|
||||
// JS bundle). But OUR backend is one of those processes, and killing it
|
||||
// mid-update produces the boot→kill→crash loop in #37532 — the desktop
|
||||
// already restarts its own backend via the rebuild+relaunch below, so the
|
||||
// reap must spare it. Hand the live backend's PID to the update process;
|
||||
// _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes
|
||||
// it while still reaping any genuinely-orphaned dashboards. (#37532)
|
||||
// Exclude every desktop-managed backend (primary + all pool profiles) from
|
||||
// the update reaper. _kill_stale_dashboard_processes accepts a comma-separated
|
||||
// list (a single int still parses for back-compat).
|
||||
@@ -4481,30 +4454,6 @@ async function ensureBackend(profile) {
|
||||
return existing.connectionPromise
|
||||
}
|
||||
|
||||
// Before spawning a new gateway process, check if one is already running
|
||||
// (e.g. the user runs `hermes -p worker gateway run` themselves, or the
|
||||
// desktop left a gateway running from a previous session that survived a
|
||||
// renderer restart).
|
||||
const alreadyRunning = readGatewayHttpInfo(key)
|
||||
if (alreadyRunning) {
|
||||
rememberLog(`Profile "${key}" gateway already running on port ${alreadyRunning.port} — reusing`)
|
||||
const conn = {
|
||||
baseUrl: alreadyRunning.base_url,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token: alreadyRunning.token,
|
||||
profile: key,
|
||||
wsUrl: `${alreadyRunning.ws_url}?token=${encodeURIComponent(alreadyRunning.token)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
const entry = { process: null, port: alreadyRunning.port, token: alreadyRunning.token, connectionPromise: Promise.resolve(conn), lastActiveAt: Date.now() }
|
||||
backendPool.set(key, entry)
|
||||
startPoolIdleReaper()
|
||||
return conn
|
||||
}
|
||||
|
||||
evictLruPoolBackends(POOL_MAX_BACKENDS - 1)
|
||||
|
||||
const entry = { process: null, port: null, token: null, connectionPromise: null, lastActiveAt: Date.now() }
|
||||
@@ -4589,9 +4538,10 @@ async function spawnPoolBackend(profile, entry) {
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
// --profile wins over the inherited HERMES_HOME env (see _apply_profile_override
|
||||
// step 3 in hermes_cli/main.py), so the child re-homes to this profile.
|
||||
const backendArgs = ['--profile', profile, 'gateway', 'run', '--http-port', String(port), '--http-host', '127.0.0.1', '--http-token', token]
|
||||
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
|
||||
const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
@@ -4605,11 +4555,11 @@ async function spawnPoolBackend(profile, entry) {
|
||||
// the child process. Inherited TERMINAL_CWD (or a stale config bridge)
|
||||
// can still point at the install dir even when spawn cwd is home.
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
GATEWAY_HTTP_TOKEN: token,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this gateway backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop.
|
||||
HERMES_DESKTOP: '1'
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
@@ -4774,43 +4724,23 @@ async function startHermes() {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the primary profile's gateway is already running (e.g. started
|
||||
// by the user from the CLI before launching the desktop). Reuse it rather
|
||||
// than spawning a second gateway on a different port.
|
||||
const activeProfile = readActiveDesktopProfile()
|
||||
const primaryAlreadyRunning = readGatewayHttpInfo(activeProfile || null)
|
||||
if (primaryAlreadyRunning) {
|
||||
rememberLog(`Primary gateway already running on port ${primaryAlreadyRunning.port} — reusing`)
|
||||
await advanceBootProgress('backend.wait', 'Connecting to existing Hermes gateway', 90)
|
||||
await waitForHermes(primaryAlreadyRunning.base_url, primaryAlreadyRunning.token)
|
||||
updateBootProgress({ phase: 'backend.ready', message: 'Hermes backend is ready', progress: 94, running: true, error: null })
|
||||
return {
|
||||
baseUrl: primaryAlreadyRunning.base_url,
|
||||
mode: 'local',
|
||||
source: 'local',
|
||||
authMode: 'token',
|
||||
token: primaryAlreadyRunning.token,
|
||||
wsUrl: `${primaryAlreadyRunning.ws_url}?token=${encodeURIComponent(primaryAlreadyRunning.token)}`,
|
||||
logs: hermesLog.slice(-80),
|
||||
...getWindowState()
|
||||
}
|
||||
}
|
||||
|
||||
await advanceBootProgress('backend.port', 'Finding an open local port', 16)
|
||||
const port = await pickPort()
|
||||
const token = crypto.randomBytes(32).toString('base64url')
|
||||
const backendArgs = ['gateway', 'run', '--http-port', String(port), '--http-host', '127.0.0.1', '--http-token', token]
|
||||
const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', String(port)]
|
||||
// Pin the desktop's chosen profile via the global --profile flag. This is
|
||||
// deterministic (it wins over the sticky ~/.hermes/active_profile file) and
|
||||
// resolves HERMES_HOME the same way `hermes -p <name>` does on the CLI. An
|
||||
// unset preference keeps the legacy launch so existing installs are
|
||||
// unaffected.
|
||||
const activeProfile = readActiveDesktopProfile()
|
||||
if (activeProfile) {
|
||||
backendArgs.unshift('--profile', activeProfile)
|
||||
dashboardArgs.unshift('--profile', activeProfile)
|
||||
}
|
||||
await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28)
|
||||
const backend = await ensureRuntime(resolveHermesBackend(backendArgs))
|
||||
const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs))
|
||||
const hermesCwd = resolveHermesCwd()
|
||||
const webDist = resolveWebDist()
|
||||
|
||||
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
|
||||
rememberLog(`Starting Hermes backend via ${backend.label}`)
|
||||
@@ -4823,18 +4753,18 @@ async function startHermes() {
|
||||
// resolves to the SAME location our resolveHermesHome() picked. Without
|
||||
// this pin, Python falls back to ~/.hermes on every platform — fine on
|
||||
// mac/linux (where our default matches), but on Windows our default is
|
||||
// %LOCALAPPDATA%\\hermes, which differs from C:\\Users\\<u>\\.hermes.
|
||||
// %LOCALAPPDATA%\hermes, which differs from C:\Users\<u>\.hermes.
|
||||
// Mismatch would split config / sessions / .env / logs across two
|
||||
// directories. install.ps1 sets HERMES_HOME via setx; the desktop
|
||||
// can't reliably do that, so we set it inline for every spawn.
|
||||
HERMES_HOME,
|
||||
...backend.env,
|
||||
TERMINAL_CWD: hermesCwd,
|
||||
GATEWAY_HTTP_TOKEN: token,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: token,
|
||||
// Marks this gateway backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop.
|
||||
HERMES_DESKTOP: '1'
|
||||
// Marks this dashboard backend as desktop-spawned so it runs the cron
|
||||
// scheduler tick loop (the gateway isn't running under the app).
|
||||
HERMES_DESKTOP: '1',
|
||||
HERMES_WEB_DIST: webDist
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
|
||||
@@ -539,12 +539,6 @@ class GatewayConfig:
|
||||
# Streaming configuration
|
||||
streaming: StreamingConfig = field(default_factory=StreamingConfig)
|
||||
|
||||
# HTTP Management API configuration
|
||||
http_enabled: bool = True
|
||||
http_host: str = "127.0.0.1"
|
||||
http_port: int = 0 # 0 = auto-assign
|
||||
http_token: Optional[str] = None # None = auto-generate
|
||||
|
||||
# Session store pruning: drop SessionEntry records older than this many
|
||||
# days from the in-memory dict and sessions.json. Keeps the store from
|
||||
# growing unbounded in gateways serving many chats/threads/users over
|
||||
@@ -647,11 +641,6 @@ class GatewayConfig:
|
||||
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
|
||||
"streaming": self.streaming.to_dict(),
|
||||
"session_store_max_age_days": self.session_store_max_age_days,
|
||||
# HTTP Management API
|
||||
"http_enabled": self.http_enabled,
|
||||
"http_host": self.http_host,
|
||||
"http_port": self.http_port,
|
||||
"http_token": self.http_token,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -735,11 +724,6 @@ class GatewayConfig:
|
||||
unauthorized_dm_behavior=unauthorized_dm_behavior,
|
||||
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
|
||||
session_store_max_age_days=session_store_max_age_days,
|
||||
# HTTP Management API
|
||||
http_enabled=_coerce_bool(data.get("http_enabled"), True),
|
||||
http_host=data.get("http_host", "127.0.0.1"),
|
||||
http_port=_coerce_optional_positive_int(data.get("http_port"), "http_port") or 0,
|
||||
http_token=data.get("http_token"),
|
||||
)
|
||||
|
||||
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
|
||||
@@ -859,23 +843,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
"pair",
|
||||
)
|
||||
|
||||
# HTTP Management API config
|
||||
http_section = None
|
||||
if "gateway" in yaml_cfg and isinstance(yaml_cfg["gateway"], dict):
|
||||
http_section = yaml_cfg["gateway"].get("http")
|
||||
elif "http" in yaml_cfg:
|
||||
http_section = yaml_cfg.get("http")
|
||||
|
||||
if isinstance(http_section, dict):
|
||||
if "enabled" in http_section:
|
||||
gw_data["http_enabled"] = http_section["enabled"]
|
||||
if "host" in http_section:
|
||||
gw_data["http_host"] = http_section["host"]
|
||||
if "port" in http_section:
|
||||
gw_data["http_port"] = http_section["port"]
|
||||
if "token" in http_section:
|
||||
gw_data["http_token"] = http_section["token"]
|
||||
|
||||
# Merge platform config into gw_data so runtime-only settings under
|
||||
# ``gateway.platforms`` are loaded the same way as top-level
|
||||
# ``platforms``. Merge nested first so top-level config keeps
|
||||
@@ -2111,23 +2078,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
|
||||
for platform_config in config.platforms.values():
|
||||
platform_config.extra.pop("_enabled_explicit", None)
|
||||
|
||||
# HTTP Management API
|
||||
http_enabled = os.getenv("GATEWAY_HTTP_ENABLED")
|
||||
if http_enabled is not None:
|
||||
config.http_enabled = http_enabled.lower() in {"true", "1", "yes"}
|
||||
|
||||
http_host = os.getenv("GATEWAY_HTTP_HOST")
|
||||
if http_host:
|
||||
config.http_host = http_host
|
||||
|
||||
http_port = os.getenv("GATEWAY_HTTP_PORT")
|
||||
if http_port:
|
||||
try:
|
||||
config.http_port = int(http_port)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
http_token = os.getenv("GATEWAY_HTTP_TOKEN") or os.getenv("HERMES_DASHBOARD_SESSION_TOKEN")
|
||||
if http_token:
|
||||
config.http_token = http_token
|
||||
|
||||
-1328
File diff suppressed because it is too large
Load Diff
+2
-67
@@ -15620,15 +15620,7 @@ def _start_cron_ticker(stop_event: threading.Event, adapters=None, loop=None, in
|
||||
logger.info("Cron ticker stopped")
|
||||
|
||||
|
||||
async def start_gateway(
|
||||
config: Optional[GatewayConfig] = None,
|
||||
replace: bool = False,
|
||||
verbosity: Optional[int] = 0,
|
||||
http_port: Optional[int] = None,
|
||||
http_host: Optional[str] = None,
|
||||
http_token: Optional[str] = None,
|
||||
http_enabled: Optional[bool] = None,
|
||||
) -> bool:
|
||||
async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool = False, verbosity: Optional[int] = 0) -> bool:
|
||||
"""
|
||||
Start the gateway and run until interrupted.
|
||||
|
||||
@@ -15641,10 +15633,6 @@ async def start_gateway(
|
||||
replace: If True, kill any existing gateway instance before starting.
|
||||
Useful for systemd services to avoid restart-loop deadlocks
|
||||
when the previous process hasn't fully exited yet.
|
||||
http_port: HTTP management API port (0 = auto-assign, None = use config)
|
||||
http_host: HTTP management API bind host (None = use config)
|
||||
http_token: HTTP management API token (None = auto-generate)
|
||||
http_enabled: If False, disable HTTP management API (None = use config)
|
||||
"""
|
||||
# ── Duplicate-instance guard ──────────────────────────────────────
|
||||
# Prevent two gateways from running under the same HERMES_HOME.
|
||||
@@ -15812,20 +15800,6 @@ async def start_gateway(
|
||||
if _stderr_level < logging.getLogger().level:
|
||||
logging.getLogger().setLevel(_stderr_level)
|
||||
|
||||
# Ensure config exists and apply HTTP management API CLI overrides
|
||||
if config is None:
|
||||
config = load_gateway_config()
|
||||
|
||||
# Apply HTTP management API CLI overrides
|
||||
if http_enabled is not None:
|
||||
config.http_enabled = http_enabled
|
||||
if http_port is not None:
|
||||
config.http_port = http_port
|
||||
if http_host is not None:
|
||||
config.http_host = http_host
|
||||
if http_token is not None:
|
||||
config.http_token = http_token
|
||||
|
||||
runner = GatewayRunner(config)
|
||||
|
||||
# Track whether an unexpected signal initiated the shutdown. When an
|
||||
@@ -16039,35 +16013,6 @@ async def start_gateway(
|
||||
logger.error("Gateway exiting cleanly: %s", runner.exit_reason)
|
||||
return True
|
||||
|
||||
# Start HTTP Management API server
|
||||
http_server = None
|
||||
http_task = None
|
||||
if config.http_enabled:
|
||||
try:
|
||||
import secrets
|
||||
from gateway.http_api import run_http_server as _run_http_server
|
||||
from gateway.status import write_gateway_http_info, remove_gateway_http_info
|
||||
|
||||
# Generate token if not provided
|
||||
http_token = config.http_token or secrets.token_urlsafe(32)
|
||||
config.http_token = http_token
|
||||
|
||||
http_server, actual_port = await _run_http_server(
|
||||
runner=runner,
|
||||
host=config.http_host,
|
||||
port=config.http_port,
|
||||
token=http_token,
|
||||
)
|
||||
config.http_port = actual_port
|
||||
logger.info("HTTP Management API started on %s:%d", config.http_host, actual_port)
|
||||
|
||||
# Publish host/port/token so dashboard, desktop, and other tools
|
||||
# can discover this gateway without needing to spawn it themselves.
|
||||
write_gateway_http_info(config.http_host, actual_port, http_token)
|
||||
atexit.register(remove_gateway_http_info)
|
||||
except Exception as e:
|
||||
logger.error("Failed to start HTTP Management API: %s", e)
|
||||
|
||||
# Start background cron ticker so scheduled jobs fire automatically.
|
||||
# Pass the event loop so cron delivery can use live adapters (E2EE support).
|
||||
cron_stop = threading.Event()
|
||||
@@ -16091,17 +16036,7 @@ async def start_gateway(
|
||||
# Stop cron ticker cleanly
|
||||
cron_stop.set()
|
||||
cron_thread.join(timeout=5)
|
||||
|
||||
# Stop HTTP Management API server
|
||||
if http_server is not None:
|
||||
try:
|
||||
from gateway.status import remove_gateway_http_info
|
||||
remove_gateway_http_info()
|
||||
await http_server.shutdown()
|
||||
logger.info("HTTP Management API stopped")
|
||||
except Exception as e:
|
||||
logger.debug("HTTP server shutdown error: %s", e)
|
||||
|
||||
|
||||
# Stop the planned-stop watcher (daemon=True so this is belt-and-suspenders).
|
||||
_planned_stop_watcher_stop.set()
|
||||
_planned_stop_watcher_thread.join(timeout=2)
|
||||
|
||||
@@ -555,88 +555,6 @@ def read_runtime_status() -> Optional[dict[str, Any]]:
|
||||
return _read_json_file(_get_runtime_status_path())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP management API discovery
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# While the gateway is running it writes its HTTP management API address
|
||||
# (host, port, token) to ``{HERMES_HOME}/gateway_http.json``. Any caller
|
||||
# (dashboard, desktop, CLI tool) that wants to proxy a request to a specific
|
||||
# profile's gateway reads this file to find where to connect.
|
||||
#
|
||||
# The file is profile-scoped because HERMES_HOME is profile-scoped: the
|
||||
# "default" profile writes to ``~/.hermes/gateway_http.json`` and a named
|
||||
# profile "worker" writes to ``~/.hermes/profiles/worker/gateway_http.json``.
|
||||
#
|
||||
# The PID in the file is checked for liveness so stale files from crashed
|
||||
# gateways are treated as "not running".
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GATEWAY_HTTP_FILE = "gateway_http.json"
|
||||
|
||||
|
||||
def _get_gateway_http_path(hermes_home: Optional[Path] = None) -> Path:
|
||||
home = hermes_home if hermes_home is not None else get_hermes_home()
|
||||
return home / _GATEWAY_HTTP_FILE
|
||||
|
||||
|
||||
def write_gateway_http_info(
|
||||
host: str,
|
||||
port: int,
|
||||
token: str,
|
||||
hermes_home: Optional[Path] = None,
|
||||
) -> None:
|
||||
"""Persist this gateway's HTTP management API info so other processes can find it."""
|
||||
path = _get_gateway_http_path(hermes_home)
|
||||
_write_json_file(path, {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"token": token,
|
||||
"pid": os.getpid(),
|
||||
"base_url": f"http://{host}:{port}",
|
||||
"ws_url": f"ws://{host}:{port}/api/ws",
|
||||
})
|
||||
|
||||
|
||||
def remove_gateway_http_info(hermes_home: Optional[Path] = None) -> None:
|
||||
"""Remove this gateway's HTTP management API info on shutdown (best-effort)."""
|
||||
path = _get_gateway_http_path(hermes_home)
|
||||
if not path.exists():
|
||||
return
|
||||
# Only remove if it belongs to this process, to avoid clobbering a
|
||||
# replacement gateway that already wrote its own file.
|
||||
try:
|
||||
data = _read_json_file(path)
|
||||
if data and data.get("pid") == os.getpid():
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def read_gateway_http_info(hermes_home: Optional[Path] = None) -> Optional[dict[str, Any]]:
|
||||
"""Read and validate a gateway's HTTP management API info.
|
||||
|
||||
Returns ``None`` when no gateway is running (file absent, stale PID, or
|
||||
corrupt JSON). Callers should fall back to direct file access when this
|
||||
returns ``None``.
|
||||
"""
|
||||
path = _get_gateway_http_path(hermes_home)
|
||||
data = _read_json_file(path)
|
||||
if not data:
|
||||
return None
|
||||
pid = data.get("pid")
|
||||
if pid and not _pid_exists(int(pid)):
|
||||
# Stale file from a crashed gateway — clean up silently.
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
if not data.get("port") or not data.get("token"):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def remove_pid_file() -> None:
|
||||
"""Remove the gateway PID file, but only if it belongs to this process.
|
||||
|
||||
|
||||
+12
-1
@@ -693,16 +693,27 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||
right_lines.append("")
|
||||
right_lines.append(f"[bold {accent}]MCP Servers[/]")
|
||||
for srv in mcp_status:
|
||||
status = srv.get("status")
|
||||
if srv["connected"]:
|
||||
right_lines.append(
|
||||
f"[dim {dim}]{srv['name']}[/] [{text}]({srv['transport']})[/] "
|
||||
f"[dim {dim}]—[/] [{text}]{srv['tools']} tool(s)[/]"
|
||||
)
|
||||
elif srv.get("disabled"):
|
||||
elif srv.get("disabled") or status == "disabled":
|
||||
right_lines.append(
|
||||
f"[dim {dim}]{srv['name']}[/] [dim]({srv['transport']})[/] "
|
||||
f"[dim {dim}]— disabled[/]"
|
||||
)
|
||||
elif status == "connecting":
|
||||
right_lines.append(
|
||||
f"[dim {dim}]{srv['name']}[/] [dim]({srv['transport']})[/] "
|
||||
f"[yellow]— connecting[/]"
|
||||
)
|
||||
elif status == "configured":
|
||||
right_lines.append(
|
||||
f"[dim {dim}]{srv['name']}[/] [dim]({srv['transport']})[/] "
|
||||
f"[dim {dim}]— configured[/]"
|
||||
)
|
||||
else:
|
||||
right_lines.append(
|
||||
f"[red]{srv['name']}[/] [dim]({srv['transport']})[/] "
|
||||
|
||||
+2
-14
@@ -14,7 +14,6 @@ import sys
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
@@ -3790,7 +3789,7 @@ def _guard_official_docker_root_gateway() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, http_port: Optional[int] = None, http_host: Optional[str] = None, http_token: Optional[str] = None, no_http: bool = False):
|
||||
def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False):
|
||||
"""Run the gateway in foreground.
|
||||
|
||||
Args:
|
||||
@@ -3799,10 +3798,6 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, ht
|
||||
replace: If True, kill any existing gateway instance before starting.
|
||||
This prevents systemd restart loops when the old process
|
||||
hasn't fully exited yet.
|
||||
http_port: HTTP management API port (0 = auto-assign, default: from config)
|
||||
http_host: HTTP management API bind host (default: from config)
|
||||
http_token: HTTP management API token (auto-generated if not set)
|
||||
no_http: If True, disable HTTP management API
|
||||
"""
|
||||
_guard_official_docker_root_gateway()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
@@ -3928,14 +3923,7 @@ def run_gateway(verbose: int = 0, quiet: bool = False, replace: bool = False, ht
|
||||
|
||||
success = False
|
||||
try:
|
||||
success = asyncio.run(start_gateway(
|
||||
replace=replace,
|
||||
verbosity=verbosity,
|
||||
http_port=http_port,
|
||||
http_host=http_host,
|
||||
http_token=http_token,
|
||||
http_enabled=not no_http,
|
||||
))
|
||||
success = asyncio.run(start_gateway(replace=replace, verbosity=verbosity))
|
||||
_exit_diag("asyncio.run.returned", success=success)
|
||||
except KeyboardInterrupt:
|
||||
# On Windows-detached runs this shouldn't fire (we absorb SIGINT above),
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
"""
|
||||
Shared helper for discovering and communicating with a profile's running
|
||||
gateway HTTP management API.
|
||||
|
||||
Usage
|
||||
-----
|
||||
from hermes_cli.gateway_http import get_profile_gateway, call_profile_gateway
|
||||
|
||||
info = get_profile_gateway("worker")
|
||||
if info:
|
||||
# Gateway is running — talk to it
|
||||
result = await call_profile_gateway("worker", "GET", "/api/config")
|
||||
else:
|
||||
# Not running — fall back to direct file access
|
||||
|
||||
The gateway writes ``{HERMES_HOME}/gateway_http.json`` when it starts.
|
||||
This module reads that file for any profile to obtain the host/port/token.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Token header name — matches the dashboard and the gateway's auth middleware.
|
||||
_TOKEN_HEADER = "X-Hermes-Session-Token"
|
||||
|
||||
|
||||
def _get_profile_home(profile: Optional[str]) -> Optional[Path]:
|
||||
"""Resolve a profile name to its HERMES_HOME directory.
|
||||
|
||||
Returns None for the default/current profile (callers use get_hermes_home()
|
||||
directly).
|
||||
"""
|
||||
if not profile or profile.lower() in ("default", "current", ""):
|
||||
return None
|
||||
try:
|
||||
from hermes_cli.profiles import get_profile_dir, profile_exists
|
||||
if not profile_exists(profile):
|
||||
return None
|
||||
return get_profile_dir(profile)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_profile_gateway(profile: Optional[str] = None) -> Optional[dict[str, Any]]:
|
||||
"""Return the running gateway's HTTP info for a profile, or None.
|
||||
|
||||
Returns a dict with ``base_url``, ``ws_url``, and ``token`` when a gateway
|
||||
is running for the given profile — or ``None`` when no gateway is up (file
|
||||
absent, stale PID, or gateway not configured with HTTP).
|
||||
|
||||
Callers must fall back to direct file access when this returns ``None``.
|
||||
|
||||
:param profile: Profile name, or None/'' for the current default profile.
|
||||
"""
|
||||
from gateway.status import read_gateway_http_info
|
||||
|
||||
home = _get_profile_home(profile)
|
||||
return read_gateway_http_info(home)
|
||||
|
||||
|
||||
async def call_profile_gateway(
|
||||
profile: Optional[str],
|
||||
method: str,
|
||||
path: str,
|
||||
**httpx_kwargs: Any,
|
||||
) -> Optional[Any]:
|
||||
"""Call a profile's gateway HTTP API.
|
||||
|
||||
Returns the parsed JSON response, or ``None`` when the gateway isn't
|
||||
running (so callers can fall back to ``_profile_scope``).
|
||||
|
||||
Raises ``httpx.HTTPStatusError`` on HTTP 4xx/5xx.
|
||||
|
||||
:param profile: Profile name, or None for the default profile.
|
||||
:param method: HTTP method (GET, POST, PUT, DELETE, PATCH).
|
||||
:param path: Path including leading slash, e.g. ``"/api/config"``.
|
||||
:param httpx_kwargs: Extra kwargs forwarded to ``httpx.AsyncClient.request``
|
||||
(e.g. ``json=...``, ``params=...``).
|
||||
"""
|
||||
info = get_profile_gateway(profile)
|
||||
if not info:
|
||||
return None
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
logger.debug("httpx not available; cannot proxy to profile gateway")
|
||||
return None
|
||||
|
||||
url = f"{info['base_url']}{path}"
|
||||
headers = {_TOKEN_HEADER: info["token"]}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.request(method, url, headers=headers, **httpx_kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp.json() if resp.content else None
|
||||
except httpx.ConnectError:
|
||||
# Gateway reported as running but TCP refused — stale PID surviving a
|
||||
# crash where atexit didn't fire. Don't raise; caller falls back.
|
||||
logger.debug("Gateway HTTP connect failed for profile %r at %s", profile, url)
|
||||
return None
|
||||
except Exception:
|
||||
raise
|
||||
|
||||
|
||||
def call_profile_gateway_sync(
|
||||
profile: Optional[str],
|
||||
method: str,
|
||||
path: str,
|
||||
**httpx_kwargs: Any,
|
||||
) -> Optional[Any]:
|
||||
"""Synchronous wrapper around ``call_profile_gateway`` for non-async contexts.
|
||||
|
||||
Spins up a throwaway event loop. Prefer the async version when already
|
||||
inside an asyncio context.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# A loop is already running — can't call asyncio.run() from here.
|
||||
# Caller is async and should use call_profile_gateway directly.
|
||||
logger.debug(
|
||||
"call_profile_gateway_sync called from a running loop; use async version"
|
||||
)
|
||||
return None
|
||||
except RuntimeError:
|
||||
pass # no running loop — safe to call asyncio.run()
|
||||
|
||||
return asyncio.run(call_profile_gateway(profile, method, path, **httpx_kwargs))
|
||||
+63
-17
@@ -334,21 +334,66 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
# Falls back to ~/.hermes/active_profile for sticky default.
|
||||
# ---------------------------------------------------------------------------
|
||||
def _apply_profile_override() -> None:
|
||||
"""Pre-parse --profile/-p and set HERMES_HOME before module imports."""
|
||||
"""Pre-parse --profile/-p and set HERMES_HOME before imports."""
|
||||
argv = sys.argv[1:]
|
||||
profile_name = None
|
||||
consume = 0
|
||||
profile_index = None
|
||||
|
||||
# 1. Check for explicit -p / --profile flag
|
||||
for i, arg in enumerate(argv):
|
||||
def _inside_mcp_add_args(index: int) -> bool:
|
||||
"""True once argv reaches `hermes mcp add ... --args <command argv>`.
|
||||
|
||||
``mcp add --args`` is command-argv passthrough. Flags after that point
|
||||
belong to the child MCP command (for example Docker MCP Toolkit's
|
||||
``--profile``), not to Hermes' own profile selector.
|
||||
"""
|
||||
try:
|
||||
mcp_index = argv.index("mcp", 0, index)
|
||||
argv.index("add", mcp_index + 1, index)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
# 1. Check for explicit -p / --profile flag. Historically this worked even
|
||||
# after the subcommand (`hermes chat -p coder`), so keep scanning broadly.
|
||||
# The exception is command-argv passthrough regions such as `mcp add --args`.
|
||||
value_flags = {
|
||||
"-z", "--oneshot",
|
||||
"-m", "--model",
|
||||
"--provider",
|
||||
"-t", "--toolsets",
|
||||
"-r", "--resume",
|
||||
"-s", "--skills",
|
||||
}
|
||||
optional_value_flags = {"-c", "--continue"}
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--":
|
||||
break
|
||||
if arg == "--args" and _inside_mcp_add_args(i):
|
||||
break
|
||||
if arg in {"--profile", "-p"} and i + 1 < len(argv):
|
||||
profile_name = argv[i + 1]
|
||||
consume = 2
|
||||
profile_index = i
|
||||
break
|
||||
elif arg.startswith("--profile="):
|
||||
if arg.startswith("--profile="):
|
||||
profile_name = arg.split("=", 1)[1]
|
||||
consume = 1
|
||||
profile_index = i
|
||||
break
|
||||
if "=" not in arg and arg in value_flags and i + 1 < len(argv):
|
||||
i += 2
|
||||
elif (
|
||||
"=" not in arg
|
||||
and arg in optional_value_flags
|
||||
and i + 1 < len(argv)
|
||||
and not argv[i + 1].startswith("-")
|
||||
):
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
# 1b. Reject values that can't be valid profile names (e.g. pytest's
|
||||
# "-p no:xdist" would be misread as profile "no:xdist" otherwise).
|
||||
@@ -360,6 +405,7 @@ def _apply_profile_override() -> None:
|
||||
if not _re.match(r"^[a-z0-9][a-z0-9_-]{0,63}$", profile_name):
|
||||
profile_name = None
|
||||
consume = 0
|
||||
profile_index = None
|
||||
|
||||
# 1.5 If HERMES_HOME is already set and no explicit flag was given, trust it
|
||||
# only when it already points to a specific profile directory. The
|
||||
@@ -407,16 +453,9 @@ def _apply_profile_override() -> None:
|
||||
return
|
||||
os.environ["HERMES_HOME"] = hermes_home
|
||||
# Strip the flag from argv so argparse doesn't choke
|
||||
if consume > 0:
|
||||
for i, arg in enumerate(argv):
|
||||
if arg in {"--profile", "-p"}:
|
||||
start = i + 1 # +1 because argv is sys.argv[1:]
|
||||
sys.argv = sys.argv[:start] + sys.argv[start + consume :]
|
||||
break
|
||||
elif arg.startswith("--profile="):
|
||||
start = i + 1
|
||||
sys.argv = sys.argv[:start] + sys.argv[start + 1 :]
|
||||
break
|
||||
if consume > 0 and profile_index is not None:
|
||||
start = profile_index + 1 # +1 because argv is sys.argv[1:]
|
||||
sys.argv = sys.argv[:start] + sys.argv[start + consume :]
|
||||
|
||||
|
||||
_apply_profile_override()
|
||||
@@ -1523,6 +1562,8 @@ def _ensure_tui_node() -> None:
|
||||
env={**os.environ, "HERMES_HOME": hermes_home},
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
@@ -1647,6 +1688,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env={**os.environ, "CI": "1"},
|
||||
)
|
||||
if result.returncode != 0:
|
||||
@@ -1671,6 +1714,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
cwd=str(ink_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
@@ -1699,6 +1744,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]:
|
||||
cwd=str(tui_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
combined = f"{result.stdout or ''}{result.stderr or ''}".strip()
|
||||
@@ -2372,6 +2419,8 @@ def cmd_whatsapp(args):
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n ✗ Install cancelled")
|
||||
@@ -5347,9 +5396,6 @@ def _find_stale_dashboard_pids(
|
||||
"hermes dashboard",
|
||||
"hermes_cli.main dashboard",
|
||||
"hermes_cli/main.py dashboard",
|
||||
"hermes gateway run",
|
||||
"hermes_cli.main gateway",
|
||||
"hermes_cli/main.py gateway",
|
||||
]
|
||||
self_pid = os.getpid()
|
||||
dashboard_pids: list[int] = []
|
||||
|
||||
@@ -288,6 +288,8 @@ def cmd_mcp_add(args):
|
||||
# hermes_cli/main.py for why the dest is renamed.
|
||||
command = getattr(args, "mcp_command", None)
|
||||
cmd_args = getattr(args, "args", None) or []
|
||||
if cmd_args and cmd_args[0] == "--":
|
||||
cmd_args = cmd_args[1:]
|
||||
auth_type = getattr(args, "auth", None)
|
||||
preset_name = getattr(args, "preset", None)
|
||||
raw_env = getattr(args, "env", None)
|
||||
|
||||
@@ -58,28 +58,6 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
||||
"gateway's exit code. No effect outside an s6 container."
|
||||
),
|
||||
)
|
||||
# HTTP Management API
|
||||
gateway_run.add_argument(
|
||||
"--http-port",
|
||||
type=int,
|
||||
default=None,
|
||||
help="HTTP management API port (0 = auto-assign, default: 0)",
|
||||
)
|
||||
gateway_run.add_argument(
|
||||
"--http-host",
|
||||
default=None,
|
||||
help="HTTP management API bind host (default: 127.0.0.1)",
|
||||
)
|
||||
gateway_run.add_argument(
|
||||
"--http-token",
|
||||
default=None,
|
||||
help="HTTP management API token (auto-generated if not set)",
|
||||
)
|
||||
gateway_run.add_argument(
|
||||
"--no-http",
|
||||
action="store_true",
|
||||
help="Disable HTTP management API",
|
||||
)
|
||||
add_accept_hooks_flag(gateway_run)
|
||||
add_accept_hooks_flag(gateway_parser)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ Handler injected to avoid importing ``main``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import Callable
|
||||
|
||||
from hermes_cli.subcommands._shared import add_accept_hooks_flag
|
||||
@@ -52,7 +53,10 @@ def build_mcp_parser(subparsers, *, cmd_mcp: Callable) -> None:
|
||||
"--command", dest="mcp_command", help="Stdio command (e.g. npx)"
|
||||
)
|
||||
mcp_add_p.add_argument(
|
||||
"--args", nargs="*", default=[], help="Arguments for stdio command"
|
||||
"--args",
|
||||
nargs=argparse.REMAINDER,
|
||||
default=[],
|
||||
help="Arguments for stdio command; must be the last option",
|
||||
)
|
||||
mcp_add_p.add_argument("--auth", choices=["oauth", "header"], help="Auth method")
|
||||
mcp_add_p.add_argument("--preset", help="Known MCP preset name")
|
||||
|
||||
+22
-123
@@ -2931,10 +2931,8 @@ async def set_model_assignment(body: ModelAssignment, profile: Optional[str] = N
|
||||
"confirm_message": warning.message,
|
||||
}
|
||||
|
||||
effective = body.profile or profile
|
||||
|
||||
def _apply_assignment():
|
||||
with _profile_scope(effective):
|
||||
with _profile_scope(body.profile or profile):
|
||||
return _apply_model_assignment_sync(
|
||||
scope, provider, model, task, base_url
|
||||
)
|
||||
@@ -8712,31 +8710,6 @@ def _profile_scope(profile: Optional[str]):
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
async def _profile_gateway_write(
|
||||
profile: Optional[str],
|
||||
method: str,
|
||||
path: str,
|
||||
**httpx_kwargs,
|
||||
) -> Optional[Any]:
|
||||
"""Try to proxy a write to a profile's running gateway HTTP API.
|
||||
|
||||
Returns the gateway's JSON response when the gateway is running, or
|
||||
``None`` when it isn't (caller falls back to ``_profile_scope``).
|
||||
|
||||
This is the one-line adapter for phase 4c: every write endpoint calls this
|
||||
first, and only enters ``_profile_scope`` on a ``None`` return.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.gateway_http import call_profile_gateway
|
||||
return await call_profile_gateway(profile, method, path, **httpx_kwargs)
|
||||
except Exception:
|
||||
_log.debug(
|
||||
"Gateway write proxy failed for profile=%r %s %s, falling back",
|
||||
profile, method, path, exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class SkillToggle(BaseModel):
|
||||
name: str
|
||||
enabled: bool
|
||||
@@ -8759,15 +8732,7 @@ async def get_skills(profile: Optional[str] = None):
|
||||
@app.put("/api/skills/toggle")
|
||||
async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
|
||||
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
|
||||
effective = body.profile or profile
|
||||
# Try proxying to the profile's running gateway first
|
||||
gw = await _profile_gateway_write(
|
||||
effective, "PUT", "/api/skills/toggle",
|
||||
json={"name": body.name, "enabled": body.enabled},
|
||||
)
|
||||
if gw is not None:
|
||||
return {"ok": True, "name": body.name, "enabled": body.enabled}
|
||||
with _profile_scope(effective):
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
disabled = get_disabled_skills(config)
|
||||
if body.enabled:
|
||||
@@ -8825,16 +8790,15 @@ async def get_skill_content(name: str, profile: Optional[str] = None):
|
||||
|
||||
@app.post("/api/skills")
|
||||
async def create_skill(body: SkillCreate):
|
||||
"""Create a new custom skill (SKILL.md) from the dashboard editor."""
|
||||
"""Create a new custom skill (SKILL.md) from the dashboard editor.
|
||||
|
||||
Calls the same validated write path as the agent's ``skill_manage``
|
||||
tool (frontmatter validation, name/category validation, size limit,
|
||||
optional security scan) — but bypasses the agent write-approval gate:
|
||||
a write from the authenticated dashboard IS the user acting directly.
|
||||
"""
|
||||
from tools.skill_manager_tool import _create_skill
|
||||
|
||||
gw = await _profile_gateway_write(
|
||||
body.profile, "POST", "/api/skills",
|
||||
json={"name": body.name, "content": body.content, "category": body.category},
|
||||
)
|
||||
if gw is not None:
|
||||
_clear_skills_prompt_cache()
|
||||
return gw
|
||||
with _profile_scope(body.profile):
|
||||
result = _create_skill(body.name, body.content, body.category or None)
|
||||
if not result.get("success"):
|
||||
@@ -8848,13 +8812,6 @@ async def update_skill_content(body: SkillContentUpdate):
|
||||
"""Replace the SKILL.md of an existing skill (full rewrite) from the editor."""
|
||||
from tools.skill_manager_tool import _edit_skill
|
||||
|
||||
gw = await _profile_gateway_write(
|
||||
body.profile, "PUT", "/api/skills/content",
|
||||
json={"name": body.name, "content": body.content},
|
||||
)
|
||||
if gw is not None:
|
||||
_clear_skills_prompt_cache()
|
||||
return gw
|
||||
with _profile_scope(body.profile):
|
||||
result = _edit_skill(body.name, body.content)
|
||||
if not result.get("success"):
|
||||
@@ -8925,23 +8882,16 @@ async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str]
|
||||
if name not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
effective = body.profile or profile
|
||||
gw = await _profile_gateway_write(
|
||||
effective, "POST", f"/api/tools/toolsets/{name}/config",
|
||||
params={"enabled": str(body.enabled).lower()},
|
||||
)
|
||||
if gw is not None:
|
||||
return {"ok": True, "name": name, "enabled": body.enabled}
|
||||
with _profile_scope(effective):
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
enabled_set = set(
|
||||
enabled = set(
|
||||
_get_platform_tools(config, "cli", include_default_mcp_servers=False)
|
||||
)
|
||||
if body.enabled:
|
||||
enabled_set.add(name)
|
||||
enabled.add(name)
|
||||
else:
|
||||
enabled_set.discard(name)
|
||||
_save_platform_tools(config, "cli", enabled_set)
|
||||
enabled.discard(name)
|
||||
_save_platform_tools(config, "cli", enabled)
|
||||
return {"ok": True, "name": name, "enabled": body.enabled}
|
||||
|
||||
|
||||
@@ -9034,14 +8984,7 @@ async def select_toolset_provider(
|
||||
if name not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
effective = body.profile or profile
|
||||
gw = await _profile_gateway_write(
|
||||
effective, "POST", f"/api/tools/toolsets/{name}/provider",
|
||||
json={"provider": body.provider},
|
||||
)
|
||||
if gw is not None:
|
||||
return {"ok": True, "name": name, "provider": body.provider}
|
||||
with _profile_scope(effective):
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
try:
|
||||
apply_provider_selection(name, body.provider, config)
|
||||
@@ -9079,16 +9022,7 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[
|
||||
if name not in valid_ts:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
effective = body.profile or profile
|
||||
# Env writes: each key goes to ~/.hermes/.env; proxy to the gateway so it
|
||||
# picks up the new values in its live process environment.
|
||||
gw = await _profile_gateway_write(
|
||||
effective, "PUT", f"/api/tools/toolsets/{name}/env",
|
||||
json={"env": body.env},
|
||||
)
|
||||
if gw is not None:
|
||||
return {"ok": True, "name": name, **gw}
|
||||
with _profile_scope(effective):
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
allowed: set[str] = set()
|
||||
@@ -9200,16 +9134,8 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None
|
||||
parsed = yaml.safe_load(body.yaml_text)
|
||||
if not isinstance(parsed, dict):
|
||||
raise HTTPException(status_code=400, detail="YAML must be a mapping")
|
||||
effective = body.profile or profile
|
||||
# Try gateway first so the live process picks up config changes immediately.
|
||||
# Use PUT /api/config/raw which accepts a full YAML string.
|
||||
gw = await _profile_gateway_write(
|
||||
effective, "PUT", "/api/config/raw",
|
||||
params={"yaml_text": body.yaml_text},
|
||||
)
|
||||
if gw is None:
|
||||
with _profile_scope(effective):
|
||||
save_config(parsed)
|
||||
with _profile_scope(body.profile or profile):
|
||||
save_config(parsed)
|
||||
return {"ok": True}
|
||||
except yaml.YAMLError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
|
||||
@@ -9724,40 +9650,13 @@ def _resolve_chat_argv(
|
||||
if sidecar_url:
|
||||
env["HERMES_TUI_SIDECAR_URL"] = sidecar_url
|
||||
|
||||
# Profile-scoped chats: prefer attaching to the profile's own running
|
||||
# gateway (which already has the right HERMES_HOME, config, skills, etc.)
|
||||
# over the old approach of spawning a fresh tui_gateway.entry subprocess
|
||||
# with HERMES_HOME env-injected.
|
||||
#
|
||||
# When no gateway is running for that profile we fall back to the previous
|
||||
# behaviour: no HERMES_TUI_GATEWAY_URL, so tui_gateway.entry spawns its
|
||||
# own instance inheriting the HERMES_HOME we set above.
|
||||
# Profile-scoped chats must NOT attach to the dashboard's in-memory
|
||||
# gateway — it runs under the dashboard's own profile. Without the
|
||||
# attach URL, gatewayClient spawns its own `tui_gateway.entry`, which
|
||||
# inherits the profile HERMES_HOME set above.
|
||||
if profile_dir is None:
|
||||
# Default/current profile: attach to this dashboard's in-memory gateway.
|
||||
if gateway_ws_url := _build_gateway_ws_url():
|
||||
env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url
|
||||
else:
|
||||
# Named profile: try to attach to that profile's running gateway.
|
||||
try:
|
||||
from hermes_cli.gateway_http import get_profile_gateway
|
||||
gw = get_profile_gateway(requested)
|
||||
if gw:
|
||||
# Gateway is running for this profile — attach directly.
|
||||
# Use ?token= on the ws url so it works with our auth middleware.
|
||||
import urllib.parse as _up
|
||||
ws_url = gw["ws_url"]
|
||||
token = gw["token"]
|
||||
ws_url_with_token = (
|
||||
ws_url + ("&" if "?" in ws_url else "?") +
|
||||
_up.urlencode({"token": token})
|
||||
)
|
||||
env["HERMES_TUI_GATEWAY_URL"] = ws_url_with_token
|
||||
# Gateway process owns HERMES_HOME — no need to override it.
|
||||
env.pop("HERMES_HOME", None)
|
||||
except Exception:
|
||||
_log.debug("Failed to look up gateway for profile %r", requested, exc_info=True)
|
||||
# Fall back: keep HERMES_HOME set, no HERMES_TUI_GATEWAY_URL,
|
||||
# tui_gateway.entry will spawn its own instance.
|
||||
|
||||
return list(argv), str(cwd) if cwd else None, env
|
||||
|
||||
|
||||
+5
-2
@@ -703,7 +703,7 @@ check_git() {
|
||||
}
|
||||
|
||||
# The desktop build runs Vite ^8, which refuses to start on Node outside
|
||||
# `>=26.0.0` — older Node lacks the required features, so `vite build`
|
||||
# `^20.19 || >=22.12` — older Node lacks `node:util.styleText`, so `vite build`
|
||||
# crashes with a SyntaxError that surfaces only as the opaque "Build desktop
|
||||
# app … exit code 1" install failure. Returns 0 when the given `node --version`
|
||||
# string clears that floor; anything below it is replaced with the Hermes-
|
||||
@@ -711,8 +711,11 @@ check_git() {
|
||||
node_satisfies_build() {
|
||||
local ver="${1#v}"
|
||||
local major="${ver%%.*}"
|
||||
local minor="${ver#*.}"; minor="${minor%%.*}"
|
||||
case "$major" in ''|*[!0-9]*) return 1 ;; esac
|
||||
if [ "$major" -ge 26 ]; then return 0; fi
|
||||
case "$minor" in ''|*[!0-9]*) minor=0 ;; esac
|
||||
if [ "$major" -eq 20 ] && [ "$minor" -ge 19 ]; then return 0; fi
|
||||
if [ "$major" -ge 22 ] && { [ "$major" -gt 22 ] || [ "$minor" -ge 12 ]; }; then return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -152,11 +152,14 @@ class TestProjectFacts:
|
||||
assert "make test" in block
|
||||
assert "make deploy" not in block
|
||||
|
||||
def test_context_files_listed(self, tmp_path):
|
||||
def test_context_files_not_listed(self, tmp_path):
|
||||
# Context files (AGENTS.md etc.) are injected into the system prompt
|
||||
# in full as the Project Context block — naming them in the snapshot
|
||||
# would be redundant.
|
||||
_git_init(tmp_path)
|
||||
(tmp_path / "AGENTS.md").write_text("# rules")
|
||||
block = cc.build_coding_workspace_block(tmp_path)
|
||||
assert "Context files: AGENTS.md" in block
|
||||
assert "Context files:" not in block
|
||||
|
||||
def test_marker_only_project_gets_snapshot_without_git(self, tmp_path):
|
||||
# A non-git project (manifest only) still gets a workspace snapshot —
|
||||
@@ -368,20 +371,24 @@ class TestProfiles:
|
||||
assert cc.GENERAL_PROFILE.toolset is None
|
||||
assert cc.GENERAL_PROFILE.guidance == ""
|
||||
|
||||
def test_skill_pruning_scoped_to_coding_posture(self, tmp_path):
|
||||
# Coding posture hides clearly-non-coding categories; coding-adjacent
|
||||
# ones stay visible (deny-list semantics).
|
||||
def test_skill_demotion_scoped_to_coding_posture(self, tmp_path):
|
||||
# Coding posture demotes clearly-non-coding categories to names-only
|
||||
# in the index (never hides them — agent-created skills are the
|
||||
# model's project memory and must stay recallable by name).
|
||||
# Coding-adjacent categories keep full entries (deny-list semantics).
|
||||
_git_init(tmp_path)
|
||||
coding = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={})
|
||||
hidden = coding.hidden_skill_categories()
|
||||
assert "social-media" in hidden and "smart-home" in hidden
|
||||
for kept in ("github", "devops", "software-development", "data-science"):
|
||||
assert kept not in hidden
|
||||
# General posture hides nothing.
|
||||
general = cc.resolve_runtime_mode(
|
||||
platform="telegram", cwd=tmp_path, config={}
|
||||
)
|
||||
assert general.hidden_skill_categories() == frozenset()
|
||||
for raw in ("auto", "on", "focus"):
|
||||
mode = cc.resolve_runtime_mode(
|
||||
platform="cli", cwd=tmp_path, config={"agent": {"coding_context": raw}}
|
||||
)
|
||||
assert mode.is_coding is True
|
||||
compact = mode.compact_skill_categories()
|
||||
assert "social-media" in compact and "smart-home" in compact
|
||||
for kept in ("github", "devops", "software-development", "data-science"):
|
||||
assert kept not in compact
|
||||
# General posture demotes nothing.
|
||||
general = cc.resolve_runtime_mode(platform="telegram", cwd=tmp_path, config={})
|
||||
assert general.compact_skill_categories() == frozenset()
|
||||
|
||||
|
||||
# ── detection signals ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -276,8 +276,14 @@ class TestBuildSkillsSystemPrompt:
|
||||
# "search" should appear only once per category
|
||||
assert result.count("- search") == 1
|
||||
|
||||
def test_hidden_categories_pruned_with_note(self, monkeypatch, tmp_path):
|
||||
"""Posture-driven pruning drops whole categories and discloses it."""
|
||||
def test_compact_categories_demoted_to_names_only(self, monkeypatch, tmp_path):
|
||||
"""Posture-driven demotion keeps every skill NAME visible.
|
||||
|
||||
Demoted categories lose their descriptions, never their entries —
|
||||
full pruning caused silent capability loss in a real workflow
|
||||
(agent-created skills are the model's project memory, and models
|
||||
don't rediscover them via skills_list once the index goes quiet).
|
||||
"""
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
for cat, name in (("social-media", "tweet-stuff"), ("github", "pr-review")):
|
||||
d = tmp_path / "skills" / cat / name
|
||||
@@ -287,14 +293,18 @@ class TestBuildSkillsSystemPrompt:
|
||||
)
|
||||
|
||||
result = build_skills_system_prompt(
|
||||
hidden_categories=frozenset({"social-media"})
|
||||
compact_categories=frozenset({"social-media"})
|
||||
)
|
||||
assert "pr-review" in result
|
||||
assert "tweet-stuff" not in result
|
||||
# Disclosure note so the model knows the full catalog exists.
|
||||
assert "skills_list" in result
|
||||
# Coding-adjacent category keeps its full entry.
|
||||
assert "pr-review" in result and "Does pr-review things" in result
|
||||
# Demoted category: name stays visible, description is dropped.
|
||||
assert "tweet-stuff" in result
|
||||
assert "Does tweet-stuff things" not in result
|
||||
assert "social-media [names only]" in result
|
||||
# Disclosure note explains the demotion and how to load.
|
||||
assert "skill_view" in result
|
||||
|
||||
def test_hidden_categories_prune_nested_and_miss_cache_separately(
|
||||
def test_compact_categories_demote_nested_and_miss_cache_separately(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
@@ -303,14 +313,16 @@ class TestBuildSkillsSystemPrompt:
|
||||
(d / "SKILL.md").write_text(
|
||||
"---\nname: thread-writer\ndescription: Write threads\n---\n"
|
||||
)
|
||||
# Nested category ("social-media/twitter") pruned via its parent.
|
||||
pruned = build_skills_system_prompt(
|
||||
hidden_categories=frozenset({"social-media"})
|
||||
# Nested category ("social-media/twitter") demoted via its parent:
|
||||
# name visible, description gone.
|
||||
compact = build_skills_system_prompt(
|
||||
compact_categories=frozenset({"social-media"})
|
||||
)
|
||||
assert "thread-writer" not in pruned
|
||||
# Unfiltered call must not be served from the filtered cache entry.
|
||||
assert "thread-writer" in compact
|
||||
assert "Write threads" not in compact
|
||||
# Unfiltered call must not be served from the compacted cache entry.
|
||||
full = build_skills_system_prompt()
|
||||
assert "thread-writer" in full
|
||||
assert "Write threads" in full
|
||||
|
||||
def test_excludes_incompatible_platform_skills(self, monkeypatch, tmp_path):
|
||||
"""Skills with platforms: [macos] should not appear on Linux."""
|
||||
|
||||
@@ -138,3 +138,80 @@ class TestApplyProfileOverrideHermesHomeGuard:
|
||||
_apply_profile_override()
|
||||
|
||||
assert os.environ.get("HERMES_HOME") is None
|
||||
|
||||
def test_subcommand_profile_flag_is_not_consumed(self, tmp_path, monkeypatch):
|
||||
"""Command argv flags named --profile must stay with that command.
|
||||
|
||||
Docker Desktop's MCP Toolkit uses `docker mcp gateway run --profile ...`.
|
||||
When that argv is passed through `hermes mcp add --args`, the early
|
||||
profile pre-parser must not interpret the Docker profile as a Hermes
|
||||
profile.
|
||||
"""
|
||||
hermes_root = tmp_path / ".hermes"
|
||||
hermes_root.mkdir(parents=True, exist_ok=True)
|
||||
argv = [
|
||||
"hermes",
|
||||
"mcp",
|
||||
"add",
|
||||
"docker-research",
|
||||
"--command",
|
||||
"docker",
|
||||
"--args",
|
||||
"mcp",
|
||||
"gateway",
|
||||
"run",
|
||||
"--profile",
|
||||
"research",
|
||||
]
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
monkeypatch.setattr(sys, "argv", list(argv))
|
||||
|
||||
from hermes_cli.main import _apply_profile_override
|
||||
_apply_profile_override()
|
||||
|
||||
assert os.environ.get("HERMES_HOME") is None
|
||||
assert sys.argv == argv
|
||||
|
||||
def test_profile_after_chat_subcommand_is_still_consumed(self, tmp_path, monkeypatch):
|
||||
"""Profile flags historically work after normal Hermes subcommands."""
|
||||
result = _run_apply_profile_override(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
hermes_home=None,
|
||||
active_profile="coder",
|
||||
argv=["hermes", "chat", "-p", "coder", "-q", "hello"],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.endswith("coder")
|
||||
assert sys.argv == ["hermes", "chat", "-q", "hello"]
|
||||
|
||||
def test_top_level_profile_after_value_flag_is_consumed(self, tmp_path, monkeypatch):
|
||||
"""Top-level --profile still works after other top-level value flags."""
|
||||
result = _run_apply_profile_override(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
hermes_home=None,
|
||||
active_profile="coder",
|
||||
argv=["hermes", "-m", "gpt-5", "--profile", "coder", "chat"],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.endswith("coder")
|
||||
assert sys.argv == ["hermes", "-m", "gpt-5", "chat"]
|
||||
|
||||
def test_top_level_profile_after_continue_flag_is_consumed(self, tmp_path, monkeypatch):
|
||||
"""--continue has an optional value, so a following --profile is a flag."""
|
||||
result = _run_apply_profile_override(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
hermes_home=None,
|
||||
active_profile="coder",
|
||||
argv=["hermes", "--continue", "--profile", "coder"],
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.endswith("coder")
|
||||
assert sys.argv == ["hermes", "--continue"]
|
||||
|
||||
@@ -167,3 +167,36 @@ def test_build_welcome_banner_disabled_mcp_shows_disabled_not_failed():
|
||||
assert "broken" in output
|
||||
assert "failed" in output
|
||||
|
||||
|
||||
def test_build_welcome_banner_configured_mcp_is_not_failed():
|
||||
"""A configured MCP server with no connection attempt yet is not a failure."""
|
||||
with (
|
||||
patch.object(model_tools, "check_tool_availability", return_value=(["web"], [])),
|
||||
patch.object(banner, "get_available_skills", return_value={}),
|
||||
patch.object(banner, "get_update_result", return_value=None),
|
||||
patch.object(
|
||||
tools.mcp_tool,
|
||||
"get_mcp_status",
|
||||
return_value=[
|
||||
{
|
||||
"name": "docker-profile",
|
||||
"transport": "stdio",
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "configured",
|
||||
},
|
||||
],
|
||||
),
|
||||
):
|
||||
console = Console(record=True, force_terminal=False, color_system=None, width=160)
|
||||
banner.build_welcome_banner(
|
||||
console=console, model="anthropic/test-model", cwd="/tmp/project",
|
||||
tools=[{"function": {"name": "read_file"}}],
|
||||
get_toolset_for_tool=lambda n: "file",
|
||||
)
|
||||
|
||||
output = console.export_text()
|
||||
assert "docker-profile" in output
|
||||
assert "configured" in output
|
||||
assert "failed" not in output
|
||||
|
||||
@@ -41,6 +41,7 @@ def _build_parser():
|
||||
mcp_add.add_argument("name")
|
||||
mcp_add.add_argument("--url")
|
||||
mcp_add.add_argument("--command", dest="mcp_command")
|
||||
mcp_add.add_argument("--args", nargs=argparse.REMAINDER, default=[])
|
||||
|
||||
return parser
|
||||
|
||||
@@ -85,3 +86,26 @@ class TestMcpAddCommandDest:
|
||||
assert args.command == "mcp"
|
||||
assert args.mcp_command is None
|
||||
assert args.url is None
|
||||
|
||||
def test_args_passthrough_keeps_nested_option_flags(self):
|
||||
"""`--args` must keep command flags like Docker MCP's --profile."""
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(
|
||||
[
|
||||
"mcp",
|
||||
"add",
|
||||
"docker-research",
|
||||
"--command",
|
||||
"docker",
|
||||
"--args",
|
||||
"mcp",
|
||||
"gateway",
|
||||
"run",
|
||||
"--profile",
|
||||
"research",
|
||||
]
|
||||
)
|
||||
|
||||
assert args.command == "mcp"
|
||||
assert args.mcp_command == "docker"
|
||||
assert args.args == ["mcp", "gateway", "run", "--profile", "research"]
|
||||
|
||||
@@ -26,6 +26,12 @@ def _touch_tui_entry(root: Path) -> None:
|
||||
entry.write_text("console.log('tui')")
|
||||
|
||||
|
||||
def _assert_utf8_replace_capture(kwargs: dict) -> None:
|
||||
assert kwargs["text"] is True
|
||||
assert kwargs["encoding"] == "utf-8"
|
||||
assert kwargs["errors"] == "replace"
|
||||
|
||||
|
||||
def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None:
|
||||
(tmp_path / "package-lock.json").write_text("{}")
|
||||
assert main_mod._tui_need_npm_install(tmp_path) is True
|
||||
@@ -228,6 +234,8 @@ def test_make_tui_argv_scopes_npm_install_on_termux_workspace(
|
||||
"--include-workspace-root=false",
|
||||
]
|
||||
assert calls[0][1]["cwd"] == str(tmp_path)
|
||||
_assert_utf8_replace_capture(calls[0][1])
|
||||
_assert_utf8_replace_capture(calls[1][1])
|
||||
|
||||
|
||||
def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
|
||||
@@ -263,6 +271,8 @@ def test_make_tui_argv_keeps_desktop_workspace_install_behaviour(
|
||||
"--progress=false",
|
||||
]
|
||||
assert calls[0][1]["cwd"] == str(tmp_path)
|
||||
_assert_utf8_replace_capture(calls[0][1])
|
||||
_assert_utf8_replace_capture(calls[1][1])
|
||||
|
||||
|
||||
def test_make_tui_argv_keeps_desktop_always_build_behaviour(
|
||||
@@ -286,6 +296,35 @@ def test_make_tui_argv_keeps_desktop_always_build_behaviour(
|
||||
|
||||
assert calls
|
||||
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
|
||||
_assert_utf8_replace_capture(calls[0][1])
|
||||
|
||||
|
||||
def test_make_tui_argv_decodes_dev_prebuild_with_utf8_replace(
|
||||
tmp_path: Path, main_mod, monkeypatch
|
||||
) -> None:
|
||||
ink_dir = tmp_path / "packages" / "hermes-ink"
|
||||
ink_dir.mkdir(parents=True)
|
||||
tsx = tmp_path / "node_modules" / ".bin" / "tsx"
|
||||
tsx.parent.mkdir(parents=True)
|
||||
tsx.write_text("")
|
||||
|
||||
monkeypatch.setattr(main_mod, "_tui_need_npm_install", lambda _root: False)
|
||||
monkeypatch.setattr(main_mod.shutil, "which", lambda name: f"/bin/{name}")
|
||||
calls = []
|
||||
|
||||
def fake_run(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(main_mod.subprocess, "run", fake_run)
|
||||
|
||||
argv, cwd = main_mod._make_tui_argv(tmp_path, tui_dev=True)
|
||||
|
||||
assert argv == [str(tsx), "src/entry.tsx"]
|
||||
assert cwd == tmp_path
|
||||
assert calls[0][0][0] == ["/bin/npm", "run", "build"]
|
||||
assert calls[0][1]["cwd"] == str(ink_dir)
|
||||
_assert_utf8_replace_capture(calls[0][1])
|
||||
|
||||
|
||||
# ── _workspace_root helper ──────────────────────────────────────────
|
||||
|
||||
@@ -82,6 +82,56 @@ class TestLoadMCPConfig:
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestMCPStatus:
|
||||
def test_status_distinguishes_configured_connecting_failed_and_disabled(
|
||||
self, monkeypatch
|
||||
):
|
||||
import tools.mcp_tool as mcp_tool
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_tool,
|
||||
"_load_mcp_config",
|
||||
lambda: {
|
||||
"configured": {"command": "docker", "args": ["mcp", "gateway", "run"]},
|
||||
"connecting": {"command": "slow-mcp"},
|
||||
"failed": {"command": "bad-mcp"},
|
||||
"disabled": {"command": "off-mcp", "enabled": False},
|
||||
},
|
||||
)
|
||||
with mcp_tool._lock:
|
||||
saved_servers = dict(mcp_tool._servers)
|
||||
saved_connecting = set(mcp_tool._server_connecting)
|
||||
saved_errors = dict(mcp_tool._server_connect_errors)
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._server_connecting.clear()
|
||||
mcp_tool._server_connect_errors.clear()
|
||||
mcp_tool._server_connecting.add("connecting")
|
||||
mcp_tool._server_connect_errors["failed"] = "Connection closed"
|
||||
|
||||
try:
|
||||
statuses = {
|
||||
entry["name"]: entry
|
||||
for entry in mcp_tool.get_mcp_status()
|
||||
}
|
||||
finally:
|
||||
with mcp_tool._lock:
|
||||
mcp_tool._servers.clear()
|
||||
mcp_tool._servers.update(saved_servers)
|
||||
mcp_tool._server_connecting.clear()
|
||||
mcp_tool._server_connecting.update(saved_connecting)
|
||||
mcp_tool._server_connect_errors.clear()
|
||||
mcp_tool._server_connect_errors.update(saved_errors)
|
||||
|
||||
assert statuses["configured"]["status"] == "configured"
|
||||
assert statuses["configured"]["connected"] is False
|
||||
assert statuses["configured"]["disabled"] is False
|
||||
assert statuses["connecting"]["status"] == "connecting"
|
||||
assert statuses["failed"]["status"] == "failed"
|
||||
assert statuses["failed"]["error"] == "Connection closed"
|
||||
assert statuses["disabled"]["status"] == "disabled"
|
||||
assert statuses["disabled"]["disabled"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1378,6 +1428,33 @@ class TestBuildSafeEnv:
|
||||
assert "DATABASE_URL" not in result
|
||||
assert "API_SECRET" not in result
|
||||
|
||||
def test_windows_location_vars_passed_without_secrets(self):
|
||||
"""Windows launcher tools need location vars, but secrets stay filtered."""
|
||||
from tools.mcp_tool import _build_safe_env
|
||||
|
||||
fake_env = {
|
||||
"PATH": r"C:\Windows\System32",
|
||||
"ProgramFiles": r"C:\Program Files",
|
||||
"ProgramData": r"C:\ProgramData",
|
||||
"ProgramW6432": r"C:\Program Files",
|
||||
"LOCALAPPDATA": r"C:\Users\alice\AppData\Local",
|
||||
"APPDATA": r"C:\Users\alice\AppData\Roaming",
|
||||
"USERPROFILE": r"C:\Users\alice",
|
||||
"GITHUB_TOKEN": "ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"OPENAI_API_KEY": "sk-proj-abc123",
|
||||
}
|
||||
with patch.dict("os.environ", fake_env, clear=True):
|
||||
result = _build_safe_env(None)
|
||||
|
||||
assert result["ProgramFiles"] == r"C:\Program Files"
|
||||
assert result["ProgramData"] == r"C:\ProgramData"
|
||||
assert result["ProgramW6432"] == r"C:\Program Files"
|
||||
assert result["LOCALAPPDATA"].endswith("Local")
|
||||
assert result["APPDATA"].endswith("Roaming")
|
||||
assert result["USERPROFILE"] == r"C:\Users\alice"
|
||||
assert "GITHUB_TOKEN" not in result
|
||||
assert "OPENAI_API_KEY" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _sanitize_error
|
||||
|
||||
+93
-8
@@ -268,6 +268,38 @@ _SAFE_ENV_KEYS = frozenset({
|
||||
"PATH", "HOME", "USER", "LANG", "LC_ALL", "TERM", "SHELL", "TMPDIR",
|
||||
})
|
||||
|
||||
_SAFE_ENV_KEYS_CASE_INSENSITIVE = frozenset({
|
||||
# Windows process/location vars. These are needed by launcher-style tools
|
||||
# such as Docker Desktop's MCP plugin discovery, and do not carry secrets.
|
||||
"ALLUSERSPROFILE",
|
||||
"APPDATA",
|
||||
"COMMONPROGRAMFILES",
|
||||
"COMMONPROGRAMFILES(X86)",
|
||||
"COMMONPROGRAMW6432",
|
||||
"COMPUTERNAME",
|
||||
"COMSPEC",
|
||||
"HOMEDRIVE",
|
||||
"HOMEPATH",
|
||||
"LOCALAPPDATA",
|
||||
"NUMBER_OF_PROCESSORS",
|
||||
"OS",
|
||||
"PATHEXT",
|
||||
"PROCESSOR_ARCHITECTURE",
|
||||
"PROGRAMDATA",
|
||||
"PROGRAMFILES",
|
||||
"PROGRAMFILES(X86)",
|
||||
"PROGRAMW6432",
|
||||
"PUBLIC",
|
||||
"SYSTEMDRIVE",
|
||||
"SYSTEMROOT",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"USERDOMAIN",
|
||||
"USERNAME",
|
||||
"USERPROFILE",
|
||||
"WINDIR",
|
||||
})
|
||||
|
||||
# Regex for credential patterns to strip from error messages
|
||||
_CREDENTIAL_PATTERN = re.compile(
|
||||
r"(?:"
|
||||
@@ -305,7 +337,11 @@ def _build_safe_env(user_env: Optional[dict]) -> dict:
|
||||
"""
|
||||
env = {}
|
||||
for key, value in os.environ.items():
|
||||
if key in _SAFE_ENV_KEYS or key.startswith("XDG_"):
|
||||
if (
|
||||
key in _SAFE_ENV_KEYS
|
||||
or key.upper() in _SAFE_ENV_KEYS_CASE_INSENSITIVE
|
||||
or key.startswith("XDG_")
|
||||
):
|
||||
env[key] = value
|
||||
if user_env:
|
||||
env.update(user_env)
|
||||
@@ -1986,6 +2022,8 @@ class MCPServerTask:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_servers: Dict[str, MCPServerTask] = {}
|
||||
_server_connecting: set[str] = set()
|
||||
_server_connect_errors: Dict[str, str] = {}
|
||||
|
||||
# Circuit breaker: consecutive error counts per server. After
|
||||
# _CIRCUIT_BREAKER_THRESHOLD consecutive failures, the handler returns
|
||||
@@ -2372,8 +2410,8 @@ _mcp_tool_server_names: Dict[str, str] = {}
|
||||
_mcp_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_mcp_thread: Optional[threading.Thread] = None
|
||||
|
||||
# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers,
|
||||
# _mcp_tool_server_names, and _stdio_pids.
|
||||
# Protects _mcp_loop, _mcp_thread, _servers, MCP connection status maps,
|
||||
# _parallel_safe_servers, _mcp_tool_server_names, and _stdio_pids.
|
||||
_lock = threading.Lock()
|
||||
|
||||
# PIDs of stdio MCP server subprocesses. Tracked so we can force-kill
|
||||
@@ -3517,6 +3555,8 @@ async def _discover_and_register_server(name: str, config: dict) -> List[str]:
|
||||
timeout=connect_timeout,
|
||||
)
|
||||
with _lock:
|
||||
_server_connecting.discard(name)
|
||||
_server_connect_errors.pop(name, None)
|
||||
_servers[name] = server
|
||||
|
||||
registered_names = _register_server_tools(name, server, config)
|
||||
@@ -3563,6 +3603,9 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
||||
for k, v in servers.items()
|
||||
if k not in _servers and _parse_boolish(v.get("enabled", True), default=True)
|
||||
}
|
||||
_server_connecting.update(new_servers)
|
||||
for srv_name in new_servers:
|
||||
_server_connect_errors.pop(srv_name, None)
|
||||
# Track which servers opt-in to parallel tool calls (idempotent).
|
||||
for srv_name, srv_cfg in servers.items():
|
||||
if _parse_boolish(srv_cfg.get("supports_parallel_tool_calls", False), default=False):
|
||||
@@ -3590,12 +3633,20 @@ def register_mcp_servers(servers: Dict[str, dict]) -> List[str]:
|
||||
for name, result in zip(server_names, results):
|
||||
if isinstance(result, BaseException):
|
||||
command = new_servers.get(name, {}).get("command")
|
||||
message = _format_connect_error(result)
|
||||
with _lock:
|
||||
_server_connecting.discard(name)
|
||||
_server_connect_errors[name] = message
|
||||
logger.warning(
|
||||
"Failed to connect to MCP server '%s'%s: %s",
|
||||
name,
|
||||
f" (command={command})" if command else "",
|
||||
_format_connect_error(result),
|
||||
message,
|
||||
)
|
||||
else:
|
||||
with _lock:
|
||||
_server_connecting.discard(name)
|
||||
_server_connect_errors.pop(name, None)
|
||||
|
||||
# Per-server timeouts are handled inside _discover_and_register_server.
|
||||
# The outer timeout is generous: 120s total for parallel discovery.
|
||||
@@ -3700,8 +3751,10 @@ def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
|
||||
def get_mcp_status() -> List[dict]:
|
||||
"""Return status of all configured MCP servers for banner display.
|
||||
|
||||
Returns a list of dicts with keys: name, transport, tools, connected.
|
||||
Includes both successfully connected servers and configured-but-failed ones.
|
||||
Returns a list of dicts with keys: name, transport, tools, connected,
|
||||
disabled, and status. Includes connected servers, disabled servers,
|
||||
in-flight connection attempts, recorded failures, and servers that are
|
||||
configured but have not been started in this process yet.
|
||||
"""
|
||||
result: List[dict] = []
|
||||
|
||||
@@ -3712,6 +3765,8 @@ def get_mcp_status() -> List[dict]:
|
||||
|
||||
with _lock:
|
||||
active_servers = dict(_servers)
|
||||
connecting = set(_server_connecting)
|
||||
connect_errors = dict(_server_connect_errors)
|
||||
|
||||
for name, cfg in configured.items():
|
||||
transport = cfg.get("transport", "http") if "url" in cfg else "stdio"
|
||||
@@ -3724,11 +3779,12 @@ def get_mcp_status() -> List[dict]:
|
||||
"tools": len(server._registered_tool_names) if hasattr(server, "_registered_tool_names") else len(server._tools),
|
||||
"connected": True,
|
||||
"disabled": False,
|
||||
"status": "connected",
|
||||
}
|
||||
if server._sampling:
|
||||
entry["sampling"] = dict(server._sampling.metrics)
|
||||
result.append(entry)
|
||||
else:
|
||||
elif not enabled:
|
||||
# A server with enabled: false is intentionally not connected — it is
|
||||
# disabled, not failed. Surface that distinction so consumers (banner,
|
||||
# TUI) can render "disabled" rather than an alarming "failed".
|
||||
@@ -3737,7 +3793,36 @@ def get_mcp_status() -> List[dict]:
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": not enabled,
|
||||
"disabled": True,
|
||||
"status": "disabled",
|
||||
})
|
||||
elif name in connecting:
|
||||
result.append({
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "connecting",
|
||||
})
|
||||
elif name in connect_errors:
|
||||
result.append({
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "failed",
|
||||
"error": connect_errors[name],
|
||||
})
|
||||
else:
|
||||
result.append({
|
||||
"name": name,
|
||||
"transport": transport,
|
||||
"tools": 0,
|
||||
"connected": False,
|
||||
"disabled": False,
|
||||
"status": "configured",
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
@@ -254,6 +254,12 @@ export function SessionPanel({ info, maxWidth, sid, t }: SessionPanelProps) {
|
||||
<Text color={t.color.text}>
|
||||
{s.tools} tool{s.tools === 1 ? '' : 's'}
|
||||
</Text>
|
||||
) : s.disabled || s.status === 'disabled' ? (
|
||||
<Text color={t.color.muted}>disabled</Text>
|
||||
) : s.status === 'connecting' ? (
|
||||
<Text color={t.color.warn}>connecting</Text>
|
||||
) : s.status === 'configured' ? (
|
||||
<Text color={t.color.muted}>configured</Text>
|
||||
) : (
|
||||
<Text color={t.color.error}>failed</Text>
|
||||
)}
|
||||
|
||||
@@ -138,6 +138,8 @@ export type SectionVisibility = Partial<Record<SectionName, DetailsMode>>
|
||||
|
||||
export interface McpServerStatus {
|
||||
connected: boolean
|
||||
disabled?: boolean
|
||||
status?: 'configured' | 'connecting' | 'connected' | 'disabled' | 'failed'
|
||||
name: string
|
||||
tools: number
|
||||
transport: string
|
||||
|
||||
@@ -1180,7 +1180,7 @@ Manage MCP (Model Context Protocol) server configurations and run Hermes as an M
|
||||
| `catalog` | List Nous-approved MCPs (plain text, scriptable). |
|
||||
| `install <name>` | Install a catalog entry (e.g. `hermes mcp install n8n`). |
|
||||
| `serve [-v\|--verbose]` | Run Hermes as an MCP server — expose conversations to other agents. |
|
||||
| `add <name> [--url URL] [--command CMD] [--args ...] [--auth oauth\|header]` | Add a custom MCP server with automatic tool discovery. |
|
||||
| `add <name> [--url URL] [--command CMD] [--auth oauth\|header] [--args ...]` | Add a custom MCP server with automatic tool discovery. `--args` passes the remaining argv to the stdio command, so put it last. |
|
||||
| `remove <name>` (alias: `rm`) | Remove an MCP server from config. |
|
||||
| `list` (alias: `ls`) | List configured MCP servers. |
|
||||
| `test <name>` | Test connection to an MCP server. |
|
||||
|
||||
Reference in New Issue
Block a user