Compare commits

..
Author SHA1 Message Date
Teknium 3645ee221c docs(agent): verify edit-format steering against current Codex CLI + Claude Code source
Checked ~/agent-codebases (Codex @ 2026-05-30, Claude Code decompiled src):
- Codex: apply_patch is the only file-edit tool; apply_patch.lark is V4A;
  GPT-5.1/5.2(-codex) prompts mandate it; gated per-model via
  ModelInfo.apply_patch_tool_type. No str_replace editor in the repo.
- Claude Code: FileEditTool is old_string/new_string exact replacement with
  unique-match semantics — current Claude models train against str_replace
  in their first-party harness, not just the 2025 API text-editor tool.
2026-06-11 09:16:45 -07:00
Teknium 8d2e0127b3 fix(agent): gate skill-index pruning behind focus mode; drop redundant context-files line; cite edit-format sources
Follow-up to #43316 (coding-context posture):

- Skill-category pruning from the prompt's skill index now only fires under
  the opt-in 'focus' mode, matching the toolset-collapse gating. The default
  'auto' posture must never silently hide skills — a hidden skill is
  effectively never loaded proactively, footer note or not.
- Drop the 'Context files: AGENTS.md ...' line from the workspace snapshot:
  those files' full contents are already injected into the system prompt as
  the Project Context block, so naming them again is redundant.
- Replace the unsourced edit-format steering rationale with citations:
  OpenAI GPT-4.1 prompting guide (apply_patch/V4A), Anthropic text-editor
  tool docs (str_replace schema trained into the model), and the
  str_replace-style editors in SWE-agent/OpenHands/Qwen Code/Gemini CLI.
2026-06-11 09:16:45 -07:00
helix4u e71d746820 fix(mcp): avoid false failed startup status 2026-06-11 09:01:52 -07:00
Teknium 5508f4bc54 fix(cli): utf-8 decode for whatsapp-bridge npm install capture (sibling of #43790) 2026-06-11 09:00:55 -07:00
helix4u b2043cf157 fix(tui): decode startup subprocess output as utf-8 2026-06-11 09:00:55 -07:00
helix4u dca11b6650 fix(mcp): preserve stdio argv passthrough 2026-06-11 08:59:55 -07:00
brooklyn! ee1a744ace fix(agent): demote non-coding skill categories to names-only — never hide skills (#44342)
Real-world failure with the original index pruning: under the default auto
posture, an agent-created ops skill in a demoted category vanished from the
prompt's skill index mid-project, and the agent silently fell back to a
stale sibling skill instead. The "discovery-only" premise didn't hold —
models do not reach for skills_list to rediscover what the index stops
showing them, and agent-created skills are the model's accumulated project
memory (runbooks, pitfalls, operating rules).

Gating pruning behind the opt-in focus mode was the wrong fix too: users
opening a worktree don't know the config exists, so the index-noise win
would effectively never ship.

Instead, the coding posture now DEMOTES non-coding categories rather than
hiding them: each demoted category renders as a single names-only line
("gaming [names only]: allthemons10-ops, mc-backup") with a footer note
explaining the omitted descriptions. Every skill name stays in the prompt,
so memory-anchored recall ("load <name>") keeps working in every mode,
while the description noise is still cut. Applies in auto/on/focus alike;
the general posture demotes nothing. Deny-list semantics unchanged —
unknown/custom categories and coding-adjacent ones keep full entries.

API renamed to match the honest semantics: hidden_skill_categories →
compact_skill_categories, build_skills_system_prompt(hidden_categories=) →
compact_categories=.
2026-06-11 10:25:42 -05:00
28 changed files with 640 additions and 2042 deletions
+61 -30
View File
@@ -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 (MayJun 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
View File
@@ -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
+8 -7
View File
@@ -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 = ""
+25 -95
View File
@@ -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']
-53
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+2 -67
View File
@@ -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)
-82
View File
@@ -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
View File
@@ -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
View File
@@ -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),
-135
View File
@@ -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
View File
@@ -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] = []
+2
View File
@@ -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)
-22
View File
@@ -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)
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+22 -15
View File
@@ -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 ───────────────────────────────────────────────────────
+26 -14
View File
@@ -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"]
+33
View File
@@ -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"]
+39
View File
@@ -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 ──────────────────────────────────────────
+77
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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>
)}
+2
View File
@@ -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
+1 -1
View File
@@ -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. |