bosun 0.36.2 → 0.36.4

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 (57) hide show
  1. package/agent-prompts.mjs +95 -0
  2. package/analyze-agent-work-helpers.mjs +308 -0
  3. package/analyze-agent-work.mjs +926 -0
  4. package/autofix.mjs +2 -0
  5. package/bosun.schema.json +101 -3
  6. package/codex-shell.mjs +85 -10
  7. package/desktop/main.mjs +871 -48
  8. package/desktop/preload.mjs +54 -1
  9. package/desktop-shortcut.mjs +90 -11
  10. package/git-editor-fix.mjs +273 -0
  11. package/mcp-registry.mjs +579 -0
  12. package/meeting-workflow-service.mjs +631 -0
  13. package/monitor.mjs +18 -103
  14. package/package.json +21 -2
  15. package/primary-agent.mjs +32 -12
  16. package/session-tracker.mjs +68 -0
  17. package/setup-web-server.mjs +20 -10
  18. package/setup.mjs +376 -83
  19. package/startup-service.mjs +51 -6
  20. package/stream-resilience.mjs +17 -7
  21. package/ui/app.js +164 -4
  22. package/ui/components/agent-selector.js +145 -1
  23. package/ui/components/chat-view.js +161 -15
  24. package/ui/components/session-list.js +2 -2
  25. package/ui/components/shared.js +188 -15
  26. package/ui/modules/icons.js +13 -0
  27. package/ui/modules/utils.js +44 -0
  28. package/ui/modules/voice-client-sdk.js +733 -0
  29. package/ui/modules/voice-overlay.js +128 -15
  30. package/ui/modules/voice.js +15 -6
  31. package/ui/setup.html +281 -81
  32. package/ui/styles/components.css +99 -3
  33. package/ui/styles/sessions.css +122 -14
  34. package/ui/styles.css +14 -0
  35. package/ui/tabs/agents.js +1 -1
  36. package/ui/tabs/chat.js +123 -14
  37. package/ui/tabs/control.js +16 -22
  38. package/ui/tabs/dashboard.js +85 -8
  39. package/ui/tabs/library.js +113 -17
  40. package/ui/tabs/settings.js +116 -2
  41. package/ui/tabs/tasks.js +388 -39
  42. package/ui/tabs/telemetry.js +0 -1
  43. package/ui/tabs/workflows.js +4 -0
  44. package/ui-server.mjs +400 -22
  45. package/update-check.mjs +41 -13
  46. package/voice-action-dispatcher.mjs +844 -0
  47. package/voice-agents-sdk.mjs +664 -0
  48. package/voice-auth-manager.mjs +164 -0
  49. package/voice-relay.mjs +1194 -0
  50. package/voice-tools.mjs +914 -0
  51. package/workflow-templates/agents.mjs +6 -2
  52. package/workflow-templates/github.mjs +154 -12
  53. package/workflow-templates.mjs +3 -0
  54. package/github-reconciler.mjs +0 -506
  55. package/merge-strategy.mjs +0 -1210
  56. package/pr-cleanup-daemon.mjs +0 -992
  57. package/workspace-reaper.mjs +0 -405
package/desktop/main.mjs CHANGED
@@ -1,4 +1,14 @@
1
- import { app, BrowserWindow, session } from "electron";
1
+ import {
2
+ app,
3
+ BrowserWindow,
4
+ dialog,
5
+ Menu,
6
+ shell,
7
+ Tray,
8
+ globalShortcut,
9
+ ipcMain,
10
+ session,
11
+ } from "electron";
2
12
  import { dirname, join, resolve } from "node:path";
3
13
  import { fileURLToPath, pathToFileURL } from "node:url";
4
14
  import { existsSync, readFileSync } from "node:fs";
@@ -6,19 +16,47 @@ import { execFileSync, spawn } from "node:child_process";
6
16
  import { request as httpRequest } from "node:http";
7
17
  import { request as httpsRequest } from "node:https";
8
18
  import { homedir } from "node:os";
19
+ import {
20
+ initShortcuts,
21
+ onShortcut,
22
+ getAllShortcuts,
23
+ getEffectiveAccelerator,
24
+ registerGlobalShortcuts,
25
+ unregisterGlobalShortcuts,
26
+ setShortcut,
27
+ resetShortcut,
28
+ resetAllShortcuts,
29
+ } from "./desktop-shortcuts.mjs";
9
30
 
10
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
32
 
12
33
  process.title = "bosun-desktop";
13
34
 
14
35
  let mainWindow = null;
36
+ let followWindow = null;
37
+ let tray = null;
38
+ let followWindowLaunchSignature = "";
15
39
  let shuttingDown = false;
16
40
  let uiServerStarted = false;
17
41
  let uiOrigin = null;
18
42
  let uiApi = null;
19
43
  let runtimeConfigLoaded = false;
44
+ /** True when the app is running as a persistent background / tray resident. */
45
+ let trayMode = false;
46
+ /** True when the main window should start hidden (background mode). */
47
+ let startHidden = false;
20
48
  const DEFAULT_TELEGRAM_UI_PORT = 3080;
21
49
 
50
+ /**
51
+ * Shorthand: returns the effective accelerator for a shortcut ID.
52
+ * Used throughout buildAppMenu() and refreshTrayMenu() so the menu
53
+ * always reflects the user's current shortcut customizations.
54
+ *
55
+ * @param {string} id Shortcut ID from DEFAULT_SHORTCUTS catalog.
56
+ * @returns {string|undefined} Accelerator string, or undefined if disabled.
57
+ */
58
+ const acc = (id) => getEffectiveAccelerator(id) ?? undefined;
59
+
22
60
  const DAEMON_PID_FILE = resolve(homedir(), ".cache", "bosun", "daemon.pid");
23
61
 
