@mthines/reaper-mcp 0.10.0 → 0.12.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/main.js CHANGED
@@ -131,6 +131,91 @@ function getTimeoutCounter() {
131
131
  }
132
132
  return _timeoutCounter;
133
133
  }
134
+ var _handlerDurationHistogram = null;
135
+ var _pickupDurationHistogram = null;
136
+ function getHandlerDurationHistogram() {
137
+ if (!_handlerDurationHistogram) {
138
+ _handlerDurationHistogram = getMeter().createHistogram(
139
+ "mcp.bridge.handler.duration",
140
+ {
141
+ description: "Time Lua spent executing the command handler inside REAPER",
142
+ unit: "ms",
143
+ advice: {
144
+ explicitBucketBoundaries: [
145
+ 0.1,
146
+ 0.25,
147
+ 0.5,
148
+ 1,
149
+ 2,
150
+ 5,
151
+ 10,
152
+ 25,
153
+ 50,
154
+ 100,
155
+ 250,
156
+ 500
157
+ ]
158
+ }
159
+ }
160
+ );
161
+ }
162
+ return _handlerDurationHistogram;
163
+ }
164
+ function getPickupDurationHistogram() {
165
+ if (!_pickupDurationHistogram) {
166
+ _pickupDurationHistogram = getMeter().createHistogram(
167
+ "mcp.bridge.pickup.duration",
168
+ {
169
+ description: "Time from Node writing the command file to Lua picking it up (defer cycle latency)",
170
+ unit: "ms",
171
+ advice: {
172
+ explicitBucketBoundaries: [
173
+ 1,
174
+ 2,
175
+ 5,
176
+ 10,
177
+ 16,
178
+ 33,
179
+ 50,
180
+ 100,
181
+ 200,
182
+ 500,
183
+ 1e3
184
+ ]
185
+ }
186
+ }
187
+ );
188
+ }
189
+ return _pickupDurationHistogram;
190
+ }
191
+ var _deferP50Ms = 0;
192
+ var _deferP95Ms = 0;
193
+ var _scanAvgMs = 0;
194
+ var _scanMaxMs = 0;
195
+ function updateDeferStats(p50, p95) {
196
+ _deferP50Ms = p50;
197
+ _deferP95Ms = p95;
198
+ }
199
+ function updateScanStats(avg, max) {
200
+ _scanAvgMs = avg;
201
+ _scanMaxMs = max;
202
+ }
203
+ function registerBridgeGauges() {
204
+ getMeter().createObservableGauge("mcp.bridge.defer.interval", {
205
+ description: "REAPER defer loop interval (p50/p95 over last 100 cycles)",
206
+ unit: "ms"
207
+ }).addCallback((obs) => {
208
+ obs.observe(_deferP50Ms, { percentile: "p50" });
209
+ obs.observe(_deferP95Ms, { percentile: "p95" });
210
+ });
211
+ getMeter().createObservableGauge("mcp.bridge.scan.duration", {
212
+ description: "Time Lua spends per main_loop scan cycle (avg/max over last 100 scans)",
213
+ unit: "ms"
214
+ }).addCallback((obs) => {
215
+ obs.observe(_scanAvgMs, { percentile: "avg" });
216
+ obs.observe(_scanMaxMs, { percentile: "max" });
217
+ });
218
+ }
134
219
 
135
220
  // apps/reaper-mcp-server/src/bridge.ts
136
221
  import { randomUUID } from "node:crypto";
