remove the desktop-pet system
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

The pet feature was three loosely coupled subsystems that no core flow
depended on: the Petdex catalog page, the pet install/preference store, and
the pet-state socket. All three are removed.

The only cross-boundary touch point was a try/catch-wrapped observer that
mapped chat-run events to pet animations. Removing it leaves the /chat-run
event contract unchanged: no event name, field name, field type, or emission
timing changed.

Removing petdexPublicRoutes also closes the unauthenticated outbound proxy
at /api/hermes/petdex/asset, which shrinks the SSRF surface.
This commit is contained in:
2026-10-20 13:24:17 +08:00
committed by trae-solo-agent
parent 5e1003ceb7
commit b709ec6742
30 changed files with 64 additions and 1793 deletions
@@ -1,26 +0,0 @@
import type { Context } from 'koa'
import { fetchPetdexAsset, fetchPetdexManifest } from '../../services/hermes/petdex'
export async function manifest(ctx: Context) {
const force = ctx.query.force === '1' || ctx.query.force === 'true'
ctx.body = await fetchPetdexManifest({ force })
}
export async function asset(ctx: Context) {
const url = typeof ctx.query.url === 'string' ? ctx.query.url.trim() : ''
if (!url) {
ctx.status = 400
ctx.body = { error: 'Petdex asset URL is required' }
return
}
try {
const asset = await fetchPetdexAsset(url)
ctx.type = asset.mime
ctx.set('Cache-Control', `public, max-age=${asset.maxAgeSeconds}`)
ctx.body = asset.buffer
} catch (err) {
ctx.status = 400
ctx.body = { error: err instanceof Error ? err.message : 'Petdex asset request failed' }
}
}
@@ -1,50 +0,0 @@
import type { Context } from 'koa'
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
import { adoptPetFromPetdex, getActivePet, updateActivePetPreferences } from '../../services/hermes/pets'
function requestedProfile(ctx: Context): string {
return ctx.state.profile?.name || getActiveProfileName() || 'default'
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Pet request failed'
}
export async function active(ctx: Context) {
ctx.body = { pet: await getActivePet(requestedProfile(ctx)) }
}
export async function adopt(ctx: Context) {
const body = ctx.request.body as { slug?: unknown } | undefined
const slug = typeof body?.slug === 'string' ? body.slug.trim() : ''
if (!slug) {
ctx.status = 400
ctx.body = { error: 'Pet slug is required' }
return
}
try {
ctx.body = { pet: await adoptPetFromPetdex(requestedProfile(ctx), slug) }
} catch (err) {
const message = errorMessage(err)
ctx.status = message.includes('was not found') ? 404 : 400
ctx.body = { error: message }
}
}
export async function updateActive(ctx: Context) {
const body = ctx.request.body as {
scale?: unknown
position?: { x?: unknown; y?: unknown }
enabled?: unknown
} | undefined
const pet = await updateActivePetPreferences(requestedProfile(ctx), {
scale: typeof body?.scale === 'number' ? body.scale : undefined,
enabled: typeof body?.enabled === 'boolean' ? body.enabled : undefined,
position: body?.position && typeof body.position.x === 'number' && typeof body.position.y === 'number'
? { x: body.position.x, y: body.position.y }
: undefined,
})
ctx.body = { pet }
}
-5
View File
@@ -27,7 +27,6 @@ import { scanLanDevices, startLanDiscoveryResponder } from './services/lan-disco
import { getLanPeerSocketManager, getLanPeerSocketPath } from './services/lan-peer-socket'
import { startGlobalAgentServer } from './services/global-agent/server'
import { WorkflowSocketServer } from './services/workflow-socket'
import { PetStateSocketServer } from './services/hermes/pet-state-socket'
import { logger } from './services/logger'
import { createStaticCompressionMiddleware } from './middleware/static-compression'
import { requireUserJwt, resolveUserProfile } from './middleware/user-auth'
@@ -57,7 +56,6 @@ let server: any = null
let servers: any[] = []
let chatRunServer: any = null
let workflowSocketServer: WorkflowSocketServer | null = null
let petStateSocketServer: PetStateSocketServer | null = null
let agentBridgeManager: any = null
interface ListenResult {
@@ -251,9 +249,6 @@ export async function bootstrap() {
workflowSocketServer = new WorkflowSocketServer(groupChatServer.getIO())
workflowSocketServer.init()
petStateSocketServer = new PetStateSocketServer(groupChatServer.getIO())
petStateSocketServer.init()
const loopbackBaseUrl = getLoopbackBaseUrl(server)
startGlobalAgentServer(groupChatServer.getIO(), { localBaseUrl: loopbackBaseUrl })
console.log('[bootstrap] global agent server ready')
@@ -1,8 +0,0 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/petdex'
export const petdexRoutes = new Router()
export const petdexPublicRoutes = new Router()
petdexPublicRoutes.get('/api/hermes/petdex/asset', ctrl.asset)
petdexRoutes.get('/api/hermes/petdex/manifest', ctrl.manifest)
@@ -1,8 +0,0 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/pets'
export const petRoutes = new Router()
petRoutes.get('/api/hermes/pets/active', ctrl.active)
petRoutes.patch('/api/hermes/pets/active', ctrl.updateActive)
petRoutes.post('/api/hermes/pets/adopt', ctrl.adopt)
-5
View File
@@ -43,8 +43,6 @@ import { journeyRoutes } from './hermes/journey'
import { mcpRoutes } from './hermes/mcp'
import { runtimeVersionRoutes } from './hermes/runtime-versions'
import { writeGateRoutes } from './hermes/write-gate'
import { petdexPublicRoutes, petdexRoutes } from './hermes/petdex'
import { petRoutes } from './hermes/pets'
/**
* Register all routes on the Koa app.
@@ -59,7 +57,6 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne
app.use(devicePublicRoutes.routes())
app.use(ttsRoutes.routes())
app.use(apiDocsRoutes.routes())
app.use(petdexPublicRoutes.routes())
// --- Auth middleware: all routes below require authentication ---
authMiddleware.forEach((middleware) => app.use(middleware))
@@ -102,6 +99,4 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne
app.use(mcpRoutes.routes()) // MCP management
app.use(runtimeVersionRoutes.routes()) // Runtime and version management
app.use(writeGateRoutes.routes()) // Hermes Agent write approval review
app.use(petdexRoutes.routes())
app.use(petRoutes.routes())
}
@@ -1,291 +0,0 @@
import type { Server, Socket } from 'socket.io'
import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../middleware/user-auth'
import { userCanAccessProfile } from '../../db/hermes/users-store'
import { logger } from '../logger'
const PET_STATE_NAMESPACE = '/pet-state'
const DEFAULT_PROFILE = 'default'
export interface PetActivity {
busy?: boolean
reasoning?: boolean
toolRunning?: boolean
awaitingInput?: boolean
error?: boolean
justCompleted?: boolean
celebrate?: boolean
}
export type PetState = 'idle' | 'run' | 'review' | 'failed' | 'wave' | 'jump' | 'waiting'
export interface PetStateSnapshot {
profile: string
state: PetState
activity: PetActivity
updatedAt: number
sessionId?: string
runId?: string
activeTools: string[]
awaiting: Array<'approval' | 'clarify' | 'input'>
}
export interface PetActivityUpdate {
profile?: string | null
activity: PetActivity
replace?: boolean
}
function normalizeProfile(value: unknown): string {
const profile = typeof value === 'string' ? value.trim() : ''
return profile || DEFAULT_PROFILE
}
export function derivePetState(activity: PetActivity): PetState {
if (activity.error) return 'failed'
if (activity.celebrate) return 'jump'
if (activity.justCompleted) return 'wave'
if (activity.awaitingInput) return 'waiting'
if (activity.toolRunning) return 'run'
if (activity.reasoning) return 'review'
if (activity.busy) return 'run'
return 'idle'
}
function canAccessProfile(user: AuthenticatedUser | undefined, profile: string): boolean {
if (!user || user.role === 'super_admin') return true
return userCanAccessProfile(user.id, profile)
}
class PetStateRegistry {
private snapshots = new Map<string, PetStateSnapshot>()
private runProfiles = new Map<string, string>()
private flashTimers = new Map<string, ReturnType<typeof setTimeout>>()
get(profile: string): PetStateSnapshot {
const normalized = normalizeProfile(profile)
return this.snapshots.get(normalized) ?? {
profile: normalized,
state: 'idle',
activity: {},
updatedAt: Date.now(),
activeTools: [],
awaiting: [],
}
}
update(update: PetActivityUpdate): PetStateSnapshot {
const profile = normalizeProfile(update.profile)
const previous = this.get(profile)
const activity = update.replace ? { ...update.activity } : { ...previous.activity, ...update.activity }
const snapshot: PetStateSnapshot = {
...previous,
profile,
state: derivePetState(activity),
activity,
updatedAt: Date.now(),
}
this.snapshots.set(profile, snapshot)
return snapshot
}
applyRunChatEvent(profileInput: string | null | undefined, event: string, payload: Record<string, unknown>): PetStateSnapshot {
const sessionId = stringValue(payload.session_id)
const runId = stringValue(payload.run_id)
const knownProfile = sessionId ? this.runProfiles.get(sessionId) : ''
const profile = normalizeProfile(profileInput || knownProfile)
if (sessionId) this.runProfiles.set(sessionId, profile)
const previous = this.get(profile)
const tools = new Set(previous.activeTools)
const awaiting = new Set(previous.awaiting)
let activity: PetActivity = { ...previous.activity }
switch (event) {
case 'run.queued':
case 'run.started':
activity = { busy: true }
awaiting.clear()
tools.clear()
break
case 'tool.started':
activity.busy = true
activity.toolRunning = true
tools.add(stringValue(payload.tool_name) || stringValue(payload.toolName) || stringValue(payload.name) || 'tool')
break
case 'tool.completed':
case 'tool.failed': {
const toolName = stringValue(payload.tool_name) || stringValue(payload.toolName) || stringValue(payload.name)
if (toolName) tools.delete(toolName)
else tools.clear()
activity.toolRunning = tools.size > 0
break
}
case 'reasoning.delta':
case 'thinking.delta':
case 'reasoning.available':
case 'moa.aggregating':
activity.busy = true
activity.reasoning = true
break
case 'message.delta':
activity.busy = true
activity.reasoning = false
break
case 'approval.requested':
awaiting.add('approval')
activity.busy = false
activity.awaitingInput = true
activity.reasoning = false
activity.toolRunning = false
break
case 'clarify.requested':
awaiting.add('clarify')
activity.busy = false
activity.awaitingInput = true
activity.reasoning = false
activity.toolRunning = false
break
case 'approval.resolved':
awaiting.delete('approval')
activity.awaitingInput = awaiting.size > 0
break
case 'clarify.resolved':
awaiting.delete('clarify')
activity.awaitingInput = awaiting.size > 0
break
case 'run.completed':
activity = { justCompleted: true }
awaiting.clear()
tools.clear()
this.scheduleFlashClear(profile, 'justCompleted')
break
case 'run.failed':
activity = { error: true }
awaiting.clear()
tools.clear()
this.scheduleFlashClear(profile, 'error')
break
case 'abort.completed':
activity = {}
awaiting.clear()
tools.clear()
break
default:
break
}
const snapshot: PetStateSnapshot = {
profile,
sessionId: sessionId || previous.sessionId,
runId: runId || previous.runId,
state: derivePetState(activity),
activity,
updatedAt: Date.now(),
activeTools: Array.from(tools),
awaiting: Array.from(awaiting),
}
this.snapshots.set(profile, snapshot)
return snapshot
}
private scheduleFlashClear(profile: string, key: 'error' | 'justCompleted'): void {
const existing = this.flashTimers.get(profile)
if (existing) clearTimeout(existing)
const timer = setTimeout(() => {
const current = this.get(profile)
if (!current.activity[key]) return
const activity = { ...current.activity, [key]: false }
const snapshot: PetStateSnapshot = {
...current,
state: derivePetState(activity),
activity,
updatedAt: Date.now(),
}
this.snapshots.set(profile, snapshot)
activeServer?.broadcast(snapshot)
}, 1600)
this.flashTimers.set(profile, timer)
}
}
const registry = new PetStateRegistry()
let activeServer: PetStateSocketServer | null = null
export function getPetStateSnapshot(profile?: string | null): PetStateSnapshot {
return registry.get(normalizeProfile(profile))
}
export function publishPetActivity(update: PetActivityUpdate): PetStateSnapshot {
const snapshot = registry.update(update)
activeServer?.broadcast(snapshot)
return snapshot
}
export function observeRunChatPetEvent(profile: string | null | undefined, event: string, payload: Record<string, unknown>): PetStateSnapshot {
const snapshot = registry.applyRunChatEvent(profile, event, payload)
activeServer?.broadcast(snapshot)
return snapshot
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
export class PetStateSocketServer {
private readonly nsp: ReturnType<Server['of']>
constructor(io: Server) {
this.nsp = io.of(PET_STATE_NAMESPACE)
activeServer = this
}
init(): void {
this.nsp.use(this.authMiddleware.bind(this))
this.nsp.on('connection', this.onConnection.bind(this))
logger.info('[pet-state-socket] Socket.IO ready at /pet-state')
}
broadcast(snapshot: PetStateSnapshot): void {
this.nsp.to(this.profileRoom(snapshot.profile)).emit('pet.state.updated', snapshot)
}
private async authMiddleware(socket: Socket, next: (err?: Error) => void): Promise<void> {
if (!await isAuthEnabled()) {
next()
return
}
const token = socket.handshake.auth?.token as string | undefined
const user = await authenticateUserToken(token || '')
if (!user) {
next(new Error('Authentication failed'))
return
}
const profile = normalizeProfile(socket.handshake.query?.profile)
if (!canAccessProfile(user, profile)) {
next(new Error('Profile access denied'))
return
}
socket.data.user = user
socket.data.profile = profile
next()
}
private onConnection(socket: Socket): void {
const profile = normalizeProfile(socket.data.profile)
void socket.join(this.profileRoom(profile))
socket.emit('pet.state.snapshot', registry.get(profile))
socket.on('pet.state.get', (ack?: (snapshot: PetStateSnapshot) => void) => {
if (typeof ack === 'function') {
ack(registry.get(profile))
}
})
}
private profileRoom(profile: string): string {
return `profile:${normalizeProfile(profile)}`
}
}
@@ -1,137 +0,0 @@
export interface PetdexPet {
slug: string
displayName: string
kind: string
submittedBy: string
spritesheetUrl: string
previewUrl?: string
petJsonUrl: string
zipUrl: string
}
export interface PetdexManifest {
generatedAt: string
total: number
pets: PetdexPet[]
}
const PETDEX_MANIFEST_URL = 'https://assets.petdex.dev/manifests/petdex-v1.json'
const CACHE_TTL_MS = 5 * 60 * 1000
const FETCH_TIMEOUT_MS = 15_000
const MAX_ASSET_BYTES = 10 * 1024 * 1024
let cache: { expiresAt: number; manifest: PetdexManifest } | null = null
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function normalizePet(value: unknown): PetdexPet | null {
if (!value || typeof value !== 'object') return null
const item = value as Record<string, unknown>
const slug = asString(item.slug).trim()
const spritesheetUrl = asString(item.spritesheetUrl).trim()
if (!slug || !spritesheetUrl) return null
return {
slug,
displayName: asString(item.displayName).trim() || slug,
kind: asString(item.kind).trim() || 'pet',
submittedBy: asString(item.submittedBy).trim(),
spritesheetUrl,
previewUrl: `/api/hermes/petdex/asset?url=${encodeURIComponent(spritesheetUrl)}`,
petJsonUrl: asString(item.petJsonUrl).trim(),
zipUrl: asString(item.zipUrl).trim(),
}
}
function assertPetdexAssetUrl(value: string): URL {
const url = new URL(value)
const host = url.hostname.toLowerCase()
if (url.protocol !== 'https:' || (host !== 'petdex.dev' && !host.endsWith('.petdex.dev'))) {
throw new Error('Unsupported petdex asset host')
}
return url
}
function imageMimeFromResponse(response: Response, pathname: string): string {
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
if (contentType?.startsWith('image/')) return contentType
const lower = pathname.toLowerCase()
if (lower.endsWith('.webp')) return 'image/webp'
if (lower.endsWith('.png')) return 'image/png'
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'
return 'application/octet-stream'
}
function normalizeManifest(value: unknown): PetdexManifest {
if (!value || typeof value !== 'object') {
throw new Error('petdex manifest is not an object')
}
const data = value as Record<string, unknown>
const pets = Array.isArray(data.pets) ? data.pets.map(normalizePet).filter((pet): pet is PetdexPet => Boolean(pet)) : []
if (pets.length === 0) {
throw new Error('petdex manifest has no pets')
}
return {
generatedAt: asString(data.generatedAt),
total: typeof data.total === 'number' ? data.total : pets.length,
pets,
}
}
export async function fetchPetdexManifest(options: { force?: boolean } = {}): Promise<PetdexManifest> {
const now = Date.now()
if (!options.force && cache && cache.expiresAt > now) {
return cache.manifest
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(PETDEX_MANIFEST_URL, {
headers: { 'User-Agent': 'hermes-ui-petdex' },
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`petdex manifest request failed: ${response.status}`)
}
const manifest = normalizeManifest(await response.json())
cache = { expiresAt: now + CACHE_TTL_MS, manifest }
return manifest
} finally {
clearTimeout(timeout)
}
}
export async function fetchPetdexAsset(urlValue: string): Promise<{ buffer: Buffer; mime: string; maxAgeSeconds: number }> {
const url = assertPetdexAssetUrl(urlValue)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'hermes-ui-petdex' },
signal: controller.signal,
})
if (!response.ok) throw new Error(`petdex asset request failed: ${response.status}`)
const length = Number(response.headers.get('content-length') || '0')
if (Number.isFinite(length) && length > MAX_ASSET_BYTES) {
throw new Error('petdex asset is too large')
}
const arrayBuffer = await response.arrayBuffer()
if (arrayBuffer.byteLength > MAX_ASSET_BYTES) {
throw new Error('petdex asset is too large')
}
return {
buffer: Buffer.from(arrayBuffer),
mime: imageMimeFromResponse(response, url.pathname),
maxAgeSeconds: 60 * 60,
}
} finally {
clearTimeout(timeout)
}
}
-305
View File
@@ -1,305 +0,0 @@
import { existsSync } from 'fs'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
import { getWebUiHome } from '../../config'
import { fetchPetdexManifest, type PetdexPet } from './petdex'
export type ActivePetState = 'idle' | 'run' | 'review' | 'failed' | 'wave' | 'jump' | 'waiting'
export interface InstalledPet {
slug: string
displayName: string
kind: string
submittedBy: string
source: 'petdex'
spritesheetUrl: string
petJsonUrl: string
zipUrl: string
spritesheetFile: string
petJsonFile?: string
mime: string
installedAt: number
updatedAt: number
}
export interface ActivePetConfig {
enabled: boolean
slug: string
scale: number
position?: {
x: number
y: number
}
updatedAt: number
}
export interface ActivePetResponse {
enabled: boolean
slug: string
displayName: string
kind: string
submittedBy: string
source: 'petdex'
mime: string
spritesheetDataUrl: string
spritesheetRevision: number
frameW: number
frameH: number
framesPerState: number
loopMs: number
scale: number
position?: {
x: number
y: number
}
stateRows: string[]
installedAt: number
updatedAt: number
}
const FRAME_W = 192
const FRAME_H = 208
const FRAMES_PER_STATE = 6
const LOOP_MS = 1100
const DEFAULT_SCALE = 0.33
const STATE_ROWS = [
'idle',
'running-right',
'running-left',
'waving',
'jumping',
'failed',
'waiting',
'running',
'review',
]
const MAX_SPRITESHEET_BYTES = 10 * 1024 * 1024
const MAX_JSON_BYTES = 512 * 1024
const FETCH_TIMEOUT_MS = 20_000
function profileMetadataRoot(): string {
return join(getWebUiHome(), 'profile-metadata')
}
function profileMetadataDir(name: string): string {
const segment = Buffer.from(name || 'default', 'utf-8').toString('base64url')
return join(profileMetadataRoot(), segment)
}
function petsRoot(profile: string): string {
return join(profileMetadataDir(profile), 'pets')
}
function activePetPath(profile: string): string {
return join(petsRoot(profile), 'active.json')
}
function petDir(profile: string, slug: string): string {
return join(petsRoot(profile), safeSlug(slug))
}
function petMetaPath(profile: string, slug: string): string {
return join(petDir(profile, slug), 'pet.json')
}
function safeSlug(slug: string): string {
const normalized = String(slug || '').trim().toLowerCase()
const safe = normalized.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
if (!safe) throw new Error('Pet slug is required')
return safe.slice(0, 120)
}
function assertPetdexAssetUrl(value: string): URL {
const url = new URL(value)
const host = url.hostname.toLowerCase()
if (url.protocol !== 'https:' || (host !== 'petdex.dev' && !host.endsWith('.petdex.dev'))) {
throw new Error('Unsupported pet asset host')
}
return url
}
function mimeFromResponse(response: Response, fallbackUrl: string): string {
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
if (contentType?.startsWith('image/')) return contentType
if (fallbackUrl.endsWith('.webp')) return 'image/webp'
if (fallbackUrl.endsWith('.png')) return 'image/png'
return 'application/octet-stream'
}
async function fetchBytes(urlValue: string, maxBytes: number): Promise<{ buffer: Buffer; mime: string }> {
const url = assertPetdexAssetUrl(urlValue)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(url, {
headers: { 'User-Agent': 'hermes-ui-pets' },
signal: controller.signal,
})
if (!response.ok) throw new Error(`Pet asset request failed: ${response.status}`)
const length = Number(response.headers.get('content-length') || '0')
if (Number.isFinite(length) && length > maxBytes) {
throw new Error('Pet asset is too large')
}
const arrayBuffer = await response.arrayBuffer()
if (arrayBuffer.byteLength > maxBytes) {
throw new Error('Pet asset is too large')
}
return {
buffer: Buffer.from(arrayBuffer),
mime: mimeFromResponse(response, url.pathname.toLowerCase()),
}
} finally {
clearTimeout(timeout)
}
}
async function fetchTextAsset(urlValue: string, maxBytes: number): Promise<string | null> {
if (!urlValue) return null
const { buffer } = await fetchBytes(urlValue, maxBytes)
return buffer.toString('utf-8')
}
async function readJsonFile<T>(filePath: string): Promise<T | null> {
try {
return JSON.parse(await readFile(filePath, 'utf-8')) as T
} catch {
return null
}
}
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
await mkdir(dirname(filePath), { recursive: true })
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 })
}
function normalizeInstalledPet(pet: PetdexPet, asset: { mime: string }, now: number): InstalledPet {
return {
slug: pet.slug,
displayName: pet.displayName || pet.slug,
kind: pet.kind || 'pet',
submittedBy: pet.submittedBy || '',
source: 'petdex',
spritesheetUrl: pet.spritesheetUrl,
petJsonUrl: pet.petJsonUrl,
zipUrl: pet.zipUrl,
spritesheetFile: 'spritesheet.webp',
petJsonFile: pet.petJsonUrl ? 'petdex-pet.json' : undefined,
mime: asset.mime,
installedAt: now,
updatedAt: now,
}
}
export async function adoptPetFromPetdex(profile: string, slugInput: string): Promise<ActivePetResponse> {
const slug = safeSlug(slugInput)
const manifest = await fetchPetdexManifest()
const pet = manifest.pets.find(item => safeSlug(item.slug) === slug)
if (!pet) throw new Error(`Pet "${slugInput}" was not found in petdex`)
const targetDir = petDir(profile, pet.slug)
await mkdir(targetDir, { recursive: true })
const spritesheet = await fetchBytes(pet.spritesheetUrl, MAX_SPRITESHEET_BYTES)
await writeFile(join(targetDir, 'spritesheet.webp'), spritesheet.buffer, { mode: 0o600 })
if (pet.petJsonUrl) {
const petJson = await fetchTextAsset(pet.petJsonUrl, MAX_JSON_BYTES)
if (petJson) await writeFile(join(targetDir, 'petdex-pet.json'), petJson, { encoding: 'utf-8', mode: 0o600 })
}
const now = Date.now()
const installed = normalizeInstalledPet(pet, spritesheet, now)
const active: ActivePetConfig = {
enabled: true,
slug: installed.slug,
scale: DEFAULT_SCALE,
updatedAt: now,
}
await writeJsonFile(petMetaPath(profile, pet.slug), installed)
await writeJsonFile(activePetPath(profile), active)
const response = await buildActivePetResponse(profile, installed, active)
if (!response) throw new Error('Installed pet asset is missing')
return response
}
export async function getActivePet(profile: string): Promise<ActivePetResponse | null> {
const active = await readJsonFile<ActivePetConfig>(activePetPath(profile))
if (!active?.enabled || !active.slug) return null
const installed = await readJsonFile<InstalledPet>(petMetaPath(profile, active.slug))
if (!installed) return null
return buildActivePetResponse(profile, installed, active)
}
export async function updateActivePetPreferences(
profile: string,
input: { scale?: number; position?: { x?: number; y?: number }; enabled?: boolean },
): Promise<ActivePetResponse | null> {
const active = await readJsonFile<ActivePetConfig>(activePetPath(profile))
if (!active?.slug) return null
const next: ActivePetConfig = {
...active,
updatedAt: Date.now(),
}
if (typeof input.enabled === 'boolean') {
next.enabled = input.enabled
}
if (typeof input.scale === 'number' && Number.isFinite(input.scale)) {
next.scale = Math.min(1.2, Math.max(0.18, input.scale))
}
if (input.position && typeof input.position.x === 'number' && typeof input.position.y === 'number') {
next.position = {
x: Math.round(Math.max(0, input.position.x)),
y: Math.round(Math.max(0, input.position.y)),
}
}
await writeJsonFile(activePetPath(profile), next)
if (!next.enabled) return null
return getActivePet(profile)
}
async function buildActivePetResponse(
profile: string,
installed: InstalledPet,
active: ActivePetConfig,
): Promise<ActivePetResponse | null> {
const filePath = join(petDir(profile, installed.slug), installed.spritesheetFile || 'spritesheet.webp')
if (!existsSync(filePath)) return null
const data = await readFile(filePath)
const spritesheetRevision = installed.updatedAt || installed.installedAt || 0
const updatedAt = Math.max(active.updatedAt || 0, spritesheetRevision)
return {
enabled: active.enabled,
slug: installed.slug,
displayName: installed.displayName,
kind: installed.kind,
submittedBy: installed.submittedBy,
source: installed.source,
mime: installed.mime || 'image/webp',
spritesheetDataUrl: `data:${installed.mime || 'image/webp'};base64,${data.toString('base64')}`,
frameW: FRAME_W,
frameH: FRAME_H,
framesPerState: FRAMES_PER_STATE,
loopMs: LOOP_MS,
scale: typeof active.scale === 'number' && active.scale > 0 ? active.scale : DEFAULT_SCALE,
position: active.position && Number.isFinite(active.position.x) && Number.isFinite(active.position.y)
? { x: active.position.x, y: active.position.y }
: undefined,
stateRows: STATE_ROWS,
installedAt: installed.installedAt,
spritesheetRevision,
updatedAt,
}
}
@@ -35,7 +35,6 @@ import { markAbortCompleted } from './abort'
import { writeModelRunProfileToken } from './model-run-prompt'
import type { AuthenticatedUser } from '../../../middleware/user-auth'
import { ensureHermesRunWorkspace } from './workspace'
import { observeRunChatPetEvent } from '../pet-state-socket'
import { completeWorkspaceRunCheckpoint, startWorkspaceRunCheckpoint } from './workspace-diff-tracker'
const BRIDGE_USAGE_FLUSH_DELAY_MS = 200
@@ -462,7 +461,6 @@ export async function handleBridgeRun(
}
const emit = (event: string, payload: any) => {
const tagged = { ...payload, session_id }
observePetEvent(profile, event, tagged)
data.onEvent?.(event, tagged)
nsp.to(`session:${session_id}`).emit(event, tagged)
if (!data.onEvent && !nsp.adapter.rooms.get(`session:${session_id}`)?.size && socket.connected) {
@@ -735,7 +733,6 @@ export async function resumeBridgeRun(
const emit = (event: string, payload: any) => {
const tagged = { ...payload, session_id: sessionId }
observePetEvent(profile, event, tagged)
nsp.to(`session:${sessionId}`).emit(event, tagged)
if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) {
socket.emit(event, tagged)
@@ -851,14 +848,6 @@ export async function resumeBridgeRun(
}
}
function observePetEvent(profile: string, event: string, payload: Record<string, unknown>): void {
try {
observeRunChatPetEvent(profile, event, payload)
} catch (err) {
logger.debug(err, '[chat-run-socket] failed to update pet state')
}
}
async function refreshFinalContextUsage(args: {
sessionId: string
profile: string
@@ -24,7 +24,6 @@ import { contentBlocksToString } from './content-blocks'
import type { ChatRunSource, ContentBlock, QueuedRun, SessionState } from './types'
import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../../middleware/user-auth'
import { userCanAccessProfile } from '../../../db/hermes/users-store'
import { observeRunChatPetEvent } from '../pet-state-socket'
export type { ContentBlock } from './types'
@@ -798,8 +797,6 @@ export class ChatRunSocket {
emitExternalEvent(sessionId: string, event: string, payload: any) {
const tagged = { ...payload, session_id: sessionId }
const profile = this.resolvePetEventProfile(sessionId, tagged)
this.observePetEvent(profile, event, tagged)
const state = this.sessionMap.get(sessionId)
if (state?.isWorking) {
state.events.push({ event, data: tagged })
@@ -932,8 +929,6 @@ export class ChatRunSocket {
private emitToSession(socket: Socket, sessionId: string, event: string, payload: any) {
const tagged = { ...payload, session_id: sessionId }
const profile = this.resolvePetEventProfile(sessionId, tagged)
this.observePetEvent(profile, event, tagged)
this.nsp.to(`session:${sessionId}`).emit(event, tagged)
if (!this.nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) {
socket.emit(event, tagged)
@@ -968,21 +963,4 @@ export class ChatRunSocket {
this.sessionMap.clear()
logger.info('[chat-run-socket] closed all connections and cleared state')
}
private resolvePetEventProfile(sessionId: string, payload: Record<string, unknown>): string {
const payloadProfile = typeof payload.profile === 'string' ? payload.profile.trim() : ''
if (payloadProfile) return payloadProfile
const stateProfile = this.sessionMap.get(sessionId)?.profile
if (stateProfile) return stateProfile
const storedProfile = getSession(sessionId)?.profile
return storedProfile || 'default'
}
private observePetEvent(profile: string, event: string, payload: Record<string, unknown>): void {
try {
observeRunChatPetEvent(profile, event, payload)
} catch (err) {
logger.debug(err, '[chat-run-socket] failed to update pet state')
}
}
}