Commit Graph

172 Commits

Author SHA1 Message Date
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
`_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
Brooklyn Nicholson 3c5da5307c feat(pairing): give pairing its own change signal
The poll this page's pairing block rode was retired hours earlier by
f8e07a332, which moved Messaging onto platforms.changed. That signal is the
mtime of gateway_state.json — where the gateway persists connect/disconnect
health — and a new pairing request moves none of it. So on an event-capable
backend a pending row stayed invisible until something unrelated
reconnected, and the count badge with it.

Adds pairing.changed to the change watcher, signed off the pending/approved
ledgers across the global store and every profile's own. _rate_limits.json
is deliberately excluded: it moves on every unauthorized DM, including ones
that produce no new row, so signalling on it would refetch for nothing.

The page now refreshes platforms and pairing on their own signals rather
than one combined call, and the legacy visible-tab poll (older backends)
covers both.
2026-07-29 18:50:09 -05:00
brooklyn! 5c07ba2f3a
Merge pull request #74422 from NousResearch/bb/platforms-changed
Desktop: platforms.changed broadcast retires the Messaging page's 6s status poll
2026-07-29 18:37:35 -05:00
Brooklyn Nicholson f8e07a332e feat(desktop): platforms.changed broadcast retires the Messaging page's 6s status poll
The change watcher (#73673) missed one always-on-while-mounted timer: the
Messaging page polled /api/messaging/platforms every 6s for connection
status. The gateway already persists platform connect/disconnect/health to
gateway_state.json, so watch that file's mtime and broadcast
platforms.changed (floored to 5s — the gateway also rewrites the file for
in-flight-count bookkeeping), route it through live-sync like its
siblings, and refresh the page on the tick. Older backends keep the
legacy visible-tab poll verbatim.

Finishes the always-on poll sweep for #73618.
2026-07-29 17:33:35 -05:00
Teknium 6b81590c55
test: prune low-value tests suite-wide (wave 1) — 46,820 → 28,106 test functions
Systematic prune per AGENTS.md test policy, one pass over every major
test tree (gateway, hermes_cli, tools, agent, run_agent, plugins, cli,
cron, tui_gateway, honcho/openviking, root-level):

- DELETE: source-reading tests (read_text/getsource on prod files),
  change-detector tests (exact catalog counts, model-name snapshots,
  config version literals), mock-echo tests (assert a mock returns what
  it was told), assertion-free/trivial tests, near-duplicate
  parametrizations (boundaries + one representative kept), async/sync
  twin duplicates, cosmetic within-file variations.
- KEEP (mandatory): security/redaction/approval guards, message-role
  alternation invariants, prompt-caching/deterministic-call-id
  invariants, issue-number regression tests (deduped), E2E tests.
- 6 test files deleted outright (script-style/no-assert or fully
  redundant); conftest.py, fakes/, fixtures/ untouched.
- tests/acp/conftest.py added: autouse fixture stubs the live
  models.dev/GitHub/Copilot/Anthropic inventory fetches that ACP server
  tests performed on every session create — test_server.py 147s → 3.4s,
  and the tests are now genuinely hermetic.
- Sleep-based slowness shrunk where safe (codex_ttfb_watchdog,
  compression_concurrent_fork, etc.); no wall-clock assertion tightened.

Verification: full hermetic suite via scripts/run_tests.sh —
2439 files, 31,130 tests passed, 0 failed, 0 flaky retries, 315s wall
(baseline: 583s wall, 13,564s subprocess CPU).
2026-07-29 13:10:23 -07:00
Brooklyn Nicholson fa8fb82d0e test(gateway): change-watcher behavior contracts; align status-snapshot test with 60s cadence
Real temp-HERMES_HOME tests for _broadcast_watched_changes: silent seed,
cron/sessions signature moves, the 2s sessions floor's trailing edge, the
pet signature staying 'off' without a renderable pet, meta payload on
pet.changed, and a broken probe never killing the pass. Part of #73618.
2026-07-28 17:21:58 -05:00
dsad 748c12b148 fix(session): skip display_kind timeline rows in undo/retry turn targets
list_recent_user_messages and the in-memory /retry + session.undo walkers
treated every role=user row as a real user turn. Timeline bookkeeping
(model_switch, async_delegation_complete, auto_continue, hidden) is stored
that way, so /undo soft-deleted from a marker and /retry re-sent opaque
bookkeeping text. Exclude display_kind the same way CLI resume counting and
the prompt.submit ordinal path do.
2026-07-28 19:37:14 +05:30
Brooklyn Nicholson dddfe6e4a3 test(gateway): expect display on skill-bundle slash payloads
command.dispatch / slash.exec now project a display invocation for
bundle sends; update the protocol assertions to match.
2026-07-28 04:12:48 -05:00
Brooklyn Nicholson 7ab5567953 fix(gateway): read an untyped recovery note as a timeline event
Pass the display type into the turn so the row is born typed, and
recognize the note's fixed prefix in _history_to_messages so the
untyped rows already on disk stop painting as user bubbles.
2026-07-28 03:54:21 -05:00
Brooklyn Nicholson 60b6ea237f feat(gateway): group unplaced sessions into a Home bucket in the project tree
Sessions with no cwd — or whose folder can't be promoted to a project (the
bare home dir, a deleted workspace, HERMES state) — were dropped from the
project tree entirely, so the grouped sidebar silently showed fewer chats
than flat Recents. Collect them into a synthetic `__no_project__` node at
the head of the list. It carries one lane purely to hold the rows, and is
omitted when empty so a project-less install stays blank.
2026-07-27 16:04:57 -05:00
brein 8b11249755 fix(tui-gateway): guard entry signal installs to main thread
Importing tui_gateway.entry from a worker thread raised
'ValueError: signal only works in main thread of the main interpreter'
because signal.signal() was called unconditionally at import time.

On the Desktop/WebSocket path, server._build() runs in a daemon thread and
does 'from tui_gateway.entry import ensure_mcp_discovery_started' as the
first import of entry (entry.main() is never run there), which crashed and
aborted MCP discovery startup — every session then lost its MCP servers
(e.g. Dart-mcp) with ClosedResourceError.

signal handlers are process-global, so installing them only when the module
is first imported in the main thread is sufficient; importing from a worker
thread becomes a safe no-op.

Fixes #72667
2026-07-28 00:05:36 +05:30
alelpoan 820a8083d4
fix(desktop): Branch in new chat drops the question and loses the branched session on restart (#71960)
* fix: Branch in new chat loses the question and the branched session (#issue)

- session.branch on the backend now accepts a count param to truncate
  the parent history to the clicked message, instead of always forking
  the entire transcript. Also returns stored_session_id/messages/info
  so the frontend has parity with session.create's response shape.
- branchCurrentSession (open live chat) now slices history from 0
  instead of from the clicked message index, so the question preceding
  an assistant reply is no longer dropped when branching.
- forkBranch now calls session.branch (not session.create) when
  branching an open live chat, since session.create only persists a DB
  row lazily on first prompt - a branched chat that nobody types into
  never got saved, and vanished as 'session not found' on the next
  app restart. branchStoredSession (branching from the sidebar, no
  live runtime) keeps using session.create as before.

* test: cover session.branch count truncation and open-chat branching

- backend: assert session.branch with a count param only persists the
  first N messages of the live history to the new session.
- frontend: BranchHarness now exposes branchCurrentSession; assert
  branching an open chat from a middle message calls session.branch
  with the parent session id and the correct trimmed count, instead of
  session.create.
2026-07-27 10:45:00 -04:00
teknium1 a0bc7d5572 fix(kanban): snap notify-sub cursor to current MAX(task_events.id) at creation
Fixes the boot-storm half of issue #29905: kanban_notify_subs.last_event_id
defaulted to 0, so a subscription created on an already-active task replayed
the task's ENTIRE terminal-event backlog on the next notifier tick. With
many stale subs (27 observed in the report) a gateway boot after downtime
burst 100+ notifications in one go.

add_notify_sub now snaps the cursor to COALESCE(MAX(task_events.id), 0) for
the task inside the same INSERT, so new subscriptions start caught up and
only receive events that occur AFTER subscribing. The gateway slash-command
and kanban-tool auto-subscribe paths run at task creation, where the
snapshot is just the 'created' event — behavior there is unchanged.

Stale fixtures that asserted the literal 0 creation cursor now assert
'cursor unchanged/unclaimed' instead, which is what they actually meant.
2026-07-26 16:14:15 -07:00
Siddharth Balyan 59529afee0
feat(billing): carry the payment-method union through to clients (#71542)
* feat(billing): add payment_method union to the billing-state wire type

* feat(billing): carry the payment-method union through the gateway

NAS now sends a typed `paymentMethod` union on /api/billing/state alongside
the legacy `card` field. The gateway parses payloads field-by-field, so the
new field was dropped on the floor before reaching TUI/Desktop.

Parse it into PaymentMethodInfo and re-emit it as snake_case `payment_method`,
matching the translation the rest of this payload already does. The payment
method id is deliberately not carried through — clients have no use for it.

No client rendering changes: `card` stays populated for cards, so every
existing consumer behaves exactly as before and the new field is inert until
a surface opts into reading it.

* fix(billing): send only the fields each payment-method kind declares

The serializer emitted every key for every kind, so a Link method went out
carrying brand, last4 and wallet set to null. That contradicts the shared
type, where each kind declares its own fields: a client testing `'brand' in
pm` would read every Link method as a card, and one trusting the declared
non-null `brand` could crash on it.

Send each kind's own fields, and forward an unrecognized kind by name alone
so a client that predates it can still say something honest. The shared type
gains the matching fallback arm its own comment already promised.

Tests now follow a payload from the server response through to the client
wire for each kind, rather than checking parsing and serializing separately —
which is why the old expectation locked in the wrong shape without noticing.

* fix(billing): keep the payment-method kind narrowable

Typing the fallback arm's kind as `string & {}` borrowed a trick that only
works on unions of plain strings. On a union of objects it makes the
discriminant non-literal, so TypeScript stops narrowing on every arm — even
`if (pm.kind === 'card')` no longer gives you `brand`. The first client to
use this would have hit a compile error and reached for a cast.

An unrecognised kind now arrives as `unknown`, carrying the real name
alongside it, so every arm has a literal discriminant. A type-level test
pins this: it fails to compile if the discriminant stops narrowing.

The parser settles which kind it is, the way the card parser already does,
so the record cannot hold fields that do not belong to its kind and the
serializer no longer re-checks. The type comment also stops claiming `card`
is a safe signal — it is null for Link, so `!card` does not mean "nothing on
file".
2026-07-27 01:49:43 +05:30
teknium1 588b7059a8 test(tui): prove kanban poller reads the shared board under a profile override
The sweeper review on #66435 flagged that the collector doesn't bind
session["profile_home"]. That binding is intentionally unnecessary: the
kanban board is shared across profiles by design — kanban_home() anchors
on get_default_hermes_root(), which resolves the process env and ignores
context-local profile overrides (see the kanban_db.py module docstring).
Add a regression test that claims a subscription while a foreign-profile
set_hermes_home_override() is active, proving delivery still works for
non-launch-profile Desktop sessions.
2026-07-26 12:54:51 -07:00
falkoro 6247712c3f test(tui): drive a real subscription through _notification_poller_loop
Covers the poller wiring above _collect_kanban_notifications, per the
hermes-sweeper review: status.update emission, agent-turn dispatch via
_run_prompt_submit when the session is idle, and the busy-session
pending buffer that flushes once the session goes idle.
2026-07-26 12:54:51 -07:00
falkoro badb240ffa fix(tui): deliver kanban notify subscriptions to TUI/desktop sessions
kanban_create auto-subscribes TUI/desktop sessions with platform="tui" and
chat_id=HERMES_SESSION_KEY, and tools/kanban_tools.py documents that the
TUI notification poller (tui_gateway/server.py) reads kanban_notify_subs
and posts completion messages into the running session — but that reader
was never implemented. The poller only watched process_registry completion
events, and the gateway notifier skips "tui" rows because no such
messaging adapter exists. Result: subscriptions accumulate with
last_event_id=0 forever and no task event is ever delivered (18 subs,
29 terminal events, 0 deliveries in the report).

Implement the missing delivery path in the TUI notification poller:

- every 5s, claim unseen terminal events for this session's
  platform="tui" subscriptions via claim_unseen_events_for_sub — the
  same atomic cursor-claim the gateway notifier uses, so an event is
  delivered exactly once even with a gateway polling the same board DB
- format events with the same wording as the gateway notifier
  (done/blocked/gave up/crashed/timed out/status; archived and
  unblocked are claimed but silent, so they can't wedge the cursor)
- emit a status.update for user visibility, then chain an agent turn
  when the session is idle — mirroring process-completion handling;
  claimed events buffer in the session until it goes idle since the
  cursor (unlike the process queue) cannot re-queue
- unsubscribe only at a truly final task status (done/archived),
  matching the gateway rule so respawned tasks keep notifying
- multi-board: iterate boards, polling each resolved DB path once

Fixes #59890
2026-07-26 12:54:51 -07:00
Brooklyn Nicholson 0158569ee7 fix(gateway): follow a session into the worktree it settled in
An agent told to work in a fresh git worktree does exactly that — creates
it, cds in, and runs every later command there — but the session stayed
pinned to the checkout it started in. The desktop kept labelling the chat
with the primary branch while all the work landed somewhere else.

The desktop half already existed: session.info carrying a moved cwd runs
followActiveSessionCwd, which refreshes the project tree and scopes the
sidebar into the new project. The backend just never reported the move.

Reconcile the session's cwd against terminal_tool's per-session record at
the end of a turn, when the agent has stopped moving and its recorded cwd
is a stable answer. A plain cd stays what it always was — not a workspace
move — so the reconcile only fires when the recorded cwd sits in a
different git working tree than the session's workspace.
2026-07-26 03:59:46 -05:00
brooklyn! 6deb92df52
Merge pull request #71800 from NousResearch/bb/project-sort
fix(desktop): sort projects by real activity and record terminal session cwd
2026-07-26 02:06:00 -05:00
Brooklyn Nicholson 3172b8739e fix(gateway): record a terminal session's working directory
Sessions started from the terminal were stored with no `cwd` and no
`git_repo_root`, so the sidebar had nothing to group them by and they never
appeared under their project — they fell into "No workspace" instead. On one
real profile this covered 690 of 1063 TUI sessions.

The row write persisted a cwd only when `explicit_cwd` was set, which happens
only if the user switches directory mid-session. The intent was to avoid
filing chats under whatever folder the app launched in, and that reasoning
holds for the desktop, whose launch directory is an artifact of how the bundle
was opened. It does not hold for a terminal session: the user cd'd into that
directory before running hermes, and it is where the agent's terminal runs.

Split the two cases behind one helper. An explicit pick is always persisted;
otherwise the launch directory is recorded for terminal-started sessions and
left unset for the desktop, preserving the existing "No workspace" default
there.

Existing rows are unaffected: they carry neither cwd nor git_repo_root, so
there is nothing to recover them from.
2026-07-26 01:20:35 -05:00