From 6752da9a7735add1aff6ebc632c7e83fc4005a48 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Thu, 18 Jun 2026 11:32:18 +0530
Subject: [PATCH 01/90] fix(dashboard): clean up upload temp file on client
disconnect + pin python-multipart (NS-501)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follow-up to #47663 (streaming multipart upload), fixing two issues that
landed with it.
1. Temp file leaked on client disconnect. The streaming upload endpoint's
except chain caught only HTTPException / PermissionError / OSError — all
Exception subclasses. asyncio.CancelledError, raised when a browser aborts
a large upload mid-stream (the exact NS-501 scenario), is a BaseException,
so it bypassed every except clause and reached a finally that only closed
the file handle and never unlinked the temp file. Every aborted large
upload orphaned a partial `.{name}.*.upload` file (up to ~100 MB) in the
target directory. Cleanup now lives in finally, keyed on a `renamed`
success flag, so the temp file is removed on every non-success exit
including BaseException paths. Added test_stream_upload_cleans_temp_on_cancellation,
which fails on the pre-fix code (leaks the temp file) and passes with the fix.
2. python-multipart pinned to ==0.0.27 instead of ==0.0.20. The package was
already resolved at 0.0.27 transitively (via daytona) before #47663; the
explicit ==0.0.20 pin in the [web] extra and the tool.dashboard lazy-install
set downgraded it. Bumped both to ==0.0.27 and regenerated with `uv lock`,
keeping the lockfile coherent. The base dependency stays >=0.0.9,<1.
---
hermes_cli/web_server.py | 12 ++++--
pyproject.toml | 2 +-
tests/hermes_cli/test_web_server_files.py | 52 +++++++++++++++++++++++
tools/lazy_deps.py | 2 +-
uv.lock | 8 ++--
5 files changed, 67 insertions(+), 9 deletions(-)
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index ed619979bfb..ad82d9fdfef 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -1529,6 +1529,7 @@ async def upload_managed_file_stream(
)
tmp_path = Path(tmp_name)
total = 0
+ renamed = False
try:
with os.fdopen(tmp_fd, "wb") as out:
while True:
@@ -1540,16 +1541,21 @@ async def upload_managed_file_stream(
raise HTTPException(status_code=413, detail="File is too large")
out.write(chunk)
os.replace(tmp_path, target)
+ renamed = True
except HTTPException:
- tmp_path.unlink(missing_ok=True)
raise
except PermissionError:
- tmp_path.unlink(missing_ok=True)
raise HTTPException(status_code=403, detail="File is not writable")
except OSError as exc:
- tmp_path.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
finally:
+ # Clean up the temp file on every non-success exit, including
+ # BaseException paths the `except` clauses above don't catch — most
+ # importantly asyncio.CancelledError when a browser aborts a large
+ # upload mid-stream (the exact NS-501 scenario). os.replace clears
+ # tmp_path on success, so only unlink when the rename didn't happen.
+ if not renamed:
+ tmp_path.unlink(missing_ok=True)
await file.close()
return {
diff --git a/pyproject.toml b/pyproject.toml
index 6e371126dd2..cab849dc755 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -258,7 +258,7 @@ youtube = [
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
# starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette
# transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above.
-web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", "python-multipart==0.0.20"]
+web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", "python-multipart==0.0.27"]
all = [
# Policy (2026-05-12): `[all]` includes only extras that genuinely
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
diff --git a/tests/hermes_cli/test_web_server_files.py b/tests/hermes_cli/test_web_server_files.py
index 46ba18b1355..b295f0ab998 100644
--- a/tests/hermes_cli/test_web_server_files.py
+++ b/tests/hermes_cli/test_web_server_files.py
@@ -436,3 +436,55 @@ def test_stream_upload_large_file_under_cap_succeeds(forced_files_client, monkey
assert created.status_code == 200
assert file_path.stat().st_size == len(payload)
assert file_path.read_bytes() == payload
+
+
+def test_stream_upload_cleans_temp_on_cancellation(forced_files_client):
+ """A client disconnect mid-stream (asyncio.CancelledError) must not leak a temp file.
+
+ CancelledError is a BaseException, not an Exception, so it bypasses the
+ endpoint's ``except`` clauses entirely. The cleanup therefore lives in a
+ ``finally`` keyed on a success flag — without it, every aborted large
+ upload (the exact NS-501 scenario) would orphan a partial ``.upload`` temp
+ file in the target directory. We invoke the endpoint coroutine directly so
+ the BaseException propagates instead of being swallowed by the test client.
+ """
+ import asyncio
+
+ _client, root = forced_files_client
+ target = root / "out" / "aborted.bin"
+ target.parent.mkdir(parents=True, exist_ok=True)
+
+ class _AbortingUpload:
+ """UploadFile stand-in that yields one chunk then aborts like a dropped client."""
+
+ filename = "aborted.bin"
+
+ def __init__(self):
+ self._calls = 0
+
+ async def read(self, _size):
+ self._calls += 1
+ if self._calls == 1:
+ return b"partial chunk before the client vanished"
+ raise asyncio.CancelledError()
+
+ async def close(self):
+ return None
+
+ request = SimpleNamespace()
+
+ with pytest.raises(asyncio.CancelledError):
+ asyncio.run(
+ web_server.upload_managed_file_stream(
+ request=request,
+ file=_AbortingUpload(),
+ path=str(target),
+ overwrite=True,
+ )
+ )
+
+ # No partial data was promoted into place ...
+ assert not target.exists()
+ # ... and no .upload temp file was left behind.
+ leftovers = [p.name for p in target.parent.iterdir() if ".upload" in p.name]
+ assert leftovers == [], f"temp upload files leaked on cancellation: {leftovers}"
diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py
index 98bacbf42a0..4e2159a1a02 100644
--- a/tools/lazy_deps.py
+++ b/tools/lazy_deps.py
@@ -178,7 +178,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
"fastapi==0.133.1",
"uvicorn[standard]==0.41.0",
"starlette==1.0.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web]
- "python-multipart==0.0.20", # FastAPI UploadFile/Form for streaming uploads (NS-501)
+ "python-multipart==0.0.27", # FastAPI UploadFile/Form for streaming uploads (NS-501)
),
# Vision image-resize recovery (Pillow). Pillow is now a CORE dependency
# (pyproject `dependencies`), so this entry is a belt-and-suspenders fallback
diff --git a/uv.lock b/uv.lock
index fc340bdbe89..095b7563311 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1713,7 +1713,7 @@ requires-dist = [
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
{ name = "python-dotenv", specifier = "==1.2.2" },
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
- { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.20" },
+ { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.27" },
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" },
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" },
{ name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" },
@@ -3317,11 +3317,11 @@ wheels = [
[[package]]
name = "python-multipart"
-version = "0.0.20"
+version = "0.0.27"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
+ { url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
]
[[package]]
From b892ee2bcf1b65f3010c7229f4d61e574ada54ad Mon Sep 17 00:00:00 2001
From: xxxigm
Date: Tue, 16 Jun 2026 21:20:14 +0700
Subject: [PATCH 02/90] fix(agent): summarize non-retryable API errors so raw
HTML never leaks
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a non-retryable client error aborts the turn (e.g. a Codex/Cloudflare
HTTP 403 "managed challenge" page), the conversation loop returned the
failure dict with `error: str(api_error)` — the entire ~60KB HTML page.
Downstream consumers deliver that field verbatim: a cron job dumped a
Cloudflare challenge page to Discord, where it was split into ~31 messages.
The sibling "max retries exhausted" path already collapses such bodies via
`_summarize_api_error` (which extracts the / status from HTML error
pages). This makes the non-retryable path consistent: compute the summary
once and use it for both the status emit and the returned `error`.
---
agent/conversation_loop.py | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py
index ef69ac68329..163a508a8cd 100644
--- a/agent/conversation_loop.py
+++ b/agent/conversation_loop.py
@@ -3197,15 +3197,22 @@ def run_conversation(
# Terminal — flush buffered context so the user sees
# what was tried before the abort.
agent._flush_status_buffer()
+ # Summarize once: Cloudflare/proxy HTML challenge pages and
+ # other raw provider bodies must be collapsed to a short
+ # one-liner here, otherwise the full page leaks into the
+ # returned ``error`` field and downstream consumers deliver
+ # it verbatim (e.g. a cron failure notification dumped a
+ # ~60KB Cloudflare challenge page as 31 Discord messages).
+ _nonretryable_summary = agent._summarize_api_error(api_error)
if classified.reason == FailoverReason.content_policy_blocked:
agent._emit_status(
f"❌ Provider safety filter blocked this request: "
- f"{agent._summarize_api_error(api_error)}"
+ f"{_nonretryable_summary}"
)
else:
agent._emit_status(
f"❌ Non-retryable error (HTTP {status_code}): "
- f"{agent._summarize_api_error(api_error)}"
+ f"{_nonretryable_summary}"
)
agent._vprint(f"{agent.log_prefix}❌ Non-retryable client error (HTTP {status_code}). Aborting.", force=True)
agent._vprint(f"{agent.log_prefix} 🔌 Provider: {_provider} Model: {_model}", force=True)
@@ -3309,7 +3316,7 @@ def run_conversation(
"api_calls": api_call_count,
"completed": False,
"failed": True,
- "error": str(api_error),
+ "error": _nonretryable_summary,
}
if retry_count >= max_retries:
From f18f31ebf6dda993ade9f9de222fcf7fdfe8952e Mon Sep 17 00:00:00 2001
From: xxxigm
Date: Thu, 18 Jun 2026 14:55:38 +0700
Subject: [PATCH 03/90] test(agent): cover non-retryable error HTML
summarization
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Locks the contract that a non-retryable failure (a Cloudflare 403
"managed challenge" page) returns a short, HTML-free `error` field —
guarding the field path where the raw page was dumped to Discord as
~31 messages.
The test drives the standard chat-completions path with a concrete
model so the turn actually reaches `client.chat.completions.create`,
where the mocked 403 is raised. It asserts the create call happened
(guarding against a vacuous pass — an empty model on the Codex
Responses path would otherwise abort on a validation ValueError before
any API call) and that the summarized error includes "403" while
excluding / _cf_chl_opt. The non-retryable abort path is
provider-agnostic; a Cloudflare managed-challenge 403 can surface on
any provider behind Cloudflare.
---
.../test_nonretryable_error_html_summary.py | 130 ++++++++++++++++++
1 file changed, 130 insertions(+)
create mode 100644 tests/run_agent/test_nonretryable_error_html_summary.py
diff --git a/tests/run_agent/test_nonretryable_error_html_summary.py b/tests/run_agent/test_nonretryable_error_html_summary.py
new file mode 100644
index 00000000000..db765b124f3
--- /dev/null
+++ b/tests/run_agent/test_nonretryable_error_html_summary.py
@@ -0,0 +1,130 @@
+"""Regression: non-retryable API failures must not leak raw HTML pages.
+
+A scheduled cron job fell back to the Codex (``chatgpt.com``) provider, which
+returned a Cloudflare *challenge* page (HTTP 403) instead of a normal API
+response. The conversation loop classified this as a non-retryable client
+error and returned the failure dict — but the ``error`` field carried
+``str(api_error)``, i.e. the entire ~60 KB Cloudflare HTML page. The cron
+scheduler then delivered that verbatim to Discord, where it was split into
+~31 messages (the reporter's "31 part discord message which is cloudflares
+challenge page").
+
+The sibling "max retries exhausted" path already summarized the error via
+``_summarize_api_error`` (which collapses HTML pages to a one-liner); the
+non-retryable path did not. These tests lock the contract: whichever
+terminal path is taken, ``result['error']`` is a short, HTML-free summary.
+"""
+
+from unittest.mock import MagicMock, patch
+
+import run_agent
+from run_agent import AIAgent
+
+
+# A representative Cloudflare "managed challenge" body, matching the shape the
+# Codex backend returned in the field report (no , large inline
+# ``_cf_chl_opt`` script). Padded so length-based assertions are meaningful.
+_CLOUDFLARE_CHALLENGE_HTML = (
+ "\n\n \n"
+ ' \n'
+ " \n
"
+ "
\n \n\n"
+)
+
+
+def _make_403_html_error() -> Exception:
+ """An exception mimicking a Codex 403 whose body is a Cloudflare page."""
+ err = Exception(_CLOUDFLARE_CHALLENGE_HTML)
+ err.status_code = 403
+ return err
+
+
+def _make_agent() -> AIAgent:
+ # Drive the standard chat-completions path with a concrete model so the
+ # turn actually reaches ``client.chat.completions.create`` — that is where
+ # the mocked 403 is raised. The non-retryable abort being exercised lives
+ # in the shared conversation loop and is provider-agnostic; a Cloudflare
+ # "managed challenge" 403 can surface on any provider sitting behind
+ # Cloudflare (it was first reported on the Codex backend). Pinning
+ # ``api_mode`` + ``model`` here avoids the earlier abort the previous
+ # revision hit: an empty model on the Codex Responses path raised a
+ # validation ``ValueError`` *before* any API call, so the test passed
+ # without ever touching the 403 summarization path.
+ with (
+ patch("run_agent.get_tool_definitions", return_value=[]),
+ patch("run_agent.check_toolset_requirements", return_value={}),
+ patch("run_agent.OpenAI"),
+ ):
+ a = AIAgent(
+ api_key="test-key-1234567890",
+ base_url="https://api.openai.com/v1",
+ provider="openai",
+ api_mode="chat_completions",
+ model="gpt-5.5",
+ quiet_mode=True,
+ skip_context_files=True,
+ skip_memory=True,
+ )
+ a.client = MagicMock()
+ a._cached_system_prompt = "You are helpful."
+ a._use_prompt_caching = False
+ a.tool_delay = 0
+ a.compression_enabled = False
+ a.save_trajectories = False
+ return a
+
+
+def test_summarize_collapses_cloudflare_challenge_page():
+ """``_summarize_api_error`` must never echo the raw HTML body."""
+ summary = AIAgent._summarize_api_error(_make_403_html_error())
+
+ assert "
Date: Thu, 18 Jun 2026 15:46:47 +0530
Subject: [PATCH 04/90] refactor(agent): reuse hoisted summary in
content-policy branch
The non-retryable abort path now computes _nonretryable_summary once and
reuses it at the emit sites and the returned error field. The
content-policy-blocked return branch still recomputed the identical
value into a separate _summary local, half-honoring the 'summarize once'
intent. _summarize_api_error is a pure staticmethod and api_error is
never reassigned in this block, so _summary was provably byte-identical
to _nonretryable_summary. Reuse the hoisted value and drop the redundant
call. Behavior-preserving.
---
agent/conversation_loop.py | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py
index 163a508a8cd..0ccc9649428 100644
--- a/agent/conversation_loop.py
+++ b/agent/conversation_loop.py
@@ -3297,18 +3297,17 @@ def run_conversation(
else:
agent._persist_session(messages, conversation_history)
if classified.reason == FailoverReason.content_policy_blocked:
- _summary = agent._summarize_api_error(api_error)
_policy_response = (
"⚠️ The model provider's safety filter blocked this request "
"(not a Hermes/gateway failure).\n\n"
- f"Provider message: {_summary}\n\n"
+ f"Provider message: {_nonretryable_summary}\n\n"
f"{_CONTENT_POLICY_RECOVERY_HINT}"
)
return _content_policy_blocked_result(
messages,
api_call_count,
final_response=_policy_response,
- error_detail=_summary,
+ error_detail=_nonretryable_summary,
)
return {
"final_response": None,
From 245b95b09470bb3887943122a7d0de5bf20da055 Mon Sep 17 00:00:00 2001
From: AhmetArif0 <147827411+AhmetArif0@users.noreply.github.com>
Date: Tue, 2 Jun 2026 18:34:26 +0300
Subject: [PATCH 05/90] fix(terminal): block gateway lifecycle commands from
inside the gateway process
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
systemctl --user restart hermes-gateway run via the terminal tool is a
child of the gateway itself. When systemd delivers SIGTERM the gateway
kills this subprocess before it can complete, so the service may never
restart — reproducing issue #37453.
The hermes gateway restart/stop guard (hermes_cli/gateway.py) and the
cron-path guard (hermes_cli/cron.py) already block equivalent commands
in their respective paths but the terminal tool had no such defense.
Add a hard-block before command execution in terminal_tool: when
_HERMES_GATEWAY=1 and the command matches _contains_gateway_lifecycle_command,
return an error immediately. force=True cannot bypass it — unlike the
normal dangerous-command approval flow, here even a user-approved restart
would fail because the SIGTERM propagates to child processes.
Also extend _GATEWAY_LIFECYCLE_PATTERNS to match systemctl with flags
(e.g. systemctl --user restart) — the previous regex required the
action word immediately after systemctl with no flags in between.
Adds 9 regression tests: 6 blocked variants (parametrized), force bypass
attempt, safe systemctl passthrough, and guard-inactive-outside-gateway.
---
hermes_cli/cron.py | 2 +-
tests/hermes_cli/test_gateway_restart_loop.py | 107 ++++++++++++++++++
tools/terminal_tool.py | 23 ++++
3 files changed, 131 insertions(+), 1 deletion(-)
diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py
index 717c1e97658..86f8e6b09e2 100644
--- a/hermes_cli/cron.py
+++ b/hermes_cli/cron.py
@@ -25,7 +25,7 @@ _GATEWAY_LIFECYCLE_PATTERNS = re.compile(
r"(?i)"
r"(hermes\s+gateway\s+(restart|stop|start))"
r"|(launchctl\s+(kickstart|unload|load|stop|restart)\s+.*hermes)"
- r"|(systemctl\s+(restart|stop|start)\s+.*hermes)"
+ r"|(systemctl\s+(-\S+\s+)*(restart|stop|start)\s+.*hermes)"
r"|(p?kill\s+.*hermes.*gateway)"
)
diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py
index d6c9bb06cec..74ee9e4934e 100644
--- a/tests/hermes_cli/test_gateway_restart_loop.py
+++ b/tests/hermes_cli/test_gateway_restart_loop.py
@@ -6,6 +6,7 @@ Covers:
- _contains_gateway_lifecycle_command pattern matching
"""
+import json
import os
from argparse import Namespace
@@ -250,3 +251,109 @@ class TestGatewaySelfTargetingGuard:
args = Namespace(gateway_command="restart", all=False, system=False)
with pytest.raises(_Reached):
gw.gateway_command(args)
+
+
+# ---------------------------------------------------------------------------
+# Defense 3: terminal_tool hard-blocks gateway lifecycle commands inside gateway
+# ---------------------------------------------------------------------------
+
+class TestTerminalToolGatewayLifecycleGuard:
+ """terminal_tool must refuse gateway lifecycle commands when _HERMES_GATEWAY=1.
+
+ Issue #37453: systemctl --user restart hermes-gateway runs as a child of the
+ gateway process. When systemd delivers SIGTERM the gateway kills its own
+ restart command mid-execution — the service may never restart. The guard
+ must fire before execution, unconditionally (force=True cannot bypass it).
+ """
+
+ def _make_fake_env(self):
+ class _FakeEnv:
+ env = {}
+ def execute(self, command, **kwargs): # pragma: no cover
+ raise AssertionError("execute must not be reached")
+ return _FakeEnv()
+
+ def _minimal_config(self):
+ return {"env_type": "local", "cwd": "/tmp", "timeout": 60, "lifetime_seconds": 3600}
+
+ def _patch_env(self, monkeypatch, fake_env, *, inside_gateway: bool):
+ import tools.terminal_tool as tt
+ eid = "default"
+ monkeypatch.setattr(tt, "_active_environments", {eid: fake_env})
+ monkeypatch.setattr(tt, "_last_activity", {eid: 0.0})
+ monkeypatch.setattr(tt, "_task_env_overrides", {})
+ monkeypatch.setattr(tt, "_get_env_config", self._minimal_config)
+ if inside_gateway:
+ monkeypatch.setenv("_HERMES_GATEWAY", "1")
+ else:
+ monkeypatch.delenv("_HERMES_GATEWAY", raising=False)
+
+ @pytest.mark.parametrize("cmd", [
+ "systemctl restart hermes-gateway",
+ "systemctl --user restart hermes-gateway",
+ "systemctl stop hermes-gateway.service",
+ "hermes gateway restart",
+ "launchctl kickstart gui/501/ai.hermes.gateway",
+ "pkill -f hermes.*gateway",
+ ])
+ def test_blocks_lifecycle_commands_inside_gateway(self, monkeypatch, cmd):
+ import tools.terminal_tool as tt
+ self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
+
+ result = json.loads(tt.terminal_tool(command=cmd))
+
+ assert result["exit_code"] == 1
+ assert "Blocked" in result["error"]
+
+ def test_force_true_cannot_bypass_block(self, monkeypatch):
+ import tools.terminal_tool as tt
+ self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True)
+
+ result = json.loads(tt.terminal_tool(
+ command="systemctl restart hermes-gateway", force=True
+ ))
+
+ assert result["exit_code"] == 1
+ assert "Blocked" in result["error"]
+
+ def test_safe_systemctl_commands_pass_through(self, monkeypatch):
+ """Non-hermes systemctl commands must not be blocked by this guard."""
+ import tools.terminal_tool as tt
+
+ calls = []
+
+ class _FakeEnv:
+ env = {}
+ def execute(self, command, **kwargs):
+ calls.append(command)
+ return {"output": "Active: running", "returncode": 0}
+
+ self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=True)
+ monkeypatch.setattr(tt, "_check_all_guards", lambda cmd, env: {"approved": True})
+
+ result = json.loads(tt.terminal_tool(command="systemctl status nginx"))
+
+ assert result["exit_code"] == 0
+ assert calls == ["systemctl status nginx"]
+
+ def test_guard_inactive_outside_gateway(self, monkeypatch):
+ """Without _HERMES_GATEWAY=1 the lifecycle guard must not fire."""
+ import tools.terminal_tool as tt
+
+ calls = []
+
+ class _FakeEnv:
+ env = {}
+ def execute(self, command, **kwargs):
+ calls.append(command)
+ return {"output": "restarting...", "returncode": 0}
+
+ self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=False)
+ monkeypatch.setattr(tt, "_check_all_guards", lambda cmd, env: {"approved": True})
+
+ result = json.loads(tt.terminal_tool(command="systemctl restart hermes-gateway"))
+
+ # Outside the gateway the lifecycle guard doesn't block — the normal
+ # approval flow handles it (here mocked as approved).
+ assert result["exit_code"] == 0
+ assert calls == ["systemctl restart hermes-gateway"]
diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py
index 71907a3a3cc..26d0f425c56 100644
--- a/tools/terminal_tool.py
+++ b/tools/terminal_tool.py
@@ -2058,6 +2058,29 @@ def terminal_tool(
env = new_env
logger.info("%s environment ready for task %s", env_type, effective_task_id[:8])
+ # Hard-block: gateway lifecycle commands (systemctl/launchctl/hermes
+ # restart|stop targeting hermes-gateway) must never run inside the
+ # gateway process itself. The restart would SIGTERM the gateway, which
+ # kills this very subprocess before it can complete — the service may
+ # never restart. This mirrors the `hermes gateway restart` guard in
+ # hermes_cli/gateway.py and the cron-path guard in hermes_cli/cron.py,
+ # but applies unconditionally (force=True cannot help here).
+ if os.environ.get("_HERMES_GATEWAY") == "1":
+ from hermes_cli.cron import _contains_gateway_lifecycle_command
+ if _contains_gateway_lifecycle_command(command):
+ return json.dumps({
+ "output": "",
+ "exit_code": 1,
+ "error": (
+ "Blocked: cannot restart or stop the gateway from inside the "
+ "gateway process. The gateway would kill this command before "
+ "it could complete (SIGTERM propagates to child processes). "
+ "Run `hermes gateway restart` from a separate shell outside "
+ "the running gateway."
+ ),
+ "status": "error",
+ }, ensure_ascii=False)
+
# Pre-exec security checks (tirith + dangerous command detection)
# Skip check if force=True (user has confirmed they want to run it)
approval_note = None
From a64fc490fe61dfe865e9b189aa5f4c5f1598b285 Mon Sep 17 00:00:00 2001
From: Ben Barclay
Date: Fri, 19 Jun 2026 16:30:24 +1000
Subject: [PATCH 06/90] fix(relay): make hosted gateways actually connect AND
complete the inbound/outbound round-trip (#48828)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(relay): enable RELAY platform + normalize dial URL so hosted gateways actually connect
Three bugs blocked a self-provisioned hosted gateway from ever establishing its
inbound relay WS (found while standing up the live staging end-to-end). Each
masked the next; all three are needed for inbound to work.
1. RELAY platform never enabled in config.platforms (gateway/config.py).
register_relay_adapter() puts the adapter in the platform_registry, but
start_gateway()'s connect loop iterates self.config.platforms — which never
contained Platform.RELAY. So the adapter was "registered" but never connected
(logs showed "relay adapter registered" then "No messaging platforms
enabled"). Fix: _apply_env_overrides now enables Platform.RELAY (mirroring
relay_url into extra for the connected-checker) when GATEWAY_RELAY_URL (env)
or gateway.relay_url (yaml) is set. Absent -> no RELAY entry (direct/
single-tenant gateways unaffected).
2. URL scheme not converted for the WS dial (gateway/relay/ws_transport.py).
The relay URL is configured once as the http(s):// base (used as-is for the
provision POST), but websockets.connect rejects http(s):// with "scheme isn't
ws or wss". Fix: _ws_dial_url converts https->wss / http->ws.
3. /relay path not appended (same helper). The connector mounts its
WebSocketServer at path "/relay" and returns HTTP 400 on an upgrade to any
other path. GATEWAY_RELAY_URL is the base (no /relay), so the dial hit "/"
-> 400. Fix: _ws_dial_url ensures the path ends in /relay. Idempotent — a URL
already carrying ws(s):// and/or /relay is unchanged, so provision's
_provision_url (which derives /relay/provision from either form) still works.
Why the cross-repo E2E missed #2/#3: the stub connector binds ws://host:port and
its websockets.serve accepts ANY path, so neither the scheme nor the /relay path
was exercised. Real connector needs both.
Verified live on staging hermes-agent-stg-automated-perception-5054: after the
fixes the gateway logs "Connecting to relay..." -> "✓ relay connected" ->
"Gateway running with 1 platform(s)" against
wss://gateway-gateway.staging-nousresearch.com/relay, stable.
Tests: added _ws_dial_url scheme+path+idempotency cases (test_ws_transport.py)
and RELAY-platform-enablement cases for env + yaml + absent (test_config.py).
Full gateway/relay + config suites green (191 passed).
Relay-adapter lane. EXPERIMENTAL.
* fix(relay): re-attach guild_id to outbound so connector egress resolves the tenant
The final bug in the hosted-relay round-trip. Inbound worked end to end (Discord
-> connector -> bus -> agent WS -> agent runs -> reply), but the reply's egress
was declined by the connector: "discord egress declined: target not routed to an
onboarded tenant".
Cause: the connector's routedEgressGuard resolves the owning tenant from the
OUTBOUND action's metadata.guild_id (Discord's routing discriminator). The
gateway's generic delivery path builds outbound metadata via
run.py _thread_metadata_for_source, which only carries thread_id (and returns
None entirely for a non-threaded message) — so guild_id never reached the
connector, tenant resolution failed, and the shared bot refused to post.
Fix (relay-adapter-local, no perturbation of the generic delivery path or other
platforms): RelayAdapter learns chat_id -> guild_id from each inbound event
(_capture_scope) and re-attaches it to the outbound action's metadata in send()
(_with_scope) when not already present. No-op for chats we never saw inbound
(e.g. DMs) and never overwrites an explicit guild_id.
Verified live on staging hermes-agent-stg-automated-perception-5054: an
@mention in #general now produces a visible bot reply — full multi-tenant relay
round-trip (real Discord -> shared connector bot -> tenant routing -> agent WS ->
reply egress -> Discord).
Tests: _capture_scope/_with_scope reattach, no-scope no-op, explicit-guild_id
preserved (test_relay_adapter.py). Full relay + config suites green (160 passed).
Relay-adapter lane. EXPERIMENTAL.
---
gateway/config.py | 19 +++++++
gateway/relay/adapter.py | 36 ++++++++++++-
gateway/relay/ws_transport.py | 31 ++++++++++-
tests/gateway/relay/test_relay_adapter.py | 65 +++++++++++++++++++++++
tests/gateway/relay/test_ws_transport.py | 22 ++++++++
tests/gateway/test_config.py | 49 +++++++++++++++++
6 files changed, 220 insertions(+), 2 deletions(-)
diff --git a/gateway/config.py b/gateway/config.py
index 0ebf23e12d0..c63b9523d73 100644
--- a/gateway/config.py
+++ b/gateway/config.py
@@ -2143,5 +2143,24 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
except Exception as e:
logger.debug("Plugin platform enable pass failed: %s", e)
+ # Relay (generic connector-fronted platform, EXPERIMENTAL). Enabled when a
+ # connector relay URL is configured via GATEWAY_RELAY_URL (env) or
+ # gateway.relay_url (config.yaml). The adapter is registered into the
+ # platform_registry at gateway startup (gateway.relay.register_relay_adapter)
+ # and dials OUT to the connector — so, like Telegram/Matrix, it has no public
+ # inbound port and just needs Platform.RELAY present+enabled in
+ # config.platforms for start_gateway()'s connect loop to bring it up. The
+ # connected-checker (Platform.RELAY in _PLATFORM_CONNECTED_CHECKERS) keys on
+ # extra["relay_url"], so mirror the URL into extra here.
+ relay_url_env = os.getenv("GATEWAY_RELAY_URL", "").strip()
+ relay_url_yaml = ""
+ existing_relay = config.platforms.get(Platform.RELAY)
+ if existing_relay is not None:
+ relay_url_yaml = str(existing_relay.extra.get("relay_url") or "").strip()
+ relay_url_val = relay_url_env or relay_url_yaml
+ if relay_url_val:
+ relay_config = _enable_from_env(Platform.RELAY)
+ relay_config.extra["relay_url"] = relay_url_val.rstrip("/")
+
for platform_config in config.platforms.values():
platform_config.extra.pop("_enabled_explicit", None)
diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py
index fc4e5f40ee7..a1a7826f8f8 100644
--- a/gateway/relay/adapter.py
+++ b/gateway/relay/adapter.py
@@ -57,6 +57,13 @@ class RelayAdapter(BasePlatformAdapter):
self._transport = transport
# Capability surface read by stream_consumer (getattr(..., 4096)).
self.MAX_MESSAGE_LENGTH = descriptor.max_message_length
+ # chat_id -> guild_id (Discord) / workspace scope, learned from inbound
+ # events. The connector's egress guard resolves the owning tenant from
+ # the OUTBOUND action's metadata.guild_id; the gateway's generic delivery
+ # path (run.py _thread_metadata_for_source) only carries thread_id, so we
+ # re-attach the scope here from what we saw inbound. Keyed by chat_id
+ # (channel) since that's what send() receives. See routedEgressGuard.ts.
+ self._scope_by_chat: Dict[str, str] = {}
self.supports_code_blocks = descriptor.markdown_dialect not in ("", "plain")
# ── capability surface (from descriptor) ─────────────────────────────
@@ -108,8 +115,35 @@ class RelayAdapter(BasePlatformAdapter):
async def _on_inbound(self, event) -> None:
"""Bridge a connector-delivered MessageEvent into the normal adapter path."""
+ self._capture_scope(event)
await self.handle_message(event)
+ def _capture_scope(self, event) -> None:
+ """Remember chat_id -> guild scope from an inbound event so our outbound
+ (the agent's reply) can re-assert it for the connector's egress tenant
+ resolution. Never raises — scope tracking must not break inbound."""
+ try:
+ src = getattr(event, "source", None)
+ scope = getattr(src, "guild_id", None) if src else None
+ chat = getattr(src, "chat_id", None) if src else None
+ if scope and chat:
+ self._scope_by_chat[str(chat)] = str(scope)
+ except Exception: # noqa: BLE001 - scope tracking must never break inbound
+ pass
+
+ def _with_scope(self, chat_id: str, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
+ """Ensure the outbound metadata carries guild_id for the connector's
+ egress tenant resolution. The connector resolves the owning tenant from
+ metadata.guild_id (Discord); without it egress is declined as
+ 'target not routed to an onboarded tenant'. No-op when we have no scope
+ for this chat (e.g. DMs) or it's already present."""
+ meta: Dict[str, Any] = dict(metadata or {})
+ if not meta.get("guild_id"):
+ scope = self._scope_by_chat.get(str(chat_id))
+ if scope:
+ meta["guild_id"] = scope
+ return meta
+
async def on_interrupt(self, session_key: str, chat_id: str) -> None:
"""Bridge a connector-delivered /stop into the adapter's interrupt path.
@@ -140,7 +174,7 @@ class RelayAdapter(BasePlatformAdapter):
"chat_id": chat_id,
"content": content,
"reply_to": reply_to,
- "metadata": metadata or {},
+ "metadata": self._with_scope(chat_id, metadata),
}
)
return SendResult(
diff --git a/gateway/relay/ws_transport.py b/gateway/relay/ws_transport.py
index b2e8eda09cd..b091d44faa8 100644
--- a/gateway/relay/ws_transport.py
+++ b/gateway/relay/ws_transport.py
@@ -54,6 +54,35 @@ _HANDSHAKE_TIMEOUT_S = 30.0
_OUTBOUND_TIMEOUT_S = 30.0
+def _ws_dial_url(url: str) -> str:
+ """Normalize a connector URL to the ``ws(s)://…/relay`` dial target.
+
+ The relay URL is configured once (``GATEWAY_RELAY_URL`` / ``gateway.relay_url``)
+ as the connector's BASE URL (e.g. ``https://connector.example``) and shared by
+ both the provision POST (which needs ``http(s)://…/relay/provision`` — see
+ ``_provision_url``) and the WS dial (which needs ``ws(s)://…/relay``, the path
+ the connector mounts its ``WebSocketServer`` on). Two normalizations, both
+ load-bearing:
+
+ - scheme: ``https -> wss``, ``http -> ws`` (``websockets.connect`` raises
+ "scheme isn't ws or wss" on an http(s) URL).
+ - path: ensure it ends in ``/relay`` (the connector returns HTTP 400 on an
+ upgrade to any other path, since the WS server is mounted at ``/relay``).
+
+ Idempotent: an already-``ws(s)://…/relay`` URL is returned unchanged, so a URL
+ configured WITH the scheme and/or ``/relay`` still works.
+ """
+ raw = (url or "").strip()
+ if raw.startswith("https://"):
+ raw = "wss://" + raw[len("https://"):]
+ elif raw.startswith("http://"):
+ raw = "ws://" + raw[len("http://"):]
+ raw = raw.rstrip("/")
+ if not raw.endswith("/relay"):
+ raw = f"{raw}/relay"
+ return raw
+
+
def _event_from_wire(raw: Dict[str, Any]) -> MessageEvent:
"""Rebuild a MessageEvent from the connector's normalized inbound payload.
@@ -118,7 +147,7 @@ class WebSocketRelayTransport:
"WebSocketRelayTransport requires the 'websockets' package "
"(install the messaging extra)."
)
- self._url = url
+ self._url = _ws_dial_url(url)
self._platform = platform
self._bot_id = bot_id
self._connect_timeout_s = connect_timeout_s
diff --git a/tests/gateway/relay/test_relay_adapter.py b/tests/gateway/relay/test_relay_adapter.py
index 64d6aab2f86..f176eb5728c 100644
--- a/tests/gateway/relay/test_relay_adapter.py
+++ b/tests/gateway/relay/test_relay_adapter.py
@@ -75,3 +75,68 @@ async def test_send_without_transport_returns_failure():
result = await a.send("chat1", "hello")
assert result.success is False
assert result.error == "no transport"
+
+
+class _CaptureTransport:
+ """Minimal RelayTransport stand-in that records the outbound action."""
+
+ def __init__(self):
+ self.sent = None
+
+ def set_inbound_handler(self, h): # noqa: D401
+ self._h = h
+
+ async def send_outbound(self, action):
+ self.sent = action
+ return {"success": True, "message_id": "m1"}
+
+
+def _make_event(chat_id="chan-1", guild_id="guild-9"):
+ from gateway.platforms.base import MessageEvent, MessageType
+ from gateway.session import SessionSource
+
+ src = SessionSource(
+ platform=Platform.RELAY,
+ chat_id=chat_id,
+ chat_type="channel",
+ guild_id=guild_id,
+ )
+ return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT)
+
+
+@pytest.mark.asyncio
+async def test_send_reattaches_guild_id_from_inbound_scope():
+ """The connector's egress guard resolves the owning tenant from
+ metadata.guild_id; the gateway's generic delivery path drops it, so the
+ relay adapter must re-attach the guild scope learned from the inbound event.
+ Regression for live 'discord egress declined: target not routed to an
+ onboarded tenant'."""
+ t = _CaptureTransport()
+ a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
+ # Simulate the connector delivering an inbound message in guild-9 / chan-1,
+ # but don't run the full handle_message pipeline — just the scope capture.
+ a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
+
+ await a.send("chan-1", "the reply")
+
+ assert t.sent["metadata"].get("guild_id") == "guild-9"
+
+
+@pytest.mark.asyncio
+async def test_send_without_known_scope_omits_guild_id():
+ """A chat we never saw inbound (e.g. a DM) gets no guild_id — no-op, never
+ invents a scope."""
+ t = _CaptureTransport()
+ a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
+ await a.send("unknown-chat", "hi")
+ assert "guild_id" not in t.sent["metadata"]
+
+
+@pytest.mark.asyncio
+async def test_send_preserves_explicit_guild_id():
+ """An explicitly-provided metadata.guild_id is never overwritten."""
+ t = _CaptureTransport()
+ a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t)
+ a._capture_scope(_make_event(chat_id="chan-1", guild_id="guild-9"))
+ await a.send("chan-1", "hi", metadata={"guild_id": "explicit-1"})
+ assert t.sent["metadata"]["guild_id"] == "explicit-1"
diff --git a/tests/gateway/relay/test_ws_transport.py b/tests/gateway/relay/test_ws_transport.py
index dcb3f6c714f..00aa9b43327 100644
--- a/tests/gateway/relay/test_ws_transport.py
+++ b/tests/gateway/relay/test_ws_transport.py
@@ -177,3 +177,25 @@ async def test_disconnect_fails_pending_waiters_cleanly(server):
# After disconnect, an outbound returns a structured failure rather than hanging.
result = await t.send_outbound({"op": "send", "chat_id": "c", "content": "x"})
assert result["success"] is False
+
+
+def test_https_url_normalized_to_wss():
+ """The relay URL is configured once as the http(s):// BASE (for the provision
+ POST), but websockets.connect needs ws(s):// and the connector mounts its WS
+ server at /relay. The transport must convert scheme AND ensure the /relay
+ path. Regression for the live staging failures 'scheme isn't ws or wss' then
+ 'server rejected WebSocket connection: HTTP 400' (wrong path)."""
+ t = WebSocketRelayTransport("https://connector.example", "discord", "b")
+ assert t._url == "wss://connector.example/relay"
+ t2 = WebSocketRelayTransport("http://connector.local:8080", "discord", "b")
+ assert t2._url == "ws://connector.local:8080/relay"
+
+
+def test_ws_dial_url_idempotent_with_scheme_and_path():
+ # Already ws(s):// and/or already ending in /relay -> unchanged (no double append).
+ t = WebSocketRelayTransport("wss://connector.example/relay", "discord", "b")
+ assert t._url == "wss://connector.example/relay"
+ t2 = WebSocketRelayTransport("https://connector.example/relay/", "discord", "b")
+ assert t2._url == "wss://connector.example/relay"
+ t3 = WebSocketRelayTransport("ws://127.0.0.1:9", "discord", "b")
+ assert t3._url == "ws://127.0.0.1:9/relay"
diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py
index 9e74dd355ad..9f38f9b8a0d 100644
--- a/tests/gateway/test_config.py
+++ b/tests/gateway/test_config.py
@@ -311,6 +311,55 @@ class TestLoadGatewayConfig:
assert config.quick_commands == {"limits": {"type": "exec", "command": "echo ok"}}
+ def test_relay_platform_enabled_from_env_url(self, tmp_path, monkeypatch):
+ """GATEWAY_RELAY_URL must enable Platform.RELAY in config.platforms so
+ start_gateway()'s connect loop actually dials the connector. Registering
+ the adapter in the platform_registry is NOT enough — the connect loop
+ iterates config.platforms, so an un-enabled RELAY never connects (the
+ 'relay registered but no inbound' bug)."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.setenv("GATEWAY_RELAY_URL", "https://connector.example/relay/")
+
+ config = load_gateway_config()
+
+ assert Platform.RELAY in config.platforms
+ relay = config.platforms[Platform.RELAY]
+ assert relay.enabled is True
+ # Trailing slash stripped; mirrored into extra for the connected-checker.
+ assert relay.extra.get("relay_url") == "https://connector.example/relay"
+ assert Platform.RELAY in config.get_connected_platforms()
+
+ def test_relay_platform_absent_when_url_unset(self, tmp_path, monkeypatch):
+ """No relay URL -> no RELAY platform, so direct/single-tenant gateways
+ are unaffected."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.delenv("GATEWAY_RELAY_URL", raising=False)
+
+ config = load_gateway_config()
+
+ assert Platform.RELAY not in config.platforms
+
+ def test_relay_platform_enabled_from_config_yaml(self, tmp_path, monkeypatch):
+ """gateway.relay_url in config.yaml also enables RELAY (env-less path)."""
+ hermes_home = tmp_path / ".hermes"
+ hermes_home.mkdir()
+ config_path = hermes_home / "config.yaml"
+ config_path.write_text(
+ "gateway:\n platforms:\n relay:\n extra:\n relay_url: https://connector.example/relay\n",
+ encoding="utf-8",
+ )
+ monkeypatch.setenv("HERMES_HOME", str(hermes_home))
+ monkeypatch.delenv("GATEWAY_RELAY_URL", raising=False)
+
+ config = load_gateway_config()
+
+ assert Platform.RELAY in config.platforms
+ assert config.platforms[Platform.RELAY].enabled is True
+
def test_bridges_group_sessions_per_user_from_config_yaml(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
From 12dfcfdf73ed0543617ce0f4779aae8a9acb1e33 Mon Sep 17 00:00:00 2001
From: Shannon Sands
Date: Fri, 19 Jun 2026 16:11:55 +1000
Subject: [PATCH 07/90] fix(tui): restart dashboard chat on idle exit hotkeys
---
hermes_cli/web_server.py | 1 +
tests/hermes_cli/test_web_server.py | 1 +
ui-tui/src/__tests__/gatewayClient.test.ts | 40 +++++++++++++++++++
ui-tui/src/__tests__/gracefulExit.test.ts | 11 +++++
ui-tui/src/__tests__/useInputHandlers.test.ts | 39 +++++++++++++++++-
ui-tui/src/app/useInputHandlers.ts | 36 +++++++++++++++--
ui-tui/src/config/env.ts | 8 ++++
ui-tui/src/entry.tsx | 9 ++++-
ui-tui/src/gatewayClient.ts | 7 ++++
ui-tui/src/gatewayTypes.ts | 1 +
ui-tui/src/lib/gracefulExit.ts | 28 +++++++++++--
web/src/components/ChatSidebar.tsx | 23 ++++++++---
web/src/pages/ChatPage.tsx | 21 +++++++++-
13 files changed, 207 insertions(+), 18 deletions(-)
create mode 100644 ui-tui/src/__tests__/gracefulExit.test.ts
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index b2544ce9d77..ba6f4277deb 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -10830,6 +10830,7 @@ def _resolve_chat_argv(
# the dashboard PTY path.
env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1")
env.setdefault("HERMES_TUI_INLINE", "1")
+ env["HERMES_TUI_DASHBOARD"] = "1"
if profile_dir is not None:
env["HERMES_HOME"] = str(profile_dir)
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index e0ad77dfc8a..e65a28101cd 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -5062,6 +5062,7 @@ class TestPtyWebSocket:
_argv, _cwd, env = self.ws_module._resolve_chat_argv()
+ assert env["HERMES_TUI_DASHBOARD"] == "1"
assert env["HERMES_TUI_INLINE"] == "1"
assert env["HERMES_TUI_DISABLE_MOUSE"] == "1"
diff --git a/ui-tui/src/__tests__/gatewayClient.test.ts b/ui-tui/src/__tests__/gatewayClient.test.ts
index a872a008ddb..43d96add35a 100644
--- a/ui-tui/src/__tests__/gatewayClient.test.ts
+++ b/ui-tui/src/__tests__/gatewayClient.test.ts
@@ -187,6 +187,46 @@ describe('GatewayClient websocket attach mode', () => {
gw.kill()
})
+ it('publishes local dashboard-control events to the sidecar websocket', async () => {
+ process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
+ process.env.HERMES_TUI_SIDECAR_URL = 'ws://gateway.test/api/pub?token=abc&channel=demo'
+
+ const gw = new GatewayClient()
+ const seen: string[] = []
+
+ gw.on('event', ev => seen.push(ev.type))
+ gw.start()
+
+ const gatewaySocket = FakeWebSocket.instances[0]!
+
+ gatewaySocket.open()
+ await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2))
+
+ const sidecarSocket = FakeWebSocket.instances[1]!
+
+ sidecarSocket.open()
+ gw.drain()
+
+ gw.publishLocalEvent({
+ payload: { reason: 'idle_exit_hotkey' },
+ session_id: 'sid-old',
+ type: 'dashboard.new_session_requested'
+ })
+
+ expect(seen).toContain('dashboard.new_session_requested')
+ expect(JSON.parse(sidecarSocket.sent.at(-1) ?? '{}')).toEqual({
+ jsonrpc: '2.0',
+ method: 'event',
+ params: {
+ payload: { reason: 'idle_exit_hotkey' },
+ session_id: 'sid-old',
+ type: 'dashboard.new_session_requested'
+ }
+ })
+
+ gw.kill()
+ })
+
it('emits exit when attached websocket closes', () => {
process.env.HERMES_TUI_GATEWAY_URL = 'ws://gateway.test/api/ws?token=abc'
const gw = new GatewayClient()
diff --git a/ui-tui/src/__tests__/gracefulExit.test.ts b/ui-tui/src/__tests__/gracefulExit.test.ts
new file mode 100644
index 00000000000..6c805dfce7c
--- /dev/null
+++ b/ui-tui/src/__tests__/gracefulExit.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from 'vitest'
+
+import { shouldExitForSignal } from '../lib/gracefulExit.js'
+
+describe('shouldExitForSignal', () => {
+ it('ignores only the signals explicitly disabled for embedded dashboard chat', () => {
+ expect(shouldExitForSignal('SIGINT', ['SIGINT'])).toBe(false)
+ expect(shouldExitForSignal('SIGTERM', ['SIGINT'])).toBe(true)
+ expect(shouldExitForSignal('SIGHUP', ['SIGINT'])).toBe(true)
+ })
+})
diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts
index 0d3fd69c1ed..fa9372d5356 100644
--- a/ui-tui/src/__tests__/useInputHandlers.test.ts
+++ b/ui-tui/src/__tests__/useInputHandlers.test.ts
@@ -1,6 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
-import { applyVoiceRecordResponse, shouldFallThroughForScroll } from '../app/useInputHandlers.js'
+import {
+ applyVoiceRecordResponse,
+ handleIdleHotkeyExit,
+ shouldAllowIdleHotkeyExit,
+ shouldFallThroughForScroll
+} from '../app/useInputHandlers.js'
const baseKey = {
downArrow: false,
@@ -42,6 +47,38 @@ describe('shouldFallThroughForScroll — keep transcript scrolling alive during
})
})
+describe('shouldAllowIdleHotkeyExit', () => {
+ it('keeps idle exit hotkeys enabled in normal terminals', () => {
+ expect(shouldAllowIdleHotkeyExit(false)).toBe(true)
+ })
+
+ it('disables idle exit hotkeys in dashboard chat', () => {
+ expect(shouldAllowIdleHotkeyExit(true)).toBe(false)
+ })
+})
+
+describe('handleIdleHotkeyExit', () => {
+ it('exits in normal terminals', () => {
+ const actions = { die: vi.fn(), sys: vi.fn() }
+
+ handleIdleHotkeyExit(actions, false)
+
+ expect(actions.die).toHaveBeenCalledTimes(1)
+ expect(actions.sys).not.toHaveBeenCalled()
+ })
+
+ it('asks the dashboard for a fresh chat instead of leaving a ghost session', () => {
+ const actions = { die: vi.fn(), sys: vi.fn() }
+ const requestDashboardNewSession = vi.fn()
+
+ handleIdleHotkeyExit(actions, true, requestDashboardNewSession)
+
+ expect(actions.die).not.toHaveBeenCalled()
+ expect(requestDashboardNewSession).toHaveBeenCalledTimes(1)
+ expect(actions.sys).toHaveBeenCalledWith('starting a fresh dashboard chat...')
+ })
+})
+
describe('applyVoiceRecordResponse', () => {
it('reverts optimistic REC state when the gateway reports voice busy', () => {
const setProcessing = vi.fn()
diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts
index 20d3493f547..f19cccfe5b5 100644
--- a/ui-tui/src/app/useInputHandlers.ts
+++ b/ui-tui/src/app/useInputHandlers.ts
@@ -2,6 +2,7 @@ import { forceRedraw, useInput } from '@hermes/ink'
import { useStore } from '@nanostores/react'
import { useEffect, useRef } from 'react'
+import { DASHBOARD_TUI_MODE } from '../config/env.js'
import { TYPING_IDLE_MS } from '../config/timing.js'
import type {
ApprovalRespondResponse,
@@ -15,13 +16,30 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW
import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js'
import { getInputSelection } from './inputSelectionStore.js'
-import type { InputHandlerContext, InputHandlerResult } from './interfaces.js'
+import type { InputHandlerActions, InputHandlerContext, InputHandlerResult } from './interfaces.js'
import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { getUiState } from './uiStore.js'
const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target
+const DASHBOARD_NEW_SESSION_MESSAGE = 'starting a fresh dashboard chat...'
+
+export const shouldAllowIdleHotkeyExit = (dashboardTuiMode = DASHBOARD_TUI_MODE) => !dashboardTuiMode
+
+export function handleIdleHotkeyExit(
+ actions: Pick,
+ dashboardTuiMode = DASHBOARD_TUI_MODE,
+ requestDashboardNewSession?: () => void
+) {
+ if (!shouldAllowIdleHotkeyExit(dashboardTuiMode)) {
+ requestDashboardNewSession?.()
+
+ return actions.sys(DASHBOARD_NEW_SESSION_MESSAGE)
+ }
+
+ return actions.die()
+}
/**
* Approval / clarify / confirm overlays mount their own `useInput` handlers
@@ -505,11 +523,23 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult {
return cActions.clearIn()
}
- return actions.die()
+ return handleIdleHotkeyExit(actions, DASHBOARD_TUI_MODE, () => {
+ gateway.gw.publishLocalEvent({
+ payload: { reason: 'idle_exit_hotkey' },
+ session_id: live.sid ?? undefined,
+ type: 'dashboard.new_session_requested'
+ })
+ })
}
if (isAction(key, ch, 'd')) {
- return actions.die()
+ return handleIdleHotkeyExit(actions, DASHBOARD_TUI_MODE, () => {
+ gateway.gw.publishLocalEvent({
+ payload: { reason: 'idle_exit_hotkey' },
+ session_id: live.sid ?? undefined,
+ type: 'dashboard.new_session_requested'
+ })
+ })
}
if (isAction(key, ch, 'l')) {
diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts
index 3b5b9bee4d4..843512ed76a 100644
--- a/ui-tui/src/config/env.ts
+++ b/ui-tui/src/config/env.ts
@@ -1,4 +1,5 @@
import type { MouseTrackingMode } from '@hermes/ink'
+
import { isTermuxTuiMode } from '../lib/termux.js'
const truthy = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
@@ -43,12 +44,19 @@ export const STARTUP_IMAGE = (process.env.HERMES_TUI_IMAGE ?? '').trim()
// behavior.
const mouseTrackingOverride = parseToggle(process.env.HERMES_TUI_MOUSE_TRACKING)
const mouseTrackingDisabledLegacy = truthy(process.env.HERMES_TUI_DISABLE_MOUSE)
+
const resolvedBootMouseEnabled =
mouseTrackingOverride ?? (TERMUX_TUI_MODE ? false : !mouseTrackingDisabledLegacy)
+
export const MOUSE_TRACKING: MouseTrackingMode = resolvedBootMouseEnabled ? 'all' : 'off'
export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM)
+// Set by the dashboard PTY launcher. This is intentionally narrower than
+// INLINE_MODE: users can opt into inline terminal rendering locally, but the
+// browser-embedded TUI has no healthy restart path after an idle exit.
+export const DASHBOARD_TUI_MODE = truthy(process.env.HERMES_TUI_DASHBOARD)
+
// HERMES_DEV_CREDITS — dev-only live-spend readout (Δ status segment + "(dev credits)"
// banner). Throwaway dev scaffolding; the whole readout gates on this one flag.
export const DEV_CREDITS_MODE = truthy(process.env.HERMES_DEV_CREDITS)
diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx
index 22fee6bccbd..de60d966760 100644
--- a/ui-tui/src/entry.tsx
+++ b/ui-tui/src/entry.tsx
@@ -5,7 +5,7 @@ import './lib/forceTruecolor.js'
import type { FrameEvent } from '@hermes/ink'
-import { TERMUX_TUI_MODE } from './config/env.js'
+import { DASHBOARD_TUI_MODE, TERMUX_TUI_MODE } from './config/env.js'
import { GatewayClient } from './gatewayClient.js'
import { setupGracefulExit } from './lib/gracefulExit.js'
import { formatBytes, type HeapDumpResult, performHeapDump } from './lib/memory.js'
@@ -76,7 +76,12 @@ setupGracefulExit({
recordParentLifecycle(`graceful-exit received signal=${signal} → killing gateway`)
resetTerminalModes()
process.stderr.write(`hermes-tui lifecycle: received ${signal}\n`)
- }
+ },
+ // The dashboard chat tab has no in-page restart path after the PTY child
+ // exits. Ignore SIGINT there so Ctrl+C cannot kill the embedded TUI if raw
+ // mode briefly drops and the terminal driver turns the keystroke into a
+ // signal instead of input bytes. SIGTERM/SIGHUP still cleanly shut down.
+ ignoredSignals: DASHBOARD_TUI_MODE ? ['SIGINT'] : []
})
const stopMemoryMonitor = startMemoryMonitor({
diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts
index 5dfbe880fb1..88ddc0fcdc3 100644
--- a/ui-tui/src/gatewayClient.ts
+++ b/ui-tui/src/gatewayClient.ts
@@ -307,6 +307,13 @@ export class GatewayClient extends EventEmitter {
}
}
+ publishLocalEvent(ev: GatewayEvent) {
+ const frame = JSON.stringify({ jsonrpc: '2.0', method: 'event', params: ev })
+
+ this.mirrorEventToSidecar(frame)
+ this.publish(ev)
+ }
+
private handleWebSocketFrame(raw: unknown) {
const text = asWireText(raw)
diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts
index 016171008c1..74a6f7627d1 100644
--- a/ui-tui/src/gatewayTypes.ts
+++ b/ui-tui/src/gatewayTypes.ts
@@ -634,6 +634,7 @@ export type GatewayEvent =
}
| { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' }
| { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' }
+ | { payload?: { reason?: string }; session_id?: string; type: 'dashboard.new_session_requested' }
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
| {
payload?: { level?: 'info' | 'warn' | 'error'; message?: string }
diff --git a/ui-tui/src/lib/gracefulExit.ts b/ui-tui/src/lib/gracefulExit.ts
index 2896fd12651..089269ac1ae 100644
--- a/ui-tui/src/lib/gracefulExit.ts
+++ b/ui-tui/src/lib/gracefulExit.ts
@@ -1,11 +1,16 @@
interface SetupOptions {
cleanups?: (() => Promise | void)[]
failsafeMs?: number
+ ignoredSignals?: GracefulSignal[]
onError?: (scope: 'uncaughtException' | 'unhandledRejection', err: unknown) => void
onSignal?: (signal: NodeJS.Signals) => void
}
-const SIGNAL_EXIT_CODE: Record<'SIGHUP' | 'SIGINT' | 'SIGTERM', number> = {
+export type GracefulSignal = 'SIGHUP' | 'SIGINT' | 'SIGTERM'
+
+const SIGNALS: readonly GracefulSignal[] = ['SIGINT', 'SIGTERM', 'SIGHUP']
+
+const SIGNAL_EXIT_CODE: Record = {
SIGHUP: 129,
SIGINT: 130,
SIGTERM: 143
@@ -13,7 +18,16 @@ const SIGNAL_EXIT_CODE: Record<'SIGHUP' | 'SIGINT' | 'SIGTERM', number> = {
let wired = false
-export function setupGracefulExit({ cleanups = [], failsafeMs = 4000, onError, onSignal }: SetupOptions = {}) {
+export const shouldExitForSignal = (signal: GracefulSignal, ignoredSignals: readonly GracefulSignal[] = []) =>
+ !ignoredSignals.includes(signal)
+
+export function setupGracefulExit({
+ cleanups = [],
+ failsafeMs = 4000,
+ ignoredSignals = [],
+ onError,
+ onSignal
+}: SetupOptions = {}) {
if (wired) {
return
}
@@ -38,8 +52,14 @@ export function setupGracefulExit({ cleanups = [], failsafeMs = 4000, onError, o
void Promise.allSettled(cleanups.map(fn => Promise.resolve().then(fn))).finally(() => process.exit(code))
}
- for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
- process.on(sig, () => exit(SIGNAL_EXIT_CODE[sig], sig))
+ for (const sig of SIGNALS) {
+ process.on(sig, () => {
+ if (!shouldExitForSignal(sig, ignoredSignals)) {
+ return
+ }
+
+ exit(SIGNAL_EXIT_CODE[sig], sig)
+ })
}
process.on('uncaughtException', err => onError?.('uncaughtException', err))
diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx
index 1a53741d8fd..e6e3437781a 100644
--- a/web/src/components/ChatSidebar.tsx
+++ b/web/src/components/ChatSidebar.tsx
@@ -74,9 +74,15 @@ interface ChatSidebarProps {
/** Management profile from the dashboard switcher — scopes session.create. */
profile?: string;
className?: string;
+ onDashboardNewSessionRequest?: () => void;
}
-export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
+export function ChatSidebar({
+ channel,
+ profile,
+ className,
+ onDashboardNewSessionRequest,
+}: ChatSidebarProps) {
// `version` bumps on reconnect; gw is derived so we never call setState
// for it inside an effect (React 19's set-state-in-effect rule). The
// counter is the dependency on purpose — it's not read in the memo body,
@@ -112,9 +118,12 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
useEffect(() => {
let cancelled = false;
- setSessionId(null);
- setInfo({});
- setError(null);
+ queueMicrotask(() => {
+ if (cancelled) return;
+ setSessionId(null);
+ setInfo({});
+ setError(null);
+ });
const offState = gw.onState(setState);
const offSessionInfo = gw.on("session.info", (ev) => {
@@ -233,7 +242,9 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
const { type, payload } = frame.params;
- if (type === "tool.start") {
+ if (type === "dashboard.new_session_requested") {
+ onDashboardNewSessionRequest?.();
+ } else if (type === "tool.start") {
const p = payload as
| { tool_id?: string; name?: string; context?: string }
| undefined;
@@ -309,7 +320,7 @@ export function ChatSidebar({ channel, profile, className }: ChatSidebarProps) {
unmounting = true;
ws?.close();
};
- }, [channel, version]);
+ }, [channel, onDashboardNewSessionRequest, version]);
const reconnect = useCallback(() => {
setError(null);
diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx
index 4e3a6c23151..dcb006e0da2 100644
--- a/web/src/pages/ChatPage.tsx
+++ b/web/src/pages/ChatPage.tsx
@@ -153,6 +153,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
setBanner(null);
setReconnectNonce((n) => n + 1);
}, []);
+ const startFreshDashboardChat = useCallback(() => {
+ const next = new URLSearchParams(searchParams);
+
+ next.delete("resume");
+ setSearchParams(next, { replace: true });
+ setSessionEnded(false);
+ setBanner(null);
+ setReconnectNonce((n) => n + 1);
+ }, [searchParams, setSearchParams]);
// Raw state for the mobile side-sheet + a derived value that force-
// closes whenever the chat tab isn't active. The *derived* value is
// what side-effects (body-scroll lock, keydown listener, portal render)
@@ -881,7 +890,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
"border-t border-current/10",
)}
>
-
+
>,
@@ -967,7 +980,11 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
className="flex min-h-0 shrink-0 flex-col overflow-hidden lg:h-full lg:w-80"
>
-
+
)}
From f741e70791c1c69b501fdb98da80bec3e4d130c0 Mon Sep 17 00:00:00 2001
From: Shannon Sands
Date: Fri, 19 Jun 2026 14:27:42 +1000
Subject: [PATCH 08/90] Add Slack allowed users setup field
---
hermes_cli/config.py | 7 +++++
hermes_cli/web_server.py | 22 ++++++++++++--
tests/hermes_cli/test_web_server.py | 47 +++++++++++++++++++++++++++++
3 files changed, 74 insertions(+), 2 deletions(-)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index f698c11d5ac..8c790e7e856 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -3439,6 +3439,13 @@ OPTIONAL_ENV_VARS = {
"password": True,
"category": "messaging",
},
+ "SLACK_ALLOWED_USERS": {
+ "description": "Comma-separated Slack member IDs allowed to use Hermes, e.g. U01ABC2DEF3. Without this, Slack may connect but deny messages by default.",
+ "prompt": "Allowed Slack member IDs",
+ "url": "https://api.slack.com/apps",
+ "password": False,
+ "category": "messaging",
+ },
"MATTERMOST_URL": {
"description": "Mattermost server URL (e.g. https://mm.example.com)",
"prompt": "Mattermost server URL",
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index 2dbb316d32d..b1320875c53 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -2325,6 +2325,23 @@ def _gateway_display_command(profile: Optional[str], verb: str) -> str:
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
+def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None:
+ """Reject platform credentials that are clearly in the wrong field."""
+ if platform_id != "slack" or not value:
+ return
+
+ if key == "SLACK_BOT_TOKEN" and not value.startswith("xoxb-"):
+ raise HTTPException(
+ status_code=400,
+ detail="Slack Bot Token must start with xoxb-. Paste the bot token from OAuth & Permissions.",
+ )
+ if key == "SLACK_APP_TOKEN" and not value.startswith("xapp-"):
+ raise HTTPException(
+ status_code=400,
+ detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.",
+ )
+
+
def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]:
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
@@ -4155,9 +4172,9 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = {
},
"slack": {
"name": "Slack",
- "description": "Use Hermes from Slack via Socket Mode.",
+ "description": "Use Hermes from Slack via Socket Mode. Add allowed Slack member IDs so connected bots can respond.",
"docs_url": "https://api.slack.com/apps",
- "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
+ "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"),
"required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"),
},
"mattermost": {
@@ -5221,6 +5238,7 @@ async def update_messaging_platform(
)
trimmed = value.strip()
if trimmed:
+ _validate_messaging_env_value(platform_id, key, trimmed)
save_env_value(key, trimmed)
if body.enabled is not None:
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index e65a28101cd..3f6ed3e0435 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -1552,6 +1552,24 @@ class TestWebServerEndpoints:
assert telegram["enabled"] is False
assert any(field["key"] == "TELEGRAM_BOT_TOKEN" and field["required"] for field in telegram["env_vars"])
+ def test_slack_messaging_platform_exposes_user_allowlist(self):
+ resp = self.client.get("/api/messaging/platforms")
+
+ assert resp.status_code == 200
+ platforms = resp.json()["platforms"]
+ slack = next(platform for platform in platforms if platform["id"] == "slack")
+ fields = {field["key"]: field for field in slack["env_vars"]}
+
+ assert "allowed Slack member IDs" in slack["description"]
+ assert set(fields) >= {
+ "SLACK_BOT_TOKEN",
+ "SLACK_APP_TOKEN",
+ "SLACK_ALLOWED_USERS",
+ }
+ assert fields["SLACK_ALLOWED_USERS"]["prompt"] == "Allowed Slack member IDs"
+ assert fields["SLACK_ALLOWED_USERS"]["is_password"] is False
+ assert "member IDs" in fields["SLACK_ALLOWED_USERS"]["description"]
+
def test_weixin_messaging_metadata_describes_personal_ilink_setup(self):
resp = self.client.get("/api/messaging/platforms")
@@ -1628,6 +1646,35 @@ class TestWebServerEndpoints:
telegram = next(platform for platform in status if platform["id"] == "telegram")
assert telegram["enabled"] is False
+ def test_update_messaging_platform_saves_slack_allowed_users(self):
+ from hermes_cli.config import load_env
+
+ resp = self.client.put(
+ "/api/messaging/platforms/slack",
+ json={"env": {"SLACK_ALLOWED_USERS": "U01ABC2DEF3,U04XYZ5LMN6"}},
+ )
+
+ assert resp.status_code == 200
+ assert load_env()["SLACK_ALLOWED_USERS"] == "U01ABC2DEF3,U04XYZ5LMN6"
+
+ def test_update_messaging_platform_rejects_swapped_slack_bot_token(self):
+ resp = self.client.put(
+ "/api/messaging/platforms/slack",
+ json={"env": {"SLACK_BOT_TOKEN": "xapp-wrong-token-type"}},
+ )
+
+ assert resp.status_code == 400
+ assert "xoxb-" in resp.json()["detail"]
+
+ def test_update_messaging_platform_rejects_swapped_slack_app_token(self):
+ resp = self.client.put(
+ "/api/messaging/platforms/slack",
+ json={"env": {"SLACK_APP_TOKEN": "xoxb-wrong-token-type"}},
+ )
+
+ assert resp.status_code == 400
+ assert "xapp-" in resp.json()["detail"]
+
def test_messaging_platform_test_reports_missing_required_setup(self):
resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True})
assert resp.status_code == 200
From d9190491a687d7f29fee5e09c2418d66025e9660 Mon Sep 17 00:00:00 2001
From: Shannon Sands
Date: Fri, 19 Jun 2026 14:37:16 +1000
Subject: [PATCH 09/90] Add Slack setup hints and field validation
---
hermes_cli/config.py | 3 +
hermes_cli/web_server.py | 13 +++++
tests/hermes_cli/test_web_server.py | 12 ++++
web/src/lib/api.ts | 1 +
web/src/pages/ChannelsPage.tsx | 85 ++++++++++++++++++++++++++---
5 files changed, 106 insertions(+), 8 deletions(-)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 8c790e7e856..c81df25c03b 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -3426,6 +3426,7 @@ OPTIONAL_ENV_VARS = {
"Required scopes: chat:write, app_mentions:read, channels:history, groups:history, "
"im:history, im:read, im:write, users:read, files:read, files:write",
"prompt": "Slack Bot Token (xoxb-...)",
+ "help": "In your Slack app, add the required bot scopes, install the app to the workspace, then copy OAuth & Permissions > Bot User OAuth Token.",
"url": "https://api.slack.com/apps",
"password": True,
"category": "messaging",
@@ -3435,6 +3436,7 @@ OPTIONAL_ENV_VARS = {
"App-Level Tokens. Also ensure Event Subscriptions include: message.im, "
"message.channels, message.groups, app_mention",
"prompt": "Slack App Token (xapp-...)",
+ "help": "In your Slack app, enable Socket Mode, then create Basic Information > App-Level Tokens with the connections:write scope.",
"url": "https://api.slack.com/apps",
"password": True,
"category": "messaging",
@@ -3442,6 +3444,7 @@ OPTIONAL_ENV_VARS = {
"SLACK_ALLOWED_USERS": {
"description": "Comma-separated Slack member IDs allowed to use Hermes, e.g. U01ABC2DEF3. Without this, Slack may connect but deny messages by default.",
"prompt": "Allowed Slack member IDs",
+ "help": "In Slack, open your profile, choose More or the three-dot menu, then Copy member ID. Add multiple IDs comma-separated.",
"url": "https://api.slack.com/apps",
"password": False,
"category": "messaging",
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index b1320875c53..b890f68649e 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -2340,6 +2340,18 @@ def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> Non
status_code=400,
detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.",
)
+ if key == "SLACK_ALLOWED_USERS":
+ user_ids = [part.strip() for part in value.split(",")]
+ invalid = [
+ user_id
+ for user_id in user_ids
+ if not user_id or not re.fullmatch(r"[UW][A-Z0-9]{2,}", user_id)
+ ]
+ if invalid:
+ raise HTTPException(
+ status_code=400,
+ detail="Slack allowed user IDs must be comma-separated member IDs like U01ABC2DEF3.",
+ )
def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]:
@@ -4659,6 +4671,7 @@ def _messaging_env_info(key: str) -> dict[str, Any]:
return {
"description": info.get("description", ""),
"prompt": info.get("prompt", key),
+ "help": info.get("help", ""),
"url": info.get("url"),
"is_password": info.get("password", False),
"advanced": info.get("advanced", False),
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index 3f6ed3e0435..d44c789b3e3 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -1569,6 +1569,9 @@ class TestWebServerEndpoints:
assert fields["SLACK_ALLOWED_USERS"]["prompt"] == "Allowed Slack member IDs"
assert fields["SLACK_ALLOWED_USERS"]["is_password"] is False
assert "member IDs" in fields["SLACK_ALLOWED_USERS"]["description"]
+ assert "Bot User OAuth Token" in fields["SLACK_BOT_TOKEN"]["help"]
+ assert "App-Level Tokens" in fields["SLACK_APP_TOKEN"]["help"]
+ assert "Copy member ID" in fields["SLACK_ALLOWED_USERS"]["help"]
def test_weixin_messaging_metadata_describes_personal_ilink_setup(self):
resp = self.client.get("/api/messaging/platforms")
@@ -1675,6 +1678,15 @@ class TestWebServerEndpoints:
assert resp.status_code == 400
assert "xapp-" in resp.json()["detail"]
+ def test_update_messaging_platform_rejects_invalid_slack_allowed_users(self):
+ resp = self.client.put(
+ "/api/messaging/platforms/slack",
+ json={"env": {"SLACK_ALLOWED_USERS": "U01ABC2DEF3,not-a-user"}},
+ )
+
+ assert resp.status_code == 400
+ assert "member IDs" in resp.json()["detail"]
+
def test_messaging_platform_test_reports_missing_required_setup(self):
resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True})
assert resp.status_code == 200
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index ec03997b6c6..3955d3324c9 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -1346,6 +1346,7 @@ export interface MessagingPlatformEnvVar {
redacted_value: string | null;
description: string;
prompt: string;
+ help: string;
url: string | null;
is_password: boolean;
advanced: boolean;
diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx
index d42ab7b9e74..84791738a25 100644
--- a/web/src/pages/ChannelsPage.tsx
+++ b/web/src/pages/ChannelsPage.tsx
@@ -4,6 +4,7 @@ import {
Check,
CheckCircle2,
ExternalLink,
+ Info,
PlugZap,
QrCode,
Radio,
@@ -55,6 +56,34 @@ function stateBadge(state: string) {
}
const TELEGRAM_USER_ID_RE = /^\d+$/;
+const SLACK_MEMBER_ID_RE = /^[UW][A-Z0-9]{2,}$/;
+const SLACK_TOKEN_PREFIXES: Record = {
+ SLACK_BOT_TOKEN: "xoxb-",
+ SLACK_APP_TOKEN: "xapp-",
+};
+
+function validateMessagingEnvField(field: MessagingPlatformEnvVar, value: string): string | null {
+ const trimmed = value.trim();
+ if (!trimmed) return null;
+
+ const expectedPrefix = SLACK_TOKEN_PREFIXES[field.key];
+ if (expectedPrefix && !trimmed.startsWith(expectedPrefix)) {
+ return `${field.prompt || field.key} must start with ${expectedPrefix}`;
+ }
+
+ if (field.key === "SLACK_ALLOWED_USERS") {
+ const parts = trimmed.split(",").map((part) => part.trim());
+ if (parts.some((part) => !part)) {
+ return "Slack member IDs must be comma-separated without empty entries.";
+ }
+ const invalid = parts.find((part) => !SLACK_MEMBER_ID_RE.test(part));
+ if (invalid) {
+ return `${invalid} does not look like a Slack member ID. Use IDs like U01ABC2DEF3.`;
+ }
+ }
+
+ return null;
+}
function formatExpiry(expiresAt: string): string {
const ms = Date.parse(expiresAt) - Date.now();
@@ -83,8 +112,12 @@ export default function ChannelsPage() {
// Config modal state
const [editing, setEditing] = useState(null);
const [draftEnv, setDraftEnv] = useState>({});
+ const [fieldErrors, setFieldErrors] = useState>({});
const [saving, setSaving] = useState(false);
- const closeEdit = useCallback(() => setEditing(null), []);
+ const closeEdit = useCallback(() => {
+ setEditing(null);
+ setFieldErrors({});
+ }, []);
const editModalRef = useModalBehavior({ open: editing !== null, onClose: closeEdit });
// Per-card busy + restart-needed tracking
@@ -116,6 +149,7 @@ export default function ChannelsPage() {
initial[v.key] = "";
});
setDraftEnv(initial);
+ setFieldErrors({});
setEditing(platform);
};
@@ -138,6 +172,16 @@ export default function ChannelsPage() {
showToast(`${missing[0].prompt || missing[0].key} is required`, "error");
return;
}
+ const nextFieldErrors: Record = {};
+ editing.env_vars.forEach((field) => {
+ const message = validateMessagingEnvField(field, draftEnv[field.key] || "");
+ if (message) nextFieldErrors[field.key] = message;
+ });
+ if (Object.keys(nextFieldErrors).length > 0) {
+ setFieldErrors(nextFieldErrors);
+ showToast("Fix the highlighted fields before saving.", "error");
+ return;
+ }
setSaving(true);
try {
const body: MessagingPlatformUpdate = { env, enabled: true };
@@ -326,10 +370,22 @@ export default function ChannelsPage() {
))}
From 83c034bd5bc855955a825ff4acd1ed11edab6c3d Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Fri, 19 Jun 2026 12:18:15 +0530
Subject: [PATCH 10/90] fix(dashboard): accept Slack allow-all wildcard in
allowed-users validation
The new SLACK_ALLOWED_USERS validation rejected '*', but the Slack gateway
honors '*' as an allow-all wildcard (gateway/platforms/slack.py DM auth,
slash-confirm, and approval-button paths). Accept '*' as a valid list entry
in both the API validator and the dashboard form so a value the runtime
honors is no longer blocked at setup.
---
hermes_cli/web_server.py | 4 +++-
tests/hermes_cli/test_web_server.py | 13 +++++++++++++
web/src/pages/ChannelsPage.tsx | 2 +-
3 files changed, 17 insertions(+), 2 deletions(-)
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index b890f68649e..316bc154fa4 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -2342,10 +2342,12 @@ def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> Non
)
if key == "SLACK_ALLOWED_USERS":
user_ids = [part.strip() for part in value.split(",")]
+ # "*" is the gateway's allow-all wildcard (see gateway/platforms/slack.py),
+ # so accept it as a valid entry alongside Slack member IDs (U.../W...).
invalid = [
user_id
for user_id in user_ids
- if not user_id or not re.fullmatch(r"[UW][A-Z0-9]{2,}", user_id)
+ if user_id != "*" and (not user_id or not re.fullmatch(r"[UW][A-Z0-9]{2,}", user_id))
]
if invalid:
raise HTTPException(
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index d44c789b3e3..d7a4dbcbbf9 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -1687,6 +1687,19 @@ class TestWebServerEndpoints:
assert resp.status_code == 400
assert "member IDs" in resp.json()["detail"]
+ def test_update_messaging_platform_accepts_slack_allowed_users_wildcard(self):
+ # "*" is the gateway's allow-all wildcard (gateway/platforms/slack.py),
+ # so the dashboard must accept it rather than rejecting it as malformed.
+ from hermes_cli.config import load_env
+
+ resp = self.client.put(
+ "/api/messaging/platforms/slack",
+ json={"env": {"SLACK_ALLOWED_USERS": "*"}},
+ )
+
+ assert resp.status_code == 200
+ assert load_env()["SLACK_ALLOWED_USERS"] == "*"
+
def test_messaging_platform_test_reports_missing_required_setup(self):
resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True})
assert resp.status_code == 200
diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx
index 84791738a25..db56beb1925 100644
--- a/web/src/pages/ChannelsPage.tsx
+++ b/web/src/pages/ChannelsPage.tsx
@@ -76,7 +76,7 @@ function validateMessagingEnvField(field: MessagingPlatformEnvVar, value: string
if (parts.some((part) => !part)) {
return "Slack member IDs must be comma-separated without empty entries.";
}
- const invalid = parts.find((part) => !SLACK_MEMBER_ID_RE.test(part));
+ const invalid = parts.find((part) => part !== "*" && !SLACK_MEMBER_ID_RE.test(part));
if (invalid) {
return `${invalid} does not look like a Slack member ID. Use IDs like U01ABC2DEF3.`;
}
From 1ab6f34791e28559911185b308d8bd1b0be5f393 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Fri, 19 Jun 2026 12:22:30 +0530
Subject: [PATCH 11/90] refactor(dashboard): align Slack allowlist validation
with gateway parse
- Drop empty entries before validating SLACK_ALLOWED_USERS so a trailing or
interior comma (which the gateway silently tolerates in
gateway/platforms/slack.py) is no longer rejected at the dashboard.
- Hoist the member-ID regex to a module-level _SLACK_MEMBER_ID_RE constant
and note it stays in sync with the frontend SLACK_MEMBER_ID_RE.
- Add a regression test for the trailing-comma case.
---
hermes_cli/web_server.py | 14 ++++++++++----
tests/hermes_cli/test_web_server.py | 13 +++++++++++++
web/src/pages/ChannelsPage.tsx | 11 +++++++----
3 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index 316bc154fa4..b0d51e2481e 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -2325,6 +2325,11 @@ def _gateway_display_command(profile: Optional[str], verb: str) -> str:
return " ".join(["hermes", *_gateway_subcommand(profile, verb)])
+# Slack member IDs (users U..., Enterprise Grid W...). Kept in sync with the
+# frontend SLACK_MEMBER_ID_RE in web/src/pages/ChannelsPage.tsx.
+_SLACK_MEMBER_ID_RE = re.compile(r"[UW][A-Z0-9]{2,}")
+
+
def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None:
"""Reject platform credentials that are clearly in the wrong field."""
if platform_id != "slack" or not value:
@@ -2341,13 +2346,14 @@ def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> Non
detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.",
)
if key == "SLACK_ALLOWED_USERS":
- user_ids = [part.strip() for part in value.split(",")]
- # "*" is the gateway's allow-all wildcard (see gateway/platforms/slack.py),
- # so accept it as a valid entry alongside Slack member IDs (U.../W...).
+ # Mirror the gateway's parse (gateway/platforms/slack.py): split on comma,
+ # strip, and drop empty entries so a trailing/interior comma isn't rejected
+ # here when the runtime would accept it. "*" is the allow-all wildcard.
+ user_ids = [part.strip() for part in value.split(",") if part.strip()]
invalid = [
user_id
for user_id in user_ids
- if user_id != "*" and (not user_id or not re.fullmatch(r"[UW][A-Z0-9]{2,}", user_id))
+ if user_id != "*" and not _SLACK_MEMBER_ID_RE.fullmatch(user_id)
]
if invalid:
raise HTTPException(
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index d7a4dbcbbf9..7416ec0b87a 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -1700,6 +1700,19 @@ class TestWebServerEndpoints:
assert resp.status_code == 200
assert load_env()["SLACK_ALLOWED_USERS"] == "*"
+ def test_update_messaging_platform_accepts_slack_allowed_users_trailing_comma(self):
+ # The gateway drops empty entries (gateway/platforms/slack.py), so a
+ # trailing/interior comma must not be rejected by the dashboard.
+ from hermes_cli.config import load_env
+
+ resp = self.client.put(
+ "/api/messaging/platforms/slack",
+ json={"env": {"SLACK_ALLOWED_USERS": "U01ABC2DEF3,,W04XYZ5LMN6,"}},
+ )
+
+ assert resp.status_code == 200
+ assert load_env()["SLACK_ALLOWED_USERS"] == "U01ABC2DEF3,,W04XYZ5LMN6,"
+
def test_messaging_platform_test_reports_missing_required_setup(self):
resp = self.client.put("/api/messaging/platforms/discord", json={"enabled": True})
assert resp.status_code == 200
diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx
index db56beb1925..7658c0cd61a 100644
--- a/web/src/pages/ChannelsPage.tsx
+++ b/web/src/pages/ChannelsPage.tsx
@@ -72,10 +72,13 @@ function validateMessagingEnvField(field: MessagingPlatformEnvVar, value: string
}
if (field.key === "SLACK_ALLOWED_USERS") {
- const parts = trimmed.split(",").map((part) => part.trim());
- if (parts.some((part) => !part)) {
- return "Slack member IDs must be comma-separated without empty entries.";
- }
+ // Mirror the gateway's parse (gateway/platforms/slack.py): drop empty
+ // entries so a trailing/interior comma isn't rejected here. "*" is the
+ // allow-all wildcard the gateway honors.
+ const parts = trimmed
+ .split(",")
+ .map((part) => part.trim())
+ .filter(Boolean);
const invalid = parts.find((part) => part !== "*" && !SLACK_MEMBER_ID_RE.test(part));
if (invalid) {
return `${invalid} does not look like a Slack member ID. Use IDs like U01ABC2DEF3.`;
From c7b7f92ec14a5c43deef844804f0bf6a7f2d992d Mon Sep 17 00:00:00 2001
From: Eurekaxun
Date: Tue, 2 Jun 2026 14:33:12 +0800
Subject: [PATCH 12/90] fix(openviking): sync structured turns with tool parts
---
plugins/memory/openviking/__init__.py | 339 +++++++++++++++++-
tests/openviking_plugin/test_openviking.py | 274 ++++++++++++++
.../memory/test_openviking_provider.py | 47 ++-
3 files changed, 639 insertions(+), 21 deletions(-)
diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py
index 7ebe6869a46..c7b05a4864c 100644
--- a/plugins/memory/openviking/__init__.py
+++ b/plugins/memory/openviking/__init__.py
@@ -70,6 +70,8 @@ _TIMEOUT = 30.0
_SESSION_DRAIN_TIMEOUT = 10.0
_DEFERRED_COMMIT_TIMEOUT = (_TIMEOUT * 2) + 5.0
_REMOTE_RESOURCE_PREFIXES = ("http://", "https://", "git@", "ssh://", "git://")
+_SYNC_TRACE_ENV = "HERMES_OPENVIKING_SYNC_TRACE"
+_OPENVIKING_RECALL_TOOL_NAMES = {"viking_search", "viking_read", "viking_browse"}
# Maps the viking_remember `category` enum to a viking:// subdirectory.
# Keep in sync with REMEMBER_SCHEMA.parameters.properties.category.enum.
@@ -156,6 +158,18 @@ def _derive_openviking_user_text(content: Any) -> str:
return extract_user_instruction_from_skill_message(content) or ""
+def _sync_trace_enabled() -> bool:
+ return os.environ.get(_SYNC_TRACE_ENV, "").strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _preview(value: Any, limit: int = 160) -> str:
+ text = "" if value is None else str(value)
+ text = text.replace("\n", "\\n")
+ if len(text) > limit:
+ return text[:limit] + "..."
+ return text
+
+
# ---------------------------------------------------------------------------
# Process-level atexit safety net — ensures pending sessions are committed
# even if shutdown_memory_provider is never called (e.g. gateway crash,
@@ -2221,7 +2235,10 @@ class OpenVikingMemoryProvider(MemoryProvider):
def _commit_session(self, sid: str, turn_count: int, *, context: str) -> bool:
try:
- self._client.post(f"/api/v1/sessions/{sid}/commit")
+ self._client.post(
+ f"/api/v1/sessions/{sid}/commit",
+ {"keep_recent_count": 0},
+ )
self._mark_session_committed(sid)
logger.info("OpenViking session %s committed %s (%d turns)", sid, context, turn_count)
return True
@@ -2293,7 +2310,261 @@ class OpenVikingMemoryProvider(MemoryProvider):
with self._prefetch_lock:
self._prefetch_result = ""
- def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
+ @staticmethod
+ def _message_text(content: Any) -> str:
+ """Extract text from OpenAI-style string/list content."""
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ chunks = []
+ for block in content:
+ if isinstance(block, str):
+ chunks.append(block)
+ elif isinstance(block, dict):
+ if block.get("type") == "text" and isinstance(block.get("text"), str):
+ chunks.append(block["text"])
+ elif isinstance(block.get("content"), str):
+ chunks.append(block["content"])
+ return "\n".join(chunk for chunk in chunks if chunk)
+ if content is None:
+ return ""
+ return str(content)
+
+ @classmethod
+ def _message_matches_text(cls, message: Dict[str, Any], expected: Any) -> bool:
+ expected_text = cls._message_text(expected).strip()
+ if not expected_text:
+ return False
+ actual_text = cls._message_text(message.get("content")).strip()
+ return actual_text == expected_text
+
+ @classmethod
+ def _extract_current_turn_messages(
+ cls,
+ messages: Optional[List[Dict[str, Any]]],
+ user_content: str,
+ assistant_content: str,
+ ) -> List[Dict[str, Any]]:
+ """Slice the completed turn out of Hermes' full canonical transcript."""
+ if not messages:
+ return []
+
+ end_idx: Optional[int] = None
+ if cls._message_text(assistant_content).strip():
+ for idx in range(len(messages) - 1, -1, -1):
+ message = messages[idx]
+ if (
+ isinstance(message, dict)
+ and message.get("role") == "assistant"
+ and cls._message_matches_text(message, assistant_content)
+ ):
+ end_idx = idx
+ break
+ if end_idx is None:
+ for idx in range(len(messages) - 1, -1, -1):
+ message = messages[idx]
+ if isinstance(message, dict) and message.get("role") == "assistant":
+ end_idx = idx
+ break
+ if end_idx is None:
+ end_idx = len(messages) - 1
+
+ start_idx: Optional[int] = None
+ if cls._message_text(user_content).strip():
+ for idx in range(end_idx, -1, -1):
+ message = messages[idx]
+ if (
+ isinstance(message, dict)
+ and message.get("role") == "user"
+ and cls._message_matches_text(message, user_content)
+ ):
+ start_idx = idx
+ break
+ if start_idx is None:
+ for idx in range(end_idx, -1, -1):
+ message = messages[idx]
+ if isinstance(message, dict) and message.get("role") == "user":
+ start_idx = idx
+ break
+ if start_idx is None:
+ return []
+
+ return [message for message in messages[start_idx : end_idx + 1] if isinstance(message, dict)]
+
+ @staticmethod
+ def _tool_call_id(tool_call: Dict[str, Any]) -> str:
+ return str(tool_call.get("id") or tool_call.get("tool_call_id") or "")
+
+ @staticmethod
+ def _tool_call_name(tool_call: Dict[str, Any]) -> str:
+ function = tool_call.get("function")
+ if isinstance(function, dict):
+ return str(function.get("name") or "")
+ return str(tool_call.get("name") or "")
+
+ @staticmethod
+ def _is_openviking_recall_tool_name(tool_name: Any) -> bool:
+ return str(tool_name or "").strip().lower() in _OPENVIKING_RECALL_TOOL_NAMES
+
+ @staticmethod
+ def _tool_call_input(tool_call: Dict[str, Any]) -> Dict[str, Any]:
+ function = tool_call.get("function")
+ raw_args: Any = None
+ if isinstance(function, dict):
+ raw_args = function.get("arguments")
+ if raw_args is None:
+ raw_args = tool_call.get("args")
+ if raw_args is None:
+ return {}
+ if isinstance(raw_args, dict):
+ return raw_args
+ if isinstance(raw_args, str):
+ if not raw_args.strip():
+ return {}
+ try:
+ parsed = json.loads(raw_args)
+ except Exception:
+ return {"value": raw_args}
+ if isinstance(parsed, dict):
+ return parsed
+ return {"value": parsed}
+ return {"value": raw_args}
+
+ @classmethod
+ def _tool_result_status(cls, message: Dict[str, Any]) -> str:
+ raw_status = str(message.get("status") or message.get("tool_status") or "").lower()
+ if raw_status in {"error", "failed", "failure"}:
+ return "error"
+ if raw_status in {"completed", "complete", "success", "succeeded"}:
+ return "completed"
+
+ text = cls._message_text(message.get("content")).strip()
+ if text:
+ try:
+ parsed = json.loads(text)
+ except Exception:
+ parsed = None
+ if isinstance(parsed, dict):
+ status = str(parsed.get("status") or "").lower()
+ exit_code = parsed.get("exit_code")
+ if (
+ status in {"error", "failed", "failure"}
+ or parsed.get("success") is False
+ or bool(parsed.get("error"))
+ or (isinstance(exit_code, int) and exit_code != 0)
+ ):
+ return "error"
+ return "completed"
+
+ @classmethod
+ def _messages_to_openviking_batch(
+ cls,
+ messages: List[Dict[str, Any]],
+ ) -> List[Dict[str, Any]]:
+ """Convert Hermes canonical messages into OpenViking batch payloads."""
+ tool_calls_by_id: Dict[str, Dict[str, Any]] = {}
+ completed_tool_ids: set[str] = set()
+ skipped_tool_ids: set[str] = set()
+ for message in messages:
+ if not isinstance(message, dict):
+ continue
+ if message.get("role") == "tool":
+ tool_id = str(message.get("tool_call_id") or message.get("id") or "")
+ if tool_id:
+ completed_tool_ids.add(tool_id)
+ if cls._is_openviking_recall_tool_name(message.get("name")):
+ skipped_tool_ids.add(tool_id)
+ continue
+ if message.get("role") != "assistant":
+ continue
+ for tool_call in message.get("tool_calls") or []:
+ if not isinstance(tool_call, dict):
+ continue
+ tool_id = cls._tool_call_id(tool_call)
+ tool_name = cls._tool_call_name(tool_call)
+ if tool_id:
+ tool_calls_by_id[tool_id] = {
+ "tool_name": tool_name,
+ "tool_input": cls._tool_call_input(tool_call),
+ }
+ if cls._is_openviking_recall_tool_name(tool_name):
+ skipped_tool_ids.add(tool_id)
+
+ payload_messages: List[Dict[str, Any]] = []
+ pending_tool_parts: List[Dict[str, Any]] = []
+
+ def flush_tool_parts() -> None:
+ nonlocal pending_tool_parts
+ if pending_tool_parts:
+ payload_messages.append({"role": "user", "parts": pending_tool_parts})
+ pending_tool_parts = []
+
+ for message in messages:
+ if not isinstance(message, dict):
+ continue
+
+ role = str(message.get("role") or "")
+ if role in {"system", "developer"}:
+ continue
+
+ if role == "tool":
+ tool_id = str(message.get("tool_call_id") or message.get("id") or "")
+ prior_call = tool_calls_by_id.get(tool_id, {})
+ tool_name = str(message.get("name") or prior_call.get("tool_name") or "")
+ if tool_id in skipped_tool_ids or cls._is_openviking_recall_tool_name(tool_name):
+ continue
+ tool_part = {
+ "type": "tool",
+ "tool_id": tool_id,
+ "tool_name": tool_name,
+ "tool_input": prior_call.get("tool_input", {}),
+ "tool_output": cls._message_text(message.get("content")),
+ "tool_status": cls._tool_result_status(message),
+ }
+ pending_tool_parts.append(tool_part)
+ continue
+
+ if role not in {"user", "assistant"}:
+ continue
+
+ flush_tool_parts()
+ parts: List[Dict[str, Any]] = []
+ text = cls._message_text(message.get("content"))
+ if text:
+ parts.append({"type": "text", "text": text})
+
+ if role == "assistant":
+ for tool_call in message.get("tool_calls") or []:
+ if not isinstance(tool_call, dict):
+ continue
+ tool_id = cls._tool_call_id(tool_call)
+ tool_name = cls._tool_call_name(tool_call)
+ if tool_id in skipped_tool_ids or cls._is_openviking_recall_tool_name(tool_name):
+ continue
+ if tool_id in completed_tool_ids:
+ continue
+ parts.append({
+ "type": "tool",
+ "tool_id": tool_id,
+ "tool_name": tool_name,
+ "tool_input": cls._tool_call_input(tool_call),
+ "tool_status": "pending",
+ })
+
+ if parts:
+ payload_messages.append({"role": role, "parts": parts})
+
+ flush_tool_parts()
+ return payload_messages
+
+ def sync_turn(
+ self,
+ user_content: str,
+ assistant_content: str,
+ *,
+ session_id: str = "",
+ messages: Optional[List[Dict[str, Any]]] = None,
+ ) -> None:
"""Record the conversation turn in OpenViking's session (non-blocking)."""
if not self._client:
return
@@ -2302,6 +2573,37 @@ class OpenVikingMemoryProvider(MemoryProvider):
if not user_content:
return
+ turn_messages = (
+ self._extract_current_turn_messages(messages, user_content, assistant_content)
+ if messages is not None
+ else []
+ )
+ if turn_messages:
+ turn_messages = [dict(message) for message in turn_messages]
+ for message in turn_messages:
+ if message.get("role") == "user":
+ message["content"] = user_content
+ break
+ batch_messages = self._messages_to_openviking_batch(turn_messages)
+
+ if _sync_trace_enabled():
+ logger.info(
+ "OpenViking sync_turn trace: session_arg=%r cached_session=%r "
+ "messages_param_supported=true messages_present=%s message_count=%s "
+ "turn_message_count=%d batch_message_count=%d user_len=%d assistant_len=%d "
+ "user_preview=%r assistant_preview=%r",
+ session_id,
+ self._session_id,
+ messages is not None,
+ len(messages) if messages is not None else None,
+ len(turn_messages),
+ len(batch_messages),
+ len(str(user_content or "")),
+ len(str(assistant_content or "")),
+ _preview(user_content),
+ _preview(assistant_content),
+ )
+
# Snapshot the sid and bump the turn counter atomically so a
# concurrent on_session_switch/on_session_end can't interleave its
# snapshot+reset between the read and the increment (lost turn) and so
@@ -2313,24 +2615,39 @@ class OpenVikingMemoryProvider(MemoryProvider):
self._turn_count += 1
def _sync():
- try:
- client = self._new_client()
+ def _post_turn(client: _VikingClient) -> None:
+ if batch_messages:
+ payload = {"messages": batch_messages}
+ if _sync_trace_enabled():
+ logger.info(
+ "OpenViking sync_turn trace: POST /api/v1/sessions/%s/messages/batch payload=%s",
+ sid,
+ json.dumps(payload, ensure_ascii=False),
+ )
+ try:
+ client.post(f"/api/v1/sessions/{sid}/messages/batch", payload)
+ return
+ except Exception as batch_error:
+ logger.warning(
+ "OpenViking structured sync failed; falling back to text sync: %s",
+ batch_error,
+ )
+
self._post_session_turn(
client,
sid,
user_content[:4000],
- assistant_content[:4000],
+ self._message_text(assistant_content)[:4000],
)
+
+ try:
+ client = self._new_client()
+ _post_turn(client)
except Exception as e:
logger.debug("OpenViking sync_turn failed, reconnecting: %s", e)
try:
client = self._new_client()
- self._post_session_turn(
- client,
- sid,
- user_content[:4000],
- assistant_content[:4000],
- )
+ _post_turn(client)
except Exception as retry_error:
logger.warning("OpenViking sync_turn failed: %s", retry_error)
diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py
index f10fc502000..ee5d1eb2373 100644
--- a/tests/openviking_plugin/test_openviking.py
+++ b/tests/openviking_plugin/test_openviking.py
@@ -265,6 +265,280 @@ class TestOpenVikingSkillQuerySafety:
assert RecordingVikingClient.calls == []
+class TestOpenVikingTurnConversion:
+ def test_extract_current_turn_anchors_on_latest_matching_user_and_assistant(self):
+ messages = [
+ {"role": "user", "content": "Please inspect the repository for assemble hooks."},
+ {"role": "assistant", "content": "Earlier answer."},
+ {"role": "user", "content": "Please inspect the repository for assemble hooks."},
+ {
+ "role": "assistant",
+ "content": "I will search the codebase.",
+ "tool_calls": [
+ {
+ "id": "call_rg_1",
+ "type": "function",
+ "function": {
+ "name": "shell_command",
+ "arguments": json.dumps({"command": "rg assemble"}),
+ },
+ }
+ ],
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_rg_1",
+ "name": "shell_command",
+ "content": "agent/context_engine.py: no preassemble hook",
+ },
+ {"role": "assistant", "content": "The current main does not expose assemble."},
+ ]
+
+ turn = OpenVikingMemoryProvider._extract_current_turn_messages(
+ messages,
+ "Please inspect the repository for assemble hooks.",
+ "The current main does not expose assemble.",
+ )
+
+ assert turn == messages[2:]
+
+ def test_messages_to_openviking_batch_coalesces_tool_results(self):
+ turn = [
+ {"role": "user", "content": "Please inspect the repository for assemble hooks."},
+ {
+ "role": "assistant",
+ "content": "I will search the codebase.",
+ "tool_calls": [
+ {
+ "id": "call_rg_1",
+ "type": "function",
+ "function": {
+ "name": "shell_command",
+ "arguments": json.dumps({"command": "rg assemble"}),
+ },
+ }
+ ],
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_rg_1",
+ "name": "shell_command",
+ "content": "agent/context_engine.py: no preassemble hook",
+ },
+ {"role": "assistant", "content": "The current main does not expose assemble."},
+ ]
+
+ batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
+
+ assert [message["role"] for message in batch] == ["user", "assistant", "user", "assistant"]
+ assert batch[0]["parts"] == [
+ {"type": "text", "text": "Please inspect the repository for assemble hooks."}
+ ]
+ assert batch[1]["parts"] == [
+ {"type": "text", "text": "I will search the codebase."}
+ ]
+ assert batch[2]["parts"] == [
+ {
+ "type": "tool",
+ "tool_id": "call_rg_1",
+ "tool_name": "shell_command",
+ "tool_input": {"command": "rg assemble"},
+ "tool_output": "agent/context_engine.py: no preassemble hook",
+ "tool_status": "completed",
+ }
+ ]
+ assert batch[3]["parts"] == [
+ {"type": "text", "text": "The current main does not expose assemble."}
+ ]
+
+ def test_messages_to_openviking_batch_marks_json_tool_error_results(self):
+ turn = [
+ {"role": "user", "content": "Check the file."},
+ {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {
+ "id": "call_read_1",
+ "type": "function",
+ "function": {
+ "name": "read_file",
+ "arguments": json.dumps({"path": "missing.md"}),
+ },
+ }
+ ],
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_read_1",
+ "name": "read_file",
+ "content": json.dumps({"error": "File not found", "exit_code": 1}),
+ },
+ ]
+
+ batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
+
+ assert batch[1]["parts"] == [
+ {
+ "type": "tool",
+ "tool_id": "call_read_1",
+ "tool_name": "read_file",
+ "tool_input": {"path": "missing.md"},
+ "tool_output": json.dumps({"error": "File not found", "exit_code": 1}),
+ "tool_status": "error",
+ }
+ ]
+
+ def test_messages_to_openviking_batch_keeps_pending_tool_call_without_result(self):
+ turn = [
+ {"role": "user", "content": "Start a long running check."},
+ {
+ "role": "assistant",
+ "content": "Starting it now.",
+ "tool_calls": [
+ {
+ "id": "call_long_1",
+ "type": "function",
+ "function": {
+ "name": "long_check",
+ "arguments": json.dumps({"target": "repo"}),
+ },
+ }
+ ],
+ },
+ ]
+
+ batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
+
+ assert batch[1]["parts"] == [
+ {"type": "text", "text": "Starting it now."},
+ {
+ "type": "tool",
+ "tool_id": "call_long_1",
+ "tool_name": "long_check",
+ "tool_input": {"target": "repo"},
+ "tool_status": "pending",
+ },
+ ]
+
+ def test_messages_to_openviking_batch_coalesces_adjacent_tool_results(self):
+ turn = [
+ {"role": "user", "content": "Run both tools."},
+ {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {
+ "id": "call_a",
+ "type": "function",
+ "function": {
+ "name": "first_tool",
+ "arguments": json.dumps({"x": 1}),
+ },
+ },
+ {
+ "id": "call_b",
+ "type": "function",
+ "function": {
+ "name": "second_tool",
+ "arguments": json.dumps({"y": 2}),
+ },
+ },
+ ],
+ },
+ {"role": "tool", "tool_call_id": "call_a", "name": "first_tool", "content": "a"},
+ {"role": "tool", "tool_call_id": "call_b", "name": "second_tool", "content": "b"},
+ {"role": "assistant", "content": "Done."},
+ ]
+
+ batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
+
+ assert [message["role"] for message in batch] == ["user", "user", "assistant"]
+ assert batch[1]["parts"] == [
+ {
+ "type": "tool",
+ "tool_id": "call_a",
+ "tool_name": "first_tool",
+ "tool_input": {"x": 1},
+ "tool_output": "a",
+ "tool_status": "completed",
+ },
+ {
+ "type": "tool",
+ "tool_id": "call_b",
+ "tool_name": "second_tool",
+ "tool_input": {"y": 2},
+ "tool_output": "b",
+ "tool_status": "completed",
+ },
+ ]
+
+ def test_messages_to_openviking_batch_skips_openviking_recall_tool_results(self):
+ for recall_tool_name in ("viking_search", "viking_read", "viking_browse"):
+ turn = [
+ {"role": "user", "content": "What did we decide about context assembly?"},
+ {
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {
+ "id": "call_recall_1",
+ "type": "function",
+ "function": {
+ "name": recall_tool_name,
+ "arguments": json.dumps({"query": "context assembly decision"}),
+ },
+ },
+ {
+ "id": "call_shell_1",
+ "type": "function",
+ "function": {
+ "name": "shell_command",
+ "arguments": json.dumps({"command": "rg preassemble"}),
+ },
+ },
+ ],
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_recall_1",
+ "name": recall_tool_name,
+ "content": json.dumps({
+ "results": [
+ {
+ "uri": "viking://user/hermes/memories/context",
+ "abstract": "Old OpenViking memory content",
+ }
+ ]
+ }),
+ },
+ {
+ "role": "tool",
+ "tool_call_id": "call_shell_1",
+ "name": "shell_command",
+ "content": "plugins/memory/openviking/__init__.py",
+ },
+ {"role": "assistant", "content": "We decided to keep sync_turn scoped to ingestion."},
+ ]
+
+ batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
+
+ assert [message["role"] for message in batch] == ["user", "user", "assistant"]
+ assert batch[1]["parts"] == [
+ {
+ "type": "tool",
+ "tool_id": "call_shell_1",
+ "tool_name": "shell_command",
+ "tool_input": {"command": "rg preassemble"},
+ "tool_output": "plugins/memory/openviking/__init__.py",
+ "tool_status": "completed",
+ }
+ ]
+ batch_text = json.dumps(batch)
+ assert recall_tool_name not in batch_text
+ assert "Old OpenViking memory content" not in batch_text
+
+
class TestOpenVikingRead:
def test_overview_read_normalizes_uri_and_unwraps_result(self):
provider = OpenVikingMemoryProvider()
diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py
index 954385fa54e..2863566b367 100644
--- a/tests/plugins/memory/test_openviking_provider.py
+++ b/tests/plugins/memory/test_openviking_provider.py
@@ -1975,7 +1975,10 @@ def test_on_session_switch_commits_old_session_and_rotates_id():
provider.on_session_switch("new-sid", parent_session_id="old-sid")
- provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit")
+ provider._client.post.assert_called_once_with(
+ "/api/v1/sessions/old-sid/commit",
+ {"keep_recent_count": 0},
+ )
assert provider._session_id == "new-sid"
assert provider._turn_count == 0
@@ -1998,7 +2001,10 @@ def test_on_session_switch_commits_pending_tokens_without_turn_count():
provider.on_session_switch("new-sid")
provider._client.get.assert_called_once_with("/api/v1/sessions/old-sid")
- provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit")
+ provider._client.post.assert_called_once_with(
+ "/api/v1/sessions/old-sid/commit",
+ {"keep_recent_count": 0},
+ )
assert provider._session_id == "new-sid"
assert provider._turn_count == 0
@@ -2051,7 +2057,10 @@ def test_on_session_switch_waits_for_inflight_sync_thread():
provider.on_session_switch("new-sid")
assert join_calls, "expected on_session_switch to join the in-flight sync thread"
- provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit")
+ provider._client.post.assert_called_once_with(
+ "/api/v1/sessions/old-sid/commit",
+ {"keep_recent_count": 0},
+ )
def test_on_session_switch_noop_on_empty_new_id():
@@ -2206,7 +2215,10 @@ def test_on_session_end_marks_session_clean_after_successful_commit():
provider.on_session_end([])
- provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit")
+ provider._client.post.assert_called_once_with(
+ "/api/v1/sessions/old-sid/commit",
+ {"keep_recent_count": 0},
+ )
assert provider._turn_count == 0
@@ -2228,7 +2240,10 @@ def test_on_session_end_commits_pending_tokens_without_turn_count():
provider.on_session_end([])
provider._client.get.assert_called_once_with("/api/v1/sessions/old-sid")
- provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit")
+ provider._client.post.assert_called_once_with(
+ "/api/v1/sessions/old-sid/commit",
+ {"keep_recent_count": 0},
+ )
def test_end_then_switch_does_not_double_commit():
@@ -2241,7 +2256,10 @@ def test_end_then_switch_does_not_double_commit():
provider.on_session_switch("new-sid", parent_session_id="old-sid")
# Exactly one commit call, on the OLD session, fired by on_session_end.
- provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit")
+ provider._client.post.assert_called_once_with(
+ "/api/v1/sessions/old-sid/commit",
+ {"keep_recent_count": 0},
+ )
assert provider._session_id == "new-sid"
assert provider._turn_count == 0
@@ -2253,7 +2271,10 @@ def test_end_then_switch_with_pending_tokens_does_not_double_commit():
provider.on_session_end([])
provider.on_session_switch("new-sid", parent_session_id="old-sid")
- provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit")
+ provider._client.post.assert_called_once_with(
+ "/api/v1/sessions/old-sid/commit",
+ {"keep_recent_count": 0},
+ )
assert provider._session_id == "new-sid"
assert provider._turn_count == 0
@@ -2400,7 +2421,10 @@ def test_on_session_switch_does_not_block_caller_on_slow_drain():
# Let the finalizer finish so it doesn't leak past the test.
release_drain.set()
assert provider._drain_finalizers(timeout=5.0)
- provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit")
+ provider._client.post.assert_called_once_with(
+ "/api/v1/sessions/old-sid/commit",
+ {"keep_recent_count": 0},
+ )
def test_on_session_switch_defers_old_commit_to_finalizer_thread():
@@ -2415,7 +2439,7 @@ def test_on_session_switch_defers_old_commit_to_finalizer_thread():
committed = threading.Event()
drain_timeouts = []
- def fake_post(path):
+ def fake_post(path, payload=None):
committed.set()
return {}
@@ -2433,7 +2457,10 @@ def test_on_session_switch_defers_old_commit_to_finalizer_thread():
assert provider._turn_count == 0
# The old-session commit lands on the finalizer thread, not inline.
assert committed.wait(timeout=5.0), "old session was not finalized off-thread"
- provider._client.post.assert_called_once_with("/api/v1/sessions/old-sid/commit")
+ provider._client.post.assert_called_once_with(
+ "/api/v1/sessions/old-sid/commit",
+ {"keep_recent_count": 0},
+ )
# The finalizer drains with the deferred (longer) budget, not inline 10s.
assert drain_timeouts == [_DEFERRED_COMMIT_TIMEOUT]
From d7cd0bc0863cda1a203f00422b1441ca2d9890ed Mon Sep 17 00:00:00 2001
From: Hao Zhe
Date: Fri, 19 Jun 2026 13:42:36 +0800
Subject: [PATCH 13/90] fix(openviking): preserve structured sync attribution
---
agent/codex_runtime.py | 1 +
agent/message_content.py | 50 +++++++++++++
plugins/memory/openviking/__init__.py | 36 +++++-----
tests/agent/test_message_content.py | 25 +++++++
tests/openviking_plugin/test_openviking.py | 36 +++++++++-
.../memory/test_openviking_provider.py | 72 +++++++++++++++++++
.../test_codex_app_server_integration.py | 13 +++-
7 files changed, 210 insertions(+), 23 deletions(-)
create mode 100644 agent/message_content.py
create mode 100644 tests/agent/test_message_content.py
diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py
index 7f175fff97f..4ff67871934 100644
--- a/agent/codex_runtime.py
+++ b/agent/codex_runtime.py
@@ -290,6 +290,7 @@ def run_codex_app_server_turn(
original_user_message=original_user_message,
final_response=turn.final_text,
interrupted=False,
+ messages=messages,
)
except Exception:
logger.debug("external memory sync raised", exc_info=True)
diff --git a/agent/message_content.py b/agent/message_content.py
new file mode 100644
index 00000000000..c42bf408550
--- /dev/null
+++ b/agent/message_content.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+
+_NON_TEXT_PART_TYPES = {"image", "image_url", "input_image", "audio", "input_audio"}
+_TEXT_KEYS = ("text", "content", "input_text", "output_text", "summary_text")
+
+
+def _field(value: Any, key: str) -> Any:
+ if isinstance(value, Mapping):
+ return value.get(key)
+ return getattr(value, key, None)
+
+
+def _text_from_part(part: Any) -> str:
+ if part is None:
+ return ""
+ if isinstance(part, str):
+ return part
+
+ part_type = str(_field(part, "type") or "").strip().lower()
+ if part_type in _NON_TEXT_PART_TYPES:
+ return ""
+
+ for key in _TEXT_KEYS:
+ text = _field(part, key)
+ if isinstance(text, str):
+ return text
+ return ""
+
+
+def flatten_message_text(content: Any, *, sep: str = "\n") -> str:
+ """Return the visible text from common chat/Responses message content shapes."""
+ if content is None:
+ return ""
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ chunks = [_text_from_part(part) for part in content]
+ return sep.join(chunk for chunk in chunks if chunk)
+
+ text = _text_from_part(content)
+ if text:
+ return text
+ try:
+ return str(content)
+ except Exception:
+ return ""
diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py
index c7b05a4864c..82f1f26a0a0 100644
--- a/plugins/memory/openviking/__init__.py
+++ b/plugins/memory/openviking/__init__.py
@@ -45,6 +45,7 @@ from typing import Any, Callable, Dict, List, Optional, Set
from urllib.parse import urlparse
from urllib.request import url2pathname
+from agent.message_content import flatten_message_text
from agent.memory_provider import MemoryProvider
from agent.skill_commands import extract_user_instruction_from_skill_message
from tools.registry import tool_error
@@ -2313,22 +2314,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
@staticmethod
def _message_text(content: Any) -> str:
"""Extract text from OpenAI-style string/list content."""
- if isinstance(content, str):
- return content
- if isinstance(content, list):
- chunks = []
- for block in content:
- if isinstance(block, str):
- chunks.append(block)
- elif isinstance(block, dict):
- if block.get("type") == "text" and isinstance(block.get("text"), str):
- chunks.append(block["text"])
- elif isinstance(block.get("content"), str):
- chunks.append(block["content"])
- return "\n".join(chunk for chunk in chunks if chunk)
- if content is None:
- return ""
- return str(content)
+ return flatten_message_text(content)
@classmethod
def _message_matches_text(cls, message: Dict[str, Any], expected: Any) -> bool:
@@ -2460,8 +2446,11 @@ class OpenVikingMemoryProvider(MemoryProvider):
def _messages_to_openviking_batch(
cls,
messages: List[Dict[str, Any]],
+ *,
+ assistant_peer_id: str = "",
) -> List[Dict[str, Any]]:
"""Convert Hermes canonical messages into OpenViking batch payloads."""
+ assistant_peer_id = str(assistant_peer_id or "").strip()
tool_calls_by_id: Dict[str, Dict[str, Any]] = {}
completed_tool_ids: set[str] = set()
skipped_tool_ids: set[str] = set()
@@ -2493,10 +2482,16 @@ class OpenVikingMemoryProvider(MemoryProvider):
payload_messages: List[Dict[str, Any]] = []
pending_tool_parts: List[Dict[str, Any]] = []
+ def payload_message(role: str, parts: List[Dict[str, Any]]) -> Dict[str, Any]:
+ payload: Dict[str, Any] = {"role": role, "parts": parts}
+ if role == "assistant" and assistant_peer_id:
+ payload["peer_id"] = assistant_peer_id
+ return payload
+
def flush_tool_parts() -> None:
nonlocal pending_tool_parts
if pending_tool_parts:
- payload_messages.append({"role": "user", "parts": pending_tool_parts})
+ payload_messages.append(payload_message("assistant", pending_tool_parts))
pending_tool_parts = []
for message in messages:
@@ -2552,7 +2547,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
})
if parts:
- payload_messages.append({"role": role, "parts": parts})
+ payload_messages.append(payload_message(role, parts))
flush_tool_parts()
return payload_messages
@@ -2584,7 +2579,10 @@ class OpenVikingMemoryProvider(MemoryProvider):
if message.get("role") == "user":
message["content"] = user_content
break
- batch_messages = self._messages_to_openviking_batch(turn_messages)
+ batch_messages = self._messages_to_openviking_batch(
+ turn_messages,
+ assistant_peer_id=getattr(self, "_agent", _DEFAULT_AGENT),
+ )
if _sync_trace_enabled():
logger.info(
diff --git a/tests/agent/test_message_content.py b/tests/agent/test_message_content.py
new file mode 100644
index 00000000000..0207d63600b
--- /dev/null
+++ b/tests/agent/test_message_content.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from agent.message_content import flatten_message_text
+
+
+def test_flatten_message_text_accepts_chat_and_responses_text_parts():
+ content = [
+ {"type": "text", "text": "chat text"},
+ {"type": "input_text", "text": "user text"},
+ {"type": "output_text", "text": "assistant text"},
+ {"type": "summary_text", "text": "summary text"},
+ ]
+
+ assert flatten_message_text(content) == "chat text\nuser text\nassistant text\nsummary text"
+
+
+def test_flatten_message_text_accepts_object_parts():
+ content = [
+ SimpleNamespace(type="output_text", text="object text"),
+ {"content": "legacy content"},
+ ]
+
+ assert flatten_message_text(content) == "object text\nlegacy content"
diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py
index ee5d1eb2373..3a743287672 100644
--- a/tests/openviking_plugin/test_openviking.py
+++ b/tests/openviking_plugin/test_openviking.py
@@ -330,7 +330,7 @@ class TestOpenVikingTurnConversion:
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
- assert [message["role"] for message in batch] == ["user", "assistant", "user", "assistant"]
+ assert [message["role"] for message in batch] == ["user", "assistant", "assistant", "assistant"]
assert batch[0]["parts"] == [
{"type": "text", "text": "Please inspect the repository for assemble hooks."}
]
@@ -378,6 +378,7 @@ class TestOpenVikingTurnConversion:
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
+ assert batch[1]["role"] == "assistant"
assert batch[1]["parts"] == [
{
"type": "tool",
@@ -453,7 +454,7 @@ class TestOpenVikingTurnConversion:
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
- assert [message["role"] for message in batch] == ["user", "user", "assistant"]
+ assert [message["role"] for message in batch] == ["user", "assistant", "assistant"]
assert batch[1]["parts"] == [
{
"type": "tool",
@@ -523,7 +524,7 @@ class TestOpenVikingTurnConversion:
batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
- assert [message["role"] for message in batch] == ["user", "user", "assistant"]
+ assert [message["role"] for message in batch] == ["user", "assistant", "assistant"]
assert batch[1]["parts"] == [
{
"type": "tool",
@@ -538,6 +539,35 @@ class TestOpenVikingTurnConversion:
assert recall_tool_name not in batch_text
assert "Old OpenViking memory content" not in batch_text
+ def test_messages_to_openviking_batch_preserves_responses_text_parts(self):
+ turn = [
+ {"role": "user", "content": [{"type": "input_text", "text": "hello"}]},
+ {"role": "assistant", "content": [{"type": "output_text", "text": "answer"}]},
+ ]
+
+ batch = OpenVikingMemoryProvider._messages_to_openviking_batch(turn)
+
+ assert batch == [
+ {"role": "user", "parts": [{"type": "text", "text": "hello"}]},
+ {"role": "assistant", "parts": [{"type": "text", "text": "answer"}]},
+ ]
+
+ def test_messages_to_openviking_batch_adds_assistant_peer_id_when_requested(self):
+ turn = [
+ {"role": "user", "content": "hello"},
+ {"role": "assistant", "content": "answer"},
+ ]
+
+ batch = OpenVikingMemoryProvider._messages_to_openviking_batch(
+ turn,
+ assistant_peer_id="hermes",
+ )
+
+ assert batch == [
+ {"role": "user", "parts": [{"type": "text", "text": "hello"}]},
+ {"role": "assistant", "parts": [{"type": "text", "text": "answer"}], "peer_id": "hermes"},
+ ]
+
class TestOpenVikingRead:
def test_overview_read_normalizes_uri_and_unwraps_result(self):
diff --git a/tests/plugins/memory/test_openviking_provider.py b/tests/plugins/memory/test_openviking_provider.py
index 2863566b367..28f2d8e9d46 100644
--- a/tests/plugins/memory/test_openviking_provider.py
+++ b/tests/plugins/memory/test_openviking_provider.py
@@ -2195,6 +2195,78 @@ def test_sync_turn_retries_batch_write_with_fresh_client():
)]
+def test_sync_turn_structured_messages_include_assistant_peer_id():
+ provider = OpenVikingMemoryProvider()
+ provider._client = MagicMock()
+ provider._endpoint = "http://test"
+ provider._api_key = ""
+ provider._account = "acct"
+ provider._user = "usr"
+ provider._agent = "hermes"
+ provider._session_id = "sid-structured"
+
+ captured = []
+
+ class StubClient:
+ def __init__(self, *a, **kw):
+ pass
+
+ def post(self, path, payload=None, **kwargs):
+ captured.append((path, payload))
+ return {}
+
+ import plugins.memory.openviking as _mod
+
+ real_client_cls = _mod._VikingClient
+ _mod._VikingClient = StubClient
+ messages = [
+ {"role": "user", "content": [{"type": "input_text", "text": "u"}]},
+ {
+ "role": "assistant",
+ "content": "Looking.",
+ "tool_calls": [
+ {
+ "id": "call-1",
+ "type": "function",
+ "function": {"name": "shell_command", "arguments": json.dumps({"cmd": "pwd"})},
+ }
+ ],
+ },
+ {"role": "tool", "tool_call_id": "call-1", "name": "shell_command", "content": "ok"},
+ {"role": "assistant", "content": [{"type": "output_text", "text": "a"}]},
+ ]
+ try:
+ provider.sync_turn("u", "a", messages=messages)
+ assert provider._drain_writers("sid-structured", timeout=2.0)
+ finally:
+ _mod._VikingClient = real_client_cls
+
+ assert captured == [(
+ "/api/v1/sessions/sid-structured/messages/batch",
+ {
+ "messages": [
+ {"role": "user", "parts": [{"type": "text", "text": "u"}]},
+ {"role": "assistant", "parts": [{"type": "text", "text": "Looking."}], "peer_id": "hermes"},
+ {
+ "role": "assistant",
+ "parts": [
+ {
+ "type": "tool",
+ "tool_id": "call-1",
+ "tool_name": "shell_command",
+ "tool_input": {"cmd": "pwd"},
+ "tool_output": "ok",
+ "tool_status": "completed",
+ }
+ ],
+ "peer_id": "hermes",
+ },
+ {"role": "assistant", "parts": [{"type": "text", "text": "a"}], "peer_id": "hermes"},
+ ]
+ },
+ )]
+
+
def test_sync_turn_noop_when_session_id_blank():
provider = OpenVikingMemoryProvider()
provider._client = MagicMock()
diff --git a/tests/run_agent/test_codex_app_server_integration.py b/tests/run_agent/test_codex_app_server_integration.py
index 14c058178b9..b0d2ec23861 100644
--- a/tests/run_agent/test_codex_app_server_integration.py
+++ b/tests/run_agent/test_codex_app_server_integration.py
@@ -12,7 +12,7 @@ Verifies that:
from __future__ import annotations
-from unittest.mock import patch
+from unittest.mock import MagicMock, patch
import pytest
@@ -148,6 +148,17 @@ class TestRunConversationCodexPath:
and m.get("content") == "echo: hello"]
assert final, f"expected final assistant message in {msgs}"
+ def test_projected_messages_are_synced_to_external_memory(self, fake_session):
+ agent = _make_codex_agent()
+ agent._memory_manager = MagicMock()
+ agent._memory_manager.build_system_prompt.return_value = ""
+
+ with patch.object(agent, "_spawn_background_review", return_value=None):
+ result = agent.run_conversation("hello")
+
+ agent._memory_manager.sync_all.assert_called_once()
+ assert agent._memory_manager.sync_all.call_args.kwargs["messages"] == result["messages"]
+
def test_nudge_counters_tick(self, fake_session):
"""The skill nudge counter must accumulate tool_iterations across
turns. The memory nudge counter is gated on memory being configured
From 15e3b64b7538bb0a38e4bfd91d9c8a4f8110ce8f Mon Sep 17 00:00:00 2001
From: Shannon Sands
Date: Fri, 19 Jun 2026 11:25:05 +1000
Subject: [PATCH 14/90] fix(tui): keep hosted dashboard chat alive on exit
---
.../src/__tests__/createSlashHandler.test.ts | 30 +++++++++++++++++++
ui-tui/src/app/slash/commands/core.ts | 24 ++++++++++++++-
2 files changed, 53 insertions(+), 1 deletion(-)
diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts
index a671063e5e9..c0247795af3 100644
--- a/ui-tui/src/__tests__/createSlashHandler.test.ts
+++ b/ui-tui/src/__tests__/createSlashHandler.test.ts
@@ -9,6 +9,10 @@ describe('createSlashHandler', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
+ delete process.env.HERMES_TUI_INLINE
+ delete process.env.HERMES_HOME
+ delete process.env.HERMES_WRITE_SAFE_ROOT
+ delete process.env.HERMES_DISABLE_LAZY_INSTALLS
})
it('opens the unified sessions overlay for /resume', () => {
@@ -68,6 +72,32 @@ describe('createSlashHandler', () => {
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
})
+ it('keeps hosted dashboard chat alive for /exit', () => {
+ process.env.HERMES_TUI_INLINE = '1'
+ process.env.HERMES_HOME = '/opt/data/profiles/worker'
+ process.env.HERMES_WRITE_SAFE_ROOT = '/opt/data'
+ process.env.HERMES_DISABLE_LAZY_INSTALLS = '1'
+ const ctx = buildCtx()
+
+ expect(createSlashHandler(ctx)('/exit')).toBe(true)
+ expect(ctx.session.die).not.toHaveBeenCalled()
+ expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
+ expect(ctx.transcript.sys).toHaveBeenCalledWith(
+ 'exit is disabled in hosted dashboard chat — use /new to start a fresh session'
+ )
+ })
+
+ it('keeps /quit available outside hosted dashboard chat', () => {
+ process.env.HERMES_TUI_INLINE = '1'
+ process.env.HERMES_HOME = '/Users/example/.hermes'
+ process.env.HERMES_WRITE_SAFE_ROOT = '/Users/example/.hermes'
+ process.env.HERMES_DISABLE_LAZY_INSTALLS = '1'
+ const ctx = buildCtx()
+
+ expect(createSlashHandler(ctx)('/quit')).toBe(true)
+ expect(ctx.session.die).toHaveBeenCalledTimes(1)
+ })
+
it('handles /update locally and exits with code 42 via dieWithCode', () => {
vi.useFakeTimers()
const ctx = buildCtx()
diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts
index 5c021dbcdf9..b5d72cf7712 100644
--- a/ui-tui/src/app/slash/commands/core.ts
+++ b/ui-tui/src/app/slash/commands/core.ts
@@ -76,6 +76,20 @@ const DETAILS_USAGE =
const DETAILS_SECTION_USAGE = 'usage: /details [hidden|collapsed|expanded|reset]'
+const truthyEnv = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
+
+const hostedInlineDashboardChat = () => {
+ const hermesHome = (process.env.HERMES_HOME ?? '').trim()
+ const hostedHome = hermesHome === '/opt/data' || hermesHome.startsWith('/opt/data/')
+
+ return (
+ process.env.HERMES_TUI_INLINE === '1' &&
+ hostedHome &&
+ process.env.HERMES_WRITE_SAFE_ROOT === '/opt/data' &&
+ truthyEnv(process.env.HERMES_DISABLE_LAZY_INSTALLS)
+ )
+}
+
export const coreCommands: SlashCommand[] = [
{
help: 'list commands + hotkeys',
@@ -113,7 +127,15 @@ export const coreCommands: SlashCommand[] = [
aliases: ['exit'],
help: 'exit hermes',
name: 'quit',
- run: (_arg, ctx) => ctx.session.die()
+ run: (_arg, ctx) => {
+ if (hostedInlineDashboardChat()) {
+ ctx.transcript.sys('exit is disabled in hosted dashboard chat — use /new to start a fresh session')
+
+ return
+ }
+
+ ctx.session.die()
+ }
},
{
From 3f0e9849e7a2753931ef32c624cae33a7461e653 Mon Sep 17 00:00:00 2001
From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com>
Date: Fri, 19 Jun 2026 12:29:19 +0530
Subject: [PATCH 15/90] refactor(tui): reuse DASHBOARD_TUI_MODE for hosted
/exit guard
Follow-up to the salvaged hosted /exit fix. Instead of a separate 4-env-var
fingerprint (HERMES_TUI_INLINE + /opt/data HERMES_HOME + HERMES_WRITE_SAFE_ROOT
+ HERMES_DISABLE_LAZY_INSTALLS), gate /exit and /quit on the existing
DASHBOARD_TUI_MODE flag (HERMES_TUI_DASHBOARD) that the keyboard idle-exit
(useInputHandlers) and SIGINT-ignore (entry.tsx) paths already use. One hosted
detection mechanism instead of two divergent ones.
Extract the refusal text to an exported DASHBOARD_EXIT_DISABLED_MESSAGE so the
test asserts the same source of truth as production (no change-detector on the
literal). Test mocks only the DASHBOARD_TUI_MODE export via importActual so the
other env exports stay real.
---
.../src/__tests__/createSlashHandler.test.ts | 35 +++++++++++--------
ui-tui/src/app/slash/commands/core.ts | 30 ++++++++--------
2 files changed, 34 insertions(+), 31 deletions(-)
diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts
index c0247795af3..415dd4c0f3c 100644
--- a/ui-tui/src/__tests__/createSlashHandler.test.ts
+++ b/ui-tui/src/__tests__/createSlashHandler.test.ts
@@ -2,17 +2,30 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSlashHandler } from '../app/createSlashHandler.js'
import { getOverlayState, resetOverlayState } from '../app/overlayStore.js'
+import { DASHBOARD_EXIT_DISABLED_MESSAGE } from '../app/slash/commands/core.js'
import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js'
import { TUI_SESSION_MODEL_FLAG } from '../domain/slash.js'
+// DASHBOARD_TUI_MODE resolves once at module load from HERMES_TUI_DASHBOARD,
+// so toggling process.env in a test body can't move it. Mock just that one
+// export (everything else stays real) and flip the holder per test.
+const envState = { dashboardTuiMode: false }
+vi.mock('../config/env.js', async importActual => {
+ const actual = await importActual()
+
+ return {
+ ...actual,
+ get DASHBOARD_TUI_MODE() {
+ return envState.dashboardTuiMode
+ }
+ }
+})
+
describe('createSlashHandler', () => {
beforeEach(() => {
resetOverlayState()
resetUiState()
- delete process.env.HERMES_TUI_INLINE
- delete process.env.HERMES_HOME
- delete process.env.HERMES_WRITE_SAFE_ROOT
- delete process.env.HERMES_DISABLE_LAZY_INSTALLS
+ envState.dashboardTuiMode = false
})
it('opens the unified sessions overlay for /resume', () => {
@@ -73,25 +86,17 @@ describe('createSlashHandler', () => {
})
it('keeps hosted dashboard chat alive for /exit', () => {
- process.env.HERMES_TUI_INLINE = '1'
- process.env.HERMES_HOME = '/opt/data/profiles/worker'
- process.env.HERMES_WRITE_SAFE_ROOT = '/opt/data'
- process.env.HERMES_DISABLE_LAZY_INSTALLS = '1'
+ envState.dashboardTuiMode = true
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/exit')).toBe(true)
expect(ctx.session.die).not.toHaveBeenCalled()
expect(ctx.gateway.gw.request).not.toHaveBeenCalled()
- expect(ctx.transcript.sys).toHaveBeenCalledWith(
- 'exit is disabled in hosted dashboard chat — use /new to start a fresh session'
- )
+ expect(ctx.transcript.sys).toHaveBeenCalledWith(DASHBOARD_EXIT_DISABLED_MESSAGE)
})
it('keeps /quit available outside hosted dashboard chat', () => {
- process.env.HERMES_TUI_INLINE = '1'
- process.env.HERMES_HOME = '/Users/example/.hermes'
- process.env.HERMES_WRITE_SAFE_ROOT = '/Users/example/.hermes'
- process.env.HERMES_DISABLE_LAZY_INSTALLS = '1'
+ envState.dashboardTuiMode = false
const ctx = buildCtx()
expect(createSlashHandler(ctx)('/quit')).toBe(true)
diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts
index b5d72cf7712..7c5a79505ad 100644
--- a/ui-tui/src/app/slash/commands/core.ts
+++ b/ui-tui/src/app/slash/commands/core.ts
@@ -1,6 +1,6 @@
import { forceRedraw, type MouseTrackingMode } from '@hermes/ink'
-import { NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
+import { DASHBOARD_TUI_MODE, NO_CONFIRM_DESTRUCTIVE } from '../../../config/env.js'
import { dailyFortune, randomFortune } from '../../../content/fortunes.js'
import { HOTKEYS } from '../../../content/hotkeys.js'
import { isSectionName, nextDetailsMode, parseDetailsMode, SECTION_NAMES } from '../../../domain/details.js'
@@ -76,19 +76,10 @@ const DETAILS_USAGE =
const DETAILS_SECTION_USAGE = 'usage: /details [hidden|collapsed|expanded|reset]'
-const truthyEnv = (v?: string) => /^(?:1|true|yes|on)$/i.test((v ?? '').trim())
-
-const hostedInlineDashboardChat = () => {
- const hermesHome = (process.env.HERMES_HOME ?? '').trim()
- const hostedHome = hermesHome === '/opt/data' || hermesHome.startsWith('/opt/data/')
-
- return (
- process.env.HERMES_TUI_INLINE === '1' &&
- hostedHome &&
- process.env.HERMES_WRITE_SAFE_ROOT === '/opt/data' &&
- truthyEnv(process.env.HERMES_DISABLE_LAZY_INSTALLS)
- )
-}
+// Shown when /exit or /quit is refused in the hosted dashboard chat. Kept as a
+// constant so the test asserts against the same source of truth as production.
+export const DASHBOARD_EXIT_DISABLED_MESSAGE =
+ 'exit is disabled in hosted dashboard chat — use /new to start a fresh session'
export const coreCommands: SlashCommand[] = [
{
@@ -128,8 +119,15 @@ export const coreCommands: SlashCommand[] = [
help: 'exit hermes',
name: 'quit',
run: (_arg, ctx) => {
- if (hostedInlineDashboardChat()) {
- ctx.transcript.sys('exit is disabled in hosted dashboard chat — use /new to start a fresh session')
+ // In the hosted dashboard chat there is no in-page restart path after
+ // the PTY child exits, so quitting bricks the tab until a refresh. The
+ // keyboard idle-exit (Ctrl+C / Ctrl+D) and SIGINT handling already refuse
+ // to die in this mode (see useInputHandlers + entry.tsx); gate /exit and
+ // /quit on the same DASHBOARD_TUI_MODE flag. Unlike the keyboard path
+ // (which auto-starts a fresh chat), the explicit quit command refuses and
+ // instructs the user to run /new themselves.
+ if (DASHBOARD_TUI_MODE) {
+ ctx.transcript.sys(DASHBOARD_EXIT_DISABLED_MESSAGE)
return
}
From 5a856bdfa355bb45330a23ecb63abdf9b810e865 Mon Sep 17 00:00:00 2001
From: Hao Zhe
Date: Fri, 19 Jun 2026 15:38:25 +0800
Subject: [PATCH 16/90] chore(release): add OpenViking contributor attribution
---
scripts/release.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/scripts/release.py b/scripts/release.py
index 6c5d33ec3a1..4e5f8844439 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -1577,6 +1577,7 @@ AUTHOR_MAP = {
"sunsky.lau@gmail.com": "liuhao1024", # PR #45494 salvage (claim session slot before auto-resume task; #45456)
"andrewdmwalker@gmail.com": "capt-marbles", # PR #38440 salvage (resolve xAI OAuth credentials across profiles; #43589)
"infinitycrew39@gmail.com": "infinitycrew39", # PR #47945 salvage (scope langfuse trace state by turn/request ids; #48292)
+ "eurekaxun@163.com": "huangxun375-stack", # PR #37251 / #48894 structured OpenViking sync
}
From 9362ce2575e00f5a795285b74e79d54c02e1326c Mon Sep 17 00:00:00 2001
From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com>
Date: Fri, 19 Jun 2026 13:32:31 +0530
Subject: [PATCH 17/90] feat(skills): add html-artifact skill, fold in sketch +
architecture-diagram + concept-diagrams (#48899)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(skills): add html-artifact skill, fold in sketch + architecture-diagram + concept-diagrams
Adds a unified `html-artifact` creative skill that produces self-contained,
single-file HTML artifacts — concept explainers, implementation plans,
status/incident reports, code-review walkthroughs, technical + educational
SVG diagrams, multi-variant design comparisons, and throwaway editors that
export their state back to the clipboard. Grounded in Anthropic's
html-effectiveness gallery (MIT); the house style (token block, serif/sans/
mono split, hand-rolled diffs, inline-SVG diagrams, graceful degradation) is
distilled from reading all 20 reference files.
Supersedes and removes three overlapping skills, folding their unique value in:
- sketch -> the fidelity dial (throwaway vs presentation) + the
multi-variant comparison layouts + the browser-vision
verify loop (references/fidelity-and-verify.md)
- architecture-diagram-> the dark "infra" token variant + double-rect masking +
semantic component palette (references/dark-tech.md,
templates/diagram.html infra mode)
- concept-diagrams -> the 9-ramp educational color system + the concept
archetype library (references/concept-archetypes.md,
the light design system in templates/diagram.html)
Structure:
- SKILL.md (description exactly 60 chars), 6 references, 3 templates
- templates verified by headless-Chrome render + vision inspection
- editor export logic (file://-safe clipboard, Promise-normalized) verified in node
Cross-references updated in claude-design (new disambiguation table row drawing
the design-taste vs information-artifact boundary), design-md, pretext, spike,
and kanban-video-orchestrator. Website skill docs + catalogs regenerated;
stale EN/zh-Hans per-skill pages pruned and i18n cross-refs fixed.
Not folded (intentionally orthogonal): excalidraw (.excalidraw JSON), p5js
(generative canvas), claude-design / popular-web-designs / design-md (visual
design taste / brand vocab / token spec).
* feat(skills): ship html-effectiveness gallery as fetched reference examples
Add scripts/fetch-examples.sh (idempotent clone/pull of Anthropic's MIT
html-effectiveness gallery) + references/examples.md mapping each of the 20
example files to a mode so the agent reads the right worked example. The clone
lands in references/examples/ and is gitignored (it's a 384KB upstream repo,
not vendored). SKILL.md workflow + reference list now point at it; falls back to
the distilled pattern references when offline.
* feat(skills): make reading a gallery example a required authoring step
Reading the matching html-effectiveness example is now workflow step 2 (was an
optional aside in step 3): fetch the gallery, read_file the file for your mode,
mirror its structure. Models skip optional steps; the examples are the ground
truth, so consulting one is mandatory. Added an 'Example' column to the
mode->build quick-reference table and a 'don't skip the example' pitfall.
Also dogfooded the skill: read 03-code-review-pr.html and 13-flowchart-diagram.html
raw and reconciled the distilled references against source — aligned diff-row tint
opacity to the source's 0.15 (was 0.18) and added the .ctx/.hunk rows in
house-style.md + base.html so they match 03-code-review-pr.html verbatim.
* docs(skills): explain the consolidation + bundled-vs-optional rationale
The supersession note only stated *what* was folded, not *why* the prune is
sound. Expand SKILL.md's intro into a 'Why this skill exists' section: the three
former skills emitted the same artifact and overlapped, so consolidating removes
which-one-do-I-load ambiguity; and the optional->bundled promotion of
concept-diagrams is footprint-safe because this skill has zero deps (only cost is
the 60-char description; everything else is progressive-disclosure). States the
bundling dividing line explicitly: zero install cost + broadly useful gets
bundled, real install cost (hyperframes: Node+FFmpeg+Chromium) stays optional.
Regenerated website per-skill page to match.
---
.../creative/concept-diagrams/SKILL.md | 362 -----------------
.../apartment-floor-plan-conversion.md | 244 -----------
.../examples/automated-password-reset-flow.md | 276 -------------
.../autonomous-llm-research-agent-flow.md | 240 -----------
.../banana-journey-tree-to-smoothie.md | 161 --------
.../examples/commercial-aircraft-structure.md | 209 ----------
.../examples/cpu-ooo-microarchitecture.md | 236 -----------
.../examples/electricity-grid-flow.md | 182 ---------
.../feature-film-production-pipeline.md | 172 --------
.../hospital-emergency-department-flow.md | 165 --------
.../ml-benchmark-grouped-bar-chart.md | 114 ------
.../examples/place-order-uml-sequence.md | 325 ---------------
.../examples/smart-city-infrastructure.md | 173 --------
.../examples/smartphone-layer-anatomy.md | 154 -------
.../examples/sn2-reaction-mechanism.md | 247 ------------
.../examples/wind-turbine-structure.md | 338 ----------------
.../references/dashboard-patterns.md | 43 --
.../references/infrastructure-patterns.md | 144 -------
.../references/physical-shape-cookbook.md | 42 --
.../concept-diagrams/templates/template.html | 174 --------
.../kanban-video-orchestrator/SKILL.md | 2 +-
.../references/intake.md | 3 +-
.../references/role-archetypes.md | 5 +-
.../references/tool-matrix.md | 4 +-
skills/creative/architecture-diagram/SKILL.md | 148 -------
.../templates/template.html | 319 ---------------
skills/creative/claude-design/SKILL.md | 12 +-
skills/creative/design-md/SKILL.md | 2 +-
skills/creative/html-artifact/SKILL.md | 184 +++++++++
.../html-artifact/references/.gitignore | 3 +
.../references/concept-archetypes.md | 94 +++++
.../html-artifact/references/dark-tech.md | 92 +++++
.../html-artifact/references/examples.md | 64 +++
.../references/fidelity-and-verify.md | 78 ++++
.../html-artifact/references/house-style.md | 179 +++++++++
.../html-artifact/references/svg-diagrams.md | 123 ++++++
.../references/throwaway-editors.md | 114 ++++++
.../html-artifact/scripts/fetch-examples.sh | 43 ++
.../html-artifact/templates/base.html | 104 +++++
.../html-artifact/templates/diagram.html | 127 ++++++
.../html-artifact/templates/editor.html | 120 ++++++
skills/creative/pretext/SKILL.md | 2 +-
skills/creative/sketch/SKILL.md | 218 ----------
skills/software-development/spike/SKILL.md | 2 +-
.../docs/reference/optional-skills-catalog.md | 1 -
website/docs/reference/skills-catalog.md | 3 +-
.../autonomous-ai-agents-hermes-agent.md | 4 +-
.../creative/creative-architecture-diagram.md | 165 --------
.../creative/creative-claude-design.md | 12 +-
.../bundled/creative/creative-design-md.md | 2 +-
.../creative/creative-html-artifact.md | 202 ++++++++++
.../bundled/creative/creative-pretext.md | 2 +-
.../bundled/creative/creative-sketch.md | 238 -----------
.../creative/creative-touchdesigner-mcp.md | 2 +-
.../skills/bundled/email/email-himalaya.md | 5 +
.../bundled/github/github-github-auth.md | 4 +-
.../github/github-github-code-review.md | 4 +-
.../bundled/github/github-github-issues.md | 4 +-
.../github/github-github-pr-workflow.md | 4 +-
.../github/github-github-repo-management.md | 4 +-
.../skills/bundled/media/media-gif-search.md | 2 +-
.../note-taking/note-taking-obsidian.md | 2 +-
.../productivity/productivity-airtable.md | 4 +-
.../productivity/productivity-notion.md | 4 +-
.../productivity-teams-meeting-pipeline.md | 2 +-
.../bundled/research/research-llm-wiki.md | 2 +-
.../research-research-paper-writing.md | 2 +-
...tware-development-node-inspect-debugger.md | 2 +-
.../software-development-python-debugpy.md | 2 +-
.../software-development-spike.md | 2 +-
.../autonomous-ai-agents-honcho.md | 4 +-
.../blockchain/blockchain-hyperliquid.md | 4 +-
.../creative/creative-concept-diagrams.md | 379 ------------------
.../creative-kanban-video-orchestrator.md | 4 +-
.../optional/devops/devops-pinggy-tunnel.md | 2 +-
.../skills/optional/devops/devops-watchers.md | 2 +-
.../skills/optional/mcp/mcp-fastmcp.md | 2 +-
.../payments/payments-stripe-projects.md | 2 +-
.../productivity/productivity-canvas.md | 2 +-
.../productivity/productivity-shopify.md | 2 +-
.../productivity/productivity-siyuan.md | 2 +-
.../productivity/productivity-telephony.md | 8 +-
.../research/research-gitnexus-explorer.md | 2 +-
.../skills/optional/research/research-qmd.md | 2 +-
.../optional/security/security-1password.md | 2 +-
.../optional/security/security-godmode.md | 2 +-
...software-development-rest-graphql-debug.md | 2 +-
.../reference/optional-skills-catalog.md | 1 -
.../current/reference/skills-catalog.md | 2 -
.../creative/creative-architecture-diagram.md | 165 --------
.../creative/creative-claude-design.md | 2 +-
.../bundled/creative/creative-design-md.md | 2 +-
.../bundled/creative/creative-pretext.md | 2 +-
.../bundled/creative/creative-sketch.md | 238 -----------
.../software-development-spike.md | 2 +-
.../creative/creative-concept-diagrams.md | 379 ------------------
.../creative-kanban-video-orchestrator.md | 2 +-
website/sidebars.ts | 5 +-
98 files changed, 1610 insertions(+), 6336 deletions(-)
delete mode 100644 optional-skills/creative/concept-diagrams/SKILL.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/autonomous-llm-research-agent-flow.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/banana-journey-tree-to-smoothie.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/commercial-aircraft-structure.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/cpu-ooo-microarchitecture.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/electricity-grid-flow.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/feature-film-production-pipeline.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/hospital-emergency-department-flow.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/ml-benchmark-grouped-bar-chart.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/place-order-uml-sequence.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/smart-city-infrastructure.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/smartphone-layer-anatomy.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/sn2-reaction-mechanism.md
delete mode 100644 optional-skills/creative/concept-diagrams/examples/wind-turbine-structure.md
delete mode 100644 optional-skills/creative/concept-diagrams/references/dashboard-patterns.md
delete mode 100644 optional-skills/creative/concept-diagrams/references/infrastructure-patterns.md
delete mode 100644 optional-skills/creative/concept-diagrams/references/physical-shape-cookbook.md
delete mode 100644 optional-skills/creative/concept-diagrams/templates/template.html
delete mode 100644 skills/creative/architecture-diagram/SKILL.md
delete mode 100644 skills/creative/architecture-diagram/templates/template.html
create mode 100644 skills/creative/html-artifact/SKILL.md
create mode 100644 skills/creative/html-artifact/references/.gitignore
create mode 100644 skills/creative/html-artifact/references/concept-archetypes.md
create mode 100644 skills/creative/html-artifact/references/dark-tech.md
create mode 100644 skills/creative/html-artifact/references/examples.md
create mode 100644 skills/creative/html-artifact/references/fidelity-and-verify.md
create mode 100644 skills/creative/html-artifact/references/house-style.md
create mode 100644 skills/creative/html-artifact/references/svg-diagrams.md
create mode 100644 skills/creative/html-artifact/references/throwaway-editors.md
create mode 100755 skills/creative/html-artifact/scripts/fetch-examples.sh
create mode 100644 skills/creative/html-artifact/templates/base.html
create mode 100644 skills/creative/html-artifact/templates/diagram.html
create mode 100644 skills/creative/html-artifact/templates/editor.html
delete mode 100644 skills/creative/sketch/SKILL.md
delete mode 100644 website/docs/user-guide/skills/bundled/creative/creative-architecture-diagram.md
create mode 100644 website/docs/user-guide/skills/bundled/creative/creative-html-artifact.md
delete mode 100644 website/docs/user-guide/skills/bundled/creative/creative-sketch.md
delete mode 100644 website/docs/user-guide/skills/optional/creative/creative-concept-diagrams.md
delete mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-architecture-diagram.md
delete mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/creative/creative-sketch.md
delete mode 100644 website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/optional/creative/creative-concept-diagrams.md
diff --git a/optional-skills/creative/concept-diagrams/SKILL.md b/optional-skills/creative/concept-diagrams/SKILL.md
deleted file mode 100644
index 6017d4fd121..00000000000
--- a/optional-skills/creative/concept-diagrams/SKILL.md
+++ /dev/null
@@ -1,362 +0,0 @@
----
-name: concept-diagrams
-description: Generate flat, minimal light/dark-aware SVG diagrams as standalone HTML files, using a unified educational visual language with 9 semantic color ramps, sentence-case typography, and automatic dark mode. Best suited for educational and non-software visuals — physics setups, chemistry mechanisms, math curves, physical objects (aircraft, turbines, smartphones, mechanical watches), anatomy, floor plans, cross-sections, narrative journeys (lifecycle of X, process of Y), hub-spoke system integrations (smart city, IoT), and exploded layer views. If a more specialized skill exists for the subject (dedicated software/cloud architecture, hand-drawn sketches, animated explainers, etc.), prefer that — otherwise this skill can also serve as a general-purpose SVG diagram fallback with a clean educational look. Ships with 15 example diagrams.
-version: 0.1.0
-author: v1k22 (original PR), ported into hermes-agent
-license: MIT
-dependencies: []
-platforms: [linux, macos, windows]
-metadata:
- hermes:
- tags: [diagrams, svg, visualization, education, physics, chemistry, engineering]
- related_skills: [architecture-diagram, excalidraw, generative-widgets]
----
-
-# Concept Diagrams
-
-Generate production-quality SVG diagrams with a unified flat, minimal design system. Output is a single self-contained HTML file that renders identically in any modern browser, with automatic light/dark mode.
-
-## Scope
-
-**Best suited for:**
-- Physics setups, chemistry mechanisms, math curves, biology
-- Physical objects (aircraft, turbines, smartphones, mechanical watches, cells)
-- Anatomy, cross-sections, exploded layer views
-- Floor plans, architectural conversions
-- Narrative journeys (lifecycle of X, process of Y)
-- Hub-spoke system integrations (smart city, IoT networks, electricity grids)
-- Educational / textbook-style visuals in any domain
-- Quantitative charts (grouped bars, energy profiles)
-
-**Look elsewhere first for:**
-- Dedicated software / cloud infrastructure architecture with a dark tech aesthetic (consider `architecture-diagram` if available)
-- Hand-drawn whiteboard sketches (consider `excalidraw` if available)
-- Animated explainers or video output (consider an animation skill)
-
-If a more specialized skill is available for the subject, prefer that. If none fits, this skill can serve as a general-purpose SVG diagram fallback — the output will carry the clean educational aesthetic described below, which is a reasonable default for almost any subject.
-
-## Workflow
-
-1. Decide on the diagram type (see Diagram Types below).
-2. Lay out components using the Design System rules.
-3. Write the full HTML page using `templates/template.html` as the wrapper — paste your SVG where the template says ``.
-4. Save as a standalone `.html` file (for example `~/my-diagram.html` or `./my-diagram.html`).
-5. User opens it directly in a browser — no server, no dependencies.
-
-Optional: if the user wants a browsable gallery of multiple diagrams, see "Local Preview Server" at the bottom.
-
-Load the HTML template:
-```
-skill_view(name="concept-diagrams", file_path="templates/template.html")
-```
-
-The template embeds the full CSS design system (`c-*` color classes, text classes, light/dark variables, arrow marker styles). The SVG you generate relies on these classes being present on the hosting page.
-
----
-
-## Design System
-
-### Philosophy
-
-- **Flat**: no gradients, drop shadows, blur, glow, or neon effects.
-- **Minimal**: show the essential. No decorative icons inside boxes.
-- **Consistent**: same colors, spacing, typography, and stroke widths across every diagram.
-- **Dark-mode ready**: all colors auto-adapt via CSS classes — no per-mode SVG.
-
-### Color Palette
-
-9 color ramps, each with 7 stops. Put the class name on a `` or shape element; the template CSS handles both modes.
-
-| Class | 50 (lightest) | 100 | 200 | 400 | 600 | 800 | 900 (darkest) |
-|------------|---------------|---------|---------|---------|---------|---------|---------------|
-| `c-purple` | #EEEDFE | #CECBF6 | #AFA9EC | #7F77DD | #534AB7 | #3C3489 | #26215C |
-| `c-teal` | #E1F5EE | #9FE1CB | #5DCAA5 | #1D9E75 | #0F6E56 | #085041 | #04342C |
-| `c-coral` | #FAECE7 | #F5C4B3 | #F0997B | #D85A30 | #993C1D | #712B13 | #4A1B0C |
-| `c-pink` | #FBEAF0 | #F4C0D1 | #ED93B1 | #D4537E | #993556 | #72243E | #4B1528 |
-| `c-gray` | #F1EFE8 | #D3D1C7 | #B4B2A9 | #888780 | #5F5E5A | #444441 | #2C2C2A |
-| `c-blue` | #E6F1FB | #B5D4F4 | #85B7EB | #378ADD | #185FA5 | #0C447C | #042C53 |
-| `c-green` | #EAF3DE | #C0DD97 | #97C459 | #639922 | #3B6D11 | #27500A | #173404 |
-| `c-amber` | #FAEEDA | #FAC775 | #EF9F27 | #BA7517 | #854F0B | #633806 | #412402 |
-| `c-red` | #FCEBEB | #F7C1C1 | #F09595 | #E24B4A | #A32D2D | #791F1F | #501313 |
-
-#### Color Assignment Rules
-
-Color encodes **meaning**, not sequence. Never cycle through colors like a rainbow.
-
-- Group nodes by **category** — all nodes of the same type share one color.
-- Use `c-gray` for neutral/structural nodes (start, end, generic steps, users).
-- Use **2-3 colors per diagram**, not 6+.
-- Prefer `c-purple`, `c-teal`, `c-coral`, `c-pink` for general categories.
-- Reserve `c-blue`, `c-green`, `c-amber`, `c-red` for semantic meaning (info, success, warning, error).
-
-Light/dark stop mapping (handled by the template CSS — just use the class):
-- Light mode: 50 fill + 600 stroke + 800 title / 600 subtitle
-- Dark mode: 800 fill + 200 stroke + 100 title / 200 subtitle
-
-### Typography
-
-Only two font sizes. No exceptions.
-
-| Class | Size | Weight | Use |
-|-------|------|--------|-----|
-| `th` | 14px | 500 | Node titles, region labels |
-| `ts` | 12px | 400 | Subtitles, descriptions, arrow labels |
-| `t` | 14px | 400 | General text |
-
-- **Sentence case always.** Never Title Case, never ALL CAPS.
-- Every `` MUST carry a class (`t`, `ts`, or `th`). No unclassed text.
-- `dominant-baseline="central"` on all text inside boxes.
-- `text-anchor="middle"` for centered text in boxes.
-
-**Width estimation (approx):**
-- 14px weight 500: ~8px per character
-- 12px weight 400: ~6.5px per character
-- Always verify: `box_width >= (char_count × px_per_char) + 48` (24px padding each side)
-
-### Spacing & Layout
-
-- **ViewBox**: `viewBox="0 0 680 H"` where H = content height + 40px buffer.
-- **Safe area**: x=40 to x=640, y=40 to y=(H-40).
-- **Between boxes**: 60px minimum gap.
-- **Inside boxes**: 24px horizontal padding, 12px vertical padding.
-- **Arrowhead gap**: 10px between arrowhead and box edge.
-- **Single-line box**: 44px height.
-- **Two-line box**: 56px height, 18px between title and subtitle baselines.
-- **Container padding**: 20px minimum inside every container.
-- **Max nesting**: 2-3 levels deep. Deeper gets unreadable at 680px width.
-
-### Stroke & Shape
-
-- **Stroke width**: 0.5px on all node borders. Not 1px, not 2px.
-- **Rect rounding**: `rx="8"` for nodes, `rx="12"` for inner containers, `rx="16"` to `rx="20"` for outer containers.
-- **Connector paths**: MUST have `fill="none"`. SVG defaults to `fill: black` otherwise.
-
-### Arrow Marker
-
-Include this `` block at the start of **every** SVG:
-
-```xml
-
-
-
-
-
-```
-
-Use `marker-end="url(#arrow)"` on lines. The arrowhead inherits the line color via `context-stroke`.
-
-### CSS Classes (Provided by the Template)
-
-The template page provides:
-
-- Text: `.t`, `.ts`, `.th`
-- Neutral: `.box`, `.arr`, `.leader`, `.node`
-- Color ramps: `.c-purple`, `.c-teal`, `.c-coral`, `.c-pink`, `.c-gray`, `.c-blue`, `.c-green`, `.c-amber`, `.c-red` (all with automatic light/dark mode)
-
-You do **not** need to redefine these — just apply them in your SVG. The template file contains the full CSS definitions.
-
----
-
-## SVG Boilerplate
-
-Every SVG inside the template page starts with this exact structure:
-
-```xml
-
-```
-
-Replace `{HEIGHT}` with the actual computed height (last element bottom + 40px).
-
-### Node Patterns
-
-**Single-line node (44px):**
-```xml
-
-
- Service name
-
-```
-
-**Two-line node (56px):**
-```xml
-
-
- Service name
- Short description
-
-```
-
-**Connector (no label):**
-```xml
-
-```
-
-**Container (dashed or solid):**
-```xml
-
-
- Container label
- Subtitle info
-
-```
-
----
-
-## Diagram Types
-
-Choose the layout that fits the subject:
-
-1. **Flowchart** — CI/CD pipelines, request lifecycles, approval workflows, data processing. Single-direction flow (top-down or left-right). Max 4-5 nodes per row.
-2. **Structural / Containment** — Cloud infrastructure nesting, system architecture with layers. Large outer containers with inner regions. Dashed rects for logical groupings.
-3. **API / Endpoint Map** — REST routes, GraphQL schemas. Tree from root, branching to resource groups, each containing endpoint nodes.
-4. **Microservice Topology** — Service mesh, event-driven systems. Services as nodes, arrows for communication patterns, message queues between.
-5. **Data Flow** — ETL pipelines, streaming architectures. Left-to-right flow from sources through processing to sinks.
-6. **Physical / Structural** — Vehicles, buildings, hardware, anatomy. Use shapes that match the physical form — `` for curved bodies, `` for tapered shapes, ``/`` for cylindrical parts, nested `` for compartments. See `references/physical-shape-cookbook.md`.
-7. **Infrastructure / Systems Integration** — Smart cities, IoT networks, multi-domain systems. Hub-spoke layout with central platform connecting subsystems. Semantic line styles (`.data-line`, `.power-line`, `.water-pipe`, `.road`). See `references/infrastructure-patterns.md`.
-8. **UI / Dashboard Mockups** — Admin panels, monitoring dashboards. Screen frame with nested chart/gauge/indicator elements. See `references/dashboard-patterns.md`.
-
-For physical, infrastructure, and dashboard diagrams, load the matching reference file before generating — each one provides ready-made CSS classes and shape primitives.
-
----
-
-## Validation Checklist
-
-Before finalizing any SVG, verify ALL of the following:
-
-1. Every `` has class `t`, `ts`, or `th`.
-2. Every `` inside a box has `dominant-baseline="central"`.
-3. Every connector `` or `` used as arrow has `fill="none"`.
-4. No arrow line crosses through an unrelated box.
-5. `box_width >= (longest_label_chars × 8) + 48` for 14px text.
-6. `box_width >= (longest_label_chars × 6.5) + 48` for 12px text.
-7. ViewBox height = bottom-most element + 40px.
-8. All content stays within x=40 to x=640.
-9. Color classes (`c-*`) are on `` or shape elements, never on `` connectors.
-10. Arrow `` block is present.
-11. No gradients, shadows, blur, or glow effects.
-12. Stroke width is 0.5px on all node borders.
-
----
-
-## Output & Preview
-
-### Default: standalone HTML file
-
-Write a single `.html` file the user can open directly. No server, no dependencies, works offline. Pattern:
-
-```python
-# 1. Load the template
-template = skill_view("concept-diagrams", "templates/template.html")
-
-# 2. Fill in title, subtitle, and paste your SVG
-html = template.replace(
- "", "SN2 reaction mechanism"
-).replace(
- "", "Bimolecular nucleophilic substitution"
-).replace(
- "", svg_content
-)
-
-# 3. Write to a user-chosen path (or ./ by default)
-write_file("./sn2-mechanism.html", html)
-```
-
-Tell the user how to open it:
-
-```
-# macOS
-open ./sn2-mechanism.html
-# Linux
-xdg-open ./sn2-mechanism.html
-```
-
-### Optional: local preview server (multi-diagram gallery)
-
-Only use this when the user explicitly wants a browsable gallery of multiple diagrams.
-
-**Rules:**
-- Bind to `127.0.0.1` only. Never `0.0.0.0`. Exposing diagrams on all network interfaces is a security hazard on shared networks.
-- Pick a free port (do NOT hard-code one) and tell the user the chosen URL.
-- The server is optional and opt-in — prefer the standalone HTML file first.
-
-Recommended pattern (lets the OS pick a free ephemeral port):
-
-```bash
-# Put each diagram in its own folder under .diagrams/
-mkdir -p .diagrams/sn2-mechanism
-# ...write .diagrams/sn2-mechanism/index.html...
-
-# Serve on loopback only, free port
-cd .diagrams && python3 -c "
-import http.server, socketserver
-with socketserver.TCPServer(('127.0.0.1', 0), http.server.SimpleHTTPRequestHandler) as s:
- print(f'Serving at http://127.0.0.1:{s.server_address[1]}/')
- s.serve_forever()
-" &
-```
-
-If the user insists on a fixed port, use `127.0.0.1:` — still never `0.0.0.0`. Document how to stop the server (`kill %1` or `pkill -f "http.server"`).
-
----
-
-## Examples Reference
-
-The `examples/` directory ships 15 complete, tested diagrams. Browse them for working patterns before writing a new diagram of a similar type:
-
-| File | Type | Demonstrates |
-|------|------|--------------|
-| `hospital-emergency-department-flow.md` | Flowchart | Priority routing with semantic colors |
-| `feature-film-production-pipeline.md` | Flowchart | Phased workflow, horizontal sub-flows |
-| `automated-password-reset-flow.md` | Flowchart | Auth flow with error branches |
-| `autonomous-llm-research-agent-flow.md` | Flowchart | Loop-back arrows, decision branches |
-| `place-order-uml-sequence.md` | Sequence | UML sequence diagram style |
-| `commercial-aircraft-structure.md` | Physical | Paths, polygons, ellipses for realistic shapes |
-| `wind-turbine-structure.md` | Physical cross-section | Underground/above-ground separation, color coding |
-| `smartphone-layer-anatomy.md` | Exploded view | Alternating left/right labels, layered components |
-| `apartment-floor-plan-conversion.md` | Floor plan | Walls, doors, proposed changes in dotted red |
-| `banana-journey-tree-to-smoothie.md` | Narrative journey | Winding path, progressive state changes |
-| `cpu-ooo-microarchitecture.md` | Hardware pipeline | Fan-out, memory hierarchy sidebar |
-| `sn2-reaction-mechanism.md` | Chemistry | Molecules, curved arrows, energy profile |
-| `smart-city-infrastructure.md` | Hub-spoke | Semantic line styles per system |
-| `electricity-grid-flow.md` | Multi-stage flow | Voltage hierarchy, flow markers |
-| `ml-benchmark-grouped-bar-chart.md` | Chart | Grouped bars, dual axis |
-
-Load any example with:
-```
-skill_view(name="concept-diagrams", file_path="examples/")
-```
-
----
-
-## Quick Reference: What to Use When
-
-| User says | Diagram type | Suggested colors |
-|-----------|--------------|------------------|
-| "show the pipeline" | Flowchart | gray start/end, purple steps, red errors, teal deploy |
-| "draw the data flow" | Data pipeline (left-right) | gray sources, purple processing, teal sinks |
-| "visualize the system" | Structural (containment) | purple container, teal services, coral data |
-| "map the endpoints" | API tree | purple root, one ramp per resource group |
-| "show the services" | Microservice topology | gray ingress, teal services, purple bus, coral workers |
-| "draw the aircraft/vehicle" | Physical | paths, polygons, ellipses for realistic shapes |
-| "smart city / IoT" | Hub-spoke integration | semantic line styles per subsystem |
-| "show the dashboard" | UI mockup | dark screen, chart colors: teal, purple, coral for alerts |
-| "power grid / electricity" | Multi-stage flow | voltage hierarchy (HV/MV/LV line weights) |
-| "wind turbine / turbine" | Physical cross-section | foundation + tower cutaway + nacelle color-coded |
-| "journey of X / lifecycle" | Narrative journey | winding path, progressive state changes |
-| "layers of X / exploded" | Exploded layer view | vertical stack, alternating labels |
-| "CPU / pipeline" | Hardware pipeline | vertical stages, fan-out to execution ports |
-| "floor plan / apartment" | Floor plan | walls, doors, proposed changes in dotted red |
-| "reaction mechanism" | Chemistry | atoms, bonds, curved arrows, transition state, energy profile |
diff --git a/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md b/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md
deleted file mode 100644
index 7c11d3401e5..00000000000
--- a/optional-skills/creative/concept-diagrams/examples/apartment-floor-plan-conversion.md
+++ /dev/null
@@ -1,244 +0,0 @@
-# Apartment Floor Plan: 3 BHK to 4 BHK Conversion
-
-An architectural floor plan showing a 1,500 sq ft apartment with proposed modifications to convert from 3 BHK to 4 BHK. Demonstrates architectural drawing conventions, room layouts, proposed changes with dotted lines, and area comparison tables.
-
-## Key Patterns Used
-
-- **Architectural floor plan**: Top-down view with walls, doors, windows
-- **Proposed modifications**: Dotted red lines for new walls
-- **Room color coding**: Light fills to distinguish room types
-- **Circulation paths**: Arrows showing new access routes
-- **Data table**: Before/after area comparison with highlighting
-- **Architectural symbols**: North arrow, scale bar, door swings
-
-## Diagram Type
-
-This is an **architectural floor plan** with:
-- **Plan view**: Top-down orthographic projection
-- **Overlay technique**: Existing structure + proposed changes
-- **Quantitative data**: Area measurements and comparison table
-
-## Architectural Drawing Elements
-
-### Wall Styles
-
-```xml
-
-
-
-
-
-
-
-
-```
-
-```css
-.wall { stroke: var(--text-primary); stroke-width: 6; fill: none; stroke-linecap: square; }
-.wall-thin { stroke: var(--text-primary); stroke-width: 3; fill: none; }
-.proposed-wall { stroke: #A32D2D; stroke-width: 4; fill: none; stroke-dasharray: 8 4; }
-```
-
-### Door Symbols
-
-```xml
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-```css
-.door { stroke: var(--text-secondary); stroke-width: 1.5; fill: none; }
-.door-swing { stroke: var(--text-tertiary); stroke-width: 1; fill: none; stroke-dasharray: 3 2; }
-```
-
-### Window Symbols
-
-```xml
-
-
-
-
-
-
-
-```
-
-```css
-.window { stroke: var(--text-primary); stroke-width: 1; fill: var(--bg-primary); }
-.window-glass { stroke: #378ADD; stroke-width: 2; fill: none; }
-```
-
-### Room Fills
-
-```xml
-
-
-
-
-
-
-
-
-
-```
-
-```css
-.room-master { fill: rgba(206, 203, 246, 0.3); } /* purple tint */
-.room-bed2 { fill: rgba(159, 225, 203, 0.3); } /* teal tint */
-.room-bed3 { fill: rgba(250, 199, 117, 0.3); } /* amber tint */
-.room-living { fill: rgba(245, 196, 179, 0.3); } /* coral tint */
-.room-kitchen { fill: rgba(237, 147, 177, 0.3); } /* pink tint */
-.room-bath { fill: rgba(133, 183, 235, 0.3); } /* blue tint */
-.room-new { fill: rgba(163, 45, 45, 0.15); } /* red tint for proposed */
-```
-
-### Support Fixtures
-
-```xml
-
-
-Counter
-
-
-
-```
-
-```css
-.balcony { fill: none; stroke: var(--text-secondary); stroke-width: 2; stroke-dasharray: 6 3; }
-.balcony-fill { fill: rgba(93, 202, 165, 0.1); }
-```
-
-### Room Labels
-
-```xml
-
-MASTER
-BEDROOM
-195 sq ft
-
-
-BEDROOM 4
-(NEW)
-```
-
-```css
-.room-label { font-family: system-ui; font-size: 11px; fill: var(--text-primary); font-weight: 500; }
-.area-label { font-family: system-ui; font-size: 9px; fill: var(--text-tertiary); }
-```
-
-### Circulation Arrow
-
-```xml
-
-
-
-
-
-
-
-New corridor access
-```
-
-```css
-.circulation { stroke: #3B6D11; stroke-width: 2; fill: none; }
-.circulation-fill { fill: #3B6D11; }
-```
-
-### North Arrow and Scale Bar
-
-```xml
-
-
-
-
- N
-
-
-
-
-
-
-
-
- 0
- 5'
- 10'
-
-```
-
-## Area Comparison Table
-
-### Table Structure
-
-```xml
-
-
-Room
-
-
-
-Master Bedroom
-195
-
-
-
-
-
-
-Bedroom 4 (NEW)
-+100
-
-
-
-TOTAL CARPET AREA
-```
-
-```css
-.table-header { fill: var(--bg-secondary); }
-.table-row { fill: var(--bg-primary); stroke: var(--border); stroke-width: 0.5; }
-.table-row-alt { fill: var(--bg-tertiary); stroke: var(--border); stroke-width: 0.5; }
-.table-highlight { fill: rgba(163, 45, 45, 0.1); stroke: #A32D2D; stroke-width: 0.5; }
-```
-
-## Layout Notes
-
-- **ViewBox**: 800×780 (portrait for floor plan + table)
-- **Scale**: 10px = 1 foot (apartment ~50ft × 33ft)
-- **Floor plan origin**: Offset at (50, 60) for margins
-- **Wall thickness**: 6px outer, 3px inner (represents ~6" walls)
-- **Room labels**: Centered in each room with area below
-- **Table placement**: Below floor plan with full width
-
-## Color Coding
-
-| Element | Color | Usage |
-|---------|-------|-------|
-| Proposed walls | Red (#A32D2D) dotted | New construction |
-| New room fill | Red 15% opacity | Bedroom 4 area |
-| Circulation | Green (#3B6D11) | New access path |
-| Window glass | Blue (#378ADD) | Glass indication |
-| Bedrooms | Purple/Teal/Amber tints | Room differentiation |
-| Wet areas | Blue tint | Bathrooms |
-| Living | Coral tint | Common areas |
-
-## When to Use This Pattern
-
-Use this diagram style for:
-- Apartment/house floor plans
-- Office layout planning
-- Renovation proposals showing before/after
-- Space planning with area calculations
-- Real estate marketing materials
-- Interior design presentations
-- Building permit documentation
diff --git a/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md b/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md
deleted file mode 100644
index 86cd1cc0782..00000000000
--- a/optional-skills/creative/concept-diagrams/examples/automated-password-reset-flow.md
+++ /dev/null
@@ -1,276 +0,0 @@
-# Automated Password Reset Flow
-
-A two-section flowchart tracing the full user journey for a web application password reset: the initial request phase (forgot password → email check → token generation) and the reset-form phase (link click → new password entry → token/password validation). Demonstrates multi-exit decision diamonds, a three-column branching layout, a loop-back path, and a cross-section separator arrow.
-
-## Key Patterns Used
-
-- **Three-column layout**: Left column (error/terminal branches at cx=115), center column (main happy path at cx=340), right column (expired-token branch at cx=552) — allows side branches to live at the same y-level as center nodes without overlap
-- **Decision diamonds with ``**: Each decision uses a `` wrapper containing a `` and centered ``; the diamond points are computed as `cx±hw, cy±hh` (hw=100, hh=28)
-- **Pill-shaped terminals**: Start and end nodes use `rx=22` on their `` to signal entry/exit points; all mid-flow process nodes use `rx=8`
-- **Three-branch decision paths**: Each diamond has a "Yes" branch (down, short ``) and a "No" branch (`` going horizontal then vertical to a side column)
-- **Loop-back path**: Mismatch error node loops back to the password-entry node via a routing corridor at x=215 — a 5-px gap between the left column (right edge x=210) and center column (left edge x=220); the path exits the bottom of the error node, drops below it, travels right to x=215, then goes up to the target node's center y, then right 5 px into the node's left edge
-- **Section separator**: A dashed horizontal `` at y=452 splits the two phases; the connecting arrow crosses it with a faded label ("user receives email") to preserve flow continuity
-- **Italic annotation**: The exact UX copy for the generic message ("If that email exists…") is shown as a faded italic `ts` text block below the left-branch terminal node
-- **Legend row**: Five inline swatches (gray, purple, teal, red, amber diamond) at the bottom explain the color-to-role mapping
-
-## Diagram
-
-```xml
-
-```
-
-## Custom CSS
-
-Add these classes to the hosting page `
-
-
-
-
-
-
-
-
-
diff --git a/optional-skills/creative/kanban-video-orchestrator/SKILL.md b/optional-skills/creative/kanban-video-orchestrator/SKILL.md
index c5ac2a8c96e..f323406300b 100644
--- a/optional-skills/creative/kanban-video-orchestrator/SKILL.md
+++ b/optional-skills/creative/kanban-video-orchestrator/SKILL.md
@@ -8,7 +8,7 @@ platforms: [linux, macos, windows]
metadata:
hermes:
tags: [video, kanban, multi-agent, orchestration, production-pipeline]
- related_skills: [kanban-orchestrator, kanban-worker, ascii-video, manim-video, p5js, comfyui, touchdesigner-mcp, blender-mcp, pixel-art, ascii-art, songwriting-and-ai-music, heartmula, songsee, spotify, youtube-content, claude-design, excalidraw, architecture-diagram, concept-diagrams, baoyu-comic, baoyu-infographic, humanizer, gif-search, meme-generation]
+ related_skills: [kanban-orchestrator, kanban-worker, ascii-video, manim-video, p5js, comfyui, touchdesigner-mcp, blender-mcp, pixel-art, ascii-art, songwriting-and-ai-music, heartmula, songsee, spotify, youtube-content, claude-design, excalidraw, html-artifact, baoyu-comic, baoyu-infographic, humanizer, gif-search, meme-generation]
credits: |
The single-project workspace layout, profile-config patching pattern,
SOUL.md-per-profile model, TEAM.md task-graph convention, and
diff --git a/optional-skills/creative/kanban-video-orchestrator/references/intake.md b/optional-skills/creative/kanban-video-orchestrator/references/intake.md
index d290b606f49..1f817da020b 100644
--- a/optional-skills/creative/kanban-video-orchestrator/references/intake.md
+++ b/optional-skills/creative/kanban-video-orchestrator/references/intake.md
@@ -96,8 +96,7 @@ texture inside the final scene.
- **Terminal-only or with GUI?**
- **Voiceover for narration?**
- **Diagram support needed?** — Often these benefit from a diagram skill
- alongside the screen-capture/render step (`excalidraw`,
- `architecture-diagram`, `concept-diagrams`)
+ alongside the screen-capture/render step (`excalidraw`, `html-artifact`)
### ASCII / terminal art
diff --git a/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md b/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md
index 95eaeb33b66..c5e15c06f4b 100644
--- a/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md
+++ b/optional-skills/creative/kanban-video-orchestrator/references/role-archetypes.md
@@ -59,7 +59,7 @@ local skills.
- **Toolsets:** kanban, terminal, file
- **Skills:** `kanban-worker` plus any project-specific design skill —
- `claude-design` (UI/web), `sketch` (quick mockup variants),
+ `claude-design` (UI/web), `html-artifact` (quick mockup variants, explainers, diagrams),
`popular-web-designs` (matching known web aesthetic), `pixel-art` (retro),
`ascii-art` (terminal/retro), `excalidraw` (hand-drawn frames),
`design-md` (text-based design docs)
@@ -72,8 +72,7 @@ film and music video. Often pairs with a diagramming tool.
- **Toolsets:** kanban, file
- **Skills:** `kanban-worker` plus a diagram skill — `excalidraw` (sketch),
- `architecture-diagram` (technical/system), `concept-diagrams` (educational/
- scientific)
+ `html-artifact` (technical/system + educational/scientific diagrams)
- **Outputs:** `storyboard.md` with one row per scene/shot, optional
storyboard sketches
diff --git a/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md b/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md
index b5e59c31478..2f27ffc41e7 100644
--- a/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md
+++ b/optional-skills/creative/kanban-video-orchestrator/references/tool-matrix.md
@@ -30,10 +30,8 @@ called from the terminal toolset; they don't appear in `always_load`.
| `claude-design` | Design one-off HTML artifacts (landing, deck, prototype) | Concept artist for product video style frames; storyboarder for UI-heavy content |
| `design-md` | Design markdown docs | Concept artist documenting visual specs |
| `popular-web-designs` | Reference patterns for popular web designs | Concept artist; cinematographer when matching a known UI aesthetic |
-| `sketch` | Throwaway HTML mockups (2-3 design variants to compare) | Concept artist exploring directions; storyboarder for UI flows |
| `excalidraw` | Excalidraw-style hand-drawn diagrams | Storyboarder; concept artist for sketch-style frames |
-| `architecture-diagram` | Software architecture diagrams | Storyboarder for technical content; explainer scenes about systems |
-| `concept-diagrams` *(optional)* | Flat, minimal SVG diagrams (educational visual language; physics, chemistry, math, anatomy, etc.) | Renderer / storyboarder for explainer scenes with clean educational diagrams |
+| `html-artifact` | Self-contained HTML artifacts: throwaway mockup variants, explainers, dark-tech architecture + educational SVG diagrams | Concept artist exploring directions; storyboarder for UI flows + technical/educational explainer scenes |
| `pretext` | Mathematical/scientific content authoring | Writer / cinematographer for technical-explainer pretexts |
| `creative-ideation` | Constraint-driven project ideation | Director / cinematographer when the brief is wide-open and needs framing |
| `humanizer` | Strip AI-isms from text, add real voice | Writer / copywriter post-process to avoid AI-tells in scripts and VO copy |
diff --git a/skills/creative/architecture-diagram/SKILL.md b/skills/creative/architecture-diagram/SKILL.md
deleted file mode 100644
index 2c813c53c13..00000000000
--- a/skills/creative/architecture-diagram/SKILL.md
+++ /dev/null
@@ -1,148 +0,0 @@
----
-name: architecture-diagram
-description: "Dark-themed SVG architecture/cloud/infra diagrams as HTML."
-version: 1.0.0
-author: Cocoon AI (hello@cocoon-ai.com), ported by Hermes Agent
-license: MIT
-dependencies: []
-platforms: [linux, macos, windows]
-metadata:
- hermes:
- tags: [architecture, diagrams, SVG, HTML, visualization, infrastructure, cloud]
- related_skills: [concept-diagrams, excalidraw]
----
-
-# Architecture Diagram Skill
-
-Generate professional, dark-themed technical architecture diagrams as standalone HTML files with inline SVG graphics. No external tools, no API keys, no rendering libraries — just write the HTML file and open it in a browser.
-
-## Scope
-
-**Best suited for:**
-- Software system architecture (frontend / backend / database layers)
-- Cloud infrastructure (VPC, regions, subnets, managed services)
-- Microservice / service-mesh topology
-- Database + API map, deployment diagrams
-- Anything with a tech-infra subject that fits a dark, grid-backed aesthetic
-
-**Look elsewhere first for:**
-- Physics, chemistry, math, biology, or other scientific subjects
-- Physical objects (vehicles, hardware, anatomy, cross-sections)
-- Floor plans, narrative journeys, educational / textbook-style visuals
-- Hand-drawn whiteboard sketches (consider `excalidraw`)
-- Animated explainers (consider an animation skill)
-
-If a more specialized skill is available for the subject, prefer that. If none fits, this skill can also serve as a general SVG diagram fallback — the output will just carry the dark tech aesthetic described below.
-
-Based on [Cocoon AI's architecture-diagram-generator](https://github.com/Cocoon-AI/architecture-diagram-generator) (MIT).
-
-## Workflow
-
-1. User describes their system architecture (components, connections, technologies)
-2. Generate the HTML file following the design system below
-3. Save with `write_file` to a `.html` file (e.g. `~/architecture-diagram.html`)
-4. User opens in any browser — works offline, no dependencies
-
-### Output Location
-
-Save diagrams to a user-specified path, or default to the current working directory:
-```
-./[project-name]-architecture.html
-```
-
-### Preview
-
-After saving, suggest the user open it:
-```bash
-# macOS
-open ./my-architecture.html
-# Linux
-xdg-open ./my-architecture.html
-```
-
-## Design System & Visual Language
-
-### Color Palette (Semantic Mapping)
-
-Use specific `rgba` fills and hex strokes to categorize components:
-
-| Component Type | Fill (rgba) | Stroke (Hex) |
-| :--- | :--- | :--- |
-| **Frontend** | `rgba(8, 51, 68, 0.4)` | `#22d3ee` (cyan-400) |
-| **Backend** | `rgba(6, 78, 59, 0.4)` | `#34d399` (emerald-400) |
-| **Database** | `rgba(76, 29, 149, 0.4)` | `#a78bfa` (violet-400) |
-| **AWS/Cloud** | `rgba(120, 53, 15, 0.3)` | `#fbbf24` (amber-400) |
-| **Security** | `rgba(136, 19, 55, 0.4)` | `#fb7185` (rose-400) |
-| **Message Bus** | `rgba(251, 146, 60, 0.3)` | `#fb923c` (orange-400) |
-| **External** | `rgba(30, 41, 59, 0.5)` | `#94a3b8` (slate-400) |
-
-### Typography & Background
-- **Font:** JetBrains Mono (Monospace), loaded from Google Fonts
-- **Sizes:** 12px (Names), 9px (Sublabels), 8px (Annotations), 7px (Tiny labels)
-- **Background:** Slate-950 (`#020617`) with a subtle 40px grid pattern
-
-```svg
-
-
-
-
-```
-
-## Technical Implementation Details
-
-### Component Rendering
-Components are rounded rectangles (`rx="6"`) with 1.5px strokes. To prevent arrows from showing through semi-transparent fills, use a **double-rect masking technique**:
-1. Draw an opaque background rect (`#0f172a`)
-2. Draw the semi-transparent styled rect on top
-
-### Connection Rules
-- **Z-Order:** Draw arrows *early* in the SVG (after the grid) so they render behind component boxes
-- **Arrowheads:** Defined via SVG markers
-- **Security Flows:** Use dashed lines in rose color (`#fb7185`)
-- **Boundaries:**
- - *Security Groups:* Dashed (`4,4`), rose color
- - *Regions:* Large dashed (`8,4`), amber color, `rx="12"`
-
-### Spacing & Layout Logic
-- **Standard Height:** 60px (Services); 80-120px (Large components)
-- **Vertical Gap:** Minimum 40px between components
-- **Message Buses:** Must be placed *in the gap* between services, not overlapping them
-- **Legend Placement:** **CRITICAL.** Must be placed outside all boundary boxes. Calculate the lowest Y-coordinate of all boundaries and place the legend at least 20px below it.
-
-## Document Structure
-
-The generated HTML file follows a four-part layout:
-1. **Header:** Title with a pulsing dot indicator and subtitle
-2. **Main SVG:** The diagram contained within a rounded border card
-3. **Summary Cards:** A grid of three cards below the diagram for high-level details
-4. **Footer:** Minimal metadata
-
-### Info Card Pattern
-```html
-
-
-
-
Title
-
-
-
• Item one
-
• Item two
-
-
-```
-
-## Output Requirements
-- **Single File:** One self-contained `.html` file
-- **No External Dependencies:** All CSS and SVG must be inline (except Google Fonts)
-- **No JavaScript:** Use pure CSS for any animations (like pulsing dots)
-- **Compatibility:** Must render correctly in any modern web browser
-
-## Template Reference
-
-Load the full HTML template for the exact structure, CSS, and SVG component examples:
-
-```
-skill_view(name="architecture-diagram", file_path="templates/template.html")
-```
-
-The template contains working examples of every component type (frontend, backend, database, cloud, security), arrow styles (standard, dashed, curved), security groups, region boundaries, and the legend — use it as your structural reference when generating diagrams.
diff --git a/skills/creative/architecture-diagram/templates/template.html b/skills/creative/architecture-diagram/templates/template.html
deleted file mode 100644
index f5b32fbe7fd..00000000000
--- a/skills/creative/architecture-diagram/templates/template.html
+++ /dev/null
@@ -1,319 +0,0 @@
-
-
-
-
-
- [PROJECT NAME] Architecture Diagram
-
-
-
-
-
-
-
-
-
-
[PROJECT NAME] Architecture
-
-
[Subtitle description]
-
-
-
-
-
-
-
-
-
-
-
-
-
Card Title 1
-
-
-
• Item one
-
• Item two
-
• Item three
-
• Item four
-
-
-
-
-
-
-
Card Title 2
-
-
-
• Item one
-
• Item two
-
• Item three
-
• Item four
-
-
-
-
-
-
-
Card Title 3
-
-
-
• Item one
-
• Item two
-
• Item three
-
• Item four
-
-
-
-
-
-
- [Project Name] • [Additional metadata]
-
-
-
-
diff --git a/skills/creative/claude-design/SKILL.md b/skills/creative/claude-design/SKILL.md
index 673d1ff827a..d61dbcb2f00 100644
--- a/skills/creative/claude-design/SKILL.md
+++ b/skills/creative/claude-design/SKILL.md
@@ -8,7 +8,7 @@ platforms: [linux, macos, windows]
metadata:
hermes:
tags: [design, html, prototype, ux, ui, creative, artifact, deck, motion, design-system]
- related_skills: [design-md, popular-web-designs, excalidraw, architecture-diagram]
+ related_skills: [html-artifact, design-md, popular-web-designs, excalidraw]
---
# Claude Design for CLI/API Agents
@@ -19,19 +19,21 @@ The goal is to preserve Claude Design's useful design behavior and taste while r
**Before starting, check for other web-design skills like `popular-web-designs` (ready-to-paste design systems for Stripe, Linear, Vercel, Notion, etc.) and `design-md` (Google's DESIGN.md token spec format).** If the user wants a known brand's look, load `popular-web-designs` alongside this one and let it supply the visual vocabulary. If the deliverable is a token spec file rather than a rendered artifact, use `design-md` instead. Full decision table below.
-## When To Use This Skill vs `popular-web-designs` vs `design-md`
+## When To Use This Skill vs `html-artifact` vs `popular-web-designs` vs `design-md`
-Hermes has three design-related skills under `skills/creative/`. They do different jobs — load the right one (or combine them):
+Several skills produce HTML — they do different jobs. Load the right one (or combine them):
| Skill | What it gives you | Use when the user wants... |
|---|---|---|
-| **claude-design** (this one) | Design *process and taste* — how to scope a brief, gather context, produce variants, verify a local HTML artifact, avoid AI-design slop | a from-scratch designed artifact (landing page, prototype, deck, component lab, motion study) with no specific brand or token system dictated |
+| **claude-design** (this one) | Visual design *process and taste* — how to scope a brief, gather context, produce variants, verify a local HTML artifact, avoid AI-design slop | a from-scratch *designed* artifact (landing page, prototype, deck, component lab, motion study) where the look itself is the point and no specific brand or token system is dictated |
+| **html-artifact** | A house style for *information* artifacts — explainers, plans, reports, code reviews, technical/educational diagrams, throwaway editors | to *explain / plan / report / diagram / review* something as a shareable HTML page — the content is the point, not bespoke visual design |
| **popular-web-designs** | 54 ready-to-paste design systems — exact colors, typography, components, CSS values for sites like Stripe, Linear, Vercel, Notion, Airbnb | "make it look like Stripe / Linear / Vercel", a page styled after a known brand, or a visual starting point pulled from a real product |
| **design-md** | Google's DESIGN.md spec format — author/validate/diff/export design-token files, WCAG contrast checking, Tailwind/DTCG export | a formal, persistent, machine-readable design-system *spec file* (tokens + rationale) that lives in a repo and gets consumed by agents over time |
Rule of thumb:
-- **Process + taste, one-off artifact** → claude-design
+- **Bespoke visual design, taste-driven artifact** → claude-design
+- **Explain / plan / report / diagram as a shareable page** → html-artifact
- **Match a known brand's look** → popular-web-designs (and let claude-design drive the process)
- **Author the tokens spec itself** → design-md
diff --git a/skills/creative/design-md/SKILL.md b/skills/creative/design-md/SKILL.md
index 6604be1979d..e0534d9ba72 100644
--- a/skills/creative/design-md/SKILL.md
+++ b/skills/creative/design-md/SKILL.md
@@ -8,7 +8,7 @@ platforms: [linux, macos, windows]
metadata:
hermes:
tags: [design, design-system, tokens, ui, accessibility, wcag, tailwind, dtcg, google]
- related_skills: [popular-web-designs, claude-design, excalidraw, architecture-diagram]
+ related_skills: [popular-web-designs, claude-design, excalidraw, html-artifact]
---
# DESIGN.md Skill
diff --git a/skills/creative/html-artifact/SKILL.md b/skills/creative/html-artifact/SKILL.md
new file mode 100644
index 00000000000..4883e1ff4c1
--- /dev/null
+++ b/skills/creative/html-artifact/SKILL.md
@@ -0,0 +1,184 @@
+---
+name: html-artifact
+description: Build self-contained HTML files to explain, plan, or review.
+version: 1.0.0
+author: Anthropic (html-effectiveness gallery, MIT), adapted for Hermes Agent
+license: MIT
+platforms: [linux, macos, windows]
+metadata:
+ hermes:
+ tags: [html, artifact, explainer, plan, report, code-review, diagram, svg, design, prototype, editor]
+ related_skills: [claude-design, popular-web-designs, design-md, excalidraw, p5js]
+---
+
+# HTML Artifact Skill
+
+Produce a single self-contained `.html` file — no build step, no dependencies, no
+CDN — whenever the deliverable is something a human should *read, share, or poke at*:
+a concept explainer, an implementation plan, a status/incident report, a code-review
+walkthrough, a technical or educational diagram, a set of design variants, or a
+throwaway editor that exports its result back to you.
+
+HTML beats Markdown once a doc has color, layout, diagrams, tables, code, or
+interaction. It opens in any browser, shares as a link, stays readable past 100
+lines, and can carry SVG diagrams and live controls Markdown can't. Default to an
+HTML artifact when the user says "make an HTML file/artifact", or asks you to
+*explain how X works*, *write up a plan/PR/report*, *diagram* something, *compare*
+options, or *prototype* an interaction — even when they don't say "HTML".
+
+## Why this skill exists (and what it replaced)
+
+This skill **supersedes** three former skills — `sketch` (throwaway multi-variant
+HTML mockups), `architecture-diagram` (dark-tech infra SVG), and `concept-diagrams`
+(educational SVG). They were consolidated for a concrete reason: all three emitted
+the *same artifact* — a single self-contained HTML file with inline CSS/SVG — and
+overlapped heavily (three "diagram" skills, two "compare variants" paths, no shared
+token system). Folding them into one mode-switched skill removes the
+which-one-do-I-load ambiguity and gives every output the same house style, while
+keeping each skill's unique value: the fidelity dial + verify loop (from `sketch`),
+the dark infra aesthetic (from `architecture-diagram`), and the 9-ramp educational
+system + archetype library (from `concept-diagrams`).
+
+The consolidation is footprint-safe: this skill has **zero dependencies** (no Node,
+FFmpeg, Chromium, or pip packages — it authors plain HTML/CSS/SVG), so even though it
+ships **bundled** (active by default) where `concept-diagrams` was optional, the only
+always-in-context cost is this skill's one-line description. All references,
+templates, and the example gallery load on demand. `concept-diagrams` was optional
+because it was niche, not because it had an install cost — promoting that capability
+into a general-purpose, zero-dep bundled skill is the right home for it. Diagram-style
+work with a *real* install cost (e.g. `hyperframes`: Node + FFmpeg + Chromium)
+deliberately stays optional and is **not** folded in here.
+
+Use a different skill when: matching a known brand's look → `popular-web-designs`; a
+formal design-token spec file → `design-md`; a *bespoke visually-designed* artifact
+where the look itself is the point → `claude-design`; hand-drawn/whiteboard
+`.excalidraw` files → `excalidraw`; generative/animated canvas art → `p5js`. This
+skill is for everything else that ships as a readable, shareable HTML page.
+
+## Reference files (load on demand)
+
+- `references/house-style.md` — the canonical `:root` token block, type system,
+ card/table/callout/code-block patterns. **Read this before authoring any artifact.**
+- `references/examples.md` — 20 complete reference HTML files (Anthropic's
+ html-effectiveness gallery, MIT) keyed to each mode, plus the script to fetch them.
+ Read/fetch one that matches your task to calibrate the house style from a full example.
+- `references/svg-diagrams.md` — hand-authored inline SVG: arrow markers, node
+ groups, decision diamonds, edge semantics, coordinate-grid discipline. Read for
+ any flowchart / architecture / concept diagram.
+- `references/concept-archetypes.md` — the 9-ramp educational color system + a
+ library of diagram archetypes (timeline, tree, quadrant, layered stack,
+ before/after, hub-spoke, cross-section). Read for educational / non-software visuals.
+- `references/dark-tech.md` — the dark "infra" token variant (carries the old
+ architecture-diagram aesthetic). Read for cloud/infra/system architecture diagrams.
+- `references/throwaway-editors.md` — the single-file editor recipe and the
+ copy-to-clipboard export pattern that survives `file://`. Read when the artifact
+ needs interactive controls that export state back to a prompt.
+- `references/fidelity-and-verify.md` — the throwaway↔presentation fidelity dial,
+ the multi-variant comparison layout, and the mandatory browser-vision verify loop.
+
+## Templates
+
+- `templates/base.html` — document scaffold with the house-style `
+
+
+
+
Section · Context
+
Artifact Title
+
One-sentence framing of what this artifact is and who it's for.
+
+
Overview
+
Body copy. Keep paragraphs readable; let layout carry structure.
+
+
+
Metric
42
+
Metric
7
+
Needs attention
3
+
Metric
98%
+
+
+
Note. Use callouts for the one thing the reader must not miss.