From 409a16060b9c41b08d5b73e0f5ae1da346d156b4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 20 Jan 2026 12:16:36 +0000 Subject: [PATCH] feat: enrich presence with roles --- .../ClawdbotProtocol/GatewayModels.swift | 12 +++++++++ .../ClawdbotProtocol/GatewayModels.swift | 12 +++++++++ src/gateway/protocol/schema/snapshot.ts | 3 +++ src/gateway/server-methods/system.ts | 12 +++++++++ .../server/ws-connection/message-handler.ts | 3 +++ src/infra/system-presence.ts | 26 +++++++++++++++++++ ui/src/ui/types.ts | 3 +++ ui/src/ui/views/instances.ts | 10 +++++++ 8 files changed, 81 insertions(+) diff --git a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift index 3b5b96258..de4d960dc 100644 --- a/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdbotProtocol/GatewayModels.swift @@ -205,6 +205,9 @@ public struct PresenceEntry: Codable, Sendable { public let tags: [String]? public let text: String? public let ts: Int + public let deviceid: String? + public let roles: [String]? + public let scopes: [String]? public let instanceid: String? public init( @@ -220,6 +223,9 @@ public struct PresenceEntry: Codable, Sendable { tags: [String]?, text: String?, ts: Int, + deviceid: String?, + roles: [String]?, + scopes: [String]?, instanceid: String? ) { self.host = host @@ -234,6 +240,9 @@ public struct PresenceEntry: Codable, Sendable { self.tags = tags self.text = text self.ts = ts + self.deviceid = deviceid + self.roles = roles + self.scopes = scopes self.instanceid = instanceid } private enum CodingKeys: String, CodingKey { @@ -249,6 +258,9 @@ public struct PresenceEntry: Codable, Sendable { case tags case text case ts + case deviceid = "deviceId" + case roles + case scopes case instanceid = "instanceId" } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift index 32c424f99..dd01ffe70 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotProtocol/GatewayModels.swift @@ -205,6 +205,9 @@ public struct PresenceEntry: Codable, Sendable { public let tags: [String]? public let text: String? public let ts: Int + public let deviceid: String? + public let roles: [String]? + public let scopes: [String]? public let instanceid: String? public init( @@ -220,6 +223,9 @@ public struct PresenceEntry: Codable, Sendable { tags: [String]?, text: String?, ts: Int, + deviceid: String?, + roles: [String]?, + scopes: [String]?, instanceid: String? ) { self.host = host @@ -234,6 +240,9 @@ public struct PresenceEntry: Codable, Sendable { self.tags = tags self.text = text self.ts = ts + self.deviceid = deviceid + self.roles = roles + self.scopes = scopes self.instanceid = instanceid } private enum CodingKeys: String, CodingKey { @@ -249,6 +258,9 @@ public struct PresenceEntry: Codable, Sendable { case tags case text case ts + case deviceid = "deviceId" + case roles + case scopes case instanceid = "instanceId" } } diff --git a/src/gateway/protocol/schema/snapshot.ts b/src/gateway/protocol/schema/snapshot.ts index efd899eca..764b25734 100644 --- a/src/gateway/protocol/schema/snapshot.ts +++ b/src/gateway/protocol/schema/snapshot.ts @@ -15,6 +15,9 @@ export const PresenceEntrySchema = Type.Object( tags: Type.Optional(Type.Array(NonEmptyString)), text: Type.Optional(Type.String()), ts: Type.Integer({ minimum: 0 }), + deviceId: Type.Optional(NonEmptyString), + roles: Type.Optional(Type.Array(NonEmptyString)), + scopes: Type.Optional(Type.Array(NonEmptyString)), instanceId: Type.Optional(NonEmptyString), }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/system.ts b/src/gateway/server-methods/system.ts index 4533a1cdd..66d6eb3de 100644 --- a/src/gateway/server-methods/system.ts +++ b/src/gateway/server-methods/system.ts @@ -37,6 +37,7 @@ export const systemHandlers: GatewayRequestHandlers = { return; } const sessionKey = resolveMainSessionKeyFromConfig(); + const deviceId = typeof params.deviceId === "string" ? params.deviceId : undefined; const instanceId = typeof params.instanceId === "string" ? params.instanceId : undefined; const host = typeof params.host === "string" ? params.host : undefined; const ip = typeof params.ip === "string" ? params.ip : undefined; @@ -51,12 +52,21 @@ export const systemHandlers: GatewayRequestHandlers = { ? params.lastInputSeconds : undefined; const reason = typeof params.reason === "string" ? params.reason : undefined; + const roles = + Array.isArray(params.roles) && params.roles.every((t) => typeof t === "string") + ? (params.roles as string[]) + : undefined; + const scopes = + Array.isArray(params.scopes) && params.scopes.every((t) => typeof t === "string") + ? (params.scopes as string[]) + : undefined; const tags = Array.isArray(params.tags) && params.tags.every((t) => typeof t === "string") ? (params.tags as string[]) : undefined; const presenceUpdate = updateSystemPresence({ text, + deviceId, instanceId, host, ip, @@ -67,6 +77,8 @@ export const systemHandlers: GatewayRequestHandlers = { modelIdentifier, lastInputSeconds, reason, + roles, + scopes, tags, }); const isNodePresenceLine = text.startsWith("Node:"); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 1f46f1f2a..a8491c32e 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -619,6 +619,9 @@ export function attachGatewayWsMessageHandler(params: { deviceFamily: connectParams.client.deviceFamily, modelIdentifier: connectParams.client.modelIdentifier, mode: connectParams.client.mode, + deviceId: connectParams.device?.id, + roles: [role], + scopes, instanceId: connectParams.device?.id ?? instanceId, reason: "connect", }); diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index d963116c0..0e5d453ac 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -11,6 +11,9 @@ export type SystemPresence = { lastInputSeconds?: number; mode?: string; reason?: string; + deviceId?: string; + roles?: string[]; + scopes?: string[]; instanceId?: string; text: string; ts: number; @@ -153,6 +156,7 @@ function parsePresence(text: string): SystemPresence { type SystemPresencePayload = { text: string; + deviceId?: string; instanceId?: string; host?: string; ip?: string; @@ -163,13 +167,28 @@ type SystemPresencePayload = { lastInputSeconds?: number; mode?: string; reason?: string; + roles?: string[]; + scopes?: string[]; tags?: string[]; }; +function mergeStringList(...values: Array): string[] | undefined { + const out = new Set(); + for (const list of values) { + if (!Array.isArray(list)) continue; + for (const item of list) { + const trimmed = String(item).trim(); + if (trimmed) out.add(trimmed); + } + } + return out.size > 0 ? [...out] : undefined; +} + export function updateSystemPresence(payload: SystemPresencePayload): SystemPresenceUpdate { ensureSelfPresence(); const parsed = parsePresence(payload.text); const key = + normalizePresenceKey(payload.deviceId) || normalizePresenceKey(payload.instanceId) || normalizePresenceKey(parsed.instanceId) || normalizePresenceKey(parsed.host) || @@ -191,6 +210,9 @@ export function updateSystemPresence(payload: SystemPresencePayload): SystemPres lastInputSeconds: payload.lastInputSeconds ?? parsed.lastInputSeconds ?? existing.lastInputSeconds, reason: payload.reason ?? parsed.reason ?? existing.reason, + deviceId: payload.deviceId ?? existing.deviceId, + roles: mergeStringList(existing.roles, payload.roles), + scopes: mergeStringList(existing.scopes, payload.scopes), instanceId: payload.instanceId ?? parsed.instanceId ?? existing.instanceId, text: payload.text || parsed.text || existing.text, ts: Date.now(), @@ -221,9 +243,13 @@ export function upsertPresence(key: string, presence: Partial) { ensureSelfPresence(); const normalizedKey = normalizePresenceKey(key) ?? os.hostname().toLowerCase(); const existing = entries.get(normalizedKey) ?? ({} as SystemPresence); + const roles = mergeStringList(existing.roles, presence.roles); + const scopes = mergeStringList(existing.scopes, presence.scopes); const merged: SystemPresence = { ...existing, ...presence, + roles, + scopes, ts: Date.now(), text: presence.text || diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 5244233af..2a492cfdc 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -246,6 +246,7 @@ export type ConfigSchemaResponse = { }; export type PresenceEntry = { + deviceId?: string | null; instanceId?: string | null; host?: string | null; ip?: string | null; @@ -256,6 +257,8 @@ export type PresenceEntry = { mode?: string | null; lastInputSeconds?: number | null; reason?: string | null; + roles?: string[] | null; + scopes?: string[] | null; text?: string | null; ts?: number | null; }; diff --git a/ui/src/ui/views/instances.ts b/ui/src/ui/views/instances.ts index dc9dad2f5..43a4f4191 100644 --- a/ui/src/ui/views/instances.ts +++ b/ui/src/ui/views/instances.ts @@ -48,6 +48,14 @@ function renderEntry(entry: PresenceEntry) { ? `${entry.lastInputSeconds}s ago` : "n/a"; const mode = entry.mode ?? "unknown"; + const roles = Array.isArray(entry.roles) ? entry.roles.filter(Boolean) : []; + const scopes = Array.isArray(entry.scopes) ? entry.scopes.filter(Boolean) : []; + const scopesLabel = + scopes.length > 0 + ? scopes.length > 3 + ? `${scopes.length} scopes` + : `scopes: ${scopes.join(", ")}` + : null; return html`
@@ -55,6 +63,8 @@ function renderEntry(entry: PresenceEntry) {
${formatPresenceSummary(entry)}
${mode} + ${roles.map((role) => html`${role}`)} + ${scopesLabel ? html`${scopesLabel}` : nothing} ${entry.platform ? html`${entry.platform}` : nothing} ${entry.deviceFamily ? html`${entry.deviceFamily}`