@chrrxs/robloxstudio-mcp 3.1.5 → 3.2.0

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/dist/index.js CHANGED
@@ -2710,7 +2710,7 @@ var BridgeService = class {
2710
2710
  if (this.pendingRequests.size >= MAX_PENDING_REQUESTS || this.pendingRequestBytes + requestBytes > MAX_PENDING_REQUEST_BYTES) {
2711
2711
  return Promise.reject(new RequestFailure(`Request ${requestId} rejected at admission: pending capacity exceeded (${this.pendingRequests.size}/${MAX_PENDING_REQUESTS} requests, ${this.pendingRequestBytes + requestBytes}/${MAX_PENDING_REQUEST_BYTES} bytes); queued; not_executed`, "request_capacity_exceeded", { ...details, bytes: this.pendingRequestBytes + requestBytes, limitBytes: MAX_PENDING_REQUEST_BYTES }));
2712
2712
  }
2713
- const { promise, resolve: resolve5, reject } = Promise.withResolvers();
2713
+ const { promise, resolve: resolve6, reject } = Promise.withResolvers();
2714
2714
  const abortListener = () => {
2715
2715
  const pending = this.pendingRequests.get(requestId);
2716
2716
  if (pending)
@@ -2728,7 +2728,7 @@ var BridgeService = class {
2728
2728
  data,
2729
2729
  targetPeerId,
2730
2730
  timestamp: now,
2731
- resolve: resolve5,
2731
+ resolve: resolve6,
2732
2732
  reject,
2733
2733
  promise,
2734
2734
  timeoutId,
@@ -3127,19 +3127,20 @@ async function fetchDocCatalog() {
3127
3127
  }
3128
3128
  const controller = new AbortController();
3129
3129
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
3130
- let response;
3130
+ let html;
3131
3131
  try {
3132
- response = await fetch(DOCS_INDEX_URL, {
3132
+ const response = await fetch(DOCS_INDEX_URL, {
3133
3133
  signal: controller.signal,
3134
3134
  headers: { Accept: "text/html" }
3135
3135
  });
3136
+ if (!response.ok) {
3137
+ await response.body?.cancel();
3138
+ throw new Error(`Failed to fetch Roblox docs index: HTTP ${response.status}`);
3139
+ }
3140
+ html = await response.text();
3136
3141
  } finally {
3137
3142
  clearTimeout(timer);
3138
3143
  }
3139
- if (!response.ok) {
3140
- throw new Error(`Failed to fetch Roblox docs index: HTTP ${response.status}`);
3141
- }
3142
- const html = await response.text();
3143
3144
  const pages = /* @__PURE__ */ new Map();
3144
3145
  const routePattern = /reference\/engine\/(classes|enums|datatypes|libraries|globals)\/([A-Za-z0-9_]+)/g;
3145
3146
  for (const match of html.matchAll(routePattern)) {
@@ -3192,27 +3193,30 @@ async function fetchRobloxDoc(category, name) {
3192
3193
  }
3193
3194
  const controller = new AbortController();
3194
3195
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
3195
- let response;
3196
3196
  try {
3197
- response = await fetch(docUrl(category, name), {
3197
+ const response = await fetch(docUrl(category, name), {
3198
3198
  signal: controller.signal,
3199
3199
  headers: { Accept: "text/markdown, text/plain" }
3200
3200
  });
3201
+ if (response.status === 404) {
3202
+ await response.body?.cancel();
3203
+ cacheSet(key, { fetchedAt: Date.now(), notFound: true });
3204
+ throw new DocNotFoundError(category, name);
3205
+ }
3206
+ if (!response.ok) {
3207
+ await response.body?.cancel();
3208
+ throw new Error(`HTTP ${response.status}`);
3209
+ }
3210
+ const content = await response.text();
3211
+ cacheSet(key, { fetchedAt: Date.now(), content });
3212
+ return content;
3201
3213
  } catch (error) {
3214
+ if (error instanceof DocNotFoundError)
3215
+ throw error;
3202
3216
  throw new Error(`Failed to fetch Roblox docs for ${key}: ${error instanceof Error ? error.message : String(error)}`);
3203
3217
  } finally {
3204
3218
  clearTimeout(timer);
3205
3219
  }
3206
- if (response.status === 404) {
3207
- cacheSet(key, { fetchedAt: Date.now(), notFound: true });
3208
- throw new DocNotFoundError(category, name);
3209
- }
3210
- if (!response.ok) {
3211
- throw new Error(`Failed to fetch Roblox docs for ${key}: HTTP ${response.status}`);
3212
- }
3213
- const content = await response.text();
3214
- cacheSet(key, { fetchedAt: Date.now(), content });
3215
- return content;
3216
3220
  }
3217
3221
  function listSections(markdown) {
3218
3222
  const sections = [];
@@ -3342,6 +3346,7 @@ Read output with get_runtime_logs. Reuse nextCursor as cursor for one Instance,
3342
3346
  - breakpoints clear removes only MCP-created breakpoints unless clear_all is true. clear_all also removes user-created breakpoints.
3343
3347
  - capture_script_profiler ranks Luau functions by CPU time. Use output_path when the raw capture is needed.
3344
3348
  - capture_micro_profiler attributes frame time across engine and game work. Its rows are inclusive or cumulative views, so do not sum them as disjoint totals.
3349
+ - MicroProfiler records frames only while Studio renders. Studio stops rendering when the display turns off or Studio is minimized, and the capture then fails with micro_profiler_no_frames.
3345
3350
  - Use baseline_path or baseline for before-and-after MicroProfiler comparisons.
3346
3351
  - Use get_memory_breakdown for memory categories and get_scene_analysis for instance, script, triangle, animation, or audio cost.
3347
3352
 
@@ -3353,6 +3358,16 @@ Studio must allow third-party asset loading for public third-party previews and
3353
3358
 
3354
3359
  generate_model stages generated content under ServerStorage for review. upload_asset sends an explicit local file to the chosen Roblox user or group.
3355
3360
 
3361
+ ## Monetization
3362
+
3363
+ manage_monetization lists, reads, creates, and updates developer products and game passes through Roblox Open Cloud. ROBLOX_OPEN_CLOUD_API_KEY needs developer-product:read and developer-product:write, or game-pass:read and game-pass:write, with the experience added to the key. universe_id defaults to the connected Studio place; an unpublished place has no universe.
3364
+
3365
+ - Roblox cannot delete developer products or game passes. To retire one, update it with for_sale=false.
3366
+ - create keeps new items off sale unless for_sale=true, which also needs a price.
3367
+ - create refuses a name that already exists in the universe and returns the existing item. If a create times out, list the universe before retrying.
3368
+ - update changes only the arguments supplied, then reads the item back.
3369
+ - image_path uploads a local PNG, JPEG, or BMP icon. Roblox shows icons at 512x512 inside a circle, so keep details away from the corners.
3370
+
3356
3371
  ## RBXM files
3357
3372
 
3358
3373
  - export_rbxm writes selected instances to an explicit local path. It can read the edit DataModel or a live server DataModel.
@@ -3410,18 +3425,104 @@ function registerResourceHandlers(server) {
3410
3425
  }
3411
3426
 
3412
3427
  // ../core/dist/studio-instance-manager.js
3413
- import { execFile, execFileSync as execFileSync2, spawn } from "child_process";
3414
- import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync2, realpathSync, rmSync, statSync } from "fs";
3415
- import { randomUUID as randomUUID3 } from "crypto";
3428
+ import { execFile, execFileSync as execFileSync2, spawn as spawn2 } from "child_process";
3429
+ import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, statSync as statSync2 } from "fs";
3430
+ import { randomUUID as randomUUID4 } from "crypto";
3416
3431
  import * as os2 from "os";
3417
- import * as path2 from "path";
3432
+ import * as path5 from "path";
3418
3433
  import { promisify } from "util";
3419
3434
 
3435
+ // ../core/dist/studio-sign-in.js
3436
+ import { open, readdir } from "fs/promises";
3437
+ import * as path from "path";
3438
+ import { setTimeout as delay } from "timers/promises";
3439
+ var SESSION_LOG = /^[\d.]+_(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z_Studio_[A-Fa-f0-9]+_last\.log$/u;
3440
+ var LINE_TIME = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z),/u;
3441
+ var SIGN_IN_START = /\[FLog::StudioKeyEvents\] login \([^)]*\) \[start\]/u;
3442
+ var SIGN_IN_END = /\[FLog::StudioKeyEvents\] login \[end\]\[(success|failure)\]/u;
3443
+ var HEAD_BYTES = 1024 * 1024;
3444
+ var FILETIME_UNIX_EPOCH = 116444736000000000n;
3445
+ function fileTimeToUnixMs(fileTime) {
3446
+ return Number((BigInt(fileTime) - FILETIME_UNIX_EPOCH) / 10000n);
3447
+ }
3448
+ function studioSessionLogStartMs(name) {
3449
+ const match = SESSION_LOG.exec(name);
3450
+ if (!match)
3451
+ return void 0;
3452
+ const [, year, month, day, hour, minute, second] = match.map(Number);
3453
+ return Date.UTC(year, month - 1, day, hour, minute, second);
3454
+ }
3455
+ function parseStudioSignIn(name, text) {
3456
+ const session = { name, signingIn: false };
3457
+ for (const line of text.split(/\r?\n/u)) {
3458
+ if (SIGN_IN_START.test(line)) {
3459
+ session.signingIn = true;
3460
+ continue;
3461
+ }
3462
+ const end = SIGN_IN_END.exec(line);
3463
+ if (!end)
3464
+ continue;
3465
+ session.signingIn = false;
3466
+ session.outcome = end[1];
3467
+ const time = LINE_TIME.exec(line)?.[1];
3468
+ if (time !== void 0)
3469
+ session.lastEndMs = Date.parse(time);
3470
+ }
3471
+ return session;
3472
+ }
3473
+ async function readStudioSignInSessions(logsRoot, fromMs, toMs) {
3474
+ let names;
3475
+ try {
3476
+ names = await readdir(logsRoot);
3477
+ } catch (error) {
3478
+ if (error.code === "ENOENT")
3479
+ return [];
3480
+ throw error;
3481
+ }
3482
+ const sessions = [];
3483
+ for (const name of names) {
3484
+ const startedAt = studioSessionLogStartMs(name);
3485
+ if (startedAt === void 0 || startedAt < fromMs || startedAt > toMs)
3486
+ continue;
3487
+ const file = await open(path.join(logsRoot, name), "r");
3488
+ try {
3489
+ const buffer = Buffer.alloc(HEAD_BYTES);
3490
+ const { bytesRead } = await file.read(buffer, 0, HEAD_BYTES, 0);
3491
+ sessions.push(parseStudioSignIn(name, buffer.subarray(0, bytesRead).toString("utf8")));
3492
+ } finally {
3493
+ await file.close();
3494
+ }
3495
+ }
3496
+ return sessions;
3497
+ }
3498
+ async function waitForStudioSignInToSettle({ logsRoot, processStartedAtMs, timeoutMs = 15e3, settleMs = 1500, youngMs = 5e3, now = Date.now, sleep: sleep2 = delay }) {
3499
+ const begin = now();
3500
+ for (; ; ) {
3501
+ const sessions = await readStudioSignInSessions(logsRoot, processStartedAtMs - 2e3, processStartedAtMs + 3e4);
3502
+ const signingIn = sessions.some((session) => session.signingIn);
3503
+ const concluded = sessions.some((session) => session.outcome !== void 0);
3504
+ const undecided = !signingIn && !concluded && now() - processStartedAtMs < youngMs;
3505
+ if (!signingIn && !undecided) {
3506
+ const ends = sessions.flatMap((session) => session.lastEndMs === void 0 ? [] : [session.lastEndMs]);
3507
+ if (!ends.length)
3508
+ return "idle";
3509
+ const sinceEnd = now() - Math.max(...ends);
3510
+ const remaining = Math.min(settleMs, Math.max(0, settleMs - sinceEnd));
3511
+ if (remaining > 0)
3512
+ await sleep2(remaining);
3513
+ return "settled";
3514
+ }
3515
+ if (now() - begin >= timeoutMs)
3516
+ return "timeout";
3517
+ await sleep2(250);
3518
+ }
3519
+ }
3520
+
3420
3521
  // ../core/dist/managed-instance-registry.js
3421
3522
  import * as fs from "fs/promises";
3422
3523
  import { randomUUID as randomUUID2 } from "crypto";
3423
3524
  import * as os from "os";
3424
- import * as path from "path";
3525
+ import * as path2 from "path";
3425
3526
  import { setTimeout as delayTimer } from "timers/promises";
3426
3527
  var REGISTRY_VERSION = 1;
3427
3528
  var LOCK_STALE_MS = 1e4;
@@ -3438,14 +3539,14 @@ function defaultManagedInstanceRegistryDir() {
3438
3539
  return process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR;
3439
3540
  }
3440
3541
  if (process.platform === "win32" && process.env.LOCALAPPDATA) {
3441
- return path.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", "managed-instances", "v1");
3542
+ return path2.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", "managed-instances", "v1");
3442
3543
  }
3443
3544
  if (process.platform === "darwin") {
3444
- return path.join(os.homedir(), "Library", "Application Support", "robloxstudio-mcp", "managed-instances", "v1");
3545
+ return path2.join(os.homedir(), "Library", "Application Support", "robloxstudio-mcp", "managed-instances", "v1");
3445
3546
  }
3446
- return path.join(os.homedir(), ".local", "state", "robloxstudio-mcp", "managed-instances", "v1");
3547
+ return path2.join(os.homedir(), ".local", "state", "robloxstudio-mcp", "managed-instances", "v1");
3447
3548
  }
3448
- function delay(ms) {
3549
+ function delay2(ms) {
3449
3550
  return delayTimer(ms, void 0, { ref: false });
3450
3551
  }
3451
3552
  function ymd(timestamp) {
@@ -3540,7 +3641,7 @@ var ManagedInstanceRegistry = class {
3540
3641
  }
3541
3642
  async withLock(fn) {
3542
3643
  await this.ensureDir();
3543
- const lockDir = path.join(this.dir, ".lock");
3644
+ const lockDir = path2.join(this.dir, ".lock");
3544
3645
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
3545
3646
  const owner = {
3546
3647
  pid: process.pid,
@@ -3553,7 +3654,7 @@ var ManagedInstanceRegistry = class {
3553
3654
  activeLockTokens.add(owner.token);
3554
3655
  activeLockPaths.add(lockDir);
3555
3656
  try {
3556
- await fs.writeFile(path.join(lockDir, "owner.json"), `${JSON.stringify(owner)}
3657
+ await fs.writeFile(path2.join(lockDir, "owner.json"), `${JSON.stringify(owner)}
3557
3658
  `, {
3558
3659
  encoding: "utf8",
3559
3660
  flag: "wx"
@@ -3574,7 +3675,9 @@ var ManagedInstanceRegistry = class {
3574
3675
  const currentOwner = await this.readLockOwner(lockDir, stat2.isDirectory());
3575
3676
  const ownerIsActive = currentOwner?.pid === process.pid ? activeLockTokens.has(currentOwner.token) : currentOwner ? isProcessAlive(currentOwner.pid) : void 0;
3576
3677
  if (ownerIsActive === false || currentOwner === void 0 && !activeLockPaths.has(lockDir) && Date.now() - stat2.mtimeMs > LOCK_STALE_MS) {
3577
- await fs.rm(lockDir, { recursive: true, force: true });
3678
+ if (await this.isInspectedLock(lockDir, stat2, currentOwner)) {
3679
+ await fs.rm(lockDir, { recursive: true, force: true });
3680
+ }
3578
3681
  continue;
3579
3682
  }
3580
3683
  } catch {
@@ -3583,7 +3686,7 @@ var ManagedInstanceRegistry = class {
3583
3686
  if (Date.now() > deadline) {
3584
3687
  throw new Error(`Timed out waiting for managed instance registry lock: ${lockDir}`);
3585
3688
  }
3586
- await delay(LOCK_RETRY_MS);
3689
+ await delay2(LOCK_RETRY_MS);
3587
3690
  }
3588
3691
  }
3589
3692
  try {
@@ -3604,21 +3707,31 @@ var ManagedInstanceRegistry = class {
3604
3707
  }
3605
3708
  async readLockOwner(lockPath, isDirectory) {
3606
3709
  try {
3607
- const contents = await fs.readFile(isDirectory ? path.join(lockPath, "owner.json") : lockPath, "utf8");
3710
+ const contents = await fs.readFile(isDirectory ? path2.join(lockPath, "owner.json") : lockPath, "utf8");
3608
3711
  const parsed = JSON.parse(contents);
3609
3712
  return isLockOwner(parsed) ? parsed : void 0;
3610
3713
  } catch {
3611
3714
  return void 0;
3612
3715
  }
3613
3716
  }
3717
+ // The inspected owner can release, and another holder can take the lock, while this
3718
+ // waiter decides. Reclaim only the instance that was judged abandoned: the same owner
3719
+ // token, or for an unreadable owner the same unchanged directory entry.
3720
+ async isInspectedLock(lockPath, inspected, inspectedOwner) {
3721
+ const current = await fs.stat(lockPath);
3722
+ const currentOwner = await this.readLockOwner(lockPath, current.isDirectory());
3723
+ if (inspectedOwner)
3724
+ return currentOwner?.token === inspectedOwner.token;
3725
+ return currentOwner === void 0 && current.ino === inspected.ino && current.mtimeMs === inspected.mtimeMs;
3726
+ }
3614
3727
  async ensureDir() {
3615
3728
  await fs.mkdir(this.dir, { recursive: true });
3616
3729
  }
3617
3730
  recordPath(recordId) {
3618
- return path.join(this.dir, `${recordId}.json`);
3731
+ return path2.join(this.dir, `${recordId}.json`);
3619
3732
  }
3620
3733
  async recordFilesUnlocked() {
3621
- return (await fs.readdir(this.dir)).filter((name) => name.endsWith(".json")).map((name) => path.join(this.dir, name));
3734
+ return (await fs.readdir(this.dir)).filter((name) => name.endsWith(".json")).map((name) => path2.join(this.dir, name));
3622
3735
  }
3623
3736
  async readRecordUnlocked(recordId) {
3624
3737
  try {
@@ -3647,7 +3760,7 @@ var ManagedInstanceRegistry = class {
3647
3760
  async writeRecordUnlocked(record) {
3648
3761
  await this.ensureDir();
3649
3762
  const finalPath = this.recordPath(record.recordId);
3650
- const tmpPath = path.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
3763
+ const tmpPath = path2.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
3651
3764
  const fd = await fs.open(tmpPath, "w");
3652
3765
  try {
3653
3766
  await fd.writeFile(`${JSON.stringify(record, null, 2)}
@@ -3662,7 +3775,7 @@ var ManagedInstanceRegistry = class {
3662
3775
  await fs.rm(this.recordPath(recordId), { force: true });
3663
3776
  }
3664
3777
  async appendEventUnlocked(event, now) {
3665
- const file = path.join(this.dir, `events-${ymd(now)}.jsonl`);
3778
+ const file = path2.join(this.dir, `events-${ymd(now)}.jsonl`);
3666
3779
  await fs.appendFile(file, `${JSON.stringify({
3667
3780
  ts: new Date(now).toISOString(),
3668
3781
  ...event
@@ -3676,7 +3789,7 @@ var ManagedInstanceRegistry = class {
3676
3789
  if (!date || date >= cutoff)
3677
3790
  continue;
3678
3791
  try {
3679
- await fs.rm(path.join(this.dir, name), { force: true });
3792
+ await fs.rm(path2.join(this.dir, name), { force: true });
3680
3793
  } catch {
3681
3794
  }
3682
3795
  }
@@ -3819,7 +3932,8 @@ var ManagedInstanceRegistry = class {
3819
3932
 
3820
3933
  // ../core/dist/studio-platform.js
3821
3934
  import { execFileSync } from "child_process";
3822
- import { existsSync, readFileSync } from "fs";
3935
+ import { accessSync, constants, existsSync, readFileSync, statSync } from "fs";
3936
+ import * as path3 from "path";
3823
3937
  var WSL_KERNEL_PATTERN = /microsoft|wsl/i;
3824
3938
  var WINDOWS_INTEROP_PROBE_TOKEN = "ROBLOXSTUDIO_MCP_WINDOWS_INTEROP_OK";
3825
3939
  var WINDOWS_POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe";
@@ -3860,7 +3974,20 @@ function detectStudioPlatform(evidence) {
3860
3974
  }
3861
3975
  };
3862
3976
  }
3863
- const reason = hasWslKernel ? "The Linux kernel has a WSL signature, but the live process cannot execute the Windows launcher." : "The retained Windows Studio launcher is unavailable on native Linux.";
3977
+ if (!hasWslKernel && evidence.wineLauncher && evidence.wineLauncherExecutable === true) {
3978
+ return {
3979
+ hostPlatform: "linux",
3980
+ isWsl: false,
3981
+ windowsInteropAvailable: false,
3982
+ processIdentity: {
3983
+ supported: true,
3984
+ launcher: "wine-retained"
3985
+ },
3986
+ // Wine hosts are always Linux; keep POSIX semantics when tests run elsewhere.
3987
+ wineLauncher: path3.posix.resolve(evidence.wineLauncher)
3988
+ };
3989
+ }
3990
+ const reason = hasWslKernel ? "The Linux kernel has a WSL signature, but the live process cannot execute the Windows launcher." : evidence.wineLauncher ? `The retained Windows Studio launcher is unavailable on native Linux, and ROBLOX_STUDIO_WINE_LAUNCHER (${evidence.wineLauncher}) is not an executable file.` : "The retained Windows Studio launcher is unavailable on native Linux. Set ROBLOX_STUDIO_WINE_LAUNCHER to an executable Wine launcher to run Studio under Wine.";
3864
3991
  return {
3865
3992
  hostPlatform: "linux",
3866
3993
  isWsl: false,
@@ -3910,11 +4037,22 @@ function probeWindowsInterop(kernelVersion) {
3910
4037
  }
3911
4038
  return false;
3912
4039
  }
4040
+ function isExecutableFile(file) {
4041
+ try {
4042
+ if (!statSync(file).isFile())
4043
+ return false;
4044
+ accessSync(file, constants.X_OK);
4045
+ return true;
4046
+ } catch {
4047
+ return false;
4048
+ }
4049
+ }
3913
4050
  var cachedStudioPlatformCapabilities;
3914
4051
  function getStudioPlatformCapabilities() {
3915
4052
  if (cachedStudioPlatformCapabilities)
3916
4053
  return cachedStudioPlatformCapabilities;
3917
4054
  const kernelVersion = readKernelVersion();
4055
+ const wineLauncher = process.env.ROBLOX_STUDIO_WINE_LAUNCHER || void 0;
3918
4056
  cachedStudioPlatformCapabilities = detectStudioPlatform({
3919
4057
  platform: process.platform,
3920
4058
  kernelVersion,
@@ -3922,13 +4060,435 @@ function getStudioPlatformCapabilities() {
3922
4060
  wslDistroName: process.env.WSL_DISTRO_NAME,
3923
4061
  windowsRootPresent: existsSync("/mnt/c/Windows"),
3924
4062
  wslPathPresent: existsSync("/usr/bin/wslpath") || existsSync("/bin/wslpath"),
3925
- windowsInteropAvailable: probeWindowsInterop(kernelVersion)
4063
+ windowsInteropAvailable: probeWindowsInterop(kernelVersion),
4064
+ wineLauncher,
4065
+ wineLauncherExecutable: process.platform === "linux" && wineLauncher !== void 0 && isExecutableFile(wineLauncher)
3926
4066
  });
3927
4067
  return cachedStudioPlatformCapabilities;
3928
4068
  }
3929
4069
 
4070
+ // ../core/dist/studio-wine.js
4071
+ import { spawn } from "child_process";
4072
+ import { randomUUID as randomUUID3 } from "crypto";
4073
+ import { closeSync, fstatSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync as readFileSync2, readSync, rmSync, unlinkSync, writeFileSync } from "fs";
4074
+ import * as path4 from "path";
4075
+ var DEFAULT_PROC_ROOT = "/proc";
4076
+ var STUDIO_EXECUTABLE_NAME = "robloxstudiobeta.exe";
4077
+ var GATE_SCRIPT = 'kill -STOP $$ && exec "$0" "$@"';
4078
+ var GATE_TIMEOUT_MS = 5e3;
4079
+ var STUDIO_EXEC_TIMEOUT_MS = 6e4;
4080
+ var MAX_STUDIO_EXEC_TIMEOUT_MS = 15e4;
4081
+ var STUDIO_EXEC_TIMEOUT_ENV = "ROBLOX_STUDIO_WINE_START_TIMEOUT_MS";
4082
+ var ABORT_TIMEOUT_MS = 1e4;
4083
+ var TERMINATE_GRACE_MS = 5e3;
4084
+ var POLL_INTERVAL_MS = 50;
4085
+ var LAUNCHER_LOGS_KEPT = 10;
4086
+ var LAUNCHER_LOG_TAIL_BYTES = 4096;
4087
+ var LAUNCHER_LOG_TAIL_LINES = 20;
4088
+ var BOOT_ANCHOR_PREFIX = "wine-boot-time-";
4089
+ var FILETIME_UNIX_EPOCH2 = 116444736000000000n;
4090
+ var FILETIME_TICKS_PER_SECOND = 10000000n;
4091
+ var USER_HZ = 100n;
4092
+ var bootTimeByProcRoot = /* @__PURE__ */ new Map();
4093
+ function delay3(ms) {
4094
+ const { promise, resolve: resolve6 } = Promise.withResolvers();
4095
+ setTimeout(resolve6, ms);
4096
+ return promise;
4097
+ }
4098
+ function isMissingProcessError(error) {
4099
+ const code = error?.code;
4100
+ return code === "ENOENT" || code === "ESRCH";
4101
+ }
4102
+ function procStartTimeToFileTime(bootTimeSeconds2, startTimeTicks) {
4103
+ return (FILETIME_UNIX_EPOCH2 + bootTimeSeconds2 * FILETIME_TICKS_PER_SECOND + startTimeTicks * (FILETIME_TICKS_PER_SECOND / USER_HZ)).toString();
4104
+ }
4105
+ function toWineWindowsPath(unixPath) {
4106
+ return `Z:${unixPath.replace(/\//g, "\\")}`;
4107
+ }
4108
+ function isWineStudioCommand(argv0) {
4109
+ return argv0 !== void 0 && argv0.split(/[\\/]/u).pop()?.toLowerCase() === STUDIO_EXECUTABLE_NAME;
4110
+ }
4111
+ function parseProcStat(content) {
4112
+ const commEnd = content.lastIndexOf(")");
4113
+ const fields = commEnd < 0 ? [] : content.slice(commEnd + 1).trim().split(/\s+/u);
4114
+ const state = fields[0];
4115
+ const startTime = fields[19];
4116
+ if (!state || !startTime || !/^\d+$/u.test(startTime)) {
4117
+ throw new Error("Malformed /proc/<pid>/stat.");
4118
+ }
4119
+ return { state, startTimeTicks: BigInt(startTime) };
4120
+ }
4121
+ function readProcStat(procRoot, pid) {
4122
+ let content;
4123
+ try {
4124
+ content = readFileSync2(`${procRoot}/${pid}/stat`, "utf8");
4125
+ } catch (error) {
4126
+ if (isMissingProcessError(error))
4127
+ return void 0;
4128
+ throw error;
4129
+ }
4130
+ return parseProcStat(content);
4131
+ }
4132
+ function readCmdline(procRoot, pid) {
4133
+ let content;
4134
+ try {
4135
+ content = readFileSync2(`${procRoot}/${pid}/cmdline`, "utf8");
4136
+ } catch (error) {
4137
+ if (isMissingProcessError(error))
4138
+ return void 0;
4139
+ throw error;
4140
+ }
4141
+ const args = content.split("\0");
4142
+ if (args[args.length - 1] === "")
4143
+ args.pop();
4144
+ return args;
4145
+ }
4146
+ function readArgv0(procRoot, pid) {
4147
+ return readCmdline(procRoot, pid)?.[0] || void 0;
4148
+ }
4149
+ function readRealUid(procRoot, pid) {
4150
+ let content;
4151
+ try {
4152
+ content = readFileSync2(`${procRoot}/${pid}/status`, "utf8");
4153
+ } catch (error) {
4154
+ if (isMissingProcessError(error))
4155
+ return void 0;
4156
+ throw error;
4157
+ }
4158
+ const match = /^Uid:\s+(\d+)/mu.exec(content);
4159
+ if (!match)
4160
+ throw new Error(`Malformed ${procRoot}/${pid}/status.`);
4161
+ return Number(match[1]);
4162
+ }
4163
+ function readLiveBootTime(procRoot) {
4164
+ const match = /^btime\s+(\d+)\s*$/mu.exec(readFileSync2(`${procRoot}/stat`, "utf8"));
4165
+ if (!match)
4166
+ throw new Error(`${procRoot}/stat has no btime.`);
4167
+ return BigInt(match[1]);
4168
+ }
4169
+ function readBootId(procRoot) {
4170
+ try {
4171
+ const bootId = readFileSync2(`${procRoot}/sys/kernel/random/boot_id`, "utf8").trim();
4172
+ return /^[0-9a-f-]+$/iu.test(bootId) ? bootId : void 0;
4173
+ } catch {
4174
+ return void 0;
4175
+ }
4176
+ }
4177
+ function readBootAnchor(file) {
4178
+ let content;
4179
+ try {
4180
+ content = readFileSync2(file, "utf8");
4181
+ } catch (error) {
4182
+ if (error.code === "ENOENT")
4183
+ return void 0;
4184
+ throw error;
4185
+ }
4186
+ const match = /^(\d+)\n?$/u.exec(content);
4187
+ if (!match)
4188
+ throw new Error(`${file} is not a boot time.`);
4189
+ return BigInt(match[1]);
4190
+ }
4191
+ function removeStaleBootAnchors(anchorDir, currentName) {
4192
+ try {
4193
+ for (const name of readdirSync(anchorDir)) {
4194
+ if (!name.startsWith(BOOT_ANCHOR_PREFIX) || name.startsWith(currentName))
4195
+ continue;
4196
+ rmSync(path4.join(anchorDir, name), { force: true });
4197
+ }
4198
+ } catch {
4199
+ }
4200
+ }
4201
+ function anchoredBootTime(procRoot, liveBootTime) {
4202
+ const bootId = readBootId(procRoot);
4203
+ if (bootId === void 0)
4204
+ return liveBootTime;
4205
+ const anchorDir = defaultManagedInstanceRegistryDir();
4206
+ const anchorName = `${BOOT_ANCHOR_PREFIX}${bootId}`;
4207
+ const anchorFile = path4.join(anchorDir, anchorName);
4208
+ try {
4209
+ const existing = readBootAnchor(anchorFile);
4210
+ if (existing !== void 0)
4211
+ return existing;
4212
+ mkdirSync(anchorDir, { recursive: true });
4213
+ const tmpFile = `${anchorFile}.${process.pid}.${randomUUID3()}.tmp`;
4214
+ writeFileSync(tmpFile, `${liveBootTime}
4215
+ `);
4216
+ try {
4217
+ linkSync(tmpFile, anchorFile);
4218
+ } catch (error) {
4219
+ if (error.code !== "EEXIST")
4220
+ throw error;
4221
+ } finally {
4222
+ unlinkSync(tmpFile);
4223
+ }
4224
+ removeStaleBootAnchors(anchorDir, anchorName);
4225
+ return readBootAnchor(anchorFile) ?? liveBootTime;
4226
+ } catch (error) {
4227
+ console.error(`[studio] Could not persist the Wine boot-time anchor at ${anchorFile}: ${error instanceof Error ? error.message : String(error)}. Studio identities may not survive a broker restart after a clock change.`);
4228
+ return liveBootTime;
4229
+ }
4230
+ }
4231
+ function bootTimeSeconds(procRoot) {
4232
+ const cached = bootTimeByProcRoot.get(procRoot);
4233
+ if (cached !== void 0)
4234
+ return cached;
4235
+ const bootTime = anchoredBootTime(procRoot, readLiveBootTime(procRoot));
4236
+ bootTimeByProcRoot.set(procRoot, bootTime);
4237
+ return bootTime;
4238
+ }
4239
+ function readWineProcessStartTime(pid, procRoot = DEFAULT_PROC_ROOT) {
4240
+ const stat2 = readProcStat(procRoot, pid);
4241
+ if (!stat2 || stat2.state === "Z" || stat2.state === "X")
4242
+ return void 0;
4243
+ return procStartTimeToFileTime(bootTimeSeconds(procRoot), stat2.startTimeTicks);
4244
+ }
4245
+ function listWineStudioProcesses(procRoot = DEFAULT_PROC_ROOT, uid = process.getuid?.()) {
4246
+ const processes = [];
4247
+ for (const entry of readdirSync(procRoot)) {
4248
+ if (!/^\d+$/u.test(entry))
4249
+ continue;
4250
+ const pid = Number(entry);
4251
+ let argv0;
4252
+ let startedAt;
4253
+ try {
4254
+ if (uid !== void 0 && readRealUid(procRoot, pid) !== uid)
4255
+ continue;
4256
+ argv0 = readArgv0(procRoot, pid);
4257
+ if (!isWineStudioCommand(argv0))
4258
+ continue;
4259
+ startedAt = readWineProcessStartTime(pid, procRoot);
4260
+ } catch (error) {
4261
+ if (error.code === "EACCES" || isMissingProcessError(error))
4262
+ continue;
4263
+ throw error;
4264
+ }
4265
+ if (startedAt === void 0)
4266
+ continue;
4267
+ processes.push({
4268
+ Id: pid,
4269
+ Name: "RobloxStudioBeta",
4270
+ Path: argv0,
4271
+ MainWindowTitle: "",
4272
+ StartTimeUtcFileTime: startedAt
4273
+ });
4274
+ }
4275
+ return processes;
4276
+ }
4277
+ function signalProcess(pid, signal) {
4278
+ try {
4279
+ process.kill(pid, signal);
4280
+ } catch (error) {
4281
+ if (!isMissingProcessError(error))
4282
+ throw error;
4283
+ }
4284
+ }
4285
+ async function stopWineStudio(pid, startedAt, timeoutMs, procRoot = DEFAULT_PROC_ROOT) {
4286
+ if (!Number.isSafeInteger(pid) || pid <= 0)
4287
+ throw new Error("processId must be a positive integer.");
4288
+ if (!/^[1-9]\d*$/u.test(startedAt))
4289
+ throw new Error("startedAt must be a positive FILETIME string.");
4290
+ const isTarget = () => readWineProcessStartTime(pid, procRoot) === startedAt;
4291
+ if (!isTarget())
4292
+ return;
4293
+ signalProcess(pid, "SIGTERM");
4294
+ const graceDeadline = Date.now() + Math.min(TERMINATE_GRACE_MS, timeoutMs / 2);
4295
+ while (Date.now() < graceDeadline) {
4296
+ await delay3(POLL_INTERVAL_MS);
4297
+ if (!isTarget())
4298
+ return;
4299
+ }
4300
+ if (isTarget())
4301
+ signalProcess(pid, "SIGKILL");
4302
+ }
4303
+ function killOrphanedWineLaunchGate(pid, startedAt, procRoot = DEFAULT_PROC_ROOT) {
4304
+ if (!Number.isSafeInteger(pid) || pid <= 0)
4305
+ return false;
4306
+ const stat2 = readProcStat(procRoot, pid);
4307
+ if (stat2?.state !== "T")
4308
+ return false;
4309
+ if (procStartTimeToFileTime(bootTimeSeconds(procRoot), stat2.startTimeTicks) !== startedAt)
4310
+ return false;
4311
+ const cmdline = readCmdline(procRoot, pid);
4312
+ if (cmdline?.[1] !== "-c" || cmdline[2] !== GATE_SCRIPT)
4313
+ return false;
4314
+ signalProcess(pid, "SIGKILL");
4315
+ return true;
4316
+ }
4317
+ function studioExecTimeoutMs(explicit) {
4318
+ if (explicit !== void 0)
4319
+ return explicit;
4320
+ const configured = process.env[STUDIO_EXEC_TIMEOUT_ENV];
4321
+ if (configured === void 0 || configured === "")
4322
+ return STUDIO_EXEC_TIMEOUT_MS;
4323
+ const value = /^\d+$/u.test(configured) ? Number(configured) : NaN;
4324
+ if (!Number.isSafeInteger(value) || value < 1 || value > MAX_STUDIO_EXEC_TIMEOUT_MS) {
4325
+ throw new Error(`${STUDIO_EXEC_TIMEOUT_ENV} must be a whole number of milliseconds from 1 to ${MAX_STUDIO_EXEC_TIMEOUT_MS}; got "${configured}".`);
4326
+ }
4327
+ return value;
4328
+ }
4329
+ function openLauncherLog(logDir) {
4330
+ try {
4331
+ mkdirSync(logDir, { recursive: true });
4332
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/gu, "-");
4333
+ const file = path4.join(logDir, `${stamp}-${randomUUID3().slice(0, 8)}.log`);
4334
+ const fd = openSync(file, "a", 384);
4335
+ const logs = readdirSync(logDir).filter((name) => name.endsWith(".log")).sort();
4336
+ for (const name of logs.slice(0, Math.max(0, logs.length - LAUNCHER_LOGS_KEPT))) {
4337
+ rmSync(path4.join(logDir, name), { force: true });
4338
+ }
4339
+ return { file, fd };
4340
+ } catch (error) {
4341
+ console.error(`[studio] Could not open a Wine launcher log in ${logDir}; launcher output is discarded: ${error instanceof Error ? error.message : String(error)}`);
4342
+ return void 0;
4343
+ }
4344
+ }
4345
+ function launcherLogTail(file) {
4346
+ if (file === void 0)
4347
+ return "";
4348
+ let tail;
4349
+ try {
4350
+ const fd = openSync(file, "r");
4351
+ try {
4352
+ const size = fstatSync(fd).size;
4353
+ const length = Math.min(size, LAUNCHER_LOG_TAIL_BYTES);
4354
+ const buffer = Buffer.alloc(length);
4355
+ readSync(fd, buffer, 0, length, size - length);
4356
+ tail = buffer.toString("utf8");
4357
+ } finally {
4358
+ closeSync(fd);
4359
+ }
4360
+ } catch {
4361
+ return ` Launcher log: ${file}.`;
4362
+ }
4363
+ const lines = tail.trimEnd().split("\n").slice(-LAUNCHER_LOG_TAIL_LINES).join("\n");
4364
+ return lines ? ` Launcher output (${file}):
4365
+ ${lines}` : ` The launcher wrote no output (log: ${file}).`;
4366
+ }
4367
+ async function spawnWineStudio(launcher, exe, args, spawnOptions, options = {}) {
4368
+ const procRoot = options.procRoot ?? DEFAULT_PROC_ROOT;
4369
+ const execTimeoutMs = studioExecTimeoutMs(options.studioExecTimeoutMs);
4370
+ const log = options.logDir === void 0 ? void 0 : openLauncherLog(options.logDir);
4371
+ let child;
4372
+ try {
4373
+ child = spawn("/bin/sh", ["-c", GATE_SCRIPT, launcher, exe, ...args], {
4374
+ cwd: spawnOptions.cwd,
4375
+ env: spawnOptions.env,
4376
+ detached: true,
4377
+ stdio: log ? ["ignore", log.fd, log.fd] : "ignore"
4378
+ });
4379
+ } finally {
4380
+ if (log)
4381
+ closeSync(log.fd);
4382
+ }
4383
+ const logTail = () => launcherLogTail(log?.file);
4384
+ let exitDetail;
4385
+ const { promise: exited, resolve: markExited } = Promise.withResolvers();
4386
+ child.once("exit", (code, signal) => {
4387
+ exitDetail ??= signal ? `signal ${signal}` : `code ${code}`;
4388
+ markExited();
4389
+ });
4390
+ child.once("error", (error) => {
4391
+ exitDetail ??= error.message;
4392
+ });
4393
+ const pid = child.pid;
4394
+ if (pid === void 0) {
4395
+ await delay3(0);
4396
+ throw new Error(`Failed to start the Wine Studio launch gate: ${exitDetail ?? "no PID was assigned"}.`);
4397
+ }
4398
+ const gateDeadline = Date.now() + (options.gateTimeoutMs ?? GATE_TIMEOUT_MS);
4399
+ let gated;
4400
+ try {
4401
+ while (exitDetail === void 0 && Date.now() < gateDeadline) {
4402
+ const stat2 = readProcStat(procRoot, pid);
4403
+ if (stat2?.state === "T") {
4404
+ gated = stat2;
4405
+ break;
4406
+ }
4407
+ await delay3(10);
4408
+ }
4409
+ } catch (error) {
4410
+ child.kill("SIGKILL");
4411
+ throw error;
4412
+ }
4413
+ if (!gated) {
4414
+ child.kill("SIGKILL");
4415
+ throw new Error(exitDetail === void 0 ? "Timed out waiting for the Wine Studio launch gate to stop; the gated process was killed." : `Wine Studio launch gate exited before stopping (${exitDetail}).${logTail()}`);
4416
+ }
4417
+ const nativeStartedAt = procStartTimeToFileTime(bootTimeSeconds(procRoot), gated.startTimeTicks);
4418
+ let controlState = "pending";
4419
+ const authorize = async () => {
4420
+ if (controlState === "resumed")
4421
+ return;
4422
+ if (controlState !== "pending") {
4423
+ throw new Error(`Studio launch cannot be authorized from ${controlState}.`);
4424
+ }
4425
+ controlState = "authorizing";
4426
+ child.kill("SIGCONT");
4427
+ const deadline = Date.now() + execTimeoutMs;
4428
+ try {
4429
+ while (exitDetail === void 0 && Date.now() < deadline) {
4430
+ if (readWineProcessStartTime(pid, procRoot) === nativeStartedAt && isWineStudioCommand(readArgv0(procRoot, pid))) {
4431
+ controlState = "resumed";
4432
+ return;
4433
+ }
4434
+ await delay3(POLL_INTERVAL_MS);
4435
+ }
4436
+ } catch (error) {
4437
+ controlState = "aborted";
4438
+ child.kill("SIGKILL");
4439
+ throw error;
4440
+ }
4441
+ controlState = "aborted";
4442
+ if (exitDetail !== void 0) {
4443
+ throw new Error(`Wine Studio process ${pid} exited (${exitDetail}) before RobloxStudioBeta.exe started.${logTail()}`);
4444
+ }
4445
+ child.kill("SIGKILL");
4446
+ throw new Error(`Wine launcher did not exec RobloxStudioBeta.exe in gated process ${pid} within ${execTimeoutMs} ms; the process was killed. ROBLOX_STUDIO_WINE_LAUNCHER must exec Wine so Studio keeps the launcher PID; if Wine is only slow to start, raise ${STUDIO_EXEC_TIMEOUT_ENV}.${logTail()}`);
4447
+ };
4448
+ const release = async () => {
4449
+ if (controlState === "released")
4450
+ return;
4451
+ if (controlState !== "resumed") {
4452
+ throw new Error(`Studio launch cannot be released from ${controlState}.`);
4453
+ }
4454
+ controlState = "released";
4455
+ };
4456
+ const abort = async () => {
4457
+ if (controlState === "released")
4458
+ return;
4459
+ controlState = "aborted";
4460
+ if (exitDetail !== void 0)
4461
+ return;
4462
+ child.kill("SIGKILL");
4463
+ const { promise: timedOut, resolve: markTimedOut } = Promise.withResolvers();
4464
+ const timer = setTimeout(() => markTimedOut(true), ABORT_TIMEOUT_MS);
4465
+ try {
4466
+ if (await Promise.race([exited.then(() => false), timedOut])) {
4467
+ throw new Error(`Timed out aborting Wine Studio process ${pid}.`);
4468
+ }
4469
+ } finally {
4470
+ clearTimeout(timer);
4471
+ }
4472
+ };
4473
+ return {
4474
+ pid,
4475
+ nativePid: pid,
4476
+ nativeStartedAt,
4477
+ unref: () => child.unref(),
4478
+ authorize,
4479
+ release,
4480
+ abort,
4481
+ onExit: (listener) => {
4482
+ child.once("exit", listener);
4483
+ },
4484
+ onError: (listener) => {
4485
+ child.once("error", listener);
4486
+ }
4487
+ };
4488
+ }
4489
+
3930
4490
  // ../core/dist/studio-instance-manager.js
3931
- var BASEPLATE_TEMP_DIR = path2.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
4491
+ var BASEPLATE_TEMP_DIR = path5.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
3932
4492
  var BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
3933
4493
  var BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
3934
4494
  var retainedLaunchControls = /* @__PURE__ */ new Map();
@@ -4011,6 +4571,9 @@ async function runAsync(command, args, options = {}) {
4011
4571
  function isWsl() {
4012
4572
  return getStudioPlatformCapabilities().isWsl;
4013
4573
  }
4574
+ function isWineHost() {
4575
+ return getStudioPlatformCapabilities().processIdentity.launcher === "wine-retained";
4576
+ }
4014
4577
  async function powershellAsync(script, timeoutMs = 15e3) {
4015
4578
  return runAsync("powershell.exe", ["-NoProfile", "-Command", script], {
4016
4579
  cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
@@ -4054,7 +4617,9 @@ async function toWslPathAsync(windowsPath) {
4054
4617
  return runAsync("wslpath", ["-u", windowsPath]);
4055
4618
  }
4056
4619
  async function toStudioLaunchArgAsync(arg) {
4057
- if (!isWsl() || !path2.isAbsolute(arg) || !existsSync2(arg))
4620
+ if (isWineHost() && path5.isAbsolute(arg) && existsSync2(arg))
4621
+ return toWineWindowsPath(arg);
4622
+ if (!isWsl() || !path5.isAbsolute(arg) || !existsSync2(arg))
4058
4623
  return arg;
4059
4624
  return runAsync("wslpath", ["-w", arg]);
4060
4625
  }
@@ -4374,7 +4939,8 @@ public sealed class McpSuspendedStudio : IDisposable
4374
4939
 
4375
4940
  private static IntPtr OpenTestWorkerJob(string name)
4376
4941
  {
4377
- if (name == null) return IntPtr.Zero;
4942
+ // PowerShell passes $null to a .NET string parameter as "", so both mean "no worker job".
4943
+ if (String.IsNullOrEmpty(name)) return IntPtr.Zero;
4378
4944
  if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"\\ALocal\\\\RsmcpStudioWorker-[a-f0-9]{32}\\z"))
4379
4945
  throw new ArgumentException("Invalid Studio test worker job name");
4380
4946
  IntPtr handle = OpenJobObjectW(5, false, name);
@@ -4546,6 +5112,19 @@ function buildWindowsStudioStopScript(processId, startedAt) {
4546
5112
  "} finally { if ($null -ne $studio) { $studio.Dispose() } }"
4547
5113
  ].join("; ");
4548
5114
  }
5115
+ var studioLogsRoot;
5116
+ async function settleWindowsStudioSignIn(startedAt) {
5117
+ studioLogsRoot ??= (async () => {
5118
+ const localAppData = await windowsLocalAppDataAsync();
5119
+ return localAppData ? path5.join(await toWslPathAsync(localAppData), "Roblox", "logs") : void 0;
5120
+ })();
5121
+ const logsRoot = await studioLogsRoot;
5122
+ if (!logsRoot)
5123
+ return;
5124
+ const result = await waitForStudioSignInToSettle({ logsRoot, processStartedAtMs: fileTimeToUnixMs(startedAt) });
5125
+ if (result === "timeout")
5126
+ console.error("[studio] Studio was still signing in after 15 s; closing it anyway.");
5127
+ }
4549
5128
  async function stopWindowsStudio(processId, startedAt, timeoutMs = 15e3) {
4550
5129
  await powershellAsync(buildWindowsStudioStopScript(processId, startedAt), timeoutMs);
4551
5130
  }
@@ -4554,12 +5133,12 @@ async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDi
4554
5133
  const windowsWorkingDirectory = studioWorkingDirectory ? await toStudioLaunchArgAsync(studioWorkingDirectory) : void 0;
4555
5134
  const testWorkerJobName = process.env.RSMCP_STUDIO_TEST_WORKER_JOB;
4556
5135
  const script = buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory, testWorkerJobName);
4557
- const launcher = spawn("powershell.exe", ["-NoProfile", "-Command", script], {
5136
+ const launcher = spawn2("powershell.exe", ["-NoProfile", "-Command", script], {
4558
5137
  cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
4559
5138
  stdio: ["pipe", "pipe", "pipe"],
4560
5139
  windowsHide: true
4561
5140
  });
4562
- return new Promise((resolve5, reject) => {
5141
+ return new Promise((resolve6, reject) => {
4563
5142
  let stdout = "";
4564
5143
  let controlOutput = "";
4565
5144
  let stderr = "";
@@ -4706,7 +5285,7 @@ async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDi
4706
5285
  throw new Error("PowerShell returned an invalid native Studio process identity.");
4707
5286
  }
4708
5287
  identity = { pid: nativePid, startedAt: nativeStartedAt };
4709
- resolve5({
5288
+ resolve6({
4710
5289
  pid: nativePid,
4711
5290
  nativePid,
4712
5291
  nativeStartedAt,
@@ -4774,21 +5353,21 @@ function resolveEntrypointDir() {
4774
5353
  if (!entrypoint)
4775
5354
  return void 0;
4776
5355
  try {
4777
- return path2.dirname(realpathSync(entrypoint));
5356
+ return path5.dirname(realpathSync(entrypoint));
4778
5357
  } catch {
4779
- return path2.dirname(path2.resolve(entrypoint));
5358
+ return path5.dirname(path5.resolve(entrypoint));
4780
5359
  }
4781
5360
  }
4782
5361
  function resolveBaseplateTemplatePath() {
4783
5362
  const entrypointDir = resolveEntrypointDir();
4784
5363
  const candidates = [
4785
5364
  ...entrypointDir ? [
4786
- path2.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
4787
- path2.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
5365
+ path5.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
5366
+ path5.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
4788
5367
  ] : [],
4789
- path2.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
4790
- path2.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
4791
- path2.join(process.cwd(), "assets", BASEPLATE_TEMPLATE_NAME)
5368
+ path5.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
5369
+ path5.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
5370
+ path5.join(process.cwd(), "assets", BASEPLATE_TEMPLATE_NAME)
4792
5371
  ];
4793
5372
  for (const candidate of candidates) {
4794
5373
  if (existsSync2(candidate))
@@ -4810,7 +5389,7 @@ function isProcessAlive2(pid) {
4810
5389
  function sweepStaleBaseplateFiles() {
4811
5390
  let entries;
4812
5391
  try {
4813
- entries = readdirSync(BASEPLATE_TEMP_DIR);
5392
+ entries = readdirSync2(BASEPLATE_TEMP_DIR);
4814
5393
  } catch {
4815
5394
  return;
4816
5395
  }
@@ -4821,24 +5400,24 @@ function sweepStaleBaseplateFiles() {
4821
5400
  continue;
4822
5401
  if (Number(match[1]) !== process.pid && isProcessAlive2(Number(match[1])))
4823
5402
  continue;
4824
- const file = path2.join(BASEPLATE_TEMP_DIR, entry);
5403
+ const file = path5.join(BASEPLATE_TEMP_DIR, entry);
4825
5404
  try {
4826
- if (statSync(file).mtimeMs < cutoff)
4827
- rmSync(file, { force: true });
5405
+ if (statSync2(file).mtimeMs < cutoff)
5406
+ rmSync2(file, { force: true });
4828
5407
  } catch {
4829
5408
  }
4830
5409
  }
4831
5410
  }
4832
5411
  function createBaseplatePlaceFile() {
4833
- mkdirSync(BASEPLATE_TEMP_DIR, { recursive: true });
5412
+ mkdirSync2(BASEPLATE_TEMP_DIR, { recursive: true });
4834
5413
  sweepStaleBaseplateFiles();
4835
- const file = path2.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
5414
+ const file = path5.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
4836
5415
  copyFileSync(resolveBaseplateTemplatePath(), file);
4837
5416
  return file;
4838
5417
  }
4839
5418
  function isGeneratedBaseplatePlaceFile(file) {
4840
- const resolvedFile = path2.resolve(file);
4841
- return path2.dirname(resolvedFile) === path2.resolve(BASEPLATE_TEMP_DIR) && BASEPLATE_TEMP_NAME.test(path2.basename(resolvedFile));
5419
+ const resolvedFile = path5.resolve(file);
5420
+ return path5.dirname(resolvedFile) === path5.resolve(BASEPLATE_TEMP_DIR) && BASEPLATE_TEMP_NAME.test(path5.basename(resolvedFile));
4842
5421
  }
4843
5422
  function cleanupManagedBaseplateFiles(record) {
4844
5423
  if (record.source !== "baseplate" || !record.localPlaceFile)
@@ -4847,7 +5426,7 @@ function cleanupManagedBaseplateFiles(record) {
4847
5426
  return;
4848
5427
  for (const file of [record.localPlaceFile, `${record.localPlaceFile}.lock`]) {
4849
5428
  try {
4850
- rmSync(file, { force: true });
5429
+ rmSync2(file, { force: true });
4851
5430
  } catch {
4852
5431
  }
4853
5432
  }
@@ -4864,21 +5443,33 @@ function resolveStudioExeFromVersions(root) {
4864
5443
  if (!existsSync2(root)) {
4865
5444
  throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
4866
5445
  }
4867
- const candidates = readdirSync(root).filter((name) => name.startsWith("version-")).map((name) => path2.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
5446
+ const candidates = readdirSync2(root).filter((name) => name.startsWith("version-")).map((name) => path5.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync2(b).mtimeMs - statSync2(a).mtimeMs);
4868
5447
  if (candidates.length === 0) {
4869
5448
  throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
4870
5449
  }
4871
5450
  const exe = candidates[0];
4872
- const installationDir = path2.dirname(exe);
4873
- const hasIncompleteDownload = readdirSync(installationDir).some((name) => /\.crdownload$/i.test(name));
4874
- const settings = statSync(path2.join(installationDir, "AppSettings.xml"), { throwIfNoEntry: false });
5451
+ const installationDir = path5.dirname(exe);
5452
+ const hasIncompleteDownload = readdirSync2(installationDir).some((name) => /\.crdownload$/i.test(name));
5453
+ const settings = statSync2(path5.join(installationDir, "AppSettings.xml"), { throwIfNoEntry: false });
4875
5454
  if (hasIncompleteDownload || !settings?.isFile() || settings.size === 0) {
4876
5455
  const reason = hasIncompleteDownload ? "an unfinished .crdownload download is present" : "AppSettings.xml is missing, empty, or not a regular file";
4877
5456
  throw new Error(`Roblox Studio installation/update is incomplete at ${installationDir}: ${reason}. Complete the installation/update or reinstall Roblox Studio under the owning Windows account before launching.`);
4878
5457
  }
4879
5458
  return exe;
4880
5459
  }
5460
+ function resolveNativeLinuxStudioExe() {
5461
+ const exe = process.env.ROBLOX_STUDIO_EXE;
5462
+ if (!exe) {
5463
+ throw new Error("Set ROBLOX_STUDIO_EXE to RobloxStudioBeta.exe inside your Wine prefix to launch Studio on native Linux.");
5464
+ }
5465
+ if (!statSync2(exe, { throwIfNoEntry: false })?.isFile()) {
5466
+ throw new Error(`ROBLOX_STUDIO_EXE does not name an existing file: ${exe}`);
5467
+ }
5468
+ return exe;
5469
+ }
4881
5470
  function resolveStudioExe() {
5471
+ if (process.platform === "linux" && isWineHost())
5472
+ return resolveNativeLinuxStudioExe();
4882
5473
  if (process.env.ROBLOX_STUDIO_EXE)
4883
5474
  return process.env.ROBLOX_STUDIO_EXE;
4884
5475
  if (process.platform === "darwin") {
@@ -4888,10 +5479,12 @@ function resolveStudioExe() {
4888
5479
  throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
4889
5480
  }
4890
5481
  const localAppData = windowsLocalAppData();
4891
- const root = localAppData ? path2.join(toWslPath(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
5482
+ const root = localAppData ? path5.join(toWslPath(localAppData), "Roblox", "Versions") : path5.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4892
5483
  return resolveStudioExeFromVersions(root);
4893
5484
  }
4894
5485
  async function resolveStudioExeAsync() {
5486
+ if (process.platform === "linux" && isWineHost())
5487
+ return resolveNativeLinuxStudioExe();
4895
5488
  if (process.env.ROBLOX_STUDIO_EXE)
4896
5489
  return process.env.ROBLOX_STUDIO_EXE;
4897
5490
  if (process.platform === "darwin") {
@@ -4901,7 +5494,7 @@ async function resolveStudioExeAsync() {
4901
5494
  throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
4902
5495
  }
4903
5496
  const localAppData = await windowsLocalAppDataAsync();
4904
- const root = localAppData ? path2.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
5497
+ const root = localAppData ? path5.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path5.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4905
5498
  return resolveStudioExeFromVersions(root);
4906
5499
  }
4907
5500
  var WINDOWS_STUDIO_PROCESS_QUERY = [
@@ -4938,6 +5531,9 @@ async function observeStudioProcesses(timeoutMs = 15e3) {
4938
5531
  }).filter((proc) => Number.isFinite(proc.Id));
4939
5532
  return { status: "ok", observedAt, processes };
4940
5533
  }
5534
+ if (process.platform === "linux" && isWineHost()) {
5535
+ return { status: "ok", observedAt, processes: listWineStudioProcesses() };
5536
+ }
4941
5537
  if (process.platform !== "win32" && !isWsl()) {
4942
5538
  return { status: "ok", observedAt, processes: [] };
4943
5539
  }
@@ -4954,7 +5550,7 @@ async function observeStudioProcesses(timeoutMs = 15e3) {
4954
5550
  async function currentBootIdAsync() {
4955
5551
  if (process.platform === "linux") {
4956
5552
  try {
4957
- return readFileSync2("/proc/sys/kernel/random/boot_id", "utf8").trim();
5553
+ return readFileSync3("/proc/sys/kernel/random/boot_id", "utf8").trim();
4958
5554
  } catch {
4959
5555
  }
4960
5556
  }
@@ -5005,11 +5601,11 @@ function buildStudioLaunchArgs(options) {
5005
5601
  ];
5006
5602
  }
5007
5603
  }
5008
- function delay2(ms) {
5009
- return new Promise((resolve5) => setTimeout(resolve5, ms));
5604
+ function delay4(ms) {
5605
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
5010
5606
  }
5011
5607
  function basenameAny(filePath) {
5012
- return path2.basename(filePath.replace(/\\/g, "/"));
5608
+ return path5.basename(filePath.replace(/\\/g, "/"));
5013
5609
  }
5014
5610
  var StudioInstanceManager = class {
5015
5611
  managedByInstanceId = /* @__PURE__ */ new Map();
@@ -5026,6 +5622,7 @@ var StudioInstanceManager = class {
5026
5622
  snapshotCacheMs;
5027
5623
  launchCompletionTimeoutMs;
5028
5624
  closeTimeoutMs;
5625
+ settleSignIn;
5029
5626
  coordinatorTimer;
5030
5627
  coordinatorRefresh;
5031
5628
  cachedSnapshot;
@@ -5041,6 +5638,7 @@ var StudioInstanceManager = class {
5041
5638
  this.snapshotCacheMs = options.snapshotCacheMs ?? 0;
5042
5639
  this.launchCompletionTimeoutMs = options.launchCompletionTimeoutMs ?? LAUNCH_COMPLETION_TIMEOUT_MS;
5043
5640
  this.closeTimeoutMs = options.closeTimeoutMs ?? 1e4;
5641
+ this.settleSignIn = options.settleSignIn ?? (!this.processAdapter.stopProcess && (process.platform === "win32" || isWsl()) ? settleWindowsStudioSignIn : void 0);
5044
5642
  }
5045
5643
  getLifecycleCapabilities() {
5046
5644
  const platform = this.platformCapabilities ?? getStudioPlatformCapabilities();
@@ -5220,8 +5818,8 @@ var StudioInstanceManager = class {
5220
5818
  async launch(options) {
5221
5819
  const previous = this.launchQueue;
5222
5820
  let release;
5223
- this.launchQueue = new Promise((resolve5) => {
5224
- release = resolve5;
5821
+ this.launchQueue = new Promise((resolve6) => {
5822
+ release = resolve6;
5225
5823
  });
5226
5824
  await previous;
5227
5825
  try {
@@ -5259,8 +5857,15 @@ var StudioInstanceManager = class {
5259
5857
  proc = await this.processAdapter.spawnStudio(exe, args, spawnOptions);
5260
5858
  } else if (lifecycleCapabilities.processIdentity.launcher === "windows-retained" || lifecycleCapabilities.processIdentity.launcher === "wsl-windows-retained") {
5261
5859
  proc = await this.windowsStudioLauncher(exe, args, processEnvironment, studioWorkingDirectory);
5860
+ } else if (lifecycleCapabilities.processIdentity.launcher === "wine-retained") {
5861
+ const wineLauncher = (this.platformCapabilities ?? getStudioPlatformCapabilities()).wineLauncher;
5862
+ if (!wineLauncher)
5863
+ throw new Error("The Wine Studio launcher path is unavailable.");
5864
+ proc = await spawnWineStudio(wineLauncher, exe, args, spawnOptions, {
5865
+ logDir: path5.join(this.registry.dir, "wine-launches")
5866
+ });
5262
5867
  } else {
5263
- const child = spawn(exe, args, spawnOptions);
5868
+ const child = spawn2(exe, args, spawnOptions);
5264
5869
  proc = {
5265
5870
  pid: child.pid,
5266
5871
  nativePid: child.pid,
@@ -5310,7 +5915,7 @@ var StudioInstanceManager = class {
5310
5915
  }
5311
5916
  const launchedAt = Date.now();
5312
5917
  const record = {
5313
- recordId: randomUUID3(),
5918
+ recordId: randomUUID4(),
5314
5919
  source: options.source,
5315
5920
  nativeProcessId: proc.nativePid,
5316
5921
  nativeProcessStartedAt: proc.nativeStartedAt,
@@ -5380,7 +5985,7 @@ var StudioInstanceManager = class {
5380
5985
  await this.persist(record);
5381
5986
  break;
5382
5987
  }
5383
- await delay2(250);
5988
+ await delay4(250);
5384
5989
  }
5385
5990
  if (record.nativeProcessId === void 0 && process.platform !== "win32" && !isWsl()) {
5386
5991
  record.nativeProcessId = proc.pid;
@@ -5438,7 +6043,7 @@ var StudioInstanceManager = class {
5438
6043
  instanceId: record.instanceId
5439
6044
  };
5440
6045
  }
5441
- const deadline = Date.now() + this.closeTimeoutMs;
6046
+ let deadline = Date.now() + this.closeTimeoutMs;
5442
6047
  const processId = record.nativeProcessId ?? record.spawnPid;
5443
6048
  if (!processId) {
5444
6049
  throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
@@ -5477,6 +6082,8 @@ var StudioInstanceManager = class {
5477
6082
  };
5478
6083
  }
5479
6084
  }
6085
+ if (!abort)
6086
+ deadline = await this.beforeStudioStop(record.nativeProcessStartedAt, deadline);
5480
6087
  try {
5481
6088
  await this.closeProcess(processId, record.nativeProcessStartedAt, abort, deadline);
5482
6089
  } catch (error) {
@@ -5525,7 +6132,7 @@ var StudioInstanceManager = class {
5525
6132
  };
5526
6133
  }
5527
6134
  async closeConnectedInstance(instance) {
5528
- const deadline = Date.now() + this.closeTimeoutMs;
6135
+ let deadline = Date.now() + this.closeTimeoutMs;
5529
6136
  const snapshot = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, 0);
5530
6137
  if (snapshot.status === "error") {
5531
6138
  throw new Error(`Could not enumerate Studio processes: ${snapshot.error}`);
@@ -5534,8 +6141,20 @@ var StudioInstanceManager = class {
5534
6141
  if (!process2) {
5535
6142
  throw new Error(`Could not find a Studio process for connected instance "${instance.instanceId}".`);
5536
6143
  }
6144
+ deadline = await this.beforeStudioStop(process2.StartTimeUtcFileTime, deadline);
5537
6145
  await this.closeProcess(process2.Id, process2.StartTimeUtcFileTime, void 0, deadline);
5538
6146
  }
6147
+ async beforeStudioStop(startedAt, deadline) {
6148
+ if (!this.settleSignIn || startedAt === void 0 || !/^[1-9]\d*$/u.test(startedAt))
6149
+ return deadline;
6150
+ const waitStarted = Date.now();
6151
+ try {
6152
+ await this.settleSignIn(startedAt);
6153
+ } catch (error) {
6154
+ console.error(`[studio] Could not check Studio sign-in before closing: ${error instanceof Error ? error.message : String(error)}`);
6155
+ }
6156
+ return deadline + (Date.now() - waitStarted);
6157
+ }
5539
6158
  async closeProcess(processId, startedAt, stop, deadline = Date.now() + this.closeTimeoutMs) {
5540
6159
  this.cachedSnapshot = void 0;
5541
6160
  await beforeCloseDeadline(async () => {
@@ -5548,6 +6167,11 @@ var StudioInstanceManager = class {
5548
6167
  throw new Error("Cannot stop a Windows Studio process without its creation-time identity.");
5549
6168
  }
5550
6169
  await stopWindowsStudio(processId, startedAt, Math.max(1, deadline - Date.now()));
6170
+ } else if (isWineHost()) {
6171
+ if (startedAt === void 0) {
6172
+ throw new Error("Cannot stop a Wine Studio process without its creation-time identity.");
6173
+ }
6174
+ await stopWineStudio(processId, startedAt, Math.max(1, deadline - Date.now()));
5551
6175
  } else {
5552
6176
  try {
5553
6177
  process.kill(processId, "SIGTERM");
@@ -5557,7 +6181,7 @@ var StudioInstanceManager = class {
5557
6181
  }
5558
6182
  }
5559
6183
  }, deadline, processId);
5560
- while (true) {
6184
+ for (; ; ) {
5561
6185
  const observation = await beforeCloseDeadline(() => this.observeClosingProcess(processId, startedAt, deadline), deadline, processId);
5562
6186
  if (observation.status === "not_running")
5563
6187
  return;
@@ -5568,7 +6192,7 @@ var StudioInstanceManager = class {
5568
6192
  if (remaining <= 0) {
5569
6193
  throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
5570
6194
  }
5571
- await delay2(Math.min(100, remaining));
6195
+ await delay4(Math.min(100, remaining));
5572
6196
  if (Date.now() >= deadline) {
5573
6197
  throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
5574
6198
  }
@@ -5665,15 +6289,39 @@ var StudioInstanceManager = class {
5665
6289
  confirmedExitGraceMs: this.confirmedExitGraceMs
5666
6290
  };
5667
6291
  }
6292
+ isOrphanedUnreleasedLaunch(registryRecord, currentBootId) {
6293
+ const ownerPid = registryRecord.ownerPid;
6294
+ return !(registryRecord.processAuthorizationState === "released" || this.launchControls.has(registryRecord.recordId) || registryRecord.bootId !== currentBootId || ownerPid === void 0 || ownerPid !== process.pid && isProcessAlive2(ownerPid));
6295
+ }
6296
+ // A broker that dies between spawn and authorization leaves its Wine launch
6297
+ // gate stopped. The gate is /bin/sh, not Studio, so the orphan cleanup below
6298
+ // never sees it in a Studio snapshot.
6299
+ async reapOrphanedWineLaunchGates(currentBootId) {
6300
+ if (this.getLifecycleCapabilities().processIdentity.launcher !== "wine-retained")
6301
+ return;
6302
+ for (const registryRecord of await this.registry.listOpenUnchecked()) {
6303
+ const processId = registryRecord.nativeProcessId;
6304
+ const startedAt = registryRecord.nativeProcessStartedAt;
6305
+ if (registryRecord.processAuthorizationState !== "pending" || !processId || !startedAt || !this.isOrphanedUnreleasedLaunch(registryRecord, currentBootId)) {
6306
+ continue;
6307
+ }
6308
+ try {
6309
+ if (killOrphanedWineLaunchGate(processId, startedAt)) {
6310
+ console.error(`[studio] Killed Wine launch gate ${processId}, left stopped by a broker that exited before authorizing it.`);
6311
+ }
6312
+ } catch (error) {
6313
+ console.error(`[studio] Could not check orphaned Wine launch gate ${processId}: ${error instanceof Error ? error.message : String(error)}`);
6314
+ }
6315
+ }
6316
+ }
5668
6317
  async sweepRegistry(snapshot) {
5669
6318
  const sweepOptions = await this.registrySweepOptions(snapshot);
6319
+ await this.reapOrphanedWineLaunchGates(sweepOptions.currentBootId);
5670
6320
  await this.registry.sweep(sweepOptions);
5671
6321
  const persisted = await this.registry.listOpenUnchecked();
5672
6322
  for (const registryRecord of persisted) {
5673
- const ownerPid = registryRecord.ownerPid;
5674
- if (registryRecord.processAuthorizationState === "released" || this.launchControls.has(registryRecord.recordId) || registryRecord.bootId !== sweepOptions.currentBootId || ownerPid === void 0 || ownerPid !== process.pid && isProcessAlive2(ownerPid)) {
6323
+ if (!this.isOrphanedUnreleasedLaunch(registryRecord, sweepOptions.currentBootId))
5675
6324
  continue;
5676
- }
5677
6325
  const record = this.fromRegistryRecord(registryRecord);
5678
6326
  if (this.observeRecord(record, snapshot).status !== "running")
5679
6327
  continue;
@@ -5736,13 +6384,13 @@ var StudioInstanceManager = class {
5736
6384
  const processId = record.nativeProcessId ?? record.spawnPid;
5737
6385
  if (record.spawnPid && record.spawnPid === processId && studioProcess.Id === processId)
5738
6386
  return true;
5739
- const processPath = studioProcess.Path ? path2.normalize(studioProcess.Path).toLowerCase() : "";
5740
- const exePath = record.exe ? path2.normalize(record.exe).toLowerCase() : "";
6387
+ const processPath = studioProcess.Path ? path5.normalize(studioProcess.Path).toLowerCase() : "";
6388
+ const exePath = record.exe ? path5.normalize(record.exe).toLowerCase() : "";
5741
6389
  if (processPath && exePath && (processPath === exePath || basenameAny(processPath) === basenameAny(exePath))) {
5742
6390
  return true;
5743
6391
  }
5744
6392
  const commandLine = studioProcess.CommandLine ?? "";
5745
- if (record.localPlaceFile && commandLine.includes(path2.basename(record.localPlaceFile)))
6393
+ if (record.localPlaceFile && commandLine.includes(path5.basename(record.localPlaceFile)))
5746
6394
  return true;
5747
6395
  if (record.placeId !== void 0 && commandLine.includes(String(record.placeId)))
5748
6396
  return true;
@@ -6061,6 +6709,7 @@ var OPEN_WORLD_TOOLS = /* @__PURE__ */ new Set([
6061
6709
  "get_asset_thumbnail",
6062
6710
  "get_roblox_docs",
6063
6711
  "insert_asset",
6712
+ "manage_monetization",
6064
6713
  "preview_asset",
6065
6714
  "search_assets",
6066
6715
  "upload_asset"
@@ -6273,11 +6922,21 @@ function createToolHttpHandler(options) {
6273
6922
 
6274
6923
  // ../core/dist/auth.js
6275
6924
  import { randomBytes, createHash as createHash2, timingSafeEqual } from "crypto";
6276
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync, chmodSync } from "fs";
6277
- import { join as join3, dirname as dirname2 } from "path";
6925
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync2, linkSync as linkSync2, unlinkSync as unlinkSync2 } from "fs";
6926
+ import { join as join5, dirname as dirname2 } from "path";
6278
6927
  import { homedir as homedir3 } from "os";
6279
6928
  function authTokenFilePath() {
6280
- return join3(homedir3(), ".robloxstudio-mcp", "auth-token");
6929
+ return join5(homedir3(), ".robloxstudio-mcp", "auth-token");
6930
+ }
6931
+ var EMPTY_TOKEN_RETRY_MS = 25;
6932
+ var EMPTY_TOKEN_RETRIES = 20;
6933
+ function readTokenFile(filePath) {
6934
+ for (let attempt = 0; ; attempt++) {
6935
+ const token = readFileSync4(filePath, "utf8").trim();
6936
+ if (token || attempt >= EMPTY_TOKEN_RETRIES)
6937
+ return token;
6938
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, EMPTY_TOKEN_RETRY_MS);
6939
+ }
6281
6940
  }
6282
6941
  function resolveAuthToken() {
6283
6942
  const noAuth = (process.env.ROBLOX_STUDIO_NO_AUTH || "").toLowerCase();
@@ -6290,24 +6949,40 @@ function resolveAuthToken() {
6290
6949
  }
6291
6950
  const filePath = authTokenFilePath();
6292
6951
  try {
6293
- if (existsSync3(filePath)) {
6294
- const existing = readFileSync3(filePath, "utf8").trim();
6295
- if (existing) {
6296
- return { token: existing, source: "file", filePath };
6297
- }
6952
+ try {
6953
+ const existing = readTokenFile(filePath);
6954
+ if (!existing)
6955
+ throw new Error("Auth token file is empty; remove it while all MCP servers are stopped to reinitialize it.");
6956
+ return { token: existing, source: "file", filePath };
6957
+ } catch (error) {
6958
+ if (error.code !== "ENOENT")
6959
+ throw error;
6298
6960
  }
6299
6961
  const fresh = randomBytes(32).toString("hex");
6300
- mkdirSync2(dirname2(filePath), { recursive: true, mode: 448 });
6301
- writeFileSync(filePath, fresh + "\n", { encoding: "utf8", mode: 384 });
6962
+ mkdirSync3(dirname2(filePath), { recursive: true, mode: 448 });
6963
+ const stagingPath = `${filePath}.${process.pid}.${randomBytes(16).toString("hex")}.tmp`;
6964
+ writeFileSync2(stagingPath, fresh + "\n", { encoding: "utf8", mode: 384, flag: "wx" });
6302
6965
  try {
6303
- chmodSync(filePath, 384);
6304
- } catch {
6305
- }
6306
- return { token: fresh, source: "file", filePath };
6307
- } catch (err2) {
6308
- console.error(`[auth] Could not read/create ${filePath} (${err2 instanceof Error ? err2.message : err2}). Using an in-memory token for this process; set ROBLOX_STUDIO_AUTH_TOKEN to share one across sessions.`);
6309
- return { token: randomBytes(32).toString("hex"), source: "file" };
6310
- }
6966
+ try {
6967
+ linkSync2(stagingPath, filePath);
6968
+ } catch (error) {
6969
+ if (error.code !== "EEXIST")
6970
+ throw error;
6971
+ }
6972
+ const token = readTokenFile(filePath);
6973
+ if (!token)
6974
+ throw new Error("Auth token file is empty.");
6975
+ return { token, source: "file", filePath };
6976
+ } finally {
6977
+ try {
6978
+ unlinkSync2(stagingPath);
6979
+ } catch {
6980
+ }
6981
+ }
6982
+ } catch (err2) {
6983
+ console.error(`[auth] Could not read/create ${filePath} (${err2 instanceof Error ? err2.message : err2}). Using an in-memory token for this process; set ROBLOX_STUDIO_AUTH_TOKEN to share one across sessions.`);
6984
+ return { token: randomBytes(32).toString("hex"), source: "file" };
6985
+ }
6311
6986
  }
6312
6987
  function tokensMatch(provided, expected) {
6313
6988
  const a = createHash2("sha256").update(provided).digest();
@@ -6655,7 +7330,8 @@ var TOOL_PROXY_ENDPOINTS = {
6655
7330
  get_scene_analysis: ["/api/get-scene-analysis"],
6656
7331
  export_rbxm: ["/api/export-rbxm"],
6657
7332
  import_rbxm: ["/api/import-rbxm"],
6658
- find_and_replace_in_scripts: ["/api/find-and-replace-in-scripts"]
7333
+ find_and_replace_in_scripts: ["/api/find-and-replace-in-scripts"],
7334
+ manage_monetization: ["/api/place-info"]
6659
7335
  };
6660
7336
  function toPassivePeer(peer) {
6661
7337
  return {
@@ -6773,6 +7449,7 @@ var TOOL_HANDLERS = {
6773
7449
  set_device_simulator: (tools, body) => tools.setDeviceSimulator(body.target, body.deviceId, body.orientation, body.resolution, body.pixelDensity, body.scalingMode, body.stopSimulation, body.instance_id),
6774
7450
  capture_device_matrix: (tools, body) => tools.captureDeviceMatrix(body.entries, body.target, body.format, body.quality, body.settleSeconds, body.restoreAfter, body.instance_id),
6775
7451
  manage_instance: (tools, body) => tools.manageInstance(body),
7452
+ manage_monetization: (tools, body) => tools.manageMonetization(body, body.instance_id),
6776
7453
  solo_playtest: (tools, body) => tools.soloPlaytest(body.action, body.mode, body.timeout, body.instance_id),
6777
7454
  multiplayer_playtest: (tools, body) => tools.multiplayerPlaytest(body.action, body.numPlayers, body.target, body.testArgs, body.value, body.timeout, body.instance_id),
6778
7455
  get_runtime_logs: (tools, body, context) => tools.getRuntimeLogs(body.instance_id, body.multiplayer_group_id, body.cursor, body.cursor_by_instance, body.tail, body.filter, context?.signal),
@@ -6821,7 +7498,7 @@ var TOOL_HANDLERS = {
6821
7498
  get_memory_breakdown: (tools, body) => tools.getMemoryBreakdown(body.target, body.tags, body.instance_id),
6822
7499
  get_scene_analysis: (tools, body) => tools.getSceneAnalysis(body.mode, body.target, body.topN, body.raw, body.instance_id),
6823
7500
  export_rbxm: (tools, body) => tools.exportRbxm(body.instance_paths, body.output_path, body.target, body.instance_id),
6824
- import_rbxm: (tools, body) => tools.importRbxm(body.source, body.parent_path, body.target, body.instance_id),
7501
+ import_rbxm: (tools, body, context) => tools.importRbxm(body.source, body.parent_path, body.target, body.instance_id, context?.signal),
6825
7502
  find_and_replace_in_scripts: (tools, body) => tools.findAndReplaceInScripts(body.pattern, body.replacement, {
6826
7503
  caseSensitive: body.caseSensitive,
6827
7504
  usePattern: body.usePattern,
@@ -6990,8 +7667,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6990
7667
  });
6991
7668
  const authToken = security?.authToken;
6992
7669
  const authRequired = (requestPath) => {
6993
- const path7 = requestPath.toLowerCase().replace(/\/+$/, "");
6994
- return path7 === "/mcp" || path7.startsWith("/mcp/") || path7 === "/proxy" || path7 === "/topology" || path7 === "/request-status" || path7 === "/unregister-instance-id" || path7 === "/create-multiplayer-group" || path7 === "/remove-multiplayer-group";
7670
+ const path11 = requestPath.toLowerCase().replace(/\/+$/, "");
7671
+ return path11 === "/mcp" || path11.startsWith("/mcp/") || path11 === "/proxy" || path11 === "/topology" || path11 === "/request-status" || path11 === "/unregister-instance-id" || path11 === "/create-multiplayer-group" || path11 === "/remove-multiplayer-group";
6995
7672
  };
6996
7673
  app.use((req, res, next) => {
6997
7674
  if (!authToken || !authRequired(req.path)) {
@@ -7420,6 +8097,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
7420
8097
  studioTransport.close();
7421
8098
  webSocketServer.close();
7422
8099
  await mcpHandler?.close();
8100
+ await tools.dispose();
7423
8101
  };
7424
8102
  return app;
7425
8103
  }
@@ -7440,7 +8118,7 @@ async function listenWithRetry(app, host, startPort, maxAttempts = 5) {
7440
8118
  throw new Error(`All ports ${startPort}-${startPort + maxAttempts - 1} are in use. Stop some MCP server instances and retry.`);
7441
8119
  }
7442
8120
  function bindPort(app, host, port) {
7443
- const { promise, resolve: resolve5, reject } = Promise.withResolvers();
8121
+ const { promise, resolve: resolve6, reject } = Promise.withResolvers();
7444
8122
  const server = http.createServer(app);
7445
8123
  const onError = (err2) => {
7446
8124
  server.removeListener("error", onError);
@@ -7453,7 +8131,7 @@ function bindPort(app, host, port) {
7453
8131
  if ("attachStudioTransport" in app && typeof app.attachStudioTransport === "function") {
7454
8132
  app.attachStudioTransport(server);
7455
8133
  }
7456
- resolve5(server);
8134
+ resolve6(server);
7457
8135
  } catch (error) {
7458
8136
  server.close();
7459
8137
  reject(error);
@@ -7462,6 +8140,123 @@ function bindPort(app, host, port) {
7462
8140
  return promise;
7463
8141
  }
7464
8142
 
8143
+ // ../core/dist/rbxm-input.js
8144
+ import { open as open3 } from "fs/promises";
8145
+ import { resolve as resolve2 } from "path";
8146
+ var ENVELOPE_RESERVE_BYTES = 16 * 1024;
8147
+ var DOWNLOAD_TIMEOUT_MS = 3e4;
8148
+ function rbxmInputByteLimit(parentPath, sourceLabel) {
8149
+ const metadataBytes = Buffer.byteLength(JSON.stringify({
8150
+ base64: "",
8151
+ parent_path: parentPath,
8152
+ source_label: sourceLabel
8153
+ }));
8154
+ const encodedBytes = Math.min(HTTP_BODY_LIMIT_BYTES, MAX_STUDIO_FRAME_BYTES) - ENVELOPE_RESERVE_BYTES - metadataBytes;
8155
+ if (encodedBytes < 4)
8156
+ throw new Error("RBXM import metadata exceeds the transport byte budget.");
8157
+ return Math.floor(encodedBytes / 4) * 3;
8158
+ }
8159
+ function tooLarge(limit) {
8160
+ return new Error(`RBXM input exceeds the ${limit}-byte limit after allowing for base64 and transport overhead.`);
8161
+ }
8162
+ async function readBoundedResponse(response, limit) {
8163
+ const declared = Number(response.headers.get("content-length"));
8164
+ if (Number.isFinite(declared) && declared > limit) {
8165
+ await response.body?.cancel();
8166
+ throw tooLarge(limit);
8167
+ }
8168
+ if (!response.body)
8169
+ return Buffer.alloc(0);
8170
+ const reader = response.body.getReader();
8171
+ const chunks = [];
8172
+ let size = 0;
8173
+ try {
8174
+ for (; ; ) {
8175
+ const { done, value } = await reader.read();
8176
+ if (done)
8177
+ break;
8178
+ size += value.byteLength;
8179
+ if (size > limit)
8180
+ throw tooLarge(limit);
8181
+ chunks.push(Buffer.from(value));
8182
+ }
8183
+ return Buffer.concat(chunks, size);
8184
+ } finally {
8185
+ await reader.cancel().catch(() => {
8186
+ });
8187
+ reader.releaseLock();
8188
+ }
8189
+ }
8190
+ async function readRbxmInput(source, parentPath, signal) {
8191
+ const modes = ["path", "url", "base64"];
8192
+ const provided = modes.filter((key) => source[key] !== void 0);
8193
+ if (provided.length !== 1 || typeof source[provided[0]] !== "string") {
8194
+ throw new Error("source must contain exactly one string: path, url, or base64");
8195
+ }
8196
+ signal?.throwIfAborted();
8197
+ if (source.path !== void 0) {
8198
+ const sourceLabel = resolve2(source.path);
8199
+ const limit2 = rbxmInputByteLimit(parentPath, sourceLabel);
8200
+ const file = await open3(sourceLabel, "r");
8201
+ try {
8202
+ const stats = await file.stat();
8203
+ if (!stats.isFile())
8204
+ throw new Error("RBXM source must be a regular file.");
8205
+ if (stats.size > limit2)
8206
+ throw tooLarge(limit2);
8207
+ const chunks = [];
8208
+ let size = 0;
8209
+ for (; ; ) {
8210
+ signal?.throwIfAborted();
8211
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, limit2 - size + 1));
8212
+ const { bytesRead } = await file.read(chunk);
8213
+ if (bytesRead === 0)
8214
+ break;
8215
+ size += bytesRead;
8216
+ if (size > limit2)
8217
+ throw tooLarge(limit2);
8218
+ chunks.push(chunk.subarray(0, bytesRead));
8219
+ }
8220
+ return { bytes: Buffer.concat(chunks, size), sourceLabel };
8221
+ } finally {
8222
+ await file.close();
8223
+ }
8224
+ }
8225
+ if (source.url !== void 0) {
8226
+ const url = new URL(source.url);
8227
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
8228
+ throw new Error(`import_rbxm url must use http(s); got ${url.protocol}`);
8229
+ }
8230
+ const limit2 = rbxmInputByteLimit(parentPath, source.url);
8231
+ const deadline = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
8232
+ const response = await fetch(url, {
8233
+ signal: signal ? AbortSignal.any([signal, deadline]) : deadline
8234
+ });
8235
+ if (!response.ok) {
8236
+ await response.body?.cancel();
8237
+ throw new Error(`RBXM download returned HTTP ${response.status}`);
8238
+ }
8239
+ return { bytes: await readBoundedResponse(response, limit2), sourceLabel: source.url };
8240
+ }
8241
+ const limit = rbxmInputByteLimit(parentPath, "base64(9999999999B)");
8242
+ const maxEncoded = 4 * Math.ceil(limit / 3);
8243
+ if (source.base64.length > maxEncoded + 2 * Math.ceil(maxEncoded / 64))
8244
+ throw tooLarge(limit);
8245
+ const encoded = source.base64.replace(/[\t\n\r ]+/g, "");
8246
+ if (encoded.length > maxEncoded)
8247
+ throw tooLarge(limit);
8248
+ if (encoded.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.includes("=") && encoded.length % 4 !== 0) {
8249
+ throw new Error("RBXM base64 must be valid base64.");
8250
+ }
8251
+ const bytes = Buffer.from(encoded, "base64");
8252
+ if (bytes.length > limit)
8253
+ throw tooLarge(limit);
8254
+ if (bytes.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "")) {
8255
+ throw new Error("RBXM base64 is not canonical.");
8256
+ }
8257
+ return { bytes, sourceLabel: `base64(${bytes.length}B)` };
8258
+ }
8259
+
7465
8260
  // ../core/dist/tools/studio-client.js
7466
8261
  var StudioHttpClient = class {
7467
8262
  bridge;
@@ -7474,6 +8269,56 @@ var StudioHttpClient = class {
7474
8269
  };
7475
8270
 
7476
8271
  // ../core/dist/opencloud-client.js
8272
+ var MONETIZATION_APIS = {
8273
+ developer_product: {
8274
+ label: "developer product",
8275
+ collectionPath: (universeId) => `/developer-products/v2/universes/${universeId}/developer-products`,
8276
+ listField: "developerProducts",
8277
+ idField: "productId",
8278
+ iconField: "iconImageAssetId"
8279
+ },
8280
+ game_pass: {
8281
+ label: "game pass",
8282
+ collectionPath: (universeId) => `/game-passes/v1/universes/${universeId}/game-passes`,
8283
+ listField: "gamePasses",
8284
+ idField: "gamePassId",
8285
+ iconField: "iconAssetId"
8286
+ }
8287
+ };
8288
+ function readField(source, key) {
8289
+ return typeof source === "object" && source !== null ? Reflect.get(source, key) : void 0;
8290
+ }
8291
+ function positiveSafeInteger(value) {
8292
+ const parsed = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value;
8293
+ return typeof parsed === "number" && Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
8294
+ }
8295
+ function normalizeMonetizationItem(kind, universeId, value) {
8296
+ const api = MONETIZATION_APIS[kind];
8297
+ const id = positiveSafeInteger(readField(value, api.idField));
8298
+ if (id === void 0) {
8299
+ throw new Error(`Open Cloud returned a ${api.label} without a valid ${api.idField}.`);
8300
+ }
8301
+ const name = readField(value, "name");
8302
+ const description = readField(value, "description");
8303
+ const price = readField(readField(value, "priceInformation"), "defaultPriceInRobux");
8304
+ const createdAt = readField(value, "createdTimestamp");
8305
+ const updatedAt = readField(value, "updatedTimestamp");
8306
+ const immutable = readField(value, "isImmutable");
8307
+ return {
8308
+ kind,
8309
+ id,
8310
+ universeId: positiveSafeInteger(readField(value, "universeId")) ?? universeId,
8311
+ name: typeof name === "string" ? name : "",
8312
+ description: typeof description === "string" ? description : "",
8313
+ forSale: readField(value, "isForSale") === true,
8314
+ price: typeof price === "number" && Number.isSafeInteger(price) && price >= 0 ? price : null,
8315
+ managedPricing: readField(value, "isManagedPricingEnabled") === true,
8316
+ iconAssetId: positiveSafeInteger(readField(value, api.iconField)) ?? null,
8317
+ ...typeof createdAt === "string" ? { createdAt } : {},
8318
+ ...typeof updatedAt === "string" ? { updatedAt } : {},
8319
+ ...kind === "developer_product" && typeof immutable === "boolean" ? { immutable } : {}
8320
+ };
8321
+ }
7477
8322
  function detectAudioMimeType(data) {
7478
8323
  if (data.length >= 4 && data.subarray(0, 4).toString("ascii") === "OggS") {
7479
8324
  return "audio/ogg";
@@ -7489,6 +8334,68 @@ function detectAudioMimeType(data) {
7489
8334
  }
7490
8335
  return void 0;
7491
8336
  }
8337
+ var OpenCloudRequestError = class extends Error {
8338
+ status;
8339
+ details;
8340
+ constructor(message, status, details = {}) {
8341
+ super(message);
8342
+ this.status = status;
8343
+ this.details = details;
8344
+ this.name = "OpenCloudRequestError";
8345
+ }
8346
+ };
8347
+ async function openCloudResponseError(response) {
8348
+ const errorBody = await response.text();
8349
+ let parsed;
8350
+ try {
8351
+ parsed = JSON.parse(errorBody);
8352
+ } catch {
8353
+ parsed = void 0;
8354
+ }
8355
+ const text = (key) => {
8356
+ const value = readField(parsed, key);
8357
+ return typeof value === "string" && value ? value : void 0;
8358
+ };
8359
+ const errorMessage2 = text("detail") ?? text("message") ?? text("errorMessage") ?? errorBody;
8360
+ const errorCode3 = readField(parsed, "errorCode");
8361
+ const field = text("field");
8362
+ const hint = text("hint");
8363
+ const details = {
8364
+ ...typeof errorCode3 === "string" || typeof errorCode3 === "number" ? { errorCode: errorCode3 } : {},
8365
+ ...field ? { field } : {},
8366
+ ...hint ? { hint } : {}
8367
+ };
8368
+ const { status } = response;
8369
+ let message;
8370
+ if (status === 401) {
8371
+ message = "Invalid or expired API key";
8372
+ } else if (status === 403) {
8373
+ message = `API key lacks required permissions: ${errorMessage2}`;
8374
+ } else if (status === 429) {
8375
+ message = "Rate limit exceeded. Please try again later.";
8376
+ } else {
8377
+ message = `Open Cloud API error (${status}): ${errorMessage2}`;
8378
+ }
8379
+ return new OpenCloudRequestError(message, status, details);
8380
+ }
8381
+ function monetizationForm(changes) {
8382
+ const form = new FormData();
8383
+ if (changes.name !== void 0)
8384
+ form.append("name", changes.name);
8385
+ if (changes.description !== void 0)
8386
+ form.append("description", changes.description);
8387
+ if (changes.price !== void 0)
8388
+ form.append("price", String(changes.price));
8389
+ if (changes.forSale !== void 0)
8390
+ form.append("isForSale", String(changes.forSale));
8391
+ if (changes.managedPricing !== void 0) {
8392
+ form.append("isManagedPricingEnabled", String(changes.managedPricing));
8393
+ }
8394
+ if (changes.icon) {
8395
+ form.append("imageFile", new Blob([new Uint8Array(changes.icon.data)], { type: changes.icon.mimeType }), changes.icon.fileName);
8396
+ }
8397
+ return form;
8398
+ }
7492
8399
  var OpenCloudClient = class {
7493
8400
  apiKey;
7494
8401
  baseUrl;
@@ -7529,29 +8436,12 @@ var OpenCloudClient = class {
7529
8436
  body: body ? JSON.stringify(body) : void 0,
7530
8437
  signal: controller.signal
7531
8438
  });
7532
- clearTimeout(timeoutId);
7533
- if (!response.ok) {
7534
- const errorBody = await response.text();
7535
- let errorMessage2;
7536
- try {
7537
- const errorJson = JSON.parse(errorBody);
7538
- errorMessage2 = errorJson.detail || errorJson.message || errorBody;
7539
- } catch {
7540
- errorMessage2 = errorBody;
7541
- }
7542
- if (response.status === 401) {
7543
- throw new Error("Invalid or expired API key");
7544
- } else if (response.status === 403) {
7545
- throw new Error(`API key lacks required permissions: ${errorMessage2}`);
7546
- } else if (response.status === 429) {
7547
- throw new Error("Rate limit exceeded. Please try again later.");
7548
- } else {
7549
- throw new Error(`Open Cloud API error (${response.status}): ${errorMessage2}`);
7550
- }
7551
- }
8439
+ if (!response.ok)
8440
+ throw await openCloudResponseError(response);
7552
8441
  return await response.json();
7553
8442
  } catch (error) {
7554
- clearTimeout(timeoutId);
8443
+ if (controller.signal.aborted)
8444
+ throw new Error("Request timed out");
7555
8445
  if (error instanceof Error) {
7556
8446
  if (error.name === "AbortError") {
7557
8447
  throw new Error("Request timed out");
@@ -7559,6 +8449,8 @@ var OpenCloudClient = class {
7559
8449
  throw error;
7560
8450
  }
7561
8451
  throw new Error(`Unknown error: ${String(error)}`);
8452
+ } finally {
8453
+ clearTimeout(timeoutId);
7562
8454
  }
7563
8455
  }
7564
8456
  async searchAssets(params) {
@@ -7590,10 +8482,41 @@ var OpenCloudClient = class {
7590
8482
  }
7591
8483
  });
7592
8484
  }
8485
+ async listMonetizationItems(kind, universeId, pageToken) {
8486
+ const api = MONETIZATION_APIS[kind];
8487
+ const response = await this.request(`${api.collectionPath(universeId)}/creator`, {
8488
+ params: { pageToken }
8489
+ });
8490
+ const rows = readField(response, api.listField);
8491
+ if (!Array.isArray(rows)) {
8492
+ throw new Error(`Open Cloud returned a ${api.label} list without ${api.listField}.`);
8493
+ }
8494
+ const nextPageToken = readField(response, "nextPageToken");
8495
+ return {
8496
+ items: rows.map((row) => normalizeMonetizationItem(kind, universeId, row)),
8497
+ ...typeof nextPageToken === "string" && nextPageToken ? { nextPageToken } : {}
8498
+ };
8499
+ }
8500
+ async getMonetizationItem(kind, universeId, id) {
8501
+ const path11 = `${MONETIZATION_APIS[kind].collectionPath(universeId)}/${id}/creator`;
8502
+ return normalizeMonetizationItem(kind, universeId, await this.request(path11));
8503
+ }
8504
+ async createMonetizationItem(kind, universeId, item) {
8505
+ const response = await this.requestMultipart(MONETIZATION_APIS[kind].collectionPath(universeId), monetizationForm(item));
8506
+ return normalizeMonetizationItem(kind, universeId, response);
8507
+ }
8508
+ /** Changes only the given fields. Roblox answers with an empty 204. */
8509
+ async updateMonetizationItem(kind, universeId, id, changes) {
8510
+ const path11 = `${MONETIZATION_APIS[kind].collectionPath(universeId)}/${id}`;
8511
+ await this.sendMultipart(path11, monetizationForm(changes), "PATCH", async (response) => {
8512
+ await response.arrayBuffer();
8513
+ });
8514
+ }
7593
8515
  async getAssetThumbnail(assetId, size = "420x420") {
7594
8516
  const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${assetId}&size=${size}&format=Png`;
7595
8517
  try {
7596
- const response = await fetch(url);
8518
+ const signal = AbortSignal.timeout(this.timeout);
8519
+ const response = await fetch(url, { signal });
7597
8520
  if (!response.ok)
7598
8521
  return null;
7599
8522
  const data = await response.json();
@@ -7601,7 +8524,7 @@ var OpenCloudClient = class {
7601
8524
  if (!thumbnail || thumbnail.state !== "Completed" || !thumbnail.imageUrl) {
7602
8525
  return null;
7603
8526
  }
7604
- const imageResponse = await fetch(thumbnail.imageUrl);
8527
+ const imageResponse = await fetch(thumbnail.imageUrl, { signal });
7605
8528
  if (!imageResponse.ok)
7606
8529
  return null;
7607
8530
  const arrayBuffer = await imageResponse.arrayBuffer();
@@ -7622,7 +8545,7 @@ var OpenCloudClient = class {
7622
8545
  for (const batch of batches) {
7623
8546
  const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${batch.join(",")}&size=${size}&format=Png`;
7624
8547
  try {
7625
- const response = await fetch(url);
8548
+ const response = await fetch(url, { signal: AbortSignal.timeout(this.timeout) });
7626
8549
  if (response.ok) {
7627
8550
  const data = await response.json();
7628
8551
  for (const thumbnail of data.data) {
@@ -7638,7 +8561,7 @@ var OpenCloudClient = class {
7638
8561
  }
7639
8562
  async downloadAudioAssetContent(assetId, maxBytes) {
7640
8563
  if (!this.apiKey) {
7641
- throw new Error("Open Cloud API key not configured. Set ROBLOX_OPEN_CLOUD_API_KEY with asset:read permission to download audio previews.");
8564
+ throw new Error("Open Cloud API key not configured. Set ROBLOX_OPEN_CLOUD_API_KEY with the legacy-asset:manage scope to download audio previews.");
7642
8565
  }
7643
8566
  if (!Number.isSafeInteger(assetId) || assetId <= 0) {
7644
8567
  throw new Error("Audio asset ID must be a positive integer.");
@@ -7656,7 +8579,8 @@ var OpenCloudClient = class {
7656
8579
  signal: controller.signal
7657
8580
  });
7658
8581
  if (!deliveryResponse.ok) {
7659
- throw new Error(`Roblox asset delivery request failed (${deliveryResponse.status}).`);
8582
+ const scopeHint = deliveryResponse.status === 403 ? " The Open Cloud API key lacks the legacy-asset:manage scope required to download audio." : "";
8583
+ throw new Error(`Roblox asset delivery request failed (${deliveryResponse.status}).${scopeHint}`);
7660
8584
  }
7661
8585
  const delivery = await deliveryResponse.json();
7662
8586
  if (!delivery.location) {
@@ -7683,7 +8607,7 @@ var OpenCloudClient = class {
7683
8607
  const chunks = [];
7684
8608
  let totalBytes = 0;
7685
8609
  const reader = contentResponse.body.getReader();
7686
- while (true) {
8610
+ for (; ; ) {
7687
8611
  const { done, value } = await reader.read();
7688
8612
  if (done)
7689
8613
  break;
@@ -7750,7 +8674,11 @@ var OpenCloudClient = class {
7750
8674
  }
7751
8675
  return mimeTypes[ext];
7752
8676
  }
7753
- async requestMultipart(endpoint, formData) {
8677
+ requestMultipart(endpoint, formData) {
8678
+ return this.sendMultipart(endpoint, formData, "POST", async (response) => await response.json());
8679
+ }
8680
+ // readResponse runs inside the deadline, so a stalled body still times out.
8681
+ async sendMultipart(endpoint, formData, method, readResponse) {
7754
8682
  if (!this.apiKey) {
7755
8683
  throw new Error("Open Cloud API key not configured. Set ROBLOX_OPEN_CLOUD_API_KEY environment variable.");
7756
8684
  }
@@ -7759,34 +8687,17 @@ var OpenCloudClient = class {
7759
8687
  const timeoutId = setTimeout(() => controller.abort(), this.timeout);
7760
8688
  try {
7761
8689
  const response = await fetch(url, {
7762
- method: "POST",
8690
+ method,
7763
8691
  headers: { "x-api-key": this.apiKey },
7764
8692
  body: formData,
7765
8693
  signal: controller.signal
7766
8694
  });
7767
- clearTimeout(timeoutId);
7768
- if (!response.ok) {
7769
- const errorBody = await response.text();
7770
- let errorMessage2;
7771
- try {
7772
- const errorJson = JSON.parse(errorBody);
7773
- errorMessage2 = errorJson.detail || errorJson.message || errorBody;
7774
- } catch {
7775
- errorMessage2 = errorBody;
7776
- }
7777
- if (response.status === 401) {
7778
- throw new Error("Invalid or expired API key");
7779
- } else if (response.status === 403) {
7780
- throw new Error(`API key lacks required permissions: ${errorMessage2}`);
7781
- } else if (response.status === 429) {
7782
- throw new Error("Rate limit exceeded. Please try again later.");
7783
- } else {
7784
- throw new Error(`Open Cloud API error (${response.status}): ${errorMessage2}`);
7785
- }
7786
- }
7787
- return await response.json();
8695
+ if (!response.ok)
8696
+ throw await openCloudResponseError(response);
8697
+ return await readResponse(response);
7788
8698
  } catch (error) {
7789
- clearTimeout(timeoutId);
8699
+ if (controller.signal.aborted)
8700
+ throw new Error("Request timed out");
7790
8701
  if (error instanceof Error) {
7791
8702
  if (error.name === "AbortError") {
7792
8703
  throw new Error("Request timed out");
@@ -7794,6 +8705,8 @@ var OpenCloudClient = class {
7794
8705
  throw error;
7795
8706
  }
7796
8707
  throw new Error(`Unknown error: ${String(error)}`);
8708
+ } finally {
8709
+ clearTimeout(timeoutId);
7797
8710
  }
7798
8711
  }
7799
8712
  async pollOperation(operationPath, maxAttempts = 30, intervalMs = 2e3) {
@@ -7805,7 +8718,7 @@ var OpenCloudClient = class {
7805
8718
  if (result.error) {
7806
8719
  throw new Error(`Asset upload failed: ${result.error.message}`);
7807
8720
  }
7808
- await new Promise((resolve5) => setTimeout(resolve5, intervalMs));
8721
+ await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
7809
8722
  }
7810
8723
  throw new Error(`Asset upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
7811
8724
  }
@@ -7822,6 +8735,7 @@ var RobloxCookieClient = class {
7822
8735
  return !!this.cookie;
7823
8736
  }
7824
8737
  async fetchWithCsrf(url, options = {}) {
8738
+ options = { ...options, signal: options.signal ?? AbortSignal.timeout(3e4) };
7825
8739
  const headers = {
7826
8740
  Cookie: `.ROBLOSECURITY=${this.cookie}`,
7827
8741
  ...options.headers || {}
@@ -7835,6 +8749,7 @@ var RobloxCookieClient = class {
7835
8749
  if (newToken) {
7836
8750
  this.csrfToken = newToken;
7837
8751
  headers["X-CSRF-TOKEN"] = newToken;
8752
+ await response.body?.cancel();
7838
8753
  return fetch(url, { ...options, headers });
7839
8754
  }
7840
8755
  }
@@ -7954,7 +8869,7 @@ var RobloxCookieClient = class {
7954
8869
  throw new Error("Image upload completed without an asset ID.");
7955
8870
  }
7956
8871
  if (attempt < maxAttempts - 1) {
7957
- await new Promise((resolve5) => setTimeout(resolve5, intervalMs));
8872
+ await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
7958
8873
  }
7959
8874
  }
7960
8875
  throw new Error(`Image upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
@@ -7977,9 +8892,54 @@ var RobloxCookieClient = class {
7977
8892
  };
7978
8893
 
7979
8894
  // ../core/dist/image-decode.js
7980
- import * as fs2 from "fs";
7981
- import * as path3 from "path";
7982
8895
  import { inflateSync } from "zlib";
8896
+
8897
+ // ../core/dist/local-file.js
8898
+ import * as fs2 from "fs";
8899
+ import * as path6 from "path";
8900
+ function errorCode(error) {
8901
+ return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
8902
+ }
8903
+ function readRegularFileWithinLimit(filePath, argumentName, validateSize) {
8904
+ let descriptor;
8905
+ try {
8906
+ const flags = fs2.constants.O_RDONLY | (fs2.constants.O_NONBLOCK ?? 0);
8907
+ descriptor = fs2.openSync(path6.resolve(filePath), flags);
8908
+ } catch (error) {
8909
+ const code = errorCode(error);
8910
+ if (code === "ENOENT") {
8911
+ throw new Error(`${argumentName} not found: ${filePath}`);
8912
+ }
8913
+ if (code === "EISDIR") {
8914
+ throw new Error(`${argumentName} must reference a regular file: ${filePath}`);
8915
+ }
8916
+ throw error;
8917
+ }
8918
+ try {
8919
+ const stats = fs2.fstatSync(descriptor);
8920
+ if (!stats.isFile()) {
8921
+ throw new Error(`${argumentName} must reference a regular file: ${filePath}`);
8922
+ }
8923
+ validateSize(stats.size);
8924
+ const data = Buffer.allocUnsafe(stats.size + 1);
8925
+ let offset = 0;
8926
+ while (offset < data.length) {
8927
+ const bytesRead = fs2.readSync(descriptor, data, offset, data.length - offset, offset);
8928
+ if (bytesRead === 0)
8929
+ break;
8930
+ offset += bytesRead;
8931
+ }
8932
+ if (offset !== stats.size) {
8933
+ validateSize(offset);
8934
+ throw new Error(`${argumentName} size changed while reading: ${filePath}`);
8935
+ }
8936
+ return data.subarray(0, offset);
8937
+ } finally {
8938
+ fs2.closeSync(descriptor);
8939
+ }
8940
+ }
8941
+
8942
+ // ../core/dist/image-decode.js
7983
8943
  var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
7984
8944
  var MAX_EDITABLE_IMAGE_DIMENSION = 1024;
7985
8945
  var MAX_PNG_DIMENSION = 16 * 1024;
@@ -8043,40 +9003,6 @@ function getBase64DecodedLength(encoded) {
8043
9003
  }
8044
9004
  return decodedLength;
8045
9005
  }
8046
- function readPngFileWithinLimit(resolved, imagePath) {
8047
- let descriptor;
8048
- try {
8049
- const flags = fs2.constants.O_RDONLY | (fs2.constants.O_NONBLOCK ?? 0);
8050
- descriptor = fs2.openSync(resolved, flags);
8051
- } catch (error) {
8052
- if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") {
8053
- throw new Error(`image_path not found: ${imagePath}`);
8054
- }
8055
- throw error;
8056
- }
8057
- try {
8058
- const stats = fs2.fstatSync(descriptor);
8059
- if (!stats.isFile()) {
8060
- throw new Error(`image_path must reference a regular file: ${imagePath}`);
8061
- }
8062
- validatePngInputSize(stats.size);
8063
- const data = Buffer.allocUnsafe(stats.size + 1);
8064
- let offset = 0;
8065
- while (offset < data.length) {
8066
- const bytesRead = fs2.readSync(descriptor, data, offset, data.length - offset, offset);
8067
- if (bytesRead === 0)
8068
- break;
8069
- offset += bytesRead;
8070
- }
8071
- if (offset !== stats.size) {
8072
- validatePngInputSize(offset);
8073
- throw new Error(`image_path size changed while reading: ${imagePath}`);
8074
- }
8075
- return data.subarray(0, offset);
8076
- } finally {
8077
- fs2.closeSync(descriptor);
8078
- }
8079
- }
8080
9006
  function validateSourceDimensions(width, height, maxPixels) {
8081
9007
  if (width <= 0 || height <= 0) {
8082
9008
  throw new Error("PNG IHDR dimensions must be positive.");
@@ -8310,14 +9236,13 @@ function decodePngBase64ToRgba(encoded) {
8310
9236
  return decodePngToRgba(data);
8311
9237
  }
8312
9238
  function decodeImagePathToRgba(imagePath) {
8313
- const resolved = path3.resolve(imagePath);
8314
- return decodePngToRgba(readPngFileWithinLimit(resolved, imagePath));
9239
+ return decodePngToRgba(readRegularFileWithinLimit(imagePath, "image_path", validatePngInputSize));
8315
9240
  }
8316
9241
 
8317
9242
  // ../core/dist/studio-skills.js
8318
9243
  import { createHash as createHash3 } from "crypto";
8319
- import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
8320
- import * as path4 from "path";
9244
+ import { existsSync as existsSync3, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
9245
+ import * as path7 from "path";
8321
9246
 
8322
9247
  // ../../node_modules/fzstd/esm/index.mjs
8323
9248
  var ab = ArrayBuffer;
@@ -9205,17 +10130,17 @@ function parseBuiltInStudioSkills(buffer) {
9205
10130
  }).sort((left, right) => left.name.localeCompare(right.name));
9206
10131
  }
9207
10132
  function discoverNamedFile(root, fileName, depth = 0) {
9208
- if (depth > MAX_DISCOVERY_DEPTH || !existsSync4(root))
10133
+ if (depth > MAX_DISCOVERY_DEPTH || !existsSync3(root))
9209
10134
  return [];
9210
10135
  const matches = [];
9211
10136
  let entries;
9212
10137
  try {
9213
- entries = readdirSync2(root, { withFileTypes: true });
10138
+ entries = readdirSync3(root, { withFileTypes: true });
9214
10139
  } catch {
9215
10140
  return matches;
9216
10141
  }
9217
10142
  for (const entry of entries) {
9218
- const entryPath = path4.join(root, entry.name);
10143
+ const entryPath = path7.join(root, entry.name);
9219
10144
  if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
9220
10145
  matches.push(entryPath);
9221
10146
  } else if (entry.isDirectory()) {
@@ -9227,26 +10152,26 @@ function discoverNamedFile(root, fileName, depth = 0) {
9227
10152
  function resolveAssistantBundlePath(studioExe) {
9228
10153
  const override = process.env.ROBLOX_STUDIO_ASSISTANT_BUNDLE;
9229
10154
  if (override) {
9230
- if (!existsSync4(override)) {
10155
+ if (!existsSync3(override)) {
9231
10156
  throw new Error(`ROBLOX_STUDIO_ASSISTANT_BUNDLE does not exist: ${override}`);
9232
10157
  }
9233
10158
  return override;
9234
10159
  }
9235
- const exeDirectory = path4.dirname(studioExe ?? resolveStudioExe());
10160
+ const exeDirectory = path7.dirname(studioExe ?? resolveStudioExe());
9236
10161
  const roots = [
9237
- path4.join(exeDirectory, "BuiltInStandalonePlugins"),
9238
- path4.resolve(exeDirectory, "..", "Resources", "BuiltInStandalonePlugins"),
9239
- path4.resolve(exeDirectory, "..", "..", "OTAPlugins")
10162
+ path7.join(exeDirectory, "BuiltInStandalonePlugins"),
10163
+ path7.resolve(exeDirectory, "..", "Resources", "BuiltInStandalonePlugins"),
10164
+ path7.resolve(exeDirectory, "..", "..", "OTAPlugins")
9240
10165
  ];
9241
- const direct = path4.join(exeDirectory, "BuiltInStandalonePlugins", "Optimized_Embedded_Signature", "Assistant.rbxm");
10166
+ const direct = path7.join(exeDirectory, "BuiltInStandalonePlugins", "Optimized_Embedded_Signature", "Assistant.rbxm");
9242
10167
  const candidates = /* @__PURE__ */ new Set();
9243
- if (existsSync4(direct))
10168
+ if (existsSync3(direct))
9244
10169
  candidates.add(direct);
9245
10170
  for (const root of roots) {
9246
10171
  for (const candidate of discoverNamedFile(root, "Assistant.rbxm"))
9247
10172
  candidates.add(candidate);
9248
10173
  }
9249
- const newest = [...candidates].map((candidate) => ({ candidate, modifiedAt: statSync2(candidate).mtimeMs })).sort((left, right) => right.modifiedAt - left.modifiedAt)[0]?.candidate;
10174
+ const newest = [...candidates].map((candidate) => ({ candidate, modifiedAt: statSync3(candidate).mtimeMs })).sort((left, right) => right.modifiedAt - left.modifiedAt)[0]?.candidate;
9250
10175
  if (!newest) {
9251
10176
  throw new Error(`Studio Assistant bundle not found for ${studioExe}. Set ROBLOX_STUDIO_ASSISTANT_BUNDLE to the installed Assistant.rbxm path.`);
9252
10177
  }
@@ -9254,16 +10179,16 @@ function resolveAssistantBundlePath(studioExe) {
9254
10179
  }
9255
10180
  var cachedBundle;
9256
10181
  function loadBuiltInStudioSkills(bundlePath = resolveAssistantBundlePath()) {
9257
- const stats = statSync2(bundlePath);
10182
+ const stats = statSync3(bundlePath);
9258
10183
  if (cachedBundle?.path === bundlePath && cachedBundle.modifiedAt === stats.mtimeMs && cachedBundle.size === stats.size) {
9259
10184
  return cachedBundle.value;
9260
10185
  }
9261
- const buffer = readFileSync4(bundlePath);
10186
+ const buffer = readFileSync5(bundlePath);
9262
10187
  const skills = parseBuiltInStudioSkills(buffer);
9263
10188
  if (skills.length === 0) {
9264
10189
  throw new Error(`No built-in skill documents found in ${bundlePath}`);
9265
10190
  }
9266
- const studioVersion = bundlePath.split(path4.sep).find((segment) => /^version-[a-z0-9]+$/i.test(segment));
10191
+ const studioVersion = bundlePath.split(path7.sep).find((segment) => /^version-[a-z0-9]+$/i.test(segment));
9267
10192
  const value = {
9268
10193
  bundlePath,
9269
10194
  bundleModifiedAt: stats.mtime.toISOString(),
@@ -9279,6 +10204,314 @@ function findBuiltInStudioSkill(bundle, requestedName) {
9279
10204
  return bundle.skills.find((skill) => skill.name.toLowerCase() === wanted || skill.sourceName.toLowerCase() === wanted);
9280
10205
  }
9281
10206
 
10207
+ // ../core/dist/tools/monetization.js
10208
+ var MAX_PRICE_ROBUX = 1e9;
10209
+ var MAX_NAME_SCAN_PAGES = 200;
10210
+ var MAX_ICON_BYTES = 10 * 1024 * 1024;
10211
+ var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
10212
+ var SCOPE_PREFIX = {
10213
+ developer_product: "developer-product",
10214
+ game_pass: "game-pass"
10215
+ };
10216
+ var ACTION_FIELDS = {
10217
+ list: ["page_token"],
10218
+ get: ["id"],
10219
+ create: ["name", "description", "price", "for_sale", "managed_pricing", "image_path"],
10220
+ update: ["id", "name", "description", "price", "for_sale", "managed_pricing", "image_path"]
10221
+ };
10222
+ var ACTION_SCOPED_FIELDS = [
10223
+ "id",
10224
+ "page_token",
10225
+ "name",
10226
+ "description",
10227
+ "price",
10228
+ "for_sale",
10229
+ "managed_pricing",
10230
+ "image_path"
10231
+ ];
10232
+ var MonetizationError = class extends Error {
10233
+ code;
10234
+ details;
10235
+ constructor(code, message, details = {}) {
10236
+ super(message);
10237
+ this.code = code;
10238
+ this.details = details;
10239
+ this.name = "MonetizationError";
10240
+ }
10241
+ };
10242
+ function invalidArgument(message) {
10243
+ return new MonetizationError("invalid_argument", message);
10244
+ }
10245
+ function isAction(value) {
10246
+ return value === "list" || value === "get" || value === "create" || value === "update";
10247
+ }
10248
+ function isKind(value) {
10249
+ return value === "developer_product" || value === "game_pass";
10250
+ }
10251
+ function isPresent(value) {
10252
+ return value !== void 0 && value !== null;
10253
+ }
10254
+ function optionalId(value, name) {
10255
+ if (!isPresent(value))
10256
+ return void 0;
10257
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
10258
+ throw invalidArgument(`${name} must be a positive whole number.`);
10259
+ }
10260
+ return value;
10261
+ }
10262
+ function optionalPrice(value) {
10263
+ if (!isPresent(value))
10264
+ return void 0;
10265
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1 || value > MAX_PRICE_ROBUX) {
10266
+ throw invalidArgument(`price must be a whole number of Robux from 1 to ${MAX_PRICE_ROBUX}.`);
10267
+ }
10268
+ return value;
10269
+ }
10270
+ function optionalString(value, name) {
10271
+ if (!isPresent(value))
10272
+ return void 0;
10273
+ if (typeof value !== "string")
10274
+ throw invalidArgument(`${name} must be a string.`);
10275
+ return value;
10276
+ }
10277
+ function optionalNonBlankString(value, name) {
10278
+ const text = optionalString(value, name);
10279
+ if (text !== void 0 && text.trim() === "")
10280
+ throw invalidArgument(`${name} must not be empty.`);
10281
+ return text;
10282
+ }
10283
+ function optionalBoolean(value, name) {
10284
+ if (!isPresent(value))
10285
+ return void 0;
10286
+ if (typeof value !== "boolean")
10287
+ throw invalidArgument(`${name} must be true or false.`);
10288
+ return value;
10289
+ }
10290
+ function parseRequest(request) {
10291
+ const { action, kind } = request;
10292
+ if (!isAction(action))
10293
+ throw invalidArgument("action must be list, get, create, or update.");
10294
+ if (!isKind(kind))
10295
+ throw invalidArgument("kind must be developer_product or game_pass.");
10296
+ for (const field of ACTION_SCOPED_FIELDS) {
10297
+ if (isPresent(request[field]) && !ACTION_FIELDS[action].includes(field)) {
10298
+ throw invalidArgument(`${field} is not used by action=${action}.`);
10299
+ }
10300
+ }
10301
+ const parsed = {
10302
+ action,
10303
+ kind,
10304
+ universeId: optionalId(request.universe_id, "universe_id"),
10305
+ id: optionalId(request.id, "id"),
10306
+ pageToken: optionalNonBlankString(request.page_token, "page_token"),
10307
+ name: optionalNonBlankString(request.name, "name")?.trim(),
10308
+ description: optionalString(request.description, "description"),
10309
+ price: optionalPrice(request.price),
10310
+ forSale: optionalBoolean(request.for_sale, "for_sale"),
10311
+ managedPricing: optionalBoolean(request.managed_pricing, "managed_pricing"),
10312
+ imagePath: optionalNonBlankString(request.image_path, "image_path")
10313
+ };
10314
+ if (action === "get" || action === "update")
10315
+ requiredId(parsed);
10316
+ if (action === "create") {
10317
+ requiredName(parsed);
10318
+ if (parsed.forSale === true && parsed.price === void 0) {
10319
+ throw invalidArgument("price is required to create an item with for_sale=true.");
10320
+ }
10321
+ }
10322
+ if (action === "update" && Object.keys(itemChanges(parsed)).length === 0 && parsed.imagePath === void 0) {
10323
+ throw invalidArgument("update needs at least one of name, description, price, for_sale, managed_pricing, or image_path.");
10324
+ }
10325
+ return parsed;
10326
+ }
10327
+ function requiredId(request) {
10328
+ if (request.id === void 0)
10329
+ throw invalidArgument(`id is required for action=${request.action}.`);
10330
+ return request.id;
10331
+ }
10332
+ function requiredName(request) {
10333
+ if (request.name === void 0)
10334
+ throw invalidArgument(`name is required for action=${request.action}.`);
10335
+ return request.name;
10336
+ }
10337
+ function itemChanges(request) {
10338
+ return {
10339
+ ...request.name !== void 0 ? { name: request.name } : {},
10340
+ ...request.description !== void 0 ? { description: request.description } : {},
10341
+ ...request.price !== void 0 ? { price: request.price } : {},
10342
+ ...request.forSale !== void 0 ? { forSale: request.forSale } : {},
10343
+ ...request.managedPricing !== void 0 ? { managedPricing: request.managedPricing } : {}
10344
+ };
10345
+ }
10346
+ function itemLabel(kind) {
10347
+ return kind === "developer_product" ? "developer product" : "game pass";
10348
+ }
10349
+ function comparableName(name) {
10350
+ return name.trim().toLowerCase();
10351
+ }
10352
+ async function findItemNamed(client, kind, universeId, name) {
10353
+ const wanted = comparableName(name);
10354
+ let pageToken;
10355
+ for (let page = 0; page < MAX_NAME_SCAN_PAGES; page++) {
10356
+ const result = await client.listMonetizationItems(kind, universeId, pageToken);
10357
+ const match = result.items.find((candidate) => comparableName(candidate.name) === wanted);
10358
+ if (match)
10359
+ return match;
10360
+ if (!result.nextPageToken)
10361
+ return void 0;
10362
+ pageToken = result.nextPageToken;
10363
+ }
10364
+ throw new MonetizationError("duplicate_check_incomplete", `Stopped looking for an existing ${itemLabel(kind)} named "${name}" after ${MAX_NAME_SCAN_PAGES} pages, so nothing was created.`);
10365
+ }
10366
+ function requiredScopes(request) {
10367
+ const prefix = SCOPE_PREFIX[request.kind];
10368
+ return request.action === "list" || request.action === "get" ? [`${prefix}:read`] : [`${prefix}:read`, `${prefix}:write`];
10369
+ }
10370
+ function publicItem(item) {
10371
+ return {
10372
+ kind: item.kind,
10373
+ id: item.id,
10374
+ universe_id: item.universeId,
10375
+ name: item.name,
10376
+ description: item.description,
10377
+ for_sale: item.forSale,
10378
+ price: item.price,
10379
+ managed_pricing: item.managedPricing,
10380
+ icon_asset_id: item.iconAssetId,
10381
+ ...item.createdAt !== void 0 ? { created_at: item.createdAt } : {},
10382
+ ...item.updatedAt !== void 0 ? { updated_at: item.updatedAt } : {},
10383
+ ...item.immutable !== void 0 ? { immutable: item.immutable } : {}
10384
+ };
10385
+ }
10386
+ async function resolveUniverse(request, context) {
10387
+ if (request.universeId !== void 0)
10388
+ return request.universeId;
10389
+ const universeId = await context.connectedUniverseId();
10390
+ if (!Number.isSafeInteger(universeId) || universeId <= 0) {
10391
+ throw new MonetizationError("unpublished_place", "The connected Studio place is not published, so it has no universe. Publish it or pass universe_id.");
10392
+ }
10393
+ return universeId;
10394
+ }
10395
+ function iconType(data) {
10396
+ if (data.length >= PNG_SIGNATURE2.length && data.subarray(0, PNG_SIGNATURE2.length).equals(PNG_SIGNATURE2)) {
10397
+ return { mimeType: "image/png", extension: "png" };
10398
+ }
10399
+ if (data.length >= 3 && data[0] === 255 && data[1] === 216 && data[2] === 255) {
10400
+ return { mimeType: "image/jpeg", extension: "jpg" };
10401
+ }
10402
+ if (data.length >= 2 && data[0] === 66 && data[1] === 77) {
10403
+ return { mimeType: "image/bmp", extension: "bmp" };
10404
+ }
10405
+ return void 0;
10406
+ }
10407
+ function readIcon(imagePath) {
10408
+ let data;
10409
+ try {
10410
+ data = readRegularFileWithinLimit(imagePath, "image_path", (byteLength) => {
10411
+ if (byteLength > MAX_ICON_BYTES) {
10412
+ throw new Error(`image_path is ${byteLength} bytes; icons are limited to ${MAX_ICON_BYTES} bytes.`);
10413
+ }
10414
+ });
10415
+ } catch (error) {
10416
+ throw invalidArgument(error instanceof Error ? error.message : String(error));
10417
+ }
10418
+ const type = iconType(data);
10419
+ if (!type)
10420
+ throw invalidArgument("image_path must be a PNG, JPEG, or BMP file.");
10421
+ return { data, fileName: `icon.${type.extension}`, mimeType: type.mimeType };
10422
+ }
10423
+ async function runAction(request, universeId, client, icon) {
10424
+ const { kind } = request;
10425
+ const changes = { ...itemChanges(request), ...icon ? { icon } : {} };
10426
+ switch (request.action) {
10427
+ case "list": {
10428
+ const page = await client.listMonetizationItems(kind, universeId, request.pageToken);
10429
+ return {
10430
+ kind,
10431
+ universe_id: universeId,
10432
+ items: page.items.map(publicItem),
10433
+ ...page.nextPageToken ? { next_page_token: page.nextPageToken } : {}
10434
+ };
10435
+ }
10436
+ case "get":
10437
+ return { item: publicItem(await client.getMonetizationItem(kind, universeId, requiredId(request))) };
10438
+ case "create": {
10439
+ const name = requiredName(request);
10440
+ const existing = await findItemNamed(client, kind, universeId, name);
10441
+ if (existing) {
10442
+ throw new MonetizationError("duplicate_name", `A ${itemLabel(kind)} named "${existing.name}" already exists in universe ${universeId}. Update it or choose another name.`, { existing: publicItem(existing) });
10443
+ }
10444
+ const created = await client.createMonetizationItem(kind, universeId, {
10445
+ ...changes,
10446
+ name,
10447
+ forSale: request.forSale ?? false
10448
+ });
10449
+ return { created: true, item: publicItem(created) };
10450
+ }
10451
+ case "update": {
10452
+ const id = requiredId(request);
10453
+ await client.updateMonetizationItem(kind, universeId, id, changes);
10454
+ try {
10455
+ return { updated: true, item: publicItem(await client.getMonetizationItem(kind, universeId, id)) };
10456
+ } catch (error) {
10457
+ const reason = error instanceof Error ? error.message : String(error);
10458
+ return {
10459
+ updated: true,
10460
+ item: null,
10461
+ warnings: [`The update succeeded, but reading the item back failed: ${reason}`]
10462
+ };
10463
+ }
10464
+ }
10465
+ }
10466
+ }
10467
+ function openCloudFailure(error, request) {
10468
+ const { message, status } = error;
10469
+ switch (status) {
10470
+ case 401:
10471
+ return { error: "unauthorized", message, hint: "Check ROBLOX_OPEN_CLOUD_API_KEY." };
10472
+ case 403:
10473
+ return {
10474
+ error: "forbidden",
10475
+ message,
10476
+ required_scopes: requiredScopes(request),
10477
+ hint: "Grant these scopes to the API key and add this experience to it."
10478
+ };
10479
+ case 404:
10480
+ return { error: "not_found", message, hint: "Check id, kind, and universe_id." };
10481
+ case 409:
10482
+ return { error: "conflict", message };
10483
+ case 429:
10484
+ return { error: "rate_limited", message, hint: "Wait before retrying." };
10485
+ default: {
10486
+ if (status >= 400 && status < 500) {
10487
+ const { field, hint } = error.details;
10488
+ return { error: "invalid_request", message, ...field ? { field } : {}, ...hint ? { hint } : {} };
10489
+ }
10490
+ return { error: "open_cloud_error", message, status };
10491
+ }
10492
+ }
10493
+ }
10494
+ async function runManageMonetization(request, context) {
10495
+ let parsed;
10496
+ try {
10497
+ parsed = parseRequest(request);
10498
+ if (!context.client.hasApiKey()) {
10499
+ const scopes = requiredScopes(parsed);
10500
+ throw new MonetizationError("missing_api_key", `ROBLOX_OPEN_CLOUD_API_KEY is not set. Create an Open Cloud API key with ${scopes.join(" and ")} for this experience.`, { required_scopes: scopes });
10501
+ }
10502
+ const icon = parsed.imagePath === void 0 ? void 0 : readIcon(parsed.imagePath);
10503
+ const universeId = await resolveUniverse(parsed, context);
10504
+ return await runAction(parsed, universeId, context.client, icon);
10505
+ } catch (error) {
10506
+ if (error instanceof MonetizationError) {
10507
+ return { error: error.code, message: error.message, ...error.details };
10508
+ }
10509
+ if (error instanceof OpenCloudRequestError && parsed)
10510
+ return openCloudFailure(error, parsed);
10511
+ throw error;
10512
+ }
10513
+ }
10514
+
9282
10515
  // ../core/dist/jpeg-encoder.js
9283
10516
  var ZIGZAG = [
9284
10517
  0,
@@ -10232,7 +11465,7 @@ function rgbaToJpeg(rgba, width, height, quality = 80) {
10232
11465
 
10233
11466
  // ../core/dist/png-encoder.js
10234
11467
  import { deflateSync } from "zlib";
10235
- var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
11468
+ var PNG_SIGNATURE3 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
10236
11469
  var CRC_TABLE = new Uint32Array(256);
10237
11470
  for (let n = 0; n < 256; n++) {
10238
11471
  let c = n;
@@ -10240,54 +11473,324 @@ for (let n = 0; n < 256; n++) {
10240
11473
  c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
10241
11474
  CRC_TABLE[n] = c;
10242
11475
  }
10243
- function crc32(buf) {
10244
- let crc = 4294967295;
10245
- for (let i = 0; i < buf.length; i++)
10246
- crc = CRC_TABLE[(crc ^ buf[i]) & 255] ^ crc >>> 8;
10247
- return (crc ^ 4294967295) >>> 0;
11476
+ function crc32(buf) {
11477
+ let crc = 4294967295;
11478
+ for (let i = 0; i < buf.length; i++)
11479
+ crc = CRC_TABLE[(crc ^ buf[i]) & 255] ^ crc >>> 8;
11480
+ return (crc ^ 4294967295) >>> 0;
11481
+ }
11482
+ function writeChunk(type, data) {
11483
+ const typeBytes = Buffer.from(type, "ascii");
11484
+ const length = Buffer.alloc(4);
11485
+ length.writeUInt32BE(data.length);
11486
+ const crcInput = Buffer.concat([typeBytes, data]);
11487
+ const checksum = Buffer.alloc(4);
11488
+ checksum.writeUInt32BE(crc32(crcInput));
11489
+ return Buffer.concat([length, typeBytes, data, checksum]);
11490
+ }
11491
+ function rgbaToPng(rgba, width, height) {
11492
+ if (width <= 0 || height <= 0)
11493
+ throw new Error(`Invalid PNG dimensions: ${width}x${height}`);
11494
+ const expected = width * height * 4;
11495
+ if (rgba.length < expected)
11496
+ throw new Error(`Buffer too small: got ${rgba.length}, need ${expected}`);
11497
+ const stride = width * 4;
11498
+ const filtered = Buffer.alloc(height * (1 + stride));
11499
+ for (let y = 0; y < height; y++) {
11500
+ filtered[y * (1 + stride)] = 0;
11501
+ rgba.copy(filtered, y * (1 + stride) + 1, y * stride, (y + 1) * stride);
11502
+ }
11503
+ const ihdr = Buffer.alloc(13);
11504
+ ihdr.writeUInt32BE(width, 0);
11505
+ ihdr.writeUInt32BE(height, 4);
11506
+ ihdr[8] = 8;
11507
+ ihdr[9] = 6;
11508
+ ihdr[10] = 0;
11509
+ ihdr[11] = 0;
11510
+ ihdr[12] = 0;
11511
+ return Buffer.concat([
11512
+ PNG_SIGNATURE3,
11513
+ writeChunk("IHDR", ihdr),
11514
+ writeChunk("IDAT", deflateSync(filtered)),
11515
+ writeChunk("IEND", Buffer.alloc(0))
11516
+ ]);
11517
+ }
11518
+
11519
+ // ../core/dist/host-capture.js
11520
+ import { spawn as spawn4 } from "child_process";
11521
+ import * as fs4 from "fs";
11522
+ import * as os4 from "os";
11523
+ import * as path9 from "path";
11524
+
11525
+ // ../core/dist/host-capture-macos.js
11526
+ import { spawn as spawn3 } from "child_process";
11527
+ import * as fs3 from "fs";
11528
+ import * as os3 from "os";
11529
+ import * as path8 from "path";
11530
+ var MAX_IMAGE_BYTES = 128 * 1024 * 1024;
11531
+ var MAX_OUTPUT_BYTES = 64 * 1024;
11532
+ var STUDIO_BUNDLE_ID = "com.Roblox.RobloxStudio";
11533
+ var MACOS_CAPTURE_SOURCE = String.raw`
11534
+ import Foundation
11535
+ import AppKit
11536
+ import CoreGraphics
11537
+ import ScreenCaptureKit
11538
+
11539
+ struct CaptureFailure: Error { let message: String }
11540
+ func fail(_ message: String) throws -> Never { throw CaptureFailure(message: message) }
11541
+ func emit(_ value: [String: Any]) {
11542
+ if let data = try? JSONSerialization.data(withJSONObject: value), let text = String(data: data, encoding: .utf8) {
11543
+ print(text)
11544
+ }
11545
+ }
11546
+
11547
+ // Local files can use their absolute path in the macOS window title while
11548
+ // Studio reports only the basename as DataModel.Name. Match that basename
11549
+ // after removing Studio's exact title suffix; retain ambiguity checks below.
11550
+ func matchesStudioTitle(_ title: String, hint: String) -> Bool {
11551
+ if hint.isEmpty || title == hint || title.hasPrefix(hint) { return true }
11552
+ let suffix = " - Roblox Studio"
11553
+ guard title.hasSuffix(suffix) else { return false }
11554
+ let place = String(title.dropLast(suffix.count))
11555
+ return place.hasPrefix("/") && (place as NSString).lastPathComponent == hint
11556
+ }
11557
+
11558
+ @available(macOS 14.0, *)
11559
+ @MainActor
11560
+ func capture() async throws {
11561
+ // Never request or bypass Screen Recording permission from an MCP call.
11562
+ guard CGPreflightScreenCaptureAccess() else {
11563
+ try fail("macOS Screen Recording permission is not granted to the MCP host. Enable it in System Settings > Privacy & Security > Screen Recording, then restart the MCP host.")
11564
+ }
11565
+ // A command-line helper must establish its WindowServer connection before
11566
+ // ScreenCaptureKit uses Core Graphics. Do not activate or create any UI.
11567
+ NSApplication.shared.setActivationPolicy(.prohibited)
11568
+ let env = ProcessInfo.processInfo.environment
11569
+ let hint = env["MCP_CAPTURE_TITLE_HINT"] ?? ""
11570
+ let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
11571
+ let candidates = content.windows.filter { window in
11572
+ window.owningApplication?.bundleIdentifier == "com.Roblox.RobloxStudio" &&
11573
+ window.windowLayer == 0 && window.isOnScreen &&
11574
+ window.frame.width > 0 && window.frame.height > 0 &&
11575
+ matchesStudioTitle(window.title ?? "", hint: hint)
11576
+ }
11577
+ guard candidates.count == 1, let window = candidates.first, let app = window.owningApplication else {
11578
+ try fail(candidates.isEmpty ? "no visible Roblox Studio window matches the requested place title" : "multiple Roblox Studio windows match the requested place title; the capture is ambiguous")
11579
+ }
11580
+ if let expected = env["MCP_CAPTURE_EXPECTED_IDENTITY"], !expected.isEmpty {
11581
+ guard let data = expected.data(using: .utf8),
11582
+ let identity = try JSONSerialization.jsonObject(with: data) as? [String: Any],
11583
+ (identity["windowId"] as? NSNumber)?.uint32Value == window.windowID,
11584
+ (identity["processId"] as? NSNumber)?.int32Value == app.processID,
11585
+ identity["bundleIdentifier"] as? String == app.bundleIdentifier else {
11586
+ try fail("the selected Roblox Studio window identity changed between captures")
11587
+ }
11588
+ }
11589
+ let filter = SCContentFilter(desktopIndependentWindow: window)
11590
+ let scale = CGFloat(filter.pointPixelScale)
11591
+ let pixelWidth = filter.contentRect.width * scale
11592
+ let pixelHeight = filter.contentRect.height * scale
11593
+ guard pixelWidth.isFinite, pixelHeight.isFinite, pixelWidth > 0, pixelHeight > 0,
11594
+ pixelWidth <= 16384, pixelHeight <= 16384 else { try fail("invalid Studio window capture dimensions") }
11595
+ let width = Int(pixelWidth.rounded(.up))
11596
+ let height = Int(pixelHeight.rounded(.up))
11597
+ guard width * height * 4 <= 134217728 else { try fail("Studio window capture exceeds the image byte limit") }
11598
+ let config = SCStreamConfiguration()
11599
+ config.width = width
11600
+ config.height = height
11601
+ config.showsCursor = false
11602
+ config.ignoreShadowsSingleWindow = true
11603
+ config.ignoreGlobalClipSingleWindow = true
11604
+ let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
11605
+ guard image.width == width, image.height == height else { try fail("Studio window capture returned unexpected dimensions") }
11606
+ var bytes = [UInt8](repeating: 0, count: width * height * 4)
11607
+ try bytes.withUnsafeMutableBytes { raw in
11608
+ guard let context = CGContext(data: raw.baseAddress, width: width, height: height,
11609
+ bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!,
11610
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) else {
11611
+ try fail("could not allocate the RGBA capture context")
11612
+ }
11613
+ // Quartz draws the image upright into row-major top-to-bottom bitmap storage.
11614
+ context.setBlendMode(.copy)
11615
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
11616
+ }
11617
+ guard let output = env["MCP_CAPTURE_OUT"] else { try fail("missing capture output path") }
11618
+ try Data(bytes).write(to: URL(fileURLWithPath: output), options: .atomic)
11619
+ emit(["ok": true, "width": width, "height": height, "title": String((window.title ?? "").prefix(2048)),
11620
+ "identity": ["windowId": window.windowID, "processId": app.processID, "bundleIdentifier": app.bundleIdentifier]])
11621
+ }
11622
+
11623
+ @main struct Main {
11624
+ @MainActor
11625
+ static func main() async {
11626
+ do {
11627
+ if #available(macOS 14.0, *) { try await capture() }
11628
+ else { try fail("macOS host window capture requires macOS 14 or newer") }
11629
+ } catch {
11630
+ emit(["ok": false, "error": String(((error as? CaptureFailure)?.message ?? error.localizedDescription).prefix(2048))])
11631
+ }
11632
+ }
11633
+ }
11634
+ `;
11635
+ function runBoundedCaptureProcess(executable, args, timeoutMs, env = process.env) {
11636
+ return new Promise((resolve6, reject) => {
11637
+ const processGroup = process.platform !== "win32";
11638
+ const child = spawn3(executable, args, { env, stdio: ["ignore", "pipe", "pipe"], detached: processGroup });
11639
+ let stdout = "";
11640
+ let stderr = "";
11641
+ let outputBytes = 0;
11642
+ let failure;
11643
+ const stop = (message) => {
11644
+ failure ??= new Error(message);
11645
+ try {
11646
+ if (processGroup && child.pid)
11647
+ process.kill(-child.pid, "SIGKILL");
11648
+ else
11649
+ child.kill("SIGKILL");
11650
+ } catch {
11651
+ child.kill("SIGKILL");
11652
+ }
11653
+ };
11654
+ const timer = setTimeout(() => stop(`macOS host capture helper timed out after ${timeoutMs}ms`), timeoutMs);
11655
+ child.stdout.setEncoding("utf8");
11656
+ child.stderr.setEncoding("utf8");
11657
+ const collect = (chunk, isError) => {
11658
+ outputBytes += Buffer.byteLength(chunk);
11659
+ if (outputBytes > MAX_OUTPUT_BYTES) {
11660
+ stop("macOS host capture helper exceeded its output byte limit");
11661
+ return;
11662
+ }
11663
+ if (isError)
11664
+ stderr += chunk;
11665
+ else
11666
+ stdout += chunk;
11667
+ };
11668
+ child.stdout.on("data", (chunk) => collect(chunk, false));
11669
+ child.stderr.on("data", (chunk) => collect(chunk, true));
11670
+ child.on("error", (error) => {
11671
+ failure ??= error;
11672
+ });
11673
+ child.on("close", (code) => {
11674
+ clearTimeout(timer);
11675
+ if (failure)
11676
+ reject(failure);
11677
+ else if (code !== 0)
11678
+ reject(new Error(`macOS host capture helper exited with code ${code}: ${stderr.trim().slice(0, 600)}`));
11679
+ else
11680
+ resolve6(stdout);
11681
+ });
11682
+ });
11683
+ }
11684
+ var MISSING_SWIFT_TOOLCHAIN = "macOS host capture requires Xcode or the Xcode Command Line Tools (xcrun swiftc). Install them with `xcode-select --install`, then retry the capture.";
11685
+ async function assertSwiftToolchain(env = process.env, selectedDeveloperDir = () => runBoundedCaptureProcess("/usr/bin/xcode-select", ["-p"], 5e3, env), exists = fs3.existsSync) {
11686
+ let developerDir = env.DEVELOPER_DIR?.trim();
11687
+ if (!developerDir) {
11688
+ try {
11689
+ developerDir = (await selectedDeveloperDir()).trim();
11690
+ } catch {
11691
+ throw new Error(MISSING_SWIFT_TOOLCHAIN);
11692
+ }
11693
+ }
11694
+ const compilers = [
11695
+ path8.join(developerDir, "usr", "bin", "swiftc"),
11696
+ path8.join(developerDir, "Toolchains", "XcodeDefault.xctoolchain", "usr", "bin", "swiftc")
11697
+ ];
11698
+ if (!path8.isAbsolute(developerDir) || !compilers.some((compiler) => exists(compiler))) {
11699
+ throw new Error(MISSING_SWIFT_TOOLCHAIN);
11700
+ }
11701
+ }
11702
+ var compiledHelper;
11703
+ async function getHelper() {
11704
+ if (!compiledHelper) {
11705
+ compiledHelper = (async () => {
11706
+ await assertSwiftToolchain();
11707
+ const directory = fs3.mkdtempSync(path8.join(os3.tmpdir(), "robloxstudio-mcp-macos-helper-"));
11708
+ const cleanup = () => fs3.rmSync(directory, { recursive: true, force: true });
11709
+ try {
11710
+ const source = path8.join(directory, "capture.swift");
11711
+ const executable = path8.join(directory, "capture");
11712
+ fs3.writeFileSync(source, MACOS_CAPTURE_SOURCE, { mode: 384 });
11713
+ await runBoundedCaptureProcess("/usr/bin/xcrun", [
11714
+ "swiftc",
11715
+ "-parse-as-library",
11716
+ source,
11717
+ "-o",
11718
+ executable,
11719
+ "-module-cache-path",
11720
+ path8.join(directory, "modules")
11721
+ ], 6e4);
11722
+ process.once("exit", cleanup);
11723
+ return executable;
11724
+ } catch (error) {
11725
+ cleanup();
11726
+ throw error;
11727
+ }
11728
+ })();
11729
+ compiledHelper.catch(() => {
11730
+ compiledHelper = void 0;
11731
+ });
11732
+ }
11733
+ return compiledHelper;
11734
+ }
11735
+ async function prepareMacHostCapture() {
11736
+ await getHelper();
10248
11737
  }
10249
- function writeChunk(type, data) {
10250
- const typeBytes = Buffer.from(type, "ascii");
10251
- const length = Buffer.alloc(4);
10252
- length.writeUInt32BE(data.length);
10253
- const crcInput = Buffer.concat([typeBytes, data]);
10254
- const checksum = Buffer.alloc(4);
10255
- checksum.writeUInt32BE(crc32(crcInput));
10256
- return Buffer.concat([length, typeBytes, data, checksum]);
11738
+ function validIdentity(value) {
11739
+ if (!value || typeof value !== "object")
11740
+ return false;
11741
+ const id = value;
11742
+ return Number.isSafeInteger(id.windowId) && id.windowId > 0 && id.windowId <= 4294967295 && Number.isSafeInteger(id.processId) && id.processId > 0 && id.processId <= 2147483647 && id.bundleIdentifier === STUDIO_BUNDLE_ID;
10257
11743
  }
10258
- function rgbaToPng(rgba, width, height) {
10259
- if (width <= 0 || height <= 0)
10260
- throw new Error(`Invalid PNG dimensions: ${width}x${height}`);
10261
- const expected = width * height * 4;
10262
- if (rgba.length < expected)
10263
- throw new Error(`Buffer too small: got ${rgba.length}, need ${expected}`);
10264
- const stride = width * 4;
10265
- const filtered = Buffer.alloc(height * (1 + stride));
10266
- for (let y = 0; y < height; y++) {
10267
- filtered[y * (1 + stride)] = 0;
10268
- rgba.copy(filtered, y * (1 + stride) + 1, y * stride, (y + 1) * stride);
11744
+ function parseMacCaptureReport(stdout, expectedIdentity) {
11745
+ if (Buffer.byteLength(stdout) > MAX_OUTPUT_BYTES)
11746
+ throw new Error("macOS capture report exceeds its byte limit");
11747
+ const value = JSON.parse(stdout.trim());
11748
+ if (!value || typeof value !== "object")
11749
+ throw new Error("invalid macOS capture report");
11750
+ if (value.ok === false && typeof value.error === "string")
11751
+ return { ok: false, error: value.error.slice(0, 2048) };
11752
+ const { width, height, title, identity } = value;
11753
+ if (value.ok !== true || !Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0 || width > 16384 || height > 16384 || width * height * 4 > MAX_IMAGE_BYTES || typeof title !== "string" || title.length > 2048 || !validIdentity(identity)) {
11754
+ throw new Error("invalid macOS capture dimensions or window identity");
11755
+ }
11756
+ if (expectedIdentity && (identity.windowId !== expectedIdentity.windowId || identity.processId !== expectedIdentity.processId || identity.bundleIdentifier !== expectedIdentity.bundleIdentifier)) {
11757
+ throw new Error("the selected Roblox Studio window identity changed between captures");
11758
+ }
11759
+ return { ok: true, width, height, title, identity };
11760
+ }
11761
+ async function runMacCapture(output, titleHint, expectedIdentity) {
11762
+ const executable = await getHelper();
11763
+ return runBoundedCaptureProcess(executable, [], 2e4, {
11764
+ ...process.env,
11765
+ MCP_CAPTURE_TITLE_HINT: titleHint ?? "",
11766
+ MCP_CAPTURE_OUT: output,
11767
+ MCP_CAPTURE_EXPECTED_IDENTITY: expectedIdentity ? JSON.stringify(expectedIdentity) : ""
11768
+ });
11769
+ }
11770
+ async function captureMacStudioWindow(titleHint, expectedIdentity, runCapture = runMacCapture) {
11771
+ const directory = fs3.mkdtempSync(path8.join(os3.tmpdir(), "robloxstudio-mcp-macos-capture-"));
11772
+ try {
11773
+ const output = path8.join(directory, "capture.rgba");
11774
+ const stdout = await runCapture(output, titleHint, expectedIdentity);
11775
+ const report = parseMacCaptureReport(stdout, expectedIdentity);
11776
+ if (!report.ok)
11777
+ return report;
11778
+ const expectedBytes = report.width * report.height * 4;
11779
+ const stat2 = fs3.lstatSync(output);
11780
+ if (!stat2.isFile() || stat2.size !== expectedBytes)
11781
+ throw new Error("macOS capture returned an invalid RGBA byte count");
11782
+ const rgba = fs3.readFileSync(output);
11783
+ if (rgba.length !== expectedBytes)
11784
+ throw new Error("macOS capture RGBA byte count changed during read");
11785
+ return { ok: true, capture: { width: report.width, height: report.height, title: report.title, identity: report.identity, rgba } };
11786
+ } catch (error) {
11787
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
11788
+ } finally {
11789
+ fs3.rmSync(directory, { recursive: true, force: true });
10269
11790
  }
10270
- const ihdr = Buffer.alloc(13);
10271
- ihdr.writeUInt32BE(width, 0);
10272
- ihdr.writeUInt32BE(height, 4);
10273
- ihdr[8] = 8;
10274
- ihdr[9] = 6;
10275
- ihdr[10] = 0;
10276
- ihdr[11] = 0;
10277
- ihdr[12] = 0;
10278
- return Buffer.concat([
10279
- PNG_SIGNATURE2,
10280
- writeChunk("IHDR", ihdr),
10281
- writeChunk("IDAT", deflateSync(filtered)),
10282
- writeChunk("IEND", Buffer.alloc(0))
10283
- ]);
10284
11791
  }
10285
11792
 
10286
11793
  // ../core/dist/host-capture.js
10287
- import { spawn as spawn2 } from "child_process";
10288
- import * as fs3 from "fs";
10289
- import * as os3 from "os";
10290
- import * as path5 from "path";
10291
11794
  var HOST_CAPTURE_TIMEOUT_MS = 2e4;
10292
11795
  var MARKER_MIN_RB = 200;
10293
11796
  var MARKER_MAX_G = 60;
@@ -10297,10 +11800,14 @@ function isHostCaptureDisabled(env = process.env) {
10297
11800
  return value === "0" || value === "false" || value === "off";
10298
11801
  }
10299
11802
  function isHostCaptureSupported(platform = process.platform) {
10300
- return platform === "win32";
11803
+ return platform === "win32" || platform === "darwin";
10301
11804
  }
10302
11805
  function hostCaptureUnsupportedReason(platform = process.platform) {
10303
- return `host window capture is only implemented on Windows (this is ${platform})`;
11806
+ return `host window capture is only implemented on Windows and macOS (this is ${platform})`;
11807
+ }
11808
+ async function prepareHostWindowCapture() {
11809
+ if (process.platform === "darwin" && !isHostCaptureDisabled())
11810
+ await prepareMacHostCapture();
10304
11811
  }
10305
11812
  function isUniformFrame(rgba, width, height) {
10306
11813
  const pixels = width * height;
@@ -10450,17 +11957,40 @@ public static class McpStudioCapture {
10450
11957
  }
10451
11958
  "@
10452
11959
  function Emit($obj) { Write-Output ($obj | ConvertTo-Json -Compress) }
11960
+ # Local files show their absolute path in the window title while Studio reports
11961
+ # only the basename as DataModel.Name. Match that basename after removing
11962
+ # Studio's exact title suffix; the ambiguity checks below still apply.
11963
+ function Test-StudioTitle([string]$title, [string]$hint) {
11964
+ if (-not $hint -or $title.StartsWith($hint, [StringComparison]::Ordinal)) { return $true }
11965
+ $suffix = ' - Roblox Studio'
11966
+ if (-not $title.EndsWith($suffix, [StringComparison]::Ordinal)) { return $false }
11967
+ $place = $title.Substring(0, $title.Length - $suffix.Length)
11968
+ if ($place -cnotmatch '^(?:[A-Za-z]:[\\/]|\\\\|/)') { return $false }
11969
+ return $place.Substring($place.LastIndexOfAny([char[]]@([char]'\', [char]'/')) + 1) -ceq $hint
11970
+ }
10453
11971
  $hint = $env:MCP_CAPTURE_TITLE_HINT
10454
11972
  $outFile = $env:MCP_CAPTURE_OUT
10455
11973
  $candidates = [McpStudioCapture]::Find('RobloxStudioBeta')
10456
11974
  if ($candidates.Count -eq 0) { Emit @{ ok = $false; error = 'no visible Roblox Studio window was found' }; exit 0 }
10457
- $pick = $null
10458
- if ($hint) { $pick = $candidates | Where-Object { $_.Title.StartsWith($hint) } | Select-Object -First 1 }
10459
- if ($pick -eq $null -and $candidates.Count -eq 1) { $pick = $candidates[0] }
10460
- if ($pick -eq $null) {
10461
- $titles = ($candidates | ForEach-Object { $_.Title }) -join ' | '
10462
- Emit @{ ok = $false; error = "could not pick a Studio window for '$hint' among: $titles" }; exit 0
11975
+ $matching = @($candidates | Where-Object { Test-StudioTitle $_.Title $hint })
11976
+ $expectedJson = $env:MCP_CAPTURE_EXPECTED_IDENTITY
11977
+ if ($expectedJson) {
11978
+ try { $expected = $expectedJson | ConvertFrom-Json -ErrorAction Stop }
11979
+ catch { Emit @{ ok = $false; error = 'invalid expected Studio window identity' }; exit 0 }
11980
+ $matching = @($matching | Where-Object {
11981
+ $_.Handle.ToInt64() -eq $expected.windowId -and $_.Pid -eq $expected.processId -and
11982
+ $expected.bundleIdentifier -ceq 'RobloxStudioBeta'
11983
+ })
11984
+ if ($matching.Count -ne 1) {
11985
+ Emit @{ ok = $false; error = 'the selected Roblox Studio window identity changed between captures' }; exit 0
11986
+ }
11987
+ } elseif ($matching.Count -ne 1) {
11988
+ $reason = if ($matching.Count -eq 0) { 'no visible Roblox Studio window matches the requested place title' }
11989
+ else { 'multiple Roblox Studio windows match the requested place title; the capture is ambiguous' }
11990
+ Emit @{ ok = $false; error = $reason }; exit 0
10463
11991
  }
11992
+ $pick = $matching[0]
11993
+ $identity = @{ windowId = $pick.Handle.ToInt64(); processId = [long]$pick.Pid; bundleIdentifier = 'RobloxStudioBeta' }
10464
11994
  if ([McpStudioCapture]::IsIconic($pick.Handle)) {
10465
11995
  Emit @{ ok = $false; error = "the Studio window '$($pick.Title)' is minimized; restore it (it may stay behind other windows)" }; exit 0
10466
11996
  }
@@ -10482,83 +12012,110 @@ $bytes = New-Object byte[] ($data.Stride * $h)
10482
12012
  $stride = $data.Stride
10483
12013
  $bmp.UnlockBits($data); $bmp.Dispose()
10484
12014
  [System.IO.File]::WriteAllBytes($outFile, $bytes)
10485
- Emit @{ ok = $true; width = $w; height = $h; stride = $stride; title = $pick.Title }
12015
+ Emit @{ ok = $true; width = $w; height = $h; stride = $stride; title = $pick.Title; identity = $identity }
10486
12016
  `;
12017
+ function validWindowsIdentity(value) {
12018
+ return value !== null && typeof value === "object" && "windowId" in value && typeof value.windowId === "number" && Number.isSafeInteger(value.windowId) && value.windowId > 0 && "processId" in value && typeof value.processId === "number" && Number.isSafeInteger(value.processId) && value.processId > 0 && value.processId <= 4294967295 && "bundleIdentifier" in value && value.bundleIdentifier === "RobloxStudioBeta";
12019
+ }
12020
+ function parseWindowsCaptureReport(value, expectedIdentity) {
12021
+ if (value === null || typeof value !== "object" || !("ok" in value))
12022
+ throw new Error("invalid Windows capture report");
12023
+ if (value.ok === false && "error" in value && typeof value.error === "string")
12024
+ return { ok: false, error: value.error };
12025
+ if (!("identity" in value) || !validWindowsIdentity(value.identity))
12026
+ throw new Error("invalid Windows capture window identity");
12027
+ const identity = value.identity;
12028
+ if (expectedIdentity && (identity.windowId !== expectedIdentity.windowId || identity.processId !== expectedIdentity.processId || identity.bundleIdentifier !== expectedIdentity.bundleIdentifier)) {
12029
+ throw new Error("the selected Roblox Studio window identity changed between captures");
12030
+ }
12031
+ if (value.ok !== true || !("width" in value) || typeof value.width !== "number" || !Number.isSafeInteger(value.width) || value.width <= 0 || !("height" in value) || typeof value.height !== "number" || !Number.isSafeInteger(value.height) || value.height <= 0 || !("stride" in value) || typeof value.stride !== "number" || !Number.isSafeInteger(value.stride) || value.stride < value.width * 4 || value.width > 16384 || value.height > 16384 || value.stride * value.height > 128 * 1024 * 1024 || !("title" in value) || typeof value.title !== "string") {
12032
+ throw new Error("invalid Windows capture dimensions or title");
12033
+ }
12034
+ return { ok: true, width: value.width, height: value.height, stride: value.stride, title: value.title, identity };
12035
+ }
10487
12036
  function powershellPath() {
10488
12037
  const systemRoot = process.env.SystemRoot ?? process.env.windir;
10489
12038
  if (systemRoot) {
10490
- const candidate = path5.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
10491
- if (fs3.existsSync(candidate))
12039
+ const candidate = path9.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
12040
+ if (fs4.existsSync(candidate))
10492
12041
  return candidate;
10493
12042
  }
10494
12043
  return "powershell.exe";
10495
12044
  }
10496
- function runWindowsCapture(titleHint, outFile) {
10497
- return new Promise((resolve5, reject) => {
10498
- const encoded = Buffer.from(WINDOWS_CAPTURE_SCRIPT, "utf16le").toString("base64");
10499
- const child = spawn2(powershellPath(), ["-NoProfile", "-NonInteractive", "-NoLogo", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], {
10500
- env: { ...process.env, MCP_CAPTURE_TITLE_HINT: titleHint ?? "", MCP_CAPTURE_OUT: outFile },
10501
- stdio: ["ignore", "pipe", "pipe"],
10502
- windowsHide: true
10503
- });
10504
- let stdout = "";
10505
- let stderr = "";
10506
- let settled = false;
10507
- const timer = setTimeout(() => {
10508
- if (settled)
10509
- return;
10510
- settled = true;
10511
- child.kill();
10512
- reject(new Error(`host window capture timed out after ${HOST_CAPTURE_TIMEOUT_MS}ms`));
10513
- }, HOST_CAPTURE_TIMEOUT_MS);
10514
- child.stdout.setEncoding("utf8");
10515
- child.stderr.setEncoding("utf8");
10516
- child.stdout.on("data", (chunk) => {
10517
- stdout += chunk;
10518
- });
10519
- child.stderr.on("data", (chunk) => {
10520
- stderr += chunk;
10521
- });
10522
- child.on("error", (error) => {
10523
- if (settled)
10524
- return;
10525
- settled = true;
10526
- clearTimeout(timer);
10527
- reject(new Error(`could not start PowerShell for host window capture: ${error.message}`));
10528
- });
10529
- child.on("close", (code) => {
10530
- if (settled)
12045
+ function runWindowsCapture(titleHint, outFile, expectedIdentity) {
12046
+ const { promise, resolve: resolve6, reject } = Promise.withResolvers();
12047
+ const encoded = Buffer.from(WINDOWS_CAPTURE_SCRIPT, "utf16le").toString("base64");
12048
+ const child = spawn4(powershellPath(), ["-NoProfile", "-NonInteractive", "-NoLogo", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], {
12049
+ env: {
12050
+ ...process.env,
12051
+ MCP_CAPTURE_TITLE_HINT: titleHint ?? "",
12052
+ MCP_CAPTURE_OUT: outFile,
12053
+ MCP_CAPTURE_EXPECTED_IDENTITY: expectedIdentity ? JSON.stringify(expectedIdentity) : ""
12054
+ },
12055
+ stdio: ["ignore", "pipe", "pipe"],
12056
+ windowsHide: true
12057
+ });
12058
+ let stdout = "";
12059
+ let stderr = "";
12060
+ let settled = false;
12061
+ const timer = setTimeout(() => {
12062
+ if (settled)
12063
+ return;
12064
+ settled = true;
12065
+ child.kill();
12066
+ reject(new Error(`host window capture timed out after ${HOST_CAPTURE_TIMEOUT_MS}ms`));
12067
+ }, HOST_CAPTURE_TIMEOUT_MS);
12068
+ child.stdout.setEncoding("utf8");
12069
+ child.stderr.setEncoding("utf8");
12070
+ child.stdout.on("data", (chunk) => {
12071
+ stdout += chunk;
12072
+ });
12073
+ child.stderr.on("data", (chunk) => {
12074
+ stderr += chunk;
12075
+ });
12076
+ child.on("error", (error) => {
12077
+ if (settled)
12078
+ return;
12079
+ settled = true;
12080
+ clearTimeout(timer);
12081
+ reject(new Error(`could not start PowerShell for host window capture: ${error.message}`));
12082
+ });
12083
+ child.on("close", (code) => {
12084
+ if (settled)
12085
+ return;
12086
+ settled = true;
12087
+ clearTimeout(timer);
12088
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
12089
+ const last = lines[lines.length - 1];
12090
+ if (last && last.startsWith("{")) {
12091
+ try {
12092
+ resolve6(JSON.parse(last));
10531
12093
  return;
10532
- settled = true;
10533
- clearTimeout(timer);
10534
- const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
10535
- const last = lines[lines.length - 1];
10536
- if (last && last.startsWith("{")) {
10537
- try {
10538
- resolve5(JSON.parse(last));
10539
- return;
10540
- } catch {
10541
- }
12094
+ } catch {
10542
12095
  }
10543
- const detail = (stderr.trim() || stdout.trim()).slice(0, 600);
10544
- reject(new Error(`host window capture helper exited with code ${code}${detail ? `: ${detail}` : ""}`));
10545
- });
12096
+ }
12097
+ const detail = (stderr.trim() || stdout.trim()).slice(0, 600);
12098
+ reject(new Error(`host window capture helper exited with code ${code}${detail ? `: ${detail}` : ""}`));
10546
12099
  });
12100
+ return promise;
10547
12101
  }
10548
- async function captureStudioWindow(titleHint) {
12102
+ async function captureStudioWindow(titleHint, expectedIdentity) {
10549
12103
  if (isHostCaptureDisabled()) {
10550
12104
  return { ok: false, error: "host window capture is disabled by ROBLOX_STUDIO_HOST_CAPTURE" };
10551
12105
  }
10552
12106
  if (!isHostCaptureSupported()) {
10553
12107
  return { ok: false, error: hostCaptureUnsupportedReason() };
10554
12108
  }
10555
- const outFile = path5.join(os3.tmpdir(), `robloxstudio-mcp-capture-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.bgra`);
12109
+ if (process.platform === "darwin")
12110
+ return captureMacStudioWindow(titleHint, expectedIdentity);
12111
+ const outFile = path9.join(os4.tmpdir(), `robloxstudio-mcp-capture-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.bgra`);
10556
12112
  try {
10557
- const report = await runWindowsCapture(titleHint, outFile);
10558
- if (!report.ok || !report.width || !report.height || !report.stride) {
10559
- return { ok: false, error: report.error ?? "host window capture returned no image" };
10560
- }
10561
- const raw = fs3.readFileSync(outFile);
12113
+ const report = parseWindowsCaptureReport(await runWindowsCapture(titleHint, outFile, expectedIdentity), expectedIdentity);
12114
+ if (!report.ok)
12115
+ return report;
12116
+ const raw = fs4.readFileSync(outFile);
12117
+ if (raw.length !== report.stride * report.height)
12118
+ throw new Error("Windows capture returned an invalid BGRA byte count");
10562
12119
  const { width, height, stride } = report;
10563
12120
  const rgba = Buffer.alloc(width * height * 4);
10564
12121
  for (let y = 0; y < height; y++) {
@@ -10573,17 +12130,20 @@ async function captureStudioWindow(titleHint) {
10573
12130
  rgba[d + 3] = 255;
10574
12131
  }
10575
12132
  }
10576
- return { ok: true, capture: { width, height, rgba, title: report.title ?? "" } };
12133
+ return { ok: true, capture: { width, height, rgba, title: report.title, identity: report.identity } };
10577
12134
  } catch (error) {
10578
12135
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
10579
12136
  } finally {
10580
- fs3.rmSync(outFile, { force: true });
12137
+ fs4.rmSync(outFile, { force: true });
10581
12138
  }
10582
12139
  }
10583
12140
 
10584
12141
  // ../core/dist/tools/index.js
10585
- import * as fs4 from "fs";
10586
- import * as path6 from "path";
12142
+ import * as fs5 from "fs";
12143
+ import * as path10 from "path";
12144
+ function sameHostWindowIdentity(actual, expected) {
12145
+ return actual?.windowId === expected.windowId && actual.processId === expected.processId && actual.bundleIdentifier === expected.bundleIdentifier;
12146
+ }
10587
12147
  var HOST_VIEWPORT_RECT_TTL_MS = 6e4;
10588
12148
  var MAX_INLINE_IMAGE_BYTES = 6e6;
10589
12149
  var MAX_MATRIX_IMAGE_BYTES = 8e6;
@@ -10669,7 +12229,7 @@ function encodeImageFromRgbaResponse(response, format, quality) {
10669
12229
  };
10670
12230
  }
10671
12231
  function sleep(ms) {
10672
- return new Promise((resolve5) => setTimeout(resolve5, ms));
12232
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
10673
12233
  }
10674
12234
  function errorMessage(error) {
10675
12235
  return error instanceof Error ? error.message : String(error);
@@ -10763,9 +12323,9 @@ function compactSoundReference(sound) {
10763
12323
  name: stringField(sound, "name"),
10764
12324
  className: stringField(sound, "className")
10765
12325
  };
10766
- const path7 = stringField(sound, "path");
10767
- if (path7)
10768
- compact.path = path7;
12326
+ const path11 = stringField(sound, "path");
12327
+ if (path11)
12328
+ compact.path = path11;
10769
12329
  const assetId = robloxAssetIdFromContentId(sound.assetId ?? sound.soundId ?? sound.asset);
10770
12330
  if (assetId !== void 0)
10771
12331
  compact.assetId = assetId;
@@ -10828,8 +12388,8 @@ function loadMicroProfilerBaseline(source, sourcePath) {
10828
12388
  if (typeof sourcePath !== "string" || sourcePath === "") {
10829
12389
  throw new Error("baseline_path must be a non-empty string when provided");
10830
12390
  }
10831
- const resolved = path6.resolve(sourcePath);
10832
- const parsed = JSON.parse(fs4.readFileSync(resolved, "utf8"));
12391
+ const resolved = path10.resolve(sourcePath);
12392
+ const parsed = JSON.parse(fs5.readFileSync(resolved, "utf8"));
10833
12393
  const record = asRecord(parsed);
10834
12394
  if (!record)
10835
12395
  throw new Error(`baseline_path did not contain a JSON object: ${resolved}`);
@@ -11331,6 +12891,7 @@ var RobloxStudioTools = class {
11331
12891
  cookieClient;
11332
12892
  instanceManager;
11333
12893
  managedConnectionAssociations = Promise.resolve();
12894
+ unsubscribePeerRegistered;
11334
12895
  hostWindowCapture = captureStudioWindow;
11335
12896
  hostViewportRects = /* @__PURE__ */ new Map();
11336
12897
  viewportCaptureQueues = /* @__PURE__ */ new Map();
@@ -11340,7 +12901,7 @@ var RobloxStudioTools = class {
11340
12901
  this.openCloudClient = new OpenCloudClient();
11341
12902
  this.cookieClient = new RobloxCookieClient();
11342
12903
  this.instanceManager = new StudioInstanceManager();
11343
- this.bridge.onPeerRegistered((peer) => {
12904
+ this.unsubscribePeerRegistered = this.bridge.onPeerRegistered((peer) => {
11344
12905
  const instanceManager = this.instanceManager;
11345
12906
  const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(peer, instanceManager));
11346
12907
  this.managedConnectionAssociations = association.catch((error) => {
@@ -11351,6 +12912,10 @@ var RobloxStudioTools = class {
11351
12912
  getStudioLifecycleCapabilities() {
11352
12913
  return this.instanceManager.getLifecycleCapabilities();
11353
12914
  }
12915
+ async dispose() {
12916
+ this.unsubscribePeerRegistered();
12917
+ await this.managedConnectionAssociations;
12918
+ }
11354
12919
  _textResult(body) {
11355
12920
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
11356
12921
  }
@@ -11474,8 +13039,8 @@ var RobloxStudioTools = class {
11474
13039
  }
11475
13040
  return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal, operationId);
11476
13041
  }
11477
- // Prefer the first client role in the selected process/group scope for live
11478
- // viewport and input operations; otherwise retain the default Peer's Instance.
13042
+ // Honor an explicitly selected client for viewport/input operations; otherwise
13043
+ // prefer the first client in the scope, then retain the default Peer's Instance.
11479
13044
  _resolveRuntime(instance_id) {
11480
13045
  const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
11481
13046
  if (!resolved.ok)
@@ -11487,7 +13052,9 @@ var RobloxStudioTools = class {
11487
13052
  data: this._routingErrorData()
11488
13053
  });
11489
13054
  }
11490
- const client = this.bridge.getPeersInScope(resolved.targetInstanceId).filter((peer) => /^client-\d+$/.test(peer.role)).sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
13055
+ const clients = this.bridge.getPeersInScope(resolved.targetInstanceId).filter((peer) => /^client-\d+$/.test(peer.role));
13056
+ const selectedClients = clients.filter((peer) => instance_id === peer.instanceId || instance_id === `${peer.instanceId}-${peer.role}`);
13057
+ const client = selectedClients.length === 1 ? selectedClients[0] : clients.sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
11491
13058
  return {
11492
13059
  instanceId: client?.instanceId ?? resolved.targetInstanceId,
11493
13060
  clientRole: client?.role
@@ -11773,8 +13340,8 @@ var RobloxStudioTools = class {
11773
13340
  timedOut: true
11774
13341
  };
11775
13342
  }
11776
- async getFileTree(path7 = "", instance_id) {
11777
- const response = await this._callSingle("/api/file-tree", { path: path7 }, void 0, instance_id);
13343
+ async getFileTree(path11 = "", instance_id) {
13344
+ const response = await this._callSingle("/api/file-tree", { path: path11 }, void 0, instance_id);
11778
13345
  return {
11779
13346
  content: [
11780
13347
  {
@@ -11806,6 +13373,28 @@ var RobloxStudioTools = class {
11806
13373
  ]
11807
13374
  };
11808
13375
  }
13376
+ async manageMonetization(request, instance_id) {
13377
+ return this._textResult(await runManageMonetization(request, {
13378
+ client: this.openCloudClient,
13379
+ connectedUniverseId: () => this._connectedUniverseId(instance_id)
13380
+ }));
13381
+ }
13382
+ // Monetization APIs address a universe, which Studio reports as game.GameId.
13383
+ async _connectedUniverseId(instance_id) {
13384
+ let info;
13385
+ try {
13386
+ info = await this._callSingle("/api/place-info", {}, void 0, instance_id);
13387
+ } catch (error) {
13388
+ if (error instanceof RoutingFailure) {
13389
+ throw new RoutingFailure({
13390
+ ...error.routingError,
13391
+ message: `${error.routingError.message} Pass universe_id to skip Studio.`
13392
+ });
13393
+ }
13394
+ throw error;
13395
+ }
13396
+ return typeof info.gameId === "number" ? info.gameId : 0;
13397
+ }
11809
13398
  async searchObjects(query, searchType = "name", propertyName, instance_id) {
11810
13399
  const response = await this._callSingle("/api/search-objects", {
11811
13400
  query,
@@ -11866,9 +13455,9 @@ var RobloxStudioTools = class {
11866
13455
  ]
11867
13456
  };
11868
13457
  }
11869
- async getProjectStructure(path7, maxDepth, scriptsOnly, instance_id) {
13458
+ async getProjectStructure(path11, maxDepth, scriptsOnly, instance_id) {
11870
13459
  const response = await this._callSingle("/api/project-structure", {
11871
- path: path7,
13460
+ path: path11,
11872
13461
  maxDepth,
11873
13462
  scriptsOnly
11874
13463
  }, void 0, instance_id);
@@ -12029,7 +13618,7 @@ var RobloxStudioTools = class {
12029
13618
  };
12030
13619
  }
12031
13620
  async setSelection(paths, mode, instance_id) {
12032
- if (!Array.isArray(paths) || paths.some((path7) => typeof path7 !== "string" || path7.length === 0)) {
13621
+ if (!Array.isArray(paths) || paths.some((path11) => typeof path11 !== "string" || path11.length === 0)) {
12033
13622
  throw new Error("selection paths must contain only non-empty instance paths");
12034
13623
  }
12035
13624
  const selectionMode = mode ?? "set";
@@ -12766,9 +14355,9 @@ var RobloxStudioTools = class {
12766
14355
  const rawJson = mutable.raw_json;
12767
14356
  if (typeof rawJson === "string") {
12768
14357
  if (typeof outputPath === "string" && outputPath !== "") {
12769
- const resolvedOutputPath = path6.resolve(outputPath);
12770
- fs4.mkdirSync(path6.dirname(resolvedOutputPath), { recursive: true });
12771
- fs4.writeFileSync(resolvedOutputPath, rawJson, "utf8");
14358
+ const resolvedOutputPath = path10.resolve(outputPath);
14359
+ fs5.mkdirSync(path10.dirname(resolvedOutputPath), { recursive: true });
14360
+ fs5.writeFileSync(resolvedOutputPath, rawJson, "utf8");
12772
14361
  mutable.output_path = resolvedOutputPath;
12773
14362
  }
12774
14363
  delete mutable.raw_json;
@@ -12829,9 +14418,9 @@ var RobloxStudioTools = class {
12829
14418
  const rawSnapshotBase64 = mutable.raw_snapshot_base64;
12830
14419
  if (typeof rawSnapshotBase64 === "string") {
12831
14420
  if (typeof outputPath === "string" && outputPath !== "") {
12832
- const resolvedOutputPath = path6.resolve(outputPath);
12833
- fs4.mkdirSync(path6.dirname(resolvedOutputPath), { recursive: true });
12834
- fs4.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
14421
+ const resolvedOutputPath = path10.resolve(outputPath);
14422
+ fs5.mkdirSync(path10.dirname(resolvedOutputPath), { recursive: true });
14423
+ fs5.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
12835
14424
  mutable.output_path = resolvedOutputPath;
12836
14425
  }
12837
14426
  delete mutable.raw_snapshot_base64;
@@ -12845,9 +14434,9 @@ var RobloxStudioTools = class {
12845
14434
  });
12846
14435
  }
12847
14436
  if (typeof summaryOutputPath === "string" && summaryOutputPath !== "") {
12848
- const resolvedSummaryPath = path6.resolve(summaryOutputPath);
12849
- fs4.mkdirSync(path6.dirname(resolvedSummaryPath), { recursive: true });
12850
- fs4.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
14437
+ const resolvedSummaryPath = path10.resolve(summaryOutputPath);
14438
+ fs5.mkdirSync(path10.dirname(resolvedSummaryPath), { recursive: true });
14439
+ fs5.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
12851
14440
  mutable.summary_output_path = resolvedSummaryPath;
12852
14441
  }
12853
14442
  if (!includeComparisonIndex) {
@@ -12909,7 +14498,7 @@ var RobloxStudioTools = class {
12909
14498
  return record.placeId !== void 0 && instance.placeId === record.placeId;
12910
14499
  }
12911
14500
  if ((record.source === "baseplate" || record.source === "local_file") && record.localPlaceFile) {
12912
- const expectedName = path6.basename(record.localPlaceFile);
14501
+ const expectedName = path10.basename(record.localPlaceFile);
12913
14502
  return instance.placeName === expectedName || instance.dataModelName === expectedName;
12914
14503
  }
12915
14504
  return true;
@@ -12922,7 +14511,7 @@ var RobloxStudioTools = class {
12922
14511
  await instanceManager.attachInstanceId(candidate, instance.instanceId);
12923
14512
  }
12924
14513
  async _deriveUniverseId(placeId) {
12925
- const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`);
14514
+ const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`, { signal: AbortSignal.timeout(3e4) });
12926
14515
  if (!response.ok) {
12927
14516
  const body = await response.text().catch(() => "");
12928
14517
  throw new Error(`Could not resolve the universe for place_id ${placeId} (${response.status}): ${body}`);
@@ -14240,11 +15829,11 @@ var RobloxStudioTools = class {
14240
15829
  return this.resolveUploadedReferenceImageId(decalId, instance_id);
14241
15830
  }
14242
15831
  async uploadAsset(filePath, assetType, displayName, description, userId, groupId) {
14243
- if (!fs4.existsSync(filePath)) {
15832
+ if (!fs5.existsSync(filePath)) {
14244
15833
  throw new Error(`File not found: ${filePath}`);
14245
15834
  }
14246
- const fileContent = fs4.readFileSync(filePath);
14247
- const fileName = path6.basename(filePath);
15835
+ const fileContent = fs5.readFileSync(filePath);
15836
+ const fileName = path10.basename(filePath);
14248
15837
  const resolvedGroupId = groupId || process.env.ROBLOX_CREATOR_GROUP_ID;
14249
15838
  const resolvedUserId = userId || process.env.ROBLOX_CREATOR_USER_ID;
14250
15839
  if (assetType === "Decal" && this.cookieClient.hasCookie()) {
@@ -14460,10 +16049,10 @@ var RobloxStudioTools = class {
14460
16049
  return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
14461
16050
  }
14462
16051
  const bytes = Buffer.from(response.base64, "base64");
14463
- const resolved = path6.resolve(outputPath);
16052
+ const resolved = path10.resolve(outputPath);
14464
16053
  try {
14465
- fs4.mkdirSync(path6.dirname(resolved), { recursive: true });
14466
- fs4.writeFileSync(resolved, bytes);
16054
+ fs5.mkdirSync(path10.dirname(resolved), { recursive: true });
16055
+ fs5.writeFileSync(resolved, bytes);
14467
16056
  } catch (err2) {
14468
16057
  return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err2.message}` }) }] };
14469
16058
  }
@@ -14478,7 +16067,7 @@ var RobloxStudioTools = class {
14478
16067
  }]
14479
16068
  };
14480
16069
  }
14481
- async importRbxm(source, parentPath, target, instance_id) {
16070
+ async importRbxm(source, parentPath, target, instance_id, signal) {
14482
16071
  if (!source || typeof source !== "object") {
14483
16072
  throw new Error("source is required for import_rbxm");
14484
16073
  }
@@ -14493,59 +16082,18 @@ var RobloxStudioTools = class {
14493
16082
  if (modes.length !== 1) {
14494
16083
  throw new Error(`source must contain exactly one of { path, url, base64 } (got: ${modes.join(", ") || "none"})`);
14495
16084
  }
14496
- let bytes;
14497
- let sourceLabel;
14498
- if (source.path !== void 0) {
14499
- const resolved = path6.resolve(source.path);
14500
- try {
14501
- bytes = fs4.readFileSync(resolved);
14502
- } catch (err2) {
14503
- return { content: [{ type: "text", text: JSON.stringify({ error: `failed to read ${resolved}: ${err2.message}` }) }] };
14504
- }
14505
- sourceLabel = resolved;
14506
- } else if (source.url !== void 0) {
14507
- let parsedUrl;
14508
- try {
14509
- parsedUrl = new URL(source.url);
14510
- } catch {
14511
- return { content: [{ type: "text", text: JSON.stringify({ error: `import_rbxm url is not a valid URL: ${source.url}` }) }] };
14512
- }
14513
- if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
14514
- return { content: [{ type: "text", text: JSON.stringify({ error: `import_rbxm url must use http(s); got ${parsedUrl.protocol}` }) }] };
14515
- }
14516
- const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
14517
- try {
14518
- const res = await fetch(source.url);
14519
- if (!res.ok) {
14520
- const snippet = (await res.text()).slice(0, 500);
14521
- return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url} returned ${res.status}: ${snippet}` }) }] };
14522
- }
14523
- const claimed = Number(res.headers.get("content-length") ?? "0");
14524
- if (claimed > MAX_IMPORT_BYTES) {
14525
- return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url}: content-length ${claimed} exceeds ${MAX_IMPORT_BYTES} byte cap` }) }] };
14526
- }
14527
- const arr = await res.arrayBuffer();
14528
- if (arr.byteLength > MAX_IMPORT_BYTES) {
14529
- return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url}: downloaded ${arr.byteLength} bytes exceeds ${MAX_IMPORT_BYTES} byte cap` }) }] };
14530
- }
14531
- bytes = Buffer.from(arr);
14532
- } catch (err2) {
14533
- return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url} failed: ${err2.message}` }) }] };
14534
- }
14535
- sourceLabel = source.url;
14536
- } else {
14537
- try {
14538
- bytes = Buffer.from(source.base64, "base64");
14539
- } catch (err2) {
14540
- return { content: [{ type: "text", text: JSON.stringify({ error: `base64 decode failed: ${err2.message}` }) }] };
14541
- }
14542
- sourceLabel = `base64(${bytes.length}B)`;
16085
+ let input;
16086
+ try {
16087
+ input = await readRbxmInput(source, parentPath, signal);
16088
+ } catch (error) {
16089
+ return this._textResult({ error: error instanceof Error ? error.message : String(error) });
14543
16090
  }
16091
+ const { bytes, sourceLabel } = input;
14544
16092
  const response = await this._callSingle("/api/import-rbxm", {
14545
16093
  base64: bytes.toString("base64"),
14546
16094
  parent_path: parentPath,
14547
16095
  source_label: sourceLabel
14548
- }, tgt, instance_id);
16096
+ }, tgt, instance_id, void 0, signal);
14549
16097
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
14550
16098
  }
14551
16099
  // Even native capture must wait while another call has fitted the simulator
@@ -14567,24 +16115,26 @@ var RobloxStudioTools = class {
14567
16115
  const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
14568
16116
  let response = await this._callSingle("/api/capture-studio", { encoding: fmt === "png" ? "png" : "rgba8" }, targetRole, instanceId);
14569
16117
  const studioFastPathMissing = response.unavailable !== void 0 || (response.error?.includes("/api/capture-studio") ?? false);
16118
+ const studioFastPathUnavailable = studioFastPathMissing ? response.unavailable ?? response.error : void 0;
14570
16119
  if (studioFastPathMissing) {
14571
16120
  if (targetRole.startsWith("client-")) {
14572
16121
  const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
14573
16122
  if (begin.error) {
14574
- return { success: false, error: begin.error };
14575
- }
14576
- if (!begin.contentId) {
14577
- return { success: false, error: "Screenshot capture failed: no content id returned from client." };
16123
+ response = { error: begin.error };
16124
+ } else if (!begin.contentId) {
16125
+ response = { error: "Screenshot capture failed: no content id returned from client." };
16126
+ } else {
16127
+ response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
14578
16128
  }
14579
- response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
14580
16129
  } else {
14581
16130
  response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
14582
16131
  }
16132
+ response.source ??= "CaptureService";
14583
16133
  }
14584
16134
  let hostNote = "";
14585
16135
  let hostReason;
14586
16136
  try {
14587
- hostReason = response.error ? `Studio's capture failed (${response.error})` : this._isUniformCaptureResponse(response) ? "Studio's CaptureService returned a blank (single-colour) frame" : void 0;
16137
+ hostReason = response.error ? `Studio's capture failed (${response.error})` : this._isUniformCaptureResponse(response) ? `Studio's ${response.source ?? "capture"} returned a blank (single-colour) frame` : void 0;
14588
16138
  } catch (error) {
14589
16139
  response = { ...response, error: `Could not decode Studio's screenshot: ${error instanceof Error ? error.message : String(error)}` };
14590
16140
  hostReason = `Studio's capture failed (${response.error})`;
@@ -14605,7 +16155,7 @@ var RobloxStudioTools = class {
14605
16155
  if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
14606
16156
  text = `Screenshot capture reached the multiplayer client, but Roblox returned a temporary screenshot texture that the edit peer cannot read in StudioTestService multiplayer sessions. Regular solo_playtest capture works because the temporary rbxtemp:// handle is readable from the edit process; multiplayer client handles appear to be scoped to the client process. Raw error: ${response.error}`;
14607
16157
  }
14608
- return { success: false, error: text };
16158
+ return { success: false, error: text, studioFastPathUnavailable };
14609
16159
  }
14610
16160
  const w = response.width;
14611
16161
  const h = response.height;
@@ -14659,7 +16209,9 @@ var RobloxStudioTools = class {
14659
16209
  note,
14660
16210
  data: buffer.toString("base64"),
14661
16211
  mimeType,
14662
- message
16212
+ message,
16213
+ source: response.source,
16214
+ studioFastPathUnavailable
14663
16215
  };
14664
16216
  }
14665
16217
  _isUniformCaptureResponse(response) {
@@ -14681,6 +16233,19 @@ var RobloxStudioTools = class {
14681
16233
  }
14682
16234
  // Always restore the simulator, including failures and cached window grabs.
14683
16235
  async _captureViewportFromHostWindow(instanceId, targetRole) {
16236
+ if (isHostCaptureDisabled()) {
16237
+ return { success: false, error: "host window capture is disabled by ROBLOX_STUDIO_HOST_CAPTURE" };
16238
+ }
16239
+ if (!isHostCaptureSupported()) {
16240
+ return { success: false, error: hostCaptureUnsupportedReason() };
16241
+ }
16242
+ if (this.hostWindowCapture === captureStudioWindow) {
16243
+ try {
16244
+ await prepareHostWindowCapture();
16245
+ } catch (error) {
16246
+ return { success: false, error: `could not prepare host capture: ${error instanceof Error ? error.message : String(error)}` };
16247
+ }
16248
+ }
14684
16249
  let prepared;
14685
16250
  let result;
14686
16251
  try {
@@ -14734,9 +16299,12 @@ var RobloxStudioTools = class {
14734
16299
  const viewportWidth = prepared.viewportWidth ?? sizeInfo.viewportWidth;
14735
16300
  const viewportHeight = prepared.viewportHeight ?? sizeInfo.viewportHeight;
14736
16301
  if (cached && Date.now() - cached.cachedAt < HOST_VIEWPORT_RECT_TTL_MS && cached.viewportWidth === viewportWidth && cached.viewportHeight === viewportHeight) {
14737
- const grab = await this.hostWindowCapture(titleHint);
16302
+ const grab = await this.hostWindowCapture(titleHint, cached.identity);
14738
16303
  if (!grab.ok)
14739
16304
  return { success: false, error: grab.error };
16305
+ if (cached.identity !== void 0 && !sameHostWindowIdentity(grab.capture.identity, cached.identity)) {
16306
+ return { success: false, error: "the Studio window identity changed since locating the viewport; retry the capture" };
16307
+ }
14740
16308
  if (grab.capture.width === cached.windowWidth && grab.capture.height === cached.windowHeight) {
14741
16309
  return {
14742
16310
  success: true,
@@ -14772,21 +16340,25 @@ var RobloxStudioTools = class {
14772
16340
  });
14773
16341
  if ("error" in located)
14774
16342
  return { success: false, error: located.error };
16343
+ const cleanGrab = await this.hostWindowCapture(titleHint, markerGrab.capture.identity);
16344
+ if (!cleanGrab.ok)
16345
+ return { success: false, error: cleanGrab.error };
16346
+ if (markerGrab.capture.identity !== void 0 && !sameHostWindowIdentity(cleanGrab.capture.identity, markerGrab.capture.identity)) {
16347
+ return { success: false, error: "the Studio window identity changed while locating the viewport; retry the capture" };
16348
+ }
16349
+ if (cleanGrab.capture.width !== markerGrab.capture.width || cleanGrab.capture.height !== markerGrab.capture.height) {
16350
+ this.hostViewportRects.delete(cacheKey);
16351
+ return { success: false, error: "the Studio window was resized while locating the viewport; retry the capture" };
16352
+ }
14775
16353
  this.hostViewportRects.set(cacheKey, {
14776
16354
  rect: located.rect,
16355
+ identity: markerGrab.capture.identity,
14777
16356
  windowWidth: markerGrab.capture.width,
14778
16357
  windowHeight: markerGrab.capture.height,
14779
16358
  viewportWidth,
14780
16359
  viewportHeight,
14781
16360
  cachedAt: Date.now()
14782
16361
  });
14783
- const cleanGrab = await this.hostWindowCapture(titleHint);
14784
- if (!cleanGrab.ok)
14785
- return { success: false, error: cleanGrab.error };
14786
- if (cleanGrab.capture.width !== markerGrab.capture.width || cleanGrab.capture.height !== markerGrab.capture.height) {
14787
- this.hostViewportRects.delete(cacheKey);
14788
- return { success: false, error: "the Studio window was resized while locating the viewport; retry the capture" };
14789
- }
14790
16362
  return {
14791
16363
  success: true,
14792
16364
  response: this._hostResponseFromCrop(cleanGrab.capture.rgba, cleanGrab.capture.width, cleanGrab.capture.height, located.rect, viewportWidth, viewportHeight)
@@ -14812,7 +16384,7 @@ var RobloxStudioTools = class {
14812
16384
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
14813
16385
  const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
14814
16386
  if (!capture.success) {
14815
- return this._textResult({ error: capture.error });
16387
+ return this._textResult({ error: capture.error, studioFastPathUnavailable: capture.studioFastPathUnavailable });
14816
16388
  }
14817
16389
  return {
14818
16390
  content: [
@@ -14824,7 +16396,9 @@ var RobloxStudioTools = class {
14824
16396
  format: capture.format,
14825
16397
  mimeType: capture.mimeType,
14826
16398
  ...capture.quality === void 0 ? {} : { quality: capture.quality },
14827
- message: capture.message
16399
+ message: capture.message,
16400
+ source: capture.source,
16401
+ studioFastPathUnavailable: capture.studioFastPathUnavailable
14828
16402
  })
14829
16403
  },
14830
16404
  {
@@ -14838,7 +16412,7 @@ var RobloxStudioTools = class {
14838
16412
  };
14839
16413
 
14840
16414
  // ../core/dist/proxy-bridge-service.js
14841
- import { randomUUID as randomUUID4 } from "crypto";
16415
+ import { randomUUID as randomUUID5 } from "crypto";
14842
16416
  var PROXY_RESPONSE_GRACE_MS = 5e3;
14843
16417
  function parseRequestStatus(value) {
14844
16418
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -14897,7 +16471,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14897
16471
  super();
14898
16472
  this.primaryBaseUrl = primaryBaseUrl;
14899
16473
  this.authToken = authToken;
14900
- this.proxyInstanceId = randomUUID4();
16474
+ this.proxyInstanceId = randomUUID5();
14901
16475
  this.initialRefresh = this.refreshTopology();
14902
16476
  this.refreshTimer = setInterval(() => this.refreshTopology(), _ProxyBridgeService.REFRESH_INTERVAL_MS);
14903
16477
  }
@@ -14974,6 +16548,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14974
16548
  const response = await fetch(`${this.primaryBaseUrl}/create-multiplayer-group`, {
14975
16549
  method: "POST",
14976
16550
  headers: this.authHeaders({ "Content-Type": "application/json" }),
16551
+ signal: AbortSignal.timeout(5e3),
14977
16552
  body: JSON.stringify({ groupId, controllerInstanceId })
14978
16553
  });
14979
16554
  if (!response.ok) {
@@ -15004,6 +16579,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
15004
16579
  const response = await fetch(`${this.primaryBaseUrl}/remove-multiplayer-group`, {
15005
16580
  method: "POST",
15006
16581
  headers: this.authHeaders({ "Content-Type": "application/json" }),
16582
+ signal: AbortSignal.timeout(5e3),
15007
16583
  body: JSON.stringify({ groupId })
15008
16584
  });
15009
16585
  if (response.status === 409) {
@@ -15035,6 +16611,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
15035
16611
  const response = await fetch(`${this.primaryBaseUrl}/unregister-instance-id`, {
15036
16612
  method: "POST",
15037
16613
  headers: this.authHeaders({ "Content-Type": "application/json" }),
16614
+ signal: AbortSignal.timeout(5e3),
15038
16615
  body: JSON.stringify({ instanceId })
15039
16616
  });
15040
16617
  if (!response.ok) {
@@ -15064,7 +16641,8 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
15064
16641
  }
15065
16642
  async getRequestStatusEverywhere(requestId) {
15066
16643
  const response = await fetch(`${this.primaryBaseUrl}/request-status?requestId=${encodeURIComponent(requestId)}`, {
15067
- headers: this.authHeaders()
16644
+ headers: this.authHeaders(),
16645
+ signal: AbortSignal.timeout(5e3)
15068
16646
  });
15069
16647
  if (!response.ok)
15070
16648
  throw new Error(`Proxy request status failed (${response.status})`);
@@ -15079,7 +16657,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
15079
16657
  return status;
15080
16658
  }
15081
16659
  async sendRequest(endpoint, data, targetPeerId, timeoutMs = this.proxyRequestTimeout, signal, operationId) {
15082
- const requestId = operationId ?? randomUUID4();
16660
+ const requestId = operationId ?? randomUUID5();
15083
16661
  const details = { requestId, targetPeerId, stage: "queued", outcome: "not_executed", executionOutcome: "not_executed" };
15084
16662
  if (typeof requestId !== "string" || requestId.trim().length === 0 || requestId.length > 128) {
15085
16663
  throw new RequestFailure("operationId must be a nonempty string of at most 128 characters", "invalid_operation_id", details);
@@ -15369,8 +16947,8 @@ var BoundedStdioTransport = class {
15369
16947
  this.onStreamError(error);
15370
16948
  return Promise.reject(error);
15371
16949
  }
15372
- const { promise, resolve: resolve5, reject } = Promise.withResolvers();
15373
- const pending = { resolve: resolve5, reject };
16950
+ const { promise, resolve: resolve6, reject } = Promise.withResolvers();
16951
+ const pending = { resolve: resolve6, reject };
15374
16952
  this.pendingSends.add(pending);
15375
16953
  this.pendingWrites++;
15376
16954
  try {
@@ -15382,7 +16960,7 @@ var BoundedStdioTransport = class {
15382
16960
  });
15383
16961
  if (writable) {
15384
16962
  this.pendingSends.delete(pending);
15385
- resolve5();
16963
+ resolve6();
15386
16964
  } else if (!this.closed) {
15387
16965
  this.outputBlocked = true;
15388
16966
  this.input.pause();
@@ -15481,6 +17059,7 @@ var RobloxStudioMCPServer = class {
15481
17059
  try {
15482
17060
  const result = await listenWithRetry(candidateApp, host, basePort, 1);
15483
17061
  const oldBridge = this.bridge;
17062
+ const oldTools = this.tools;
15484
17063
  this.bridge = candidateBridge;
15485
17064
  this.tools = candidateTools;
15486
17065
  if (oldBridge instanceof ProxyBridgeService) {
@@ -15494,6 +17073,7 @@ var RobloxStudioMCPServer = class {
15494
17073
  console.error(`Promoted from proxy to primary on port ${boundPort}`);
15495
17074
  if (promotionInterval)
15496
17075
  clearInterval(promotionInterval);
17076
+ await oldTools.dispose();
15497
17077
  } catch {
15498
17078
  await candidateApp.cleanup().catch(() => {
15499
17079
  });
@@ -15558,6 +17138,7 @@ var RobloxStudioMCPServer = class {
15558
17138
  });
15559
17139
  if (httpHandle)
15560
17140
  httpHandle.close();
17141
+ await this.tools.dispose();
15561
17142
  process.exit(0);
15562
17143
  };
15563
17144
  process.on("SIGTERM", shutdown);
@@ -16974,7 +18555,7 @@ var TOOL_DEFINITIONS = [
16974
18555
  includeAudio: {
16975
18556
  type: "boolean",
16976
18557
  default: true,
16977
- description: "Return inline audio; needs asset:read and never writes files."
18558
+ description: "Return inline audio; needs legacy-asset:manage and never writes files."
16978
18559
  },
16979
18560
  maxAudioPreviews: {
16980
18561
  type: "number",
@@ -17050,6 +18631,72 @@ var TOOL_DEFINITIONS = [
17050
18631
  }
17051
18632
  }
17052
18633
  },
18634
+ // === Monetization ===
18635
+ {
18636
+ name: "manage_monetization",
18637
+ category: "write",
18638
+ description: "Use to list, inspect, create, or update developer products and game passes.",
18639
+ inputSchema: {
18640
+ type: "object",
18641
+ properties: {
18642
+ action: {
18643
+ type: "string",
18644
+ enum: ["list", "get", "create", "update"],
18645
+ description: "Operation; create and update change live Roblox items."
18646
+ },
18647
+ kind: {
18648
+ type: "string",
18649
+ enum: ["developer_product", "game_pass"],
18650
+ description: "Item type."
18651
+ },
18652
+ universe_id: {
18653
+ type: "integer",
18654
+ minimum: 1,
18655
+ description: "Universe ID; defaults to the connected Studio place."
18656
+ },
18657
+ id: {
18658
+ type: "integer",
18659
+ minimum: 1,
18660
+ description: "Product or pass ID; required for get and update."
18661
+ },
18662
+ name: {
18663
+ type: "string",
18664
+ description: "Item name; required for create."
18665
+ },
18666
+ description: {
18667
+ type: "string",
18668
+ description: "Item description shown to buyers."
18669
+ },
18670
+ price: {
18671
+ type: "integer",
18672
+ minimum: 1,
18673
+ maximum: 1e9,
18674
+ description: "Default price in Robux."
18675
+ },
18676
+ for_sale: {
18677
+ type: "boolean",
18678
+ description: "Purchasable; create defaults to false."
18679
+ },
18680
+ managed_pricing: {
18681
+ type: "boolean",
18682
+ description: "Let Roblox price optimization set the price."
18683
+ },
18684
+ image_path: {
18685
+ type: "string",
18686
+ description: "Local PNG, JPEG, or BMP icon file."
18687
+ },
18688
+ page_token: {
18689
+ type: "string",
18690
+ description: "Prior list page token."
18691
+ },
18692
+ instance_id: {
18693
+ type: "string",
18694
+ description: "Studio process ID when ambiguous."
18695
+ }
18696
+ },
18697
+ required: ["action", "kind"]
18698
+ }
18699
+ },
17053
18700
  // === Input Simulation ===
17054
18701
  {
17055
18702
  name: "simulate_mouse_input",
@@ -17219,7 +18866,7 @@ var TOOL_DEFINITIONS = [
17219
18866
  properties: {
17220
18867
  source: {
17221
18868
  type: "object",
17222
- description: "Exactly one path, URL, or base64 source; URLs cap at 50 MiB.",
18869
+ description: "Exactly one path, URL, or base64 source. All inputs are capped below 37.5 MiB to fit base64 plus routing metadata within transport limits; URL downloads have a 30-second deadline.",
17223
18870
  properties: {
17224
18871
  path: { type: "string", description: "Absolute local .rbxm path." },
17225
18872
  url: { type: "string", description: "HTTP or HTTPS .rbxm URL." },
@@ -17348,10 +18995,10 @@ var getAllTools = () => [...TOOL_DEFINITIONS];
17348
18995
 
17349
18996
  // ../core/dist/install-plugin-helpers.js
17350
18997
  var import_saxes = __toESM(require_saxes(), 1);
17351
- import { existsSync as existsSync7, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync7, renameSync, rmSync as rmSync3, rmdirSync, statSync as statSync3, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
18998
+ import { existsSync as existsSync7, lstatSync as lstatSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync9, renameSync, rmSync as rmSync5, rmdirSync, statSync as statSync4, unlinkSync as unlinkSync3, writeFileSync as writeFileSync5 } from "fs";
17352
18999
  import { execSync } from "child_process";
17353
- import { randomUUID as randomUUID5 } from "crypto";
17354
- import { basename as basename3, join as join6 } from "path";
19000
+ import { randomUUID as randomUUID6 } from "crypto";
19001
+ import { basename as basename3, join as join9 } from "path";
17355
19002
  import { homedir as homedir4 } from "os";
17356
19003
  import { isUtf8 } from "buffer";
17357
19004
  var DEFAULT_PLUGIN_PORT = 58741;
@@ -17403,15 +19050,15 @@ function getPluginsFolder() {
17403
19050
  if (process.env.MCP_PLUGINS_DIR)
17404
19051
  return process.env.MCP_PLUGINS_DIR;
17405
19052
  if (process.platform === "win32") {
17406
- return join6(process.env.LOCALAPPDATA || join6(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
19053
+ return join9(process.env.LOCALAPPDATA || join9(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
17407
19054
  }
17408
19055
  if (isWSL()) {
17409
19056
  const localAppData = getWindowsUserLocalAppData();
17410
19057
  if (localAppData)
17411
- return join6(localAppData.linuxPath, "Roblox", "Plugins");
19058
+ return join9(localAppData.linuxPath, "Roblox", "Plugins");
17412
19059
  console.warn("[install-plugin] WSL detected but could not resolve Windows %LOCALAPPDATA%. Falling back to ~/Documents/Roblox/Plugins/ - you will likely need to copy the rbxmx to /mnt/c/Users/<you>/AppData/Local/Roblox/Plugins/ manually. Set MCP_PLUGINS_DIR to skip detection.");
17413
19060
  }
17414
- return join6(homedir4(), "Documents", "Roblox", "Plugins");
19061
+ return join9(homedir4(), "Documents", "Roblox", "Plugins");
17415
19062
  }
17416
19063
  var STALE_TEST_PLUGINS_DIRECTORY = "RsmcpIsolatedPlugins";
17417
19064
  function pluginDirectorySettingPattern() {
@@ -17426,7 +19073,7 @@ function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDi
17426
19073
  if (studioPluginsDirectory === "") {
17427
19074
  throw new Error("studioPluginsDirectory must be a non-empty path.");
17428
19075
  }
17429
- const contents = readFileSync7(settingsPath, "utf8");
19076
+ const contents = readFileSync9(settingsPath, "utf8");
17430
19077
  const matches = [...contents.matchAll(pluginDirectorySettingPattern())];
17431
19078
  if (matches.length !== 1) {
17432
19079
  throw new Error(`Expected exactly one Studio.PluginsDir QDir in ${settingsPath}; found ${matches.length}.`);
@@ -17434,16 +19081,16 @@ function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDi
17434
19081
  if (matches[0][2] !== STALE_TEST_PLUGINS_DIRECTORY)
17435
19082
  return false;
17436
19083
  const studioPath = escapeXmlText(studioPluginsDirectory);
17437
- const updated = contents.replace(pluginDirectorySettingPattern(), (_match, open2, _value, close) => `${open2}${studioPath}${close}`);
17438
- const temporaryPath = `${settingsPath}.rsmcp-install-${process.pid}-${randomUUID5()}.tmp`;
19084
+ const updated = contents.replace(pluginDirectorySettingPattern(), (_match, open4, _value, close) => `${open4}${studioPath}${close}`);
19085
+ const temporaryPath = `${settingsPath}.rsmcp-install-${process.pid}-${randomUUID6()}.tmp`;
17439
19086
  try {
17440
- writeFileSync3(temporaryPath, updated, {
19087
+ writeFileSync5(temporaryPath, updated, {
17441
19088
  encoding: "utf8",
17442
- mode: statSync3(settingsPath).mode
19089
+ mode: statSync4(settingsPath).mode
17443
19090
  });
17444
19091
  renameSync(temporaryPath, settingsPath);
17445
19092
  } finally {
17446
- rmSync3(temporaryPath, { force: true });
19093
+ rmSync5(temporaryPath, { force: true });
17447
19094
  }
17448
19095
  return true;
17449
19096
  }
@@ -17454,16 +19101,16 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
17454
19101
  let defaultPluginsFolder;
17455
19102
  let studioPluginsDirectory;
17456
19103
  if (process.platform === "win32") {
17457
- const localAppData = process.env.LOCALAPPDATA || join6(homedir4(), "AppData", "Local");
17458
- defaultPluginsFolder = join6(localAppData, "Roblox", "Plugins");
17459
- settingsPath = join6(localAppData, "Roblox", "GlobalSettings_13.xml");
19104
+ const localAppData = process.env.LOCALAPPDATA || join9(homedir4(), "AppData", "Local");
19105
+ defaultPluginsFolder = join9(localAppData, "Roblox", "Plugins");
19106
+ settingsPath = join9(localAppData, "Roblox", "GlobalSettings_13.xml");
17460
19107
  studioPluginsDirectory = defaultPluginsFolder.replaceAll("\\", "/");
17461
19108
  } else if (isWSL()) {
17462
19109
  const localAppData = getWindowsUserLocalAppData();
17463
19110
  if (!localAppData)
17464
19111
  return;
17465
- defaultPluginsFolder = join6(localAppData.linuxPath, "Roblox", "Plugins");
17466
- settingsPath = join6(localAppData.linuxPath, "Roblox", "GlobalSettings_13.xml");
19112
+ defaultPluginsFolder = join9(localAppData.linuxPath, "Roblox", "Plugins");
19113
+ settingsPath = join9(localAppData.linuxPath, "Roblox", "GlobalSettings_13.xml");
17467
19114
  studioPluginsDirectory = [
17468
19115
  localAppData.windowsPath.replace(/[\\/]+$/u, ""),
17469
19116
  "Roblox",
@@ -17479,12 +19126,12 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
17479
19126
  }
17480
19127
  }
17481
19128
  function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
17482
- const otherDest = join6(pluginsFolder, otherAssetName);
19129
+ const otherDest = join9(pluginsFolder, otherAssetName);
17483
19130
  if (!existsSync7(otherDest))
17484
19131
  return;
17485
19132
  if (replace) {
17486
19133
  try {
17487
- unlinkSync(otherDest);
19134
+ unlinkSync3(otherDest);
17488
19135
  log(`Removed conflicting ${otherAssetName}.`);
17489
19136
  } catch (err2) {
17490
19137
  warn(`[install-plugin] Could not remove ${otherDest}: ${err2}. Continuing.`);
@@ -17498,18 +19145,18 @@ Delete ${otherAssetName} manually or use the default CLI installer behavior to r
17498
19145
  `);
17499
19146
  }
17500
19147
  var PLUGIN_INSTALL_LOCK_NAME = ".robloxstudio-mcp-plugin-install.lock";
17501
- function errorCode(error) {
19148
+ function errorCode2(error) {
17502
19149
  return error !== null && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : void 0;
17503
19150
  }
17504
19151
  function pluginInstallLockError(lockPath, reason) {
17505
19152
  return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
17506
19153
  }
17507
19154
  function readPluginInstallLockOwner(lockPath) {
17508
- const ownerPath = join6(lockPath, "owner.json");
17509
- if (!lstatSync(lockPath).isDirectory() || !lstatSync(ownerPath).isFile()) {
19155
+ const ownerPath = join9(lockPath, "owner.json");
19156
+ if (!lstatSync2(lockPath).isDirectory() || !lstatSync2(ownerPath).isFile()) {
17510
19157
  throw new Error("Lock and owner metadata must be a directory and regular file");
17511
19158
  }
17512
- const owner = JSON.parse(readFileSync7(ownerPath, "utf8"));
19159
+ const owner = JSON.parse(readFileSync9(ownerPath, "utf8"));
17513
19160
  if (owner === null || typeof owner !== "object" || !("pid" in owner) || typeof owner.pid !== "number" || !Number.isSafeInteger(owner.pid) || owner.pid <= 0 || owner.pid > 2147483647 || !("token" in owner) || typeof owner.token !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(owner.token) || !("createdAt" in owner) || typeof owner.createdAt !== "number" || !Number.isFinite(owner.createdAt) || owner.createdAt < 0) {
17514
19161
  throw new Error("Invalid lock owner metadata");
17515
19162
  }
@@ -17520,9 +19167,9 @@ function assertPluginInstallOwnerExited(lockPath, owner) {
17520
19167
  try {
17521
19168
  process.kill(owner.pid, 0);
17522
19169
  } catch (error) {
17523
- if (errorCode(error) === "ESRCH")
19170
+ if (errorCode2(error) === "ESRCH")
17524
19171
  return;
17525
- throw pluginInstallLockError(lockPath, `Cannot confirm that plugin installation owner PID ${owner.pid} has exited (${errorCode(error) ?? error})`);
19172
+ throw pluginInstallLockError(lockPath, `Cannot confirm that plugin installation owner PID ${owner.pid} has exited (${errorCode2(error) ?? error})`);
17526
19173
  }
17527
19174
  }
17528
19175
  throw pluginInstallLockError(lockPath, `Another Studio plugin installation is already in progress (owner PID ${owner.pid})`);
@@ -17535,13 +19182,13 @@ function recoverPluginInstallLock(lockPath, warn) {
17535
19182
  throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
17536
19183
  }
17537
19184
  assertPluginInstallOwnerExited(lockPath, owner);
17538
- const claimPath = join6(lockPath, `recovery-${owner.token}`);
19185
+ const claimPath = join9(lockPath, `recovery-${owner.token}`);
17539
19186
  try {
17540
- mkdirSync4(claimPath);
19187
+ mkdirSync5(claimPath);
17541
19188
  } catch (error) {
17542
- throw pluginInstallLockError(lockPath, errorCode(error) === "EEXIST" ? "Plugin installation lock recovery is already in progress or was interrupted" : `Could not claim plugin installation lock recovery (${error})`);
19189
+ throw pluginInstallLockError(lockPath, errorCode2(error) === "EEXIST" ? "Plugin installation lock recovery is already in progress or was interrupted" : `Could not claim plugin installation lock recovery (${error})`);
17543
19190
  }
17544
- const abandonedPath = `${lockPath}.${randomUUID5()}.abandoned`;
19191
+ const abandonedPath = `${lockPath}.${randomUUID6()}.abandoned`;
17545
19192
  let moved = false;
17546
19193
  try {
17547
19194
  let currentOwner;
@@ -17561,49 +19208,49 @@ function recoverPluginInstallLock(lockPath, warn) {
17561
19208
  try {
17562
19209
  rmdirSync(claimPath);
17563
19210
  } catch (error) {
17564
- if (errorCode(error) !== "ENOENT") {
19211
+ if (errorCode2(error) !== "ENOENT") {
17565
19212
  warn(`[install-plugin] Could not release lock recovery claim ${claimPath}: ${error}`);
17566
19213
  }
17567
19214
  }
17568
19215
  }
17569
19216
  }
17570
19217
  try {
17571
- rmSync3(abandonedPath, { recursive: true, force: true });
19218
+ rmSync5(abandonedPath, { recursive: true, force: true });
17572
19219
  } catch (error) {
17573
19220
  warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
17574
19221
  }
17575
19222
  }
17576
19223
  function acquirePluginInstallLock(pluginsFolder, warn) {
17577
- const lockPath = join6(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
17578
- const ownerPath = join6(lockPath, "owner.json");
19224
+ const lockPath = join9(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
19225
+ const ownerPath = join9(lockPath, "owner.json");
17579
19226
  const owner = {
17580
19227
  pid: process.pid,
17581
- token: randomUUID5(),
19228
+ token: randomUUID6(),
17582
19229
  createdAt: Date.now()
17583
19230
  };
17584
19231
  try {
17585
- mkdirSync4(lockPath);
19232
+ mkdirSync5(lockPath);
17586
19233
  } catch (error) {
17587
- if (errorCode(error) !== "EEXIST")
19234
+ if (errorCode2(error) !== "EEXIST")
17588
19235
  throw error;
17589
19236
  recoverPluginInstallLock(lockPath, warn);
17590
19237
  try {
17591
- mkdirSync4(lockPath);
19238
+ mkdirSync5(lockPath);
17592
19239
  } catch (retryError) {
17593
- if (errorCode(retryError) !== "EEXIST")
19240
+ if (errorCode2(retryError) !== "EEXIST")
17594
19241
  throw retryError;
17595
19242
  throw pluginInstallLockError(lockPath, "Another Studio plugin installation is already in progress");
17596
19243
  }
17597
19244
  }
17598
19245
  try {
17599
- writeFileSync3(ownerPath, `${JSON.stringify(owner)}
19246
+ writeFileSync5(ownerPath, `${JSON.stringify(owner)}
17600
19247
  `, {
17601
19248
  encoding: "utf8",
17602
19249
  flag: "wx"
17603
19250
  });
17604
19251
  } catch (error) {
17605
19252
  try {
17606
- rmSync3(lockPath, { recursive: true, force: true });
19253
+ rmSync5(lockPath, { recursive: true, force: true });
17607
19254
  } catch (cleanupError) {
17608
19255
  warn(`[install-plugin] Could not clean failed install lock ${lockPath}: ${cleanupError}`);
17609
19256
  }
@@ -17613,10 +19260,10 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
17613
19260
  try {
17614
19261
  const currentOwner = readPluginInstallLockOwner(lockPath);
17615
19262
  if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
17616
- rmSync3(lockPath, { recursive: true, force: true });
19263
+ rmSync5(lockPath, { recursive: true, force: true });
17617
19264
  }
17618
19265
  } catch (error) {
17619
- if (errorCode(error) !== "ENOENT") {
19266
+ if (errorCode2(error) !== "ENOENT") {
17620
19267
  warn(`[install-plugin] Could not release install lock ${lockPath}: ${error}`);
17621
19268
  }
17622
19269
  }
@@ -17707,9 +19354,9 @@ function assertPluginAssetIdentity(source, assetName, expectedVersion, expectedV
17707
19354
  function installedFileMatches(source, destination) {
17708
19355
  if (!existsSync7(destination))
17709
19356
  return false;
17710
- if (!lstatSync(destination).isFile())
19357
+ if (!lstatSync2(destination).isFile())
17711
19358
  return false;
17712
- const installed = readFileSync7(destination);
19359
+ const installed = readFileSync9(destination);
17713
19360
  return installed.length === source.length && installed.equals(source);
17714
19361
  }
17715
19362
  function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source, expectedVersion, expectedVariant, rawPort, replaceVariant = true, log = console.log, warn = console.warn }) {
@@ -17720,22 +19367,22 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
17720
19367
  }
17721
19368
  const configured = configurePluginAssetForPort(source, rawPort);
17722
19369
  assertPluginAssetIdentity(configured, assetName, expectedVersion, expectedVariant);
17723
- mkdirSync4(pluginsFolder, { recursive: true });
17724
- const destination = join6(pluginsFolder, assetName);
19370
+ mkdirSync5(pluginsFolder, { recursive: true });
19371
+ const destination = join9(pluginsFolder, assetName);
17725
19372
  const releaseLock = acquirePluginInstallLock(pluginsFolder, warn);
17726
19373
  try {
17727
19374
  let installed = false;
17728
19375
  if (!installedFileMatches(configured, destination)) {
17729
- const staged = join6(pluginsFolder, `.${assetName}.${process.pid}.${randomUUID5()}.tmp`);
19376
+ const staged = join9(pluginsFolder, `.${assetName}.${process.pid}.${randomUUID6()}.tmp`);
17730
19377
  try {
17731
- writeFileSync3(staged, configured, { flag: "wx" });
19378
+ writeFileSync5(staged, configured, { flag: "wx" });
17732
19379
  renameSync(staged, destination);
17733
19380
  installed = true;
17734
19381
  } finally {
17735
19382
  try {
17736
- unlinkSync(staged);
19383
+ unlinkSync3(staged);
17737
19384
  } catch (error) {
17738
- if (errorCode(error) !== "ENOENT") {
19385
+ if (errorCode2(error) !== "ENOENT") {
17739
19386
  warn(`[install-plugin] Could not remove staging file ${staged}: ${error}`);
17740
19387
  }
17741
19388
  }
@@ -17755,175 +19402,156 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
17755
19402
  }
17756
19403
  }
17757
19404
 
17758
- // src/index.ts
17759
- import { createRequire } from "module";
17760
-
17761
- // src/install-plugin.ts
17762
- import { existsSync as existsSync8, readFileSync as readFileSync8 } from "fs";
17763
- import { dirname as dirname5, join as join7 } from "path";
19405
+ // ../core/dist/plugin-installer.js
19406
+ import { existsSync as existsSync8, readFileSync as readFileSync10 } from "fs";
19407
+ import { dirname as dirname5, join as join10 } from "path";
17764
19408
  import { fileURLToPath } from "url";
17765
19409
  import { get } from "https";
17766
19410
  var REPO = "chrrxs/robloxstudio-mcp";
17767
- var ASSET_NAME = "MCPPlugin.rbxmx";
17768
- var OTHER_VARIANT = "MCPInspectorPlugin.rbxmx";
17769
19411
  var TIMEOUT_MS = 3e4;
17770
19412
  var MAX_REDIRECTS = 5;
17771
19413
  var MAX_ASSET_BYTES = 64 * 1024 * 1024;
17772
- function httpsGet(url) {
17773
- return new Promise((resolve5, reject) => {
17774
- const req = get(url, { headers: { "User-Agent": "robloxstudio-mcp" } }, resolve5);
17775
- req.on("error", reject);
17776
- req.setTimeout(TIMEOUT_MS, () => {
17777
- req.destroy(new Error(`Request timed out after ${TIMEOUT_MS}ms`));
19414
+ function createPluginInstaller(moduleUrl, variant) {
19415
+ const currentDir = dirname5(fileURLToPath(moduleUrl));
19416
+ const ASSET_NAME = variant === "main" ? "MCPPlugin.rbxmx" : "MCPInspectorPlugin.rbxmx";
19417
+ const OTHER_VARIANT = variant === "main" ? "MCPInspectorPlugin.rbxmx" : "MCPPlugin.rbxmx";
19418
+ const userAgent = variant === "main" ? "robloxstudio-mcp" : "robloxstudio-mcp-inspector";
19419
+ const buildCommand = variant === "main" ? "build:plugin" : "build:plugin:inspector";
19420
+ function httpsGet(url) {
19421
+ return new Promise((resolve6, reject) => {
19422
+ const req = get(url, { headers: { "User-Agent": userAgent } }, resolve6);
19423
+ req.on("error", reject);
19424
+ req.setTimeout(TIMEOUT_MS, () => {
19425
+ req.destroy(new Error(`Request timed out after ${TIMEOUT_MS}ms`));
19426
+ });
17778
19427
  });
17779
- });
17780
- }
17781
- async function download(url, redirects = 0) {
17782
- const res = await httpsGet(url);
17783
- if (res.statusCode === 301 || res.statusCode === 302) {
17784
- if (redirects >= MAX_REDIRECTS) throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
17785
- const location = res.headers.location;
17786
- if (!location) throw new Error("Redirect with no location header");
17787
- for await (const _chunk of res) {
17788
- }
17789
- return download(location, redirects + 1);
17790
19428
  }
17791
- if (res.statusCode !== 200) {
17792
- throw new Error(`Download failed: HTTP ${res.statusCode}`);
17793
- }
17794
- const chunks = [];
17795
- let totalBytes = 0;
17796
- for await (const chunk of res) {
17797
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
17798
- totalBytes += bytes.length;
17799
- if (totalBytes > MAX_ASSET_BYTES) {
17800
- res.destroy();
17801
- throw new Error(
17802
- `${ASSET_NAME} download exceeds the ${MAX_ASSET_BYTES}-byte limit.`
17803
- );
19429
+ async function download(url, redirects = 0) {
19430
+ const res = await httpsGet(url);
19431
+ if (res.statusCode === 301 || res.statusCode === 302) {
19432
+ if (redirects >= MAX_REDIRECTS)
19433
+ throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
19434
+ const location = res.headers.location;
19435
+ if (!location)
19436
+ throw new Error("Redirect with no location header");
19437
+ for await (const chunk of res) {
19438
+ void chunk;
19439
+ }
19440
+ return download(location, redirects + 1);
17804
19441
  }
17805
- chunks.push(bytes);
17806
- }
17807
- return Buffer.concat(chunks, totalBytes);
17808
- }
17809
- async function fetchJson(url) {
17810
- const res = await httpsGet(url);
17811
- if (res.statusCode !== 200) {
17812
- throw new Error(`GitHub API returned HTTP ${res.statusCode}`);
19442
+ if (res.statusCode !== 200) {
19443
+ throw new Error(`Download failed: HTTP ${res.statusCode}`);
19444
+ }
19445
+ const chunks = [];
19446
+ let totalBytes = 0;
19447
+ for await (const chunk of res) {
19448
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
19449
+ totalBytes += bytes.length;
19450
+ if (totalBytes > MAX_ASSET_BYTES) {
19451
+ res.destroy();
19452
+ throw new Error(`${ASSET_NAME} download exceeds the ${MAX_ASSET_BYTES}-byte limit.`);
19453
+ }
19454
+ chunks.push(bytes);
19455
+ }
19456
+ return Buffer.concat(chunks, totalBytes);
17813
19457
  }
17814
- const chunks = [];
17815
- for await (const chunk of res) {
17816
- chunks.push(chunk);
19458
+ async function fetchJson(url) {
19459
+ const res = await httpsGet(url);
19460
+ if (res.statusCode !== 200) {
19461
+ throw new Error(`GitHub API returned HTTP ${res.statusCode}`);
19462
+ }
19463
+ const chunks = [];
19464
+ for await (const chunk of res) {
19465
+ chunks.push(chunk);
19466
+ }
19467
+ return JSON.parse(Buffer.concat(chunks).toString());
17817
19468
  }
17818
- return JSON.parse(Buffer.concat(chunks).toString());
17819
- }
17820
- async function findDevRelease() {
17821
- const releases = await fetchJson(`https://api.github.com/repos/${REPO}/releases?per_page=20`);
17822
- const prerelease = releases.find(
17823
- (r) => r.prerelease && r.assets.some((a) => a.name === ASSET_NAME)
17824
- );
17825
- if (!prerelease) {
17826
- throw new Error(`No prerelease found with ${ASSET_NAME}`);
19469
+ function bundledAssetPath() {
19470
+ const candidates = [
19471
+ join10(currentDir, "..", "studio-plugin", ASSET_NAME),
19472
+ join10(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
19473
+ ];
19474
+ return candidates.find((candidate) => existsSync8(candidate)) ?? null;
17827
19475
  }
17828
- return prerelease;
17829
- }
17830
- function bundledAssetPath() {
17831
- const currentDir = dirname5(fileURLToPath(import.meta.url));
17832
- const candidates = [
17833
- join7(currentDir, "..", "studio-plugin", ASSET_NAME),
17834
- join7(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
17835
- ];
17836
- return candidates.find((candidate) => existsSync8(candidate)) ?? null;
17837
- }
17838
- function resolvePluginAssetPath(sourcePath) {
17839
- if (sourcePath === void 0) return bundledAssetPath();
17840
- return existsSync8(sourcePath) ? sourcePath : null;
17841
- }
17842
- function packageVersion() {
17843
- const currentDir = dirname5(fileURLToPath(import.meta.url));
17844
- const pkg = JSON.parse(readFileSync8(join7(currentDir, "..", "package.json"), "utf8"));
17845
- if (!pkg.version) {
17846
- throw new Error("Package version not found");
19476
+ function resolvePluginAssetPath(sourcePath) {
19477
+ if (sourcePath === void 0)
19478
+ return bundledAssetPath();
19479
+ return existsSync8(sourcePath) ? sourcePath : null;
17847
19480
  }
17848
- return pkg.version;
17849
- }
17850
- async function installBundledPlugin(options = {}) {
17851
- const log = options.log ?? console.log;
17852
- const warn = options.warn ?? console.warn;
17853
- const replaceVariant = options.replaceVariant ?? true;
17854
- const sourcePath = resolvePluginAssetPath(options.sourcePath);
17855
- if (!sourcePath) {
17856
- throw new Error(
17857
- `Bundled ${ASSET_NAME} not found. Run npm run build:plugin in this worktree first.`
17858
- );
17859
- }
17860
- const result = await installPluginAsset({
17861
- pluginsFolder: getPluginsFolder(),
17862
- assetName: ASSET_NAME,
17863
- otherAssetName: OTHER_VARIANT,
17864
- source: readFileSync8(sourcePath),
17865
- expectedVersion: packageVersion(),
17866
- expectedVariant: "main",
17867
- replaceVariant,
17868
- log,
17869
- warn
17870
- });
17871
- if (result.installed) {
17872
- log(`Installed ${ASSET_NAME} to ${result.destination}`);
19481
+ function packageVersion() {
19482
+ const pkg = JSON.parse(readFileSync10(join10(currentDir, "..", "package.json"), "utf8"));
19483
+ if (!pkg.version) {
19484
+ throw new Error("Package version not found");
19485
+ }
19486
+ return pkg.version;
17873
19487
  }
17874
- }
17875
- async function installPlugin(options = {}) {
17876
- const dev = options.dev ?? process.argv.includes("--dev");
17877
- const replaceVariant = options.replaceVariant ?? true;
17878
- const log = options.log ?? console.log;
17879
- const warn = options.warn ?? console.warn;
17880
- const bundled = resolvePluginAssetPath(options.sourcePath);
17881
- if (options.sourcePath !== void 0 && !bundled) {
17882
- throw new Error(`Plugin asset not found at explicit path ${options.sourcePath}.`);
17883
- }
17884
- if (bundled) {
17885
- const result2 = await installPluginAsset({
19488
+ function installSource(source, options) {
19489
+ return installPluginAsset({
17886
19490
  pluginsFolder: getPluginsFolder(),
17887
19491
  assetName: ASSET_NAME,
17888
19492
  otherAssetName: OTHER_VARIANT,
17889
- source: readFileSync8(bundled),
19493
+ source,
17890
19494
  expectedVersion: packageVersion(),
17891
- expectedVariant: "main",
17892
- replaceVariant,
17893
- log,
17894
- warn
19495
+ expectedVariant: variant,
19496
+ replaceVariant: options.replaceVariant ?? true,
19497
+ log: options.log ?? console.log,
19498
+ warn: options.warn ?? console.warn
17895
19499
  });
17896
- if (result2.installed) {
17897
- log(`Installed bundled ${ASSET_NAME} to ${result2.destination}`);
19500
+ }
19501
+ async function installBundledPlugin2(options = {}) {
19502
+ const log = options.log ?? console.log;
19503
+ const sourcePath = resolvePluginAssetPath(options.sourcePath);
19504
+ if (!sourcePath) {
19505
+ throw new Error(`Bundled ${ASSET_NAME} not found. Run npm run ${buildCommand} in this worktree first.`);
19506
+ }
19507
+ const result = installSource(readFileSync10(sourcePath), options);
19508
+ if (result.installed) {
19509
+ log(`Installed ${ASSET_NAME} to ${result.destination}`);
19510
+ }
19511
+ }
19512
+ async function installPlugin2(options = {}, dev = false) {
19513
+ const log = options.log ?? console.log;
19514
+ const bundled = resolvePluginAssetPath(options.sourcePath);
19515
+ if (options.sourcePath !== void 0 && !bundled) {
19516
+ throw new Error(`Plugin asset not found at explicit path ${options.sourcePath}.`);
19517
+ }
19518
+ if (bundled) {
19519
+ const result2 = installSource(readFileSync10(bundled), options);
19520
+ if (result2.installed) {
19521
+ log(`Installed bundled ${ASSET_NAME} to ${result2.destination}`);
19522
+ } else {
19523
+ log(`${ASSET_NAME} already installed.`);
19524
+ }
19525
+ return;
19526
+ }
19527
+ log(dev ? "Fetching matching dev release..." : "Fetching matching release...");
19528
+ const release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/tags/v${encodeURIComponent(packageVersion())}`);
19529
+ const asset = release.assets?.find((a) => a.name === ASSET_NAME);
19530
+ if (!asset) {
19531
+ throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
19532
+ }
19533
+ log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
19534
+ const downloaded = await download(asset.browser_download_url);
19535
+ const result = installSource(downloaded, options);
19536
+ if (result.installed) {
19537
+ log(`Installed to ${result.destination}`);
17898
19538
  } else {
17899
19539
  log(`${ASSET_NAME} already installed.`);
17900
19540
  }
17901
- return;
17902
- }
17903
- log(dev ? "Fetching latest dev prerelease..." : "Fetching latest release...");
17904
- const release = dev ? await findDevRelease() : await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
17905
- const asset = release.assets?.find((a) => a.name === ASSET_NAME);
17906
- if (!asset) {
17907
- throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
17908
- }
17909
- log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
17910
- const downloaded = await download(asset.browser_download_url);
17911
- const result = await installPluginAsset({
17912
- pluginsFolder: getPluginsFolder(),
17913
- assetName: ASSET_NAME,
17914
- otherAssetName: OTHER_VARIANT,
17915
- source: downloaded,
17916
- expectedVersion: release.tag_name.replace(/^v/, ""),
17917
- expectedVariant: "main",
17918
- replaceVariant,
17919
- log,
17920
- warn
17921
- });
17922
- if (result.installed) {
17923
- log(`Installed to ${result.destination}`);
17924
- } else {
17925
- log(`${ASSET_NAME} already installed.`);
17926
19541
  }
19542
+ return { installBundledPlugin: installBundledPlugin2, installPlugin: installPlugin2 };
19543
+ }
19544
+
19545
+ // src/index.ts
19546
+ import { createRequire } from "module";
19547
+
19548
+ // src/install-plugin.ts
19549
+ var installer = createPluginInstaller(import.meta.url, "main");
19550
+ async function installBundledPlugin(options = {}) {
19551
+ await installer.installBundledPlugin(options);
19552
+ }
19553
+ async function installPlugin(options = {}) {
19554
+ await installer.installPlugin(options, options.dev ?? process.argv.includes("--dev"));
17927
19555
  }
17928
19556
 
17929
19557
  // src/index.ts