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 +17 -0
- package/m4l_device/LivePilot_Analyzer.amxd +0 -0
- package/m4l_device/livepilot_bridge.js +1 -1
- package/mcp_server/__init__.py +1 -1
- package/package.json +1 -1
- package/remote_script/LivePilot/__init__.py +1 -1
- package/remote_script/LivePilot/server.py +38 -22
- package/remote_script/LivePilot/transport.py +15 -5
- package/server.json +1 -1
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.
|
|
37
|
+
var VERSION = "1.23.5";
|
|
38
38
|
|
|
39
39
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
40
40
|
|
package/mcp_server/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
|
|
2
|
-
__version__ = "1.23.
|
|
2
|
+
__version__ = "1.23.5"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "livepilot",
|
|
3
|
-
"version": "1.23.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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):
|