@muggleai/works 5.31.0-staging.270 → 5.31.0-staging.271

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.
@@ -9,7 +9,7 @@
9
9
  "name": "muggleai",
10
10
  "source": "./plugin",
11
11
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
12
- "version": "5.31.0-staging.270"
12
+ "version": "5.31.0-staging.271"
13
13
  }
14
14
  ]
15
15
  }
@@ -2830,6 +2830,13 @@ var ClaudeTurnStatus = /* @__PURE__ */ ((ClaudeTurnStatus2) => {
2830
2830
  ClaudeTurnStatus2["Waiting"] = "waiting";
2831
2831
  return ClaudeTurnStatus2;
2832
2832
  })(ClaudeTurnStatus || {});
2833
+ var ClaudeProcessStatus = /* @__PURE__ */ ((ClaudeProcessStatus2) => {
2834
+ ClaudeProcessStatus2["Idle"] = "idle";
2835
+ ClaudeProcessStatus2["Busy"] = "busy";
2836
+ ClaudeProcessStatus2["Shell"] = "shell";
2837
+ ClaudeProcessStatus2["Waiting"] = "waiting";
2838
+ return ClaudeProcessStatus2;
2839
+ })(ClaudeProcessStatus || {});
2833
2840
  var ClaudeJobState = /* @__PURE__ */ ((ClaudeJobState2) => {
2834
2841
  ClaudeJobState2["Working"] = "working";
2835
2842
  ClaudeJobState2["Blocked"] = "blocked";
@@ -2932,6 +2939,12 @@ var CLAUDE_SESSION_REGISTRY_DIR_NAME = "sessions";
2932
2939
  var CLAUDE_PROJECTS_DIR_NAME = "projects";
2933
2940
  var CLAUDE_INSTALLED_PLUGINS_RELATIVE_PATH = ["plugins", "installed_plugins.json"];
2934
2941
  var CLAUDE_GLOBAL_CONFIG_FILE_NAME = ".claude.json";
2942
+ var CLAUDE_SETTINGS_FILE_NAME = "settings.json";
2943
+ var CLAUDE_CREDENTIALS_FILE_NAME = ".credentials.json";
2944
+ var REMOTE_CONTROL_MAX_RESTARTS = 2;
2945
+ var REMOTE_CONTROL_RESTART_WINDOW_MS = 24 * 60 * 6e4;
2946
+ var REMOTE_CONTROL_RESTART_COOLDOWN_MS = 30 * 6e4;
2947
+ var REMOTE_CONTROL_NAME_COLUMN_WIDTH = 32;
2935
2948
  var USAGE_WINDOW_EXHAUSTED_PERCENT = 100;
2936
2949
  var RELOAD_PLUGIN_SCOPE = "user";
2937
2950
  var MUGGLE_PLUGIN_ID = "muggleai@muggle-works";
@@ -2987,6 +3000,8 @@ function resolveWatchPaths(env = process.env) {
2987
3000
  claudeProjectsDir: join(claudeConfigDir, CLAUDE_PROJECTS_DIR_NAME),
2988
3001
  claudeInstalledPluginsFile: join(claudeConfigDir, ...CLAUDE_INSTALLED_PLUGINS_RELATIVE_PATH),
2989
3002
  claudeGlobalConfigFile: join(env.CLAUDE_CONFIG_DIR || homedir(), CLAUDE_GLOBAL_CONFIG_FILE_NAME),
3003
+ claudeSettingsFile: join(claudeConfigDir, CLAUDE_SETTINGS_FILE_NAME),
3004
+ claudeCredentialsFile: join(claudeConfigDir, CLAUDE_CREDENTIALS_FILE_NAME),
2990
3005
  codexSessionsDir: join(codexHome, "sessions"),
2991
3006
  muggleInstallLogFile: join(muggleDataDir, MUGGLE_INSTALL_LOG_FILE)
2992
3007
  };
@@ -4841,6 +4856,9 @@ function processSummary(snapshot) {
4841
4856
  `${countOf("pr-watch-loop" /* PrWatchLoop */)} PR watch loop processes`
4842
4857
  ].join(", ");
4843
4858
  }
4859
+ function countDecisions(report, action) {
4860
+ return report.decisions.filter((decision) => decision.action === action).length;
4861
+ }
4844
4862
  function renderWatchStatus(snapshot, nowMs) {
4845
4863
  const lines = [];
4846
4864
  const snapshotAgeMs = nowMs - Date.parse(snapshot.takenAt);
@@ -4883,12 +4901,24 @@ function renderWatchStatus(snapshot, nowMs) {
4883
4901
  }
4884
4902
  }
4885
4903
  if (snapshot.reload) {
4886
- const countOf = (action) => snapshot.reload?.decisions.filter((decision) => decision.action === action).length ?? 0;
4904
+ const reload = snapshot.reload;
4887
4905
  lines.push(
4888
4906
  "",
4889
- `Plugin reload: ${snapshot.reload.respawnedSessionIds.length} restarted, ${snapshot.reload.failedSessionIds.length} failed, ${countOf("defer" /* Defer */)} deferred, ${countOf("notify-manual" /* NotifyManual */) + (snapshot.reload.untrustedSessionIds?.length ?? 0)} need /reload-plugins by hand`
4907
+ `Plugin reload: ${reload.respawnedSessionIds.length} restarted, ${reload.failedSessionIds.length} failed, ${countDecisions(reload, "defer" /* Defer */)} deferred, ${countDecisions(reload, "notify-manual" /* NotifyManual */) + (reload.untrustedSessionIds?.length ?? 0)} need /reload-plugins by hand`
4890
4908
  );
4891
4909
  }
4910
+ if (snapshot.remoteControl) {
4911
+ const remoteControl = snapshot.remoteControl;
4912
+ lines.push(
4913
+ "",
4914
+ `Remote Control: ${remoteControl.connectedSessionIds.length} connected, ${remoteControl.respawnedSessionIds.length} restarted, ${remoteControl.failedSessionIds.length} failed, ${remoteControl.untrustedSessionIds.length} refused, ${countDecisions(remoteControl, "defer" /* Defer */)} waiting, ${countDecisions(remoteControl, "skip" /* Skip */) + countDecisions(remoteControl, "notify-manual" /* NotifyManual */)} skipped`
4915
+ );
4916
+ for (const decision of remoteControl.decisions) {
4917
+ const outcome = remoteControl.untrustedSessionIds.includes(decision.sessionId) ? " \u2014 refused: its workspace takes no background restart, run /remote-control in it" : remoteControl.failedSessionIds.includes(decision.sessionId) ? " \u2014 restart failed" : "";
4918
+ const label = (decision.name ?? "").slice(0, REMOTE_CONTROL_NAME_COLUMN_WIDTH).padEnd(REMOTE_CONTROL_NAME_COLUMN_WIDTH);
4919
+ lines.push(` ${decision.action.padEnd(14)} ${shortSessionId(decision.sessionId)} ${label} ${decision.reason}${outcome}`);
4920
+ }
4921
+ }
4892
4922
  return `${lines.join("\n")}
4893
4923
  `;
4894
4924
  }
