bosun 0.37.0 → 0.37.2

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.
Files changed (43) hide show
  1. package/.env.example +4 -1
  2. package/agent-tool-config.mjs +338 -0
  3. package/bosun-skills.mjs +59 -4
  4. package/bosun.schema.json +1 -1
  5. package/desktop/launch.mjs +18 -0
  6. package/desktop/main.mjs +52 -13
  7. package/fleet-coordinator.mjs +34 -1
  8. package/kanban-adapter.mjs +30 -3
  9. package/library-manager.mjs +66 -0
  10. package/maintenance.mjs +30 -5
  11. package/monitor.mjs +56 -0
  12. package/package.json +4 -1
  13. package/setup-web-server.mjs +73 -12
  14. package/setup.mjs +3 -3
  15. package/ui/app.js +40 -3
  16. package/ui/components/session-list.js +25 -7
  17. package/ui/components/workspace-switcher.js +48 -1
  18. package/ui/demo.html +176 -0
  19. package/ui/modules/mic-track-registry.js +83 -0
  20. package/ui/modules/settings-schema.js +4 -1
  21. package/ui/modules/state.js +25 -0
  22. package/ui/modules/streaming.js +1 -1
  23. package/ui/modules/voice-barge-in.js +27 -0
  24. package/ui/modules/voice-client-sdk.js +268 -42
  25. package/ui/modules/voice-client.js +665 -61
  26. package/ui/modules/voice-overlay.js +829 -47
  27. package/ui/setup.html +151 -9
  28. package/ui/styles.css +258 -0
  29. package/ui/tabs/chat.js +11 -0
  30. package/ui/tabs/library.js +890 -15
  31. package/ui/tabs/settings.js +51 -11
  32. package/ui/tabs/telemetry.js +327 -105
  33. package/ui/tabs/workflows.js +86 -0
  34. package/ui-server.mjs +1201 -107
  35. package/voice-action-dispatcher.mjs +81 -0
  36. package/voice-agents-sdk.mjs +2 -2
  37. package/voice-relay.mjs +131 -14
  38. package/voice-tools.mjs +475 -9
  39. package/workflow-engine.mjs +54 -0
  40. package/workflow-nodes.mjs +177 -28
  41. package/workflow-templates/github.mjs +205 -94
  42. package/workflow-templates/task-batch.mjs +247 -0
  43. package/workflow-templates.mjs +15 -0
@@ -153,6 +153,79 @@ const LIBRARY_STYLES = `
153
153
  .library-stat { min-width: 64px; }
154
154
  .library-stat-val { font-size: 1.3em; }
155
155
  }
156
+
157
+ /* ── MCP Marketplace ────────────────────────────────── */
158
+ .mcp-section { display: flex; flex-direction: column; gap: 12px; }
159
+ .mcp-section-header { display: flex; align-items: center; gap: 8px; }
160
+ .mcp-section-header h3 { margin: 0; font-size: 1em; flex: 1; }
161
+ .mcp-catalog-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 10px; }
162
+ .mcp-card { background: var(--bg-card, #1a1a2e); border: 1px solid var(--border, #333);
163
+ border-radius: 10px; padding: 14px; display: flex; flex-direction: column; gap: 8px;
164
+ transition: border-color 0.15s; }
165
+ .mcp-card:hover { border-color: var(--accent, #58a6ff); }
166
+ .mcp-card-header { display: flex; align-items: center; gap: 10px; }
167
+ .mcp-card-name { font-weight: 600; font-size: 0.92em; color: var(--text-primary, #eee); flex: 1; }
168
+ .mcp-card-desc { font-size: 0.8em; color: var(--text-secondary, #aaa); line-height: 1.4; }
169
+ .mcp-card-tags { display: flex; gap: 4px; flex-wrap: wrap; }
170
+ .mcp-card-tag { font-size: 0.72em; padding: 2px 7px; border-radius: 8px;
171
+ background: rgba(88,166,255,0.1); color: var(--accent, #58a6ff); }
172
+ .mcp-card-actions { display: flex; gap: 6px; margin-top: auto; }
173
+ .mcp-card-actions button { padding: 5px 14px; border-radius: 7px; border: 1px solid var(--border, #333);
174
+ background: transparent; color: var(--text-secondary, #aaa); cursor: pointer; font-size: 0.82em;
175
+ transition: all 0.15s; }
176
+ .mcp-card-actions button:hover { border-color: var(--accent, #58a6ff); color: var(--text-primary, #eee); }
177
+ .mcp-card-actions .btn-install { background: var(--accent, #58a6ff); color: #fff; border-color: var(--accent, #58a6ff); }
178
+ .mcp-card-actions .btn-install:hover { filter: brightness(1.1); }
179
+ .mcp-card-actions .btn-installed { background: rgba(63,185,80,0.15); color: #3fb950; border-color: rgba(63,185,80,0.3); cursor: default; }
180
+ .mcp-card-actions .btn-uninstall { color: #d73a49; border-color: rgba(215,58,73,0.3); }
181
+ .mcp-card-actions .btn-uninstall:hover { background: rgba(215,58,73,0.1); }
182
+
183
+ .mcp-env-editor { display: flex; flex-direction: column; gap: 6px; margin-top: 8px;
184
+ padding: 10px; background: var(--bg-input, #0d1117); border-radius: 8px; border: 1px solid var(--border, #333); }
185
+ .mcp-env-row { display: flex; gap: 6px; align-items: center; }
186
+ .mcp-env-key { font-size: 0.78em; font-family: monospace; color: var(--accent, #58a6ff); min-width: 140px; }
187
+ .mcp-env-input { flex: 1; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border, #333);
188
+ background: var(--bg-card, #1a1a2e); color: var(--text-primary, #eee); font-size: 0.82em; font-family: monospace; }
189
+
190
+ /* Custom MCP form */
191
+ .mcp-custom-form { display: flex; flex-direction: column; gap: 10px; padding: 14px;
192
+ background: var(--bg-card, #1a1a2e); border: 1px solid var(--border, #333); border-radius: 10px; }
193
+ .mcp-custom-form label { display: flex; flex-direction: column; gap: 3px; font-size: 0.82em;
194
+ color: var(--text-secondary, #aaa); }
195
+ .mcp-custom-form input, .mcp-custom-form select { padding: 6px 10px; border-radius: 7px;
196
+ border: 1px solid var(--border, #333); background: var(--bg-input, #0d1117);
197
+ color: var(--text-primary, #eee); font-size: 0.85em; }
198
+
199
+ /* ── Tool Configuration ────────────────────────────── */
200
+ .tool-config-section { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
201
+ .tool-config-header { display: flex; align-items: center; gap: 8px; padding-bottom: 8px;
202
+ border-bottom: 1px solid var(--border, #333); }
203
+ .tool-config-header h4 { margin: 0; font-size: 0.92em; flex: 1; color: var(--text-primary, #eee); }
204
+ .tool-config-group { display: flex; flex-direction: column; gap: 2px; }
205
+ .tool-config-group-label { font-size: 0.78em; color: var(--text-secondary, #aaa); text-transform: uppercase;
206
+ letter-spacing: 0.04em; margin: 8px 0 4px; font-weight: 600; }
207
+ .tool-config-item { display: flex; align-items: center; gap: 10px; padding: 8px 10px;
208
+ border-radius: 8px; transition: background 0.1s; }
209
+ .tool-config-item:hover { background: rgba(255,255,255,0.03); }
210
+ .tool-config-item-icon { font-size: 1.1em; width: 24px; text-align: center; flex-shrink: 0; }
211
+ .tool-config-item-icon svg { width: 16px; height: 16px; vertical-align: middle; }
212
+ .tool-config-item-info { flex: 1; min-width: 0; }
213
+ .tool-config-item-name { font-size: 0.88em; font-weight: 500; color: var(--text-primary, #eee); }
214
+ .tool-config-item-desc { font-size: 0.76em; color: var(--text-secondary, #aaa); }
215
+ .tool-config-toggle { flex-shrink: 0; }
216
+
217
+ /* ── Agent Detail with Tools ──────────────────────── */
218
+ .agent-tools-section { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border, #333); }
219
+ .agent-tools-tabs { display: flex; gap: 6px; margin-bottom: 10px; }
220
+ .agent-tools-tab { padding: 5px 14px; border-radius: 14px; border: 1px solid var(--border, #333);
221
+ background: transparent; color: var(--text-secondary, #aaa); cursor: pointer; font-size: 0.82em;
222
+ transition: all 0.15s; }
223
+ .agent-tools-tab:hover { border-color: var(--accent, #58a6ff); }
224
+ .agent-tools-tab.active { background: var(--accent, #58a6ff); color: #fff; border-color: var(--accent, #58a6ff); }
225
+
226
+ @media (min-width: 1000px) {
227
+ .mcp-catalog-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
228
+ }
156
229
  `;
