@muggleai/works 5.31.0-staging.273 → 5.31.0-staging.275

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.273"
12
+ "version": "5.31.0-staging.275"
13
13
  }
14
14
  ]
15
15
  }
@@ -5,7 +5,7 @@ import { v4 } from 'uuid';
5
5
  import { z, ZodError } from 'zod';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import * as fs from 'fs';
8
- import { readFileSync, existsSync, rmSync, mkdirSync, readdirSync, createWriteStream, writeFileSync, statSync, readlinkSync, realpathSync, openSync, closeSync, writeSync, unlinkSync, copyFileSync, renameSync, appendFileSync, readSync } from 'fs';
8
+ import { readFileSync, existsSync, rmSync, mkdirSync, readdirSync, createWriteStream, writeFileSync, statSync, readlinkSync, realpathSync, openSync, closeSync, writeSync, unlinkSync, copyFileSync, renameSync, appendFileSync, readSync, fstatSync } from 'fs';
9
9
  import * as path from 'path';
10
10
  import { join, dirname, resolve, win32, basename } from 'path';
11
11
  import { fileURLToPath, pathToFileURL } from 'url';
@@ -2945,6 +2945,12 @@ var REMOTE_CONTROL_MAX_RESTARTS = 2;
2945
2945
  var REMOTE_CONTROL_RESTART_WINDOW_MS = 24 * 60 * 6e4;
2946
2946
  var REMOTE_CONTROL_RESTART_COOLDOWN_MS = 30 * 6e4;
2947
2947
  var REMOTE_CONTROL_NAME_COLUMN_WIDTH = 32;
2948
+ var USAGE_LIMIT_API_ERROR = "usage_limit_reached";
2949
+ var TRANSCRIPT_TAIL_BYTES = 256 * 1024;
2950
+ var LIMIT_RESUME_PROMPT = "The usage limit that stopped your last turn has reset. Continue where you left off.";
2951
+ var LIMIT_RESUME_MAX_ATTEMPTS = 2;
2952
+ var LIMIT_RESUME_WINDOW_MS = 24 * 60 * 6e4;
2953
+ var LIMIT_STOP_MAX_AGE_MS = 8 * 24 * 60 * 6e4;
2948
2954
  var USAGE_WINDOW_EXHAUSTED_PERCENT = 100;
2949
2955
  var RELOAD_PLUGIN_SCOPE = "user";
2950
2956
  var MUGGLE_PLUGIN_ID = "muggleai@muggle-works";
@@ -4927,6 +4933,22 @@ function renderWatchStatus(snapshot, nowMs) {
4927
4933
  lines.push(` ${decision.action.padEnd(14)} ${shortSessionId(decision.sessionId)} ${label} ${decision.reason}${outcome}`);
4928
4934
  }
4929
4935
  }
4936
+ const limitDecisions = snapshot.limitResumes?.decisions ?? [];
4937
+ if (limitDecisions.length > 0) {
4938
+ const countOutcome = (outcome) => limitDecisions.filter((decision) => decision.outcome === outcome).length;
4939
+ const countAction = (action) => limitDecisions.filter((decision) => decision.action === action).length;
4940
+ const continueCount = countAction("continue" /* Continue */);
4941
+ const continuedCount = countOutcome("resumed" /* Resumed */);
4942
+ lines.push(
4943
+ "",
4944
+ `Stopped at a usage limit: ${continuedCount} continued, ${continueCount - continuedCount} failed, ${countAction("wait" /* Wait */)} waiting, ${countAction("skip" /* Skip */)} skipped`
4945
+ );
4946
+ for (const decision of limitDecisions) {
4947
+ const outcome = decision.outcome === void 0 || decision.outcome === "resumed" /* Resumed */ ? "" : ` \u2014 ${decision.outcome}`;
4948
+ const label = (decision.name ?? "").slice(0, REMOTE_CONTROL_NAME_COLUMN_WIDTH).padEnd(REMOTE_CONTROL_NAME_COLUMN_WIDTH);
4949
+ lines.push(` ${decision.action.padEnd(14)} ${shortSessionId(decision.sessionId)} ${label} ${decision.reason}${outcome}`);
4950
+ }
4951
+ }
4930
4952
  return `${lines.join("\n")}
4931
4953
  `;
4932
4954
  }
@@ -4949,6 +4971,22 @@ function readFileHead(filePath) {
4949
4971
  if (fileDescriptor !== void 0) closeSync(fileDescriptor);
4950
4972
  }
4951
4973
  }
