livepilot 1.27.1 → 1.27.3

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 (117) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +10 -7
  3. package/bin/livepilot.js +156 -37
  4. package/livepilot/.Codex-plugin/plugin.json +1 -1
  5. package/livepilot/.claude-plugin/plugin.json +1 -1
  6. package/livepilot/skills/livepilot-core/SKILL.md +33 -1
  7. package/livepilot/skills/livepilot-core/references/atlas-tool-notes.md +69 -0
  8. package/livepilot/skills/livepilot-core/references/overview.md +18 -11
  9. package/livepilot/skills/livepilot-core/references/perception.md +125 -0
  10. package/livepilot/skills/livepilot-devices/references/device-parameter-units.md +66 -0
  11. package/livepilot/skills/livepilot-evaluation/references/capability-modes.md +1 -1
  12. package/livepilot/skills/livepilot-mix-engine/SKILL.md +8 -0
  13. package/livepilot/skills/livepilot-release/SKILL.md +1 -1
  14. package/livepilot/skills/livepilot-sample-engine/references/splice-tools-notes.md +56 -0
  15. package/livepilot/skills/livepilot-wonder/SKILL.md +6 -0
  16. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  17. package/m4l_device/livepilot_bridge.js +48 -11
  18. package/mcp_server/__init__.py +1 -1
  19. package/mcp_server/atlas/__init__.py +181 -40
  20. package/mcp_server/atlas/overlays.py +46 -3
  21. package/mcp_server/atlas/tools.py +226 -86
  22. package/mcp_server/audit/checks.py +50 -22
  23. package/mcp_server/audit/state.py +10 -2
  24. package/mcp_server/audit/tools.py +27 -6
  25. package/mcp_server/composer/develop/apply.py +7 -7
  26. package/mcp_server/composer/fast/apply.py +29 -23
  27. package/mcp_server/composer/fast/brief_builder.py +6 -2
  28. package/mcp_server/composer/framework/atlas_resolver.py +16 -1
  29. package/mcp_server/composer/full/apply.py +40 -34
  30. package/mcp_server/composer/full/engine.py +66 -32
  31. package/mcp_server/composer/tools.py +16 -9
  32. package/mcp_server/connection.py +69 -10
  33. package/mcp_server/creative_constraints/engine.py +10 -2
  34. package/mcp_server/creative_constraints/tools.py +24 -7
  35. package/mcp_server/curves.py +30 -7
  36. package/mcp_server/device_forge/builder.py +40 -11
  37. package/mcp_server/device_forge/models.py +9 -0
  38. package/mcp_server/device_forge/tools.py +30 -11
  39. package/mcp_server/experiment/engine.py +29 -22
  40. package/mcp_server/experiment/tools.py +15 -5
  41. package/mcp_server/m4l_bridge.py +217 -62
  42. package/mcp_server/memory/taste_graph.py +43 -0
  43. package/mcp_server/memory/technique_store.py +26 -3
  44. package/mcp_server/memory/tools.py +62 -4
  45. package/mcp_server/mix_engine/critics.py +187 -30
  46. package/mcp_server/mix_engine/models.py +21 -1
  47. package/mcp_server/mix_engine/state_builder.py +87 -8
  48. package/mcp_server/mix_engine/tools.py +16 -4
  49. package/mcp_server/performance_engine/tools.py +56 -8
  50. package/mcp_server/persistence/base_store.py +11 -0
  51. package/mcp_server/persistence/project_store.py +132 -8
  52. package/mcp_server/persistence/taste_store.py +90 -7
  53. package/mcp_server/preview_studio/engine.py +40 -10
  54. package/mcp_server/preview_studio/models.py +40 -0
  55. package/mcp_server/preview_studio/tools.py +56 -12
  56. package/mcp_server/project_brain/arrangement_graph.py +4 -0
  57. package/mcp_server/project_brain/models.py +2 -0
  58. package/mcp_server/project_brain/role_graph.py +13 -7
  59. package/mcp_server/reference_engine/gap_analyzer.py +58 -3
  60. package/mcp_server/reference_engine/profile_builder.py +47 -4
  61. package/mcp_server/reference_engine/tools.py +6 -0
  62. package/mcp_server/runtime/execution_router.py +57 -1
  63. package/mcp_server/runtime/mcp_dispatch.py +22 -0
  64. package/mcp_server/runtime/remote_commands.py +9 -4
  65. package/mcp_server/runtime/tools.py +0 -1
  66. package/mcp_server/sample_engine/sources.py +0 -2
  67. package/mcp_server/sample_engine/tools.py +19 -38
  68. package/mcp_server/semantic_moves/mix_compilers.py +276 -51
  69. package/mcp_server/semantic_moves/performance_compilers.py +51 -17
  70. package/mcp_server/semantic_moves/resolvers.py +45 -0
  71. package/mcp_server/semantic_moves/sound_design_compilers.py +14 -6
  72. package/mcp_server/semantic_moves/tools.py +80 -2
  73. package/mcp_server/semantic_moves/transition_compilers.py +26 -9
  74. package/mcp_server/server.py +27 -25
  75. package/mcp_server/session_continuity/tracker.py +51 -3
  76. package/mcp_server/song_brain/builder.py +47 -5
  77. package/mcp_server/song_brain/tools.py +21 -7
  78. package/mcp_server/sound_design/critics.py +1 -0
  79. package/mcp_server/splice_client/client.py +117 -33
  80. package/mcp_server/splice_client/http_bridge.py +15 -3
  81. package/mcp_server/splice_client/quota.py +28 -0
  82. package/mcp_server/stuckness_detector/detector.py +8 -5
  83. package/mcp_server/synthesis_brain/adapters/drift.py +13 -0
  84. package/mcp_server/synthesis_brain/adapters/meld.py +13 -0
  85. package/mcp_server/tools/_analyzer_engine/sample.py +36 -10
  86. package/mcp_server/tools/_perception_engine.py +6 -0
  87. package/mcp_server/tools/agent_os.py +4 -1
  88. package/mcp_server/tools/analyzer.py +198 -209
  89. package/mcp_server/tools/arrangement.py +7 -4
  90. package/mcp_server/tools/automation.py +24 -4
  91. package/mcp_server/tools/browser.py +25 -11
  92. package/mcp_server/tools/clips.py +6 -0
  93. package/mcp_server/tools/composition.py +33 -2
  94. package/mcp_server/tools/devices.py +53 -53
  95. package/mcp_server/tools/generative.py +14 -14
  96. package/mcp_server/tools/harmony.py +7 -7
  97. package/mcp_server/tools/mixing.py +4 -4
  98. package/mcp_server/tools/planner.py +68 -6
  99. package/mcp_server/tools/research.py +20 -2
  100. package/mcp_server/tools/theory.py +10 -10
  101. package/mcp_server/tools/transport.py +7 -2
  102. package/mcp_server/transition_engine/critics.py +13 -1
  103. package/mcp_server/user_corpus/tools.py +30 -1
  104. package/mcp_server/wonder_mode/engine.py +82 -9
  105. package/mcp_server/wonder_mode/session.py +32 -10
  106. package/mcp_server/wonder_mode/tools.py +14 -1
  107. package/package.json +1 -1
  108. package/remote_script/LivePilot/__init__.py +21 -8
  109. package/remote_script/LivePilot/arrangement.py +93 -33
  110. package/remote_script/LivePilot/browser.py +60 -4
  111. package/remote_script/LivePilot/devices.py +132 -62
  112. package/remote_script/LivePilot/mixing.py +31 -5
  113. package/remote_script/LivePilot/server.py +94 -22
  114. package/remote_script/LivePilot/tracks.py +11 -5
  115. package/remote_script/LivePilot/transport.py +11 -0
  116. package/requirements.txt +5 -5
  117. package/server.json +2 -2
