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
@@ -36,8 +36,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
36
36
  // 2. Node module resolution via createRequire — handles global install hoisting
37
37
  // 3. CDN redirect — last resort
38
38
  const _require = createRequire(import.meta.url);
39
- const uiRootPreferred = resolve(__dirname, "site", "ui");
40
- const uiRootFallback = resolve(__dirname, "ui");
39
+ const uiRootPreferred = resolve(__dirname, "ui");
40
+ const uiRootFallback = resolve(__dirname, "site", "ui");
41
41
  const uiRoot = existsSync(uiRootPreferred) ? uiRootPreferred : uiRootFallback;
42
42
  const BUNDLED_VENDOR_DIR = resolve(uiRoot, "vendor");
43
43
 
@@ -134,12 +134,27 @@ const MODELS = {
134
134
  { value: "claude-sonnet-4", label: "claude-sonnet-4" },
135
135
  { value: "claude-haiku-4.5", label: "claude-haiku-4.5" },
136
136
  ],
137
+ gemini: [
138
+ { value: "gemini-2.5-pro", label: "gemini-2.5-pro", recommended: true },
139
+ { value: "gemini-2.5-flash", label: "gemini-2.5-flash" },
140
+ { value: "gemini-2.0-flash", label: "gemini-2.0-flash" },
141
+ { value: "gemini-1.5-pro", label: "gemini-1.5-pro" },
142
+ { value: "gemini-1.5-flash", label: "gemini-1.5-flash" },
143
+ ],
144
+ opencode: [
145
+ { value: "gpt-5.3-codex", label: "gpt-5.3-codex", recommended: true },
146
+ { value: "gpt-5.2-codex", label: "gpt-5.2-codex" },
147
+ { value: "claude-opus-4.6", label: "claude-opus-4.6" },
148
+ { value: "gemini-2.5-pro", label: "gemini-2.5-pro" },
149
+ ],
137
150
  };
138
151
 
139
152
  const EXECUTOR_TYPES = [
140
153
  { value: "COPILOT", label: "GitHub Copilot (recommended)", recommended: true },
141
154
  { value: "CODEX", label: "OpenAI Codex CLI" },
142
155
  { value: "CLAUDE_CODE", label: "Claude Code" },
156
+ { value: "GEMINI", label: "Google Gemini" },
157
+ { value: "OPENCODE", label: "OpenCode (local server)" },
143
158
  ];
144
159
 