4974
+ function readFileTailLines(filePath, byteCount) {
4975
+ let fileDescriptor;
4976
+ try {
4977
+ fileDescriptor = openSync(filePath, "r");
4978
+ const fileSize = fstatSync(fileDescriptor).size;
4979
+ const start = Math.max(0, fileSize - byteCount);
4980
+ const buffer = Buffer.alloc(fileSize - start);
4981
+ const bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, start);
4982
+ const lines = buffer.subarray(0, bytesRead).toString("utf-8").split(/\r?\n/);
4983
+ return (start > 0 ? lines.slice(1) : lines).filter((line) => line.length > 0);
4984
+ } catch {
4985
+ return void 0;
4986
+ } finally {
4987
+ if (fileDescriptor !== void 0) closeSync(fileDescriptor);
4988
+ }
4989
+ }
4952
4990
  function cwdFromSessionHead(head) {
4953
4991
  const parts = head.match(/"cwd"\s*:\s*("(?:[^"\\]|\\.)*")/);
4954
4992
  if (!parts) return void 0;
@@ -5129,6 +5167,56 @@ function readCodexSessions(sessionsDir, nowMs) {
5129
5167
  }
5130
5168
  return sessions;
5131
5169
  }
5170
+ function findTranscriptPath(projectsDir, sessionId) {
5171
+ let projectDirNames;
5172
+ try {
5173
+ projectDirNames = readdirSync(projectsDir);
5174
+ } catch {
5175
+ return void 0;
5176
+ }
5177
+ const transcriptFileName = `${sessionId}.jsonl`;
5178
+ for (const projectDirName of projectDirNames) {
5179
+ const transcriptPath = join(projectsDir, projectDirName, transcriptFileName);
5180
+ if (existsSync(transcriptPath)) return transcriptPath;
5181
+ }
5182
+ return void 0;
5183
+ }
5184
+ function parseTranscriptLine(line) {
5185
+ try {
5186
+ const parsed = JSON.parse(line);
5187
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
5188
+ } catch {
5189
+ return void 0;
5190
+ }
5191
+ }
5192
+ function isConversationTurn(entry) {
5193
+ return (entry.type === "user" || entry.type === "assistant") && entry.isSidechain !== true && entry.isMeta !== true;
5194
+ }
5195
+ function readLimitStop(projectsDir, sessionId) {
5196
+ const transcriptPath = findTranscriptPath(projectsDir, sessionId);
5197
+ if (transcriptPath === void 0) return void 0;
5198
+ const lines = readFileTailLines(transcriptPath, TRANSCRIPT_TAIL_BYTES) ?? [];
5199
+ for (let index = lines.length - 1; index >= 0; index--) {
5200
+ const entry = parseTranscriptLine(lines[index]);
5201
+ if (entry === void 0 || !isConversationTurn(entry)) continue;
5202
+ if (entry.type !== "assistant" || entry.apiError !== USAGE_LIMIT_API_ERROR) return void 0;
5203
+ const noticedAtMs = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN;
5204
+ if (typeof entry.uuid !== "string" || Number.isNaN(noticedAtMs)) return void 0;
5205
+ const resetsAtSeconds = entry.apiErrorParams?.rate_limit_info?.resetsAt;
5206
+ return {
5207
+ noticeUuid: entry.uuid,
5208
+ noticedAtMs,
5209
+ ...typeof resetsAtSeconds === "number" ? { resetsAtMs: resetsAtSeconds * 1e3 } : {}
5210
+ };
5211
+ }
5212
+ return void 0;
5213
+ }
5214
+ function findClaudeSessionCwd(projectsDir, sessionId) {
5215
+ const transcriptPath = findTranscriptPath(projectsDir, sessionId);
5216
+ if (transcriptPath === void 0) return void 0;
5217
+ const head = readFileHead(transcriptPath);
5218
+ return head === void 0 ? void 0 : cwdFromSessionHead(head);
5219
+ }
5132
5220
 
5133
5221
  // src/pr-watch/constants.ts
5134
5222
  var REBASE_KEY_SEPARATOR = "..";
@@ -5282,26 +5370,6 @@ function fetchPrLiveStatuses(prs, runCommand) {
5282
5370
  }
5283
5371
  return statusesByUrl;
5284
5372
  }
5285
- function findTranscriptPath(projectsDir, sessionId) {
5286
- let projectDirNames;
5287
- try {
5288
- projectDirNames = readdirSync(projectsDir);
5289
- } catch {
5290
- return void 0;
5291
- }
5292
- const transcriptFileName = `${sessionId}.jsonl`;
5293
- for (const projectDirName of projectDirNames) {
5294
- const transcriptPath = join(projectsDir, projectDirName, transcriptFileName);
5295
- if (existsSync(transcriptPath)) return transcriptPath;
5296
- }
5297
- return void 0;
5298
- }
5299
- function findClaudeSessionCwd(projectsDir, sessionId) {
5300
- const transcriptPath = findTranscriptPath(projectsDir, sessionId);
5301
- if (transcriptPath === void 0) return void 0;
5302
- const head = readFileHead(transcriptPath);
5303
- return head === void 0 ? void 0 : cwdFromSessionHead(head);
5304
- }
5305
5373
  function parseServiceLoopReport(outputText, writtenAtMs) {
5306
5374
  const reportLines = [];
5307
5375
  let kind = "silent" /* Silent */;
@@ -5735,6 +5803,106 @@ function readInstallHistory(installLogFile) {
5735
5803
  }
5736
5804
  return installs;
5737
5805
  }
