Commit Graph

156 Commits

Author SHA1 Message Date
Teknium e6962b818c style: eslint --fix across src/ + electron/ (CI lints the full tree); drop unused destructure 2026-08-22 22:35:55 -07:00
Teknium 0404020f7b Merge PR #90006: connection-bound Bot Mode actions, reconciled with name-identity + fail-closed canonical resolution
Salvage of saralilyb's remote-bot routing work onto current main:
- kept: immutable (connectionId, profile) owner capture, requestForBot
  routing, backendTargetProfile aliasing, group session owners,
  connection-qualified deletion, focused-owner atoms, remote roster
  merge, Electron profile-delete routing, sdk/store/transcript changes
- reconciled: canonical Bot Chat resolution stays NAME-identity (the
  'Bot Chat' registry row) and FAIL-CLOSED on lookup errors — now
  consulted on the bot's own source via the captured owner route, so
  remote bots get the same no-fork guarantees
- dropped: pointer-pin plumbing (preferredSessionIds, saveBotMeta chat
  writes, pin verification) — superseded by name-identity on main;
  renderer-side remote DM delivery (deliverRemoteRosterMentions /
  pollRemoteDmReply / ensureRemoteCanonicalChat) — superseded by the
  message_agent tool architecture (#91802/#91915: middleware identifies,
  never delivers); pointer-era test files deleted on main
- openStoredBotChat/createCanonicalChat: remote opens keep Desktop's
  chrome home (keepAllProfilesScope: true on routed opens); local bots
  keep the measured workspace re-home
- prepareBotSource: capability gate only — routed RPCs never require
  activation authority
2026-08-22 22:24:54 -07:00
Teknium 87b645f52c fix(desktop): a failed Bot Chat registry lookup no longer forks the bot's forever chat
findExistingCanonicalChat() swallowed every lookup error and returned
null — indistinguishable from 'this bot has no Bot Chat yet' — so a
transient RPC failure against a just-restarted backend (the exact
post-desktop-update window) sent createCanonicalChat() straight to
session.create, minting a fresh 'Bot Chat' while the real one (data
intact, hidden) still held the canonical title. Users experienced this
as bots losing all context after every desktop update.

The lookup now fails CLOSED: a failed registry consultation throws,
both open paths surface their existing 'try again' toast, and
session.create can never fire off an unknown ownership state.

Tests: two new VM-executed regression tests (sabotage-verified — both
fail with the old fail-open catch); hide-bot-chats source-shape regex
updated for the new layout. 364/364 plugin tests green.
2026-08-22 21:24:30 -07:00
Mauvis Ledford e95dd466b1 fix(bot-mode): persist canonical chat before opening 2026-08-22 02:35:47 -07:00
Teknium a9860d413d fix(bot-mode): the canonical Bot Chat is found by NAME — session-id pins removed
A bot's forever-chat now has exactly one identity: the session titled
"Bot Chat" on that bot's profile. Core UNIQUE(title) makes (profile,
'Bot Chat') an exact registry, and every open consults it directly via
session.list {title, include_hidden}. The stored-id pin
(ui_meta['hermes-bots'].chat) and its entire verification apparatus —
preferred_session_ids resolution, drifted-pin keep branches, last_session
grandfathering, dead-pin recovery re-anchoring, newerVisibleBotChat — are
removed, not deprecated. Legacy ui_meta.chat keys are ignored and dropped
from merges on sight.

Every lost-canonical-chat incident (#88146, #88200, #90524, #90705, and
five hardening waves) traced to that pointer dangling or being stolen,
then later guards welding the wrong session in. A name cannot dangle:
corrupt pins self-heal on first click because the pointer is simply never
read.

Gateway: profiles.list now reports canonical_session per profile row
(registry row resolved server-side by title — hidden rows resolve,
deny-listed sources and archived rows do not, compression lineages
resolve to the live tip), replacing the preferred_session_ids request
contract. The roster preview, activity signals, and the /new→/compact
guard all read canonical_session, so preview identity and click identity
are the same row by construction.

