Commit Graph

22221 Commits

Author SHA1 Message Date
kshitij d3e87eef44 refactor: drop dead sys.exc_info check in delivery error log
The result-error path in _deliver_result is not inside an except block,
so sys.exc_info() always returns (None, None, None) — the condition was
always False. Simplify to a plain logger.error call with accurate comment.
2026-08-10 10:58:05 +05:30
aameobius 100219f664 fix(cron): surface exception type and traceback for standalone Discord delivery errors 2026-08-10 10:58:05 +05:30
aameobius b1663edf2a fix(cron): load .env on no_agent path so standalone ticks resolve delivery home channels
hermes-cron-tick.service starts without TELEGRAM_HOME_CHANNEL/DISCORD_HOME_CHANNEL
in the unit env; the per-run load_hermes_dotenv reload lived only on the agent
path (after the no_agent short-circuit returns), so every deliver=telegram/all
script job failed with 'no delivery target resolved'. Load the dotenv at the top
of the no_agent branch; override=False keeps the gateway's in-process tick
behavior unchanged.
2026-08-10 10:58:05 +05:30
kshitij 6fa646e7d8 fix(skills): reject colon in bundle path components (NTFS ADS bypass)
_normalize_bundle_path rejected absolute paths, .. traversal, and a bare
drive-letter prefix, but permitted a colon inside a later path component.
On NTFS a bundle member named scripts/helper.py:payload writes a hidden
Alternate Data Stream into the visible file scripts/helper.py. The skill
scanner walks with rglob('*'), which does not enumerate streams, so both
operator review and the guard scanner miss the executable bytes.

Reject a colon in any component (the whole class, not just the trailing
one). This subsumes the previous bare drive-letter check, which is folded
into the single colon guard. '/' is the only legal separator once
normalized, so no portable bundle path needs a colon.

Adds an OS-independent quarantine_bundle regression plus a direct
normalizer unit test covering leading/mid/trailing-component colons,
bare/qualified drive letters, and the empty stream name.

Reported-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com>
2026-08-10 10:36:59 +05:30
kshitij 2afa4be932 fix: trim comments and fix sibling pop site in summary path
Trim verbose comments in conversation_loop.py and run_agent.py to 2 lines
each. Fix the same bug class in the compression summary path at
chat_completion_helpers.py: remove _thinking_prefill from the explicit
pop tuple and move the generic underscore-key sweep to after
_drop_thinking_only_and_merge_users, so the drop pass can recognize
prefill stubs there too.
2026-08-10 10:01:44 +05:30
Jefftree 126a6ffa4d test(agent): cover the API-copy build so restoring the marker pop fails 2026-08-10 10:01:44 +05:30
Jefftree 97ced4bce2 fix(agent): keep the thinking-prefill marker so the drop pass can strip trailing stubs 2026-08-10 10:01:44 +05:30
Gille 1439a65829 fix(tui): recover active goals after compression exhaustion 2026-08-10 09:58:46 +05:30
kshitij e4b2a90dad refactor: follow-up for salvaged PR #81692
- warn (not debug) on final text-turn flush failure: a failure here
  reopens the exact #81641 data-loss window with _persist_session as
  the only remaining retry, unlike the verify siblings which retry
  in-loop; include session id for triage
- trim the flush-site comment to sibling proportion, pointing to the
  test module for the full incident narrative
- test: assert _persist_session presence before indexing, so a wiring
  change fails with a clean assertion instead of ValueError from max()
