bosun 0.36.2 → 0.36.4

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 (57) hide show
  1. package/agent-prompts.mjs +95 -0
  2. package/analyze-agent-work-helpers.mjs +308 -0
  3. package/analyze-agent-work.mjs +926 -0
  4. package/autofix.mjs +2 -0
  5. package/bosun.schema.json +101 -3
  6. package/codex-shell.mjs +85 -10
  7. package/desktop/main.mjs +871 -48
  8. package/desktop/preload.mjs +54 -1
  9. package/desktop-shortcut.mjs +90 -11
  10. package/git-editor-fix.mjs +273 -0
  11. package/mcp-registry.mjs +579 -0
  12. package/meeting-workflow-service.mjs +631 -0
  13. package/monitor.mjs +18 -103
  14. package/package.json +21 -2
  15. package/primary-agent.mjs +32 -12
  16. package/session-tracker.mjs +68 -0
  17. package/setup-web-server.mjs +20 -10
  18. package/setup.mjs +376 -83
  19. package/startup-service.mjs +51 -6
  20. package/stream-resilience.mjs +17 -7
  21. package/ui/app.js +164 -4
  22. package/ui/components/agent-selector.js +145 -1
  23. package/ui/components/chat-view.js +161 -15
  24. package/ui/components/session-list.js +2 -2
  25. package/ui/components/shared.js +188 -15
  26. package/ui/modules/icons.js +13 -0
  27. package/ui/modules/utils.js +44 -0
  28. package/ui/modules/voice-client-sdk.js +733 -0
  29. package/ui/modules/voice-overlay.js +128 -15
  30. package/ui/modules/voice.js +15 -6
  31. package/ui/setup.html +281 -81
  32. package/ui/styles/components.css +99 -3
  33. package/ui/styles/sessions.css +122 -14
  34. package/ui/styles.css +14 -0
  35. package/ui/tabs/agents.js +1 -1
  36. package/ui/tabs/chat.js +123 -14
  37. package/ui/tabs/control.js +16 -22
  38. package/ui/tabs/dashboard.js +85 -8
  39. package/ui/tabs/library.js +113 -17
  40. package/ui/tabs/settings.js +116 -2
  41. package/ui/tabs/tasks.js +388 -39
  42. package/ui/tabs/telemetry.js +0 -1
  43. package/ui/tabs/workflows.js +4 -0
  44. package/ui-server.mjs +400 -22
  45. package/update-check.mjs +41 -13
  46. package/voice-action-dispatcher.mjs +844 -0
  47. package/voice-agents-sdk.mjs +664 -0
  48. package/voice-auth-manager.mjs +164 -0
  49. package/voice-relay.mjs +1194 -0
  50. package/voice-tools.mjs +914 -0
  51. package/workflow-templates/agents.mjs +6 -2
  52. package/workflow-templates/github.mjs +154 -12
  53. package/workflow-templates.mjs +3 -0
  54. package/github-reconciler.mjs +0 -506
  55. package/merge-strategy.mjs +0 -1210
  56. package/pr-cleanup-daemon.mjs +0 -992
  57. package/workspace-reaper.mjs +0 -405