No migration shims: this IS the system.
2026-08-22 01:23:39 -07:00
abitme ff88f27403 fix(bot-mode): a bot row opens the bot's canonical Bot Chat (#92042)
Partially reverts the newer-visible-session preference from #91791
(salvage of #91258), which made the pinned canonical Bot Chat
unreachable. Fixes #92040.

Canonical Bot Chats are ALWAYS hidden from the Sessions sidebar:
session.create passes hidden:true unconditionally and
hideOwnedBotSessions() sweeps any that were born visible (asserted in
tests/hide-bot-chats.test.mjs). The bot row is therefore the ONLY
entry point to a bot's forever-chat, so preferring the profile's
freshest visible session did not re-order two equivalent doors — it
removed the only one. Reported symptom: a 106-message bot-building
conversation with no reachable entry point anywhere in the UI, while
the row previewed one session and opened another (a regression of the
preview/click identity #88200 established).

The report behind #91791 was real but has a non-destructive answer:
scratch sessions started via "New chat with this agent" are not
plumbing-titled, so neither hideOwnedBotSessions() nor
sweepBotProfileSessions() hides them (the sweep matches the exact
titles 'Bot Chat' / 'Agent Inbox' / 'Group: …'). They stay listed in
the Sessions sidebar and are reachable there; they simply are not what
the bot row targets, which is by design.

Changes:

- openBotCanonicalChat: when the pin is alive and verified, open it
  directly. The newerVisibleBotChat preference is removed from that
  branch only; the helper stays for the dead-pin recovery path.
- Drop the now-unused latestVisible parameter and its argument at the
  BotRow call site. The second call site already passed three args.
- tests/bot-row-opens-latest.test.mjs ->
  tests/bot-row-opens-canonical-chat.test.mjs: the two tests that
  asserted the newer-session behaviour are rewritten rather than
  deleted, so the reasoning survives in the suite. Adds a source-level
  guard ("the healthy-pin branch never prefers a newer visible
  session") so this cannot silently regress. The deleted-newer-session
  fallback test covered a path that no longer exists; replaced with one
  asserting a failed open of a verified pin propagates instead of
  forking the forever-chat.

The keepAllProfilesScope: false half of #91791 is untouched.

Plugin suite: 392 pass, 0 fail.
2026-08-22 00:31:14 -07:00
Teknium 729782d058 feat(bot-mode): @mention middleware identifies, never delivers — the agent owns messaging
The composer middleware is now identification-only: it resolves the
user's @tags against the live roster and annotates the draft with who
they refer to (profile, friendly title, device for cross-connection
rows). The agent decides whether to contact them and does it through
its message_agent tool — one send path, composed messages only.

Deleted the renderer's entire parallel delivery transport:
deliverRemoteRosterMentions / pollRemoteDmReply /
ensureRemoteCanonicalChat and the injected shellout instructions
('[@mention handoff — run hermes -p …]' and 'Desktop is delivering …
over Connections'). This retires the whole invocation bug class at the
source instead of sanitizing it: no verbatim user text is ever
forwarded by the renderer (#91397), and no shell command is ever
composed from prompt text (#91304, #91339 shape).

Tests: mention-identification.test.mjs replaces the two delivery-era
files — identification note shape, no-shellout/no-delivery containment
(sabotage-verified: re-adding a renderer delivery call fails 2 tests),
poisoned-title inertness, pass-through for unknown @s, and a source
contract pinning the deleted machinery. hide-bots + roster-cache-key
harnesses re-pinned to the new contract. 390/390 green.
2026-08-21 19:09:47 -07:00
Minsang Lee 0287dfb0c2 fix(bot-mode): a bot row opens the conversation you were last having
Clicking a bot in the roster always reopened its pinned canonical Bot Chat.
Start a new conversation with bot A, click bot B, click back to A — the new
conversation was gone, replaced by the pinned transcript. A bot row is a
workspace entry point, so it has to land on the live conversation.

Two independent causes, both fixed here:

1. The pin overrode newer work.
   `openBotCanonicalChat` opened the pin unconditionally. It now prefers the
   bot's freshest VISIBLE session — but only AFTER `profiles.list` has
   verified through `preferred_session` that the pin is alive and is a real
   canonical Bot Chat. That ordering matters: with a dead or unverified pin,
   adopting the profile's latest row would claim an unrelated user
   conversation as the bot's chat, and the hide sweep would then hide it.
   The existing "no pin" / "dead pin" safety tests cover exactly that and
   still pass. The pin keeps owning plumbing (creation, hide sweep, DM
   delivery); it just stops shadowing newer conversations.

   Guards on the candidate (`newerVisibleBotChat`): the canonical chat can
   never shadow itself, an empty draft never displaces a real conversation,
   and a gateway that omits `message_count` is treated as real history
   rather than discarded.

2. The workspace did not follow the bot.
   The three `host.openSession` calls on the bot path relied on the SDK
   default `keepAllProfilesScope: true`, so `$activeGatewayProfile` stayed on
   whatever profile was active before the click. Sessions created afterwards
   were then filed under the previous bot's profile — measured: four new
   chats started from three different bots all persisted into one profile's
   state.db. Clicking a bot IS a profile switch, so these pass `false`.

Note on the call shape: `previewSession` is `bot.preferred_session || last`,
so on a pinned bot it resolves to the PIN (preview identity must match click
identity). Feeding that as the "newer" candidate makes the whole preference
dead code — it always sees the pin and short-circuits on "same id". The
freshest visible session therefore arrives as its own argument. The first
attempt at this fix had that bug and passed its tests, which is why
`bot-row-opens-latest.test.mjs` mirrors the production call site argument for
argument rather than constructing a convenient one.

Tests: 362 pass (was 348). Each new guard was verified by sabotage — reverting
any one of the three behaviours above makes the suite fail (1, 3, and 1 tests
respectively), so none of them is a test that passes either way.
2026-08-21 13:42:54 -07:00
ethernet 9815319d5f refactor(desktop): derive the tab hover close button from the close verb
PaneTab gated its hover close button on two independent inputs: the
onClose verb, and a showCloseButton prop that TreeGroup fed from a
showCloseButton flag on the pane contribution. The middle-click and
Meta-click gestures read only onClose. A tab could therefore close on a
pointer gesture and advertise no control for it.

The flag had no user that hideOnly did not already cover. Both setters
also set hideOnly: true, which removes every close gesture:

- the sessions pane (app/contrib/controller.tsx),
- the Bots pane (plugins/hermes-bots/plugin.js).

The flag was an opt-out marker with no reachable effect, so this change
deletes it instead of teaching it to track the gestures. onClose alone
now decides both shapes. A tab that closes shows the button. A tab
without the verb shows nothing. To make a tab uncloseable, give it no
close verb.

hideOnly and uncloseable keep their meaning. They gate the verb, and
both shapes follow the verb together.

The DialogContent and SheetContent prop of the same name is a different
prop and stays. It has no close verb to derive from, and one caller
changes it while the dialog is open.

Tests: the new tab-close-affordance test renders the real TreeGroup and
asserts that button presence equals middle-click closure. It covers
hideOnly chrome, a plain side pane, the uncloseable workspace, and a
session tile. It reads closure from the layout tree, not from a spy, so
a wired-up mock cannot pass it. A regression that hides the button on a
closeable tab fails two of the four cases. The compiler rejects the
deleted prop, so the test carries no fixture for it. The pane-tab unit
test moves off the deleted prop.

Verified with the full apps/desktop vitest suite, npm run typecheck, and
npm run lint. Two electron process-spawn tests fail on this machine.
They also fail on a clean tree, and they do not touch the pane shell.
2026-08-21 16:04:25 -04:00
Teknium a2da0ab797 feat(cron): bot-chat delivery target — cron output lands in a bot's canonical Bot Chat and the bot responds
deliver='bot-chat[:<profile>]' is a machine-local pseudo-platform: the
scheduler delivers job output as a real inbound turn in the target
profile's canonical Bot Chat via the chat CLI lane (--in ~ -c "Bot Chat"
--create-if-missing -Q --query-file), the same lane Bot Mode
agent-to-agent messages use. The bot reads the output, acts on it, and
responds in its chat — instead of the output only landing in Run history.

- cron/scheduler.py: token parsing, target resolution (own profile /
  named local profile / unknown -> skipped with warning), subprocess
  delivery lane with cron.bot_chat_delivery_timeout_seconds (default
  600s), preflight exemption, and bot-chat entries in
  cron_delivery_targets() for UI pickers. Excluded from 'all' by design.
- tools/cronjob_tools.py: create/update-time validation — named profiles
  must exist on this machine (fail at create, not at 3am); deliver schema
  documents the new token.
- tui_gateway/methods_tools.py: cron.manage add forwards deliver.
- hermes_cli/profiles.py: list_profile_names() cheap name-only scan.
- hermes-bots plugin: Create Cronjob dialog gains a 'Send results to'
  picker (Run history only / <bot>'s chat); bot-chat jobs send the BARE
  token on the profile-scoped create so Desktop-side aliases can never
  name a profile the backend doesn't have.
- Docs: user cron guide, automate-with-cron, cron-internals.

Machine-local by construction: names resolve only against the executing
machine's ~/.hermes/profiles/, so overlapping profile names across
multiple connected gateways are unambiguous.
2026-08-21 12:48:53 -07:00
pierrenode fb7f0602fb fix(bot-mode): durableGroupChatRooms drops tombstones and roomId on the remote-merge persist path
updateGroupChat's inline durable-map builder (the local-mutation persist
path) skips tombstoned rooms and carries roomId — but durableGroupChatRooms,
the SEPARATE builder persistGroupChatRooms uses for the remote-merge path
(every pullGroupChatServerState / gateway-swap sync), has neither.

Two independent gaps in the same function:

1. Tombstone resurrection. Disband sets a runtime-only tombstone
   ({tombstone: true, log: [], ...}) while a drive may still be mid-turn,
   with no roomId. mergeRemoteGroupChatSnapshotIntoRooms spreads
   ...existing before its explicit field overrides (none of which touch
   tombstone), so if a remote gateway hasn't received the delete yet
   (plausible now that sync fans out to every reachable default-profile
   gateway with independent per-connection backoff) and still has a live
   copy of the room, the tombstone flag survives into the merged room.
   That merged map is handed straight to persistGroupChatRooms, which
   wrote it to storage because durableGroupChatRooms had no tombstone
   check. On the next cold hydrate the persisted tombstone reads back as
   an empty, non-tombstoned room, resurrecting the original bug
   (recreating a room under the same name silently becomes "<name> 2")
   through a path the earlier tombstone fix didn't cover.

2. roomId loss. mergeRemoteGroupChatSnapshotIntoRooms correctly carries
   roomId into the merged in-memory room, but durableGroupChatRooms never
   included it in the persisted snapshot. Every room merged in via the
   remote-sync path therefore loses its immutable room identity on the
   next cold hydrate (comes back with roomId: null) and falls back to
   legacy name-keyed identity — breaking id-based rename/merge resolution
   and member-session titling ("Group: <roomId>").

Fix: durableGroupChatRooms now mirrors updateGroupChat's inline map
exactly — skip tombstones, carry roomId.

Tests: durableGroupChatRooms unit tests for both gaps, plus an
end-to-end reachability test (tombstone -> mergeRemoteGroupChatSnapshot-
IntoRooms -> persistGroupChatRooms -> storage) proving the merge really
does forward the tombstone and the fix really does keep it out of
storage. Mutation-verified against pre-fix code (all 3 new tests fail).
Full hermes-bots plugin test suite (60+ files) green.
2026-08-21 04:32:43 -07:00
Teknium 7af4b8ee92 test(bot-mode): drop unused stub param flagged by eslint 2026-08-20 21:54:18 -07:00
Teknium c6745da72b feat(bot-mode): durable room identity + full gateway fan-out for group-room sync
Resolves the room-lifecycle class on top of the salvaged #89369 projection:

- v3 projection keys rooms by immutable roomId (id:<roomId>) with
  name:<name> fallback for legacy rooms; v1/v2 envelopes are normalized
  on read so mixed-version fleets share one merge path
- rename is now a same-key field update — no distributed delete+create,
  no old-name resurrection from lagging gateways
- id tombstones are FINAL (ids are never reused), so a gateway that was
  offline during a disband can never resurrect the room, regardless of
  the revision its stale copy carries; same-name recreation is unaffected
  because it mints a fresh roomId
- the projection fans out to EVERY reachable default-profile gateway
  (per-gateway job queues, CAS revision streams, backoff and retry caps),
  so rooms survive any single gateway dying and surface on gateway-only
  clients without waiting for a Desktop to foreground that gateway
- cold hydrate follows a remote rename via roomId instead of duplicating
  the room under both names

New tests: id-keyed rename continuity, final id-tombstones vs lagging
high-revision copies, rename-job shape (changed+deleted same key),
cold-hydrate re-keying, multi-gateway fan-out. Sabotage-verified: each
new test fails against the pre-class behavior.
2026-08-20 21:54:18 -07:00
Teknium 5d75a46645 fix(bot-mode): stabilize sync entry identity across gateway round-trips
Two follow-ups to the salvaged #89369 base against current main:
- compact projection entries no longer overwrite the local rich copy
  (attachments survive; watermark accounting stays stable)
- synthetic legacy-N thread ids collapse to one bucket in the entry key,
  so id-less entries don't duplicate after a pull and manufacture
  phantom member turns into busy sessions
2026-08-20 21:54:18 -07:00
TheTom 3b83b5e1fa fix(gateway): serialize shared bot room updates 2026-08-20 21:54:18 -07:00
TheTom 7656a2ad46 fix(desktop): hydrate shared bot room previews 2026-08-20 21:54:18 -07:00
TheTom 64d393cb7e fix(desktop): sync bot room previews through profiles 2026-08-20 21:54:18 -07:00
Mengfan ba501f02ce fix(desktop): Bot Mode mention readers miss the connection-suffixed roster cache
useRoster() keys its query on [...ROSTER_KEY, connectionId] so every
connection the window has been on gets its own cache entry, but the two
imperative roster readers — the @mention completions and the mention
middleware — still called getQueryData(ROSTER_KEY). TanStack Query's
getQueryData is an exact-key match, so the bare key matched nothing and
both readers saw an empty roster: the composer offered no agent handles,
and remote @name-device mentions (e.g. @default-vera for a same-named
profile on an SSH connection) fell through to the local profiles.list
fallback, which only knows bare local names — the mention passed through
untouched to the local agent instead of being routed over Connections.

Add a cachedRoster() helper: honor the legacy bare-key write first, then
scan the key family with getQueriesData, preferring the active
connection's entry and falling back to any other entry (a stale roster
beats none). Never throws, matching the readers' existing posture.

Regression tests model the real cache shape — entries under the suffixed
key, exact-key reads missing them — so the key-shape mismatch is pinned;
the previous tests stubbed getQueryData to ignore keys, which is exactly
why this slipped through.
2026-08-20 19:42:00 -07:00
Gille 76952ba54f fix(desktop): follow focused bot for mentions 2026-08-20 19:12:39 -07:00
ArthurFranckPat 770f9a5747 fix(desktop): read the live union roster in mention-completions + mention middleware
useRoster caches under [...ROSTER_KEY, activeConnectionId] — a 3-element
key — but the composer @ autocomplete provider and the mention-routing
middleware both read getQueryData(ROSTER_KEY) with the bare 2-element
key. An exact-match lookup against a 3-element cache key returns
undefined forever, so:

- @ autocomplete offered ZERO bot rows (local and remote alike)
- remote-bot mentions never matched the union roster and fell back to a
  local-only profiles.list, so @name-device mentions did not route

That is issue #89303: the remote-source Bot row tells the user to
`@name-device` the agent, and the completion surface never offers it.

Fix: cachedUnionRoster() — read the live connection's cache entry first
(getQueryData with the full 3-element key), fall back to a prefix scan
(getQueriesData) keeping the freshest snapshot. Never throws; callers
keep their existing cold-cache fallbacks.

The plugin test mocked getQueryData as key-agnostic, which is why this
never failed in CI: the mock now reproduces the real QueryClient v5
exact-key semantics (a bare ROSTER_KEY lookup misses the 3-element
cache), and the tests fail against the old code (verified: 2 failures
before the plugin fix, 9/9 pass after).

Fixes #89303
2026-08-20 19:12:39 -07:00
Teknium e30388e409 fix: retained group panes re-anchor to the latest message on reopen
Keep-alive workspace panes (#89788) stay mounted while hidden, so returning
to an already-open room never remounted GroupChatWorkspace and the
mount-time bottom anchor didn't rerun — reopening group A after visiting
group B left A at the old scroll position. GroupChatMainView now subscribes
to the pane's visibility via feature-detected host.paneVisibility (always-
visible atom fallback for older SDKs) and the workspace scrolls the bottom
sentinel into view on the hidden→visible edge. Repro and fix direction from
the live-audit review comment on #90526.

Also updates the composer shape test to slice only GroupMentionInput
(GroupClarifyCard, merged since the branch was cut, legitimately uses
Input) and teaches the legacy-SDK react proxy about useMemo.
2026-08-20 14:25:50 -07:00
Teknium 3c5fb284e5 fix(bot-mode): group-room UX cluster — Shift+Enter newlines, open-at-latest, late replies harvested (#89884, #89835, #89545)
Composer was a single-line Input (Enter always submitted, newlines
impossible) — now the SDK Textarea with Enter=send / Shift+Enter=newline
and popover-first key handling. Room log had no scroll anchoring (opened
at position 0) — bottom sentinel + near-bottom-guarded anchor effect.
Stranded replies were only harvested inside an active turn loop (stuck
until the user's next send) — the settle path now runs a bounded
background harvest that yields to a live loop.
2026-08-20 14:25:50 -07:00
Bikash Joshi 693d6341b7 fix(desktop): expose group deletion from roster rows 2026-08-20 14:14:51 -07:00
Sara Burke ddc78217fd Merge current Bot Mode group fixes
Carry the reviewed hybrid routing branch through current main at 603d5651b,
preserving fresh room-session identity and prior signed history.
2026-08-20 16:55:00 -04:00
Teknium d1ea11d589 fix: disband tombstones no longer hold the group name or leak into storage
Found in live GUI E2E of the roomId salvage: disbanding a room while a
drive is mid-turn leaves an epoch-bump tombstone under the room's key.
Two defects surfaced: (1) updateGroupChat persists the WHOLE atom map, so
the next unrelated room write persisted the tombstone as an empty room;
(2) create/rename collision sets counted the tombstone's key, so
recreating the group under the same display name silently became
'<name> 2'. Tombstones are now flagged, excluded from the durable map,
from create/rename collision sets (liveGroupChatNames), and from roster
rows. Regression coverage in the existing tombstone test.
2026-08-20 13:53:28 -07:00
Teknium 92b6da23bc fix: disband confirm copy no longer hardcodes the legacy session title
New rooms title member sessions 'Group: <roomId>'; the dialog copy now
describes the kept sessions generically instead of quoting a title that is
wrong for post-roomId rooms.
2026-08-20 13:53:28 -07:00
YuYigeng 3752948bb7 fix(bot-mode): recreating a same-name group mints fresh member sessions 2026-08-20 13:53:28 -07:00
Sara Burke 052513f65b Merge current upstream into hybrid remote Bot routing
Adopt upstream's one-hidden-forever-chat model and remove the per-bot hidden
session browser while preserving connection-bound remote Bot opening, the full
remote action menu, same-name isolation, and global Sessions navigation.
2026-08-20 10:26:19 -04:00
Teknium 2123a01601 fix: disbanded group chats no longer resurface as empty roster rows (Bot Mode)
Disband removed the room log and each member's group membership, but
BotsPane's mergeServerMeta then overlaid the STALE cached roster snapshot
(fetched before the disband) whose ui_meta['hermes-bots'] still carried the
old groups array — spreading it back over local meta and re-listing the
group as an empty row. Any later meta write for the bot (pin, title,
canonical-chat pointer) re-uploaded the resurrected membership server-side,
making the ghost permanent. Same stale-overlay class could revert renames,
re-group left members, and undo pins/hides.

Fix (class-level, not per-field):
- saveBotMeta stamps a per-bot last-local-write time (re-stamped when the
  profiles.configure write settles).
- useRoster stamps each snapshot with its fetch ISSUE time (fetchedAt).
- mergeServerMeta skips overlaying any bot whose local write post-dates the
  snapshot; the next (fresh) fetch overlays normally, so server truth still
  gets the last word. No-fetchedAt callers behave exactly as before.
- disbandGroupChat / renameGroupChat invalidate the roster query so all
  surfaces converge on a fresh snapshot immediately.

Tests: new regression (stale snapshot cannot resurrect a disbanded
membership; fresh snapshot still wins) proven to fail without the fix via
sabotage run; fence-off compatibility test; hide-bots shape regex updated.
341/341 plugin tests pass.
2026-08-20 05:47:40 -07:00
Teknium 1179f148e4 fix(bot-mode): group-chat command approvals surface in the room too — same hidden-session class
Approvals (pending_approval) had the identical blind spot as clarify:
blocked server-side in the hidden member session, invisible until timeout.

- syncGroupClarify mirrors pending_approval alongside pending_clarify
  (clarify outranks when both appear); entries carry kind, command, the
  server's choice set (once/session/always/deny, fallback once/deny), and
  the runtime session id approval.respond keys on.
- The room card renders approvals as command-in-code + choice buttons
  (closed set, no free text; deny tinted destructive) and routes
  approval.respond via the member's own source.
- 6 new tests incl. an end-to-end blocked-on-approval drive; sabotage
  run (approval mirroring disabled) fails all 4 behavioral tests.
2026-08-20 04:30:21 -07:00
Teknium c757f99e63 fix(bot-mode): group-chat members' clarify questions surface in the room and are answerable (#90694)
Group members run in hidden plumbing sessions, so a member's clarify tool
blocked server-side with no surface to answer it — the room showed
'@lead is thinking…' until the 300s clarify timeout (salihsungur's report).

- The turn poll and the stranded-harvest pass mirror each member's
  `pending_clarify` resume field into $groupClarify and hold the turn
  deadline open while a question waits (bounded by the existing hard cap).
- The room renders a question card per blocked member — choice buttons,
  free-text, batch sub-questions — and answers route via clarify.respond
  through the member's OWN source (requestForBot), so cross-connection
  members work. The answered exchange echoes into the room log.
- needs-you badges the roster row while a question waits; disband/rename
  clear mirrored cards; older backends without pending_clarify no-op.
- 7 new tests incl. an end-to-end blocked-turn drive, sabotage-verified
  (disabling the poll gate fails the drive test).
2026-08-20 04:30:21 -07:00
kshitijk4poor 8265b873e0 fix(bot-mode): disband dialog no longer points at the removed session browser
PR #90732 removed the per-bot Sessions browser (right-click -> Sessions),
but the Disband-group confirm dialog still told users they could open the
kept 'Group: X' sessions 'from each bot's session browser' - an affordance
that no longer exists. Drop the stale clause; also update the one test
comment that still described the removed workspace.

Review follow-up from #90732 (found by the 3-angle review pass).
2026-08-20 03:56:43 -07:00
Teknium 21e9d4532f fix(bot-mode): canonical-chat adoption survives busy profiles via exact-title lookup
The #90732 adoption scan used session.list's 200-row recency window. A busy
bot profile (group-chat traffic, routines, or accumulated fork spam) pushes
an older forever-chat past row 200, the scan misses it, and the mint path
re-enters the unique-title-conflict fork loop — same pathology, higher
trigger threshold.

Profile → Named Session is an exact registry (UNIQUE title index), so
consult it exactly:

- session.list gains a `title` param: indexed WHERE title = ? lookup,
  window-free, hidden rows resolve, archived/deny-listed do not,
  compression lineages resolve to the live tip (resolved_id), mirroring
  profiles.list's preferred_session resolver.
- findExistingCanonicalChat sends title: 'Bot Chat'. Older gateways ignore
  the unknown param and return the windowed listing — the local scan stays
  as the compatibility rung.
- Adoption opens the lineage tip (resolved_id) while pinning the durable id,
  same split as the preferred_session path.
2026-08-20 03:49:15 -07:00
Teknium 5ab74737e0 fix(bot-mode): kill the canonical-chat infinite-fork loop; drop the per-bot Sessions browser (#90732)
Symptom: switching between bots forked a brand-new "Bot Chat" for the
returned-to bot on EVERY switch, burying the user's real forever-chat
(one report: a 930-message chat displaced by 7 forks in one morning).

Root cause is a self-perpetuating loop between three parties:
- state.db enforces UNIQUE(title): the first fork permanently squats
  the "Bot Chat" title; every later mint's title request is silently
  dropped by set_session_title (returns 0, no error to the caller).
- The post-turn LLM auto-titler then names the untitled fork from its
  kickoff content ("Assistant introduction request #2", ...).
- openBotCanonicalChat's identity check is title-string matching, so
  the renamed fork reads as "not plumbing" -> corrupted metadata ->
  clear pin -> mint again. Grandfathered pre-convention chats (real
  history, derived titles) hit the same branch and are forked away
  from immediately.

Fix, two invariants:
1. Adopt-before-mint: createCanonicalChat first scans the profile via
   session.list include_hidden:true for an existing "Bot Chat" row and
   re-pins it instead of creating. The UNIQUE index makes this an exact
   registry lookup (at most one match), not a heuristic. Older gateways
   without include_hidden find nothing and fall through to mint.
2. A pin that resolves to a NON-plumbing session carrying real history
   is the user's conversation - keep it and open it (title drift is
   metadata damage, not ownership loss). Only a pin resolving to an
   EMPTY stray draft is treated as corrupted and replaced (which now
   goes through adoption first).

Also removes the right-click -> Sessions per-bot stored-session browser
(ProfileSessionsWorkspace and its atoms/query/rows). Bot Mode's product
contract is ONE forever-chat per bot; a browser listing every hidden
plumbing session contradicts that and confused users into opening dead
forks. The Sessions workspace test goes with it; the include_hidden
source-shape test now pins the adoption scan instead, and a new suite
(canonical-chat-adopt-before-mint.test.mjs) covers both invariants plus
the older-gateway fallback.
2026-08-20 03:37:52 -07:00
Teknium d29f76c70e fix(bot-mode): group chat no longer doubles into the Bots pane beside its main tab (#89788 follow-up)
The #89788 gate read main-tab ownership from a plain module Map — invisible
to React — and openGroupChat set the selection atom before recording the
tab. Every open therefore rendered BotsPane in a selected-but-unowned
window, painting the in-pane room beside the main tab, and the duplicate
stuck because the later Map write repaints nothing.

- $groupMainTabsRev atom shadows tab-map membership; all mutations go
  through recordGroupMainTab/dropGroupMainTab; BotsPane subscribes, so the
  in-pane gate re-evaluates on tab open/close.
- openGroupChat records the tab BEFORE setting the selection atom; older
  desktops without the main-window door (and a throwing door) still get
  the in-pane fallback.
- Regression tests: gate is false at the instant the selection atom flips
  (fails on the old ordering — sabotage-verified), and rev bumps on tab
  open/close.
2026-08-20 03:12:39 -07:00
hermes-seaeye[bot] 27562ad5f8 fmt(js): npm run fix on merge (#90637)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-20 07:37:56 +00:00
Brooklyn Nicholson bd853747bb fix(desktop): a fresh profile follows the OS, and plugins stay behind the SDK
Two things a genuinely fresh instance surfaced that no existing profile could.

The renderer's mode fell back to `light` when nothing was stored, so a
dark-mode desktop opened a white window on first launch. Main already
defaulted its own themeSource to `system`, so the two disagreed at boot — and
once translucency became per-appearance it also handed those users light's
much heavier tint, tuned for a bright desktop they don't have. Both the
normalizer and the SSR fallback now say `system`; an explicit choice still
wins.

The accent plugin reached straight into `@/components` and `@/themes`, which
the plugin lint rule exists to prevent: plugins import `@hermes/plugin-sdk`
and nothing else, so the app can move its internals without breaking them.
The fix is to widen the SDK rather than exempt the plugin — it now exports the
OKLCH colour maths, `useTheme`, `retintTheme`, and the accent override, so any
plugin can derive a palette instead of hardcoding one.
2026-08-20 02:28:24 -05:00
Brooklyn Nicholson 98bac5feb4 feat(desktop): an accent picker plugin, off by default
Finding a colour by hex is guesswork; finding one by eye needs a picker that
does not lie about where you will land. HSV crushes the whole blue family
into a narrow band of its hue rail, so dragging "to blue" puts you on pure
sRGB blue, which reads violet — every blue that actually looks blue lives in
a few degrees you cannot reliably hit there.

This one is OKLCH. The hue rail is perceptually even and previews the
current colour at every hue rather than showing a generic rainbow, and the
field is a canvas drawn per-pixel through the real conversion, so its curved
edge is the true sRGB gamut boundary — every pixel is a colour the display
can show. Dragging repaints the whole app against the real derivation.

It ships off (`defaultEnabled: false`) and holds no persisted state: the
override clears on dispose, so turning the plugin off returns every surface
to the authored theme rather than stranding a colour with no control to
clear it. The retint itself stays in core, where Appearance settings and the
command palette can reach it.
2026-08-20 02:28:24 -05:00
Teknium ad7a14a539 fix(desktop): renamed Bot Mode agents stay @-taggable by their new name
Renaming a bot (Bot Mode title or 'hermes profile rename' display_name)
changed the roster row but not what the user could @-tag it with — mentions
still only resolved the original profile handle, and the composer
autocomplete never offered the new name.

- mentionNameForms()/botFriendlyNames()/botMentionTag(): one resolver for
  the taggable forms a friendly name yields (slugged + collapsed), with
  reserved tokens (hermes/default/everyone/all/user) excluded so a rename
  can never hijack them.
- resolveRosterMentions() and parseGroupChatMentions() accept the friendly
  forms alongside the profile name/handle (both keep working).
- Composer @ autocomplete (global provider + group-room popover) inserts
  the renamed tag and prefix-matches on tag, handle, and display name.
- Mention middleware's cold-cache fallback now runs the same resolver
  instead of a bare-names-only parse, so renamed tags resolve there too.
- durableGroupChatMembers persists title/display_name so renamed-tag
  mentions survive connection switches in cross-machine rooms.
- Docs: bot-mode.md documents renamed tags.
2026-08-20 00:08:46 -07:00
Teknium 6851841112 fix(bot-mode): group chat opens as one room pane, not two (#89788)
Opening a Bot Mode group chat painted the room twice — once as a main-window
workspace tab (host.openWorkspace) and once as the in-panel fallback, because
the Bots pane rendered off $groupChatWorkspace alone. Two live panes with
independent drafts drove one shared engine, and the roster disappeared behind
the duplicate.

The in-panel room is the fallback surface, not a second copy: it now renders
only while no main tab owns the group. The selection atom stays set either way
so the roster row still highlights, and desktops without the door — or whose
door throws — keep the in-pane room.

Consolidates #89881, #90274 and #90398, which fixed the same bug.

Closes #89788

Co-authored-by: helix4u <helix4u@users.noreply.github.com>
2026-08-19 23:12:25 -05:00
Owenz-creator 8408edcfb5 fix(bot-mode): protect ordinary sessions from hide sweep 2026-08-19 19:30:28 -07:00
Teknium d604ba6585 fix(bot-mode): running kanban/tool workers now light the Bots roster (#90268)
Worker sessions are deny-listed out of every conversation list, so a
profile grinding through a 30-minute kanban task read idle ('3 hr ago')
with no ACTIVE NOW entry the entire run.

- tui_gateway/methods_profiles.py: profiles.list rows gain worker_session
  — the newest kanban/tool row (id, source, title, last_active). Workers
  heartbeat last_activity_at every <=60s while running (#72016), so the
  field stays fresh exactly while work is happening. last_session keeps
  its deny-list contract; include_sessions:false omits the field; older
  clients ignore it.
- hermes-bots plugin: workerActiveAt() (150s window, one missed heartbeat
  of slack) feeds ACTIVE NOW, the row pulse dot ('Working on a task right
  now'), and the row age label while a worker runs. Chat semantics are
  untouched when no worker is live.
- Tests: 4 new pytest (real SessionDB on temp HERMES_HOME), 2 new node
  behavior tests; sabotage-verified.

Session-list visibility of workers (the issue's first half) is left as-is
by design — auto-resume and shared lists must keep excluding workers; the
roster signal was the actionable gap.
2026-08-19 19:30:12 -07:00
EndeavorYen 2367b90b9f fix(desktop): keep Sessions workspace when opening a Bot Chat
Bot Mode passed keepAllProfilesScope:false, which re-homed the sidebar
onto the bot profile. That profile forever-chat is hidden, so Sessions
and the roster looked empty. Opening a bot is navigation, not a workspace
switch. Also restore all-profiles when the bot backend is already live.

Related: #89789
2026-08-19 20:01:40 -05:00
chelsealong 6ec4aa8c3a fix(desktop): move the hydration-timeout retry into host.openSession
A review of the previous commit found that retrying at the plugin layer
(openStoredBotChat catching and re-calling host.openSession) didn't fix
the reported bug: host.openSession's own catch block unconditionally
calls setResumeExhaustedSessionId on a hydration timeout before
rethrowing, and only an explicit resumeSession() (the manual Retry
button) clears that latch for the currently-routed session. A
plugin-side retry is a different code path that can hydrate the
transcript fine while the full-screen "Couldn't load this session"
overlay stays latched over it.

host.openSession now takes a retryHydrationTimeoutOnce option and
retries the open+hydration-wait internally, before the latch is ever
set, so a successful retry never arms the overlay. openStoredBotChat
just opts in via that option.
2026-08-19 20:01:40 -05:00
Teknium 271e49a8ff fix(bot-mode): accept both host.connections() shapes in the Create-on picker normalize
The SDK now returns the registry rows per its documented contract
(salvaged #89893), while desktops predating the SDK unwrap resolve the
raw registry envelope. The plugin normalize accepts both, so the picker
works across the transition; regression test updated to pin the
dual-shape normalize.
2026-08-19 16:10:10 -07:00
Teknium 010c9925e3 fix(bot-mode): roster age, pulse, unread, and sort now see canonical Bot Chat activity
The canonical Bot Chat is hidden from session lists by design, so
profiles.list's last_session never advances when you message a bot there.
PR #88690 moved the roster PREVIEW to preferred_session but left every
activity signal on last_session — a bot you just messaged showed '6d ago',
never pulsed, never badged, and sorted below stale bots.

New botActivitySession(bot) helper returns the fresher of preferred_session
(the pinned Bot Chat, resolved precisely by the backend) and last_session
(newest visible conversation). All four activity sites key off it now:

- row age label (relativeTime)
- active-now pulse dot + activeBots strip
- unread watermark + activity toast preview
- roster recency sort (activityOf)

Older gateways without the preferred_session resolver degrade to
last_session exactly as before. Backend untouched.

Tests: extracted the real helper into the vm harnesses (no stub drift),
5 new behavior tests; sabotage-verified they fail against the old code.
2026-08-19 16:04:13 -07:00
Tom 7b25941b0e fix(bot-mode): unwrap connections registry object so 'Create on' picker renders
host.connections() resolves the IPC handler hermes:connections:list, which
returns the registry OBJECT ({version, primary, connections: [...]}) — not a
bare array. CreateAgentDialog did setConnections(Array.isArray(value) ? value
: []), so on a multi-connection desktop the picker gate
(Array.isArray(connections) && connections.length > 1) never fired and the
'Create on' picker stayed hidden, making cross-machine bot creation
impossible despite the multi-connection feature being documented.

Any new agent is still created on the active gateway (unchanged behaviour);
the picker is the only path that regressed. The built-in Connections UI
(refreshConnectionsRegistry) consumes the same registry object, so the IPC
handler contract is left untouched.

Adds a regression test asserting the unwrap and that the IPC contract is
preserved. Plugin suite: 309/309 pass.
2026-08-19 15:06:57 -07:00
Sara Burke 723dbda041 Merge upstream main into fix/remote-bot-routing
Preserve current upstream Desktop and Bot Mode behavior while carrying the
connection-bound remote Bot routing implementation forward without rewriting
the signed feature commit.
2026-08-19 09:58:05 -04:00
Sara Burke 9b7ab9d65a feat(desktop): route remote bot actions by connection
Carry immutable connection and profile ownership through Bot Mode actions,
session hydration, transcript loading, new chats, and profile deletion.
Keep same-named local and remote profiles isolated, preserve the full context
menu, and migrate Bot metadata to connection-qualified storage.

Add adversarial routing, alias, migration, cache, and compatibility coverage.
2026-08-19 08:01:29 -04:00
David Dudok de Wit 0a36fa7fbd fix(desktop): keep remote bot identity visible 2026-08-18 22:03:25 -07:00