bosun 0.30.0 → 0.31.0

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.
@@ -42,6 +42,7 @@ function ensureElectronInstalled() {
42
42
  const result = spawnSync("npm", ["install"], {
43
43
  cwd: desktopDir,
44
44
  stdio: "inherit",
45
+ shell: process.platform === "win32",
45
46
  env: process.env,
46
47
  });
47
48
  return result.status === 0 && existsSync(electronBin);
@@ -60,6 +61,7 @@ function launch() {
60
61
 
61
62
  const child = spawn(electronBin, args, {
62
63
  stdio: "inherit",
64
+ shell: process.platform === "win32",
63
65
  env: {
64
66
  ...process.env,
65
67
  BOSUN_DESKTOP: "1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
@@ -28,21 +28,43 @@ const MAX_PORT_ATTEMPTS = 20;
28
28
 
29
29
  const MODELS = {
30
30
  copilot: [
31
- { value: "claude-sonnet-4", label: "Claude Sonnet 4 (recommended)", recommended: true },
32
- { value: "claude-opus-4", label: "Claude Opus 4" },
33
- { value: "gpt-4.1", label: "GPT-4.1" },
34
- { value: "o3", label: "o3" },
35
- { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
31
+ { value: "claude-opus-4.6", label: "claude-opus-4.6", recommended: true },
32
+ { value: "claude-sonnet-4.6", label: "claude-sonnet-4.6" },
33
+ { value: "claude-opus-4.5", label: "claude-opus-4.5" },
34
+ { value: "claude-sonnet-4.5", label: "claude-sonnet-4.5" },
35
+ { value: "claude-sonnet-4", label: "claude-sonnet-4" },
36
+ { value: "claude-haiku-4.5", label: "claude-haiku-4.5" },
37
+ { value: "gpt-5.2-codex", label: "gpt-5.2-codex" },
38
+ { value: "gpt-5.3-codex", label: "gpt-5.3-codex" },
39
+ { value: "gpt-5.1-codex", label: "gpt-5.1-codex" },
40
+ { value: "gpt-5.1-codex-mini", label: "gpt-5.1-codex-mini" },
41
+ { value: "gpt-5.1-codex-max", label: "gpt-5.1-codex-max" },
42
+ { value: "gpt-5.2", label: "gpt-5.2" },
43
+ { value: "gpt-5.1", label: "gpt-5.1" },
44
+ { value: "gpt-5-mini", label: "gpt-5-mini" },
45
+ { value: "gemini-3.1-pro", label: "gemini-3.1-pro" },
46
+ { value: "gemini-3-pro", label: "gemini-3-pro" },
47
+ { value: "gemini-3-flash", label: "gemini-3-flash" },
48
+ { value: "gemini-2.5-pro", label: "gemini-2.5-pro" },
49
+ { value: "grok-code-fast-1", label: "grok-code-fast-1" },
36
50
  ],
37
51
  codex: [
38
- { value: "o3", label: "o3 (recommended)", recommended: true },
39
- { value: "o4-mini", label: "o4-mini" },
40
- { value: "gpt-4.1", label: "gpt-4.1" },
41
- { value: "codex-mini-latest", label: "codex-mini-latest" },
52
+ { value: "gpt-5.3-codex", label: "gpt-5.3-codex", recommended: true },
53
+ { value: "gpt-5.2-codex", label: "gpt-5.2-codex" },
54
+ { value: "gpt-5.1-codex", label: "gpt-5.1-codex" },
55
+ { value: "gpt-5.1-codex-mini", label: "gpt-5.1-codex-mini" },
56
+ { value: "gpt-5.1-codex-max", label: "gpt-5.1-codex-max" },
57
+ { value: "gpt-5.2", label: "gpt-5.2" },
58
+ { value: "gpt-5.1", label: "gpt-5.1" },
59
+ { value: "gpt-5-mini", label: "gpt-5-mini" },
42
60
  ],
43
61
  claude: [
44
- { value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4 (recommended)", recommended: true },
45
- { value: "claude-opus-4-20250514", label: "Claude Opus 4" },
62
+ { value: "claude-opus-4.6", label: "claude-opus-4.6", recommended: true },
63
+ { value: "claude-sonnet-4.6", label: "claude-sonnet-4.6" },
64
+ { value: "claude-opus-4.5", label: "claude-opus-4.5" },
65
+ { value: "claude-sonnet-4.5", label: "claude-sonnet-4.5" },
66
+ { value: "claude-sonnet-4", label: "claude-sonnet-4" },
67
+ { value: "claude-haiku-4.5", label: "claude-haiku-4.5" },
46
68
  ],
47
69
  };
48
70
 
@@ -361,6 +361,36 @@ const AGENT_SELECTOR_STYLES = `
361
361
  padding: 8px 12px;
362
362
  }
363
363
  }
364
+
365
+ /* ── Native Agent Select ── */
366
+ .agent-picker-native {
367
+ appearance: none;
368
+ -webkit-appearance: none;
369
+ background: var(--tg-theme-secondary-bg-color, #1e1e2e);
370
+ border: 1px solid rgba(255,255,255,0.08);
371
+ border-radius: 8px;
372
+ color: var(--tg-theme-text-color, #fff);
373
+ font-size: 12px;
374
+ font-weight: 500;
375
+ padding: 5px 28px 5px 28px;
376
+ cursor: pointer;
377
+ min-width: 120px;
378
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath fill='%23999' d='M1 3l4 4 4-4'/%3E%3C/svg%3E");
379
+ background-repeat: no-repeat;
380
+ background-position: right 8px center;
381
+ transition: border-color 0.2s ease;
382
+ }
383
+ .agent-picker-native:hover {
384
+ border-color: rgba(255,255,255,0.15);
385
+ }
386
+ .agent-picker-native:focus {
387
+ outline: none;
388
+ border-color: var(--tg-theme-button-color, #3b82f6);
389
+ }
390
+ .agent-picker-native option {
391
+ background: #1a1a2e;
392
+ color: #fff;
393
+ }
364
394
  `;
365
395
 
366
396
  let _agentStylesInjected = false;
@@ -480,106 +510,41 @@ export function AgentModeSelector() {
480
510
  * ═══════════════════════════════════════════════ */
481
511
 
482
512
  export function AgentPicker() {
483
- const [open, setOpen] = useState(false);
484
- const wrapRef = useRef(null);
485
513
  const agents = availableAgents.value;
486
- const current = activeAgentInfo.value;
514
+ const current = activeAgent.value;
487
515
  const loading = agentSelectorLoading.value;
488
516
 
489
- // Close on outside click
490
- useEffect(() => {
491
- if (!open) return;
492
- const handler = (e) => {
493
- if (wrapRef.current && !wrapRef.current.contains(e.target)) {
494
- setOpen(false);
495
- }
496
- };
497
- document.addEventListener("pointerdown", handler, true);
498
- return () => document.removeEventListener("pointerdown", handler, true);
499
- }, [open]);
500
-
501
- // Close on Escape
502
- useEffect(() => {
503
- if (!open) return;
504
- const handler = (e) => { if (e.key === "Escape") setOpen(false); };
505
- document.addEventListener("keydown", handler);
506
- return () => document.removeEventListener("keydown", handler);
507
- }, [open]);
508
-
509
- const handleToggle = useCallback(() => {
510
- haptic("light");
511
- setOpen((v) => !v);
512
- }, []);
513
-
514
- const handleSelect = useCallback((agentId) => {
515
- if (agentId === activeAgent.value) {
516
- setOpen(false);
517
- return;
518
- }
517
+ const handleChange = useCallback((e) => {
518
+ const agentId = e.target.value;
519
+ if (agentId === activeAgent.value) return;
519
520
  haptic("medium");
520
521
  switchAgent(agentId);
521
- setOpen(false);
522
522
  }, []);
523
523
 
524
- const currentIcon = current ? (AGENT_ICONS[current.id] || "🔌") : "🔌";
525
- const currentName = current ? current.name : (loading ? "Loading…" : "Select Agent");
524
+ const currentIcon = AGENT_ICONS[current] || "";
526
525
 
527
526
  return html`
528
- <div class="agent-picker-wrap" ref=${wrapRef}>
529
- <button
530
- class="agent-picker-btn ${open ? "open" : ""}"
531
- onClick=${handleToggle}
532
- aria-haspopup="listbox"
533
- aria-expanded=${open}
527
+ <div class="agent-picker-wrap">
528
+ <span class="picker-icon" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);font-size:13px;pointer-events:none;z-index:1">${currentIcon}</span>
529
+ <select
530
+ class="agent-picker-native"
531
+ value=${current}
532
+ onChange=${handleChange}
533
+ disabled=${loading}
534
534
  title="Select AI agent"
535
535
  >
536
- <span class="picker-icon">${currentIcon}</span>
537
- <span>${currentName}</span>
538
- <span class="agent-picker-chevron">▾</span>
539
- </button>
540
-
541
- ${open && html`
542
- <div class="agent-picker-backdrop" onClick=${() => setOpen(false)} />
543
- <div class="agent-picker-dropdown" role="listbox" aria-label="Available agents">
544
- ${agents.length === 0 && html`
545
- <div style="padding: 12px; text-align: center; color: var(--tg-theme-hint-color, #888); font-size: 12px;">
546
- ${loading ? "Loading agents…" : "No agents available"}
547
- </div>
548
- `}
549
- ${agents.map((agent) => {
550
- const isActive = agent.id === activeAgent.value;
551
- const icon = AGENT_ICONS[agent.id] || "🔌";
552
- const statusClass = agent.busy ? "busy" : agent.available ? "available" : "offline";
553
- const providerColor = PROVIDER_COLORS[agent.provider] || "#6b7280";
554
-
555
- return html`
556
- <button
557
- key=${agent.id}
558
- class="agent-picker-item ${isActive ? "active" : ""}"
559
- role="option"
560
- aria-selected=${isActive}
561
- onClick=${() => handleSelect(agent.id)}
562
- >
563
- <div class="agent-picker-item-icon">${icon}</div>
564
- <div class="agent-picker-item-info">
565
- <div class="agent-picker-item-name">${agent.name}</div>
566
- <div class="agent-picker-item-provider">
567
- <span
568
- class="agent-provider-badge"
569
- style="background: ${providerColor}"
570
- >${agent.provider}</span>
571
- </div>
572
- </div>
573
- <div class="agent-picker-item-end">
574
- <span class="agent-picker-status-dot ${statusClass}"
575
- title=${statusClass} />
576
- ${isActive && html`<span class="agent-picker-check">✓</span>`}
577
- </div>
578
- </button>
579
- `;
580
- })}
581
- </div>
582
- `}
536
+ ${agents.length === 0 && html`
537
+ <option disabled>${loading ? "Loading…" : "No agents"}</option>
538
+ `}
539
+ ${agents.map((agent) => {
540
+ const statusLabel = agent.busy ? " (busy)" : !agent.available ? " (offline)" : "";
541
+ return html`
542
+ <option key=${agent.id} value=${agent.id}>
543
+ ${agent.name} · ${agent.provider}${statusLabel}
544
+ </option>
545
+ `;
546
+ })}
547
+ </select>
583
548
  </div>
584
549
  `;
585
550
  }
@@ -268,6 +268,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
268
268
  const [autoScroll, setAutoScroll] = useState(true);
269
269
  const [unreadCount, setUnreadCount] = useState(0);
270
270
  const [visibleCount, setVisibleCount] = useState(200);
271
+ const [showStreamMeta, setShowStreamMeta] = useState(false);
271
272
  const [filters, setFilters] = useState({
272
273
  tool: false,
273
274
  result: false,
@@ -704,15 +705,12 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
704
705
  >
705
706
  ${paused ? "Resume" : "Pause"}
706
707
  </button>
707
- <button class="btn btn-ghost btn-xs" onClick=${handleCopyStream} title="Copy stream">
708
- Copy
709
- </button>
710
- <button class="btn btn-ghost btn-xs" onClick=${handleExportStream} title="Export stream">
711
- Export
708
+ <button class="btn btn-ghost btn-xs" onClick=${() => setShowStreamMeta((prev) => !prev)} title="Toggle filters">
709
+ ${showStreamMeta ? "Hide filters" : "Filters"}
712
710
  </button>
713
711
  </div>
714
712
  </div>
715
- <div class="chat-stream-meta">
713
+ <div class="chat-stream-meta ${showStreamMeta ? 'expanded' : ''}">
716
714
  <div class="chat-stream-filters">
717
715
  <button
718
716
  class="chat-filter-chip ${activeFilters.length === 0 ? "active" : ""}"
package/ui/setup.html CHANGED
@@ -410,6 +410,7 @@ function SelectWithCustom({ options, value, onChange, placeholder }) {
410
410
  const handleSelect = (e) => {
411
411
  if (e.target.value === "__custom__") {
412
412
  setIsCustom(true);
413
+ setCustomValue(value || "");
413
414
  setTimeout(() => inputRef.current?.focus(), 50);
414
415
  } else {
415
416
  setIsCustom(false);
@@ -418,9 +419,12 @@ function SelectWithCustom({ options, value, onChange, placeholder }) {
418
419
  }
419
420
  };
420
421
 
421
- const handleCustomChange = (e) => {
422
- setCustomValue(e.target.value);
423
- onChange(e.target.value);
422
+ const commitCustom = () => {
423
+ if (customValue.trim()) onChange(customValue.trim());
424
+ };
425
+
426
+ const handleCustomKeyDown = (e) => {
427
+ if (e.key === "Enter") { commitCustom(); e.target.blur(); }
424
428
  };
425
429
 
426
430
  return html`
@@ -435,8 +439,10 @@ function SelectWithCustom({ options, value, onChange, placeholder }) {
435
439
  ref=${inputRef}
436
440
  type="text"
437
441
  value=${customValue}
438
- oninput=${handleCustomChange}
439
- placeholder="Enter custom value..."
442
+ oninput=${(e) => setCustomValue(e.target.value)}
443
+ onblur=${commitCustom}
444
+ onkeydown=${handleCustomKeyDown}
445
+ placeholder="Enter model slug..."
440
446
  />
441
447
  <button class="btn btn-sm" onclick=${() => { setIsCustom(false); onChange(options[0]?.value || ""); }}>
442
448
  Cancel
@@ -469,8 +475,8 @@ function App() {
469
475
  const [repoSlug, setRepoSlug] = useState("");
470
476
  const [repoRoot, setRepoRoot] = useState("");
471
477
  const [executors, setExecutors] = useState([
472
- { name: "primary", executor: "COPILOT", model: "claude-sonnet-4", weight: 70, role: "primary" },
473
- { name: "backup", executor: "CODEX", model: "o3", weight: 30, role: "backup" },
478
+ { name: "primary", executor: "COPILOT", model: "claude-opus-4.6", weight: 70, role: "primary" },
479
+ { name: "backup", executor: "CODEX", model: "gpt-5.2-codex", weight: 30, role: "backup" },
474
480
  ]);
475
481
  const [repos, setRepos] = useState([""]);
476
482
  const [kanbanBackend, setKanbanBackend] = useState("internal");
@@ -845,7 +845,7 @@ select.input {
845
845
  background: var(--backdrop);
846
846
  backdrop-filter: var(--backdrop-blur);
847
847
  -webkit-backdrop-filter: var(--backdrop-blur);
848
- z-index: 9500;
848
+ z-index: 10000;
849
849
  display: flex;
850
850
  align-items: flex-end;
851
851
  justify-content: center;
@@ -882,6 +882,21 @@ select.input {
882
882
  max-width: 860px;
883
883
  }
884
884
 
885
+ /* ── Desktop: Center modal as dialog ── */
886
+ @media (min-width: 769px) {
887
+ .modal-overlay {
888
+ align-items: center;
889
+ padding: 24px;
890
+ z-index: 10000;
891
+ }
892
+ .modal-content {
893
+ border-radius: var(--radius-xl);
894
+ border-bottom: 1px solid var(--border);
895
+ max-height: 85vh;
896
+ animation: springUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
897
+ }
898
+ }
899
+
885
900
  .modal-form-grid {
886
901
  display: flex;
887
902
  flex-direction: column;
@@ -66,19 +66,7 @@
66
66
  }
67
67
  }
68
68
 
69
- @media (min-width: 1200px) {
70
- .app-shell[data-has-rail="true"] .session-split {
71
- grid-template-columns: 1fr;
72
- }
73
-
74
- .app-shell[data-has-rail="true"] .session-pane {
75
- display: none;
76
- }
77
-
78
- .app-shell[data-has-rail="true"] .chat-shell-header .session-drawer-btn {
79
- display: none;
80
- }
81
- }
69
+ /* Rail no longer hides the session pane on desktop — sessions always visible */
82
70
 
83
71
  @media (max-width: 768px) {
84
72
  .session-split {
@@ -173,7 +161,7 @@
173
161
  width: 100%;
174
162
  max-width: 980px;
175
163
  margin: 0 auto;
176
- padding: 12px 12px;
164
+ padding: 8px 16px;
177
165
  }
178
166
 
179
167
  .session-drawer-btn {
@@ -567,10 +555,10 @@
567
555
  display: flex;
568
556
  align-items: center;
569
557
  justify-content: space-between;
570
- gap: 12px;
571
- padding: 12px 12px 6px;
558
+ gap: 8px;
559
+ padding: 6px 16px;
572
560
  border-bottom: 1px solid var(--border);
573
- background: var(--bg-secondary);
561
+ background: var(--bg-primary);
574
562
  }
575
563
 
576
564
  .chat-stream-status {
@@ -581,11 +569,11 @@
581
569
  }
582
570
 
583
571
  .chat-stream-dot {
584
- width: 10px;
585
- height: 10px;
572
+ width: 8px;
573
+ height: 8px;
586
574
  border-radius: 999px;
587
575
  background: var(--text-hint);
588
- box-shadow: 0 0 0 4px rgba(218, 119, 86, 0.1);
576
+ box-shadow: 0 0 0 3px rgba(218, 119, 86, 0.1);
589
577
  }
590
578
 
591
579
  .chat-stream-dot.thinking,
@@ -641,12 +629,16 @@
641
629
  }
642
630
 
643
631
  .chat-stream-meta {
644
- display: flex;
632
+ display: none;
645
633
  flex-direction: column;
646
- gap: 8px;
647
- padding: 6px 12px 10px;
634
+ gap: 6px;
635
+ padding: 4px 16px 6px;
648
636
  border-bottom: 1px solid var(--border);
649
- background: var(--bg-secondary);
637
+ background: var(--bg-primary);
638
+ }
639
+
640
+ .chat-stream-meta.expanded {
641
+ display: flex;
650
642
  }
651
643
 
652
644
  .chat-stream-filters {
@@ -784,11 +776,14 @@
784
776
  scroll-padding-bottom: 80px;
785
777
  scrollbar-width: thin;
786
778
  scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
787
- padding: 20px 16px 48px;
779
+ padding: 24px 24px 48px;
788
780
  display: flex;
789
781
  flex-direction: column;
790
- gap: var(--space-sm);
782
+ gap: 6px;
791
783
  align-items: stretch;
784
+ max-width: 860px;
785
+ margin: 0 auto;
786
+ width: 100%;
792
787
  }
793
788
 
794
789
  .chat-messages::-webkit-scrollbar {
@@ -862,16 +857,16 @@
862
857
  /* ─── Bubbles ─── */
863
858
  .chat-bubble {
864
859
  width: 100%;
865
- max-width: min(760px, 100%);
866
- padding: 10px 14px;
867
- border-radius: var(--radius-md);
868
- font-size: 13px;
869
- line-height: 1.5;
860
+ max-width: 100%;
861
+ padding: 12px 16px;
862
+ border-radius: 18px;
863
+ font-size: 14px;
864
+ line-height: 1.6;
870
865
  word-wrap: break-word;
871
- border: 1px solid transparent;
866
+ border: none;
872
867
  background: var(--bg-card);
873
868
  align-self: flex-start;
874
- box-shadow: var(--shadow-sm);
869
+ box-shadow: none;
875
870
  /* CSS containment — tells the browser each bubble is layout-independent,
876
871
  reducing style recalc and layout thrashing on long message lists */
877
872
  content-visibility: auto;
@@ -882,17 +877,20 @@
882
877
  .chat-bubble.user {
883
878
  align-self: flex-end;
884
879
  width: auto;
885
- max-width: min(560px, 100%);
886
- background: rgba(218, 119, 86, 0.16);
887
- border-color: rgba(218, 119, 86, 0.35);
880
+ max-width: min(75%, 560px);
881
+ background: rgba(218, 119, 86, 0.14);
882
+ border: none;
888
883
  color: var(--text-primary);
884
+ border-radius: 18px 18px 4px 18px;
889
885
  }
890
886
 
891
887
  .chat-bubble.assistant {
892
888
  align-self: flex-start;
893
- background: var(--bg-card);
889
+ background: rgba(255, 255, 255, 0.03);
894
890
  color: var(--text-primary);
895
- border-color: var(--border);
891
+ border: none;
892
+ border-radius: 18px 18px 18px 4px;
893
+ padding: 12px 4px;
896
894
  }
897
895
 
898
896
  .chat-bubble.system {
@@ -901,21 +899,25 @@
901
899
  background: transparent;
902
900
  padding: 6px 12px;
903
901
  box-shadow: none;
902
+ border: none;
904
903
  }
905
904
 
906
905
  .chat-bubble.tool {
907
906
  align-self: center;
908
- background: rgba(218, 119, 86, 0.08);
909
- border: 1px solid rgba(218, 119, 86, 0.28);
907
+ background: rgba(218, 119, 86, 0.06);
908
+ border: 1px solid rgba(218, 119, 86, 0.15);
910
909
  color: var(--text-primary);
911
910
  font-family: var(--font-mono);
911
+ border-radius: 12px;
912
+ font-size: 12px;
912
913
  }
913
914
 
914
915
  .chat-bubble.error {
915
916
  align-self: center;
916
- background: rgba(229, 83, 75, 0.12);
917
- border: 1px solid rgba(229, 83, 75, 0.4);
917
+ background: rgba(229, 83, 75, 0.08);
918
+ border: 1px solid rgba(229, 83, 75, 0.25);
918
919
  color: var(--text-primary);
920
+ border-radius: 12px;
919
921
  }
920
922
 
921
923
  .chat-system-text {
@@ -926,6 +928,7 @@
926
928
  }
927
929
 
928
930
  .chat-bubble-label {
931
+ display: none;
929
932
  font-size: 10px;
930
933
  font-weight: 600;
931
934
  text-transform: uppercase;
@@ -934,14 +937,19 @@
934
937
  margin-bottom: 4px;
935
938
  }
936
939
 
940
+ .chat-bubble.tool .chat-bubble-label,
941
+ .chat-bubble.error .chat-bubble-label {
942
+ display: block;
943
+ }
944
+
937
945
  .chat-bubble-content {
938
946
  white-space: pre-wrap;
939
947
  }
940
948
 
941
949
  .chat-bubble-time {
942
950
  font-size: 10px;
943
- opacity: 0.6;
944
- margin-top: 4px;
951
+ opacity: 0.4;
952
+ margin-top: 6px;
945
953
  }
946
954
 
947
955
  /* Subtle entrance for bubbles — only the last few get the animation
@@ -1908,3 +1916,30 @@ ul.md-list li::before {
1908
1916
  max-width: min(840px, 100%);
1909
1917
  }
1910
1918
  }
1919
+
1920
+ /* ── Focus mode exit FAB ── */
1921
+ .focus-exit-fab {
1922
+ position: fixed;
1923
+ top: 16px;
1924
+ right: 16px;
1925
+ z-index: 10001;
1926
+ width: 40px;
1927
+ height: 40px;
1928
+ border-radius: 50%;
1929
+ border: none;
1930
+ background: #ef4444;
1931
+ color: #fff;
1932
+ font-size: 18px;
1933
+ font-weight: 700;
1934
+ cursor: pointer;
1935
+ display: flex;
1936
+ align-items: center;
1937
+ justify-content: center;
1938
+ box-shadow: 0 4px 16px rgba(239, 68, 68, 0.4);
1939
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
1940
+ -webkit-tap-highlight-color: transparent;
1941
+ }
1942
+ .focus-exit-fab:hover {
1943
+ transform: scale(1.1);
1944
+ box-shadow: 0 6px 20px rgba(239, 68, 68, 0.5);
1945
+ }
package/ui/tabs/chat.js CHANGED
@@ -691,9 +691,11 @@ export function ChatTab() {
691
691
  html`
692
692
  <div class="chat-shell-header">
693
693
  <div class="chat-shell-inner">
694
- <button class="session-drawer-btn" onClick=${handleBack}>
695
- Sessions
696
- </button>
694
+ ${isMobile && html`
695
+ <button class="session-drawer-btn" onClick=${handleBack}>
696
+ ☰ Sessions
697
+ </button>
698
+ `}
697
699
  <div class="chat-shell-title">
698
700
  <div class="chat-shell-name">${sessionTitle}</div>
699
701
  <div class="chat-shell-meta">${sessionMeta || "Session"}</div>
@@ -784,6 +786,13 @@ export function ChatTab() {
784
786
  </div>
785
787
  </div>
786
788
  </div>
789
+ ${focusMode && html`
790
+ <button
791
+ class="focus-exit-fab"
792
+ onClick=${() => setFocusMode(false)}
793
+ title="Exit focus mode"
794
+ >✕</button>
795
+ `}
787
796
  ${isMobile &&
788
797
  html`
789
798
  <div
package/ui/tabs/tasks.js CHANGED
@@ -214,6 +214,27 @@ export function StartTaskModal({
214
214
  }, [task?.id, task?.meta?.codex?.isIgnored, task?.meta?.labels]);
215
215
 
216
216
  const canModel = sdk && sdk !== "auto";
217
+
218
+ const EXECUTOR_MODELS = {
219
+ codex: [
220
+ "gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.1-codex",
221
+ "gpt-5.1-codex-mini", "gpt-5.1-codex-max",
222
+ "gpt-5.2", "gpt-5.1", "gpt-5-mini",
223
+ ],
224
+ copilot: [
225
+ "claude-opus-4.6", "claude-sonnet-4.6", "claude-opus-4.5", "claude-sonnet-4.5",
226
+ "claude-sonnet-4", "claude-haiku-4.5",
227
+ "gpt-5.2-codex", "gpt-5.3-codex", "gpt-5.1-codex", "gpt-5.1-codex-mini",
228
+ "gpt-5.2", "gpt-5.1", "gpt-5-mini",
229
+ "gemini-3.1-pro", "gemini-3-pro", "gemini-3-flash", "gemini-2.5-pro",
230
+ "grok-code-fast-1",
231
+ ],
232
+ claude: [
233
+ "claude-opus-4.6", "claude-sonnet-4.6", "claude-opus-4.5",
234
+ "claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5",
235
+ ],
236
+ };
237
+
217
238
  const resolvedTaskId = (task?.id || taskIdInput || "").trim();
218
239
 
219
240
  const handleStart = async () => {
@@ -272,13 +293,10 @@ export function StartTaskModal({
272
293
  </div>
273
294
  <div class="modal-form-field">
274
295
  <div class="card-subtitle">Model Override (optional)</div>
275
- <input
276
- class="input"
277
- placeholder=${canModel ? "e.g. gpt-5.3-codex" : "Select SDK to enable"}
278
- value=${model}
279
- disabled=${!canModel}
280
- onInput=${(e) => setModel(e.target.value)}
281
- />
296
+ <select class="input" value=${model} disabled=${!canModel} onChange=${(e) => setModel(e.target.value)}>
297
+ <option value="">Auto (default)</option>
298
+ ${canModel && (EXECUTOR_MODELS[sdk] || []).map(m => html`<option value=${m}>${m}</option>`)}
299
+ </select>
282
300
  </div>
283
301
  <div class="modal-form-field modal-form-span">
284
302
  <button
package/ui-server.mjs CHANGED
@@ -110,7 +110,7 @@ let _configValidator = null;
110
110
  function resolveConfigPath() {
111
111
  return process.env.BOSUN_CONFIG_PATH
112
112
  ? resolve(process.env.BOSUN_CONFIG_PATH)
113
- : resolve(__dirname, "bosun.config.json");
113
+ : resolve(resolveUiConfigDir(), "bosun.config.json");
114
114
  }
115
115
 
116
116
  function getConfigSchema() {
@@ -660,7 +660,7 @@ const SETTINGS_KNOWN_SET = new Set(SETTINGS_KNOWN_KEYS);
660
660
  let _settingsLastUpdateTime = 0;
661
661
 
662
662
  function updateEnvFile(changes) {
663
- const envPath = resolve(__dirname, '.env');
663
+ const envPath = resolve(resolveUiConfigDir(), '.env');
664
664
  let content = '';
665
665
  try { content = readFileSync(envPath, 'utf8'); } catch { content = ''; }
666
666
 
@@ -4339,7 +4339,7 @@ async function handleApi(req, res, url) {
4339
4339
  data[key] = val || "";
4340
4340
  }
4341
4341
  }
4342
- const envPath = resolve(__dirname, ".env");
4342
+ const envPath = resolve(resolveUiConfigDir(), ".env");
4343
4343
  const configPath = resolveConfigPath();
4344
4344
  const configExists = existsSync(configPath);
4345
4345
  const configSchema = getConfigSchema();
@@ -5468,6 +5468,21 @@ export async function startTelegramUiServer(options = {}) {
5468
5468
  console.log(`[telegram-ui] LAN access: ${protocol}://${lanIp}:${actualPort}`);
5469
5469
  console.log(`[telegram-ui] Browser access: ${protocol}://${lanIp}:${actualPort}/?token=${sessionToken}`);
5470
5470
 
5471
+ // Auto-open browser (skip in desktop/embedded mode)
5472
+ if (process.env.BOSUN_DESKTOP !== "1" && !options.skipAutoOpen) {
5473
+ const openUrl = `${protocol}://${lanIp}:${actualPort}/?token=${sessionToken}`;
5474
+ try {
5475
+ const { exec } = await import("node:child_process");
5476
+ if (process.platform === "win32") {
5477
+ exec(`start "" "${openUrl}"`);
5478
+ } else if (process.platform === "darwin") {
5479
+ exec(`open "${openUrl}"`);
5480
+ } else {
5481
+ exec(`xdg-open "${openUrl}"`);
5482
+ }
5483
+ } catch { /* ignore auto-open failure */ }
5484
+ }
5485
+
5471
5486
  // Check firewall rules for the UI port
5472
5487
  firewallState = await checkFirewall(actualPort);
5473
5488
  if (firewallState) {