Three fixes from live testing the org persist/restore flow:
1. Org not persisting (stale closure). discoverCloud() resolves the org
asynchronously from the NAS response and setCloudOrg() is a React state
update, but connectCloudAgent read the cloudOrg value captured in its render
closure — often still null when the user clicked Connect in the same tick, so
no org was saved. Mirror the org into a ref (cloudOrgRef) updated
synchronously alongside state; connect reads cloudOrgRef.current.
2. Cloud connection lingered after switching away. coerceDesktopConnectionConfig
inherits existingBlock.url across mode switches (correct for remote↔local),
so switching cloud→local/remote kept the cloud instance URL in the remote
block — re-selecting Cloud then looked 'already connected' with no way to
re-pick. Added a leavingCloud rule: when the saved block was cloud and the new
mode isn't cloud, start from an empty block (drop the cloud url/org/token),
cleanly unselecting the cloud gateway. remote↔local toggles still preserve a
real remote URL.
3. Change-org button vanished after restore-open. It was gated on
cloudOrgs.length > 1, but the restore path discovers straight into the saved
org and never populates cloudOrgs. Gate on cloudOrg being set instead, via a
new changeCloudOrg() that clears the org + agent list and re-discovers with no
org arg (multi-org → NAS 409 picker; single-org → auto-resolve back).
Depends on NAS #550 (echo resolved org), merged + live on prod (0dc86d0b).
tsc + eslint clean; 57 node --test + 16 vitest pass; all three verified live on
Ben's host (org persists + restores, cloud unselects on switch, Change-org shows
after reopen). The benign 'Session not found' 404 on backend switch is left as-is
(already handled by isSessionGoneError → fresh draft; dev-log noise only).
cloud-auto-discovery Phase 3/4 follow-up.
The Hermes Cloud ModeCard in Settings → Gateway now only appears when the BETA
env var is truthy (1/true/yes/on, case-insensitive); absent/empty/false/0 hides
it. While the feature is in beta, non-beta users see only Local + Remote.
- main.cjs: betaFeaturesEnabled() reads process.env.BETA; exposed via new IPC
hermes☁️beta-enabled (the sandboxed renderer can't read process.env, and
runtime IPC means the same build honors BETA per-launch with no rebuild).
- preload.cjs / global.d.ts: cloud.betaEnabled() bridge + type.
- gateway-settings.tsx: fetch the flag on mount (default false so it never
flashes in for non-beta users), conditionally render the Cloud ModeCard, and
flip the grid sm:grid-cols-3 → sm:grid-cols-2 when hidden.
Gates the SELECTOR only — an already-saved cloud connection keeps working if
BETA is later turned off; only newly selecting cloud is hidden.
tsc + eslint clean; 57 node --test + 16 vitest pass; env parsing unit-checked
across 9 cases; gate verified in the packaged bundle.
cloud-auto-discovery beta gating.
Settings → Gateway remembered 'cloud' mode but not WHICH org/instance, so
reopening dropped multi-org users back to the org picker, hiding the connected
agent (reported live).
- Persist a cloudOrg on the saved cloud connection (rides the remote block:
coerce reads input.cloudOrg / inherits saved; buildRemoteBlock + profile
sanitizer carry it; sanitize echoes it back as config.cloudOrg). Only for
mode:'cloud'; plain remote is unchanged. The instance was already persisted as
remoteUrl (the dashboardUrl).
- discoverCloudAgents now returns the org NAS echoes in the response
(trimCloudOrg), and the renderer records cloudOrg AUTHORITATIVELY from
result.org — so it's set even on single-membership auto-resolve where no
picker ran (the exact case that left the org unpersisted). Requires NAS #550
(echo resolved org in /api/agents); before that deploys, falls back to the
requested org.
- On open, the cloud-status effect seeds cloudOrg from the persisted
config.cloudOrg and discovers scoped to it, so Settings reopens straight into
that org's agent list instead of the picker.
- connectCloudAgent passes cloudOrg when saving so the choice sticks.
- The connected instance is highlighted (primary tint + ring) and shows a
'Connected' pill instead of a Connect button (compares saved remoteUrl to each
agent's dashboardUrl, normalized).
tsc + eslint clean; 57 node --test + 16 vitest pass. Connected-pill verified live;
org-restore pending NAS #550 deploy for the authoritative echo.
cloud-auto-discovery Phase 3/4 follow-up.
When Hermes Cloud discovery returns zero agents, the empty-state message now
renders 'Nous portal' as a hyperlink to https://portal.nousresearch.com/agents
(opened via the app's ExternalLink → shell.openExternal), so the user can jump
straight to creating an agent instead of finding the portal manually.
The cloudNoAgents i18n string becomes { before, linkText, after } (en + zh) so
each locale controls link placement; ja/zh-hant fall back to en via defineLocale.
No external-link icon on this inline link to keep the sentence clean.
tsc + eslint clean; link verified present in the packaged renderer bundle.
The silent per-agent sign-in (decisions.md Q5) was prompting a SECOND interactive
login after portal sign-in → org → dashboard selection (Ben's screencast). Root
cause: cloudAgentSilentSignIn → openOauthLoginWindow loaded the agent gateway's
/login, but /login is a PUBLIC route (dashboard-auth middleware allowlist), so the
gate's _auto_sso_response never runs there — it only fires on an unauthenticated
load of a PROTECTED page. The window therefore rendered the interactive
'Log in with X' chooser every time, instead of the silent 302 cascade. (Auto-SSO
is correctly configured on hosted agents: exactly one 'nous' session provider,
client_id agent:{id}, so it would have fired silently if triggered.)
Fix: openOauthLoginWindow(baseUrl, { silent }). The cascade passes silent:true,
which loads the PROTECTED root '/' instead of '/login'. The gate then runs
auto-SSO — single provider + a live partition portal session → 302 through
/auth/login → portal /oauth/authorize (auto-approves org members) → /auth/callback
sets the gateway session cookie with NO prompt. In silent mode the window also
starts HIDDEN and only reveals after 2.5s if the cascade hasn't completed
(graceful fallback to interactive, e.g. the portal session lapsed). The
interactive remote-gateway login (settings UI) keeps silent:false → /login
chooser, behavior unchanged.
Verified live end-to-end on Ben's host: portal sign-in → org picker → select agent
→ Connect now completes with no second login prompt.
cloud-auto-discovery Phase 3 follow-up (decisions.md Q9).
Two fixes surfaced by the first live end-to-end test of cloud sign-in (both
would have shipped broken — green units + code review did not catch them).
1. Portal session is PRIVY, not Hermes-gateway cookies (Q7). Phase 3 polled for
hermes_session_at/rt on the portal host, but the Nous portal (NAS) is a
Privy-authed Next.js app — it sets privy-token (which NAS auth() and the
/api/agents cookie path both read). The sign-in window therefore never
detected success and hung. Fix: cookiesHavePrivySession (privy-token + __Host/
__Secure/legacy privy-session variants) in connection-config.cjs, and
hasLivePortalSession now checks the Privy cookie on the portal host. The
per-agent silent cascade still uses the gateway-cookie check (each agent IS a
Hermes gateway).
2. Multi-org discovery needs an org picker (Q8). A portal session carries no org
pin, so a user in >1 org got a dead-end 403. Paired with NAS #545 (merged):
/api/agents now returns 409 org_selection_required + the user's org list, and
accepts a membership-validated ?org=. discoverCloudAgents(org) appends ?org=,
and on 409 returns { needsOrgSelection, orgs } instead of throwing; the cloud
panel shows a 'Choose an organization' picker, then re-runs discovery scoped
to the chosen org (with a 'Change org' affordance for multi-org users).
Also reverts the ERR_NETWORK_CHANGED retry helper from the prior commit: the
IPv6-churn aborts on Ben's Arch host are a host/network-layer issue, and a
client reload can't safely drive Privy's single-use-code redirect chain
(disable IPv6 for the session is the workaround). Kept out of this feature PR.
Tests: connection-config.test.cjs (57, +5 Privy-cookie cases, proven to fail
without the helper); boot-failure-reauth (16). tsc + eslint clean. Verified live
end-to-end against prod portal: sign-in → org picker → scoped agent list →
silent per-agent connect.
cloud-auto-discovery Phases 3+4 follow-up (decisions.md Q7, Q8).
Phase 4 of cloud-auto-discovery — the UI on top of the Phase 3 cloud plumbing.
Adds a third 'Hermes Cloud' ModeCard alongside Local/Remote in gateway-settings.
Selecting it reveals the cloud panel instead of the URL/token form:
- signed-out → 'Sign in to Hermes Cloud' (one portal login in the OAuth partition)
- signed-in → a discovered-agent picker (loading / empty / list states) with a
Refresh control. Selecting an agent drives the silent per-agent cascade
(cloud.agentSignIn) then applies a mode:'cloud' connection pointed at its
dashboardUrl — no second sign-in prompt.
Cloud auto-discovers on entering the mode when a portal session already exists.
Test/Save bottom-row actions are hidden in cloud mode (selection applies the
connection); the remote URL/token form is now gated to remote mode only.
Wires the renderer to the Phase 3 IPC (window.hermesDesktop.cloud.*). i18n
strings added to en + zh (full) and the Translations type; ja/zh-hant inherit via
defineLocale fallback. New 'Cloud' icon (IconCloud) exported from lib/icons.
Validated: tsc clean, eslint clean, vite renderer build succeeds, 52 electron +
16 vitest tests pass.
cloud-auto-discovery Phase 4.
Phase 3 (non-UI) of cloud-auto-discovery. Adds the 'cloud' connection mode and
the IPC plumbing for a single portal login that powers both agent discovery and
silent per-agent sign-in. The Phase 4 UI (cloud ModeCard + instance picker)
sits on top of these IPC methods.
Mode widening (Model A, decisions.md Q6): DesktopConnectionConfig.mode and
DesktopConnectionConfigInput.mode widen to 'local'|'remote'|'cloud'. A cloud
entry is a remote-shaped block (remoteUrl = the selected agent's dashboardUrl,
remoteAuthMode 'oauth') tagged mode 'cloud' so settings reopens into the cloud
picker. Every RESOLUTION site treats cloud as remote via the new
modeIsRemoteLike() helper (centralized in connection-config.cjs): readDesktop-
ConnectionConfig, sanitizeConnectionProfiles, sanitizeDesktopConnectionConfig,
coerceDesktopConnectionConfig, profileRemoteOverride, resolveRemoteBackend,
globalRemoteActive, testDesktopConnectionConfig, and isRemoteReauthFailure. The
live resolved HermesConnection.mode stays 'local'|'remote' — cloud never reaches
the boot path or the renderer remote-gating sites.
Cloud mechanics (main.cjs): one portal session in the persist:hermes-remote-oauth
partition does double duty — discoverCloudAgents() GETs {portal}/api/agents over
the partition-bound net (cookie-authed; NAS #542 accepts the cookie), and
cloudAgentSilentSignIn() opens a selected agent's /login in the same partition so
the portal's silent auto-approve 302s back with that agent's session cookie, no
second prompt. Portal base URL resolves via DEFAULT_NOUS_PORTAL_URL +
HERMES_PORTAL_BASE_URL/NOUS_PORTAL_BASE_URL overrides, mirroring the CLI.
IPC: hermes☁️{status,login,logout,discover,agent-sign-in} in main.cjs +
preload.cjs, typed in global.d.ts (DesktopCloudStatus/Agent/DiscoverResult/
AgentSignInResult).
Tests: modeIsRemoteLike + cloud profileRemoteOverride (node --test, 52 pass);
cloud reauth-failure cases (vitest, 16 pass). tsc clean; eslint clean. New tests
verified to fail without the source changes.
cloud-auto-discovery Phase 3 (non-discovery half + discovery/cascade plumbing).
BaseEnvironment writes shell snapshots and cwd metadata through the process
umask. With a common 022 umask, snapshot files containing exported environment
state landed at mode 0644 even though they can include env-carried credentials
from the parent process.
Set umask 077 only around Hermes metadata writes: the initial snapshot
bootstrap and the post-command snapshot/cwd refresh. User commands still run
under the caller's original umask, while Hermes-owned snapshot and cwd files
are created owner-only.
This intentionally does not copy the source PR's global orphan sweep; deleting
all matching /tmp snapshot files could interfere with concurrent Hermes
processes. The security-critical local disclosure fix is the file mode clamp.
This is salvageable because the source report still identifies a concrete
credential-disclosure path, but the safe subset is smaller than the original
proposal: clamp only the Hermes-owned snapshot writes and leave process-wide
cleanup, user command umask, and concurrent sessions alone.
Salvages source PR: https://github.com/NousResearch/hermes-agent/pull/20056
Related issue: https://github.com/NousResearch/hermes-agent/issues/48441
Co-authored-by: Andrew Homeyer <andrew@hndl.app>
_SlashWorker call sites now pass profile_home=; the fakes' 2-arg
__init__ raised TypeError inside the spawn guard, leaving
slash_worker=None and failing the orphan-race regression tests.
Same bug class as skills_tool: module-level SKILLS_DIR pinned at import
under the launch HERMES_HOME makes skill_manage() write/edit against the
wrong profile in long-lived multi-profile runtimes. Apply the same
_skills_dir() call-time resolution (honoring explicit test patches of
SKILLS_DIR) to _containing_skills_root, _resolve_skill_dir,
_find_skill_in_other_profiles, and create-result path reporting.
Refs #40677
Profile-local skills are unavailable in Dashboard/TUI/Desktop GUI because the
_SlashWorker subprocess is spawned with os.environ.copy() but does NOT receive
the profile-specific HERMES_HOME from the parent session. This causes the
subprocess to search ~/.hermes instead of the active profile's skills directory.
1. Modify _SlashWorker.__init__ to accept optional profile_home parameter
2. When profile_home is provided, set env['HERMES_HOME'] = profile_home before
spawning the subprocess
3. Update all 4 call sites to pass profile_home=session.get('profile_home')
4. Add regression tests for profile-home propagation
- Full TUI gateway test suite: 107 tests pass
- New tests cover:
- profile_home parameter acceptance
- backward compatibility (None, omitted)
- argv correctness
Fixes#40677
Discord thread names share the same UTF-16 component budget as select
labels and buttons — route the sanitizers in gateway/run.py and the
adapter's rename_thread through utf16_len/_prefix_within_utf16_limit
instead of code-point slices. Adds rungmc357 to AUTHOR_MAP.
Sessions no longer auto-reset by default. SessionResetPolicy.mode now
defaults to "none" (was "both": 24h idle + daily 4am), matching the
setup wizard's existing no-reset default and community feedback that
surprise context loss hurts more than it helps.
- gateway/config.py: dataclass default + from_dict fallback -> "none";
installs whose config.yaml lacks a session_reset section stop
auto-resetting
- hermes_cli/setup.py: "Never auto-reset" is now the recommended/default
choice in hermes setup agent; stale comment updated
- docs (en + zh-Hans): default is no auto-reset, opt in via
session_reset in config.yaml
Users who explicitly configured idle/daily/both resets keep them.
/new's handler calls _reset_notice_session_info, which resolves live
provider credentials and can probe model context length over HTTP. In
CI there are no credentials, so resolution walks the entire fallback
chain (the failed run's log shows 'Primary provider auth failed ...
trying fallback' captured inside the test) and on a slow runner the
first parametrization can blow past send_and_capture's 2s poll window,
making adapter.send appear never-called.
Stub it to return an empty info block in the e2e runner fixture — these
tests exercise gateway command dispatch, not provider resolution, and no
other network-touching path exists in the /new flow. Flaked in run
28856659216 (telegram param only); tests/e2e now 57/57 locally.
Replace the hand-rolled ensurepip bootstrap (and five other one-off
pip-install code paths) with hermes_cli.tools_config._pip_install, which
prefers the bundled uv (fast, needs no pip in the venv), falls back to
python -m pip, and bootstraps pip via ensurepip only when missing.
Sites unified:
- hermes_cli/setup.py: _install_neutts_deps, _install_kittentts_deps,
modal SDK install, daytona SDK install
- hermes_cli/memory_setup.py: memory-plugin pip deps (previously dead-ended
when uv AND pip binaries were both absent)
- hermes_cli/dingtalk_auth.py: qrcode auto-install (previously invoked
'python -m uv' which is not how uv ships)
- agent/lsp/install.py: --target LSP server installs
- plugins/google_meet/cli.py, plugins/platforms/matrix/adapter.py,
plugins/platforms/google_chat/oauth.py, plugins/memory/honcho/cli.py
Tests updated to assert the ladder behavior (uv-first, pip fallback,
ensurepip bootstrap) instead of the removed bespoke branches.
Follow-up on the salvaged fix, which bounded start_polling() only in
_handle_polling_network_error. The same wedge (#59614) exists at the two
sibling call sites:
1. _start_polling_resilient (bootstrap): an exhausted pool hangs connect()
forever. The TimeoutError from wait_for is a builtins TimeoutError
(OSError subclass), so the existing except classifies it via
_looks_like_network_error and schedules background recovery.
2. _handle_polling_conflict (conflict-retry ladder): identical hang wedges
conflict attempt N forever; timeout now converts to RuntimeError and the
existing except schedules the next attempt.
Tests replaced with a stronger suite: hung-network-ladder repro (RED without
the fix), bootstrap hang schedules recovery, success-path sanity, and a
bug-class contract test asserting EVERY updater.start_polling( call site is
wrapped in wait_for so a new unbounded site can't reintroduce the wedge.
Verified RED (3 failures) with the wrappers removed, GREEN with them.
When the connection pool is in a degraded state after
_drain_polling_connections(), start_polling() can hang indefinitely
when both primary and fallback Telegram endpoints are unreachable. The
httpx client may hold a stale socket that neither connects nor times out
within PTB's internal flow, causing the reconnect ladder to stall at
attempt 1/10 forever.
Wrap start_polling() in asyncio.wait_for() with a 30-second timeout so a
hung call raises asyncio.TimeoutError and feeds back into the existing
retry ladder. This unblocks:
- The 10-retry ladder advances to attempt 2, 3, ...
- The heartbeat loop sees _polling_error_task.done() and can trigger recovery
- The reconnect watcher gets the adapter in _failed_platforms
Fixes#59614
Port from anomalyco/opencode#31877: JSON Schema type arrays like
["number","string"] (common in MCP tool schemas) were collapsed to the
first non-null type, silently dropping every other branch. Several
tool-call backends reject the array form outright — llama.cpp's grammar
generator and Gemini via OpenAI-compatible transports (e.g. GitHub
Copilot proxying to Gemini) 400 on it.
_sanitize_node now mirrors @ai-sdk/google: a single non-null type stays
type:X (+nullable if null was present), multiple non-null types become
an anyOf of single-type schemas so no branch is lost, and an all-null
array becomes type:null. Single-null collapse is unchanged.
Verified nested (object props, array items) survive the full sanitize
pipeline — combinator stripping is top-level-only and nullable-union
collapse only fires on single-survivor unions, so multi-type anyOf is
left intact.
Replace the unittest.mock module-name check with an
inspect.iscoroutinefunction probe on content.read, and collapse the
duplicate read/iter_chunked reader paths into one. Non-streaming
objects (test doubles, proxy wrappers) fall back to the response's
native json()/text() as before.
Drop the Responses-API native compaction path and its opt-in umbrella
flag from the salvaged feature. On the Codex OAuth chat route Hermes
owns the message list and the summary compressor works (and stays
provider-portable — encrypted compaction items would lock the session
history to chatgpt.com and break /model switches and provider
fallback). On the app-server runtime (codex CLI/agent) the codex agent
owns the real thread context, so thread/compact/start is the only
mechanism that can actually shrink it (#36801) — that path is now the
default behavior for codex_app_server sessions, controlled by
compression.codex_app_server_auto (native|hermes|off), no umbrella
flag.
Removed: responses.compact() call path, codex_compaction_items replay/
persistence plumbing, codex_native_compaction + codex_responses_threshold
config keys, desktop settings fields, and their tests. Kept: everything
app-server (compact_thread(), compaction notifications, bookkeeping,
docs, tests) plus cache-busting keys for the surviving knobs.
Port from openclaw/openclaw#91950: normalize LLM-generated URLs like 'https:// docs.example' before web tool safety checks while preserving path and query encoding semantics.
* fix: cool down transient Telegram typing failures
Port from openclaw/openclaw#93020: add per-chat cooldown for transient sendChatAction failures so keep-typing refreshes do not hammer Telegram during network blips or rate limits.
* fix: support bare Telegram adapters in typing cooldown
* test: update typing backoff imports for relocated Telegram adapter
The Telegram adapter moved from gateway/platforms/telegram.py to
plugins/platforms/telegram/adapter.py since this branch was created;
point the test imports and monkeypatch targets at the new module.
Drop the duplicate mermaid-block.tsx (own mermaid.initialize + render path,
theme frozen at first load) and wire preview-file.tsx's MarkdownCode through
the existing RichCodeBlock registry from #52935 instead. One mermaid init
path, theme-flip re-init, Zoomable + copy-as-PNG, RichBoundary error
fallback — and the preview pane gets svg fences for free. Shiki block stays
as the fallback for all other languages.
Salvaged from #40531; surgically reapplied onto current main (i18n'd
preview-file.tsx). mermaid dep already present on main.
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
--safe-mode promised to disable ALL customizations, but shell hooks
declared in config.yaml's hooks: block registered anyway —
register_from_config() runs independently of plugin discovery and
load_config() does not honor HERMES_IGNORE_USER_CONFIG. Gate it on
HERMES_SAFE_MODE at the single chokepoint so troubleshooting runs fire
zero user-configured code (plugins, MCP, and hooks).
Docs (en + zh) updated; positive + negative tests added.
The salvaged fix added a post-worker _interrupt_requested re-check to the
main OpenAI/Anthropic streaming poll loop. The Bedrock Converse poll loop
(interruptible_streaming_api_call, api_mode='bedrock_converse') has the same
bug class: its worker calls stream_converse_with_callbacks(on_interrupt_check=
...), which breaks out of the event loop on interrupt and returns a PARTIAL
response WITHOUT raising (bedrock_adapter.py). The worker sets result[
'response'] and exits with _interrupt_requested still True, so the in-loop
raise never fires and the poll loop returns the partial — silently swallowing
/stop on Bedrock exactly as it was on the paths the salvaged commit fixed.
Add the identical post-worker re-check before the Bedrock loop's return.
The non-streaming loop (interruptible_api_call) is structurally immune: its
worker's only early return fires off _request_cancelled, which is set by the
main loop immediately before it raises in-loop, so no swallow window exists.
Guard test flips _interrupt_requested True mid-stream (after the pre-flight
check) and asserts InterruptedError is raised; verified RED without the fix
(DID NOT RAISE) and GREEN with it.
The provider-mismatch guard now checks pool_provider and
current_provider != pool_provider. MagicMock.provider returns
a truthy child mock by default, which would trigger the guard
and skip the pool recovery tests. Set pool.provider='' explicitly.
Two deep bugs found through systematic analysis of the streaming API
call and fallback credential subsystems:
1. Interrupt signal loss (chat_completion_helpers.py):
When the worker thread exits before the main thread's poll loop
checks the interrupt flag (e.g. _call_anthropic() detects the flag
and returns None), the while loop exits normally and the
InterruptedError is never raised. /stop is silently swallowed.
Fix: re-check _interrupt_requested after the while loop exits.
2. Empty provider bypasses credential guard (agent_runtime_helpers.py):
recover_with_credential_pool() guards against cross-provider pool
swaps with 'if current_provider and pool_provider and current !=
pool_provider'. When agent.provider is '' (valid unset state from
agent_init.py:326), current_provider is falsy, the guard is skipped,
and the pool swaps credentials onto an agent with empty provider.
This is the root cause of the 'provider= model=' empty-string error.
Fix: only skip the guard when pool_provider is empty (unscoped pool),
not when agent provider is empty.
Review finding: when the FTS write-corruption guard (#50502) prefers the
cached agent's live _session_messages over the reloaded transcript, that
history bypasses the replay-cleanup pass in _build_gateway_agent_history
— a stale dangerous confirmation could slip through unredacted on the
same-process salvage path. Re-apply the (idempotent) expiry stripper to
the selected live history.
Review findings: (a) an absolute path outside trusted roots passes
through unchanged and gets rejected downstream by skill_view — add a
debug log at the pass-through so the cron 'skill not found' symptom is
diagnosable next time; (b) test_relative_path_unchanged patched
get_skills_dir although the relative branch early-returns before any
root lookup — drop the misleading patch.
The extracted normalize_skill_lookup_name() resolved trusted roots via
agent.skill_utils.get_skills_dir(), but skill_view() enforces
tools.skills_tool.SKILLS_DIR — a separate module attribute that callers
and 60+ existing tests patch directly. With the helper reading a
different symbol than the enforcer, any SKILLS_DIR patch (or future
divergence between the two resolvers) makes normalization disagree with
enforcement and absolute-path loads regress silently. Read SKILLS_DIR at
call time (deferred import, cycle-safe) with get_skills_dir() as the
fallback, and align the new tests to patch the enforced symbol.
Follow-up to the salvage of #59829 by @HexLab98.
Add unit tests for normalize_skill_lookup_name and a cron scheduler
regression that absolute paths under the skills dir reach skill_view as
relative lookups.
Cron jobs may store absolute paths to skills under HERMES_HOME/skills or
external_dirs, but skill_view rejects absolute names for security. Extract
the slash-command normalization into agent.skill_utils and reuse it when
cron loads job skills.