@@ -73,7 +73,7 @@ async def _back_to_arranger(ctx: Any) -> dict:
73
73
  if ableton is None:
74
74
  return {"ok": False}
75
75
  try:
76
- return ableton.send_command("back_to_arranger", {})
76
+ return await ableton.send_command_async("back_to_arranger", {})
77
77
  except Exception as exc:
78
78
  logger.warning("apply_develop: back_to_arranger failed: %s", exc)
79
79
  return {"ok": False}
@@ -148,10 +148,10 @@ async def apply_develop_plan(ctx: Context, plan: dict) -> dict:
148
148
  plan_tempo = plan.get("tempo")
149
149
  if plan_tempo is not None:
150
150
  try:
151
- session = ableton.send_command("get_session_info", {})
151
+ session = await ableton.send_command_async("get_session_info", {})
152
152
  current_tempo = float(session.get("tempo", 0.0))
153
153
  if abs(current_tempo - float(plan_tempo)) > 0.01:
154
- ableton.send_command("set_tempo", {"tempo": float(plan_tempo)})
154
+ await ableton.send_command_async("set_tempo", {"tempo": float(plan_tempo)})
155
155
  except Exception as exc:
156
156
  logger.warning("apply_develop: tempo set failed: %s", exc)
157
157
 
@@ -171,27 +171,27 @@ async def apply_develop_plan(ctx: Context, plan: dict) -> dict:
171
171
  try:
