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
package/ui/app.js CHANGED
@@ -70,6 +70,7 @@ const VOICE_LAUNCH_QUERY_KEYS = [
70
70
  "executor",
71
71
  "mode",
72
72
  "model",
73
+ "voiceAgentId",
73
74
  "vision",
74
75
  "source",
75
76
  "chat_id",
@@ -127,6 +128,7 @@ function parseVoiceLaunchFromUrl() {
127
128
  executor: String(params.get("executor") || "").trim() || null,
128
129
  mode: String(params.get("mode") || "").trim() || null,
129
130
  model: String(params.get("model") || "").trim() || null,
131
+ voiceAgentId: String(params.get("voiceAgentId") || "").trim() || null,
130
132
  },
131
133
  };
132
134
  }
@@ -166,11 +168,13 @@ function buildBrowserFollowUrl(detail = {}) {
166
168
  const executor = String(detail?.executor || "").trim();
167
169
  const mode = String(detail?.mode || "").trim();
168
170
  const model = String(detail?.model || "").trim();
171
+ const voiceAgentId = String(detail?.voiceAgentId || "").trim();
169
172
  const vision = String(detail?.initialVisionSource || "").trim();
170
173
  if (sessionId) target.searchParams.set("sessionId", sessionId);
171
174
  if (executor) target.searchParams.set("executor", executor);
172
175
  if (mode) target.searchParams.set("mode", mode);
173
176
  if (model) target.searchParams.set("model", model);
177
+ if (voiceAgentId) target.searchParams.set("voiceAgentId", voiceAgentId);
174
178
  if (vision) target.searchParams.set("vision", vision);
175
179
  return target.toString();
176
180
  }
