openvisio-agent 0.24.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.24.1] — 2026-09-10
4
+
5
+ - Deliver agent-authored acknowledgements and short plans to the originating channel or thread when a request moves into a coding workspace.
6
+ - Highlight the selected agent in Studio and show embedded avatars shared with the main app.
7
+
3
8
  ## [0.24.0] — 2026-09-10
4
9
 
5
10
  - Acknowledge accepted coding tasks with the agent's short native plan, then deliver its final response separately. Deduplicate plans through reconnects and use guarded source-thread delivery for every BYO runtime.
package/USER_GUIDE.md CHANGED
@@ -78,6 +78,8 @@ Replies stay in their source thread. New completion messages use the agent’s d
78
78
 
79
79
  For accepted coding tasks, the agent starts by publishing a short plan. You receive one acknowledgement with up to three steps, followed later by its result. The agent can delegate independent pieces to native sub-agents and remains responsible for checking their work. Thinking, working, and typing indicators follow runtime activity; they expire after work stops. A reconnect does not repeat the plan or cancel healthy work.
80
80
 
81
+ When a conversation turns into coding work, the agent includes its acknowledgement and two or three next steps in the workspace handoff. They are posted to the original channel thread before coding starts. Private continuation context stays internal, and the work session does not send a second pickup message.
82
+
81
83
  ## Open Agent Studio
82
84
 
