bosun 0.36.0 → 0.36.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 (98) hide show
  1. package/.env.example +98 -16
  2. package/README.md +27 -0
  3. package/agent-event-bus.mjs +5 -5
  4. package/agent-pool.mjs +129 -12
  5. package/agent-prompts.mjs +7 -1
  6. package/agent-sdk.mjs +13 -2
  7. package/agent-supervisor.mjs +2 -2
  8. package/agent-work-report.mjs +1 -1
  9. package/anomaly-detector.mjs +6 -6
  10. package/autofix.mjs +15 -15
  11. package/bosun-skills.mjs +4 -4
  12. package/bosun.schema.json +160 -4
  13. package/claude-shell.mjs +11 -11
  14. package/cli.mjs +21 -21
  15. package/codex-config.mjs +19 -19
  16. package/codex-shell.mjs +180 -29
  17. package/config-doctor.mjs +27 -2
  18. package/config.mjs +60 -7
  19. package/copilot-shell.mjs +4 -4
  20. package/error-detector.mjs +1 -1
  21. package/fleet-coordinator.mjs +2 -2
  22. package/gemini-shell.mjs +692 -0
  23. package/github-oauth-portal.mjs +1 -1
  24. package/github-reconciler.mjs +2 -2
  25. package/kanban-adapter.mjs +741 -168
  26. package/merge-strategy.mjs +25 -25
  27. package/monitor.mjs +123 -105
  28. package/opencode-shell.mjs +22 -22
  29. package/package.json +7 -1
  30. package/postinstall.mjs +22 -22
  31. package/pr-cleanup-daemon.mjs +6 -6
  32. package/prepublish-check.mjs +4 -4
  33. package/presence.mjs +2 -2
  34. package/primary-agent.mjs +85 -7
  35. package/publish.mjs +1 -1
  36. package/review-agent.mjs +1 -1
  37. package/session-tracker.mjs +11 -0
  38. package/setup-web-server.mjs +429 -21
  39. package/setup.mjs +367 -12
  40. package/shared-knowledge.mjs +1 -1
  41. package/startup-service.mjs +9 -9
  42. package/stream-resilience.mjs +58 -4
  43. package/sync-engine.mjs +2 -2
  44. package/task-assessment.mjs +9 -9
  45. package/task-cli.mjs +1 -1
  46. package/task-complexity.mjs +71 -2
  47. package/task-context.mjs +1 -2
  48. package/task-executor.mjs +104 -41
  49. package/telegram-bot.mjs +825 -494
  50. package/telegram-sentinel.mjs +28 -28
  51. package/ui/app.js +256 -23
  52. package/ui/app.monolith.js +1 -1
  53. package/ui/components/agent-selector.js +4 -3
  54. package/ui/components/chat-view.js +101 -28
  55. package/ui/components/diff-viewer.js +3 -3
  56. package/ui/components/kanban-board.js +3 -3
  57. package/ui/components/session-list.js +255 -35
  58. package/ui/components/workspace-switcher.js +3 -3
  59. package/ui/demo.html +209 -194
  60. package/ui/index.html +3 -3
  61. package/ui/modules/icon-utils.js +206 -142
  62. package/ui/modules/icons.js +2 -27
  63. package/ui/modules/settings-schema.js +29 -5
  64. package/ui/modules/streaming.js +30 -2
  65. package/ui/modules/vision-stream.js +275 -0
  66. package/ui/modules/voice-client.js +102 -9
  67. package/ui/modules/voice-fallback.js +62 -6
  68. package/ui/modules/voice-overlay.js +594 -59
  69. package/ui/modules/voice.js +31 -38
  70. package/ui/setup.html +284 -34
  71. package/ui/styles/components.css +47 -0
  72. package/ui/styles/sessions.css +75 -0
  73. package/ui/tabs/agents.js +73 -43
  74. package/ui/tabs/chat.js +37 -40
  75. package/ui/tabs/control.js +2 -2
  76. package/ui/tabs/dashboard.js +1 -1
  77. package/ui/tabs/infra.js +10 -10
  78. package/ui/tabs/library.js +8 -8
  79. package/ui/tabs/logs.js +10 -10
  80. package/ui/tabs/settings.js +20 -20
  81. package/ui/tabs/tasks.js +76 -47
  82. package/ui-server.mjs +1761 -124
  83. package/update-check.mjs +13 -13
  84. package/ve-kanban.mjs +1 -1
  85. package/whatsapp-channel.mjs +5 -5
  86. package/workflow-engine.mjs +20 -1
  87. package/workflow-nodes.mjs +904 -4
  88. package/workflow-templates/agents.mjs +321 -7
  89. package/workflow-templates/ci-cd.mjs +6 -6
  90. package/workflow-templates/github.mjs +156 -84
  91. package/workflow-templates/planning.mjs +8 -8
  92. package/workflow-templates/reliability.mjs +8 -8
  93. package/workflow-templates/security.mjs +3 -3
  94. package/workflow-templates.mjs +15 -9
  95. package/workspace-manager.mjs +85 -1
  96. package/workspace-monitor.mjs +2 -2
  97. package/workspace-registry.mjs +2 -2
  98. package/worktree-manager.mjs +1 -1
@@ -223,41 +223,39 @@ export function useVoiceInput(onTranscript, opts = {}) {
223
223
  return { listening, supported, start, stop, toggle, error };
224
224
  }
225
225
 
