773 lines
32 KiB
TypeScript
773 lines
32 KiB
TypeScript
/**
|
|
* Hermes Bot Mode — a "one chat per agent" roster for the Hermes desktop.
|
|
*
|
|
* Left pane "Bots": one row per Hermes profile (a bot = an agent profile) with
|
|
* a customizable avatar (shape + color + eyes, image, or pet). Click opens that
|
|
* bot's chat; right-click → Edit Profile (avatar, title, description).
|
|
* "New Bot" creates a profile — Name / Title / Description with an
|
|
* "Advanced" disclosure for full profile config.
|
|
*
|
|
* Right tile "Routines": scheduled tasks (Hermes cron jobs) scoped to the
|
|
* bot you're currently chatting with — follows the live gateway profile.
|
|
*
|
|
* Bots message each other straight into each bot's ONE canonical "Bot
|
|
* Chat" — @-mentions deliver over gateway RPCs (no CLI relay), and
|
|
* bot-initiated sends use `hermes -p <bot> chat --in ~ -c "Bot Chat"`.
|
|
*/
|
|
|
|
import { CHAT_EMPTY_AREA, COMPOSER_AREAS, host, PALETTE_AREA, translateNow } from '@hermes/plugin-sdk'
|
|
import type { ChatEmptyProps, PluginContext } from '@hermes/plugin-sdk'
|
|
|
|
import { startFaceClock, stopFaceClock } from './avatar'
|
|
import {
|
|
$botChatFocused,
|
|
$botsPaneVisible,
|
|
$focusedBotOwner,
|
|
$openBotChat,
|
|
$selectedBot,
|
|
$selectedRosterHydrated,
|
|
$selectedRosterKey,
|
|
focusedMentionProfile
|
|
} from './bot-state'
|
|
import { isCanonicalChatOnScreen, openBotCanonicalChat } from './canonical-chat'
|
|
import { BotChatEmpty } from './chat-empty'
|
|
import { bindProfileSync, RoutinesPane } from './cron'
|
|
import {
|
|
$botMeta,
|
|
$lastRoster,
|
|
botHandle,
|
|
botMentionTag,
|
|
cachedUnionRoster,
|
|
isActiveRosterBot,
|
|
migrateBotMeta,
|
|
resolveRosterMentions
|
|
} from './data'
|
|
import {
|
|
$groupChats,
|
|
$groupChatWorkspace,
|
|
assignLegacyThreads,
|
|
handleSessionsGatewayTransition,
|
|
pullGroupChatServerState,
|
|
scheduleGroupChatServerSync,
|
|
setGroupChatSyncDisposed,
|
|
stopGroupChatServerSync,
|
|
sweepGroupChatMembersForRemovedConnection,
|
|
updateGroupChat
|
|
} from './group-chat'
|
|
import { groupWorkspaceOwnerKey } from './group-membership'
|
|
import { annotateOrphanedGroupChatMembers } from './hygiene'
|
|
import { BOTS_LOCALES } from './i18n'
|
|
import { displayName } from './labels'
|
|
import { startBotRelay, stopBotRelay } from './relay'
|
|
import { $activityToasts } from './roster-actions'
|
|
import {
|
|
botChatOwnsWorkspace,
|
|
BotsPane,
|
|
releaseStaleOpenBotChat,
|
|
selectedRosterBot,
|
|
sessionOwnsWorkspace
|
|
} from './roster-pane'
|
|
import { botRosterMeta, botWorkspaceOwnerKey, setBotsWorkspaceOwner } from './routing'
|
|
import { startHideSweepScheduler } from './session-sweep'
|
|
import { bumpBotOpenGeneration, getBotOpenGeneration, ID, setPluginCtx } from './shared'
|
|
import type { GroupChat, RosterRow } from './types'
|
|
import { loadBotSections } from './user-sections'
|
|
|
|
// ── plugin ───────────────────────────────────────────────────────────────────
|
|
|
|
/** One row the composer's `@` popover renders from the roster. */
|
|
interface MentionCompletionItem {
|
|
display: string
|
|
insert: string
|
|
meta: string
|
|
}
|
|
|
|
/** The draft a `composer.middleware` handler rewrites, passes through, or
|
|
* cancels (this plugin never cancels). */
|
|
interface ComposerDraftPayload {
|
|
attachments?: unknown[]
|
|
text: string
|
|
}
|
|
|
|
export default {
|
|
id: ID,
|
|
name: 'Bots',
|
|
description:
|
|
'Bot Mode — a one-chat-per-agent roster with avatars, routines, group chats, and bot-to-bot messaging. Ships with the app; disable here if unwanted.',
|
|
register(ctx: PluginContext) {
|
|
setPluginCtx(ctx)
|
|
// The user's own roster sections. Read once at register; every mutation
|
|
// writes through.
|
|
loadBotSections()
|
|
const disposeLocales = ctx.i18n.register(BOTS_LOCALES)
|
|
setGroupChatSyncDisposed(false)
|
|
startFaceClock()
|
|
// The cross-connection relay rides every gateway socket this Desktop
|
|
// holds: roster sync + envelope drain/deliver/reply loops.
|
|
startBotRelay()
|
|
|
|
// Disabling the plugin (or a hot reload) must actually stop the clock —
|
|
// before this, the rAF loop + 1Hz document scan ran until app restart.
|
|
if (typeof ctx.onDispose === 'function') {
|
|
ctx.onDispose(disposeLocales)
|
|
ctx.onDispose(stopFaceClock)
|
|
ctx.onDispose(stopBotRelay)
|
|
}
|
|
|
|
// @-mention autocomplete: typing "@rese…" in ANY composer offers the
|
|
// roster's handles (issue #88060). Reads the roster straight from the
|
|
// query cache — useRoster keeps it ≤5s stale and the popover must answer
|
|
// synchronously per keystroke. Multi-source rosters contribute their
|
|
// precomputed @name-device handles via botHandle. The active profile is
|
|
// excluded (a bot doesn't @ itself); 'default' surfaces as @hermes.
|
|
ctx.register({
|
|
id: 'mention-completions',
|
|
area: COMPOSER_AREAS.atCompletions,
|
|
data: {
|
|
provide: (query: string): MentionCompletionItem[] => {
|
|
const roster = cachedUnionRoster()
|
|
const profiles = Array.isArray(roster?.profiles) ? roster.profiles : []
|
|
|
|
if (!profiles.length) {
|
|
return []
|
|
}
|
|
|
|
const active = focusedMentionProfile()
|
|
const q = (query || '').toLowerCase()
|
|
const items: MentionCompletionItem[] = []
|
|
|
|
const live = {
|
|
name: active,
|
|
connectionId: String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || 'local')
|
|
}
|
|
|
|
for (const profile of profiles) {
|
|
if (!profile?.name || isActiveRosterBot(profile, live)) {
|
|
continue
|
|
}
|
|
|
|
const handle = botHandle(profile.name, profile)
|
|
const display = displayName(profile, $botMeta.get()[profile.name])
|
|
// Renamed bots complete on their friendly name — the tag is the
|
|
// renamed slug when one exists, the profile handle otherwise.
|
|
const tag = botMentionTag(profile)
|
|
|
|
if (
|
|
q &&
|
|
!tag.toLowerCase().startsWith(q) &&
|
|
!handle.toLowerCase().startsWith(q) &&
|
|
!display.toLowerCase().startsWith(q)
|
|
) {
|
|
continue
|
|
}
|
|
|
|
const source = profile.connectionLabel ? ` · ${profile.connectionLabel}` : ''
|
|
items.push({
|
|
insert: `@${tag}`,
|
|
display: `@${tag}`,
|
|
meta: `Bot · ${display}${source}`
|
|
})
|
|
}
|
|
|
|
return items.slice(0, 8)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Hydrate persisted avatars/titles. Migration writes v2 only after a
|
|
// provable sole-local topology and deliberately leaves v1 untouched for
|
|
// one-version rollback.
|
|
void migrateBotMeta(ctx.storage).catch(() => undefined)
|
|
|
|
// The last selected bot, source-qualified. Restoring it is PRESENTATION
|
|
// ONLY: it paints the roster highlight, and never activates a gateway,
|
|
// opens a chat, or creates a session. The hydrated flag must flip on
|
|
// every settle path — the roster holds a loading state until it does,
|
|
// and a storage quirk must not strand it there.
|
|
try {
|
|
// TODO(bot-mode-types): PluginStorage.get(key, fallback) requires the fallback; every
|
|
// Bot Mode read omits it (works at runtime, undefined fallback) — same at the two reads below.
|
|
// @ts-expect-error typed as written rather than changing the call.
|
|
Promise.resolve(ctx.storage?.get?.('selected-roster-bot-v1'))
|
|
.then(value => {
|
|
if (typeof value === 'string' && value.trim()) {
|
|
$selectedRosterKey.set(value.trim())
|
|
}
|
|
})
|
|
.catch(() => undefined)
|
|
.finally(() => $selectedRosterHydrated.set(true))
|
|
} catch {
|
|
/* no storage — this window starts with no restored selection */
|
|
$selectedRosterHydrated.set(true)
|
|
}
|
|
|
|
// Bot Mode sessions are always hidden now — the old "hide Bot Chats"
|
|
// pref is gone (its stored key is simply ignored). The reconciliation
|
|
// sweep below hides any rows born visible under the old pref.
|
|
|
|
// Hydrate the activity-toast pref (default OFF).
|
|
try {
|
|
// @ts-expect-error TODO(bot-mode-types): PluginStorage.get requires a fallback argument.
|
|
Promise.resolve(ctx.storage?.get?.('activity-toasts'))
|
|
.then(value => {
|
|
if (typeof value === 'boolean') {
|
|
$activityToasts.set(value)
|
|
}
|
|
})
|
|
.catch(() => undefined)
|
|
} catch {
|
|
/* no storage — default (silent) stays */
|
|
}
|
|
|
|
// Hydrate persisted group-chat room logs (epoch/running are runtime-only
|
|
// and always reset — a loop can't survive a window reload anyway).
|
|
try {
|
|
// @ts-expect-error TODO(bot-mode-types): PluginStorage.get requires a fallback argument.
|
|
Promise.resolve(ctx.storage?.get?.('group-chats'))
|
|
.then(async value => {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
const rooms: Record<string, GroupChat> = {}
|
|
|
|
for (const [name, room] of Object.entries(value)) {
|
|
if (room && Array.isArray(room.log)) {
|
|
rooms[name] = {
|
|
// Pre-thread entries get synthetic thread ids on hydrate so
|
|
// every UI/engine path can assume entry.thread exists.
|
|
log: assignLegacyThreads(room.log),
|
|
watermarks: room.watermarks && typeof room.watermarks === 'object' ? room.watermarks : {},
|
|
sessions: room.sessions && typeof room.sessions === 'object' ? room.sessions : {},
|
|
sessionOwners: room.sessionOwners && typeof room.sessionOwners === 'object' ? room.sessionOwners : {},
|
|
stranded: room.stranded && typeof room.stranded === 'object' ? room.stranded : {},
|
|
// #93129: rehydrate sticky stop holds with the same shape
|
|
// guard as the other maps — a held bot stays held across
|
|
// window restarts until explicitly released.
|
|
holds: room.holds && typeof room.holds === 'object' ? room.holds : {},
|
|
members: Array.isArray(room.members) ? room.members : [],
|
|
roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null,
|
|
image: typeof room.image === 'string' && room.image ? room.image : null,
|
|
syncRevision: Math.max(0, Number(room.syncRevision || 0)),
|
|
epoch: 0,
|
|
running: false
|
|
}
|
|
}
|
|
}
|
|
|
|
$groupChats.set({
|
|
...rooms,
|
|
...$groupChats.get()
|
|
})
|
|
|
|
// #93492: annotate rows orphaned before this build (their
|
|
// connection was deleted while an older Desktop ran, so no
|
|
// 'removed' push ever swept them). Registry read is
|
|
// feature-detected; when unavailable only the unresolvable-route
|
|
// shape (lost connectionId) is annotated.
|
|
try {
|
|
const registry =
|
|
typeof window !== 'undefined'
|
|
? await Promise.resolve(window.hermesDesktop?.connections?.list?.()).catch(() => null)
|
|
: null
|
|
|
|
const liveIds = Array.isArray(registry?.connections)
|
|
? new Set(registry.connections.map(connection => String(connection?.id || '').trim()).filter(Boolean))
|
|
: null
|
|
|
|
const annotated = annotateOrphanedGroupChatMembers($groupChats.get(), liveIds)
|
|
|
|
if (annotated.changed) {
|
|
// Per-room updateGroupChat keeps the durable record's full
|
|
// shape (sessionOwners, holds) in storage; sync:false —
|
|
// the scheduleGroupChatServerSync below publishes once.
|
|
for (const [roomName, room] of Object.entries(annotated.rooms)) {
|
|
if (room !== $groupChats.get()[roomName]) {
|
|
updateGroupChat(roomName, () => room, {
|
|
sync: false
|
|
})
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
/* registry unavailable — the lost-connectionId shape is still safe to render */
|
|
}
|
|
}
|
|
|
|
// Receive before publish. A fresh Desktop with no local room cache
|
|
// must hydrate the gateway projection instead of merely avoiding an
|
|
// empty overwrite and then rendering an empty conversation.
|
|
await pullGroupChatServerState().catch(() => false)
|
|
scheduleGroupChatServerSync($groupChats.get())
|
|
})
|
|
.catch(() => undefined)
|
|
} catch {
|
|
/* no storage — rooms start empty */
|
|
}
|
|
|
|
// Routines follow the chat you're in: track the focused chat's owner
|
|
// profile (falls back to the live gateway profile on older desktops —
|
|
// see $focusedBotProfile). Keying this off the socket's home alone left
|
|
// the unread-suppression and Routines scope on the wrong bot whenever a
|
|
// focused tab showed another profile's chat.
|
|
// Capture the unbinds: without them a disable → re-enable cycle stacks a
|
|
// duplicate listener per cycle (same survives-disable class as the face
|
|
// clock before its onDispose hook — these kept firing until app restart).
|
|
const unbindProfileListener = bindProfileSync($focusedBotOwner)
|
|
const unbindGatewayListener = host.state.gateway.listen(handleSessionsGatewayTransition)
|
|
|
|
// #93492 root fix: the registry pushes a lifecycle event when a
|
|
// connection is removed. The gateway store already disposes the dead
|
|
// sockets; the persisted group-chat rosters referencing that connection
|
|
// were never touched, which is what left panes throwing "Bot X has no
|
|
// connection owner" forever. Annotate (never silently delete) those
|
|
// member rows the moment the connection goes away. Feature-detected:
|
|
// older Electron mains don't emit it, and bare vm test harnesses have
|
|
// no window global.
|
|
let unbindConnectionsChanged: null | (() => void) = null
|
|
|
|
try {
|
|
if (typeof window !== 'undefined') {
|
|
unbindConnectionsChanged =
|
|
window.hermesDesktop?.connections?.onChanged?.(payload => {
|
|
if (payload?.reason === 'removed') {
|
|
sweepGroupChatMembersForRemovedConnection(payload.connectionId)
|
|
}
|
|
}) || null
|
|
}
|
|
} catch {
|
|
/* registry lifecycle push unavailable — hydrate-time annotate still covers it */
|
|
}
|
|
|
|
if (typeof ctx.onDispose === 'function') {
|
|
ctx.onDispose(() => {
|
|
stopGroupChatServerSync()
|
|
|
|
if (typeof unbindProfileListener === 'function') {
|
|
unbindProfileListener()
|
|
}
|
|
|
|
if (typeof unbindGatewayListener === 'function') {
|
|
unbindGatewayListener()
|
|
}
|
|
|
|
if (typeof unbindConnectionsChanged === 'function') {
|
|
unbindConnectionsChanged()
|
|
}
|
|
})
|
|
}
|
|
|
|
// Reconciliation sweep: hide every Bot Mode session we know about, on
|
|
// load and again on each reconnect (a swap can land on a gateway whose
|
|
// rows were created before the always-hidden policy). Deferred a tick so
|
|
// the meta/room storage hydrates above have landed; idempotent after that.
|
|
// (Feature-guarded: bare vm test harnesses have no setTimeout global.)
|
|
startHideSweepScheduler(ctx)
|
|
ctx.register({
|
|
id: 'pane',
|
|
area: 'panes',
|
|
title: translateNow('zones.bots'),
|
|
// dock: explicit adoption gesture — CENTER-STACK into the sessions zone
|
|
// so the sidebar grows a SESSIONS | BOTS tab strip instead of splitting
|
|
// two cramped panes down the column. Center is safe now: insertAtGroup
|
|
// pins the zone's header explicitly shown on a center gain (and it
|
|
// stays shown once the zone has stacked), so the sessions pane can
|
|
// never vanish behind a stripless Bots tab — the lone-pane auto-hide
|
|
// trap this dock used to work around with a 'bottom' split.
|
|
// enforce: standing invariant, not a one-shot migration — the pane
|
|
// re-homes into the sessions strip at EVERY boot it isn't already
|
|
// there, whatever tokens or user placement an older install persisted.
|
|
// The one-time heal ('sessions-tab-v1') burned its token even when its
|
|
// guards skipped the move, so exactly the users who had fought the old
|
|
// stacked layout (dragged panes → $userPlacedPanes) stayed stacked
|
|
// forever. Owner's order: SESSIONS | BOTS is always a tab strip.
|
|
// An intra-session drag still sticks until the next launch (the
|
|
// invariant runs at adoption time only — see enforceDockedPanes in the
|
|
// tree store).
|
|
// collapsible: the pane lives in the sessions zone, so it must LEAVE
|
|
// the grid with that zone below the sidebar-collapse breakpoint. The
|
|
// sessions pane collapses alone without this flag. The zone then keeps
|
|
// a stranded BOTS tab on screen. The narrow edge overlay mirrors the
|
|
// zone's tab strip, so the pane stays reachable while collapsed.
|
|
data: {
|
|
placement: 'left',
|
|
tabTitle: () => translateNow('zones.bots'),
|
|
width: '260px',
|
|
collapsible: true,
|
|
hideOnly: true,
|
|
dock: {
|
|
pane: 'sessions',
|
|
pos: 'center',
|
|
enforce: true
|
|
}
|
|
},
|
|
render: () => <BotsPane />
|
|
})
|
|
|
|
// Routines — its OWN tiling pane splitting the workspace's right edge
|
|
// (NOT the collapsible right sidebar; placement 'right' is that sidebar's
|
|
// role and hides the pane until "Show Right Sidebar").
|
|
//
|
|
// Registered ONLY while Bot Mode is on screen: the pane exists while the
|
|
// Bots pane is visible (its zone's active tab, or a lone pane in a
|
|
// stacked pre-heal layout) and unregisters when the user tabs back to
|
|
// Sessions — no Cronjobs tile squatting beside the chat outside Bot Mode.
|
|
// `ctx.register` returns the disposer that makes this cheap; the tree
|
|
// keeps the pane's spot, so re-registering re-adopts it where it was.
|
|
// host.paneVisibility is feature-detected: older desktops without the SDK
|
|
// export keep the always-registered behavior.
|
|
const registerRoutinesPane = () =>
|
|
ctx.register({
|
|
id: 'routines',
|
|
area: 'panes',
|
|
// The app's noun for these, so the tab agrees with the pane header and
|
|
// with the core Scheduled jobs surface. `translateNow`, not `useI18n`:
|
|
// a pane title is read at registration, outside React.
|
|
title: translateNow('cron.title'),
|
|
data: {
|
|
placement: 'main',
|
|
// Repair persisted layouts that stranded Cronjobs in the Bots tab strip.
|
|
dock: {
|
|
pane: 'workspace',
|
|
pos: 'right',
|
|
enforce: true
|
|
},
|
|
// A bot's schedule is glanceable, not something you sit in — it
|
|
// arrives as the right edge's vertical tab and takes no width off the
|
|
// chat until the user opens it.
|
|
defaultCollapsed: true,
|
|
width: '250px'
|
|
},
|
|
render: () => <RoutinesPane />
|
|
})
|
|
|
|
if (typeof host.paneVisibility === 'function') {
|
|
// The contribution-scoped pane id (`register` prefixes `${ID}:`).
|
|
const $sidebarVisible = host.paneVisibility(`${ID}:pane`)
|
|
let unregisterRoutines: null | (() => void) = null
|
|
|
|
const syncRoutinesPane = () => {
|
|
if (botChatOwnsWorkspace()) {
|
|
unregisterRoutines ??= registerRoutinesPane()
|
|
} else if (unregisterRoutines) {
|
|
// Clicking the Cronjobs tile moves focus onto the tile itself, which
|
|
// drops bot-chat workspace ownership for a beat. While Bot Mode is
|
|
// still on screen and the tile is the one holding focus, keep it —
|
|
// a pane must never unregister itself out from under its own click.
|
|
// Leaving Bot Mode ($botsPaneVisible false) still unregisters.
|
|
const $self = host.paneVisibility(`${ID}:routines`)
|
|
|
|
if ($botsPaneVisible.get() && $self && typeof $self.get === 'function' && $self.get()) {
|
|
return
|
|
}
|
|
|
|
unregisterRoutines()
|
|
unregisterRoutines = null
|
|
}
|
|
}
|
|
|
|
const stopSidebarSync = $sidebarVisible.listen(visible => {
|
|
$botsPaneVisible.set(Boolean(visible))
|
|
|
|
if (visible) {
|
|
const group = $groupChatWorkspace.get()
|
|
const selected = selectedRosterBot($lastRoster.get(), $selectedRosterKey.get())
|
|
|
|
// Owner routing only. With neither a group nor a selected bot there
|
|
// is no bot-owned surface to scope, so leave the workspace on
|
|
// whatever the user was already looking at — the roster-hydration
|
|
// effect in BotsPane scopes it the moment a real owner exists.
|
|
//
|
|
// This is where the Bots home used to claim the center and refuse a
|
|
// new chat with "Select a Bot or group first." Deleting the home
|
|
// deleted the dead end it was apologizing for: with nothing selected
|
|
// the center is an ordinary session and `+` works there. The refusal
|
|
// itself still stands for the cases that remain — a group room, and
|
|
// a selected row too orphaned to route (setBotsWorkspaceOwner's
|
|
// blocked target).
|
|
if (group) {
|
|
setBotsWorkspaceOwner(
|
|
groupWorkspaceOwnerKey(group),
|
|
null,
|
|
'New group conversations start in the group composer.'
|
|
)
|
|
} else if (selected) {
|
|
setBotsWorkspaceOwner(botWorkspaceOwnerKey(selected), selected)
|
|
}
|
|
} else {
|
|
// Strand any owner wake still dialing. Its SDK open will fail the
|
|
// workspace token too; this plugin generation prevents that expected
|
|
// cancellation from showing an error after the user deliberately
|
|
// returned to Sessions.
|
|
bumpBotOpenGeneration()
|
|
host.setWorkspaceScope?.('sessions')
|
|
}
|
|
|
|
syncRoutinesPane()
|
|
})
|
|
|
|
const stopGroupSync = $groupChatWorkspace.listen(syncRoutinesPane)
|
|
|
|
// React on the NEXT tick — a layout notification arrives mid-mutation,
|
|
// and registering/unregistering panes from inside it would re-enter the
|
|
// tree store.
|
|
const scheduleRoutinesSync = () => {
|
|
try {
|
|
setTimeout(syncRoutinesPane, 0)
|
|
} catch {
|
|
syncRoutinesPane()
|
|
}
|
|
}
|
|
|
|
// Tab focus moves without swapping the gateway socket, so the focused
|
|
// STORED session is the truth about session focus; older shells fall
|
|
// back to the active session id.
|
|
const focusStore = host.state.focusedStoredSessionId || host.state.activeSessionId
|
|
|
|
const stopFocusSync =
|
|
typeof focusStore?.listen === 'function'
|
|
? focusStore.listen(id => {
|
|
$botChatFocused.set(Boolean(id))
|
|
releaseStaleOpenBotChat(id)
|
|
syncRoutinesPane()
|
|
})
|
|
: null
|
|
|
|
// Proactive reclaim refresh: when the gateway reaps the runtime behind
|
|
// the OPEN bot chat (idle TTL, LRU cap, WS-orphan reap — the mass-reap
|
|
// shape hits every background bot at once), re-resume the canonical
|
|
// chat immediately instead of letting the user's next send eat the
|
|
// stale-id error + recovery retry. Matched on the STORED id (the
|
|
// claim's ids are stored ids; the payload carries both). Best-effort:
|
|
// a failed re-resume (backend still down) leaves the lazy recovery on
|
|
// next send as the backstop. Feature-detected — older shells have no
|
|
// host.onEvent.
|
|
const stopReclaimSync =
|
|
typeof host.onEvent === 'function'
|
|
? host.onEvent('session.reclaimed', event => {
|
|
const payload = (event?.payload || {}) as { stored_session_id?: string }
|
|
const stored = String(payload.stored_session_id || '')
|
|
const claim = $openBotChat.get()
|
|
|
|
if (!stored || !claim) {
|
|
return
|
|
}
|
|
|
|
const owned = [claim.openedSessionId, claim.openedRegistryId].filter(Boolean)
|
|
|
|
if (!owned.includes(stored)) {
|
|
return
|
|
}
|
|
|
|
// A claim without a registry id is the legacy newChat draft
|
|
// fallback: re-resolving the canonical chat here would replace
|
|
// a draft the user is typing into. Its tile recovers on the next
|
|
// send like any tab.
|
|
if (!claim.openedRegistryId) {
|
|
return
|
|
}
|
|
|
|
const bot = selectedRosterBot($lastRoster.get(), $selectedRosterKey.get())
|
|
|
|
if (!bot) {
|
|
return
|
|
}
|
|
|
|
const generation = getBotOpenGeneration()
|
|
void openBotCanonicalChat(bot)
|
|
.then(opened => {
|
|
// A user action while the re-resume ran owns the center now.
|
|
if (!opened || generation !== getBotOpenGeneration()) {
|
|
return
|
|
}
|
|
|
|
$openBotChat.set({
|
|
key: claim.key,
|
|
openedRegistryId: opened.registryId,
|
|
openedSessionId: opened.openedId
|
|
})
|
|
})
|
|
.catch(() => {
|
|
/* backend still down — next send recovers via the ladder */
|
|
})
|
|
})
|
|
: null
|
|
|
|
$botsPaneVisible.set(Boolean($sidebarVisible.get()))
|
|
$botChatFocused.set(sessionOwnsWorkspace())
|
|
// A persisted layout can boot directly into Bot Mode. Reconcile now,
|
|
// then once more after the layout mutation finishes.
|
|
syncRoutinesPane()
|
|
scheduleRoutinesSync()
|
|
|
|
if (typeof ctx.onDispose === 'function') {
|
|
// The registration disposer is already tracked by ctx.register; only
|
|
// the listeners need explicit teardown or they survive plugin disable.
|
|
ctx.onDispose(() => {
|
|
stopSidebarSync()
|
|
stopGroupSync()
|
|
stopFocusSync?.()
|
|
stopReclaimSync?.()
|
|
})
|
|
}
|
|
} else {
|
|
registerRoutinesPane()
|
|
}
|
|
|
|
// A bot's chat before it has spoken: core's splash is Hermes' wordmark and
|
|
// stands down for any session that exists, so the bot titles its own.
|
|
ctx.register({
|
|
id: 'chat-empty',
|
|
area: CHAT_EMPTY_AREA,
|
|
data: {
|
|
render: ({ sessionId }: ChatEmptyProps) => <BotChatEmpty sessionId={sessionId} />
|
|
}
|
|
})
|
|
|
|
ctx.register({
|
|
id: 'new-agent',
|
|
area: PALETTE_AREA,
|
|
data: {
|
|
id: `${ID}.new-agent`,
|
|
label: 'New Bot…',
|
|
keywords: ['bot', 'agent', 'profile', 'teammate', 'create'],
|
|
run: () => {
|
|
host.notify({
|
|
kind: 'info',
|
|
message: ctx.i18n.t('bot.createFirstHint')
|
|
})
|
|
}
|
|
}
|
|
})
|
|
|
|
// @-mention middleware: "@<bot> do the thing" in any chat gets an
|
|
// IDENTIFICATION note — who the user is referring to, resolved against
|
|
// the LIVE roster ("user@example.com" or an unknown @ passes through
|
|
// untouched). The middleware never delivers anything itself: the agent
|
|
// owns messaging via its message_agent tool (Bot Chats), so there is
|
|
// exactly one send path and user text is never forwarded verbatim by
|
|
// the renderer. The composer's @-autocomplete remains the picking aid.
|
|
ctx.register({
|
|
id: 'mention-middleware',
|
|
area: COMPOSER_AREAS.middleware,
|
|
data: {
|
|
handler: async (draft: ComposerDraftPayload): Promise<ComposerDraftPayload> => {
|
|
const text = draft.text || ''
|
|
|
|
// /new inside a bot's canonical forever-chat would fork the
|
|
// relationship into a scratch session — the one thing Bots mode
|
|
// promises never happens. Reroute to /compact (same felt effect:
|
|
// fresh working context, SAME conversation) and say so. Only
|
|
// guards the canonical chat: Sessions-mode scratchpads on the
|
|
// same profile keep full /new freedom.
|
|
const slashNew = /^\/(new|reset)\s*$/.exec(text.trim())
|
|
|
|
if (slashNew) {
|
|
const activeBot = $selectedBot.get()
|
|
// Canonical identity is the profile's "Bot Chat" registry row —
|
|
// read it from the roster cache (canonical_session, resolved
|
|
// server-side by name), matching either the durable row id or
|
|
// the compression-lineage tip currently on screen.
|
|
const roster = $lastRoster.get()
|
|
const row = Array.isArray(roster) ? roster.find(bot => bot?.name === activeBot) : null
|
|
|
|
// The STORED id, which is the id space canonical_session is keyed
|
|
// in. `host.state.activeSessionId` is the runtime id and could
|
|
// never match, so the guard read `host.activeSessionId` — no such
|
|
// property — and silently resolved to null on every turn: /new
|
|
// reset the forever-chat instead of compacting it.
|
|
if (activeBot && isCanonicalChatOnScreen(row, host.state.focusedStoredSessionId.get())) {
|
|
host.notify({
|
|
kind: 'info',
|
|
title: 'This chat never resets',
|
|
message:
|
|
'Bot chats are one continuous conversation — compacting instead. ' +
|
|
'For a throwaway session with this bot, use Sessions mode.'
|
|
})
|
|
|
|
return {
|
|
...draft,
|
|
text: '/compact'
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!/(^|\s)@[a-z0-9][a-z0-9_-]*/i.test(text)) {
|
|
return draft
|
|
}
|
|
|
|
const live = {
|
|
name: focusedMentionProfile(),
|
|
connectionId: String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || 'local')
|
|
}
|
|
|
|
const cached = cachedUnionRoster()
|
|
const roster = Array.isArray(cached?.profiles) ? cached.profiles : null
|
|
let mentionedBots = roster ? resolveRosterMentions(text, roster, live) : []
|
|
|
|
if (!roster) {
|
|
try {
|
|
const res = await host.request<{ profiles?: RosterRow[] }>('profiles.list', {
|
|
include_sessions: false
|
|
})
|
|
|
|
// Same resolver as the cached path — renamed bots (display_name
|
|
// / ui_meta title) stay taggable when the roster cache is cold.
|
|
mentionedBots = resolveRosterMentions(text, res?.profiles ?? [], live).map(bot => ({
|
|
...bot,
|
|
remoteSource: false
|
|
}))
|
|
} catch {
|
|
return draft
|
|
}
|
|
}
|
|
|
|
if (!mentionedBots.length) {
|
|
return draft
|
|
}
|
|
|
|
// Identification only. Each line names the agent the user's tag
|
|
// resolves to (friendly title + device for cross-connection rows),
|
|
// so the agent knows exactly who "@research-buddy" is. Cross-
|
|
// connection targets carry the '@connection' suffix message_agent
|
|
// resolves against the Desktop-synced relay roster.
|
|
const lines = mentionedBots.map(bot => {
|
|
const handle = botHandle(bot.name, bot)
|
|
|
|
const title = String(
|
|
botRosterMeta(bot, $botMeta.get())?.title || bot.ui_meta?.['hermes-bots']?.title || bot.title || ''
|
|
).trim()
|
|
|
|
// message_agent only resolves canonical identities: the relay
|
|
// matches a roster row's handle/profile (± @connection-id), the
|
|
// local path a bare profile name or 'hermes'. botHandle() prefers
|
|
// the row's source-qualified UI alias ('default-vera'), which
|
|
// neither resolver accepts — annotate the canonical form instead.
|
|
const target =
|
|
bot.remoteSource && bot.connectionId ? `${bot.name}@${bot.connectionId}` : botHandle(bot.name)
|
|
|
|
// Local rows get the same annotation whenever their UI alias
|
|
// ('default-this-device') differs from the resolvable handle —
|
|
// otherwise the agent has only the alias to go on and the local
|
|
// path rejects it the same way (#97678).
|
|
const where = bot.remoteSource
|
|
? ` — on ${bot.connectionLabel || bot.connectionId} (message_agent target: "${target}")`
|
|
: handle !== target
|
|
? ` (message_agent target: "${target}")`
|
|
: ''
|
|
|
|
return `@${handle} = agent profile "${bot.name}"${title ? ` ("${title}")` : ''}${where}`
|
|
})
|
|
|
|
const note =
|
|
'\n\n[@mentions resolved from the Bot Mode roster — the user is referring to: ' +
|
|
lines.join('; ') +
|
|
'. If they want one of these agents contacted, compose your own message and send it with your message_agent tool (agents on other connected machines are reachable too — the Desktop relays it); never forward the user\u2019s text verbatim. If this session has no message_agent tool, agent messaging is unavailable here — say so.]'
|
|
|
|
return {
|
|
...draft,
|
|
text: text + note
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|