diff --git a/apps/desktop/electron/connection-registry.test.ts b/apps/desktop/electron/connection-registry.test.ts index a5d7b6206f..79eb04eb75 100644 --- a/apps/desktop/electron/connection-registry.test.ts +++ b/apps/desktop/electron/connection-registry.test.ts @@ -544,6 +544,24 @@ test('roster: unique profiles keep bare handles; duplicates get @name-device', ( assert.equal(roster.length, 4) }) +test('roster: source profile metadata follows the connection-qualified row', () => { + const local = { id: 'local', kind: 'local' as const, label: 'This device' } + const vps = { id: 'vps', kind: 'remote' as const, label: 'VPS', url: 'http://vps:8642' } + const vpsMeta = { + display_name: 'Emma', + ui_meta: { 'hermes-bots': { title: 'Emma', shape: 'blobatar::sun', color: '#8b5cf6' } }, + has_avatar: true + } + + const roster = buildAgentRoster([ + { connection: local, profiles: ['default'] }, + { connection: vps, profiles: ['default'], profileMetadata: { default: vpsMeta } } + ]) + + assert.deepEqual(roster.find(agent => agent.connectionId === 'vps')?.profileMetadata, vpsMeta) + assert.equal(roster.find(agent => agent.connectionId === 'local')?.profileMetadata, undefined) +}) + test('rememberSshEnumeration: live list wins, cache then seed default', () => { assert.deepEqual(rememberSshEnumeration({ profiles: ['bob', 'kai'] }, ['stale'], 'ssh'), { profiles: ['bob', 'kai'] diff --git a/apps/desktop/electron/connection-registry.ts b/apps/desktop/electron/connection-registry.ts index a98a175eac..01ec43d644 100644 --- a/apps/desktop/electron/connection-registry.ts +++ b/apps/desktop/electron/connection-registry.ts @@ -413,6 +413,9 @@ export interface ConnectionAgents { /** Profile names enumerated from the connection, or null when unreachable / * connect-on-demand (ssh not yet dialed). */ profiles: null | string[] + /** Credential-free profile metadata from the same connection. Kept separate + * from `profiles` so old enumerators can continue returning names only. */ + profileMetadata?: Record /** Present when profiles is null: why enumeration was skipped. */ error?: string /** Stable backend identity from the connection's /api/status (`install_id`). @@ -433,6 +436,15 @@ export interface RosterAgent { /** Bare profile name, or `-` when the profile name * exists on more than one registered source (the @name-device rule). */ handle: string + /** Rich metadata for this exact connection + profile, when enumerated. */ + profileMetadata?: RosterProfileMetadata +} + +export interface RosterProfileMetadata { + display_name?: string + title?: string + ui_meta?: Record + has_avatar?: boolean } /** @@ -529,18 +541,30 @@ export function buildAgentRoster( // counting names for @name-device disambiguation. const identities = new Map< string, - { connection: RegistryConnection; installId?: string; order: number; profile: string } + { + connection: RegistryConnection + installId?: string + order: number + profile: string + profileMetadata?: RosterProfileMetadata + } >() let order = 0 - for (const { connection, installId, profiles } of enumerations) { + for (const { connection, installId, profiles, profileMetadata } of enumerations) { for (const profile of profiles || []) { const name = String(profile || '').trim() || 'default' const key = `${connection.id}\0${name}` if (!identities.has(key)) { - identities.set(key, { connection, installId, order, profile: name }) + identities.set(key, { + connection, + installId, + order, + profile: name, + ...(profileMetadata?.[name] ? { profileMetadata: profileMetadata[name] } : {}) + }) } } @@ -551,16 +575,19 @@ export function buildAgentRoster( // are the SAME physical install registered under two addresses, so their // (install, profile) rows are one bot, not two. Connections without an id // (older backends, undialed ssh) keep a per-connection key — no collapse. - const backends = new Map() + const backends = new Map< + string, + { connection: RegistryConnection; order: number; profile: string; profileMetadata?: RosterProfileMetadata }[] + >() - for (const { connection, installId, order: rank, profile } of identities.values()) { + for (const { connection, installId, order: rank, profile, profileMetadata } of identities.values()) { const key = installId ? `id:${installId}\0${profile}` : `conn:${connection.id}\0${profile}` const group = backends.get(key) if (group) { - group.push({ connection, order: rank, profile }) + group.push({ connection, order: rank, profile, profileMetadata }) } else { - backends.set(key, [{ connection, order: rank, profile }]) + backends.set(key, [{ connection, order: rank, profile, profileMetadata }]) } } @@ -577,14 +604,15 @@ export function buildAgentRoster( const roster: RosterAgent[] = [] - for (const { connection, profile } of rows) { + for (const { connection, profile, profileMetadata } of rows) { roster.push({ connectionId: connection.id, connectionKind: connection.kind, connectionLabel: connection.label, profile, targetProfile: connection.remoteProfile || profile, - handle: agentHandle(profile, connection.label, (counts.get(profile) || 0) > 1) + handle: agentHandle(profile, connection.label, (counts.get(profile) || 0) > 1), + ...(profileMetadata ? { profileMetadata } : {}) }) } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 48fb22bcad..5f7c598679 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -145,6 +145,7 @@ import { updateEligibility, upsertConnection } from './connection-registry' +import type { RosterProfileMetadata } from './connection-registry' import { describeCrashReason, installCrashForensics } from './crash-forensics' import { adoptServedDashboardToken } from './dashboard-token' import { loadOrCreateInstallationId, sshOwnershipId } from './desktop-installation' @@ -13706,7 +13707,13 @@ async function enumerateRegistryAgentSources(registry = readDesktopConnectionsRe return Promise.all( registry.connections.map(async connection => { - let raw: { connection: typeof connection; error?: string; installId?: string; profiles: null | string[] } + let raw: { + connection: typeof connection + error?: string + installId?: string + profiles: null | string[] + profileMetadata?: Record + } try { // SSH roster listing must never spawn a dashboard. A stale @@ -13751,13 +13758,52 @@ async function enumerateRegistryAgentSources(registry = readDesktopConnectionsRe ? body.profiles.map(p => String(p?.name || '').trim()).filter(Boolean) : [] + const profileMetadata = Array.isArray(body?.profiles) + ? Object.fromEntries( + body.profiles + .map(profile => { + const name = String(profile?.name || '').trim() + + if (!name) { + return null + } + + const metadata: RosterProfileMetadata = {} + + if (typeof profile?.display_name === 'string' && profile.display_name.trim()) { + metadata.display_name = profile.display_name.trim() + } + + if (typeof profile?.title === 'string' && profile.title.trim()) { + metadata.title = profile.title.trim() + } + + if (profile?.ui_meta && typeof profile.ui_meta === 'object') { + metadata.ui_meta = profile.ui_meta + } + + if (typeof profile?.has_avatar === 'boolean') { + metadata.has_avatar = profile.has_avatar + } + + return [name, metadata] as const + }) + .filter((entry): entry is readonly [string, RosterProfileMetadata] => Boolean(entry)) + ) + : undefined + // The root HERMES_HOME is an agent too; enumerations that omit it // (older backends list only named profiles) still get a default row. if (!profiles.includes('default')) { profiles.unshift('default') } - raw = { connection, profiles, ...(installId ? { installId } : {}) } + raw = { + connection, + profiles, + ...(installId ? { installId } : {}), + ...(profileMetadata ? { profileMetadata } : {}) + } } } catch (error: any) { raw = { connection, profiles: null, error: String(error?.message || error) } @@ -13769,7 +13815,12 @@ async function enumerateRegistryAgentSources(registry = readDesktopConnectionsRe const remembered = rememberSshEnumeration(raw, sshRosterCache.get(connection.id), connection.kind) - return { connection, ...remembered, ...(raw.installId ? { installId: raw.installId } : {}) } + return { + connection, + ...remembered, + ...(raw.installId ? { installId: raw.installId } : {}), + ...(raw.profileMetadata ? { profileMetadata: raw.profileMetadata } : {}) + } }) ) } diff --git a/contributors/emails/tom@spoct.com b/contributors/emails/tom@spoct.com new file mode 100644 index 0000000000..2025548a23 --- /dev/null +++ b/contributors/emails/tom@spoct.com @@ -0,0 +1 @@ +TomSpoct