Add hermes_cli/config_backend.py (config-config design §4.1, D10/D11): a
ConfigBackend protocol, the FileBackend (today's behaviour), selection by
HERMES_CONFIG_BACKEND only (default file; 'remote' and unknown values fail
closed with ConfigBackendUnavailable), and path-level helpers
(read_config_doc, read_config_doc_readonly, config_version, config_exists,
write_config_key, write_config_document, supports_file_tooling).
Migrate every direct reader/stat/exists/bypass writer of a user config.yaml
to the helpers (config.py loaders + caches, config_effective, env_loader,
gateway, TUI, CLI watchers, cron, tools, profiles, logging, time). File
tooling (config edit, backups, xAI migration rewrite, raw dashboard editor)
checks supports_file_tooling() first; migrations skip write-back without it.
Add scripts/check_config_yaml_readers.py (CI lint next to the writers gate)
that rejects a direct read of a config path outside the backend.
- run_agent / gateway shutdown / cli + hermes -q atexit / /browser and the
browser.manage RPC import cleanup_*, emergency_cleanup_all_sessions,
get_cloud_provider, get_browser_engine, the lightpanda status pair and the
CDP override + supervisor helpers from tools.browser_session_{lifecycle,
cloud,cdp} and tools.browser_lightpanda;
- per-turn cleanup reads browser.headed through is_headed_mode() only (the
AGENT_BROWSER_HEADED fallback is gone with agent-browser);
- tests repointed; tests that only covered browser_vision / the built-in
tool wrapper are dropped, the browser child env is credential-free per
the session layer, and the codex MCP list must name core tools only.
browser_exec is the only core browser path, so there is no Browser Use /
built-in tools switch to flip and no silent downgrade to announce.
- classic CLI, Ink TUI and the gateway browser.manage RPC lose the `use`
action (contract regenerated: BrowserAction, enabled, browser_use);
- /browser status no longer reports a mode; the Chromium-fallback line for
Lightpanda and the dead "chrome" engine row go with the built-in tools;
- browser_backend_notice leaves the session snapshot, the CLI banner and
the canonical contract.
TUI locale packs and _keys.tui.json drop the five slashCmd.ops.browser
keys whose only caller was `/browser use`.
PluginContext.ask_choice(question, choices) -> str | None lets a sync
slash-command handler ask the user who ran it to pick one option and get
the picked value back. Choices are strings or {value, label, description}.
Each surface binds an asker around the handler call
(hermes_cli/plugin_choices.py), and every asker is that surface's
existing clarify prompt. No surface gets a new card type or wire shape:
- messaging chats (gateway/plugin_command_choice.py): adapter.send_clarify
plus the clarify tool's own delivery (_clarify_send_then_wait, text
fallback, card retire). Telegram/Discord/Slack show buttons, other
platforms a numbered list. The typed reply reaches the clarify
text-intercept while the command still holds the chat.
- local sessions on the gateway (classic CLI, Ink TUI, Desktop): the
`clarify` server request to the session's viewers. Two fixes make the
answer arrive while slash.exec is still waiting for the handler:
* tui_gateway/ws.py: a viewer's answer to an open server request (a
response frame, `clarify.lock`) is settled at once instead of queuing
behind that socket's running slash.exec.
* hermes_cli/gateway_plugin_commands.py: the classic CLI answers the
session's `clarify` requests with its clarify panel while it waits for
slash.exec. No turn pump reads the session's frames between turns.
- in-process classic CLI and the legacy `hermes serve` backend: their
clarify panel / `clarify` server request.
Returns None when nothing can ask (hooks, cron, async handlers on the
event loop), when the user cancels or answers with something that is not a
choice, or when the clarify timeout passes. The handler then prints its
text usage.
`hermes -w` (classic, not `--tui -w`) runs its session's tools in the
new worktree: the gateway policy freezes the tree as the session's cwd.
Quit removes a clean tree, and a later `hermes -c` / `--resume` of that
session then had every turn refused `workspace_unavailable`: the owner
refuses a missing directory.
The `--tui -w` resume fix (N8) already recreates a missing tree on
resume, but only one its launcher recorded, and it looked for it in
the session row's cwd. A classic `-w` launcher recorded nothing, and
its row keeps main's stamp, the repo root (e504e030e3, @teknium1,
2026-10-12), not the tree its tools run in.
- The classic launcher passes the profile's state.db to
`_setup_worktree`, as `--tui -w` does: the tree is recorded for
resume, and its lock names the store. The row's cwd stays the repo
root, so a reaped clean classic `-w` tree is not kept; it is
recreated on resume.
- The resume step reads the session's frozen policy cwd (its local
receipt; a read) before the row's cwd, since that is the directory
the owner checks. A store error on that read falls back to the row's
cwd.
Trees are still removed on quit.
Refs #134889 (CLI-15)
Refs #139158
The idle tick queued the started admission's text, and run_conversation
pumped it only when the text it got still matched. The input loop and
chat() clean the text first (leaked CPR or mouse reports, paste markers,
lone surrogates). A queued prompt with any of those no longer matched, so
run_conversation sent a new prompt.submit while the owner was already
running the queued one, and the prompt ran twice.
The seeded item now carries the admission id. When the input loop takes
it, that id is armed on the session state, and run_conversation pumps
exactly that admission. A typed input with the same text is its own
submit.
Refs #134889 (N5)
Widen the contributor's opt-in `source=` to the calling-session context plugins
asked for (#140199, #91526, #123245): a `ctx.register_command` handler that
declares any of `session_id`, `session_key`, `platform`, `source` (or
`**kwargs`) receives them; one-argument handlers keep `handler(raw_args)`.
- One call boundary (hermes_cli/plugin_command_dispatch.py) used by the gateway
(messaging platforms and local CLI/Desktop sessions via
`_hm_run_plugin_handler`), the TUI/Desktop RPC (`_run_plugin_command`) and
the classic CLI's off-gateway fallback. The first positional parameter
always gets raw_args whatever its name.
- Messaging plugin commands look up the chat's current session entry (never
create one), so HERMES_SESSION_ID / `session_id` are the id a later /new
reports as old_session_id instead of "" (#123245).
- Fold the contributor's separate gateway adapter module back into
`_hm_run_plugin_handler` so the local-session path shares it.
- Tests trimmed to two invariants, both red on origin/main.
Ignored files in a worktree (.env copies, caches, build and test output,
launcher scripts) are never meant to be preserved: removing a worktree
deletes them, the same as every other worktree tool. The keep-ignored-work
rule kept 461 of 574 real trees forever, so it is removed with its
regenerable-dir list and main-checkout byte comparison.
Kept: keep-large-untracked, keep-unarchivable (embedded repos, including
ones inside ignored dirs, whose commits may exist nowhere else; an
over-budget ignored walk now reports keep-unarchivable), keep-worktree-refs,
the submodule rule, the showUntrackedFiles=no and skip-worktree fixes, and
the staged-edit recheck.
The second review found trees deleted with bytes or commits that were in
neither git nor the archive: untracked files over archive_max_file_mb
(skipped from the snapshot, tree still removed), gitignored embedded
repositories (ls-files --exclude-standard never lists them) and every
ignored file (.env edits, *.secret, info/exclude notes). The classic
pruner had three older gaps of the same class: status.showUntrackedFiles=no
made new files invisible, skip-worktree/assume-unchanged edits read clean,
and commits held only by refs/worktree/* or refs/bisect/* died with the
tree's admin dir.
Rule now enforced at one decision point (worktree_retention.removal_blocker)
used by archive_preflight, the post-snapshot removal boundary in
archive_then_remove (sweep, exit Archive, subagent finalize), the classic
clean-tree pruner, the attended `hermes worktree prune` and the clean exit
path: a tree is removed only when everything irreproducible is in git or the
archive. Kept: keep-large-untracked (archive ref stays), keep-unarchivable
(embedded repo, ignored or not), keep-worktree-refs, keep-ignored-work
(ignored files outside a regenerable-dir allowlist that are not
byte-identical to the main checkout's copy, so .worktreeinclude .env copies
never block). Exit Archive and the subagent note name the reason. Every
dirty check forces --untracked-files=normal and counts flagged-index edits.
The ignored-path walk skips dependency stores without descending and is
capped per tree (unverifiable -> keep).
Review of the snapshot-then-delete sweep found paths where it could remove
work it should keep or store bytes that differ from the working files:
- `hermes worktree prune --older-than N` ran the sweep before applying N; the
plan now marks any tree idle < N days skip-young before anything runs.
- execute_plan checked liveness once per batch, then archived trees one by one.
Each tree is now re-gated (lock, /proc cwd/fds, fresh activity) right before
it is touched, and archive_then_remove re-checks after the snapshot, keeping
the tree when a process entered or a file changed during capture.
- _last_activity ignored deleted paths; it now stats each changed path's
nearest surviving folder, so a fresh deletion counts as activity.
- `--attr-source` does not cover info/attributes or core.attributesFile, so
eol rules still rewrote bytes. Every blob is re-hashed with
`hash-object --no-filters` and restore writes blob bytes directly.
- The classic CLI applied the exit Archive answer in atexit, after the exit
watchdog was armed; it is now applied synchronously in _tui_shutdown, with
a progress line, before _run_cleanup. A failed or interrupted archive keeps
the tree.
- web_git.worktree_add's existingBranch path skipped the repo registry.
- subagent-* trees got the 72h named-tree tier; one prune_tier_hours helper
now gives hermes-* and subagent-* the 24h tier in both planners.
- worktree_sweep.py had a text=True git call without encoding="utf-8" (CI red).
The worktree pruner never removed a dirty or unpushed tree at any age, so leftovers of finished
lanes (stray probe files, WIP commits on closed PRs) accumulated forever: 658 trees / ~1 TB on
one machine, 553 of them named lane trees. Approved design (Teknium, 2026-10-17):
- Snapshot-then-delete: an idle tree past its tier (hermes-*/subagent-* 24h, named 72h) with
uncommitted changes or unpushed commits is archived to refs/hermes/archive/<tree> (HEAD +
staged + unstaged + non-ignored untracked bytes, built with a temporary index; large untracked
files skipped and listed) and then removed. A failed archive keeps the tree. Branch kept when
published on a remote, otherwise the archive anchors its commits.
- Never touched at any age or budget: live hermes lock owners, any live process whose cwd/open fd
is inside the tree (/proc on Linux, psutil cwd elsewhere), trees with recent file/ref activity,
Kanban t_<hex> trees.
- No count cap; optional worktrees.max_total_gb evicts the oldest idle trees first.
- Per-profile registry (<HERMES_HOME>/worktree_repos.json) of every repo Hermes made a worktree
in (hermes -w, /worktree new, subagent isolation, Desktop git pane); the 6h cron sweep unions
it with its existing sources, scoped and throttled per profile.
- Subagent worktrees with commits/edits are archived and removed at child finish (branch kept).
- Interactive `hermes -w` (CLI and --tui) exit with a dirty/unpushed tree asks Keep (default) /
Archive & remove / Remove and discard; non-interactive exits never ask.
- `hermes worktree restore <name> [--path]`, archives in `hermes worktree list`, a full
decision table in `hermes worktree prune --dry-run`, archive retention (30 days).
- worktrees.archive_dirty: false restores the classic never-delete-dirty pruner exactly.
/config (cli.py show_config) and the agent's key banner (_print_key_banner)
printed "Microsoft Entra ID" for every callable api_key, so a custom
provider's key_cmd bearer or MiniMax OAuth read as Azure. /config also
picked Vertex out with a __module__ string compare, and the banner had no
Vertex case at all.
Each builder now tags the callable it returns with a source
(agent/credential_source.py): build_token_provider -> entra-id,
vertex_access_token -> vertex-oauth, CommandTokenSource -> key-cmd,
build_minimax_oauth_token_provider -> minimax-oauth. Both surfaces resolve
the label through one source-to-label table. An untagged callable gets the
neutral "Token provider" label. The tag is read with getattr, so no display
path calls the callable (no token minted just to mask it).
New catalog keys key_cmd_token, minimax_oauth and token_provider in every
locale (English text, matching the brand-name convention of the existing
microsoft_entra_id / google_vertex_oauth keys).
Tests: tests/agent/test_credential_source_labels.py, one red/green case per
label on each surface (7 red before: key_cmd/minimax/unknown on /config,
vertex/key_cmd/minimax/unknown on the banner). Each case's callable raises
if invoked. The old "any other callable is Entra" pin now uses a real
Entra-tagged provider.
Review finding by @unsupportedpastels on #140469
Review finding by @alt-glitch on #140469
Refs #134889 (T6)
Refs #140337
The gateway-backed classic CLI resolves Vertex under deferred_token_mint,
so its api_key is agent.vertex_adapter.vertex_access_token: a callable
that mints a Google OAuth token on first use. /config treated every
callable as an Azure token provider and printed "Microsoft Entra ID".
It now prints "Google OAuth (Vertex AI)" for the Vertex callable and still
never invokes it: masking a real token would take a second JWT exchange
from the CLI process, the double mint the one-gateway rewire removed (the
gateway session mints the token for the turn). Other token providers keep
the Entra label. New catalog key in every locale (English text; the
language packs already carry the English "Microsoft Entra ID" there).
Refs #134889 (T6)
Refs #140337
On a gateway session the classic CLI now reads its skill commands from the
gateway's catalog, not this process's scan. _process_unregistered_slash took
that list before running a cached plugin command. When a plugin reload had
dropped the command, run() re-read the catalog (GONE), but dispatch kept
using the list it took before. A same-named skill the same reload brought
was not run on that first try; the input got the "no longer has this plugin
command" line instead.
- After GONE, dispatch takes the session's skill commands again, so it uses
the list from the re-read.
- Lane 3's tests gave /pptwin a skill through a mock of the CLI's local
scan, which a gateway session no longer reads. The daemon test now
installs a real pptwin skill in the profile, and the unit tests list it in
the gateway's catalog.
- _dropped still clears only the cached plugin list. Its re-read (_read)
publishes the plugin list and the skill list together. If the re-read
fails, the next lookup reads both again, so invalidate() would change
nothing anyone can see.
Refs #134889 (PLG-7)
Refs #139523
After the cutover no local surface fired pre_command where the command runs.
slash.exec and command.dispatch (classic CLI forwards, Ink TUI, Desktop) never
fired it, and the classic CLI fired it from its own second copy of the plugin.
- The gateway fires pre_command for every recognized command a local session
runs, under the session's profile: before the handler for commands it runs
itself (slash.exec, command.dispatch; an accepted TUI read fires once inside
the read path), and for commands the classic CLI runs in its own process
through the new command.observe (plain data: the command as typed and its
arguments). command.observe replies first, so the CLI never waits on a
plugin. The hook arguments keep main's values: surface and platform "cli"
on every local surface, session_key the session id.
- It fires only for commands that run. A busy rejection or a named refusal
fires nothing; during a turn it fires when the busy dispatcher accepts the
command, so a refused /goal, /loop or empty /queue or /steer fires nothing
either. Every command that does run mid-turn fires, as main's CLI did,
except messaging's escape hatches (/stop and the approval controls).
- Each delivery waits out its callback on its own thread, never the shared
executor, with at most one delivery in flight per session: one session's
deliveries fire in order and a slow callback never holds up another
session's. The hook dispatcher's existing bounds apply
(plugins.hook_callback_timeout and its abandoned-worker cap), and with
plugins.isolation: host the hook runs in the plugin host. pre_command takes
that bounded path everywhere, so off the gateway its callbacks also run on a
worker thread.
- On a gateway session the classic CLI loads no plugin code. It reads skills,
plugin commands and plugin toolsets from the gateway (commands.catalog,
scoped to the session's own folder, and plugins.list) for /help, skill
dispatch and completion, /tools and the status and banner fallbacks. -s and
skills.auto_load are rendered in the gateway, and /reload-skills and
/skills install rescan there and drop the CLI's cached catalog. These reads
are cached per gateway life and never served once the connection has hung
up. The "Unknown toolsets" warning comes from the gateway (a new
unknown_toolsets field when the session opens). An older gateway without
command.observe gets one named notice, and nothing fires locally.
- The canonical gateway connection serves plugins.list for its profile:
loaded plugins with tool/hook/command counts, load error, toolsets and
portable MCP server names (optional fields added to the contract; TS and
OpenRPC regenerated). On a gateway session the CLI's /plugins reads it
instead of this process's plugin manager. Off the gateway the in-process
paths are unchanged.
Key-less inject_message (B2-11), which the classic CLI's own plugin copy
used to serve, is handled in the next commit.
Refs #134889 (PLG-7)
Refs #139523
The classic CLI cached the gateway's plugin command names per session and
gateway life. A `reload-plugins` that removes a command (`hermes plugins
disable`, or a plugin that stops registering it) changes neither, so the
cached name kept going to `slash.exec` and every later try printed a bare
`unsupported_command`; a same-named skill never ran until `/new`.
When `slash.exec` refuses the name with `unsupported_command` (or answers
it as no plugin run), the CLI now drops its cached catalog, reads it once
more, and if the name is no longer a plugin command dispatches it again
through the usual order: bundle, skill, prefix expansion. When nothing
else matches it prints the existing "the gateway no longer has this plugin
command; nothing was run" line instead of the raw code. Tested against a
real daemon for both a disabled plugin and a plugin that stops
registering a command a skill shares.
Refs #134889 (CLI-13)
Refs #134889 (PLG-7)
Refs #139523
The classic CLI ran a plugin's /command through its own in-process copy of
the plugin, while the plugin's hooks, tools and MCP servers live in the
gateway. ctx.call_mcp from the command therefore failed with "MCP server
'x' is not connected".
On a gateway session the CLI now sends the command to the gateway with
slash.exec, which already runs plugin commands through the messaging
gateway's own plugin dispatch, and prints the reply. The CLI reads plugin
command names (for dispatch and tab completion) from the gateway's
commands.catalog instead of importing plugin code: once per session and
gateway life (/new, resume and a redial read again), and once more when a
typed name misses, so a plugin the gateway enabled since still dispatches.
A read is cached only under the gateway epoch that served it (call_tagged)
and the session captured before the call; a reply that arrives after a
concurrent redial or session change is not cached as current.
When the catalog cannot be read, or the gateway answers but reports that its
plugin discovery failed (the new commands.catalog field
plugin_discovery_error; older gateways without it keep today's behaviour),
the CLI prints the gateway's error and runs nothing for that input (no
skill, bundle or prefix expansion stands in); completion degrades quietly.
Off the gateway (tests, the TUI slash worker's shadow CLI) the in-process
path is unchanged.
A daemon test runs the command through the real slash.exec handler and
checks that the handler and ctx.call_mcp run in the gateway process. PLG-7
is only partly fixed: the CLI process still loads plugin code for /plugins
and the pre_command hook. Nothing fires twice: the gateway's slash.exec path
does not fire pre_command, and the CLI suppresses on_session_finalize/reset
for gateway sessions.
Refs #134889 (CLI-13)
Refs #134889 (PLG-7)
Refs #139523
- `coding_selection` returns None before probing the folder unless
`agent.coding_context` is `focus` (the only mode that swaps toolsets),
so the default `auto` no longer runs ~200 stat calls per CLI start,
TUI agent build and gateway session creation for a result it discards.
- `_resolve_toolsets` converts the posture to a set once instead of
each caller.
- cli.py: `profile_default` was always `not toolsets`; test that directly.
The gateway now resolves it from the session cwd; the CLI keeps the list only for its local display.
(cherry picked from commit 75cb9203d09178a56aef54a6aa4827e3d26aa7bb)
- CLI-2: cli._explicit_toolsets records an explicit -t, and the resume
check compares only that against the session's frozen toolsets. The
CLI's resolved default selection still never refuses a resume.
- The classic CLI's own -w system note is removed: the gateway-backed CLI
never sent its system prompt, and the gateway now renders the worktree
note from the session's folder (CLI-4).
Refs #134889
With no -t, the classic CLI resolved platform_toolsets.cli itself and sent the
list as session.create `toolsets`, which the gateway treats as an explicit pick
and validates. A stale entry (a toolset whose plugin was uninstalled) then
refused every `hermes chat` with invalid_params. Send `toolsets` only for a -t
pick or the coding-posture selection; the gateway resolves the profile default
itself, and the CLI keeps its "Unknown toolsets" startup warning.
Fixes#138670
The classic CLI drives a GatewayAgent while tools, checkpoints and the
environment live in the gateway, so several commands read empty CLI-process
state.
- CLI-7 /agents lists the gateway's processes and its running-subagent count.
- CLI-8 /diff session goes to the gateway's /diff handler via slash.exec
(it crashed on the proxy's missing checkpoint manager).
- CLI-17 /handoff says it is not available on the shared gateway yet, as
Ink and Desktop do, instead of silently doing nothing.
- CLI-21 /reload also reloads the gateway's .env (reload.env) and reports
the gateway's count.
- CLI-23 /insights --source cli also matches `local` rows whose
created_source is that surface (older databases fall back to the label).
- N4: /stop typed during a turn interrupts it like Ctrl+C (its declared
interrupt_then_dispatch policy), then stops background processes as
before. An interrupt before the first lease attempt no longer says
"Stopped waiting for another Hermes process".
- W2: tools/bot_live_delivery.py skips chmod(0o700) on Windows, where it
rewrote the NTFS ACL and the Telegram adapter crash-looped on its lock.
Refs #134889 (CLI-7, CLI-8, CLI-17, CLI-21, CLI-23, N4, W2)
Per the command-layer ruling these change session/agent state, so the gateway handler decides and
applies them (the same handler every surface reaches); the classic CLI prints its reply instead of
changing its own copy. /busy is also mirrored locally because the CLI enforces what Enter does
while a turn runs. Off the gateway, main's handlers still run.
The gateway session runs every turn and owns MCP servers, shell hooks, outbound webhooks,
state.db / checkpoint maintenance and async delegations (session policy, owner housekeeping).
The CLI process no longer:
- waits on / starts MCP discovery before its (gateway-backed) agent build or on the Termux
deferred path;
- registers shell hooks / outbound webhooks for `hermes` / `hermes chat` launches
(accept_hooks still rides on session.create);
- runs state.db archive/prune/VACUUM or checkpoint pruning at startup (a second writer);
- tears down MCP servers or interrupts async delegations on exit.
LEDGER: NEW-startup-trim (DB-WRITE-023, IPS-057, IPS-058, IPS-059, IPS-140).
(cherry picked from commit 4dbd74263cb3a36eb73c932cc61be805d08aac08)
The maintainer's requirement is main's classic CLI, unchanged, with the gateway
behind the agent seam. cmd_chat and cli.main() handed every classic launch to the
thin gateway REPL (hermes_cli/gateway_chat_view.py). Now:
- cmd_chat: only stream-json and the --safe-mode / --ignore-user-config bypass keep
the gateway transport client; interactive and -q/-Q reach cli.main exactly as on
main. The TUI flag refusal applies to --tui launches only.
- cli.py is byte-identical to origin/main (the launch_from_kwargs hand-off and the
"refused on the gateway path" docstring are gone).
- The fast chat path restores main's deferred-startup hints for the interactive
prompt (HERMES_DEFER_AGENT_STARTUP / HERMES_FAST_STARTUP_BANNER, accept_hooks).
The agent itself becomes gateway-backed at the AIAgent seam in the next commits.
Test: tests/hermes_cli/test_classic_cli_routing.py (red on cli-base: 3 failed).
(cherry picked from commit b0f08281a3f905cc1da0a1cb0822238b02e2a441)
Conflicts: tests/hermes_cli/test_finite_chat_delegation.py and
tests/tools/test_completed_process_results.py, where main's lint sweep (0006a5e229) added
check=False to subprocess.run calls this branch had rewritten. Kept the branch's test bodies
and carried check=False onto their subprocess.run calls.
17 conflicts, all from main's lint sweep (ruff UP007/UP017/UP022/UP028/UP031/UP037/UP041,
FURB188/FURB192); no other main commit touches a conflicted file. Resolution: the branch side
of each file plus main's exact autofix (ruff 0.15.10, main's ruff.strict.toml, --fix
--unsafe-fixes). ruff.strict.toml takes main's copy. The pool-retirement live fixture stays
deleted with the pooled backend this branch retires.
`hermes chat` refused bare -c, --resume latest, --list-tools and --list-toolsets (exit 2), and
`chat -q` dropped main's "Resume this session with: hermes --resume <id>" footer, which scripts
parse.
- bare -c: this terminal's breadcrumb session (main's rule) when the owner still has it, else
the owner's most recent CLI session; --resume latest: the most recent CLI session. Both are
resolved by the owner (session.resume latest='cli' [workspace]) with main's
_resolve_last_session rule -- cli+oneshot sources, this workspace (git root / cwd) first,
then global -- so the client still opens no database. Another principal's sessions are
skipped, never confirmed. Interactive launches write the terminal breadcrumb again.
- --list-tools / --list-toolsets: print the catalog for this profile's CLI selection (or
--toolsets) and exit, without a session or a gateway, as main did.
- chat -q without -Q: the single-query exit block again (with -p for a named profile).
- cli.main docstring: drop the --image / -w / --skills examples that exit 2 now.
-w, --image, --run-budget, -v, --compact stay documented refusals (launcher-side worktree,
attachment staging, per-launch budget, display-only flags).
Review: https://github.com/NousResearch/hermes-agent/pull/106742#issuecomment-6073624820
(cherry picked from commit 80c6d9564cf301bc25c75a40ecab3e75c1f63b84)
8 conflicts, all from main's lint sweep (#135281 "Harder linter fixes" + the RUF series:
implicit-Optional, unused-unpacked-variable, next(...) for [...][0]). No other main commit touches
a conflicted file. Resolution: the branch side of each file plus main's exact autofix
(ruff 0.15.10, main's ruff.strict.toml, --fix --unsafe-fixes) on it. ruff.strict.toml takes main's
copy (the newly enabled rules). tests/cron/test_bot_chat_delivery_child_env.py stays deleted
(1cc9cb9199 retired its lane).
45 conflicts, all from #135177 (lefthook + `ruff check --fix` over 2,366 files). No other main commit
touches a conflicted file. Resolution: the branch side of each file, then main's exact autofix
(ruff 0.15.10, `--config ./ruff.strict.toml --fix`) on it — rewrites only (typing generics, noqa
drops, import forms), no import removed. tests/cron/test_bot_chat_cli_home.py stays deleted
(1cc9cb9199 retired its lane).
Conflicts (dacb06ea93 on_session_finalize messages):
- gateway/run_shutdown.py: both imports (managed-turn shutdown + GatewaySessionEndMixin).
- ui-tui useSessionLifecycle: main's deferred finalize messages kept; the branch's rule that a
canonical (gateway-owned) session is detached, never closed, still gates session.close.
A plugin's on_session_finalize hook can now return a str or {"message": str}
and Hermes shows it at the session boundary without starting a turn or
rewriting the model reply:
- CLI /new: printed before the new session; CLI quit: printed above the
exit summary; one-shot -q/-z: printed to stderr (stdout stays the answer)
- TUI: session.close returns the messages; /new shows them as system lines
- Gateway /new and shutdown: sent to the owning chat via the adapter, while
adapters are still connected
on_session_end stays observer-only: it fires after every turn, not at a
session boundary. Gateway finalize dispatch moves to
gateway/run_shutdown_session_end.py.
12 conflicted files, resolved in three lanes:
- gateway/run.py: take the branch split; main's tirith removal and spool-replay
ordering ported into run_runtime_init.py / run_bootstrap.py.
- config migrations: main keeps v50 (tirith); the branch's bot-chat timeout removal
becomes v51 (_config_version 51, template + test updated).
- hermes_state_usage.py: main's voice_chat task route threaded through the branch's
owner transaction; worker.persist usage allowlist accepts task.
- tui_gateway/server.py: branch side; main's edits applied where the code now lives
(agent_factory, session_registry). Desktop sessions get the catalog toolset on the
authority path; daemon-run finite CLI turns are not watched turns; lazy session info
reports nous/welcome on the free tier.
- Desktop/Ink: one branch-child spinner fix (main's route-token check); main's
uninstall i18n keys kept, pool keys stay removed; voice_turn forwarded and accepted
by the authority submit (surface_v1.voice_turn).
- chat_ws / PTY keepalive test: main's stricter test kept.
tirith ran `tirith check` on every terminal command, default-on with fail-open, as a
pinned PM binary auto-downloaded at startup. Measured against the shipped 0.4.2 binary
it caught 14/16 attack commands but flagged 12/24 everyday agent commands
(`echo $((6*7))`, `"$VAR/x.sh"`, `gh api | python3`, LAN curls, `.dev` hosts,
`pip install` via analysis_incomplete), and in cron/-q deny mode each of those is a
hard block. It only ever saw terminal strings (execute_code, writes and the browser
bypass it), fail-open meant no protection wherever the binary could not install
(Windows, Termux, x64 musl), and it produced a steady issue/PR stream (fail_open
semantics four times, install loops filling disks, a SIGSEGV hang, session-approval
scoping, third-party promo text on approval cards).
Removed: tools/tirith_security.py, the tirith branches of the command guard (the
is_tirith tuple, session-only "always" downgrade, import-error/fail-open paths,
unattended tirith pass), the CLI and gateway startup installers, the PM package and
lock pins, the nix runtime dep, security.tirith_* defaults, the CLI warning string
and three tirith tips (gaps refilled from the tail so translations survive), plus
the gateway's "no automated assessor" warning that only existed to point at tirith.
Config v50 drops security.tirith_* and enables nothing in its place. Hermes ships
no tirith plugin; if tirith upstream publishes one, it goes through normal catalog
admission under their name.
Re-review of the B2 fix found the stacked token scan default flipped to
native mode while the CLI call site still used the default: stacked
/plugin:skill tokens in the REPL silently degraded to literal prompt
text. The CLI is an interactive surface — pass interactive=True there;
the messaging gateway keeps its explicit filesystem-only path.
Adds a CLI-side regression test that loads both bodies through the real
HermesCLI method and fails when the interactive flag is dropped.
Include namespaced ctx.register_skill entries in CLI/TUI completion and
invocation while keeping native messaging command maps portable. Cover
compatible -s preloading and preserve disabled, platform, environment,
and profile gates.
Based on the qualified interactive-registry approach from #85725.
Co-authored-by: Konrad Stawiski <konrad.stawiski@umed.lodz.pl>
(cherry picked from commit cd3e30b5ceb5da9ef9daabc2458d0ea2e340da04)
Gate 2c/quality finding (Low): when some -s skills loaded, cli.py,
hermes_cli/oneshot.py and tui_gateway/server.py still logged "Unknown
skill(s) requested, skipping: Ambiguous skill name dup-demo: ..." --
calling an ambiguous name unknown, the exact wording 3b6f5eb813 removed
from the all-missing error. All three now log format_missing_skills(),
and the "Ambiguous skill name " prefix lives in one constant
(agent.skill_utils.AMBIGUOUS_SKILL_PREFIX) shared by the skill_view
error, the preload/cron label and format_missing_skills' classifier.
`hermes -s dup-demo` (CLI, oneshot, TUI/kanban worker) died with "Unknown
skill(s): dup-demo" and cron logged "skill not found, skipping" for a name that
exists twice. skill_view's refusal now carries load_names; preload labels the
failed identifier "Ambiguous skill name X: use one of <paths>" (the label is
computed only on the failure path) and format_missing_skills() keeps that
wording in the raised error; cron skips with the same message and lists it in
the job's skipped-skills notice.
_include_symlink_paths took the main checkout as the parent of
--git-common-dir. For a submodule (common dir .git/modules/<name>) or a
--separate-git-dir repo that is not the checkout, so no .worktreeinclude
symlink matched and every include-only worktree was kept forever as
"dirty" — a regression against BASE, which removed it.
_worktree_is_dirty / _include_symlink_paths now take repo_root, and the
CLI exit cleanup, the startup pruner and kanban teardown pass the one
they already hold. When it is None, fall back to the first
`git worktree list --porcelain` entry resolved through
`rev-parse --show-toplevel` (a submodule lists its git dir there).
Also share the .worktreeinclude parsing between the copy step and the
symlink check (_worktreeinclude_entries), compare with rstrip('/') only,
drop the backslash rewrite, and drop the redundant inner try/except —
_worktree_is_dirty's outer handler already fails safe to dirty.
Every path past the existence check (dirty keep, unpushed keep, remove)
cleared _active_worktree separately; set it once right after the check
so a new branch can't forget it. Reuse TestWorktreeLockReaping._mk in the
exit-cleanup test instead of duplicating its worktree setup.
The salvaged fix keeps dirty session worktrees, but routed them through the
unpushed-commits branch: the user saw "unpushed commits" (or, on the default
shallow install, "shallow clone ... prunes automatically") plus a hint to run
`git worktree remove --force`, which would destroy the very changes we kept.
Check dirty state first and print a dedicated cli.worktree.uncommitted_keeping
message with no --force hint. Key added to every locale with the sibling
cli.worktree keys. Extends the contributor test to assert the message.
Fixes#129482.
Co-authored-by: Yuan Li <dskwelmcy@163.com>
The top-level import was unguarded (its neighbours already had try/except
patterns), so a partial prompt_toolkit install — e.g. a stale venv with the
package present but a submodule broken — crashed every import of cli,
including tui_gateway.slash_worker, which every gateway session spawns.
The only use site is a 'with patch_stdout():' block, so fall back to
contextlib.nullcontext, mirroring agent/display.py's guarded
patch_stdout StdoutProxy import.
Fixes#96075