@@ -4921,6 +4951,32 @@ function cwdFromSessionHead(head) {
4921
4951
  }
4922
4952
  }
4923
4953
 
4954
+ // src/watch/claude-account.ts
4955
+ function isRemoteControlAtStartup(claudeSettingsFile) {
4956
+ try {
4957
+ const settings = JSON.parse(readFileSync(claudeSettingsFile, "utf-8"));
4958
+ return settings.remoteControlAtStartup === true;
4959
+ } catch {
4960
+ return false;
4961
+ }
4962
+ }
4963
+ function readClaudeLogin(claudeGlobalConfigFile, claudeCredentialsFile) {
4964
+ let accountUuid;
4965
+ try {
4966
+ const config = JSON.parse(readFileSync(claudeGlobalConfigFile, "utf-8"));
4967
+ accountUuid = optionalString(config.oauthAccount?.accountUuid);
4968
+ } catch {
4969
+ accountUuid = void 0;
4970
+ }
4971
+ let credentialsWrittenAtMs;
4972
+ try {
4973
+ credentialsWrittenAtMs = statSync(claudeCredentialsFile).mtimeMs;
4974
+ } catch {
4975
+ credentialsWrittenAtMs = void 0;
4976
+ }
4977
+ return { accountUuid, credentialsWrittenAtMs };
4978
+ }
4979
+
4924
4980
  // src/watch/session-liveness.ts
