bosun 0.36.29 → 0.37.0
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/.env.example +2 -32
- package/agent-prompts.mjs +0 -91
- package/config.mjs +6 -75
- package/copilot-shell.mjs +18 -1
- package/desktop/desktop-shortcuts.mjs +144 -27
- package/desktop/main.mjs +138 -16
- package/desktop/package.json +1 -2
- package/desktop/preload.mjs +10 -0
- package/fleet-coordinator.mjs +4 -16
- package/maintenance.mjs +1 -2
- package/monitor.mjs +217 -3109
- package/package.json +3 -2
- package/primary-agent.mjs +47 -8
- package/session-tracker.mjs +5 -0
- package/setup-web-server.mjs +1 -1
- package/task-cli.mjs +4 -38
- package/telegram-bot.mjs +41 -136
- package/ui/app.js +237 -29
- package/ui/components/agent-selector.js +26 -4
- package/ui/demo.html +6 -0
- package/ui/modules/vision-stream.js +196 -22
- package/ui/modules/voice-client-sdk.js +567 -60
- package/ui/modules/voice-client.js +501 -20
- package/ui/modules/voice-fallback.js +28 -6
- package/ui/modules/voice-overlay.js +1454 -500
- package/ui/setup.html +232 -24
- package/ui/styles/components.css +65 -0
- package/ui/styles/layout.css +4 -0
- package/ui/styles/sessions.css +24 -0
- package/ui/tabs/agents.js +125 -28
- package/ui/tabs/chat.js +233 -21
- package/ui/tabs/settings.js +135 -92
- package/ui/tabs/tasks.js +5 -11
- package/ui-server.mjs +339 -131
- package/vision-session-state.mjs +35 -0
- package/voice-action-dispatcher.mjs +163 -20
- package/voice-auth-manager.mjs +88 -25
- package/voice-relay.mjs +243 -18
- package/voice-tools.mjs +1532 -122
package/.env.example
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
# All variables are optional unless marked [REQUIRED].
|
|
5
5
|
# Boolean flags use true/false (preferred). Legacy 1/0 is still accepted.
|
|
6
6
|
# Profile guidance:
|
|
7
|
-
# - Local development: DEVMODE=true,
|
|
8
|
-
# - End-user stable: DEVMODE=false,
|
|
7
|
+
# - Local development: DEVMODE=true, *_TRANSPORT=sdk
|
|
8
|
+
# - End-user stable: DEVMODE=false, *_TRANSPORT=sdk
|
|
9
9
|
|
|
10
10
|
# ─── Task Claims and Coordination ─────────────────────────────────────────────
|
|
11
11
|
# Shared state manager enables distributed task coordination across multiple
|
|
@@ -222,10 +222,6 @@ VOICE_DELEGATE_EXECUTOR=codex-sdk
|
|
|
222
222
|
# SENTINEL_RESTART_BACKOFF_SEC=5
|
|
223
223
|
# After manual /stop, suppress auto-restart for this many minutes (default: 10)
|
|
224
224
|
# SENTINEL_MANUAL_STOP_HOLD_MIN=10
|
|
225
|
-
# In devmode, validate monitor-monitor freshness before restart decisions (default: 1)
|
|
226
|
-
# SENTINEL_MONITOR_MONITOR_CHECK_ENABLED=true
|
|
227
|
-
# Max acceptable monitor-monitor age in minutes (default: 20)
|
|
228
|
-
# SENTINEL_MONITOR_MONITOR_MAX_AGE_MIN=20
|
|
229
225
|
|
|
230
226
|
# ─── Notification Batching (RECOMMENDED) ─────────────────────────────────────
|
|
231
227
|
# Batch notifications into periodic summaries instead of spamming individual messages
|
|
@@ -776,31 +772,6 @@ VK_RECOVERY_PORT=54089
|
|
|
776
772
|
# scripts check for this and exit silently if not present, preventing
|
|
777
773
|
# hooks from firing in standalone Copilot/Codex/Claude sessions.
|
|
778
774
|
|
|
779
|
-
# ─── Devmode Monitor-Monitor (24/7 reliability guardian) ───────────────────
|
|
780
|
-
# Prompt is injected directly from bosun source (no .github/agents file required).
|
|
781
|
-
# Enabled by default in devmode source checkouts. Set to false to disable.
|
|
782
|
-
# DEVMODE_MONITOR_MONITOR_ENABLED=true
|
|
783
|
-
# Poll interval for monitor-monitor runs (milliseconds). Default: 300000 (5 min)
|
|
784
|
-
# DEVMODE_MONITOR_MONITOR_INTERVAL_MS=300000
|
|
785
|
-
# Status stream update interval (milliseconds). Default: 1800000 (30 min)
|
|
786
|
-
# DEVMODE_MONITOR_MONITOR_STATUS_INTERVAL_MS=1800000
|
|
787
|
-
# Per-run timeout before watchdog abort/failover (milliseconds).
|
|
788
|
-
# Default is 21600000 (6h) for long-running reliability analysis sessions.
|
|
789
|
-
# 30 minutes (1800000) is safe if you prefer faster failover on stuck runs.
|
|
790
|
-
# Watchdog abort triggers at timeout+60s, then accelerated force-reset at +120s.
|
|
791
|
-
# Set this explicitly to avoid inherited shell
|
|
792
|
-
# defaults (for example DEVMODE_AUTO_CODE_FIX_TIMEOUT_MS=300000).
|
|
793
|
-
# DEVMODE_MONITOR_MONITOR_TIMEOUT_MS=1800000
|
|
794
|
-
# Optional timeout bounds (applied only when set):
|
|
795
|
-
# DEVMODE_MONITOR_MONITOR_TIMEOUT_MIN_MS=600000
|
|
796
|
-
# DEVMODE_MONITOR_MONITOR_TIMEOUT_MAX_MS=7200000
|
|
797
|
-
# Optional override for Claude tool access (comma-separated)
|
|
798
|
-
# DEVMODE_MONITOR_MONITOR_CLAUDE_ALLOWED_TOOLS=Read,Write,Edit,Grep,Glob,Bash,WebSearch,Task,Skill
|
|
799
|
-
# Legacy alias: DEVMODE_AUTO_CODE_FIX=true also enables this subsystem.
|
|
800
|
-
# Legacy timeout fallback: if DEVMODE_MONITOR_MONITOR_TIMEOUT_MS is unset and
|
|
801
|
-
# DEVMODE_AUTO_CODE_FIX_TIMEOUT_MS is set, monitor-monitor will use it (and
|
|
802
|
-
# still apply DEVMODE_MONITOR_MONITOR_TIMEOUT_MIN_MS/MAX_MS bounds if provided).
|
|
803
|
-
|
|
804
775
|
# ─── Copilot SDK (Primary Agent) ─────────────────────────────────────────────
|
|
805
776
|
# Requires GitHub Copilot CLI installed and authenticated.
|
|
806
777
|
# Set to true to disable Copilot SDK (primary agent) usage.
|
|
@@ -1046,7 +1017,6 @@ COPILOT_CLOUD_DISABLED=true
|
|
|
1046
1017
|
# Files in that folder are loaded automatically and are intended for per-project customization.
|
|
1047
1018
|
# You can also override any prompt path explicitly with env vars:
|
|
1048
1019
|
# BOSUN_PROMPT_PLANNER=.bosun/agents/task-planner.md
|
|
1049
|
-
# BOSUN_PROMPT_MONITOR_MONITOR=.bosun/agents/monitor-monitor.md
|
|
1050
1020
|
# BOSUN_PROMPT_TASK_EXECUTOR=.bosun/agents/task-executor.md
|
|
1051
1021
|
# BOSUN_PROMPT_REVIEWER=.bosun/agents/reviewer.md
|
|
1052
1022
|
# BOSUN_PROMPT_SDK_CONFLICT_RESOLVER=.bosun/agents/sdk-conflict-resolver.md
|
package/agent-prompts.mjs
CHANGED
|
@@ -17,16 +17,6 @@ const PROMPT_DEFS = [
|
|
|
17
17
|
filename: "orchestrator.md",
|
|
18
18
|
description: "Primary task execution prompt for autonomous task agents.",
|
|
19
19
|
},
|
|
20
|
-
{
|
|
21
|
-
key: "planner",
|
|
22
|
-
filename: "task-planner.md",
|
|
23
|
-
description: "Backlog planning prompt used by task planner runs.",
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
key: "monitorMonitor",
|
|
27
|
-
filename: "monitor-monitor.md",
|
|
28
|
-
description: "Long-running reliability monitor prompt used in devmode.",
|
|
29
|
-
},
|
|
30
20
|
{
|
|
31
21
|
key: "taskExecutor",
|
|
32
22
|
filename: "task-executor.md",
|
|
@@ -199,68 +189,6 @@ apply patterns discovered by previous agents:
|
|
|
199
189
|
After completing a task, if you discovered a non-obvious pattern, workaround, or
|
|
200
190
|
domain-specific fact, write or update a skill file at \`.bosun/skills/<module>.md\`
|
|
201
191
|
so the next agent benefits from your investigation.
|
|
202
|
-
`,
|
|
203
|
-
planner: `# Codex-Task-Planner Agent
|
|
204
|
-
|
|
205
|
-
You generate production-grade backlog tasks for autonomous executors.
|
|
206
|
-
|
|
207
|
-
## Mission
|
|
208
|
-
|
|
209
|
-
1. Analyze current repo and delivery state.
|
|
210
|
-
2. Identify highest-value next work.
|
|
211
|
-
3. Create concrete, execution-ready tasks.
|
|
212
|
-
|
|
213
|
-
## Requirements
|
|
214
|
-
|
|
215
|
-
- Avoid vague tasks and duplicate work.
|
|
216
|
-
- Balance reliability fixes, feature delivery, and debt reduction.
|
|
217
|
-
- Every task includes implementation steps, acceptance criteria, and verification plan.
|
|
218
|
-
- Every task title starts with one size label: [xs], [s], [m], [l], [xl], [xxl].
|
|
219
|
-
- Prefer task sets that can run in parallel with low file overlap.
|
|
220
|
-
- Do not call any kanban API, CLI, or external service to create tasks.
|
|
221
|
-
The workflow will automatically materialize your output into kanban tasks.
|
|
222
|
-
- Output must be machine-parseable JSON — see Output Contract below.
|
|
223
|
-
- Task objects must be valid for Bosun backlog creation with fields:
|
|
224
|
-
\'title\', \'description\', \'implementation_steps\', \'acceptance_criteria\',
|
|
225
|
-
\'verification\', optional \'base_branch\'.
|
|
226
|
-
- Do not emit empty or placeholder tasks. Every task must be actionable and execution-ready.
|
|
227
|
-
|
|
228
|
-
## Output Contract (MANDATORY — STRICT)
|
|
229
|
-
|
|
230
|
-
Your ENTIRE response must be a single fenced JSON block. Do NOT include any
|
|
231
|
-
text, commentary, explanations, or markdown before or after the JSON block.
|
|
232
|
-
The downstream parser extracts JSON from fenced blocks — any deviation causes
|
|
233
|
-
task creation to hard-fail.
|
|
234
|
-
|
|
235
|
-
Return exactly this shape:
|
|
236
|
-
|
|
237
|
-
\`\`\`json
|
|
238
|
-
{
|
|
239
|
-
"tasks": [
|
|
240
|
-
{
|
|
241
|
-
"title": "[m] feat(veid): example task title",
|
|
242
|
-
"description": "Problem statement and scope",
|
|
243
|
-
"implementation_steps": ["step 1", "step 2"],
|
|
244
|
-
"acceptance_criteria": ["criterion 1", "criterion 2"],
|
|
245
|
-
"verification": ["test/check 1", "test/check 2"],
|
|
246
|
-
"base_branch": "origin/veid"
|
|
247
|
-
}
|
|
248
|
-
]
|
|
249
|
-
}
|
|
250
|
-
\`\`\`
|
|
251
|
-
|
|
252
|
-
Rules:
|
|
253
|
-
- The \`tasks\` array MUST contain at least the requested task count.
|
|
254
|
-
- Do NOT output partial JSON, truncated arrays, or commentary mixed with JSON.
|
|
255
|
-
- Keep titles unique and specific.
|
|
256
|
-
- Keep file overlap low across tasks to maximize parallel execution.
|
|
257
|
-
- Descriptions must include concrete implementation details, not generic intent text.
|
|
258
|
-
- Include verification commands/checks that a worker can run without additional planning.
|
|
259
|
-
- **Module branch routing:** When the task title follows conventional commit format
|
|
260
|
-
\`feat(module):\` or \`fix(module):\`, set \`base_branch\` to \`origin/<module>\`.
|
|
261
|
-
This routes the task to the module's dedicated branch for parallel, isolated development.
|
|
262
|
-
Examples: \`feat(veid):\` → \`"base_branch": "origin/veid"\`, \`fix(market):\` → \`"base_branch": "origin/market"\`.
|
|
263
|
-
Omit \`base_branch\` for cross-cutting tasks that span multiple modules.
|
|
264
192
|
`,
|
|
265
193
|
taskManager: `# Bosun Task Manager Agent
|
|
266
194
|
|
|
@@ -305,8 +233,6 @@ bosun task stats --json
|
|
|
305
233
|
# Bulk import from JSON file
|
|
306
234
|
bosun task import ./backlog.json
|
|
307
235
|
|
|
308
|
-
# Trigger AI task planner
|
|
309
|
-
bosun task plan --count 5 --reason "Sprint planning"
|
|
310
236
|
\`\`\`
|
|
311
237
|
|
|
312
238
|
### 2. REST API (port 18432 — always available when bosun daemon runs)
|
|
@@ -440,23 +366,6 @@ draft → todo → inprogress → inreview → done
|
|
|
440
366
|
7. **Module branch routing** — When a task title follows conventional commit format
|
|
441
367
|
\`feat(module):\` or \`fix(module):\`, set \`baseBranch\` to \`origin/<module>\` to route the task
|
|
442
368
|
to the module's dedicated branch for parallel, isolated development.
|
|
443
|
-
`,
|
|
444
|
-
monitorMonitor: `# Bosun-Monitor Agent
|
|
445
|
-
|
|
446
|
-
You are the always-on reliability guardian for bosun in devmode.
|
|
447
|
-
|
|
448
|
-
## Core Role
|
|
449
|
-
|
|
450
|
-
- Monitor logs, failures, and agent/orchestrator behavior continuously.
|
|
451
|
-
- Immediately fix reliability regressions and execution blockers.
|
|
452
|
-
- Improve prompt/tool/executor reliability to reduce failure loops.
|
|
453
|
-
- Only when runtime is healthy, perform code-analysis improvements.
|
|
454
|
-
|
|
455
|
-
## Constraints
|
|
456
|
-
|
|
457
|
-
- Operate only in devmode.
|
|
458
|
-
- Do not commit/push/initiate PR lifecycle changes in this context.
|
|
459
|
-
- Apply focused fixes, run focused validation, and keep monitoring.
|
|
460
369
|
`,
|
|
461
370
|
taskExecutor: `# {{TASK_ID}} — {{TASK_TITLE}}
|
|
462
371
|
|
package/config.mjs
CHANGED
|
@@ -506,7 +506,9 @@ function normalizeExecutorModels(executor, models, variant = "DEFAULT") {
|
|
|
506
506
|
);
|
|
507
507
|
return inferred.length > 0 ? inferred : [...known];
|
|
508
508
|
}
|
|
509
|
-
|
|
509
|
+
// Preserve custom/deployment slugs in addition to known models so user-provided
|
|
510
|
+
// model routing survives normalization (for example Azure deployment names).
|
|
511
|
+
return [...new Set(input.filter(Boolean))];
|
|
510
512
|
}
|
|
511
513
|
|
|
512
514
|
function normalizeExecutorEntry(entry, index = 0, total = 1) {
|
|
@@ -540,49 +542,8 @@ function normalizeExecutorEntry(entry, index = 0, total = 1) {
|
|
|
540
542
|
};
|
|
541
543
|
}
|
|
542
544
|
|
|
543
|
-
function buildDefaultTriggerTemplates({
|
|
544
|
-
plannerMode,
|
|
545
|
-
plannerPerCapitaThreshold,
|
|
546
|
-
plannerIdleSlotThreshold,
|
|
547
|
-
plannerDedupHours,
|
|
548
|
-
} = {}) {
|
|
545
|
+
function buildDefaultTriggerTemplates() {
|
|
549
546
|
return [
|
|
550
|
-
{
|
|
551
|
-
id: "task-planner",
|
|
552
|
-
name: "Task Planner",
|
|
553
|
-
description: "Create planning tasks when backlog/slot metrics indicate replenishment.",
|
|
554
|
-
enabled: false,
|
|
555
|
-
action: "task-planner",
|
|
556
|
-
trigger: {
|
|
557
|
-
anyOf: [
|
|
558
|
-
{
|
|
559
|
-
kind: "metric",
|
|
560
|
-
metric: "backlogPerCapita",
|
|
561
|
-
operator: "lt",
|
|
562
|
-
value: plannerPerCapitaThreshold,
|
|
563
|
-
},
|
|
564
|
-
{
|
|
565
|
-
kind: "metric",
|
|
566
|
-
metric: "idleSlots",
|
|
567
|
-
operator: "gte",
|
|
568
|
-
value: plannerIdleSlotThreshold,
|
|
569
|
-
},
|
|
570
|
-
{
|
|
571
|
-
kind: "metric",
|
|
572
|
-
metric: "backlogRemaining",
|
|
573
|
-
operator: "eq",
|
|
574
|
-
value: 0,
|
|
575
|
-
},
|
|
576
|
-
],
|
|
577
|
-
},
|
|
578
|
-
minIntervalMinutes: Math.max(1, Number(plannerDedupHours || 6) * 60),
|
|
579
|
-
config: {
|
|
580
|
-
plannerMode,
|
|
581
|
-
defaultTaskCount: Number(process.env.TASK_PLANNER_DEFAULT_COUNT || "30"),
|
|
582
|
-
executor: "auto",
|
|
583
|
-
model: "auto",
|
|
584
|
-
},
|
|
585
|
-
},
|
|
586
547
|
{
|
|
587
548
|
id: "daily-review-digest",
|
|
588
549
|
name: "Daily Review Digest",
|
|
@@ -1990,32 +1951,8 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
1990
1951
|
"summary"
|
|
1991
1952
|
).toLowerCase();
|
|
1992
1953
|
|
|
1993
|
-
// ── Task Planner ─────────────────────────────────────────
|
|
1994
|
-
// Mode: "codex-sdk" (default) runs Codex directly, "kanban" creates a VK
|
|
1995
|
-
// task for a real agent to plan, "disabled" turns off the planner entirely.
|
|
1996
|
-
const plannerMode = (
|
|
1997
|
-
process.env.TASK_PLANNER_MODE ||
|
|
1998
|
-
configData.plannerMode ||
|
|
1999
|
-
(mode === "generic" ? "disabled" : "codex-sdk")
|
|
2000
|
-
).toLowerCase();
|
|
2001
|
-
const plannerPerCapitaThreshold = Number(
|
|
2002
|
-
process.env.TASK_PLANNER_PER_CAPITA_THRESHOLD || "1",
|
|
2003
|
-
);
|
|
2004
|
-
const plannerIdleSlotThreshold = Number(
|
|
2005
|
-
process.env.TASK_PLANNER_IDLE_SLOT_THRESHOLD || "1",
|
|
2006
|
-
);
|
|
2007
|
-
const plannerDedupHours = Number(process.env.TASK_PLANNER_DEDUP_HOURS || "6");
|
|
2008
|
-
const plannerDedupMs = Number.isFinite(plannerDedupHours)
|
|
2009
|
-
? plannerDedupHours * 60 * 60 * 1000
|
|
2010
|
-
: 24 * 60 * 60 * 1000;
|
|
2011
|
-
|
|
2012
1954
|
const triggerSystemDefaults = Object.freeze({
|
|
2013
|
-
templates: buildDefaultTriggerTemplates(
|
|
2014
|
-
plannerMode,
|
|
2015
|
-
plannerPerCapitaThreshold,
|
|
2016
|
-
plannerIdleSlotThreshold,
|
|
2017
|
-
plannerDedupHours,
|
|
2018
|
-
}),
|
|
1955
|
+
templates: buildDefaultTriggerTemplates(),
|
|
2019
1956
|
defaults: Object.freeze({
|
|
2020
1957
|
executor: "auto",
|
|
2021
1958
|
model: "auto",
|
|
@@ -2290,12 +2227,6 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
2290
2227
|
telegramCommandEnabled,
|
|
2291
2228
|
telegramVerbosity,
|
|
2292
2229
|
|
|
2293
|
-
// Task Planner
|
|
2294
|
-
plannerMode,
|
|
2295
|
-
plannerPerCapitaThreshold,
|
|
2296
|
-
plannerIdleSlotThreshold,
|
|
2297
|
-
plannerDedupHours,
|
|
2298
|
-
plannerDedupMs,
|
|
2299
2230
|
triggerSystem,
|
|
2300
2231
|
|
|
2301
2232
|
// GitHub Reconciler
|
|
@@ -2349,7 +2280,7 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
2349
2280
|
configData.trustedCreators ||
|
|
2350
2281
|
process.env.BOSUN_TRUSTED_CREATORS?.split(",") ||
|
|
2351
2282
|
[],
|
|
2352
|
-
// Enforce all new tasks go to backlog
|
|
2283
|
+
// Enforce all new tasks go to backlog
|
|
2353
2284
|
enforceBacklog:
|
|
2354
2285
|
typeof configData.enforceBacklog === "boolean"
|
|
2355
2286
|
? configData.enforceBacklog
|
package/copilot-shell.mjs
CHANGED
|
@@ -469,9 +469,26 @@ async function ensureClientStarted() {
|
|
|
469
469
|
const modeLabel = clientOptions.cliUrl ? "remote" : "local (stdio)";
|
|
470
470
|
console.log(`[copilot-shell] starting client in ${modeLabel} mode`);
|
|
471
471
|
|
|
472
|
+
const START_TIMEOUT_MS =
|
|
473
|
+
Number(process.env.COPILOT_START_TIMEOUT_MS) || 20_000;
|
|
474
|
+
|
|
472
475
|
await withSanitizedOpenAiEnv(async () => {
|
|
473
476
|
copilotClient = new Cls(clientOptions);
|
|
474
|
-
await
|
|
477
|
+
await Promise.race([
|
|
478
|
+
copilotClient.start(),
|
|
479
|
+
new Promise((_, reject) =>
|
|
480
|
+
setTimeout(
|
|
481
|
+
() =>
|
|
482
|
+
reject(
|
|
483
|
+
new Error(
|
|
484
|
+
`Copilot CLI failed to start within ${START_TIMEOUT_MS / 1000}s — ` +
|
|
485
|
+
`verify COPILOT_CLI_PATH or run \`gh auth login\``,
|
|
486
|
+
),
|
|
487
|
+
),
|
|
488
|
+
START_TIMEOUT_MS,
|
|
489
|
+
),
|
|
490
|
+
),
|
|
491
|
+
]);
|
|
475
492
|
});
|
|
476
493
|
clientStarted = true;
|
|
477
494
|
console.log("[copilot-shell] client started");
|
|
@@ -36,6 +36,7 @@ export const SCOPE_LOCAL = "local";
|
|
|
36
36
|
* @property {string} description Longer description for tooltips/help.
|
|
37
37
|
* @property {string} defaultAccelerator Default Electron accelerator string.
|
|
38
38
|
* @property {string} scope "global" | "local"
|
|
39
|
+
* @property {boolean} [globalEligible] If true the user can opt this local shortcut in as a global one.
|
|
39
40
|
* @property {string} [group] Optional display grouping.
|
|
40
41
|
*/
|
|
41
42
|
|
|
@@ -61,26 +62,35 @@ export const DEFAULT_SHORTCUTS = [
|
|
|
61
62
|
{
|
|
62
63
|
id: "bosun.voice.call",
|
|
63
64
|
label: "Start Voice Call",
|
|
64
|
-
description:
|
|
65
|
+
description:
|
|
66
|
+
"Open the voice companion and start a voice call. " +
|
|
67
|
+
"Only fires when Bosun is focused unless 'Enable as global shortcut' is on.",
|
|
65
68
|
defaultAccelerator: "CmdOrCtrl+Shift+Space",
|
|
66
|
-
scope:
|
|
67
|
-
|
|
69
|
+
scope: SCOPE_LOCAL,
|
|
70
|
+
globalEligible: true,
|
|
71
|
+
group: "Voice",
|
|
68
72
|
},
|
|
69
73
|
{
|
|
70
74
|
id: "bosun.voice.video",
|
|
71
75
|
label: "Start Video Call",
|
|
72
|
-
description:
|
|
76
|
+
description:
|
|
77
|
+
"Open the voice companion and start a video call. " +
|
|
78
|
+
"Only fires when Bosun is focused unless 'Enable as global shortcut' is on.",
|
|
73
79
|
defaultAccelerator: "CmdOrCtrl+Shift+K",
|
|
74
|
-
scope:
|
|
75
|
-
|
|
80
|
+
scope: SCOPE_LOCAL,
|
|
81
|
+
globalEligible: true,
|
|
82
|
+
group: "Voice",
|
|
76
83
|
},
|
|
77
84
|
{
|
|
78
85
|
id: "bosun.voice.toggle",
|
|
79
86
|
label: "Toggle Voice Companion",
|
|
80
|
-
description:
|
|
87
|
+
description:
|
|
88
|
+
"Show or hide the floating voice companion window. " +
|
|
89
|
+
"Only fires when Bosun is focused unless 'Enable as global shortcut' is on.",
|
|
81
90
|
defaultAccelerator: "CmdOrCtrl+Shift+V",
|
|
82
|
-
scope:
|
|
83
|
-
|
|
91
|
+
scope: SCOPE_LOCAL,
|
|
92
|
+
globalEligible: true,
|
|
93
|
+
group: "Voice",
|
|
84
94
|
},
|
|
85
95
|
|
|
86
96
|
// ── Local ─ menu accelerators, only when app is focused ───────────────────
|
|
@@ -217,6 +227,12 @@ const actionHandlers = new Map();
|
|
|
217
227
|
*/
|
|
218
228
|
let customizations = new Map();
|
|
219
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Per-shortcut global-scope overrides for globalEligible shortcuts.
|
|
232
|
+
* Map<id, boolean> — true = user has opted this local shortcut in as global.
|
|
233
|
+
*/
|
|
234
|
+
let scopeOverrides = new Map();
|
|
235
|
+
|
|
220
236
|
/** Path to the JSON config file. */
|
|
221
237
|
let configFilePath = null;
|
|
222
238
|
|
|
@@ -233,7 +249,9 @@ let globalsActive = false;
|
|
|
233
249
|
*/
|
|
234
250
|
export function initShortcuts(configDir) {
|
|
235
251
|
configFilePath = resolve(configDir, "desktop-shortcuts.json");
|
|
236
|
-
|
|
252
|
+
const loaded = _loadPersisted(configFilePath);
|
|
253
|
+
customizations = loaded.customizations;
|
|
254
|
+
scopeOverrides = loaded.scopeOverrides;
|
|
237
255
|
}
|
|
238
256
|
|
|
239
257
|
/**
|
|
@@ -248,7 +266,24 @@ export function onShortcut(id, handler) {
|
|
|
248
266
|
}
|
|
249
267
|
|
|
250
268
|
/**
|
|
251
|
-
*
|
|
269
|
+
* Return true when a shortcut should currently be registered as a global.
|
|
270
|
+
* A shortcut qualifies if:
|
|
271
|
+
* - Its catalog scope is SCOPE_GLOBAL, OR
|
|
272
|
+
* - It is globalEligible AND the user has opted it in via setShortcutScope().
|
|
273
|
+
*
|
|
274
|
+
* @param {ShortcutDef} def
|
|
275
|
+
* @returns {boolean}
|
|
276
|
+
*/
|
|
277
|
+
function _isRegisteredGlobal(def) {
|
|
278
|
+
if (def.scope === SCOPE_GLOBAL) return true;
|
|
279
|
+
if (def.globalEligible && scopeOverrides.get(def.id) === true) return true;
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Register all global shortcuts with Electron.
|
|
285
|
+
* Includes catalog-scope SCOPE_GLOBAL entries + any globalEligible shortcuts
|
|
286
|
+
* that the user has opted in to fire system-wide.
|
|
252
287
|
* Silently skips shortcuts that have no registered action handler.
|
|
253
288
|
* Safe to call multiple times — old registrations are cleared first.
|
|
254
289
|
*/
|
|
@@ -257,7 +292,7 @@ export function registerGlobalShortcuts() {
|
|
|
257
292
|
_unregisterAllGlobals();
|
|
258
293
|
|
|
259
294
|
for (const def of DEFAULT_SHORTCUTS) {
|
|
260
|
-
if (def
|
|
295
|
+
if (!_isRegisteredGlobal(def)) continue;
|
|
261
296
|
|
|
262
297
|
const accelerator = getEffectiveAccelerator(def.id);
|
|
263
298
|
if (!accelerator) continue; // disabled by user
|
|
@@ -311,6 +346,15 @@ export function getAllShortcuts() {
|
|
|
311
346
|
? (overrideValue ?? null)
|
|
312
347
|
: def.defaultAccelerator,
|
|
313
348
|
scope: def.scope,
|
|
349
|
+
/** Whether this shortcut can be opted in as a system-wide global. */
|
|
350
|
+
globalEligible: def.globalEligible ?? false,
|
|
351
|
+
/**
|
|
352
|
+
* Whether this globalEligible shortcut is currently firing system-wide.
|
|
353
|
+
* Always true for catalog-scope SCOPE_GLOBAL shortcuts.
|
|
354
|
+
*/
|
|
355
|
+
isGlobalEnabled:
|
|
356
|
+
def.scope === SCOPE_GLOBAL ||
|
|
357
|
+
(def.globalEligible === true && scopeOverrides.get(def.id) === true),
|
|
314
358
|
group: def.group ?? "",
|
|
315
359
|
isCustomized: hasOverride && overrideValue !== undefined,
|
|
316
360
|
isDisabled: hasOverride && overrideValue === null,
|
|
@@ -342,6 +386,35 @@ export function getEffectiveAccelerator(id) {
|
|
|
342
386
|
* @param {string|null} accelerator Electron accelerator string, or null to disable.
|
|
343
387
|
* @returns {{ ok: boolean, error?: string }}
|
|
344
388
|
*/
|
|
389
|
+
/**
|
|
390
|
+
* Enable or disable global (system-wide) firing for a globalEligible shortcut.
|
|
391
|
+
* Has no effect on shortcuts that are catalog-level SCOPE_GLOBAL.
|
|
392
|
+
*
|
|
393
|
+
* @param {string} id
|
|
394
|
+
* @param {boolean} isGlobal
|
|
395
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
396
|
+
*/
|
|
397
|
+
export function setShortcutScope(id, isGlobal) {
|
|
398
|
+
const def = DEFAULT_SHORTCUTS.find((d) => d.id === id);
|
|
399
|
+
if (!def) return { ok: false, error: `Unknown shortcut: ${id}` };
|
|
400
|
+
if (!def.globalEligible) {
|
|
401
|
+
return { ok: false, error: `Shortcut '${id}' does not support scope override.` };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (isGlobal) {
|
|
405
|
+
scopeOverrides.set(id, true);
|
|
406
|
+
} else {
|
|
407
|
+
scopeOverrides.delete(id);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
_savePersisted(configFilePath, customizations, scopeOverrides);
|
|
411
|
+
|
|
412
|
+
// Re-apply so the shortcut is registered / unregistered immediately.
|
|
413
|
+
if (globalsActive) registerGlobalShortcuts();
|
|
414
|
+
|
|
415
|
+
return { ok: true };
|
|
416
|
+
}
|
|
417
|
+
|
|
345
418
|
export function setShortcut(id, accelerator) {
|
|
346
419
|
const def = DEFAULT_SHORTCUTS.find((d) => d.id === id);
|
|
347
420
|
if (!def) return { ok: false, error: `Unknown shortcut: ${id}` };
|
|
@@ -362,10 +435,10 @@ export function setShortcut(id, accelerator) {
|
|
|
362
435
|
}
|
|
363
436
|
|
|
364
437
|
customizations.set(id, accelerator === undefined ? null : accelerator);
|
|
365
|
-
|
|
438
|
+
_savePersisted(configFilePath, customizations, scopeOverrides);
|
|
366
439
|
|
|
367
|
-
// Re-apply globals when a global shortcut is changed.
|
|
368
|
-
if (def
|
|
440
|
+
// Re-apply globals when a global/globally-enabled shortcut is changed.
|
|
441
|
+
if (_isRegisteredGlobal(def) && globalsActive) {
|
|
369
442
|
registerGlobalShortcuts();
|
|
370
443
|
}
|
|
371
444
|
|
|
@@ -383,9 +456,9 @@ export function resetShortcut(id) {
|
|
|
383
456
|
if (!def) return { ok: false, error: `Unknown shortcut: ${id}` };
|
|
384
457
|
|
|
385
458
|
customizations.delete(id);
|
|
386
|
-
|
|
459
|
+
_savePersisted(configFilePath, customizations, scopeOverrides);
|
|
387
460
|
|
|
388
|
-
if (def
|
|
461
|
+
if (_isRegisteredGlobal(def) && globalsActive) {
|
|
389
462
|
registerGlobalShortcuts();
|
|
390
463
|
}
|
|
391
464
|
|
|
@@ -399,7 +472,8 @@ export function resetShortcut(id) {
|
|
|
399
472
|
*/
|
|
400
473
|
export function resetAllShortcuts() {
|
|
401
474
|
customizations.clear();
|
|
402
|
-
|
|
475
|
+
scopeOverrides.clear();
|
|
476
|
+
_savePersisted(configFilePath, customizations, scopeOverrides);
|
|
403
477
|
|
|
404
478
|
if (globalsActive) registerGlobalShortcuts();
|
|
405
479
|
return { ok: true };
|
|
@@ -409,7 +483,10 @@ export function resetAllShortcuts() {
|
|
|
409
483
|
|
|
410
484
|
function _unregisterAllGlobals() {
|
|
411
485
|
for (const def of DEFAULT_SHORTCUTS) {
|
|
412
|
-
|
|
486
|
+
// Unregister any shortcut that was or could be registered as global.
|
|
487
|
+
// This covers catalog-level globals AND globalEligible shortcuts regardless
|
|
488
|
+
// of the current scopeOverride value (handles transitions cleanly).
|
|
489
|
+
if (def.scope !== SCOPE_GLOBAL && !def.globalEligible) continue;
|
|
413
490
|
|
|
414
491
|
// Unregister both the currently effective AND the default to be safe
|
|
415
492
|
// when an override is being replaced.
|
|
@@ -454,25 +531,65 @@ function _normalizeAcc(acc) {
|
|
|
454
531
|
return String(acc).toLowerCase().replace(/\s+/g, "");
|
|
455
532
|
}
|
|
456
533
|
|
|
457
|
-
|
|
534
|
+
/**
|
|
535
|
+
* Load both accelerator customizations and scope overrides from disk.
|
|
536
|
+
* File format:
|
|
537
|
+
* {
|
|
538
|
+
* "shortcut.id": "Accelerator" | null,
|
|
539
|
+
* "_scopes": { "shortcut.id": true }
|
|
540
|
+
* }
|
|
541
|
+
* The "_scopes" key is reserved and never treated as a shortcut ID.
|
|
542
|
+
*
|
|
543
|
+
* @param {string} filePath
|
|
544
|
+
* @returns {{ customizations: Map<string, string|null>, scopeOverrides: Map<string, boolean> }}
|
|
545
|
+
*/
|
|
546
|
+
function _loadPersisted(filePath) {
|
|
458
547
|
try {
|
|
459
|
-
if (!existsSync(filePath))
|
|
548
|
+
if (!existsSync(filePath)) {
|
|
549
|
+
return { customizations: new Map(), scopeOverrides: new Map() };
|
|
550
|
+
}
|
|
460
551
|
const raw = JSON.parse(readFileSync(filePath, "utf8"));
|
|
461
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
462
|
-
|
|
463
|
-
|
|
552
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
553
|
+
return { customizations: new Map(), scopeOverrides: new Map() };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const scopesRaw = raw._scopes;
|
|
557
|
+
const scopeOverridesMap = new Map(
|
|
558
|
+
scopesRaw && typeof scopesRaw === "object" && !Array.isArray(scopesRaw)
|
|
559
|
+
? Object.entries(scopesRaw)
|
|
560
|
+
.filter(([, v]) => v === true)
|
|
561
|
+
.map(([k]) => [k, true])
|
|
562
|
+
: [],
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const customizationsMap = new Map(
|
|
566
|
+
Object.entries(raw)
|
|
567
|
+
.filter(([k]) => k !== "_scopes")
|
|
568
|
+
.map(([k, v]) => [k, v === null ? null : String(v)]),
|
|
464
569
|
);
|
|
570
|
+
|
|
571
|
+
return { customizations: customizationsMap, scopeOverrides: scopeOverridesMap };
|
|
465
572
|
} catch {
|
|
466
|
-
return new Map();
|
|
573
|
+
return { customizations: new Map(), scopeOverrides: new Map() };
|
|
467
574
|
}
|
|
468
575
|
}
|
|
469
576
|
|
|
470
|
-
|
|
577
|
+
/**
|
|
578
|
+
* Persist both accelerator customizations and scope overrides to disk.
|
|
579
|
+
*
|
|
580
|
+
* @param {string|null} filePath
|
|
581
|
+
* @param {Map<string, string|null>} customizationsMap
|
|
582
|
+
* @param {Map<string, boolean>} scopeOverridesMap
|
|
583
|
+
*/
|
|
584
|
+
function _savePersisted(filePath, customizationsMap, scopeOverridesMap) {
|
|
471
585
|
if (!filePath) return;
|
|
472
586
|
try {
|
|
473
587
|
const dir = dirname(filePath);
|
|
474
588
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
475
|
-
const obj = Object.fromEntries(
|
|
589
|
+
const obj = Object.fromEntries(customizationsMap);
|
|
590
|
+
if (scopeOverridesMap.size > 0) {
|
|
591
|
+
obj._scopes = Object.fromEntries(scopeOverridesMap);
|
|
592
|
+
}
|
|
476
593
|
writeFileSync(filePath, JSON.stringify(obj, null, 2), "utf8");
|
|
477
594
|
} catch (err) {
|
|
478
595
|
console.warn("[shortcuts] failed to save:", err?.message || err);
|