Commit Graph

191 Commits

Author SHA1 Message Date
Teknium cab6eb78f9 test(projects): widen lane-id derivation regression coverage
Cover the kanban ::kanban id, the -wt- suffix raw-path lane, and
Windows separator/trailing-slash spellings collapsing to one lane key.
2026-08-15 00:33:11 -07:00
Teknium f378a8fb3b fix(projects): dedup project_create by primary_path (#75820)
Creating a project whose resolved primary path already belongs to a
non-archived project now raises a clear ValueError naming the existing
project (create_project) — duplicated projects each seeded an identical
copy of the repo subtree, multiplying the duplicate-lane bug per copy.
The agent-facing project_create tool is idempotent instead: it re-activates
the existing project rather than erroring. allow_duplicate_path=True keeps
deliberate duplicates possible. Also updates the legacy non-git lane-id
expectation to the branch-style id introduced for #53329.
2026-08-15 00:33:11 -07:00
embwl0x e89532d97e fix(desktop): order async session git metadata 2026-08-15 00:33:11 -07:00
briandevans f7de2ca416 fix(desktop): order project-tree lanes by recency in the overview, not alphabetically
_build_repos emptied each lane's sessions array for the overview (hydrate=False)
payload BEFORE _sort_lanes ran. _lane_sort_key derives a lane's activity from
max(_session_time(s) for s in group["sessions"]), so with the rows already gone
every non-trunk lane scored activity=0.0 and the sort key
(is_trunk, is_kanban, -activity, label) collapsed to alphabetical-by-label. The
documented intent — branches and linked worktrees sort by most-recent activity,
then label — was silently defeated on the projects.tree RPC that feeds the
desktop sidebar overview, while the drill-in path (hydrate=True) kept the rows
and sorted correctly. Any repo with two or more non-trunk lanes showed a
different order in the overview than when opened.

Move the session-clearing to after _sort_lanes/_disambiguate_labels so the sort
reads real recency. Lane counts are still captured before clearing, so
sessionCount and the slim overview payload are unchanged — only the order is
fixed, and the overview now matches the drill-in.
2026-08-15 00:33:11 -07:00
Tranquil-Flow 0d07fe63f9 fix(projects): use _branch_lane_id for non-git folders to prevent duplicate lanes (#53329)
_place_by_heuristic used the raw path as the lane key for non-git
project folders, while the desktop overlay independently computed
::branch::main for the same session (since git_branch was null).
The ID mismatch caused duplicate lanes — one from the backend with
the folder name, one from the overlay labeled 'main'.

Use _branch_lane_id(path, DEFAULT_BRANCH_LABEL) so the backend's
lane key matches the overlay's expected ::branch::main scheme,
eliminating the duplicate lane.
2026-08-15 00:33:11 -07:00
Teknium 73bcfddb3d fix(desktop): replay pending clarify prompts after reconnect
Clarify prompts share the same emitted-while-detached failure class the
pending-approval replay fixed: `clarify.request` rides `_block()`'s pending
registry, so a client whose transport was down when the event fired never
sees the question and the agent thread stays parked until timeout.

Widen the resume snapshot the same way:

- tui_gateway/server.py: `_live_session_payload` now carries
  `pending_clarify` — a read-only snapshot of the clarify prompt still
  blocking the session, scoped to the owning runtime sid. The registry stays
  authoritative; the embedded request_id resolves via clarify.respond.
- Desktop resume paths (`use-session-actions`) restore the parked clarify
  into the clarify store (multi_select preserved) and flag needsInput,
  mirroring restorePendingApproval on both the activate and resume paths.
- pending_approval replay now also forwards the queue-injected request_id so
  the restored prompt responds with exact-request correlation.
- Tests: server-side replay + scoping test; harmonized the #82087 replay
  test with the request_id `_ApprovalEntry` now injects.
2026-08-14 20:24:32 -07:00
konsisumer 38b9005b95 fix(desktop): replay pending approvals after reconnect 2026-08-14 20:24:32 -07:00
VooDoo Pixels f703e70618 fix: make desktop approval routing reliable
Correlate approval requests, reject stale responses, replay pending approvals after reconnect or session resume, and preserve fail-closed timeout behavior.
2026-08-14 20:24:32 -07:00
Teknium d275b96bfd feat(gateway): per-profile MCP server lifecycle RPCs (mcp.servers.*) (#86473)
Adds the full MCP setup surface as profile-scoped gateway RPCs so a
desktop client (Bot Mode's bot editor, the core Capabilities tab) can
add/configure/test/authenticate/remove MCP servers for ANY profile, not
just the launch profile:

- mcp.servers.list (profile) -> configured servers (transport, auth,
  oauth_tokens_present, enabled, tool names; no secret values)
- mcp.servers.add (profile, name, config|preset, bearer_token?) -> reuses
  mcp_config._apply_mcp_preset / _save_mcp_server / _save_bearer_auth_token
- mcp.servers.set_api_key (profile, name, value, env_var?) -> http auth
  header template or stdio env ref, via save_env_value
- mcp.servers.test (profile, name) -> _probe_single_server + oauth state
- mcp.servers.remove (profile, name)
- mcp.servers.oauth.start/poll (profile, name[, session_id]) -> mirrors the
  PROVIDER oauth session/poll model (not the FastAPI dashboard flow): a
  background worker drives the same interactive machinery 'hermes mcp login'
  uses, capturing the browser redirect on a local loopback listener. Client
  opens auth_url via openExternal and polls until status=='approved'.

All handlers are profile-scoped via set_hermes_home_override in try/finally
(mirrors skills.manage). Shared helpers live in tui_gateway/mcp_rpc_helpers.py
and are aliased onto server.py's namespace so the rebound handler bodies
(HandlerRegistry.install) can resolve them — a plain def in methods_tools is
unreachable post-rebind. Reuses hermes_cli/mcp_config.py throughout; no config
logic duplicated; no raw yaml near config.yaml (config-read-guard safe).

Tests: tests/tui_gateway/test_mcp_profile_rpcs.py, 8 E2E against real temp
HERMES_HOME profiles asserting add/list/set_api_key/remove land in the RIGHT
profile's config.yaml and not the launch profile's. 8/8. Registration +
live mcp.servers.list verified in an imported gateway.

Co-authored-by: Teknium <teknium1@users.noreply.github.com>
2026-08-14 16:17:22 -07:00
David Gutowsky 3abe6cc503 fix(gateway): bind profile HERMES_HOME override in ephemeral agent threads (#50233)
Normal prompt turns bind session['profile_home'] via set_hermes_home_override
before run_conversation, but the two ephemeral RPC paths (prompt.background,
preview.restart) spawn a fresh AIAgent on a new thread where the HERMES_HOME
ContextVar does not propagate — so a background/preview turn under a
non-default profile ran against the wrong home. Re-bind for the duration of
the ephemeral turn and restore in finally, mirroring the normal prompt turn.

Surgically reapplied from PR #50777 (handlers moved to methods_prompt.py
since the PR was authored; handler bodies rebind onto server.py globals, so
the original pattern transplants verbatim). Includes the contributor's
regression tests unchanged.
2026-08-14 15:04:46 -07:00
teknium1 f79440e0f4 feat: /loop — recurring in-session wakeups (Claude Code parity)
Ports Claude Code's /loop (and its /proactive alias) across every Hermes
surface. /loop [interval] <prompt> re-runs a prompt or slash command on a
recurring cadence inside the live session; omitting the interval enables
self-paced mode (starts at the floor, backs off exponentially while the
agent's replies stop changing, snaps back on change — local digest
comparison, zero extra LLM cost).

Stop conditions: agent-emitted LOOP_COMPLETE marker, --times N,
--until <condition> (judged by the existing goal_judge aux task,
fail-open), /loop stop, and a loops.max_ticks backstop budget.

Core: hermes_cli/loops.py (LoopState + LoopManager + shared
dispatch_loop_command), persisted per session in SessionDB state_meta
(loop:<sid>) so /resume picks it up; migrates across compression
boundaries like /goal. New SessionDB.list_meta_prefix() powers the
gateway's cross-session scan.

Surfaces:
- CLI: /loop handler + idle-fire and post-turn-complete hooks in
  process_loop (mirrors the /goal hook shape; Ctrl+C pauses the loop)
- Gateway: /loop handler with route capture, mid-run control-verb guard,
  post-turn tick completion, and a supervised loop_wakeup_watcher that
  injects due wakeups into idle chats via the synthetic-message path
- TUI/dashboard/desktop: command.dispatch handler + per-session
  notification-poller wakeup driver + post-turn completion in the turn
  dispatcher; /loop added to the desktop slash palette
- /goal mixing: an active non-parked goal owns the idle boundary — loop
  ticks defer until it finishes, pauses, or parks; real user input always
  wins over both

Config: loops.{min_interval_seconds,max_ticks,self_paced_floor_seconds,
self_paced_ceiling_seconds}. Docs page + sidebar entry. 77 new tests.
Slack's 50-slash cap: /version moves to /hermes version to free the
native slot for /loop.
2026-08-14 13:40:19 -07:00
Brooklyn Nicholson 7de5634a2f test: attach RPCs complete while the agent is still building
Behavior contracts, not timings: each handler must return with the session's
agent_ready event still unset, the staged image must still reach the turn,
and an unknown session must still be rejected. Verified to fail against the
unfixed resolver (4 failed, 90s of real stalls) rather than only passing
against the fix.
2026-08-14 13:24:40 -07:00
xxxigm d6b4083f41 test(approval): cover CLI EXEC_ASK leak and fix slash-worker Path mock
Regression tests for silent pending_approval when ask-mode leaks into
interactive CLI, plus a Path-typed hermes_constants mock so the
slash-worker profile_home test survives per-file isolation.
2026-08-14 17:48:28 +05:30
Laura f84f1dd132 fix(tests): return Path from get_hermes_home mock in slash_worker profile test
test_slash_worker_accepts_profile_home mocks hermes_constants with
get_hermes_home=MagicMock(return_value="/tmp/hermes_test"), a str. In
production get_hermes_home() returns a Path, and hermes_state.py's
module-level DEFAULT_DB_PATH = get_hermes_home() / "state.db" does path
division. Under the str mock that becomes str / str, so importing
tui_gateway.server inside the patch raises TypeError and the test fails on
every main run (slice 4). Wrap the mock return in Path(...) so it matches the
real return type. Test-only; no production code change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 16:53:40 +05:30
Dmytro Afanasiev b640e63035 fix(kanban): preserve TUI subscription after done 2026-08-13 12:21:04 -07:00
kshitij 3b3bda7b00 fix(gateway): pool-route wake.start/wake.status — same STT lazy-install chain
wake.start calls check_wake_word_requirements() → _stt_ready() →
_get_provider() → _try_lazy_install_stt() → ensure("stt.faster_whisper")
(same synchronous subprocess install chain as voice.toggle), and
start_listening() → _build_engine() whose constructors call
lazy_deps.ensure("wake.openwakeword" / "wake.sherpa" / …).
wake.status calls check_wake_word_requirements() too and is polled
by the desktop on every gateway-ready. Same bug class as #21123 /
#50005 — sibling to the voice RPC fix in the prior commit.

Update existing wake.start test call sites from server.dispatch() to
_dispatch_sync() since dispatch() now returns None for pool-routed
methods. Extend the pool-routing regression test to cover wake RPCs.
2026-08-13 22:44:23 +05:30
hustwkr 6a9d2dc2f3 fix(gateway): pool-route voice RPCs so STT lazy install can't block WS sends
voice.toggle (status) triggers check_voice_requirements() -> STT provider
auto-detect -> a synchronous faster-whisper lazy install (uv/pip subprocess
with a 300s timeout). Inline on the WS reader thread it stalls handle_ws
before it reads the next frame, so prompt.submit / session.list queued
behind a voice.toggle sit unread and the desktop 'send message' appears
dead for minutes while the install churns (reproduced: voice.toggle ->
session.list 40s+ timeout).

Route voice.toggle/voice.record/voice.tts to the RPC pool (same bug class
as #21123 / #50005) so a slow lazy install can't block message handling.
Adapt the voice handler tests to drive the handler inline via a small
_dispatch_sync helper (preserving transport-binding semantics) since
dispatch() now returns None for pool-routed methods, and add a regression
test asserting the voice RPCs stay pool-routed.
2026-08-13 22:44:23 +05:30
Brooklyn Nicholson 08a3b20dff test: register setup_mcp in the desktop_ui toolset + post-hook contracts
The toolset inventory and the post-hook ownership contract both
enumerate the GUI tools; the new tool joins both lists (and the
emit-once parametrization actually exercises its executor path).
2026-08-13 01:06:51 -05:00
Gille 1439a65829 fix(tui): recover active goals after compression exhaustion 2026-08-10 09:58:46 +05:30
Brooklyn Nicholson 368625e001 perf(gateway): warm every path the project tree will resolve
The warm covered session cwds, but build_tree also resolves each declared
project folder and each discovered repo root. Those were the last probes
running one directory at a time while the sidebar showed a skeleton.
2026-08-09 06:53:55 -05:00
Brooklyn Nicholson e3836efc5f perf(gateway): stop spawning git for paths that cannot answer
The project tree probes every distinct session cwd, and on a long-lived
history most of those directories are deleted worktrees — `git -C` there
can only fail, at the price of a fork each. Stat first.

The second elision is `common_repo_root`: only repos have a common dir,
and the parallel warm never covers that probe because `resolve()` reaches
it only for cwds that already resolved. Every non-repo cwd was therefore
paying a serial `git` spawn on the discovery pass.
2026-08-09 06:53:55 -05:00
Brooklyn Nicholson 41d435ff4d fix(desktop): stop /home showing as a second Home project
/home and /Users hold home directories; neither is a workspace. A session
whose cwd was one of them got promoted to its own auto project, so the
sidebar listed a lowercase "home" row beside the synthetic Home bucket.
Both POSIX spellings are excluded on every host — macOS ships an empty
/home autofs stub, and container/remote shells hand back Linux paths — as
are the filesystem root and the parent of $HOME.
2026-08-09 02:28:02 -05:00
Brooklyn Nicholson e24bac49fa feat(desktop): tell the agent when it is floating in HUD mode
In HUD mode Hermes is a strip over the app the user is actually working
in, so "what's under you?" or "look up the weather" is almost always
about that app — but the agent had no way to know it was floating, and
answered from its own browser and panes instead.

The desktop tags a HUD submit with `surface: 'hud'` and the gateway turns
that into a per-turn note pointing at read_window_below, and at carrying
the work out in the app underneath. It rides the model-bound message
beside the reaction and speech-interrupted notes rather than the system
prompt: one session can be driven from the app window on one turn and the
HUD on the next, and the system prompt has to stay byte-stable.

Every tool the note names is checked against the agent's own schema
first, so a session without computer_use or read_window_below is never
pointed at a tool it cannot call.
2026-08-08 15:37:41 -05:00
Brooklyn Nicholson fe9e4d1776 test(personality): regression coverage for #81791
Assert config.set and /personality preserve manual agent.system_prompt,
and that startup resolution prefers display.personality.

Co-authored-by: kyssta-exe <kyssta-exe@users.noreply.github.com>
Co-authored-by: EMT5320 <1908937833@qq.com>
2026-08-08 14:01:57 -05:00
Brooklyn Nicholson beda5149d9 test: pin read_window_below into the toolset + post-hook contracts, appease eslint
The desktop_ui and post-hook ownership contract tests enumerate their tool
sets exactly — add read_window_below to both (plus the executor-path
parametrize case). Lint: sorted type import, explicit GetWindowsModule type
instead of an import() annotation, curly + blank-line style.
2026-08-08 12:17:50 -05:00
Teknium 1405d330e7 Port from superagent-ai/grok-cli: description-aware slash-menu fuzzy scoring 2026-08-08 04:29:16 -07:00
kshitij 5b4b9bbf77 fix(sessions): tip-only resume guard on the CLI mid-setup path; fail open on guard errors
The mid-setup CLI resume path loads only the tip session's rows, so
gate it with a tip-only count instead of the full-lineage count (which
over-rejected heavily-compressed sessions). Transient guard failures
(locked DB, adaptor stores) now log and proceed instead of blocking
resume with a new error.
2026-08-08 13:36:08 +05:30
kinsolee c750d5354a fix(sessions): prevent oversized transcripts from exhausting memory 2026-08-08 13:36:08 +05:30
bb 4cefba3ec9 fix(desktop): stop rendering a repo's main checkout as a duplicate sidebar lane
The main-checkout test compared the two probe roots with raw string
equality. When they differed only in separator spelling, the repo's own
checkout was misclassified as a linked worktree: it fell through to the
worktree branch and was labeled by directory basename. The sidebar then
showed one checkout twice — a dir-labeled lane plus the branch-labeled
`main` lane built from the same sessions.

Compare with `_path_key` so platform path identity decides, matching how
every other path comparison in this module is already keyed.

Tests cover the single-checkout case and the main + linked-worktree case;
both fail before this change (the lane comes back labeled `repo`, not
`main`).
2026-08-07 12:48:41 -05:00
kshitij 813793db23 test(tui_gateway): pin the raising-close swallow + no-retry contract
Review finding: the close block's comment promises a raising
session_db.close() is swallowed with the flag already cleared (no re-close
on a second agent.close()), but nothing pinned it. One test with a raising
_RecordingDB proves both halves.
2026-08-07 19:44:41 +05:30
Yishova be14a4bee3 tui_gateway: close dedicated profile SessionDB handles at teardown too
Follow-up to the review on the session.resume ownership fix. Closing the
pre-transfer early returns left two gaps, both real.

1. The transfer had no owner on the other side. Once ownership moved to the
   agent, teardown ran AIAgent.close() (via _teardown_session on session.close
   and the orphaned-session reaper), which called session_db.end_session() —
   that finalizes the session ROW, not the connection. A successfully resumed
   profile session kept its dedicated handle, its db/-wal/-shm fds and its
   background token-writer thread for the life of the gateway.

   AIAgent now carries an explicit _owns_session_db, defaulting False so the
   SHARED launch handle — which outlives every agent and backs every other live
   session — is still never closed there. Only the dedicated-open sites set it,
   at the point ownership actually changes hands.

2. session.resume was not the only profile-scoped open with no close on its
   failure paths. Covered here with the same flag, via a _transfer_db_to_agent
   helper that refuses the transfer unless the agent really holds that handle:

   - the deferred builder (_start_agent_build), including the session-reaped-
     mid-build case, where the built agent is discarded and never torn down, so
     transferring to it would leak exactly as before;
   - session.branch's branch_db;
   - the compute host's per-profile open;
   - AIAgent's own lazy open in _get_session_db_for_recall, which no other
     object ever references and so was unconditionally abandoned.

Where a handle has already reached a registered session, the drop is
unconditional and the transfer is best-effort on top: a refused transfer leaves
the old leak, which is survivable, whereas closing under a live session is the
permanent "Cannot operate on a closed database" break the original patch exists
to avoid.

Tests: tests/tui_gateway/test_session_db_ownership_teardown.py (new, 14).
11 of the 14 fail without this change; the 3 that pass are the "must NOT close"
guards, which hold in both directions by design.
2026-08-07 19:44:41 +05:30
Yishova 79625e3c0b tui_gateway: session.resume abandons the profile SessionDB it opens 2026-08-07 19:44:41 +05:30
Brooklyn Nicholson 7ad9ace2cc fix(agent): the desktop's tools reach it on remote and cloud backends too
The pane, in-app browser, and reaction tools were gated on HERMES_DESKTOP=1 —
an env var set only on backends Electron spawns itself (local and SSH). A
desktop client connected to a plain URL gateway or Hermes Cloud lost all six:
they were stripped from the schema before the model saw them, on the same
backend whose platform hint was telling it "you are chatting inside the Hermes
desktop app". open_preview, read_preview, read_terminal, close_terminal,
focus_pane, and react_to_message were all silently absent.

The client is not the host. Capability now resolves from the session's own
source, which session.create already carries:

- The six tools move into a `desktop_ui` toolset, off _HERMES_CORE_TOOLS so no
  other platform pays their schema.
- _gui_surface_toolsets(platform) folds `desktop_ui` (and the existing
  `project` tools) into the GUI gateway's resolution when the session's
  platform is the desktop app — the same answer on every topology.
- check_fn drops the env probe. It kept the one thing that is genuinely a
  per-process/user fact: react_to_message's display.message_reactions opt-in,
  which the desktop mirrors onto whichever gateway it is connected to.

react_to_message was doubly broken: it read that toggle behind the env gate, so
even a local-backend user's Settings toggle could not reach a remote session.

The embedded terminal pane keeps working correctly the other way round: it runs
`hermes --tui` against a desktop-spawned backend, and a tui-sourced session
gets no GUI tools even though HERMES_DESKTOP=1 is set on that process.
2026-08-06 19:35:47 -06:00
kshitij 5c078b987e test(tui): pin picker-cache prewarm wiring in entry.main()
teknium's review gap on #72021: the helper's worker/once-guard was
covered, but nothing asserted the stdio TUI entry point actually
invokes prewarm_picker_cache_async() — or that it does so in the right
place. Add a focused entrypoint test that runs the real entry.main()
with stubbed collaborators (same monkeypatch-module-attrs harness as
test_tui_entry_mcp_owner.py), spies on the helper in
hermes_cli.model_switch (the lazy-import source), and asserts:

- prewarm fires exactly once, strictly AFTER the gateway.ready write
- startup stays non-blocking: main() reaches the stdin loop and
  returns on EOF
- a prewarm failure is swallowed (fire-and-forget) without breaking
  startup

Mutation-checked: deleting the prewarm hunk from entry.py fails both
tests.
2026-08-04 12:28:05 +05:30
kshitijk4poor 0bb14627b8 test: harden cold-start regression tests + debug-log the env-var skip
Review folds on the #60807 salvage:
- resolve_skin tests are behavioral (thread-ident probe + ready-frame
  wiring check) instead of pure source inspection, per the #72720
  pattern; a source assertion remains as belt-and-braces.
- The warm-list test does REAL imports and checks sys.modules —
  _warm_gateway_module swallows ImportError by design, so the PR's
  tracking-stub test would pass even with a typo'd module name.
- resolve_copilot_token logs a debug line when the env-var
  short-circuit skips the gh-CLI fallback (behavioral change made
  observable).
2026-08-03 21:16:09 +05:30
jinglun010 25a9c2c245 perf(cold-start): mitigate ~14s GIL stall during backend init (#60800)
Three fixes for the Desktop/TUI cold-start stall where the event loop
is blocked for ~14s between HERMES_BACKEND_READY and the first
prompt (#60800):

1. copilot_auth: skip  subprocess fallback when any
   Copilot env var is explicitly set (even if invalid). The user
   expressed token intent via env var; silently substituting a CLI
   token is surprising and the subprocess adds up to 5s on Windows.

2. tui_gateway/ws: run resolve_skin() via asyncio.to_thread so config
   loading + skin engine init do not block the WS read loop during
   the cold-start RPC burst.

3. web_server: extend _warm_gateway_module to pre-import the heavy
   module chains (auth, copilot_auth, runtime_provider, skin_engine,
   inventory, model_switch) that the first WS connection + RPC burst
   would otherwise import on the loop thread. These trigger .pyc
   compilation and Defender scans on Windows (15-30s per the existing
   comment) and were not covered by the original gateway-only warm.

Tests: 5 new tests in test_cold_start_gil_stall.py + 2 new tests in
test_copilot_auth.py. All 36 copilot_auth tests + 16 ws/web_server
tests pass.
2026-08-03 21:16:09 +05:30
joncaldwell90 58286878ef fix(tui): avoid writable Kanban opens on empty polls 2026-08-03 20:40:28 +05:30
briandevans eb4f514b2d fix(tui_gateway): retain live-turn sessions unfinalized when the drain deadline expires
The drain reserves a slice of the shutdown budget so flush_all_sessions
still runs when in-flight turns outlast the window. But that flush was
unconditional: a session whose turn was still running got its one-shot
_finalize_session spent mid-turn, and the executor.shutdown(wait=False,
cancel_futures=True) immediately after does not join the turn. The
session was then permanently un-finalizable and its active-session lease
had been released out from under live work — the same persistence and
lifecycle race the drain exists to close, just relocated past the
deadline instead of removed.

Give _turn_futures a session association (Future -> sid, the same key
space as server._sessions) at both submit sites, and on deadline expiry
exclude the sids whose futures are still running from the flush. Those
sessions are retained unfinalized and therefore recoverable; sessions
with no live turn finalize exactly as before. The done-callback now pops
under the lock, since a bare dict.pop is not the drop-in set.discard was.

wait semantics, the reserve math and the bounded per-tick sleep are
unchanged, so this adds no shutdown latency. All three shutdown callers
(orphan, sigterm, and the tight stdin_closed wait=2.0 path) funnel
through this one function and are covered.
2026-08-03 09:59:58 +05:30
briandevans e239184521 fix(tui_gateway): bound the shutdown drain sleep by the time left to it
The drain loop slept a flat 0.05s per tick, so it could overshoot its deadline
by up to one tick and spend part of the reserve withheld for
flush_all_sessions(). For a small `wait` the reserve is itself half the budget,
so a single overshoot can consume all of it: at wait=0.34 the drain budget is
0.17s but the loop requested 4 x 0.05 = 0.20s of sleep.

Clamp each tick to the remaining time. The new test asserts on the summed
*requested* sleep rather than wall-clock, which is deterministic: every sleep is
bounded by the strictly-decreasing remainder, so the total can never exceed the
drain budget regardless of how the scheduler interleaves.
2026-08-03 09:59:58 +05:30
briandevans 1fba2dbe85 fix(tui_gateway): drain in-flight turns before finalizing sessions on compute-host shutdown
ComputeHost.shutdown() called flush_all_sessions() before its own in-flight
turn drain loop. server._finalize_session latches on session["_finalized"]
and every later call returns immediately, so that one flush was spent while
turns were still producing output: the unflushed tail was never persisted,
commit_memory_session wrote long-term memory from a truncated transcript, the
session's DB row was marked ended while it was live, on_session_end fired with
completed=False/interrupted=True against a running session, and the
active-session lease was released out from under a turn. The drain loop exists
precisely so that mid-turn work survives a teardown; finalizing first defeated
it. Reachable from all three teardown paths: the parent/orphan guard (which
os._exit(0)s immediately after), the SIGTERM/SIGINT handler, and stdin close.

Drain first, then flush. A slice of the caller's budget (_FLUSH_RESERVE_SECS,
never more than half of it so a short explicit wait still gets a real drain) is
withheld from the drain so the flush still runs when turns outlast the window:
HostSupervisor SIGKILLs the host _SHUTDOWN_TIMEOUT_SECS after SIGTERM — 10.0s,
the same value as shutdown()'s default wait — so a drain allowed to consume the
whole budget would leave the durability write racing that kill. `wait` itself is
unchanged, so total shutdown latency and the SIGTERM->SIGKILL margin are
unchanged.
2026-08-03 09:59:58 +05:30
Teknium 9667236ccb test(tui_gateway): accept predicate kwarg in _close_session_by_id stub
The LRU-cap test in tests/tui_gateway/test_protocol.py stubs
_close_session_by_id with a two-arg lambda; the reaper now passes a
revalidation predicate. Widen the stub signature.
2026-08-02 14:02:08 -07:00
Shaun Prince d15b638a88 fix(compression): let explicit interrupts cancel safely
Makes interrupt-protected context compression cancellable by an explicit
user or lifecycle stop, without weakening protection against ordinary
incoming messages, voice interjections, or active-turn redirects.

Separates explicit hard cancellation from ordinary interrupt/redirect
state with a dedicated threading.Event; introduces
AuxiliaryExplicitCancellation as an attempt-local frozen-cause signal;
isolates the synchronous provider callback in a bounded daemon worker
during protected compression; atomically linearizes Codex timeout
cleanup against explicit cancellation; propagates hard cancellation
through child agents and explicit stop surfaces; serializes hard-cancel
admission against compression commit admission with
CompressionCommitFence; aborts before session rotation or late DB commit,
restores in-place transcript mutations and compressor state, and releases
the heartbeat and compression lease.

Based on #74449 by @suparious. Resolved merge conflicts in
agent/context_compressor.py (feasibility check + try/except) and
tui_gateway/methods_session.py.
2026-08-02 22:15:20 +05:30
Teknium 8babfe95b4 fix(gateway): only follow settles into same-repo worktrees; never override an explicit cwd
Builds on #72787's current_root guard (cherry-picked with authorship
preserved). Two further hardenings for #72776:

- require the settled cwd and the workspace to share the same common .git
  dir (the shape 'git worktree add' produces), so a git workspace visiting
  an UNRELATED repo is a browsing visit, not a re-home (repro'd by
  Johnny-xuan in the issue thread);
- never reconcile over an explicitly chosen workspace (explicit_cwd),
  while a settle-adopted cwd stays followable via a cwd_from_settle
  marker cleared by _set_session_cwd / project switch.

Fixes #72776
2026-08-01 10:51:30 -07:00
PRATHAMESH75 a22d6516df fix(gateway): don't re-home a non-git session onto a repo it only visited
CI / Check uv.lock (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Build&Test Docker image (push) Has been cancelled
CI / Supply-chain scan (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / OSV scan (push) Has been cancelled
CI / CI review comment (live) (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
Build Skills Index / build-index (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
`_reconcile_session_cwd_from_terminal()` treats a settled terminal cwd in a
different git working tree than the session's workspace as a relocation. But
when the session's workspace is not itself in a git repo, `_git_repo_root_for_cwd(current)`
is None, so the `landed == _git_repo_root_for_cwd(current)` guard never matches
and the FIRST git directory a tool call steps into hijacks the session: its
cwd/git_repo_root flip to that repo, later tools run from the wrong place, and
the repo's AGENTS.md gets injected into an unrelated conversation.

Only reconcile when both the current and settled cwds resolve to valid, differing
git roots. A non-git workspace visiting a git repo to read a file or run a command
is a browsing visit, not a re-home — matching the docstring's own intent that
`cd`-ing away must not re-home the chat. The intended "follow into a worktree"
behavior is unaffected: that path starts from a git checkout, so current_root is
valid there.

Adds a regression test covering a non-git workspace that touches a git repo.
2026-08-01 10:51:30 -07:00
hans f09c56c003 fix(gateway): dedup model-switch markers so they don't accumulate in history
Each mid-session `/model` switch appended a `[System: The active model for
this chat has changed to …]` user-role marker to the live conversation history
and never removed the prior one. N switches left N stale markers, all re-sent
to the provider on every subsequent turn — the issue measured ~80-120 tokens
each (Chinese+English), so 5 MoA-preset switches burned ~400-600 context
tokens per API call, permanently. Only the newest marker is meaningful (it
names the currently-active model); the rest are pure waste. Fixes #65891.

`_append_model_switch_marker` now strips any earlier markers from
`session["history"]` (in place, under the existing history_lock) before
appending the new one, so the live payload carries exactly one marker. This is
the payload re-sent each turn, and it's self-healing across resumes: whatever
markers a history reload brings back, the next switch collapses to one.

A stable `_MODEL_SWITCH_MARKER_PREFIX` constant is shared by the builder and
the `_is_model_switch_marker` predicate so the two can't drift; the marker
string itself is byte-for-byte unchanged. Existing behavior (role='user' per
the single db.append_message persistence) is preserved.

Scope note: this dedups the in-memory history (the per-turn cost the issue
quantifies). The DB still persists one row per switch for audit/resume; I did
NOT soft-archive prior marker rows because `active=0, compacted=0` is the
repo's rewind/undo sentinel (hermes_state.py get_messages docs), so
deactivating a marker naively would make it look rewound — a follow-up that
needs its own archive semantics.

Tests (tests/tui_gateway/test_model_switch_marker_role.py, +5): second switch
replaces (not stacks) the marker; the issue's exact 5-MoA-switch sequence
leaves one marker naming the last model; real conversation turns are preserved
in order; a stale marker sitting between turns is stripped; history_version
increments once per switch. `pytest test_model_switch_marker_role.py` → 13
passed (8 existing unchanged). Authored on Windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013b1XyXitAxV7phGmKWigJX
2026-08-01 14:20:38 +05:30
Brooklyn Nicholson 2b039d90e3 test(gateway): cover the session.reclaimed broadcast contract 2026-07-31 23:04:44 -05:00
Austin Pickett e444d16580 fix(vision): make desktop image uploads reachable from profile Docker sandboxes (#69575) (#75671)
* fix(vision): mount images/ upload dir into sandboxes and permit host read (#69575)

Desktop, clipboard, and PDF uploads land in the flat top-level
HERMES_HOME/images/ dir, but Docker sandboxes only mounted the cache/
subtree and the vision resolver only permitted host reads from the media
caches. So vision_analyze on any desktop-app upload failed under a Docker
backend with "not reachable inside the sandbox".

- Add ("images", "images") to _CACHE_DIRS so the uploads dir is bind-mounted
  into sandbox containers through the existing profile-scoped cache-mount and
  reverse-mapping mechanism.
- Add home/"images" to _media_cache_roots() so the non-local host-read
  allowlist permits reading uploads directly from the host filesystem.
- Cover the mount entry, the container path mapping, and the Docker-mode
  resolver read for a profile-scoped upload.

Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>

* fix(tui_gateway): write image uploads under the session's profile home (#69575)

The attach RPCs (image.attach_bytes, clipboard.paste, pdf.attach) wrote
uploads to the gateway's module-cached launch home via _hermes_home/"images".
Those RPCs run before prompt.submit installs the session's profile HERMES_HOME
override, so in a multi-profile / root-gateway deployment the file landed in
the launch home while the sandbox mount and the vision host-read allowlist
both resolve the session profile's images/ at run time — the agent could
never see the upload it was handed.

Add _session_images_dir(session), which anchors the write on the session's
stored profile_home when present (matching the mount/read scope) and falls
back to the launch home otherwise. Route both write sites through it, keeping
per-profile isolation.

Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>

---------

Co-authored-by: JonthanaHanh <92574114+JonthanaHanh@users.noreply.github.com>
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
2026-07-31 17:43:37 -04:00
Jeff Watts 0860b9804f test(tui_gateway): pin goal-command config home against collection-time _hermes_home freeze
Sibling files (test_billing_rpc etc.) import tui_gateway.server at
collection time, freezing module-level _hermes_home = get_hermes_home()
(server.py:54) to the developer's real home before conftest isolation
runs. _load_cfg() then reads the REAL ~/.hermes/config.yaml — e.g. a
local MoA preset — instead of what _write_moa_config wrote.

The server fixture now monkeypatches _hermes_home to the isolated
HERMES_HOME and resets the mtime-keyed cfg cache (_cfg_cache/_cfg_mtime/
_cfg_path); monkeypatch restores originals on teardown.

Complementary to the #57066 autouse teardown, which only restores the
cfg cache to its pre-test value and never re-points _hermes_home.

Combined tests/tui_gateway + tests/test_tui_gateway_server.py:
792 passed.

Salvaged from PR #63981 by @lEWFkRAD.
2026-07-29 21:30:53 -07:00
Jeff Watts f708a85d2e test(tui_gateway): make the tui gateway suite order-independent
Cross-file leaks made tests/tui_gateway + tests/test_tui_gateway_server.py
fail when run in one process (issue #57068):

- conftest: _hermetic_environment now re-pins hermes_state.DEFAULT_DB_PATH
  to the fake home so no test ever touches the developer's real state.db
- conftest: new autouse _reset_tui_gateway_server_state snapshots/restores
  _methods, cfg cache, db handle and _real_stdout, and tears down leftover
  sessions via the production _close_session_by_id(..., end_reason=
  "test_cleanup") boundary
- per-file fixtures scope patch.dict(sys.modules) to the import only
- drop reload()-based teardowns that duplicated atexit hooks

Conflict resolution vs main: kept main's mod._live_transports.clear() in
the test_protocol.py server fixture alongside the snapshot/restore logic.

Combined run now 792 passed / 0 failed.

Salvaged from PR #57066 by @lEWFkRAD. Fixes #57068.
2026-07-29 21:30:53 -07:00
fcavalcantirj f04fd1e7ad fix(update): test runs never mutate the live checkout — pytest-guard the marker and repair paths
Guard the .lazy-refresh-incomplete marker writer (update_cmd), launch-time
recovery (main.py), and _early_recovery repair paths behind a two-condition
check: running under pytest AND the target is this live checkout. Sandboxed
tmp_path tests still exercise the real code paths.

Salvaged from PR #72002 by @fcavalcantirj. Fixes #72000.

Co-authored-by: fcavalcantirj <felipe.cavalcanti.rj@gmail.com>
2026-07-29 21:30:53 -07:00