226
+ export function requestVoiceModeOpen(detail = {}) {
227
+ try {
228
+ globalThis.dispatchEvent?.(new CustomEvent("ve:open-voice-mode", { detail }));
229
+ } catch {
230
+ // no-op
231
+ }
232
+ }
233
+
226
234
  /* ─── VoiceMicButton component ─────────────────────────────────
227
235
  *
228
- * @param {{ onTranscript: (text: string) => void,
229
- * disabled?: boolean,
230
- * title?: string,
231
- * className?: string,
232
- * size?: 'sm' | 'md' }} props
236
+ * In v0.36+, all in-app mic actions open the real voice mode overlay.
233
237
  */
234
238
  export function VoiceMicButton({ onTranscript, disabled = false, title, className = "", size = "md" }) {
235
- const { listening, supported, toggle, error } = useVoiceInput(
236
- (text) => {
237
- haptic("light");
238
- onTranscript(text);
239
- },
240
- {},
241
- );
242
-
243
- // Inject styles on first render
239
+ // Legacy callback kept for backward compatibility; real voice mode owns transcript flow.
240
+ void onTranscript;
244
241
  useEffect(() => { injectVoiceStyles(); }, []);
245
242
 
246
- if (!supported) return null;
247
-
248
243
  const sizeClass = size === "sm" ? "mic-btn-sm" : "";
249
244
 
250
245
  return html`
251
246
  <button
252
247
  type="button"
253
- class="mic-btn ${sizeClass} ${listening ? "listening" : ""} ${className}"
248
+ class="mic-btn ${sizeClass} ${className}"
254
249
  disabled=${disabled}
255
- onClick=${() => { haptic("light"); toggle(); }}
256
- title=${title || (listening ? "Stop recording" : "Voice input")}
257
- aria-label=${listening ? "Stop voice recording" : "Start voice input"}
258
- aria-pressed=${listening}
250
+ onClick=${() => {
251
+ haptic("light");
252
+ requestVoiceModeOpen();
253
+ }}
254
+ title=${title || "Live voice mode"}
255
+ aria-label="Open live voice mode"
256
+ aria-pressed="false"
259
257
  >
260
- ${resolveIcon(listening ? "⏹" : "🎤")}
258
+ ${resolveIcon(":mic:")}
261
259
  </button>
262
260
  `;
263
261
  }
