bosun 0.37.1 → 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/ui-server.mjs CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  matchAgentProfile,
71
71
  loadManifest,
72
72
  getManifestPath,
73
+ scaffoldAgentProfiles,
73
74
  } from "./library-manager.mjs";
74
75
  import {
75
76
  listCatalog,
@@ -161,7 +162,7 @@ const repoRoot = resolveRepoRoot();
161
162
  const uiRootPreferred = resolve(__dirname, "ui");
162
163
  const uiRootFallback = resolve(__dirname, "site", "ui");
163
164
  const uiRoot = existsSync(uiRootPreferred) ? uiRootPreferred : uiRootFallback;
164
- let libraryInitAttempted = false;
165
+ const libraryInitAttemptedRoots = new Set();
165
166
  const MAX_VISION_FRAME_BYTES = Math.max(
166
167
  128_000,
167
168
  Number.parseInt(process.env.VISION_FRAME_MAX_BYTES || "", 10) || 2_000_000,
@@ -208,21 +209,238 @@ function parseVisionFrameDataUrl(dataUrl) {
208
209
  return { ok: true, mimeType, base64Data, approxBytes, raw };
209
210
  }
210
211
 
211
- function ensureLibraryInitialized() {
212
- if (libraryInitAttempted) return;
213
- libraryInitAttempted = true;
212
+ function ensureLibraryInitialized(rootDir = repoRoot) {
213
+ const normalizedRoot = normalizeCandidatePath(rootDir) || repoRoot;
214
+ if (libraryInitAttemptedRoots.has(normalizedRoot)) return;
215
+ libraryInitAttemptedRoots.add(normalizedRoot);
214
216
  try {
215
- const manifestPath = getManifestPath(repoRoot);
216
- const manifest = loadManifest(repoRoot);
217
+ const manifestPath = getManifestPath(normalizedRoot);
218
+ const manifest = loadManifest(normalizedRoot);
217
219
  if (!existsSync(manifestPath) || !Array.isArray(manifest?.entries) || manifest.entries.length === 0) {
218
- const result = initLibrary(repoRoot);
220
+ const result = initLibrary(normalizedRoot);
219
221
  const count = result?.manifest?.entries?.length ?? 0;
220
222
  if (count > 0) {
221
- console.log(`[ui] Library initialized (${count} entries).`);
223
+ console.log(`[ui] Library initialized (${count} entries) at ${normalizedRoot}.`);
222
224
  }
223
225
  }
226
+ const scaffoldResult = scaffoldAgentProfiles(normalizedRoot);
227
+ if (Array.isArray(scaffoldResult?.written) && scaffoldResult.written.length > 0) {
228
+ rebuildManifest(normalizedRoot);
229
+ }
224
230
  } catch (err) {
225
- console.warn(`[ui] Library init failed: ${err.message}`);
231
+ console.warn(`[ui] Library init failed for ${normalizedRoot}: ${err.message}`);
232
+ }
233
+ }
234
+
235
+ const VOICE_TOOL_ID_MAP = Object.freeze({
236
+ "search-files": ["search_code", "list_directory"],
237
+ "read-file": ["read_file_content"],
238
+ "edit-file": ["delegate_to_agent", "run_workspace_command"],
239
+ "run-command": ["run_command", "run_workspace_command"],
240
+ "web-search": ["ask_agent_context"],
241
+ "code-search": ["search_code", "get_workspace_context"],
242
+ "git-operations": ["run_workspace_command", "get_pr_status"],
243
+ "create-task": ["create_task"],
244
+ "delegate-task": ["delegate_to_agent", "ask_agent_context", "poll_background_session"],
245
+ "fetch-url": ["run_workspace_command"],
246
+ "list-directory": ["list_directory"],
247
+ "grep-search": ["search_code"],
248
+ "task-management": ["list_tasks", "get_task", "search_tasks", "get_task_stats", "delete_task", "comment_on_task"],
249
+ "notifications": ["dispatch_action"],
250
+ "vision-analysis": ["query_live_view"],
251
+ });
252
+
253
+ const BUILTIN_TOOL_ID_SET = new Set(
254
+ (Array.isArray(DEFAULT_BUILTIN_TOOLS) ? DEFAULT_BUILTIN_TOOLS : [])
255
+ .map((tool) => String(tool?.id || "").trim())
256
+ .filter(Boolean),
257
+ );
258
+
259
+ function mapToolConfigIdsToVoiceToolNames(enabledIds = []) {
260
+ const resolved = new Set();
261
+ for (const rawId of Array.isArray(enabledIds) ? enabledIds : []) {
262
+ const id = String(rawId || "").trim();
263
+ if (!id) continue;
264
+ const mapped = VOICE_TOOL_ID_MAP[id];
265
+ if (Array.isArray(mapped) && mapped.length > 0) {
266
+ for (const toolName of mapped) resolved.add(String(toolName || "").trim());
267
+ continue;
268
+ }
269
+ // If this is a known built-in tool id without a runtime mapping, skip it
270
+ // instead of treating the id as a voice runtime tool name.
271
+ if (BUILTIN_TOOL_ID_SET.has(id)) {
272
+ continue;
273
+ }
274
+ resolved.add(id);
275
+ }
276
+ return resolved;
277
+ }
278
+
279
+ function isVoiceAgentProfileEntry(entry, profile) {
280
+ if (!entry || entry.type !== "agent") return false;
281
+ const id = String(entry.id || "").trim().toLowerCase();
282
+ const tags = Array.isArray(entry.tags) ? entry.tags.map((t) => String(t || "").trim().toLowerCase()) : [];
283
+ const profileType = String(profile?.agentType || "").trim().toLowerCase();
284
+ if (profileType === "voice") return true;
285
+ if (id.startsWith("voice-agent")) return true;
286
+ if (tags.includes("voice") || tags.includes("audio-agent") || tags.includes("realtime")) return true;
287
+ if (profile && typeof profile === "object" && profile.voiceAgent === true) return true;
288
+ return false;
289
+ }
290
+
291
+ function resolveAgentProfileType(entry, profile) {
292
+ const explicit = String(profile?.agentType || "").trim().toLowerCase();
293
+ if (explicit === "voice" || explicit === "task" || explicit === "chat") return explicit;
294
+ if (isVoiceAgentProfileEntry(entry, profile)) return "voice";
295
+ return "task";
296
+ }
297
+
298
+ function resolveVoiceLibraryRoot(callContext = {}) {
299
+ const sessionId = String(callContext?.sessionId || "").trim();
300
+ if (!sessionId) return repoRoot;
301
+ try {
302
+ const tracker = getSessionTracker();
303
+ const session = tracker.getSessionById
304
+ ? tracker.getSessionById(sessionId)
305
+ : (tracker.getSession ? tracker.getSession(sessionId) : null);
306
+ const workspaceDir = String(
307
+ session?.workspaceDir
308
+ || session?.metadata?.workspaceDir
309
+ || "",
310
+ ).trim();
311
+ if (workspaceDir) return workspaceDir;
312
+ } catch {
313
+ // best effort
314
+ }
315
+ return repoRoot;
316
+ }
317
+
318
+ function listVoiceAgentProfiles(rootDir = repoRoot) {
319
+ const libraryRoot = rootDir || repoRoot;
320
+ ensureLibraryInitialized(libraryRoot);
321
+ const entries = listEntries(libraryRoot, { type: "agent" }) || [];
322
+ const profiles = [];
323
+ for (const entry of entries) {
324
+ const profile = getEntryContent(libraryRoot, entry);
325
+ if (!isVoiceAgentProfileEntry(entry, profile)) continue;
326
+ profiles.push({
327
+ id: entry.id,
328
+ name: entry.name || entry.id,
329
+ description: entry.description || "",
330
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
331
+ model: String(profile?.model || "").trim() || null,
332
+ voicePersona: String(profile?.voicePersona || "").trim() || "neutral",
333
+ voiceInstructions: String(profile?.voiceInstructions || "").trim() || "",
334
+ skills: Array.isArray(profile?.skills) ? profile.skills.map((s) => String(s || "").trim()).filter(Boolean) : [],
335
+ promptOverride: String(profile?.promptOverride || "").trim() || null,
336
+ agentType: resolveAgentProfileType(entry, profile),
337
+ });
338
+ }
339
+
340
+ profiles.sort((a, b) => {
341
+ const rank = (id) => {
342
+ if (id === "voice-agent-female") return 0;
343
+ if (id === "voice-agent-male") return 1;
344
+ if (id === "voice-agent") return 2;
345
+ return 3;
346
+ };
347
+ const ra = rank(a.id);
348
+ const rb = rank(b.id);
349
+ if (ra !== rb) return ra - rb;
350
+ return String(a.name || a.id).localeCompare(String(b.name || b.id));
351
+ });
352
+ return profiles;
353
+ }
354
+
355
+ function resolveActiveVoiceAgent(rootDir = repoRoot, requestedAgentId = "") {
356
+ const agents = listVoiceAgentProfiles(rootDir);
357
+ if (agents.length === 0) return { agents: [], selected: null };
358
+ const requested = String(requestedAgentId || "").trim();
359
+ const selected = agents.find((a) => a.id === requested)
360
+ || agents.find((a) => a.id === "voice-agent-female")
361
+ || agents[0];
362
+ return { agents, selected };
363
+ }
364
+
365
+ function applyVoiceAgentToolFilters(allTools, toolConfig = null) {
366
+ const tools = Array.isArray(allTools) ? [...allTools] : [];
367
+ if (!toolConfig || typeof toolConfig !== "object") return tools;
368
+
369
+ const disabledBuiltinIds = Array.isArray(toolConfig.disabledBuiltinTools)
370
+ ? toolConfig.disabledBuiltinTools
371
+ : [];
372
+ const enabledIds = Array.isArray(toolConfig.enabledTools) ? toolConfig.enabledTools : null;
373
+ const enabledServers = Array.isArray(toolConfig.enabledMcpServers) ? toolConfig.enabledMcpServers : [];
374
+
375
+ const disabledNames = mapToolConfigIdsToVoiceToolNames(disabledBuiltinIds);
376
+ const enabledNames = enabledIds ? mapToolConfigIdsToVoiceToolNames(enabledIds) : null;
377
+
378
+ return tools.filter((tool) => {
379
+ const name = String(tool?.name || "").trim();
380
+ if (!name) return false;
381
+ if (name === "invoke_mcp_tool" && enabledServers.length === 0) return false;
382
+ if (enabledNames && enabledNames.size > 0) {
383
+ return enabledNames.has(name);
384
+ }
385
+ if (disabledNames.has(name)) return false;
386
+ return true;
387
+ });
388
+ }
389
+
390
+ function buildVoiceToolCapabilityPrompt(tools = [], toolConfig = null, selectedVoiceAgent = null) {
391
+ const runtimeTools = Array.isArray(tools) ? tools : [];
392
+ const enabledServers = Array.isArray(toolConfig?.enabledMcpServers)
393
+ ? toolConfig.enabledMcpServers
394
+ : [];
395
+ const toolLines = runtimeTools
396
+ .slice(0, 48)
397
+ .map((tool) => {
398
+ const name = String(tool?.name || "").trim();
399
+ if (!name) return null;
400
+ const description = String(tool?.description || "").replace(/\s+/g, " ").trim();
401
+ return description ? `- ${name}: ${description}` : `- ${name}`;
402
+ })
403
+ .filter(Boolean);
404
+ const skills = Array.isArray(selectedVoiceAgent?.skills)
405
+ ? selectedVoiceAgent.skills.map((skill) => String(skill || "").trim()).filter(Boolean)
406
+ : [];
407
+ const agentName = String(selectedVoiceAgent?.name || selectedVoiceAgent?.id || "Voice Agent").trim();
408
+
409
+ return [
410
+ "",
411
+ "## Active Voice Agent Capability Contract",
412
+ `Agent profile: ${agentName}.`,
413
+ "You can execute tools directly in this voice session. Do not claim you cannot use tools or must rely on chat-only help.",
414
+ "When the user asks for action or facts, call the most relevant tool first, then report results briefly.",
415
+ toolLines.length > 0
416
+ ? "Enabled runtime tools:\n" + toolLines.join("\n")
417
+ : "Enabled runtime tools: none (tool calls unavailable for this profile).",
418
+ enabledServers.length > 0
419
+ ? `Enabled MCP servers (for invoke_mcp_tool): ${enabledServers.join(", ")}.`
420
+ : "Enabled MCP servers: none.",
421
+ skills.length > 0
422
+ ? `Voice agent skills: ${skills.join(", ")}.`
423
+ : "Voice agent skills: none specified.",
424
+ "",
425
+ "If you need the current tool list, call get_admin_help.",
426
+ ].join("\n");
427
+ }
428
+
429
+ async function listBosunRuntimeTools(context = {}) {
430
+ try {
431
+ const { getVoiceToolDefinitions } = await import("./voice-relay.mjs");
432
+ const defs = await getVoiceToolDefinitions({ delegateOnly: false, context });
433
+ return (Array.isArray(defs) ? defs : [])
434
+ .map((tool) => ({
435
+ id: String(tool?.name || "").trim(),
436
+ name: String(tool?.name || "").trim(),
437
+ description: String(tool?.description || "").trim(),
438
+ category: "Bosun",
439
+ }))
440
+ .filter((tool) => Boolean(tool.id))
441
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)));
442
+ } catch {
443
+ return [];
226
444
  }
227
445
  }
