Files
hermes-agent/apps/desktop/src/plugins/hermes-bots/plugin.js
T
2026-08-19 19:30:28 -07:00

10455 lines
389 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 Agent" 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 * as sdk from '@hermes/plugin-sdk'
import {
atom,
Button,
Checkbox,
cn,
Codicon,
COMPOSER_AREAS,
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
ConfirmDialog,
CopyButton,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
GlyphSpinner,
haptic,
host,
Input,
PALETTE_AREA,
profileColor,
queryClient,
relativeTime,
ScrollArea,
SearchField,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Switch,
Textarea,
Tip,
useQuery,
useValue
} from '@hermes/plugin-sdk'
import { useEffect, useRef, useState } from 'react'
import { jsx, jsxs } from 'react/jsx-runtime'
const { McpTab, ToolsetConfigPanel } = sdk
// Keep optional exports feature-detected; test harnesses may strip the SDK namespace.
const SkillsView = typeof sdk === 'undefined' ? undefined : sdk.SkillsView
// TRUE only on builds whose SkillsView routes `fixedConnection` to the pinned
// registry connection's backend. Older builds export SkillsView WITHOUT the
// prop — rendering it for a remote-target draft there would read/write the
// ACTIVE gateway's skills under the remote bot's name (the wrong machine),
// so those builds keep the staged checklists for remote targets.
const skillsViewRoutesConnections = Boolean(SkillsView && SkillsView.supportsFixedConnection)
const Streamdown = typeof sdk === 'undefined' ? undefined : sdk.Streamdown
// Deterministic blob avatars (name → face). Feature-detected: older SDKs
// without the export fall back to the legacy math-face shapes below.
const blobatarSvg = typeof sdk === 'undefined' ? undefined : sdk.blobatarSvg
// Budgeted render loop (fps cap + observability pause + dormancy + teardown).
// Feature-detected: older desktops fall back to the hand-rolled clock below.
const createBudgetedLoop = typeof sdk === 'undefined' ? undefined : sdk.createBudgetedLoop
const ID = 'hermes-bots'
const ROSTER_KEY = [ID, 'roster']
const ROUTINES_KEY = [ID, 'routines']
const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
/** Captured in register() so components can reach plugin storage. */
let pluginCtx = null
/** Live roster snapshot for imperative handlers (context menus). */
const $lastRoster = atom([])
/** Bots with chat activity the user hasn't seen yet (name -> true).
* Fed by the roster poll's activity watermark, so it catches EVERY
* delivery path: RPC, CLI (bot-to-bot), cron runs, other machines. */
const $botUnread = atom({})
// last_active watermark per bot, seeded on first poll so a fresh mount
// doesn't mark ancient history unread.
const rosterWatermarks = new Map()
let watermarksSeeded = false
/** User pref: toast on every new bot activity. Default OFF — a busy roster
* (cron runs, bot-to-bot chatter) turns the toasts into a firehose, and the
* unread badge already carries the signal. Persisted via ctx.storage. */
const $activityToasts = atom(false)
/** Flip the activity-toast pref and persist it. */
function setActivityToasts(enabled) {
$activityToasts.set(enabled)
try {
Promise.resolve(pluginCtx?.storage?.set?.('activity-toasts', enabled)).catch(() => undefined)
} catch {
/* storage unavailable — pref holds for this window only */
}
}
/** Detect new inbound activity from a fresh roster: last_active moved past
* the watermark for a bot whose chat isn't on screen -> unread + toast.
* Watermarks follow botActivitySession (canonical Bot Chat included) —
* last_session alone never sees the hidden Bot Chat, so DMs delivered
* there would neither badge nor toast. */
function trackInboundActivity(roster) {
const seeding = !watermarksSeeded
watermarksSeeded = true
for (const bot of roster) {
const activity = botActivitySession(bot)
const ts = activity?.last_active || 0
const prev = rosterWatermarks.get(bot.name) || 0
rosterWatermarks.set(bot.name, Math.max(prev, ts))
if (seeding || ts <= prev) {
continue
}
// Activity in the bot the user is currently looking at is already
// visible — never badge the open chat.
if ($selectedBot.get() === bot.name) {
continue
}
$botUnread.set({ ...$botUnread.get(), [bot.name]: true })
// Roster-hidden bots stay quiet: the unread flag above accumulates
// silently (unhiding reveals the badge) but a hidden bot never toasts.
if ($botMeta.get()[bot.name]?.hidden) {
continue
}
// Toasts are opt-in: the unread badge is always set above, but the
// per-message notification fires only when the user enabled it.
if ($activityToasts.get()) {
const meta = $botMeta.get()[bot.name]
const label = displayName(bot, meta)
const preview = (activity?.preview || '').trim()
const inbound = /^Message from/i.test(preview)
host.notify({
kind: 'info',
title: inbound ? `\uD83E\uDD16 New message for ${label}` : `${label} has new activity`,
message: preview.slice(0, 140) || 'Open the chat to see it.'
})
}
}
}
/** Last good cron list, same idea as the roster snapshot. */
const $lastJobs = atom([])
// Bot Mode sessions are ALWAYS hidden from the global Sessions sidebar:
// canonical Bot Chats are plugin-owned forever-chats and group-chat member
// sessions are room plumbing — neither is a scratch conversation, and a
// 6-member room would otherwise dump six identical "Group: ..." rows into
// recents. Backed by the core generic `hidden` session flag (session.create
// hidden:true / session.set_hidden); the Bots pane browses them via
// session.list include_hidden. Older gateways ignore the flag and the
// sessions simply stay visible there.
/** Bot the Routines tile is scoped to. Follows the live gateway profile
* (the bot you're actually chatting with) and roster clicks. */
const $selectedBot = atom('default')
/** Owner profile of the chat the user is LOOKING AT. Newer desktops expose
* `host.state.focusedSessionProfile` (the focused session row's stamped
* owner, gateway profile for drafts); older builds fall back to the gateway
* profile atom — the socket's home — which is the previous behavior. The
* distinction matters because tab/tile focus moves WITHOUT swapping the
* gateway socket: with only the socket atom, opening another bot's chat in
* a tab left the roster highlight (and the Cronjobs tile) on whichever bot
* the socket happened to be homed on. */
const $focusedBotProfile = host.state.focusedSessionProfile || host.state.profile
/** Optional secondary navigation inside the Bots pane. Primary row clicks still
* open the bot's canonical chat; this state opens its stored-session browser. */
const $botSessionsWorkspace = atom(null)
const $botSelectedSessions = atom({})
const $sessionsGatewayGeneration = atom(0)
/** Group-chat rooms: { [group]: { log: [{from:{kind,name},text,at}], watermarks:{[member]:idx}, epoch, running } }.
* Log + watermarks persist via plugin storage; epoch/running are runtime-only. */
const $groupChats = atom({})
/** Group whose room view is open in the Bots pane (secondary navigation,
* same pattern as $botSessionsWorkspace). */
const $groupChatWorkspace = atom(null)
/** Groups whose latest room activity mentions @user — the needs-you badge. */
const $groupNeedsYou = atom({})
// ── group activity feed ─────────────────────────────────────────────────────
// Runtime-only, bounded per-room record of turn events that feeds the
// collapsible Activity view. Never persisted — it is presentation state like
// running/epoch, and the room transcript (log) stays the only durable record.
// Every event is tagged with the room epoch it belongs to, so the view shows
// only the CURRENT run: a newer send bumps the epoch (old-run events drop
// away), and a rename re-keys the room (the feed starts clean under the new
// name — stale events under the old key simply have no room to attach to).
const GROUP_ACTIVITY_LIMIT = 50
const $groupActivity = atom({})
function recordGroupActivity(group, event) {
const room = $groupChats.get()[group]
if (!room) {
return null
}
const current = $groupActivity.get()[group] || { events: [] }
const entry = { at: Date.now(), epoch: room.epoch || 0, ...event }
const events = [...current.events, entry].slice(-GROUP_ACTIVITY_LIMIT)
$groupActivity.set({ ...$groupActivity.get(), [group]: { ...current, events } })
return entry
}
/** Events for the room's CURRENT run — superseded runs (epoch moved on)
* are dropped from view instead of describing work that already ended. */
function currentGroupActivity(group) {
const epoch = ($groupChats.get()[group] || {}).epoch || 0
return ($groupActivity.get()[group] || {}).events?.filter(event => (event.epoch || 0) === epoch) || []
}
/** Human label for one activity event, used by the collapsed summary and
* the expanded rows. */
function groupActivityLabel(event) {
const kind = event?.kind
const base = GROUP_ACTIVITY_LABELS[kind] || kind || 'did something'
if (kind === 'cancelled' || kind === 'settled') {
return base
}
const who = event?.member === 'You' ? 'You' : groupSpeakerLabel(event?.member || 'A bot')
return `${who} ${base}`
}
const GROUP_ACTIVITY_LABELS = {
queued: 'sent a message',
working: 'is working…',
replied: 'replied',
passed: 'passed',
'timed-out': 'took too long',
failed: 'hit an error',
cancelled: 'turn interrupted by a newer message',
settled: 'turn settled',
delivered: 'delivered a late reply'
}
const GROUP_ACTIVITY_GLYPHS = {
queued: 'comment',
working: 'sync',
replied: 'check',
passed: 'circle-outline',
'timed-out': 'clock',
failed: 'error',
cancelled: 'close',
settled: 'check-all',
delivered: 'mail-read'
}
/** Text tone for an activity row: quiet for pass/cancel/settle, accent for
* work and real replies, destructive for failures and timeouts. */
function groupActivityTone(kind) {
if (kind === 'failed' || kind === 'timed-out') {
return 'text-destructive'
}
if (kind === 'working' || kind === 'replied' || kind === 'delivered') {
return 'text-(--ui-accent,#4f9cf9)'
}
return 'text-(--ui-text-tertiary)'
}
function handleSessionsGatewayTransition() {
$sessionsGatewayGeneration.set($sessionsGatewayGeneration.get() + 1)
$botSelectedSessions.set({})
// A gateway swap invalidates any in-flight room drive: bump every room's
// epoch so running loops bail at their next member boundary.
const rooms = { ...$groupChats.get() }
for (const name of Object.keys(rooms)) {
rooms[name] = { ...rooms[name], epoch: (rooms[name].epoch || 0) + 1, running: false }
}
$groupChats.set(rooms)
}
/** Per-bot appearance + display meta, persisted via ctx.storage:
* { [botName]: { shape, color, title } } */
const $botMeta = atom({})
async function saveBotMeta(name, patch) {
const prevMeta = $botMeta.get()[name] || {}
const next = { ...$botMeta.get(), [name]: { ...prevMeta, ...patch } }
$botMeta.set(next)
// Local plugin storage: instant, and the fallback for older gateways.
try {
Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', next)).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
try {
const { image, pet, ...rest } = next[name] || {}
serverRequest = Promise.resolve(host.request('profiles.configure', { name, ui_meta: { 'hermes-bots': rest } }))
} 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
? host.request('profiles.set_asset', { name, asset: 'avatar', data: patch.image })
: 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 = '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 */
}
}
return { serverPersisted: serverOutcome === 'persisted', serverOutcome }
}
// ── hidden bots (right-click → Hide Bot) ────────────────────────────────────
// Hiding is a ROSTER-DISPLAY concern only: a hidden bot keeps working —
// @mentions still resolve, group-chat membership is untouched, its name
// still counts as taken, and an open chat stays open. The flag lives in bot
// meta (`hidden: true`), so it rides the same local-storage + server
// ui_meta pipeline as pins/titles and follows the profile across machines.
// Unhide writes `hidden: false` (never null): a null key survives the local
// `{ ...prev, ...patch }` merge while the server DELETES None keys, and
// that asymmetry lets mergeServerMeta resurrect a stale truthy copy. A
// literal false round-trips identically through both stores.
/** Session-only view toggle: reveal hidden bots (dimmed) in the roster. */
const $showHiddenBots = atom(false)
/** Hidden flag for a roster row. Thin remote-source rows never read local
* meta (botRosterMeta returns null for them), so hide is by NAME on the
* active source; remote rows of the same name stay visible. */
function isBotHidden(bot, metaByName) {
return Boolean(botRosterMeta(bot, metaByName)?.hidden)
}
/** Hiding the selected bot re-homes the selection (the Routines pane
* follows it): first visible bot wins, then 'default' — unless default is
* itself hidden with nothing else visible, in which case the selection
* stays put rather than pointing somewhere even less real. */
function fallbackSelectionAfterHide(name) {
if ($selectedBot.get() !== name) {
return
}
const meta = $botMeta.get()
const visible = $lastRoster
.get()
.filter(bot => !bot.remoteSource && bot.name !== name && !meta[bot.name]?.hidden)
if (visible.length) {
$selectedBot.set(visible[0].name)
return
}
if (name !== 'default' && !meta.default?.hidden) {
$selectedBot.set('default')
}
}
/** One-time reconciliation: Bot Mode sessions are always hidden, but rooms
* and Bot Chats created before this policy (or while the old pref was off)
* left visible rows behind. On every plugin load, sweep every session id we
* own — canonical chats from bot meta plus each group room's member
* sessions — through the core session.set_hidden RPC, then run the
* ownership-based sweep for the rows we DON'T know by id. Idempotent (the DB
* setter is a no-op on already-hidden rows) and feature-detected: older
* gateways lack session.set_hidden and simply keep the rows visible. */
function hideOwnedBotSessions() {
const canonical = Object.entries($botMeta.get())
.map(([name, meta]) => ({ name, id: meta && meta.chat }))
.filter(entry => Boolean(entry.id))
const rooms = Object.values($groupChats.get())
.flatMap(room => Object.values(room?.sessions || {}))
.filter(sid => Boolean(sid) && sid !== true)
// A stale local/server pointer must not be trusted merely because it looks
// like a session id. Resolve every canonical pointer through the backend and
// require the canonical Bot Chat title before the hide write. This is
// deliberately fail-closed: an unavailable/old gateway may leave an old
// Bot Chat visible, but it must never hide an unrelated user conversation.
const verifiedCanonical = Promise.resolve()
.then(() =>
host.request('profiles.list', {
include_sessions: true,
preferred_session_ids: Object.fromEntries(canonical.map(entry => [entry.name, entry.id]))
})
)
.then(res => {
const profiles = Array.isArray(res?.profiles) ? res.profiles : []
const valid = []
for (const entry of canonical) {
const profile = profiles.find(item => item?.name === entry.name)
const preferred = profile?.preferred_session
const ids = [preferred?.id, preferred?.resolved_id, preferred?.session_id, preferred?.session_key]
.filter(Boolean)
.map(String)
if (String(preferred?.title || '').trim() === 'Bot Chat' && ids.includes(String(entry.id))) {
valid.push(entry.id)
}
}
return valid
})
.catch(() => [])
const known = verifiedCanonical.then(validCanonical =>
Promise.all(
[...new Set([...validCanonical, ...rooms])].map(sid =>
Promise.resolve(host.request('session.set_hidden', { session_id: sid, hidden: true })).catch(() => undefined)
)
)
)
return Promise.all([known, sweepBotProfileSessions().catch(() => undefined)])
}
// Titles Bot Mode itself mints for its plumbing sessions. Bot-to-bot CLI
// handoffs (`hermes -p <bot> chat --in ~ -c "Bot Chat" --create-if-missing`)
// and mention handoffs create sessions with EXACTLY these titles; the
// "Group: " prefix is the member-session title ensureGroupChatSession has
// used since group chats shipped. Exact/prefix matching is deliberate — a
// user's real conversation inside a bot profile keeps whatever title the
// user gave it and is never touched.
const BOT_MODE_SWEEP_TITLES = new Set(['Bot Chat', 'Agent Inbox'])
function isBotModeSweepTitle(title) {
const t = String(title || '').trim()
return BOT_MODE_SWEEP_TITLES.has(t) || t.startsWith('Group: ')
}
/** Ownership-based sweep: the id-based sweep above only covers sessions the
* plugin recorded ($botMeta canonical chats, $groupChats member sids), but
* Bot Mode sessions are ALSO minted outside the plugin — bot-to-bot CLI
* handoffs ("Agent Inbox" / extra "Bot Chat" rows born visible in a bot's
* profile) — and those ids the plugin never learns. So: enumerate each
* roster bot's OWN profile sessions (only bot profiles — a non-bot profile
* is never listed, so its sessions are never touched) and hide any VISIBLE
* row whose title is Bot Mode plumbing. session.list without include_hidden
* returns only visible rows, which keeps the sweep naturally idempotent.
* Remote-source bots route to their own connection via requestForBot.
* Feature-detected + fire-and-forget: older gateways without per-profile
* session.list / session.set_hidden simply reject and the sweep no-ops. */
async function sweepBotProfileSessions() {
const cached = $lastRoster.get()
let roster = Array.isArray(cached) && cached.length ? cached : null
if (!roster) {
// Plugin load can run before the Bots pane hydrates $lastRoster — fall
// back to the active gateway's own profile list (local bots; remote
// sources get covered by the next sweep once the roster cache exists).
try {
const res = await host.request('profiles.list', {})
roster = Array.isArray(res?.profiles) ? res.profiles : []
} catch {
return
}
}
await Promise.all(
roster.map(async bot => {
const name = String(bot?.name || '').trim()
if (!name) {
return
}
try {
const res = await requestForBot(bot, 'session.list', { profile: name, limit: PROFILE_SESSION_LIST_LIMIT })
const rows = Array.isArray(res?.sessions) ? res.sessions : []
await Promise.all(
rows
.filter(row => row && row.id && isBotModeSweepTitle(row.title))
.map(row =>
Promise.resolve(
requestForBot(bot, 'session.set_hidden', { session_id: row.id, hidden: true, profile: name })
).catch(() => undefined)
)
)
} catch {
/* older gateway / unreachable source — leave this profile alone */
}
})
)
}
/** Fetch server-side avatars for roster rows flagged has_avatar when the
* local cache doesn't already have an image for them. Fire-and-forget. */
const avatarFetchInflight = new Set()
const avatarPushInflight = new Set()
/** Backfill: local meta has art the server lacks -> profiles.set_asset.
* Server-side avatars power the inter-agent notice pfp (core #85855) and
* cross-machine roster art, so local-only images are a bug, not a state. */
function pushLocalAvatars(roster) {
for (const bot of roster) {
if (bot.has_avatar || avatarPushInflight.has(bot.name)) {
continue
}
const image = $botMeta.get()[bot.name]?.image
if (image && typeof image === 'string' && image.startsWith('data:')) {
avatarPushInflight.add(bot.name)
host
.request('profiles.set_asset', { name: bot.name, asset: 'avatar', data: image })
.then(() => queryClient.invalidateQueries({ queryKey: ['hermes-bots', 'roster'] }))
.catch(() => avatarPushInflight.delete(bot.name))
continue
}
// Vector shape/color face: no image exists anywhere — rasterize the
// live SVG (tagged data-bot-face) to a PNG and push that, so the
// inter-agent notices (core #85855/#85888) can show the real pfp.
const svg = document.querySelector('svg[data-bot-face=' + JSON.stringify(bot.name) + ']')
if (!svg) {
continue
}
avatarPushInflight.add(bot.name)
rasterizeSvgToPng(svg, 160)
.then(png =>
png
? host
.request('profiles.set_asset', { name: bot.name, asset: 'avatar', data: png })
.then(() => queryClient.invalidateQueries({ queryKey: ['hermes-bots', 'roster'] }))
: Promise.reject(new Error('rasterize failed'))
)
.catch(() => avatarPushInflight.delete(bot.name))
}
}
/** Serialize an inline SVG and draw it to a canvas -> PNG data URL. */
function rasterizeSvgToPng(svgEl, size) {
return new Promise(resolve => {
try {
const clone = svgEl.cloneNode(true)
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
clone.setAttribute('width', String(size))
clone.setAttribute('height', String(size))
const markup = new XMLSerializer().serializeToString(clone)
const url = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup)
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
canvas.getContext('2d').drawImage(img, 0, 0, size, size)
resolve(canvas.toDataURL('image/png'))
} catch {
resolve(null)
}
}
img.onerror = () => resolve(null)
img.src = url
} catch {
resolve(null)
}
})
}
/** The roster backfill draws the live SVG at 160x160. Pets are 96x104
* and uploads are 256. Use that to tell a still face-copy from a real picture. */
function isBackfilledFacePng(dataUrl) {
if (!dataUrl || typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/png;base64,')) {
return false
}
try {
const bin = atob(dataUrl.slice('data:image/png;base64,'.length).slice(0, 48))
if (bin.length < 24) {
return false
}
const w = (bin.charCodeAt(16) << 24) | (bin.charCodeAt(17) << 16) | (bin.charCodeAt(18) << 8) | bin.charCodeAt(19)
const h = (bin.charCodeAt(20) << 24) | (bin.charCodeAt(21) << 16) | (bin.charCodeAt(22) << 8) | bin.charCodeAt(23)
return w === 160 && h === 160
} catch {
return false
}
}
function pullServerAvatars(roster) {
pushLocalAvatars(roster)
for (const bot of roster) {
if (!bot.has_avatar || avatarFetchInflight.has(bot.name)) {
continue
}
if ($botMeta.get()[bot.name]?.image) {
continue
}
avatarFetchInflight.add(bot.name)
host
.request('profiles.get_asset', { name: bot.name, asset: 'avatar' })
.then(res => {
if (res?.found && res.data) {
const current = $botMeta.get()
const mine = current[bot.name] || {}
// A 160px raster of the vector face is only for inter-agent
// notices. Do not park it on the roster or the live face dies.
if (isBackfilledFacePng(res.data) && mine.imageKind !== 'photo' && !mine.pet) {
return
}
$botMeta.set({ ...current, [bot.name]: { ...mine, image: res.data } })
try {
Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', $botMeta.get())).catch(() => undefined)
} catch {
/* no storage */
}
}
})
.catch(() => undefined)
.finally(() => avatarFetchInflight.delete(bot.name))
}
}
/** Server ui_meta (per roster row) beats local storage for the compact
* fields it carries; local-only fields (avatar image data URL, extracted
* pet icon) are PRESERVED — the server copy never includes them, so a
* naive replace would wipe a just-saved image avatar on the next roster
* paint. When server bot metadata exists, an omitted chat is authoritative
* deletion; local still fills all gaps for older gateways with no metadata. */
function mergeServerMeta(roster) {
const local = $botMeta.get()
let changed = false
const next = { ...local }
for (const bot of roster) {
const server = bot.ui_meta?.['hermes-bots']
if (server && typeof server === 'object') {
const mine = next[bot.name] || {}
const merged = { ...mine, ...server }
// Local-only fields survive the server overlay.
if (mine.image) {
merged.image = mine.image
}
// Server metadata is authoritative for the canonical chat pointer.
// Without this deletion sync, ctx.storage resurrects stale sessions
// after the server pin is cleared and even after a full app restart.
if (
Object.prototype.hasOwnProperty.call(mine, 'chat') &&
!Object.prototype.hasOwnProperty.call(server, 'chat')
) {
delete merged.chat
}
// Canonical multi-group metadata is authoritative for the compatibility
// scalar too. A server-side `group: null` is represented by omission,
// so retaining the local scalar would resurrect a membership that another
// desktop just removed.
if (
Array.isArray(server.groups) &&
Object.prototype.hasOwnProperty.call(mine, 'group') &&
!Object.prototype.hasOwnProperty.call(server, 'group')
) {
delete merged.group
}
if (JSON.stringify(next[bot.name] || null) !== JSON.stringify(merged)) {
next[bot.name] = merged
changed = true
}
}
}
if (changed) {
$botMeta.set(next)
// Persist server reconciliation so a relaunch cannot rehydrate stale
// local fields that the server intentionally removed.
try {
Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', next)).catch(() => undefined)
} catch {
/* storage unavailable — reconciliation lasts for this window only */
}
}
}
/** Clone a bot: profile (config/skills/SOUL/memory via clone_from) + look.
* Name is "<base>-2", "-3", … — first free slot against the live roster. */
async function duplicateBot(bot, roster) {
const base = bot.name
let name = null
for (let n = 2; n < 100; n++) {
// Truncate the BASE, never the suffix — slicing the joined string chops
// the "-2" off a max-length name and the candidate collides with the
// base forever (#19).
const suffix = `-${n}`
const candidate = base.slice(0, 64 - suffix.length) + suffix
if (!roster.some(b => b.name === candidate)) {
name = candidate
break
}
}
if (!name) {
throw new Error('No free name for the duplicate.')
}
await host.request('profiles.create', {
name,
clone_from: base,
description: bot.description || ''
})
// Same look: avatar shape/color/image and a "(copy)" title so the two
// are tellable apart in the roster until the user renames. Do not copy
// chat or created. Those belong to the original bot.
const meta = $botMeta.get()[base]
if (meta) {
const { chat, created, ...look } = meta
saveBotMeta(name, {
...look,
title: meta.title ? `${meta.title} (copy)` : ''
})
}
return name
}
/** Permanently delete a bot's Hermes profile, then remove plugin-local state
* that would otherwise leave stale appearance/unread data behind.
*
* Prefer the SDK's `host.deleteProfile` when this Desktop build ships it: it
* routes through the Electron-intercepted REST delete, which tears down the
* bot's pool backend FIRST and routes the next request away from it. The
* older `cli.exec` path bypasses that interception, so a backend that the
* roster's hover pre-warm just woke (right-click hovers the row!) holds the
* profile dir open — the CLI's rmtree races the live backend and the
* renderer's socket reconnect respawns it mid-delete, resurrecting the
* directory (hermes-agent#52279). That is the "can't delete a bot" error. */
async function deleteBot(bot) {
if (typeof host.deleteProfile === 'function') {
await host.deleteProfile(bot.name)
} else {
// Older desktop without the SDK verb — best effort via the CLI.
const result = await host.request('cli.exec', {
argv: ['profile', 'delete', bot.name, '--yes']
})
if (result?.blocked || result?.code !== 0) {
throw new Error(result?.hint || result?.output || `Could not delete profile ${bot.name}.`)
}
}
const meta = { ...$botMeta.get() }
delete meta[bot.name]
$botMeta.set(meta)
try {
await Promise.resolve(pluginCtx?.storage?.set?.('bot-meta', meta))
} catch {
/* profile is deleted; stale local appearance is harmless if storage fails */
}
const unread = { ...$botUnread.get() }
delete unread[bot.name]
$botUnread.set(unread)
rosterWatermarks.delete(bot.name)
avatarFetchInflight.delete(bot.name)
avatarPushInflight.delete(bot.name)
if ($selectedBot.get() === bot.name) {
$selectedBot.set('default')
}
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
if (host.state.profile.get?.() === bot.name && typeof host.newChat === 'function') {
host.newChat('default')
}
}
// ── avatars (shape + color + eyes) ──────────────────────────────────────────
// The original flat shapes. Sigils ('sigil-N') and platonic
// solids remain render-only so any bot that picked one during the experiments
// keeps its look.
// Radix ScrollArea's viewport wraps children in a display:table div that
// sizes to content — unbounded width means `truncate` below it never fires
// and previews run through the panel edge. Scope-limited corrective.
//
// A second Radix quirk bites in the dialogs: the viewport is height:100%,
// which computes to auto when the root only has max-height (no definite
// height anywhere up the chain) — the viewport grows to full content height,
// the root's overflow:hidden clips it, and NOTHING scrolls (#88). Capping
// the viewport itself (inheriting the root's max-height) makes it the real
// scroll container; lists shorter than the cap still shrink to fit.
if (typeof document !== 'undefined' && !document.getElementById('hermes-bots-roster-css')) {
const style = document.createElement('style')
style.id = 'hermes-bots-roster-css'
style.textContent =
'.hermes-bots-roster [data-radix-scroll-area-viewport] > div {' +
' display: block !important; width: 100%; min-width: 0; }' +
'.hermes-scroll-cap > [data-radix-scroll-area-viewport] { max-height: inherit; }' +
'@keyframes hermes-bots-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }' +
'.hermes-bots-pulse { animation: hermes-bots-pulse 1.2s ease-in-out infinite; }'
document.head.appendChild(style)
}
const AVATAR_SHAPES = ['circle', 'squircle', 'pill', 'triangle', 'hexagon', 'cloud', 'drop']
const AVATAR_PICKER_SHAPES = ['circle', 'blob', 'squircle', 'pill', 'triangle', 'hexagon', 'cloud', 'drop']
/** xorshift PRNG seeded from a string — stable across sessions/platforms. */
function sigilRng(text) {
let h = 2166136261
for (const ch of text) {
h ^= ch.charCodeAt(0)
h = Math.imul(h, 16777619)
}
let state = h >>> 0 || 88675123
return () => {
state ^= state << 13
state ^= state >>> 17
state ^= state << 5
state >>>= 0
return state / 4294967296
}
}
/**
* Angular hermetic sigil: strokes on the left half of a 5-column grid,
* mirrored right, plus a chance of a diamond ring. Returns SVG path strings.
*/
function sigilGeometry(name, seed) {
const rng = sigilRng(`${name}::${seed}`)
const gx = i => 6 + i * 7 // 5 cols: 6..34
const gy = j => 8 + j * 6 // 5 rows: 8..32
const strokes = []
const segments = 4 + Math.floor(rng() * 3)
for (let k = 0; k < segments; k++) {
const x1 = Math.floor(rng() * 3) // left half incl. center
const y1 = Math.floor(rng() * 5)
const x2 = Math.min(2, Math.max(0, x1 + (rng() > 0.5 ? 1 : -1)))
const y2 = Math.min(4, Math.max(0, y1 + Math.floor(rng() * 3) - 1))
strokes.push(`M${gx(x1)} ${gy(y1)} L${gx(x2)} ${gy(y2)}`)
// mirror (col i → col 4-i)
strokes.push(`M${gx(4 - x1)} ${gy(y1)} L${gx(4 - x2)} ${gy(y2)}`)
// occasional cross-tie through the axis for connectedness
if (rng() > 0.6) {
strokes.push(`M${gx(x2)} ${gy(y2)} L${gx(4 - x2)} ${gy(y2)}`)
}
}
// spine down the axis grounds every variant
strokes.push(`M20 ${gy(0)} L20 ${gy(4)}`)
const ring = rng() > 0.45 ? 'M20 4 L36 20 L20 36 L4 20 Z' : null
return { strokes: strokes.join(' '), ring }
}
const AVATAR_COLORS = [
'#f5f5f4', // white
'#8d6748', // brown
'#ef4444', // red
'#f97316', // orange
'#14b8a6', // teal
'#38bdf8', // cyan
'#3b40c8', // royal blue
'#8b5cf6', // violet
'#ec4899', // magenta
'#9ca3af' // silver
]
/** Perceptual luminance — eyes/pupils flip light on dark bodies (ink, oxblood). */
function isDarkColor(hex) {
try {
const n = parseInt(hex.slice(1), 16)
const r = (n >> 16) & 255
const g = (n >> 8) & 255
const b = n & 255
return 0.2126 * r + 0.7152 * g + 0.0722 * b < 110
} catch {
return false
}
}
function defaultShapeFor(name) {
let hash = 0
for (const ch of name) {
hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
}
return AVATAR_SHAPES[hash % AVATAR_SHAPES.length]
}
// ── blobatar shapes mode (default for new agents) ───────────────────────────
// Deterministic soft-body faces drawn from a string. Shape strings:
// 'blobatar' — the face follows the bot's NAME (renaming the
// bot re-rolls the face, live in the dialog)
// 'blobatar:<seed>' — seed locked (the 🔒 lock / 🎲 randomize picks)
// 'blobatar:<seed>:<kind>' — plus one of the ten silhouettes pinned
// 'blobatar::<kind>' — silhouette pinned, seed still follows the name
// Bot names are slugs (NAME_RE) and generated seeds are base36, so ':' never
// appears inside a segment. Colors come from the library's own name-derived
// palette (contrast-guaranteed) — the classic color swatches don't apply.
const BLOB_KINDS = ['round', 'organic', 'boxy', 'capsule', 'nub', 'cloud', 'droplet', 'hexagon', 'sun', 'triangle']
// Trait positions at the center of each silhouette band. Band thresholds are
// frozen per blobatar major (gen2: 0.22 / 0.48 / 0.60 / 0.70 / 0.79 / 0.86 /
// 0.915 / 0.95 / 0.98).
const BLOB_KIND_TRAIT = {
round: 0.11, organic: 0.35, boxy: 0.54, capsule: 0.65, nub: 0.745,
cloud: 0.825, droplet: 0.8875, hexagon: 0.9325, sun: 0.965, triangle: 0.99
}
function isBlobShape(shape) {
return shape === 'blobatar' || (typeof shape === 'string' && shape.startsWith('blobatar:'))
}
function parseBlobShape(shape, name) {
const parts = typeof shape === 'string' ? shape.split(':') : []
const seedPart = parts[1] || ''
const kind = BLOB_KINDS.includes(parts[2]) ? parts[2] : ''
return { seed: seedPart || name || 'agent', seedPart, kind }
}
function blobShapeString(seedPart, kind) {
if (kind) {
return `blobatar:${seedPart}:${kind}`
}
return seedPart ? `blobatar:${seedPart}` : 'blobatar'
}
/** Static SVG markup for a blob face, tagged data-bot-face so the roster's
* PNG backfill (pushLocalAvatars → rasterizeSvgToPng) still finds it. */
function blobMarkup(shape, name, size) {
if (!blobatarSvg) {
return null
}
const { seed, kind } = parseBlobShape(shape, name)
const opts = { size }
if (kind) {
opts.traits = { shape: BLOB_KIND_TRAIT[kind] }
}
try {
return blobatarSvg(seed, opts).replace('<svg ', '<svg data-bot-face=' + JSON.stringify(name) + ' ')
} catch {
return null
}
}
/** The colored body of the avatar (no eyes). Platonic solids are a filled
* silhouette + translucent internal edge lines (the projected wireframe);
* legacy flat shapes keep their old geometry so stored picks still render. */
function shapeNode(shape, color, botName = 'agent') {
if (shape.startsWith('sigil-')) {
const seed = Number(shape.slice(6)) || 0
const { strokes, ring } = sigilGeometry(botName, seed)
const sw = { fill: 'none', stroke: color, strokeWidth: 2.2, strokeLinecap: 'round', strokeLinejoin: 'round' }
return jsxs('g', {
children: [
ring ? jsx('path', { d: ring, fill: 'none', stroke: color, strokeWidth: 1.2, opacity: 0.5 }) : null,
jsx('path', { d: strokes, ...sw })
]
})
}
const stroke = { fill: color, stroke: color, strokeWidth: 7, strokeLinejoin: 'round' }
const edge = { fill: 'none', stroke: 'rgba(0,0,0,0.4)', strokeWidth: 1.4, strokeLinejoin: 'round', strokeLinecap: 'round' }
const face = { fill: color, stroke: 'rgba(0,0,0,0.4)', strokeWidth: 1.4, strokeLinejoin: 'round' }
switch (shape) {
// ── platonic solids ──
case 'tetrahedron':
return jsxs('g', {
children: [
jsx('path', { d: 'M20 5 L36 33 L4 33 Z', ...face }),
jsx('path', { d: 'M20 5 L20 25 M4 33 L20 25 M36 33 L20 25', ...edge })
]
})
case 'cube':
return jsxs('g', {
children: [
jsx('path', { d: 'M20 4 L33 11 L33 29 L20 36 L7 29 L7 11 Z', ...face }),
jsx('path', { d: 'M7 11 L20 18 L33 11 M20 18 L20 36', ...edge })
]
})
case 'octahedron':
return jsxs('g', {
children: [
jsx('path', { d: 'M20 3 L36 20 L20 37 L4 20 Z', ...face }),
jsx('path', { d: 'M4 20 L36 20 M20 3 L20 37', ...edge })
]
})
case 'dodecahedron':
return jsxs('g', {
children: [
jsx('path', {
d: 'M20 3 L30 6.2 L36.2 14.7 L36.2 25.3 L30 33.8 L20 37 L10 33.8 L3.8 25.3 L3.8 14.7 L10 6.2 Z',
...face
}),
jsx('path', {
d:
'M20 12 L27.6 17.5 L24.7 26.5 L15.3 26.5 L12.4 17.5 Z ' +
'M20 12 L20 3 M27.6 17.5 L36.2 14.7 M24.7 26.5 L30 33.8 M15.3 26.5 L10 33.8 M12.4 17.5 L3.8 14.7',
...edge
})
]
})
case 'icosahedron':
return jsxs('g', {
children: [
jsx('path', { d: 'M20 3 L34.7 11.5 L34.7 28.5 L20 37 L5.3 28.5 L5.3 11.5 Z', ...face }),
jsx('path', {
d:
'M20 11 L27.8 24.5 L12.2 24.5 Z ' +
'M20 11 L20 3 M20 11 L34.7 11.5 M20 11 L5.3 11.5 ' +
'M27.8 24.5 L34.7 11.5 M27.8 24.5 L34.7 28.5 M27.8 24.5 L20 37 ' +
'M12.2 24.5 L5.3 11.5 M12.2 24.5 L5.3 28.5 M12.2 24.5 L20 37',
...edge
})
]
})
// ── legacy flat shapes (stored picks from earlier versions) ──
case 'squircle':
return jsx('rect', { x: 3, y: 3, width: 34, height: 34, rx: 11, fill: color })
case 'pill':
return jsx('rect', { x: 2, y: 7, width: 36, height: 26, rx: 13, fill: color })
case 'triangle':
return jsx('path', { d: 'M20 5.5 L36 33.5 L4 33.5 Z', ...stroke })
case 'hexagon':
return jsx('path', { d: 'M20 3.5 L34.5 11.75 L34.5 28.25 L20 36.5 L5.5 28.25 L5.5 11.75 Z', ...stroke })
case 'cloud':
return jsx('path', {
d: 'M11 32 a7.5 7.5 0 0 1 -1 -14.9 A9.5 9.5 0 0 1 29 12.5 A7 7 0 0 1 30 32 Z',
fill: color
})
case 'drop':
return jsx('path', { d: 'M20 3 C20 3 6 20 6 27 a14 13.5 0 0 0 28 0 C34 20 20 3 20 3 Z', fill: color })
default:
return jsx('circle', { cx: 20, cy: 20, r: 17.5, fill: color })
}
}
const EYE_Y = {
// solids: eyes sit on the upper face region, clear of the busiest edges
tetrahedron: 26,
cube: 22.5,
octahedron: 14.5,
dodecahedron: 20,
icosahedron: 17.5,
// legacy
circle: 17,
squircle: 17,
pill: 20,
triangle: 25,
hexagon: 17,
cloud: 22,
drop: 24
}
// Solids draw eyes slightly tighter so they read as ON a face.
const EYE_X = {
tetrahedron: [16.5, 23.5],
cube: [15, 25],
octahedron: [16, 24],
dodecahedron: [16.5, 23.5],
icosahedron: [16.5, 23.5]
}
function cubicAt(p0, p1, p2, p3, t) {
const u = 1 - t
return [
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1]
]
}
/** Same outline as the old GitHub drop path, so it stays a fat water drop. */
function sampleDropRing(steps) {
const pts = []
const n = Math.max(8, Math.floor(steps / 3))
for (let i = 0; i < n; i++) {
pts.push(cubicAt([20, 3], [20, 3], [6, 20], [6, 27], i / n))
}
for (let i = 0; i <= n; i++) {
const t = (i / n) * Math.PI
pts.push([20 - 14 * Math.cos(t), 27 + 13.5 * Math.sin(t)])
}
for (let i = 1; i <= n; i++) {
pts.push(cubicAt([34, 27], [34, 20], [20, 3], [20, 3], i / n))
}
return pts
}
function svgArc(x1, y1, rx, ry, fa, fs, x2, y2) {
const dx = (x1 - x2) / 2
const dy = (y1 - y2) / 2
let rx2 = rx * rx
let ry2 = ry * ry
const lam = (dx * dx) / rx2 + (dy * dy) / ry2
if (lam > 1) {
const s = Math.sqrt(lam)
rx *= s
ry *= s
rx2 = rx * rx
ry2 = ry * ry
}
const num = rx2 * ry2 - rx2 * dy * dy - ry2 * dx * dx
const den = rx2 * dy * dy + ry2 * dx * dx
let sq = Math.sqrt(Math.max(0, num / den))
if (fa === fs) {
sq = -sq
}
const cx = sq * (rx * dy / ry) + (x1 + x2) / 2
const cy = sq * (-ry * dx / rx) + (y1 + y2) / 2
const ang = (ux, uy, vx, vy) => {
const n = Math.hypot(ux, uy) * Math.hypot(vx, vy) || 1
let a = Math.acos(Math.max(-1, Math.min(1, (ux * vx + uy * vy) / n)))
if (ux * vy - uy * vx < 0) {
a = -a
}
return a
}
const theta1 = ang(1, 0, (x1 - cx) / rx, (y1 - cy) / ry)
let dtheta = ang((x1 - cx) / rx, (y1 - cy) / ry, (x2 - cx) / rx, (y2 - cy) / ry)
if (!fs && dtheta > 0) {
dtheta -= Math.PI * 2
}
if (fs && dtheta < 0) {
dtheta += Math.PI * 2
}
return { cx, cy, rx, ry, theta1, dtheta }
}
function sampleArc(arc, n) {
const pts = []
for (let i = 0; i < n; i++) {
const th = arc.theta1 + arc.dtheta * (i / n)
pts.push([arc.cx + arc.rx * Math.cos(th), arc.cy + arc.ry * Math.sin(th)])
}
return pts
}
/** Same outline as the old GitHub cloud path: three puffs and a flat floor. */
function sampleCloudRing(steps) {
const a1 = svgArc(11, 32, 7.5, 7.5, 0, 1, 10, 17.1)
const a2 = svgArc(10, 17.1, 9.5, 9.5, 0, 1, 29, 12.5)
const a3 = svgArc(29, 12.5, 7, 7, 0, 1, 30, 32)
const len1 = Math.abs(a1.dtheta) * a1.rx
const len2 = Math.abs(a2.dtheta) * a2.rx
const len3 = Math.abs(a3.dtheta) * a3.rx
const len4 = 19
const total = len1 + len2 + len3 + len4
const n = Math.max(64, steps)
const n1 = Math.max(8, Math.round(n * len1 / total))
const n2 = Math.max(10, Math.round(n * len2 / total))
const n3 = Math.max(10, Math.round(n * len3 / total))
const n4 = Math.max(4, n - n1 - n2 - n3)
const pts = []
pts.push(...sampleArc(a1, n1))
pts.push(...sampleArc(a2, n2))
pts.push(...sampleArc(a3, n3))
for (let i = 0; i < n4; i++) {
pts.push([30 + (11 - 30) * (i / n4), 32])
}
return pts
}
/** Outline of a face in a 40x40 box. Same family as Grok Bot
* (blob / squircle / pebble / \u2026) but sampled from formulas, not
* a dumped point cloud. */
function sampleFaceRing(shape, steps = 52) {
const kind = (shape || '').startsWith('sigil-') ? 'circle' : shape
if (kind === 'drop' || kind === 'teardrop') {
return sampleDropRing(steps)
}
if (kind === 'cloud') {
return sampleCloudRing(steps)
}
const pts = []
for (let i = 0; i < steps; i++) {
const a = (i / steps) * Math.PI * 2 - Math.PI / 2
const c = Math.cos(a)
const s = Math.sin(a)
let rx = 16
let ry = 16
if (kind === 'circle') {
rx = ry = 16.2
} else if (kind === 'blob') {
rx = ry = 16 + 1.7 * Math.sin(3 * a) + 0.7 * Math.cos(5 * a)
} else if (kind === 'squircle') {
const p = 5
const d = Math.pow(Math.abs(c) ** p + Math.abs(s) ** p, 1 / p) || 1
rx = ry = 16.2 / d
} else if (kind === 'pill') {
const d = Math.pow(Math.abs(c) ** 8 + Math.abs(s / 0.72) ** 8, 1 / 8) || 1
rx = ry = 16 / d
} else if (kind === 'triangle' || kind === 'tetrahedron' || kind === 'wedge') {
const u = (a + Math.PI / 2 + Math.PI * 2) % (Math.PI * 2)
const sector = (u / (Math.PI * 2 / 3)) % 1
rx = ry = 13.5 / Math.max(0.42, Math.cos((sector - 0.5) * 1.9))
} else if (kind === 'hexagon' || kind === 'hex' || kind === 'icosahedron' || kind === 'dodecahedron') {
const seg = Math.PI / 3
const hex = Math.cos(seg / 2) / Math.cos(a - seg * Math.round(a / seg))
rx = ry = 16.2 * hex
} else if (kind === 'cube' || kind === 'octahedron') {
const p = 3.1
const d = Math.pow(Math.abs(c) ** p + Math.abs(s) ** p, 1 / p) || 1
rx = ry = 16 / d
} else if (kind === 'pebble') {
rx = 16.4 * (1.04 - 0.14 * Math.cos(2 * a))
ry = 15.2 * (1.06 + 0.08 * Math.sin(2 * a))
} else {
rx = ry = 16.2
}
pts.push([20 + rx * c, 20 + ry * s])
}
return pts
}
function projectFacePoint(x, y, turn, tilt, roll) {
const dx = x - 20
const dy = y - 20
const r = (roll * Math.PI) / 180
const xr = dx * Math.cos(r) - dy * Math.sin(r)
const yr = dx * Math.sin(r) + dy * Math.cos(r)
const sx = 0.74 + 0.26 * Math.abs(Math.cos((turn * Math.PI) / 180))
const sy = 0.8 + 0.2 * Math.abs(Math.cos((tilt * Math.PI) / 180))
return [20 + xr * sx, 20 + yr * sy]
}
function ringToPath(pts) {
if (!pts.length) {
return ''
}
let d = `M${pts[0][0].toFixed(2)} ${pts[0][1].toFixed(2)}`
for (let i = 1; i < pts.length; i++) {
d += `L${pts[i][0].toFixed(2)} ${pts[i][1].toFixed(2)}`
}
return d + 'Z'
}
/** Grok-style pose. thinking/working lean and sway. idle is a small sine. */
function facePose(mood, t) {
if (mood === 'work') {
return {
turn: -11 + Math.sin(t * 0.48) * 8,
tilt: Math.sin(t * 0.42) * 8 + Math.sin(t * 1.1) * 1.6,
roll: Math.sin(t * 0.75) * 4.2,
gazeX: Math.sin(t * 0.55) * 3.6,
gazeY: -1.6 + Math.sin(t * 0.38) * 2,
blink: t % 1.45 > 1.26,
d0: 0.2 + 0.8 * Math.max(0, Math.sin(t * 2.6)),
d1: 0.2 + 0.8 * Math.max(0, Math.sin(t * 2.6 - 0.7)),
d2: 0.2 + 0.8 * Math.max(0, Math.sin(t * 2.6 - 1.4))
}
}
return {
turn: Math.sin(t * 0.5) * 1.5,
tilt: Math.sin(t * 0.27),
roll: Math.sin(t * 0.85) * 1.2,
gazeX: 0,
gazeY: 0,
blink: t % 3.2 > 3.02,
d0: 0,
d1: 0,
d2: 0
}
}
function paintMathFace(svg, t) {
const mood = svg.getAttribute('data-hb-mood') || 'idle'
const shape = svg.getAttribute('data-hb-shape') || 'circle'
const pose = facePose(mood, t)
const body = svg.querySelector('[data-hb-body]')
const open = svg.querySelector('[data-hb-open]')
const shut = svg.querySelector('[data-hb-shut]')
const el = svg.querySelector('[data-hb-el]')
const er = svg.querySelector('[data-hb-er]')
const dots = svg.querySelectorAll('[data-hb-dot]')
if (body) {
if (shape === 'cloud') {
body.setAttribute('d', 'M11 32 a7.5 7.5 0 0 1 -1 -14.9 A9.5 9.5 0 0 1 29 12.5 A7 7 0 0 1 30 32 Z')
} else {
const ring = sampleFaceRing(shape).map(([x, y]) => projectFacePoint(x, y, pose.turn, pose.tilt, pose.roll))
body.setAttribute('d', ringToPath(ring))
}
}
const eyeY = (shape === 'cloud' ? 22 : 17.2) + pose.gazeY
const eyeL = 15.4 + pose.gazeX
const eyeR = 24.6 + pose.gazeX
if (el) {
el.setAttribute('cx', eyeL)
el.setAttribute('cy', eyeY)
}
if (er) {
er.setAttribute('cx', eyeR)
er.setAttribute('cy', eyeY)
}
// Catchlights ride the pupils (upper-left offset) — without this they
// stay at the circle-face position and drift outside e.g. the cloud's
// lower-set eyes.
const hl = svg.querySelector('[data-hb-hl-l]')
const hr = svg.querySelector('[data-hb-hl-r]')
if (hl) {
hl.setAttribute('cx', eyeL - 0.6)
hl.setAttribute('cy', eyeY - 0.7)
}
if (hr) {
hr.setAttribute('cx', eyeR - 0.6)
hr.setAttribute('cy', eyeY - 0.7)
}
if (open) {
open.setAttribute('opacity', pose.blink ? '0' : '1')
}
if (shut) {
shut.setAttribute('d', `M${eyeL - 2.6} ${eyeY} L${eyeL + 2.6} ${eyeY} M${eyeR - 2.6} ${eyeY} L${eyeR + 2.6} ${eyeY}`)
shut.setAttribute('opacity', pose.blink ? '1' : '0')
}
dots.forEach((dot, i) => {
const o = i === 0 ? pose.d0 : i === 1 ? pose.d1 : pose.d2
dot.setAttribute('opacity', String(o))
})
svg.style.transform = `rotate(${pose.tilt}deg)`
svg.style.transformOrigin = '50% 70%'
}
function walkMathFaces(root, acc) {
if (!root || !root.querySelectorAll) {
return acc
}
root.querySelectorAll('svg[data-hb-math]').forEach(node => acc.push(node))
root.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) {
walkMathFaces(el.shadowRoot, acc)
}
})
return acc
}
function startFaceClock() {
if (typeof window === 'undefined') {
return
}
if (window.__hbFaceClock) {
// Already initialized (possibly parked) — make sure it's awake. BotFace
// renders route here, so a face mounting is what wakes a dormant clock.
window.__hbFaceClock.wake()
return
}
const t0 = performance.now()
// A large roster can mount hundreds of faces. Observe the cached nodes so
// off-screen cards do not consume a full animation frame by themselves.
let faces = []
let lastScan = -Infinity
const visibleFaces = new Set()
const observedFaces = new Set()
const observer =
typeof IntersectionObserver === 'function'
? new IntersectionObserver(entries => {
let becameVisible = false
for (const entry of entries) {
if (entry.isIntersecting) {
visibleFaces.add(entry.target)
becameVisible = true
} else {
visibleFaces.delete(entry.target)
}
}
// A parked clock (no visible faces) resumes when one scrolls in.
if (becameVisible) {
window.__hbFaceClock?.wake()
}
})
: null
const scanFaces = () => {
faces = walkMathFaces(document, [])
if (!observer) {
return
}
const currentFaces = new Set(faces)
for (const svg of observedFaces) {
if (!currentFaces.has(svg)) {
observer.unobserve(svg)
observedFaces.delete(svg)
visibleFaces.delete(svg)
}
}
for (const svg of faces) {
if (!observedFaces.has(svg)) {
observedFaces.add(svg)
observer.observe(svg)
}
}
}
// Shared painting body for both scheduling paths: 1Hz document rescans,
// paint only visible faces (all cached faces when IO is unavailable).
const paint = now => {
if (now - lastScan > 1000) {
scanFaces()
lastScan = now
}
const t = (now - t0) / 1000
const facesToPaint = observer ? visibleFaces : faces
for (const svg of facesToPaint) {
if (svg.isConnected) {
paintMathFace(svg, t)
}
}
}
// Nothing worth animating: no faces mounted (BotFace wakes us on the next
// mount) or none visible (the observer wakes us when one scrolls in).
const idle = () => faces.length === 0 || (observer && visibleFaces.size === 0)
const teardownCaches = () => {
if (observer) {
observer.disconnect()
}
visibleFaces.clear()
observedFaces.clear()
faces = []
delete window.__hbFaceClock
}
// Newer desktops: the SDK's budgeted loop owns scheduling (15fps budget,
// hidden/minimized/unfocused pause, dormancy, teardown). typeof-guarded so
// older shells and the vm test harness use the hand-rolled path below.
if (typeof createBudgetedLoop === 'function' && createBudgetedLoop) {
const loop = createBudgetedLoop(paint, { fps: 15, idleWhen: idle })
window.__hbFaceClock = {
stop: () => {
loop.dispose()
teardownCaches()
},
wake: () => {
// Faces may have mounted/unmounted while parked — rescan on wake.
lastScan = -Infinity
loop.wake()
}
}
return
}
// Fallback scheduling for desktops whose SDK predates createBudgetedLoop.
let lastPaint = -Infinity
let rafId = 0
let dormant = false
let stopped = false
const tick = now => {
if (stopped) {
return
}
rafId = 0
// 15fps is smooth at avatar scale and bounds SVG/DOM churn. The clock
// still uses rAF so Chromium can pause it when the window is occluded.
if (!document.hidden && now - lastPaint >= 1000 / 15) {
paint(now)
lastPaint = now
}
// Park instead of burning frames + 1Hz whole-document shadow walks.
if (idle()) {
dormant = true
return
}
rafId = window.requestAnimationFrame(tick)
}
const wake = () => {
if (stopped || !dormant) {
return
}
dormant = false
// Faces may have mounted/unmounted while parked — rescan on first tick.
lastScan = -Infinity
rafId = window.requestAnimationFrame(tick)
}
const stop = () => {
stopped = true
if (rafId) {
window.cancelAnimationFrame(rafId)
rafId = 0
}
teardownCaches()
}
window.__hbFaceClock = { stop, wake }
rafId = window.requestAnimationFrame(tick)
}
/** Tear the face clock down (plugin disable/reload) — cancels the animation
* frame, disconnects the visibility observer, and drops all cached nodes. */
function stopFaceClock() {
if (typeof window !== 'undefined' && window.__hbFaceClock) {
window.__hbFaceClock.stop()
}
}
/**
* Live math face. Photos still use <img>. Shape avatars stay SVG so
* the clock can move them (a baked PNG cannot).
*/
function BotFace({ shape, color, image, size = 36, name = 'agent', mood = 'idle' }) {
startFaceClock()
if (image) {
return jsx('img', {
src: image,
alt: '',
'aria-hidden': true,
style: { width: size, height: size, borderRadius: '22%', objectFit: 'cover', display: 'block' }
})
}
// Blobatar shapes: the library draws the whole face (body + eyes + its own
// name-derived palette). Inline SVG via innerHTML so the roster PNG
// backfill's `svg[data-bot-face=…]` query still finds it; the math clock
// ignores it (no data-hb-math). Falls back to the legacy math face when the
// SDK predates the export.
if (isBlobShape(shape)) {
const markup = blobMarkup(shape, name, size)
if (markup) {
return jsx('span', {
'aria-hidden': true,
style: { width: size, height: size, display: 'block', lineHeight: 0 },
dangerouslySetInnerHTML: { __html: markup }
})
}
// Older SDK without blobatar: legacy deterministic shape from the name.
shape = defaultShapeFor(name)
}
// Sigils are line art (no filled body) — the math clock rebuilds filled
// outlines, which would turn a stored sigil pick into a blank circle.
// Keep the legacy static render for them so old picks still draw.
if (shape.startsWith('sigil-')) {
const eyes = jsxs('g', {
children: [
jsx('circle', { cx: 16, cy: 14, r: 2.4, fill: color }),
jsx('circle', { cx: 24, cy: 14, r: 2.4, fill: color })
]
})
return jsxs('svg', {
'data-bot-face': name,
viewBox: '0 0 40 40',
width: size,
height: size,
'aria-hidden': true,
children: [shapeNode(shape, color, name), eyes]
})
}
const working = mood === 'work'
const eyeFill = isDarkColor(color) ? 'rgba(232,220,195,0.95)' : 'rgba(0,0,0,0.85)'
// Catchlight contrast follows the pupil, not the body: dark pupils get the
// white sparkle, light (cream) pupils on dark bodies get a dark one — a
// white dot on a cream pupil is invisible, which read as "no eye dots" on
// maroon/ink/oxblood avatars.
const hlFill = isDarkColor(color) ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.85)'
const ring = sampleFaceRing(shape)
const rest = facePose(working ? 'work' : 'idle', 0)
// Shape-aware initial eye line — the cloud body sits lower, so its eyes
// (and their catchlights) start at the cloud position instead of jumping
// there on the first clock paint.
const eyeY0 = shape === 'cloud' ? 22 : 17.2
return jsxs('svg', {
'data-bot-face': name,
'data-hb-math': '1',
'data-hb-mood': working ? 'work' : 'idle',
'data-hb-shape': shape || 'circle',
viewBox: '0 0 40 44',
width: size,
height: size,
'aria-hidden': true,
style: { overflow: 'visible', display: 'block' },
children: [
jsx('path', {
'data-hb-body': '1',
d: shape === 'cloud'
? 'M11 32 a7.5 7.5 0 0 1 -1 -14.9 A9.5 9.5 0 0 1 29 12.5 A7 7 0 0 1 30 32 Z'
: ringToPath(ring),
fill: color
}),
jsxs('g', {
'data-hb-open': '1',
children: [
jsx('ellipse', { 'data-hb-el': '1', cx: 15.4, cy: eyeY0, rx: 2.2, ry: working ? 2.6 : 2.3, fill: eyeFill }),
jsx('ellipse', { 'data-hb-er': '1', cx: 24.6, cy: eyeY0, rx: 2.2, ry: working ? 2.6 : 2.3, fill: eyeFill }),
jsx('circle', { 'data-hb-hl-l': '1', cx: 14.8, cy: eyeY0 - 0.7, r: 0.65, fill: hlFill }),
jsx('circle', { 'data-hb-hl-r': '1', cx: 24, cy: eyeY0 - 0.7, r: 0.65, fill: hlFill })
]
}),
jsx('path', {
'data-hb-shut': '1',
d: `M12.8 ${eyeY0} L18 ${eyeY0} M22 ${eyeY0} L27.2 ${eyeY0}`,
stroke: eyeFill,
strokeWidth: 2,
strokeLinecap: 'round',
fill: 'none',
opacity: 0
}),
working
? jsxs('g', {
children: [
jsx('circle', { 'data-hb-dot': '1', cx: 16.4, cy: 41.2, r: 1.15, fill: color, opacity: rest.d0 }),
jsx('circle', { 'data-hb-dot': '1', cx: 20, cy: 41.2, r: 1.15, fill: color, opacity: rest.d1 }),
jsx('circle', { 'data-hb-dot': '1', cx: 23.6, cy: 41.2, r: 1.15, fill: color, opacity: rest.d2 })
]
})
: null
]
})
}
// -- inline MCP setup (per-profile), driven by the mcp.servers.* gateway RPCs --
// Feature-detected: if the gateway predates those RPCs the setup button hides
// and the row falls back to the "run hermes mcp / Settings" hint. profile is
// the target bot's profile name (its config is what we write).
async function mcpRpc(method, params) {
// Returns { ok, result } or { ok:false, unsupported:true } when the gateway
// doesn't know the method (older backend) vs a real error.
try {
const res = await host.request(method, params)
return { ok: true, result: res }
} catch (err) {
const msg = String((err && err.message) || err || '')
if (/unknown method/i.test(msg)) {
return { ok: false, unsupported: true }
}
return { ok: false, error: msg }
}
}
// Probe whether the new lifecycle RPCs exist on this gateway (cached per session).
let _mcpRpcSupported = null
async function mcpSetupSupported() {
if (_mcpRpcSupported !== null) {
return _mcpRpcSupported
}
const r = await mcpRpc('mcp.servers.list', {})
_mcpRpcSupported = !(r.ok === false && r.unsupported)
return _mcpRpcSupported
}
function McpSetupButton({ profile, entry, onDone, ensureProfile }) {
// entry: { name, requires:[env keys], auth?, fromCatalog, installed }
// profile may be null at first (New Agent: the profile isn't created yet).
// ensureProfile() lazily creates it on the first setup action and returns the
// slug, so OAuth / API-key setup works DURING creation, not only in Edit.
const [phase, setPhase] = useState('idle') // idle | keys | oauth | busy | done | error
const [supported, setSupported] = useState(null)
const [keyValues, setKeyValues] = useState({})
const [message, setMessage] = useState('')
const pollRef = useRef(null)
const profileRef = useRef(profile || null)
useEffect(() => {
if (profile) {
profileRef.current = profile
}
}, [profile])
// Resolve the target profile, creating it on demand for the New Agent flow.
const resolveProfile = async () => {
if (profileRef.current) {
return profileRef.current
}
if (ensureProfile) {
const created = await ensureProfile()
if (created) {
profileRef.current = created
}
return created
}
return null
}
useEffect(() => {
let alive = true
mcpSetupSupported().then(ok => {
if (alive) setSupported(ok)
})
return () => {
alive = false
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
}, [])
const isOAuth = (entry.auth || '').toLowerCase() === 'oauth'
const requires = entry.requires || []
const beginKeys = async () => {
// Ensure the server exists in the target profile first (add from catalog).
setPhase('busy')
setMessage('')
const profile = await resolveProfile()
if (!profile) {
setPhase('idle')
return
}
if (entry.fromCatalog && !entry.installed) {
const add = await mcpRpc('mcp.servers.add', { profile, name: entry.name, preset: entry.name })
if (!add.ok) {
setPhase('error')
setMessage(add.error || 'Could not add server')
return
}
}
setPhase(isOAuth ? 'oauth' : 'keys')
}
const submitKeys = async () => {
setPhase('busy')
const profile = profileRef.current
if (!profile) {
setPhase('error')
setMessage('No target profile')
return
}
for (const k of requires) {
const val = (keyValues[k] || '').trim()
if (!val) {
continue
}
const r = await mcpRpc('mcp.servers.set_api_key', { profile, name: entry.name, env_var: k, value: val })
if (!r.ok) {
setPhase('error')
setMessage(r.error || ('Failed to set ' + k))
return
}
}
// Verify via test.
const t = await mcpRpc('mcp.servers.test', { profile, name: entry.name })
if (t.ok && t.result && (t.result.ok || (t.result.result && t.result.result.ok))) {
setPhase('done')
host.notify({ kind: 'success', message: entry.name + ' configured' })
onDone && onDone()
} else {
setPhase('error')
setMessage((t.result && (t.result.error || (t.result.result && t.result.result.error))) || 'Server test failed after setup')
}
}
const beginOAuth = async () => {
// A second click (retry, impatient double-click) must not orphan the
// previous poll interval — an overwritten pollRef leaks a 2s poller that
// runs until unmount and can flip phase from a stale OAuth session.
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
setPhase('busy')
setMessage('')
const profile = await resolveProfile()
if (!profile) {
setPhase('idle')
return
}
if (entry.fromCatalog && !entry.installed) {
const add = await mcpRpc('mcp.servers.add', { profile, name: entry.name, preset: entry.name })
if (!add.ok) {
setPhase('error')
setMessage(add.error || 'Could not add server')
return
}
}
const start = await mcpRpc('mcp.servers.oauth.start', { profile, name: entry.name })
const payload = start.result && (start.result.result || start.result)
const authUrl = payload && (payload.auth_url || payload.verification_url)
const sessionId = payload && payload.session_id
if (!start.ok || !authUrl || !sessionId) {
setPhase('error')
setMessage((start.error) || 'Could not start OAuth')
return
}
// Open the auth URL in the native browser, same as provider OAuth.
try {
if (host.openExternal) {
host.openExternal(authUrl)
} else if (typeof window !== 'undefined' && window.hermesDesktop && window.hermesDesktop.openExternal) {
window.hermesDesktop.openExternal(authUrl)
} else {
window.open(authUrl, '_blank')
}
} catch {
/* fall through to poll; user can open the URL from the toast */
}
setPhase('oauth')
setMessage('Complete sign-in in your browser...')
pollRef.current = setInterval(async () => {
const poll = await mcpRpc('mcp.servers.oauth.poll', { profile, name: entry.name, session_id: sessionId })
const pd = poll.result && (poll.result.result || poll.result)
const status = pd && pd.status
if (status === 'approved') {
clearInterval(pollRef.current)
pollRef.current = null
setPhase('done')
host.notify({ kind: 'success', message: entry.name + ' authenticated' })
onDone && onDone()
} else if (status === 'error') {
clearInterval(pollRef.current)
pollRef.current = null
setPhase('error')
setMessage((pd && pd.error_message) || 'OAuth failed')
}
}, 2000)
}
if (supported === false) {
return jsx('span', {
className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)',
children: 'needs setup (' + requires.join(', ') + ') \u2014 restart the gateway to enable in-app setup'
})
}
if (phase === 'done') {
return jsx('span', { className: 'ml-1.5 text-[0.65rem] text-(--ui-success,#22c55e)', children: 'set up \u2713' })
}
if (phase === 'keys') {
return jsxs('div', {
className: 'mt-1 grid gap-1',
children: [
...requires.map(k =>
jsx(Input, {
key: k,
type: 'password',
className: 'h-6 text-[0.7rem]',
placeholder: k,
value: keyValues[k] || '',
onChange: e => setKeyValues(prev => ({ ...prev, [k]: e.target.value }))
}, k)
),
jsxs('div', {
className: 'flex gap-1',
children: [
jsx(Button, { size: 'xs', variant: 'secondary', onClick: () => void submitKeys(), children: 'Save & test' }),
jsx(Button, { size: 'xs', variant: 'ghost', onClick: () => setPhase('idle'), children: 'Cancel' })
]
})
]
})
}
if (phase === 'oauth') {
return jsx('span', { className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)', children: message || 'Authorizing\u2026' })
}
if (phase === 'busy') {
return jsx('span', { className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)', children: 'Working\u2026' })
}
if (phase === 'error') {
return jsxs('span', {
className: 'ml-1.5 text-[0.65rem] text-(--ui-danger,#ef4444)',
children: [(message || 'Setup failed') + ' ', jsx('button', { className: 'underline', onClick: () => setPhase('idle'), children: 'retry' })]
})
}
// idle
return jsx('button', {
className: 'ml-1.5 text-[0.65rem] text-(--ui-accent,#4f9cf9) underline',
onClick: () => void (isOAuth ? beginOAuth() : beginKeys()),
children: isOAuth ? 'Sign in\u2026' : 'Set up\u2026'
})
}
function botAppearance(name, meta) {
// The primary profile is literally named "default"; the SDK's profileColor
// can hand it a near-black that renders as an ugly black square, and any
// auto-seeded color in local bot-meta would otherwise stick. Give the
// primary a nice fixed generic look (a friendly violet squircle). A user's
// EXPLICIT customization still wins: an uploaded/generated/pet image, or a
// shape/color they set via the editor (tracked by meta.custom === true).
const isPrimary = (name || '').trim().toLowerCase() === 'default'
const userCustomized = Boolean(meta?.custom)
if (isPrimary && !userCustomized) {
return { shape: 'squircle', color: '#8b5cf6', image: meta?.image || null }
}
return {
shape: meta?.shape || defaultShapeFor(name),
color: meta?.color || profileColor(name),
image: meta?.image || null
}
}
// ── image avatars: upload from device + generate via image.generate ─────────
/** Downscale to a small square so plugin storage stays light. */
function normalizeAvatarImage(dataUrl, edge = 256) {
return new Promise(resolve => {
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = edge
canvas.height = edge
const ctx2d = canvas.getContext('2d')
const side = Math.min(img.width, img.height)
ctx2d.drawImage(img, (img.width - side) / 2, (img.height - side) / 2, side, side, 0, 0, edge, edge)
resolve(canvas.toDataURL('image/png'))
} catch {
resolve(dataUrl)
}
}
img.onerror = () => resolve(dataUrl)
img.src = dataUrl
})
}
function pickImageFromDevice() {
return new Promise(resolve => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/png,image/jpeg,image/webp,image/gif'
input.onchange = () => {
const file = input.files?.[0]
if (!file) {
return resolve(null)
}
if (file.size > 15_000_000) {
host.notify({ kind: 'error', message: 'Image too large (max 15MB).' })
return resolve(null)
}
const reader = new FileReader()
reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : null)
reader.onerror = () => resolve(null)
reader.readAsDataURL(file)
}
input.click()
})
}
// ── group-chat attachments: pick/paste/drop files the room's members see ────
/** Classify a picked file for the group-attachment pipeline. */
function groupAttachmentKind(file) {
if (/^image\//.test(file.type || '')) {
return 'image'
}
if (file.type === 'application/pdf' || /\.pdf$/i.test(file.name || '')) {
return 'pdf'
}
return 'file'
}
/** File objects → [{ name, data, kind }] (data URLs), oversized files skipped
* with a toast. Images are downscaled; PDFs and other files ride as raw data
* URLs for the gateway's pdf.attach / file.attach staging. Shared by the
* picker button, the composer paste handler, and room drag & drop. */
async function filesToGroupAttachments(files) {
const picked = []
for (const file of [...(files || [])]) {
if (!file) {
continue
}
if (file.size > 15_000_000) {
host.notify({ kind: 'error', message: `${file.name || 'attachment'}: too large (max 15MB).` })
continue
}
const data = await new Promise(done => {
const reader = new FileReader()
reader.onload = () => done(typeof reader.result === 'string' ? reader.result : null)
reader.onerror = () => done(null)
reader.readAsDataURL(file)
})
if (!data) {
continue
}
const kind = groupAttachmentKind(file)
picked.push({
name: file.name || (kind === 'image' ? 'pasted image' : 'attachment'),
data: kind === 'image' ? await normalizeGroupAttachment(data) : data,
kind
})
}
return picked
}
/** Multi-file picker for the group composer — any file type; kind decides
* the staging RPC. Resolves to [{ name, data, kind }]. */
function pickGroupAttachments() {
return new Promise(resolve => {
const input = document.createElement('input')
input.type = 'file'
input.multiple = true
input.onchange = () => resolve(filesToGroupAttachments(input.files))
input.click()
})
}
/** Bound a group attachment's long edge so room logs (persisted with the
* plugin's other durable state) stay light while screenshots keep enough
* resolution for vision models to read text. No-op for small images or
* anything the canvas can't decode. */
function normalizeGroupAttachment(dataUrl, maxEdge = 1568) {
return new Promise(resolve => {
const img = new Image()
img.onload = () => {
try {
const long = Math.max(img.width, img.height)
if (!long || long <= maxEdge) {
return resolve(dataUrl)
}
const scale = maxEdge / long
const canvas = document.createElement('canvas')
canvas.width = Math.max(1, Math.round(img.width * scale))
canvas.height = Math.max(1, Math.round(img.height * scale))
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height)
resolve(canvas.toDataURL('image/png'))
} catch {
resolve(dataUrl)
}
}
img.onerror = () => resolve(dataUrl)
img.src = dataUrl
})
}
/** Cached probe: does the gateway have an image backend? A `false` answer
* is re-checked on every dialog open — the gateway may have been restarted
* (picking up image.generate) or a backend enabled since the last probe.
* Only `true` is sticky. */
const $imagenAvailable = atom(null)
let imagenProbeInflight = null
function probeImagen() {
if (imagenProbeInflight) {
return imagenProbeInflight
}
imagenProbeInflight = host
.request('image.generate', { probe: true })
.then(res => $imagenAvailable.set(Boolean(res?.available)))
.catch(() => $imagenAvailable.set(false))
.finally(() => {
imagenProbeInflight = null
})
return imagenProbeInflight
}
async function generateAvatarImage(bot, title, description) {
const who = [title || bot, description].filter(Boolean).join(' — ')
const res = await host.request('image.generate', {
prompt:
`Cute minimal robot avatar for an AI agent named "${who}". ` +
'Friendly simple mascot face, bold flat vector style, solid color background, centered, no text.',
aspect_ratio: 'square'
})
if (!res?.success) {
throw new Error(res?.error || 'generation failed')
}
// image_data (data URL) works over local AND remote gateways; the raw
// backend URL is the fallback when the gateway couldn't inline it.
return res.image_data || res.image
}
/** Shape grid + color swatches, shared by Edit Profile and New Agent.
* Layout uses inline grid styles — arbitrary Tailwind classes like
* `grid-cols-7` are NOT in the app's precompiled CSS, which collapsed
* this into a single vertical column. */
function AvatarPicker({ shape, color, image, onShape, onColor, onImage, generateSeed }) {
const pickerName = generateSeed?.name || 'agent'
const imagen = useValue($imagenAvailable)
const [tab, setTab] = useState('bot')
const [describe, setDescribe] = useState('')
const [genBusy, setGenBusy] = useState(false)
if (imagen === null) {
void probeImagen()
}
// Re-check a stale "unavailable" whenever the user lands on the Generate
// tab — the gateway may have restarted with image.generate since.
const goTab = id => {
setTab(id)
if (id === 'generate' && $imagenAvailable.get() === false) {
$imagenAvailable.set(null)
void probeImagen()
}
}
const upload = async () => {
const raw = await pickImageFromDevice()
if (raw) {
onImage(await normalizeAvatarImage(raw))
}
}
const generate = async () => {
if (genBusy) {
return
}
setGenBusy(true)
try {
const custom = describe.trim()
const img = custom
? await (async () => {
const res = await host.request('image.generate', {
prompt: `${custom}. Avatar for an AI agent: centered, bold flat vector style, solid color background, no text.`,
aspect_ratio: 'square'
})
if (!res?.success) {
throw new Error(res?.error || 'generation failed')
}
return res.image_data || res.image
})()
: await generateAvatarImage(generateSeed?.name || 'agent', generateSeed?.title, generateSeed?.description)
if (img) {
onImage(await normalizeAvatarImage(img))
}
} catch (err) {
host.notifyError(err, 'Avatar generation failed')
} finally {
setGenBusy(false)
}
}
const tabButton = (id, label) =>
jsx(
'button',
{
type: 'button',
className: cn(
'rounded-full px-3 py-1 text-xs font-medium transition-colors',
tab === id
? 'bg-(--chrome-action-hover) text-foreground'
: 'text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)'
),
onClick: () => goTab(id),
children: label
},
id
)
return jsxs('div', {
className: 'grid justify-items-center gap-3',
children: [
// Tab pills: Bot | Generate | Upload | Pet
jsxs('div', {
className: 'flex items-center gap-1',
children: [tabButton('bot', 'Bot'), tabButton('generate', 'Generate'), tabButton('upload', 'Upload'), tabButton('pet', 'Pet')]
}),
image && tab !== 'generate'
? jsx(Button, {
type: 'button',
variant: 'ghost',
size: 'sm',
onClick: () => onImage(null),
children: 'Remove image — use shape'
})
: null,
tab === 'bot'
? isBlobShape(shape) && blobatarSvg
? (() => {
const { seedPart, kind } = parseBlobShape(shape, pickerName)
const locked = Boolean(seedPart)
return jsxs('div', {
className: 'grid justify-items-center gap-3',
children: [
// Silhouette pins: Auto (name decides) + the six blob kinds.
jsx('div', {
style: {
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: '6px',
justifyItems: 'center'
},
children: ['', ...BLOB_KINDS].map(k =>
jsx(
'button',
{
type: 'button',
title: k || 'Auto — the name decides',
className: cn(
'flex items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover)',
k === kind && !image && 'ring-1 ring-(--ui-accent)'
),
style: { width: 44, height: 44 },
onClick: () => {
onImage(null)
onShape(blobShapeString(seedPart, k))
},
children: k
? jsx(BotFace, { shape: blobShapeString(seedPart, k), color, size: 32, name: pickerName })
: jsx('span', { className: 'text-[0.6rem] text-(--ui-text-tertiary)', children: 'Auto' })
},
k || 'auto'
)
)
}),
jsxs('div', {
className: 'flex items-center gap-1',
children: [
jsxs(Button, {
type: 'button',
variant: 'ghost',
size: 'sm',
onClick: () => {
onImage(null)
onShape(blobShapeString(Math.random().toString(36).slice(2, 10), kind))
},
children: [jsx(Codicon, { name: 'refresh', className: 'mr-1 text-[0.8rem]' }), 'Randomize']
}),
jsxs(Button, {
type: 'button',
variant: 'ghost',
size: 'sm',
title: locked
? 'Unlock — the face follows the agent\u2019s name again'
: 'Keep this exact face even if the name changes',
onClick: () => onShape(blobShapeString(locked ? '' : pickerName, kind)),
children: [
jsx(Codicon, { name: locked ? 'unlock' : 'lock', className: 'mr-1 text-[0.8rem]' }),
locked ? 'Unlock' : 'Lock face'
]
})
]
}),
jsx('div', {
className: 'text-center text-[0.65rem] text-(--ui-text-quaternary)',
children: locked ? 'Face locked — renaming won\u2019t change it.' : 'Face follows the name.'
}),
jsx(Button, {
type: 'button',
variant: 'ghost',
size: 'sm',
className: 'text-(--ui-text-tertiary)',
onClick: () => onShape(defaultShapeFor(pickerName)),
children: 'Classic shapes'
})
]
})
})()
: jsxs('div', {
className: 'grid justify-items-center gap-3',
children: [
jsx('div', {
style: {
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: '6px',
justifyItems: 'center'
},
children: (blobatarSvg ? ['blobatar', ...AVATAR_PICKER_SHAPES] : AVATAR_PICKER_SHAPES).map(s =>
jsx(
'button',
{
type: 'button',
title: s === 'blobatar' ? 'Blob face — drawn from the agent\u2019s name' : undefined,
className: cn(
'flex items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover)',
s === shape && !image && 'ring-1 ring-(--ui-accent)'
),
style: { width: 44, height: 44 },
onClick: () => {
onImage(null)
onShape(s)
},
children: jsx(BotFace, { shape: s, color, size: 32, name: pickerName })
},
s
)
)
}),
jsx('div', {
style: {
display: 'grid',
gridTemplateColumns: 'repeat(5, minmax(0, 1fr))',
gap: '8px',
justifyItems: 'center'
},
children: AVATAR_COLORS.map(c =>
jsx(
'button',
{
type: 'button',
className: cn(
'rounded-full transition-transform hover:scale-110',
c === color && 'ring-2 ring-(--ui-accent) ring-offset-1 ring-offset-(--ui-bg, transparent)'
),
style: { width: 22, height: 22, backgroundColor: c },
onClick: () => onColor(c)
},
c
)
)
})
]
})
: null,
tab === 'generate'
? imagen
? jsxs('div', {
className: 'grid w-full gap-2',
children: [
jsx(Textarea, {
className: 'min-h-16 text-xs',
placeholder: 'Describe your avatar…',
value: describe,
onChange: event => setDescribe(event.target.value)
}),
jsxs(Button, {
type: 'button',
variant: 'secondary',
className: 'w-full justify-center',
disabled: genBusy,
onClick: generate,
children: [
genBusy
? jsx(GlyphSpinner, { spinner: 'breathe', className: 'mr-1 text-[0.8rem]' })
: jsx(Codicon, { name: 'sparkle', className: 'mr-1 text-[0.8rem]' }),
genBusy ? 'Generating…' : 'Generate'
]
}),
describe.trim()
? null
: jsx('div', {
className: 'text-center text-[0.65rem] text-(--ui-text-quaternary)',
children: 'Leave blank to generate from the agent\u2019s name and description.'
})
]
})
: jsx('div', {
className: 'px-2 py-3 text-center text-xs leading-5 text-(--ui-text-tertiary)',
children:
imagen === false
? 'No image model available. If you just enabled one (or updated Hermes), restart the gateway: Ctrl+K → "Restart gateway".'
: 'Checking image backend…'
})
: null,
tab === 'upload'
? jsxs(Button, {
type: 'button',
variant: 'secondary',
className: 'w-full justify-center',
onClick: upload,
children: [jsx(Codicon, { name: 'device-camera', className: 'mr-1 text-[0.8rem]' }), 'Choose an image…']
})
: null,
tab === 'pet' ? jsx(PetTab, { image, onImage }) : null
]
})
}
// ── pet tab: attach a petdex companion that lives beside the avatar ─────────
// A petdex "spritesheet" is the FULL animation sheet (1536×1872 webp, ~2MB;
// 8×9 grid of 192×208 frames). Using it as an <img> both downloads megabytes
// per tile and shows the whole sheet squashed. Extract frame 0 once per slug
// via canvas, downscale to 96px, and cache the data URL. Concurrency-capped
// so opening the tab doesn't fire dozens of 2MB fetches at once.
const PET_FRAME_W = 192
const PET_FRAME_H = 208
const petFrameCache = new Map()
let petFetchActive = 0
const petFetchQueue = []
function pumpPetQueue() {
while (petFetchActive < 4 && petFetchQueue.length) {
const job = petFetchQueue.shift()
petFetchActive++
job().finally(() => {
petFetchActive--
pumpPetQueue()
})
}
}
function petFrameIcon(spriteUrl) {
if (!spriteUrl) {
return Promise.resolve(null)
}
if (!petFrameCache.has(spriteUrl)) {
petFrameCache.set(
spriteUrl,
new Promise(resolve => {
petFetchQueue.push(async () => {
try {
const resp = await fetch(spriteUrl, { signal: AbortSignal.timeout(15000) })
const blob = await resp.blob()
// Crop frame 0 during decode — never materialize the full sheet.
const bitmap = await createImageBitmap(blob, 0, 0, PET_FRAME_W, PET_FRAME_H)
const canvas = document.createElement('canvas')
canvas.width = 96
canvas.height = 104
canvas.getContext('2d').drawImage(bitmap, 0, 0, 96, 104)
bitmap.close()
resolve(canvas.toDataURL('image/png'))
} catch {
petFrameCache.delete(spriteUrl)
resolve(null)
}
})
pumpPetQueue()
})
)
}
return petFrameCache.get(spriteUrl)
}
/** One pet tile image: frame 0 only, resolved lazily through the cache. */
function PetThumb({ spriteUrl, size = 40 }) {
const [icon, setIcon] = useState(null)
useEffect(() => {
let alive = true
petFrameIcon(spriteUrl).then(url => {
if (alive) {
setIcon(url)
}
})
return () => {
alive = false
}
}, [spriteUrl])
if (!icon) {
return jsx('div', {
style: { width: size, height: size, borderRadius: 6, background: 'var(--chrome-action-hover, rgba(255,255,255,0.06))' }
})
}
return jsx('img', {
src: icon,
alt: '',
style: { width: size, height: size, objectFit: 'contain', imageRendering: 'pixelated', borderRadius: 6 }
})
}
function PetTab({ image, onImage }) {
// Selection is dialog-local: committed by the dialog's Save like any
// uploaded/generated image (a direct meta write here gets clobbered by
// Save's own image state).
const [selectedSlug, setSelectedSlug] = useState(null)
const { data, isLoading } = useQuery({
queryKey: [ID, 'pet-gallery'],
queryFn: () => host.request('pet.gallery', {}),
staleTime: 300000
})
const [query, setQuery] = useState('')
// Windowed rendering: the gallery is 4500+ pets — mounting an <img> per pet
// froze the dialog. Render `limit` at a time and grow on scroll-to-bottom.
const [limit, setLimit] = useState(24)
const pets = data?.pets ?? []
if (isLoading) {
return jsx('div', {
className: 'flex justify-center py-4',
children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' })
})
}
if (!pets.length) {
return jsx('div', {
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
children: 'No pets in the petdex gallery. Run `hermes pets` to explore.'
})
}
const q = query.trim().toLowerCase()
const filtered = q
? pets.filter(pet => (pet.displayName || '').toLowerCase().includes(q) || (pet.slug || '').includes(q))
: pets
// Installed and curated pets surface first — they're the likeliest picks.
const ranked = filtered.slice().sort((a, b) => {
const rank = pet => (pet.installed ? 0 : pet.curated ? 1 : 2)
return rank(a) - rank(b)
})
const visible = ranked.slice(0, limit)
const onScroll = event => {
const el = event.currentTarget
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && limit < ranked.length) {
setLimit(prev => Math.min(prev + 24, ranked.length))
}
}
return jsxs('div', {
className: 'grid w-full gap-2',
children: [
jsx('div', {
className: 'text-center text-[0.65rem] text-(--ui-text-quaternary)',
children: 'Pick a pet as this agent’s profile picture.'
}),
jsx(Input, {
className: 'h-7 text-xs',
placeholder: `Search ${pets.length} pets…`,
value: query,
onChange: event => {
setQuery(event.target.value)
setLimit(24)
}
}),
image && selectedSlug
? jsx(Button, {
type: 'button',
variant: 'ghost',
size: 'sm',
className: 'justify-center',
onClick: () => {
setSelectedSlug(null)
onImage(null)
},
children: 'Remove — back to shape avatar'
})
: null,
filtered.length === 0
? jsx('div', {
className: 'py-3 text-center text-xs text-(--ui-text-quaternary)',
children: 'No pets match.'
})
: jsxs('div', {
onScroll,
style: { maxHeight: 220, overflowY: 'auto' },
children: [
jsx('div', {
style: {
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: '6px'
},
children: visible.map(pet =>
jsxs(
'button',
{
type: 'button',
className: cn(
'grid justify-items-center gap-1 rounded-md p-1.5 transition-colors hover:bg-(--chrome-action-hover)',
selectedSlug === pet.slug && 'ring-1 ring-(--ui-accent)'
),
onClick: () => {
// The pet IS the profile picture: extract frame 0
// and hand it to the dialog as the avatar image.
// Persisted when the user hits Save.
setSelectedSlug(pet.slug)
void petFrameIcon(pet.spritesheetUrl).then(icon => {
if (icon) {
onImage(icon)
} else {
setSelectedSlug(null)
host.notify({ kind: 'error', message: 'Could not load that pet — try another.' })
}
})
},
children: [
jsx(PetThumb, { spriteUrl: pet.spritesheetUrl, size: 40 }),
jsx('span', {
className: 'w-full truncate text-center text-[0.6rem] text-(--ui-text-tertiary)',
children: pet.displayName
})
]
},
pet.slug
)
)
}),
limit < ranked.length
? jsx('div', {
className: 'py-2 text-center text-[0.65rem] text-(--ui-text-quaternary)',
children: `Scroll for more (${limit} of ${ranked.length})`
})
: null
]
})
]
})
}
// ── 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. */
let serverInjectsProtocol = false
/** Pins to resolve precisely on the next roster poll: {profile: chatId}.
* The backend answers "what about THIS conversation" per entry
* (preferred_session), so a row's preview can describe the same session its
* click opens (hermes-agent#88200). Unknown params are ignored by older
* gateways, which simply omit the field. */
function preferredSessionIds(allMeta) {
const pins = {}
for (const [name, meta] of Object.entries(allMeta || {})) {
if (meta?.chat) {
pins[name] = meta.chat
}
}
return pins
}
function useRoster() {
const activeConnectionId = useValue(host.state.connectionId)
return useQuery({
queryKey: [...ROSTER_KEY, activeConnectionId],
queryFn: async () => {
// Rich rows (last_session, ui_meta, has_avatar) come from the ACTIVE
// gateway's profiles.list — unchanged single-source behavior.
const pins = preferredSessionIds($botMeta.get())
const local = await host.request(
'profiles.list',
Object.keys(pins).length ? { preferred_session_ids: pins } : {}
)
// 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()
return mergeMultiSourceRoster(local, union, activeConnectionId, $lastRoster.get())
} catch {
/* older build or roster failure — single-source list stands */
}
}
return local
},
refetchInterval: 5000,
staleTime: 5000,
// Remote (SSH) gateways connect slowly and drop on sleep/wake; keep
// retrying instead of latching a terminal error card.
retry: true,
retryDelay: attempt => Math.min(15000, 1000 * 2 ** attempt)
})
}
/** 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 and route open/warm through
* ensureAgent/warmAgent. Pure — exercised directly by the tests. */
function mergeMultiSourceRoster(local, union, activeConnectionId, previous = []) {
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()
// 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()
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.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,
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. */
function botHandle(name, bot) {
if (bot?.handle && bot.handle !== name) {
return bot.handle
}
return (name || '').trim().toLowerCase() === 'default' ? 'hermes' : name
}
function isActiveRosterBot(bot, active) {
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
}
/** 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. */
function resolveRosterMentions(text, roster, active = {}) {
const members = Array.isArray(roster) ? roster : []
const prose = String(text || '').replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ')
const byForm = new Map()
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())
}
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 = []
const seen = new Set()
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
}
const REMOTE_DM_TIMEOUT_MS = 180000
const REMOTE_DM_POLL_MS = 2000
/** The remote bot's canonical Bot Chat: pinned stored-id from its profile's
* ui_meta first, then resume-by-title, then create. Mirrors
* ensureGroupChatSession so DMs land in the ONE forever-chat instead of
* minting a fresh "Bot Chat" per mention. */
async function ensureRemoteCanonicalChat(route, profile) {
let pinned = null
try {
const listed = await host.requestProfile(route, 'profiles.list', {})
const owner = listed?.profiles?.find(p => p.name === profile)
pinned = owner?.ui_meta?.['hermes-bots']?.chat || null
} catch {
/* older remote gateway — title lookup below still works */
}
for (const target of [pinned, 'Bot Chat']) {
if (!target) {
continue
}
try {
const res = await host.requestProfile(route, 'session.resume', {
session_id: target,
profile,
omit_messages: true
})
if (res?.session_id) {
return { runtime: res.session_id, stored: res.session_key || pinned }
}
} catch {
/* fall through */
}
}
const created = await host.requestProfile(route, 'session.create', {
profile,
title: 'Bot Chat',
// Bot Mode sessions are always hidden from the global sidebar.
hidden: true
})
return { runtime: created?.session_id || null, stored: created?.stored_session_id || null }
}
/** Bounded reply poll on the recipient's session — same shape as a group
* member turn: wait for a NEW assistant message after `before`, or time out. */
async function pollRemoteDmReply(route, profile, sessionRef, before) {
const deadline = Date.now() + REMOTE_DM_TIMEOUT_MS
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, REMOTE_DM_POLL_MS))
let state = null
try {
state = await host.requestProfile(route, 'session.resume', { session_id: sessionRef, profile })
} catch {
continue
}
const messages = Array.isArray(state?.messages) ? state.messages : []
const done = !state?.inflight && !state?.running
if (messages.length > before && done) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg?.role === 'assistant') {
const text = typeof msg.content === 'string'
? msg.content
: Array.isArray(msg.content)
? msg.content.map(p => (typeof p === 'string' ? p : p?.text || '')).join('')
: msg?.text || ''
return String(text).trim() || null
}
}
return null
}
}
return null
}
/** Deliver a user mention to bots on OTHER connections: into each bot's
* canonical Bot Chat, with the standard sender-attribution prefix (so the
* recipient's messaging protocol recognizes an agent-to-agent message), then
* relay the reply back as a notification. Sequential and fire-and-forget
* from the composer's perspective. */
async function deliverRemoteRosterMentions(bots, userText, sender) {
const text = String(userText || '').trim()
if (!text || typeof host.requestProfile !== 'function') {
return
}
const senderName = String(sender?.name || 'the user').trim()
const senderHandle = String(sender?.handle || senderName).trim()
for (const bot of bots) {
const connectionId = String(bot?.connectionId || '').trim()
const profile = String(bot?.name || '').trim() || 'default'
if (!connectionId || connectionId === 'local') {
continue
}
const route = { connectionId, mode: 'remote', profile, targetProfile: profile }
const label = bot.connectionLabel || connectionId
try {
const { runtime, stored } = await ensureRemoteCanonicalChat(route, profile)
if (!runtime) {
throw new Error('No remote session')
}
// Baseline before our submit, so the poll can spot the NEW reply.
let before = 0
try {
const pre = await host.requestProfile(route, 'session.resume', { session_id: stored || runtime, profile })
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
} catch {
/* lazy session — zero messages */
}
// The delivery prefix is the recipient's cue that an agent (not its
// human) is talking — same contract as the local CLI handoff.
await host.requestProfile(route, 'prompt.submit', {
session_id: runtime,
text: `Message from \u{1F916} ${senderName} (@${senderHandle}): ${text}`
})
host.notify?.({
kind: 'info',
title: displayName(bot),
message: `Messaged @${botHandle(profile, bot)} on ${label} — will relay the reply here.`
})
const reply = await pollRemoteDmReply(route, profile, stored || runtime, before)
if (reply) {
host.notify?.({
kind: 'info',
title: `\u{1F916} ${displayName(bot)} (${label})`,
message: reply.slice(0, 500)
})
} else {
host.notify?.({
kind: 'info',
title: displayName(bot),
message: `No reply from @${botHandle(profile, bot)} yet — check its Bot Chat on ${label}.`
})
}
} catch (error) {
host.notifyError?.(error, `Could not reach ${label}`)
}
}
}
/** 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). */
function botRosterKey(bot) {
return `${bot?.connectionId || 'legacy'}::${bot?.name || 'default'}`
}
// ── cross-connection routing ─────────────────────────────────────────────────
// A bot from another registered connection (remoteSource rows) is reached
// through host.requestProfile with a route descriptor; local bots keep the
// active-gateway door. Feature-detected: older desktops without
// requestProfile simply have no remote routes (callers fall back / disable).
/** Route descriptor for a bot on another connection, or null for the local /
* active source (or when the desktop can't route). */
function botConnectionRoute(bot) {
const id = String(bot?.connectionId || '').trim()
if (!bot?.remoteSource || !id || id === 'local' || typeof host.requestProfile !== 'function') {
return null
}
const profile = String(bot?.name || '').trim() || 'default'
return { connectionId: id, mode: 'remote', profile, targetProfile: profile }
}
/** Gateway RPC on the bot's OWN source: requestProfile for remote rows,
* the active gateway for local ones. Never activates/foregrounds. */
async function requestForBot(bot, method, params = {}) {
const route = botConnectionRoute(bot)
if (route) {
return host.requestProfile(route, method, params)
}
return host.request(method, params)
}
/** Stable per-member identity inside a group room. Local members keep their
* bare name (compat with rooms persisted before cross-connection groups);
* remote members get the source-qualified key so `dixie` on the Mini and a
* local `dixie` never share watermarks or sessions. */
function groupMemberKey(member) {
return member?.remoteSource ? botRosterKey(member) : member?.name
}
// Bot metadata is scoped to the active gateway until the server exposes a
// union of rich profile rows. Never paint that metadata onto a thin row from
// another source: two `default` agents must not borrow each other's title,
// pin, avatar, group, unread state, or canonical-chat pointer.
function botRosterMeta(bot, metaByName) {
return bot?.remoteSource ? null : metaByName?.[bot?.name]
}
function showsHandle(name, meta, bot) {
const display = displayName({ name }, meta)
return Boolean(name && display.toLowerCase() !== botHandle(name, bot).toLowerCase())
}
// ── canonical bot chat ───────────────────────────────────────────────────────
// Each bot has ONE forever chat, pinned by stored-session id in bot meta
// (meta.chat — synced server-side via ui_meta, so it follows the profile).
// Opening a bot ALWAYS lands there: never "most recent session", which
// drifts whenever the profile is used from the CLI, Sessions mode, or a
// cronjob. The pin only changes through explicit adoption:
// - grandfather: first open of a bot that already has history pins its
// current latest session, so continuity starts from the chat in use
// - fresh bot: opens a draft; when the first message persists a stored
// session, we adopt that id (empty sessions are pruned server-side, so
// pre-creating one at enable time is not possible)
// - recovery: if the pinned id vanishes from the DB (compaction rewrote
// the lineage), re-pin the newest session carrying the canonical title.
// In-flight creations, keyed by bot name — double-clicking a row must not
// mint two canonical chats.
const canonicalCreations = new Map()
let botOpenGeneration = 0
async function openStoredBotChat(name, storedId, summary) {
if (!storedId || typeof host.openSession !== 'function') {
throw new Error('This Hermes Desktop version cannot open stored sessions')
}
const hasAuthoritativeCount =
typeof summary?.message_count === 'number' && Number.isFinite(summary.message_count)
const expectHistory = hasAuthoritativeCount ? summary.message_count > 0 : true
// A profile backend that just woke up can lose the hydration-timeout race
// even though the session is fine (hermes-agent#89617) — clicking Retry
// succeeds because the backend is warm by then. retryHydrationTimeoutOnce
// asks the SDK layer to retry that same wait internally, BEFORE it arms the
// core stranded-session overlay: a plugin-side retry can't do this because
// only host.openSession sees the resume-exhausted latch that overlay reads.
await host.openSession(storedId, {
profile: name,
intent: 'main',
awaitHydration: true,
expectHistory,
keepAllProfilesScope: true,
retryHydrationTimeoutOnce: true
})
return storedId
}
/** Create the bot's ONE forever chat: a real session opened with a kickoff
* message (the gateway prunes zero-message sessions, so the chat is born
* with the bot introducing itself). Pins the stored id in bot meta and
* returns it. */
function createCanonicalChat(name) {
const inflight = canonicalCreations.get(name)
if (inflight) {
return inflight
}
const run = (async () => {
const res = await host.request('session.create', {
profile: name,
title: 'Bot Chat',
// Always born hidden from the global sidebar — Bot Mode sessions are
// plugin-owned. Core applies this via the generic `hidden` flag
// (deferred as pending_hidden until the row exists); older gateways
// ignore the unknown param and it stays visible.
hidden: true
})
const sid = res?.stored_session_id
const runtime = res?.session_id
if (sid) {
saveBotMeta(name, { chat: sid })
}
// Mount the session view FIRST, then send the kickoff — submitting into
// an unmounted session left the intro reply invisible until reopen.
let opened = false
if (sid && typeof host.openSession === 'function') {
try {
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true })
opened = true
} catch {
// The stored row may not exist until the kickoff persists it. Retry
// after prompt.submit below instead of leaving the chat off-screen.
}
}
if (runtime) {
await new Promise(resolve => window.setTimeout(resolve, 400))
try {
await host.request('prompt.submit', { session_id: runtime, text: 'Hey, tell me about yourself!' })
if (!opened && sid && typeof host.openSession === 'function') {
await host.openSession(sid, { profile: name, intent: 'main', keepAllProfilesScope: true })
}
} catch {
// The chat already exists. Keep the pin so the next click
// opens it instead of making a second Bot Chat.
}
}
return sid || null
})().finally(() => canonicalCreations.delete(name))
canonicalCreations.set(name, run)
return run
}
/** Open the bot's ONE forever chat and return the opened id (or the pin).
*
* Identity rules (hermes-agent#88200 — the row must open the session its
* preview describes):
* - grandfather: no pin + an existing Bot Chat adopts the previewed session
* (`history`, the roster's last_session for this bot) instead of minting
* a new empty chat. Ordinary user conversations are never adopted;
* `last_session` is only a recency hint, not an ownership proof;
* - a live pin is verified through the backend's precise preferred_session
* resolver (hidden rows still resolve; compression lineages resolve to
* the live tip) — never inferred from a paginated, hidden-excluding
* session.list window, which misjudged real hidden pins as gone;
* - transient lookup failures keep the pin: try the stored id as-is, and
* only a rejected open enters recovery. */
function isCanonicalBotChatHistory(history) {
const rootTitle = String(history?.root_title || '').trim()
const title = String(history?.title || '').trim()
return rootTitle === 'Bot Chat' || (!rootTitle && title === 'Bot Chat')
}
async function openBotCanonicalChat(name, pinned, history) {
if (!pinned) {
// Grandfather only an actual Bot Chat. `last_session` is merely the most
// recent row for the profile; adopting it blindly can claim an unrelated
// user conversation and the hide sweep would then hide that conversation.
const adoptId = isCanonicalBotChatHistory(history) ? history.id : null
if (adoptId && typeof host.openSession === 'function') {
await openStoredBotChat(name, adoptId, history)
saveBotMeta(name, { chat: adoptId })
return adoptId
}
return createCanonicalChat(name)
}
// Precise verification. An older gateway ignores the unknown param and
// omits the key — that reads as a lookup failure below, NOT as a missing
// session, so legacy backends keep the try-as-is escape hatch.
let preferred
let lookupFailed = false
try {
const res = await host.request('profiles.list', {
include_sessions: true,
preferred_session_ids: { [name]: pinned }
})
const row = (res?.profiles ?? []).find(p => p.name === name)
preferred = row?.preferred_session
if (preferred === undefined) {
lookupFailed = true
}
} catch {
lookupFailed = true
}
if (lookupFailed) {
// Transient gateway state (or an older backend): the pin is innocent
// until proven guilty — try it as-is. A rejected open is still ambiguous:
// it can be the same reconnect/hydration outage that broke this lookup, so
// preserve the forever-chat pin and surface Retry instead of forking it.
return openStoredBotChat(name, pinned, history)
}
if (preferred && isCanonicalBotChatHistory(preferred)) {
try {
await openStoredBotChat(name, preferred.resolved_id || preferred.id, preferred)
return pinned
} catch (error) {
// The precise lookup JUST confirmed this session exists, so a failed
// open is transient (reconnect, backend restart). Clearing the pin or
// minting a replacement here would fork the bot's forever-chat on
// every hiccup — report and keep everything as it is.
throw error
}
}
if (preferred) {
// The stored pointer resolved to a real session, but not to Bot Mode's
// plumbing session. Treat it as corrupted metadata rather than opening or
// hiding the user's ordinary conversation.
await saveBotMeta(name, { chat: null })
return createCanonicalChat(name)
}
// Definitively gone (db reset, or the lineage was rewritten past
// recovery): re-anchor on the previewed session when there is one.
// A previewed row is safe to re-anchor only when it is Bot Mode plumbing.
// Otherwise a stale pin must not steal the profile's ordinary latest chat.
const recoveryId = isCanonicalBotChatHistory(history) ? history.id : null
if (recoveryId && typeof host.openSession === 'function') {
await openStoredBotChat(name, recoveryId, history)
saveBotMeta(name, { chat: recoveryId })
return recoveryId
}
saveBotMeta(name, { chat: null })
return createCanonicalChat(name)
}
async function prepareBotSource(bot, pinnedChat) {
if (!bot.sourceScoped) {
return pinnedChat
}
if (typeof host.ensureAgent !== 'function') {
throw new Error('Update Hermes Desktop to chat with agents on other connections.')
}
await host.ensureAgent(bot.connectionId, bot.name)
if (!bot.remoteSource) {
return pinnedChat
}
const liveId = String(typeof host.activeConnectionId === 'function' ? host.activeConnectionId() || '' : '').trim()
const targetId = String(bot.connectionId || '').trim()
if (targetId && targetId !== 'local' && liveId !== targetId) {
throw new Error(`Still on ${liveId || 'this device'}, not ${bot.connectionLabel || targetId}`)
}
// Thin rows deliberately omit metadata from the active source. Once their
// owner is active, recover that source's canonical-chat pointer so
// same-named agents never reuse or overwrite each other's pin.
try {
const refreshed = await host.request('profiles.list', {})
const owner = refreshed?.profiles?.find(profile => profile.name === bot.name)
return owner?.ui_meta?.['hermes-bots']?.chat || null
} catch {
// Metadata refresh is best-effort; canonical creation remains the fallback.
return null
}
}
function displayName(bot, meta) {
// Only THIN rows from another source trade the friendly name for their
// connection label — the active gateway's own default must keep reading
// "Hermes". Annotated active rows carry sourceScoped too, and keying this
// off sourceScoped renamed the user's main agent to an IP-derived label
// (community report, Aug 17 2026).
if (bot?.remoteSource && (bot.name || '').trim().toLowerCase() === 'default' && bot.connectionLabel) {
return bot.connectionLabel
}
if (meta?.title?.trim()) {
return meta.title.trim()
}
// Core-profile display name (profile.yaml, set via `hermes profile rename
// default <name>` or the dashboard) — the CLI-level equivalent of a Bot
// Mode title. Rides the profiles.list row; presentation-only.
if (typeof bot?.display_name === 'string' && bot.display_name.trim()) {
return bot.display_name.trim()
}
// The primary profile is literally named "default" — as a bot identity
// that reads like nobody bothered. Present it as Hermes (the agent it is)
// unless the user gives it a real title.
if ((bot.name || '').trim().toLowerCase() === 'default' && !bot.title) {
return 'Hermes'
}
const raw = (bot.title || bot.name || '').replace(/[-_]+/g, ' ').trim()
return raw.replace(/\b\w/g, ch => ch.toUpperCase())
}
/** 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. */
function filterBots(roster, metaByName, query) {
const needle = query.trim().toLowerCase().replace(/^@/, '')
if (!needle) {
return roster
}
return roster.filter(bot => {
const display = displayName(bot, botRosterMeta(bot, metaByName)).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()
return (
display.includes(needle) || profile.includes(needle) || handle.includes(needle) || sourceLabel.includes(needle)
)
})
}
function slugify(value) {
return value
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64)
}
/** Flatten markdown syntax out of a one-line roster preview so rows read
* like Discord's — no raw **bold**, `code`, > quotes, or [link](url)
* characters in the preview line. */
function stripPreviewMarkdown(text) {
return String(text || '')
.replace(/```[\s\S]*?```/g, ' ')
.replace(/`([^`\n]*)`/g, '$1')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(^|\s)[*_](\S(?:.*?\S)?)[*_](?=\s|$|[.,;:!?])/g, '$1$2')
.replace(/~~(.*?)~~/g, '$1')
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
.replace(/^\s{0,3}>\s?/gm, '')
.replace(/\s+/g, ' ')
.trim()
}
/** Canonical multi-group read with legacy scalar compatibility. Profiles that
* predate `groups` still fall back to `group`; once the canonical array exists,
* it is authoritative. Writes keep `group` as a first-membership projection so
* older desktops can still display one room without corrupting the array. */
function botGroups(meta) {
const groups = []
const seen = new Set()
const values = Array.isArray(meta?.groups) ? meta.groups : [meta?.group]
for (const value of values) {
if (typeof value !== 'string') {
continue
}
const group = value.trim()
if (group && !seen.has(group)) {
seen.add(group)
groups.push(group)
}
}
return groups
}
function groupMembershipPatch(meta, group, enabled) {
const name = String(group || '').trim()
let groups = botGroups(meta)
if (enabled) {
if (name && !groups.includes(name)) {
groups = [...groups, name]
}
} else {
groups = groups.filter(existing => existing !== name)
}
return { groups, group: groups[0] || null }
}
/** Group chats that should hold a roster row: every group named in bot meta
* (local members) plus every room record that still has stored members or
* log — cross-connection rooms whose members can't ride bot-meta. */
function groupChatNames(metaByName, rooms) {
const names = new Set(knownGroups(metaByName))
for (const [name, room] of Object.entries(rooms || {})) {
if ((Array.isArray(room?.members) && room.members.length) || (Array.isArray(room?.log) && room.log.length)) {
names.add(name)
}
}
return [...names]
}
/** Millisecond timestamp of a room's newest log entry (0 for a silent room) —
* the group's recency key, competing in the same ordering as bot rows. */
function groupLastActivity(room) {
const log = Array.isArray(room?.log) ? room.log : []
return log.length ? log[log.length - 1].at || 0 : 0
}
/** Seat a group's member roster: local bots whose meta names the group, plus
* the room record's stored descriptors (remote members can't ride bot-meta).
* Prefers the LIVE roster row for a stored descriptor when present. */
function groupChatMemberBots(group, roster, metaByName) {
const local = (roster || []).filter(
bot => !bot.remoteSource && botGroups(botRosterMeta(bot, metaByName)).includes(group)
)
const stored = ($groupChats.get()[group] || {}).members || []
const seated = new Set(local.map(botRosterKey))
const remote = []
for (const descriptor of stored) {
const key = botRosterKey(descriptor)
if (seated.has(key)) {
continue
}
seated.add(key)
remote.push((roster || []).find(bot => botRosterKey(bot) === key) || descriptor)
}
return [...local, ...remote]
}
/** Persist source-qualified identities for every selected member. The active
* source's row may become remote after a connection switch, so retaining it
* here is what keeps the same room intact across machines. */
function durableGroupChatMembers(bots) {
return (bots || []).map(bot => ({
name: bot.name,
handle: bot.handle || bot.name,
connectionId: bot.connectionId,
connectionKind: bot.connectionKind,
connectionLabel: bot.connectionLabel,
remoteSource: true,
sourceScoped: true
}))
}
/** Existing group names, alphabetical — feeds the Manage-groups dialog. */
function knownGroups(metaByName) {
const names = new Set()
for (const meta of Object.values(metaByName || {})) {
for (const group of botGroups(meta)) {
names.add(group)
}
}
return [...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
}
// ── group chats: bounded round-robin coordination over a shared room log ─────
//
// Behavioral model (clean-room): a group conversation is ONE ordered room log
// owned by the plugin. A user send triggers at most GROUP_CHAT_MAX_ROUNDS
// serial round-robin rounds over the member roster — never parallel, no LLM
// router. Who speaks each round is a deterministic @mention parse since the
// last user message (mentioned members only, else everyone); whether a member
// actually speaks is its own turn's choice — replying with exactly "(pass)"
// (or nothing, or failing) is silence. Hard caps end every turn; a round in
// which everyone passed means the conversation settled. Each member runs its
// turn in its OWN persistent per-group Hermes session and is fed only the
// room messages that are NEW since it last saw the room.
const GROUP_CHAT_MAX_ROUNDS = 3
const GROUP_CHAT_MAX_MESSAGES = 10
const GROUP_CHAT_HISTORY_LIMIT = 24
const GROUP_CHAT_MAX_MEMBERS = 6
/** "(pass)" (loosely: pass / (pass) / pass.) or empty = the member stayed silent. */
function isGroupPassText(text) {
const trimmed = String(text || '').trim()
if (!trimmed) {
return true
}
return /^\(?\s*pass\s*\)?\.?$/i.test(trimmed)
}
/** Deterministic @mention parse. Handles @name, @"two words" via display
* titles, and @everyone/@all. Names match case-insensitively against member
* profile names, display titles, and collapsed no-space forms. */
function parseGroupChatMentions(text, members) {
const source = String(text || '')
const mentioned = new Set()
let everyone = false
const handles = new Map()
for (const member of members) {
const title = String(member.title || '').trim()
// Cross-connection members are also addressable by their @name-device
// handle (the roster's disambiguated form) — same-named agents on two
// machines resolve to the right one.
const handle = String(member.handle || botHandle(member.name, member) || '').trim()
const forms = new Set([
member.name.toLowerCase(),
member.name.toLowerCase().replace(/[\s_-]+/g, ''),
...(handle ? [handle.toLowerCase(), handle.toLowerCase().replace(/[\s_-]+/g, '')] : []),
...(title
? [title.toLowerCase(), title.toLowerCase().replace(/[\s_-]+/g, ''), title.split(/\s+/)[0].toLowerCase()]
: [])
])
for (const form of forms) {
if (form) {
handles.set(form, groupMemberKey(member))
}
}
}
for (const match of source.matchAll(/@([a-z0-9][a-z0-9._-]*)/gi)) {
const handle = match[1].toLowerCase()
if (handle === 'everyone' || handle === 'all') {
everyone = true
continue
}
if (handle === 'user') {
continue
}
const resolved = handles.get(handle) || handles.get(handle.replace(/[._-]+/g, ''))
if (resolved) {
mentioned.add(resolved)
}
}
return { everyone, mentioned }
}
/** Members that should take a turn this round: everyone when no member is
* @-mentioned in messages since the last user entry (or @everyone appears),
* otherwise only the mentioned members. Recomputed every round so a member
* pulled in mid-conversation joins the next round. */
function resolveGroupResponders(log, members) {
let sinceLastUser = []
for (let i = log.length - 1; i >= 0; i--) {
if (log[i].from.kind === 'user') {
sinceLastUser = log.slice(i)
break
}
}
const mentioned = new Set()
let everyone = false
for (const entry of sinceLastUser) {
const parsed = parseGroupChatMentions(entry.text, members)
if (parsed.everyone) {
everyone = true
}
for (const name of parsed.mentioned) {
mentioned.add(name)
}
}
if (everyone || mentioned.size === 0) {
return members
}
return members.filter(member => mentioned.has(groupMemberKey(member)))
}
/** Rotate the roster so a different member leads each round. */
function rotateGroupSpeakers(members, round) {
if (members.length < 2) {
return members
}
const shift = round % members.length
return [...members.slice(shift), ...members.slice(0, shift)]
}
/** Transcript form of a room speaker's profile name. The primary profile is
* literally named "default" — render it as Hermes (matching displayName and
* the @hermes handle) so the main agent never loses its name in rooms. */
function groupSpeakerLabel(name) {
return (name || '').trim().toLowerCase() === 'default' ? 'Hermes' : name
}
/** Room-log line as a member sees it: `Name (user): …` / `Name: …` /
* `Name (you): …`. */
function formatGroupChatLine(entry, viewerName) {
// Attachments are staged into each member's session as real payloads; the
// transcript line names them so the delta text and the bytes line up.
const attached = Array.isArray(entry.images) && entry.images.length
? ` ${entry.images
.map(img => {
const label = img.kind === 'pdf' ? 'attached PDF' : img.kind === 'file' ? 'attached file' : 'attached image'
return `[${label}: ${img.name || 'image'}]`
})
.join(' ')}`
: ''
if (entry.from.kind === 'user') {
return `${entry.from.name || 'User'} (user): ${entry.text}${attached}`
}
const suffix = entry.from.name === viewerName ? ' (you)' : ''
// Cross-connection speakers carry their device so same-named agents on
// two machines stay tellable apart in every member's transcript.
const source = entry.from.source ? ` [${entry.from.source}]` : ''
return `${groupSpeakerLabel(entry.from.name)}${suffix}${source}: ${entry.text}${attached}`
}
/** The full per-turn payload for one member: participation rules + the room
* delta. Rules travel in the turn payload (not SOUL) so every existing bot
* can join a group chat without a profile migration. */
function buildGroupChatTurnPrompt({ groupName, members, viewer, deltaLines }) {
const viewerKey = groupMemberKey(viewer)
const peers = members.filter(m => groupMemberKey(m) !== viewerKey)
const peerNames = peers
.map(m => {
const handle = m.title ? `${m.title} (@${botHandle(m.name, m)})` : `@${botHandle(m.name, m)}`
return m.remoteSource ? `${handle} [on ${m.connectionLabel || m.connectionId}]` : handle
})
.join(', ')
return [
`[Group chat: "${groupName}"] You are @${botHandle(viewer.name, viewer)}, one participant in a group chat with ${peerNames || 'no one else yet'} and the user.`,
'',
'New messages in the room since your last turn (oldest first):',
...deltaLines.map(line => ` ${line}`),
'',
'Rules for this room:',
'- Reply with ONE conversational message ONLY if you have something new worth adding: build on what was just said, claim or hand off work, answer a question aimed at you, or report a real result. Keep chatter short (1-3 sentences) — but when you are delivering a result, an answer the user asked for, or substantive work, give it at full quality and length; never thin out real content to fit the room.',
'- If you have nothing new to add, reply with exactly "(pass)". Passing is good — it lets the conversation settle.',
'- Mention a teammate as @name to pull them in; mention @user only for a judgment call or a result the user needs. Do not repeat points already made.',
'- Never reveal content from your private 1:1 chats. Your reply text goes to the room verbatim — no preamble, no meta-commentary.'
].join('\n')
}
/** Trim a room log + its watermarks to the retained window, keeping
* watermark indices consistent with the trimmed array. */
function trimGroupChatLog(log, watermarks, limit = GROUP_CHAT_HISTORY_LIMIT * 4) {
if (log.length <= limit) {
return { log, watermarks }
}
const drop = log.length - limit
const trimmed = {}
for (const [name, index] of Object.entries(watermarks || {})) {
trimmed[name] = Math.max(0, index - drop)
}
return { log: log.slice(drop), watermarks: trimmed }
}
/** Mutate one group's room state through the atom + persist the durable part. */
function updateGroupChat(group, mutate) {
const all = { ...$groupChats.get() }
const current = all[group] || { log: [], watermarks: {}, epoch: 0, running: false }
const next = mutate({ ...current, log: [...current.log], watermarks: { ...current.watermarks } })
const bounded = trimGroupChatLog(next.log, next.watermarks)
next.log = bounded.log
next.watermarks = bounded.watermarks
all[group] = next
$groupChats.set(all)
try {
const durable = {}
for (const [name, room] of Object.entries(all)) {
durable[name] = {
log: room.log,
watermarks: room.watermarks,
sessions: room.sessions || {},
// Timed-out turns awaiting a late reply — keyed by member, valued
// with the pre-turn message baseline. Survives reloads so finished
// work is still harvested after a window restart.
stranded: room.stranded || {},
// Source-qualified member descriptors keep the room whole when the
// active connection changes and today's local members become remote.
members: Array.isArray(room.members) ? room.members : [],
// Room picture (small data URL, same normalization as bot avatars).
image: room.image || null
}
}
Promise.resolve(pluginCtx?.storage?.set?.('group-chats', durable)).catch(() => undefined)
} catch {
/* storage unavailable — room survives for this window only */
}
return next
}
/** Soft-disband a group chat: remove only this group from every local member's
* membership list (the metadata syncs cross-machine via ui_meta), drop the
* room log from the atom + plugin storage, and close the room view if it's
* open. Other group memberships and the members' per-group gateway sessions
* ("Group: <name>") are intentionally KEPT. */
async function disbandGroupChat(group, members) {
// Invalidate any in-flight round-robin FIRST: bump the epoch so a running
// drive bails at its next member boundary instead of appending to a room
// the user just discarded.
const all = { ...$groupChats.get() }
const prior = all[group] || {}
delete all[group]
// Keep a runtime-only tombstone while a drive may still be mid-turn; it
// carries no log and is never persisted, so it can't rehydrate.
if (prior.running) {
all[group] = { log: [], watermarks: {}, sessions: {}, epoch: (prior.epoch || 0) + 1, running: false }
}
$groupChats.set(all)
if ($groupChatWorkspace.get() === group) {
$groupChatWorkspace.set(null)
}
// Retire the room's MAIN-window tab too (host.openWorkspace path).
closeGroupChatMainTab(group)
const needs = { ...$groupNeedsYou.get() }
delete needs[group]
$groupNeedsYou.set(needs)
// Persist the room map WITHOUT the disbanded room so it can't come back
// on the next window load.
try {
const durable = {}
for (const [name, room] of Object.entries($groupChats.get())) {
if (name !== group && Array.isArray(room.log)) {
durable[name] = {
log: room.log,
watermarks: room.watermarks,
sessions: room.sessions || {},
members: Array.isArray(room.members) ? room.members : [],
image: room.image || null
}
}
}
await Promise.resolve(pluginCtx?.storage?.set?.('group-chats', durable))
} catch {
/* storage unavailable — the atom reset above still empties the room */
}
// Remove this membership last. saveBotMeta never throws (local storage +
// best-effort profiles.configure per member), so a flaky gateway can't
// strand the disband halfway with the room log already gone.
for (const member of members) {
if (!member?.name || member.remoteSource) {
continue
}
const meta = $botMeta.get()[member.name] || {}
await saveBotMeta(member.name, groupMembershipPatch(meta, group, false))
}
}
/** Set or clear a group chat's room picture (small data URL, normalized by
* the same pipeline as bot avatars). Persists with the room record. */
function setGroupChatImage(group, image) {
updateGroupChat(group, room => {
room.image = image || null
return room
})
}
/** Rename a group chat. The group's NAME is its identity everywhere — the
* room-map key, each local member's ui_meta membership list, and derived
* state — so a rename re-keys all of them. Member gateway sessions are kept
* as-is: stored sids keep resuming, so no history is lost (only a member
* whose sid is later lost falls back to a fresh "Group: <new name>" title
* lookup). Returns the new name, or null when the target name is taken. */
async function renameGroupChat(oldName, newName, members) {
const next = String(newName || '').trim().slice(0, 64)
if (!next || next === oldName) {
return oldName
}
// Renames are explicit user intent: reject a collision honestly instead of
// silently suffixing like creation does.
const taken = new Set(Object.keys($groupChats.get()))
for (const meta of Object.values($botMeta.get() || {})) {
for (const existing of botGroups(meta)) {
taken.add(existing)
}
}
taken.delete(oldName)
if (taken.has(next)) {
host.notify({ kind: 'error', message: `A group named “${next}” already exists.` })
return null
}
// Move the room record wholesale — log, watermarks, sessions, members,
// picture, and runtime flags all belong to the same room under its new name.
const all = { ...$groupChats.get() }
const room = all[oldName]
delete all[oldName]
if (room) {
all[next] = room
}
$groupChats.set(all)
const needs = { ...$groupNeedsYou.get() }
if (oldName in needs) {
needs[next] = needs[oldName]
delete needs[oldName]
$groupNeedsYou.set(needs)
}
// Local memberships: swap the name inside each member's canonical groups
// list (syncs cross-machine via ui_meta). Remote members' seating lives in
// the room record we just moved.
for (const member of members || []) {
if (!member?.name || member.remoteSource) {
continue
}
const meta = $botMeta.get()[member.name] || {}
const groups = [...new Set(botGroups(meta).map(g => (g === oldName ? next : g)))]
await saveBotMeta(member.name, { groups, group: groups[0] || null })
}
// Persist the re-keyed map (updateGroupChat writes the whole durable map).
updateGroupChat(next, r => r)
// Follow the open views to the new identity.
if ($groupChatWorkspace.get() === oldName) {
$groupChatWorkspace.set(next)
}
if (groupChatMainTabs.has(oldName)) {
closeGroupChatMainTab(oldName)
openGroupChat(next)
}
return next
}
function appendGroupChatEntry(group, from, text, thread, images) {
const entry = { at: Date.now(), from, text: String(text).trim(), thread: thread || 'legacy' }
if (Array.isArray(images) && images.length) {
// [{ name, data }] — data URLs. Persisted with the room log so reloads
// keep showing what the members were shown.
entry.images = images
}
updateGroupChat(group, room => {
room.log.push(entry)
return room
})
// Needs-you: a member addressing @user badges the group header.
if (from.kind === 'member' && /@user\b/i.test(entry.text)) {
$groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: true })
}
return entry
}
/** Ensure the member's per-group session exists and return a LIVE runtime
* session id for it. Gateway-native: session.create mints the session
* (lazy until its first message), session.resume by stored id — or by
* title, which also covers rehydrated rooms whose sid was lost — reopens
* it after restarts. Cross-connection members route to their OWN source
* via requestForBot; the window's gateway never switches. */
async function ensureGroupChatSession(group, member) {
const title = `Group: ${group}`
const room = $groupChats.get()[group] || {}
const key = groupMemberKey(member)
const known = room.sessions && room.sessions[key]
// Try resuming what we know (stored sid first, then title lookup).
for (const target of [known, title]) {
if (!target || target === true) {
continue
}
try {
const res = await requestForBot(member, 'session.resume', {
session_id: target,
profile: member.name,
omit_messages: true
})
if (res?.session_id) {
return { runtime: res.session_id, stored: res.session_key || known }
}
} catch {
/* fall through to create */
}
}
const created = await requestForBot(member, 'session.create', {
profile: member.name,
title,
// Room member sessions are plumbing — always hidden from the sidebar.
hidden: true
})
const stored = created?.stored_session_id || null
if (stored) {
updateGroupChat(group, r => {
r.sessions = { ...(r.sessions || {}), [key]: stored }
return r
})
}
return { runtime: created?.session_id || null, stored }
}
const GROUP_TURN_TIMEOUT_MS = 180000
const GROUP_TURN_POLL_MS = 2000
// A member turn that is VISIBLY still working (session reports
// inflight/running) keeps its slot alive up to this hard cap. The base
// timeout alone silently dropped long real turns: a 7-minute research run
// timed out at 3 minutes, read as a pass, and its finished result never
// reached the room (db's Aug 2026 report).
const GROUP_TURN_HARD_CAP_MS = 20 * 60000
/** One member turn, gateway-native: submit the room delta as a prompt into
* the member's per-group session, then poll the session until a NEW
* assistant message lands (or timeout → pass). While the session visibly
* reports work in flight the deadline extends (bounded by the hard cap),
* so slow models aren't cut off mid-run. A turn that still times out
* records a stranded marker so the finished reply can be harvested into
* the room at the member's next turn instead of being lost. */
async function runGroupChatMemberTurn(group, member, prompt, thread, images) {
const { runtime, stored } = await ensureGroupChatSession(group, member)
if (!runtime) {
return null
}
recordGroupActivity(group, { kind: 'working', member: member.name, thread })
// Baseline: how many messages exist before our submit.
let before = 0
try {
const pre = await requestForBot(member, 'session.resume', {
session_id: stored || runtime,
profile: member.name
})
before = Array.isArray(pre?.messages) ? pre.messages.length : pre?.message_count || 0
} catch {
/* lazy session — zero messages */
}
// Stage this delta's attachments into the member's session so the model
// receives the actual payload with the prompt — the same attach RPCs the
// 1:1 chat uses (they also work cross-connection, where the member's
// gateway can't see this machine's files). Images queue as vision tiles,
// PDFs render per-page via pdf.attach, and other files materialize in the
// session workspace (their @file: refs are appended to the prompt so the
// member's file tools can read them). A failed attach degrades that
// member to text-only; the transcript line still names the attachment so
// the member knows something was shared.
const fileRefs = []
for (const img of Array.isArray(images) ? images : []) {
if (!img || typeof img.data !== 'string' || !img.data) {
continue
}
try {
if (img.kind === 'pdf') {
await requestForBot(member, 'pdf.attach', {
session_id: runtime,
content_base64: img.data,
filename: img.name || 'attachment.pdf'
})
} else if (img.kind === 'file') {
const res = await requestForBot(member, 'file.attach', {
session_id: runtime,
data_url: img.data,
name: img.name || 'attachment'
})
if (res?.ref_text) {
fileRefs.push(`${img.name || 'attachment'} → ${res.ref_text}`)
}
} else {
await requestForBot(member, 'image.attach_bytes', {
session_id: runtime,
content_base64: img.data,
filename: img.name || 'attachment.png'
})
}
} catch {
/* text-only fallback for this member */
}
}
const turnText = fileRefs.length
? `${prompt}\n\nAttached files staged in your session workspace:\n${fileRefs.join('\n')}`
: prompt
await requestForBot(member, 'prompt.submit', { session_id: runtime, text: turnText })
const started = Date.now()
let deadline = started + GROUP_TURN_TIMEOUT_MS
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, GROUP_TURN_POLL_MS))
let state = null
try {
state = await requestForBot(member, 'session.resume', {
session_id: stored || runtime,
profile: member.name
})
} catch {
continue
}
const messages = Array.isArray(state?.messages) ? state.messages : []
const busy = Boolean(state?.inflight || state?.running)
const done = !busy
if (messages.length > before && done) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg?.role === 'assistant') {
const text = typeof msg.content === 'string'
? msg.content
: Array.isArray(msg.content)
? msg.content.map(p => (typeof p === 'string' ? p : p?.text || '')).join('')
: msg?.text || ''
const replyText = String(text).trim()
recordGroupActivity(group, {
kind: isGroupPassText(replyText) ? 'passed' : 'replied',
member: member.name,
thread
})
return replyText
}
}
recordGroupActivity(group, { kind: 'passed', member: member.name, thread })
return null
}
// Still visibly working: extend the deadline (never past the hard cap).
if (busy) {
deadline = Math.min(started + GROUP_TURN_HARD_CAP_MS, Math.max(deadline, Date.now() + GROUP_TURN_TIMEOUT_MS))
}
}
// Timeout — reads as a pass, but remember the baseline + thread
// (runtime-only) so the finished reply can be posted late into the RIGHT
// thread instead of vanishing.
recordGroupActivity(group, { kind: 'timed-out', member: member.name, thread })
updateGroupChat(group, r => {
r.stranded = { ...(r.stranded || {}), [groupMemberKey(member)]: { before, thread } }
return r
})
return null
}
/** Post a timed-out member's finished reply into the room, if it landed
* after we stopped waiting. Called at the member's next turn boundary and
* on user sends, so long-running work is delivered late rather than lost. */
async function harvestStrandedGroupReply(group, member) {
const memberKey = groupMemberKey(member)
const room = $groupChats.get()[group] || {}
const marker = room.stranded?.[memberKey]
// Markers were a bare number before threads; normalize both shapes.
const strandedBefore = typeof marker === 'number' ? marker : marker?.before
const strandedThread = (typeof marker === 'object' && marker?.thread) || 'legacy'
if (typeof strandedBefore !== 'number') {
return
}
let state = null
try {
const stored = room.sessions?.[memberKey]
state = await requestForBot(member, 'session.resume', {
session_id: stored || `Group: ${group}`,
profile: member.name
})
} catch {
return // source unreachable — leave the marker for the next boundary
}
if (state?.inflight || state?.running) {
return // still grinding — keep waiting
}
// Done (or dead): the marker is consumed either way.
updateGroupChat(group, r => {
const next = { ...(r.stranded || {}) }
delete next[memberKey]
r.stranded = next
return r
})
const messages = Array.isArray(state?.messages) ? state.messages : []
if (messages.length <= strandedBefore) {
return
}
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg?.role === 'assistant') {
const text = typeof msg.content === 'string'
? msg.content
: Array.isArray(msg.content)
? msg.content.map(p => (typeof p === 'string' ? p : p?.text || '')).join('')
: msg?.text || ''
const reply = String(text).trim()
if (reply && !isGroupPassText(reply)) {
recordGroupActivity(group, { kind: 'delivered', member: member.name, thread: strandedThread })
appendGroupChatEntry(
group,
{ kind: 'member', name: member.name, ...(member.remoteSource ? { source: member.connectionLabel || member.connectionId } : {}) },
reply,
strandedThread
)
updateGroupChat(group, r => {
r.watermarks[`${strandedThread}::${memberKey}`] = r.log.length
return r
})
}
return
}
}
}
/** Drive one bounded round-robin turn for ONE THREAD. Serial — one member at
* a time. A newer user send bumps the room epoch; this loop notices at the
* next member boundary, bails, and the newest send's own loop takes over.
* Watermarks are per thread+member (`${thread}::${memberKey}`), so parallel
* topics never eat each other's deltas. */
async function runGroupChatRounds(group, members, thread) {
const startEpoch = ($groupChats.get()[group] || {}).epoch || 0
const isCurrent = () => (($groupChats.get()[group] || {}).epoch || 0) === startEpoch
let posted = 0
try {
for (let round = 0; round < GROUP_CHAT_MAX_ROUNDS; round++) {
// Deliver any replies that finished after their turn timed out —
// every member, not just this round's responders, so long work is
// late, never lost.
for (const member of members) {
if (!isCurrent()) {
recordGroupActivity(group, { kind: 'cancelled', member: null, thread })
return
}
await harvestStrandedGroupReply(group, member)
}
const roomLog = (($groupChats.get()[group] || {}).log || []).filter(e => groupThreadOf(e) === thread)
// Exclude members the harvest pass just above confirmed are STILL
// running (their stranded marker survived harvest because
// state.inflight/running was true). Re-selecting one here would
// prompt.submit into their live session — the gateway's default busy
// policy redirects or hard-interrupts that turn (tui_gateway's
// _handle_busy_submit), killing exactly the long-running work this
// stranded/harvest mechanism exists to protect. Skip them; the next
// harvest pass picks the reply up once it actually lands. A marker's
// mere presence means "still stranded" (harvestStrandedGroupReply
// deletes it once the member is confirmed done/dead) — presence, not
// value shape, since markers are a bare number pre-thread or
// {before, thread} post-thread.
const strandedNow = ($groupChats.get()[group] || {}).stranded || {}
const responders = rotateGroupSpeakers(resolveGroupResponders(roomLog, members), round)
.filter(member => !Object.prototype.hasOwnProperty.call(strandedNow, groupMemberKey(member)))
let spokeThisRound = 0
for (const member of responders) {
if (!isCurrent() || posted >= GROUP_CHAT_MAX_MESSAGES) {
if (!isCurrent()) {
recordGroupActivity(group, { kind: 'cancelled', member: null, thread })
}
return
}
const room = $groupChats.get()[group] || { log: [], watermarks: {} }
const memberKey = groupMemberKey(member)
const markKey = `${thread}::${memberKey}`
const seen = room.watermarks[markKey] || 0
// Delta: NEW room entries, narrowed to this thread — the member's
// turn sees only the conversation it's part of.
const delta = room.log.slice(seen).filter(e => groupThreadOf(e) === thread)
if (!delta.length) {
continue
}
const prompt = buildGroupChatTurnPrompt({
groupName: group,
members,
viewer: member,
deltaLines: delta.slice(-GROUP_CHAT_HISTORY_LIMIT).map(e => formatGroupChatLine(e, member.name))
})
// Images riding this delta (user attachments — member entries don't
// carry images today, but flatMap keeps this future-proof) get staged
// into the member's session so the model sees the pixels, not just
// the transcript's [attached image: …] marker.
const deltaImages = delta.flatMap(e => (Array.isArray(e.images) ? e.images : []))
// Surface WHO is on turn (runtime-only, like running/epoch) so the
// room shows "Radar is thinking…" instead of a generic working line —
// long model turns otherwise read as the room being stuck.
updateGroupChat(group, r => {
r.turn = member.name
return r
})
let reply = null
try {
reply = await runGroupChatMemberTurn(group, member, prompt, thread, deltaImages)
} catch {
recordGroupActivity(group, { kind: 'failed', member: member.name, thread })
reply = null // a failed turn is a pass, never a room error
}
// The member has now seen everything up to the pre-reply log length.
updateGroupChat(group, r => {
r.watermarks[markKey] = r.log.length
return r
})
if (reply !== null && !isGroupPassText(reply)) {
appendGroupChatEntry(
group,
{ kind: 'member', name: member.name, ...(member.remoteSource ? { source: member.connectionLabel || member.connectionId } : {}) },
reply,
thread
)
// Its own message counts as seen too.
updateGroupChat(group, r => {
r.watermarks[markKey] = r.log.length
return r
})
posted += 1
spokeThisRound += 1
}
}
if (spokeThisRound === 0) {
return // everyone passed — the conversation settled
}
}
} finally {
if (isCurrent()) {
recordGroupActivity(group, { kind: 'settled', member: null, thread })
updateGroupChat(group, r => {
r.running = false
r.turn = null
return r
})
}
}
}
/** User send into a group room. `thread` continues that thread (its reply
* box); omitted/null mints a NEW thread — the main composer's Slack shape.
* Appends, bumps the room epoch (supersedes any running loop at its next
* member boundary), and starts the turn drive for the target thread.
* Returns the thread id the message landed in. */
function sendToGroupChat(group, members, text, thread, images) {
const trimmed = String(text || '').trim()
const attached = Array.isArray(images) ? images.filter(img => img && img.data) : []
if ((!trimmed && !attached.length) || !members.length) {
return null
}
const target = thread || mintGroupThreadId()
$groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false })
appendGroupChatEntry(group, { kind: 'user', name: 'You' }, trimmed, target, attached)
const wasRunning = ($groupChats.get()[group] || {}).running === true
updateGroupChat(group, room => {
room.epoch = (room.epoch || 0) + 1
room.running = true
return room
})
recordGroupActivity(group, { kind: 'queued', member: 'You', thread: target })
if (!wasRunning) {
void runGroupChatRounds(group, members, target).catch(() => {
updateGroupChat(group, r => {
r.running = false
return r
})
})
} else {
// A loop is live; it bails at its next boundary. Chain the fresh loop
// after a short settle so exactly one drive owns the room.
setTimeout(() => {
void runGroupChatRounds(group, members, target).catch(() => {
updateGroupChat(group, r => {
r.running = false
return r
})
})
}, 250)
}
return target
}
/** Share one in-flight async operation across concurrent callers. Failures
* clear the slot so a later attempt can retry. */
function singleFlight(ref, start) {
if (ref.current) {
return ref.current
}
let flight
try {
flight = Promise.resolve(start())
} catch (err) {
flight = Promise.reject(err)
}
ref.current = flight
flight.catch(() => {
if (ref.current === flight) {
ref.current = null
}
})
return flight
}
/** The agent-to-agent messaging protocol, reusable so a CUSTOM SOUL keeps
* the handoff protocol too — a custom SOUL used to silently drop it,
* breaking @mentions for customized bots (@wesleysimplicio, #16). */
function messagingProtocolSection(name, roster) {
const teammates = (roster || []).filter(b => b.name !== name)
const handle = botHandle(name)
return [
'## Messaging other agents',
'',
'You work alongside other named agents. Every agent (including you) has',
'ONE canonical conversation titled "Bot Chat" — created with the agent,',
'so it always exists. Agent-to-agent messages are delivered straight',
'into it, like a DM. To message a teammate, run:',
'',
'```',
'hermes -p <agent-name> chat --in ~ -c "Bot Chat" --create-if-missing -Q -q "Message from \uD83E\uDD16 ' + handle + ' (@' + handle + '): your message"',
'',
'Run the send with background=true and notify_on_complete=true on the',
'terminal tool, then finish your turn — the reply arrives later as a',
'background process notification. Never block waiting for it.',
'```',
'',
'(`--in ~ -c "Bot Chat" --create-if-missing` resumes their canonical',
'conversation in the home workspace, creating it if the target has no',
'"Bot Chat" yet. `-Q` keeps output clean. Always open with the',
'"Message from \uD83E\uDD16 ' + handle + ' (@' + handle + '):" prefix so they know',
'who is talking (the @handle lets the app show your avatar to them).',
'Their reply prints to stdout — relay the relevant part back to the',
'user, and say which agent it came from.)',
'',
'If a message in YOUR chat starts with "Message from \uD83E\uDD16 <name>", it is',
'a teammate messaging you, not the user. Answer it directly — your reply',
'reaches them via their own delivery — and use the same command if you',
'need to start a conversation yourself.',
'',
'When the user writes @<agent-name> or says "ask <name> to ..." /',
'"tell <name> ...", that is a handoff: message that agent, wait for the',
'reply, and report back.',
'',
'The roster grows over time — run `hermes profile list` for the LIVE',
'teammate list before a handoff. Teammates when you were created:',
...(teammates.length
? teammates.map(b => `- \`${b.name}\`${b.description ? ` — ${b.description}` : ''}`)
: ['- (none yet)'])
].join('\n')
}
/** True when SOUL.md already carries the Bot Mode handoff section.
* #16 appends this at create-time; pre-existing profiles (especially
* `default`) never went through composeSoul and silently lack it. */
function hasMessagingProtocol(soul) {
return /(^|\n)## Messaging other agents(\s|$)/.test(soul || '')
}
/** Idempotent: append the protocol once, never duplicate a custom SOUL
* that already has it (clone-from-default after a backfill, Edit save).
* No-op when the backend injects the protocol into the system prompt
* itself (bot_mode_protocol) — SOUL.md stays the user's identity text. */
function ensureMessagingProtocol(soul, name, roster) {
const text = (soul || '').trim()
if (serverInjectsProtocol || hasMessagingProtocol(text)) return text
const section = messagingProtocolSection(name, roster)
return text ? text + '\n\n' + section : section
}
const soulProtocolChecked = new Set()
const soulProtocolInflight = new Set()
/** One-shot per profile per session: if an existing SOUL has no protocol,
* append it. This is the install-time fix for default / pre-Bot-Mode
* personas that #16 never touched. Never overwrites identity text. */
function backfillMessagingProtocol(roster) {
// Newer backends teach the protocol via the system prompt — never touch
// user SOUL files when the server already covers every session.
if (serverInjectsProtocol) {
return
}
for (const bot of roster || []) {
const name = bot && bot.name
if (!name || soulProtocolChecked.has(name) || soulProtocolInflight.has(name)) {
continue
}
soulProtocolInflight.add(name)
host
.request('profiles.describe', { name })
.then(res => {
const soul = (res && res.soul) || ''
if (hasMessagingProtocol(soul)) {
soulProtocolChecked.add(name)
return null
}
return host
.request('profiles.configure', { name, soul: ensureMessagingProtocol(soul, name, roster) })
.then(() => {
soulProtocolChecked.add(name)
})
})
.catch(() => {
// Older gateway or a one-off describe/configure miss — do not hammer.
soulProtocolChecked.add(name)
})
.finally(() => {
soulProtocolInflight.delete(name)
})
}
}
/** SOUL.md for a new bot: identity (or the user's custom SOUL) + the
* messaging protocol — which ships UNLESS the backend injects it into the
* system prompt itself (bot_mode_protocol capability). */
function composeSoul({ name, title, description, roster, customSoul }) {
if (customSoul && customSoul.trim()) {
return ensureMessagingProtocol(customSoul, name, roster)
}
const lines = [
`# ${displayName({ name, title })}`,
'',
title ? `**Role:** ${title}` : null,
description ? `**Mission:** ${description}` : null,
'',
`You are ${displayName({ name, title })}, a persistent named agent (profile \`${name}\`) on this machine.`,
'You keep your own memory, skills, and conversation history across sessions.'
]
const identity = lines.filter(line => line !== null).join('\n')
return serverInjectsProtocol ? identity : identity + '\n\n' + messagingProtocolSection(name, roster)
}
// ── human-readable row helpers ───────────────────────────────────────────────
/** Bot-to-bot delivery prefix (see messagingProtocolSection): either the
* current "Message from 🤖 name (@handle):" form or the older
* "[Message from agent 'name']" shape. Captures the sender's handle. */
const A2A_RE = /^Message from (?:agent '([^']+)'|🤖\s*([^\s(@]+))/i
/** Strip the delivery prefix so a DM preview reads like a DM, not a log line. */
const A2A_PREFIX_RE = /^Message from (?:agent '[^']+'|🤖[^:]+):\s*/i
/** Classify a roster preview: `{ fromBot: handle|null }`. A preview that
* starts with the delivery prefix is a bot-to-bot message — the receiving
* bot's row should show WHO sent it, not present it as the human's chat. */
function previewKind(preview) {
const text = (preview || '').trim()
if (!text) {
return { fromBot: null }
}
const match = text.match(A2A_RE)
if (match) {
// The captured name is whatever the delivery prefix carried — a raw
// profile name. Map it the way every other surface does so the primary
// profile reads @hermes, never @default (#89484).
const sender = (match[1] || match[2] || '').trim().toLowerCase()
return { fromBot: sender ? botHandle(sender) : null }
}
return { fromBot: null }
}
/** Session titles the gateway auto-assigns that carry no information. */
const GENERIC_TITLES = new Set(['', 'bot chat', 'new chat', 'new conversation', 'conversation', 'chat', 'untitled'])
function isGenericTitle(title) {
return GENERIC_TITLES.has((title || '').trim().toLowerCase())
}
/** Title for the session chip: the real session title when it means
* something, otherwise a short label generated from the newest message
* (delivery prefixes stripped) so "Bot Chat" rows still say what the
* conversation is actually about. */
function generatedSessionTitle(session, preview) {
const raw = (session?.title || '').trim()
if (raw && !isGenericTitle(raw)) {
return raw
}
const cleaned = (preview || '').trim().replace(A2A_PREFIX_RE, '').trim()
if (!cleaned) {
return raw || 'Conversation'
}
const words = cleaned.split(/\s+/).slice(0, 5).join(' ').replace(/[,;:.]+$/, '')
if (!words) {
return raw || 'Conversation'
}
return words.length > 34 ? `${words.slice(0, 33)}…` : words
}
/** Roster liveness window: a bot whose last message landed within this many
* seconds is treated as "active now" (pulsing dot in its row). */
const ACTIVE_WINDOW_S = 90
/** The session whose activity best represents this bot — the FRESHER of the
* pinned canonical Bot Chat (preferred_session) 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.
* #88690 moved the preview text to preferred_session but left every activity
* signal (age label, pulse dot, unread watermark, recency sort) on
* last_session. All of them key off this helper now. Older gateways without
* the preferred_session resolver degrade to last_session unchanged. */
function botActivitySession(bot) {
const preferred = bot?.preferred_session
const last = bot?.last_session
if (!preferred || !last) {
return preferred || last || null
}
return (preferred.last_active || 0) >= (last.last_active || 0) ? preferred : last
}
/** Worker liveness window: kanban/tool workers heartbeat last_activity_at
* at least every 60s while running (agent/session_activity.py), so a
* worker whose stamp is older than this is finished or stalled. Wider
* than ACTIVE_WINDOW_S to bridge one missed heartbeat. */
const WORKER_ACTIVE_WINDOW_S = 150
/** True while this bot's freshest kanban/tool worker looks alive. Workers
* never surface in conversation lists, so without this a profile grinding
* through a 30-minute kanban task reads idle ("3 hr ago") the whole time
* (hermes-agent#90268). Older gateways omit worker_session — always false. */
function workerActiveAt(bot, now = Date.now()) {
const ts = bot?.worker_session?.last_active || 0
return Boolean(ts && now / 1000 - ts < WORKER_ACTIVE_WINDOW_S)
}
/** Bots that are working right now: the profile the gateway is running a
* turn for (busy), any bot whose last message landed inside the liveness
* window, plus any bot with a live kanban/tool worker. Pure — output
* follows the input roster's order, so presence never reorders or hides
* the normal list. */
function activeBots(roster, activeProfile, gatewayState, now = Date.now()) {
return (roster || []).filter(bot => {
const busyTurn = !bot.remoteSource && bot.name === activeProfile && gatewayState === 'busy'
const last = botActivitySession(bot)?.last_active || 0
const inWindow = Boolean(last && now / 1000 - last < ACTIVE_WINDOW_S)
return busyTurn || inWindow || workerActiveAt(bot, now)
})
}
// ── bot row ──────────────────────────────────────────────────────────────────
function BotRow({ bot, onDelete, onEdit, onGroup }) {
const activeProfile = useValue(host.state.profile)
const focusedProfile = useValue($focusedBotProfile)
const activeGroup = useValue($groupChatWorkspace)
const meta = botRosterMeta(bot, useValue($botMeta))
const groups = botGroups(meta)
const last = bot.last_session
// Highlight follows the chat on screen (focused session's owner), not the
// gateway socket's home — a focused tab doesn't swap the socket, and on the
// old keying the wrong bot stayed highlighted while you read another's chat.
// A selected group chat suppresses every bot-row highlight: the group row
// owns the selection then (#88979).
const isActive = !activeGroup && !bot.remoteSource && bot.name === focusedProfile
// Turn-busy is a SOCKET fact: only the gateway-home profile can be mid-turn.
const isGatewayHome = !bot.remoteSource && bot.name === activeProfile
const { shape, color, image } = botAppearance(bot.name, meta)
// Keep user photos/pets. Drop the 160px SVG backfill so the math face can move.
const photo = Boolean(image && !isBackfilledFacePng(image))
const gatewayState = useValue(host.state.gateway)
// Preview identity must match click identity (#88200): when the backend
// resolved the pinned canonical chat, preview THAT session — not the
// profile's most recent (but unrelated) activity. Activity signals
// (age label, pulse dot) follow the same rule via botActivitySession:
// the canonical Bot Chat is hidden from last_session, so keying age off
// last_session alone shows "6d ago" on a bot you just messaged.
const previewSession = bot.preferred_session || last
const activitySession = botActivitySession(bot)
// A live kanban/tool worker counts as activity (#90268): pulse + fresh
// age while it runs, falling back to chat activity when it ends.
const workerActive = workerActiveAt(bot)
const activeNow =
workerActive ||
Boolean(activitySession?.last_active && Date.now() / 1000 - activitySession.last_active < ACTIVE_WINDOW_S)
const rowAgeTs = workerActive
? Math.max(activitySession?.last_active || 0, bot.worker_session?.last_active || 0)
: activitySession?.last_active || 0
// Work pose only when this bot is actually doing something: the active
// profile while the gateway is busy, or a bot that wrote within the
// liveness window. Not every bot whenever the gateway is busy.
const botMood = (isGatewayHome && gatewayState === 'busy') || activeNow ? 'work' : 'idle'
// Subscribe on every render. A source switch turns the same keyed row from
// thin to rich; conditionally calling useValue here breaks React hook order.
const unreadByName = useValue($botUnread)
const unread = !bot.remoteSource && Boolean(unreadByName[bot.name])
// WHO sent the last message (bot-to-bot DM vs human) — the full stored
// history lives in the Sessions workspace (context menu), not inline.
const { fromBot } = previewKind(previewSession?.preview)
// DM previews read like DMs: strip the delivery prefix, keep the message.
const displayPreview = stripPreviewMarkdown(
fromBot
? (previewSession?.preview || '').replace(A2A_PREFIX_RE, '').trim() || '…'
: previewSession?.preview || bot.description || 'No conversations yet — say hi'
)
const warm = () => {
// Multi-source row: pre-dial the agent's OWN source (feature-detected).
if (bot.sourceScoped && typeof host.warmAgent === 'function') {
try {
host.warmAgent(bot.connectionId, bot.name)
} catch {
/* warm is best-effort */
}
return
}
if (typeof host.warmProfile !== 'function') {
return
}
try {
host.warmProfile(bot.name)
} catch {
/* warm is best-effort */
}
}
const open = async () => {
const generation = ++botOpenGeneration
haptic('tap')
$groupChatWorkspace.set(null)
$selectedBot.set(bot.name)
if (bot.remoteSource) {
const handle = botHandle(bot.name, bot)
host.notify?.({
kind: 'info',
title: displayName(bot),
message: `Stay in this chat and @${handle} to message them. Gateway stays on this device.`
})
return
}
let pinnedChat = meta?.chat
if (!bot.remoteSource && $botUnread.get()[bot.name]) {
const next = { ...$botUnread.get() }
delete next[bot.name]
$botUnread.set(next)
}
// Activate the owner first so every canonical-chat RPC lands on the
// backend that owns this bot's state database.
try {
pinnedChat = await prepareBotSource(bot, pinnedChat)
} catch (error) {
host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`)
return
}
if (generation !== botOpenGeneration) {
return
}
try {
const id = await openBotCanonicalChat(bot.name, pinnedChat, previewSession)
if (generation === botOpenGeneration && id) {
return
}
} catch (error) {
if (generation === botOpenGeneration) {
host.notifyError?.(error, `Could not open ${displayName(bot, meta)}'s chat — try again`)
}
return
}
if (generation !== botOpenGeneration) {
return
}
if (typeof host.newChat === 'function') {
// Older gateway without profile-scoped session.create — plain draft.
host.newChat(bot.name)
} else {
host.navigate('/')
}
}
const row = jsxs('button', {
type: 'button',
onPointerEnter: warm,
onClick: open,
className: cn(
'flex w-full min-w-0 max-w-full items-center gap-2.5 overflow-hidden rounded-md px-2 py-2 text-left transition-colors',
'hover:bg-(--chrome-action-hover)',
isActive && 'bg-(--chrome-action-hover)',
// Hidden bots only render while the header eye toggle is on — dimmed,
// so the temporary reveal reads as a different state from the roster.
meta?.hidden && 'opacity-60'
),
children: [
jsx('div', {
className: 'shrink-0',
children: jsx(BotFace, { shape, color, image: photo ? image : null, size: 34, name: bot.name, mood: botMood })
}),
jsxs('div', {
className: 'min-w-0 flex-1',
children: [
jsxs('div', {
className: 'flex items-baseline justify-between gap-2',
children: [
jsxs('div', {
className: 'flex min-w-0 items-baseline gap-1.5 truncate',
children: [
meta?.pinned
? jsx('span', {
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
title: 'Pinned',
children: '📌'
})
: null,
meta?.hidden
? jsx(Codicon, {
name: 'eye-closed',
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
title: 'Hidden from the roster'
})
: null,
jsx('span', {
className: cn(
'truncate text-[0.8125rem] font-medium',
bot.remoteSource && 'max-w-[42%] shrink-0'
),
children: displayName(bot, meta)
}),
showsHandle(bot.name, meta, bot)
? jsx('span', {
className: 'min-w-0 truncate font-mono text-[0.6875rem] text-(--ui-text-quaternary)',
children: `@${botHandle(bot.name, bot)}`
})
: null,
bot.remoteSource
? jsx('span', {
className:
'max-w-[28%] shrink-0 truncate rounded bg-(--chrome-action-hover) px-1 font-mono text-[0.625rem] text-(--ui-text-tertiary)',
title: `Lives on ${bot.connectionLabel}`,
children: bot.connectionLabel
})
: null
]
}),
unread
? jsx('span', {
className: 'size-2 shrink-0 rounded-full bg-(--ui-accent,#4f9cf9)',
'aria-label': 'unread'
})
: null,
activeNow
? jsx('span', {
className: 'hermes-bots-pulse size-1.5 shrink-0 rounded-full bg-(--ui-accent,#4f9cf9)',
title: workerActive ? 'Working on a task right now' : 'Active in the last 90s'
})
: null,
rowAgeTs
? jsx('span', {
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
children: relativeTime(rowAgeTs * 1000)
})
: null
]
}),
jsxs('div', {
className: 'flex min-w-0 items-center gap-1',
children: [
jsx('div', {
className: fromBot
? 'min-w-0 truncate text-xs italic text-(--ui-accent,#4f9cf9)'
: 'min-w-0 truncate text-xs text-(--ui-text-tertiary)',
children: displayPreview
}),
fromBot
? jsxs('span', {
className:
'flex shrink-0 items-center gap-1 rounded-full bg-(--chrome-action-hover) px-1.5 py-px text-[0.625rem] font-medium text-(--ui-accent,#4f9cf9)',
title: `Last message came from @${fromBot} (bot-to-bot)`,
children: ['🤖', `@${fromBot}`]
})
: null
]
})
]
})
]
})
// Thin rows from another source are navigation targets only. Their profile
// metadata is not loaded yet, so edit/delete/pin/group actions would mutate
// whichever backend happens to be active. A normal click activates the
// owner; the refreshed rich row then exposes the full context menu.
if (bot.remoteSource) {
return row
}
return jsxs(ContextMenu, {
children: [
jsx(ContextMenuTrigger, { asChild: true, children: row }),
jsxs(ContextMenuContent, {
children: [
jsx(ContextMenuItem, {
onSelect: () => {
const pinned = Boolean($botMeta.get()[bot.name]?.pinned)
saveBotMeta(bot.name, { pinned: !pinned })
host.notify({
kind: 'info',
message: `${displayName(bot, meta)} ${pinned ? 'unpinned' : 'pinned to top'}`
})
},
children: meta?.pinned ? 'Unpin' : 'Pin to top'
}),
jsx(ContextMenuItem, {
onSelect: () => {
const hidden = Boolean($botMeta.get()[bot.name]?.hidden)
// `hidden: false` (not null) so unhide round-trips through the
// server ui_meta merge the same way the local merge sees it.
saveBotMeta(bot.name, { hidden: !hidden })
if (!hidden) {
fallbackSelectionAfterHide(bot.name)
}
host.notify({
kind: 'info',
message: hidden
? `${displayName(bot, meta)} is back in the roster`
: `${displayName(bot, meta)} hidden — use the eye button in the Bots header to see hidden bots`
})
},
children: meta?.hidden ? 'Unhide Bot' : 'Hide Bot'
}),
jsx(ContextMenuSeparator, {}),
jsx(ContextMenuItem, {
onSelect: () => openBotSessionsWorkspace(bot),
children: 'Sessions'
}),
jsx(ContextMenuItem, { onSelect: () => onEdit(bot), children: 'Edit Profile' }),
!bot.remoteSource
? jsx(ContextMenuItem, {
onSelect: () => onGroup(bot),
children: groups.length ? `Groups: ${groups.join(', ')}…` : 'Manage groups…'
})
: null,
jsx(ContextMenuItem, {
onSelect: () => {
host.notify({ kind: 'info', message: `Duplicating ${displayName(bot, meta)}…` })
duplicateBot(bot, $lastRoster.get().filter(candidate => !candidate.remoteSource))
.then(name => {
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
host.notify({ kind: 'success', message: `Created ${name} — full copy of ${bot.name}` })
})
.catch(err => host.notifyError(err, 'Duplicate failed'))
},
children: 'Duplicate'
}),
jsx(ContextMenuSeparator, {}),
jsx(ContextMenuItem, {
onSelect: () => {
$selectedBot.set(bot.name)
if (typeof host.newChat === 'function') {
host.newChat(bot.name)
}
},
children: 'New chat with this agent'
}),
bot.is_default ? null : jsx(ContextMenuSeparator, {}),
bot.is_default
? null
: jsx(ContextMenuItem, {
onSelect: () => onDelete(bot),
variant: 'destructive',
children: 'Delete'
})
]
})
]
})
}
// ── model picker (provider/model dropdowns via model.options) ───────────────
function useModelOptions() {
return useQuery({
queryKey: [ID, 'model-options'],
queryFn: () => host.request('model.options', { include_unconfigured: true, explicit_only: false, refresh: true }),
staleTime: 120000,
retry: false
})
}
/**
* Provider + model dropdowns from the gateway's configured inventory — the
* same data the core model picker shows. `value = {provider, model}`;
* onChange receives the merged patch.
*/
function ModelPicker({ value, onChange, placeholderModel = 'gateway default' }) {
const { data, isLoading, error } = useModelOptions()
// Hooks are ALWAYS declared up front, before any conditional return.
// Declaring them after a return trips React error #310.
const NONE = '__default__'
const CUSTOM = '__custom__'
const providers = (data?.providers || []).filter(p => p && p.slug)
const isKnown =
!value.provider || value.provider === NONE || providers.some(p => p.slug === value.provider)
const [useFreeText, setUseFreeText] = useState(!isKnown)
if (isLoading) {
return jsx('div', {
className: 'flex justify-center py-2',
children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' })
})
}
if (error || !providers.length) {
// Fallback: free text (older gateway or empty inventory).
return jsxs('div', {
style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' },
children: [
labeled(
'Provider',
jsx(Input, {
placeholder: 'omnirouter / 9router / nous \u2026',
value: value.provider,
onChange: event => onChange({ provider: event.target.value })
})
),
labeled(
'Model',
jsx(Input, {
placeholder: 'antigravity/gemini-3.6-flash-high',
value: value.model,
onChange: event => onChange({ model: event.target.value })
})
)
]
})
}
if (useFreeText) {
return jsxs('div', {
style: { display: 'flex', flexDirection: 'column', gap: '8px' },
children: [
jsxs('div', {
style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' },
children: [
labeled(
'Provider (Custom)',
jsx(Input, {
placeholder: 'e.g. omnirouter, inferx, 9router',
value: value.provider,
onChange: event => onChange({ provider: event.target.value })
})
),
labeled(
'Model (Custom)',
jsx(Input, {
placeholder: 'e.g. antigravity/gemini-3.6-flash-high',
value: value.model,
onChange: event => onChange({ model: event.target.value })
})
)
]
}),
jsx(Button, {
variant: 'ghost',
size: 'sm',
className: 'h-6 self-start text-xs text-(--ui-text-tertiary)',
onClick: () => setUseFreeText(false),
children: '← Back to dropdowns'
})
]
})
}
const activeProvider = providers.find(p => p.slug === value.provider) || null
const models = activeProvider
? (activeProvider.models || []).map(m => (typeof m === 'string' ? m : m.id || m.name || ''))
: []
return jsxs('div', {
style: { display: 'grid', gridTemplateColumns: '1fr 1.4fr', gap: '10px' },
children: [
labeled(
'Provider',
jsxs(Select, {
value: value.provider || NONE,
onValueChange: v => {
if (v === NONE) {
onChange({ provider: '', model: '' })
} else if (v === CUSTOM) {
setUseFreeText(true)
} else {
const prov = providers.find(p => p.slug === v)
const provModels = (prov?.models || []).map(m =>
typeof m === 'string' ? m : m.id || m.name || ''
)
const first = provModels[0] || ''
onChange({
provider: v,
model: prov && provModels.includes(value.model) ? value.model : first
})
}
},
children: [
jsx(SelectTrigger, { className: 'h-8 rounded-md', children: jsx(SelectValue, {}) }),
jsxs(SelectContent, {
children: [
jsx(SelectItem, { value: NONE, children: 'Inherit (launch profile)' }),
...providers.map(p =>
jsx(
SelectItem,
{ value: p.slug, children: p.name ? `${p.name} (${p.slug})` : p.slug },
p.slug
)
),
jsx(SelectItem, { value: CUSTOM, children: '✏️ Enter manually…' })
]
})
]
})
),
labeled(
'Model',
activeProvider && models.length > 0
? jsxs(Select, {
value: value.model || (models[0] ?? ''),
onValueChange: v => onChange({ model: v }),
children: [
jsx(SelectTrigger, { className: 'h-8 rounded-md', children: jsx(SelectValue, {}) }),
jsx(SelectContent, {
children: models.map(m => jsx(SelectItem, { value: m, children: m }, m))
})
]
})
: jsx(Input, {
placeholder: placeholderModel || 'e.g. model name',
value: value.model,
onChange: event => onChange({ model: event.target.value })
})
)
]
})
}
// ── advanced profile config (skills / toolsets / model / SOUL) ──────────────
//
// Shared by Edit Profile and New Agent (edit mode only for skills/toolsets —
// a not-yet-created profile has nothing installed to toggle). Backed by
// profiles.describe / profiles.configure; feature-detects older gateways.
function CheckList({ items, onToggle, columns = 2 }) {
return jsx('div', {
style: {
display: 'grid',
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
gap: '2px 12px'
},
children: items.map(item =>
jsxs(
'label',
{
className: 'flex min-w-0 cursor-pointer items-center gap-1.5 py-0.5 text-xs text-(--ui-text-secondary)',
title: item.description || item.name,
children: [
jsx(Checkbox, {
checked: item.enabled,
onCheckedChange: value => onToggle(item.name, Boolean(value))
}),
jsx('span', { className: 'truncate', children: item.name }),
item.tool_count
? jsx('span', {
className: 'shrink-0 text-[0.6rem] text-(--ui-text-quaternary)',
children: `${item.tool_count}`
})
: null
]
},
item.name
)
)
})
}
function AdvancedProfileConfig({ bot, state, setState }) {
const [loaded, setLoaded] = useState(false)
const [unsupported, setUnsupported] = useState(false)
const [skillFilter, setSkillFilter] = useState('')
if (!loaded) {
setLoaded(true)
Promise.all([
host.request('profiles.describe', { name: bot }),
host.request('mcp.catalog', { profile: bot }).catch(() => null)
])
.then(([res, cat]) => {
const configured = res.mcp_servers || []
const have = new Set(configured.map(m => m.name))
const catalog = ((cat && cat.servers) || []).filter(s => !have.has(s.name))
setState(prev => ({
...prev,
provider: res.model?.provider || '',
model: res.model?.default || '',
soul: res.soul || '',
skills: res.skills || [],
toolsets: res.toolsets || [],
mcp: [
...configured.map(m => ({ ...m, enabled: m.enabled !== false })),
...catalog.map(s => ({
name: s.name,
enabled: false,
fromCatalog: true,
installed: s.installed,
auth: s.auth,
requires: s.requires || [],
description: s.description || ''
}))
],
loaded: true
}))
})
.catch(() => setUnsupported(true))
}
if (unsupported) {
return jsx('div', {
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
children: 'Full configuration needs a newer gateway (restart it after updating Hermes).'
})
}
if (!state.loaded) {
return jsx('div', {
className: 'flex justify-center py-4',
children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' })
})
}
const visibleSkills = skillFilter.trim()
? state.skills.filter(s => s.name.toLowerCase().includes(skillFilter.trim().toLowerCase()))
: state.skills
const toggleSkill = (name, enabled) =>
setState(prev => ({
...prev,
dirtySkills: true,
skills: prev.skills.map(s => (s.name === name ? { ...s, enabled } : s))
}))
const toggleToolset = (name, enabled) =>
setState(prev => ({
...prev,
dirtyToolsets: true,
toolsets: prev.toolsets.map(t => (t.name === name ? { ...t, enabled } : t))
}))
const toggleMcp = (name, enabled) =>
setState(prev => ({
...prev,
dirtyMcp: true,
mcp: (prev.mcp || []).map(m => (m.name === name ? { ...m, enabled } : m))
}))
const enabledSkills = state.skills.filter(s => s.enabled).length
const enabledToolsets = state.toolsets.filter(t => t.enabled).length
const mcpList = state.mcp || []
const enabledMcp = mcpList.filter(m => m.enabled).length
// Newer desktop builds export the WHOLE core Capabilities surface
// (hermes-agent#87317): Skills (installed list + one-click hub installs +
// full-skill detail), Tools (per-toolset config), and MCP — pinned to this
// bot via fixedProfile, tab state kept out of the page router via embedded.
// Render THAT instead of the checkbox stand-ins; writes go straight to the
// bot's backend, so the dirty-section staging below only carries
// model + SOUL on these builds. Older builds keep the full checklist UI.
if (SkillsView) {
return jsxs('div', {
className: 'grid gap-4',
children: [
jsx(ModelPicker, {
value: { provider: state.provider, model: state.model },
onChange: patch => setState(prev => ({ ...prev, dirtyModel: true, ...patch }))
}),
labeled(
'Capabilities (applies immediately — skills, tools, MCP)',
jsx('div', {
className: 'overflow-hidden rounded-md border border-(--ui-stroke-secondary)',
style: { height: 460, minHeight: 300, resize: 'vertical', overflow: 'auto' },
children: jsx(SkillsView, { embedded: true, fixedProfile: bot })
})
),
labeled(
'SOUL.md (persona + agent-messaging protocol)',
jsx(Textarea, {
className: 'min-h-28 font-mono text-xs leading-5',
value: state.soul,
onChange: event => setState(prev => ({ ...prev, dirtySoul: true, soul: event.target.value }))
})
)
]
})
}
return jsxs('div', {
className: 'grid gap-4',
children: [
jsx(ModelPicker, {
value: { provider: state.provider, model: state.model },
onChange: patch => setState(prev => ({ ...prev, dirtyModel: true, ...patch }))
}),
labeled(
`Skills (${enabledSkills}/${state.skills.length} enabled)`,
jsxs('div', {
className: 'grid gap-1.5 rounded-md border border-(--ui-stroke-secondary) p-2',
children: [
jsx(Input, {
className: 'h-7 text-xs',
placeholder: 'Filter skills…',
value: skillFilter,
onChange: event => setSkillFilter(event.target.value)
}),
jsx(ScrollArea, {
className: 'hermes-scroll-cap',
style: { maxHeight: 180 },
children: jsx(CheckList, { items: visibleSkills, onToggle: toggleSkill, columns: 2 })
}),
jsx(HubSkillsSection, {
forProfile: bot,
onInstalled: name =>
setState(prev =>
prev.skills.some(s => s.name === name)
? prev
: { ...prev, skills: [...prev.skills, { name, enabled: true }] }
)
})
]
})
),
labeled(
`Toolsets (${enabledToolsets}/${state.toolsets.length} enabled — unchecking all restores the default)`,
jsx('div', {
className: 'rounded-md border border-(--ui-stroke-secondary) p-2',
children: jsx(ScrollArea, {
className: 'hermes-scroll-cap',
style: { maxHeight: 320 },
children: jsx('div', {
className: 'grid gap-1.5',
children: state.toolsets.map(tset =>
jsxs(
'div',
{
className: 'rounded-md border border-(--ui-stroke-secondary) p-2',
children: [
jsxs('label', {
className: 'flex items-center gap-2 text-xs font-medium text-(--ui-text-secondary)',
children: [
jsx(Checkbox, {
checked: !!tset.enabled,
onCheckedChange: value => toggleToolset(tset.name, Boolean(value))
}),
jsx('span', { children: tset.name })
]
}),
// The REAL per-toolset config (env vars / API keys / model
// picker / post-setup), scoped to THIS bot's profile, when
// the desktop build exposes it. Older builds: just the toggle.
ToolsetConfigPanel
? jsx('div', {
className: 'mt-1.5 border-t border-(--ui-stroke-secondary) pt-1.5',
children: jsx(ToolsetConfigPanel, { toolset: tset.name, profile: bot })
})
: null
]
},
tset.name
)
)
})
})
})
),
labeled(
'MCP servers',
jsx('div', {
className: 'overflow-hidden rounded-md border border-(--ui-stroke-secondary)',
// The REAL MCP tab core Settings renders — per-server enable + OAuth
// sign-in + API-key setup + live probes — scoped to this bot's profile.
// Feature-detected: older desktop builds without the SDK export fall
// back to the plugin's own checkbox list + inline setup buttons.
children: McpTab && typeof host.getGateway === 'function'
? jsx('div', {
style: { minHeight: 220, maxHeight: 360 },
children: jsx(McpTab, { gateway: host.getGateway(), profile: bot })
})
: mcpList.length === 0
? jsx('div', {
className: 'px-1 py-2 text-center text-xs text-(--ui-text-tertiary)',
children: 'No MCP servers configured or in the catalog.'
})
: jsx(ScrollArea, {
className: 'hermes-scroll-cap',
style: { maxHeight: 180 },
children: jsx('div', {
className: 'grid gap-1 p-2',
children: mcpList.map(m => {
const needsSetup = m.fromCatalog && !m.installed && ((m.requires || []).length > 0 || (m.auth || '').toLowerCase() === 'oauth')
return jsxs(
'label',
{
className: 'flex items-start gap-2 text-xs text-(--ui-text-secondary)',
children: [
jsx(Checkbox, {
checked: !!m.enabled,
disabled: needsSetup,
onCheckedChange: value => toggleMcp(m.name, Boolean(value))
}),
jsxs('span', {
className: 'min-w-0',
children: [
jsx('span', { children: m.name }),
m.fromCatalog && !needsSetup
? jsx('span', {
className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)',
children: m.installed ? 'catalog · installed' : 'catalog'
})
: null,
needsSetup
? jsx(McpSetupButton, {
profile: bot,
entry: m,
onDone: () => toggleMcp(m.name, true)
})
: null,
m.description
? jsx('div', {
className: 'truncate text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
children: m.description
})
: null
]
})
]
},
m.name
)
})
})
})
})
),
labeled(
'SOUL.md (persona + agent-messaging protocol)',
jsx(Textarea, {
className: 'min-h-28 font-mono text-xs leading-5',
value: state.soul,
onChange: event => setState(prev => ({ ...prev, dirtySoul: true, soul: event.target.value }))
})
)
]
})
}
// ── skills hub section: the REAL hub page (docs) embedded as a picker ──────
// https://hermes-agent.nousresearch.com/docs/skills?embed=picker hides the
// docs chrome and adds "+ Add to this Agent" per card, posting
// {type: 'hermes-skill-pick', ...} to us (hermes-agent#86243). We validate
// the origin, install via skills.manage, and bubble onInstalled so the
// checklist above gains the row. Search-box fallback kept for offline use.
const HUB_ORIGIN = 'https://hermes-agent.nousresearch.com'
const HUB_PICKER_URL = HUB_ORIGIN + '/docs/skills?embed=picker'
function HubSkillsSection({ forProfile, onInstalled }) {
const [query, setQuery] = useState('')
const [results, setResults] = useState(null)
const [searching, setSearching] = useState(false)
const [installing, setInstalling] = useState(null)
const [installed, setInstalled] = useState({})
const [browseHub, setBrowseHub] = useState(false)
const installRef = useRef(null)
const frameRef = useRef(null)
// Picker messages from the embedded hub page. Origin- AND source-checked —
// only OUR frame may ask for an install (the hub origin alone would let any
// other window on it, e.g. an OAuth popup, trigger installs too); installs
// route through the same install() the search fallback uses.
useEffect(() => {
if (!browseHub) {
return undefined
}
const onMessage = event => {
if (event.origin !== HUB_ORIGIN) {
return
}
if (!frameRef.current || event.source !== frameRef.current.contentWindow) {
return
}
const data = event.data
if (!data || data.type !== 'hermes-skill-pick' || !data.name) {
return
}
const target = String(data.identifier || data.name)
// Skill identifiers are slugs / owner-name paths — keep anything
// else out of skills.manage.
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(target)) {
return
}
if (installRef.current) {
void installRef.current(target, String(data.name))
}
}
window.addEventListener('message', onMessage)
return () => window.removeEventListener('message', onMessage)
}, [browseHub])
const search = async () => {
const q = query.trim()
if (!q || searching) {
return
}
setSearching(true)
setResults(null)
try {
const res = await host.request('skills.manage', { action: 'search', query: q })
setResults(res.results || [])
} catch {
setResults([])
} finally {
setSearching(false)
}
}
const install = async (name, displayName) => {
const label = displayName || name
if (installing) {
return
}
setInstalling(label)
try {
// With forProfile the install lands in that bot's skills dir
// (gateway skills.manage profile scoping); null = launch profile,
// which is right at create time — the new bot clones/copies from it.
await host.request('skills.manage', {
action: 'install',
query: name,
...(forProfile ? { profile: forProfile } : {})
})
setInstalled(prev => ({ ...prev, [label]: true }))
host.notify({ kind: 'success', message: `Skill "${label}" installed` })
if (typeof onInstalled === 'function') {
onInstalled(label)
}
} catch (err) {
host.notifyError(err, `Installing "${label}" failed`)
} finally {
setInstalling(null)
}
}
installRef.current = install
return jsxs('div', {
className: 'grid gap-1.5 border-t border-(--ui-stroke-secondary) pt-2',
children: [
jsxs('div', {
className: 'flex items-baseline justify-between gap-2',
children: [
jsx('div', {
className: 'text-[0.7rem] font-medium text-(--ui-text-secondary)',
children: 'Skills Hub'
}),
jsx('button', {
type: 'button',
className: 'text-[0.65rem] text-(--ui-text-quaternary) hover:text-(--ui-text-secondary)',
onClick: () => setBrowseHub(v => !v),
children: browseHub ? 'hide the hub browser' : 'browse the full hub ▾'
})
]
}),
browseHub
? jsxs('div', {
className: 'grid gap-1',
children: [
// Resizable viewport: native CSS resize handle (bottom-right
// corner) lets the user drag it larger/smaller. The iframe
// inside is rendered oversized and scaled DOWN (133% × 0.75)
// so the hub page starts zoomed out — we can't style the
// cross-origin page itself, but scaling the frame is ours.
jsx('div', {
style: {
width: '100%',
height: 560,
minHeight: 240,
minWidth: 320,
maxWidth: '100%',
resize: 'both',
overflow: 'hidden',
border: '1px solid var(--ui-stroke-secondary)',
borderRadius: 8,
position: 'relative'
},
children: jsx('iframe', {
src: HUB_PICKER_URL,
title: 'Hermes Skills Hub',
ref: frameRef,
style: {
width: '133.34%',
height: '133.34%',
border: 'none',
background: 'transparent',
transform: 'scale(0.75)',
transformOrigin: 'top left'
},
sandbox: 'allow-scripts allow-same-origin'
})
}),
jsx('div', {
className: 'px-1 text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
children:
installing
? `Installing "${installing}"…`
: 'Hit "+ Add to this Agent" on any skill — it installs and appears in the list above. Drag the corner to resize.'
})
]
})
: null,
jsxs('div', {
className: 'flex gap-1.5',
children: [
jsx(Input, {
className: 'h-7 flex-1 text-xs',
placeholder: 'Search the hub (community + well-known sources)…',
value: query,
onChange: event => setQuery(event.target.value),
onKeyDown: event => {
if (event.key === 'Enter') {
event.preventDefault()
void search()
}
}
}),
jsx(Button, {
size: 'sm',
variant: 'secondary',
disabled: searching || !query.trim(),
onClick: () => void search(),
children: searching ? 'Searching…' : 'Search'
})
]
}),
searching
? jsx('div', {
className: 'px-1 text-[0.65rem] text-(--ui-text-quaternary)',
children: 'Searching community + well-known sources — can take ~10s…'
})
: null,
results === null
? null
: results.length === 0
? jsx('div', {
className: 'px-1 py-1.5 text-[0.7rem] text-(--ui-text-quaternary)',
children: 'No hub skills matched.'
})
: jsx(ScrollArea, {
className: 'hermes-scroll-cap',
style: { maxHeight: 150 },
children: jsx('div', {
className: 'grid gap-1',
children: results.map(r =>
jsxs(
'div',
{
className: 'flex items-center gap-2 text-xs',
children: [
jsxs('div', {
className: 'min-w-0 flex-1',
children: [
jsx('div', { className: 'truncate font-medium', children: r.name }),
r.description
? jsx('div', {
className: 'truncate text-[0.65rem] text-(--ui-text-quaternary)',
children: r.description
})
: null
]
}),
installed[r.name]
? jsx('span', {
className: 'shrink-0 text-[0.65rem] text-(--ui-text-tertiary)',
children: '✓ added'
})
: jsx(Button, {
size: 'sm',
variant: 'ghost',
className: 'shrink-0 px-2 font-semibold',
disabled: installing !== null,
title: `Install "${r.name}" and add it to the list above`,
onClick: () => void install(r.name),
children: installing === r.name ? '…' : '+'
})
]
},
r.name
)
)
})
})
]
})
}
function emptyAdvancedState() {
return {
loaded: false,
provider: '',
model: '',
soul: '',
skills: [],
toolsets: [],
mcp: [],
dirtyModel: false,
dirtySoul: false,
dirtySkills: false,
dirtyToolsets: false,
dirtyMcp: false
}
}
/** Persist only the dirty sections of the advanced editor. */
async function applyAdvancedConfig(bot, state) {
const payload = { name: bot }
const applied = {}
if (state.dirtySoul) {
payload.soul = ensureMessagingProtocol(state.soul, bot, $lastRoster.get())
}
if (state.dirtyModel) {
const model = state.model.trim()
const provider = state.provider.trim()
if (model && provider) {
payload.model = model
payload.provider = provider
} else if (!model && !provider) {
try {
const result = await host.request('cli.exec', {
argv: ['--profile', bot, 'config', 'unset', 'model']
})
applied.model = result?.blocked !== true && result?.code === 0
} catch {
applied.model = false
}
} else {
applied.model = false
}
}
if (state.dirtySkills) {
payload.disabled_skills = state.skills.filter(s => !s.enabled).map(s => s.name)
}
if (state.dirtyToolsets) {
const all = state.toolsets.length
const enabled = state.toolsets.filter(t => t.enabled)
// All enabled (or none) = clear the pin; otherwise pin the checked set.
payload.enabled_toolsets = enabled.length === all || enabled.length === 0 ? [] : enabled.map(t => t.name)
}
if (state.dirtyMcp) {
payload.enabled_mcp_servers = (state.mcp || []).filter(m => m.enabled).map(m => m.name)
}
if (Object.keys(payload).length === 1) {
return { ok: Object.values(applied).every(Boolean), applied }
}
const result = await host.request('profiles.configure', payload)
const merged = { ...applied, ...(result?.applied || {}) }
return { ...result, ok: Object.values(merged).every(Boolean), applied: merged }
}
// ── edit profile dialog ──────────────────────────────────────────────────────
function labeled(label, control) {
return jsxs('div', {
className: 'grid gap-1.5',
children: [
jsx('label', {
className: 'text-xs font-medium text-(--ui-text-secondary)',
children: label
}),
control
]
})
}
function EditProfileDialog({ bot, open, onClose }) {
const metaAll = useValue($botMeta)
const meta = bot ? metaAll[bot.name] : null
const appearance = bot ? botAppearance(bot.name, meta) : { shape: 'circle', color: AVATAR_COLORS[3] }
const [shape, setShape] = useState(appearance.shape)
const [color, setColor] = useState(appearance.color)
const [image, setImage] = useState(appearance.image)
const [title, setTitle] = useState(meta?.title || '')
const [description, setDescription] = useState(bot?.description || '')
const [busy, setBusy] = useState(false)
const [advanced, setAdvanced] = useState(false)
const [adv, setAdv] = useState(emptyAdvancedState())
// Re-seed local state each time a different bot opens the dialog.
const [seedKey, setSeedKey] = useState(null)
const currentKey = bot ? `${bot.name}:${open}` : null
if (currentKey !== seedKey) {
setSeedKey(currentKey)
if (bot && open) {
setShape(appearance.shape)
setColor(appearance.color)
setImage(appearance.image)
setTitle(meta?.title || '')
setDescription(bot.description || '')
setBusy(false)
setAdvanced(false)
setAdv(emptyAdvancedState())
}
}
if (!bot) {
return null
}
const submit = async () => {
if (busy) {
return
}
setBusy(true)
let advancedFailed = false
const persistence = await saveBotMeta(bot.name, {
shape,
color,
image,
imageKind: image ? 'photo' : 'shape',
title: title.trim(),
custom: true
})
// Only an explicit remote failure is an error — 'unsupported' is the
// documented older-gateway fallback (local wins, silently), and toasting
// it would flag every save on every legacy setup forever.
const lookFailed = persistence.serverOutcome === 'failed'
if (lookFailed) {
host.notify({ kind: 'error', message: 'Saved look locally; remote persistence failed' })
}
if (persistence.serverOutcome === 'persisted') {
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
}
const desc = description.trim()
if (desc !== (bot.description || '').trim()) {
try {
await host.request('cli.exec', {
argv: ['profile', 'describe', bot.name, '--text', desc]
})
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
} catch (err) {
host.notifyError(err, 'Saved look locally; description update failed')
}
}
if (adv.loaded && (adv.dirtyModel || adv.dirtySoul || adv.dirtySkills || adv.dirtyToolsets || adv.dirtyMcp)) {
try {
const res = await applyAdvancedConfig(bot.name, adv)
const failed = Object.entries(res?.applied || {}).filter(([, ok]) => !ok)
if (failed.length) {
advancedFailed = true
host.notify({ kind: 'error', message: `Some sections failed: ${failed.map(([k]) => k).join(', ')}` })
}
} catch (err) {
advancedFailed = true
host.notifyError(err, 'Advanced configuration failed')
}
}
if (!advancedFailed && !lookFailed) {
host.notify({ kind: 'success', message: `${displayName(bot, { title })} updated` })
}
setBusy(false)
onClose()
}
return jsx(Dialog, {
open,
onOpenChange: value => !value && !busy && onClose(),
children: jsxs(DialogContent, {
className: advanced ? 'max-w-3xl' : 'max-w-sm',
// Same resizable-window treatment as the create dialog.
style: advanced
? { resize: 'both', overflow: 'auto', minWidth: 420, minHeight: 360, maxWidth: '95vw', maxHeight: '90vh' }
: undefined,
children: [
jsxs(DialogHeader, {
children: [
jsx(DialogTitle, { children: 'Edit Profile' }),
jsx(DialogDescription, { children: `Appearance and role for ${displayName(bot, null)} (${bot.name}).` })
]
}),
jsxs('div', {
className: 'grid gap-4',
children: [
jsx('div', {
className: 'flex justify-center py-1',
children: jsx(BotFace, { shape, color, image, size: 64, name: bot.name })
}),
jsx(AvatarPicker, {
shape,
color,
image,
onShape: setShape,
onColor: setColor,
onImage: setImage,
generateSeed: { name: bot.name, title, description }
}),
labeled(
'Title',
jsx(Input, {
placeholder: displayName(bot, null),
value: title,
onChange: event => setTitle(event.target.value)
})
),
labeled(
'Description',
jsx(Textarea, {
className: 'min-h-16',
placeholder: 'What should this agent help with?',
value: description,
onChange: event => setDescription(event.target.value)
})
),
jsxs('button', {
type: 'button',
className:
'flex items-center gap-1 text-xs font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)',
onClick: () => setAdvanced(v => !v),
children: [
jsx(Codicon, { name: advanced ? 'chevron-down' : 'chevron-right', className: 'text-[0.8rem]' }),
'Advanced — model, skills, toolsets, SOUL.md'
]
}),
advanced
? jsx('div', {
className: 'rounded-md border border-(--ui-stroke-secondary) p-3',
children: jsx(AdvancedProfileConfig, { bot: bot.name, state: adv, setState: setAdv })
})
: null
]
}),
jsxs(DialogFooter, {
children: [
jsx(Button, { variant: 'ghost', disabled: busy, onClick: onClose, children: 'Cancel' }),
jsx(Button, { disabled: busy, onClick: submit, children: busy ? 'Saving…' : 'Save' })
]
})
]
})
})
}
// ── create dialog ────────────────────────────────────────────────────────────
function CreateAgentDialog({ open, onClose, roster }) {
const [name, setName] = useState('')
// Create mode: the profile is created LAZILY. Capability toggles are staged in
// component state; the profile is materialized either on Create (submit) or on
// the first MCP credential setup (ensureAgentCreated), whichever comes first —
// so OAuth / API-key setup works DURING creation, not only after in Edit.
const createdRef = useRef(null)
// In-flight profiles.create shared across concurrent triggers (Create
// button + MCP setup buttons). Distinct from createdRef on purpose:
// createdRef must stay a slug string for its sibling consumers.
const flightRef = useRef(null)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
// Default shapes mode: deterministic blob face drawn from the agent's name
// (falls back to the legacy shape vocabulary on older SDKs).
const [shape, setShape] = useState(blobatarSvg ? 'blobatar' : 'circle')
const [color, setColor] = useState(AVATAR_COLORS[3])
const [image, setImage] = useState(null)
const [advanced, setAdvanced] = useState(false)
const [cloneFrom, setCloneFrom] = useState('default')
const [model, setModel] = useState('')
const [provider, setProvider] = useState('')
const [soul, setSoul] = useState('')
const [noSkills, setNoSkills] = useState(false)
const [shareAuth, setShareAuth] = useState(true)
const [advTab, setAdvTab] = useState('general')
// Where the profile is created: '' = the active gateway (unchanged default),
// else a registry connection id — the profiles.create lands on THAT
// machine's backend via host.requestProfile, no gateway switch. Only
// rendered when the desktop has a multi-connection registry.
const [targetConnection, setTargetConnection] = useState('')
const [connections, setConnections] = useState(null)
useEffect(() => {
if (!open || connections !== null || typeof host.connections !== 'function' || typeof host.requestProfile !== 'function') {
return
}
host
.connections()
// host.connections() returns the registry ROWS on current SDKs, but the
// envelope object ({version, primary, connections: [...]}) on desktops
// that predate the SDK-side unwrap — accept both shapes.
.then(value => setConnections(Array.isArray(value) ? value : Array.isArray(value?.connections) ? value.connections : []))
.catch(() => setConnections([]))
}, [open, connections])
const activeConnectionId = String(host.state?.connectionId?.get?.() || '').trim()
// Remote target = an explicitly picked registry connection that is not the
// one this window is already on.
const remoteTarget = Boolean(targetConnection) && targetConnection !== (activeConnectionId || 'local')
const targetLabel = remoteTarget
? (connections || []).find(c => c.id === targetConnection)?.label || targetConnection
: ''
/** Gateway RPC on the create target: the picked connection's default
* backend for remote targets, the active gateway otherwise. */
const requestForTarget = (method, params = {}) =>
remoteTarget
? host.requestProfile(
{ connectionId: targetConnection, mode: 'remote', profile: 'default', targetProfile: 'default' },
method,
params
)
: host.request(method, params)
// Set once ensureAgentCreated() materializes the profile for the live
// Capabilities tab (SkillsView needs a real backend to point at). State —
// not just createdRef — because the render must flip when it lands.
const [createdForCaps, setCreatedForCaps] = useState(null)
const [caps, setCaps] = useState(null)
const [capsFailed, setCapsFailed] = useState(false)
const [dirtyCaps, setDirtyCaps] = useState({ skills: false, toolsets: false, mcp: false })
const [capFilter, setCapFilter] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const slug = slugify(name)
const valid = slug.length > 0 && NAME_RE.test(slug)
// Once the draft profile is materialized (Capabilities tab / MCP setup) it
// shows up in the roster — its OWN slug must not read as "taken".
// A remote-target create is gated by the TARGET machine's roster: a local
// name clash is fine there, and the remote's own duplicate check rejects
// real collisions at profiles.create time.
const taken = remoteTarget
? roster.some(b => b.remoteSource && b.connectionId === targetConnection && b.name === slug && b.name !== createdRef.current)
: roster.some(b => !b.remoteSource && b.name === slug && b.name !== createdRef.current)
// Draft semantics for the lazily-created profile: opening the Capabilities
// tab (or running MCP setup) materializes the profile so the LIVE config
// surfaces have a real backend to write to — but until the user hits
// Create Agent it is a DRAFT. Cancelling the dialog deletes it, so
// preconfigure-then-back-out leaves zero residue. Best-effort and
// fire-and-forget: a failed cleanup surfaces a toast, never blocks close.
const discardDraft = () => {
const draft = createdRef.current
if (!draft) {
return
}
createdRef.current = null
flightRef.current = null
const discard = remoteTarget
? requestForTarget('cli.exec', { argv: ['profile', 'delete', draft, '--yes'] })
: deleteBot({ name: draft })
void Promise.resolve(discard)
.then(() => host.notify({ kind: 'success', message: `Draft agent "${draft}" discarded` }))
.catch(err => host.notifyError(err, `Could not clean up draft profile "${draft}"`))
}
const reset = () => {
setName('')
setTitle('')
setDescription('')
setShape(blobatarSvg ? 'blobatar' : 'circle')
setColor(AVATAR_COLORS[3])
setImage(null)
setAdvanced(false)
// Same default as the initial useState — resetting to '__none__' made
// the second agent you create silently start from a fresh profile
// instead of cloning the main one like the first dialog open did.
setCloneFrom('default')
setModel('')
setProvider('')
setSoul('')
setNoSkills(false)
setShareAuth(true)
setAdvTab('general')
setCreatedForCaps(null)
setCaps(null)
setCapsFailed(false)
setDirtyCaps({ skills: false, toolsets: false, mcp: false })
setCapFilter('')
setTargetConnection('')
setBusy(false)
setError(null)
createdRef.current = null
flightRef.current = null
}
// Capability catalog for the tabs: the profile doesn't exist yet, so show
// what it WILL have — the clone source's catalog, else the main profile's.
const capSource = cloneFrom === '__none__' ? 'default' : cloneFrom
const ensureCaps = () => {
if ((caps && caps.source === capSource) || capsFailed) {
return
}
Promise.all([
requestForTarget('profiles.describe', { name: remoteTarget ? 'default' : capSource }),
requestForTarget('mcp.catalog', {}).catch(() => null)
])
.then(([res, cat]) => {
// Full MCP menu = the profile's configured servers + the bundled
// catalog (installable). Configured entries win on name clash.
const configured = res.mcp_servers || []
const have = new Set(configured.map(m => m.name))
const catalog = ((cat && cat.servers) || []).filter(s => !have.has(s.name))
setCaps({
source: capSource,
skills: res.skills || [],
toolsets: res.toolsets || [],
mcp: [
...configured,
...catalog.map(s => ({
name: s.name,
enabled: false,
fromCatalog: true,
installed: s.installed,
auth: s.auth,
requires: s.requires || [],
description: s.description || ''
}))
]
})
})
.catch(() => setCapsFailed(true))
}
const toggleCap = (kind, name, enabled) => {
setDirtyCaps(prev => ({ ...prev, [kind === 'mcp' ? 'mcp' : kind]: true }))
setCaps(prev =>
prev
? { ...prev, [kind]: prev[kind].map(x => (x.name === name ? { ...x, enabled } : x)) }
: prev
)
}
// Materialize the profile exactly once. createdRef stores the finished slug
// (its consumers — the taken check, draft discard on cancel, the MCP setup
// button's profile param — all read a string); flightRef shares the
// in-flight creation promise so simultaneous MCP setup / Create clicks fire
// ONE profiles.create. A settled flight clears its slot: failures retry,
// and a null result (form invalid at flight time) isn't sticky.
const ensureAgentCreated = () => {
// Renamed since the draft materialized? The old draft is orphaned —
// discard it and create fresh under the new slug.
if (createdRef.current && createdRef.current !== slug) {
discardDraft()
setCreatedForCaps(null)
}
if (createdRef.current) {
return Promise.resolve(createdRef.current)
}
const flight = singleFlight(flightRef, async () => {
if (!valid || taken) {
return null
}
const descriptionText = [title, description].filter(Boolean).join(' — ')
await requestForTarget('profiles.create', {
name: slug,
description: descriptionText,
// Clone sources are profiles of the TARGET backend. The picker's
// roster is the local one, so a remote create always starts from the
// remote machine's default (or fresh) — never a local profile name
// the remote box doesn't have.
clone_from: cloneFrom === '__none__' ? null : remoteTarget ? 'default' : cloneFrom,
no_skills: noSkills,
// Shared (not copied) auth keeps ONE OAuth/token pool with the main
// profile, so refreshes can't invalidate each other. Older gateways
// ignore the param and copy — still functional, just forked.
share_auth: shareAuth,
soul: composeSoul({ name: slug, title, description, roster, customSoul: soul }),
...(model.trim() && provider.trim() ? { model: model.trim(), provider: provider.trim() } : {})
})
createdRef.current = slug
// Apply capability picks from the Advanced tabs (best-effort; the
// profile exists either way and Edit Profile can finish the job).
try {
const capPayload = {}
if (dirtyCaps.skills && caps) {
capPayload.disabled_skills = caps.skills.filter(s => !s.enabled).map(s => s.name)
}
if (dirtyCaps.toolsets && caps) {
const en = caps.toolsets.filter(t => t.enabled)
capPayload.enabled_toolsets =
en.length === caps.toolsets.length || en.length === 0 ? [] : en.map(t => t.name)
}
if (dirtyCaps.mcp && caps) {
capPayload.enabled_mcp_servers = caps.mcp.filter(m => m.enabled).map(m => m.name)
}
if (Object.keys(capPayload).length) {
await requestForTarget('profiles.configure', { name: slug, ...capPayload })
}
} catch {
/* capability application is best-effort */
}
if (remoteTarget) {
// The bot lives on ANOTHER machine — local bot-meta is scoped to the
// active gateway, so write appearance/title into the remote
// profile's ui_meta (and asset store) directly. Best-effort: the
// profile exists either way.
const { image: avatarImage, ...look } = {
shape,
color,
image,
imageKind: image ? 'photo' : 'shape',
title: title.trim(),
created: Date.now()
}
try {
void requestForTarget('profiles.configure', { name: slug, ui_meta: { 'hermes-bots': look } }).catch(() => undefined)
if (avatarImage) {
void requestForTarget('profiles.set_asset', { name: slug, asset: 'avatar', data: avatarImage }).catch(() => undefined)
}
} catch {
/* older remote gateway */
}
} else {
saveBotMeta(slug, { shape, color, image, imageKind: image ? 'photo' : 'shape', title: title.trim(), created: Date.now() })
}
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
return slug
})
return flight
}
const submit = async () => {
if (!valid || taken || busy) {
return
}
setBusy(true)
setError(null)
try {
const slugCreated = await ensureAgentCreated()
if (!slugCreated) {
setBusy(false)
setError('Could not create the agent.')
return
}
host.notify({
kind: 'success',
message: remoteTarget
? `Agent "${displayName({ name: slug, title })}" created on ${targetLabel}`
: `Agent "${displayName({ name: slug, title })}" created`
})
const wasRemote = remoteTarget
reset()
onClose()
if (wasRemote) {
// The bot lives on another machine: it appears in the roster via the
// union enumeration; chat routes through its own source. No local
// canonical chat to birth here.
queryClient.invalidateQueries({ queryKey: ROSTER_KEY })
return
}
$selectedBot.set(slug)
// Birth the bot's forever chat right away: it introduces itself as
// the first thing the user sees, and the pin exists from minute one.
try {
// Creates, pins, opens, and kicks off the intro in one flow.
const sid = await createCanonicalChat(slug)
if (!sid && typeof host.newChat === 'function') {
host.newChat(slug)
}
} catch {
if (typeof host.newChat === 'function') {
host.newChat(slug)
}
}
} catch (err) {
setBusy(false)
setError(err instanceof Error ? err.message : String(err))
}
}
return jsx(Dialog, {
open,
onOpenChange: value => {
if (!value && !busy) {
// Cancel path (esc / overlay click): a materialized draft profile is
// discarded — preconfigure-then-back-out leaves nothing behind.
discardDraft()
reset()
onClose()
}
},
children: jsxs(DialogContent, {
className: advanced ? 'max-w-3xl' : 'max-w-md',
// Native resize handle (bottom-right corner): the dialog becomes a
// window the user can grow/shrink. overflow:auto is required for CSS
// resize to engage; caps keep it on screen.
style: advanced
? { resize: 'both', overflow: 'auto', minWidth: 420, minHeight: 360, maxWidth: '95vw', maxHeight: '90vh' }
: undefined,
children: [
jsxs(DialogHeader, {
children: [
jsx(DialogTitle, { children: 'New Agent' }),
jsx(DialogDescription, {
children: 'A named teammate with its own memory, skills, and chat. It can message your other agents.'
})
]
}),
jsxs('div', {
className: 'grid gap-3.5',
children: [
jsx('div', {
className: 'flex justify-center py-1',
children: jsx(BotFace, { shape, color, image, size: 56, name: slug || 'agent' })
}),
jsx(AvatarPicker, {
shape,
color,
image,
onShape: setShape,
onColor: setColor,
onImage: setImage,
generateSeed: { name: slug || 'agent', title, description }
}),
labeled(
'Name',
jsx(Input, {
autoFocus: true,
placeholder: 'inbox-triage',
value: name,
onChange: event => setName(event.target.value)
})
),
taken
? jsx('div', {
className: 'text-xs text-(--ui-accent)',
children: remoteTarget
? `An agent named "${slug}" already exists on ${targetLabel}.`
: `An agent named "${slug}" already exists.`
})
: null,
// Multi-connection desktops choose WHERE the agent lives. Hidden
// on single-connection setups — the active gateway is the only
// possible home, exactly the old behavior.
Array.isArray(connections) && connections.length > 1
? labeled(
'Create on',
jsxs(Select, {
value: targetConnection || activeConnectionId || 'local',
onValueChange: value => {
setTargetConnection(value === (activeConnectionId || 'local') ? '' : value)
// The capability catalog and clone list belong to the
// target backend — refetch for the new home. The live
// Capabilities tab re-pins to it via fixedConnection on
// builds that route it (staged checklists otherwise).
setCaps(null)
setCapsFailed(false)
setAdvTab('general')
},
children: [
jsx(SelectTrigger, {
className: 'h-8 rounded-md',
children: jsx(SelectValue, {})
}),
jsx(SelectContent, {
children: connections.map(connection =>
jsx(
SelectItem,
{
value: connection.id,
children:
connection.id === (activeConnectionId || 'local')
? `${connection.label || connection.id} (current)`
: connection.label || connection.id
},
connection.id
)
)
})
]
})
)
: null,
remoteTarget
? jsx('div', {
className: 'text-[0.7rem] leading-5 text-(--ui-text-tertiary)',
children: `The agent is created on ${targetLabel} and appears in the roster as a Connections bot. Chat routes to that machine.`
})
: null,
labeled(
'Title',
jsx(Input, {
placeholder: 'Inbox Triage',
value: title,
onChange: event => setTitle(event.target.value)
})
),
labeled(
'Description',
jsx(Textarea, {
className: 'min-h-16',
placeholder: 'What should this Bot help with?',
value: description,
onChange: event => setDescription(event.target.value)
})
),
jsxs('button', {
type: 'button',
className:
'flex items-center gap-1 text-xs font-medium text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)',
onClick: () => {
setAdvanced(v => {
if (!v) {
ensureCaps()
}
return !v
})
},
children: [
jsx(Codicon, { name: advanced ? 'chevron-down' : 'chevron-right', className: 'text-[0.8rem]' }),
'Advanced'
]
}),
advanced
? jsxs('div', {
className: 'grid gap-3 rounded-md border border-(--ui-stroke-secondary) p-3',
children: [
jsx('div', {
className: 'flex gap-1',
// Newer desktops export the whole Capabilities surface —
// one live tab replaces the three staged checklists.
// The live Capabilities surface (SkillsView) binds to
// the ACTIVE gateway's backend unless this build routes
// fixedConnection (skillsViewRoutesConnections) — then a
// remote-target draft gets the live surface pinned to
// ITS machine. Builds without that routing keep the
// staged checklists for remote targets (their catalog
// reads already route to the target).
children: (SkillsView && (!remoteTarget || skillsViewRoutesConnections)
? [
['general', 'General'],
['capabilities', 'Capabilities']
]
: [
['general', 'General'],
['skills', 'Skills'],
['toolsets', 'Tools'],
['mcp', 'MCP']
]
).map(([id, label]) =>
jsx(
'button',
{
type: 'button',
className: cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
advTab === id
? 'bg-(--chrome-action-hover) text-(--ui-text-primary)'
: 'text-(--ui-text-tertiary) hover:text-(--ui-text-secondary)'
),
onClick: () => {
setAdvTab(id)
setCapFilter('')
if (id === 'capabilities') {
// The live surface needs a real profile —
// materialize it now (same lazy-create door
// the MCP setup buttons use).
void ensureAgentCreated()
.then(created => created && setCreatedForCaps(created))
.catch(err => host.notifyError(err, 'Could not create the profile yet'))
} else if (id !== 'general') {
ensureCaps()
}
},
children: label
},
id
)
)
}),
advTab === 'general'
? jsxs('div', {
className: 'grid gap-3.5',
children: [
labeled(
remoteTarget ? `Clone from profile (on ${targetLabel})` : 'Clone from profile',
jsxs(Select, {
disabled: remoteTarget,
value: remoteTarget ? 'default' : cloneFrom,
onValueChange: value => {
setCloneFrom(value)
setCaps(null)
setCapsFailed(false)
},
children: [
jsx(SelectTrigger, {
className: 'h-8 rounded-md',
children: jsx(SelectValue, {})
}),
jsxs(SelectContent, {
children: [
jsx(SelectItem, {
value: '__none__',
children: 'Fresh profile (bundled skills)'
}),
...roster.map(b => jsx(SelectItem, { value: b.name, children: b.name }, b.name))
]
})
]
})
),
jsx(ModelPicker, {
value: { provider, model },
onChange: patch => {
if ('provider' in patch) {
setProvider(patch.provider)
}
if ('model' in patch) {
setModel(patch.model)
}
},
placeholderModel: 'inherited from launch profile'
}),
labeled(
'SOUL.md (optional — replaces the generated persona)',
jsx(Textarea, {
className: 'min-h-24 font-mono text-xs leading-5',
placeholder:
'Leave blank to auto-generate from name/title/description + agent-messaging roster.',
value: soul,
onChange: event => setSoul(event.target.value)
})
),
jsxs('label', {
className: 'flex items-center gap-2 text-xs text-(--ui-text-secondary)',
children: [
jsx(Checkbox, {
checked: shareAuth,
onCheckedChange: value => setShareAuth(Boolean(value))
}),
'Share keys & accounts with the main profile'
]
}),
jsx('div', {
className: 'pl-6 pt-0.5 text-[0.7rem] leading-5 text-(--ui-text-tertiary)',
children:
'Subscriptions, OAuth logins, and API keys stay shared (not copied), so token refreshes never invalidate each other. Uncheck for an isolated snapshot copy.'
}),
jsxs('label', {
className: 'flex items-center gap-2 text-xs text-(--ui-text-secondary)',
children: [
jsx(Checkbox, {
checked: noSkills,
onCheckedChange: value => setNoSkills(Boolean(value))
}),
'Create empty (skip bundled skills)'
]
})
]
})
: advTab === 'capabilities'
? !valid || taken
? jsx('div', {
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
children: taken
? 'That name is taken — pick another before configuring capabilities.'
: 'Name the agent first — a draft profile is created when you open this tab (discarded if you cancel).'
})
: !createdForCaps
? jsx('div', {
className: 'flex justify-center py-4',
children: jsx(GlyphSpinner, {
spinner: 'breathe',
className: 'text-(--ui-text-tertiary)'
})
})
: jsx('div', {
className: 'overflow-hidden rounded-md border border-(--ui-stroke-secondary)',
style: { height: 440, minHeight: 280, resize: 'vertical', overflow: 'auto' },
// The REAL core Capabilities surface (skills +
// one-click hub installs + tools + MCP), pinned
// to the just-created profile — and, for a
// remote-target draft, to the target machine's
// backend via fixedConnection. Writes land
// immediately — no staging needed.
children: jsx(SkillsView, {
embedded: true,
fixedProfile: createdForCaps,
...(remoteTarget ? { fixedConnection: targetConnection } : {})
})
})
: capsFailed
? jsx('div', {
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
children:
'Capability catalog needs a newer gateway (restart it after updating Hermes).'
})
: !caps
? jsx('div', {
className: 'flex justify-center py-4',
children: jsx(GlyphSpinner, {
spinner: 'breathe',
className: 'text-(--ui-text-tertiary)'
})
})
: advTab === 'skills'
? noSkills
? jsx('div', {
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
children: '“Create empty” is checked — no bundled skills will be installed.'
})
: jsxs('div', {
className: 'grid gap-1.5',
children: [
jsx(Input, {
className: 'h-7 text-xs',
placeholder: 'Filter skills…',
value: capFilter,
onChange: event => setCapFilter(event.target.value)
}),
jsx(ScrollArea, {
className: 'hermes-scroll-cap',
style: { maxHeight: 200 },
children: jsx(CheckList, {
items: capFilter.trim()
? caps.skills.filter(s =>
s.name.toLowerCase().includes(capFilter.trim().toLowerCase())
)
: caps.skills,
onToggle: (name, enabled) => toggleCap('skills', name, enabled),
columns: 2
})
}),
jsx('div', {
className: 'text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
children: `Catalog from ${caps.source} — unchecked skills are disabled after creation.`
}),
jsx(HubSkillsSection, {
forProfile: null,
onInstalled: name =>
setCaps(prev =>
!prev || prev.skills.some(s => s.name === name)
? prev
: { ...prev, skills: [...prev.skills, { name, enabled: true }] }
)
})
]
})
: advTab === 'toolsets'
? jsxs('div', {
className: 'grid gap-1.5',
children: [
jsx(ScrollArea, {
className: 'hermes-scroll-cap',
style: { maxHeight: 200 },
children: jsx(CheckList, {
items: caps.toolsets,
onToggle: (name, enabled) => toggleCap('toolsets', name, enabled),
columns: 2
})
}),
jsx('div', {
className: 'text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
children: 'Leaving all (or none) checked keeps the default toolset behavior.'
})
]
})
: caps.mcp.length === 0
? jsx('div', {
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
children: 'No MCP servers configured or in the catalog.'
})
: jsxs('div', {
className: 'grid gap-1.5',
children: [
jsx(ScrollArea, {
className: 'hermes-scroll-cap',
style: { maxHeight: 200 },
children: jsx('div', {
className: 'grid gap-1',
children: caps.mcp.map(m => {
const needsSetup =
m.fromCatalog && !m.installed && ((m.requires || []).length > 0 || (m.auth || '').toLowerCase() === 'oauth')
return jsxs(
'label',
{
className: 'flex items-start gap-2 text-xs text-(--ui-text-secondary)',
children: [
jsx(Checkbox, {
checked: !!m.enabled,
disabled: needsSetup,
onCheckedChange: value => toggleCap('mcp', m.name, Boolean(value))
}),
jsxs('span', {
className: 'min-w-0',
children: [
jsx('span', { children: m.name }),
m.fromCatalog && !needsSetup
? jsx('span', {
className: 'ml-1.5 text-[0.65rem] text-(--ui-text-quaternary)',
children: m.installed
? 'catalog · installed'
: 'catalog'
})
: null,
needsSetup
? jsx(McpSetupButton, {
profile: createdRef.current,
entry: m,
ensureProfile: ensureAgentCreated,
onDone: () => {
// Setup done: mark installed so the row's
// checkbox un-disables, and enable it.
setCaps(prev =>
prev
? {
...prev,
mcp: prev.mcp.map(x =>
x.name === m.name
? { ...x, installed: true, enabled: true }
: x
)
}
: prev
)
setDirtyCaps(prev => ({ ...prev, mcp: true }))
}
})
: null,
m.description
? jsx('div', {
className:
'truncate text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
children: m.description
})
: null
]
})
]
},
m.name
)
})
})
}),
jsx('div', {
className: 'text-[0.65rem] leading-4 text-(--ui-text-quaternary)',
children:
'Configured servers copy from the main profile; catalog entries are the bundled MCP menu. Entries needing API keys route through setup first (credentials follow the shared keys setting).'
})
]
})
]
})
: null,
error
? jsx('div', {
className: 'rounded-md border border-(--ui-stroke-secondary) px-3 py-2 text-xs text-(--ui-accent)',
children: error
})
: null
]
}),
jsxs(DialogFooter, {
children: [
jsx(Button, {
variant: 'ghost',
disabled: busy,
onClick: () => {
discardDraft()
reset()
onClose()
},
children: 'Cancel'
}),
jsx(Button, {
disabled: busy || !valid || taken,
onClick: submit,
children: busy ? 'Creating…' : 'Create Agent'
})
]
})
]
})
})
}
// ── routines (cron) ──────────────────────────────────────────────────────────
//
// Jobs are namespaced "[bot:<name>] <routine>". A job running in the active
// bot profile uses the plain instruction; a different profile keeps the
// hermes -p <bot> chat delegation wrapper so the run reaches that bot's
// history. The tile follows the bot you're chatting with (gateway profile).
const BOT_TAG_RE = /^\[bot:([a-z0-9][a-z0-9_-]*)\]\s*/i
const SAFE_ROUTINE_MARKER = '[bot-mode:routine:v2] '
const LEGACY_DELEGATED_ROUTINE_PREFIX = 'You are running the scheduled routine "'
function routineBot(job) {
const match = BOT_TAG_RE.exec(job?.name || '')
return match ? match[1].toLowerCase() : null
}
function routineTitle(job) {
return (job?.name || '').replace(BOT_TAG_RE, '') || 'Untitled cronjob'
}
function isLegacyDelegatedRoutine(job) {
const preview = typeof job?.prompt_preview === 'string' ? job.prompt_preview : job?.prompt
return Boolean(routineBot(job) && typeof preview === 'string' && preview.startsWith(LEGACY_DELEGATED_ROUTINE_PREFIX))
}
async function loadRoutines(profile) {
// profile scopes cron.manage to that bot's own cron store (core RPC gained an
// optional `profile` param). Older gateways ignore the unknown param and
// return the launch-profile store — the [bot:] tag filter in selectRoutineJobs
// remains the graceful fallback there.
const scope = profile ? { profile } : {}
const data = await host.request('cron.manage', { action: 'list', include_disabled: true, ...scope })
const jobs = Array.isArray(data?.jobs) ? data.jobs : []
const activeLegacyJobs = jobs.filter(
job => isLegacyDelegatedRoutine(job) && job.enabled !== false && job.state !== 'paused'
)
// A pause failing must not fail the LIST — the pane would report "could
// not load cronjobs" over data that loaded fine, and the 20s poll would
// re-attempt the failing pause inside a failing query forever. Each pause
// swallows its own error; the overlay only claims jobs the gateway
// actually paused, and the next poll retries the rest.
const pauses = await Promise.all(
activeLegacyJobs.map(job =>
host
.request('cron.manage', { action: 'pause', name: job.job_id, ...scope })
.then(() => true)
.catch(() => false)
)
)
if (!activeLegacyJobs.length) {
return data
}
const pausedIds = new Set(activeLegacyJobs.filter((job, index) => pauses[index]).map(job => job.job_id))
return {
...data,
jobs: jobs.map(job => (pausedIds.has(job.job_id) ? { ...job, enabled: false, state: 'paused' } : job))
}
}
function useRoutines(profile) {
return useQuery({
queryKey: [...ROUTINES_KEY, profile || ''],
queryFn: () => loadRoutines(profile),
refetchInterval: 20000,
staleTime: 8000
})
}
function routineCreateTarget(owner, activeBot) {
return owner || activeBot
}
async function invalidateRoutineOwner(profile) {
await queryClient.invalidateQueries({
queryKey: [...ROUTINES_KEY, profile || ''],
exact: true
})
}
/** Pick which cron jobs to show. A failed refresh keeps the last good list. */
function selectRoutineJobs(data, error, lastJobs, bot) {
const live = Array.isArray(data?.jobs) ? data.jobs : null
const all = live ?? (error ? lastJobs : [])
const scopedToBot = normalizedProfileName(data?.scoped) === normalizedProfileName(bot)
return {
live,
all,
jobs: scopedToBot ? all : all.filter(job => (routineBot(job) || 'default') === bot)
}
}
/**
* Why the Routines pane can be empty while the bot's cron store has jobs.
*
* On older gateways the pane only shows jobs namespaced `[bot:<name>]` for the
* active bot (plus untagged legacy jobs on the default bot). When jobs exist in
* the store but none surface for this bot, the user is left staring at the
* generic empty state with no hint that cronjobs are present but hidden.
* Return a short explanation string in that case, or null when the store is
* genuinely empty (or the active bot's jobs are already shown).
*/
function routineFilterHint(all, jobs) {
if (jobs.length !== 0 || !Array.isArray(all) || all.length === 0) {
return null
}
return 'Cronjobs exist in this profile but none are tagged for this bot. ' +
'Name a job "[bot:<name>] …" to show it here, or see them in Cron below.'
}
function normalizedProfileName(profile) {
return typeof profile === 'string' ? profile.trim().toLowerCase() : ''
}
function shellQuote(value) {
return `'${String(value).replaceAll("'", "'\"'\"'")}'`
}
/** Escape for interpolation INSIDE an existing double-quoted shell string:
* keeps ", `, $, and \ literal so free-text titles (which sync from ui_meta)
* and gateway profile names can't expand or break out of the quotes. */
function shellDoubleQuote(value) {
return String(value).replace(/[\\"`$]/g, ch => '\\' + ch)
}
function routineInputError(title, instruction) {
if (String(title).includes('\0')) {
return 'Cronjob name cannot contain NUL (U+0000).'
}
if (String(instruction).includes('\0')) {
return 'Cronjob instruction cannot contain NUL (U+0000).'
}
return null
}
function routinePrompt(bot, title, instruction, activeProfile) {
if (normalizedProfileName(bot) && normalizedProfileName(bot) === normalizedProfileName(activeProfile)) {
return instruction
}
return (
`${SAFE_ROUTINE_MARKER}You are running the scheduled routine "${title}" for agent '${bot}'. ` +
`Execute it AS that agent so the run lands in its own history: run this in the terminal and relay the output:\n\n` +
`hermes -p ${shellQuote(bot)} chat -c ${shellQuote(`Routine: ${title}`)} -q ${shellQuote(`[Scheduled routine] ${instruction}`)}\n\n` +
`If the command fails, report the error instead.`
)
}
function scheduleLabel(schedule) {
const once = /^once in (.+)$/.exec(schedule || '')
if (once) {
return `Once (${once[1]})`
}
const bare = /^(\d+)([mhd])$/.exec(schedule || '')
if (bare) {
return `Once (${bare[1]}${bare[2]})`
}
const match = /^every (\d+)m$/.exec(schedule || '')
if (match) {
const minutes = Number(match[1])
if (minutes % 1440 === 0) {
const d = minutes / 1440
return d === 1 ? 'Daily' : `Every ${d} days`
}
if (minutes % 60 === 0) {
const h = minutes / 60
return h === 1 ? 'Hourly' : `Every ${h}h`
}
return `Every ${minutes}m`
}
return schedule || ''
}
function RoutineRow({ job, profile }) {
const [busy, setBusy] = useState(false)
// Optimistic overlay: null = trust server state. Set immediately on
// toggle so the switch responds even before the refetch lands.
const [pendingActive, setPendingActive] = useState(null)
const legacyUnsafe = isLegacyDelegatedRoutine(job)
const serverActive = !legacyUnsafe && job.enabled !== false && job.state !== 'paused'
const active = pendingActive === null ? serverActive : pendingActive
if (pendingActive !== null && pendingActive === serverActive) {
setPendingActive(null) // server caught up
}
const act = async action => {
if (busy) {
return
}
setBusy(true)
if (action === 'pause' || action === 'resume') {
setPendingActive(action === 'resume')
}
try {
await host.request('cron.manage', { action, name: job.job_id, ...(profile ? { profile } : {}) })
await invalidateRoutineOwner(profile)
} catch (err) {
setPendingActive(null)
host.notifyError(err, 'Cronjob update failed')
} finally {
setBusy(false)
}
}
return jsxs('div', {
className: cn(
'group grid gap-1.5 rounded-lg border border-(--ui-stroke-secondary) p-2.5 transition-colors',
'hover:border-(--ui-stroke-primary, var(--ui-stroke-secondary))'
),
children: [
jsxs('div', {
className: 'flex items-center gap-2',
children: [
jsx('span', {
'aria-hidden': true,
className: cn('size-1.5 shrink-0 rounded-full', active ? 'bg-emerald-500' : 'bg-(--ui-text-quaternary)')
}),
jsx('span', {
className: cn('min-w-0 flex-1 truncate text-xs font-medium', !active && 'text-(--ui-text-tertiary)'),
children: routineTitle(job)
}),
jsx(Switch, {
checked: active,
disabled: busy || legacyUnsafe,
onCheckedChange: value => act(value ? 'resume' : 'pause')
}),
jsx(Tip, {
label: 'Delete cronjob',
children: jsx('button', {
type: 'button',
disabled: busy,
className:
'flex size-5 items-center justify-center rounded text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover:opacity-100 hover:bg-(--chrome-action-hover) hover:text-foreground',
onClick: () => act('remove'),
children: jsx(Codicon, { name: 'trash', className: 'text-[0.75rem]' })
})
})
]
}),
jsxs('div', {
className: 'flex items-center justify-between gap-2 pl-3.5',
children: [
jsxs('span', {
className:
'inline-flex items-center gap-1 rounded-full border border-(--ui-stroke-secondary) px-1.5 py-0.5 text-[0.65rem] text-(--ui-text-tertiary)',
children: [jsx(Codicon, { name: 'calendar', className: 'text-[0.7rem]' }), scheduleLabel(job.schedule)]
}),
jsx('span', {
className: 'truncate text-[0.65rem] text-(--ui-text-quaternary)',
children: active && job.next_run_at ? `next ${relativeTime(new Date(job.next_run_at).getTime())}` : 'paused'
})
]
}),
legacyUnsafe
? jsx('div', {
className:
'rounded-md border border-(--ui-stroke-secondary) px-2 py-1.5 text-[0.65rem] leading-4 text-(--ui-accent)',
children: 'Paused for security: delete and recreate this legacy cronjob before running it again.'
})
: null
]
})
}
// Structured schedule picker: frequency first, then only the detail that
// frequency needs (time of day, weekday, day of month, interval). Emits a
// Hermes-native schedule string; Advanced exposes it raw.
const FREQUENCIES = [
{ id: 'once', label: 'Once, in\u2026' },
{ id: 'hourly', label: 'Every hour' },
{ id: 'daily', label: 'Every day' },
{ id: 'weekdays', label: 'Weekdays' },
{ id: 'weekly', label: 'Every week' },
{ id: 'monthly', label: 'Every month' },
{ id: 'interval', label: 'Interval' },
{ id: 'advanced', label: 'Advanced\u2026' }
]
const WEEKDAYS = [
{ id: '1', label: 'Monday' },
{ id: '2', label: 'Tuesday' },
{ id: '3', label: 'Wednesday' },
{ id: '4', label: 'Thursday' },
{ id: '5', label: 'Friday' },
{ id: '6', label: 'Saturday' },
{ id: '0', label: 'Sunday' }
]
const TIMES = (() => {
const out = []
for (let h = 0; h < 24; h++) {
for (const m of [0, 30]) {
const ampm = h < 12 ? 'AM' : 'PM'
const h12 = h % 12 === 0 ? 12 : h % 12
out.push({ id: `${h}:${m}`, label: `${h12}:${String(m).padStart(2, '0')} ${ampm}`, h, m })
}
}
return out
})()
/** Compose the Hermes schedule string from picker state. */
function composeSchedule(state) {
const [h, m] = (state.time || '9:0').split(':').map(Number)
switch (state.freq) {
case 'once': {
const n = Math.max(1, parseInt(state.onceN, 10) || 1)
return `${n}${state.onceUnit || 'h'}`
}
case 'hourly':
return 'every 1h'
case 'daily':
return `${m} ${h} * * *`
case 'weekdays':
return `${m} ${h} * * 1-5`
case 'weekly':
return `${m} ${h} * * ${state.weekday || '1'}`
case 'monthly':
return `${m} ${h} ${state.monthday || '1'} * *`
case 'interval': {
const n = Math.max(1, parseInt(state.intervalN, 10) || 1)
return `every ${n}${state.intervalUnit || 'h'}`
}
default:
return state.raw || ''
}
}
function scheduleSummary(state) {
const t = TIMES.find(x => x.id === state.time)
const tl = t ? t.label : '9:00 AM'
const unitWord = u => (u === 'm' ? 'minute(s)' : u === 'd' ? 'day(s)' : 'hour(s)')
const cap =
state.freq !== 'once' && String(state.repeatN || '').trim()
? `, ${Math.max(1, parseInt(state.repeatN, 10) || 1)} time(s) total`
: ''
switch (state.freq) {
case 'once':
return `Runs once, ${Math.max(1, parseInt(state.onceN, 10) || 1)} ${unitWord(state.onceUnit)} from now`
case 'hourly':
return 'Runs at the top of every hour' + cap
case 'daily':
return `Runs every day at ${tl}` + cap
case 'weekdays':
return `Runs Monday\u2013Friday at ${tl}` + cap
case 'weekly':
return `Runs every ${(WEEKDAYS.find(w => w.id === state.weekday) || WEEKDAYS[0]).label} at ${tl}` + cap
case 'monthly':
return `Runs on day ${state.monthday || '1'} of each month at ${tl}` + cap
case 'interval':
return `Runs every ${Math.max(1, parseInt(state.intervalN, 10) || 1)} ${unitWord(state.intervalUnit)}` + cap
default:
return 'Raw schedule \u2014 every Nm/Nh/Nd or 5-field cron'
}
}
function pickerSelect(value, onChange, options) {
return jsxs(Select, {
value,
onValueChange: onChange,
children: [
jsx(SelectTrigger, { className: 'h-8 rounded-md', children: jsx(SelectValue, {}) }),
jsx(SelectContent, {
children: options.map(o => jsx(SelectItem, { value: o.id, children: o.label }, o.id))
})
]
})
}
function SchedulePicker({ state, setState }) {
const upd = patch => setState(prev => ({ ...prev, ...patch }))
const needsTime = ['daily', 'weekdays', 'weekly', 'monthly'].includes(state.freq)
return jsxs('div', {
className: 'grid gap-2',
children: [
jsxs('div', {
style: { display: 'grid', gridTemplateColumns: needsTime ? '1fr 1fr' : '1fr', gap: '8px' },
children: [
pickerSelect(state.freq, v => upd({ freq: v }), FREQUENCIES),
needsTime ? pickerSelect(state.time, v => upd({ time: v }), TIMES) : null
]
}),
state.freq === 'once'
? jsxs('div', {
style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' },
children: [
jsx(Input, {
className: 'h-8',
placeholder: '30',
value: state.onceN,
onChange: event => upd({ onceN: event.target.value.replace(/[^0-9]/g, '').slice(0, 4) })
}),
pickerSelect(state.onceUnit, v => upd({ onceUnit: v }), [
{ id: 'm', label: 'minutes from now' },
{ id: 'h', label: 'hours from now' },
{ id: 'd', label: 'days from now' }
])
]
})
: null,
state.freq === 'weekly'
? pickerSelect(state.weekday, v => upd({ weekday: v }), WEEKDAYS)
: null,
state.freq === 'monthly'
? labeled(
'Day of month',
jsx(Input, {
className: 'h-8',
placeholder: '1',
value: state.monthday,
onChange: event => upd({ monthday: event.target.value.replace(/[^0-9]/g, '').slice(0, 2) })
})
)
: null,
state.freq === 'interval'
? jsxs('div', {
style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' },
children: [
jsx(Input, {
className: 'h-8',
placeholder: '2',
value: state.intervalN,
onChange: event => upd({ intervalN: event.target.value.replace(/[^0-9]/g, '').slice(0, 4) })
}),
pickerSelect(state.intervalUnit, v => upd({ intervalUnit: v }), [
{ id: 'm', label: 'minutes' },
{ id: 'h', label: 'hours' },
{ id: 'd', label: 'days' }
])
]
})
: null,
state.freq === 'advanced'
? jsx(Input, {
className: 'h-8 font-mono text-xs',
placeholder: 'every 1d \u00b7 every 2h \u00b7 0 9 * * * (cron)',
value: state.raw,
onChange: event => upd({ raw: event.target.value })
})
: null,
state.freq !== 'once' && state.freq !== 'advanced'
? jsxs('div', {
className: 'flex items-center gap-2',
children: [
jsx('span', { className: 'text-xs text-(--ui-text-tertiary)', children: 'Stop after' }),
jsx(Input, {
className: 'h-7 w-16 text-xs',
placeholder: '\u221e',
value: state.repeatN,
onChange: event => upd({ repeatN: event.target.value.replace(/[^0-9]/g, '').slice(0, 4) })
}),
jsx('span', { className: 'text-xs text-(--ui-text-tertiary)', children: 'runs (blank = forever)' })
]
})
: null,
jsx('div', {
className: 'text-[0.65rem] text-(--ui-text-quaternary)',
children: `${scheduleSummary(state)} \u00b7 ${composeSchedule(state) || '\u2014'}`
})
]
})
}
function defaultScheduleState() {
return { freq: 'daily', time: '9:0', weekday: '1', monthday: '1', intervalN: '2', intervalUnit: 'h', onceN: '30', onceUnit: 'm', repeatN: '', raw: '' }
}
function CreateRoutineDialog({ bot, open, onClose }) {
const [name, setName] = useState('')
const [instruction, setInstruction] = useState('')
const [sched, setSched] = useState(defaultScheduleState())
const [continuity, setContinuity] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const activeProfile = useValue(host.state.profile)
const schedule = composeSchedule(sched)
const reset = () => {
setName('')
setInstruction('')
setSched(defaultScheduleState())
setContinuity(false)
setBusy(false)
setError(null)
}
const submit = async () => {
const title = name.trim()
const task = instruction.trim()
const inputError = routineInputError(title, task)
if (inputError) {
setError(inputError)
return
}
if (!title || !task || !schedule.trim() || busy) {
return
}
setBusy(true)
setError(null)
try {
const repeatN =
sched.freq !== 'once' && sched.freq !== 'advanced' && String(sched.repeatN || '').trim()
? Math.max(1, parseInt(sched.repeatN, 10) || 1)
: null
await host.request('cron.manage', {
action: 'add',
name: `[bot:${bot}] ${title}`,
schedule: schedule.trim(),
prompt: routinePrompt(bot, title, task, activeProfile),
...(bot ? { profile: bot } : {}),
...(repeatN ? { repeat: repeatN } : {}),
...(continuity ? { continuity: true } : {})
})
await invalidateRoutineOwner(bot)
host.notify({ kind: 'success', message: `Cronjob "${title}" scheduled` })
reset()
onClose()
} catch (err) {
setBusy(false)
setError(err instanceof Error ? err.message : String(err))
}
}
return jsx(Dialog, {
open,
onOpenChange: value => {
if (!value && !busy) {
reset()
onClose()
}
},
children: jsxs(DialogContent, {
className: 'max-w-md',
children: [
jsxs(DialogHeader, {
children: [
jsx(DialogTitle, { children: 'New Cronjob' }),
jsx(DialogDescription, {
children: `A recurring task ${displayName({ name: bot }, $botMeta.get()[bot])} runs on a schedule. Runs land in its own chat history.`
})
]
}),
jsxs('div', {
className: 'grid gap-3.5',
children: [
labeled(
'Name',
jsx(Input, {
autoFocus: true,
placeholder: 'Name this cronjob',
value: name,
onChange: event => setName(event.target.value)
})
),
labeled(
'Instruction',
jsx(Textarea, {
className: 'min-h-20',
placeholder: 'What should this cronjob do each time it runs?',
value: instruction,
onChange: event => setInstruction(event.target.value)
})
),
labeled('When to run', jsx(SchedulePicker, { state: sched, setState: setSched })),
jsxs('label', {
className: 'flex items-center gap-2 text-xs text-(--ui-text-tertiary) cursor-pointer select-none',
children: [
jsx('input', {
type: 'checkbox',
className: 'accent-(--ui-accent)',
checked: continuity,
onChange: event => setContinuity(event.target.checked)
}),
'Continuity: each run sees the previous run\u2019s output (dedupe, continue where it left off)'
]
}),
error
? jsx('div', {
className: 'rounded-md border border-(--ui-stroke-secondary) px-3 py-2 text-xs text-(--ui-accent)',
children: error
})
: null
]
}),
jsxs(DialogFooter, {
children: [
jsx(Button, {
variant: 'ghost',
disabled: busy,
onClick: () => {
reset()
onClose()
},
children: 'Cancel'
}),
jsx(Button, {
disabled: busy || !name.trim() || !instruction.trim() || !schedule.trim(),
onClick: submit,
children: busy ? 'Scheduling…' : 'Create Cronjob'
})
]
})
]
})
})
}
/** Keeps $selectedBot in sync with the focused chat's owner profile.
* nanostores' `.listen()` never replays the current value the way
* `.subscribe()` does, so a disable → profile switch → re-enable sequence
* would otherwise leave $selectedBot pointed at whichever bot was active
* before the plugin was disabled — reseeding here on every register() call
* closes that gap. Returns the unbind function for ctx.onDispose. */
function bindProfileSync(profileStore) {
const current = profileStore.get?.()
if (current && typeof current === 'string') {
$selectedBot.set(current)
}
return profileStore.listen(profile => {
if (profile && typeof profile === 'string') {
$selectedBot.set(profile)
}
})
}
function RoutinesPane() {
const selected = useValue($selectedBot)
const focusedProfile = useValue($focusedBotProfile)
// The tile maps to the bot you're chatting with: the focused chat's owner
// profile is the truth once a chat opens (on older desktops without the
// focused-owner atom this is the live gateway profile, the previous
// behavior); $selectedBot covers the gap between a roster click and the
// focus/profile swap landing.
const bot = (focusedProfile || selected || 'default').trim() || 'default'
const meta = useValue($botMeta)[bot]
const { shape, color, image } = botAppearance(bot, meta)
const { data, error, isLoading, refetch } = useRoutines(bot)
const [createOpen, setCreateOpen] = useState(false)
const [createOwner, setCreateOwner] = useState(null)
const createTarget = routineCreateTarget(createOwner, bot)
const openCreate = () => {
setCreateOwner(bot)
setCreateOpen(true)
}
const view = selectRoutineJobs(data, error, $lastJobs.get(), bot)
if (view.live) {
$lastJobs.set(view.live)
}
const jobs = view.jobs
const staleNotice = error && !view.live && view.all.length
? 'Could not refresh cronjobs. Showing the last list we had.'
: null
const filterHint = routineFilterHint(view.all, jobs)
return jsxs('div', {
className: 'flex h-full flex-col',
children: [
jsxs('div', {
className: 'flex items-center gap-2 px-3 pt-3 pb-2',
children: [
jsx(BotFace, { shape, color, image, size: 22, name: bot }),
jsxs('div', {
className: 'min-w-0 flex-1',
children: [
jsxs('div', {
className: 'flex min-w-0 items-baseline gap-1.5 truncate',
children: [
jsx('div', {
className: 'truncate text-xs font-semibold',
children: displayName({ name: bot }, meta)
}),
showsHandle(bot, meta)
? jsx('span', {
className: 'shrink-0 font-mono text-[0.65rem] text-(--ui-text-quaternary)',
children: `@${botHandle(bot)}`
})
: null
]
}),
jsx('div', {
className: 'text-[0.65rem] uppercase tracking-wider text-(--ui-text-quaternary)',
children: 'Cronjobs'
})
]
}),
jsx(Tip, {
label: 'New Cronjob',
children: jsx('button', {
type: 'button',
className:
'flex size-6 shrink-0 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
onClick: openCreate,
children: jsx(Codicon, { name: 'add' })
})
})
]
}),
jsx('div', { className: 'mx-3 border-t border-(--ui-stroke-secondary)' }),
staleNotice
? jsx('div', {
className: 'mx-3 mt-2 rounded-md bg-(--chrome-action-hover) px-2 py-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
children: staleNotice
})
: null,
isLoading && !view.all.length
? jsx('div', {
className: 'flex flex-1 items-center justify-center',
children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' })
})
: error && !view.all.length
? jsxs('div', {
className: 'flex flex-1 flex-col items-center justify-center gap-3 px-4 text-center',
children: [
jsx(Codicon, { name: 'warning', className: 'text-[1.6rem] text-(--ui-text-quaternary)' }),
jsx('div', {
className: 'text-xs leading-5 text-(--ui-text-tertiary)',
children: 'Could not load cronjobs. The list may still be there.'
}),
jsx(Button, {
variant: 'secondary',
size: 'sm',
onClick: () => void refetch(),
children: 'Retry'
})
]
})
: jobs.length === 0
? jsxs('div', {
className: 'flex flex-1 flex-col items-center justify-center gap-3 px-4 text-center',
children: [
// No generic placeholder here: an icon + "cronjobs are…" blurb and the
// create button both just said "empty" (Teknium, Aug 2026). The hint
// text stays only when jobs exist but are hidden by the bot filter —
// that carries real information, not an empty-state marker.
filterHint
? jsx('div', {
className: 'text-xs leading-5 text-(--ui-text-tertiary)',
children: filterHint
})
: null,
jsx(Button, {
variant: 'secondary',
size: 'sm',
onClick: openCreate,
children: filterHint ? 'Create a cronjob for this bot' : 'Create Cronjob'
})
]
})
: jsx(ScrollArea, {
className: 'min-h-0 flex-1',
children: jsx('div', {
className: 'grid gap-1.5 px-2.5 py-2',
children: jobs.map(job => jsx(RoutineRow, { job, profile: bot }, job.job_id))
})
}),
jsx(CreateRoutineDialog, {
bot: createTarget,
open: createOpen,
onClose: () => {
setCreateOpen(false)
setCreateOwner(null)
}
// key is the jsx() THIRD argument — as a prop it is silently ignored
// and the dialog kept stale per-bot form state when the target changed.
}, createTarget)
]
})
}
// ── profile session workspace ────────────────────────────────────────────────
const PROFILE_SESSION_LIST_LIMIT = 200
function openBotSessionsWorkspace(bot) {
if (bot?.name && NAME_RE.test(bot.name)) {
$botSessionsWorkspace.set(bot.name)
}
}
function filterProfileSessions(sessions, query) {
const needle = String(query || '').trim().toLowerCase()
const rows = Array.isArray(sessions) ? sessions : []
if (!needle) return rows
return rows.filter(session =>
`${session?.title || ''} ${session?.preview || ''} ${session?.source || ''}`.toLowerCase().includes(needle)
)
}
function useProfileSessions(botName, gatewayGeneration) {
return useQuery({
queryKey: [ID, 'profile-sessions', botName, gatewayGeneration],
enabled: Boolean(botName),
// include_hidden: this browser exists precisely to see the profile's own
// (always-hidden) Bot Mode sessions alongside its regular ones.
queryFn: () => host.request('session.list', { profile: botName, limit: PROFILE_SESSION_LIST_LIMIT, include_hidden: true }),
refetchInterval: 8000,
staleTime: 4000,
retry: false
})
}
async function openProfileSession(botName, session, gatewayGeneration) {
const profile = String(botName || '')
const id = String(session?.id || '')
if (!NAME_RE.test(profile) || !id || gatewayGeneration !== $sessionsGatewayGeneration.get()) return
if (typeof host.openSession !== 'function') {
throw new Error('This Hermes Desktop version cannot open stored sessions')
}
// Same hydration contract as canonical Bot Chats (#89206): a bare open can
// focus a main surface whose runtime/transcript silently vanished, leaving a
// blank pane while the row preview still shows the conversation. Waiting on
// hydration lets the SDK issue the explicit resume when the surface is stale.
const hasAuthoritativeCount =
typeof session?.message_count === 'number' && Number.isFinite(session.message_count)
const expectHistory = hasAuthoritativeCount ? session.message_count > 0 : Boolean(session?.preview)
await host.openSession(id, { profile, awaitHydration: true, expectHistory, keepAllProfilesScope: true })
if (gatewayGeneration !== $sessionsGatewayGeneration.get()) return
$botSelectedSessions.set({ ...$botSelectedSessions.get(), [profile]: id })
}
function ProfileSessionRow({ session, botName, active, gatewayGeneration }) {
return jsxs('button', {
type: 'button',
'aria-current': active ? 'page' : undefined,
onClick: () => void openProfileSession(botName, session, gatewayGeneration).catch(err => host.notifyError(err, 'Could not open session')),
className: cn(
'flex w-full flex-col gap-0.5 overflow-hidden rounded-md px-2 py-1.5 text-left transition-colors',
'hover:bg-(--chrome-action-hover)',
active && 'bg-(--ui-row-active-background)'
),
children: [
jsx('span', {
className: 'truncate text-[0.8125rem] font-medium',
children: session.title || 'Untitled session'
}),
jsx('div', {
className: 'truncate text-[0.7rem] text-(--ui-text-tertiary)',
children: session.preview || session.source || 'No messages yet'
})
]
})
}
function ProfileSessionsWorkspace({ bot }) {
const gatewayGeneration = useValue($sessionsGatewayGeneration)
const { data, isLoading, error } = useProfileSessions(bot.name, gatewayGeneration)
const selectedByProfile = useValue($botSelectedSessions)
const [query, setQuery] = useState('')
const sourceSessions = data?.sessions || []
const sessions = filterProfileSessions(sourceSessions, query)
const inventoryBounded = sourceSessions.length >= PROFILE_SESSION_LIST_LIMIT
const selectedId = selectedByProfile[bot.name] || ''
const header = jsxs('div', {
className: 'flex items-center gap-2 px-2.5 pt-2.5 pb-2',
children: [
jsx(Button, {
variant: 'ghost',
size: 'sm',
onClick: () => $botSessionsWorkspace.set(null),
children: 'Back'
}),
jsx('div', {
className: 'min-w-0 flex-1 truncate text-sm font-semibold',
children: `${displayName(bot, $botMeta.get()[bot.name])} sessions`
})
]
})
return jsxs('div', {
className: 'flex h-full flex-col',
children: [
header,
jsx('div', {
className: 'px-2 pb-2',
children: jsx(Input, {
'aria-label': 'Filter sessions',
placeholder: 'Filter sessions…',
value: query,
onChange: event => setQuery(event.target.value)
})
}),
inventoryBounded
? jsx('div', {
className: 'px-2.5 pb-2 text-[0.65rem] text-(--ui-text-quaternary)',
children: `Showing the ${PROFILE_SESSION_LIST_LIMIT} most recent sessions.`
})
: null,
isLoading
? jsx('div', {
className: 'flex flex-1 items-center justify-center',
children: jsx(GlyphSpinner, { spinner: 'breathe' })
})
: error
? jsx('div', {
className: 'px-3 py-3 text-xs text-(--ui-text-tertiary)',
children: 'Could not load sessions for this profile.'
})
: jsx(ScrollArea, {
className: 'min-h-0 flex-1',
children: jsx('div', {
className: 'grid gap-0.5 px-1.5 pb-2',
children: sessions.length
? sessions.map(session => jsx(ProfileSessionRow, {
session,
botName: bot.name,
active: selectedId === session.id,
gatewayGeneration
}, session.id))
: jsx('div', {
className: 'px-2 py-3 text-center text-xs text-(--ui-text-tertiary)',
children: query.trim()
? inventoryBounded
? `No matching sessions in the ${PROFILE_SESSION_LIST_LIMIT} most recent.`
: 'No sessions match that filter.'
: 'No stored sessions yet.'
})
})
})
]
})
}
// ── roster pane ──────────────────────────────────────────────────────────────
/** "Active now" presence strip above the roster: chips for every bot that is
* working right now (the gateway-busy selected profile + bots whose last
* message landed inside the liveness window). Reuses the row avatar; each
* chip opens that bot's canonical Bot Chat. Omitted entirely when nothing
* is active, and never reorders the roster below it. */
function ActiveNowStrip({ roster, activeProfile, gatewayState, metaByName, onOpen }) {
const active = activeBots(roster, activeProfile, gatewayState)
if (!active.length) {
return null
}
return jsxs('div', {
role: 'status',
'aria-live': 'polite',
'aria-label': 'Active now',
className: 'flex flex-wrap items-center gap-1.5 px-2.5 pb-1.5',
children: [
jsx('span', {
className: 'text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)',
children: 'Active now'
}),
...active.map(bot => {
const meta = metaByName?.[bot.name]
const { shape, color, image } = botAppearance(bot.name, meta)
const photo = Boolean(image && !isBackfilledFacePng(image))
const label = displayName(bot, meta)
return jsx('button', {
type: 'button',
title: `Open ${label}'s chat`,
className: cn(
'flex items-center gap-1.5 rounded-md bg-(--chrome-action-hover) px-1.5 py-1 text-left transition-colors',
'hover:bg-(--chrome-action-hover) hover:text-foreground'
),
onClick: () => onOpen(bot),
children: [
jsx(BotFace, {
shape,
color,
image: photo ? image : null,
size: 24,
name: bot.name,
mood: 'work'
}),
jsx('span', {
className: 'max-w-28 truncate text-xs font-medium',
children: label
})
]
}, botRosterKey(bot))
})
]
})
}
/** Assign a bot to a group-chat membership without replacing its others.
* Existing groups are independent toggles; the input creates and joins a new
* one. Canonical groups + the legacy scalar projection ride ui_meta. */
function GroupDialog({ bot, onClose }) {
const meta = useValue($botMeta)
const [name, setName] = useState('')
const current = botGroups(meta[bot?.name])
const groups = knownGroups(meta)
const setMembership = (group, enabled) => {
saveBotMeta(bot.name, groupMembershipPatch(meta[bot.name], group, enabled))
host.notify({
kind: 'info',
message: enabled
? `${displayName(bot, meta[bot.name])} added to “${group}”`
: `${displayName(bot, meta[bot.name])} removed from “${group}”`
})
}
return jsx(Dialog, {
open: Boolean(bot),
onOpenChange: value => {
if (!value) {
onClose()
}
},
children: jsxs(DialogContent, {
className: 'max-w-sm',
children: [
jsxs(DialogHeader, {
children: [
jsx(DialogTitle, { children: 'Manage groups' }),
jsx(DialogDescription, {
children: 'A bot can join multiple group chats. Memberships sync to every machine.'
})
]
}),
groups.length
? jsx('div', {
className: 'grid gap-1.5',
children: groups.map(group => {
const enabled = current.includes(group)
return jsxs(
'label',
{
className:
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-(--chrome-action-hover)',
children: [
jsx(Checkbox, {
checked: enabled,
onCheckedChange: checked => setMembership(group, checked === true)
}),
jsx('span', { children: group })
]
},
group
)
})
})
: null,
jsxs('form', {
className: 'flex items-center gap-1.5',
onSubmit: event => {
event.preventDefault()
const trimmed = name.trim()
if (trimmed) {
setMembership(trimmed, true)
setName('')
}
},
children: [
jsx(Input, {
autoFocus: true,
placeholder: groups.length ? 'New group…' : 'Group name (e.g. Research)',
value: name,
onChange: event => setName(event.target.value)
}),
jsx(Button, { type: 'submit', size: 'sm', disabled: !name.trim(), children: 'Create & join' })
]
}),
current.length
? jsx(Button, {
variant: 'ghost',
size: 'sm',
className: 'justify-self-start',
onClick: () => saveBotMeta(bot.name, { groups: [], group: null }),
children: 'Remove from all groups'
})
: null
]
})
})
}
/** Compact picture controls shared by group-chat creation and settings:
* a live preview (image, else the organization glyph), Upload / Generate /
* Remove. Reuses the bot-avatar pipeline (device picker, 256px normalize,
* image.generate probe) so room pictures cost the same as bot avatars. */
function GroupImageControls({ image, onImage, seedName, seedMembers }) {
const imagen = useValue($imagenAvailable)
const [busy, setBusy] = useState(false)
if (imagen === null) {
void probeImagen()
}
const upload = async () => {
const raw = await pickImageFromDevice()
if (raw) {
onImage(await normalizeAvatarImage(raw))
}
}
const generate = async () => {
if (busy) {
return
}
setBusy(true)
try {
const who = [seedName, seedMembers?.length ? `a team of ${seedMembers.join(', ')}` : '']
.filter(Boolean)
.join(' — ')
const res = await host.request('image.generate', {
prompt:
`Group chat icon for an AI agent team called "${who || 'a bot team'}". ` +
'Friendly minimal emblem, bold flat vector style, solid color background, centered, no text.',
aspect_ratio: 'square'
})
if (!res?.success) {
throw new Error(res?.error || 'generation failed')
}
const img = res.image_data || res.image
if (img) {
onImage(await normalizeAvatarImage(img))
}
} catch (err) {
host.notifyError(err, 'Group picture generation failed')
} finally {
setBusy(false)
}
}
return jsxs('div', {
className: 'flex items-center gap-2',
children: [
jsx('div', {
className:
'flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--chrome-action-hover)',
children: image
? jsx('img', { src: image, alt: '', className: 'size-full object-cover' })
: jsx(Codicon, { name: 'organization', className: 'text-(--ui-text-tertiary)' })
}),
jsx(Button, { type: 'button', variant: 'secondary', size: 'sm', onClick: upload, children: 'Upload' }),
imagen
? jsx(Button, {
type: 'button',
variant: 'secondary',
size: 'sm',
disabled: busy,
onClick: generate,
children: busy ? 'Generating…' : 'Generate'
})
: null,
image
? jsx(Button, { type: 'button', variant: 'ghost', size: 'sm', onClick: () => onImage(null), children: 'Remove' })
: null
]
})
}
/** Edit an existing group chat's name and picture. Renames re-key the room
* and every local member's membership (renameGroupChat); the picture rides
* the room record. Both apply on Save so a cancelled dialog changes nothing. */
function GroupChatSettingsDialog({ group, members, open, onClose, onRenamed }) {
const rooms = useValue($groupChats)
const current = (rooms[group] || {}).image || null
const [name, setName] = useState(group)
const [image, setImage] = useState(current)
useEffect(() => {
if (open) {
setName(group)
setImage(current)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, group])
const save = async () => {
const finalName = await renameGroupChat(group, name, members)
if (finalName === null) {
return // collision — dialog stays open for a different name
}
if (image !== current) {
setGroupChatImage(finalName, image)
}
onClose()
if (finalName !== group) {
onRenamed?.(finalName)
}
}
return jsx(Dialog, {
open,
onOpenChange: value => {
if (!value) {
onClose()
}
},
children: jsxs(DialogContent, {
className: 'max-w-sm',
children: [
jsxs(DialogHeader, {
children: [
jsx(DialogTitle, { children: 'Group settings' }),
jsx(DialogDescription, {
children: 'Rename the group or set a room picture. Members and history are kept.'
})
]
}),
jsx(GroupImageControls, {
image,
onImage: setImage,
seedName: name.trim() || group,
seedMembers: (members || []).map(b => b.name)
}),
jsx('form', {
onSubmit: event => {
event.preventDefault()
void save()
},
children: jsx(Input, {
'aria-label': 'Group name',
autoFocus: true,
maxLength: 64,
value: name,
onChange: event => setName(event.target.value)
})
}),
jsxs(DialogFooter, {
children: [
jsx(Button, { variant: 'secondary', onClick: onClose, children: 'Cancel' }),
jsx(Button, { disabled: !name.trim(), onClick: () => void save(), children: 'Save' })
]
})
]
})
})
}
/** Discord-style group chat creation: pick 2+ bots via checkboxes (with
* search), name the group, create. Assignment appends to each local bot's
* group membership list, so the room appears in the roster and syncs
* cross-machine via ui_meta without replacing its other groups. */
function CreateGroupChatDialog({ open, roster, onClose, onCreated }) {
const allMeta = useValue($botMeta)
const [query, setQuery] = useState('')
const [checked, setChecked] = useState({})
const [name, setName] = useState('')
const [image, setImage] = useState(null)
// Reset per open so a cancelled draft doesn't leak into the next one.
useEffect(() => {
if (open) {
setQuery('')
setChecked({})
setName('')
setImage(null)
}
}, [open])
const selected = roster.filter(bot => checked[botRosterKey(bot)])
const visible = filterBots(roster, allMeta, query)
const atCap = selected.length >= GROUP_CHAT_MAX_MEMBERS
const placeholder = selected.length
? selected.map(bot => displayName(bot, botRosterMeta(bot, allMeta))).join(', ')
: 'Group name'
const canCreate = selected.length >= 2 && Boolean(name.trim() || selected.length)
const create = () => {
let groupName = (name.trim() || placeholder).slice(0, 64)
if (selected.length < 2 || !groupName) {
return
}
// Creating a group is always a FRESH room. Without this, re-creating a
// group under an existing name (easy — the default name is just the
// member names) silently reopens the old room with its full log, which
// reads as "not a fresh group" (db's Aug 2026 report). Uniquify against
// both live rooms and any bot's current grouping.
const taken = new Set(Object.keys($groupChats.get()))
for (const meta of Object.values($botMeta.get() || {})) {
for (const existing of botGroups(meta)) {
taken.add(existing)
}
}
if (taken.has(groupName)) {
let n = 2
while (taken.has(`${groupName} ${n}`)) {
n += 1
}
groupName = `${groupName} ${n}`.slice(0, 64)
}
for (const bot of selected) {
if (!bot.remoteSource) {
void saveBotMeta(bot.name, groupMembershipPatch(botRosterMeta(bot, allMeta), groupName, true))
}
}
// Persist every machine identity, including today's active source. That
// member becomes remote after a source switch and cannot rely on the new
// gateway's name-keyed bot metadata to remain seated in this room.
const roomMembers = durableGroupChatMembers(selected)
updateGroupChat(groupName, room => {
room.members = roomMembers
if (image) {
room.image = image
}
return room
})
host.notify({ kind: 'info', message: `“${groupName}” created with ${selected.length} bots` })
onClose()
onCreated?.(groupName)
}
return jsx(Dialog, {
open,
onOpenChange: value => {
if (!value) {
onClose()
}
},
children: jsxs(DialogContent, {
className: 'max-w-md',
children: [
jsxs(DialogHeader, {
children: [
jsx(DialogTitle, { children: 'New Group Chat' }),
jsx(DialogDescription, {
children: `Pick 2–${GROUP_CHAT_MAX_MEMBERS} bots. Local memberships sync through each Bot profile; cross-machine members stay scoped to this room.`
})
]
}),
jsx(SearchField, {
'aria-label': 'Search bots to add',
autoFocus: true,
containerClassName: 'w-full',
inputClassName: 'w-full',
placeholder: 'Search bots to add…',
value: query,
onChange: setQuery
}),
selected.length
? jsx('div', {
className: 'flex flex-wrap gap-1',
children: selected.map(bot =>
jsxs('button', {
type: 'button',
className:
'flex items-center gap-1 rounded-full bg-(--chrome-action-hover) py-0.5 pl-2 pr-1.5 text-[0.6875rem] text-(--ui-text-secondary) transition-colors hover:text-foreground',
title: 'Remove from selection',
onClick: () => setChecked(prev => ({ ...prev, [botRosterKey(bot)]: false })),
children: [displayName(bot, botRosterMeta(bot, allMeta)), jsx(Codicon, { name: 'close', className: 'text-[0.6rem]' })]
}, botRosterKey(bot))
)
})
: null,
jsx(ScrollArea, {
className: 'max-h-64 min-h-0',
children: jsx('div', {
className: 'grid gap-0.5 pr-2',
children: visible.length
? visible.map(bot => {
const meta = botRosterMeta(bot, allMeta)
const { shape, color, image } = botAppearance(bot.name, meta)
const isChecked = Boolean(checked[botRosterKey(bot)])
const disabled = !isChecked && atCap
const currentGroups = botGroups(meta)
return jsxs('label', {
className: cn(
'flex cursor-pointer items-center gap-2 rounded-md px-1.5 py-1 transition-colors hover:bg-(--chrome-action-hover)',
disabled && 'cursor-not-allowed opacity-50'
),
children: [
jsx(BotFace, {
shape,
color,
image: image && !isBackfilledFacePng(image) ? image : null,
size: 24,
name: bot.name
}),
jsxs('div', {
className: 'min-w-0 flex-1',
children: [
jsx('div', { className: 'truncate text-xs text-foreground', children: displayName(bot, meta) }),
jsx('div', {
className: 'truncate text-[0.625rem] text-(--ui-text-quaternary)',
children: [
currentGroups.length
? `@${botHandle(bot.name, bot)} · in ${currentGroups.map(group => `“${group}”`).join(', ')}`
: `@${botHandle(bot.name, bot)}`,
bot.remoteSource && bot.connectionLabel ? ` · ${bot.connectionLabel}` : ''
].join('')
})
]
}),
jsx(Checkbox, {
checked: isChecked,
disabled,
onCheckedChange: value => setChecked(prev => ({ ...prev, [botRosterKey(bot)]: Boolean(value) }))
})
]
}, botRosterKey(bot))
})
: jsx('div', {
className: 'px-1.5 py-3 text-center text-xs text-(--ui-text-tertiary)',
children: query.trim() ? `No bots match “${query.trim()}”` : 'No bots yet — create agents first.'
})
})
}),
jsxs('div', {
className: 'grid gap-2',
children: [
jsx(GroupImageControls, {
image,
onImage: setImage,
seedName: name.trim() || (selected.length ? placeholder : ''),
seedMembers: selected.map(bot => displayName(bot, botRosterMeta(bot, allMeta)))
}),
jsx('form', {
onSubmit: event => {
event.preventDefault()
create()
},
children: jsx(Input, {
'aria-label': 'Group name',
maxLength: 64,
placeholder,
value: name,
onChange: event => setName(event.target.value)
})
})
]
}),
jsxs(DialogFooter, {
children: [
jsx(Button, { variant: 'secondary', onClick: onClose, children: 'Cancel' }),
jsx(Button, {
disabled: !canCreate,
title: selected.length < 2 ? 'Pick at least 2 bots' : undefined,
onClick: create,
children: `Create Group${selected.length ? ` (${selected.length})` : ''}`
})
]
})
]
})
})
}
// ── threads: the Slack/Discord shape ─────────────────────────────────────────
// Every room entry belongs to a THREAD. Messaging the room composer starts a
// new thread with the whole group; replying inside a thread continues that
// work. Member turns are scoped to the thread that triggered them — deltas,
// watermarks, and responder resolution all key on the thread id.
function groupThreadOf(entry) {
return entry?.thread || 'legacy'
}
function mintGroupThreadId() {
return `t${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`
}
// Pre-thread logs (hydrated from storage) get synthetic thread ids: a user
// entry after a real lull starts one, so multi-turn tasks stay whole instead
// of splitting on every follow-up.
const GROUP_THREAD_GAP_MS = 15 * 60000
function assignLegacyThreads(log) {
let current = null
let n = 0
return (log || []).map((entry, i) => {
if (entry?.thread) {
current = null
return entry
}
const prev = log[i - 1]
const lull = !prev || (entry.at || 0) - (prev.at || 0) > GROUP_THREAD_GAP_MS
if (!current || (entry.from?.kind === 'user' && lull)) {
current = `legacy-${n++}`
}
return { ...entry, thread: current }
})
}
/** Merged room view for one group: shared timeline with per-member
* attribution, a composer that drives the round-robin, and a working
* indicator while member turns run. Renders identically in the MAIN chat
* window (host.openWorkspace tile) and in the bots panel (older-desktop
* fallback); `onBack` is where the Back button routes — the main tile's
* closer, or clearing the in-panel workspace atom. */
/** The active @-token at the caret: text from the nearest '@' (that begins a
* word) up to the caret, or null when the caret isn't inside a mention. */
function mentionTokenAt(text, caret) {
const upto = String(text || '').slice(0, caret)
const match = /(^|\s)@([a-z0-9._-]*)$/i.exec(upto)
if (!match) {
return null
}
return { query: match[2].toLowerCase(), start: caret - match[2].length - 1 }
}
/** Mention-aware composer input for group rooms. The core composer's
* @-completion area doesn't mount inside workspace tiles (#89049), so this
* wraps the plain SDK Input with a member-scoped popover: @everyone/@all
* quick picks plus each seated member's handle. Insertion produces exactly
* the strings parseGroupChatMentions resolves. Keyboard: Up/Down navigate,
* Enter/Tab insert (Enter falls through to submit when the popover is
* closed), Escape dismisses. */
function GroupMentionInput({ members, onChange, value, ...inputProps }) {
const allMeta = useValue($botMeta)
const inputRef = useRef(null)
const [token, setToken] = useState(null)
const [selected, setSelected] = useState(0)
const options = []
if (token) {
for (const pick of ['everyone', 'all']) {
if (pick.startsWith(token.query)) {
options.push({ handle: pick, meta: 'Every bot in the room' })
}
}
for (const member of members) {
const handle = String(member.handle || botHandle(member.name, member) || '').trim()
if (!handle || (token.query && !handle.toLowerCase().startsWith(token.query))) {
continue
}
options.push({
handle,
meta: displayName(member, botRosterMeta(member, allMeta))
})
}
}
const open = Boolean(token) && options.length > 0
const active = open ? Math.min(selected, options.length - 1) : 0
const refreshToken = target => {
setToken(mentionTokenAt(target.value, target.selectionStart ?? target.value.length))
setSelected(0)
}
const insert = handle => {
if (!token) {
return
}
const caret = inputRef.current?.selectionStart ?? value.length
const next = `${value.slice(0, token.start)}@${handle} ${value.slice(caret)}`
onChange(next)
setToken(null)
// Restore focus with the caret after the inserted mention.
const pos = token.start + handle.length + 2
requestAnimationFrame(() => {
const el = inputRef.current
if (el) {
el.focus()
try {
el.setSelectionRange(pos, pos)
} catch {
/* input type without selection support */
}
}
})
}
return jsxs('div', {
className: 'relative min-w-0 flex-1',
children: [
open
? jsx('div', {
className:
'absolute bottom-full left-0 z-50 mb-1 max-h-48 w-64 overflow-y-auto rounded-md border border-(--ui-stroke-secondary) bg-(--ui-bg-primary,#111) py-1 shadow-lg',
children: options.map((option, index) =>
jsxs('button', {
type: 'button',
className: cn(
'flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs',
index === active ? 'bg-(--ui-control-hover-background) text-foreground' : 'text-(--ui-text-secondary)'
),
// preventDefault on mousedown so the input keeps focus.
onMouseDown: event => {
event.preventDefault()
insert(option.handle)
},
onMouseEnter: () => setSelected(index),
children: [
jsx('span', { className: 'font-medium', children: `@${option.handle}` }),
jsx('span', { className: 'truncate text-[0.65rem] text-(--ui-text-quaternary)', children: option.meta })
]
}, option.handle)
)
})
: null,
jsx(Input, {
...inputProps,
ref: inputRef,
value,
onChange: event => {
onChange(event.target.value)
refreshToken(event.target)
},
onClick: event => refreshToken(event.target),
onKeyDown: event => {
if (!open) {
return
}
if (event.key === 'ArrowDown') {
event.preventDefault()
setSelected((active + 1) % options.length)
} else if (event.key === 'ArrowUp') {
event.preventDefault()
setSelected((active - 1 + options.length) % options.length)
} else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
insert(options[active].handle)
} else if (event.key === 'Escape') {
event.preventDefault()
setToken(null)
}
},
onBlur: () => setToken(null)
})
]
})
}
function GroupChatWorkspace({ group, members, onBack }) {
const rooms = useValue($groupChats)
const allMeta = useValue($botMeta)
const room = rooms[group] || { log: [], running: false }
const [draft, setDraft] = useState('')
const [confirmDisband, setConfirmDisband] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
// Click-to-disambiguate: which log entry is showing its speaker's full
// @handle (the roster's name-device form when names collide across
// connections). Naturally every speaker just shows its display name.
const [revealedSpeaker, setRevealedSpeaker] = useState(null)
// Threads, the Slack/Discord shape: entries carry a thread id. The most
// recently active thread renders open; older ones collapse to summary rows.
// `openThreads` is the user's explicit expand/collapse overrides, and
// `replyThread` is the thread whose reply box currently owns the composer
// (null = the main composer, which STARTS a new thread).
const [openThreads, setOpenThreads] = useState({})
const [replyThread, setReplyThread] = useState(null)
const [replyDrafts, setReplyDrafts] = useState({})
// Pending image attachments per composer: `null` thread key = the main
// composer, otherwise the reply box of that thread. Data URLs, already
// downscaled — they ride the send into every responding member's session.
const [pendingImages, setPendingImages] = useState({})
const imagesFor = thread => pendingImages[thread ?? 'main'] || []
const addImages = (thread, picked) => {
if (!picked.length) {
return
}
const key = thread ?? 'main'
setPendingImages(prev => ({ ...prev, [key]: [...(prev[key] || []), ...picked] }))
}
const clearImages = thread => {
const key = thread ?? 'main'
setPendingImages(prev => ({ ...prev, [key]: [] }))
}
const removeImage = (thread, index) => {
const key = thread ?? 'main'
setPendingImages(prev => ({ ...prev, [key]: (prev[key] || []).filter((_, i) => i !== index) }))
}
// Ctrl/⌘-V a screenshot (or any file) into any composer in this room.
const pasteImages = (thread, event) => {
const files = [...(event.clipboardData?.files || [])]
if (!files.length) {
return
}
event.preventDefault()
void filesToGroupAttachments(files).then(picked => addImages(thread, picked))
}
// Drag & drop anywhere on the room drops into the ACTIVE composer — the
// open reply box when one owns the composer, else the main (new-thread)
// composer. Matches the 1:1 chat's drop affordance.
const [dragOver, setDragOver] = useState(false)
const dropFiles = event => {
const files = [...(event.dataTransfer?.files || [])]
setDragOver(false)
if (!files.length) {
return
}
event.preventDefault()
void filesToGroupAttachments(files).then(picked => addImages(replyThread, picked))
}
// Collapsible Activity view: collapsed by default — opening it is always an
// explicit user action, it never steals focus, and it never auto-scrolls.
const [activityOpen, setActivityOpen] = useState(false)
// Subscribe: activity rows re-render as turn events land.
useValue($groupActivity)
const header = jsxs('div', {
className: 'flex items-center gap-2 px-2.5 pt-2.5 pb-2',
children: [
jsx(Button, {
variant: 'ghost',
size: 'sm',
onClick: () => (onBack ? onBack() : $groupChatWorkspace.set(null)),
children: 'Back'
}),
// Room picture (set via Group settings) leads the title when present.
room.image
? jsx('img', {
src: room.image,
alt: '',
className: 'size-6 shrink-0 rounded-full object-cover ring-1 ring-(--ui-stroke-secondary)'
})
: null,
jsx('div', {
className: 'min-w-0 flex-1 truncate text-sm font-semibold',
children: `${group} — group chat`
}),
// Member faces: the room's roster at a glance, matching each bot's
// avatar in the sidebar. Falls back to the count for the title tooltip.
jsx('div', {
className: 'flex shrink-0 items-center -space-x-1.5',
title: members.map(b => displayName(b, botRosterMeta(b, allMeta))).join(', '),
children: members.slice(0, 6).map(b => {
const bMeta = botRosterMeta(b, allMeta)
const { shape, color, image } = botAppearance(b.name, bMeta)
const photo = Boolean(image && !isBackfilledFacePng(image))
return jsx('div', {
className: 'rounded-full ring-2 ring-(--ui-bg-primary,#111)',
children: jsx(BotFace, { shape, color, image: photo ? image : null, size: 20, name: b.name })
}, botRosterKey(b))
})
}),
jsx('span', {
className: 'shrink-0 text-[0.65rem] text-(--ui-text-quaternary)',
children: `${members.length} bots`
}),
jsx(Button, {
variant: 'ghost',
size: 'sm',
className: 'shrink-0 text-(--ui-text-tertiary) hover:text-foreground',
title: `Group settings — rename ${group} or set a room picture`,
onClick: () => setSettingsOpen(true),
children: jsx(Codicon, { name: 'gear' })
}),
jsx(Button, {
variant: 'ghost',
size: 'sm',
className: 'shrink-0 text-(--ui-text-tertiary) hover:text-destructive',
title: `Disband the ${group} group chat`,
onClick: () => setConfirmDisband(true),
children: jsx(Codicon, { name: 'trash' })
})
]
})
const memberDescriptors = () =>
members.map(b => ({
...b,
title: (b.remoteSource ? '' : allMeta[b.name]?.title) || b.title || ''
}))
// Activity disclosure: quiet, collapsed by default. The collapsed row shows
// the latest event; expanding lists the current run's events newest-first.
// Events are epoch-tagged, so a superseded run's history drops out of view.
const activityEvents = currentGroupActivity(group)
const latestActivity = activityEvents.length ? activityEvents[activityEvents.length - 1] : null
const activityPanel = jsxs('div', {
className: 'border-b border-(--ui-stroke-secondary)',
children: [
jsxs('button', {
type: 'button',
'aria-expanded': activityOpen,
'aria-controls': `group-activity:${group}`,
title: activityOpen ? 'Hide room activity' : 'Show room activity',
className:
'flex w-full items-center gap-1.5 px-2.5 py-1 text-left text-[0.7rem] text-(--ui-text-quaternary) transition-colors hover:text-foreground',
onClick: () => setActivityOpen(prev => !prev),
children: [
jsx(Codicon, {
name: activityOpen ? 'chevron-down' : 'chevron-right',
className: 'shrink-0 text-[0.65rem]'
}),
jsx('span', { className: 'shrink-0 font-medium', children: 'Activity' }),
latestActivity
? jsx('span', {
className: 'min-w-0 flex-1 truncate',
children: `${groupActivityLabel(latestActivity)} · ${relativeTime(latestActivity.at)}`
})
: null
]
}),
activityOpen
? jsx('div', {
id: `group-activity:${group}`,
className: 'grid gap-0.5 px-2.5 pb-1.5',
children: activityEvents.length
? [...activityEvents]
.reverse()
.map((event, i) =>
jsxs('div', {
className: 'flex items-center gap-1.5 text-[0.7rem]',
children: [
jsx(Codicon, {
name: GROUP_ACTIVITY_GLYPHS[event.kind] || 'circle-outline',
className: cn('shrink-0 text-[0.65rem]', groupActivityTone(event.kind))
}),
jsx('span', {
className: cn('min-w-0 flex-1 truncate', groupActivityTone(event.kind)),
children: groupActivityLabel(event)
}),
jsx('span', {
className: 'shrink-0 text-[0.625rem] text-(--ui-text-quaternary)',
children: relativeTime(event.at)
})
]
}, `${event.at}:${i}`)
)
: jsx('div', {
className: 'px-0.5 pb-0.5 text-[0.625rem] text-(--ui-text-quaternary)',
children: 'No activity in this turn yet.'
})
})
: null
]
})
const submit = () => {
const text = draft.trim()
const images = imagesFor(null)
if (!text && !images.length) {
return
}
setDraft('')
clearImages(null)
// Main composer = START A NEW THREAD with the whole group (Slack shape).
// Full descriptors ride into the turn loop: remote members keep their
// connection fields so their turns route to their own machines.
const minted = sendToGroupChat(group, memberDescriptors(), text, null, images)
if (minted) {
setOpenThreads(prev => ({ ...prev, [minted]: true }))
}
}
const submitReply = thread => {
const text = (replyDrafts[thread] || '').trim()
const images = imagesFor(thread)
if (!text && !images.length) {
return
}
setReplyDrafts(prev => ({ ...prev, [thread]: '' }))
clearImages(thread)
// Reply box = CONTINUE this thread; the member turns it triggers are
// scoped to it.
sendToGroupChat(group, memberDescriptors(), text, thread, images)
setOpenThreads(prev => ({ ...prev, [thread]: true }))
}
/** Pending-attachment chips + the picker for one composer (thread = null →
* main). Image chips preview the pixels; PDFs/files show a type icon.
* X removes it. */
const attachmentRow = thread => {
const images = imagesFor(thread)
if (!images.length) {
return null
}
return jsx('div', {
className: 'flex flex-wrap items-center gap-1.5 px-1 pb-1',
children: images.map((img, index) =>
jsxs('div', {
className:
'flex items-center gap-1 rounded-md border border-(--ui-stroke-secondary) bg-(--ui-bg-secondary,#181818) px-1 py-0.5',
children: [
img.kind === 'pdf' || img.kind === 'file'
? jsx(Codicon, {
name: img.kind === 'pdf' ? 'file-pdf' : 'file',
className: 'text-[0.9rem] text-(--ui-text-tertiary)'
})
: jsx('img', { src: img.data, alt: '', className: 'size-6 rounded object-cover' }),
jsx('span', {
className: 'max-w-32 truncate text-[0.65rem] text-(--ui-text-tertiary)',
children: img.name || 'image'
}),
jsx('button', {
type: 'button',
className: 'cursor-pointer border-0 bg-transparent p-0 text-(--ui-text-quaternary) hover:text-foreground',
title: 'Remove attachment',
onClick: () => removeImage(thread, index),
children: jsx(Codicon, { name: 'close', className: 'text-[0.65rem]' })
})
]
}, `${img.name || 'img'}:${index}`)
)
})
}
const attachButton = thread =>
jsx(Button, {
type: 'button',
variant: 'ghost',
size: 'sm',
className: 'shrink-0 text-(--ui-text-tertiary) hover:text-foreground',
title: 'Attach files — every responding bot sees them',
onClick: () => void pickGroupAttachments().then(picked => addImages(thread, picked)),
children: jsx(Codicon, { name: 'attach' })
})
// One log entry, rendered exactly as before conversation folding existed.
const renderEntry = (entry, index) => {
const isUser = entry.from.kind === 'user'
const meta = isUser || entry.from.source ? null : allMeta[entry.from.name]
// Match this speaker back to its member descriptor so display
// names and disambiguating handles come from the roster (the
// primary "default" profile renders as Hermes, remote dupes
// carry their @name-device handle) instead of raw profile ids.
const member = isUser
? null
: members.find(b =>
b.name === entry.from.name &&
(entry.from.source
? (b.connectionLabel || b.connectionId) === entry.from.source
: !b.remoteSource)
) || null
const display = isUser ? 'You' : displayName(member || { name: entry.from.name }, meta)
const entryKey = `${entry.at}:${index}`
const revealed = !isUser && revealedSpeaker === entryKey
// Clicked: append the gateway name so same-named agents on
// two connections are tellable apart on demand.
const label = isUser
? 'You'
: revealed
? `${display}${entry.from.source ? `-${entry.from.source}` : ''} (@${botHandle(entry.from.name, member || undefined)})`
: display
// Speaker avatar: same appearance pipeline as the roster
// (custom image/pet, else deterministic shape+color face).
// Remote speakers have no local meta and get the
// deterministic face for their name — stable per bot.
const { shape, color, image } = isUser
? { shape: null, color: null, image: null }
: botAppearance(entry.from.name, meta)
const photo = Boolean(image && !isBackfilledFacePng(image))
return jsxs('div', {
className: cn(
'group flex items-start gap-2',
isUser ? 'rounded-md bg-(--chrome-action-hover) px-2 py-1.5' : 'px-2 py-1'
),
children: [
isUser
? null
: jsx('div', {
className: 'mt-0.5 shrink-0',
children: jsx(BotFace, {
shape,
color,
image: photo ? image : null,
size: 24,
name: entry.from.name
})
}),
jsxs('div', {
className: 'min-w-0 flex-1',
children: [
jsxs('div', {
className: 'flex items-center gap-2',
children: [
isUser
? jsx('span', {
className: 'text-[0.7rem] font-semibold text-foreground',
children: label
})
: jsx('button', {
type: 'button',
className:
'cursor-pointer border-0 bg-transparent p-0 text-left text-[0.7rem] font-semibold text-(--ui-accent,#4f9cf9)',
title: revealed ? 'Hide full handle' : 'Show full handle',
onClick: () => setRevealedSpeaker(revealed ? null : entryKey),
children: label
}),
jsx('span', {
className: 'text-[0.625rem] text-(--ui-text-quaternary)',
children: relativeTime(entry.at)
}),
entry.text.trim()
? jsx('div', {
className:
'ml-auto shrink-0 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
children: jsx(CopyButton, {
appearance: 'icon',
buttonSize: 'icon',
stopPropagation: true,
text: entry.text
})
})
: null
]
}),
jsx('div', {
className:
'text-xs text-(--ui-text-secondary) [&_p]:mb-1 [&_p:last-child]:mb-0 [&_ul]:mb-1 [&_ul]:list-disc [&_ul]:pl-4 [&_ol]:mb-1 [&_ol]:list-decimal [&_ol]:pl-4 [&_pre]:overflow-x-auto',
// The app shell sets user-select: none globally; message bodies opt
// back in so drag-select and ⌘C work in group chat logs.
'data-selectable-text': 'true',
children: Streamdown ? jsx(Streamdown, { children: entry.text }) : entry.text
}),
// User attachments: what every responding bot was
// shown — image previews, or a named chip for
// PDFs/files.
Array.isArray(entry.images) && entry.images.length
? jsx('div', {
className: 'mt-1 flex flex-wrap items-center gap-1.5',
children: entry.images.map((img, imgIndex) =>
img.kind === 'pdf' || img.kind === 'file'
? jsxs('div', {
className:
'flex items-center gap-1 rounded-md border border-(--ui-stroke-secondary) px-1.5 py-1 text-[0.65rem] text-(--ui-text-tertiary)',
title: img.name || 'attached file',
children: [
jsx(Codicon, { name: img.kind === 'pdf' ? 'file-pdf' : 'file', className: 'text-[0.8rem]' }),
jsx('span', { className: 'max-w-48 truncate', children: img.name || 'attached file' })
]
}, `${entryKey}:img:${imgIndex}`)
: jsx('img', {
src: img.data,
alt: img.name || 'attached image',
title: img.name || 'attached image',
className:
'max-h-40 max-w-60 rounded-md border border-(--ui-stroke-secondary) object-contain'
}, `${entryKey}:img:${imgIndex}`)
)
})
: null
]
})
]
}, entryKey)
}
// Threads: group entries by thread id (hydration assigned legacy ids, but
// guard live pre-thread entries too), ordered by last activity — oldest
// first, so the busiest/newest thread sits at the bottom by the composer.
// The most recently ACTIVE thread renders open; older ones collapse to a
// Slack-style summary row unless explicitly opened. Every open thread gets
// its own reply box, which continues THAT thread.
const threadsById = new Map()
for (let i = 0; i < room.log.length; i++) {
const entry = room.log[i]
const id = groupThreadOf(entry)
let bucket = threadsById.get(id)
if (!bucket) {
bucket = { entries: [], id, startIndex: i }
threadsById.set(id, bucket)
}
bucket.entries.push({ entry, index: i })
}
const threads = [...threadsById.values()].sort(
(a, b) => (a.entries[a.entries.length - 1].entry.at || 0) - (b.entries[b.entries.length - 1].entry.at || 0)
)
const newestThread = threads.length ? threads[threads.length - 1].id : null
const logChildren = []
threads.forEach(threadBucket => {
const { entries, id } = threadBucket
const head = entries.find(({ entry }) => entry.from.kind === 'user')?.entry || entries[0].entry
const isNewest = id === newestThread
const expanded = openThreads[id] ?? isNewest
if (!expanded) {
const replies = entries.length - 1
const headText = stripPreviewMarkdown(head?.text || '').slice(0, 80)
logChildren.push(
jsxs('button', {
type: 'button',
className:
'flex w-full items-center gap-2 rounded-md border border-(--ui-stroke-secondary) px-2 py-1.5 text-left text-xs text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover)',
title: 'Open this thread',
onClick: () => setOpenThreads(prev => ({ ...prev, [id]: true })),
children: [
jsx(Codicon, { name: 'chevron-right', className: 'shrink-0 text-[0.65rem]' }),
jsx('span', { className: 'min-w-0 flex-1 truncate', children: headText || 'Thread' }),
jsx('span', {
className: 'shrink-0 text-[0.625rem] text-(--ui-text-quaternary)',
children: `${replies} ${replies === 1 ? 'reply' : 'replies'} · ${relativeTime(entries[entries.length - 1].entry.at)}`
})
]
}, `fold:${id}`)
)
return
}
// Open thread: a rail-indented block — collapse affordance, its entries,
// and its own reply box (Slack's "reply in thread").
const threadRows = []
if (!isNewest || openThreads[id] !== undefined) {
threadRows.push(
jsxs('button', {
type: 'button',
className:
'flex w-full items-center gap-1.5 px-2 pt-1 text-left text-[0.65rem] text-(--ui-text-quaternary) transition-colors hover:text-foreground',
title: 'Collapse this thread',
onClick: () => setOpenThreads(prev => ({ ...prev, [id]: false })),
children: [jsx(Codicon, { name: 'chevron-down', className: 'text-[0.6rem]' }), 'Collapse thread']
}, `unfold:${id}`)
)
}
for (const { entry, index } of entries) {
threadRows.push(renderEntry(entry, index))
}
// Reply-in-thread: the newest thread's continuation ALSO lives here, so
// the main composer below can stay "new thread" without ambiguity.
threadRows.push(
replyThread === id
? jsxs('form', {
className: 'grid gap-0 px-2 pb-1',
onSubmit: event => {
event.preventDefault()
submitReply(id)
},
children: [
attachmentRow(id),
jsxs('div', {
className: 'flex items-center gap-1.5',
children: [
jsx(GroupMentionInput, {
'aria-label': 'Reply in thread',
autoFocus: true,
placeholder: 'Reply in thread…',
members,
value: replyDrafts[id] || '',
onChange: text => setReplyDrafts(prev => ({ ...prev, [id]: text })),
onPaste: event => pasteImages(id, event)
}),
attachButton(id),
jsx(Button, {
type: 'submit',
size: 'sm',
disabled: !(replyDrafts[id] || '').trim() && !imagesFor(id).length,
children: 'Reply'
})
]
})
]
}, `replybox:${id}`)
: jsx('button', {
type: 'button',
className:
'w-fit px-2 pb-1 text-left text-[0.65rem] text-(--ui-accent,#4f9cf9) transition-colors hover:underline',
onClick: () => setReplyThread(id),
children: 'Reply in thread'
}, `replylink:${id}`)
)
logChildren.push(
jsx('div', {
className: 'grid gap-1.5 border-l-2 border-(--ui-stroke-secondary) pl-1.5',
children: threadRows
}, `thread:${id}`)
)
})
return jsxs('div', {
className: 'relative flex h-full flex-col',
onDragOver: event => {
if ([...(event.dataTransfer?.types || [])].includes('Files')) {
event.preventDefault()
setDragOver(true)
}
},
onDragLeave: event => {
// Only clear when leaving the room container itself, not when the
// cursor moves between its children.
if (!event.currentTarget.contains(event.relatedTarget)) {
setDragOver(false)
}
},
onDrop: dropFiles,
children: [
dragOver
? jsx('div', {
className:
'pointer-events-none absolute inset-0 z-40 flex items-center justify-center border-2 border-dashed border-(--ui-accent,#4f9cf9) text-sm font-medium text-(--ui-accent,#4f9cf9)',
children: replyThread ? 'Drop to attach to this thread reply' : 'Drop to attach — every responding bot sees it'
}, 'dropzone')
: null,
header,
activityPanel,
jsx(ScrollArea, {
className: 'min-h-0 flex-1',
children: jsxs('div', {
className: 'grid gap-1.5 px-2.5 pb-2',
children: [
...(room.log.length
? logChildren
: [
jsx('div', {
className: 'px-2 py-4 text-center text-xs text-(--ui-text-tertiary)',
children: 'Say something — every bot in this group hears the room.'
}, 'empty')
]),
room.running
? jsx('div', {
className: 'px-2 py-1 text-[0.7rem] italic text-(--ui-text-quaternary)',
children: room.turn
? `${groupSpeakerLabel(room.turn)} is thinking…`
: 'The room is working…'
}, 'working')
: null
]
})
}),
jsx('div', {
className: 'border-t border-(--ui-stroke-secondary) p-2',
children: jsxs('form', {
className: 'grid gap-0',
onSubmit: event => {
event.preventDefault()
submit()
},
children: [
attachmentRow(null),
jsxs('div', {
className: 'flex items-center gap-1.5',
children: [
jsx(GroupMentionInput, {
'aria-label': `Message ${group}`,
placeholder: `New thread in ${group}… (@name to direct, @everyone for all)`,
members,
value: draft,
onChange: setDraft,
onPaste: event => pasteImages(null, event)
}),
attachButton(null),
jsx(Button, {
type: 'submit',
size: 'sm',
disabled: !draft.trim() && !imagesFor(null).length,
children: 'New Thread'
})
]
})
]
})
}),
jsx(GroupChatSettingsDialog, {
group,
members,
open: settingsOpen,
onClose: () => setSettingsOpen(false)
}),
jsx(ConfirmDialog, {
open: confirmDisband,
title: 'Disband group chat?',
description: jsxs('span', {
children: [
'This removes the ',
jsx('span', { className: 'font-medium text-foreground', children: group }),
' grouping from its ',
String(members.length),
' bots and clears the shared room log. The bots themselves and their “Group: ',
group,
'” sessions are kept — you can still open those from each bot’s session browser.'
]
}),
destructive: true,
confirmLabel: 'Disband',
busyLabel: 'Disbanding…',
doneLabel: 'Disbanded',
onClose: () => setConfirmDisband(false),
onConfirm: async () => {
await disbandGroupChat(group, members)
host.notify({ kind: 'success', message: `Disbanded “${group}”` })
}
})
]
})
}
/** Live closers for group-chat MAIN-window tabs, by group name — so a
* disband (or the room view's own Back) can retire the tab it opened. */
const groupChatMainTabs = new Map()
function closeGroupChatMainTab(group) {
const close = groupChatMainTabs.get(group)
groupChatMainTabs.delete(group)
if ($groupChatWorkspace.get() === group) {
$groupChatWorkspace.set(null)
}
if (typeof close === 'function') {
try {
close()
} catch {
/* tab already gone */
}
}
}
/** Main-window wrapper: seats the member roster reactively (live roster +
* bot meta + the room's stored cross-connection descriptors) so the room
* keeps working as members change while the tab is open. */
function GroupChatMainView({ group }) {
const allMeta = useValue($botMeta)
// Subscribe: membership changes ride bot meta AND the room record.
useValue($groupChats)
const roster = useValue($lastRoster)
const members = groupChatMemberBots(group, roster, allMeta)
return jsx(GroupChatWorkspace, { group, members, onBack: () => closeGroupChatMainTab(group) })
}
/** Open a group chat the Discord way: a tab taking over the MAIN chat window
* (host.openWorkspace, newer desktops), falling back to the in-panel room
* view on desktops whose SDK predates the main-area door. */
function openGroupChat(group) {
$groupNeedsYou.set({ ...$groupNeedsYou.get(), [group]: false })
$groupChatWorkspace.set(group)
if (typeof host.openWorkspace === 'function') {
try {
const close = host.openWorkspace(`${ID}:group:${slugify(group)}`, {
title: group,
minWidth: '24rem',
render: () => jsx(GroupChatMainView, { group }),
onClose: () => {
groupChatMainTabs.delete(group)
if ($groupChatWorkspace.get() === group) {
$groupChatWorkspace.set(null)
}
}
})
groupChatMainTabs.set(group, close)
return
} catch {
// Fall through to the in-panel room below.
}
}
// The selected-group atom was set before trying the main-window door, so
// older desktops naturally render the in-panel room as the fallback.
}
/** One group chat as ONE roster row — the Discord shape: stacked member
* avatars, group name, member count, the newest room line as the preview
* (markdown flattened), relative time of the last activity, and the
* needs-you badge on the row itself. Sorts into the same recency ordering
* as bot rows; clicking opens the room in the main chat window. */
function GroupRow({ active, group, members, needsYou, onOpen }) {
const rooms = useValue($groupChats)
const allMeta = useValue($botMeta)
const room = rooms[group] || { log: [] }
const log = Array.isArray(room.log) ? room.log : []
const last = log.length ? log[log.length - 1] : null
const lastAt = groupLastActivity(room)
// Room previews speak the same handle vocabulary as the roster, mentions
// and the group prompt: the primary profile is @hermes, not @default.
const lastFrom = last?.from?.name || ''
const lastHandle = botHandle(lastFrom || 'bot', members.find(member => member?.name === lastFrom))
const preview = last
? `${last.from?.kind === 'user' ? 'You' : `@${lastHandle}`}: ${stripPreviewMarkdown(last.text) || '…'}`
: 'No messages yet — say hi to the room'
const faces = members.slice(0, 3)
return jsxs('button', {
type: 'button',
onClick: () => {
haptic('tap')
onOpen(group)
},
className: cn(
'flex w-full min-w-0 max-w-full items-center gap-2.5 overflow-hidden rounded-md px-2 py-2 text-left transition-colors',
'hover:bg-(--chrome-action-hover)',
active && 'bg-(--ui-row-active-background)'
),
children: [
// Room picture when the user set one; else a composite avatar of up to
// three member faces fanned like Discord's group-DM icon; a bare glyph
// when the room has no seated members.
jsx('div', {
className: 'flex w-[34px] shrink-0 items-center justify-center',
children: room.image
? jsx('img', {
src: room.image,
alt: '',
className: 'size-7 rounded-full object-cover ring-2 ring-(--ui-bg-primary,#111)'
})
: faces.length
? jsx('div', {
className: 'flex items-center -space-x-2.5',
children: faces.map(member => {
const meta = member.remoteSource ? null : allMeta[member.name]
const { shape, color, image } = botAppearance(member.name, meta)
return jsx(
'div',
{
className: 'rounded-full ring-2 ring-(--ui-bg-primary,#111)',
children: jsx(BotFace, {
shape,
color,
image: image && !isBackfilledFacePng(image) ? image : null,
size: 20,
name: member.name,
mood: 'idle'
})
},
botRosterKey(member)
)
})
})
: jsx(Codicon, { name: 'organization', className: 'text-(--ui-text-tertiary)' })
}),
jsxs('div', {
className: 'min-w-0 flex-1',
children: [
jsxs('div', {
className: 'flex items-baseline justify-between gap-2',
children: [
jsxs('div', {
className: 'flex min-w-0 items-baseline gap-1.5 truncate',
children: [
jsx('span', { className: 'truncate text-[0.8125rem] font-medium', children: group }),
jsx('span', {
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
children: `${members.length} bots`
})
]
}),
needsYou
? jsx('span', {
className:
'shrink-0 rounded-full bg-(--ui-accent,#4f9cf9) px-1.5 text-[0.6rem] font-semibold text-white',
title: 'A bot in this room needs your input',
children: 'needs you'
})
: null,
lastAt
? jsx('span', {
className: 'shrink-0 text-[0.6875rem] text-(--ui-text-quaternary)',
children: relativeTime(lastAt)
})
: null
]
}),
jsx('div', {
className: 'min-w-0 truncate text-xs text-(--ui-text-tertiary)',
children: preview
})
]
})
]
})
}
function BotsPane() {
const { data, error, isLoading, refetch } = useRoster()
const gatewayState = useValue(host.state.gateway)
const gatewayUp = gatewayState === 'open'
const activeProfile = (useValue(host.state.profile) || 'default').trim() || 'default'
const [createOpen, setCreateOpen] = useState(false)
const [groupCreateOpen, setGroupCreateOpen] = useState(false)
const [editing, setEditing] = useState(null)
const [deleting, setDeleting] = useState(null)
const [grouping, setGrouping] = useState(null)
const [query, setQuery] = useState('')
const activityToasts = useValue($activityToasts)
const sessionsWorkspaceName = useValue($botSessionsWorkspace)
const groupChatName = useValue($groupChatWorkspace)
const groupNeedsYou = useValue($groupNeedsYou)
const groupRooms = useValue($groupChats)
// The socket opening (boot, SSH reconnect, sleep/wake) is the signal to
// retry immediately instead of waiting out the poll interval.
useEffect(() => {
if (gatewayUp) {
void refetch()
}
}, [gatewayUp, refetch])
const allMeta = useValue($botMeta)
// Messaging-app order: most recent activity first, where "activity" is
// the newest of (bot created, last message in any of its sessions). A
// freshly created bot tops the list until another bot gets a message.
// No special slot for the primary bot — it competes on recency too.
const activityOf = bot => {
const created = botRosterMeta(bot, allMeta)?.created || bot.ui_meta?.['hermes-bots']?.created || 0
const lastMsg = (botActivitySession(bot)?.last_active || 0) * 1000
return Math.max(created, lastMsg)
}
// Pinned bots (right-click → Pin) float to the top as a group; within the
// pinned group and within the unpinned group, recency still rules. A
// plain boolean flag in bot-meta (rides ui_meta to every machine).
const isPinned = bot => Boolean(botRosterMeta(bot, allMeta)?.pinned)
// Resilience (@wesleysimplicio, #13): a failed refresh must not erase a
// roster the user already had — mixed local+cloud gateways and remotes
// waking from sleep fail transiently. Render the last good snapshot with
// a notice; the full error card is reserved for "never had a roster".
const live = Array.isArray(data?.profiles) ? data.profiles : null
const source = live ?? (error ? $lastRoster.get() : [])
const roster = source.slice().sort((a, b) => {
const pa = isPinned(a) ? 1 : 0
const pb = isPinned(b) ? 1 : 0
if (pa !== pb) {
return pb - pa
}
return activityOf(b) - activityOf(a)
})
const activeSourceRoster = roster.filter(bot => !bot.remoteSource)
// Hidden bots (right-click → Hide Bot) drop out of the roster list unless
// the header eye toggle reveals them. Display-only: every other consumer
// (mentions, group chats, name-collision checks, merge/avatar/activity
// sweeps) keeps the FULL roster.
const showHidden = useValue($showHiddenBots)
const unreadByName = useValue($botUnread)
const hiddenBots = roster.filter(bot => isBotHidden(bot, allMeta))
const hiddenUnread = hiddenBots.some(bot => !bot.remoteSource && unreadByName[bot.name])
const visibleRoster = showHidden ? roster : roster.filter(bot => !isBotHidden(bot, allMeta))
const filteredRoster = filterBots(visibleRoster, allMeta, query)
// Group chats are first-class roster rows (Discord-style): one standalone
// row per room, competing in the SAME recency ordering as bot rows — a
// group's activity is its newest room-log line. Pinned bots still lead;
// groups and unpinned bots interleave by recency below them.
const needle = query.trim().toLowerCase()
const groupRows = groupChatNames(allMeta, groupRooms)
.filter(name => !needle || name.toLowerCase().includes(needle))
.map(name => ({
kind: 'group',
name,
members: groupChatMemberBots(name, roster, allMeta),
activity: groupLastActivity(groupRooms[name])
}))
const rosterRows = [
...filteredRoster.map(bot => ({ kind: 'bot', bot, pinned: isPinned(bot), activity: activityOf(bot) })),
...groupRows
].sort((a, b) => {
const pa = a.pinned ? 1 : 0
const pb = b.pinned ? 1 : 0
if (pa !== pb) {
return pb - pa
}
return b.activity - a.activity
})
if (live) {
$lastRoster.set(roster)
mergeServerMeta(activeSourceRoster)
pullServerAvatars(activeSourceRoster)
trackInboundActivity(activeSourceRoster)
backfillMessagingProtocol(activeSourceRoster)
}
const staleNotice = error && !live && roster.length
? 'Roster refresh failed — showing the last good list.' + (gatewayUp ? '' : ' Waiting for the gateway to reconnect…')
: null
const sessionsWorkspaceBot = roster.find(bot => bot.name === sessionsWorkspaceName)
if (sessionsWorkspaceBot) {
return jsx(ProfileSessionsWorkspace, { bot: sessionsWorkspaceBot })
}
const groupChatMembers = groupChatName ? groupChatMemberBots(groupChatName, roster, allMeta) : []
if (groupChatName && groupChatMembers.length) {
return jsx(GroupChatWorkspace, { group: groupChatName, members: groupChatMembers })
}
return jsxs('div', {
className: 'flex h-full flex-col',
children: [
jsxs('div', {
className: 'flex items-center justify-between gap-2 px-2.5 pt-2.5 pb-1.5',
children: [
jsx('span', {
className: 'text-[0.6875rem] font-semibold uppercase tracking-wider text-(--ui-text-quaternary)',
children: 'Bots'
}),
jsxs('div', {
className: 'flex items-center gap-0.5',
children: [
jsx(Tip, {
label: activityToasts ? 'Activity toasts on — click to silence' : 'Activity toasts off — click to enable',
children: jsx('button', {
type: 'button',
className:
'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
onClick: () => setActivityToasts(!activityToasts),
children: jsx(Codicon, { name: activityToasts ? 'bell' : 'bell-slash' })
})
}),
// Eye toggle appears only once something is hidden — zero
// hidden bots means zero extra chrome. It stays visible while
// hidden rows are revealed, so Unhide is always reachable.
hiddenBots.length
? jsx(Tip, {
label: showHidden
? 'Hide hidden bots again'
: `Show ${hiddenBots.length} hidden bot${hiddenBots.length === 1 ? '' : 's'}`,
children: jsxs('button', {
type: 'button',
'aria-label': showHidden ? 'Hide hidden bots' : 'Show hidden bots',
className: cn(
'relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
showHidden ? 'text-foreground' : 'text-(--ui-text-tertiary)'
),
onClick: () => $showHiddenBots.set(!showHidden),
children: [
jsx(Codicon, { name: showHidden ? 'eye' : 'eye-closed' }),
hiddenUnread && !showHidden
? jsx('span', {
className:
'absolute right-0.5 top-0.5 size-1.5 rounded-full bg-(--ui-accent,#4f9cf9)',
'aria-label': 'a hidden bot has unread activity'
})
: null
]
})
})
: null,
jsxs(DropdownMenu, {
children: [
jsx(Tip, {
label: 'New…',
children: jsx(DropdownMenuTrigger, {
asChild: true,
children: jsx('button', {
type: 'button',
'aria-label': 'New agent or group chat',
className:
'flex size-6 items-center justify-center rounded-md text-(--ui-text-tertiary) transition-colors hover:bg-(--chrome-action-hover) hover:text-foreground',
children: jsx(Codicon, { name: 'add' })
})
})
}),
jsxs(DropdownMenuContent, {
align: 'end',
children: [
jsxs(DropdownMenuItem, {
onSelect: () => setCreateOpen(true),
children: [jsx(Codicon, { name: 'hubot', className: 'mr-1.5' }), 'New Agent']
}),
jsxs(DropdownMenuItem, {
disabled: activeSourceRoster.length < 2,
onSelect: () => setGroupCreateOpen(true),
children: [jsx(Codicon, { name: 'organization', className: 'mr-1.5' }), 'New Group Chat']
})
]
})
]
})
]
})
]
}),
jsx(ActiveNowStrip, {
roster: visibleRoster,
activeProfile,
gatewayState,
metaByName: allMeta,
onOpen: bot => {
const generation = ++botOpenGeneration
haptic('tap')
$selectedBot.set(bot.name)
if (bot.remoteSource) {
const handle = botHandle(bot.name, bot)
host.notify?.({
kind: 'info',
title: displayName(bot),
message: `Stay in this chat and @${handle} to message them. Gateway stays on this device.`
})
return
}
if ($botUnread.get()[bot.name]) {
const next = { ...$botUnread.get() }
delete next[bot.name]
$botUnread.set(next)
}
void (async () => {
let pinnedChat = botRosterMeta(bot, allMeta)?.chat
try {
pinnedChat = await prepareBotSource(bot, pinnedChat)
} catch (error) {
host.notifyError?.(error, `Could not reach ${bot.connectionLabel || 'the remote source'}`)
return
}
if (generation !== botOpenGeneration) {
return
}
try {
const id = await openBotCanonicalChat(
bot.name,
pinnedChat,
bot.preferred_session || bot.last_session
)
if (generation === botOpenGeneration && id) {
return
}
} catch (error) {
if (generation === botOpenGeneration) {
host.notifyError?.(error, `Could not open ${displayName(bot)}'s chat — try again`)
}
return
}
if (generation !== botOpenGeneration) {
return
}
if (typeof host.newChat === 'function') {
host.newChat(bot.name)
} else {
host.navigate('/')
}
})()
}
}),
roster.length
? jsx('div', {
className: 'px-2.5 pb-1.5',
children: jsx(SearchField, {
'aria-label': 'Search bots',
containerClassName: 'w-full',
inputClassName: 'w-full',
placeholder: 'Search bots…',
value: query,
onChange: setQuery
})
})
: null,
staleNotice
? jsx('div', {
className: 'mx-2.5 mb-1 rounded-md bg-(--chrome-action-hover) px-2 py-1.5 text-[0.6875rem] text-(--ui-text-tertiary)',
children: staleNotice
})
: null,
isLoading && !roster.length
? jsx('div', {
className: 'flex flex-1 items-center justify-center',
children: jsx(GlyphSpinner, { spinner: 'breathe', className: 'text-(--ui-text-tertiary)' })
})
: error && !roster.length
? jsxs('div', {
className: 'grid gap-2 px-3 py-4 text-xs text-(--ui-text-tertiary)',
children: [
jsx('div', {
children: gatewayUp
? `Roster unavailable: ${error instanceof Error ? error.message : 'gateway error'}. If your gateway predates profiles.list, update Hermes and restart the gateway.`
: 'Waiting for the gateway connection… (remote gateways can take a few seconds; retries automatically)'
}),
jsx(Button, {
variant: 'secondary',
size: 'sm',
className: 'justify-self-start',
onClick: () => void refetch(),
children: 'Retry now'
})
]
})
: roster.length === 0
? jsx(EmptyState, {
icon: 'hubot',
title: 'No agents yet',
description: 'Create your first teammate.'
})
: filteredRoster.length === 0 && rosterRows.length === 0
? jsx('div', {
'aria-live': 'polite',
className:
'flex flex-1 items-center justify-center px-4 text-center text-xs text-(--ui-text-tertiary)',
role: 'status',
children: query.trim()
? `No bots match “${query.trim()}”`
: 'All bots are hidden — use the eye button above to show them.'
})
: jsx(ScrollArea, {
className: 'hermes-bots-roster min-h-0 flex-1',
children: jsx('div', {
className: 'grid w-full min-w-0 gap-0.5 px-1.5 pb-2',
// Flat, Discord-style list: bot rows and group rows
// interleaved by recency — no section headers.
children: rosterRows.map(row =>
row.kind === 'group'
? jsx(
GroupRow,
{
active: groupChatName === row.name,
group: row.name,
members: row.members,
needsYou: Boolean(groupNeedsYou[row.name]),
onOpen: openGroupChat
},
`group:${row.name}`
)
: jsx(
BotRow,
{ bot: row.bot, onDelete: setDeleting, onEdit: setEditing, onGroup: setGrouping },
botRosterKey(row.bot)
)
)
})
}),
jsx('div', {
className: 'border-t border-(--ui-stroke-secondary) p-2',
children: jsxs(Button, {
className: 'w-full justify-center gap-1.5',
variant: 'secondary',
onClick: () => setCreateOpen(true),
children: [jsx(Codicon, { name: 'add' }), 'New Agent']
})
}),
jsx(CreateAgentDialog, {
open: createOpen,
onClose: () => {
setCreateOpen(false)
void refetch()
},
roster: activeSourceRoster
}),
jsx(CreateGroupChatDialog, {
open: groupCreateOpen,
// Full multi-source roster: group chats can seat bots from other
// registered connections — their turns route to their own machines.
roster,
onClose: () => setGroupCreateOpen(false),
onCreated: groupName => openGroupChat(groupName)
}),
jsx(EditProfileDialog, {
bot: editing,
open: Boolean(editing),
onClose: () => {
setEditing(null)
void refetch()
}
}),
grouping ? jsx(GroupDialog, { bot: grouping, onClose: () => setGrouping(null) }) : null,
jsx(ConfirmDialog, {
open: Boolean(deleting),
title: 'Delete bot and profile?',
description: deleting
? jsxs('span', {
children: [
'This will permanently delete the bot ',
jsx('span', { className: 'font-medium text-foreground', children: deleting.name }),
' and its associated Hermes profile at ',
jsx('span', { className: 'font-mono text-xs', children: deleting.path }),
'. This cannot be undone.'
]
})
: null,
destructive: true,
confirmLabel: 'Delete',
busyLabel: 'Deleting…',
doneLabel: 'Deleted',
onClose: () => setDeleting(null),
onConfirm: async () => {
if (!deleting) {
return
}
const name = deleting.name
await deleteBot(deleting)
await refetch()
host.notify({ kind: 'success', message: `Deleted profile ${name}` })
}
})
]
})
}
// ── plugin ───────────────────────────────────────────────────────────────────
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) {
pluginCtx = ctx
startFaceClock()
// 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(stopFaceClock)
}
// @-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 => {
const roster = queryClient.getQueryData(ROSTER_KEY)
const profiles = Array.isArray(roster?.profiles) ? roster.profiles : []
if (!profiles.length) {
return []
}
const active = (host.state.profile.get() || 'default').trim() || 'default'
const q = (query || '').toLowerCase()
const items = []
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)
if (q && !handle.toLowerCase().startsWith(q)) {
continue
}
const display = displayName(profile, $botMeta.get()[profile.name])
const source = profile.connectionLabel ? ` · ${profile.connectionLabel}` : ''
items.push({
insert: `@${handle}`,
display: `@${handle}`,
meta: `Bot · ${display}${source}`
})
}
return items.slice(0, 8)
}
}
})
// Keyframes for the pet bob — injected because plugin classes aren't in
// the app's precompiled CSS. Idempotent across hot reloads.
if (!document.getElementById('hermes-bots-keyframes')) {
const style = document.createElement('style')
style.id = 'hermes-bots-keyframes'
style.textContent = '@keyframes hermes-bots-bob { from { transform: translateY(0); } to { transform: translateY(-3px); } }'
document.head.appendChild(style)
}
// Hydrate persisted avatars/titles. Storage may be sync, async, or
// absent depending on shell version — normalize through Promise.resolve
// inside a try so a storage quirk can NEVER fail the plugin load.
try {
Promise.resolve(ctx.storage?.get?.('bot-meta'))
.then(value => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const live = $botMeta.get()
const next = { ...value }
for (const name of Object.keys(live)) {
next[name] = { ...(value[name] || {}), ...live[name] }
}
$botMeta.set(next)
}
})
.catch(() => undefined)
} catch {
/* no storage on this shell — defaults stay */
}
// 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 {
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 {
Promise.resolve(ctx.storage?.get?.('group-chats'))
.then(value => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const rooms = {}
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 : {},
stranded: room.stranded && typeof room.stranded === 'object' ? room.stranded : {},
members: Array.isArray(room.members) ? room.members : [],
image: typeof room.image === 'string' && room.image ? room.image : null,
epoch: 0,
running: false
}
}
}
$groupChats.set({ ...rooms, ...$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($focusedBotProfile)
const unbindGatewayListener = host.state.gateway.listen(handleSessionsGatewayTransition)
if (typeof ctx.onDispose === 'function') {
ctx.onDispose(() => {
if (typeof unbindProfileListener === 'function') {
unbindProfileListener()
}
if (typeof unbindGatewayListener === 'function') {
unbindGatewayListener()
}
})
}
// 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.)
const scheduleHideSweep = () => {
try {
setTimeout(() => void hideOwnedBotSessions(), 0)
} catch {
void hideOwnedBotSessions()
}
}
host.state.gateway.listen(state => {
if (state === 'open') {
scheduleHideSweep()
}
})
scheduleHideSweep()
ctx.register({
id: 'pane',
area: 'panes',
title: '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', width: '260px', collapsible: true, showCloseButton: false, hideOnly: true, dock: { pane: 'sessions', pos: 'center', enforce: true } },
render: () => jsx(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',
title: 'Cronjobs',
data: {
placement: 'main',
// Repair persisted layouts that stranded Cronjobs in the Bots tab strip.
dock: { pane: 'workspace', pos: 'right', enforce: true },
width: '250px'
},
render: () => jsx(RoutinesPane, {})
})
if (typeof host.paneVisibility === 'function') {
// The contribution-scoped pane id (`register` prefixes `${ID}:`).
const $botsPaneVisible = host.paneVisibility(`${ID}:pane`)
let unregisterRoutines = null
const syncRoutinesPane = visible => {
if (visible) {
unregisterRoutines ??= registerRoutinesPane()
} else if (unregisterRoutines) {
unregisterRoutines()
unregisterRoutines = null
}
}
const stopRoutinesSync = $botsPaneVisible.listen(syncRoutinesPane)
syncRoutinesPane($botsPaneVisible.get())
if (typeof ctx.onDispose === 'function') {
// The registration disposer is already tracked by ctx.register; only
// the listener needs explicit teardown or it survives plugin disable.
ctx.onDispose(stopRoutinesSync)
}
} else {
registerRoutinesPane()
}
ctx.register({
id: 'new-agent',
area: PALETTE_AREA,
data: {
id: `${ID}.new-agent`,
label: 'New Agent…',
keywords: ['bot', 'agent', 'profile', 'teammate', 'create'],
run: () => {
host.notify({ kind: 'info', message: 'Open the Bots pane and hit “New Agent”.' })
}
}
})
// @-mention middleware: "@<bot> do the thing" in any chat becomes an
// explicit handoff instruction the active agent's SOUL.md knows how to
// execute. Names are validated against the LIVE roster so
// "user@example.com" or an unknown @ passes through untouched.
ctx.register({
id: 'mention-middleware',
area: COMPOSER_AREAS.middleware,
data: {
handler: async draft => {
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()
const meta = activeBot ? $botMeta.get()[activeBot] : null
const pinnedId = meta?.chat || null
const currentId = host.activeSessionId?.get?.() ?? null
if (activeBot && pinnedId && currentId && String(currentId) === String(pinnedId)) {
host.notify({
kind: 'info',
title: 'This chat never resets',
message:
'Bot chats are one continuous conversation — compacting instead. ' +
'For a throwaway session with this agent, use Sessions mode.'
})
return { ...draft, text: '/compact' }
}
}
if (!/(^|\s)@[a-z0-9][a-z0-9_-]*/i.test(text)) {
return draft
}
const live = {
name: (host.state.profile.get() || 'default').trim() || 'default',
connectionId: String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || 'local')
}
const cached = typeof queryClient !== 'undefined' && queryClient && typeof queryClient.getQueryData === 'function'
? queryClient.getQueryData(ROSTER_KEY)
: null
const roster = Array.isArray(cached?.profiles) ? cached.profiles : null
let mentionedBots = roster ? resolveRosterMentions(text, roster, live) : []
if (!roster) {
let names = []
try {
const res = await host.request('profiles.list', { include_sessions: false })
names = (res?.profiles ?? []).map(p => p.name)
} catch {
return draft
}
const prose = text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ')
const mentioned = []
for (const match of prose.matchAll(/(^|\s)@([a-z0-9][a-z0-9_-]*)/gi)) {
let name = match[2].toLowerCase()
if (name === 'hermes' && !names.includes('hermes') && names.includes('default')) {
name = 'default'
}
if (names.includes(name) && name !== live.name && !mentioned.includes(name)) {
mentioned.push(name)
}
}
mentionedBots = mentioned.map(name => ({ name }))
}
if (!mentionedBots.length) {
return draft
}
const localMentions = mentionedBots.filter(bot => !bot.remoteSource)
const remoteMentions = mentionedBots.filter(bot => bot.remoteSource)
const activeMeta = $botMeta.get()[live.name]
const senderName = displayName({ name: live.name, title: activeMeta?.title }, activeMeta)
if (remoteMentions.length && typeof host.requestProfile === 'function') {
void deliverRemoteRosterMentions(remoteMentions, text, {
name: senderName,
handle: botHandle(live.name)
})
}
let note = ''
if (localMentions.length) {
note +=
'\n\n[@mention handoff — for each mentioned agent (' + localMentions.map(bot => botHandle(bot.name, bot)).join(', ') + '): ' +
'COMPOSE a message from you (' + senderName + ') to that agent conveying what the user wants — do not forward this text verbatim (avoid double quotes in your composed message). Send it with exactly one terminal call, run with background=true AND notify_on_complete=true (the recipient may take minutes; the user must not be blocked):\n' +
localMentions.map(bot => '`hermes -p ' + shellQuote(bot.name) + ' chat --in ~ -c "Bot Chat" --create-if-missing -Q -q "Message from 🤖 ' + shellDoubleQuote(senderName) + ' (@' + shellDoubleQuote(botHandle(live.name)) + '): <your composed message>"`').join('\n') +
'\nAfter dispatching, tell the user the message was sent and END YOUR TURN — do not wait or poll; when the background process completes, its notification carries the reply — relay it then, attributed to that agent. ' +
'Relay the reply back to the user, attributed to that agent.]'
}
if (remoteMentions.length) {
const labels = remoteMentions.map(bot => `@${botHandle(bot.name, bot)} (${bot.connectionLabel || bot.connectionId})`).join(', ')
note +=
'\n\n[@mention — stay on this device. Desktop is delivering to ' + labels +
' over Connections in the background. Do not run hermes -p for them and do not switch Gateway. Tell the user they were messaged here; when a reply lands, relay it attributed to that agent.]'
}
return { ...draft, text: text + note }
} }
})
}
}