@chrrxs/robloxstudio-mcp 3.1.5 → 3.1.6

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 = [];
@@ -3414,14 +3418,100 @@ import { execFile, execFileSync as execFileSync2, spawn } from "child_process";
3414
3418
  import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync2, realpathSync, rmSync, statSync } from "fs";
3415
3419
  import { randomUUID as randomUUID3 } from "crypto";
3416
3420
  import * as os2 from "os";
3417
- import * as path2 from "path";
3421
+ import * as path3 from "path";
3418
3422
  import { promisify } from "util";
3419
3423
 
3424
+ // ../core/dist/studio-sign-in.js
3425
+ import { open, readdir } from "fs/promises";
3426
+ import * as path from "path";
3427
+ import { setTimeout as delay } from "timers/promises";
3428
+ 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;
3429
+ var LINE_TIME = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z),/u;
3430
+ var SIGN_IN_START = /\[FLog::StudioKeyEvents\] login \([^)]*\) \[start\]/u;
3431
+ var SIGN_IN_END = /\[FLog::StudioKeyEvents\] login \[end\]\[(success|failure)\]/u;
3432
+ var HEAD_BYTES = 1024 * 1024;
3433
+ var FILETIME_UNIX_EPOCH = 116444736000000000n;
3434
+ function fileTimeToUnixMs(fileTime) {
3435
+ return Number((BigInt(fileTime) - FILETIME_UNIX_EPOCH) / 10000n);
3436
+ }
3437
+ function studioSessionLogStartMs(name) {
3438
+ const match = SESSION_LOG.exec(name);
3439
+ if (!match)
3440
+ return void 0;
3441
+ const [, year, month, day, hour, minute, second] = match.map(Number);
3442
+ return Date.UTC(year, month - 1, day, hour, minute, second);
3443
+ }
3444
+ function parseStudioSignIn(name, text) {
3445
+ const session = { name, signingIn: false };
3446
+ for (const line of text.split(/\r?\n/u)) {
3447
+ if (SIGN_IN_START.test(line)) {
3448
+ session.signingIn = true;
3449
+ continue;
3450
+ }
3451
+ const end = SIGN_IN_END.exec(line);
3452
+ if (!end)
3453
+ continue;
3454
+ session.signingIn = false;
3455
+ session.outcome = end[1];
3456
+ const time = LINE_TIME.exec(line)?.[1];
3457
+ if (time !== void 0)
3458
+ session.lastEndMs = Date.parse(time);
3459
+ }
3460
+ return session;
3461
+ }
3462
+ async function readStudioSignInSessions(logsRoot, fromMs, toMs) {
3463
+ let names;
3464
+ try {
3465
+ names = await readdir(logsRoot);
3466
+ } catch (error) {
3467
+ if (error.code === "ENOENT")
3468
+ return [];
3469
+ throw error;
3470
+ }
3471
+ const sessions = [];
3472
+ for (const name of names) {
3473
+ const startedAt = studioSessionLogStartMs(name);
3474
+ if (startedAt === void 0 || startedAt < fromMs || startedAt > toMs)
3475
+ continue;
3476
+ const file = await open(path.join(logsRoot, name), "r");
3477
+ try {
3478
+ const buffer = Buffer.alloc(HEAD_BYTES);
3479
+ const { bytesRead } = await file.read(buffer, 0, HEAD_BYTES, 0);
3480
+ sessions.push(parseStudioSignIn(name, buffer.subarray(0, bytesRead).toString("utf8")));
3481
+ } finally {
3482
+ await file.close();
3483
+ }
3484
+ }
3485
+ return sessions;
3486
+ }
3487
+ async function waitForStudioSignInToSettle({ logsRoot, processStartedAtMs, timeoutMs = 15e3, settleMs = 1500, youngMs = 5e3, now = Date.now, sleep: sleep2 = delay }) {
3488
+ const begin = now();
3489
+ for (; ; ) {
3490
+ const sessions = await readStudioSignInSessions(logsRoot, processStartedAtMs - 2e3, processStartedAtMs + 3e4);
3491
+ const signingIn = sessions.some((session) => session.signingIn);
3492
+ const concluded = sessions.some((session) => session.outcome !== void 0);
3493
+ const undecided = !signingIn && !concluded && now() - processStartedAtMs < youngMs;
3494
+ if (!signingIn && !undecided) {
3495
+ const ends = sessions.flatMap((session) => session.lastEndMs === void 0 ? [] : [session.lastEndMs]);
3496
+ if (!ends.length)
3497
+ return "idle";
3498
+ const sinceEnd = now() - Math.max(...ends);
3499
+ const remaining = Math.min(settleMs, Math.max(0, settleMs - sinceEnd));
3500
+ if (remaining > 0)
3501
+ await sleep2(remaining);
3502
+ return "settled";
3503
+ }
3504
+ if (now() - begin >= timeoutMs)
3505
+ return "timeout";
3506
+ await sleep2(250);
3507
+ }
3508
+ }
3509
+
3420
3510
  // ../core/dist/managed-instance-registry.js
3421
3511
  import * as fs from "fs/promises";
3422
3512
  import { randomUUID as randomUUID2 } from "crypto";
3423
3513
  import * as os from "os";
3424
- import * as path from "path";
3514
+ import * as path2 from "path";
3425
3515
  import { setTimeout as delayTimer } from "timers/promises";
3426
3516
  var REGISTRY_VERSION = 1;
3427
3517
  var LOCK_STALE_MS = 1e4;
@@ -3438,14 +3528,14 @@ function defaultManagedInstanceRegistryDir() {
3438
3528
  return process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR;
3439
3529
  }
3440
3530
  if (process.platform === "win32" && process.env.LOCALAPPDATA) {
3441
- return path.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", "managed-instances", "v1");
3531
+ return path2.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", "managed-instances", "v1");
3442
3532
  }
3443
3533
  if (process.platform === "darwin") {
3444
- return path.join(os.homedir(), "Library", "Application Support", "robloxstudio-mcp", "managed-instances", "v1");
3534
+ return path2.join(os.homedir(), "Library", "Application Support", "robloxstudio-mcp", "managed-instances", "v1");
3445
3535
  }
3446
- return path.join(os.homedir(), ".local", "state", "robloxstudio-mcp", "managed-instances", "v1");
3536
+ return path2.join(os.homedir(), ".local", "state", "robloxstudio-mcp", "managed-instances", "v1");
3447
3537
  }
