SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
28ad89a verified
Raw History Blame Contribute Delete
48.5 kB
/**
* Bot Mode's domain layer: the roster query, bot metadata storage, the
* identity keys everything else is filed under, and needs-attention state.
*
* Framework-light — useRoster is the one React binding — and it depends only
* on routing.ts, for the owner descriptors it dispatches through.
*/
import { atom, host, queryClient, useQuery, useValue } from '@hermes/plugin-sdk'
import { displayName } from './labels'
import {
aliasIdentityFor,
beginAliasRouteIndex,
botConnectionRoute,
botRosterMeta,
botRouteKey,
botWorkspaceOwnerKey,
indexAliasRoutes,
requestForBot,
setBotsWorkspaceOwner
} from './routing'
import { getPluginCtx, ID } from './shared'
import type {
AttentionClass,
BotMeta,
CanonicalSession,
GatewaySource,
GroupMember,
ProfileRoute,
RosterRow,
SessionPreview
} from './types'
export const ROSTER_KEY = [ID, 'roster']
// Bounded retries. `retry: true` keeps React Query in isLoading until the
// first success, so a stalled profiles.list (live state.db write lock, SSH
// flap) leaves the Bots sidebar on a spinner with no error card. The 5s
// refetchInterval and the gateway-open effect already recover drops.
const ROSTER_QUERY_RETRY = 2
export const BOT_META_V1_KEY = 'bot-meta'
const BOT_META_V2_KEY = 'bot-meta-v2'
const BOT_META_MIGRATION_KEY = 'bot-meta-v2-migrated'
export let botMetaV2Active = false
let botMetaV2Commit = Promise.resolve()
const migratedLocalRoutes = new Map<string, ProfileRoute>()
/** Live roster snapshot for imperative handlers (context menus). */
export const $lastRoster = atom<RosterRow[]>([])
// ── needs-attention badge (#93091 item 3) ───────────────────────────────────
// Attention-worthy failure classes — matches the #93091 item-1 reason-code
// enum (shipped separately). Until reason codes flow end-to-end,
// attentionReasonFromError ALSO classifies raw error text as a fallback so
// the badge works against current gateway error strings.
const BOT_ATTENTION_CLASSES: ReadonlySet<string> = new Set<AttentionClass>([
'agent_blocked',
'provider_auth_or_access',
'provider_quota_limit',
'missing_config'
])
/** One-line user hint per attention class (roster badge tooltip). */
export const BOT_ATTENTION_HINTS: Record<string, string> = {
provider_auth_or_access: 'Sign in again for this profile',
provider_quota_limit: 'Quota or balance exhausted',
missing_config: 'Provider not configured — run hermes model',
agent_blocked: 'Bot is blocked — see its last message'
}
/** Map an error (a #93091 reason code or raw error text) to an attention
* class, or null when the failure is transient (rate limit, server error,
* timeout) — transient classes must NEVER badge. Pure; tested directly. */
function attentionReasonFromError(errorTextOrReason: unknown) {
const raw = String(errorTextOrReason || '').trim()
if (!raw) {
return null
}
if (BOT_ATTENTION_CLASSES.has(raw)) {
return raw
}
const text = raw.toLowerCase()
// Transient failures first, so a retryable error never sticks a badge.
if (
/rate.?limit|too many requests|\b429\b|\b5\d\d\b|server error|overloaded|timed?.?out|timeout|temporar/.test(text)
) {
return null
}
if (/no llm provider|no access token|not configured|no api key|missing api key/.test(text)) {
return 'missing_config'
}
if (
/\b401\b|\b403\b|unauthorized|forbidden|authentication|invalid.?api.?key|credentials? (are )?(invalid|expired)/.test(
text
)
) {
return 'provider_auth_or_access'
}
if (/quota|out of funds|insufficient (credits?|funds|balance)|payment required|\b402\b|billing/.test(text)) {
return 'provider_quota_limit'
}
if (/\bblocked\b/.test(text)) {
return 'agent_blocked'
}
return null
}
/** Per-bot needs-attention state: roster key -> {reason, at, message}.
* Display-only presentation state (never persisted, never alters delivery).
* Latest failure wins; the bot's next good turn clears it. Hidden bots keep
* their entry — hiding is a roster-DISPLAY concern only. */
export const $botAttention = atom<Record<string, { at: number; message: string; reason: string }>>({})
/** Record attention for a bot after a failed turn/delivery. Transient errors
* classify to null and set nothing. Latest failure wins. */
export function noteBotAttention(key: string, errorTextOrReason: unknown) {
const reason = attentionReasonFromError(errorTextOrReason)
if (!key || !reason) {
return
}
$botAttention.set({
...$botAttention.get(),
[key]: {
reason,
at: Date.now(),
message: String(errorTextOrReason || '')
.trim()
.slice(0, 200)
}
})
}
/** A good turn clears the badge. */
export function clearBotAttention(key: string) {
if (!key || !$botAttention.get()[key]) {
return
}
const next = {
...$botAttention.get()
}
delete next[key]
$botAttention.set(next)
}
/** The persisted record adds two keys the shared domain model deliberately
* omits: `chat`, the dead canonical-chat pointer that mergeServerMeta strips
* on sight, and `pet`, the extracted pet icon that stays local and is never
* sent to the server. */
interface StoredBotMeta extends BotMeta {
chat?: unknown
pet?: unknown
}
/** Appearance records keyed by meta key — `connectionId::profile`, or the bare
* bot name on legacy single-source installs. */
export type BotMetaSnapshot = Record<string, StoredBotMeta>
/** `ctx.storage` as this file consumes it. `get` is optional because the write
* path feature-detects it mid-body, and it is called without the SDK's
* required `fallback`, so it resolves whatever JSON was persisted. */
interface BotMetaStorage {
get?: (key: string, fallback?: unknown) => any
remove: (key: string) => unknown
set: (key: string, value: unknown) => unknown
}
/** Per-bot appearance + display meta, persisted via ctx.storage:
* { [botName]: { shape, color, title } } */
export const $botMeta = atom<BotMetaSnapshot>({})
export function commitBotMetaV2(storage: BotMetaStorage | undefined, snapshot: BotMetaSnapshot) {
const commit = botMetaV2Commit.then(async () => {
if (typeof storage?.remove !== 'function' || typeof storage?.set !== 'function') {
throw new Error('bot metadata v2 storage is unavailable')
}
const [previousSnapshot, previousMarker] =
typeof storage.get === 'function'
? await Promise.all([storage.get(BOT_META_V2_KEY), storage.get(BOT_META_MIGRATION_KEY)])
: [null, null]
const hasCommittedPrevious =
previousMarker === true &&
previousSnapshot &&
typeof previousSnapshot === 'object' &&
!Array.isArray(previousSnapshot)
try {
await storage.remove(BOT_META_MIGRATION_KEY)
await storage.set(BOT_META_V2_KEY, snapshot)
await storage.set(BOT_META_MIGRATION_KEY, true)
} catch (error) {
if (hasCommittedPrevious) {
try {
await storage.set(BOT_META_V2_KEY, previousSnapshot)
await storage.set(BOT_META_MIGRATION_KEY, true)
} catch {
await Promise.allSettled([storage.remove(BOT_META_MIGRATION_KEY), storage.remove(BOT_META_V2_KEY)])
}
} else {
await Promise.allSettled([BOT_META_MIGRATION_KEY, BOT_META_V2_KEY].map(key => storage.remove(key)))
}
throw error
}
})
botMetaV2Commit = commit.catch(() => undefined)
return commit
}
/** A save target resolved into the four things every write site needs. */
interface BotOwner {
bot: RosterRow
key: string
name: string
route: null | ProfileRoute
}
export function botOwner(owner: RosterRow | string): BotOwner {
if (typeof owner === 'string') {
const name = owner.trim()
const route = migratedLocalRoutes.get(name)
return {
bot: route
? {
name,
sourceScoped: true,
route
}
: {
name
},
name,
key: route ? botRouteKey(route) : name,
route: route || null
}
}
const name = String(owner?.name || '').trim()
const route = botConnectionRoute(owner)
return {
bot: owner,
name,
key: route ? botRouteKey(route) : name,
route
}
}
/** Freshness fence for the server-meta overlay: a roster snapshot fetched
* before the latest local/server metadata write must not overwrite it. */
export const botMetaWriteAt = new Map<string, number>()
/** How long a write can still outrank a roster snapshot. The overlay only
* skips a bot while some snapshot ISSUED before the write is still landing,
* and the roster refetches every 5s with retry backoff capped at 15s — well
* inside a minute. Past that the stamp can never fence anything again, so
* keeping it just grows the map for the life of the renderer. */
const BOT_META_WRITE_FENCE_MS = 60_000
export function noteBotMetaWrite(key: string) {
const now = Date.now()
for (const [written, at] of botMetaWriteAt) {
if (now - at > BOT_META_WRITE_FENCE_MS) {
botMetaWriteAt.delete(written)
}
}
botMetaWriteAt.set(key, now)
}
/** Three-way outcome of the server half of a save — see the block comment on
* `serverOutcome` below for what separates 'unsupported' from 'failed'. */
type BotMetaServerOutcome = 'failed' | 'persisted' | 'unsupported'
interface BotMetaSaveResult {
serverOutcome: BotMetaServerOutcome
serverPersisted: boolean
}
/** `profiles.configure` reply. Older gateways answer without `applied` at all,
* which is what makes the field optional rather than the contract. */
interface ProfilesConfigureResult {
applied?: { ui_meta?: boolean }
}
export async function saveBotMeta(owner: RosterRow | string, patch: StoredBotMeta): Promise<BotMetaSaveResult> {
const { bot, key, name, route } = botOwner(owner)
const prevMeta = $botMeta.get()[key] || {}
const next = {
...$botMeta.get(),
[key]: {
...prevMeta,
...patch
}
}
noteBotMetaWrite(key)
$botMeta.set(next)
// Local plugin storage: instant, and the fallback for older gateways.
let localPersistence = Promise.resolve()
try {
const persisted =
route || botMetaV2Active
? commitBotMetaV2(getPluginCtx()?.storage, next)
: Promise.resolve(getPluginCtx()?.storage?.set?.(BOT_META_V1_KEY, next))
localPersistence = persisted.catch(() => undefined)
} catch {
/* storage unavailable — look persists for this window only */
}
// Server-side (source of truth when supported): profile.yaml ui_meta,
// namespaced under this plugin's id — every client machine sees the same
// roster. Return the outcome so user-initiated saves can distinguish a
// cross-machine save from a local-only fallback instead of reporting a
// false success. Data-URL fields are stripped from ui_meta (64KB cap,
// rides every profiles.list); the avatar IMAGE goes to the profile asset
// store instead (profiles.set_asset), which is server-side and uncapped by
// the list call — so pfps follow the profile across machines too.
let serverRequest: null | Promise<ProfilesConfigureResult> = null
try {
const { image, pet, ...rest } = next[key] || {}
const request = route
? requestForBot(bot, 'profiles.configure', {
name,
ui_meta: {
'hermes-bots': rest
}
})
: host.request('profiles.configure', {
name,
ui_meta: {
'hermes-bots': rest
}
})
serverRequest = Promise.resolve(request) as Promise<ProfilesConfigureResult>
} catch {
/* older/unavailable gateway — the local fallback remains saved */
}
// Avatar image → profile asset store (feature-detected; local storage
// remains the fallback rendering source on older gateways) — but only when
// the image actually CHANGED. Every Edit Profile save sends the image key
// (changed or not); a no-op `clear` from one machine can race another
// machine's just-pushed avatar and wipe it server-side, and a no-op
// `data` push re-uploads the full data URL for nothing.
if ('image' in patch && patch.image !== (prevMeta.image ?? null)) {
try {
const req = patch.image
? route
? requestForBot(bot, 'profiles.set_asset', {
name,
asset: 'avatar',
data: patch.image
})
: host.request('profiles.set_asset', {
name,
asset: 'avatar',
data: patch.image
})
: route
? requestForBot(bot, 'profiles.set_asset', {
name,
asset: 'avatar',
clear: true
})
: host.request('profiles.set_asset', {
name,
asset: 'avatar',
clear: true
})
req.catch(() => undefined)
} catch {
/* older gateway */
}
}
// Three-way outcome so callers can tell a REAL remote failure from the
// documented legacy fallback ("older gateways reject the param shape;
// that's fine, local wins"):
// 'persisted' — gateway confirmed applied.ui_meta === true
// 'unsupported' — older gateway: request rejected, or response carries
// no `applied` contract at all. Silent local fallback;
// an error toast here would fire on EVERY save forever.
// 'failed' — gateway speaks the contract and explicitly reported
// the ui_meta write did NOT apply.
let serverOutcome: BotMetaServerOutcome = 'unsupported'
if (serverRequest) {
try {
const result = await serverRequest
if (result?.applied?.ui_meta === true) {
serverOutcome = 'persisted'
} else if (result && typeof result === 'object' && result.applied && typeof result.applied === 'object') {
serverOutcome = 'failed'
}
} catch {
/* older/unavailable gateway — the local fallback remains saved */
}
// Re-stamp now that the server write settled: a roster snapshot fetched
// while profiles.configure was still in flight predates the new ui_meta
// just as surely as one fetched before the local write.
noteBotMetaWrite(key)
}
await localPersistence
return {
serverPersisted: serverOutcome === 'persisted',
serverOutcome
}
}
/** Migrate name-keyed appearance state only when the live registry proves
* there is one local source. A v1 name cannot identify a machine in a
* multi-source desktop, so the conservative result there is to retain v1 as
* rollback data and leave remote rows unpainted. */
function hydrateBotMeta(snapshot: BotMetaSnapshot, remap: Map<string, string> | null = null): BotMetaSnapshot {
const next = {
...snapshot
}
for (const [key, meta] of Object.entries($botMeta.get())) {
const target = remap?.get(key) || key
next[target] = {
...(next[target] || {}),
...meta
}
}
$botMeta.set(next)
return next
}
export async function migrateBotMeta(storage: BotMetaStorage | undefined = getPluginCtx()?.storage) {
let v1: BotMetaSnapshot | null = null
let v2: BotMetaSnapshot | null = null
let v2Committed = false
try {
;[v1, v2, v2Committed] = await Promise.all([
storage?.get?.(BOT_META_V1_KEY),
storage?.get?.(BOT_META_V2_KEY),
storage?.get?.(BOT_META_MIGRATION_KEY)
])
} catch {
return false
}
if (v2Committed === true && v2 && typeof v2 === 'object' && !Array.isArray(v2)) {
hydrateBotMeta(v2)
botMetaV2Active = true
return true
}
if (!v1 || typeof v1 !== 'object' || Array.isArray(v1) || typeof host.agents !== 'function') {
if (v1 && typeof v1 === 'object' && !Array.isArray(v1)) {
hydrateBotMeta(v1)
}
return false
}
let union
let routes
try {
union = await host.agents()
routes = typeof host.profileRoutes === 'function' ? await host.profileRoutes() : []
} catch {
hydrateBotMeta(v1)
return false
}
const sources = Array.isArray(union?.sources) ? union.sources : []
const localAgents = (union?.agents || []).filter(agent => agent?.connectionKind === 'local')
const soleLocal =
sources.length === 1
? sources[0]?.kind === 'local'
: sources.length === 0 &&
localAgents.length > 0 &&
(union?.agents || []).every(agent => agent?.connectionKind === 'local')
if (!soleLocal) {
hydrateBotMeta(v1)
return false
}
const migrated: BotMetaSnapshot = {}
const pendingLocalRoutes = new Map<string, ProfileRoute>()
for (const [name, meta] of Object.entries(v1)) {
const route =
(routes || []).find(candidate => candidate?.mode === 'local' && candidate?.profile === name) ||
(() => {
const agent = localAgents.find(candidate => candidate.profile === name)
return agent
? {
connectionId: agent.connectionId,
mode: 'local',
profile: name,
targetProfile: agent.targetProfile || name
}
: null
})()
if (!route?.connectionId) {
// A missing route makes the topology proof unusable for this key. Keep
// the v1 record intact rather than guessing a local/remote projection.
hydrateBotMeta(v1)
return false
}
const captured: ProfileRoute = {
connectionId: route.connectionId,
mode: 'local',
profile: name,
targetProfile: route.targetProfile || name
}
migrated[botRouteKey(captured)] = meta
pendingLocalRoutes.set(name, captured)
}
const remap = new Map([...pendingLocalRoutes].map(([name, route]) => [name, botRouteKey(route)]))
const hydrated = {
...migrated
}
for (const [key, meta] of Object.entries($botMeta.get())) {
const target = remap.get(key) || key
hydrated[target] = {
...(hydrated[target] || {}),
...meta
}
}
try {
await commitBotMetaV2(storage, hydrated)
} catch {
botMetaV2Active = false
hydrateBotMeta(v1)
return false
}
migratedLocalRoutes.clear()
for (const [name, route] of pendingLocalRoutes) {
migratedLocalRoutes.set(name, route)
}
hydrateBotMeta(hydrated)
botMetaV2Active = true
return true
}
// ── data ─────────────────────────────────────────────────────────────────────
/** True once profiles.list reports the backend injects the bot-to-bot
* protocol into the system prompt itself (hermes-agent bot_mode_probe).
* Gates every SOUL.md protocol append below. */
export let serverInjectsProtocol = false
/** A `profiles.list` answer as Bot Mode consumes it, plus the fields the
* multi-source merge and the roster query stamp on afterwards. Not in
* types.ts: this is the query-cache envelope around RosterRow, not a domain
* object. */
interface RosterSnapshot {
/** Newer backends inject the teammate protocol into the system prompt. */
bot_mode_protocol?: boolean
/** Time the request was ISSUED, the conservative bound mergeServerMeta wants. */
fetchedAt?: number
primaryConnectionId?: string
profiles?: RosterRow[]
sources?: GatewaySource[]
}
/** One row of the desktop-wide union agent roster (`host.agents`). */
interface UnionAgentRow {
connectionId?: string
connectionKind?: string
connectionLabel?: string
handle?: string
profile?: string
targetProfile?: string
}
/** The union roster payload. `primaryConnectionId` is served by Electron but
* absent from the SDK's `DesktopAgentRoster`, so the merge reads it here. */
interface UnionRoster {
agents?: UnionAgentRow[]
primaryConnectionId?: string
sources?: GatewaySource[]
}
export function useRoster() {
const activeConnectionId = useValue(host.state.connectionId)
return useQuery({
queryKey: [...ROSTER_KEY, activeConnectionId],
queryFn: async () => {
// Stamp the ISSUE time on the snapshot: mergeServerMeta compares it
// against each bot's last local meta write, and a fetch issued before
// a write can only carry pre-write ui_meta. (Issue time is the
// conservative bound — the server answered no earlier than this.)
const issuedAt = Date.now()
// Rich rows (last_session, canonical_session, ui_meta, has_avatar)
// come from the ACTIVE gateway's profiles.list — the canonical Bot
// Chat is resolved server-side by NAME (the "Bot Chat" registry row),
// so the roster never sends session pointers.
// Refresh the alias identity index alongside the roster: alias routes
// (Desktop profile → remote backend root) are what let a backend row
// keep its configured friendly identity after activation (#89131).
// Best-effort and feature-detected — a failed read keeps the last
// good index rather than dropping identities mid-session.
if (typeof host.profileRoutes === 'function') {
const epoch = beginAliasRouteIndex()
try {
indexAliasRoutes(await host.profileRoutes(), epoch)
} catch {
/* keep the previous alias index */
}
}
// Owner routing is ambient in the SDK now (post-#92731): requestForBot
// resolves the active owner itself, no captured route needed here.
const activeBot = {
name: String(host.state.profile?.get?.() || 'default').trim() || 'default'
}
const local = await requestForBot<RosterSnapshot>(activeBot, 'profiles.list', {})
// Newer backends inject the teammate-messaging protocol into every
// session's system prompt (agent.bot_mode_protocol) — SOUL.md must not
// carry a second copy. Older gateways lack the flag: keep appending.
serverInjectsProtocol = Boolean(local?.bot_mode_protocol)
// Multi-source desktops (hermes-agent #86875) also expose the union
// agent roster across every registered connection. Merge agents from
// OTHER sources in as additional rows. Feature-detected + best-effort:
// an older Desktop build (no host.agents) or a roster hiccup leaves
// the local list exactly as it was.
if (typeof host.agents === 'function') {
try {
const union = await host.agents()
const previous: RosterRow[] = $lastRoster.get().filter(row => !row?.ghost)
const merged = mergeMultiSourceRoster(local, union, activeConnectionId, previous)
const sources = Array.isArray(union?.sources) ? union.sources : []
return {
...merged,
profiles: (merged?.profiles || []).map(row => annotateBotSource(row, sources)),
sources,
fetchedAt: issuedAt
}
} catch {
/* older build or roster failure — single-source list stands */
}
}
return {
...(local && typeof local === 'object' ? local : {}),
fetchedAt: issuedAt
}
},
refetchInterval: 5000,
staleTime: 5000,
retry: ROSTER_QUERY_RETRY,
retryDelay: attempt => Math.min(15000, 1000 * 2 ** attempt)
})
}
/** Synchronous union-roster read for the composer surfaces (autocomplete
* provider + mention middleware). useRoster caches under
* [...ROSTER_KEY, activeConnectionId] — a 3-element key — so a bare
* getQueryData(ROSTER_KEY) exact-match lookup returns undefined forever
* (issue #89303: remote handles absent from @ autocomplete, mentions
* unrouted). Read the live connection's entry first, then fall back to a
* prefix scan keeping the freshest snapshot. Never throws: cold cache or
* legacy queryClient returns null and callers fall back to their own path. */
export function cachedUnionRoster(): RosterSnapshot | null {
if (typeof queryClient === 'undefined' || !queryClient || typeof queryClient.getQueryData !== 'function') {
return null
}
try {
const connectionId = String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || 'local')
const exact = queryClient.getQueryData<RosterSnapshot>([...ROSTER_KEY, connectionId])
if (Array.isArray(exact?.profiles)) {
return exact
}
if (typeof queryClient.getQueriesData === 'function') {
let best: RosterSnapshot | null = null
// v5 takes a filters object; a legacy v3 queryClient treats the same
// object as the key itself and simply matches nothing — harmless.
for (const [, data] of queryClient.getQueriesData<RosterSnapshot>({
queryKey: ROSTER_KEY
})) {
if (Array.isArray(data?.profiles) && (!best || Number(data.fetchedAt || 0) > Number(best.fetchedAt || 0))) {
best = data
}
}
return best
}
} catch {
/* cache hiccup — caller falls back (middleware refetches) */
}
return null
}
/** Merge the union agent roster (host.agents) over the active gateway's
* profiles.list. Active-source rows — matched by the LIVE connection id,
* falling back to the roster's primaryConnectionId, then the legacy
* kind==='local' rule on older desktops — are the agents profiles.list
* already returned: they only ANNOTATE the rich rows (handle, connection
* fields); rich fields stay authoritative and they are NOT duplicated.
* Rows from other sources become new roster entries tagged with their
* source label so BotRow can badge them, warm the captured agent, and route
* every open directly through that descriptor. Pure — exercised directly by
* the tests. */
function mergeMultiSourceRoster(
local: RosterSnapshot | null | undefined,
union: UnionRoster | null | undefined,
activeConnectionId?: null | string,
previous: RosterRow[] = []
): RosterSnapshot {
const localProfiles = Array.isArray(local?.profiles) ? local.profiles : []
const agents = Array.isArray(union?.agents) ? union.agents : []
// A live id of null/'' means the window is on the unscoped local backend
// (legacy hosts reported null for mode:'local'; the SDK now reports
// 'local'). Do NOT fall back to registry primary when the third argument
// was passed — primary can still say "spark" after the user clicked a
// local bot, which skipped every Spark row as "active" and invented a
// This-device shadow of default.
const liveProvided = arguments.length >= 3
const liveId = String(activeConnectionId || '').trim()
let activeId = liveId || (liveProvided ? '' : String(union?.primaryConnectionId || '').trim())
// Migrated remote-primary windows can still expose a legacy remote
// descriptor without connectionId. That produces a null live id even
// though profiles.list is answering from the registry primary. Infer the
// primary only when its inventory matches the rich rows and the local
// inventory does not. A genuinely local window has a matching local row,
// so it keeps the null-is-local behavior used after clicking This device.
if (!activeId && liveProvided) {
const primaryId = String(union?.primaryConnectionId || '').trim()
const richNames = new Set(localProfiles.map(profile => String(profile?.name || '').trim()).filter(Boolean))
const localMatches = agents.some(
agent => agent?.connectionKind === 'local' && richNames.has(String(agent?.profile || '').trim())
)
const primaryMatches = agents.some(
agent =>
String(agent?.connectionId || '').trim() === primaryId && richNames.has(String(agent?.profile || '').trim())
)
if (!localMatches && primaryId && primaryMatches) {
activeId = primaryId
}
}
const activeByName = new Map<string, RosterRow>()
// Treat the rich list as one row per active-source profile. Clone every
// row: some gateway clients reuse response objects, and annotating those in
// place made each five-second refresh feed the previous union back into the
// next merge, growing duplicate source rows indefinitely.
for (const profile of localProfiles) {
const name = String(profile?.name || '').trim()
if (!name || profile?.remoteSource) {
continue
}
if (profile?.sourceScoped && activeId && profile.connectionId !== activeId) {
continue
}
if (!activeByName.has(name)) {
activeByName.set(name, {
...profile,
name
})
}
}
const profiles = [...activeByName.values()]
// host.agents is an Electron/main-process capability. Defend the plugin
// boundary too: older shells or reconnect races can still hand us repeated
// identities even after the core roster deduplicates them.
const seenSources = new Set<string>()
for (const agent of agents) {
const profile = String(agent?.profile || '').trim()
const connectionId = String(agent?.connectionId || '').trim()
const sourceKey = `${connectionId}::${profile || 'default'}`
if (!profile || seenSources.has(sourceKey)) {
continue
}
seenSources.add(sourceKey)
// The union enumerates EVERY registered connection, including the active
// gateway that already answered profiles.list. Without this the active
// gateway's own agents (connectionKind 'remote' on a remote-primary
// desktop) would be appended as phantom duplicates — every bot listed
// twice. Older Electron builds predate the connection ids; fall back to
// the legacy local-source rule so single-source behavior stays intact.
const isActiveSource = activeId ? connectionId === activeId : agent.connectionKind === 'local'
const row = isActiveSource ? activeByName.get(profile) : null
if (row) {
// Annotate in place: the @name-device handle only differs from the
// bare name when the profile exists on several sources.
row.handle = agent.handle
row.connectionId = agent.connectionId
row.connectionKind = agent.connectionKind
row.connectionLabel = agent.connectionLabel
row.targetProfile = agent.targetProfile || profile
row.route = {
connectionId,
mode: agent.connectionKind === 'local' ? 'local' : 'remote',
profile,
targetProfile: agent.targetProfile || profile
}
row.sourceScoped = true
continue
}
if (isActiveSource) {
// Union saw an active-source profile profiles.list didn't return (older
// backend mid-refresh) — skip rather than invent a thin row.
continue
}
profiles.push({
name: profile,
handle: agent.handle,
connectionId,
connectionKind: agent.connectionKind,
connectionLabel: agent.connectionLabel,
targetProfile: agent.targetProfile || profile,
route: {
connectionId,
mode: agent.connectionKind === 'local' ? 'local' : 'remote',
profile,
targetProfile: agent.targetProfile || profile
},
remoteSource: true,
sourceScoped: true
})
}
// SSH sources drop to connect-on-demand the moment their tunnel is not
// the live gateway. Keep previously painted remote rows so clicking the
// local agent does not empty Bot Mode.
if (Array.isArray(previous) && previous.length > 0) {
const present = new Set(profiles.map(row => `${row.connectionId || ''}::${row.name}`))
const unionSourceIds = new Set(agents.map(agent => String(agent?.connectionId || '').trim()).filter(Boolean))
const omitted = new Set(
(Array.isArray(union?.sources) ? union.sources : [])
.filter(source => source?.error === 'connect-on-demand' || source?.reachable === false)
.map(source => String(source.connectionId || '').trim())
.filter(Boolean)
)
const registered = new Set(
(Array.isArray(union?.sources) ? union.sources : [])
.map(source => String(source?.connectionId || '').trim())
.filter(Boolean)
)
for (const row of previous) {
const connectionId = String(row?.connectionId || '').trim()
const name = String(row?.name || '').trim()
const key = `${connectionId}::${name || 'default'}`
if (!row?.remoteSource || !connectionId || !name || present.has(key)) {
continue
}
if (registered.size > 0 && !registered.has(connectionId)) {
continue
}
if (omitted.has(connectionId) || !unionSourceIds.has(connectionId)) {
profiles.push({
...row,
remoteSource: true,
sourceScoped: true
})
present.add(key)
}
}
}
return {
...local,
profiles
}
}
/** The @handle users tag a bot with. Multi-source rosters precompute the
* handle (bare name, or name-device when the profile exists on several
* registered sources) — prefer it when present. The primary profile's
* callable alias is 'hermes' — the mention middleware resolves it back to
* 'default' — so the word 'default' never surfaces in the UI. */
export function botHandle(name: string, bot?: Partial<RosterRow> | null): string {
if (bot?.handle && bot.handle !== name) {
return bot.handle
}
return (name || '').trim().toLowerCase() === 'default' ? 'hermes' : name
}
/** Taggable @-forms derived from a bot's friendly names — the core profile
* display name (`hermes profile rename`) and the Bot Mode title. Free text
* reduces to the mention charset two ways: slugified ("Research Buddy" →
* research-buddy, the form autocomplete inserts) and collapsed
* (researchbuddy). Reserved tokens are dropped so a bot renamed "Hermes"
* can never hijack the primary profile's @hermes alias. */
export function mentionNameForms(value: null | string | undefined): string[] {
const name = String(value || '')
.trim()
.toLowerCase()
if (!name) {
return []
}
const slug = name.replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
const collapsed = name.replace(/[^a-z0-9_-]+/g, '')
return [...new Set([slug, collapsed])].filter(
form => /^[a-z0-9][a-z0-9_-]*$/.test(form) && !['all', 'everyone', 'user', 'default', 'hermes'].includes(form)
)
}
/** Every friendly (renameable) name a roster row carries: the Bot Mode title
* (server-synced via ui_meta, locally stored, or persisted on a durable
* group descriptor) and the core profile display_name — in displayName's
* precedence order. Remote rows never borrow local meta (two `default`s
* must not share a title) — EXCEPT the connection-exact alias identity
* (#89131): a backend row claimed by a configured alias route carries the
* alias's friendly names, so @moxie keeps resolving after handoff. */
export function botFriendlyNames(bot: Partial<RosterRow> | null | undefined): Array<null | string | undefined> {
const metaByName: Record<string, BotMeta | undefined> | null = typeof $botMeta !== 'undefined' ? $botMeta.get() : null
const localTitle = !bot?.remoteSource ? metaByName?.[bot?.name!]?.title : null
const alias = aliasIdentityFor(bot)
const aliasTitle = alias
? alias.metaKeys.map(key => metaByName?.[key]?.title).find(title => typeof title === 'string' && title.trim()) ||
alias.name
: null
return [bot?.ui_meta?.['hermes-bots']?.title, localTitle, aliasTitle, bot?.title, bot?.display_name]
}
/** The tag autocomplete inserts for a bot: the renamed (friendly) slug when
* the user gave the bot a real name, otherwise the profile @handle. The
* resolvers accept both, so older muscle memory keeps working. */
export function botMentionTag(bot: GroupMember | RosterRow): string {
for (const friendly of botFriendlyNames(bot)) {
const forms = mentionNameForms(friendly)
if (forms.length) {
return forms[0]
}
}
return botHandle(bot?.name, bot)
}
/** Who a roster row is being compared against: a profile name plus the
* registry connection it lives on. Covers the focused chat's owner, the live
* gateway pair, and the bare `{ name }` the mention provider builds. */
interface RosterOwnerRef {
connectionId?: null | string
name?: null | string
}
export function isActiveRosterBot(
bot: Partial<RosterRow> | null | undefined,
active: RosterOwnerRef | null | undefined
) {
if (!active) {
return false
}
const activeName = String(active.name || 'default').trim() || 'default'
const activeId = String(active?.connectionId || '').trim()
const botId = String(bot?.connectionId || '').trim()
const botName = String(bot?.name || '').trim() || 'default'
if (bot?.remoteSource) {
return Boolean(activeId) && activeId === botId && botName === activeName
}
if (activeId && activeId !== 'local' && botId && activeId !== botId) {
return false
}
return botName === activeName
}
/** The key a bot is selected/badged/watermarked under. `undefined` is
* load-bearing on the nullable overload — callers use it to CLEAR a
* selection, so it must never be coerced to a 'default' fallback the way
* botRosterKey does. A real row always yields a key (`name` is required). */
/* eslint-disable no-redeclare -- overload signatures; the rule predates TS */
export function botSelectionKey(bot: RosterRow): string
export function botSelectionKey(bot: Partial<RosterRow> | null | undefined): string | undefined
export function botSelectionKey(bot: Partial<RosterRow> | null | undefined): string | undefined {
return bot?.sourceScoped || bot?.remoteSource ? botRosterKey(bot) : bot?.name
}
/* eslint-enable no-redeclare */
export function isDefaultBot(bot: Partial<RosterRow> | null | undefined): boolean {
const route = botConnectionRoute(bot)
return (
String(route?.profile || bot?.name || '')
.trim()
.toLowerCase() === 'default'
)
}
export function newBotChat(bot: RosterRow) {
if (typeof host.newChat !== 'function') {
host.notify?.({
kind: 'error',
message:
getPluginCtx()?.i18n?.t('bot.openAnotherChatUnsupported') ?? 'Update Hermes Desktop to open another Bot chat.'
})
return
}
const route = botConnectionRoute(bot)
if (!route) {
host.notify?.({
kind: 'error',
message:
getPluginCtx()?.i18n?.t('bot.openAnotherChatUnsupported') ?? 'Update Hermes Desktop to open another Bot chat.'
})
return
}
const ownerKey = botWorkspaceOwnerKey(bot)
setBotsWorkspaceOwner(ownerKey, bot)
host.newChat(route, {
workspaceMode: 'bots',
workspaceOwnerKey: ownerKey
})
}
/** Resolve @handles in prose against the Bot Mode roster (local + Connections).
* Skips the bot already speaking in this chat. Unique bare names match;
* duplicate names require the @name-device handle. */
export function resolveRosterMentions(
text: null | string | undefined,
roster: RosterRow[] | null | undefined,
active: RosterOwnerRef = {}
): RosterRow[] {
const members = Array.isArray(roster) ? roster : []
const prose = String(text || '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`[^`\n]*`/g, ' ')
const byForm = new Map<string, RosterRow | null>()
for (const bot of members) {
if (!bot?.name || isActiveRosterBot(bot, active)) {
continue
}
const handle = String(botHandle(bot.name, bot) || '').toLowerCase()
const name = String(bot.name || '').toLowerCase()
const forms = new Set([handle, name])
if (bot.handle) {
forms.add(String(bot.handle).toLowerCase())
}
// Renamed bots are taggable by their friendly names too — the core
// profile display_name and the Bot Mode title (issue: renaming a bot
// didn't change what you @-tag it with).
for (const friendly of botFriendlyNames(bot)) {
for (const form of mentionNameForms(friendly)) {
forms.add(form)
}
}
for (const form of forms) {
if (!form) {
continue
}
const existing = byForm.get(form)
if (existing && existing !== bot) {
byForm.set(form, null)
continue
}
if (!existing) {
byForm.set(form, bot)
}
}
}
const mentioned: RosterRow[] = []
const seen = new Set<string>()
for (const match of prose.matchAll(/(^|\s)@([a-z0-9][a-z0-9_-]*)/gi)) {
let token = match[2].toLowerCase()
if (token === 'hermes') {
token = byForm.has('hermes') ? 'hermes' : token
}
const bot = byForm.get(token)
if (!bot) {
continue
}
const key = botRosterKey(bot)
if (seen.has(key)) {
continue
}
seen.add(key)
mentioned.push(bot)
}
return mentioned
}
/** Source-qualified identity for a roster row — the React list key AND the
* cross-surface roster identity. Names alone are NOT unique in a
* multi-source roster (two connections can both expose 'default');
* duplicate keys make React reconciliation repeat whole blocks of the list
* on every poll repaint (the Aug 2026 dupe-bots smear). */
export function botRosterKey(bot: Partial<RosterRow> | null | undefined): string {
return `${bot?.connectionId || 'legacy'}::${bot?.name || 'default'}`
}
/** The key a bot's persisted appearance/meta lives under. Same nullable
* contract as botSelectionKey: a real row always resolves to a key, a
* partial/absent one may not. */
/* eslint-disable no-redeclare -- overload signatures; the rule predates TS */
export function botMetaKey(bot: RosterRow): string
export function botMetaKey(bot: Partial<RosterRow> | null | undefined): string | undefined
export function botMetaKey(bot: Partial<RosterRow> | null | undefined): string | undefined {
const route = botConnectionRoute(bot)
return route ? botRouteKey(route) : bot?.name
}
/* eslint-enable no-redeclare */
export function persistBotMetaSnapshot(value: Record<string, BotMeta>, scoped = false): Promise<void> {
try {
const persisted = scoped
? commitBotMetaV2(getPluginCtx()?.storage, value)
: Promise.resolve(getPluginCtx()?.storage?.set?.(BOT_META_V1_KEY, value))
return persisted.catch(() => undefined)
} catch {
return Promise.resolve()
}
}
export function sourceByConnection(sources: GatewaySource[] | null | undefined): Map<string, GatewaySource> {
return new Map(
(Array.isArray(sources) ? sources : [])
.filter(source => source?.connectionId)
.map(source => [String(source.connectionId), source])
)
}
/** Copy current source health onto a row without changing its owner. */
export function annotateBotSource(bot: RosterRow, sources: GatewaySource[] | null | undefined): RosterRow {
const id = String(bot?.connectionId || '').trim()
if (!id) {
return bot
}
const list = Array.isArray(sources) ? sources : []
const source = sourceByConnection(list).get(id)
if (!source) {
return list.length && bot?.sourceScoped
? {
...bot,
sourceMissing: true,
sourceReachable: false
}
: bot
}
return {
...bot,
connectionKind: bot.connectionKind || source.kind,
connectionLabel: bot.connectionLabel || source.label,
sourceError: source.error || null,
sourceMissing: false,
sourceReachable: source.reachable
}
}
/** The source-health fields botSourceStatus reads. Both a full RosterRow and
* the reduced GroupMember satisfy it, and so do the ad-hoc literals the
* group/connection headers build. */
interface BotSourceFields {
ghost?: boolean
name?: string
sourceError?: null | string
sourceMissing?: boolean
sourceReachable?: boolean | null
}
/** How a row's owning gateway is doing, as the roster paints it. */
interface BotSourceStatus {
available: boolean
key: 'missing' | 'on-demand' | 'ready' | 'unavailable' | 'unknown'
label: string
tone: 'bad' | 'good' | 'muted' | 'warn'
}
/** Called from plain functions rather than components, so there is no `useBots`
* to lean on and the ctx may not be installed yet — the English text is the
* floor, not the intended reading. Guard `i18n` as well as the ctx: this runs
* on every roster row, and a throw here paints an empty rail. */
function sourceLabel(key: string, fallback: string): string {
return getPluginCtx()?.i18n?.t(`roster.${key}`) ?? fallback
}
export function botSourceStatus(bot: BotSourceFields | null | undefined): BotSourceStatus {
const error = String(bot?.sourceError || '').trim()
if (bot?.sourceMissing) {
return {
available: false,
key: 'missing',
label: sourceLabel('gatewayRemoved', 'Gateway removed'),
tone: 'bad'
}
}
if (error === 'connect-on-demand') {
return {
available: true,
key: 'on-demand',
label: sourceLabel('onDemand', 'On demand'),
tone: 'muted'
}
}
if (error || bot?.sourceReachable === false) {
return {
available: false,
key: 'unavailable',
label: sourceLabel('unavailable', 'Unavailable'),
tone: 'warn'
}
}
if (bot?.sourceReachable === true) {
return {
available: true,
key: 'ready',
label: sourceLabel('ready', 'Ready'),
tone: 'good'
}
}
return {
available: true,
key: 'unknown',
label: sourceLabel('statusUnknown', 'Status unknown'),
tone: 'muted'
}
}
/** Drop unreachable same-name copies from the top-level agent list.
*
* Group rooms persist source-qualified members. After Desktop moves to the
* built-in This-device source, the old loopback row (127.0.0.1:port, not
* listening) still sits next to the live profile and looks like a second
* agent (#92286). Routing identities stay intact: this only filters sidebar
* tiles. $lastRoster, group members, and @-mentions still see every
* (connectionId, profile) row.
*
* Ghosts stay: a selected-but-offline owner must remain visible rather than
* being replaced by a same-named twin on another gateway. A name with no
* reachable copy is kept, so a genuinely down source still has a row. */
export function preferReachableSameNameRows(bots: RosterRow[] | null | undefined): RosterRow[] {
const rows = Array.isArray(bots) ? bots : []
const reachableNames = new Set<string>()
for (const bot of rows) {
if (botSourceStatus(bot).available) {
reachableNames.add(bot?.name)
}
}
return rows.filter(bot => botSourceStatus(bot).available || bot?.ghost || !reachableNames.has(bot?.name))
}
/** Filter by the two stable identities rendered in every roster row: the
* customizable display name and the profile's @handle. Keep the current
* activity order — search narrows the roster, it never re-ranks it. */
export function filterBots(roster: RosterRow[], metaByName: Record<string, BotMeta>, query: string) {
const needle = query.trim().toLowerCase().replace(/^@/, '')
if (!needle) {
return roster
}
return roster.filter(bot => {
const meta = botRosterMeta(bot, metaByName)
const display = displayName(bot, meta).toLowerCase()
const profile = (bot.name || '').toLowerCase()
const handle = botHandle(bot.name, bot).toLowerCase()
// Multi-source rows also match on their device name ("homelab" finds
// every bot living on the Homelab connection).
const sourceLabel = (bot.connectionLabel || '').toLowerCase()
const role = `${meta?.description || ''} ${bot.description || ''}`.toLowerCase()
const preview = String(botActivitySession(bot)?.preview || '').toLowerCase()
return (
display.includes(needle) ||
profile.includes(needle) ||
handle.includes(needle) ||
sourceLabel.includes(needle) ||
role.includes(needle) ||
preview.includes(needle)
)
})
}
/** The session whose activity best represents this bot — the FRESHER of the
* canonical Bot Chat (canonical_session, the profile's "Bot Chat" registry
* row resolved server-side by name) and the profile's newest visible
* conversation (last_session).
*
* Canonical Bot Chats are hidden from the session list by design, so
* last_session alone never sees them: a bot you talk to all day through its
* Bot Chat reads "6d ago" because its newest VISIBLE session is a week old.
* Every activity signal (age label, pulse dot, unread watermark, recency
* sort) keys off this helper. Older gateways without the canonical_session
* field degrade to last_session unchanged. */
export function botActivitySession(bot: null | RosterRow | undefined): CanonicalSession | SessionPreview | null {
const preferred = bot?.canonical_session
const last = bot?.last_session
if (!preferred || !last) {
return preferred || last || null
}
return (preferred.last_active || 0) >= (last.last_active || 0) ? preferred : last
}