157
230
 
158
231
  let stylesInjected = false;
@@ -172,8 +245,11 @@ const entries = signal([]);
172
245
  const scopes = signal([]);
173
246
  const isLoading = signal(false);
174
247
  const initialized = signal(false);
175
- const filterType = signal("all"); // "all" | "prompt" | "agent" | "skill"
248
+ const filterType = signal("all"); // "all" | "prompt" | "agent" | "skill" | "mcp"
176
249
  const searchQuery = signal("");
250
+ const mcpCatalog = signal([]);
251
+ const mcpInstalled = signal([]);
252
+ const mcpCatalogLoaded = signal(false);
177
253
 
178
254
  /* ═══════════════════════════════════════════════════════════════
179
255
  * API Helpers
@@ -230,13 +306,144 @@ async function testProfileMatch(title) {
230
306
  return res?.data || null;
231
307
  }
232
308
 
309
+ /* ── MCP API Helpers ─ */
310
+
311
+ async function fetchMcpCatalog() {
312
+ const res = await apiFetch("/api/mcp/catalog");
313
+ return res?.data || [];
314
+ }
315
+
316
+ async function fetchMcpInstalled() {
317
+ const res = await apiFetch("/api/mcp/installed");
318
+ return res?.data || [];
319
+ }
320
+
321
+ async function installMcp(idOrDef, envOverrides) {
322
+ const body = typeof idOrDef === "string"
323
+ ? { id: idOrDef, envOverrides }
324
+ : { serverDef: idOrDef, envOverrides };
325
+ return apiFetch("/api/mcp/install", {
326
+ method: "POST",
327
+ headers: { "Content-Type": "application/json" },
328
+ body: JSON.stringify(body),
329
+ });
330
+ }
331
+
332
+ async function uninstallMcp(id) {
333
+ return apiFetch("/api/mcp/uninstall", {
334
+ method: "POST",
335
+ headers: { "Content-Type": "application/json" },
336
+ body: JSON.stringify({ id }),
337
+ });
338
+ }
339
+
340
+ async function configureMcpEnv(id, env) {
341
+ return apiFetch("/api/mcp/configure", {
342
+ method: "POST",
343
+ headers: { "Content-Type": "application/json" },
344
+ body: JSON.stringify({ id, env }),
345
+ });
346
+ }
347
+
348
+ /* ── Agent Tool Config API Helpers ─ */
349
+
350
+ async function fetchAvailableTools() {
351
+ const res = await apiFetch("/api/agent-tools/available");
352
+ return res?.data || { builtinTools: [], bosunTools: [], mcpServers: [] };
353
+ }
354
+
355
+ async function fetchAgentToolConfig(agentId) {
356
+ const res = await apiFetch(`/api/agent-tools/config?agentId=${encodeURIComponent(agentId)}`);
357
+ return res?.data || { builtinTools: [], bosunTools: [], mcpServers: [], enabledTools: null };
358
+ }
359
+
360
+ async function saveAgentToolConfig(agentId, config) {
361
+ return apiFetch("/api/agent-tools/config", {
362
+ method: "POST",
363
+ headers: { "Content-Type": "application/json" },
364
+ body: JSON.stringify({ agentId, ...config }),
365
+ });
366
+ }
367
+
368
+ async function fetchBuiltinToolDefaults() {
369
+ const res = await apiFetch("/api/agent-tools/defaults");
370
+ return res?.data?.builtinTools || [];
371
+ }
372
+
233
373
  /* ═══════════════════════════════════════════════════════════════
234
374
  * Icons per type
235
375
  * ═══════════════════════════════════════════════════════════════ */
236
376
 
