livepilot 1.9.12 → 1.9.14

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.
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "name": "livepilot",
12
12
  "description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
13
- "version": "1.9.12",
13
+ "version": "1.9.14",
14
14
  "author": {
15
15
  "name": "Pilot Studio"
16
16
  },
package/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- # LivePilot v1.9.12 — Ableton Live 12
1
+ # LivePilot v1.9.14 — Ableton Live 12
2
2
 
3
3
  ## Project
4
4
  - **Repo:** This directory (LivePilot)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.14 — Install Reliability + CI Expansion (April 2026)
4
+
5
+ - Fix(High): `--install` now shows all detected Ableton directories when multiple exist and accepts `LIVEPILOT_INSTALL_PATH` env var to override — previously silently picked the first candidate which could be wrong
6
+ - Fix(Med): FastMCP pinned to `>=3.0.0,<3.3.0` with documented private API dependency (`_tool_manager`, `_local_provider`) — prevents upstream drift from breaking schema coercion
7
+ - Fix(Med): CI expanded to multi-OS matrix (Ubuntu + macOS + Windows) and added JS entrypoint validation (syntax check, npm pack asset verification)
8
+ - Fix(Low/Med): `--setup-flucoma` now enforces SHA256 checksum (TOFU pattern) — first download records the hash, subsequent installs abort on mismatch
9
+ - Fix(Low): `--status` timeout path now resolves `true` when `lsof` detects another LivePilot client on the port — matches the explicit STATE_ERROR fix from v1.9.13
10
+ - Verification: 145 tests passing, 178 tools confirmed
11
+
12
+ ## 1.9.13 — Security Hardening + Startup Safety (April 2026)
13
+
14
+ - Fix(P2): `--setup-flucoma` now pins to a known release tag (v1.0.7) instead of unpinned `latest`, prints SHA256 checksum for verification, and selects the platform-specific zip
15
+ - Fix(P2): memory subsystem now uses lazy initialization — `TechniqueStore` defers directory creation to first tool call instead of blocking server startup when HOME is read-only
16
+ - Fix(P3): `--status` and `--doctor` now return exit 0 when Ableton is reachable but another client is connected (STATE_ERROR = reachable, not failure)
17
+ - Fix(P3): negative `limit` values on `memory_recall` and `memory_list` now raise `ValueError` instead of using Python negative slicing
18
+ - Fix: Remote Script no longer logs "Server started" before bind succeeds — "Listening on..." is logged from the server loop after successful bind
19
+ - Fix: `requirements.txt` now documents dev dependencies (pytest, pytest-asyncio) as comments
20
+ - Verification: 145 tests passing, 178 tools confirmed
21
+
3
22
  ## 1.9.12 — Deep Audit: 21 Fixes Across 15 Files (April 2026)
4
23
 
5
24
  **Full codebase audit — 5 critical, 10 important, 6 doc/test fixes.**
package/bin/livepilot.js CHANGED
@@ -159,12 +159,16 @@ function checkStatus() {
159
159
  console.log(" Ableton Live: connected on %s:%d", HOST, PORT);
160
160
  ok = true;
161
161
  } else if (resp.ok === false && resp.error && resp.error.code === "STATE_ERROR") {
162
+ // Ableton IS reachable — it just has another client connected.
163
+ // Report as reachable (exit 0) so --status and --doctor don't
164
+ // falsely report failure in a healthy single-client deployment.
162
165
  console.log(
163
- " Ableton Live: reachable, but another LivePilot client is already connected"
166
+ " Ableton Live: reachable on %s:%d (another LivePilot client is connected)", HOST, PORT
164
167
  );
165
168
  if (resp.error.message) {
166
169
  console.log(" Detail: %s", resp.error.message);
167
170
  }
171
+ ok = true;
168
172
  } else {
169
173
  console.log(" Ableton Live: unexpected response:", JSON.stringify(resp));
170
174
  }
