claude-music 1.0.0 → 1.1.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/README.md CHANGED
@@ -15,9 +15,20 @@ npm install -g claude-music # or: pnpm add -g claude-music
15
15
  claude-music install
16
16
  ```
17
17
 
18
- `claude-music install` downloads the prebuilt audio daemon and the model, registers
19
- the MCP server with Claude Code, and installs the `/music` command. **Restart Claude
20
- Code** afterwards so it loads the MCP server, then:
18
+ `claude-music install` downloads the prebuilt audio daemon and a model, registers
19
+ the MCP server with Claude Code, and installs the `/music` command. In a terminal it
20
+ prompts for which model to use; pass `--model small|base` to choose without a prompt:
21
+
22
+ ```
23
+ claude-music install --model small # ~0.5 GB, realtime on any Apple Silicon (default)
24
+ claude-music install --model base # ~2.8 GB, higher quality, realtime on M2 Pro+
25
+ ```
26
+
27
+ **Small** is the default — smallest download and realtime on any Apple Silicon, so it's
28
+ the safe pick for older Macs. **Base** sounds better and runs in realtime on M2 Pro and
29
+ above (and usually on M1 too). The ~1.4 GB of shared resources download once and are
30
+ reused by both, so switching models only re-downloads the model itself. **Restart Claude
31
+ Code** after installing so it loads the MCP server, then:
21
32
 
22
33
  ```
23
34
  /music # start — Claude picks an opening vibe
@@ -32,7 +43,7 @@ changes, and music stops when you end the session.
32
43
 
33
44
  - **Apple-Silicon Mac** (M-series) — required by MLX/Metal.
34
45
  - **Node ≥ 18** and the **`claude`** CLI (Claude Code).
35
- - ~1.3 GB of disk for the model, downloaded on first install.
46
+ - ~1.7 GB of disk for the model + resources, downloaded on first install.
36
47
 
