App._agentsTab = 'roster';
App._availableModelsCache = [];
App._availableModelsProvidersReport = {};
App._availableModelsProxyRuntime = {};
App._availableModelsFetchedAt = 0;
App._availableModelsInFlight = null;
App._loadAvailableModels = function(opts) {
opts = opts || {};
var self = this;
var now = Date.now();
var cacheTtlMs = opts.cacheTtlMs != null ? opts.cacheTtlMs : 300000;
var timeoutMs = opts.timeoutMs != null ? opts.timeoutMs : 1200;
var skipFetch = !!opts.skipFetch;
var validationLevel = (opts.validationLevel || 'zero').toString().toLowerCase();
var cached = Array.isArray(self._availableModelsCache) ? self._availableModelsCache : [];
var cacheFresh = self._availableModelsFetchedAt && ((now - self._availableModelsFetchedAt) < cacheTtlMs);
if (cacheFresh && !opts.forceRefresh) return Promise.resolve(cached);
if (skipFetch) return Promise.resolve(cached);
if (!self._availableModelsInFlight) {
var modelsPath = '/api/models/available?validation_level=' + encodeURIComponent(validationLevel);
self._availableModelsInFlight = api(modelsPath)
.then(function(payload) {
var models = (payload && payload.models) || [];
self._availableModelsCache = Array.isArray(models) ? models : [];
self._availableModelsProvidersReport = (payload && payload.providers) || {};
self._availableModelsProxyRuntime = (payload && payload.proxy) || {};
self._availableModelsFetchedAt = Date.now();
return self._availableModelsCache;
})
.catch(function() {
return cached;
})
.finally(function() {
self._availableModelsInFlight = null;
});
}
if (opts.nonBlocking) return Promise.resolve(cached);
return Promise.race([
self._availableModelsInFlight,
new Promise(function(resolve) { setTimeout(function() { resolve(cached); }, timeoutMs); })
]);
};
App.renderAgents = function() {
var self = this;
var tab = self._agentsTab || 'roster';
var tabs = '<div class="tabs" style="margin-bottom:1rem">'
+ '<button class="' + (tab === 'roster' ? 'active' : '') + '" data-agents-tab="roster">Roster</button>'
+ '<button class="' + (tab === 'list' ? 'active' : '') + '" data-agents-tab="list">List</button>'
+ '</div>';
var render = tab === 'list' ? self._renderListTab() : self._renderRosterTab();
return render.then(function(body) { return tabs + body; });
};
App._renderRosterTab = function() {
return api('/api/roster').then(function(data) {
var roster = data.roster || [];
if (roster.length === 0) return '<p style="color:var(--muted)">No taskable agents found.</p>';
var grid = roster.map(function(agent) {
var isCmd = agent.role === 'orchestrator';
var borderColor = agent.color || 'var(--accent)';
var stateColor = agent.state === 'Running' ? '#22c55e' : (agent.state === 'Error' ? '#ef4444' : (agent.state === 'Disabled' ? '#9baad6' : '#eab308'));
var stateDot = '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + stateColor + ';margin-right:6px"></span>';
var displayName = agent.display_name || agent.name;
var model = agent.model || '—';
var modelShort = model.length > 28 ? model.substring(model.lastIndexOf('/') + 1) : model;
var sessionCount = agent.session_count != null ? agent.session_count : '\u2014';
var lastUsedLabel = formatTimestampLabel(agent.last_used_at);
var roleLabel = isCmd ? 'ORCHESTRATOR' : 'SUBAGENT';
var roleBg = isCmd ? 'rgba(234,179,8,0.15)' : 'rgba(193,128,255,0.15)';
var roleColor = isCmd ? '#eab308' : borderColor;
var card = '<div class="card roster-card" data-roster-id="' + esc(agent.name || agent.id) + '" style="cursor:pointer;border-left:3px solid ' + borderColor + ';padding:1rem;transition:transform 0.15s,box-shadow 0.15s' + (!agent.enabled ? ';opacity:0.5' : '') + '">'
+ '<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:0.75rem;margin-bottom:0.5rem">'
+ '<div style="display:flex;align-items:flex-start;gap:0.5rem;flex:1;min-width:0">'
+ stateDot
+ '<span style="font-weight:700;font-size:1rem;line-height:1.2;color:var(--text);display:block;min-width:0;overflow-wrap:anywhere;word-break:break-word">' + esc(displayName) + '</span>'
+ '</div>'
+ '<span style="font-size:0.65rem;font-weight:600;letter-spacing:0.05em;padding:2px 8px;border-radius:3px;background:' + roleBg + ';color:' + roleColor + ';flex:0 0 auto;white-space:nowrap;align-self:flex-start">' + roleLabel + '</span>'
+ '</div>'
+ '<div style="font-size:0.75rem;color:var(--muted);margin-bottom:0.4rem;font-family:var(--mono)">' + esc(modelShort) + '</div>'
+ '<div style="display:flex;gap:1rem;font-size:0.75rem;color:var(--muted);flex-wrap:wrap">'
+ (function() {
var sk = agent.skills || [];
if (sk.length === 0) return '<span>0 skills</span>';
var maxShow = 3;
var shown = sk.slice(0, maxShow).map(function(s) {
var name = typeof s === 'string' ? s : (s.name || String(s));
return '<span class="badge muted" style="font-size:0.6rem">' + esc(name) + '</span>';
}).join(' ');
var overflow = sk.length > maxShow ? ' <span style="font-size:0.6rem;color:var(--muted)">+' + (sk.length - maxShow) + '</span>' : '';
return shown + overflow;
})()
+ '<span>' + sessionCount + ' sessions</span>'
+ '<span>last used ' + esc(lastUsedLabel) + '</span>'
+ '</div>'
+ '</div>';
return card;
}).join('');
var cmdCount = roster.filter(function(a) { return a.role === 'orchestrator'; }).length;
var specCount = roster.filter(function(a) { return a.role === 'subagent'; }).length;
var header = '<div style="display:flex;align-items:baseline;gap:1rem;margin-bottom:1rem;flex-wrap:wrap">'
+ '<span style="font-size:0.8125rem;color:var(--muted)">' + roster.length + ' taskable agents</span>'
+ '<span class="badge">' + cmdCount + ' orchestrator</span>'
+ '<span class="badge">' + specCount + ' subagents</span>'
+ '</div>';
return header
+ '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:0.75rem">' + grid + '</div>'
+ '<div id="roster-modal" style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,0.6);backdrop-filter:blur(4px);padding:2rem;overflow-y:auto"></div>';
});
};
App._rosterCache = null;
App._renderListTab = function() {
var self = this;
return Promise.all([
api('/api/subagents'),
self._loadAvailableModels({ nonBlocking: true, skipFetch: true }),
fetchWithFallback('/api/skills', { skills: [] }, 'skills')
]).then(function(arr) {
var data = arr[0] || {};
var discoveredModels = arr[1] || [];
var skillsData = (arr[2] && arr[2].data) ? arr[2].data : { skills: [] };
var agents = data.agents || [];
var skillRecords = (skillsData.skills || []).filter(function(s) { return s && s.name; });
var skillNameSet = {};
var allSkillNames = [];
skillRecords.forEach(function(s) {
var key = String(s.name).toLowerCase();
if (!skillNameSet[key]) {
skillNameSet[key] = true;
allSkillNames.push(String(s.name));
}
});
allSkillNames.sort();
var enabledSkillNames = skillRecords
.filter(function(s) { return !!s.enabled; })
.map(function(s) { return String(s.name); });
var enabledSkillCount = enabledSkillNames.length;
var enabledSkillHint = enabledSkillNames.length
? enabledSkillNames.slice(0, 10).join(', ') + (enabledSkillNames.length > 10 ? ' ...' : '')
: 'No enabled skills found. Enable skills from the Skills page first.';
var skillOptions = allSkillNames
.map(function(name) { return '<option value="' + esc(name) + '"></option>'; })
.join('');
var specialists = agents.filter(function(a) { return a.role === 'subagent' || a.role === 'specialist'; });
var proxies = agents.filter(function(a) { return a.role === 'model-proxy'; });
var enabledCount = specialists.filter(function(a) { return a.enabled; }).length;
var modelOptions = self._renderModelOptionsHtml({
discoveredModels: discoveredModels,
roster: specialists,
config: _cachedConfig,
includeControlModes: true
});
var html = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem"><div>';
html += '<span style="font-size:1.5rem;font-weight:700;color:var(--text)">' + specialists.length + ' Sub-Agents</span>';
html += '<span style="margin-left:0.75rem;font-size:0.8125rem;color:var(--muted)">' + enabledCount + ' enabled</span>';
html += '<span style="margin-left:0.75rem;font-size:0.8125rem;color:var(--muted)">' + enabledSkillCount + ' shared runtime skills</span>';
html += '</div>';
html += '<button class="btn" id="sa-add-btn" style="font-size:0.75rem;padding:0.4rem 1rem">+ Add Agent</button>';
html += '</div>';
if (proxies.length > 0) {
html += '<div class="card" style="margin-bottom:0.75rem;padding:0.75rem 1rem;font-size:0.75rem;color:var(--muted)">'
+ proxies.length + ' model prox' + (proxies.length === 1 ? 'y is' : 'ies are')
+ ' hidden from this taskable sub-agent list.'
+ '</div>';
}
function agentCard(a) {
var c = '';
var skills = a.skills || [];
var fixedSkillCount = skills.length;
var lastUsedLabel = formatTimestampLabel(a.last_used_at);
var statusBadge = a.enabled
? '<span class="badge success">enabled</span>'
: '<span class="badge" style="background:var(--surface);color:var(--muted)">disabled</span>';
var roleBadge = '<span class="badge" style="background:rgba(193,128,255,0.15);color:#818cf8;margin-left:0.25rem">' + esc(a.role) + '</span>';
c += '<div class="card" style="margin-bottom:0.75rem;opacity:' + (a.enabled ? '1' : '0.6') + '">';
c += '<div style="display:flex;align-items:center;justify-content:space-between">';
c += '<div style="display:flex;align-items:center;gap:0.5rem">';
c += '<span style="font-size:1.25rem">\uD83E\uDD16</span>';
c += '<div>';
c += '<div style="font-weight:600;color:var(--text)">' + esc(a.display_name || a.name) + '</div>';
c += '<div style="font-size:0.6875rem;color:var(--muted);font-family:var(--font-mono)">' + esc(a.name) + '</div>';
c += '</div>';
c += '</div>';
c += '<div style="display:flex;align-items:center;gap:0.5rem">' + statusBadge + roleBadge + '</div>';
c += '</div>';
c += '<div style="display:flex;gap:1rem;margin-top:0.75rem;font-size:0.75rem;color:var(--muted)">';
if (a.model) c += '<div><span style="color:var(--text-dim)">Model:</span> ' + esc(a.model) + '</div>';
if (a.fallback_models && a.fallback_models.length) c += '<div><span style="color:var(--text-dim)">Fallbacks:</span> ' + esc(a.fallback_models.join(', ')) + '</div>';
if (a.model_mode && a.model_mode !== 'fixed') c += '<div><span style="color:var(--text-dim)">Mode:</span> ' + esc(a.model_mode) + '</div>';
if (a.resolved_model) c += '<div><span style="color:var(--text-dim)">Resolved:</span> ' + esc(a.resolved_model) + '</div>';
c += '<div><span style="color:var(--text-dim)">Fixed skills:</span> '
+ '<button class="sa-edit-skills"'
+ ' data-name="' + esc(a.name) + '"'
+ ' data-display="' + esc(a.display_name || '') + '"'
+ ' data-model="' + esc(a.model || '') + '"'
+ ' data-fallbacks="' + esc((a.fallback_models || []).join(",")) + '"'
+ ' data-role="' + esc(a.role) + '"'
+ ' data-skills="' + esc((a.skills || []).join(",")) + '"'
+ ' data-desc="' + esc(a.description || '') + '"'
+ ' style="margin-left:0.25rem;background:transparent;border:1px solid var(--border);border-radius:4px;padding:0.05rem 0.35rem;color:var(--text);cursor:pointer;font-size:0.7rem">'
+ fixedSkillCount
+ '</button></div>';
if (skills.length > 0) {
c += '<div style="display:flex;flex-wrap:wrap;gap:3px;margin-top:4px">';
skills.forEach(function(s) {
var name = typeof s === 'string' ? s : (s.name || String(s));
c += '<span class="badge muted" style="font-size:0.6rem">' + esc(name) + '</span>';
});
c += '</div>';
}
c += '<div><span style="color:var(--text-dim)">Shared skills:</span> ' + enabledSkillCount + '</div>';
c += '<div><span style="color:var(--text-dim)">Sessions:</span> ' + (a.session_count != null ? a.session_count : '\u2014') + '</div>';
c += '<div><span style="color:var(--text-dim)">Last used:</span> ' + esc(lastUsedLabel) + '</div>';
if (a.description) c += '<div style="flex:1">' + esc(a.description) + '</div>';
c += '</div>';
c += '<div style="display:flex;gap:0.5rem;margin-top:0.75rem;border-top:1px solid var(--border);padding-top:0.6rem">';
c += '<button class="btn secondary sa-toggle" data-name="' + esc(a.name) + '" style="font-size:0.6875rem;padding:0.25rem 0.6rem">' + (a.enabled ? 'Disable' : 'Enable') + '</button>';
c += '<button class="btn secondary sa-edit" data-name="' + esc(a.name) + '" data-display="' + esc(a.display_name || '') + '" data-model="' + esc(a.model || '') + '" data-fallbacks="' + esc((a.fallback_models || []).join(",")) + '" data-role="' + esc(a.role) + '" data-skills="' + esc((a.skills || []).join(",")) + '" data-desc="' + esc(a.description || '') + '" style="font-size:0.6875rem;padding:0.25rem 0.6rem">Edit</button>';
c += '<button class="btn secondary sa-delete" data-name="' + esc(a.name) + '" style="font-size:0.6875rem;padding:0.25rem 0.6rem;color:var(--error)">Delete</button>';
c += '</div>';
c += '</div>';
return c;
}
if (specialists.length > 0) {
html += '<div style="font-size:0.8125rem;font-weight:600;color:var(--text);margin-bottom:0.5rem;text-transform:uppercase;letter-spacing:0.05em">Subagents</div>';
specialists.forEach(function(a) { html += agentCard(a); });
}
if (specialists.length === 0) {
html += '<div class="card" style="text-align:center;padding:3rem"><div style="font-size:2rem;margin-bottom:0.5rem">\uD83E\uDD16</div>';
html += '<div style="color:var(--muted)">No taskable sub-agents configured</div>';
html += '<div style="font-size:0.75rem;color:var(--muted);margin-top:0.5rem">Import a prior agent export with <code style="background:var(--surface);padding:0.1rem 0.4rem;border-radius:4px">roboticus migrate import <export-root> --areas agents</code> or add one below</div>';
html += '</div>';
}
html += '<div id="sa-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;display:none;align-items:center;justify-content:center">';
html += '<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.5rem;width:min(440px,90vw)">';
html += '<div style="font-weight:600;font-size:1rem;margin-bottom:1rem" id="sa-modal-title">Add Sub-Agent</div>';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Name (kebab-case)</label>';
html += '<input class="settings-input" id="sa-m-name" placeholder="e.g. data-analyst" style="margin-bottom:0.75rem;font-family:var(--font-mono)">';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Display Name</label>';
html += '<input class="settings-input" id="sa-m-display" placeholder="e.g. Data Analyst" style="margin-bottom:0.75rem">';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Model</label>';
html += '<input class="settings-input" id="sa-m-model" list="sa-model-options" placeholder="e.g. ollama/qwen3:8b | auto | orchestrator" style="margin-bottom:0.75rem;font-family:var(--font-mono)">';
html += '<datalist id="sa-model-options">' + modelOptions + '</datalist>';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Fallback Models (comma-separated)</label>';
html += '<input class="settings-input" id="sa-m-fallbacks" list="sa-model-options" placeholder="e.g. openrouter/openai/gpt-4o, moonshot/kimi-k2-turbo-preview" style="margin-bottom:0.75rem;font-family:var(--font-mono)">';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Role</label>';
html += '<select class="settings-input" id="sa-m-role" style="margin-bottom:0.75rem"><option value="subagent">subagent</option><option value="model-proxy">model-proxy</option></select>';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Fixed Skills (comma-separated)</label>';
html += '<input class="settings-input" id="sa-m-skills" list="sa-skill-options" placeholder="e.g. research,summarization" style="margin-bottom:0.35rem">';
html += '<datalist id="sa-skill-options">' + skillOptions + '</datalist>';
html += '<div id="sa-m-skills-help" style="font-size:0.6875rem;color:var(--muted);margin-bottom:0.75rem">Available: ' + esc(enabledSkillHint) + '</div>';
html += '<label style="font-size:0.75rem;color:var(--muted);display:block;margin-bottom:0.25rem">Description</label>';
html += '<textarea class="settings-input" id="sa-m-desc" rows="2" placeholder="What does this agent do?" style="margin-bottom:1rem;resize:vertical"></textarea>';
html += '<div style="display:flex;gap:0.5rem;justify-content:flex-end">';
html += '<button class="btn secondary" id="sa-m-cancel" style="font-size:0.75rem;padding:0.35rem 1rem">Cancel</button>';
html += '<button class="btn" id="sa-m-save" style="font-size:0.75rem;padding:0.35rem 1rem">Save</button>';
html += '</div></div></div>';
setTimeout(function() {
var modal = document.getElementById('sa-modal');
var editingName = null;
var skillsHelpDefault = 'Available: ' + enabledSkillHint;
function syncRoleSkillsState() {
var role = ((document.getElementById('sa-m-role') || {}).value || 'subagent').toLowerCase();
var skillsInput = document.getElementById('sa-m-skills');
var help = document.getElementById('sa-m-skills-help');
if (!skillsInput || !help) return;
if (role === 'model-proxy') {
skillsInput.disabled = true;
skillsInput.value = '';
help.textContent = 'Model proxies cannot own fixed skills. Set role to subagent to assign skills.';
} else {
skillsInput.disabled = false;
help.textContent = skillsHelpDefault;
}
}
function openModal(title, vals) {
if (!modal) return;
modal.style.display = 'flex';
var t = document.getElementById('sa-modal-title'); if (t) t.textContent = title;
var nameInp = document.getElementById('sa-m-name');
if (nameInp) { nameInp.value = vals.name || ''; nameInp.disabled = !!vals.editing; }
var d = document.getElementById('sa-m-display'); if (d) d.value = vals.display || '';
var m = document.getElementById('sa-m-model'); if (m) m.value = vals.model || '';
var fb = document.getElementById('sa-m-fallbacks'); if (fb) fb.value = vals.fallbacks || '';
var r = document.getElementById('sa-m-role'); if (r) r.value = vals.role || 'subagent';
var sk = document.getElementById('sa-m-skills'); if (sk) sk.value = vals.skills || '';
var desc = document.getElementById('sa-m-desc'); if (desc) desc.value = vals.desc || '';
editingName = vals.editing || null;
syncRoleSkillsState();
}
function closeModal() { if (modal) modal.style.display = 'none'; editingName = null; }
var addBtn = document.getElementById('sa-add-btn');
if (addBtn) addBtn.onclick = function() { openModal('Add Sub-Agent', {}); };
var cancelBtn = document.getElementById('sa-m-cancel');
if (cancelBtn) cancelBtn.onclick = closeModal;
if (modal) modal.onclick = function(e) { if (e.target === modal) closeModal(); };
var roleSelect = document.getElementById('sa-m-role');
if (roleSelect) roleSelect.onchange = syncRoleSkillsState;
var saveBtn = document.getElementById('sa-m-save');
if (saveBtn) saveBtn.onclick = function() {
var nameVal = (document.getElementById('sa-m-name') || {}).value || '';
var displayVal = (document.getElementById('sa-m-display') || {}).value || '';
var modelVal = (document.getElementById('sa-m-model') || {}).value || '';
var fallbackVals = (document.getElementById('sa-m-fallbacks') || {}).value || '';
var roleVal = (document.getElementById('sa-m-role') || {}).value || 'subagent';
var skillsVal = (document.getElementById('sa-m-skills') || {}).value || '';
var skills = skillsVal.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
var fallbacks = fallbackVals.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
var descVal = (document.getElementById('sa-m-desc') || {}).value || '';
if (!nameVal && !editingName) { toast('Name is required'); return; }
if (editingName) {
api('/api/subagents/' + encodeURIComponent(editingName), {
method: 'PUT', headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json' }),
body: JSON.stringify({ display_name: displayVal || null, model: modelVal || null, fallback_models: fallbacks, role: roleVal, skills: skills, description: descVal || null })
}).then(function() { toast('Agent updated'); closeModal(); self.navigate('agents'); });
} else {
api('/api/subagents', {
method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json', 'Accept': 'application/json' }),
body: JSON.stringify({ name: nameVal, display_name: displayVal || null, model: modelVal, fallback_models: fallbacks, role: roleVal, skills: skills, description: descVal || null })
}).then(function() { toast('Agent created'); closeModal(); self.navigate('agents'); });
}
};
document.querySelectorAll('.sa-toggle').forEach(function(btn) {
btn.onclick = function() {
var n = this.getAttribute('data-name');
api('/api/subagents/' + encodeURIComponent(n) + '/toggle', {
method: 'PUT', headers: authHeaders({ 'Accept': 'application/json' })
}).then(function() { toast('Agent toggled'); self.navigate('agents'); });
};
});
document.querySelectorAll('.sa-edit').forEach(function(btn) {
btn.onclick = function() {
var n = this.getAttribute('data-name');
openModal('Edit: ' + n, {
name: n, editing: n,
display: this.getAttribute('data-display'),
model: this.getAttribute('data-model'),
fallbacks: this.getAttribute('data-fallbacks'),
role: this.getAttribute('data-role'),
skills: this.getAttribute('data-skills'),
desc: this.getAttribute('data-desc')
});
};
});
document.querySelectorAll('.sa-edit-skills').forEach(function(btn) {
btn.onclick = function() {
var n = this.getAttribute('data-name');
openModal('Edit skills: ' + n, {
name: n, editing: n,
display: this.getAttribute('data-display'),
model: this.getAttribute('data-model'),
fallbacks: this.getAttribute('data-fallbacks'),
role: this.getAttribute('data-role'),
skills: this.getAttribute('data-skills'),
desc: this.getAttribute('data-desc')
});
var input = document.getElementById('sa-m-skills');
if (input && !input.disabled) input.focus();
};
});
document.querySelectorAll('.sa-delete').forEach(function(btn) {
btn.onclick = function() {
var n = this.getAttribute('data-name');
if (!confirm('Delete sub-agent "' + n + '"? This cannot be undone.')) return;
api('/api/subagents/' + encodeURIComponent(n), {
method: 'DELETE', headers: authHeaders({ 'Accept': 'application/json' })
}).then(function() { toast('Agent deleted'); self.navigate('agents'); });
};
});
if (self._pendingSubagentEdit) {
var pending = self._pendingSubagentEdit;
self._pendingSubagentEdit = null;
openModal('Edit: ' + pending.name, pending);
}
}, 0);
return html;
});
};