bosun 0.37.0 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/.env.example +4 -1
  2. package/agent-tool-config.mjs +338 -0
  3. package/bosun-skills.mjs +59 -4
  4. package/bosun.schema.json +1 -1
  5. package/desktop/launch.mjs +18 -0
  6. package/desktop/main.mjs +52 -13
  7. package/fleet-coordinator.mjs +34 -1
  8. package/kanban-adapter.mjs +30 -3
  9. package/library-manager.mjs +66 -0
  10. package/maintenance.mjs +30 -5
  11. package/monitor.mjs +56 -0
  12. package/package.json +4 -1
  13. package/setup-web-server.mjs +73 -12
  14. package/setup.mjs +3 -3
  15. package/ui/app.js +40 -3
  16. package/ui/components/session-list.js +25 -7
  17. package/ui/components/workspace-switcher.js +48 -1
  18. package/ui/demo.html +176 -0
  19. package/ui/modules/mic-track-registry.js +83 -0
  20. package/ui/modules/settings-schema.js +4 -1
  21. package/ui/modules/state.js +25 -0
  22. package/ui/modules/streaming.js +1 -1
  23. package/ui/modules/voice-barge-in.js +27 -0
  24. package/ui/modules/voice-client-sdk.js +268 -42
  25. package/ui/modules/voice-client.js +665 -61
  26. package/ui/modules/voice-overlay.js +829 -47
  27. package/ui/setup.html +151 -9
  28. package/ui/styles.css +258 -0
  29. package/ui/tabs/chat.js +11 -0
  30. package/ui/tabs/library.js +890 -15
  31. package/ui/tabs/settings.js +51 -11
  32. package/ui/tabs/telemetry.js +327 -105
  33. package/ui/tabs/workflows.js +86 -0
  34. package/ui-server.mjs +1201 -107
  35. package/voice-action-dispatcher.mjs +81 -0
  36. package/voice-agents-sdk.mjs +2 -2
  37. package/voice-relay.mjs +131 -14
  38. package/voice-tools.mjs +475 -9
  39. package/workflow-engine.mjs +54 -0
  40. package/workflow-nodes.mjs +177 -28
  41. package/workflow-templates/github.mjs +205 -94
  42. package/workflow-templates/task-batch.mjs +247 -0
  43. package/workflow-templates.mjs +15 -0
package/ui-server.mjs CHANGED
@@ -70,7 +70,25 @@ import {
70
70
  matchAgentProfile,
71
71
  loadManifest,
72
72
  getManifestPath,
73
+ scaffoldAgentProfiles,
73
74
  } from "./library-manager.mjs";