@@ -0,0 +1,844 @@
1
+ /**
2
+ * voice-action-dispatcher.mjs — Direct JavaScript action dispatcher for voice agents.
3
+ *
4
+ * The voice model returns JSON action intents. This module:
5
+ * 1. Parses the action intent (tool calls, task operations, agent delegations)
6
+ * 2. Executes the action directly via Bosun's JavaScript APIs (no MCP bridge)
7
+ * 3. Returns structured results back to the voice session
8
+ *
9
+ * Supported action types:
10
+ * - task.* → kanban CRUD (list, get, create, update, delete, stats, search)
11
+ * - agent.* → agent delegation (/ask, /agent, /plan via primary-agent)
12
+ * - session.* → session management (list, history, create)
13
+ * - system.* → system status, fleet, config
14
+ * - workspace.* → file read, directory list, code search
15
+ * - tool.* → MCP tool passthrough (Bosun processes tool calls server-side)
16
+ * - workflow.* → workflow management (list, trigger)
17
+ * - skill.* → skill/prompt management (list, get)
18
+ *
19
+ * @module voice-action-dispatcher
20
+ */
21
+
22
+ import { loadConfig } from "./config.mjs";
23
+ import { execPrimaryPrompt, getPrimaryAgentName, setPrimaryAgent, getAgentMode, setAgentMode } from "./primary-agent.mjs";
24
+
25
+ // ── Module-scope lazy imports ───────────────────────────────────────────────
26
+
27
+ let _kanbanAdapter = null;
28
+ let _sessionTracker = null;
29
+ let _fleetCoordinator = null;
30
+ let _agentSupervisor = null;
31
+ let _sharedStateManager = null;
32
+ let _agentPrompts = null;
33
+ let _bosunSkills = null;
34
+ let _workflowTemplates = null;
35
+ let _taskStore = null;
36
+
37
+ async function getKanban() {
38
+ if (!_kanbanAdapter) {
39
+ _kanbanAdapter = await import("./kanban-adapter.mjs");
40
+ }
41
+ return _kanbanAdapter;
42
+ }
43
+
44
+ async function getSessionTracker() {
45
+ if (!_sessionTracker) {
46
+ _sessionTracker = await import("./session-tracker.mjs");
47
+ }
48
+ return _sessionTracker;
49
+ }
50
+
51
+ async function getFleetCoordinator() {
52
+ if (!_fleetCoordinator) {
53
+ _fleetCoordinator = await import("./fleet-coordinator.mjs");
54
+ }
55
+ return _fleetCoordinator;
56
+ }
57
+
58
+ async function getSupervisor() {
59
+ if (!_agentSupervisor) {
60
+ _agentSupervisor = await import("./agent-supervisor.mjs");
61
+ }
62
+ return _agentSupervisor;
63
+ }
64
+
65
+ async function getSharedState() {
66
+ if (!_sharedStateManager) {
67
+ _sharedStateManager = await import("./shared-state-manager.mjs");
68
+ }
69
+ return _sharedStateManager;
70
+ }
71
+
72
+ async function getAgentPrompts() {
73
+ if (!_agentPrompts) {
74
+ _agentPrompts = await import("./agent-prompts.mjs");
75
+ }
76
+ return _agentPrompts;
77
+ }
78
+
79
+ async function getBosunSkills() {
80
+ if (!_bosunSkills) {
81
+ _bosunSkills = await import("./bosun-skills.mjs");
82
+ }
83
+ return _bosunSkills;
84
+ }
85
+
86
+ async function getWorkflowTemplates() {
87
+ if (!_workflowTemplates) {
88
+ _workflowTemplates = await import("./workflow-templates.mjs");
89
+ }
90
+ return _workflowTemplates;
91
+ }
92
+
93
+ async function getTaskStore() {
94
+ if (!_taskStore) {
95
+ try {
96
+ _taskStore = await import("./task-store.mjs");
97
+ } catch {
98
+ _taskStore = null;
99
+ }
100
+ }
101
+ return _taskStore;
102
+ }
103
+
104
+ // ── Constants ───────────────────────────────────────────────────────────────
105
+
106
+ const VALID_EXECUTORS = new Set([
107
+ "codex-sdk",
108
+ "copilot-sdk",
109
+ "claude-sdk",
110
+ "gemini-sdk",
111
+ "opencode-sdk",
112
+ ]);
113
+
114
+ const VALID_AGENT_MODES = new Set(["ask", "agent", "plan"]);
115
+
116
+ const MODE_ALIASES = Object.freeze({
117
+ code: "agent",
118
+ architect: "plan",
119
+ chat: "ask",
120
+ question: "ask",
121
+ implement: "agent",
122
+ execute: "agent",
123
+ design: "plan",
124
+ });
125
+
126
+ // ── Action intent schema ────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * @typedef {Object} VoiceActionIntent
130
+ * @property {string} action — Dotted action name, e.g. "task.list", "agent.ask"
131
+ * @property {Object} params — Action-specific parameters
132
+ * @property {string} [id] — Optional correlation ID for response matching
133
+ */
134
+
135
+ /**
136
+ * @typedef {Object} VoiceActionResult
137
+ * @property {boolean} ok — Whether the action succeeded
138
+ * @property {string} action — Echo of the action name
139
+ * @property {any} data — Result payload
140
+ * @property {string|null} error — Error message if !ok
141
+ * @property {string} [id] — Correlation ID echo
142
+ * @property {number} durationMs — Execution time in ms
143
+ */
144
+
145
+ // ── Action registry ─────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Registry of action handlers.
149
+ * Key: action name (e.g. "task.list")
150
+ * Value: async (params, context) => result data
151
+ */
152
+ const ACTION_HANDLERS = {};
153
+
154
+ function registerAction(name, handler) {
155
+ ACTION_HANDLERS[name] = handler;
156
+ }
157
+
158
+ // ── Task actions ────────────────────────────────────────────────────────────
159
+
160
+ registerAction("task.list", async (params) => {
161
+ const kanban = await getKanban();
162
+ const status = String(params.status || "all").trim().toLowerCase();
163
+ const limit = Math.min(Math.max(Number(params.limit) || 20, 1), 200);
164
+ const tasks = await kanban.listTasks(undefined, {
165
+ status: status === "all" ? undefined : status,
166
+ });
167
+ const limited = Array.isArray(tasks) ? tasks.slice(0, limit) : [];
168
+ return {
169
+ count: limited.length,
170
+ total: Array.isArray(tasks) ? tasks.length : 0,
171
+ tasks: limited.map((t) => ({
172
+ id: t.id || t.number,
173
+ title: t.title,
174
+ status: t.status,
175
+ assignee: t.assignee || t.assignees?.[0] || "unassigned",
176
+ labels: t.labels || [],
177
+ priority: t.priority || null,
178
+ })),
179
+ };
180
+ });
181
+
182
+ registerAction("task.get", async (params) => {
183
+ const kanban = await getKanban();
184
+ const taskId = String(params.taskId || params.id || "").trim();
185
+ if (!taskId) throw new Error("taskId is required");
186
+ const task = await kanban.getTask(taskId);
187
+ if (!task) throw new Error(`Task ${taskId} not found`);
188
+ return {
189
+ id: task.id || task.number,
190
+ title: task.title,
191
+ status: task.status,
192
+ description: task.body || task.description || "",
193
+ assignee: task.assignee || task.assignees?.[0] || "unassigned",
194
+ labels: task.labels || [],
195
+ priority: task.priority || null,
196
+ createdAt: task.createdAt,
197
+ updatedAt: task.updatedAt,
198
+ };
199
+ });
200
+
201
+ registerAction("task.create", async (params) => {
202
+ const kanban = await getKanban();
203
+ const title = String(params.title || "").trim();
204
+ if (!title) throw new Error("title is required");
205
+ const result = await kanban.createTask(undefined, {
206
+ title,
207
+ body: params.description || params.body || "",
208
+ priority: params.priority,
209
+ labels: Array.isArray(params.labels) ? params.labels : [],
210
+ });
211
+ return {
212
+ id: result.id || result.number,
213
+ title: result.title || title,
214
+ status: result.status || "todo",
215
+ message: `Task created: ${result.title || title}`,
216
+ };
217
+ });
218
+
219
+ registerAction("task.update", async (params) => {
220
+ const kanban = await getKanban();
221
+ const taskId = String(params.taskId || params.id || "").trim();
222
+ if (!taskId) throw new Error("taskId is required");
223
+ const patch = {};
224
+ if (params.status) patch.status = params.status;
225
+ if (params.title) patch.title = params.title;
226
+ if (params.description) patch.description = params.description;
227
+ if (params.priority) patch.priority = params.priority;
228
+ if (params.labels) patch.labels = params.labels;
229
+ if (params.assignee) patch.assignee = params.assignee;
230
+ await kanban.updateTask(taskId, patch);
231
+ return { taskId, updated: Object.keys(patch), message: `Task ${taskId} updated.` };
232
+ });
233
+
234
+ registerAction("task.updateStatus", async (params) => {
235
+ const kanban = await getKanban();
236
+ const taskId = String(params.taskId || params.id || "").trim();
237
+ const status = String(params.status || "").trim().toLowerCase();
238
+ if (!taskId) throw new Error("taskId is required");
239
+ if (!status) throw new Error("status is required");
240
+ await kanban.updateTaskStatus(taskId, status);
241
+ return { taskId, status, message: `Task ${taskId} moved to ${status}.` };
242
+ });
243
+
244
+ registerAction("task.delete", async (params) => {
245
+ const kanban = await getKanban();
246
+ const taskId = String(params.taskId || params.id || "").trim();
247
+ if (!taskId) throw new Error("taskId is required");
248
+ await kanban.deleteTask(taskId);
249
+ return { taskId, message: `Task ${taskId} deleted.` };
250
+ });
251
+
252
+ registerAction("task.search", async (params) => {
253
+ const kanban = await getKanban();
254
+ const query = String(params.query || params.q || "").trim();
255
+ if (!query) throw new Error("query is required");
256
+ const limit = Math.min(Math.max(Number(params.limit) || 20, 1), 200);
257
+ const tasks = await kanban.listTasks(undefined, {});
258
+ const matches = (Array.isArray(tasks) ? tasks : []).filter((t) => {
259
+ const text = `${t.title || ""} ${t.body || t.description || ""} ${(t.labels || []).join(" ")}`.toLowerCase();
260
+ return text.includes(query.toLowerCase());
261
+ });
262
+ return {
263
+ query,
264
+ count: Math.min(matches.length, limit),
265
+ total: matches.length,
266
+ tasks: matches.slice(0, limit).map((t) => ({
267
+ id: t.id || t.number,
268
+ title: t.title,
269
+ status: t.status,
270
+ })),
271
+ };
272
+ });
273
+
274
+ registerAction("task.stats", async () => {
275
+ const kanban = await getKanban();
276
+ const tasks = await kanban.listTasks(undefined, {});
277
+ const all = Array.isArray(tasks) ? tasks : [];
278
+ const byStatus = {};
279
+ for (const t of all) {
280
+ const s = t.status || "unknown";
281
+ byStatus[s] = (byStatus[s] || 0) + 1;
282
+ }
283
+ return {
284
+ total: all.length,
285
+ byStatus,
286
+ backlog: byStatus.todo || 0,
287
+ inProgress: byStatus.inprogress || 0,
288
+ inReview: byStatus.inreview || 0,
289
+ done: byStatus.done || 0,
290
+ };
291
+ });
292
+
293
+ registerAction("task.comment", async (params) => {
294
+ const kanban = await getKanban();
295
+ const taskId = String(params.taskId || params.id || "").trim();
296
+ const body = String(params.body || params.comment || params.message || "").trim();
297
+ if (!taskId) throw new Error("taskId is required");
298
+ if (!body) throw new Error("comment body is required");
299
+ await kanban.addComment(taskId, body);
300
+ return { taskId, message: `Comment added to task ${taskId}.` };
301
+ });
302
+
303
+ // ── Agent delegation actions ────────────────────────────────────────────────
304
+
305
+ registerAction("agent.delegate", async (params, context) => {
306
+ const cfg = loadConfig();
307
+ const message = String(params.message || params.prompt || "").trim();
308
+ if (!message) throw new Error("message is required");
309
+
310
+ const rawExecutor = String(
311
+ params.executor || context.executor || cfg.voice?.delegateExecutor || cfg.primaryAgent || "codex-sdk",
312
+ ).trim().toLowerCase();
313
+ const executor = VALID_EXECUTORS.has(rawExecutor) ? rawExecutor : (cfg.primaryAgent || "codex-sdk");
314
+
315
+ const rawMode = String(params.mode || context.mode || "agent").trim().toLowerCase();
316
+ const mode = MODE_ALIASES[rawMode] || (VALID_AGENT_MODES.has(rawMode) ? rawMode : "agent");
317
+ const model = String(params.model || context.model || "").trim() || undefined;
318
+ const sessionId = String(context.sessionId || "").trim() || `voice-dispatch-${Date.now()}`;
319
+
320
+ const previousAgent = getPrimaryAgentName();
321
+ if (executor !== previousAgent) {
322
+ setPrimaryAgent(executor);
323
+ }
324
+
325
+ try {
326
+ const result = await execPrimaryPrompt(message, {
327
+ mode,
328
+ model,
329
+ sessionId,
330
+ sessionType: "voice-dispatch",
331
+ timeoutMs: 5 * 60 * 1000,
332
+ });
333
+ const text = typeof result === "string"
334
+ ? result
335
+ : result?.finalResponse || result?.text || result?.message || JSON.stringify(result);
336
+ const truncated = text.length > 4000 ? text.slice(0, 4000) + "... (truncated)" : text;
337
+ return { executor, mode, response: truncated };
338
+ } finally {
339
+ if (executor !== previousAgent) {
340
+ try { setPrimaryAgent(previousAgent); } catch { /* best effort */ }
341
+ }
342
+ }
343
+ });
344
+
345
+ registerAction("agent.ask", async (params, context) => {
346
+ return ACTION_HANDLERS["agent.delegate"]({ ...params, mode: "ask" }, context);
347
+ });
348
+
349
+ registerAction("agent.plan", async (params, context) => {
350
+ return ACTION_HANDLERS["agent.delegate"]({ ...params, mode: "plan" }, context);
351
+ });
352
+
353
+ registerAction("agent.code", async (params, context) => {
354
+ return ACTION_HANDLERS["agent.delegate"]({ ...params, mode: "agent" }, context);
355
+ });
356
+
357
+ registerAction("agent.status", async () => {
358
+ const name = getPrimaryAgentName();
359
+ const mode = getAgentMode();
360
+ return {
361
+ activeAgent: name,
362
+ mode,
363
+ status: "available",
364
+ message: `${name} is active in ${mode} mode.`,
365
+ };
366
+ });
367
+
368
+ registerAction("agent.switch", async (params) => {
369
+ const executor = String(params.executor || params.agent || "").trim().toLowerCase();
370
+ if (!executor) throw new Error("executor is required");
371
+ if (!VALID_EXECUTORS.has(executor)) {
372
+ throw new Error(`Invalid executor: ${executor}. Valid: ${[...VALID_EXECUTORS].join(", ")}`);
373
+ }
374
+ const previous = getPrimaryAgentName();
375
+ setPrimaryAgent(executor);
376
+ return { previous, current: executor, message: `Switched from ${previous} to ${executor}.` };
377
+ });
378
+
379
+ registerAction("agent.setMode", async (params) => {
380
+ const rawMode = String(params.mode || "").trim().toLowerCase();
381
+ const mode = MODE_ALIASES[rawMode] || (VALID_AGENT_MODES.has(rawMode) ? rawMode : null);
382
+ if (!mode) {
383
+ throw new Error(`Invalid mode: ${rawMode}. Valid: ${[...VALID_AGENT_MODES].join(", ")}`);
384
+ }
385
+ const previous = getAgentMode();
386
+ setAgentMode(mode);
387
+ return { previous, current: mode, message: `Agent mode set to ${mode}.` };
388
+ });
389
+
390
+ // ── Session actions ─────────────────────────────────────────────────────────
391
+
392
+ registerAction("session.list", async (params) => {
393
+ const tracker = await getSessionTracker();
394
+ const sessions = tracker.listSessions ? tracker.listSessions() : [];
395
+ const limit = Math.min(Math.max(Number(params.limit) || 10, 1), 100);
396
+ return {
397
+ count: Math.min(sessions.length, limit),
398
+ sessions: sessions.slice(0, limit).map((s) => ({
399
+ id: s.id || s.taskId,
400
+ type: s.type || "task",
401
+ status: s.status,
402
+ lastActive: s.lastActiveAt || s.lastActivityAt,
403
+ })),
404
+ };
405
+ });
406
+
407
+ registerAction("session.history", async (params) => {
408
+ const tracker = await getSessionTracker();
409
+ const sessionId = String(params.sessionId || params.id || "").trim();
410
+ if (!sessionId) throw new Error("sessionId is required");
411
+ const session = tracker.getSession ? tracker.getSession(sessionId) : null;
412
+ if (!session) throw new Error(`Session ${sessionId} not found`);
413
+ const limit = Math.min(Math.max(Number(params.limit) || 20, 1), 200);
414
+ const messages = (session.messages || []).slice(-limit);
415
+ return {
416
+ sessionId,
417
+ count: messages.length,
418
+ messages: messages.map((m) => ({
419
+ role: m.role || m.type,
420
+ content: typeof m.content === "string" ? m.content.slice(0, 500) : String(m.content || ""),
421
+ timestamp: m.timestamp,
422
+ })),
423
+ };
424
+ });
425
+
426
+ registerAction("session.create", async (params, context) => {
427
+ const tracker = await getSessionTracker();
428
+ const session = tracker.createSession
429
+ ? tracker.createSession({
430
+ type: params.type || "voice",
431
+ metadata: {
432
+ agent: params.executor || context.executor || getPrimaryAgentName(),
433
+ mode: params.mode || context.mode || getAgentMode(),
434
+ source: "voice-dispatch",
435
+ },
436
+ })
437
+ : null;
438
+ if (!session) throw new Error("Session tracker does not support createSession");
439
+ return {
440
+ sessionId: session.id,
441
+ type: session.type,
442
+ message: `Session ${session.id} created.`,
443
+ };
444
+ });
445
+
446
+ // ── System actions ──────────────────────────────────────────────────────────
447
+
448
+ registerAction("system.status", async () => {
449
+ const cfg = loadConfig();
450
+ const name = getPrimaryAgentName();
451
+ const mode = getAgentMode();
452
+ return {
453
+ primaryAgent: name,
454
+ agentMode: mode,
455
+ kanbanBackend: cfg.kanbanBackend || cfg.kanban?.backend || "internal",
456
+ projectName: cfg.projectName || "unknown",
457
+ configMode: cfg.mode || "generic",
458
+ voiceEnabled: cfg.voice?.enabled !== false,
459
+ };
460
+ });
461
+
462
+ registerAction("system.fleet", async () => {
463
+ try {
464
+ const fleet = await getFleetCoordinator();
465
+ const status = fleet.getFleetStatus ? fleet.getFleetStatus() : {};
466
+ return status;
467
+ } catch {
468
+ return { message: "Fleet coordinator not available." };
469
+ }
470
+ });
471
+
472
+ registerAction("system.config", async (params) => {
473
+ const cfg = loadConfig();
474
+ const key = String(params.key || "").trim();
475
+ if (key) {
476
+ const value = cfg[key];
477
+ return value !== undefined ? { [key]: value } : { error: `Config key "${key}" not found.` };
478
+ }
479
+ return {
480
+ primaryAgent: cfg.primaryAgent,
481
+ mode: cfg.mode,
482
+ kanbanBackend: cfg.kanbanBackend || cfg.kanban?.backend,
483
+ projectName: cfg.projectName,
484
+ autoFixEnabled: cfg.autoFixEnabled,
485
+ watchEnabled: cfg.watchEnabled,
486
+ voiceEnabled: cfg.voice?.enabled !== false,
487
+ };
488
+ });
489
+
490
+ registerAction("system.health", async () => {
491
+ const cfg = loadConfig();
492
+ const name = getPrimaryAgentName();
493
+ let fleetOk = false;
494
+ try {
495
+ const fleet = await getFleetCoordinator();
496
+ fleetOk = Boolean(fleet.getFleetStatus);
497
+ } catch { /* not available */ }
498
+
499
+ return {
500
+ healthy: true,
501
+ primaryAgent: name,
502
+ fleetAvailable: fleetOk,
503
+ uptime: process.uptime(),
504
+ memoryMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
505
+ };
506
+ });
507
+
508
+ // ── Workspace actions ───────────────────────────────────────────────────────
509
+
510
+ registerAction("workspace.readFile", async (params) => {
511
+ const { readFileSync } = await import("node:fs");
512
+ const { resolve } = await import("node:path");
513
+ const filePath = String(params.filePath || params.path || "").trim();
514
+ if (!filePath) throw new Error("filePath is required");
515
+ const fullPath = resolve(process.cwd(), filePath);
516
+ const content = readFileSync(fullPath, "utf8");
517
+ const lines = content.split("\n");
518
+ const start = Math.max((Number(params.startLine) || 1) - 1, 0);
519
+ const end = Number(params.endLine) || lines.length;
520
+ const slice = lines.slice(start, end).join("\n");
521
+ return {
522
+ filePath,
523
+ lineCount: end - start,
524
+ content: slice.length > 4000 ? slice.slice(0, 4000) + "\n... (truncated)" : slice,
525
+ };
526
+ });
527
+
528
+ registerAction("workspace.listDir", async (params) => {
529
+ const { readdirSync, statSync } = await import("node:fs");
530
+ const { resolve, join } = await import("node:path");
531
+ const dirPath = String(params.path || params.dir || ".").trim();
532
+ const fullPath = resolve(process.cwd(), dirPath);
533
+ const entries = readdirSync(fullPath).slice(0, 100);
534
+ return {
535
+ path: dirPath,
536
+ entries: entries.map((name) => {
537
+ try {
538
+ const isDir = statSync(join(fullPath, name)).isDirectory();
539
+ return { name, type: isDir ? "directory" : "file" };
540
+ } catch {
541
+ return { name, type: "unknown" };
542
+ }
543
+ }),
544
+ };
545
+ });
546
+
547
+ registerAction("workspace.search", async (params) => {
548
+ const { execSync } = await import("node:child_process");
549
+ const query = String(params.query || params.q || "").trim();
550
+ if (!query) throw new Error("query is required");
551
+ const limit = Math.min(Math.max(Number(params.maxResults) || 20, 1), 100);
552
+ const filePattern = params.filePattern ? `--include="${params.filePattern}"` : "";
553
+ try {
554
+ const result = execSync(
555
+ `grep -rn ${filePattern} "${query}" . --max-count=${limit} 2>/dev/null || true`,
556
+ { encoding: "utf8", timeout: 10_000, cwd: process.cwd() },
557
+ );
558
+ const lines = result.trim().split("\n").filter(Boolean);
559
+ return { query, count: lines.length, matches: lines.slice(0, limit) };
560
+ } catch {
561
+ return { query, count: 0, matches: [] };
562
+ }
563
+ });
564
+
565
+ // ── MCP tool passthrough ────────────────────────────────────────────────────
566
+
567
+ registerAction("tool.call", async (params, context) => {
568
+ const toolName = String(params.toolName || params.name || "").trim();
569
+ if (!toolName) throw new Error("toolName is required");
570
+ const toolArgs = params.args || params.arguments || {};
571
+ const { executeToolCall } = await import("./voice-tools.mjs");
572
+ return executeToolCall(toolName, toolArgs, context);
573
+ });
574
+
575
+ // ── Workflow actions ────────────────────────────────────────────────────────
576
+
577
+ registerAction("workflow.list", async () => {
578
+ const wf = await getWorkflowTemplates();
579
+ const templates = wf.listTemplates ? wf.listTemplates() : [];
580
+ return {
581
+ count: templates.length,
582
+ templates: templates.map((t) => ({
583
+ id: t.id,
584
+ name: t.name || t.id,
585
+ description: t.description || "",
586
+ })),
587
+ };
588
+ });
589
+
590
+ registerAction("workflow.get", async (params) => {
591
+ const wf = await getWorkflowTemplates();
592
+ const id = String(params.id || params.templateId || "").trim();
593
+ if (!id) throw new Error("workflow template id is required");
594
+ const template = wf.getTemplate ? wf.getTemplate(id) : null;
595
+ if (!template) throw new Error(`Workflow template "${id}" not found`);
596
+ return {
597
+ id: template.id,
598
+ name: template.name || template.id,
599
+ description: template.description || "",
600
+ steps: template.steps || template.nodes || [],
601
+ };
602
+ });
603
+
604
+ // ── Skill/prompt actions ────────────────────────────────────────────────────
605
+
606
+ registerAction("skill.list", async () => {
607
+ const skills = await getBosunSkills();
608
+ const builtins = skills.BUILTIN_SKILLS || [];
609
+ return {
610
+ count: builtins.length,
611
+ skills: builtins.map((s) => ({
612
+ filename: s.filename,
613
+ title: s.title,
614
+ tags: s.tags || [],
615
+ scope: s.scope || "global",
616
+ })),
617
+ };
618
+ });
619
+
620
+ registerAction("prompt.list", async () => {
621
+ const prompts = await getAgentPrompts();
622
+ const defs = prompts.getAgentPromptDefinitions
623
+ ? prompts.getAgentPromptDefinitions()
624
+ : prompts.AGENT_PROMPT_DEFINITIONS || [];
625
+ return {
626
+ count: defs.length,
627
+ prompts: defs.map((d) => ({
628
+ key: d.key,
629
+ filename: d.filename,
630
+ description: d.description || "",
631
+ })),
632
+ };
633
+ });
634
+
635
+ registerAction("prompt.get", async (params) => {
636
+ const prompts = await getAgentPrompts();
637
+ const key = String(params.key || params.name || "").trim();
638
+ if (!key) throw new Error("prompt key is required");
639
+ const content = prompts.getDefaultPromptTemplate
640
+ ? prompts.getDefaultPromptTemplate(key)
641
+ : "";
642
+ if (!content) throw new Error(`Prompt "${key}" not found`);
643
+ const truncated = content.length > 3000 ? content.slice(0, 3000) + "\n... (truncated)" : content;
644
+ return { key, content: truncated };
645
+ });
646
+
647
+ // ── Batch action support ────────────────────────────────────────────────────
648
+
649
+ registerAction("batch", async (params, context) => {
650
+ const actions = Array.isArray(params.actions) ? params.actions : [];
651
+ if (!actions.length) throw new Error("actions array is required");
652
+ if (actions.length > 10) throw new Error("Maximum 10 actions per batch");
653
+
654
+ const results = [];
655
+ for (const actionIntent of actions) {
656
+ const result = await dispatchVoiceAction(actionIntent, context);
657
+ results.push(result);
658
+ }
659
+ return { count: results.length, results };
660
+ });
661
+
662
+ // ── Dispatcher ──────────────────────────────────────────────────────────────
663
+
664
+ /**
665
+ * List all registered action names.
666
+ * @returns {string[]}
667
+ */
668
+ export function listAvailableActions() {
669
+ return Object.keys(ACTION_HANDLERS).sort();
670
+ }
671
+
672
+ /**
673
+ * Check if an action is registered.
674
+ * @param {string} action
675
+ * @returns {boolean}
676
+ */
677
+ export function hasAction(action) {
678
+ return Boolean(ACTION_HANDLERS[String(action || "").trim()]);
679
+ }
680
+
681
+ /**
682
+ * Get a descriptive manifest of all available actions for prompt injection.
683
+ * @returns {Array<{ action: string, description: string }>}
684
+ */
685
+ export function getActionManifest() {
686
+ return [
687
+ { action: "task.list", description: "List tasks from the kanban board. params: { status?, limit? }" },
688
+ { action: "task.get", description: "Get task details. params: { taskId }" },
689
+ { action: "task.create", description: "Create a new task. params: { title, description?, priority?, labels? }" },
690
+ { action: "task.update", description: "Update task fields. params: { taskId, status?, title?, priority?, labels? }" },
691
+ { action: "task.updateStatus", description: "Move task to a new status. params: { taskId, status }" },
692
+ { action: "task.delete", description: "Delete a task. params: { taskId }" },
693
+ { action: "task.search", description: "Search tasks by text. params: { query, limit? }" },
694
+ { action: "task.stats", description: "Get task statistics. params: {}" },
695
+ { action: "task.comment", description: "Add a comment to a task. params: { taskId, body }" },
696
+ { action: "agent.delegate", description: "Delegate work to an agent. params: { message, executor?, mode?, model? }" },
697
+ { action: "agent.ask", description: "Ask an agent a question (read-only). params: { message, executor? }" },
698
+ { action: "agent.plan", description: "Ask an agent to create a plan. params: { message, executor? }" },
699
+ { action: "agent.code", description: "Ask an agent to write/modify code. params: { message, executor? }" },
700
+ { action: "agent.status", description: "Get active agent status. params: {}" },
701
+ { action: "agent.switch", description: "Switch the primary agent. params: { executor }" },
702
+ { action: "agent.setMode", description: "Set agent interaction mode. params: { mode: ask|agent|plan }" },
703
+ { action: "session.list", description: "List active sessions. params: { limit? }" },
704
+ { action: "session.history", description: "Get session message history. params: { sessionId, limit? }" },
705
+ { action: "session.create", description: "Create a new session. params: { type?, executor? }" },
706
+ { action: "system.status", description: "Get system status. params: {}" },
707
+ { action: "system.fleet", description: "Get fleet coordination status. params: {}" },
708
+ { action: "system.config", description: "Get config values. params: { key? }" },
709
+ { action: "system.health", description: "Get system health. params: {}" },
710
+ { action: "workspace.readFile", description: "Read a file. params: { filePath, startLine?, endLine? }" },
711
+ { action: "workspace.listDir", description: "List directory contents. params: { path? }" },
712
+ { action: "workspace.search", description: "Search code. params: { query, filePattern?, maxResults? }" },
713
+ { action: "tool.call", description: "Call a registered tool by name. params: { toolName, args }" },
714
+ { action: "workflow.list", description: "List workflow templates. params: {}" },
715
+ { action: "workflow.get", description: "Get a workflow template. params: { id }" },
716
+ { action: "skill.list", description: "List available skills. params: {}" },
717
+ { action: "prompt.list", description: "List agent prompt definitions. params: {}" },
718
+ { action: "prompt.get", description: "Get a prompt template. params: { key }" },
719
+ { action: "batch", description: "Execute multiple actions. params: { actions: [{ action, params }] }" },
720
+ ];
721
+ }
722
+
723
+ /**
724
+ * Dispatch a voice action intent and return structured results.
725
+ *
726
+ * The voice model can return JSON like:
727
+ * { "action": "task.list", "params": { "status": "todo" } }
728
+ *
729
+ * Bosun processes this directly via JavaScript and returns:
730
+ * { "ok": true, "action": "task.list", "data": { "count": 5, "tasks": [...] } }
731
+ *
732
+ * @param {VoiceActionIntent} intent — { action, params, id? }
733
+ * @param {Object} context — { sessionId, executor, mode, model, userId }
734
+ * @returns {Promise<VoiceActionResult>}
735
+ */
736
+ export async function dispatchVoiceAction(intent, context = {}) {
737
+ const startMs = Date.now();
738
+ const action = String(intent?.action || "").trim();
739
+ const params = intent?.params || {};
740
+ const correlationId = intent?.id || null;
741
+
742
+ if (!action) {
743
+ return {
744
+ ok: false,
745
+ action: "",
746
+ data: null,
747
+ error: "action is required",
748
+ id: correlationId,
749
+ durationMs: Date.now() - startMs,
750
+ };
751
+ }
752
+
753
+ const handler = ACTION_HANDLERS[action];
754
+ if (!handler) {
755
+ return {
756
+ ok: false,
757
+ action,
758
+ data: null,
759
+ error: `Unknown action: ${action}. Available: ${listAvailableActions().join(", ")}`,
760
+ id: correlationId,
761
+ durationMs: Date.now() - startMs,
762
+ };
763
+ }
764
+
765
+ try {
766
+ const data = await handler(params, context);
767
+ return {
768
+ ok: true,
769
+ action,
770
+ data,
771
+ error: null,
772
+ id: correlationId,
773
+ durationMs: Date.now() - startMs,
774
+ };
775
+ } catch (err) {
776
+ console.error(`[voice-action-dispatcher] ${action} error:`, err.message);
777
+ return {
778
+ ok: false,
779
+ action,
780
+ data: null,
781
+ error: err.message,
782
+ id: correlationId,
783
+ durationMs: Date.now() - startMs,
784
+ };
785
+ }
786
+ }
787
+
788
+ /**
789
+ * Process a batch of voice action intents sequentially.
790
+ * @param {VoiceActionIntent[]} intents
791
+ * @param {Object} context
792
+ * @returns {Promise<VoiceActionResult[]>}
793
+ */
794
+ export async function dispatchVoiceActions(intents, context = {}) {
795
+ if (!Array.isArray(intents)) return [];
796
+ const results = [];
797
+ for (const intent of intents.slice(0, 20)) {
798
+ const result = await dispatchVoiceAction(intent, context);
799
+ results.push(result);
800
+ }
801
+ return results;
802
+ }
803
+
804
+ /**
805
+ * Generate the system prompt section describing available actions for the voice model.
806
+ * This is injected into the voice agent's instructions so it knows what actions
807
+ * it can return as JSON.
808
+ *
809
+ * @returns {string}
810
+ */
811
+ export function getVoiceActionPromptSection() {
812
+ const manifest = getActionManifest();
813
+ const lines = [
814
+ "",
815
+ "## Available Bosun Actions",
816
+ "",
817
+ "You can perform any of these actions by returning a JSON object with the following structure:",
818
+ "",
819
+ "```json",
820
+ '{ "action": "<action_name>", "params": { ... } }',
821
+ "```",
822
+ "",
823
+ "Actions:",
824
+ "",
825
+ ];
826
+ for (const entry of manifest) {
827
+ lines.push(`- **${entry.action}** — ${entry.description}`);
828
+ }
829
+ lines.push("");
830
+ lines.push("For multiple actions at once, use the batch action:");
831
+ lines.push("");
832
+ lines.push("```json");
833
+ lines.push('{ "action": "batch", "params": { "actions": [');
834
+ lines.push(' { "action": "task.stats", "params": {} },');
835
+ lines.push(' { "action": "agent.status", "params": {} }');
836
+ lines.push("] } }");
837
+ lines.push("```");
838
+ lines.push("");
839
+ lines.push("When I ask about tasks, agents, or system state, use these actions to get real data.");
840
+ lines.push("Return the JSON action object, and I will process it and give you the results.");
841
+ lines.push("Then speak the results to the user naturally.");
842
+ lines.push("");
843
+ return lines.join("\n");
844
+ }