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
@@ -0,0 +1,692 @@
1
+ /**
2
+ * gemini-shell.mjs — Gemini adapter for Bosun.
3
+ *
4
+ * Supports:
5
+ * 1) Direct SDK calls via @google/genai
6
+ * 2) CLI fallback via the Gemini CLI binary
7
+ *
8
+ * Transport is controlled by GEMINI_TRANSPORT:
9
+ * auto (default) -> prefer SDK, fall back to CLI
10
+ * sdk -> SDK only
11
+ * cli -> CLI only
12
+ */
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import { resolve } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import {
19
+ isTransientStreamError,
20
+ streamRetryDelay,
21
+ MAX_STREAM_RETRIES,
22
+ } from "./stream-resilience.mjs";
23
+ import { resolveRepoRoot } from "./repo-root.mjs";
24
+
25
+ const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
26
+
27
+ const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000; // 60 min for agentic task runs
28
+ const STATE_FILE = resolve(__dirname, "logs", "gemini-shell-state.json");
29
+ const REPO_ROOT = resolveRepoRoot();
30
+ const MAX_PROMPT_BYTES = 180_000;
31
+
32
+ let GoogleGenAIClass = null;
33
+ let geminiClient = null;
34
+ let activeTurn = false;
35
+ let activeSessionId = null;
36
+ let turnCount = 0;
37
+ let stateLoaded = false;
38
+ let activeTransport = "auto";
39
+
40
+ function timestamp() {
41
+ return new Date().toISOString();
42
+ }
43
+
44
+ function envFlagEnabled(value) {
45
+ const raw = String(value ?? "")
46
+ .trim()
47
+ .toLowerCase();
48
+ return ["1", "true", "yes", "on", "y"].includes(raw);
49
+ }
50
+
51
+ function resolveGeminiTransport() {
52
+ const raw = String(process.env.GEMINI_TRANSPORT || "auto")
53
+ .trim()
54
+ .toLowerCase();
55
+ if (["auto", "sdk", "cli"].includes(raw)) return raw;
56
+ console.warn(
57
+ `[gemini-shell] invalid GEMINI_TRANSPORT='${raw}', defaulting to 'auto'`,
58
+ );
59
+ return "auto";
60
+ }
61
+
62
+ function resolveGeminiModel(options = {}) {
63
+ const explicit = String(options.model || "").trim();
64
+ if (explicit) return explicit;
65
+ return String(process.env.GEMINI_MODEL || "gemini-2.5-pro").trim();
66
+ }
67
+
68
+ function resolveGeminiApiKey() {
69
+ return String(
70
+ process.env.GEMINI_API_KEY ||
71
+ process.env.GOOGLE_API_KEY ||
72
+ "",
73
+ ).trim();
74
+ }
75
+
76
+ function resolveGeminiCliPath() {
77
+ return String(process.env.GEMINI_CLI_PATH || "gemini").trim();
78
+ }
79
+
80
+ function sanitizeAndTruncatePrompt(text) {
81
+ if (typeof text !== "string") return "";
82
+ // eslint-disable-next-line no-control-regex
83
+ const sanitized = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
84
+ const bytes = Buffer.byteLength(sanitized, "utf8");
85
+ if (bytes <= MAX_PROMPT_BYTES) return sanitized;
86
+ const buf = Buffer.from(sanitized, "utf8").slice(0, MAX_PROMPT_BYTES);
87
+ const truncated = buf.toString("utf8");
88
+ const removedBytes = bytes - MAX_PROMPT_BYTES;
89
+ console.warn(
90
+ `[gemini-shell] prompt truncated: ${bytes} → ${MAX_PROMPT_BYTES} bytes (removed ${removedBytes} bytes)`,
91
+ );
92
+ return truncated + `\n\n[...prompt truncated — ${removedBytes} bytes removed]`;
93
+ }
94
+
95
+ function appendStatusContext(prompt, statusData) {
96
+ if (!statusData || typeof statusData !== "object") return prompt;
97
+ try {
98
+ const payload = JSON.stringify(statusData, null, 2);
99
+ return `${prompt}\n\nOrchestrator Status:\n${payload}`;
100
+ } catch {
101
+ return prompt;
102
+ }
103
+ }
104
+
105
+ function splitArgs(input) {
106
+ const text = String(input || "").trim();
107
+ if (!text) return [];
108
+ const out = [];
109
+ const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|`([^`\\]*(?:\\.[^`\\]*)*)`|([^\s]+)/g;
110
+ let match = re.exec(text);
111
+ while (match) {
112
+ const token =
113
+ match[1] ?? match[2] ?? match[3] ?? match[4] ?? "";
114
+ if (token) out.push(token.replace(/\\(["'`\\])/g, "$1"));
115
+ match = re.exec(text);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ function extractTextFromGeminiResponse(response) {
121
+ if (!response) return "";
122
+ if (typeof response.text === "string") return response.text.trim();
123
+ if (typeof response.text === "function") {
124
+ try {
125
+ const text = response.text();
126
+ if (typeof text === "string") return text.trim();
127
+ } catch {
128
+ // no-op
129
+ }
130
+ }
131
+ const candidates = Array.isArray(response.candidates)
132
+ ? response.candidates
133
+ : [];
134
+ for (const candidate of candidates) {
135
+ const parts = candidate?.content?.parts;
136
+ if (!Array.isArray(parts)) continue;
137
+ const merged = parts
138
+ .map((part) => (typeof part?.text === "string" ? part.text : ""))
139
+ .filter(Boolean)
140
+ .join("\n")
141
+ .trim();
142
+ if (merged) return merged;
143
+ }
144
+ return "";
145
+ }
146
+
147
+ function extractUsage(response) {
148
+ const usage = response?.usageMetadata || response?.usage || null;
149
+ if (!usage || typeof usage !== "object") return null;
150
+ return {
151
+ promptTokens:
152
+ Number(usage.promptTokenCount ?? usage.input_tokens ?? usage.prompt_tokens) ||
153
+ null,
154
+ completionTokens:
155
+ Number(
156
+ usage.candidatesTokenCount ??
157
+ usage.output_tokens ??
158
+ usage.completion_tokens,
159
+ ) || null,
160
+ totalTokens:
161
+ Number(usage.totalTokenCount ?? usage.total_tokens) || null,
162
+ };
163
+ }
164
+
165
+ function extractTextFromCliOutput(stdout, stderr = "") {
166
+ const joined = String(stdout || "").trim();
167
+ const fallback = joined || String(stderr || "").trim();
168
+ if (!fallback) return "";
169
+
170
+ const tryParse = (raw) => {
171
+ if (!raw) return "";
172
+ try {
173
+ const data = JSON.parse(raw);
174
+ if (typeof data?.text === "string" && data.text.trim()) {
175
+ return data.text.trim();
176
+ }
177
+ if (typeof data?.output_text === "string" && data.output_text.trim()) {
178
+ return data.output_text.trim();
179
+ }
180
+ if (typeof data?.response?.text === "string" && data.response.text.trim()) {
181
+ return data.response.text.trim();
182
+ }
183
+ if (typeof data?.message === "string" && data.message.trim()) {
184
+ return data.message.trim();
185
+ }
186
+ if (Array.isArray(data?.candidates)) {
187
+ for (const candidate of data.candidates) {
188
+ const parts = candidate?.content?.parts;
189
+ if (!Array.isArray(parts)) continue;
190
+ const text = parts
191
+ .map((part) => (typeof part?.text === "string" ? part.text : ""))
192
+ .filter(Boolean)
193
+ .join("\n")
194
+ .trim();
195
+ if (text) return text;
196
+ }
197
+ }
198
+ } catch {
199
+ // ignore parse errors
200
+ }
201
+ return "";
202
+ };
203
+
204
+ const parsedWhole = tryParse(fallback);
205
+ if (parsedWhole) return parsedWhole;
206
+ for (const line of fallback.split(/\r?\n/).reverse()) {
207
+ const parsedLine = tryParse(line.trim());
208
+ if (parsedLine) return parsedLine;
209
+ }
210
+ return fallback;
211
+ }
212
+
213
+ async function loadGeminiSdk() {
214
+ if (GoogleGenAIClass) return GoogleGenAIClass;
215
+ try {
216
+ const mod = await import("@google/genai");
217
+ GoogleGenAIClass =
218
+ mod?.GoogleGenAI ||
219
+ mod?.default?.GoogleGenAI ||
220
+ mod?.default ||
221
+ null;
222
+ if (!GoogleGenAIClass) {
223
+ console.error("[gemini-shell] @google/genai loaded but GoogleGenAI export missing");
224
+ return null;
225
+ }
226
+ console.log("[gemini-shell] SDK loaded successfully");
227
+ return GoogleGenAIClass;
228
+ } catch (err) {
229
+ console.error(`[gemini-shell] failed to load @google/genai: ${err.message}`);
230
+ return null;
231
+ }
232
+ }
233
+
234
+ async function loadState() {
235
+ if (stateLoaded) return;
236
+ stateLoaded = true;
237
+ try {
238
+ const raw = await readFile(STATE_FILE, "utf8");
239
+ const data = JSON.parse(raw);
240
+ activeSessionId = data.activeSessionId || null;
241
+ turnCount = data.turnCount || 0;
242
+ activeTransport = data.activeTransport || "auto";
243
+ } catch {
244
+ activeSessionId = null;
245
+ turnCount = 0;
246
+ activeTransport = "auto";
247
+ }
248
+ }
249
+
250
+ async function saveState() {
251
+ try {
252
+ await mkdir(resolve(__dirname, "logs"), { recursive: true });
253
+ await writeFile(
254
+ STATE_FILE,
255
+ JSON.stringify(
256
+ {
257
+ activeSessionId,
258
+ turnCount,
259
+ activeTransport,
260
+ updatedAt: timestamp(),
261
+ },
262
+ null,
263
+ 2,
264
+ ),
265
+ "utf8",
266
+ );
267
+ } catch (err) {
268
+ console.warn(`[gemini-shell] failed to save state: ${err.message}`);
269
+ }
270
+ }
271
+
272
+ async function ensureGeminiClient() {
273
+ if (geminiClient) return true;
274
+ const apiKey = resolveGeminiApiKey();
275
+ if (!apiKey) {
276
+ console.warn("[gemini-shell] GEMINI_API_KEY/GOOGLE_API_KEY is not set");
277
+ return false;
278
+ }
279
+ const Cls = await loadGeminiSdk();
280
+ if (!Cls) return false;
281
+ try {
282
+ geminiClient = new Cls({ apiKey });
283
+ return true;
284
+ } catch (err) {
285
+ console.error(`[gemini-shell] failed to initialize SDK client: ${err.message}`);
286
+ geminiClient = null;
287
+ return false;
288
+ }
289
+ }
290
+
291
+ function withTimeout(promise, timeoutMs, abortSignal = null) {
292
+ return new Promise((resolvePromise, rejectPromise) => {
293
+ let settled = false;
294
+ const timer = setTimeout(() => {
295
+ if (settled) return;
296
+ settled = true;
297
+ rejectPromise(
298
+ new Error(`Gemini request timed out after ${Math.round(timeoutMs / 1000)}s`),
299
+ );
300
+ }, timeoutMs);
301
+
302
+ const onAbort = () => {
303
+ if (settled) return;
304
+ settled = true;
305
+ clearTimeout(timer);
306
+ rejectPromise(new Error("Gemini request aborted"));
307
+ };
308
+
309
+ if (abortSignal) {
310
+ if (abortSignal.aborted) return onAbort();
311
+ abortSignal.addEventListener("abort", onAbort, { once: true });
312
+ }
313
+
314
+ Promise.resolve(promise).then(
315
+ (value) => {
316
+ if (settled) return;
317
+ settled = true;
318
+ clearTimeout(timer);
319
+ if (abortSignal) {
320
+ abortSignal.removeEventListener("abort", onAbort);
321
+ }
322
+ resolvePromise(value);
323
+ },
324
+ (error) => {
325
+ if (settled) return;
326
+ settled = true;
327
+ clearTimeout(timer);
328
+ if (abortSignal) {
329
+ abortSignal.removeEventListener("abort", onAbort);
330
+ }
331
+ rejectPromise(error);
332
+ },
333
+ );
334
+ });
335
+ }
336
+
337
+ function runCliCommand(cliPath, args, timeoutMs, abortSignal = null) {
338
+ return new Promise((resolvePromise) => {
339
+ const child = spawn(cliPath, args, {
340
+ cwd: REPO_ROOT,
341
+ env: process.env,
342
+ stdio: ["ignore", "pipe", "pipe"],
343
+ windowsHide: true,
344
+ });
345
+
346
+ let stdout = "";
347
+ let stderr = "";
348
+ let settled = false;
349
+
350
+ const timer = setTimeout(() => {
351
+ if (settled) return;
352
+ settled = true;
353
+ try {
354
+ child.kill("SIGTERM");
355
+ } catch {
356
+ // no-op
357
+ }
358
+ resolvePromise({
359
+ ok: false,
360
+ code: null,
361
+ stdout,
362
+ stderr: stderr || `Timed out after ${Math.round(timeoutMs / 1000)}s`,
363
+ });
364
+ }, timeoutMs);
365
+
366
+ const onAbort = () => {
367
+ if (settled) return;
368
+ settled = true;
369
+ clearTimeout(timer);
370
+ try {
371
+ child.kill("SIGTERM");
372
+ } catch {
373
+ // no-op
374
+ }
375
+ resolvePromise({
376
+ ok: false,
377
+ code: null,
378
+ stdout,
379
+ stderr: "Aborted",
380
+ });
381
+ };
382
+
383
+ if (abortSignal) {
384
+ if (abortSignal.aborted) {
385
+ onAbort();
386
+ return;
387
+ }
388
+ abortSignal.addEventListener("abort", onAbort, { once: true });
389
+ }
390
+
391
+ child.stdout.on("data", (chunk) => {
392
+ stdout += String(chunk || "");
393
+ });
394
+ child.stderr.on("data", (chunk) => {
395
+ stderr += String(chunk || "");
396
+ });
397
+
398
+ child.on("error", (err) => {
399
+ if (settled) return;
400
+ settled = true;
401
+ clearTimeout(timer);
402
+ if (abortSignal) abortSignal.removeEventListener("abort", onAbort);
403
+ resolvePromise({
404
+ ok: false,
405
+ code: null,
406
+ stdout,
407
+ stderr: err.message || String(err),
408
+ });
409
+ });
410
+
411
+ child.on("close", (code) => {
412
+ if (settled) return;
413
+ settled = true;
414
+ clearTimeout(timer);
415
+ if (abortSignal) abortSignal.removeEventListener("abort", onAbort);
416
+ resolvePromise({
417
+ ok: code === 0,
418
+ code,
419
+ stdout,
420
+ stderr,
421
+ });
422
+ });
423
+ });
424
+ }
425
+
426
+ function buildCliAttempts(promptText) {
427
+ const attempts = [];
428
+ const custom = String(process.env.GEMINI_CLI_ARGS || "").trim();
429
+ if (custom) {
430
+ const rawTokens = splitArgs(custom);
431
+ if (rawTokens.length > 0) {
432
+ const hasPlaceholder = rawTokens.some((token) => token.includes("{prompt}"));
433
+ const mapped = rawTokens.map((token) =>
434
+ token.replaceAll("{prompt}", promptText),
435
+ );
436
+ if (!hasPlaceholder) mapped.push(promptText);
437
+ attempts.push(mapped);
438
+ }
439
+ }
440
+
441
+ attempts.push(["--prompt", promptText, "--format", "json"]);
442
+ attempts.push(["--prompt", promptText, "--format", "text"]);
443
+ attempts.push(["--prompt", promptText]);
444
+ attempts.push(["-p", promptText, "--format", "json"]);
445
+ attempts.push(["-p", promptText]);
446
+ attempts.push([promptText]);
447
+
448
+ return attempts;
449
+ }
450
+
451
+ async function execGeminiCliPrompt(promptText, options = {}) {
452
+ const cliPath = resolveGeminiCliPath();
453
+ const timeoutMs = Number(options.timeoutMs) > 0
454
+ ? Number(options.timeoutMs)
455
+ : DEFAULT_TIMEOUT_MS;
456
+ const attempts = buildCliAttempts(promptText);
457
+ let lastError = "Gemini CLI failed";
458
+
459
+ for (let i = 0; i < attempts.length; i++) {
460
+ const args = attempts[i];
461
+ if (typeof options.onEvent === "function") {
462
+ options.onEvent(
463
+ `:zap: Gemini CLI (${i + 1}/${attempts.length}): ${cliPath} ${args.join(" ")}`,
464
+ );
465
+ }
466
+ const result = await runCliCommand(
467
+ cliPath,
468
+ args,
469
+ timeoutMs,
470
+ options.abortController?.signal || null,
471
+ );
472
+ if (result.ok) {
473
+ const finalResponse = extractTextFromCliOutput(result.stdout, result.stderr);
474
+ return {
475
+ finalResponse: finalResponse || "Gemini CLI completed with no text output.",
476
+ items: finalResponse
477
+ ? [{ type: "text", text: finalResponse }]
478
+ : [],
479
+ usage: null,
480
+ };
481
+ }
482
+ const stderr = String(result.stderr || "").trim();
483
+ if (stderr) {
484
+ lastError = stderr;
485
+ } else if (result.code !== null) {
486
+ lastError = `Gemini CLI exited with code ${result.code}`;
487
+ }
488
+ }
489
+
490
+ return {
491
+ finalResponse: `:close: Gemini CLI failed: ${lastError}`,
492
+ items: [],
493
+ usage: null,
494
+ };
495
+ }
496
+
497
+ export async function execGeminiPrompt(userMessage, options = {}) {
498
+ await loadState();
499
+
500
+ if (envFlagEnabled(process.env.GEMINI_SDK_DISABLED)) {
501
+ return {
502
+ finalResponse: ":close: Gemini adapter disabled via GEMINI_SDK_DISABLED.",
503
+ items: [],
504
+ usage: null,
505
+ };
506
+ }
507
+
508
+ if (activeTurn) {
509
+ return {
510
+ finalResponse:
511
+ ":clock: Gemini agent is still executing a previous task. Please wait.",
512
+ items: [],
513
+ usage: null,
514
+ };
515
+ }
516
+
517
+ const timeoutMs = Number(options.timeoutMs) > 0
518
+ ? Number(options.timeoutMs)
519
+ : DEFAULT_TIMEOUT_MS;
520
+ const transport = resolveGeminiTransport();
521
+ activeTransport = transport;
522
+
523
+ const preferredSession = String(options.sessionId || "").trim();
524
+ if (preferredSession) {
525
+ activeSessionId = preferredSession;
526
+ } else if (!activeSessionId) {
527
+ activeSessionId = "primary-gemini";
528
+ }
529
+
530
+ const preparedPrompt = sanitizeAndTruncatePrompt(
531
+ appendStatusContext(String(userMessage || ""), options.statusData),
532
+ );
533
+ activeTurn = true;
534
+ let retryAttempt = 0;
535
+
536
+ while (retryAttempt <= MAX_STREAM_RETRIES) {
537
+ try {
538
+ if (transport === "cli") {
539
+ const cliResult = await execGeminiCliPrompt(preparedPrompt, options);
540
+ turnCount += 1;
541
+ await saveState();
542
+ return cliResult;
543
+ }
544
+
545
+ if (typeof options.onEvent === "function") {
546
+ options.onEvent(":cpu: Gemini SDK: generating response…");
547
+ }
548
+
549
+ const sdkReady = await ensureGeminiClient();
550
+ if (!sdkReady) {
551
+ if (transport === "sdk") {
552
+ return {
553
+ finalResponse:
554
+ ":close: Gemini SDK unavailable. Install @google/genai and set GEMINI_API_KEY (or GOOGLE_API_KEY), or set GEMINI_TRANSPORT=cli.",
555
+ items: [],
556
+ usage: null,
557
+ };
558
+ }
559
+ const cliResult = await execGeminiCliPrompt(preparedPrompt, options);
560
+ turnCount += 1;
561
+ await saveState();
562
+ return cliResult;
563
+ }
564
+
565
+ const model = resolveGeminiModel(options);
566
+ const response = await withTimeout(
567
+ geminiClient.models.generateContent({
568
+ model,
569
+ contents: preparedPrompt,
570
+ }),
571
+ timeoutMs,
572
+ options.abortController?.signal || null,
573
+ );
574
+ const finalResponse = extractTextFromGeminiResponse(response);
575
+ turnCount += 1;
576
+ await saveState();
577
+ return {
578
+ finalResponse: finalResponse || "Gemini SDK completed with no text output.",
579
+ items: finalResponse
580
+ ? [{ type: "text", text: finalResponse }]
581
+ : [],
582
+ usage: extractUsage(response),
583
+ };
584
+ } catch (err) {
585
+ const retryable = isTransientStreamError(err) && retryAttempt < MAX_STREAM_RETRIES;
586
+ if (retryable) {
587
+ retryAttempt += 1;
588
+ const delay = streamRetryDelay(retryAttempt);
589
+ console.warn(
590
+ `[gemini-shell] transient error (attempt ${retryAttempt}/${MAX_STREAM_RETRIES}): ${err.message || err} — retrying in ${Math.round(delay)}ms`,
591
+ );
592
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, delay));
593
+ continue;
594
+ }
595
+ return {
596
+ finalResponse: `:close: Gemini agent failed: ${err.message || String(err)}`,
597
+ items: [],
598
+ usage: null,
599
+ };
600
+ } finally {
601
+ activeTurn = false;
602
+ }
603
+ }
604
+
605
+ activeTurn = false;
606
+ return {
607
+ finalResponse: ":close: Gemini agent failed after all retry attempts.",
608
+ items: [],
609
+ usage: null,
610
+ };
611
+ }
612
+
613
+ export async function steerGeminiPrompt() {
614
+ return {
615
+ ok: false,
616
+ reason: activeTurn ? "steering_unsupported" : "idle",
617
+ message: activeTurn
618
+ ? "Gemini adapter does not support steering during active turns."
619
+ : "No active Gemini turn.",
620
+ };
621
+ }
622
+
623
+ export function isGeminiBusy() {
624
+ return activeTurn;
625
+ }
626
+
627
+ export function getSessionInfo() {
628
+ return {
629
+ sessionId: activeSessionId,
630
+ turnCount,
631
+ isActive: Boolean(activeSessionId),
632
+ isBusy: activeTurn,
633
+ transport: activeTransport,
634
+ };
635
+ }
636
+
637
+ export function getActiveSessionId() {
638
+ return activeSessionId;
639
+ }
640
+
641
+ export async function listSessions() {
642
+ await loadState();
643
+ if (!activeSessionId) return [];
644
+ return [
645
+ {
646
+ id: activeSessionId,
647
+ title: "Gemini Session",
648
+ active: true,
649
+ turnCount,
650
+ },
651
+ ];
652
+ }
653
+
654
+ export async function switchSession(id) {
655
+ await loadState();
656
+ const next = String(id || "").trim();
657
+ if (!next) return;
658
+ activeSessionId = next;
659
+ await saveState();
660
+ }
661
+
662
+ export async function createSession(id) {
663
+ await loadState();
664
+ const next = String(id || "").trim();
665
+ if (!next) {
666
+ throw new Error("session id required");
667
+ }
668
+ activeSessionId = next;
669
+ turnCount = 0;
670
+ await saveState();
671
+ return { id: next };
672
+ }
673
+
674
+ export async function resetSession() {
675
+ activeTurn = false;
676
+ activeSessionId = null;
677
+ turnCount = 0;
678
+ activeTransport = "auto";
679
+ geminiClient = null;
680
+ await saveState();
681
+ }
682
+
683
+ export async function initGeminiShell() {
684
+ await loadState();
685
+ if (envFlagEnabled(process.env.GEMINI_SDK_DISABLED)) return false;
686
+ const transport = resolveGeminiTransport();
687
+ activeTransport = transport;
688
+ if (transport === "cli") return true;
689
+ if (transport === "sdk") return ensureGeminiClient();
690
+ const sdkReady = await ensureGeminiClient();
691
+ return sdkReady || true;
692
+ }
@@ -269,7 +269,7 @@ function htmlPage(title, bodyHtml) {
269
269
  </style>
270
270
  </head>
271
271
  <body>
272
- <div class="logo">⚓</div>
272
+ <div class="logo">:link:</div>
273
273
  <h1>Bosun</h1>
274
274
  <div class="subtitle">GitHub App OAuth Setup Portal · <code>bosun-ve</code></div>
275
275
  ${bodyHtml}
@@ -339,7 +339,7 @@ export class GitHubReconciler {
339
339
  : "";
340
340
  await this.addComment(
341
341
  issueNumber,
342
- `## Auto-Reconciled\nThis issue was auto-closed by bosun after detecting merged PR linkage.${suffix}`,
342
+ `## :check: Auto-Reconciled\nThis issue was auto-closed by bosun after detecting merged PR linkage.${suffix}`,
343
343
  );
344
344
  }
345
345
  summary.closed += 1;
@@ -463,7 +463,7 @@ export class GitHubReconciler {
463
463
  if (this.sendTelegram) {
464
464
  void Promise.resolve(
465
465
  this.sendTelegram(
466
- `⚠️ GitHub reconciler cycle failed: ${msg}`,
466
+ `:alert: GitHub reconciler cycle failed: ${msg}`,
467
467
  ),
468
468
  ).catch(() => {});
469
469
  }