pmOnFail: ignore keeps the lock a single document, and with it a direct
`pnpm install` accepts any pnpm; node-deps.mjs (builds, hermes update) is
what refuses another version.
READMEs, contributor guides, AGENTS.md files, skills and the website (with
the zh-Hans translations) give pnpm commands for the workspaces; the
website itself is still an npm project.
memory-provider-plugin.md and cli-config.yaml.example say the gate binds to
every compaction authority. This branch adds the proactive tool-result prune
to the authorities it withholds, so both lists name it now (wording from
7e79a8b32b on #149265). They also state what the gate doesn't cover: a
plugin context engine's compress() is checkpointed, because the host calls
on_pre_compress() first, but an engine that removes or rewrites host-
transcript messages outside compress() is neither checkpointed nor refused
yet. The refusal for that engine stays with the plugin-contract work.
The limit sentence is @GottZ's, from his comment on #149265.
Co-authored-by: Jan-Stefan Janetzky <git@gottz.de>
Arming compression.checkpoint_required flips the four fork sites to
skip_memory=False so the child can reach the checkpoint provider. In
_init_memory that flag does two things: it loads the external provider
(what the gate needs) and it takes the built-in store branch, which reads
memory.memory_enabled / memory.user_profile_enabled (both default true)
and loads MEMORY.md and USER.md into agent._memory_store;
system_prompt._memory_parts then renders both. The MemoryManager fork scope
does not reach this, because the store is not a provider. So with the gate
armed a delegate child routed to another provider, a batch_runner
trajectory and the Feishu comment responder all carried the user's notes
and profile in their prompt; gate off (and on main) none of them did.
Found in review (BearHuddleston, #94983).
- agent_init._init_memory: right after the flags are read, a fork labelled
memory_agent_context="subagent" that did not request the memory toolset
gets both flags cleared, so no store is built and nothing renders. The
toolset request still wins (#65429: enabled_toolsets=["memory"] must get
a store), exactly as it does for a skip_memory=True fork with the gate
off. mem_config stays defined for the provider branch below. The memory
nudge is already gated on agent._memory_store (_tick_memory_nudge), so
gate on and gate off now agree on every observable of the built-in store.
- memory-provider-plugin.md, "Forks under an armed gate": the prompt stays
as with skip_memory=True; the toolset exception is named.
Tests (tests/agent/test_fork_memory_under_checkpoint_gate.py): the probe
builds a fork and a primary agent through a real AIAgent init with the
recording provider and MEMORY.md/USER.md in the sandboxed home, then renders
build_system_prompt for both. Red against the previous commit ("parent-only"
in the fork's prompt, a MemoryStore on the fork); the primary is the
control. A second test pins the toolset exception. The helper grows a
builtin_memory switch that sets both store flags.
(cherry picked from commit b4cf44d6544c1aa1d9b8ece6e4f3061d08e3652b)
(cherry picked from commit 3acd933faba7c17dec2e0047fc6792e90e614ac8)
MemoryProvider.initialize documents parent_session_id among the kwargs a
provider may receive, and the fork docs rely on it to tell the fork shapes
apart, but _memory_provider_init_kwargs never sent it: parent_session_id
lived on agent._parent_session_id and never reached initialize(). Every
provider.initialize() takes **kwargs, so pass it through when set -- a no-op
for providers that ignore it, and the documented behaviour for those that
don't. Pinned with the recording provider through a real AIAgent init (red
without the change), with the unset case as the control.
(cherry picked from commit a69ce3b5a078dc30fe8a1f197fe4d2e8c4b0d1f6)
(cherry picked from commit bc96063187d562845a2beb7bceea133e91c2db13)
Arming checkpoint_required flips the four fork sites to skip_memory=False so
the child can reach the checkpoint contract. That also woke the ordinary
per-turn fan-outs (on_turn_start, sync_all/sync_turn, prefetch_all,
queue_prefetch_all) and the session-end and delegation writes, which leak the
fork's harness prompt and output into the user's real memory. The old
skip_memory=True guard existed to stop exactly that. The escape hatch only
worked for providers that honour agent_context; most bundled providers
(mem0, hindsight, byterover, holographic, openviking, retaindb) ignore it.
Enforce the skip on the host: MemoryManager captures agent_context at
initialize_all() and short-circuits prefetch_all, queue_prefetch_all,
sync_all, on_turn_start, on_session_end and on_delegation for
agent_context "subagent", independent of provider behaviour. initialize(),
on_session_switch() and on_pre_compress() (the checkpoint path) stay
ungated -- the contract needs them; the on_pre_compress docstring and the
provider guide say so, since a v2 provider that applied its non-primary
write skip there would either report success without a save or kill the
fork at its first compaction.
Scoped to "subagent" only: cron and flush agents ran with a provider before
this change and keep the provider-side contract (MemoryProvider.initialize),
so their recall and writes are not changed here.
(cherry picked from commit a2ead2a17eb646bee8c78da41dde1ad971e8335a)
(cherry picked from commit 654f7c7a9ba06ab2379405b180e058978b0eafa8)
Four call sites fork a second agent with skip_memory=True, which leaves the
child with _memory_manager = None: a fork is not the user, and its scratch
transcript is not the user's memory. With compression.checkpoint_required
armed that saving turns into a kill. The gate is armed globally per agent
instance from config, not from whether the agent has a provider, so the child
arms the same gate its parent did and then meets its first compaction with
nothing to checkpoint against. It raises CompressionCheckpointUnavailable --
"no active provider implements checkpoint API v2" -- and the delegate turn
dies with status=error and a null summary while the parent runs on. The gate
is not bypassed there; it is armed against a provider that was never loaded.
- The four sites (delegate_tool, curator, batch_runner, feishu_comment) pass
skip_memory=not checkpoint_required_from_config(). Gate off is the default
and stays byte-identical: skip_memory=True, no provider init, no hook. Same
shape and the same reason as the two gateway disposable-agent sites that
already read the key this way (run_turn_hygiene.py,
slash_commands_session.py). delegate_tool reads it inside the delegated
profile scope, where the child reads it.
- The detached review and /btw forks (build_cache_parity_fork) are not
touched: they persist nothing, and _pre_compress_memory_context already
exempts exactly that shape (decabd79f1), so the gate never fails them.
- checkpoint_required_from_config() in conversation_compression_checkpoint,
next to the gate's init refusals. One resolver rather than four parses, and
the call sites need it before an agent exists:
agent.compression_checkpoint_required is assigned inside the child's own
init, and the parent's says nothing about the child. It reads
load_config_readonly() -- the call init_agent makes to arm the gate on that
child, so parent and child answer from one source and a fork is given a
provider precisely when the child would refuse to compact without one. An
unreadable config reads as "not armed", matching init_agent's own except
around the same load.
- New memory_agent_context kwarg on AIAgent/init_agent, handed to the
provider's initialize() as agent_context; unset, the platform rule decides
as before (cron/subagent, else "primary"). The platform rule alone cannot
scope these forks: the curator runs as platform "curator", the batch runner
takes its platform from the caller's config, and the Feishu responder sets
none, so each would file its transcript as the user's own memory in every
provider that scopes writes by that key. All four pass "subagent" -- the
value memory_provider.py documents and supermemory gates on. A fresh
per-site string ("curator", "batch", ...) would drop out of every
provider's non-primary membership test and re-enable writes the provider
had deliberately disabled, which is a fail-open on the one path this commit
opens. platform still separates the shapes.
Costs, taken deliberately rather than hidden: one provider initialize() per
fork (a round trip for a network-backed store, on a path that made none), and
fork transcripts reaching the archive as checkpoints. Both are named at the
call site and in the contract doc, where a provider author reads them.
Ten tests (tests/agent/test_fork_memory_under_checkpoint_gate.py). The
four site probes drive the real spawn functions with the smallest parent stub
they accept and record the child constructor kwargs -- red at all four against
the previous commit with the literal skip_memory=True. The gate-off probes are
the control in the other direction: without them "inherit the provider" could
have shipped as "always inherit". The recorder aborts with a BaseException
because the sites wrap the spawn in except Exception and would have swallowed
an ordinary failure into a green test. The provider-initialize test drives a
real AIAgent init, the only way to observe the keyword at initialize().
Live probe (on the August base), isolated HERMES_HOME with a stub API-v2
provider and the gate armed: before, the child aborts with failure_class
"exception:CompressionCheckpointUnavailable" and delegate_task returns
status=error, summary=null. After, the child initializes the provider
(agent_context "subagent", platform "subagent"), its own session fires
on_pre_compress, no CompressionCheckpointUnavailable appears, and the parent
returns the subagent's answer.
(cherry picked from commit 31af59c54ebff4c6c1861961111700b8e42dba76)
(cherry picked from commit 9bf3feaf8c98dff6e90eadf0f533204adc150a8c)
The public list `hermes plugins validate` checks plugin imports against
was a hardcoded frozenset inside hermes_cli/plugin_validate_private_imports.py,
with no page that showed it to plugin authors.
Move the list, unchanged (89 entries), into hermes_cli/data/plugin_api.json
(already shipped as package data via `data/*.json`) and load it once in the
validator. scripts/gen_plugin_api_docs.py renders
website/docs/developer-guide/plugins/public-api.md from the same file, and a
test fails when the page is stale. Validate output is unchanged for the same
inputs.
The plugin guide example said get_session_env returns "" outside a session. When the
session ContextVar was never bound it reads os.environ first and only then returns the
default, so the comment now says that.
The plugin guide told authors to read session identity with
`gateway.session_context.get_session_env(...)`, written as a dotted module
call with no import line. `hermes plugins validate` treats only
`from gateway.session_context import get_session_env` as public, so an author
who wrote `import gateway.session_context` to match that spelling got a
private-import warning.
The developer guide and the user guide now name that import, and the
slash-command example uses it to read the platform and the chat id. A test
validates the guide's `get_session_env` example as a plugin and expects no
private-import warning.
gateway/builtin_hooks/ held only a docstring, and
HookRegistry._register_builtin_hooks() was an empty method called at the
start of discover_and_load(). Nothing registered through either one, so
hook loading is unchanged: user hooks under the profile's hooks/ dir
load exactly as before.
Remove both, the test helper that patched the empty method out, and
the developer docs (and zh-Hans copies) that listed the package as an
extension point.
Related: Code Health PLG-08.
On the gateway's Webapp behind the sign-in gate, a provider token worked
on its own (bearer header, socket ?token=) only when this listener gave
it to a native app's sign-in. The deployment session token
(HERMES_DASHBOARD_SESSION_TOKEN, literal or file:<path>) that the bundled
session-token provider vouches for was therefore refused with 401 /
close 4401, although the docs promise it authorizes the gated listener.
The bearer, peek_session and socket ?token= checks now call
access_token_works_alone: token_works_alone (looked up at call time) or
the token the registered session-token provider read at startup,
compared in constant time by the provider's matches_token, which its
verify_session also uses. With the provider not registered
(dashboard.session_token_auth enabled: false) the token is refused as on
main. token_works_alone and the refresh route are unchanged from main:
the deployment token has no refresh.
The ledger is working data for the serve cutover, not user documentation, and
is deleted with the serve stack. Move it next to its drift test as
tests/hermes_cli/serve_handler_ledger.json, which Docker, Desktop payloads and
Nix already exclude; the generator and the test only change the path.
Creating a profile is host-level work, not session work, so POST /api/profiles
and profiles.create are recorded as keep-host-wide instead of undecided.
Add scripts/gen_serve_handler_ledger.py and the generated
website/docs/developer-guide/serve-handler-ledger.json: one row per
HTTP/WebSocket route of the app `hermes serve` runs and per JSON-RPC
method an /api/ws client can call, with its current path and an owner
decision.
RPC rows are classified with the tables /api/ws dispatch reads: the
authority's served verbs, then ws_legacy_fallback's refused/fallback
rules. A served verb is decided `authority`; every other row is
`undecided` (RPC fallback/refused rows reference D3) until a reviewed
decision is recorded in the script's DECISIONS table. Dashboard-plugin
routes depend on installed plugins and are one row, the hot-mount route.
tests/hermes_cli/test_serve_handler_ledger.py fails when a handler is
added or removed without regenerating the ledger; --check does the same
from the command line.
Refs #145171 (EP-1)
- plugin_host_wire: an error crossing the host now carries the nearest builtin base
class, so a ValueError subclass (json.JSONDecodeError) raised by a hosted provider's
write_config_fields is still a ValueError in core and the dashboard returns 400, not 500.
- notify_memory_provider catches SystemExit as well as Exception: a provider hook that
exits no longer skips rename's alias, active-profile and identity updates.
- Docs and docstring: a key missing from the target profile's .env reads as unset; the
hooks never fall back to the caller's environment.
- Memory panel GET maps a provider read failure like PUT does (ValueError -> 400).
- One helper for submitted secrets; the Honcho placeholder rule moves into its storage
branch so the field loop no longer branches on storage kind.
- Tests: wire round-trip of error classes, SystemExit hook, missing-secret fail-closed,
and the failing-hook test now asserts both hooks ran.
A catalog memory provider (Honcho today) has no way to own its per-profile setup or its
dashboard config file, so core carries that code for it: profile create/rename edit Honcho's
host blocks and the dashboard has a Honcho-only storage kind that imports the plugin's private
modules. These seams let a provider do that work itself; core's Honcho code is untouched here
and is removed in a follow-up once the plugin implements them.
- MemoryProvider.on_profile_created(profile_name, hermes_home) / on_profile_renamed(old, new,
hermes_home): called from create_profile (CLI, dashboard, Desktop/TUI) and rename_profile for
the provider the profile's effective config selects, with that profile's home and its own
.env secrets bound. A failing hook or unreadable config is logged and never fails the create
or rename. No-ops by default.
- storage=STORAGE_PROVIDER in config_schema.py, with read_config_fields(hermes_home) /
write_config_fields(values, hermes_home): the provider owns its file layout. Core keeps
value coercion, placeholders, write-only secrets, the memory.provider switch and the 400 on
a refused write, and saves secrets to .env only after the provider accepted them.
_record_profile_rename moves to profile_identity.py (profiles.py line budget).
Classic CLI gains a Codex-style backtrack picker. Esc Esc on an idle, empty composer with no
overlay (or /rewind) lists earlier prompts newest first with a preview and turn number.
- Enter: prefill the prompt for editing. Nothing is cut until the edit is submitted; the
submission rides to the process loop as a BacktrackResend, which re-validates the picked
turn (same session, same text) and then runs the same durable rewind /undo uses. Esc Esc,
Ctrl+C, an empty submit, or submitting a slash/! command cancels and cuts nothing.
- f: fork. Runs the existing /branch path, then rewinds only the CHILD to just before the
picked prompt and prefills it; the parent's rows stay active.
- /rewind N and /rewind fork N do the same without the picker.
Esc Esc with a draft still discards it; mid-turn Esc Esc is unchanged.
undo_last's rewind body moves into CLISessionMixin._rewind_to_user_ordinal (shared by /undo
and the picker; same compaction-carrier handling, agent patching and memory rewound hook).
The picker lives in a new hermes_cli/cli_backtrack.py mixin; cli_tui_mixin.py is untouched.
CommandDef rewind is cli_only (desktop: terminal); desktop registry and parity table regenerated.
An outside adapter following the guide kept the answer-the-oldest bug, and one using the new
helper drew a private-import warning at admission. Review follow-up.
- locales/_keys.desktop.json: drop settings.model.tasks.skills_hub.{label,hint}.
The committed key list mirrors the English catalog
(apps/desktop/scripts/i18n-keys.test.mjs runs `--check`), and the rows were
removed from en_aux_tasks.ts.
- zh-Hans configuring-models still had the Skills Hub row claiming that
`hermes skills search` uses the slot; the developer-guide provider-runtime
lists (EN + zh) still named "skills hub operations" as an aux task.
- Pin the removal: no built-in surface (DEFAULT_CONFIG, the `hermes model`
picker, the dashboard REST allowlist, the web and desktop rows) offers the
slot, and a config that still carries an auxiliary.skills_hub block loads.
Refs #40703, #142403
- configuring-models: the config-reference link no longer promises YAML
keys for every slot (the reference lacks several of them); Review and
Voice Chat rows phrase their advice as advice, not as defaults, and
stop implying an `auto` review is not independent.
- adding-providers (EN + zh): drop "session-search summaries" next to the
removed web-extraction bullet; session_search has no aux LLM either.
- zh fallback-providers intro still listed web extraction as a fallback
side task; zh Review link text matches the delegation page title.
- integrations/providers: compression is not a tool, say "features".
Refs #147807
- The configuring-models table lists the slots the dashboard exposes, not
every auxiliary task (goal_judge, memory_query_rewrite and others are
config-only), so say that and link the canonical Auxiliary Models
reference; "All auxiliary tasks" assigns the slots on the page.
- /review does not take a fallback_chain (it resolves credentials like a
delegation pin), so the agent-loop and integrations fallback sentences
name vision, compression and title generation instead of "every task".
- Remove "web extraction summarization" / "web summarization" from the
aux-task lists in adding-providers, provider-runtime and
integrations/providers, and the zh-Hans copies of those pages plus
agent-loop and integrations/index.
- zh-Hans: drop "offloaded to smaller models" from the intro, which
contradicted the Review row, and pin the {#setting-auxiliary-models} and
{#auxiliary-models} heading ids the new links point at.
Refs #147807
Follow-up to the Web Extract removal:
- Review row: `/review` is a full independent subagent, and its own guide
(features/delegation.md "Review model") pins a strong model, so advise a
separate (often stronger) model instead of a cheaper one; drop the
"async delegation rail" internals and link the canonical section.
- Voice Chat row: drop the source-file path, say that reasoning is off on
voice turns even on `auto` and that GPT-Live ignores the slot, link
features/voice-mode.md "Voice chat model".
- Drop the hard-coded slot count: "11" went stale because nothing ties it
to `_AUX_TASK_SLOTS`, and plugin-registered tasks add rows anyway. The
intro gives examples and links the table instead of a partial list.
- Apply the same corrections to the zh-Hans mirror, which still listed
Web Extract and 11 slots.
- Remove the remaining "web extraction" mentions from aux-task lists in
the CLI aux picker prose, fallback-providers, agent-loop, integrations
index and the --usage-file paragraph.
Refs #147807
Committing the Store draft at publication makes every certified build go
live by itself, with no way left to keep one back for a chosen release time.
Setting the repository variable MS_STORE_HOLD=true makes the publication
pass commit the draft Manual instead: it is certified and then waits for
Publish now in Partner Center, with a warning that says so. A rerun of a
held submission is a no-op and never flips it to Immediate. Unset keeps the
auto-publish default.
Also quote the real v0.22.7 refusal (state 'PreProcessing') and update the
stable-releases developer guide, which still described the held commit.
The darwin matrix is Apple Silicon only, so the step's if: was always true
and its comment pointed at the removed macos-intel lane. The macOS bundle
update docs now say the publish job waits for the arm64 build and its smoke.
Removes 7 optional skills:
- notion, airtable: the official vendor MCPs are in the MCP catalog
(OAuth, no Portal needed), hosted connectors exist, and each vendor
keeps its own skills repo.
- shopify: hosted connector plus Shopify's own maintained `shopify`
skill in Shopify/Shopify-AI-Toolkit.
- duckduckgo-search, searxng-search: both are bundled web_search
backends now and web_search works keyless out of the box, so these
fallback-only skills essentially never surfaced.
- honcho: Honcho left core for Plastic Labs' catalog plugin, which owns
the `hermes honcho` commands this skill documented.
- mpp-agent: the verified Pay plugins (hosted and local) cover paying
HTTP 402 / x402 / MPP APIs with a bundled skill.
makenotion/skills, Airtable/skills and Shopify/Shopify-AI-Toolkit become
default trusted taps (all MIT, vendor-owned) so the topic still shows in
`hermes skills browse`, matching the wandb/qdrant/pinecone precedent.
The `box` skill stays for now.
AIRTABLE_API_KEY leaves OPTIONAL_ENV_VARS (its only consumer was the
skill); NOTION_API_KEY stays because the Teams meeting pipeline's Notion
sink reads it, and its description now says so. Related-skill links,
docs examples and the zh-Hans catalogs drop the removed names.
The shared-bundle-builds note and the stage_uv_cache docstring read as if
uv's wheel entries were symlinks on every target. On Windows uv writes
pointer files. The docstring's layout also covered only the pypi family;
URL, git and named-index entries carry an extra hash directory.
Reported in review by @unsupportedpastels.
stage_uv_cache copied the build cache with shutil.copytree's default
symlinks=False. uv stores each cached wheel as a relative link into
archive-v0/, so the copy stored every unpacked wheel a second time and
the seed then merged those directories into the machine cache. On the
published v0.22.0 macOS ZIP that is 30,859 files under
uv-cache/wheels-v6 (278 MB compressed, 901 MB installed), and uv fails
with "Is a directory (os error 21)" when it publishes one of those
wheels again (#141891). Windows is unaffected: uv writes pointer files
there.
Keep the links. The signing walkers already skip symlinks, so each
binary is still signed once at its archive-v0 path. Also correct the
shared-bundle-builds doc and the staging comment: built wheel ZIPs ship.
While a turn runs, every background process that finishes is admitted to the
session FIFO as its own queued turn, so ten finished jobs meant ten
back-to-back "[IMPORTANT: Background process ... completed]" turns after the
current one, each re-reading the whole context and answering one result.
A completion now folds into a completion still waiting at the very end of the
FIFO: the new admission carries both texts, every producer identity and a
"N background process results" header, and cancels the waiting row in the
same transaction (the `replaces` path heartbeat ticks already use). Only the
tail folds, so a prompt queued between two completions keeps its place; a
waiting row claimed first leaves the new completion to queue alone.
- A message the adapter sees as idle (its earlier message's adapter task ended while a queued
turn runs as a FIFO turn, outside the adapter's session guard) is stamped by the runner's
running-turn check at FIFO admission and settled there, so a busy chat counts every waiting
message once. A queued row cancelled later (/stop, /new, refused head, edit) settles dropped
through SessionAuthority._settle_cancelled.
- A row that is already terminal when admit_message re-reads it counts as reached only for a
turn outcome (completed / interrupted / failed / steered); cancelled, rejected or deleted is
dropped / never.
- The arrival's home is the routing identity's runtime profile, not the profile that last ran a
turn in that chat id (two bots of different profiles in one channel).
- The day's peak of running chats is kept per UTC day, and a day closing after midnight folds
its own peak before its row is written.
A graceful stop/restart (runtime_draining) and the session_busy /
unknown_execution pause notices tell the user the message is saved while its
FIFO row stays queued, but the busy-arrival settle recorded dropped/never.
Park the arrival instead: it counts as queued if this process later runs the
row, and not at all across a restart (the existing doc promise). A row read
back as unknown right after commit already started, so it counts as reached.
The startup auto-resume loop claims chats from the launch profile's scope;
pre-assign each claimed chat to the home its turn resolves to, so a resumed
secondary-profile chat no longer counts toward the launch profile's peak.
hermes.gateway.busy_arrival.count: one row per human message that reaches a
chat whose turn is still running (the shared runtime's busy dispatch),
recorded once its fate is known: queued (ran later as its own turn),
steered (steer or interrupt-mode redirect into the running turn),
interrupted (hard interrupt, then ran next), merged (folded into another
busy message of the same burst), dropped (/stop, /new, refused, queue cap),
with the wait from arrival to the moment it reached the agent.
hermes.engagement.day.count gains gateway_peak_busy_chats_bucket: the most
chats of the owning profile with a turn running at once that UTC day, kept
in memory from the runner's running-turn set at every turn boundary
(_persist_active_agents, moved to the agent-cache mixin so the gateway.run
facade does not grow) and folded into the profile's engagement day state.
Both are opt-in through the existing shared-metrics gate; no chat ids or
session keys leave the process.
Spotify left core (#135389) and the five browser_vault_* tools folded into one
browser_vault after v0.21.6/v0.21.7 shipped them, which dropped spotify,
spotify_* and browser_vault_* from the shared-metrics contract (and spotify /
spotify_* from the v3/v4 schema enums). Pending rows older releases stored
with those names no longer exported as recorded: the drift re-bucket turned
toolset=spotify into custom and tool_name=spotify_search into unknown, and a
tool_unavailable row (no fallback bucket) was left out entirely.
They join RETIRED_TOOL_NAMES / RETIRED_TOOLSET_NAMES, the existing legacy set
for removed core names; recording still maps through the live sets, so no new
row carries them. The v3/v4 schema enums regain the removed values, and the
v4 tool_unavailable enum also gains the contract values it never listed
(browser_vault, set_theme, setup_beat, setup_choose, start_chat).
Tests: the retired-name packaging test gains spotify and browser_vault cases,
and the schema-equals-contract test covers tool_unavailable; both red on
origin/main.
The block sections now say the prompt is stored as the withheld placeholder
(later turns, compaction, session_search, memory providers and review read
only that; transcripts still show what was typed), that the hook runs before
turn-start compaction, and that a callback which raises or times out is
skipped, so the turn goes ahead. observer-hooks.md lists the block return.
A pre_llm_call callback (Python plugin or shell hook) may now return
{"action": "block", "message": "..."} to veto the turn: no model call is made,
the message is the reply the user sees and the transcript keeps a normal
user -> assistant pair, and the result carries
turn_exit_reason=blocked_by_plugin_pre_llm_call. This is the supported seam for
DLP / privacy / policy plugins that previously had to patch core (#57364).
The vetoed prompt also stays on the host for that turn: no session titling call,
no external-memory prefetch or sync, no background memory review. Its user row
gets an api_content placeholder for the main-loop replay. That alone does not
keep the prompt out of later turns (compaction, session_search and memory
providers read the row's content); the next commit stores the placeholder as
the content.
First valid block wins; a Python block without a message is ignored (same as
pre_tool_call). Shell hooks accept both the Hermes {"action": "block"} and the
{"decision": "block", "reason": ...} shapes, mirroring pre_tool_call.
Install scanner (tools/plugin_guard*, PLUGIN_SCANNER_VERSION bumped once):
- hardcoded_secret: a PostHog project key (phc_, write-only ingest key) joins the
Google installed-app secret as a public client key -> high (caution), only
when every matched literal on the line has a public prefix (telesec
telemetry.py:27 was dangerous). phx_ personal keys and any other token stay
critical.
- proc_access: reads of /proc/self|<pid>/{cgroup,mountinfo,stat,status,...}
(container placement, pid start time) land at low instead of high
(hermes-lcm-x cpu_capacity.py was caution). environ, cmdline, mem, maps, fd,
root/cwd, ns, bare /proc/self, any unlisted leaf and any write keep high.
Generalises #144304's cgroup/mountinfo allowance and keeps the finding
visible instead of dropping it.
- bytecode_file: a committed .pyc/.pyo (git ls-files; plus any bytecode outside
__pycache__) is a high structural finding, the binary_file tier (rule 17;
hermes-emotion shipped tests/__pycache__/*.pyc and scanned safe). Untracked
caches the validate run writes are not flagged.
Validator (hermes plugins validate, warnings only):
- private imports: gateway.session_context.get_session_env is the documented
route (plugins/index.md) and a plugin's fallback import of its own install
path (plugins.memory.openltm._db) is not a core import.
- new warning for private-method calls on core handles (SessionDB, AIAgent,
GatewayRunner, PluginContext) the plugin does not define itself
(opencode-sync db._execute_write, runner._evict_cached_agent).
- desktop surface review warning for raw window.hermesDesktop calls and app
markup selectors ([data-slot], [data-tree-*], [data-pane-host]) in CSS text
or closest() (squad-hub, novel-reader, search-sources).
The catch-up window (how late a missed cron occurrence may run instead of being skipped) was
half the job's period, clamped to 120s-2h, for every job. A job can now override it outright:
0-1440 minutes, `0` meaning never catch up.
`grace_minutes` is a real 0, not "unset" — and the obvious write pattern would eat it:
`v in {None, "", False}` (the `workdir` update lambda) and `if value:` both treat `0` as
False in Python. Both doors therefore share `jobs_time.normalize_grace_minutes`, reads test
`is None`, and the renderer formats before testing truthiness.
The override moves the dispatch stamp too: `_classify_dispatch_lateness` and the dispatch
health metric read the same window, so a job the scheduler calls "late" is never reported
overdue by the dashboard.
Refused on `once`/`interval` schedules: the window describes a missed wall clock, and a
duration has none.
Split from the timezone commit so it can be reverted alone.
A job may pin its own IANA timezone; its cron expression is then that zone's wall clock,
independent of the profile's configured timezone. Absent/None inherits the profile, so an
existing jobs.json keeps byte-identical behaviour and needs no migration.
The dispatch path needs the pin as much as the compute does. `compute_next_run()` resolves
the zone as job pin -> profile zone -> base offset, and every site that writes `next_run_at`
now passes it (advance, claim, resume, re-arm, recovery, and the quota/unreachable
planners); one missed site re-fires or skips a slot because the stored instant and the
expression would be read in different zones.
`_evaluate_due_job()` normalizes the stored instant into the JOB's zone, which hands every
downstream cron predicate the job's clock, and a pinned job skips
`_repair_timezone_shifted_cron` outright. That repair exists for jobs that FOLLOW the profile
zone across a zone change: on a pinned job its wall-clock comparison reads a due occurrence
as still-in-the-future and re-anchors past it.
Failing shape this fixes (profile America/Los_Angeles, job 0 9 * * 1-5 pinned to
America/New_York, scanned at 09:00 New York): `get_due_jobs()` returned [] and the job was
re-anchored to the next day, i.e. job zones east of the profile zone silently never fired.
The regression test drives the public store API and asserts both the fire and the absence of
the repair, and is proven red on main ([] and a rewritten instant).
Knobs live in the new sibling `cron/jobs_time.py`; `cron/jobs.py` stays over the file-lines
ratchet and only gained the call-site plumbing.
browser_vault reached pages only through the task's CDP supervisor. Camofox (now a plugin) has no
CDP endpoint, so every fill on a Camofox tab ended with "Could not determine the current page
origin": the username from browser_vault list worked, the password never filled.
ctx.register_browser_page_backend lets a plugin browser carry the vault's origin read, form
inspection and fill script to its own page. Core keeps the secret contract: the exact-origin
pre-check and in-page re-check, the Bot Desktop takeover check, an in-page try/catch around the
secret script, and fixed failure text on that path. Registration follows the login-backend
provenance rule (bundled, or catalog verified/official at its pin) and is refused under
plugins.isolation: host.
Consolidates the core half of #127179 and #135204 (the Camofox halves already shipped in the
official plugin); the in-page wrapper follows #114414.
Co-authored-by: Jonathan Bruce <jonathan.bruce369@gmail.com>
Co-authored-by: clsferguson <cferguson@live.ca>
Co-authored-by: Benjamin PERRY <benjaminperry6@yahoo.fr>
A mixed package with plugin.yaml + __init__.py and a ROOT plugin.js is
reported by the install probe as carrying both halves, so a browser tab
(and native Desktop on a local backend) installs the agent half alone and
leaves the desktop half to the reconcile pass. Both reconcilers (the host's
desktop_plugin_halves.py and Electron's desktop-plugins-root.ts) only read
desktop/plugin.js, so nothing was copied while the dialog still said
"Desktop plugin <name> installed".
- One entry rule for every reader: desktop_source / desktopHalfSource (root
plugin.js wins, else desktop/plugin.js), used by both reconcilers, the
Webapp installer, findDesktopEntry, plugins.manage list's has_desktop_half
and the plugin doctor's stale-copy check.
- A root-layout half publishes the package root as the installers do,
without .git and __pycache__, held to the installer's plain-tree rule
(no links, 64 MB cap); a refused tree is logged and not copied. The
marker's source is the package root, so the prune follows it.
- The install dialog reports the desktop half only when the reconcile
copied it or <root>/<name>/plugin.js is already there; an agent install
that left no desktop half reports "Desktop plugin install failed".
Review findings on the config_keys seam:
- Discovery: drop the glob scan. config_keys is parsed onto PluginManifest
(plugin.yaml, and extensions.com.nousresearch.hermes in a portable
plugin.json) and read from the loader's own collect_directory_manifests +
entry points + resolve_manifest_winners, so skip rules, precedence and the
project-plugin gate are the loader's.
- Only active plugins count: gate_manifest must load the plugin (disabled wins,
plugins.enabled opt-in, requires_hermes); memory providers count when
memory.provider selects them.
- Bounded: a declared path is at least two segments deep and is ignored when
DEFAULT_CONFIG already defines it; runtime keys are consulted only after the
schema walk refused a key, so a plugin declaring [agent] no longer hides
agent.max_turnz.
- No lru_cache: a plugin installed or enabled in a long-running process counts
on the next config get/set (the lookup runs only for refused keys).
- computer_use.backend (read by the plugins/computer_use selector) and
dashboard.kanban move to a core table with their readers; the selection
sections derive from read_selection's own _SELECTION_NAME_KEYS.
- Tests use fixture plugins (enabled/disabled, hostile [agent], selected memory
provider, portable plugin.json, project opt-in).
`hermes config get browser.cloud_provider` / `browser.use_gateway` warned "not a recognized
config key" although the runtime selects the browser provider from them. The validator only
walks DEFAULT_CONFIG, and provider selections stay unseeded on purpose (a stored key is the
explicit pick), as do core-section keys plugins read.
- tools.tool_backend_helpers.selection_config_keys(): the keys read_selection() honours
(<category>.use_gateway, browser.cloud_provider, stt/tts/image_gen/video_gen provider keys,
web.backend/search_backend/extract_backend).
- plugin.yaml / dashboard manifest.json `config_keys`: a plugin names the core-section paths it
reads; declared for retaindb, byterover, computer_use/cua, kanban dashboard.
- hermes_cli/config_runtime_keys.py consults both before the DEFAULT_CONFIG walk.
Found on Hermes Cloud staging.
The Webapp keys BrowserGuestFrame by its sanitized tokens, so a sandbox
change swaps ref.current and restarts the guest; a direct frame on native
Desktop keeps its element and applies new tokens at its next navigation
(live-checked in Chromium and WebKit). Plugins that keep the first frame
element need to know.
Dispatch each guest load on the outer iframe as its own marked load
event, so onLoad gets a real React event and ancestors see guest loads.
The shell's own load is taken in the document's capture phase, before
React or any consumer handler, and the document is sent from there, also
while an Activity or Suspense boundary hides the frame; a frame mounted
hidden starts when first shown. Inside a shadow root, where a load never
reaches the document, the frame's own handler takes the shell load. Guest loads are routed by the shell's
window, so they arrive while hidden too.
Relay nested transferables both ways with an iterative walk, retry plain
once, and close ports when a message cannot be delivered, including one
that arrives before the guest frame exists.
Remount the shell when sanitized sandbox tokens change.
Mark a document sent only once it reached a shell, and send it again
only when it changed.
The Webapp page policy authorizes only the app's own inline scripts, and a
data: document inherits it, so a plugin's documented SandboxedFrame
`data:text/html` embed stopped running its script in browser tabs.
In the browser host only, SandboxedFrame now decodes HTML data: URLs and
hands the markup to a separately served guest shell
(/assets/webapp-guest.html), the same parent-messaged shell file previews
use, served with its own policy and the SDK sandbox allowlist as its
ceiling. The frame's sandbox attribute narrows that to the caller's
sanitized tokens (plus allow-scripts for the shell); the nested guest
document gets the caller's tokens exactly, so a script-less embed stays
inert and allow-same-origin can never apply. The shell relays messages
both ways, so `ref.current.contentWindow.postMessage` and
`event.source === ref.current.contentWindow` keep working, and onLoad
fires once the guest document (not the shell) has loaded. Native Desktop,
http(s) and non-HTML data: embeds keep the direct frame; the page script
policy is unchanged.
Live (built Webapp, real gateway, scratch HOME, Chromium): a SandboxedFrame
data: guest posts ready and answers a ping after onLoad on main and with
this commit; on the previous head it never ran.
Agent init assigns compression.model_thresholds to a plugin engine before
its first update_model(); the cold gateway path now does the same. The
context-engine guide states what manual /compress calls and which boundary
hook follows the commit.