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
@@ -446,7 +446,7 @@ async function runRepairAgent(triggerReason, details = "") {
446
446
  await sendTelegram(
447
447
  telegramChatId,
448
448
  [
449
- "🧰 Crash-loop detected. Launching repair agent.",
449
+ ":settings: Crash-loop detected. Launching repair agent.",
450
450
  `Trigger: ${triggerReason}`,
451
451
  details ? `Context: ${details}` : "",
452
452
  ]
@@ -483,7 +483,7 @@ async function runRepairAgent(triggerReason, details = "") {
483
483
  await sendTelegram(
484
484
  telegramChatId,
485
485
  [
486
- `✅ Repair agent completed via ${agentInfo.adapter}.`,
486
+ `:check: Repair agent completed via ${agentInfo.adapter}.`,
487
487
  "",
488
488
  summary.slice(0, 3500),
489
489
  ].join("\n"),
@@ -492,7 +492,7 @@ async function runRepairAgent(triggerReason, details = "") {
492
492
  } catch (err) {
493
493
  await sendTelegram(
494
494
  telegramChatId,
495
- `❌ Repair agent failed: ${err?.message || err}`,
495
+ `:close: Repair agent failed: ${err?.message || err}`,
496
496
  );
497
497
  return false;
498
498
  } finally {
@@ -510,7 +510,7 @@ async function runPrimaryAgentFallback(chatId, text, command) {
510
510
  const agentInfo = getPrimaryAgentInfo();
511
511
  await sendTelegram(
512
512
  chatId,
513
- `🤖 bosun is down. Running via sentinel fallback (${agentInfo.adapter})...`,
513
+ `:bot: bosun is down. Running via sentinel fallback (${agentInfo.adapter})...`,
514
514
  );
515
515
 
516
516
  const prompt = [
@@ -536,7 +536,7 @@ async function runPrimaryAgentFallback(chatId, text, command) {
536
536
  } catch (err) {
537
537
  await sendTelegram(
538
538
  chatId,
539
- `❌ Sentinel fallback failed: ${err?.message || err}`,
539
+ `:close: Sentinel fallback failed: ${err?.message || err}`,
540
540
  );
541
541
  return false;
542
542
  }
@@ -556,7 +556,7 @@ async function attemptMonitorRecovery(triggerReason) {
556
556
  await sendTelegram(
557
557
  telegramChatId,
558
558
  [
559
- "⚠️ Monitor crash-loop detected.",
559
+ ":alert: Monitor crash-loop detected.",
560
560
  `Window: ${Math.round(sentinelConfig.crashLoopWindowMs / 60000)}m | threshold: ${sentinelConfig.crashLoopThreshold}`,
561
561
  `Monitor-monitor: ${mmHealth.ok ? "healthy" : "degraded"} (${mmHealth.reason})`,
562
562
  "Attempting autonomous repair before restart.",
@@ -575,12 +575,12 @@ async function attemptMonitorRecovery(triggerReason) {
575
575
  const pidSuffix = pid ? ` (PID ${pid})` : "";
576
576
  await sendTelegram(
577
577
  telegramChatId,
578
- `✅ bosun recovered${pidSuffix}.`,
578
+ `:check: bosun recovered${pidSuffix}.`,
579
579
  );
580
580
  } catch (err) {
581
581
  await sendTelegram(
582
582
  telegramChatId,
583
- `❌ bosun auto-restart failed: ${err?.message || err}`,
583
+ `:close: bosun auto-restart failed: ${err?.message || err}`,
584
584
  );
585
585
  }
586
586
  }
@@ -1013,14 +1013,14 @@ async function handleStandaloneCommand(chatId, command, fullText) {
1013
1013
  */
1014
1014
  async function handlePing(chatId) {
1015
1015
  const monPid = readAlivePid(MONITOR_PID_FILE);
1016
- const monStatus = monPid ? `✅ running (PID ${monPid})` : " not running";
1016
+ const monStatus = monPid ? `:check: running (PID ${monPid})` : ":close: not running";
1017
1017
  const uptime = formatUptime(Date.now() - new Date(startedAt).getTime());
1018
1018
  await sendTelegram(
1019
1019
  chatId,
1020
1020
  [
1021
- "🏓 *Pong!*",
1021
+ ":target: *Pong!*",
1022
1022
  "",
1023
- `Sentinel: alive (${uptime})`,
1023
+ `Sentinel: :check: alive (${uptime})`,
1024
1024
  `Mode: ${mode}`,
1025
1025
  `Monitor: ${monStatus}`,
1026
1026
  `Host: \`${os.hostname()}\``,
@@ -1038,14 +1038,14 @@ async function handleStatus(chatId) {
1038
1038
  if (!existsSync(STATUS_FILE)) {
1039
1039
  await sendTelegram(
1040
1040
  chatId,
1041
- "📊 No status file found. bosun may not have run yet.",
1041
+ ":chart: No status file found. bosun may not have run yet.",
1042
1042
  );
1043
1043
  return;
1044
1044
  }
1045
1045
  const raw = await readFile(STATUS_FILE, "utf8");
1046
1046
  const data = JSON.parse(raw);
1047
1047
 
1048
- const lines = ["📊 *Orchestrator Status*", ""];
1048
+ const lines = [":chart: *Orchestrator Status*", ""];
1049
1049
 
1050
1050
  if (data.executor_mode) lines.push(`Mode: \`${data.executor_mode}\``);
1051
1051
  if (data.active_slots) lines.push(`Slots: \`${data.active_slots}\``);
@@ -1074,7 +1074,7 @@ async function handleStatus(chatId) {
1074
1074
 
1075
1075
  await sendTelegram(chatId, lines.join("\n"), { parseMode: "Markdown" });
1076
1076
  } catch (err) {
1077
- await sendTelegram(chatId, `❌ Error reading status: ${err.message}`);
1077
+ await sendTelegram(chatId, `:close: Error reading status: ${err.message}`);
1078
1078
  }
1079
1079
  }
1080
1080
 
@@ -1085,7 +1085,7 @@ async function handleStatus(chatId) {
1085
1085
  async function handleSentinelInfo(chatId) {
1086
1086
  const status = getSentinelStatus();
1087
1087
  const lines = [
1088
- "🛡️ *Telegram Sentinel*",
1088
+ ":shield: *Telegram Sentinel*",
1089
1089
  "",
1090
1090
  `PID: \`${process.pid}\``,
1091
1091
  `Mode: ${status.mode}`,
@@ -1112,22 +1112,22 @@ async function handleStartMonitor(chatId) {
1112
1112
  if (monPid) {
1113
1113
  await sendTelegram(
1114
1114
  chatId,
1115
- `✅ bosun is already running (PID ${monPid}).`,
1115
+ `:check: bosun is already running (PID ${monPid}).`,
1116
1116
  );
1117
1117
  return;
1118
1118
  }
1119
- await sendTelegram(chatId, "🚀 Starting bosun...");
1119
+ await sendTelegram(chatId, ":rocket: Starting bosun...");
1120
1120
  try {
1121
1121
  await ensureMonitorRunning("manual /start command");
1122
1122
  const pid = readAlivePid(MONITOR_PID_FILE);
1123
1123
  await sendTelegram(
1124
1124
  chatId,
1125
- `✅ bosun started${pid ? ` (PID ${pid})` : ""}.`,
1125
+ `:check: bosun started${pid ? ` (PID ${pid})` : ""}.`,
1126
1126
  );
1127
1127
  } catch (err) {
1128
1128
  await sendTelegram(
1129
1129
  chatId,
1130
- `❌ Failed to start bosun: ${err.message}`,
1130
+ `:close: Failed to start bosun: ${err.message}`,
1131
1131
  );
1132
1132
  }
1133
1133
  }
@@ -1139,10 +1139,10 @@ async function handleStartMonitor(chatId) {
1139
1139
  async function handleStopMonitor(chatId) {
1140
1140
  const monPid = readAlivePid(MONITOR_PID_FILE);
1141
1141
  if (!monPid) {
1142
- await sendTelegram(chatId, "ℹ️ bosun is not running.");
1142
+ await sendTelegram(chatId, ":help: bosun is not running.");
1143
1143
  return;
1144
1144
  }
1145
- await sendTelegram(chatId, `🛑 Stopping bosun (PID ${monPid})...`);
1145
+ await sendTelegram(chatId, `:close: Stopping bosun (PID ${monPid})...`);
1146
1146
  try {
1147
1147
  process.kill(monPid, "SIGTERM");
1148
1148
  // Wait for process to die
@@ -1162,13 +1162,13 @@ async function handleStopMonitor(chatId) {
1162
1162
  }
1163
1163
  }
1164
1164
  removePidFile(MONITOR_PID_FILE);
1165
- await sendTelegram(chatId, " bosun stopped.");
1165
+ await sendTelegram(chatId, ":check: bosun stopped.");
1166
1166
  monitorManualStopUntil = Date.now() + sentinelConfig.manualStopHoldMs;
1167
1167
  saveRecoveryState();
1168
1168
  // Transition to standalone mode after stopping monitor
1169
1169
  await transitionToStandalone("monitor manually stopped");
1170
1170
  } catch (err) {
1171
- await sendTelegram(chatId, `❌ Error stopping monitor: ${err.message}`);
1171
+ await sendTelegram(chatId, `:close: Error stopping monitor: ${err.message}`);
1172
1172
  }
1173
1173
  }
1174
1174
 
@@ -1181,7 +1181,7 @@ async function handleHelp(chatId) {
1181
1181
  const monStatus = monPid ? "running" : "stopped";
1182
1182
 
1183
1183
  const lines = [
1184
- "🛡️ *Sentinel Commands* (always available)",
1184
+ ":shield: *Sentinel Commands* (always available)",
1185
1185
  "",
1186
1186
  "/ping — Check sentinel + monitor liveness",
1187
1187
  "/status — Show cached orchestrator status",
@@ -1232,7 +1232,7 @@ async function handleMonitorCommand(chatId, text, command) {
1232
1232
  return;
1233
1233
  }
1234
1234
 
1235
- await sendTelegram(chatId, " Starting bosun in the background...");
1235
+ await sendTelegram(chatId, ":clock: Starting bosun in the background...");
1236
1236
 
1237
1237
  try {
1238
1238
  await ensureMonitorRunning(`command: ${command}`);
@@ -1247,7 +1247,7 @@ async function handleMonitorCommand(chatId, text, command) {
1247
1247
  if (!fallbackHandled) {
1248
1248
  await sendTelegram(
1249
1249
  chatId,
1250
- `❌ Failed to start bosun: ${err.message}\n\nYour command was not processed.`,
1250
+ `:close: Failed to start bosun: ${err.message}\n\nYour command was not processed.`,
1251
1251
  );
1252
1252
  }
1253
1253
  // Clear the failed commands
@@ -1559,7 +1559,7 @@ async function healthCheck() {
1559
1559
  await sendTelegram(
1560
1560
  telegramChatId,
1561
1561
  [
1562
- `🔥 ${tag} bosun crashed`,
1562
+ `:zap: ${tag} bosun crashed`,
1563
1563
  "",
1564
1564
  `Host: \`${host}\``,
1565
1565
  `Time: ${new Date().toISOString()}`,
@@ -1747,7 +1747,7 @@ export async function startSentinel(options = {}) {
1747
1747
  // Attempt crash notification
1748
1748
  sendTelegram(
1749
1749
  telegramChatId,
1750
- `🛡️❌ Sentinel crashed: ${err.message}\nHost: \`${os.hostname()}\``,
1750
+ `:shield::close: Sentinel crashed: ${err.message}\nHost: \`${os.hostname()}\``,
1751
1751
  { parseMode: "Markdown" },
1752
1752
  ).catch(() => {});
1753
1753
  stopSentinel();
package/ui/app.js CHANGED
@@ -61,6 +61,87 @@ const TABLET_MIN_WIDTH = 768;
61
61
  const COMPACT_NAV_MAX_WIDTH = 520;
62
62
  const RAIL_ICON_WIDTH = 54;
63
63
  const SIDEBAR_ICON_WIDTH = 54;
64
+ const APP_LOGO_SOURCES = ["/logo.png", "/logo.svg", "/favicon.png"];
65
+ const VOICE_LAUNCH_QUERY_KEYS = [
66
+ "launch",
67
+ "call",
68
+ "autostart",
69
+ "sessionId",
70
+ "executor",
71
+ "mode",
72
+ "model",
73
+ "vision",
74
+ "source",
75
+ "chat_id",
76
+ ];
77
+
78
+ function getAppLogoSource(index = 0) {
79
+ const safeIndex = Number.isFinite(index) ? Math.trunc(index) : 0;
80
+ if (safeIndex <= 0) return APP_LOGO_SOURCES[0];
81
+ if (safeIndex >= APP_LOGO_SOURCES.length) {
82
+ return APP_LOGO_SOURCES[APP_LOGO_SOURCES.length - 1];
83
+ }
84
+ return APP_LOGO_SOURCES[safeIndex];
85
+ }
86
+
87
+ function handleAppLogoLoadError(event) {
88
+ const target = event?.currentTarget;
89
+ if (!target) return;
90
+
91
+ const currentIndex = Number.parseInt(
92
+ String(target.dataset?.logoFallbackIndex || "0"),
93
+ 10,
94
+ );
95
+ const nextIndex = Number.isFinite(currentIndex) ? currentIndex + 1 : 1;
96
+ if (nextIndex >= APP_LOGO_SOURCES.length) return;
97
+
98
+ target.dataset.logoFallbackIndex = String(nextIndex);
99
+ target.src = getAppLogoSource(nextIndex);
100
+ }
101
+
102
+ function parseVoiceLaunchFromUrl() {
103
+ if (typeof window === "undefined") return null;
104
+ const params = new URLSearchParams(window.location.search || "");
105
+ const launch = String(params.get("launch") || "").trim().toLowerCase();
106
+ if (launch !== "meeting" && launch !== "voice") return null;
107
+
108
+ const callRaw = String(params.get("call") || "").trim().toLowerCase();
109
+ const call = callRaw === "video" ? "video" : "voice";
110
+ const explicitVision = String(params.get("vision") || "").trim().toLowerCase();
111
+ const initialVisionSource =
112
+ explicitVision === "camera" || explicitVision === "screen"
113
+ ? explicitVision
114
+ : call === "video"
115
+ ? "camera"
116
+ : null;
117
+
118
+ return {
119
+ tab: "chat",
120
+ detail: {
121
+ call,
122
+ initialVisionSource,
123
+ sessionId: String(params.get("sessionId") || "").trim() || null,
124
+ executor: String(params.get("executor") || "").trim() || null,
125
+ mode: String(params.get("mode") || "").trim() || null,
126
+ model: String(params.get("model") || "").trim() || null,
127
+ },
128
+ };
129
+ }
130
+
131
+ function scrubVoiceLaunchQuery() {
132
+ if (typeof window === "undefined" || !window.history?.replaceState) return;
133
+ const url = new URL(window.location.href);
134
+ let changed = false;
135
+ for (const key of VOICE_LAUNCH_QUERY_KEYS) {
136
+ if (url.searchParams.has(key)) {
137
+ url.searchParams.delete(key);
138
+ changed = true;
139
+ }
140
+ }
141
+ if (!changed) return;
142
+ const nextPath = `${url.pathname}${url.search}${url.hash}`;
143
+ window.history.replaceState(window.history.state, "", nextPath || "/");
144
+ }
64
145
 
65
146
  /* ── Module imports ── */
66
147
  import { ICONS } from "./modules/icons.js";
@@ -111,12 +192,18 @@ import {
111
192
  sessionsData,
112
193
  initSessionWsListener,
113
194
  } from "./components/session-list.js";
195
+ import {
196
+ activeAgent,
197
+ agentMode,
198
+ selectedModel,
199
+ } from "./components/agent-selector.js";
114
200
  import { WorkspaceSwitcher } from "./components/workspace-switcher.js";
115
201
  import { DiffViewer } from "./components/diff-viewer.js";
116
202
  import {
117
203
  CommandPalette,
118
204
  useCommandPalette,
119
205
  } from "./components/command-palette.js";
206
+ import { VoiceOverlay } from "./modules/voice-overlay.js";
120
207
 
121
208
  /* ── Tab imports ── */
122
209
  import { DashboardTab } from "./tabs/dashboard.js";
@@ -393,7 +480,7 @@ class TabErrorBoundary extends Component {
393
480
  return html`
394
481
  <div class="tab-error-boundary">
395
482
  <div class="tab-error-pulse">
396
- <span style="font-size:20px;color:#ef4444;">${resolveIcon("")}</span>
483
+ <span style="font-size:20px;color:#ef4444;">${resolveIcon(":alert:")}</span>
397
484
  </div>
398
485
  <div>
399
486
  <div style="font-size:14px;font-weight:600;margin-bottom:4px;color:var(--text-primary);">
@@ -524,7 +611,13 @@ function SidebarNav({ collapsed = false, onToggle }) {
524
611
  <div class="sidebar-brand-row">
525
612
  <div class="sidebar-brand">
526
613
  <div class="sidebar-logo">
527
- <img src="logo.png" alt="Bosun" class="app-logo-img" />
614
+ <img
615
+ src=${getAppLogoSource(0)}
616
+ alt="Bosun"
617
+ class="app-logo-img"
618
+ data-logo-fallback-index="0"
619
+ onError=${handleAppLogoLoadError}
620
+ />
528
621
  </div>
529
622
  ${!collapsed && html`<div class="sidebar-title">Bosun</div>`}
530
623
  </div>
@@ -540,10 +633,10 @@ function SidebarNav({ collapsed = false, onToggle }) {
540
633
  ${!collapsed && html`
541
634
  <div class="sidebar-actions">
542
635
  <button class="btn btn-primary btn-block" onClick=${() => createSession({ type: "primary" })}>
543
- <span class="btn-icon">${resolveIcon("")}</span> New Session
636
+ <span class="btn-icon">${resolveIcon(":plus:")}</span> New Session
544
637
  </button>
545
638
  <button class="btn btn-ghost btn-block" onClick=${() => navigateTo("tasks")}>
546
- <span class="btn-icon">${resolveIcon("📋")}</span> View Tasks
639
+ <span class="btn-icon">${resolveIcon(":clipboard:")}</span> View Tasks
547
640
  </button>
548
641
  </div>
549
642
  `}
@@ -554,7 +647,7 @@ function SidebarNav({ collapsed = false, onToggle }) {
554
647
  onClick=${() => createSession({ type: "primary" })}
555
648
  title="New Session"
556
649
  aria-label="New Session"
557
- >${resolveIcon("")}</button>
650
+ >${resolveIcon(":plus:")}</button>
558
651
  </div>
559
652
  `}
560
653
  <nav class="sidebar-nav" aria-label="Main navigation">
@@ -1027,25 +1120,25 @@ function MoreSheet({ open, onClose, onNavigate, onOpenBot }) {
1027
1120
  * ═══════════════════════════════════════════════ */
1028
1121
  const BOT_SCREENS = {
1029
1122
  home: {
1030
- title: "🎛️ Bosun Control Center",
1123
+ title: ":sliders: Bosun Control Center",
1031
1124
  body: "Manage your automation fleet.",
1032
1125
  keyboard: [
1033
- [{ text: "📊 Status", cmd: "/status" }, { text: "📋 Tasks", cmd: "/tasks" }, { text: "🤖 Agents", cmd: "/agents" }],
1034
- [{ text: "⚙️ Executor", go: "executor" }, { text: "🛰 Routing", go: "routing" }, { text: "🌳 Workspaces", go: "workspaces" }],
1035
- [{ text: "📁 Logs", cmd: "/logs" }, { text: "🏥 Health", cmd: "/health" }, { text: "🔄 Refresh", cmd: "/status" }],
1126
+ [{ text: ":chart: Status", cmd: "/status" }, { text: ":clipboard: Tasks", cmd: "/tasks" }, { text: ":bot: Agents", cmd: "/agents" }],
1127
+ [{ text: ":settings: Executor", go: "executor" }, { text: ":server: Routing", go: "routing" }, { text: ":git: Workspaces", go: "workspaces" }],
1128
+ [{ text: ":folder: Logs", cmd: "/logs" }, { text: ":heart: Health", cmd: "/health" }, { text: ":refresh: Refresh", cmd: "/status" }],
1036
1129
  ],
1037
1130
  },
1038
1131
  executor: {
1039
- title: "⚙️ Executor",
1132
+ title: ":settings: Executor",
1040
1133
  parent: "home",
1041
1134
  body: "Task execution slots, pause, resume, and parallelism.",
1042
1135
  keyboard: [
1043
- [{ text: "📊 Status", cmd: "/executor" }, { text: " Pause", cmd: "/pause" }, { text: "▶️ Resume", cmd: "/resume" }],
1044
- [{ text: "🔢 Max Parallel", go: "maxparallel" }, { text: "🔁 Retry Active", cmd: "/retrytask" }],
1136
+ [{ text: ":chart: Status", cmd: "/executor" }, { text: ":pause: Pause", cmd: "/pause" }, { text: ":play: Resume", cmd: "/resume" }],
1137
+ [{ text: ":hash: Max Parallel", go: "maxparallel" }, { text: ":repeat: Retry Active", cmd: "/retrytask" }],
1045
1138
  ],
1046
1139
  },
1047
1140
  maxparallel: {
1048
- title: "🔢 Max Parallel Slots",
1141
+ title: ":hash: Max Parallel Slots",
1049
1142
  parent: "executor",
1050
1143
  body: "Set the maximum number of concurrent task slots.",
1051
1144
  keyboard: [
@@ -1055,25 +1148,25 @@ const BOT_SCREENS = {
1055
1148
  ],
1056
1149
  },
1057
1150
  routing: {
1058
- title: "🛰 Routing & SDKs",
1151
+ title: ":server: Routing & SDKs",
1059
1152
  parent: "home",
1060
1153
  body: "SDK routing, kanban binding, and version info.",
1061
1154
  keyboard: [
1062
- [{ text: "🤖 SDK Status", cmd: "/sdk" }, { text: "📋 Kanban", cmd: "/kanban" }],
1063
- [{ text: "🌐 Version", cmd: "/version" }, { text: " Help", cmd: "/help" }],
1155
+ [{ text: ":bot: SDK Status", cmd: "/sdk" }, { text: ":clipboard: Kanban", cmd: "/kanban" }],
1156
+ [{ text: ":globe: Version", cmd: "/version" }, { text: ":help: Help", cmd: "/help" }],
1064
1157
  ],
1065
1158
  },
1066
1159
  workspaces: {
1067
- title: "🌳 Workspaces",
1160
+ title: ":git: Workspaces",
1068
1161
  parent: "home",
1069
1162
  body: "Git worktrees, logs, and task planning.",
1070
1163
  keyboard: [
1071
- [{ text: "📊 Fleet Status", cmd: "/status" }, { text: "📁 Logs", cmd: "/logs" }],
1072
- [{ text: "🗺️ Planner", go: "planner" }, { text: " Start Task", cmd: "/starttask" }],
1164
+ [{ text: ":chart: Fleet Status", cmd: "/status" }, { text: ":folder: Logs", cmd: "/logs" }],
1165
+ [{ text: ":grid: Planner", go: "planner" }, { text: ":check: Start Task", cmd: "/starttask" }],
1073
1166
  ],
1074
1167
  },
1075
1168
  planner: {
1076
- title: "🗺️ Task Planner",
1169
+ title: ":grid: Task Planner",
1077
1170
  parent: "workspaces",
1078
1171
  body: "Seed new tasks from the backlog into the active queue.",
1079
1172
  keyboard: [
@@ -1145,7 +1238,7 @@ function BotControlsSheet({ open, onClose }) {
1145
1238
  } else if (d?.executed === false && d?.error) {
1146
1239
  setCmdError(d.error);
1147
1240
  } else {
1148
- setCmdOutput(`✅ ${cmd} sent.`);
1241
+ setCmdOutput(`:check: ${cmd} sent.`);
1149
1242
  }
1150
1243
  } else {
1151
1244
  setCmdError(result?.error || "Command failed");
@@ -1172,7 +1265,7 @@ function BotControlsSheet({ open, onClose }) {
1172
1265
  </button>
1173
1266
  ${navStack.length > 1 ? html`
1174
1267
  <button class="btn btn-ghost btn-sm" type="button" onClick=${botGoHome} aria-label="Go to home">
1175
- ${iconText("🏠 Home")}
1268
+ ${iconText(":home: Home")}
1176
1269
  </button>
1177
1270
  ` : null}
1178
1271
  </div>
@@ -1188,7 +1281,7 @@ function BotControlsSheet({ open, onClose }) {
1188
1281
  ` : null}
1189
1282
 
1190
1283
  ${cmdError && !cmdLoading ? html`
1191
- <div class="bot-controls-result bot-controls-result-error">${iconText(`❌ ${cmdError}`)}</div>
1284
+ <div class="bot-controls-result bot-controls-result-error">${iconText(`:close: ${cmdError}`)}</div>
1192
1285
  ` : null}
1193
1286
 
1194
1287
  ${cmdOutput && !cmdLoading && !cmdError ? html`
@@ -1261,6 +1354,16 @@ function App() {
1261
1354
  };
1262
1355
  }, [isLoading]);
1263
1356
  const [isMoreOpen, setIsMoreOpen] = useState(false);
1357
+ const [voiceOverlayOpen, setVoiceOverlayOpen] = useState(false);
1358
+ const [voiceTier, setVoiceTier] = useState(2);
1359
+ const [voiceSessionId, setVoiceSessionId] = useState(null);
1360
+ const [voiceExecutor, setVoiceExecutor] = useState(null);
1361
+ const [voiceAgentMode, setVoiceAgentMode] = useState(null);
1362
+ const [voiceModel, setVoiceModel] = useState(null);
1363
+ const [voiceCallType, setVoiceCallType] = useState("voice");
1364
+ const [voiceInitialVisionSource, setVoiceInitialVisionSource] = useState(
1365
+ null,
1366
+ );
1264
1367
  const resizeRef = useRef(null);
1265
1368
  const [isCompactNav, setIsCompactNav] = useState(() => {
1266
1369
  const win = globalThis.window;
@@ -1566,6 +1669,125 @@ function App() {
1566
1669
  return () => globalThis.removeEventListener?.("beforeunload", onBeforeUnload);
1567
1670
  }, []);
1568
1671
 
1672
+ useEffect(() => {
1673
+ const handleOpenVoiceMode = async (event) => {
1674
+ try {
1675
+ const requestedCallType =
1676
+ String(event?.detail?.call || "").trim().toLowerCase() === "video"
1677
+ ? "video"
1678
+ : "voice";
1679
+ const requestedVisionSourceRaw = String(
1680
+ event?.detail?.initialVisionSource || "",
1681
+ )
1682
+ .trim()
1683
+ .toLowerCase();
1684
+ const requestedVisionSource =
1685
+ requestedVisionSourceRaw === "camera" ||
1686
+ requestedVisionSourceRaw === "screen"
1687
+ ? requestedVisionSourceRaw
1688
+ : requestedCallType === "video"
1689
+ ? "camera"
1690
+ : null;
1691
+ const currentExecutor =
1692
+ String(event?.detail?.executor || activeAgent.value || "").trim() ||
1693
+ null;
1694
+ const currentMode =
1695
+ String(event?.detail?.mode || agentMode.value || "").trim() || null;
1696
+ const currentModel =
1697
+ String(event?.detail?.model || selectedModel.value || "").trim() ||
1698
+ null;
1699
+ const explicitSessionId =
1700
+ String(event?.detail?.sessionId || "").trim() || null;
1701
+ let currentSessionId =
1702
+ explicitSessionId ||
1703
+ (selectedSessionId.value ? String(selectedSessionId.value) : null);
1704
+
1705
+ // Ensure voice calls always bind to a real chat session so transcript +
1706
+ // delegated agent output are persisted in shared history.
1707
+ if (!currentSessionId) {
1708
+ const created = await createSession({
1709
+ type: "primary",
1710
+ agent: currentExecutor || undefined,
1711
+ mode: currentMode || undefined,
1712
+ model: currentModel || undefined,
1713
+ });
1714
+ const createdId = String(created?.session?.id || "").trim();
1715
+ currentSessionId = createdId || null;
1716
+ if (currentSessionId) {
1717
+ selectedSessionId.value = currentSessionId;
1718
+ }
1719
+ }
1720
+
1721
+ if (!currentSessionId) {
1722
+ showToast("Could not create a chat session for voice mode.", "error");
1723
+ return;
1724
+ }
1725
+
1726
+ setVoiceSessionId(currentSessionId);
1727
+ setVoiceExecutor(currentExecutor);
1728
+ setVoiceAgentMode(currentMode);
1729
+ setVoiceModel(currentModel);
1730
+ setVoiceCallType(requestedCallType);
1731
+ setVoiceInitialVisionSource(requestedVisionSource);
1732
+
1733
+ const response = await fetch("/api/voice/config", { method: "GET" });
1734
+ const cfg = response.ok ? await response.json() : null;
1735
+ if (!cfg?.available) {
1736
+ showToast(cfg?.reason || "Voice mode is not available.", "error");
1737
+ return;
1738
+ }
1739
+ setVoiceTier(Number(cfg?.tier) === 1 ? 1 : 2);
1740
+ setVoiceOverlayOpen(true);
1741
+ } catch (err) {
1742
+ showToast(
1743
+ `Could not open voice mode: ${err?.message || "unknown error"}`,
1744
+ "error",
1745
+ );
1746
+ }
1747
+ };
1748
+
1749
+ globalThis.addEventListener?.("ve:open-voice-mode", handleOpenVoiceMode);
1750
+ return () =>
1751
+ globalThis.removeEventListener?.("ve:open-voice-mode", handleOpenVoiceMode);
1752
+ }, []);
1753
+
1754
+ useEffect(() => {
1755
+ const launch = parseVoiceLaunchFromUrl();
1756
+ if (!launch) return;
1757
+ let cancelled = false;
1758
+
1759
+ const start = async () => {
1760
+ if (launch.tab === "chat") {
1761
+ const launchSessionId = String(launch.detail?.sessionId || "").trim();
1762
+ if (launchSessionId) {
1763
+ selectedSessionId.value = launchSessionId;
1764
+ navigateTo("chat", {
1765
+ params: { sessionId: launchSessionId },
1766
+ replace: true,
1767
+ skipGuard: true,
1768
+ });
1769
+ } else {
1770
+ navigateTo("chat", { replace: true, skipGuard: true });
1771
+ }
1772
+ }
1773
+ await new Promise((resolve) => setTimeout(resolve, 60));
1774
+ if (cancelled) return;
1775
+ globalThis.dispatchEvent?.(
1776
+ new CustomEvent("ve:open-voice-mode", { detail: launch.detail }),
1777
+ );
1778
+ };
1779
+
1780
+ start()
1781
+ .catch(() => {})
1782
+ .finally(() => {
1783
+ scrubVoiceLaunchQuery();
1784
+ });
1785
+
1786
+ return () => {
1787
+ cancelled = true;
1788
+ };
1789
+ }, []);
1790
+
1569
1791
  useEffect(() => {
1570
1792
  const el = mainRef.current;
1571
1793
  if (!el) return;
@@ -1831,6 +2053,17 @@ function App() {
1831
2053
  open=${isBotOpen}
1832
2054
  onClose=${closeBot}
1833
2055
  />
2056
+ <${VoiceOverlay}
2057
+ visible=${voiceOverlayOpen}
2058
+ onClose=${() => setVoiceOverlayOpen(false)}
2059
+ tier=${voiceTier}
2060
+ sessionId=${voiceSessionId}
2061
+ executor=${voiceExecutor}
2062
+ mode=${voiceAgentMode}
2063
+ model=${voiceModel}
2064
+ callType=${voiceCallType}
2065
+ initialVisionSource=${voiceInitialVisionSource}
2066
+ />
1834
2067
  `;
1835
2068
  }
1836
2069
 
@@ -2398,7 +2398,7 @@ function Header() {
2398
2398
  return html`
2399
2399
  <header class="app-header">
2400
2400
  <div class="app-header-brand">
2401
- <img src="logo.png" alt="Bosun" class="app-logo-img" />
2401
+ <img src="/logo.png" alt="Bosun" class="app-logo-img" />
2402
2402
  <div class="app-header-title">Bosun</div>
2403
2403
  </div>
2404
2404
  <div class="connection-pill ${isConn ? "connected" : "disconnected"}">
@@ -913,10 +913,11 @@ export function AgentPicker() {
913
913
  >
914
914
  ${loading && html`<option disabled value="">Loading…</option>`}
915
915
  ${enabledAgents.map((agent) => {
916
- const name = EXECUTOR_DISPLAY_NAMES[agent.id] || agent.name;
917
- const busy = agent.busy ? " (busy)" : "";
916
+ const rawName = EXECUTOR_DISPLAY_NAMES[agent.id] || agent.name || "";
917
+ const name =
918
+ String(rawName).replace(/\s*\(busy\)\s*$/i, "").trim() || "Executor";
918
919
  return html`
919
- <option key=${agent.id} value=${agent.id}>${name}${busy}</option>
920
+ <option key=${agent.id} value=${agent.id}>${name}</option>
920
921
  `;
921
922
  })}
922
923
  </select>