5806
+ function recentAttempts(attempts, nowMs) {
5807
+ return attempts.filter((attempt) => nowMs - Date.parse(attempt.at) < LIMIT_RESUME_WINDOW_MS);
5808
+ }
5809
+ function decide(session, stop, action, reason) {
5810
+ return {
5811
+ sessionId: session.sessionId,
5812
+ ...session.shortId === void 0 ? {} : { shortId: session.shortId },
5813
+ ...session.name === void 0 ? {} : { name: session.name },
5814
+ action,
5815
+ reason,
5816
+ noticeUuid: stop.noticeUuid
5817
+ };
5818
+ }
5819
+ function planLimitResumes(sessions, limitStopsBySessionId, attemptsBySession, nowMs, isUsageLimitInForce, maxContinues) {
5820
+ const decisions = [];
5821
+ let continueCount = 0;
5822
+ for (const session of sessions) {
5823
+ const stop = limitStopsBySessionId.get(session.sessionId);
5824
+ if (session.agent !== "claude" /* Claude */ || stop === void 0) continue;
5825
+ const attempts = attemptsBySession[session.sessionId] ?? [];
5826
+ if (nowMs - stop.noticedAtMs > LIMIT_STOP_MAX_AGE_MS) continue;
5827
+ if (attempts.some((attempt) => attempt.noticeUuid === stop.noticeUuid && attempt.isContinued)) continue;
5828
+ const isRunning = hasLiveProcess(session);
5829
+ if (session.kind !== "background" /* Background */) {
5830
+ decisions.push(decide(session, stop, "skip" /* Skip */, "terminal session, continue it there"));
5831
+ } else if (session.shortId === void 0) {
5832
+ decisions.push(decide(session, stop, "skip" /* Skip */, "no short id to continue it by"));
5833
+ } else if (isRunning && session.turnStatus !== "idle" /* Idle */) {
5834
+ decisions.push(decide(session, stop, "skip" /* Skip */, `turn in flight (${session.turnStatus ?? "unknown"})`));
5835
+ } else if (stop.resetsAtMs !== void 0 && stop.resetsAtMs > nowMs) {
5836
+ decisions.push(decide(session, stop, "wait" /* Wait */, `limit resets at ${new Date(stop.resetsAtMs).toISOString()}`));
5837
+ } else if (isUsageLimitInForce) {
5838
+ decisions.push(decide(session, stop, "wait" /* Wait */, "the account is still at a usage limit"));
5839
+ } else if (recentAttempts(attempts, nowMs).length >= LIMIT_RESUME_MAX_ATTEMPTS) {
5840
+ decisions.push(decide(session, stop, "skip" /* Skip */, `after ${LIMIT_RESUME_MAX_ATTEMPTS} tries today`));
5841
+ } else if (continueCount >= maxContinues) {
5842
+ decisions.push(decide(session, stop, "wait" /* Wait */, "tick cap reached"));
5843
+ } else {
5844
+ continueCount++;
5845
+ const reason = stop.resetsAtMs === void 0 ? "usage limit cleared" : `limit reset at ${new Date(stop.resetsAtMs).toISOString()}`;
5846
+ decisions.push({ ...decide(session, stop, "continue" /* Continue */, reason), ...isRunning ? { runningShortId: session.shortId } : {} });
5847
+ }
5848
+ }
5849
+ return decisions;
5850
+ }
5851
+ function continuedOutcomeOf(outcome) {
5852
+ if (outcome === "continued" /* Continued */) return "resumed" /* Resumed */;
5853
+ return outcome === "copied" /* Copied */ ? "resume-forked" /* ResumeForked */ : "resume-failed" /* ResumeFailed */;
5854
+ }
5855
+ function eventKindOf(outcome) {
5856
+ if (outcome === "resumed" /* Resumed */) return "resumed" /* Resumed */;
5857
+ if (outcome === "resume-forked" /* ResumeForked */) return "resume-forked" /* ResumeForked */;
5858
+ return outcome === "restart-refused" /* RestartRefused */ ? "restart-refused" /* RestartRefused */ : "resume-failed" /* ResumeFailed */;
5859
+ }
5860
+ function continueMessageOf(resumed, reason) {
5861
+ if (resumed.outcome === "continued" /* Continued */) return `continued it after its usage limit reset (${reason})`;
5862
+ if (resumed.outcome === "copied" /* Copied */) {
5863
+ const copyNote = resumed.isCopyRemoved ? "stopped and removed the copy" : `run claude rm ${resumed.copyShortId}`;
5864
+ return `could not continue it: Claude started copy ${resumed.copyShortId} instead; ${copyNote}`;
5865
+ }
5866
+ const cause = resumed.outcome === "still-running" /* StillRunning */ ? "it is still running" : "the resume did not start";
5867
+ return `could not continue it after its usage limit: ${cause}`;
5868
+ }
5869
+ function executeLimitResumes(planned, attemptsBySession, deps) {
5870
+ const at = new Date(deps.nowMs).toISOString();
5871
+ const nextAttemptsBySession = {};
5872
+ for (const [sessionId, attempts] of Object.entries(attemptsBySession)) {
5873
+ const kept = attempts.filter((attempt) => deps.nowMs - Date.parse(attempt.at) < LIMIT_STOP_MAX_AGE_MS);
5874
+ if (kept.length > 0) nextAttemptsBySession[sessionId] = kept;
5875
+ }
5876
+ const events = [];
5877
+ const decisions = planned.map((decision) => {
5878
+ if (decision.action !== "continue" /* Continue */) return decision;
5879
+ const subject = `session ${decision.shortId ?? shortSessionId(decision.sessionId)}`;
5880
+ const cwd = findClaudeSessionCwd(deps.claudeProjectsDir, decision.sessionId);
5881
+ if (!cwd || !existsSync(cwd)) return { ...decision, outcome: "transcript-missing" /* TranscriptMissing */ };
5882
+ let outcome;
5883
+ let message;
5884
+ if (decision.runningShortId !== void 0 && !deps.canRestartIn(cwd)) {
5885
+ outcome = "restart-refused" /* RestartRefused */;
5886
+ message = `left stopped at its usage limit: Claude would not resume it in ${cwd}`;
5887
+ } else {
5888
+ const resumed = resumeSession(
5889
+ { sessionId: decision.sessionId, cwd, prompt: LIMIT_RESUME_PROMPT, runningShortId: decision.runningShortId },
5890
+ deps.runCommand,
5891
+ deps.waitForExit,
5892
+ deps.isSessionRunning
5893
+ );
5894
+ outcome = continuedOutcomeOf(resumed.outcome);
5895
+ message = continueMessageOf(resumed, decision.reason);
5896
+ }
5897
+ nextAttemptsBySession[decision.sessionId] = [
5898
+ ...nextAttemptsBySession[decision.sessionId] ?? [],
5899
+ { noticeUuid: decision.noticeUuid, at, isContinued: outcome === "resumed" /* Resumed */ }
5900
+ ];
5901
+ events.push({ at, kind: eventKindOf(outcome), subject, message });
5902
+ return { ...decision, outcome };
5903
+ });
5904
+ return { decisions, attemptsBySession: nextAttemptsBySession, events };
5905
+ }
5738
5906
  function emptyLedgerState() {
5739
5907
  return { activeFindings: {}, recoveryAttemptsByOwner: {}, terminalPrsByUrl: {} };
5740
5908
  }
