Commit Graph
1403 Commits
Author SHA1 Message Date
Ben Barclay 871900df63 feat(config): route every user config.yaml read and write through a ConfigBackend seam
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.
2026-10-25 18:54:09 -07:00
Teknium d6a5251d93 refactor(browser): callers import the browser_session layer
- 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.
2026-10-23 01:41:24 -07:00
Teknium 433e420be4 refactor(browser): drop /browser use, the browser.manage use action and the downgrade notice
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`.
2026-10-23 01:41:24 -07:00
Teknium 49520132f4 feat(plugins): plugin slash commands can ask the user to pick an option
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.
2026-10-22 14:37:10 -07:00
kshitijk4poor 26409f994c fix(cli): a classic -w session gets its clean tree back on resume
`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
2026-10-20 15:08:20 +05:30
Teknium 5f39eb5221 fix(cli): adopt a started /queue admission by its id, never by its text
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)
2026-10-19 07:02:06 -07:00
Teknium 0975937593 feat(plugins): pass the calling session to plugin slash commands on every surface
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.
2026-10-18 01:27:20 -07:00
Teknium 06671b7075 fix: worktree removal treats ignored files as disposable
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.
2026-10-17 21:49:14 -07:00
Teknium ab1c29d8bb fix: worktree sweep keeps any tree holding work an archive cannot restore
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).
2026-10-17 21:49:14 -07:00
Teknium 71a83d74d5 fix: worktree sweep honours --older-than, re-gates each tree, archives exact bytes
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).
2026-10-17 21:49:14 -07:00
Teknium 3882113085 feat: archive stale dirty worktrees before removing them, sweep every repo, ask on dirty exit
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.
2026-10-17 21:49:14 -07:00
Teknium e7a81e7520 fix(cli): label a callable API key by its credential source, not by "is it callable"
/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
2026-10-17 07:23:06 -07:00
Teknium bf59361d20 fix(cli): /config names a Vertex route's credential as Google OAuth
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
2026-10-17 07:20:30 -07:00
unsupportedpastels 6df7e00d41 fix(cli): take the gateway's skill list again when a dropped plugin command is dispatched again
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
2026-10-16 19:39:48 -04:00
unsupportedpastels 4b6a8edd6a fix(plugins): fire pre_command in the gateway for every local command
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
2026-10-16 19:39:48 -04:00
unsupportedpastels 247ce9eedc fix(cli): dispatch a plugin command again once a gateway reload dropped it
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
2026-10-16 16:09:24 +00:00
unsupportedpastels b16cd8f66f fix(cli): run classic CLI plugin slash commands in the gateway
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
2026-10-16 12:47:19 +00:00
kshitijk4poor 1d39fab8b9 refactor(gateway): skip the workspace probe outside focus and drop redundant posture plumbing
- `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.
2026-10-15 13:27:53 +05:30
unsupportedpastels aca77bf059 fix(cli): stop sending the coding posture as a toolsets pick
The gateway now resolves it from the session cwd; the CLI keeps the list only for its local display.

(cherry picked from commit 75cb9203d09178a56aef54a6aa4827e3d26aa7bb)
2026-10-15 13:27:53 +05:30
unsupportedpastels e65b61c37e fix(cli): check an explicit -t on resume; drop the dead client worktree note
- 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
2026-10-15 00:07:21 -04:00
unsupportedpastels 1d2f5a441e fix(cli): leave the profile-default toolsets to the gateway on session.create
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
2026-10-14 22:31:10 -04:00
unsupportedpastels ecfcf103ad fix(cli): answer gateway-owned state from the gateway in classic CLI commands
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)
2026-10-14 20:47:20 -04:00
Teknium 2c6a8f0557 fix(cli): /personality /busy /approvals /footer /fast run the gateway's handler (slash.exec)
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.
2026-10-13 05:43:48 -07:00
Teknium 46a5a6278e fix(cli): no MCP, hook, webhook or maintenance work in the classic CLI process
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)
2026-10-12 22:48:29 -07:00
Teknium fb93022be7 feat(cli): hermes / hermes chat / -q run main's HermesCLI again
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)
2026-10-12 22:25:18 -07:00
Teknium b1ccce898b Merge origin/main (#38: 70 commits to c57331677d) into feat/unified-gateway-runtime
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.
2026-10-10 17:47:35 -07:00
Maciej Lisiewski 0006a5e229 ci: fix PLW1509, PLW1510, PLW2101 linter errors 2026-10-10 15:20:13 +02:00
Teknium 408cc0d12e Merge origin/main (#32: 36 commits to cf23a1a5cc) into feat/unified-gateway-runtime
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.
2026-10-09 10:24:04 -07:00
Maciej Lisiewski 2ff58849f4 ci: ruff UP037 fixes 2026-10-09 17:34:38 +02:00
Teknium 7b4a2c6ba1 fix(cli): bare -c, --resume latest, --list-tools(ets) and the resume footer on the gateway path
`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)
2026-10-09 02:10:48 -07:00
Teknium 607a23e858 Merge origin/main (#30: 54 commits to 1744a19e0d) into feat/unified-gateway-runtime
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).
2026-10-08 21:38:54 -07:00
Maciej Lisiewski 4278b6aa24 ci: RUF013, RUF015 2026-10-08 21:29:48 +02:00
Teknium 65bec86e37 Merge origin/main (#27: 19 commits to 517b5e10fe) into feat/unified-gateway-runtime
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).
2026-10-08 12:09:24 -07:00
Maciej Lisiewski c0fc687685 ci: address code review comments 2026-10-08 18:04:50 +02:00
Maciej Lisiewski d76cce4f11 ci: apply all automatic linter fixes available 2026-10-08 17:22:54 +02:00
Teknium 302894a2cd Merge origin/main (#25: 63 commits to 8f463316fd) into feat/unified-gateway-runtime
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.
2026-10-08 04:19:43 -07:00
teknium1 dacb06ea93 feat(plugins): show on_session_finalize return messages to the user
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.
2026-10-08 04:11:24 -07:00
Teknium ca38632fc0 Merge origin/main (#17: 454 commits to e6757f2029) into feat/unified-gateway-runtime
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.
2026-10-07 06:48:09 -07:00
Teknium 1e500ebce2 Merge origin/main (#16: 4,465 commits to 9dcab1e440) into feat/unified-gateway-runtime
99 conflicted files resolved in nine area lanes (Desktop composer, Desktop renderer/shared
client, Electron main, Ink, tui_gateway, gateway, state, CLI/config, cron/tools/CI). The
unified runtime's architecture wins where it deliberately replaced a mechanism (pool,
fallback lanes, claim poller, renderer queue); every orthogonal main change is kept, ported
into the <stem>_<topic> sibling that now owns relocated code.

Notable ports and repairs in this merge:
- tui_gateway/server.py relocated regions: atomic _attach_built_agent (#49852), slash ranking
  registry names, minted-key helpers methods_session.py calls, request_overrides, service tier.
- gateway/run_turn*.py: i18n accessors for the removed reply constants, first-contact
  internal/DM rule, rotated-compression persist, fast-mode tiers, prompt-pin rehydration.
- webhook: route_from_chat_id decodes main's opaque webhook:v2 chat ids (old ids still read).
- ws.py: main's per-connection dispatch queue with authority-first dispatch inside it.
- state: schema event for runtime revision renumbered to 31; worker mirrors updated for
  main's message uid/merge fields, on_contended lease arg, compression columns.
- config: branch's bot_chat_delivery_timeout removal renumbered v47 -> v50.
- Electron: primary-profile pin, redirect header scoping, remote fetchJsonForBackend, SSH
  teardown on profile delete, held-update dial gate restored (lost in earlier merges).
- Desktop: canonical clarify.request mapped to main's questions[] card; replay truncation vs
  snapshot_required; periodic secondary prune tick restored; DELETE already_absent restored.
- cron/tools: diagnostic category on cron failure notices restored; owner_holds_delivery
  ported onto the authority; admitted receipts can no longer be cancelled locally.
2026-10-06 14:24:24 -07:00
Teknium 911da42e9e refactor(security): remove the bundled tirith scanner from core
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.
2026-10-06 14:22:13 -07:00
Hermes Agent 0319b1505d fix(cli): keep stacked plugin skills working on the interactive CLI path
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.
2026-10-05 00:23:05 -07:00
Hermes Agent 3e68934a40 fix: keep interactive plugin skill routing in sync 2026-10-05 00:23:05 -07:00
Carl Taylor dc9ee6722a fix(skills): expose plugin skills to interactive commands
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)
2026-10-05 00:23:05 -07:00
kshitijk4poor fb6b900bc0 fix(skills): partial preload warning keeps the Ambiguous wording
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.
2026-10-04 02:54:56 +05:30
kshitijk4poor 7025dd1cee fix(skills): preload and cron say "Ambiguous" instead of "Unknown"/"not found"
`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.
2026-10-04 02:54:56 +05:30
kshitijk4poor 99839aad85 fix(worktree): resolve the main checkout from git, not --git-common-dir's parent
_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.
2026-10-02 00:54:45 +05:30
kshitijk4poor f6465a3079 refactor(cli): clear _active_worktree once in _cleanup_worktree
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.
2026-10-02 00:54:45 +05:30
kshitijk4poorandYuan Li cf15cd3186 fix(cli): say "uncommitted changes" when keeping a dirty worktree on exit
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>
2026-10-02 00:54:45 +05:30
Yuan Li acc56a8ea4 fix(cli): preserve dirty session worktrees
(cherry picked from commit 17bee6ef394b771c92b0e8f80cd3601b249e6dc3)
2026-10-02 00:54:45 +05:30
Brooklyn Nicholson cf9dbe5952 fix(cli): degrade gracefully when prompt_toolkit.patch_stdout is missing
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
2026-09-29 14:40:47 -05:00