@mthines/reaper-mcp 0.19.0 → 0.70.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +19 -570
  2. package/bridge-ui/mcp_bridge.html +129 -0
  3. package/bridge-ui/mcp_bridge.webmanifest +16 -0
  4. package/bridge-ui/mcp_bridge_icon_192.png +0 -0
  5. package/bridge-ui/mcp_bridge_icon_512.png +0 -0
  6. package/bridge-ui/mcp_bridge_sw.js +35 -0
  7. package/claude/agents/plugin-researcher.md +188 -0
  8. package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
  9. package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
  10. package/claude/knowledge/reference/lorekit-setup.md +36 -0
  11. package/claude/knowledge/reference/memory-protocol.md +377 -0
  12. package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
  13. package/claude/knowledge/reference/perceived-loudness.md +182 -0
  14. package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
  15. package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
  16. package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
  17. package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
  18. package/claude/knowledge/workflows/section-levels.md +138 -0
  19. package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
  20. package/claude/knowledge/workflows/transient-lighting.md +52 -0
  21. package/claude/skills/critique/SKILL.md +131 -0
  22. package/claude/skills/editing/SKILL.md +281 -0
  23. package/claude/skills/learn-plugin/SKILL.md +210 -0
  24. package/claude/skills/mastering/SKILL.md +151 -0
  25. package/claude/skills/mix-memory/SKILL.md +140 -0
  26. package/claude/skills/mixer/SKILL.md +253 -0
  27. package/claude/skills/new-extension/SKILL.md +324 -0
  28. package/main.js +7843 -1940
  29. package/package.json +17 -15
  30. package/reaper/CLAUDE.md +22 -4
  31. package/reaper/mcp_actions_lib.lua +375 -0
  32. package/reaper/mcp_analyzer.jsfx +27 -12
  33. package/reaper/mcp_arrange_lib.lua +949 -0
  34. package/reaper/mcp_bridge.lua +2321 -758
  35. package/reaper/mcp_edit_views_lib.lua +1378 -0
  36. package/reaper/mcp_envelopes_lib.lua +748 -0
  37. package/reaper/mcp_extensions_lib.lua +55 -0
  38. package/reaper/mcp_fxstate_lib.lua +144 -0
  39. package/reaper/mcp_gain_trace.jsfx +126 -0
  40. package/reaper/mcp_items_lib.lua +877 -0
  41. package/reaper/mcp_lufs_meter.jsfx +16 -13
  42. package/reaper/mcp_midi_lib.lua +555 -0
  43. package/reaper/mcp_mixing_lib.lua +1021 -0
  44. package/reaper/mcp_plan_lib.lua +801 -0
  45. package/reaper/mcp_prefs_lib.lua +127 -0
  46. package/reaper/mcp_project_lib.lua +1088 -0
  47. package/reaper/mcp_recording_lib.lua +595 -0
  48. package/reaper/mcp_render_lib.lua +492 -0
  49. package/reaper/mcp_snapshot_lib.lua +547 -120
  50. package/reaper/mcp_snapshot_next.lua +10 -4
  51. package/reaper/mcp_snapshot_prev.lua +11 -4
  52. package/reaper/mcp_snapshot_quick_save.lua +7 -32
  53. package/reaper/mcp_snapshots_ui_lib.lua +258 -0
  54. package/reaper/mcp_tracks_lib.lua +409 -0
  55. package/sidecar/analysis.py +661 -0
  56. package/sidecar/chords.py +96 -0
  57. package/sidecar/mixcheck.py +295 -0
  58. package/sidecar/requirements-chords.txt +6 -0
  59. package/sidecar/requirements-separation.txt +3 -0
  60. package/sidecar/requirements.txt +17 -0
  61. package/sidecar/separation.py +142 -0
  62. package/sidecar/server.py +622 -0
  63. package/sidecar/tests/test_analysis.py +249 -0
  64. package/sidecar/tests/test_chords.py +117 -0
  65. package/sidecar/tests/test_mixcheck.py +163 -0
  66. package/sidecar/tests/test_separation.py +73 -0
  67. package/sidecar/tests/test_server.py +464 -0
  68. package/sidecar/tests/test_verify.py +243 -0
  69. package/sidecar/tests/test_voice_render.py +268 -0
  70. package/sidecar/tests/test_voice_sketch.py +140 -0
  71. package/sidecar/verify.py +224 -0
  72. package/sidecar/voice_render.py +295 -0
  73. package/sidecar/voice_sketch.py +280 -0
  74. package/claude-agents/critique.md +0 -147
  75. package/claude-agents/editor.md +0 -184
  76. package/claude-agents/levels.md +0 -107
  77. package/claude-agents/mastering.md +0 -129
  78. package/claude-agents/mixer.md +0 -248
  79. package/claude-agents/preflight.md +0 -149
  80. package/claude-agents/producer.md +0 -159
  81. package/claude-agents/setup.md +0 -150
  82. package/claude-agents/stems.md +0 -135
  83. package/claude-rules/architecture.md +0 -39
  84. package/claude-rules/development.md +0 -54
  85. package/claude-rules/lua-bridge.md +0 -50
  86. package/claude-rules/testing.md +0 -42
  87. package/claude-skills/learn-plugin.md +0 -123
  88. package/knowledge/CLAUDE.md +0 -71
  89. package/knowledge/plugins/_template.md +0 -82
  90. package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
  91. package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
  92. package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
  93. package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
  94. package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
  95. package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
  96. package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
  97. package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
  98. package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
  99. package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
  100. package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
  101. package/knowledge/reference/perceived-loudness.md +0 -122
  102. package/reaper/mcp_snapshot_manager.lua +0 -1001
  103. /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
  104. /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
  105. /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
  106. /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
  107. /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
  108. /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
  109. /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
  110. /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
  111. /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
  112. /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
  113. /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
  114. /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
  115. /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
  116. /package/{knowledge → claude/knowledge}/workflows/vocal-chain.md +0 -0