172
172
  # Optional sample swap (sample-trigger layers only)
173
173
  if sample_uri:
174
- ableton.send_command(
174
+ await ableton.send_command_async(
175
175
  "load_browser_item",
176
176
  {"track_index": track_index, "uri": sample_uri},
177
177
  )
178
178
  sample_swaps += 1
179
179
 
180
180
  # Create clip
181
- ableton.send_command(
181
+ await ableton.send_command_async(
182
182
  "create_clip",
183
183
  {"track_index": track_index, "clip_index": scene_index, "length": clip_length},
184
184
  )
185
185
 
186
186
  # Add notes (skip if empty — empty clip is a valid drum-dropout pattern)
187
187
  if notes:
188
- ableton.send_command(
188
+ await ableton.send_command_async(
189
189
  "add_notes",
190
190
  {"track_index": track_index, "clip_index": scene_index, "notes": notes},
191
191
  )
192
192
 
193
193
  # Name the clip
194
- ableton.send_command(
194
+ await ableton.send_command_async(
195
195
  "set_clip_name",
196
196
  {"track_index": track_index, "clip_index": scene_index, "name": name},
197
197
  )
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import logging
6
7
  import time
7
8
 
@@ -22,13 +23,13 @@ def _is_drum_role(role: str) -> bool:
22
23
  return role.lower() in DRUM_ROLES
23
24
 
24
25
 
25
- def _detect_silent_load(ableton, track_index: int, device_index: int = 0) -> tuple:
26
+ async def _detect_silent_load(ableton, track_index: int, device_index: int = 0) -> tuple:
26
27
  """Detect if the loaded device is silently misconfigured (empty container).
27
28
 
28
29
  Returns (is_silent: bool, reason: str).
29
30
  """
30
31
  try:
31
- device_info = ableton.send_command("get_device_info", {
32
+ device_info = await ableton.send_command_async("get_device_info", {
32
33
  "track_index": track_index, "device_index": device_index,
33
34
  })
34
35
  except Exception:
@@ -44,7 +45,7 @@ def _detect_silent_load(ableton, track_index: int, device_index: int = 0) -> tup
44
45
  # Simpler with Sample Length near zero
45
46
  if class_name == "OriginalSimpler":
46
47
  try:
47
- params_resp = ableton.send_command("get_device_parameters", {
48
+ params_resp = await ableton.send_command_async("get_device_parameters", {
48
49
  "track_index": track_index, "device_index": device_index,
49
50
  })
50
51
  for p in params_resp.get("parameters", []):
@@ -60,7 +61,7 @@ def _detect_silent_load(ableton, track_index: int, device_index: int = 0) -> tup
60
61
  return False, ""
61
62
 
62
63
 
63
- def _apply_drum_role_repair(ableton, track_index: int, device_index: int = 0) -> dict:
64
+ async def _apply_drum_role_repair(ableton, track_index: int, device_index: int = 0) -> dict:
64
65
  """Apply role-default repair to a drum-role layer's instrument.
65
66
 
66
67
  Defense in depth: load_browser_item's role='drum' silently fails to
@@ -83,7 +84,7 @@ def _apply_drum_role_repair(ableton, track_index: int, device_index: int = 0) ->
83
84
  """
84
85
  # Detect device class so we apply class-appropriate params
85
86
  try:
86
- device_info = ableton.send_command("get_device_info", {
87
+ device_info = await ableton.send_command_async("get_device_info", {
87
88
  "track_index": track_index, "device_index": device_index,
88
89
  })
89
90
  class_name = device_info.get("class_name", "")
@@ -99,14 +100,19 @@ def _apply_drum_role_repair(ableton, track_index: int, device_index: int = 0) ->
99
100
  ])
100
101
 
101
102
  try:
102
- result = ableton.send_command("batch_set_parameters", {
103
+ result = await ableton.send_command_async("batch_set_parameters", {
103
104
  "track_index": track_index,
104
105
  "device_index": device_index,
105
106
  "parameters": params_to_set,
106
107
  })
108
+ # P2-50: batch_set_parameters no longer raises on a per-entry failure —
109
+ # it returns {"ok": bool, "failed": M, ...}. Reflect the actual outcome
110
+ # instead of hardcoding applied=True (default True keeps pre-P2-50
111
+ # Remote Scripts that omit "ok" working).
107
112
  return {
108
- "applied": True,
113
+ "applied": bool(result.get("ok", True)),
109
114
  "device_class": class_name,
115
+ "failed": result.get("failed", 0),
110
116
  "params": result.get("parameters", []),
111
117
  }
112
118
  except Exception as exc:
@@ -189,14 +195,14 @@ async def apply_fast_plan(
189
195
  logger.debug("fast apply: preflight failed (bridge unavailable): %s", exc)
190
196
 
191
197
  # Pre-flight: where do new tracks go, and which scene?
192
- session = ableton.send_command("get_session_info", {})
198
+ session = await ableton.send_command_async("get_session_info", {})
193
199
  starting_track_count = int(session.get("track_count", 0))
194
200
  scene_count = int(session.get("scene_count", 0))
195
201
 
196
202
  # Optional tempo override
197
203
  if plan.get("tempo"):
198
204
  try:
199
- ableton.send_command("set_tempo", {"tempo": float(plan["tempo"])})
205
+ await ableton.send_command_async("set_tempo", {"tempo": float(plan["tempo"])})
200
206
  except Exception as exc:
201
207
  logger.debug("apply: set_tempo failed: %s", exc)
202
208
 
@@ -216,7 +222,7 @@ async def apply_fast_plan(
216
222
  # returns ("A-Reverb") instead of remembering integer send indices.
217
223
  return_name_to_send_index: dict[str, int] = {}
218
224
  try:
219
- returns_resp = ableton.send_command("get_return_tracks", {}) or {}
225
+ returns_resp = await ableton.send_command_async("get_return_tracks", {}) or {}
220
226
  for i, rt in enumerate(returns_resp.get("return_tracks", []) or []):
221
227
  name = (rt.get("name") or "").strip()
222
228
  if name:
@@ -243,7 +249,7 @@ async def apply_fast_plan(
243
249
 
244
250
  new_track_idx = starting_track_count + len(new_track_indices)
245
251
  try:
246
- ableton.send_command("create_midi_track", {"index": -1, "name": track_name})
252
+ await ableton.send_command_async("create_midi_track", {"index": -1, "name": track_name})
247
253
  except Exception as exc:
248
254
  logger.warning("apply: create_midi_track(%s) failed: %s", track_name, exc)
249
255
  layer_results.append({
@@ -263,14 +269,14 @@ async def apply_fast_plan(
263
269
  load_params: dict = {"track_index": new_track_idx, "uri": uri}
264
270
  if simpler_role:
265
271
  load_params["role"] = simpler_role
266
- ableton.send_command("load_browser_item", load_params)
272
+ await ableton.send_command_async("load_browser_item", load_params)
267
273
  loaded = True
268
274
  except Exception as exc:
269
275
  logger.debug("apply: load_browser_item(%s, %s) failed: %s", new_track_idx, uri, exc)
270
276
 
271
277
  if loaded:
272
278
  # v1.24 Phase 4 Task 18b: detect empty containers post-load
273
- is_silent, silent_reason = _detect_silent_load(ableton, new_track_idx, device_index=0)
279
+ is_silent, silent_reason = await _detect_silent_load(ableton, new_track_idx, device_index=0)
274
280
  if is_silent:
275
281
  silent_load_warning = silent_reason
276
282
  logger.warning(
@@ -282,7 +288,7 @@ async def apply_fast_plan(
282
288
  # load_browser_item role='drum' silently skips Vol/Snap/root fixes
283
289
  # when the track already has FX. Re-apply deterministically.
284
290
  if _is_drum_role(role):
285
- role_repair = _apply_drum_role_repair(ableton, new_track_idx, device_index=0)
291
+ role_repair = await _apply_drum_role_repair(ableton, new_track_idx, device_index=0)
286
292
  if not role_repair.get("applied"):
287
293
  logger.debug(
288
294
  "apply: drum role repair failed for track %s: %s",
@@ -301,7 +307,7 @@ async def apply_fast_plan(
301
307
  clip_length_beats = max(bars * 4, int(max_end) + 1) if notes else bars * 4
302
308
 
303
309
  try:
304
- ableton.send_command("create_clip", {
310
+ await ableton.send_command_async("create_clip", {
305
311
  "track_index": new_track_idx,
306
312
  "clip_index": target_scene,
307
313
  "length": float(clip_length_beats),
@@ -317,7 +323,7 @@ async def apply_fast_plan(
317
323
  notes_added = 0
318
324
  if notes:
319
325
  try:
320
- ableton.send_command("add_notes", {
326
+ await ableton.send_command_async("add_notes", {
321
327
  "track_index": new_track_idx,
322
328
  "clip_index": target_scene,
323
329
  "notes": notes,
@@ -336,7 +342,7 @@ async def apply_fast_plan(
336
342
  if not device_name:
337
343
  continue
338
344
  try:
339
- ins_resp = ableton.send_command("insert_device", {
345
+ ins_resp = await ableton.send_command_async("insert_device", {
340
346
  "track_index": new_track_idx,
341
347
  "device_name": device_name,
342
348
  }) or {}
@@ -346,7 +352,7 @@ async def apply_fast_plan(
346
352
  if device_index is not None:
347
353
  for pname, pvalue in (fx.get("params") or {}).items():
348
354
  try:
349
- ableton.send_command("set_device_parameter", {
355
+ await ableton.send_command_async("set_device_parameter", {
350
356
  "track_index": new_track_idx,
351
357
  "device_index": int(device_index),
352
358
  "parameter_name": str(pname),
@@ -396,7 +402,7 @@ async def apply_fast_plan(
396
402
  })
397
403
  continue
398
404
  try:
399
- ableton.send_command("set_track_send", {
405
+ await ableton.send_command_async("set_track_send", {
400
406
  "track_index": new_track_idx,
401
407
  "send_index": int(send_index),
402
408
  "value": value,
@@ -446,7 +452,7 @@ async def apply_fast_plan(
446
452
  # Fire the scene
447
453
  fired = False
448
454
  try:
449
- ableton.send_command("fire_scene", {"scene_index": target_scene})
455
+ await ableton.send_command_async("fire_scene", {"scene_index": target_scene})
450
456
  fired = True
451
457
  except Exception as exc:
452
458
  logger.warning("apply: fire_scene(%s) failed: %s", target_scene, exc)
@@ -454,7 +460,7 @@ async def apply_fast_plan(
454
460
  # Final fresh-project cleanup: delete the leftover default track if
455
461
  # the brief left one in place to satisfy Ableton's "≥1 track" guard.
456
462
  final_cleanup_actions: list[str] = []
457
- new_session = ableton.send_command("get_session_info", {})
463
+ new_session = await ableton.send_command_async("get_session_info", {})
458
464
  final_tracks = new_session.get("tracks", []) or []
459
465
  # If track 0 is still default-named and empty, AND we just added new
460
466
  # tracks, prune it now (we have ≥2 tracks total, safe to delete).
@@ -462,9 +468,9 @@ async def apply_fast_plan(
462
468
  first = final_tracks[0]
463
469
  if fast_compose.is_default_track_name(first.get("name", "")):
464
470
  try:
465
- ti0 = ableton.send_command("get_track_info", {"track_index": 0})
471
+ ti0 = await ableton.send_command_async("get_track_info", {"track_index": 0})
466
472
  if fast_compose.track_is_empty(ti0):
467
- ableton.send_command("delete_track", {"track_index": 0})
473
+ await ableton.send_command_async("delete_track", {"track_index": 0})
468
474
  final_cleanup_actions.append("deleted_leftover_default_track")
469
475
  # All track indices shift down by 1
470
476
  new_track_indices = [i - 1 for i in new_track_indices]
@@ -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"
@@ -541,8 +541,23 @@ class AtlasResolver:
541
541
  @staticmethod
542
542
  def _to_candidate(dev: dict, score: float, reasoning: str, *, source: str) -> AtlasCandidate:
543
543
  char_tags = list(dev.get("character_tags") or dev.get("tags") or [])
544
+ uri = dev.get("uri") or ""
545
+ # LIVE#3: M4L pack instruments carry a bogus `query:Synths#<Name>` atlas
546
+ # URI that load_browser_item can't resolve. Clear it for every resolver
547
+ # consumer (compose-full design / atlas_explore included) and note the
548
+ # preset fallback. Lazy import of the canonical ID set keeps a single
549
+ # source of truth without a module-load circular import.
550
+ try:
551
+ from mcp_server.atlas.tools import _M4L_PACK_SYNTH_IDS
552
+ if (dev.get("id") or "").lower() in _M4L_PACK_SYNTH_IDS and uri.startswith("query:Synths#"):
553
+ uri = ""
554
+ hint = ("M4L pack instrument — not directly loadable via "
555
+ "query:Synths#; resolve via search_browser(path='sounds').")
556
+ reasoning = (reasoning + " | " + hint) if reasoning else hint
557
+ except Exception:
558
+ pass
544
559
  return AtlasCandidate(
545
- uri=dev.get("uri") or "",
560
+ uri=uri,
546
561
  name=dev.get("name") or "",
547
562
  source=source,
548
563
  score=score,
@@ -329,7 +329,7 @@ async def apply_full_plan(
329
329
  )
330
330
 
331
331
  # ── Detect + clean default tracks ──────────────────────────────────
332
- session = ableton.send_command("get_session_info", {})
332
+ session = await ableton.send_command_async("get_session_info", {})
333
333
  starting_track_count = int(session.get("track_count", 0))
334
334
 
335
335
  fresh_project = fast_compose.detect_fresh_project(session)
@@ -337,7 +337,7 @@ async def apply_full_plan(
337
337
  candidates: list[int] = []
338
338
  for i in range(starting_track_count):
339
339
  try:
340
- ti = ableton.send_command("get_track_info", {"track_index": i})
340
+ ti = await ableton.send_command_async("get_track_info", {"track_index": i})
341
341
  if fast_compose.track_is_empty(ti):
342
342
  candidates.append(i)
343
343
  except Exception as exc:
@@ -350,7 +350,7 @@ async def apply_full_plan(
350
350
  deleted = 0
351
351
  for idx in deletable:
352
352
  try:
353
- ableton.send_command("delete_track", {"track_index": idx})
353
+ await ableton.send_command_async("delete_track", {"track_index": idx})
354
354
  deleted += 1
355
355
  except Exception as exc:
356
356
  logger.debug("full apply: delete_track(%s) failed: %s", idx, exc)
@@ -414,18 +414,22 @@ async def apply_full_plan(
414
414
  result = await _load_sample(ctx, **resolved_params)
415
415
  elif tool_name in ("create_midi_track", "create_audio_track"):
416
416
  # Track the index so postflight can set monitoring on them
417
- result = ableton.send_command(tool_name, resolved_params) or {}
417
+ result = await ableton.send_command_async(tool_name, resolved_params) or {}
418
418
  if ok and isinstance(result, dict):
419
- track_idx = result.get("track_index")
419
+ # create_midi_track/create_audio_track return {"index": N}
420
+ # from the real Remote Script, not {"track_index": N} —
421
+ # fall back to the legacy key for any mock that still
422
+ # uses it.
423
+ track_idx = result.get("index", result.get("track_index"))
420
424
  if track_idx is not None:
421
425
  created_track_indices.append(int(track_idx))
422
426
  elif tool_name in _FULL_PLAN_TCP_TOOLS:
423
427
  # Direct Remote-Script TCP command
424
- result = ableton.send_command(tool_name, resolved_params) or {}
428
+ result = await ableton.send_command_async(tool_name, resolved_params) or {}
425
429
  else:
426
430
  # Unknown tool — try generic TCP send (most LivePilot tools
427
431
  # have a 1:1 Remote-Script handler with the same name).
428
- result = ableton.send_command(tool_name, resolved_params) or {}
432
+ result = await ableton.send_command_async(tool_name, resolved_params) or {}
429
433
  except Exception as exc:
430
434
  ok = False
431
435
  err_msg = str(exc)
@@ -456,14 +460,14 @@ async def apply_full_plan(
456
460
  # invalidate the indices below.
457
461
  final_cleanup_actions: list[str] = []
458
462
  try:
459
- post_session = ableton.send_command("get_session_info", {})
463
+ post_session = await ableton.send_command_async("get_session_info", {})
460
464
  tracks = post_session.get("tracks", []) or []
461
465
  if tracks and len(tracks) > 1:
462
466
  default_indices: list[int] = []
463
467
  for i, t in enumerate(tracks):
464
468
  if fast_compose.is_default_track_name(t.get("name", "")):
465
469
  try:
466
- ti = ableton.send_command("get_track_info", {"track_index": i})
470
+ ti = await ableton.send_command_async("get_track_info", {"track_index": i})
467
471
  if fast_compose.track_is_empty(ti):
468
472
  default_indices.append(i)
469
473
  except Exception as exc:
@@ -475,7 +479,7 @@ async def apply_full_plan(
475
479
  if len(tracks) - len(final_cleanup_actions) <= 1:
476
480
  break
477
481
  try:
478
- ableton.send_command("delete_track", {"track_index": idx})
482
+ await ableton.send_command_async("delete_track", {"track_index": idx})
479
483
  final_cleanup_actions.append(f"deleted_leftover_default_track_at_{idx}")
480
484
  except Exception as exc:
481
485
  logger.debug("full apply: final cleanup delete_track(%s) failed: %s", idx, exc)
@@ -620,7 +624,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
620
624
  if ab is None:
621
625
  return {"ok": False}
622
626
  try:
623
- return ab.send_command(
627
+ return await ab.send_command_async(
624
628
  "set_track_input_monitoring",
625
629
  {"track_index": track_index, "state": state},
626
630
  )
@@ -646,7 +650,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
646
650
  fresh_cleanup_actions: list[str] = []
647
651
  try:
648
652
  from ..fast.brief_builder import detect_fresh_project, is_default_track_name
649
- session_preflight = ableton.send_command("get_session_info", {})
653
+ session_preflight = await ableton.send_command_async("get_session_info", {})
650
654
  if detect_fresh_project(session_preflight):
651
655
  # Delete default tracks in REVERSE order to keep indices stable.
652
656
  # Ableton requires at least 1 track — keep the lowest-indexed default.
@@ -662,7 +666,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
662
666
  if len(default_indices) > 1:
663
667
  for idx in default_indices[:-1]:
664
668
  try:
665
- ableton.send_command("delete_track", {"track_index": idx})
669
+ await ableton.send_command_async("delete_track", {"track_index": idx})
666
670
  fresh_cleanup_actions.append(f"deleted_default_track_{idx}")
667
671
  except Exception as exc:
668
672
  logger.debug("apply_full_v2: delete_track(%d) failed: %s", idx, exc)
@@ -674,15 +678,15 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
674
678
  plan_key = plan.get("key")
675
679
  if plan_tempo is not None:
676
680
  try:
677
- session = ableton.send_command("get_session_info", {})
681
+ session = await ableton.send_command_async("get_session_info", {})
678
682
  current_tempo = float(session.get("tempo", 0.0))
679
683
  if abs(current_tempo - float(plan_tempo)) > 0.01:
680
- ableton.send_command("set_tempo", {"tempo": float(plan_tempo)})
684
+ await ableton.send_command_async("set_tempo", {"tempo": float(plan_tempo)})
681
685
  except Exception as exc:
682
686
  logger.warning("apply_full_v2: tempo set failed: %s", exc)
683
687
  if plan_key:
684
688
  try:
685
- ableton.send_command("set_song_scale", {"root_note": plan_key})
689
+ await ableton.send_command_async("set_song_scale", {"root_note": plan_key})
686
690
  except Exception as exc:
687
691
  logger.debug("apply_full_v2: set_song_scale skipped: %s", exc)
688
692
 
@@ -702,7 +706,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
702
706
  track_index = track_spec.get("track_index")
703
707
  if track_index is None:
704
708
  try:
705
- result = ableton.send_command(
709
+ result = await ableton.send_command_async(
706
710
  "create_midi_track",
707
711
  {"index": -1, "name": track_spec.get("role", "")},
708
712
  )
@@ -733,7 +737,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
733
737
  instrument = track_spec.get("instrument") or {}
734
738
  if instrument.get("uri"):
735
739
  try:
736
- ableton.send_command(
740
+ await ableton.send_command_async(
737
741
  "load_browser_item",
738
742
  {"track_index": track_index, "uri": instrument["uri"]},
739
743
  )
@@ -754,7 +758,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
754
758
  track_role = track_spec.get("role", "")
755
759
  if _is_drum_role(track_role):
756
760
  try:
757
- _apply_drum_role_repair(ableton, track_index, device_index=0)
761
+ await _apply_drum_role_repair(ableton, track_index, device_index=0)
758
762
  except Exception as exc:
759
763
  logger.debug(
760
764
  "apply_full_v2: drum_role_repair failed for track %s: %s",
@@ -769,7 +773,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
769
773
  if not device_name:
770
774
  continue
771
775
  try:
772
- ins_resp = ableton.send_command("insert_device", {
776
+ ins_resp = await ableton.send_command_async("insert_device", {
773
777
  "track_index": track_index,
774
778
  "device_name": device_name,
775
779
  }) or {}
@@ -778,7 +782,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
778
782
  if device_index is None:
779
783
  # Fallback: query track and take the last device
780
784
  try:
781
- track_info = ableton.send_command(
785
+ track_info = await ableton.send_command_async(
782
786
  "get_track_info", {"track_index": track_index}
783
787
  )
784
788
  device_index = len(track_info.get("devices", [])) - 1
@@ -786,7 +790,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
786
790
  pass
787
791
  for param_name, param_value in (effect_spec.get("params") or {}).items():
788
792
  try:
789
- ableton.send_command("set_device_parameter", {
793
+ await ableton.send_command_async("set_device_parameter", {
790
794
  "track_index": track_index,
791
795
  "device_index": int(device_index),
792
796
  "parameter_name": str(param_name),
@@ -811,7 +815,9 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
811
815
  return_name = (send_spec.get("return_name") or "").strip()
812
816
  value = send_spec.get("value")
813
817
  send_index = send_spec.get("send_index")
814
- if return_name is None and send_index is None:
818
+ # return_name is always a str (post-.strip()), never None — the
819
+ # real "no destination given" case is an empty string.
820
+ if not return_name and send_index is None:
815
821
  continue
816
822
  try:
817
823
  value = float(value or 0.0)
@@ -819,7 +825,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
819
825
  continue
820
826
  if send_index is None and return_name:
821
827
  try:
822
- session_info = ableton.send_command("get_session_info", {})
828
+ session_info = await ableton.send_command_async("get_session_info", {})
823
829
  return_tracks = session_info.get("return_tracks", []) or []
824
830
  for i, rt in enumerate(return_tracks):
825
831
  if (rt.get("name") or "").lower() == return_name.lower():
@@ -840,7 +846,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
840
846
  })
841
847
  continue
842
848
  try:
843
- ableton.send_command("set_track_send", {
849
+ await ableton.send_command_async("set_track_send", {
844
850
  "track_index": track_index,
845
851
  "send_index": int(send_index),
846
852
  "value": value,
@@ -922,18 +928,18 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
922
928
  slot = vi
923
929
  variant_id_to_slot[variant["id"]] = slot
924
930
  try:
925
- ableton.send_command("create_clip", {
931
+ await ableton.send_command_async("create_clip", {
926
932
  "track_index": track_index,
927
933
  "clip_index": slot,
928
934
  "length": 4.0,
929
935
  })
930
936
  if variant.get("notes"):
931
- ableton.send_command("add_notes", {
937
+ await ableton.send_command_async("add_notes", {
932
938
  "track_index": track_index,
933
939
  "clip_index": slot,
934
940
  "notes": variant["notes"],
935
941
  })
936
- ableton.send_command("set_clip_name", {
942
+ await ableton.send_command_async("set_clip_name", {
937
943
  "track_index": track_index,
938
944
  "clip_index": slot,
939
945
  "name": variant["id"],
@@ -996,7 +1002,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
996
1002
  # NEW FLOW: create ONE native arrangement clip spanning the full
997
1003
  # section. Replaces create_arrangement_clip (session-clip duplication).
998
1004
  try:
999
- native_resp = ableton.send_command("create_native_arrangement_clip", {
1005
+ native_resp = await ableton.send_command_async("create_native_arrangement_clip", {
1000
1006
  "track_index": track_index,
1001
1007
  "start_time": section_start_beats,
1002
1008
  "length": section_length_beats,
@@ -1022,7 +1028,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
1022
1028
  # clip start — same coordinate space the agent used).
1023
1029
  if variant_notes:
1024
1030
  try:
1025
- ableton.send_command("add_arrangement_notes", {
1031
+ await ableton.send_command_async("add_arrangement_notes", {
1026
1032
  "track_index": track_index,
1027
1033
  "clip_index": new_clip_index,
1028
1034
  "notes": variant_notes,
@@ -1038,7 +1044,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
1038
1044
  # full section length (e.g. a 4-beat kick loops 16× in a 64-beat
1039
1045
  # verse section without requiring 64 beats of notes).
1040
1046
  try:
1041
- ableton.send_command("set_clip_loop", {
1047
+ await ableton.send_command_async("set_clip_loop", {
1042
1048
  "track_index": track_index,
1043
1049
  "clip_index": new_clip_index,
1044
1050
  "enabled": True,
@@ -1101,7 +1107,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
1101
1107
  # would survive cleanup.
1102
1108
  try:
1103
1109
  from ..fast.brief_builder import is_default_track_name as _is_default_track_name
1104
- session_post = ableton.send_command("get_session_info", {})
1110
+ session_post = await ableton.send_command_async("get_session_info", {})
1105
1111
  all_tracks = session_post.get("tracks", [])
1106
1112
  # Skip tracks we just created — only target leftover/zombie tracks
1107
1113
  compose_track_indices = set(applied_track_indices)
@@ -1120,7 +1126,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
1120
1126
  # Indicates a leftover from a previous compose run that the
1121
1127
  # default-name detector missed.
1122
1128
  try:
1123
- track_info = ableton.send_command("get_track_info", {"track_index": idx})
1129
+ track_info = await ableton.send_command_async("get_track_info", {"track_index": idx})
1124
1130
  devices = track_info.get("devices", []) or []
1125
1131
  clip_slots = track_info.get("clip_slots", []) or []
1126
1132
  has_instrument = any(
@@ -1140,7 +1146,7 @@ async def apply_full_plan_v2(ctx: Context, plan: dict) -> dict:
1140
1146
  # Delete in reverse-index order so indices stay stable
1141
1147
  for idx in sorted(candidate_indices, reverse=True):
1142
1148
  try:
1143
- ableton.send_command("delete_track", {"track_index": idx})
1149
+ await ableton.send_command_async("delete_track", {"track_index": idx})
1144
1150
  fresh_cleanup_actions.append(f"postflight_deleted_track_{idx}")
1145
1151
  except Exception as exc:
1146
1152
  logger.debug("apply_full_v2: postflight delete_track(%s) failed: %s", idx, exc)