237
- const TYPE_ICONS = { prompt: ":edit:", agent: ":bot:", skill: ":cpu:" };
238
- const TYPE_LABELS = { prompt: "Prompt", agent: "Agent Profile", skill: "Skill" };
239
- const TYPE_COLORS = { prompt: "#58a6ff", agent: "#af7bff", skill: "#3fb950" };
377
+ const TYPE_ICONS = { prompt: ":edit:", agent: ":bot:", skill: ":cpu:", mcp: ":plug:" };
378
+ const TYPE_LABELS = { prompt: "Prompt", agent: "Agent Profile", skill: "Skill", mcp: "MCP Server" };
379
+ const TYPE_COLORS = { prompt: "#58a6ff", agent: "#af7bff", skill: "#3fb950", mcp: "#f59e0b" };
380
+ const AGENT_TYPE_OPTIONS = Object.freeze([
381
+ { value: "voice", label: "Voice" },
382
+ { value: "task", label: "Task" },
383
+ { value: "chat", label: "Chat" },
384
+ ]);
385
+
386
+ function normalizeAgentType(rawType) {
387
+ const value = String(rawType || "").trim().toLowerCase();
388
+ if (value === "voice" || value === "task" || value === "chat") return value;
389
+ return "task";
390
+ }
391
+
392
+ function inferAgentTypeFromEntry(entry, parsedContent) {
393
+ const explicit = normalizeAgentType(parsedContent?.agentType);
394
+ if (parsedContent?.agentType) return explicit;
395
+ if (parsedContent?.voiceAgent === true) return "voice";
396
+ const id = String(entry?.id || "").trim().toLowerCase();
397
+ const tags = Array.isArray(entry?.tags)
398
+ ? entry.tags.map((tag) => String(tag || "").trim().toLowerCase())
399
+ : [];
400
+ if (id.startsWith("voice-agent")) return "voice";
401
+ if (tags.includes("voice") || tags.includes("audio-agent") || tags.includes("realtime")) return "voice";
402
+ return "task";
403
+ }
404
+
405
+ const AUDIO_AGENT_TEMPLATES = Object.freeze({
406
+ female: {
407
+ type: "agent",
408
+ name: "Voice Agent (Female)",
409
+ description: "Conversational voice specialist with concise guidance and call-friendly pacing.",
410
+ tags: "voice,audio-agent,female,realtime",
411
+ scope: "global",
412
+ content: JSON.stringify({
413
+ name: "Voice Agent (Female)",
414
+ description: "Conversational voice specialist with concise guidance and call-friendly pacing.",
415
+ titlePatterns: ["\\bvoice\\b", "\\bcall\\b", "\\bmeeting\\b", "\\bassistant\\b"],
416
+ scopes: ["voice", "assistant"],
417
+ model: null,
418
+ promptOverride: null,
419
+ skills: ["concise-voice-guidance", "conversation-memory"],
420
+ agentType: "voice",
421
+ voiceAgent: true,
422
+ voicePersona: "female",
423
+ voiceInstructions: "You are Nova, a female voice agent. Be concise, warm, and practical. Use tools for facts and execution. Keep spoken responses short and clear.",
424
+ }, null, 2),
425
+ },
426
+ male: {
427
+ type: "agent",
428
+ name: "Voice Agent (Male)",
429
+ description: "Operational voice specialist focused on diagnostics and execution.",
430
+ tags: "voice,audio-agent,male,realtime",
431
+ scope: "global",
432
+ content: JSON.stringify({
433
+ name: "Voice Agent (Male)",
434
+ description: "Operational voice specialist focused on diagnostics and execution.",
435
+ titlePatterns: ["\\bvoice\\b", "\\bcall\\b", "\\bmeeting\\b", "\\bassistant\\b"],
436
+ scopes: ["voice", "assistant"],
437
+ model: null,
438
+ promptOverride: null,
439
+ skills: ["ops-diagnostics", "task-execution"],
440
+ agentType: "voice",
441
+ voiceAgent: true,
442
+ voicePersona: "male",
443
+ voiceInstructions: "You are Atlas, a male voice agent. Be direct and execution-oriented. Prefer actionable status updates. Use tools proactively for diagnostics.",
444
+ }, null, 2),
445
+ },
446
+ });
240
447
 
241
448
  /* ═══════════════════════════════════════════════════════════════
242
449
  * Sub-components
@@ -244,7 +451,7 @@ const TYPE_COLORS = { prompt: "#58a6ff", agent: "#af7bff", skill: "#3fb950" };
244
451
 
245
452
  function LibraryStats() {
246
453
  const all = entries.value;
247
- const counts = { prompt: 0, agent: 0, skill: 0 };
454
+ const counts = { prompt: 0, agent: 0, skill: 0, mcp: 0 };
248
455
  for (const e of all) { if (counts[e.type] !== undefined) counts[e.type]++; }
249
456
  return html`
250
457
  <div class="library-stats">
@@ -264,6 +471,10 @@ function LibraryStats() {
264
471
  <div class="library-stat-val" style="color: ${TYPE_COLORS.skill}">${counts.skill}</div>
265
472
  <div class="library-stat-lbl">${iconText(`${TYPE_ICONS.skill} Skills`)}</div>
266
473
  </div>
474
+ <div class="library-stat">
475
+ <div class="library-stat-val" style="color: ${TYPE_COLORS.mcp}">${counts.mcp}</div>
476
+ <div class="library-stat-lbl">${iconText(`${TYPE_ICONS.mcp} MCP`)}</div>
477
+ </div>
267
478
  </div>
268
479
  `;
269
480
  }
@@ -274,6 +485,7 @@ function TypePills() {
274
485
  { id: "prompt", label: `${TYPE_ICONS.prompt} Prompts` },
275
486
  { id: "agent", label: `${TYPE_ICONS.agent} Agents` },
276
487
  { id: "skill", label: `${TYPE_ICONS.skill} Skills` },
488
+ { id: "mcp", label: `${TYPE_ICONS.mcp} MCP Servers` },
277
489
  ];
278
490
  return html`
279
491
  <div class="library-type-pills">
@@ -309,6 +521,9 @@ function LibraryCard({ entry, onSelect }) {
309
521
  <div class="library-card-desc">${entry.description}</div>
310
522
  `}
311
523
  <div class="library-card-meta">
524
+ ${entry.type === "agent" && entry.agentType && html`
525
+ <span class="library-card-tag">${String(entry.agentType).toUpperCase()}</span>
526
+ `}
312
527
  ${(entry.tags || []).slice(0, 5).map((tag) => html`
313
528
  <span class="library-card-tag" key=${tag}>${tag}</span>
314
529
  `)}
@@ -331,7 +546,8 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
331
546
  description: entry?.description || "",
332
547
  tags: (entry?.tags || []).join(", "),
333
548
  scope: entry?.scope || "global",
334
- content: "",
549
+ agentType: inferAgentTypeFromEntry(entry, null),
550
+ content: typeof entry?.content === "string" ? entry.content : "",
335
551
  };
336
552
  const [form, setForm] = useState(initialFormSnapshot);
337
553
  const [baseline, setBaseline] = useState(initialFormSnapshot);
@@ -351,6 +567,7 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
351
567
  description: entry?.description || "",
352
568
  tags: (entry?.tags || []).join(", "),
353
569
  scope: entry?.scope || "global",
570
+ agentType: inferAgentTypeFromEntry(entry, null),
354
571
  content: "",
355
572
  };
356
573
  setForm(next);
@@ -368,8 +585,13 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
368
585
  if (cancelled) return;
369
586
  let contentStr = detail?.content ?? "";
370
587
  if (typeof contentStr === "object") contentStr = JSON.stringify(contentStr, null, 2);
588
+ const parsed = detail?.content && typeof detail.content === "object" ? detail.content : null;
371
589
  setForm((f) => {
372
- const next = { ...f, content: contentStr };
590
+ const next = {
591
+ ...f,
592
+ content: contentStr,
593
+ agentType: inferAgentTypeFromEntry(detail || entry, parsed),
594
+ };
373
595
  setBaseline(next);
374
596
  return next;
375
597
  });
@@ -406,7 +628,19 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
406
628
  const tags = form.tags.split(/[,\s]+/).map((t) => t.trim().toLowerCase()).filter(Boolean);
407
629
  let content = form.content;
408
630
  if (form.type === "agent") {
409
- try { content = JSON.parse(content); } catch { /* keep as string if invalid JSON */ }
631
+ try {
632
+ content = JSON.parse(content);
633
+ } catch {
634
+ showToast("Agent profile content must be valid JSON", "error");
635
+ return false;
636
+ }
637
+ const agentType = normalizeAgentType(form.agentType);
638
+ content.agentType = agentType;
639
+ if (agentType === "voice") {
640
+ content.voiceAgent = true;
641
+ } else if (content.voiceAgent === true) {
642
+ content.voiceAgent = false;
643
+ }
410
644
  }