75
+ import {
76
+ listCatalog,
77
+ getCatalogEntry,
78
+ installMcpServer,
79
+ uninstallMcpServer,
80
+ listInstalledMcpServers,
81
+ getInstalledMcpServer,
82
+ } from "./mcp-registry.mjs";
83
+ import {
84
+ loadToolConfig,
85
+ saveToolConfig,
86
+ getAgentToolConfig,
87
+ setAgentToolConfig,
88
+ getEffectiveTools,
89
+ listAvailableTools,
90
+ DEFAULT_BUILTIN_TOOLS,
91
+ } from "./agent-tool-config.mjs";
74
92
  import {
75
93
  loadSharedWorkspaceRegistry,
76
94
  sweepExpiredLeases,
@@ -144,7 +162,7 @@ const repoRoot = resolveRepoRoot();
144
162
  const uiRootPreferred = resolve(__dirname, "ui");
145
163
  const uiRootFallback = resolve(__dirname, "site", "ui");
146
164
  const uiRoot = existsSync(uiRootPreferred) ? uiRootPreferred : uiRootFallback;
147
- let libraryInitAttempted = false;
165
+ const libraryInitAttemptedRoots = new Set();
148
166
  const MAX_VISION_FRAME_BYTES = Math.max(
149
167
  128_000,
150
168
  Number.parseInt(process.env.VISION_FRAME_MAX_BYTES || "", 10) || 2_000_000,
@@ -191,21 +209,238 @@ function parseVisionFrameDataUrl(dataUrl) {
191
209
  return { ok: true, mimeType, base64Data, approxBytes, raw };
192
210
  }
193
211
 
194
- function ensureLibraryInitialized() {
195
- if (libraryInitAttempted) return;
196
- libraryInitAttempted = true;
212
+ function ensureLibraryInitialized(rootDir = repoRoot) {
213
+ const normalizedRoot = normalizeCandidatePath(rootDir) || repoRoot;
214
+ if (libraryInitAttemptedRoots.has(normalizedRoot)) return;
215
+ libraryInitAttemptedRoots.add(normalizedRoot);
197
216
  try {
198
- const manifestPath = getManifestPath(repoRoot);
199
- const manifest = loadManifest(repoRoot);
217
+ const manifestPath = getManifestPath(normalizedRoot);
218
+ const manifest = loadManifest(normalizedRoot);
200
219
  if (!existsSync(manifestPath) || !Array.isArray(manifest?.entries) || manifest.entries.length === 0) {
201
- const result = initLibrary(repoRoot);
220
+ const result = initLibrary(normalizedRoot);
202
221
  const count = result?.manifest?.entries?.length ?? 0;
203
222
  if (count > 0) {
204
- console.log(`[ui] Library initialized (${count} entries).`);
223
+ console.log(`[ui] Library initialized (${count} entries) at ${normalizedRoot}.`);
205
224
  }
206
225
  }
226
+ const scaffoldResult = scaffoldAgentProfiles(normalizedRoot);
227
+ if (Array.isArray(scaffoldResult?.written) && scaffoldResult.written.length > 0) {
228
+ rebuildManifest(normalizedRoot);
229
+ }
207
230
  } catch (err) {
208
- 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 [];
209
444
  }
210
445
  }
211
446
 
@@ -214,10 +449,27 @@ let _wfEngine;
214
449
  let _wfNodes;
215
450
  let _wfTemplates;
216
451
  let _wfServicesReady = false;
452
+ let _wfServices = null;
217
453
  let _wfRecommendedInstalled = false;
454
+ const _wfRecommendedInstalledByWorkspace = new Set();
455
+ const _wfEngineByWorkspace = new Map();
218
456
  let _wfInitPromise = null;
219
457
  let _wfInitDone = false;
220
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
+
221
473
  let workflowEventDedupWindowMs = (() => {
222
474
  const parsed = Number.parseInt(process.env.WORKFLOW_EVENT_DEDUP_WINDOW_MS || "15000", 10);
223
475
  if (!Number.isFinite(parsed) || parsed <= 0) return 15_000;
@@ -424,6 +676,7 @@ async function getWorkflowEngineModule() {
424
676
  meeting: meetingService,
425
677
  prompts: promptBundle?.prompts || null,
426
678
  };
679
+ _wfServices = services;
427
680
  _wfEngine.getWorkflowEngine({ services });
428
681
  _wfServicesReady = true;
429
682
 
@@ -516,6 +769,100 @@ async function getWorkflowEngineModule() {
516
769
  return _wfEngine;
517
770
  }
518
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
+
519
866
  function allowWorkflowEvent(dedupKey, windowMs = workflowEventDedupWindowMs) {
520
867
  if (!dedupKey) return true;
521
868
  const now = Date.now();
@@ -559,10 +906,11 @@ async function dispatchWorkflowEvent(eventType, eventData = {}, opts = {}) {
559
906
  return false;
560
907
  }
561
908
 
562
- const wfMod = await getWorkflowEngineModule();
563
- if (!wfMod?.getWorkflowEngine) return false;
564
-
565
- 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;
566
914
  if (!engine?.evaluateTriggers || !engine?.execute) return false;
567
915
 
568
916
  const payload = buildWorkflowEventPayload(eventType, eventData, "ui-server");
@@ -1278,6 +1626,79 @@ function resolveActiveWorkspaceExecutionContext() {
1278
1626
  };
1279
1627
  }
1280
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
+
1281
1702
  function resolveSessionWorkspaceDir(session = null) {
1282
1703
  const metadata =
1283
1704
  session && typeof session.metadata === "object" && session.metadata
@@ -5617,6 +6038,128 @@ function summarizeTelemetry(metrics, days) {
5617
6038
  };
5618
6039
  }
5619
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
+
5620
6163
  function resolveAgentWorkLogDir() {
5621
6164
  const candidates = [
5622
6165
  resolve(repoRoot, ".cache", "agent-work-logs"),
@@ -5925,7 +6468,15 @@ async function handleApi(req, res, url) {
5925
6468
  if (path === "/api/tasks") {
5926
6469
  const status = url.searchParams.get("status") || "";
5927
6470
  const projectId = url.searchParams.get("project") || "";
5928
- 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
+ }
5929
6480
  const repositoryFilter = (url.searchParams.get("repository") || "").trim().toLowerCase();
5930
6481
  const page = Math.max(0, Number(url.searchParams.get("page") || "0"));
5931
6482
  const pageSize = Math.min(
@@ -6528,14 +7079,34 @@ async function handleApi(req, res, url) {
6528
7079
 
6529
7080
  if (path === "/api/library") {
6530
7081
  try {
6531
- 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);
6532
7089
  const typeRaw = (url.searchParams.get("type") || "").trim();
7090
+ const agentTypeRaw = String(url.searchParams.get("agentType") || "").trim().toLowerCase();
6533
7091
  const search = (url.searchParams.get("search") || "").trim();
6534
7092
  const type = typeRaw && typeRaw !== "all" ? typeRaw : "";
6535
- const data = listEntries(repoRoot, {
7093
+ let data = listEntries(libraryRoot, {
6536
7094
  type: type || undefined,
6537
7095
  search: search || undefined,
6538
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
+ }
6539
7110
  jsonResponse(res, 200, { ok: true, data });
6540
7111
  } catch (err) {
6541
7112
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -6545,19 +7116,25 @@ async function handleApi(req, res, url) {
6545
7116
 
6546
7117
  if (path === "/api/library/entry") {
6547
7118
  try {
6548
- 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);
6549
7126
  if (req.method === "GET") {
6550
7127
  const id = (url.searchParams.get("id") || "").trim();
6551
7128
  if (!id) {
6552
7129
  jsonResponse(res, 400, { ok: false, error: "id required" });
6553
7130
  return;
6554
7131
  }
6555
- const entry = getEntry(repoRoot, id);
7132
+ const entry = getEntry(libraryRoot, id);
6556
7133
  if (!entry) {
6557
7134
  jsonResponse(res, 404, { ok: false, error: "not found" });
6558
7135
  return;
6559
7136
  }
6560
- const content = getEntryContent(repoRoot, entry);
7137
+ const content = getEntryContent(libraryRoot, entry);
6561
7138
  jsonResponse(res, 200, { ok: true, data: { ...entry, content } });
6562
7139
  return;
6563
7140
  }
@@ -6565,7 +7142,7 @@ async function handleApi(req, res, url) {
6565
7142
  if (req.method === "POST") {
6566
7143
  const body = await readJsonBody(req);
6567
7144
  const { content, ...entryData } = body || {};
6568
- const entry = upsertEntry(repoRoot, entryData, content);
7145
+ const entry = upsertEntry(libraryRoot, entryData, content);
6569
7146
  jsonResponse(res, 200, { ok: true, data: entry });
6570
7147
  return;
6571
7148
  }
@@ -6577,7 +7154,7 @@ async function handleApi(req, res, url) {
6577
7154
  jsonResponse(res, 400, { ok: false, error: "id required" });
6578
7155
  return;
6579
7156
  }
6580
- const deleted = deleteEntry(repoRoot, id, { deleteFile: Boolean(body?.deleteFile) });
7157
+ const deleted = deleteEntry(libraryRoot, id, { deleteFile: Boolean(body?.deleteFile) });
6581
7158
  if (!deleted) {
6582
7159
  jsonResponse(res, 404, { ok: false, error: "not found" });
6583
7160
  return;
@@ -6595,8 +7172,14 @@ async function handleApi(req, res, url) {
6595
7172
 
6596
7173
  if (path === "/api/library/scopes") {
6597
7174
  try {
6598
- ensureLibraryInitialized();
6599
- 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);
6600
7183
  jsonResponse(res, 200, { ok: true, data: result?.scopes || [] });
6601
7184
  } catch (err) {
6602
7185
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -6606,7 +7189,13 @@ async function handleApi(req, res, url) {
6606
7189
 
6607
7190
  if (path === "/api/library/init" && req.method === "POST") {
6608
7191
  try {
6609
- 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);
6610
7199
  const entriesCount = result?.manifest?.entries?.length ?? 0;
6611
7200
  const scaffoldedCount = result?.scaffolded?.written?.length ?? 0;
6612
7201
  jsonResponse(res, 200, {
@@ -6621,7 +7210,13 @@ async function handleApi(req, res, url) {
6621
7210
 
6622
7211
  if (path === "/api/library/rebuild" && req.method === "POST") {
6623
7212
  try {
6624
- 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);
6625
7220
  jsonResponse(res, 200, {
6626
7221
  ok: true,
6627
7222
  data: {
@@ -6638,9 +7233,15 @@ async function handleApi(req, res, url) {
6638
7233
 
6639
7234
  if (path === "/api/library/match-profile") {
6640
7235
  try {
6641
- 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);
6642
7243
  const title = (url.searchParams.get("title") || "").trim();
6643
- const match = matchAgentProfile(repoRoot, title);
7244
+ const match = matchAgentProfile(libraryRoot, title);
6644
7245
  jsonResponse(res, 200, { ok: true, data: match || null });
6645
7246
  } catch (err) {
6646
7247
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -6648,6 +7249,186 @@ async function handleApi(req, res, url) {
6648
7249
  return;
6649
7250
  }
6650
7251
 
7252
+ // ── MCP Server Management API ─────────────────────────────────────────────
7253
+
7254
+ if (path === "/api/mcp/catalog") {
7255
+ try {
7256
+ const tagsRaw = (url.searchParams.get("tags") || "").trim();
7257
+ const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()) : undefined;
7258
+ const catalog = listCatalog({ tags });
7259
+ jsonResponse(res, 200, { ok: true, data: catalog });
7260
+ } catch (err) {
7261
+ jsonResponse(res, 500, { ok: false, error: err.message });
7262
+ }
7263
+ return;
7264
+ }
7265
+
7266
+ if (path === "/api/mcp/installed") {
7267
+ try {
7268
+ const servers = await listInstalledMcpServers(repoRoot);
7269
+ jsonResponse(res, 200, { ok: true, data: servers });
7270
+ } catch (err) {
7271
+ jsonResponse(res, 500, { ok: false, error: err.message });
7272
+ }
7273
+ return;
7274
+ }
7275
+
7276
+ if (path === "/api/mcp/install" && req.method === "POST") {
7277
+ try {
7278
+ const body = await readJsonBody(req);
7279
+ if (!body?.id && !body?.serverDef) {
7280
+ jsonResponse(res, 400, { ok: false, error: "id (catalog) or serverDef (custom) required" });
7281
+ return;
7282
+ }
7283
+ const result = await installMcpServer(
7284
+ repoRoot,
7285
+ body.serverDef || body.id,
7286
+ { envOverrides: body.envOverrides },
7287
+ );
7288
+ jsonResponse(res, 200, { ok: true, data: result });
7289
+ broadcastUiEvent(["library"], "invalidate", { reason: "mcp-installed", id: result?.id });
7290
+ } catch (err) {
7291
+ jsonResponse(res, 500, { ok: false, error: err.message });
7292
+ }
7293
+ return;
7294
+ }
7295
+
7296
+ if (path === "/api/mcp/uninstall" && req.method === "POST") {
7297
+ try {
7298
+ const body = await readJsonBody(req);
7299
+ if (!body?.id) {
7300
+ jsonResponse(res, 400, { ok: false, error: "id required" });
7301
+ return;
7302
+ }
7303
+ const removed = await uninstallMcpServer(repoRoot, body.id);
7304
+ jsonResponse(res, 200, { ok: true, removed });
7305
+ broadcastUiEvent(["library"], "invalidate", { reason: "mcp-uninstalled", id: body.id });
7306
+ } catch (err) {
7307
+ jsonResponse(res, 500, { ok: false, error: err.message });
7308
+ }
7309
+ return;
7310
+ }
7311
+
7312
+ if (path === "/api/mcp/configure" && req.method === "POST") {
7313
+ try {
7314
+ const body = await readJsonBody(req);
7315
+ if (!body?.id) {
7316
+ jsonResponse(res, 400, { ok: false, error: "id required" });
7317
+ return;
7318
+ }
7319
+ const existing = await getInstalledMcpServer(repoRoot, body.id);
7320
+ if (!existing) {
7321
+ jsonResponse(res, 404, { ok: false, error: "MCP server not installed" });
7322
+ return;
7323
+ }
7324
+ // Re-install with updated env
7325
+ const updated = await installMcpServer(repoRoot, {
7326
+ ...existing.serverConfig,
7327
+ id: body.id,
7328
+ name: existing.name,
7329
+ description: existing.description,
7330
+ tags: existing.tags,
7331
+ }, { envOverrides: body.env });
7332
+ jsonResponse(res, 200, { ok: true, data: updated });
7333
+ } catch (err) {
7334
+ jsonResponse(res, 500, { ok: false, error: err.message });
7335
+ }
7336
+ return;
7337
+ }
7338
+
7339
+ // ── Agent Tool Configuration API ──────────────────────────────────────────
7340
+
7341
+ if (path === "/api/agent-tools/available") {
7342
+ try {
7343
+ const available = await listAvailableTools(repoRoot);
7344
+ const bosunTools = await listBosunRuntimeTools({});
7345
+ jsonResponse(res, 200, {
7346
+ ok: true,
7347
+ data: {
7348
+ ...available,
7349
+ bosunTools,
7350
+ },
7351
+ });
7352
+ } catch (err) {
7353
+ jsonResponse(res, 500, { ok: false, error: err.message });
7354
+ }
7355
+ return;
7356
+ }
7357
+
7358
+ if (path === "/api/agent-tools/config") {
7359
+ try {
7360
+ if (req.method === "GET") {
7361
+ const agentId = (url.searchParams.get("agentId") || "").trim();
7362
+ if (!agentId) {
7363
+ // Return full config
7364
+ const config = loadToolConfig(repoRoot);
7365
+ jsonResponse(res, 200, { ok: true, data: config });
7366
+ return;
7367
+ }
7368
+ const effective = getEffectiveTools(repoRoot, agentId);
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
+ });
7392
+ return;
7393
+ }
7394
+
7395
+ if (req.method === "POST") {
7396
+ const body = await readJsonBody(req);
7397
+ if (!body?.agentId) {
7398
+ jsonResponse(res, 400, { ok: false, error: "agentId required" });
7399
+ return;
7400
+ }
7401
+ const result = setAgentToolConfig(repoRoot, body.agentId, {
7402
+ enabledTools: body.enabledTools,
7403
+ enabledMcpServers: body.enabledMcpServers,
7404
+ disabledBuiltinTools: body.disabledBuiltinTools,
7405
+ });
7406
+ jsonResponse(res, 200, { ok: true, ...result });
7407
+ broadcastUiEvent(["library"], "invalidate", { reason: "agent-tools-updated", agentId: body.agentId });
7408
+ return;
7409
+ }
7410
+
7411
+ jsonResponse(res, 405, { ok: false, error: "method not allowed" });
7412
+ } catch (err) {
7413
+ jsonResponse(res, 500, { ok: false, error: err.message });
7414
+ }
7415
+ return;
7416
+ }
7417
+
7418
+ if (path === "/api/agent-tools/defaults") {
7419
+ try {
7420
+ jsonResponse(res, 200, {
7421
+ ok: true,
7422
+ data: {
7423
+ builtinTools: [...DEFAULT_BUILTIN_TOOLS],
7424
+ },
7425
+ });
7426
+ } catch (err) {
7427
+ jsonResponse(res, 500, { ok: false, error: err.message });
7428
+ }
7429
+ return;
7430
+ }
7431
+
6651
7432
  if (path === "/api/logs") {
6652
7433
  const lines = Math.min(
6653
7434
  1000,
@@ -6724,7 +7505,7 @@ async function handleApi(req, res, url) {
6724
7505
  ok: true,
6725
7506
  activeId: String(active?.id || wsId),
6726
7507
  });
6727
- broadcastUiEvent(["workspaces", "tasks", "overview"], "invalidate", {
7508
+ broadcastUiEvent(["workspaces", "tasks", "overview", "sessions", "workflows", "library"], "invalidate", {
6728
7509
  reason: "workspace-switched",
6729
7510
  workspaceId: wsId,
6730
7511
  });
@@ -7151,6 +7932,17 @@ async function handleApi(req, res, url) {
7151
7932
  return;
7152
7933
  }
7153
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
+
7154
7946
  if (path === "/api/agent-logs/context") {
7155
7947
  try {
7156
7948
  const query = url.searchParams.get("query") || "";
@@ -7718,14 +8510,14 @@ async function handleApi(req, res, url) {
7718
8510
  * Workflow API endpoints
7719
8511
  * ═══════════════════════════════════════════════════════════ */
7720
8512
 
7721
- // Use module-scope getWorkflowEngineModule() for cross-request caching.
7722
- const getWorkflowEngine = getWorkflowEngineModule;
7723
-
7724
8513
  if (path === "/api/workflows") {
7725
8514
  try {
7726
- const wfMod = await getWorkflowEngine();
7727
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7728
- 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;
7729
8521
  const all = engine.list();
7730
8522
  jsonResponse(res, 200, { ok: true, workflows: all.map(w => ({
7731
8523
  id: w.id, name: w.name, description: w.description, category: w.category,
@@ -7742,9 +8534,12 @@ async function handleApi(req, res, url) {
7742
8534
  if (path === "/api/workflows/save") {
7743
8535
  try {
7744
8536
  const body = await readJsonBody(req);
7745
- const wfMod = await getWorkflowEngine();
7746
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7747
- 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;
7748
8543
  if (typeof _wfTemplates?.applyWorkflowTemplateState === "function") {
7749
8544
  _wfTemplates.applyWorkflowTemplateState(body);
7750
8545
  }
@@ -7758,8 +8553,11 @@ async function handleApi(req, res, url) {
7758
8553
 
7759
8554
  if (path === "/api/workflows/templates") {
7760
8555
  try {
7761
- const wfMod = await getWorkflowEngine();
7762
- 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
+ }
7763
8561
  const tplMod = _wfTemplates;
7764
8562
  const list = tplMod.listTemplates();
7765
8563
  jsonResponse(res, 200, { ok: true, templates: list });
@@ -7772,10 +8570,13 @@ async function handleApi(req, res, url) {
7772
8570
  if (path === "/api/workflows/install-template") {
7773
8571
  try {
7774
8572
  const body = await readJsonBody(req);
7775
- const wfMod = await getWorkflowEngine();
7776
- 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
+ }
7777
8578
  const tplMod = _wfTemplates;
7778
- const engine = wfMod.getWorkflowEngine();
8579
+ const engine = wfCtx.engine;
7779
8580
  const wf = await tplMod.installTemplate(body.templateId, engine, body.overrides);
7780
8581
  jsonResponse(res, 200, { ok: true, workflow: wf });
7781
8582
  } catch (err) {
@@ -7786,9 +8587,12 @@ async function handleApi(req, res, url) {
7786
8587
 
7787
8588
  if (path === "/api/workflows/template-updates") {
7788
8589
  try {
7789
- const wfMod = await getWorkflowEngine();
7790
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7791
- 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;
7792
8596
  if (typeof _wfTemplates?.reconcileInstalledTemplates === "function") {
7793
8597
  _wfTemplates.reconcileInstalledTemplates(engine, {
7794
8598
  autoUpdateUnmodified: true,
@@ -7820,9 +8624,12 @@ async function handleApi(req, res, url) {
7820
8624
 
7821
8625
  if (path.startsWith("/api/workflows/") && path.endsWith("/template-update")) {
7822
8626
  try {
7823
- const wfMod = await getWorkflowEngine();
7824
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7825
- 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;
7826
8633
  const workflowId = decodeURIComponent(path.split("/")[3] || "");
7827
8634
  if (!workflowId) {
7828
8635
  jsonResponse(res, 400, { ok: false, error: "Missing workflow id" });
@@ -7845,8 +8652,12 @@ async function handleApi(req, res, url) {
7845
8652
 
7846
8653
  if (path === "/api/workflows/node-types") {
7847
8654
  try {
7848
- const wfMod = await getWorkflowEngine();
7849
- 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;
7850
8661
  const types = wfMod.listNodeTypes();
7851
8662
  jsonResponse(res, 200, { ok: true, nodeTypes: types.map(nt => ({
7852
8663
  type: nt.type,
@@ -7862,9 +8673,12 @@ async function handleApi(req, res, url) {
7862
8673
 
7863
8674
  if (path === "/api/workflows/runs") {
7864
8675
  try {
7865
- const wfMod = await getWorkflowEngine();
7866
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7867
- 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;
7868
8682
  const rawLimit = Number(url.searchParams.get("limit"));
7869
8683
  const limit = Number.isFinite(rawLimit) && rawLimit > 0
7870
8684
  ? Math.min(rawLimit, 500)
@@ -7879,9 +8693,12 @@ async function handleApi(req, res, url) {
7879
8693
 
7880
8694
  if (path.startsWith("/api/workflows/runs/")) {
7881
8695
  try {
7882
- const wfMod = await getWorkflowEngine();
7883
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7884
- 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;
7885
8702
  const subPath = path.replace("/api/workflows/runs/", "");
7886
8703
  const segments = subPath.split("/").map(decodeURIComponent);
7887
8704
  const runId = (segments[0] || "").trim();
@@ -7963,9 +8780,12 @@ async function handleApi(req, res, url) {
7963
8780
  const action = segments[1] || "";
7964
8781
 
7965
8782
  try {
7966
- const wfMod = await getWorkflowEngine();
7967
- if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
7968
- 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;
7969
8789
 
7970
8790
  if (action === "execute" && req.method === "POST") {
7971
8791
  const body = await readJsonBody(req);
@@ -8850,11 +9670,20 @@ async function handleApi(req, res, url) {
8850
9670
  if (path === "/api/sessions" && req.method === "GET") {
8851
9671
  try {
8852
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
+ }
8853
9678
  let sessions = tracker.listAllSessions();
8854
9679
  const typeFilter = url.searchParams.get("type");
8855
9680
  const statusFilter = url.searchParams.get("status");
8856
9681
  if (typeFilter) sessions = sessions.filter((s) => s.type === typeFilter);
8857
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
+ });
8858
9687
  jsonResponse(res, 200, { ok: true, sessions });
8859
9688
  } catch (err) {
8860
9689
  jsonResponse(res, 500, { ok: false, error: err.message });
@@ -8900,10 +9729,24 @@ async function handleApi(req, res, url) {
8900
9729
  if (sessionMatch) {
8901
9730
  const sessionId = decodeURIComponent(sessionMatch[1]);
8902
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
+ };
8903
9743
 
8904
9744
  if (!action && req.method === "GET") {
8905
9745
  try {
8906
- const tracker = getSessionTracker();
9746
+ if (!getScopedSession()) {
9747
+ jsonResponse(res, 404, { ok: false, error: "Session not found" });
9748
+ return;
9749
+ }
8907
9750
  const session = tracker.getSessionMessages(sessionId);
8908
9751
  if (!session) {
8909
9752
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
@@ -8944,8 +9787,7 @@ async function handleApi(req, res, url) {
8944
9787
 
8945
9788
  if (action === "attachments" && req.method === "POST") {
8946
9789
  try {
8947
- const tracker = getSessionTracker();
8948
- const session = tracker.getSessionById(sessionId);
9790
+ const session = getScopedSession();
8949
9791
  if (!session) {
8950
9792
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
8951
9793
  return;
@@ -8997,8 +9839,7 @@ async function handleApi(req, res, url) {
8997
9839
 
8998
9840
  if (action === "stop" && req.method === "POST") {
8999
9841
  try {
9000
- const tracker = getSessionTracker();
9001
- const session = tracker.getSessionById(sessionId);
9842
+ const session = getScopedSession();
9002
9843
  if (!session) {
9003
9844
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9004
9845
  return;
@@ -9033,8 +9874,7 @@ async function handleApi(req, res, url) {
9033
9874
 
9034
9875
  if (action === "message" && req.method === "POST") {
9035
9876
  try {
9036
- const tracker = getSessionTracker();
9037
- const session = tracker.getSessionById(sessionId);
9877
+ const session = getScopedSession();
9038
9878
  if (!session) {
9039
9879
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9040
9880
  return;
@@ -9188,8 +10028,7 @@ async function handleApi(req, res, url) {
9188
10028
 
9189
10029
  if (action === "message/edit" && req.method === "POST") {
9190
10030
  try {
9191
- const tracker = getSessionTracker();
9192
- const session = tracker.getSessionById(sessionId);
10031
+ const session = getScopedSession();
9193
10032
  if (!session) {
9194
10033
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9195
10034
  return;
@@ -9226,8 +10065,7 @@ async function handleApi(req, res, url) {
9226
10065
 
9227
10066
  if (action === "archive" && req.method === "POST") {
9228
10067
  try {
9229
- const tracker = getSessionTracker();
9230
- const session = tracker.getSessionById(sessionId);
10068
+ const session = getScopedSession();
9231
10069
  if (!session) {
9232
10070
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9233
10071
  return;
@@ -9246,8 +10084,7 @@ async function handleApi(req, res, url) {
9246
10084
 
9247
10085
  if (action === "resume" && req.method === "POST") {
9248
10086
  try {
9249
- const tracker = getSessionTracker();
9250
- const session = tracker.getSessionById(sessionId);
10087
+ const session = getScopedSession();
9251
10088
  if (!session) {
9252
10089
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9253
10090
  return;
@@ -9263,8 +10100,7 @@ async function handleApi(req, res, url) {
9263
10100
 
9264
10101
  if (action === "delete" && req.method === "POST") {
9265
10102
  try {
9266
- const tracker = getSessionTracker();
9267
- const session = tracker.getSessionById(sessionId);
10103
+ const session = getScopedSession();
9268
10104
  if (!session) {
9269
10105
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9270
10106
  return;
@@ -9283,8 +10119,7 @@ async function handleApi(req, res, url) {
9283
10119
 
9284
10120
  if (action === "rename" && req.method === "POST") {
9285
10121
  try {
9286
- const tracker = getSessionTracker();
9287
- const session = tracker.getSessionById(sessionId);
10122
+ const session = getScopedSession();
9288
10123
  if (!session) {
9289
10124
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
9290
10125
  return;
@@ -9306,8 +10141,7 @@ async function handleApi(req, res, url) {
9306
10141
 
9307
10142
  if (action === "diff" && req.method === "GET") {
9308
10143
  try {
9309
- const tracker = getSessionTracker();
9310
- const session = tracker.getSessionById(sessionId);
10144
+ const session = getScopedSession();
9311
10145
  if (!session) {
9312
10146
  jsonResponse(res, 200, {
9313
10147
  ok: true,
@@ -9500,9 +10334,10 @@ async function handleApi(req, res, url) {
9500
10334
  // Respect it as final and issue the probe directly.
9501
10335
  testUrl = base;
9502
10336
  } else {
9503
- testUrl = dep
9504
- ? `${base}/openai/deployments/${encodeURIComponent(dep)}?api-version=2024-10-21`
9505
- : `${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`;
9506
10341
  }
9507
10342
  if (apiKey) headers["api-key"] = apiKey;
9508
10343
  else {
@@ -9560,8 +10395,37 @@ async function handleApi(req, res, url) {
9560
10395
  clearTimeout(timer);
9561
10396
  const latencyMs = Date.now() - start;
9562
10397
  if (resp.ok || resp.status === 200) {
9563
- // Single-deployment GET: a 200 means both key and deployment are valid.
9564
- 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
+ }
9565
10429
  } else {
9566
10430
  const text = await resp.text().catch(() => "");
9567
10431
  let errMsg = `HTTP ${resp.status}`;
@@ -9570,9 +10434,13 @@ async function handleApi(req, res, url) {
9570
10434
  const missing = String(errMsg || "").match(/Missing scopes?:\s*([A-Za-z0-9._:\s-]+)/i)?.[1]?.trim() || "required scopes";
9571
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.`;
9572
10436
  }
9573
- // Friendly message when the deployment name itself is not found (key is fine)
9574
- if (resp.status === 404 && deployment) {
9575
- 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
+ }
9576
10444
  }
9577
10445
  jsonResponse(res, 200, { ok: false, error: errMsg, latencyMs });
9578
10446
  }
@@ -9799,10 +10667,33 @@ async function handleApi(req, res, url) {
9799
10667
  return;
9800
10668
  }
9801
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
+
9802
10692
  // POST /api/voice/token
9803
10693
  if (path === "/api/voice/token" && req.method === "POST") {
9804
10694
  try {
9805
10695
  const body = await readJsonBody(req).catch(() => ({}));
10696
+ const requestedVoiceAgentId = String(body?.voiceAgentId || "").trim();
9806
10697
  const callContext = {
9807
10698
  sessionId: String(body?.sessionId || "").trim() || undefined,
9808
10699
  executor: String(body?.executor || "").trim() || undefined,
@@ -9810,18 +10701,56 @@ async function handleApi(req, res, url) {
9810
10701
  model: String(body?.model || "").trim() || undefined,
9811
10702
  authSource: String(authResult?.source || "").trim() || undefined,
9812
10703
  };
10704
+ const libraryRoot = resolveVoiceLibraryRoot(callContext);
10705
+ const { selected: selectedVoiceAgent } = resolveActiveVoiceAgent(
10706
+ libraryRoot,
10707
+ requestedVoiceAgentId,
10708
+ );
10709
+ const activeVoiceAgentId = selectedVoiceAgent?.id || "voice-agent";
9813
10710
  const { createEphemeralToken, getVoiceToolDefinitions, getVoiceConfig, isPrivilegedVoiceContext } = await import("./voice-relay.mjs");
9814
10711
  const privileged = isPrivilegedVoiceContext(callContext);
9815
10712
  const delegateOnly =
9816
10713
  body?.delegateOnly === true && !privileged;
9817
- const tools = await getVoiceToolDefinitions({ delegateOnly, context: callContext });
9818
- const tokenData = await createEphemeralToken(tools, callContext);
10714
+ let tools = await getVoiceToolDefinitions({ delegateOnly, context: callContext });
10715
+
10716
+ const voiceToolCfg = getAgentToolConfig(libraryRoot, activeVoiceAgentId);
10717
+ tools = applyVoiceAgentToolFilters(tools, voiceToolCfg);
10718
+ const capabilityPrompt = buildVoiceToolCapabilityPrompt(
10719
+ tools,
10720
+ voiceToolCfg,
10721
+ selectedVoiceAgent,
10722
+ );
10723
+
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 : [];
9819
10743
 
9820
10744
  // When client requests sdkMode, include extra fields for @openai/agents SDK
9821
10745
  if (body?.sdkMode === true) {
9822
10746
  const voiceCfg = getVoiceConfig();
9823
- tokenData.instructions = voiceCfg.instructions || undefined;
9824
- 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
+ }
9825
10754
  if (tokenData.provider === "azure") {
9826
10755
  tokenData.azureEndpoint = voiceCfg.azureEndpoint || undefined;
9827
10756
  tokenData.azureDeployment = voiceCfg.azureDeployment || undefined;
@@ -9850,6 +10779,7 @@ async function handleApi(req, res, url) {
9850
10779
  executor: String(body?.executor || "").trim() || undefined,
9851
10780
  mode: String(body?.mode || "").trim() || undefined,
9852
10781
  model: String(body?.model || "").trim() || undefined,
10782
+ voiceAgentId: String(body?.voiceAgentId || "").trim() || undefined,
9853
10783
  };
9854
10784
  const options = {
9855
10785
  voiceId: String(body?.voiceId || "").trim() || undefined,
@@ -9864,9 +10794,55 @@ async function handleApi(req, res, url) {
9864
10794
  return;
9865
10795
  }
9866
10796
 
9867
- // POST /api/voice/tool
9868
- if (path === "/api/voice/tool" && req.method === "POST") {
10797
+ // GET /api/agents/tools
10798
+ if (path === "/api/agents/tools" && req.method === "GET") {
9869
10799
  try {
10800
+ const { getVoiceToolDefinitions, getAllowedVoiceTools } = await import("./voice-relay.mjs");
10801
+ const requestedVoiceAgentId = String(url.searchParams.get("voiceAgentId") || "").trim();
10802
+ const context = {
10803
+ sessionId: String(url.searchParams.get("sessionId") || "").trim() || undefined,
10804
+ executor: String(url.searchParams.get("executor") || "").trim() || undefined,
10805
+ mode: String(url.searchParams.get("mode") || "").trim() || undefined,
10806
+ model: String(url.searchParams.get("model") || "").trim() || undefined,
10807
+ authSource: String(authResult?.source || "").trim() || undefined,
10808
+ };
10809
+ const allTools = await getVoiceToolDefinitions({ delegateOnly: false, context });
10810
+ const allowed = await getAllowedVoiceTools(context);
10811
+ const allowedTools = allowed instanceof Set ? allowed : null;
10812
+ let tools = allowedTools
10813
+ ? (Array.isArray(allTools) ? allTools : []).filter((tool) => allowedTools.has(String(tool?.name || "").trim()))
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);
10823
+ jsonResponse(res, 200, {
10824
+ ok: true,
10825
+ tools: Array.isArray(tools) ? tools : [],
10826
+ allowedTools: allowedTools ? Array.from(allowedTools.values()).sort() : null,
10827
+ totalTools: Array.isArray(tools) ? tools.length : 0,
10828
+ voiceAgentId: activeVoiceAgentId,
10829
+ });
10830
+ } catch (err) {
10831
+ jsonResponse(res, 500, { ok: false, error: err.message });
10832
+ }
10833
+ return;
10834
+ }
10835
+
10836
+ // POST /api/voice/tool
10837
+ // POST /api/agents/tool
10838
+ if ((path === "/api/voice/tool" || path === "/api/agents/tool") && req.method === "POST") {
10839
+ try {
10840
+ const isVoiceToolRoute = path === "/api/voice/tool";
10841
+ const eventSource = isVoiceToolRoute ? "voice" : "agent";
10842
+ const startedEventType = isVoiceToolRoute ? "voice_tool_started" : "agent_tool_started";
10843
+ const errorEventType = isVoiceToolRoute ? "voice_tool_error" : "agent_tool_error";
10844
+ const completeEventType = isVoiceToolRoute ? "voice_tool_complete_summary" : "agent_tool_complete_summary";
10845
+ const eventLabel = isVoiceToolRoute ? "Voice Action" : "Agent Tool";
9870
10846
  const body = await readJsonBody(req);
9871
10847
  const {
9872
10848
  toolName,
@@ -9875,10 +10851,15 @@ async function handleApi(req, res, url) {
9875
10851
  executor,
9876
10852
  mode,
9877
10853
  model,
10854
+ voiceAgentId,
9878
10855
  } = body || {};
9879
10856
  const normalizedToolName = String(toolName || "").trim();
9880
10857
  if (!normalizedToolName) {
9881
- jsonResponse(res, 400, { error: "toolName required" });
10858
+ if (isVoiceToolRoute) {
10859
+ jsonResponse(res, 400, { error: "toolName required" });
10860
+ } else {
10861
+ jsonResponse(res, 400, { ok: false, error: "toolName required" });
10862
+ }
9882
10863
  return;
9883
10864
  }
9884
10865
  const summarizeToolArgs = (value) => {
@@ -9908,8 +10889,16 @@ async function handleApi(req, res, url) {
9908
10889
  mode: String(mode || "").trim() || undefined,
9909
10890
  model: String(model || "").trim() || undefined,
9910
10891
  authSource: String(authResult?.source || "").trim() || undefined,
10892
+ voiceAgentId: String(voiceAgentId || "").trim() || undefined,
9911
10893
  };
9912
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);
9913
10902
  let tracker = null;
9914
10903
  let session = null;
9915
10904
  if (context.sessionId) {
@@ -9928,11 +10917,11 @@ async function handleApi(req, res, url) {
9928
10917
  }
9929
10918
  tracker.recordEvent(session?.id || context.sessionId, {
9930
10919
  role: "system",
9931
- content: `[Voice Action Started] ${normalizedToolName}\nArgs: ${summarizeToolArgs(normalizedArgs)}`,
10920
+ content: `[${eventLabel} Started] ${normalizedToolName}\nArgs: ${summarizeToolArgs(normalizedArgs)}`,
9932
10921
  timestamp: new Date().toISOString(),
9933
10922
  meta: {
9934
- source: "voice",
9935
- eventType: "voice_tool_started",
10923
+ source: eventSource,
10924
+ eventType: startedEventType,
9936
10925
  toolName: normalizedToolName,
9937
10926
  },
9938
10927
  });
@@ -9941,9 +10930,43 @@ async function handleApi(req, res, url) {
9941
10930
  // Session-bound calls are validated against the allowed tool set
9942
10931
  const allowed = await getAllowedVoiceTools(context);
9943
10932
  if (!allowed.has(normalizedToolName)) {
9944
- jsonResponse(res, 400, {
9945
- error: `Tool "${normalizedToolName}" is not allowed for session-bound calls`,
9946
- });
10933
+ const deniedMessage = `Tool "${normalizedToolName}" is not allowed for session-bound calls`;
10934
+ if (isVoiceToolRoute) {
10935
+ jsonResponse(res, 400, { error: deniedMessage });
10936
+ } else {
10937
+ jsonResponse(res, 400, { ok: false, error: deniedMessage });
10938
+ }
10939
+ return;
10940
+ }
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
+ }
9947
10970
  return;
9948
10971
  }
9949
10972
  }
@@ -9952,31 +10975,43 @@ async function handleApi(req, res, url) {
9952
10975
  if (result?.error) {
9953
10976
  tracker.recordEvent(session?.id || context.sessionId, {
9954
10977
  role: "system",
9955
- content: `[Voice Action Error] ${normalizedToolName}\nError: ${String(result.error || "Unknown error")}`,
10978
+ content: `[${eventLabel} Error] ${normalizedToolName}\nError: ${String(result.error || "Unknown error")}`,
9956
10979
  timestamp: new Date().toISOString(),
9957
10980
  meta: {
9958
- source: "voice",
9959
- eventType: "voice_tool_error",
10981
+ source: eventSource,
10982
+ eventType: errorEventType,
9960
10983
  toolName: normalizedToolName,
9961
10984
  },
9962
10985
  });
9963
10986
  } else {
9964
10987
  tracker.recordEvent(session?.id || context.sessionId, {
9965
10988
  role: "system",
9966
- content: `[Voice Action Complete] ${normalizedToolName}\nSummary: ${summarizeToolResult(result?.result)}`,
10989
+ content: `[${eventLabel} Complete] ${normalizedToolName}\nSummary: ${summarizeToolResult(result?.result)}`,
9967
10990
  timestamp: new Date().toISOString(),
9968
10991
  meta: {
9969
- source: "voice",
9970
- eventType: "voice_tool_complete_summary",
10992
+ source: eventSource,
10993
+ eventType: completeEventType,
9971
10994
  toolName: normalizedToolName,
9972
10995
  },
9973
10996
  });
9974
10997
  }
9975
10998
  }
9976
10999
 
9977
- jsonResponse(res, 200, result);
11000
+ if (isVoiceToolRoute) {
11001
+ jsonResponse(res, 200, result);
11002
+ } else {
11003
+ jsonResponse(res, 200, {
11004
+ ok: !result?.error,
11005
+ toolName: normalizedToolName,
11006
+ ...result,
11007
+ });
11008
+ }
9978
11009
  } catch (err) {
9979
- jsonResponse(res, 500, { error: err.message });
11010
+ if (path === "/api/voice/tool") {
11011
+ jsonResponse(res, 500, { error: err.message });
11012
+ } else {
11013
+ jsonResponse(res, 500, { ok: false, error: err.message });
11014
+ }
9980
11015
  }
9981
11016
  return;
9982
11017
  }
@@ -10669,6 +11704,7 @@ export async function startTelegramUiServer(options = {}) {
10669
11704
  executor,
10670
11705
  mode,
10671
11706
  model,
11707
+ voiceAgentId,
10672
11708
  } = message;
10673
11709
  const normalizedToolName = String(toolName || "").trim();
10674
11710
  if (!normalizedToolName) {
@@ -10690,6 +11726,7 @@ export async function startTelegramUiServer(options = {}) {
10690
11726
  mode: String(mode || "").trim() || undefined,
10691
11727
  model: String(model || "").trim() || undefined,
10692
11728
  authSource: String(socket.__authSource || "").trim() || undefined,
11729
+ voiceAgentId: String(voiceAgentId || "").trim() || undefined,
10693
11730
  };
10694
11731
  const normalizedArgs = relay.normalizeVoiceToolArgs(normalizedToolName, args || {});
10695
11732
  relay.getAllowedVoiceTools(context).then((allowed) => {
@@ -10702,6 +11739,44 @@ export async function startTelegramUiServer(options = {}) {
10702
11739
  });
10703
11740
  return;
10704
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
+ }
10705
11780
  // Tool is allowed — execute it
10706
11781
  relay.executeVoiceTool(normalizedToolName, normalizedArgs, context).then((result) => {
10707
11782
  sendWsMessage(socket, {
@@ -10744,8 +11819,27 @@ export async function startTelegramUiServer(options = {}) {
10744
11819
  mode: String(mode || "").trim() || undefined,
10745
11820
  model: String(model || "").trim() || undefined,
10746
11821
  authSource: String(socket.__authSource || "").trim() || undefined,
11822
+ voiceAgentId: String(voiceAgentId || "").trim() || undefined,
10747
11823
  };
10748
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
+ }
10749
11843
  const result = await relay.executeVoiceTool(normalizedToolName, normalizedArgs, context);
10750
11844
  sendWsMessage(socket, {
10751
11845
  type: "voice-tool-result",