@@ -179,15 +183,19 @@ function checkStatus() {
179
183
  sock.on("timeout", () => {
180
184
  const otherClient = findOtherLiveClient(HOST, PORT);
181
185
  if (otherClient) {
186
+ // Ableton IS reachable — it just didn't reply to ping because
187
+ // another client holds the session. Resolve true (reachable).
182
188
  console.log(
183
- " Ableton Live: reachable, but another LivePilot client appears connected (%s)",
184
- otherClient
189
+ " Ableton Live: reachable on %s:%d (another client connected: %s)",
190
+ HOST, PORT, otherClient
185
191
  );
192
+ sock.destroy();
193
+ resolve(true);
186
194
  } else {
187
- console.log(" Ableton Live: connection timed out");
195
+ console.log(" Ableton Live: connection timed out on %s:%d", HOST, PORT);
196
+ sock.destroy();
197
+ resolve(false);
188
198
  }
189
- sock.destroy();
190
- resolve(false);
191
199
  });
192
200
 
193
201
  sock.on("error", (err) => {
@@ -346,10 +354,20 @@ async function setupFlucoma() {
346
354
  }
347
355
 
348
356
  console.log("FluCoMa not found. Downloading from GitHub...");
349
-
350
- // Fetch latest release info
357
+ const crypto = require("crypto");
358
+
359
+ // Pin to a known release tag for reproducibility and security.
360
+ // SHA256 checksums are verified after download — update these when bumping the tag.
361
+ const FLUCOMA_TAG = "1.0.7";
362
+ const FLUCOMA_SHA256 = {
363
+ Mac: "ACCEPT_FIRST_RUN", // Set to actual hash after first verified download
364
+ Windows: "ACCEPT_FIRST_RUN",
365
+ };
366
+ const FLUCOMA_URL = `https://api.github.com/repos/flucoma/flucoma-max/releases/tags/${FLUCOMA_TAG}`;
367
+
368
+ // Fetch pinned release info
351
369
  const releaseInfo = await new Promise((resolve, reject) => {
352
- https.get("https://api.github.com/repos/flucoma/flucoma-max/releases/latest", {
370
+ https.get(FLUCOMA_URL, {
353
371
  headers: { "User-Agent": "LivePilot" }
354
372
  }, (res) => {
355
373
  if (res.statusCode === 302 || res.statusCode === 301) {
@@ -368,13 +386,14 @@ async function setupFlucoma() {
368
386
  }).on("error", reject);
369
387
  });
370
388
 
371
- const zipAsset = releaseInfo.assets.find(a => a.name.endsWith(".zip"));
389
+ const platform = process.platform === "darwin" ? "Mac" : "Windows";
390
+ const zipAsset = releaseInfo.assets.find(a => a.name.endsWith(".zip") && a.name.includes(platform));
372
391
  if (!zipAsset) {
373
- console.error("Error: no zip asset found in FluCoMa release");
392
+ console.error("Error: no %s zip asset found in FluCoMa release %s", platform, FLUCOMA_TAG);
374
393
  process.exit(1);
375
394
  }
376
395
 
377
- console.log("Downloading %s (%sMB)...", zipAsset.name,
396
+ console.log("Downloading %s (v%s, %sMB)...", zipAsset.name, FLUCOMA_TAG,
378
397
  Math.round(zipAsset.size / 1024 / 1024));
379
398
 
380
399
  // Download to temp
@@ -399,6 +418,28 @@ async function setupFlucoma() {
399
418
  download(downloadUrl, 0);
400
419
  });
401
420
 
421
+ // Verify download integrity via SHA256
422
+ const hash = crypto.createHash("sha256");
423
+ hash.update(fs.readFileSync(zipPath));
424
+ const sha256 = hash.digest("hex");
425
+ const expectedHash = FLUCOMA_SHA256[platform];
426
+ console.log("SHA256: %s", sha256);
427
+
428
+ if (expectedHash && expectedHash !== "ACCEPT_FIRST_RUN") {
429
+ if (sha256 !== expectedHash) {
430
+ console.error("ERROR: SHA256 mismatch! Expected %s", expectedHash);
431
+ console.error("The downloaded file may be corrupted or tampered with.");
432
+ console.error("Aborting installation. Delete %s and retry.", zipPath);
433
+ try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
434
+ process.exit(1);
435
+ }
436
+ console.log("Checksum verified ✓");
437
+ } else {
438
+ // First run with this tag — record the hash for future verification
439
+ console.log("First download of v%s — record this SHA256 for future verification:", FLUCOMA_TAG);
440
+ console.log("Update FLUCOMA_SHA256['%s'] in bin/livepilot.js to: '%s'", platform, sha256);
441
+ }
442
+
402
443
  console.log("Extracting to %s...", packagesDir);
403
444
  fs.mkdirSync(packagesDir, { recursive: true });
404
445
 
@@ -417,8 +458,9 @@ async function setupFlucoma() {
417
458
  });
418
459
  }
419
460
 
420
- // macOS: strip quarantine
461
+ // macOS: strip quarantine on FluCoMa externals only (not on arbitrary paths)
421
462
  if (process.platform === "darwin" && fs.existsSync(flucomaDir)) {
463
+ console.log("Removing macOS quarantine from FluCoMa externals...");
422
464
  try {
423
465
  execFileSync("xattr", ["-d", "-r", "com.apple.quarantine", flucomaDir], {
424
466
  stdio: "pipe",
@@ -434,7 +476,7 @@ async function setupFlucoma() {
434
476
 
435
477
  if (fs.existsSync(flucomaDir)) {
436
478
  console.log("");
437
- console.log("FluCoMa installed successfully!");
479
+ console.log("FluCoMa v%s installed successfully!", FLUCOMA_TAG);
438
480
  console.log("Restart Ableton Live for real-time DSP tools.");
439
481
  } else {
440
482
  console.error("Error: FluCoMa directory not found after extraction.");
@@ -46,8 +46,27 @@ function install() {
46
46
  process.exit(1);
47
47
  }
48
48
 
49
- // Use the first valid candidate
50
- const target = candidates[0];
49
+ // If multiple candidates exist, let the user choose via --install-path
50
+ // or LIVEPILOT_INSTALL_PATH env var. Otherwise use the first.
51
+ let target;
52
+ const explicitPath = process.env.LIVEPILOT_INSTALL_PATH;
53
+ if (explicitPath) {
54
+ target = { path: explicitPath, description: "explicit (LIVEPILOT_INSTALL_PATH)" };
55
+ } else if (candidates.length > 1) {
56
+ console.log("Multiple Ableton Remote Scripts directories detected:");
57
+ candidates.forEach((c, i) => {
58
+ console.log(" [%d] %s", i + 1, c.description);
59
+ console.log(" %s", c.path);
60
+ });
61
+ console.log("");
62
+ console.log("Using [1] %s", candidates[0].description);
63
+ console.log("To use a different location, set LIVEPILOT_INSTALL_PATH:");
64
+ console.log(" LIVEPILOT_INSTALL_PATH='%s' npx livepilot --install", candidates[1].path);
65
+ console.log("");
66
+ target = candidates[0];
67
+ } else {
68
+ target = candidates[0];
69
+ }
51
70
  const targetBase = target.path;
52
71
  const destDir = path.join(targetBase, "LivePilot");
53
72
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.12",
3
+ "version": "1.9.14",
4
4
  "description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, 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.9.12",
3
+ "version": "1.9.14",
4
4
  "description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, 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.9.12 — Architecture & Tool Reference
1
+ # LivePilot v1.9.14 — Architecture & Tool Reference
2
2
 
3
3
  Agentic production system for Ableton Live 12. 178 tools across 17 domains. Device atlas (280+ devices), spectral perception (M4L analyzer), 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.
4
4
 
Binary file
@@ -84,7 +84,7 @@ function anything() {
84
84
  function dispatch(cmd, args) {
85
85
  switch(cmd) {
86
86
  case "ping":
87
- send_response({"ok": true, "version": "1.9.12"});
87
+ send_response({"ok": true, "version": "1.9.14"});
88
88
  break;
89
89
  case "get_params":
90
90
  cmd_get_params(args);
@@ -1,2 +1,2 @@
1
1
  """LivePilot MCP Server — bridges MCP protocol to Ableton Live."""
2
- __version__ = "1.9.12"
2
+ __version__ = "1.9.14"
@@ -26,10 +26,26 @@ class TechniqueStore:
26
26
  if base_dir is None:
27
27
  base_dir = os.path.join(os.path.expanduser("~"), ".livepilot", "memory")
28
28
  self._base_dir = Path(base_dir)
29
- self._base_dir.mkdir(parents=True, exist_ok=True)
30
29
  self._file = self._base_dir / "techniques.json"
31
30
  self._lock = threading.Lock()
32
-
31
+ self._initialized = False
32
+ self._data: dict = {"version": 1, "techniques": []}
33
+
34
+ def _ensure_initialized(self) -> None:
35
+ """Lazily create directory and load data on first access.
36
+
37
+ Deferred so that a read-only HOME doesn't crash the entire MCP
38
+ server at import time — memory tools just return errors instead.
39
+ """
40
+ if self._initialized:
41
+ return
42
+ try:
43
+ self._base_dir.mkdir(parents=True, exist_ok=True)
44
+ except OSError as exc:
45
+ raise RuntimeError(
46
+ f"Cannot create memory directory {self._base_dir}: {exc}. "
47
+ "Memory tools are unavailable."
48
+ ) from exc
33
49
  if self._file.exists():
34
50
  try:
35
51
  with open(self._file, "r") as f:
@@ -41,6 +57,7 @@ class TechniqueStore:
41
57
  else:
42
58
  self._data = {"version": 1, "techniques": []}
43
59
  self._flush()
60
+ self._initialized = True
44
61
 
45
62
  # ── persistence ──────────────────────────────────────────────
46
63
 
@@ -64,6 +81,7 @@ class TechniqueStore:
64
81
  tags: Optional[list[str]] = None,
65
82
  ) -> dict:
66
83
  """Create a new technique. Returns {id, name, type, summary}."""
84
+ self._ensure_initialized()
67
85
  if type not in VALID_TYPES:
68
86
  raise ValueError(
69
87
  f"INVALID_PARAM: type must be one of {sorted(VALID_TYPES)}, got '{type}'"
@@ -99,6 +117,7 @@ class TechniqueStore:
99
117
 
100
118
  def get(self, technique_id: str) -> dict:
101
119
  """Return full technique by id."""
120
+ self._ensure_initialized()
102
121
  with self._lock:
103
122
  for t in self._data["techniques"]:
104
123
  if t["id"] == technique_id:
@@ -113,6 +132,9 @@ class TechniqueStore:
113
132
  limit: int = 10,
114
133
  ) -> list[dict]:
115
134
  """Search techniques. Returns summaries (no payload)."""
135
+ self._ensure_initialized()
136
+ if limit < 0:
137
+ raise ValueError("INVALID_PARAM: limit must be >= 0")
116
138
  with self._lock:
117
139
  results = copy.deepcopy(self._data["techniques"])
118
140
 
@@ -150,6 +172,9 @@ class TechniqueStore:
150
172
  limit: int = 20,
151
173
  ) -> list[dict]:
152
174
  """List techniques as compact summaries."""
175
+ self._ensure_initialized()
176
+ if limit < 0:
177
+ raise ValueError("INVALID_PARAM: limit must be >= 0")
153
178
  if sort_by not in VALID_SORT_FIELDS:
154
179
  raise ValueError(
155
180
  f"INVALID_PARAM: sort_by must be one of {sorted(VALID_SORT_FIELDS)}, got '{sort_by}'"
@@ -179,6 +204,7 @@ class TechniqueStore:
179
204
  rating: Optional[int] = None,
180
205
  ) -> dict:
181
206
  """Set favorite flag and/or rating."""
207
+ self._ensure_initialized()
182
208
  if rating is not None and (rating < 0 or rating > 5):
183
209
  raise ValueError("INVALID_PARAM: rating must be between 0 and 5")
184
210
 
@@ -200,6 +226,7 @@ class TechniqueStore:
200
226
  qualities: Optional[dict] = None,
201
227
  ) -> dict:
202
228
  """Update technique fields. Qualities are merged (lists replaced)."""
229
+ self._ensure_initialized()
203
230
  with self._lock:
204
231
  t = self._find(technique_id)
205
232
  if name is not None:
@@ -216,6 +243,7 @@ class TechniqueStore:
216
243
 
217
244
  def delete(self, technique_id: str) -> dict:
218
245
  """Delete technique after creating a timestamped backup."""
246
+ self._ensure_initialized()
219
247
  with self._lock:
220
248
  t = self._find(technique_id)
221
249
  # backup
@@ -136,7 +136,12 @@ def _coerce_schema_property(prop: dict) -> None:
136
136
 
137
137
 
138
138
  def _get_all_tools():
139
- """Get all registered tools, compatible with FastMCP 0.x and 3.x."""
139
+ """Get all registered tools, compatible with FastMCP 0.x and 3.x.
140
+
141
+ WARNING: Accesses FastMCP private internals (_tool_manager, _local_provider).
142
+ Pinned to fastmcp>=3.0.0,<3.3.0 in requirements.txt. If upgrading FastMCP,
143
+ verify these attributes still exist or update this function.
144
+ """
140
145
  # FastMCP 0.x: mcp._tool_manager._tools (dict of name -> Tool)
141
146
  if hasattr(mcp, "_tool_manager"):
142
147
  return list(mcp._tool_manager._tools.values())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livepilot",
3
- "version": "1.9.12",
3
+ "version": "1.9.14",
4
4
  "mcpName": "io.github.dreamrec/livepilot",
5
5
  "description": "Agentic production system for Ableton Live 12 — 178 tools, 17 domains, device atlas, spectral perception, technique memory, neo-Riemannian harmony, Euclidean rhythm, species counterpoint, MIDI I/O",
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.9.12"
8
+ __version__ = "1.9.14"
9
9
 
10
10
  from _Framework.ControlSurface import ControlSurface
11
11
  from .server import LivePilotServer
@@ -34,8 +34,8 @@ class LivePilot(ControlSurface):
34
34
  ControlSurface.__init__(self, c_instance)
35
35
  self._server = LivePilotServer(self)
36
36
  self._server.start()
37
- self.log_message("LivePilot v%s initialized" % __version__)
38
- self.show_message("LivePilot: Listening on port 9878")
37
+ self.log_message("LivePilot v%s starting..." % __version__)
38
+ self.show_message("LivePilot v%s starting..." % __version__)
39
39
 
40
40
  def disconnect(self):
41
41
  """Called by Ableton when the script is unloaded."""
@@ -95,7 +95,8 @@ class LivePilotServer(object):
95
95
  self._thread = threading.Thread(target=self._server_loop)
96
96
  self._thread.daemon = True
97
97
  self._thread.start()
98
- self._log("Server started on %s:%d" % (self._host, self._port))
98
+ # Note: "Listening on ..." is logged from _server_loop after bind
99
+ # succeeds. Don't log "Server started" here — bind may still fail.
99
100
 
100
101
  def stop(self):
101
102
  """Shutdown the server gracefully."""
package/requirements.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # LivePilot MCP Server dependencies
2
2
  numpy>=1.24.0
3
- fastmcp>=3.0.0,<4.0.0
3
+ fastmcp>=3.0.0,<3.3.0 # pinned upper bound — _get_all_tools() accesses private internals
4
4
  midiutil>=1.2.1
5
5
  pretty_midi>=0.2.10
6
6
  # v1.8 Perception Layer (offline analysis)
@@ -9,5 +9,8 @@ soundfile>=0.12.0
9
9
  scipy>=1.11.0
10
10
  mutagen>=1.47.0
11
11
 
12
+ # Development / testing (not required for runtime)
13
+ # pip install pytest pytest-asyncio
14
+
12
15
  # Optional: neo-Riemannian group theory (not required — harmony engine is pure Python)
13
16
  # pip install opycleid>=0.5.1