2026-08-10 09:38:28 +05:30
joaomarcos 6c2c77efba fix(agent): persist completed text turns before the loop exits (#81641)
A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.

Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.

The neighbouring exits of the same loop already close this gap:

  * the tool-call exit flushes the assistant(tool_calls) block before
    handing control to _execute_tool_calls (#49045)
  * the verify-on-stop and pre_verify exits flush final_msg before
    appending their nudge (#65919 §7)

Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.

Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 09:38:28 +05:30
ethernet 8359e760be fix(ci): don't report all-good before jobs start
The live comment poller inferred completion from the job list. An empty
job list looks the same as a finished run: GitHub has not spawned the
jobs yet, so nothing is pending, and the poller posted a final
"all good!" comment and exited.

The run status is now the authoritative signal. collect_run_jobs()
returns whether the CI run and every watched sibling run report
status=completed, and the loop exits only when no job is pending AND
all runs are complete. While a run is still queued or in progress with
no visible jobs, the comment shows "waiting for jobs to start" instead
of a final banner.
2026-08-09 22:24:19 -04:00
ethernet cd4317b449 test: convert the last host-OS fakes and guard double markers
Six test files still selected an OS branch with a faked host. Each one now
carries the marker for the host that owns the branch, or derives the
expectation from the real host:

- test_clipboard: macos_only on the has_clipboard_image dispatch. The fake
  picked the branch, but _macos_has_image needs osascript.
- test_claw: windows_only on the tasklist/powershell scan, with return_value
  in place of a side_effect list that pinned the call count.
- test_linux_desktop_entry: the parametrize over "darwin"/"win32" becomes one
  marked test per host. A fake left POSIX paths and a POSIX XDG layout.
- test_graphical_browser_detection: linux_only on the display-server arm. The
  $BROWSER check runs before the platform branch, so its test stays unmarked.
- test_auth_nous_provider: the fixture pinned linux so the macOS certifi
  fallback could not change the result. The assertion now reads the host, so
  the macOS lane covers the fallback too.
- test_tts_macos_output and test_voice_mode: the afplay policy exists because
  CoreAudio init raises a TCC prompt, which no Linux runner reproduces.

tests/conftest.py refuses collection when one test carries two OS markers.
Each marker skips on all but one host, so two of them make a test that runs
nowhere while every lane reports green. tests/test_os_marker_gating.py pins
that behavior.

The docstring on TestConfirmDestructiveSlash said the Windows job runs it.
The class has no marker, so -m windows_only deselects it.
2026-08-09 22:09:49 -04:00
ethernet a298dbcfe3 ci: print the zero-selection diagnostic instead of dying first
`shell: bash` runs the step with -e injected, and `set -uo pipefail` does
not clear it. A non-zero pytest exit killed the script before `status=$?`,
so the -eq 5 branch and its ::error message never ran. The job still failed
red, but the diagnostic that names the cause never printed.
2026-08-09 22:09:49 -04:00
ethernet 641d254db4 ci: add macos and windows test lanes for the os-marked tests
the markers from the previous commit skip off-host. without a host to
run them on, every marked test is a silent skip. this commit adds the
hosts.

- tests-os.yml runs -m macos_only on macos-latest and -m windows_only
  on windows-latest. ci.yml requires both lanes in all-checks-pass.
- a lane fails on pytest exit code 5 (zero tests selected). a renamed
  marker cannot produce a green job that ran nothing.
- each lane repeats 'not integration' because a command-line -m
  replaces the addopts filter.
- scripts/ci/list_os_marked_tests.py selects which files each lane
  imports. -m filters after collection, and collection imports every
  module. without this helper, one unrelated ImportError on the
  foreign host fails a job whose own tests passed. the helper exits
  non-zero when a marker matches no file, and writes bytes with
  explicit lf so windows crlf translation cannot corrupt the bash
  file list. it has its own tests in tests/ci/.
- the local runner now reports the skipped count and prints a note:
  macos_only/windows_only tests were skipped on this host, and this
  ci lane runs them. a green local run on linux no longer reads as
  coverage of the other hosts.
- the runner default job count is now #cpu, not #cpu*2.
2026-08-09 22:09:49 -04:00
ethernet 30da5d0a89 test: run os-specific tests on their real host, not a faked one
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.

this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.

each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
  dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has

bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.

running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.
2026-08-09 22:09:49 -04:00
ethernet 3bd844edf1 fix(desktop): make un-highlighted code readable while streaming in light theme
streaming code blocks in the light theme render near-white text on the
white code card until shiki's highlight lands, then snap to normal token
colors. the pale text is @tailwindcss/typography's pre foreground: its
prose theme styles pre as a dark slab (--tw-prose-pre-code = gray-200 on
a gray-800 bg). we strip the bg for our own code card but the near-white
foreground survives on the container. shiki's opaque per-token span
colors normally hide it — it shows through wherever text renders without
spans: the streaming delay window, the lazy-chunk suspense fallback, and
over-budget blocks that never highlight.

traced on the live renderer: computed color on the wrapper of mid-stream
code was oklch(0.928 0.006 264.531) (gray-200), supplied by the
.prose :where(pre) rule.

fix: prose-pre:text-foreground on the markdown container, so every
fenced path inherits the transcript foreground instead. the utility
layer is emitted after typography's base rule in the built css, so the
override wins by order at equal specificity.
2026-08-09 19:29:59 -04:00
hermes-seaeye[bot] e34c4c009a
fmt(js): `npm run fix` on merge (#82771)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-09 22:43:38 +00:00
Teknium aaf0fbfa95 test(desktop-ssh): cover wrapper preservation and explicit-path passthrough in locateHermes
Replaces the canonicalization test (which pinned the behavior #74425
removes) with wrapper-preservation coverage for auto-detection and an
explicit remoteHermesPath, both asserting no python3 -c parser call is
issued. Verified both fail against the pre-fix implementation.
2026-08-09 15:35:25 -07:00
webtecnica 8b752603c4 fix(desktop-ssh): stop resolving exec-wrappers to python in locateHermes (#74411)
Problem 1: resolveLauncher() read bash 'exec <python> <script>' wrappers
and returned ONLY the python interpreter path, discarding the script.
This made probeHermesVersion() run '<python> --version', which always
printed 'Python x.y.z' instead of the Hermes version. And
remoteSupportsSshOwnership() ran '<python> serve --help' which failed
entirely because no 'serve' module exists in the python stdlib.

Problem 2: When the user set remoteHermesPath (an explicit override),
resolveLauncher() resolved it to the python interpreter, replacing the
user's specified path. The override was effectively ignored for version
checking and capability probing.

Fix: resolveLauncher now returns the candidate path directly. The hermes
binary or wrapper script is already executable and handles argument
forwarding (e.g. 'exec <python> <script> "$@"') correctly on its own.
No additional remote SSH round-trip or python script needed.
2026-08-09 15:35:25 -07:00
Teknium 497c90f317 chore: map contributor email for hillimited 2026-08-09 15:02:27 -07:00
Hill Chitsanupong 784f733cf8 fix(gateway): honor session_reset policy when recovering sessions
Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- _create_entry_from_recovered_row derives updated_at from the durable
  last_activity_at the finder already returns on the row (no extra DB
  round-trip; the original PR added SessionDB.get_last_activity for
  this, unnecessary post-#82633), falling back to created_at. An
  invalid or missing started_at now maps to epoch 0 instead of now — an
  invalid durable timestamp must look old, never freshly active.
  reset_had_activity is set from the row's durable activity/message
  signals so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.

Cherry-picked from #78618 and adapted to the #82633 finder.
(cherry picked from commit 31c71f7629)
2026-08-09 15:02:27 -07:00
Tranquil-Flow 6e99531c8e fix(gateway): respect reset boundaries during recovery (#68539)
find_latest_gateway_session_for_peer filtered non-recoverable rows out of
candidacy BEFORE ordering, so recovery could search behind a /new reset
boundary and resurrect an older still-open row for the same peer —
silently restoring the exact context the user reset.

Rebuilt against the #82633 finder (has-messages ranking +
COALESCE(last_activity_at, started_at) recency): the fence is expressed
as a NOT EXISTS guard inside both the exact-key and peer-fallback
queries — a candidate is rejected when an intentional boundary row
(session_reset / session_switch / idle / daily / suspended /
resume_pending_expired) for the same peer ended after the candidate's
last activity. If the conversation's most recent event is an intentional
reset, recovery returns nothing rather than reaching behind it.

Cherry-picked from #68617 and adapted to the rewritten finder.
(cherry picked from commit bb2c562a16)
2026-08-09 15:02:27 -07:00
Teknium 7830d9e102 chore: map TomAce7 contributor email for attribution audit 2026-08-09 15:02:00 -07:00
TomAce7 6e8dcb8f47 fix(gateway): distinguish durable cached transcript rows 2026-08-09 15:02:00 -07:00
Teknium 59f1aa8bf6 fix(gateway): carry origin_json/display_name into /branch child sessions too
Complete the /branch routing-identity fix (salvaged from PR #62278 by
@jcjc81): in addition to user_id/session_key/chat_id/chat_type/thread_id,
forward origin_json and display_name at create_session() time, matching
the reset-path db_create_kwargs pattern (#82633) so the branch row is
born with full identity — no backfill gap for state.db consumers
(mcp_serve, mirror, channel directory) if a crash lands before
switch_session().

The obsolete compression-rotation half of #62278 was dropped: rotation
now goes exclusively through publish_compression_child, which already
copies all identity columns in-transaction.
2026-08-09 14:59:59 -07:00
Jason 4f0531c972 fix(gateway): also persist user_id and session_key in child-session creates
The sweeper flagged two gaps in the routing-columns fix:

1. /branch create_session() omitted user_id and session_key — the
   fallback lookup path (find_latest_gateway_session_for_peer) requires
   user_id to match the complete peer tuple when session_key lookup fails,
   and /resume IDOR guards reject sessions without matching user_id.

2. Compression-rotation create_session() omitted agent._user_id — same
   problem: rotated child cannot satisfy persisted /resume ownership proof
   before the later gateway backfill.

Forward user_id and session_key at CREATE time in both call sites so
the child row is immediately fully routable with zero backfill gap.

Extended tests: compression rotation asserts user_id is carried (and None
for CLI sessions). Branch routing asserts both user_id and session_key on
the child row before switch_session runs.
2026-08-09 14:59:59 -07:00
jcjc81 3cbeeaa175 fix(gateway): carry chat_id/thread_id/session_key into /branch child sessions too
Same defect as the compression-rotation fix in the prior commit, found
during a full-audit of every create_session() call site per the repo's
'fix the whole bug class, sibling call paths included' contribution
guidance.

_handle_branch_command() (gateway/slash_commands.py) creates the branched
child session via create_session() without chat_id/chat_type/thread_id.
The routing columns are only backfilled later, when switch_session() runs
at the end of the function and calls _record_gateway_session_peer(). In
between, the function copies the parent's conversation history to the new
session_id one message at a time, with each append_message() call
independently try/excepted (best-effort) — a crash/kill anywhere in that
window leaves the branched session permanently unroutable, same failure
mode as the compression bug: NULL chat_id/thread_id can never be found by
find_latest_gateway_session_for_peer, AND unreachable via /resume's IDOR
guard (which requires the row's chat_id/thread_id to match the caller's).

Fix: forward source.chat_id/chat_type/thread_id at create_session() time,
mirroring the existing correct pattern already used by /title's
auto-create path a few hundred lines up in the same file (which has an
explicit IDOR-scoping comment justifying it).

Tests: tests/gateway/test_branch_routing_columns.py drives the real
_handle_branch_command against a real SessionStore + SessionDB (SQLite in
tmp_path, no DB/session-store mocks). Patches switch_session to simulate a
crash landing before it runs (the exact gap the routing columns need to
survive), then asserts the branched child's chat_id/chat_type/thread_id
are already correct in state.db at that point. RED verified against
unpatched code (assert None == '170829464'), GREEN after the fix.

Regression: 102/102 across the new test + pre-existing /branch, session
boundary, compression rotation, DM thread seeding, session API, and
resume-command suites. Broader tests/gateway/ -k "branch or session_api or
resume or topic_mode or session_boundary" sweep: 255/255 passed, 1
(unrelated) skip.
2026-08-09 14:59:59 -07:00
Teknium 3e05d7abf1
fix(skills): trim ast-grep description to the 60-char hardline
test_authoring_standards.py::test_description_hardline red on main since
461c493972 landed with a 383-char description. The trimmed detail is all
preserved in the SKILL.md body (When-to-use, decision tree, search_files
comparison). Unbreaks every open PR's slice 4.
2026-08-09 14:55:28 -07:00
ethernet c002b6fbe5 fix(desktop): send full tool args so expanded rows show the whole command
The gateway sent only an 80-char preview (context) for a tool call.
The desktop rebuilds the expanded tool row from the args of the part.
When the args were absent, the row showed the preview, and long
commands ended in '...' after the user expanded them.

Two paths had this fault:

- tool.start: the payload had no args until tool.complete, so the
  expanded row was truncated while the tool ran. Now tool.start ships
  the args, the same as tool.complete already does.
- _history_to_messages: the projection read the full arguments, then
  discarded them. Hydration from this projection (watch windows,
  compress, branch, seeded create) kept only the preview, so the
  truncation was permanent. Now tool rows carry the args. This
  projection is the display view of the transcript — each renderer
  decides what to paint, and the preview stays for collapsed titles.

The DB rows do not change: the args already persist in tool_calls.
2026-08-09 17:48:37 -04:00
Teknium 461c493972 Port from code-yeongyu/oh-my-openagent: ast-grep structural search/codemod optional skill
Vendors the ast-grep skill from oh-my-openagent's shared-skills bundle
(upstream code-yeongyu/ast-grep-skill @ 3148c69, MIT) into
optional-skills/software-development/ast-grep with Hermes conventions:

- SKILL.md rewritten with Hermes frontmatter (platforms, tags, category)
  and Hermes tool routing (search_files instead of raw rg, terminal for
  sg invocations, patch-vs-ast-grep division of labor)
- scripts/ast_grep_helper.py: fixed argparse so trailing paths after an
  optional flag parse (parse_known_args + fold extras into paths);
  upstream errored 'unrecognized arguments: .' on the documented
  'search PATTERN --lang js .' form
- 7 reference docs, install.sh/install.ps1 (pinned-release GitHub
  fallback), smoke tests carried over verbatim

E2E validated: install (github method, ast-grep 0.45.0), doctor,
search, validate (regex rejection), replace dry-run + apply two-pass,
scan with YAML rule, tests/smoke.sh 15/15 pass.
2026-08-09 14:40:15 -07:00
Teknium e95e13783b fix(docker): per-session container isolation and session-scoped workspace mounts
Two bugs reported on the docker terminal backend (desktop app, sandboxed
profiles with container_persistent: false):

1. A NEW chat's container inherited the PREVIOUS session's workspace,
   bind-mounted rw at /workspace, because the mount source was the
   process-global TERMINAL_CWD env var (written by the workspace picker,
   outliving its session) and all sessions shared one 'default' container.

2. Every command failed with exit 126 because the desktop gateway recorded
   the HOST launch directory as the session cwd, and each command was
   prefixed with 'cd /Users/<user>/...' inside the container.

Fixes (class-wide, single owners):

- container_persistent: false + docker now keys containers PER SESSION:
  fresh container per chat, removed at session close/idle. delegate_task
  children share the parent's container via an explicit alias registry.
  container_persistent: true keeps the documented ONE-long-lived-container
  contract unchanged.
- _resolve_task_host_cwd() is the single owner of the cwd->/workspace mount
  policy across all four env-creation sites; under isolation it refuses
  process-global cwd sources and mounts only the session's own attached
  workspace (tui_gateway now tags overrides with cwd_source).
- _resolve_command_cwd() gains the same host-path guard the env-creation
  sites already had (#50636/#54447 sibling site): a recorded host cwd is
  discarded on container backends instead of cd-ing every command into a
  nonexistent path.

E2E-tested against real Docker: distinct containers per session, no stale
mount in a fresh session, no exit 126 from host cwd records, containers
removed at session teardown.
2026-08-09 14:35:02 -07:00
izumi0uu 1527a81b5e fix(state): keep canonical writes available when FTS is corrupt 2026-08-09 14:10:21 -07:00
Teknium de0f20ff05 fix(gateway): spool cap-dropped pending transcript messages instead of discarding
When the per-session pending transcript queue hits _MAX_PENDING_PER_SESSION
(200) while the session DB is broken, the gateway previously popped the
oldest message and discarded it permanently — silent user data loss during
live operation (#78182). The on-disk pending spool only ran at shutdown via
flush_pending_to_file.

Extend that existing spool machinery for runtime drops:

- gateway/shutdown_flush.py: add spool_dropped_transcript_message() and
  drain_transcript_spool(), reusing _get_flush_dir/_write_payload (same
  atomic-JSON pending_messages/ spool format). recover_pending_to_db()
  now also replays transcript_cap_drop payloads left over across restarts.
- gateway/session.py: on cap eviction, spool the dropped message and log a
  WARNING that includes the spool path; if spooling fails, degrade to the
  previous drop-and-warn behavior. On the next fully successful transcript
  flush for that session, drain and replay spooled messages in drop order;
  replay failures keep the spool files for the next attempt.
- tests/gateway/test_pending_queue_spool.py: drop→spool→drain roundtrip,
  per-session drain isolation, spool-failure degradation, replay-failure
  retention, and spool primitive ordering/reason filtering.

No new config; extends existing flush_pending_to_file infrastructure per
AGENTS.md guidance.

Refs #82616, #78182
2026-08-09 14:08:51 -07:00
joaomarcos c790ed2a5d fix(state): recover gateway sessions stranded without a routing identity
When state.db's write path fails (corrupt FTS, or a crash landing between
routing publication and row creation), the live gateway conversation can end
up in a session row that never received its identity columns: session_key,
chat_id, chat_type and origin_json are all NULL. In-memory routing hides the
damage for as long as the gateway stays up. After a restart the chat is
resolved from the DB, and find_latest_gateway_session_for_peer cannot see
that row — both of its queries match on the very columns it lacks — so the
chat resumes the last keyed sibling instead, days older. The messages were
never lost, only unreachable.

Hardening the write side cannot reach a row that is already damaged, so add
the offline repair path the tracking issue asks for:

- SessionDB.find_orphaned_gateway_sessions() reports message-bearing rows
  with no session_key, and names the predecessor each one continues only
  when the evidence is unambiguous — a recorded parent_session_id
  ("lineage"), or exactly one keyed row of the same source and compatible
  user_id that fell quiet within 15 minutes of the orphan's start
  ("contiguity"). Contested pairs are reported with a reason and left alone:
  a wrong adoption would splice one person's conversation into another
  person's chat. Branch, delegate and tool rows are excluded — they are
  unkeyed by design, not by damage.
- SessionDB.adopt_orphaned_gateway_session() stamps the orphan from the
  predecessor (never overwriting a column that already has a value), records
  the lineage, and retires the predecessor under end_reason
  'superseded_by_repair' — a reason recovery does not treat as resumable, so
  the repaired row wins the chat from then on. The pair is re-verified inside
  the write transaction, making a concurrent heal a no-op rather than a
  conflicting write.
- `hermes sessions repair-routing` drives both. It reports without touching
  the database; --apply confirms first and warns that a running gateway
  still holds the old mapping in memory.

Refs #82616.
2026-08-09 14:06:06 -07:00
ygd58 408634c2e1 fix(tui): fix unreadable session-title chip contrast in the status bar
Fixes #82465.

The session-name chip at the right end of the TUI status bar rendered
white/near-white text (t.color.statusFg) on a raw, full-saturation
accent-hue background (t.color.accent, #FFBF00 -- bright yellow -- in
DARK_SEEDS). Two token problems stacked: accent is the accent
IDENTITY hue, never used elsewhere as a solid fill (fills are always
softened, e.g. activeRow = mix(surface, accent, 0.22)); and statusFg
is derived as a light gray lifted toward near-white text, a tone never
designed to sit on a saturated fill. Together: roughly 1.5-2:1
contrast, unreadable on the default dark theme.

Applied the issue's recommended first option: drop the background fill
entirely and render the title as accent-colored text on the normal
status bar background. Same highlight intent (the title still stands
out via its color), readable contrast on both dark and light seeds.

Updated the existing test that had encoded the buggy background-fill
expectation, and added an explicit contrast-regression assertion.
Verified as a genuine regression by reverting the fix and confirming
the test fails with the exact reported #FFBF00 background color.

54/54 pass across the three appChrome-related test files (no
regression).
2026-08-09 13:59:29 -07:00
Phil Thomas e7d01dd021 fix(personality): preserve config comments in TUI/gateway config writes
tui_gateway/server.py:_save_cfg called yaml.safe_dump on a deep-loaded
config dict, which reordered top-level keys alphabetically, stripped
every user-edited comment, and re-escaped non-ASCII (kaomoji/Chinese)
personality prompts to \uXXXX. Every TUI setting change - /personality,
/reasoning, /details_mode, /skin, /prompt - rewrote the file top to
bottom.

Changes:

* Add atomic_roundtrip_yaml_save(path, new_state) in utils.py - a
  comment-, ordering-, and unicode-preserving full-state replacement
  for yaml.safe_dump(cfg, f). Uses ruamel round-trip mode like the
  existing atomic_roundtrip_yaml_update, but accepts the whole cfg
  dict so callers that mutate multiple keys before saving (the
  _save_cfg pattern) don't have to be rewritten. Recurses into nested
  dicts, deletes keys missing from new_state (preserves the
  cfg.pop()-then-save semantic), and overwrites lists/scalars
  wholesale.

* Fail closed on an unreadable existing config.yaml the same way
  hermes_cli.config.atomic_config_write does, via a lazy import of
  require_readable_config_before_write (avoids a module-level circular
  import, since hermes_cli.config itself imports from utils). Also
  preserves both file mode and owner across the write, matching the
  existing atomic_roundtrip_yaml_update contract.

* Force-quote any new string value that YAML 1.1 would misparse as a
  bool/null (yes/no/on/off/true/false/null/~). ruamel's round-trip
  dumper resolves against the YAML 1.2 core schema and emits these
  unquoted, but PyYAML-based readers elsewhere in the codebase parse
  under YAML 1.1 rules - so an unquoted `approvals.mode: off` would
  silently round-trip back as the boolean False.

* tui_gateway/server.py:_save_cfg now delegates to
  atomic_roundtrip_yaml_save. Drop-in - all call sites (/personality,
  /reasoning, /details_mode, /prompt, etc.) inherit comment
  preservation and the fail-closed contract.

Tests:

* tests/test_utils_atomic_roundtrip_yaml_save.py - unit tests covering
  create-from-empty, top-level key-order preservation, comment
  preservation, readable Unicode, append-new-keys, delete-missing-keys,
  scalar/list overwrite, nested-dict recursion, refusal on an
  unreadable existing config, and owner preservation.

* tests/test_atomic_replace_symlinks.py - owner-preservation regression
  test mirroring the existing atomic_roundtrip_yaml_update coverage.

* tests/test_tui_gateway_server.py - 4 new tests pinning _save_cfg
  comment preservation, top-level key-order preservation, and
  unicode-readability under unrelated writes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 13:43:14 -07:00
ethernet 7aecab56db ci: move the review comment and the image build out of the CI run
The CI run stayed in progress until its last job ended. Two advisory jobs
set that time: the review-comment poller (40 minutes) and the Docker image
build (45 minutes). Neither job was required to merge.

GitHub refuses `gh run rerun` on a run that is in progress. Thus a reviewer
who added the `ci-reviewed` label had to wait for the two slow jobs, and
label-rerun.yml carried a 2100-second wait loop for this reason. The fast
required jobs were ready long before.

Each slow job now runs in its own workflow:

- docker.yml owns its `pull_request` trigger and does its own change
  detection. The new `detect` job runs the same composite action with the
  same condition that ci.yml applied, so a tests-only PR still skips the
  build. The `workflow_call` trigger is gone.
- ci-review-comment.yml starts on `workflow_run` when CI starts. It reads
  the workflow and the scripts from the default branch, which is the trust
  boundary that the old job got from its `ref: default_branch` checkout.

The poller reads job results through the API, so it can report on a run
that it does not belong to. `WATCH_WORKFLOWS` names sibling workflows for
the same commit, and `select_watched_runs` keeps the newest run for each
name. Thus the comment still shows the Docker results. The list is
newline-separated, because a workflow name can contain a comma.

The poller always exits 0 now. It reports on the CI run from a different
run, so a failed CI job is not a failure of the poller. The CI run has its
own gate for that.

Also correct a parse error in label-rerun.yml. STATUS came from the already
truncated RUN_ID, so its value was the run id and never "completed". Thus
the wait branch always ran.

ci.yml no longer needs `packages: write`, because the image build has left.
2026-08-09 15:40:04 -04:00
Teknium d2a4d373eb fix(gateway): make session identity durable so chat continuity survives crashes and restarts
Root cause of #82616: gateway session identity (session_key/chat_id/
origin_json) was written best-effort in a separate UPDATE after row
creation, both reset-path DB writes swallowed failures silently
(logger.debug / bare print), transcript reads ignored the reroute map
that writes follow, and restart recovery ranked candidate rows by
started_at while hard-rejecting empty rows. A single failed write could
therefore strand the live conversation in an unroutable orphan row while
a days-old zombie kept the routing key — after any gateway restart the
chat silently resumed the zombie (user-visible context loss, 5 confirmed
incidents on one install since June).

Four class fixes:

1. Identity lands atomically in the session INSERT: origin_json and
   display_name join _insert_session_row's column list + COALESCE
   backfill; both gateway creation paths (get_or_create + reset) pass
   full identity including parent_session_id lineage (fixes #12857).

2. record_gateway_session_peer self-heals: when the target row is
   missing (failed/deferred create, crash window) it INSERTs the row
   with full identity instead of silently no-opping — every per-turn
   peer refresh is now a repair opportunity, and an identity-less lazy
   writer (update_token_counts/record_auxiliary_usage) can never leave
   a gateway session permanently unroutable.

3. load_transcript follows the write-side reroute chain and the durable
   compression tip before querying, so reads can no longer return 0
   rows for a session whose messages live under its compression child;
   read exceptions are WARNING, distinguishable from an empty result.

4. find_latest_gateway_session_for_peer ranks by
   COALESCE(last_activity_at, started_at) (message-bearing rows first)
   and returns an empty-but-keyed row instead of None — a zombie
   predecessor can no longer beat the live conversation, and recovery
   never mints a fresh id when a keyed row exists.

Reset-path DB write failures now log at WARNING with the routing
consequence spelled out.

Tests: tests/gateway/test_session_continuity_82616.py (11 tests) —
sabotage-verified: 6/11 fail without the fixes. E2E incident replay
(real SessionDB, temp HERMES_HOME) confirms the production shape now
resolves to the live session.

Fixes #82616. Related: #12857, #78182 (read-path half), #79576.
2026-08-09 12:37:06 -07:00
ethernet 0e2b5f3835 chore: remove old plan files 2026-08-09 15:17:01 -04:00
Teknium 244d296646 fix(personality): single-owner personality state + one-time reset migration
Personality persistence used to be split per surface: the TUI/desktop wrote
the NAME to display.personality while the CLI/gateway wrote rendered TEXT
into agent.system_prompt (and their /personality none only blanked the
text, leaving the name behind). When #81946 made display.personality
authoritative everywhere, stale names written long ago resurrected
personalities users had turned off - kawaii defaulting on after updating.

- hermes_cli/personality.py: new single owner of personality state.
  Built-in personality definitions, neutral-name normalization, rendering,
  availability (built-ins overlaid by agent.personalities), overlay
  resolution, and the ONLY sanctioned persistence path
  (persist_personality -> display.personality; never agent.system_prompt).
- v34 config migration: one-time reset of display.personality to none
  (announcing which personality was cleared and how to re-enable), plus a
  scrub of agent.system_prompt when it verbatim-equals a known personality
  render (machine-written by the old CLI/gateway). Hand-written manual
  prompts are never touched.
- All surfaces rewired through the module: CLI /personality (incl. active
  marker in the list), gateway /personality, TUI config.set + slash path
  (which previously applied without persisting), TUI config.get (reports
  the EFFECTIVE personality), completer, hermes config display, and the
  tui_gateway health probe.
- cli.py/config duplicates removed: built-ins now defined once; the
  desktop mirrors them from one lib module (src/lib/personalities.ts).
- Docs updated: selection lives in display.personality, built-ins always
  available, one-time reset note.
2026-08-09 10:33:58 -07:00
kshitij c9411b72df fix: store strong ref to detached fatal handler task to prevent GC
asyncio.ensure_future(result) creates a task with only a weak ref in
the event loop's task table. After the carrier raises CancelledError,
the local 'task' variable goes out of scope and the loop can GC the
handler before it finishes — the exact 'handler killed mid-flight'
class we are fixing, just via GC instead of cancellation.

Add _detached_fatal_tasks set on BasePlatformAdapter (matching the
gateway-level pattern in _handle_adapter_fatal_error). Uses getattr
fallback for test stubs built via object.__new__().
2026-08-09 22:34:28 +05:30
gregorustar-maker 7bc81c4ffd fix(gateway): shield fatal-error handler from carrier task cancellation
When an adapter escalates a retryable fatal error from inside one of its
own tasks (e.g. Telegram's _polling_error_task after exhausting polling
network retries), the gateway's _handle_adapter_fatal_error tears the
adapter down via disconnect() — which cancels that very task. The
propagating CancelledError killed the handler between popping the
adapter from the adapter map and queueing the platform in
_failed_platforms, leaving a zombie gateway: process alive, zero
connected platforms, zero pending retries, until a manual restart.

Run the handler as a detached task under asyncio.shield so carrier
cancellation no longer aborts teardown/queueing mid-flight. The carrier
still observes CancelledError (teardown semantics unchanged); only the
handler is protected. A done-callback consumes the detached task's
exception to avoid 'Task exception was never retrieved' noise.

Fixes #81335
2026-08-09 22:34:28 +05:30
kshitij f284693496 chore: AUTHOR_MAP for afgl_mk93@icloud.com (PR #81851) 2026-08-09 22:29:39 +05:30
miura 1e8339a48c fix(compression): preserve live tail before snapshot adoption 2026-08-09 22:29:39 +05:30
kshitij 326bdfb7a2 refactor: clean up gateway scope identity predicate and tests
- Remove dead use_systemd_scope = False assignment (leftover from
  the old try/except pattern, immediately overwritten).
- Update stale log label supervisor= -> in_supervised_gateway=
  to match the renamed variable.
- Convert autouse _mark_gateway_process fixture to opt-in
  _gateway_identity so negative tests start from a clean slate
  instead of undoing the fixture's env/PID mocks.
- Parametrize 4 near-duplicate negative tests (2 scenarios x
  pipe/PTY) into 2 parametrized tests, reducing ~130 lines to ~80.

76 tests pass, ruff clean, net -32 LOC.
2026-08-09 21:43:32 +05:30
bgrablin aa32e81141 fix(process-registry): bind gateway scope identity to pid 2026-08-09 21:43:32 +05:30
bgrablin ff5dfdecef fix(process-registry): keep CLI workers off controlling tty 2026-08-09 21:43:32 +05:30
joaomarcos bcdfdd51e5 fix(gateway): make the restart-loop breaker see slow crash cycles (#81642)
The auto-resume restart-loop breaker (#30719, defense-3) pruned its boot
log against an absolute `window_seconds` (default 60s). That prune is
period-sensitive: a crash cycle slower than the window drops its own
history on every boot, so the counter never leaves 1 and the breaker can
never trip, no matter how long the loop runs.

The cycle reported in #81642 is ~150s — a wedged event loop, the liveness
watchdog hard-exiting at ~90s, a supervisor respawn, and auto-resume
replaying the same session that wedges it again. Structurally invisible to
a 60s window: `gateway/restart_loop.json` kept a single timestamp across 15
kills in one morning. Because every cycle leaves a gateway that cannot
process SIGTERM, `hermes update` has no drainable gateway to stop, which is
the reported hang.

Chain boots on the inter-boot GAP instead of an absolute window: two boots
belong to the same loop when they are no more than `max_gap_seconds` apart
(default 300s, floored by `window_seconds` so widening the window never
makes the breaker less sensitive). The verdict becomes period-agnostic —
the original ~10s respawn loop still trips in 3 boots, and so does a 150s
one — while a boot after real quiet resets the chain, so occasional
operator restarts still never accumulate. The persisted chain is capped at
50 entries.

- gateway/restart_loop_guard.py: gap-chained pruning (`_chain_ending_at`),
  `DEFAULT_MAX_GAP_SECONDS`, `max_gap_seconds` kwarg on the three entry
  points, clock-step tolerance, bounded state file
- gateway/run.py: `_restart_loop_guard_config` reads and returns
  `max_gap_seconds`; the auto-resume call site passes it through
- hermes_cli/config_defaults.py: `gateway.restart_loop_guard.max_gap_seconds`

Tests: 7 new cases in TestRestartLoopGuard covering the slow cycle, chain
persistence, quiet-period reset, the #30719 fast loop, the config knob, the
window floor, and the disabled breaker. Verified RED before the fix (the
slow-cycle case asserted `[1300.0] == [1000.0, 1150.0, 1300.0]`, exactly
the single-timestamp state file from the report) and GREEN after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:40:23 +05:30
kshitij 6bdeb2df24 fix: move ghost filter before alternation repair + promote scaffold constant
Move the legacy ghost-row filter from inside the api_messages loop to
BEFORE repair_message_sequence_with_cursor. Dropping a ghost assistant
row between two user messages creates user→user which the repair can
now fix (previously the repair ran first and missed it).

Promote '[This response was interrupted by a user correction.]' to
module-level _INTERRUPT_SCAFFOLD_MARKER constant — used in both
_apply_active_turn_redirect (checkpoint_parts) and the ghost filter,
so they can never drift.

Update ghost-row test: the two consecutive user messages are now
merged by repair, so check for content as substring.
2026-08-09 21:30:54 +05:30
HexLab98 0072969c18 fix(agent): drop legacy interrupt-scaffold ghost rows from API replay
Sessions already poisoned by the incomplete #73146 else branch still replay
hidden assistant rows whose content is the raw interrupt scaffold. Skip those
rows when building provider messages so old state.db history cannot keep
seeding the echo loop.
2026-08-09 21:30:54 +05:30