@@ -1464,12 +1468,14 @@ function App() {
1464
1468
  const [voiceExecutor, setVoiceExecutor] = useState(null);
1465
1469
  const [voiceAgentMode, setVoiceAgentMode] = useState(null);
1466
1470
  const [voiceModel, setVoiceModel] = useState(null);
1471
+ const [voiceAgentId, setVoiceAgentId] = useState(null);
1467
1472
  const [voiceCallType, setVoiceCallType] = useState("voice");
1468
1473
  const [voiceInitialVisionSource, setVoiceInitialVisionSource] = useState(
1469
1474
  null,
1470
1475
  );
1471
1476
  const followWindowMode = isFollowWindowFromUrl();
1472
1477
  const followOverlayOpenedRef = useRef(false);
1478
+ const externalizeInFlightRef = useRef(false);
1473
1479
  const [floatingCallState, setFloatingCallState] = useState(() =>
1474
1480
  readFloatingCallState(),
1475
1481
  );
@@ -1805,6 +1811,9 @@ function App() {
1805
1811
  const currentModel =
1806
1812
  String(event?.detail?.model || selectedModel.value || "").trim() ||
1807
1813
  null;
1814
+ const currentVoiceAgentId =
1815
+ String(event?.detail?.voiceAgentId || voiceAgentId || "").trim() ||
1816
+ null;
1808
1817
  const explicitSessionId =
1809
1818
  String(event?.detail?.sessionId || "").trim() || null;
1810
1819
  let currentSessionId =
@@ -1836,6 +1845,7 @@ function App() {
1836
1845
  setVoiceExecutor(currentExecutor);
1837
1846
  setVoiceAgentMode(currentMode);
1838
1847
  setVoiceModel(currentModel);
1848
+ setVoiceAgentId(currentVoiceAgentId);
1839
1849
  setVoiceCallType(requestedCallType);
1840
1850
  setVoiceInitialVisionSource(requestedVisionSource);
1841
1851
 
@@ -1859,6 +1869,7 @@ function App() {
1859
1869
  executor: currentExecutor || undefined,
1860
1870
  mode: currentMode || undefined,
1861
1871
  model: currentModel || undefined,
1872
+ voiceAgentId: currentVoiceAgentId || undefined,
1862
1873
  });
1863
1874
  if (followResult?.ok) {
1864
1875
  const nextFloatingState = {
@@ -1868,6 +1879,7 @@ function App() {
1868
1879
  executor: currentExecutor,
1869
1880
  mode: currentMode,
1870
1881
  model: currentModel,
1882
+ voiceAgentId: currentVoiceAgentId,
1871
1883
  initialVisionSource: requestedVisionSource,
1872
1884
  };
1873
1885
  setFloatingCallState(nextFloatingState);
@@ -1890,7 +1902,7 @@ function App() {
1890
1902
  globalThis.addEventListener?.("ve:open-voice-mode", handleOpenVoiceMode);
1891
1903
  return () =>
1892
1904
  globalThis.removeEventListener?.("ve:open-voice-mode", handleOpenVoiceMode);
1893
- }, [followWindowMode]);
1905
+ }, [followWindowMode, voiceAgentId]);
1894
1906
 
1895
1907
  useEffect(() => {
1896
1908
  const onStorage = (event) => {
@@ -1912,6 +1924,7 @@ function App() {
1912
1924
  executor: voiceExecutor,
1913
1925
  mode: voiceAgentMode,
1914
1926
  model: voiceModel,
1927
+ voiceAgentId,
1915
1928
  initialVisionSource: voiceInitialVisionSource,
1916
1929
  };
1917
1930
  setFloatingCallState(nextFloatingState);
@@ -1924,6 +1937,7 @@ function App() {
1924
1937
  voiceExecutor,
1925
1938
  voiceAgentMode,
1926
1939
  voiceModel,
1940
+ voiceAgentId,
1927
1941
  voiceInitialVisionSource,
1928
1942
  ]);
1929
1943
 
@@ -1937,6 +1951,7 @@ function App() {
1937
1951
  executor: voiceExecutor,
1938
1952
  mode: voiceAgentMode,
1939
1953
  model: voiceModel,
1954
+ voiceAgentId,
1940
1955
  initialVisionSource: voiceInitialVisionSource,
1941
1956
  };
1942
1957
  setFloatingCallState(nextFloatingState);
@@ -1951,6 +1966,7 @@ function App() {
1951
1966
  voiceExecutor,
1952
1967
  voiceAgentMode,
1953
1968
  voiceModel,
1969
+ voiceAgentId,
1954
1970
  voiceInitialVisionSource,
1955
1971
  ]);
1956
1972
 
@@ -2002,7 +2018,10 @@ function App() {
2002
2018
  navigateTo("chat", { replace: true, skipGuard: true });
2003
2019
  }
2004
2020
  }
2005
- await new Promise((resolve) => setTimeout(resolve, 60));
2021
+ // Wait for UI components to mount before dispatching the voice launch
2022
+ // event. 60 ms was too aggressive for cold-start Electron windows where
2023
+ // JS bundles are still being parsed; 200 ms is reliably sufficient.
2024
+ await new Promise((resolve) => setTimeout(resolve, 200));
2006
2025
  if (cancelled) return;
2007
2026
  globalThis.dispatchEvent?.(
2008
2027
  new CustomEvent("ve:open-voice-mode", { detail: launch.detail }),
@@ -2341,6 +2360,7 @@ function App() {
2341
2360
  executor: floatingCallState?.executor,
2342
2361
  mode: floatingCallState?.mode,
2343
2362
  model: floatingCallState?.model,
2363
+ voiceAgentId: floatingCallState?.voiceAgentId,
2344
2364
  });
2345
2365
  if (!popupResult.ok) {
2346
2366
  showToast(
@@ -2366,6 +2386,10 @@ function App() {
2366
2386
  onDismiss=${(detail = {}) => {
2367
2387
  const reason = String(detail?.reason || "").trim().toLowerCase();
2368
2388
  if (!followWindowMode && reason === "externalize") {
2389
+ if (externalizeInFlightRef.current) {
2390
+ return;
2391
+ }
2392
+ externalizeInFlightRef.current = true;
2369
2393
  const followDetail = {
2370
2394
  call: voiceCallType,
2371
2395
  sessionId: voiceSessionId,
@@ -2373,6 +2397,7 @@ function App() {
2373
2397
  executor: voiceExecutor,
2374
2398
  mode: voiceAgentMode,
2375
2399
  model: voiceModel,
2400
+ voiceAgentId,
2376
2401
  };
2377
2402
  const desktopFollowApi = globalThis?.veDesktop?.follow;
2378
2403
  if (typeof desktopFollowApi?.open === "function") {
@@ -2390,13 +2415,17 @@ function App() {
2390
2415
  executor: followDetail.executor,
2391
2416
  mode: followDetail.mode,
2392
2417
  model: followDetail.model,
2418
+ voiceAgentId: followDetail.voiceAgentId,
2393
2419
  initialVisionSource: followDetail.initialVisionSource,
2394
2420
  };
2395
2421
  setFloatingCallState(nextFloatingState);
2396
2422
  writeFloatingCallState(nextFloatingState);
2397
2423
  setVoiceOverlayOpen(false);
2398
2424
  })
2399
- .catch(() => showToast("Could not open floating call window.", "error"));
2425
+ .catch(() => showToast("Could not open floating call window.", "error"))
2426
+ .finally(() => {
2427
+ externalizeInFlightRef.current = false;
2428
+ });
2400
2429
  return;
2401
2430
  }
2402
2431
  const popupResult = openBrowserFollowWindow(followDetail);
@@ -2405,6 +2434,7 @@ function App() {
2405
2434
  popupResult.reason || "Could not open floating browser call window.",
2406
2435
  "error",
2407
2436
  );
2437
+ externalizeInFlightRef.current = false;
2408
2438
  return;
2409
2439
  }
2410
2440
  const nextFloatingState = {
@@ -2414,13 +2444,16 @@ function App() {
2414
2444
  executor: followDetail.executor,
2415
2445
  mode: followDetail.mode,
2416
2446
  model: followDetail.model,
2447
+ voiceAgentId: followDetail.voiceAgentId,
2417
2448
  initialVisionSource: followDetail.initialVisionSource,
2418
2449
  };
2419
2450
  setFloatingCallState(nextFloatingState);
2420
2451
  writeFloatingCallState(nextFloatingState);
2421
2452
  setVoiceOverlayOpen(false);
2453
+ externalizeInFlightRef.current = false;
2422
2454
  return;
2423
2455
  }
2456
+ externalizeInFlightRef.current = false;
2424
2457
  if (followWindowMode && globalThis?.veDesktop?.follow?.hide) {
2425
2458
  globalThis.veDesktop.follow.hide().catch(() => {});
2426
2459
  return;
@@ -2432,6 +2465,10 @@ function App() {
2432
2465
  executor=${voiceExecutor}
2433
2466
  mode=${voiceAgentMode}
2434
2467
  model=${voiceModel}
2468
+ voiceAgentId=${voiceAgentId}
2469
+ onVoiceAgentChange=${(nextAgentId) => {
2470
+ setVoiceAgentId(String(nextAgentId || "").trim() || null);
2471
+ }}
2435
2472
  callType=${voiceCallType}
2436
2473
  initialVisionSource=${voiceInitialVisionSource}
2437
2474
  compact=${followWindowMode}
@@ -36,9 +36,19 @@ function sessionPath(id, action = "") {
36
36
 
37
37
  /* ─── Data loaders ─── */
38
38
  export async function loadSessions(filter = {}) {
39
- _lastLoadFilter = filter;
39
+ const normalizedFilter = {
40
+ ...(filter && typeof filter === "object" ? filter : {}),
41
+ };
42
+ if (!Object.prototype.hasOwnProperty.call(normalizedFilter, "workspace")) {
43
+ normalizedFilter.workspace = "active";
44
+ }
45
+ _lastLoadFilter = normalizedFilter;
40
46
  try {
41
- const params = new URLSearchParams(filter);
47
+ const params = new URLSearchParams();
48
+ for (const [key, value] of Object.entries(normalizedFilter)) {
49
+ if (value == null || value === "") continue;
50
+ params.set(key, String(value));
51
+ }
42
52
  const res = await apiFetch(`/api/sessions?${params}`, { _silent: true });
43
53
  if (res?.sessions) sessionsData.value = res.sessions;
44
54
  sessionsError.value = null;
@@ -317,11 +327,19 @@ export function initSessionWsListener() {
317
327
  if (_wsListenerReady) return;
318
328
  _wsListenerReady = true;
319
329
  onWsMessage((msg) => {
320
- if (msg?.type !== "session-message") return;
321
- const payload = msg.payload || {};
322
- const sessionId = payload.sessionId || payload.taskId;
323
- if (!sessionId) return;
324
- appendSessionMessage(sessionId, payload.message, payload.session);
330
+ if (msg?.type === "session-message") {
331
+ const payload = msg.payload || {};
332
+ const sessionId = payload.sessionId || payload.taskId;
333
+ if (!sessionId) return;
334
+ appendSessionMessage(sessionId, payload.message, payload.session);
335
+ return;
336
+ }
337
+ if (msg?.type === "invalidate") {
338
+ const channels = Array.isArray(msg.channels) ? msg.channels : [];
339
+ if (channels.includes("*") || channels.includes("sessions")) {
340
+ loadSessions(_lastLoadFilter).catch(() => {});
341
+ }
342
+ }
325
343
  });
326
344
  }
327
345
 
@@ -8,7 +8,7 @@ import { h } from "preact";
8
8
  import { useState, useEffect, useCallback } from "preact/hooks";
9
9
  import { signal } from "@preact/signals";
10
10
  import htm from "htm";
11
- import { apiFetch } from "../modules/api.js";
11
+ import { apiFetch, onWsMessage } from "../modules/api.js";
12
12
  import { haptic } from "../modules/telegram.js";
13
13
  import { Modal } from "./shared.js";
14
14
  import { iconText, resolveIcon } from "../modules/icon-utils.js";
@@ -53,6 +53,24 @@ export async function switchWorkspace(wsId) {
53
53
  }
54
54
  activeWorkspaceId.value = String(res.activeId || wsId);
55
55
  await loadWorkspaces();
56
+ try {
57
+ globalThis.dispatchEvent?.(
58
+ new CustomEvent("ve:workspace-switched", {
59
+ detail: { workspaceId: activeWorkspaceId.value || String(wsId || "") },
60
+ }),
61
+ );
62
+ } catch {
63
+ // no-op
64
+ }
65
+ try {
66
+ const { refreshTab } = await import("../modules/state.js");
67
+ await Promise.allSettled([
68
+ refreshTab("tasks", { background: true, manual: false }),
69
+ refreshTab("dashboard", { background: true, manual: false }),
70
+ ]);
71
+ } catch {
72
+ // best effort
73
+ }
56
74
  return true;
57
75
  } catch (err) {
58
76
  console.warn("[workspace-switcher] Failed to switch workspace:", err);
@@ -461,6 +479,35 @@ export function WorkspaceSwitcher() {
461
479
  loadWorkspaces();
462
480
  }, []);
463
481
 
482
+ // Keep selector state in sync when workspace is switched externally
483
+ // (for example via Electron menu or another client).
484
+ useEffect(() => {
485
+ const unsubscribe = onWsMessage((msg) => {
486
+ if (msg?.type !== "invalidate") return;
487
+ const channels = Array.isArray(msg.channels) ? msg.channels : [];
488
+ if (channels.includes("*") || channels.includes("workspaces")) {
489
+ loadWorkspaces().catch(() => {});
490
+ }
491
+ });
492
+ return unsubscribe;
493
+ }, []);
494
+
495
+ // Desktop fallback when WS is unavailable: refresh workspace state whenever
496
+ // the window regains focus/visibility.
497
+ useEffect(() => {
498
+ const sync = () => loadWorkspaces().catch(() => {});
499
+ const onFocus = () => sync();
500
+ const onVisibility = () => {
501
+ if (document.visibilityState === "visible") sync();
502
+ };
503
+ globalThis.addEventListener?.("focus", onFocus);
504
+ document.addEventListener?.("visibilitychange", onVisibility);
505
+ return () => {
506
+ globalThis.removeEventListener?.("focus", onFocus);
507
+ document.removeEventListener?.("visibilitychange", onVisibility);
508
+ };
509
+ }, []);
510
+
464
511
  const wsList = workspaces.value;
465
512
  const currentId = activeWorkspaceId.value;
466
513
 
package/ui/demo.html CHANGED
@@ -2723,6 +2723,55 @@
2723
2723
  return { data: STATE.executors.map((e) => ({ ...e, status: e.enabled ? 'active' : 'disabled' })) };
2724
2724
  if (route === '/api/telemetry/alerts')
2725
2725
  return { data: [] };
2726
+ if (route === '/api/analytics/usage') {
2727
+ const daysParam = Number(params.get('days') || '30');
2728
+ // Build mock usage analytics from demo STATE
2729
+ const now = Date.now();
2730
+ const dayMs = 86400000;
2731
+ const numDays = Math.min(daysParam || 30, 30);
2732
+ // Generate synthetic daily trend data
2733
+ const dates = [];
2734
+ const agentDaily = { codex: [], copilot: [], claude: [] };
2735
+ const skillDaily = { 'background-task-execution': [], 'pr-workflow': [], 'error-recovery': [] };
2736
+ const mcpDaily = { execute_bash: [], read_file: [], write_file: [], list_directory: [] };
2737
+ for (let i = numDays - 1; i >= 0; i--) {
2738
+ const d = new Date(now - i * dayMs);
2739
+ dates.push(d.toISOString().slice(0, 10));
2740
+ const active = (i < (numDays * 0.6)) ? 1 : 0;
2741
+ agentDaily.codex.push(active ? Math.floor(Math.random() * 8 + 2) : 0);
2742
+ agentDaily.copilot.push(active ? Math.floor(Math.random() * 5 + 1) : 0);
2743
+ agentDaily.claude.push(active ? Math.floor(Math.random() * 4) : 0);
2744
+ skillDaily['background-task-execution'].push(active ? Math.floor(Math.random() * 6 + 1) : 0);
2745
+ skillDaily['pr-workflow'].push(active ? Math.floor(Math.random() * 4) : 0);
2746
+ skillDaily['error-recovery'].push(active ? Math.floor(Math.random() * 3) : 0);
2747
+ mcpDaily.execute_bash.push(active ? Math.floor(Math.random() * 12 + 2) : 0);
2748
+ mcpDaily.read_file.push(active ? Math.floor(Math.random() * 8 + 1) : 0);
2749
+ mcpDaily.write_file.push(active ? Math.floor(Math.random() * 6) : 0);
2750
+ mcpDaily.list_directory.push(active ? Math.floor(Math.random() * 5) : 0);
2751
+ }
2752
+ const sum = (arr) => arr.reduce((a, b) => a + b, 0);
2753
+ const agentRuns = sum(agentDaily.codex) + sum(agentDaily.copilot) + sum(agentDaily.claude);
2754
+ const skillInvocations = Object.values(skillDaily).reduce((t, a) => t + sum(a), 0);
2755
+ const mcpToolCalls = Object.values(mcpDaily).reduce((t, a) => t + sum(a), 0);
2756
+ return { ok: true, data: {
2757
+ agentRuns, skillInvocations, mcpToolCalls,
2758
+ avgPerDay: Math.round((agentRuns + skillInvocations + mcpToolCalls) / (numDays || 1)),
2759
+ lastActiveAt: new Date(now - 7200000).toISOString(),
2760
+ sinceAt: new Date(now - numDays * dayMs).toISOString(),
2761
+ topAgents: [
2762
+ { name: 'codex', count: sum(agentDaily.codex) },
2763
+ { name: 'copilot', count: sum(agentDaily.copilot) },
2764
+ { name: 'claude', count: sum(agentDaily.claude) },
2765
+ ].filter(a => a.count > 0).sort((a, b) => b.count - a.count),
2766
+ topSkills: Object.entries(skillDaily)
2767
+ .map(([name, v]) => ({ name, count: sum(v) }))
2768
+ .filter(s => s.count > 0).sort((a, b) => b.count - a.count),
2769
+ topMcpTools: Object.entries(mcpDaily)
2770
+ .map(([name, v]) => ({ name, count: sum(v) }))
2771
+ .filter(t => t.count > 0).sort((a, b) => b.count - a.count),
2772
+ trend: { dates, agents: agentDaily, skills: skillDaily, mcpTools: mcpDaily },
2773
+ }};
2774
+ }
2726
2775
  if (route === '/api/executor/pause') {
2727
2776
  STATE.paused = true; addLog('info', 'executor', 'Executor paused');
2728
2777
  return { ok: true, paused: true };
@@ -2945,6 +2994,72 @@
2945
2994
  return { ok: true, data: best };
2946
2995
  }
2947
2996
 
2997
+ // ── MCP Servers ──
2998
+ if (route === '/api/mcp/catalog') {
2999
+ return { ok: true, data: [
3000
+ { id: 'github', name: 'GitHub', description: 'GitHub MCP server', transport: 'stdio', tags: ['code', 'git'], installed: false },
3001
+ { id: 'playwright', name: 'Playwright', description: 'Browser automation', transport: 'stdio', tags: ['testing'], installed: false },
3002
+ { id: 'context7', name: 'Context7', description: 'Documentation lookup', transport: 'stdio', tags: ['docs'], installed: true },
3003
+ ]};
3004
+ }
3005
+ if (route === '/api/mcp/installed') {
3006
+ return { ok: true, data: [
3007
+ { id: 'context7', name: 'Context7', description: 'Documentation lookup', transport: 'stdio', tags: ['docs'] },
3008
+ ]};
3009
+ }
3010
+ if (route === '/api/mcp/install') {
3011
+ return { ok: true, installed: { id: body?.catalogId || 'custom', name: body?.name || 'Custom MCP' } };
3012
+ }
3013
+ if (route === '/api/mcp/uninstall') {
3014
+ return { ok: true };
3015
+ }
3016
+ if (route === '/api/mcp/configure') {
3017
+ return { ok: true };
3018
+ }
3019
+
3020
+ // ── Agent Tool Config ──
3021
+ if (route === '/api/agent-tools/available') {
3022
+ return { ok: true, data: {
3023
+ builtinTools: [
3024
+ { id: 'search-files', name: 'Search Files', description: 'Search workspace files', category: 'Built-In', default: true },
3025
+ { id: 'read-file', name: 'Read File', description: 'Read file contents', category: 'Built-In', default: true },
3026
+ { id: 'edit-file', name: 'Edit File', description: 'Edit workspace files', category: 'Built-In', default: true },
3027
+ { id: 'run-command', name: 'Run Command', description: 'Execute shell commands', category: 'Built-In', default: true },
3028
+ { id: 'web-search', name: 'Web Search', description: 'Search the web', category: 'Built-In', default: true },
3029
+ ],
3030
+ mcpServers: [
3031
+ { id: 'context7', name: 'Context7', description: 'Documentation lookup', tags: ['docs'], transport: 'stdio' },
3032
+ ],
3033
+ }};
3034
+ }
3035
+ if (route === '/api/agent-tools/config') {
3036
+ if (method === 'POST') {
3037
+ return { ok: true };
3038
+ }
3039
+ const agentId = params.get('agentId');
3040
+ return { ok: true, data: {
3041
+ builtinTools: [
3042
+ { id: 'search-files', name: 'Search Files', enabled: true },
3043
+ { id: 'read-file', name: 'Read File', enabled: true },
3044
+ { id: 'edit-file', name: 'Edit File', enabled: true },
3045
+ { id: 'run-command', name: 'Run Command', enabled: true },
3046
+ { id: 'web-search', name: 'Web Search', enabled: true },
3047
+ ],
3048
+ mcpServers: [],
3049
+ }};
3050
+ }
3051
+ if (route === '/api/agent-tools/defaults') {
3052
+ return { ok: true, data: {
3053
+ builtinTools: [
3054
+ { id: 'search-files', name: 'Search Files', description: 'Search workspace files', category: 'Built-In', default: true },
3055
+ { id: 'read-file', name: 'Read File', description: 'Read file contents', category: 'Built-In', default: true },
3056
+ { id: 'edit-file', name: 'Edit File', description: 'Edit workspace files', category: 'Built-In', default: true },
3057
+ { id: 'run-command', name: 'Run Command', description: 'Execute shell commands', category: 'Built-In', default: true },
3058
+ { id: 'web-search', name: 'Web Search', description: 'Search the web', category: 'Built-In', default: true },
3059
+ ],
3060
+ }};
3061
+ }
3062
+
2948
3063
  // ── Agents ──
2949
3064
  if (route === '/api/agents')
2950
3065
  return { data: STATE.agents };
@@ -3477,6 +3592,67 @@
3477
3592
  return { data: buildProjectSnapshot() };
3478
3593
 
3479
3594
  // ── Voice ──
3595
+ if (route === '/api/voice/agents' && method === 'GET') {
3596
+ const fromLibrary = (STATE.libraryEntries || [])
3597
+ .filter((entry) => {
3598
+ if (!entry || entry.type !== 'agent') return false;
3599
+ const id = String(entry.id || '').toLowerCase();
3600
+ const tags = Array.isArray(entry.tags) ? entry.tags.map((t) => String(t || '').toLowerCase()) : [];
3601
+ return id.includes('voice-agent') || tags.includes('voice') || tags.includes('audio-agent');
3602
+ })
3603
+ .map((entry) => ({
3604
+ id: entry.id,
3605
+ name: entry.name || entry.id,
3606
+ description: entry.description || '',
3607
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
3608
+ model: null,
3609
+ voicePersona: String(entry.id || '').includes('female')
3610
+ ? 'female'
3611
+ : (String(entry.id || '').includes('male') ? 'male' : 'neutral'),
3612
+ voiceInstructions: '',
3613
+ skills: [],
3614
+ promptOverride: null,
3615
+ }));
3616
+
3617
+ const defaults = [
3618
+ {
3619
+ id: 'voice-agent-female',
3620
+ name: 'Voice Agent (Female)',
3621
+ description: 'Conversational voice specialist with concise guidance and call-friendly pacing.',
3622
+ tags: ['voice', 'audio-agent', 'female', 'realtime'],
3623
+ model: null,
3624
+ voicePersona: 'female',
3625
+ voiceInstructions: 'You are Nova, a concise and practical female voice agent.',
3626
+ skills: ['concise-voice-guidance', 'conversation-memory'],
3627
+ promptOverride: null,
3628
+ },
3629
+ {
3630
+ id: 'voice-agent-male',
3631
+ name: 'Voice Agent (Male)',
3632
+ description: 'Operational voice specialist focused on diagnostics and execution.',
3633
+ tags: ['voice', 'audio-agent', 'male', 'realtime'],
3634
+ model: null,
3635
+ voicePersona: 'male',
3636
+ voiceInstructions: 'You are Atlas, a direct and execution-oriented male voice agent.',
3637
+ skills: ['ops-diagnostics', 'task-execution'],
3638
+ promptOverride: null,
3639
+ },
3640
+ ];
3641
+
3642
+ const seen = new Set();
3643
+ const agents = [...fromLibrary, ...defaults].filter((agent) => {
3644
+ const id = String(agent?.id || '').trim();
3645
+ if (!id || seen.has(id)) return false;
3646
+ seen.add(id);
3647
+ return true;
3648
+ });
3649
+ return {
3650
+ ok: true,
3651
+ agents,
3652
+ defaultAgentId: agents[0]?.id || 'voice-agent-female',
3653
+ };
3654
+ }
3655
+
3480
3656
  if (route === '/api/voice/audio/respond' && method === 'POST') {
3481
3657
  const inputText = String(body?.inputText || body?.text || '').trim();
3482
3658
  if (!inputText) {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * mic-track-registry.js
3
+ *
4
+ * Tracks microphone input streams obtained via getUserMedia and provides a
5
+ * hard-stop primitive used by voice teardown to prevent lingering "mic in use"
6
+ * indicators after a call is closed.
7
+ */
8
+
9
+ const trackedStreams = new Set();
10
+ let patched = false;
11
+
12
+ function isMediaStreamLike(stream) {
13
+ return Boolean(stream && typeof stream.getTracks === "function");
14
+ }
15
+
16
+ function getAudioTracks(stream) {
17
+ if (!isMediaStreamLike(stream)) return [];
18
+ try {
19
+ return (stream.getAudioTracks?.() || [])
20
+ .filter((track) => String(track?.kind || "").toLowerCase() === "audio");
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ function pruneInactiveStreams() {
27
+ for (const stream of trackedStreams) {
28
+ const tracks = getAudioTracks(stream);
29
+ if (!tracks.length) {
30
+ trackedStreams.delete(stream);
31
+ continue;
32
+ }
33
+ const hasLive = tracks.some((track) => String(track?.readyState || "live").toLowerCase() !== "ended");
34
+ if (!hasLive) trackedStreams.delete(stream);
35
+ }
36
+ }
37
+
38
+ export function registerMicStream(stream) {
39
+ if (!isMediaStreamLike(stream)) return;
40
+ trackedStreams.add(stream);
41
+ const tracks = getAudioTracks(stream);
42
+ for (const track of tracks) {
43
+ try {
44
+ track.addEventListener?.("ended", () => {
45
+ pruneInactiveStreams();
46
+ }, { once: true });
47
+ } catch {
48
+ // no-op
49
+ }
50
+ }
51
+ }
52
+
53
+ export function ensureMicTrackingPatched() {
54
+ if (patched) return;
55
+ const mediaDevices = globalThis?.navigator?.mediaDevices;
56
+ if (!mediaDevices || typeof mediaDevices.getUserMedia !== "function") return;
57
+ const original = mediaDevices.getUserMedia.bind(mediaDevices);
58
+ mediaDevices.getUserMedia = async (...args) => {
59
+ const stream = await original(...args);
60
+ registerMicStream(stream);
61
+ return stream;
62
+ };
63
+ patched = true;
64
+ }
65
+
66
+ export function stopTrackedMicStreams() {
67
+ for (const stream of trackedStreams) {
68
+ const tracks = getAudioTracks(stream);
69
+ for (const track of tracks) {
70
+ try {
71
+ track.stop();
72
+ } catch {
73
+ // no-op
74
+ }
75
+ }
76
+ }
77
+ pruneInactiveStreams();
78
+ }
79
+
80
+ export function _resetMicTrackRegistryForTests() {
81
+ trackedStreams.clear();
82
+ patched = false;
83
+ }
@@ -132,7 +132,10 @@ export const SETTINGS_SCHEMA = [
132
132
  { key: "AZURE_OPENAI_REALTIME_API_KEY", label: "Azure Realtime Key (legacy)", category: "voice", type: "secret", sensitive: true, description: "Legacy fallback: Azure OpenAI API key. Use the Voice Endpoints card above for full multi-endpoint config. Falls back to AZURE_OPENAI_API_KEY if not set." },
133
133
  { key: "AZURE_OPENAI_REALTIME_DEPLOYMENT", label: "Azure Deployment (legacy)", category: "voice", type: "select", defaultVal: "gpt-audio-1.5", options: ["gpt-audio-1.5", "gpt-realtime-1.5", "gpt-4o-realtime-preview", "custom"], description: "Legacy fallback: Azure deployment name. Use the Voice Endpoints card above. GA models (gpt-realtime-1.5) auto-use /openai/v1/ paths." },
134
134
  { key: "VOICE_ID", label: "Voice", category: "voice", type: "select", defaultVal: "alloy", options: ["alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"], description: "Voice personality for text-to-speech output." },
135
- { key: "VOICE_TURN_DETECTION", label: "Turn Detection", category: "voice", type: "select", defaultVal: "server_vad", options: ["server_vad", "semantic_vad", "none"], description: "How the model detects when you stop speaking. 'semantic_vad' is more intelligent but higher latency." },
135
+ { key: "VOICE_TURN_DETECTION", label: "Turn Detection", category: "voice", type: "select", defaultVal: "semantic_vad", options: ["server_vad", "semantic_vad", "none"], description: "How the model detects when you stop speaking. 'semantic_vad' is more intelligent but higher latency." },
136
+ { key: "VOICE_TRANSCRIPTION_ENABLED", label: "Input Transcription Enabled", category: "voice", type: "boolean", defaultVal: true, description: "Enable per-turn input audio transcription for OpenAI-compatible realtime sessions." },
137
+ { key: "VOICE_TRANSCRIPTION_MODEL", label: "Input Transcription Model", category: "voice", type: "string", defaultVal: "gpt-4o-transcribe", description: "Model used for input audio transcription when transcription is enabled." },
138
+ { key: "VOICE_AZURE_TRANSCRIPTION_ENABLED", label: "Azure Input Transcription", category: "voice", type: "boolean", defaultVal: false, description: "Enable input transcription specifically for Azure realtime sessions. Disabled by default to avoid Azure per-item transcription failures." },
136
139
  { key: "VOICE_DELEGATE_EXECUTOR", label: "Delegate Executor", category: "voice", type: "select", defaultVal: "codex-sdk", options: ["codex-sdk", "copilot-sdk", "claude-sdk", "gemini-sdk", "opencode-sdk"], description: "Which agent executor voice tool calls delegate to for complex tasks." },
137
140
  { key: "VOICE_FALLBACK_MODE", label: "Fallback Mode", category: "voice", type: "select", defaultVal: "browser", options: ["browser", "disabled"], description: "When Tier 1 (Realtime API) is unavailable, use browser speech APIs as fallback." },
138
141
 
@@ -52,6 +52,7 @@ const CACHE_TTL = {
52
52
  presence: 30000, config: 60000, projects: 60000, git: 20000,
53
53
  infra: 30000,
54
54
  telemetry: 15000,
55
+ analytics: 30000,
55
56
  };
56
57
 
57
58
  function _cacheKey(url) { return url; }
@@ -126,6 +127,9 @@ export const telemetryErrors = signal([]);
126
127
  export const telemetryExecutors = signal({});
127
128
  export const telemetryAlerts = signal([]);
128
129
 
130
+ // ── Usage Analytics
131
+ export const usageAnalytics = signal(null);
132
+
129
133
  // ── Config (routing, regions, etc.)
130
134
  export const configData = signal(null);
131
135
 
@@ -687,6 +691,26 @@ export async function loadTelemetryAlerts() {
687
691
  _markFresh("telemetry");
688
692
  }
689
693
 
694
+ /**
695
+ * Load usage analytics. Pass `days=0` for all-time data.
696
+ * The result is stored in the `usageAnalytics` signal.
697
+ *
698
+ * @param {number} [days=30]
699
+ */
700
+ export async function loadUsageAnalytics(days = 30) {
701
+ const url = `/api/analytics/usage?days=${days}`;
702
+ // Don't use _cacheFresh here — callers pass explicit day window
703
+ // and the period toggle must always trigger a fresh load.
704
+ try {
705
+ const res = await apiFetch(url, { _silent: true }).catch(() => ({ ok: false }));
706
+ usageAnalytics.value = res?.data ?? null;
707
+ _cacheSet(url, usageAnalytics.value);
708
+ _markFresh("analytics");
709
+ } catch {
710
+ /* best effort */
711
+ }
712
+ }
713
+
690
714
  /* ═══════════════════════════════════════════════════════════════
691
715
  * TAB REFRESH — map tab names to their required loaders
692
716
  * ═══════════════════════════════════════════════════════════════ */
@@ -712,6 +736,7 @@ const TAB_LOADERS = {
712
736
  loadTelemetryErrors(),
713
737
  loadTelemetryExecutors(),
714
738
  loadTelemetryAlerts(),
739
+ loadUsageAnalytics(30),
715
740
  ]),
716
741
  settings: () => Promise.all([loadStatus(), loadConfig()]),
717
742
  };