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:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user