fix(desktop): mention Connections bots from this local chat

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.
This commit is contained in:
Al Zilla
2026-08-17 14:03:52 -06:00
committed by Teknium
parent 30299efa38
commit f89f884a5d
4 changed files with 351 additions and 45 deletions
+213 -39
View File
@@ -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)) + '): <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.]'
}
// 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)) + '): <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 }
} }
@@ -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')
})
@@ -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')
})
@@ -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)
})