24
62
  // Local/private-network patterns — TLS cert bypass for the embedded UI server
@@ -34,6 +72,36 @@ function isLocalHost(hostname) {
34
72
  return LOCAL_HOSTNAME_RE.some((re) => re.test(hostname));
35
73
  }
36
74
 
75
+ /**
76
+ * Detect whether a graphical display environment is available.
77
+ * On Windows and macOS this is always true.
78
+ * On Linux we probe for an X11 / Wayland display server.
79
+ */
80
+ function isGuiEnvironment() {
81
+ if (process.platform === "win32" || process.platform === "darwin") return true;
82
+ // Linux / BSD: check for a display server
83
+ if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return true;
84
+ // Running inside a desktop session without a forwarded $DISPLAY is possible
85
+ // (e.g. XDG_SESSION_TYPE=wayland without WAYLAND_DISPLAY being exported).
86
+ if (process.env.XDG_SESSION_TYPE && process.env.XDG_SESSION_TYPE !== "tty") return true;
87
+ return false;
88
+ }
89
+
90
+ /**
91
+ * Returns true when the app should run as a persistent tray resident.
92
+ * Opt-out: set BOSUN_DESKTOP_TRAY=0 / BOSUN_DESKTOP_NO_TRAY=1
93
+ * Explicit opt-in: BOSUN_DESKTOP_TRAY=1
94
+ * Default: enabled on any GUI environment.
95
+ */
96
+ function isTrayModeEnabled() {
97
+ if (parseBoolEnv(process.env.BOSUN_DESKTOP_NO_TRAY, false)) return false;
98
+ const explicit = process.env.BOSUN_DESKTOP_TRAY;
99
+ if (explicit !== undefined && explicit !== "") {
100
+ return parseBoolEnv(explicit, true);
101
+ }
102
+ return isGuiEnvironment();
103
+ }
104
+
37
105
  function parseBoolEnv(value, fallback) {
38
106
  if (value === undefined || value === null) return fallback;
39
107
  const normalized = String(value).trim().toLowerCase();
@@ -132,6 +200,248 @@ function resolveBosunRuntimePath(file) {
132
200
  return resolve(resolveBosunRoot(), file);
133
201
  }
134
202
 
203
+ function encodeFollowParam(value) {
204
+ const normalized = String(value || "").trim();
205
+ return normalized;
206
+ }
207
+
208
+ function buildFollowWindowUrl(baseUrl, detail = {}) {
209
+ const target = new URL(baseUrl);
210
+ target.searchParams.set("follow", "1");
211
+ target.searchParams.set("launch", "voice");
212
+ target.searchParams.set(
213
+ "call",
214
+ String(detail.call || "").trim().toLowerCase() === "video"
215
+ ? "video"
216
+ : "voice",
217
+ );
218
+ const sessionId = encodeFollowParam(detail.sessionId);
219
+ const executor = encodeFollowParam(detail.executor);
220
+ const mode = encodeFollowParam(detail.mode);
221
+ const model = encodeFollowParam(detail.model);
222
+ const vision = encodeFollowParam(detail.initialVisionSource);
223
+ if (sessionId) target.searchParams.set("sessionId", sessionId);
224
+ if (executor) target.searchParams.set("executor", executor);
225
+ if (mode) target.searchParams.set("mode", mode);
226
+ if (model) target.searchParams.set("model", model);
227
+ if (vision) target.searchParams.set("vision", vision);
228
+ return target;
229
+ }
230
+
231
+ function setWindowVisible(win) {
232
+ if (!win || win.isDestroyed()) return;
233
+ if (win.isMinimized()) win.restore();
234
+ if (!win.isVisible()) win.show();
235
+ win.focus();
236
+ }
237
+
238
+ /**
239
+ * Navigate the main window's SPA to the given path.
240
+ * Falls back to a no-op if the window is not ready.
241
+ */
242
+ function navigateMainWindow(path) {
243
+ if (!mainWindow || mainWindow.isDestroyed()) return;
244
+ setWindowVisible(mainWindow);
245
+ if (uiOrigin) {
246
+ const safePath = JSON.stringify(path);
247
+ mainWindow.webContents
248
+ .executeJavaScript(
249
+ `(function(){
250
+ if (window.history && window.history.pushState) {
251
+ window.history.pushState(null, '', ${safePath});
252
+ window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
253
+ }
254
+ })()`,
255
+ )
256
+ .catch(() => {});
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Build and return the application menu template.
262
+ * This is called once during bootstrap and can be refreshed when
263
+ * pack status or update state changes.
264
+ */
265
+ function buildAppMenu() {
266
+ const isMac = process.platform === "darwin";
267
+ const isDev = !app.isPackaged;
268
+
269
+ const openUrl = (url) => shell.openExternal(url).catch(() => {});
270
+
271
+ /** @type {Electron.MenuItemConstructorOptions[]} */
272
+ const template = [
273
+ // ── macOS app menu ──────────────────────────────────────────────────────
274
+ ...(isMac
275
+ ? [
276
+ {
277
+ label: app.name,
278
+ submenu: [
279
+ { role: /** @type {const} */ ("about") },
280
+ { type: /** @type {const} */ ("separator") },
281
+ { role: /** @type {const} */ ("services") },
282
+ { type: /** @type {const} */ ("separator") },
283
+ { role: /** @type {const} */ ("hide") },
284
+ { role: /** @type {const} */ ("hideOthers") },
285
+ { role: /** @type {const} */ ("unhide") },
286
+ { type: /** @type {const} */ ("separator") },
287
+ { role: /** @type {const} */ ("quit") },
288
+ ],
289
+ },
290
+ ]
291
+ : []),
292
+
293
+ // ── File ────────────────────────────────────────────────────────────────
294
+ {
295
+ label: "File",
296
+ submenu: [
297
+ {
298
+ label: "New Chat",
299
+ accelerator: acc("app.newchat"),
300
+ click: () => navigateMainWindow("/"),
301
+ },
302
+ { type: /** @type {const} */ ("separator") },
303
+ {
304
+ label: "Settings",
305
+ accelerator: acc("app.settings"),
306
+ click: () => navigateMainWindow("/settings"),
307
+ },
308
+ { type: /** @type {const} */ ("separator") },
309
+ isMac
310
+ ? { role: /** @type {const} */ ("close") }
311
+ : { role: /** @type {const} */ ("quit") },
312
+ ],
313
+ },
314
+
315
+ // ── Edit ────────────────────────────────────────────────────────────────
316
+ { role: /** @type {const} */ ("editMenu") },
317
+
318
+ // ── View ────────────────────────────────────────────────────────────────
319
+ {
320
+ label: "View",
321
+ submenu: [
322
+ { role: /** @type {const} */ ("reload") },
323
+ { role: /** @type {const} */ ("forceReload") },
324
+ { type: /** @type {const} */ ("separator") },
325
+ { role: /** @type {const} */ ("resetZoom") },
326
+ { role: /** @type {const} */ ("zoomIn") },
327
+ { role: /** @type {const} */ ("zoomOut") },
328
+ { type: /** @type {const} */ ("separator") },
329
+ { role: /** @type {const} */ ("togglefullscreen") },
330
+ ...(isDev
331
+ ? [
332
+ { type: /** @type {const} */ ("separator") },
333
+ { role: /** @type {const} */ ("toggleDevTools") },
334
+ ]
335
+ : []),
336
+ ],
337
+ },
338
+
339
+ // ── Bosun ───────────────────────────────────────────────────────────────
340
+ {
341
+ label: "Bosun",
342
+ submenu: [
343
+ {
344
+ label: "Show Main Window",
345
+ accelerator: acc("bosun.focus"),
346
+ click: () => setWindowVisible(mainWindow),
347
+ },
348
+ {
349
+ label: "Voice Call",
350
+ accelerator: acc("bosun.voice.call"),
351
+ click: () => openFollowWindow({ call: "voice" }).catch(() => {}),
352
+ },
353
+ {
354
+ label: "Video Call",
355
+ accelerator: acc("bosun.voice.video"),
356
+ click: () => openFollowWindow({ call: "video" }).catch(() => {}),
357
+ },
358
+ {
359
+ label: "Toggle Voice Companion",
360
+ accelerator: acc("bosun.voice.toggle"),
361
+ click: () => {
362
+ if (!restoreFollowWindow()) setWindowVisible(mainWindow);
363
+ },
364
+ },
365
+ { type: /** @type {const} */ ("separator") },
366
+ {
367
+ label: "Dashboard",
368
+ accelerator: acc("bosun.navigate.home"),
369
+ click: () => navigateMainWindow("/"),
370
+ },
371
+ {
372
+ label: "Agents",
373
+ accelerator: acc("bosun.navigate.agents"),
374
+ click: () => navigateMainWindow("/agents"),
375
+ },
376
+ {
377
+ label: "Tasks",
378
+ accelerator: acc("bosun.navigate.tasks"),
379
+ click: () => navigateMainWindow("/tasks"),
380
+ },
381
+ {
382
+ label: "Logs",
383
+ accelerator: acc("bosun.navigate.logs"),
384
+ click: () => navigateMainWindow("/logs"),
385
+ },
386
+ {
387
+ label: "Settings",
388
+ accelerator: acc("bosun.navigate.settings"),
389
+ click: () => navigateMainWindow("/settings"),
390
+ },
391
+ { type: /** @type {const} */ ("separator") },
392
+ {
393
+ label: "Quick New Chat",
394
+ accelerator: acc("bosun.quickchat"),
395
+ click: () => navigateMainWindow("/"),
396
+ },
397
+ { type: /** @type {const} */ ("separator") },
398
+ {
399
+ label: "Check for Updates",
400
+ enabled: app.isPackaged,
401
+ click: () => maybeAutoUpdate().catch(() => {}),
402
+ },
403
+ ],
404
+ },
405
+
406
+ // ── Window ──────────────────────────────────────────────────────────────
407
+ { role: /** @type {const} */ ("windowMenu") },
408
+
409
+ // ── Help ────────────────────────────────────────────────────────────────
410
+ {
411
+ role: /** @type {const} */ ("help"),
412
+ submenu: [
413
+ {
414
+ label: "Bosun Documentation",
415
+ click: () => openUrl("https://github.com/virtengine/bosun#readme"),
416
+ },
417
+ {
418
+ label: "GitHub Repository",
419
+ click: () => openUrl("https://github.com/virtengine/bosun"),
420
+ },
421
+ {
422
+ label: "Report an Issue",
423
+ click: () =>
424
+ openUrl("https://github.com/virtengine/bosun/issues/new"),
425
+ },
426
+ { type: /** @type {const} */ ("separator") },
427
+ {
428
+ label: "Keyboard Shortcuts",
429
+ accelerator: acc("bosun.show.shortcuts"),
430
+ click: () => showShortcutsDialog(),
431
+ },
432
+ { type: /** @type {const} */ ("separator") },
433
+ {
434
+ label: "Toggle Developer Tools",
435
+ accelerator: isMac ? "Alt+Cmd+I" : "Ctrl+Shift+I",
436
+ click: () => mainWindow?.webContents?.toggleDevTools(),
437
+ },
438
+ ],
439
+ },
440
+ ];
441
+
442
+ return Menu.buildFromTemplate(template);
443
+ }
444
+
135
445
  async function loadBosunModule(file) {
136
446
  const modulePath = resolveBosunRuntimePath(file);
137
447
  return import(pathToFileURL(modulePath).href);
@@ -198,6 +508,9 @@ async function probeUiServer(url) {
198
508
  });
199
509
  }
200
510
 
511
+ /** Set to true if the daemon UI was not reachable at startup (offline mode). */
512
+ let bosunDaemonWasOffline = false;
513
+
201
514
  async function resolveDaemonUiUrl() {
202
515
  const useDaemon = parseBoolEnv(
203
516
  process.env.BOSUN_DESKTOP_USE_DAEMON_UI,
@@ -210,49 +523,19 @@ async function resolveDaemonUiUrl() {
210
523
  return ok ? base : null;
211
524
  }
212
525
 
526
+ /**
527
+ * Previously attempted to auto-start the bosun daemon from the Electron process.
528
+ * This behaviour has been intentionally removed: the desktop MUST NOT launch bosun.
529
+ *
530
+ * If the daemon is offline, buildUiUrl() sets bosunDaemonWasOffline=true and
531
+ * createMainWindow() injects an in-page banner with instructions on how to
532
+ * start bosun manually or configure auto-start via `bosun --setup`.
533
+ *
534
+ * Kept as a no-op so bootstrap() need not change its call site.
535
+ */
213
536
  async function ensureDaemonRunning() {
214
- const autoStart = parseBoolEnv(
215
- process.env.BOSUN_DESKTOP_AUTO_START_DAEMON,
216
- false,
217
- );
218
- if (!autoStart) return;
219
-
220
- const existing = getDaemonPid();
221
- if (existing) return;
222
-
223
- const ghosts = findGhostDaemonPids();
224
- if (ghosts.length > 0) return;
225
-
226
- const cliPath = resolveBosunRuntimePath("cli.mjs");
227
- if (!existsSync(cliPath)) {
228
- console.warn("[desktop] bosun CLI not found; daemon auto-start skipped");
229
- return;
230
- }
231
-
232
- try {
233
- const setupModule = await loadBosunModule("setup.mjs");
234
- if (setupModule?.shouldRunSetup?.()) {
235
- console.warn(
236
- "[desktop] setup required before daemon start; run: bosun --setup",
237
- );
238
- return;
239
- }
240
- } catch (err) {
241
- console.warn(
242
- "[desktop] unable to verify setup state; starting daemon anyway",
243
- );
244
- }
245
-
246
- const child = spawn(process.execPath, ["--run-as-node", cliPath, "--daemon"], {
247
- detached: true,
248
- stdio: "ignore",
249
- env: { ...process.env, BOSUN_DESKTOP: "1" },
250
- cwd: resolveBosunRoot(),
251
- windowsHide: true,
252
- });
253
- child.unref();
254
-
255
- await waitForDaemon(4000);
537
+ // Intentional no-op. Daemon auto-start from the desktop is permanently disabled.
538
+ // Detection happens in buildUiUrl(); the offline banner is shown in createMainWindow().
256
539
  }
257
540
 
258
541
  async function startUiServer() {
@@ -277,8 +560,18 @@ async function buildUiUrl() {
277
560
  const daemonUrl = await resolveDaemonUiUrl();
278
561
  if (daemonUrl) {
279
562
  uiOrigin = new URL(daemonUrl).origin;
563
+ // Authenticate the initial WebView load against the separately-running
564
+ // daemon using the desktop API key (set during bootstrap).
565
+ const desktopKey = process.env.BOSUN_DESKTOP_API_KEY;
566
+ if (desktopKey) {
567
+ const daemonTarget = new URL(daemonUrl);
568
+ daemonTarget.searchParams.set("desktopKey", desktopKey);
569
+ return daemonTarget.toString();
570
+ }
280
571
  return daemonUrl;
281
572
  }
573
+ // Daemon is not reachable — flag it so the window can show an offline banner.
574
+ bosunDaemonWasOffline = true;
282
575
  await startUiServer();
283
576
  const api = await loadUiServerModule();
284
577
  const uiServerUrl = api.getTelegramUiUrl();
@@ -287,9 +580,16 @@ async function buildUiUrl() {
287
580
  }
288
581
  const targetUrl = new URL(uiServerUrl);
289
582
  uiOrigin = targetUrl.origin;
290
- const sessionToken = api.getSessionToken();
291
- if (sessionToken) {
292
- targetUrl.searchParams.set("token", sessionToken);
583
+ // Prefer the non-expiring desktop API key over the TTL-based session token.
584
+ // Both result in the server setting a ve_session cookie and redirecting to /.
585
+ const desktopKey = process.env.BOSUN_DESKTOP_API_KEY;
586
+ if (desktopKey) {
587
+ targetUrl.searchParams.set("desktopKey", desktopKey);
588
+ } else {
589
+ const sessionToken = api.getSessionToken();
590
+ if (sessionToken) {
591
+ targetUrl.searchParams.set("token", sessionToken);
592
+ }
293
593
  }
294
594
  return targetUrl.toString();
295
595
  }
@@ -306,6 +606,9 @@ async function createMainWindow() {
306
606
  backgroundColor: "#0b0b0c",
307
607
  ...(existsSync(iconPath) ? { icon: iconPath } : {}),
308
608
  show: false,
609
+ // In tray mode Windows/Linux should not show a taskbar button for the
610
+ // hidden state — the tray icon IS the taskbar presence.
611
+ skipTaskbar: Boolean(trayMode && startHidden),
309
612
  webPreferences: {
310
613
  contextIsolation: true,
311
614
  nodeIntegration: false,
@@ -314,16 +617,474 @@ async function createMainWindow() {
314
617
  },
315
618
  });
316
619
 
620
+ mainWindow.on("close", (event) => {
621
+ // In tray mode, closing the window hides it to the tray instead of quitting.
622
+ if (trayMode && !shuttingDown) {
623
+ event.preventDefault();
624
+ mainWindow?.hide();
625
+ // On macOS, hide the dock icon when the window is hidden.
626
+ if (process.platform === "darwin") {
627
+ app.dock?.hide();
628
+ }
629
+ return;
630
+ }
631
+ });
632
+
317
633
  mainWindow.on("closed", () => {
318
634
  mainWindow = null;
319
635
  });
320
636
 
321
637
  mainWindow.once("ready-to-show", () => {
322
- mainWindow?.show();
638
+ if (!startHidden) {
639
+ mainWindow?.show();
640
+ }
641
+ });
642
+
643
+ // When the window is shown again, make it appear in the taskbar on
644
+ // Windows / Linux and restore the macOS dock icon.
645
+ mainWindow.on("show", () => {
646
+ mainWindow?.setSkipTaskbar(false);
647
+ if (process.platform === "darwin") {
648
+ app.dock?.show();
649
+ }
323
650
  });
324
651
 
325
652
  const uiUrl = await buildUiUrl();
326
653
  await mainWindow.loadURL(uiUrl);
654
+
655
+ // If the bosun daemon was not running when the desktop started, inject a
656
+ // non-blocking banner so the user knows and has clear instructions.
657
+ // The desktop NEVER auto-starts bosun — it is always a separate process.
658
+ if (bosunDaemonWasOffline) {
659
+ mainWindow.webContents.once("did-finish-load", () => {
660
+ mainWindow?.webContents
661
+ .executeJavaScript(
662
+ `(function() {
663
+ if (document.getElementById('bosun-offline-banner')) return;
664
+ const b = document.createElement('div');
665
+ b.id = 'bosun-offline-banner';
666
+ b.style.cssText = [
667
+ 'position:fixed','top:0','left:0','right:0','z-index:99999',
668
+ 'background:#7f1d1d','color:#fff','padding:10px 16px',
669
+ 'font:13px/1.5 monospace','display:flex','gap:12px',
670
+ 'align-items:center','justify-content:space-between',
671
+ ].join(';');
672
+ b.innerHTML = [
673
+ '<span>',
674
+ '\u26a0\ufe0f <strong>Bosun daemon is not running.</strong>',
675
+ ' This portal is in local mode — agents & tasks from your background service are unavailable.',
676
+ ' Start it: ',
677
+ '<code style="background:rgba(255,255,255,.15);padding:2px 6px;border-radius:3px">bosun --daemon</code>',
678
+ ' &bull; Configure auto-start: ',
679
+ '<code style="background:rgba(255,255,255,.15);padding:2px 6px;border-radius:3px">bosun --setup</code>',
680
+ '</span>',
681
+ '<button onclick="document.getElementById(\'bosun-offline-banner\').remove()"',
682
+ ' style="background:none;border:none;color:#fff;cursor:pointer;font-size:20px;line-height:1;padding:0 4px">&times;</button>',
683
+ ].join(' ');
684
+ document.body.prepend(b);
685
+ })()`
686
+ )
687
+ .catch(() => {});
688
+ });
689
+ }
690
+ }
691
+
692
+ async function createFollowWindow() {
693
+ if (followWindow && !followWindow.isDestroyed()) return followWindow;
694
+ const iconPath = resolveBosunRuntimePath("logo.png");
695
+ followWindow = new BrowserWindow({
696
+ width: 460,
697
+ height: 720,
698
+ minWidth: 380,
699
+ minHeight: 520,
700
+ backgroundColor: "#0b0b0c",
701
+ ...(existsSync(iconPath) ? { icon: iconPath } : {}),
702
+ show: false,
703
+ alwaysOnTop: true,
704
+ autoHideMenuBar: true,
705
+ skipTaskbar: false,
706
+ webPreferences: {
707
+ contextIsolation: true,
708
+ nodeIntegration: false,
709
+ sandbox: true,
710
+ backgroundThrottling: false,
711
+ preload: join(__dirname, "preload.mjs"),
712
+ },
713
+ });
714
+
715
+ followWindow.setAlwaysOnTop(true, "floating", 1);
716
+
717
+ followWindow.on("close", (event) => {
718
+ if (shuttingDown) return;
719
+ event.preventDefault();
720
+ followWindow?.hide();
721
+ });
722
+
723
+ followWindow.on("closed", () => {
724
+ followWindow = null;
725
+ followWindowLaunchSignature = "";
726
+ });
727
+
728
+ followWindow.once("ready-to-show", () => {
729
+ setWindowVisible(followWindow);
730
+ });
731
+
732
+ return followWindow;
733
+ }
734
+
735
+ async function openFollowWindow(detail = {}) {
736
+ const win = await createFollowWindow();
737
+ const baseUiUrl = await buildUiUrl();
738
+ const target = buildFollowWindowUrl(baseUiUrl, detail);
739
+ const signature = target.toString();
740
+ if (!win.webContents.getURL() || followWindowLaunchSignature !== signature) {
741
+ followWindowLaunchSignature = signature;
742
+ await win.loadURL(signature);
743
+ return;
744
+ }
745
+ setWindowVisible(win);
746
+ }
747
+
748
+ function hideFollowWindow() {
749
+ if (!followWindow || followWindow.isDestroyed()) return false;
750
+ followWindow.hide();
751
+ return true;
752
+ }
753
+
754
+ function restoreFollowWindow() {
755
+ if (!followWindow || followWindow.isDestroyed()) return false;
756
+ setWindowVisible(followWindow);
757
+ return true;
758
+ }
759
+
760
+ /** Rebuild and apply the tray context menu (called after state changes). */
761
+ function refreshTrayMenu() {
762
+ if (!tray) return;
763
+
764
+ const openUrl = (url) => shell.openExternal(url).catch(() => {});
765
+ const isDev = !app.isPackaged;
766
+
767
+ const menu = Menu.buildFromTemplate([
768
+ // ── Identity header ──────────────────────────────────────────────────
769
+ {
770
+ label: "VirtEngine",
771
+ enabled: false,
772
+ },
773
+ {
774
+ label: "Open",
775
+ click: () => setWindowVisible(mainWindow),
776
+ },
777
+ { type: /** @type {const} */ ("separator") },
778
+
779
+ // ── Launch Control ───────────────────────────────────────────────────
780
+ {
781
+ label: "Launch Control",
782
+ submenu: [
783
+ {
784
+ label: "Dashboard",
785
+ click: () => navigateMainWindow("/"),
786
+ },
787
+ {
788
+ label: "Agents",
789
+ click: () => navigateMainWindow("/agents"),
790
+ },
791
+ {
792
+ label: "Tasks",
793
+ click: () => navigateMainWindow("/tasks"),
794
+ },
795
+ {
796
+ label: "Logs",
797
+ click: () => navigateMainWindow("/logs"),
798
+ },
799
+ { type: /** @type {const} */ ("separator") },
800
+ {
801
+ label: "Voice Companion",
802
+ accelerator: FOLLOW_RESTORE_SHORTCUT,
803
+ click: () => {
804
+ if (!restoreFollowWindow()) setWindowVisible(mainWindow);
805
+ },
806
+ },
807
+ ],
808
+ },
809
+ {
810
+ label: "Restart to Apply Update",
811
+ enabled: app.isPackaged,
812
+ click: () => {
813
+ app.relaunch();
814
+ void shutdown("tray_restart_update");
815
+ },
816
+ },
817
+ {
818
+ label: "Preferences",
819
+ accelerator: "CmdOrCtrl+,",
820
+ click: () => navigateMainWindow("/settings"),
821
+ },
822
+ { type: /** @type {const} */ ("separator") },
823
+
824
+ // ── Troubleshooting ──────────────────────────────────────────────────
825
+ {
826
+ label: "Troubleshooting",
827
+ submenu: [
828
+ {
829
+ label: "Reload UI",
830
+ click: () => mainWindow?.webContents?.reload(),
831
+ },
832
+ {
833
+ label: "Force Reload UI",
834
+ click: () => mainWindow?.webContents?.reloadIgnoringCache(),
835
+ },
836
+ {
837
+ label: "Clear Cache & Reload",
838
+ click: async () => {
839
+ try {
840
+ await session.defaultSession.clearCache();
841
+ } catch {
842
+ // best effort
843
+ }
844
+ mainWindow?.webContents?.reloadIgnoringCache();
845
+ },
846
+ },
847
+ { type: /** @type {const} */ ("separator") },
848
+ {
849
+ label: "Toggle Developer Tools",
850
+ enabled: isDev || !app.isPackaged,
851
+ click: () => mainWindow?.webContents?.toggleDevTools(),
852
+ },
853
+ { type: /** @type {const} */ ("separator") },
854
+ {
855
+ label: "View Logs",
856
+ click: () => navigateMainWindow("/logs"),
857
+ },
858
+ {
859
+ label: "Report an Issue",
860
+ click: () =>
861
+ openUrl("https://github.com/virtengine/bosun/issues/new"),
862
+ },
863
+ ],
864
+ },
865
+ { type: /** @type {const} */ ("separator") },
866
+
867
+ // ── Startup / login ──────────────────────────────────────────────
868
+ ...( app.isPackaged
869
+ ? [
870
+ {
871
+ label: "Start at Login",
872
+ type: /** @type {const} */ ("checkbox"),
873
+ checked: app.getLoginItemSettings().openAtLogin,
874
+ click: (item) => {
875
+ app.setLoginItemSettings({ openAtLogin: item.checked });
876
+ },
877
+ },
878
+ ]
879
+ : []),
880
+ { type: /** @type {const} */ ("separator") },
881
+
882
+ // ── Quit ───────────────────────────────────────────────────────────
883
+ {
884
+ label: "Quit",
885
+ accelerator: process.platform === "darwin" ? "Cmd+Q" : "Ctrl+Q",
886
+ click: () => {
887
+ void shutdown("tray_quit");
888
+ },
889
+ },
890
+ ]);
891
+
892
+ tray.setContextMenu(menu);
893
+ }
894
+
895
+ function ensureTray() {
896
+ if (tray) return;
897
+ const iconPath = resolveBosunRuntimePath("logo.png");
898
+ if (!existsSync(iconPath)) return;
899
+
900
+ tray = new Tray(iconPath);
901
+ tray.setToolTip("Bosun — AI Control Center");
902
+
903
+ refreshTrayMenu();
904
+
905
+ // Single click: show/restore the main window (or follow window).
906
+ tray.on("click", () => {
907
+ if (mainWindow && !mainWindow.isDestroyed()) {
908
+ if (mainWindow.isVisible() && mainWindow.isFocused()) {
909
+ mainWindow.hide();
910
+ if (process.platform === "darwin") app.dock?.hide();
911
+ } else {
912
+ setWindowVisible(mainWindow);
913
+ }
914
+ } else {
915
+ void createMainWindow();
916
+ }
917
+ });
918
+
919
+ // Double-click on Windows brings up the window unambiguously.
920
+ tray.on("double-click", () => {
921
+ setWindowVisible(mainWindow);
922
+ });
923
+ }
924
+
925
+ /**
926
+ * Display a native shortcuts reference dialog.
927
+ * Groups shortcuts by their group property so the list is easy to scan.
928
+ */
929
+ function showShortcutsDialog() {
930
+ const shortcuts = getAllShortcuts();
931
+
932
+ // Group entries
933
+ /** @type {Map<string, typeof shortcuts>} */
934
+ const groups = new Map();
935
+ for (const s of shortcuts) {
936
+ const g = s.group || "Other";
937
+ if (!groups.has(g)) groups.set(g, []);
938
+ groups.get(g).push(s);
939
+ }
940
+
941
+ const isMac = process.platform === "darwin";
942
+ const modSymbol = isMac ? "⌘" : "Ctrl";
943
+ const shiftSym = isMac ? "⇧" : "+Shift";
944
+
945
+ const lines = [];
946
+ for (const [group, items] of groups) {
947
+ lines.push(`── ${group} ──`);
948
+ for (const s of items) {
949
+ const display = s.isDisabled
950
+ ? "(disabled)"
951
+ : (s.accelerator ?? "(none)")
952
+ .replace(/CmdOrCtrl/g, modSymbol)
953
+ .replace(/CommandOrControl/g, modSymbol)
954
+ .replace(/Shift\+/g, `${shiftSym}+`);
955
+ const custom = s.isCustomized ? " ★" : "";
956
+ lines.push(` ${s.label.padEnd(30)} ${display}${custom}`);
957
+ }
958
+ lines.push("");
959
+ }
960
+ lines.push("★ = customized from default");
961
+ lines.push("");
962
+ lines.push("To customize shortcuts, edit:");
963
+ lines.push(` ${resolveDesktopConfigDir()}/desktop-shortcuts.json`);
964
+
965
+ dialog
966
+ .showMessageBox(mainWindow ?? undefined, {
967
+ type: "info",
968
+ title: "Bosun — Keyboard Shortcuts",
969
+ message: "Keyboard Shortcuts Reference",
970
+ detail: lines.join("\n"),
971
+ buttons: ["OK"],
972
+ })
973
+ .catch(() => {});
974
+ }
975
+
976
+ /**
977
+ * Wire all shortcut action handlers and register global shortcuts.
978
+ * Called once during bootstrap, after the config dir is known.
979
+ *
980
+ * @param {string} configDir
981
+ */
982
+ function initAndRegisterShortcuts(configDir) {
983
+ // Initialise the shortcuts manager (loads user customizations).
984
+ initShortcuts(configDir);
985
+
986
+ // ── Register action handlers ───────────────────────────────────────────
987
+ // Global: fire from anywhere on the desktop
988
+ onShortcut("bosun.focus", () => {
989
+ setWindowVisible(mainWindow);
990
+ });
991
+
992
+ onShortcut("bosun.quickchat", () => {
993
+ setWindowVisible(mainWindow);
994
+ navigateMainWindow("/");
995
+ });
996
+
997
+ onShortcut("bosun.voice.call", () => {
998
+ openFollowWindow({ call: "voice" }).catch((err) =>
999
+ console.warn("[shortcuts] voice.call failed:", err?.message || err),
1000
+ );
1001
+ });
1002
+
1003
+ onShortcut("bosun.voice.video", () => {
1004
+ openFollowWindow({ call: "video" }).catch((err) =>
1005
+ console.warn("[shortcuts] voice.video failed:", err?.message || err),
1006
+ );
1007
+ });
1008
+
1009
+ onShortcut("bosun.voice.toggle", () => {
1010
+ if (!restoreFollowWindow()) setWindowVisible(mainWindow);
1011
+ });
1012
+
1013
+ // Local: navigation (also in menu, but registered here for completeness)
1014
+ onShortcut("bosun.navigate.home", () => navigateMainWindow("/"));
1015
+ onShortcut("bosun.navigate.agents", () => navigateMainWindow("/agents"));
1016
+ onShortcut("bosun.navigate.tasks", () => navigateMainWindow("/tasks"));
1017
+ onShortcut("bosun.navigate.logs", () => navigateMainWindow("/logs"));
1018
+ onShortcut("bosun.navigate.settings", () => navigateMainWindow("/settings"));
1019
+ onShortcut("app.newchat", () => navigateMainWindow("/"));
1020
+ onShortcut("app.settings", () => navigateMainWindow("/settings"));
1021
+ onShortcut("bosun.show.shortcuts", () => showShortcutsDialog());
1022
+
1023
+ // ── Register global shortcuts with the OS ────────────────────────────
1024
+ registerGlobalShortcuts();
1025
+ }
1026
+
1027
+ function registerDesktopIpc() {
1028
+ ipcMain.handle("bosun:desktop:follow:open", async (_event, detail) => {
1029
+ await openFollowWindow(detail || {});
1030
+ return { ok: true };
1031
+ });
1032
+ ipcMain.handle("bosun:desktop:follow:hide", async () => {
1033
+ return { ok: hideFollowWindow() };
1034
+ });
1035
+ ipcMain.handle("bosun:desktop:follow:restore", async () => {
1036
+ return { ok: restoreFollowWindow() };
1037
+ });
1038
+
1039
+ // ── Shortcuts IPC ───────────────────────────────────────────────────
1040
+ /** Returns the full shortcuts catalog with effective accelerators. */
1041
+ ipcMain.handle("bosun:shortcuts:list", () => getAllShortcuts());
1042
+
1043
+ /**
1044
+ * Set a custom accelerator for a shortcut.
1045
+ * Payload: { id: string, accelerator: string | null }
1046
+ * Pass null to disable the shortcut.
1047
+ * Returns: { ok: boolean, error?: string }
1048
+ * Side-effects: re-registers global shortcuts + rebuilds the app menu.
1049
+ */
1050
+ ipcMain.handle("bosun:shortcuts:set", (_event, { id, accelerator }) => {
1051
+ const result = setShortcut(id, accelerator);
1052
+ if (result.ok) {
1053
+ // Rebuild the menu so the new accelerator is reflected immediately.
1054
+ Menu.setApplicationMenu(buildAppMenu());
1055
+ refreshTrayMenu();
1056
+ }
1057
+ return result;
1058
+ });
1059
+
1060
+ /**
1061
+ * Reset a single shortcut to its default.
1062
+ * Payload: { id: string }
1063
+ */
1064
+ ipcMain.handle("bosun:shortcuts:reset", (_event, { id }) => {
1065
+ const result = resetShortcut(id);
1066
+ if (result.ok) {
1067
+ Menu.setApplicationMenu(buildAppMenu());
1068
+ refreshTrayMenu();
1069
+ }
1070
+ return result;
1071
+ });
1072
+
1073
+ /** Reset ALL shortcuts to defaults. */
1074
+ ipcMain.handle("bosun:shortcuts:resetAll", () => {
1075
+ const result = resetAllShortcuts();
1076
+ if (result.ok) {
1077
+ Menu.setApplicationMenu(buildAppMenu());
1078
+ refreshTrayMenu();
1079
+ }
1080
+ return result;
1081
+ });
1082
+
1083
+ /** Show the native keyboard shortcuts reference dialog. */
1084
+ ipcMain.handle("bosun:shortcuts:showDialog", () => {
1085
+ showShortcutsDialog();
1086
+ return { ok: true };
1087
+ });
327
1088
  }
328
1089
 
329
1090
  async function bootstrap() {
@@ -344,6 +1105,18 @@ async function bootstrap() {
344
1105
  process.chdir(resolveBosunRoot());
345
1106
  await loadRuntimeConfig();
346
1107
 
1108
+ // Provision (or reload) the dedicated Electron desktop API key.
1109
+ // The key is stored at {configDir}/desktop-api-key.json and is set in
1110
+ // process.env so that the in-process UI server can validate it without
1111
+ // importing the module directly (avoids circular dependency).
1112
+ try {
1113
+ const keyMod = await loadBosunModule("desktop-api-key.mjs");
1114
+ const desktopApiKey = keyMod.ensureDesktopApiKey(resolveDesktopConfigDir());
1115
+ process.env.BOSUN_DESKTOP_API_KEY = desktopApiKey;
1116
+ } catch (err) {
1117
+ console.warn("[desktop] could not load desktop-api-key module:", err?.message || err);
1118
+ }
1119
+
347
1120
  // Bypass TLS verification for the local embedded UI server.
348
1121
  // setCertificateVerifyProc works at the OpenSSL level — it fires before
349
1122
  // the higher-level `certificate-error` event and stops the repeated
@@ -357,7 +1130,44 @@ async function bootstrap() {
357
1130
  });
358
1131
 
359
1132
  await ensureDaemonRunning();
1133
+
1134
+ // Initialise shortcuts (loads user config) and register globals.
1135
+ // Must happen before buildAppMenu() so acc() returns correct values.
1136
+ initAndRegisterShortcuts(resolveDesktopConfigDir());
1137
+
1138
+ Menu.setApplicationMenu(buildAppMenu());
1139
+
1140
+ // Determine tray / background mode before creating any windows.
1141
+ trayMode = isTrayModeEnabled();
1142
+ // In tray mode the window starts hidden by default (background resident).
1143
+ // Set BOSUN_DESKTOP_START_HIDDEN=0 to open the window on every launch.
1144
+ startHidden = trayMode
1145
+ ? parseBoolEnv(process.env.BOSUN_DESKTOP_START_HIDDEN, true)
1146
+ : false;
1147
+
1148
+ if (trayMode) {
1149
+ // Always create the tray icon first so the app has a presence even
1150
+ // before the window is ready.
1151
+ ensureTray();
1152
+ // On macOS, hide the dock icon in background mode unless the user has
1153
+ // explicitly disabled it.
1154
+ if (
1155
+ process.platform === "darwin"
1156
+ && !parseBoolEnv(process.env.BOSUN_DESKTOP_SHOW_DOCK, false)
1157
+ ) {
1158
+ app.dock?.hide();
1159
+ }
1160
+ }
1161
+
1162
+ registerDesktopIpc();
360
1163
  await createMainWindow();
1164
+
1165
+ // In normal (non-background) mode the tray is still useful as an
1166
+ // indicator and quick-access — create it after the window is up.
1167
+ if (!trayMode) {
1168
+ ensureTray();
1169
+ }
1170
+
361
1171
  await maybeAutoUpdate();
362
1172
  } catch (error) {
363
1173
  console.error("[desktop] startup failed", error);
@@ -403,6 +1213,13 @@ async function shutdown(reason) {
403
1213
 
404
1214
  app.on("before-quit", () => {
405
1215
  shuttingDown = true;
1216
+ // Unregister all custom global shortcuts before Electron's own cleanup.
1217
+ try { unregisterGlobalShortcuts(); } catch { /* ignore */ }
1218
+ globalShortcut.unregisterAll();
1219
+ if (tray) {
1220
+ tray.destroy();
1221
+ tray = null;
1222
+ }
406
1223
  try {
407
1224
  if (uiServerStarted && uiApi?.stopTelegramUiServer) {
408
1225
  uiApi.stopTelegramUiServer();
@@ -430,12 +1247,18 @@ app.on(
430
1247
  );
431
1248
 
432
1249
  app.on("window-all-closed", () => {
1250
+ // In tray mode the app intentionally keeps running with no open windows.
1251
+ // Only shut down when quitting explicitly (e.g. tray menu Quit or Cmd+Q).
1252
+ if (trayMode) return;
433
1253
  void shutdown("window_all_closed");
434
1254
  });
435
1255
 
436
1256
  app.on("activate", () => {
437
- if (!mainWindow) {
1257
+ // macOS: clicking the dock icon (re-)shows the app.
1258
+ if (!mainWindow || mainWindow.isDestroyed()) {
438
1259
  void createMainWindow();
1260
+ } else {
1261
+ setWindowVisible(mainWindow);
439
1262
  }
440
1263
  });
441
1264