37
48
  No Xcode, CMake, or Python needed — the daemon ships as a prebuilt binary. (If a
38
49
  prebuilt binary can't be fetched, `install` falls back to building from source,
@@ -1,25 +1,32 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { MODEL_FILES } from "../download.js";
3
- import { MODEL_FILE, RESOURCES_DIR, MAGENTA_HOME } from "../paths.js";
4
- describe("MODEL_FILES", () => {
5
- it("includes the compiled small model and its state", () => {
6
- expect(MODEL_FILES).toContain("models/mrt2_small/mrt2_small.mlxfn");
7
- expect(MODEL_FILES).toContain("models/mrt2_small/mrt2_small_state.safetensors");
2
+ import { RESOURCE_FILES, modelFiles } from "../download.js";
3
+ import { modelMlxfn, MAGENTA_HOME, RESOURCES_DIR } from "../paths.js";
4
+ describe("model + resource file lists", () => {
5
+ it("model files cover the compiled model and its state", () => {
6
+ expect(modelFiles("small")).toEqual([
7
+ "models/mrt2_small/mrt2_small.mlxfn",
8
+ "models/mrt2_small/mrt2_small_state.safetensors",
9
+ ]);
10
+ expect(modelFiles("base")).toEqual([
11
+ "models/mrt2_base/mrt2_base.mlxfn",
12
+ "models/mrt2_base/mrt2_base_state.safetensors",
13
+ ]);
8
14
  });
9
15
  it("includes all musiccoca and spectrostream resources", () => {
10
- const musiccoca = MODEL_FILES.filter((f) => f.startsWith("resources/musiccoca/"));
11
- const spectrostream = MODEL_FILES.filter((f) => f.startsWith("resources/spectrostream/"));
16
+ const musiccoca = RESOURCE_FILES.filter((f) => f.startsWith("resources/musiccoca/"));
17
+ const spectrostream = RESOURCE_FILES.filter((f) => f.startsWith("resources/spectrostream/"));
12
18
  expect(musiccoca).toHaveLength(6);
13
19
  expect(spectrostream).toHaveLength(4);
14
20
  });
15
- it("has no duplicate entries", () => {
16
- expect(new Set(MODEL_FILES).size).toBe(MODEL_FILES.length);
21
+ it("has no duplicate resource entries", () => {
22
+ expect(new Set(RESOURCE_FILES).size).toBe(RESOURCE_FILES.length);
17
23
  });
18
24
  });
19
25
  describe("paths", () => {
20
- it("derives the model + resources paths from the magenta home", () => {
21
- expect(MODEL_FILE.startsWith(MAGENTA_HOME)).toBe(true);
26
+ it("derives model paths under the magenta home from the model key", () => {
27
+ expect(modelMlxfn("small").startsWith(MAGENTA_HOME)).toBe(true);
28
+ expect(modelMlxfn("small").endsWith("models/mrt2_small/mrt2_small.mlxfn")).toBe(true);
29
+ expect(modelMlxfn("base").endsWith("models/mrt2_base/mrt2_base.mlxfn")).toBe(true);
22
30
  expect(RESOURCES_DIR.startsWith(MAGENTA_HOME)).toBe(true);
23
- expect(MODEL_FILE.endsWith("models/mrt2_small/mrt2_small.mlxfn")).toBe(true);
24
31
  });
25
32
  });
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { installShutdownHooks } from "../server.js";
4
+ describe("installShutdownHooks", () => {
5
+ it("stops the daemon when the stdio pipe closes (Claude Code exits)", () => {
6
+ const stop = vi.fn();
7
+ const exit = vi.fn();
8
+ const stdin = new EventEmitter();
9
+ installShutdownHooks(stop, { stdin, exit });
10
+ expect(stop).not.toHaveBeenCalled();
11
+ // Parent closed the pipe → stdin hits EOF.
12
+ stdin.emit("end");
13
+ expect(stop).toHaveBeenCalledOnce();
14
+ expect(exit).toHaveBeenCalledWith(0);
15
+ });
16
+ it("stops at most once even if several signals arrive", () => {
17
+ const stop = vi.fn();
18
+ const exit = vi.fn();
19
+ const stdin = new EventEmitter();
20
+ installShutdownHooks(stop, { stdin, exit });
21
+ stdin.emit("end");
22
+ stdin.emit("close");
23
+ expect(stop).toHaveBeenCalledOnce();
24
+ });
25
+ it("survives a throwing stop() without rethrowing", () => {
26
+ const stop = vi.fn(() => {
27
+ throw new Error("daemon already gone");
28
+ });
29
+ const exit = vi.fn();
30
+ const stdin = new EventEmitter();
31
+ installShutdownHooks(stop, { stdin, exit });
32
+ expect(() => stdin.emit("end")).not.toThrow();
33
+ expect(exit).toHaveBeenCalledWith(0);
34
+ });
35
+ });
package/dist/cli.js CHANGED
@@ -5,7 +5,9 @@ const USAGE = `claude-music — live AI background music for Claude Code
5
5
 
6
6
  Usage:
7
7
  claude-music install Download the daemon + model, register the MCP
8
- server, and install the /music command.
8
+ server, and install the /music command. Prompts
9
+ for a model on a terminal; pass --model small|base
10
+ to choose non-interactively (default: small).
9
11
  claude-music uninstall Remove the MCP registration and /music command.
10
12
  (add --purge to also delete the cached binary + model)
11
13
  claude-music doctor Check that everything is installed correctly.
@@ -22,7 +24,7 @@ async function main() {
22
24
  await runServer();
23
25
  return; // server keeps the process alive on the stdio transport
24
26
  case "install":
25
- await install();
27
+ await install(rest);
26
28
  break;
27
29
  case "uninstall":
28
30
  await uninstall(rest.includes("--purge"));
package/dist/download.js CHANGED
@@ -5,15 +5,12 @@ import * as path from "node:path";
5
5
  import { pipeline } from "node:stream/promises";
6
6
  import { Readable } from "node:stream";
7
7
  import { fileURLToPath } from "node:url";
8
- import { MAGENTA_HOME } from "./paths.js";
8
+ import { MAGENTA_HOME, MODELS } from "./paths.js";
9
9
  const HF_REPO = "google/magenta-realtime-2";
10
10
  const HF_BASE = `https://huggingface.co/${HF_REPO}/resolve/main`;
11
- // The complete set of files the mrt2d daemon needs at runtime: the compiled
12
- // small model + its state, and the shared musiccoca / spectrostream resources.
13
- // All are public on HuggingFace and download without auth.
14
- export const MODEL_FILES = [
15
- "models/mrt2_small/mrt2_small.mlxfn",
16
- "models/mrt2_small/mrt2_small_state.safetensors",
11
+ // Shared musiccoca / spectrostream resources, needed by every model. All are
12
+ // public on HuggingFace and download without auth.
13
+ export const RESOURCE_FILES = [
17
14
  "resources/musiccoca/audio_preprocessor.tflite",
18
15
  "resources/musiccoca/mapper.tflite",
19
16
  "resources/musiccoca/music_encoder.tflite",
@@ -25,6 +22,14 @@ export const MODEL_FILES = [
25
22
  "resources/spectrostream/quantizer.safetensors",
26
23
  "resources/spectrostream/spectrostream_encoder.mlxfn",
27
24
  ];
25
+ /** The two files specific to a given model: its compiled .mlxfn + state. */
26
+ export function modelFiles(key) {
27
+ const { name } = MODELS[key];
28
+ return [
29
+ `models/${name}/${name}.mlxfn`,
30
+ `models/${name}/${name}_state.safetensors`,
31
+ ];
32
+ }
28
33
  function humanBytes(n) {
29
34
  if (n >= 1 << 30)
30
35
  return `${(n / (1 << 30)).toFixed(2)} GiB`;
@@ -77,18 +82,19 @@ async function downloadFile(url, dest, label) {
77
82
  console.log(` ${label}: done (${humanBytes(received)})`);
78
83
  }
79
84
  /**
80
- * Download all model + resource files into MAGENTA_HOME, skipping any file that
81
- * already exists locally with the same size as the remote. Returns how many
82
- * files were actually fetched.
85
+ * Download the chosen model + the shared resources into MAGENTA_HOME, skipping
86
+ * any file that already exists locally with the same size as the remote.
87
+ * Returns how many files were actually fetched.
83
88
  */
84
- export async function downloadModel() {
89
+ export async function downloadModel(key) {
90
+ const files = [...modelFiles(key), ...RESOURCE_FILES];
85
91
  let fetched = 0;
86
- console.log(`==> Downloading model + resources to ${MAGENTA_HOME}`);
87
- for (let i = 0; i < MODEL_FILES.length; i++) {
88
- const rel = MODEL_FILES[i];
92
+ console.log(`==> Downloading ${MODELS[key].name} + resources to ${MAGENTA_HOME}`);
93
+ for (let i = 0; i < files.length; i++) {
94
+ const rel = files[i];
89
95
  const dest = path.join(MAGENTA_HOME, rel);
90
96
  const url = `${HF_BASE}/${rel}`;
91
- const tag = `[${i + 1}/${MODEL_FILES.length}] ${rel}`;
97
+ const tag = `[${i + 1}/${files.length}] ${rel}`;
92
98
  if (fs.existsSync(dest)) {
93
99
  const local = fs.statSync(dest).size;
94
100
  const remote = await remoteSize(url);
package/dist/install.js CHANGED
@@ -2,9 +2,10 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as os from "node:os";
4
4
  import * as path from "node:path";
5
+ import * as readline from "node:readline";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { downloadBinary, downloadModel } from "./download.js";
7
- import { BIN_DIR, CLAUDE_MUSIC_HOME, COMMANDS_DIR, DAEMON_BIN, MAGENTA_HOME, MODEL_FILE, MUSIC_COMMAND, RESOURCES_DIR, } from "./paths.js";
8
+ import { BIN_DIR, CLAUDE_MUSIC_HOME, COMMANDS_DIR, DAEMON_BIN, DEFAULT_MODEL, MAGENTA_HOME, modelMlxfn, MODELS, MUSIC_COMMAND, readConfig, RESOURCES_DIR, RESOURCES_MB, writeConfig, } from "./paths.js";
8
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
10
  const PKG_ROOT = path.resolve(__dirname, "..");
10
11
  const CLI_JS = path.join(__dirname, "cli.js");
@@ -182,18 +183,73 @@ function installCommand() {
182
183
  fs.copyFileSync(TEMPLATE_MUSIC, MUSIC_COMMAND);
183
184
  }
184
185
  // ---------------------------------------------------------------------------
186
+ // model selection
187
+ // ---------------------------------------------------------------------------
188
+ /** Parse a model choice from CLI args (`--model base|small`, `--base`, `--small`). */
189
+ function modelFromArgs(args) {
190
+ const i = args.indexOf("--model");
191
+ const val = i >= 0 ? args[i + 1] : undefined;
192
+ if (val === "base" || args.includes("--base"))
193
+ return "base";
194
+ if (val === "small" || args.includes("--small"))
195
+ return "small";
196
+ if (val !== undefined) {
197
+ throw new Error(`unknown --model '${val}' (expected 'small' or 'base')`);
198
+ }
199
+ return undefined;
200
+ }
201
+ function ask(question) {
202
+ const rl = readline.createInterface({
203
+ input: process.stdin,
204
+ output: process.stdout,
205
+ });
206
+ return new Promise((resolve) => rl.question(question, (a) => {
207
+ rl.close();
208
+ resolve(a.trim());
209
+ }));
210
+ }
211
+ /** Interactive model picker (only used on a TTY). */
212
+ async function promptModel() {
213
+ const totalSmall = ((MODELS.small.approxMB + RESOURCES_MB) / 1024).toFixed(1);
214
+ const totalBase = ((MODELS.base.approxMB + RESOURCES_MB) / 1024).toFixed(1);
215
+ console.log("\nWhich model would you like?");
216
+ console.log(` 1) ${MODELS.small.label} — ${MODELS.small.note}`);
217
+ console.log(` 2) ${MODELS.base.label} — ${MODELS.base.note}`);
218
+ console.log(` (total first-time download incl. shared resources: ` +
219
+ `~${totalSmall} GB small / ~${totalBase} GB base)`);
220
+ const answer = await ask("Choose [1]: ");
221
+ return answer === "2" || answer.toLowerCase() === "base" ? "base" : "small";
222
+ }
223
+ /**
224
+ * Resolve which model to install: an explicit flag wins; otherwise prompt on an
225
+ * interactive terminal; otherwise fall back to the default (keeps the
226
+ * one-command, non-interactive flow working in scripts/CI).
227
+ */
228
+ async function resolveModel(args) {
229
+ const flagged = modelFromArgs(args);
230
+ if (flagged)
231
+ return flagged;
232
+ if (process.stdin.isTTY && process.stdout.isTTY)
233
+ return promptModel();
234
+ return DEFAULT_MODEL;
235
+ }
236
+ // ---------------------------------------------------------------------------
185
237
  // entry points
186
238
  // ---------------------------------------------------------------------------
187
- export async function install() {
239
+ export async function install(args = []) {
188
240
  preflight();
241
+ const model = await resolveModel(args);
242
+ console.log(`==> Model: ${MODELS[model].name}`);
189
243
  await ensureBinary();
190
- await downloadModel();
244
+ await downloadModel(model);
245
+ writeConfig({ model });
191
246
  await registerMcp();
192
247
  installCommand();
193
248
  console.log("");
194
- console.log("claude-music installed.");
249
+ console.log(`claude-music installed (${MODELS[model].name}).`);
195
250
  console.log(" Restart Claude Code, then type /music to start. It steers itself as you");
196
251
  console.log(" work and stops when you end the session.");
252
+ console.log(" Switch models anytime: claude-music install --model small|base");
197
253
  }
198
254
  export async function uninstall(purge) {
199
255
  console.log("==> Removing mrt2-mcp registration...");
@@ -212,7 +268,9 @@ export async function doctor() {
212
268
  const platformOk = process.platform === "darwin" && process.arch === "arm64";
213
269
  const claudeOk = have("claude");
214
270
  const binOk = fs.existsSync(DAEMON_BIN);
215
- const modelOk = fs.existsSync(MODEL_FILE);
271
+ const model = readConfig().model;
272
+ const modelPath = modelMlxfn(model);
273
+ const modelOk = fs.existsSync(modelPath);
216
274
  const resourcesOk = fs.existsSync(RESOURCES_DIR);
217
275
  let mcpOk = false;
218
276
  const r = spawnSync("claude", ["mcp", "list"], { encoding: "utf8" });
@@ -223,7 +281,7 @@ export async function doctor() {
223
281
  console.log(` [${ok(platformOk)}] platform: ${process.platform}/${process.arch}`);
224
282
  console.log(` [${ok(claudeOk)}] claude CLI on PATH`);
225
283
  console.log(` [${ok(binOk)}] daemon binary: ${DAEMON_BIN}`);
226
- console.log(` [${ok(modelOk)}] model: ${MODEL_FILE}`);
284
+ console.log(` [${ok(modelOk)}] model (${MODELS[model].name}): ${modelPath}`);
227
285
  console.log(` [${ok(resourcesOk)}] resources: ${RESOURCES_DIR}`);
228
286
  console.log(` [${ok(mcpOk)}] mrt2-mcp registered`);
229
287
  console.log(` [${ok(cmdOk)}] /music command: ${MUSIC_COMMAND}`);
package/dist/paths.js CHANGED
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs";
1
2
  import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  // Central path constants for claude-music. Everything the installer writes and
@@ -13,10 +14,46 @@ export const BIN_DIR = path.join(CLAUDE_MUSIC_HOME, "bin");
13
14
  * fallback, which leaves the binary inside the magenta-realtime build tree).
14
15
  */
15
16
  export const DAEMON_BIN = process.env.MRT2D_BIN || path.join(BIN_DIR, "mrt2d");
16
- /** Where the model + shared resources are downloaded. The daemon hardcodes this. */
17
+ /** Where the model + shared resources are downloaded. The daemon defaults here. */
17
18
  export const MAGENTA_HOME = path.join(HOME, "Documents", "Magenta", "magenta-rt-v2");
18
- export const MODEL_FILE = path.join(MAGENTA_HOME, "models", "mrt2_small", "mrt2_small.mlxfn");
19
19
  export const RESOURCES_DIR = path.join(MAGENTA_HOME, "resources");
20
+ /** Shared resources (musiccoca + spectrostream) add ~1.4 GB regardless of model. */
21
+ export const RESOURCES_MB = 1376;
22
+ export const MODELS = {
23
+ small: {
24
+ name: "mrt2_small",
25
+ label: "Small",
26
+ approxMB: 464,
27
+ note: "~0.5 GB · realtime on any Apple Silicon (default)",
28
+ },
29
+ base: {
30
+ name: "mrt2_base",
31
+ label: "Base",
32
+ approxMB: 2788,
33
+ note: "~2.8 GB · higher quality · realtime on M2 Pro and above",
34
+ },
35
+ };
36
+ export const DEFAULT_MODEL = "small";
37
+ /** Absolute path to a model's compiled .mlxfn for the given key. */
38
+ export function modelMlxfn(key) {
39
+ const { name } = MODELS[key];
40
+ return path.join(MAGENTA_HOME, "models", name, `${name}.mlxfn`);
41
+ }
42
+ export const CONFIG_FILE = path.join(CLAUDE_MUSIC_HOME, "config.json");
43
+ /** Read the persisted config, defaulting to the small model. */
44
+ export function readConfig() {
45
+ try {
46
+ const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
47
+ return { model: raw.model === "base" ? "base" : "small" };
48
+ }
49
+ catch {
50
+ return { model: DEFAULT_MODEL };
51
+ }
52
+ }
53
+ export function writeConfig(cfg) {
54
+ fs.mkdirSync(CLAUDE_MUSIC_HOME, { recursive: true });
55
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
56
+ }
20
57
  /** Runtime artefacts created by the daemon. */
21
58
  export const SOCKET_PATH = path.join(HOME, ".mrt2d.sock");
22
59
  export const PID_PATH = path.join(HOME, ".mrt2d.pid");
package/dist/server.js CHANGED
@@ -5,7 +5,7 @@ import * as fs from "node:fs";
5
5
  import * as net from "node:net";
6
6
  import { z } from "zod";
7
7
  import { buildVibeMessage } from "./vibe.js";
8
- import { DAEMON_BIN, LOG_PATH, PID_PATH, SOCKET_PATH } from "./paths.js";
8
+ import { DAEMON_BIN, LOG_PATH, modelMlxfn, PID_PATH, readConfig, RESOURCES_DIR, SOCKET_PATH, } from "./paths.js";
9
9
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
10
10
  /** Can we actually connect to the daemon's socket right now? */
11
11
  export function socketAlive() {
@@ -29,17 +29,34 @@ export async function ensureDaemon() {
29
29
  if (!fs.existsSync(DAEMON_BIN)) {
30
30
  throw new Error(`mrt2d not installed at ${DAEMON_BIN} — run: claude-music install`);
31
31
  }
32
+ // Launch the model the user selected at install time (defaults to small).
33
+ const modelPath = modelMlxfn(readConfig().model);
34
+ // Fail fast with an actionable message if the model/resources are missing,
35
+ // rather than spawning a daemon that exits immediately and waiting it out.
36
+ if (!fs.existsSync(modelPath) || !fs.existsSync(RESOURCES_DIR)) {
37
+ throw new Error(`model not found at ${modelPath} — run: claude-music install`);
38
+ }
32
39
  const out = fs.openSync(LOG_PATH, "a");
33
- const child = spawn(DAEMON_BIN, [], {
40
+ const child = spawn(DAEMON_BIN, ["--model", modelPath, "--resources", RESOURCES_DIR], {
34
41
  detached: true,
35
42
  stdio: ["ignore", out, out],
36
43
  });
44
+ // Detect an early crash (e.g. a corrupt/partial model) so we don't sit through
45
+ // the full timeout. The daemon stays alive past unref() on success.
46
+ let exitCode = null;
47
+ child.on("exit", (code) => {
48
+ exitCode = code ?? 1;
49
+ });
37
50
  child.unref();
38
51
  // Model load is fast for mrt2_small (~2s) but allow generous headroom.
39
52
  for (let i = 0; i < 60; i++) {
40
53
  await sleep(1000);
41
54
  if (await socketAlive())
42
55
  return;
56
+ if (exitCode !== null) {
57
+ throw new Error(`mrt2d exited during startup (code ${exitCode}) — check ${LOG_PATH}. ` +
58
+ `If the model is incomplete, re-run: claude-music install`);
59
+ }
43
60
  }
44
61
  throw new Error(`daemon did not come up within 60s — check ${LOG_PATH}`);
45
62
  }
@@ -79,6 +96,41 @@ export function stopDaemon() {
79
96
  }
80
97
  fs.rmSync(SOCKET_PATH, { force: true });
81
98
  }
99
+ /**
100
+ * Tie the daemon's lifetime to this MCP server process. The daemon is spawned
101
+ * detached so it survives across tool calls, which also means nothing stops it
102
+ * when Claude Code shuts us down. We bridge that gap here: when the host closes
103
+ * our stdio pipe (the normal "Claude Code exited" path — stdin hits EOF) or
104
+ * sends us a termination signal, stop the daemon so the music doesn't outlive
105
+ * the session.
106
+ */
107
+ export function installShutdownHooks(stop, opts = {}) {
108
+ const stdin = opts.stdin ?? process.stdin;
109
+ const exit = opts.exit ?? ((code) => process.exit(code));
110
+ let done = false;
111
+ const cleanup = (shouldExit) => {
112
+ if (done)
113
+ return;
114
+ done = true;
115
+ try {
116
+ stop();
117
+ }
118
+ catch {
119
+ /* best effort — we're on the way out regardless */
120
+ }
121
+ if (shouldExit)
122
+ exit(0);
123
+ };
124
+ // Parent (Claude Code) closed the stdio pipe → EOF reaches us as 'end'/'close'.
125
+ stdin.on("end", () => cleanup(true));
126
+ stdin.on("close", () => cleanup(true));
127
+ // Host asked us to terminate.
128
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
129
+ process.on(sig, () => cleanup(true));
130
+ }
131
+ // Last-ditch sync safety net for any other exit path.
132
+ process.on("exit", () => cleanup(false));
133
+ }
82
134
  /** Build and connect the MCP stdio server. Invoked by `claude-music mcp`. */
83
135
  export async function runServer() {
84
136
  const server = new McpServer({ name: "mrt2-mcp", version: "1.0.0" });
@@ -125,4 +177,6 @@ export async function runServer() {
125
177
  });
126
178
  const transport = new StdioServerTransport();
127
179
  await server.connect(transport);
180
+ // connect() puts stdin in flowing mode, so 'end' fires reliably on EOF.
181
+ installShutdownHooks(stopDaemon);
128
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-music",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Live AI background music for Claude Code, scored in real time by Claude itself (Magenta RealTime 2 on Apple Silicon).",
5
5
  "type": "module",
6
6
  "bin": {