@chrrxs/robloxstudio-mcp-inspector 2.22.2 → 2.22.4

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
@@ -889,6 +889,7 @@ function requiredClosedLineRange(body, toolName) {
889
889
  }
890
890
  function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
891
891
  const app = express();
892
+ const studioLifecycleCallable = !allowedTools || allowedTools.has("manage_instance");
892
893
  let mcpServerActive = false;
893
894
  let lastMCPActivity = 0;
894
895
  let mcpServerStartTime = 0;
@@ -968,8 +969,15 @@ function createHttpServer(tools, bridge, allowedTools, serverConfig, security) {
968
969
  res.json({
969
970
  status: "ok",
970
971
  service: "robloxstudio-mcp",
972
+ serverName: serverConfig?.name ?? "robloxstudio-mcp",
971
973
  version: serverConfig?.version,
972
974
  serverVersion: serverConfig?.version,
975
+ capabilities: studioLifecycleCallable ? {
976
+ studioLifecycle: {
977
+ protocolVersion: 1,
978
+ endpoint: "/mcp/manage_instance"
979
+ }
980
+ } : {},
973
981
  pluginConnected: instances.length > 0,
974
982
  instanceCount: instances.length,
975
983
  instances: publicInstances,
@@ -3065,7 +3073,8 @@ var init_roblox_cookie_client = __esm({
3065
3073
  });
3066
3074
 
3067
3075
  // ../core/dist/managed-instance-registry.js
3068
- import * as fs from "fs";
3076
+ import * as fs from "fs/promises";
3077
+ import { randomUUID } from "crypto";
3069
3078
  import * as os from "os";
3070
3079
  import * as path from "path";
3071
3080
  function defaultManagedInstanceRegistryDir() {
@@ -3080,8 +3089,8 @@ function defaultManagedInstanceRegistryDir() {
3080
3089
  }
3081
3090
  return path.join(os.homedir(), ".local", "state", "robloxstudio-mcp", "managed-instances", "v1");
3082
3091
  }
3083
- function sleepSync(ms) {
3084
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
3092
+ function delay(ms) {
3093
+ return new Promise((resolve5) => setTimeout(resolve5, ms));
3085
3094
  }
3086
3095
  function ymd(timestamp) {
3087
3096
  return new Date(timestamp).toISOString().slice(0, 10);
@@ -3096,92 +3105,131 @@ function isRecord(value) {
3096
3105
  const record = value;
3097
3106
  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";
3098
3107
  }
3099
- var REGISTRY_VERSION, LOCK_STALE_MS, LOCK_RETRY_MS, LOCK_TIMEOUT_MS, EVENT_RETENTION_DAYS, TERMINAL_RECORD_RETENTION_MS, ManagedInstanceRegistry;
3108
+ function isLockOwner(value) {
3109
+ if (!value || typeof value !== "object")
3110
+ return false;
3111
+ const owner = value;
3112
+ return Number.isInteger(owner.pid) && owner.pid > 0 && typeof owner.token === "string" && owner.token.length > 0 && typeof owner.createdAt === "number";
3113
+ }
3114
+ function isProcessAlive(pid) {
3115
+ if (pid === process.pid)
3116
+ return true;
3117
+ try {
3118
+ process.kill(pid, 0);
3119
+ return true;
3120
+ } catch (error) {
3121
+ return error.code === "EPERM";
3122
+ }
3123
+ }
3124
+ 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, ManagedInstanceRegistry;
3100
3125
  var init_managed_instance_registry = __esm({
3101
3126
  "../core/dist/managed-instance-registry.js"() {
3102
3127
  "use strict";
3103
3128
  REGISTRY_VERSION = 1;
3104
3129
  LOCK_STALE_MS = 1e4;
3105
3130
  LOCK_RETRY_MS = 25;
3106
- LOCK_TIMEOUT_MS = 5e3;
3131
+ LOCK_TIMEOUT_MS = 15e3;
3107
3132
  EVENT_RETENTION_DAYS = 2;
3108
3133
  TERMINAL_RECORD_RETENTION_MS = 24 * 60 * 60 * 1e3;
3134
+ DEFAULT_CONFIRMED_EXIT_MISSES = 2;
3135
+ DEFAULT_CONFIRMED_EXIT_GRACE_MS = 5e3;
3136
+ activeLockTokens = /* @__PURE__ */ new Set();
3109
3137
  ManagedInstanceRegistry = class {
3110
3138
  dir;
3111
3139
  constructor(dir = defaultManagedInstanceRegistryDir()) {
3112
3140
  this.dir = dir;
3113
3141
  }
3114
- upsert(record) {
3115
- this.withLock(() => this.writeRecordUnlocked(record));
3142
+ async upsert(record) {
3143
+ await this.withLock(() => this.writeRecordUnlocked(record));
3116
3144
  }
3117
- attachInstanceId(recordId, instanceId) {
3118
- this.withLock(() => {
3119
- const record = this.readRecordUnlocked(recordId);
3145
+ async attachInstanceId(recordId, instanceId) {
3146
+ await this.withLock(async () => {
3147
+ const record = await this.readRecordUnlocked(recordId);
3120
3148
  if (!record)
3121
3149
  return;
3122
3150
  record.instanceId = instanceId;
3123
3151
  record.attachedAt = Date.now();
3124
- this.writeRecordUnlocked(record);
3152
+ await this.writeRecordUnlocked(record);
3125
3153
  });
3126
3154
  }
3127
- findOpenByInstanceId(instanceId, options) {
3128
- return this.withLock(() => {
3129
- this.sweepUnlocked(options);
3130
- return this.readOpenRecordsUnlocked().find((record) => record.instanceId === instanceId);
3155
+ async findOpenByInstanceId(instanceId, options) {
3156
+ return this.withLock(async () => {
3157
+ await this.sweepUnlocked(options);
3158
+ return (await this.readOpenRecordsUnlocked()).find((record) => record.instanceId === instanceId);
3131
3159
  });
3132
3160
  }
3133
- findAnyByInstanceId(instanceId) {
3134
- return this.withLock(() => this.readRecordsUnlocked().find((record) => record.instanceId === instanceId));
3161
+ async findAnyByInstanceId(instanceId) {
3162
+ return this.withLock(async () => (await this.readRecordsUnlocked()).find((record) => record.instanceId === instanceId));
3135
3163
  }
3136
- findAnyByRecordId(recordId, options) {
3137
- return this.withLock(() => {
3138
- this.sweepUnlocked(options);
3139
- return this.readRecordsUnlocked().find((record) => record.recordId === recordId);
3164
+ async findAnyByRecordId(recordId, options) {
3165
+ return this.withLock(async () => {
3166
+ if (options)
3167
+ await this.sweepUnlocked(options);
3168
+ return (await this.readRecordsUnlocked()).find((record) => record.recordId === recordId);
3140
3169
  });
3141
3170
  }
3142
- listOpen(options) {
3143
- return this.withLock(() => {
3144
- this.sweepUnlocked(options);
3171
+ async listOpen(options) {
3172
+ return this.withLock(async () => {
3173
+ await this.sweepUnlocked(options);
3145
3174
  return this.readOpenRecordsUnlocked();
3146
3175
  });
3147
3176
  }
3148
- listOpenUnchecked() {
3177
+ async listOpenUnchecked() {
3149
3178
  return this.withLock(() => this.readOpenRecordsUnlocked());
3150
3179
  }
3151
- markClosed(recordId, closedAt = Date.now()) {
3152
- this.withLock(() => {
3153
- const record = this.readRecordUnlocked(recordId);
3180
+ async markClosed(recordId, closedAt = Date.now()) {
3181
+ await this.withLock(async () => {
3182
+ const record = await this.readRecordUnlocked(recordId);
3154
3183
  if (!record)
3155
3184
  return;
3156
3185
  record.closedAt = closedAt;
3157
- this.writeRecordUnlocked(record);
3186
+ await this.writeRecordUnlocked(record);
3158
3187
  });
3159
3188
  }
3160
- delete(recordId) {
3161
- this.withLock(() => this.deleteRecordUnlocked(recordId));
3189
+ async delete(recordId) {
3190
+ await this.withLock(() => this.deleteRecordUnlocked(recordId));
3162
3191
  }
3163
- sweep(options) {
3164
- this.withLock(() => this.sweepUnlocked(options));
3192
+ async sweep(options) {
3193
+ await this.withLock(() => this.sweepUnlocked(options));
3165
3194
  }
3166
- logEvent(event, now = Date.now()) {
3167
- this.withLock(() => this.appendEventUnlocked(event, now));
3195
+ async logEvent(event, now = Date.now()) {
3196
+ await this.withLock(() => this.appendEventUnlocked(event, now));
3168
3197
  }
3169
- withLock(fn) {
3170
- this.ensureDir();
3198
+ async withLock(fn) {
3199
+ await this.ensureDir();
3171
3200
  const lockDir = path.join(this.dir, ".lock");
3172
3201
  const deadline = Date.now() + LOCK_TIMEOUT_MS;
3173
- while (true) {
3202
+ const owner = {
3203
+ pid: process.pid,
3204
+ token: randomUUID(),
3205
+ createdAt: Date.now()
3206
+ };
3207
+ for (; ; ) {
3174
3208
  try {
3175
- fs.mkdirSync(lockDir);
3209
+ await fs.mkdir(lockDir);
3210
+ activeLockTokens.add(owner.token);
3211
+ try {
3212
+ await fs.writeFile(path.join(lockDir, "owner.json"), `${JSON.stringify(owner)}
3213
+ `, {
3214
+ encoding: "utf8",
3215
+ flag: "wx"
3216
+ });
3217
+ } catch (error) {
3218
+ activeLockTokens.delete(owner.token);
3219
+ await fs.rm(lockDir, { recursive: true, force: true });
3220
+ throw error;
3221
+ }
3176
3222
  break;
3177
3223
  } catch (error) {
3178
3224
  const code = error.code;
3179
3225
  if (code !== "EEXIST")
3180
3226
  throw error;
3181
3227
  try {
3182
- const stat = fs.statSync(lockDir);
3183
- if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
3184
- fs.rmSync(lockDir, { recursive: true, force: true });
3228
+ const stat2 = await fs.stat(lockDir);
3229
+ const currentOwner = await this.readLockOwner(lockDir, stat2.isDirectory());
3230
+ const ownerIsActive = currentOwner?.pid === process.pid ? activeLockTokens.has(currentOwner.token) : currentOwner ? isProcessAlive(currentOwner.pid) : void 0;
3231
+ if (ownerIsActive === false || currentOwner === void 0 && Date.now() - stat2.mtimeMs > LOCK_STALE_MS) {
3232
+ await fs.rm(lockDir, { recursive: true, force: true });
3185
3233
  continue;
3186
3234
  }
3187
3235
  } catch {
@@ -3190,40 +3238,58 @@ var init_managed_instance_registry = __esm({
3190
3238
  if (Date.now() > deadline) {
3191
3239
  throw new Error(`Timed out waiting for managed instance registry lock: ${lockDir}`);
3192
3240
  }
3193
- sleepSync(LOCK_RETRY_MS);
3241
+ await delay(LOCK_RETRY_MS);
3194
3242
  }
3195
3243
  }
3196
3244
  try {
3197
- return fn();
3245
+ return await fn();
3198
3246
  } finally {
3199
- fs.rmSync(lockDir, { recursive: true, force: true });
3247
+ try {
3248
+ const stat2 = await fs.stat(lockDir);
3249
+ const currentOwner = await this.readLockOwner(lockDir, stat2.isDirectory());
3250
+ if (currentOwner?.pid === owner.pid && currentOwner.token === owner.token) {
3251
+ await fs.rm(lockDir, { recursive: true, force: true });
3252
+ }
3253
+ } catch {
3254
+ } finally {
3255
+ activeLockTokens.delete(owner.token);
3256
+ }
3257
+ }
3258
+ }
3259
+ async readLockOwner(lockPath, isDirectory) {
3260
+ try {
3261
+ const contents = await fs.readFile(isDirectory ? path.join(lockPath, "owner.json") : lockPath, "utf8");
3262
+ const parsed = JSON.parse(contents);
3263
+ return isLockOwner(parsed) ? parsed : void 0;
3264
+ } catch {
3265
+ return void 0;
3200
3266
  }
3201
3267
  }
3202
- ensureDir() {
3203
- fs.mkdirSync(this.dir, { recursive: true });
3268
+ async ensureDir() {
3269
+ await fs.mkdir(this.dir, { recursive: true });
3204
3270
  }
3205
3271
  recordPath(recordId) {
3206
3272
  return path.join(this.dir, `${recordId}.json`);
3207
3273
  }
3208
- recordFilesUnlocked() {
3209
- return fs.readdirSync(this.dir).filter((name) => name.endsWith(".json")).map((name) => path.join(this.dir, name));
3274
+ async recordFilesUnlocked() {
3275
+ return (await fs.readdir(this.dir)).filter((name) => name.endsWith(".json")).map((name) => path.join(this.dir, name));
3210
3276
  }
3211
- readRecordUnlocked(recordId) {
3277
+ async readRecordUnlocked(recordId) {
3212
3278
  try {
3213
- const parsed = JSON.parse(fs.readFileSync(this.recordPath(recordId), "utf8"));
3279
+ const parsed = JSON.parse(await fs.readFile(this.recordPath(recordId), "utf8"));
3214
3280
  return isRecord(parsed) ? parsed : void 0;
3215
3281
  } catch {
3216
3282
  return void 0;
3217
3283
  }
3218
3284
  }
3219
- readOpenRecordsUnlocked() {
3220
- return this.readRecordsUnlocked().filter((record) => record.closedAt === void 0);
3285
+ async readOpenRecordsUnlocked() {
3286
+ return (await this.readRecordsUnlocked()).filter((record) => record.closedAt === void 0);
3221
3287
  }
3222
- readRecordsUnlocked() {
3288
+ async readRecordsUnlocked() {
3223
3289
  const records = [];
3224
- for (const file of this.recordFilesUnlocked()) {
3290
+ for (const file of await this.recordFilesUnlocked()) {
3225
3291
  try {
3226
- const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
3292
+ const parsed = JSON.parse(await fs.readFile(file, "utf8"));
3227
3293
  if (!isRecord(parsed))
3228
3294
  continue;
3229
3295
  records.push(parsed);
@@ -3232,48 +3298,48 @@ var init_managed_instance_registry = __esm({
3232
3298
  }
3233
3299
  return records;
3234
3300
  }
3235
- writeRecordUnlocked(record) {
3236
- this.ensureDir();
3301
+ async writeRecordUnlocked(record) {
3302
+ await this.ensureDir();
3237
3303
  const finalPath = this.recordPath(record.recordId);
3238
3304
  const tmpPath = path.join(this.dir, `${record.recordId}.${process.pid}.${Date.now()}.tmp`);
3239
- const fd = fs.openSync(tmpPath, "w");
3305
+ const fd = await fs.open(tmpPath, "w");
3240
3306
  try {
3241
- fs.writeFileSync(fd, `${JSON.stringify(record, null, 2)}
3307
+ await fd.writeFile(`${JSON.stringify(record, null, 2)}
3242
3308
  `, "utf8");
3243
- fs.fsyncSync(fd);
3309
+ await fd.sync();
3244
3310
  } finally {
3245
- fs.closeSync(fd);
3311
+ await fd.close();
3246
3312
  }
3247
- fs.renameSync(tmpPath, finalPath);
3313
+ await fs.rename(tmpPath, finalPath);
3248
3314
  }
3249
- deleteRecordUnlocked(recordId) {
3250
- fs.rmSync(this.recordPath(recordId), { force: true });
3315
+ async deleteRecordUnlocked(recordId) {
3316
+ await fs.rm(this.recordPath(recordId), { force: true });
3251
3317
  }
3252
- appendEventUnlocked(event, now) {
3318
+ async appendEventUnlocked(event, now) {
3253
3319
  const file = path.join(this.dir, `events-${ymd(now)}.jsonl`);
3254
- fs.appendFileSync(file, `${JSON.stringify({
3320
+ await fs.appendFile(file, `${JSON.stringify({
3255
3321
  ts: new Date(now).toISOString(),
3256
3322
  ...event
3257
3323
  })}
3258
3324
  `, "utf8");
3259
3325
  }
3260
- cleanupOldEventLogsUnlocked(now) {
3326
+ async cleanupOldEventLogsUnlocked(now) {
3261
3327
  const cutoff = ymd(now - EVENT_RETENTION_DAYS * 24 * 60 * 60 * 1e3);
3262
- for (const name of fs.readdirSync(this.dir)) {
3328
+ for (const name of await fs.readdir(this.dir)) {
3263
3329
  const date = eventLogDate(name);
3264
3330
  if (!date || date >= cutoff)
3265
3331
  continue;
3266
3332
  try {
3267
- fs.rmSync(path.join(this.dir, name), { force: true });
3333
+ await fs.rm(path.join(this.dir, name), { force: true });
3268
3334
  } catch {
3269
3335
  }
3270
3336
  }
3271
3337
  }
3272
- cleanupRecord(options, record) {
3338
+ async cleanupRecord(options, record) {
3273
3339
  try {
3274
- options.cleanupRecord?.(record);
3340
+ await options.cleanupRecord?.(record);
3275
3341
  } catch {
3276
- this.appendEventUnlocked({
3342
+ await this.appendEventUnlocked({
3277
3343
  event: "registry_cleanup_failed",
3278
3344
  recordId: record.recordId,
3279
3345
  instanceId: record.instanceId,
@@ -3282,16 +3348,16 @@ var init_managed_instance_registry = __esm({
3282
3348
  }, options.now ?? Date.now());
3283
3349
  }
3284
3350
  }
3285
- sweepUnlocked(options) {
3351
+ async sweepUnlocked(options) {
3286
3352
  const now = options.now ?? Date.now();
3287
- this.cleanupOldEventLogsUnlocked(now);
3288
- for (const file of this.recordFilesUnlocked()) {
3353
+ await this.cleanupOldEventLogsUnlocked(now);
3354
+ for (const file of await this.recordFilesUnlocked()) {
3289
3355
  let parsed;
3290
3356
  try {
3291
- parsed = JSON.parse(fs.readFileSync(file, "utf8"));
3357
+ parsed = JSON.parse(await fs.readFile(file, "utf8"));
3292
3358
  } catch {
3293
- fs.rmSync(file, { force: true });
3294
- this.appendEventUnlocked({
3359
+ await fs.rm(file, { force: true });
3360
+ await this.appendEventUnlocked({
3295
3361
  event: "registry_pruned_malformed_record",
3296
3362
  reason: "parse_error",
3297
3363
  action: "deleted_record"
@@ -3299,8 +3365,8 @@ var init_managed_instance_registry = __esm({
3299
3365
  continue;
3300
3366
  }
3301
3367
  if (!parsed || typeof parsed !== "object") {
3302
- fs.rmSync(file, { force: true });
3303
- this.appendEventUnlocked({
3368
+ await fs.rm(file, { force: true });
3369
+ await this.appendEventUnlocked({
3304
3370
  event: "registry_pruned_malformed_record",
3305
3371
  reason: "invalid_shape",
3306
3372
  action: "deleted_record"
@@ -3311,8 +3377,8 @@ var init_managed_instance_registry = __esm({
3311
3377
  if (typeof version === "number" && version > REGISTRY_VERSION)
3312
3378
  continue;
3313
3379
  if (!isRecord(parsed)) {
3314
- fs.rmSync(file, { force: true });
3315
- this.appendEventUnlocked({
3380
+ await fs.rm(file, { force: true });
3381
+ await this.appendEventUnlocked({
3316
3382
  event: "registry_pruned_malformed_record",
3317
3383
  reason: "invalid_shape",
3318
3384
  action: "deleted_record"
@@ -3322,8 +3388,8 @@ var init_managed_instance_registry = __esm({
3322
3388
  const terminalAt = parsed.closedAt ?? parsed.exitedAt;
3323
3389
  if (terminalAt !== void 0) {
3324
3390
  if (now - terminalAt > TERMINAL_RECORD_RETENTION_MS) {
3325
- fs.rmSync(file, { force: true });
3326
- this.appendEventUnlocked({
3391
+ await fs.rm(file, { force: true });
3392
+ await this.appendEventUnlocked({
3327
3393
  event: "registry_pruned_terminal_record",
3328
3394
  recordId: parsed.recordId,
3329
3395
  instanceId: parsed.instanceId,
@@ -3335,13 +3401,13 @@ var init_managed_instance_registry = __esm({
3335
3401
  continue;
3336
3402
  }
3337
3403
  if (parsed.bootId !== options.currentBootId) {
3338
- this.cleanupRecord(options, parsed);
3404
+ await this.cleanupRecord(options, parsed);
3339
3405
  parsed.state = parsed.state === "failed" ? "failed" : "exited";
3340
3406
  parsed.exitedAt = now;
3341
3407
  parsed.closedAt = now;
3342
3408
  parsed.failureReason ??= "Studio process belongs to a previous host boot.";
3343
- this.writeRecordUnlocked(parsed);
3344
- this.appendEventUnlocked({
3409
+ await this.writeRecordUnlocked(parsed);
3410
+ await this.appendEventUnlocked({
3345
3411
  event: "registry_marked_previous_boot_exited",
3346
3412
  recordId: parsed.recordId,
3347
3413
  instanceId: parsed.instanceId,
@@ -3351,22 +3417,56 @@ var init_managed_instance_registry = __esm({
3351
3417
  }, now);
3352
3418
  continue;
3353
3419
  }
3354
- if (options.isProcessRunning && (parsed.nativeProcessId || parsed.spawnPid) && !options.isProcessRunning(parsed)) {
3355
- this.cleanupRecord(options, parsed);
3356
- parsed.state = parsed.state === "failed" ? "failed" : "exited";
3357
- parsed.exitedAt = now;
3358
- parsed.closedAt = now;
3359
- parsed.failureReason ??= parsed.instanceId ? "Studio process exited." : "Studio process exited before the MCP plugin connected.";
3360
- this.writeRecordUnlocked(parsed);
3361
- this.appendEventUnlocked({
3362
- event: "registry_marked_process_exited",
3363
- recordId: parsed.recordId,
3364
- instanceId: parsed.instanceId,
3365
- source: parsed.source,
3366
- reason: "pid_not_running",
3367
- action: "marked_exited_and_cleaned_baseplate"
3368
- }, now);
3420
+ if (!options.observeProcess || !(parsed.nativeProcessId || parsed.spawnPid))
3421
+ continue;
3422
+ const observation = await options.observeProcess(parsed);
3423
+ if (observation.status === "unknown") {
3424
+ parsed.processObservationStatus = "unknown";
3425
+ parsed.lastProcessObservationAt = observation.observedAt;
3426
+ parsed.lastProcessObservationError = observation.error;
3427
+ parsed.consecutiveConfirmedMisses = 0;
3428
+ parsed.firstConfirmedMissAt = void 0;
3429
+ await this.writeRecordUnlocked(parsed);
3430
+ continue;
3431
+ }
3432
+ const previousObservationAt = parsed.lastProcessObservationAt;
3433
+ parsed.lastSuccessfulProcessObservationAt = observation.observedAt;
3434
+ parsed.lastProcessObservationAt = observation.observedAt;
3435
+ parsed.lastProcessObservationError = void 0;
3436
+ if (observation.status === "running") {
3437
+ parsed.processObservationStatus = "running";
3438
+ parsed.consecutiveConfirmedMisses = 0;
3439
+ parsed.firstConfirmedMissAt = void 0;
3440
+ await this.writeRecordUnlocked(parsed);
3441
+ continue;
3369
3442
  }
3443
+ const isNewObservation = previousObservationAt !== observation.observedAt;
3444
+ parsed.processObservationStatus = "not_running";
3445
+ if (previousObservationAt !== observation.observedAt) {
3446
+ parsed.consecutiveConfirmedMisses = (parsed.consecutiveConfirmedMisses ?? 0) + 1;
3447
+ parsed.firstConfirmedMissAt ??= observation.observedAt;
3448
+ }
3449
+ const requiredMisses = options.confirmedExitMisses ?? DEFAULT_CONFIRMED_EXIT_MISSES;
3450
+ const graceMs = options.confirmedExitGraceMs ?? DEFAULT_CONFIRMED_EXIT_GRACE_MS;
3451
+ const confirmedAbsent = observation.reason === "identity_mismatch" || isNewObservation && (parsed.consecutiveConfirmedMisses ?? 0) >= requiredMisses && observation.observedAt - (parsed.firstConfirmedMissAt ?? observation.observedAt) >= graceMs;
3452
+ if (!confirmedAbsent) {
3453
+ await this.writeRecordUnlocked(parsed);
3454
+ continue;
3455
+ }
3456
+ await this.cleanupRecord(options, parsed);
3457
+ parsed.state = parsed.state === "failed" ? "failed" : "exited";
3458
+ parsed.exitedAt = observation.observedAt;
3459
+ parsed.closedAt = observation.observedAt;
3460
+ 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.";
3461
+ await this.writeRecordUnlocked(parsed);
3462
+ await this.appendEventUnlocked({
3463
+ event: "registry_marked_process_exited",
3464
+ recordId: parsed.recordId,
3465
+ instanceId: parsed.instanceId,
3466
+ source: parsed.source,
3467
+ reason: observation.reason === "identity_mismatch" ? "identity_mismatch" : "pid_not_running",
3468
+ action: "marked_exited_and_cleaned_baseplate"
3469
+ }, now);
3370
3470
  }
3371
3471
  }
3372
3472
  };
@@ -3374,11 +3474,12 @@ var init_managed_instance_registry = __esm({
3374
3474
  });
3375
3475
 
3376
3476
  // ../core/dist/studio-instance-manager.js
3377
- import { execFileSync, spawn } from "child_process";
3378
- import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, realpathSync, rmSync as rmSync2, statSync as statSync2 } from "fs";
3379
- import { randomUUID } from "crypto";
3477
+ import { execFile, execFileSync, spawn } from "child_process";
3478
+ import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync, readFileSync as readFileSync2, realpathSync, rmSync, statSync } from "fs";
3479
+ import { randomUUID as randomUUID2 } from "crypto";
3380
3480
  import * as os2 from "os";
3381
3481
  import * as path2 from "path";
3482
+ import { promisify } from "util";
3382
3483
  function run(command, args, options = {}) {
3383
3484
  return execFileSync(command, args, {
3384
3485
  encoding: "utf8",
@@ -3386,17 +3487,29 @@ function run(command, args, options = {}) {
3386
3487
  ...options
3387
3488
  }).trim();
3388
3489
  }
3490
+ async function runAsync(command, args, options = {}) {
3491
+ const result = await execFileAsync(command, args, {
3492
+ encoding: "utf8",
3493
+ maxBuffer: 4 * 1024 * 1024,
3494
+ timeout: 15e3,
3495
+ killSignal: "SIGKILL",
3496
+ ...options
3497
+ });
3498
+ return `${result.stdout}`.trim();
3499
+ }
3389
3500
  function isWsl() {
3390
3501
  if (process.platform !== "linux")
3391
3502
  return false;
3503
+ if (!process.env.WSL_INTEROP && !process.env.WSL_DISTRO_NAME)
3504
+ return false;
3392
3505
  try {
3393
- return /microsoft|wsl/i.test(readFileSync3("/proc/version", "utf8"));
3506
+ return /microsoft|wsl/i.test(readFileSync2("/proc/version", "utf8"));
3394
3507
  } catch {
3395
3508
  return false;
3396
3509
  }
3397
3510
  }
3398
- function powershell(script) {
3399
- return run("powershell.exe", ["-NoProfile", "-Command", script], {
3511
+ async function powershellAsync(script) {
3512
+ return runAsync("powershell.exe", ["-NoProfile", "-Command", script], {
3400
3513
  cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
3401
3514
  });
3402
3515
  }
@@ -3413,19 +3526,96 @@ function windowsLocalAppData() {
3413
3526
  return void 0;
3414
3527
  }
3415
3528
  }
3529
+ async function windowsLocalAppDataAsync() {
3530
+ if (process.platform === "win32")
3531
+ return process.env.LOCALAPPDATA;
3532
+ if (!isWsl())
3533
+ return void 0;
3534
+ try {
3535
+ return await runAsync("cmd.exe", ["/c", "echo %LOCALAPPDATA%"], {
3536
+ cwd: existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd()
3537
+ });
3538
+ } catch {
3539
+ return void 0;
3540
+ }
3541
+ }
3416
3542
  function toWslPath(windowsPath) {
3417
3543
  if (!isWsl())
3418
3544
  return windowsPath;
3419
3545
  return run("wslpath", ["-u", windowsPath]);
3420
3546
  }
3421
- function toStudioLaunchArg(arg) {
3547
+ async function toWslPathAsync(windowsPath) {
3548
+ if (!isWsl())
3549
+ return windowsPath;
3550
+ return runAsync("wslpath", ["-u", windowsPath]);
3551
+ }
3552
+ async function toStudioLaunchArgAsync(arg) {
3422
3553
  if (!isWsl() || !path2.isAbsolute(arg) || !existsSync2(arg))
3423
3554
  return arg;
3424
- return run("wslpath", ["-w", arg]);
3555
+ return runAsync("wslpath", ["-w", arg]);
3425
3556
  }
3426
3557
  function powershellStringLiteral(value) {
3427
3558
  return `'${value.replace(/'/g, "''")}'`;
3428
3559
  }
3560
+ function parseStudioProcessEnvironmentPatch(value) {
3561
+ if (value === void 0)
3562
+ return void 0;
3563
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
3564
+ throw new Error("process_environment must be an object when provided.");
3565
+ }
3566
+ const raw = value;
3567
+ const unsupported = Object.keys(raw).filter((key) => key !== "set" && key !== "remove");
3568
+ if (unsupported.length > 0) {
3569
+ throw new Error(`process_environment contains unsupported field(s): ${unsupported.join(", ")}.`);
3570
+ }
3571
+ let set;
3572
+ if (raw.set !== void 0) {
3573
+ if (raw.set === null || typeof raw.set !== "object" || Array.isArray(raw.set)) {
3574
+ throw new Error("process_environment.set must be an object mapping names to string values.");
3575
+ }
3576
+ set = {};
3577
+ for (const [name, setting] of Object.entries(raw.set)) {
3578
+ if (!ENVIRONMENT_VARIABLE_NAME.test(name)) {
3579
+ throw new Error(`Invalid process environment variable name "${name}".`);
3580
+ }
3581
+ if (typeof setting !== "string") {
3582
+ throw new Error(`process_environment.set.${name} must be a string.`);
3583
+ }
3584
+ if (setting.includes("\0")) {
3585
+ throw new Error(`process_environment.set.${name} must not contain a null character.`);
3586
+ }
3587
+ set[name] = setting;
3588
+ }
3589
+ }
3590
+ let remove;
3591
+ if (raw.remove !== void 0) {
3592
+ if (!Array.isArray(raw.remove) || raw.remove.some((name) => typeof name !== "string")) {
3593
+ throw new Error("process_environment.remove must be an array of environment variable names.");
3594
+ }
3595
+ remove = [...new Set(raw.remove)];
3596
+ for (const name of remove) {
3597
+ if (!ENVIRONMENT_VARIABLE_NAME.test(name)) {
3598
+ throw new Error(`Invalid process environment variable name "${name}".`);
3599
+ }
3600
+ }
3601
+ }
3602
+ return { set, remove };
3603
+ }
3604
+ function patchedProcessEnvironment(patch) {
3605
+ const environment = { ...process.env };
3606
+ for (const name of patch.remove ?? [])
3607
+ delete environment[name];
3608
+ for (const [name, value] of Object.entries(patch.set ?? {}))
3609
+ environment[name] = value;
3610
+ return environment;
3611
+ }
3612
+ function powershellEnvironmentPatchStatements(patch) {
3613
+ const target = "[EnvironmentVariableTarget]::Process";
3614
+ return [
3615
+ ...(patch.remove ?? []).map((name) => `[Environment]::SetEnvironmentVariable(${powershellStringLiteral(name)}, $null, ${target})`),
3616
+ ...Object.entries(patch.set ?? {}).map(([name, value]) => `[Environment]::SetEnvironmentVariable(${powershellStringLiteral(name)}, ${powershellStringLiteral(value)}, ${target})`)
3617
+ ];
3618
+ }
3429
3619
  function quoteWindowsCommandLineArg(value) {
3430
3620
  if (value.length > 0 && !/[\s"]/u.test(value))
3431
3621
  return value;
@@ -3446,16 +3636,17 @@ function quoteWindowsCommandLineArg(value) {
3446
3636
  }
3447
3637
  return `${quoted}${"\\".repeat(backslashes * 2)}"`;
3448
3638
  }
3449
- function buildWindowsStudioStartScript(exe, args) {
3450
- const windowsExe = toStudioLaunchArg(exe);
3639
+ function buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment) {
3640
+ const environmentPatch = parseStudioProcessEnvironmentPatch(processEnvironment);
3451
3641
  const commandLine = args.map(quoteWindowsCommandLineArg).join(" ");
3452
3642
  return [
3643
+ ...environmentPatch ? powershellEnvironmentPatchStatements(environmentPatch) : [],
3453
3644
  "$psi = New-Object System.Diagnostics.ProcessStartInfo",
3454
3645
  `$psi.FileName = ${powershellStringLiteral(windowsExe)}`,
3455
3646
  `$psi.Arguments = ${powershellStringLiteral(commandLine)}`,
3456
3647
  // With UseShellExecute=false, Studio inherits the synchronous
3457
3648
  // powershell.exe invocation's stdout/stderr pipe handles under WSL. Those
3458
- // handles keep execFileSync waiting until Studio exits even though
3649
+ // handles keep the PowerShell invocation waiting until Studio exits even though
3459
3650
  // PowerShell already printed the PID. Shell execution prevents that
3460
3651
  // inheritance while Process.Start still returns the native Studio PID.
3461
3652
  "$psi.UseShellExecute = $true",
@@ -3463,9 +3654,10 @@ function buildWindowsStudioStartScript(exe, args) {
3463
3654
  'if ($null -eq $studio) { throw "Roblox Studio process did not start." }'
3464
3655
  ].join("; ");
3465
3656
  }
3466
- function spawnWindowsStudioFromWsl(exe, args) {
3467
- const script = buildWindowsStudioStartScript(exe, args);
3468
- const output = powershell(`${script}; [PSCustomObject]@{ pid = $studio.Id; started = $studio.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } | ConvertTo-Json -Compress`);
3657
+ async function spawnWindowsStudioFromWsl(exe, args, processEnvironment) {
3658
+ const windowsExe = await toStudioLaunchArgAsync(exe);
3659
+ const script = buildWindowsStudioStartScriptFromConvertedExe(windowsExe, args, processEnvironment);
3660
+ const output = await powershellAsync(`${script}; [PSCustomObject]@{ pid = $studio.Id; started = $studio.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() } | ConvertTo-Json -Compress`);
3469
3661
  const parsed = JSON.parse(output);
3470
3662
  const nativePid = Number(parsed.pid);
3471
3663
  const nativeStartedAt = typeof parsed.started === "string" && /^\d+$/u.test(parsed.started) ? parsed.started : void 0;
@@ -3507,7 +3699,7 @@ function resolveBaseplateTemplatePath() {
3507
3699
  }
3508
3700
  throw new Error(`Baseplate template not found. Expected ${BASEPLATE_TEMPLATE_NAME} in one of: ${candidates.join(", ")}`);
3509
3701
  }
3510
- function isProcessAlive(pid) {
3702
+ function isProcessAlive2(pid) {
3511
3703
  try {
3512
3704
  process.kill(pid, 0);
3513
3705
  return true;
@@ -3518,7 +3710,7 @@ function isProcessAlive(pid) {
3518
3710
  function sweepStaleBaseplateFiles() {
3519
3711
  let entries;
3520
3712
  try {
3521
- entries = readdirSync2(BASEPLATE_TEMP_DIR);
3713
+ entries = readdirSync(BASEPLATE_TEMP_DIR);
3522
3714
  } catch {
3523
3715
  return;
3524
3716
  }
@@ -3527,18 +3719,18 @@ function sweepStaleBaseplateFiles() {
3527
3719
  const match = BASEPLATE_TEMP_SWEEP_NAME.exec(entry);
3528
3720
  if (!match)
3529
3721
  continue;
3530
- if (Number(match[1]) !== process.pid && isProcessAlive(Number(match[1])))
3722
+ if (Number(match[1]) !== process.pid && isProcessAlive2(Number(match[1])))
3531
3723
  continue;
3532
3724
  const file = path2.join(BASEPLATE_TEMP_DIR, entry);
3533
3725
  try {
3534
- if (statSync2(file).mtimeMs < cutoff)
3535
- rmSync2(file, { force: true });
3726
+ if (statSync(file).mtimeMs < cutoff)
3727
+ rmSync(file, { force: true });
3536
3728
  } catch {
3537
3729
  }
3538
3730
  }
3539
3731
  }
3540
3732
  function createBaseplatePlaceFile() {
3541
- mkdirSync3(BASEPLATE_TEMP_DIR, { recursive: true });
3733
+ mkdirSync2(BASEPLATE_TEMP_DIR, { recursive: true });
3542
3734
  sweepStaleBaseplateFiles();
3543
3735
  const file = path2.join(BASEPLATE_TEMP_DIR, `Baseplate-${process.pid}-${Date.now()}.rbxl`);
3544
3736
  copyFileSync(resolveBaseplateTemplatePath(), file);
@@ -3555,7 +3747,7 @@ function cleanupManagedBaseplateFiles(record) {
3555
3747
  return;
3556
3748
  for (const file of [record.localPlaceFile, `${record.localPlaceFile}.lock`]) {
3557
3749
  try {
3558
- rmSync2(file, { force: true });
3750
+ rmSync(file, { force: true });
3559
3751
  } catch {
3560
3752
  }
3561
3753
  }
@@ -3582,54 +3774,83 @@ function resolveStudioExe() {
3582
3774
  if (!existsSync2(root)) {
3583
3775
  throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
3584
3776
  }
3585
- 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);
3777
+ 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);
3586
3778
  if (candidates.length === 0) {
3587
3779
  throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
3588
3780
  }
3589
3781
  return candidates[0];
3590
3782
  }
3591
- function listStudioProcesses() {
3783
+ async function resolveStudioExeAsync() {
3784
+ if (process.env.ROBLOX_STUDIO_EXE)
3785
+ return process.env.ROBLOX_STUDIO_EXE;
3592
3786
  if (process.platform === "darwin") {
3593
- let out2 = "";
3594
- try {
3595
- out2 = run("pgrep", ["-fl", "RobloxStudio"]);
3596
- } catch {
3597
- return [];
3598
- }
3599
- return out2.split("\n").filter(Boolean).map((line) => {
3600
- const [pid, ...rest] = line.trim().split(/\s+/);
3601
- return { Id: Number(pid), Name: "RobloxStudio", Path: rest.join(" "), MainWindowTitle: "" };
3602
- }).filter((proc) => Number.isFinite(proc.Id));
3787
+ return "/Applications/RobloxStudio.app/Contents/MacOS/RobloxStudio";
3603
3788
  }
3604
- if (process.platform !== "win32" && !isWsl())
3605
- return [];
3606
- let out = "";
3789
+ if (process.platform !== "win32" && !isWsl()) {
3790
+ throw new Error("Roblox Studio executable auto-discovery is only supported on Windows, WSL, and macOS. Set ROBLOX_STUDIO_EXE.");
3791
+ }
3792
+ const localAppData = await windowsLocalAppDataAsync();
3793
+ const root = localAppData ? path2.join(await toWslPathAsync(localAppData), "Roblox", "Versions") : path2.join(os2.homedir(), "AppData", "Local", "Roblox", "Versions");
3794
+ if (!existsSync2(root)) {
3795
+ throw new Error(`Roblox Studio Versions folder not found: ${root}. Set ROBLOX_STUDIO_EXE.`);
3796
+ }
3797
+ 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);
3798
+ if (candidates.length === 0) {
3799
+ throw new Error(`RobloxStudioBeta.exe not found under ${root}. Set ROBLOX_STUDIO_EXE.`);
3800
+ }
3801
+ return candidates[0];
3802
+ }
3803
+ async function observeStudioProcesses() {
3804
+ const observedAt = Date.now();
3607
3805
  try {
3608
- 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");
3609
- } catch {
3610
- return [];
3806
+ if (process.platform === "darwin") {
3807
+ let out2 = "";
3808
+ try {
3809
+ out2 = await runAsync("pgrep", ["-fl", "RobloxStudio"]);
3810
+ } catch (error) {
3811
+ const code = error.code;
3812
+ if (Number(code) === 1)
3813
+ return { status: "ok", observedAt, processes: [] };
3814
+ throw error;
3815
+ }
3816
+ const processes = out2.split("\n").filter(Boolean).map((line) => {
3817
+ const [pid, ...rest] = line.trim().split(/\s+/);
3818
+ return { Id: Number(pid), Name: "RobloxStudio", Path: rest.join(" "), MainWindowTitle: "" };
3819
+ }).filter((proc) => Number.isFinite(proc.Id));
3820
+ return { status: "ok", observedAt, processes };
3821
+ }
3822
+ if (process.platform !== "win32" && !isWsl()) {
3823
+ return { status: "ok", observedAt, processes: [] };
3824
+ }
3825
+ 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");
3826
+ if (!out)
3827
+ return { status: "ok", observedAt, processes: [] };
3828
+ const parsed = JSON.parse(out);
3829
+ return { status: "ok", observedAt, processes: Array.isArray(parsed) ? parsed : [parsed] };
3830
+ } catch (error) {
3831
+ return {
3832
+ status: "error",
3833
+ observedAt,
3834
+ error: error instanceof Error ? error.message : String(error)
3835
+ };
3611
3836
  }
3612
- if (!out)
3613
- return [];
3614
- const parsed = JSON.parse(out);
3615
- return Array.isArray(parsed) ? parsed : [parsed];
3616
3837
  }
3617
- function currentBootId() {
3838
+ async function currentBootIdAsync() {
3618
3839
  if (process.platform === "linux") {
3619
3840
  try {
3620
- return readFileSync3("/proc/sys/kernel/random/boot_id", "utf8").trim();
3841
+ return readFileSync2("/proc/sys/kernel/random/boot_id", "utf8").trim();
3621
3842
  } catch {
3622
3843
  }
3623
3844
  }
3624
3845
  if (process.platform === "win32" || isWsl()) {
3625
3846
  try {
3626
- return powershell('(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString("o")');
3847
+ return await powershellAsync('(Get-CimInstance Win32_OperatingSystem).LastBootUpTime.ToUniversalTime().ToString("o")');
3627
3848
  } catch {
3628
3849
  }
3629
3850
  }
3630
3851
  if (process.platform === "darwin") {
3631
3852
  try {
3632
- return run("sysctl", ["-n", "kern.boottime"]);
3853
+ return await runAsync("sysctl", ["-n", "kern.boottime"]);
3633
3854
  } catch {
3634
3855
  }
3635
3856
  }
@@ -3668,13 +3889,13 @@ function buildStudioLaunchArgs(options) {
3668
3889
  ];
3669
3890
  }
3670
3891
  }
3671
- function delay(ms) {
3892
+ function delay2(ms) {
3672
3893
  return new Promise((resolve5) => setTimeout(resolve5, ms));
3673
3894
  }
3674
3895
  function basenameAny(filePath) {
3675
3896
  return path2.basename(filePath.replace(/\\/g, "/"));
3676
3897
  }
3677
- var BASEPLATE_TEMP_DIR, BASEPLATE_TEMP_NAME, BASEPLATE_TEMPLATE_NAME, STALE_BASEPLATE_MAX_AGE_MS, BASEPLATE_TEMP_SWEEP_NAME, StudioInstanceManager;
3898
+ var BASEPLATE_TEMP_DIR, BASEPLATE_TEMP_NAME, BASEPLATE_TEMPLATE_NAME, execFileAsync, ENVIRONMENT_VARIABLE_NAME, STALE_BASEPLATE_MAX_AGE_MS, BASEPLATE_TEMP_SWEEP_NAME, StudioInstanceManager;
3678
3899
  var init_studio_instance_manager = __esm({
3679
3900
  "../core/dist/studio-instance-manager.js"() {
3680
3901
  "use strict";
@@ -3682,26 +3903,39 @@ var init_studio_instance_manager = __esm({
3682
3903
  BASEPLATE_TEMP_DIR = path2.join(os2.tmpdir(), "robloxstudio-mcp-baseplates");
3683
3904
  BASEPLATE_TEMP_NAME = /^Baseplate-\d+-\d+\.rbxl$/;
3684
3905
  BASEPLATE_TEMPLATE_NAME = "Baseplate.rbxl";
3906
+ execFileAsync = promisify(execFile);
3907
+ ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
3685
3908
  STALE_BASEPLATE_MAX_AGE_MS = 24 * 60 * 60 * 1e3;
3686
3909
  BASEPLATE_TEMP_SWEEP_NAME = /^Baseplate-(\d+)-\d+\.rbxl(\.lock)?$/;
3687
3910
  StudioInstanceManager = class {
3688
3911
  managedByInstanceId = /* @__PURE__ */ new Map();
3689
3912
  pending = /* @__PURE__ */ new Set();
3690
- monitors = /* @__PURE__ */ new Map();
3691
3913
  connectionTimers = /* @__PURE__ */ new Map();
3692
3914
  registry;
3693
3915
  processAdapter;
3916
+ confirmedExitMisses;
3917
+ confirmedExitGraceMs;
3918
+ snapshotCacheMs;
3919
+ coordinatorTimer;
3920
+ coordinatorRefresh;
3921
+ cachedSnapshot;
3922
+ snapshotInFlight;
3923
+ launchQueue = Promise.resolve();
3694
3924
  constructor(options = {}) {
3695
3925
  this.registry = options.registry ?? new ManagedInstanceRegistry(options.registryDir);
3696
3926
  this.processAdapter = options.processAdapter ?? {};
3927
+ this.confirmedExitMisses = options.confirmedExitMisses ?? 2;
3928
+ this.confirmedExitGraceMs = options.confirmedExitGraceMs ?? 5e3;
3929
+ this.snapshotCacheMs = options.snapshotCacheMs ?? 0;
3697
3930
  }
3698
- list() {
3699
- this.sweepRegistry();
3931
+ async list() {
3932
+ const snapshot = await this.getProcessSnapshot();
3933
+ await this.sweepRegistry(snapshot);
3700
3934
  for (const record of [...this.managedByInstanceId.values(), ...this.pending]) {
3701
- this.refresh(record);
3935
+ await this.refresh(record, snapshot);
3702
3936
  }
3703
3937
  const records = [...this.managedByInstanceId.values(), ...this.pending];
3704
- for (const registryRecord of this.registry.listOpen(this.registrySweepOptions())) {
3938
+ for (const registryRecord of await this.registry.listOpenUnchecked()) {
3705
3939
  const record = this.fromRegistryRecord(registryRecord);
3706
3940
  if (records.some((existing) => record.recordId && existing.recordId === record.recordId || record.instanceId && existing.instanceId === record.instanceId)) {
3707
3941
  continue;
@@ -3710,27 +3944,30 @@ var init_studio_instance_manager = __esm({
3710
3944
  }
3711
3945
  return records.filter((record) => record.closedAt === void 0).filter((instance, index, all) => all.indexOf(instance) === index);
3712
3946
  }
3713
- get(instanceId) {
3714
- this.sweepRegistry();
3947
+ async get(instanceId) {
3948
+ const snapshot = await this.getProcessSnapshot();
3949
+ await this.sweepRegistry(snapshot);
3715
3950
  const memoryRecord = this.managedByInstanceId.get(instanceId);
3716
3951
  if (memoryRecord)
3717
- return this.refresh(memoryRecord);
3718
- const registryRecord = this.registry.findAnyByInstanceId(instanceId);
3719
- return registryRecord ? this.refresh(this.fromRegistryRecord(registryRecord)) : void 0;
3952
+ return this.refresh(memoryRecord, snapshot);
3953
+ const registryRecord = await this.registry.findAnyByInstanceId(instanceId);
3954
+ return registryRecord ? this.refresh(this.fromRegistryRecord(registryRecord), snapshot) : void 0;
3720
3955
  }
3721
- getByLaunchId(launchId) {
3722
- this.sweepRegistry();
3956
+ async getByLaunchId(launchId) {
3957
+ const snapshot = await this.getProcessSnapshot();
3958
+ await this.sweepRegistry(snapshot);
3723
3959
  const memoryRecord = [...this.managedByInstanceId.values(), ...this.pending].find((record) => record.recordId === launchId);
3724
3960
  if (memoryRecord)
3725
- return this.refresh(memoryRecord);
3726
- const registryRecord = this.registry.findAnyByRecordId(launchId, this.registrySweepOptions());
3727
- return registryRecord ? this.refresh(this.fromRegistryRecord(registryRecord)) : void 0;
3961
+ return this.refresh(memoryRecord, snapshot);
3962
+ const registryRecord = await this.registry.findAnyByRecordId(launchId);
3963
+ return registryRecord ? this.refresh(this.fromRegistryRecord(registryRecord), snapshot) : void 0;
3728
3964
  }
3729
- pendingLaunches() {
3965
+ async pendingLaunches() {
3730
3966
  const now = Date.now();
3967
+ const bootId = await this.getCurrentBootId();
3731
3968
  const records = [...this.pending];
3732
- for (const registryRecord of this.registry.listOpenUnchecked()) {
3733
- if (registryRecord.bootId !== this.getCurrentBootId())
3969
+ for (const registryRecord of await this.registry.listOpenUnchecked()) {
3970
+ if (registryRecord.bootId !== bootId)
3734
3971
  continue;
3735
3972
  if (records.some((record) => record.recordId === registryRecord.recordId))
3736
3973
  continue;
@@ -3738,8 +3975,9 @@ var init_studio_instance_manager = __esm({
3738
3975
  }
3739
3976
  return records.filter((record) => record.instanceId === void 0).filter((record) => record.state === "launching").filter((record) => record.connectionDeadlineAt === void 0 || record.connectionDeadlineAt > now);
3740
3977
  }
3741
- attachInstanceId(record, instanceId) {
3742
- this.refresh(record);
3978
+ async attachInstanceId(record, instanceId) {
3979
+ const snapshot = await this.getProcessSnapshot(true);
3980
+ await this.reconcileFromPositiveEvidence(record, snapshot);
3743
3981
  if (record.closedAt !== void 0 || record.state === "failed" || record.state === "exited")
3744
3982
  return;
3745
3983
  if (record.instanceId && record.instanceId !== instanceId)
@@ -3750,54 +3988,71 @@ var init_studio_instance_manager = __esm({
3750
3988
  this.clearConnectionTimer(record);
3751
3989
  this.pending.delete(record);
3752
3990
  this.managedByInstanceId.set(instanceId, record);
3753
- this.persist(record);
3991
+ await this.persist(record);
3754
3992
  }
3755
- markFailed(record, reason) {
3756
- this.refresh(record);
3993
+ async markFailed(record, reason) {
3757
3994
  if (record.closedAt !== void 0 || record.state !== "launching")
3758
3995
  return record;
3759
3996
  record.state = "failed";
3760
3997
  record.failedAt = Date.now();
3761
3998
  record.failureReason = reason;
3762
3999
  this.clearConnectionTimer(record);
3763
- this.persist(record);
4000
+ await this.persist(record);
3764
4001
  return record;
3765
4002
  }
3766
- refresh(record) {
4003
+ async refresh(record, providedSnapshot) {
4004
+ if (record.closedAt !== void 0)
4005
+ return record;
4006
+ const snapshot = providedSnapshot ?? await this.getProcessSnapshot();
4007
+ await this.applyProcessObservation(record, this.observeRecord(record, snapshot));
3767
4008
  if (record.closedAt !== void 0)
3768
4009
  return record;
3769
- const processId = record.nativeProcessId ?? record.spawnPid;
3770
- const studioProcess = processId ? this.findProcessById(processId) : void 0;
3771
- if (processId && (!studioProcess || !this.verifyProcessForRecord(record, studioProcess))) {
3772
- 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.");
3773
- }
3774
4010
  if (record.state === "launching" && record.connectionDeadlineAt !== void 0 && Date.now() >= record.connectionDeadlineAt) {
3775
4011
  record.state = "failed";
3776
4012
  record.failedAt = Date.now();
3777
4013
  record.failureReason = "Studio launched, but the MCP plugin did not connect before timeout.";
3778
4014
  this.clearConnectionTimer(record);
3779
- this.persist(record);
4015
+ await this.persist(record);
3780
4016
  }
3781
4017
  return record;
3782
4018
  }
3783
4019
  async launch(options) {
3784
- this.sweepRegistry();
4020
+ const previous = this.launchQueue;
4021
+ let release;
4022
+ this.launchQueue = new Promise((resolve5) => {
4023
+ release = resolve5;
4024
+ });
4025
+ await previous;
4026
+ try {
4027
+ return await this.launchSerialized(options);
4028
+ } finally {
4029
+ release();
4030
+ }
4031
+ }
4032
+ async launchSerialized(options) {
4033
+ const initialSnapshot = await this.getProcessSnapshot(true);
4034
+ await this.sweepRegistry(initialSnapshot);
4035
+ const processEnvironment = parseStudioProcessEnvironmentPatch(options.processEnvironment);
4036
+ if (options.studioExecutable !== void 0 && (typeof options.studioExecutable !== "string" || options.studioExecutable.length === 0 || options.studioExecutable.includes("\0"))) {
4037
+ throw new Error("studio_executable must be a non-empty string without null characters.");
4038
+ }
3785
4039
  const preparedOptions = prepareStudioLaunchOptions(options);
3786
- const bootId = this.getCurrentBootId();
3787
- const before = new Set(this.listStudioProcesses().map((proc2) => proc2.Id));
3788
- const exe = this.processAdapter.resolveStudioExe?.() ?? resolveStudioExe();
3789
- const args = buildStudioLaunchArgs(preparedOptions).map(toStudioLaunchArg);
4040
+ const bootId = await this.getCurrentBootId();
4041
+ const before = new Set(initialSnapshot.status === "ok" ? initialSnapshot.processes.map((proc2) => proc2.Id) : []);
4042
+ const exe = preparedOptions.studioExecutable ?? (this.processAdapter.resolveStudioExe ? await this.processAdapter.resolveStudioExe() : await resolveStudioExeAsync());
4043
+ const args = await Promise.all(buildStudioLaunchArgs(preparedOptions).map(toStudioLaunchArgAsync));
3790
4044
  const spawnOptions = {
3791
4045
  cwd: isWsl() && existsSync2("/mnt/c/Windows") ? "/mnt/c/Windows" : process.cwd(),
3792
4046
  detached: true,
3793
- stdio: "ignore"
4047
+ stdio: "ignore",
4048
+ ...processEnvironment ? { env: patchedProcessEnvironment(processEnvironment) } : {}
3794
4049
  };
3795
4050
  let proc;
3796
4051
  try {
3797
4052
  if (this.processAdapter.spawnStudio) {
3798
- proc = this.processAdapter.spawnStudio(exe, args, spawnOptions);
4053
+ proc = await this.processAdapter.spawnStudio(exe, args, spawnOptions);
3799
4054
  } else if (isWsl()) {
3800
- proc = spawnWindowsStudioFromWsl(exe, args);
4055
+ proc = await spawnWindowsStudioFromWsl(exe, args, processEnvironment);
3801
4056
  } else {
3802
4057
  const child = spawn(exe, args, spawnOptions);
3803
4058
  proc = {
@@ -3816,8 +4071,9 @@ var init_studio_instance_manager = __esm({
3816
4071
  cleanupManagedBaseplateFiles({ source: preparedOptions.source, localPlaceFile: preparedOptions.localPlaceFile });
3817
4072
  throw error;
3818
4073
  }
4074
+ const launchedAt = Date.now();
3819
4075
  const record = {
3820
- recordId: randomUUID(),
4076
+ recordId: randomUUID2(),
3821
4077
  source: options.source,
3822
4078
  nativeProcessId: proc.nativePid,
3823
4079
  nativeProcessStartedAt: proc.nativeStartedAt,
@@ -3828,23 +4084,27 @@ var init_studio_instance_manager = __esm({
3828
4084
  universeId: preparedOptions.universeId,
3829
4085
  placeVersion: preparedOptions.placeVersion,
3830
4086
  localPlaceFile: preparedOptions.localPlaceFile,
3831
- launchedAt: Date.now(),
3832
- connectionDeadlineAt: Date.now() + (options.connectionTimeoutMs ?? 12e4),
4087
+ launchedAt,
4088
+ connectionDeadlineAt: launchedAt + (options.connectionTimeoutMs ?? 12e4),
3833
4089
  state: "launching",
3834
4090
  ownerPid: process.pid,
3835
4091
  bootId,
3836
- deleteLocalPlaceFileOnClose: options.source === "baseplate"
4092
+ deleteLocalPlaceFileOnClose: options.source === "baseplate",
4093
+ processObservationStatus: "running",
4094
+ lastProcessObservationAt: launchedAt,
4095
+ lastSuccessfulProcessObservationAt: launchedAt,
4096
+ consecutiveConfirmedMisses: 0
3837
4097
  };
3838
4098
  this.pending.add(record);
3839
4099
  try {
3840
- this.persist(record);
4100
+ await this.persist(record);
3841
4101
  } catch (error) {
3842
4102
  this.pending.delete(record);
3843
4103
  const processId = record.nativeProcessId ?? record.spawnPid;
3844
4104
  let stopError;
3845
4105
  if (processId) {
3846
4106
  try {
3847
- this.closeProcess(processId);
4107
+ await this.closeProcess(processId);
3848
4108
  } catch (caught) {
3849
4109
  stopError = caught;
3850
4110
  }
@@ -3856,38 +4116,40 @@ var init_studio_instance_manager = __esm({
3856
4116
  }
3857
4117
  proc.unref();
3858
4118
  proc.onExit?.((code, signal) => {
3859
- 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.");
4119
+ 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."));
3860
4120
  });
3861
4121
  proc.onError?.((error) => {
3862
- this.markFailed(record, `Studio process failed to start: ${error.message}`);
4122
+ this.runInBackground("persisting a Studio process launch failure", this.markFailed(record, `Studio process failed to start: ${error.message}`));
3863
4123
  });
3864
4124
  const deadline = Date.now() + 5e3;
3865
4125
  while (Date.now() < deadline && record.nativeProcessId === void 0) {
3866
- const created = this.listStudioProcesses().find((candidate) => !before.has(candidate.Id));
4126
+ const snapshot = await this.getProcessSnapshot(true);
4127
+ const created = snapshot.status === "ok" ? snapshot.processes.find((candidate) => !before.has(candidate.Id)) : void 0;
3867
4128
  if (created) {
3868
4129
  record.nativeProcessId = created.Id;
3869
4130
  record.nativeProcessStartedAt = created.StartTimeUtcFileTime;
3870
- this.persist(record);
4131
+ await this.persist(record);
3871
4132
  break;
3872
4133
  }
3873
- await delay(250);
4134
+ await delay2(250);
3874
4135
  }
3875
4136
  if (record.nativeProcessId === void 0 && process.platform !== "win32" && !isWsl()) {
3876
4137
  record.nativeProcessId = proc.pid;
3877
- this.persist(record);
4138
+ await this.persist(record);
3878
4139
  }
3879
4140
  if (record.nativeProcessId !== void 0 && record.nativeProcessStartedAt === void 0) {
3880
- const nativeProcess = this.findProcessById(record.nativeProcessId);
4141
+ const snapshot = await this.getProcessSnapshot(true);
4142
+ const nativeProcess = snapshot.status === "ok" ? snapshot.processes.find((candidate) => candidate.Id === record.nativeProcessId) : void 0;
3881
4143
  if (nativeProcess?.StartTimeUtcFileTime !== void 0) {
3882
4144
  record.nativeProcessStartedAt = nativeProcess.StartTimeUtcFileTime;
3883
- this.persist(record);
4145
+ await this.persist(record);
3884
4146
  }
3885
4147
  }
3886
- this.startMonitor(record);
4148
+ this.startCoordinator(record);
3887
4149
  return record;
3888
4150
  }
3889
- closeByLaunchId(launchId) {
3890
- const record = this.getByLaunchId(launchId);
4151
+ async closeByLaunchId(launchId) {
4152
+ const record = await this.getByLaunchId(launchId);
3891
4153
  if (!record)
3892
4154
  return { status: "not_found", launchId };
3893
4155
  if (record.closedAt !== void 0) {
@@ -3895,19 +4157,19 @@ var init_studio_instance_manager = __esm({
3895
4157
  }
3896
4158
  return this.close(record);
3897
4159
  }
3898
- closeByInstanceId(instanceId) {
3899
- this.sweepRegistry();
4160
+ async closeByInstanceId(instanceId) {
4161
+ const snapshot = await this.getProcessSnapshot(true);
4162
+ await this.sweepRegistry(snapshot);
3900
4163
  const memoryRecord = this.managedByInstanceId.get(instanceId);
3901
4164
  if (memoryRecord)
3902
4165
  return this.close(memoryRecord);
3903
- const registryRecord = this.registry.findAnyByInstanceId(instanceId);
4166
+ const registryRecord = await this.registry.findAnyByInstanceId(instanceId);
3904
4167
  if (!registryRecord) {
3905
- this.sweepRegistry();
3906
4168
  return { status: "not_found", instanceId };
3907
4169
  }
3908
4170
  if (registryRecord.closedAt !== void 0) {
3909
4171
  this.cleanupManagedRecord(registryRecord);
3910
- this.registry.logEvent({
4172
+ await this.registry.logEvent({
3911
4173
  event: "registry_close_already_stopped",
3912
4174
  recordId: registryRecord.recordId,
3913
4175
  instanceId: registryRecord.instanceId,
@@ -3919,9 +4181,7 @@ var init_studio_instance_manager = __esm({
3919
4181
  }
3920
4182
  return this.close(this.fromRegistryRecord(registryRecord));
3921
4183
  }
3922
- close(record) {
3923
- this.stopMonitor(record);
3924
- this.refresh(record);
4184
+ async close(record) {
3925
4185
  if (record.closedAt !== void 0) {
3926
4186
  return { status: "already_closed", launchId: record.recordId, instanceId: record.instanceId };
3927
4187
  }
@@ -3929,36 +4189,33 @@ var init_studio_instance_manager = __esm({
3929
4189
  if (!processId) {
3930
4190
  throw new Error(`Cannot close ${record.instanceId ?? "Studio launch"} because its process id was not detected.`);
3931
4191
  }
3932
- const studioProcess = this.findProcessById(processId);
3933
- if (!studioProcess) {
4192
+ const snapshot = await this.getProcessSnapshot(true);
4193
+ const observation = this.observeRecord(record, snapshot);
4194
+ if (observation.status === "unknown") {
4195
+ await this.applyProcessObservation(record, observation);
4196
+ throw new Error(`Cannot verify the managed Studio process because process observation failed: ${observation.error}`);
4197
+ }
4198
+ if (observation.status === "not_running") {
3934
4199
  this.cleanupManagedRecord(record);
3935
- this.markProcessExited(record, void 0, record.failureReason);
3936
- this.registry.logEvent({
4200
+ await this.markProcessExited(record, void 0, observation.reason === "identity_mismatch" ? "Studio process identity changed; the retained PID was not reused." : record.failureReason);
4201
+ await this.registry.logEvent({
3937
4202
  event: "registry_close_already_stopped",
3938
4203
  recordId: record.recordId,
3939
4204
  instanceId: record.instanceId,
3940
4205
  source: record.source,
3941
- reason: "pid_not_running",
4206
+ reason: observation.reason === "identity_mismatch" ? "identity_mismatch" : "pid_not_running",
3942
4207
  action: "marked_closed_and_cleaned_baseplate"
3943
4208
  });
3944
4209
  return { status: "already_closed", launchId: record.recordId, instanceId: record.instanceId };
3945
4210
  }
3946
- if (!this.verifyProcessForRecord(record, studioProcess)) {
3947
- this.registry.logEvent({
3948
- event: "registry_process_verification_failed",
3949
- recordId: record.recordId,
3950
- instanceId: record.instanceId,
3951
- source: record.source,
3952
- reason: "identity_mismatch"
3953
- });
3954
- throw new Error("Managed Studio process identity could not be verified.");
3955
- }
3956
4211
  try {
3957
- this.closeProcess(processId);
4212
+ await this.closeProcess(processId);
3958
4213
  } catch (error) {
3959
- if (this.findProcessById(processId))
4214
+ const retry = await this.getProcessSnapshot(true);
4215
+ const retryObservation = this.observeRecord(record, retry);
4216
+ if (retryObservation.status === "running" || retryObservation.status === "unknown")
3960
4217
  throw error;
3961
- this.registry.logEvent({
4218
+ await this.registry.logEvent({
3962
4219
  event: "registry_close_already_stopped",
3963
4220
  recordId: record.recordId,
3964
4221
  instanceId: record.instanceId,
@@ -3967,7 +4224,7 @@ var init_studio_instance_manager = __esm({
3967
4224
  action: "marked_closed_and_cleaned_baseplate"
3968
4225
  });
3969
4226
  this.cleanupManagedRecord(record);
3970
- this.markProcessExited(record, void 0, record.failureReason);
4227
+ await this.markProcessExited(record, void 0, record.failureReason);
3971
4228
  return { status: "already_closed", launchId: record.recordId, instanceId: record.instanceId };
3972
4229
  }
3973
4230
  const closedAt = Date.now();
@@ -3975,25 +4232,33 @@ var init_studio_instance_manager = __esm({
3975
4232
  record.exitedAt = record.exitedAt ?? closedAt;
3976
4233
  if (record.state !== "failed")
3977
4234
  record.state = "exited";
4235
+ record.processObservationStatus = "not_running";
4236
+ record.lastProcessObservationAt = closedAt;
4237
+ record.lastSuccessfulProcessObservationAt = closedAt;
4238
+ record.lastProcessObservationError = void 0;
3978
4239
  this.cleanupManagedRecord(record);
3979
4240
  this.markClosedInMemory(record);
3980
- this.persist(record);
4241
+ await this.persist(record);
3981
4242
  return { status: "closed", launchId: record.recordId, instanceId: record.instanceId };
3982
4243
  }
3983
- closeConnectedInstance(instance) {
3984
- const process2 = this.findProcessForConnectedInstance(instance);
4244
+ async closeConnectedInstance(instance) {
4245
+ const snapshot = await this.getProcessSnapshot(true);
4246
+ if (snapshot.status === "error") {
4247
+ throw new Error(`Could not enumerate Studio processes: ${snapshot.error}`);
4248
+ }
4249
+ const process2 = this.findProcessForConnectedInstance(instance, snapshot.processes);
3985
4250
  if (!process2) {
3986
4251
  throw new Error(`Could not find a Studio process for connected instance "${instance.instanceId}".`);
3987
4252
  }
3988
- this.closeProcess(process2.Id);
4253
+ await this.closeProcess(process2.Id);
3989
4254
  }
3990
- closeProcess(processId) {
4255
+ async closeProcess(processId) {
3991
4256
  if (this.processAdapter.stopProcess) {
3992
- this.processAdapter.stopProcess(processId);
4257
+ await this.processAdapter.stopProcess(processId);
3993
4258
  return;
3994
4259
  }
3995
4260
  if (process.platform === "win32" || isWsl()) {
3996
- powershell(`Stop-Process -Id ${Math.trunc(processId)} -Force -ErrorAction Stop`);
4261
+ await powershellAsync(`Stop-Process -Id ${Math.trunc(processId)} -Force -ErrorAction Stop`);
3997
4262
  } else {
3998
4263
  try {
3999
4264
  process.kill(processId, "SIGTERM");
@@ -4003,8 +4268,7 @@ var init_studio_instance_manager = __esm({
4003
4268
  }
4004
4269
  }
4005
4270
  }
4006
- findProcessForConnectedInstance(instance) {
4007
- const processes = this.listStudioProcesses();
4271
+ findProcessForConnectedInstance(instance, processes) {
4008
4272
  if (processes.length === 0)
4009
4273
  return void 0;
4010
4274
  if (processes.length === 1)
@@ -4023,31 +4287,67 @@ var init_studio_instance_manager = __esm({
4023
4287
  }
4024
4288
  return void 0;
4025
4289
  }
4026
- listStudioProcesses() {
4027
- return this.processAdapter.listStudioProcesses?.() ?? listStudioProcesses();
4290
+ async getProcessSnapshot(force = false) {
4291
+ const now = Date.now();
4292
+ if (!force && this.snapshotCacheMs > 0 && this.cachedSnapshot && now - this.cachedSnapshot.observedAt <= this.snapshotCacheMs) {
4293
+ return this.cachedSnapshot;
4294
+ }
4295
+ if (this.snapshotInFlight)
4296
+ return this.snapshotInFlight;
4297
+ this.snapshotInFlight = (async () => {
4298
+ try {
4299
+ let snapshot;
4300
+ if (this.processAdapter.observeStudioProcesses) {
4301
+ snapshot = await this.processAdapter.observeStudioProcesses();
4302
+ } else if (this.processAdapter.listStudioProcesses) {
4303
+ const observedAt = Date.now();
4304
+ const processes = await this.processAdapter.listStudioProcesses();
4305
+ snapshot = { status: "ok", observedAt, processes };
4306
+ } else {
4307
+ snapshot = await observeStudioProcesses();
4308
+ }
4309
+ this.cachedSnapshot = snapshot;
4310
+ return snapshot;
4311
+ } catch (error) {
4312
+ const snapshot = {
4313
+ status: "error",
4314
+ observedAt: Date.now(),
4315
+ error: error instanceof Error ? error.message : String(error)
4316
+ };
4317
+ this.cachedSnapshot = snapshot;
4318
+ return snapshot;
4319
+ } finally {
4320
+ this.snapshotInFlight = void 0;
4321
+ }
4322
+ })();
4323
+ return this.snapshotInFlight;
4028
4324
  }
4029
- getCurrentBootId() {
4030
- return this.processAdapter.currentBootId?.() ?? currentBootId();
4325
+ async getCurrentBootId() {
4326
+ return this.processAdapter.currentBootId ? await this.processAdapter.currentBootId() : currentBootIdAsync();
4031
4327
  }
4032
- registrySweepOptions() {
4328
+ async registrySweepOptions(snapshot) {
4033
4329
  return {
4034
- currentBootId: this.getCurrentBootId(),
4035
- isProcessRunning: (record) => this.isRegistryProcessRunning(record),
4036
- cleanupRecord: (record) => this.cleanupManagedRecord(record)
4330
+ currentBootId: await this.getCurrentBootId(),
4331
+ observeProcess: (record) => this.observeRecord(this.fromRegistryRecord(record), snapshot),
4332
+ cleanupRecord: (record) => this.cleanupManagedRecord(record),
4333
+ confirmedExitMisses: this.confirmedExitMisses,
4334
+ confirmedExitGraceMs: this.confirmedExitGraceMs
4037
4335
  };
4038
4336
  }
4039
- sweepRegistry() {
4040
- this.registry.sweep(this.registrySweepOptions());
4041
- }
4042
- findProcessById(processId) {
4043
- return this.listStudioProcesses().find((proc) => proc.Id === processId);
4337
+ async sweepRegistry(snapshot) {
4338
+ await this.registry.sweep(await this.registrySweepOptions(snapshot));
4044
4339
  }
4045
- isRegistryProcessRunning(record) {
4340
+ observeRecord(record, snapshot) {
4341
+ if (snapshot.status === "error") {
4342
+ return { status: "unknown", observedAt: snapshot.observedAt, error: snapshot.error };
4343
+ }
4046
4344
  const processId = record.nativeProcessId ?? record.spawnPid;
4047
4345
  if (!processId)
4048
- return true;
4049
- const studioProcess = this.findProcessById(processId);
4050
- return !!studioProcess && this.verifyProcessForRecord(this.fromRegistryRecord(record), studioProcess);
4346
+ return { status: "running", observedAt: snapshot.observedAt };
4347
+ const studioProcess = snapshot.processes.find((candidate) => candidate.Id === processId);
4348
+ if (!studioProcess)
4349
+ return { status: "not_running", observedAt: snapshot.observedAt, reason: "missing" };
4350
+ return this.verifyProcessForRecord(record, studioProcess) ? { status: "running", observedAt: snapshot.observedAt } : { status: "not_running", observedAt: snapshot.observedAt, reason: "identity_mismatch" };
4051
4351
  }
4052
4352
  verifyProcessForRecord(record, studioProcess) {
4053
4353
  const processName = `${studioProcess.Name ?? ""} ${studioProcess.Path ?? ""}`.toLowerCase();
@@ -4081,9 +4381,9 @@ var init_studio_instance_manager = __esm({
4081
4381
  if (record.instanceId)
4082
4382
  this.managedByInstanceId.delete(record.instanceId);
4083
4383
  this.pending.delete(record);
4084
- this.stopMonitor(record);
4384
+ this.clearConnectionTimer(record);
4085
4385
  }
4086
- markProcessExited(record, exitCode, reason) {
4386
+ async markProcessExited(record, exitCode, reason) {
4087
4387
  if (record.closedAt !== void 0)
4088
4388
  return record;
4089
4389
  const exitedAt = Date.now();
@@ -4091,43 +4391,44 @@ var init_studio_instance_manager = __esm({
4091
4391
  record.closedAt = exitedAt;
4092
4392
  if (record.state !== "failed")
4093
4393
  record.state = "exited";
4394
+ record.processObservationStatus = "not_running";
4395
+ record.lastProcessObservationAt = exitedAt;
4396
+ record.lastSuccessfulProcessObservationAt = exitedAt;
4397
+ record.lastProcessObservationError = void 0;
4094
4398
  if (exitCode !== void 0)
4095
4399
  record.exitCode = exitCode;
4096
4400
  if (reason)
4097
4401
  record.failureReason = reason;
4098
4402
  this.cleanupManagedRecord(record);
4099
4403
  this.markClosedInMemory(record);
4100
- this.persist(record);
4404
+ await this.persist(record);
4101
4405
  return record;
4102
4406
  }
4103
- startMonitor(record) {
4104
- if (!record.recordId || record.closedAt !== void 0 || this.monitors.has(record.recordId))
4407
+ startCoordinator(record) {
4408
+ if (!record.recordId || record.closedAt !== void 0)
4105
4409
  return;
4106
4410
  if (record.state === "launching" && record.connectionDeadlineAt !== void 0) {
4107
4411
  const timeout = setTimeout(() => {
4108
- this.markFailed(record, "Studio launched, but the MCP plugin did not connect before timeout.");
4412
+ this.runInBackground("persisting a Studio plugin connection timeout", this.markFailed(record, "Studio launched, but the MCP plugin did not connect before timeout."));
4109
4413
  }, Math.max(0, record.connectionDeadlineAt - Date.now()));
4110
4414
  if (typeof timeout === "object" && "unref" in timeout)
4111
4415
  timeout.unref();
4112
4416
  this.connectionTimers.set(record.recordId, timeout);
4113
4417
  }
4114
- const timer = setInterval(() => {
4115
- this.refresh(record);
4116
- if (record.closedAt !== void 0)
4117
- this.stopMonitor(record);
4118
- }, 5e3);
4119
- if (typeof timer === "object" && "unref" in timer)
4120
- timer.unref();
4121
- this.monitors.set(record.recordId, timer);
4122
- }
4123
- stopMonitor(record) {
4124
- if (!record.recordId)
4418
+ if (this.coordinatorTimer)
4125
4419
  return;
4126
- const timer = this.monitors.get(record.recordId);
4127
- if (timer)
4128
- clearInterval(timer);
4129
- this.monitors.delete(record.recordId);
4130
- this.clearConnectionTimer(record);
4420
+ this.coordinatorTimer = setInterval(() => {
4421
+ if (this.coordinatorRefresh)
4422
+ return;
4423
+ this.coordinatorRefresh = this.refreshOwnedRecords().catch((error) => {
4424
+ this.reportBackgroundFailure("refreshing managed Studio records", error);
4425
+ }).finally(() => {
4426
+ this.coordinatorRefresh = void 0;
4427
+ });
4428
+ }, 5e3);
4429
+ if (typeof this.coordinatorTimer === "object" && "unref" in this.coordinatorTimer) {
4430
+ this.coordinatorTimer.unref();
4431
+ }
4131
4432
  }
4132
4433
  clearConnectionTimer(record) {
4133
4434
  if (!record.recordId)
@@ -4137,8 +4438,80 @@ var init_studio_instance_manager = __esm({
4137
4438
  clearTimeout(timer);
4138
4439
  this.connectionTimers.delete(record.recordId);
4139
4440
  }
4140
- persist(record) {
4141
- this.registry.upsert(this.toRegistryRecord(record));
4441
+ async refreshOwnedRecords() {
4442
+ const snapshot = await this.getProcessSnapshot(true);
4443
+ await this.sweepRegistry(snapshot);
4444
+ for (const record of [...this.managedByInstanceId.values(), ...this.pending]) {
4445
+ await this.refresh(record, snapshot);
4446
+ }
4447
+ }
4448
+ runInBackground(context, operation) {
4449
+ void operation.catch((error) => this.reportBackgroundFailure(context, error));
4450
+ }
4451
+ reportBackgroundFailure(context, error) {
4452
+ console.warn(`[robloxstudio-mcp] failed while ${context}: ${error instanceof Error ? error.message : String(error)}`);
4453
+ }
4454
+ async applyProcessObservation(record, observation) {
4455
+ if (record.closedAt !== void 0)
4456
+ return;
4457
+ const previousObservationAt = record.lastProcessObservationAt;
4458
+ record.lastProcessObservationAt = observation.observedAt;
4459
+ if (observation.status === "unknown") {
4460
+ record.processObservationStatus = "unknown";
4461
+ record.lastProcessObservationError = observation.error;
4462
+ record.consecutiveConfirmedMisses = 0;
4463
+ record.firstConfirmedMissAt = void 0;
4464
+ await this.persist(record);
4465
+ return;
4466
+ }
4467
+ record.lastSuccessfulProcessObservationAt = observation.observedAt;
4468
+ record.lastProcessObservationError = void 0;
4469
+ if (observation.status === "running") {
4470
+ record.processObservationStatus = "running";
4471
+ record.consecutiveConfirmedMisses = 0;
4472
+ record.firstConfirmedMissAt = void 0;
4473
+ await this.persist(record);
4474
+ return;
4475
+ }
4476
+ record.processObservationStatus = "not_running";
4477
+ if (previousObservationAt !== observation.observedAt) {
4478
+ record.consecutiveConfirmedMisses = (record.consecutiveConfirmedMisses ?? 0) + 1;
4479
+ record.firstConfirmedMissAt ??= observation.observedAt;
4480
+ }
4481
+ const confirmedAbsent = observation.reason === "identity_mismatch" || (record.consecutiveConfirmedMisses ?? 0) >= this.confirmedExitMisses && observation.observedAt - (record.firstConfirmedMissAt ?? observation.observedAt) >= this.confirmedExitGraceMs;
4482
+ if (!confirmedAbsent) {
4483
+ await this.persist(record);
4484
+ return;
4485
+ }
4486
+ 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.");
4487
+ }
4488
+ async reconcileFromPositiveEvidence(record, snapshot) {
4489
+ if (record.closedAt === void 0)
4490
+ return;
4491
+ if (record.failureReason !== "Studio process exited." && record.failureReason !== "Studio process exited before the MCP plugin connected.")
4492
+ return;
4493
+ if (snapshot.status !== "ok")
4494
+ return;
4495
+ const processId = record.nativeProcessId ?? record.spawnPid;
4496
+ const studioProcess = processId ? snapshot.processes.find((candidate) => candidate.Id === processId) : void 0;
4497
+ if (!studioProcess || !this.verifyProcessForRecord(record, studioProcess))
4498
+ return;
4499
+ if (record.exitCode !== void 0)
4500
+ return;
4501
+ record.closedAt = void 0;
4502
+ record.exitedAt = void 0;
4503
+ record.failureReason = void 0;
4504
+ record.state = record.instanceId ? "connected" : "launching";
4505
+ record.processObservationStatus = "running";
4506
+ record.lastProcessObservationAt = snapshot.observedAt;
4507
+ record.lastSuccessfulProcessObservationAt = snapshot.observedAt;
4508
+ record.lastProcessObservationError = void 0;
4509
+ record.consecutiveConfirmedMisses = 0;
4510
+ record.firstConfirmedMissAt = void 0;
4511
+ await this.persist(record);
4512
+ }
4513
+ async persist(record) {
4514
+ await this.registry.upsert(this.toRegistryRecord(record));
4142
4515
  }
4143
4516
  toRegistryRecord(record) {
4144
4517
  if (!record.recordId)
@@ -4170,7 +4543,13 @@ var init_studio_instance_manager = __esm({
4170
4543
  failureReason: record.failureReason,
4171
4544
  closedAt: record.closedAt,
4172
4545
  ownerPid: record.ownerPid,
4173
- bootId: record.bootId
4546
+ bootId: record.bootId,
4547
+ processObservationStatus: record.processObservationStatus,
4548
+ lastProcessObservationAt: record.lastProcessObservationAt,
4549
+ lastSuccessfulProcessObservationAt: record.lastSuccessfulProcessObservationAt,
4550
+ lastProcessObservationError: record.lastProcessObservationError,
4551
+ consecutiveConfirmedMisses: record.consecutiveConfirmedMisses,
4552
+ firstConfirmedMissAt: record.firstConfirmedMissAt
4174
4553
  };
4175
4554
  }
4176
4555
  fromRegistryRecord(record) {
@@ -4199,7 +4578,13 @@ var init_studio_instance_manager = __esm({
4199
4578
  closedAt: record.closedAt,
4200
4579
  ownerPid: record.ownerPid,
4201
4580
  bootId: record.bootId,
4202
- deleteLocalPlaceFileOnClose: record.deleteLocalPlaceFileOnClose
4581
+ deleteLocalPlaceFileOnClose: record.deleteLocalPlaceFileOnClose,
4582
+ processObservationStatus: record.processObservationStatus,
4583
+ lastProcessObservationAt: record.lastProcessObservationAt,
4584
+ lastSuccessfulProcessObservationAt: record.lastSuccessfulProcessObservationAt,
4585
+ lastProcessObservationError: record.lastProcessObservationError,
4586
+ consecutiveConfirmedMisses: record.consecutiveConfirmedMisses,
4587
+ firstConfirmedMissAt: record.firstConfirmedMissAt
4203
4588
  };
4204
4589
  }
4205
4590
  };
@@ -5010,7 +5395,7 @@ var init_esm = __esm({
5010
5395
 
5011
5396
  // ../core/dist/studio-skills.js
5012
5397
  import { createHash as createHash2 } from "crypto";
5013
- import { existsSync as existsSync4, readFileSync as readFileSync5, readdirSync as readdirSync3, statSync as statSync3 } from "fs";
5398
+ import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
5014
5399
  import * as path4 from "path";
5015
5400
  function decompressLz4Block(input, outputLength) {
5016
5401
  const output = Buffer.allocUnsafe(outputLength);
@@ -5222,7 +5607,7 @@ function discoverNamedFile(root, fileName, depth = 0) {
5222
5607
  const matches = [];
5223
5608
  let entries;
5224
5609
  try {
5225
- entries = readdirSync3(root, { withFileTypes: true });
5610
+ entries = readdirSync2(root, { withFileTypes: true });
5226
5611
  } catch {
5227
5612
  return matches;
5228
5613
  }
@@ -5236,7 +5621,7 @@ function discoverNamedFile(root, fileName, depth = 0) {
5236
5621
  }
5237
5622
  return matches;
5238
5623
  }
5239
- function resolveAssistantBundlePath(studioExe = resolveStudioExe()) {
5624
+ function resolveAssistantBundlePath(studioExe) {
5240
5625
  const override = process.env.ROBLOX_STUDIO_ASSISTANT_BUNDLE;
5241
5626
  if (override) {
5242
5627
  if (!existsSync4(override)) {
@@ -5244,7 +5629,7 @@ function resolveAssistantBundlePath(studioExe = resolveStudioExe()) {
5244
5629
  }
5245
5630
  return override;
5246
5631
  }
5247
- const exeDirectory = path4.dirname(studioExe);
5632
+ const exeDirectory = path4.dirname(studioExe ?? resolveStudioExe());
5248
5633
  const roots = [
5249
5634
  path4.join(exeDirectory, "BuiltInStandalonePlugins"),
5250
5635
  path4.resolve(exeDirectory, "..", "Resources", "BuiltInStandalonePlugins"),
@@ -5258,18 +5643,18 @@ function resolveAssistantBundlePath(studioExe = resolveStudioExe()) {
5258
5643
  for (const candidate of discoverNamedFile(root, "Assistant.rbxm"))
5259
5644
  candidates.add(candidate);
5260
5645
  }
5261
- const newest = [...candidates].map((candidate) => ({ candidate, modifiedAt: statSync3(candidate).mtimeMs })).sort((left, right) => right.modifiedAt - left.modifiedAt)[0]?.candidate;
5646
+ const newest = [...candidates].map((candidate) => ({ candidate, modifiedAt: statSync2(candidate).mtimeMs })).sort((left, right) => right.modifiedAt - left.modifiedAt)[0]?.candidate;
5262
5647
  if (!newest) {
5263
5648
  throw new Error(`Studio Assistant bundle not found for ${studioExe}. Set ROBLOX_STUDIO_ASSISTANT_BUNDLE to the installed Assistant.rbxm path.`);
5264
5649
  }
5265
5650
  return newest;
5266
5651
  }
5267
5652
  function loadBuiltInStudioSkills(bundlePath = resolveAssistantBundlePath()) {
5268
- const stats = statSync3(bundlePath);
5653
+ const stats = statSync2(bundlePath);
5269
5654
  if (cachedBundle?.path === bundlePath && cachedBundle.modifiedAt === stats.mtimeMs && cachedBundle.size === stats.size) {
5270
5655
  return cachedBundle.value;
5271
5656
  }
5272
- const buffer = readFileSync5(bundlePath);
5657
+ const buffer = readFileSync4(bundlePath);
5273
5658
  const skills = parseBuiltInStudioSkills(buffer);
5274
5659
  if (skills.length === 0) {
5275
5660
  throw new Error(`No built-in skill documents found in ${bundlePath}`);
@@ -6986,13 +7371,20 @@ var init_tools = __esm({
6986
7371
  openCloudClient;
6987
7372
  cookieClient;
6988
7373
  instanceManager;
7374
+ managedConnectionAssociations = Promise.resolve();
6989
7375
  constructor(bridge) {
6990
7376
  this.client = new StudioHttpClient(bridge);
6991
7377
  this.bridge = bridge;
6992
7378
  this.openCloudClient = new OpenCloudClient();
6993
7379
  this.cookieClient = new RobloxCookieClient();
6994
7380
  this.instanceManager = new StudioInstanceManager();
6995
- this.bridge.onInstanceRegistered((instance) => this._associateManagedEditConnection(instance));
7381
+ this.bridge.onInstanceRegistered((instance) => {
7382
+ const instanceManager = this.instanceManager;
7383
+ const association = this.managedConnectionAssociations.then(() => this._associateManagedEditConnection(instance, instanceManager));
7384
+ this.managedConnectionAssociations = association.catch((error) => {
7385
+ console.warn(`[robloxstudio-mcp] managed Studio connection association failed: ${error instanceof Error ? error.message : String(error)}`);
7386
+ });
7387
+ });
6996
7388
  }
6997
7389
  _textResult(body) {
6998
7390
  return { content: [{ type: "text", text: JSON.stringify(body) }] };
@@ -8573,9 +8965,9 @@ ${code}`
8573
8965
  _publicInstanceKey(instance) {
8574
8966
  return `${instance.instanceId}:${instance.role}:${instance.connectedAt}`;
8575
8967
  }
8576
- _isLatestPublishedPlaceOpen(placeId) {
8968
+ async _isLatestPublishedPlaceOpen(placeId) {
8577
8969
  const publishedInstanceId2 = `place:${placeId}`;
8578
- 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);
8970
+ 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);
8579
8971
  }
8580
8972
  _matchesManagedLaunch(record, instance) {
8581
8973
  if (record.source === "published_place") {
@@ -8587,12 +8979,12 @@ ${code}`
8587
8979
  }
8588
8980
  return true;
8589
8981
  }
8590
- _associateManagedEditConnection(instance) {
8982
+ async _associateManagedEditConnection(instance, instanceManager) {
8591
8983
  if (instance.role !== "edit")
8592
8984
  return;
8593
- 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];
8985
+ 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];
8594
8986
  if (candidate)
8595
- this.instanceManager.attachInstanceId(candidate, instance.instanceId);
8987
+ await instanceManager.attachInstanceId(candidate, instance.instanceId);
8596
8988
  }
8597
8989
  async _deriveUniverseId(placeId) {
8598
8990
  const response = await fetch(`https://apis.roblox.com/universes/v1/places/${placeId}/universe`);
@@ -8609,7 +9001,7 @@ ${code}`
8609
9001
  async _waitForManagedEditConnection(record, beforeKeys, timeoutMs) {
8610
9002
  const deadline = Date.now() + timeoutMs;
8611
9003
  while (Date.now() < deadline) {
8612
- this.instanceManager.refresh(record);
9004
+ await this.instanceManager.refresh(record);
8613
9005
  if (record.state === "failed" || record.state === "exited" || record.closedAt !== void 0) {
8614
9006
  return void 0;
8615
9007
  }
@@ -8621,7 +9013,6 @@ ${code}`
8621
9013
  return void 0;
8622
9014
  }
8623
9015
  _managedStatus(record) {
8624
- this.instanceManager.refresh(record);
8625
9016
  const connected = record.instanceId ? this.bridge.getPublicInstances().filter((instance) => instance.instanceId === record.instanceId) : [];
8626
9017
  return {
8627
9018
  launch_id: record.recordId,
@@ -8629,7 +9020,12 @@ ${code}`
8629
9020
  managed: true,
8630
9021
  state: record.state,
8631
9022
  pid: record.nativeProcessId ?? record.spawnPid,
8632
- process_running: record.closedAt === void 0 && record.exitedAt === void 0,
9023
+ process_running: record.closedAt !== void 0 || record.exitedAt !== void 0 ? false : record.processObservationStatus === "running" ? true : record.processObservationStatus === "not_running" ? false : null,
9024
+ process_observation_status: record.processObservationStatus ?? "unknown",
9025
+ last_process_observation_at: record.lastProcessObservationAt ? new Date(record.lastProcessObservationAt).toISOString() : void 0,
9026
+ last_successful_process_observation_at: record.lastSuccessfulProcessObservationAt ? new Date(record.lastSuccessfulProcessObservationAt).toISOString() : void 0,
9027
+ last_process_observation_error: record.lastProcessObservationError,
9028
+ consecutive_confirmed_misses: record.consecutiveConfirmedMisses ?? 0,
8633
9029
  source: record.source,
8634
9030
  local_place_file: record.localPlaceFile,
8635
9031
  place_id: record.placeId,
@@ -8681,15 +9077,18 @@ ${code}`
8681
9077
  body.next_page_token = response.nextPageToken;
8682
9078
  return this._textResult(body);
8683
9079
  }
9080
+ if (action === "status" || action === "close") {
9081
+ await this.managedConnectionAssociations;
9082
+ }
8684
9083
  if (action === "status") {
8685
9084
  if (launch_id) {
8686
- const record2 = this.instanceManager.getByLaunchId(launch_id);
9085
+ const record2 = await this.instanceManager.getByLaunchId(launch_id);
8687
9086
  if (!record2)
8688
9087
  return this._textResult({ error: "Launch is not managed.", launch_id });
8689
9088
  return this._textResult(this._managedStatus(record2));
8690
9089
  }
8691
9090
  if (instance_id) {
8692
- const record2 = this.instanceManager.get(instance_id);
9091
+ const record2 = await this.instanceManager.get(instance_id);
8693
9092
  const connected2 = this.bridge.getPublicInstances().filter((instance) => instance.instanceId === instance_id);
8694
9093
  if (!record2 && connected2.length === 0) {
8695
9094
  return this._textResult({ error: "Instance is not connected or managed.", instance_id });
@@ -8706,7 +9105,7 @@ ${code}`
8706
9105
  });
8707
9106
  }
8708
9107
  return this._textResult({
8709
- managed: this.instanceManager.list().filter((record2) => record2.closedAt === void 0).map((record2) => this._managedStatus(record2)),
9108
+ managed: (await this.instanceManager.list()).filter((record2) => record2.closedAt === void 0).map((record2) => this._managedStatus(record2)),
8710
9109
  connected: this.bridge.getPublicInstances().map((instance) => ({
8711
9110
  instance_id: instance.instanceId,
8712
9111
  role: instance.role,
@@ -8718,11 +9117,11 @@ ${code}`
8718
9117
  if (action === "close") {
8719
9118
  let record2;
8720
9119
  if (launch_id) {
8721
- record2 = this.instanceManager.getByLaunchId(launch_id);
9120
+ record2 = await this.instanceManager.getByLaunchId(launch_id);
8722
9121
  if (!record2)
8723
9122
  return this._textResult({ error: "Launch is not managed.", launch_id });
8724
9123
  const connectedInstanceId = record2.instanceId;
8725
- const closeResult2 = record2.closedAt === void 0 ? this.instanceManager.close(record2) : { status: "already_closed" };
9124
+ const closeResult2 = record2.closedAt === void 0 ? await this.instanceManager.close(record2) : { status: "already_closed" };
8726
9125
  if (connectedInstanceId) {
8727
9126
  await this.bridge.unregisterInstanceIdEverywhere(connectedInstanceId);
8728
9127
  await sleep(500);
@@ -8730,19 +9129,21 @@ ${code}`
8730
9129
  }
8731
9130
  return this._textResult({
8732
9131
  ...this._managedStatus(record2),
9132
+ close_status: closeResult2.status,
8733
9133
  message: closeResult2.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
8734
9134
  });
8735
9135
  }
8736
9136
  if (instance_id) {
8737
- const recordBeforeClose = this.instanceManager.get(instance_id);
8738
- const managedClose = this.instanceManager.closeByInstanceId(instance_id);
9137
+ const recordBeforeClose = await this.instanceManager.get(instance_id);
9138
+ const managedClose = await this.instanceManager.closeByInstanceId(instance_id);
8739
9139
  if (managedClose.status !== "not_found") {
8740
9140
  await this.bridge.unregisterInstanceIdEverywhere(instance_id);
8741
9141
  await sleep(500);
8742
9142
  await this.bridge.unregisterInstanceIdEverywhere(instance_id);
8743
- const closedRecord = recordBeforeClose ?? (managedClose.launchId ? this.instanceManager.getByLaunchId(managedClose.launchId) : void 0);
9143
+ const closedRecord = managedClose.launchId ? await this.instanceManager.getByLaunchId(managedClose.launchId) : recordBeforeClose;
8744
9144
  return this._textResult({
8745
9145
  ...closedRecord ? this._managedStatus(closedRecord) : { instance_id },
9146
+ close_status: managedClose.status,
8746
9147
  message: managedClose.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
8747
9148
  });
8748
9149
  }
@@ -8755,7 +9156,7 @@ ${code}`
8755
9156
  });
8756
9157
  }
8757
9158
  try {
8758
- this.instanceManager.closeConnectedInstance(edit);
9159
+ await this.instanceManager.closeConnectedInstance(edit);
8759
9160
  await sleep(500);
8760
9161
  } catch (error) {
8761
9162
  return this._textResult({
@@ -8766,10 +9167,11 @@ ${code}`
8766
9167
  await this.bridge.unregisterInstanceIdEverywhere(instance_id);
8767
9168
  return this._textResult({
8768
9169
  instance_id,
9170
+ close_status: "closed",
8769
9171
  message: "Studio instance closed."
8770
9172
  });
8771
9173
  } else {
8772
- const active = this.instanceManager.list().filter((entry) => entry.closedAt === void 0);
9174
+ const active = (await this.instanceManager.list()).filter((entry) => entry.closedAt === void 0);
8773
9175
  if (active.length === 0) {
8774
9176
  return this._textResult({ message: "No managed Studio instances are active." });
8775
9177
  }
@@ -8783,13 +9185,14 @@ ${code}`
8783
9185
  }
8784
9186
  if (record2.instanceId)
8785
9187
  await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
8786
- const closeResult = this.instanceManager.close(record2);
9188
+ const closeResult = await this.instanceManager.close(record2);
8787
9189
  if (record2.instanceId) {
8788
9190
  await sleep(500);
8789
9191
  await this.bridge.unregisterInstanceIdEverywhere(record2.instanceId);
8790
9192
  }
8791
9193
  return this._textResult({
8792
9194
  ...this._managedStatus(record2),
9195
+ close_status: closeResult.status,
8793
9196
  message: closeResult.status === "already_closed" ? "Studio instance was already closed." : "Studio instance closed."
8794
9197
  });
8795
9198
  }
@@ -8801,7 +9204,15 @@ ${code}`
8801
9204
  const placeId = launchSource === "published_place" || launchSource === "place_revision" ? this._positiveInteger(request.place_id, "place_id") : void 0;
8802
9205
  const placeVersion = launchSource === "place_revision" ? this._positiveInteger(request.place_version, "place_version") : void 0;
8803
9206
  const localPlaceFile = typeof request.local_place_file === "string" ? request.local_place_file : void 0;
8804
- if (launchSource === "published_place" && placeId !== void 0 && this._isLatestPublishedPlaceOpen(placeId)) {
9207
+ let studioExecutable;
9208
+ if (request.studio_executable !== void 0) {
9209
+ if (typeof request.studio_executable !== "string" || request.studio_executable.length === 0) {
9210
+ throw new Error("studio_executable must be a non-empty string when provided.");
9211
+ }
9212
+ studioExecutable = request.studio_executable;
9213
+ }
9214
+ const processEnvironment = parseStudioProcessEnvironmentPatch(request.process_environment);
9215
+ if (launchSource === "published_place" && placeId !== void 0 && await this._isLatestPublishedPlaceOpen(placeId)) {
8805
9216
  return this._textResult({
8806
9217
  error: "Place is already open.",
8807
9218
  message: `place_id ${placeId} is already connected. Use the existing instance or launch a specific place_revision.`
@@ -8817,7 +9228,9 @@ ${code}`
8817
9228
  placeId,
8818
9229
  universeId,
8819
9230
  placeVersion,
8820
- connectionTimeoutMs: timeoutMs
9231
+ connectionTimeoutMs: timeoutMs,
9232
+ studioExecutable,
9233
+ processEnvironment
8821
9234
  });
8822
9235
  if (!waitForConnection) {
8823
9236
  return this._textResult({
@@ -8828,11 +9241,11 @@ ${code}`
8828
9241
  const connected = await this._waitForManagedEditConnection(record, beforeKeys, timeoutMs);
8829
9242
  if (!connected) {
8830
9243
  if (record.state === "launching") {
8831
- this.instanceManager.markFailed(record, "Studio launched, but the MCP plugin did not connect before timeout.");
9244
+ await this.instanceManager.markFailed(record, "Studio launched, but the MCP plugin did not connect before timeout.");
8832
9245
  }
8833
9246
  if (record.closedAt === void 0) {
8834
9247
  try {
8835
- this.instanceManager.close(record);
9248
+ await this.instanceManager.close(record);
8836
9249
  } catch {
8837
9250
  }
8838
9251
  }
@@ -8841,7 +9254,7 @@ ${code}`
8841
9254
  error: record.failureReason ?? "Studio launched, but the MCP plugin did not connect before timeout."
8842
9255
  });
8843
9256
  }
8844
- this.instanceManager.attachInstanceId(record, connected.instanceId);
9257
+ await this.instanceManager.attachInstanceId(record, connected.instanceId);
8845
9258
  return this._textResult({
8846
9259
  ...this._managedStatus(record),
8847
9260
  message: launchSource === "place_revision" ? `Studio opened place revision ${placeVersion}.` : "Studio opened."
@@ -11874,6 +12287,35 @@ var init_definitions = __esm({
11874
12287
  type: "number",
11875
12288
  description: 'For action="launch": max milliseconds for plugin connection (default 120000). The deadline also applies asynchronously when wait_for_connection=false.'
11876
12289
  },
12290
+ studio_executable: {
12291
+ type: "string",
12292
+ description: 'For action="launch": exact Roblox Studio executable to launch instead of auto-discovering a version.'
12293
+ },
12294
+ process_environment: {
12295
+ type: "object",
12296
+ description: 'For action="launch": process-scoped environment patch applied only while creating Studio. Values are never retained in the managed-instance registry.',
12297
+ properties: {
12298
+ set: {
12299
+ type: "object",
12300
+ description: "Environment variables to set for the Studio process.",
12301
+ propertyNames: {
12302
+ pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
12303
+ },
12304
+ additionalProperties: {
12305
+ type: "string"
12306
+ }
12307
+ },
12308
+ remove: {
12309
+ type: "array",
12310
+ description: "Environment variables to remove from the Studio process environment.",
12311
+ items: {
12312
+ type: "string",
12313
+ pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
12314
+ }
12315
+ }
12316
+ },
12317
+ additionalProperties: false
12318
+ },
11877
12319
  max_page_size: {
11878
12320
  type: "number",
11879
12321
  description: 'For action="list_place_versions": number of versions to return, clamped to 1-50 (default 10).'
@@ -12243,7 +12685,7 @@ var init_definitions = __esm({
12243
12685
  {
12244
12686
  name: "get_runtime_logs",
12245
12687
  category: "read",
12246
- description: "Read the in-memory log buffers captured by Studio plugin peers. Each buffer captures ~64 KB of recent LogService output; runtime peers seed from LogService:GetLogHistory() at plugin load so early startup logs emitted before the plugin finishes loading can still be returned, then continue capturing LogService.MessageOut entries. Oldest entries drop when over budget. Entries include capturedBy for the plugin buffer that observed the log. In ordinary Studio play/run sessions, LogService reflects logs across edit/server/client, so script-origin peer is not reliable and entries omit peer. In StudioTestService multiplayer sessions only, peer attribution is reliable and entries also include peer. target=all (default) merges buffers and dedups same-message-and-level entries captured within 2s across different buffers.",
12688
+ description: "Read the in-memory log buffers captured by Studio plugin peers. Each buffer captures ~64 KB of recent LogService output; runtime peers seed from LogService:GetLogHistory() at plugin load so early startup logs emitted before the plugin finishes loading can still be returned, then continue capturing LogService.MessageOut entries. Live structured LogService entries include their context dictionary as optional data; Roblox GetLogHistory does not expose context for entries seeded at plugin load. Oldest entries drop when over budget. Entries include capturedBy for the plugin buffer that observed the log. In ordinary Studio play/run sessions, LogService reflects logs across edit/server/client, so script-origin peer is not reliable and entries omit peer. In StudioTestService multiplayer sessions only, peer attribution is reliable and entries also include peer. target=all (default) merges buffers and dedups same-message-and-level entries captured within 2s across different buffers.",
12247
12689
  inputSchema: {
12248
12690
  type: "object",
12249
12691
  properties: {
@@ -13670,7 +14112,7 @@ part(0,2,0,2,1,1,"b")`,
13670
14112
  });
13671
14113
 
13672
14114
  // ../core/dist/install-plugin-helpers.js
13673
- import { existsSync as existsSync6, readFileSync as readFileSync7, unlinkSync } from "fs";
14115
+ import { existsSync as existsSync6, readFileSync as readFileSync6, unlinkSync } from "fs";
13674
14116
  import { execSync } from "child_process";
13675
14117
  import { join as join6 } from "path";
13676
14118
  import { homedir as homedir5 } from "os";
@@ -13678,7 +14120,7 @@ function isWSL() {
13678
14120
  if (process.platform !== "linux")
13679
14121
  return false;
13680
14122
  try {
13681
- const v = readFileSync7("/proc/version", "utf8");
14123
+ const v = readFileSync6("/proc/version", "utf8");
13682
14124
  return /microsoft|wsl/i.test(v);
13683
14125
  } catch {
13684
14126
  return false;
@@ -13763,7 +14205,7 @@ __export(install_plugin_exports, {
13763
14205
  installBundledPlugin: () => installBundledPlugin,
13764
14206
  installPlugin: () => installPlugin
13765
14207
  });
13766
- import { copyFileSync as copyFileSync2, createWriteStream, existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync8, unlinkSync as unlinkSync2 } from "fs";
14208
+ import { copyFileSync as copyFileSync2, createWriteStream, existsSync as existsSync7, mkdirSync as mkdirSync4, readFileSync as readFileSync7, unlinkSync as unlinkSync2 } from "fs";
13767
14209
  import { dirname as dirname5, join as join7 } from "path";
13768
14210
  import { fileURLToPath } from "url";
13769
14211
  import { get } from "https";
@@ -13825,7 +14267,7 @@ function prepareInstall({
13825
14267
  }) {
13826
14268
  const pluginsFolder = getPluginsFolder();
13827
14269
  if (!existsSync7(pluginsFolder)) {
13828
- mkdirSync5(pluginsFolder, { recursive: true });
14270
+ mkdirSync4(pluginsFolder, { recursive: true });
13829
14271
  }
13830
14272
  handleVariantConflict({
13831
14273
  pluginsFolder,
@@ -13846,14 +14288,14 @@ function bundledAssetPath() {
13846
14288
  }
13847
14289
  function packageVersion() {
13848
14290
  const currentDir = dirname5(fileURLToPath(import.meta.url));
13849
- const pkg = JSON.parse(readFileSync8(join7(currentDir, "..", "package.json"), "utf8"));
14291
+ const pkg = JSON.parse(readFileSync7(join7(currentDir, "..", "package.json"), "utf8"));
13850
14292
  if (!pkg.version) {
13851
14293
  throw new Error("Package version not found");
13852
14294
  }
13853
14295
  return pkg.version;
13854
14296
  }
13855
14297
  function bundledPluginVersion(source) {
13856
- const match = readFileSync8(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
14298
+ const match = readFileSync7(source, "utf8").match(/local CURRENT_VERSION = "([^"]+)"/);
13857
14299
  return match ? match[1] : null;
13858
14300
  }
13859
14301
  function assertBundledPluginVersion(source) {
@@ -13867,8 +14309,8 @@ function assertBundledPluginVersion(source) {
13867
14309
  }
13868
14310
  function filesMatch(a, b) {
13869
14311
  if (!existsSync7(b)) return false;
13870
- const aBytes = readFileSync8(a);
13871
- const bBytes = readFileSync8(b);
14312
+ const aBytes = readFileSync7(a);
14313
+ const bBytes = readFileSync7(b);
13872
14314
  return aBytes.length === bBytes.length && aBytes.equals(bBytes);
13873
14315
  }
13874
14316
  async function installBundledPlugin(options = {}) {