With the homeassistant plugin missing, send_message to a `homeassistant:` target failed with
"Unknown or unregistered plugin platform: homeassistant" and no way forward.
Both unknown-platform errors (target resolution and platform config) now go through one helper
that appends, for a platform that left core (a LEFT_CORE row's platform), the exact install
command for the active home (`hermes [-p <profile>] plugins install homeassistant`). Other unknown
names keep the bare message.
Moving HA out of core dropped HASS_TOKEN/HASS_URL from the static provider blocklist and HASS_TOKEN
from the Tier-1 always-strip set. Once installed, the plugin's manifest declares them, but with the
plugin absent (migration pending, declined or failed) the token reached every terminal/subprocess
child. Each left-core row now names the env core stripped while it shipped the feature
(secret_env -> Tier 1, private_env -> provider blocklist) and local_env_policy keeps stripping it.
The Home Assistant gateway platform (plugins/platforms/homeassistant) and the
homeassistant toolset (tools/homeassistant_tool.py) now ship as the official
catalog plugin `homeassistant` (NousResearch/hermes-homeassistant), with the
same platform name, toolset/tool names, config keys and env vars.
Migration (hermes_cli/left_core_migration.py, a generic "left core" table
reusing the memory-provider migration's helpers): every profile home that
used Home Assistant (HASS_TOKEN in its .env, platforms.homeassistant enabled
or holding a token, or the homeassistant toolset selected) gets the plugin
installed from the catalog at its reviewed pin by `hermes update`, and lazily
once per process at agent start / gateway start (honours
security.allow_lazy_installs). Every outcome reaches terminal, Desktop and
chat; a gateway-start outcome is handed to that home's first agent.
Core HA special cases become generic PlatformEntry seams the plugin uses:
trusted_inbound (allowlist bypass for event-bus platforms), display_tier
(built-in display defaults) and shared_env_prefixes (profile-clone ownership
rule for env shared with the plugin's tools). Cron delivery uses the existing
cron_deliver_env_var seam; HASS_TOKEN stays stripped from subprocesses via the
installed plugin's manifest secrets.
The same angle-bracketed chat role name that OpenRouter's gateway 403s as
role_tag_injection (#132504) also ships in:
- optional-skills/creative/system-atlas (SKILL.md + assets/data.example.mjs,
both served verbatim by skill_view) as a docs path placeholder;
- three tool-error strings returned to the model as tool results
(tool_call bridge "not a known tool name" hint, and the malformed
connector-name errors in model_tools and the connector gateway).
A tool result enters history and is re-sent every turn, so one of these
errors poisons the session exactly like a skill_view load does. Switch all
of them to the brace form already used for the skill docs. Comments,
docstrings and log-only strings never reach the provider and are left as is.
Refs #132504
Skill Sync only worked against a Nous Portal sync plane that was never made
publicly accessible, so no install has ever used it. We don't want it in core
right now, so this deletes it outright instead of moving it behind a plugin
hook surface nobody needs yet:
- `hermes sync` command, `tools/skills_sync_client*.py` and their tests
- the personal pull ticks (gateway housekeeping, classic CLI startup,
`hermes serve` maintenance) and the debounced post-write push
- the per-skill `sync` usage flag (`set_sync` / `is_sync_enabled`)
- the `skills/_org/<org_id>/` mirror: index gating on `.active_org`, the
`[org-shared]` / name-collision labels, the org provenance header and
`org_provenance` field on skill_view, the org delete guard and the
auto-propose `org_sharing` note on skill_manage
The code is preserved in the private NousResearch/hermes-skill-sync repo if
the feature comes back.
Since systemd 254, `systemd-run --scope` expands $VAR / ${VAR} / $$ in the
command line itself (--expand-environment defaults to yes), so a background
command under a supervised gateway ran rewritten: `$$` became a literal `$`,
`${arr[@]}` became '' with a warning on stderr.
_systemd_scope_argv is the single argv choke point shared by the availability
probe, the pipe/PTY background spawns and restart-safe cron/kanban children,
so pass `--expand-environment=no` there. systemd-run < 254 rejects the
option (and never expanded in scope mode), so the existing cached probe drops
it and re-probes once when systemd-run names it in the rejection — no extra
subprocess on modern hosts and no lost isolation on old ones.
Fixes#132385.
skill_view's path rule looked the copy up in the visible-only catalog, so a same-tier
duplicate hidden at offer time (requires_apps / environments / platform) and disabled by
its load path (skills.disabled: [a/one]) still loaded via `a/one` or `one`. The lookup now
includes hidden rows (separate cache key), matching how skill_view collects candidates.
r2 gate (efficiency, Low): skill_view called _is_skill_disabled twice, once
for the declared name and once for the load path. Each call does a full
load_config(), which costs a deepcopy of the merged config even on a cache
hit. That happened on every view, preload and cron skill. The probe
(fold/probe-SKDUP-2.py) counted 2 load_config calls per view of an enabled
skill; it now counts 1.
_is_skill_disabled now takes *names and returns whether any of them is
disabled, after one config load. Existing single-name callers are
unchanged. It does not switch to get_disabled_skill_names, because that
subtracts ESSENTIAL_SKILLS and parses differently. The catalog lookup for a
path match now runs only after a name or path has matched.
r2 gate (2c, Low): `skills.disabled: [a/one]` — what `hermes skills` / the
web toggle save for the same-tier duplicate shown as `a/one` — matched every
copy's relative path, so an unrelated, uniquely named skill in another tier
at the same relative path (external `e1/a/one` named `other`) vanished from
list, index, view, preload and cron while the config UI showed it enabled.
is_disabled_entry now matches the declared name or the entry's load_name
(the relative path only for an ambiguous same-tier row). skill_view applies
the path rule only when that path is the resolved copy's catalog load_name.
This is the reviewer's intent but not the literal `name == rel` check: that
check would let the disabled a/one copy load through its directory alias
(`one`), which r2 verified is refused. The extended test pins both cases.
Gate quality findings (Low): reload_skills reached into
tools.skills_tool._SKILLS_CACHE directly -- add clear_skills_cache() next
to the cache and call it. _collect_skill_candidates still said "the
caller refuses >1", but after the precedence change the caller resolves
candidates via pick_skill_candidate and only refuses same-tier ties.
Gate reuse+quality finding (Low, convergent): resolve_skill_catalog
hand-copied skill_view's same-root rank as (False, len(parts) + 1), so
listing/slash and skill_view agreed only while two tuples stayed in sync.
Move the rank to agent.skill_utils.skill_candidate_rank(skill_md, root)
and call it from both; _rank_same_root_candidate is gone. Both probes
(gate fixture + 2c fixture) print identical output before and after.
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.
Gate 2c finding (Medium): a same-tier duplicate is listed under its exact
relative path (`a/one`), so `hermes skills` and the web toggle save that
path into skills.disabled -- but the list filter, skill_view and the
prompt index only matched the declared name (`dup-demo`). Disabling the
row silently did nothing while the UI showed it disabled.
One helper, agent.skill_utils.is_disabled_entry(entry, disabled), matches
the declared name OR the relative path; the catalog (skills_list, config
UI, slash commands) and the prompt index use it, and skill_view also
refuses when the resolved copy's exact path is disabled.
skills_list / `hermes skills list` kept the first copy of a duplicated name, the
prompt index listed every copy under the same bare name, and skill_view refused
that name — so the model was told to load identifiers that could never load.
Add agent/skill_utils.resolve_skill_catalog(): every scanned skill is matched
against the aliases skill_view accepts (declared name, directory name, path
relative to its root) and the shared pick_skill_candidate() rule, yielding a
status (unique / ambiguous / shadowed) and the exact load_name. The list
(tools/skills_tool._skill_catalog -> _find_all_skills, `hermes skills list`) and
the prompt index (project, local, create_dir and external roots resolved
together) both render load_name: a same-tier duplicate appears as `a/one`,
`b/two`; a shadowed lower-tier copy is hidden with a one-time warning; a copy
whose name AND path are both shared is omitted with a rename note. `hermes
skills list` also labels project / create_dir / external rows instead of
"local". Prompt snapshot version bumped to 4 (entries gain `rel`); ordering
stays deterministic.
Rebuilt from ef3d8ba385e (#64462) onto current main: the resolver keeps the
original alias-ownership design, adds tier precedence, and runs over the
refactored prompt_builder collection path.
skill_view refused any name that matched in more than one skills directory, so a
local skill shadowing an external one (even at the same relative path, e.g.
productivity/xdup) could not be loaded by ANY identifier, while skills_list, the
prompt index and slash commands all showed the local copy.
Resolve by one tier order everywhere: project > local > skills.create_dir >
external_dirs. get_skill_search_roots() in agent/skill_utils.py is now the single
directory ordering (get_all_skills_dirs and tools.skills_tool._skill_search_dirs
both derive from it; the latter previously omitted skills.create_dir). The tier
is classified from the live profile root (_skills_dir()), not the import-time
SKILLS_DIR. Two DIFFERENT skills sharing a name inside one tier still refuse
(59da8ec4e); the refusal now names the exact paths to load instead.
Rebuilt from aba7772261f (#55634) onto current main: the original preferred a
unique local candidate over external_dirs only; this generalises it to the full
tier order and moves the same-root identical-copy ranking into the shared
pick_skill_candidate() helper.
Scoping a supervised dashboard/serve backend's background processes moved them out of the
backend's service cgroup, so they survived a backend restart, but nothing in dashboard/serve
startup recovers the process checkpoint: they kept running while process list/poll/kill could
not find them, and the next spawn dropped their checkpoint record. Recovering them in serve
would conflict with the gateway owning background processes (#106742). Revert to main's
behaviour for dashboard/serve (workers stay in the backend's cgroup and die with it) and leave
escapee cleanup there to the serve-to-gateway cutover.
Tests: the escapee in the hang test printed before the kill deadline, which ended the reader's
blocked read and let the old close finish in time, so the test passed without the fix. The hang
test's escapee is now silent; late-output rejection has its own test. Removing the reader-close
guard now fails the hang test.
The reader checked session.exited and then appended, so a chunk read before
the kill could pass the check, lose the CPU while kill_process snapshotted the
output and set exited, and then land in the killed session (and be streamed)
after the kill returned.
ProcessSession.append_output_if_running() checks exited and appends under the
same session lock the kill holds for its snapshot, and _ingest_output(...,
unless_exited=True) only scans and streams a chunk it admitted. A chunk is now
either in the kill's receipt or dropped. The pipe reader is unchanged.
Tests: a deterministic regression pauses the reader between reading a chunk
and buffering it, runs the real kill, then resumes, and asserts the chunk is
in neither the receipt, the buffer nor the live stream. The escapee spawn
helper now terminates the PTY child it spawned if its readiness check fails.
Review follow-ups on the deferred PTY close:
- The reader can now outlive a kill while a detached descendant holds the
slave open. It keeps draining, but no longer appends to or streams into the
killed session, so the output stays as the kill reported it.
- The live-reader guard in _release_finished_handles is POSIX-only. pywinpty
reads don't block, so Windows closes the PTY on the finish path as before.
Tests:
- The unscoped kill test has the escapee print after the kill and asserts the
killed session never receives that output.
- New test_start_server_marks_the_process_as_backend_host drives the real
web_server.start_server entry point, so removing the mark_backend_host()
call fails a test.
- The live scope test bounds its escapee to 30 s and terminates the PTY child
it spawned on failure (the reparented escapee is left to its bound).
Only the supervised messaging gateway ran background processes in their own
systemd scope (_is_supervised_gateway_process requires _HERMES_GATEWAY and the
gateway PID file). A dashboard or serve backend run by systemd hosts the same
kind of agent turns, but its background processes shared its cgroup, so a
descendant that setsid()s past the PTY/process-group kill survived it: the kill
reported success while the escapee kept running (and kept the PTY slave open).
web_server.start_server now marks the process as a backend host (process-local,
so terminal children and CLIs that inherit the supervisor env don't count), and
_scope_argv scopes when that host was launched by a service manager. The kill
already stops the scope, which reaps the whole cgroup. The self-kill guards that
also read _is_supervised_gateway_process are unchanged.
kill_process released the PTY master while the PTY reader thread was
still blocked in read(). ptyprocess wraps the master in a BufferedRWPair,
so close() waits on the buffer lock the reader holds, and that read only
ends when every holder of the slave side is gone. A descendant that
setsid()s past the kill keeps it open, so the kill never returned (and on
the TTL prune path the same close runs under ProcessRegistry._lock).
Leave the close to the reader while it is alive: its own teardown
(_finish_reader -> _move_to_finished) releases the PTY once its read ends.
Register direct workers with a live future and fence dispatcher release and future attachment with per-registration identity tokens. Preserve profile-scoped release keys.
Port the in-memory fix from 251b99332550be4dc5a7ba265109e367842e9081, without timestamp-based durable-state guards.
Fixes#132223. Refs #88688.
(cherry picked from commit dd834c874fef04a7d56658e0d05baa3bee5decba)
Review and simplify pass on the guest fast-search restore.
- tools/tool_backend_helpers.py: _account_info was split out so two gates
could share one account read. Only managed_nous_tools_enabled is left, so
fold it back into its pre-salvage shape. Keep the branched force_fresh
call, because tests stub the reader with zero-arg lambdas.
- plugins/web/perplexity/provider.py: the managed= parameter on
_managed_gateway only fed the removed refusal helper. search() now calls
resolve_free_search_gateway() directly once it has the route answer. The
module docstring now names guests.
- hermes_cli/nous_account.py: the _error_info docstring described the
removed gate. The tier carried on a failed lookup still picks the "needs
a Nous account" copy over billing or re-login guidance.
- Tests: the case-insensitive tier match and the tier on error snapshots
lost their only coverage when the gate tests went, so cover them at
their consumer, format_nous_portal_entitlement_message. The fast-search
premise now asserts that the guest case really reads as the anonymous
tier. Remove the dead exp_offset parameter.
- Docs: the Perplexity section said there is no anonymous tier, which is
true for direct keys only. It now names the managed guest route.
The free managed Perplexity fast-search route exists for anonymous guest
Portal users. The salvage of #130121 (cee3000c24, via #130660) added a
registered-account gate, fast_search_entitled(), that excluded the
anonymous tier. That defeated the original change: guests fell back to
the keyless ring and never sent a gateway request.
This removes the gate, so resolve_free_search_gateway() decides on
identity alone again, as rewbs wrote it. The guest-disabled, expiry
refresh and dead-guest rules still apply through the token reader. Every
other vendor, and the billed managed-Firecrawl fallback, keep the paid
entitlement gate.
Kept from the salvage: the case-insensitive is_anonymous_tier, the
account tier carried on error snapshots, and the single route walk per
search dispatch. Tests: the anonymous identity is back in the autodetect
and rescue cases. The tests that pinned the guest exclusion are removed.
The terminal tool arms a heartbeat only after its spawn bookkeeping, and a
fast-starting process has already written its first lines by then.
arm_heartbeat reset the output baseline to the current total, so those lines
were in no heartbeat at all: the agent never saw them until completion.
Baseline is the spawn (field default 0). Every produced line now lands in
exactly one heartbeat regardless of how long the spawn->arm gap is.
CI: tests/tools/test_process_heartbeat.py FLAKY on main (run 36033989953),
beats read ["", "", "second\n", ""]. Reproduced locally with a 0.3 s gap
between spawn_local and arm_heartbeat: base loses "first", fixed keeps it.
When every DRM card's vendor file or every Windows display adapter's
MatchingDeviceId was unreadable, classify_gpu_vendors returned 'none' and
availability refused with unsupported_gpu, contradicting the documented
'unreadable GPU passes' rule. Windows now records an unreadable numbered
adapter as an empty id so the classifier sees it.
Also drops the type-narrowing assert in availability() and makes
_action's gpu_label required.
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.
The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.
An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.
A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
A `GOCSPX-` literal is the client secret of a Google installed-app (desktop/CLI)
OAuth client. Google's OAuth docs state such secrets cannot be kept confidential:
gcloud ships its own in every copy. The generic
`hardcoded_secret` pattern scored it critical, so any plugin that talks to a
Google installed-app client (the catalog's quota plugin) scanned `dangerous` and
could not be installed even with --force. Cap that one shape at high: the
finding stays in the report and the install asks for review (caution); every
other secret-shaped literal keeps its critical severity.
Salvage of #131963 by @mekenthompson onto main after #131946.
The shutdown reaper's SIGKILL pass only checked whether the leader PID was
still alive. Once the leader had exited, a descendant that ignored SIGTERM
kept the process group alive and was never escalated. The pass now also
probes the group with killpg(pgid, 0) and sends SIGKILL through the same
start-time guard as the SIGTERM pass.
The per-spawn environment marker from #131963 is not carried over: it left
helpers launched with a scrubbed environment running on Linux and brought
back the dead-leader refusal on macOS when a start time was recorded.
Gate finding BN21 r1 quality (Lows):
- The race/eager-import snippets put the repo on sys.path three ways; PYTHONPATH and
cwd already do it, so drop the in-snippet sys.path.insert and the REPO_ROOT templating.
- The deadlock explanation was spelled out in the module docstring, again in the
__getattr__ docstring, and a third time in the test module. Cut it to a 2-line
pointer to the test, and give __getattr__ a one-line docstring that describes what
it does (cache the name in globals()).
- Unquote the dict[str, str] / list[str] annotations: requires-python is >=3.11.
No behaviour change. Both kept tests still pass on HEAD and fail with BASE
tools/connectors/__init__.py.
`tools/connectors/__init__.py` imported all five of its submodules eagerly, so a
thread importing the package held the package's module lock while reaching for a
submodule that another thread already held. Python 3.14 aborts one of the two
with:
_frozen_importlib._DeadlockError: deadlock detected by
_ModuleLock('tools.connectors.tool')
which is the failure in the Group Chat hosted-room worker startup path
(`gateway/run_startup.py::_ensure_hosted_room_worker` -> `import tui_gateway.server`
-> `methods_connectors._install_update_hook` -> `from tools.connectors import
operation`) while the tool registry's discovery scan imports
`tools.connectors.tool` from another thread.
Exporting the cross-package surface through a module-level `__getattr__` (PEP 562)
removes the parent->child edge entirely: the package `__init__` now imports no
submodule, so it cannot hold its lock while waiting for one. Attribute access,
`from tools.connectors import x` and `__all__` are unchanged.
Measured on Python 3.14.7 by racing `import tools.connectors` (3 threads) against
`import tools.connectors.tool` (3 threads), 40 iterations each:
| tree | outcomes |
| --- | --- |
| before | 120 `_DeadlockError`, 0 successes |
| after | 0 `_DeadlockError`, 0 threads left alive, `import tools.connectors` loads no submodule |
Regression tests run in subprocesses so they assert on a pristine `sys.modules`
(`tests/tools/test_connectors_import_deadlock.py`).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
(cherry picked from commit 1803f53b1ed34487ff6a60cdbde915726be21338)
A base_url that urlparse rejects (e.g. an unclosed IPv6 bracket) was refused with the
stored-key/origin explanation, which tells the user to configure the endpoint as a custom
provider - advice that cannot help a URL that does not parse. It now says "It is not a valid
URL." while keeping the shared "not allowed" refusal wording; the existing test pins it.
The configured-origin list read custom providers through the private
agent.credential_pool._iter_custom_providers, which reloads config and only wraps
hermes_cli.config.get_compatible_custom_providers. Every entry that view returns is a dict
with a non-empty name and base_url, so the name filter in the private iterator never drops
one; calling rp.get_compatible_custom_providers on the config already loaded here gives the
same base_urls without a second config load or a private import.
A bare-custom base_url that urlparse rejects (an unclosed IPv6 bracket such as
http://[::1/v1) raised ValueError from the host-gated key lookup, so cronjob
create/update crashed instead of returning a refusal. Check the origin first
and refuse with the usual "not allowed for provider 'custom'" message.
Also read the raw model.base_url from load_config() rather than
_get_model_config(), which can network-probe a local model during a pure
validation call; drop the redundant _DIRECT_API_BASE_URLS (PROVIDER_REGISTRY's
openai-api already carries https://api.openai.com/v1); import get_secret_str
from its home in agent.secret_scope; and build the three refusal strings
through one _base_url_refused helper.
The bare 'custom' arm of _validate_cron_base_url returned early as pure BYOK, but the resolver attaches the host-gated env keys (OPENAI, OPENROUTER, OLLAMA, <VENDOR>_API_KEY) to such a job by hostname. A job could pair one with another scheme, port or subdomain. When a stored key would go with the base_url, require the origin of a configured endpoint or a registry provider; a base_url no stored key matches stays unrestricted.
(cherry picked from commit bb7c37c8d7b88077acd45de74f1efb1501adca1a)
_validate_cron_base_url accepted a job base_url whose hostname matched
the provider's endpoint or was a subdomain of it, so a cronjob tool call
could pair the stored key with plain HTTP, another port, or a subdomain.
Compare the full origin (scheme, host, effective port) via
utils.base_url_origin, the rule the other secret-to-new-URL checks use.
The scheduler backstop calls the same validator, so every surface that
stores a job is checked before the key is sent.
(cherry picked from commit a281efeedac02f87e168743b389b78f6feaf5472)
#131910 refused to signal a process group whenever its leader's start time
no longer matched the spawn-time baseline, and a REAPED leader reads as None,
so "leader gone" was treated as "PGID recycled". POSIX never reuses a PGID
while any member of the group lives, so a gone leader still leaves the group
ours, and its reparented grandchildren (an MCP server's pipe holder, a
terminal command's daemon) leaked past host shutdown.
Only a LIVE leader with a different start time (drift-tolerant since
a5e7df27) proves recycling now; the recycled-PID skip is unchanged.
a5e7df27 kept "gone leader refuses" and asserted it in
test_pgid_starttime_drift.py; that assertion now expects the kill. Same fix in the terminal
environment's twin guard (tools/environments/local.py::_leader_is_ours).
Red on main: tests/e2e/core/parity/test_mcp_lifecycle.py::
test_mcp_server_death_mid_call_fails_fast_and_the_turn_completes and
test_entrypoint_parity[*] zero_mcp_orphans (e2e lane on eaecc99c).
_real_profile_cdp acquires the process-global launch lock with a 30s bound
and fails closed with an actionable error when a previous (possibly
abandoned) resolver worker still holds it, instead of parking every later
real-profile call until the 420s outer tool deadline (#96731).
Conflict notes: rebased by hand from #96752 onto the extracted
browser_tool_real_profile module — the bounded acquire/finally-release
moves into _real_profile_cdp there; the regression test is the
synchronized version from the same PR.
Co-authored-by: fangliquanflq <280272527+fangliquanflq@users.noreply.github.com>
Rebase of the #96731 fix onto the post-split layout: the call sites moved
from tools/browser_tool.py to tools/browser_tool_real_profile.py
(_agent_browser_session_cmd and the attach open).
The CLI forks a resident daemon that inherits its stdio and outlives the
CLI. With pipe capture the CLI can exit while its output never reaches
EOF: POSIX burns the whole 15s timeout draining pipes on every call, and
Windows' TimeoutExpired cleanup re-drains unbounded, blocking past the
outer tool deadline (#96731). Reuse _popen_agent_browser (the pattern
_run_browser_command already established) instead of adding a second
mechanism: wait for the CLI alone; a grandchild holding inherited file
handles is harmless.
TimeoutExpired semantics are preserved for both callers (session_cmd ->
None + debug log, attach -> the took-too-long message), and the daemon
env (reaper-visible socket dir, owner_pid claim, no idle timeout) is now
built once per capture inside the shared helper.
The ladder now ends by observing frontmost after the settle and re-assert:
the tool output says whether the opened document actually ended up in front,
and when another application holds focus it names that application — a focus
race can no longer read as plain success (#95261 requirement 2). The
not-front branch gets one extra activate+0.4s so the report reads the state
after a last raise attempt, not before it.
The side-effect screen on #131931 caught _parent_live_home's double
getattr turning any parent without a real _session_db.db_path (MagicMock
test doubles, duck-typed fakes) into a truthy Mock 'home', sending
transcripts to str(<MagicMock>) paths — red 3/3 in
tests/tools/test_delegation_recovery_diagnostics.py, green on main.
Validate the resolved db_path against concrete str/Path before trusting
its .parent; anything else falls back to None (ambient resolve) with a
debug log. Concrete types on purpose, not the os.PathLike protocol:
MagicMock registers __fspath__ and so IS PathLike, which is exactly how
the garbage pin slipped through a protocol check.
Also documents the answer to the positional-.parent invariant question
from Enough1122's review on source PR #92001: state.db's direct-under-home
placement is load-bearing across the codebase, asserted by a new test.
Review follow-ups on the profile-home pinning (#91996):
- extract _parent_live_home() and log a WARNING when the parent exposes
no _session_db: the ambient fallback is exactly the #91996 failure
mode, so the skipped pin must be diagnosable instead of silent
- prune_stale_live_dirs now accepts the root and create_live_transcripts
passes the same resolved root it writes to — previously retention
always swept the ambient root, so stale dirs under a pinned home were
never cleaned by its own dispatches
- module docstring documents the per-root retention scope
- new tests: no-_session_db parent warns and returns None, db-path
parent pins, and a pinned-home dispatch prunes its own stale dir
while leaving the ambient root alone
delegate_task live transcripts resolved their root through ambient
get_hermes_dir(): _HERMES_HOME_OVERRIDE is a ContextVar that raw
threading.Thread boundaries drop, and process-wide HERMES_HOME is
unstable under concurrent multi-profile workers, so transcripts (and
the manifest) could land under a different profile than the session
that initiated the delegation (#91996).
Thread an explicit home through the live-log seam instead, resolved
from stable parent-owned state at delegate_task entry — the parent's
per-profile SessionDB path (state.db sits directly under the home).
None falls back to today's ambient resolve.
`-mindepth 1` in the find-lane base command broke the #116270
symlinked-root contract: with `-H` the followed operand IS depth 0,
so the root itself was excluded — the only match when the root is a
symlink to a file. `search_files(target='files', path=<symlink-to-file>)`
answered total_count=0 on the find lane again, exactly the silent-zero
the invariant test pins (red on the previous head).
Drop `-mindepth 1` and exclude the search root inside the `-type d`
arm by path identity instead (`! -path <root>`, glob metacharacters in
the root escaped), so:
- a symlink-to-file root keeps its depth-0 `-type f` match (#116270)
- a directory root still never lists itself (#54347 intent preserved)
- a same-named descendant directory still matches
Gate directory discovery on find availability and globally sort filtered file and directory candidates before pagination.
Addresses review feedback on #54391.
A symlink anywhere under skills/ makes _safe_skills_path serve the skills
mount from a fresh mkdtemp copy per process, so hashing its host path into
hermes-environment made the label differ across processes: the persistent
container was never reused and every session started fresh, losing the
long-lived-container contract (installed packages, filesystem state).
Canonicalize tempdir-sourced mount specs to a stable placeholder that
keeps the container path; real mounts, image and HERMES_HOME still force
a fresh container on change.
Follow-up to #131895.
Follow-up to #131910 (issue #43044): both new guards compared the
spawn-time start-time baseline with a later reading using exact
==/!= — tools/environments/local._leader_is_ours (terminal group
teardown, _kill_process_group_posix / _force_kill_process) and
tools.mcp_tool_lifecycle._signal_mcp_process (stdio MCP orphan
reaper). Same-host readings drift ~1 s on macOS (kern.boottime
adjustment, #117505), and gateway.status ships
start_time_fingerprints_match (tolerance 200 ticks = ~2 s) for
exactly that comparison; the "macOS has no baseline" premise was
also wrong (the psutil create_time fallback captures one). A
drifted reading made the guard refuse to kill live, legitimately
owned groups: timed-out terminal commands survived their teardown
and live stdio MCP servers leaked past the orphan reaper.
Route both comparisons through the shared tolerant comparator,
and keep the legacy best-effort kill when the current reading is
unreadable but the leader is still alive (a gone leader still
refuses the possibly-recycled number). Correct the stale
"macOS has no /proc baseline" comments.
Regression tests patch get_process_start_time to return
expected+100 (within tolerance) and assert the group is still
signalled; far-outside-tolerance readings are still refused.