From f89f884a5daefc43c318e342a7799bb23aff586e Mon Sep 17 00:00:00 2001 From: Al Zilla Date: Mon, 17 Aug 2026 14:03:52 -0600 Subject: [PATCH] fix(desktop): mention Connections bots from this local chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stay on the default gateway. Bot Mode still lists every Connections agent. Clicking a remote row no longer hops the window onto SSH — @dixie / @bob-spark in this chat resolve against the roster and Desktop delivers in the background via requestProfile. --- .../desktop/src/plugins/hermes-bots/plugin.js | 252 +++++++++++++++--- .../tests/mention-handoff-quoting.test.mjs | 63 +++++ .../tests/multi-source-roster.test.mjs | 71 ++++- .../tests/profile-prewarm.test.mjs | 10 +- 4 files changed, 351 insertions(+), 45 deletions(-) diff --git a/apps/desktop/src/plugins/hermes-bots/plugin.js b/apps/desktop/src/plugins/hermes-bots/plugin.js index 39f69cb45e..9192f8700f 100644 --- a/apps/desktop/src/plugins/hermes-bots/plugin.js +++ b/apps/desktop/src/plugins/hermes-bots/plugin.js @@ -2381,6 +2381,134 @@ function botHandle(name, bot) { 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 +} + +async function deliverRemoteRosterMentions(bots, userText) { + const text = String(userText || '').trim() + + if (!text || typeof host.requestProfile !== 'function') { + return + } + + for (const bot of bots) { + const connectionId = String(bot?.connectionId || '').trim() + const profile = String(bot?.name || '').trim() || 'default' + + if (!connectionId || connectionId === 'local') { + continue + } + + try { + const created = await host.requestProfile( + { connectionId, profile, mode: 'remote', targetProfile: profile }, + 'session.create', + { profile, title: 'Bot Chat', hidden: true } + ) + const sessionId = created?.session_id + + if (!sessionId) { + throw new Error('No remote session') + } + + await host.requestProfile( + { connectionId, profile, mode: 'remote', targetProfile: profile }, + 'prompt.submit', + { session_id: sessionId, text } + ) + host.notify?.({ + kind: 'info', + title: displayName(bot), + message: `Messaged @${botHandle(profile, bot)} on ${bot.connectionLabel || connectionId} from this chat.` + }) + } catch (error) { + host.notifyError?.(error, `Could not reach ${bot.connectionLabel || profile}`) + } + } +} + /** 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'); @@ -3458,6 +3586,17 @@ function BotRow({ bot, onDelete, onEdit, onGroup }) { const open = async () => { 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 + } + let pinnedChat = meta?.chat if (!bot.remoteSource && $botUnread.get()[bot.name]) { @@ -6877,6 +7016,16 @@ function BotsPane() { 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] @@ -7119,9 +7268,13 @@ export default { 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 || profile.name === active) { + if (!profile?.name || isActiveRosterBot(profile, live)) { continue } @@ -7321,52 +7474,73 @@ export default { return draft } - let names = [] - try { - const res = await host.request('profiles.list', { include_sessions: false }) - names = (res?.profiles ?? []).map(p => p.name) - } catch { + 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 } - // Mentions in code are code, not handoffs (#20). - const prose = text.replace(/```[\s\S]*?```/g, ' ').replace(/`[^`\n]*`/g, ' ') - const active = (host.state.profile.get() || 'default').trim() || 'default' - const mentioned = [] + const localMentions = mentionedBots.filter(bot => !bot.remoteSource) + const remoteMentions = mentionedBots.filter(bot => bot.remoteSource) - 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 !== active && !mentioned.includes(name)) { - mentioned.push(name) - } + if (remoteMentions.length && typeof host.requestProfile === 'function') { + void deliverRemoteRosterMentions(remoteMentions, text) } - if (!mentioned.length) { - return draft + const activeMeta = $botMeta.get()[live.name] + const senderName = displayName({ name: live.name, title: activeMeta?.title }, activeMeta) + 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)) + '): "`').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.]' } - // The ACTIVE BOT composes the message — it understands intent; a - // text pipe never can. Delivery is the one blessed command into the - // recipient's canonical Bot Chat, so their side reads as a normal - // DM (message bubble + their reply), and the reply prints on - // stdout for the sender to relay. - const activeMeta = $botMeta.get()[active] - const senderName = displayName({ name: active, title: activeMeta?.title }, activeMeta) - // The command below runs verbatim in the active agent's terminal: - // sender titles are free text (and sync from ui_meta), and profile - // names come from the gateway — every interpolated value must stay - // shell-literal, same class as the routine-prompt fix (#21). - const note = - '\n\n[@mention handoff — for each mentioned agent (' + mentioned.map(botHandle).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' + - mentioned.map(n => '`hermes -p ' + shellQuote(n) + ' chat --in ~ -c "Bot Chat" --create-if-missing -Q -q "Message from \uD83E\uDD16 ' + shellDoubleQuote(senderName) + ' (@' + shellDoubleQuote(botHandle(active)) + '): "`').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 } } } diff --git a/apps/desktop/src/plugins/hermes-bots/tests/mention-handoff-quoting.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/mention-handoff-quoting.test.mjs index 9dbb8e1ba2..330c03fa3c 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/mention-handoff-quoting.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/mention-handoff-quoting.test.mjs @@ -110,3 +110,66 @@ test('regression: the handoff command quotes the recipient argument', async () = const result = await handler({ text: 'ping @ops please' }) assert.match(result.text, /`hermes -p 'ops' chat --in ~/) }) + +test('behavior: @dixie on a Connections bot stays in this chat and does not hermes -p', async () => { + const values = new Map() + const atom = initial => { + const slot = { get: () => values.get(slot), set: value => values.set(slot, value) } + values.set(slot, initial) + return slot + } + const delivered = [] + const context = { + atom, + PALETTE_AREA: 'palette', + COMPOSER_AREAS: { middleware: 'middleware' }, + queryClient: { + getQueryData: () => ({ + profiles: [ + { name: 'default', connectionId: 'local' }, + { + name: 'dixie', + connectionId: 'mac-mini', + connectionLabel: 'Mac Mini', + handle: 'dixie', + remoteSource: true + } + ] + }) + }, + document: { getElementById: () => null, createElement: () => ({}), head: { appendChild: () => undefined } }, + host: { + request: async () => ({ profiles: [{ name: 'default' }] }), + requestProfile: async (route, method) => { + delivered.push([route.connectionId, route.profile, method]) + return { session_id: 'remote-1' } + }, + state: { + profile: { get: () => 'default', listen: () => undefined }, + connectionId: { get: () => 'local', listen: () => undefined }, + gateway: { listen: () => undefined } + } + } + } + const source = pluginSource + .replace(/^import\s+\*\s+as\s+sdk\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^import\s+\{[\s\S]*?\}\s+from '@hermes\/plugin-sdk'\r?\n/m, '') + .replace(/^const \{ McpTab, ToolsetConfigPanel \} = sdk\r?\n/m, '') + .replace(/^import .* from 'react'\r?\n/m, '') + .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') + .replace('export default {', 'globalThis.plugin = {') + .concat('\nglobalThis.__mention = { $botMeta };\n') + vm.runInNewContext(source, context, { filename: 'plugin.js' }) + context.__mention.$botMeta.set({}) + + const registered = [] + context.plugin.register({ storage: { get: () => null }, register: entry => registered.push(entry) }) + const middleware = registered.find(entry => entry.id === 'mention-middleware') + const result = await middleware.data.handler({ text: '@dixie what is the disk space?' }) + + assert.match(result.text, /stay on this device/i) + assert.doesNotMatch(result.text, /hermes -p 'dixie'/) + await new Promise(resolve => setTimeout(resolve, 0)) + assert.equal(delivered[0][0], 'mac-mini') + assert.equal(delivered[0][1], 'dixie') +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs index 19e0dceaf8..848e030e27 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/multi-source-roster.test.mjs @@ -32,7 +32,7 @@ function runtime() { .replace(/^import .* from 'react\/jsx-runtime'\r?\n/m, '') .replace('export default {', 'globalThis.plugin = {') .concat( - '\nglobalThis.__mergeMultiSourceRoster = mergeMultiSourceRoster;\nglobalThis.__botHandle = botHandle;\nglobalThis.__botRosterKey = botRosterKey;\nglobalThis.__botRosterMeta = botRosterMeta;\nglobalThis.__displayName = displayName;\nglobalThis.__filterBots = filterBots;' + '\nglobalThis.__mergeMultiSourceRoster = mergeMultiSourceRoster;\nglobalThis.__botHandle = botHandle;\nglobalThis.__botRosterKey = botRosterKey;\nglobalThis.__botRosterMeta = botRosterMeta;\nglobalThis.__displayName = displayName;\nglobalThis.__filterBots = filterBots;\nglobalThis.__resolveRosterMentions = resolveRosterMentions;' ) vm.runInNewContext(code, context) return context @@ -514,3 +514,72 @@ test('botRosterKey: same name on two sources yields distinct React keys', () => // Single-source desktops (no connection ids anywhere) keep a stable key. assert.equal(legacyRow, 'legacy::default') }) + +test('resolveRosterMentions: @dixie and @bob-mac-mini hit Connections bots, not this chat', () => { + const { __resolveRosterMentions: resolve } = runtime() + const roster = [ + { name: 'default', connectionId: 'local', connectionKind: 'local', handle: 'default-this-device' }, + { + name: 'dixie', + connectionId: 'mac-mini', + connectionKind: 'ssh', + connectionLabel: 'Mac Mini', + handle: 'dixie', + remoteSource: true, + sourceScoped: true + }, + { + name: 'bob', + connectionId: 'mac-mini', + connectionKind: 'ssh', + connectionLabel: 'Mac Mini', + handle: 'bob-mac-mini', + remoteSource: true, + sourceScoped: true + }, + { + name: 'bob', + connectionId: 'spark', + connectionKind: 'ssh', + connectionLabel: 'Spark', + handle: 'bob-spark', + remoteSource: true, + sourceScoped: true + } + ] + + const hits = resolve('hey @dixie and @bob-spark, ping @bob-mac-mini', roster, { + name: 'default', + connectionId: 'local' + }) + + assert.equal( + hits + .map(bot => `${bot.connectionId}::${bot.name}`) + .sort() + .join(','), + 'mac-mini::bob,mac-mini::dixie,spark::bob' + ) +}) + +test('resolveRosterMentions: @hermes in this chat is not a handoff to yourself', () => { + const { __resolveRosterMentions: resolve } = runtime() + const roster = [ + { name: 'default', connectionId: 'local', handle: 'hermes' }, + { + name: 'default', + connectionId: 'mac-mini', + connectionLabel: 'Mac Mini', + handle: 'default-mac-mini', + remoteSource: true + } + ] + + const hits = resolve('ask @hermes and @default-mac-mini', roster, { + name: 'default', + connectionId: 'local' + }) + + assert.equal(hits.length, 1) + assert.equal(hits[0].connectionId, 'mac-mini') +}) diff --git a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs index de402b903d..0ef6743803 100644 --- a/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs +++ b/apps/desktop/src/plugins/hermes-bots/tests/profile-prewarm.test.mjs @@ -118,7 +118,7 @@ test('behavior: pointer entry prewarms only the hovered bot', () => { assert.deepEqual(warmed, ['alpha']) }) -test('behavior: a thin source row activates its owner and uses that owner\'s chat pin', async () => { +test('behavior: a remote Connections row stays in this chat instead of hopping SSH', async () => { const { ensured, opened, row, warmed } = renderBotRow({ connectionId: 'work', connectionLabel: 'Work', @@ -131,8 +131,8 @@ test('behavior: a thin source row activates its owner and uses that owner\'s cha assert.deepEqual(warmed, [['work', 'research']]) await row.props.onClick() - assert.deepEqual(ensured, [['work', 'research']]) - assert.deepEqual(opened, [['research', 'owner-chat']]) + assert.deepEqual(ensured, []) + assert.deepEqual(opened, []) }) test('behavior: remote default does not open this-device chat when the source did not activate', async () => { @@ -210,7 +210,7 @@ test('behavior: remote default does not open this-device chat when the source di const row = tree.type === 'button' ? tree : tree.props.children[0].props.children await row.props.onClick() - assert.deepEqual(ensured, [['mac-mini', 'default']]) + assert.deepEqual(ensured, []) assert.deepEqual(opened, []) - assert.match(String(errors[0] || ''), /Mac Mini/) + assert.equal(errors.length, 0) })