83
85
  In the app, choose **Agents → Open Agent Studio**. Updated backend watchers start Studio automatically. Agent chat replies can also show this button and open the relevant agent’s settings directly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openvisio-agent",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "Connect Claude Code, Codex, or OpenCode to an OpenVisio team \u2014 MCP tools + optional autonomy \u2014 in one command.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,18 +4,25 @@ export function runtimeControlTools({ canCode = false, workspaceAvailable = fals
4
4
  if (canCode || !workspaceAvailable) return []
5
5
  return [{
6
6
  name: WORK_SESSION_TOOL,
7
- description: 'Continue this same request in your configured coding workspace. Use when investigation reveals that local execution or edits are needed. You remain the same agent and retain the original task, recipient, and permissions. Supply the context worth carrying forward, then end this turn; the watcher starts your work session and delivers its eventual response. Do not ask the user to reassign the task.',
8
- inputSchema: { type: 'object', properties: { context: { type: 'string', minLength: 1, maxLength: 12000, description: 'Relevant findings, decisions, remaining work, and context for your continuation.' } }, required: ['context'], additionalProperties: false },
7
+ description: 'Continue this same request in your configured coding workspace. Use when investigation reveals that local execution or edits are needed. You remain the same agent and retain the original task, recipient, and permissions. Supply private continuation context plus a teammate-facing acknowledgement and 2-3 concrete plan steps. The watcher posts that acknowledgement and plan in the original channel/thread before starting your work session, then delivers your eventual result separately. End this turn after requesting continuation; do not repeat the acknowledgement or ask for reassignment.',
8
+ inputSchema: { type: 'object', properties: {
9
+ context: { type: 'string', minLength: 1, maxLength: 12000, description: 'Private findings, decisions, remaining work, and context for your continuation. This field is not posted to chat.' },
10
+ acknowledgement: { type: 'string', minLength: 1, maxLength: 1200, description: 'A short, natural first-person acknowledgement for the teammate. Say what you have accepted; do not claim completion.' },
11
+ plan: { type: 'array', minItems: 2, maxItems: 3, items: { type: 'string', minLength: 1, maxLength: 240 }, description: '2-3 concrete next steps to post alongside your acknowledgement before doing the work.' },
12
+ }, required: ['context', 'acknowledgement', 'plan'], additionalProperties: false },
9
13
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
10
14
  }]
11
15
  }
12
16
 
13
17
  export function requestWorkSession(args, capabilities) {
14
18
  if (!runtimeControlTools(capabilities).length) throw new Error('No additional coding workspace is available in this session')
15
- if (!args || Object.keys(args).some((key) => key !== 'context') || typeof args.context !== 'string' || !args.context.trim() || args.context.length > 12000) throw new Error('Provide only a nonempty context string of at most 12000 characters')
16
- return { content: [{ type: 'text', text: JSON.stringify({ openvisioControl: { action: 'request_work_session', context: args.context.trim() } }) }] }
19
+ if (!args || Object.keys(args).some((key) => !['context', 'acknowledgement', 'plan'].includes(key)) || typeof args.context !== 'string' || !args.context.trim() || args.context.length > 12000) throw new Error('Provide a nonempty context string of at most 12000 characters and only the advertised acknowledgement and plan fields')
20
+ if (typeof args.acknowledgement !== 'string' || !args.acknowledgement.trim() || args.acknowledgement.length > 1200 || !validPlan(args.plan)) throw new Error('Include a short acknowledgement and 2-3 nonempty plan steps of at most 240 characters each for delivery to the original conversation')
21
+ return { content: [{ type: 'text', text: JSON.stringify({ openvisioControl: { action: 'request_work_session', context: args.context.trim(), acknowledgement: args.acknowledgement.trim(), plan: args.plan.map(step => step.trim()) } }) }] }
17
22
  }
18
23
 
24
+ const validPlan = (plan) => Array.isArray(plan) && plan.length >= 2 && plan.length <= 3 && plan.every(step => typeof step === 'string' && step.trim() && step.length <= 240)
25
+
19
26
  // Only inspect outputs of our named local control tool, never arbitrary model
20
27
  // prose, commands, ticket text, or repository content.
21
28
  export function workSessionRequest(output, depth = 0) {
@@ -28,7 +35,11 @@ export function workSessionRequest(output, depth = 0) {
28
35
  return null
29
36
  }
30
37
  const request = output.openvisioControl
31
- if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return { context: request.context.trim() }
38
+ if (request?.action === 'request_work_session' && typeof request.context === 'string' && request.context.trim() && request.context.length <= 12000) return {
39
+ context: request.context.trim(),
40
+ ...(typeof request.acknowledgement === 'string' && request.acknowledgement.trim() && request.acknowledgement.length <= 1200 ? { acknowledgement: request.acknowledgement.trim() } : {}),
41
+ ...(validPlan(request.plan) ? { plan: request.plan.map(step => step.trim()) } : {}),
42
+ }
32
43
  for (const key of ['content', 'text', 'result', 'structuredContent']) {
33
44
  const nested = workSessionRequest(output[key], depth + 1)
34
45
  if (nested) return nested
@@ -105,14 +105,19 @@ export async function readStudioSnapshot({ stateDir, now = Date.now }) {
105
105
  const age = now() - Date.parse(event.timestamp)
106
106
  const online = age >= -5000 && age <= FRESH_MS && event.type !== 'runtime.stopped' && pidAlive(event.pid)
107
107
  const existing = agentsById.get(event.agent.identifier)
108
- agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
108
+ agentsById.set(event.agent.identifier, { ...existing, id: event.agent.identifier, identifier: event.agent.identifier, slug: existing?.slug || event.agent.slug || event.agent.identifier, name: existing?.name || event.agent.slug || event.agent.identifier, provider: event.agent.provider || 'unknown', runId: event.runId, pid: Number.isSafeInteger(event.pid) ? event.pid : null, watcherAlive: online, status: online ? 'online' : 'offline', lastSeen: event.timestamp, lastEventType: event.type })
109
109
  }
110
- const agents = [...agentsById.values()].sort((a, b) => a.name.localeCompare(b.name))
110
+ const agents = [...agentsById.values()]
111
111
  for (const agent of agents) {
112
+ const profileEvent = events.findLast(event => event.agent.identifier === agent.identifier && (event.type === 'agent.profile' || event.type === 'watcher.heartbeat' && event.data?.profile))
113
+ const profile = profileEvent?.type === 'agent.profile' ? profileEvent.data : profileEvent?.data?.profile
114
+ if (typeof profile?.name === 'string' && profile.name.trim()) agent.name = profile.name.trim()
115
+ if (typeof profile?.avatarSeed === 'string' && profile.avatarSeed) agent.avatarSeed = profile.avatarSeed
112
116
  const applied = events.findLast(event => event.agent.identifier === agent.identifier && event.runId === agent.runId && typeof event.data?.modelSettingsRevision === 'string')
113
117
  agent.appliedModelRevision = applied?.data.modelSettingsRevision || ''
114
118
  agent.modelControlsSupported = !!applied
115
119
  }
120
+ agents.sort((a, b) => a.name.localeCompare(b.name))
116
121
  return { schemaVersion: 1, demo: false, generatedAt: new Date(now()).toISOString(), agents, events: events.slice(-MAX_EVENTS), limits: { maxEvents: MAX_EVENTS, maxFiles: MAX_FILES, maxTotalBytes: MAX_TOTAL_BYTES }, warnings }
117
122
  }
118
123
 
@@ -219,7 +224,7 @@ export async function startStudioServer({ stateDir, host = '127.0.0.1', port = 4
219
224
  sendSnapshot(res, await snapshot())
220
225
  return
221
226
  }
222
- const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs', '/satoshi-400.woff2': 'satoshi-400.woff2', '/satoshi-500.woff2': 'satoshi-500.woff2', '/openvisio.svg': 'openvisio.svg' })[path]
227
+ const file = ({ '/': 'index.html', '/index.html': 'index.html', '/guide': 'guide.html', '/guide.html': 'guide.html', '/style.css': 'style.css', '/app.mjs': 'app.mjs', '/bot-avatar.mjs': 'bot-avatar.mjs', '/satoshi-400.woff2': 'satoshi-400.woff2', '/satoshi-500.woff2': 'satoshi-500.woff2', '/openvisio.svg': 'openvisio.svg' })[path]
223
228
  if (!file) { res.writeHead(404); res.end('Not found'); return }
224
229
  const target = await realpath(resolve(root, file))
225
230
  const rel = relative(root, target)
package/src/watch.mjs CHANGED
@@ -630,6 +630,9 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
630
630
  if (!live || live.control.cancelled || stopping) return
631
631
  if (type === 'tool.started' || type === 'tool.updated') activity.set(data.cycleId, live.control.statusTargets, 'working')
632
632
  if (type === 'output.progress') activity.set(data.cycleId, live.control.statusTargets, 'typing')
633
+ // Reply sessions may discover work themselves. Keep their native plan for
634
+ // an explicit continuation, without announcing plans for ordinary answers.
635
+ if (type === 'plan.updated') live.control.planEntries = data.entries
633
636
  if (type === 'plan.updated' && live.data.kind === 'full' && data.entries?.some(entry => typeof entry?.content === 'string' && entry.content.trim()) && !live.control.planDelivery) {
634
637
  live.control.planDelivery = trackOperation(publishWorkPlan(live, data.entries)).catch(error => log('Task plan delivery unavailable: ' + (error?.message || error)))
635
638
  }
@@ -878,13 +881,17 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
878
881
  let selfAgentId = null
879
882
  let selfOrganizationId = null
880
883
  let identityContext = memory.get?.(`identity:${identifier}`)?.meta?.context || agentProfileContext({}, identifier)
884
+ let agentAppearance = memory.get?.(`identity:${identifier}`)?.meta?.appearance || null
885
+ if (agentAppearance) emit('agent.profile', agentAppearance)
881
886
  const selfAliases = new Set([slug, identifier].map((value) => String(value || '').toLowerCase()).filter(Boolean))
882
887
  const rememberSelfAgent = (self) => {
883
888
  if (!self || typeof self !== 'object' || String(self.identifier || self.slug || '') !== identifier) return
884
889
  if (validBackendId(self.id)) selfAgentId = Number(self.id)
885
890
  for (const alias of [self.name, self.identifier, self.slug]) if (alias) selfAliases.add(String(alias).toLowerCase())
886
891
  identityContext = agentProfileContext(self, identifier)
887
- memory.remember({ key: `identity:${identifier}`, kind: 'identity', state: 'configured', summary: self.name || identifier, meta: { context: identityContext } })
892
+ const appearance = { name: String(self.name || identifier).slice(0, 160), avatarSeed: String(self.slug || self.identifier || identifier).slice(0, 240) }
893
+ if (JSON.stringify(appearance) !== JSON.stringify(agentAppearance)) { agentAppearance = appearance; emit('agent.profile', appearance) }
894
+ memory.remember({ key: `identity:${identifier}`, kind: 'identity', state: 'configured', summary: self.name || identifier, meta: { context: identityContext, appearance } })
888
895
  const organizationId = self.organization_id ?? self.organizationId
889
896
  if (validBackendId(organizationId)) {
890
897
  selfOrganizationId = Number(organizationId)
@@ -1165,11 +1172,12 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1165
1172
  return run
1166
1173
  }
1167
1174
 
1168
- async function publishWorkPlan(live, entries) {
1175
+ async function publishWorkPlan(live, entries, acknowledgement = '') {
1169
1176
  const { control, delivery, taskRef } = live
1170
1177
  const steps = (Array.isArray(entries) ? entries : []).filter(entry => entry && typeof entry.content === 'string' && entry.content.trim()).slice(0, 3).map(entry => entry.content.trim().slice(0, 240))
1171
1178
  if (!steps.length || control.cancelled || !control.planKey || memory.has(control.planKey)) return
1172
- const content = `${control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on."}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
1179
+ const introduction = String(acknowledgement || '').trim().slice(0, 1200) || (control.taskLabel ? `I'm on ${control.taskLabel}.` : "I'll take this on.")
1180
+ const content = `${introduction}\n\n${steps.map((step, index) => `${index + 1}. ${step}`).join('\n')}`
1173
1181
  const channelId = delivery?.channelId ?? taskRef?.channelId ?? await projectStatusChannel(taskRef?.projectId)
1174
1182
  if (channelId == null || control.cancelled) return
1175
1183
  const refs = { channelId: Number(channelId), ...(delivery?.parentId != null ? { threadId: Number(delivery.parentId) } : {}) }
@@ -1618,7 +1626,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1618
1626
  try { recalled = await memory.context(memoryRefs) }
1619
1627
  catch (error) { log('Optional history unavailable; agent can retrieve context with its tools: ' + (error?.message || error)) }
1620
1628
  const capabilityContext = kind !== 'full' && canCode
1621
- ? 'YOUR CODING WORKSPACE IS AVAILABLE. If this request needs local execution or edits, call openvisio_request_work_session with the findings and context to carry forward, then end this turn. The same agent continues the same request in its coding workspace. Do not claim to be a chat-only agent or ask anyone to reassign the ticket.'
1629
+ ? 'YOUR CODING WORKSPACE IS AVAILABLE. If this request needs local execution or edits, call openvisio_request_work_session with private continuation context, a short teammate-facing acknowledgement, and 2-3 concrete plan steps. The watcher posts the acknowledgement and plan in this source conversation before starting the work session. Plain final text and internal context are not posted during this handoff: put your public acknowledgement and plan in the tool fields. Then end this turn. The same agent continues the same request in its coding workspace. Do not claim to be a chat-only agent or ask anyone to reassign the ticket.'
1622
1630
  : canCode ? 'This session has your configured coding workspace. Choose the tools and context appropriate to the request.' : 'This connection has no configured local coding workspace. Use your available tools for the request; do not claim local changes you cannot perform.'
1623
1631
  const prompt = identityContext + '\n\n' + capabilityContext + '\n\n' + (ctx.length ? ctx.join('\n') + '\n\n' : '') + (recalled ? recalled + '\n\n' : '') + baseFor(kind, delivery)
1624
1632
  // Chat-shaped cycles (mentions/intro) may run on the cheaper chat model; code
@@ -1689,6 +1697,11 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
1689
1697
  // An agent-selected continuation, not a heuristic retry or a new task.
1690
1698
  // Preserve the authoritative recipient and cancellation key; the tool
1691
1699
  // cannot select another task, channel, identity, or permission scope.
1700
+ const entries = Array.isArray(result.workRequest.plan)
1701
+ ? result.workRequest.plan.map(content => ({ content })) : cycleControl.planEntries
1702
+ try { await publishWorkPlan({ control: cycleControl, delivery, taskRef: activeTaskRef }, entries, result.workRequest.acknowledgement) }
1703
+ catch (error) { log('Task plan delivery unavailable: ' + (error?.message || error)) }
1704
+ if (cycleControl.cancelled || stopping) return
1692
1705
  cycleControl.outcome = 'continued'
1693
1706
  cycleControl.finishedBy = 'agent'
1694
1707
  const continuation = String(result.workRequest.context || '').slice(0, 12000)
@@ -2106,7 +2119,7 @@ export function createBackendWatcher({ backend, wsUrl, apiKey, identifier, slug,
2106
2119
  modelSettingsTimer?.unref?.()
2107
2120
  emit('models.updated', { model: codeModel, chatModel: liteModel, modelSettingsRevision })
2108
2121
  const heartbeatTimer = autoStart ? setInterval(() => {
2109
- emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '' })
2122
+ emit('watcher.heartbeat', { status: 'running', active: queues.work.activeSize + queues.reply.activeSize, pending: queues.work.size + queues.reply.size, model: codeModel, chatModel: liteModel, modelSettingsRevision, workdir: workdir || '', ...(agentAppearance ? { profile: agentAppearance } : {}) })
2110
2123
  // The Studio reads a bounded journal tail. Re-state current queue ownership
2111
2124
  // so a long-running cycle stays visible after its start event rotates out.
2112
2125
  for (const { data, control } of liveCycles.values()) {
package/studio/app.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // The studio displays recorded, visible actions. It never starts work or infers a plan.
2
+ import { botAvatarUrl } from './bot-avatar.mjs';
2
3
  const MAX_EVENTS = 1000;
3
4
  const PAGE_SIZE = 40;
4
5
  const terminalStatuses = new Set(['completed', 'failed', 'cancelled', 'offline', 'skipped', 'blocked', 'timeout', 'continued']);
@@ -287,6 +288,14 @@ function initializeStudio() {
287
288
  const element = node('span', `badge ${status}`, label);
288
289
  return element;
289
290
  }
291
+ function agentImage(agent, size = 31) {
292
+ const profile = state.model.agents.find(item => agentId(item) === agentId(agent)) || agent;
293
+ const image = node('img', 'bot-avatar');
294
+ image.src = botAvatarUrl(profile.avatarSeed || profile.slug || profile.identifier || profile.id);
295
+ image.alt = ''; image.setAttribute('aria-hidden', 'true');
296
+ image.width = size; image.height = size;
297
+ return image;
298
+ }
290
299
  function toast(message) {
291
300
  clearTimeout(toastTimer);
292
301
  $('toast').textContent = message;
@@ -352,10 +361,12 @@ function initializeStudio() {
352
361
  button.setAttribute('aria-pressed', String(state.selectedAgent === id));
353
362
  const avatar = node('span', 'agent-avatar');
354
363
  if (id === 'all') avatar.append(icon('agents'));
355
- else { avatar.textContent = name.slice(0, 2).toUpperCase(); avatar.append(node('span', `avatar-status ${agent.status}`)); }
364
+ else { avatar.classList.add('has-image'); avatar.append(agentImage(agent), node('span', `avatar-status ${agent.status}`)); }
356
365
  const body = node('span', 'agent-text');
357
366
  body.append(node('span', 'agent-name', name), node('span', 'agent-caption', caption));
358
- button.append(avatar, body);
367
+ const selection = node('span', 'agent-selection'); selection.append(icon('check'));
368
+ selection.setAttribute('aria-hidden', 'true');
369
+ button.append(avatar, body, selection);
359
370
  button.addEventListener('click', () => { state.selectedAgent = id; resetSelection(); scheduleRender(); });
360
371
  return button;
361
372
  }
@@ -704,9 +715,11 @@ function initializeStudio() {
704
715
  branch.setAttribute('aria-pressed', String(item.id === cycle.id));
705
716
  const marker = node('span', `fanout-marker ${item.status}`);
706
717
  marker.append(icon(item.status === 'completed' ? 'check' : ['failed', 'cancelled', 'blocked', 'timeout'].includes(item.status) ? 'close' : ['active', 'recovering'].includes(item.status) ? 'loader' : 'clock'));
707
- branch.append(marker, node('span', 'fanout-name', agentName(item.agent)));
718
+ const identity = node('span', 'fanout-identity');
719
+ identity.append(agentImage(item.agent, 20), node('span', 'fanout-name', agentName(item.agent)));
720
+ branch.append(marker, identity);
708
721
  branch.addEventListener('click', () => {
709
- state.selectedCycle = item.id; state.selectedEvent = null; state.selectedAgent = 'all';
722
+ state.selectedCycle = item.id; state.selectedEvent = null; state.selectedAgent = agentId(item.agent);
710
723
  state.view = 'cycles'; state.search = ''; state.kind = 'all'; state.status = 'all';
711
724
  $('event-search').value = ''; $('kind-filter').value = 'all'; $('status-filter').value = 'all';
712
725
  state.follow = false; $('follow-latest').checked = false; scheduleRender();
@@ -0,0 +1,70 @@
1
+ // Embedded version of frontend/components/team/BotAvatar.tsx. Keep the seed,
2
+ // palettes and SVG geometry identical so an agent looks the same in both apps.
3
+ // Only generated numbers and fixed shapes enter the SVG; no remote images.
4
+ function hashSeed(seed) {
5
+ let h = 2166136261 >>> 0;
6
+ for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 16777619); }
7
+ return h >>> 0;
8
+ }
9
+ function mulberry32(seed) {
10
+ let a = seed >>> 0;
11
+ return () => {
12
+ a = (a + 0x6d2b79f5) | 0;
13
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
14
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
15
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
16
+ };
17
+ }
18
+ const catalogHues = { sentinel: 0, forge: 40, pulse: 80, nova: 120, sage: 160, atlas: 200, vera: 240, relay: 280, quill: 320 };
19
+ function botSpec(seed) {
20
+ const h = Object.hasOwn(catalogHues, seed) ? catalogHues[seed] : hashSeed(seed + '#hue') % 360;
21
+ const v = hashSeed(seed + '#tone') % 97, saturation = 58 + v % 20, lightness = 50 + v % 9;
22
+ const rng = mulberry32(hashSeed(seed));
23
+ const pick = choices => choices[Math.floor(rng() * choices.length)];
24
+ return {
25
+ p: {
26
+ tile: `hsl(${h} ${Math.min(saturation + 6, 84)}% 90%)`,
27
+ body: `hsl(${h} ${saturation}% ${lightness}%)`,
28
+ dark: `hsl(${h} ${Math.min(saturation + 4, 82)}% ${lightness - 17}%)`,
29
+ face: `hsl(${h} 48% 97%)`,
30
+ accent: `hsl(${(h + 150 + v % 61) % 360} 82% 56%)`,
31
+ },
32
+ headRx: pick([14, 20, 26, 26]),
33
+ antenna: pick(['none', 'single', 'double', 'dish']),
34
+ eyes: pick(['round', 'square', 'visor', 'cyclops', 'happy']),
35
+ mouth: pick(['grid', 'smile', 'flat', 'dots', 'wave']),
36
+ };
37
+ }
38
+
39
+ export function botAvatarSvg(seed) {
40
+ const spec = botSpec(String(seed || 'bot')), { p } = spec;
41
+ const element = (tag, attributes, children = '') => `<${tag} ${Object.entries(attributes).map(([key, value]) => `${key}="${value}"`).join(' ')}>${children}</${tag}>`;
42
+ const circle = (cx, cy, r, fill, more = {}) => element('circle', { cx, cy, r, fill, ...more });
43
+ const rect = (x, y, width, height, rx, fill) => element('rect', { x, y, width, height, rx, fill });
44
+ const line = (x1, y1, x2, y2, more = {}) => element('line', { x1, y1, x2, y2, ...more });
45
+ const stroke = (color, width) => ({ stroke: color, 'stroke-width': width, 'stroke-linecap': 'round' });
46
+ const antenna = {
47
+ single: element('g', stroke(p.dark, 3), line(50, 26, 50, 15) + circle(50, 12, 4, p.accent, { stroke: 'none' })),
48
+ double: element('g', stroke(p.dark, 2.6), line(40, 26, 37, 16) + line(60, 26, 63, 16) + circle(36, 14, 3.2, p.accent, { stroke: 'none' }) + circle(64, 14, 3.2, p.accent, { stroke: 'none' })),
49
+ dish: element('g', {}, line(50, 26, 50, 16, stroke(p.dark, 3)) + element('ellipse', { cx: 50, cy: 14, rx: 10, ry: 3.4, fill: p.dark }) + circle(50, 12, 2.4, p.accent)),
50
+ }[spec.antenna] || '';
51
+ const eyes = {
52
+ round: circle(42, 48, 5, p.dark) + circle(58, 48, 5, p.dark) + circle(43.4, 46.6, 1.6, '#fff') + circle(59.4, 46.6, 1.6, '#fff'),
53
+ square: rect(37, 43, 9, 9, 2.5, p.dark) + rect(54, 43, 9, 9, 2.5, p.dark),
54
+ visor: element('g', {}, rect(36, 44, 28, 9, 4.5, p.dark) + circle(44, 48.5, 2.4, p.accent) + circle(56, 48.5, 2.4, p.accent)),
55
+ cyclops: circle(50, 48, 8, p.dark) + circle(50, 48, 3.4, p.accent),
56
+ happy: element('g', { ...stroke(p.dark, 3), fill: 'none' }, element('path', { d: 'M37 49 q5 -6 10 0' }) + element('path', { d: 'M53 49 q5 -6 10 0' })),
57
+ }[spec.eyes];
58
+ const mouth = {
59
+ grid: element('g', {}, rect(42, 58, 16, 6.5, 2, p.dark) + [47, 50, 53].map(x => line(x, 58, x, 64.5, { stroke: p.face, 'stroke-width': 1.2 })).join('')),
60
+ smile: element('path', { d: 'M42 59 q8 7 16 0', ...stroke(p.dark, 3), fill: 'none' }),
61
+ flat: rect(43, 60.5, 14, 3, 1.5, p.dark),
62
+ dots: element('g', { fill: p.dark }, [44, 50, 56].map(cx => element('circle', { cx, cy: 61, r: 1.8 })).join('')),
63
+ wave: element('path', { d: 'M42 61 q3 -4 6 0 t6 0', ...stroke(p.dark, 2.6), fill: 'none' }),
64
+ }[spec.mouth];
65
+ return element('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 100 100' }, rect(0, 0, 100, 100, 24, p.tile) + antenna + rect(18, 46, 5, 16, 2.5, p.dark) + rect(77, 46, 5, 16, 2.5, p.dark) + rect(24, 26, 52, 50, spec.headRx, p.body) + rect(31, 35, 38, 32, 9, p.face) + eyes + mouth);
66
+ }
67
+
68
+ export function botAvatarUrl(seed) {
69
+ return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(botAvatarSvg(seed));
70
+ }
package/studio/style.css CHANGED
@@ -82,8 +82,14 @@
82
82
  .agent-list { display: flex; flex-direction: column; gap: 5px; }
83
83
  .agent-item { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; border: 0; background: transparent; padding: 10px; border-radius: var(--radius-md); corner-shape: squircle; }
84
84
  .agent-item:hover { background: var(--hover); }
85
- .agent-item.is-selected { background: var(--stage); }
85
+ .agent-item.is-selected { background: color-mix(in oklab, var(--ink) 8%, transparent); box-shadow: inset 0 0 0 1px var(--border); }
86
+ .agent-selection { display: grid; place-items: center; width: 15px; flex-shrink: 0; visibility: hidden; }
87
+ .agent-selection .icon { width: 14px; height: 14px; }
88
+ .agent-item.is-selected .agent-selection { visibility: visible; }
86
89
  .agent-avatar { width: 31px; height: 31px; display: grid; place-items: center; position: relative; flex-shrink: 0; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; corner-shape: squircle; color: var(--muted); font-size: 12px; font-weight: 500; }
90
+ .agent-avatar.has-image { border: 0; background: transparent; }
91
+ .bot-avatar { display: block; flex-shrink: 0; }
92
+ .fanout-identity { display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 0; max-width: 100%; }
87
93
  .avatar-status { position: absolute; width: 7px; height: 7px; right: -1px; bottom: -1px; border-radius: 50%; border: 2px solid var(--background); box-sizing: content-box; background: var(--subtle); }
88
94
  .avatar-status.online { background: var(--success); }
89
95
  .agent-text { flex: 1; min-width: 0; }