411
645
  const res = await saveEntry({
412
646
  id: form.id || undefined,
@@ -466,6 +700,7 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
466
700
  model: null,
467
701
  promptOverride: null,
468
702
  skills: [],
703
+ agentType: "task",
469
704
  tags: [],
470
705
  }, null, 2)
471
706
  : "# Skill Title\n\n## Purpose\nDescribe what this skill teaches agents.\n\n## Instructions\n...";
@@ -490,6 +725,7 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
490
725
  <option value="prompt">Prompt</option>
491
726
  <option value="agent">Agent Profile</option>
492
727
  <option value="skill">Skill</option>
728
+ <option value="mcp">MCP Server</option>
493
729
  </select>
494
730
  </label>
495
731
  `}
@@ -515,6 +751,16 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
515
751
  <option value="workspace">Workspace</option>
516
752
  </select>
517
753
  </label>
754
+ ${form.type === "agent" && html`
755
+ <label>
756
+ Agent Type
757
+ <select value=${normalizeAgentType(form.agentType)} onChange=${updateField("agentType")}>
758
+ ${AGENT_TYPE_OPTIONS.map((opt) => html`
759
+ <option key=${opt.value} value=${opt.value}>${opt.label}</option>
760
+ `)}
761
+ </select>
762
+ </label>
763
+ `}
518
764
  <label>
519
765
  Content
520
766
  ${loadingContent
@@ -526,10 +772,17 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
526
772
  </label>
527
773
  <div style="font-size:0.78em;color:var(--text-tertiary,#666);margin-top:-8px;">
528
774
  ${form.type === "prompt" ? "Use {{VARIABLE_NAME}} for template variables. Reference in workflows as {{prompt:name}}."
529
- : form.type === "agent" ? "JSON format. Referenced in workflows as {{agent:name}}."
775
+ : form.type === "agent" ? "JSON format. Referenced in workflows as {{agent:name}}."
776
+ : form.type === "mcp" ? "MCP server configuration. Managed via the MCP Servers panel."
530
777
  : "Markdown format. Referenced in workflows as {{skill:name}}."}
531
778
  </div>
532
779
 
780
+ ${/* ── Agent Tool Configuration Section ── */
781
+ !isNew && form.type === "agent" && entry?.id && html`
782
+ <${AgentToolConfigurator} agentId=${entry.id} agentName=${entry.name || form.name} />
783
+ `
784
+ }
785
+
533
786
  <div class="library-actions">
534
787
  ${!isNew && html`
535
788
  <button class="btn-danger" onClick=${() => setConfirmDelete(true)} disabled=${loading}>
