livepilot 1.23.4 → 1.23.5

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,22 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.23.5 — 2026-04-30
4
+
5
+ ### Fixed (Remote Script reliability — credit: PR #35 reporter)
6
+
7
+ Two production-blocking bugs in the Remote Script that ship into the Ableton process. Reported by @juancarlosaxtro-hash in [PR #35](https://github.com/dreamrec/LivePilot/pull/35); reimplemented cleanly here with regression tests because the originally-submitted patch contained Python indentation errors that broke `transport.py` parse.
8
+
9
+ - **TCP server now replaces stale clients instead of rejecting new connections.** Previously, when the MCP server restarted uncleanly (e.g. user relaunched Claude Desktop), the Remote Script's `recv()` loop didn't notice the disconnect for up to a second. During that window, the legitimate reconnect attempt was rejected with `STATE_ERROR("Another client is already connected")` — often requiring a full Ableton restart to recover. New behavior: when the accept loop sees a new connection while one is "active", it closes the stale socket from the accept loop, joins the old client thread (with 2 s timeout, OUTSIDE the lock so the thread's `finally` block can acquire it), then accepts the new connection. Single-client architecture means a new connection is proof the old one is dead. New `_current_client` field tracks the active socket so the accept loop can kick it; the `_run_client_session` finally only nulls `_current_client` if it still points at the closing client (the accept loop may have already replaced it). (`remote_script/LivePilot/server.py`)
10
+ - **`get_session_info` no longer crashes on Group tracks.** `song.tracks` includes Group tracks, which raise a `RuntimeError` on `arm` / `has_midi_input` / `has_audio_input` access. `hasattr()` returns `True` regardless because Live's LOM doesn't use `AttributeError` — only try/except on the actual access works. Without the guard, any session with a Group track failed the entire session-info call with *"Main and Return Tracks have no 'Arm' state!"* Surgical try/except around the three LOM-fragile properties only; other fields (`name`, `color_index`, `mute`, `solo`) populate normally; the fragile ones return `None` on Group tracks. (`remote_script/LivePilot/transport.py`)
11
+
12
+ ### Tests
13
+ - `tests/test_remote_server_single_client.py` — rewrote `test_second_client_gets_explicit_state_error` → `test_second_client_replaces_stale_connection` for the new kick-stale semantics.
14
+ - `tests/test_remote_transport_group_tracks.py` — new file, 4 tests covering normal-track baseline, mixed Group+normal sessions, all-Group edge case, and non-fragile-field preservation. Hermetic loader stubs the Ableton-only `Live` module + `version_detect` helpers; autouse fixture restores `sys.modules` to prevent test pollution.
15
+ - Total: **3407 passing**, 1 skipped, 0 failed (up from 3403 in v1.23.4).
16
+
17
+ ### CI green check
18
+ - All 9 jobs green on commit `4aec8e6` (run `25174868624`): metadata-drift, js-entrypoint, amxd-freeze-drift, python-tests × {macos, ubuntu, windows} × {3.11, 3.12}.
19
+
3
20
  ## v1.23.4 — 2026-04-30
4
21
 
5
22
  ### Fixed (2026-04-30 live-test wave — 38 bugs surfaced, 36 fixed, 2 deferred to v1.23.5)
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.23.4";
37
+ var VERSION = "1.23.5";
38
38
 
39
39
  // ── State ──────────────────────────────────────────────────────────────────
40
40
 
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.23.4"
2
+ __version__ = "1.23.5"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.23.4",
3
+ "version": "1.23.5",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
5
  "description": "Agentic production system for Ableton Live 12 \u2014 453 tools, 54 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.23.4"
8
+ __version__ = "1.23.5"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
@@ -86,6 +86,12 @@ class LivePilotServer(object):
86
86
  self._command_queue = queue.Queue()
87
87
  self._client_lock = threading.Lock()
88
88
  self._client_connected = False
89
+ # Track the active client socket so we can close it from the accept
90
+ # loop when a new connection arrives. See _server_loop's kick-stale
91
+ # flow — without this, an unclean MCP-server restart leaves the
92
+ # Remote Script in a state where new connections get rejected until
93
+ # the old socket times out (often requiring an Ableton restart).
94
+ self._current_client = None
89
95
 
90
96
  # ── Public API ───────────────────────────────────────────────────────
91
97
 
@@ -138,30 +144,35 @@ class LivePilotServer(object):
138
144
  while self._running:
139
145
  try:
140
146
  client, addr = self._server_socket.accept()
147
+ # Single-client design: a new connection means the previous one
148
+ # is dead. Close the stale socket and join its thread (outside
149
+ # the lock so the thread's finally block can acquire it), then
150
+ # accept the new connection. Without this, the server could
151
+ # reject reconnections for up to 1s after an unclean MCP-server
152
+ # restart — the old recv() loop hadn't yet observed EOF.
153
+ stale_thread = None
154
+ stale_client = None
155
+ with self._client_lock:
156
+ if self._client_connected and self._current_client is not None:
157
+ stale_client = self._current_client
158
+ stale_thread = self._client_thread
159
+ self._log(
160
+ "Replacing stale client with new connection from %s:%d" % addr
161
+ )
162
+ if stale_client is not None:
163
+ try:
164
+ stale_client.close()
165
+ except OSError:
166
+ pass
167
+ if stale_thread is not None and stale_thread.is_alive():
168
+ # 2s is generous — the old recv() unblocks the moment we
169
+ # close the socket above, then the thread's finally block
170
+ # acquires the lock, resets _client_connected, and exits.
171
+ stale_thread.join(timeout=2)
172
+
141
173
  with self._client_lock:
142
- if self._client_connected:
143
- # Reject concurrent clients with an explicit message
144
- self._log("Rejected client from %s:%d (another client is connected)" % addr)
145
- try:
146
- reject = json.dumps({
147
- "id": "system",
148
- "ok": False,
149
- "error": {
150
- "code": "STATE_ERROR",
151
- "message": "Another client is already connected. "
152
- "LivePilot accepts one client at a time. "
153
- "Disconnect the current client first."
154
- }
155
- }) + "\n"
156
- client.sendall(reject.encode("utf-8"))
157
- except OSError:
158
- pass
159
- try:
160
- client.close()
161
- except OSError:
162
- pass
163
- continue
164
174
  self._client_connected = True
175
+ self._current_client = client
165
176
  self._client_thread = threading.Thread(
166
177
  target=self._run_client_session,
167
178
  args=(client, addr),
@@ -194,6 +205,11 @@ class LivePilotServer(object):
194
205
  pass
195
206
  with self._client_lock:
196
207
  self._client_connected = False
208
+ # Only clear _current_client if it still points at us — the
209
+ # accept loop may have already replaced us with a new client
210
+ # (in which case _current_client is the new socket).
211
+ if self._current_client is client:
212
+ self._current_client = None
197
213
  self._log("Client disconnected")
198
214
 
199
215
  def _handle_client(self, client):
@@ -11,16 +11,26 @@ def get_session_info(song, params):
11
11
  """Return comprehensive session state."""
12
12
  tracks_info = []
13
13
  for i, track in enumerate(song.tracks):
14
- tracks_info.append({
14
+ track_data = {
15
15
  "index": i,
16
16
  "name": track.name,
17
17
  "color_index": track.color_index,
18
- "has_midi_input": track.has_midi_input,
19
- "has_audio_input": track.has_audio_input,
20
18
  "mute": track.mute,
21
19
  "solo": track.solo,
22
- "arm": track.arm,
23
- })
20
+ }
21
+ # Group tracks (and any Return tracks that leak into song.tracks)
22
+ # don't expose `arm` / `has_midi_input` / `has_audio_input`. The
23
+ # Live Object Model raises a RuntimeError on access — and crucially
24
+ # `hasattr()` returns True regardless, so we must use try/except.
25
+ try:
26
+ track_data["arm"] = track.arm
27
+ track_data["has_midi_input"] = track.has_midi_input
28
+ track_data["has_audio_input"] = track.has_audio_input
29
+ except Exception:
30
+ track_data["arm"] = None
31
+ track_data["has_midi_input"] = None
32
+ track_data["has_audio_input"] = None
33
+ tracks_info.append(track_data)
24
34
 
25
35
  return_tracks_info = []
26
36
  for i, track in enumerate(song.return_tracks):
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.23.4",
9
+ "version": "1.23.5",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",