Commit Graph

27219 Commits

Author SHA1 Message Date
Teknium 4afbecb429 test(cli): trim fast-serve coverage to the parity + dispatch invariants 2026-09-02 06:06:47 -07:00
Gille 5180601a6a perf(cli): dispatch serve without the full parser tree 2026-09-02 06:06:47 -07:00
Teknium e9dd0bf5d5 feat(desktop): polish bot roster sections — dialog rename, Undo delete, Esc-cancel drag, nested under gateways (salvage #100745)
Follow-up on @fortun8te's user-made roster sections:

- Sections start empty: no seeded General/Workforce/Clients. With no
  sections created the roster renders exactly as before.
- New section and Rename go through one Dialog + Input + Cancel/Save
  (the app's session-rename shape) instead of an inline caret; the row
  menu's "New section…" files the bot as it creates.
- Delete needs no confirmation: bots return to Unassigned and the toast
  offers Undo (restores the section in its slot and refiles its bots).
- Drag: single-row drag under a private MIME type, every valid target
  shows a faint outline while a drag is live, the hovered target lights
  up, the source section refuses the drop, Escape cancels, and the moved
  row no longer stays faded after it remounts under its new section.
- Multi-select (cmd/shift-click, querySelectorAll shift-range) dropped:
  the roster has no selection model. Per-bot saveBotMeta writes run in
  sequence, one per profile (membership IS a field on each profile).
- Section heading reuses RosterSectionHeader (gains `action` /
  `onDoubleClick`), so user sections fold and look like the gateway
  headings; ⋯ menu and right-click drive the same Rename / Move up /
  Move down / Delete. Empty sections show a dashed "Drag bots here" slot.
- Composes with gateway buckets: sections nest INSIDE each connection
  bucket, indented under a hairline rail (membership lives in the bot's
  profile on that gateway); empty sections repeat there only mid-drag.
- Full i18n parity (en / ja / zh / zh-hant) for every new string; icon
  toggle and the storage-async plumbing removed.
- Tests trimmed to the three invariants (membership persists through
  saveBotMeta + reload, remainder = Unassigned, delete returns bots +
  undo) plus a live Electron e2e covering the whole flow.
- Docs: "Organize bots into sections" in user-guide/bot-mode.md.
2026-09-02 06:06:32 -07:00
Michael Knaap bd9955d529 fix(desktop): section rename — Escape cancels, Enter commits once
Closing the rename field unmounts the input, and the unmount can still
fire onBlur, which committed the draft the user had just asked to throw
away with Escape. Enter also called commit() directly and then again from
blur. Route both through blur with a cancelled flag so the commit runs
exactly once and Escape never renames.
2026-09-02 06:06:32 -07:00
Michael Knaap 3d0ac691af feat(desktop): user-made sections in the bot roster, with drag-and-drop filing
The roster already has sections, but only automatic ones: one per gateway
connection plus the group-chat bucket. Those answer "where does this bot
run", which is not the question being asked when someone wants two client
bots filed together under "Clients" and the internal ones under "Team".

This adds a second axis that composes with the first: gateway sections keep
the top level whenever more than one connection is showing, and user
sections group the flat list underneath.

Design choices, each deliberate:

- Membership lives on the BOT (`ui_meta.sectionId`), not as a member list on
  the section. A bot can only be in one place, deleting a section cannot
  orphan anybody, and the assignment rides the same profile.yaml sync every
  other bot setting already uses, so it follows the profile to another
  machine. Section records (id, name, icon) live in plugin storage.
- "Unassigned" is not a section. It is whatever is left, always drawn last,
  and it is where members of a deleted section land. No record, so nothing
  to keep in sync.
- Three gestures, one rule: drag a row onto a section heading; cmd/ctrl-click
  and shift-click build a multi-selection (shift ranges in DOCUMENT order,
  anchored Finder-style); and the row's context menu gets "Move to section…"
  with the same targets the drag would use. Dragging a row that is part of
  the selection drags the whole selection.
- The drag uses a private MIME type, so a bot dropped on the composer or the
  transcript is simply not a valid payload there instead of pasting its key
  as text.
- Section headings rename inline (double-click / menu), reorder, hide their
  glyph, and delete (keeping their bots). Right-click and the ⋯ button open
  the same menu so neither can drift.

With no sections created the roster renders exactly as before.

Tests: user-sections.test.ts covers the pure model (normalisation, grouping
with unknown/deleted sections falling to Unassigned, drag payload
round-trip). The existing hermes-bots suite passes; tsc and eslint clean.
2026-09-02 06:06:32 -07:00
Teknium bb7838c57c chore: map contributor crdesign8@hotmail.com -> @crdesign8 2026-09-02 05:59:24 -07:00
Teknium 458e2ef1d2 refactor(state): collapse telegram topic v3 migration into one table-driven rebuild
Same behavior as the salvaged #76487 migration (fresh installs get the v3
shape; v1/v2 tables rebuild with profile_name leading the PK, legacy rows
into 'default' only, CASCADE FK supplied on the way), with the per-table
DDL written once instead of three times and the now-redundant v1->v2
CASCADE-only rebuild dropped (the v3 rebuild subsumes it).

Co-authored-by: Celio Monteiro <crdesign8@hotmail.com>
2026-09-02 05:59:24 -07:00
Celio Monteiro d55d9d128a fix(gateway): route profile into topic prune, cooldowns, and docs
Address hermes-sweeper review on #76487:

- Prefer hermes_profile from send metadata when pruning stale topic
  bindings so profile_routes cannot delete the transport adapter's
  namespace instead of the routed runtime's
- Namespace lobby/capability cooldowns and /topic off cleanup by
  (profile, chat_id)
- Document profile_name PKs and scoped cleanup SQL in telegram.md
- Regression: primary-adapter stamp + routed metadata prune isolation
2026-09-02 05:59:24 -07:00
Celio Monteiro 62be7043ff fix(gateway): pass routed source.profile into telegram topic state
Issue #76423 follow-up: wire SessionDB profile_name through gateway paths.

- Resolve profile from source.profile (never process-global active profile)
- Stamp adapter._hermes_profile_name for prune under multiplex
- /topic enable/status and binding record/recover/disable/restore paths
2026-09-02 05:59:24 -07:00
Celio Monteiro 351e4c0067 fix(state): namespace telegram topic tables by profile_name
Issue #76423: under multiplex_profiles a shared state.db keyed topic mode
and bindings only by Telegram chat_id/thread_id, so private-chat ids
collided across bots/profiles.

- Add profile_name to telegram_dm_topic_mode and telegram_dm_topic_bindings
- Schema v2→v3 rebuild; legacy rows migrate into the "default" namespace
- Keyword-only profile_name="default" on SessionDB topic APIs (compat)
2026-09-02 05:59:24 -07:00
Teknium 648c664eb3 feat(desktop): gate cold-start restore on display.resume_last_session
- use-desktop-integrations: hold the restore latch until the config
  record answers; when false, stay on the fresh chat (route and session
  restore alike) while still remembering the open chat for next launch.
- wiring: read the shared config-record query; undefined while pending,
  fetch failure falls back to the historical behavior (resume).
- appearance-settings: ToggleRow writing through the shared config
  cache with rollback + notifyError on a failed save.
- ar/ru strings, docs line in user-guide/desktop.md, two hook tests.
2026-09-02 05:56:54 -07:00
jinglun010 7aff724e56 feat(desktop): add display.resume_last_session config toggle (#60812)
Config default (true) plus the Appearance-settings strings for a
"Reopen Last Chat on Launch" switch. Salvaged from PR #60816 onto
current main (defaults moved to config_defaults.py since the PR).
2026-09-02 05:56:54 -07:00
Teknium 0437fe66f7 fix(discord): native slash commands honor guild/channel profile_routes
_build_slash_event and _dispatch_thread_session built their SessionSource
without guild_id/parent_chat_id, while on_message passes both. Route
matching in build_source keys off exactly those fields, so under
gateway.multiplex_profiles a guild- or channel-routed profile never matched
a native slash command: /new, /reset, /model, /profile, /status ... all ran
against the default profile and reset the wrong session (#69178, #91633).

Pass guild_id (interaction.guild_id, falling back to channel.guild like the
message path) and the thread's parent channel id into build_source at both
sites. One test pins channel + thread routing parity with messages.

Fixes #69178
Fixes #91633
Co-authored-by: Sora-bluesky <179361977+Sora-bluesky@users.noreply.github.com>
Co-authored-by: jondgilbert <42873618+jondgilbert@users.noreply.github.com>
Co-authored-by: tensorbit89-netizen <257030052+tensorbit89-netizen@users.noreply.github.com>
2026-09-02 05:55:36 -07:00
Teknium 17f86ab36f fix(gateway): enter per-message profile scope off the event loop too
Same class as the handoff watcher (#100014): the secondary/primary message
handlers and the scoped inbound-preprocess hop entered
_profile_runtime_scope synchronously on the loop thread, so a slow
profile .env read or external secret-source hydration stalled every other
adapter's traffic. Route those three async sites through
_async_profile_runtime_scope (asyncio.to_thread load, then the existing
sync scope with prepared_secret_scope=). _format_session_info_scoped is
already called via asyncio.to_thread and stays sync.
2026-09-02 05:55:36 -07:00
fangliquanflq f069ffd471 fix(gateway): offload handoff secret scope loading
The handoff watcher entered _profile_runtime_scope synchronously on the
event loop each tick; hydrate_profile_secret_sources + build_profile_secret_scope
do blocking file/secret-source IO, so a slow profile secret read stalled every
adapter (#100014). Load the secret scope via asyncio.to_thread, then enter
the existing sync scope with prepared_secret_scope=.

Fixes #100014
Co-authored-by: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com>
2026-09-02 05:55:36 -07:00
Adolanium 55e1698979 fix(gateway): a secondary profile handoff fails closed when its config cannot load
_process_handoff caught a config load failure for a secondary profile,
logged a warning, and kept going with self.config, which is the primary
profile's config. The handoff then went out through the right bot to the
primary's home channel and the row was reported completed. That is the
exact wrong delivery the multi-profile handoff work exists to prevent,
and the same fail closed posture the no-live-adapters branch already
takes.

A load failure now logs an error and raises, which marks the row failed
so the CLI can report and retry it. The default profile path is
untouched, it never reloaded config.
2026-09-02 05:55:36 -07:00
kshitijk4poor e9fa7bc05e fix(state): keep the #94736 teardown self-heal alive under the deleted-WAL guard
Follow-ups on the salvaged #101081 guard:

- A clean close() lets SQLite unlink the WAL sidecars legitimately; the
  guard treated that as a lost generation and permanently halted the
  handle, so the #94736 late-write self-heal reopen dropped transcript
  tails (4 existing tests failed). close() now clears the recorded
  sidecar generation, and _wal_generation_was_lost() re-adopts the
  current sidecars after a clean /proc/self probe instead of relying on
  a stale snapshot.
- Healthy writes no longer walk /proc/self/fd: once a sidecar
  generation is recorded, the stat-based inode check alone detects an
  unlink/replace. The fd probe only runs in the empty-identity state
  (fresh DB, post-close reopen).
- DeletedWalGenerationError now subclasses StateDbReplacedError, so the
  gateway retry queue and run_agent flush divert transcripts to the
  JSONL fallback exactly as they do for a replaced store, instead of
  retrying forever against a halted handle.
- __init__ refuses once (under the startup lock) instead of twice per
  open, halving the system-wide /proc scan; dropped the dead
  include_self parameter and the dead _IS_WINDOWS clause.
- Test fixes: rstrip(' (deleted)') char-set bug -> removesuffix; the
  non-linux test now patches sys.platform (the real gate) instead of
  _IS_WINDOWS.
2026-09-02 18:24:54 +05:30
Cursor Agent 7f7df1ce44 fix(state): refuse SessionDB open and writes on a deleted WAL generation
A live writer can keep a deleted state.db-wal inode while a second opener
mints a fresh WAL at the same path. Fail closed on writable open (before
connect) and on the write-path sidecar identity check so the second
generation is never created.

Co-authored-by: Noa <rainbowgore@users.noreply.github.com>
2026-09-02 18:24:54 +05:30
liguoyu 1592e48ac9 fix(desktop): a split-dragged Bot tab keeps its Bot workspace scope
Dragging a session tab to split in the Bot workspace committed through
openSessionTile with no scope, whose default { workspaceMode: 'sessions' }
was written onto the already-open tile before the pane move — so the tile
re-bucketed into the Sessions workspace and vanished from the Bot strip.

A scope-less open of an already-open tile is a MOVE, not a re-scope:
default the scope to the tile's current workspaceMode and only rewrite
scope when the caller passed one explicitly (sidebar/bot openers still
win). Brand-new tiles keep the 'sessions' default.

Closes #96865
Supersedes #96998

Co-authored-by: Teknium <127238744+teknium1@users.noreply.github.com>
2026-09-02 05:54:10 -07:00
Teknium d1f8c2ea11 test(desktop): group chat picker row label opts out of min-width:auto so names truncate
Renders CreateGroupChatDialog with a long-named bot and asserts the row
label carries min-w-0 (and the inner text column keeps min-w-0 flex-1 +
truncate). Sabotage-verified: fails against the pre-#90624 markup with
'expected [...] to include min-w-0'.
2026-09-02 05:42:14 -07:00
Jay c65c79a4a8 fix(bot-mode): group chat picker rows overflow and scroll names out of view
The New Group Chat picker's rows are `label` flex containers holding a
`min-w-0 flex-1` text column whose two lines are `truncate`. The label
itself has no `min-w-0`, so as a flex item it keeps its `auto` minimum
width and cannot shrink below its content. `truncate` therefore never
fires: the row grows to fit the longest secondary line instead, which is
`@handle · in "Group A", "Group B", …` and so scales with how many groups
a bot already belongs to.

The row is a grid item inside a Radix ScrollArea, whose viewport wraps
children in a `display: table` div that sizes to content, so the whole
list widens rather than clipping.

Measured on a 414px viewport with one bot in three groups:

  wrapper width  593  (viewport 414)
  widest row     585
  overflows      yes

Nothing is visibly wrong until the first click. The checkbox now sits
past the right edge, so focusing it scrolls it into view: `scrollLeft`
jumps 0 → 178.38 (= 593 − 414) and every row shifts to `left: -162px`,
clipping the bot names from the left — the user clicks a name and the
names disappear. The scroll offset persists after unchecking, until the
dialog is remounted.

Adding `min-w-0` to the label lets it shrink, so `truncate` engages as
the markup already intended. Same viewport, same data:

  wrapper width  414  (unchanged display: table)
  widest row     406
  overflows      no
  scrollLeft after clicking a row  0 → 0

`display: table` on the ScrollArea wrapper is untouched; the fix works
with it rather than around it. Verified against a packaged build via CDP,
before and after, on identical roster data.

No test. The rule for this is "extract the logic into a small
pure/DI-testable function and call it for real", but there is no logic
here — `min-w-0` is a class name, and the behaviour under test belongs to
the layout engine. jsdom does not lay out, so a unit test cannot observe
the overflow; the Playwright suite could, but has no bots-roster fixture,
which is a large scaffold to hang off a one-class change. A source-regex
assertion would pass without ever laying anything out, which is precisely
the false confidence AGENTS.md describes. The before/after measurements
above are offered as the evidence instead — happy to add a Playwright
case if you'd rather have the fixture.
2026-09-02 05:42:14 -07:00
Teknium 8d6a286fe8 fix(desktop): restored background tabs resolve their session title without a click
A restored session tile has no runtimeId and never mounts its pane until first
activation, so the by-id resolution effect inside SessionTilePane never runs.
When the row is also outside the recents page and project tree, tileTitle()
falls back to "New session" until the user clicks the tab (#94167).

Add a one-shot backfill, wired next to watchSessionTiles(): once the gateway
is open, look each unrestored, untitled, unlisted tile up via
resolveStoredSession(id, tile.ownerRoute). That call already upserts the row
into $sessions, which the tab strip watches, so the tab renames itself —
nothing new is persisted and workspaceTabTitle stays the Bot Chat marker.

Live repro (Electron e2e, target session pushed off the 50-row recents page
by 60 newer sessions, restored as a stacked background tab): main showed
"New session" after boot with no click; with this fix the tab reads the real
title while the pane is still unmounted.

Closes #94167
Supersedes #94212

Co-authored-by: 686f6c61 <github@00b.tech>
2026-09-02 05:42:00 -07:00
Teknium ad800ea8cd fix(desktop): cold resume paints the prefetched REST transcript before session.resume settles
The REST prefetch and the gateway `session.resume` already ran concurrently,
but the prefetch result was held until the runtime resume settled. A cold
profile build (skills / MCP / memory) can keep `session.resume` pending past
the hydration budget while the complete transcript is already in hand, so a
Bot Chat sat on the loader and burned its retries with readable history
off screen.

- Publish the grafted REST snapshot as soon as the prefetch resolves and
  `isCurrentResume()` holds; the runtime path grafts only its live projection
  onto that same snapshot, and the post-resume `chatMessageArraysEquivalent`
  skip keeps reference identity when nothing changed (no second DOM build).
- Stamp the eagerly painted page with persisted-display provenance on the
  runtime state so the warm-path gate admits it on the next switch.
- REST fallback after a resume rejection skips the redundant re-publish when
  the early paint already shows the transcript.

Live repro (Electron e2e, session.resume stalled 25s via a temporary
backend shim): main never painted within 20s; with this fix the transcript
painted 150ms after the row click.

Supersedes #90130.

Co-authored-by: Alexandre Roumieu <269586168+alexandreroumieu-codeapprentice@users.noreply.github.com>
2026-09-02 05:41:47 -07:00
Teknium 000d6efd09 test: widen _find_live_session_by_key monkeypatch for the optional profile_home arg 2026-09-02 05:40:49 -07:00
Teknium 54b2c0ea38 fix(tui_gateway): scope live-session reuse to the requesting profile
`_find_live_session_by_key` matched live runtimes by bare stored session id.
Stored ids are timestamp-based and can exist in more than one profile's
store, so `session.resume` for profile B (fast path, post-build re-check, and
`_claim_or_reuse_live`) could hand back profile A's live runtime — the turn
then ran with A's persona/tools and wrote A's memory (#100029).

Give the lookup an optional `profile_home` (default: any profile, unchanged
for callers that have no profile to scope by) using the same string compare
`_find_live_unpersisted` already uses, and pass the resolved home at every
resume/claim site. `_claim_parked_runtimes` gets the same scope so a resume
under B never finalizes A's parked runtime of the same id.

Reimplements the profile-scope half of #100213 by @Finn763; the Group-title
capability-sync change from that PR is intentionally not carried.

Co-authored-by: Finn763 <165816600+finn763@users.noreply.github.com>
2026-09-02 05:40:49 -07:00
Teknium 1bf2cf57ef fix(desktop): project tree follows sessions.changed so external session create/delete/rename/cwd changes show up (#100354) 2026-09-02 05:40:18 -07:00
Teknium 89e2e4f572 docs(sessions): explain why the canonical Bot Chat guard is provenance-blind (#99517)
Follow-up to the salvaged #99560 commit: restore the original docstring
wording (user writes still always land everywhere else), name the
llm-outranks-derived hole the guard now closes, and annotate the two new
tests with what each pins.
2026-09-02 05:39:24 -07:00
fangliquanflq fb9a294794 fix(sessions): protect canonical Bot Chat from auto-title 2026-09-02 05:39:24 -07:00
Teknium 209de12d5f fix(desktop): Bot Mode tabs caption a Bot Chat with the bot's name, not "Bot Chat"
Every bot's canonical chat is stored under the same title ("Bot Chat" — the
name the gateway resolves it by, and an invariant roster-actions.ts's stale-tile
probe and #90102 rely on), so the main tab strip captioned every open bot chat
identically and two bots' tabs were indistinguishable (#99152).

Fix at the presentation layer, leaving the stored title and tabTitle untouched:
- workspace-scope.ts gains `$workspaceOwnerLabels` + `workspaceOwnerTitle()`:
  a bots-mode tab whose resolved title still equals its registered placeholder
  reads its owner's label instead. Side threads / Sessions tabs are untouched.
- session-tile.tsx captions tiles through it (and the drag payload); the main
  `workspace` tab (controller.tsx) does the same via `$botChatScopes`, the
  bot-mode scope the main tab was last opened under (it has no tile).
- The hermes-bots roster publishes displayName() per owner key through the new
  `host.setWorkspaceOwnerLabel` (feature-detected), so renames follow.

Supersedes #99177, which set tabTitle at open time — that reverts after mount
because tileTitle() prefers the stored row's title once the hidden row is
upserted, and breaks the `workspaceTabTitle === 'Bot Chat'` invariant.

Tests: one unit test on workspaceOwnerTitle() (bot chat → bot name; side
thread / sessions tab / unlabeled owner untouched) and one Electron e2e
(tab strip reads "Alpha", not "Bot Chat"); both fail on main, pass here.

Closes #99152
Supersedes #99177

Co-authored-by: twotnguyen <nguyenngoctinh011258@gmail.com>
2026-09-02 05:38:10 -07:00
Teknium 7b951e46ab fix(desktop): route a shared-id unpin to the row the pull adopted
With every slice feeding the pin sync, two profiles can legitimately hold
the same session id. The pull already tie-breaks toward the active gateway's
row (rowsByPinId), but the push resolved the profile by first match — so an
unpin PATCHed the other profile and the next page re-adopted the pin.
Resolve the write's row with the same active-gateway preference.

Case and test from #92609.

Co-authored-by: Jake Vincent <45184202+jakewvincent@users.noreply.github.com>
2026-09-02 05:37:58 -07:00
fangliquanflq d417aee387 fix(desktop): sync pins across all session slices 2026-09-02 05:37:58 -07:00
Teknium 3292fbcc97 chore: map praxis1244-consulting contributor email 2026-09-02 05:37:05 -07:00
Teknium 9b84a98e29 test: trim salvaged #99763 to the two invariant-pinning tests
Drop the no-field fallback vitest case and the get_compression_chain
unit test: the fallback is implied by the predicate (Boolean(undefined))
and the chain walk is covered by test_list_serves_full_lineage_ids_for_projected_rows
through the real list projection.
2026-09-02 05:37:05 -07:00
Alonso 202997b51d fix(desktop): one conversation never opens as two tabs after compaction
Compression rotates a conversation's tip id while tiles stay keyed by
whichever segment id they were opened with. focusOpenSession and
openSessionTile tested exact ids, so right after a rotation the same
chat read as 'not open' and opened again in a second tab — and a tile
keyed to a MIDDLE segment (the tip when it was opened) could no longer
prove it names the conversation at all, rendering as an untitled ghost.

The projected list row now carries the full chain
(SessionDB.get_compression_chain, served as _lineage_ids by
list_sessions_rich and the sidebar tree row), lineageAliases indexes
every segment, sessionMatchesStoredId accepts membership, and the tab
focus/open paths dedupe through the lineage instead of the exact id.
Older gateways omit the field and degrade to today's root/tip pairing.
2026-09-02 05:37:05 -07:00
Gille 2599793271 fix(bot-mode): hand off group tabs to remote bots 2026-09-02 05:36:54 -07:00
chelsealong 87d5e40f53 fix(desktop): fail closed when Bot Chat lookup returns zero rows
session.list can succeed with an empty sessions array during a profile
backend restart instead of throwing, and findExistingCanonicalChat's
`rows.find(...) || null` mapped that to the same value as "this bot
never had a chat". The click path then minted a replacement Bot Chat
and re-fired the kickoff intro on an intact, hidden canonical row,
orphaning in-progress work each time (#98383).

When the roster's own canonical_session already confirms this profile
has a Bot Chat, treat a zero-row result as unconfirmed absence and
fail closed the same way a thrown RPC error already does, instead of
minting.
2026-09-02 05:36:54 -07:00
Teknium 6d061ede58 fix(desktop): open transcript catches up on every reconnect
Closes #94779. A turn that finished while the gateway socket was down never
replays its sessions.changed tick, so the open transcript stayed stale
until the user reopened the session. The gateway-open effect now also
requests one signature-gated tail of the active transcript on every
(re)connect (connection-scoped, so a plain session switch adds no read;
messaging transcripts already refresh on open via their own effect).

Reported-by: Kkkkkuro
2026-09-02 05:36:41 -07:00
Teknium a0e4ce252c fix(desktop): open cron-run transcripts stay live
Reimplements PR #89728 on top of the ownerRoute rework. The active
transcript resolver only looked in $sessions and $messagingSessions, so an
open cron-run transcript resolved to nothing and every sessions.changed
tick was dropped — the pane froze until the user reopened it. Resolve
through ownerLookupSessionRows() (recents + cron + messaging) instead.

Co-authored-by: fangliquanflq <fangliquan@qq.com>
2026-09-02 05:36:41 -07:00
Teknium a53286999b fix(desktop): bot tile transcripts read from their owner backend
Salvages the client half of PR #99333. A workspace tile pinned to an exact
owner (connection + target profile) was reconciled via a bare
getLatestSessionMessages(storedId) — the foreground profile's backend — so
a bot tile on another profile never saw its new turns (or saw the wrong
session's). reconcileTileTranscripts now derives a ProfileScope from
tile.ownerRoute and keys the per-tile signature by that route; route-less
tiles keep the legacy local read.

The tui_gateway/server.py `_sessions_sig` cross-profile scan from #99333 is
intentionally not taken: the desktop runs one backend per profile, each with
its own watcher, so scanning sibling profiles would misattribute ticks.

Co-authored-by: stods21 <stods21@users.noreply.github.com>
2026-09-02 05:36:41 -07:00
Dolverin 51953a302f fix(desktop): keep busy tile transcripts stable 2026-09-02 05:36:41 -07:00
Teknium 782dd635fe feat(tts): speech toggles warm/release plugin and command TTS providers
Extends the TTS lease from #100912 (4e3feb8bbb) beyond built-in local
engines: when the configured tts.provider is user-declared, acquiring
the first lease and releasing the last one now reach it, so a
self-hosted TTS server can preload its model when read-aloud / voice
conversation turns on and unload when it turns off (Discord request).

- agent/tts_provider.py: TTSProvider gains concrete no-op warm() /
  release() (not abstract — existing plugins are unaffected).
- tools/tts_tool.py: _signal_user_tts_provider() forwards the lease
  hook; plugin providers get warm()/release(), command providers run
  optional `warm_command` / `release_command` (config.yaml, under
  tts.providers.<name>) through the existing _run_command_tts helper
  on a daemon thread — best-effort, output discarded, failures at
  debug. warm_tts_provider() and release_tts_provider() call it.
- tests/tools/test_tts_lifecycle_leases.py: fake plugin provider and
  fake command provider observe warm/release through acquire/release
  lease (both fail on main with action == "noop").
- docs: features/tts.md — lease section, command-provider optional
  keys table, plugin optional hooks.
2026-09-02 05:35:14 -07:00
Teknium 552159d222 feat(cli,tui): collapse bell_on_clarify/approval into display.bell_on_prompt
One key covers every blocking prompt modal: clarify (single + batch),
dangerous-command approval (incl. computer_use), sudo password, and
secret capture. CLI gets a _ring_bell() helper shared with
bell_on_complete; TUI rings on clarify/approval/sudo/secret .request
events (isTTY-gated). 'hermes config' Bell summary shows both flags.
2026-09-02 05:34:35 -07:00
Turgut Kural 3082a34669 feat(cli,tui): add display.bell_on_approval + fix eslint error
- display.bell_on_approval (default false): same BEL mechanism as
  bell_on_complete, rings when a dangerous-command approval prompt
  opens (_approval_callback / approval.request event). Complements
  bell_on_clarify from the previous commit.
- fix(ui-tui): eslint curly error in useConfigSync.applyDisplay
  (if without braces) that failed the CI JS & TS checks job.
2026-09-02 05:34:35 -07:00
Turgut Kural ef6d3367a6 feat(cli,tui): add display.bell_on_clarify — terminal bell on clarify prompts
Same BEL mechanism as display.bell_on_complete (\a / \x07), gated by
display.bell_on_clarify (default false). CLI rings in _clarify_callback
and _clarify_callback_batch before _paint_now(); TUI rings on
clarify.request when bellOnClarify && stdout.isTTY. Docs in
cli-config.yaml.example and website/docs/user-guide/configuration.md.
2026-09-02 05:34:35 -07:00
Teknium 4a7f228e2c chore: map contributor email for muhifni (#99225 salvage) 2026-09-02 05:34:28 -07:00
Teknium a78bb93cad docs: terminal settings resolve per profile under multiplexing
Documents the per-profile terminal.* resolution and fail-closed refusal
behavior introduced by the terminal scope seam (#68559 class).
2026-09-02 05:34:28 -07:00
1052326311 ea9924b09c fix(container): honor config.yaml multiplex_profiles at boot
hermes_cli/container_boot.py resolved multiplexing from the
GATEWAY_MULTIPLEX_PROFILES env var only, while the gateway runtime
resolves env -> config.yaml -> default. A deployment enabling
multiplex_profiles via config.yaml alone therefore auto-started every
named profile's gateway slot at boot, which then crash-looped in the
double-bind guard against the multiplexing default gateway.

Resolve through load_gateway_config().multiplex_profiles (the shared
resolver, so env override precedence is preserved) and fall back to the
env var only when config loading fails.

Salvage of #85437 (test module trimmed to config-only + env-override).

Fixes #85413
2026-09-02 05:34:28 -07:00
muhifni 1cd736ff63 fix(terminal): scope terminal config per turn under profile multiplexing
A multiplexed Hermes process (gateway.multiplex_profiles, unified
dashboard/TUI, or cron) serves several profiles at once, but terminal.*
resolved through process-global TERMINAL_* env vars bridged ONCE at
startup from the launch profile (gateway/run.py ~2700-2760) plus the
one-shot _ensure_terminal_env_bridged() guard. Every routed profile
therefore inherited the launch profile's backend, cwd, docker volumes,
SSH target and shared-container key: a local profile ran inside another
profile's docker sandbox (or a docker profile escaped to the host), and a
container labeled profile A carried profile B's RW bind mounts.

Fix: an authoritative per-profile terminal policy seam, mirroring
agent/secret_scope.py:

- tools/terminal_scope.py: ContextVar holding the routed profile's
  COMPLETE effective TERMINAL_* policy (defined defaults <- profile .env
  TERMINAL_* <- config.yaml terminal:). While bound, terminal_env()
  resolves ONLY from it - an omitted key yields the defined default,
  never os.environ. Unreadable/malformed policy installs a refusal
  scope; terminal_tool / execute_code refuse instead of running under
  ambient launch-process policy (fail closed).
- Installed at every in-process profile boundary: gateway
  _profile_runtime_scope, tui_gateway session/build/turn scopes, cron
  per-job fire. The unscoped single-process path is byte-identical.
- Every terminal.* consumer reads through the scope: terminal_tool
  (_get_env_config, _resolve_container_task_id shared key, orphan
  reaper lifetime, degraded mode), gateway/platforms/base.py docker
  media translation (volumes, shared key, persistence), runtime_cwd /
  agent_init / skill_utils / code_execution_tool / file_tools cwd
  anchors, prompt_builder / browser_tool / env_probe backend checks,
  gateway footer, @-refs and slash-command cwd. env_probe resolves the
  backend in the caller's context, since the probe worker thread does
  not inherit the ContextVar.

Salvage of #99225 onto current main: adds the three ambient reads the PR
missed (tools/file_tools.py TERMINAL_CWD, tools/browser_tool.py and
tools/env_probe.py TERMINAL_ENV; shape from #79117) and trims the test
module to the leak matrix driven through the real gateway boundary,
omitted-key defaults, refusal, and boundary reset.

Fixes #68559
Fixes #94200
Fixes #101132
Fixes #95470

Co-authored-by: x7peeps <9640837+x7peeps@users.noreply.github.com>
Co-authored-by: Eva <239388517+100yenadmin@users.noreply.github.com>
Co-authored-by: ExitMaster <292490062+ExitMaster@users.noreply.github.com>
2026-09-02 05:34:28 -07:00
Teknium d2085389d6 fix(slack): route SessionSource/history is_bot through _event_declares_bot_sender
The two sibling is_bot computations (SessionSource construction and the
thread-history fetch) re-derived bot-ness from bot_id/subtype only, so an
api_human_users post would be human at the drop gate but still flagged
is_bot downstream. Use the single predicate everywhere.
2026-09-02 05:34:11 -07:00
xoy8n 8be47c19bf fix(slack): treat allowlisted users' Web-API (user-token) posts as human
Posts made with a user token (xoxp-) arrive with app_id and no
client_msg_id, so _event_declares_bot_sender dropped them as app traffic;
the only workaround was allow_bots: all. Adds
platforms.slack.extra.api_human_users (SLACK_API_HUMAN_USERS fallback), a
users-only allowlist consulted inside the predicate.

Salvaged from #100964 (users only: an app-id allowlist would also admit
the app's own xoxb bot posts, which share the user+app_id shape).
2026-09-02 05:34:11 -07:00