228
446
 
@@ -231,10 +449,27 @@ let _wfEngine;
231
449
  let _wfNodes;
232
450
  let _wfTemplates;
233
451
  let _wfServicesReady = false;
452
+ let _wfServices = null;
234
453
  let _wfRecommendedInstalled = false;
454
+ const _wfRecommendedInstalledByWorkspace = new Set();
455
+ const _wfEngineByWorkspace = new Map();
235
456
  let _wfInitPromise = null;
236
457
  let _wfInitDone = false;
237
458
  let _wfLoadedBase = null;
459
+
460
+ /**
461
+ * Test-only: inject a mock workflow engine module and pre-seed the per-workspace
462
+ * engine cache so that dispatchWorkflowEvent uses the specified mock.
463
+ */
464
+ let _testDefaultEngine = null;
465
+ export function _testInjectWorkflowEngine(mockModule, mockEngine) {
466
+ _wfEngine = mockModule;
467
+ _wfInitDone = true;
468
+ _wfInitPromise = null;
469
+ _testDefaultEngine = mockEngine || null;
470
+ _wfEngineByWorkspace.clear();
471
+ }
472
+
238
473
  let workflowEventDedupWindowMs = (() => {
239
474
  const parsed = Number.parseInt(process.env.WORKFLOW_EVENT_DEDUP_WINDOW_MS || "15000", 10);
240
475
  if (!Number.isFinite(parsed) || parsed <= 0) return 15_000;
@@ -441,6 +676,7 @@ async function getWorkflowEngineModule() {
441
676
  meeting: meetingService,
442
677
  prompts: promptBundle?.prompts || null,
443
678
  };
679
+ _wfServices = services;
444
680
  _wfEngine.getWorkflowEngine({ services });
445
681
  _wfServicesReady = true;
446
682
 
@@ -533,6 +769,100 @@ async function getWorkflowEngineModule() {
533
769
  return _wfEngine;
534
770
  }
535
771
 
772
+ function getWorkflowWorkspaceKey(workspaceDir = "") {
773
+ const normalized = normalizeCandidatePath(workspaceDir) || repoRoot;
774
+ if (process.platform === "win32") {
775
+ return normalized.toLowerCase();
776
+ }
777
+ return normalized;
778
+ }
779
+
780
+ function getWorkflowStoragePaths(workspaceDir = "") {
781
+ const root = normalizeCandidatePath(workspaceDir) || repoRoot;
782
+ return {
783
+ workspaceRoot: root,
784
+ workflowDir: resolve(root, ".bosun", "workflows"),
785
+ runsDir: resolve(root, ".bosun", "workflow-runs"),
786
+ };
787
+ }
788
+
789
+ function maybeBootstrapWorkspaceWorkflowTemplates(engine, workspaceKey, workspaceLabel) {
790
+ if (!engine || !_wfTemplates) return;
791
+ if (_wfRecommendedInstalledByWorkspace.has(workspaceKey)) return;
792
+ try {
793
+ const selection = resolveWorkflowBootstrapSelection(_wfTemplates);
794
+ let result = { installed: [], skipped: [], errors: [] };
795
+ if (selection.enabled) {
796
+ if (
797
+ Array.isArray(selection.templateIds) &&
798
+ selection.templateIds.length > 0 &&
799
+ typeof _wfTemplates.installTemplateSet === "function"
800
+ ) {
801
+ result = _wfTemplates.installTemplateSet(engine, selection.templateIds);
802
+ } else if (
803
+ selection.source === "recommended" &&
804
+ typeof _wfTemplates.installRecommendedTemplates === "function"
805
+ ) {
806
+ result = _wfTemplates.installRecommendedTemplates(engine);
807
+ }
808
+ }
809
+ if (typeof _wfTemplates.reconcileInstalledTemplates === "function") {
810
+ _wfTemplates.reconcileInstalledTemplates(engine, {
811
+ autoUpdateUnmodified: true,
812
+ });
813
+ }
814
+ if (result.installed.length) {
815
+ console.log(
816
+ `[workflows] Installed ${result.installed.length} default workflow templates for workspace ${workspaceLabel}`,
817
+ );
818
+ }
819
+ } catch (err) {
820
+ console.warn(
821
+ `[workflows] Default template install failed for workspace ${workspaceLabel}: ${err.message}`,
822
+ );
823
+ } finally {
824
+ _wfRecommendedInstalledByWorkspace.add(workspaceKey);
825
+ }
826
+ }
827
+
828
+ async function getWorkflowRequestContext(reqUrl) {
829
+ const workspaceContext = resolveWorkspaceContextFromRequest(reqUrl, { allowAll: false });
830
+ if (!workspaceContext) {
831
+ return { ok: false, status: 400, error: "Unknown workspace. Set a valid workspace query value." };
832
+ }
833
+ const wfMod = await getWorkflowEngineModule();
834
+ if (!wfMod?.WorkflowEngine) {
835
+ return { ok: false, status: 503, error: "Workflow engine not available" };
836
+ }
837
+ const paths = getWorkflowStoragePaths(workspaceContext.workspaceDir);
838
+ const workspaceKey = getWorkflowWorkspaceKey(paths.workspaceRoot);
839
+ let engine = _wfEngineByWorkspace.get(workspaceKey) || null;
840
+ if (!engine) {
841
+ if (_testDefaultEngine) {
842
+ engine = _testDefaultEngine;
843
+ } else {
844
+ engine = new wfMod.WorkflowEngine({
845
+ workflowDir: paths.workflowDir,
846
+ runsDir: paths.runsDir,
847
+ services: _wfServices || {},
848
+ });
849
+ engine.load();
850
+ }
851
+ _wfEngineByWorkspace.set(workspaceKey, engine);
852
+ }
853
+ maybeBootstrapWorkspaceWorkflowTemplates(
854
+ engine,
855
+ workspaceKey,
856
+ workspaceContext.workspaceId || workspaceKey,
857
+ );
858
+ return {
859
+ ok: true,
860
+ wfMod,
861
+ engine,
862
+ workspaceContext: { ...workspaceContext, workspaceDir: paths.workspaceRoot },
863
+ };
864
+ }
865
+
536
866
  function allowWorkflowEvent(dedupKey, windowMs = workflowEventDedupWindowMs) {
537
867
  if (!dedupKey) return true;
538
868
  const now = Date.now();
@@ -576,10 +906,11 @@ async function dispatchWorkflowEvent(eventType, eventData = {}, opts = {}) {
576
906
  return false;
577
907
  }
578
908
 
579
- const wfMod = await getWorkflowEngineModule();
580
- if (!wfMod?.getWorkflowEngine) return false;
581
-
582
- const engine = wfMod.getWorkflowEngine();
909
+ const wfCtx = await getWorkflowRequestContext(
910
+ new URL(`http://localhost?workspace=active`),
911
+ );
912
+ if (!wfCtx?.ok) return false;
913
+ const engine = wfCtx.engine;
583
914
  if (!engine?.evaluateTriggers || !engine?.execute) return false;
584
915
 
585
916
  const payload = buildWorkflowEventPayload(eventType, eventData, "ui-server");
@@ -1295,6 +1626,79 @@ function resolveActiveWorkspaceExecutionContext() {
1295
1626
  };
1296
1627
  }
1297
1628
 
1629
+ function resolveWorkspaceContextById(workspaceId = "") {
1630
+ const requestedId = String(workspaceId || "").trim().toLowerCase();
1631
+ if (!requestedId) return resolveActiveWorkspaceExecutionContext();
1632
+ const configDir = resolveUiConfigDir();
1633
+ if (!configDir) return null;
1634
+ const listed = listManagedWorkspaces(configDir, { repoRoot });
1635
+ const workspace = listed.find(
1636
+ (entry) => String(entry?.id || "").trim().toLowerCase() === requestedId,
1637
+ );
1638
+ if (!workspace) return null;
1639
+ const id = String(workspace.id || "").trim();
1640
+ return {
1641
+ workspaceId: id,
1642
+ workspaceDir: pickWorkspaceRepoDir(workspace) || repoRoot,
1643
+ };
1644
+ }
1645
+
1646
+ function resolveWorkspaceContextFromRequest(reqUrl, opts = {}) {
1647
+ const allowAll = opts.allowAll !== false;
1648
+ const workspaceRaw = String(reqUrl?.searchParams?.get("workspace") || "").trim();
1649
+ const workspaceKey = workspaceRaw.toLowerCase();
1650
+ if (allowAll && (workspaceKey === "all" || workspaceKey === "*")) {
1651
+ return {
1652
+ allWorkspaces: true,
1653
+ workspaceId: "",
1654
+ workspaceDir: repoRoot,
1655
+ workspaceFilter: "",
1656
+ };
1657
+ }
1658
+ if (!workspaceKey || workspaceKey === "active") {
1659
+ const active = resolveActiveWorkspaceExecutionContext();
1660
+ return {
1661
+ allWorkspaces: false,
1662
+ workspaceId: String(active.workspaceId || "").trim(),
1663
+ workspaceDir: normalizeCandidatePath(active.workspaceDir) || repoRoot,
1664
+ workspaceFilter: String(active.workspaceId || "").trim().toLowerCase(),
1665
+ };
1666
+ }
1667
+ const explicit = resolveWorkspaceContextById(workspaceKey);
1668
+ if (!explicit) return null;
1669
+ return {
1670
+ allWorkspaces: false,
1671
+ workspaceId: String(explicit.workspaceId || "").trim(),
1672
+ workspaceDir: normalizeCandidatePath(explicit.workspaceDir) || repoRoot,
1673
+ workspaceFilter: String(explicit.workspaceId || "").trim().toLowerCase(),
1674
+ };
1675
+ }
1676
+
1677
+ function resolveSessionWorkspaceMeta(session) {
1678
+ const metadata =
1679
+ session && typeof session.metadata === "object" && session.metadata
1680
+ ? session.metadata
1681
+ : null;
1682
+ return {
1683
+ workspaceId: String(metadata?.workspaceId || "").trim().toLowerCase(),
1684
+ workspaceDir: normalizeCandidatePath(metadata?.workspaceDir),
1685
+ };
1686
+ }
1687
+
1688
+ function sessionMatchesWorkspaceContext(session, workspaceContext) {
1689
+ if (!session) return false;
1690
+ if (!workspaceContext || workspaceContext.allWorkspaces) return true;
1691
+ const sessionWorkspace = resolveSessionWorkspaceMeta(session);
1692
+ if (sessionWorkspace.workspaceId) {
1693
+ return sessionWorkspace.workspaceId === String(workspaceContext.workspaceFilter || "").trim().toLowerCase();
1694
+ }
1695
+ const activeWorkspaceDir = normalizeCandidatePath(workspaceContext.workspaceDir);
1696
+ if (sessionWorkspace.workspaceDir && activeWorkspaceDir) {
1697
+ return sessionWorkspace.workspaceDir === activeWorkspaceDir;
1698
+ }
1699
+ return !workspaceContext.workspaceFilter;
1700
+ }
1701
+
1298
1702
  function resolveSessionWorkspaceDir(session = null) {
1299
1703
  const metadata =
1300
1704
  session && typeof session.metadata === "object" && session.metadata
@@ -5634,6 +6038,128 @@ function summarizeTelemetry(metrics, days) {
5634
6038
  };
5635
6039
  }
5636
6040
 
6041
+ // ── Usage Analytics ─────────────────────────────────────────────────────────
6042
+
6043
+ /**
6044
+ * Build comprehensive usage analytics from agent-work-stream.jsonl.
6045
+ * Aggregates agent runs, skill invocations, MCP tool calls, and daily trends.
6046
+ *
6047
+ * @param {number} [days] - Look-back window in days; 0 = all time.
6048
+ * @returns {Promise<Object>}
6049
+ */
6050
+ async function buildUsageAnalytics(days) {
6051
+ const logDir = resolveAgentWorkLogDir();
6052
+ const streamPath = resolve(logDir, "agent-work-stream.jsonl");
6053
+ const events = await readJsonlTail(streamPath, 100_000);
6054
+
6055
+ const cutoff = days ? Date.now() - days * 24 * 60 * 60 * 1000 : 0;
6056
+
6057
+ let agentRuns = 0;
6058
+ let skillInvocations = 0;
6059
+ let mcpToolCalls = 0;
6060
+ let oldestTs = Infinity;
6061
+ let newestTs = 0;
6062
+
6063
+ /** @type {Map<string,number>} */
6064
+ const agents = new Map();
6065
+ /** @type {Map<string,number>} */
6066
+ const skills = new Map();
6067
+ /** @type {Map<string,number>} */
6068
+ const mcpTools = new Map();
6069
+
6070
+ /** dailyAgents[date][executor] = count */
6071
+ const dailyAgents = {};
6072
+ /** dailySkills[date][skill] = count */
6073
+ const dailySkills = {};
6074
+ /** dailyMcp[date][tool] = count */
6075
+ const dailyMcp = {};
6076
+
6077
+ const allDates = new Set();
6078
+
6079
+ for (const e of events) {
6080
+ const ts = Date.parse(e.timestamp || "");
6081
+ if (!Number.isFinite(ts)) continue;
6082
+ if (cutoff && ts < cutoff) continue;
6083
+ if (ts < oldestTs) oldestTs = ts;
6084
+ if (ts > newestTs) newestTs = ts;
6085
+ const day = (e.timestamp || "").slice(0, 10);
6086
+ if (day) allDates.add(day);
6087
+
6088
+ if (e.event_type === "session_start") {
6089
+ agentRuns++;
6090
+ const exec = e.executor || "unknown";
6091
+ agents.set(exec, (agents.get(exec) || 0) + 1);
6092
+ if (day) {
6093
+ (dailyAgents[day] = dailyAgents[day] || {})[exec] =
6094
+ (dailyAgents[day][exec] || 0) + 1;
6095
+ }
6096
+ } else if (e.event_type === "skill_invoke") {
6097
+ skillInvocations++;
6098
+ const skill = e.data?.skill_name || e.skill_name || "unknown";
6099
+ skills.set(skill, (skills.get(skill) || 0) + 1);
6100
+ if (day) {
6101
+ (dailySkills[day] = dailySkills[day] || {})[skill] =
6102
+ (dailySkills[day][skill] || 0) + 1;
6103
+ }
6104
+ } else if (e.event_type === "tool_call") {
6105
+ mcpToolCalls++;
6106
+ const tool = e.data?.tool_name || e.tool_name || "unknown";
6107
+ mcpTools.set(tool, (mcpTools.get(tool) || 0) + 1);
6108
+ if (day) {
6109
+ (dailyMcp[day] = dailyMcp[day] || {})[tool] =
6110
+ (dailyMcp[day][tool] || 0) + 1;
6111
+ }
6112
+ }
6113
+ }
6114
+
6115
+ const sortedDates = [...allDates].sort();
6116
+ const dayCount = sortedDates.length || 1;
6117
+ const total = agentRuns + skillInvocations + mcpToolCalls;
6118
+ const avgPerDay = Math.round(total / dayCount);
6119
+
6120
+ const topAgents = [...agents.entries()]
6121
+ .sort((a, b) => b[1] - a[1])
6122
+ .slice(0, 8)
6123
+ .map(([name, count]) => ({ name, count }));
6124
+ const topSkills = [...skills.entries()]
6125
+ .sort((a, b) => b[1] - a[1])
6126
+ .slice(0, 8)
6127
+ .map(([name, count]) => ({ name, count }));
6128
+ const topMcpTools = [...mcpTools.entries()]
6129
+ .sort((a, b) => b[1] - a[1])
6130
+ .slice(0, 8)
6131
+ .map(([name, count]) => ({ name, count }));
6132
+
6133
+ // Build trend series for top-6 items per category
6134
+ const topAgentNames = topAgents.slice(0, 6).map((a) => a.name);
6135
+ const topSkillNames = topSkills.slice(0, 6).map((s) => s.name);
6136
+ const topMcpNames = topMcpTools.slice(0, 6).map((t) => t.name);
6137
+
6138
+ const trend = { dates: sortedDates, agents: {}, skills: {}, mcpTools: {} };
6139
+ for (const name of topAgentNames) {
6140
+ trend.agents[name] = sortedDates.map((d) => dailyAgents[d]?.[name] || 0);
6141
+ }
6142
+ for (const name of topSkillNames) {
6143
+ trend.skills[name] = sortedDates.map((d) => dailySkills[d]?.[name] || 0);
6144
+ }
6145
+ for (const name of topMcpNames) {
6146
+ trend.mcpTools[name] = sortedDates.map((d) => dailyMcp[d]?.[name] || 0);
6147
+ }
6148
+
6149
+ return {
6150
+ agentRuns,
6151
+ skillInvocations,
6152
+ mcpToolCalls,
6153
+ avgPerDay,
6154
+ lastActiveAt: newestTs < Infinity && newestTs > 0 ? new Date(newestTs).toISOString() : null,
6155
+ sinceAt: oldestTs < Infinity ? new Date(oldestTs).toISOString() : null,
6156
+ topAgents,
6157
+ topSkills,
6158
+ topMcpTools,
6159
+ trend,
6160
+ };
6161
+ }
6162
+
5637
6163
  function resolveAgentWorkLogDir() {
5638
6164
  const candidates = [
5639
6165
  resolve(repoRoot, ".cache", "agent-work-logs"),
@@ -5942,7 +6468,15 @@ async function handleApi(req, res, url) {
5942
6468
  if (path === "/api/tasks") {
5943
6469
  const status = url.searchParams.get("status") || "";
5944
6470
  const projectId = url.searchParams.get("project") || "";
5945
- const workspaceFilter = (url.searchParams.get("workspace") || "").trim().toLowerCase();
6471
+ const workspaceQueryRaw = String(url.searchParams.get("workspace") || "").trim();
6472
+ let workspaceFilter = workspaceQueryRaw.toLowerCase();
6473
+ if (!workspaceFilter || workspaceFilter === "active") {
6474
+ const activeWorkspace = getActiveManagedWorkspace(resolveUiConfigDir());
6475
+ workspaceFilter = String(activeWorkspace?.id || "").trim().toLowerCase();
6476
+ }
6477
+ if (workspaceFilter === "*" || workspaceFilter === "all") {
6478
+ workspaceFilter = "";
6479
+ }
5946
6480
  const repositoryFilter = (url.searchParams.get("repository") || "").trim().toLowerCase();
5947
6481
  const page = Math.max(0, Number(url.searchParams.get("page") || "0"));
5948
6482
  const pageSize = Math.min(
@@ -6545,14 +7079,34 @@ async function handleApi(req, res, url) {
6545
7079
 
6546
7080
  if (path === "/api/library") {
6547
7081
  try {
6548
- ensureLibraryInitialized();
7082
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false });
7083
+ if (!workspaceContext) {
7084
+ jsonResponse(res, 400, { ok: false, error: "Unknown workspace" });
7085
+ return;
7086
+ }
7087
+ const libraryRoot = workspaceContext.workspaceDir || repoRoot;
7088
+ ensureLibraryInitialized(libraryRoot);
6549
7089
  const typeRaw = (url.searchParams.get("type") || "").trim();
7090
+ const agentTypeRaw = String(url.searchParams.get("agentType") || "").trim().toLowerCase();
6550
7091
  const search = (url.searchParams.get("search") || "").trim();
6551
7092
  const type = typeRaw && typeRaw !== "all" ? typeRaw : "";
6552
- const data = listEntries(repoRoot, {
7093
+ let data = listEntries(libraryRoot, {
6553
7094
  type: type || undefined,
6554
7095
  search: search || undefined,
6555
7096
  });
7097
+ data = data.map((entry) => {
7098
+ if (entry?.type !== "agent") return entry;
7099
+ const profile = getEntryContent(libraryRoot, entry);
7100
+ return {
7101
+ ...entry,
7102
+ agentType: resolveAgentProfileType(entry, profile),
7103
+ };
7104
+ });
7105
+ if (type === "agent" && (agentTypeRaw === "voice" || agentTypeRaw === "task" || agentTypeRaw === "chat")) {
7106
+ data = data.filter((entry) => {
7107
+ return String(entry?.agentType || "").trim().toLowerCase() === agentTypeRaw;
7108
+ });
7109
+ }
6556
7110
  jsonResponse(res, 200, { ok: true, data });
6557
7111
  } catch (err) {
6558
7112
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -6562,19 +7116,25 @@ async function handleApi(req, res, url) {
6562
7116
 
6563
7117
  if (path === "/api/library/entry") {
6564
7118
  try {
6565
- ensureLibraryInitialized();
7119
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false });
7120
+ if (!workspaceContext) {
7121
+ jsonResponse(res, 400, { ok: false, error: "Unknown workspace" });
7122
+ return;
7123
+ }
7124
+ const libraryRoot = workspaceContext.workspaceDir || repoRoot;
7125
+ ensureLibraryInitialized(libraryRoot);
6566
7126
  if (req.method === "GET") {
6567
7127
  const id = (url.searchParams.get("id") || "").trim();
6568
7128
  if (!id) {
6569
7129
  jsonResponse(res, 400, { ok: false, error: "id required" });
6570
7130
  return;
6571
7131
  }
6572
- const entry = getEntry(repoRoot, id);
7132
+ const entry = getEntry(libraryRoot, id);
6573
7133
  if (!entry) {
6574
7134
  jsonResponse(res, 404, { ok: false, error: "not found" });
6575
7135
  return;
6576
7136
  }
6577
- const content = getEntryContent(repoRoot, entry);
7137
+ const content = getEntryContent(libraryRoot, entry);
6578
7138
  jsonResponse(res, 200, { ok: true, data: { ...entry, content } });
6579
7139
  return;
6580
7140
  }
@@ -6582,7 +7142,7 @@ async function handleApi(req, res, url) {
6582
7142
  if (req.method === "POST") {
6583
7143
  const body = await readJsonBody(req);
6584
7144
  const { content, ...entryData } = body || {};
6585
- const entry = upsertEntry(repoRoot, entryData, content);
7145
+ const entry = upsertEntry(libraryRoot, entryData, content);
6586
7146
  jsonResponse(res, 200, { ok: true, data: entry });
6587
7147
  return;
6588
7148
  }
@@ -6594,7 +7154,7 @@ async function handleApi(req, res, url) {
6594
7154
  jsonResponse(res, 400, { ok: false, error: "id required" });
6595
7155
  return;
6596
7156
  }
6597
- const deleted = deleteEntry(repoRoot, id, { deleteFile: Boolean(body?.deleteFile) });
7157
+ const deleted = deleteEntry(libraryRoot, id, { deleteFile: Boolean(body?.deleteFile) });
6598
7158
  if (!deleted) {
6599
7159
  jsonResponse(res, 404, { ok: false, error: "not found" });
6600
7160
  return;
@@ -6612,8 +7172,14 @@ async function handleApi(req, res, url) {
6612
7172
 
6613
7173
  if (path === "/api/library/scopes") {
6614
7174
  try {
6615
- ensureLibraryInitialized();
6616
- const result = detectScopes(repoRoot);
7175
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false });
7176
+ if (!workspaceContext) {
7177
+ jsonResponse(res, 400, { ok: false, error: "Unknown workspace" });
7178
+ return;
7179
+ }
7180
+ const libraryRoot = workspaceContext.workspaceDir || repoRoot;
7181
+ ensureLibraryInitialized(libraryRoot);
7182
+ const result = detectScopes(libraryRoot);
6617
7183
  jsonResponse(res, 200, { ok: true, data: result?.scopes || [] });
6618
7184
  } catch (err) {
6619
7185
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -6623,7 +7189,13 @@ async function handleApi(req, res, url) {
6623
7189
 
6624
7190
  if (path === "/api/library/init" && req.method === "POST") {
6625
7191
  try {
6626
- const result = initLibrary(repoRoot);
7192
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false });
7193
+ if (!workspaceContext) {
7194
+ jsonResponse(res, 400, { ok: false, error: "Unknown workspace" });
7195
+ return;
7196
+ }
7197
+ const libraryRoot = workspaceContext.workspaceDir || repoRoot;
7198
+ const result = initLibrary(libraryRoot);
6627
7199
  const entriesCount = result?.manifest?.entries?.length ?? 0;
6628
7200
  const scaffoldedCount = result?.scaffolded?.written?.length ?? 0;
6629
7201
  jsonResponse(res, 200, {
@@ -6638,7 +7210,13 @@ async function handleApi(req, res, url) {
6638
7210
 
6639
7211
  if (path === "/api/library/rebuild" && req.method === "POST") {
6640
7212
  try {
6641
- const result = rebuildManifest(repoRoot);
7213
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false });
7214
+ if (!workspaceContext) {
7215
+ jsonResponse(res, 400, { ok: false, error: "Unknown workspace" });
7216
+ return;
7217
+ }
7218
+ const libraryRoot = workspaceContext.workspaceDir || repoRoot;
7219
+ const result = rebuildManifest(libraryRoot);
6642
7220
  jsonResponse(res, 200, {
6643
7221
  ok: true,
6644
7222
  data: {
@@ -6655,9 +7233,15 @@ async function handleApi(req, res, url) {
6655
7233
 
6656
7234
  if (path === "/api/library/match-profile") {
6657
7235
  try {
6658
- ensureLibraryInitialized();
7236
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false });
7237
+ if (!workspaceContext) {
7238
+ jsonResponse(res, 400, { ok: false, error: "Unknown workspace" });
7239
+ return;
7240
+ }
7241
+ const libraryRoot = workspaceContext.workspaceDir || repoRoot;
7242
+ ensureLibraryInitialized(libraryRoot);
6659
7243
  const title = (url.searchParams.get("title") || "").trim();
6660
- const match = matchAgentProfile(repoRoot, title);
7244
+ const match = matchAgentProfile(libraryRoot, title);
6661
7245
  jsonResponse(res, 200, { ok: true, data: match || null });
6662
7246
  } catch (err) {
6663
7247
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -6757,7 +7341,14 @@ async function handleApi(req, res, url) {
6757
7341
  if (path === "/api/agent-tools/available") {
6758
7342
  try {
6759
7343
  const available = await listAvailableTools(repoRoot);
6760
- jsonResponse(res, 200, { ok: true, data: available });
7344
+ const bosunTools = await listBosunRuntimeTools({});
7345
+ jsonResponse(res, 200, {
7346
+ ok: true,
7347
+ data: {
7348
+ ...available,
7349
+ bosunTools,
7350
+ },
7351
+ });
6761
7352
  } catch (err) {
6762
7353
  jsonResponse(res, 500, { ok: false, error: err.message });
6763
7354
  }
@@ -6775,7 +7366,29 @@ async function handleApi(req, res, url) {
6775
7366
  return;
6776
7367
  }
6777
7368
  const effective = getEffectiveTools(repoRoot, agentId);
6778
- jsonResponse(res, 200, { ok: true, data: effective });
7369
+ const bosunTools = await listBosunRuntimeTools({});
7370
+ const raw = getAgentToolConfig(repoRoot, agentId);
7371
+ const enabledSet = Array.isArray(raw?.enabledTools) && raw.enabledTools.length > 0
7372
+ ? new Set(raw.enabledTools.map((id) => String(id || "").trim()).filter(Boolean))
7373
+ : null;
7374
+ const bosunToolIdSet = new Set(
7375
+ bosunTools.map((tool) => String(tool?.id || "").trim()).filter(Boolean),
7376
+ );
7377
+ const hasBosunAllowlist = Boolean(
7378
+ enabledSet && [...enabledSet].some((id) => bosunToolIdSet.has(id)),
7379
+ );
7380
+ const effectiveBosunTools = bosunTools.map((tool) => ({
7381
+ ...tool,
7382
+ enabled: hasBosunAllowlist ? enabledSet.has(tool.id) : true,
7383
+ }));
7384
+ jsonResponse(res, 200, {
7385
+ ok: true,
7386
+ data: {
7387
+ ...effective,
7388
+ bosunTools: effectiveBosunTools,
7389
+ enabledTools: raw?.enabledTools ?? null,
7390
+ },
7391
+ });
6779
7392
  return;
6780
7393
  }
6781
7394
 
@@ -6892,7 +7505,7 @@ async function handleApi(req, res, url) {
6892
7505
  ok: true,
6893
7506
  activeId: String(active?.id || wsId),
6894
7507
  });
6895
- broadcastUiEvent(["workspaces", "tasks", "overview"], "invalidate", {
7508
+ broadcastUiEvent(["workspaces", "tasks", "overview", "sessions", "workflows", "library"], "invalidate", {
6896
7509
  reason: "workspace-switched",
6897
7510
  workspaceId: wsId,
6898
7511
  });
@@ -7319,6 +7932,17 @@ async function handleApi(req, res, url) {
7319
7932
  return;
7320
7933
  }
7321
7934
 
7935
+ if (path === "/api/analytics/usage") {
7936
+ try {
7937
+ const days = Number(url.searchParams.get("days") || "30");
7938
+ const data = await buildUsageAnalytics(days || 0);
7939
+ jsonResponse(res, 200, { ok: true, data });
7940
+ } catch (err) {
7941
+ jsonResponse(res, 500, { ok: false, error: err.message });
7942
+ }
7943
+ return;
7944
+ }
7945
+
7322
7946
  if (path === "/api/agent-logs/context") {
7323
7947
  try {
7324
7948
  const query = url.searchParams.get("query") || "";
@@ -7886,14 +8510,14 @@ async function handleApi(req, res, url) {
7886
8510
  * Workflow API endpoints
7887
8511
  * ═══════════════════════════════════════════════════════════ */
7888
8512
 
7889
- // Use module-scope getWorkflowEngineModule() for cross-request caching.
7890
- const getWorkflowEngine = getWorkflowEngineModule;
7891
-
7892
8513
  if (path === "/api/workflows") {
7893
8514
  try {
7894
- const wfMod = await getWorkflowEngine();
7895
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7896
- const engine = wfMod.getWorkflowEngine();
8515
+ const wfCtx = await getWorkflowRequestContext(url);
8516
+ if (!wfCtx.ok) {
8517
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
8518
+ return;
8519
+ }
8520
+ const engine = wfCtx.engine;
7897
8521
  const all = engine.list();
7898
8522
  jsonResponse(res, 200, { ok: true, workflows: all.map(w => ({
7899
8523
  id: w.id, name: w.name, description: w.description, category: w.category,
@@ -7910,9 +8534,12 @@ async function handleApi(req, res, url) {
7910
8534
  if (path === "/api/workflows/save") {
7911
8535
  try {
7912
8536
  const body = await readJsonBody(req);
7913
- const wfMod = await getWorkflowEngine();
7914
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7915
- const engine = wfMod.getWorkflowEngine();
8537
+ const wfCtx = await getWorkflowRequestContext(url);
8538
+ if (!wfCtx.ok) {
8539
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
8540
+ return;
8541
+ }
8542
+ const engine = wfCtx.engine;
7916
8543
  if (typeof _wfTemplates?.applyWorkflowTemplateState === "function") {
7917
8544
  _wfTemplates.applyWorkflowTemplateState(body);
7918
8545
  }
@@ -7926,8 +8553,11 @@ async function handleApi(req, res, url) {
7926
8553
 
7927
8554
  if (path === "/api/workflows/templates") {
7928
8555
  try {
7929
- const wfMod = await getWorkflowEngine();
7930
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
8556
+ const wfCtx = await getWorkflowRequestContext(url);
8557
+ if (!wfCtx.ok) {
8558
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
8559
+ return;
8560
+ }
7931
8561
  const tplMod = _wfTemplates;
7932
8562
  const list = tplMod.listTemplates();
7933
8563
  jsonResponse(res, 200, { ok: true, templates: list });
@@ -7940,10 +8570,13 @@ async function handleApi(req, res, url) {
7940
8570
  if (path === "/api/workflows/install-template") {
7941
8571
  try {
7942
8572
  const body = await readJsonBody(req);
7943
- const wfMod = await getWorkflowEngine();
7944
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
8573
+ const wfCtx = await getWorkflowRequestContext(url);
8574
+ if (!wfCtx.ok) {
8575
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
8576
+ return;
8577
+ }
7945
8578
  const tplMod = _wfTemplates;
7946
- const engine = wfMod.getWorkflowEngine();
8579
+ const engine = wfCtx.engine;
7947
8580
  const wf = await tplMod.installTemplate(body.templateId, engine, body.overrides);
7948
8581
  jsonResponse(res, 200, { ok: true, workflow: wf });
7949
8582
  } catch (err) {
@@ -7954,9 +8587,12 @@ async function handleApi(req, res, url) {
7954
8587
 
7955
8588
  if (path === "/api/workflows/template-updates") {
7956
8589
  try {
7957
- const wfMod = await getWorkflowEngine();
7958
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7959
- const engine = wfMod.getWorkflowEngine();
8590
+ const wfCtx = await getWorkflowRequestContext(url);
8591
+ if (!wfCtx.ok) {
8592
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
8593
+ return;
8594
+ }
8595
+ const engine = wfCtx.engine;
7960
8596
  if (typeof _wfTemplates?.reconcileInstalledTemplates === "function") {
7961
8597
  _wfTemplates.reconcileInstalledTemplates(engine, {
7962
8598
  autoUpdateUnmodified: true,
@@ -7988,9 +8624,12 @@ async function handleApi(req, res, url) {
7988
8624
 
7989
8625
  if (path.startsWith("/api/workflows/") && path.endsWith("/template-update")) {
7990
8626
  try {
7991
- const wfMod = await getWorkflowEngine();
7992
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7993
- const engine = wfMod.getWorkflowEngine();
8627
+ const wfCtx = await getWorkflowRequestContext(url);
8628
+ if (!wfCtx.ok) {
8629
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
8630
+ return;
8631
+ }
8632
+ const engine = wfCtx.engine;
7994
8633
  const workflowId = decodeURIComponent(path.split("/")[3] || "");
7995
8634
  if (!workflowId) {
7996
8635
  jsonResponse(res, 400, { ok: false, error: "Missing workflow id" });
@@ -8013,8 +8652,12 @@ async function handleApi(req, res, url) {
8013
8652
 
8014
8653
  if (path === "/api/workflows/node-types") {
8015
8654
  try {
8016
- const wfMod = await getWorkflowEngine();
8017
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
8655
+ const wfCtx = await getWorkflowRequestContext(url);
8656
+ if (!wfCtx.ok) {
8657
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
8658
+ return;
8659
+ }
8660
+ const wfMod = wfCtx.wfMod;
8018
8661
  const types = wfMod.listNodeTypes();
8019
8662
  jsonResponse(res, 200, { ok: true, nodeTypes: types.map(nt => ({
8020
8663
  type: nt.type,
@@ -8030,9 +8673,12 @@ async function handleApi(req, res, url) {
8030
8673
 
8031
8674
  if (path === "/api/workflows/runs") {
8032
8675
  try {
8033
- const wfMod = await getWorkflowEngine();
8034
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
8035
- const engine = wfMod.getWorkflowEngine();
8676
+ const wfCtx = await getWorkflowRequestContext(url);
8677
+ if (!wfCtx.ok) {
8678
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
8679
+ return;
8680
+ }
8681
+ const engine = wfCtx.engine;
8036
8682
  const rawLimit = Number(url.searchParams.get("limit"));
8037
8683
  const limit = Number.isFinite(rawLimit) && rawLimit > 0
8038
8684
  ? Math.min(rawLimit, 500)
@@ -8047,9 +8693,12 @@ async function handleApi(req, res, url) {
8047
8693
 
8048
8694
  if (path.startsWith("/api/workflows/runs/")) {
8049
8695
  try {
8050
- const wfMod = await getWorkflowEngine();
8051
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
8052
- const engine = wfMod.getWorkflowEngine();
8696
+ const wfCtx = await getWorkflowRequestContext(url);
8697
+ if (!wfCtx.ok) {
8698
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
8699
+ return;
8700
+ }
8701
+ const engine = wfCtx.engine;
8053
8702
  const subPath = path.replace("/api/workflows/runs/", "");
8054
8703
  const segments = subPath.split("/").map(decodeURIComponent);
8055
8704
  const runId = (segments[0] || "").trim();
@@ -8131,9 +8780,12 @@ async function handleApi(req, res, url) {
8131
8780
  const action = segments[1] || "";
8132
8781
 
8133
8782
  try {
8134
- const wfMod = await getWorkflowEngine();
8135
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
8136
- const engine = wfMod.getWorkflowEngine();
8783
+ const wfCtx = await getWorkflowRequestContext(url);
8784
+ if (!wfCtx.ok) {
8785
+ jsonResponse(res, wfCtx.status, { ok: false, error: wfCtx.error });
8786
+ return;
8787
+ }
8788
+ const engine = wfCtx.engine;
8137
8789
 
8138
8790
  if (action === "execute" && req.method === "POST") {
8139
8791
  const body = await readJsonBody(req);
@@ -9018,11 +9670,20 @@ async function handleApi(req, res, url) {
9018
9670
  if (path === "/api/sessions" && req.method === "GET") {
9019
9671
  try {
9020
9672
  const tracker = getSessionTracker();
9673
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: true });
9674
+ if (!workspaceContext) {
9675
+ jsonResponse(res, 400, { ok: false, error: "Unknown workspace" });
9676
+ return;
9677
+ }
9021
9678
  let sessions = tracker.listAllSessions();
9022
9679
  const typeFilter = url.searchParams.get("type");
9023
9680
  const statusFilter = url.searchParams.get("status");
9024
9681
  if (typeFilter) sessions = sessions.filter((s) => s.type === typeFilter);
9025
9682
  if (statusFilter) sessions = sessions.filter((s) => s.status === statusFilter);
9683
+ sessions = sessions.filter((session) => {
9684
+ const detailed = tracker.getSessionById(session.id) || session;
9685
+ return sessionMatchesWorkspaceContext(detailed, workspaceContext);
9686
+ });
9026
9687
  jsonResponse(res, 200, { ok: true, sessions });
9027
9688
  } catch (err) {
9028
9689
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -9068,10 +9729,24 @@ async function handleApi(req, res, url) {
9068
9729
  if (sessionMatch) {
9069
9730
  const sessionId = decodeURIComponent(sessionMatch[1]);
9070
9731
  const action = sessionMatch[2] || null;
9732
+ const workspaceContext = resolveWorkspaceContextFromRequest(url, { allowAll: false });
9733
+ if (!workspaceContext) {
9734
+ jsonResponse(res, 400, { ok: false, error: "Unknown workspace" });
9735
+ return;
9736
+ }
9737
+ const tracker = getSessionTracker();
9738
+ const getScopedSession = () => {
9739
+ const session = tracker.getSessionById(sessionId);
9740
+ if (!session) return null;
9741
+ return sessionMatchesWorkspaceContext(session, workspaceContext) ? session : null;
9742
+ };
9071
9743
 
9072
9744
  if (!action && req.method === "GET") {
9073
9745
  try {
9074
- const tracker = getSessionTracker();
9746
+ if (!getScopedSession()) {
9747
+ jsonResponse(res, 404, { ok: false, error: "Session not found" });
9748
+ return;
9749
+ }
9075
9750
  const session = tracker.getSessionMessages(sessionId);
9076
9751
  if (!session) {
9077
9752
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
@@ -9112,8 +9787,7 @@ async function handleApi(req, res, url) {
9112
9787
 
9113
9788
  if (action === "attachments" && req.method === "POST") {
9114
9789
  try {
9115
- const tracker = getSessionTracker();
9116
- const session = tracker.getSessionById(sessionId);
9790
+ const session = getScopedSession();
9117
9791
  if (!session) {
9118
9792
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9119
9793
  return;
@@ -9165,8 +9839,7 @@ async function handleApi(req, res, url) {
9165
9839
 
9166
9840
  if (action === "stop" && req.method === "POST") {
9167
9841
  try {
9168
- const tracker = getSessionTracker();
9169
- const session = tracker.getSessionById(sessionId);
9842
+ const session = getScopedSession();
9170
9843
  if (!session) {
9171
9844
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9172
9845
  return;
@@ -9201,8 +9874,7 @@ async function handleApi(req, res, url) {
9201
9874
 
9202
9875
  if (action === "message" && req.method === "POST") {
9203
9876
  try {
9204
- const tracker = getSessionTracker();
9205
- const session = tracker.getSessionById(sessionId);
9877
+ const session = getScopedSession();
9206
9878
  if (!session) {
9207
9879
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9208
9880
  return;
@@ -9356,8 +10028,7 @@ async function handleApi(req, res, url) {
9356
10028
 
9357
10029
  if (action === "message/edit" && req.method === "POST") {
9358
10030
  try {
9359
- const tracker = getSessionTracker();
9360
- const session = tracker.getSessionById(sessionId);
10031
+ const session = getScopedSession();
9361
10032
  if (!session) {
9362
10033
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9363
10034
  return;
@@ -9394,8 +10065,7 @@ async function handleApi(req, res, url) {
9394
10065
 
9395
10066
  if (action === "archive" && req.method === "POST") {
9396
10067
  try {
9397
- const tracker = getSessionTracker();
9398
- const session = tracker.getSessionById(sessionId);
10068
+ const session = getScopedSession();
9399
10069
  if (!session) {
9400
10070
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9401
10071
  return;
@@ -9414,8 +10084,7 @@ async function handleApi(req, res, url) {
9414
10084
 
9415
10085
  if (action === "resume" && req.method === "POST") {
9416
10086
  try {
9417
- const tracker = getSessionTracker();
9418
- const session = tracker.getSessionById(sessionId);
10087
+ const session = getScopedSession();
9419
10088
  if (!session) {
9420
10089
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9421
10090
  return;
@@ -9431,8 +10100,7 @@ async function handleApi(req, res, url) {
9431
10100
 
9432
10101
  if (action === "delete" && req.method === "POST") {
9433
10102
  try {
9434
- const tracker = getSessionTracker();
9435
- const session = tracker.getSessionById(sessionId);
10103
+ const session = getScopedSession();
9436
10104
  if (!session) {
9437
10105
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9438
10106
  return;
@@ -9451,8 +10119,7 @@ async function handleApi(req, res, url) {
9451
10119
 
9452
10120
  if (action === "rename" && req.method === "POST") {
9453
10121
  try {
9454
- const tracker = getSessionTracker();
9455
- const session = tracker.getSessionById(sessionId);
10122
+ const session = getScopedSession();
9456
10123
  if (!session) {
9457
10124
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9458
10125
  return;
@@ -9474,8 +10141,7 @@ async function handleApi(req, res, url) {
9474
10141
 
9475
10142
  if (action === "diff" && req.method === "GET") {
9476
10143
  try {
9477
- const tracker = getSessionTracker();
9478
- const session = tracker.getSessionById(sessionId);
10144
+ const session = getScopedSession();
9479
10145
  if (!session) {
9480
10146
  jsonResponse(res, 200, {
9481
10147
  ok: true,
@@ -9668,9 +10334,10 @@ async function handleApi(req, res, url) {
9668
10334
  // Respect it as final and issue the probe directly.
9669
10335
  testUrl = base;
9670
10336
  } else {
9671
- testUrl = dep
9672
- ? `${base}/openai/deployments/${encodeURIComponent(dep)}?api-version=2024-10-21`
9673
- : `${base}/openai/models?api-version=2024-10-21`;
10337
+ // Always use /openai/models — works on both classic Azure OpenAI and
10338
+ // Azure AI Foundry (Global Standard) where /openai/deployments/{name}
10339
+ // returns 404.
10340
+ testUrl = `${base}/openai/models?api-version=2024-10-21`;
9674
10341
  }
9675
10342
  if (apiKey) headers["api-key"] = apiKey;
9676
10343
  else {
@@ -9728,8 +10395,37 @@ async function handleApi(req, res, url) {
9728
10395
  clearTimeout(timer);
9729
10396
  const latencyMs = Date.now() - start;
9730
10397
  if (resp.ok || resp.status === 200) {
9731
- // Single-deployment GET: a 200 means both key and deployment are valid.
9732
- jsonResponse(res, 200, { ok: true, latencyMs });
10398
+ // Credentials verified via /openai/models. If a deployment name was
10399
+ // provided, do a secondary check to confirm the deployment exists.
10400
+ if (provider === "azure" && deployment) {
10401
+ const dep = String(deployment).trim();
10402
+ try {
10403
+ let base2 = String(azureEndpoint || "").replace(/\/+$/, "");
10404
+ try { const u2 = new URL(base2); base2 = `${u2.protocol}//${u2.host}`; } catch { /* keep */ }
10405
+ const depUrl = `${base2}/openai/deployments/${encodeURIComponent(dep)}/chat/completions?api-version=2024-10-21`;
10406
+ const depCtrl = new AbortController();
10407
+ const depTimer = setTimeout(() => depCtrl.abort(), 8_000);
10408
+ const depResp = await fetch(depUrl, {
10409
+ method: "POST",
10410
+ headers: { ...headers, "Content-Type": "application/json" },
10411
+ body: JSON.stringify({ messages: [{ role: "user", content: "test" }], max_tokens: 1 }),
10412
+ signal: depCtrl.signal,
10413
+ });
10414
+ clearTimeout(depTimer);
10415
+ // 200 = chat model works, 400 = realtime model (expected), both confirm deployment exists
10416
+ if (depResp.ok || depResp.status === 400) {
10417
+ jsonResponse(res, 200, { ok: true, latencyMs, deployment: dep });
10418
+ } else if (depResp.status === 404) {
10419
+ jsonResponse(res, 200, { ok: false, error: `Credentials valid but deployment "${dep}" not found — check the deployment name in Azure AI Foundry`, latencyMs });
10420
+ } else {
10421
+ jsonResponse(res, 200, { ok: true, latencyMs, warning: `Credentials valid. Could not verify deployment "${dep}" (HTTP ${depResp.status})` });
10422
+ }
10423
+ } catch {
10424
+ jsonResponse(res, 200, { ok: true, latencyMs, warning: `Credentials valid. Could not verify deployment "${dep}" (timeout)` });
10425
+ }
10426
+ } else {
10427
+ jsonResponse(res, 200, { ok: true, latencyMs });
10428
+ }
9733
10429
  } else {
9734
10430
  const text = await resp.text().catch(() => "");
9735
10431
  let errMsg = `HTTP ${resp.status}`;
@@ -9738,9 +10434,13 @@ async function handleApi(req, res, url) {
9738
10434
  const missing = String(errMsg || "").match(/Missing scopes?:\s*([A-Za-z0-9._:\s-]+)/i)?.[1]?.trim() || "required scopes";
9739
10435
  errMsg = `OpenAI Connected Account token is missing scopes (${missing}). Sign out and reconnect OpenAI in Connected Accounts. Also verify role access: org Owner/Reader, project Owner/Member, and workspace RBAC API/dashboard permissions.`;
9740
10436
  }
9741
- // Friendly message when the deployment name itself is not found (key is fine)
9742
- if (resp.status === 404 && deployment) {
9743
- errMsg = `Deployment "${deployment}" not found check deployment name in Azure AI Foundry`;
10437
+ // Azure-specific: provide helpful messages for common errors
10438
+ if (provider === "azure") {
10439
+ if (resp.status === 401 || resp.status === 403) {
10440
+ errMsg = `Authentication failed (HTTP ${resp.status}) — check API key and endpoint URL`;
10441
+ } else if (resp.status === 404) {
10442
+ errMsg = `Endpoint not found (HTTP 404) — check the Azure endpoint URL. Use https://<resource>.openai.azure.com`;
10443
+ }
9744
10444
  }
9745
10445
  jsonResponse(res, 200, { ok: false, error: errMsg, latencyMs });
9746
10446
  }
@@ -9967,10 +10667,33 @@ async function handleApi(req, res, url) {
9967
10667
  return;
9968
10668
  }
9969
10669
 
10670
+ // GET /api/voice/agents
10671
+ if (path === "/api/voice/agents" && req.method === "GET") {
10672
+ try {
10673
+ const callContext = {
10674
+ sessionId: String(url.searchParams.get("sessionId") || "").trim() || undefined,
10675
+ };
10676
+ const libraryRoot = resolveVoiceLibraryRoot(callContext);
10677
+ const { agents, selected } = resolveActiveVoiceAgent(
10678
+ libraryRoot,
10679
+ String(url.searchParams.get("voiceAgentId") || "").trim(),
10680
+ );
10681
+ jsonResponse(res, 200, {
10682
+ ok: true,
10683
+ agents,
10684
+ defaultAgentId: selected?.id || null,
10685
+ });
10686
+ } catch (err) {
10687
+ jsonResponse(res, 500, { ok: false, error: err.message });
10688
+ }
10689
+ return;
10690
+ }
10691
+
9970
10692
  // POST /api/voice/token
9971
10693
  if (path === "/api/voice/token" && req.method === "POST") {
9972
10694
  try {
9973
10695
  const body = await readJsonBody(req).catch(() => ({}));
10696
+ const requestedVoiceAgentId = String(body?.voiceAgentId || "").trim();
9974
10697
  const callContext = {
9975
10698
  sessionId: String(body?.sessionId || "").trim() || undefined,
9976
10699
  executor: String(body?.executor || "").trim() || undefined,
@@ -9978,31 +10701,56 @@ async function handleApi(req, res, url) {
9978
10701
  model: String(body?.model || "").trim() || undefined,
9979
10702
  authSource: String(authResult?.source || "").trim() || undefined,
9980
10703
  };
10704
+ const libraryRoot = resolveVoiceLibraryRoot(callContext);
10705
+ const { selected: selectedVoiceAgent } = resolveActiveVoiceAgent(
10706
+ libraryRoot,
10707
+ requestedVoiceAgentId,
10708
+ );
10709
+ const activeVoiceAgentId = selectedVoiceAgent?.id || "voice-agent";
9981
10710
  const { createEphemeralToken, getVoiceToolDefinitions, getVoiceConfig, isPrivilegedVoiceContext } = await import("./voice-relay.mjs");
9982
10711
  const privileged = isPrivilegedVoiceContext(callContext);
9983
10712
  const delegateOnly =
9984
10713
  body?.delegateOnly === true && !privileged;
9985
10714
  let tools = await getVoiceToolDefinitions({ delegateOnly, context: callContext });
9986
10715
 
9987
- // Apply voice-agent tool config if present
9988
- try {
9989
- const { getAgentToolConfig } = await import("./agent-tool-config.mjs");
9990
- const voiceToolCfg = getAgentToolConfig(repoRoot, "voice-agent");
9991
- if (voiceToolCfg) {
9992
- const disabled = voiceToolCfg.disabledBuiltinTools || [];
9993
- if (disabled.length > 0 && Array.isArray(tools)) {
9994
- tools = tools.filter(t => !disabled.includes(String(t?.name || "")));
9995
- }
9996
- }
9997
- } catch { /* agent-tool-config not critical */ }
10716
+ const voiceToolCfg = getAgentToolConfig(libraryRoot, activeVoiceAgentId);
10717
+ tools = applyVoiceAgentToolFilters(tools, voiceToolCfg);
10718
+ const capabilityPrompt = buildVoiceToolCapabilityPrompt(
10719
+ tools,
10720
+ voiceToolCfg,
10721
+ selectedVoiceAgent,
10722
+ );
9998
10723
 
9999
- const tokenData = await createEphemeralToken(tools, callContext);
10724
+ const voiceCallContext = {
10725
+ ...callContext,
10726
+ voiceAgentId: activeVoiceAgentId,
10727
+ voiceAgentName: selectedVoiceAgent?.name || undefined,
10728
+ voiceAgentInstructions: selectedVoiceAgent?.voiceInstructions || undefined,
10729
+ voiceAgentSkills: Array.isArray(selectedVoiceAgent?.skills) ? selectedVoiceAgent.skills : undefined,
10730
+ voiceToolCapabilityPrompt: capabilityPrompt,
10731
+ enabledMcpServers: Array.isArray(voiceToolCfg?.enabledMcpServers)
10732
+ ? voiceToolCfg.enabledMcpServers
10733
+ : undefined,
10734
+ };
10735
+ const tokenData = await createEphemeralToken(tools, voiceCallContext);
10736
+ tokenData.voiceAgentId = activeVoiceAgentId;
10737
+ tokenData.voiceAgentName = selectedVoiceAgent?.name || null;
10738
+ tokenData.voiceAgentSkills = Array.isArray(selectedVoiceAgent?.skills) ? selectedVoiceAgent.skills : [];
10739
+ tokenData.enabledMcpServers = Array.isArray(voiceToolCfg?.enabledMcpServers)
10740
+ ? voiceToolCfg.enabledMcpServers
10741
+ : [];
10742
+ tokenData.tools = Array.isArray(tools) ? tools : [];
10000
10743
 
10001
10744
  // When client requests sdkMode, include extra fields for @openai/agents SDK
10002
10745
  if (body?.sdkMode === true) {
10003
10746
  const voiceCfg = getVoiceConfig();
10004
- tokenData.instructions = voiceCfg.instructions || undefined;
10005
- tokenData.tools = tools;
10747
+ tokenData.instructions = [voiceCfg.instructions || "", capabilityPrompt]
10748
+ .filter(Boolean)
10749
+ .join("\n\n")
10750
+ .trim() || undefined;
10751
+ if (selectedVoiceAgent?.voiceInstructions) {
10752
+ tokenData.instructions = `${tokenData.instructions || ""}\n\n${selectedVoiceAgent.voiceInstructions}`.trim();
10753
+ }
10006
10754
  if (tokenData.provider === "azure") {
10007
10755
  tokenData.azureEndpoint = voiceCfg.azureEndpoint || undefined;
10008
10756
  tokenData.azureDeployment = voiceCfg.azureDeployment || undefined;
@@ -10031,6 +10779,7 @@ async function handleApi(req, res, url) {
10031
10779
  executor: String(body?.executor || "").trim() || undefined,
10032
10780
  mode: String(body?.mode || "").trim() || undefined,
10033
10781
  model: String(body?.model || "").trim() || undefined,
10782
+ voiceAgentId: String(body?.voiceAgentId || "").trim() || undefined,
10034
10783
  };
10035
10784
  const options = {
10036
10785
  voiceId: String(body?.voiceId || "").trim() || undefined,
@@ -10049,6 +10798,7 @@ async function handleApi(req, res, url) {
10049
10798
  if (path === "/api/agents/tools" && req.method === "GET") {
10050
10799
  try {
10051
10800
  const { getVoiceToolDefinitions, getAllowedVoiceTools } = await import("./voice-relay.mjs");
10801
+ const requestedVoiceAgentId = String(url.searchParams.get("voiceAgentId") || "").trim();
10052
10802
  const context = {
10053
10803
  sessionId: String(url.searchParams.get("sessionId") || "").trim() || undefined,
10054
10804
  executor: String(url.searchParams.get("executor") || "").trim() || undefined,
@@ -10059,14 +10809,23 @@ async function handleApi(req, res, url) {
10059
10809
  const allTools = await getVoiceToolDefinitions({ delegateOnly: false, context });
10060
10810
  const allowed = await getAllowedVoiceTools(context);
10061
10811
  const allowedTools = allowed instanceof Set ? allowed : null;
10062
- const tools = allowedTools
10812
+ let tools = allowedTools
10063
10813
  ? (Array.isArray(allTools) ? allTools : []).filter((tool) => allowedTools.has(String(tool?.name || "").trim()))
10064
10814
  : allTools;
10815
+ const libraryRoot = resolveVoiceLibraryRoot(context);
10816
+ const { selected: selectedVoiceAgent } = resolveActiveVoiceAgent(
10817
+ libraryRoot,
10818
+ requestedVoiceAgentId,
10819
+ );
10820
+ const activeVoiceAgentId = selectedVoiceAgent?.id || "voice-agent";
10821
+ const voiceToolCfg = getAgentToolConfig(libraryRoot, activeVoiceAgentId);
10822
+ tools = applyVoiceAgentToolFilters(tools, voiceToolCfg);
10065
10823
  jsonResponse(res, 200, {
10066
10824
  ok: true,
10067
10825
  tools: Array.isArray(tools) ? tools : [],
10068
10826
  allowedTools: allowedTools ? Array.from(allowedTools.values()).sort() : null,
10069
10827
  totalTools: Array.isArray(tools) ? tools.length : 0,
10828
+ voiceAgentId: activeVoiceAgentId,
10070
10829
  });
10071
10830
  } catch (err) {
10072
10831
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -10092,6 +10851,7 @@ async function handleApi(req, res, url) {
10092
10851
  executor,
10093
10852
  mode,
10094
10853
  model,
10854
+ voiceAgentId,
10095
10855
  } = body || {};
10096
10856
  const normalizedToolName = String(toolName || "").trim();
10097
10857
  if (!normalizedToolName) {
@@ -10129,8 +10889,16 @@ async function handleApi(req, res, url) {
10129
10889
  mode: String(mode || "").trim() || undefined,
10130
10890
  model: String(model || "").trim() || undefined,
10131
10891
  authSource: String(authResult?.source || "").trim() || undefined,
10892
+ voiceAgentId: String(voiceAgentId || "").trim() || undefined,
10132
10893
  };
10133
10894
  const normalizedArgs = normalizeVoiceToolArgs(normalizedToolName, args || {});
10895
+ const libraryRoot = resolveVoiceLibraryRoot(context);
10896
+ const { selected: selectedVoiceAgent } = resolveActiveVoiceAgent(
10897
+ libraryRoot,
10898
+ context.voiceAgentId || "",
10899
+ );
10900
+ const activeVoiceAgentId = selectedVoiceAgent?.id || "voice-agent";
10901
+ const voiceToolCfg = getAgentToolConfig(libraryRoot, activeVoiceAgentId);
10134
10902
  let tracker = null;
10135
10903
  let session = null;
10136
10904
  if (context.sessionId) {
@@ -10171,6 +10939,37 @@ async function handleApi(req, res, url) {
10171
10939
  return;
10172
10940
  }
10173
10941
  }
10942
+ const toolEnabledForAgent =
10943
+ applyVoiceAgentToolFilters([{ name: normalizedToolName }], voiceToolCfg).length > 0;
10944
+ if (!toolEnabledForAgent) {
10945
+ const deniedMessage = `Tool "${normalizedToolName}" is not enabled for voice agent "${activeVoiceAgentId}"`;
10946
+ if (isVoiceToolRoute) {
10947
+ jsonResponse(res, 403, { error: deniedMessage });
10948
+ } else {
10949
+ jsonResponse(res, 403, { ok: false, error: deniedMessage });
10950
+ }
10951
+ return;
10952
+ }
10953
+ if (
10954
+ normalizedToolName === "invoke_mcp_tool"
10955
+ && Array.isArray(voiceToolCfg?.enabledMcpServers)
10956
+ && voiceToolCfg.enabledMcpServers.length > 0
10957
+ ) {
10958
+ const requestedServer = String(
10959
+ normalizedArgs?.server
10960
+ || normalizedArgs?.serverId
10961
+ || "",
10962
+ ).trim();
10963
+ if (requestedServer && !voiceToolCfg.enabledMcpServers.includes(requestedServer)) {
10964
+ const deniedMessage = `MCP server "${requestedServer}" is not enabled for voice agent "${activeVoiceAgentId}"`;
10965
+ if (isVoiceToolRoute) {
10966
+ jsonResponse(res, 403, { error: deniedMessage });
10967
+ } else {
10968
+ jsonResponse(res, 403, { ok: false, error: deniedMessage });
10969
+ }
10970
+ return;
10971
+ }
10972
+ }
10174
10973
  const result = await executeVoiceTool(normalizedToolName, normalizedArgs, context);
10175
10974
  if (tracker && context.sessionId) {
10176
10975
  if (result?.error) {
@@ -10905,6 +11704,7 @@ export async function startTelegramUiServer(options = {}) {
10905
11704
  executor,
10906
11705
  mode,
10907
11706
  model,
11707
+ voiceAgentId,
10908
11708
  } = message;
10909
11709
  const normalizedToolName = String(toolName || "").trim();
10910
11710
  if (!normalizedToolName) {
@@ -10926,6 +11726,7 @@ export async function startTelegramUiServer(options = {}) {
10926
11726
  mode: String(mode || "").trim() || undefined,
10927
11727
  model: String(model || "").trim() || undefined,
10928
11728
  authSource: String(socket.__authSource || "").trim() || undefined,
11729
+ voiceAgentId: String(voiceAgentId || "").trim() || undefined,
10929
11730
  };
10930
11731
  const normalizedArgs = relay.normalizeVoiceToolArgs(normalizedToolName, args || {});
10931
11732
  relay.getAllowedVoiceTools(context).then((allowed) => {
@@ -10938,6 +11739,44 @@ export async function startTelegramUiServer(options = {}) {
10938
11739
  });
10939
11740
  return;
10940
11741
  }
11742
+ const libraryRoot = resolveVoiceLibraryRoot(context);
11743
+ const { selected: selectedVoiceAgent } = resolveActiveVoiceAgent(
11744
+ libraryRoot,
11745
+ context.voiceAgentId || "",
11746
+ );
11747
+ const activeVoiceAgentId = selectedVoiceAgent?.id || "voice-agent";
11748
+ const voiceToolCfg = getAgentToolConfig(libraryRoot, activeVoiceAgentId);
11749
+ const toolEnabledForAgent =
11750
+ applyVoiceAgentToolFilters([{ name: normalizedToolName }], voiceToolCfg).length > 0;
11751
+ if (!toolEnabledForAgent) {
11752
+ sendWsMessage(socket, {
11753
+ type: "voice-tool-result",
11754
+ callId,
11755
+ error: `Tool "${normalizedToolName}" is not enabled for voice agent "${activeVoiceAgentId}"`,
11756
+ ts: Date.now(),
11757
+ });
11758
+ return;
11759
+ }
11760
+ if (
11761
+ normalizedToolName === "invoke_mcp_tool"
11762
+ && Array.isArray(voiceToolCfg?.enabledMcpServers)
11763
+ && voiceToolCfg.enabledMcpServers.length > 0
11764
+ ) {
11765
+ const requestedServer = String(
11766
+ normalizedArgs?.server
11767
+ || normalizedArgs?.serverId
11768
+ || "",
11769
+ ).trim();
11770
+ if (requestedServer && !voiceToolCfg.enabledMcpServers.includes(requestedServer)) {
11771
+ sendWsMessage(socket, {
11772
+ type: "voice-tool-result",
11773
+ callId,
11774
+ error: `MCP server "${requestedServer}" is not enabled for voice agent "${activeVoiceAgentId}"`,
11775
+ ts: Date.now(),
11776
+ });
11777
+ return;
11778
+ }
11779
+ }
10941
11780
  // Tool is allowed — execute it
10942
11781
  relay.executeVoiceTool(normalizedToolName, normalizedArgs, context).then((result) => {
10943
11782
  sendWsMessage(socket, {
@@ -10980,8 +11819,27 @@ export async function startTelegramUiServer(options = {}) {
10980
11819
  mode: String(mode || "").trim() || undefined,
10981
11820
  model: String(model || "").trim() || undefined,
10982
11821
  authSource: String(socket.__authSource || "").trim() || undefined,
11822
+ voiceAgentId: String(voiceAgentId || "").trim() || undefined,
10983
11823
  };
10984
11824
  const normalizedArgs = relay.normalizeVoiceToolArgs(normalizedToolName, args || {});
11825
+ const libraryRoot = resolveVoiceLibraryRoot(context);
11826
+ const { selected: selectedVoiceAgent } = resolveActiveVoiceAgent(
11827
+ libraryRoot,
11828
+ context.voiceAgentId || "",
11829
+ );
11830
+ const activeVoiceAgentId = selectedVoiceAgent?.id || "voice-agent";
11831
+ const voiceToolCfg = getAgentToolConfig(libraryRoot, activeVoiceAgentId);
11832
+ const toolEnabledForAgent =
11833
+ applyVoiceAgentToolFilters([{ name: normalizedToolName }], voiceToolCfg).length > 0;
11834
+ if (!toolEnabledForAgent) {
11835
+ sendWsMessage(socket, {
11836
+ type: "voice-tool-result",
11837
+ callId,
11838
+ error: `Tool "${normalizedToolName}" is not enabled for voice agent "${activeVoiceAgentId}"`,
11839
+ ts: Date.now(),
11840
+ });
11841
+ return;
11842
+ }
10985
11843
  const result = await relay.executeVoiceTool(normalizedToolName, normalizedArgs, context);
10986
11844
  sendWsMessage(socket, {
10987
11845
  type: "voice-tool-result",