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/CHANGELOG.md +797 -0
- package/README.md +8 -2
- package/USER_GUIDE.md +15 -6
- package/package.json +3 -2
- package/scenarios/runtime.scenarios.mjs +6 -11
- package/scenarios/workspace.scenarios.mjs +4 -3
- package/scripts/certify.mjs +13 -13
- package/src/codex-mcp-proxy.mjs +12 -1
- package/src/events.mjs +23 -3
- package/src/mastra-harness.mjs +51 -22
- package/src/memory.mjs +4 -3
- package/src/model-settings.mjs +57 -0
- package/src/opencode-config.mjs +13 -16
- package/src/runtime-control.mjs +50 -0
- package/src/studio-cli.mjs +11 -0
- package/src/studio-server.mjs +43 -7
- package/src/watch.mjs +239 -168
- package/studio/app.mjs +234 -47
- package/studio/guide.html +15 -3
- package/studio/index.html +45 -9
- package/studio/openvisio.svg +1 -0
- package/studio/satoshi-400.woff2 +0 -0
- package/studio/satoshi-500.woff2 +0 -0
- package/studio/style.css +240 -70
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('
|
|
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: () => {
|
|
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
|
|
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
|
|
499
|
-
inspector.replaceChildren(
|
|
500
|
-
const overview =
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
const
|
|
522
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
list
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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.
|
|
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 (
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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>
|
|
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
|
|
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
|
|
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>
|