@@ -266,29 +264,24 @@ export function VoiceMicButton({ onTranscript, disabled = false, title, classNam
266
264
  * Positioned absolutely inside a .input-with-mic or .textarea-with-mic wrapper.
267
265
  */
268
266
  export function VoiceMicButtonInline({ onTranscript, disabled = false }) {
269
- const { listening, supported, toggle } = useVoiceInput(
270
- (text) => {
271
- haptic("light");
272
- onTranscript(text);
273
- },
274
- {},
275
- );
276
-
267
+ void onTranscript;
277
268
  useEffect(() => { injectVoiceStyles(); }, []);
278
269
 
279
- if (!supported) return null;
280
-
281
270
  return html`
282
271
  <button
283
272
  type="button"
284
- class="mic-btn mic-btn-sm mic-btn-inline ${listening ? "listening" : ""}"
273
+ class="mic-btn mic-btn-sm mic-btn-inline"
285
274
  disabled=${disabled}
286
- onClick=${(e) => { e.stopPropagation(); haptic("light"); toggle(); }}
287
- title=${listening ? "Stop recording" : "Voice input"}
288
- aria-label=${listening ? "Stop" : "Voice"}
289
- aria-pressed=${listening}
275
+ onClick=${(e) => {
276
+ e.stopPropagation();
277
+ haptic("light");
278
+ requestVoiceModeOpen();
279
+ }}
280
+ title="Live voice mode"
281
+ aria-label="Open live voice mode"
282
+ aria-pressed="false"
290
283
  >
291
- ${resolveIcon(listening ? "⏹" : "🎤")}
284
+ ${resolveIcon(":mic:")}
292
285
  </button>
293
286
  `;
294
287
  }
package/ui/setup.html CHANGED
@@ -344,6 +344,28 @@
344
344
  .success-banner h2 { color: var(--success); margin-bottom: 8px; }
345
345
  .success-banner p { color: var(--text-secondary); font-size: 0.9rem; }
346
346
 
347
+ /* ── Inline icon helpers (shared with iconText output) ───────────── */
348
+ .icon-text {
349
+ display: inline-flex;
350
+ align-items: center;
351
+ gap: 0.35em;
352
+ flex-wrap: wrap;
353
+ }
354
+ .icon-inline {
355
+ display: inline-flex;
356
+ align-items: center;
357
+ justify-content: center;
358
+ width: 1em;
359
+ height: 1em;
360
+ line-height: 1;
361
+ vertical-align: middle;
362
+ }
363
+ .icon-inline svg {
364
+ width: 1em;
365
+ height: 1em;
366
+ display: block;
367
+ }
368
+
347
369
  /* ── Profile Cards ───────────────────────────────────────────────── */
348
370
  .profile-cards {
349
371
  display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
@@ -489,6 +511,7 @@
489
511
  import { h, render } from "preact";
490
512
  import { useState, useEffect, useRef } from "preact/hooks";
491
513
  import htm from "htm";
514
+ import { iconText } from "./modules/icon-utils.js";
492
515
 
493
516
  const html = htm.bind(h);
494
517
 
@@ -506,15 +529,15 @@ const apiPost = (path, body) => api(path, { method: "POST", body: JSON.stringify
506
529
  // ── Steps definition ─────────────────────────────────────────────────────────
507
530
 
508
531
  const STEPS = [
509
- { id: "prerequisites", label: "Prerequisites", icon: "" },
510
- { id: "profile", label: "Profile", icon: "👤" },
511
- { id: "executors", label: "AI Executors", icon: "🤖" },
512
- { id: "repos", label: "Repositories", icon: "📁" },
513
- { id: "kanban", label: "Task Mgmt", icon: "📋" },
514
- { id: "workflows", label: "Workflows", icon: "🧭" },
515
- { id: "notifications", label: "Notifications", icon: "🔔" },
516
- { id: "advanced", label: "Advanced", icon: "⚙️" },
517
- { id: "review", label: "Review", icon: "🚀" },
532
+ { id: "prerequisites", label: "Prerequisites", icon: ":check:" },
533
+ { id: "profile", label: "Profile", icon: ":user:" },
534
+ { id: "executors", label: "AI Executors", icon: ":bot:" },
535
+ { id: "repos", label: "Repositories", icon: ":folder:" },
536
+ { id: "kanban", label: "Task Mgmt", icon: ":clipboard:" },
537
+ { id: "workflows", label: "Workflows", icon: ":compass:" },
538
+ { id: "notifications", label: "Notifications", icon: ":bell:" },
539
+ { id: "advanced", label: "Advanced", icon: ":settings:" },
540
+ { id: "review", label: "Review", icon: ":rocket:" },
518
541
  ];
519
542
 
520
543
  // ── SelectWithCustom component ───────────────────────────────────────────────
@@ -679,6 +702,19 @@ function App() {
679
702
  const [whatsappEnabled, setWhatsappEnabled] = useState(false);
680
703
  const [telegramIntervalMin, setTelegramIntervalMin] = useState(10);
681
704
  const [orchestratorScript, setOrchestratorScript] = useState("");
705
+ // Voice assistant
706
+ const [voiceEnabled, setVoiceEnabled] = useState(true);
707
+ const [voiceProvider, setVoiceProvider] = useState("auto");
708
+ const [voiceModel, setVoiceModel] = useState("gpt-4o-realtime-preview-2024-12-17");
709
+ const [voiceVisionModel, setVoiceVisionModel] = useState("gpt-4.1-mini");
710
+ const [voiceId, setVoiceId] = useState("alloy");
711
+ const [voiceTurnDetection, setVoiceTurnDetection] = useState("server_vad");
712
+ const [voiceFallbackMode, setVoiceFallbackMode] = useState("browser");
713
+ const [voiceDelegateExecutor, setVoiceDelegateExecutor] = useState("codex-sdk");
714
+ const [openaiRealtimeApiKey, setOpenaiRealtimeApiKey] = useState("");
715
+ const [azureOpenaiRealtimeEndpoint, setAzureOpenaiRealtimeEndpoint] = useState("");
716
+ const [azureOpenaiRealtimeApiKey, setAzureOpenaiRealtimeApiKey] = useState("");
717
+ const [azureOpenaiRealtimeDeployment, setAzureOpenaiRealtimeDeployment] = useState("gpt-4o-realtime-preview");
682
718
 
683
719
  const getWorkflowProfileById = (profileId, profileList = workflowProfiles) =>
684
720
  (profileList || []).find((profile_) => profile_.id === profileId) || null;
@@ -866,6 +902,18 @@ function App() {
866
902
  }
867
903
  if (d.bosunHome) setBosunHome(d.bosunHome);
868
904
  if (d.workspacesDir) setWorkspacesDir(d.workspacesDir);
905
+ if (d.voiceEnabled !== undefined) { setVoiceEnabled(d.voiceEnabled !== false); }
906
+ if (d.voiceProvider) { setVoiceProvider(d.voiceProvider); }
907
+ if (d.voiceModel) { setVoiceModel(d.voiceModel); }
908
+ if (d.voiceVisionModel) { setVoiceVisionModel(d.voiceVisionModel); }
909
+ if (d.voiceId) { setVoiceId(d.voiceId); }
910
+ if (d.voiceTurnDetection) { setVoiceTurnDetection(d.voiceTurnDetection); }
911
+ if (d.voiceFallbackMode) { setVoiceFallbackMode(d.voiceFallbackMode); }
912
+ if (d.voiceDelegateExecutor) { setVoiceDelegateExecutor(d.voiceDelegateExecutor); }
913
+ if (d.openaiRealtimeApiKey) { setOpenaiRealtimeApiKey(d.openaiRealtimeApiKey); }
914
+ if (d.azureOpenaiRealtimeEndpoint) { setAzureOpenaiRealtimeEndpoint(d.azureOpenaiRealtimeEndpoint); }
915
+ if (d.azureOpenaiRealtimeApiKey) { setAzureOpenaiRealtimeApiKey(d.azureOpenaiRealtimeApiKey); }
916
+ if (d.azureOpenaiRealtimeDeployment) { setAzureOpenaiRealtimeDeployment(d.azureOpenaiRealtimeDeployment); }
869
917
 
870
918
  // Pre-fill repos from existing config (slugs preferred)
871
919
  if (statusData.existingConfig?.repos?.length) {
@@ -877,6 +925,19 @@ function App() {
877
925
  // ── Pre-fill from existing .env ──────────────────────────────────────
878
926
  const env = statusData.existingEnv || {};
879
927
  let envLoaded = false;
928
+ const existingVoice = statusData.existingConfig?.voice || {};
929
+ if (existingVoice.enabled != null) { setVoiceEnabled(existingVoice.enabled !== false); envLoaded = true; }
930
+ if (existingVoice.provider) { setVoiceProvider(String(existingVoice.provider)); envLoaded = true; }
931
+ if (existingVoice.model) { setVoiceModel(String(existingVoice.model)); envLoaded = true; }
932
+ if (existingVoice.visionModel) { setVoiceVisionModel(String(existingVoice.visionModel)); envLoaded = true; }
933
+ if (existingVoice.voiceId) { setVoiceId(String(existingVoice.voiceId)); envLoaded = true; }
934
+ if (existingVoice.turnDetection) { setVoiceTurnDetection(String(existingVoice.turnDetection)); envLoaded = true; }
935
+ if (existingVoice.fallbackMode) { setVoiceFallbackMode(String(existingVoice.fallbackMode)); envLoaded = true; }
936
+ if (existingVoice.delegateExecutor) { setVoiceDelegateExecutor(String(existingVoice.delegateExecutor)); envLoaded = true; }
937
+ if (existingVoice.openaiApiKey) { setOpenaiRealtimeApiKey(String(existingVoice.openaiApiKey)); envLoaded = true; }
938
+ if (existingVoice.azureEndpoint) { setAzureOpenaiRealtimeEndpoint(String(existingVoice.azureEndpoint)); envLoaded = true; }
939
+ if (existingVoice.azureApiKey) { setAzureOpenaiRealtimeApiKey(String(existingVoice.azureApiKey)); envLoaded = true; }
940
+ if (existingVoice.azureDeployment) { setAzureOpenaiRealtimeDeployment(String(existingVoice.azureDeployment)); envLoaded = true; }
880
941
  if (env.BOSUN_HOME) { setBosunHome(env.BOSUN_HOME); envLoaded = true; }
881
942
  if (env.BOSUN_WORKSPACES_DIR) { setWorkspacesDir(env.BOSUN_WORKSPACES_DIR); setWorkspacesDirCustomized(true); envLoaded = true; }
882
943
  if (env.PROJECT_NAME) { setProjectName(env.PROJECT_NAME); envLoaded = true; }
@@ -981,13 +1042,29 @@ function App() {
981
1042
  if (env.WHATSAPP_ENABLED) { setWhatsappEnabled(env.WHATSAPP_ENABLED === "true"); envLoaded = true; }
982
1043
  if (env.TELEGRAM_INTERVAL_MIN) { setTelegramIntervalMin(Number(env.TELEGRAM_INTERVAL_MIN) || 10); envLoaded = true; }
983
1044
  if (env.ORCHESTRATOR_SCRIPT) { setOrchestratorScript(env.ORCHESTRATOR_SCRIPT); envLoaded = true; }
1045
+ // Voice settings
1046
+ if (env.VOICE_ENABLED !== undefined) { setVoiceEnabled(env.VOICE_ENABLED !== "false"); envLoaded = true; }
1047
+ if (env.VOICE_PROVIDER) { setVoiceProvider(env.VOICE_PROVIDER); envLoaded = true; }
1048
+ if (env.VOICE_MODEL) { setVoiceModel(env.VOICE_MODEL); envLoaded = true; }
1049
+ if (env.VOICE_VISION_MODEL) { setVoiceVisionModel(env.VOICE_VISION_MODEL); envLoaded = true; }
1050
+ if (env.VOICE_ID) { setVoiceId(env.VOICE_ID); envLoaded = true; }
1051
+ if (env.VOICE_TURN_DETECTION) { setVoiceTurnDetection(env.VOICE_TURN_DETECTION); envLoaded = true; }
1052
+ if (env.VOICE_FALLBACK_MODE) { setVoiceFallbackMode(env.VOICE_FALLBACK_MODE); envLoaded = true; }
1053
+ if (env.VOICE_DELEGATE_EXECUTOR) { setVoiceDelegateExecutor(env.VOICE_DELEGATE_EXECUTOR); envLoaded = true; }
1054
+ if (env.OPENAI_REALTIME_API_KEY) { setOpenaiRealtimeApiKey(env.OPENAI_REALTIME_API_KEY); envLoaded = true; }
1055
+ if (env.AZURE_OPENAI_REALTIME_ENDPOINT) { setAzureOpenaiRealtimeEndpoint(env.AZURE_OPENAI_REALTIME_ENDPOINT); envLoaded = true; }
1056
+ if (env.AZURE_OPENAI_REALTIME_API_KEY) { setAzureOpenaiRealtimeApiKey(env.AZURE_OPENAI_REALTIME_API_KEY); envLoaded = true; }
1057
+ if (env.AZURE_OPENAI_REALTIME_DEPLOYMENT) { setAzureOpenaiRealtimeDeployment(env.AZURE_OPENAI_REALTIME_DEPLOYMENT); envLoaded = true; }
984
1058
  // Multi-workspace: load workspaces[] from existing config
985
- if (statusData.existingConfig?.workspaces?.length > 1) {
1059
+ if (statusData.existingConfig?.workspaces?.length > 0) {
986
1060
  setMultiWorkspaceEnabled(true);
987
1061
  setWorkspaces(statusData.existingConfig.workspaces.map((ws) => ({
988
1062
  id: ws.id || Date.now() + Math.random(),
989
1063
  name: ws.name || "",
990
- repos: (ws.repos || []).map((r) => r.slug || r.path || r || ""),
1064
+ repos: (ws.repos || []).map((r) => {
1065
+ if (typeof r === "string") return r;
1066
+ return r.slug || r.url || r.name || r.path || "";
1067
+ }),
991
1068
  })));
992
1069
  envLoaded = true;
993
1070
  }
@@ -1148,6 +1225,24 @@ function App() {
1148
1225
  };
1149
1226
  });
1150
1227
 
1228
+ const buildRepoConfigEntry = (rawRepo, index = 0) => {
1229
+ const value = String(rawRepo || "").trim();
1230
+ if (!value) return null;
1231
+ const slugMatch = value.match(/^([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)$/);
1232
+ const slug = slugMatch ? slugMatch[1] : "";
1233
+ const urlMatch = value.match(/^https?:\/\/github\.com\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:\.git)?$/i);
1234
+ const resolvedSlug = slug || (urlMatch ? urlMatch[1] : "");
1235
+ const name = (resolvedSlug.split("/").pop() || value.split("/").pop() || `repo-${index + 1}`)
1236
+ .replace(/\.git$/i, "")
1237
+ .trim();
1238
+ return {
1239
+ name,
1240
+ slug: resolvedSlug,
1241
+ url: resolvedSlug ? `https://github.com/${resolvedSlug}.git` : value,
1242
+ primary: index === 0,
1243
+ };
1244
+ };
1245
+
1151
1246
  // ── Apply config ───────────────────────────────────────────────────────────
1152
1247
 
1153
1248
  const handleApply = async () => {
@@ -1214,6 +1309,19 @@ function App() {
1214
1309
  copilotEnableAskUser,
1215
1310
  copilotEnableAllMcpTools,
1216
1311
  copilotMcpConfig,
1312
+ // Voice assistant
1313
+ voiceEnabled,
1314
+ voiceProvider,
1315
+ voiceModel,
1316
+ voiceVisionModel,
1317
+ voiceId,
1318
+ voiceTurnDetection,
1319
+ voiceFallbackMode,
1320
+ voiceDelegateExecutor,
1321
+ openaiRealtimeApiKey,
1322
+ azureOpenaiRealtimeEndpoint,
1323
+ azureOpenaiRealtimeApiKey,
1324
+ azureOpenaiRealtimeDeployment,
1217
1325
  // Infrastructure
1218
1326
  containerEnabled,
1219
1327
  containerRuntime,
@@ -1228,7 +1336,9 @@ function App() {
1228
1336
  bosunHome,
1229
1337
  workspacesDir,
1230
1338
  executors: buildExecutorsConfig(),
1231
- repos: filteredRepos.map((r) => ({ slug: r })),
1339
+ repos: filteredRepos
1340
+ .map((r, idx) => buildRepoConfigEntry(r, idx))
1341
+ .filter(Boolean),
1232
1342
  kanban: { backend: kanbanBackend, syncPolicy: kanbanSyncPolicy },
1233
1343
  failover: {
1234
1344
  strategy: failoverStrategy,
@@ -1261,7 +1371,9 @@ function App() {
1261
1371
  ? workspaces.filter((ws) => ws.name.trim()).map((ws) => ({
1262
1372
  id: ws.id,
1263
1373
  name: ws.name,
1264
- repos: (ws.repos || []).filter((r) => r.trim()).map((r) => ({ slug: r })),
1374
+ repos: (ws.repos || [])
1375
+ .map((r, idx) => buildRepoConfigEntry(r, idx))
1376
+ .filter(Boolean),
1265
1377
  }))
1266
1378
  : undefined,
1267
1379
  },
@@ -1506,7 +1618,7 @@ function App() {
1506
1618
  <div class="setup-container">
1507
1619
  <div class="step-panel">
1508
1620
  <div class="success-banner">
1509
- <div class="icon">🎉</div>
1621
+ <div class="icon">${iconText(":star:")}</div>
1510
1622
  <h2>Setup Complete!</h2>
1511
1623
  <p>Bosun is configured and ready to go.</p>
1512
1624
  <p style="margin-top:12px;font-size:0.8rem;color:var(--text-dim)">
@@ -1568,10 +1680,10 @@ function App() {
1568
1680
  <ul class="prereq-list">
1569
1681
  ${items.map((item) => {
1570
1682
  const statusClass = item.installed ? "ok" : item.required ? "fail" : "warn";
1571
- const icon = item.installed ? "" : item.required ? "" : "⚠️";
1683
+ const icon = item.installed ? ":check:" : item.required ? ":close:" : ":alert:";
1572
1684
  return html`
1573
1685
  <li class="prereq-item ${statusClass}">
1574
- <span class="icon">${icon}</span>
1686
+ <span class="icon">${iconText(icon)}</span>
1575
1687
  <span class="name">${item.label}${!item.required ? " (optional)" : ""}</span>
1576
1688
  <span class="version">${item.version || (item.installed ? "found" : "not found")}</span>
1577
1689
  </li>
@@ -1579,7 +1691,7 @@ function App() {
1579
1691
  })}
1580
1692
  ${prereqs.gh && !prereqs.gh.authenticated && prereqs.gh.installed ? html`
1581
1693
  <li class="prereq-item warn">
1582
- <span class="icon">⚠️</span>
1694
+ <span class="icon">${iconText(":alert:")}</span>
1583
1695
  <span class="name">GitHub CLI not authenticated</span>
1584
1696
  <span class="version">Run: gh auth login</span>
1585
1697
  </li>
@@ -1630,12 +1742,12 @@ function App() {
1630
1742
 
1631
1743
  <div class="profile-cards">
1632
1744
  <div class="profile-card ${profile === "standard" ? "selected" : ""}" onclick=${() => setProfile("standard")}>
1633
- <div class="icon">⚡</div>
1745
+ <div class="icon">${iconText(":zap:")}</div>
1634
1746
  <h4>Standard</h4>
1635
1747
  <p>Sensible defaults with primary & backup executors. Best for most users.</p>
1636
1748
  </div>
1637
1749
  <div class="profile-card ${profile === "advanced" ? "selected" : ""}" onclick=${() => setProfile("advanced")}>
1638
- <div class="icon">🔧</div>
1750
+ <div class="icon">${iconText(":settings:")}</div>
1639
1751
  <h4>Advanced</h4>
1640
1752
  <p>Full control over executors, failover, distribution weights, and all settings.</p>
1641
1753
  </div>
@@ -1665,7 +1777,7 @@ function App() {
1665
1777
  <p class="step-desc">Configure which AI coding agents bosun will use. You can add multiple executors with weighted distribution.</p>
1666
1778
 
1667
1779
  <div style="background:rgba(99,102,241,.08);border:1px solid rgba(99,102,241,.25);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:0.8rem;line-height:1.6">
1668
- <strong>🔐 No API key required in most cases.</strong>
1780
+ <strong>${iconText(":lock: No API key required in most cases.")}</strong>
1669
1781
  GitHub Copilot, Codex CLI, and Claude Code all support OAuth — just run
1670
1782
  <code style="font-family:var(--font-mono);color:var(--accent-light)">gh auth login</code>,
1671
1783
  <code style="font-family:var(--font-mono);color:var(--accent-light)">codex auth login</code>, or
@@ -1685,10 +1797,11 @@ function App() {
1685
1797
  ? configuredModelOptions
1686
1798
  : getModelsForExecutor(ex.executor);
1687
1799
  const authMode = ex.authMode || "oauth";
1800
+ const executorHeading = `${ex.enabled === false ? ":dot:" : ex.role === "primary" ? ":dot:" : ":dot:"} Executor ${i + 1}: ${ex.name}`;
1688
1801
  return html`
1689
1802
  <div class="executor-card">
1690
1803
  <div class="executor-card-header">
1691
- <h4>${ex.enabled === false ? "⚫" : ex.role === "primary" ? "🟢" : "🔵"} Executor ${i + 1}: ${ex.name}</h4>
1804
+ <h4>${iconText(executorHeading)}</h4>
1692
1805
  ${executors.length > 1 && html`
1693
1806
  <button class="btn btn-sm btn-danger" onclick=${() => removeExecutor(i)}>Remove</button>
1694
1807
  `}
@@ -1773,7 +1886,7 @@ function App() {
1773
1886
  return html`
1774
1887
  <div class="connection-card">
1775
1888
  <div class="connection-card-header">
1776
- <span class="connection-label">${isPrimary ? " Primary" : `☁ Profile ${ci + 1}`}: ${conn.name || (isPrimary ? "default" : "(unnamed)")}</span>
1889
+ <span class="connection-label">${iconText(`${isPrimary ? ":star: Primary" : `:globe: Profile ${ci + 1}`}: ${conn.name || (isPrimary ? "default" : "(unnamed)")}`)}</span>
1777
1890
  <button class="btn btn-sm btn-danger"
1778
1891
  onclick=${() => removeConnection(i, ci)}>✕ Remove</button>
1779
1892
  </div>
@@ -1920,7 +2033,7 @@ function App() {
1920
2033
 
1921
2034
  ${!multiWorkspaceEnabled && html`
1922
2035
  <div style="background:var(--bg-input);border:1px solid var(--border-primary);border-radius:var(--radius-sm);padding:10px 14px;margin-bottom:16px;font-size:0.78rem;color:var(--text-secondary)">
1923
- 📁 Repos will be cloned into:
2036
+ ${iconText(":folder: Repos will be cloned into:")}
1924
2037
  <code style="font-family:var(--font-mono);color:var(--accent-light);margin-left:4px">${cloneRoot}/${"<repo-name>"}</code>
1925
2038
  </div>
1926
2039
  `}
@@ -2186,12 +2299,12 @@ function App() {
2186
2299
  ${/* ── GitHub App installation callout ── */ ""}
2187
2300
  <div style="background:rgba(56,139,253,.07);border:1px solid rgba(56,139,253,.25);border-radius:var(--radius-sm);padding:14px 16px;margin-bottom:20px">
2188
2301
  <div style="display:flex;align-items:flex-start;gap:10px">
2189
- <span style="font-size:1.3em;line-height:1.2;flex-shrink:0">📦</span>
2302
+ <span style="font-size:1.3em;line-height:1.2;flex-shrink:0">${iconText(":box:")}</span>
2190
2303
  <div style="flex:1;min-width:0">
2191
2304
  <div style="font-weight:600;font-size:0.9rem;color:var(--accent-light);margin-bottom:4px">GitHub App — Bosun[VE]</div>
2192
2305
  ${oauthStatus === "received"
2193
2306
  ? html`<div style="color:#4ade80;font-size:0.82rem">
2194
- GitHub App authorized!
2307
+ ${iconText(":check: GitHub App authorized!")}
2195
2308
  ${oauthInstallationId ? html` (Installation <code style="font-family:var(--font-mono)">${oauthInstallationId}</code>)` : null}
2196
2309
  </div>`
2197
2310
  : html`<div style="font-size:0.82rem;color:var(--text-secondary);line-height:1.5">
@@ -2201,7 +2314,7 @@ function App() {
2201
2314
  <div style="margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap">
2202
2315
  <a href="https://github.com/apps/bosun-ve" target="_blank" rel="noopener"
2203
2316
  style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--accent);color:#fff;border-radius:5px;font-size:0.8rem;font-weight:600;text-decoration:none">
2204
- 🔗 Install from GitHub Marketplace
2317
+ ${iconText(":link: Install from GitHub Marketplace")}
2205
2318
  </a>
2206
2319
  <span style="font-size:0.75rem;color:var(--text-dim)">
2207
2320
  <span class="spinner" style="width:10px;height:10px;border-width:2px;vertical-align:middle;margin-right:4px"></span>
@@ -2253,7 +2366,7 @@ function App() {
2253
2366
  <div style="border:1px solid var(--border-primary);border-radius:var(--radius-sm);margin-bottom:10px;overflow:hidden">
2254
2367
  <div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;cursor:pointer;background:var(--bg-input);user-select:none"
2255
2368
  onclick=${() => setOpen((v) => !v)}>
2256
- <span style="font-weight:600;font-size:0.88rem">${title}</span>
2369
+ <span style="font-weight:600;font-size:0.88rem">${iconText(title)}</span>
2257
2370
  <span style="color:var(--text-dim);font-size:0.8rem">${open ? "▲" : "▼"}</span>
2258
2371
  </div>
2259
2372
  ${open && html`<div style="padding:16px">${children}</div>`}
@@ -2265,7 +2378,7 @@ function App() {
2265
2378
  <h2>Advanced Settings</h2>
2266
2379
  <p class="step-desc">Fine-tune execution, model profiles, and infrastructure. Defaults are sensible — only change what you need.</p>
2267
2380
 
2268
- <${Section} title=" Execution & Distribution" defaultOpen=${true}>
2381
+ <${Section} title=":zap: Execution & Distribution" defaultOpen=${true}>
2269
2382
  <div class="form-group">
2270
2383
  <label>Executor Mode</label>
2271
2384
  <select value=${executorMode} onchange=${(e) => setExecutorMode(e.target.value)}>
@@ -2343,7 +2456,7 @@ function App() {
2343
2456
  `}
2344
2457
  <//>
2345
2458
 
2346
- <${Section} title="🔀 Failover">
2459
+ <${Section} title=":git: Failover">
2347
2460
  <div class="form-group">
2348
2461
  <label>Failover Strategy</label>
2349
2462
  <select value=${failoverStrategy} onchange=${(e) => setFailoverStrategy(e.target.value)}>
@@ -2372,7 +2485,7 @@ function App() {
2372
2485
  </div>
2373
2486
  <//>
2374
2487
 
2375
- <${Section} title="🧭 Workflow Engine">
2488
+ <${Section} title=":compass: Workflow Engine">
2376
2489
  <div class="executor-grid">
2377
2490
  <div class="form-group">
2378
2491
  <label>Default Workflow Profile</label>
@@ -2422,7 +2535,7 @@ function App() {
2422
2535
  </div>
2423
2536
  <//>
2424
2537
 
2425
- <${Section} title="🔵 Codex Settings">
2538
+ <${Section} title=":dot: Codex Settings">
2426
2539
  <div class="executor-grid">
2427
2540
  <div class="form-group">
2428
2541
  <label>Active Model Profile</label>
@@ -2509,7 +2622,7 @@ function App() {
2509
2622
  </div>
2510
2623
  <//>
2511
2624
 
2512
- <${Section} title="🟣 Copilot Settings">
2625
+ <${Section} title=":dot: Copilot Settings">
2513
2626
  <div class="executor-grid">
2514
2627
  <div class="form-group">
2515
2628
  <label>Max Requests per Session</label>
@@ -2569,7 +2682,136 @@ function App() {
2569
2682
  </div>
2570
2683
  <//>
2571
2684
 
2572
- <${Section} title="🏗️ Infrastructure">
2685
+ <${Section} title=":mic: Voice Assistant">
2686
+ <div class="form-group">
2687
+ <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
2688
+ <input type="checkbox" checked=${voiceEnabled}
2689
+ onchange=${(e) => setVoiceEnabled(e.target.checked)}
2690
+ style="width:auto;padding:0;margin:0;accent-color:var(--accent);cursor:pointer" />
2691
+ Enable Voice Mode in the UI
2692
+ </label>
2693
+ <div class="hint">Allows live voice/video calls from chat. Tier 2 browser fallback works without cloud keys.</div>
2694
+ </div>
2695
+ ${voiceEnabled && html`
2696
+ <div class="executor-grid">
2697
+ <div class="form-group">
2698
+ <label>Voice Provider</label>
2699
+ <select value=${voiceProvider} onchange=${(e) => setVoiceProvider(e.target.value)}>
2700
+ <option value="auto">Auto Detect</option>
2701
+ <option value="openai">OpenAI Realtime</option>
2702
+ <option value="azure">Azure OpenAI Realtime</option>
2703
+ <option value="claude">Claude (fallback + Claude vision)</option>
2704
+ <option value="gemini">Gemini (fallback + Gemini vision)</option>
2705
+ <option value="fallback">Browser Fallback Only</option>
2706
+ </select>
2707
+ </div>
2708
+ <div class="form-group">
2709
+ <label>Voice Persona</label>
2710
+ <select value=${voiceId} onchange=${(e) => setVoiceId(e.target.value)}>
2711
+ <option value="alloy">alloy</option>
2712
+ <option value="ash">ash</option>
2713
+ <option value="ballad">ballad</option>
2714
+ <option value="coral">coral</option>
2715
+ <option value="echo">echo</option>
2716
+ <option value="fable">fable</option>
2717
+ <option value="nova">nova</option>
2718
+ <option value="onyx">onyx</option>
2719
+ <option value="sage">sage</option>
2720
+ <option value="shimmer">shimmer</option>
2721
+ <option value="verse">verse</option>
2722
+ </select>
2723
+ </div>
2724
+ <div class="form-group">
2725
+ <label>Realtime Voice Model</label>
2726
+ <input
2727
+ type="text"
2728
+ value=${voiceModel}
2729
+ oninput=${(e) => setVoiceModel(e.target.value)}
2730
+ placeholder="gpt-4o-realtime-preview-2024-12-17"
2731
+ />
2732
+ </div>
2733
+ <div class="form-group">
2734
+ <label>Vision Model</label>
2735
+ <input
2736
+ type="text"
2737
+ value=${voiceVisionModel}
2738
+ oninput=${(e) => setVoiceVisionModel(e.target.value)}
2739
+ placeholder="gpt-4.1-mini"
2740
+ />
2741
+ </div>
2742
+ <div class="form-group">
2743
+ <label>Turn Detection</label>
2744
+ <select value=${voiceTurnDetection} onchange=${(e) => setVoiceTurnDetection(e.target.value)}>
2745
+ <option value="server_vad">server_vad</option>
2746
+ <option value="semantic_vad">semantic_vad</option>
2747
+ <option value="none">none</option>
2748
+ </select>
2749
+ </div>
2750
+ <div class="form-group">
2751
+ <label>Fallback Mode</label>
2752
+ <select value=${voiceFallbackMode} onchange=${(e) => setVoiceFallbackMode(e.target.value)}>
2753
+ <option value="browser">browser</option>
2754
+ <option value="disabled">disabled</option>
2755
+ </select>
2756
+ </div>
2757
+ <div class="form-group">
2758
+ <label>Delegate Executor</label>
2759
+ <select value=${voiceDelegateExecutor} onchange=${(e) => setVoiceDelegateExecutor(e.target.value)}>
2760
+ <option value="codex-sdk">codex-sdk</option>
2761
+ <option value="copilot-sdk">copilot-sdk</option>
2762
+ <option value="claude-sdk">claude-sdk</option>
2763
+ <option value="gemini-sdk">gemini-sdk</option>
2764
+ <option value="opencode-sdk">opencode-sdk</option>
2765
+ </select>
2766
+ </div>
2767
+ </div>
2768
+ ${(voiceProvider === "auto" || voiceProvider === "openai") && html`
2769
+ <div class="form-group">
2770
+ <label>OpenAI Realtime API Key</label>
2771
+ <input
2772
+ type="password"
2773
+ value=${openaiRealtimeApiKey}
2774
+ oninput=${(e) => setOpenaiRealtimeApiKey(e.target.value)}
2775
+ placeholder="Optional - defaults to OPENAI_API_KEY"
2776
+ />
2777
+ <div class="hint">Leave blank to use OPENAI_API_KEY.</div>
2778
+ </div>
2779
+ `}
2780
+ ${(voiceProvider === "auto" || voiceProvider === "azure") && html`
2781
+ <div class="executor-grid">
2782
+ <div class="form-group">
2783
+ <label>Azure Realtime Endpoint</label>
2784
+ <input
2785
+ type="text"
2786
+ value=${azureOpenaiRealtimeEndpoint}
2787
+ oninput=${(e) => setAzureOpenaiRealtimeEndpoint(e.target.value)}
2788
+ placeholder="https://<resource>.openai.azure.com"
2789
+ />
2790
+ </div>
2791
+ <div class="form-group">
2792
+ <label>Azure Realtime Deployment</label>
2793
+ <input
2794
+ type="text"
2795
+ value=${azureOpenaiRealtimeDeployment}
2796
+ oninput=${(e) => setAzureOpenaiRealtimeDeployment(e.target.value)}
2797
+ placeholder="gpt-4o-realtime-preview"
2798
+ />
2799
+ </div>
2800
+ </div>
2801
+ <div class="form-group">
2802
+ <label>Azure Realtime API Key</label>
2803
+ <input
2804
+ type="password"
2805
+ value=${azureOpenaiRealtimeApiKey}
2806
+ oninput=${(e) => setAzureOpenaiRealtimeApiKey(e.target.value)}
2807
+ placeholder="Optional - defaults to AZURE_OPENAI_API_KEY"
2808
+ />
2809
+ </div>
2810
+ `}
2811
+ `}
2812
+ <//>
2813
+
2814
+ <${Section} title=":hammer: Infrastructure">
2573
2815
  <div class="form-group">
2574
2816
  <label style="display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none">
2575
2817
  <input type="checkbox" checked=${containerEnabled}
@@ -2673,6 +2915,14 @@ function App() {
2673
2915
  </tr>
2674
2916
  <tr><th>Workflow Auto-Install</th><td>${workflowAutoInstall ? "Enabled" : "Disabled"}</td></tr>
2675
2917
  <tr><th>Telegram</th><td>${telegramEnabled && telegramToken ? "Configured" : "Skipped"}</td></tr>
2918
+ <tr>
2919
+ <th>Voice</th>
2920
+ <td>
2921
+ ${voiceEnabled
2922
+ ? `${voiceProvider} (${voiceFallbackMode} fallback)`
2923
+ : "Disabled"}
2924
+ </td>
2925
+ </tr>
2676
2926
  ${profile === "advanced" ? html`
2677
2927
  <tr><th>Max Parallel</th><td>${maxParallel}</td></tr>
2678
2928
  <tr><th>Max Retries</th><td>${maxRetries}</td></tr>
@@ -2740,7 +2990,7 @@ function App() {
2740
2990
  `}
2741
2991
  ${oauthStatus === "received" && html`
2742
2992
  <div style="background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.35);border-radius:var(--radius-sm);padding:12px 16px;margin-bottom:16px;font-size:0.85rem;color:#4ade80;display:flex;align-items:flex-start;gap:10px">
2743
- <span style="font-size:1.2em;line-height:1">✅</span>
2993
+ <span style="font-size:1.2em;line-height:1">${iconText(":check:")}</span>
2744
2994
  <div>
2745
2995
  <strong>GitHub App authorized!</strong>
2746
2996
  ${oauthInstallationId && html` Installation ID: <code style="font-family:var(--font-mono);font-size:0.8em;color:#86efac">${oauthInstallationId}</code>.`}