bosun 0.36.7 → 0.36.8

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.
package/bosun.schema.json CHANGED
@@ -188,7 +188,11 @@
188
188
  "verse"
189
189
  ]
190
190
  },
191
- "azureDeployment": { "type": "string" }
191
+ "azureDeployment": { "type": "string" },
192
+ "endpointId": {
193
+ "type": "string",
194
+ "description": "ID of the voice endpoint to use for this provider slot"
195
+ }
192
196
  },
193
197
  "required": ["provider"]
194
198
  }
@@ -304,13 +308,17 @@
304
308
  "additionalProperties": false,
305
309
  "required": ["provider"],
306
310
  "properties": {
311
+ "id": {
312
+ "type": "string",
313
+ "description": "Unique identifier for this endpoint"
314
+ },
307
315
  "name": {
308
316
  "type": "string",
309
317
  "description": "Friendly label for this endpoint"
310
318
  },
311
319
  "provider": {
312
320
  "type": "string",
313
- "enum": ["azure", "openai"],
321
+ "enum": ["azure", "openai", "claude", "gemini"],
314
322
  "description": "Provider type"
315
323
  },
316
324
  "endpoint": {
package/desktop/main.mjs CHANGED
@@ -1135,6 +1135,18 @@ function registerDesktopIpc() {
1135
1135
 
1136
1136
  async function bootstrap() {
1137
1137
  try {
1138
+ // Register cert bypass for the local UI server as the very first operation
1139
+ // — before any network request is made (config loading, API key probe, etc.).
1140
+ // allow-insecure-localhost (set pre-ready above) handles 127.0.0.1; this
1141
+ // setCertificateVerifyProc covers LAN IPs (192.168.x.x / 10.x etc.).
1142
+ session.defaultSession.setCertificateVerifyProc((request, callback) => {
1143
+ if (isLocalHost(request.hostname)) {
1144
+ callback(0); // 0 = verified OK
1145
+ return;
1146
+ }
1147
+ callback(-3); // -3 = use Chromium default chain verification
1148
+ });
1149
+
1138
1150
  if (process.env.ELECTRON_DISABLE_SANDBOX === "1") {
1139
1151
  app.commandLine.appendSwitch("no-sandbox");
1140
1152
  app.commandLine.appendSwitch("disable-gpu-sandbox");
@@ -1163,18 +1175,6 @@ async function bootstrap() {
1163
1175
  console.warn("[desktop] could not load desktop-api-key module:", err?.message || err);
1164
1176
  }
1165
1177
 
1166
- // Bypass TLS verification for the local embedded UI server.
1167
- // setCertificateVerifyProc works at the OpenSSL level — it fires before
1168
- // the higher-level `certificate-error` event and stops the repeated
1169
- // "handshake failed" logs from Chromium's ssl_client_socket_impl.
1170
- session.defaultSession.setCertificateVerifyProc((request, callback) => {
1171
- if (isLocalHost(request.hostname)) {
1172
- callback(0); // 0 = verified OK
1173
- return;
1174
- }
1175
- callback(-3); // -3 = use Chromium default chain verification
1176
- });
1177
-
1178
1178
  await ensureDaemonRunning();
1179
1179
 
1180
1180
  // Initialise shortcuts (loads user config) and register globals.
@@ -1316,4 +1316,14 @@ process.on("SIGTERM", () => {
1316
1316
  void shutdown("sigterm");
1317
1317
  });
1318
1318
 
1319
+ // ── Pre-ready Chromium flags ──────────────────────────────────────────────────
1320
+ // These MUST be set before app.isReady() — Chromium reads the command line at
1321
+ // process startup and ignores changes made after the browser process launches.
1322
+
1323
+ // Allow HTTPS connections to localhost (127.0.0.1, ::1, "localhost") using
1324
+ // self-signed certificates without triggering CertVerifyProcBuiltin errors or
1325
+ // ssl_client_socket_impl handshake-failed spam. This only suppresses cert
1326
+ // errors for the loopback address; external HTTPS connections are unaffected.
1327
+ app.commandLine.appendSwitch("allow-insecure-localhost");
1328
+
1319
1329
  app.whenReady().then(bootstrap);
package/monitor.mjs CHANGED
@@ -6610,6 +6610,18 @@ async function checkMergedPRsAndUpdateTasks() {
6610
6610
  }
6611
6611
 
6612
6612
  if (worktreePath) {
6613
+ if (isWorkflowReplacingModule("sdk-conflict-resolver.mjs")) {
6614
+ console.log(`[monitor] SDK conflict resolution delegated to workflow for PR #${cc.prNumber}`);
6615
+ void queueWorkflowEvent("pr.conflict_detected", {
6616
+ worktreePath,
6617
+ branch: cc.branch,
6618
+ baseBranch: resolveAttemptTargetBranch(attemptInfo, task),
6619
+ prNumber: cc.prNumber,
6620
+ taskId: task.id,
6621
+ taskTitle: task.title,
6622
+ taskDescription: task.description || "",
6623
+ });
6624
+ } else {
6613
6625
  void (async () => {
6614
6626
  try {
6615
6627
  const result = await resolveConflictsWithSDK({
@@ -6655,6 +6667,7 @@ async function checkMergedPRsAndUpdateTasks() {
6655
6667
  );
6656
6668
  }
6657
6669
  })();
6670
+ } // end else (workflow not replacing sdk-conflict-resolver)
6658
6671
  } else {
6659
6672
  console.warn(
6660
6673
  `[monitor] No worktree found for ${cc.branch} — deferring to orchestrator`,
@@ -15169,11 +15182,13 @@ try {
15169
15182
  runGuarded("startup-maintenance-sweep", () =>
15170
15183
  runMaintenanceSweep({
15171
15184
  repoRoot,
15172
- archiveCompletedTasks: async () => {
15173
- const projectId = await findVkProjectId();
15174
- if (!projectId) return { archived: 0 };
15175
- return await archiveCompletedTasks(fetchVk, projectId, { maxArchive: 50 });
15176
- },
15185
+ archiveCompletedTasks: isWorkflowReplacingModule("task-archiver.mjs")
15186
+ ? async () => { console.log("[monitor] task archiver delegated to workflow"); return { archived: 0 }; }
15187
+ : async () => {
15188
+ const projectId = await findVkProjectId();
15189
+ if (!projectId) return { archived: 0 };
15190
+ return await archiveCompletedTasks(fetchVk, projectId, { maxArchive: 50 });
15191
+ },
15177
15192
  }),
15178
15193
  );
15179
15194
 
@@ -15186,14 +15201,16 @@ safeSetInterval("maintenance-sweep", () => {
15186
15201
  return runMaintenanceSweep({
15187
15202
  repoRoot,
15188
15203
  childPid,
15189
- archiveCompletedTasks: async () => {
15190
- const projectId = await findVkProjectId();
15191
- if (!projectId) return { archived: 0 };
15192
- return await archiveCompletedTasks(fetchVk, projectId, {
15193
- maxArchive: 25,
15194
- dryRun: false,
15195
- });
15196
- },
15204
+ archiveCompletedTasks: isWorkflowReplacingModule("task-archiver.mjs")
15205
+ ? async () => { console.log("[monitor] task archiver delegated to workflow"); return { archived: 0 }; }
15206
+ : async () => {
15207
+ const projectId = await findVkProjectId();
15208
+ if (!projectId) return { archived: 0 };
15209
+ return await archiveCompletedTasks(fetchVk, projectId, {
15210
+ maxArchive: 25,
15211
+ dryRun: false,
15212
+ });
15213
+ },
15197
15214
  });
15198
15215
  }, maintenanceIntervalMs);
15199
15216
 
@@ -16054,6 +16071,9 @@ if (isExecutorDisabled()) {
16054
16071
 
16055
16072
  // ── Sync Engine ──
16056
16073
  try {
16074
+ if (isWorkflowReplacingModule("sync-engine.mjs")) {
16075
+ console.log("[monitor] sync engine delegated to workflow — skipping legacy init");
16076
+ } else {
16057
16077
  const activeKanbanBackend = getActiveKanbanBackend();
16058
16078
 
16059
16079
  // Sync engine only makes sense when there is an external backend to sync
@@ -16097,6 +16117,7 @@ if (isExecutorDisabled()) {
16097
16117
  );
16098
16118
  }
16099
16119
  } // end else (non-internal backend)
16120
+ } // end else (workflow not replacing sync-engine)
16100
16121
  } catch (err) {
16101
16122
  console.warn(`[monitor] sync engine failed to start: ${err.message}`);
16102
16123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.36.7",
3
+ "version": "0.36.8",
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",
@@ -30,7 +30,7 @@ const DEFAULT_CHAT_MAX_MESSAGES = 2000;
30
30
  const MAX_MESSAGE_CHARS = 2000;
31
31
 
32
32
  /** Maximum total sessions to keep in memory. */
33
- const MAX_SESSIONS = 50;
33
+ const MAX_SESSIONS = 100;
34
34
 
35
35
  function resolveSessionMaxMessages(type, metadata, explicitMax, fallbackMax) {
36
36
  if (Number.isFinite(explicitMax)) {
@@ -124,6 +124,9 @@ export class SessionTracker {
124
124
  /** @type {ReturnType<typeof setInterval>|null} */
125
125
  #flushTimer = null;
126
126
 
127
+ /** @type {ReturnType<typeof setInterval>|null} */
128
+ #reaperTimer = null;
129
+
127
130
  /**
128
131
  * @param {Object} [options]
129
132
  * @param {number} [options.maxMessages=10]
@@ -138,9 +141,15 @@ export class SessionTracker {
138
141
  if (this.#persistDir) {
139
142
  this.#ensureDir();
140
143
  this.#loadFromDisk();
144
+ this.#purgeExcessFiles();
141
145
  this.#flushTimer = setInterval(() => this.#flushDirty(), FLUSH_INTERVAL_MS);
142
146
  if (this.#flushTimer.unref) this.#flushTimer.unref();
143
147
  }
148
+
149
+ // Idle reaper — runs periodically to mark stale "active" sessions as "completed"
150
+ const reaperInterval = Math.max(60_000, this.#idleThresholdMs);
151
+ this.#reaperTimer = setInterval(() => this.#reapIdleSessions(), reaperInterval);
152
+ if (this.#reaperTimer.unref) this.#reaperTimer.unref();
144
153
  }
145
154
 
146
155
  /**
@@ -153,12 +162,7 @@ export class SessionTracker {
153
162
  startSession(taskId, taskTitle) {
154
163
  // Evict oldest sessions if at capacity
155
164
  if (this.#sessions.size >= MAX_SESSIONS && !this.#sessions.has(taskId)) {
156
- const oldest = [...this.#sessions.entries()]
157
- .sort((a, b) => a[1].startedAt - b[1].startedAt)
158
- .slice(0, Math.ceil(MAX_SESSIONS / 4));
159
- for (const [id] of oldest) {
160
- this.#sessions.delete(id);
161
- }
165
+ this.#evictOldest();
162
166
  }
163
167
 
164
168
  this.#sessions.set(taskId, {
@@ -475,6 +479,11 @@ export class SessionTracker {
475
479
  * @param {{ id: string, type?: string, taskId?: string, metadata?: Object }} opts
476
480
  */
477
481
  createSession({ id, type = "manual", taskId, metadata = {}, maxMessages }) {
482
+ // Evict oldest non-active sessions if at capacity
483
+ if (this.#sessions.size >= MAX_SESSIONS && !this.#sessions.has(id)) {
484
+ this.#evictOldest();
485
+ }
486
+
478
487
  const now = new Date().toISOString();
479
488
  const resolvedMax = resolveSessionMaxMessages(
480
489
  type,
@@ -666,6 +675,7 @@ export class SessionTracker {
666
675
  /**
667
676
  * Merge any on-disk session updates into memory.
668
677
  * Useful when another process writes session files.
678
+ * Respects MAX_SESSIONS and heals stale "active" status.
669
679
  */
670
680
  refreshFromDisk() {
671
681
  if (!this.#persistDir) return;
@@ -676,6 +686,10 @@ export class SessionTracker {
676
686
  } catch {
677
687
  return;
678
688
  }
689
+
690
+ // Pre-parse for sorting
691
+ /** @type {Array<{file: string, data: Object, lastActive: number}>} */
692
+ const parsed = [];
679
693
  for (const file of files) {
680
694
  const filePath = resolve(this.#persistDir, file);
681
695
  try {
@@ -687,34 +701,53 @@ export class SessionTracker {
687
701
  Date.parse(data.lastActiveAt || "") ||
688
702
  Date.parse(data.updatedAt || "") ||
689
703
  0;
704
+ // Skip if already in memory and newer
690
705
  const existing = this.#sessions.get(sessionId);
691
706
  const existingLast =
692
707
  existing?.lastActivityAt ||
693
708
  Date.parse(existing?.lastActiveAt || "") ||
694
709
  0;
695
- if (existing && existingLast >= lastActiveAt) {
696
- continue;
697
- }
698
- this.#sessions.set(sessionId, {
699
- taskId: data.taskId || sessionId,
700
- taskTitle: data.title || data.taskTitle || null,
701
- id: sessionId,
702
- type: data.type || "task",
703
- startedAt: Date.parse(data.createdAt || "") || Date.now(),
704
- createdAt: data.createdAt || new Date().toISOString(),
705
- lastActiveAt: data.lastActiveAt || data.updatedAt || new Date().toISOString(),
706
- endedAt: data.endedAt || null,
707
- messages: Array.isArray(data.messages) ? data.messages : [],
708
- totalEvents: Array.isArray(data.messages) ? data.messages.length : 0,
709
- turnCount: data.turnCount || 0,
710
- status: data.status || "active",
711
- lastActivityAt: lastActiveAt || Date.now(),
712
- metadata: data.metadata || {},
713
- });
710
+ if (existing && existingLast >= lastActiveAt) continue;
711
+ parsed.push({ file, data, lastActive: lastActiveAt });
714
712
  } catch {
715
713
  /* ignore corrupt session file */
716
714
  }
717
715
  }
716
+
717
+ // Sort by lastActive descending and limit to MAX_SESSIONS
718
+ parsed.sort((a, b) => b.lastActive - a.lastActive);
719
+ const available = MAX_SESSIONS - this.#sessions.size;
720
+ const toLoad = parsed.slice(0, Math.max(0, available));
721
+
722
+ for (const { data, lastActive } of toLoad) {
723
+ const sessionId = String(data.id || data.taskId || "").trim();
724
+ // Heal stale "active" sessions
725
+ let status = data.status || "completed";
726
+ let endedAt = data.endedAt || null;
727
+ if (status === "active" && lastActive > 0) {
728
+ const ageMs = Date.now() - lastActive;
729
+ if (ageMs > this.#idleThresholdMs) {
730
+ status = "completed";
731
+ endedAt = endedAt || lastActive;
732
+ }
733
+ }
734
+ this.#sessions.set(sessionId, {
735
+ taskId: data.taskId || sessionId,
736
+ taskTitle: data.title || data.taskTitle || null,
737
+ id: sessionId,
738
+ type: data.type || "task",
739
+ startedAt: Date.parse(data.createdAt || "") || Date.now(),
740
+ createdAt: data.createdAt || new Date().toISOString(),
741
+ lastActiveAt: data.lastActiveAt || data.updatedAt || new Date().toISOString(),
742
+ endedAt,
743
+ messages: Array.isArray(data.messages) ? data.messages : [],
744
+ totalEvents: Array.isArray(data.messages) ? data.messages.length : 0,
745
+ turnCount: data.turnCount || 0,
746
+ status,
747
+ lastActivityAt: lastActive || Date.now(),
748
+ metadata: data.metadata || {},
749
+ });
750
+ }
718
751
  }
719
752
 
720
753
  // ── Private helpers ───────────────────────────────────────────────────────
@@ -730,6 +763,49 @@ export class SessionTracker {
730
763
  });
731
764
  }
732
765
 
766
+ /**
767
+ * Evict the oldest 25% of sessions, preferring completed/idle sessions first.
768
+ * Active sessions are only evicted as a last resort.
769
+ */
770
+ #evictOldest() {
771
+ const evictCount = Math.max(1, Math.ceil(MAX_SESSIONS / 4));
772
+ // Prefer evicting completed/idle/failed sessions before active ones
773
+ const sorted = [...this.#sessions.entries()]
774
+ .sort((a, b) => {
775
+ const aActive = a[1].status === "active" ? 1 : 0;
776
+ const bActive = b[1].status === "active" ? 1 : 0;
777
+ if (aActive !== bActive) return aActive - bActive; // non-active first
778
+ return (a[1].lastActivityAt || a[1].startedAt) - (b[1].lastActivityAt || b[1].startedAt);
779
+ });
780
+ const toEvict = sorted.slice(0, evictCount);
781
+ for (const [id] of toEvict) {
782
+ this.#sessions.delete(id);
783
+ }
784
+ }
785
+
786
+ /**
787
+ * Reap idle sessions: mark sessions as "completed" if they have been
788
+ * inactive for longer than the idle threshold.
789
+ * Called periodically by the reaper interval.
790
+ */
791
+ #reapIdleSessions() {
792
+ const now = Date.now();
793
+ let reaped = 0;
794
+ for (const [id, session] of this.#sessions) {
795
+ if (session.status !== "active") continue;
796
+ const idleMs = now - (session.lastActivityAt || session.startedAt || now);
797
+ if (idleMs > this.#idleThresholdMs) {
798
+ session.status = "completed";
799
+ session.endedAt = now;
800
+ this.#markDirty(id);
801
+ reaped++;
802
+ }
803
+ }
804
+ if (reaped > 0) {
805
+ console.log(`${TAG} idle reaper: marked ${reaped} stale session(s) as completed`);
806
+ }
807
+ }
808
+
733
809
  /** Get preview text from last message */
734
810
  #lastMessagePreview(session) {
735
811
  const last = session.messages?.at(-1);
@@ -785,10 +861,17 @@ export class SessionTracker {
785
861
  this.#dirty.clear();
786
862
  }
787
863
 
864
+ /** @type {Set<string>} filenames loaded during #loadFromDisk (for purge) */
865
+ #loadedFiles = new Set();
866
+
788
867
  #loadFromDisk() {
789
868
  if (!this.#persistDir || !existsSync(this.#persistDir)) return;
790
869
  try {
791
870
  const files = readdirSync(this.#persistDir).filter((f) => f.endsWith(".json"));
871
+
872
+ // Pre-parse all session files with their timestamps for sorting
873
+ /** @type {Array<{file: string, data: Object, lastActive: number}>} */
874
+ const parsed = [];
792
875
  for (const file of files) {
793
876
  try {
794
877
  const raw = readFileSync(resolve(this.#persistDir, file), "utf8");
@@ -796,31 +879,89 @@ export class SessionTracker {
796
879
  if (!data.id && !data.taskId) continue;
797
880
  const id = data.id || data.taskId;
798
881
  if (this.#sessions.has(id)) continue; // don't overwrite in-memory
799
- this.#sessions.set(id, {
800
- id,
801
- taskId: data.taskId || id,
802
- taskTitle: data.metadata?.title || id,
803
- type: data.type || "task",
804
- status: data.status || "completed",
805
- createdAt: data.createdAt || new Date().toISOString(),
806
- lastActiveAt: data.lastActiveAt || new Date().toISOString(),
807
- startedAt: data.createdAt ? new Date(data.createdAt).getTime() : Date.now(),
808
- endedAt: data.status !== "active" ? Date.now() : null,
809
- messages: data.messages || [],
810
- totalEvents: (data.messages || []).length,
811
- turnCount: data.turnCount || 0,
812
- lastActivityAt: data.lastActiveAt ? new Date(data.lastActiveAt).getTime() : Date.now(),
813
- metadata: data.metadata || {},
814
- });
882
+ const lastActive = data.lastActiveAt
883
+ ? new Date(data.lastActiveAt).getTime()
884
+ : data.createdAt
885
+ ? new Date(data.createdAt).getTime()
886
+ : 0;
887
+ parsed.push({ file, data, lastActive });
815
888
  } catch {
816
889
  // Skip corrupt files
817
890
  }
818
891
  }
892
+
893
+ // Sort by lastActive descending (newest first) and keep only MAX_SESSIONS
894
+ parsed.sort((a, b) => b.lastActive - a.lastActive);
895
+ const toLoad = parsed.slice(0, MAX_SESSIONS);
896
+
897
+ // Track which files were loaded so #purgeExcessFiles can remove the rest
898
+ this.#loadedFiles = new Set(toLoad.map((p) => p.file));
899
+
900
+ for (const { data, lastActive } of toLoad) {
901
+ const id = data.id || data.taskId;
902
+ // Heal stale "active" sessions — if restored from disk and the last
903
+ // activity was more than idleThresholdMs ago, mark as completed.
904
+ let status = data.status || "completed";
905
+ let endedAt = data.endedAt || null;
906
+ if (status === "active" && lastActive > 0) {
907
+ const ageMs = Date.now() - lastActive;
908
+ if (ageMs > this.#idleThresholdMs) {
909
+ status = "completed";
910
+ endedAt = endedAt || lastActive;
911
+ }
912
+ }
913
+
914
+ this.#sessions.set(id, {
915
+ id,
916
+ taskId: data.taskId || id,
917
+ taskTitle: data.metadata?.title || id,
918
+ type: data.type || "task",
919
+ status,
920
+ createdAt: data.createdAt || new Date().toISOString(),
921
+ lastActiveAt: data.lastActiveAt || new Date().toISOString(),
922
+ startedAt: data.createdAt ? new Date(data.createdAt).getTime() : Date.now(),
923
+ endedAt,
924
+ messages: data.messages || [],
925
+ totalEvents: (data.messages || []).length,
926
+ turnCount: data.turnCount || 0,
927
+ lastActivityAt: lastActive || Date.now(),
928
+ metadata: data.metadata || {},
929
+ });
930
+ }
819
931
  } catch {
820
932
  // Directory read failed — proceed without disk data
821
933
  }
822
934
  }
823
935
 
936
+ /**
937
+ * Remove session files that were NOT loaded into memory (excess beyond MAX_SESSIONS).
938
+ * This runs once at startup to clean up historical bloat.
939
+ */
940
+ #purgeExcessFiles() {
941
+ if (!this.#persistDir || !existsSync(this.#persistDir)) return;
942
+ try {
943
+ const files = readdirSync(this.#persistDir).filter((f) => f.endsWith(".json"));
944
+ let purged = 0;
945
+ for (const file of files) {
946
+ if (!this.#loadedFiles.has(file)) {
947
+ try {
948
+ unlinkSync(resolve(this.#persistDir, file));
949
+ purged++;
950
+ } catch {
951
+ // best-effort cleanup
952
+ }
953
+ }
954
+ }
955
+ if (purged > 0) {
956
+ console.log(`${TAG} purged ${purged} excess session file(s) from disk`);
957
+ }
958
+ // Free the reference — only needed once at startup
959
+ this.#loadedFiles.clear();
960
+ } catch {
961
+ // best-effort
962
+ }
963
+ }
964
+
824
965
  /**
825
966
  * Normalize a raw SDK event into a SessionMessage.
826
967
  * Returns null for events that shouldn't be tracked (noise).