bosun 0.37.0 → 0.37.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 (43) hide show
  1. package/.env.example +4 -1
  2. package/agent-tool-config.mjs +338 -0
  3. package/bosun-skills.mjs +59 -4
  4. package/bosun.schema.json +1 -1
  5. package/desktop/launch.mjs +18 -0
  6. package/desktop/main.mjs +52 -13
  7. package/fleet-coordinator.mjs +34 -1
  8. package/kanban-adapter.mjs +30 -3
  9. package/library-manager.mjs +66 -0
  10. package/maintenance.mjs +30 -5
  11. package/monitor.mjs +56 -0
  12. package/package.json +4 -1
  13. package/setup-web-server.mjs +73 -12
  14. package/setup.mjs +3 -3
  15. package/ui/app.js +40 -3
  16. package/ui/components/session-list.js +25 -7
  17. package/ui/components/workspace-switcher.js +48 -1
  18. package/ui/demo.html +176 -0
  19. package/ui/modules/mic-track-registry.js +83 -0
  20. package/ui/modules/settings-schema.js +4 -1
  21. package/ui/modules/state.js +25 -0
  22. package/ui/modules/streaming.js +1 -1
  23. package/ui/modules/voice-barge-in.js +27 -0
  24. package/ui/modules/voice-client-sdk.js +268 -42
  25. package/ui/modules/voice-client.js +665 -61
  26. package/ui/modules/voice-overlay.js +829 -47
  27. package/ui/setup.html +151 -9
  28. package/ui/styles.css +258 -0
  29. package/ui/tabs/chat.js +11 -0
  30. package/ui/tabs/library.js +890 -15
  31. package/ui/tabs/settings.js +51 -11
  32. package/ui/tabs/telemetry.js +327 -105
  33. package/ui/tabs/workflows.js +86 -0
  34. package/ui-server.mjs +1201 -107
  35. package/voice-action-dispatcher.mjs +81 -0
  36. package/voice-agents-sdk.mjs +2 -2
  37. package/voice-relay.mjs +131 -14
  38. package/voice-tools.mjs +475 -9
  39. package/workflow-engine.mjs +54 -0
  40. package/workflow-nodes.mjs +177 -28
  41. package/workflow-templates/github.mjs +205 -94
  42. package/workflow-templates/task-batch.mjs +247 -0
  43. package/workflow-templates.mjs +15 -0
package/.env.example CHANGED
@@ -21,7 +21,6 @@ SHARED_STATE_STALE_THRESHOLD_MS=300000
21
21
  # Maximum retry attempts before permanently ignoring a task (default: 3)
22
22
  SHARED_STATE_MAX_RETRIES=3
23
23
  # Task claim owner staleness threshold in milliseconds (default: 600000 = 10 minutes)
24
- # Used by task-claims.mjs to detect stale local claims
25
24
  TASK_CLAIM_OWNER_STALE_TTL_MS=600000
26
25
 
27
26
  # ─── Project Identity ─────────────────────────────────────────────────────────
@@ -178,6 +177,10 @@ VOICE_VISION_MODEL=gpt-4.1-mini
178
177
  # Gemini provider mode (Tier 2 voice fallback + Gemini vision)
179
178
  # GEMINI_API_KEY=
180
179
  # GOOGLE_API_KEY=
180
+ # Transcription model used for audio-to-text (default: gpt-4o-transcribe)
181
+ # VOICE_TRANSCRIPTION_MODEL=gpt-4o-mini-transcribe
182
+ # Enable/disable input audio transcription in realtime sessions (default: true)
183
+ # VOICE_TRANSCRIPTION_ENABLED=true
181
184
  # Voice output persona
182
185
  VOICE_ID=alloy
183
186
  # server_vad | semantic_vad | none
