Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
c52cbf6
feat: Ctrl+G external editor for input + /keys command
iRonin Apr 3, 2026
9cdf7c5
feat: Ctrl+S input stash with auto-restore, image support, and UI ind…
iRonin Apr 3, 2026
57b012d
fix: don't treat bare file paths as slash commands
iRonin Apr 3, 2026
b23b93f
fix: Ctrl+D deletes char under cursor, only exits on empty input (bas…
iRonin Apr 3, 2026
dce4eb6
docs: update /keys to reflect Ctrl+D delete-char behaviour
iRonin Apr 3, 2026
633eef1
feat: Alt+Enter queues follow-up messages without interrupting agent
iRonin Apr 3, 2026
f0b6ad5
feat: Alt+Up recalls queued follow-ups back into input
iRonin Apr 3, 2026
479bb91
fix: no separator before first recalled follow-up
iRonin Apr 3, 2026
e9963d1
fix: add -m/--model and --provider flags to root hermes parser
iRonin Apr 3, 2026
7795321
fix: use UUID tags for followup cancellation, fix phantom queue pops
iRonin Apr 3, 2026
fc69dd2
feat: set terminal window/tab title with session name and thinking in…
iRonin Apr 3, 2026
a85eb92
feat: double ESC clears input, Ctrl+P peeks paste, \r\n normalisation
iRonin Apr 3, 2026
599ee2f
feat: Ctrl+P history pager + /history full (newest first, full text)
iRonin Apr 3, 2026
001f34f
fix: tab title shows symbol only (⚕), drop text and Python process name
iRonin Apr 3, 2026
9b67e46
fix: write OSC title sequences to real stdout, bypassing patch_stdout…
iRonin Apr 3, 2026
9487b9d
feat: add display.terminal_title config opt-out
iRonin Apr 3, 2026
24a0ef9
feat: /browser connect profile — launch Chrome with real profile and …
iRonin Apr 3, 2026
3bcce0d
feat: /browser connect auto-launches Chrome with Hermes profile
iRonin Apr 4, 2026
72215e0
fix: write terminal title through prompt_toolkit Output to avoid rend…
iRonin Apr 4, 2026
c0ccc0d
feat: dual queue — 📬 follow-up (Alt+Enter) + 🎯 steering (Enter/queue …
iRonin Apr 4, 2026
ba9d73c
fix: show recall shortcuts in placeholder when queues are active
iRonin Apr 4, 2026
48cbde2
fix: shorten idle queue placeholder to '📬 2 (Alt+↑)'
iRonin Apr 4, 2026
0b5144a
fix: set terminal title from inside running app via call_from_executor
iRonin Apr 4, 2026
caaa77c
fix: write terminal title via os.ctermid() — bypasses all I/O layers
iRonin Apr 4, 2026
6a463f6
docs: update /keys with Ctrl+P, ESC ESC, and dual-queue shortcuts
iRonin Apr 4, 2026
b5e00c4
debug: explicit title write with error reporting in /title handler
iRonin Apr 4, 2026
72cef2f
fix: use OSC 0 + ST terminator (\x1b\\) matching what works in iTerm2
iRonin Apr 4, 2026
8c28f70
debug: try all title write paths with visible output
iRonin Apr 4, 2026
d30ad38
fix: show session title in tab — '⚕ My Session' not just '⚕'
iRonin Apr 4, 2026
14e432b
feat: configurable full user message display + Ctrl+O toggle
iRonin Apr 4, 2026
277f55c
feat(gateway): model override via chat completions request model field
iRonin Apr 4, 2026
1860d3e
fix: browser CDP from config + tab title appends ⏳ instead of replacing
iRonin Apr 4, 2026
252154e
feat: show session title in response panel header
iRonin Apr 4, 2026
1311512
fix(stash): don't auto-restore over non-empty buffer
iRonin Apr 4, 2026
77c2485
fix: maxItems schema matches default of 3
iRonin Apr 4, 2026
38aa3fa
feat(/resume): pipe all sessions through less pager when no arg given
iRonin Apr 4, 2026
85f217b
feat(/resume): interactive prompt_toolkit session picker
iRonin Apr 4, 2026
47705b1
feat(queues): steering_dispatch + followup_dispatch config
iRonin Apr 4, 2026
8e879f4
fix(/resume): label→title consistency + order by last_active
iRonin Apr 4, 2026
2b87222
feat(/resume): display.resume_include_gateway config
iRonin Apr 4, 2026
3c233af
feat(gateway): GET /v1/sessions endpoint
iRonin Apr 4, 2026
852150d
feat: subagent control panel (Ctrl+X)
iRonin Apr 5, 2026
86be2cb
fix(subagent-panel): correct box frame math + status bar hint
iRonin Apr 5, 2026
ebd04c5
fix(skill_manager): use patchable SKILLS_DIR in _find_skill
iRonin Apr 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,266 changes: 1,152 additions & 114 deletions cli.py

Large diffs are not rendered by default.

137 changes: 121 additions & 16 deletions gateway/platforms/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- GET /v1/responses/{response_id} — Retrieve a stored response
- DELETE /v1/responses/{response_id} — Delete a stored response
- GET /v1/models — lists hermes-agent as an available model
- GET /v1/sessions — lists sessions from the shared DB (source/limit/offset)
- GET /health — health check

Any OpenAI-compatible frontend (Open WebUI, LobeChat, LibreChat,
Expand Down Expand Up @@ -400,6 +401,7 @@ def _create_agent(
session_id: Optional[str] = None,
stream_delta_callback=None,
tool_progress_callback=None,
model_override: Optional[str] = None,
) -> Any:
"""
Create an AIAgent instance using the gateway's runtime config.
Expand All @@ -408,13 +410,21 @@ def _create_agent(
base_url, etc. from config.yaml / env vars. Toolsets are resolved
from config.yaml platform_toolsets.api_server (same as all other
gateway platforms), falling back to the hermes-api-server default.

If *model_override* is provided and is not "hermes-agent", it is used
as the model instead of the value from config.yaml. This lets Open
WebUI (or any OpenAI-compatible frontend) select the underlying LLM
via the ``model`` field in the chat completions request.
"""
from run_agent import AIAgent
from gateway.run import _resolve_runtime_agent_kwargs, _resolve_gateway_model, _load_gateway_config
from hermes_cli.tools_config import _get_platform_tools

runtime_kwargs = _resolve_runtime_agent_kwargs()
model = _resolve_gateway_model()
if model_override and model_override not in ("hermes-agent",):
model = model_override
else:
model = _resolve_gateway_model()

user_config = _load_gateway_config()
enabled_toolsets = sorted(_get_platform_tools(user_config, "api_server"))
Expand Down Expand Up @@ -446,25 +456,71 @@ async def _handle_health(self, request: "web.Request") -> "web.Response":
return web.json_response({"status": "ok", "platform": "hermes-agent"})

async def _handle_models(self, request: "web.Request") -> "web.Response":
"""GET /v1/models — return hermes-agent as an available model."""
"""GET /v1/models — return hermes-agent plus per-provider model list."""
auth_err = self._check_auth(request)
if auth_err:
return auth_err

return web.json_response({
"object": "list",
"data": [
{
"id": "hermes-agent",
"object": "model",
"created": int(time.time()),
"owned_by": "hermes",
"permission": [],
"root": "hermes-agent",
"parent": None,
}
],
})
now = int(time.time())

# Always include the default hermes-agent entry (uses config.yaml model)
models = [
{
"id": "hermes-agent",
"object": "model",
"created": now,
"owned_by": "hermes",
"permission": [],
"root": "hermes-agent",
"parent": None,
"description": "Default model from config.yaml",
}
]

# Add per-provider models based on which API keys are configured
try:
from hermes_cli.models import _PROVIDER_MODELS
import os

_provider_env: list[tuple[str, str]] = [
("anthropic", "ANTHROPIC_API_KEY"),
("openrouter", "OPENROUTER_API_KEY"),
("nous", "NOUS_API_KEY"),
("deepseek", "DEEPSEEK_API_KEY"),
("zai", "GLM_API_KEY"),
("kimi-coding", "KIMI_API_KEY"),
("minimax", "MINIMAX_API_KEY"),
("opencode-zen", "OPENCODE_ZEN_API_KEY"),
("opencode-go", "OPENCODE_GO_API_KEY"),
]

seen: set[str] = {"hermes-agent"}
for provider, env_var in _provider_env:
if not os.getenv(env_var):
continue
for model_id in _PROVIDER_MODELS.get(provider, []):
if model_id in seen:
continue
seen.add(model_id)
# Normalise to provider/model format for anthropic native
display_id = (
f"anthropic/{model_id}"
if provider == "anthropic" and "/" not in model_id
else model_id
)
models.append({
"id": display_id,
"object": "model",
"created": now,
"owned_by": provider,
"permission": [],
"root": display_id,
"parent": None,
})
except Exception:
pass # Fall back to hermes-agent only

return web.json_response({"object": "list", "data": models})

async def _handle_chat_completions(self, request: "web.Request") -> "web.Response":
"""POST /v1/chat/completions — OpenAI Chat Completions format."""
Expand Down Expand Up @@ -571,6 +627,7 @@ def _on_tool_progress(name, preview, args):
stream_delta_callback=_on_delta,
tool_progress_callback=_on_tool_progress,
agent_ref=agent_ref,
model_override=model_name,
))

return await self._write_sse_chat_completion(
Expand All @@ -585,6 +642,7 @@ async def _compute_completion():
conversation_history=history,
ephemeral_system_prompt=system_prompt,
session_id=session_id,
model_override=model_name,
)

idempotency_key = request.headers.get("Idempotency-Key")
Expand Down Expand Up @@ -1180,6 +1238,50 @@ async def _handle_run_job(self, request: "web.Request") -> "web.Response":
except Exception as e:
return web.json_response({"error": str(e)}, status=500)

async def _handle_sessions(self, request: "web.Request") -> "web.Response":
"""GET /v1/sessions — list sessions from the shared DB."""
auth_err = self._check_auth(request)
if auth_err:
return auth_err

source_param = request.rel_url.query.get("source") or None
try:
limit = min(int(request.rel_url.query.get("limit", 50)), 200)
except (ValueError, TypeError):
limit = 50
try:
offset = max(int(request.rel_url.query.get("offset", 0)), 0)
except (ValueError, TypeError):
offset = 0

db = self._ensure_session_db()
if db is None:
return web.json_response({"object": "list", "data": [], "count": 0})

try:
sessions = db.list_sessions_rich(
source=source_param,
exclude_sources=["tool"],
limit=limit,
offset=offset,
)
except Exception as e:
logger.warning("list_sessions_rich failed: %s", e)
return web.json_response({"error": str(e)}, status=500)

data = [
{
"id": s.get("id"),
"title": s.get("title"),
"preview": s.get("preview"),
"last_active": s.get("last_active"),
"source": s.get("source"),
"message_count": s.get("message_count"),
}
for s in sessions
]
return web.json_response({"object": "list", "data": data, "count": len(data)})

# ------------------------------------------------------------------
# Output extraction helper
# ------------------------------------------------------------------
Expand Down Expand Up @@ -1245,6 +1347,7 @@ async def _run_agent(
stream_delta_callback=None,
tool_progress_callback=None,
agent_ref: Optional[list] = None,
model_override: Optional[str] = None,
) -> tuple:
"""
Create an agent and run a conversation in a thread executor.
Expand All @@ -1265,6 +1368,7 @@ def _run():
session_id=session_id,
stream_delta_callback=stream_delta_callback,
tool_progress_callback=tool_progress_callback,
model_override=model_override,
)
if agent_ref is not None:
agent_ref[0] = agent
Expand Down Expand Up @@ -1298,6 +1402,7 @@ async def connect(self) -> bool:
self._app.router.add_get("/health", self._handle_health)
self._app.router.add_get("/v1/health", self._handle_health)
self._app.router.add_get("/v1/models", self._handle_models)
self._app.router.add_get("/v1/sessions", self._handle_sessions)
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
self._app.router.add_post("/v1/responses", self._handle_responses)
self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ class CommandDef:
CommandDef("commands", "Browse all commands and skills (paginated)", "Info",
gateway_only=True, args_hint="[page]"),
CommandDef("help", "Show available commands", "Info"),
CommandDef("keys", "Show keyboard shortcuts", "Info",
cli_only=True, aliases=("shortcuts",)),
CommandDef("usage", "Show token usage for the current session", "Info"),
CommandDef("insights", "Show usage insights and analytics", "Info",
args_hint="[days]"),
Expand Down
30 changes: 22 additions & 8 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3983,6 +3983,18 @@ def main():
metavar="SESSION_NAME",
help="Resume a session by name, or the most recent if no name given"
)
parser.add_argument(
"-m", "--model",
default=None,
metavar="MODEL",
help="Model to use for this session (e.g. anthropic/claude-sonnet-4-6)"
)
parser.add_argument(
"--provider",
default=None,
metavar="PROVIDER",
help="Inference provider (e.g. anthropic, openrouter)"
)
parser.add_argument(
"--worktree", "-w",
action="store_true",
Expand Down Expand Up @@ -5375,10 +5387,11 @@ def cmd_acp(args):
if (args.resume or args.continue_last) and args.command is None:
args.command = "chat"
args.query = None
args.model = None
args.provider = None
args.toolsets = None
args.verbose = False
# model and provider already set from root parser — don't stomp them
if not hasattr(args, "toolsets"):
args.toolsets = None
if not hasattr(args, "verbose"):
args.verbose = False
if not hasattr(args, "worktree"):
args.worktree = False
cmd_chat(args)
Expand All @@ -5387,10 +5400,11 @@ def cmd_acp(args):
# Default to chat if no command specified
if args.command is None:
args.query = None
args.model = None
args.provider = None
args.toolsets = None
args.verbose = False
# model and provider already set from root parser — don't stomp them
if not hasattr(args, "toolsets"):
args.toolsets = None
if not hasattr(args, "verbose"):
args.verbose = False
args.resume = None
args.continue_last = None
if not hasattr(args, "worktree"):
Expand Down
Loading
Loading