openvisio-agent 0.20.0 → 0.22.0

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/studio/app.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  // The studio displays recorded, visible actions. It never starts work or infers a plan.
2
2
  const MAX_EVENTS = 1000;
3
3
  const PAGE_SIZE = 40;
4
- const terminalStatuses = new Set(['completed', 'failed', 'cancelled', 'offline', 'skipped', 'blocked', 'timeout']);
4
+ const terminalStatuses = new Set(['completed', 'failed', 'cancelled', 'offline', 'skipped', 'blocked', 'timeout', 'continued']);
5
5
  const text = (value, max = 24000) => value == null ? '' : String(value).slice(0, max);
6
6
  const record = value => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
7
7
  const array = value => Array.isArray(value) ? value : [];
@@ -14,10 +14,10 @@ export function normalizeStatus(value, fallback = 'observed') {
14
14
  if (['active', 'running', 'started', 'ready', 'in_progress', 'working', 'online', 'preflight'].includes(status)) return 'active';
15
15
  if (['queued', 'pending', 'waiting'].includes(status)) return 'queued';
16
16
  if (['completed', 'complete', 'finished', 'done', 'success', 'succeeded', 'ok'].includes(status)) return 'completed';
17
- if (['failed', 'failure', 'error', 'rejected'].includes(status)) return 'failed';
17
+ if (['failed', 'failure', 'error', 'rejected', 'rate_limited'].includes(status)) return 'failed';
18
18
  if (['cancelled', 'canceled', 'aborted', 'interrupted'].includes(status)) return 'cancelled';
19
19
  if (['offline', 'stopped', 'disconnected', 'exited'].includes(status)) return 'offline';
20
- if (['skipped', 'recovering', 'idle', 'blocked', 'timeout'].includes(status)) return status;
20
+ if (['skipped', 'recovering', 'idle', 'blocked', 'timeout', 'continued'].includes(status)) return status;
21
21
  if (['starting', 'connecting', 'reused'].includes(status)) return 'connecting';
22
22
  return fallback;
23
23
  }
