@chrrxs/robloxstudio-mcp-inspector 2.22.3 → 2.22.5

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
@@ -974,7 +974,7 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
974
974
  serverVersion: serverConfig?.version,
975
975
  capabilities: studioLifecycleCallable ? {
976
976
  studioLifecycle: {
977
- protocolVersion: 1,
977
+ protocolVersion: 3,
978
978
  endpoint: "/mcp/manage_instance"
979
979
  }
980
980
  } : {},
@@ -3024,34 +3024,124 @@ var init_roblox_cookie_client = __esm({
3024
3024
  }
3025
3025
  return response;
3026
3026
  }
3027
- async uploadDecal(fileContent, name, description) {
3027
+ async uploadImage(options) {
3028
3028
  if (!this.cookie) {
3029
3029
  throw new Error("ROBLOSECURITY cookie is not set.");
3030
3030
  }
3031
- const encodedName = encodeURIComponent(name);
3032
- const encodedDesc = encodeURIComponent(description);
3033
- const url = `https://data.roblox.com/data/upload/json?assetTypeId=13&name=${encodedName}&description=${encodedDesc}`;
3034
- const response = await this.fetchWithCsrf(url, {
3031
+ const creator = await this.resolveCreator(options.userId, options.groupId);
3032
+ const request = {
3033
+ assetType: "Image",
3034
+ displayName: options.displayName,
3035
+ description: options.description,
3036
+ creationContext: { creator }
3037
+ };
3038
+ const formData = new FormData();
3039
+ formData.append("request", JSON.stringify(request));
3040
+ formData.append("fileContent", new Blob([new Uint8Array(options.fileContent)], { type: this.getImageMimeType(options.fileName) }), options.fileName);
3041
+ const response = await this.fetchWithCsrf("https://apis.roblox.com/assets/user-auth/v1/assets", {
3035
3042
  method: "POST",
3036
- headers: {
3037
- "Content-Type": "application/octet-stream",
3038
- "User-Agent": "RobloxStudio/WinInet",
3039
- Requester: "Client"
3040
- },
3041
- body: new Uint8Array(fileContent)
3043
+ body: formData
3042
3044
  });
3045
+ const operation = await this.readOperation(response, "Image upload");
3046
+ return { assetId: await this.completeOperation(operation) };
3047
+ }
3048
+ async resolveCreator(userId, groupId) {
3049
+ if (groupId)
3050
+ return { groupId };
3051
+ if (userId)
3052
+ return { userId };
3053
+ const response = await this.fetchWithCsrf("https://users.roblox.com/v1/users/authenticated");
3043
3054
  if (!response.ok) {
3044
3055
  const body = await response.text();
3045
- throw new Error(`Decal upload failed (${response.status}): ${body}`);
3056
+ throw new Error(`Failed to resolve authenticated Roblox user (${response.status}): ${body}`);
3046
3057
  }
3047
- const result = await response.json();
3048
- if (!result.Success || !result.AssetId) {
3049
- throw new Error(`Decal upload failed: ${result.Message || "Unknown error"}`);
3058
+ const authenticatedUser = await response.json();
3059
+ const resolvedUserId = String(authenticatedUser.id ?? "");
3060
+ if (!/^\d+$/.test(resolvedUserId) || resolvedUserId === "0") {
3061
+ throw new Error("Authenticated Roblox user response did not include a valid user ID.");
3050
3062
  }
3051
- return {
3052
- assetId: result.AssetId,
3053
- backingAssetId: result.BackingAssetId || 0
3063
+ return { userId: resolvedUserId };
3064
+ }
3065
+ getImageMimeType(fileName) {
3066
+ const extension = fileName.split(".").pop()?.toLowerCase();
3067
+ const mimeTypes = {
3068
+ png: "image/png",
3069
+ jpg: "image/jpeg",
3070
+ jpeg: "image/jpeg",
3071
+ bmp: "image/bmp",
3072
+ tga: "image/tga"
3054
3073
  };
3074
+ const mimeType = extension ? mimeTypes[extension] : void 0;
3075
+ if (mimeType)
3076
+ return mimeType;
3077
+ throw new Error(`Unsupported image format: .${extension ?? "(none)"}. Supported: png/jpg/jpeg/bmp/tga`);
3078
+ }
3079
+ async readOperation(response, action) {
3080
+ const body = await response.text();
3081
+ if (!response.ok) {
3082
+ throw new Error(`${action} failed (${response.status}): ${body}`);
3083
+ }
3084
+ try {
3085
+ return JSON.parse(body);
3086
+ } catch {
3087
+ throw new Error(`${action} returned malformed JSON: ${body}`);
3088
+ }
3089
+ }
3090
+ operationAssetId(operation) {
3091
+ const rawAssetId = operation.response?.assetId;
3092
+ if (rawAssetId === void 0)
3093
+ return null;
3094
+ const assetId = Number(rawAssetId);
3095
+ if (!Number.isSafeInteger(assetId) || assetId <= 0) {
3096
+ throw new Error(`Image upload returned an invalid asset ID: ${String(rawAssetId)}`);
3097
+ }
3098
+ return assetId;
3099
+ }
3100
+ operationError(operation) {
3101
+ if (operation.error?.message)
3102
+ return operation.error.message;
3103
+ if (operation.response?.message && operation.response.assetId === void 0) {
3104
+ return operation.response.message;
3105
+ }
3106
+ if (operation.message)
3107
+ return operation.message;
3108
+ return null;
3109
+ }
3110
+ async completeOperation(operation) {
3111
+ const initialError = this.operationError(operation);
3112
+ if (initialError)
3113
+ throw new Error(`Image upload failed: ${initialError}`);
3114
+ const initialAssetId = this.operationAssetId(operation);
3115
+ if (initialAssetId !== null)
3116
+ return initialAssetId;
3117
+ if (operation.done) {
3118
+ throw new Error("Image upload completed without an asset ID.");
3119
+ }
3120
+ const operationId = operation.operationId ?? operation.path?.split("/").pop();
3121
+ if (!operationId) {
3122
+ throw new Error("Image upload response did not include an operation ID.");
3123
+ }
3124
+ return this.pollOperation(operationId);
3125
+ }
3126
+ async pollOperation(operationId, maxAttempts = 30, intervalMs = 2e3) {
3127
+ const url = `https://apis.roblox.com/assets/user-auth/v1/operations/${encodeURIComponent(operationId)}`;
3128
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
3129
+ const response = await this.fetchWithCsrf(url);
3130
+ const operation = await this.readOperation(response, "Image upload status");
3131
+ const operationError = this.operationError(operation);
3132
+ if (operationError)
3133
+ throw new Error(`Image upload failed: ${operationError}`);
3134
+ const assetId = this.operationAssetId(operation);
3135
+ if (assetId !== null)
3136
+ return assetId;
3137
+ if (operation.done) {
3138
+ throw new Error("Image upload completed without an asset ID.");
3139
+ }
3140
+ if (attempt < maxAttempts - 1) {
3141
+ await new Promise((resolve5) => setTimeout(resolve5, intervalMs));
3142
+ }
3143
+ }
3144
+ throw new Error(`Image upload timed out after ${maxAttempts * intervalMs / 1e3}s. Operation ID: ${operationId}`);
3055
3145
  }
3056
3146
  async getAssetDetails(assetIds) {
3057
3147
  if (!this.cookie) {
@@ -3073,7 +3163,8 @@ var init_roblox_cookie_client = __esm({
3073
3163
  });
3074
3164
 
3075
3165
  // ../core/dist/managed-instance-registry.js
3076
- import * as fs from "fs";
3166
+ import * as fs from "fs/promises";
3167
+ import { randomUUID } from "crypto";
3077
3168
  import * as os from "os";
3078
3169
  import * as path from "path";
3079
3170
  function defaultManagedInstanceRegistryDir() {
@@ -3088,8 +3179,8 @@ function defaultManagedInstanceRegistryDir() {
3088
3179
  }
3089
3180
  return path.join(os.homedir(), ".local", "state", "robloxstudio-mcp", "managed-instances", "v1");
3090
3181
  }
3091
- function sleepSync(ms) {
3092
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
3182
+ function delay(ms) {
3183
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
3093
3184
  }
3094
3185
  function ymd(timestamp) {
3095
3186
  return new Date(timestamp).toISOString().slice(0, 10);
@@ -3104,92 +3195,134 @@ function isRecord(value) {
3104
3195
  const record = value;
3105
3196
  return record.version === REGISTRY_VERSION && typeof record.recordId === "string" && typeof record.source === "string" && typeof record.exe === "string" && Array.isArray(record.args) && typeof record.launchedAt === "number" && typeof record.bootId === "string";
3106
3197
  }
3107
- var REGISTRY_VERSION, LOCK_STALE_MS, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, EVENT_RETENTION_DAYS, TERMINAL_RECORD_RETENTION_MS, ManagedInstanceRegistry;
3198
+ function isLockOwner(value) {
3199
+ if (!value || typeof value !== "object")
3200
+ return false;
3201
+ const owner = value;
3202
+ return Number.isInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" && owner.token.length > 0 && typeof owner.createdAt === "number";
3203
+ }
3204
+ function isProcessAlive(pid) {
3205
+ if (pid === process.pid)
3206
+ return true;
3207
+ try {
3208
+ process.kill(pid, 0);
3209
+ return true;
3210
+ } catch (error) {
3211
+ return error.code === "EPERM";
3212
+ }
3213
+ }
3214
+ var REGISTRY_VERSION, LOCK_STALE_MS, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, EVENT_RETENTION_DAYS, TERMINAL_RECORD_RETENTION_MS, DEFAULT_CONFIRMED_EXIT_MISSES, DEFAULT_CONFIRMED_EXIT_GRACE_MS, activeLockTokens, activeLockPaths, ManagedInstanceRegistry;
3108
3215
  var init_managed_instance_registry = __esm({
3109
3216
  "../core/dist/managed-instance-registry.js"() {
3110
3217
  "use strict";
3111
3218
  REGISTRY_VERSION = 1;
3112
3219
  LOCK_STALE_MS = 1e4;
3113
3220
  LOCK_RETRY_MS = 25;
3114
- LOCK_TIMEOUT_MS = 5e3;
3221
+ LOCK_TIMEOUT_MS = 15e3;
3115
3222
  EVENT_RETENTION_DAYS = 2;
3116
3223
  TERMINAL_RECORD_RETENTION_MS = 24 * 60 * 60 * 1e3;
3224
+ DEFAULT_CONFIRMED_EXIT_MISSES = 2;
3225
+ DEFAULT_CONFIRMED_EXIT_GRACE_MS = 5e3;
3226
+ activeLockTokens = /* @__PURE__ */ new Set();
3227
+ activeLockPaths = /* @__PURE__ */ new Set();
3117
3228
  ManagedInstanceRegistry = class {
3118
3229
  dir;
3119
3230
  constructor(dir = defaultManagedInstanceRegistryDir()) {
3120
3231
  this.dir = dir;
3121
3232
  }
3122
- upsert(record) {
3123
- this.withLock(() => this.writeRecordUnlocked(record));
3233
+ async upsert(record) {
3234
+ await this.withLock(() => this.writeRecordUnlocked(record));
3124
3235
  }
3125
- attachInstanceId(recordId, instanceId) {
3126
- this.withLock(() => {
3127
- const record = this.readRecordUnlocked(recordId);
3236
+ async attachInstanceId(recordId, instanceId) {
3237
+ await this.withLock(async () => {
3238
+ const record = await this.readRecordUnlocked(recordId);
3128
3239
  if (!record)
3129
3240
  return;
3130
3241
  record.instanceId = instanceId;
3131
3242
  record.attachedAt = Date.now();
3132
- this.writeRecordUnlocked(record);
3243
+ await this.writeRecordUnlocked(record);
3133
3244
  });
3134
3245
  }
3135
- findOpenByInstanceId(instanceId, options) {
3136
- return this.withLock(() => {
3137
- this.sweepUnlocked(options);
3138
- return this.readOpenRecordsUnlocked().find((record) => record.instanceId === instanceId);
3246
+ async findOpenByInstanceId(instanceId, options) {
3247
+ return this.withLock(async () => {
3248
+ await this.sweepUnlocked(options);
3249
+ return (await this.readOpenRecordsUnlocked()).find((record) => record.instanceId === instanceId);
3139
3250
  });
3140
3251
  }
3141
- findAnyByInstanceId(instanceId) {
3142
- return this.withLock(() => this.readRecordsUnlocked().find((record) => record.instanceId === instanceId));
3252
+ async findAnyByInstanceId(instanceId) {
3253
+ return this.withLock(async () => (await this.readRecordsUnlocked()).find((record) => record.instanceId === instanceId));
3143
3254
  }
3144
- findAnyByRecordId(recordId, options) {
3145
- return this.withLock(() => {
3146
- this.sweepUnlocked(options);
3147
- return this.readRecordsUnlocked().find((record) => record.recordId === recordId);
3255
+ async findAnyByRecordId(recordId, options) {
3256
+ return this.withLock(async () => {
3257
+ if (options)
3258
+ await this.sweepUnlocked(options);
3259
+ return (await this.readRecordsUnlocked()).find((record) => record.recordId === recordId);
3148
3260
  });
3149
3261
  }
3150
- listOpen(options) {
3151
- return this.withLock(() => {
3152
- this.sweepUnlocked(options);
3262
+ async listOpen(options) {
3263
+ return this.withLock(async () => {
3264
+ await this.sweepUnlocked(options);
3153
3265
  return this.readOpenRecordsUnlocked();
3154
3266
  });
3155
3267
  }
3156
- listOpenUnchecked() {
3268
+ async listOpenUnchecked() {
3157
3269
  return this.withLock(() => this.readOpenRecordsUnlocked());
3158
3270
  }
3159
- markClosed(recordId, closedAt = Date.now()) {
3160
- this.withLock(() => {
3161
- const record = this.readRecordUnlocked(recordId);
3271
+ async markClosed(recordId, closedAt = Date.now()) {
3272
+ await this.withLock(async () => {
3273
+ const record = await this.readRecordUnlocked(recordId);
3162
3274
  if (!record)
3163
3275
  return;
3164
3276
  record.closedAt = closedAt;
3165
- this.writeRecordUnlocked(record);
3277
+ await this.writeRecordUnlocked(record);
3166
3278
  });
3167
3279
  }
3168
- delete(recordId) {
3169
- this.withLock(() => this.deleteRecordUnlocked(recordId));
3280
+ async delete(recordId) {
3281
+ await this.withLock(() => this.deleteRecordUnlocked(recordId));
3170
3282
  }
3171
- sweep(options) {
3172
- this.withLock(() => this.sweepUnlocked(options));
3283
+ async sweep(options) {
3284
+ await this.withLock(() => this.sweepUnlocked(options));
3173
3285
  }
3174
- logEvent(event, now = Date.now()) {
3175
- this.withLock(() => this.appendEventUnlocked(event, now));
3286
+ async logEvent(event, now = Date.now()) {
3287
+ await this.withLock(() => this.appendEventUnlocked(event, now));
3176
3288
  }
3177
- withLock(fn) {
3178
- this.ensureDir();
3289
+ async withLock(fn) {
3290
+ await this.ensureDir();
3179
3291
  const lockDir = path.join(this.dir, ".lock");
3180
3292
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
3181
- while (true) {
3293
+ const owner = {
3294
+ pid: process.pid,
3295
+ token: randomUUID(),
3296
+ createdAt: Date.now()
3297
+ };
3298
+ for (; ; ) {
3182
3299
  try {
3183
- fs.mkdirSync(lockDir);
3300
+ await fs.mkdir(lockDir);
3301
+ activeLockTokens.add(owner.token);
3302
+ activeLockPaths.add(lockDir);
3303
+ try {
3304
+ await fs.writeFile(path.join(lockDir, "owner.json"), `${JSON.stringify(owner)}
3305
+ `, {
3306
+ encoding: "utf8",
3307
+ flag: "wx"
3308
+ });
3309
+ } catch (error) {
3310
+ activeLockTokens.delete(owner.token);
3311
+ activeLockPaths.delete(lockDir);
3312
+ await fs.rm(lockDir, { recursive: true, force: true });
3313
+ throw error;
3314
+ }
3184
3315
  break;
3185
3316
  } catch (error) {
3186
3317
  const code = error.code;
3187
3318
  if (code !== "EEXIST")
3188
3319
  throw error;
3189
3320
  try {
3190
- const stat = fs.statSync(lockDir);
3191
- if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
3192
- fs.rmSync(lockDir, { recursive: true, force: true });
3321
+ const stat2 = await fs.stat(lockDir);
3322
+ const currentOwner = await this.readLockOwner(lockDir, stat2.isDirectory());
3323
+ const ownerIsActive = currentOwner?.pid === process.pid ? activeLockTokens.has(currentOwner.token) : currentOwner ? isProcessAlive(currentOwner.pid) : void 0;
3324
+ if (ownerIsActive === false || currentOwner === void 0 && !activeLockPaths.has(lockDir) && Date.now() - stat2.mtimeMs > LOCK_STALE_MS) {
3325
+ await fs.rm(lockDir, { recursive: true, force: true });
3193
3326
  continue;
3194
3327
  }
3195
3328
  } catch {
@@ -3198,40 +3331,59 @@ var init_managed_instance_registry = __esm({
3198
3331
  if (Date.now() > deadline) {
3199
3332
  throw new Error(`Timed out waiting for managed instance registry lock: ${lockDir}`);
3200
3333
  }
3201
- sleepSync(LOCK_RETRY_MS);
3334
+ await delay(LOCK_RETRY_MS);
3202
3335
  }
3203
3336
  }
3204
3337
  try {
3205
- return fn();
3338
+ return await fn();
3206
3339
  } finally {
3207
- fs.rmSync(lockDir, { recursive: true, force: true });
3340
+ try {
3341
+ const stat2 = await fs.stat(lockDir);
3342
+ const currentOwner = await this.readLockOwner(lockDir, stat2.isDirectory());
3343
+ if (currentOwner?.pid === owner.pid && currentOwner.token === owner.token) {
3344
+ await fs.rm(lockDir, { recursive: true, force: true });
3345
+ }
3346
+ } catch {
3347
+ } finally {
3348
+ activeLockTokens.delete(owner.token);
3349
+ activeLockPaths.delete(lockDir);
3350
+ }
3351
+ }
3352
+ }
3353
+ async readLockOwner(lockPath, isDirectory) {
3354
+ try {
3355
+ const contents = await fs.readFile(isDirectory ? path.join(lockPath, "owner.json") : lockPath, "utf8");
3356
+ const parsed = JSON.parse(contents);
3357
+ return isLockOwner(parsed) ? parsed : void 0;
3358
+ } catch {
3359
+ return void 0;
3208
3360
  }
3209
3361
  }
3210
- ensureDir() {
3211
- fs.mkdirSync(this.dir, { recursive: true });
3362
+ async ensureDir() {
3363
+ await fs.mkdir(this.dir, { recursive: true });
3212
3364
  }
3213
3365
  recordPath(recordId) {
3214
3366
  return path.join(this.dir, `${recordId}.json`);
3215
3367
  }
3216
- recordFilesUnlocked() {
3217
- return fs.readdirSync(this.dir).filter((name) => name.endsWith(".json")).map((name) => path.join(this.dir, name));
3368
+ async recordFilesUnlocked() {
3369
+ return (await fs.readdir(this.dir)).filter((name) => name.endsWith(".json")).map((name) => path.join(this.dir, name));
3218
3370
  }
3219
- readRecordUnlocked(recordId) {
3371
+ async readRecordUnlocked(recordId) {
3220
3372
  try {
3221
- const parsed = JSON.parse(fs.readFileSync(this.recordPath(recordId), "utf8"));
3373
+ const parsed = JSON.parse(await fs.readFile(this.recordPath(recordId), "utf8"));
3222
3374
  return isRecord(parsed) ? parsed : void 0;
3223
3375
  } catch {
3224
3376
  return void 0;
3225
3377
  }
3226
3378
  }
3227
- readOpenRecordsUnlocked() {
3228
- return this.readRecordsUnlocked().filter((record) => record.closedAt === void 0);
3379
+ async readOpenRecordsUnlocked() {
3380
+ return (await this.readRecordsUnlocked()).filter((record) => record.closedAt === void 0);
3229
3381
  }
3230
- readRecordsUnlocked() {
3382
+ async readRecordsUnlocked() {
3231
3383
  const records = [];
3232
- for (const file of this.recordFilesUnlocked()) {
3384
+ for (const file of await this.recordFilesUnlocked()) {
3233
3385
  try {
3234
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
3386
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
3235
3387
  if (!isRecord(parsed))
3236
3388
  continue;
3237
3389
  records.push(parsed);
@@ -3240,48 +3392,48 @@ var init_managed_instance_registry = __esm({
3240
3392
  }
3241
3393
  return records;
3242
3394
  }
3243
- writeRecordUnlocked(record) {
3244
- this.ensureDir();
3395
+ async writeRecordUnlocked(record) {
3396
+ await this.ensureDir();
3245
3397
  const finalPath = this.recordPath(record.recordId);
3246
3398
  const tmpPath = path.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
3247
- const fd = fs.openSync(tmpPath, "w");
3399
+ const fd = await fs.open(tmpPath, "w");
3248
3400
  try {
3249
- fs.writeFileSync(fd, `${JSON.stringify(record, null, 2)}
3401
+ await fd.writeFile(`${JSON.stringify(record, null, 2)}
3250
3402
  `, "utf8");
3251
- fs.fsyncSync(fd);
3403
+ await fd.sync();
3252
3404
  } finally {
3253
- fs.closeSync(fd);
3405
+ await fd.close();
3254
3406
  }
3255
- fs.renameSync(tmpPath, finalPath);
3407
+ await fs.rename(tmpPath, finalPath);
3256
3408
  }
3257
- deleteRecordUnlocked(recordId) {
3258
- fs.rmSync(this.recordPath(recordId), { force: true });
3409
+ async deleteRecordUnlocked(recordId) {
3410
+ await fs.rm(this.recordPath(recordId), { force: true });
3259
3411
  }
3260
- appendEventUnlocked(event, now) {
3412
+ async appendEventUnlocked(event, now) {
3261
3413
  const file = path.join(this.dir, `events-${ymd(now)}.jsonl`);
3262
- fs.appendFileSync(file, `${JSON.stringify({
3414
+ await fs.appendFile(file, `${JSON.stringify({
3263
3415
  ts: new Date(now).toISOString(),
3264
3416
  ...event
3265
3417
  })}
3266
3418
  `, "utf8");
3267
3419
  }
3268
- cleanupOldEventLogsUnlocked(now) {
3420
+ async cleanupOldEventLogsUnlocked(now) {
3269
3421
  const cutoff = ymd(now - EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1e3);
3270
- for (const name of fs.readdirSync(this.dir)) {
3422
+ for (const name of await fs.readdir(this.dir)) {
3271
3423
  const date = eventLogDate(name);
3272
3424
  if (!date || date >= cutoff)
3273
3425
  continue;
3274
3426
  try {
3275
- fs.rmSync(path.join(this.dir, name), { force: true });
3427
+ await fs.rm(path.join(this.dir, name), { force: true });
3276
3428
  } catch {
3277
3429
  }
3278
3430
  }
3279
3431
  }
3280
- cleanupRecord(options, record) {
3432
+ async cleanupRecord(options, record) {
3281
3433
  try {
3282
- options.cleanupRecord?.(record);
3434
+ await options.cleanupRecord?.(record);
3283
3435
  } catch {
3284
- this.appendEventUnlocked({
3436
+ await this.appendEventUnlocked({
3285
3437
  event: "registry_cleanup_failed",
3286
3438
  recordId: record.recordId,
3287
3439
  instanceId: record.instanceId,
@@ -3290,16 +3442,16 @@ var init_managed_instance_registry = __esm({
3290
3442
  }, options.now ?? Date.now());
3291
3443
  }
3292
3444
  }
3293
- sweepUnlocked(options) {
3445
+ async sweepUnlocked(options) {
3294
3446
  const now = options.now ?? Date.now();
3295
- this.cleanupOldEventLogsUnlocked(now);
3296
- for (const file of this.recordFilesUnlocked()) {
3447
+ await this.cleanupOldEventLogsUnlocked(now);
3448
+ for (const file of await this.recordFilesUnlocked()) {
3297
3449
  let parsed;
3298
3450
  try {
3299
- parsed = JSON.parse(fs.readFileSync(file, "utf8"));
3451
+ parsed = JSON.parse(await fs.readFile(file, "utf8"));
3300
3452
  } catch {
3301
- fs.rmSync(file, { force: true });
3302
- this.appendEventUnlocked({
3453
+ await fs.rm(file, { force: true });
3454
+ await this.appendEventUnlocked({
3303
3455
  event: "registry_pruned_malformed_record",
3304
3456
  reason: "parse_error",
3305
3457
  action: "deleted_record"
@@ -3307,8 +3459,8 @@ var init_managed_instance_registry = __esm({
3307
3459
  continue;
3308
3460
  }
3309
3461
  if (!parsed || typeof parsed !== "object") {
3310
- fs.rmSync(file, { force: true });
3311
- this.appendEventUnlocked({
3462
+ await fs.rm(file, { force: true });
3463
+ await this.appendEventUnlocked({
3312
3464
  event: "registry_pruned_malformed_record",
3313
3465
  reason: "invalid_shape",
3314
3466
  action: "deleted_record"
@@ -3319,8 +3471,8 @@ var init_managed_instance_registry = __esm({
3319
3471
  if (typeof version === "number" && version > REGISTRY_VERSION)
3320
3472
  continue;
3321
3473
  if (!isRecord(parsed)) {
3322
- fs.rmSync(file, { force: true });
3323
- this.appendEventUnlocked({
3474
+ await fs.rm(file, { force: true });
3475
+ await this.appendEventUnlocked({
3324
3476
  event: "registry_pruned_malformed_record",
3325
3477
  reason: "invalid_shape",
3326
3478
  action: "deleted_record"
@@ -3330,8 +3482,8 @@ var init_managed_instance_registry = __esm({
3330
3482
  const terminalAt = parsed.closedAt ?? parsed.exitedAt;
3331
3483
  if (terminalAt !== void 0) {
3332
3484
  if (now - terminalAt > TERMINAL_RECORD_RETENTION_MS) {
3333
- fs.rmSync(file, { force: true });
3334
- this.appendEventUnlocked({
3485
+ await fs.rm(file, { force: true });
3486
+ await this.appendEventUnlocked({
3335
3487
  event: "registry_pruned_terminal_record",
3336
3488
  recordId: parsed.recordId,
3337
3489
  instanceId: parsed.instanceId,
@@ -3343,13 +3495,13 @@ var init_managed_instance_registry = __esm({
3343
3495
  continue;
3344
3496
  }
3345
3497
  if (parsed.bootId !== options.currentBootId) {
3346
- this.cleanupRecord(options, parsed);
3498
+ await this.cleanupRecord(options, parsed);
3347
3499
  parsed.state = parsed.state === "failed" ? "failed" : "exited";
3348
3500
  parsed.exitedAt = now;
3349
3501
  parsed.closedAt = now;
3350
3502
  parsed.failureReason ??= "Studio process belongs to a previous host boot.";
3351
- this.writeRecordUnlocked(parsed);
3352
- this.appendEventUnlocked({
3503
+ await this.writeRecordUnlocked(parsed);
3504
+ await this.appendEventUnlocked({
3353
3505
  event: "registry_marked_previous_boot_exited",
3354
3506
  recordId: parsed.recordId,
3355
3507
  instanceId: parsed.instanceId,
@@ -3359,22 +3511,56 @@ var init_managed_instance_registry = __esm({
3359
3511
  }, now);
3360
3512
  continue;
3361
3513
  }
3362
- if (options.isProcessRunning && (parsed.nativeProcessId || parsed.spawnPid) && !options.isProcessRunning(parsed)) {
3363
- this.cleanupRecord(options, parsed);
3364
- parsed.state = parsed.state === "failed" ? "failed" : "exited";
3365
- parsed.exitedAt = now;
3366
- parsed.closedAt = now;
3367
- parsed.failureReason ??= parsed.instanceId ? "Studio process exited." : "Studio process exited before the MCP plugin connected.";
3368
- this.writeRecordUnlocked(parsed);
3369
- this.appendEventUnlocked({
3370
- event: "registry_marked_process_exited",
3371
- recordId: parsed.recordId,
3372
- instanceId: parsed.instanceId,
3373
- source: parsed.source,
3374
- reason: "pid_not_running",
3375
- action: "marked_exited_and_cleaned_baseplate"
3376
- }, now);
3514
+ if (!options.observeProcess || !(parsed.nativeProcessId || parsed.spawnPid))
3515
+ continue;
3516
+ const observation = await options.observeProcess(parsed);
3517
+ if (observation.status === "unknown") {
3518
+ parsed.processObservationStatus = "unknown";
3519
+ parsed.lastProcessObservationAt = observation.observedAt;
3520
+ parsed.lastProcessObservationError = observation.error;
3521
+ parsed.consecutiveConfirmedMisses = 0;
3522
+ parsed.firstConfirmedMissAt = void 0;
3523
+ await this.writeRecordUnlocked(parsed);
3524
+ continue;
3525
+ }
3526
+ const previousObservationAt = parsed.lastProcessObservationAt;
3527
+ parsed.lastSuccessfulProcessObservationAt = observation.observedAt;
3528
+ parsed.lastProcessObservationAt = observation.observedAt;
3529
+ parsed.lastProcessObservationError = void 0;
3530
+ if (observation.status === "running") {
3531
+ parsed.processObservationStatus = "running";
3532
+ parsed.consecutiveConfirmedMisses = 0;
3533
+ parsed.firstConfirmedMissAt = void 0;
3534
+ await this.writeRecordUnlocked(parsed);
3535
+ continue;
3536
+ }
3537
+ const isNewObservation = previousObservationAt !== observation.observedAt;
3538
+ parsed.processObservationStatus = "not_running";
3539
+ if (previousObservationAt !== observation.observedAt) {
3540
+ parsed.consecutiveConfirmedMisses = (parsed.consecutiveConfirmedMisses ?? 0) + 1;
3541
+ parsed.firstConfirmedMissAt ??= observation.observedAt;
3542
+ }
3543
+ const requiredMisses = options.confirmedExitMisses ?? DEFAULT_CONFIRMED_EXIT_MISSES;
3544
+ const graceMs = options.confirmedExitGraceMs ?? DEFAULT_CONFIRMED_EXIT_GRACE_MS;
3545
+ const confirmedAbsent = observation.reason === "identity_mismatch" || isNewObservation && (parsed.consecutiveConfirmedMisses ?? 0) >= requiredMisses && observation.observedAt - (parsed.firstConfirmedMissAt ?? observation.observedAt) >= graceMs;
3546
+ if (!confirmedAbsent) {
3547
+ await this.writeRecordUnlocked(parsed);
3548
+ continue;
3377
3549
  }
3550
+ await this.cleanupRecord(options, parsed);
3551
+ parsed.state = parsed.state === "failed" ? "failed" : "exited";
3552
+ parsed.exitedAt = observation.observedAt;
3553
+ parsed.closedAt = observation.observedAt;
3554
+ parsed.failureReason = observation.reason === "identity_mismatch" ? "Studio process identity changed; the retained PID was not reused." : parsed.instanceId ? "Studio process exited." : "Studio process exited before the MCP plugin connected.";
3555
+ await this.writeRecordUnlocked(parsed);
3556
+ await this.appendEventUnlocked({
3557
+ event: "registry_marked_process_exited",
3558
+ recordId: parsed.recordId,
3559
+ instanceId: parsed.instanceId,
3560
+ source: parsed.source,
3561
+ reason: observation.reason === "identity_mismatch" ? "identity_mismatch" : "pid_not_running",
3562
+ action: "marked_exited_and_cleaned_baseplate"
3563
+ }, now);
3378
3564
  }
3379
3565
  }
3380
3566
  };
@@ -3382,11 +3568,12 @@ var init_managed_instance_registry = __esm({
3382
3568
  });
3383
3569
 
3384
3570
  // ../core/dist/studio-instance-manager.js
3385
- import { execFileSync, spawn } from "child_process";
3386
- import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, statSync as statSync2 } from "fs";
3387
- import { randomUUID } from "crypto";
3571
+ import { execFile, execFileSync, spawn } from "child_process";
3572
+ import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, realpathSync, rmSync, statSync } from "fs";
3573
+ import { randomUUID as randomUUID2 } from "crypto";
3388
3574
  import * as os2 from "os";
3389
3575
  import * as path2 from "path";
3576
+ import { promisify } from "util";
3390
3577
  function run(command, args, options = {}) {
3391
3578
  return execFileSync(command, args, {
3392
3579
  encoding: "utf8",
@@ -3394,17 +3581,29 @@ function run(command, args, options = {}) {
3394
3581
  ...options
3395
3582
  }).trim();
3396
3583
  }
3584
+ async function runAsync(command, args, options = {}) {
3585
+ const result = await execFileAsync(command, args, {
3586
+ encoding: "utf8",
3587
+ maxBuffer: 4 * 1024 * 1024,
3588
+ timeout: 15e3,
3589
+ killSignal: "SIGKILL",
3590
+ ...options
3591
+ });
3592
+ return `${result.stdout}`.trim();
3593
+ }
3397
3594
  function isWsl() {
3398
3595
  if (process.platform !== "linux")
3399
3596
  return false;
3597
+ if (!process.env.WSL_INTEROP && !process.env.WSL_DISTRO_NAME)
3598
+ return false;
3400
3599
  try {
3401
- return /microsoft|wsl/i.test(readFileSync3("/proc/version", "utf8"));
3600
+ return /microsoft|wsl/i.test(readFileSync2("/proc/version", "utf8"));
3402
3601
  } catch {
3403
3602
  return false;
3404
3603
  }
3405
3604
  }
3406
- function powershell(script) {
3407
- return run("powershell.exe", ["-NoProfile", "-Command", script], {
3605
+ async function powershellAsync(script) {
3606
+ return runAsync("powershell.exe", ["-NoProfile", "-Command", script], {
3408
3607
  cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
3409
3608
  });
3410
3609
  }
@@ -3421,15 +3620,33 @@ function windowsLocalAppData() {
3421
3620
  return void 0;
3422
3621
  }
3423
3622
  }
3623
+ async function windowsLocalAppDataAsync() {
3624
+ if (process.platform === "win32")
3625
+ return process.env.LOCALAPPDATA;
3626
+ if (!isWsl())
3627
+ return void 0;
3628
+ try {
3629
+ return await runAsync("cmd.exe", ["/c", "echo %LOCALAPPDATA%"], {
3630
+ cwd: existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
3631
+ });
3632
+ } catch {
3633
+ return void 0;
3634
+ }
3635
+ }
3424
3636
  function toWslPath(windowsPath) {
3425
3637
  if (!isWsl())
3426
3638
  return windowsPath;
3427
3639
  return run("wslpath", ["-u", windowsPath]);
3428
3640
  }
3429
- function toStudioLaunchArg(arg) {
3641
+ async function toWslPathAsync(windowsPath) {
3642
+ if (!isWsl())
3643
+ return windowsPath;
3644
+ return runAsync("wslpath", ["-u", windowsPath]);
3645
+ }
3646
+ async function toStudioLaunchArgAsync(arg) {
3430
3647
  if (!isWsl() || !path2.isAbsolute(arg) || !existsSync2(arg))
3431
3648
  return arg;
3432
- return run("wslpath", ["-w", arg]);
3649
+ return runAsync("wslpath", ["-w", arg]);
3433
3650
  }
3434
3651
  function powershellStringLiteral(value) {
3435
3652
  return `'${value.replace(/'/g, "''")}'`;
@@ -3513,41 +3730,556 @@ function quoteWindowsCommandLineArg(value) {
3513
3730
  }
3514
3731
  return `${quoted}${"\\".repeat(backslashes * 2)}"`;
3515
3732
  }
3516
- function buildWindowsStudioStartScript(exe, args, processEnvironment) {
3733
+ function buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment) {
3517
3734
  const environmentPatch = parseStudioProcessEnvironmentPatch(processEnvironment);
3518
- const windowsExe = toStudioLaunchArg(exe);
3519
- const commandLine = args.map(quoteWindowsCommandLineArg).join(" ");
3735
+ const commandLine = [windowsExe, ...args].map(quoteWindowsCommandLineArg).join(" ");
3520
3736
  return [
3521
3737
  ...environmentPatch ? powershellEnvironmentPatchStatements(environmentPatch) : [],
3522
- "$psi = New-Object System.Diagnostics.ProcessStartInfo",
3523
- `$psi.FileName = ${powershellStringLiteral(windowsExe)}`,
3524
- `$psi.Arguments = ${powershellStringLiteral(commandLine)}`,
3525
- // With UseShellExecute=false, Studio inherits the synchronous
3526
- // powershell.exe invocation's stdout/stderr pipe handles under WSL. Those
3527
- // handles keep execFileSync waiting until Studio exits even though
3528
- // PowerShell already printed the PID. Shell execution prevents that
3529
- // inheritance while Process.Start still returns the native Studio PID.
3530
- "$psi.UseShellExecute = $true",
3531
- "$studio = [System.Diagnostics.Process]::Start($psi)",
3532
- 'if ($null -eq $studio) { throw "Roblox Studio process did not start." }'
3738
+ `Add-Type -TypeDefinition @'
3739
+ using System;
3740
+ using System.ComponentModel;
3741
+ using System.Runtime.InteropServices;
3742
+ using System.Text;
3743
+
3744
+ public sealed class McpSuspendedStudio : IDisposable
3745
+ {
3746
+ private const uint CREATE_SUSPENDED = 0x00000004;
3747
+ private const uint CREATE_BREAKAWAY_FROM_JOB = 0x01000000;
3748
+ private const uint RESUME_FAILED = 0xFFFFFFFF;
3749
+ private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000;
3750
+ private const int JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9;
3751
+ private const uint WAIT_OBJECT_0 = 0x00000000;
3752
+ private const uint WAIT_TIMEOUT = 0x00000102;
3753
+ private const uint WAIT_FAILED = 0xFFFFFFFF;
3754
+
3755
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
3756
+ private struct StartupInfo
3757
+ {
3758
+ public int cb;
3759
+ public string lpReserved;
3760
+ public string lpDesktop;
3761
+ public string lpTitle;
3762
+ public uint dwX;
3763
+ public uint dwY;
3764
+ public uint dwXSize;
3765
+ public uint dwYSize;
3766
+ public uint dwXCountChars;
3767
+ public uint dwYCountChars;
3768
+ public uint dwFillAttribute;
3769
+ public uint dwFlags;
3770
+ public short wShowWindow;
3771
+ public short cbReserved2;
3772
+ public IntPtr lpReserved2;
3773
+ public IntPtr hStdInput;
3774
+ public IntPtr hStdOutput;
3775
+ public IntPtr hStdError;
3776
+ }
3777
+
3778
+ [StructLayout(LayoutKind.Sequential)]
3779
+ private struct ProcessInformation
3780
+ {
3781
+ public IntPtr hProcess;
3782
+ public IntPtr hThread;
3783
+ public uint dwProcessId;
3784
+ public uint dwThreadId;
3785
+ }
3786
+
3787
+ [StructLayout(LayoutKind.Sequential)]
3788
+ private struct FileTime
3789
+ {
3790
+ public uint Low;
3791
+ public uint High;
3792
+ }
3793
+
3794
+ [StructLayout(LayoutKind.Sequential)]
3795
+ private struct BasicLimitInformation
3796
+ {
3797
+ public long PerProcessUserTimeLimit;
3798
+ public long PerJobUserTimeLimit;
3799
+ public uint LimitFlags;
3800
+ public UIntPtr MinimumWorkingSetSize;
3801
+ public UIntPtr MaximumWorkingSetSize;
3802
+ public uint ActiveProcessLimit;
3803
+ public UIntPtr Affinity;
3804
+ public uint PriorityClass;
3805
+ public uint SchedulingClass;
3806
+ }
3807
+
3808
+ [StructLayout(LayoutKind.Sequential)]
3809
+ private struct IoCounters
3810
+ {
3811
+ public ulong ReadOperationCount;
3812
+ public ulong WriteOperationCount;
3813
+ public ulong OtherOperationCount;
3814
+ public ulong ReadTransferCount;
3815
+ public ulong WriteTransferCount;
3816
+ public ulong OtherTransferCount;
3817
+ }
3818
+
3819
+ [StructLayout(LayoutKind.Sequential)]
3820
+ private struct ExtendedLimitInformation
3821
+ {
3822
+ public BasicLimitInformation BasicLimitInformation;
3823
+ public IoCounters IoInfo;
3824
+ public UIntPtr ProcessMemoryLimit;
3825
+ public UIntPtr JobMemoryLimit;
3826
+ public UIntPtr PeakProcessMemoryUsed;
3827
+ public UIntPtr PeakJobMemoryUsed;
3828
+ }
3829
+
3830
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
3831
+ private static extern bool CreateProcessW(
3832
+ string applicationName,
3833
+ StringBuilder commandLine,
3834
+ IntPtr processAttributes,
3835
+ IntPtr threadAttributes,
3836
+ bool inheritHandles,
3837
+ uint creationFlags,
3838
+ IntPtr environment,
3839
+ string currentDirectory,
3840
+ ref StartupInfo startupInfo,
3841
+ out ProcessInformation processInformation);
3842
+
3843
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
3844
+ private static extern IntPtr CreateJobObjectW(IntPtr jobAttributes, string name);
3845
+
3846
+ [DllImport("kernel32.dll", SetLastError = true)]
3847
+ private static extern bool SetInformationJobObject(
3848
+ IntPtr job,
3849
+ int informationClass,
3850
+ IntPtr information,
3851
+ uint informationLength);
3852
+
3853
+ [DllImport("kernel32.dll", SetLastError = true)]
3854
+ private static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process);
3855
+
3856
+ [DllImport("kernel32.dll", SetLastError = true)]
3857
+ private static extern bool GetProcessTimes(
3858
+ IntPtr process,
3859
+ out FileTime creation,
3860
+ out FileTime exit,
3861
+ out FileTime kernel,
3862
+ out FileTime user);
3863
+
3864
+ [DllImport("kernel32.dll", SetLastError = true)]
3865
+ private static extern uint ResumeThread(IntPtr thread);
3866
+
3867
+ [DllImport("kernel32.dll", SetLastError = true)]
3868
+ private static extern bool TerminateProcess(IntPtr process, uint exitCode);
3869
+
3870
+ [DllImport("kernel32.dll", SetLastError = true)]
3871
+ private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
3872
+
3873
+ [DllImport("kernel32.dll")]
3874
+ private static extern bool CloseHandle(IntPtr handle);
3875
+
3876
+ private IntPtr process;
3877
+ private IntPtr thread;
3878
+
3879
+ private IntPtr job;
3880
+ public uint ProcessId { get; private set; }
3881
+ public ulong StartedAtFileTime { get; private set; }
3882
+
3883
+ private McpSuspendedStudio(ProcessInformation processInformation, IntPtr jobHandle)
3884
+ {
3885
+ process = processInformation.hProcess;
3886
+ thread = processInformation.hThread;
3887
+ job = jobHandle;
3888
+ ProcessId = processInformation.dwProcessId;
3889
+ FileTime creation;
3890
+ FileTime exit;
3891
+ FileTime kernel;
3892
+ FileTime user;
3893
+ if (!GetProcessTimes(process, out creation, out exit, out kernel, out user))
3894
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "GetProcessTimes failed");
3895
+ StartedAtFileTime = ((ulong)creation.High << 32) | creation.Low;
3896
+ }
3897
+
3898
+ private static void ConfigureKillOnClose(IntPtr jobHandle, bool enabled)
3899
+ {
3900
+ var information = new ExtendedLimitInformation();
3901
+ information.BasicLimitInformation.LimitFlags = enabled ? JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE : 0;
3902
+ int length = Marshal.SizeOf(typeof(ExtendedLimitInformation));
3903
+ IntPtr buffer = Marshal.AllocHGlobal(length);
3904
+ try
3905
+ {
3906
+ Marshal.StructureToPtr(information, buffer, false);
3907
+ if (!SetInformationJobObject(jobHandle, JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, buffer, (uint)length))
3908
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "SetInformationJobObject failed");
3909
+ }
3910
+ finally
3911
+ {
3912
+ Marshal.FreeHGlobal(buffer);
3913
+ }
3914
+ }
3915
+
3916
+ private static void TerminateAndWait(IntPtr processHandle)
3917
+ {
3918
+ if (!TerminateProcess(processHandle, 1))
3919
+ {
3920
+ int error = Marshal.GetLastWin32Error();
3921
+ if (error != 5)
3922
+ throw new Win32Exception(error, "TerminateProcess failed");
3923
+ }
3924
+ uint wait = WaitForSingleObject(processHandle, 15000);
3925
+ if (wait == WAIT_TIMEOUT)
3926
+ throw new TimeoutException("Timed out waiting for the terminated Studio process");
3927
+ if (wait == WAIT_FAILED)
3928
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "WaitForSingleObject failed");
3929
+ if (wait != WAIT_OBJECT_0)
3930
+ throw new InvalidOperationException("Unexpected Studio process wait result: " + wait);
3931
+ }
3932
+
3933
+ public static McpSuspendedStudio Start(string application, string commandLine)
3934
+ {
3935
+ IntPtr jobHandle = CreateJobObjectW(IntPtr.Zero, null);
3936
+ if (jobHandle == IntPtr.Zero)
3937
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateJobObjectW failed");
3938
+ try
3939
+ {
3940
+ ConfigureKillOnClose(jobHandle, true);
3941
+ var startup = new StartupInfo();
3942
+ startup.cb = Marshal.SizeOf(startup);
3943
+ ProcessInformation created;
3944
+ uint flags = CREATE_SUSPENDED | CREATE_BREAKAWAY_FROM_JOB;
3945
+ bool started = CreateProcessW(application, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, false, flags, IntPtr.Zero, null, ref startup, out created);
3946
+ if (!started && Marshal.GetLastWin32Error() == 5)
3947
+ started = CreateProcessW(application, new StringBuilder(commandLine), IntPtr.Zero, IntPtr.Zero, false, CREATE_SUSPENDED, IntPtr.Zero, null, ref startup, out created);
3948
+ if (!started)
3949
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "CreateProcessW failed");
3950
+ try
3951
+ {
3952
+ if (!AssignProcessToJobObject(jobHandle, created.hProcess))
3953
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "AssignProcessToJobObject failed");
3954
+ return new McpSuspendedStudio(created, jobHandle);
3955
+ }
3956
+ catch
3957
+ {
3958
+ try
3959
+ {
3960
+ TerminateAndWait(created.hProcess);
3961
+ }
3962
+ finally
3963
+ {
3964
+ CloseHandle(created.hThread);
3965
+ CloseHandle(created.hProcess);
3966
+ }
3967
+ throw;
3968
+ }
3969
+ }
3970
+ catch
3971
+ {
3972
+ CloseHandle(jobHandle);
3973
+ throw;
3974
+ }
3975
+ }
3976
+
3977
+ public void Resume()
3978
+ {
3979
+ if (thread == IntPtr.Zero)
3980
+ throw new InvalidOperationException("Studio launch was already resumed or disposed");
3981
+ if (ResumeThread(thread) == RESUME_FAILED)
3982
+ throw new Win32Exception(Marshal.GetLastWin32Error(), "ResumeThread failed");
3983
+ CloseHandle(thread);
3984
+ thread = IntPtr.Zero;
3985
+ }
3986
+
3987
+ public void Release()
3988
+ {
3989
+ if (thread != IntPtr.Zero)
3990
+ throw new InvalidOperationException("Studio launch cannot be released before it is resumed");
3991
+ if (job == IntPtr.Zero)
3992
+ throw new InvalidOperationException("Studio launch was already released or disposed");
3993
+ ConfigureKillOnClose(job, false);
3994
+ CloseHandle(job);
3995
+ job = IntPtr.Zero;
3996
+ }
3997
+
3998
+ public void Abort()
3999
+ {
4000
+ if (process != IntPtr.Zero)
4001
+ TerminateAndWait(process);
4002
+ }
4003
+
4004
+ public void Dispose()
4005
+ {
4006
+ if (thread != IntPtr.Zero)
4007
+ {
4008
+ CloseHandle(thread);
4009
+ thread = IntPtr.Zero;
4010
+ }
4011
+ if (job != IntPtr.Zero)
4012
+ {
4013
+ CloseHandle(job);
4014
+ job = IntPtr.Zero;
4015
+ }
4016
+ if (process != IntPtr.Zero)
4017
+ {
4018
+ CloseHandle(process);
4019
+ process = IntPtr.Zero;
4020
+ }
4021
+ }
4022
+ }
4023
+ '@`,
4024
+ `$launch = [McpSuspendedStudio]::Start(${powershellStringLiteral(windowsExe)}, ${powershellStringLiteral(commandLine)})`,
4025
+ "try {",
4026
+ "[Console]::Out.WriteLine((ConvertTo-Json @{ pid = $launch.ProcessId; started = [string]$launch.StartedAtFileTime } -Compress)); [Console]::Out.Flush()",
4027
+ "$accepted = $false",
4028
+ "$command = [Console]::In.ReadLine()",
4029
+ 'if ($command -eq "MCP_STUDIO_LAUNCH_ACCEPT") {',
4030
+ "$launch.Resume()",
4031
+ '[Console]::Out.WriteLine("MCP_STUDIO_LAUNCH_RESUMED"); [Console]::Out.Flush()',
4032
+ "$command = [Console]::In.ReadLine()",
4033
+ 'if ($command -eq "MCP_STUDIO_LAUNCH_COMPLETE") { $launch.Release(); $accepted = $true }',
4034
+ 'elseif ($command -eq "MCP_STUDIO_LAUNCH_ABORT") { $launch.Abort(); $accepted = $true }',
4035
+ 'else { throw "Resumed Studio launch was not completed or aborted." }',
4036
+ '} elseif ($command -eq "MCP_STUDIO_LAUNCH_ABORT") { $launch.Abort(); $accepted = $true }',
4037
+ 'else { throw "Studio launch identity was not accepted." }',
4038
+ "} finally {",
4039
+ "try { if (-not $accepted) { $launch.Abort() } } finally { $launch.Dispose() }",
4040
+ "}"
4041
+ ].join("\n");
4042
+ }
4043
+ function buildWindowsStudioStopScript(processId, startedAt) {
4044
+ if (!Number.isSafeInteger(processId) || processId <= 0)
4045
+ throw new Error("processId must be a positive integer.");
4046
+ if (!/^[1-9]\d*$/u.test(startedAt))
4047
+ throw new Error("startedAt must be a positive FILETIME string.");
4048
+ return [
4049
+ `$processId = [int]${processId}`,
4050
+ `$expectedStartedAt = [long]${startedAt}`,
4051
+ "$studio = $null",
4052
+ "try { $studio = [System.Diagnostics.Process]::GetProcessById($processId) } catch [System.ArgumentException] { return }",
4053
+ "try {",
4054
+ "$actualStartedAt = $studio.StartTime.ToUniversalTime().ToFileTimeUtc()",
4055
+ "if ($actualStartedAt -ne $expectedStartedAt) { return }",
4056
+ "if (-not $studio.HasExited) {",
4057
+ "try { $studio.Kill() } catch { if (-not $studio.HasExited) { throw } }",
4058
+ "$studio.WaitForExit()",
4059
+ "}",
4060
+ "} finally { if ($null -ne $studio) { $studio.Dispose() } }"
3533
4061
  ].join("; ");
3534
4062
  }
3535
- function spawnWindowsStudioFromWsl(exe, args, processEnvironment) {
3536
- const script = buildWindowsStudioStartScript(exe, args, processEnvironment);
3537
- const output = powershell(`${script}; [PSCustomObject]@{ pid = $studio.Id; started = $studio.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } | ConvertTo-Json -Compress`);
3538
- const parsed = JSON.parse(output);
3539
- const nativePid = Number(parsed.pid);
3540
- const nativeStartedAt = typeof parsed.started === "string" && /^\d+$/u.test(parsed.started) ? parsed.started : void 0;
3541
- if (!Number.isSafeInteger(nativePid) || nativePid <= 0) {
3542
- throw new Error(`Could not determine the Windows Studio process id from: ${nativePid}`);
3543
- }
3544
- return {
3545
- pid: nativePid,
3546
- nativePid,
3547
- nativeStartedAt,
3548
- unref: () => {
3549
- }
3550
- };
4063
+ async function stopWindowsStudio(processId, startedAt) {
4064
+ await powershellAsync(buildWindowsStudioStopScript(processId, startedAt));
4065
+ }
4066
+ async function spawnWindowsStudio(exe, args, processEnvironment) {
4067
+ const windowsExe = await toStudioLaunchArgAsync(exe);
4068
+ const script = buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment);
4069
+ const launcher = spawn("powershell.exe", ["-NoProfile", "-Command", script], {
4070
+ cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
4071
+ stdio: ["pipe", "pipe", "pipe"],
4072
+ windowsHide: true
4073
+ });
4074
+ return new Promise((resolve5, reject) => {
4075
+ let stdout = "";
4076
+ let controlOutput = "";
4077
+ let stderr = "";
4078
+ let identity;
4079
+ let initialFailure;
4080
+ let identitySettled = false;
4081
+ let controlState = "pending";
4082
+ let resumeSettled = false;
4083
+ let completeResume;
4084
+ const resumeAcknowledgment = new Promise((complete) => {
4085
+ completeResume = complete;
4086
+ });
4087
+ const signalResume = (error) => {
4088
+ if (resumeSettled)
4089
+ return;
4090
+ resumeSettled = true;
4091
+ completeResume(error);
4092
+ };
4093
+ let completeLauncher;
4094
+ const launcherCompletion = new Promise((complete) => {
4095
+ completeLauncher = complete;
4096
+ });
4097
+ const waitForLauncherCompletion = (timeoutMessage) => new Promise((complete) => {
4098
+ let settled = false;
4099
+ const forceTimeout = setTimeout(() => {
4100
+ if (settled)
4101
+ return;
4102
+ settled = true;
4103
+ const error = new Error(timeoutMessage);
4104
+ launcher.kill("SIGKILL");
4105
+ signalResume(error);
4106
+ completeLauncher(error);
4107
+ complete(error);
4108
+ }, 1e4);
4109
+ void launcherCompletion.then((error) => {
4110
+ if (settled)
4111
+ return;
4112
+ settled = true;
4113
+ clearTimeout(forceTimeout);
4114
+ complete(error);
4115
+ });
4116
+ });
4117
+ const waitForResumeAcknowledgment = () => new Promise((complete) => {
4118
+ const forceTimeout = setTimeout(() => {
4119
+ const error = new Error("Timed out resuming the suspended Studio process.");
4120
+ launcher.kill("SIGKILL");
4121
+ signalResume(error);
4122
+ completeLauncher(error);
4123
+ }, 1e4);
4124
+ void resumeAcknowledgment.then((error) => {
4125
+ clearTimeout(forceTimeout);
4126
+ complete(error);
4127
+ });
4128
+ });
4129
+ const throwWithExactCleanup = async (error) => {
4130
+ if (!identity)
4131
+ throw error;
4132
+ try {
4133
+ await stopWindowsStudio(identity.pid, identity.startedAt);
4134
+ } catch (cleanupError) {
4135
+ throw new Error(`${error.message} Exact Studio cleanup also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
4136
+ }
4137
+ throw error;
4138
+ };
4139
+ const authorize = async () => {
4140
+ if (controlState === "resumed")
4141
+ return;
4142
+ if (controlState !== "pending")
4143
+ throw new Error(`Studio launch cannot be authorized from ${controlState}.`);
4144
+ controlState = "accepting";
4145
+ launcher.stdin.write("MCP_STUDIO_LAUNCH_ACCEPT\n");
4146
+ const error = await waitForResumeAcknowledgment();
4147
+ if (error)
4148
+ await throwWithExactCleanup(error);
4149
+ controlState = "resumed";
4150
+ };
4151
+ const release = async () => {
4152
+ if (controlState === "released")
4153
+ return;
4154
+ if (controlState !== "resumed")
4155
+ throw new Error(`Studio launch cannot be released from ${controlState}.`);
4156
+ controlState = "releasing";
4157
+ launcher.stdin.end("MCP_STUDIO_LAUNCH_COMPLETE\n");
4158
+ const error = await waitForLauncherCompletion("Timed out releasing the authorized Studio process.");
4159
+ if (error)
4160
+ await throwWithExactCleanup(error);
4161
+ controlState = "released";
4162
+ };
4163
+ const abort = async () => {
4164
+ if (controlState === "released")
4165
+ return;
4166
+ if (controlState !== "aborting") {
4167
+ controlState = "aborting";
4168
+ launcher.stdin.end("MCP_STUDIO_LAUNCH_ABORT\n");
4169
+ }
4170
+ const error = await waitForLauncherCompletion("Timed out aborting the owned Studio process.");
4171
+ if (error && identity) {
4172
+ await stopWindowsStudio(identity.pid, identity.startedAt);
4173
+ } else if (error) {
4174
+ throw error;
4175
+ }
4176
+ };
4177
+ const timeout = setTimeout(() => {
4178
+ if (identitySettled)
4179
+ return;
4180
+ identitySettled = true;
4181
+ const failure = initialFailure = new Error("Timed out while capturing the exact Studio process identity.");
4182
+ controlState = "aborting";
4183
+ launcher.stdin.end("MCP_STUDIO_LAUNCH_ABORT\n");
4184
+ void waitForLauncherCompletion("Timed out aborting the Studio identity launcher.").then((completionError) => {
4185
+ if (!identity)
4186
+ reject(completionError ?? failure);
4187
+ });
4188
+ }, 15e3);
4189
+ launcher.stdout.on("data", (chunk) => {
4190
+ if (identitySettled) {
4191
+ controlOutput = (controlOutput + chunk.toString("utf8")).slice(-1024);
4192
+ if (controlOutput.includes("MCP_STUDIO_LAUNCH_RESUMED"))
4193
+ signalResume();
4194
+ return;
4195
+ }
4196
+ stdout += chunk.toString("utf8");
4197
+ if (stdout.length > 1024 * 1024) {
4198
+ const failure = initialFailure = new Error("Studio launcher identity output exceeded 1 MiB.");
4199
+ identitySettled = true;
4200
+ controlState = "aborting";
4201
+ launcher.stdin.end("MCP_STUDIO_LAUNCH_ABORT\n");
4202
+ void waitForLauncherCompletion("Timed out aborting the oversized Studio identity response.").then((completionError) => {
4203
+ if (!identity)
4204
+ reject(completionError ?? failure);
4205
+ });
4206
+ return;
4207
+ }
4208
+ const newline = stdout.indexOf("\n");
4209
+ if (newline < 0)
4210
+ return;
4211
+ identitySettled = true;
4212
+ clearTimeout(timeout);
4213
+ try {
4214
+ const parsed = JSON.parse(stdout.slice(0, newline).trim());
4215
+ const nativePid = Number(parsed.pid);
4216
+ const nativeStartedAt = typeof parsed.started === "string" && /^[1-9]\d*$/u.test(parsed.started) ? parsed.started : void 0;
4217
+ if (!Number.isSafeInteger(nativePid) || nativePid <= 0 || nativeStartedAt === void 0) {
4218
+ throw new Error("PowerShell returned an invalid native Studio process identity.");
4219
+ }
4220
+ identity = { pid: nativePid, startedAt: nativeStartedAt };
4221
+ resolve5({
4222
+ pid: nativePid,
4223
+ nativePid,
4224
+ nativeStartedAt,
4225
+ unref: () => {
4226
+ },
4227
+ authorize,
4228
+ release,
4229
+ abort
4230
+ });
4231
+ } catch (error) {
4232
+ const failure = initialFailure = error instanceof Error ? error : new Error(String(error));
4233
+ controlState = "aborting";
4234
+ launcher.stdin.end("MCP_STUDIO_LAUNCH_ABORT\n");
4235
+ void waitForLauncherCompletion("Timed out aborting the invalid Studio identity response.").then((completionError) => {
4236
+ if (!identity)
4237
+ reject(completionError ?? failure);
4238
+ });
4239
+ }
4240
+ });
4241
+ launcher.stdin.on("error", (error) => {
4242
+ if (!initialFailure)
4243
+ initialFailure = error;
4244
+ });
4245
+ launcher.stderr.on("data", (chunk) => {
4246
+ if (stderr.length <= 1024 * 1024)
4247
+ stderr += chunk.toString("utf8");
4248
+ });
4249
+ launcher.once("error", (error) => {
4250
+ clearTimeout(timeout);
4251
+ signalResume(error);
4252
+ completeLauncher(error);
4253
+ if (!identity)
4254
+ reject(error);
4255
+ });
4256
+ launcher.once("exit", (code) => {
4257
+ clearTimeout(timeout);
4258
+ void (async () => {
4259
+ if (code === 0 && identity && !initialFailure) {
4260
+ if (!resumeSettled)
4261
+ signalResume(new Error("Studio launcher exited before resume acknowledgment."));
4262
+ completeLauncher();
4263
+ return;
4264
+ }
4265
+ let cleanupError;
4266
+ if (identity) {
4267
+ try {
4268
+ await stopWindowsStudio(identity.pid, identity.startedAt);
4269
+ } catch (error2) {
4270
+ cleanupError = error2;
4271
+ }
4272
+ }
4273
+ const detail = initialFailure?.message ?? (stderr.trim() || `PowerShell launcher exited with code ${code}.`);
4274
+ const cleanupDetail = cleanupError ? ` Exact Studio cleanup also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}` : "";
4275
+ const error = new Error(`${detail}${cleanupDetail}`);
4276
+ signalResume(error);
4277
+ completeLauncher(error);
4278
+ if (!identity)
4279
+ reject(error);
4280
+ })();
4281
+ });
4282
+ });
3551
4283
  }
3552
4284
  function resolveEntrypointDir() {
3553
4285
  const entrypoint = process.argv[1];
@@ -3576,7 +4308,7 @@ function resolveBaseplateTemplatePath() {
3576
4308
  }
3577
4309
  throw new Error(`Baseplate template not found. Expected ${BASEPLATE_TEMPLATE_NAME} in one of: ${candidates.join(", ")}`);
3578
4310
  }
3579
- function isProcessAlive(pid) {
4311
+ function isProcessAlive2(pid) {
3580
4312
  try {
3581
4313
  process.kill(pid, 0);
3582
4314
  return true;
@@ -3587,7 +4319,7 @@ function isProcessAlive(pid) {
3587
4319
  function sweepStaleBaseplateFiles() {
3588
4320
  let entries;
3589
4321
  try {
3590
- entries = readdirSync2(BASEPLATE_TEMP_DIR);
4322
+ entries = readdirSync(BASEPLATE_TEMP_DIR);
3591
4323
  } catch {
3592
4324
  return;
3593
4325
  }
@@ -3596,18 +4328,18 @@ function sweepStaleBaseplateFiles() {
3596
4328
  const match = BASEPLATE_TEMP_SWEEP_NAME.exec(entry);
3597
4329
  if (!match)
3598
4330
  continue;
3599
- if (Number(match[1]) !== process.pid && isProcessAlive(Number(match[1])))
4331
+ if (Number(match[1]) !== process.pid && isProcessAlive2(Number(match[1])))
3600
4332
  continue;
3601
4333
  const file = path2.join(BASEPLATE_TEMP_DIR, entry);
3602
4334
  try {
3603
- if (statSync2(file).mtimeMs < cutoff)
3604
- rmSync2(file, { force: true });
4335
+ if (statSync(file).mtimeMs < cutoff)
4336
+ rmSync(file, { force: true });
3605
4337
  } catch {
3606
4338
  }
3607
4339
  }
3608
4340
  }
3609
4341
  function createBaseplatePlaceFile() {
3610
- mkdirSync3(BASEPLATE_TEMP_DIR, { recursive: true });
4342
+ mkdirSync2(BASEPLATE_TEMP_DIR, { recursive: true });
3611
4343
  sweepStaleBaseplateFiles();
3612
4344
  const file = path2.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
3613
4345
  copyFileSync(resolveBaseplateTemplatePath(), file);
@@ -3624,7 +4356,7 @@ function cleanupManagedBaseplateFiles(record) {
3624
4356
  return;
3625
4357
  for (const file of [record.localPlaceFile, `${record.localPlaceFile}.lock`]) {
3626
4358
  try {
3627
- rmSync2(file, { force: true });
4359
+ rmSync(file, { force: true });
3628
4360
  } catch {
3629
4361
  }
3630
4362
  }
@@ -3651,54 +4383,83 @@ function resolveStudioExe() {
3651
4383
  if (!existsSync2(root)) {
3652
4384
  throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
3653
4385
  }
3654
- const candidates = readdirSync2(root).filter((name) => name.startsWith("version-")).map((name) => path2.join(root, name, "RobloxStudioBeta.exe")).filter((candidate) => existsSync2(candidate)).sort((a, b) => statSync2(b).mtimeMs - statSync2(a).mtimeMs);
4386
+ 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);
3655
4387
  if (candidates.length === 0) {
3656
4388
  throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
3657
4389
  }
3658
4390
  return candidates[0];
3659
4391
  }
3660
- function listStudioProcesses() {
4392
+ async function resolveStudioExeAsync() {
4393
+ if (process.env.ROBLOX_STUDIO_EXE)
4394
+ return process.env.ROBLOX_STUDIO_EXE;
3661
4395
  if (process.platform === "darwin") {
3662
- let out2 = "";
3663
- try {
3664
- out2 = run("pgrep", ["-fl", "RobloxStudio"]);
3665
- } catch {
3666
- return [];
3667
- }
3668
- return out2.split("\n").filter(Boolean).map((line) => {
3669
- const [pid, ...rest] = line.trim().split(/\s+/);
3670
- return { Id: Number(pid), Name: "RobloxStudio", Path: rest.join(" "), MainWindowTitle: "" };
3671
- }).filter((proc) => Number.isFinite(proc.Id));
4396
+ return "/Applications/RobloxStudio.app/Contents/MacOS/RobloxStudio";
3672
4397
  }
3673
- if (process.platform !== "win32" && !isWsl())
3674
- return [];
3675
- let out = "";
4398
+ if (process.platform !== "win32" && !isWsl()) {
4399
+ throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
4400
+ }
4401
+ const localAppData = await windowsLocalAppDataAsync();
4402
+ const root = localAppData ? path2.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
4403
+ if (!existsSync2(root)) {
4404
+ throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
4405
+ }
4406
+ 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);
4407
+ if (candidates.length === 0) {
4408
+ throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
4409
+ }
4410
+ return candidates[0];
4411
+ }
4412
+ async function observeStudioProcesses() {
4413
+ const observedAt = Date.now();
3676
4414
  try {
3677
- out = powershell("Get-Process RobloxStudioBeta -ErrorAction SilentlyContinue | ForEach-Object { [PSCustomObject]@{ Id = $_.Id; Name = $_.Name; Path = $_.Path; MainWindowTitle = $_.MainWindowTitle; StartTimeUtcFileTime = $_.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } } | ConvertTo-Json -Compress");
3678
- } catch {
3679
- return [];
4415
+ if (process.platform === "darwin") {
4416
+ let out2 = "";
4417
+ try {
4418
+ out2 = await runAsync("pgrep", ["-fl", "RobloxStudio"]);
4419
+ } catch (error) {
4420
+ const code = error.code;
4421
+ if (Number(code) === 1)
4422
+ return { status: "ok", observedAt, processes: [] };
4423
+ throw error;
4424
+ }
4425
+ const processes = out2.split("\n").filter(Boolean).map((line) => {
4426
+ const [pid, ...rest] = line.trim().split(/\s+/);
4427
+ return { Id: Number(pid), Name: "RobloxStudio", Path: rest.join(" "), MainWindowTitle: "" };
4428
+ }).filter((proc) => Number.isFinite(proc.Id));
4429
+ return { status: "ok", observedAt, processes };
4430
+ }
4431
+ if (process.platform !== "win32" && !isWsl()) {
4432
+ return { status: "ok", observedAt, processes: [] };
4433
+ }
4434
+ const out = await powershellAsync("Get-Process RobloxStudioBeta -ErrorAction SilentlyContinue | ForEach-Object { [PSCustomObject]@{ Id = $_.Id; Name = $_.Name; Path = $_.Path; MainWindowTitle = $_.MainWindowTitle; StartTimeUtcFileTime = $_.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } } | ConvertTo-Json -Compress");
4435
+ if (!out)
4436
+ return { status: "ok", observedAt, processes: [] };
4437
+ const parsed = JSON.parse(out);
4438
+ return { status: "ok", observedAt, processes: Array.isArray(parsed) ? parsed : [parsed] };
4439
+ } catch (error) {
4440
+ return {
4441
+ status: "error",
4442
+ observedAt,
4443
+ error: error instanceof Error ? error.message : String(error)
4444
+ };
3680
4445
  }
3681
- if (!out)
3682
- return [];
3683
- const parsed = JSON.parse(out);
3684
- return Array.isArray(parsed) ? parsed : [parsed];
3685
4446
  }
3686
- function currentBootId() {
4447
+ async function currentBootIdAsync() {
3687
4448
  if (process.platform === "linux") {
3688
4449
  try {
3689
- return readFileSync3("/proc/sys/kernel/random/boot_id", "utf8").trim();
4450
+ return readFileSync2("/proc/sys/kernel/random/boot_id", "utf8").trim();
3690
4451
  } catch {
3691
4452
  }
3692
4453
  }
3693
4454
  if (process.platform === "win32" || isWsl()) {
3694
4455
  try {
3695
- return powershell('(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString("o")');
4456
+ return await powershellAsync('(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString("o")');
3696
4457
  } catch {
3697
4458
  }
3698
4459
  }
3699
4460
  if (process.platform === "darwin") {
3700
4461
  try {
3701
- return run("sysctl", ["-n", "kern.boottime"]);
4462
+ return await runAsync("sysctl", ["-n", "kern.boottime"]);
3702
4463
  } catch {
3703
4464
  }
3704
4465
  }
@@ -3737,13 +4498,13 @@ function buildStudioLaunchArgs(options) {
3737
4498
  ];
3738
4499
  }
3739
4500
  }
3740
- function delay(ms) {
4501
+ function delay2(ms) {
3741
4502
  return new Promise((resolve5) => setTimeout(resolve5, ms));
3742
4503
  }
3743
4504
  function basenameAny(filePath) {
3744
4505
  return path2.basename(filePath.replace(/\\/g, "/"));
3745
4506
  }
3746
- var BASEPLATE_TEMP_DIR, BASEPLATE_TEMP_NAME, BASEPLATE_TEMPLATE_NAME, ENVIRONMENT_VARIABLE_NAME, STALE_BASEPLATE_MAX_AGE_MS, BASEPLATE_TEMP_SWEEP_NAME, StudioInstanceManager;
4507
+ var BASEPLATE_TEMP_DIR, BASEPLATE_TEMP_NAME, BASEPLATE_TEMPLATE_NAME, retainedLaunchControls, execFileAsync, ENVIRONMENT_VARIABLE_NAME, STALE_BASEPLATE_MAX_AGE_MS, BASEPLATE_TEMP_SWEEP_NAME, LAUNCH_COMPLETION_TIMEOUT_MS, StudioInstanceManager;
3747
4508
  var init_studio_instance_manager = __esm({
3748
4509
  "../core/dist/studio-instance-manager.js"() {
3749
4510
  "use strict";
@@ -3751,27 +4512,45 @@ var init_studio_instance_manager = __esm({
3751
4512
  BASEPLATE_TEMP_DIR = path2.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
3752
4513
  BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
3753
4514
  BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
4515
+ retainedLaunchControls = /* @__PURE__ */ new Map();
4516
+ execFileAsync = promisify(execFile);
3754
4517
  ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
3755
4518
  STALE_BASEPLATE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
3756
4519
  BASEPLATE_TEMP_SWEEP_NAME = /^Baseplate-(\d+)-\d+\.rbxl(\.lock)?$/;
4520
+ LAUNCH_COMPLETION_TIMEOUT_MS = 3 * 60 * 1e3;
3757
4521
  StudioInstanceManager = class {
3758
4522
  managedByInstanceId = /* @__PURE__ */ new Map();
3759
4523
  pending = /* @__PURE__ */ new Set();
3760
- monitors = /* @__PURE__ */ new Map();
3761
4524
  connectionTimers = /* @__PURE__ */ new Map();
4525
+ launchCompletionTimers = /* @__PURE__ */ new Map();
4526
+ launchControls = retainedLaunchControls;
3762
4527
  registry;
3763
4528
  processAdapter;
4529
+ confirmedExitMisses;
4530
+ confirmedExitGraceMs;
4531
+ snapshotCacheMs;
4532
+ launchCompletionTimeoutMs;
4533
+ coordinatorTimer;
4534
+ coordinatorRefresh;
4535
+ cachedSnapshot;
4536
+ snapshotInFlight;
4537
+ launchQueue = Promise.resolve();
3764
4538
  constructor(options = {}) {
3765
4539
  this.registry = options.registry ?? new ManagedInstanceRegistry(options.registryDir);
3766
4540
  this.processAdapter = options.processAdapter ?? {};
3767
- }
3768
- list() {
3769
- this.sweepRegistry();
4541
+ this.confirmedExitMisses = options.confirmedExitMisses ?? 2;
4542
+ this.confirmedExitGraceMs = options.confirmedExitGraceMs ?? 5e3;
4543
+ this.snapshotCacheMs = options.snapshotCacheMs ?? 0;
4544
+ this.launchCompletionTimeoutMs = options.launchCompletionTimeoutMs ?? LAUNCH_COMPLETION_TIMEOUT_MS;
4545
+ }
4546
+ async list() {
4547
+ const snapshot = await this.getProcessSnapshot();
4548
+ await this.sweepRegistry(snapshot);
3770
4549
  for (const record of [...this.managedByInstanceId.values(), ...this.pending]) {
3771
- this.refresh(record);
4550
+ await this.refresh(record, snapshot);
3772
4551
  }
3773
4552
  const records = [...this.managedByInstanceId.values(), ...this.pending];
3774
- for (const registryRecord of this.registry.listOpen(this.registrySweepOptions())) {
4553
+ for (const registryRecord of await this.registry.listOpenUnchecked()) {
3775
4554
  const record = this.fromRegistryRecord(registryRecord);
3776
4555
  if (records.some((existing) => record.recordId && existing.recordId === record.recordId || record.instanceId && existing.instanceId === record.instanceId)) {
3777
4556
  continue;
@@ -3780,27 +4559,86 @@ var init_studio_instance_manager = __esm({
3780
4559
  }
3781
4560
  return records.filter((record) => record.closedAt === void 0).filter((instance, index, all) => all.indexOf(instance) === index);
3782
4561
  }
3783
- get(instanceId) {
3784
- this.sweepRegistry();
4562
+ async get(instanceId) {
4563
+ const snapshot = await this.getProcessSnapshot();
4564
+ await this.sweepRegistry(snapshot);
3785
4565
  const memoryRecord = this.managedByInstanceId.get(instanceId);
3786
4566
  if (memoryRecord)
3787
- return this.refresh(memoryRecord);
3788
- const registryRecord = this.registry.findAnyByInstanceId(instanceId);
3789
- return registryRecord ? this.refresh(this.fromRegistryRecord(registryRecord)) : void 0;
4567
+ return this.refresh(memoryRecord, snapshot);
4568
+ const registryRecord = await this.registry.findAnyByInstanceId(instanceId);
4569
+ return registryRecord ? this.refresh(this.fromRegistryRecord(registryRecord), snapshot) : void 0;
3790
4570
  }
3791
- getByLaunchId(launchId) {
3792
- this.sweepRegistry();
4571
+ async getByLaunchId(launchId) {
4572
+ const snapshot = await this.getProcessSnapshot();
4573
+ await this.sweepRegistry(snapshot);
3793
4574
  const memoryRecord = [...this.managedByInstanceId.values(), ...this.pending].find((record) => record.recordId === launchId);
3794
4575
  if (memoryRecord)
3795
- return this.refresh(memoryRecord);
3796
- const registryRecord = this.registry.findAnyByRecordId(launchId, this.registrySweepOptions());
3797
- return registryRecord ? this.refresh(this.fromRegistryRecord(registryRecord)) : void 0;
4576
+ return this.refresh(memoryRecord, snapshot);
4577
+ const registryRecord = await this.registry.findAnyByRecordId(launchId);
4578
+ return registryRecord ? this.refresh(this.fromRegistryRecord(registryRecord), snapshot) : void 0;
4579
+ }
4580
+ peekByLaunchId(launchId) {
4581
+ return [...this.managedByInstanceId.values(), ...this.pending].find((record) => record.recordId === launchId);
4582
+ }
4583
+ async authorizeByLaunchId(launchId) {
4584
+ const record = this.peekByLaunchId(launchId);
4585
+ if (!record)
4586
+ throw new Error(`Launch ${launchId} is not retained by this broker process.`);
4587
+ if (record.closedAt !== void 0 || record.state === "failed" || record.state === "exited") {
4588
+ throw new Error(`Launch ${launchId} is no longer eligible for process authorization.`);
4589
+ }
4590
+ if (record.processAuthorizationState === "authorized")
4591
+ return record;
4592
+ const control = this.launchControls.get(launchId);
4593
+ if (record.processAuthorizationState !== "pending" || !control) {
4594
+ throw new Error(`Launch ${launchId} has no retained process authorization handle.`);
4595
+ }
4596
+ try {
4597
+ this.armLaunchCompletionTimer(record);
4598
+ await control.authorize();
4599
+ record.processAuthorizationState = "authorized";
4600
+ await this.persist(record);
4601
+ this.armLaunchCompletionTimer(record);
4602
+ return record;
4603
+ } catch (error) {
4604
+ record.processAuthorizationState = "pending";
4605
+ const detail = error instanceof Error ? error.message : String(error);
4606
+ await this.markFailed(record, `Studio process authorization failed: ${detail}`);
4607
+ throw error;
4608
+ }
4609
+ }
4610
+ async completeByLaunchId(launchId) {
4611
+ const record = this.peekByLaunchId(launchId);
4612
+ if (!record)
4613
+ throw new Error(`Launch ${launchId} is not retained by this broker process.`);
4614
+ if (record.closedAt !== void 0 || record.state === "failed" || record.state === "exited") {
4615
+ throw new Error(`Launch ${launchId} is no longer eligible for ownership release.`);
4616
+ }
4617
+ if (record.processAuthorizationState === "released")
4618
+ return record;
4619
+ const control = this.launchControls.get(launchId);
4620
+ if (record.processAuthorizationState !== "authorized" || !control) {
4621
+ throw new Error(`Launch ${launchId} has no authorized ownership handle to release.`);
4622
+ }
4623
+ try {
4624
+ this.clearLaunchCompletionTimer(record);
4625
+ await control.release();
4626
+ record.processAuthorizationState = "released";
4627
+ this.launchControls.delete(launchId);
4628
+ await this.persist(record);
4629
+ return record;
4630
+ } catch (error) {
4631
+ const detail = error instanceof Error ? error.message : String(error);
4632
+ await this.markFailed(record, `Studio process ownership release failed: ${detail}`);
4633
+ throw error;
4634
+ }
3798
4635
  }
3799
- pendingLaunches() {
4636
+ async pendingLaunches() {
3800
4637
  const now = Date.now();
4638
+ const bootId = await this.getCurrentBootId();
3801
4639
  const records = [...this.pending];
3802
- for (const registryRecord of this.registry.listOpenUnchecked()) {
3803
- if (registryRecord.bootId !== this.getCurrentBootId())
4640
+ for (const registryRecord of await this.registry.listOpenUnchecked()) {
4641
+ if (registryRecord.bootId !== bootId)
3804
4642
  continue;
3805
4643
  if (records.some((record) => record.recordId === registryRecord.recordId))
3806
4644
  continue;
@@ -3808,8 +4646,9 @@ var init_studio_instance_manager = __esm({
3808
4646
  }
3809
4647
  return records.filter((record) => record.instanceId === void 0).filter((record) => record.state === "launching").filter((record) => record.connectionDeadlineAt === void 0 || record.connectionDeadlineAt > now);
3810
4648
  }
3811
- attachInstanceId(record, instanceId) {
3812
- this.refresh(record);
4649
+ async attachInstanceId(record, instanceId) {
4650
+ const snapshot = await this.getProcessSnapshot(true);
4651
+ await this.reconcileFromPositiveEvidence(record, snapshot);
3813
4652
  if (record.closedAt !== void 0 || record.state === "failed" || record.state === "exited")
3814
4653
  return;
3815
4654
  if (record.instanceId && record.instanceId !== instanceId)
@@ -3820,47 +4659,75 @@ var init_studio_instance_manager = __esm({
3820
4659
  this.clearConnectionTimer(record);
3821
4660
  this.pending.delete(record);
3822
4661
  this.managedByInstanceId.set(instanceId, record);
3823
- this.persist(record);
4662
+ await this.persist(record);
3824
4663
  }
3825
- markFailed(record, reason) {
3826
- this.refresh(record);
3827
- if (record.closedAt !== void 0 || record.state !== "launching")
4664
+ async markFailed(record, reason) {
4665
+ if (record.closedAt !== void 0 || record.state === "failed" || record.state === "exited")
3828
4666
  return record;
3829
4667
  record.state = "failed";
3830
4668
  record.failedAt = Date.now();
3831
4669
  record.failureReason = reason;
3832
4670
  this.clearConnectionTimer(record);
3833
- this.persist(record);
4671
+ this.clearLaunchCompletionTimer(record);
4672
+ const control = record.recordId ? this.launchControls.get(record.recordId) : void 0;
4673
+ if (record.processAuthorizationState !== "released" && control) {
4674
+ try {
4675
+ await control.abort();
4676
+ const exitedAt = Date.now();
4677
+ record.exitedAt = exitedAt;
4678
+ record.closedAt = exitedAt;
4679
+ record.processObservationStatus = "not_running";
4680
+ record.lastProcessObservationAt = exitedAt;
4681
+ record.lastSuccessfulProcessObservationAt = exitedAt;
4682
+ this.cleanupManagedRecord(record);
4683
+ this.markClosedInMemory(record);
4684
+ } catch (error) {
4685
+ record.failureReason += ` Exact suspended-process cleanup also failed: ${error instanceof Error ? error.message : String(error)}`;
4686
+ }
4687
+ }
4688
+ await this.persist(record);
3834
4689
  return record;
3835
4690
  }
3836
- refresh(record) {
4691
+ async refresh(record, providedSnapshot) {
4692
+ if (record.closedAt !== void 0)
4693
+ return record;
4694
+ const snapshot = providedSnapshot ?? await this.getProcessSnapshot();
4695
+ await this.applyProcessObservation(record, this.observeRecord(record, snapshot));
3837
4696
  if (record.closedAt !== void 0)
3838
4697
  return record;
3839
- const processId = record.nativeProcessId ?? record.spawnPid;
3840
- const studioProcess = processId ? this.findProcessById(processId) : void 0;
3841
- if (processId && (!studioProcess || !this.verifyProcessForRecord(record, studioProcess))) {
3842
- return this.markProcessExited(record, void 0, studioProcess ? "Studio process identity changed; the retained PID was not reused." : record.instanceId ? "Studio process exited." : "Studio process exited before the MCP plugin connected.");
3843
- }
3844
4698
  if (record.state === "launching" && record.connectionDeadlineAt !== void 0 && Date.now() >= record.connectionDeadlineAt) {
3845
- record.state = "failed";
3846
- record.failedAt = Date.now();
3847
- record.failureReason = "Studio launched, but the MCP plugin did not connect before timeout.";
3848
- this.clearConnectionTimer(record);
3849
- this.persist(record);
4699
+ return this.markFailed(record, "Studio launched, but the MCP plugin did not connect before timeout.");
3850
4700
  }
3851
4701
  return record;
3852
4702
  }
3853
4703
  async launch(options) {
3854
- this.sweepRegistry();
4704
+ const previous = this.launchQueue;
4705
+ let release;
4706
+ this.launchQueue = new Promise((resolve5) => {
4707
+ release = resolve5;
4708
+ });
4709
+ await previous;
4710
+ try {
4711
+ return await this.launchSerialized(options);
4712
+ } finally {
4713
+ release();
4714
+ }
4715
+ }
4716
+ async launchSerialized(options) {
4717
+ const initialSnapshot = await this.getProcessSnapshot(true);
4718
+ await this.sweepRegistry(initialSnapshot);
3855
4719
  const processEnvironment = parseStudioProcessEnvironmentPatch(options.processEnvironment);
3856
4720
  if (options.studioExecutable !== void 0 && (typeof options.studioExecutable !== "string" || options.studioExecutable.length === 0 || options.studioExecutable.includes("\0"))) {
3857
4721
  throw new Error("studio_executable must be a non-empty string without null characters.");
3858
4722
  }
4723
+ if (options.requireProcessIdentity && !this.processAdapter.spawnStudio && process.platform !== "win32" && !isWsl()) {
4724
+ throw new Error("require_process_identity is supported only by the identity-retaining Windows launcher or a custom process adapter.");
4725
+ }
3859
4726
  const preparedOptions = prepareStudioLaunchOptions(options);
3860
- const bootId = this.getCurrentBootId();
3861
- const before = new Set(this.listStudioProcesses().map((proc2) => proc2.Id));
3862
- const exe = preparedOptions.studioExecutable ?? this.processAdapter.resolveStudioExe?.() ?? resolveStudioExe();
3863
- const args = buildStudioLaunchArgs(preparedOptions).map(toStudioLaunchArg);
4727
+ const bootId = await this.getCurrentBootId();
4728
+ const before = new Set(initialSnapshot.status === "ok" ? initialSnapshot.processes.map((proc2) => proc2.Id) : []);
4729
+ const exe = preparedOptions.studioExecutable ?? (this.processAdapter.resolveStudioExe ? await this.processAdapter.resolveStudioExe() : await resolveStudioExeAsync());
4730
+ const args = await Promise.all(buildStudioLaunchArgs(preparedOptions).map(toStudioLaunchArgAsync));
3864
4731
  const spawnOptions = {
3865
4732
  cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
3866
4733
  detached: true,
@@ -3870,9 +4737,9 @@ var init_studio_instance_manager = __esm({
3870
4737
  let proc;
3871
4738
  try {
3872
4739
  if (this.processAdapter.spawnStudio) {
3873
- proc = this.processAdapter.spawnStudio(exe, args, spawnOptions);
3874
- } else if (isWsl()) {
3875
- proc = spawnWindowsStudioFromWsl(exe, args, processEnvironment);
4740
+ proc = await this.processAdapter.spawnStudio(exe, args, spawnOptions);
4741
+ } else if (process.platform === "win32" || isWsl()) {
4742
+ proc = await spawnWindowsStudio(exe, args, processEnvironment);
3876
4743
  } else {
3877
4744
  const child = spawn(exe, args, spawnOptions);
3878
4745
  proc = {
@@ -3891,8 +4758,40 @@ var init_studio_instance_manager = __esm({
3891
4758
  cleanupManagedBaseplateFiles({ source: preparedOptions.source, localPlaceFile: preparedOptions.localPlaceFile });
3892
4759
  throw error;
3893
4760
  }
4761
+ if (options.requireProcessIdentity && (!Number.isSafeInteger(proc.nativePid) || (proc.nativePid ?? 0) <= 0 || proc.nativeStartedAt === void 0 || !/^[1-9]\d*$/u.test(proc.nativeStartedAt) || !proc.authorize || !proc.release || !proc.abort)) {
4762
+ const reason = "Studio launcher did not return an exact creation identity and suspended-process control handles.";
4763
+ let abortError;
4764
+ try {
4765
+ if (proc.abort) {
4766
+ await proc.abort();
4767
+ } else if (proc.nativePid && proc.nativeStartedAt) {
4768
+ await this.closeProcess(proc.nativePid, proc.nativeStartedAt);
4769
+ } else {
4770
+ throw new Error("the process adapter did not retain an abort handle or exact process identity");
4771
+ }
4772
+ } catch (error) {
4773
+ abortError = error;
4774
+ }
4775
+ cleanupManagedBaseplateFiles({ source: preparedOptions.source, localPlaceFile: preparedOptions.localPlaceFile });
4776
+ const abortDetail = abortError ? ` Exact child cleanup also failed: ${abortError instanceof Error ? abortError.message : String(abortError)}` : " The owned process was stopped.";
4777
+ throw new Error(`${reason}${abortDetail}`);
4778
+ }
4779
+ if (!options.requireProcessIdentity && proc.authorize) {
4780
+ try {
4781
+ await proc.authorize();
4782
+ await proc.release?.();
4783
+ } catch (error) {
4784
+ try {
4785
+ await proc.abort?.();
4786
+ } finally {
4787
+ cleanupManagedBaseplateFiles({ source: preparedOptions.source, localPlaceFile: preparedOptions.localPlaceFile });
4788
+ }
4789
+ throw error;
4790
+ }
4791
+ }
4792
+ const launchedAt = Date.now();
3894
4793
  const record = {
3895
- recordId: randomUUID(),
4794
+ recordId: randomUUID2(),
3896
4795
  source: options.source,
3897
4796
  nativeProcessId: proc.nativePid,
3898
4797
  nativeProcessStartedAt: proc.nativeStartedAt,
@@ -3903,26 +4802,41 @@ var init_studio_instance_manager = __esm({
3903
4802
  universeId: preparedOptions.universeId,
3904
4803
  placeVersion: preparedOptions.placeVersion,
3905
4804
  localPlaceFile: preparedOptions.localPlaceFile,
3906
- launchedAt: Date.now(),
3907
- connectionDeadlineAt: Date.now() + (options.connectionTimeoutMs ?? 12e4),
4805
+ launchedAt,
4806
+ connectionDeadlineAt: options.requireProcessIdentity ? void 0 : launchedAt + (options.connectionTimeoutMs ?? 12e4),
3908
4807
  state: "launching",
3909
4808
  ownerPid: process.pid,
3910
4809
  bootId,
3911
- deleteLocalPlaceFileOnClose: options.source === "baseplate"
4810
+ deleteLocalPlaceFileOnClose: options.source === "baseplate",
4811
+ processAuthorizationState: options.requireProcessIdentity && proc.authorize && proc.release ? "pending" : "released",
4812
+ processObservationStatus: "running",
4813
+ lastProcessObservationAt: launchedAt,
4814
+ lastSuccessfulProcessObservationAt: launchedAt,
4815
+ consecutiveConfirmedMisses: 0
3912
4816
  };
3913
4817
  this.pending.add(record);
3914
4818
  try {
3915
- this.persist(record);
4819
+ await this.persist(record);
4820
+ if (record.recordId && record.processAuthorizationState === "pending" && proc.authorize && proc.release && proc.abort) {
4821
+ this.launchControls.set(record.recordId, {
4822
+ authorize: proc.authorize,
4823
+ release: proc.release,
4824
+ abort: proc.abort
4825
+ });
4826
+ this.armLaunchCompletionTimer(record);
4827
+ }
3916
4828
  } catch (error) {
3917
4829
  this.pending.delete(record);
3918
4830
  const processId = record.nativeProcessId ?? record.spawnPid;
3919
4831
  let stopError;
3920
- if (processId) {
3921
- try {
3922
- this.closeProcess(processId);
3923
- } catch (caught) {
3924
- stopError = caught;
4832
+ try {
4833
+ if (proc.abort) {
4834
+ await proc.abort();
4835
+ } else if (processId) {
4836
+ await this.closeProcess(processId, record.nativeProcessStartedAt);
3925
4837
  }
4838
+ } catch (caught) {
4839
+ stopError = caught;
3926
4840
  }
3927
4841
  cleanupManagedBaseplateFiles(record);
3928
4842
  const detail = error instanceof Error ? error.message : String(error);
@@ -3931,38 +4845,40 @@ var init_studio_instance_manager = __esm({
3931
4845
  }
3932
4846
  proc.unref();
3933
4847
  proc.onExit?.((code, signal) => {
3934
- this.markProcessExited(record, code ?? void 0, signal ? `Studio process exited from signal ${signal}.` : record.instanceId ? "Studio process exited." : "Studio process exited before the MCP plugin connected.");
4848
+ this.runInBackground("persisting a Studio process exit", this.markProcessExited(record, code ?? void 0, signal ? `Studio process exited from signal ${signal}.` : record.instanceId ? "Studio process exited." : "Studio process exited before the MCP plugin connected."));
3935
4849
  });
3936
4850
  proc.onError?.((error) => {
3937
- this.markFailed(record, `Studio process failed to start: ${error.message}`);
4851
+ this.runInBackground("persisting a Studio process launch failure", this.markFailed(record, `Studio process failed to start: ${error.message}`));
3938
4852
  });
3939
4853
  const deadline = Date.now() + 5e3;
3940
4854
  while (Date.now() < deadline && record.nativeProcessId === void 0) {
3941
- const created = this.listStudioProcesses().find((candidate) => !before.has(candidate.Id));
4855
+ const snapshot = await this.getProcessSnapshot(true);
4856
+ const created = snapshot.status === "ok" ? snapshot.processes.find((candidate) => !before.has(candidate.Id)) : void 0;
3942
4857
  if (created) {
3943
4858
  record.nativeProcessId = created.Id;
3944
4859
  record.nativeProcessStartedAt = created.StartTimeUtcFileTime;
3945
- this.persist(record);
4860
+ await this.persist(record);
3946
4861
  break;
3947
4862
  }
3948
- await delay(250);
4863
+ await delay2(250);
3949
4864
  }
3950
4865
  if (record.nativeProcessId === void 0 && process.platform !== "win32" && !isWsl()) {
3951
4866
  record.nativeProcessId = proc.pid;
3952
- this.persist(record);
4867
+ await this.persist(record);
3953
4868
  }
3954
4869
  if (record.nativeProcessId !== void 0 && record.nativeProcessStartedAt === void 0) {
3955
- const nativeProcess = this.findProcessById(record.nativeProcessId);
4870
+ const snapshot = await this.getProcessSnapshot(true);
4871
+ const nativeProcess = snapshot.status === "ok" ? snapshot.processes.find((candidate) => candidate.Id === record.nativeProcessId) : void 0;
3956
4872
  if (nativeProcess?.StartTimeUtcFileTime !== void 0) {
3957
4873
  record.nativeProcessStartedAt = nativeProcess.StartTimeUtcFileTime;
3958
- this.persist(record);
4874
+ await this.persist(record);
3959
4875
  }
3960
4876
  }
3961
- this.startMonitor(record);
4877
+ this.startCoordinator(record);
3962
4878
  return record;
3963
4879
  }
3964
- closeByLaunchId(launchId) {
3965
- const record = this.getByLaunchId(launchId);
4880
+ async closeByLaunchId(launchId) {
4881
+ const record = await this.getByLaunchId(launchId);
3966
4882
  if (!record)
3967
4883
  return { status: "not_found", launchId };
3968
4884
  if (record.closedAt !== void 0) {
@@ -3970,19 +4886,19 @@ var init_studio_instance_manager = __esm({
3970
4886
  }
3971
4887
  return this.close(record);
3972
4888
  }
3973
- closeByInstanceId(instanceId) {
3974
- this.sweepRegistry();
4889
+ async closeByInstanceId(instanceId) {
4890
+ const snapshot = await this.getProcessSnapshot(true);
4891
+ await this.sweepRegistry(snapshot);
3975
4892
  const memoryRecord = this.managedByInstanceId.get(instanceId);
3976
4893
  if (memoryRecord)
3977
4894
  return this.close(memoryRecord);
3978
- const registryRecord = this.registry.findAnyByInstanceId(instanceId);
4895
+ const registryRecord = await this.registry.findAnyByInstanceId(instanceId);
3979
4896
  if (!registryRecord) {
3980
- this.sweepRegistry();
3981
4897
  return { status: "not_found", instanceId };
3982
4898
  }
3983
4899
  if (registryRecord.closedAt !== void 0) {
3984
4900
  this.cleanupManagedRecord(registryRecord);
3985
- this.registry.logEvent({
4901
+ await this.registry.logEvent({
3986
4902
  event: "registry_close_already_stopped",
3987
4903
  recordId: registryRecord.recordId,
3988
4904
  instanceId: registryRecord.instanceId,
@@ -3994,46 +4910,69 @@ var init_studio_instance_manager = __esm({
3994
4910
  }
3995
4911
  return this.close(this.fromRegistryRecord(registryRecord));
3996
4912
  }
3997
- close(record) {
3998
- this.stopMonitor(record);
3999
- this.refresh(record);
4913
+ async close(record) {
4000
4914
  if (record.closedAt !== void 0) {
4001
- return { status: "already_closed", launchId: record.recordId, instanceId: record.instanceId };
4915
+ return {
4916
+ status: "already_closed",
4917
+ launchId: record.recordId,
4918
+ instanceId: record.instanceId
4919
+ };
4002
4920
  }
4003
4921
  const processId = record.nativeProcessId ?? record.spawnPid;
4004
4922
  if (!processId) {
4005
4923
  throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
4006
4924
  }
4007
- const studioProcess = this.findProcessById(processId);
4008
- if (!studioProcess) {
4925
+ const control = record.recordId ? this.launchControls.get(record.recordId) : void 0;
4926
+ if (record.processAuthorizationState !== "released" && control) {
4927
+ await control.abort();
4928
+ const closedAt2 = Date.now();
4929
+ record.closedAt = closedAt2;
4930
+ record.exitedAt = closedAt2;
4931
+ if (record.state !== "failed")
4932
+ record.state = "exited";
4933
+ record.processObservationStatus = "not_running";
4934
+ record.lastProcessObservationAt = closedAt2;
4935
+ record.lastSuccessfulProcessObservationAt = closedAt2;
4936
+ record.lastProcessObservationError = void 0;
4009
4937
  this.cleanupManagedRecord(record);
4010
- this.markProcessExited(record, void 0, record.failureReason);
4011
- this.registry.logEvent({
4938
+ this.markClosedInMemory(record);
4939
+ await this.persist(record);
4940
+ return {
4941
+ status: "closed",
4942
+ launchId: record.recordId,
4943
+ instanceId: record.instanceId
4944
+ };
4945
+ }
4946
+ const snapshot = await this.getProcessSnapshot(true);
4947
+ const observation = this.observeRecord(record, snapshot);
4948
+ if (observation.status === "unknown") {
4949
+ await this.applyProcessObservation(record, observation);
4950
+ throw new Error(`Cannot verify the managed Studio process because process observation failed: ${observation.error}`);
4951
+ }
4952
+ if (observation.status === "not_running") {
4953
+ await this.markProcessExited(record, void 0, observation.reason === "identity_mismatch" ? "Studio process identity changed; the retained PID was not reused." : record.failureReason);
4954
+ await this.registry.logEvent({
4012
4955
  event: "registry_close_already_stopped",
4013
4956
  recordId: record.recordId,
4014
4957
  instanceId: record.instanceId,
4015
4958
  source: record.source,
4016
- reason: "pid_not_running",
4959
+ reason: observation.reason === "identity_mismatch" ? "identity_mismatch" : "pid_not_running",
4017
4960
  action: "marked_closed_and_cleaned_baseplate"
4018
4961
  });
4019
- return { status: "already_closed", launchId: record.recordId, instanceId: record.instanceId };
4020
- }
4021
- if (!this.verifyProcessForRecord(record, studioProcess)) {
4022
- this.registry.logEvent({
4023
- event: "registry_process_verification_failed",
4024
- recordId: record.recordId,
4025
- instanceId: record.instanceId,
4026
- source: record.source,
4027
- reason: "identity_mismatch"
4028
- });
4029
- throw new Error("Managed Studio process identity could not be verified.");
4962
+ return {
4963
+ status: "already_closed",
4964
+ launchId: record.recordId,
4965
+ instanceId: record.instanceId
4966
+ };
4030
4967
  }
4031
4968
  try {
4032
- this.closeProcess(processId);
4969
+ await this.closeProcess(processId, record.nativeProcessStartedAt);
4033
4970
  } catch (error) {
4034
- if (this.findProcessById(processId))
4971
+ const retry = await this.getProcessSnapshot(true);
4972
+ const retryObservation = this.observeRecord(record, retry);
4973
+ if (retryObservation.status === "running" || retryObservation.status === "unknown")
4035
4974
  throw error;
4036
- this.registry.logEvent({
4975
+ await this.registry.logEvent({
4037
4976
  event: "registry_close_already_stopped",
4038
4977
  recordId: record.recordId,
4039
4978
  instanceId: record.instanceId,
@@ -4041,34 +4980,52 @@ var init_studio_instance_manager = __esm({
4041
4980
  reason: "stop_raced_with_exit",
4042
4981
  action: "marked_closed_and_cleaned_baseplate"
4043
4982
  });
4044
- this.cleanupManagedRecord(record);
4045
- this.markProcessExited(record, void 0, record.failureReason);
4046
- return { status: "already_closed", launchId: record.recordId, instanceId: record.instanceId };
4983
+ await this.markProcessExited(record, void 0, record.failureReason);
4984
+ return {
4985
+ status: "already_closed",
4986
+ launchId: record.recordId,
4987
+ instanceId: record.instanceId
4988
+ };
4047
4989
  }
4048
4990
  const closedAt = Date.now();
4049
4991
  record.closedAt = closedAt;
4050
4992
  record.exitedAt = record.exitedAt ?? closedAt;
4051
4993
  if (record.state !== "failed")
4052
4994
  record.state = "exited";
4995
+ record.processObservationStatus = "not_running";
4996
+ record.lastProcessObservationAt = closedAt;
4997
+ record.lastSuccessfulProcessObservationAt = closedAt;
4998
+ record.lastProcessObservationError = void 0;
4053
4999
  this.cleanupManagedRecord(record);
4054
5000
  this.markClosedInMemory(record);
4055
- this.persist(record);
4056
- return { status: "closed", launchId: record.recordId, instanceId: record.instanceId };
5001
+ await this.persist(record);
5002
+ return {
5003
+ status: "closed",
5004
+ launchId: record.recordId,
5005
+ instanceId: record.instanceId
5006
+ };
4057
5007
  }
4058
- closeConnectedInstance(instance) {
4059
- const process2 = this.findProcessForConnectedInstance(instance);
5008
+ async closeConnectedInstance(instance) {
5009
+ const snapshot = await this.getProcessSnapshot(true);
5010
+ if (snapshot.status === "error") {
5011
+ throw new Error(`Could not enumerate Studio processes: ${snapshot.error}`);
5012
+ }
5013
+ const process2 = this.findProcessForConnectedInstance(instance, snapshot.processes);
4060
5014
  if (!process2) {
4061
5015
  throw new Error(`Could not find a Studio process for connected instance "${instance.instanceId}".`);
4062
5016
  }
4063
- this.closeProcess(process2.Id);
5017
+ await this.closeProcess(process2.Id, process2.StartTimeUtcFileTime);
4064
5018
  }
4065
- closeProcess(processId) {
5019
+ async closeProcess(processId, startedAt) {
4066
5020
  if (this.processAdapter.stopProcess) {
4067
- this.processAdapter.stopProcess(processId);
5021
+ await this.processAdapter.stopProcess(processId, startedAt);
4068
5022
  return;
4069
5023
  }
4070
5024
  if (process.platform === "win32" || isWsl()) {
4071
- powershell(`Stop-Process -Id ${Math.trunc(processId)} -Force -ErrorAction Stop`);
5025
+ if (startedAt === void 0) {
5026
+ throw new Error("Cannot stop a Windows Studio process without its creation-time identity.");
5027
+ }
5028
+ await stopWindowsStudio(processId, startedAt);
4072
5029
  } else {
4073
5030
  try {
4074
5031
  process.kill(processId, "SIGTERM");
@@ -4078,8 +5035,7 @@ var init_studio_instance_manager = __esm({
4078
5035
  }
4079
5036
  }
4080
5037
  }
4081
- findProcessForConnectedInstance(instance) {
4082
- const processes = this.listStudioProcesses();
5038
+ findProcessForConnectedInstance(instance, processes) {
4083
5039
  if (processes.length === 0)
4084
5040
  return void 0;
4085
5041
  if (processes.length === 1)
@@ -4098,31 +5054,109 @@ var init_studio_instance_manager = __esm({
4098
5054
  }
4099
5055
  return void 0;
4100
5056
  }
4101
- listStudioProcesses() {
4102
- return this.processAdapter.listStudioProcesses?.() ?? listStudioProcesses();
5057
+ async getProcessSnapshot(force = false) {
5058
+ const now = Date.now();
5059
+ if (!force && this.snapshotCacheMs > 0 && this.cachedSnapshot && now - this.cachedSnapshot.observedAt <= this.snapshotCacheMs) {
5060
+ return this.cachedSnapshot;
5061
+ }
5062
+ if (this.snapshotInFlight)
5063
+ return this.snapshotInFlight;
5064
+ this.snapshotInFlight = (async () => {
5065
+ try {
5066
+ let snapshot;
5067
+ if (this.processAdapter.observeStudioProcesses) {
5068
+ snapshot = await this.processAdapter.observeStudioProcesses();
5069
+ } else if (this.processAdapter.listStudioProcesses) {
5070
+ const observedAt = Date.now();
5071
+ const processes = await this.processAdapter.listStudioProcesses();
5072
+ snapshot = { status: "ok", observedAt, processes };
5073
+ } else {
5074
+ snapshot = await observeStudioProcesses();
5075
+ }
5076
+ this.cachedSnapshot = snapshot;
5077
+ return snapshot;
5078
+ } catch (error) {
5079
+ const snapshot = {
5080
+ status: "error",
5081
+ observedAt: Date.now(),
5082
+ error: error instanceof Error ? error.message : String(error)
5083
+ };
5084
+ this.cachedSnapshot = snapshot;
5085
+ return snapshot;
5086
+ } finally {
5087
+ this.snapshotInFlight = void 0;
5088
+ }
5089
+ })();
5090
+ return this.snapshotInFlight;
4103
5091
  }
4104
- getCurrentBootId() {
4105
- return this.processAdapter.currentBootId?.() ?? currentBootId();
5092
+ async getCurrentBootId() {
5093
+ return this.processAdapter.currentBootId ? await this.processAdapter.currentBootId() : currentBootIdAsync();
4106
5094
  }
4107
- registrySweepOptions() {
5095
+ async registrySweepOptions(snapshot) {
4108
5096
  return {
4109
- currentBootId: this.getCurrentBootId(),
4110
- isProcessRunning: (record) => this.isRegistryProcessRunning(record),
4111
- cleanupRecord: (record) => this.cleanupManagedRecord(record)
5097
+ currentBootId: await this.getCurrentBootId(),
5098
+ observeProcess: (record) => this.observeRecord(this.fromRegistryRecord(record), snapshot),
5099
+ cleanupRecord: (record) => {
5100
+ if (record.processAuthorizationState !== "released" && this.launchControls.has(record.recordId))
5101
+ return;
5102
+ this.cleanupManagedRecord(record);
5103
+ },
5104
+ confirmedExitMisses: this.confirmedExitMisses,
5105
+ confirmedExitGraceMs: this.confirmedExitGraceMs
4112
5106
  };
4113
5107
  }
4114
- sweepRegistry() {
4115
- this.registry.sweep(this.registrySweepOptions());
4116
- }
4117
- findProcessById(processId) {
4118
- return this.listStudioProcesses().find((proc) => proc.Id === processId);
5108
+ async sweepRegistry(snapshot) {
5109
+ const sweepOptions = await this.registrySweepOptions(snapshot);
5110
+ await this.registry.sweep(sweepOptions);
5111
+ const persisted = await this.registry.listOpenUnchecked();
5112
+ for (const registryRecord of persisted) {
5113
+ const ownerPid = registryRecord.ownerPid;
5114
+ if (registryRecord.processAuthorizationState === "released" || this.launchControls.has(registryRecord.recordId) || registryRecord.bootId !== sweepOptions.currentBootId || ownerPid === void 0 || ownerPid !== process.pid && isProcessAlive2(ownerPid)) {
5115
+ continue;
5116
+ }
5117
+ const record = this.fromRegistryRecord(registryRecord);
5118
+ if (this.observeRecord(record, snapshot).status !== "running")
5119
+ continue;
5120
+ const processId = record.nativeProcessId ?? record.spawnPid;
5121
+ if (!processId || !record.nativeProcessStartedAt) {
5122
+ record.state = "failed";
5123
+ record.failedAt = Date.now();
5124
+ record.failureReason = "Orphaned unreleased Studio launch has no exact process identity for cleanup.";
5125
+ await this.persist(record);
5126
+ continue;
5127
+ }
5128
+ try {
5129
+ await this.closeProcess(processId, record.nativeProcessStartedAt);
5130
+ const closedAt = Date.now();
5131
+ record.state = "failed";
5132
+ record.failedAt = closedAt;
5133
+ record.failureReason = "Orphaned unreleased Studio launch was stopped after its broker owner exited.";
5134
+ record.exitedAt = closedAt;
5135
+ record.closedAt = closedAt;
5136
+ record.processObservationStatus = "not_running";
5137
+ record.lastProcessObservationAt = closedAt;
5138
+ record.lastSuccessfulProcessObservationAt = closedAt;
5139
+ this.cleanupManagedRecord(record);
5140
+ await this.persist(record);
5141
+ } catch (error) {
5142
+ record.state = "failed";
5143
+ record.failedAt = Date.now();
5144
+ record.failureReason = `Failed to stop orphaned unreleased Studio launch: ${error instanceof Error ? error.message : String(error)}`;
5145
+ await this.persist(record);
5146
+ }
5147
+ }
4119
5148
  }
4120
- isRegistryProcessRunning(record) {
5149
+ observeRecord(record, snapshot) {
5150
+ if (snapshot.status === "error") {
5151
+ return { status: "unknown", observedAt: snapshot.observedAt, error: snapshot.error };
5152
+ }
4121
5153
  const processId = record.nativeProcessId ?? record.spawnPid;
4122
5154
  if (!processId)
4123
- return true;
4124
- const studioProcess = this.findProcessById(processId);
4125
- return !!studioProcess && this.verifyProcessForRecord(this.fromRegistryRecord(record), studioProcess);
5155
+ return { status: "running", observedAt: snapshot.observedAt };
5156
+ const studioProcess = snapshot.processes.find((candidate) => candidate.Id === processId);
5157
+ if (!studioProcess)
5158
+ return { status: "not_running", observedAt: snapshot.observedAt, reason: "missing" };
5159
+ return this.verifyProcessForRecord(record, studioProcess) ? { status: "running", observedAt: snapshot.observedAt } : { status: "not_running", observedAt: snapshot.observedAt, reason: "identity_mismatch" };
4126
5160
  }
4127
5161
  verifyProcessForRecord(record, studioProcess) {
4128
5162
  const processName = `${studioProcess.Name ?? ""} ${studioProcess.Path ?? ""}`.toLowerCase();
@@ -4147,62 +5181,104 @@ var init_studio_instance_manager = __esm({
4147
5181
  return false;
4148
5182
  }
4149
5183
  cleanupManagedRecord(record) {
5184
+ if (record.recordId)
5185
+ this.launchControls.delete(record.recordId);
5186
+ if (record.recordId)
5187
+ this.clearLaunchCompletionTimer(record);
4150
5188
  if (record.source !== "baseplate")
4151
5189
  return;
4152
- cleanupManagedBaseplateFiles({ source: "baseplate", localPlaceFile: record.localPlaceFile });
5190
+ cleanupManagedBaseplateFiles({
5191
+ source: "baseplate",
5192
+ localPlaceFile: record.localPlaceFile
5193
+ });
4153
5194
  }
4154
5195
  markClosedInMemory(record) {
4155
5196
  record.closedAt = record.closedAt ?? Date.now();
4156
5197
  if (record.instanceId)
4157
5198
  this.managedByInstanceId.delete(record.instanceId);
4158
5199
  this.pending.delete(record);
4159
- this.stopMonitor(record);
5200
+ this.clearConnectionTimer(record);
5201
+ this.clearLaunchCompletionTimer(record);
5202
+ }
5203
+ armLaunchCompletionTimer(record) {
5204
+ if (!record.recordId)
5205
+ return;
5206
+ this.clearLaunchCompletionTimer(record);
5207
+ const timeout = setTimeout(() => {
5208
+ this.runInBackground("aborting an uncompleted Studio launch", this.markFailed(record, "Carbon did not complete Studio launch ownership transfer before timeout."));
5209
+ }, this.launchCompletionTimeoutMs);
5210
+ if (typeof timeout === "object" && "unref" in timeout)
5211
+ timeout.unref();
5212
+ this.launchCompletionTimers.set(record.recordId, timeout);
5213
+ }
5214
+ clearLaunchCompletionTimer(record) {
5215
+ if (!record.recordId)
5216
+ return;
5217
+ const timer = this.launchCompletionTimers.get(record.recordId);
5218
+ clearTimeout(timer);
5219
+ this.launchCompletionTimers.delete(record.recordId);
4160
5220
  }
4161
- markProcessExited(record, exitCode, reason) {
5221
+ async markProcessExited(record, exitCode, reason) {
4162
5222
  if (record.closedAt !== void 0)
4163
5223
  return record;
5224
+ const control = record.recordId ? this.launchControls.get(record.recordId) : void 0;
5225
+ let controlFailure;
5226
+ if (record.processAuthorizationState !== "released" && control) {
5227
+ try {
5228
+ await control.abort();
5229
+ } catch (error) {
5230
+ controlFailure = error instanceof Error ? error.message : String(error);
5231
+ }
5232
+ }
4164
5233
  const exitedAt = Date.now();
4165
5234
  record.exitedAt = exitedAt;
4166
5235
  record.closedAt = exitedAt;
4167
5236
  if (record.state !== "failed")
4168
5237
  record.state = "exited";
5238
+ record.processObservationStatus = "not_running";
5239
+ record.lastProcessObservationAt = exitedAt;
5240
+ record.lastSuccessfulProcessObservationAt = exitedAt;
5241
+ record.lastProcessObservationError = void 0;
4169
5242
  if (exitCode !== void 0)
4170
5243
  record.exitCode = exitCode;
4171
5244
  if (reason)
4172
5245
  record.failureReason = reason;
5246
+ if (controlFailure) {
5247
+ record.failureReason = [
5248
+ record.failureReason,
5249
+ `Launch ownership cleanup failed: ${controlFailure}`
5250
+ ].filter(Boolean).join(" ");
5251
+ }
4173
5252
  this.cleanupManagedRecord(record);
4174
5253
  this.markClosedInMemory(record);
4175
- this.persist(record);
5254
+ await this.persist(record);
4176
5255
  return record;
4177
5256
  }
4178
- startMonitor(record) {
4179
- if (!record.recordId || record.closedAt !== void 0 || this.monitors.has(record.recordId))
5257
+ startCoordinator(record) {
5258
+ if (!record.recordId || record.closedAt !== void 0)
4180
5259
  return;
4181
5260
  if (record.state === "launching" && record.connectionDeadlineAt !== void 0) {
4182
5261
  const timeout = setTimeout(() => {
4183
- this.markFailed(record, "Studio launched, but the MCP plugin did not connect before timeout.");
5262
+ this.runInBackground("persisting a Studio plugin connection timeout", this.markFailed(record, "Studio launched, but the MCP plugin did not connect before timeout."));
4184
5263
  }, Math.max(0, record.connectionDeadlineAt - Date.now()));
4185
5264
  if (typeof timeout === "object" && "unref" in timeout)
4186
5265
  timeout.unref();
4187
5266
  this.connectionTimers.set(record.recordId, timeout);
4188
5267
  }
4189
- const timer = setInterval(() => {
4190
- this.refresh(record);
4191
- if (record.closedAt !== void 0)
4192
- this.stopMonitor(record);
4193
- }, 5e3);
4194
- if (typeof timer === "object" && "unref" in timer)
4195
- timer.unref();
4196
- this.monitors.set(record.recordId, timer);
4197
- }
4198
- stopMonitor(record) {
4199
- if (!record.recordId)
5268
+ if (this.coordinatorTimer)
4200
5269
  return;
4201
- const timer = this.monitors.get(record.recordId);
4202
- if (timer)
4203
- clearInterval(timer);
4204
- this.monitors.delete(record.recordId);
4205
- this.clearConnectionTimer(record);
5270
+ this.coordinatorTimer = setInterval(() => {
5271
+ if (this.coordinatorRefresh)
5272
+ return;
5273
+ this.coordinatorRefresh = this.refreshOwnedRecords().catch((error) => {
5274
+ this.reportBackgroundFailure("refreshing managed Studio records", error);
5275
+ }).finally(() => {
5276
+ this.coordinatorRefresh = void 0;
5277
+ });
5278
+ }, 5e3);
5279
+ if (typeof this.coordinatorTimer === "object" && "unref" in this.coordinatorTimer) {
5280
+ this.coordinatorTimer.unref();
5281
+ }
4206
5282
  }
4207
5283
  clearConnectionTimer(record) {
4208
5284
  if (!record.recordId)
@@ -4212,8 +5288,80 @@ var init_studio_instance_manager = __esm({
4212
5288
  clearTimeout(timer);
4213
5289
  this.connectionTimers.delete(record.recordId);
4214
5290
  }
4215
- persist(record) {
4216
- this.registry.upsert(this.toRegistryRecord(record));
5291
+ async refreshOwnedRecords() {
5292
+ const snapshot = await this.getProcessSnapshot(true);
5293
+ await this.sweepRegistry(snapshot);
5294
+ for (const record of [...this.managedByInstanceId.values(), ...this.pending]) {
5295
+ await this.refresh(record, snapshot);
5296
+ }
5297
+ }
5298
+ runInBackground(context, operation) {
5299
+ void operation.catch((error) => this.reportBackgroundFailure(context, error));
5300
+ }
5301
+ reportBackgroundFailure(context, error) {
5302
+ console.warn(`[robloxstudio-mcp] failed while ${context}: ${error instanceof Error ? error.message : String(error)}`);
5303
+ }
5304
+ async applyProcessObservation(record, observation) {
5305
+ if (record.closedAt !== void 0)
5306
+ return;
5307
+ const previousObservationAt = record.lastProcessObservationAt;
5308
+ record.lastProcessObservationAt = observation.observedAt;
5309
+ if (observation.status === "unknown") {
5310
+ record.processObservationStatus = "unknown";
5311
+ record.lastProcessObservationError = observation.error;
5312
+ record.consecutiveConfirmedMisses = 0;
5313
+ record.firstConfirmedMissAt = void 0;
5314
+ await this.persist(record);
5315
+ return;
5316
+ }
5317
+ record.lastSuccessfulProcessObservationAt = observation.observedAt;
5318
+ record.lastProcessObservationError = void 0;
5319
+ if (observation.status === "running") {
5320
+ record.processObservationStatus = "running";
5321
+ record.consecutiveConfirmedMisses = 0;
5322
+ record.firstConfirmedMissAt = void 0;
5323
+ await this.persist(record);
5324
+ return;
5325
+ }
5326
+ record.processObservationStatus = "not_running";
5327
+ if (previousObservationAt !== observation.observedAt) {
5328
+ record.consecutiveConfirmedMisses = (record.consecutiveConfirmedMisses ?? 0) + 1;
5329
+ record.firstConfirmedMissAt ??= observation.observedAt;
5330
+ }
5331
+ const confirmedAbsent = observation.reason === "identity_mismatch" || (record.consecutiveConfirmedMisses ?? 0) >= this.confirmedExitMisses && observation.observedAt - (record.firstConfirmedMissAt ?? observation.observedAt) >= this.confirmedExitGraceMs;
5332
+ if (!confirmedAbsent) {
5333
+ await this.persist(record);
5334
+ return;
5335
+ }
5336
+ await this.markProcessExited(record, void 0, observation.reason === "identity_mismatch" ? "Studio process identity changed; the retained PID was not reused." : record.instanceId ? "Studio process exited." : "Studio process exited before the MCP plugin connected.");
5337
+ }
5338
+ async reconcileFromPositiveEvidence(record, snapshot) {
5339
+ if (record.closedAt === void 0)
5340
+ return;
5341
+ if (record.failureReason !== "Studio process exited." && record.failureReason !== "Studio process exited before the MCP plugin connected.")
5342
+ return;
5343
+ if (snapshot.status !== "ok")
5344
+ return;
5345
+ const processId = record.nativeProcessId ?? record.spawnPid;
5346
+ const studioProcess = processId ? snapshot.processes.find((candidate) => candidate.Id === processId) : void 0;
5347
+ if (!studioProcess || !this.verifyProcessForRecord(record, studioProcess))
5348
+ return;
5349
+ if (record.exitCode !== void 0)
5350
+ return;
5351
+ record.closedAt = void 0;
5352
+ record.exitedAt = void 0;
5353
+ record.failureReason = void 0;
5354
+ record.state = record.instanceId ? "connected" : "launching";
5355
+ record.processObservationStatus = "running";
5356
+ record.lastProcessObservationAt = snapshot.observedAt;
5357
+ record.lastSuccessfulProcessObservationAt = snapshot.observedAt;
5358
+ record.lastProcessObservationError = void 0;
5359
+ record.consecutiveConfirmedMisses = 0;
5360
+ record.firstConfirmedMissAt = void 0;
5361
+ await this.persist(record);
5362
+ }
5363
+ async persist(record) {
5364
+ await this.registry.upsert(this.toRegistryRecord(record));
4217
5365
  }
4218
5366
  toRegistryRecord(record) {
4219
5367
  if (!record.recordId)
@@ -4245,7 +5393,14 @@ var init_studio_instance_manager = __esm({
4245
5393
  failureReason: record.failureReason,
4246
5394
  closedAt: record.closedAt,
4247
5395
  ownerPid: record.ownerPid,
4248
- bootId: record.bootId
5396
+ bootId: record.bootId,
5397
+ processObservationStatus: record.processObservationStatus,
5398
+ processAuthorizationState: record.processAuthorizationState,
5399
+ lastProcessObservationAt: record.lastProcessObservationAt,
5400
+ lastSuccessfulProcessObservationAt: record.lastSuccessfulProcessObservationAt,
5401
+ lastProcessObservationError: record.lastProcessObservationError,
5402
+ consecutiveConfirmedMisses: record.consecutiveConfirmedMisses,
5403
+ firstConfirmedMissAt: record.firstConfirmedMissAt
4249
5404
  };
4250
5405
  }
4251
5406
  fromRegistryRecord(record) {
@@ -4264,7 +5419,7 @@ var init_studio_instance_manager = __esm({
4264
5419
  placeVersion: record.placeVersion,
4265
5420
  localPlaceFile: record.localPlaceFile,
4266
5421
  launchedAt: record.launchedAt,
4267
- connectionDeadlineAt: record.connectionDeadlineAt ?? (state === "launching" ? record.launchedAt + 12e4 : void 0),
5422
+ connectionDeadlineAt: record.connectionDeadlineAt ?? (state === "launching" && record.processAuthorizationState === void 0 ? record.launchedAt + 12e4 : void 0),
4268
5423
  state,
4269
5424
  connectedAt: record.attachedAt,
4270
5425
  failedAt: record.failedAt,
@@ -4274,7 +5429,14 @@ var init_studio_instance_manager = __esm({
4274
5429
  closedAt: record.closedAt,
4275
5430
  ownerPid: record.ownerPid,
4276
5431
  bootId: record.bootId,
4277
- deleteLocalPlaceFileOnClose: record.deleteLocalPlaceFileOnClose
5432
+ deleteLocalPlaceFileOnClose: record.deleteLocalPlaceFileOnClose,
5433
+ processObservationStatus: record.processObservationStatus,
5434
+ processAuthorizationState: record.processAuthorizationState ?? "released",
5435
+ lastProcessObservationAt: record.lastProcessObservationAt,
5436
+ lastSuccessfulProcessObservationAt: record.lastSuccessfulProcessObservationAt,
5437
+ lastProcessObservationError: record.lastProcessObservationError,
5438
+ consecutiveConfirmedMisses: record.consecutiveConfirmedMisses,
5439
+ firstConfirmedMissAt: record.firstConfirmedMissAt
4278
5440
  };
4279
5441
  }
4280
5442
  };
@@ -5085,7 +6247,7 @@ var init_esm = __esm({
5085
6247
 
5086
6248
  // ../core/dist/studio-skills.js
5087
6249
  import { createHash as createHash2 } from "crypto";
5088
- import { existsSync as existsSync4, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
6250
+ import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
5089
6251
  import * as path4 from "path";
5090
6252
  function decompressLz4Block(input, outputLength) {
5091
6253
  const output = Buffer.allocUnsafe(outputLength);
@@ -5297,7 +6459,7 @@ function discoverNamedFile(root, fileName, depth = 0) {
5297
6459
  const matches = [];
5298
6460
  let entries;
5299
6461
  try {
5300
- entries = readdirSync3(root, { withFileTypes: true });
6462
+ entries = readdirSync2(root, { withFileTypes: true });
5301
6463
  } catch {
5302
6464
  return matches;
5303
6465
  }
@@ -5311,7 +6473,7 @@ function discoverNamedFile(root, fileName, depth = 0) {
5311
6473
  }
5312
6474
  return matches;
5313
6475
  }
5314
- function resolveAssistantBundlePath(studioExe = resolveStudioExe()) {
6476
+ function resolveAssistantBundlePath(studioExe) {
5315
6477
  const override = process.env.ROBLOX_STUDIO_ASSISTANT_BUNDLE;
5316
6478
  if (override) {
5317
6479
  if (!existsSync4(override)) {
@@ -5319,7 +6481,7 @@ function resolveAssistantBundlePath(studioExe = resolveStudioExe()) {
5319
6481
  }
5320
6482
  return override;
5321
6483
  }
5322
- const exeDirectory = path4.dirname(studioExe);
6484
+ const exeDirectory = path4.dirname(studioExe ?? resolveStudioExe());
5323
6485
  const roots = [
5324
6486
  path4.join(exeDirectory, "BuiltInStandalonePlugins"),
5325
6487
  path4.resolve(exeDirectory, "..", "Resources", "BuiltInStandalonePlugins"),
@@ -5333,18 +6495,18 @@ function resolveAssistantBundlePath(studioExe = resolveStudioExe()) {
5333
6495
  for (const candidate of discoverNamedFile(root, "Assistant.rbxm"))
5334
6496
  candidates.add(candidate);
5335
6497
  }
5336
- const newest = [...candidates].map((candidate) => ({ candidate, modifiedAt: statSync3(candidate).mtimeMs })).sort((left, right) => right.modifiedAt - left.modifiedAt)[0]?.candidate;
6498
+ const newest = [...candidates].map((candidate) => ({ candidate, modifiedAt: statSync2(candidate).mtimeMs })).sort((left, right) => right.modifiedAt - left.modifiedAt)[0]?.candidate;
5337
6499
  if (!newest) {
5338
6500
  throw new Error(`Studio Assistant bundle not found for ${studioExe}. Set ROBLOX_STUDIO_ASSISTANT_BUNDLE to the installed Assistant.rbxm path.`);
5339
6501
  }
5340
6502
  return newest;
5341
6503
  }
5342
6504
  function loadBuiltInStudioSkills(bundlePath = resolveAssistantBundlePath()) {
5343
- const stats = statSync3(bundlePath);
6505
+ const stats = statSync2(bundlePath);
5344
6506
  if (cachedBundle?.path === bundlePath && cachedBundle.modifiedAt === stats.mtimeMs && cachedBundle.size === stats.size) {
5345
6507
  return cachedBundle.value;
5346
6508
  }
5347
- const buffer = readFileSync5(bundlePath);
6509
+ const buffer = readFileSync4(bundlePath);
5348
6510
  const skills = parseBuiltInStudioSkills(buffer);
5349
6511
  if (skills.length === 0) {
5350
6512
  throw new Error(`No built-in skill documents found in ${bundlePath}`);
@@ -7061,13 +8223,20 @@ var init_tools = __esm({
7061
8223
  openCloudClient;
7062
8224
  cookieClient;
7063
8225
  instanceManager;
8226
+ managedConnectionAssociations = Promise.resolve();
7064
8227
  constructor(bridge) {
7065
8228
  this.client = new StudioHttpClient(bridge);
7066
8229
  this.bridge = bridge;
7067
8230
  this.openCloudClient = new OpenCloudClient();
7068
8231
  this.cookieClient = new RobloxCookieClient();
7069
8232
  this.instanceManager = new StudioInstanceManager();
7070
- this.bridge.onInstanceRegistered((instance) => this._associateManagedEditConnection(instance));
8233
+ this.bridge.onInstanceRegistered((instance) => {
8234
+ const instanceManager = this.instanceManager;
8235
+ const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(instance, instanceManager));
8236
+ this.managedConnectionAssociations = association.catch((error) => {
8237
+ console.warn(`[robloxstudio-mcp] managed Studio connection association failed: ${error instanceof Error ? error.message : String(error)}`);
8238
+ });
8239
+ });
7071
8240
  }
7072
8241
  _textResult(body) {
7073
8242
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
@@ -7384,7 +8553,8 @@ var init_tools = __esm({
7384
8553
  if (typeof state !== "object" || state === null || Array.isArray(state)) {
7385
8554
  return state;
7386
8555
  }
7387
- const { devices: _devices, ...rest } = state;
8556
+ const rest = { ...state };
8557
+ delete rest.devices;
7388
8558
  return rest;
7389
8559
  }
7390
8560
  _assertCanRestoreDeviceSimulatorState(state) {
@@ -8313,7 +9483,8 @@ ${code}`
8313
9483
  };
8314
9484
  entrySummaries.push(entrySummary);
8315
9485
  try {
8316
- const { label: _label, ...settings } = entry;
9486
+ const settings = { ...entry };
9487
+ delete settings.label;
8317
9488
  const applied = await this._executeDeviceSimulatorOperation(resolved.instanceId, resolved.role, "set", { settings });
8318
9489
  entrySummary.applied = applied;
8319
9490
  if (settleMs > 0)
@@ -8648,9 +9819,9 @@ ${code}`
8648
9819
  _publicInstanceKey(instance) {
8649
9820
  return `${instance.instanceId}:${instance.role}:${instance.connectedAt}`;
8650
9821
  }
8651
- _isLatestPublishedPlaceOpen(placeId) {
9822
+ async _isLatestPublishedPlaceOpen(placeId) {
8652
9823
  const publishedInstanceId2 = `place:${placeId}`;
8653
- return this.bridge.getPublicInstances().some((instance) => instance.placeId === placeId || instance.instanceId === publishedInstanceId2) || this.instanceManager.list().some((record) => record.closedAt === void 0 && record.source === "published_place" && record.placeId === placeId);
9824
+ return this.bridge.getPublicInstances().some((instance) => instance.placeId === placeId || instance.instanceId === publishedInstanceId2) || (await this.instanceManager.list()).some((record) => record.closedAt === void 0 && record.source === "published_place" && record.placeId === placeId);
8654
9825
  }
8655
9826
  _matchesManagedLaunch(record, instance) {
8656
9827
  if (record.source === "published_place") {
@@ -8662,12 +9833,12 @@ ${code}`
8662
9833
  }
8663
9834
  return true;
8664
9835
  }
8665
- _associateManagedEditConnection(instance) {
9836
+ async _associateManagedEditConnection(instance, instanceManager) {
8666
9837
  if (instance.role !== "edit")
8667
9838
  return;
8668
- const candidate = this.instanceManager.pendingLaunches().filter((record) => instance.connectedAt >= record.launchedAt - 1e3).filter((record) => this._matchesManagedLaunch(record, instance)).sort((a, b) => a.launchedAt - b.launchedAt)[0];
9839
+ const candidate = (await instanceManager.pendingLaunches()).filter((record) => instance.connectedAt >= record.launchedAt - 1e3).filter((record) => this._matchesManagedLaunch(record, instance)).sort((a, b) => a.launchedAt - b.launchedAt)[0];
8669
9840
  if (candidate)
8670
- this.instanceManager.attachInstanceId(candidate, instance.instanceId);
9841
+ await instanceManager.attachInstanceId(candidate, instance.instanceId);
8671
9842
  }
8672
9843
  async _deriveUniverseId(placeId) {
8673
9844
  const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`);
@@ -8684,7 +9855,7 @@ ${code}`
8684
9855
  async _waitForManagedEditConnection(record, beforeKeys, timeoutMs) {
8685
9856
  const deadline = Date.now() + timeoutMs;
8686
9857
  while (Date.now() < deadline) {
8687
- this.instanceManager.refresh(record);
9858
+ await this.instanceManager.refresh(record);
8688
9859
  if (record.state === "failed" || record.state === "exited" || record.closedAt !== void 0) {
8689
9860
  return void 0;
8690
9861
  }
@@ -8696,7 +9867,6 @@ ${code}`
8696
9867
  return void 0;
8697
9868
  }
8698
9869
  _managedStatus(record) {
8699
- this.instanceManager.refresh(record);
8700
9870
  const connected = record.instanceId ? this.bridge.getPublicInstances().filter((instance) => instance.instanceId === record.instanceId) : [];
8701
9871
  return {
8702
9872
  launch_id: record.recordId,
@@ -8704,7 +9874,15 @@ ${code}`
8704
9874
  managed: true,
8705
9875
  state: record.state,
8706
9876
  pid: record.nativeProcessId ?? record.spawnPid,
8707
- process_running: record.closedAt === void 0 && record.exitedAt === void 0,
9877
+ process_started_at_file_time: record.nativeProcessStartedAt,
9878
+ process_authorized: record.processAuthorizationState !== "pending",
9879
+ process_ownership_released: record.processAuthorizationState === "released",
9880
+ process_running: record.closedAt !== void 0 || record.exitedAt !== void 0 ? false : record.processObservationStatus === "running" ? true : record.processObservationStatus === "not_running" ? false : null,
9881
+ process_observation_status: record.processObservationStatus ?? "unknown",
9882
+ last_process_observation_at: record.lastProcessObservationAt ? new Date(record.lastProcessObservationAt).toISOString() : void 0,
9883
+ last_successful_process_observation_at: record.lastSuccessfulProcessObservationAt ? new Date(record.lastSuccessfulProcessObservationAt).toISOString() : void 0,
9884
+ last_process_observation_error: record.lastProcessObservationError,
9885
+ consecutive_confirmed_misses: record.consecutiveConfirmedMisses ?? 0,
8708
9886
  source: record.source,
8709
9887
  local_place_file: record.localPlaceFile,
8710
9888
  place_id: record.placeId,
@@ -8730,8 +9908,8 @@ ${code}`
8730
9908
  if (instance_id && launch_id) {
8731
9909
  throw new Error("manage_instance accepts only one of instance_id or launch_id.");
8732
9910
  }
8733
- if (action !== "launch" && action !== "close" && action !== "status" && action !== "list_place_versions") {
8734
- throw new Error("manage_instance requires action=launch|close|status|list_place_versions");
9911
+ if (action !== "launch" && action !== "authorize" && action !== "complete" && action !== "close" && action !== "status" && action !== "list_place_versions") {
9912
+ throw new Error("manage_instance requires action=launch|authorize|complete|close|status|list_place_versions");
8735
9913
  }
8736
9914
  if (action === "list_place_versions") {
8737
9915
  if (!this.openCloudClient.hasApiKey()) {
@@ -8756,15 +9934,30 @@ ${code}`
8756
9934
  body.next_page_token = response.nextPageToken;
8757
9935
  return this._textResult(body);
8758
9936
  }
9937
+ if (action === "close" || action === "status") {
9938
+ await this.managedConnectionAssociations;
9939
+ }
9940
+ if (action === "authorize") {
9941
+ if (!launch_id)
9942
+ throw new Error("manage_instance action=authorize requires launch_id.");
9943
+ const record2 = await this.instanceManager.authorizeByLaunchId(launch_id);
9944
+ return this._textResult(this._managedStatus(record2));
9945
+ }
9946
+ if (action === "complete") {
9947
+ if (!launch_id)
9948
+ throw new Error("manage_instance action=complete requires launch_id.");
9949
+ const record2 = await this.instanceManager.completeByLaunchId(launch_id);
9950
+ return this._textResult(this._managedStatus(record2));
9951
+ }
8759
9952
  if (action === "status") {
8760
9953
  if (launch_id) {
8761
- const record2 = this.instanceManager.getByLaunchId(launch_id);
9954
+ const record2 = await this.instanceManager.getByLaunchId(launch_id);
8762
9955
  if (!record2)
8763
9956
  return this._textResult({ error: "Launch is not managed.", launch_id });
8764
9957
  return this._textResult(this._managedStatus(record2));
8765
9958
  }
8766
9959
  if (instance_id) {
8767
- const record2 = this.instanceManager.get(instance_id);
9960
+ const record2 = await this.instanceManager.get(instance_id);
8768
9961
  const connected2 = this.bridge.getPublicInstances().filter((instance) => instance.instanceId === instance_id);
8769
9962
  if (!record2 && connected2.length === 0) {
8770
9963
  return this._textResult({ error: "Instance is not connected or managed.", instance_id });
@@ -8781,7 +9974,7 @@ ${code}`
8781
9974
  });
8782
9975
  }
8783
9976
  return this._textResult({
8784
- managed: this.instanceManager.list().filter((record2) => record2.closedAt === void 0).map((record2) => this._managedStatus(record2)),
9977
+ managed: (await this.instanceManager.list()).filter((record2) => record2.closedAt === void 0).map((record2) => this._managedStatus(record2)),
8785
9978
  connected: this.bridge.getPublicInstances().map((instance) => ({
8786
9979
  instance_id: instance.instanceId,
8787
9980
  role: instance.role,
@@ -8793,11 +9986,11 @@ ${code}`
8793
9986
  if (action === "close") {
8794
9987
  let record2;
8795
9988
  if (launch_id) {
8796
- record2 = this.instanceManager.getByLaunchId(launch_id);
9989
+ record2 = await this.instanceManager.getByLaunchId(launch_id);
8797
9990
  if (!record2)
8798
9991
  return this._textResult({ error: "Launch is not managed.", launch_id });
8799
9992
  const connectedInstanceId = record2.instanceId;
8800
- const closeResult2 = record2.closedAt === void 0 ? this.instanceManager.close(record2) : { status: "already_closed" };
9993
+ const closeResult2 = record2.closedAt === void 0 ? await this.instanceManager.close(record2) : { status: "already_closed" };
8801
9994
  if (connectedInstanceId) {
8802
9995
  await this.bridge.unregisterInstanceIdEverywhere(connectedInstanceId);
8803
9996
  await sleep(500);
@@ -8810,13 +10003,13 @@ ${code}`
8810
10003
  });
8811
10004
  }
8812
10005
  if (instance_id) {
8813
- const recordBeforeClose = this.instanceManager.get(instance_id);
8814
- const managedClose = this.instanceManager.closeByInstanceId(instance_id);
10006
+ const recordBeforeClose = await this.instanceManager.get(instance_id);
10007
+ const managedClose = await this.instanceManager.closeByInstanceId(instance_id);
8815
10008
  if (managedClose.status !== "not_found") {
8816
10009
  await this.bridge.unregisterInstanceIdEverywhere(instance_id);
8817
10010
  await sleep(500);
8818
10011
  await this.bridge.unregisterInstanceIdEverywhere(instance_id);
8819
- const closedRecord = recordBeforeClose ?? (managedClose.launchId ? this.instanceManager.getByLaunchId(managedClose.launchId) : void 0);
10012
+ const closedRecord = managedClose.launchId ? await this.instanceManager.getByLaunchId(managedClose.launchId) : recordBeforeClose;
8820
10013
  return this._textResult({
8821
10014
  ...closedRecord ? this._managedStatus(closedRecord) : { instance_id },
8822
10015
  close_status: managedClose.status,
@@ -8832,7 +10025,7 @@ ${code}`
8832
10025
  });
8833
10026
  }
8834
10027
  try {
8835
- this.instanceManager.closeConnectedInstance(edit);
10028
+ await this.instanceManager.closeConnectedInstance(edit);
8836
10029
  await sleep(500);
8837
10030
  } catch (error) {
8838
10031
  return this._textResult({
@@ -8847,7 +10040,7 @@ ${code}`
8847
10040
  message: "Studio instance closed."
8848
10041
  });
8849
10042
  } else {
8850
- const active = this.instanceManager.list().filter((entry) => entry.closedAt === void 0);
10043
+ const active = (await this.instanceManager.list()).filter((entry) => entry.closedAt === void 0);
8851
10044
  if (active.length === 0) {
8852
10045
  return this._textResult({ message: "No managed Studio instances are active." });
8853
10046
  }
@@ -8861,7 +10054,7 @@ ${code}`
8861
10054
  }
8862
10055
  if (record2.instanceId)
8863
10056
  await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
8864
- const closeResult = this.instanceManager.close(record2);
10057
+ const closeResult = await this.instanceManager.close(record2);
8865
10058
  if (record2.instanceId) {
8866
10059
  await sleep(500);
8867
10060
  await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
@@ -8888,14 +10081,18 @@ ${code}`
8888
10081
  studioExecutable = request.studio_executable;
8889
10082
  }
8890
10083
  const processEnvironment = parseStudioProcessEnvironmentPatch(request.process_environment);
8891
- if (launchSource === "published_place" && placeId !== void 0 && this._isLatestPublishedPlaceOpen(placeId)) {
10084
+ if (launchSource === "published_place" && placeId !== void 0 && await this._isLatestPublishedPlaceOpen(placeId)) {
8892
10085
  return this._textResult({
8893
10086
  error: "Place is already open.",
8894
10087
  message: `place_id ${placeId} is already connected. Use the existing instance or launch a specific place_revision.`
8895
10088
  });
8896
10089
  }
8897
10090
  const universeId = launchSource === "published_place" || launchSource === "place_revision" ? await this._deriveUniverseId(placeId) : void 0;
8898
- const waitForConnection = request.wait_for_connection !== false;
10091
+ if (request.require_process_identity !== void 0 && typeof request.require_process_identity !== "boolean") {
10092
+ throw new Error("require_process_identity must be a boolean when provided.");
10093
+ }
10094
+ const requireProcessIdentity = request.require_process_identity === true;
10095
+ const waitForConnection = !requireProcessIdentity && request.wait_for_connection !== false;
8899
10096
  const timeoutMs = this._optionalPositiveInteger(request.timeout_ms, "timeout_ms") ?? 12e4;
8900
10097
  const beforeKeys = new Set(this.bridge.getPublicInstances().map((instance) => this._publicInstanceKey(instance)));
8901
10098
  const record = await this.instanceManager.launch({
@@ -8906,7 +10103,8 @@ ${code}`
8906
10103
  placeVersion,
8907
10104
  connectionTimeoutMs: timeoutMs,
8908
10105
  studioExecutable,
8909
- processEnvironment
10106
+ processEnvironment,
10107
+ ...requireProcessIdentity ? { requireProcessIdentity: true } : {}
8910
10108
  });
8911
10109
  if (!waitForConnection) {
8912
10110
  return this._textResult({
@@ -8917,11 +10115,11 @@ ${code}`
8917
10115
  const connected = await this._waitForManagedEditConnection(record, beforeKeys, timeoutMs);
8918
10116
  if (!connected) {
8919
10117
  if (record.state === "launching") {
8920
- this.instanceManager.markFailed(record, "Studio launched, but the MCP plugin did not connect before timeout.");
10118
+ await this.instanceManager.markFailed(record, "Studio launched, but the MCP plugin did not connect before timeout.");
8921
10119
  }
8922
10120
  if (record.closedAt === void 0) {
8923
10121
  try {
8924
- this.instanceManager.close(record);
10122
+ await this.instanceManager.close(record);
8925
10123
  } catch {
8926
10124
  }
8927
10125
  }
@@ -8930,7 +10128,7 @@ ${code}`
8930
10128
  error: record.failureReason ?? "Studio launched, but the MCP plugin did not connect before timeout."
8931
10129
  });
8932
10130
  }
8933
- this.instanceManager.attachInstanceId(record, connected.instanceId);
10131
+ await this.instanceManager.attachInstanceId(record, connected.instanceId);
8934
10132
  return this._textResult({
8935
10133
  ...this._managedStatus(record),
8936
10134
  message: launchSource === "place_revision" ? `Studio opened place revision ${placeVersion}.` : "Studio opened."
@@ -10203,11 +11401,15 @@ ${code}`
10203
11401
  }
10204
11402
  async uploadGenerateModelReferenceImage(imageContent, instance_id) {
10205
11403
  if (this.cookieClient.hasCookie()) {
10206
- const result2 = await this.cookieClient.uploadDecal(imageContent, STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL, STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL);
10207
- if (result2.backingAssetId && result2.backingAssetId > 0) {
10208
- return result2.backingAssetId;
10209
- }
10210
- return this.resolveUploadedReferenceImageId(String(result2.assetId), instance_id);
11404
+ const result2 = await this.cookieClient.uploadImage({
11405
+ fileContent: imageContent,
11406
+ fileName: "generate-model-reference.png",
11407
+ displayName: STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL,
11408
+ description: STUDIO_ASSISTANT_SOURCE_IMAGE_LABEL,
11409
+ userId: process.env.ROBLOX_CREATOR_USER_ID,
11410
+ groupId: process.env.ROBLOX_CREATOR_GROUP_ID
11411
+ });
11412
+ return result2.assetId;
10211
11413
  }
10212
11414
  if (!this.openCloudClient.hasApiKey()) {
10213
11415
  throw new Error("image_path and image_base64 require Roblox asset upload credentials because GenerateModelAsync only accepts rbxassetid:// or rbxasset:// image inputs. Set ROBLOX_OPEN_CLOUD_API_KEY plus ROBLOX_CREATOR_USER_ID or ROBLOX_CREATOR_GROUP_ID, or pass image_asset_id.");
@@ -10241,8 +11443,17 @@ ${code}`
10241
11443
  }
10242
11444
  const fileContent = fs3.readFileSync(filePath);
10243
11445
  const fileName = path5.basename(filePath);
11446
+ const resolvedGroupId = groupId || process.env.ROBLOX_CREATOR_GROUP_ID;
11447
+ const resolvedUserId = userId || process.env.ROBLOX_CREATOR_USER_ID;
10244
11448
  if (assetType === "Decal" && this.cookieClient.hasCookie()) {
10245
- const result2 = await this.cookieClient.uploadDecal(fileContent, displayName, description || "");
11449
+ const result2 = await this.cookieClient.uploadImage({
11450
+ fileContent,
11451
+ fileName,
11452
+ displayName,
11453
+ description: description || "",
11454
+ userId: resolvedUserId,
11455
+ groupId: resolvedGroupId
11456
+ });
10246
11457
  return {
10247
11458
  content: [{
10248
11459
  type: "text",
@@ -10251,9 +11462,9 @@ ${code}`
10251
11462
  response: {
10252
11463
  assetId: String(result2.assetId),
10253
11464
  displayName,
10254
- assetType,
10255
- decalId: String(result2.assetId),
10256
- imageId: String(result2.backingAssetId)
11465
+ assetType: "Image",
11466
+ decalId: null,
11467
+ imageId: String(result2.assetId)
10257
11468
  }
10258
11469
  })
10259
11470
  }]
@@ -10263,8 +11474,6 @@ ${code}`
10263
11474
  const cookieHint = assetType === "Decal" ? " Alternatively, set ROBLOSECURITY to use cookie auth." : "";
10264
11475
  throw new Error(`No auth configured for ${assetType} upload. Set ROBLOX_OPEN_CLOUD_API_KEY (needs asset:write scope).${cookieHint}`);
10265
11476
  }
10266
- const resolvedGroupId = groupId || process.env.ROBLOX_CREATOR_GROUP_ID;
10267
- const resolvedUserId = userId || process.env.ROBLOX_CREATOR_USER_ID;
10268
11477
  if (!resolvedUserId && !resolvedGroupId) {
10269
11478
  throw new Error("Creator identity required for Open Cloud upload. Set ROBLOX_CREATOR_USER_ID or ROBLOX_CREATOR_GROUP_ID, or pass userId/groupId as parameters.");
10270
11479
  }
@@ -11929,14 +13138,14 @@ var init_definitions = __esm({
11929
13138
  {
11930
13139
  name: "manage_instance",
11931
13140
  category: "write",
11932
- description: 'Launch, close, inspect, and find revisions for Studio instances. Every launch returns launch_id, native pid, source, and lifecycle state; status and close accept launch_id before the plugin connects and instance_id after association. Use action="launch" with source="baseplate" for a blank place, or source="local_file" with local_place_file for a local place; neither uses place_id. Use action="list_place_versions" with place_id to retrieve version numbers through Open Cloud asset versions, then action="launch" with source="place_revision", place_id, and place_version to open an older revision. action="launch" source="published_place" opens the latest published place and is blocked if that place_id is already connected; source="place_revision" is allowed because Studio opens explicit past revisions as anonymous local copies. Requires ROBLOX_OPEN_CLOUD_API_KEY with asset:read for list_place_versions.',
13141
+ description: 'Launch, authorize, complete, close, inspect, and find revisions for Studio instances. Every launch returns launch_id, native pid, source, and lifecycle state; status and close accept launch_id before the plugin connects and instance_id after association. Use action="launch" with source="baseplate" for a blank place, or source="local_file" with local_place_file for a local place; neither uses place_id. A process-identity launch requires action="authorize" after injection is prepared, followed by action="complete" only after the injected runtime is independently attested. Use action="list_place_versions" with place_id to retrieve version numbers through Open Cloud asset versions, then action="launch" with source="place_revision", place_id, and place_version to open an older revision. action="launch" source="published_place" opens the latest published place and is blocked if that place_id is already connected; source="place_revision" is allowed because Studio opens explicit past revisions as anonymous local copies. Requires ROBLOX_OPEN_CLOUD_API_KEY with asset:read for list_place_versions.',
11933
13142
  inputSchema: {
11934
13143
  type: "object",
11935
13144
  properties: {
11936
13145
  action: {
11937
13146
  type: "string",
11938
- enum: ["launch", "close", "status", "list_place_versions"],
11939
- description: "Instance management action."
13147
+ enum: ["launch", "authorize", "complete", "close", "status", "list_place_versions"],
13148
+ description: "Instance management action. authorize resumes a protocol-v3 launch after the caller has prepared process-scoped injection. complete releases broker process ownership after the caller independently attests that injection finished."
11940
13149
  },
11941
13150
  source: {
11942
13151
  type: "string",
@@ -11955,13 +13164,17 @@ var init_definitions = __esm({
11955
13164
  type: "number",
11956
13165
  description: 'Required for source="place_revision". Use action="list_place_versions" to discover available version numbers.'
11957
13166
  },
13167
+ require_process_identity: {
13168
+ type: "boolean",
13169
+ description: 'For action="launch": require an exact native PID and process creation time, return launch_id immediately, and retain broker ownership of the native process until action="complete" succeeds. The process remains suspended until action="authorize" begins injection. If identity capture, authorization, or ownership completion fails, the broker stops the launched process.'
13170
+ },
11958
13171
  wait_for_connection: {
11959
13172
  type: "boolean",
11960
- description: 'For action="launch": wait until the MCP plugin connects and return instance_id (default true). false returns launch_id immediately and continues association/failure tracking asynchronously.'
13173
+ description: 'For action="launch": wait until the MCP plugin connects and return instance_id (default true). false returns launch_id immediately and continues association/failure tracking asynchronously. Ignored when require_process_identity=true, which always returns the suspended launch immediately.'
11961
13174
  },
11962
13175
  timeout_ms: {
11963
13176
  type: "number",
11964
- description: 'For action="launch": max milliseconds for plugin connection (default 120000). The deadline also applies asynchronously when wait_for_connection=false.'
13177
+ description: 'For action="launch": max milliseconds for plugin connection (default 120000). The deadline also applies asynchronously when wait_for_connection=false. It does not apply when require_process_identity=true; that protocol uses the broker ownership-completion lease through action="complete".'
11965
13178
  },
11966
13179
  studio_executable: {
11967
13180
  type: "string",
@@ -13182,7 +14395,7 @@ part(0,2,0,2,1,1,"b")`,
13182
14395
  {
13183
14396
  name: "upload_asset",
13184
14397
  category: "write",
13185
- description: "Upload any supported asset type to Roblox: Audio (mp3/ogg/wav/flac), Decal (png/jpg/bmp/tga), Model (fbx/gltf/glb/rbxm/rbxmx), Animation (rbxm/rbxmx), or Video (mp4/mov). Decal supports ROBLOSECURITY cookie auth or ROBLOX_OPEN_CLOUD_API_KEY. All other types require Open Cloud API key with asset:write scope + creator ID. Audio: max 7 min, 100 uploads/month (ID-verified). Video: max 5 min, requires 13+ ID-verified.",
14398
+ description: "Upload any supported asset type to Roblox: Audio (mp3/ogg/wav/flac), Decal (png/jpg/bmp/tga), Model (fbx/gltf/glb/rbxm/rbxmx), Animation (rbxm/rbxmx), or Video (mp4/mov). Decal supports ROBLOSECURITY cookie auth through the Asset Manager user-auth API and returns the direct Image asset ID, or ROBLOX_OPEN_CLOUD_API_KEY. All other types require Open Cloud API key with asset:write scope + creator ID. Audio: max 7 min, 100 uploads/month (ID-verified). Video: max 5 min, requires 13+ ID-verified.",
13186
14399
  inputSchema: {
13187
14400
  type: "object",
13188
14401
  properties: {
@@ -13788,7 +15001,7 @@ part(0,2,0,2,1,1,"b")`,
13788
15001
  });
13789
15002
 
13790
15003
  // ../core/dist/install-plugin-helpers.js
13791
- import { existsSync as existsSync6, readFileSync as readFileSync7, unlinkSync } from "fs";
15004
+ import { existsSync as existsSync6, readFileSync as readFileSync6, unlinkSync } from "fs";
13792
15005
  import { execSync } from "child_process";
13793
15006
  import { join as join6 } from "path";
13794
15007
  import { homedir as homedir5 } from "os";
@@ -13796,7 +15009,7 @@ function isWSL() {
13796
15009
  if (process.platform !== "linux")
13797
15010
  return false;
13798
15011
  try {
13799
- const v = readFileSync7("/proc/version", "utf8");
15012
+ const v = readFileSync6("/proc/version", "utf8");
13800
15013
  return /microsoft|wsl/i.test(v);
13801
15014
  } catch {
13802
15015
  return false;
@@ -13881,7 +15094,7 @@ __export(install_plugin_exports, {
13881
15094
  installBundledPlugin: () => installBundledPlugin,
13882
15095
  installPlugin: () => installPlugin
13883
15096
  });
13884
- import { copyFileSync as copyFileSync2, createWriteStream, existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync8, unlinkSync as unlinkSync2 } from "fs";
15097
+ import { copyFileSync as copyFileSync2, createWriteStream, existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync7, unlinkSync as unlinkSync2 } from "fs";
13885
15098
  import { dirname as dirname5, join as join7 } from "path";
13886
15099
  import { fileURLToPath } from "url";
13887
15100
  import { get } from "https";
@@ -13943,7 +15156,7 @@ function prepareInstall({
13943
15156
  }) {
13944
15157
  const pluginsFolder = getPluginsFolder();
13945
15158
  if (!existsSync7(pluginsFolder)) {
13946
- mkdirSync5(pluginsFolder, { recursive: true });
15159
+ mkdirSync4(pluginsFolder, { recursive: true });
13947
15160
  }
13948
15161
  handleVariantConflict({
13949
15162
  pluginsFolder,
@@ -13964,14 +15177,14 @@ function bundledAssetPath() {
13964
15177
  }
13965
15178
  function packageVersion() {
13966
15179
  const currentDir = dirname5(fileURLToPath(import.meta.url));
13967
- const pkg = JSON.parse(readFileSync8(join7(currentDir, "..", "package.json"), "utf8"));
15180
+ const pkg = JSON.parse(readFileSync7(join7(currentDir, "..", "package.json"), "utf8"));
13968
15181
  if (!pkg.version) {
13969
15182
  throw new Error("Package version not found");
13970
15183
  }
13971
15184
  return pkg.version;
13972
15185
  }
13973
15186
  function bundledPluginVersion(source) {
13974
- const match = readFileSync8(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
15187
+ const match = readFileSync7(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
13975
15188
  return match ? match[1] : null;
13976
15189
  }
13977
15190
  function assertBundledPluginVersion(source) {
@@ -13985,8 +15198,8 @@ function assertBundledPluginVersion(source) {
13985
15198
  }
13986
15199
  function filesMatch(a, b) {
13987
15200
  if (!existsSync7(b)) return false;
13988
- const aBytes = readFileSync8(a);
13989
- const bBytes = readFileSync8(b);
15201
+ const aBytes = readFileSync7(a);
15202
+ const bBytes = readFileSync7(b);
13990
15203
  return aBytes.length === bBytes.length && aBytes.equals(bBytes);
13991
15204
  }
13992
15205
  async function installBundledPlugin(options = {}) {