TUI slash command dispatch bugs and fixes

NousResearch/hermes-agent · cluster 12143 · hermes cli

Data as of

Nothing open on GitHub. Fix merged in #12141. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 3 issues already closed.
Fix event
A fix was merged to main in PR #12141. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PRs #12141, #62859, #121077. That is separate from the fix event.
Still open
Nothing open on GitHub.

These members concern slash commands (/steer, /queue, and custom skill-bundle commands) silently failing or misbehaving in the TUI and CLI interfaces. The group includes bug reports and corresponding fix attempts addressing command dispatch, idle-state handling, and related TUI behavior.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #12141 fix(tui): clickable hyperlinks and skill slash command dispatch

    merged to main

    files: tests/tui_gateway/test_protocol.py, tui_gateway/server.py, ui-tui/src/__tests__/asCommandDispatch.test.ts, ui-tui/src/__tests__/createSlashHandler.test.ts, ui-tui/src/app/createSlashHandler.ts, ui-tui/src/app/useMainApp.ts, ui-tui/src/components/markdown.tsx, ui-tui/src/components/messageLine.tsx, ui-tui/src/gatewayTypes.ts, ui-tui/src/lib/rpc.ts, ui-tui/src/types/hermes-ink.d.ts

    Show the diff
    • tests/tui_gateway/test_protocol.py
      @@ -231,3 +231,201 @@ 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.get_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"]
      +
      +
      [redacted:email]("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_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"
      +    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"
    • tui_gateway/server.py
      @@ -1949,6 +1949,13 @@ def _(rid, params: dict) -> dict:
           ("/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:
      @@ -2117,6 +2124,75 @@ 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 <prompt>")
      +        return _ok(rid, {"type": "send", "message": arg})
      +
      +    if name == "retry":
      +        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")
      +        # 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:
      +            return _err(rid, 4004, "usage: /steer <prompt>")
      +        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
      +            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:
      +            return _err(rid, 5030, f"plan skill failed: {e}")
      +
           return _err(rid, 4018, f"not a quick/plugin/skill command: {name}")
       
       
      @@ -2333,6 +2409,26 @@ def _(rid, params: dict) -> dict:
           if not cmd:
               return _err(rid, 4004, "empty command")
       
      +    # 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.)
      +    _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 get_skill_commands
      +        _cmd_key = f"/{_cmd_base}"
      +        if _cmd_key in get_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:
    • 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()
         })
       })
    • ui-tui/src/__tests__/createSlashHandler.test.ts
      @@ -121,6 +121,67 @@ 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)
      +  })
      +
      +  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> = {}): Ctx => ({
    • 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)
             })
    • 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
    • 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) => (
      -  <Text color={t.color.amber} key={key} underline>
      -    {label}
      -  </Text>
      +const renderLink = (key: number, t: Theme, label: string, url: string) => (
      +  <Link key={key} url={url}>
      +    <Text color={t.color.amber} underline>
      +      {label}
      +    </Text>
      +  </Link>
       )
       
       const trimBareUrl = (value: string) => {
      @@ -38,9 +40,11 @@ const trimBareUrl = (value: string) => {
       }
       
       const renderAutolink = (key: number, t: Theme, raw: string) => (
      -  <Text color={t.color.amber} key={key} underline>
      -    {raw.replace(/^mailto:/, '')}
      -  </Text>
      +  <Link key={key} url={raw}>
      +    <Text color={t.color.amber} underline>
      +      {raw.replace(/^mailto:/, '')}
      +    </Text>
      +  </Link>
       )
       
       const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, '  ').length / 2)
      @@ -141,7 +145,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) {
               </Text>
             )
           } 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]) {
    • ui-tui/src/components/messageLine.tsx
      @@ -28,12 +28,19 @@ export const MessageLine = memo(function MessageLine({
         }
       
         if (msg.role === 'tool') {
      +    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 (
             <Box alignSelf="flex-start" borderColor={t.color.dim} borderStyle="round" marginLeft={3} paddingX={1}>
      -        <Text color={t.color.dim} wrap="truncate-end">
      -          {compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) ||
      -            '(empty tool result)'}
      -        </Text>
      +        {hasAnsi(msg.text) ? (
      +          <Text wrap="truncate-end"><Ansi>{msg.text}</Ansi></Text>
      +        ) : (
      +          <Text color={t.color.dim} wrap="truncate-end">
      +            {preview}
      +          </Text>
      +        )}
             </Box>
           )
         }
    • 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 ───────────────────────────────────────────────────────────
       
    • 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
       }
       
    • ui-tui/src/types/hermes-ink.d.ts
      @@ -63,6 +63,7 @@ declare module '@hermes/ink' {
         export const Box: React.ComponentType<any>
         export const AlternateScreen: React.ComponentType<any>
         export const Ansi: React.ComponentType<any>
      +  export const Link: React.ComponentType<{ readonly url: string; readonly children?: React.ReactNode; readonly fallback?: React.ReactNode }>
         export const NoSelect: React.ComponentType<any>
         export const ScrollBox: React.ComponentType<any>
         export const Text: React.ComponentType<any>
      @@ -92,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
  • #62859 fix(tui): dispatch custom skill bundles as agent turns

    merged to main

    files: tests/tui_gateway/test_protocol.py, tui_gateway/server.py

  • #121077 fix(tui-gateway): never park an idle /steer on the agent

    merged to main

    files: tests/tui_gateway/test_tui_gateway_server.py, tui_gateway/methods_session.py, tui_gateway/methods_tools.py, ui-tui/src/__tests__/steerCommand.test.ts, ui-tui/src/app/slash/commands/core.ts

Closed issues · 3
  • Closed#12143TUI: Skill slash commands silently do nothing
  • Closed#62863[Bug]: custom skill-bundle slash commands hang under --tui
  • Closed#64578[Bug]: /steer and /queue submitted after response completes are silently demoted or stranded (CLI + Desktop)
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.25: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
hermes cli
Merged PRs
#12141, #62859, #121077
Closed issues
3
Attempts
0 PRs closed without merging
Fix link checked
24 Sep 2026

For your agent

The same facts, typed.