diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md
index f95d91c..1df47cf 100644
--- a/apps/cli/CHANGELOG.md
+++ b/apps/cli/CHANGELOG.md
@@ -1,5 +1,28 @@
# Changelog
+## 1.30.1 (2026-05-04) — daemon install upgrade-safe + node-pinned
+
+Two install-path fixes that bit on first user upgrade:
+
+- **Pin `node` by absolute path in the launchd plist / systemd unit.**
+ The bin script's `#!/usr/bin/env node` shebang resolves against the
+ service environment's PATH, which on macOS launchd defaults to
+ `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin`.
+ That picks up whatever Node is installed system-wide instead of the
+ Node that ran `claudemesh install` — and Node 22.x doesn't expose
+ `node:sqlite` without the experimental flag, so the daemon crashed
+ with `db open failed: ERR_UNKNOWN_BUILTIN_MODULE`. Now we write
+ `process.execPath` as the first ProgramArgument so the daemon
+ always runs under the same Node that installed it.
+- **Tear down the old daemon before re-bootstrapping.** `claudemesh
+ install` on a machine that already has a running daemon was hitting
+ `Bootstrap failed: 5: Input/output error` because launchctl refuses
+ to bootstrap a unit that's already loaded, and the old daemon
+ process held the singleton lock. The install path now runs
+ `launchctl bootout` (or `systemctl --user stop`) first, plus a
+ `SIGTERM` to any orphaned daemon pid in `~/.claudemesh/daemon/
+ daemon.pid`, so subsequent installs replace cleanly.
+
## 1.30.0 (2026-05-04) — per-session broker presence
Sprint A Phase 3. Two `claudemesh launch` sessions in the same cwd now
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 38aa962..e8900cd 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
- "version": "1.30.0",
+ "version": "1.30.1",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",
diff --git a/apps/cli/src/daemon/service-install.ts b/apps/cli/src/daemon/service-install.ts
index b74a16c..2c7cff3 100644
--- a/apps/cli/src/daemon/service-install.ts
+++ b/apps/cli/src/daemon/service-install.ts
@@ -87,7 +87,14 @@ function installDarwin(args: InstallArgs): InstallResult {
const plist = darwinPlistPath();
mkdirSync(dirname(plist), { recursive: true });
const log = DAEMON_PATHS.LOG_FILE;
+ // Resolve `node` explicitly. The bin script in node_modules/.bin starts
+ // with `#!/usr/bin/env node`; under launchd's restricted PATH that would
+ // resolve `node` to a system Node (often the wrong major) instead of the
+ // one that installed claudemesh-cli. Pinning process.execPath here means
+ // the daemon always runs under the same Node that ran `claudemesh install`.
+ const nodeBin = process.execPath;
const meshArgs = [
+ `${escapeXml(args.binaryPath)}`,
"daemon",
"up",
"--mesh",
@@ -103,7 +110,7 @@ function installDarwin(args: InstallArgs): InstallResult {
${SERVICE_LABEL}
ProgramArguments
- ${escapeXml(args.binaryPath)}
+ ${escapeXml(nodeBin)}
${meshArgs}
RunAtLoad
@@ -128,6 +135,26 @@ function installDarwin(args: InstallArgs): InstallResult {
`;
writeFileSync(plist, xml, { mode: 0o644 });
+ // Stop any prior incarnation BEFORE bootstrapping so an upgrade run
+ // doesn't hit "service already loaded" → bootstrap exit-5 IO_ERROR.
+ // Both calls are best-effort: launchctl prints to stderr if the unit
+ // isn't loaded, and we don't want to fail install for that.
+ try {
+ execSync(`launchctl bootout gui/$(id -u)/${SERVICE_LABEL}`, { stdio: "ignore" });
+ } catch { /* unit not loaded — fine */ }
+ // Also kill any orphaned daemon process (started manually or by an
+ // older script) so the new launchd-managed one can claim the singleton
+ // lock on first start.
+ try {
+ const pidPath = DAEMON_PATHS.PID_FILE;
+ if (existsSync(pidPath)) {
+ const pid = parseInt(readFileSync(pidPath, "utf8").trim(), 10);
+ if (Number.isFinite(pid) && pid > 0) {
+ try { process.kill(pid, "SIGTERM"); } catch { /* already dead */ }
+ }
+ }
+ } catch { /* pid file missing — fine */ }
+
return {
platform: "darwin",
unitPath: plist,
@@ -144,6 +171,9 @@ function linuxUnitPath(): string {
function installLinux(args: InstallArgs): InstallResult {
const unit = linuxUnitPath();
mkdirSync(dirname(unit), { recursive: true });
+ // Same node-pinning rationale as macOS — systemd's User= environment is
+ // similarly minimal; resolve node by absolute path.
+ const nodeBin = process.execPath;
const execArgs = [
"daemon", "up",
"--mesh", args.meshSlug,
@@ -157,7 +187,7 @@ Wants=network-online.target
[Service]
Type=simple
-ExecStart=${shellQuote(args.binaryPath)} ${execArgs}
+ExecStart=${shellQuote(nodeBin)} ${shellQuote(args.binaryPath)} ${execArgs}
Restart=always
RestartSec=3
StandardOutput=append:${DAEMON_PATHS.LOG_FILE}
@@ -169,6 +199,22 @@ WantedBy=default.target
`;
writeFileSync(unit, content, { mode: 0o644 });
+ // Mirror the darwin path: stop the previous unit (if any) so an
+ // upgrade run replaces it cleanly, plus kill any orphaned manual
+ // daemon process holding the singleton lock.
+ try {
+ execSync(`systemctl --user stop ${SYSTEMD_UNIT}`, { stdio: "ignore" });
+ } catch { /* not loaded — fine */ }
+ try {
+ const pidPath = DAEMON_PATHS.PID_FILE;
+ if (existsSync(pidPath)) {
+ const pid = parseInt(readFileSync(pidPath, "utf8").trim(), 10);
+ if (Number.isFinite(pid) && pid > 0) {
+ try { process.kill(pid, "SIGTERM"); } catch { /* already dead */ }
+ }
+ }
+ } catch { /* pid file missing — fine */ }
+
return {
platform: "linux",
unitPath: unit,