/** * The group-chat room store: the room atoms, the durable record in plugin * storage, the bounded cross-client projection that rides the default * profile's ui_meta, and the small pure helpers every room surface shares * (identity, thread ids, entry append, the #93127 decision predicates). * * The sync projection lives here rather than in its own module because * updateGroupChat schedules it and the scheduler reads the same atom — one * store, one writer. */ import { atom, host } from '@hermes/plugin-sdk' import { $botMeta, $lastRoster, botRosterKey } from './data' import { groupMemberReferencesConnection, markOrphanedGroupMemberDescriptor } from './hygiene' import { getPluginCtx } from './shared' import type { Attachment, GroupChat, GroupHold, GroupMember, GroupMessage, GroupMessageAuthor, GroupPrompt, RosterRow } from './types' /** Optional secondary navigation inside the Bots pane (group-chat rooms). */ /** 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. */ export const $groupChats = atom>({}) /** Group whose room view is open in the Bots pane (secondary navigation * inside the pane; a normal row click returns to the roster). */ export const $groupChatWorkspace = atom(null) /** Groups whose latest room activity mentions @user — the needs-you badge. */ export const $groupNeedsYou = atom>({}) // Pending prompts (clarify questions AND command approvals) raised inside // hidden group-member sessions, keyed `${group}::${memberKey}` (#90694). // Members run in invisible plumbing sessions, so a member's blocking prompt // used to park server-side with no surface to answer it — the user saw // "is thinking…" until the prompt timeout. The turn poll mirrors each // member's `pending_clarify` / `pending_approval` resume fields in here; // the room renders answer cards from it. export const $groupClarify = atom>({}) const GROUP_CHAT_SYNC_META_KEY = 'hermes-bots-groups' // Gateway ui_meta is capped after Python JSON serialization. Keep a healthy // margin below that limit because Python escapes Unicode while JS does not. const GROUP_CHAT_SYNC_MAX_BYTES = 48000 const GROUP_CHAT_SYNC_MESSAGES = 16 const GROUP_CHAT_SYNC_TEXT_CHARS = 1200 const GROUP_CHAT_SYNC_IMAGE_CHARS = 24000 let groupChatSyncTimer: ReturnType | null = null /** One room inside the bounded ui_meta projection: a compacted log plus the * identity fields, without any of `GroupChat`'s runtime/orchestration state. */ interface GroupChatSyncRoom { image?: null | string log: GroupMessage[] members?: GroupMember[] name?: string revision?: number roomId?: string } /** The v3 envelope stored under the default profile's `hermes-bots-groups` * ui_meta key. `deleted` maps a room key to its tombstone revision. */ interface GroupChatSyncSnapshot { deleted?: Record rooms: Record updatedAt?: number version: number } /** A queued publish for one gateway, coalesced while the debounce runs. */ interface GroupChatSyncJob { allowEmpty?: boolean changedRooms?: string[] connectionId: string deletedRooms?: string[] } // Fan-out scheduler state, keyed by gateway connectionId ('' = active/local). // Every connected gateway carries the full projection so a room survives any // single gateway being removed and surfaces on every remote backend. const groupChatSyncPendingByConnection = new Map() const groupChatSyncInFlightConnections = new Set() const groupChatSyncRetryTimers = new Map>() const groupChatSyncRetryCounts = new Map() export let groupChatSyncDisposed = false /** Conservative byte count for the gateway's ensure_ascii JSON encoding. * Python also inserts separator spaces, so reserve one extra byte per JS * structural separator on top of escaped Unicode code-point widths. */ export function groupChatGatewayJsonSize(value: unknown) { const json = JSON.stringify(value) let bytes = 0 for (const character of json) { // Non-null: string iteration yields whole code points, never an empty string. const codePoint = character.codePointAt(0)! if (codePoint <= 0x7f) { bytes += 1 if (character === ',' || character === ':') { bytes += 1 } } else { bytes += codePoint <= 0xffff ? 6 : 12 } } return bytes } /** Durable room identity for the sync projection. Rooms minted on current * builds carry an immutable roomId; the projection keys rooms by * `id:` so rename is a display-name edit, not a distributed * delete+create, and disband tombstones follow the room itself. Legacy * rooms (no roomId) fall back to `name:` keys with the older * revision-gated tombstone semantics. */ export function groupChatRoomKey(name: string, room: GroupChat) { return typeof room?.roomId === 'string' && room.roomId ? `id:${room.roomId}` : `name:${String(name)}` } /** Lift any historical projection shape (v1 wall-clock, v2 name-keyed) to * the v3 room-key shape so one merge path serves mixed-version fleets. */ function normalizeGroupChatSyncSnapshot(snapshot: GroupChatSyncSnapshot | null | undefined): GroupChatSyncSnapshot { if (!snapshot || typeof snapshot !== 'object') { return { version: 3, rooms: {}, deleted: {} } } if (Number(snapshot.version || 0) >= 3) { return { version: 3, updatedAt: Number(snapshot.updatedAt || 0), rooms: snapshot.rooms && typeof snapshot.rooms === 'object' ? snapshot.rooms : {}, deleted: snapshot.deleted && typeof snapshot.deleted === 'object' ? snapshot.deleted : {} } } const rooms: Record = {} for (const [name, room] of Object.entries(snapshot.rooms || {})) { if (!room || !Array.isArray(room.log)) { continue } rooms[`name:${name}`] = { ...room, name } } const deleted: Record = {} for (const [name, at] of Object.entries(snapshot.deleted || {})) { // v1 tombstones carried wall-clock ms, not gateway revisions — they must // not outrank real revisions. deleted[`name:${name}`] = Number(snapshot.version || 0) >= 2 ? Math.max(0, Number(at || 0)) : 0 } return { version: 3, updatedAt: Number(snapshot.updatedAt || 0), rooms, deleted } } /** Compact, display-oriented copy of Desktop's room log for gateway clients. * The live orchestration state stays in plugin storage; this bounded mirror * rides the default profile's ui_meta so mobile can show the same messages. * Newest rooms/messages win when the profile metadata size cap is reached. */ export function groupChatSyncSnapshot( // `revision` is the pre-`syncRevision` field name, still read below as a // fallback for rooms hydrated from an older plugin-storage record. all: Record = $groupChats.get(), deleted: Record = {} ): GroupChatSyncSnapshot { const ranked = Object.entries(all || {}) // Empty runtime tombstones are used to stop an in-flight room after // disband. They are not real rooms and must never reappear on mobile. .filter(([, room]) => room && Array.isArray(room.log) && room.log.length > 0) .sort(([, left], [, right]) => { const leftAt = Number(left.log[left.log.length - 1]?.at || 0) const rightAt = Number(right.log[right.log.length - 1]?.at || 0) return rightAt - leftAt }) const rooms: Record = {} const boundedDeleted = Object.fromEntries( Object.entries(deleted) .sort(([, left], [, right]) => Number(right || 0) - Number(left || 0)) .slice(0, 64) ) const envelope: GroupChatSyncSnapshot = { version: 3, updatedAt: Date.now(), rooms, ...(Object.keys(boundedDeleted).length ? { deleted: boundedDeleted } : {}) } for (const [name, room] of ranked) { const log: GroupMessage[] = room.log.slice(-GROUP_CHAT_SYNC_MESSAGES).map(entry => ({ ...(entry?.id ? { id: String(entry.id).slice(0, 160) } : {}), from: { kind: entry?.from?.kind === 'member' ? 'member' : 'user', name: String(entry?.from?.name || (entry?.from?.kind === 'member' ? 'Bot' : 'You')).slice(0, 128), ...(entry?.from?.source ? { source: String(entry.from.source).slice(0, 128) } : {}) }, text: String(entry?.text || '').slice(0, GROUP_CHAT_SYNC_TEXT_CHARS), at: Number(entry?.at || 0), ...(entry?.thread ? { thread: String(entry.thread).slice(0, 128) } : {}) })) const compact: GroupChatSyncRoom = { name: String(name).slice(0, 64), ...(typeof room?.roomId === 'string' && room.roomId ? { roomId: String(room.roomId).slice(0, 128) } : {}), log, revision: Math.max(0, Number(room?.syncRevision ?? room?.revision ?? 0)), members: (Array.isArray(room.members) ? room.members : []).slice(0, GROUP_CHAT_MAX_MEMBERS).map(member => ({ name: String(member?.name || '').slice(0, 128), ...(member?.handle ? { handle: String(member.handle).slice(0, 128) } : {}), ...(member?.connectionId ? { connectionId: String(member.connectionId).slice(0, 128) } : {}), ...(member?.connectionKind ? { connectionKind: String(member.connectionKind).slice(0, 64) } : {}), ...(member?.connectionLabel ? { connectionLabel: String(member.connectionLabel).slice(0, 128) } : {}), ...(member?.sourceScoped ? { sourceScoped: true } : {}) })), ...(typeof room?.image === 'string' && room.image.length <= GROUP_CHAT_SYNC_IMAGE_CHARS ? { image: room.image } : {}) } const key = groupChatRoomKey(name, room) rooms[key] = compact while (compact.log.length > 1 && groupChatGatewayJsonSize(envelope) > GROUP_CHAT_SYNC_MAX_BYTES) { compact.log.shift() } if (compact.image && groupChatGatewayJsonSize(envelope) > GROUP_CHAT_SYNC_MAX_BYTES) { delete compact.image } if (groupChatGatewayJsonSize(envelope) > GROUP_CHAT_SYNC_MAX_BYTES) { delete rooms[key] } } return envelope } function groupChatSyncEntryKey(entry: GroupMessage) { if (entry?.id) { return `id:${String(entry.id)}` } return JSON.stringify([ Number(entry?.at || 0), String(entry?.from?.kind || ''), String(entry?.from?.name || ''), String(entry?.from?.source || ''), // Threadless entries (pre-thread rooms, older Desktop builds) get // SYNTHETIC `legacy-N` ids from assignLegacyThreads. Those ids are // position-derived — not stable across a gateway round-trip (the // projection copy may be threadless or numbered differently). Collapse // the whole synthetic family to one bucket, or the merge duplicates // every id-less entry — shifting watermarks and manufacturing phantom // member turns that re-submit into busy sessions. String(entry?.thread || 'legacy').replace(/^legacy-\d+$/, 'legacy'), String(entry?.text || '') ]) } /** Members dedupe on durable identity — the same (connectionId, name) pair * botRosterKey seats them by everywhere else. `connectionLabel` and `handle` * are display strings each machine re-derives (a connection rename, an older * build with no handle), so keying on them seats one bot twice; both copies * then answer to a single groupMemberKey in watermarks/sessions/stranded and * the round engine gives that bot two turns. Deliberately unconditional, * unlike groupMemberKey: the projection stamps `remoteSource` onto members it * merges in, so a scoped/unscoped branch would fork a member from its own * previously-merged copy. */ function groupChatSyncMemberKey(member: GroupMember) { return botRosterKey(member) } /** Merge two bounded projections without treating an absent room/message as * deletion. Rooms are identified by durable room keys (id: when the * room carries one), so a rename is a same-key field update — never a * distributed delete+create — and a disband tombstone follows the room * itself. Gateway revisions order identity/membership/picture and * tombstones; stable message ids make concurrent log union idempotent. * `changedRooms`/`deletedRooms` accept display names or room keys. */ export function mergeGroupChatSyncSnapshots( remote: GroupChatSyncSnapshot | null | undefined, local: GroupChatSyncSnapshot | null | undefined, { changedRooms = [], deletedRooms = [], writeRevision = 0 }: { changedRooms?: string[]; deletedRooms?: string[]; writeRevision?: number } = {} ) { const remoteNorm = normalizeGroupChatSyncSnapshot(remote) const localNorm = normalizeGroupChatSyncSnapshot(local) const keysFor = (label: string, norm: GroupChatSyncSnapshot) => { const keys = new Set() for (const [key, room] of Object.entries(norm.rooms || {})) { if (key === label || String(room?.name || '') === label || key === `name:${label}`) { keys.add(key) } } if (String(label).startsWith('id:') || String(label).startsWith('name:')) { keys.add(label) } else if (!keys.size) { keys.add(`name:${label}`) } return keys } const changed = new Set() for (const label of changedRooms) { for (const key of keysFor(label, localNorm)) { changed.add(key) } } const deleted: Record = {} for (const source of [remoteNorm, localNorm]) { for (const [key, at] of Object.entries(source.deleted || {})) { deleted[key] = Math.max(Number(deleted[key] || 0), Math.max(0, Number(at || 0))) } } for (const label of deletedRooms) { for (const key of new Set([...keysFor(label, remoteNorm), ...keysFor(label, localNorm)])) { // Rename passes changedRooms:[newName] + deletedRooms:[oldName]. For an // id-keyed room both labels resolve to the SAME durable key (the remote // copy still carries the old display name), and id tombstones are // final — so tombstoning here would kill the room being renamed. A key // that is being written this cycle is a rename target, not a disband. if (changed.has(key)) { continue } deleted[key] = Math.max(Number(deleted[key] || 0), Number(writeRevision || 0)) } } const rooms: Record = {} const roomKeys = new Set([...Object.keys(remoteNorm.rooms || {}), ...Object.keys(localNorm.rooms || {})]) for (const key of roomKeys) { const remoteRoom = remoteNorm.rooms?.[key] const localRoom = localNorm.rooms?.[key] if ((!remoteRoom || !Array.isArray(remoteRoom.log)) && (!localRoom || !Array.isArray(localRoom.log))) { continue } const remoteRevision = Math.max(0, Number(remoteRoom?.revision || 0)) const localRevision = changed.has(key) ? Math.max(0, Number(writeRevision || 0)) : Math.max(0, Number(localRoom?.revision || 0)) const entries = new Map() for (const entry of [...(remoteRoom?.log || []), ...(localRoom?.log || [])]) { entries.set(groupChatSyncEntryKey(entry), entry) } // Identity fields (display name, membership, picture) follow the higher // revision; a tie unions members and prefers the local writer's fields. let identity: GroupChatSyncRoom | undefined let members: GroupMember[] let image: null | string | undefined if (localRevision > remoteRevision) { identity = localRoom members = [...(localRoom?.members || [])] image = localRoom?.image } else if (remoteRevision > localRevision) { identity = remoteRoom members = [...(remoteRoom?.members || [])] image = remoteRoom?.image } else { identity = localRoom || remoteRoom const byId = new Map() for (const member of [...(remoteRoom?.members || []), ...(localRoom?.members || [])]) { byId.set(groupChatSyncMemberKey(member), member) } members = [...byId.values()] image = Object.prototype.hasOwnProperty.call(localRoom || {}, 'image') ? localRoom.image : remoteRoom?.image } rooms[key] = { ...(identity?.name ? { name: identity.name } : {}), ...(identity?.roomId || (key.startsWith('id:') ? key.slice(3) : '') ? { roomId: identity?.roomId || key.slice(3) } : {}), log: [...entries.values()].sort((left, right) => { const byTime = Number(left?.at || 0) - Number(right?.at || 0) return byTime || groupChatSyncEntryKey(left).localeCompare(groupChatSyncEntryKey(right)) }), members, revision: Math.max(remoteRevision, localRevision), ...(typeof image === 'string' && image ? { image } : {}) } } for (const [key, deletedRevision] of Object.entries(deleted)) { if (key.startsWith('id:')) { // Tombstones for id-keyed rooms are FINAL: the roomId is minted once // and never reused (same-name recreation mints a fresh id), so a // resurrect-by-revision race is structurally impossible. Keep the // tombstone even when a lagging gateway's copy carries a higher // revision — that copy is the resurrection this exists to prevent. delete rooms[key] } else if (Number(deletedRevision || 0) >= Number(rooms[key]?.revision || 0)) { delete rooms[key] } else { delete deleted[key] } } return groupChatSyncEnvelope(rooms, deleted) } /** Assemble + size-bound a v3 envelope from already-compacted rooms. */ function groupChatSyncEnvelope( rooms: Record, deleted: Record = {} ): GroupChatSyncSnapshot { const boundedDeleted = Object.fromEntries( Object.entries(deleted) .sort(([, left], [, right]) => Number(right || 0) - Number(left || 0)) .slice(0, 64) ) const envelope: GroupChatSyncSnapshot = { version: 3, updatedAt: Date.now(), rooms, ...(Object.keys(boundedDeleted).length ? { deleted: boundedDeleted } : {}) } const ranked = Object.entries(rooms).sort(([, left], [, right]) => { const leftAt = Number(left?.log?.[left.log.length - 1]?.at || 0) const rightAt = Number(right?.log?.[right.log.length - 1]?.at || 0) return leftAt - rightAt }) for (const [key, room] of ranked) { while ((room.log?.length || 0) > 1 && groupChatGatewayJsonSize(envelope) > GROUP_CHAT_SYNC_MAX_BYTES) { room.log.shift() } if (room.image && groupChatGatewayJsonSize(envelope) > GROUP_CHAT_SYNC_MAX_BYTES) { delete room.image } if (groupChatGatewayJsonSize(envelope) > GROUP_CHAT_SYNC_MAX_BYTES) { delete rooms[key] } } return envelope } /** Merge the gateway's bounded display projection into Desktop's richer room * state without discarding local session/watermark/runtime fields. Missing * remote rooms/messages are not deletions; only explicit tombstones remove a * room, and a genuinely newer local message wins over a stale tombstone. */ export function mergeRemoteGroupChatSnapshotIntoRooms( remote: GroupChatSyncSnapshot | null | undefined, current: Record = $groupChats.get(), { preserveRooms = [], deletedRooms = [] }: { deletedRooms?: string[]; preserveRooms?: string[] } = {} ) { const remoteNorm = normalizeGroupChatSyncSnapshot(remote) const rooms: Record = { ...(current || {}) } const preserved = new Set(preserveRooms) const locallyDeleted = new Set(deletedRooms) // Local rooms indexed by durable identity so an id-keyed projection room // finds its local twin even when the display name changed remotely. const localByRoomId = new Map() for (const [name, room] of Object.entries(rooms)) { if (typeof room?.roomId === 'string' && room.roomId) { localByRoomId.set(room.roomId, name) } } for (const [key, projected] of Object.entries(remoteNorm.rooms || {})) { if (!projected || !Array.isArray(projected.log)) { continue } const projectedRoomId = projected.roomId || (key.startsWith('id:') ? key.slice(3) : null) const localName = projectedRoomId && localByRoomId.has(projectedRoomId) ? localByRoomId.get(projectedRoomId) : projected.name && rooms[projected.name] ? projected.name : null const displayName = String(projected.name || localName || (key.startsWith('name:') ? key.slice(5) : key)) if (locallyDeleted.has(displayName) || (localName && locallyDeleted.has(localName))) { // Mid-rename guard: the remote copy may still be under the OLD display // name while the local record was already re-keyed (same roomId, new // name). That old name sits in deletedRooms, but the local record is // the rename in flight — deleting it here would kill the renamed room. if (localName && localName !== displayName && !locallyDeleted.has(localName)) { continue } delete rooms[displayName] if (localName) { delete rooms[localName] } continue } const existing = (localName ? rooms[localName] : rooms[displayName]) || {} const remoteRevision = Math.max(0, Number(projected.revision || 0)) const localRevision = Math.max(0, Number(existing.syncRevision || 0)) const entries = new Map( (Array.isArray(existing.log) ? existing.log : []).map(entry => [groupChatSyncEntryKey(entry), entry]) ) const members = new Map( (Array.isArray(existing.members) ? existing.members : []).map(member => [groupChatSyncMemberKey(member), member]) ) for (const entry of projected.log) { const entryKey = groupChatSyncEntryKey(entry) // The projection is COMPACT (truncated text, no images). When the same // entry exists locally, the local rich copy is authoritative — merging // the compact twin over it would strip attachments and retrigger // watermark deltas for members that already saw it (phantom rounds). if (!entries.has(entryKey)) { entries.set(entryKey, entry) } } const isPreserved = preserved.has(displayName) || (localName && preserved.has(localName)) if (!isPreserved) { if (remoteRevision > localRevision) { members.clear() } for (const member of Array.isArray(projected.members) ? projected.members : []) { members.set(groupChatSyncMemberKey(member), { ...member, remoteSource: true }) } } const log = assignLegacyThreads( [...entries.values()].sort((left, right) => { const byTime = Number(left?.at || 0) - Number(right?.at || 0) return byTime || groupChatSyncEntryKey(left).localeCompare(groupChatSyncEntryKey(right)) }) ) const bounded = trimGroupChatLog(log, existing.watermarks || {}) // A remote rename with a higher revision moves the local record to the // new display name; local views keyed by the old name follow on the // next repaint (roster derives from $groupChats keys). const targetName = !isPreserved && remoteRevision > localRevision ? displayName : localName || displayName if (localName && targetName !== localName) { delete rooms[localName] } rooms[targetName] = { ...existing, log: bounded.log, watermarks: bounded.watermarks, sessions: existing.sessions && typeof existing.sessions === 'object' ? existing.sessions : {}, stranded: existing.stranded && typeof existing.stranded === 'object' ? existing.stranded : {}, members: [...members.values()], ...(projectedRoomId || existing.roomId ? { roomId: existing.roomId || projectedRoomId } : {}), image: isPreserved ? existing.image || null : remoteRevision >= localRevision && Object.prototype.hasOwnProperty.call(projected, 'image') ? projected.image || null : existing.image || null, syncRevision: isPreserved ? localRevision : Math.max(remoteRevision, localRevision), epoch: Number(existing.epoch || 0), running: Boolean(existing.running) } } for (const [key, deletedAt] of Object.entries(remoteNorm.deleted || {})) { const deletedRoomId = key.startsWith('id:') ? key.slice(3) : null const targetName = deletedRoomId && localByRoomId.has(deletedRoomId) ? localByRoomId.get(deletedRoomId) : key.startsWith('name:') ? key.slice(5) : null if (!targetName || preserved.has(targetName)) { continue } if (deletedRoomId) { // Id tombstones are final — the id is never reused, so there is no // legitimate higher-revision recreation to protect. delete rooms[targetName] } else { const deletedRevision = Math.max(0, Number(deletedAt || 0)) if (deletedRevision >= Number(rooms[targetName]?.syncRevision || 0)) { delete rooms[targetName] } } } for (const name of locallyDeleted) { delete rooms[name] } return rooms } export function durableGroupChatRooms(all: Record = $groupChats.get()) { const durable: Record = {} for (const [name, room] of Object.entries(all || {})) { if (!room || !Array.isArray(room.log)) { continue } // Disband tombstones are runtime-only coordination state (they hold the // epoch bump for an in-flight drive). Persisting one would resurrect the // room as an empty record on the next load AND keep its name "taken" for // same-name recreates. Mirrors updateGroupChat's inline durable map. if (room.tombstone) { continue } durable[name] = { log: room.log, watermarks: room.watermarks || {}, sessions: room.sessions || {}, stranded: room.stranded || {}, members: Array.isArray(room.members) ? room.members : [], // Immutable room identity: without this, a room merged in via the // remote-sync path (the only caller of this function) loses its // roomId on the next cold hydrate and falls back to legacy // name-keyed identity — same field updateGroupChat's inline map // already carries. roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, image: room.image || null, syncRevision: Math.max(0, Number(room.syncRevision || 0)) } } return durable } export function persistGroupChatRooms(all: Record = $groupChats.get()) { try { return Promise.resolve(getPluginCtx()?.storage?.set?.('group-chats', durableGroupChatRooms(all))).catch( () => undefined ) } catch { return Promise.resolve() } } /** Register-removed sweep: annotate (not delete) every persisted group-chat * member owned by the deleted connection, in the atom AND plugin storage. * Writes ride updateGroupChat so the durable record keeps its full shape * (sessionOwners, holds — durableGroupChatRooms would drop them). * Returns whether anything changed. */ export function sweepGroupChatMembersForRemovedConnection(connectionId: string) { const id = String(connectionId || '').trim() if (!id) { return false } let changed = false for (const [name, room] of Object.entries($groupChats.get())) { const members = Array.isArray(room?.members) ? room.members : [] if (!members.some(member => groupMemberReferencesConnection(member, id) && !member?.sourceMissing)) { continue } changed = true updateGroupChat(name, (current: GroupChat) => ({ ...current, members: (Array.isArray(current.members) ? current.members : []).map(member => groupMemberReferencesConnection(member, id) ? markOrphanedGroupMemberDescriptor(member) : member ) })) } return changed } function groupChatSyncConnectionId() { return String(host.state.connectionId?.get?.() || host.activeConnectionId?.() || '') } /** Route a sync job back to the gateway that was active when it was queued. * A foreground switch during debounce must not write the old snapshot into * the newly active gateway. */ async function groupChatSyncRequest( job: GroupChatSyncJob, method: string, params: Record ): Promise { if (job.connectionId && typeof host.profileRoutes === 'function' && typeof host.requestProfile === 'function') { const routes = await host.profileRoutes() const route = (Array.isArray(routes) ? routes : []).find(candidate => { const profile = String(candidate?.targetProfile || candidate?.profile || '') return String(candidate?.connectionId || '') === job.connectionId && profile === 'default' }) if (route) { return host.requestProfile(route, method, params) } } const currentConnectionId = groupChatSyncConnectionId() if (job.connectionId && currentConnectionId && job.connectionId !== currentConnectionId) { throw new Error('Group chat gateway changed before sync') } return host.request(method, params) } async function groupChatRemoteSnapshot(job: GroupChatSyncJob) { const result = await groupChatSyncRequest<{ profiles?: RosterRow[] }>(job, 'profiles.list', { include_sessions: false }) const profile = (Array.isArray(result?.profiles) ? result.profiles : []).find(row => row?.name === 'default') const snapshot = profile?.ui_meta?.[GROUP_CHAT_SYNC_META_KEY] as GroupChatSyncSnapshot | undefined const supportsCas = Boolean(profile && Object.prototype.hasOwnProperty.call(profile, 'ui_meta_revisions')) return { snapshot: snapshot && typeof snapshot === 'object' && !Array.isArray(snapshot) ? snapshot : null, revision: Math.max(0, Number(profile?.ui_meta_revisions?.[GROUP_CHAT_SYNC_META_KEY] || 0)), supportsCas } } /** Pull the shared room projection into this Desktop before it publishes any * local state. This is the receive half of the client-only sync contract. */ export async function pullGroupChatServerState(connectionId: string = groupChatSyncConnectionId()) { const { snapshot: remote } = await groupChatRemoteSnapshot({ connectionId }) if (!remote) { return false } const pending = groupChatSyncPendingByConnection.get(String(connectionId || '')) const merged = mergeRemoteGroupChatSnapshotIntoRooms(remote, $groupChats.get(), { preserveRooms: pending?.changedRooms || [], deletedRooms: pending?.deletedRooms || [] }) $groupChats.set(merged) await persistGroupChatRooms(merged) return true } function groupChatSyncBackoff(connectionId: string) { const count = Number(groupChatSyncRetryCounts.get(connectionId) || 0) return Math.min(30000, 1000 * 2 ** Math.min(count, 5)) } function mergeGroupChatSyncJobs(existing: GroupChatSyncJob | undefined, incoming: GroupChatSyncJob): GroupChatSyncJob { if (!existing || existing.connectionId !== incoming.connectionId) { return incoming } return { connectionId: incoming.connectionId, allowEmpty: Boolean(existing.allowEmpty || incoming.allowEmpty), changedRooms: [...new Set([...(existing.changedRooms || []), ...(incoming.changedRooms || [])])], deletedRooms: [...new Set([...(existing.deletedRooms || []), ...(incoming.deletedRooms || [])])] } } function groupChatSyncPayloadEqual( left: GroupChatSyncSnapshot | null | undefined, right: GroupChatSyncSnapshot | null | undefined ) { return ( JSON.stringify(left?.rooms || {}) === JSON.stringify(right?.rooms || {}) && JSON.stringify(left?.deleted || {}) === JSON.stringify(right?.deleted || {}) ) } /** Every default-profile gateway route this Desktop can currently reach. * The projection fans out to ALL of them, so any single gateway can die or * be removed without losing the shared room state, and gateway-only * clients (Hermes Go, headless backends) see rooms regardless of which * gateway a Desktop was foregrounding when the room was used. */ async function groupChatSyncTargetConnections() { const targets = new Set() const active = groupChatSyncConnectionId() targets.add(String(active || '')) if (typeof host.profileRoutes === 'function' && typeof host.requestProfile === 'function') { try { const routes = await host.profileRoutes() for (const route of Array.isArray(routes) ? routes : []) { const profile = String(route?.targetProfile || route?.profile || '') const connectionId = String(route?.connectionId || '') if (profile === 'default' && connectionId) { targets.add(connectionId) } } } catch { // Route inventory unavailable — the active gateway alone still syncs. } } return [...targets] } async function flushGroupChatServerSync(connectionId?: string) { if (connectionId === undefined) { // Drain every connection with pending work. for (const pendingId of [...groupChatSyncPendingByConnection.keys()]) { void flushGroupChatServerSync(pendingId) } return } const id = String(connectionId || '') if (groupChatSyncDisposed || groupChatSyncInFlightConnections.has(id) || !groupChatSyncPendingByConnection.has(id)) { return } // Non-null: the `has(id)` guard directly above is the entry condition. const job = groupChatSyncPendingByConnection.get(id)! groupChatSyncPendingByConnection.delete(id) groupChatSyncInFlightConnections.add(id) try { const remoteState = await groupChatRemoteSnapshot(job) const local = groupChatSyncSnapshot($groupChats.get()) const writeRevision = remoteState.revision + 1 const snapshot = mergeGroupChatSyncSnapshots(remoteState.snapshot, local, { changedRooms: job.changedRooms, deletedRooms: job.deletedRooms, writeRevision }) // Reconnect/startup reconciliation often discovers that the gateway // already holds the exact merged projection. Avoid advancing a revision // merely because a view reopened. if ( !(job.changedRooms || []).length && !(job.deletedRooms || []).length && groupChatSyncPayloadEqual(snapshot, remoteState.snapshot) ) { if (remoteState.snapshot) { const pending = groupChatSyncPendingByConnection.get(id) const mergedRooms = mergeRemoteGroupChatSnapshotIntoRooms(remoteState.snapshot, $groupChats.get(), { preserveRooms: pending?.changedRooms || [], deletedRooms: pending?.deletedRooms || [] }) $groupChats.set(mergedRooms) await persistGroupChatRooms(mergedRooms) } groupChatSyncRetryCounts.delete(id) return } const configureParams: { name: string ui_meta: Record ui_meta_expected_revisions?: Record } = { name: 'default', ui_meta: { [GROUP_CHAT_SYNC_META_KEY]: snapshot } } if (remoteState.supportsCas) { configureParams.ui_meta_expected_revisions = { [GROUP_CHAT_SYNC_META_KEY]: remoteState.revision } } const result = await groupChatSyncRequest<{ applied?: { ui_meta?: boolean; ui_meta_revisions?: Record } }>(job, 'profiles.configure', configureParams) if (result?.applied?.ui_meta !== true) { throw new Error('Gateway rejected group chat ui_meta') } if ( remoteState.supportsCas && Number(result?.applied?.ui_meta_revisions?.[GROUP_CHAT_SYNC_META_KEY] || 0) !== writeRevision ) { throw new Error('Gateway did not advance group chat ui_meta revision') } const confirmedState = await groupChatRemoteSnapshot(job) if (remoteState.supportsCas && confirmedState.revision < writeRevision) { throw new Error('Group chat ui_meta revision missing after read-back') } if (confirmedState.snapshot) { const pending = groupChatSyncPendingByConnection.get(id) const mergedRooms = mergeRemoteGroupChatSnapshotIntoRooms(confirmedState.snapshot, $groupChats.get(), { preserveRooms: pending?.changedRooms || [], deletedRooms: pending?.deletedRooms || [] }) $groupChats.set(mergedRooms) await persistGroupChatRooms(mergedRooms) } groupChatSyncRetryCounts.delete(id) } catch { if (!groupChatSyncDisposed) { const retries = Number(groupChatSyncRetryCounts.get(id) || 0) + 1 // A gateway that was REMOVED (not just flaky) has no route anymore and // would otherwise retry forever. Give up after the backoff ladder tops // out; local storage remains authoritative and a future reconnect of // that gateway re-seeds it via the gateway-transition pull/publish. if (retries > 8) { groupChatSyncRetryCounts.delete(id) return } groupChatSyncPendingByConnection.set(id, mergeGroupChatSyncJobs(groupChatSyncPendingByConnection.get(id), job)) groupChatSyncRetryCounts.set(id, retries) if (typeof setTimeout === 'function' && !groupChatSyncRetryTimers.has(id)) { groupChatSyncRetryTimers.set( id, setTimeout(() => { groupChatSyncRetryTimers.delete(id) void flushGroupChatServerSync(id) }, groupChatSyncBackoff(id)) ) } } } finally { groupChatSyncInFlightConnections.delete(id) if (groupChatSyncPendingByConnection.has(id) && !groupChatSyncRetryTimers.has(id) && !groupChatSyncDisposed) { void flushGroupChatServerSync(id) } } } export function stopGroupChatServerSync() { groupChatSyncDisposed = true groupChatSyncPendingByConnection.clear() if (groupChatSyncTimer !== null) { clearTimeout(groupChatSyncTimer) groupChatSyncTimer = null } for (const timer of groupChatSyncRetryTimers.values()) { clearTimeout(timer) } groupChatSyncRetryTimers.clear() groupChatSyncRetryCounts.clear() } /** Debounced, pull-merge-write server mirror, fanned out to every reachable * default-profile gateway. Local storage keeps the complete orchestration * log; ui_meta is a bounded cross-client projection per gateway, each with * its own CAS revision stream. */ export function scheduleGroupChatServerSync( all: Record = $groupChats.get(), { allowEmpty = false, changedRooms = [], deletedRooms = [] }: { allowEmpty?: boolean; changedRooms?: string[]; deletedRooms?: string[] } = {} ) { // Browser shells provide timers; source-level VM tests and older embedded // hosts may not. Room persistence must never break the surrounding gateway // lifecycle when the optional mirror cannot be scheduled. if (typeof setTimeout !== 'function') { return } const snapshot = groupChatSyncSnapshot(all) // A newly installed Desktop has no local room cache. Publishing that empty // state on hydrate/reconnect would erase a valid mirror produced elsewhere. // Only an explicit final-room disband is allowed to clear the projection. if (Object.keys(snapshot.rooms).length === 0 && !allowEmpty) { return } if (groupChatSyncTimer !== null) { clearTimeout(groupChatSyncTimer) } // Queue on the ACTIVE gateway synchronously (tests and older hosts have no // async route inventory), then widen to every reachable gateway before the // debounce fires. const activeId = String(groupChatSyncConnectionId() || '') const queueFor = (connectionId: string) => { const id = String(connectionId || '') const retryTimer = groupChatSyncRetryTimers.get(id) if (retryTimer !== undefined) { clearTimeout(retryTimer) groupChatSyncRetryTimers.delete(id) } groupChatSyncPendingByConnection.set( id, mergeGroupChatSyncJobs(groupChatSyncPendingByConnection.get(id), { connectionId: id, allowEmpty, changedRooms, deletedRooms }) ) } queueFor(activeId) groupChatSyncTimer = setTimeout(() => { groupChatSyncTimer = null void groupChatSyncTargetConnections() .then(targets => { for (const target of targets) { if (String(target || '') !== activeId) { queueFor(target) } } }) .catch(() => undefined) .then(() => flushGroupChatServerSync()) }, 350) } export function handleSessionsGatewayTransition() { // 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) // Pull before re-publishing so a reconnect or source swap never lets this // client's stale cache hide a room written by another Desktop/mobile client. void pullGroupChatServerState() .catch(() => false) .then(() => scheduleGroupChatServerSync($groupChats.get())) } /** Re-arm the mirror after a dispose. `register()` owns this door: an * imported binding cannot be assigned, so the flag's reset crosses the * module edge as an accessor (same pattern as shared.ts's plugin context). */ export function setGroupChatSyncDisposed(disposed: boolean) { groupChatSyncDisposed = disposed } // ── one room's budget ──────────────────────────────────────────────────────── // Every ceiling a single user send can spend, in one block on purpose: making // them configurable (per room, or model-aware from config.yaml) is live // contributor work — #92213 (per-room limits) and #96842 (config + token // budget) — and both need exactly one seam to hook. Carried over at the same // values the old plugin.js shipped so neither rebase inherits a behavior // change on top of a rewrite; deciding the shape of the override belongs to // those PRs, not to a design-system pass. export const GROUP_CHAT_MAX_ROUNDS = 3 // #94478 review: continuation rounds are bounded independently of the message cap so a pathological mention chain can't consume the room's whole budget on handoffs. export const GROUP_CHAT_MAX_MESSAGES = 10 export const GROUP_CHAT_MAX_CONTINUATIONS = 2 export const GROUP_CHAT_HISTORY_LIMIT = 24 export const GROUP_CHAT_MAX_MEMBERS = 6 /** Transcript form of a room speaker's profile name. Friendly identity wins: * a Bot Mode title or a core profile display_name (e.g. default renamed to * "Lucy") labels the speaker everywhere this helper feeds — the "X is * thinking…" working line, the activity feed, and transcript lines — so a * renamed bot never shows up as its raw profile id or a stale "Hermes" * (community report, Aug 21 2026: renamed default still read "Hermes is * thinking…" in group rooms). The untitled 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. */ export function groupSpeakerLabel(name?: null | string) { const trimmed = (name || '').trim() if (!trimmed) { return trimmed } // Bot Mode title (edit dialog) — same first rung as displayName(). const title = String($botMeta.get()?.[trimmed]?.title || '').trim() if (title) { return title } // Core profile display_name (`hermes profile rename …` / dashboard) from // the ACTIVE gateway's roster row. Source-scoped remote speakers carry // their device suffix separately and keep their raw name here. const roster = $lastRoster.get() const row = Array.isArray(roster) ? roster.find(bot => bot?.name === trimmed && !bot?.remoteSource && !bot?.sourceScoped) : null const renamed = typeof row?.display_name === 'string' ? row.display_name.trim() : '' if (renamed) { return renamed } return trimmed.toLowerCase() === 'default' ? 'Hermes' : trimmed } /** Trim a room log + its watermarks to the retained window, keeping * watermark indices consistent with the trimmed array. */ export function trimGroupChatLog( log: GroupMessage[], watermarks: Record, limit = GROUP_CHAT_HISTORY_LIMIT * 4 ) { if (log.length <= limit) { return { log, watermarks } } const drop = log.length - limit const trimmed: Record = {} for (const [name, index] of Object.entries(watermarks || {})) { trimmed[name] = Math.max(0, index - drop) } return { log: log.slice(drop), watermarks: trimmed } } interface UpdateGroupChatOptions { sync?: boolean } /** Mutate one group's room state through the atom + persist the durable part. */ export function updateGroupChat( group: string, mutate: (room: GroupChat) => GroupChat, { sync = true }: UpdateGroupChatOptions = {} ) { 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: Record = {} for (const [name, room] of Object.entries(all)) { // Disband tombstones are runtime-only coordination state (they hold the // epoch bump for an in-flight drive). Persisting one would resurrect // the room as an empty record on the next load AND keep its name // "taken" for same-name recreates. if (room.tombstone) { continue } durable[name] = { log: room.log, watermarks: room.watermarks, sessions: room.sessions || {}, sessionOwners: room.sessionOwners || {}, // 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 || {}, // #93129: sticky per-member stop holds. Watermarks persist, so holds // must too — otherwise a window restart silently releases a bot the // user explicitly stopped. holds: room.holds || {}, // 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 : [], // Immutable room identity: the member-session title for new rooms. roomId: typeof room.roomId === 'string' && room.roomId ? room.roomId : null, // Room picture (small data URL, same normalization as bot avatars). image: room.image || null, syncRevision: Math.max(0, Number(room.syncRevision || 0)) } } Promise.resolve(getPluginCtx()?.storage?.set?.('group-chats', durable)).catch(() => undefined) } catch { /* storage unavailable — room survives for this window only */ } if (sync) { scheduleGroupChatServerSync(all, { changedRooms: [group] }) } return next } /** A #93129 member hold as this file mints it. `GroupHold` models only the * two fields that survive a reload; the live stamp also records WHICH user * message, in which thread, put the member on hold. */ export interface GroupHoldStamp extends GroupHold { byMessageId?: null | string thread?: null | string } /** The room record as the coordination engine handles it: `GroupChat` plus * `turn`, the runtime-only name of the member currently mid-turn. Like * `running`/`epoch` it never persists, so it has no place in the durable * shape. Holds carry the fuller live stamp. */ export interface GroupChatRoom extends GroupChat { holds?: Record turn?: null | string } /** 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. */ export function setGroupChatImage(group: string, image: null | string | undefined) { updateGroupChat(group, (room: GroupChatRoom) => { room.image = image || null return room }) } function groupChatEntryId(): string { if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') { return globalThis.crypto.randomUUID() } return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}` } /** The agent loop's "(empty)" terminal sentinel (empty_response_exhausted) is * a FAILURE marker, never bot text. Mirror gateway/run.py's user-friendly * substitution so the room log never shows the raw sentinel. */ const GROUP_EMPTY_SENTINEL = '(empty)' const GROUP_EMPTY_FRIENDLY = '⚠️ The model returned no response after processing tool results. ' + 'This can happen with some models — try again or rephrase your question.' function normalizeGroupChatText(text: string): string { const trimmed = String(text || '').trim() return trimmed === GROUP_EMPTY_SENTINEL ? GROUP_EMPTY_FRIENDLY : trimmed } export function appendGroupChatEntry( group: string, from: GroupMessageAuthor, text: string, thread?: null | string, images?: Attachment[] ): GroupMessage { const entry: GroupMessage = { id: groupChatEntryId(), at: Date.now(), from, text: normalizeGroupChatText(text), 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 } // #93127 insurance: a residual double-append path (stale loop + fresh // loop both committing the same member reply) lands back-to-back and // byte-identical. Drop the echo instead of flooding the room. User // entries and non-adjacent repeats are never touched. const priorLog = ($groupChats.get()[group] || {}).log || [] const lastEntry = priorLog[priorLog.length - 1] if (isDuplicateGroupAppend(lastEntry, from, entry.text, entry.thread)) { return lastEntry } updateGroupChat(group, (room: GroupChatRoom) => { 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 } /** Fresh room identity for a group. Independent of the editable display * name: a disbanded-and-recreated group mints a new roomId even when the * display name is identical, so member sessions never resume by title. */ export function mintGroupRoomId(): string { return `r${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}` } /** Unique display name for a NEW group. Collisions get a " 2", " 3", … * suffix; the BASE is truncated (never the joined string), so a 64-char * base keeps its suffix instead of colliding with the original. */ export function uniqueGroupChatName(base: string, taken: Set): string { if (!taken.has(base)) { return base } for (let n = 2; n < 100; n++) { const suffix = ` ${n}` const candidate = base.slice(0, 64 - suffix.length) + suffix if (!taken.has(candidate)) { return candidate } } throw new Error('No free name for the group.') } // --- room-turn decision helpers (#93127) — pure, unit-tested --- /** #93127: whether a finished member turn may still commit (append its reply * and advance its watermark). A turn dispatched under an older epoch was * superseded mid-flight by a newer user send — its late result must be * dropped, because the new send's own loop re-drives this member with the * full delta and committing both is exactly the double-delivery bug. * * The re-drive premise is only true for a send in the SAME thread (delta * filters are thread-scoped): a cross-thread epoch bump must NOT discard * finished work no fresh loop will regenerate. Callers pass whether a newer * USER entry landed in this thread since dispatch; the default (true) * preserves the conservative drop when the caller can't tell. */ export function shouldCommitMemberTurn(epochAtDispatch: number, currentEpoch: number, newerUserEntryInThread = true) { if (epochAtDispatch === currentEpoch) { return true } return !newerUserEntryInThread } /** #93127 insurance: byte-identical member echo detection. TRUE only when * the immediately-preceding log entry has the same author (kind + name + * source), same thread, and identical text, within a short recency window — * a residual double-append fires back-to-back; two legitimately identical * replies hours apart (or with anything in between) are never dropped. */ const GROUP_DUPLICATE_APPEND_WINDOW_MS = 10 * 60 * 1000 function isDuplicateGroupAppend( lastEntry: GroupMessage | undefined, from: GroupMessageAuthor, text: string, thread: null | string | undefined, now = Date.now() ): boolean { if (!lastEntry || !from || from.kind !== 'member' || lastEntry.from?.kind !== 'member') { return false } if (String(lastEntry.from?.name || '') !== String(from.name || '')) { return false } if (String(lastEntry.from?.source || '') !== String(from.source || '')) { return false } if (String(lastEntry.thread || 'legacy') !== String(thread || 'legacy')) { return false } if (now - (lastEntry.at || 0) > GROUP_DUPLICATE_APPEND_WINDOW_MS) { return false } return String(lastEntry.text || '') === String(text || '').trim() } // --- end room-turn decision helpers --- export function groupThreadOf(entry: GroupMessage): string { return entry?.thread || 'legacy' } export function mintGroupThreadId(): string { 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 export function assignLegacyThreads(log: GroupMessage[]): GroupMessage[] { let current: null | string = 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 } }) }