3448
- function delay(ms) {
3538
+ function delay2(ms) {
3449
3539
  return delayTimer(ms, void 0, { ref: false });
3450
3540
  }
3451
3541
  function ymd(timestamp) {
@@ -3540,7 +3630,7 @@ var ManagedInstanceRegistry = class {
3540
3630
  }
3541
3631
  async withLock(fn) {
3542
3632
  await this.ensureDir();
3543
- const lockDir = path.join(this.dir, ".lock");
3633
+ const lockDir = path2.join(this.dir, ".lock");
3544
3634
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
3545
3635
  const owner = {
3546
3636
  pid: process.pid,
@@ -3553,7 +3643,7 @@ var ManagedInstanceRegistry = class {
3553
3643
  activeLockTokens.add(owner.token);
3554
3644
  activeLockPaths.add(lockDir);
3555
3645
  try {
3556
- await fs.writeFile(path.join(lockDir, "owner.json"), `${JSON.stringify(owner)}
3646
+ await fs.writeFile(path2.join(lockDir, "owner.json"), `${JSON.stringify(owner)}
3557
3647
  `, {
3558
3648
  encoding: "utf8",
3559
3649
  flag: "wx"
@@ -3583,7 +3673,7 @@ var ManagedInstanceRegistry = class {
3583
3673
  if (Date.now() > deadline) {
3584
3674
  throw new Error(`Timed out waiting for managed instance registry lock: ${lockDir}`);
3585
3675
  }
3586
- await delay(LOCK_RETRY_MS);
3676
+ await delay2(LOCK_RETRY_MS);
3587
3677
  }
3588
3678
  }
3589
3679
  try {
@@ -3604,7 +3694,7 @@ var ManagedInstanceRegistry = class {
3604
3694
  }
3605
3695
  async readLockOwner(lockPath, isDirectory) {
3606
3696
  try {
3607
- const contents = await fs.readFile(isDirectory ? path.join(lockPath, "owner.json") : lockPath, "utf8");
3697
+ const contents = await fs.readFile(isDirectory ? path2.join(lockPath, "owner.json") : lockPath, "utf8");
3608
3698
  const parsed = JSON.parse(contents);
3609
3699
  return isLockOwner(parsed) ? parsed : void 0;
3610
3700
  } catch {
@@ -3615,10 +3705,10 @@ var ManagedInstanceRegistry = class {
3615
3705
  await fs.mkdir(this.dir, { recursive: true });
3616
3706
  }
3617
3707
  recordPath(recordId) {
3618
- return path.join(this.dir, `${recordId}.json`);
3708
+ return path2.join(this.dir, `${recordId}.json`);
3619
3709
  }
3620
3710
  async recordFilesUnlocked() {
3621
- return (await fs.readdir(this.dir)).filter((name) => name.endsWith(".json")).map((name) => path.join(this.dir, name));
3711
+ return (await fs.readdir(this.dir)).filter((name) => name.endsWith(".json")).map((name) => path2.join(this.dir, name));
3622
3712
  }
3623
3713
  async readRecordUnlocked(recordId) {
3624
3714
  try {
@@ -3647,7 +3737,7 @@ var ManagedInstanceRegistry = class {
3647
3737
  async writeRecordUnlocked(record) {
3648
3738
  await this.ensureDir();
3649
3739
  const finalPath = this.recordPath(record.recordId);
3650
- const tmpPath = path.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
3740
+ const tmpPath = path2.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
3651
3741
  const fd = await fs.open(tmpPath, "w");
3652
3742
  try {
3653
3743
  await fd.writeFile(`${JSON.stringify(record, null, 2)}
@@ -3662,7 +3752,7 @@ var ManagedInstanceRegistry = class {
3662
3752
  await fs.rm(this.recordPath(recordId), { force: true });
3663
3753
  }
3664
3754
  async appendEventUnlocked(event, now) {
3665
- const file = path.join(this.dir, `events-${ymd(now)}.jsonl`);
3755
+ const file = path2.join(this.dir, `events-${ymd(now)}.jsonl`);
3666
3756
  await fs.appendFile(file, `${JSON.stringify({
3667
3757
  ts: new Date(now).toISOString(),
3668
3758
  ...event
@@ -3676,7 +3766,7 @@ var ManagedInstanceRegistry = class {
3676
3766
  if (!date || date >= cutoff)
3677
3767
  continue;
3678
3768
  try {
3679
- await fs.rm(path.join(this.dir, name), { force: true });
3769
+ await fs.rm(path2.join(this.dir, name), { force: true });
3680
3770
  } catch {
3681
3771
  }
3682
3772
  }
@@ -3928,7 +4018,7 @@ function getStudioPlatformCapabilities() {
3928
4018
  }
3929
4019
 
3930
4020
  // ../core/dist/studio-instance-manager.js
3931
- var BASEPLATE_TEMP_DIR = path2.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
4021
+ var BASEPLATE_TEMP_DIR = path3.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
3932
4022
  var BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
3933
4023
  var BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
3934
4024
  var retainedLaunchControls = /* @__PURE__ */ new Map();
@@ -4054,7 +4144,7 @@ async function toWslPathAsync(windowsPath) {
4054
4144
  return runAsync("wslpath", ["-u", windowsPath]);
4055
4145
  }
4056
4146
  async function toStudioLaunchArgAsync(arg) {
4057
- if (!isWsl() || !path2.isAbsolute(arg) || !existsSync2(arg))
4147
+ if (!isWsl() || !path3.isAbsolute(arg) || !existsSync2(arg))
4058
4148
  return arg;
4059
4149
  return runAsync("wslpath", ["-w", arg]);
4060
4150
  }
@@ -4546,6 +4636,19 @@ function buildWindowsStudioStopScript(processId, startedAt) {
4546
4636
  "} finally { if ($null -ne $studio) { $studio.Dispose() } }"
4547
4637
  ].join("; ");
4548
4638
  }
4639
+ var studioLogsRoot;
4640
+ async function settleWindowsStudioSignIn(startedAt) {
4641
+ studioLogsRoot ??= (async () => {
4642
+ const localAppData = await windowsLocalAppDataAsync();
4643
+ return localAppData ? path3.join(await toWslPathAsync(localAppData), "Roblox", "logs") : void 0;
4644
+ })();
4645
+ const logsRoot = await studioLogsRoot;
4646
+ if (!logsRoot)
4647
+ return;
4648
+ const result = await waitForStudioSignInToSettle({ logsRoot, processStartedAtMs: fileTimeToUnixMs(startedAt) });
4649
+ if (result === "timeout")
4650
+ console.error("[studio] Studio was still signing in after 15 s; closing it anyway.");
4651
+ }
4549
4652
  async function stopWindowsStudio(processId, startedAt, timeoutMs = 15e3) {
4550
4653
  await powershellAsync(buildWindowsStudioStopScript(processId, startedAt), timeoutMs);
4551
4654
  }
@@ -4559,7 +4662,7 @@ async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDi
4559
4662
  stdio: ["pipe", "pipe", "pipe"],
4560
4663
  windowsHide: true
4561
4664
  });
4562
- return new Promise((resolve5, reject) => {
4665
+ return new Promise((resolve6, reject) => {
4563
4666
  let stdout = "";
4564
4667
  let controlOutput = "";
4565
4668
  let stderr = "";
@@ -4706,7 +4809,7 @@ async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDi
4706
4809
  throw new Error("PowerShell returned an invalid native Studio process identity.");
4707
4810
  }
4708
4811
  identity = { pid: nativePid, startedAt: nativeStartedAt };
4709
- resolve5({
4812
+ resolve6({
4710
4813
  pid: nativePid,
4711
4814
  nativePid,
4712
4815
  nativeStartedAt,
@@ -4774,21 +4877,21 @@ function resolveEntrypointDir() {
4774
4877
  if (!entrypoint)
4775
4878
  return void 0;
4776
4879
  try {
4777
- return path2.dirname(realpathSync(entrypoint));
4880
+ return path3.dirname(realpathSync(entrypoint));
4778
4881
  } catch {
4779
- return path2.dirname(path2.resolve(entrypoint));
4882
+ return path3.dirname(path3.resolve(entrypoint));
4780
4883
  }
4781
4884
  }
4782
4885
  function resolveBaseplateTemplatePath() {
4783
4886
  const entrypointDir = resolveEntrypointDir();
4784
4887
  const candidates = [
4785
4888
  ...entrypointDir ? [
4786
- path2.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
4787
- path2.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
4889
+ path3.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
4890
+ path3.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
4788
4891
  ] : [],
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)
4892
+ path3.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
4893
+ path3.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
4894
+ path3.join(process.cwd(), "assets", BASEPLATE_TEMPLATE_NAME)
4792
4895
  ];
4793
4896
  for (const candidate of candidates) {
4794
4897
  if (existsSync2(candidate))
@@ -4821,7 +4924,7 @@ function sweepStaleBaseplateFiles() {
4821
4924
  continue;
4822
4925
  if (Number(match[1]) !== process.pid && isProcessAlive2(Number(match[1])))
4823
4926
  continue;
4824
- const file = path2.join(BASEPLATE_TEMP_DIR, entry);
4927
+ const file = path3.join(BASEPLATE_TEMP_DIR, entry);
4825
4928
  try {
4826
4929
  if (statSync(file).mtimeMs < cutoff)
4827
4930
  rmSync(file, { force: true });
@@ -4832,13 +4935,13 @@ function sweepStaleBaseplateFiles() {
4832
4935
  function createBaseplatePlaceFile() {
4833
4936
  mkdirSync(BASEPLATE_TEMP_DIR, { recursive: true });
4834
4937
  sweepStaleBaseplateFiles();
4835
- const file = path2.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
4938
+ const file = path3.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
4836
4939
  copyFileSync(resolveBaseplateTemplatePath(), file);
4837
4940
  return file;
4838
4941
  }
4839
4942
  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));
4943
+ const resolvedFile = path3.resolve(file);
4944
+ return path3.dirname(resolvedFile) === path3.resolve(BASEPLATE_TEMP_DIR) && BASEPLATE_TEMP_NAME.test(path3.basename(resolvedFile));
4842
4945
  }
4843
4946
  function cleanupManagedBaseplateFiles(record) {
4844
4947
  if (record.source !== "baseplate" || !record.localPlaceFile)
@@ -4864,14 +4967,14 @@ function resolveStudioExeFromVersions(root) {
4864
4967
  if (!existsSync2(root)) {
4865
4968
  throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
4866
4969
  }
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);
4970
+ const candidates = readdirSync(root).filter((name) => name.startsWith("version-")).map((name) => path3.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
4868
4971
  if (candidates.length === 0) {
4869
4972
  throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
4870
4973
  }
4871
4974
  const exe = candidates[0];
4872
- const installationDir = path2.dirname(exe);
4975
+ const installationDir = path3.dirname(exe);
4873
4976
  const hasIncompleteDownload = readdirSync(installationDir).some((name) => /\.crdownload$/i.test(name));
4874
- const settings = statSync(path2.join(installationDir, "AppSettings.xml"), { throwIfNoEntry: false });
4977
+ const settings = statSync(path3.join(installationDir, "AppSettings.xml"), { throwIfNoEntry: false });
4875
4978
  if (hasIncompleteDownload || !settings?.isFile() || settings.size === 0) {
4876
4979
  const reason = hasIncompleteDownload ? "an unfinished .crdownload download is present" : "AppSettings.xml is missing, empty, or not a regular file";
4877
4980
  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.`);
@@ -4888,7 +4991,7 @@ function resolveStudioExe() {
4888
4991
  throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
4889
4992
  }
4890
4993
  const localAppData = windowsLocalAppData();
4891
- const root = localAppData ? path2.join(toWslPath(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4994
+ const root = localAppData ? path3.join(toWslPath(localAppData), "Roblox", "Versions") : path3.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4892
4995
  return resolveStudioExeFromVersions(root);
4893
4996
  }
4894
4997
  async function resolveStudioExeAsync() {
@@ -4901,7 +5004,7 @@ async function resolveStudioExeAsync() {
4901
5004
  throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
4902
5005
  }
4903
5006
  const localAppData = await windowsLocalAppDataAsync();
4904
- const root = localAppData ? path2.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
5007
+ const root = localAppData ? path3.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path3.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4905
5008
  return resolveStudioExeFromVersions(root);
4906
5009
  }
4907
5010
  var WINDOWS_STUDIO_PROCESS_QUERY = [
@@ -5005,11 +5108,11 @@ function buildStudioLaunchArgs(options) {
5005
5108
  ];
5006
5109
  }
5007
5110
  }
5008
- function delay2(ms) {
5009
- return new Promise((resolve5) => setTimeout(resolve5, ms));
5111
+ function delay3(ms) {
5112
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
5010
5113
  }
5011
5114
  function basenameAny(filePath) {
5012
- return path2.basename(filePath.replace(/\\/g, "/"));
5115
+ return path3.basename(filePath.replace(/\\/g, "/"));
5013
5116
  }
5014
5117
  var StudioInstanceManager = class {
5015
5118
  managedByInstanceId = /* @__PURE__ */ new Map();
@@ -5026,6 +5129,7 @@ var StudioInstanceManager = class {
5026
5129
  snapshotCacheMs;
5027
5130
  launchCompletionTimeoutMs;
5028
5131
  closeTimeoutMs;
5132
+ settleSignIn;
5029
5133
  coordinatorTimer;
5030
5134
  coordinatorRefresh;
5031
5135
  cachedSnapshot;
@@ -5041,6 +5145,7 @@ var StudioInstanceManager = class {
5041
5145
  this.snapshotCacheMs = options.snapshotCacheMs ?? 0;
5042
5146
  this.launchCompletionTimeoutMs = options.launchCompletionTimeoutMs ?? LAUNCH_COMPLETION_TIMEOUT_MS;
5043
5147
  this.closeTimeoutMs = options.closeTimeoutMs ?? 1e4;
5148
+ this.settleSignIn = options.settleSignIn ?? (!this.processAdapter.stopProcess && (process.platform === "win32" || isWsl()) ? settleWindowsStudioSignIn : void 0);
5044
5149
  }
5045
5150
  getLifecycleCapabilities() {
5046
5151
  const platform = this.platformCapabilities ?? getStudioPlatformCapabilities();
@@ -5220,8 +5325,8 @@ var StudioInstanceManager = class {
5220
5325
  async launch(options) {
5221
5326
  const previous = this.launchQueue;
5222
5327
  let release;
5223
- this.launchQueue = new Promise((resolve5) => {
5224
- release = resolve5;
5328
+ this.launchQueue = new Promise((resolve6) => {
5329
+ release = resolve6;
5225
5330
  });
5226
5331
  await previous;
5227
5332
  try {
@@ -5380,7 +5485,7 @@ var StudioInstanceManager = class {
5380
5485
  await this.persist(record);
5381
5486
  break;
5382
5487
  }
5383
- await delay2(250);
5488
+ await delay3(250);
5384
5489
  }
5385
5490
  if (record.nativeProcessId === void 0 && process.platform !== "win32" && !isWsl()) {
5386
5491
  record.nativeProcessId = proc.pid;
@@ -5438,7 +5543,7 @@ var StudioInstanceManager = class {
5438
5543
  instanceId: record.instanceId
5439
5544
  };
5440
5545
  }
5441
- const deadline = Date.now() + this.closeTimeoutMs;
5546
+ let deadline = Date.now() + this.closeTimeoutMs;
5442
5547
  const processId = record.nativeProcessId ?? record.spawnPid;
5443
5548
  if (!processId) {
5444
5549
  throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
@@ -5477,6 +5582,8 @@ var StudioInstanceManager = class {
5477
5582
  };
5478
5583
  }
5479
5584
  }
5585
+ if (!abort)
5586
+ deadline = await this.beforeStudioStop(record.nativeProcessStartedAt, deadline);
5480
5587
  try {
5481
5588
  await this.closeProcess(processId, record.nativeProcessStartedAt, abort, deadline);
5482
5589
  } catch (error) {
@@ -5525,7 +5632,7 @@ var StudioInstanceManager = class {
5525
5632
  };
5526
5633
  }
5527
5634
  async closeConnectedInstance(instance) {
5528
- const deadline = Date.now() + this.closeTimeoutMs;
5635
+ let deadline = Date.now() + this.closeTimeoutMs;
5529
5636
  const snapshot = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, 0);
5530
5637
  if (snapshot.status === "error") {
5531
5638
  throw new Error(`Could not enumerate Studio processes: ${snapshot.error}`);
@@ -5534,8 +5641,20 @@ var StudioInstanceManager = class {
5534
5641
  if (!process2) {
5535
5642
  throw new Error(`Could not find a Studio process for connected instance "${instance.instanceId}".`);
5536
5643
  }
5644
+ deadline = await this.beforeStudioStop(process2.StartTimeUtcFileTime, deadline);
5537
5645
  await this.closeProcess(process2.Id, process2.StartTimeUtcFileTime, void 0, deadline);
5538
5646
  }
5647
+ async beforeStudioStop(startedAt, deadline) {
5648
+ if (!this.settleSignIn || startedAt === void 0 || !/^[1-9]\d*$/u.test(startedAt))
5649
+ return deadline;
5650
+ const waitStarted = Date.now();
5651
+ try {
5652
+ await this.settleSignIn(startedAt);
5653
+ } catch (error) {
5654
+ console.error(`[studio] Could not check Studio sign-in before closing: ${error instanceof Error ? error.message : String(error)}`);
5655
+ }
5656
+ return deadline + (Date.now() - waitStarted);
5657
+ }
5539
5658
  async closeProcess(processId, startedAt, stop, deadline = Date.now() + this.closeTimeoutMs) {
5540
5659
  this.cachedSnapshot = void 0;
5541
5660
  await beforeCloseDeadline(async () => {
@@ -5557,7 +5676,7 @@ var StudioInstanceManager = class {
5557
5676
  }
5558
5677
  }
5559
5678
  }, deadline, processId);
5560
- while (true) {
5679
+ for (; ; ) {
5561
5680
  const observation = await beforeCloseDeadline(() => this.observeClosingProcess(processId, startedAt, deadline), deadline, processId);
5562
5681
  if (observation.status === "not_running")
5563
5682
  return;
@@ -5568,7 +5687,7 @@ var StudioInstanceManager = class {
5568
5687
  if (remaining <= 0) {
5569
5688
  throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
5570
5689
  }
5571
- await delay2(Math.min(100, remaining));
5690
+ await delay3(Math.min(100, remaining));
5572
5691
  if (Date.now() >= deadline) {
5573
5692
  throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
5574
5693
  }
@@ -5736,13 +5855,13 @@ var StudioInstanceManager = class {
5736
5855
  const processId = record.nativeProcessId ?? record.spawnPid;
5737
5856
  if (record.spawnPid && record.spawnPid === processId && studioProcess.Id === processId)
5738
5857
  return true;
5739
- const processPath = studioProcess.Path ? path2.normalize(studioProcess.Path).toLowerCase() : "";
5740
- const exePath = record.exe ? path2.normalize(record.exe).toLowerCase() : "";
5858
+ const processPath = studioProcess.Path ? path3.normalize(studioProcess.Path).toLowerCase() : "";
5859
+ const exePath = record.exe ? path3.normalize(record.exe).toLowerCase() : "";
5741
5860
  if (processPath && exePath && (processPath === exePath || basenameAny(processPath) === basenameAny(exePath))) {
5742
5861
  return true;
5743
5862
  }
5744
5863
  const commandLine = studioProcess.CommandLine ?? "";
5745
- if (record.localPlaceFile && commandLine.includes(path2.basename(record.localPlaceFile)))
5864
+ if (record.localPlaceFile && commandLine.includes(path3.basename(record.localPlaceFile)))
5746
5865
  return true;
5747
5866
  if (record.placeId !== void 0 && commandLine.includes(String(record.placeId)))
5748
5867
  return true;
@@ -6273,11 +6392,21 @@ function createToolHttpHandler(options) {
6273
6392
 
6274
6393
  // ../core/dist/auth.js
6275
6394
  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";
6395
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync, linkSync, unlinkSync } from "fs";
6396
+ import { join as join4, dirname as dirname2 } from "path";
6278
6397
  import { homedir as homedir3 } from "os";
6279
6398
  function authTokenFilePath() {
6280
- return join3(homedir3(), ".robloxstudio-mcp", "auth-token");
6399
+ return join4(homedir3(), ".robloxstudio-mcp", "auth-token");
6400
+ }
6401
+ var EMPTY_TOKEN_RETRY_MS = 25;
6402
+ var EMPTY_TOKEN_RETRIES = 20;
6403
+ function readTokenFile(filePath) {
6404
+ for (let attempt = 0; ; attempt++) {
6405
+ const token = readFileSync3(filePath, "utf8").trim();
6406
+ if (token || attempt >= EMPTY_TOKEN_RETRIES)
6407
+ return token;
6408
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, EMPTY_TOKEN_RETRY_MS);
6409
+ }
6281
6410
  }
6282
6411
  function resolveAuthToken() {
6283
6412
  const noAuth = (process.env.ROBLOX_STUDIO_NO_AUTH || "").toLowerCase();
@@ -6290,20 +6419,36 @@ function resolveAuthToken() {
6290
6419
  }
6291
6420
  const filePath = authTokenFilePath();
6292
6421
  try {
6293
- if (existsSync3(filePath)) {
6294
- const existing = readFileSync3(filePath, "utf8").trim();
6295
- if (existing) {
6296
- return { token: existing, source: "file", filePath };
6297
- }
6422
+ try {
6423
+ const existing = readTokenFile(filePath);
6424
+ if (!existing)
6425
+ throw new Error("Auth token file is empty; remove it while all MCP servers are stopped to reinitialize it.");
6426
+ return { token: existing, source: "file", filePath };
6427
+ } catch (error) {
6428
+ if (error.code !== "ENOENT")
6429
+ throw error;
6298
6430
  }
6299
6431
  const fresh = randomBytes(32).toString("hex");
6300
6432
  mkdirSync2(dirname2(filePath), { recursive: true, mode: 448 });
6301
- writeFileSync(filePath, fresh + "\n", { encoding: "utf8", mode: 384 });
6433
+ const stagingPath = `${filePath}.${process.pid}.${randomBytes(16).toString("hex")}.tmp`;
6434
+ writeFileSync(stagingPath, fresh + "\n", { encoding: "utf8", mode: 384, flag: "wx" });
6302
6435
  try {
6303
- chmodSync(filePath, 384);
6304
- } catch {
6436
+ try {
6437
+ linkSync(stagingPath, filePath);
6438
+ } catch (error) {
6439
+ if (error.code !== "EEXIST")
6440
+ throw error;
6441
+ }
6442
+ const token = readTokenFile(filePath);
6443
+ if (!token)
6444
+ throw new Error("Auth token file is empty.");
6445
+ return { token, source: "file", filePath };
6446
+ } finally {
6447
+ try {
6448
+ unlinkSync(stagingPath);
6449
+ } catch {
6450
+ }
6305
6451
  }
6306
- return { token: fresh, source: "file", filePath };
6307
6452
  } catch (err2) {
6308
6453
  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
6454
  return { token: randomBytes(32).toString("hex"), source: "file" };
@@ -6821,7 +6966,7 @@ var TOOL_HANDLERS = {
6821
6966
  get_memory_breakdown: (tools, body) => tools.getMemoryBreakdown(body.target, body.tags, body.instance_id),
6822
6967
  get_scene_analysis: (tools, body) => tools.getSceneAnalysis(body.mode, body.target, body.topN, body.raw, body.instance_id),
6823
6968
  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),
6969
+ import_rbxm: (tools, body, context) => tools.importRbxm(body.source, body.parent_path, body.target, body.instance_id, context?.signal),
6825
6970
  find_and_replace_in_scripts: (tools, body) => tools.findAndReplaceInScripts(body.pattern, body.replacement, {
6826
6971
  caseSensitive: body.caseSensitive,
6827
6972
  usePattern: body.usePattern,
@@ -6990,8 +7135,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6990
7135
  });
6991
7136
  const authToken = security?.authToken;
6992
7137
  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";
7138
+ const path9 = requestPath.toLowerCase().replace(/\/+$/, "");
7139
+ return path9 === "/mcp" || path9.startsWith("/mcp/") || path9 === "/proxy" || path9 === "/topology" || path9 === "/request-status" || path9 === "/unregister-instance-id" || path9 === "/create-multiplayer-group" || path9 === "/remove-multiplayer-group";
6995
7140
  };
6996
7141
  app.use((req, res, next) => {
6997
7142
  if (!authToken || !authRequired(req.path)) {
@@ -7420,6 +7565,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
7420
7565
  studioTransport.close();
7421
7566
  webSocketServer.close();
7422
7567
  await mcpHandler?.close();
7568
+ await tools.dispose();
7423
7569
  };
7424
7570
  return app;
7425
7571
  }
@@ -7440,7 +7586,7 @@ async function listenWithRetry(app, host, startPort, maxAttempts = 5) {
7440
7586
  throw new Error(`All ports ${startPort}-${startPort + maxAttempts - 1} are in use. Stop some MCP server instances and retry.`);
7441
7587
  }
7442
7588
  function bindPort(app, host, port) {
7443
- const { promise, resolve: resolve5, reject } = Promise.withResolvers();
7589
+ const { promise, resolve: resolve6, reject } = Promise.withResolvers();
7444
7590
  const server = http.createServer(app);
7445
7591
  const onError = (err2) => {
7446
7592
  server.removeListener("error", onError);
@@ -7453,7 +7599,7 @@ function bindPort(app, host, port) {
7453
7599
  if ("attachStudioTransport" in app && typeof app.attachStudioTransport === "function") {
7454
7600
  app.attachStudioTransport(server);
7455
7601
  }
7456
- resolve5(server);
7602
+ resolve6(server);
7457
7603
  } catch (error) {
7458
7604
  server.close();
7459
7605
  reject(error);
@@ -7462,6 +7608,123 @@ function bindPort(app, host, port) {
7462
7608
  return promise;
7463
7609
  }
7464
7610
 
7611
+ // ../core/dist/rbxm-input.js
7612
+ import { open as open3 } from "fs/promises";
7613
+ import { resolve as resolve2 } from "path";
7614
+ var ENVELOPE_RESERVE_BYTES = 16 * 1024;
7615
+ var DOWNLOAD_TIMEOUT_MS = 3e4;
7616
+ function rbxmInputByteLimit(parentPath, sourceLabel) {
7617
+ const metadataBytes = Buffer.byteLength(JSON.stringify({
7618
+ base64: "",
7619
+ parent_path: parentPath,
7620
+ source_label: sourceLabel
7621
+ }));
7622
+ const encodedBytes = Math.min(HTTP_BODY_LIMIT_BYTES, MAX_STUDIO_FRAME_BYTES) - ENVELOPE_RESERVE_BYTES - metadataBytes;
7623
+ if (encodedBytes < 4)
7624
+ throw new Error("RBXM import metadata exceeds the transport byte budget.");
7625
+ return Math.floor(encodedBytes / 4) * 3;
7626
+ }
7627
+ function tooLarge(limit) {
7628
+ return new Error(`RBXM input exceeds the ${limit}-byte limit after allowing for base64 and transport overhead.`);
7629
+ }
7630
+ async function readBoundedResponse(response, limit) {
7631
+ const declared = Number(response.headers.get("content-length"));
7632
+ if (Number.isFinite(declared) && declared > limit) {
7633
+ await response.body?.cancel();
7634
+ throw tooLarge(limit);
7635
+ }
7636
+ if (!response.body)
7637
+ return Buffer.alloc(0);
7638
+ const reader = response.body.getReader();
7639
+ const chunks = [];
7640
+ let size = 0;
7641
+ try {
7642
+ for (; ; ) {
7643
+ const { done, value } = await reader.read();
7644
+ if (done)
7645
+ break;
7646
+ size += value.byteLength;
7647
+ if (size > limit)
7648
+ throw tooLarge(limit);
7649
+ chunks.push(Buffer.from(value));
7650
+ }
7651
+ return Buffer.concat(chunks, size);
7652
+ } finally {
7653
+ await reader.cancel().catch(() => {
7654
+ });
7655
+ reader.releaseLock();
7656
+ }
7657
+ }
7658
+ async function readRbxmInput(source, parentPath, signal) {
7659
+ const modes = ["path", "url", "base64"];
7660
+ const provided = modes.filter((key) => source[key] !== void 0);
7661
+ if (provided.length !== 1 || typeof source[provided[0]] !== "string") {
7662
+ throw new Error("source must contain exactly one string: path, url, or base64");
7663
+ }
7664
+ signal?.throwIfAborted();
7665
+ if (source.path !== void 0) {
7666
+ const sourceLabel = resolve2(source.path);
7667
+ const limit2 = rbxmInputByteLimit(parentPath, sourceLabel);
7668
+ const file = await open3(sourceLabel, "r");
7669
+ try {
7670
+ const stats = await file.stat();
7671
+ if (!stats.isFile())
7672
+ throw new Error("RBXM source must be a regular file.");
7673
+ if (stats.size > limit2)
7674
+ throw tooLarge(limit2);
7675
+ const chunks = [];
7676
+ let size = 0;
7677
+ for (; ; ) {
7678
+ signal?.throwIfAborted();
7679
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, limit2 - size + 1));
7680
+ const { bytesRead } = await file.read(chunk);
7681
+ if (bytesRead === 0)
7682
+ break;
7683
+ size += bytesRead;
7684
+ if (size > limit2)
7685
+ throw tooLarge(limit2);
7686
+ chunks.push(chunk.subarray(0, bytesRead));
7687
+ }
7688
+ return { bytes: Buffer.concat(chunks, size), sourceLabel };
7689
+ } finally {
7690
+ await file.close();
7691
+ }
7692
+ }
7693
+ if (source.url !== void 0) {
7694
+ const url = new URL(source.url);
7695
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
7696
+ throw new Error(`import_rbxm url must use http(s); got ${url.protocol}`);
7697
+ }
7698
+ const limit2 = rbxmInputByteLimit(parentPath, source.url);
7699
+ const deadline = AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS);
7700
+ const response = await fetch(url, {
7701
+ signal: signal ? AbortSignal.any([signal, deadline]) : deadline
7702
+ });
7703
+ if (!response.ok) {
7704
+ await response.body?.cancel();
7705
+ throw new Error(`RBXM download returned HTTP ${response.status}`);
7706
+ }
7707
+ return { bytes: await readBoundedResponse(response, limit2), sourceLabel: source.url };
7708
+ }
7709
+ const limit = rbxmInputByteLimit(parentPath, "base64(9999999999B)");
7710
+ const maxEncoded = 4 * Math.ceil(limit / 3);
7711
+ if (source.base64.length > maxEncoded + 2 * Math.ceil(maxEncoded / 64))
7712
+ throw tooLarge(limit);
7713
+ const encoded = source.base64.replace(/[\t\n\r ]+/g, "");
7714
+ if (encoded.length > maxEncoded)
7715
+ throw tooLarge(limit);
7716
+ if (encoded.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded) || encoded.includes("=") && encoded.length % 4 !== 0) {
7717
+ throw new Error("RBXM base64 must be valid base64.");
7718
+ }
7719
+ const bytes = Buffer.from(encoded, "base64");
7720
+ if (bytes.length > limit)
7721
+ throw tooLarge(limit);
7722
+ if (bytes.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "")) {
7723
+ throw new Error("RBXM base64 is not canonical.");
7724
+ }
7725
+ return { bytes, sourceLabel: `base64(${bytes.length}B)` };
7726
+ }
7727
+
7465
7728
  // ../core/dist/tools/studio-client.js
7466
7729
  var StudioHttpClient = class {
7467
7730
  bridge;
@@ -7529,7 +7792,6 @@ var OpenCloudClient = class {
7529
7792
  body: body ? JSON.stringify(body) : void 0,
7530
7793
  signal: controller.signal
7531
7794
  });
7532
- clearTimeout(timeoutId);
7533
7795
  if (!response.ok) {
7534
7796
  const errorBody = await response.text();
7535
7797
  let errorMessage2;
@@ -7551,7 +7813,8 @@ var OpenCloudClient = class {
7551
7813
  }
7552
7814
  return await response.json();
7553
7815
  } catch (error) {
7554
- clearTimeout(timeoutId);
7816
+ if (controller.signal.aborted)
7817
+ throw new Error("Request timed out");
7555
7818
  if (error instanceof Error) {
7556
7819
  if (error.name === "AbortError") {
7557
7820
  throw new Error("Request timed out");
@@ -7559,6 +7822,8 @@ var OpenCloudClient = class {
7559
7822
  throw error;
7560
7823
  }
7561
7824
  throw new Error(`Unknown error: ${String(error)}`);
7825
+ } finally {
7826
+ clearTimeout(timeoutId);
7562
7827
  }
7563
7828
  }
7564
7829
  async searchAssets(params) {
@@ -7593,7 +7858,8 @@ var OpenCloudClient = class {
7593
7858
  async getAssetThumbnail(assetId, size = "420x420") {
7594
7859
  const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${assetId}&size=${size}&format=Png`;
7595
7860
  try {
7596
- const response = await fetch(url);
7861
+ const signal = AbortSignal.timeout(this.timeout);
7862
+ const response = await fetch(url, { signal });
7597
7863
  if (!response.ok)
7598
7864
  return null;
7599
7865
  const data = await response.json();
@@ -7601,7 +7867,7 @@ var OpenCloudClient = class {
7601
7867
  if (!thumbnail || thumbnail.state !== "Completed" || !thumbnail.imageUrl) {
7602
7868
  return null;
7603
7869
  }
7604
- const imageResponse = await fetch(thumbnail.imageUrl);
7870
+ const imageResponse = await fetch(thumbnail.imageUrl, { signal });
7605
7871
  if (!imageResponse.ok)
7606
7872
  return null;
7607
7873
  const arrayBuffer = await imageResponse.arrayBuffer();
@@ -7622,7 +7888,7 @@ var OpenCloudClient = class {
7622
7888
  for (const batch of batches) {
7623
7889
  const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${batch.join(",")}&size=${size}&format=Png`;
7624
7890
  try {
7625
- const response = await fetch(url);
7891
+ const response = await fetch(url, { signal: AbortSignal.timeout(this.timeout) });
7626
7892
  if (response.ok) {
7627
7893
  const data = await response.json();
7628
7894
  for (const thumbnail of data.data) {
@@ -7683,7 +7949,7 @@ var OpenCloudClient = class {
7683
7949
  const chunks = [];
7684
7950
  let totalBytes = 0;
7685
7951
  const reader = contentResponse.body.getReader();
7686
- while (true) {
7952
+ for (; ; ) {
7687
7953
  const { done, value } = await reader.read();
7688
7954
  if (done)
7689
7955
  break;
@@ -7764,7 +8030,6 @@ var OpenCloudClient = class {
7764
8030
  body: formData,
7765
8031
  signal: controller.signal
7766
8032
  });
7767
- clearTimeout(timeoutId);
7768
8033
  if (!response.ok) {
7769
8034
  const errorBody = await response.text();
7770
8035
  let errorMessage2;
@@ -7786,7 +8051,8 @@ var OpenCloudClient = class {
7786
8051
  }
7787
8052
  return await response.json();
7788
8053
  } catch (error) {
7789
- clearTimeout(timeoutId);
8054
+ if (controller.signal.aborted)
8055
+ throw new Error("Request timed out");
7790
8056
  if (error instanceof Error) {
7791
8057
  if (error.name === "AbortError") {
7792
8058
  throw new Error("Request timed out");
@@ -7794,6 +8060,8 @@ var OpenCloudClient = class {
7794
8060
  throw error;
7795
8061
  }
7796
8062
  throw new Error(`Unknown error: ${String(error)}`);
8063
+ } finally {
8064
+ clearTimeout(timeoutId);
7797
8065
  }
7798
8066
  }
7799
8067
  async pollOperation(operationPath, maxAttempts = 30, intervalMs = 2e3) {
@@ -7805,7 +8073,7 @@ var OpenCloudClient = class {
7805
8073
  if (result.error) {
7806
8074
  throw new Error(`Asset upload failed: ${result.error.message}`);
7807
8075
  }
7808
- await new Promise((resolve5) => setTimeout(resolve5, intervalMs));
8076
+ await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
7809
8077
  }
7810
8078
  throw new Error(`Asset upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
7811
8079
  }
@@ -7822,6 +8090,7 @@ var RobloxCookieClient = class {
7822
8090
  return !!this.cookie;
7823
8091
  }
7824
8092
  async fetchWithCsrf(url, options = {}) {
8093
+ options = { ...options, signal: options.signal ?? AbortSignal.timeout(3e4) };
7825
8094
  const headers = {
7826
8095
  Cookie: `.ROBLOSECURITY=${this.cookie}`,
7827
8096
  ...options.headers || {}
@@ -7835,6 +8104,7 @@ var RobloxCookieClient = class {
7835
8104
  if (newToken) {
7836
8105
  this.csrfToken = newToken;
7837
8106
  headers["X-CSRF-TOKEN"] = newToken;
8107
+ await response.body?.cancel();
7838
8108
  return fetch(url, { ...options, headers });
7839
8109
  }
7840
8110
  }
@@ -7954,7 +8224,7 @@ var RobloxCookieClient = class {
7954
8224
  throw new Error("Image upload completed without an asset ID.");
7955
8225
  }
7956
8226
  if (attempt < maxAttempts - 1) {
7957
- await new Promise((resolve5) => setTimeout(resolve5, intervalMs));
8227
+ await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
7958
8228
  }
7959
8229
  }
7960
8230
  throw new Error(`Image upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
@@ -7978,7 +8248,7 @@ var RobloxCookieClient = class {
7978
8248
 
7979
8249
  // ../core/dist/image-decode.js
7980
8250
  import * as fs2 from "fs";
7981
- import * as path3 from "path";
8251
+ import * as path4 from "path";
7982
8252
  import { inflateSync } from "zlib";
7983
8253
  var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
7984
8254
  var MAX_EDITABLE_IMAGE_DIMENSION = 1024;
@@ -8310,14 +8580,14 @@ function decodePngBase64ToRgba(encoded) {
8310
8580
  return decodePngToRgba(data);
8311
8581
  }
8312
8582
  function decodeImagePathToRgba(imagePath) {
8313
- const resolved = path3.resolve(imagePath);
8583
+ const resolved = path4.resolve(imagePath);
8314
8584
  return decodePngToRgba(readPngFileWithinLimit(resolved, imagePath));
8315
8585
  }
8316
8586
 
8317
8587
  // ../core/dist/studio-skills.js
8318
8588
  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";
8589
+ import { existsSync as existsSync3, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
8590
+ import * as path5 from "path";
8321
8591
 
8322
8592
  // ../../node_modules/fzstd/esm/index.mjs
8323
8593
  var ab = ArrayBuffer;
@@ -9205,7 +9475,7 @@ function parseBuiltInStudioSkills(buffer) {
9205
9475
  }).sort((left, right) => left.name.localeCompare(right.name));
9206
9476
  }
9207
9477
  function discoverNamedFile(root, fileName, depth = 0) {
9208
- if (depth > MAX_DISCOVERY_DEPTH || !existsSync4(root))
9478
+ if (depth > MAX_DISCOVERY_DEPTH || !existsSync3(root))
9209
9479
  return [];
9210
9480
  const matches = [];
9211
9481
  let entries;
@@ -9215,7 +9485,7 @@ function discoverNamedFile(root, fileName, depth = 0) {
9215
9485
  return matches;
9216
9486
  }
9217
9487
  for (const entry of entries) {
9218
- const entryPath = path4.join(root, entry.name);
9488
+ const entryPath = path5.join(root, entry.name);
9219
9489
  if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
9220
9490
  matches.push(entryPath);
9221
9491
  } else if (entry.isDirectory()) {
@@ -9227,20 +9497,20 @@ function discoverNamedFile(root, fileName, depth = 0) {
9227
9497
  function resolveAssistantBundlePath(studioExe) {
9228
9498
  const override = process.env.ROBLOX_STUDIO_ASSISTANT_BUNDLE;
9229
9499
  if (override) {
9230
- if (!existsSync4(override)) {
9500
+ if (!existsSync3(override)) {
9231
9501
  throw new Error(`ROBLOX_STUDIO_ASSISTANT_BUNDLE does not exist: ${override}`);
9232
9502
  }
9233
9503
  return override;
9234
9504
  }
9235
- const exeDirectory = path4.dirname(studioExe ?? resolveStudioExe());
9505
+ const exeDirectory = path5.dirname(studioExe ?? resolveStudioExe());
9236
9506
  const roots = [
9237
- path4.join(exeDirectory, "BuiltInStandalonePlugins"),
9238
- path4.resolve(exeDirectory, "..", "Resources", "BuiltInStandalonePlugins"),
9239
- path4.resolve(exeDirectory, "..", "..", "OTAPlugins")
9507
+ path5.join(exeDirectory, "BuiltInStandalonePlugins"),
9508
+ path5.resolve(exeDirectory, "..", "Resources", "BuiltInStandalonePlugins"),
9509
+ path5.resolve(exeDirectory, "..", "..", "OTAPlugins")
9240
9510
  ];
9241
- const direct = path4.join(exeDirectory, "BuiltInStandalonePlugins", "Optimized_Embedded_Signature", "Assistant.rbxm");
9511
+ const direct = path5.join(exeDirectory, "BuiltInStandalonePlugins", "Optimized_Embedded_Signature", "Assistant.rbxm");
9242
9512
  const candidates = /* @__PURE__ */ new Set();
9243
- if (existsSync4(direct))
9513
+ if (existsSync3(direct))
9244
9514
  candidates.add(direct);
9245
9515
  for (const root of roots) {
9246
9516
  for (const candidate of discoverNamedFile(root, "Assistant.rbxm"))
@@ -9263,7 +9533,7 @@ function loadBuiltInStudioSkills(bundlePath = resolveAssistantBundlePath()) {
9263
9533
  if (skills.length === 0) {
9264
9534
  throw new Error(`No built-in skill documents found in ${bundlePath}`);
9265
9535
  }
9266
- const studioVersion = bundlePath.split(path4.sep).find((segment) => /^version-[a-z0-9]+$/i.test(segment));
9536
+ const studioVersion = bundlePath.split(path5.sep).find((segment) => /^version-[a-z0-9]+$/i.test(segment));
9267
9537
  const value = {
9268
9538
  bundlePath,
9269
9539
  bundleModifiedAt: stats.mtime.toISOString(),
@@ -10284,10 +10554,280 @@ function rgbaToPng(rgba, width, height) {
10284
10554
  }
10285
10555
 
10286
10556
  // ../core/dist/host-capture.js
10557
+ import { spawn as spawn3 } from "child_process";
10558
+ import * as fs4 from "fs";
10559
+ import * as os4 from "os";
10560
+ import * as path7 from "path";
10561
+
10562
+ // ../core/dist/host-capture-macos.js
10287
10563
  import { spawn as spawn2 } from "child_process";
10288
10564
  import * as fs3 from "fs";
10289
10565
  import * as os3 from "os";
10290
- import * as path5 from "path";
10566
+ import * as path6 from "path";
10567
+ var MAX_IMAGE_BYTES = 128 * 1024 * 1024;
10568
+ var MAX_OUTPUT_BYTES = 64 * 1024;
10569
+ var STUDIO_BUNDLE_ID = "com.Roblox.RobloxStudio";
10570
+ var MACOS_CAPTURE_SOURCE = String.raw`
10571
+ import Foundation
10572
+ import AppKit
10573
+ import CoreGraphics
10574
+ import ScreenCaptureKit
10575
+
10576
+ struct CaptureFailure: Error { let message: String }
10577
+ func fail(_ message: String) throws -> Never { throw CaptureFailure(message: message) }
10578
+ func emit(_ value: [String: Any]) {
10579
+ if let data = try? JSONSerialization.data(withJSONObject: value), let text = String(data: data, encoding: .utf8) {
10580
+ print(text)
10581
+ }
10582
+ }
10583
+
10584
+ // Local files can use their absolute path in the macOS window title while
10585
+ // Studio reports only the basename as DataModel.Name. Match that basename
10586
+ // after removing Studio's exact title suffix; retain ambiguity checks below.
10587
+ func matchesStudioTitle(_ title: String, hint: String) -> Bool {
10588
+ if hint.isEmpty || title == hint || title.hasPrefix(hint) { return true }
10589
+ let suffix = " - Roblox Studio"
10590
+ guard title.hasSuffix(suffix) else { return false }
10591
+ let place = String(title.dropLast(suffix.count))
10592
+ return place.hasPrefix("/") && (place as NSString).lastPathComponent == hint
10593
+ }
10594
+
10595
+ @available(macOS 14.0, *)
10596
+ @MainActor
10597
+ func capture() async throws {
10598
+ // Never request or bypass Screen Recording permission from an MCP call.
10599
+ guard CGPreflightScreenCaptureAccess() else {
10600
+ 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.")
10601
+ }
10602
+ // A command-line helper must establish its WindowServer connection before
10603
+ // ScreenCaptureKit uses Core Graphics. Do not activate or create any UI.
10604
+ NSApplication.shared.setActivationPolicy(.prohibited)
10605
+ let env = ProcessInfo.processInfo.environment
10606
+ let hint = env["MCP_CAPTURE_TITLE_HINT"] ?? ""
10607
+ let content = try await SCShareableContent.excludingDesktopWindows(true, onScreenWindowsOnly: true)
10608
+ let candidates = content.windows.filter { window in
10609
+ window.owningApplication?.bundleIdentifier == "com.Roblox.RobloxStudio" &&
10610
+ window.windowLayer == 0 && window.isOnScreen &&
10611
+ window.frame.width > 0 && window.frame.height > 0 &&
10612
+ matchesStudioTitle(window.title ?? "", hint: hint)
10613
+ }
10614
+ guard candidates.count == 1, let window = candidates.first, let app = window.owningApplication else {
10615
+ 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")
10616
+ }
10617
+ if let expected = env["MCP_CAPTURE_EXPECTED_IDENTITY"], !expected.isEmpty {
10618
+ guard let data = expected.data(using: .utf8),
10619
+ let identity = try JSONSerialization.jsonObject(with: data) as? [String: Any],
10620
+ (identity["windowId"] as? NSNumber)?.uint32Value == window.windowID,
10621
+ (identity["processId"] as? NSNumber)?.int32Value == app.processID,
10622
+ identity["bundleIdentifier"] as? String == app.bundleIdentifier else {
10623
+ try fail("the selected Roblox Studio window identity changed between captures")
10624
+ }
10625
+ }
10626
+ let filter = SCContentFilter(desktopIndependentWindow: window)
10627
+ let scale = CGFloat(filter.pointPixelScale)
10628
+ let pixelWidth = filter.contentRect.width * scale
10629
+ let pixelHeight = filter.contentRect.height * scale
10630
+ guard pixelWidth.isFinite, pixelHeight.isFinite, pixelWidth > 0, pixelHeight > 0,
10631
+ pixelWidth <= 16384, pixelHeight <= 16384 else { try fail("invalid Studio window capture dimensions") }
10632
+ let width = Int(pixelWidth.rounded(.up))
10633
+ let height = Int(pixelHeight.rounded(.up))
10634
+ guard width * height * 4 <= 134217728 else { try fail("Studio window capture exceeds the image byte limit") }
10635
+ let config = SCStreamConfiguration()
10636
+ config.width = width
10637
+ config.height = height
10638
+ config.showsCursor = false
10639
+ config.ignoreShadowsSingleWindow = true
10640
+ config.ignoreGlobalClipSingleWindow = true
10641
+ let image = try await SCScreenshotManager.captureImage(contentFilter: filter, configuration: config)
10642
+ guard image.width == width, image.height == height else { try fail("Studio window capture returned unexpected dimensions") }
10643
+ var bytes = [UInt8](repeating: 0, count: width * height * 4)
10644
+ try bytes.withUnsafeMutableBytes { raw in
10645
+ guard let context = CGContext(data: raw.baseAddress, width: width, height: height,
10646
+ bitsPerComponent: 8, bytesPerRow: width * 4, space: CGColorSpace(name: CGColorSpace.sRGB)!,
10647
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue) else {
10648
+ try fail("could not allocate the RGBA capture context")
10649
+ }
10650
+ // Quartz draws the image upright into row-major top-to-bottom bitmap storage.
10651
+ context.setBlendMode(.copy)
10652
+ context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
10653
+ }
10654
+ guard let output = env["MCP_CAPTURE_OUT"] else { try fail("missing capture output path") }
10655
+ try Data(bytes).write(to: URL(fileURLWithPath: output), options: .atomic)
10656
+ emit(["ok": true, "width": width, "height": height, "title": String((window.title ?? "").prefix(2048)),
10657
+ "identity": ["windowId": window.windowID, "processId": app.processID, "bundleIdentifier": app.bundleIdentifier]])
10658
+ }
10659
+
10660
+ @main struct Main {
10661
+ @MainActor
10662
+ static func main() async {
10663
+ do {
10664
+ if #available(macOS 14.0, *) { try await capture() }
10665
+ else { try fail("macOS host window capture requires macOS 14 or newer") }
10666
+ } catch {
10667
+ emit(["ok": false, "error": String(((error as? CaptureFailure)?.message ?? error.localizedDescription).prefix(2048))])
10668
+ }
10669
+ }
10670
+ }
10671
+ `;
10672
+ function runBoundedCaptureProcess(executable, args, timeoutMs, env = process.env) {
10673
+ return new Promise((resolve6, reject) => {
10674
+ const processGroup = process.platform !== "win32";
10675
+ const child = spawn2(executable, args, { env, stdio: ["ignore", "pipe", "pipe"], detached: processGroup });
10676
+ let stdout = "";
10677
+ let stderr = "";
10678
+ let outputBytes = 0;
10679
+ let failure;
10680
+ const stop = (message) => {
10681
+ failure ??= new Error(message);
10682
+ try {
10683
+ if (processGroup && child.pid)
10684
+ process.kill(-child.pid, "SIGKILL");
10685
+ else
10686
+ child.kill("SIGKILL");
10687
+ } catch {
10688
+ child.kill("SIGKILL");
10689
+ }
10690
+ };
10691
+ const timer = setTimeout(() => stop(`macOS host capture helper timed out after ${timeoutMs}ms`), timeoutMs);
10692
+ child.stdout.setEncoding("utf8");
10693
+ child.stderr.setEncoding("utf8");
10694
+ const collect = (chunk, isError) => {
10695
+ outputBytes += Buffer.byteLength(chunk);
10696
+ if (outputBytes > MAX_OUTPUT_BYTES) {
10697
+ stop("macOS host capture helper exceeded its output byte limit");
10698
+ return;
10699
+ }
10700
+ if (isError)
10701
+ stderr += chunk;
10702
+ else
10703
+ stdout += chunk;
10704
+ };
10705
+ child.stdout.on("data", (chunk) => collect(chunk, false));
10706
+ child.stderr.on("data", (chunk) => collect(chunk, true));
10707
+ child.on("error", (error) => {
10708
+ failure ??= error;
10709
+ });
10710
+ child.on("close", (code) => {
10711
+ clearTimeout(timer);
10712
+ if (failure)
10713
+ reject(failure);
10714
+ else if (code !== 0)
10715
+ reject(new Error(`macOS host capture helper exited with code ${code}: ${stderr.trim().slice(0, 600)}`));
10716
+ else
10717
+ resolve6(stdout);
10718
+ });
10719
+ });
10720
+ }
10721
+ 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.";
10722
+ async function assertSwiftToolchain(env = process.env, selectedDeveloperDir = () => runBoundedCaptureProcess("/usr/bin/xcode-select", ["-p"], 5e3, env), exists = fs3.existsSync) {
10723
+ let developerDir = env.DEVELOPER_DIR?.trim();
10724
+ if (!developerDir) {
10725
+ try {
10726
+ developerDir = (await selectedDeveloperDir()).trim();
10727
+ } catch {
10728
+ throw new Error(MISSING_SWIFT_TOOLCHAIN);
10729
+ }
10730
+ }
10731
+ const compilers = [
10732
+ path6.join(developerDir, "usr", "bin", "swiftc"),
10733
+ path6.join(developerDir, "Toolchains", "XcodeDefault.xctoolchain", "usr", "bin", "swiftc")
10734
+ ];
10735
+ if (!path6.isAbsolute(developerDir) || !compilers.some((compiler) => exists(compiler))) {
10736
+ throw new Error(MISSING_SWIFT_TOOLCHAIN);
10737
+ }
10738
+ }
10739
+ var compiledHelper;
10740
+ async function getHelper() {
10741
+ if (!compiledHelper) {
10742
+ compiledHelper = (async () => {
10743
+ await assertSwiftToolchain();
10744
+ const directory = fs3.mkdtempSync(path6.join(os3.tmpdir(), "robloxstudio-mcp-macos-helper-"));
10745
+ const cleanup = () => fs3.rmSync(directory, { recursive: true, force: true });
10746
+ try {
10747
+ const source = path6.join(directory, "capture.swift");
10748
+ const executable = path6.join(directory, "capture");
10749
+ fs3.writeFileSync(source, MACOS_CAPTURE_SOURCE, { mode: 384 });
10750
+ await runBoundedCaptureProcess("/usr/bin/xcrun", [
10751
+ "swiftc",
10752
+ "-parse-as-library",
10753
+ source,
10754
+ "-o",
10755
+ executable,
10756
+ "-module-cache-path",
10757
+ path6.join(directory, "modules")
10758
+ ], 6e4);
10759
+ process.once("exit", cleanup);
10760
+ return executable;
10761
+ } catch (error) {
10762
+ cleanup();
10763
+ throw error;
10764
+ }
10765
+ })();
10766
+ compiledHelper.catch(() => {
10767
+ compiledHelper = void 0;
10768
+ });
10769
+ }
10770
+ return compiledHelper;
10771
+ }
10772
+ async function prepareMacHostCapture() {
10773
+ await getHelper();
10774
+ }
10775
+ function validIdentity(value) {
10776
+ if (!value || typeof value !== "object")
10777
+ return false;
10778
+ const id = value;
10779
+ 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;
10780
+ }
10781
+ function parseMacCaptureReport(stdout, expectedIdentity) {
10782
+ if (Buffer.byteLength(stdout) > MAX_OUTPUT_BYTES)
10783
+ throw new Error("macOS capture report exceeds its byte limit");
10784
+ const value = JSON.parse(stdout.trim());
10785
+ if (!value || typeof value !== "object")
10786
+ throw new Error("invalid macOS capture report");
10787
+ if (value.ok === false && typeof value.error === "string")
10788
+ return { ok: false, error: value.error.slice(0, 2048) };
10789
+ const { width, height, title, identity } = value;
10790
+ 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)) {
10791
+ throw new Error("invalid macOS capture dimensions or window identity");
10792
+ }
10793
+ if (expectedIdentity && (identity.windowId !== expectedIdentity.windowId || identity.processId !== expectedIdentity.processId || identity.bundleIdentifier !== expectedIdentity.bundleIdentifier)) {
10794
+ throw new Error("the selected Roblox Studio window identity changed between captures");
10795
+ }
10796
+ return { ok: true, width, height, title, identity };
10797
+ }
10798
+ async function runMacCapture(output, titleHint, expectedIdentity) {
10799
+ const executable = await getHelper();
10800
+ return runBoundedCaptureProcess(executable, [], 2e4, {
10801
+ ...process.env,
10802
+ MCP_CAPTURE_TITLE_HINT: titleHint ?? "",
10803
+ MCP_CAPTURE_OUT: output,
10804
+ MCP_CAPTURE_EXPECTED_IDENTITY: expectedIdentity ? JSON.stringify(expectedIdentity) : ""
10805
+ });
10806
+ }
10807
+ async function captureMacStudioWindow(titleHint, expectedIdentity, runCapture = runMacCapture) {
10808
+ const directory = fs3.mkdtempSync(path6.join(os3.tmpdir(), "robloxstudio-mcp-macos-capture-"));
10809
+ try {
10810
+ const output = path6.join(directory, "capture.rgba");
10811
+ const stdout = await runCapture(output, titleHint, expectedIdentity);
10812
+ const report = parseMacCaptureReport(stdout, expectedIdentity);
10813
+ if (!report.ok)
10814
+ return report;
10815
+ const expectedBytes = report.width * report.height * 4;
10816
+ const stat2 = fs3.lstatSync(output);
10817
+ if (!stat2.isFile() || stat2.size !== expectedBytes)
10818
+ throw new Error("macOS capture returned an invalid RGBA byte count");
10819
+ const rgba = fs3.readFileSync(output);
10820
+ if (rgba.length !== expectedBytes)
10821
+ throw new Error("macOS capture RGBA byte count changed during read");
10822
+ return { ok: true, capture: { width: report.width, height: report.height, title: report.title, identity: report.identity, rgba } };
10823
+ } catch (error) {
10824
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
10825
+ } finally {
10826
+ fs3.rmSync(directory, { recursive: true, force: true });
10827
+ }
10828
+ }
10829
+
10830
+ // ../core/dist/host-capture.js
10291
10831
  var HOST_CAPTURE_TIMEOUT_MS = 2e4;
10292
10832
  var MARKER_MIN_RB = 200;
10293
10833
  var MARKER_MAX_G = 60;
@@ -10297,10 +10837,14 @@ function isHostCaptureDisabled(env = process.env) {
10297
10837
  return value === "0" || value === "false" || value === "off";
10298
10838
  }
10299
10839
  function isHostCaptureSupported(platform = process.platform) {
10300
- return platform === "win32";
10840
+ return platform === "win32" || platform === "darwin";
10301
10841
  }
10302
10842
  function hostCaptureUnsupportedReason(platform = process.platform) {
10303
- return `host window capture is only implemented on Windows (this is ${platform})`;
10843
+ return `host window capture is only implemented on Windows and macOS (this is ${platform})`;
10844
+ }
10845
+ async function prepareHostWindowCapture() {
10846
+ if (process.platform === "darwin" && !isHostCaptureDisabled())
10847
+ await prepareMacHostCapture();
10304
10848
  }
10305
10849
  function isUniformFrame(rgba, width, height) {
10306
10850
  const pixels = width * height;
@@ -10450,17 +10994,40 @@ public static class McpStudioCapture {
10450
10994
  }
10451
10995
  "@
10452
10996
  function Emit($obj) { Write-Output ($obj | ConvertTo-Json -Compress) }
10997
+ # Local files show their absolute path in the window title while Studio reports
10998
+ # only the basename as DataModel.Name. Match that basename after removing
10999
+ # Studio's exact title suffix; the ambiguity checks below still apply.
11000
+ function Test-StudioTitle([string]$title, [string]$hint) {
11001
+ if (-not $hint -or $title.StartsWith($hint, [StringComparison]::Ordinal)) { return $true }
11002
+ $suffix = ' - Roblox Studio'
11003
+ if (-not $title.EndsWith($suffix, [StringComparison]::Ordinal)) { return $false }
11004
+ $place = $title.Substring(0, $title.Length - $suffix.Length)
11005
+ if ($place -cnotmatch '^(?:[A-Za-z]:[\\/]|\\\\|/)') { return $false }
11006
+ return $place.Substring($place.LastIndexOfAny([char[]]@([char]'\', [char]'/')) + 1) -ceq $hint
11007
+ }
10453
11008
  $hint = $env:MCP_CAPTURE_TITLE_HINT
10454
11009
  $outFile = $env:MCP_CAPTURE_OUT
10455
11010
  $candidates = [McpStudioCapture]::Find('RobloxStudioBeta')
10456
11011
  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
11012
+ $matching = @($candidates | Where-Object { Test-StudioTitle $_.Title $hint })
11013
+ $expectedJson = $env:MCP_CAPTURE_EXPECTED_IDENTITY
11014
+ if ($expectedJson) {
11015
+ try { $expected = $expectedJson | ConvertFrom-Json -ErrorAction Stop }
11016
+ catch { Emit @{ ok = $false; error = 'invalid expected Studio window identity' }; exit 0 }
11017
+ $matching = @($matching | Where-Object {
11018
+ $_.Handle.ToInt64() -eq $expected.windowId -and $_.Pid -eq $expected.processId -and
11019
+ $expected.bundleIdentifier -ceq 'RobloxStudioBeta'
11020
+ })
11021
+ if ($matching.Count -ne 1) {
11022
+ Emit @{ ok = $false; error = 'the selected Roblox Studio window identity changed between captures' }; exit 0
11023
+ }
11024
+ } elseif ($matching.Count -ne 1) {
11025
+ $reason = if ($matching.Count -eq 0) { 'no visible Roblox Studio window matches the requested place title' }
11026
+ else { 'multiple Roblox Studio windows match the requested place title; the capture is ambiguous' }
11027
+ Emit @{ ok = $false; error = $reason }; exit 0
10463
11028
  }
11029
+ $pick = $matching[0]
11030
+ $identity = @{ windowId = $pick.Handle.ToInt64(); processId = [long]$pick.Pid; bundleIdentifier = 'RobloxStudioBeta' }
10464
11031
  if ([McpStudioCapture]::IsIconic($pick.Handle)) {
10465
11032
  Emit @{ ok = $false; error = "the Studio window '$($pick.Title)' is minimized; restore it (it may stay behind other windows)" }; exit 0
10466
11033
  }
@@ -10482,83 +11049,110 @@ $bytes = New-Object byte[] ($data.Stride * $h)
10482
11049
  $stride = $data.Stride
10483
11050
  $bmp.UnlockBits($data); $bmp.Dispose()
10484
11051
  [System.IO.File]::WriteAllBytes($outFile, $bytes)
10485
- Emit @{ ok = $true; width = $w; height = $h; stride = $stride; title = $pick.Title }
11052
+ Emit @{ ok = $true; width = $w; height = $h; stride = $stride; title = $pick.Title; identity = $identity }
10486
11053
  `;
11054
+ function validWindowsIdentity(value) {
11055
+ 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";
11056
+ }
11057
+ function parseWindowsCaptureReport(value, expectedIdentity) {
11058
+ if (value === null || typeof value !== "object" || !("ok" in value))
11059
+ throw new Error("invalid Windows capture report");
11060
+ if (value.ok === false && "error" in value && typeof value.error === "string")
11061
+ return { ok: false, error: value.error };
11062
+ if (!("identity" in value) || !validWindowsIdentity(value.identity))
11063
+ throw new Error("invalid Windows capture window identity");
11064
+ const identity = value.identity;
11065
+ if (expectedIdentity && (identity.windowId !== expectedIdentity.windowId || identity.processId !== expectedIdentity.processId || identity.bundleIdentifier !== expectedIdentity.bundleIdentifier)) {
11066
+ throw new Error("the selected Roblox Studio window identity changed between captures");
11067
+ }
11068
+ 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") {
11069
+ throw new Error("invalid Windows capture dimensions or title");
11070
+ }
11071
+ return { ok: true, width: value.width, height: value.height, stride: value.stride, title: value.title, identity };
11072
+ }
10487
11073
  function powershellPath() {
10488
11074
  const systemRoot = process.env.SystemRoot ?? process.env.windir;
10489
11075
  if (systemRoot) {
10490
- const candidate = path5.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
10491
- if (fs3.existsSync(candidate))
11076
+ const candidate = path7.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
11077
+ if (fs4.existsSync(candidate))
10492
11078
  return candidate;
10493
11079
  }
10494
11080
  return "powershell.exe";
10495
11081
  }
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)
11082
+ function runWindowsCapture(titleHint, outFile, expectedIdentity) {
11083
+ const { promise, resolve: resolve6, reject } = Promise.withResolvers();
11084
+ const encoded = Buffer.from(WINDOWS_CAPTURE_SCRIPT, "utf16le").toString("base64");
11085
+ const child = spawn3(powershellPath(), ["-NoProfile", "-NonInteractive", "-NoLogo", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded], {
11086
+ env: {
11087
+ ...process.env,
11088
+ MCP_CAPTURE_TITLE_HINT: titleHint ?? "",
11089
+ MCP_CAPTURE_OUT: outFile,
11090
+ MCP_CAPTURE_EXPECTED_IDENTITY: expectedIdentity ? JSON.stringify(expectedIdentity) : ""
11091
+ },
11092
+ stdio: ["ignore", "pipe", "pipe"],
11093
+ windowsHide: true
11094
+ });
11095
+ let stdout = "";
11096
+ let stderr = "";
11097
+ let settled = false;
11098
+ const timer = setTimeout(() => {
11099
+ if (settled)
11100
+ return;
11101
+ settled = true;
11102
+ child.kill();
11103
+ reject(new Error(`host window capture timed out after ${HOST_CAPTURE_TIMEOUT_MS}ms`));
11104
+ }, HOST_CAPTURE_TIMEOUT_MS);
11105
+ child.stdout.setEncoding("utf8");
11106
+ child.stderr.setEncoding("utf8");
11107
+ child.stdout.on("data", (chunk) => {
11108
+ stdout += chunk;
11109
+ });
11110
+ child.stderr.on("data", (chunk) => {
11111
+ stderr += chunk;
11112
+ });
11113
+ child.on("error", (error) => {
11114
+ if (settled)
11115
+ return;
11116
+ settled = true;
11117
+ clearTimeout(timer);
11118
+ reject(new Error(`could not start PowerShell for host window capture: ${error.message}`));
11119
+ });
11120
+ child.on("close", (code) => {
11121
+ if (settled)
11122
+ return;
11123
+ settled = true;
11124
+ clearTimeout(timer);
11125
+ const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
11126
+ const last = lines[lines.length - 1];
11127
+ if (last && last.startsWith("{")) {
11128
+ try {
11129
+ resolve6(JSON.parse(last));
10531
11130
  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
- }
11131
+ } catch {
10542
11132
  }
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
- });
11133
+ }
11134
+ const detail = (stderr.trim() || stdout.trim()).slice(0, 600);
11135
+ reject(new Error(`host window capture helper exited with code ${code}${detail ? `: ${detail}` : ""}`));
10546
11136
  });
11137
+ return promise;
10547
11138
  }
10548
- async function captureStudioWindow(titleHint) {
11139
+ async function captureStudioWindow(titleHint, expectedIdentity) {
10549
11140
  if (isHostCaptureDisabled()) {
10550
11141
  return { ok: false, error: "host window capture is disabled by ROBLOX_STUDIO_HOST_CAPTURE" };
10551
11142
  }
10552
11143
  if (!isHostCaptureSupported()) {
10553
11144
  return { ok: false, error: hostCaptureUnsupportedReason() };
10554
11145
  }
10555
- const outFile = path5.join(os3.tmpdir(), `robloxstudio-mcp-capture-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.bgra`);
11146
+ if (process.platform === "darwin")
11147
+ return captureMacStudioWindow(titleHint, expectedIdentity);
11148
+ const outFile = path7.join(os4.tmpdir(), `robloxstudio-mcp-capture-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.bgra`);
10556
11149
  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);
11150
+ const report = parseWindowsCaptureReport(await runWindowsCapture(titleHint, outFile, expectedIdentity), expectedIdentity);
11151
+ if (!report.ok)
11152
+ return report;
11153
+ const raw = fs4.readFileSync(outFile);
11154
+ if (raw.length !== report.stride * report.height)
11155
+ throw new Error("Windows capture returned an invalid BGRA byte count");
10562
11156
  const { width, height, stride } = report;
10563
11157
  const rgba = Buffer.alloc(width * height * 4);
10564
11158
  for (let y = 0; y < height; y++) {
@@ -10573,17 +11167,20 @@ async function captureStudioWindow(titleHint) {
10573
11167
  rgba[d + 3] = 255;
10574
11168
  }
10575
11169
  }
10576
- return { ok: true, capture: { width, height, rgba, title: report.title ?? "" } };
11170
+ return { ok: true, capture: { width, height, rgba, title: report.title, identity: report.identity } };
10577
11171
  } catch (error) {
10578
11172
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
10579
11173
  } finally {
10580
- fs3.rmSync(outFile, { force: true });
11174
+ fs4.rmSync(outFile, { force: true });
10581
11175
  }
10582
11176
  }
10583
11177
 
10584
11178
  // ../core/dist/tools/index.js
10585
- import * as fs4 from "fs";
10586
- import * as path6 from "path";
11179
+ import * as fs5 from "fs";
11180
+ import * as path8 from "path";
11181
+ function sameHostWindowIdentity(actual, expected) {
11182
+ return actual?.windowId === expected.windowId && actual.processId === expected.processId && actual.bundleIdentifier === expected.bundleIdentifier;
11183
+ }
10587
11184
  var HOST_VIEWPORT_RECT_TTL_MS = 6e4;
10588
11185
  var MAX_INLINE_IMAGE_BYTES = 6e6;
10589
11186
  var MAX_MATRIX_IMAGE_BYTES = 8e6;
@@ -10669,7 +11266,7 @@ function encodeImageFromRgbaResponse(response, format, quality) {
10669
11266
  };
10670
11267
  }
10671
11268
  function sleep(ms) {
10672
- return new Promise((resolve5) => setTimeout(resolve5, ms));
11269
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
10673
11270
  }
10674
11271
  function errorMessage(error) {
10675
11272
  return error instanceof Error ? error.message : String(error);
@@ -10763,9 +11360,9 @@ function compactSoundReference(sound) {
10763
11360
  name: stringField(sound, "name"),
10764
11361
  className: stringField(sound, "className")
10765
11362
  };
10766
- const path7 = stringField(sound, "path");
10767
- if (path7)
10768
- compact.path = path7;
11363
+ const path9 = stringField(sound, "path");
11364
+ if (path9)
11365
+ compact.path = path9;
10769
11366
  const assetId = robloxAssetIdFromContentId(sound.assetId ?? sound.soundId ?? sound.asset);
10770
11367
  if (assetId !== void 0)
10771
11368
  compact.assetId = assetId;
@@ -10828,8 +11425,8 @@ function loadMicroProfilerBaseline(source, sourcePath) {
10828
11425
  if (typeof sourcePath !== "string" || sourcePath === "") {
10829
11426
  throw new Error("baseline_path must be a non-empty string when provided");
10830
11427
  }
10831
- const resolved = path6.resolve(sourcePath);
10832
- const parsed = JSON.parse(fs4.readFileSync(resolved, "utf8"));
11428
+ const resolved = path8.resolve(sourcePath);
11429
+ const parsed = JSON.parse(fs5.readFileSync(resolved, "utf8"));
10833
11430
  const record = asRecord(parsed);
10834
11431
  if (!record)
10835
11432
  throw new Error(`baseline_path did not contain a JSON object: ${resolved}`);
@@ -11331,6 +11928,7 @@ var RobloxStudioTools = class {
11331
11928
  cookieClient;
11332
11929
  instanceManager;
11333
11930
  managedConnectionAssociations = Promise.resolve();
11931
+ unsubscribePeerRegistered;
11334
11932
  hostWindowCapture = captureStudioWindow;
11335
11933
  hostViewportRects = /* @__PURE__ */ new Map();
11336
11934
  viewportCaptureQueues = /* @__PURE__ */ new Map();
@@ -11340,7 +11938,7 @@ var RobloxStudioTools = class {
11340
11938
  this.openCloudClient = new OpenCloudClient();
11341
11939
  this.cookieClient = new RobloxCookieClient();
11342
11940
  this.instanceManager = new StudioInstanceManager();
11343
- this.bridge.onPeerRegistered((peer) => {
11941
+ this.unsubscribePeerRegistered = this.bridge.onPeerRegistered((peer) => {
11344
11942
  const instanceManager = this.instanceManager;
11345
11943
  const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(peer, instanceManager));
11346
11944
  this.managedConnectionAssociations = association.catch((error) => {
@@ -11351,6 +11949,10 @@ var RobloxStudioTools = class {
11351
11949
  getStudioLifecycleCapabilities() {
11352
11950
  return this.instanceManager.getLifecycleCapabilities();
11353
11951
  }
11952
+ async dispose() {
11953
+ this.unsubscribePeerRegistered();
11954
+ await this.managedConnectionAssociations;
11955
+ }
11354
11956
  _textResult(body) {
11355
11957
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
11356
11958
  }
@@ -11474,8 +12076,8 @@ var RobloxStudioTools = class {
11474
12076
  }
11475
12077
  return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal, operationId);
11476
12078
  }
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.
12079
+ // Honor an explicitly selected client for viewport/input operations; otherwise
12080
+ // prefer the first client in the scope, then retain the default Peer's Instance.
11479
12081
  _resolveRuntime(instance_id) {
11480
12082
  const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
11481
12083
  if (!resolved.ok)
@@ -11487,7 +12089,9 @@ var RobloxStudioTools = class {
11487
12089
  data: this._routingErrorData()
11488
12090
  });
11489
12091
  }
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];
12092
+ const clients = this.bridge.getPeersInScope(resolved.targetInstanceId).filter((peer) => /^client-\d+$/.test(peer.role));
12093
+ const selectedClients = clients.filter((peer) => instance_id === peer.instanceId || instance_id === `${peer.instanceId}-${peer.role}`);
12094
+ const client = selectedClients.length === 1 ? selectedClients[0] : clients.sort((a, b) => a.role.localeCompare(b.role) || a.peerId.localeCompare(b.peerId))[0];
11491
12095
  return {
11492
12096
  instanceId: client?.instanceId ?? resolved.targetInstanceId,
11493
12097
  clientRole: client?.role
@@ -11773,8 +12377,8 @@ var RobloxStudioTools = class {
11773
12377
  timedOut: true
11774
12378
  };
11775
12379
  }
11776
- async getFileTree(path7 = "", instance_id) {
11777
- const response = await this._callSingle("/api/file-tree", { path: path7 }, void 0, instance_id);
12380
+ async getFileTree(path9 = "", instance_id) {
12381
+ const response = await this._callSingle("/api/file-tree", { path: path9 }, void 0, instance_id);
11778
12382
  return {
11779
12383
  content: [
11780
12384
  {
@@ -11866,9 +12470,9 @@ var RobloxStudioTools = class {
11866
12470
  ]
11867
12471
  };
11868
12472
  }
11869
- async getProjectStructure(path7, maxDepth, scriptsOnly, instance_id) {
12473
+ async getProjectStructure(path9, maxDepth, scriptsOnly, instance_id) {
11870
12474
  const response = await this._callSingle("/api/project-structure", {
11871
- path: path7,
12475
+ path: path9,
11872
12476
  maxDepth,
11873
12477
  scriptsOnly
11874
12478
  }, void 0, instance_id);
@@ -12029,7 +12633,7 @@ var RobloxStudioTools = class {
12029
12633
  };
12030
12634
  }
12031
12635
  async setSelection(paths, mode, instance_id) {
12032
- if (!Array.isArray(paths) || paths.some((path7) => typeof path7 !== "string" || path7.length === 0)) {
12636
+ if (!Array.isArray(paths) || paths.some((path9) => typeof path9 !== "string" || path9.length === 0)) {
12033
12637
  throw new Error("selection paths must contain only non-empty instance paths");
12034
12638
  }
12035
12639
  const selectionMode = mode ?? "set";
@@ -12766,9 +13370,9 @@ var RobloxStudioTools = class {
12766
13370
  const rawJson = mutable.raw_json;
12767
13371
  if (typeof rawJson === "string") {
12768
13372
  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");
13373
+ const resolvedOutputPath = path8.resolve(outputPath);
13374
+ fs5.mkdirSync(path8.dirname(resolvedOutputPath), { recursive: true });
13375
+ fs5.writeFileSync(resolvedOutputPath, rawJson, "utf8");
12772
13376
  mutable.output_path = resolvedOutputPath;
12773
13377
  }
12774
13378
  delete mutable.raw_json;
@@ -12829,9 +13433,9 @@ var RobloxStudioTools = class {
12829
13433
  const rawSnapshotBase64 = mutable.raw_snapshot_base64;
12830
13434
  if (typeof rawSnapshotBase64 === "string") {
12831
13435
  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"));
13436
+ const resolvedOutputPath = path8.resolve(outputPath);
13437
+ fs5.mkdirSync(path8.dirname(resolvedOutputPath), { recursive: true });
13438
+ fs5.writeFileSync(resolvedOutputPath, Buffer.from(rawSnapshotBase64, "base64"));
12835
13439
  mutable.output_path = resolvedOutputPath;
12836
13440
  }
12837
13441
  delete mutable.raw_snapshot_base64;
@@ -12845,9 +13449,9 @@ var RobloxStudioTools = class {
12845
13449
  });
12846
13450
  }
12847
13451
  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");
13452
+ const resolvedSummaryPath = path8.resolve(summaryOutputPath);
13453
+ fs5.mkdirSync(path8.dirname(resolvedSummaryPath), { recursive: true });
13454
+ fs5.writeFileSync(resolvedSummaryPath, JSON.stringify(mutable, null, 2), "utf8");
12851
13455
  mutable.summary_output_path = resolvedSummaryPath;
12852
13456
  }
12853
13457
  if (!includeComparisonIndex) {
@@ -12909,7 +13513,7 @@ var RobloxStudioTools = class {
12909
13513
  return record.placeId !== void 0 && instance.placeId === record.placeId;
12910
13514
  }
12911
13515
  if ((record.source === "baseplate" || record.source === "local_file") && record.localPlaceFile) {
12912
- const expectedName = path6.basename(record.localPlaceFile);
13516
+ const expectedName = path8.basename(record.localPlaceFile);
12913
13517
  return instance.placeName === expectedName || instance.dataModelName === expectedName;
12914
13518
  }
12915
13519
  return true;
@@ -12922,7 +13526,7 @@ var RobloxStudioTools = class {
12922
13526
  await instanceManager.attachInstanceId(candidate, instance.instanceId);
12923
13527
  }
12924
13528
  async _deriveUniverseId(placeId) {
12925
- const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`);
13529
+ const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`, { signal: AbortSignal.timeout(3e4) });
12926
13530
  if (!response.ok) {
12927
13531
  const body = await response.text().catch(() => "");
12928
13532
  throw new Error(`Could not resolve the universe for place_id ${placeId} (${response.status}): ${body}`);
@@ -14240,11 +14844,11 @@ var RobloxStudioTools = class {
14240
14844
  return this.resolveUploadedReferenceImageId(decalId, instance_id);
14241
14845
  }
14242
14846
  async uploadAsset(filePath, assetType, displayName, description, userId, groupId) {
14243
- if (!fs4.existsSync(filePath)) {
14847
+ if (!fs5.existsSync(filePath)) {
14244
14848
  throw new Error(`File not found: ${filePath}`);
14245
14849
  }
14246
- const fileContent = fs4.readFileSync(filePath);
14247
- const fileName = path6.basename(filePath);
14850
+ const fileContent = fs5.readFileSync(filePath);
14851
+ const fileName = path8.basename(filePath);
14248
14852
  const resolvedGroupId = groupId || process.env.ROBLOX_CREATOR_GROUP_ID;
14249
14853
  const resolvedUserId = userId || process.env.ROBLOX_CREATOR_USER_ID;
14250
14854
  if (assetType === "Decal" && this.cookieClient.hasCookie()) {
@@ -14460,10 +15064,10 @@ var RobloxStudioTools = class {
14460
15064
  return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
14461
15065
  }
14462
15066
  const bytes = Buffer.from(response.base64, "base64");
14463
- const resolved = path6.resolve(outputPath);
15067
+ const resolved = path8.resolve(outputPath);
14464
15068
  try {
14465
- fs4.mkdirSync(path6.dirname(resolved), { recursive: true });
14466
- fs4.writeFileSync(resolved, bytes);
15069
+ fs5.mkdirSync(path8.dirname(resolved), { recursive: true });
15070
+ fs5.writeFileSync(resolved, bytes);
14467
15071
  } catch (err2) {
14468
15072
  return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err2.message}` }) }] };
14469
15073
  }
@@ -14478,7 +15082,7 @@ var RobloxStudioTools = class {
14478
15082
  }]
14479
15083
  };
14480
15084
  }
14481
- async importRbxm(source, parentPath, target, instance_id) {
15085
+ async importRbxm(source, parentPath, target, instance_id, signal) {
14482
15086
  if (!source || typeof source !== "object") {
14483
15087
  throw new Error("source is required for import_rbxm");
14484
15088
  }
@@ -14493,59 +15097,18 @@ var RobloxStudioTools = class {
14493
15097
  if (modes.length !== 1) {
14494
15098
  throw new Error(`source must contain exactly one of { path, url, base64 } (got: ${modes.join(", ") || "none"})`);
14495
15099
  }
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)`;
15100
+ let input;
15101
+ try {
15102
+ input = await readRbxmInput(source, parentPath, signal);
15103
+ } catch (error) {
15104
+ return this._textResult({ error: error instanceof Error ? error.message : String(error) });
14543
15105
  }
15106
+ const { bytes, sourceLabel } = input;
14544
15107
  const response = await this._callSingle("/api/import-rbxm", {
14545
15108
  base64: bytes.toString("base64"),
14546
15109
  parent_path: parentPath,
14547
15110
  source_label: sourceLabel
14548
- }, tgt, instance_id);
15111
+ }, tgt, instance_id, void 0, signal);
14549
15112
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
14550
15113
  }
14551
15114
  // Even native capture must wait while another call has fitted the simulator
@@ -14567,24 +15130,26 @@ var RobloxStudioTools = class {
14567
15130
  const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
14568
15131
  let response = await this._callSingle("/api/capture-studio", { encoding: fmt === "png" ? "png" : "rgba8" }, targetRole, instanceId);
14569
15132
  const studioFastPathMissing = response.unavailable !== void 0 || (response.error?.includes("/api/capture-studio") ?? false);
15133
+ const studioFastPathUnavailable = studioFastPathMissing ? response.unavailable ?? response.error : void 0;
14570
15134
  if (studioFastPathMissing) {
14571
15135
  if (targetRole.startsWith("client-")) {
14572
15136
  const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
14573
15137
  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." };
15138
+ response = { error: begin.error };
15139
+ } else if (!begin.contentId) {
15140
+ response = { error: "Screenshot capture failed: no content id returned from client." };
15141
+ } else {
15142
+ response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
14578
15143
  }
14579
- response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
14580
15144
  } else {
14581
15145
  response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
14582
15146
  }
15147
+ response.source ??= "CaptureService";
14583
15148
  }
14584
15149
  let hostNote = "";
14585
15150
  let hostReason;
14586
15151
  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;
15152
+ 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
15153
  } catch (error) {
14589
15154
  response = { ...response, error: `Could not decode Studio's screenshot: ${error instanceof Error ? error.message : String(error)}` };
14590
15155
  hostReason = `Studio's capture failed (${response.error})`;
@@ -14605,7 +15170,7 @@ var RobloxStudioTools = class {
14605
15170
  if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
14606
15171
  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
15172
  }
14608
- return { success: false, error: text };
15173
+ return { success: false, error: text, studioFastPathUnavailable };
14609
15174
  }
14610
15175
  const w = response.width;
14611
15176
  const h = response.height;
@@ -14659,7 +15224,9 @@ var RobloxStudioTools = class {
14659
15224
  note,
14660
15225
  data: buffer.toString("base64"),
14661
15226
  mimeType,
14662
- message
15227
+ message,
15228
+ source: response.source,
15229
+ studioFastPathUnavailable
14663
15230
  };
14664
15231
  }
14665
15232
  _isUniformCaptureResponse(response) {
@@ -14681,6 +15248,19 @@ var RobloxStudioTools = class {
14681
15248
  }
14682
15249
  // Always restore the simulator, including failures and cached window grabs.
14683
15250
  async _captureViewportFromHostWindow(instanceId, targetRole) {
15251
+ if (isHostCaptureDisabled()) {
15252
+ return { success: false, error: "host window capture is disabled by ROBLOX_STUDIO_HOST_CAPTURE" };
15253
+ }
15254
+ if (!isHostCaptureSupported()) {
15255
+ return { success: false, error: hostCaptureUnsupportedReason() };
15256
+ }
15257
+ if (this.hostWindowCapture === captureStudioWindow) {
15258
+ try {
15259
+ await prepareHostWindowCapture();
15260
+ } catch (error) {
15261
+ return { success: false, error: `could not prepare host capture: ${error instanceof Error ? error.message : String(error)}` };
15262
+ }
15263
+ }
14684
15264
  let prepared;
14685
15265
  let result;
14686
15266
  try {
@@ -14734,9 +15314,12 @@ var RobloxStudioTools = class {
14734
15314
  const viewportWidth = prepared.viewportWidth ?? sizeInfo.viewportWidth;
14735
15315
  const viewportHeight = prepared.viewportHeight ?? sizeInfo.viewportHeight;
14736
15316
  if (cached && Date.now() - cached.cachedAt < HOST_VIEWPORT_RECT_TTL_MS && cached.viewportWidth === viewportWidth && cached.viewportHeight === viewportHeight) {
14737
- const grab = await this.hostWindowCapture(titleHint);
15317
+ const grab = await this.hostWindowCapture(titleHint, cached.identity);
14738
15318
  if (!grab.ok)
14739
15319
  return { success: false, error: grab.error };
15320
+ if (cached.identity !== void 0 && !sameHostWindowIdentity(grab.capture.identity, cached.identity)) {
15321
+ return { success: false, error: "the Studio window identity changed since locating the viewport; retry the capture" };
15322
+ }
14740
15323
  if (grab.capture.width === cached.windowWidth && grab.capture.height === cached.windowHeight) {
14741
15324
  return {
14742
15325
  success: true,
@@ -14772,21 +15355,25 @@ var RobloxStudioTools = class {
14772
15355
  });
14773
15356
  if ("error" in located)
14774
15357
  return { success: false, error: located.error };
15358
+ const cleanGrab = await this.hostWindowCapture(titleHint, markerGrab.capture.identity);
15359
+ if (!cleanGrab.ok)
15360
+ return { success: false, error: cleanGrab.error };
15361
+ if (markerGrab.capture.identity !== void 0 && !sameHostWindowIdentity(cleanGrab.capture.identity, markerGrab.capture.identity)) {
15362
+ return { success: false, error: "the Studio window identity changed while locating the viewport; retry the capture" };
15363
+ }
15364
+ if (cleanGrab.capture.width !== markerGrab.capture.width || cleanGrab.capture.height !== markerGrab.capture.height) {
15365
+ this.hostViewportRects.delete(cacheKey);
15366
+ return { success: false, error: "the Studio window was resized while locating the viewport; retry the capture" };
15367
+ }
14775
15368
  this.hostViewportRects.set(cacheKey, {
14776
15369
  rect: located.rect,
15370
+ identity: markerGrab.capture.identity,
14777
15371
  windowWidth: markerGrab.capture.width,
14778
15372
  windowHeight: markerGrab.capture.height,
14779
15373
  viewportWidth,
14780
15374
  viewportHeight,
14781
15375
  cachedAt: Date.now()
14782
15376
  });
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
15377
  return {
14791
15378
  success: true,
14792
15379
  response: this._hostResponseFromCrop(cleanGrab.capture.rgba, cleanGrab.capture.width, cleanGrab.capture.height, located.rect, viewportWidth, viewportHeight)
@@ -14812,7 +15399,7 @@ var RobloxStudioTools = class {
14812
15399
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
14813
15400
  const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
14814
15401
  if (!capture.success) {
14815
- return this._textResult({ error: capture.error });
15402
+ return this._textResult({ error: capture.error, studioFastPathUnavailable: capture.studioFastPathUnavailable });
14816
15403
  }
14817
15404
  return {
14818
15405
  content: [
@@ -14824,7 +15411,9 @@ var RobloxStudioTools = class {
14824
15411
  format: capture.format,
14825
15412
  mimeType: capture.mimeType,
14826
15413
  ...capture.quality === void 0 ? {} : { quality: capture.quality },
14827
- message: capture.message
15414
+ message: capture.message,
15415
+ source: capture.source,
15416
+ studioFastPathUnavailable: capture.studioFastPathUnavailable
14828
15417
  })
14829
15418
  },
14830
15419
  {
@@ -14974,6 +15563,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14974
15563
  const response = await fetch(`${this.primaryBaseUrl}/create-multiplayer-group`, {
14975
15564
  method: "POST",
14976
15565
  headers: this.authHeaders({ "Content-Type": "application/json" }),
15566
+ signal: AbortSignal.timeout(5e3),
14977
15567
  body: JSON.stringify({ groupId, controllerInstanceId })
14978
15568
  });
14979
15569
  if (!response.ok) {
@@ -15004,6 +15594,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
15004
15594
  const response = await fetch(`${this.primaryBaseUrl}/remove-multiplayer-group`, {
15005
15595
  method: "POST",
15006
15596
  headers: this.authHeaders({ "Content-Type": "application/json" }),
15597
+ signal: AbortSignal.timeout(5e3),
15007
15598
  body: JSON.stringify({ groupId })
15008
15599
  });
15009
15600
  if (response.status === 409) {
@@ -15035,6 +15626,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
15035
15626
  const response = await fetch(`${this.primaryBaseUrl}/unregister-instance-id`, {
15036
15627
  method: "POST",
15037
15628
  headers: this.authHeaders({ "Content-Type": "application/json" }),
15629
+ signal: AbortSignal.timeout(5e3),
15038
15630
  body: JSON.stringify({ instanceId })
15039
15631
  });
15040
15632
  if (!response.ok) {
@@ -15064,7 +15656,8 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
15064
15656
  }
15065
15657
  async getRequestStatusEverywhere(requestId) {
15066
15658
  const response = await fetch(`${this.primaryBaseUrl}/request-status?requestId=${encodeURIComponent(requestId)}`, {
15067
- headers: this.authHeaders()
15659
+ headers: this.authHeaders(),
15660
+ signal: AbortSignal.timeout(5e3)
15068
15661
  });
15069
15662
  if (!response.ok)
15070
15663
  throw new Error(`Proxy request status failed (${response.status})`);
@@ -15369,8 +15962,8 @@ var BoundedStdioTransport = class {
15369
15962
  this.onStreamError(error);
15370
15963
  return Promise.reject(error);
15371
15964
  }
15372
- const { promise, resolve: resolve5, reject } = Promise.withResolvers();
15373
- const pending = { resolve: resolve5, reject };
15965
+ const { promise, resolve: resolve6, reject } = Promise.withResolvers();
15966
+ const pending = { resolve: resolve6, reject };
15374
15967
  this.pendingSends.add(pending);
15375
15968
  this.pendingWrites++;
15376
15969
  try {
@@ -15382,7 +15975,7 @@ var BoundedStdioTransport = class {
15382
15975
  });
15383
15976
  if (writable) {
15384
15977
  this.pendingSends.delete(pending);
15385
- resolve5();
15978
+ resolve6();
15386
15979
  } else if (!this.closed) {
15387
15980
  this.outputBlocked = true;
15388
15981
  this.input.pause();
@@ -15481,6 +16074,7 @@ var RobloxStudioMCPServer = class {
15481
16074
  try {
15482
16075
  const result = await listenWithRetry(candidateApp, host, basePort, 1);
15483
16076
  const oldBridge = this.bridge;
16077
+ const oldTools = this.tools;
15484
16078
  this.bridge = candidateBridge;
15485
16079
  this.tools = candidateTools;
15486
16080
  if (oldBridge instanceof ProxyBridgeService) {
@@ -15494,6 +16088,7 @@ var RobloxStudioMCPServer = class {
15494
16088
  console.error(`Promoted from proxy to primary on port ${boundPort}`);
15495
16089
  if (promotionInterval)
15496
16090
  clearInterval(promotionInterval);
16091
+ await oldTools.dispose();
15497
16092
  } catch {
15498
16093
  await candidateApp.cleanup().catch(() => {
15499
16094
  });
@@ -15558,6 +16153,7 @@ var RobloxStudioMCPServer = class {
15558
16153
  });
15559
16154
  if (httpHandle)
15560
16155
  httpHandle.close();
16156
+ await this.tools.dispose();
15561
16157
  process.exit(0);
15562
16158
  };
15563
16159
  process.on("SIGTERM", shutdown);
@@ -17219,7 +17815,7 @@ var TOOL_DEFINITIONS = [
17219
17815
  properties: {
17220
17816
  source: {
17221
17817
  type: "object",
17222
- description: "Exactly one path, URL, or base64 source; URLs cap at 50 MiB.",
17818
+ 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
17819
  properties: {
17224
17820
  path: { type: "string", description: "Absolute local .rbxm path." },
17225
17821
  url: { type: "string", description: "HTTP or HTTPS .rbxm URL." },
@@ -17348,10 +17944,10 @@ var getAllTools = () => [...TOOL_DEFINITIONS];
17348
17944
 
17349
17945
  // ../core/dist/install-plugin-helpers.js
17350
17946
  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";
17947
+ import { existsSync as existsSync7, lstatSync as lstatSync2, mkdirSync as mkdirSync4, readFileSync as readFileSync8, renameSync, rmSync as rmSync4, rmdirSync, statSync as statSync3, unlinkSync as unlinkSync2, writeFileSync as writeFileSync4 } from "fs";
17352
17948
  import { execSync } from "child_process";
17353
17949
  import { randomUUID as randomUUID5 } from "crypto";
17354
- import { basename as basename3, join as join6 } from "path";
17950
+ import { basename as basename3, join as join8 } from "path";
17355
17951
  import { homedir as homedir4 } from "os";
17356
17952
  import { isUtf8 } from "buffer";
17357
17953
  var DEFAULT_PLUGIN_PORT = 58741;
@@ -17403,15 +17999,15 @@ function getPluginsFolder() {
17403
17999
  if (process.env.MCP_PLUGINS_DIR)
17404
18000
  return process.env.MCP_PLUGINS_DIR;
17405
18001
  if (process.platform === "win32") {
17406
- return join6(process.env.LOCALAPPDATA || join6(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
18002
+ return join8(process.env.LOCALAPPDATA || join8(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
17407
18003
  }
17408
18004
  if (isWSL()) {
17409
18005
  const localAppData = getWindowsUserLocalAppData();
17410
18006
  if (localAppData)
17411
- return join6(localAppData.linuxPath, "Roblox", "Plugins");
18007
+ return join8(localAppData.linuxPath, "Roblox", "Plugins");
17412
18008
  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
18009
  }
17414
- return join6(homedir4(), "Documents", "Roblox", "Plugins");
18010
+ return join8(homedir4(), "Documents", "Roblox", "Plugins");
17415
18011
  }
17416
18012
  var STALE_TEST_PLUGINS_DIRECTORY = "RsmcpIsolatedPlugins";
17417
18013
  function pluginDirectorySettingPattern() {
@@ -17426,7 +18022,7 @@ function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDi
17426
18022
  if (studioPluginsDirectory === "") {
17427
18023
  throw new Error("studioPluginsDirectory must be a non-empty path.");
17428
18024
  }
17429
- const contents = readFileSync7(settingsPath, "utf8");
18025
+ const contents = readFileSync8(settingsPath, "utf8");
17430
18026
  const matches = [...contents.matchAll(pluginDirectorySettingPattern())];
17431
18027
  if (matches.length !== 1) {
17432
18028
  throw new Error(`Expected exactly one Studio.PluginsDir QDir in ${settingsPath}; found ${matches.length}.`);
@@ -17434,16 +18030,16 @@ function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDi
17434
18030
  if (matches[0][2] !== STALE_TEST_PLUGINS_DIRECTORY)
17435
18031
  return false;
17436
18032
  const studioPath = escapeXmlText(studioPluginsDirectory);
17437
- const updated = contents.replace(pluginDirectorySettingPattern(), (_match, open2, _value, close) => `${open2}${studioPath}${close}`);
18033
+ const updated = contents.replace(pluginDirectorySettingPattern(), (_match, open4, _value, close) => `${open4}${studioPath}${close}`);
17438
18034
  const temporaryPath = `${settingsPath}.rsmcp-install-${process.pid}-${randomUUID5()}.tmp`;
17439
18035
  try {
17440
- writeFileSync3(temporaryPath, updated, {
18036
+ writeFileSync4(temporaryPath, updated, {
17441
18037
  encoding: "utf8",
17442
18038
  mode: statSync3(settingsPath).mode
17443
18039
  });
17444
18040
  renameSync(temporaryPath, settingsPath);
17445
18041
  } finally {
17446
- rmSync3(temporaryPath, { force: true });
18042
+ rmSync4(temporaryPath, { force: true });
17447
18043
  }
17448
18044
  return true;
17449
18045
  }
@@ -17454,16 +18050,16 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
17454
18050
  let defaultPluginsFolder;
17455
18051
  let studioPluginsDirectory;
17456
18052
  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");
18053
+ const localAppData = process.env.LOCALAPPDATA || join8(homedir4(), "AppData", "Local");
18054
+ defaultPluginsFolder = join8(localAppData, "Roblox", "Plugins");
18055
+ settingsPath = join8(localAppData, "Roblox", "GlobalSettings_13.xml");
17460
18056
  studioPluginsDirectory = defaultPluginsFolder.replaceAll("\\", "/");
17461
18057
  } else if (isWSL()) {
17462
18058
  const localAppData = getWindowsUserLocalAppData();
17463
18059
  if (!localAppData)
17464
18060
  return;
17465
- defaultPluginsFolder = join6(localAppData.linuxPath, "Roblox", "Plugins");
17466
- settingsPath = join6(localAppData.linuxPath, "Roblox", "GlobalSettings_13.xml");
18061
+ defaultPluginsFolder = join8(localAppData.linuxPath, "Roblox", "Plugins");
18062
+ settingsPath = join8(localAppData.linuxPath, "Roblox", "GlobalSettings_13.xml");
17467
18063
  studioPluginsDirectory = [
17468
18064
  localAppData.windowsPath.replace(/[\\/]+$/u, ""),
17469
18065
  "Roblox",
@@ -17479,12 +18075,12 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
17479
18075
  }
17480
18076
  }
17481
18077
  function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
17482
- const otherDest = join6(pluginsFolder, otherAssetName);
18078
+ const otherDest = join8(pluginsFolder, otherAssetName);
17483
18079
  if (!existsSync7(otherDest))
17484
18080
  return;
17485
18081
  if (replace) {
17486
18082
  try {
17487
- unlinkSync(otherDest);
18083
+ unlinkSync2(otherDest);
17488
18084
  log(`Removed conflicting ${otherAssetName}.`);
17489
18085
  } catch (err2) {
17490
18086
  warn(`[install-plugin] Could not remove ${otherDest}: ${err2}. Continuing.`);
@@ -17505,11 +18101,11 @@ function pluginInstallLockError(lockPath, reason) {
17505
18101
  return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
17506
18102
  }
17507
18103
  function readPluginInstallLockOwner(lockPath) {
17508
- const ownerPath = join6(lockPath, "owner.json");
17509
- if (!lstatSync(lockPath).isDirectory() || !lstatSync(ownerPath).isFile()) {
18104
+ const ownerPath = join8(lockPath, "owner.json");
18105
+ if (!lstatSync2(lockPath).isDirectory() || !lstatSync2(ownerPath).isFile()) {
17510
18106
  throw new Error("Lock and owner metadata must be a directory and regular file");
17511
18107
  }
17512
- const owner = JSON.parse(readFileSync7(ownerPath, "utf8"));
18108
+ const owner = JSON.parse(readFileSync8(ownerPath, "utf8"));
17513
18109
  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
18110
  throw new Error("Invalid lock owner metadata");
17515
18111
  }
@@ -17535,7 +18131,7 @@ function recoverPluginInstallLock(lockPath, warn) {
17535
18131
  throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
17536
18132
  }
17537
18133
  assertPluginInstallOwnerExited(lockPath, owner);
17538
- const claimPath = join6(lockPath, `recovery-${owner.token}`);
18134
+ const claimPath = join8(lockPath, `recovery-${owner.token}`);
17539
18135
  try {
17540
18136
  mkdirSync4(claimPath);
17541
18137
  } catch (error) {
@@ -17568,14 +18164,14 @@ function recoverPluginInstallLock(lockPath, warn) {
17568
18164
  }
17569
18165
  }
17570
18166
  try {
17571
- rmSync3(abandonedPath, { recursive: true, force: true });
18167
+ rmSync4(abandonedPath, { recursive: true, force: true });
17572
18168
  } catch (error) {
17573
18169
  warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
17574
18170
  }
17575
18171
  }
17576
18172
  function acquirePluginInstallLock(pluginsFolder, warn) {
17577
- const lockPath = join6(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
17578
- const ownerPath = join6(lockPath, "owner.json");
18173
+ const lockPath = join8(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
18174
+ const ownerPath = join8(lockPath, "owner.json");
17579
18175
  const owner = {
17580
18176
  pid: process.pid,
17581
18177
  token: randomUUID5(),
@@ -17596,14 +18192,14 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
17596
18192
  }
17597
18193
  }
17598
18194
  try {
17599
- writeFileSync3(ownerPath, `${JSON.stringify(owner)}
18195
+ writeFileSync4(ownerPath, `${JSON.stringify(owner)}
17600
18196
  `, {
17601
18197
  encoding: "utf8",
17602
18198
  flag: "wx"
17603
18199
  });
17604
18200
  } catch (error) {
17605
18201
  try {
17606
- rmSync3(lockPath, { recursive: true, force: true });
18202
+ rmSync4(lockPath, { recursive: true, force: true });
17607
18203
  } catch (cleanupError) {
17608
18204
  warn(`[install-plugin] Could not clean failed install lock ${lockPath}: ${cleanupError}`);
17609
18205
  }
@@ -17613,7 +18209,7 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
17613
18209
  try {
17614
18210
  const currentOwner = readPluginInstallLockOwner(lockPath);
17615
18211
  if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
17616
- rmSync3(lockPath, { recursive: true, force: true });
18212
+ rmSync4(lockPath, { recursive: true, force: true });
17617
18213
  }
17618
18214
  } catch (error) {
17619
18215
  if (errorCode(error) !== "ENOENT") {
@@ -17707,9 +18303,9 @@ function assertPluginAssetIdentity(source, assetName, expectedVersion, expectedV
17707
18303
  function installedFileMatches(source, destination) {
17708
18304
  if (!existsSync7(destination))
17709
18305
  return false;
17710
- if (!lstatSync(destination).isFile())
18306
+ if (!lstatSync2(destination).isFile())
17711
18307
  return false;
17712
- const installed = readFileSync7(destination);
18308
+ const installed = readFileSync8(destination);
17713
18309
  return installed.length === source.length && installed.equals(source);
17714
18310
  }
17715
18311
  function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source, expectedVersion, expectedVariant, rawPort, replaceVariant = true, log = console.log, warn = console.warn }) {
@@ -17721,19 +18317,19 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
17721
18317
  const configured = configurePluginAssetForPort(source, rawPort);
17722
18318
  assertPluginAssetIdentity(configured, assetName, expectedVersion, expectedVariant);
17723
18319
  mkdirSync4(pluginsFolder, { recursive: true });
17724
- const destination = join6(pluginsFolder, assetName);
18320
+ const destination = join8(pluginsFolder, assetName);
17725
18321
  const releaseLock = acquirePluginInstallLock(pluginsFolder, warn);
17726
18322
  try {
17727
18323
  let installed = false;
17728
18324
  if (!installedFileMatches(configured, destination)) {
17729
- const staged = join6(pluginsFolder, `.${assetName}.${process.pid}.${randomUUID5()}.tmp`);
18325
+ const staged = join8(pluginsFolder, `.${assetName}.${process.pid}.${randomUUID5()}.tmp`);
17730
18326
  try {
17731
- writeFileSync3(staged, configured, { flag: "wx" });
18327
+ writeFileSync4(staged, configured, { flag: "wx" });
17732
18328
  renameSync(staged, destination);
17733
18329
  installed = true;
17734
18330
  } finally {
17735
18331
  try {
17736
- unlinkSync(staged);
18332
+ unlinkSync2(staged);
17737
18333
  } catch (error) {
17738
18334
  if (errorCode(error) !== "ENOENT") {
17739
18335
  warn(`[install-plugin] Could not remove staging file ${staged}: ${error}`);
@@ -17755,175 +18351,156 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
17755
18351
  }
17756
18352
  }
17757
18353
 
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";
18354
+ // ../core/dist/plugin-installer.js
18355
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "fs";
18356
+ import { dirname as dirname5, join as join9 } from "path";
17764
18357
  import { fileURLToPath } from "url";
17765
18358
  import { get } from "https";
17766
18359
  var REPO = "chrrxs/robloxstudio-mcp";
17767
- var ASSET_NAME = "MCPPlugin.rbxmx";
17768
- var OTHER_VARIANT = "MCPInspectorPlugin.rbxmx";
17769
18360
  var TIMEOUT_MS = 3e4;
17770
18361
  var MAX_REDIRECTS = 5;
17771
18362
  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`));
18363
+ function createPluginInstaller(moduleUrl, variant) {
18364
+ const currentDir = dirname5(fileURLToPath(moduleUrl));
18365
+ const ASSET_NAME = variant === "main" ? "MCPPlugin.rbxmx" : "MCPInspectorPlugin.rbxmx";
18366
+ const OTHER_VARIANT = variant === "main" ? "MCPInspectorPlugin.rbxmx" : "MCPPlugin.rbxmx";
18367
+ const userAgent = variant === "main" ? "robloxstudio-mcp" : "robloxstudio-mcp-inspector";
18368
+ const buildCommand = variant === "main" ? "build:plugin" : "build:plugin:inspector";
18369
+ function httpsGet(url) {
18370
+ return new Promise((resolve6, reject) => {
18371
+ const req = get(url, { headers: { "User-Agent": userAgent } }, resolve6);
18372
+ req.on("error", reject);
18373
+ req.setTimeout(TIMEOUT_MS, () => {
18374
+ req.destroy(new Error(`Request timed out after ${TIMEOUT_MS}ms`));
18375
+ });
17778
18376
  });
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
18377
  }
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
- );
18378
+ async function download(url, redirects = 0) {
18379
+ const res = await httpsGet(url);
18380
+ if (res.statusCode === 301 || res.statusCode === 302) {
18381
+ if (redirects >= MAX_REDIRECTS)
18382
+ throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
18383
+ const location = res.headers.location;
18384
+ if (!location)
18385
+ throw new Error("Redirect with no location header");
18386
+ for await (const chunk of res) {
18387
+ void chunk;
18388
+ }
18389
+ return download(location, redirects + 1);
18390
+ }
18391
+ if (res.statusCode !== 200) {
18392
+ throw new Error(`Download failed: HTTP ${res.statusCode}`);
17804
18393
  }
17805
- chunks.push(bytes);
18394
+ const chunks = [];
18395
+ let totalBytes = 0;
18396
+ for await (const chunk of res) {
18397
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
18398
+ totalBytes += bytes.length;
18399
+ if (totalBytes > MAX_ASSET_BYTES) {
18400
+ res.destroy();
18401
+ throw new Error(`${ASSET_NAME} download exceeds the ${MAX_ASSET_BYTES}-byte limit.`);
18402
+ }
18403
+ chunks.push(bytes);
18404
+ }
18405
+ return Buffer.concat(chunks, totalBytes);
17806
18406
  }
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}`);
18407
+ async function fetchJson(url) {
18408
+ const res = await httpsGet(url);
18409
+ if (res.statusCode !== 200) {
18410
+ throw new Error(`GitHub API returned HTTP ${res.statusCode}`);
18411
+ }
18412
+ const chunks = [];
18413
+ for await (const chunk of res) {
18414
+ chunks.push(chunk);
18415
+ }
18416
+ return JSON.parse(Buffer.concat(chunks).toString());
17813
18417
  }
17814
- const chunks = [];
17815
- for await (const chunk of res) {
17816
- chunks.push(chunk);
18418
+ function bundledAssetPath() {
18419
+ const candidates = [
18420
+ join9(currentDir, "..", "studio-plugin", ASSET_NAME),
18421
+ join9(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
18422
+ ];
18423
+ return candidates.find((candidate) => existsSync8(candidate)) ?? null;
17817
18424
  }
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}`);
17827
- }
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");
18425
+ function resolvePluginAssetPath(sourcePath) {
18426
+ if (sourcePath === void 0)
18427
+ return bundledAssetPath();
18428
+ return existsSync8(sourcePath) ? sourcePath : null;
17847
18429
  }
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}`);
18430
+ function packageVersion() {
18431
+ const pkg = JSON.parse(readFileSync9(join9(currentDir, "..", "package.json"), "utf8"));
18432
+ if (!pkg.version) {
18433
+ throw new Error("Package version not found");
18434
+ }
18435
+ return pkg.version;
17873
18436
  }
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({
18437
+ function installSource(source, options) {
18438
+ return installPluginAsset({
17886
18439
  pluginsFolder: getPluginsFolder(),
17887
18440
  assetName: ASSET_NAME,
17888
18441
  otherAssetName: OTHER_VARIANT,
17889
- source: readFileSync8(bundled),
18442
+ source,
17890
18443
  expectedVersion: packageVersion(),
17891
- expectedVariant: "main",
17892
- replaceVariant,
17893
- log,
17894
- warn
18444
+ expectedVariant: variant,
18445
+ replaceVariant: options.replaceVariant ?? true,
18446
+ log: options.log ?? console.log,
18447
+ warn: options.warn ?? console.warn
17895
18448
  });
17896
- if (result2.installed) {
17897
- log(`Installed bundled ${ASSET_NAME} to ${result2.destination}`);
18449
+ }
18450
+ async function installBundledPlugin2(options = {}) {
18451
+ const log = options.log ?? console.log;
18452
+ const sourcePath = resolvePluginAssetPath(options.sourcePath);
18453
+ if (!sourcePath) {
18454
+ throw new Error(`Bundled ${ASSET_NAME} not found. Run npm run ${buildCommand} in this worktree first.`);
18455
+ }
18456
+ const result = installSource(readFileSync9(sourcePath), options);
18457
+ if (result.installed) {
18458
+ log(`Installed ${ASSET_NAME} to ${result.destination}`);
18459
+ }
18460
+ }
18461
+ async function installPlugin2(options = {}, dev = false) {
18462
+ const log = options.log ?? console.log;
18463
+ const bundled = resolvePluginAssetPath(options.sourcePath);
18464
+ if (options.sourcePath !== void 0 && !bundled) {
18465
+ throw new Error(`Plugin asset not found at explicit path ${options.sourcePath}.`);
18466
+ }
18467
+ if (bundled) {
18468
+ const result2 = installSource(readFileSync9(bundled), options);
18469
+ if (result2.installed) {
18470
+ log(`Installed bundled ${ASSET_NAME} to ${result2.destination}`);
18471
+ } else {
18472
+ log(`${ASSET_NAME} already installed.`);
18473
+ }
18474
+ return;
18475
+ }
18476
+ log(dev ? "Fetching matching dev release..." : "Fetching matching release...");
18477
+ const release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/tags/v${encodeURIComponent(packageVersion())}`);
18478
+ const asset = release.assets?.find((a) => a.name === ASSET_NAME);
18479
+ if (!asset) {
18480
+ throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
18481
+ }
18482
+ log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
18483
+ const downloaded = await download(asset.browser_download_url);
18484
+ const result = installSource(downloaded, options);
18485
+ if (result.installed) {
18486
+ log(`Installed to ${result.destination}`);
17898
18487
  } else {
17899
18488
  log(`${ASSET_NAME} already installed.`);
17900
18489
  }
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
18490
  }
18491
+ return { installBundledPlugin: installBundledPlugin2, installPlugin: installPlugin2 };
18492
+ }
18493
+
18494
+ // src/index.ts
18495
+ import { createRequire } from "module";
18496
+
18497
+ // src/install-plugin.ts
18498
+ var installer = createPluginInstaller(import.meta.url, "main");
18499
+ async function installBundledPlugin(options = {}) {
18500
+ await installer.installBundledPlugin(options);
18501
+ }
18502
+ async function installPlugin(options = {}) {
18503
+ await installer.installPlugin(options, options.dev ?? process.argv.includes("--dev"));
17927
18504
  }
17928
18505
 
17929
18506
  // src/index.ts