@@ -5949,7 +6117,7 @@ function readProcessTable(platform4, runCommand, nowMs) {
5949
6117
  }
5950
6118
 
5951
6119
  // src/watch/reload-plugins.ts
5952
- function decide(session, action, reason) {
6120
+ function decide2(session, action, reason) {
5953
6121
  return {
5954
6122
  sessionId: session.sessionId,
5955
6123
  shortId: session.shortId,
@@ -5969,24 +6137,24 @@ function planReloadFanOut(sessions, pending, watchOwnerSessionIds, maxRestarts =
5969
6137
  for (const session of sessions) {
5970
6138
  if (session.agent !== "claude" /* Claude */) continue;
5971
6139
  if (!hasLiveProcess(session)) {
5972
- decisions.push(decide(session, "skip" /* Skip */, "no live process"));
6140
+ decisions.push(decide2(session, "skip" /* Skip */, "no live process"));
5973
6141
  } else if (hasCurrentPlugins(session, pending)) {
5974
- decisions.push(decide(session, "skip" /* Skip */, "already on the installed build"));
6142
+ decisions.push(decide2(session, "skip" /* Skip */, "already on the installed build"));
5975
6143
  } else if (pending.respawnedSessionIds.includes(session.sessionId) || pending.skippedSessionIds.includes(session.sessionId)) {
5976
- decisions.push(decide(session, "skip" /* Skip */, "already handled for this install"));
6144
+ decisions.push(decide2(session, "skip" /* Skip */, "already handled for this install"));
5977
6145
  } else if (session.kind === "interactive" /* Interactive */) {
5978
- decisions.push(pending.notifiedSessionIds.includes(session.sessionId) ? decide(session, "skip" /* Skip */, "already asked to run /reload-plugins") : decide(session, "notify-manual" /* NotifyManual */, "terminal session \u2014 run /reload-plugins in it"));
6146
+ decisions.push(pending.notifiedSessionIds.includes(session.sessionId) ? decide2(session, "skip" /* Skip */, "already asked to run /reload-plugins") : decide2(session, "notify-manual" /* NotifyManual */, "terminal session \u2014 run /reload-plugins in it"));
5979
6147
  } else if (session.kind !== "background" /* Background */ || !session.shortId) {
5980
- decisions.push(decide(session, "skip" /* Skip */, "not a background session the watch can restart"));
6148
+ decisions.push(decide2(session, "skip" /* Skip */, "not a background session the watch can restart"));
5981
6149
  } else if (session.turnStatus !== "idle" /* Idle */) {
5982
- decisions.push(decide(session, "defer" /* Defer */, `turn in flight (${session.turnStatus ?? "unknown"})`));
6150
+ decisions.push(decide2(session, "defer" /* Defer */, `turn in flight (${session.turnStatus ?? "unknown"})`));
5983
6151
  } else if (watchOwnerSessionIds.has(session.sessionId)) {
5984
- decisions.push(decide(session, "skip" /* Skip */, "owns open PR watches \u2014 left running; run /reload-plugins in it"));
6152
+ decisions.push(decide2(session, "skip" /* Skip */, "owns open PR watches \u2014 left running; run /reload-plugins in it"));
5985
6153
  } else if (restartsPlanned >= maxRestarts) {
5986
- decisions.push(decide(session, "defer" /* Defer */, "restart cap reached this pass"));
6154
+ decisions.push(decide2(session, "defer" /* Defer */, "restart cap reached this pass"));
5987
6155
  } else {
5988
6156
  restartsPlanned++;
5989
- decisions.push(decide(session, "respawn" /* Respawn */, "idle background session"));
6157
+ decisions.push(decide2(session, "respawn" /* Respawn */, "idle background session"));
5990
6158
  }
5991
6159
  }
5992
6160
  return decisions;
@@ -6041,7 +6209,7 @@ function isTurnOver(session) {
6041
6209
  function recentRestartsMs(restartsBySession, sessionId, nowMs) {
6042
6210
  return (restartsBySession[sessionId] ?? []).map((restartedAt) => Date.parse(restartedAt)).filter((restartedAtMs) => !Number.isNaN(restartedAtMs) && nowMs - restartedAtMs < REMOTE_CONTROL_RESTART_WINDOW_MS);
6043
6211
  }
6044
- function decide2(session, action, reason) {
6212
+ function decide3(session, action, reason) {
6045
6213
  return { sessionId: session.sessionId, shortId: session.shortId, name: session.name, cwd: session.cwd, action, reason };
6046
6214
  }
6047
6215
  function planRemoteControlRestarts(sessions, login, restartsBySession, nowMs, maxRestarts) {
@@ -6057,20 +6225,20 @@ function planRemoteControlRestarts(sessions, login, restartsBySession, nowMs, ma
6057
6225
  }
6058
6226
  const restartsMs = recentRestartsMs(restartsBySession, session.sessionId, nowMs);
6059
6227
  if (session.kind === "interactive" /* Interactive */) {
6060
- decisions.push(decide2(session, "notify-manual" /* NotifyManual */, `${gap} \u2014 terminal session, run /remote-control in it`));
6228
+ decisions.push(decide3(session, "notify-manual" /* NotifyManual */, `${gap} \u2014 terminal session, run /remote-control in it`));
6061
6229
  } else if (session.kind !== "background" /* Background */ || !session.shortId) {
6062
- decisions.push(decide2(session, "skip" /* Skip */, `${gap} \u2014 not a background session the watch can restart`));
6230
+ decisions.push(decide3(session, "skip" /* Skip */, `${gap} \u2014 not a background session the watch can restart`));
6063
6231
  } else if (restartsMs.length >= REMOTE_CONTROL_MAX_RESTARTS) {
6064
- decisions.push(decide2(session, "skip" /* Skip */, `${gap} after ${restartsMs.length} restarts today`));
6232
+ decisions.push(decide3(session, "skip" /* Skip */, `${gap} after ${restartsMs.length} restarts today`));
6065
6233
  } else if (nowMs - Math.max(-Infinity, ...restartsMs) < REMOTE_CONTROL_RESTART_COOLDOWN_MS) {
6066
- decisions.push(decide2(session, "skip" /* Skip */, `${gap} \u2014 restarted recently, waiting for it to link`));
6234
+ decisions.push(decide3(session, "skip" /* Skip */, `${gap} \u2014 restarted recently, waiting for it to link`));
6067
6235
  } else if (!isTurnOver(session)) {
6068
- decisions.push(decide2(session, "defer" /* Defer */, `${gap} \u2014 turn in flight (${session.processStatus ?? session.turnStatus ?? "unknown"})`));
6236
+ decisions.push(decide3(session, "defer" /* Defer */, `${gap} \u2014 turn in flight (${session.processStatus ?? session.turnStatus ?? "unknown"})`));
6069
6237
  } else if (restartsPlanned >= maxRestarts) {
6070
- decisions.push(decide2(session, "defer" /* Defer */, `${gap} \u2014 restart cap reached this pass`));
6238
+ decisions.push(decide3(session, "defer" /* Defer */, `${gap} \u2014 restart cap reached this pass`));
6071
6239
  } else {
6072
6240
  restartsPlanned++;
6073
- decisions.push(decide2(session, "respawn" /* Respawn */, gap));
6241
+ decisions.push(decide3(session, "respawn" /* Respawn */, gap));
6074
6242
  }
6075
6243
  }
6076
6244
  return { connectedSessionIds, decisions };
@@ -6455,6 +6623,7 @@ function createDefaultWatchTickDeps(options) {
6455
6623
  isAutoReloadEnabled: options.isAutoReloadEnabled,
6456
6624
  isRecoveryEnabled: options.isRecoveryEnabled,
6457
6625
  isRemoteControlKeptOn: options.isRemoteControlKeptOn,
6626
+ isLimitResumeEnabled: options.isLimitResumeEnabled,
6458
6627
  launchServiceLoop: createServiceLoopLauncher(process.platform, defaultCommandRunner)
6459
6628
  };
6460
6629
  }
@@ -6529,6 +6698,33 @@ function waitForExitVia(deps) {
6529
6698
  function isSessionRunningVia(deps) {
6530
6699
  return (sessionId) => isSessionRunning(deps.runCommand, deps.paths.claudeSessionRegistryDir, sessionId) !== false;
6531
6700
  }
6701
+ function resumeDepsOf(deps) {
6702
+ return {
6703
+ runCommand: deps.runCommand,
6704
+ claudeProjectsDir: deps.paths.claudeProjectsDir,
6705
+ nowMs: deps.nowMs,
6706
+ canRestartIn: canRestartVia(deps),
6707
+ waitForExit: waitForExitVia(deps),
6708
+ isSessionRunning: isSessionRunningVia(deps)
6709
+ };
6710
+ }
6711
+ function runLimitResumePass(claudeSessions, attemptsBySession, maxContinues, deps) {
6712
+ const limitStopsBySessionId = /* @__PURE__ */ new Map();
6713
+ for (const session of claudeSessions) {
6714
+ const stop = readLimitStop(deps.paths.claudeProjectsDir, session.sessionId);
6715
+ if (stop !== void 0) limitStopsBySessionId.set(session.sessionId, stop);
6716
+ }
6717
+ const planned = planLimitResumes(
6718
+ claudeSessions,
6719
+ limitStopsBySessionId,
6720
+ attemptsBySession,
6721
+ deps.nowMs,
6722
+ isClaudeUsageLimitInForce(deps.paths.claudeGlobalConfigFile, deps.nowMs),
6723
+ maxContinues
6724
+ );
6725
+ const executed = executeLimitResumes(planned, attemptsBySession, resumeDepsOf(deps));
6726
+ return { report: { decisions: executed.decisions }, attemptsBySession: executed.attemptsBySession, events: executed.events };
6727
+ }
6532
6728
  function reloadTargetAtMs(deps, installs) {
6533
6729
  return resolveReloadTargetAtMs(installs, readMugglePluginUpdatedAtMs(deps.paths.claudeInstalledPluginsFile));
6534
6730
  }
@@ -6591,14 +6787,7 @@ function runTickUnlocked(deps) {
6591
6787
  sessionsById,
6592
6788
  isClaudeUsageLimitInForce(deps.paths.claudeGlobalConfigFile, deps.nowMs)
6593
6789
  );
6594
- const executed = executeWatcherRecoveries(planned, recoveryAttemptsByOwner, {
6595
- runCommand: deps.runCommand,
6596
- claudeProjectsDir: deps.paths.claudeProjectsDir,
6597
- nowMs: deps.nowMs,
6598
- canRestartIn: canRestartVia(deps),
6599
- waitForExit: waitForExitVia(deps),
6600
- isSessionRunning: isSessionRunningVia(deps)
6601
- });
6790
+ const executed = executeWatcherRecoveries(planned, recoveryAttemptsByOwner, resumeDepsOf(deps));
6602
6791
  recoveries = executed.recoveries;
6603
6792
  recoveryAttemptsByOwner = executed.attemptsByOwner;
6604
6793
  events.push(...executed.events);
@@ -6613,6 +6802,22 @@ function runTickUnlocked(deps) {
6613
6802
  events.push(...started.events);
6614
6803
  }
6615
6804
  const restartedSessionIds = new Set(recoveries.filter(isRestartAttempted).map((recovery) => recovery.ownerSessionId));
6805
+ let limitResumeAttemptsBySession = state.limitResumeAttemptsBySession ?? {};
6806
+ let limitResumes;
6807
+ if (deps.isLimitResumeEnabled && claudeSessions !== null) {
6808
+ const pass = runLimitResumePass(
6809
+ claudeSessions.filter((session) => !restartedSessionIds.has(session.sessionId)),
6810
+ limitResumeAttemptsBySession,
6811
+ Math.max(0, MAX_RESUMES_PER_TICK - restartedSessionIds.size),
6812
+ deps
6813
+ );
6814
+ limitResumes = pass.report;
6815
+ limitResumeAttemptsBySession = pass.attemptsBySession;
6816
+ events.push(...pass.events);
6817
+ for (const decision of pass.report.decisions) {
6818
+ if (decision.outcome !== void 0) restartedSessionIds.add(decision.sessionId);
6819
+ }
6820
+ }
6616
6821
  let pendingReload = state.pendingReload;
6617
6822
  let reload;
6618
6823
  const targetAtMs = reloadTargetAtMs(deps, installs);
@@ -6649,7 +6854,8 @@ function runTickUnlocked(deps) {
6649
6854
  terminalPrsByUrl,
6650
6855
  pendingReload,
6651
6856
  claudeLogin,
6652
- remoteControlRestartsBySession
6857
+ remoteControlRestartsBySession,
6858
+ limitResumeAttemptsBySession
6653
6859
  });
6654
6860
  const snapshot = {
6655
6861
  takenAt: at,
@@ -6663,7 +6869,8 @@ function runTickUnlocked(deps) {
6663
6869
  recoveries,
6664
6870
  serviceArms,
6665
6871
  reload,
6666
- remoteControl
6872
+ remoteControl,
6873
+ limitResumes
6667
6874
  };
6668
6875
  writeWatchSnapshot(deps.paths.watchDir, snapshot);
6669
6876
  return snapshot;
@@ -6720,7 +6927,8 @@ function watchTickCommand(flags) {
6720
6927
  createDefaultWatchTickDeps({
6721
6928
  isAutoReloadEnabled: flags.autoReload,
6722
6929
  isRecoveryEnabled: flags.recovery,
6723
- isRemoteControlKeptOn: flags.remoteControl
6930
+ isRemoteControlKeptOn: flags.remoteControl,
6931
+ isLimitResumeEnabled: flags.limitResume
6724
6932
  })
6725
6933
  );
6726
6934
  if (flags.json) {
@@ -6762,7 +6970,7 @@ function watchReloadPluginsCommand(flags) {
6762
6970
  try {
6763
6971
  const isDryRun = flags.dryRun === true;
6764
6972
  const report = runReloadPluginsPass(
6765
- createDefaultWatchTickDeps({ isAutoReloadEnabled: true, isRecoveryEnabled: false, isRemoteControlKeptOn: false }),
6973
+ createDefaultWatchTickDeps({ isAutoReloadEnabled: true, isRecoveryEnabled: false, isRemoteControlKeptOn: false, isLimitResumeEnabled: false }),
6766
6974
  isDryRun
6767
6975
  );
6768
6976
  if (!report) {
@@ -6849,7 +7057,7 @@ function createProgram() {
6849
7057
  stack.command("status").description("Show every lease, the line, and the services prepare tracks").option("--json", "Print the full report as JSON").action(stackStatusCommand);
6850
7058
  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);
6851
7059
  const watch = program.command("watch").description("Standing watch over Claude/Codex sessions, their processes, and PR watchers");
6852
- 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);
7060
+ 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("--no-limit-resume", "Report sessions a usage limit cut off without continuing them").option("--json", "Print the full snapshot as JSON").action(watchTickCommand);
6853
7061
  watch.command("status").description("Show the last watch pass: sessions, processes, PR watches, findings").option("--json", "Print the snapshot as JSON").action(watchStatusCommand);
6854
7062
  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);
6855
7063
  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-7JQWRPFI.js';
2
+ import { runCli } from './chunk-HK7XCCNT.js';
3
3
  import './chunk-7J7DS4SA.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-7JQWRPFI.js';
1
+ export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-HK7XCCNT.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-7J7DS4SA.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.273",
4
+ "version": "5.31.0-staging.275",
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.273",
7
+ "@muggleai/works@5.31.0-staging.275",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.31.0-staging.273",
3
+ "version": "5.31.0-staging.275",
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.273",
5
+ "version": "5.31.0-staging.275",
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.273",
7
+ "@muggleai/works@5.31.0-staging.275",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "release": "5.26.0",
3
- "buildId": "run-273-1",
4
- "commitSha": "79c091d63782313be3672ba64db5ae65c44e2c2b",
5
- "buildTime": "2026-11-01T07:21:17Z",
3
+ "buildId": "run-275-1",
4
+ "commitSha": "6a543704f7984c8423ed97c96d23d154bbafc342",
5
+ "buildTime": "2026-11-01T07:28:44Z",
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.273",
4
+ "version": "5.31.0-staging.275",
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.273",
4
+ "version": "5.31.0-staging.275",
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.273",
7
+ "@muggleai/works@5.31.0-staging.275",
8
8
  "serve"
9
9
  ],
10
10
  "env": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "muggle",
3
- "version": "5.31.0-staging.273",
3
+ "version": "5.31.0-staging.275",
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.273",
5
+ "version": "5.31.0-staging.275",
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.273",
7
+ "@muggleai/works@5.31.0-staging.275",
8
8
  "serve"
9
9
  ]
10
10
  }
@@ -8,6 +8,7 @@ A standing per-user scheduled job that runs `muggle watch tick` every few minute
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
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
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
+ - **Continues sessions a usage limit cut off.** Claude never retries a turn a usage limit ended: the session stays stopped on the limit notice. A background session whose last turn is that notice is resumed in place with one continue prompt once the notice's own reset time has passed and Claude's usage cache shows no window still exhausted. A running session is stopped first, between turns only, and a copy Claude starts instead is removed. Each notice is continued once, a session at most twice a day, sharing the per-tick cap with owner wakes; a notice older than 8 days is left alone, and terminal sessions are listed to continue by hand.
11
12
  - **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.
12
13
 
13
14
  ## Install
@@ -45,6 +46,7 @@ On Windows the PowerShell installer also takes flags directly:
45
46
  | `-NoRecovery` | Report dead watchers without resuming their owners |
46
47
  | `-NoAutoReload` | Never restart sessions onto a new build |
47
48
  | `-NoRemoteControl` | Report sessions off Remote Control without restarting them |
49
+ | `-NoLimitResume` | Report sessions a usage limit cut off without continuing them |
48
50
  | `-NoWait` | Return once the task is registered, before its first tick |
49
51
  | `-MuggleEntry <path>` | Run a specific `bin\muggle.js` instead of the global install |
50
52
  | `-Uninstall` | Remove the task; the ledger is kept |
@@ -3,7 +3,8 @@
3
3
  # Codex sessions, their processes and every PR-watch slot, alerts on dead or deaf
4
4
  # watchers, red CI and stale branches, restarts the loop of a registered slot
5
5
  # whose watcher died, fans plugin reloads out across sessions, and restarts
6
- # sessions that are off Remote Control under the current login.
6
+ # sessions that are off Remote Control under the current login, and continues
7
+ # background sessions a usage limit cut off once that limit resets.
7
8
  #
8
9
  # The task runs the global @muggleai/works install, whose path survives
9
10
  # `muggle upgrade`, so the service always runs the current release. Only the
@@ -22,6 +23,7 @@ param(
22
23
  [switch]$NoAutoReload,
23
24
  [switch]$NoRecovery,
24
25
  [switch]$NoRemoteControl,
26
+ [switch]$NoLimitResume,
25
27
  # Return once the task is registered; its trigger runs the first tick a minute later.
26
28
  [switch]$NoWait,
27
29
  [switch]$Uninstall
@@ -64,6 +66,7 @@ $tickFlags = @()
64
66
  if ($NoAutoReload) { $tickFlags += "--no-auto-reload" }
65
67
  if ($NoRecovery) { $tickFlags += "--no-recovery" }
66
68
  if ($NoRemoteControl) { $tickFlags += "--no-remote-control" }
69
+ if ($NoLimitResume) { $tickFlags += "--no-limit-resume" }
67
70
  $launcherArguments = @("`"$installedLauncher`"", "`"$($nodeCommand.Source)`"", "`"$MuggleEntry`"") + $tickFlags
68
71
 
69
72
  # wscript.exe rather than node.exe is what keeps every run off the desktop; see