@@ -0,0 +1,338 @@
1
+ /**
2
+ * agent-tool-config.mjs — Per-Agent Tool Configuration Store
3
+ *
4
+ * Manages which tools and MCP servers are enabled for each agent profile.
5
+ * Persisted as `.bosun/agent-tools.json` alongside the library manifest.
6
+ *
7
+ * Schema:
8
+ * {
9
+ * "agents": {
10
+ * "<agentId>": {
11
+ * "enabledTools": ["tool1", "tool2"] | null, // null = all tools
12
+ * "enabledMcpServers": ["github", "context7"], // enabled MCP server IDs
13
+ * "disabledBuiltinTools": ["tool3"], // explicitly disabled builtins
14
+ * "updatedAt": "2026-01-01T00:00:00.000Z"
15
+ * }
16
+ * },
17
+ * "defaults": {
18
+ * "builtinTools": [...], // default tool list for all agents
19
+ * "updatedAt": "..."
20
+ * }
21
+ * }
22
+ *
23
+ * EXPORTS:
24
+ * DEFAULT_BUILTIN_TOOLS — list of default built-in tools for voice/agents
25
+ * loadToolConfig(rootDir) — load the full config
26
+ * saveToolConfig(rootDir, cfg) — save the full config
27
+ * getAgentToolConfig(rootDir, agentId) — get config for one agent
28
+ * setAgentToolConfig(rootDir, agentId, config) — update config for one agent
29
+ * getEffectiveTools(rootDir, agentId) — compute final enabled tools list
30
+ * listAvailableTools(rootDir) — list all available tools (builtin + MCP)
31
+ */
32
+
33
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
34
+ import { resolve } from "node:path";
35
+ import { homedir } from "node:os";
36
+
37
+ // ── Constants ─────────────────────────────────────────────────────────────────
38
+
39
+ const TAG = "[agent-tool-config]";
40
+ const CONFIG_FILE = "agent-tools.json";
41
+
42
+ function getBosunHome() {
43
+ return (
44
+ process.env.BOSUN_HOME ||
45
+ process.env.BOSUN_DIR ||
46
+ resolve(homedir(), ".bosun")
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Default built-in tools available to all voice agents and executors.
52
+ * Maps to common capabilities that voice/agent sessions can invoke.
53
+ */
54
+ export const DEFAULT_BUILTIN_TOOLS = Object.freeze([
55
+ {
56
+ id: "search-files",
57
+ name: "Search Files",
58
+ description: "Search for files in the workspace by name or pattern",
59
+ category: "Built-In",
60
+ icon: ":search:",
61
+ default: true,
62
+ },
63
+ {
64
+ id: "read-file",
65
+ name: "Read File",
66
+ description: "Read contents of a file in the workspace",
67
+ category: "Built-In",
68
+ icon: ":file:",
69
+ default: true,
70
+ },
71
+ {
72
+ id: "edit-file",
73
+ name: "Edit File",
74
+ description: "Create or edit files in the workspace",
75
+ category: "Built-In",
76
+ icon: ":edit:",
77
+ default: true,
78
+ },
79
+ {
80
+ id: "run-command",
81
+ name: "Run Terminal Command",
82
+ description: "Execute shell commands in a terminal",
83
+ category: "Built-In",
84
+ icon: ":terminal:",
85
+ default: true,
86
+ },
87
+ {
88
+ id: "web-search",
89
+ name: "Web Search",
90
+ description: "Search the web for information",
91
+ category: "Built-In",
92
+ icon: ":globe:",
93
+ default: true,
94
+ },
95
+ {
96
+ id: "code-search",
97
+ name: "Semantic Code Search",
98
+ description: "Search codebase semantically for relevant code",
99
+ category: "Built-In",
100
+ icon: ":cpu:",
101
+ default: true,
102
+ },
103
+ {
104
+ id: "git-operations",
105
+ name: "Git Operations",
106
+ description: "Run git commands (commit, push, branch, etc.)",
107
+ category: "Built-In",
108
+ icon: ":git:",
109
+ default: true,
110
+ },
111
+ {
112
+ id: "create-task",
113
+ name: "Create Task",
114
+ description: "Create new tasks and issues",
115
+ category: "Built-In",
116
+ icon: ":check:",
117
+ default: true,
118
+ },
119
+ {
120
+ id: "delegate-task",
121
+ name: "Delegate to Agent",
122
+ description: "Delegate work to another agent executor",
123
+ category: "Built-In",
124
+ icon: ":bot:",
125
+ default: true,
126
+ },
127
+ {
128
+ id: "fetch-url",
129
+ name: "Fetch URL",
130
+ description: "Fetch content from a URL and convert for LLM usage",
131
+ category: "Built-In",
132
+ icon: ":link:",
133
+ default: true,
134
+ },
135
+ {
136
+ id: "list-directory",
137
+ name: "List Directory",
138
+ description: "List contents of a directory in the workspace",
139
+ category: "Built-In",
140
+ icon: ":folder:",
141
+ default: true,
142
+ },
143
+ {
144
+ id: "grep-search",
145
+ name: "Text Search (Grep)",
146
+ description: "Search for exact text or regex patterns in files",
147
+ category: "Built-In",
148
+ icon: ":search:",
149
+ default: true,
150
+ },
151
+ {
152
+ id: "task-management",
153
+ name: "Task Management",
154
+ description: "Track and manage todo items and task status",
155
+ category: "Built-In",
156
+ icon: ":clipboard:",
157
+ default: true,
158
+ },
159
+ {
160
+ id: "notifications",
161
+ name: "Send Notifications",
162
+ description: "Send notifications via Telegram, webhook, etc.",
163
+ category: "Built-In",
164
+ icon: ":bell:",
165
+ default: false,
166
+ },
167
+ {
168
+ id: "vision-analysis",
169
+ name: "Vision Analysis",
170
+ description: "Analyze images and screenshots",
171
+ category: "Built-In",
172
+ icon: ":eye:",
173
+ default: true,
174
+ },
175
+ ]);
176
+
177
+ // ── Config File I/O ───────────────────────────────────────────────────────────
178
+
179
+ function getConfigPath(rootDir) {
180
+ return resolve(rootDir || getBosunHome(), ".bosun", CONFIG_FILE);
181
+ }
182
+
183
+ /**
184
+ * Load the agent tool configuration.
185
+ * @param {string} [rootDir]
186
+ * @returns {{ agents: Object, defaults: Object }}
187
+ */
188
+ export function loadToolConfig(rootDir) {
189
+ const configPath = getConfigPath(rootDir);
190
+ if (!existsSync(configPath)) {
191
+ return {
192
+ agents: {},
193
+ defaults: {
194
+ builtinTools: DEFAULT_BUILTIN_TOOLS.filter((t) => t.default).map((t) => t.id),
195
+ updatedAt: new Date().toISOString(),
196
+ },
197
+ };
198
+ }
199
+ try {
200
+ const raw = readFileSync(configPath, "utf8");
201
+ const parsed = JSON.parse(raw);
202
+ return {
203
+ agents: parsed.agents || {},
204
+ defaults: parsed.defaults || {
205
+ builtinTools: DEFAULT_BUILTIN_TOOLS.filter((t) => t.default).map((t) => t.id),
206
+ updatedAt: new Date().toISOString(),
207
+ },
208
+ };
209
+ } catch {
210
+ return {
211
+ agents: {},
212
+ defaults: {
213
+ builtinTools: DEFAULT_BUILTIN_TOOLS.filter((t) => t.default).map((t) => t.id),
214
+ updatedAt: new Date().toISOString(),
215
+ },
216
+ };
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Save the full tool configuration.
222
+ * @param {string} rootDir
223
+ * @param {{ agents: Object, defaults: Object }} config
224
+ */
225
+ export function saveToolConfig(rootDir, config) {
226
+ const configPath = getConfigPath(rootDir);
227
+ const dir = resolve(configPath, "..");
228
+ mkdirSync(dir, { recursive: true });
229
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf8");
230
+ }
231
+
232
+ /**
233
+ * Get tool configuration for a specific agent.
234
+ * @param {string} rootDir
235
+ * @param {string} agentId
236
+ * @returns {{ enabledTools: string[]|null, enabledMcpServers: string[], disabledBuiltinTools: string[] }}
237
+ */
238
+ export function getAgentToolConfig(rootDir, agentId) {
239
+ const config = loadToolConfig(rootDir);
240
+ const agentConfig = config.agents[agentId];
241
+ if (!agentConfig) {
242
+ return {
243
+ enabledTools: null,
244
+ enabledMcpServers: [],
245
+ disabledBuiltinTools: [],
246
+ };
247
+ }
248
+ return {
249
+ enabledTools: agentConfig.enabledTools ?? null,
250
+ enabledMcpServers: agentConfig.enabledMcpServers || [],
251
+ disabledBuiltinTools: agentConfig.disabledBuiltinTools || [],
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Update tool configuration for a specific agent.
257
+ * @param {string} rootDir
258
+ * @param {string} agentId
259
+ * @param {{ enabledTools?: string[]|null, enabledMcpServers?: string[], disabledBuiltinTools?: string[] }} update
260
+ * @returns {{ ok: boolean }}
261
+ */
262
+ export function setAgentToolConfig(rootDir, agentId, update) {
263
+ const config = loadToolConfig(rootDir);
264
+ const existing = config.agents[agentId] || {};
265
+ config.agents[agentId] = {
266
+ ...existing,
267
+ enabledTools: update.enabledTools !== undefined ? update.enabledTools : (existing.enabledTools ?? null),
268
+ enabledMcpServers: update.enabledMcpServers !== undefined ? update.enabledMcpServers : (existing.enabledMcpServers || []),
269
+ disabledBuiltinTools: update.disabledBuiltinTools !== undefined ? update.disabledBuiltinTools : (existing.disabledBuiltinTools || []),
270
+ updatedAt: new Date().toISOString(),
271
+ };
272
+ saveToolConfig(rootDir, config);
273
+ return { ok: true };
274
+ }
275
+
276
+ /**
277
+ * Compute the effective enabled tools for an agent.
278
+ * Merges builtin defaults with agent-specific overrides and MCP servers.
279
+ *
280
+ * @param {string} rootDir
281
+ * @param {string} agentId
282
+ * @returns {{ builtinTools: Array<{ id: string, name: string, enabled: boolean }>, mcpServers: string[] }}
283
+ */
284
+ export function getEffectiveTools(rootDir, agentId) {
285
+ const config = loadToolConfig(rootDir);
286
+ const agentConfig = config.agents[agentId] || {};
287
+ const disabledSet = new Set(agentConfig.disabledBuiltinTools || []);
288
+ const defaultIds = new Set(config.defaults?.builtinTools || DEFAULT_BUILTIN_TOOLS.filter((t) => t.default).map((t) => t.id));
289
+ const builtinIdSet = new Set(DEFAULT_BUILTIN_TOOLS.map((tool) => tool.id));
290
+ const explicitEnabled = Array.isArray(agentConfig.enabledTools)
291
+ ? agentConfig.enabledTools.map((id) => String(id || "").trim()).filter(Boolean)
292
+ : null;
293
+ const explicitBuiltinEnabled = explicitEnabled
294
+ ? explicitEnabled.filter((id) => builtinIdSet.has(id))
295
+ : [];
296
+ const useBuiltinAllowlist = explicitBuiltinEnabled.length > 0;
297
+ const explicitBuiltinSet = new Set(explicitBuiltinEnabled);
298
+
299
+ const builtinTools = DEFAULT_BUILTIN_TOOLS.map((tool) => ({
300
+ ...tool,
301
+ enabled: !disabledSet.has(tool.id) && (
302
+ useBuiltinAllowlist
303
+ ? explicitBuiltinSet.has(tool.id)
304
+ : defaultIds.has(tool.id)
305
+ ),
306
+ }));
307
+
308
+ return {
309
+ builtinTools,
310
+ mcpServers: agentConfig.enabledMcpServers || [],
311
+ };
312
+ }
313
+
314
+ /**
315
+ * List all available tools (builtin + installed MCP servers).
316
+ * @param {string} rootDir
317
+ * @returns {{ builtinTools: Array<Object>, mcpServers: Array<Object> }}
318
+ */
319
+ export async function listAvailableTools(rootDir) {
320
+ let mcpServers = [];
321
+ try {
322
+ const { listInstalledMcpServers } = await import("./mcp-registry.mjs");
323
+ mcpServers = await listInstalledMcpServers(rootDir);
324
+ } catch {
325
+ // MCP registry not available
326
+ }
327
+
328
+ return {
329
+ builtinTools: [...DEFAULT_BUILTIN_TOOLS],
330
+ mcpServers: mcpServers.map((s) => ({
331
+ id: s.id,
332
+ name: s.name,
333
+ description: s.description || "",
334
+ tags: s.tags || [],
335
+ transport: s.meta?.transport || "stdio",
336
+ })),
337
+ };
338
+ }
package/bosun-skills.mjs CHANGED
@@ -16,8 +16,44 @@
16
16
  * scan quickly to decide which skill files to read.
17
17
  */
18
18
 
19
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
20
- import { resolve, basename } from "node:path";
19
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
20
+ import { dirname, resolve, basename } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const __filename = fileURLToPath(import.meta.url);
24
+ const __dirname = dirname(__filename);
25
+
26
+ // ── Analytics stream path (same file task-executor writes to) ────────────────
27
+ const _SKILL_STREAM_PATH = resolve(
28
+ __dirname,
29
+ ".cache",
30
+ "agent-work-logs",
31
+ "agent-work-stream.jsonl",
32
+ );
33
+
34
+ /**
35
+ * Best-effort: emit a skill_invoke event to the agent work stream so usage
36
+ * analytics can track which skills are loaded per task.
37
+ *
38
+ * @param {string} skillName
39
+ * @param {string} [skillTitle]
40
+ * @param {{ taskId?: string, executor?: string }} [opts]
41
+ */
42
+ function emitSkillInvokeEvent(skillName, skillTitle, opts = {}) {
43
+ try {
44
+ const event = {
45
+ timestamp: new Date().toISOString(),
46
+ event_type: "skill_invoke",
47
+ data: { skill_name: skillName, skill_title: skillTitle || skillName },
48
+ ...(opts.taskId ? { task_id: String(opts.taskId) } : {}),
49
+ ...(opts.executor ? { executor: String(opts.executor) } : {}),
50
+ };
51
+ mkdirSync(dirname(_SKILL_STREAM_PATH), { recursive: true });
52
+ appendFileSync(_SKILL_STREAM_PATH, JSON.stringify(event) + "\n", "utf8");
53
+ } catch {
54
+ /* best effort — never let analytics crash skill loading */
55
+ }
56
+ }
21
57
 
22
58
  // ── Built-in skill definitions ────────────────────────────────────────────────
23
59
 
@@ -911,14 +947,25 @@ export function loadSkillsIndex(bosunHome) {
911
947
  * @param {string} [taskDescription]
912
948
  * @returns {Array<{filename:string,title:string,tags:string[],content:string}>}
913
949
  */
914
- export function findRelevantSkills(bosunHome, taskTitle, taskDescription = "") {
950
+ /**
951
+ * Find skills relevant to a given task by matching tags against the task title
952
+ * and description. Also emits `skill_invoke` analytics events for each matched
953
+ * skill so usage analytics can track skill popularity over time.
954
+ *
955
+ * @param {string} bosunHome
956
+ * @param {string} taskTitle
957
+ * @param {string} [taskDescription]
958
+ * @param {{ taskId?: string, executor?: string }} [opts] - Optional task context for analytics.
959
+ * @returns {Array<{filename:string,title:string,tags:string[],content:string}>}
960
+ */
961
+ export function findRelevantSkills(bosunHome, taskTitle, taskDescription = "", opts = {}) {
915
962
  const index = loadSkillsIndex(bosunHome);
916
963
  if (!index?.skills?.length) return [];
917
964
 
918
965
  const searchText = `${taskTitle} ${taskDescription}`.toLowerCase();
919
966
  const skillsDir = getSkillsDir(bosunHome);
920
967
 
921
- return index.skills
968
+ const matched = index.skills
922
969
  .filter(({ tags }) =>
923
970
  tags.some((tag) => searchText.includes(tag)),
924
971
  )
@@ -930,4 +977,12 @@ export function findRelevantSkills(bosunHome, taskTitle, taskDescription = "") {
930
977
  return { filename, title, tags, content };
931
978
  })
932
979
  .filter(({ content }) => !!content);
980
+
981
+ // Emit analytics events for each loaded skill
982
+ for (const skill of matched) {
983
+ const skillName = skill.filename.replace(/\.md$/i, "");
984
+ emitSkillInvokeEvent(skillName, skill.title, opts);
985
+ }
986
+
987
+ return matched;
933
988
  }
package/bosun.schema.json CHANGED
@@ -281,7 +281,7 @@
281
281
  "turnDetection": {
282
282
  "type": "string",
283
283
  "enum": ["server_vad", "semantic_vad", "none"],
284
- "default": "server_vad",
284
+ "default": "semantic_vad",
285
285
  "description": "Turn detection mode for voice activity detection"
286
286
  },
287
287
  "instructions": {
@@ -17,6 +17,13 @@ const chromeSandbox = resolve(
17
17
 
18
18
  process.title = "bosun-desktop-launcher";
19
19
 
20
+ function hasGuiEnvironment() {
21
+ if (process.platform !== "linux") return true;
22
+ if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return true;
23
+ if (process.env.XDG_SESSION_TYPE && process.env.XDG_SESSION_TYPE !== "tty") return true;
24
+ return false;
25
+ }
26
+
20
27
  function shouldDisableSandbox() {
21
28
  if (process.env.BOSUN_DESKTOP_DISABLE_SANDBOX === "1") return true;
22
29
  if (process.platform !== "linux") return false;
@@ -49,6 +56,17 @@ function ensureElectronInstalled() {
49
56
  }
50
57
 
51
58
  function launch() {
59
+ if (!hasGuiEnvironment()) {
60
+ console.error(
61
+ [
62
+ "[desktop] No GUI display server detected.",
63
+ "Cannot launch Electron portal without DISPLAY/WAYLAND.",
64
+ "Run Bosun in daemon/web mode instead (for example: `bosun --daemon`).",
65
+ ].join(" "),
66
+ );
67
+ process.exit(1);
68
+ }
69
+
52
70
  if (!ensureElectronInstalled()) {
53
71
  process.exit(1);
54
72
  }
package/desktop/main.mjs CHANGED
@@ -89,6 +89,7 @@ let shuttingDown = false;
89
89
  let uiServerStarted = false;
90
90
  let uiOrigin = null;
91
91
  let uiApi = null;
92
+ let desktopAuthHeaderBridgeInstalled = false;
92
93
  let runtimeConfigLoaded = false;
93
94
  /** True when the app is running as a persistent background / tray resident. */
94
95
  let trayMode = false;
@@ -154,6 +155,38 @@ function isTrustedCaptureOrigin(originLike) {
154
155
  }
155
156
  }
156
157
 
158
+ function isTrustedDesktopRequestUrl(urlLike) {
159
+ return isTrustedCaptureOrigin(urlLike);
160
+ }
161
+
162
+ function installDesktopAuthHeaderBridge() {
163
+ if (desktopAuthHeaderBridgeInstalled) return;
164
+ const ses = session.defaultSession;
165
+ if (!ses) return;
166
+ ses.webRequest.onBeforeSendHeaders((details, callback) => {
167
+ try {
168
+ const desktopKey = String(process.env.BOSUN_DESKTOP_API_KEY || "").trim();
169
+ if (!desktopKey) {
170
+ callback({ requestHeaders: details.requestHeaders });
171
+ return;
172
+ }
173
+ if (!isTrustedDesktopRequestUrl(details?.url || "")) {
174
+ callback({ requestHeaders: details.requestHeaders });
175
+ return;
176
+ }
177
+ const headers = { ...(details.requestHeaders || {}) };
178
+ const existingAuth = String(headers.Authorization || headers.authorization || "").trim();
179
+ if (!existingAuth) {
180
+ headers.Authorization = `Bearer ${desktopKey}`;
181
+ }
182
+ callback({ requestHeaders: headers });
183
+ } catch {
184
+ callback({ requestHeaders: details.requestHeaders });
185
+ }
186
+ });
187
+ desktopAuthHeaderBridgeInstalled = true;
188
+ }
189
+
157
190
  function installDesktopMediaHandlers() {
158
191
  const ses = session.defaultSession;
159
192
  if (!ses) return;
@@ -583,20 +616,25 @@ async function fetchWorkspaces({ force = false } = {}) {
583
616
  * @param {string} workspaceId
584
617
  */
585
618
  async function switchWorkspace(workspaceId) {
586
- if (!uiOrigin || !workspaceId) return;
619
+ if (!uiOrigin || !workspaceId) return { ok: false, error: "workspace unavailable" };
587
620
  const body = JSON.stringify({ workspaceId });
588
621
  const data = await uiServerRequest(`${uiOrigin}/api/workspaces/active`, {
589
622
  method: "POST",
590
623
  body,
591
624
  });
592
- if (data?.ok) {
593
- _cachedActiveWorkspaceId = workspaceId;
594
- _workspaceCacheAt = 0; // force re-fetch next time
625
+ if (!data?.ok) {
626
+ return {
627
+ ok: false,
628
+ error: String(data?.error || "workspace switch failed"),
629
+ };
595
630
  }
631
+ _cachedActiveWorkspaceId = String(data?.activeId || workspaceId);
632
+ _workspaceCacheAt = 0; // force re-fetch next time
596
633
  await fetchWorkspaces({ force: true });
597
634
  Menu.setApplicationMenu(buildAppMenu());
598
635
  refreshTrayMenu();
599
636
  navigateMainWindow("/");
637
+ return { ok: true, activeId: _cachedActiveWorkspaceId };
600
638
  }
601
639
 
602
640
  /**
@@ -1265,14 +1303,15 @@ async function openFollowWindow(detail = {}) {
1265
1303
  const win = await createFollowWindow();
1266
1304
  const baseUiUrl = await buildUiUrl();
1267
1305
  const target = buildFollowWindowUrl(baseUiUrl, detail);
1306
+ // Append a cache-buster timestamp so every Call press produces a unique URL.
1307
+ // Without this, a second Call press with the same parameters would match
1308
+ // followWindowLaunchSignature and skip loadURL — leaving the follow window
1309
+ // in its previous dead state (launch params already scrubbed, voice overlay
1310
+ // closed, useEffect([], []) already fired and won't re-run).
1311
+ target.searchParams.set("t", String(Date.now()));
1268
1312
  const signature = target.toString();
1269
- if (!win.webContents.getURL() || followWindowLaunchSignature !== signature) {
1270
- followWindowLaunchSignature = signature;
1271
- await win.loadURL(signature);
1272
- anchorFollowWindow(win);
1273
- setWindowVisible(win);
1274
- return;
1275
- }
1313
+ followWindowLaunchSignature = signature;
1314
+ await win.loadURL(signature);
1276
1315
  anchorFollowWindow(win);
1277
1316
  setWindowVisible(win);
1278
1317
  }
@@ -1727,8 +1766,7 @@ function registerDesktopIpc() {
1727
1766
  */
1728
1767
  ipcMain.handle("bosun:workspaces:switch", async (_event, { workspaceId } = {}) => {
1729
1768
  if (!workspaceId) return { ok: false, error: "workspaceId required" };
1730
- await switchWorkspace(workspaceId);
1731
- return { ok: true, activeId: workspaceId };
1769
+ return switchWorkspace(workspaceId);
1732
1770
  });
1733
1771
  }
1734
1772
 
@@ -1745,6 +1783,7 @@ async function bootstrap() {
1745
1783
  }
1746
1784
  callback(-3); // -3 = use Chromium default chain verification
1747
1785
  });
1786
+ installDesktopAuthHeaderBridge();
1748
1787
  installDesktopMediaHandlers();
1749
1788
 
1750
1789
  if (process.env.ELECTRON_DISABLE_SANDBOX === "1") {
@@ -36,6 +36,24 @@ import {
36
36
 
37
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
38
38
 
39
+ // ── Workflow Event Bridge ────────────────────────────────────────────────────
40
+ // Lazy-import queueWorkflowEvent from monitor.mjs — cached at module scope.
41
+ let _queueWorkflowEvent = null;
42
+ function emitFleetEvent(eventType, eventData = {}, opts = {}) {
43
+ if (!_queueWorkflowEvent) {
44
+ import("./monitor.mjs")
45
+ .then((mod) => {
46
+ if (typeof mod.queueWorkflowEvent === "function") {
47
+ _queueWorkflowEvent = mod.queueWorkflowEvent;
48
+ _queueWorkflowEvent(eventType, eventData, opts);
49
+ }
50
+ })
51
+ .catch(() => {});
52
+ return;
53
+ }
54
+ _queueWorkflowEvent(eventType, eventData, opts);
55
+ }
56
+
39
57
  // ── Repo Fingerprinting ──────────────────────────────────────────────────────
40
58
 
41
59
  function buildGitEnv() {
@@ -434,13 +452,21 @@ export function assignTasksToWorkstations(waves, peers, taskMap = new Map()) {
434
452
  assignments.push(...waveAssignments);
435
453
  }
436
454
 
437
- return {
455
+ const result = {
438
456
  assignments,
439
457
  totalTasks: assignments.length,
440
458
  totalPeers: peers.length,
441
459
  waveCount: waves.length,
442
460
  createdAt: new Date().toISOString(),
443
461
  };
462
+
463
+ emitFleetEvent("fleet.tasks_assigned", {
464
+ totalTasks: result.totalTasks,
465
+ totalPeers: result.totalPeers,
466
+ waveCount: result.waveCount,
467
+ }, { dedupKey: `fleet-assign-${result.createdAt}` });
468
+
469
+ return result;
444
470
  }
445
471
 
446
472
  // ── Backlog Depth Calculator ─────────────────────────────────────────────────
@@ -502,6 +528,10 @@ export function detectMaintenanceMode(status) {
502
528
 
503
529
  // Maintenance mode: nothing to do AND nothing in progress
504
530
  if (backlog === 0 && todo === 0 && running === 0 && review === 0) {
531
+ emitFleetEvent("fleet.maintenance_mode", {
532
+ isMaintenanceMode: true,
533
+ reason: "all tasks completed — no backlog, no active work",
534
+ });
505
535
  return {
506
536
  isMaintenanceMode: true,
507
537
  reason: "all tasks completed — no backlog, no active work",
@@ -722,6 +752,9 @@ export function shouldAutoGenerateTasks({
722
752
  */
723
753
  export function markAutoGenTriggered() {
724
754
  lastAutoGenTimestamp = Date.now();
755
+ emitFleetEvent("fleet.auto_gen_triggered", {
756
+ triggeredAt: new Date(lastAutoGenTimestamp).toISOString(),
757
+ });
725
758
  }
726
759
 
727
760
  /**