@@ -76,7 +76,7 @@ export function deriveModel(input) {
76
76
  cycle.events.push(event);
77
77
  cycle.lastSeen = event.timestamp;
78
78
  // Merge only fields that describe the cycle; tool kind/status belong to that tool.
79
- for (const field of ['model', 'workdir', 'watcherPid', 'ticket', 'thread', 'lane', 'queueMs', 'durationMs', 'reason']) {
79
+ for (const field of ['model', 'workdir', 'watcherPid', 'ticket', 'thread', 'lane', 'queueMs', 'durationMs', 'reason', 'finishedBy']) {
80
80
  if (data[field] != null) cycle.data[field] = data[field];
81
81
  }
82
82
  if (event.type.startsWith('cycle.')) {
@@ -164,11 +164,28 @@ export function summarizeModel(model, selectedAgent = 'all') {
164
164
  };
165
165
  }
166
166
 
167
+ // Shared task identity groups recorded work; it does not imply delegation.
168
+ export function taskActivityCycles(model, selectedCycle) {
169
+ const ticket = record(selectedCycle.data.ticket);
170
+ if (ticket.projectId == null || ticket.ticketId == null) return [selectedCycle];
171
+ const latest = new Map();
172
+ for (const cycle of model.cycles) {
173
+ const candidate = record(cycle.data.ticket);
174
+ if (String(candidate.projectId) !== String(ticket.projectId) || String(candidate.ticketId) !== String(ticket.ticketId)) continue;
175
+ const id = agentId(cycle.agent);
176
+ if (!latest.has(id) || timeValue(cycle.lastSeen) > timeValue(latest.get(id).lastSeen)) latest.set(id, cycle);
177
+ }
178
+ // Keep the selected historical cycle visible even when this agent ran again.
179
+ latest.set(agentId(selectedCycle.agent), selectedCycle);
180
+ return [...latest.values()].sort((a, b) => agentId(a.agent).localeCompare(agentId(b.agent)));
181
+ }
182
+
167
183
  export function eventTitle(event) {
168
184
  const data = record(event?.data);
169
185
  const titles = {
170
186
  'runtime.started': 'Watcher connected', 'runtime.heartbeat': 'Watcher heartbeat', 'runtime.stopped': 'Watcher stopped',
171
187
  'cycle.queued': 'Cycle queued', 'cycle.started': 'Cycle started', 'cycle.finished': 'Cycle finished',
188
+ 'cycle.continued': 'Continued in coding workspace',
172
189
  'cycle.completed': 'Cycle completed', 'cycle.cancelled': 'Cycle cancelled', 'cycle.failed': 'Cycle failed',
173
190
  'plan.updated': 'Plan updated', 'output.progress': 'Progress update', 'output.final': 'Final response',
174
191
  'process.started': 'Coding runtime starting', 'process.ready': 'Coding runtime ready', 'process.stopped': 'Coding runtime stopped',
@@ -222,7 +239,7 @@ export function filterModelItems(model, { view = 'activity', selectedAgent = 'al
222
239
  && (!query || searchableEvent(event).includes(query)));
223
240
  }
224
241
 
225
- const statusLabels = { active: 'Active', queued: 'Queued', completed: 'Completed', failed: 'Failed', cancelled: 'Cancelled', offline: 'Offline', observed: 'Recorded', demo: 'Simulated', uninstrumented: 'No activity yet', unknown: 'Unknown', skipped: 'Skipped', recovering: 'Recovering', idle: 'Idle', blocked: 'Blocked', timeout: 'Timed out', connecting: 'Connecting' };
242
+ const statusLabels = { continued: 'Continued in workspace', active: 'Active', queued: 'Queued', completed: 'Completed', failed: 'Failed', cancelled: 'Cancelled', offline: 'Offline', observed: 'Recorded', demo: 'Simulated', uninstrumented: 'No activity yet', unknown: 'Unknown', skipped: 'Skipped', recovering: 'Recovering', idle: 'Idle', blocked: 'Blocked', timeout: 'Timed out', connecting: 'Connecting' };
226
243
  const kindIcons = { cycle: 'stack', tool: 'tool', plan: 'list', output: 'message', runtime: 'pulse', process: 'terminal' };
227
244
  const shortTime = timestamp => timeValue(timestamp) ? new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) : '—';
228
245
  const fullTime = timestamp => timeValue(timestamp) ? new Date(timestamp).toLocaleString() : 'Not recorded';
@@ -238,10 +255,13 @@ if (typeof document !== 'undefined') initializeStudio();
238
255
 
239
256
  function initializeStudio() {
240
257
  const $ = id => document.getElementById(id);
258
+ const launch = new URLSearchParams(location.hash.slice(1));
259
+ let launchSettings = launch.get('settings') === '1';
260
+ let editingAgent = null, editingSettings = null, settingsRequest = 0;
241
261
  const state = {
242
262
  snapshot: null, model: null, pending: null, paused: false, connected: false,
243
- selectedAgent: 'all', agentSearch: '', search: '', kind: 'all', status: 'all',
244
- view: 'activity', selectedCycle: null, selectedEvent: null, selectedProcess: null,
263
+ selectedAgent: launch.get('agent') || 'all', agentSearch: '', search: '', kind: 'all', status: 'all',
264
+ view: 'activity', detailView: 'plan', selectedCycle: null, selectedEvent: null, selectedProcess: null,
245
265
  limit: PAGE_SIZE, follow: true, pendingUpdates: 0, error: null,
246
266
  };
247
267
  let scheduled = false;
@@ -331,7 +351,7 @@ function initializeStudio() {
331
351
  button.type = 'button';
332
352
  button.setAttribute('aria-pressed', String(state.selectedAgent === id));
333
353
  const avatar = node('span', 'agent-avatar');
334
- if (id === 'all') avatar.append(icon('grid'));
354
+ if (id === 'all') avatar.append(icon('agents'));
335
355
  else { avatar.textContent = name.slice(0, 2).toUpperCase(); avatar.append(node('span', `avatar-status ${agent.status}`)); }
336
356
  const body = node('span', 'agent-text');
337
357
  body.append(node('span', 'agent-name', name), node('span', 'agent-caption', caption));
@@ -351,6 +371,64 @@ function initializeStudio() {
351
371
  $('metric-processes-caption').textContent = state.snapshot.demo ? 'Simulated processes' : summary.unreportedProcesses ? `${summary.unreportedProcesses} runtime PID not reported` : `${online} ${online === 1 ? 'watcher' : 'watchers'} connected`;
352
372
  $('metric-events-caption').textContent = 'In the available local history';
353
373
  }
374
+ function renderModelSettings() {
375
+ const agent = state.model.agents.find(agent => agentId(agent) === state.selectedAgent);
376
+ const section = $('model-settings-summary');
377
+ section.hidden = !agent || state.snapshot.demo;
378
+ if (!agent || state.snapshot.demo) return;
379
+ $('model-settings-agent').textContent = `${agentName(agent)} · Models`;
380
+ $('open-model-settings').disabled = !agent.settingsEditable;
381
+ const settings = agent.modelSettings || {};
382
+ const pending = settings.revision && agent.appliedModelRevision !== settings.revision;
383
+ $('model-settings-state').textContent = !agent.settingsEditable ? 'Model controls are available for agents configured on this computer.'
384
+ : pending ? agent.modelControlsSupported && agent.status === 'online' ? 'Saved · waiting for the watcher to apply your choices.' : 'Saved for the next watcher connection. Older watchers need an update to apply changes live.'
385
+ : `Replies: ${settings.chatModel || settings.model || 'Runtime default'} · Coding: ${settings.model || 'Runtime default'}`;
386
+ if (launchSettings && agent.settingsEditable) { launchSettings = false; void openModelSettings(agent); }
387
+ }
388
+ async function settingsFetch(path, options) {
389
+ const response = await fetch(path, { cache: 'no-store', ...options });
390
+ const body = await response.json();
391
+ if (!response.ok) throw new Error(body.error || 'Could not load model settings. Try again.');
392
+ return body;
393
+ }
394
+ async function loadModels(agent, request) {
395
+ $('model-catalog-status').textContent = 'Loading model choices…';
396
+ try {
397
+ const { models } = await settingsFetch(`/api/agents/${encodeURIComponent(agent.slug)}/models`);
398
+ if (request !== settingsRequest) return;
399
+ const choices = [...new Set([editingSettings?.model, editingSettings?.chatModel, ...models].filter(Boolean))];
400
+ $('runtime-models').replaceChildren(...choices.map(model => { const option = node('option'); option.value = model; return option; }));
401
+ $('model-catalog-status').textContent = choices.length ? `${choices.length} models · select a field to search, or enter a model ID.` : 'Enter a model ID supported by your connected runtime.';
402
+ } catch (error) {
403
+ if (request === settingsRequest) $('model-catalog-status').textContent = `${error.message} You can still enter a model ID.`;
404
+ }
405
+ }
406
+ async function openModelSettings(agent) {
407
+ const request = ++settingsRequest;
408
+ editingAgent = agent; editingSettings = null;
409
+ $('model-settings-title').textContent = `${agentName(agent)} · Model settings`;
410
+ $('model-settings-error').hidden = true;
411
+ $('reply-model').value = ''; $('coding-model').value = '';
412
+ $('runtime-models').replaceChildren();
413
+ $('save-model-settings').disabled = true;
414
+ $('reload-models').disabled = true;
415
+ $('model-catalog-status').textContent = 'Loading settings…';
416
+ if (!$('model-settings-dialog').open) $('model-settings-dialog').showModal();
417
+ try {
418
+ const settings = await settingsFetch(`/api/agents/${encodeURIComponent(agent.slug)}/settings`);
419
+ if (request !== settingsRequest) return;
420
+ editingSettings = settings;
421
+ $('reply-model').value = settings.chatModel || settings.model || '';
422
+ $('coding-model').value = settings.model || '';
423
+ $('save-model-settings').disabled = false;
424
+ $('reload-models').disabled = false;
425
+ void loadModels(agent, request);
426
+ } catch (error) {
427
+ if (request !== settingsRequest) return;
428
+ $('model-settings-error').hidden = false; $('model-settings-error').textContent = error.message;
429
+ $('model-catalog-status').textContent = 'Close this panel and try again when the agent is available.';
430
+ }
431
+ }
354
432
  function resetSelection() {
355
433
  state.limit = PAGE_SIZE;
356
434
  state.selectedEvent = null;
@@ -413,7 +491,12 @@ function initializeStudio() {
413
491
  return baseRow({ id: event.id, title: eventTitle(event), detail: description(event), agent: event.agent, status: eventStatus(event), timestamp: event.timestamp,
414
492
  iconName: kindIcons[event.type.split('.')[0]] || 'pulse', selected: state.selectedEvent === event.id,
415
493
  extra: text(ticket.slug || (ticket.ticketId != null ? `Ticket ${ticket.ticketId}` : '')),
416
- onClick: () => { state.selectedEvent = event.id; state.selectedCycle = cycleKey(event); state.selectedProcess = null; },
494
+ onClick: () => {
495
+ state.selectedEvent = event.id; state.selectedCycle = cycleKey(event); state.selectedProcess = null;
496
+ if (event.type.startsWith('tool.')) state.detailView = 'commands';
497
+ else if (event.type.startsWith('output.')) state.detailView = 'response';
498
+ else if (event.type === 'plan.updated') state.detailView = 'plan';
499
+ },
417
500
  });
418
501
  }
419
502
  function cycleRow(cycle) {
@@ -455,7 +538,7 @@ function initializeStudio() {
455
538
  return empty;
456
539
  }
457
540
  function detailSection(title, iconName) {
458
- const section = node('section', 'detail-section');
541
+ const section = node('section', 'detail-section oa-card');
459
542
  const heading = node('h3'); if (iconName) heading.append(icon(iconName)); heading.append(document.createTextNode(title));
460
543
  section.append(heading);
461
544
  return section;
@@ -479,6 +562,7 @@ function initializeStudio() {
479
562
  const inspector = $('inspector');
480
563
  // Preserve the user's expanded history and scroll across live snapshots.
481
564
  const historyOpen = inspector.querySelector('.history-details')?.open;
565
+ const runtimeOpen = inspector.querySelector('.runtime-details')?.open;
482
566
  const oldScroll = inspector.scrollTop;
483
567
  const process = state.view === 'processes' ? state.model.processes.find(item => item.id === state.selectedProcess) : null;
484
568
  const cycle = state.view !== 'processes' ? state.model.cycles.find(item => item.id === state.selectedCycle) : null;
@@ -489,15 +573,20 @@ function initializeStudio() {
489
573
  inspector.replaceChildren(empty); return;
490
574
  }
491
575
  if (process) renderProcessDetail(inspector, process);
492
- else if (cycle) renderCycleDetail(inspector, cycle, event);
576
+ else if (cycle) renderCycleDetail(inspector, cycle);
493
577
  else renderEventDetail(inspector, event);
494
578
  const history = inspector.querySelector('.history-details');
495
579
  if (history && historyOpen) history.open = true;
580
+ const runtime = inspector.querySelector('.runtime-details');
581
+ if (runtime && runtimeOpen) runtime.open = true;
496
582
  if (!state.follow) inspector.scrollTop = oldScroll;
497
583
  }
498
- function renderCycleDetail(inspector, cycle, selectedEvent) {
499
- inspector.replaceChildren(inspectorHeader('Cycle details', cycleTitle(cycle), `${agentName(cycle.agent)}${cycle.agent.provider ? ` · ${cycle.agent.provider}` : ''}`, cycle.status));
500
- const overview = detailSection('Runtime', 'terminal');
584
+ function renderCycleDetail(inspector, cycle) {
585
+ inspector.replaceChildren(taskActivityCard(cycle));
586
+ const overview = node('details', 'detail-section runtime-details');
587
+ const runtimeSummary = node('summary', '', 'Runtime details');
588
+ runtimeSummary.dataset.focusKey = `runtime:${cycle.id}`;
589
+ overview.append(runtimeSummary);
501
590
  const ticket = record(cycle.data.ticket); const thread = record(cycle.data.thread);
502
591
  overview.append(detailGrid([
503
592
  ['Model', cycle.data.model], ['Lane', cycle.data.lane || cycle.data.kind],
@@ -508,62 +597,137 @@ function initializeStudio() {
508
597
  ['Thread', thread.threadId != null ? `${thread.channelId != null ? `Channel ${thread.channelId} · ` : ''}${thread.threadId}` : null],
509
598
  ['Workspace', cycle.data.workdir, true],
510
599
  ]));
511
- inspector.append(overview);
512
600
  if (cycle.status === 'offline') {
513
601
  const note = detailSection('Activity unavailable', 'pulse');
514
602
  note.append(node('p', 'detail-note', 'This watcher stopped reporting or restarted. The recorded history has no final outcome for this cycle. Check the ticket and watcher log before deciding what to do next.'));
515
603
  inspector.append(note);
516
604
  }
517
605
  if (cycle.data.reason) { const reason = detailSection('Outcome', 'message'); reason.append(node('p', 'detail-note', cycle.data.reason)); inspector.append(reason); }
518
- if (selectedEvent && !selectedEvent.type.startsWith('cycle.')) {
519
- const selected = detailSection('Selected action', kindIcons[selectedEvent.type.split('.')[0]] || 'pulse');
520
- selected.append(node('p', 'tool-name', eventTitle(selectedEvent)), node('p', 'detail-note', fullTime(selectedEvent.timestamp)));
521
- const detail = description(selectedEvent); if (detail) selected.append(node('pre', 'tool-title', detail));
522
- inspector.append(selected);
606
+ const variants = node('div', 'detail-variants');
607
+ variants.setAttribute('role', 'group'); variants.setAttribute('aria-label', 'Cycle details view');
608
+ for (const [id, label, iconName] of [['plan', 'Plan', 'list'], ['commands', 'Commands', 'command'], ['response', 'Response', 'message']]) {
609
+ const button = node('button', `button variant${state.detailView === id ? ' is-selected' : ''}`);
610
+ button.type = 'button'; button.dataset.focusKey = `detail-view:${id}`;
611
+ button.setAttribute('aria-pressed', String(state.detailView === id));
612
+ button.append(icon(iconName), node('span', '', label));
613
+ button.addEventListener('click', () => { state.detailView = id; scheduleRender(); });
614
+ variants.append(button);
523
615
  }
524
- const plan = detailSection('Explicit plan', 'list');
525
- if (cycle.plan?.length) {
526
- const list = node('ol', 'plan-list');
527
- cycle.plan.forEach((step, index) => {
528
- const item = node('li', `plan-step ${step.status}`); const marker = node('span', 'plan-marker');
529
- if (step.status === 'completed') marker.append(icon('check')); else marker.textContent = String(index + 1);
530
- item.append(marker, node('span', 'plan-content', step.content), node('span', 'plan-status', statusLabels[step.status] || 'Pending'));
531
- list.append(item);
532
- });
533
- plan.append(list);
534
- } else plan.append(node('p', 'detail-note', 'No explicit plan recorded for this cycle.'));
535
- inspector.append(plan);
536
- if (cycle.tools.size) {
537
- const tools = detailSection('Tools & coding actions', 'tool'); tools.querySelector('h3').append(node('span', 'count', cycle.tools.size));
616
+ inspector.append(variants);
617
+ const detail = node('div', 'cycle-detail-content');
618
+ inspector.append(detail);
619
+ if (state.detailView === 'plan') {
620
+ const plan = detailSection('Plan', 'list');
621
+ if (cycle.plan?.length) {
622
+ plan.querySelector('h3').append(node('span', 'count', `${cycle.plan.filter(step => step.status === 'completed').length}/${cycle.plan.length}`));
623
+ const list = node('ol', 'plan-list');
624
+ cycle.plan.forEach((step, index) => {
625
+ const item = node('li', `plan-step ${step.status}`); const marker = node('span', 'plan-marker');
626
+ if (step.status === 'completed') marker.append(icon('check')); else marker.textContent = String(index + 1);
627
+ item.append(marker, node('span', 'plan-content', step.content), node('span', 'plan-status', statusLabels[step.status] || 'Pending'));
628
+ list.append(item);
629
+ });
630
+ plan.append(list);
631
+ } else plan.append(node('p', 'detail-note', 'No explicit plan recorded for this cycle.'));
632
+ detail.append(plan);
633
+ }
634
+ if (state.detailView === 'commands') {
635
+ const tools = detailSection('Commands & tools', 'command'); tools.querySelector('h3').append(node('span', 'count', cycle.tools.size));
538
636
  const list = node('div', 'tools-list');
539
637
  for (const tool of [...cycle.tools.values()].reverse()) {
540
638
  const item = node('div', 'tool-item'); const heading = node('div', 'tool-heading');
541
639
  heading.append(node('span', 'tool-name', tool.name || tool.kind || 'Tool'), badge(tool.status)); item.append(heading);
542
- if (tool.title) item.append(node('pre', 'tool-title', tool.title));
640
+ if (tool.title && tool.title !== tool.name) item.append(node('pre', 'tool-title', tool.title));
543
641
  for (const location of array(tool.locations)) {
544
642
  if (location?.path) item.append(node('span', 'file-location', `${location.path}${location.line != null ? `:${location.line}` : ''}`));
545
643
  }
546
644
  list.append(item);
547
645
  }
548
- tools.append(list); inspector.append(tools);
646
+ if (!cycle.tools.size) list.append(node('p', 'detail-note', 'Commands and tool calls will appear here as the agent works.'));
647
+ tools.append(list); detail.append(tools);
549
648
  }
550
- if (cycle.progress.size) {
551
- const progress = detailSection('Progress updates', 'message');
552
- for (const entry of cycle.progress.values()) progress.append(node('pre', 'recorded-output output-entry', entry.text));
553
- inspector.append(progress);
554
- }
555
- if (cycle.final !== null) {
556
- const final = detailSection('Final response', 'check'); final.append(node('pre', 'recorded-output', cycle.final || 'The recorded final response is empty.')); inspector.append(final);
557
- }
558
- if (!cycle.progress.size && cycle.final === null) {
559
- const output = detailSection('Recorded output', 'message'); output.append(node('p', 'detail-note', 'Visible progress and the final response will appear here when recorded.')); inspector.append(output);
649
+ if (state.detailView === 'response') {
650
+ if (cycle.progress.size) {
651
+ const progress = detailSection('Progress updates', 'message');
652
+ for (const entry of cycle.progress.values()) progress.append(node('pre', 'recorded-output output-entry', entry.text));
653
+ detail.append(progress);
654
+ }
655
+ if (cycle.final !== null) {
656
+ const final = detailSection('Final response', 'check'); final.append(node('pre', 'recorded-output', cycle.final || 'The recorded final response is empty.')); detail.append(final);
657
+ }
658
+ if (!cycle.progress.size && cycle.final === null) {
659
+ const output = detailSection('Recorded output', 'message'); output.append(node('p', 'detail-note', 'Visible progress and the final response will appear here when recorded.')); detail.append(output);
660
+ }
560
661
  }
662
+ inspector.append(overview);
561
663
  const history = node('details', 'detail-section history-details'); history.append(node('summary', '', `Cycle history · ${cycle.events.length} events`));
562
664
  history.querySelector('summary').dataset.focusKey = `history:${cycle.id}`;
563
665
  for (const event of cycle.events) { const row = node('div', 'mini-event'); row.append(node('time', '', shortTime(event.timestamp)), node('span', '', eventTitle(event))); history.append(row); }
564
666
  history.append(detailGrid([['Cycle ID', cycle.cycleId, true], ['Run ID', cycle.runId, true]]));
565
667
  inspector.append(history);
566
668
  }
669
+
670
+ function taskActivityCard(cycle) {
671
+ const related = taskActivityCycles(state.model, cycle);
672
+ const displayed = related.slice(0, 4);
673
+ if (!displayed.some(item => item.id === cycle.id)) displayed[displayed.length - 1] = cycle;
674
+ const card = node('section', 'task-activity-card oa-card');
675
+ card.setAttribute('aria-label', 'Task activity');
676
+ const header = node('div', 'fanout-header');
677
+ const title = node('h2', '', 'Task activity');
678
+ const count = node('span', 'badge fanout-count');
679
+ const dots = node('span', 'agent-dots'); dots.setAttribute('aria-hidden', 'true');
680
+ displayed.forEach((_, index) => dots.append(node('span', `dot agent-color-${index}`)));
681
+ count.append(dots, node('span', '', `${related.filter(item => item.status === 'completed').length}/${related.length} turns ended`));
682
+ header.append(title, count);
683
+ const surface = node('div', 'fanout-surface oa-inset');
684
+ const task = node('div', 'fanout-task');
685
+ task.append(node('span', 'eyebrow', 'Task'), node('span', '', cycleTitle(cycle)));
686
+ surface.append(task);
687
+ const graph = node('div', 'fanout-graph');
688
+ const root = node('span', `fanout-root ${cycle.status}`); root.append(icon('stack'));
689
+ graph.append(root);
690
+ const lines = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
691
+ lines.setAttribute('viewBox', '0 0 400 80'); lines.setAttribute('preserveAspectRatio', 'none');
692
+ lines.setAttribute('class', 'fanout-lines'); lines.setAttribute('aria-hidden', 'true');
693
+ for (let index = 0; index < displayed.length; index++) {
694
+ const x = (index + .5) * 400 / displayed.length;
695
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
696
+ path.setAttribute('d', `M200 0 C200 38 ${x} 38 ${x} 80`); lines.append(path);
697
+ }
698
+ graph.append(lines);
699
+ const branches = node('div', 'fanout-branches');
700
+ displayed.forEach((item, index) => {
701
+ const branch = node('button', `fanout-branch agent-color-${index}${item.id === cycle.id ? ' is-selected' : ''}`);
702
+ branch.type = 'button'; branch.dataset.focusKey = `branch:${item.id}`;
703
+ branch.setAttribute('aria-label', `${agentName(item.agent)} · ${statusLabels[item.status] || item.status} · View cycle`);
704
+ branch.setAttribute('aria-pressed', String(item.id === cycle.id));
705
+ const marker = node('span', `fanout-marker ${item.status}`);
706
+ 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)));
708
+ branch.addEventListener('click', () => {
709
+ state.selectedCycle = item.id; state.selectedEvent = null; state.selectedAgent = 'all';
710
+ state.view = 'cycles'; state.search = ''; state.kind = 'all'; state.status = 'all';
711
+ $('event-search').value = ''; $('kind-filter').value = 'all'; $('status-filter').value = 'all';
712
+ state.follow = false; $('follow-latest').checked = false; scheduleRender();
713
+ });
714
+ branches.append(branch);
715
+ });
716
+ graph.append(branches); surface.append(graph);
717
+ const rows = node('div', 'fanout-results');
718
+ for (const item of displayed) {
719
+ const row = node('div', 'fanout-result');
720
+ const elapsed = Number.isFinite(item.data.durationMs) ? duration(item.data.durationMs) : statusLabels[item.status] || item.status;
721
+ const summary = item.final || [...item.progress.values()].at(-1)?.text || `${item.tools.size} tool calls recorded`;
722
+ row.append(node('span', 'fanout-result-name', agentName(item.agent)), node('span', 'fanout-result-summary', summary), node('span', 'fanout-duration', elapsed));
723
+ rows.append(row);
724
+ }
725
+ surface.append(rows);
726
+ const footer = node('div', 'fanout-footer');
727
+ footer.append(node('span', '', related.length > displayed.length ? `${displayed.length} of ${related.length} agents shown` : `${cycle.events.length} events in this cycle`), badge(cycle.status));
728
+ card.append(header, surface, footer);
729
+ return card;
730
+ }
567
731
  function renderEventDetail(inspector, event) {
568
732
  inspector.replaceChildren(inspectorHeader('Event details', eventTitle(event), agentName(event.agent), eventStatus(event)));
569
733
  const section = detailSection('Recorded event', kindIcons[event.type.split('.')[0]] || 'pulse');
@@ -614,7 +778,7 @@ function initializeStudio() {
614
778
  for (const button of document.querySelectorAll('[data-view]')) {
615
779
  const selected = button.dataset.view === state.view; button.classList.toggle('is-selected', selected); button.setAttribute('aria-pressed', String(selected));
616
780
  }
617
- renderAgents(); renderMetrics(); renderActivity();
781
+ renderAgents(); renderMetrics(); renderActivity(); renderModelSettings();
618
782
  $('history-label').textContent = `Local history · ${state.model.events.length} of up to ${Number(state.snapshot.limits?.maxEvents) || MAX_EVENTS} recent events`;
619
783
  $('updated-at').textContent = `Snapshot at ${shortTime(state.snapshot.generatedAt)}`;
620
784
  $('updated-at').title = fullTime(state.snapshot.generatedAt);
@@ -622,6 +786,29 @@ function initializeStudio() {
622
786
  }
623
787
 
624
788
  $('server-address').textContent = location.host;
789
+ $('open-model-settings').addEventListener('click', () => {
790
+ const agent = state.model.agents.find(agent => agentId(agent) === state.selectedAgent);
791
+ if (agent?.settingsEditable) void openModelSettings(agent);
792
+ });
793
+ $('close-model-settings').addEventListener('click', () => $('model-settings-dialog').close());
794
+ $('model-settings-dialog').addEventListener('close', () => { settingsRequest++; editingAgent = null; editingSettings = null; });
795
+ $('reload-models').addEventListener('click', () => { if (editingAgent) void loadModels(editingAgent, settingsRequest); });
796
+ $('model-settings-form').addEventListener('submit', async event => {
797
+ event.preventDefault();
798
+ if (!editingAgent || !editingSettings) return;
799
+ const request = settingsRequest;
800
+ $('save-model-settings').disabled = true; $('save-model-settings').textContent = 'Saving…';
801
+ $('model-settings-error').hidden = true;
802
+ try {
803
+ await settingsFetch(`/api/agents/${encodeURIComponent(editingAgent.slug)}/settings`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ version: editingSettings.version, model: $('coding-model').value.trim(), chatModel: $('reply-model').value.trim() }) });
804
+ if (request !== settingsRequest) return;
805
+ $('model-settings-dialog').close();
806
+ toast('Models saved. New requests will use your choices once the watcher applies them.');
807
+ receive(await settingsFetch('/api/snapshot'));
808
+ } catch (error) {
809
+ if (request === settingsRequest) { $('model-settings-error').hidden = false; $('model-settings-error').textContent = error.message; }
810
+ } finally { $('save-model-settings').disabled = false; $('save-model-settings').textContent = 'Save models'; }
811
+ });
625
812
  $('pause-button').addEventListener('click', () => {
626
813
  state.paused = !state.paused;
627
814
  if (!state.paused && state.pending) { const pending = state.pending; state.pending = null; state.pendingUpdates = 0; receive(pending); }
package/studio/guide.html CHANGED
@@ -6,6 +6,7 @@
6
6
  <meta name="color-scheme" content="light">
7
7
  <meta name="description" content="Get started with Agent Studio, understand agent activity, and troubleshoot your local viewer.">
8
8
  <title>Agent Studio user guide · OpenVisio</title>
9
+ <link rel="icon" type="image/svg+xml" href="/openvisio.svg">
9
10
  <link rel="stylesheet" href="/style.css">
10
11
  </head>
11
12
  <body>
@@ -22,6 +23,7 @@
22
23
  <a href="#find-work">Find and inspect work</a>
23
24
  <a href="#statuses">Understand statuses</a>
24
25
  <a href="#controls">Pause and follow</a>
26
+ <a href="#models">Change models</a>
25
27
  <a href="#access">Keyboard and reading</a>
26
28
  <a href="#troubleshooting">Troubleshooting</a>
27
29
  <a href="#history">History and privacy</a>
@@ -30,13 +32,21 @@
30
32
  <section id="start">
31
33
  <h2>Open Studio</h2>
32
34
  <p>Agent Studio is a local viewer for bring-your-own (BYO) agents connected to an OpenVisio team. It shows recorded actions from Claude Code, Codex, and OpenCode watchers on this machine.</p>
33
- <p>With a Studio-capable version of <code>openvisio-agent</code> installed, run:</p>
35
+ <p>In the OpenVisio app, open <strong>Agents → Open Agent Studio</strong>. Agents can also include this button in chat replies. Updated backend watchers start Studio automatically on this computer.</p>
36
+ <p>You can also open Studio manually:</p>
34
37
  <pre><code>openvisio-agent studio</code></pre>
35
38
  <p>Your browser opens the local address printed in the terminal, usually <code>http://127.0.0.1:4317</code>. Keep that terminal running while you use Studio.</p>
36
39
  <p>To explore without connecting an agent:</p>
37
40
  <pre><code>openvisio-agent studio --demo</code></pre>
38
41
  <p>The demo uses clearly labeled sample activity. It makes no model calls. To return to your agents, press <kbd>Ctrl+C</kbd> in the Studio terminal and run the command again without <code>--demo</code>.</p>
39
42
  </section>
43
+ <section id="models">
44
+ <h2>Change models</h2>
45
+ <p>Select an agent in the sidebar, choose <strong>Model settings</strong>, select its reply and coding models, then choose <strong>Save models</strong>. Model fields let you search the runtime’s choices or enter a custom model ID.</p>
46
+ <p>Updated watchers apply saved choices to the next request without a restart. Work already running continues with its original model. Studio shows when a save is waiting for the watcher. Offline agents retain the selection for their next connection; older watchers need an update to apply changes live.</p>
47
+ <p>OpenCode choices come from its local model catalog. Listing a model does not guarantee credentials or available quota. If a model is rate-limited, choose one with available quota or retry after the provider’s limit resets.</p>
48
+ <p>Studio controls agents configured on this computer. An agent running on another teammate’s computer must be managed there.</p>
49
+ </section>
40
50
  <section id="watchers">
41
51
  <h2>Show your agents</h2>
42
52
  <p>A <strong>watcher</strong> is the local program that listens for assignments and messages, then starts your coding agent when work arrives. Studio displays what the watcher records.</p>
@@ -57,6 +67,7 @@
57
67
  <li>Select a row. Its details appear beside the list, or below it on a smaller screen.</li>
58
68
  </ol>
59
69
  <p>A <strong>cycle</strong> is one unit of agent work. It can include a plan, several tool calls, progress updates, and a final response. Expand <strong>Cycle history</strong> to review its recorded sequence.</p>
70
+ <p>Switch between <strong>Plan</strong>, <strong>Commands</strong>, and <strong>Response</strong> for steps, tool calls, and visible output. The <strong>Task activity</strong> card groups recorded work on the same ticket by agent; select an agent in the diagram to inspect its cycle. Expand <strong>Runtime details</strong> for model and timing information.</p>
60
71
  <p><strong>No explicit plan recorded</strong> means the provider did not send a plan in the available history. A missing process ID means the provider did not report that operating-system identifier. Neither message alone means the work failed.</p>
61
72
  <p>The process summary counts distinct reported process IDs associated with connected watchers. It is not a system-wide process monitor. The demo deliberately shows no real process count.</p>
62
73
  </section>
@@ -65,10 +76,11 @@
65
76
  <dl>
66
77
  <dt>Live</dt><dd>Your browser is connected to the local Studio server. Check each agent’s own status to see whether its watcher is connected.</dd>
67
78
  <dt>Queued / Active</dt><dd>Work is waiting to start, or the current watcher has recorded it as running.</dd>
68
- <dt>Completed</dt><dd>The selected action or cycle reported completion. A completed tool is only one step; inspect the cycle’s final response and the ticket for the overall result.</dd>
79
+ <dt>Completed</dt><dd>The selected action finished, or the agent ended its turn. Ending a turn does not automatically mark its ticket done. A completed tool is only one step; inspect the cycle’s final response and the ticket for the overall result.</dd>
69
80
  <dt>Blocked / Failed / Timed out</dt><dd>Read the cycle’s Outcome and final response, then check the ticket or watcher log for the cause. Studio has no retry or approval button.</dd>
70
81
  <dt>Cancelled / Skipped</dt><dd>The work was stopped or did not proceed. These are separate from successful completion.</dd>
71
82
  <dt>Idle / Connecting / Recovering</dt><dd>A coding runtime is waiting, connecting, or attempting recovery. These describe the runtime’s last recorded state.</dd>
83
+ <dt>Continued in workspace</dt><dd>The agent requested its coding workspace and carried the same request and context into a work session. Follow that session for the final response.</dd>
72
84
  <dt>Offline</dt><dd>The watcher stopped reporting, exited, or was replaced by a newer run. An unfinished cycle’s outcome is unknown; offline does not mean completed or failed.</dd>
73
85
  <dt>Simulated</dt><dd>This record belongs to the demonstration. No real agent is working on it.</dd>
74
86
  </dl>
@@ -112,7 +124,7 @@ openvisio-agent watch --name ada</code></pre>
112
124
  <h2>History and privacy</h2>
113
125
  <p>Studio reads local journals under <code>~/.openvisio/observability</code>. The viewer serves up to 500 recent events from a bounded set of journal files. Logs rotate, large text can be shortened, and bursts can drop records. This is a recent activity view, not a complete audit archive.</p>
114
126
  <p>The journal records visible plans, tool metadata, and public output. Private reasoning and raw tool payloads are excluded. Known credentials and recognizable secret formats are redacted, but visible output can still include project text and file paths. Review it before sharing a screenshot or journal.</p>
115
- <p>Studio’s server accepts local connections only and has no controls that modify your team or start model calls. Your running watchers continue to use their configured team and provider connections.</p>
127
+ <p>Studio’s server accepts local connections only. Saving models changes only the selected agent’s local model settings; it does not start a model call or modify your team. Your running watchers continue to use their configured team and provider connections.</p>
116
128
  <p><a href="/">Return to Agent Studio</a></p>
117
129
  </section>
118
130
  </main>