livepilot 1.17.0 → 1.17.1

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,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.17.1 — Splice auto-reconnect + Codex installer fix (April 23 2026)
4
+
5
+ Two bug fixes discovered in a parallel worktree hours after v1.17.0
6
+ shipped. Non-breaking, test-locked, ships as a patch.
7
+
8
+ ### Fixed
9
+
10
+ - **Splice client auto-reconnect** (`mcp_server/sample_engine/tools.py`):
11
+ Every Splice MCP tool now reconnects the shared gRPC client on demand
12
+ via a new `_ensure_splice_client_connected()` helper. Before this fix,
13
+ if the Splice desktop app launched AFTER the MCP server (common when
14
+ users start the MCP via Claude Code before booting Splice), every
15
+ Splice tool stayed stuck in a disconnected state until the WHOLE MCP
16
+ server was restarted. The fix re-checks on every tool invocation, so
17
+ the first successful Splice-desktop boot auto-recovers the client
18
+ transparently. Tools affected: `get_splice_credits`,
19
+ `splice_catalog_hunt`, `search_samples` (when routing through Splice),
20
+ plus every other Splice tool that reads from the shared context.
21
+ 3 new regression tests in `tests/test_splice_reconnect_tools.py` lock
22
+ the reconnect behavior.
23
+ - **Codex plugin installer writes `.mcp.json`** (`installer/codex.js`):
24
+ The installer was copying plugin files into the Codex plugins
25
+ directory but omitting the `.mcp.json` config that tells Codex how
26
+ to launch the MCP server. Codex users had to manually create the
27
+ file or run the command with additional flags. Now
28
+ `writeLocalMcpConfig(destDir)` writes the correct
29
+ `{mcpServers: {livepilot: {command, args}}}` shape during install.
30
+ 1 new regression test in `tests/test_codex_plugin_installer.py`
31
+ asserts the file content.
32
+
33
+ ### Verified
34
+
35
+ 155/155 tests green. `sync_metadata.py --check`: all metadata in sync
36
+ at version=1.17.1, tools=426, domains=52, bridge_cmds=30, enriched=120.
37
+
38
+ ### Distribution
39
+
40
+ Same channels as v1.17.0 — GitHub release + npm + `.mcpb` + plugin
41
+ cache. Tool count and domain count unchanged; this is purely a
42
+ reliability patch.
43
+
44
+
3
45
  ## 1.17.0 — 2026-04-22 handoff close-out (April 22 2026, late)
4
46
 
5
47
  Closes every item in the 2026-04-22 handoff document: Splice's
@@ -42,6 +42,19 @@ function loadManifest() {
42
42
  return JSON.parse(fs.readFileSync(SOURCE_MANIFEST, "utf-8"));
43
43
  }
44
44
 
