livepilot 1.27.1 → 1.27.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.27.2 — 2026-06-23
4
+
5
+ Maintenance release: M4L bridge response-correlation + chunk-reassembly hardening, plan-routing fixes, a Python 3.11 floor with clearer install diagnostics, and a Remote Script Group-track crash guard. No change to the tool surface (467 tools / 56 domains). Re-frozen LivePilot_Analyzer.amxd (analyzer reports 1.27.2).
6
+
7
+ ### Fixed — M4L bridge
8
+ - Correlate responses by request id across batched reads (`get_params` / `get_hidden_params` / `get_auto_state`): the id is captured per command in the analyzer JS and echoed on both single-packet and chunked responses, so an interleaved or timed-out command can no longer resolve another command's future.
9
+ - Chunk reassembly buckets per request id, bounds-checks the chunk index, and requires every index present before reassembling — fixes a `KeyError` / silent-loss + full-timeout path on duplicate or out-of-range chunks.
10
+ - Non-blocking UDP socket so the miditool response path cannot stall the asyncio event loop; clear the capture future after completion.
11
+
12
+ ### Fixed — routing & Remote Script
13
+ - `compressor_set_sidechain` now classifies as an MCP tool (TCP Remote Script path) instead of being mis-routed to the M4L JS bridge; `get_master_rms` is now dispatchable in plans; removed the dead duplicate `get_simpler_file_path` bridge entry.
14
+ - `get_track_info` no longer crashes on Group/Return tracks (guards the LOM-fragile arm/input properties).
15
+ - `reload_handlers` reports per-module reload errors instead of silently swallowing them.
16
+ - Connection retry tears down the stale socket under the lock.
17
+
18
+ ### Changed — install & docs
19
+ - Raised the Python floor to 3.11 (numpy/scipy publish no wheels for 3.9/3.10) with a clear pre-flight message; pip failures surface captured output + a version hint, and the expected grpcio-tools resolver warning is pre-announced.
20
+ - `consult_ableton_knowledge` docstrings corrected; fast brief now notes the `mcp__Ableton_Knowledge__` prefix and that the knowledge MCP is optional; fixed an over-permissive tool-name test.
21
+
22
+
3
23
  ## v1.27.1 — 2026-06-21
4
24
 
5
25
  Maintenance release: 35 verified fixes from a deep multi-agent audit, recursive installed-plugin scanning, and Windows-CI hardening. No change to the tool surface (467 tools / 56 domains).
package/README.md CHANGED
@@ -751,7 +751,7 @@ npx livepilot --version # Show version
751
751
  | Requirement | Minimum |
752
752
  |-------------|---------|
753
753
  | Ableton Live | **12** (any edition). Suite required for Max for Live bridge and stock instruments |
754
- | Python | 3.9+ |
754
+ | Python | 3.11+ (numpy/scipy ship no wheels for 3.9/3.10) |
755
755
  | Node.js | 18+ |
756
756
  | OS | macOS / Windows |
757
757
  | Splice | Desktop app with downloaded samples (optional — enables SQLite metadata search) |
package/bin/livepilot.js CHANGED
@@ -15,6 +15,13 @@ const REQUIREMENTS = path.join(ROOT, "requirements.txt");
15
15
  // Python detection
16
16
  // ---------------------------------------------------------------------------
17
17
 
