Commit Graph

28271 Commits

Author SHA1 Message Date
briandevans eae1727721 fix(cli): route doctor's journal-mode probe through the pre-open reader
_read_journal_mode opened each Hermes database with a bare open(db_path,
"rb") to read header byte 18. The read itself is harmless; the close() is
not. Per sqlite.org/howtocorrupt.html, close() on *any* descriptor for a
file cancels every POSIX advisory lock this process holds on it — so the
close at the end of that with-block drops the locks a live connection is
holding, including the EXCLUSIVE lock a VACUUM holds while it rewrites the
whole file. Another process is then free to write into a file its writer
still believes it owns, which is the documented route to "database disk
image is malformed".

This is reachable. run_doctor is not only a standalone CLI process: the
dashboard console registers "doctor" (console_engine.py:570) and calls
run_doctor directly, in-process (console_engine.py:1297), on the web
server's console thread pool — in a process that holds live SessionDB
connections (web_server.py:11673, :11689). Typing "doctor" there
raw-opened and closed state.db, projects.db, response_store.db,
cron/executions.db and every board's kanban.db while those connections
were live. The HTTP route at /api/ops/doctor deliberately spawns a
subprocess instead; the console path did not.

hermes_cli.sqlite_safe_read exists to prevent exactly this, and its
read_header_bytes_preopen is documented as "the ONLY sanctioned
byte-level read of a database file". It performs the registry check and
the open/read/close together under the connection-lifecycle lock, so it
refuses once any connection to the path is live. The audit that converted
the other byte-probes (hermes_state.py:2750, backup.py:436,
kanban_db.py:1861) landed in 95fb477856 on 2026-07-25;
_read_journal_mode was added in 6583297086 on 2026-08-06 and reintroduced
the pattern, so this is a regression against an invariant the tree already
states, not a refactor preference.