@@ -224,9 +309,29 @@ async function sendCommand(type, params = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
224
309
  const tCleanup = now();
225
310
  await Promise.allSettled([unlink(commandPath), unlink(responsePath)]);
226
311
  if (profiling) t["cleanup"] = now() - tCleanup;
312
+ const rawResponse = response;
313
+ const handlerMs = rawResponse["_handlerMs"];
314
+ const pickupMs = rawResponse["_pickupMs"];
315
+ const deferCycle = rawResponse["_deferCycle"];
227
316
  const durationMs2 = Date.now() - startMs;
228
317
  const succeeded = response.success;
229
318
  span.setAttribute("mcp.response.success", succeeded);
319
+ if (handlerMs != null) {
320
+ span.setAttribute("mcp.bridge.handler_ms", handlerMs);
321
+ getHandlerDurationHistogram().record(handlerMs, {
322
+ command_type: type,
323
+ success: String(succeeded)
324
+ });
325
+ }
326
+ if (pickupMs != null) {
327
+ span.setAttribute("mcp.bridge.pickup_ms", pickupMs);
328
+ getPickupDurationHistogram().record(pickupMs, {
329
+ command_type: type
330
+ });
331
+ }
332
+ if (deferCycle != null) {
333
+ span.setAttribute("mcp.bridge.defer_cycle", deferCycle);
334
+ }
230
335
  if (!succeeded) {
231
336
  span.setStatus({ code: SpanStatusCode.ERROR, message: response.error ?? "Bridge error" });
232
337
  span.setAttribute("mcp.response.error", response.error ?? "unknown");
@@ -333,6 +438,69 @@ function getReaperEffectsPath() {
333
438
  function sleep(ms) {
334
439
  return new Promise((resolve) => setTimeout(resolve, ms));
335
440
  }
441
+ async function readBridgeEvents() {
442
+ const eventsPath = join2(getBridgeDir(), "bridge_events.jsonl");
443
+ let content;
444
+ try {
445
+ content = await readFile(eventsPath, "utf-8");
446
+ } catch {
447
+ return;
448
+ }
449
+ await writeFile(eventsPath, "", "utf-8").catch(() => {
450
+ });
451
+ const lines = content.split("\n").filter((l) => l.trim() !== "");
452
+ for (const line of lines) {
453
+ try {
454
+ const evt = JSON.parse(line);
455
+ console.error(JSON.stringify({ msg: "bridge_event", ...evt }));
456
+ } catch {
457
+ }
458
+ }
459
+ }
460
+ var _diagnosticsTimer = null;
461
+ var _eventsTimer = null;
462
+ function startDiagnosticsPoller(intervalMs = 6e4) {
463
+ if (_diagnosticsTimer) return;
464
+ _diagnosticsTimer = setInterval(async () => {
465
+ try {
466
+ const res = await sendCommand(
467
+ "_bridge_diagnostics",
468
+ {},
469
+ 5e3
470
+ );
471
+ if (res.success && res.data) {
472
+ const d = res.data;
473
+ if (d.deferIntervals) {
474
+ updateDeferStats(d.deferIntervals.p50Ms, d.deferIntervals.p95Ms);
475
+ }
476
+ if (d.scanDurations) {
477
+ updateScanStats(d.scanDurations.avgMs, d.scanDurations.maxMs);
478
+ }
479
+ }
480
+ } catch {
481
+ }
482
+ }, intervalMs);
483
+ }
484
+ function startEventsPoller(intervalMs = 3e4) {
485
+ if (_eventsTimer) return;
486
+ readBridgeEvents().catch(() => {
487
+ });
488
+ _eventsTimer = setInterval(
489
+ () => readBridgeEvents().catch(() => {
490
+ }),
491
+ intervalMs
492
+ );
493
+ }
494
+ function stopPollers() {
495
+ if (_diagnosticsTimer) {
496
+ clearInterval(_diagnosticsTimer);
497
+ _diagnosticsTimer = null;
498
+ }
499
+ if (_eventsTimer) {
500
+ clearInterval(_eventsTimer);
501
+ _eventsTimer = null;
502
+ }
503
+ }
336
504
 
337
505
  // apps/reaper-mcp-server/src/tools/project.ts
338
506
  function registerProjectTools(server) {
@@ -670,13 +838,15 @@ import { z as z7 } from "zod/v4";
670
838
  function registerSnapshotTools(server) {
671
839
  server.tool(
672
840
  "snapshot_save",
673
- "Save the current mixer state as a named snapshot. Uses SWS Snapshots if available, otherwise captures track volumes, pans, mutes, solos, and FX bypass states manually.",
841
+ 'Save the current mixer state as a named snapshot. Stored in .reaper-mcp/snapshots/ alongside the project file (falls back to global bridge dir for unsaved projects). Captures track volumes, pans, mutes, solos, FX bypass/offline states, full FX parameter values, preset names, track names/colors, and send levels. Response includes storageLocation ("project" or "global").',
674
842
  {
675
843
  name: z7.string().min(1).describe('Unique snapshot name (e.g. "before-compression", "v1-mix")'),
676
- description: z7.string().optional().describe("Optional human-readable description")
844
+ description: z7.string().optional().describe("Optional human-readable description"),
845
+ includeFxParams: z7.boolean().optional().default(true).describe("Include FX parameter values in snapshot (default true)"),
846
+ maxParamsPerFx: z7.coerce.number().int().min(1).max(2e3).optional().default(500).describe("Max parameters to capture per FX plugin (default 500)")
677
847
  },
678
- async ({ name, description }) => {
679
- const res = await sendCommand("snapshot_save", { name, description });
848
+ async ({ name, description, includeFxParams, maxParamsPerFx }) => {
849
+ const res = await sendCommand("snapshot_save", { name, description, includeFxParams, maxParamsPerFx });
680
850
  if (!res.success) {
681
851
  return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
682
852
  }
@@ -685,12 +855,14 @@ function registerSnapshotTools(server) {
685
855
  );
686
856
  server.tool(
687
857
  "snapshot_restore",
688
- "Restore a previously saved mixer snapshot by name",
858
+ "Restore a previously saved mixer snapshot by name. Reads from .reaper-mcp/snapshots/ alongside the project file (falls back to global bridge dir for unsaved projects). Restores volumes, pans, mutes, solos, FX states, and optionally FX parameters (if snapshot is v2). FX parameters are only restored when the plugin name matches the snapshot to prevent applying wrong values.",
689
859
  {
690
- name: z7.string().min(1).describe("Name of the snapshot to restore")
860
+ name: z7.string().min(1).describe("Name of the snapshot to restore"),
861
+ restoreTrackMeta: z7.boolean().optional().default(false).describe("Also restore track names and colors (default false)"),
862
+ restoreSendLevels: z7.boolean().optional().default(true).describe("Restore send volume/pan/mute for existing sends (default true)")
691
863
  },
692
- async ({ name }) => {
693
- const res = await sendCommand("snapshot_restore", { name });
864
+ async ({ name, restoreTrackMeta, restoreSendLevels }) => {
865
+ const res = await sendCommand("snapshot_restore", { name, restoreTrackMeta, restoreSendLevels });
694
866
  if (!res.success) {
695
867
  return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
696
868
  }
@@ -699,7 +871,7 @@ function registerSnapshotTools(server) {
699
871
  );
700
872
  server.tool(
701
873
  "snapshot_list",
702
- "List all saved mixer snapshots with names, descriptions, and timestamps",
874
+ 'List all saved mixer snapshots for the current project. Reads from .reaper-mcp/snapshots/ alongside the project file (falls back to global bridge dir for unsaved projects). Response includes storageLocation ("project" or "global").',
703
875
  {},
704
876
  async () => {
705
877
  const res = await sendCommand("snapshot_list", {});
@@ -2086,6 +2258,9 @@ async function doctor() {
2086
2258
  async function serve() {
2087
2259
  const log = (...args) => console.error("[reaper-mcp]", ...args);
2088
2260
  await initTelemetry();
2261
+ registerBridgeGauges();
2262
+ startDiagnosticsPoller();
2263
+ startEventsPoller();
2089
2264
  log("Starting REAPER MCP Server...");
2090
2265
  log(`Entry: ${fileURLToPath2(import.meta.url)}`);
2091
2266
  const tracer = getTracer();
@@ -2195,18 +2370,22 @@ Tip: install globally for shorter commands:
2195
2370
  }
2196
2371
  process.on("SIGINT", () => {
2197
2372
  console.error("[reaper-mcp] Interrupted");
2373
+ stopPollers();
2198
2374
  shutdownTelemetry().finally(() => process.exit(0));
2199
2375
  });
2200
2376
  process.on("SIGTERM", () => {
2201
2377
  console.error("[reaper-mcp] Terminated");
2378
+ stopPollers();
2202
2379
  shutdownTelemetry().finally(() => process.exit(0));
2203
2380
  });
2204
2381
  process.on("uncaughtException", (err) => {
2205
2382
  console.error("[reaper-mcp] Uncaught exception:", err);
2383
+ stopPollers();
2206
2384
  shutdownTelemetry().finally(() => process.exit(1));
2207
2385
  });
2208
2386
  process.on("unhandledRejection", (reason) => {
2209
2387
  console.error("[reaper-mcp] Unhandled rejection:", reason);
2388
+ stopPollers();
2210
2389
  shutdownTelemetry().finally(() => process.exit(1));
2211
2390
  });
2212
2391
  //# sourceMappingURL=main.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mthines/reaper-mcp",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for controlling REAPER DAW — real-time mixing, FX control, and frequency analysis for AI agents",
6
6
  "license": "MIT",
@@ -231,6 +231,22 @@ local function write_file(path, content)
231
231
  return true
232
232
  end
233
233
 
234
+ -- =============================================================================
235
+ -- Telemetry event logging
236
+ -- =============================================================================
237
+
238
+ local EVENTS_LOG = bridge_dir .. "bridge_events.jsonl"
239
+
240
+ local function log_event(event_type, payload)
241
+ local f = io.open(EVENTS_LOG, "a")
242
+ if not f then return end
243
+ payload = payload or {}
244
+ payload.ts = os.time() * 1000
245
+ payload.type = event_type
246
+ f:write(json_encode(payload) .. "\n")
247
+ f:close()
248
+ end
249
+
234
250
  local function file_exists(path)
235
251
  local f = io.open(path, "r")
236
252
  if f then f:close() return true end
@@ -815,9 +831,22 @@ end
815
831
  -- =============================================================================
816
832
 
817
833
  local function get_snapshot_dir()
834
+ local proj_path = reaper.GetProjectPath()
835
+ if proj_path and proj_path ~= "" then
836
+ return proj_path .. "/.reaper-mcp/snapshots/"
837
+ end
838
+ -- Fallback for unsaved projects
818
839
  return bridge_dir .. "snapshots/"
819
840
  end
820
841
 
842
+ local function get_snapshot_storage_location()
843
+ local proj_path = reaper.GetProjectPath()
844
+ if proj_path and proj_path ~= "" then
845
+ return "project"
846
+ end
847
+ return "global"
848
+ end
849
+
821
850
  local function ensure_snapshot_dir()
822
851
  reaper.RecursiveCreateDirectory(get_snapshot_dir(), 0)
823
852
  end
@@ -828,9 +857,13 @@ local function snapshot_path(name)
828
857
  return get_snapshot_dir() .. safe .. ".json"
829
858
  end
830
859
 
831
- local function capture_mixer_state()
832
- local state = { tracks = {} }
860
+ local function capture_mixer_state(params)
861
+ params = params or {}
862
+ local include_fx_params = params.includeFxParams ~= false -- default true
863
+ local max_params = params.maxParamsPerFx or 500
864
+ local state = { version = 2, tracks = {} }
833
865
  local count = reaper.CountTracks(0)
866
+
834
867
  for i = 0, count - 1 do
835
868
  local track = reaper.GetTrack(0, i)
836
869
  local _, name = reaper.GetTrackName(track)
@@ -838,22 +871,75 @@ local function capture_mixer_state()
838
871
  local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
839
872
  local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
840
873
  local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
874
+ local color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR")
841
875
 
842
- -- Capture FX bypass states
876
+ -- FX state with full parameter capture
843
877
  local fx_count = reaper.TrackFX_GetCount(track)
844
878
  local fx_states = {}
845
879
  for j = 0, fx_count - 1 do
846
- fx_states[#fx_states + 1] = reaper.TrackFX_GetEnabled(track, j)
880
+ local enabled = reaper.TrackFX_GetEnabled(track, j)
881
+ local offline = reaper.TrackFX_GetOffline(track, j)
882
+ local _, preset = reaper.TrackFX_GetPreset(track, j)
883
+ local _, fx_name = reaper.TrackFX_GetFXName(track, j)
884
+
885
+ local params_data = {}
886
+ if include_fx_params then
887
+ local param_count = reaper.TrackFX_GetNumParams(track, j)
888
+ local limit = math.min(param_count, max_params)
889
+ for p = 0, limit - 1 do
890
+ local val = reaper.TrackFX_GetParam(track, j, p)
891
+ params_data[p + 1] = val
892
+ end
893
+ end
894
+
895
+ fx_states[j + 1] = {
896
+ name = fx_name,
897
+ enabled = enabled,
898
+ offline = offline,
899
+ preset = preset or "",
900
+ params = params_data,
901
+ }
902
+ end
903
+
904
+ -- Send levels (volume, pan, mute for existing sends)
905
+ local send_count = reaper.GetTrackNumSends(track, 0)
906
+ local sends = {}
907
+ for s = 0, send_count - 1 do
908
+ local dest_track = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
909
+ local dest_idx = -1
910
+ local dest_name = ""
911
+ if dest_track then
912
+ dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
913
+ local _, dname = reaper.GetTrackName(dest_track)
914
+ dest_name = dname or ""
915
+ end
916
+ sends[s + 1] = {
917
+ destTrackIndex = dest_idx,
918
+ destTrackName = dest_name,
919
+ volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
920
+ pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
921
+ muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
922
+ }
847
923
  end
848
924
 
849
- state.tracks[#state.tracks + 1] = {
925
+ state.tracks[i + 1] = {
850
926
  index = i,
851
927
  name = name,
928
+ color = color,
852
929
  volume = vol,
853
930
  pan = pan,
854
931
  mute = mute ~= 0,
855
932
  solo = solo ~= 0,
856
- fxEnabled = fx_states,
933
+ fx = fx_states,
934
+ sends = sends,
935
+ -- Legacy compat: flat fxEnabled array (derived from fx_states)
936
+ fxEnabled = (function()
937
+ local arr = {}
938
+ for j = 1, #fx_states do
939
+ arr[j] = fx_states[j].enabled
940
+ end
941
+ return arr
942
+ end)(),
857
943
  }
858
944
  end
859
945
  return state
@@ -872,7 +958,10 @@ function handlers.snapshot_save(params)
872
958
  name = name,
873
959
  description = params.description or "",
874
960
  timestamp = timestamp,
875
- mixerState = capture_mixer_state(),
961
+ mixerState = capture_mixer_state({
962
+ includeFxParams = params.includeFxParams ~= false,
963
+ maxParamsPerFx = params.maxParamsPerFx or 500,
964
+ }),
876
965
  }
877
966
 
878
967
  local path = snapshot_path(name)
@@ -881,7 +970,7 @@ function handlers.snapshot_save(params)
881
970
  return nil, "Failed to write snapshot file: " .. path
882
971
  end
883
972
 
884
- return { success = true, name = name, timestamp = timestamp, path = path }
973
+ return { success = true, name = name, timestamp = timestamp, path = path, storageLocation = get_snapshot_storage_location() }
885
974
  end
886
975
 
887
976
  function handlers.snapshot_restore(params)
@@ -901,30 +990,89 @@ function handlers.snapshot_restore(params)
901
990
  return nil, "Invalid snapshot file for: " .. name
902
991
  end
903
992
 
904
- -- Restore each track state
905
- local restored = 0
906
993
  local state = snapshot.mixerState
994
+ local version = state.version or 1
995
+ local restore_meta = params.restoreTrackMeta == true -- default false
996
+ local restore_sends = params.restoreSendLevels ~= false -- default true
997
+ local warnings = {}
907
998
 
908
999
  reaper.Undo_BeginBlock()
909
1000
 
1001
+ local restored = 0
1002
+
910
1003
  if state.tracks then
911
1004
  for _, track_state in ipairs(state.tracks) do
912
1005
  local track = reaper.GetTrack(0, track_state.index)
913
1006
  if track then
1007
+ -- Basic mixer state (all versions)
914
1008
  reaper.SetMediaTrackInfo_Value(track, "D_VOL", track_state.volume)
915
1009
  reaper.SetMediaTrackInfo_Value(track, "D_PAN", track_state.pan)
916
1010
  reaper.SetMediaTrackInfo_Value(track, "B_MUTE", track_state.mute and 1 or 0)
917
1011
  reaper.SetMediaTrackInfo_Value(track, "I_SOLO", track_state.solo and 1 or 0)
918
1012
 
919
- -- Restore FX bypass states
920
- if track_state.fxEnabled then
1013
+ -- Track metadata (v2, opt-in)
1014
+ if restore_meta and version >= 2 then
1015
+ if track_state.name then
1016
+ reaper.GetSetMediaTrackInfo_String(track, "P_NAME", track_state.name, true)
1017
+ end
1018
+ if track_state.color and track_state.color ~= 0 then
1019
+ reaper.SetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR", track_state.color)
1020
+ end
1021
+ end
1022
+
1023
+ -- FX state (v2: full params; v1: bypass only)
1024
+ if version >= 2 and track_state.fx then
1025
+ for j, fx_state in ipairs(track_state.fx) do
1026
+ local fx_idx = j - 1
1027
+ if fx_idx < reaper.TrackFX_GetCount(track) then
1028
+ -- Restore enabled/offline state
1029
+ reaper.TrackFX_SetEnabled(track, fx_idx, fx_state.enabled)
1030
+ if fx_state.offline ~= nil then
1031
+ reaper.TrackFX_SetOffline(track, fx_idx, fx_state.offline)
1032
+ end
1033
+
1034
+ -- Restore FX parameters only if plugin name matches
1035
+ if fx_state.params and #fx_state.params > 0 then
1036
+ local _, current_name = reaper.TrackFX_GetFXName(track, fx_idx)
1037
+ if current_name == fx_state.name then
1038
+ for p, val in ipairs(fx_state.params) do
1039
+ reaper.TrackFX_SetParam(track, fx_idx, p - 1, val)
1040
+ end
1041
+ else
1042
+ warnings[#warnings + 1] = "Track " .. track_state.index .. " FX " .. fx_idx .. ": name mismatch ('" .. (current_name or "?") .. "' vs '" .. (fx_state.name or "?") .. "'), skipped params"
1043
+ end
1044
+ end
1045
+ end
1046
+ end
1047
+ elseif track_state.fxEnabled then
1048
+ -- Legacy v1: bypass state only
921
1049
  for j, enabled in ipairs(track_state.fxEnabled) do
922
- local fx_idx = j - 1 -- convert to 0-based
1050
+ local fx_idx = j - 1
923
1051
  if fx_idx < reaper.TrackFX_GetCount(track) then
924
1052
  reaper.TrackFX_SetEnabled(track, fx_idx, enabled)
925
1053
  end
926
1054
  end
927
1055
  end
1056
+
1057
+ -- Send levels (v2, default on)
1058
+ if version >= 2 and restore_sends and track_state.sends then
1059
+ local send_count = reaper.GetTrackNumSends(track, 0)
1060
+ for _, saved_send in ipairs(track_state.sends) do
1061
+ for s = 0, send_count - 1 do
1062
+ local dest = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
1063
+ if dest then
1064
+ local dest_idx = reaper.GetMediaTrackInfo_Value(dest, "IP_TRACKNUMBER") - 1
1065
+ if dest_idx == saved_send.destTrackIndex then
1066
+ reaper.SetTrackSendInfo_Value(track, 0, s, "D_VOL", saved_send.volume)
1067
+ reaper.SetTrackSendInfo_Value(track, 0, s, "D_PAN", saved_send.pan)
1068
+ reaper.SetTrackSendInfo_Value(track, 0, s, "B_MUTE", saved_send.muted and 1 or 0)
1069
+ break
1070
+ end
1071
+ end
1072
+ end
1073
+ end
1074
+ end
1075
+
928
1076
  restored = restored + 1
929
1077
  end
930
1078
  end
@@ -935,12 +1083,15 @@ function handlers.snapshot_restore(params)
935
1083
  reaper.TrackList_AdjustWindows(false)
936
1084
  reaper.UpdateArrange()
937
1085
 
938
- return {
1086
+ local result = {
939
1087
  success = true,
940
1088
  name = name,
941
1089
  timestamp = snapshot.timestamp,
942
1090
  tracksRestored = restored,
1091
+ version = version,
943
1092
  }
1093
+ if #warnings > 0 then result.warnings = warnings end
1094
+ return result
944
1095
  end
945
1096
 
946
1097
  function handlers.snapshot_list(params)
@@ -971,7 +1122,7 @@ function handlers.snapshot_list(params)
971
1122
  -- Sort by timestamp descending (newest first)
972
1123
  table.sort(snapshots, function(a, b) return a.timestamp > b.timestamp end)
973
1124
 
974
- return { snapshots = snapshots, total = #snapshots }
1125
+ return { snapshots = snapshots, total = #snapshots, storageLocation = get_snapshot_storage_location() }
975
1126
  end
976
1127
 
977
1128
  -- =============================================================================
@@ -2788,12 +2939,18 @@ local function process_command(filename)
2788
2939
  -- Dispatch to handler
2789
2940
  local handler = handlers[cmd.type]
2790
2941
  local response = {}
2942
+ local handler_ms = 0
2791
2943
 
2792
2944
  if handler then
2945
+ local handler_start = reaper.time_precise()
2793
2946
  local ok, data_or_err, err_msg = pcall(handler, cmd.params or {})
2947
+ handler_ms = (reaper.time_precise() - handler_start) * 1000
2948
+
2794
2949
  if not ok then
2795
2950
  -- pcall caught a Lua runtime error — handler crashed
2796
- response = { id = cmd.id, success = false, error = "Handler error: " .. tostring(data_or_err), timestamp = os.time() * 1000 }
2951
+ local err_str = tostring(data_or_err)
2952
+ log_event("handler_error", { commandType = cmd.type, commandId = cmd.id, error = err_str })
2953
+ response = { id = cmd.id, success = false, error = "Handler error: " .. err_str, timestamp = os.time() * 1000 }
2797
2954
  elseif err_msg then
2798
2955
  -- Handler returned (nil, errorString)
2799
2956
  response = { id = cmd.id, success = false, error = err_msg, timestamp = os.time() * 1000 }
@@ -2802,6 +2959,7 @@ local function process_command(filename)
2802
2959
  response = { id = cmd.id, success = true, data = data_or_err, timestamp = os.time() * 1000 }
2803
2960
  end
2804
2961
  else
2962
+ log_event("handler_unknown", { commandType = cmd.type, commandId = cmd.id })
2805
2963
  response = {
2806
2964
  id = cmd.id,
2807
2965
  success = false,
@@ -2810,6 +2968,9 @@ local function process_command(filename)
2810
2968
  }
2811
2969
  end
2812
2970
 
2971
+ -- Add handler timing to response (rounded to microsecond precision)
2972
+ response._handlerMs = math.floor(handler_ms * 1000 + 0.5) / 1000
2973
+
2813
2974
  -- Add pickup timing to response for profiling
2814
2975
  if cmd.timestamp then
2815
2976
  response._pickupMs = math.floor((pickup_time - (cmd.timestamp / 1000)) * 1000 + 0.5)
@@ -2887,5 +3048,11 @@ reaper.ShowConsoleMsg("MCP Bridge: Polling every " .. (POLL_INTERVAL * 1000) ..
2887
3048
  -- Write initial heartbeat
2888
3049
  write_heartbeat()
2889
3050
 
3051
+ -- Log bridge start event for OTel consumption
3052
+ log_event("bridge_start", { reaperVersion = reaper.GetAppVersion(), bridgeDir = bridge_dir })
3053
+
3054
+ -- Register shutdown handler
3055
+ reaper.atexit(function() log_event("bridge_stop", {}) end)
3056
+
2890
3057
  -- Start the loop
2891
3058
  main_loop()