4925
4981
  function hasLiveProcess(session) {
4926
4982
  if (session.agent !== "claude" /* Claude */) return true;
@@ -4931,25 +4987,30 @@ function isIdleBackgroundSession(session) {
4931
4987
  }
4932
4988
 
4933
4989
  // src/watch/claude-sessions.ts
4934
- function readPidsBySessionId(registryDir) {
4935
- const pidsBySessionId = /* @__PURE__ */ new Map();
4990
+ function readRegistryBySessionId(registryDir) {
4991
+ const entriesBySessionId = /* @__PURE__ */ new Map();
4936
4992
  let fileNames;
4937
4993
  try {
4938
4994
  fileNames = readdirSync(registryDir).filter((fileName) => fileName.endsWith(".json"));
4939
4995
  } catch {
4940
- return pidsBySessionId;
4996
+ return entriesBySessionId;
4941
4997
  }
4942
4998
  for (const fileName of fileNames) {
4943
4999
  try {
4944
5000
  const entry = JSON.parse(readFileSync(join(registryDir, fileName), "utf-8"));
4945
5001
  if (typeof entry.pid === "number" && typeof entry.sessionId === "string") {
4946
- pidsBySessionId.set(entry.sessionId, entry.pid);
5002
+ entriesBySessionId.set(entry.sessionId, {
5003
+ pid: entry.pid,
5004
+ processStatus: enumValueOf(ClaudeProcessStatus, entry.status),
5005
+ processStatusSinceMs: typeof entry.statusUpdatedAt === "number" ? entry.statusUpdatedAt : void 0,
5006
+ remoteControlSessionId: optionalString(entry.bridgeSessionId)
5007
+ });
4947
5008
  }
4948
5009
  } catch {
4949
5010
  continue;
4950
5011
  }
4951
5012
  }
4952
- return pidsBySessionId;
5013
+ return entriesBySessionId;
4953
5014
  }
4954
5015
  function waitForSessionExit(runCommand, registryDir, sessionId, sleep2 = blockFor) {
4955
5016
  for (let waitedMs = 0; ; waitedMs += SESSION_EXIT_POLL_MS) {
@@ -4971,11 +5032,12 @@ function readClaudeSessions(runCommand, registryDir) {
4971
5032
  } catch {
4972
5033
  return null;
4973
5034
  }
4974
- const pidsBySessionId = readPidsBySessionId(registryDir);
5035
+ const registryBySessionId = readRegistryBySessionId(registryDir);
4975
5036
  const sessions = [];
4976
5037
  for (const listing of listings) {
4977
5038
  const sessionId = optionalString(listing.sessionId);
4978
5039
  if (!sessionId) continue;
5040
+ const registryEntry = registryBySessionId.get(sessionId);
4979
5041
  sessions.push({
4980
5042
  agent: "claude" /* Claude */,
4981
5043
  sessionId,
@@ -4986,7 +5048,10 @@ function readClaudeSessions(runCommand, registryDir) {
4986
5048
  turnStatus: enumValueOf(ClaudeTurnStatus, listing.status),
4987
5049
  jobState: enumValueOf(ClaudeJobState, listing.state),
4988
5050
  startedAtMs: typeof listing.startedAt === "number" ? listing.startedAt : void 0,
4989
- pid: typeof listing.pid === "number" ? listing.pid : pidsBySessionId.get(sessionId)
5051
+ pid: typeof listing.pid === "number" ? listing.pid : registryEntry?.pid,
5052
+ processStatus: registryEntry?.processStatus,
5053
+ processStatusSinceMs: registryEntry?.processStatusSinceMs,
5054
+ remoteControlSessionId: registryEntry?.remoteControlSessionId
4990
5055
  });
4991
5056
  }
4992
5057
  return sessions;
@@ -5381,26 +5446,29 @@ function executeWatcherRecoveries(planned, attemptsByOwner, deps) {
5381
5446
  }
5382
5447
 
5383
5448
  // src/watch/findings.ts
5384
- function resolveWatcherLiveness(loop, heartbeatAgeMs, isOwnerSessionActive, isProcessTableKnown, isHeardByService = false) {
5449
+ function resolveWatcherLiveness(loop, heartbeatAgeMs, isOwnerSessionActive, isProcessTableKnown, isHeardByService = false, isLoopDroppedByOwner = false) {
5385
5450
  const isHeartbeatWithin = (windowMs) => heartbeatAgeMs !== void 0 && heartbeatAgeMs <= windowMs;
5386
5451
  if (!isProcessTableKnown) {
5387
5452
  return isHeartbeatWithin(WATCH_HEARTBEAT_STALE_MS) ? "polling" /* Polling */ : "dead" /* Dead */;
5388
5453
  }
5389
5454
  if (loop) {
5390
- return isOwnerSessionActive === false && !isHeardByService ? "deaf" /* Deaf */ : "polling" /* Polling */;
5455
+ const isUnheard = !isHeardByService && (isOwnerSessionActive === false || isLoopDroppedByOwner);
5456
+ return isUnheard ? "deaf" /* Deaf */ : "polling" /* Polling */;
5391
5457
  }
5392
5458
  return isHeartbeatWithin(WATCHER_REARM_GRACE_MS) ? "rearming" /* Rearming */ : "dead" /* Dead */;
5393
5459
  }
5394
5460
  function oldestLoopFor(slug, processes) {
5395
5461
  return processes.filter((tracked) => tracked.kind === "pr-watch-loop" /* PrWatchLoop */ && tracked.watchSlotSlug === slug).sort((left, right) => (right.ageMs ?? 0) - (left.ageMs ?? 0))[0];
5396
5462
  }
5397
- function assessWatchSlots(slots, processes, activeSessionIds, liveStatusesByUrl, nowMs) {
5463
+ function assessWatchSlots(slots, processes, activeSessionIds, liveStatusesByUrl, nowMs, idleSinceBySessionId2 = /* @__PURE__ */ new Map()) {
5398
5464
  const assessments = [];
5399
5465
  for (const slot of slots) {
5400
5466
  const loop = processes ? oldestLoopFor(slot.slug, processes) : void 0;
5401
5467
  const heartbeatAgeMs = slot.heartbeatMtimeMs === void 0 ? void 0 : Math.max(0, nowMs - slot.heartbeatMtimeMs);
5402
5468
  const isOwnerSessionActive = slot.ownerSessionId === void 0 || activeSessionIds === null ? void 0 : activeSessionIds.has(slot.ownerSessionId);
5403
- const watcher = resolveWatcherLiveness(loop, heartbeatAgeMs, isOwnerSessionActive, processes !== null, slot.isHeardByService);
5469
+ const ownerIdleSinceMs = slot.ownerSessionId === void 0 ? void 0 : idleSinceBySessionId2.get(slot.ownerSessionId);
5470
+ const isLoopDroppedByOwner = ownerIdleSinceMs !== void 0 && loop?.ageMs !== void 0 && ownerIdleSinceMs >= nowMs - loop.ageMs;
5471
+ const watcher = resolveWatcherLiveness(loop, heartbeatAgeMs, isOwnerSessionActive, processes !== null, slot.isHeardByService, isLoopDroppedByOwner);
5404
5472
  const reportAgeMs = slot.serviceLoopReport === void 0 ? void 0 : Math.max(0, nowMs - slot.serviceLoopReport.writtenAtMs);
5405
5473
  for (const pr of slot.openPrs) {
5406
5474
  const live = liveStatusesByUrl.get(pr.url);
@@ -5415,6 +5483,7 @@ function assessWatchSlots(slots, processes, activeSessionIds, liveStatusesByUrl,
5415
5483
  heartbeatAgeMs,
5416
5484
  ownerSessionId: slot.ownerSessionId,
5417
5485
  isOwnerSessionActive,
5486
+ isLoopDroppedByOwner,
5418
5487
  live,
5419
5488
  ownerEscalation: resolveOwnerEscalation(slot.lastSeenJson, slot.followupLogLines, pr, live?.headSha),
5420
5489
  isHeardByService: slot.isHeardByService,
@@ -5461,11 +5530,12 @@ function watcherFinding(assessment, subject) {
5461
5530
  );
5462
5531
  }
5463
5532
  if (assessment.watcher === "deaf" /* Deaf */) {
5533
+ const ownerState = assessment.isLoopDroppedByOwner ? "is running but no longer holds it" : "has ended";
5464
5534
  return finding(
5465
5535
  "WATCHER_DEAF" /* WatcherDeaf */,
5466
5536
  [assessment.pr.url, assessment.loopPid],
5467
5537
  subject,
5468
- `watch loop pid ${assessment.loopPid} reports to no session: its owner session ${shortSessionId(assessment.ownerSessionId ?? "")} has ended`
5538
+ `watch loop pid ${assessment.loopPid} reports to no session: its owner session ${shortSessionId(assessment.ownerSessionId ?? "")} ${ownerState}`
5469
5539
  );
5470
5540
  }
5471
5541
  return void 0;
@@ -5906,6 +5976,75 @@ function advancePendingReload(pending, report) {
5906
5976
  skippedSessionIds: [...pending.skippedSessionIds, ...report.untrustedSessionIds]
5907
5977
  };
5908
5978
  }
5979
+
5980
+ // src/watch/remote-control.ts
5981
+ function advanceClaudeLogin(previous, observed, nowMs) {
5982
+ if (observed.accountUuid === void 0) return previous;
5983
+ if (previous === void 0) return { accountUuid: observed.accountUuid, observedAtMs: nowMs };
5984
+ if (previous.accountUuid === observed.accountUuid) return { ...previous, observedAtMs: nowMs };
5985
+ const writtenAtMs = Math.min(observed.credentialsWrittenAtMs ?? nowMs, nowMs);
5986
+ return { accountUuid: observed.accountUuid, effectiveAtMs: Math.max(previous.observedAtMs, writtenAtMs), observedAtMs: nowMs };
5987
+ }
5988
+ function remoteControlGapOf(session, login) {
5989
+ if (session.remoteControlSessionId === void 0) return "no Remote Control link";
5990
+ const isStartedBeforeLogin = login.effectiveAtMs !== void 0 && session.startedAtMs !== void 0 && session.startedAtMs < login.effectiveAtMs;
5991
+ return isStartedBeforeLogin ? "linked under an earlier login" : void 0;
5992
+ }
5993
+ function isTurnOver(session) {
5994
+ if (session.processStatus !== void 0) {
5995
+ return session.processStatus === "idle" /* Idle */ || session.processStatus === "shell" /* Shell */;
5996
+ }
5997
+ return session.turnStatus === "idle" /* Idle */;
5998
+ }
5999
+ function recentRestartsMs(restartsBySession, sessionId, nowMs) {
6000
+ return (restartsBySession[sessionId] ?? []).map((restartedAt) => Date.parse(restartedAt)).filter((restartedAtMs) => !Number.isNaN(restartedAtMs) && nowMs - restartedAtMs < REMOTE_CONTROL_RESTART_WINDOW_MS);
6001
+ }
6002
+ function decide2(session, action, reason) {
6003
+ return { sessionId: session.sessionId, shortId: session.shortId, name: session.name, cwd: session.cwd, action, reason };
6004
+ }
6005
+ function planRemoteControlRestarts(sessions, login, restartsBySession, nowMs, maxRestarts) {
6006
+ let restartsPlanned = 0;
6007
+ const connectedSessionIds = [];
6008
+ const decisions = [];
6009
+ for (const session of sessions) {
6010
+ if (session.agent !== "claude" /* Claude */ || !hasLiveProcess(session)) continue;
6011
+ const gap = remoteControlGapOf(session, login);
6012
+ if (gap === void 0) {
6013
+ connectedSessionIds.push(session.sessionId);
6014
+ continue;
6015
+ }
6016
+ const restartsMs = recentRestartsMs(restartsBySession, session.sessionId, nowMs);
6017
+ if (session.kind === "interactive" /* Interactive */) {
6018
+ decisions.push(decide2(session, "notify-manual" /* NotifyManual */, `${gap} \u2014 terminal session, run /remote-control in it`));
6019
+ } else if (session.kind !== "background" /* Background */ || !session.shortId) {
6020
+ decisions.push(decide2(session, "skip" /* Skip */, `${gap} \u2014 not a background session the watch can restart`));
6021
+ } else if (restartsMs.length >= REMOTE_CONTROL_MAX_RESTARTS) {
6022
+ decisions.push(decide2(session, "skip" /* Skip */, `${gap} after ${restartsMs.length} restarts today`));
6023
+ } else if (nowMs - Math.max(-Infinity, ...restartsMs) < REMOTE_CONTROL_RESTART_COOLDOWN_MS) {
6024
+ decisions.push(decide2(session, "skip" /* Skip */, `${gap} \u2014 restarted recently, waiting for it to link`));
6025
+ } else if (!isTurnOver(session)) {
6026
+ decisions.push(decide2(session, "defer" /* Defer */, `${gap} \u2014 turn in flight (${session.processStatus ?? session.turnStatus ?? "unknown"})`));
6027
+ } else if (restartsPlanned >= maxRestarts) {
6028
+ decisions.push(decide2(session, "defer" /* Defer */, `${gap} \u2014 restart cap reached this pass`));
6029
+ } else {
6030
+ restartsPlanned++;
6031
+ decisions.push(decide2(session, "respawn" /* Respawn */, gap));
6032
+ }
6033
+ }
6034
+ return { connectedSessionIds, decisions };
6035
+ }
6036
+ function recordRemoteControlRestarts(restartsBySession, report, nowMs) {
6037
+ const nowIso = new Date(nowMs).toISOString();
6038
+ const nextRestartsBySession = {};
6039
+ for (const sessionId of Object.keys(restartsBySession)) {
6040
+ const recent = recentRestartsMs(restartsBySession, sessionId, nowMs).map((restartedAtMs) => new Date(restartedAtMs).toISOString());
6041
+ if (recent.length > 0) nextRestartsBySession[sessionId] = recent;
6042
+ }
6043
+ for (const sessionId of [...report.respawnedSessionIds, ...report.failedSessionIds, ...report.untrustedSessionIds]) {
6044
+ nextRestartsBySession[sessionId] = [...nextRestartsBySession[sessionId] ?? [], nowIso];
6045
+ }
6046
+ return nextRestartsBySession;
6047
+ }
5909
6048
  function resolvePluginRoot(env = process.env) {
5910
6049
  return env.CLAUDE_PLUGIN_ROOT || env.CURSOR_PLUGIN_ROOT || dirname(dirname(fileURLToPath(import.meta.url)));
5911
6050
  }
@@ -6273,6 +6412,7 @@ function createDefaultWatchTickDeps(options) {
6273
6412
  notify: createDesktopNotifier(process.platform, defaultCommandRunner),
6274
6413
  isAutoReloadEnabled: options.isAutoReloadEnabled,
6275
6414
  isRecoveryEnabled: options.isRecoveryEnabled,
6415
+ isRemoteControlKeptOn: options.isRemoteControlKeptOn,
6276
6416
  launchServiceLoop: createServiceLoopLauncher(process.platform, defaultCommandRunner)
6277
6417
  };
6278
6418
  }
@@ -6292,18 +6432,26 @@ function observeSessions(deps, installs) {
6292
6432
  const sessions = [...claudeSessions ?? [], ...readCodexSessions(deps.paths.codexSessionsDir, deps.nowMs)];
6293
6433
  return { processes, claudeSessions, sessions };
6294
6434
  }
6435
+ function idleSinceBySessionId(runningSessions) {
6436
+ return new Map(
6437
+ runningSessions.flatMap(
6438
+ (session) => session.processStatus === "idle" /* Idle */ && session.processStatusSinceMs !== void 0 ? [[session.sessionId, session.processStatusSinceMs]] : []
6439
+ )
6440
+ );
6441
+ }
6295
6442
  function isRestartAttempted(recovery) {
6296
6443
  return recovery.outcome === "resumed" /* Resumed */ || recovery.outcome === "resume-failed" /* ResumeFailed */;
6297
6444
  }
6298
6445
  function watchOwnersOf(slots) {
6299
6446
  return new Set(slots.flatMap((slot) => !isTerminalPrState(slot.live?.state) && slot.ownerSessionId ? [slot.ownerSessionId] : []));
6300
6447
  }
6301
- function reloadEvents(report, at) {
6448
+ function reloadEvents(report, at, restartPurpose) {
6302
6449
  const decisionById = new Map(report.decisions.map((decision) => [decision.sessionId, decision]));
6303
6450
  const describe = (sessionId) => `session ${decisionById.get(sessionId)?.name ?? shortSessionId(sessionId)}`;
6451
+ const purposeOf = (sessionId) => restartPurpose === void 0 ? {} : { message: `${restartPurpose}: ${decisionById.get(sessionId)?.reason ?? ""}` };
6304
6452
  return [
6305
- ...report.respawnedSessionIds.map((sessionId) => ({ at, kind: "respawned" /* Respawned */, subject: describe(sessionId) })),
6306
- ...report.failedSessionIds.map((sessionId) => ({ at, kind: "respawn-failed" /* RespawnFailed */, subject: describe(sessionId) })),
6453
+ ...report.respawnedSessionIds.map((sessionId) => ({ at, kind: "respawned" /* Respawned */, subject: describe(sessionId), ...purposeOf(sessionId) })),
6454
+ ...report.failedSessionIds.map((sessionId) => ({ at, kind: "respawn-failed" /* RespawnFailed */, subject: describe(sessionId), ...purposeOf(sessionId) })),
6307
6455
  ...report.untrustedSessionIds.map((sessionId) => ({
6308
6456
  at,
6309
6457
  kind: "restart-refused" /* RestartRefused */,
@@ -6321,6 +6469,15 @@ function runReloadPass(record, claudeSessions, watchOwnerSessionIds, deps, at) {
6321
6469
  const report = executeReloadFanOut(decisions, deps.runCommand, canRestartVia(deps));
6322
6470
  return { report, pendingReload: advancePendingReload(record, report), events: reloadEvents(report, at) };
6323
6471
  }
6472
+ function runRemoteControlPass(login, claudeSessions, restartsBySession, maxRestarts, deps, at) {
6473
+ const plan = planRemoteControlRestarts(claudeSessions, login, restartsBySession, deps.nowMs, maxRestarts);
6474
+ const executed = executeReloadFanOut(plan.decisions, deps.runCommand, canRestartVia(deps));
6475
+ return {
6476
+ report: { ...executed, connectedSessionIds: plan.connectedSessionIds },
6477
+ restartsBySession: recordRemoteControlRestarts(restartsBySession, executed, deps.nowMs),
6478
+ events: reloadEvents(executed, at, "restarted to reconnect Remote Control")
6479
+ };
6480
+ }
6324
6481
  function canRestartVia(deps) {
6325
6482
  return (cwd) => canRestartSessionIn(cwd, deps.runCommand);
6326
6483
  }
@@ -6366,7 +6523,7 @@ function runTickUnlocked(deps) {
6366
6523
  at
6367
6524
  );
6368
6525
  for (const cachedStatus of cachedStatuses) liveStatusesByUrl.set(cachedStatus.url, cachedStatus);
6369
- const allAssessments = assessWatchSlots(slots, processes, activeSessionIds, liveStatusesByUrl, deps.nowMs);
6526
+ const allAssessments = assessWatchSlots(slots, processes, activeSessionIds, liveStatusesByUrl, deps.nowMs, idleSinceBySessionId(runningSessions));
6370
6527
  const closeOut = closeOutTerminalSlots(allAssessments, deps, at);
6371
6528
  const assessments = allAssessments.filter((assessment) => !closeOut.closedOutSlugs.includes(assessment.slug));
6372
6529
  const findings = deriveWatchFindings(assessments, processes ?? [], isPollingDisabled);
@@ -6409,16 +6566,34 @@ function runTickUnlocked(deps) {
6409
6566
  serviceArms = started.serviceArms;
6410
6567
  events.push(...started.events);
6411
6568
  }
6569
+ const restartedSessionIds = new Set(recoveries.filter(isRestartAttempted).map((recovery) => recovery.ownerSessionId));
6412
6570
  let pendingReload = state.pendingReload;
6413
6571
  let reload;
6414
6572
  const targetAtMs = reloadTargetAtMs(deps, installs);
6415
6573
  if (deps.isAutoReloadEnabled && targetAtMs !== void 0 && claudeSessions !== null) {
6416
- const restartedOwnerIds = new Set(recoveries.filter(isRestartAttempted).map((recovery) => recovery.ownerSessionId));
6417
- const reloadableSessions = claudeSessions.filter((session) => !restartedOwnerIds.has(session.sessionId));
6574
+ const reloadableSessions = claudeSessions.filter((session) => !restartedSessionIds.has(session.sessionId));
6418
6575
  const pass = runReloadPass(reloadRecordFor(state, targetAtMs), reloadableSessions, watchOwnersOf(assessments), deps, at);
6419
6576
  pendingReload = pass.pendingReload;
6420
6577
  reload = pass.report;
6421
6578
  events.push(...pass.events);
6579
+ for (const sessionId of [...reload.respawnedSessionIds, ...reload.failedSessionIds]) restartedSessionIds.add(sessionId);
6580
+ }
6581
+ const observedLogin = readClaudeLogin(deps.paths.claudeGlobalConfigFile, deps.paths.claudeCredentialsFile);
6582
+ const claudeLogin = advanceClaudeLogin(state.claudeLogin, observedLogin, deps.nowMs);
6583
+ let remoteControlRestartsBySession = state.remoteControlRestartsBySession ?? {};
6584
+ let remoteControl;
6585
+ if (deps.isRemoteControlKeptOn && claudeSessions !== null && claudeLogin !== void 0 && observedLogin.accountUuid !== void 0 && isRemoteControlAtStartup(deps.paths.claudeSettingsFile)) {
6586
+ const pass = runRemoteControlPass(
6587
+ claudeLogin,
6588
+ claudeSessions.filter((session) => !restartedSessionIds.has(session.sessionId)),
6589
+ remoteControlRestartsBySession,
6590
+ Math.max(0, MAX_RESPAWNS_PER_TICK - (reload ? reload.respawnedSessionIds.length + reload.failedSessionIds.length : 0)),
6591
+ deps,
6592
+ at
6593
+ );
6594
+ remoteControl = pass.report;
6595
+ remoteControlRestartsBySession = pass.restartsBySession;
6596
+ events.push(...pass.events);
6422
6597
  }
6423
6598
  appendWatchEvents(deps.paths.watchDir, events);
6424
6599
  notifyIfAny(deps, composeTickNotification(transitions.raised, recoveries, reload));
@@ -6426,7 +6601,9 @@ function runTickUnlocked(deps) {
6426
6601
  activeFindings: transitions.activeFindings,
6427
6602
  recoveryAttemptsByOwner,
6428
6603
  terminalPrsByUrl,
6429
- pendingReload
6604
+ pendingReload,
6605
+ claudeLogin,
6606
+ remoteControlRestartsBySession
6430
6607
  });
6431
6608
  const snapshot = {
6432
6609
  takenAt: at,
@@ -6439,7 +6616,8 @@ function runTickUnlocked(deps) {
6439
6616
  findings,
6440
6617
  recoveries,
6441
6618
  serviceArms,
6442
- reload
6619
+ reload,
6620
+ remoteControl
6443
6621
  };
6444
6622
  writeWatchSnapshot(deps.paths.watchDir, snapshot);
6445
6623
  return snapshot;
@@ -6493,7 +6671,11 @@ function failWith(message) {
6493
6671
  function watchTickCommand(flags) {
6494
6672
  try {
6495
6673
  const snapshot = runWatchTick(
6496
- createDefaultWatchTickDeps({ isAutoReloadEnabled: flags.autoReload, isRecoveryEnabled: flags.recovery })
6674
+ createDefaultWatchTickDeps({
6675
+ isAutoReloadEnabled: flags.autoReload,
6676
+ isRecoveryEnabled: flags.recovery,
6677
+ isRemoteControlKeptOn: flags.remoteControl
6678
+ })
6497
6679
  );
6498
6680
  if (flags.json) {
6499
6681
  process.stdout.write(`${JSON.stringify(snapshot, null, 2)}
@@ -6533,7 +6715,10 @@ function renderReloadReport(report, isDryRun) {
6533
6715
  function watchReloadPluginsCommand(flags) {
6534
6716
  try {
6535
6717
  const isDryRun = flags.dryRun === true;
6536
- const report = runReloadPluginsPass(createDefaultWatchTickDeps({ isAutoReloadEnabled: true, isRecoveryEnabled: false }), isDryRun);
6718
+ const report = runReloadPluginsPass(
6719
+ createDefaultWatchTickDeps({ isAutoReloadEnabled: true, isRecoveryEnabled: false, isRemoteControlKeptOn: false }),
6720
+ isDryRun
6721
+ );
6537
6722
  if (!report) {
6538
6723
  failWith("could not list Claude sessions (`claude agents --json` failed)");
6539
6724
  return;
@@ -6618,7 +6803,7 @@ function createProgram() {
6618
6803
  stack.command("status").description("Show every lease, the line, and the services prepare tracks").option("--json", "Print the full report as JSON").action(stackStatusCommand);
6619
6804
  program.command("ci-install").description("Add the Muggle walkthrough check to this repository's GitHub Actions").option("--force", "Replace an existing workflow file").action(ciInstallCommand);
6620
6805
  const watch = program.command("watch").description("Standing watch over Claude/Codex sessions, their processes, and PR watchers");
6621
- watch.command("tick").description("Run one watch pass (the scheduled task runs this)").option("--no-auto-reload", "Do not start a plugin reload fan-out when installed plugins change").option("--no-recovery", "Report dead watchers without resuming their owner sessions").option("--json", "Print the full snapshot as JSON").action(watchTickCommand);
6806
+ watch.command("tick").description("Run one watch pass (the scheduled task runs this)").option("--no-auto-reload", "Do not start a plugin reload fan-out when installed plugins change").option("--no-recovery", "Report dead watchers without resuming their owner sessions").option("--no-remote-control", "Report sessions off Remote Control without restarting them").option("--json", "Print the full snapshot as JSON").action(watchTickCommand);
6622
6807
  watch.command("status").description("Show the last watch pass: sessions, processes, PR watches, findings").option("--json", "Print the snapshot as JSON").action(watchStatusCommand);
6623
6808
  watch.command("reload-plugins").description("Reload plugins across active Claude sessions (respawns idle background ones)").option("--dry-run", "Show what each session would get without respawning anything").option("--json", "Print the report as JSON").action(watchReloadPluginsCommand);
6624
6809
  watch.command("install").description("Schedule the watch with this OS's scheduler, or refresh the entry in place").option("--uninstall", "Remove the task; the ledger is kept").option("--no-wait", "Return once the task is registered, without waiting for its first pass").option("--from-preference", "Act on the autoInstallWatchService preference, as an install or upgrade does").action(watchInstallCommand);
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from './chunk-W3HJBL5U.js';
2
+ import { runCli } from './chunk-RQ43IPVN.js';
3
3
  import './chunk-XUANXZ2O.js';
4
4
 
5
5
  // src/cli/main.ts
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-W3HJBL5U.js';
1
+ export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-RQ43IPVN.js';
2
2
  export { createChildLogger, e2e_exports as e2e, getConfig, getLocalQaTools, getLogger, getQaTools, local_exports as localQa, mcp_exports as mcp, e2e_exports as qa, src_exports as shared } from './chunk-XUANXZ2O.js';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
4
- "version": "5.31.0-staging.270",
4
+ "version": "5.31.0-staging.271",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.31.0-staging.270",
7
+ "@muggleai/works@5.31.0-staging.271",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.31.0-staging.270",
3
+ "version": "5.31.0-staging.271",
4
4
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
5
5
  "author": {
6
6
  "name": "Muggle AI",
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
5
- "version": "5.31.0-staging.270",
5
+ "version": "5.31.0-staging.271",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.31.0-staging.270",
7
+ "@muggleai/works@5.31.0-staging.271",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "release": "5.26.0",
3
- "buildId": "run-270-1",
4
- "commitSha": "09e34fedf072b7a5ab4fdce746bd0c0bfbe53c4a",
5
- "buildTime": "2026-10-31T23:24:31Z",
3
+ "buildId": "run-271-1",
4
+ "commitSha": "5036fc9c7a6ea65a6269500436e8eb9b6c443413",
5
+ "buildTime": "2026-11-01T05:29:33Z",
6
6
  "serviceName": "muggle-ai-works-mcp"
7
7
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@muggleai/works",
3
3
  "mcpName": "io.github.multiplex-ai/muggle",
4
- "version": "5.31.0-staging.270",
4
+ "version": "5.31.0-staging.271",
5
5
  "description": "13 years of Microsoft hyperscale engineering, packaged as a harness for your AI coding agent.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "muggle",
3
3
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
4
- "version": "5.31.0-staging.270",
4
+ "version": "5.31.0-staging.271",
5
5
  "author": {
6
6
  "name": "Muggle AI",
7
7
  "email": "support@muggle-ai.com"
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.31.0-staging.270",
7
+ "@muggleai/works@5.31.0-staging.271",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.31.0-staging.270",
3
+ "version": "5.31.0-staging.271",
4
4
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
5
5
  "author": {
6
6
  "name": "Muggle AI",
@@ -2,7 +2,7 @@
2
2
  "name": "muggle",
3
3
  "displayName": "Muggle AI",
4
4
  "description": "Real-browser E2E acceptance for your coding agent: test the flows you changed and open the PR with screenshots. Built for flows other tools can't reach: managed test identities with live inboxes (magic links, email OTP and 2FA, verification mail, password resets), an encrypted secret store for login and test credentials, and a test graph that runs prerequisite flows first. Plain-English tests become replayable scripts on localhost or preview URLs.",
5
- "version": "5.31.0-staging.270",
5
+ "version": "5.31.0-staging.271",
6
6
  "author": {
7
7
  "name": "Muggle AI",
8
8
  "email": "support@muggle-ai.com"
package/plugin/.mcp.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "command": "npx",
5
5
  "args": [
6
6
  "-y",
7
- "@muggleai/works@5.31.0-staging.270",
7
+ "@muggleai/works@5.31.0-staging.271",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -4,10 +4,11 @@ A standing per-user scheduled job that runs `muggle watch tick` every few minute
4
4
 
5
5
  - **Tracks** active Claude Code sessions (`claude agents --json`) and Codex sessions (recent `~/.codex/sessions` rollouts). It also tracks their processes: claude, codex, muggle MCP servers, and PR watch loops. Each live Claude session is stamped with when it last loaded its plugins (its process start, or its newest muggle MCP server start after a `/reload-plugins`) and the muggle works build installed then, read off `~/.muggle-ai/postinstall.log`.
6
6
  - **Checks every open PR-watch slot** under `~/.muggle-ai/muggle-do/sessions/`. Is its loop running, and heard by a live session? Is CI red? Is the branch behind or conflicting? Are review threads unresolved?
7
- - **Keeps registered watchers running.** A slot carrying `watch-registration.json` (written by `pr-watch-arm.sh` on every arm) has its owner session resumed in the background when its loop is gone and the owner is down, or idle in the background (stopped first, once a throwaway launch confirms Claude will resume it there). A resume that only hit the usage limit is retried without counting against the per-owner cap. The resumed session's `/muggle:muggle-pr-followup reconcile` re-arms it with that session's own context. The service never addresses a review, pushes, or kills a process.
7
+ - **Keeps registered watchers running.** A slot carrying `watch-registration.json` (written by `pr-watch-arm.sh` on every arm) whose loop is dead or unheard gets a loop the service starts itself, as a program outside every session. When that loop reports new activity or the PR ending, the owner session is woken with `/muggle:muggle-pr-followup <slug> <n> --wake=<event|terminal>` for that one PR, but only between its turns. The service never addresses a review, pushes, or pushes anything else into a session.
8
8
  - **Closes out finished slots.** A slot whose PR GitHub reports merged or closed, with no loop and no running owner, is finalized the way a watcher's terminal tick would: `prs.json` takes the state, `followup.log` the terminal line, and `result.md` is written. Only local files change; post-merge cleanup is left to a session and recorded as not run.
9
- - **Keeps sessions on the installed build.** A session loaded before the newest global install (or a later user-scope update of the muggle plugin) is behind; other plugins updating restart nothing. Idle background sessions are restarted onto the new build, up to 3 per tick: a session that owns open PR watches is stopped and resumed on `/muggle:muggle-pr-followup reconcile`, so its watchers come back with it; any other is respawned. A resume waits for the stopped process to exit and passes no flag beyond `--bg`, so the session continues under its own id and name. Resuming a session that is still running, or adding flags, starts a copy that the agent view lists beside the original. Before either, a throwaway background launch in the session's workspace confirms Claude will bring it back; a workspace that refuses is left running and named once. Busy or waiting sessions wait for a later tick, and terminal sessions get one notification to run `/reload-plugins`. No interface runs a slash command inside another session.
10
- - **Records and alerts.** Results go to `status.json` (last snapshot, with open-PR coverage per repo), `state.json` (active findings, recovery attempts, reload progress) and `events.jsonl` (raise/clear/resume/respawn/close-out history) under `~/.muggle-ai/watch/`. New alerts raise one desktop notification per tick.
9
+ - **Keeps sessions on the installed build.** A session loaded before the newest global install (or a later user-scope update of the muggle plugin) is behind; other plugins updating restart nothing. Idle background sessions are respawned onto the new build, up to 3 per tick, keeping their own id and name. A session that owns open PR watches is left running. Before a restart, a throwaway background launch in the session's workspace confirms Claude will bring it back; a workspace that refuses is left running and named once. Busy or waiting sessions wait for a later tick, and terminal sessions get one notification to run `/reload-plugins`. No interface runs a slash command inside another session.
10
+ - **Keeps sessions on Remote Control.** With `remoteControlAtStartup` on in Claude's settings, a live background session is off when its `~/.claude/sessions/<pid>.json` entry has no Remote Control link, or its process started before the current claude.ai login took effect, so its link serves another account. Such a session is respawned once its turn is over (registry status `idle` or `shell`), sharing the 3-per-tick cap with the reload. That keeps the same session id, so there is no duplicate in the agent view, and nothing is typed into it. Background commands carry over to the restarted process, PR watch loops included, so they keep reporting to that session. Each restart opens a new Remote Control session, so a session already on is never restarted. A restarted session gets 30 minutes to link, and one still off after 2 restarts in a day is left alone. Terminal sessions that are off are listed for `/remote-control`.
11
+ - **Records and alerts.** Results go to `status.json` (last snapshot, with open-PR and Remote Control coverage), `state.json` (active findings, recovery attempts, reload progress, the login last seen, Remote Control restarts) and `events.jsonl` (raise/clear/resume/respawn/close-out history) under `~/.muggle-ai/watch/`. New alerts raise one desktop notification per tick.
11
12
 
12
13
  ## Install
13
14
 
@@ -43,6 +44,7 @@ On Windows the PowerShell installer also takes flags directly:
43
44
  | `-IntervalMinutes <n>` | Tick interval, default 2 |
44
45
  | `-NoRecovery` | Report dead watchers without resuming their owners |
45
46
  | `-NoAutoReload` | Never restart sessions onto a new build |
47
+ | `-NoRemoteControl` | Report sessions off Remote Control without restarting them |
46
48
  | `-NoWait` | Return once the task is registered, before its first tick |
47
49
  | `-MuggleEntry <path>` | Run a specific `bin\muggle.js` instead of the global install |
48
50
  | `-Uninstall` | Remove the task; the ledger is kept |
@@ -1,8 +1,9 @@
1
1
  # Installs the MuggleWatch scheduled task for the current user (no elevation):
2
2
  # every -IntervalMinutes it runs `muggle watch tick`, which snapshots Claude and
3
3
  # Codex sessions, their processes and every PR-watch slot, alerts on dead or deaf
4
- # watchers, red CI and stale branches, resumes the owner session of a registered
5
- # slot whose watcher died, and fans plugin reloads out across sessions.
4
+ # watchers, red CI and stale branches, restarts the loop of a registered slot
5
+ # whose watcher died, fans plugin reloads out across sessions, and restarts
6
+ # sessions that are off Remote Control under the current login.
6
7
  #
7
8
  # The task runs the global @muggleai/works install, whose path survives
8
9
  # `muggle upgrade`, so the service always runs the current release. Only the
@@ -20,6 +21,7 @@ param(
20
21
  [string]$MuggleEntry,
21
22
  [switch]$NoAutoReload,
22
23
  [switch]$NoRecovery,
24
+ [switch]$NoRemoteControl,
23
25
  # Return once the task is registered; its trigger runs the first tick a minute later.
24
26
  [switch]$NoWait,
25
27
  [switch]$Uninstall
@@ -61,6 +63,7 @@ Copy-Item (Join-Path $PSScriptRoot "muggle-watch-hidden.vbs") $installedLauncher
61
63
  $tickFlags = @()
62
64
  if ($NoAutoReload) { $tickFlags += "--no-auto-reload" }
63
65
  if ($NoRecovery) { $tickFlags += "--no-recovery" }
66
+ if ($NoRemoteControl) { $tickFlags += "--no-remote-control" }
64
67
  $launcherArguments = @("`"$installedLauncher`"", "`"$($nodeCommand.Source)`"", "`"$MuggleEntry`"") + $tickFlags
65
68
 
66
69
  # wscript.exe rather than node.exe is what keeps every run off the desktop; see