The helper is a plain byte read, so the docstring's stated property is
preserved: no SQLite engine open, and no -wal/-shm sidecars are created.
Only the acquisition of `header` changes; the empty / not-a-database /
unrecognized-format-version branches are untouched.
2026-08-17 14:02:07 +05:30
kshitij 3d6cfdd83b fix: close sibling finalize path for handed-off sessions (#88234)
/simplify-code review found _notify_single_query_session_finalize was
missing the _handed_off_session_ids guard that _should_emit_cleanup_session_finalize
and _emit_interrupted_session_end already had. One-shot CLI queries that
somehow handed off would still finalize the session via this path.

Added guard + test.
2026-08-17 13:26:29 +05:30
kshitij 59f302fef9 fix: prevent handoff leg data loss + surface state.db corruption to users
Two data-loss bugs reported by users:

1. /handoff CLI→gateway race (#88234): After /handoff completed, CLI
   cleanup called finalize_session on the session the gateway just
   reopened. This set end_reason on a row the gateway was actively
   writing to, causing the handoff leg to vanish from session history
   and breaking session_search recall. Fix: add _handed_off_session_ids
   module-level set (mirrors _single_query_finalize_attempted_session_ids
   pattern). _handle_handoff_command registers the session_id on
   completion; _should_emit_cleanup_session_finalize and
   _emit_interrupted_session_end check it before firing.

2. state.db corruption silent failure (#88235): When SessionDB init
   failed at gateway startup, the error stayed in logs — messages
   flowed but nothing was persisted, with no user-visible indication.
   Fix: store _session_db_init_error on GatewayRunner, broadcast a
   recovery-guidance message to all home channels via
   _send_session_db_warning_notifications() after the gateway connects.
   Also improved the 'corrupt' persistence cause wording in
   _format_turn_completion_explanation to include the full recovery
   path (hermes doctor --fix, sqlite3 .recover, backups).

Tests: 6 new tests for handoff cleanup race, 3 for corruption wording.
All existing CLI/turn-completion tests pass.
2026-08-17 13:19:58 +05:30
Gille 040ee114aa fix(desktop): remove invalid get-windows recovery command 2026-08-17 01:33:02 -06:00
kshitij 8f97ae9aec Merge pull request #88202 from notwitcheer/docs-installation-migration-routing
docs: route getting-started and updating readers to backup/export migration paths
2026-08-17 12:57:26 +05:30
Teknium bceda18df0 docs: document MCP sanitization and tool-result annotations from the scout-slate wave
Post-merge docs sweep for the Aug 16 scout slate. Two pages:

- mcp.md: tool-result sanitization section — invisible Unicode TAG chars
  (U+E0000-E007F) stripped from results/resources/descriptions (#80689);
  vendor _meta surfaced to the model minus protocol-reserved
  modelcontextprotocol/mcp prefixes (#80712)
- tools.md: tool result annotations section — signal-death exit notes
  (subprocess -signum definite, shell 128+signum hedged) (#78074); UTF-16
  read_file transcoding with disclosure hint and 10MB cap (#80717)

Security-policy docs (approvals/allowlist) intentionally untouched.
2026-08-17 00:17:05 -07:00
kshitij b202293125 Merge pull request #87604 from ehz0ah/fix/openviking-env-reliability 2026-08-17 12:19:22 +05:30
hermes-seaeye[bot] cecb3a6ed7 fmt(js): npm run fix on merge (#88206)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-17 06:37:33 +00:00
Gille 2038d4034d fix(desktop): prevent deleted profile respawn 2026-08-16 23:31:12 -07:00
Teknium 9adc900ab2 fix(bot-mode): route bot-to-bot sends through --create-if-missing
The teammate-messaging protocol told bots to send with
`chat -c "Bot Chat" -Q -q` and, on 'No session found', fall back to a
manual two-step (send without -c, then sessions rename) — a dance the
CLI already made unnecessary when --create-if-missing landed (#86794).
Profiles that never went through the Bots-panel birth flow (CLI-created,
pre-Bot-Mode, remote-source) hit that miss on every first contact, and
background sends swallowed the error entirely (the original silent-drop
in Hermes-Bot-Mode#48 / #88059).

- tools/bot_mode_probe.py: protocol command gains --create-if-missing
- bundled plugin: Bot Chat prompt section + @mention handoff note use
  the flag; rename-dance instructions deleted

The capability epoch hashes the protocol section, so existing eternal
Bot Chat sessions pick the new instructions up on their next message via
the established once-per-change rebuild — no per-turn cache drift.

Live-verified: fresh profile with zero sessions, protocol command
created 'Bot Chat' and delivered (PONG round-trip); second send resolved
the same session by title (no duplicate); missing-title send WITHOUT the
flag still errors loudly on stderr.
2026-08-16 23:28:13 -07:00
elphamale e0ce06e358 fix(test): drive MCP identity-header tests through sdk_httpx(), not httpx directly
mcp 2.0 moved the SDK's HTTP stack to httpx2, so patching httpx.AsyncClient
no longer intercepts the client Hermes builds for the SDK — the exact
pattern test_mcp_client_cert.py already documents and works around.
This file predates that awareness; 3 of its 13 tests were silently
asserting on an unpatched client and failing under the real mcp==2.0.0
install this branch pins.
2026-08-16 23:26:10 -07:00
elphamale 77ed1bbf40 fix(mcp): seed MCP-Protocol-Version from the handshake version, not the latest
The HTTP transport seeded `MCP-Protocol-Version` from LATEST_PROTOCOL_VERSION,
which on mcp 2.x is 2026-07-28 — a revision that replaced the `initialize`
handshake with a per-request envelope. But this transport connects through
`ClientSession.initialize()`, which sends LATEST_HANDSHAKE_VERSION (2025-11-25)
in the body. Header and body therefore disagreed by construction, and a
conforming 2.x server honours the header: it routed the request onto its
per-request-envelope ladder and rejected the legacy body with

    params._meta is missing the required envelope key(s):
    io.modelcontextprotocol/protocolVersion,
    io.modelcontextprotocol/clientCapabilities

Observed against a live MCP endpoint, and confirmed by probing the same
endpoint three ways: the header at 2026-07-28 is rejected, at 2025-11-25 it
succeeds, and with no header at all it succeeds.

Third defect in this migration from one cause: the 2.x bump changed what an
existing constant *means* without revisiting its uses. The header seed was
written when LATEST_PROTOCOL_VERSION was 2025-03-26 and was correct then.

Seeded from LATEST_HANDSHAKE_VERSION, imported with a fallback to
LATEST_PROTOCOL_VERSION for SDKs predating the split, where the two are the
same thing and header and body agree either way. An explicitly configured
header still wins — that override is why servers demanding a specific revision
can have one, and a test pins it.
2026-08-16 23:26:10 -07:00
elphamale 2e1d724e3e fix(mcp): accept both SDK generations' streamable-HTTP transport arity
`streamable_http_client` yields `(read, write, get_session_id)` on mcp 1.x and
`(read, write)` on 2.x. `_run_http` unpacked a fixed 3-tuple, so on 2.x every
HTTP and SSE MCP server failed its handshake with `ValueError: not enough
values to unpack (expected 3, got 2)` and parked after exhausting its retry
ladder. Only stdio servers kept working.

This is the same defect as the import gating fixed earlier in this branch, one
layer further in. That fix's own comment claimed reaching
`streamable_http_client` was "the path that does work" — reaching it was
necessary and not sufficient, and the comment asserted the half that was never
exercised. Corrected along with the code.

Unpacked positionally rather than by arity, since this file deliberately
supports both SDK generations and `get_session_id` was never used here.

The reason this survived review is worth the test it now has: the existing
coverage in test_mcp_client_cert.py fakes the transport with a 3-tuple, so it
encoded 1.x's shape into the assertion and passed on 2.x regardless. The new
test drives `_run_http` once per arity the supported SDK range actually yields,
and asserts the streams handed to ClientSession are the first two — positional,
because 1.x's third element is not a stream. Verified it fails on the 2.x case
without this change.

Found while pointing a real HTTP MCP server at a live deployment running this
branch: the server parked at startup and no tool from it ever registered.
2026-08-16 23:26:10 -07:00
elphamale 48470f8628 test(mcp): port the slash-worker discovery probe to MCPServer
`tests/tui_gateway/test_slash_worker_mcp_discovery.py` gated on
`pytest.importorskip("mcp.server.fastmcp")` and generated a probe server
that imported `FastMCP`. Since this branch removes that module by pinning
mcp 2.x, the whole file skipped instead of running — and a skip caused by
our own dependency change reads as a pass in a per-file sweep, which is how
it got missed.

The test is real end-to-end coverage of the path this branch migrates: it
spawns an MCP server subprocess, registers it as a profile-local
`mcp_servers` entry, drives `/tools` through the slash worker, and asserts
the prefixed tool name is discovered. Port the probe to
`mcp.server.MCPServer` (`run(transport="stdio")` is unchanged) so it
exercises the migrated client instead of skipping.

The module-level gate now checks for `MCPServer` specifically, so a
FastMCP-era SDK still skips cleanly rather than failing on the 2.x-only
probe.

Verified it now executes rather than skips: 1 passed in 5.37s, versus
"1 skipped in 1.04s" before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:26:10 -07:00
elphamale 11a9dcf567 feat(mcp): migrate to the mcp 2.x SDK
mcp 2.0.0 implements MCP revision 2026-07-28 and makes three breaking
changes Hermes sits on top of: `mcp.server.fastmcp` is gone, every model
field is renamed to snake_case (camelCase survives only as a
serialization alias, which pydantic does not expose to attribute
access), and the SDK's own HTTP stack moved from `httpx` to `httpx2`.

Bump the pin across the dev/mcp/computer-use extras and port the tree:

- `mcp_serve.py` and `agent/transports/hermes_tools_mcp_server.py` move
  from `FastMCP` to `mcp.server.MCPServer`, which has the same
  decorator/add_tool surface. The hermes-tools server already
  synthesised `__signature__` from Hermes' JSON Schema, which is exactly
  what 2.0's `add_tool` reads.
- SDK model reads go through `mcp_field(obj, snake, camel)`, which reads
  both spellings. A single-spelling read fails *silently* on the other
  generation — empty tool schemas, dropped structured content, tool
  results vanishing from sampling conversations — and `mcp` is an
  optional extra users install at their own version.
- `sdk_httpx()` resolves the httpx flavour from the SDK's own transport
  module, so objects handed to `streamable_http_client`, the `sse_client`
  factory, and the OAuth metadata helpers come from the module the
  installed SDK actually imports.
- HTTP support is gated on either streamable-HTTP entry point, not just
  the deprecated alias 2.0 removed.
- OAuth: `OAuthClientProvider` lost its `timeout` argument (the
  configured `oauth.timeout` now bounds the callback waiter's own poll
  loop, where the browser round-trip was always awaited), and
  `callback_handler` must return `AuthorizationCodeResult` rather than a
  tuple. 2.0 also validates the RFC 9207 `iss` parameter, so the
  callback handler and paste fallback capture it.

`mcp`/`mcp-types` 2.0.0 are inside the 14-day `exclude-newer` window, so
two narrow `exclude-newer-package` entries unblock `uv lock`, annotated
for removal on or after 2026-08-11. `httpx2` needs no exemption: 2.7.0 is
already outside the window and satisfies mcp's floor.

Refs #69931

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:26:10 -07:00
Teknium 7a2ad9ae55 chore: map contributor emails for PYTHONPATH salvage (Yiipu, mmchuangyt-ai) 2026-08-16 23:26:04 -07:00
Yiipu 8e7a5f63b5 test(terminal): cover Check 3 depth=1 and depth=2 boundaries
Add two boundary tests for _strip_mismatched_site_packages Check 3.
test_repo_root_stripped covers depth=0 (repo root itself). depth=1
(direct child like repo/tools) and depth=2 (repo/tools/environments)
were untested. depth=1 must be stripped. depth=2 must be preserved.
Both compute the repo root independently from the source file path.
2026-08-16 23:26:04 -07:00
Yiipu 2824899321 fix(terminal): correct off-by-one in _hermes_repo_root path resolution
_hermes_repo_root used parents[1] which resolves to tools/ instead of
the repository root. The file lives at tools/environments/local.py so it
needs parents[2] to reach the actual repo root that Electron injects
into PYTHONPATH.

The test test_repo_root_stripped reused the module constant under test
as its input. This made it pass regardless of what the constant pointed
at. The test now computes the real repo root independently from the
source file location. It fails with the old parents[1] code and passes
with the fix.

Reported by spfcraze in PR #78917 review.
2026-08-16 23:26:04 -07:00
mcjoys 43c463fa95 fix(terminal): strip Hermes-venv site-packages from terminal subprocess PYTHONPATH to prevent cross-version ABI conflicts
The Desktop Electron process injects the Hermes venv's site-packages path
(e.g. .../python3.11/site-packages) into PYTHONPATH so the Python 3.11
backend can import its packages. When this PYTHONPATH leaks into terminal
subprocesses running a different Python version (e.g. Python 3.13), 3.11
C extension modules appear on sys.path ahead of the correct 3.13 versions
and crash with ImportError (PIL _imaging, cryptography, etc.).

Replace the existing blunt pop of PYTHONPATH from _ACTIVE_VENV_MARKER_VARS
with a surgical Hermes-venv-aware filter:

- Parse each PYTHONPATH entry by path
- Strip only paths under ~/.hermes/hermes-agent/venv/.../site-packages
- Preserve the Hermes source root (needed for import hermes_cli)
- Preserve all user-set PYTHONPATH entries

The same filter is applied in all three env builders:
- _make_run_env (foreground terminal commands)
- _sanitize_subprocess_env (background/PTY spawns)
- PTY env builder

This preserves env_passthrough semantics and never silently discards the
user's own PYTHONPATH configuration.
2026-08-16 23:26:04 -07:00
witcheer f4d8a0c5e9 docs: route getting-started and updating readers to backup/export migration paths 2026-08-17 06:20:50 +00:00
pierrenode 93ed11379b fix(desktop): recover the tile and regenerate paths from a dead runtime id
after a stale runtime-session drop (the sleep/wake 404 that resumes the
stored session and retries once), but two call sites still build their
gateway call directly instead of routing through withSessionNotFoundResume:

- session-tile-actions.ts's own cancelRun/steerPrompt/reloadFromMessage —
  the tile's OWN UI handlers (wired directly by session-tile.tsx as
  onCancel/onSteer/onReload), distinct from use-session-tile-delegate.ts's
  interruptSession/submitToSession (used by external callers like
  quick-entry-bridge), which #81261 did wrap.
- use-prompt-actions/index.ts's reloadFromMessage (the primary chat's own
  "Regenerate") — it builds its prompt.submit call inline instead of going
  through the shared send() helper every other action in this file uses,
  so it never picked up the recovery wrapper.

After sleep/wake (the exact scenario #81261 targets), clicking Stop,
sending a steering correction, or clicking Regenerate on a tile or the
primary chat surfaces a raw "session not found" error instead of silently
resuming, even though #81261 landed the day before.

Wrap all four call sites in withSessionNotFoundResume, mirroring the
existing pattern each file already uses elsewhere (submitRewind/
syncAttachmentsForSubmit in session-tile-actions.ts, redirectPrompt/send in
index.ts) — resolve the stored session, resume once, retry, and rebind the
live runtime ref via onRecovered.
2026-08-16 22:24:31 -07:00
SHL0MS 243352e7b8 fix(desktop): unbind session tiles from a reclaimed runtime so they self-recover
A tab/tile whose live runtime the backend reclaims (ws_orphan_reap,
idle_timeout, lru_evict) rendered an empty transcript under healthy
chrome, permanently: session.reclaimed dropped the runtime's cached
state but left the tile bound to the dead runtime id, and the tile's
resume effect is gated on !runtimeId so it never refired. Sidebar
re-click could not recover it; only close-tab or an app restart did
(tile persistence strips runtime ids, which is why a remount healed).

The reconnect-path resetTileRuntimeBindings() cannot cover this case:
the WS re-dials immediately while the orphan reaper fires a grace
window later, so the reclaim always lands after that unbind ran.

On session.reclaimed, unbind whichever tile holds the reclaimed
runtime (new unbindTileRuntime, the targeted sibling of
resetTileRuntimeBindings) so the existing resume effect refires
against the intact stored session, and purge the wiring cache's entry
so resumeTile's warm path can't hand the dead runtime straight back.

Live-reproduced both ways on an isolated dev instance (20s reap
grace): pre-fix the tile stays bound to the dead runtime with its
state gone (blank pane); post-fix it sheds the binding and repaints
the transcript within seconds, across two consecutive reap cycles.

Fixes #82620
2026-08-16 22:22:04 -07:00
Teknium de29f46bab chore: map contributor emails for salvage of #85421 2026-08-16 22:20:35 -07:00
Tair Asim 134bf66b3b test(telegram): cover the lazy-install rebind path
Drives the real imported-before-installed transition: seeds a fake SDK
into sys.modules, resets the adapter globals to the placeholder the
fallback block leaves behind, stubs the installer to a no-op, then calls
check_telegram_requirements() and asserts no placeholder survived.

Asserting over the whole stubbed set rather than one name means the next
symbol dropped from the rebind list fails here too, naming itself:

    AssertionError: lazy install left these bound to the import-time
    placeholder: ['TypeHandler']

Originally written by Tair Asim in #85607, which was closed as a
duplicate of this PR. Moved into its own file to match the convention
already used by test_discord_lazy_install_views.py and
test_feishu_lazy_import.py, and widened from three hard-coded symbol
checks to the full set.

Co-authored-by: Pavel Lesyuk <paul.lesyuk@gmail.com>
2026-08-16 22:20:35 -07:00
Pavel Lesyuk a85e45da13 fix(telegram): rebind TypeHandler in the deferred SDK import
`check_telegram_requirements()` re-imports python-telegram-bot after a
lazy install and rebinds the module-level aliases that the top-level
`except ImportError` block set to `typing.Any`. TypeHandler was left out
of all three places: the `global` declaration, the
`from telegram.ext import (...)` list, and the assignments.

So whenever the top-level import fails and the deferred path runs, every
other alias is restored and TELEGRAM_AVAILABLE flips to True, while
TypeHandler stays `Any`. Handler registration then raises
`TypeError: Any cannot be instantiated` and the gateway reports:

    [Telegram] Failed to connect to Telegram: Any cannot be instantiated
    Gateway started with no connected platforms

The 22.6 -> 22.8 pin bump named in #85272 is the trigger rather than the
defect: it makes the top-level import fail, which is what routes the
module through the deferred path where the omission has always been.
2026-08-16 22:20:35 -07:00
Teknium acaac9a18c fix(config): extend structured-value parsing to multi-line YAML blocks with a conservative trigger
Consolidation follow-up on top of #59182's cherry-picked base:

- Add _looks_structured_value(): triggers a yaml.safe_load structured
  parse only when the value starts with '[' / '{' or spans multiple
  lines with YAML list-item ('- x') or mapping-entry ('key: v') shaped
  lines. Deliberately avoids the over-broad leading '-' trigger from
  #88066 so '-5' and '--flag' stay strings.
- Stays folded INSIDE the string-typed-key guard: keys whose
  DEFAULT_CONFIG type is str (e.g. approvals.mode) are never coerced.
- Tests: multi-line YAML list/dict, string-typed key given '[x]' and
  '-5' stays string, dash-prefixed scalars stay strings, plain
  multi-line prose stays a string, load_config round-trip.
  Sabotage-verified: 7 of the suite's tests fail on main without the fix.
2026-08-16 22:18:04 -07:00
Sam Liu 6f2a4676a9 fix(config): parse list/mapping literals in hermes config set
Fold the list/mapping parser INSIDE the existing string-typed-value coercion guard (the `not isinstance(_default_value_for_key(key), str)` block from e4ea0a0ed) instead of running it unconditionally, so a genuinely string-typed setting whose value merely starts with '[' or '{' is left untouched while non-string keys get JSON/YAML flow literals parsed to real lists/dicts.

Update website/docs/user-guide/configuring-models.md: the `config set only writes scalar values` note is no longer accurate; document the list/mapping support with a quoted example.

Fixes #40545 #50168
2026-08-16 22:18:04 -07:00
Teknium 5099a65a05 fix(desktop): re-resume session tiles with live runtime ids after sleep/wake
After sleep/wake the gateway reconnect path called resetTileRuntimeBindings()
to force every tile to re-resume, but it only cleared the tile atoms'
runtimeId. resumeTile()'s warm path then re-bound each tile from the wiring
cache's stored->runtime map - the same dead pre-sleep runtime id, with a
released (empty) cached transcript. Result: every split pane except the
primary repainted as an empty pane with only its header, and prompts
submitted to it recovered into the primary view instead.

- resetTileRuntimeBindings() now also invalidates the delegate's wiring
  cache (new optional SessionTileDelegate.invalidateRuntimeBindings, backed
  by runtimeIdByStoredSessionIdRef.clear()) so post-reconnect resumes go
  cold and bind a live runtime id.
- resumeTile()'s warm path now requires the cached state to carry a
  transcript (or be mid-turn): a released/stale empty state goes through
  to a real session.resume + transcript hydration instead of repainting
  an empty tile.

Regression tests fail without the fix (verified via sabotage run) and pass
with it; tsc + eslint clean.
2026-08-16 22:16:41 -07:00
Teknium c59c1de36c fix(draw-your-font): shorten description to authoring hardline (<=60 chars) 2026-08-16 22:11:23 -07:00
Teknium a98ae1929f feat(optional-skills): add draw-your-font — handwriting photo to installable font 2026-08-16 22:11:23 -07:00
Teknium 0e378e59aa Port from paperclipai/paperclip#10978: skip locally-edited hub skills on update unless --force
paperclip#10978 made destructive replacement an explicit caller choice
in their skill-sync and package-import paths: a rerun must never remove
operator edits by default. Our hub-skill updater had the same hazard --
'hermes skills update' calls do_install(force=True), which rmtree-replaces
the skill directory even when the user edited it after install.

do_update now compares the on-disk content hash against the hash the
lockfile recorded at install time; drifted skills are skipped with a
notice and only overwritten with the new --force flag (CLI + /skills
slash path). Bundled skills already had this protection via the
user-modified manifest in hermes update; this brings hub-installed
skills to parity.

Sabotage-verified: disabling the drift check makes the new skip test fail.
2026-08-16 22:10:37 -07:00
Teknium 3ef63373f8 Port from paperclipai/paperclip#10875: route all dashboard copy actions through the HTTP-safe clipboard helper
Self-hosted dashboards served over plain HTTP on a LAN have no
navigator.clipboard (insecure context), so every direct writeText call
silently failed. web/src/lib/clipboard.ts already ships the HTTP-safe
copyTextToClipboard fallback but only OAuthLoginModal used it; ChatPage
(OSC 52 + Ctrl/Cmd+Shift+C), ProfilesPage, SystemPage, and WebhooksPage
all bypassed it. Route them through the helper and add a source-level
regression test that rejects any new direct clipboard write outside
lib/clipboard.ts (clipboard reads are exempt: no legacy fallback exists).

Sabotage-verified: the guard test fails when a direct write is introduced.
2026-08-16 22:10:29 -07:00
Teknium 66312aec48 Port from can1357/oh-my-pi#7553: allow quoted shell metacharacters in allowlist matching
command_allowlist glob rules (e.g. 'cargo *') rejected any command whose
quoted arguments contained shell metacharacters — a cargo benchmark
regex filter like '^layer3/write/(a|b)$' disqualified the whole command
even though those characters are literal to the shell.

_has_allowlist_shell_operator is now quote-aware:
- metacharacters inside single/double quotes or behind a backslash are
  treated as literal arguments;
- $ and backtick inside DOUBLE quotes still disqualify (expansion is
  active there);
- quoted/escaped control characters still disqualify when the command
  carries a -c/-e/--command/--eval-style option that hands the payload
  to another interpreter (sh -c '...', git -c alias.x='!...' x);
- unterminated quotes disqualify (shape can't be reasoned about).

Compound commands (unquoted ; & | < > backtick $( newline) are rejected
exactly as before. hermes_cli/approvals_suggest.derive_glob picks up the
same semantics via its existing import.
2026-08-16 22:10:13 -07:00
Teknium d5167831b8 Port from can1357/oh-my-pi#7306: reject answer-shaped auto-title output
A tiny title model that ignores the 3-7 word titling task and answers
the user's first message instead used to have its whole reply stored
(truncated at 80 chars) as the session title. Truncating an assistant
blob still leaves an assistant blob — generate_title now rejects output
over 12 words and returns None, letting maybe_auto_title retry on the
next exchange. The 80-char truncation remains for genuine-but-wordy
titles that pass the word bound.
2026-08-16 22:10:02 -07:00
Teknium 0043a484e7 Inspired by Copilot CLI: /worktree — create isolated git worktrees mid-session
Copilot CLI 1.0.79-3 added /worktree new (start a session in a new
worktree). Hermes already has hermes -w launch-time isolation; this adds
the mid-session counterpart: /worktree new [name] creates a tree under
.worktrees/ (remote-tip base, worktree_sync honored), retargets
TERMINAL_CWD + process cwd, and registers the same keep-if-unpushed exit
cleanup. /worktree shows the active tree; /worktree list lists them.
Named trees skip the hermes- prefix so the startup pruner ages them on
the slower named-tree schedule.
2026-08-16 22:09:53 -07:00
Teknium 1002dcd3cc fix(simple-english): shorten description to authoring hardline (<=60 chars) 2026-08-16 22:09:45 -07:00
Teknium 56182cf8f7 feat(optional-skills): add simple-english — ASD-STE100 Simplified Technical English writing skill 2026-08-16 22:09:45 -07:00
Teknium 2e4d771c69 Inspired by Factory Droid: accept unique ID prefixes in process tool lookups
Factory Droid v0.175.0 made TaskOutput/TaskStop accept task-ID prefixes so
background tasks can be referenced without pasting the full ID. Hermes'
process tool had the same friction: every action required the exact
proc_<12-hex> session ID.

ProcessRegistry.get() now falls back to unique-prefix resolution when the
exact lookup misses: 'proc_4dae' or bare '4dae' resolves to
proc_4dae56ca81f6 when exactly one running/finished session matches.
Ambiguous or too-short (<4 suffix chars) prefixes still return None, so
callers keep their existing 'No process with ID ...' error and nothing is
ever picked arbitrarily. Exact IDs never pay the scan, and a full ID that
happens to prefix another always wins.

All process actions (poll/log/wait/kill/write/submit/close) route through
get(), so they all gain prefix support from the single change.
2026-08-16 22:09:37 -07:00
Teknium ea29702749 feat(cron): --continuity / --no-continuity flags on hermes cron create/edit
CLI parity for the continuity toggle:

- subcommands/cron.py: --continuity on create; --continuity / --no-continuity
  tri-state pair on edit (same store_const pattern as --no-agent/--agent)
- cron.py: forwarded to the cronjob tool; created/edited job summaries print
  a "Continuity: on" line
- cronjob_tools._format_job: reports continuity as an explicit boolean and
  strips the reserved 'self' entry from the reported context_from list
- cron-job.ts: form reader accepts both shapes (raw store record with 'self'
  inside context_from, or formatted record with the explicit flag)
- docs: CLI flag examples in the continuity section

E2E (real argparse -> cron_create/cron_edit -> jobs.json in temp HERMES_HOME):
create --continuity stores ['self']; edit --no-continuity clears; edit
--continuity restores; default-off unchanged. 91 cron/tool tests + 16 CLI
cron tests + vitest 10/10 pass.
2026-08-16 22:09:28 -07:00
Teknium 0b13cafffa feat(cron): continuity toggle across dashboard, Bot Mode routines, and TUI cron RPC
Wire the continuity flag through every cron-creation surface, not just the
model tool:

- dashboard (web/): checkbox in the cron job editor; form state round-trips
  the stored reserved 'self' entry into the toggle and strips it from the
  context_from textarea; web_server dashboard validator skips 'self'
  (create precedes the job's existence)
- Bot Mode Routines tab (hermes-bots plugin): Continuity checkbox in the
  New Cronjob dialog, forwarded through cron.manage
- tui_gateway cron.manage RPC: optional continuity param on action=add

vitest cron-job suite 10/10 (4 new), tsc app project clean, py_compile clean.
2026-08-16 22:09:28 -07:00
Teknium 2e7a46cc27 feat(cron): continuity=true/false flag as the user-facing surface for self-context
Per review: expose run-to-run continuity as a boolean `continuity` flag on
cronjob create/update instead of asking users to know the reserved
context_from='self' value. The flag translates to the 'self' entry in
context_from internally (create: appends/omits; update: adds or removes
'self' while preserving other upstream refs). Schema documents the flag and
steers context_from back to job-id chaining only. Docs updated; 7 new tests.
2026-08-16 22:09:28 -07:00
Teknium 47d7661aa8 Inspired by Amp: cron self-context — context_from='self' gives recurring jobs run-to-run continuity
Amp's 'Right on Schedule' (Jul 21 2026) lets scheduled agents wake up with
their saved context and continue where they left off. Hermes cron jobs run
in isolated sessions with per-run amnesia; the existing context_from chain
mechanism only referenced OTHER jobs. This adds the special value 'self'
(and treats a job's own literal id the same way): the job's most recent
output is injected with continuity framing so recurring scouts/monitors
dedupe against what they already reported and continue where they left off.

- cron/scheduler.py: resolve 'self'/own-id in _build_job_prompt with
  continuity framing instead of upstream-job framing
- tools/cronjob_tools.py: allow 'self' through create/update validation
  (can't be validated against the store — the job doesn't exist yet at
  create time); schema description documents the value
- tests: 6 new tests incl. sabotage-verified failures without the fix
- docs: self-context section in cron.md
2026-08-16 22:09:28 -07:00
Teknium 35598d8e8e Inspired by Perplexity Computer: sessions pin/unpin/pinned CLI (#52955)
Perplexity Computer's July update let its agent manage sessions
conversationally from any surface — pin, archive, rename, fork — treating
session organization as operational infrastructure rather than a GUI
nicety. Hermes already has the durable pinned flag in state.db (Desktop
sidebar writes it; auto-archive honors it), but no CLI access existed:
GUI-only management was a single point of failure and blocked scripting
(issue #52955).

- hermes sessions pin <id...> / unpin <id...>: set/clear the durable keep
  flag via SessionDB.set_session_pinned (whole compression lineage,
  prefix resolution, multi-id, exit 1 on any miss)
- hermes sessions pinned [--json]: list all pinned conversations via the
  include_pinned back-fill (old pins can't fall off a paging window);
  --json enables backup/restore scripting
- docs: user-guide/sessions.md section
- tests: 6 tests covering prefix resolution, multi-id partial failure,
  pinned-only filtering, JSON shape, empty hint
2026-08-16 22:09:17 -07:00
Teknium 07a5179158 Inspired by Poke: nudge review of repeatedly-failing recurring cron jobs
Poke (poke.com) 'encourages users to review recurring automations that
haven't been acted upon'. Hermes' equivalent pain point is a recurring
cron job that fails run after run: each failure delivers the same one-line
error with no signal that the automation itself needs attention.

- cron/jobs.py: persist a failure_streak counter in mark_job_run —
  incremented on agent failure, reset on success; delivery failures don't
  count. Back-compat: missing field reads as 0.
- cron/scheduler.py: _failure_streak_nudge() appends a review nudge to the
  delivered failure summary once a recurring job's streak reaches
  cron.failure_nudge_threshold (default 3, 0 disables). One-shots never
  nudge.
- hermes_cli/cron.py: 'hermes cron list' shows '(N failures in a row)' on
  failing jobs with streak >= 2.
- docs: new 'Repeated-failure review nudge' section in cron.md.

Tests: 17 passed (TestMarkJobRun + TestFailureStreakNudge); E2E verified
with real cron store in temp HERMES_HOME.
2026-08-16 22:08:59 -07:00
Teknium bd4b709258 Inspired by Copilot CLI: /rollback keeps user hand-edits by default
Copilot CLI 1.0.78 reworked /rewind to restore only the files the agent
changed, 'skipping any file whose contents no longer match what Copilot
last wrote'. This ports that protection to Hermes checkpoints:

- tools/checkpoint_manager.py: per-project agent-write ledger
  (sha256 of every landed write_file/patch), safe_restore_plan()
  classifier, and restore(safe=True) that reverts only agent-authored
  changes, deletes agent-created files, and preserves user hand-edits.
  Empty ledger (pre-existing stores) falls back to the classic full
  restore.
- run_agent.py: feed the ledger from _record_file_mutation_result on
  every landed mutation (zero new hooks; rides the existing verifier).
- CLI + gateway /rollback: safe mode is the default; --all/--force
  restores everything; skipped files are reported with a hint.
- 17 locales: new gateway.rollback.kept_user_edits key.
- Docs: checkpoints-and-rollback.md updated.
- Tests: 7 new cases incl. user-edit preservation, post-agent user
  tweaks, agent-created file removal, empty-ledger fallback.
2026-08-16 22:08:47 -07:00
Teknium 9d139320d4 test(plugins): accept scan_decision_cb in _install_plugin_core test stubs
Sibling-test blast radius: main's TestInstallResolution fake_core stubs pin
the old _install_plugin_core signature; the scanning feature adds the
scan_decision_cb kwarg, so the real cmd_install call site now passes it.
Widen the three stubs to accept it.
2026-08-16 22:08:37 -07:00
Teknium d44a295492 Inspired by Claude Cowork: security scanning for plugin install/update
Claude Cowork (Aug 6, 2026) added skill & plugin security scanning:
third-party skills and plugins are automatically checked for malicious
content on upload/edit, returning pass/warn/fail. Hermes already scans
hub-installed skills (tools/skills_guard.py), but `hermes plugins
install` cloned and activated arbitrary Git repos completely unscanned —
and plugins run Python in-process, making them the more dangerous
surface.

- tools/plugin_guard.py: plugin-adapted scanner reusing the skills_guard
  pattern engine. Exempts the documented provider-plugin patterns (own
  requires_env API-key reads, HTTP calls with keys) on code files while
  keeping true threat signals (foreign credential-store access, reverse
  shells, destructive/persistence/obfuscation patterns, prompt injection
  in docs). Plugin-sized structural limits; VCS/venv dirs excluded.
- hermes_cli/plugins_cmd.py: scan the temp clone before it is moved into
  ~/.hermes/plugins/. safe=install, caution=confirm (interactive prompt
  or --force), dangerous=blocked (--force does NOT override). Re-scan on
  `hermes plugins update`; a dangerous updated tree is deactivated until
  the user reviews the findings. Dashboard install path returns
  structured scan_blocked/scan_findings.
- Config gate: plugins.scan_on_install (default true) in config.yaml.
- Validated against all 60 bundled plugins: 57 safe, 3 caution (real
  sudo / curl|sh content in their docs), 0 false-positive blocks.
- 15 new tests incl. E2E through _install_plugin_core with real git
  clones.
2026-08-16 22:08:37 -07:00
Teknium 341d5aebc6 Port from MoonshotAI/kimi-code#2647: read UTF-16 text files by transcoding to UTF-8
UTF-16 text files (Windows Notepad .txt, PowerShell > redirects) were
refused as binary: the terminal env decodes stdout as UTF-8 with
errors=replace, so their content arrived mangled with U+FFFD and
tripped the binary guard.

ShellFileOperations.read_file now probes the raw bytes via the
backend's Python when the binary guard fires: a BOM or the zero-byte
parity heuristic (derived from VS Code's encoding sniffer, tolerant of
mixed Latin/CJK content) identifies UTF-16 LE/BE, and the file is
transcoded to UTF-8 with CRLF normalized and the BOM stripped. Real
binaries (zeros at both parities), binary extensions, files over
10 MiB, and legacy 8-bit encodings (GBK, Big5) still refuse — a wrong
silent guess is worse than a clear refusal. Works on every shell
backend (local/docker/ssh) since the probe runs via python3 -c.

Tests run against a real LocalEnvironment (E2E, no mocks); sabotage
run confirmed 6/9 fail without the fix.
2026-08-16 22:08:28 -07:00
Teknium c031fec365 Port from MoonshotAI/kimi-code#2596/#2600: surface MCP tool-result _meta to the model, minus protocol-reserved keys
MCP tool results carry a server _meta mapping (exposed as .meta by the
Python SDK) alongside structuredContent. Servers return namespaced
machine-readable contracts there (validated payloads, browser-handoff
URLs); Hermes previously dropped the field entirely, so that data was
invisible to the agent.

Now _meta is included in the JSON tool output, after filtering
protocol-reserved keys per the MCP spec's key-name rules: a prefix is
reserved when a modelcontextprotocol or mcp label is followed by at
least one more label (modelcontextprotocol.io/..., tools.mcp.com/...).
Vendor namespaces with a trailing reserved word (com.example.mcp/...)
and unprefixed keys pass through. Non-serializable metadata drops the
extras rather than failing the call.
2026-08-16 22:08:18 -07:00
Teknium 8bbda8ff33 Port from block/goose#10746: strip invisible Unicode TAG chars from MCP content
Unicode TAG characters (U+E0000-U+E007F) render as nothing in terminals
and chat UIs but are fully visible to LLM tokenizers, making them an
ASCII-smuggling prompt-injection channel for untrusted MCP servers.

- tools/ansi_strip.py: new strip_unicode_tags() with fast path; unlike
  goose we preserve valid emoji tag sequences (U+1F3F4 base + tag spec +
  U+E007F cancel), so regional flags survive.
- tools/mcp_tool.py: applied at every MCP text ingestion point — tool
  result text blocks, embedded resource text, read_resource contents,
  get_prompt message content, and tool descriptions entering the schema.
- tests/tools/test_unicode_tag_strip.py: smuggled-instruction vectors,
  goose's test vector, emoji-tag-sequence preservation, ZWJ untouched.
2026-08-16 22:08:05 -07:00