Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
@@ -0,0 +1,772 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user