145
160
  const KANBAN_BACKENDS = [
@@ -178,6 +193,195 @@ function normalizeWorkflowTemplateIds(rawValue, fallback = []) {
178
193
  return Array.isArray(fallback) ? [...fallback] : [];
179
194
  }
180
195
 
196
+ function normalizeWorkspaceId(value, fallback = "workspace") {
197
+ const normalized = String(value || "")
198
+ .trim()
199
+ .toLowerCase()
200
+ .replace(/[^a-z0-9_-]/g, "-")
201
+ .replace(/-+/g, "-")
202
+ .replace(/^-|-$/g, "");
203
+ return normalized || fallback;
204
+ }
205
+
206
+ function extractRepoNameFromText(text) {
207
+ const raw = String(text || "").trim();
208
+ if (!raw) return "";
209
+ if (/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(raw)) {
210
+ return raw.split("/").pop() || "";
211
+ }
212
+ try {
213
+ const parsed = new URL(raw);
214
+ const pathname = String(parsed.pathname || "")
215
+ .replace(/\.git$/i, "")
216
+ .replace(/^\/+|\/+$/g, "");
217
+ return pathname.split("/").pop() || "";
218
+ } catch {
219
+ const cleaned = raw
220
+ .replace(/\\/g, "/")
221
+ .replace(/\.git$/i, "")
222
+ .replace(/^\/+|\/+$/g, "");
223
+ return cleaned.split("/").pop() || "";
224
+ }
225
+ }
226
+
227
+ function normalizeRepoSlug(text) {
228
+ const raw = String(text || "").trim();
229
+ if (!raw) return "";
230
+ if (/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(raw)) return raw;
231
+ const fromUrl = raw.match(/github\.com[:/]([a-z0-9_.-]+\/[a-z0-9_.-]+)(?:\.git)?/i);
232
+ if (fromUrl?.[1]) return fromUrl[1];
233
+ return "";
234
+ }
235
+
236
+ function normalizeRepoConfigEntry(repo, index = 0) {
237
+ const raw = typeof repo === "string" ? { slug: repo } : (repo && typeof repo === "object" ? repo : null);
238
+ if (!raw) return null;
239
+ const slug = normalizeRepoSlug(raw.slug || raw.url || raw.path || raw.name || "");
240
+ const url =
241
+ String(raw.url || "").trim() ||
242
+ (slug ? `https://github.com/${slug}.git` : "");
243
+ const name = String(raw.name || raw.id || "").trim() || extractRepoNameFromText(slug || url || raw.path || "");
244
+ if (!name && !slug && !url) return null;
245
+ return {
246
+ name: name || extractRepoNameFromText(url || slug) || `repo-${index + 1}`,
247
+ slug,
248
+ url,
249
+ primary: Boolean(raw.primary) || index === 0,
250
+ };
251
+ }
252
+
253
+ function normalizeWorkspaceConfigEntry(workspace, index = 0) {
254
+ if (!workspace || typeof workspace !== "object") return null;
255
+ const fallbackId = `workspace-${index + 1}`;
256
+ const id = normalizeWorkspaceId(
257
+ workspace.id || workspace.name || fallbackId,
258
+ fallbackId,
259
+ );
260
+ const name = String(workspace.name || workspace.id || id).trim() || id;
261
+ const repos = Array.isArray(workspace.repos)
262
+ ? workspace.repos
263
+ .map((repo, repoIndex) => normalizeRepoConfigEntry(repo, repoIndex))
264
+ .filter(Boolean)
265
+ : [];
266
+ let activeRepo = String(workspace.activeRepo || "").trim();
267
+ if (!activeRepo && repos[0]?.name) activeRepo = repos[0].name;
268
+ if (activeRepo && !repos.some((repo) => repo.name === activeRepo)) {
269
+ activeRepo = repos[0]?.name || "";
270
+ }
271
+ return {
272
+ id,
273
+ name,
274
+ repos,
275
+ createdAt: workspace.createdAt || new Date().toISOString(),
276
+ activeRepo: activeRepo || null,
277
+ };
278
+ }
279
+
280
+ function normalizeWorkspaceConfigList(workspaces) {
281
+ if (!Array.isArray(workspaces)) return [];
282
+ const byId = new Map();
283
+ for (let i = 0; i < workspaces.length; i += 1) {
284
+ const normalized = normalizeWorkspaceConfigEntry(workspaces[i], i);
285
+ if (!normalized) continue;
286
+ const existing = byId.get(normalized.id);
287
+ if (!existing) {
288
+ byId.set(normalized.id, normalized);
289
+ continue;
290
+ }
291
+ // Merge with precedence to latest entry while preserving earlier repo order.
292
+ const repoMap = new Map();
293
+ for (const repo of existing.repos || []) repoMap.set(repo.name, repo);
294
+ for (const repo of normalized.repos || []) repoMap.set(repo.name, repo);
295
+ byId.set(normalized.id, {
296
+ ...existing,
297
+ ...normalized,
298
+ repos: [...repoMap.values()],
299
+ activeRepo:
300
+ normalized.activeRepo ||
301
+ existing.activeRepo ||
302
+ [...repoMap.values()][0]?.name ||
303
+ null,
304
+ });
305
+ }
306
+ return [...byId.values()];
307
+ }
308
+
309
+ function readExistingBosunConfig(bosunHome) {
310
+ const configPath = resolve(bosunHome, "bosun.config.json");
311
+ if (!existsSync(configPath)) return {};
312
+ try {
313
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
314
+ return parsed && typeof parsed === "object" ? parsed : {};
315
+ } catch {
316
+ return {};
317
+ }
318
+ }
319
+
320
+ function resolveSetupWorkspaceAndRepoConfig(existingConfig = {}, configJson = {}, env = {}) {
321
+ const normalizedIncomingRepos = Array.isArray(configJson.repos)
322
+ ? configJson.repos
323
+ .map((repo, idx) => normalizeRepoConfigEntry(repo, idx))
324
+ .filter(Boolean)
325
+ : [];
326
+ const normalizedExistingRepos = Array.isArray(existingConfig.repos)
327
+ ? existingConfig.repos
328
+ .map((repo, idx) => normalizeRepoConfigEntry(repo, idx))
329
+ .filter(Boolean)
330
+ : [];
331
+ const repos =
332
+ normalizedIncomingRepos.length > 0
333
+ ? normalizedIncomingRepos
334
+ : normalizedExistingRepos;
335
+
336
+ const normalizedIncomingWorkspaces = normalizeWorkspaceConfigList(configJson.workspaces);
337
+ const normalizedExistingWorkspaces = normalizeWorkspaceConfigList(existingConfig.workspaces);
338
+ let workspaces = normalizedIncomingWorkspaces.length > 0
339
+ ? normalizedIncomingWorkspaces
340
+ : normalizedExistingWorkspaces;
341
+
342
+ // Bootstrap a single default workspace when repos exist but no workspace
343
+ // layout was provided, so the UI and workspace APIs stay consistent.
344
+ if (workspaces.length === 0 && repos.length > 0) {
345
+ const defaultWorkspaceId = normalizeWorkspaceId(
346
+ configJson.projectName || env.projectName || existingConfig.projectName || "default",
347
+ "default",
348
+ );
349
+ workspaces = [
350
+ {
351
+ id: defaultWorkspaceId,
352
+ name:
353
+ configJson.projectName ||
354
+ env.projectName ||
355
+ existingConfig.projectName ||
356
+ "Default Workspace",
357
+ repos: repos.map((repo, idx) => ({
358
+ ...repo,
359
+ primary: repo.primary === true || idx === 0,
360
+ })),
361
+ createdAt: new Date().toISOString(),
362
+ activeRepo: repos[0]?.name || null,
363
+ },
364
+ ];
365
+ }
366
+
367
+ let activeWorkspace = "";
368
+ if (workspaces.length > 0) {
369
+ const requestedActiveWorkspace = normalizeWorkspaceId(
370
+ configJson.activeWorkspace || existingConfig.activeWorkspace || workspaces[0]?.id,
371
+ workspaces[0]?.id || "default",
372
+ );
373
+ activeWorkspace = workspaces.some((ws) => ws.id === requestedActiveWorkspace)
374
+ ? requestedActiveWorkspace
375
+ : workspaces[0].id;
376
+ }
377
+
378
+ return {
379
+ repos,
380
+ workspaces,
381
+ activeWorkspace,
382
+ };
383
+ }
384
+
181
385
  function buildStableSetupDefaults({
182
386
  projectName,
183
387
  slug,
@@ -230,6 +434,18 @@ function buildStableSetupDefaults({
230
434
  workflowRunStuckThresholdMs: 300000,
231
435
  workflowMaxPersistedRuns: 200,
232
436
  workflowMaxConcurrentBranches: 8,
437
+ voiceEnabled: true,
438
+ voiceProvider: "auto",
439
+ voiceModel: "gpt-4o-realtime-preview-2024-12-17",
440
+ voiceVisionModel: "gpt-4.1-mini",
441
+ voiceId: "alloy",
442
+ voiceTurnDetection: "server_vad",
443
+ voiceFallbackMode: "browser",
444
+ voiceDelegateExecutor: "codex-sdk",
445
+ openaiRealtimeApiKey: "",
446
+ azureOpenaiRealtimeEndpoint: "",
447
+ azureOpenaiRealtimeApiKey: "",
448
+ azureOpenaiRealtimeDeployment: "gpt-4o-realtime-preview",
233
449
  copilotEnableAllMcpTools: false,
234
450
  // Backward-compatible fields consumed by older setup UI revisions.
235
451
  distribution: "primary-only",
@@ -330,8 +546,33 @@ function applyTelegramMiniAppSetupEnv(envMap, env, sourceEnv = process.env) {
330
546
  env?.telegramUiTunnel ||
331
547
  env?.TELEGRAM_UI_TUNNEL ||
332
548
  sourceEnv.TELEGRAM_UI_TUNNEL ||
333
- "auto";
334
- envMap.TELEGRAM_UI_TUNNEL = String(tunnelRaw).trim() || "auto";
549
+ "named";
550
+ envMap.TELEGRAM_UI_TUNNEL = String(tunnelRaw).trim() || "named";
551
+
552
+ const quickFallbackRaw =
553
+ env?.telegramUiAllowQuickTunnelFallback ??
554
+ env?.TELEGRAM_UI_ALLOW_QUICK_TUNNEL_FALLBACK ??
555
+ sourceEnv.TELEGRAM_UI_ALLOW_QUICK_TUNNEL_FALLBACK;
556
+ envMap.TELEGRAM_UI_ALLOW_QUICK_TUNNEL_FALLBACK = toBooleanEnvString(
557
+ quickFallbackRaw,
558
+ false,
559
+ );
560
+
561
+ const fallbackAuthRaw =
562
+ env?.telegramUiFallbackAuthEnabled ??
563
+ env?.TELEGRAM_UI_FALLBACK_AUTH_ENABLED ??
564
+ sourceEnv.TELEGRAM_UI_FALLBACK_AUTH_ENABLED;
565
+ envMap.TELEGRAM_UI_FALLBACK_AUTH_ENABLED = toBooleanEnvString(
566
+ fallbackAuthRaw,
567
+ true,
568
+ );
569
+
570
+ const hostnamePolicyRaw =
571
+ env?.cloudflareUsernameHostnamePolicy ||
572
+ env?.CLOUDFLARE_USERNAME_HOSTNAME_POLICY ||
573
+ sourceEnv.CLOUDFLARE_USERNAME_HOSTNAME_POLICY ||
574
+ "per-user-fixed";
575
+ envMap.CLOUDFLARE_USERNAME_HOSTNAME_POLICY = String(hostnamePolicyRaw).trim() || "per-user-fixed";
335
576
 
336
577
  const unsafeRaw =
337
578
  env?.telegramUiAllowUnsafe ??
@@ -589,6 +830,122 @@ function applyNonBlockingSetupEnvDefaults(envMap, env = {}, sourceEnv = process.
589
830
  ["workspace-write", "danger-full-access", "read-only"],
590
831
  "workspace-write",
591
832
  );
833
+ envMap.VOICE_ENABLED = toBooleanEnvString(
834
+ pickNonEmptyValue(
835
+ env.voiceEnabled,
836
+ env.VOICE_ENABLED,
837
+ envMap.VOICE_ENABLED,
838
+ sourceEnv.VOICE_ENABLED,
839
+ ),
840
+ true,
841
+ );
842
+ envMap.VOICE_PROVIDER = normalizeEnumValue(
843
+ pickNonEmptyValue(
844
+ env.voiceProvider,
845
+ env.VOICE_PROVIDER,
846
+ envMap.VOICE_PROVIDER,
847
+ sourceEnv.VOICE_PROVIDER,
848
+ ),
849
+ ["auto", "openai", "azure", "claude", "gemini", "fallback"],
850
+ "auto",
851
+ );
852
+ envMap.VOICE_MODEL = String(
853
+ pickNonEmptyValue(
854
+ env.voiceModel,
855
+ env.VOICE_MODEL,
856
+ envMap.VOICE_MODEL,
857
+ sourceEnv.VOICE_MODEL,
858
+ ) || "gpt-4o-realtime-preview-2024-12-17",
859
+ ).trim() || "gpt-4o-realtime-preview-2024-12-17";
860
+ envMap.VOICE_VISION_MODEL = String(
861
+ pickNonEmptyValue(
862
+ env.voiceVisionModel,
863
+ env.VOICE_VISION_MODEL,
864
+ envMap.VOICE_VISION_MODEL,
865
+ sourceEnv.VOICE_VISION_MODEL,
866
+ ) || "gpt-4.1-mini",
867
+ ).trim() || "gpt-4.1-mini";
868
+ envMap.VOICE_ID = normalizeEnumValue(
869
+ pickNonEmptyValue(
870
+ env.voiceId,
871
+ env.VOICE_ID,
872
+ envMap.VOICE_ID,
873
+ sourceEnv.VOICE_ID,
874
+ ),
875
+ ["alloy", "ash", "ballad", "coral", "echo", "fable", "onyx", "nova", "sage", "shimmer", "verse"],
876
+ "alloy",
877
+ );
878
+ envMap.VOICE_TURN_DETECTION = normalizeEnumValue(
879
+ pickNonEmptyValue(
880
+ env.voiceTurnDetection,
881
+ env.VOICE_TURN_DETECTION,
882
+ envMap.VOICE_TURN_DETECTION,
883
+ sourceEnv.VOICE_TURN_DETECTION,
884
+ ),
885
+ ["server_vad", "semantic_vad", "none"],
886
+ "server_vad",
887
+ );
888
+ envMap.VOICE_FALLBACK_MODE = normalizeEnumValue(
889
+ pickNonEmptyValue(
890
+ env.voiceFallbackMode,
891
+ env.VOICE_FALLBACK_MODE,
892
+ envMap.VOICE_FALLBACK_MODE,
893
+ sourceEnv.VOICE_FALLBACK_MODE,
894
+ ),
895
+ ["browser", "disabled"],
896
+ "browser",
897
+ );
898
+ envMap.VOICE_DELEGATE_EXECUTOR = normalizeEnumValue(
899
+ pickNonEmptyValue(
900
+ env.voiceDelegateExecutor,
901
+ env.VOICE_DELEGATE_EXECUTOR,
902
+ envMap.VOICE_DELEGATE_EXECUTOR,
903
+ sourceEnv.VOICE_DELEGATE_EXECUTOR,
904
+ ),
905
+ ["codex-sdk", "copilot-sdk", "claude-sdk", "gemini-sdk", "opencode-sdk"],
906
+ "codex-sdk",
907
+ );
908
+
909
+ const openaiRealtimeApiKey = pickNonEmptyValue(
910
+ env.openaiRealtimeApiKey,
911
+ env.OPENAI_REALTIME_API_KEY,
912
+ envMap.OPENAI_REALTIME_API_KEY,
913
+ sourceEnv.OPENAI_REALTIME_API_KEY,
914
+ );
915
+ if (openaiRealtimeApiKey !== undefined) {
916
+ envMap.OPENAI_REALTIME_API_KEY = String(openaiRealtimeApiKey).trim();
917
+ }
918
+
919
+ const azureRealtimeEndpoint = pickNonEmptyValue(
920
+ env.azureOpenaiRealtimeEndpoint,
921
+ env.AZURE_OPENAI_REALTIME_ENDPOINT,
922
+ envMap.AZURE_OPENAI_REALTIME_ENDPOINT,
923
+ sourceEnv.AZURE_OPENAI_REALTIME_ENDPOINT,
924
+ sourceEnv.AZURE_OPENAI_ENDPOINT,
925
+ );
926
+ if (azureRealtimeEndpoint !== undefined) {
927
+ envMap.AZURE_OPENAI_REALTIME_ENDPOINT = String(azureRealtimeEndpoint).trim();
928
+ }
929
+
930
+ const azureRealtimeApiKey = pickNonEmptyValue(
931
+ env.azureOpenaiRealtimeApiKey,
932
+ env.AZURE_OPENAI_REALTIME_API_KEY,
933
+ envMap.AZURE_OPENAI_REALTIME_API_KEY,
934
+ sourceEnv.AZURE_OPENAI_REALTIME_API_KEY,
935
+ sourceEnv.AZURE_OPENAI_API_KEY,
936
+ );
937
+ if (azureRealtimeApiKey !== undefined) {
938
+ envMap.AZURE_OPENAI_REALTIME_API_KEY = String(azureRealtimeApiKey).trim();
939
+ }
940
+
941
+ envMap.AZURE_OPENAI_REALTIME_DEPLOYMENT = String(
942
+ pickNonEmptyValue(
943
+ env.azureOpenaiRealtimeDeployment,
944
+ env.AZURE_OPENAI_REALTIME_DEPLOYMENT,
945
+ envMap.AZURE_OPENAI_REALTIME_DEPLOYMENT,
946
+ sourceEnv.AZURE_OPENAI_REALTIME_DEPLOYMENT,
947
+ ) || "gpt-4o-realtime-preview",
948
+ ).trim() || "gpt-4o-realtime-preview";
592
949
 
593
950
  envMap.CONTAINER_ENABLED = toBooleanEnvString(
594
951
  pickNonEmptyValue(env.containerEnabled, envMap.CONTAINER_ENABLED, sourceEnv.CONTAINER_ENABLED),
@@ -962,8 +1319,20 @@ async function handleModelsProbe(body) {
962
1319
 
963
1320
  // Copilot and Claude Code use OAuth — we can't probe their model lists from
964
1321
  // the server side. Return the static list with a note.
965
- if (executor === "COPILOT" || executor === "CLAUDE_CODE") {
966
- const key = executor === "COPILOT" ? "copilot" : "claude";
1322
+ if (
1323
+ executor === "COPILOT" ||
1324
+ executor === "CLAUDE_CODE" ||
1325
+ executor === "GEMINI" ||
1326
+ executor === "OPENCODE"
1327
+ ) {
1328
+ const key =
1329
+ executor === "COPILOT"
1330
+ ? "copilot"
1331
+ : executor === "CLAUDE_CODE"
1332
+ ? "claude"
1333
+ : executor === "GEMINI"
1334
+ ? "gemini"
1335
+ : "opencode";
967
1336
  return {
968
1337
  ok: true,
969
1338
  models: MODELS[key] || [],
@@ -1073,6 +1442,7 @@ function handleApply(body) {
1073
1442
  // Resolve home + workspaces dirs — prefer what the user chose in the wizard
1074
1443
  const bosunHome = env.bosunHome ? resolve(env.bosunHome) : resolveConfigDir();
1075
1444
  const workspacesDir = env.workspacesDir ? resolve(env.workspacesDir) : resolveWorkspacesDir(bosunHome);
1445
+ const existingConfig = readExistingBosunConfig(bosunHome);
1076
1446
 
1077
1447
  // ── Create directory scaffold ───────────────────────────────────────────
1078
1448
  mkdirSync(bosunHome, { recursive: true });
@@ -1264,6 +1634,13 @@ function handleApply(body) {
1264
1634
  if (ex.baseUrl) envMap.ANTHROPIC_BASE_URL = ex.baseUrl;
1265
1635
  // Note: Anthropic does not have a native multi-profile env-var system;
1266
1636
  // only a single key/endpoint is supported for this executor type.
1637
+ } else if (type === "GEMINI" || type === "GOOGLE_GEMINI") {
1638
+ if (ex.apiKey) {
1639
+ envMap.GEMINI_API_KEY = ex.apiKey;
1640
+ }
1641
+ if (ex.baseUrl) {
1642
+ envMap.GEMINI_BASE_URL = ex.baseUrl;
1643
+ }
1267
1644
  }
1268
1645
  // COPILOT uses gh auth — no API key env vars needed.
1269
1646
  }
@@ -1280,17 +1657,28 @@ function handleApply(body) {
1280
1657
 
1281
1658
  // ── Build bosun.config.json ─────────────────────────────────────────────
1282
1659
  const config = {
1660
+ ...existingConfig,
1283
1661
  projectName: configJson.projectName || env.projectName || "my-project",
1284
1662
  bosunHome,
1285
1663
  workspacesDir,
1286
- executors: configJson.executors || [],
1664
+ executors:
1665
+ Array.isArray(configJson.executors)
1666
+ ? configJson.executors
1667
+ : Array.isArray(existingConfig.executors)
1668
+ ? existingConfig.executors
1669
+ : [],
1287
1670
  failover: configJson.failover || {
1671
+ ...(existingConfig.failover || {}),
1288
1672
  strategy: env.failoverStrategy || "next-in-line",
1289
1673
  maxRetries: Number(env.maxRetries) || 3,
1290
1674
  cooldownMinutes: Number(env.failoverCooldownMinutes) || 5,
1291
1675
  disableOnConsecutiveFailures: Number(env.failoverDisableOnConsecutive) || 3,
1292
1676
  },
1293
- distribution: configJson.distribution || env.executorDistribution || "primary-only",
1677
+ distribution:
1678
+ configJson.distribution ||
1679
+ existingConfig.distribution ||
1680
+ env.executorDistribution ||
1681
+ "primary-only",
1294
1682
  };
1295
1683
 
1296
1684
  const workflowProfile = normalizeWorkflowProfile(
@@ -1345,16 +1733,33 @@ function handleApply(body) {
1345
1733
  if (configJson.primaryAgent) config.primaryAgent = configJson.primaryAgent;
1346
1734
  if (configJson.projectRequirementsProfile) config.projectRequirementsProfile = configJson.projectRequirementsProfile;
1347
1735
  if (configJson.internalReplenish) config.internalReplenish = configJson.internalReplenish;
1348
- if (configJson.repos?.length) config.repos = configJson.repos;
1349
1736
  if (configJson.kanban) config.kanban = configJson.kanban;
1350
- if (configJson.workspaces?.length) config.workspaces = configJson.workspaces;
1737
+
1738
+ const workspaceConfig = resolveSetupWorkspaceAndRepoConfig(
1739
+ existingConfig,
1740
+ configJson,
1741
+ env,
1742
+ );
1743
+ if (workspaceConfig.repos.length > 0) {
1744
+ config.repos = workspaceConfig.repos;
1745
+ } else {
1746
+ delete config.repos;
1747
+ }
1748
+
1749
+ if (workspaceConfig.workspaces.length > 0) {
1750
+ config.workspaces = workspaceConfig.workspaces;
1751
+ config.activeWorkspace = workspaceConfig.activeWorkspace;
1752
+ } else {
1753
+ delete config.workspaces;
1754
+ delete config.activeWorkspace;
1755
+ }
1351
1756
 
1352
1757
  const configPath = resolve(bosunHome, "bosun.config.json");
1353
1758
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
1354
1759
 
1355
1760
  // ── Trust the BOSUN_HOME in every agent CLI ─────────────────────────────
1356
1761
  // Without this, running a codex agent from bosunHome is rejected with:
1357
- // " Project config.toml files are disabled…"
1762
+ // ":alert: Project config.toml files are disabled…"
1358
1763
  // We also register bosunHome with Claude Code so it won't prompt for
1359
1764
  // permission when accessing the workspace directories.
1360
1765
 
@@ -1362,7 +1767,7 @@ function handleApply(body) {
1362
1767
  try {
1363
1768
  const trustedResult = ensureTrustedProjects([bosunHome, workspacesDir]);
1364
1769
  if (trustedResult.added.length > 0) {
1365
- console.log(" Codex: trusted bosun home directory:", trustedResult.added.join(", "));
1770
+ console.log(" :check: Codex: trusted bosun home directory:", trustedResult.added.join(", "));
1366
1771
  }
1367
1772
  } catch (err) {
1368
1773
  console.warn("[setup] could not update codex trusted_projects:", err.message);
@@ -1373,7 +1778,7 @@ function handleApply(body) {
1373
1778
  const claudeResult = ensureClaudeAdditionalDirectory(bosunHome);
1374
1779
  const claudeWs = ensureClaudeAdditionalDirectory(workspacesDir);
1375
1780
  if (claudeResult.added || claudeWs.added) {
1376
- console.log(" Claude: added bosun directories to additionalDirectories");
1781
+ console.log(" :check: Claude: added bosun directories to additionalDirectories");
1377
1782
  }
1378
1783
  } catch (err) {
1379
1784
  console.warn("[setup] could not update claude settings:", err.message);
@@ -1521,7 +1926,7 @@ async function handleRequest(req, res) {
1521
1926
  jsonResponse(res, 200, { ok: true, message: "Setup complete" });
1522
1927
  // Shut down server after response is sent
1523
1928
  setTimeout(() => {
1524
- console.log("\n Setup complete — shutting down wizard server.\n");
1929
+ console.log("\n :check: Setup complete — shutting down wizard server.\n");
1525
1930
  if (callbackServer) callbackServer.close();
1526
1931
  server.close();
1527
1932
  process.exit(0);
@@ -1646,7 +2051,7 @@ async function startCallbackCatcher(setupPort) {
1646
2051
  </head>
1647
2052
  <body>
1648
2053
  <div class="card">
1649
- <div class="logo">🚀</div>
2054
+ <div class="logo">:rocket:</div>
1650
2055
  <h1>Bosun GitHub App Setup</h1>
1651
2056
  <p>Bosun needs to be running on your machine before you complete the GitHub Marketplace installation.</p>
1652
2057
  <div class="step"><strong>Step 1:</strong> Open a terminal and run:<br><br><code>bosun --setup</code></div>
@@ -1677,7 +2082,7 @@ async function startCallbackCatcher(setupPort) {
1677
2082
  </head>
1678
2083
  <body>
1679
2084
  <div class="card">
1680
- <div class="icon">✅</div>
2085
+ <div class="icon">:check:</div>
1681
2086
  <h1>GitHub App Authorized!</h1>
1682
2087
  <p>Redirecting you to the Bosun setup wizard…</p>
1683
2088
  </div>
@@ -1742,14 +2147,14 @@ async function startCallbackCatcher(setupPort) {
1742
2147
 
1743
2148
  try {
1744
2149
  await tryListen(callbackServer, CALLBACK_PORT);
1745
- console.log(` 📡 GitHub OAuth callback listener: http://127.0.0.1:${CALLBACK_PORT}/github/callback`);
2150
+ console.log(` :server: GitHub OAuth callback listener: http://127.0.0.1:${CALLBACK_PORT}/github/callback`);
1746
2151
  console.log(` ↳ Keep this terminal open while installing from GitHub Marketplace.\n`);
1747
2152
  } catch (err) {
1748
2153
  if (err.code === "EADDRINUSE") {
1749
2154
  // Another Bosun instance (or the main UI server) is already on this port — that's fine.
1750
- console.log(` ℹ️ Port ${CALLBACK_PORT} is already in use (main Bosun server may be running).\n`);
2155
+ console.log(` :help: Port ${CALLBACK_PORT} is already in use (main Bosun server may be running).\n`);
1751
2156
  } else {
1752
- console.warn(` ⚠️ Could not start OAuth callback listener on port ${CALLBACK_PORT}: ${err.message}`);
2157
+ console.warn(` :alert: Could not start OAuth callback listener on port ${CALLBACK_PORT}: ${err.message}`);
1753
2158
  }
1754
2159
  callbackServer = null;
1755
2160
  }
@@ -1807,7 +2212,7 @@ export async function startSetupServer(options = {}) {
1807
2212
  actualPort = await tryListen(server, 0);
1808
2213
  break;
1809
2214
  } catch (e) {
1810
- console.error(` Could not start setup server: ${e.message}`);
2215
+ console.error(` :close: Could not start setup server: ${e.message}`);
1811
2216
  process.exit(1);
1812
2217
  }
1813
2218
  }
@@ -1824,7 +2229,7 @@ export async function startSetupServer(options = {}) {
1824
2229
  console.log(`
1825
2230
  ┌──────────────────────────────────────────────────┐
1826
2231
  │ │
1827
- 🚀 Bosun Setup Wizard v${version.padEnd(25)}│
2232
+ :rocket: Bosun Setup Wizard v${version.padEnd(25)}│
1828
2233
  │ │
1829
2234
  │ Open in your browser: │
1830
2235
  │ ${url.padEnd(45)}│
@@ -1855,6 +2260,9 @@ export {
1855
2260
  applyTelegramMiniAppSetupEnv,
1856
2261
  applyNonBlockingSetupEnvDefaults,
1857
2262
  normalizeTelegramUiPort,
2263
+ normalizeRepoConfigEntry,
2264
+ normalizeWorkspaceConfigList,
2265
+ resolveSetupWorkspaceAndRepoConfig,
1858
2266
  };
1859
2267
 
1860
2268
  // Entry point when run directly