18
+ // Minimum Python is 3.11, NOT 3.9: numpy>=2.4.6 and scipy>=1.17.1 (see
19
+ // requirements.txt) publish no wheels and declare requires-python ">=3.11".
20
+ // A 3.9/3.10 interpreter passes a looser gate, the venv is created, then
21
+ // `pip install` aborts with a cryptic "no matching distribution" — the exact
22
+ // install failure users hit. Gate here so we fail early with a clear message.
23
+ const MIN_PY_MINOR = 11;
24
+
18
25
  function findPython() {
19
26
  // On Windows, also try the "py -3" launcher which avoids the
20
27
  // Microsoft Store stub that "python3" resolves to.
@@ -22,6 +29,7 @@ function findPython() {
22
29
  ? ["python", "python3", "py"]
23
30
  : ["python3", "python"];
24
31
 
32
+ let tooOld = null; // highest 3.x below the floor we saw, for a clear error
25
33
  for (const cmd of candidates) {
26
34
  try {
27
35
  const args = cmd === "py" ? ["-3", "--version"] : ["--version"];
@@ -33,17 +41,26 @@ function findPython() {
33
41
  if (match) {
34
42
  const major = parseInt(match[1], 10);
35
43
  const minor = parseInt(match[2], 10);
36
- if (major === 3 && minor >= 9) {
44
+ if (major === 3 && minor >= MIN_PY_MINOR) {
37
45
  // For "py" launcher, the actual command to use is "py -3"
38
46
  const actualCmd = cmd === "py" ? "py" : cmd;
39
47
  const actualArgs = cmd === "py" ? ["-3"] : [];
40
48
  return { cmd: actualCmd, version: out, prefixArgs: actualArgs };
41
49
  }
50
+ // Found Python 3 but below 3.11 — remember the newest for diagnostics.
51
+ if (major === 3 && (!tooOld || minor > tooOld.minor)) {
52
+ tooOld = { version: out, minor };
53
+ }
42
54
  }
43
55
  } catch {
44
56
  // command not found or failed — try next
45
57
  }
46
58
  }
59
+ // Signal "present but too old" distinctly from "absent" so callers can give
60
+ // an actionable message instead of a misleading "not found".
61
+ if (tooOld) {
62
+ return { tooOld: true, version: tooOld.version };
63
+ }
47
64
  return null;
48
65
  }
49
66
 
@@ -84,6 +101,41 @@ function findOtherLiveClient(host, port) {
84
101
  // Virtual environment bootstrap
85
102
  // ---------------------------------------------------------------------------
86
103
 
104
+ /**
105
+ * Run `pip install -r requirements.txt` with captured stderr so a failure
106
+ * yields an actionable message instead of an opaque "Command failed".
107
+ */
108
+ function pipInstall(venvPy) {
109
+ // One pip resolver warning about grpcio-tools / protobuf<7 is EXPECTED and
110
+ // harmless: LivePilot imports only the pre-generated Splice stubs at runtime,
111
+ // never grpcio-tools. Pre-announce it so it doesn't read as a failure.
112
+ console.error(" (a single grpcio-tools/protobuf resolver warning is expected and safe)");
113
+ try {
114
+ execFileSync(venvPy, ["-m", "pip", "install", "-q", "-r", REQUIREMENTS], {
115
+ cwd: ROOT,
116
+ stdio: ["pipe", "pipe", "pipe"],
117
+ encoding: "utf-8",
118
+ timeout: 120000,
119
+ });
120
+ } catch (err) {
121
+ const stderr = (err && err.stderr ? String(err.stderr) : "").trim();
122
+ console.error("");
123
+ console.error("LivePilot: dependency installation failed.");
124
+ if (/No matching distribution|requires-python|Could not find a version/i.test(stderr)) {
125
+ console.error(" Most likely your Python is too old — LivePilot needs Python >= 3.11");
126
+ console.error(" (numpy/scipy ship no wheels for 3.9/3.10). Install 3.11+, delete the");
127
+ console.error(" .venv folder, and retry.");
128
+ }
129
+ const tail = stderr.split("\n").filter(Boolean).slice(-12);
130
+ if (tail.length) {
131
+ console.error("");
132
+ console.error(" pip output (last lines):");
133
+ for (const line of tail) console.error(" " + line);
134
+ }
135
+ throw err;
136
+ }
137
+ }
138
+
87
139
  /**
88
140
  * Ensure a local .venv exists with dependencies installed.
89
141
  * Returns the path to the venv Python binary.
@@ -104,11 +156,7 @@ function ensureVenv(systemPython, prefixArgs) {
104
156
  } catch {
105
157
  // venv exists but deps missing — reinstall
106
158
  console.error("LivePilot: reinstalling Python dependencies...");
107
- execFileSync(venvPy, ["-m", "pip", "install", "-q", "-r", REQUIREMENTS], {
108
- cwd: ROOT,
109
- stdio: ["pipe", "pipe", "inherit"],
110
- timeout: 120000,
111
- });
159
+ pipInstall(venvPy);
112
160
  return venvPy;
113
161
  }
114
162
  }
@@ -122,11 +170,7 @@ function ensureVenv(systemPython, prefixArgs) {
122
170
  });
123
171
 
124
172
  console.error("LivePilot: installing dependencies...");
125
- execFileSync(venvPython(), ["-m", "pip", "install", "-q", "-r", REQUIREMENTS], {
126
- cwd: ROOT,
127
- stdio: ["pipe", "pipe", "inherit"],
128
- timeout: 120000,
129
- });
173
+ pipInstall(venvPython());
130
174
 
131
175
  return venvPython();
132
176
  }
@@ -262,11 +306,15 @@ async function doctor() {
262
306
 
263
307
  // 1. Python
264
308
  const pyInfo = findPython();
265
- if (pyInfo) {
309
+ if (pyInfo && !pyInfo.tooOld) {
266
310
  console.log(" Python: %s (%s)", pyInfo.version, pyInfo.cmd);
311
+ } else if (pyInfo && pyInfo.tooOld) {
312
+ console.log(" Python: %s found, but LivePilot needs >= 3.11", pyInfo.version);
313
+ console.log(" Fix: install Python 3.11+ (numpy/scipy ship no wheels for 3.9/3.10)");
314
+ ok = false;
267
315
  } else {
268
- console.log(" Python: NOT FOUND (need >= 3.9)");
269
- console.log(" Fix: install Python 3.9+ and add to PATH");
316
+ console.log(" Python: NOT FOUND (need >= 3.11)");
317
+ console.log(" Fix: install Python 3.11+ and add to PATH");
270
318
  ok = false;
271
319
  }
272
320
 
@@ -686,10 +734,15 @@ async function setup() {
686
734
  // 1. Python
687
735
  console.log("Step 1/5: Checking Python...");
688
736
  const pyInfo = findPython();
689
- if (pyInfo) {
737
+ if (pyInfo && !pyInfo.tooOld) {
690
738
  console.log(" ✓ %s", pyInfo.version);
739
+ } else if (pyInfo && pyInfo.tooOld) {
740
+ console.log(" ✗ %s found, but LivePilot needs Python >= 3.11", pyInfo.version);
741
+ console.log(" (numpy/scipy ship no wheels for 3.9/3.10 — pip would fail)");
742
+ console.log(" Install: brew install python@3.12 (macOS) or python.org (Windows)");
743
+ ok = false;
691
744
  } else {
692
- console.log(" ✗ Python >= 3.9 not found");
745
+ console.log(" ✗ Python >= 3.11 not found");
693
746
  console.log(" Install: brew install python@3.12 (macOS) or python.org (Windows)");
694
747
  ok = false;
695
748
  }
@@ -722,7 +775,7 @@ async function setup() {
722
775
  // 3. Bootstrap Python venv
723
776
  console.log("");
724
777
  console.log("Step 3/5: Setting up Python environment...");
725
- if (pyInfo) {
778
+ if (pyInfo && !pyInfo.tooOld) {
726
779
  try {
727
780
  ensureVenv(pyInfo.cmd, pyInfo.prefixArgs);
728
781
  console.log(" ✓ Virtual environment ready");
@@ -730,6 +783,8 @@ async function setup() {
730
783
  console.log(" ✗ Failed: %s", err.message);
731
784
  ok = false;
732
785
  }
786
+ } else if (pyInfo && pyInfo.tooOld) {
787
+ console.log(" ⊘ Skipped (%s found, need Python >= 3.11)", pyInfo.version);
733
788
  } else {
734
789
  console.log(" ⊘ Skipped (no Python)");
735
790
  }
@@ -917,12 +972,18 @@ async function main() {
917
972
 
918
973
  // Default: start MCP server
919
974
  const pyInfo = findPython();
920
- if (!pyInfo) {
921
- console.error("Error: Python >= 3.9 is required but was not found.");
975
+ if (!pyInfo || pyInfo.tooOld) {
976
+ if (pyInfo && pyInfo.tooOld) {
977
+ console.error("Error: found %s, but LivePilot requires Python >= 3.11.", pyInfo.version);
978
+ console.error(" numpy>=2.4.6 and scipy>=1.17.1 publish no wheels for Python 3.9/3.10,");
979
+ console.error(" so dependency installation would fail. Install Python 3.11 or newer.");
980
+ } else {
981
+ console.error("Error: Python >= 3.11 is required but was not found.");
982
+ console.error(" Install Python 3.11+ and ensure 'python3' or 'python' is on your PATH.");
983
+ }
922
984
  console.error("");
923
- console.error("Install Python 3.9+ and ensure 'python3' or 'python' is on your PATH.");
924
985
  console.error(" macOS: brew install python@3.12");
925
- console.error(" Ubuntu: sudo apt install python3");
986
+ console.error(" Ubuntu: sudo apt install python3.12");
926
987
  console.error(" Windows: https://www.python.org/downloads/");
927
988
  process.exit(1);
928
989
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.27.1",
3
+ "version": "1.27.2",
4
4
  "description": "Agentic production system for Ableton Live 12 \u2014 467 tools, 56 domains, 44 semantic moves, device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
5
5
  "author": {
6
6
  "name": "Pilot Studio"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.27.1",
3
+ "version": "1.27.2",
4
4
  "description": "Agentic production system for Ableton Live 12 \u2014 467 tools, 56 domains, 44 semantic moves, device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
5
5
  "author": {
6
6
  "name": "Pilot Studio"
@@ -1,4 +1,4 @@
1
- # LivePilot v1.27.1 — Architecture & Tool Reference
1
+ # LivePilot v1.27.2 — Architecture & Tool Reference
2
2
 
3
3
  Agentic production system for Ableton Live 12. 467 tools across 56 domains. Device atlas (5264 devices, 120 enriched, 47 with aesthetic-tagged `signature_techniques`), spectral perception (M4L analyzer with 9-band FFT — sub_low / sub / low / low_mid / mid / high_mid / high / presence / air), technique memory, automation intelligence (16 curve types, 15 recipes), music theory (Krumhansl-Schmuckler, species counterpoint), generative algorithms (Euclidean rhythm, tintinnabuli, phase shift, additive process), neo-Riemannian harmony (PRL transforms, Tonnetz), MIDI file I/O, **LIVE Splice describe-a-sound + variations via captured GraphQL endpoints (v1.17)**, drum-rack pad-by-pad construction, live dead-device detection via meter sampling, role-aware Simpler defaults, session-record arrangement-automation workaround.
4
4
 
@@ -173,7 +173,7 @@ Live 12.4 introduces a new capability tier that unlocks native LOM access for sa
173
173
  **Tool signatures:** unchanged. Callers do not need to detect the tier —
174
174
  routing is transparent.
175
175
 
176
- **Probe-first 12.4 surfaces in v1.27.1:**
176
+ **Probe-first 12.4 surfaces in v1.27.2:**
177
177
  - `probe_link_audio()` reports observed peer/input/routing visibility and
178
178
  returns `manual_only`, `readable`, `routable`, or `unavailable`.
179
179
  - `probe_stem_workflow()` reports observed callable paths and returns
Binary file
@@ -34,7 +34,7 @@ outlets = 2; // 0: to udpsend (responses), 1: to buffer~/status
34
34
  // Single source of truth for the bridge version — bumped alongside the
35
35
  // rest of the release manifest. Surfaced in the UI via messnamed("livepilot_version", ...)
36
36
  // so the frozen .amxd visibly reports which build it was last exported from.
37
- var VERSION = "1.27.1";
37
+ var VERSION = "1.27.2";
38
38
 
39
39
  // ── State ──────────────────────────────────────────────────────────────────
40
40
 
@@ -45,6 +45,7 @@ var pitch_history = []; // Rolling buffer for key detection
45
45
  var MAX_PITCH_HISTORY = 128;
46
46
  var detected_key = "";
47
47
  var detected_scale = "";
48
+ var current_response_request_id = "";
48
49
 
49
50
  // Capture state
50
51
  var capture_active = false;
@@ -85,9 +86,19 @@ function anything() {
85
86
  var cmd = messagename;
86
87
  if (cmd.charAt(0) === "/") cmd = cmd.substring(1);
87
88
  var args = _decode_arg_strings(arrayfromargs(arguments));
89
+ var request_id = "";
90
+ if (args.length > 0 && typeof args[args.length - 1] === "string") {
91
+ var last_arg = args[args.length - 1];
92
+ var request_prefix = "__livepilot_request_id:";
93
+ if (last_arg.indexOf(request_prefix) === 0) {
94
+ request_id = last_arg.substring(request_prefix.length);
95
+ args.pop();
96
+ }
97
+ }
88
98
 
89
99
  // Defer to low-priority thread for LiveAPI safety
90
100
  var task = new Task(function() {
101
+ current_response_request_id = request_id;
91
102
  try {
92
103
  dispatch(cmd, args);
93
104
  } catch(e) {
@@ -223,6 +234,11 @@ function cmd_get_params(args) {
223
234
 
224
235
  var batch_size = 8;
225
236
  var current = 0;
237
+ // Capture the request id now: read_batch reschedules itself via
238
+ // Task.schedule(20), and an interleaved command arriving in that gap can
239
+ // overwrite the module-level current_response_request_id before the final
240
+ // send_response fires. The closure keeps OUR id stable across batches.
241
+ var captured_request_id = current_response_request_id;
226
242
 
227
243
  function read_batch() {
228
244
  try {
@@ -246,7 +262,7 @@ function cmd_get_params(args) {
246
262
  var next_task = new Task(read_batch);
247
263
  next_task.schedule(20);
248
264
  } else {
249
- send_response({"track": track_idx, "device": device_idx, "params": params});
265
+ send_response({"track": track_idx, "device": device_idx, "params": params}, captured_request_id);
250
266
  }
251
267
  } catch (e) {
252
268
  send_response({
@@ -254,7 +270,7 @@ function cmd_get_params(args) {
254
270
  "track": track_idx,
255
271
  "device": device_idx,
256
272
  "partial_params": params
257
- });
273
+ }, captured_request_id);
258
274
  }
259
275
  }
260
276
 
@@ -275,6 +291,9 @@ function cmd_get_hidden_params(args) {
275
291
  var params = [];
276
292
  var current = 0;
277
293
  var batch_size = 8;
294
+ // See cmd_get_params: capture the request id so the closure survives the
295
+ // Task.schedule(20) gaps without being clobbered by an interleaved command.
296
+ var captured_request_id = current_response_request_id;
278
297
 
279
298
  function read_batch() {
280
299
  try {
@@ -307,7 +326,7 @@ function cmd_get_hidden_params(args) {
307
326
  "device_name": device_name,
308
327
  "total_params": param_count,
309
328
  "params": params
310
- });
329
+ }, captured_request_id);
311
330
  }
312
331
  } catch (e) {
313
332
  send_response({
@@ -316,7 +335,7 @@ function cmd_get_hidden_params(args) {
316
335
  "device": device_idx,
317
336
  "device_name": device_name,
318
337
  "partial_params": params
319
- });
338
+ }, captured_request_id);
320
339
  }
321
340
  }
322
341
 
@@ -334,6 +353,9 @@ function cmd_get_auto_state(args) {
334
353
  var results = [];
335
354
  var current = 0;
336
355
  var batch_size = 8;
356
+ // See cmd_get_params: capture the request id so the closure survives the
357
+ // Task.schedule(20) gaps without being clobbered by an interleaved command.
358
+ var captured_request_id = current_response_request_id;
337
359
 
338
360
  function read_batch() {
339
361
  try {
@@ -362,7 +384,7 @@ function cmd_get_auto_state(args) {
362
384
  "total_params": param_count,
363
385
  "automated_params": results,
364
386
  "automated_count": results.length
365
- });
387
+ }, captured_request_id);
366
388
  }
367
389
  } catch (e) {
368
390
  send_response({
@@ -372,7 +394,7 @@ function cmd_get_auto_state(args) {
372
394
  "total_params": param_count,
373
395
  "automated_params": results,
374
396
  "automated_count": results.length
375
- });
397
+ }, captured_request_id);
376
398
  }
377
399
  }
378
400
 
@@ -689,22 +711,37 @@ function correlate(a, b) {
689
711
 
690
712
  // ── Response Encoding ──────────────────────────────────────────────────────
691
713
 
692
- function send_response(obj) {
693
- var json = JSON.stringify(obj);
714
+ function send_response(obj, explicit_id) {
715
+ var response = obj || {};
716
+ // Async/batched handlers (cmd_get_params et al.) capture the request id in
717
+ // a closure and pass it explicitly here, because the module-level
718
+ // current_response_request_id can be clobbered by an interleaved command
719
+ // that arrives during a Task.schedule() gap between batches.
720
+ var rid = (typeof explicit_id === "string" && explicit_id !== "")
721
+ ? explicit_id
722
+ : current_response_request_id;
723
+ if (rid) {
724
+ response._livepilot_request_id = rid;
725
+ }
726
+ var json = JSON.stringify(response);
694
727
  var encoded = base64_encode(json);
695
728
 
696
729
  // Check if chunking needed (Max OSC packet limit ~8KB)
697
730
  if (encoded.length < 1400) {
698
731
  outlet(0, "/response", encoded);
699
732
  } else {
700
- // Split into chunks
733
+ // Split into chunks. The request id rides the chunk header (first arg)
734
+ // so the Python side buckets chunks per-response and never mixes two
735
+ // commands' chunks — the id inside the JSON can't be read until the
736
+ // chunks are reassembled.
701
737
  var chunk_size = 1400;
702
738
  var total = Math.ceil(encoded.length / chunk_size);
703
739
  for (var i = 0; i < total; i++) {
704
740
  var piece = encoded.substring(i * chunk_size, (i + 1) * chunk_size);
705
- outlet(0, "/response_chunk", i, total, piece);
741
+ outlet(0, "/response_chunk", (rid || ""), i, total, piece);
706
742
  }
707
743
  }
744
+ current_response_request_id = "";
708
745
  }
709
746
 
710
747
  function base64_encode(str) {
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.27.1"
2
+ __version__ = "1.27.2"
@@ -1402,8 +1402,12 @@ def build_creative_brief(
1402
1402
  " — those are filtered out before the brief reaches you.\n"
1403
1403
  "5. **TIER-1: Fire each search in `recommended_searches` BEFORE designing\n"
1404
1404
  " that role.** Each entry gives you a (tool, query) pair — call the named\n"
1405
- " Ableton Knowledge MCP tool. Most queries hit the Live manual or video\n"
1406
- " tutorials; capture 1 useful snippet per role and apply it.\n"
1405
+ " Ableton Knowledge MCP tool with the `mcp__Ableton_Knowledge__` prefix\n"
1406
+ " (e.g. search_live_manual mcp__Ableton_Knowledge__search_live_manual).\n"
1407
+ " Most queries hit the Live manual or video tutorials; capture 1 useful\n"
1408
+ " snippet per role and apply it. The Ableton Knowledge MCP is OPTIONAL —\n"
1409
+ " if any mcp__Ableton_Knowledge__* tool is unavailable or returns nothing,\n"
1410
+ " skip it and proceed using the creative guidance alone.\n"
1407
1411
  "6. **TIER-2: If `reference_artist` is set**, also fire each search in\n"
1408
1412
  " `reference_searches` and design USING that artist's signature techniques.\n"
1409
1413
  "7. For each layer: pick ONE uri from instruments_by_role[role], design\n"
@@ -378,12 +378,18 @@ def consult_ableton_knowledge(
378
378
  template, and surfaces sources alongside the answer.
379
379
 
380
380
  Examples:
381
- consult_ableton_knowledge("how do I make my kick punchier?")
382
- plan: [search_live_manual("Saturator"), search_transcripts("kick punch"),
383
- search_videos("kick design tutorial")] + synthesis template
384
- consult_ableton_knowledge("what's the difference between Operator and Wavetable?")
385
- → plan: [search_live_manual("Operator"), search_live_manual("Wavetable"),
386
- search_transcripts("Operator vs Wavetable")]
381
+ consult_ableton_knowledge("what does the Saturator Drive knob do?")
382
+ intent: device
383
+ → plan: [search_live_manual("what does the Saturator Drive knob do?"),
384
+ search_videos("what does the Saturator Drive knob do?"),
385
+ search_transcripts("what does the Saturator Drive knob do?")]
386
+ + synthesis template
387
+ consult_ableton_knowledge("how do I make my kick punchier?", {"current_genre": "techno"})
388
+ → intent: sound_design
389
+ → plan: [search_transcripts("techno how do I make my kick punchier?"),
390
+ search_videos("techno how do I make my kick punchier? tutorial"),
391
+ search_knowledge_base("how do I make my kick punchier?")]
392
+ + synthesis template
387
393
 
388
394
  session_context (optional): {
389
395
  "current_genre": "techno",
@@ -258,7 +258,8 @@ class AbletonConnection:
258
258
  needs_retry = fresh_connect and _is_single_client_state_error(response)
259
259
 
260
260
  if needs_retry:
261
- self.disconnect()
261
+ with self._lock:
262
+ self.disconnect()
262
263
  time.sleep(SINGLE_CLIENT_RETRY_DELAY)
263
264
  with self._lock:
264
265
  self.connect()
@@ -30,6 +30,7 @@ import socket
30
30
  import struct
31
31
  import threading
32
32
  import time
33
+ import uuid
33
34
  from typing import Any, Callable, Optional
34
35
 
35
36
 
@@ -477,7 +478,10 @@ class SpectralReceiver(asyncio.DatagramProtocol):
477
478
  /rms f — RMS level
478
479
  /pitch f f — MIDI note, amplitude
479
480
  /response s — base64-encoded JSON (single packet)
480
- /response_chunk i i s — chunked response (index, total, data)
481
+ /response_chunk [s] i i s — chunked response. New builds prefix the
482
+ per-response request id so chunks of
483
+ different commands never share a bucket;
484
+ legacy builds omit it: (index, total, data)
481
485
  """
482
486
 
483
487
  # Band names keyed by how many bands the .amxd emits. 8 bands is the v1.x
@@ -499,6 +503,12 @@ class SpectralReceiver(asyncio.DatagramProtocol):
499
503
  self._chunk_id = 0
500
504
  self._chunk_key: Optional[str] = None # Key of the single active reassembly bucket
501
505
  self._response_callback: Optional[asyncio.Future] = None
506
+ self._response_request_id: Optional[str] = None
507
+ # Set True the first time a response arrives carrying a request id.
508
+ # Once the analyzer build is known to stamp ids, a response that
509
+ # arrives with NO id (or a mismatched one) while a future is live is a
510
+ # stale straggler and must be dropped — see _handle_response.
511
+ self._seen_request_id = False
502
512
  self._capture_future: Optional[asyncio.Future] = None
503
513
  self._miditool_handler: Optional[Callable[[str, dict, list], None]] = None
504
514
 
@@ -653,7 +663,16 @@ class SpectralReceiver(asyncio.DatagramProtocol):
653
663
  self._handle_response(str(args[0]))
654
664
 
655
665
  elif address == "/response_chunk" and len(args) >= 3:
656
- self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
666
+ # New builds prefix the request id (string): (rid, index, total, data).
667
+ # Legacy builds send (index, total, data). Distinguish by the type
668
+ # of the first arg — OSC ints decode to int, the id to a str.
669
+ if len(args) >= 4 and isinstance(args[0], str):
670
+ self._handle_chunk(
671
+ int(args[1]), int(args[2]), str(args[3]),
672
+ request_id=str(args[0]),
673
+ )
674
+ else:
675
+ self._handle_chunk(int(args[0]), int(args[1]), str(args[2]))
657
676
 
658
677
  elif address == "/miditool/request" and len(args) >= 1:
659
678
  self._handle_miditool_request(str(args[0]))
@@ -665,24 +684,52 @@ class SpectralReceiver(asyncio.DatagramProtocol):
665
684
  def _handle_response(self, encoded: str) -> None:
666
685
  """Decode a single-packet base64 response.
667
686
 
668
- Resolves _response_callback exactly once, then clears it. Without the
669
- clear, a second late packet could overwrite a future belonging to a
670
- different in-flight command. The protocol has no request id yet
671
- (livepilot_bridge.js:666 emits bare /response), so correlation relies
672
- on the single-command-in-flight invariant enforced by M4LBridge._cmd_lock
673
- plus this one-shot clear.
687
+ Updated analyzer JS echoes ``_livepilot_request_id`` on every response
688
+ (single-packet here, and on the chunk header for chunked responses).
689
+ Correlation rules, once this device is known to stamp ids:
690
+ * a response whose id does NOT match the in-flight future is dropped;
691
+ * a response with NO id at all is also dropped — it is a stale
692
+ straggler (e.g. a batched read that outlived its timeout), not a
693
+ pre-request-id build.
694
+ Pre-request-id builds (which never stamp an id) stay supported: until
695
+ the first id is ever seen, no-id responses are accepted.
674
696
  """
675
697
  try:
676
698
  # URL-safe base64 decode (- and _ instead of + and /)
677
699
  padded = encoded + "=" * (-len(encoded) % 4)
678
700
  decoded = base64.urlsafe_b64decode(padded).decode('utf-8')
679
701
  result = _normalize_bridge_payload(json.loads(decoded))
702
+ response_request_id = None
703
+ if isinstance(result, dict):
704
+ response_request_id = result.pop("_livepilot_request_id", None)
705
+
706
+ if response_request_id is not None:
707
+ # This analyzer build stamps request ids — remember it so a
708
+ # later no-id straggler is recognised as stale rather than
709
+ # mistaken for an old build that never sends ids.
710
+ self._seen_request_id = True
711
+
680
712
  cb = self._response_callback
713
+ expected_request_id = self._response_request_id
714
+ if cb and not cb.done() and expected_request_id is not None:
715
+ mismatched = (
716
+ response_request_id is not None
717
+ and str(response_request_id) != str(expected_request_id)
718
+ )
719
+ missing_but_expected = (
720
+ response_request_id is None and self._seen_request_id
721
+ )
722
+ if mismatched or missing_but_expected:
723
+ # Stale/uncorrelated reply — drop it and keep waiting for
724
+ # the response that actually matches this command.
725
+ return
726
+
681
727
  if cb and not cb.done():
682
728
  cb.set_result(result)
683
729
  # Clear regardless — either we consumed it, or it was already
684
730
  # done/abandoned. Future packets with no owner get dropped.
685
731
  self._response_callback = None
732
+ self._response_request_id = None
686
733
  except Exception as exc:
687
734
  import sys
688
735
  print(f"LivePilot: failed to decode bridge response: {exc}", file=sys.stderr)
@@ -718,49 +765,62 @@ class SpectralReceiver(asyncio.DatagramProtocol):
718
765
  import sys
719
766
  print(f"LivePilot: miditool handler error: {exc}", file=sys.stderr)
720
767
 
721
- def _handle_chunk(self, index: int, total: int, encoded: str) -> None:
768
+ def _handle_chunk(
769
+ self,
770
+ index: int,
771
+ total: int,
772
+ encoded: str,
773
+ request_id: Optional[str] = None,
774
+ ) -> None:
722
775
  """Reassemble chunked responses.
723
776
 
724
- The previous implementation incremented ``_chunk_id`` only when
725
- ``index == 0`` and assumed the first chunk always arrived first.
726
- Under UDP reordering (rare on loopback but possible under system
727
- load), a chunk with ``index > 0`` arriving before ``index 0`` would
728
- be dropped into the PREVIOUS sequence's bucket silently corrupting
729
- that earlier response's payload.
730
-
731
- Until the wire protocol adds an explicit sequence id, the safer
732
- behavior is: if we see an out-of-order first-chunk (``index > 0``
733
- with no open bucket), start a fresh bucket but log a warning. That
734
- way we never poison a prior sequence, and the problem surfaces in
735
- logs if it happens.
777
+ New analyzer builds stamp the per-response request id on every
778
+ ``/response_chunk`` header, so chunks from different commands land in
779
+ SEPARATE buckets and can never be interleaved even if a stale chunk
780
+ from a timed-out command arrives mid-flight. Legacy builds omit the id
781
+ (``request_id`` None/""); they fall back to a single rolling bucket,
782
+ which is safe because ``send_command`` serialises on ``_cmd_lock``.
783
+
784
+ Hardening (v1.27.2): an index outside ``[0, total)`` is dropped a
785
+ duplicate or malformed packet must never count toward completion and
786
+ reassembly only fires once EVERY index ``0..total-1`` is present. The
787
+ old ``len(parts) == total`` check could KeyError on a duplicate or
788
+ out-of-range index, silently losing the response and forcing a full
789
+ timeout.
736
790
  """
737
- # Only one response is chunked at a time: send_command serialises on
738
- # _cmd_lock, so a single active reassembly bucket is sufficient and we
739
- # do NOT need the first packet to be index 0. Accepting chunks in any
740
- # order fixes the permanent-loss path where an index>0 chunk arriving
741
- # before index 0 (UDP loopback reordering under load) used to split
742
- # one response across two buckets that never completed.
743
- active = self._chunks.get(self._chunk_key)
744
- if active is None or active["total"] != total:
745
- # No bucket open, OR a chunk with a different `total` arrived,
746
- # meaning a new response started (e.g. the previous one timed out
747
- # without ever completing). Evict any stale partial and open fresh.
748
- if active is not None:
749
- self._chunks.pop(self._chunk_key, None)
750
- self._chunk_times.pop(self._chunk_key, None)
751
- self._chunk_id += 1
752
- self._chunk_key = str(self._chunk_id)
753
- self._chunks[self._chunk_key] = {"parts": {}, "total": total}
754
- self._chunk_times[self._chunk_key] = time.monotonic()
755
-
756
- key = self._chunk_key
757
- self._chunks[key]["parts"][index] = encoded
758
-
759
- if len(self._chunks[key]["parts"]) == total:
760
- # All chunks received reassemble
761
- full = ""
762
- for i in range(total):
763
- full += self._chunks[key]["parts"][i]
791
+ # Drop chunks that cannot belong to a well-formed response.
792
+ if total <= 0 or index < 0 or index >= total:
793
+ return
794
+
795
+ if request_id:
796
+ # Collision-free per-response bucket.
797
+ key = f"rid:{request_id}"
798
+ active = self._chunks.get(key)
799
+ if active is None or active["total"] != total:
800
+ self._chunks[key] = {"parts": {}, "total": total}
801
+ self._chunk_times[key] = time.monotonic()
802
+ self._chunk_key = key
803
+ else:
804
+ # Legacy single-active-bucket model (no id on the wire). A chunk
805
+ # with a different `total` means a new response started (e.g. the
806
+ # previous one timed out without ever completing) — evict + reopen.
807
+ active = self._chunks.get(self._chunk_key)
808
+ if active is None or active["total"] != total:
809
+ if active is not None:
810
+ self._chunks.pop(self._chunk_key, None)
811
+ self._chunk_times.pop(self._chunk_key, None)
812
+ self._chunk_id += 1
813
+ self._chunk_key = str(self._chunk_id)
814
+ self._chunks[self._chunk_key] = {"parts": {}, "total": total}
815
+ self._chunk_times[self._chunk_key] = time.monotonic()
816
+ key = self._chunk_key
817
+
818
+ parts = self._chunks[key]["parts"]
819
+ parts[index] = encoded
820
+
821
+ if len(parts) == total and all(i in parts for i in range(total)):
822
+ # Every chunk present — reassemble in order.
823
+ full = "".join(parts[i] for i in range(total))
764
824
  del self._chunks[key]
765
825
  self._chunk_times.pop(key, None)
766
826
  self._handle_response(full)
@@ -780,13 +840,22 @@ class SpectralReceiver(asyncio.DatagramProtocol):
780
840
  result = _normalize_bridge_payload(json.loads(decoded))
781
841
  if self._capture_future and not self._capture_future.done():
782
842
  self._capture_future.set_result(result)
843
+ # Clear the reference so a completed future isn't retained until the
844
+ # next capture, and so send_capture's done()-guarded cancel has no
845
+ # stale object to consider.
846
+ self._capture_future = None
783
847
  except Exception as exc:
784
848
  import sys
785
849
  print(f"LivePilot: failed to decode capture response: {exc}", file=sys.stderr)
786
850
 
787
- def set_response_future(self, future: asyncio.Future) -> None:
851
+ def set_response_future(
852
+ self,
853
+ future: Optional[asyncio.Future],
854
+ request_id: Optional[str] = None,
855
+ ) -> None:
788
856
  """Set a future to be resolved with the next response."""
789
857
  self._response_callback = future
858
+ self._response_request_id = request_id if future is not None else None
790
859
 
791
860
  def set_capture_future(self, future: asyncio.Future) -> None:
792
861
  """Set a future to be resolved when a capture_complete OSC arrives."""
@@ -810,6 +879,12 @@ class M4LBridge:
810
879
  self.receiver = receiver
811
880
  self.miditool_cache = miditool_cache
812
881
  self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
882
+ # Non-blocking so a momentarily-full send buffer never stalls the
883
+ # asyncio event loop. The miditool response path sends from inside a
884
+ # DatagramProtocol callback that runs ON the loop thread — a blocking
885
+ # sendto there would freeze every pending coroutine. UDP loopback sends
886
+ # essentially never block; _safe_sendto guards the rare case anyway.
887
+ self._sock.setblocking(False)
813
888
  self._m4l_addr = ("127.0.0.1", 9881)
814
889
  self._cmd_lock: Optional[asyncio.Lock] = None
815
890
  # BUG-audit-C1: send_capture uses _capture_future, which is
@@ -825,6 +900,22 @@ class M4LBridge:
825
900
  if self.receiver.miditool_cache is None and miditool_cache is not None:
826
901
  self.receiver.miditool_cache = miditool_cache
827
902
 
903
+ def _safe_sendto(self, data: bytes) -> None:
904
+ """Send a UDP packet without ever blocking the event loop.
905
+
906
+ The socket is non-blocking; if the OS send buffer is momentarily full
907
+ (vanishingly rare on loopback) sendto raises BlockingIOError. We drop
908
+ the packet rather than stall — the caller's timeout/retry handles it.
909
+ """
910
+ try:
911
+ self._sock.sendto(data, self._m4l_addr)
912
+ except BlockingIOError:
913
+ import sys
914
+ print(
915
+ "LivePilot: M4L UDP send buffer full — packet dropped",
916
+ file=sys.stderr,
917
+ )
918
+
828
919
  def _dispatch_miditool_request(
829
920
  self, request_id: str, context: dict, notes: list,
830
921
  ) -> None:
@@ -869,7 +960,7 @@ class M4LBridge:
869
960
  """
870
961
  payload = {"tool_name": tool_name or "", "params": params or {}}
871
962
  osc = self._build_osc("miditool/config", (json.dumps(payload),))
872
- self._sock.sendto(osc, self._m4l_addr)
963
+ self._safe_sendto(osc)
873
964
 
874
965
  def send_miditool_response(self, request_id: str, notes: list) -> None:
875
966
  """Send transformed notes back to the JS bridge.
@@ -879,7 +970,7 @@ class M4LBridge:
879
970
  """
880
971
  payload = {"request_id": str(request_id or ""), "notes": list(notes or [])}
881
972
  osc = self._build_osc("miditool/response", (json.dumps(payload),))
882
- self._sock.sendto(osc, self._m4l_addr)
973
+ self._safe_sendto(osc)
883
974
 
884
975
  async def send_command(self, command: str, *args: Any, timeout: float = 5.0) -> dict:
885
976
  """Send an OSC command to the M4L device and wait for the response."""
@@ -905,12 +996,14 @@ class M4LBridge:
905
996
  # Create a future for the response
906
997
  loop = asyncio.get_running_loop()
907
998
  future = loop.create_future()
908
- self.receiver.set_response_future(future)
999
+ request_id = uuid.uuid4().hex
1000
+ self.receiver.set_response_future(future, request_id=request_id)
909
1001
 
910
1002
  # Build and send OSC message (no leading / — Max udpreceive
911
1003
  # passes messagename with / intact to JS, breaking dispatch)
912
- osc_data = self._build_osc(command, args)
913
- self._sock.sendto(osc_data, self._m4l_addr)
1004
+ request_arg = f"__livepilot_request_id:{request_id}"
1005
+ osc_data = self._build_osc(command, (*args, request_arg))
1006
+ self._safe_sendto(osc_data)
914
1007
 
915
1008
  # Wait for response with timeout
916
1009
  try:
@@ -958,7 +1051,7 @@ class M4LBridge:
958
1051
  self.receiver.set_capture_future(future)
959
1052
 
960
1053
  osc_data = self._build_osc(command, args)
961
- self._sock.sendto(osc_data, self._m4l_addr)
1054
+ self._safe_sendto(osc_data)
962
1055
 
963
1056
  try:
964
1057
  result = await asyncio.wait_for(future, timeout=timeout)
@@ -66,6 +66,12 @@ MCP_TOOLS: frozenset[str] = frozenset({
66
66
  # set_drum_chain_note + insert_device + replace_sample_native. Used by
67
67
  # the create_drum_rack_pad semantic move.
68
68
  "add_drum_rack_pad",
69
+ # Routing-correctness (v1.27.2): these have @mcp.tool wrappers that call
70
+ # the TCP Remote Script / read the SpectralCache in-process. They must
71
+ # classify as mcp_tool so plan steps take the SAME path as direct callers
72
+ # — not the M4L JS bridge, and not the "unknown" dead-end.
73
+ "compressor_set_sidechain", # was mis-listed in BRIDGE_COMMANDS → JS bridge
74
+ "get_master_rms", # was READ_ONLY but unclassified → plan failure
69
75
  })
70
76
 
71
77
 
@@ -153,6 +153,25 @@ async def _add_drum_rack_pad(params: dict, ctx: Any = None) -> dict:
153
153
  return await _call(add_drum_rack_pad, ctx, params)
154
154
 
155
155
 
156
+ # ── Routing-correctness (v1.27.2) ─────────────────────────────────────────
157
+ #
158
+ # Both have @mcp.tool wrappers in tools/analyzer.py. compressor_set_sidechain
159
+ # was mis-listed in BRIDGE_COMMANDS, so plan steps silently routed to the M4L
160
+ # JS bridge while direct callers used the TCP Remote Script — divergent paths
161
+ # with different error handling. get_master_rms was tagged READ_ONLY but never
162
+ # classified (classify_step returned "unknown"), so plans could not use it at
163
+ # all. Both now dispatch in-process here, matching their direct @mcp.tool path.
164
+
165
+ async def _compressor_set_sidechain(params: dict, ctx: Any = None) -> dict:
166
+ from ..tools.analyzer import compressor_set_sidechain
167
+ return await _call(compressor_set_sidechain, ctx, params)
168
+
169
+
170
+ async def _get_master_rms(params: dict, ctx: Any = None) -> dict:
171
+ from ..tools.analyzer import get_master_rms
172
+ return await _call(get_master_rms, ctx, params)
173
+
174
+
156
175
  def build_mcp_dispatch_registry() -> dict[str, Callable]:
157
176
  """Return the canonical registry of MCP-only tools for plan execution.
158
177
 
@@ -186,4 +205,7 @@ def build_mcp_dispatch_registry() -> dict[str, Callable]:
186
205
  "add_session_memory": _add_session_memory,
187
206
  # v1.20 — drum rack pad construction (async orchestrator).
188
207
  "add_drum_rack_pad": _add_drum_rack_pad,
208
+ # v1.27.2 — routing-correctness (see adapters above).
209
+ "compressor_set_sidechain": _compressor_set_sidechain,
210
+ "get_master_rms": _get_master_rms,
189
211
  }
@@ -139,9 +139,11 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
139
139
  "get_params", "get_hidden_params", "get_auto_state", "walk_rack",
140
140
  "get_chains_deep", "get_track_cpu", "get_selected", "get_key",
141
141
  "get_clip_file_path", "replace_simpler_sample", "get_simpler_slices",
142
- "get_simpler_file_path", # v1.23.3closes the v1.12 follow-up that
143
- # left classify_simpler_slices unable to
144
- # auto-resolve the Simpler's sample path
142
+ # NOTE: get_simpler_file_path is NOT here it lives in REMOTE_COMMANDS
143
+ # (the primary Python LOM path since v1.23.3). classify_step checks
144
+ # REMOTE first, so listing it here too was dead for dispatch. The
145
+ # backwards-compat bridge call still exists, but analyzer.py invokes it
146
+ # directly via bridge.send_command(), bypassing classify_step.
145
147
  "crop_simpler", "reverse_simpler", "warp_simpler",
146
148
  "get_warp_markers", "add_warp_marker", "move_warp_marker",
147
149
  "remove_warp_marker", "capture_audio", "capture_stop",
@@ -152,7 +154,10 @@ BRIDGE_COMMANDS: frozenset[str] = frozenset({
152
154
  # only Max JS LiveAPI exposes). See mcp_server/tools/analyzer.py for
153
155
  # the matching MCP tools that route through bridge.send_command.
154
156
  "simpler_set_warp",
155
- "compressor_set_sidechain",
157
+ # NOTE: compressor_set_sidechain is NOT here — its @mcp.tool wrapper calls
158
+ # the TCP Remote Script ("set_compressor_sidechain", the BUG-A3 Python
159
+ # path), so it belongs in MCP_TOOLS. Listing it here routed plan steps to
160
+ # the M4L JS bridge, a divergent path. Moved to execution_router.MCP_TOOLS.
156
161
  # NOTE: load_sample_to_simpler used to live here, but it's actually an
157
162
  # async Python MCP tool in mcp_server/tools/analyzer.py, not a bridge
158
163
  # command. It has no case in livepilot_bridge.js and no @register handler
@@ -362,7 +362,7 @@ def _get_all_tools():
362
362
  to the next path rather than exploding.
363
363
 
364
364
  WARNING: Accesses FastMCP private internals. Pinned to
365
- fastmcp>=3.3.1,<3.4.0 in requirements.txt. The startup self-test
365
+ fastmcp>=3.4.2,<3.5.0 in requirements.txt. The startup self-test
366
366
  (_assert_tool_registry_accessible) will fail loudly if every probe
367
367
  returns empty — better than silently returning [] and disabling
368
368
  schema coercion.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.27.1",
3
+ "version": "1.27.2",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
5
  "description": "Agentic production system for Ableton Live 12 — 467 tools, 56 domains, 44 semantic moves. Device atlas (5264 devices, 120 enriched, 7 indexes), Splice intelligence (gRPC + GraphQL describe-a-sound + preview + collections + presets), 9-band spectral perception auto-loaded via ensure_analyzer_on_master, Creative Director skill, technique memory, 12 creative intelligence engines",
6
6
  "author": "Pilot Studio",
@@ -5,7 +5,7 @@ Entry point for the ControlSurface. Ableton calls create_instance(c_instance)
5
5
  when this script is selected in Preferences > Link, Tempo & MIDI.
6
6
  """
7
7
 
8
- __version__ = "1.27.1"
8
+ __version__ = "1.27.2"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
@@ -98,22 +98,28 @@ def _force_reload_handlers(cs=None):
98
98
  except Exception:
99
99
  pass
100
100
 
101
+ failures = []
102
+
101
103
  try:
102
104
  importlib.reload(router)
103
105
  except Exception as exc:
104
- _log("reload(router) FAILED — %s: %s" % (type(exc).__name__, exc))
106
+ msg = "reload(router) FAILED — %s: %s" % (type(exc).__name__, exc)
107
+ _log(msg)
108
+ failures.append(("LivePilot.router", "%s: %s" % (type(exc).__name__, exc)))
105
109
 
106
110
  try:
107
111
  importlib.reload(utils)
108
112
  except Exception as exc:
109
- _log("reload(utils) FAILED — %s: %s" % (type(exc).__name__, exc))
113
+ msg = "reload(utils) FAILED — %s: %s" % (type(exc).__name__, exc)
114
+ _log(msg)
115
+ failures.append(("LivePilot.utils", "%s: %s" % (type(exc).__name__, exc)))
110
116
 
111
117
  # Invalidate caches so iter_modules sees newly-added files even if
112
118
  # an importer cached the previous directory listing.
113
119
  importlib.invalidate_caches()
114
120
  pkg = _sys.modules.get("LivePilot")
115
121
  if pkg is None or getattr(pkg, "__path__", None) is None:
116
- return
122
+ return {"discovered": 0, "reloaded": 0, "first_imported": 0, "failures": failures}
117
123
 
118
124
  discovered = reloaded = first_imported = 0
119
125
  for _finder, modname, _is_pkg in pkgutil.iter_modules(pkg.__path__):
@@ -130,8 +136,9 @@ def _force_reload_handlers(cs=None):
130
136
  importlib.import_module(full_name)
131
137
  first_imported += 1
132
138
  except Exception as exc:
133
- _log("reload(%s) FAILED — %s: %s" % (
134
- full_name, type(exc).__name__, exc))
139
+ msg = "reload(%s) FAILED — %s: %s" % (full_name, type(exc).__name__, exc)
140
+ _log(msg)
141
+ failures.append((full_name, "%s: %s" % (type(exc).__name__, exc)))
135
142
 
136
143
  # reload_handlers_cmd lives in __init__.py (not a handler module),
137
144
  # so the step-3 loop does not cover it. Re-register manually.
@@ -140,15 +147,21 @@ def _force_reload_handlers(cs=None):
140
147
  _log("reload complete — %d discovered (%d reloaded, %d first-imported)" % (
141
148
  discovered, reloaded, first_imported))
142
149
 
150
+ return {"discovered": discovered, "reloaded": reloaded,
151
+ "first_imported": first_imported, "failures": failures}
152
+
143
153
 
144
154
  def reload_handlers_cmd(song, params):
145
155
  """TCP-accessible reload trigger. Lets automation refresh handlers
146
156
  without a UI Control Surface toggle — the core dev-loop improvement.
147
- Returns the handler count so the caller can assert before/after."""
148
- _force_reload_handlers(cs=None)
157
+ Returns the handler count so the caller can assert before/after.
158
+ Returns any reload failures so the caller can detect silent errors."""
159
+ result = _force_reload_handlers(cs=None)
160
+ failures = result.get("failures", []) if isinstance(result, dict) else []
149
161
  return {
150
162
  "reloaded": True,
151
163
  "handler_count": len(router._handlers),
164
+ "errors": [{"module": m, "error": e} for m, e in failures],
152
165
  }
153
166
 
154
167
 
@@ -91,12 +91,18 @@ def get_track_info(song, params):
91
91
  if track.is_foldable:
92
92
  result["fold_state"] = bool(track.fold_state)
93
93
 
94
- # Regular tracks have arm and input type; return tracks get null values
94
+ # Regular tracks expose arm + input type; Group/Return/Master tracks raise
95
+ # RuntimeError on these LOM properties. hasattr() does NOT catch it (Live
96
+ # raises rather than omitting the attribute), so a Group track at a positive
97
+ # index used to crash get_track_info. Guard each access — mirrors the
98
+ # transport.py get_session_info fix (commit 4aec8e6, closing PR #35).
95
99
  if track_index >= 0:
96
- result["arm"] = track.arm
97
- result["has_midi_input"] = track.has_midi_input
98
- result["has_audio_input"] = track.has_audio_input
99
- result["current_monitoring_state"] = track.current_monitoring_state
100
+ for _prop in ("arm", "has_midi_input", "has_audio_input",
101
+ "current_monitoring_state"):
102
+ try:
103
+ result[_prop] = getattr(track, _prop)
104
+ except Exception:
105
+ result[_prop] = None
100
106
  else:
101
107
  result["arm"] = None
102
108
  result["has_midi_input"] = None
package/server.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "https://github.com/dreamrec/LivePilot",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.27.1",
9
+ "version": "1.27.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",