# NousResearch/hermes-agent · TUI slash command dispatch bugs and fixes

## Open now

- 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.

## TL;DR

- 6 member(s): 0 open issue(s), 3 closed issue(s), 0 open PR(s), 3 merged PR(s), 0 PR(s) closed without merging.
- A fix was merged to main in PR #12141. Verify it addresses your case.
- **Recommended:** read PR #12141 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-12141,pr-62859,pr-121077`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member 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.

## Closed / duplicate issues

- #12143: TUI: Skill slash commands silently do nothing
- #62863: \[Bug\]: custom skill-bundle slash commands hang under --tui
- #64578: \[Bug\]: /steer and /queue submitted after response completes are silently demoted or stranded (CLI + Desktop)

## Prior & competing PRs

- **#12141: fix(tui): clickable hyperlinks and skill slash command dispatch**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`
  - fix hunks:
  - `tests/tui_gateway/test_protocol.py`

    ```diff
    @@ -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`

    ```diff
    @@ -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:
    ```

    ```diff
    @@ -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}")
     
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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({
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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) => {
    ```

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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>
    ```

    ```diff
    @@ -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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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 · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - 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`

## Fix status

- Merged to main in PR #12141 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-09-24`.
- Subsystem: `hermes_cli`