45
+ function writeLocalMcpConfig(destDir) {
46
+ const file = path.join(destDir, ".mcp.json");
47
+ const config = {
48
+ mcpServers: {
49
+ livepilot: {
50
+ command: process.execPath,
51
+ args: [path.join(ROOT, "bin", "livepilot.js")],
52
+ },
53
+ },
54
+ };
55
+ fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
56
+ }
57
+
45
58
  function ensureMarketplace(pluginName) {
46
59
  const file = marketplacePath();
47
60
  let marketplace = {
@@ -115,6 +128,7 @@ function installCodexPlugin() {
115
128
  fs.mkdirSync(path.dirname(destDir), { recursive: true });
116
129
  fs.rmSync(destDir, { recursive: true, force: true });
117
130
  copyDirSync(SOURCE_DIR, destDir);
131
+ writeLocalMcpConfig(destDir);
118
132
 
119
133
  console.log("Done! Next steps:");
120
134
  console.log(" 1. Open or refresh Codex");
Binary file
@@ -95,7 +95,7 @@ function anything() {
95
95
  function dispatch(cmd, args) {
96
96
  switch(cmd) {
97
97
  case "ping":
98
- send_response({"ok": true, "version": "1.16.1"});
98
+ send_response({"ok": true, "version": "1.17.1"});
99
99
  break;
100
100
  case "get_params":
101
101
  cmd_get_params(args);
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.17.0"
2
+ __version__ = "1.17.1"
@@ -259,14 +259,10 @@ async def search_samples(
259
259
  # Splice search — prefer gRPC online catalog when available, fall back
260
260
  # to local SQLite index. See docs/2026-04-14-bugs-discovered.md — P0-2.
261
261
  if source in (None, "splice"):
262
- grpc_client = None
263
- try:
264
- grpc_client = ctx.lifespan_context.get("splice_client")
265
- except AttributeError:
266
- grpc_client = None
262
+ grpc_client = await _ensure_splice_client_connected(ctx)
267
263
 
268
264
  used_grpc = False
269
- if grpc_client is not None and getattr(grpc_client, "connected", False):
265
+ if grpc_client is not None:
270
266
  try:
271
267
  grpc_result = await grpc_client.search_samples(
272
268
  query=query,
@@ -712,6 +708,43 @@ _SPLICE_USER_LIB_DEST = "~/Music/Ableton/User Library/Samples/Splice"
712
708
  _SPLICE_PREVIEW_CACHE = "~/Library/Caches/LivePilot/splice_previews"
713
709
 
714
710
 
711
+ def _get_splice_client_from_context(ctx: Context):
712
+ """Return the shared Splice client from lifespan context when present."""
713
+ try:
714
+ return ctx.lifespan_context.get("splice_client")
715
+ except AttributeError:
716
+ return None
717
+
718
+
719
+ async def _ensure_splice_client_connected(ctx: Context):
720
+ """Reconnect the shared Splice client on demand.
721
+
722
+ The MCP server creates one long-lived client during startup. If that
723
+ first handshake races or Splice launches later, the old behavior kept
724
+ every tool stuck in a disconnected state until the whole MCP server
725
+ restarted. Re-check here so tool results reflect current desktop state.
726
+ """
727
+ client = _get_splice_client_from_context(ctx)
728
+ if client is None:
729
+ return None
730
+ if getattr(client, "connected", False):
731
+ return client
732
+
733
+ connect = getattr(client, "connect", None)
734
+ if connect is None:
735
+ return None
736
+
737
+ try:
738
+ await connect()
739
+ except Exception as exc:
740
+ logger.debug("Splice reconnect failed: %s", exc)
741
+ return None
742
+
743
+ if getattr(client, "connected", False):
744
+ return client
745
+ return None
746
+
747
+
715
748
  @mcp.tool()
716
749
  async def get_splice_credits(ctx: Context) -> dict:
717
750
  """Get the user's current Splice plan, credits, and daily sample quota.
@@ -745,15 +778,10 @@ async def get_splice_credits(ctx: Context) -> dict:
745
778
  from ..splice_client.models import PlanKind
746
779
  from ..splice_client.quota import get_tracker
747
780
 
748
- client = None
749
- try:
750
- client = ctx.lifespan_context.get("splice_client")
751
- except AttributeError:
752
- pass
753
-
754
781
  quota_summary = get_tracker().summary()
782
+ client = await _ensure_splice_client_connected(ctx)
755
783
 
756
- if client is None or not getattr(client, "connected", False):
784
+ if client is None:
757
785
  return {
758
786
  "connected": False,
759
787
  "username": "",
@@ -857,13 +885,8 @@ async def splice_catalog_hunt(
857
885
  Each sample entry contains `file_hash` which you can pass to
858
886
  `splice_download_sample` to trigger a download.
859
887
  """
860
- client = None
861
- try:
862
- client = ctx.lifespan_context.get("splice_client")
863
- except AttributeError:
864
- pass
865
-
866
- if client is None or not getattr(client, "connected", False):
888
+ client = await _ensure_splice_client_connected(ctx)
889
+ if client is None:
867
890
  return {
868
891
  "connected": False,
869
892
  "error": "Splice gRPC not connected",
@@ -970,13 +993,8 @@ async def splice_download_sample(
970
993
  """
971
994
  import shutil
972
995
 
973
- client = None
974
- try:
975
- client = ctx.lifespan_context.get("splice_client")
976
- except AttributeError:
977
- pass
978
-
979
- if client is None or not getattr(client, "connected", False):
996
+ client = await _ensure_splice_client_connected(ctx)
997
+ if client is None:
980
998
  return {
981
999
  "ok": False,
982
1000
  "error": "Splice gRPC not connected",
@@ -1096,13 +1114,8 @@ async def splice_preview_sample(
1096
1114
  import urllib.request
1097
1115
  import urllib.error
1098
1116
 
1099
- client = None
1100
- try:
1101
- client = ctx.lifespan_context.get("splice_client")
1102
- except AttributeError:
1103
- pass
1104
-
1105
- if client is None or not getattr(client, "connected", False):
1117
+ client = await _ensure_splice_client_connected(ctx)
1118
+ if client is None:
1106
1119
  return {"ok": False, "error": "Splice gRPC not connected"}
1107
1120
 
1108
1121
  # Two-stage lookup: SampleInfo is the fast path but only returns
@@ -1184,14 +1197,10 @@ async def splice_preview_sample(
1184
1197
  # ────────────────────────────────────────────────────────────────────────
1185
1198
 
1186
1199
 
1187
- def _require_splice_client(ctx: Context) -> tuple[object, Optional[dict]]:
1200
+ async def _require_splice_client(ctx: Context) -> tuple[object, Optional[dict]]:
1188
1201
  """Fetch the Splice client from context, or return an error dict."""
1189
- client = None
1190
- try:
1191
- client = ctx.lifespan_context.get("splice_client")
1192
- except AttributeError:
1193
- pass
1194
- if client is None or not getattr(client, "connected", False):
1202
+ client = await _ensure_splice_client_connected(ctx)
1203
+ if client is None:
1195
1204
  return None, {"ok": False, "error": "Splice gRPC not connected"}
1196
1205
  return client, None
1197
1206
 
@@ -1215,7 +1224,7 @@ async def splice_list_collections(
1215
1224
  ],
1216
1225
  }
1217
1226
  """
1218
- client, err = _require_splice_client(ctx)
1227
+ client, err = await _require_splice_client(ctx)
1219
1228
  if err:
1220
1229
  return err
1221
1230
  total, collections = await client.list_collections(
@@ -1244,7 +1253,7 @@ async def splice_search_in_collection(
1244
1253
  `splice_catalog_hunt` — you can feed them straight into
1245
1254
  `splice_preview_sample` or `splice_download_sample`.
1246
1255
  """
1247
- client, err = _require_splice_client(ctx)
1256
+ client, err = await _require_splice_client(ctx)
1248
1257
  if err:
1249
1258
  return err
1250
1259
  total, samples = await client.collection_samples(
@@ -1271,7 +1280,7 @@ async def splice_add_to_collection(
1271
1280
  and web UI immediately. Use this to let LivePilot "save for later"
1272
1281
  items it finds during composition work.
1273
1282
  """
1274
- client, err = _require_splice_client(ctx)
1283
+ client, err = await _require_splice_client(ctx)
1275
1284
  if err:
1276
1285
  return err
1277
1286
  if not file_hashes:
@@ -1289,7 +1298,7 @@ async def splice_remove_from_collection(
1289
1298
  ctx: Context, collection_uuid: str, file_hashes: list[str],
1290
1299
  ) -> dict:
1291
1300
  """Remove one or more samples from a user Collection (server-side)."""
1292
- client, err = _require_splice_client(ctx)
1301
+ client, err = await _require_splice_client(ctx)
1293
1302
  if err:
1294
1303
  return err
1295
1304
  if not file_hashes:
@@ -1307,7 +1316,7 @@ async def splice_remove_from_collection(
1307
1316
  @mcp.tool()
1308
1317
  async def splice_create_collection(ctx: Context, name: str) -> dict:
1309
1318
  """Create a new user Collection. Returns the new UUID on success."""
1310
- client, err = _require_splice_client(ctx)
1319
+ client, err = await _require_splice_client(ctx)
1311
1320
  if err:
1312
1321
  return err
1313
1322
  name = (name or "").strip()
@@ -1347,7 +1356,7 @@ async def splice_list_presets(
1347
1356
  ],
1348
1357
  }
1349
1358
  """
1350
- client, err = _require_splice_client(ctx)
1359
+ client, err = await _require_splice_client(ctx)
1351
1360
  if err:
1352
1361
  return err
1353
1362
  total, presets = await client.list_purchased_presets(
@@ -1371,7 +1380,7 @@ async def splice_preset_info(
1371
1380
  plugin_name: str = "",
1372
1381
  ) -> dict:
1373
1382
  """Fetch metadata for a single preset (uuid, file_hash, or plugin_name)."""
1374
- client, err = _require_splice_client(ctx)
1383
+ client, err = await _require_splice_client(ctx)
1375
1384
  if err:
1376
1385
  return err
1377
1386
  if not (uuid or file_hash or plugin_name):
@@ -1394,7 +1403,7 @@ async def splice_download_preset(ctx: Context, uuid: str) -> dict:
1394
1403
  """
1395
1404
  from ..splice_client.client import CREDIT_HARD_FLOOR
1396
1405
 
1397
- client, err = _require_splice_client(ctx)
1406
+ client, err = await _require_splice_client(ctx)
1398
1407
  if err:
1399
1408
  return err
1400
1409
  if not uuid:
@@ -1437,7 +1446,7 @@ async def splice_pack_info(ctx: Context, pack_uuid: str) -> dict:
1437
1446
  Useful for discovering related samples by pack, or surfacing pack-level
1438
1447
  genre/provider info that search results omit.
1439
1448
  """
1440
- client, err = _require_splice_client(ctx)
1449
+ client, err = await _require_splice_client(ctx)
1441
1450
  if err:
1442
1451
  return err
1443
1452
  if not pack_uuid:
@@ -1469,19 +1478,15 @@ async def splice_pack_info(ctx: Context, pack_uuid: str) -> dict:
1469
1478
  # ────────────────────────────────────────────────────────────────────────
1470
1479
 
1471
1480
 
1472
- def _build_http_bridge(ctx: Context):
1481
+ async def _build_http_bridge(ctx: Context):
1473
1482
  """Construct the HTTPS bridge with the current gRPC client attached.
1474
1483
 
1475
1484
  Returns (bridge, err_dict). On success err_dict is None.
1476
1485
  """
1477
1486
  from ..splice_client.http_bridge import SpliceHTTPBridge
1478
1487
 
1479
- client = None
1480
- try:
1481
- client = ctx.lifespan_context.get("splice_client")
1482
- except AttributeError:
1483
- pass
1484
- if client is None or not getattr(client, "connected", False):
1488
+ client = await _ensure_splice_client_connected(ctx)
1489
+ if client is None:
1485
1490
  return None, {
1486
1491
  "ok": False,
1487
1492
  "error": "Splice gRPC not connected — session token unreachable",
@@ -1520,7 +1525,7 @@ async def splice_describe_sound(
1520
1525
  instrument/tags/pack_name/files. Use the uuid with
1521
1526
  `splice_download_sample(uuid)` to pull the audio file.
1522
1527
  """
1523
- bridge, err = _build_http_bridge(ctx)
1528
+ bridge, err = await _build_http_bridge(ctx)
1524
1529
  if err:
1525
1530
  return err
1526
1531
  from ..splice_client.http_bridge import SpliceHTTPError
@@ -1572,7 +1577,7 @@ async def splice_generate_variation(
1572
1577
  duration/tags/pack_name/files). Use the uuid of any result with
1573
1578
  `splice_download_sample()` to pull the audio.
1574
1579
  """
1575
- bridge, err = _build_http_bridge(ctx)
1580
+ bridge, err = await _build_http_bridge(ctx)
1576
1581
  if err:
1577
1582
  return err
1578
1583
  from ..splice_client.http_bridge import SpliceHTTPError
@@ -1631,12 +1636,8 @@ async def splice_http_diagnose(ctx: Context) -> dict:
1631
1636
  # diagnostic is worse than no diagnostic.
1632
1637
  session_token_available = False
1633
1638
  session_token_error = None
1634
- grpc_client = None
1635
- try:
1636
- grpc_client = ctx.lifespan_context.get("splice_client")
1637
- except AttributeError:
1638
- pass
1639
- if grpc_client is None or not getattr(grpc_client, "connected", False):
1639
+ grpc_client = await _ensure_splice_client_connected(ctx)
1640
+ if grpc_client is None:
1640
1641
  session_token_error = "Splice gRPC not connected"
1641
1642
  else:
1642
1643
  # Connection is up; confirm a token actually comes back.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.17.0",
3
+ "version": "1.17.1",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
5
  "description": "Agentic production system for Ableton Live 12 — 426 tools, 52 domains. Device atlas (1305 devices), sample engine (Splice + browser + filesystem), auto-composition, spectral perception, technique memory, creative intelligence (12 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.17.0"
8
+ __version__ = "1.17.1"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from . import router
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/dreamrec/LivePilot",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.17.0",
9
+ "version": "1.17.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "livepilot",
14
- "version": "1.17.0",
14
+ "version": "1.17.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }