machine-bridge-mcp 0.11.0 → 0.11.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.1 - 2026-07-12
4
+
5
+ ### Reliable macOS service and orphan-daemon takeover
6
+
7
+ - Stop launchd jobs by their loaded service target first, with the plist form retained as a compatibility fallback. Treat an already-unloaded job as an idempotent success, verify the post-stop state before reporting success, and prevent `service start` or `service uninstall` from continuing when the existing job remains active.
8
+ - Detect legacy daemon locks that predate mode/version metadata by inspecting the live process command line. A process is eligible for takeover only when its PID, daemon purpose, canonical workspace, canonical state root, entrypoint, and `--daemon-only` arguments all match. Foreground or unverifiable processes are never signalled.
9
+ - Let normal foreground startup and `machine-mcp service stop` terminate a verified detached/orphan service daemon with `SIGTERM`, wait for the actual PID as well as the lock to disappear, reclaim stale locks through the normal token-aware primitive, and fail with a bounded timeout rather than escalating to a forced kill.
10
+ - Extend `machine-mcp service status` to report platform-service state and workspace-daemon state separately, including whether a live legacy process was verified as service-style. This makes a launchd-unloaded but still-running daemon visible instead of presenting a misleading inactive-only status.
11
+
12
+ ### Tests and documentation
13
+
14
+ - Add real child-process regression coverage for legacy orphan takeover, canonical `/var` versus `/private/var` path aliases, foreground-process protection, non-forcing timeout behavior, launchd service-target generation, and packaged daemon-process module presence. Update upgrade and recovery guidance for the exact npm install command and split service/daemon diagnosis.
15
+
3
16
  ## 0.11.0 - 2026-07-12
4
17
 
5
18
  ### Zero-configuration agent working agreements
package/README.md CHANGED
@@ -79,7 +79,7 @@ On first remote start, the CLI:
79
79
  6. starts an outbound-only daemon connection;
80
80
  7. prints the Remote MCP URL and connection password.
81
81
 
82
- A normal `machine-mcp` invocation is a foreground start: it remains attached to the terminal and prints `info` logs. If an older autostart daemon is active after a global package upgrade, the CLI stops that service, waits up to 15 seconds for its workspace lock, and then starts the newly installed version in the foreground. A genuine foreground conflict is left untouched and reported with actionable guidance. To run only in the background, use `machine-mcp service start`; inspect its owner-only logs under `~/.local/state/machine-bridge-mcp/logs/`.
82
+ A normal `machine-mcp` invocation is a foreground start: it remains attached to the terminal and prints `info` logs. After a global package upgrade, the CLI unloads the platform service and also checks the workspace lock for a detached/orphan `--daemon-only` process that the service manager no longer tracks. It terminates only a process whose PID, entrypoint, canonical workspace, canonical state root, and daemon-only arguments all match, waits up to 15 seconds for the PID and lock to disappear, and then starts the installed version in the foreground. A genuine foreground or unverifiable conflict is left untouched and reported with actionable guidance. To run only in the background, use `machine-mcp service start`; inspect its owner-only logs under `~/.local/state/machine-bridge-mcp/logs/`.
83
83
 
84
84
  Recommended upgrade sequence:
85
85
 
@@ -88,7 +88,7 @@ npm install -g --omit=optional --allow-scripts=esbuild,workerd,sharp,fsevents ma
88
88
  machine-mcp --verbose
89
89
  ```
90
90
 
91
- The global install replaces files on disk but cannot hot-reload an already running Node process; the second command performs the bounded service takeover. If takeover fails, run `machine-mcp service stop`, confirm with `machine-mcp service status`, and retry.
91
+ The global install replaces files on disk but cannot hot-reload an already running Node process; the second command performs the bounded service and orphan-daemon takeover. Use the command exactly as shown: `--omit=optional` prevents the development-only optional `fsevents` package from producing a blocked-install-script warning. If takeover fails, run `machine-mcp service status` first; it reports the platform service and workspace daemon separately. Then run `machine-mcp service stop` and retry.
92
92
 
93
93
  Use the printed values in the MCP client:
94
94
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Machine Bridge Browser",
4
- "version": "0.11.0",
4
+ "version": "0.11.1",
5
5
  "description": "Connects the current Chromium browser profile to the local Machine Bridge runtime for user-authorized automation.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -29,5 +29,5 @@
29
29
  "action": {
30
30
  "default_title": "Machine Bridge Browser"
31
31
  },
32
- "version_name": "0.11.0"
32
+ "version_name": "0.11.1"
33
33
  }
@@ -40,6 +40,8 @@ A canonical workspace receives an independent profile, Worker name, secret set,
40
40
 
41
41
  `RelayConnection` owns remote WebSocket transport, authenticated `hello_ack` readiness, heartbeat liveness, reconnect backoff, and outage logging. Stdio mode invokes `LocalRuntime` directly without that adapter.
42
42
 
43
+ `daemon-process.mjs` owns workspace-daemon inspection and takeover. It distinguishes platform service state from the lock-owning Node process, canonicalizes workspace/state paths before identity comparison, parses bounded process command lines without executing them, and sends `SIGTERM` only to a verified same-workspace `--daemon-only` process. CLI orchestration never treats a missing launchd/systemd job as proof that the process exited.
44
+
43
45
  ### Agent context and capability resolver
44
46
 
45
47
  `AgentContextManager` discovers the nearest Git/workspace scope, applies the user configuration and hierarchical `.machine-bridge/agent.json` files, selects built-in/user/root-to-target instructions, discovers bounded filesystem skills, and resolves registered commands. `default-instructions.mjs` supplies a versioned in-package working-agreement block and derives a small virtual project-context block from root filenames and bounded metadata. It reads package script names but not bodies, does not inspect dependency values or source contents, executes nothing, and writes no user/repository files. A global `model_instructions_file` is a separate user-designated session source and cannot be overridden by a project.
@@ -61,7 +61,9 @@ Application UI inspection/actions require Accessibility permission for the Node/
61
61
 
62
62
  `machine-mcp` is a foreground command. It remains attached to the terminal, defaults to `info` logging, and stops on `Ctrl+C`. `machine-mcp service start` launches the installed platform service in the background and returns to the shell; that service uses `warn` logging.
63
63
 
64
- A global npm install changes the CLI files on disk but does not replace an already running Node process. On a normal foreground start, Machine Bridge checks whether its autostart service is active, requests a stop, waits up to 15 seconds for the old daemon lock to be released, and then continues with the newly installed version. New daemon locks record foreground/background mode and package version. If another foreground process owns the lock, Machine Bridge does not kill it or print a false readiness message; stop it with `Ctrl+C`. If a background takeover reaches its deadline, run:
64
+ A global npm install changes the CLI files on disk but does not replace an already running Node process. On a normal foreground start, Machine Bridge unloads the platform service and then independently examines the workspace daemon lock. This second path handles a detached/orphan `--daemon-only` process that launchd/systemd/task scheduling no longer tracks, including legacy locks without mode/version metadata. Before sending `SIGTERM`, Machine Bridge verifies the live command line against the lock, entrypoint, canonical workspace, canonical state root, and daemon-only flag. It waits up to 15 seconds for both the PID and lock to disappear and never escalates to a forced kill. A foreground or unverifiable process is left untouched; stop a foreground instance with `Ctrl+C`.
65
+
66
+ `machine-mcp service status [WORKSPACE]` reports two independent layers: the platform service (`active`) and `workspace_daemon`, plus `effective_active` and `orphaned_workspace_daemon` summary flags. On macOS it is possible for launchd to report inactive while a prior Node process remains alive with parent PID 1; that is an orphan-daemon condition, not proof that the daemon stopped. `service stop` now unloads the provider when present and then terminates only a verified service-style workspace daemon. If takeover reaches its deadline, run:
65
67
 
66
68
  ```sh
67
69
  machine-mcp service stop
@@ -76,6 +78,8 @@ npm install -g --omit=optional --allow-scripts=esbuild,workerd,sharp,fsevents ma
76
78
  machine-mcp --verbose
77
79
  ```
78
80
 
81
+ Keep `--omit=optional` in the install command. Without it npm may resolve optional `fsevents` and warn that its install script was not included in `allowScripts`; Machine Bridge does not require that development-time watcher at runtime.
82
+
79
83
  ## Logs
80
84
 
81
85
  Remote autostart definitions prefer a stable PATH alias that resolves to the currently running Node executable and persist a sanitized absolute-only service `PATH` containing the Node/CLI directories, the installer's absolute PATH entries, and platform defaults. This avoids versioned Homebrew-style paths becoming invalid after upgrades and prevents launchd/systemd from falling back to a minimal system-only PATH. Re-run `machine-mcp service install` after changing Node installation families or PATH layout. A service-style `--daemon-only` start that finds the same workspace daemon already running is an idempotent no-op: it exits successfully without repeating warnings or readiness output; explicit policy/secret/change requests still report that changes were not applied. Autostart logs are stored under the state root in `logs/daemon.out.log` and `logs/daemon.err.log`. Files are owner-only where supported and tail-trimmed before daemon startup. On a logging-schema upgrade, bounded prior content is moved into `daemon.out.legacy.log` and `daemon.err.legacy.log`; use the active filenames when diagnosing current behavior.
package/docs/TESTING.md CHANGED
@@ -18,7 +18,7 @@ The suite includes:
18
18
  - syntax validation for every shipped JavaScript entry point;
19
19
  - shared tool-catalog schema, annotation, and profile-inventory checks;
20
20
  - default working-agreement injection without user files, bounded automatic project metadata, script-body non-disclosure, user-global opt-out, repository opt-out rejection, global `model_instructions_file` injection in stdio/remote initialization, hierarchical precedence, live project/skill rescanning and fingerprints, automatic task ranking/loading, command override/removal, direct argv handling, timeout ceilings, and execution-profile denial;
21
- - foreground takeover of an asynchronously stopping background daemon, bounded timeout guidance, daemon lock mode/version metadata, and silent idempotent duplicate service starts;
21
+ - foreground takeover of active and orphaned background daemons, legacy lock identification from canonicalized process arguments, foreground-process protection, actual-PID exit waiting, non-forcing timeout guidance, daemon lock mode/version metadata, launchd service-target semantics, and silent idempotent duplicate service starts;
22
22
  - machine-level browser-broker ownership/client proxying, authenticated extension origin/subprotocol, non-cacheable local pairing, pairing-token non-disclosure, resource-backed upload routing, and broker result redaction;
23
23
  - canonical path and symbolic-link escape tests;
24
24
  - relative-path privacy and error-path redaction tests;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "machine-bridge-mcp",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Cross-client MCP bridge for local agent context, structured browser and application automation, files, Git, processes, resources, and durable jobs over stdio or OAuth relay.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,7 +44,7 @@
44
44
  "prepublishOnly": "npm run check && npm run version:check && npm run release:check",
45
45
  "worker:types": "wrangler types src/worker/worker-configuration.d.ts",
46
46
  "typecheck": "npm run worker:types && tsc -p tsconfig.json --noEmit",
47
- "syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/runtime.mjs && node --check src/local/agent-context.mjs && node --check src/local/default-instructions.mjs && node --check src/local/browser-bridge.mjs && node --check src/local/app-automation.mjs && node --check src/local/relay-connection.mjs && node --check src/local/secure-file.mjs && node --check src/local/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check src/local/atomic-fs.mjs && node --check src/local/ssh-key.mjs && node --check src/local/resource-operations.mjs && node --check src/local/full-access-test.mjs && node --check src/local/managed-jobs.mjs && node --check src/local/job-runner.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/privacy-check.mjs && node --check scripts/release-impact-check.mjs && node --check scripts/network-retry.mjs && node --check scripts/release-state.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/agent-context-test.mjs && node --check tests/browser-bridge-test.mjs && node --check tests/app-automation-test.mjs && node --check tests/local-self-test.mjs && node --check tests/runtime-self-test.mjs && node --check tests/managed-jobs-test.mjs && node --check tests/ssh-key-test.mjs && node --check tests/full-access-test.mjs && node --check tests/atomic-fs-test.mjs && node --check tests/package-test.mjs && node --check tests/release-impact-test.mjs && node --check tests/release-state-test.mjs && node --check tests/privacy-test.mjs && node --check tests/network-retry-test.mjs && node --check tests/relay-connection-test.mjs && node --check tests/install-smoke-test.mjs && node --check tests/secure-file-test.mjs && node --check tests/architecture-test.mjs && node --check browser-extension/service-worker.js && node --check browser-extension/pairing.js && node --check browser-extension/page-automation.js",
47
+ "syntax": "sh -n mbm && node --check bin/machine-mcp.mjs && node --check src/local/cli.mjs && node --check src/local/runtime.mjs && node --check src/local/agent-context.mjs && node --check src/local/default-instructions.mjs && node --check src/local/daemon-process.mjs && node --check src/local/browser-bridge.mjs && node --check src/local/app-automation.mjs && node --check src/local/relay-connection.mjs && node --check src/local/secure-file.mjs && node --check src/local/patch.mjs && node --check src/local/process-sessions.mjs && node --check src/local/tools.mjs && node --check src/local/stdio.mjs && node --check src/local/state.mjs && node --check src/local/shell.mjs && node --check src/local/service.mjs && node --check src/local/log.mjs && node --check src/local/atomic-fs.mjs && node --check src/local/ssh-key.mjs && node --check src/local/resource-operations.mjs && node --check src/local/full-access-test.mjs && node --check src/local/managed-jobs.mjs && node --check src/local/job-runner.mjs && node --check scripts/sync-worker-version.mjs && node --check scripts/privacy-check.mjs && node --check scripts/release-impact-check.mjs && node --check scripts/network-retry.mjs && node --check scripts/release-state.mjs && node --check scripts/github-release.mjs && node --check tests/worker-integration-test.mjs && node --check tests/stdio-integration-test.mjs && node --check tests/catalog-test.mjs && node --check tests/agent-context-test.mjs && node --check tests/browser-bridge-test.mjs && node --check tests/app-automation-test.mjs && node --check tests/local-self-test.mjs && node --check tests/runtime-self-test.mjs && node --check tests/managed-jobs-test.mjs && node --check tests/ssh-key-test.mjs && node --check tests/full-access-test.mjs && node --check tests/atomic-fs-test.mjs && node --check tests/package-test.mjs && node --check tests/release-impact-test.mjs && node --check tests/release-state-test.mjs && node --check tests/privacy-test.mjs && node --check tests/network-retry-test.mjs && node --check tests/relay-connection-test.mjs && node --check tests/install-smoke-test.mjs && node --check tests/secure-file-test.mjs && node --check tests/architecture-test.mjs && node --check browser-extension/service-worker.js && node --check browser-extension/pairing.js && node --check browser-extension/page-automation.js",
48
48
  "check": "npm run privacy:check && npm run privacy:test && npm run release-impact:check && npm run release-impact:test && npm run release-state:test && npm run network-retry:test && npm run relay:test && npm run secure-file:test && npm run architecture:test && npm run typecheck && npm run syntax && npm run catalog:test && npm run agent-context:test && npm run browser-bridge:test && npm run app-automation:test && npm run self-test && npm run atomic-fs:test && npm run package:test && npm run ssh-key:test && npm run full-access:test && npm run managed-jobs:test && npm run stdio:integration-test && npm run worker:integration-test",
49
49
  "worker:dry-run": "wrangler deploy --dry-run",
50
50
  "worker:integration-test": "node tests/worker-integration-test.mjs",
package/src/local/cli.mjs CHANGED
@@ -5,6 +5,7 @@ import path, { join, resolve } from "node:path";
5
5
  import process from "node:process";
6
6
  import readline from "node:readline/promises";
7
7
  import { LocalRuntime } from "./runtime.mjs";
8
+ import { acquireDaemonLockWithTakeover, inspectWorkspaceDaemon, stopWorkspaceServiceDaemon } from "./daemon-process.mjs";
8
9
  import { runStdioServer } from "./stdio.mjs";
9
10
  import { assertCanonicalFullPolicy, DEFAULT_POLICY_PROFILE, DEFAULT_POLICY_REVISION, POLICY_PROFILES, normalizePolicy, policyProfile, toolsForPolicy } from "./tools.mjs";
10
11
  import { classifyOperationalError, createLogger, normalizeLogLevel, sanitizeLogText } from "./log.mjs";
@@ -47,8 +48,6 @@ const BOOLEAN_OPTIONS = new Set([
47
48
  const VALUE_OPTIONS = new Set([
48
49
  "workspace", "stateDir", "workerName", "profile", "execMode", "client", "logLevel",
49
50
  ]);
50
- const DAEMON_TAKEOVER_TIMEOUT_MS = 15_000;
51
- const DAEMON_TAKEOVER_POLL_MS = 100;
52
51
 
53
52
  const COMMAND_HANDLERS = Object.freeze({
54
53
  start: startCommand,
@@ -146,7 +145,7 @@ const ACTION_POSITIONAL_RULES = Object.freeze({
146
145
  },
147
146
  service(args) {
148
147
  const action = String(args._[0] || "status");
149
- return { max: action === "install" ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
148
+ return { max: ["install", "status", "stop", "uninstall", "remove"].includes(action) ? 2 : 1, tooMany: `service ${action} received too many positional arguments`, workspaceConflictAfter: 1 };
150
149
  },
151
150
  autostart(args) {
152
151
  return ACTION_POSITIONAL_RULES.service(args);
@@ -324,7 +323,7 @@ async function startCommand(args) {
324
323
  try {
325
324
  const startMode = await prepareStartMode(args, state, logger);
326
325
  const daemonLock = await acquireDaemonLockWithTakeover(state, {
327
- waitForServiceExit: startMode.waitForServiceExit,
326
+ takeOverServiceOwner: startMode.takeOverServiceOwner,
328
327
  ownerMetadata: {
329
328
  mode: args.daemonOnly ? "service" : "foreground",
330
329
  version: currentPackageVersion(),
@@ -345,43 +344,15 @@ async function prepareStartMode(args, state, logger) {
345
344
  if (args.daemonOnly) {
346
345
  const { trimAutostartLogs } = await import("./service.mjs");
347
346
  trimAutostartLogs(state.paths.stateRoot);
348
- return { waitForServiceExit: false };
347
+ return { takeOverServiceOwner: false };
349
348
  }
350
- // A normal foreground start takes over from the installed background
351
- // service. Service managers terminate asynchronously, so wait for the
352
- // existing daemon to release its workspace lock before declaring conflict.
349
+ // A normal foreground start first asks the platform service manager to
350
+ // unload the job, then independently reclaims a verified daemon-only
351
+ // process. The second step handles legacy/orphan daemons that launchd or
352
+ // another service manager no longer tracks.
353
353
  return stopAutostartBestEffort(logger);
354
354
  }
355
355
 
356
- export async function acquireDaemonLockWithTakeover(state, options = {}) {
357
- const ownerMetadata = options.ownerMetadata || {};
358
- let lock = acquireDaemonLock(state, ownerMetadata);
359
- if (lock.acquired || !options.waitForServiceExit) return lock;
360
-
361
- const timeoutMs = Number.isFinite(Number(options.timeoutMs))
362
- ? Math.max(1, Number(options.timeoutMs))
363
- : DAEMON_TAKEOVER_TIMEOUT_MS;
364
- const pollMs = Number.isFinite(Number(options.pollMs))
365
- ? Math.max(1, Number(options.pollMs))
366
- : DAEMON_TAKEOVER_POLL_MS;
367
- const logger = options.logger || { info() {} };
368
- const priorPid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "the existing process";
369
- logger.info(`waiting for the background daemon (${priorPid}) to stop before foreground startup`);
370
-
371
- const deadline = Date.now() + timeoutMs;
372
- while (Date.now() < deadline) {
373
- await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
374
- lock = acquireDaemonLock(state, ownerMetadata);
375
- if (lock.acquired) {
376
- logger.info("background daemon stopped; foreground startup is taking over the workspace");
377
- return lock;
378
- }
379
- }
380
-
381
- const pid = lock.owner?.pid ? `pid ${lock.owner.pid}` : "unknown pid";
382
- throw new Error(`background daemon did not release the workspace within ${Math.ceil(timeoutMs / 1000)} seconds (${pid}); run \`machine-mcp service stop\`, verify \`machine-mcp service status\`, and retry`);
383
- }
384
-
385
356
  function reportExistingDaemon(args, state, owner, logger) {
386
357
  const pid = owner?.pid ? `pid ${owner.pid}` : "unknown pid";
387
358
  if (isIdempotentDaemonOnlyStart(args)) {
@@ -1244,7 +1215,15 @@ async function serviceCommand(args) {
1244
1215
  const { installAutostart, uninstallAutostart, autostartStatus, startAutostart, stopAutostart } = await import("./service.mjs");
1245
1216
  if (action === "status") {
1246
1217
  const status = await autostartStatus({ logger: structuredLogger(Boolean(args.quiet)) });
1247
- console.log(JSON.stringify(status, null, 2));
1218
+ const state = optionalServiceState(args, stateRoot);
1219
+ const workspaceDaemon = state ? inspectWorkspaceDaemon(state) : null;
1220
+ console.log(JSON.stringify({
1221
+ ...status,
1222
+ workspace: state?.workspace?.path || null,
1223
+ workspace_daemon: workspaceDaemon,
1224
+ effective_active: Boolean(status.active || workspaceDaemon?.alive),
1225
+ orphaned_workspace_daemon: Boolean(!status.active && workspaceDaemon?.alive && workspaceDaemon?.verified_service_daemon),
1226
+ }, null, 2));
1248
1227
  return;
1249
1228
  }
1250
1229
  if (action === "install") {
@@ -1264,18 +1243,48 @@ async function serviceCommand(args) {
1264
1243
  return;
1265
1244
  }
1266
1245
  if (action === "stop") {
1267
- const result = await stopAutostart({ logger: structuredLogger(Boolean(args.quiet)) });
1246
+ const logger = structuredLogger(Boolean(args.quiet));
1247
+ const provider = await stopAutostart({ logger });
1248
+ const state = optionalServiceState(args, stateRoot);
1249
+ const workspaceDaemon = state
1250
+ ? await stopWorkspaceServiceDaemon(state, { logger, reason: "service stop" })
1251
+ : { ok: true, found: false, stopped: false, verified_service_daemon: false, reason: "workspace_not_selected" };
1252
+ const result = {
1253
+ ...provider,
1254
+ ok: provider?.ok !== false && workspaceDaemon.ok,
1255
+ workspace: state?.workspace?.path || null,
1256
+ workspace_daemon: workspaceDaemon,
1257
+ };
1268
1258
  console.log(JSON.stringify(result, null, 2));
1259
+ if (!result.ok) process.exitCode = 1;
1269
1260
  return;
1270
1261
  }
1271
1262
  if (action === "uninstall" || action === "remove") {
1272
- const result = await uninstallAutostart({ stateRoot, logger: structuredLogger(Boolean(args.quiet)) });
1273
- console.log(JSON.stringify(result, null, 2));
1263
+ const logger = structuredLogger(Boolean(args.quiet));
1264
+ const result = await uninstallAutostart({ stateRoot, logger });
1265
+ const state = optionalServiceState(args, stateRoot);
1266
+ const workspaceDaemon = state
1267
+ ? await stopWorkspaceServiceDaemon(state, { logger, reason: "service uninstall" })
1268
+ : { ok: true, found: false, stopped: false, verified_service_daemon: false, reason: "workspace_not_selected" };
1269
+ const output = {
1270
+ ...result,
1271
+ ok: result?.ok !== false && workspaceDaemon.ok,
1272
+ workspace: state?.workspace?.path || null,
1273
+ workspace_daemon: workspaceDaemon,
1274
+ };
1275
+ console.log(JSON.stringify(output, null, 2));
1276
+ if (!output.ok) process.exitCode = 1;
1274
1277
  return;
1275
1278
  }
1276
1279
  throw new Error(`Unknown service action: ${action}`);
1277
1280
  }
1278
1281
 
1282
+ function optionalServiceState(args, stateRoot) {
1283
+ const requested = args.workspace || args._[1] || selectedWorkspace(stateRoot);
1284
+ if (!requested || requested === true) return null;
1285
+ return loadState(resolveWorkspace(String(requested)), { stateDir: stateRoot });
1286
+ }
1287
+
1279
1288
  async function installAutostartBestEffort({ workspace, stateRoot, entryScript, logger }) {
1280
1289
  try {
1281
1290
  const { installAutostart } = await import("./service.mjs");
@@ -1288,24 +1297,19 @@ async function installAutostartBestEffort({ workspace, stateRoot, entryScript, l
1288
1297
  }
1289
1298
 
1290
1299
  async function stopAutostartBestEffort(logger) {
1300
+ let result = null;
1291
1301
  try {
1292
- const { autostartStatus, stopAutostart } = await import("./service.mjs");
1293
- let status = null;
1294
- try {
1295
- status = await autostartStatus({ logger: structuredLogger(true) });
1296
- } catch (error) {
1297
- logger.debug?.("Autostart status check failed before takeover", { error_class: classifyOperationalError(error) });
1298
- }
1299
- if (status?.active === false) return { waitForServiceExit: false };
1300
-
1301
- const result = await stopAutostart({ logger: structuredLogger(true) });
1302
- const stopSucceeded = result?.code === 0 || result?.ok === true;
1303
- if (status?.active && stopSucceeded) logger.info("stopping the background service before foreground startup");
1304
- return { waitForServiceExit: Boolean(stopSucceeded && status?.active !== false) };
1302
+ const { stopAutostart } = await import("./service.mjs");
1303
+ result = await stopAutostart({ logger: structuredLogger(true) });
1305
1304
  } catch (error) {
1306
- logger.warn("Autostart stop skipped", { error_class: classifyOperationalError(error) });
1307
- return { waitForServiceExit: false };
1305
+ logger.warn("Autostart stop command was unavailable; checking the workspace daemon directly", { error_class: classifyOperationalError(error) });
1306
+ return { takeOverServiceOwner: true, provider: null };
1307
+ }
1308
+ if (result?.active_before && result?.ok) logger.info("stopping the background service before foreground startup");
1309
+ if (result?.ok === false && result?.active === true) {
1310
+ throw new Error("the background service is still active after the stop request; run `machine-mcp service status` for details");
1308
1311
  }
1312
+ return { takeOverServiceOwner: true, provider: result };
1309
1313
  }
1310
1314
 
1311
1315
  async function uninstallCommand(args) {
@@ -0,0 +1,263 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import {
5
+ acquireDaemonLock,
6
+ daemonLockPathForState,
7
+ readDaemonLockOwner,
8
+ resolveWorkspace,
9
+ } from "./state.mjs";
10
+
11
+ const DEFAULT_TAKEOVER_TIMEOUT_MS = 15_000;
12
+ const DEFAULT_TAKEOVER_POLL_MS = 100;
13
+
14
+ export async function acquireDaemonLockWithTakeover(state, options = {}) {
15
+ const ownerMetadata = options.ownerMetadata || {};
16
+ let lock = acquireDaemonLock(state, ownerMetadata);
17
+ if (lock.acquired || !options.takeOverServiceOwner) return lock;
18
+
19
+ const stopped = await stopWorkspaceServiceDaemon(state, {
20
+ owner: lock.owner,
21
+ timeoutMs: options.timeoutMs,
22
+ pollMs: options.pollMs,
23
+ logger: options.logger,
24
+ reason: "foreground startup",
25
+ });
26
+ if (!stopped.verified_service_daemon) return lock;
27
+ if (!stopped.ok) {
28
+ const pid = stopped.pid ? `pid ${stopped.pid}` : "unknown pid";
29
+ throw new Error(`background daemon did not release the workspace within ${Math.ceil(stopped.timeout_ms / 1000)} seconds (${pid}); run \`machine-mcp service stop\`, verify \`machine-mcp service status\`, and retry`);
30
+ }
31
+
32
+ lock = acquireDaemonLock(state, ownerMetadata);
33
+ if (lock.acquired) options.logger?.info?.("background daemon stopped; foreground startup is taking over the workspace");
34
+ return lock;
35
+ }
36
+
37
+ export async function stopWorkspaceServiceDaemon(state, options = {}) {
38
+ const timeoutMs = boundedPositiveInt(options.timeoutMs, DEFAULT_TAKEOVER_TIMEOUT_MS);
39
+ const pollMs = boundedPositiveInt(options.pollMs, DEFAULT_TAKEOVER_POLL_MS);
40
+ const logger = options.logger || { info() {}, warn() {} };
41
+ const deadline = Date.now() + timeoutMs;
42
+ const signalled = new Set();
43
+ let owner = options.owner || readDaemonLockOwner(daemonLockPathForState(state));
44
+ let verified = false;
45
+ let lastOwner = owner;
46
+
47
+ while (true) {
48
+ if (owner?.pid && isPidAlive(owner.pid)) {
49
+ lastOwner = owner;
50
+ const identity = inspectWorkspaceDaemonOwner(state, owner);
51
+ if (!identity.verified_service_daemon) {
52
+ return {
53
+ ok: false,
54
+ found: true,
55
+ verified_service_daemon: false,
56
+ reason: identity.reason,
57
+ timeout_ms: timeoutMs,
58
+ ...publicDaemonOwner(owner),
59
+ };
60
+ }
61
+ verified = true;
62
+ if (!signalled.has(Number(owner.pid))) {
63
+ const purpose = options.reason || "service stop";
64
+ logger.info?.(`stopping detached background daemon (pid ${owner.pid}) for ${purpose}`);
65
+ try {
66
+ process.kill(Number(owner.pid), "SIGTERM");
67
+ } catch (error) {
68
+ if (error?.code !== "ESRCH") {
69
+ return {
70
+ ok: false,
71
+ found: true,
72
+ verified_service_daemon: true,
73
+ reason: "signal_failed",
74
+ timeout_ms: timeoutMs,
75
+ ...publicDaemonOwner(owner),
76
+ };
77
+ }
78
+ }
79
+ signalled.add(Number(owner.pid));
80
+ }
81
+ }
82
+
83
+ const liveSignalled = [...signalled].filter((pid) => isPidAlive(pid));
84
+ const currentOwnerAlive = Boolean(owner?.pid && isPidAlive(owner.pid));
85
+ if (!currentOwnerAlive && liveSignalled.length === 0) break;
86
+
87
+ if (Date.now() >= deadline) {
88
+ logger.warn?.(`detached background daemon did not stop within ${Math.ceil(timeoutMs / 1000)} seconds`);
89
+ const remainingPid = Number(owner?.pid) || liveSignalled[0] || null;
90
+ return {
91
+ ok: false,
92
+ found: true,
93
+ stopped: false,
94
+ verified_service_daemon: verified,
95
+ reason: "timeout",
96
+ timeout_ms: timeoutMs,
97
+ pid: remainingPid,
98
+ mode: publicDaemonMode(lastOwner),
99
+ version: typeof lastOwner?.version === "string" ? lastOwner.version : "unknown",
100
+ };
101
+ }
102
+ await sleep(Math.min(pollMs, Math.max(1, deadline - Date.now())));
103
+ owner = readDaemonLockOwner(daemonLockPathForState(state));
104
+ }
105
+
106
+ // Reclaim a stale lock only through the normal token-aware primitive.
107
+ const staleCleanup = acquireDaemonLock(state, { mode: "service" });
108
+ if (staleCleanup.acquired) staleCleanup.release();
109
+ return {
110
+ ok: true,
111
+ found: verified,
112
+ stopped: verified,
113
+ verified_service_daemon: verified,
114
+ reason: verified ? "stopped" : "not_running",
115
+ timeout_ms: timeoutMs,
116
+ ...(lastOwner ? publicDaemonOwner(lastOwner) : {}),
117
+ };
118
+ }
119
+
120
+ export function inspectWorkspaceDaemon(state) {
121
+ const owner = readDaemonLockOwner(daemonLockPathForState(state));
122
+ if (!owner) return { present: false, alive: false, verified_service_daemon: false };
123
+ const alive = Boolean(owner.pid && isPidAlive(owner.pid));
124
+ const identity = alive
125
+ ? inspectWorkspaceDaemonOwner(state, owner)
126
+ : { verified_service_daemon: false, reason: "stale_lock" };
127
+ return {
128
+ present: true,
129
+ alive,
130
+ verified_service_daemon: identity.verified_service_daemon,
131
+ identity_reason: identity.reason,
132
+ ...publicDaemonOwner(owner),
133
+ };
134
+ }
135
+
136
+ function inspectWorkspaceDaemonOwner(state, owner) {
137
+ if (!owner || owner.purpose !== "daemon") return { verified_service_daemon: false, reason: "invalid_lock_owner" };
138
+ const pid = Number(owner.pid);
139
+ if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) return { verified_service_daemon: false, reason: "invalid_pid" };
140
+ if (!sameCanonicalPath(owner.workspace, state.workspace.path)) return { verified_service_daemon: false, reason: "workspace_mismatch" };
141
+ if (owner.mode === "foreground") return { verified_service_daemon: false, reason: "foreground_daemon" };
142
+
143
+ const command = processCommandLine(pid);
144
+ if (!command) {
145
+ return { verified_service_daemon: false, reason: owner.mode === "service" ? "service_identity_unavailable" : "legacy_identity_unavailable" };
146
+ }
147
+ const argv = splitProcessCommandLine(command);
148
+ const entryName = path.basename(String(owner.entryScript || "machine-mcp"));
149
+ const workspaceArg = commandFlagValue(argv, "--workspace");
150
+ const stateRootArg = commandFlagValue(argv, "--state-dir");
151
+ const entryMatches = argv.some((value) => {
152
+ const name = path.basename(value);
153
+ return name === entryName || name === "machine-mcp" || name === "machine-mcp.mjs";
154
+ });
155
+ const matches = argv.includes("--daemon-only")
156
+ && sameCanonicalPath(workspaceArg, state.workspace.path)
157
+ && sameCanonicalPath(stateRootArg, state.paths.stateRoot)
158
+ && entryMatches;
159
+ return { verified_service_daemon: matches, reason: matches ? "service_command" : "command_mismatch" };
160
+ }
161
+
162
+ function processCommandLine(pid) {
163
+ if (process.platform === "win32") {
164
+ const command = `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`;
165
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
166
+ encoding: "utf8",
167
+ timeout: 3000,
168
+ windowsHide: true,
169
+ });
170
+ return result.status === 0 ? String(result.stdout || "").trim() : "";
171
+ }
172
+ const result = spawnSync("ps", ["-ww", "-p", String(pid), "-o", "command="], {
173
+ encoding: "utf8",
174
+ timeout: 3000,
175
+ windowsHide: true,
176
+ });
177
+ return result.status === 0 ? String(result.stdout || "").trim() : "";
178
+ }
179
+
180
+ function splitProcessCommandLine(value) {
181
+ const args = [];
182
+ let current = "";
183
+ let quote = "";
184
+ const text = String(value);
185
+ for (let index = 0; index < text.length; index += 1) {
186
+ const character = text[index];
187
+ if (quote) {
188
+ if (character === quote) quote = "";
189
+ else if (character === "\\" && quote === '"' && ['"', "\\"].includes(text[index + 1])) {
190
+ current += text[index + 1];
191
+ index += 1;
192
+ } else current += character;
193
+ continue;
194
+ }
195
+ if (character === '"' || character === "'") {
196
+ quote = character;
197
+ continue;
198
+ }
199
+ if (/\s/.test(character)) {
200
+ if (current) {
201
+ args.push(current);
202
+ current = "";
203
+ }
204
+ continue;
205
+ }
206
+ if (character === "\\" && /[\s'"\\]/.test(text[index + 1] || "")) {
207
+ current += text[index + 1];
208
+ index += 1;
209
+ continue;
210
+ }
211
+ current += character;
212
+ }
213
+ if (current) args.push(current);
214
+ return args;
215
+ }
216
+
217
+ function commandFlagValue(argv, name) {
218
+ const index = argv.indexOf(name);
219
+ return index >= 0 && index + 1 < argv.length ? argv[index + 1] : "";
220
+ }
221
+
222
+ function sameCanonicalPath(left, right) {
223
+ if (typeof left !== "string" || typeof right !== "string") return false;
224
+ try {
225
+ const a = resolveWorkspace(left);
226
+ const b = resolveWorkspace(right);
227
+ return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
228
+ } catch {
229
+ return false;
230
+ }
231
+ }
232
+
233
+ function publicDaemonOwner(owner) {
234
+ return {
235
+ pid: Number(owner?.pid) || null,
236
+ mode: publicDaemonMode(owner),
237
+ version: typeof owner?.version === "string" ? owner.version : "unknown",
238
+ };
239
+ }
240
+
241
+ function publicDaemonMode(owner) {
242
+ return owner?.mode === "service" || owner?.mode === "foreground" ? owner.mode : "legacy";
243
+ }
244
+
245
+ function isPidAlive(pid) {
246
+ const parsed = Number(pid);
247
+ if (!Number.isInteger(parsed) || parsed <= 0) return false;
248
+ try {
249
+ process.kill(parsed, 0);
250
+ return true;
251
+ } catch (error) {
252
+ return error?.code === "EPERM";
253
+ }
254
+ }
255
+
256
+ function boundedPositiveInt(value, fallback) {
257
+ const parsed = Number(value);
258
+ return Number.isFinite(parsed) ? Math.max(1, parsed) : fallback;
259
+ }
260
+
261
+ function sleep(ms) {
262
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
263
+ }
@@ -248,6 +248,18 @@ function launchdPlistPath() {
248
248
  return path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
249
249
  }
250
250
 
251
+ export function launchdServiceTarget(uid = process.getuid?.()) {
252
+ const parsed = Number(uid);
253
+ if (!Number.isInteger(parsed) || parsed < 0) throw new Error("launchd requires a numeric user id");
254
+ return `gui/${parsed}/${LABEL}`;
255
+ }
256
+
257
+ function launchdDomainTarget(uid = process.getuid?.()) {
258
+ const parsed = Number(uid);
259
+ if (!Number.isInteger(parsed) || parsed < 0) throw new Error("launchd requires a numeric user id");
260
+ return `gui/${parsed}`;
261
+ }
262
+
251
263
  async function installLaunchd(spec, logger) {
252
264
  const plistPath = launchdPlistPath();
253
265
  mkdirSync(path.dirname(plistPath), { recursive: true });
@@ -259,34 +271,73 @@ async function installLaunchd(spec, logger) {
259
271
 
260
272
  async function startLaunchd(logger) {
261
273
  const plistPath = launchdPlistPath();
262
- const target = `gui/${process.getuid?.() ?? ""}`;
274
+ const target = launchdDomainTarget();
263
275
  if (!existsSync(plistPath)) return { ok: false, error: "launchd plist not installed" };
264
- await serviceRun("launchctl", ["bootout", target, plistPath]);
276
+ const stopped = await stopLaunchd({ info() {}, warn() {} });
277
+ if (!stopped.ok) return { ok: false, provider: "launchd", stop: stopped };
265
278
  const boot = await serviceRun("launchctl", ["bootstrap", target, plistPath]);
266
279
  const kick = await serviceRun("launchctl", ["kickstart", "-k", `${target}/${LABEL}`]);
267
- logger.info?.("launchd service started");
268
- return { ok: boot.code === 0 || kick.code === 0, bootstrap: boot, kickstart: kick };
280
+ const ok = boot.code === 0 || kick.code === 0;
281
+ if (ok) logger.info?.("launchd service started");
282
+ else logger.warn?.("launchd service failed to start");
283
+ return { ok, provider: "launchd", bootstrap: boot, kickstart: kick };
269
284
  }
270
285
 
271
286
  async function stopLaunchd(logger) {
272
287
  const plistPath = launchdPlistPath();
273
- const target = `gui/${process.getuid?.() ?? ""}`;
274
- const result = await serviceRun("launchctl", ["bootout", target, plistPath]);
275
- logger.info?.("launchd service stopped");
276
- return result;
288
+ const domainTarget = launchdDomainTarget();
289
+ const serviceTarget = launchdServiceTarget();
290
+ const before = await statusLaunchd();
291
+ if (!before.active) {
292
+ logger.info?.("launchd service is not loaded");
293
+ return {
294
+ ok: true,
295
+ provider: "launchd",
296
+ installed: existsSync(plistPath),
297
+ active_before: false,
298
+ active: false,
299
+ already_stopped: true,
300
+ code: 0,
301
+ stdout: "",
302
+ stderr: "",
303
+ };
304
+ }
305
+
306
+ const byServiceTarget = await serviceRun("launchctl", ["bootout", serviceTarget]);
307
+ const byPlist = byServiceTarget.code === 0
308
+ ? null
309
+ : await serviceRun("launchctl", ["bootout", domainTarget, plistPath]);
310
+ const after = await statusLaunchd();
311
+ const rawResult = byPlist || byServiceTarget;
312
+ const ok = !after.active;
313
+ if (ok) logger.info?.("launchd service stopped");
314
+ else logger.warn?.("launchd service is still active after the stop request");
315
+ return {
316
+ ...rawResult,
317
+ ok,
318
+ provider: "launchd",
319
+ installed: existsSync(plistPath),
320
+ active_before: true,
321
+ active: after.active,
322
+ already_stopped: false,
323
+ code: ok ? 0 : rawResult.code,
324
+ bootout_service_target: byServiceTarget,
325
+ ...(byPlist ? { bootout_plist_fallback: byPlist } : {}),
326
+ };
277
327
  }
278
328
 
279
329
  async function uninstallLaunchd(logger) {
280
- await stopLaunchd(logger).catch(() => {});
330
+ const stopped = await stopLaunchd(logger);
281
331
  const plistPath = launchdPlistPath();
332
+ if (!stopped.ok) return { ok: false, provider: "launchd", path: plistPath, stop: stopped };
282
333
  if (existsSync(plistPath)) rmSync(plistPath, { force: true });
283
334
  logger.info?.("Autostart removed.");
284
- return { ok: true, provider: "launchd", path: plistPath };
335
+ return { ok: true, provider: "launchd", path: plistPath, stop: stopped };
285
336
  }
286
337
 
287
338
  async function statusLaunchd() {
288
339
  const plistPath = launchdPlistPath();
289
- const target = `gui/${process.getuid?.() ?? ""}/${LABEL}`;
340
+ const target = launchdServiceTarget();
290
341
  const result = await serviceRun("launchctl", ["print", target]);
291
342
  return { ok: existsSync(plistPath), provider: "launchd", installed: existsSync(plistPath), path: plistPath, active: result.code === 0, detail: result.stdout || result.stderr };
292
343
  }
@@ -3,7 +3,7 @@ import toolCatalog from "../shared/tool-catalog.json";
3
3
  import serverMetadata from "../shared/server-metadata.json";
4
4
 
5
5
  const SERVER_NAME = String(serverMetadata.name);
6
- const SERVER_VERSION = "0.11.0";
6
+ const SERVER_VERSION = "0.11.1";
7
7
  const MCP_PROTOCOL_VERSION = String(serverMetadata.protocolVersion);
8
8
  const MCP_SUPPORTED_PROTOCOL_VERSIONS = serverMetadata.supportedProtocolVersions.map((value) => String(value));
9
9
  const JSONRPC_VERSION = "2.0";