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 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, DEVMODE_MONITOR_MONITOR_ENABLED=true, *_TRANSPORT=sdk
8
- # - End-user stable: DEVMODE=false, DEVMODE_MONITOR_MONITOR_ENABLED=false, *_TRANSPORT=sdk
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
- return input.filter((model) => known.has(model));
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 unless planner config allows auto-push
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 copilotClient.start();
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: "Open the voice companion and start a voice call",
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: SCOPE_GLOBAL,
67
- group: "Global",
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: "Open the voice companion and start a video call",
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: SCOPE_GLOBAL,
75
- group: "Global",
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: "Show or hide the floating voice companion window",
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: SCOPE_GLOBAL,
83
- group: "Global",
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
- customizations = _loadCustomizations(configFilePath);
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
- * Register all SCOPE_GLOBAL shortcuts with Electron.
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.scope !== SCOPE_GLOBAL) continue;
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
- _saveCustomizations(configFilePath, customizations);
438
+ _savePersisted(configFilePath, customizations, scopeOverrides);
366
439
 
367
- // Re-apply globals when a global shortcut is changed.
368
- if (def.scope === SCOPE_GLOBAL && globalsActive) {
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
- _saveCustomizations(configFilePath, customizations);
459
+ _savePersisted(configFilePath, customizations, scopeOverrides);
387
460
 
388
- if (def.scope === SCOPE_GLOBAL && globalsActive) {
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
- _saveCustomizations(configFilePath, customizations);
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
- if (def.scope !== SCOPE_GLOBAL) continue;
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
- function _loadCustomizations(filePath) {
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)) return new Map();
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)) return new Map();
462
- return new Map(
463
- Object.entries(raw).map(([k, v]) => [k, v === null ? null : String(v)]),
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
- function _saveCustomizations(filePath, map) {
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(map);
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);