@@ -11,15 +11,21 @@ if #snapshots == 0 then
11
11
  return
12
12
  end
13
13
 
14
- local idx = lib.get_current_index()
14
+ -- Resolve current position by name (stable across add/delete/re-sort).
15
+ local cur = lib.get_current_name()
16
+ local idx = 0
17
+ for i, s in ipairs(snapshots) do
18
+ if s.name == cur then idx = i; break end
19
+ end
15
20
  idx = idx + 1
16
21
  if idx > #snapshots then idx = 1 end
17
22
 
18
23
  local snap = snapshots[idx]
19
- local ok, err = lib.restore_snapshot(snap)
24
+ local ok, err, _, warnings = lib.restore_snapshot(snap)
20
25
  if ok then
21
- lib.set_current_index(idx)
22
- lib.toast("Snapshot " .. idx .. "/" .. #snapshots .. ": " .. snap.name)
26
+ lib.set_current_name(snap.name)
27
+ local w = (warnings and #warnings > 0) and (" [" .. #warnings .. " warning(s)]") or ""
28
+ lib.toast("Snapshot " .. idx .. "/" .. #snapshots .. ": " .. snap.name .. w)
23
29
  else
24
30
  lib.toast("Restore failed: " .. (err or "unknown"))
25
31
  end
@@ -11,15 +11,22 @@ if #snapshots == 0 then
11
11
  return
12
12
  end
13
13
 
14
- local idx = lib.get_current_index()
14
+ -- Resolve current position by name (stable across add/delete/re-sort).
15
+ local cur = lib.get_current_name()
16
+ local idx = 0
17
+ for i, s in ipairs(snapshots) do
18
+ if s.name == cur then idx = i; break end
19
+ end
20
+ -- If not found (idx 0), idx-1 wraps to the last snapshot.
15
21
  idx = idx - 1
16
22
  if idx < 1 then idx = #snapshots end
17
23
 
18
24
  local snap = snapshots[idx]
19
- local ok, err = lib.restore_snapshot(snap)
25
+ local ok, err, _, warnings = lib.restore_snapshot(snap)
20
26
  if ok then
21
- lib.set_current_index(idx)
22
- lib.toast("Snapshot " .. idx .. "/" .. #snapshots .. ": " .. snap.name)
27
+ lib.set_current_name(snap.name)
28
+ local w = (warnings and #warnings > 0) and (" [" .. #warnings .. " warning(s)]") or ""
29
+ lib.toast("Snapshot " .. idx .. "/" .. #snapshots .. ": " .. snap.name .. w)
23
30
  else
24
31
  lib.toast("Restore failed: " .. (err or "unknown"))
25
32
  end
@@ -1,41 +1,16 @@
1
1
  -- MCP Snapshot: Quick Save — save a snapshot with auto-generated name, no dialog
2
- -- Bind to a key for rapid iteration. Names: "Snapshot 1", "Snapshot 2", etc.
2
+ -- Bind to a key for rapid iteration. Named by the local time, e.g. "2026-10-30 14:32:05".
3
3
 
4
4
  local info = debug.getinfo(1, "S")
5
5
  local script_dir = info.source:match("@?(.*[\\/])")
6
6
  local lib = dofile(script_dir .. "mcp_snapshot_lib.lua")
7
7
 
8
- local snapshots = lib.load_snapshots()
9
-
10
- -- Find next available "Snapshot N" index
11
- local next_idx = #snapshots + 1
12
- for _, s in ipairs(snapshots) do
13
- local num = s.name:match("^Snapshot (%d+)$")
14
- if num then next_idx = math.max(next_idx, tonumber(num) + 1) end
15
- end
16
-
17
- local name = "Snapshot " .. next_idx
18
- lib.ensure_snapshot_dir()
19
-
20
- local snapshot = {
21
- name = name,
22
- description = "",
23
- timestamp = os.time() * 1000,
24
- mixerState = lib.capture_mixer_state(),
25
- }
26
-
27
- local path = lib.snapshot_path(name)
28
- local ok = lib.write_file(path, lib.json_encode(snapshot))
8
+ local name = lib.time_name()
9
+ local ok, err, track_count = lib.save_snapshot(name, "")
29
10
  if ok then
30
- -- Set current index to the new snapshot so next/prev starts from here
31
- local updated = lib.load_snapshots()
32
- for i, s in ipairs(updated) do
33
- if s.name == name then
34
- lib.set_current_index(i)
35
- break
36
- end
37
- end
38
- lib.toast("Saved: " .. name .. " (" .. #snapshot.mixerState.tracks .. " tracks)")
11
+ -- Make next/prev A/B start from the snapshot we just saved.
12
+ lib.set_current_name(name)
13
+ lib.toast("Saved: " .. name .. " (" .. (track_count or "?") .. " tracks)")
39
14
  else
40
- lib.toast("Save failed: " .. path)
15
+ lib.toast("Save failed: " .. (err or name))
41
16
  end
@@ -0,0 +1,258 @@
1
+ -- =============================================================================
2
+ -- Snapshots on the status page (apps/bridge-ui, Snapshots view)
3
+ -- =============================================================================
4
+ -- The page lists the project's mixer snapshots, restores one with a click (A/B
5
+ -- while listening) and saves a new one, through REAPER's web interface ExtState:
6
+ --
7
+ -- snap_seen = <page clock ms> The page pings this on every poll while the view is open.
8
+ -- While it keeps changing, the folder is re-read about once a
9
+ -- second; ExtState `snapshots` is rewritten only when the list
10
+ -- (or the current snapshot) changed.
11
+ -- snap_req = <id>-<token>-<op>-<hex name>[-<hex field>…]
12
+ -- op: restore | delete <name>, save <name>-<description>,
13
+ -- edit <name>-<new name>-<description>, clear (any name: deletes every snapshot
14
+ -- of the project, one snapshot_delete each). The token is the bridge's per-run control
15
+ -- token, only readable from the status feed (REAPER's web server
16
+ -- sends no CORS headers), so another site can't make it count.
17
+ -- Name and description travel hex-encoded: no URL or `;` escaping.
18
+ --
19
+ -- A request runs as an ordinary snapshot_restore / _save / _delete / _edit command through the
20
+ -- bridge's dispatcher (ctx.run): logged on the page, refused in read-only or paused
21
+ -- mode, one undo point per restore. The answer is published in `snapshots.result`.
22
+ --
23
+ -- It also registers the keybindable snapshot scripts as actions (M.register_actions).
24
+ --
25
+ -- Loaded by mcp_bridge.lua (keeps the bridge under Lua's 200-locals limit).
26
+ -- =============================================================================
27
+
28
+ local M = {}
29
+
30
+ local ctx
31
+ local cache = {} -- path -> { size, entry }
32
+ local seen_value, seen_at = "", -math.huge
33
+ local last_scan = -math.huge
34
+ local last_sig
35
+ local result -- the answer to the page's latest request
36
+ local WATCH_S = 5 -- keep watching the folder this long after the page's last ping
37
+ local SCAN_S = 1 -- re-read the folder at most this often while watched
38
+ local NAME_MAX, DESC_MAX, LIST_MAX = 128, 500, 200
39
+
40
+ -- Persisted by the snapshot action scripts too (mcp_snapshot_lib.lua): next/prev continue from here
41
+ local CURRENT_SECTION, CURRENT_KEY = "MCP_Snapshots", "current_name"
42
+
43
+ -- ctx: api (reaper), section (ExtState section), dir() and storage() (where snapshots live),
44
+ -- decode (full JSON decoder), encode (JSON encoder), read_file, token() (control token),
45
+ -- run(cmd) -> response (the bridge's dispatcher, returns { success, data, error }).
46
+ function M.init(c)
47
+ ctx = c
48
+ end
49
+
50
+ -- The keybindable snapshot scripts setup copies next to the bridge. `reaper-mcp setup` lists them in
51
+ -- reaper-kb.ini while REAPER is closed (ensureSnapshotActions() in cli.ts); when REAPER was open, the
52
+ -- bridge adds the missing ones when it starts. Returns the scripts it added.
53
+ M.ACTION_SCRIPTS = { "mcp_snapshot_quick_save.lua", "mcp_snapshot_next.lua", "mcp_snapshot_prev.lua" }
54
+
55
+ function M.register_actions(api, scripts_dir, kb_text)
56
+ local added = {}
57
+ if type(api.AddRemoveReaScript) ~= "function" then return added end
58
+ local listed = {}
59
+ for line in (kb_text or ""):gmatch("[^\r\n]+") do
60
+ if line:sub(1, 4) == "SCR " then
61
+ for _, f in ipairs(M.ACTION_SCRIPTS) do
62
+ if line:find(f, 1, true) then listed[f] = true end
63
+ end
64
+ end
65
+ end
66
+ for _, f in ipairs(M.ACTION_SCRIPTS) do
67
+ local path = scripts_dir .. f
68
+ local fh = not listed[f] and io.open(path, "rb")
69
+ if fh then
70
+ fh:close()
71
+ if (api.AddRemoveReaScript(true, 0, path, true) or 0) > 0 then added[#added + 1] = f end
72
+ end
73
+ end
74
+ return added
75
+ end
76
+
77
+ local function file_size(path)
78
+ local f = io.open(path, "rb")
79
+ if not f then return nil end
80
+ local size = f:seek("end")
81
+ f:close()
82
+ return size
83
+ end
84
+
85
+ -- Metadata of one snapshot file, wherever its keys sit: files written by the bridge before
86
+ -- metadata-first saves have description after the (large) mixerState.
87
+ local function read_entry(path)
88
+ local content = ctx.read_file(path)
89
+ if not content then return nil end
90
+ local ok, snap = pcall(ctx.decode, content)
91
+ if not ok or type(snap) ~= "table" or type(snap.name) ~= "string" or snap.name == "" then return nil end
92
+ local state = type(snap.mixerState) == "table" and snap.mixerState or {}
93
+ local tracks = type(state.tracks) == "table" and #state.tracks or tonumber(snap.trackCount)
94
+ local desc = type(snap.description) == "string" and snap.description or ""
95
+ return {
96
+ name = snap.name,
97
+ description = desc:sub(1, DESC_MAX),
98
+ timestamp = tonumber(snap.timestamp) or 0,
99
+ trackCount = tracks,
100
+ scoped = state.scoped == true or snap.scoped == true,
101
+ fullChunk = state.fullChunk == true or snap.fullChunk == true,
102
+ }
103
+ end
104
+
105
+ -- Every snapshot in the folder, newest first. A file is read again only when its size changed.
106
+ function M.list()
107
+ local api, dir = ctx.api, ctx.dir()
108
+ api.EnumerateFiles(dir, -1) -- REAPER caches directory listings; -1 re-reads this one
109
+ local list, present = {}, {}
110
+ local i = 0
111
+ while true do
112
+ local fn = api.EnumerateFiles(dir, i)
113
+ if not fn then break end
114
+ if fn:match("%.json$") then
115
+ local path = dir .. fn
116
+ local size = file_size(path)
117
+ local c = cache[path]
118
+ if not c or c.size ~= size then
119
+ c = { size = size, entry = read_entry(path) }
120
+ cache[path] = c
121
+ end
122
+ present[path] = true
123
+ if c.entry then list[#list + 1] = c.entry end
124
+ end
125
+ i = i + 1
126
+ end
127
+ for path in pairs(cache) do
128
+ if not present[path] then cache[path] = nil end
129
+ end
130
+ table.sort(list, function(a, b)
131
+ if a.timestamp == b.timestamp then return a.name < b.name end
132
+ return a.timestamp > b.timestamp
133
+ end)
134
+ for k = LIST_MAX + 1, #list do list[k] = nil end
135
+ return list
136
+ end
137
+
138
+ local function current_name()
139
+ local v = ctx.api.GetExtState(CURRENT_SECTION, CURRENT_KEY)
140
+ return v ~= "" and v or nil
141
+ end
142
+
143
+ -- A restore from anywhere (page, agent) becomes the current snapshot, as the next/prev actions do
144
+ function M.after_command(cmd, response)
145
+ if cmd.type == "snapshot_restore" and response.success and type(cmd.params) == "table" and type(cmd.params.name) == "string" then
146
+ ctx.api.SetExtState(CURRENT_SECTION, CURRENT_KEY, cmd.params.name, false)
147
+ end
148
+ -- A save (page or agent) can replace a file with one of the same size: read that file again.
149
+ -- Deleted files drop out of the cache on the next list().
150
+ local data = type(response.data) == "table" and response.data or {}
151
+ if (cmd.type == "snapshot_save" or cmd.type == "snapshot_edit") and type(data.path) == "string" then
152
+ cache[data.path] = nil
153
+ end
154
+ if cmd.type == "snapshot_save" or cmd.type == "snapshot_delete" or cmd.type == "snapshot_restore" or cmd.type == "snapshot_edit" then
155
+ last_scan = -math.huge -- re-read on the next watched tick
156
+ end
157
+ end
158
+
159
+ local function unhex(s)
160
+ return (s:gsub("%x%x", function(h) return string.char(tonumber(h, 16)) end))
161
+ end
162
+
163
+ -- Runs one page request; returns true when it was valid (and ran)
164
+ local function handle(req)
165
+ local id, token, op, rest = req:match("^(%d+)%-(%x+)%-(%a+)%-([%x%-]*)$")
166
+ if not id or token ~= ctx.token() then return false end
167
+ local fields = {}
168
+ for h in (rest .. "-"):gmatch("(%x*)%-") do
169
+ if #h % 2 ~= 0 then return false end
170
+ fields[#fields + 1] = unhex(h)
171
+ end
172
+ local name = (fields[1] or ""):sub(1, NAME_MAX)
173
+ if name == "" then return false end
174
+ local cmd = { id = "page-" .. id, source = "page" }
175
+ if op == "clear" then
176
+ -- Every snapshot, one ordinary snapshot_delete each (logged, refused while read-only or paused).
177
+ -- list() is capped, so go again until a round deletes nothing.
178
+ local deleted, failed, first_error = 0, 0, nil
179
+ local tried = {}
180
+ while true do
181
+ local progressed = false
182
+ for _, e in ipairs(M.list()) do
183
+ if not tried[e.name] then
184
+ tried[e.name] = true
185
+ progressed = true
186
+ local r = ctx.run({ id = "page-" .. id .. "-" .. (deleted + failed), source = "page", type = "snapshot_delete", params = { name = e.name } }) or {}
187
+ if r.success then deleted = deleted + 1 else failed = failed + 1; first_error = first_error or r.error end
188
+ end
189
+ end
190
+ if not progressed then break end
191
+ end
192
+ result = {
193
+ id = id, op = op, name = deleted .. (deleted == 1 and " snapshot" or " snapshots"),
194
+ ok = failed == 0, error = failed > 0 and (failed .. " not deleted: " .. tostring(first_error)) or nil,
195
+ }
196
+ return true
197
+ end
198
+ if op == "restore" or op == "delete" then
199
+ cmd.type, cmd.params = "snapshot_" .. op, { name = name }
200
+ elseif op == "save" then
201
+ cmd.type, cmd.params = "snapshot_save", { name = name, description = (fields[2] or ""):sub(1, DESC_MAX) }
202
+ elseif op == "edit" and fields[2] and fields[2] ~= "" then
203
+ cmd.type = "snapshot_edit"
204
+ cmd.params = { name = name, newName = fields[2]:sub(1, NAME_MAX), description = (fields[3] or ""):sub(1, DESC_MAX) }
205
+ else
206
+ return false
207
+ end
208
+ local response = ctx.run(cmd) or {}
209
+ local data = type(response.data) == "table" and response.data or {}
210
+ result = {
211
+ id = id, op = op, name = op == "edit" and cmd.params.newName or name, ok = response.success == true,
212
+ error = response.error, warnings = data.warnings, tracksRestored = data.tracksRestored,
213
+ }
214
+ return true
215
+ end
216
+
217
+ local function signature(storage, current, list)
218
+ local parts = { storage, current or "" }
219
+ for _, e in ipairs(list) do
220
+ parts[#parts + 1] = table.concat({ e.name, e.description, tostring(e.timestamp), tostring(e.trackCount),
221
+ tostring(e.scoped), tostring(e.fullChunk) }, "\1")
222
+ end
223
+ if result then parts[#parts + 1] = "result:" .. result.id end
224
+ return table.concat(parts, "\2")
225
+ end
226
+
227
+ local function publish(list)
228
+ local storage, current = ctx.storage(), current_name()
229
+ local sig = signature(storage, current, list)
230
+ if sig == last_sig then return end
231
+ last_sig = sig
232
+ ctx.api.SetExtState(ctx.section, "snapshots", ctx.encode({
233
+ v = 1, storage = storage, current = current, snapshots = list, result = result,
234
+ }), false)
235
+ end
236
+
237
+ -- Called every defer tick. now: seconds (reaper.time_precise)
238
+ function M.poll(now)
239
+ local api, section = ctx.api, ctx.section
240
+ local req = api.GetExtState(section, "snap_req")
241
+ local handled = false
242
+ if req ~= "" then
243
+ api.DeleteExtState(section, "snap_req", false)
244
+ handled = handle(req)
245
+ end
246
+ local ping = api.GetExtState(section, "snap_seen")
247
+ if ping ~= "" and ping ~= seen_value then
248
+ -- a fresh ping after a pause: read the folder now
249
+ if now - seen_at >= WATCH_S then last_scan = -math.huge end
250
+ seen_value, seen_at = ping, now
251
+ end
252
+ local watched = now - seen_at < WATCH_S
253
+ if not handled and not (watched and now - last_scan >= SCAN_S) then return end
254
+ last_scan = now
255
+ publish(M.list())
256
+ end
257
+
258
+ return M