From 8a0c774e9efd771c317e6f158a080ea19267182b Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:25:39 -0700 Subject: [PATCH 01/13] Add web dashboard build to Nix flake (#12194) The web dashboard (Vite/React frontend) is now built as a separate Nix derivation and baked into the Hermes package. The build output is installed to a standard location and exposed via the `HERMES_WEB_DIST` environment variable, allowing the dashboard command to use pre-built assets when available (e.g., in packaged releases) instead of rebuilding on every invocation. --- hermes_cli/main.py | 5 ++-- hermes_cli/web_server.py | 2 +- nix/packages.nix | 7 +++++ nix/web.nix | 63 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 nix/web.nix diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a13a6f88e..ce02c2e72 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -6229,8 +6229,9 @@ def cmd_dashboard(args): print(f"Install them with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'") sys.exit(1) - if not _build_web_ui(PROJECT_ROOT / "web", fatal=True): - sys.exit(1) + if "HERMES_WEB_DIST" not in os.environ: + if not _build_web_ui(PROJECT_ROOT / "web", fatal=True): + sys.exit(1) from hermes_cli.web_server import start_server diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 0d0dc4a66..110b81e4b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -59,7 +59,7 @@ except ImportError: f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" ) -WEB_DIST = Path(__file__).parent / "web_dist" +WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist" _log = logging.getLogger(__name__) app = FastAPI(title="Hermes Agent", version=__version__) diff --git a/nix/packages.nix b/nix/packages.nix index 968ad12fb..94e84af6d 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -18,6 +18,10 @@ filter = path: _type: !(pkgs.lib.hasInfix "/index-cache/" path); }; + hermesWeb = pkgs.callPackage ./web.nix { + npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default; + }; + runtimeDeps = with pkgs; [ nodejs_22 ripgrep @@ -52,6 +56,7 @@ mkdir -p $out/share/hermes-agent $out/bin cp -r ${bundledSkills} $out/share/hermes-agent/skills + cp -r ${hermesWeb} $out/share/hermes-agent/web_dist # copy pre-built TUI (same layout as dev: ui-tui/dist/ + node_modules/) mkdir -p $out/ui-tui @@ -62,6 +67,7 @@ makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \ --suffix PATH : "${runtimePath}" \ --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \ + --set HERMES_WEB_DIST $out/share/hermes-agent/web_dist \ --set HERMES_TUI_DIR $out/ui-tui \ --set HERMES_PYTHON ${hermesVenv}/bin/python3 \ --set HERMES_NODE ${pkgs.nodejs_22}/bin/node @@ -104,6 +110,7 @@ }; tui = hermesTui; + web = hermesWeb; }; }; } diff --git a/nix/web.nix b/nix/web.nix new file mode 100644 index 000000000..247889753 --- /dev/null +++ b/nix/web.nix @@ -0,0 +1,63 @@ +# nix/web.nix — Hermes Web Dashboard (Vite/React) frontend build +{ pkgs, npm-lockfile-fix, ... }: +let + src = ../web; + npmDeps = pkgs.fetchNpmDeps { + inherit src; + hash = "sha256-Y0pOzdFG8BLjfvCLmsvqYpjxFjAQabXp1i7X9W/cCU4="; + }; + + npmLockHash = builtins.hashString "sha256" (builtins.readFile ../web/package-lock.json); +in +pkgs.buildNpmPackage { + pname = "hermes-web"; + version = "0.0.0"; + inherit src npmDeps; + + doCheck = false; + + buildPhase = '' + npx tsc -b + npx vite build --outDir dist + ''; + + installPhase = '' + runHook preInstall + cp -r dist $out + runHook postInstall + ''; + + nativeBuildInputs = [ + (pkgs.writeShellScriptBin "update_web_lockfile" '' + set -euox pipefail + + REPO_ROOT=$(git rev-parse --show-toplevel) + + cd "$REPO_ROOT/web" + rm -rf node_modules/ + npm cache clean --force + CI=true npm install + ${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json + + NIX_FILE="$REPO_ROOT/nix/web.nix" + sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE + NIX_OUTPUT=$(nix build .#web 2>&1 || true) + NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}') + echo got new hash $NEW_HASH + sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE + nix build .#web + echo "Updated npm hash in $NIX_FILE to $NEW_HASH" + '') + ]; + + passthru.devShellHook = '' + STAMP=".nix-stamps/hermes-web" + STAMP_VALUE="${npmLockHash}" + if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then + echo "hermes-web: installing npm dependencies..." + cd web && CI=true npm install --silent --no-fund --no-audit 2>/dev/null && cd .. + mkdir -p .nix-stamps + echo "$STAMP_VALUE" > "$STAMP" + fi + ''; +} From b0efdf37d783e4e5345bc3687557a48b4504c1d3 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:21:03 -0700 Subject: [PATCH 02/13] =?UTF-8?q?fix(nix):=20upgrade=20Python=203.11=20?= =?UTF-8?q?=E2=86=92=203.12,=20add=20cross-platform=20eval=20check=20(#122?= =?UTF-8?q?08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nix/checks.nix | 25 ++++++++++++++++++++++++- nix/devShell.nix | 2 +- nix/nixosModules.nix | 7 +++---- nix/packages.nix | 2 +- nix/python.nix | 20 +++++++++++--------- 5 files changed, 40 insertions(+), 16 deletions(-) diff --git a/nix/checks.nix b/nix/checks.nix index ff8e7947c..984016a4f 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -37,7 +37,30 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) in { packages.configKeys = configKeys; - checks = lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + checks = { + # Cross-platform evaluation — catches "not supported for interpreter" + # errors (e.g. sphinx dropping python311) without needing a darwin builder. + # Evaluation is pure and instant; it doesn't build anything. + cross-eval = let + targetSystems = builtins.filter + (s: inputs.self.packages ? ${s}) + [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" "x86_64-darwin" ]; + tryEvalPkg = sys: + let pkg = inputs.self.packages.${sys}.default; + in builtins.tryEval (builtins.seq pkg.drvPath true); + results = map (sys: { inherit sys; result = tryEvalPkg sys; }) targetSystems; + failures = builtins.filter (r: !r.result.success) results; + failMsg = lib.concatMapStringsSep "\n" (r: " - ${r.sys}") failures; + in pkgs.runCommand "hermes-cross-eval" { } ( + if failures != [] then + builtins.throw "Package fails to evaluate on:\n${failMsg}" + else '' + echo "PASS: package evaluates on all ${toString (builtins.length targetSystems)} platforms" + mkdir -p $out + echo "ok" > $out/result + '' + ); + } // lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { # Verify binaries exist and are executable package-contents = pkgs.runCommand "hermes-package-contents" { } '' set -e diff --git a/nix/devShell.nix b/nix/devShell.nix index db39c9d95..63edc59cf 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -12,7 +12,7 @@ devShells.default = pkgs.mkShell { inputsFrom = packages; packages = with pkgs; [ - python311 uv nodejs_22 ripgrep git openssh ffmpeg + python312 uv nodejs_22 ripgrep git openssh ffmpeg ]; shellHook = let diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index 24a2a1b6d..3f2709f81 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -148,15 +148,14 @@ su -s /bin/sh "$TARGET_USER" -c 'curl -LsSf https://astral.sh/uv/install.sh | sh' || true fi - # Python 3.11 venv — gives the agent a writable Python with pip. - # Uses uv to install Python 3.11 (Ubuntu 24.04 ships 3.12). + # Python 3.12 venv — gives the agent a writable Python with pip. # --seed includes pip/setuptools so bare `pip install` works. _UV_BIN="$TARGET_HOME/.local/bin/uv" if [ ! -d "$TARGET_HOME/.venv" ] && [ -x "$_UV_BIN" ]; then su -s /bin/sh "$TARGET_USER" -c " export PATH=\"\$HOME/.local/bin:\$PATH\" - uv python install 3.11 - uv venv --python 3.11 --seed \"\$HOME/.venv\" + uv python install 3.12 + uv venv --python 3.12 --seed \"\$HOME/.venv\" " || true fi diff --git a/nix/packages.nix b/nix/packages.nix index 94e84af6d..912be7843 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -87,7 +87,7 @@ STAMP_VALUE="${pyprojectHash}:${uvLockHash}" if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then echo "hermes-agent: installing Python dependencies..." - uv venv .venv --python ${pkgs.python311}/bin/python3 2>/dev/null || true + uv venv .venv --python ${pkgs.python312}/bin/python3 2>/dev/null || true source .venv/bin/activate uv pip install -e ".[all]" [ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true diff --git a/nix/python.nix b/nix/python.nix index 91411f4d7..0bcd017e7 100644 --- a/nix/python.nix +++ b/nix/python.nix @@ -1,6 +1,6 @@ # nix/python.nix — uv2nix virtual environment builder { - python311, + python312, lib, callPackage, uv2nix, @@ -51,28 +51,30 @@ let pythonPackageOverrides = final: _prev: if isAarch64Darwin then { - numpy = mkPrebuiltOverride final python311.pkgs.numpy { }; + numpy = mkPrebuiltOverride final python312.pkgs.numpy { }; - av = mkPrebuiltOverride final python311.pkgs.av { }; + pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { }; - humanfriendly = mkPrebuiltOverride final python311.pkgs.humanfriendly { }; + av = mkPrebuiltOverride final python312.pkgs.av { }; - coloredlogs = mkPrebuiltOverride final python311.pkgs.coloredlogs { + humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { }; + + coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs { humanfriendly = [ ]; }; - onnxruntime = mkPrebuiltOverride final python311.pkgs.onnxruntime { + onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime { coloredlogs = [ ]; numpy = [ ]; packaging = [ ]; }; - ctranslate2 = mkPrebuiltOverride final python311.pkgs.ctranslate2 { + ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 { numpy = [ ]; pyyaml = [ ]; }; - faster-whisper = mkPrebuiltOverride final python311.pkgs.faster-whisper { + faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper { av = [ ]; ctranslate2 = [ ]; huggingface-hub = [ ]; @@ -84,7 +86,7 @@ let pythonSet = (callPackage pyproject-nix.build.packages { - python = python311; + python = python312; }).overrideScope (lib.composeManyExtensions [ pyproject-build-systems.overlays.default From 2da558ec36ea7c8743f0e686488af57da8be1634 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:36:06 +0530 Subject: [PATCH 03/13] fix(tui): clickable hyperlinks and skill slash command dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two TUI fixes: 1. Hyperlinks are now clickable (Cmd+Click / Ctrl+Click) in terminals that support OSC 8. The markdown renderer was rendering links as plain colored text — now wraps them in the existing component from @hermes/ink which emits OSC 8 escape sequences. 2. Skill slash commands (e.g. /hermes-agent-dev) now work in the TUI. The slash.exec handler was delegating to the _SlashWorker subprocess which calls cli.process_command(). For skills, process_command() queues the invocation message onto _pending_input — a Queue that nobody reads in the worker subprocess. The skill message was lost. Now slash.exec detects skill commands early and rejects them so the TUI falls through to command.dispatch, which correctly builds and returns the skill payload for the client to send(). --- tests/tui_gateway/test_protocol.py | 48 +++++++++++++++++++ tui_gateway/server.py | 13 +++++ .../src/__tests__/createSlashHandler.test.ts | 31 ++++++++++++ ui-tui/src/components/markdown.tsx | 22 +++++---- ui-tui/src/types/hermes-ink.d.ts | 1 + 5 files changed, 106 insertions(+), 9 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 6ee5fe65b..77cd7b167 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -231,3 +231,51 @@ def test_cli_exec_blocked(server, argv): ]) def test_cli_exec_allowed(server, argv): assert server._cli_exec_blocked(argv) is None + + +# ── slash.exec skill command interception ──────────────────────────── + + +def test_slash_exec_rejects_skill_commands(server): + """slash.exec must reject skill commands so the TUI falls through to command.dispatch.""" + # Register a mock session + sid = "test-session" + server._sessions[sid] = {"session_key": sid, "agent": None} + + # Mock scan_skill_commands to return a known skill + fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}} + + with patch("agent.skill_commands.scan_skill_commands", return_value=fake_skills): + resp = server.handle_request({ + "id": "r1", + "method": "slash.exec", + "params": {"command": "hermes-agent-dev", "session_id": sid}, + }) + + # Should return an error so the TUI's .catch() fires command.dispatch + assert "error" in resp + assert resp["error"]["code"] == 4018 + assert "skill command" in resp["error"]["message"] + + +def test_command_dispatch_returns_skill_payload(server): + """command.dispatch returns structured skill payload for the TUI to send().""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + + fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}} + fake_msg = "Loaded skill content here" + + with patch("agent.skill_commands.scan_skill_commands", return_value=fake_skills), \ + patch("agent.skill_commands.build_skill_invocation_message", return_value=fake_msg): + resp = server.handle_request({ + "id": "r2", + "method": "command.dispatch", + "params": {"name": "hermes-agent-dev", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "skill" + assert result["message"] == fake_msg + assert result["name"] == "hermes-agent-dev" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a7dae9e5c..45c95a6da 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2333,6 +2333,19 @@ def _(rid, params: dict) -> dict: if not cmd: return _err(rid, 4004, "empty command") + # Skill slash commands (e.g. /hermes-agent-dev) must NOT go through the + # slash worker — process_command() queues the skill payload onto + # _pending_input which nobody reads in the worker subprocess. Reject + # here so the TUI falls through to command.dispatch which handles skills + # correctly (builds the invocation message and returns it to the client). + try: + from agent.skill_commands import scan_skill_commands + _cmd_key = f"/{cmd.split()[0]}" if not cmd.startswith("/") else f"/{cmd.lstrip('/').split()[0]}" + if _cmd_key in scan_skill_commands(): + return _err(rid, 4018, f"skill command: use command.dispatch for {_cmd_key}") + except Exception: + pass + worker = session.get("slash_worker") if not worker: try: diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 9e1db9946..a8f050a27 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -121,6 +121,37 @@ describe('createSlashHandler', () => { expect(createSlashHandler(ctx)('/h')).toBe(true) expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array)) }) + + it('falls through to command.dispatch for skill commands and sends the message', async () => { + const skillMessage = 'Use this skill to do X.\n\n## Steps\n1. First step' + + const ctx = buildCtx({ + gateway: { + gw: { + getLogTail: vi.fn(() => ''), + request: vi.fn((method: string) => { + if (method === 'slash.exec') { + return Promise.reject(new Error('skill command: use command.dispatch')) + } + + if (method === 'command.dispatch') { + return Promise.resolve({ type: 'skill', message: skillMessage, name: 'hermes-agent-dev' }) + } + + return Promise.resolve({}) + }) + }, + rpc: vi.fn(() => Promise.resolve({})) + } + }) + + const h = createSlashHandler(ctx) + expect(h('/hermes-agent-dev')).toBe(true) + await vi.waitFor(() => { + expect(ctx.transcript.sys).toHaveBeenCalledWith('⚡ loading skill: hermes-agent-dev') + }) + expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage) + }) }) const buildCtx = (overrides: Partial = {}): Ctx => ({ diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 865ab8579..4555c8505 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from '@hermes/ink' +import { Box, Link, Text } from '@hermes/ink' import { memo, type ReactNode, useMemo } from 'react' import type { Theme } from '../theme.js' @@ -22,10 +22,12 @@ type Fence = { len: number } -const renderLink = (key: number, t: Theme, label: string) => ( - - {label} - +const renderLink = (key: number, t: Theme, label: string, url: string) => ( + + + {label} + + ) const trimBareUrl = (value: string) => { @@ -38,9 +40,11 @@ const trimBareUrl = (value: string) => { } const renderAutolink = (key: number, t: Theme, raw: string) => ( - - {raw.replace(/^mailto:/, '')} - + + + {raw.replace(/^mailto:/, '')} + + ) const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2) @@ -141,7 +145,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[4] && m[5]) { - parts.push(renderLink(parts.length, t, m[4])) + parts.push(renderLink(parts.length, t, m[4], m[5])) } else if (m[6]) { parts.push(renderAutolink(parts.length, t, m[6])) } else if (m[7]) { diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 9b2deec35..faab71ae9 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -63,6 +63,7 @@ declare module '@hermes/ink' { export const Box: React.ComponentType export const AlternateScreen: React.ComponentType export const Ansi: React.ComponentType + export const Link: React.ComponentType<{ readonly url: string; readonly children?: React.ReactNode; readonly fallback?: React.ReactNode }> export const NoSelect: React.ComponentType export const ScrollBox: React.ComponentType export const Text: React.ComponentType From abc95338c210a587c2b718d62a02dbf9c87076d1 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 18 Apr 2026 17:52:19 +0530 Subject: [PATCH 04/13] fix(tui): slash.exec _pending_input commands, tool ANSI, terminal title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additional TUI fixes discovered in the same audit: 1. /plan slash command was silently lost — process_command() queues the plan skill invocation onto _pending_input which nobody reads in the slash worker subprocess. Now intercepted in slash.exec and routed through command.dispatch with a new 'send' dispatch type. Same interception added for /retry, /queue, /steer as safety nets (these already have correct TUI-local handlers in core.ts, but the server-side guard prevents regressions if the local handler is bypassed). 2. Tool results were stripping ANSI escape codes — the messageLine component used stripAnsi() + plain for tool role messages, losing all color/styling from terminal, search_files, etc. Now uses component (already imported) when ANSI is detected. 3. Terminal tab title now shows model + busy status via useTerminalTitle hook from @hermes/ink (was never used). Users can identify Hermes tabs and see at a glance whether the agent is busy or ready. 4. Added 'send' variant to CommandDispatchResponse type + asCommandDispatch parser + createSlashHandler handler for commands that need to inject a message into the conversation (plan, queue fallback, steer fallback). --- tests/tui_gateway/test_protocol.py | 66 +++++++++++++++++++ tui_gateway/server.py | 66 ++++++++++++++++++- .../src/__tests__/asCommandDispatch.test.ts | 8 ++- .../src/__tests__/createSlashHandler.test.ts | 30 +++++++++ ui-tui/src/app/createSlashHandler.ts | 4 ++ ui-tui/src/app/useMainApp.ts | 9 ++- ui-tui/src/components/messageLine.tsx | 14 ++-- ui-tui/src/gatewayTypes.ts | 1 + ui-tui/src/lib/rpc.ts | 4 ++ ui-tui/src/types/hermes-ink.d.ts | 1 + 10 files changed, 196 insertions(+), 7 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 77cd7b167..43f2b5a16 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -258,6 +258,72 @@ def test_slash_exec_rejects_skill_commands(server): assert "skill command" in resp["error"]["message"] +@pytest.mark.parametrize("cmd", ["retry", "queue hello", "q hello", "steer fix the test", "plan"]) +def test_slash_exec_rejects_pending_input_commands(server, cmd): + """slash.exec must reject commands that use _pending_input in the CLI.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid, "agent": None} + + resp = server.handle_request({ + "id": "r1", + "method": "slash.exec", + "params": {"command": cmd, "session_id": sid}, + }) + + assert "error" in resp + assert resp["error"]["code"] == 4018 + assert "pending-input command" in resp["error"]["message"] + + +def test_command_dispatch_queue_sends_message(server): + """command.dispatch /queue returns {type: 'send', message: ...} for the TUI.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + + resp = server.handle_request({ + "id": "r1", + "method": "command.dispatch", + "params": {"name": "queue", "arg": "tell me about quantum computing", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "send" + assert result["message"] == "tell me about quantum computing" + + +def test_command_dispatch_queue_requires_arg(server): + """command.dispatch /queue without an argument returns an error.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid} + + resp = server.handle_request({ + "id": "r2", + "method": "command.dispatch", + "params": {"name": "queue", "arg": "", "session_id": sid}, + }) + + assert "error" in resp + assert resp["error"]["code"] == 4004 + + +def test_command_dispatch_steer_fallback_sends_message(server): + """command.dispatch /steer with no active agent falls back to send.""" + sid = "test-session" + server._sessions[sid] = {"session_key": sid, "agent": None} + + resp = server.handle_request({ + "id": "r3", + "method": "command.dispatch", + "params": {"name": "steer", "arg": "focus on testing", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "send" + assert result["message"] == "focus on testing" + + def test_command_dispatch_returns_skill_payload(server): """command.dispatch returns structured skill payload for the TUI to send().""" sid = "test-session" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 45c95a6da..bf8425a8d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2117,6 +2117,56 @@ def _(rid, params: dict) -> dict: except Exception: pass + # ── Commands that queue messages onto _pending_input in the CLI ─── + # In the TUI the slash worker subprocess has no reader for that queue, + # so we handle them here and return a structured payload. + + if name in ("queue", "q"): + if not arg: + return _err(rid, 4004, "usage: /queue ") + return _ok(rid, {"type": "send", "message": arg}) + + if name == "retry": + agent = session.get("agent") if session else None + if agent and hasattr(agent, "conversation_history"): + hist = agent.conversation_history or [] + for m in reversed(hist): + if m.get("role") == "user": + content = m.get("content", "") + if isinstance(content, list): + content = " ".join( + p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text" + ) + if content: + return _ok(rid, {"type": "send", "message": content}) + return _err(rid, 4018, "no previous user message to retry") + return _err(rid, 4018, "no active session to retry") + + if name == "steer": + if not arg: + return _err(rid, 4004, "usage: /steer ") + agent = session.get("agent") if session else None + if agent and hasattr(agent, "steer"): + try: + accepted = agent.steer(arg) + if accepted: + return _ok(rid, {"type": "exec", "output": f"⏩ Steer queued — arrives after the next tool call: {arg[:80]}{'...' if len(arg) > 80 else ''}"}) + except Exception: + pass + # Fallback: no active run, treat as next-turn message + return _ok(rid, {"type": "send", "message": arg}) + + if name == "plan": + try: + from agent.skill_commands import build_skill_invocation_message as _bsim, build_plan_path + plan_path = build_plan_path(session.get("session_key", "") if session else "") + msg = _bsim("/plan", f"{arg} {plan_path}".strip() if arg else plan_path, + task_id=session.get("session_key", "") if session else "") + if msg: + return _ok(rid, {"type": "send", "message": msg}) + except Exception as e: + return _err(rid, 5030, f"plan skill failed: {e}") + return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") @@ -2338,9 +2388,23 @@ def _(rid, params: dict) -> dict: # _pending_input which nobody reads in the worker subprocess. Reject # here so the TUI falls through to command.dispatch which handles skills # correctly (builds the invocation message and returns it to the client). + # + # The same applies to /retry, /queue, /steer, and /plan — they all + # put messages on _pending_input that the slash worker never reads. + # (/browser connect/disconnect also uses _pending_input for context + # notes, but the actual browser operations need the slash worker's + # env-var side effects, so they stay in slash.exec — only the context + # note to the model is lost, which is low-severity.) + _PENDING_INPUT_COMMANDS = frozenset({"retry", "queue", "q", "steer", "plan"}) + _cmd_parts = cmd.split() if not cmd.startswith("/") else cmd.lstrip("/").split() + _cmd_base = _cmd_parts[0] if _cmd_parts else "" + + if _cmd_base in _PENDING_INPUT_COMMANDS: + return _err(rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}") + try: from agent.skill_commands import scan_skill_commands - _cmd_key = f"/{cmd.split()[0]}" if not cmd.startswith("/") else f"/{cmd.lstrip('/').split()[0]}" + _cmd_key = f"/{_cmd_base}" if _cmd_key in scan_skill_commands(): return _err(rid, 4018, f"skill command: use command.dispatch for {_cmd_key}") except Exception: diff --git a/ui-tui/src/__tests__/asCommandDispatch.test.ts b/ui-tui/src/__tests__/asCommandDispatch.test.ts index 49ea56936..dfa759517 100644 --- a/ui-tui/src/__tests__/asCommandDispatch.test.ts +++ b/ui-tui/src/__tests__/asCommandDispatch.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { asCommandDispatch } from '../lib/rpc.js' describe('asCommandDispatch', () => { - it('parses exec, alias, and skill', () => { + it('parses exec, alias, skill, and send', () => { expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' }) expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' }) expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({ @@ -11,11 +11,17 @@ describe('asCommandDispatch', () => { name: 'x', message: 'do' }) + expect(asCommandDispatch({ type: 'send', message: 'hello world' })).toEqual({ + type: 'send', + message: 'hello world' + }) }) it('rejects malformed payloads', () => { expect(asCommandDispatch(null)).toBeNull() expect(asCommandDispatch({ type: 'alias' })).toBeNull() expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull() + expect(asCommandDispatch({ type: 'send' })).toBeNull() + expect(asCommandDispatch({ type: 'send', message: 42 })).toBeNull() }) }) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index a8f050a27..53a10fd8e 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -152,6 +152,36 @@ describe('createSlashHandler', () => { }) expect(ctx.transcript.send).toHaveBeenCalledWith(skillMessage) }) + + it('handles send-type dispatch for /plan command', async () => { + const planMessage = 'Plan skill content loaded' + + const ctx = buildCtx({ + gateway: { + gw: { + getLogTail: vi.fn(() => ''), + request: vi.fn((method: string) => { + if (method === 'slash.exec') { + return Promise.reject(new Error('pending-input command')) + } + + if (method === 'command.dispatch') { + return Promise.resolve({ type: 'send', message: planMessage }) + } + + return Promise.resolve({}) + }) + }, + rpc: vi.fn(() => Promise.resolve({})) + } + }) + + const h = createSlashHandler(ctx) + expect(h('/plan create a REST API')).toBe(true) + await vi.waitFor(() => { + expect(ctx.transcript.send).toHaveBeenCalledWith(planMessage) + }) + }) }) const buildCtx = (overrides: Partial = {}): Ctx => ({ diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 87475341a..425e778ef 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -105,6 +105,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`) } + + if (d.type === 'send') { + return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: empty message`) + } }) .catch(guardedErr) }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 73ea9febd..46ab21c72 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout, useTerminalTitle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -284,6 +284,13 @@ export function useMainApp(gw: GatewayClient) { useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) + // ── Terminal tab title ───────────────────────────────────────────── + // Show model name + status so users can identify the Hermes tab. + const shortModel = ui.info?.model?.replace(/^.*\//, '') ?? '' + const titleStatus = ui.busy ? '⏳' : '✓' + const terminalTitle = shortModel ? `${titleStatus} ${shortModel} — Hermes` : 'Hermes' + useTerminalTitle(terminalTitle) + useEffect(() => { if (!ui.sid || !stdout) { return diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 59db604e4..9cf78c159 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -28,12 +28,18 @@ export const MessageLine = memo(function MessageLine({ } if (msg.role === 'tool') { + const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || + '(empty tool result)' + return ( - - {compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || - '(empty tool result)'} - + {hasAnsi(msg.text) ? ( + {compactPreview(msg.text, Math.max(24, cols - 14)) || '(empty tool result)'} + ) : ( + + {preview} + + )} ) } diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index c8d1c6855..e17e0e7c7 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -47,6 +47,7 @@ export type CommandDispatchResponse = | { output?: string; type: 'exec' | 'plugin' } | { target: string; type: 'alias' } | { message?: string; name: string; type: 'skill' } + | { message: string; type: 'send' } // ── Config ─────────────────────────────────────────────────────────── diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index 1697d142b..70faa4bbb 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -26,6 +26,10 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined } } + if (t === 'send' && typeof o.message === 'string') { + return { type: 'send', message: o.message } + } + return null } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index faab71ae9..6815e4211 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -93,6 +93,7 @@ declare module '@hermes/ink' { export function useHasSelection(): boolean export function useStdout(): { readonly stdout?: NodeJS.WriteStream } export function useTerminalFocus(): boolean + export function useTerminalTitle(title: string | null): void export function useDeclaredCursor(args: { readonly line: number readonly column: number From 656c375855f7ec331c43d4c796881b02ed2a5218 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 18 Apr 2026 21:54:24 +0530 Subject: [PATCH 05/13] =?UTF-8?q?fix(tui):=20review=20follow-up=20?= =?UTF-8?q?=E2=80=94=20/retry,=20/plan,=20ANSI=20truncation,=20caching?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /retry: use session['history'] instead of non-existent agent.conversation_history; truncate history at last user message to match CLI retry_last() behavior; add history_lock safety - /plan: pass user instruction (arg) to build_plan_path instead of session_key; add runtime_note so agent knows where to save the plan - ANSI tool results: render full text via instead of slicing raw ANSI through compactPreview (which cuts mid-escape-sequence producing garbled output) - Move _PENDING_INPUT_COMMANDS frozenset to module level - Use get_skill_commands() (cached) instead of scan_skill_commands() (rescans disk) in slash.exec skill interception - Add 3 retry tests: happy path with history truncation verification, empty history error, multipart content extraction - Update test mock target from scan_skill_commands to get_skill_commands --- tests/tui_gateway/test_protocol.py | 86 ++++++++++++++++++++++++++- tui_gateway/server.py | 73 ++++++++++++++--------- ui-tui/src/components/messageLine.tsx | 7 ++- 3 files changed, 135 insertions(+), 31 deletions(-) diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 43f2b5a16..eb51cccfe 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -245,7 +245,7 @@ def test_slash_exec_rejects_skill_commands(server): # Mock scan_skill_commands to return a known skill fake_skills = {"/hermes-agent-dev": {"name": "hermes-agent-dev", "description": "Dev workflow"}} - with patch("agent.skill_commands.scan_skill_commands", return_value=fake_skills): + with patch("agent.skill_commands.get_skill_commands", return_value=fake_skills): resp = server.handle_request({ "id": "r1", "method": "slash.exec", @@ -324,6 +324,90 @@ def test_command_dispatch_steer_fallback_sends_message(server): assert result["message"] == "focus on testing" +def test_command_dispatch_retry_finds_last_user_message(server): + """command.dispatch /retry walks session['history'] to find the last user message.""" + sid = "test-session" + history = [ + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": "first answer"}, + {"role": "user", "content": "second question"}, + {"role": "assistant", "content": "second answer"}, + ] + server._sessions[sid] = { + "session_key": sid, + "agent": None, + "history": history, + "history_lock": threading.Lock(), + "history_version": 0, + } + + resp = server.handle_request({ + "id": "r4", + "method": "command.dispatch", + "params": {"name": "retry", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "send" + assert result["message"] == "second question" + # Verify history was truncated: everything from last user message onward removed + assert len(server._sessions[sid]["history"]) == 2 + assert server._sessions[sid]["history"][-1]["role"] == "assistant" + assert server._sessions[sid]["history_version"] == 1 + + +def test_command_dispatch_retry_empty_history(server): + """command.dispatch /retry with empty history returns error.""" + sid = "test-session" + server._sessions[sid] = { + "session_key": sid, + "agent": None, + "history": [], + "history_lock": threading.Lock(), + "history_version": 0, + } + + resp = server.handle_request({ + "id": "r5", + "method": "command.dispatch", + "params": {"name": "retry", "session_id": sid}, + }) + + assert "error" in resp + assert resp["error"]["code"] == 4018 + + +def test_command_dispatch_retry_handles_multipart_content(server): + """command.dispatch /retry extracts text from multipart content lists.""" + sid = "test-session" + history = [ + {"role": "user", "content": [ + {"type": "text", "text": "analyze this"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}} + ]}, + {"role": "assistant", "content": "I see the image."}, + ] + server._sessions[sid] = { + "session_key": sid, + "agent": None, + "history": history, + "history_lock": threading.Lock(), + "history_version": 0, + } + + resp = server.handle_request({ + "id": "r6", + "method": "command.dispatch", + "params": {"name": "retry", "session_id": sid}, + }) + + assert "error" not in resp + result = resp["result"] + assert result["type"] == "send" + assert result["message"] == "analyze this" + + def test_command_dispatch_returns_skill_payload(server): """command.dispatch returns structured skill payload for the TUI to send().""" sid = "test-session" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index bf8425a8d..ccb9f7260 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1949,6 +1949,13 @@ _TUI_EXTRA: list[tuple[str, str, str]] = [ ("/logs", "Show recent gateway log lines", "TUI"), ] +# Commands that queue messages onto _pending_input in the CLI. +# In the TUI the slash worker subprocess has no reader for that queue, +# so slash.exec rejects them → TUI falls through to command.dispatch. +_PENDING_INPUT_COMMANDS: frozenset[str] = frozenset({ + "retry", "queue", "q", "steer", "plan", +}) + @method("commands.catalog") def _(rid, params: dict) -> dict: @@ -2127,20 +2134,32 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"type": "send", "message": arg}) if name == "retry": - agent = session.get("agent") if session else None - if agent and hasattr(agent, "conversation_history"): - hist = agent.conversation_history or [] - for m in reversed(hist): - if m.get("role") == "user": - content = m.get("content", "") - if isinstance(content, list): - content = " ".join( - p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text" - ) - if content: - return _ok(rid, {"type": "send", "message": content}) + if not session: + return _err(rid, 4001, "no active session to retry") + history = session.get("history", []) + if not history: return _err(rid, 4018, "no previous user message to retry") - return _err(rid, 4018, "no active session to retry") + # Walk backwards to find the last user message + last_user_idx = None + for i in range(len(history) - 1, -1, -1): + if history[i].get("role") == "user": + last_user_idx = i + break + if last_user_idx is None: + return _err(rid, 4018, "no previous user message to retry") + content = history[last_user_idx].get("content", "") + if isinstance(content, list): + content = " ".join( + p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text" + ) + if not content: + return _err(rid, 4018, "last user message is empty") + # Truncate history: remove everything from the last user message onward + # (mirrors CLI retry_last() which strips the failed exchange) + with session["history_lock"]: + session["history"] = history[:last_user_idx] + session["history_version"] = int(session.get("history_version", 0)) + 1 + return _ok(rid, {"type": "send", "message": content}) if name == "steer": if not arg: @@ -2159,9 +2178,16 @@ def _(rid, params: dict) -> dict: if name == "plan": try: from agent.skill_commands import build_skill_invocation_message as _bsim, build_plan_path - plan_path = build_plan_path(session.get("session_key", "") if session else "") - msg = _bsim("/plan", f"{arg} {plan_path}".strip() if arg else plan_path, - task_id=session.get("session_key", "") if session else "") + user_instruction = arg or "" + plan_path = build_plan_path(user_instruction) + msg = _bsim( + "/plan", user_instruction, + task_id=session.get("session_key", "") if session else "", + runtime_note=( + "Save the markdown plan with write_file to this exact relative path " + f"inside the active workspace/backend cwd: {plan_path}" + ), + ) if msg: return _ok(rid, {"type": "send", "message": msg}) except Exception as e: @@ -2383,19 +2409,12 @@ def _(rid, params: dict) -> dict: if not cmd: return _err(rid, 4004, "empty command") - # Skill slash commands (e.g. /hermes-agent-dev) must NOT go through the - # slash worker — process_command() queues the skill payload onto - # _pending_input which nobody reads in the worker subprocess. Reject - # here so the TUI falls through to command.dispatch which handles skills - # correctly (builds the invocation message and returns it to the client). - # - # The same applies to /retry, /queue, /steer, and /plan — they all - # put messages on _pending_input that the slash worker never reads. + # Skill slash commands and _pending_input commands must NOT go through the + # slash worker — see _PENDING_INPUT_COMMANDS definition above. # (/browser connect/disconnect also uses _pending_input for context # notes, but the actual browser operations need the slash worker's # env-var side effects, so they stay in slash.exec — only the context # note to the model is lost, which is low-severity.) - _PENDING_INPUT_COMMANDS = frozenset({"retry", "queue", "q", "steer", "plan"}) _cmd_parts = cmd.split() if not cmd.startswith("/") else cmd.lstrip("/").split() _cmd_base = _cmd_parts[0] if _cmd_parts else "" @@ -2403,9 +2422,9 @@ def _(rid, params: dict) -> dict: return _err(rid, 4018, f"pending-input command: use command.dispatch for /{_cmd_base}") try: - from agent.skill_commands import scan_skill_commands + from agent.skill_commands import get_skill_commands _cmd_key = f"/{_cmd_base}" - if _cmd_key in scan_skill_commands(): + if _cmd_key in get_skill_commands(): return _err(rid, 4018, f"skill command: use command.dispatch for {_cmd_key}") except Exception: pass diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 9cf78c159..9de6f2aa1 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -28,13 +28,14 @@ export const MessageLine = memo(function MessageLine({ } if (msg.role === 'tool') { - const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || - '(empty tool result)' + const maxChars = Math.max(24, cols - 14) + const stripped = hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text + const preview = compactPreview(stripped, maxChars) || '(empty tool result)' return ( {hasAnsi(msg.text) ? ( - {compactPreview(msg.text, Math.max(24, cols - 14)) || '(empty tool result)'} + {msg.text} ) : ( {preview} From c14b3b58806e7abd01d9ee01e4ff218c01590cd0 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 18 Apr 2026 09:35:51 -0700 Subject: [PATCH 06/13] fix(kimi): force fixed temperature on kimi-k2.* models (k2.5, thinking, turbo) (#12144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(kimi): force fixed temperature on kimi-k2.* models (k2.5, thinking, turbo) The prior override only matched the literal model name "kimi-for-coding", but Moonshot's coding endpoint is hit with real model IDs such as `kimi-k2.5`, `kimi-k2-turbo-preview`, `kimi-k2-thinking`, etc. Those requests bypassed the override and kept the caller's temperature, so Moonshot returns HTTP 400 "invalid temperature: only 0.6 is allowed for this model" (or 1.0 for thinking variants). Match the whole kimi-k2.* family: * kimi-k2-thinking / kimi-k2-thinking-turbo -> 1.0 (thinking mode) * all other kimi-k2.* -> 0.6 (non-thinking / instant mode) Also accept an optional vendor prefix (e.g. `moonshotai/kimi-k2.5`) so aggregator routings are covered. * refactor(kimi): whitelist-match kimi coding models instead of prefix Addresses review feedback on PR #12144. - Replace `startswith("kimi-k2")` with explicit frozensets sourced from Moonshot's kimi-for-coding model list. The prefix match would have also clamped `kimi-k2-instruct` / `kimi-k2-instruct-0905`, which are the separate non-coding K2 family with variable temperature (recommended 0.6 but not enforced — see huggingface.co/moonshotai/Kimi-K2-Instruct). - Confirmed via platform.kimi.ai docs that all five coding models (k2.5, k2-turbo-preview, k2-0905-preview, k2-thinking, k2-thinking-turbo) share the fixed-temperature lock, so the preview-model mapping is no longer an assumption. - Drop the fragile `"thinking" in bare` substring test for a set lookup. - Log a debug line on each override so operators can see when Hermes silently rewrites temperature. - Update class docstring. Extend the negative test to parametrize over kimi-k2-instruct, Kimi-K2-Instruct-0905, and a hypothetical future kimi-k2-experimental name — all must keep the caller's temperature. --- agent/auxiliary_client.py | 41 +++++++++++++++++++-- tests/agent/test_auxiliary_client.py | 54 ++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 568d61092..126f4615d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -99,11 +99,48 @@ _FIXED_TEMPERATURE_MODELS: Dict[str, float] = { "kimi-for-coding": 0.6, } +# Moonshot's kimi-for-coding endpoint (api.kimi.com/coding) documents: +# "k2.5 model will use a fixed value 1.0, non-thinking mode will use a fixed +# value 0.6. Any other value will result in an error." The same lock applies +# to the other k2.* models served on that endpoint. Enumerated explicitly so +# non-coding siblings like `kimi-k2-instruct` (variable temperature, served on +# the standard chat API and third parties) are NOT clamped. +# Source: https://platform.kimi.ai/docs/guide/kimi-k2-5-quickstart +_KIMI_INSTANT_MODELS: frozenset = frozenset({ + "kimi-k2.5", + "kimi-k2-turbo-preview", + "kimi-k2-0905-preview", +}) +_KIMI_THINKING_MODELS: frozenset = frozenset({ + "kimi-k2-thinking", + "kimi-k2-thinking-turbo", +}) + def _fixed_temperature_for_model(model: Optional[str]) -> Optional[float]: - """Return a required temperature override for models with strict contracts.""" + """Return a required temperature override for models with strict contracts. + + Moonshot's kimi-for-coding endpoint rejects any non-approved temperature on + the k2.5 family. Non-thinking variants require exactly 0.6; thinking + variants require 1.0. An optional ``vendor/`` prefix (e.g. + ``moonshotai/kimi-k2.5``) is tolerated for aggregator routings. + + Returns ``None`` for every other model, including ``kimi-k2-instruct*`` + which is the separate non-coding K2 family with variable temperature. + """ normalized = (model or "").strip().lower() - return _FIXED_TEMPERATURE_MODELS.get(normalized) + fixed = _FIXED_TEMPERATURE_MODELS.get(normalized) + if fixed is not None: + logger.debug("Forcing temperature=%s for model %r (fixed map)", fixed, model) + return fixed + bare = normalized.rsplit("/", 1)[-1] + if bare in _KIMI_THINKING_MODELS: + logger.debug("Forcing temperature=1.0 for kimi thinking model %r", model) + return 1.0 + if bare in _KIMI_INSTANT_MODELS: + logger.debug("Forcing temperature=0.6 for kimi instant model %r", model) + return 0.6 + return None # Default auxiliary models for direct API-key providers (cheap/fast for side tasks) _API_KEY_PROVIDER_AUX_MODELS: Dict[str, str] = { diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 1778855dd..aea8152a5 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -697,7 +697,12 @@ class TestIsConnectionError: class TestKimiForCodingTemperature: - """kimi-for-coding now requires temperature=0.6 exactly.""" + """Moonshot kimi-for-coding models require fixed temperatures. + + k2.5 / k2-turbo-preview / k2-0905-preview → 0.6 (non-thinking lock). + k2-thinking / k2-thinking-turbo → 1.0 (thinking lock). + kimi-k2-instruct* and every other model preserve the caller's temperature. + """ def test_build_call_kwargs_forces_fixed_temperature(self): from agent.auxiliary_client import _build_call_kwargs @@ -772,12 +777,55 @@ class TestKimiForCodingTemperature: assert kwargs["model"] == "kimi-for-coding" assert kwargs["temperature"] == 0.6 - def test_non_kimi_model_still_preserves_temperature(self): + @pytest.mark.parametrize( + "model,expected", + [ + ("kimi-k2.5", 0.6), + ("kimi-k2-turbo-preview", 0.6), + ("kimi-k2-0905-preview", 0.6), + ("kimi-k2-thinking", 1.0), + ("kimi-k2-thinking-turbo", 1.0), + ("moonshotai/kimi-k2.5", 0.6), + ("moonshotai/Kimi-K2-Thinking", 1.0), + ], + ) + def test_kimi_k2_family_temperature_override(self, model, expected): + """Moonshot kimi-k2.* models only accept fixed temperatures. + + Non-thinking models → 0.6, thinking-mode models → 1.0. + """ from agent.auxiliary_client import _build_call_kwargs kwargs = _build_call_kwargs( provider="kimi-coding", - model="kimi-k2.5", + model=model, + messages=[{"role": "user", "content": "hello"}], + temperature=0.3, + ) + + assert kwargs["temperature"] == expected + + @pytest.mark.parametrize( + "model", + [ + "anthropic/claude-sonnet-4-6", + "gpt-5.4", + # kimi-k2-instruct is the non-coding K2 family — temperature is + # variable (recommended 0.6 but not enforced). Must not clamp. + "kimi-k2-instruct", + "moonshotai/Kimi-K2-Instruct", + "moonshotai/Kimi-K2-Instruct-0905", + "kimi-k2-instruct-0905", + # Hypothetical future kimi name not in the whitelist. + "kimi-k2-experimental", + ], + ) + def test_non_restricted_model_preserves_temperature(self, model): + from agent.auxiliary_client import _build_call_kwargs + + kwargs = _build_call_kwargs( + provider="openrouter", + model=model, messages=[{"role": "user", "content": "hello"}], temperature=0.3, ) From b0bde98b0fb17c0015481e2f38b655f0a07558fa Mon Sep 17 00:00:00 2001 From: bluefishs <125471205+bluefishs@users.noreply.github.com> Date: Sun, 19 Apr 2026 00:50:24 +0800 Subject: [PATCH 07/13] fix(docker): build web/ dashboard assets in image (#12180) The Dockerfile installs root-level npm dependencies (for Playwright) and the whatsapp-bridge bundle, but never builds the web/ Vite project. As a result, 'hermes dashboard' starts FastAPI on :9119 but serves a broken SPA because hermes_cli/web_dist/ is empty and requests to /assets/index-.js 404. Add a build step inside web/ so the Vite output is baked into the image. Reproduce (before): docker build -t hermes-repro -f Dockerfile . docker run --rm -p 9119:9119 hermes-repro hermes dashboard curl -sI http://localhost:9119/assets/ | head -1 # -> 404 After: /assets/ returns the built asset path. --- Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile b/Dockerfile index 370382332..4f88a303d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,12 @@ RUN npm install --prefer-offline --no-audit && \ npm install --prefer-offline --no-audit && \ npm cache clean --force +# Build the web/ dashboard so FastAPI at :9119 can serve the Vite assets +RUN cd /opt/hermes/web && \ + npm install --prefer-offline --no-audit && \ + npm run build && \ + npm cache clean --force + # Hand ownership to hermes user, then install Python deps in a virtualenv RUN chown -R hermes:hermes /opt/hermes USER hermes From a828daa7f8eb8f2969c2c46a7796845bab900d04 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:14:31 -0700 Subject: [PATCH 08/13] perf(docker): layer-cache npm/Playwright and skip redundant web rebuild (#12225) * perf(docker): layer-cache npm/Playwright and skip redundant web rebuild Copy package manifests before source so npm install + Playwright only re-run when lockfiles change. Use COPY --chown instead of chown -R, set HERMES_WEB_DIST to skip runtime web rebuild, and drop the USER root / chmod dance since entrypoint.sh is already executable in git. * Update Dockerfile --- Dockerfile | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4f88a303d..0d3da72eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,32 +21,36 @@ RUN useradd -u 10000 -m -d /opt/data hermes COPY --chmod=0755 --from=gosu_source /gosu /usr/local/bin/ COPY --chmod=0755 --from=uv_source /usr/local/bin/uv /usr/local/bin/uvx /usr/local/bin/ -COPY . /opt/hermes WORKDIR /opt/hermes -# Install Node dependencies and Playwright as root (--with-deps needs apt) +# ---------- Layer-cached dependency install ---------- +# Copy only package manifests first so npm install + Playwright are cached +# unless the lockfiles themselves change. +COPY package.json package-lock.json ./ +COPY scripts/whatsapp-bridge/package.json scripts/whatsapp-bridge/package-lock.json scripts/whatsapp-bridge/ +COPY web/package.json web/package-lock.json web/ + RUN npm install --prefer-offline --no-audit && \ npx playwright install --with-deps chromium --only-shell && \ - cd /opt/hermes/scripts/whatsapp-bridge && \ - npm install --prefer-offline --no-audit && \ + (cd scripts/whatsapp-bridge && npm install --prefer-offline --no-audit) && \ + (cd web && npm install --prefer-offline --no-audit) && \ npm cache clean --force -# Build the web/ dashboard so FastAPI at :9119 can serve the Vite assets -RUN cd /opt/hermes/web && \ - npm install --prefer-offline --no-audit && \ - npm run build && \ - npm cache clean --force +# ---------- Source code ---------- +# .dockerignore excludes node_modules, so the installs above survive. +COPY --chown=hermes:hermes . . -# Hand ownership to hermes user, then install Python deps in a virtualenv -RUN chown -R hermes:hermes /opt/hermes +# Build web dashboard (Vite outputs to hermes_cli/web_dist/) +RUN cd web && npm run build + +# ---------- Python virtualenv ---------- +RUN chown hermes:hermes /opt/hermes USER hermes - RUN uv venv && \ uv pip install --no-cache-dir -e ".[all]" -USER root -RUN chmod +x /opt/hermes/docker/entrypoint.sh - +# ---------- Runtime ---------- +ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist ENV HERMES_HOME=/opt/data VOLUME [ "/opt/data" ] ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ] From 65c0a30a776d2d20161658d7cfa8fe8ac78627ed Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 14 Apr 2026 15:53:57 -0700 Subject: [PATCH 09/13] =?UTF-8?q?feat(skills):=20add=20baoyu-infographic?= =?UTF-8?q?=20skill=20=E2=80=94=2021=20layouts=20=C3=97=2021=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of baoyu-infographic from JimLiu/baoyu-skills (v1.56.1) adapted for Hermes Agent's tool ecosystem. Adaptations from upstream: - Frontmatter: openclaw metadata → hermes metadata - Usage: slash command syntax → natural language triggers - Removed EXTEND.md config system (not part of Hermes infrastructure) - AskUserQuestion → clarify tool (one question at a time) - Image generation → image_generate tool - Removed Windows-specific paths - Simplified file operations to use Hermes file tools - All 45 reference files (layouts, styles, templates) preserved intact Attribution preserved per agreement with 宝玉 (Jim Liu): - author, version, GitHub homepage URL in frontmatter Co-authored-by: Jim Liu 宝玉 --- skills/creative/baoyu-infographic/SKILL.md | 236 +++++++++++++++++ .../references/analysis-framework.md | 182 +++++++++++++ .../references/base-prompt.md | 43 +++ .../references/layouts/bento-grid.md | 41 +++ .../references/layouts/binary-comparison.md | 48 ++++ .../references/layouts/bridge.md | 41 +++ .../references/layouts/circular-flow.md | 41 +++ .../references/layouts/comic-strip.md | 41 +++ .../references/layouts/comparison-matrix.md | 41 +++ .../references/layouts/dashboard.md | 41 +++ .../references/layouts/dense-modules.md | 72 ++++++ .../references/layouts/funnel.md | 41 +++ .../references/layouts/hierarchical-layers.md | 48 ++++ .../references/layouts/hub-spoke.md | 41 +++ .../references/layouts/iceberg.md | 41 +++ .../references/layouts/isometric-map.md | 41 +++ .../references/layouts/jigsaw.md | 41 +++ .../references/layouts/linear-progression.md | 48 ++++ .../references/layouts/periodic-table.md | 41 +++ .../references/layouts/story-mountain.md | 41 +++ .../layouts/structural-breakdown.md | 48 ++++ .../references/layouts/tree-branching.md | 41 +++ .../references/layouts/venn-diagram.md | 41 +++ .../references/layouts/winding-roadmap.md | 41 +++ .../references/structured-content-template.md | 244 ++++++++++++++++++ .../references/styles/aged-academia.md | 36 +++ .../references/styles/bold-graphic.md | 36 +++ .../references/styles/chalkboard.md | 61 +++++ .../references/styles/claymation.md | 29 +++ .../references/styles/corporate-memphis.md | 29 +++ .../references/styles/craft-handmade.md | 44 ++++ .../references/styles/cyberpunk-neon.md | 29 +++ .../references/styles/hand-drawn-edu.md | 63 +++++ .../references/styles/ikea-manual.md | 29 +++ .../references/styles/kawaii.md | 29 +++ .../references/styles/knolling.md | 29 +++ .../references/styles/lego-brick.md | 29 +++ .../references/styles/morandi-journal.md | 60 +++++ .../references/styles/origami.md | 29 +++ .../references/styles/pixel-art.md | 29 +++ .../references/styles/pop-laboratory.md | 48 ++++ .../references/styles/retro-pop-grid.md | 47 ++++ .../references/styles/storybook-watercolor.md | 29 +++ .../references/styles/subway-map.md | 29 +++ .../references/styles/technical-schematic.md | 36 +++ .../references/styles/ui-wireframe.md | 29 +++ 46 files changed, 2404 insertions(+) create mode 100644 skills/creative/baoyu-infographic/SKILL.md create mode 100644 skills/creative/baoyu-infographic/references/analysis-framework.md create mode 100644 skills/creative/baoyu-infographic/references/base-prompt.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/bento-grid.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/binary-comparison.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/bridge.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/circular-flow.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/comic-strip.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/comparison-matrix.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/dashboard.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/dense-modules.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/funnel.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/hierarchical-layers.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/hub-spoke.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/iceberg.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/isometric-map.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/jigsaw.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/linear-progression.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/periodic-table.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/story-mountain.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/structural-breakdown.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/tree-branching.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/venn-diagram.md create mode 100644 skills/creative/baoyu-infographic/references/layouts/winding-roadmap.md create mode 100644 skills/creative/baoyu-infographic/references/structured-content-template.md create mode 100644 skills/creative/baoyu-infographic/references/styles/aged-academia.md create mode 100644 skills/creative/baoyu-infographic/references/styles/bold-graphic.md create mode 100644 skills/creative/baoyu-infographic/references/styles/chalkboard.md create mode 100644 skills/creative/baoyu-infographic/references/styles/claymation.md create mode 100644 skills/creative/baoyu-infographic/references/styles/corporate-memphis.md create mode 100644 skills/creative/baoyu-infographic/references/styles/craft-handmade.md create mode 100644 skills/creative/baoyu-infographic/references/styles/cyberpunk-neon.md create mode 100644 skills/creative/baoyu-infographic/references/styles/hand-drawn-edu.md create mode 100644 skills/creative/baoyu-infographic/references/styles/ikea-manual.md create mode 100644 skills/creative/baoyu-infographic/references/styles/kawaii.md create mode 100644 skills/creative/baoyu-infographic/references/styles/knolling.md create mode 100644 skills/creative/baoyu-infographic/references/styles/lego-brick.md create mode 100644 skills/creative/baoyu-infographic/references/styles/morandi-journal.md create mode 100644 skills/creative/baoyu-infographic/references/styles/origami.md create mode 100644 skills/creative/baoyu-infographic/references/styles/pixel-art.md create mode 100644 skills/creative/baoyu-infographic/references/styles/pop-laboratory.md create mode 100644 skills/creative/baoyu-infographic/references/styles/retro-pop-grid.md create mode 100644 skills/creative/baoyu-infographic/references/styles/storybook-watercolor.md create mode 100644 skills/creative/baoyu-infographic/references/styles/subway-map.md create mode 100644 skills/creative/baoyu-infographic/references/styles/technical-schematic.md create mode 100644 skills/creative/baoyu-infographic/references/styles/ui-wireframe.md diff --git a/skills/creative/baoyu-infographic/SKILL.md b/skills/creative/baoyu-infographic/SKILL.md new file mode 100644 index 000000000..fea3499cb --- /dev/null +++ b/skills/creative/baoyu-infographic/SKILL.md @@ -0,0 +1,236 @@ +--- +name: baoyu-infographic +description: Generate professional infographics with 21 layout types and 21 visual styles. Analyzes content, recommends layout×style combinations, and generates publication-ready infographics. Use when user asks to create "infographic", "visual summary", "信息图", "可视化", or "高密度信息大图". +version: 1.56.1 +author: 宝玉 (JimLiu) +license: MIT +metadata: + hermes: + tags: [infographic, visual-summary, creative, image-generation] + homepage: https://github.com/JimLiu/baoyu-skills#baoyu-infographic +--- + +# Infographic Generator + +Adapted from [baoyu-infographic](https://github.com/JimLiu/baoyu-skills) for Hermes Agent's tool ecosystem. + +Two dimensions: **layout** (information structure) × **style** (visual aesthetics). Freely combine any layout with any style. + +## When to Use + +Trigger this skill when the user asks to create an infographic, visual summary, information graphic, or uses terms like "信息图", "可视化", or "高密度信息大图". The user provides content (text, file path, URL, or topic) and optionally specifies layout, style, aspect ratio, or language. + +## Options + +| Option | Values | +|--------|--------| +| Layout | 21 options (see Layout Gallery), default: bento-grid | +| Style | 21 options (see Style Gallery), default: craft-handmade | +| Aspect | Named: landscape (16:9), portrait (9:16), square (1:1). Custom: any W:H ratio (e.g., 3:4, 4:3, 2.35:1) | +| Language | en, zh, ja, etc. | + +## Layout Gallery + +| Layout | Best For | +|--------|----------| +| `linear-progression` | Timelines, processes, tutorials | +| `binary-comparison` | A vs B, before-after, pros-cons | +| `comparison-matrix` | Multi-factor comparisons | +| `hierarchical-layers` | Pyramids, priority levels | +| `tree-branching` | Categories, taxonomies | +| `hub-spoke` | Central concept with related items | +| `structural-breakdown` | Exploded views, cross-sections | +| `bento-grid` | Multiple topics, overview (default) | +| `iceberg` | Surface vs hidden aspects | +| `bridge` | Problem-solution | +| `funnel` | Conversion, filtering | +| `isometric-map` | Spatial relationships | +| `dashboard` | Metrics, KPIs | +| `periodic-table` | Categorized collections | +| `comic-strip` | Narratives, sequences | +| `story-mountain` | Plot structure, tension arcs | +| `jigsaw` | Interconnected parts | +| `venn-diagram` | Overlapping concepts | +| `winding-roadmap` | Journey, milestones | +| `circular-flow` | Cycles, recurring processes | +| `dense-modules` | High-density modules, data-rich guides | + +Full definitions: `references/layouts/.md` + +## Style Gallery + +| Style | Description | +|-------|-------------| +| `craft-handmade` | Hand-drawn, paper craft (default) | +| `claymation` | 3D clay figures, stop-motion | +| `kawaii` | Japanese cute, pastels | +| `storybook-watercolor` | Soft painted, whimsical | +| `chalkboard` | Chalk on black board | +| `cyberpunk-neon` | Neon glow, futuristic | +| `bold-graphic` | Comic style, halftone | +| `aged-academia` | Vintage science, sepia | +| `corporate-memphis` | Flat vector, vibrant | +| `technical-schematic` | Blueprint, engineering | +| `origami` | Folded paper, geometric | +| `pixel-art` | Retro 8-bit | +| `ui-wireframe` | Grayscale interface mockup | +| `subway-map` | Transit diagram | +| `ikea-manual` | Minimal line art | +| `knolling` | Organized flat-lay | +| `lego-brick` | Toy brick construction | +| `pop-laboratory` | Blueprint grid, coordinate markers, lab precision | +| `morandi-journal` | Hand-drawn doodle, warm Morandi tones | +| `retro-pop-grid` | 1970s retro pop art, Swiss grid, thick outlines | +| `hand-drawn-edu` | Macaron pastels, hand-drawn wobble, stick figures | + +Full definitions: `references/styles/