@@ -571,6 +824,598 @@ function EntryEditor({ entry, onClose, onSaved, onDeleted }) {
571
824
  `;
572
825
  }
573
826
 
827
+ /* ─ Agent Tool Configurator ─────────────────────────────── */
828
+
829
+ function AgentToolConfigurator({ agentId, agentName }) {
830
+ const [toolsTab, setToolsTab] = useState("builtin"); // "builtin" | "bosun" | "mcp"
831
+ const [tools, setTools] = useState({ builtinTools: [], bosunTools: [], mcpServers: [], enabledTools: null });
832
+ const [installed, setInstalled] = useState([]);
833
+ const [loading, setLoading] = useState(true);
834
+ const [saving, setSaving] = useState(false);
835
+
836
+ const loadConfig = useCallback(async () => {
837
+ setLoading(true);
838
+ try {
839
+ const [effective, inst] = await Promise.all([
840
+ fetchAgentToolConfig(agentId),
841
+ fetchMcpInstalled(),
842
+ ]);
843
+ setTools(effective);
844
+ setInstalled(inst);
845
+ } catch (err) {
846
+ showToast("Failed to load tool config: " + err.message, "error");
847
+ }
848
+ setLoading(false);
849
+ }, [agentId]);
850
+
851
+ useEffect(() => { loadConfig(); }, [agentId]);
852
+
853
+ const toggleBuiltinTool = useCallback(async (toolId, enabled) => {
854
+ const current = tools.builtinTools || [];
855
+ const disabledList = current.filter((t) => !t.enabled).map((t) => t.id);
856
+ const newDisabled = enabled
857
+ ? disabledList.filter((id) => id !== toolId)
858
+ : [...disabledList, toolId];
859
+ const currentEnabledTools = Array.isArray(tools.enabledTools)
860
+ ? tools.enabledTools.map((id) => String(id || "").trim()).filter(Boolean)
861
+ : null;
862
+ const nextEnabledTools = currentEnabledTools
863
+ ? (() => {
864
+ const set = new Set(currentEnabledTools);
865
+ if (enabled) set.add(toolId);
866
+ else set.delete(toolId);
867
+ return [...set];
868
+ })()
869
+ : undefined;
870
+ setSaving(true);
871
+ try {
872
+ await saveAgentToolConfig(agentId, {
873
+ disabledBuiltinTools: newDisabled,
874
+ ...(nextEnabledTools !== undefined ? { enabledTools: nextEnabledTools } : {}),
875
+ });
876
+ setTools((prev) => ({
877
+ ...prev,
878
+ ...(nextEnabledTools !== undefined ? { enabledTools: nextEnabledTools } : {}),
879
+ builtinTools: prev.builtinTools.map((t) =>
880
+ t.id === toolId ? { ...t, enabled } : t
881
+ ),
882
+ }));
883
+ } catch (err) {
884
+ showToast("Failed to save: " + err.message, "error");
885
+ }
886
+ setSaving(false);
887
+ }, [agentId, tools]);
888
+
889
+ const toggleBosunTool = useCallback(async (toolId, enabled) => {
890
+ const bosunIds = (tools.bosunTools || []).map((tool) => String(tool?.id || "").trim()).filter(Boolean);
891
+ const bosunIdSet = new Set(bosunIds);
892
+ const currentEnabledTools = Array.isArray(tools.enabledTools)
893
+ ? tools.enabledTools.map((id) => String(id || "").trim()).filter(Boolean)
894
+ : [];
895
+ const currentSet = new Set(currentEnabledTools);
896
+ const hasBosunAllowlist = bosunIds.some((id) => currentSet.has(id));
897
+ const nextBosunSet = hasBosunAllowlist
898
+ ? new Set(currentEnabledTools.filter((id) => bosunIdSet.has(id)))
899
+ : new Set(bosunIds);
900
+ if (enabled) nextBosunSet.add(toolId);
901
+ else nextBosunSet.delete(toolId);
902
+ const preserved = currentEnabledTools.filter((id) => !bosunIdSet.has(id));
903
+ const nextEnabledTools = [...new Set([...preserved, ...nextBosunSet])];
904
+ setSaving(true);
905
+ try {
906
+ await saveAgentToolConfig(agentId, { enabledTools: nextEnabledTools });
907
+ setTools((prev) => ({
908
+ ...prev,
909
+ enabledTools: nextEnabledTools,
910
+ }));
911
+ } catch (err) {
912
+ showToast("Failed to save: " + err.message, "error");
913
+ }
914
+ setSaving(false);
915
+ }, [agentId, tools]);
916
+
917
+ const toggleMcpServer = useCallback(async (serverId, enabled) => {
918
+ const currentMcp = tools.mcpServers || [];
919
+ const newMcp = enabled
920
+ ? [...new Set([...currentMcp, serverId])]
921
+ : currentMcp.filter((id) => id !== serverId);
922
+ setSaving(true);
923
+ try {
924
+ await saveAgentToolConfig(agentId, { enabledMcpServers: newMcp });
925
+ setTools((prev) => ({ ...prev, mcpServers: newMcp }));
926
+ } catch (err) {
927
+ showToast("Failed to save: " + err.message, "error");
928
+ }
929
+ setSaving(false);
930
+ }, [agentId, tools]);
931
+
932
+ const enabledMcpSet = new Set(tools.mcpServers || []);
933
+ const bosunTools = Array.isArray(tools.bosunTools) ? tools.bosunTools : [];
934
+ const bosunToolIds = bosunTools.map((tool) => String(tool?.id || "").trim()).filter(Boolean);
935
+ const rawEnabledTools = Array.isArray(tools.enabledTools)
936
+ ? tools.enabledTools.map((id) => String(id || "").trim()).filter(Boolean)
937
+ : null;
938
+ const hasBosunAllowlist = Boolean(rawEnabledTools && rawEnabledTools.some((id) => bosunToolIds.includes(id)));
939
+ const enabledBosunSet = new Set(
940
+ hasBosunAllowlist
941
+ ? rawEnabledTools.filter((id) => bosunToolIds.includes(id))
942
+ : bosunToolIds,
943
+ );
944
+
945
+ if (loading) {
946
+ return html`<div class="agent-tools-section">
947
+ <div style="text-align:center;padding:16px;"><${Spinner} size=${16} /> Loading tools...</div>
948
+ </div>`;
949
+ }
950
+
951
+ return html`
952
+ <div class="agent-tools-section">
953
+ <div class="tool-config-header">
954
+ <h4>${iconText(":settings: Tools & MCP Servers")}</h4>
955
+ ${saving && html`<${Spinner} size=${12} />`}
956
+ </div>
957
+
958
+ <div class="agent-tools-tabs">
959
+ <button class=${`agent-tools-tab ${toolsTab === "builtin" ? "active" : ""}`}
960
+ onClick=${() => setToolsTab("builtin")}>
961
+ ${iconText(":cpu: Built-in Tools")} (${(tools.builtinTools || []).filter((t) => t.enabled).length}/${(tools.builtinTools || []).length})
962
+ </button>
963
+ <button class=${`agent-tools-tab ${toolsTab === "bosun" ? "active" : ""}`}
964
+ onClick=${() => setToolsTab("bosun")}>
965
+ ${iconText(":zap: Bosun Tools")} (${enabledBosunSet.size}/${bosunTools.length})
966
+ </button>
967
+ <button class=${`agent-tools-tab ${toolsTab === "mcp" ? "active" : ""}`}
968
+ onClick=${() => setToolsTab("mcp")}>
969
+ ${iconText(":plug: MCP Servers")} (${enabledMcpSet.size}/${installed.length})
970
+ </button>
971
+ </div>
972
+
973
+ ${toolsTab === "builtin" && html`
974
+ <div class="tool-config-group">
975
+ ${(tools.builtinTools || []).map((tool) => html`
976
+ <div class="tool-config-item" key=${tool.id}>
977
+ <span class="tool-config-item-icon">${resolveIcon(tool.icon) || iconText(tool.icon || ":cpu:")}</span>
978
+ <div class="tool-config-item-info">
979
+ <div class="tool-config-item-name">${tool.name}</div>
980
+ <div class="tool-config-item-desc">${tool.description}</div>
981
+ </div>
982
+ <div class="tool-config-toggle">
983
+ <${Toggle}
984
+ checked=${tool.enabled}
985
+ onChange=${(val) => toggleBuiltinTool(tool.id, val)}
986
+ />
987
+ </div>
988
+ </div>
989
+ `)}
990
+ </div>
991
+ `}
992
+
993
+ ${toolsTab === "bosun" && html`
994
+ <details open class="tool-config-group">
995
+ <summary class="tool-config-group-label">
996
+ Runtime Voice Tools (collapsible)
997
+ </summary>
998
+ ${bosunTools.length === 0 && html`
999
+ <div style="padding:12px;text-align:center;color:var(--text-secondary);font-size:0.85em;">
1000
+ No Bosun runtime tools were discovered.
1001
+ </div>
1002
+ `}
1003
+ ${bosunTools.map((tool) => html`
1004
+ <div class="tool-config-item" key=${tool.id}>
1005
+ <span class="tool-config-item-icon">${resolveIcon(":zap:") || iconText(":zap:")}</span>
1006
+ <div class="tool-config-item-info">
1007
+ <div class="tool-config-item-name">${tool.name}</div>
1008
+ <div class="tool-config-item-desc">${tool.description || "Bosun runtime tool"}</div>
1009
+ </div>
1010
+ <div class="tool-config-toggle">
1011
+ <${Toggle}
1012
+ checked=${enabledBosunSet.has(tool.id)}
1013
+ onChange=${(val) => toggleBosunTool(tool.id, val)}
1014
+ />
1015
+ </div>
1016
+ </div>
1017
+ `)}
1018
+ </details>
1019
+ `}
1020
+
1021
+ ${toolsTab === "mcp" && html`
1022
+ <div class="tool-config-group">
1023
+ ${installed.length === 0 && html`
1024
+ <div style="padding:12px;text-align:center;color:var(--text-secondary);font-size:0.85em;">
1025
+ No MCP servers installed. Use the MCP Servers tab to install from the marketplace.
1026
+ </div>
1027
+ `}
1028
+ ${installed.map((srv) => html`
1029
+ <div class="tool-config-item" key=${srv.id}>
1030
+ <span class="tool-config-item-icon">${resolveIcon(":plug:") || iconText(":plug:")}</span>
1031
+ <div class="tool-config-item-info">
1032
+ <div class="tool-config-item-name">${srv.name}</div>
1033
+ <div class="tool-config-item-desc">${srv.description || `Transport: ${srv.meta?.transport || "stdio"}`}</div>
1034
+ </div>
1035
+ <div class="tool-config-toggle">
1036
+ <${Toggle}
1037
+ checked=${enabledMcpSet.has(srv.id)}
1038
+ onChange=${(val) => toggleMcpServer(srv.id, val)}
1039
+ />
1040
+ </div>
1041
+ </div>
1042
+ `)}
1043
+ </div>
1044
+ `}
1045
+ </div>
1046
+ `;
1047
+ }
1048
+
1049
+ /* ─ MCP Marketplace / Catalog ─────────────────────────────── */
1050
+
1051
+ function McpMarketplace({ onInstalled }) {
1052
+ const [catalog, setCatalog] = useState([]);
1053
+ const [installed, setInstalled] = useState([]);
1054
+ const [loading, setLoading] = useState(true);
1055
+ const [installing, setInstalling] = useState(null);
1056
+ const [uninstalling, setUninstalling] = useState(null);
1057
+ const [configuring, setConfiguring] = useState(null);
1058
+ const [envEdits, setEnvEdits] = useState({});
1059
+ const [showCustom, setShowCustom] = useState(false);
1060
+ const [marketplaceSearch, setMarketplaceSearch] = useState("");
1061
+
1062
+ const loadData = useCallback(async () => {
1063
+ setLoading(true);
1064
+ try {
1065
+ const [cat, inst] = await Promise.all([
1066
+ fetchMcpCatalog(),
1067
+ fetchMcpInstalled(),
1068
+ ]);
1069
+ setCatalog(cat);
1070
+ setInstalled(inst);
1071
+ mcpCatalog.value = cat;
1072
+ mcpInstalled.value = inst;
1073
+ } catch (err) {
1074
+ showToast("Failed to load MCP data: " + err.message, "error");
1075
+ }
1076
+ setLoading(false);
1077
+ }, []);
1078
+
1079
+ useEffect(() => { loadData(); }, []);
1080
+
1081
+ const installedIds = new Set(installed.map((s) => s.id));
1082
+
1083
+ const handleInstall = useCallback(async (catalogId) => {
1084
+ setInstalling(catalogId);
1085
+ try {
1086
+ const envVars = envEdits[catalogId] || {};
1087
+ const res = await installMcp(catalogId, envVars);
1088
+ if (res?.ok) {
1089
+ showToast(`Installed ${catalogId}`, "success");
1090
+ await loadData();
1091
+ onInstalled?.();
1092
+ } else {
1093
+ showToast(res?.error || "Install failed", "error");
1094
+ }
1095
+ } catch (err) {
1096
+ showToast("Install failed: " + err.message, "error");
1097
+ }
1098
+ setInstalling(null);
1099
+ }, [envEdits, loadData, onInstalled]);
1100
+
1101
+ const handleUninstall = useCallback(async (id) => {
1102
+ setUninstalling(id);
1103
+ try {
1104
+ const res = await uninstallMcp(id);
1105
+ if (res?.ok) {
1106
+ showToast(`Uninstalled ${id}`, "success");
1107
+ await loadData();
1108
+ onInstalled?.();
1109
+ } else {
1110
+ showToast(res?.error || "Uninstall failed", "error");
1111
+ }
1112
+ } catch (err) {
1113
+ showToast("Uninstall failed: " + err.message, "error");
1114
+ }
1115
+ setUninstalling(null);
1116
+ }, [loadData, onInstalled]);
1117
+
1118
+ const handleConfigure = useCallback(async (id) => {
1119
+ const env = envEdits[id] || {};
1120
+ try {
1121
+ const res = await configureMcpEnv(id, env);
1122
+ if (res?.ok) {
1123
+ showToast(`Updated ${id} configuration`, "success");
1124
+ setConfiguring(null);
1125
+ } else {
1126
+ showToast(res?.error || "Update failed", "error");
1127
+ }
1128
+ } catch (err) {
1129
+ showToast("Update failed: " + err.message, "error");
1130
+ }
1131
+ }, [envEdits]);
1132
+
1133
+ const handleCustomInstall = useCallback(async (def) => {
1134
+ setInstalling("custom");
1135
+ try {
1136
+ const res = await installMcp(def);
1137
+ if (res?.ok) {
1138
+ showToast(`Installed custom MCP server: ${def.name}`, "success");
1139
+ setShowCustom(false);
1140
+ await loadData();
1141
+ onInstalled?.();
1142
+ } else {
1143
+ showToast(res?.error || "Install failed", "error");
1144
+ }
1145
+ } catch (err) {
1146
+ showToast("Install failed: " + err.message, "error");
1147
+ }
1148
+ setInstalling(null);
1149
+ }, [loadData, onInstalled]);
1150
+
1151
+ const updateEnv = useCallback((serverId, key, value) => {
1152
+ setEnvEdits((prev) => ({
1153
+ ...prev,
1154
+ [serverId]: { ...(prev[serverId] || {}), [key]: value },
1155
+ }));
1156
+ }, []);
1157
+
1158
+ const filteredCatalog = useMemo(() => {
1159
+ if (!marketplaceSearch.trim()) return catalog;
1160
+ const q = marketplaceSearch.toLowerCase();
1161
+ return catalog.filter(
1162
+ (s) => s.name.toLowerCase().includes(q) ||
1163
+ s.description.toLowerCase().includes(q) ||
1164
+ (s.tags || []).some((t) => t.toLowerCase().includes(q)),
1165
+ );
1166
+ }, [catalog, marketplaceSearch]);
1167
+
1168
+ if (loading) {
1169
+ return html`<div style="text-align:center;padding:40px;"><${Spinner} /> Loading MCP marketplace...</div>`;
1170
+ }
1171
+
1172
+ return html`
1173
+ <div class="mcp-section">
1174
+ <!-- Installed section -->
1175
+ ${installed.length > 0 && html`
1176
+ <div class="mcp-section-header">
1177
+ <h3>${iconText(":check: Installed")} (${installed.length})</h3>
1178
+ </div>
1179
+ <div class="mcp-catalog-grid">
1180
+ ${installed.map((srv) => html`
1181
+ <div class="mcp-card" key=${srv.id}>
1182
+ <div class="mcp-card-header">
1183
+ <span class="mcp-card-name">${srv.name}</span>
1184
+ <${Badge} text="Installed" status="success" />
1185
+ </div>
1186
+ <div class="mcp-card-desc">${srv.description}</div>
1187
+ <div class="mcp-card-tags">
1188
+ ${(srv.tags || []).map((t) => html`<span class="mcp-card-tag" key=${t}>${t}</span>`)}
1189
+ </div>
1190
+ <div class="mcp-card-actions">
1191
+ <button class="btn-uninstall"
1192
+ onClick=${() => handleUninstall(srv.id)}
1193
+ disabled=${uninstalling === srv.id}>
1194
+ ${uninstalling === srv.id ? html`<${Spinner} size=${12} />` : "Uninstall"}
1195
+ </button>
1196
+ <button onClick=${() => setConfiguring(configuring === srv.id ? null : srv.id)}>
1197
+ ${iconText(":settings: Configure")}
1198
+ </button>
1199
+ </div>
1200
+ ${configuring === srv.id && html`
1201
+ <div class="mcp-env-editor">
1202
+ <div style="font-size:0.78em;color:var(--text-secondary);margin-bottom:4px;">
1203
+ Environment Variables
1204
+ </div>
1205
+ ${Object.entries(srv.meta?.env || {}).map(([key, val]) => html`
1206
+ <div class="mcp-env-row" key=${key}>
1207
+ <span class="mcp-env-key">${key}</span>
1208
+ <input class="mcp-env-input"
1209
+ type=${key.toLowerCase().includes("key") || key.toLowerCase().includes("token") || key.toLowerCase().includes("secret") ? "password" : "text"}
1210
+ value=${envEdits[srv.id]?.[key] ?? val}
1211
+ placeholder="Enter value..."
1212
+ onInput=${(e) => updateEnv(srv.id, key, e.target.value)} />
1213
+ </div>
1214
+ `)}
1215
+ ${Object.keys(srv.meta?.env || {}).length === 0 && html`
1216
+ <div style="font-size:0.82em;color:var(--text-tertiary,#666);">No environment variables required.</div>
1217
+ `}
1218
+ <div style="display:flex;gap:6px;justify-content:flex-end;margin-top:6px;">
1219
+ <button class="library-type-pill" onClick=${() => setConfiguring(null)}>Cancel</button>
1220
+ <button class="library-type-pill active" onClick=${() => handleConfigure(srv.id)}>
1221
+ ${iconText(":check: Save")}
1222
+ </button>
1223
+ </div>
1224
+ </div>
1225
+ `}
1226
+ </div>
1227
+ `)}
1228
+ </div>
1229
+ `}
1230
+
1231
+ <!-- Marketplace Catalog -->
1232
+ <div class="mcp-section-header">
1233
+ <h3>${iconText(":shopping: MCP Marketplace")}</h3>
1234
+ <button class="library-type-pill" onClick=${() => setShowCustom(!showCustom)}>
1235
+ ${iconText("➕ Custom Server")}
1236
+ </button>
1237
+ </div>
1238
+
1239
+ <div style="margin-bottom:8px;">
1240
+ <${SearchInput}
1241
+ value=${marketplaceSearch}
1242
+ onChange=${setMarketplaceSearch}
1243
+ placeholder="Search marketplace (GitHub, Playwright, Exa, etc.)..." />
1244
+ </div>
1245
+
1246
+ ${showCustom && html`
1247
+ <${McpCustomInstallForm} onInstall=${handleCustomInstall} installing=${installing === "custom"} />
1248
+ `}
1249
+
1250
+ <div class="mcp-catalog-grid">
1251
+ ${filteredCatalog.map((srv) => {
1252
+ const isInstalled = installedIds.has(srv.id);
1253
+ const hasEnv = srv.env && Object.keys(srv.env).length > 0;
1254
+ const isInstalling = installing === srv.id;
1255
+ return html`
1256
+ <div class="mcp-card" key=${srv.id}>
1257
+ <div class="mcp-card-header">
1258
+ <span class="mcp-card-name">${srv.name}</span>
1259
+ ${isInstalled && html`<${Badge} text="Installed" status="success" />`}
1260
+ </div>
1261
+ <div class="mcp-card-desc">${srv.description}</div>
1262
+ <div class="mcp-card-tags">
1263
+ ${(srv.tags || []).map((t) => html`<span class="mcp-card-tag" key=${t}>${t}</span>`)}
1264
+ <span class="mcp-card-tag" style="background:rgba(255,255,255,0.05);color:var(--text-tertiary,#666);">
1265
+ ${srv.transport}
1266
+ </span>
1267
+ </div>
1268
+ ${hasEnv && !isInstalled && html`
1269
+ <div class="mcp-env-editor">
1270
+ ${Object.entries(srv.env).map(([key, val]) => html`
1271
+ <div class="mcp-env-row" key=${key}>
1272
+ <span class="mcp-env-key">${key}</span>
1273
+ <input class="mcp-env-input"
1274
+ type=${key.toLowerCase().includes("key") || key.toLowerCase().includes("token") || key.toLowerCase().includes("secret") ? "password" : "text"}
1275
+ value=${envEdits[srv.id]?.[key] ?? ""}
1276
+ placeholder=${val || "Enter value..."}
1277
+ onInput=${(e) => updateEnv(srv.id, key, e.target.value)} />
1278
+ </div>
1279
+ `)}
1280
+ </div>
1281
+ `}
1282
+ ${srv.homepage && html`
1283
+ <div style="font-size:0.75em;">
1284
+ <a href=${srv.homepage} target="_blank" rel="noopener"
1285
+ style="color:var(--accent,#58a6ff);text-decoration:none;">
1286
+ ${iconText(":link: Documentation")}
1287
+ </a>
1288
+ </div>
1289
+ `}
1290
+ <div class="mcp-card-actions">
1291
+ ${isInstalled
1292
+ ? html`<button class="btn-installed" disabled>✓ Installed</button>`
1293
+ : html`
1294
+ <button class="btn-install"
1295
+ onClick=${() => handleInstall(srv.id)}
1296
+ disabled=${isInstalling}>
1297
+ ${isInstalling ? html`<${Spinner} size=${12} />` : iconText(":download: Install")}
1298
+ </button>
1299
+ `
1300
+ }
1301
+ </div>
1302
+ </div>
1303
+ `;
1304
+ })}
1305
+ </div>
1306
+
1307
+ ${filteredCatalog.length === 0 && html`
1308
+ <div style="text-align:center;padding:20px;color:var(--text-secondary);font-size:0.9em;">
1309
+ ${marketplaceSearch ? "No MCP servers match your search." : "No catalog entries available."}
1310
+ </div>
1311
+ `}
1312
+ </div>
1313
+ `;
1314
+ }
1315
+
1316
+ /* ─ Custom MCP Install Form ───────────────────────────────── */
1317
+
1318
+ function McpCustomInstallForm({ onInstall, installing }) {
1319
+ const [form, setForm] = useState({
1320
+ name: "",
1321
+ description: "",
1322
+ transport: "stdio",
1323
+ command: "npx",
1324
+ args: "",
1325
+ url: "",
1326
+ tags: "",
1327
+ envKeys: "",
1328
+ });
1329
+
1330
+ const updateField = (key) => (e) => setForm((f) => ({ ...f, [key]: e.target.value }));
1331
+
1332
+ const handleSubmit = useCallback(() => {
1333
+ if (!form.name.trim()) {
1334
+ showToast("Server name is required", "error");
1335
+ return;
1336
+ }
1337
+ const def = {
1338
+ name: form.name.trim(),
1339
+ description: form.description.trim(),
1340
+ transport: form.transport,
1341
+ tags: form.tags.split(/[,\s]+/).filter(Boolean),
1342
+ };
1343
+ if (form.transport === "stdio") {
1344
+ def.command = form.command.trim() || "npx";
1345
+ def.args = form.args.split(/\s+/).filter(Boolean);
1346
+ } else {
1347
+ def.url = form.url.trim();
1348
+ }
1349
+ // Parse env keys
1350
+ if (form.envKeys.trim()) {
1351
+ def.env = {};
1352
+ for (const key of form.envKeys.split(/[,\s]+/).filter(Boolean)) {
1353
+ def.env[key] = "";
1354
+ }
1355
+ }
1356
+ onInstall(def);
1357
+ }, [form, onInstall]);
1358
+
1359
+ return html`
1360
+ <div class="mcp-custom-form">
1361
+ <div style="font-size:0.88em;font-weight:600;color:var(--text-primary);">
1362
+ ${iconText("➕ Install Custom MCP Server")}
1363
+ </div>
1364
+ <label>
1365
+ Name *
1366
+ <input type="text" value=${form.name} onInput=${updateField("name")}
1367
+ placeholder="e.g. My Custom Server" />
1368
+ </label>
1369
+ <label>
1370
+ Description
1371
+ <input type="text" value=${form.description} onInput=${updateField("description")}
1372
+ placeholder="Brief description" />
1373
+ </label>
1374
+ <label>
1375
+ Transport
1376
+ <select value=${form.transport} onChange=${updateField("transport")}>
1377
+ <option value="stdio">stdio (command + args)</option>
1378
+ <option value="url">URL (HTTP/SSE endpoint)</option>
1379
+ </select>
1380
+ </label>
1381
+ ${form.transport === "stdio" && html`
1382
+ <label>
1383
+ Command
1384
+ <input type="text" value=${form.command} onInput=${updateField("command")}
1385
+ placeholder="npx" />
1386
+ </label>
1387
+ <label>
1388
+ Arguments (space-separated)
1389
+ <input type="text" value=${form.args} onInput=${updateField("args")}
1390
+ placeholder="-y @scope/mcp-server" />
1391
+ </label>
1392
+ `}
1393
+ ${form.transport === "url" && html`
1394
+ <label>
1395
+ URL
1396
+ <input type="text" value=${form.url} onInput=${updateField("url")}
1397
+ placeholder="https://example.com/mcp" />
1398
+ </label>
1399
+ `}
1400
+ <label>
1401
+ Tags (comma-separated)
1402
+ <input type="text" value=${form.tags} onInput=${updateField("tags")}
1403
+ placeholder="custom, tools" />
1404
+ </label>
1405
+ <label>
1406
+ Environment Variable Keys (comma-separated, values set after install)
1407
+ <input type="text" value=${form.envKeys} onInput=${updateField("envKeys")}
1408
+ placeholder="API_KEY, SECRET_TOKEN" />
1409
+ </label>
1410
+ <div class="library-actions">
1411
+ <button class="btn-primary" onClick=${handleSubmit} disabled=${installing}>
1412
+ ${installing ? html`<${Spinner} size=${14} />` : iconText(":download: Install")}
1413
+ </button>
1414
+ </div>
1415
+ </div>
1416
+ `;
1417
+ }
1418
+
574
1419
  /* ─ Scope Detector Panel ─────────────────────────────────── */
575
1420
 
576
1421
  function ScopeDetector() {
@@ -691,6 +1536,17 @@ export function LibraryTab() {
691
1536
 
692
1537
  useEffect(() => { loadEntries(); }, [filterType.value]);
693
1538
 
1539
+ useEffect(() => {
1540
+ const onWorkspaceSwitched = () => {
1541
+ setEditing(null);
1542
+ loadEntries();
1543
+ };
1544
+ window.addEventListener("ve:workspace-switched", onWorkspaceSwitched);
1545
+ return () => {
1546
+ window.removeEventListener("ve:workspace-switched", onWorkspaceSwitched);
1547
+ };
1548
+ }, [loadEntries]);
1549
+
694
1550
  // Debounced search
695
1551
  const searchTimer = useRef(null);
696
1552
  const handleSearch = useCallback((value) => {
@@ -732,6 +1588,13 @@ export function LibraryTab() {
732
1588
  setEditing(entry);
733
1589
  }, []);
734
1590
 
1591
+ const handleCreateAudioAgent = useCallback((templateKey) => {
1592
+ const template = AUDIO_AGENT_TEMPLATES[templateKey];
1593
+ if (!template) return;
1594
+ haptic("light");
1595
+ setEditing({ ...template });
1596
+ }, []);
1597
+
735
1598
  const handleSaved = useCallback(() => {
736
1599
  setEditing(null);
737
1600
  loadEntries();
@@ -755,6 +1618,12 @@ export function LibraryTab() {
755
1618
  <div class="library-root">
756
1619
  <div class="library-header">
757
1620
  <h2>${iconText(":book: Library")}</h2>
1621
+ <button class="library-type-pill" onClick=${() => handleCreateAudioAgent("female")}>
1622
+ ${iconText(":mic: New Female Audio Agent")}
1623
+ </button>
1624
+ <button class="library-type-pill" onClick=${() => handleCreateAudioAgent("male")}>
1625
+ ${iconText(":mic: New Male Audio Agent")}
1626
+ </button>
758
1627
  <button class="library-type-pill" onClick=${handleRebuild}
759
1628
  title="Rescan directories and rebuild manifest">
760
1629
  ${iconText(":refresh: Rebuild")}
@@ -779,19 +1648,25 @@ export function LibraryTab() {
779
1648
  <${SearchInput}
780
1649
  value=${searchQuery.value}
781
1650
  onChange=${handleSearch}
782
- placeholder="Search prompts, agents, skills..." />
1651
+ placeholder="Search prompts, agents, skills, MCP servers..." />
783
1652
  </div>
784
1653
  <${TypePills} />
785
1654
  </div>
786
1655
 
787
- <${ProfileMatcher} />
788
- <${ScopeDetector} />
1656
+ ${filterType.value !== "mcp" && html`<${ProfileMatcher} />`}
1657
+ ${filterType.value !== "mcp" && html`<${ScopeDetector} />`}
1658
+
1659
+ ${/* ── MCP Marketplace View ── */
1660
+ filterType.value === "mcp" && html`
1661
+ <${McpMarketplace} onInstalled=${loadEntries} />
1662
+ `
1663
+ }
789
1664
 
790
- ${loading && html`
1665
+ ${filterType.value !== "mcp" && loading && html`
791
1666
  <div style="text-align:center;padding:40px;"><${Spinner} /> Loading library...</div>
792
1667
  `}
793
1668
 
794
- ${!loading && displayed.length === 0 && initialized.value && html`
1669
+ ${filterType.value !== "mcp" && !loading && displayed.length === 0 && initialized.value && html`
795
1670
  <${EmptyState}
796
1671
  icon="book"
797
1672
  title="No resources found"
@@ -803,7 +1678,7 @@ export function LibraryTab() {
803
1678
  : { label: "➕ New Resource", onClick: () => setEditing({}) }} />
804
1679
  `}
805
1680
 
806
- ${!loading && displayed.length > 0 && html`
1681
+ ${filterType.value !== "mcp" && !loading && displayed.length > 0 && html`
807
1682
  <div class="library-grid">
808
1683
  ${displayed.map((e) => html`
809
1684
  <${LibraryCard} key=${e.id} entry=${e} onSelect=${handleSelect} />