@chrrxs/robloxstudio-mcp 3.1.4 → 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,
@@ -2869,7 +2869,7 @@ var BridgeService = class {
2869
2869
  operation.status.executionStartedAt = now;
2870
2870
  else {
2871
2871
  operation.status.executionOutcome = outcome ?? "unknown";
2872
- if (outcome !== "not_executed")
2872
+ if (outcome === "success" || outcome === "error")
2873
2873
  operation.status.executionCompletedAt = now;
2874
2874
  }
2875
2875
  operation.updatedAt = now;
@@ -2885,7 +2885,7 @@ var BridgeService = class {
2885
2885
  requestId,
2886
2886
  targetPeerId: operation.status.targetPeerId,
2887
2887
  ...observations(operation.status),
2888
- executionCompletedAt: executionOutcome === "not_executed" ? void 0 : operation.status.executionCompletedAt ?? Date.now(),
2888
+ executionCompletedAt: executionOutcome === "success" || executionOutcome === "error" ? operation.status.executionCompletedAt ?? Date.now() : void 0,
2889
2889
  stage: "response_delivery",
2890
2890
  outcome: executionOutcome === "not_executed" ? "not_executed" : "unknown",
2891
2891
  executionOutcome
@@ -2913,13 +2913,15 @@ var BridgeService = class {
2913
2913
  operation.status.state = "settled";
2914
2914
  const localRejection = error instanceof RequestFailure && error.details.transportStage === "server_send";
2915
2915
  const responseOutcome = handlerOutcome(response);
2916
- const completedOutcome = !hasError && responseOutcome === "error" ? "error" : executionOutcome ?? (localRejection ? "not_executed" : hasError ? operation.status.executionOutcome === "success" ? "success" : "error" : responseOutcome);
2916
+ const completedOutcome = executionOutcome === "unknown" || executionOutcome === "not_executed" ? executionOutcome : !hasError && responseOutcome === "error" ? "error" : executionOutcome ?? (localRejection ? "not_executed" : hasError ? operation.status.executionOutcome === "success" ? "success" : "error" : responseOutcome);
2917
2917
  operation.status.executionOutcome = completedOutcome;
2918
- operation.status.outcome = hasError || completedOutcome === "error" || completedOutcome === "not_executed" ? "error" : "success";
2918
+ operation.status.outcome = hasError || responseOutcome === "error" || completedOutcome === "error" || completedOutcome === "not_executed" ? "error" : "success";
2919
2919
  if (!localRejection) {
2920
2920
  operation.status.stage = "response_delivery";
2921
- if (completedOutcome !== "not_executed")
2921
+ if (completedOutcome === "success" || completedOutcome === "error")
2922
2922
  operation.status.executionCompletedAt ??= now;
2923
+ else
2924
+ delete operation.status.executionCompletedAt;
2923
2925
  }
2924
2926
  operation.status.settledAt = now;
2925
2927
  operation.updatedAt = now;
@@ -3125,19 +3127,20 @@ async function fetchDocCatalog() {
3125
3127
  }
3126
3128
  const controller = new AbortController();
3127
3129
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
3128
- let response;
3130
+ let html;
3129
3131
  try {
3130
- response = await fetch(DOCS_INDEX_URL, {
3132
+ const response = await fetch(DOCS_INDEX_URL, {
3131
3133
  signal: controller.signal,
3132
3134
  headers: { Accept: "text/html" }
3133
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();
3134
3141
  } finally {
3135
3142
  clearTimeout(timer);
3136
3143
  }
3137
- if (!response.ok) {
3138
- throw new Error(`Failed to fetch Roblox docs index: HTTP ${response.status}`);
3139
- }
3140
- const html = await response.text();
3141
3144
  const pages = /* @__PURE__ */ new Map();
3142
3145
  const routePattern = /reference\/engine\/(classes|enums|datatypes|libraries|globals)\/([A-Za-z0-9_]+)/g;
3143
3146
  for (const match of html.matchAll(routePattern)) {
@@ -3190,27 +3193,30 @@ async function fetchRobloxDoc(category, name) {
3190
3193
  }
3191
3194
  const controller = new AbortController();
3192
3195
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
3193
- let response;
3194
3196
  try {
3195
- response = await fetch(docUrl(category, name), {
3197
+ const response = await fetch(docUrl(category, name), {
3196
3198
  signal: controller.signal,
3197
3199
  headers: { Accept: "text/markdown, text/plain" }
3198
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;
3199
3213
  } catch (error) {
3214
+ if (error instanceof DocNotFoundError)
3215
+ throw error;
3200
3216
  throw new Error(`Failed to fetch Roblox docs for ${key}: ${error instanceof Error ? error.message : String(error)}`);
3201
3217
  } finally {
3202
3218
  clearTimeout(timer);
3203
3219
  }
3204
- if (response.status === 404) {
3205
- cacheSet(key, { fetchedAt: Date.now(), notFound: true });
3206
- throw new DocNotFoundError(category, name);
3207
- }
3208
- if (!response.ok) {
3209
- throw new Error(`Failed to fetch Roblox docs for ${key}: HTTP ${response.status}`);
3210
- }
3211
- const content = await response.text();
3212
- cacheSet(key, { fetchedAt: Date.now(), content });
3213
- return content;
3214
3220
  }
3215
3221
  function listSections(markdown) {
3216
3222
  const sections = [];
@@ -3322,6 +3328,10 @@ execute_luau runs through the Studio plugin. eval_server_runtime and eval_client
3322
3328
 
3323
3329
  Read output with get_runtime_logs. Reuse nextCursor as cursor for one Instance, or nextCursorByInstance as cursor_by_instance for a Multiplayer Group, instead of requesting the full process streams again.
3324
3330
 
3331
+ - Each Peer VM retains approximately 64 KiB of message text, rather than a fixed message count. Reads do not consume or clear the buffer; incoming messages evict old entries.
3332
+ - totalDropped sums lifetime eviction counts from successfully read Peers. It counts messages evicted, not messages missed by this caller, and may decrease when Peers fail, disconnect, or reload.
3333
+ - For successfully read Peers, nextCursor advances past all captured entries, including those omitted by filter or tail. It is a continuation watermark, not pagination through omitted results; continuing from it will not return those entries.
3334
+
3325
3335
  ## Simulation and input
3326
3336
 
3327
3337
  - Inspect current settings with get_simulation_state before changing them.
@@ -3408,14 +3418,100 @@ import { execFile, execFileSync as execFileSync2, spawn } from "child_process";
3408
3418
  import { copyFileSync, existsSync as existsSync2, mkdirSync, readdirSync, readFileSync as readFileSync2, realpathSync, rmSync, statSync } from "fs";
3409
3419
  import { randomUUID as randomUUID3 } from "crypto";
3410
3420
  import * as os2 from "os";
3411
- import * as path2 from "path";
3421
+ import * as path3 from "path";
3412
3422
  import { promisify } from "util";
3413
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
+
3414
3510
  // ../core/dist/managed-instance-registry.js
3415
3511
  import * as fs from "fs/promises";
3416
3512
  import { randomUUID as randomUUID2 } from "crypto";
3417
3513
  import * as os from "os";
3418
- import * as path from "path";
3514
+ import * as path2 from "path";
3419
3515
  import { setTimeout as delayTimer } from "timers/promises";
3420
3516
  var REGISTRY_VERSION = 1;
3421
3517
  var LOCK_STALE_MS = 1e4;
@@ -3432,14 +3528,14 @@ function defaultManagedInstanceRegistryDir() {
3432
3528
  return process.env.ROBLOXSTUDIO_MCP_MANAGED_INSTANCE_REGISTRY_DIR;
3433
3529
  }
3434
3530
  if (process.platform === "win32" && process.env.LOCALAPPDATA) {
3435
- return path.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", "managed-instances", "v1");
3531
+ return path2.join(process.env.LOCALAPPDATA, "robloxstudio-mcp", "managed-instances", "v1");
3436
3532
  }
3437
3533
  if (process.platform === "darwin") {
3438
- 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");
3439
3535
  }
3440
- 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");
3441
3537
  }
3442
- function delay(ms) {
3538
+ function delay2(ms) {
3443
3539
  return delayTimer(ms, void 0, { ref: false });
3444
3540
  }
3445
3541
  function ymd(timestamp) {
@@ -3534,7 +3630,7 @@ var ManagedInstanceRegistry = class {
3534
3630
  }
3535
3631
  async withLock(fn) {
3536
3632
  await this.ensureDir();
3537
- const lockDir = path.join(this.dir, ".lock");
3633
+ const lockDir = path2.join(this.dir, ".lock");
3538
3634
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
3539
3635
  const owner = {
3540
3636
  pid: process.pid,
@@ -3547,7 +3643,7 @@ var ManagedInstanceRegistry = class {
3547
3643
  activeLockTokens.add(owner.token);
3548
3644
  activeLockPaths.add(lockDir);
3549
3645
  try {
3550
- await fs.writeFile(path.join(lockDir, "owner.json"), `${JSON.stringify(owner)}
3646
+ await fs.writeFile(path2.join(lockDir, "owner.json"), `${JSON.stringify(owner)}
3551
3647
  `, {
3552
3648
  encoding: "utf8",
3553
3649
  flag: "wx"
@@ -3577,7 +3673,7 @@ var ManagedInstanceRegistry = class {
3577
3673
  if (Date.now() > deadline) {
3578
3674
  throw new Error(`Timed out waiting for managed instance registry lock: ${lockDir}`);
3579
3675
  }
3580
- await delay(LOCK_RETRY_MS);
3676
+ await delay2(LOCK_RETRY_MS);
3581
3677
  }
3582
3678
  }
3583
3679
  try {
@@ -3598,7 +3694,7 @@ var ManagedInstanceRegistry = class {
3598
3694
  }
3599
3695
  async readLockOwner(lockPath, isDirectory) {
3600
3696
  try {
3601
- 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");
3602
3698
  const parsed = JSON.parse(contents);
3603
3699
  return isLockOwner(parsed) ? parsed : void 0;
3604
3700
  } catch {
@@ -3609,10 +3705,10 @@ var ManagedInstanceRegistry = class {
3609
3705
  await fs.mkdir(this.dir, { recursive: true });
3610
3706
  }
3611
3707
  recordPath(recordId) {
3612
- return path.join(this.dir, `${recordId}.json`);
3708
+ return path2.join(this.dir, `${recordId}.json`);
3613
3709
  }
3614
3710
  async recordFilesUnlocked() {
3615
- 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));
3616
3712
  }
3617
3713
  async readRecordUnlocked(recordId) {
3618
3714
  try {
@@ -3641,7 +3737,7 @@ var ManagedInstanceRegistry = class {
3641
3737
  async writeRecordUnlocked(record) {
3642
3738
  await this.ensureDir();
3643
3739
  const finalPath = this.recordPath(record.recordId);
3644
- 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`);
3645
3741
  const fd = await fs.open(tmpPath, "w");
3646
3742
  try {
3647
3743
  await fd.writeFile(`${JSON.stringify(record, null, 2)}
@@ -3656,7 +3752,7 @@ var ManagedInstanceRegistry = class {
3656
3752
  await fs.rm(this.recordPath(recordId), { force: true });
3657
3753
  }
3658
3754
  async appendEventUnlocked(event, now) {
3659
- const file = path.join(this.dir, `events-${ymd(now)}.jsonl`);
3755
+ const file = path2.join(this.dir, `events-${ymd(now)}.jsonl`);
3660
3756
  await fs.appendFile(file, `${JSON.stringify({
3661
3757
  ts: new Date(now).toISOString(),
3662
3758
  ...event
@@ -3670,7 +3766,7 @@ var ManagedInstanceRegistry = class {
3670
3766
  if (!date || date >= cutoff)
3671
3767
  continue;
3672
3768
  try {
3673
- await fs.rm(path.join(this.dir, name), { force: true });
3769
+ await fs.rm(path2.join(this.dir, name), { force: true });
3674
3770
  } catch {
3675
3771
  }
3676
3772
  }
@@ -3922,7 +4018,7 @@ function getStudioPlatformCapabilities() {
3922
4018
  }
3923
4019
 
3924
4020
  // ../core/dist/studio-instance-manager.js
3925
- var BASEPLATE_TEMP_DIR = path2.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
4021
+ var BASEPLATE_TEMP_DIR = path3.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
3926
4022
  var BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
3927
4023
  var BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
3928
4024
  var retainedLaunchControls = /* @__PURE__ */ new Map();
@@ -3950,6 +4046,40 @@ var StudioLaunchPreDispatchError = class extends Error {
3950
4046
  };
3951
4047
  }
3952
4048
  };
4049
+ var StudioCloseVerificationError = class extends Error {
4050
+ observation;
4051
+ constructor(message, observation) {
4052
+ super(message);
4053
+ this.observation = observation;
4054
+ }
4055
+ };
4056
+ async function beforeCloseDeadline(operation, deadline, processId) {
4057
+ const { promise, reject } = Promise.withResolvers();
4058
+ const timeoutError = () => new StudioCloseVerificationError(`Timed out verifying Studio process ${processId} termination; process state is unknown.`, { status: "unknown", observedAt: Date.now(), error: "Close operation deadline exceeded." });
4059
+ if (Date.now() >= deadline)
4060
+ throw timeoutError();
4061
+ const timer = setTimeout(() => reject(timeoutError()), deadline - Date.now());
4062
+ try {
4063
+ return await Promise.race([operation(), promise]);
4064
+ } finally {
4065
+ clearTimeout(timer);
4066
+ }
4067
+ }
4068
+ function observePosixProcess(processId) {
4069
+ try {
4070
+ process.kill(processId, 0);
4071
+ return { status: "running", observedAt: Date.now() };
4072
+ } catch (error) {
4073
+ if (error instanceof Error && "code" in error && error.code === "ESRCH") {
4074
+ return { status: "not_running", observedAt: Date.now(), reason: "missing" };
4075
+ }
4076
+ return {
4077
+ status: "unknown",
4078
+ observedAt: Date.now(),
4079
+ error: error instanceof Error ? error.message : String(error)
4080
+ };
4081
+ }
4082
+ }
3953
4083
  function run(command, args, options = {}) {
3954
4084
  return execFileSync2(command, args, {
3955
4085
  encoding: "utf8",
@@ -3971,9 +4101,10 @@ async function runAsync(command, args, options = {}) {
3971
4101
  function isWsl() {
3972
4102
  return getStudioPlatformCapabilities().isWsl;
3973
4103
  }
3974
- async function powershellAsync(script) {
4104
+ async function powershellAsync(script, timeoutMs = 15e3) {
3975
4105
  return runAsync("powershell.exe", ["-NoProfile", "-Command", script], {
3976
- cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
4106
+ cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
4107
+ timeout: timeoutMs
3977
4108
  });
3978
4109
  }
3979
4110
  function windowsLocalAppData() {
@@ -4013,7 +4144,7 @@ async function toWslPathAsync(windowsPath) {
4013
4144
  return runAsync("wslpath", ["-u", windowsPath]);
4014
4145
  }
4015
4146
  async function toStudioLaunchArgAsync(arg) {
4016
- if (!isWsl() || !path2.isAbsolute(arg) || !existsSync2(arg))
4147
+ if (!isWsl() || !path3.isAbsolute(arg) || !existsSync2(arg))
4017
4148
  return arg;
4018
4149
  return runAsync("wslpath", ["-w", arg]);
4019
4150
  }
@@ -4108,8 +4239,15 @@ function quoteWindowsCommandLineArg(value) {
4108
4239
  }
4109
4240
  return `${quoted}${"\\".repeat(backslashes * 2)}"`;
4110
4241
  }
4111
- function buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory) {
4242
+ function parseStudioTestWorkerJobName(name) {
4243
+ if (name !== void 0 && !/^Local\\RsmcpStudioWorker-[a-f0-9]{32}$/u.test(name)) {
4244
+ throw new Error("Invalid Studio test worker job name.");
4245
+ }
4246
+ return name;
4247
+ }
4248
+ function buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory, testWorkerJobName = process.env.RSMCP_STUDIO_TEST_WORKER_JOB) {
4112
4249
  const environmentPatch = parseStudioProcessEnvironmentPatch(processEnvironment);
4250
+ parseStudioTestWorkerJobName(testWorkerJobName);
4113
4251
  const commandLine = [windowsExe, ...args].map(quoteWindowsCommandLineArg).join(" ");
4114
4252
  return [
4115
4253
  "$ErrorActionPreference = 'Stop'",
@@ -4222,6 +4360,15 @@ public sealed class McpSuspendedStudio : IDisposable
4222
4360
  [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
4223
4361
  private static extern IntPtr CreateJobObjectW(IntPtr jobAttributes, string name);
4224
4362
 
4363
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
4364
+ private static extern IntPtr OpenJobObjectW(uint access, bool inheritHandle, string name);
4365
+
4366
+ [DllImport("kernel32.dll", SetLastError = true)]
4367
+ private static extern bool GetHandleInformation(IntPtr handle, out uint flags);
4368
+
4369
+ [DllImport("kernel32.dll", SetLastError = true)]
4370
+ private static extern bool QueryInformationJobObject(IntPtr job, int informationClass, IntPtr information, uint informationLength, out uint returned);
4371
+
4225
4372
  [DllImport("kernel32.dll", SetLastError = true)]
4226
4373
  private static extern bool SetInformationJobObject(
4227
4374
  IntPtr job,
@@ -4309,20 +4456,60 @@ public sealed class McpSuspendedStudio : IDisposable
4309
4456
  throw new InvalidOperationException("Unexpected Studio process wait result: " + wait);
4310
4457
  }
4311
4458
 
4312
- public static McpSuspendedStudio Start(string application, string commandLine, string currentDirectory)
4459
+ private static Win32Exception TestWorkerError(string operation)
4460
+ {
4461
+ int error = Marshal.GetLastWin32Error();
4462
+ return new Win32Exception(error, operation + " (Win32 " + error + "): " + new Win32Exception(error).Message);
4463
+ }
4464
+
4465
+ private static IntPtr OpenTestWorkerJob(string name)
4466
+ {
4467
+ if (name == null) return IntPtr.Zero;
4468
+ if (!System.Text.RegularExpressions.Regex.IsMatch(name, @"\\ALocal\\\\RsmcpStudioWorker-[a-f0-9]{32}\\z"))
4469
+ throw new ArgumentException("Invalid Studio test worker job name");
4470
+ IntPtr handle = OpenJobObjectW(5, false, name);
4471
+ if (handle == IntPtr.Zero)
4472
+ throw TestWorkerError("Open test worker job failed");
4473
+ try
4474
+ {
4475
+ uint flags;
4476
+ if (!GetHandleInformation(handle, out flags))
4477
+ throw TestWorkerError("Get test worker job handle flags failed");
4478
+ if ((flags & 1) != 0)
4479
+ throw new InvalidOperationException("Test worker job handle must not be inherited");
4480
+ int length = Marshal.SizeOf(typeof(ExtendedLimitInformation));
4481
+ IntPtr buffer = Marshal.AllocHGlobal(length);
4482
+ try
4483
+ {
4484
+ uint returned;
4485
+ if (!QueryInformationJobObject(handle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, buffer, (uint)length, out returned))
4486
+ throw TestWorkerError("Query test worker job limits failed");
4487
+ var information = (ExtendedLimitInformation)Marshal.PtrToStructure(buffer, typeof(ExtendedLimitInformation));
4488
+ if (returned != length || information.BasicLimitInformation.LimitFlags != JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)
4489
+ throw new InvalidOperationException("Test worker job must retain kill-on-close without breakaway");
4490
+ }
4491
+ finally { Marshal.FreeHGlobal(buffer); }
4492
+ return handle;
4493
+ }
4494
+ catch { CloseHandle(handle); throw; }
4495
+ }
4496
+
4497
+ public static McpSuspendedStudio Start(string application, string commandLine, string currentDirectory, string testWorkerJobName)
4313
4498
  {
4314
4499
  if (String.IsNullOrEmpty(currentDirectory))
4315
4500
  currentDirectory = null;
4316
4501
  IntPtr jobHandle = CreateJobObjectW(IntPtr.Zero, null);
4317
4502
  if (jobHandle == IntPtr.Zero)
4318
4503
  throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObjectW failed");
4504
+ IntPtr workerJob = IntPtr.Zero;
4319
4505
  try
4320
4506
  {
4321
4507
  ConfigureKillOnClose(jobHandle, true);
4508
+ workerJob = OpenTestWorkerJob(testWorkerJobName);
4322
4509
  var startup = new StartupInfo();
4323
4510
  startup.cb = Marshal.SizeOf(startup);
4324
4511
  ProcessInformation created;
4325
- uint flags = CREATE_SUSPENDED | CREATE_BREAKAWAY_FROM_JOB;
4512
+ uint flags = CREATE_SUSPENDED | (workerJob == IntPtr.Zero ? CREATE_BREAKAWAY_FROM_JOB : 0);
4326
4513
  bool started = CreateProcessW(application, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, false, flags, IntPtr.Zero, currentDirectory, ref startup, out created);
4327
4514
  if (!started && Marshal.GetLastWin32Error() == 5)
4328
4515
  started = CreateProcessW(application, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, false, CREATE_SUSPENDED, IntPtr.Zero, currentDirectory, ref startup, out created);
@@ -4330,6 +4517,10 @@ public sealed class McpSuspendedStudio : IDisposable
4330
4517
  throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateProcessW failed");
4331
4518
  try
4332
4519
  {
4520
+ // Worker lifetime encloses the private launch-authorization job.
4521
+ // COMPLETE may release authorization, never test-worker ownership.
4522
+ if (workerJob != IntPtr.Zero && !AssignProcessToJobObject(workerJob, created.hProcess))
4523
+ throw TestWorkerError("Assign Studio to test worker job failed");
4333
4524
  if (!AssignProcessToJobObject(jobHandle, created.hProcess))
4334
4525
  throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
4335
4526
  return new McpSuspendedStudio(created, jobHandle);
@@ -4353,6 +4544,10 @@ public sealed class McpSuspendedStudio : IDisposable
4353
4544
  CloseHandle(jobHandle);
4354
4545
  throw;
4355
4546
  }
4547
+ finally
4548
+ {
4549
+ if (workerJob != IntPtr.Zero) CloseHandle(workerJob);
4550
+ }
4356
4551
  }
4357
4552
 
4358
4553
  public void Resume()
@@ -4402,7 +4597,7 @@ public sealed class McpSuspendedStudio : IDisposable
4402
4597
  }
4403
4598
  }
4404
4599
  '@`,
4405
- `$launch = [McpSuspendedStudio]::Start(${powershellStringLiteral(windowsExe)}, ${powershellStringLiteral(commandLine)}, ${windowsWorkingDirectory === void 0 ? "$null" : powershellStringLiteral(windowsWorkingDirectory)})`,
4600
+ `$launch = [McpSuspendedStudio]::Start(${powershellStringLiteral(windowsExe)}, ${powershellStringLiteral(commandLine)}, ${windowsWorkingDirectory === void 0 ? "$null" : powershellStringLiteral(windowsWorkingDirectory)}, ${testWorkerJobName === void 0 ? "$null" : powershellStringLiteral(testWorkerJobName)})`,
4406
4601
  "try {",
4407
4602
  "[Console]::Out.WriteLine((ConvertTo-Json @{ pid = $launch.ProcessId; started = [string]$launch.StartedAtFileTime } -Compress)); [Console]::Out.Flush()",
4408
4603
  "$accepted = $false",
@@ -4441,19 +4636,33 @@ function buildWindowsStudioStopScript(processId, startedAt) {
4441
4636
  "} finally { if ($null -ne $studio) { $studio.Dispose() } }"
4442
4637
  ].join("; ");
4443
4638
  }
4444
- async function stopWindowsStudio(processId, startedAt) {
4445
- await powershellAsync(buildWindowsStudioStopScript(processId, startedAt));
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
+ }
4652
+ async function stopWindowsStudio(processId, startedAt, timeoutMs = 15e3) {
4653
+ await powershellAsync(buildWindowsStudioStopScript(processId, startedAt), timeoutMs);
4446
4654
  }
4447
4655
  async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDirectory) {
4448
4656
  const windowsExe = await toStudioLaunchArgAsync(exe);
4449
4657
  const windowsWorkingDirectory = studioWorkingDirectory ? await toStudioLaunchArgAsync(studioWorkingDirectory) : void 0;
4450
- const script = buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory);
4658
+ const testWorkerJobName = process.env.RSMCP_STUDIO_TEST_WORKER_JOB;
4659
+ const script = buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment, windowsWorkingDirectory, testWorkerJobName);
4451
4660
  const launcher = spawn("powershell.exe", ["-NoProfile", "-Command", script], {
4452
4661
  cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
4453
4662
  stdio: ["pipe", "pipe", "pipe"],
4454
4663
  windowsHide: true
4455
4664
  });
4456
- return new Promise((resolve5, reject) => {
4665
+ return new Promise((resolve6, reject) => {
4457
4666
  let stdout = "";
4458
4667
  let controlOutput = "";
4459
4668
  let stderr = "";
@@ -4600,7 +4809,7 @@ async function spawnWindowsStudio(exe, args, processEnvironment, studioWorkingDi
4600
4809
  throw new Error("PowerShell returned an invalid native Studio process identity.");
4601
4810
  }
4602
4811
  identity = { pid: nativePid, startedAt: nativeStartedAt };
4603
- resolve5({
4812
+ resolve6({
4604
4813
  pid: nativePid,
4605
4814
  nativePid,
4606
4815
  nativeStartedAt,
@@ -4668,21 +4877,21 @@ function resolveEntrypointDir() {
4668
4877
  if (!entrypoint)
4669
4878
  return void 0;
4670
4879
  try {
4671
- return path2.dirname(realpathSync(entrypoint));
4880
+ return path3.dirname(realpathSync(entrypoint));
4672
4881
  } catch {
4673
- return path2.dirname(path2.resolve(entrypoint));
4882
+ return path3.dirname(path3.resolve(entrypoint));
4674
4883
  }
4675
4884
  }
4676
4885
  function resolveBaseplateTemplatePath() {
4677
4886
  const entrypointDir = resolveEntrypointDir();
4678
4887
  const candidates = [
4679
4888
  ...entrypointDir ? [
4680
- path2.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
4681
- path2.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
4889
+ path3.join(entrypointDir, "assets", BASEPLATE_TEMPLATE_NAME),
4890
+ path3.join(entrypointDir, "..", "assets", BASEPLATE_TEMPLATE_NAME)
4682
4891
  ] : [],
4683
- path2.join(process.cwd(), "packages", "core", "assets", BASEPLATE_TEMPLATE_NAME),
4684
- path2.join(process.cwd(), "packages", "robloxstudio-mcp", "dist", "assets", BASEPLATE_TEMPLATE_NAME),
4685
- 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)
4686
4895
  ];
4687
4896
  for (const candidate of candidates) {
4688
4897
  if (existsSync2(candidate))
@@ -4715,7 +4924,7 @@ function sweepStaleBaseplateFiles() {
4715
4924
  continue;
4716
4925
  if (Number(match[1]) !== process.pid && isProcessAlive2(Number(match[1])))
4717
4926
  continue;
4718
- const file = path2.join(BASEPLATE_TEMP_DIR, entry);
4927
+ const file = path3.join(BASEPLATE_TEMP_DIR, entry);
4719
4928
  try {
4720
4929
  if (statSync(file).mtimeMs < cutoff)
4721
4930
  rmSync(file, { force: true });
@@ -4726,13 +4935,13 @@ function sweepStaleBaseplateFiles() {
4726
4935
  function createBaseplatePlaceFile() {
4727
4936
  mkdirSync(BASEPLATE_TEMP_DIR, { recursive: true });
4728
4937
  sweepStaleBaseplateFiles();
4729
- 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`);
4730
4939
  copyFileSync(resolveBaseplateTemplatePath(), file);
4731
4940
  return file;
4732
4941
  }
4733
4942
  function isGeneratedBaseplatePlaceFile(file) {
4734
- const resolvedFile = path2.resolve(file);
4735
- 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));
4736
4945
  }
4737
4946
  function cleanupManagedBaseplateFiles(record) {
4738
4947
  if (record.source !== "baseplate" || !record.localPlaceFile)
@@ -4754,6 +4963,24 @@ function prepareStudioLaunchOptions(options) {
4754
4963
  localPlaceFile: createBaseplatePlaceFile()
4755
4964
  };
4756
4965
  }
4966
+ function resolveStudioExeFromVersions(root) {
4967
+ if (!existsSync2(root)) {
4968
+ throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
4969
+ }
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);
4971
+ if (candidates.length === 0) {
4972
+ throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
4973
+ }
4974
+ const exe = candidates[0];
4975
+ const installationDir = path3.dirname(exe);
4976
+ const hasIncompleteDownload = readdirSync(installationDir).some((name) => /\.crdownload$/i.test(name));
4977
+ const settings = statSync(path3.join(installationDir, "AppSettings.xml"), { throwIfNoEntry: false });
4978
+ if (hasIncompleteDownload || !settings?.isFile() || settings.size === 0) {
4979
+ const reason = hasIncompleteDownload ? "an unfinished .crdownload download is present" : "AppSettings.xml is missing, empty, or not a regular file";
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.`);
4981
+ }
4982
+ return exe;
4983
+ }
4757
4984
  function resolveStudioExe() {
4758
4985
  if (process.env.ROBLOX_STUDIO_EXE)
4759
4986
  return process.env.ROBLOX_STUDIO_EXE;
@@ -4764,15 +4991,8 @@ function resolveStudioExe() {
4764
4991
  throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
4765
4992
  }
4766
4993
  const localAppData = windowsLocalAppData();
4767
- const root = localAppData ? path2.join(toWslPath(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4768
- if (!existsSync2(root)) {
4769
- throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
4770
- }
4771
- 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);
4772
- if (candidates.length === 0) {
4773
- throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
4774
- }
4775
- return candidates[0];
4994
+ const root = localAppData ? path3.join(toWslPath(localAppData), "Roblox", "Versions") : path3.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4995
+ return resolveStudioExeFromVersions(root);
4776
4996
  }
4777
4997
  async function resolveStudioExeAsync() {
4778
4998
  if (process.env.ROBLOX_STUDIO_EXE)
@@ -4784,15 +5004,8 @@ async function resolveStudioExeAsync() {
4784
5004
  throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
4785
5005
  }
4786
5006
  const localAppData = await windowsLocalAppDataAsync();
4787
- const root = localAppData ? path2.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4788
- if (!existsSync2(root)) {
4789
- throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
4790
- }
4791
- 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);
4792
- if (candidates.length === 0) {
4793
- throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
4794
- }
4795
- return candidates[0];
5007
+ const root = localAppData ? path3.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path3.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
5008
+ return resolveStudioExeFromVersions(root);
4796
5009
  }
4797
5010
  var WINDOWS_STUDIO_PROCESS_QUERY = [
4798
5011
  "$ErrorActionPreference = 'Stop'",
@@ -4809,13 +5022,13 @@ function parseWindowsStudioProcesses(output) {
4809
5022
  }
4810
5023
  return processes;
4811
5024
  }
4812
- async function observeStudioProcesses() {
5025
+ async function observeStudioProcesses(timeoutMs = 15e3) {
4813
5026
  const observedAt = Date.now();
4814
5027
  try {
4815
5028
  if (process.platform === "darwin") {
4816
5029
  let out2 = "";
4817
5030
  try {
4818
- out2 = await runAsync("pgrep", ["-fl", "RobloxStudio"]);
5031
+ out2 = await runAsync("pgrep", ["-fl", "RobloxStudio"], { timeout: timeoutMs });
4819
5032
  } catch (error) {
4820
5033
  const code = error.code;
4821
5034
  if (Number(code) === 1)
@@ -4831,7 +5044,7 @@ async function observeStudioProcesses() {
4831
5044
  if (process.platform !== "win32" && !isWsl()) {
4832
5045
  return { status: "ok", observedAt, processes: [] };
4833
5046
  }
4834
- const out = await powershellAsync(WINDOWS_STUDIO_PROCESS_QUERY);
5047
+ const out = await powershellAsync(WINDOWS_STUDIO_PROCESS_QUERY, timeoutMs);
4835
5048
  return { status: "ok", observedAt, processes: parseWindowsStudioProcesses(out) };
4836
5049
  } catch (error) {
4837
5050
  return {
@@ -4895,11 +5108,11 @@ function buildStudioLaunchArgs(options) {
4895
5108
  ];
4896
5109
  }
4897
5110
  }
4898
- function delay2(ms) {
4899
- return new Promise((resolve5) => setTimeout(resolve5, ms));
5111
+ function delay3(ms) {
5112
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
4900
5113
  }
4901
5114
  function basenameAny(filePath) {
4902
- return path2.basename(filePath.replace(/\\/g, "/"));
5115
+ return path3.basename(filePath.replace(/\\/g, "/"));
4903
5116
  }
4904
5117
  var StudioInstanceManager = class {
4905
5118
  managedByInstanceId = /* @__PURE__ */ new Map();
@@ -4915,6 +5128,8 @@ var StudioInstanceManager = class {
4915
5128
  confirmedExitGraceMs;
4916
5129
  snapshotCacheMs;
4917
5130
  launchCompletionTimeoutMs;
5131
+ closeTimeoutMs;
5132
+ settleSignIn;
4918
5133
  coordinatorTimer;
4919
5134
  coordinatorRefresh;
4920
5135
  cachedSnapshot;
@@ -4929,6 +5144,8 @@ var StudioInstanceManager = class {
4929
5144
  this.confirmedExitGraceMs = options.confirmedExitGraceMs ?? 5e3;
4930
5145
  this.snapshotCacheMs = options.snapshotCacheMs ?? 0;
4931
5146
  this.launchCompletionTimeoutMs = options.launchCompletionTimeoutMs ?? LAUNCH_COMPLETION_TIMEOUT_MS;
5147
+ this.closeTimeoutMs = options.closeTimeoutMs ?? 1e4;
5148
+ this.settleSignIn = options.settleSignIn ?? (!this.processAdapter.stopProcess && (process.platform === "win32" || isWsl()) ? settleWindowsStudioSignIn : void 0);
4932
5149
  }
4933
5150
  getLifecycleCapabilities() {
4934
5151
  const platform = this.platformCapabilities ?? getStudioPlatformCapabilities();
@@ -5108,8 +5325,8 @@ var StudioInstanceManager = class {
5108
5325
  async launch(options) {
5109
5326
  const previous = this.launchQueue;
5110
5327
  let release;
5111
- this.launchQueue = new Promise((resolve5) => {
5112
- release = resolve5;
5328
+ this.launchQueue = new Promise((resolve6) => {
5329
+ release = resolve6;
5113
5330
  });
5114
5331
  await previous;
5115
5332
  try {
@@ -5268,7 +5485,7 @@ var StudioInstanceManager = class {
5268
5485
  await this.persist(record);
5269
5486
  break;
5270
5487
  }
5271
- await delay2(250);
5488
+ await delay3(250);
5272
5489
  }
5273
5490
  if (record.nativeProcessId === void 0 && process.platform !== "win32" && !isWsl()) {
5274
5491
  record.nativeProcessId = proc.pid;
@@ -5326,58 +5543,59 @@ var StudioInstanceManager = class {
5326
5543
  instanceId: record.instanceId
5327
5544
  };
5328
5545
  }
5546
+ let deadline = Date.now() + this.closeTimeoutMs;
5329
5547
  const processId = record.nativeProcessId ?? record.spawnPid;
5330
5548
  if (!processId) {
5331
5549
  throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
5332
5550
  }
5333
5551
  const control = record.recordId ? this.launchControls.get(record.recordId) : void 0;
5334
- if (record.processAuthorizationState !== "released" && control) {
5335
- await control.abort();
5336
- const closedAt2 = Date.now();
5337
- record.closedAt = closedAt2;
5338
- record.exitedAt = closedAt2;
5339
- if (record.state !== "failed")
5340
- record.state = "exited";
5341
- record.processObservationStatus = "not_running";
5342
- record.lastProcessObservationAt = closedAt2;
5343
- record.lastSuccessfulProcessObservationAt = closedAt2;
5344
- record.lastProcessObservationError = void 0;
5345
- this.cleanupManagedRecord(record);
5346
- this.markClosedInMemory(record);
5347
- await this.persist(record);
5348
- return {
5349
- status: "closed",
5350
- launchId: record.recordId,
5351
- instanceId: record.instanceId
5352
- };
5353
- }
5354
- const snapshot = await this.getProcessSnapshot(true);
5355
- const observation = this.observeRecord(record, snapshot);
5356
- if (observation.status === "unknown") {
5357
- await this.applyProcessObservation(record, observation);
5358
- throw new Error(`Cannot verify the managed Studio process because process observation failed: ${observation.error}`);
5359
- }
5360
- if (observation.status === "not_running") {
5361
- await this.markProcessExited(record, void 0, observation.reason === "identity_mismatch" ? "Studio process identity changed; the retained PID was not reused." : record.failureReason);
5362
- await this.registry.logEvent({
5363
- event: "registry_close_already_stopped",
5364
- recordId: record.recordId,
5365
- instanceId: record.instanceId,
5366
- source: record.source,
5367
- reason: observation.reason === "identity_mismatch" ? "identity_mismatch" : "pid_not_running",
5368
- action: "marked_closed_and_cleaned_baseplate"
5369
- });
5370
- return {
5371
- status: "already_closed",
5372
- launchId: record.recordId,
5373
- instanceId: record.instanceId
5374
- };
5552
+ const abort = record.processAuthorizationState !== "released" && control ? () => control.abort() : void 0;
5553
+ if (!abort) {
5554
+ let snapshot;
5555
+ try {
5556
+ snapshot = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, processId);
5557
+ } catch (error) {
5558
+ if (error instanceof StudioCloseVerificationError) {
5559
+ await this.applyProcessObservation(record, error.observation);
5560
+ }
5561
+ throw error;
5562
+ }
5563
+ const observation = this.observeRecord(record, snapshot);
5564
+ if (observation.status === "unknown") {
5565
+ await this.applyProcessObservation(record, observation);
5566
+ throw new Error(`Cannot verify the managed Studio process because process observation failed: ${observation.error}`);
5567
+ }
5568
+ if (observation.status === "not_running") {
5569
+ await this.markProcessExited(record, void 0, observation.reason === "identity_mismatch" ? "Studio process identity changed; the retained PID was not reused." : record.failureReason);
5570
+ await this.registry.logEvent({
5571
+ event: "registry_close_already_stopped",
5572
+ recordId: record.recordId,
5573
+ instanceId: record.instanceId,
5574
+ source: record.source,
5575
+ reason: observation.reason === "identity_mismatch" ? "identity_mismatch" : "pid_not_running",
5576
+ action: "marked_closed_and_cleaned_baseplate"
5577
+ });
5578
+ return {
5579
+ status: "already_closed",
5580
+ launchId: record.recordId,
5581
+ instanceId: record.instanceId
5582
+ };
5583
+ }
5375
5584
  }
5585
+ if (!abort)
5586
+ deadline = await this.beforeStudioStop(record.nativeProcessStartedAt, deadline);
5376
5587
  try {
5377
- await this.closeProcess(processId, record.nativeProcessStartedAt);
5588
+ await this.closeProcess(processId, record.nativeProcessStartedAt, abort, deadline);
5378
5589
  } catch (error) {
5379
- const retry = await this.getProcessSnapshot(true);
5590
+ if (error instanceof StudioCloseVerificationError) {
5591
+ await this.applyProcessObservation(record, error.observation);
5592
+ throw error;
5593
+ }
5594
+ if (abort)
5595
+ throw error;
5596
+ const retry = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, processId);
5380
5597
  const retryObservation = this.observeRecord(record, retry);
5598
+ await this.applyProcessObservation(record, retryObservation);
5381
5599
  if (retryObservation.status === "running" || retryObservation.status === "unknown")
5382
5600
  throw error;
5383
5601
  await this.registry.logEvent({
@@ -5414,7 +5632,8 @@ var StudioInstanceManager = class {
5414
5632
  };
5415
5633
  }
5416
5634
  async closeConnectedInstance(instance) {
5417
- const snapshot = await this.getProcessSnapshot(true);
5635
+ let deadline = Date.now() + this.closeTimeoutMs;
5636
+ const snapshot = await beforeCloseDeadline(() => this.readProcessSnapshot(deadline - Date.now()), deadline, 0);
5418
5637
  if (snapshot.status === "error") {
5419
5638
  throw new Error(`Could not enumerate Studio processes: ${snapshot.error}`);
5420
5639
  }
@@ -5422,26 +5641,76 @@ var StudioInstanceManager = class {
5422
5641
  if (!process2) {
5423
5642
  throw new Error(`Could not find a Studio process for connected instance "${instance.instanceId}".`);
5424
5643
  }
5425
- await this.closeProcess(process2.Id, process2.StartTimeUtcFileTime);
5644
+ deadline = await this.beforeStudioStop(process2.StartTimeUtcFileTime, deadline);
5645
+ await this.closeProcess(process2.Id, process2.StartTimeUtcFileTime, void 0, deadline);
5426
5646
  }
5427
- async closeProcess(processId, startedAt) {
5428
- if (this.processAdapter.stopProcess) {
5429
- await this.processAdapter.stopProcess(processId, startedAt);
5430
- return;
5431
- }
5432
- if (process.platform === "win32" || isWsl()) {
5433
- if (startedAt === void 0) {
5434
- throw new Error("Cannot stop a Windows Studio process without its creation-time identity.");
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
+ }
5658
+ async closeProcess(processId, startedAt, stop, deadline = Date.now() + this.closeTimeoutMs) {
5659
+ this.cachedSnapshot = void 0;
5660
+ await beforeCloseDeadline(async () => {
5661
+ if (stop) {
5662
+ await stop();
5663
+ } else if (this.processAdapter.stopProcess) {
5664
+ await this.processAdapter.stopProcess(processId, startedAt);
5665
+ } else if (process.platform === "win32" || isWsl()) {
5666
+ if (startedAt === void 0) {
5667
+ throw new Error("Cannot stop a Windows Studio process without its creation-time identity.");
5668
+ }
5669
+ await stopWindowsStudio(processId, startedAt, Math.max(1, deadline - Date.now()));
5670
+ } else {
5671
+ try {
5672
+ process.kill(processId, "SIGTERM");
5673
+ } catch (error) {
5674
+ if (!(error instanceof Error) || !("code" in error) || error.code !== "ESRCH")
5675
+ throw error;
5676
+ }
5435
5677
  }
5436
- await stopWindowsStudio(processId, startedAt);
5437
- } else {
5438
- try {
5439
- process.kill(processId, "SIGTERM");
5440
- } catch (error) {
5441
- if (error.code !== "ESRCH")
5442
- throw error;
5678
+ }, deadline, processId);
5679
+ for (; ; ) {
5680
+ const observation = await beforeCloseDeadline(() => this.observeClosingProcess(processId, startedAt, deadline), deadline, processId);
5681
+ if (observation.status === "not_running")
5682
+ return;
5683
+ if (observation.status === "unknown") {
5684
+ throw new StudioCloseVerificationError(`Could not verify Studio process ${processId} termination: ${observation.error}`, observation);
5685
+ }
5686
+ const remaining = deadline - Date.now();
5687
+ if (remaining <= 0) {
5688
+ throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
5689
+ }
5690
+ await delay3(Math.min(100, remaining));
5691
+ if (Date.now() >= deadline) {
5692
+ throw new StudioCloseVerificationError(`Timed out closing Studio process ${processId}: the process is still running. Close can be retried.`, observation);
5693
+ }
5694
+ }
5695
+ }
5696
+ async observeClosingProcess(processId, startedAt, deadline) {
5697
+ if (process.platform === "darwin" && !this.processAdapter.observeStudioProcesses && !this.processAdapter.listStudioProcesses) {
5698
+ return observePosixProcess(processId);
5699
+ }
5700
+ const snapshot = await this.readProcessSnapshot(Math.max(1, deadline - Date.now()));
5701
+ if (snapshot.status === "error") {
5702
+ return { status: "unknown", observedAt: snapshot.observedAt, error: snapshot.error };
5703
+ }
5704
+ const target = snapshot.processes.find((candidate) => candidate.Id === processId);
5705
+ if (!target)
5706
+ return { status: "not_running", observedAt: snapshot.observedAt, reason: "missing" };
5707
+ if (startedAt !== void 0 && target.StartTimeUtcFileTime !== startedAt) {
5708
+ if (target.StartTimeUtcFileTime === void 0) {
5709
+ return { status: "unknown", observedAt: snapshot.observedAt, error: "Process creation-time identity is unavailable." };
5443
5710
  }
5711
+ return { status: "not_running", observedAt: snapshot.observedAt, reason: "identity_mismatch" };
5444
5712
  }
5713
+ return { status: "running", observedAt: snapshot.observedAt };
5445
5714
  }
5446
5715
  findProcessForConnectedInstance(instance, processes) {
5447
5716
  if (processes.length === 0)
@@ -5471,24 +5740,7 @@ var StudioInstanceManager = class {
5471
5740
  return this.snapshotInFlight;
5472
5741
  this.snapshotInFlight = (async () => {
5473
5742
  try {
5474
- let snapshot;
5475
- if (this.processAdapter.observeStudioProcesses) {
5476
- snapshot = await this.processAdapter.observeStudioProcesses();
5477
- } else if (this.processAdapter.listStudioProcesses) {
5478
- const observedAt = Date.now();
5479
- const processes = await this.processAdapter.listStudioProcesses();
5480
- snapshot = { status: "ok", observedAt, processes };
5481
- } else {
5482
- snapshot = await observeStudioProcesses();
5483
- }
5484
- this.cachedSnapshot = snapshot;
5485
- return snapshot;
5486
- } catch (error) {
5487
- const snapshot = {
5488
- status: "error",
5489
- observedAt: Date.now(),
5490
- error: error instanceof Error ? error.message : String(error)
5491
- };
5743
+ const snapshot = await this.readProcessSnapshot();
5492
5744
  this.cachedSnapshot = snapshot;
5493
5745
  return snapshot;
5494
5746
  } finally {
@@ -5497,6 +5749,25 @@ var StudioInstanceManager = class {
5497
5749
  })();
5498
5750
  return this.snapshotInFlight;
5499
5751
  }
5752
+ async readProcessSnapshot(timeoutMs = 15e3) {
5753
+ try {
5754
+ if (this.processAdapter.observeStudioProcesses) {
5755
+ return await this.processAdapter.observeStudioProcesses();
5756
+ }
5757
+ if (this.processAdapter.listStudioProcesses) {
5758
+ const observedAt = Date.now();
5759
+ const processes = await this.processAdapter.listStudioProcesses();
5760
+ return { status: "ok", observedAt, processes };
5761
+ }
5762
+ return await observeStudioProcesses(Math.max(1, timeoutMs));
5763
+ } catch (error) {
5764
+ return {
5765
+ status: "error",
5766
+ observedAt: Date.now(),
5767
+ error: error instanceof Error ? error.message : String(error)
5768
+ };
5769
+ }
5770
+ }
5500
5771
  async getCurrentBootId() {
5501
5772
  return this.processAdapter.currentBootId ? await this.processAdapter.currentBootId() : currentBootIdAsync();
5502
5773
  }
@@ -5562,8 +5833,16 @@ var StudioInstanceManager = class {
5562
5833
  if (!processId)
5563
5834
  return { status: "running", observedAt: snapshot.observedAt };
5564
5835
  const studioProcess = snapshot.processes.find((candidate) => candidate.Id === processId);
5565
- if (!studioProcess)
5836
+ if (!studioProcess) {
5837
+ if (process.platform === "darwin" && !this.processAdapter.observeStudioProcesses && !this.processAdapter.listStudioProcesses) {
5838
+ const observation = observePosixProcess(processId);
5839
+ return observation.status === "running" ? { status: "unknown", observedAt: observation.observedAt, error: `Studio PID ${processId} is still alive but missing from process enumeration.` } : observation;
5840
+ }
5566
5841
  return { status: "not_running", observedAt: snapshot.observedAt, reason: "missing" };
5842
+ }
5843
+ if (record.nativeProcessStartedAt !== void 0 && studioProcess.StartTimeUtcFileTime === void 0) {
5844
+ return { status: "unknown", observedAt: snapshot.observedAt, error: "Process creation-time identity is unavailable." };
5845
+ }
5567
5846
  return this.verifyProcessForRecord(record, studioProcess) ? { status: "running", observedAt: snapshot.observedAt } : { status: "not_running", observedAt: snapshot.observedAt, reason: "identity_mismatch" };
5568
5847
  }
5569
5848
  verifyProcessForRecord(record, studioProcess) {
@@ -5576,13 +5855,13 @@ var StudioInstanceManager = class {
5576
5855
  const processId = record.nativeProcessId ?? record.spawnPid;
5577
5856
  if (record.spawnPid && record.spawnPid === processId && studioProcess.Id === processId)
5578
5857
  return true;
5579
- const processPath = studioProcess.Path ? path2.normalize(studioProcess.Path).toLowerCase() : "";
5580
- 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() : "";
5581
5860
  if (processPath && exePath && (processPath === exePath || basenameAny(processPath) === basenameAny(exePath))) {
5582
5861
  return true;
5583
5862
  }
5584
5863
  const commandLine = studioProcess.CommandLine ?? "";
5585
- if (record.localPlaceFile && commandLine.includes(path2.basename(record.localPlaceFile)))
5864
+ if (record.localPlaceFile && commandLine.includes(path3.basename(record.localPlaceFile)))
5586
5865
  return true;
5587
5866
  if (record.placeId !== void 0 && commandLine.includes(String(record.placeId)))
5588
5867
  return true;
@@ -6113,11 +6392,21 @@ function createToolHttpHandler(options) {
6113
6392
 
6114
6393
  // ../core/dist/auth.js
6115
6394
  import { randomBytes, createHash as createHash2, timingSafeEqual } from "crypto";
6116
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync, chmodSync } from "fs";
6117
- 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";
6118
6397
  import { homedir as homedir3 } from "os";
6119
6398
  function authTokenFilePath() {
6120
- 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
+ }
6121
6410
  }
6122
6411
  function resolveAuthToken() {
6123
6412
  const noAuth = (process.env.ROBLOX_STUDIO_NO_AUTH || "").toLowerCase();
@@ -6130,20 +6419,36 @@ function resolveAuthToken() {
6130
6419
  }
6131
6420
  const filePath = authTokenFilePath();
6132
6421
  try {
6133
- if (existsSync3(filePath)) {
6134
- const existing = readFileSync3(filePath, "utf8").trim();
6135
- if (existing) {
6136
- return { token: existing, source: "file", filePath };
6137
- }
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;
6138
6430
  }
6139
6431
  const fresh = randomBytes(32).toString("hex");
6140
6432
  mkdirSync2(dirname2(filePath), { recursive: true, mode: 448 });
6141
- 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" });
6142
6435
  try {
6143
- chmodSync(filePath, 384);
6144
- } 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
+ }
6145
6451
  }
6146
- return { token: fresh, source: "file", filePath };
6147
6452
  } catch (err2) {
6148
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.`);
6149
6454
  return { token: randomBytes(32).toString("hex"), source: "file" };
@@ -6488,7 +6793,7 @@ var TOOL_PROXY_ENDPOINTS = {
6488
6793
  insert_asset: ["/api/insert-asset"],
6489
6794
  generate_model: ["/api/generate-model"],
6490
6795
  preview_asset: ["/api/preview-asset"],
6491
- capture_screenshot: ["/api/capture-studio", "/api/capture-screenshot", "/api/capture-begin", "/api/capture-read"],
6796
+ capture_screenshot: ["/api/capture-studio", "/api/capture-screenshot", "/api/capture-begin", "/api/capture-read", "/api/capture-markers"],
6492
6797
  simulate_mouse_input: ["/api/simulate-mouse-input"],
6493
6798
  simulate_keyboard_input: ["/api/simulate-keyboard-input"],
6494
6799
  get_memory_breakdown: ["/api/get-memory-breakdown"],
@@ -6661,7 +6966,7 @@ var TOOL_HANDLERS = {
6661
6966
  get_memory_breakdown: (tools, body) => tools.getMemoryBreakdown(body.target, body.tags, body.instance_id),
6662
6967
  get_scene_analysis: (tools, body) => tools.getSceneAnalysis(body.mode, body.target, body.topN, body.raw, body.instance_id),
6663
6968
  export_rbxm: (tools, body) => tools.exportRbxm(body.instance_paths, body.output_path, body.target, body.instance_id),
6664
- 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),
6665
6970
  find_and_replace_in_scripts: (tools, body) => tools.findAndReplaceInScripts(body.pattern, body.replacement, {
6666
6971
  caseSensitive: body.caseSensitive,
6667
6972
  usePattern: body.usePattern,
@@ -6830,8 +7135,8 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
6830
7135
  });
6831
7136
  const authToken = security?.authToken;
6832
7137
  const authRequired = (requestPath) => {
6833
- const path6 = requestPath.toLowerCase().replace(/\/+$/, "");
6834
- return path6 === "/mcp" || path6.startsWith("/mcp/") || path6 === "/proxy" || path6 === "/topology" || path6 === "/request-status" || path6 === "/unregister-instance-id" || path6 === "/create-multiplayer-group" || path6 === "/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";
6835
7140
  };
6836
7141
  app.use((req, res, next) => {
6837
7142
  if (!authToken || !authRequired(req.path)) {
@@ -7260,6 +7565,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
7260
7565
  studioTransport.close();
7261
7566
  webSocketServer.close();
7262
7567
  await mcpHandler?.close();
7568
+ await tools.dispose();
7263
7569
  };
7264
7570
  return app;
7265
7571
  }
@@ -7280,7 +7586,7 @@ async function listenWithRetry(app, host, startPort, maxAttempts = 5) {
7280
7586
  throw new Error(`All ports ${startPort}-${startPort + maxAttempts - 1} are in use. Stop some MCP server instances and retry.`);
7281
7587
  }
7282
7588
  function bindPort(app, host, port) {
7283
- const { promise, resolve: resolve5, reject } = Promise.withResolvers();
7589
+ const { promise, resolve: resolve6, reject } = Promise.withResolvers();
7284
7590
  const server = http.createServer(app);
7285
7591
  const onError = (err2) => {
7286
7592
  server.removeListener("error", onError);
@@ -7293,7 +7599,7 @@ function bindPort(app, host, port) {
7293
7599
  if ("attachStudioTransport" in app && typeof app.attachStudioTransport === "function") {
7294
7600
  app.attachStudioTransport(server);
7295
7601
  }
7296
- resolve5(server);
7602
+ resolve6(server);
7297
7603
  } catch (error) {
7298
7604
  server.close();
7299
7605
  reject(error);
@@ -7302,6 +7608,123 @@ function bindPort(app, host, port) {
7302
7608
  return promise;
7303
7609
  }
7304
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
+
7305
7728
  // ../core/dist/tools/studio-client.js
7306
7729
  var StudioHttpClient = class {
7307
7730
  bridge;
@@ -7369,7 +7792,6 @@ var OpenCloudClient = class {
7369
7792
  body: body ? JSON.stringify(body) : void 0,
7370
7793
  signal: controller.signal
7371
7794
  });
7372
- clearTimeout(timeoutId);
7373
7795
  if (!response.ok) {
7374
7796
  const errorBody = await response.text();
7375
7797
  let errorMessage2;
@@ -7391,7 +7813,8 @@ var OpenCloudClient = class {
7391
7813
  }
7392
7814
  return await response.json();
7393
7815
  } catch (error) {
7394
- clearTimeout(timeoutId);
7816
+ if (controller.signal.aborted)
7817
+ throw new Error("Request timed out");
7395
7818
  if (error instanceof Error) {
7396
7819
  if (error.name === "AbortError") {
7397
7820
  throw new Error("Request timed out");
@@ -7399,6 +7822,8 @@ var OpenCloudClient = class {
7399
7822
  throw error;
7400
7823
  }
7401
7824
  throw new Error(`Unknown error: ${String(error)}`);
7825
+ } finally {
7826
+ clearTimeout(timeoutId);
7402
7827
  }
7403
7828
  }
7404
7829
  async searchAssets(params) {
@@ -7433,7 +7858,8 @@ var OpenCloudClient = class {
7433
7858
  async getAssetThumbnail(assetId, size = "420x420") {
7434
7859
  const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${assetId}&size=${size}&format=Png`;
7435
7860
  try {
7436
- const response = await fetch(url);
7861
+ const signal = AbortSignal.timeout(this.timeout);
7862
+ const response = await fetch(url, { signal });
7437
7863
  if (!response.ok)
7438
7864
  return null;
7439
7865
  const data = await response.json();
@@ -7441,7 +7867,7 @@ var OpenCloudClient = class {
7441
7867
  if (!thumbnail || thumbnail.state !== "Completed" || !thumbnail.imageUrl) {
7442
7868
  return null;
7443
7869
  }
7444
- const imageResponse = await fetch(thumbnail.imageUrl);
7870
+ const imageResponse = await fetch(thumbnail.imageUrl, { signal });
7445
7871
  if (!imageResponse.ok)
7446
7872
  return null;
7447
7873
  const arrayBuffer = await imageResponse.arrayBuffer();
@@ -7462,7 +7888,7 @@ var OpenCloudClient = class {
7462
7888
  for (const batch of batches) {
7463
7889
  const url = `https://thumbnails.roblox.com/v1/assets?assetIds=${batch.join(",")}&size=${size}&format=Png`;
7464
7890
  try {
7465
- const response = await fetch(url);
7891
+ const response = await fetch(url, { signal: AbortSignal.timeout(this.timeout) });
7466
7892
  if (response.ok) {
7467
7893
  const data = await response.json();
7468
7894
  for (const thumbnail of data.data) {
@@ -7523,7 +7949,7 @@ var OpenCloudClient = class {
7523
7949
  const chunks = [];
7524
7950
  let totalBytes = 0;
7525
7951
  const reader = contentResponse.body.getReader();
7526
- while (true) {
7952
+ for (; ; ) {
7527
7953
  const { done, value } = await reader.read();
7528
7954
  if (done)
7529
7955
  break;
@@ -7604,7 +8030,6 @@ var OpenCloudClient = class {
7604
8030
  body: formData,
7605
8031
  signal: controller.signal
7606
8032
  });
7607
- clearTimeout(timeoutId);
7608
8033
  if (!response.ok) {
7609
8034
  const errorBody = await response.text();
7610
8035
  let errorMessage2;
@@ -7626,7 +8051,8 @@ var OpenCloudClient = class {
7626
8051
  }
7627
8052
  return await response.json();
7628
8053
  } catch (error) {
7629
- clearTimeout(timeoutId);
8054
+ if (controller.signal.aborted)
8055
+ throw new Error("Request timed out");
7630
8056
  if (error instanceof Error) {
7631
8057
  if (error.name === "AbortError") {
7632
8058
  throw new Error("Request timed out");
@@ -7634,6 +8060,8 @@ var OpenCloudClient = class {
7634
8060
  throw error;
7635
8061
  }
7636
8062
  throw new Error(`Unknown error: ${String(error)}`);
8063
+ } finally {
8064
+ clearTimeout(timeoutId);
7637
8065
  }
7638
8066
  }
7639
8067
  async pollOperation(operationPath, maxAttempts = 30, intervalMs = 2e3) {
@@ -7645,7 +8073,7 @@ var OpenCloudClient = class {
7645
8073
  if (result.error) {
7646
8074
  throw new Error(`Asset upload failed: ${result.error.message}`);
7647
8075
  }
7648
- await new Promise((resolve5) => setTimeout(resolve5, intervalMs));
8076
+ await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
7649
8077
  }
7650
8078
  throw new Error(`Asset upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
7651
8079
  }
@@ -7662,6 +8090,7 @@ var RobloxCookieClient = class {
7662
8090
  return !!this.cookie;
7663
8091
  }
7664
8092
  async fetchWithCsrf(url, options = {}) {
8093
+ options = { ...options, signal: options.signal ?? AbortSignal.timeout(3e4) };
7665
8094
  const headers = {
7666
8095
  Cookie: `.ROBLOSECURITY=${this.cookie}`,
7667
8096
  ...options.headers || {}
@@ -7675,6 +8104,7 @@ var RobloxCookieClient = class {
7675
8104
  if (newToken) {
7676
8105
  this.csrfToken = newToken;
7677
8106
  headers["X-CSRF-TOKEN"] = newToken;
8107
+ await response.body?.cancel();
7678
8108
  return fetch(url, { ...options, headers });
7679
8109
  }
7680
8110
  }
@@ -7794,7 +8224,7 @@ var RobloxCookieClient = class {
7794
8224
  throw new Error("Image upload completed without an asset ID.");
7795
8225
  }
7796
8226
  if (attempt < maxAttempts - 1) {
7797
- await new Promise((resolve5) => setTimeout(resolve5, intervalMs));
8227
+ await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
7798
8228
  }
7799
8229
  }
7800
8230
  throw new Error(`Image upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
@@ -7818,18 +8248,16 @@ var RobloxCookieClient = class {
7818
8248
 
7819
8249
  // ../core/dist/image-decode.js
7820
8250
  import * as fs2 from "fs";
7821
- import * as path3 from "path";
8251
+ import * as path4 from "path";
7822
8252
  import { inflateSync } from "zlib";
7823
8253
  var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
7824
8254
  var MAX_EDITABLE_IMAGE_DIMENSION = 1024;
7825
8255
  var MAX_PNG_DIMENSION = 16 * 1024;
7826
8256
  var MAX_PNG_PIXELS = 8 * 1024 * 1024;
8257
+ var MAX_SCREENSHOT_PNG_PIXELS = 32 * 1024 * 1024;
7827
8258
  var MAX_PNG_INPUT_BYTES = 32 * 1024 * 1024;
7828
8259
  var MAX_PNG_BASE64_CHARACTERS = 4 * Math.ceil(MAX_PNG_INPUT_BYTES / 3);
7829
- var MAX_PNG_CHUNK_BYTES = 32 * 1024 * 1024;
7830
- var MAX_PNG_COMPRESSED_BYTES = 32 * 1024 * 1024;
7831
8260
  var MAX_PNG_CHUNKS = 65536;
7832
- var MAX_PNG_INFLATED_BYTES = MAX_PNG_PIXELS * 4 + MAX_PNG_DIMENSION;
7833
8261
  function paethPredictor(a, b, c) {
7834
8262
  const p = a + b - c;
7835
8263
  const pa = Math.abs(p - a);
@@ -7852,12 +8280,12 @@ function bytesPerPixel(colorType) {
7852
8280
  return 2;
7853
8281
  throw new Error(`Unsupported PNG color type ${colorType}. Supported color types: 0, 2, 4, 6.`);
7854
8282
  }
7855
- function validatePngInputSize(byteLength) {
8283
+ function validatePngInputSize(byteLength, maxInputBytes = MAX_PNG_INPUT_BYTES) {
7856
8284
  if (!Number.isSafeInteger(byteLength) || byteLength < 0) {
7857
8285
  throw new Error(`Invalid PNG input length ${byteLength}.`);
7858
8286
  }
7859
- if (byteLength > MAX_PNG_INPUT_BYTES) {
7860
- throw new Error(`PNG input length ${byteLength} exceeds the ${MAX_PNG_INPUT_BYTES}-byte limit.`);
8287
+ if (byteLength > maxInputBytes) {
8288
+ throw new Error(`PNG input length ${byteLength} exceeds the ${maxInputBytes}-byte limit.`);
7861
8289
  }
7862
8290
  }
7863
8291
  function getBase64DecodedLength(encoded) {
@@ -7919,7 +8347,7 @@ function readPngFileWithinLimit(resolved, imagePath) {
7919
8347
  fs2.closeSync(descriptor);
7920
8348
  }
7921
8349
  }
7922
- function validateSourceDimensions(width, height) {
8350
+ function validateSourceDimensions(width, height, maxPixels) {
7923
8351
  if (width <= 0 || height <= 0) {
7924
8352
  throw new Error("PNG IHDR dimensions must be positive.");
7925
8353
  }
@@ -7927,78 +8355,65 @@ function validateSourceDimensions(width, height) {
7927
8355
  throw new Error(`PNG dimensions ${width}x${height} exceed the ${MAX_PNG_DIMENSION}-pixel dimension limit.`);
7928
8356
  }
7929
8357
  const pixelCount = width * height;
7930
- if (!Number.isSafeInteger(pixelCount) || pixelCount > MAX_PNG_PIXELS) {
7931
- throw new Error(`PNG pixel count ${pixelCount} exceeds the ${MAX_PNG_PIXELS}-pixel limit.`);
8358
+ if (!Number.isSafeInteger(pixelCount) || pixelCount > maxPixels) {
8359
+ throw new Error(`PNG pixel count ${pixelCount} exceeds the ${maxPixels}-pixel limit.`);
7932
8360
  }
7933
8361
  }
7934
- function getExpectedScanlineLength(width, height, bpp) {
8362
+ function getExpectedScanlineLength(width, height, bpp, maxPixels) {
8363
+ const maxInflatedBytes = maxPixels * 4 + MAX_PNG_DIMENSION;
7935
8364
  const stride = width * bpp;
7936
8365
  const expected = height * (stride + 1);
7937
- if (!Number.isSafeInteger(stride) || !Number.isSafeInteger(expected) || expected > MAX_PNG_INFLATED_BYTES) {
7938
- throw new Error(`PNG scanline data length ${expected} exceeds the ${MAX_PNG_INFLATED_BYTES}-byte limit.`);
8366
+ if (!Number.isSafeInteger(stride) || !Number.isSafeInteger(expected) || expected > maxInflatedBytes) {
8367
+ throw new Error(`PNG scanline data length ${expected} exceeds the ${maxInflatedBytes}-byte limit.`);
7939
8368
  }
7940
8369
  return expected;
7941
8370
  }
7942
- function convertScanlinesToRgba(raw, width, height, colorType, bpp, expectedLength) {
7943
- const stride = width * bpp;
7944
- if (raw.length !== expectedLength) {
7945
- if (raw.length > expectedLength) {
7946
- throw new Error(`Inflated PNG data exceeds the expected scanline length of ${expectedLength} bytes.`);
7947
- }
7948
- throw new Error(`PNG data ended early. Expected ${expectedLength} bytes after inflate, got ${raw.length}.`);
7949
- }
7950
- const unfiltered = Buffer.alloc(width * height * bpp);
7951
- let srcOffset = 0;
7952
- for (let y = 0; y < height; y++) {
7953
- const filter = raw[srcOffset++];
7954
- const rowOffset = y * stride;
7955
- const prevRowOffset = (y - 1) * stride;
7956
- for (let x = 0; x < stride; x++) {
7957
- const value = raw[srcOffset++];
7958
- const left = x >= bpp ? unfiltered[rowOffset + x - bpp] : 0;
7959
- const up = y > 0 ? unfiltered[prevRowOffset + x] : 0;
7960
- const upLeft = y > 0 && x >= bpp ? unfiltered[prevRowOffset + x - bpp] : 0;
7961
- let decoded;
7962
- if (filter === 0) {
7963
- decoded = value;
7964
- } else if (filter === 1) {
7965
- decoded = value + left;
7966
- } else if (filter === 2) {
7967
- decoded = value + up;
7968
- } else if (filter === 3) {
7969
- decoded = value + Math.floor((left + up) / 2);
7970
- } else if (filter === 4) {
7971
- decoded = value + paethPredictor(left, up, upLeft);
7972
- } else {
7973
- throw new Error(`Unsupported PNG filter type ${filter}.`);
7974
- }
7975
- unfiltered[rowOffset + x] = decoded & 255;
8371
+ function unfilterPngRow(raw, rowOffset, stride, bpp) {
8372
+ const filter = raw[rowOffset - 1];
8373
+ if (filter === 0)
8374
+ return;
8375
+ const prevRowOffset = rowOffset - stride - 1;
8376
+ for (let x = 0; x < stride; x++) {
8377
+ const left = x >= bpp ? raw[rowOffset + x - bpp] : 0;
8378
+ const up = prevRowOffset > 0 ? raw[prevRowOffset + x] : 0;
8379
+ const upLeft = prevRowOffset > 0 && x >= bpp ? raw[prevRowOffset + x - bpp] : 0;
8380
+ let predictor;
8381
+ if (filter === 1) {
8382
+ predictor = left;
8383
+ } else if (filter === 2) {
8384
+ predictor = up;
8385
+ } else if (filter === 3) {
8386
+ predictor = Math.floor((left + up) / 2);
8387
+ } else {
8388
+ predictor = paethPredictor(left, up, upLeft);
7976
8389
  }
8390
+ raw[rowOffset + x] = raw[rowOffset + x] + predictor & 255;
7977
8391
  }
7978
- if (colorType === 6)
7979
- return unfiltered;
8392
+ }
8393
+ function convertScanlinesToRgba({ raw, width, height, colorType, bpp }) {
8394
+ const stride = width * bpp;
7980
8395
  const rgba = Buffer.alloc(width * height * 4);
7981
- let si = 0;
7982
8396
  let di = 0;
7983
- for (let i = 0; i < width * height; i++) {
7984
- if (colorType === 2) {
7985
- rgba[di++] = unfiltered[si++];
7986
- rgba[di++] = unfiltered[si++];
7987
- rgba[di++] = unfiltered[si++];
7988
- rgba[di++] = 255;
7989
- } else if (colorType === 0) {
7990
- const gray = unfiltered[si++];
7991
- rgba[di++] = gray;
7992
- rgba[di++] = gray;
7993
- rgba[di++] = gray;
7994
- rgba[di++] = 255;
7995
- } else {
7996
- const gray = unfiltered[si++];
7997
- const alpha = unfiltered[si++];
7998
- rgba[di++] = gray;
7999
- rgba[di++] = gray;
8000
- rgba[di++] = gray;
8001
- rgba[di++] = alpha;
8397
+ for (let rowOffset = 1; rowOffset < raw.length; rowOffset += stride + 1) {
8398
+ unfilterPngRow(raw, rowOffset, stride, bpp);
8399
+ if (colorType === 6) {
8400
+ raw.copy(rgba, di, rowOffset, rowOffset + stride);
8401
+ di += stride;
8402
+ continue;
8403
+ }
8404
+ for (let si = rowOffset; si < rowOffset + stride; si += bpp) {
8405
+ if (colorType === 2) {
8406
+ rgba[di++] = raw[si];
8407
+ rgba[di++] = raw[si + 1];
8408
+ rgba[di++] = raw[si + 2];
8409
+ rgba[di++] = 255;
8410
+ } else {
8411
+ const gray = raw[si];
8412
+ rgba[di++] = gray;
8413
+ rgba[di++] = gray;
8414
+ rgba[di++] = gray;
8415
+ rgba[di++] = colorType === 4 ? raw[si + 1] : 255;
8416
+ }
8002
8417
  }
8003
8418
  }
8004
8419
  return rgba;
@@ -8025,8 +8440,9 @@ function resizeRgbaNearest(image) {
8025
8440
  }
8026
8441
  return { width, height, rgba };
8027
8442
  }
8028
- function decodePngToRgba(data) {
8029
- validatePngInputSize(data.length);
8443
+ function inflatePng(data, maxPixels) {
8444
+ const maxInputBytes = maxPixels * 4;
8445
+ validatePngInputSize(data.length, maxInputBytes);
8030
8446
  if (data.length < PNG_SIGNATURE.length || !data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
8031
8447
  throw new Error("Unsupported image format. generate_model currently supports PNG images.");
8032
8448
  }
@@ -8047,8 +8463,8 @@ function decodePngToRgba(data) {
8047
8463
  }
8048
8464
  const length = data.readUInt32BE(offset);
8049
8465
  const type = data.toString("ascii", offset + 4, offset + 8);
8050
- if (length > MAX_PNG_CHUNK_BYTES) {
8051
- throw new Error(`PNG chunk ${type} length ${length} exceeds the ${MAX_PNG_CHUNK_BYTES}-byte chunk limit.`);
8466
+ if (length > maxInputBytes) {
8467
+ throw new Error(`PNG chunk ${type} length ${length} exceeds the ${maxInputBytes}-byte chunk limit.`);
8052
8468
  }
8053
8469
  if (++chunkCount > MAX_PNG_CHUNKS) {
8054
8470
  throw new Error(`PNG contains more than the ${MAX_PNG_CHUNKS}-chunk limit.`);
@@ -8084,12 +8500,12 @@ function decodePngToRgba(data) {
8084
8500
  if (interlace !== 0)
8085
8501
  throw new Error("Interlaced PNG images are not supported.");
8086
8502
  bpp = bytesPerPixel(colorType);
8087
- validateSourceDimensions(width, height);
8088
- expectedScanlineLength = getExpectedScanlineLength(width, height, bpp);
8503
+ validateSourceDimensions(width, height, maxPixels);
8504
+ expectedScanlineLength = getExpectedScanlineLength(width, height, bpp, maxPixels);
8089
8505
  sawIhdr = true;
8090
8506
  } else if (type === "IDAT") {
8091
- if (length > MAX_PNG_COMPRESSED_BYTES - idatLength) {
8092
- throw new Error(`PNG image data exceeds the ${MAX_PNG_COMPRESSED_BYTES}-byte compressed-data limit.`);
8507
+ if (length > maxInputBytes - idatLength) {
8508
+ throw new Error(`PNG image data exceeds the ${maxInputBytes}-byte compressed-data limit.`);
8093
8509
  }
8094
8510
  idatLength += length;
8095
8511
  idatChunks.push(chunk);
@@ -8117,8 +8533,43 @@ function decodePngToRgba(data) {
8117
8533
  const detail = error instanceof Error ? error.message : String(error);
8118
8534
  throw new Error(`Invalid PNG compressed data: ${detail}`);
8119
8535
  }
8120
- const rgba = convertScanlinesToRgba(raw, width, height, colorType, bpp, expectedScanlineLength);
8121
- return resizeRgbaNearest({ width, height, rgba });
8536
+ if (raw.length !== expectedScanlineLength) {
8537
+ throw new Error(`PNG data ended early. Expected ${expectedScanlineLength} bytes after inflate, got ${raw.length}.`);
8538
+ }
8539
+ const rowLength = width * bpp + 1;
8540
+ for (let rowOffset = 0; rowOffset < raw.length; rowOffset += rowLength) {
8541
+ if (raw[rowOffset] > 4) {
8542
+ throw new Error(`Unsupported PNG filter type ${raw[rowOffset]}.`);
8543
+ }
8544
+ }
8545
+ return { width, height, colorType, bpp, raw };
8546
+ }
8547
+ function isUniformPng(data) {
8548
+ const { raw, width, colorType, bpp } = inflatePng(data, MAX_SCREENSHOT_PNG_PIXELS);
8549
+ const stride = width * bpp;
8550
+ unfilterPngRow(raw, 1, stride, bpp);
8551
+ const red = raw[1];
8552
+ const grayscale = colorType === 0 || colorType === 4;
8553
+ const green = grayscale ? red : raw[2];
8554
+ const blue = grayscale ? red : raw[3];
8555
+ for (let rowOffset = 1; rowOffset < raw.length; rowOffset += stride + 1) {
8556
+ if (rowOffset !== 1)
8557
+ unfilterPngRow(raw, rowOffset, stride, bpp);
8558
+ for (let offset = rowOffset; offset < rowOffset + stride; offset += bpp) {
8559
+ if (raw[offset] !== red || !grayscale && (raw[offset + 1] !== green || raw[offset + 2] !== blue)) {
8560
+ return false;
8561
+ }
8562
+ }
8563
+ }
8564
+ return true;
8565
+ }
8566
+ function decodePngToRgba(data) {
8567
+ const png = inflatePng(data, MAX_PNG_PIXELS);
8568
+ return resizeRgbaNearest({
8569
+ width: png.width,
8570
+ height: png.height,
8571
+ rgba: convertScanlinesToRgba(png)
8572
+ });
8122
8573
  }
8123
8574
  function decodePngBase64ToRgba(encoded) {
8124
8575
  const expectedLength = getBase64DecodedLength(encoded);
@@ -8129,14 +8580,14 @@ function decodePngBase64ToRgba(encoded) {
8129
8580
  return decodePngToRgba(data);
8130
8581
  }
8131
8582
  function decodeImagePathToRgba(imagePath) {
8132
- const resolved = path3.resolve(imagePath);
8583
+ const resolved = path4.resolve(imagePath);
8133
8584
  return decodePngToRgba(readPngFileWithinLimit(resolved, imagePath));
8134
8585
  }
8135
8586
 
8136
8587
  // ../core/dist/studio-skills.js
8137
8588
  import { createHash as createHash3 } from "crypto";
8138
- import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
8139
- 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";
8140
8591
 
8141
8592
  // ../../node_modules/fzstd/esm/index.mjs
8142
8593
  var ab = ArrayBuffer;
@@ -9024,7 +9475,7 @@ function parseBuiltInStudioSkills(buffer) {
9024
9475
  }).sort((left, right) => left.name.localeCompare(right.name));
9025
9476
  }
9026
9477
  function discoverNamedFile(root, fileName, depth = 0) {
9027
- if (depth > MAX_DISCOVERY_DEPTH || !existsSync4(root))
9478
+ if (depth > MAX_DISCOVERY_DEPTH || !existsSync3(root))
9028
9479
  return [];
9029
9480
  const matches = [];
9030
9481
  let entries;
@@ -9034,7 +9485,7 @@ function discoverNamedFile(root, fileName, depth = 0) {
9034
9485
  return matches;
9035
9486
  }
9036
9487
  for (const entry of entries) {
9037
- const entryPath = path4.join(root, entry.name);
9488
+ const entryPath = path5.join(root, entry.name);
9038
9489
  if (entry.isFile() && entry.name.toLowerCase() === fileName.toLowerCase()) {
9039
9490
  matches.push(entryPath);
9040
9491
  } else if (entry.isDirectory()) {
@@ -9046,20 +9497,20 @@ function discoverNamedFile(root, fileName, depth = 0) {
9046
9497
  function resolveAssistantBundlePath(studioExe) {
9047
9498
  const override = process.env.ROBLOX_STUDIO_ASSISTANT_BUNDLE;
9048
9499
  if (override) {
9049
- if (!existsSync4(override)) {
9500
+ if (!existsSync3(override)) {
9050
9501
  throw new Error(`ROBLOX_STUDIO_ASSISTANT_BUNDLE does not exist: ${override}`);
9051
9502
  }
9052
9503
  return override;
9053
9504
  }
9054
- const exeDirectory = path4.dirname(studioExe ?? resolveStudioExe());
9505
+ const exeDirectory = path5.dirname(studioExe ?? resolveStudioExe());
9055
9506
  const roots = [
9056
- path4.join(exeDirectory, "BuiltInStandalonePlugins"),
9057
- path4.resolve(exeDirectory, "..", "Resources", "BuiltInStandalonePlugins"),
9058
- path4.resolve(exeDirectory, "..", "..", "OTAPlugins")
9507
+ path5.join(exeDirectory, "BuiltInStandalonePlugins"),
9508
+ path5.resolve(exeDirectory, "..", "Resources", "BuiltInStandalonePlugins"),
9509
+ path5.resolve(exeDirectory, "..", "..", "OTAPlugins")
9059
9510
  ];
9060
- const direct = path4.join(exeDirectory, "BuiltInStandalonePlugins", "Optimized_Embedded_Signature", "Assistant.rbxm");
9511
+ const direct = path5.join(exeDirectory, "BuiltInStandalonePlugins", "Optimized_Embedded_Signature", "Assistant.rbxm");
9061
9512
  const candidates = /* @__PURE__ */ new Set();
9062
- if (existsSync4(direct))
9513
+ if (existsSync3(direct))
9063
9514
  candidates.add(direct);
9064
9515
  for (const root of roots) {
9065
9516
  for (const candidate of discoverNamedFile(root, "Assistant.rbxm"))
@@ -9082,7 +9533,7 @@ function loadBuiltInStudioSkills(bundlePath = resolveAssistantBundlePath()) {
9082
9533
  if (skills.length === 0) {
9083
9534
  throw new Error(`No built-in skill documents found in ${bundlePath}`);
9084
9535
  }
9085
- 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));
9086
9537
  const value = {
9087
9538
  bundlePath,
9088
9539
  bundleModifiedAt: stats.mtime.toISOString(),
@@ -10049,62 +10500,688 @@ function rgbaToJpeg(rgba, width, height, quality = 80) {
10049
10500
  return encoder.encode(rgba, width, height);
10050
10501
  }
10051
10502
 
10052
- // ../core/dist/png-encoder.js
10053
- import { deflateSync } from "zlib";
10054
- var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
10055
- var CRC_TABLE = new Uint32Array(256);
10056
- for (let n = 0; n < 256; n++) {
10057
- let c = n;
10058
- for (let k = 0; k < 8; k++)
10059
- c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
10060
- CRC_TABLE[n] = c;
10503
+ // ../core/dist/png-encoder.js
10504
+ import { deflateSync } from "zlib";
10505
+ var PNG_SIGNATURE2 = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
10506
+ var CRC_TABLE = new Uint32Array(256);
10507
+ for (let n = 0; n < 256; n++) {
10508
+ let c = n;
10509
+ for (let k = 0; k < 8; k++)
10510
+ c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
10511
+ CRC_TABLE[n] = c;
10512
+ }
10513
+ function crc32(buf) {
10514
+ let crc = 4294967295;
10515
+ for (let i = 0; i < buf.length; i++)
10516
+ crc = CRC_TABLE[(crc ^ buf[i]) & 255] ^ crc >>> 8;
10517
+ return (crc ^ 4294967295) >>> 0;
10518
+ }
10519
+ function writeChunk(type, data) {
10520
+ const typeBytes = Buffer.from(type, "ascii");
10521
+ const length = Buffer.alloc(4);
10522
+ length.writeUInt32BE(data.length);
10523
+ const crcInput = Buffer.concat([typeBytes, data]);
10524
+ const checksum = Buffer.alloc(4);
10525
+ checksum.writeUInt32BE(crc32(crcInput));
10526
+ return Buffer.concat([length, typeBytes, data, checksum]);
10527
+ }
10528
+ function rgbaToPng(rgba, width, height) {
10529
+ if (width <= 0 || height <= 0)
10530
+ throw new Error(`Invalid PNG dimensions: ${width}x${height}`);
10531
+ const expected = width * height * 4;
10532
+ if (rgba.length < expected)
10533
+ throw new Error(`Buffer too small: got ${rgba.length}, need ${expected}`);
10534
+ const stride = width * 4;
10535
+ const filtered = Buffer.alloc(height * (1 + stride));
10536
+ for (let y = 0; y < height; y++) {
10537
+ filtered[y * (1 + stride)] = 0;
10538
+ rgba.copy(filtered, y * (1 + stride) + 1, y * stride, (y + 1) * stride);
10539
+ }
10540
+ const ihdr = Buffer.alloc(13);
10541
+ ihdr.writeUInt32BE(width, 0);
10542
+ ihdr.writeUInt32BE(height, 4);
10543
+ ihdr[8] = 8;
10544
+ ihdr[9] = 6;
10545
+ ihdr[10] = 0;
10546
+ ihdr[11] = 0;
10547
+ ihdr[12] = 0;
10548
+ return Buffer.concat([
10549
+ PNG_SIGNATURE2,
10550
+ writeChunk("IHDR", ihdr),
10551
+ writeChunk("IDAT", deflateSync(filtered)),
10552
+ writeChunk("IEND", Buffer.alloc(0))
10553
+ ]);
10554
+ }
10555
+
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
10563
+ import { spawn as spawn2 } from "child_process";
10564
+ import * as fs3 from "fs";
10565
+ import * as os3 from "os";
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
10831
+ var HOST_CAPTURE_TIMEOUT_MS = 2e4;
10832
+ var MARKER_MIN_RB = 200;
10833
+ var MARKER_MAX_G = 60;
10834
+ var MARKER_SCALE_TOLERANCE = 0.03;
10835
+ function isHostCaptureDisabled(env = process.env) {
10836
+ const value = (env.ROBLOX_STUDIO_HOST_CAPTURE ?? "").trim().toLowerCase();
10837
+ return value === "0" || value === "false" || value === "off";
10061
10838
  }
10062
- function crc32(buf) {
10063
- let crc = 4294967295;
10064
- for (let i = 0; i < buf.length; i++)
10065
- crc = CRC_TABLE[(crc ^ buf[i]) & 255] ^ crc >>> 8;
10066
- return (crc ^ 4294967295) >>> 0;
10839
+ function isHostCaptureSupported(platform = process.platform) {
10840
+ return platform === "win32" || platform === "darwin";
10067
10841
  }
10068
- function writeChunk(type, data) {
10069
- const typeBytes = Buffer.from(type, "ascii");
10070
- const length = Buffer.alloc(4);
10071
- length.writeUInt32BE(data.length);
10072
- const crcInput = Buffer.concat([typeBytes, data]);
10073
- const checksum = Buffer.alloc(4);
10074
- checksum.writeUInt32BE(crc32(crcInput));
10075
- return Buffer.concat([length, typeBytes, data, checksum]);
10842
+ function hostCaptureUnsupportedReason(platform = process.platform) {
10843
+ return `host window capture is only implemented on Windows and macOS (this is ${platform})`;
10076
10844
  }
10077
- function rgbaToPng(rgba, width, height) {
10078
- if (width <= 0 || height <= 0)
10079
- throw new Error(`Invalid PNG dimensions: ${width}x${height}`);
10080
- const expected = width * height * 4;
10081
- if (rgba.length < expected)
10082
- throw new Error(`Buffer too small: got ${rgba.length}, need ${expected}`);
10083
- const stride = width * 4;
10084
- const filtered = Buffer.alloc(height * (1 + stride));
10845
+ async function prepareHostWindowCapture() {
10846
+ if (process.platform === "darwin" && !isHostCaptureDisabled())
10847
+ await prepareMacHostCapture();
10848
+ }
10849
+ function isUniformFrame(rgba, width, height) {
10850
+ const pixels = width * height;
10851
+ if (pixels <= 0 || rgba.length < pixels * 4)
10852
+ return false;
10853
+ const r = rgba[0];
10854
+ const g = rgba[1];
10855
+ const b = rgba[2];
10856
+ for (let i = 0; i < pixels; i++) {
10857
+ const o = i * 4;
10858
+ if (rgba[o] !== r || rgba[o + 1] !== g || rgba[o + 2] !== b)
10859
+ return false;
10860
+ }
10861
+ return true;
10862
+ }
10863
+ function isMarkerPixel(rgba, offset) {
10864
+ return rgba[offset] >= MARKER_MIN_RB && rgba[offset + 1] <= MARKER_MAX_G && rgba[offset + 2] >= MARKER_MIN_RB;
10865
+ }
10866
+ function findViewportRect(rgba, width, height, hint) {
10867
+ let minX = width;
10868
+ let minY = height;
10869
+ let maxX = -1;
10870
+ let maxY = -1;
10085
10871
  for (let y = 0; y < height; y++) {
10086
- filtered[y * (1 + stride)] = 0;
10087
- rgba.copy(filtered, y * (1 + stride) + 1, y * stride, (y + 1) * stride);
10872
+ const row = y * width * 4;
10873
+ for (let x = 0; x < width; x++) {
10874
+ if (!isMarkerPixel(rgba, row + x * 4))
10875
+ continue;
10876
+ if (x < minX)
10877
+ minX = x;
10878
+ if (x > maxX)
10879
+ maxX = x;
10880
+ if (y < minY)
10881
+ minY = y;
10882
+ if (y > maxY)
10883
+ maxY = y;
10884
+ }
10885
+ }
10886
+ if (maxX < 0) {
10887
+ return { error: "no viewport markers were visible in the Studio window capture" };
10888
+ }
10889
+ const rect = { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
10890
+ const inset = Math.max(1, Math.floor(hint.markerSize / 4));
10891
+ const corners = [
10892
+ [minX + inset, minY + inset],
10893
+ [maxX - inset, minY + inset],
10894
+ [minX + inset, maxY - inset],
10895
+ [maxX - inset, maxY - inset]
10896
+ ];
10897
+ for (const [x, y] of corners) {
10898
+ if (!isMarkerPixel(rgba, (y * width + x) * 4)) {
10899
+ return { error: `viewport markers were found but do not form a rectangle (box ${rect.width}x${rect.height} at ${rect.x},${rect.y})` };
10900
+ }
10901
+ }
10902
+ const scaleX = rect.width / hint.viewportWidth;
10903
+ const scaleY = rect.height / hint.viewportHeight;
10904
+ if (Math.abs(scaleX - scaleY) > MARKER_SCALE_TOLERANCE * Math.max(scaleX, scaleY)) {
10905
+ return {
10906
+ error: `viewport marker box ${rect.width}x${rect.height} does not match the reported ${hint.viewportWidth}x${hint.viewportHeight} viewport aspect`
10907
+ };
10908
+ }
10909
+ return { rect };
10910
+ }
10911
+ function cropToViewport(rgba, width, height, rect, targetWidth, targetHeight) {
10912
+ const x0 = Math.max(0, Math.min(width - 1, rect.x));
10913
+ const y0 = Math.max(0, Math.min(height - 1, rect.y));
10914
+ const srcW = Math.max(1, Math.min(rect.width, width - x0));
10915
+ const srcH = Math.max(1, Math.min(rect.height, height - y0));
10916
+ const exact = srcW === targetWidth && srcH === targetHeight;
10917
+ const outW = targetWidth;
10918
+ const outH = targetHeight;
10919
+ const out = Buffer.alloc(outW * outH * 4);
10920
+ if (exact) {
10921
+ for (let y = 0; y < outH; y++) {
10922
+ rgba.copy(out, y * outW * 4, ((y0 + y) * width + x0) * 4, ((y0 + y) * width + x0 + outW) * 4);
10923
+ }
10924
+ return { width: outW, height: outH, rgba: out };
10925
+ }
10926
+ const sx = srcW / outW;
10927
+ const sy = srcH / outH;
10928
+ for (let y = 0; y < outH; y++) {
10929
+ const fy = Math.min(srcH - 1, (y + 0.5) * sy - 0.5);
10930
+ const iy = Math.max(0, Math.floor(fy));
10931
+ const ny = Math.min(srcH - 1, iy + 1);
10932
+ const wy = Math.max(0, fy - iy);
10933
+ for (let x = 0; x < outW; x++) {
10934
+ const fx = Math.min(srcW - 1, (x + 0.5) * sx - 0.5);
10935
+ const ix = Math.max(0, Math.floor(fx));
10936
+ const nx = Math.min(srcW - 1, ix + 1);
10937
+ const wx = Math.max(0, fx - ix);
10938
+ const o00 = ((y0 + iy) * width + x0 + ix) * 4;
10939
+ const o10 = ((y0 + iy) * width + x0 + nx) * 4;
10940
+ const o01 = ((y0 + ny) * width + x0 + ix) * 4;
10941
+ const o11 = ((y0 + ny) * width + x0 + nx) * 4;
10942
+ const dst = (y * outW + x) * 4;
10943
+ for (let c = 0; c < 4; c++) {
10944
+ const top = rgba[o00 + c] * (1 - wx) + rgba[o10 + c] * wx;
10945
+ const bottom = rgba[o01 + c] * (1 - wx) + rgba[o11 + c] * wx;
10946
+ out[dst + c] = Math.round(top * (1 - wy) + bottom * wy);
10947
+ }
10948
+ }
10949
+ }
10950
+ return { width: outW, height: outH, rgba: out };
10951
+ }
10952
+ var WINDOWS_CAPTURE_SCRIPT = String.raw`
10953
+ $ErrorActionPreference = 'Stop'
10954
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
10955
+ Add-Type -AssemblyName System.Drawing
10956
+ Add-Type -TypeDefinition @"
10957
+ using System;
10958
+ using System.Collections.Generic;
10959
+ using System.Runtime.InteropServices;
10960
+ using System.Text;
10961
+ public static class McpStudioCapture {
10962
+ [DllImport("user32.dll")] static extern bool EnumWindows(EnumWindowsProc cb, IntPtr lParam);
10963
+ [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr hWnd);
10964
+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);
10965
+ [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
10966
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowTextW(IntPtr hWnd, StringBuilder text, int count);
10967
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowTextLengthW(IntPtr hWnd);
10968
+ [DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr hWnd, out RECT rect);
10969
+ [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr hWnd, IntPtr hdc, uint flags);
10970
+ delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
10971
+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left, Top, Right, Bottom; }
10972
+ public class Candidate { public IntPtr Handle; public uint Pid; public string Title; }
10973
+ public static List<Candidate> Find(string processName) {
10974
+ var found = new List<Candidate>();
10975
+ EnumWindows((hWnd, lParam) => {
10976
+ if (!IsWindowVisible(hWnd)) return true;
10977
+ int length = GetWindowTextLengthW(hWnd);
10978
+ if (length <= 0) return true;
10979
+ var sb = new StringBuilder(length + 1);
10980
+ GetWindowTextW(hWnd, sb, sb.Capacity);
10981
+ string title = sb.ToString();
10982
+ if (!title.EndsWith("Roblox Studio", StringComparison.Ordinal)) return true;
10983
+ uint pid;
10984
+ GetWindowThreadProcessId(hWnd, out pid);
10985
+ try {
10986
+ var proc = System.Diagnostics.Process.GetProcessById((int)pid);
10987
+ if (!string.Equals(proc.ProcessName, processName, StringComparison.OrdinalIgnoreCase)) return true;
10988
+ } catch { return true; }
10989
+ found.Add(new Candidate { Handle = hWnd, Pid = pid, Title = title });
10990
+ return true;
10991
+ }, IntPtr.Zero);
10992
+ return found;
10993
+ }
10994
+ }
10995
+ "@
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
+ }
11008
+ $hint = $env:MCP_CAPTURE_TITLE_HINT
11009
+ $outFile = $env:MCP_CAPTURE_OUT
11010
+ $candidates = [McpStudioCapture]::Find('RobloxStudioBeta')
11011
+ if ($candidates.Count -eq 0) { Emit @{ ok = $false; error = 'no visible Roblox Studio window was found' }; 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
11028
+ }
11029
+ $pick = $matching[0]
11030
+ $identity = @{ windowId = $pick.Handle.ToInt64(); processId = [long]$pick.Pid; bundleIdentifier = 'RobloxStudioBeta' }
11031
+ if ([McpStudioCapture]::IsIconic($pick.Handle)) {
11032
+ Emit @{ ok = $false; error = "the Studio window '$($pick.Title)' is minimized; restore it (it may stay behind other windows)" }; exit 0
11033
+ }
11034
+ $rect = New-Object McpStudioCapture+RECT
11035
+ [void][McpStudioCapture]::GetClientRect($pick.Handle, [ref]$rect)
11036
+ $w = $rect.Right - $rect.Left; $h = $rect.Bottom - $rect.Top
11037
+ if ($w -le 0 -or $h -le 0) { Emit @{ ok = $false; error = "the Studio window client area is empty ($w x $h)" }; exit 0 }
11038
+ $bmp = New-Object System.Drawing.Bitmap $w, $h, ([System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
11039
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
11040
+ $hdc = $g.GetHdc()
11041
+ # PW_CLIENTONLY (1) | PW_RENDERFULLCONTENT (2): DWM-composited client area, occluded windows included.
11042
+ $printed = [McpStudioCapture]::PrintWindow($pick.Handle, $hdc, 3)
11043
+ $g.ReleaseHdc($hdc); $g.Dispose()
11044
+ if (-not $printed) { $bmp.Dispose(); Emit @{ ok = $false; error = 'PrintWindow failed for the Studio window' }; exit 0 }
11045
+ $bounds = New-Object System.Drawing.Rectangle 0, 0, $w, $h
11046
+ $data = $bmp.LockBits($bounds, [System.Drawing.Imaging.ImageLockMode]::ReadOnly, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
11047
+ $bytes = New-Object byte[] ($data.Stride * $h)
11048
+ [System.Runtime.InteropServices.Marshal]::Copy($data.Scan0, $bytes, 0, $bytes.Length)
11049
+ $stride = $data.Stride
11050
+ $bmp.UnlockBits($data); $bmp.Dispose()
11051
+ [System.IO.File]::WriteAllBytes($outFile, $bytes)
11052
+ Emit @{ ok = $true; width = $w; height = $h; stride = $stride; title = $pick.Title; identity = $identity }
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
+ }
11073
+ function powershellPath() {
11074
+ const systemRoot = process.env.SystemRoot ?? process.env.windir;
11075
+ if (systemRoot) {
11076
+ const candidate = path7.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
11077
+ if (fs4.existsSync(candidate))
11078
+ return candidate;
11079
+ }
11080
+ return "powershell.exe";
11081
+ }
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));
11130
+ return;
11131
+ } catch {
11132
+ }
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}` : ""}`));
11136
+ });
11137
+ return promise;
11138
+ }
11139
+ async function captureStudioWindow(titleHint, expectedIdentity) {
11140
+ if (isHostCaptureDisabled()) {
11141
+ return { ok: false, error: "host window capture is disabled by ROBLOX_STUDIO_HOST_CAPTURE" };
11142
+ }
11143
+ if (!isHostCaptureSupported()) {
11144
+ return { ok: false, error: hostCaptureUnsupportedReason() };
11145
+ }
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`);
11149
+ try {
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");
11156
+ const { width, height, stride } = report;
11157
+ const rgba = Buffer.alloc(width * height * 4);
11158
+ for (let y = 0; y < height; y++) {
11159
+ const src = y * stride;
11160
+ const dst = y * width * 4;
11161
+ for (let x = 0; x < width; x++) {
11162
+ const s = src + x * 4;
11163
+ const d = dst + x * 4;
11164
+ rgba[d] = raw[s + 2];
11165
+ rgba[d + 1] = raw[s + 1];
11166
+ rgba[d + 2] = raw[s];
11167
+ rgba[d + 3] = 255;
11168
+ }
11169
+ }
11170
+ return { ok: true, capture: { width, height, rgba, title: report.title, identity: report.identity } };
11171
+ } catch (error) {
11172
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
11173
+ } finally {
11174
+ fs4.rmSync(outFile, { force: true });
10088
11175
  }
10089
- const ihdr = Buffer.alloc(13);
10090
- ihdr.writeUInt32BE(width, 0);
10091
- ihdr.writeUInt32BE(height, 4);
10092
- ihdr[8] = 8;
10093
- ihdr[9] = 6;
10094
- ihdr[10] = 0;
10095
- ihdr[11] = 0;
10096
- ihdr[12] = 0;
10097
- return Buffer.concat([
10098
- PNG_SIGNATURE2,
10099
- writeChunk("IHDR", ihdr),
10100
- writeChunk("IDAT", deflateSync(filtered)),
10101
- writeChunk("IEND", Buffer.alloc(0))
10102
- ]);
10103
11176
  }
10104
11177
 
10105
11178
  // ../core/dist/tools/index.js
10106
- import * as fs3 from "fs";
10107
- import * as path5 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
+ }
11184
+ var HOST_VIEWPORT_RECT_TTL_MS = 6e4;
10108
11185
  var MAX_INLINE_IMAGE_BYTES = 6e6;
10109
11186
  var MAX_MATRIX_IMAGE_BYTES = 8e6;
10110
11187
  var DEFAULT_ASSET_AUDIO_PREVIEWS = 3;
@@ -10189,7 +11266,7 @@ function encodeImageFromRgbaResponse(response, format, quality) {
10189
11266
  };
10190
11267
  }
10191
11268
  function sleep(ms) {
10192
- return new Promise((resolve5) => setTimeout(resolve5, ms));
11269
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
10193
11270
  }
10194
11271
  function errorMessage(error) {
10195
11272
  return error instanceof Error ? error.message : String(error);
@@ -10283,9 +11360,9 @@ function compactSoundReference(sound) {
10283
11360
  name: stringField(sound, "name"),
10284
11361
  className: stringField(sound, "className")
10285
11362
  };
10286
- const path6 = stringField(sound, "path");
10287
- if (path6)
10288
- compact.path = path6;
11363
+ const path9 = stringField(sound, "path");
11364
+ if (path9)
11365
+ compact.path = path9;
10289
11366
  const assetId = robloxAssetIdFromContentId(sound.assetId ?? sound.soundId ?? sound.asset);
10290
11367
  if (assetId !== void 0)
10291
11368
  compact.assetId = assetId;
@@ -10348,8 +11425,8 @@ function loadMicroProfilerBaseline(source, sourcePath) {
10348
11425
  if (typeof sourcePath !== "string" || sourcePath === "") {
10349
11426
  throw new Error("baseline_path must be a non-empty string when provided");
10350
11427
  }
10351
- const resolved = path5.resolve(sourcePath);
10352
- const parsed = JSON.parse(fs3.readFileSync(resolved, "utf8"));
11428
+ const resolved = path8.resolve(sourcePath);
11429
+ const parsed = JSON.parse(fs5.readFileSync(resolved, "utf8"));
10353
11430
  const record = asRecord(parsed);
10354
11431
  if (!record)
10355
11432
  throw new Error(`baseline_path did not contain a JSON object: ${resolved}`);
@@ -10851,13 +11928,17 @@ var RobloxStudioTools = class {
10851
11928
  cookieClient;
10852
11929
  instanceManager;
10853
11930
  managedConnectionAssociations = Promise.resolve();
11931
+ unsubscribePeerRegistered;
11932
+ hostWindowCapture = captureStudioWindow;
11933
+ hostViewportRects = /* @__PURE__ */ new Map();
11934
+ viewportCaptureQueues = /* @__PURE__ */ new Map();
10854
11935
  constructor(bridge) {
10855
11936
  this.client = new StudioHttpClient(bridge);
10856
11937
  this.bridge = bridge;
10857
11938
  this.openCloudClient = new OpenCloudClient();
10858
11939
  this.cookieClient = new RobloxCookieClient();
10859
11940
  this.instanceManager = new StudioInstanceManager();
10860
- this.bridge.onPeerRegistered((peer) => {
11941
+ this.unsubscribePeerRegistered = this.bridge.onPeerRegistered((peer) => {
10861
11942
  const instanceManager = this.instanceManager;
10862
11943
  const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(peer, instanceManager));
10863
11944
  this.managedConnectionAssociations = association.catch((error) => {
@@ -10868,6 +11949,10 @@ var RobloxStudioTools = class {
10868
11949
  getStudioLifecycleCapabilities() {
10869
11950
  return this.instanceManager.getLifecycleCapabilities();
10870
11951
  }
11952
+ async dispose() {
11953
+ this.unsubscribePeerRegistered();
11954
+ await this.managedConnectionAssociations;
11955
+ }
10871
11956
  _textResult(body) {
10872
11957
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
10873
11958
  }
@@ -10991,8 +12076,8 @@ var RobloxStudioTools = class {
10991
12076
  }
10992
12077
  return this._requestPeer(endpoint, data, resolved.targetPeerId, timeoutMs, signal, operationId);
10993
12078
  }
10994
- // Prefer the first client role in the selected process/group scope for live
10995
- // 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.
10996
12081
  _resolveRuntime(instance_id) {
10997
12082
  const resolved = this.bridge.resolveTarget({ instance_id, target: void 0 });
10998
12083
  if (!resolved.ok)
@@ -11004,7 +12089,9 @@ var RobloxStudioTools = class {
11004
12089
  data: this._routingErrorData()
11005
12090
  });
11006
12091
  }
11007
- 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];
11008
12095
  return {
11009
12096
  instanceId: client?.instanceId ?? resolved.targetInstanceId,
11010
12097
  clientRole: client?.role
@@ -11290,8 +12377,8 @@ var RobloxStudioTools = class {
11290
12377
  timedOut: true
11291
12378
  };
11292
12379
  }
11293
- async getFileTree(path6 = "", instance_id) {
11294
- const response = await this._callSingle("/api/file-tree", { path: path6 }, 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);
11295
12382
  return {
11296
12383
  content: [
11297
12384
  {
@@ -11383,9 +12470,9 @@ var RobloxStudioTools = class {
11383
12470
  ]
11384
12471
  };
11385
12472
  }
11386
- async getProjectStructure(path6, maxDepth, scriptsOnly, instance_id) {
12473
+ async getProjectStructure(path9, maxDepth, scriptsOnly, instance_id) {
11387
12474
  const response = await this._callSingle("/api/project-structure", {
11388
- path: path6,
12475
+ path: path9,
11389
12476
  maxDepth,
11390
12477
  scriptsOnly
11391
12478
  }, void 0, instance_id);
@@ -11546,7 +12633,7 @@ var RobloxStudioTools = class {
11546
12633
  };
11547
12634
  }
11548
12635
  async setSelection(paths, mode, instance_id) {
11549
- if (!Array.isArray(paths) || paths.some((path6) => typeof path6 !== "string" || path6.length === 0)) {
12636
+ if (!Array.isArray(paths) || paths.some((path9) => typeof path9 !== "string" || path9.length === 0)) {
11550
12637
  throw new Error("selection paths must contain only non-empty instance paths");
11551
12638
  }
11552
12639
  const selectionMode = mode ?? "set";
@@ -12283,9 +13370,9 @@ var RobloxStudioTools = class {
12283
13370
  const rawJson = mutable.raw_json;
12284
13371
  if (typeof rawJson === "string") {
12285
13372
  if (typeof outputPath === "string" && outputPath !== "") {
12286
- const resolvedOutputPath = path5.resolve(outputPath);
12287
- fs3.mkdirSync(path5.dirname(resolvedOutputPath), { recursive: true });
12288
- fs3.writeFileSync(resolvedOutputPath, rawJson, "utf8");
13373
+ const resolvedOutputPath = path8.resolve(outputPath);
13374
+ fs5.mkdirSync(path8.dirname(resolvedOutputPath), { recursive: true });
13375
+ fs5.writeFileSync(resolvedOutputPath, rawJson, "utf8");
12289
13376
  mutable.output_path = resolvedOutputPath;
12290
13377
  }
12291
13378
  delete mutable.raw_json;
@@ -12346,9 +13433,9 @@ var RobloxStudioTools = class {
12346
13433
  const rawSnapshotBase64 = mutable.raw_snapshot_base64;
12347
13434
  if (typeof rawSnapshotBase64 === "string") {
12348
13435
  if (typeof outputPath === "string" && outputPath !== "") {
12349
- const resolvedOutputPath = path5.resolve(outputPath);
12350
- fs3.mkdirSync(path5.dirname(resolvedOutputPath), { recursive: true });
12351
- fs3.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"));
12352
13439
  mutable.output_path = resolvedOutputPath;
12353
13440
  }
12354
13441
  delete mutable.raw_snapshot_base64;
@@ -12362,9 +13449,9 @@ var RobloxStudioTools = class {
12362
13449
  });
12363
13450
  }
12364
13451
  if (typeof summaryOutputPath === "string" && summaryOutputPath !== "") {
12365
- const resolvedSummaryPath = path5.resolve(summaryOutputPath);
12366
- fs3.mkdirSync(path5.dirname(resolvedSummaryPath), { recursive: true });
12367
- fs3.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");
12368
13455
  mutable.summary_output_path = resolvedSummaryPath;
12369
13456
  }
12370
13457
  if (!includeComparisonIndex) {
@@ -12426,7 +13513,7 @@ var RobloxStudioTools = class {
12426
13513
  return record.placeId !== void 0 && instance.placeId === record.placeId;
12427
13514
  }
12428
13515
  if ((record.source === "baseplate" || record.source === "local_file") && record.localPlaceFile) {
12429
- const expectedName = path5.basename(record.localPlaceFile);
13516
+ const expectedName = path8.basename(record.localPlaceFile);
12430
13517
  return instance.placeName === expectedName || instance.dataModelName === expectedName;
12431
13518
  }
12432
13519
  return true;
@@ -12439,7 +13526,7 @@ var RobloxStudioTools = class {
12439
13526
  await instanceManager.attachInstanceId(candidate, instance.instanceId);
12440
13527
  }
12441
13528
  async _deriveUniverseId(placeId) {
12442
- 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) });
12443
13530
  if (!response.ok) {
12444
13531
  const body = await response.text().catch(() => "");
12445
13532
  throw new Error(`Could not resolve the universe for place_id ${placeId} (${response.status}): ${body}`);
@@ -12573,6 +13660,7 @@ var RobloxStudioTools = class {
12573
13660
  });
12574
13661
  }
12575
13662
  return this._textResult({
13663
+ test_worker_job_name: parseStudioTestWorkerJobName(process.env.RSMCP_STUDIO_TEST_WORKER_JOB),
12576
13664
  managed: (await this.instanceManager.list()).filter((record2) => record2.closedAt === void 0).map((record2) => this._managedStatus(record2)),
12577
13665
  connected: this.bridge.getPublicInstances().map((instance) => ({
12578
13666
  instance_id: instance.id,
@@ -12651,10 +13739,9 @@ var RobloxStudioTools = class {
12651
13739
  }
12652
13740
  record2 = active[0];
12653
13741
  }
12654
- if (record2.instanceId)
12655
- await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
12656
13742
  const closeResult = await this.instanceManager.close(record2);
12657
13743
  if (record2.instanceId) {
13744
+ await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
12658
13745
  await sleep(500);
12659
13746
  await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
12660
13747
  }
@@ -13757,11 +14844,11 @@ var RobloxStudioTools = class {
13757
14844
  return this.resolveUploadedReferenceImageId(decalId, instance_id);
13758
14845
  }
13759
14846
  async uploadAsset(filePath, assetType, displayName, description, userId, groupId) {
13760
- if (!fs3.existsSync(filePath)) {
14847
+ if (!fs5.existsSync(filePath)) {
13761
14848
  throw new Error(`File not found: ${filePath}`);
13762
14849
  }
13763
- const fileContent = fs3.readFileSync(filePath);
13764
- const fileName = path5.basename(filePath);
14850
+ const fileContent = fs5.readFileSync(filePath);
14851
+ const fileName = path8.basename(filePath);
13765
14852
  const resolvedGroupId = groupId || process.env.ROBLOX_CREATOR_GROUP_ID;
13766
14853
  const resolvedUserId = userId || process.env.ROBLOX_CREATOR_USER_ID;
13767
14854
  if (assetType === "Decal" && this.cookieClient.hasCookie()) {
@@ -13977,10 +15064,10 @@ var RobloxStudioTools = class {
13977
15064
  return { content: [{ type: "text", text: JSON.stringify({ error: "plugin returned no base64 payload" }) }] };
13978
15065
  }
13979
15066
  const bytes = Buffer.from(response.base64, "base64");
13980
- const resolved = path5.resolve(outputPath);
15067
+ const resolved = path8.resolve(outputPath);
13981
15068
  try {
13982
- fs3.mkdirSync(path5.dirname(resolved), { recursive: true });
13983
- fs3.writeFileSync(resolved, bytes);
15069
+ fs5.mkdirSync(path8.dirname(resolved), { recursive: true });
15070
+ fs5.writeFileSync(resolved, bytes);
13984
15071
  } catch (err2) {
13985
15072
  return { content: [{ type: "text", text: JSON.stringify({ error: `failed to write ${resolved}: ${err2.message}` }) }] };
13986
15073
  }
@@ -13995,7 +15082,7 @@ var RobloxStudioTools = class {
13995
15082
  }]
13996
15083
  };
13997
15084
  }
13998
- async importRbxm(source, parentPath, target, instance_id) {
15085
+ async importRbxm(source, parentPath, target, instance_id, signal) {
13999
15086
  if (!source || typeof source !== "object") {
14000
15087
  throw new Error("source is required for import_rbxm");
14001
15088
  }
@@ -14010,86 +15097,80 @@ var RobloxStudioTools = class {
14010
15097
  if (modes.length !== 1) {
14011
15098
  throw new Error(`source must contain exactly one of { path, url, base64 } (got: ${modes.join(", ") || "none"})`);
14012
15099
  }
14013
- let bytes;
14014
- let sourceLabel;
14015
- if (source.path !== void 0) {
14016
- const resolved = path5.resolve(source.path);
14017
- try {
14018
- bytes = fs3.readFileSync(resolved);
14019
- } catch (err2) {
14020
- return { content: [{ type: "text", text: JSON.stringify({ error: `failed to read ${resolved}: ${err2.message}` }) }] };
14021
- }
14022
- sourceLabel = resolved;
14023
- } else if (source.url !== void 0) {
14024
- let parsedUrl;
14025
- try {
14026
- parsedUrl = new URL(source.url);
14027
- } catch {
14028
- return { content: [{ type: "text", text: JSON.stringify({ error: `import_rbxm url is not a valid URL: ${source.url}` }) }] };
14029
- }
14030
- if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
14031
- return { content: [{ type: "text", text: JSON.stringify({ error: `import_rbxm url must use http(s); got ${parsedUrl.protocol}` }) }] };
14032
- }
14033
- const MAX_IMPORT_BYTES = 50 * 1024 * 1024;
14034
- try {
14035
- const res = await fetch(source.url);
14036
- if (!res.ok) {
14037
- const snippet = (await res.text()).slice(0, 500);
14038
- return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url} returned ${res.status}: ${snippet}` }) }] };
14039
- }
14040
- const claimed = Number(res.headers.get("content-length") ?? "0");
14041
- if (claimed > MAX_IMPORT_BYTES) {
14042
- return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url}: content-length ${claimed} exceeds ${MAX_IMPORT_BYTES} byte cap` }) }] };
14043
- }
14044
- const arr = await res.arrayBuffer();
14045
- if (arr.byteLength > MAX_IMPORT_BYTES) {
14046
- return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url}: downloaded ${arr.byteLength} bytes exceeds ${MAX_IMPORT_BYTES} byte cap` }) }] };
14047
- }
14048
- bytes = Buffer.from(arr);
14049
- } catch (err2) {
14050
- return { content: [{ type: "text", text: JSON.stringify({ error: `fetch ${source.url} failed: ${err2.message}` }) }] };
14051
- }
14052
- sourceLabel = source.url;
14053
- } else {
14054
- try {
14055
- bytes = Buffer.from(source.base64, "base64");
14056
- } catch (err2) {
14057
- return { content: [{ type: "text", text: JSON.stringify({ error: `base64 decode failed: ${err2.message}` }) }] };
14058
- }
14059
- 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) });
14060
15105
  }
15106
+ const { bytes, sourceLabel } = input;
14061
15107
  const response = await this._callSingle("/api/import-rbxm", {
14062
15108
  base64: bytes.toString("base64"),
14063
15109
  parent_path: parentPath,
14064
15110
  source_label: sourceLabel
14065
- }, tgt, instance_id);
15111
+ }, tgt, instance_id, void 0, signal);
14066
15112
  return { content: [{ type: "text", text: JSON.stringify(response) }] };
14067
15113
  }
15114
+ // Even native capture must wait while another call has fitted the simulator
15115
+ // or drawn markers. Otherwise it could observe temporary UI or dimensions.
14068
15116
  async _captureViewportImage(instanceId, targetRole, format, quality, maxBytes = MAX_INLINE_IMAGE_BYTES) {
15117
+ const previous = this.viewportCaptureQueues.get(instanceId) ?? Promise.resolve();
15118
+ const capture = previous.then(() => this._captureViewportImageNow(instanceId, targetRole, format, quality, maxBytes));
15119
+ const settled = capture.then(() => void 0, () => void 0);
15120
+ this.viewportCaptureQueues.set(instanceId, settled);
15121
+ try {
15122
+ return await capture;
15123
+ } finally {
15124
+ if (this.viewportCaptureQueues.get(instanceId) === settled)
15125
+ this.viewportCaptureQueues.delete(instanceId);
15126
+ }
15127
+ }
15128
+ async _captureViewportImageNow(instanceId, targetRole, format, quality, maxBytes = MAX_INLINE_IMAGE_BYTES) {
14069
15129
  const fmt = format === "png" ? "png" : "jpeg";
14070
15130
  const q = quality === void 0 ? 92 : Math.max(1, Math.min(100, Math.floor(quality)));
14071
15131
  let response = await this._callSingle("/api/capture-studio", { encoding: fmt === "png" ? "png" : "rgba8" }, targetRole, instanceId);
14072
15132
  const studioFastPathMissing = response.unavailable !== void 0 || (response.error?.includes("/api/capture-studio") ?? false);
15133
+ const studioFastPathUnavailable = studioFastPathMissing ? response.unavailable ?? response.error : void 0;
14073
15134
  if (studioFastPathMissing) {
14074
15135
  if (targetRole.startsWith("client-")) {
14075
15136
  const begin = await this._callSingle("/api/capture-begin", {}, targetRole, instanceId);
14076
15137
  if (begin.error) {
14077
- return { success: false, error: begin.error };
14078
- }
14079
- if (!begin.contentId) {
14080
- 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);
14081
15143
  }
14082
- response = await this._callSingle("/api/capture-read", { contentId: begin.contentId }, "edit", instanceId);
14083
15144
  } else {
14084
15145
  response = await this._callSingle("/api/capture-screenshot", {}, "edit", instanceId);
14085
15146
  }
15147
+ response.source ??= "CaptureService";
15148
+ }
15149
+ let hostNote = "";
15150
+ let hostReason;
15151
+ try {
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;
15153
+ } catch (error) {
15154
+ response = { ...response, error: `Could not decode Studio's screenshot: ${error instanceof Error ? error.message : String(error)}` };
15155
+ hostReason = `Studio's capture failed (${response.error})`;
15156
+ }
15157
+ if (hostReason !== void 0) {
15158
+ const host = await this._captureViewportFromHostWindow(instanceId, targetRole);
15159
+ if (host.success) {
15160
+ response = host.response;
15161
+ hostNote = ` Captured from the Studio window through the host OS because ${hostReason}.`;
15162
+ } else if (response.error) {
15163
+ response = { ...response, error: `${response.error} Host window capture also failed: ${host.error}.` };
15164
+ } else {
15165
+ hostNote = ` Warning: ${hostReason} and host window capture also failed (${host.error}), so this image may be blank.`;
15166
+ }
14086
15167
  }
14087
15168
  if (response.error) {
14088
15169
  let text = response.error;
14089
15170
  if (targetRole.startsWith("client-") && response.error.includes("Failed to load texture, unexpected format") && await this._isMultiplayerTestRunning(instanceId)) {
14090
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}`;
14091
15172
  }
14092
- return { success: false, error: text };
15173
+ return { success: false, error: text, studioFastPathUnavailable };
14093
15174
  }
14094
15175
  const w = response.width;
14095
15176
  const h = response.height;
@@ -14133,7 +15214,7 @@ var RobloxStudioTools = class {
14133
15214
  }
14134
15215
  const nativeW = response.nativeWidth ?? w;
14135
15216
  const nativeH = response.nativeHeight ?? h;
14136
- const message = nativeW !== w || nativeH !== h ? `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}, downscaled from the ${nativeW}x${nativeH} viewport to fit transport limits. For simulate_mouse_input, multiply x read off this image by ${(nativeW / w).toFixed(4)} and y by ${(nativeH / h).toFixed(4)} to get viewport pixel coordinates ((0,0) at the top-left).` : `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}. For simulate_mouse_input, x/y are pixel coordinates in this exact image with (0,0) at the top-left; it is not downscaled, so use coordinates as you read them off the image.`;
15217
+ const message = (nativeW !== w || nativeH !== h ? `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}, downscaled from the ${nativeW}x${nativeH} viewport to fit transport limits. For simulate_mouse_input, multiply x read off this image by ${(nativeW / w).toFixed(4)} and y by ${(nativeH / h).toFixed(4)} to get viewport pixel coordinates ((0,0) at the top-left).` : `Screenshot ${w}x${h}px (${fmt}${fmt === "jpeg" ? ` q${usedQ}` : ""})${note}. For simulate_mouse_input, x/y are pixel coordinates in this exact image with (0,0) at the top-left; it is not downscaled, so use coordinates as you read them off the image.`) + hostNote;
14137
15218
  return {
14138
15219
  success: true,
14139
15220
  width: w,
@@ -14143,7 +15224,172 @@ var RobloxStudioTools = class {
14143
15224
  note,
14144
15225
  data: buffer.toString("base64"),
14145
15226
  mimeType,
14146
- message
15227
+ message,
15228
+ source: response.source,
15229
+ studioFastPathUnavailable
15230
+ };
15231
+ }
15232
+ _isUniformCaptureResponse(response) {
15233
+ if (!response.data || !response.width || !response.height)
15234
+ return false;
15235
+ const data = Buffer.from(response.data, "base64");
15236
+ if (response.encoding === "png")
15237
+ return isUniformPng(data);
15238
+ return isUniformFrame(data, response.width, response.height);
15239
+ }
15240
+ _hostCaptureTitleHint(instanceId) {
15241
+ const peers = this.bridge.getPeers().filter((peer) => peer.instanceId === instanceId);
15242
+ const edit = peers.find((peer) => peer.role === "edit") ?? peers[0];
15243
+ const name = edit?.dataModelName || edit?.placeName;
15244
+ return name ? name : void 0;
15245
+ }
15246
+ async _callViewportMarkers(action, targetRole, instanceId, captureId) {
15247
+ return await this._callSingle("/api/capture-markers", { action, ...captureId ? { captureId } : {} }, targetRole, instanceId);
15248
+ }
15249
+ // Always restore the simulator, including failures and cached window grabs.
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
+ }
15264
+ let prepared;
15265
+ let result;
15266
+ try {
15267
+ prepared = await this._callViewportMarkers("prepare", targetRole, instanceId);
15268
+ if (prepared.error || !prepared.captureId || !prepared.viewportWidth || !prepared.viewportHeight) {
15269
+ result = { success: false, error: prepared.error ?? "the Studio plugin could not prepare host capture (update the plugin)" };
15270
+ } else {
15271
+ if (prepared.viewportChanged)
15272
+ this.hostViewportRects.delete(`${instanceId}|${targetRole}`);
15273
+ result = await this._capturePreparedViewportFromHostWindow(instanceId, targetRole, prepared);
15274
+ }
15275
+ } catch (error) {
15276
+ result = { success: false, error: `host capture failed: ${error instanceof Error ? error.message : String(error)}` };
15277
+ } finally {
15278
+ if (prepared?.captureId) {
15279
+ try {
15280
+ const restored = await this._callViewportMarkers("finish", targetRole, instanceId, prepared.captureId);
15281
+ if (restored.error)
15282
+ result = { success: false, error: `could not restore the Studio viewport: ${restored.error}` };
15283
+ else if (restored.stale || !restored.success)
15284
+ result = { success: false, error: "the host capture transaction expired or lost ownership; retry the capture" };
15285
+ } catch (error) {
15286
+ result = { success: false, error: `could not restore the Studio viewport: ${error instanceof Error ? error.message : String(error)}` };
15287
+ }
15288
+ }
15289
+ }
15290
+ if (!result.success)
15291
+ this.hostViewportRects.delete(`${instanceId}|${targetRole}`);
15292
+ return result;
15293
+ }
15294
+ // Grabs the Studio window through the host OS and crops it to the viewport
15295
+ // of `targetRole`. The plugin pins magenta markers to the viewport corners
15296
+ // so the crop is exact regardless of Studio's dock layout or DPI scale; the
15297
+ // located rect is cached briefly so repeated captures cost one window grab.
15298
+ async _capturePreparedViewportFromHostWindow(instanceId, targetRole, prepared) {
15299
+ const titleHint = this._hostCaptureTitleHint(instanceId);
15300
+ const cacheKey = `${instanceId}|${targetRole}`;
15301
+ const cached = this.hostViewportRects.get(cacheKey);
15302
+ let sizeInfo;
15303
+ try {
15304
+ sizeInfo = await this._callViewportMarkers("query", targetRole, instanceId, prepared.captureId);
15305
+ } catch (error) {
15306
+ return { success: false, error: `viewport markers unavailable: ${error instanceof Error ? error.message : String(error)}` };
15307
+ }
15308
+ if (sizeInfo.error || !sizeInfo.viewportWidth || !sizeInfo.viewportHeight) {
15309
+ return {
15310
+ success: false,
15311
+ error: sizeInfo.error ?? "the Studio plugin did not report a viewport size (update the plugin to a build with /api/capture-markers)"
15312
+ };
15313
+ }
15314
+ const viewportWidth = prepared.viewportWidth ?? sizeInfo.viewportWidth;
15315
+ const viewportHeight = prepared.viewportHeight ?? sizeInfo.viewportHeight;
15316
+ if (cached && Date.now() - cached.cachedAt < HOST_VIEWPORT_RECT_TTL_MS && cached.viewportWidth === viewportWidth && cached.viewportHeight === viewportHeight) {
15317
+ const grab = await this.hostWindowCapture(titleHint, cached.identity);
15318
+ if (!grab.ok)
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
+ }
15323
+ if (grab.capture.width === cached.windowWidth && grab.capture.height === cached.windowHeight) {
15324
+ return {
15325
+ success: true,
15326
+ response: this._hostResponseFromCrop(grab.capture.rgba, grab.capture.width, grab.capture.height, cached.rect, viewportWidth, viewportHeight)
15327
+ };
15328
+ }
15329
+ this.hostViewportRects.delete(cacheKey);
15330
+ }
15331
+ let shown;
15332
+ try {
15333
+ shown = await this._callViewportMarkers("show", targetRole, instanceId, prepared.captureId);
15334
+ } catch (error) {
15335
+ return { success: false, error: `could not draw viewport markers: ${error instanceof Error ? error.message : String(error)}` };
15336
+ }
15337
+ if (shown.error)
15338
+ return { success: false, error: `could not draw viewport markers: ${shown.error}` };
15339
+ const markerSize = shown.markerSize ?? 12;
15340
+ let markerGrab;
15341
+ let hidden;
15342
+ try {
15343
+ markerGrab = await this.hostWindowCapture(titleHint);
15344
+ } finally {
15345
+ hidden = await this._callViewportMarkers("hide", targetRole, instanceId, prepared.captureId);
15346
+ }
15347
+ if (hidden.error)
15348
+ return { success: false, error: `could not hide viewport markers: ${hidden.error}` };
15349
+ if (!markerGrab.ok)
15350
+ return { success: false, error: markerGrab.error };
15351
+ const located = findViewportRect(markerGrab.capture.rgba, markerGrab.capture.width, markerGrab.capture.height, {
15352
+ viewportWidth: shown.viewportWidth ?? viewportWidth,
15353
+ viewportHeight: shown.viewportHeight ?? viewportHeight,
15354
+ markerSize
15355
+ });
15356
+ if ("error" in located)
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
+ }
15368
+ this.hostViewportRects.set(cacheKey, {
15369
+ rect: located.rect,
15370
+ identity: markerGrab.capture.identity,
15371
+ windowWidth: markerGrab.capture.width,
15372
+ windowHeight: markerGrab.capture.height,
15373
+ viewportWidth,
15374
+ viewportHeight,
15375
+ cachedAt: Date.now()
15376
+ });
15377
+ return {
15378
+ success: true,
15379
+ response: this._hostResponseFromCrop(cleanGrab.capture.rgba, cleanGrab.capture.width, cleanGrab.capture.height, located.rect, viewportWidth, viewportHeight)
15380
+ };
15381
+ }
15382
+ _hostResponseFromCrop(rgba, width, height, rect, viewportWidth, viewportHeight) {
15383
+ const cropped = cropToViewport(rgba, width, height, rect, viewportWidth, viewportHeight);
15384
+ return {
15385
+ success: true,
15386
+ encoding: "rgba8",
15387
+ source: "host-window",
15388
+ width: cropped.width,
15389
+ height: cropped.height,
15390
+ nativeWidth: viewportWidth,
15391
+ nativeHeight: viewportHeight,
15392
+ data: cropped.rgba.toString("base64")
14147
15393
  };
14148
15394
  }
14149
15395
  async captureScreenshot(instance_id, format, quality) {
@@ -14153,7 +15399,7 @@ var RobloxStudioTools = class {
14153
15399
  const { instanceId, clientRole } = this._resolveRuntime(instance_id);
14154
15400
  const capture = await this._captureViewportImage(instanceId, clientRole ?? "edit", format, quality);
14155
15401
  if (!capture.success) {
14156
- return this._textResult({ error: capture.error });
15402
+ return this._textResult({ error: capture.error, studioFastPathUnavailable: capture.studioFastPathUnavailable });
14157
15403
  }
14158
15404
  return {
14159
15405
  content: [
@@ -14165,7 +15411,9 @@ var RobloxStudioTools = class {
14165
15411
  format: capture.format,
14166
15412
  mimeType: capture.mimeType,
14167
15413
  ...capture.quality === void 0 ? {} : { quality: capture.quality },
14168
- message: capture.message
15414
+ message: capture.message,
15415
+ source: capture.source,
15416
+ studioFastPathUnavailable: capture.studioFastPathUnavailable
14169
15417
  })
14170
15418
  },
14171
15419
  {
@@ -14315,6 +15563,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14315
15563
  const response = await fetch(`${this.primaryBaseUrl}/create-multiplayer-group`, {
14316
15564
  method: "POST",
14317
15565
  headers: this.authHeaders({ "Content-Type": "application/json" }),
15566
+ signal: AbortSignal.timeout(5e3),
14318
15567
  body: JSON.stringify({ groupId, controllerInstanceId })
14319
15568
  });
14320
15569
  if (!response.ok) {
@@ -14345,6 +15594,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14345
15594
  const response = await fetch(`${this.primaryBaseUrl}/remove-multiplayer-group`, {
14346
15595
  method: "POST",
14347
15596
  headers: this.authHeaders({ "Content-Type": "application/json" }),
15597
+ signal: AbortSignal.timeout(5e3),
14348
15598
  body: JSON.stringify({ groupId })
14349
15599
  });
14350
15600
  if (response.status === 409) {
@@ -14376,6 +15626,7 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14376
15626
  const response = await fetch(`${this.primaryBaseUrl}/unregister-instance-id`, {
14377
15627
  method: "POST",
14378
15628
  headers: this.authHeaders({ "Content-Type": "application/json" }),
15629
+ signal: AbortSignal.timeout(5e3),
14379
15630
  body: JSON.stringify({ instanceId })
14380
15631
  });
14381
15632
  if (!response.ok) {
@@ -14405,7 +15656,8 @@ var ProxyBridgeService = class _ProxyBridgeService extends BridgeService {
14405
15656
  }
14406
15657
  async getRequestStatusEverywhere(requestId) {
14407
15658
  const response = await fetch(`${this.primaryBaseUrl}/request-status?requestId=${encodeURIComponent(requestId)}`, {
14408
- headers: this.authHeaders()
15659
+ headers: this.authHeaders(),
15660
+ signal: AbortSignal.timeout(5e3)
14409
15661
  });
14410
15662
  if (!response.ok)
14411
15663
  throw new Error(`Proxy request status failed (${response.status})`);
@@ -14710,8 +15962,8 @@ var BoundedStdioTransport = class {
14710
15962
  this.onStreamError(error);
14711
15963
  return Promise.reject(error);
14712
15964
  }
14713
- const { promise, resolve: resolve5, reject } = Promise.withResolvers();
14714
- const pending = { resolve: resolve5, reject };
15965
+ const { promise, resolve: resolve6, reject } = Promise.withResolvers();
15966
+ const pending = { resolve: resolve6, reject };
14715
15967
  this.pendingSends.add(pending);
14716
15968
  this.pendingWrites++;
14717
15969
  try {
@@ -14723,7 +15975,7 @@ var BoundedStdioTransport = class {
14723
15975
  });
14724
15976
  if (writable) {
14725
15977
  this.pendingSends.delete(pending);
14726
- resolve5();
15978
+ resolve6();
14727
15979
  } else if (!this.closed) {
14728
15980
  this.outputBlocked = true;
14729
15981
  this.input.pause();
@@ -14822,6 +16074,7 @@ var RobloxStudioMCPServer = class {
14822
16074
  try {
14823
16075
  const result = await listenWithRetry(candidateApp, host, basePort, 1);
14824
16076
  const oldBridge = this.bridge;
16077
+ const oldTools = this.tools;
14825
16078
  this.bridge = candidateBridge;
14826
16079
  this.tools = candidateTools;
14827
16080
  if (oldBridge instanceof ProxyBridgeService) {
@@ -14835,6 +16088,7 @@ var RobloxStudioMCPServer = class {
14835
16088
  console.error(`Promoted from proxy to primary on port ${boundPort}`);
14836
16089
  if (promotionInterval)
14837
16090
  clearInterval(promotionInterval);
16091
+ await oldTools.dispose();
14838
16092
  } catch {
14839
16093
  await candidateApp.cleanup().catch(() => {
14840
16094
  });
@@ -14899,6 +16153,7 @@ var RobloxStudioMCPServer = class {
14899
16153
  });
14900
16154
  if (httpHandle)
14901
16155
  httpHandle.close();
16156
+ await this.tools.dispose();
14902
16157
  process.exit(0);
14903
16158
  };
14904
16159
  process.on("SIGTERM", shutdown);
@@ -16560,7 +17815,7 @@ var TOOL_DEFINITIONS = [
16560
17815
  properties: {
16561
17816
  source: {
16562
17817
  type: "object",
16563
- 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.",
16564
17819
  properties: {
16565
17820
  path: { type: "string", description: "Absolute local .rbxm path." },
16566
17821
  url: { type: "string", description: "HTTP or HTTPS .rbxm URL." },
@@ -16689,10 +17944,10 @@ var getAllTools = () => [...TOOL_DEFINITIONS];
16689
17944
 
16690
17945
  // ../core/dist/install-plugin-helpers.js
16691
17946
  var import_saxes = __toESM(require_saxes(), 1);
16692
- import { existsSync as existsSync6, lstatSync, mkdirSync as mkdirSync4, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, 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";
16693
17948
  import { execSync } from "child_process";
16694
17949
  import { randomUUID as randomUUID5 } from "crypto";
16695
- import { basename as basename3, join as join5 } from "path";
17950
+ import { basename as basename3, join as join8 } from "path";
16696
17951
  import { homedir as homedir4 } from "os";
16697
17952
  import { isUtf8 } from "buffer";
16698
17953
  var DEFAULT_PLUGIN_PORT = 58741;
@@ -16744,15 +17999,15 @@ function getPluginsFolder() {
16744
17999
  if (process.env.MCP_PLUGINS_DIR)
16745
18000
  return process.env.MCP_PLUGINS_DIR;
16746
18001
  if (process.platform === "win32") {
16747
- return join5(process.env.LOCALAPPDATA || join5(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
18002
+ return join8(process.env.LOCALAPPDATA || join8(homedir4(), "AppData", "Local"), "Roblox", "Plugins");
16748
18003
  }
16749
18004
  if (isWSL()) {
16750
18005
  const localAppData = getWindowsUserLocalAppData();
16751
18006
  if (localAppData)
16752
- return join5(localAppData.linuxPath, "Roblox", "Plugins");
18007
+ return join8(localAppData.linuxPath, "Roblox", "Plugins");
16753
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.");
16754
18009
  }
16755
- return join5(homedir4(), "Documents", "Roblox", "Plugins");
18010
+ return join8(homedir4(), "Documents", "Roblox", "Plugins");
16756
18011
  }
16757
18012
  var STALE_TEST_PLUGINS_DIRECTORY = "RsmcpIsolatedPlugins";
16758
18013
  function pluginDirectorySettingPattern() {
@@ -16762,12 +18017,12 @@ function escapeXmlText(value) {
16762
18017
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
16763
18018
  }
16764
18019
  function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDirectory }) {
16765
- if (!existsSync6(settingsPath))
18020
+ if (!existsSync7(settingsPath))
16766
18021
  return false;
16767
18022
  if (studioPluginsDirectory === "") {
16768
18023
  throw new Error("studioPluginsDirectory must be a non-empty path.");
16769
18024
  }
16770
- const contents = readFileSync6(settingsPath, "utf8");
18025
+ const contents = readFileSync8(settingsPath, "utf8");
16771
18026
  const matches = [...contents.matchAll(pluginDirectorySettingPattern())];
16772
18027
  if (matches.length !== 1) {
16773
18028
  throw new Error(`Expected exactly one Studio.PluginsDir QDir in ${settingsPath}; found ${matches.length}.`);
@@ -16775,16 +18030,16 @@ function repairStaleStudioPluginDirectorySetting({ settingsPath, studioPluginsDi
16775
18030
  if (matches[0][2] !== STALE_TEST_PLUGINS_DIRECTORY)
16776
18031
  return false;
16777
18032
  const studioPath = escapeXmlText(studioPluginsDirectory);
16778
- 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}`);
16779
18034
  const temporaryPath = `${settingsPath}.rsmcp-install-${process.pid}-${randomUUID5()}.tmp`;
16780
18035
  try {
16781
- writeFileSync3(temporaryPath, updated, {
18036
+ writeFileSync4(temporaryPath, updated, {
16782
18037
  encoding: "utf8",
16783
18038
  mode: statSync3(settingsPath).mode
16784
18039
  });
16785
18040
  renameSync(temporaryPath, settingsPath);
16786
18041
  } finally {
16787
- rmSync2(temporaryPath, { force: true });
18042
+ rmSync4(temporaryPath, { force: true });
16788
18043
  }
16789
18044
  return true;
16790
18045
  }
@@ -16795,16 +18050,16 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
16795
18050
  let defaultPluginsFolder;
16796
18051
  let studioPluginsDirectory;
16797
18052
  if (process.platform === "win32") {
16798
- const localAppData = process.env.LOCALAPPDATA || join5(homedir4(), "AppData", "Local");
16799
- defaultPluginsFolder = join5(localAppData, "Roblox", "Plugins");
16800
- settingsPath = join5(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");
16801
18056
  studioPluginsDirectory = defaultPluginsFolder.replaceAll("\\", "/");
16802
18057
  } else if (isWSL()) {
16803
18058
  const localAppData = getWindowsUserLocalAppData();
16804
18059
  if (!localAppData)
16805
18060
  return;
16806
- defaultPluginsFolder = join5(localAppData.linuxPath, "Roblox", "Plugins");
16807
- settingsPath = join5(localAppData.linuxPath, "Roblox", "GlobalSettings_13.xml");
18061
+ defaultPluginsFolder = join8(localAppData.linuxPath, "Roblox", "Plugins");
18062
+ settingsPath = join8(localAppData.linuxPath, "Roblox", "GlobalSettings_13.xml");
16808
18063
  studioPluginsDirectory = [
16809
18064
  localAppData.windowsPath.replace(/[\\/]+$/u, ""),
16810
18065
  "Roblox",
@@ -16820,12 +18075,12 @@ function repairDefaultStudioPluginDirectorySetting(pluginsFolder, log) {
16820
18075
  }
16821
18076
  }
16822
18077
  function handleVariantConflict({ pluginsFolder, otherAssetName, replace, log = console.log, warn = console.warn }) {
16823
- const otherDest = join5(pluginsFolder, otherAssetName);
16824
- if (!existsSync6(otherDest))
18078
+ const otherDest = join8(pluginsFolder, otherAssetName);
18079
+ if (!existsSync7(otherDest))
16825
18080
  return;
16826
18081
  if (replace) {
16827
18082
  try {
16828
- unlinkSync(otherDest);
18083
+ unlinkSync2(otherDest);
16829
18084
  log(`Removed conflicting ${otherAssetName}.`);
16830
18085
  } catch (err2) {
16831
18086
  warn(`[install-plugin] Could not remove ${otherDest}: ${err2}. Continuing.`);
@@ -16846,11 +18101,11 @@ function pluginInstallLockError(lockPath, reason) {
16846
18101
  return new Error(`${reason}: ${lockPath}. If no installer or lock recovery is running, remove that lock directory and retry.`);
16847
18102
  }
16848
18103
  function readPluginInstallLockOwner(lockPath) {
16849
- const ownerPath = join5(lockPath, "owner.json");
16850
- if (!lstatSync(lockPath).isDirectory() || !lstatSync(ownerPath).isFile()) {
18104
+ const ownerPath = join8(lockPath, "owner.json");
18105
+ if (!lstatSync2(lockPath).isDirectory() || !lstatSync2(ownerPath).isFile()) {
16851
18106
  throw new Error("Lock and owner metadata must be a directory and regular file");
16852
18107
  }
16853
- const owner = JSON.parse(readFileSync6(ownerPath, "utf8"));
18108
+ const owner = JSON.parse(readFileSync8(ownerPath, "utf8"));
16854
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) {
16855
18110
  throw new Error("Invalid lock owner metadata");
16856
18111
  }
@@ -16876,7 +18131,7 @@ function recoverPluginInstallLock(lockPath, warn) {
16876
18131
  throw pluginInstallLockError(lockPath, `Cannot safely read plugin installation lock owner (${error})`);
16877
18132
  }
16878
18133
  assertPluginInstallOwnerExited(lockPath, owner);
16879
- const claimPath = join5(lockPath, `recovery-${owner.token}`);
18134
+ const claimPath = join8(lockPath, `recovery-${owner.token}`);
16880
18135
  try {
16881
18136
  mkdirSync4(claimPath);
16882
18137
  } catch (error) {
@@ -16909,14 +18164,14 @@ function recoverPluginInstallLock(lockPath, warn) {
16909
18164
  }
16910
18165
  }
16911
18166
  try {
16912
- rmSync2(abandonedPath, { recursive: true, force: true });
18167
+ rmSync4(abandonedPath, { recursive: true, force: true });
16913
18168
  } catch (error) {
16914
18169
  warn(`[install-plugin] Could not clean abandoned install lock ${abandonedPath}: ${error}`);
16915
18170
  }
16916
18171
  }
16917
18172
  function acquirePluginInstallLock(pluginsFolder, warn) {
16918
- const lockPath = join5(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
16919
- const ownerPath = join5(lockPath, "owner.json");
18173
+ const lockPath = join8(pluginsFolder, PLUGIN_INSTALL_LOCK_NAME);
18174
+ const ownerPath = join8(lockPath, "owner.json");
16920
18175
  const owner = {
16921
18176
  pid: process.pid,
16922
18177
  token: randomUUID5(),
@@ -16937,14 +18192,14 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
16937
18192
  }
16938
18193
  }
16939
18194
  try {
16940
- writeFileSync3(ownerPath, `${JSON.stringify(owner)}
18195
+ writeFileSync4(ownerPath, `${JSON.stringify(owner)}
16941
18196
  `, {
16942
18197
  encoding: "utf8",
16943
18198
  flag: "wx"
16944
18199
  });
16945
18200
  } catch (error) {
16946
18201
  try {
16947
- rmSync2(lockPath, { recursive: true, force: true });
18202
+ rmSync4(lockPath, { recursive: true, force: true });
16948
18203
  } catch (cleanupError) {
16949
18204
  warn(`[install-plugin] Could not clean failed install lock ${lockPath}: ${cleanupError}`);
16950
18205
  }
@@ -16954,7 +18209,7 @@ function acquirePluginInstallLock(pluginsFolder, warn) {
16954
18209
  try {
16955
18210
  const currentOwner = readPluginInstallLockOwner(lockPath);
16956
18211
  if (currentOwner.pid === owner.pid && currentOwner.token === owner.token) {
16957
- rmSync2(lockPath, { recursive: true, force: true });
18212
+ rmSync4(lockPath, { recursive: true, force: true });
16958
18213
  }
16959
18214
  } catch (error) {
16960
18215
  if (errorCode(error) !== "ENOENT") {
@@ -17046,11 +18301,11 @@ function assertPluginAssetIdentity(source, assetName, expectedVersion, expectedV
17046
18301
  }
17047
18302
  }
17048
18303
  function installedFileMatches(source, destination) {
17049
- if (!existsSync6(destination))
18304
+ if (!existsSync7(destination))
17050
18305
  return false;
17051
- if (!lstatSync(destination).isFile())
18306
+ if (!lstatSync2(destination).isFile())
17052
18307
  return false;
17053
- const installed = readFileSync6(destination);
18308
+ const installed = readFileSync8(destination);
17054
18309
  return installed.length === source.length && installed.equals(source);
17055
18310
  }
17056
18311
  function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source, expectedVersion, expectedVariant, rawPort, replaceVariant = true, log = console.log, warn = console.warn }) {
@@ -17062,19 +18317,19 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
17062
18317
  const configured = configurePluginAssetForPort(source, rawPort);
17063
18318
  assertPluginAssetIdentity(configured, assetName, expectedVersion, expectedVariant);
17064
18319
  mkdirSync4(pluginsFolder, { recursive: true });
17065
- const destination = join5(pluginsFolder, assetName);
18320
+ const destination = join8(pluginsFolder, assetName);
17066
18321
  const releaseLock = acquirePluginInstallLock(pluginsFolder, warn);
17067
18322
  try {
17068
18323
  let installed = false;
17069
18324
  if (!installedFileMatches(configured, destination)) {
17070
- const staged = join5(pluginsFolder, `.${assetName}.${process.pid}.${randomUUID5()}.tmp`);
18325
+ const staged = join8(pluginsFolder, `.${assetName}.${process.pid}.${randomUUID5()}.tmp`);
17071
18326
  try {
17072
- writeFileSync3(staged, configured, { flag: "wx" });
18327
+ writeFileSync4(staged, configured, { flag: "wx" });
17073
18328
  renameSync(staged, destination);
17074
18329
  installed = true;
17075
18330
  } finally {
17076
18331
  try {
17077
- unlinkSync(staged);
18332
+ unlinkSync2(staged);
17078
18333
  } catch (error) {
17079
18334
  if (errorCode(error) !== "ENOENT") {
17080
18335
  warn(`[install-plugin] Could not remove staging file ${staged}: ${error}`);
@@ -17096,175 +18351,156 @@ function installPluginAsset({ pluginsFolder, assetName, otherAssetName, source,
17096
18351
  }
17097
18352
  }
17098
18353
 
17099
- // src/index.ts
17100
- import { createRequire } from "module";
17101
-
17102
- // src/install-plugin.ts
17103
- import { existsSync as existsSync7, readFileSync as readFileSync7 } from "fs";
17104
- import { dirname as dirname5, join as join6 } 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";
17105
18357
  import { fileURLToPath } from "url";
17106
18358
  import { get } from "https";
17107
18359
  var REPO = "chrrxs/robloxstudio-mcp";
17108
- var ASSET_NAME = "MCPPlugin.rbxmx";
17109
- var OTHER_VARIANT = "MCPInspectorPlugin.rbxmx";
17110
18360
  var TIMEOUT_MS = 3e4;
17111
18361
  var MAX_REDIRECTS = 5;
17112
18362
  var MAX_ASSET_BYTES = 64 * 1024 * 1024;
17113
- function httpsGet(url) {
17114
- return new Promise((resolve5, reject) => {
17115
- const req = get(url, { headers: { "User-Agent": "robloxstudio-mcp" } }, resolve5);
17116
- req.on("error", reject);
17117
- req.setTimeout(TIMEOUT_MS, () => {
17118
- 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
+ });
17119
18376
  });
17120
- });
17121
- }
17122
- async function download(url, redirects = 0) {
17123
- const res = await httpsGet(url);
17124
- if (res.statusCode === 301 || res.statusCode === 302) {
17125
- if (redirects >= MAX_REDIRECTS) throw new Error(`Too many redirects (max ${MAX_REDIRECTS})`);
17126
- const location = res.headers.location;
17127
- if (!location) throw new Error("Redirect with no location header");
17128
- for await (const _chunk of res) {
17129
- }
17130
- return download(location, redirects + 1);
17131
- }
17132
- if (res.statusCode !== 200) {
17133
- throw new Error(`Download failed: HTTP ${res.statusCode}`);
17134
18377
  }
17135
- const chunks = [];
17136
- let totalBytes = 0;
17137
- for await (const chunk of res) {
17138
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
17139
- totalBytes += bytes.length;
17140
- if (totalBytes > MAX_ASSET_BYTES) {
17141
- res.destroy();
17142
- throw new Error(
17143
- `${ASSET_NAME} download exceeds the ${MAX_ASSET_BYTES}-byte limit.`
17144
- );
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}`);
17145
18393
  }
17146
- 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);
17147
18406
  }
17148
- return Buffer.concat(chunks, totalBytes);
17149
- }
17150
- async function fetchJson(url) {
17151
- const res = await httpsGet(url);
17152
- if (res.statusCode !== 200) {
17153
- 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());
17154
18417
  }
17155
- const chunks = [];
17156
- for await (const chunk of res) {
17157
- 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;
17158
18424
  }
17159
- return JSON.parse(Buffer.concat(chunks).toString());
17160
- }
17161
- async function findDevRelease() {
17162
- const releases = await fetchJson(`https://api.github.com/repos/${REPO}/releases?per_page=20`);
17163
- const prerelease = releases.find(
17164
- (r) => r.prerelease && r.assets.some((a) => a.name === ASSET_NAME)
17165
- );
17166
- if (!prerelease) {
17167
- throw new Error(`No prerelease found with ${ASSET_NAME}`);
17168
- }
17169
- return prerelease;
17170
- }
17171
- function bundledAssetPath() {
17172
- const currentDir = dirname5(fileURLToPath(import.meta.url));
17173
- const candidates = [
17174
- join6(currentDir, "..", "studio-plugin", ASSET_NAME),
17175
- join6(currentDir, "..", "..", "..", "studio-plugin", ASSET_NAME)
17176
- ];
17177
- return candidates.find((candidate) => existsSync7(candidate)) ?? null;
17178
- }
17179
- function resolvePluginAssetPath(sourcePath) {
17180
- if (sourcePath === void 0) return bundledAssetPath();
17181
- return existsSync7(sourcePath) ? sourcePath : null;
17182
- }
17183
- function packageVersion() {
17184
- const currentDir = dirname5(fileURLToPath(import.meta.url));
17185
- const pkg = JSON.parse(readFileSync7(join6(currentDir, "..", "package.json"), "utf8"));
17186
- if (!pkg.version) {
17187
- 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;
17188
18429
  }
17189
- return pkg.version;
17190
- }
17191
- async function installBundledPlugin(options = {}) {
17192
- const log = options.log ?? console.log;
17193
- const warn = options.warn ?? console.warn;
17194
- const replaceVariant = options.replaceVariant ?? true;
17195
- const sourcePath = resolvePluginAssetPath(options.sourcePath);
17196
- if (!sourcePath) {
17197
- throw new Error(
17198
- `Bundled ${ASSET_NAME} not found. Run npm run build:plugin in this worktree first.`
17199
- );
17200
- }
17201
- const result = await installPluginAsset({
17202
- pluginsFolder: getPluginsFolder(),
17203
- assetName: ASSET_NAME,
17204
- otherAssetName: OTHER_VARIANT,
17205
- source: readFileSync7(sourcePath),
17206
- expectedVersion: packageVersion(),
17207
- expectedVariant: "main",
17208
- replaceVariant,
17209
- log,
17210
- warn
17211
- });
17212
- if (result.installed) {
17213
- 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;
17214
18436
  }
17215
- }
17216
- async function installPlugin(options = {}) {
17217
- const dev = options.dev ?? process.argv.includes("--dev");
17218
- const replaceVariant = options.replaceVariant ?? true;
17219
- const log = options.log ?? console.log;
17220
- const warn = options.warn ?? console.warn;
17221
- const bundled = resolvePluginAssetPath(options.sourcePath);
17222
- if (options.sourcePath !== void 0 && !bundled) {
17223
- throw new Error(`Plugin asset not found at explicit path ${options.sourcePath}.`);
17224
- }
17225
- if (bundled) {
17226
- const result2 = await installPluginAsset({
18437
+ function installSource(source, options) {
18438
+ return installPluginAsset({
17227
18439
  pluginsFolder: getPluginsFolder(),
17228
18440
  assetName: ASSET_NAME,
17229
18441
  otherAssetName: OTHER_VARIANT,
17230
- source: readFileSync7(bundled),
18442
+ source,
17231
18443
  expectedVersion: packageVersion(),
17232
- expectedVariant: "main",
17233
- replaceVariant,
17234
- log,
17235
- warn
18444
+ expectedVariant: variant,
18445
+ replaceVariant: options.replaceVariant ?? true,
18446
+ log: options.log ?? console.log,
18447
+ warn: options.warn ?? console.warn
17236
18448
  });
17237
- if (result2.installed) {
17238
- 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}`);
17239
18487
  } else {
17240
18488
  log(`${ASSET_NAME} already installed.`);
17241
18489
  }
17242
- return;
17243
- }
17244
- log(dev ? "Fetching latest dev prerelease..." : "Fetching latest release...");
17245
- const release = dev ? await findDevRelease() : await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
17246
- const asset = release.assets?.find((a) => a.name === ASSET_NAME);
17247
- if (!asset) {
17248
- throw new Error(`${ASSET_NAME} not found in release ${release.tag_name}`);
17249
- }
17250
- log(`Downloading ${ASSET_NAME} from ${release.tag_name}...`);
17251
- const downloaded = await download(asset.browser_download_url);
17252
- const result = await installPluginAsset({
17253
- pluginsFolder: getPluginsFolder(),
17254
- assetName: ASSET_NAME,
17255
- otherAssetName: OTHER_VARIANT,
17256
- source: downloaded,
17257
- expectedVersion: release.tag_name.replace(/^v/, ""),
17258
- expectedVariant: "main",
17259
- replaceVariant,
17260
- log,
17261
- warn
17262
- });
17263
- if (result.installed) {
17264
- log(`Installed to ${result.destination}`);
17265
- } else {
17266
- log(`${ASSET_NAME} already installed.`);
17267
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"));
17268
18504
  }
17269
18505
 
17270
18506
  // src/index.ts