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.
- package/.env.example +4 -1
- package/agent-tool-config.mjs +338 -0
- package/bosun-skills.mjs +59 -4
- package/bosun.schema.json +1 -1
- package/desktop/launch.mjs +18 -0
- package/desktop/main.mjs +52 -13
- package/fleet-coordinator.mjs +34 -1
- package/kanban-adapter.mjs +30 -3
- package/library-manager.mjs +66 -0
- package/maintenance.mjs +30 -5
- package/monitor.mjs +56 -0
- package/package.json +4 -1
- package/setup-web-server.mjs +73 -12
- package/setup.mjs +3 -3
- package/ui/app.js +40 -3
- package/ui/components/session-list.js +25 -7
- package/ui/components/workspace-switcher.js +48 -1
- package/ui/demo.html +176 -0
- package/ui/modules/mic-track-registry.js +83 -0
- package/ui/modules/settings-schema.js +4 -1
- package/ui/modules/state.js +25 -0
- package/ui/modules/streaming.js +1 -1
- package/ui/modules/voice-barge-in.js +27 -0
- package/ui/modules/voice-client-sdk.js +268 -42
- package/ui/modules/voice-client.js +665 -61
- package/ui/modules/voice-overlay.js +829 -47
- package/ui/setup.html +151 -9
- package/ui/styles.css +258 -0
- package/ui/tabs/chat.js +11 -0
- package/ui/tabs/library.js +890 -15
- package/ui/tabs/settings.js +51 -11
- package/ui/tabs/telemetry.js +327 -105
- package/ui/tabs/workflows.js +86 -0
- package/ui-server.mjs +1201 -107
- package/voice-action-dispatcher.mjs +81 -0
- package/voice-agents-sdk.mjs +2 -2
- package/voice-relay.mjs +131 -14
- package/voice-tools.mjs +475 -9
- package/workflow-engine.mjs +54 -0
- package/workflow-nodes.mjs +177 -28
- package/workflow-templates/github.mjs +205 -94
- package/workflow-templates/task-batch.mjs +247 -0
- 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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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: "
|
|
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
|
|
package/ui/modules/state.js
CHANGED
|
@@ -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
|
};
|