@muggleai/works 5.31.0-staging.270 → 5.31.0-staging.272
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/.claude-plugin/marketplace.json +1 -1
- package/dist/{chunk-W3HJBL5U.js → chunk-IZQGF3F2.js} +269 -46
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/.codex-mcp.json +1 -1
- package/dist/plugin/.codex-plugin/plugin.json +1 -1
- package/dist/plugin/.cursor-plugin/plugin.json +1 -1
- package/dist/plugin/.mcp.json +1 -1
- package/dist/plugin/skills/muggle-pr-followup/state-schemas.md +1 -1
- package/dist/release-manifest.json +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-mcp.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.cursor-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -1
- package/plugin/skills/muggle-pr-followup/state-schemas.md +1 -1
- package/scripts/muggle-watch/README.md +5 -3
- package/scripts/muggle-watch/install-muggle-watch.ps1 +5 -2
|
@@ -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.
|
|
12
|
+
"version": "5.31.0-staging.272"
|
|
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
|
|
4904
|
+
const reload = snapshot.reload;
|
|
4887
4905
|
lines.push(
|
|
4888
4906
|
"",
|
|
4889
|
-
`Plugin reload: ${
|
|
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,31 +4987,50 @@ function isIdleBackgroundSession(session) {
|
|
|
4931
4987
|
}
|
|
4932
4988
|
|
|
4933
4989
|
// src/watch/claude-sessions.ts
|
|
4934
|
-
function
|
|
4935
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
|
5013
|
+
return entriesBySessionId;
|
|
5014
|
+
}
|
|
5015
|
+
function isPidAlive(pid) {
|
|
5016
|
+
try {
|
|
5017
|
+
process.kill(pid, 0);
|
|
5018
|
+
return true;
|
|
5019
|
+
} catch (error) {
|
|
5020
|
+
return error.code === "EPERM";
|
|
5021
|
+
}
|
|
5022
|
+
}
|
|
5023
|
+
function isSessionRunning(runCommand, registryDir, sessionId) {
|
|
5024
|
+
const registryEntry = readRegistryBySessionId(registryDir).get(sessionId);
|
|
5025
|
+
if (registryEntry !== void 0 && isPidAlive(registryEntry.pid)) return true;
|
|
5026
|
+
const sessions = readClaudeSessions(runCommand, registryDir);
|
|
5027
|
+
if (sessions === null) return null;
|
|
5028
|
+
const session = sessions.find((listed) => listed.sessionId === sessionId);
|
|
5029
|
+
return session !== void 0 && hasLiveProcess(session);
|
|
4953
5030
|
}
|
|
4954
5031
|
function waitForSessionExit(runCommand, registryDir, sessionId, sleep2 = blockFor) {
|
|
4955
5032
|
for (let waitedMs = 0; ; waitedMs += SESSION_EXIT_POLL_MS) {
|
|
4956
|
-
|
|
4957
|
-
const session = sessions?.find((listed) => listed.sessionId === sessionId);
|
|
4958
|
-
if (sessions !== null && (session === void 0 || !hasLiveProcess(session))) return true;
|
|
5033
|
+
if (isSessionRunning(runCommand, registryDir, sessionId) === false) return true;
|
|
4959
5034
|
if (waitedMs >= SESSION_EXIT_WAIT_MS) return false;
|
|
4960
5035
|
sleep2(SESSION_EXIT_POLL_MS);
|
|
4961
5036
|
}
|
|
@@ -4971,11 +5046,12 @@ function readClaudeSessions(runCommand, registryDir) {
|
|
|
4971
5046
|
} catch {
|
|
4972
5047
|
return null;
|
|
4973
5048
|
}
|
|
4974
|
-
const
|
|
5049
|
+
const registryBySessionId = readRegistryBySessionId(registryDir);
|
|
4975
5050
|
const sessions = [];
|
|
4976
5051
|
for (const listing of listings) {
|
|
4977
5052
|
const sessionId = optionalString(listing.sessionId);
|
|
4978
5053
|
if (!sessionId) continue;
|
|
5054
|
+
const registryEntry = registryBySessionId.get(sessionId);
|
|
4979
5055
|
sessions.push({
|
|
4980
5056
|
agent: "claude" /* Claude */,
|
|
4981
5057
|
sessionId,
|
|
@@ -4986,7 +5062,10 @@ function readClaudeSessions(runCommand, registryDir) {
|
|
|
4986
5062
|
turnStatus: enumValueOf(ClaudeTurnStatus, listing.status),
|
|
4987
5063
|
jobState: enumValueOf(ClaudeJobState, listing.state),
|
|
4988
5064
|
startedAtMs: typeof listing.startedAt === "number" ? listing.startedAt : void 0,
|
|
4989
|
-
pid: typeof listing.pid === "number" ? listing.pid :
|
|
5065
|
+
pid: typeof listing.pid === "number" ? listing.pid : registryEntry?.pid,
|
|
5066
|
+
processStatus: registryEntry?.processStatus,
|
|
5067
|
+
processStatusSinceMs: registryEntry?.processStatusSinceMs,
|
|
5068
|
+
remoteControlSessionId: registryEntry?.remoteControlSessionId
|
|
4990
5069
|
});
|
|
4991
5070
|
}
|
|
4992
5071
|
return sessions;
|
|
@@ -5275,13 +5354,22 @@ function startServiceLoops(assessments, nowMs, isPollingDisabled, ownerSessionOf
|
|
|
5275
5354
|
}
|
|
5276
5355
|
|
|
5277
5356
|
// src/watch/session-restart.ts
|
|
5278
|
-
function resumeSession(target, runCommand, waitForExit) {
|
|
5357
|
+
function resumeSession(target, runCommand, waitForExit, isSessionRunning2) {
|
|
5358
|
+
const runningSessionId = target.runningSessionId ?? target.sessionId;
|
|
5279
5359
|
if (target.runningShortId !== void 0) {
|
|
5280
5360
|
runCommand("claude", ["stop", target.runningShortId]);
|
|
5281
|
-
if (!waitForExit(
|
|
5282
|
-
}
|
|
5283
|
-
|
|
5284
|
-
|
|
5361
|
+
if (!waitForExit(runningSessionId)) return { outcome: "still-running" /* StillRunning */ };
|
|
5362
|
+
} else if (isSessionRunning2(runningSessionId)) {
|
|
5363
|
+
return { outcome: "still-running" /* StillRunning */ };
|
|
5364
|
+
}
|
|
5365
|
+
const resumed = runCommand("claude", ["--resume", target.sessionId, "--bg", target.prompt], { cwd: target.cwd, isStderrCaptured: true });
|
|
5366
|
+
if (resumed?.exitCode !== 0) return { outcome: "failed" /* Failed */ };
|
|
5367
|
+
const backgroundedShortId = resumed.stdout.match(BACKGROUNDED_SESSION_ID_PATTERN)?.[1];
|
|
5368
|
+
const ownShortIds = [shortSessionId(target.sessionId), shortSessionId(runningSessionId), target.runningShortId];
|
|
5369
|
+
if (backgroundedShortId === void 0 || ownShortIds.includes(backgroundedShortId)) return { outcome: "continued" /* Continued */ };
|
|
5370
|
+
const isCopyStopped = runCommand("claude", ["stop", backgroundedShortId])?.exitCode === 0;
|
|
5371
|
+
const isCopyRemoved = isCopyStopped && runCommand("claude", ["rm", backgroundedShortId])?.exitCode === 0;
|
|
5372
|
+
return { outcome: "copied" /* Copied */, copyShortId: backgroundedShortId, isCopyRemoved };
|
|
5285
5373
|
}
|
|
5286
5374
|
|
|
5287
5375
|
// src/watch/recovery.ts
|
|
@@ -5362,45 +5450,59 @@ function executeWatcherRecoveries(planned, attemptsByOwner, deps) {
|
|
|
5362
5450
|
});
|
|
5363
5451
|
return { ...recovery, cwd, outcome: "restart-refused" /* RestartRefused */ };
|
|
5364
5452
|
}
|
|
5365
|
-
const
|
|
5453
|
+
const resumed = resumeSession(
|
|
5366
5454
|
{ sessionId: recovery.ownerSessionId, cwd, prompt, runningShortId: recovery.ownerShortId, runningSessionId: recovery.ownerRunningSessionId },
|
|
5367
5455
|
deps.runCommand,
|
|
5368
|
-
deps.waitForExit
|
|
5456
|
+
deps.waitForExit,
|
|
5457
|
+
deps.isSessionRunning
|
|
5369
5458
|
);
|
|
5370
|
-
if (
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5459
|
+
if (resumed.outcome === "continued" /* Continued */) {
|
|
5460
|
+
markServiceLoopReportDelivered(recovery.slotDir);
|
|
5461
|
+
events.push({ at: nowIso, kind: "resumed" /* Resumed */, subject, message: `woke it with ${prompt}` });
|
|
5462
|
+
return { ...recovery, cwd, outcome: "resumed" /* Resumed */ };
|
|
5463
|
+
}
|
|
5464
|
+
countAttempt(recovery.ownerSessionId);
|
|
5465
|
+
if (resumed.outcome === "copied" /* Copied */) {
|
|
5466
|
+
const copyNote = resumed.isCopyRemoved ? "stopped and removed the copy" : `could not remove the copy, run claude rm ${resumed.copyShortId}`;
|
|
5467
|
+
events.push({
|
|
5468
|
+
at: nowIso,
|
|
5469
|
+
kind: "resume-forked" /* ResumeForked */,
|
|
5470
|
+
subject,
|
|
5471
|
+
message: `could not wake it: Claude still ran it and started copy ${resumed.copyShortId} instead; ${copyNote}`
|
|
5472
|
+
});
|
|
5473
|
+
return { ...recovery, cwd, outcome: "resume-forked" /* ResumeForked */ };
|
|
5474
|
+
}
|
|
5475
|
+
const reason = resumed.outcome === "still-running" /* StillRunning */ ? "it is still running, so a resume would start a copy" : `${prompt} did not start`;
|
|
5476
|
+
events.push({ at: nowIso, kind: "resume-failed" /* ResumeFailed */, subject, message: `could not wake it: ${reason}` });
|
|
5477
|
+
return { ...recovery, cwd, outcome: "resume-failed" /* ResumeFailed */ };
|
|
5379
5478
|
});
|
|
5380
5479
|
return { recoveries, attemptsByOwner: nextAttemptsByOwner, events };
|
|
5381
5480
|
}
|
|
5382
5481
|
|
|
5383
5482
|
// src/watch/findings.ts
|
|
5384
|
-
function resolveWatcherLiveness(loop, heartbeatAgeMs, isOwnerSessionActive, isProcessTableKnown, isHeardByService = false) {
|
|
5483
|
+
function resolveWatcherLiveness(loop, heartbeatAgeMs, isOwnerSessionActive, isProcessTableKnown, isHeardByService = false, isLoopDroppedByOwner = false) {
|
|
5385
5484
|
const isHeartbeatWithin = (windowMs) => heartbeatAgeMs !== void 0 && heartbeatAgeMs <= windowMs;
|
|
5386
5485
|
if (!isProcessTableKnown) {
|
|
5387
5486
|
return isHeartbeatWithin(WATCH_HEARTBEAT_STALE_MS) ? "polling" /* Polling */ : "dead" /* Dead */;
|
|
5388
5487
|
}
|
|
5389
5488
|
if (loop) {
|
|
5390
|
-
|
|
5489
|
+
const isUnheard = !isHeardByService && (isOwnerSessionActive === false || isLoopDroppedByOwner);
|
|
5490
|
+
return isUnheard ? "deaf" /* Deaf */ : "polling" /* Polling */;
|
|
5391
5491
|
}
|
|
5392
5492
|
return isHeartbeatWithin(WATCHER_REARM_GRACE_MS) ? "rearming" /* Rearming */ : "dead" /* Dead */;
|
|
5393
5493
|
}
|
|
5394
5494
|
function oldestLoopFor(slug, processes) {
|
|
5395
5495
|
return processes.filter((tracked) => tracked.kind === "pr-watch-loop" /* PrWatchLoop */ && tracked.watchSlotSlug === slug).sort((left, right) => (right.ageMs ?? 0) - (left.ageMs ?? 0))[0];
|
|
5396
5496
|
}
|
|
5397
|
-
function assessWatchSlots(slots, processes, activeSessionIds, liveStatusesByUrl, nowMs) {
|
|
5497
|
+
function assessWatchSlots(slots, processes, activeSessionIds, liveStatusesByUrl, nowMs, idleSinceBySessionId2 = /* @__PURE__ */ new Map()) {
|
|
5398
5498
|
const assessments = [];
|
|
5399
5499
|
for (const slot of slots) {
|
|
5400
5500
|
const loop = processes ? oldestLoopFor(slot.slug, processes) : void 0;
|
|
5401
5501
|
const heartbeatAgeMs = slot.heartbeatMtimeMs === void 0 ? void 0 : Math.max(0, nowMs - slot.heartbeatMtimeMs);
|
|
5402
5502
|
const isOwnerSessionActive = slot.ownerSessionId === void 0 || activeSessionIds === null ? void 0 : activeSessionIds.has(slot.ownerSessionId);
|
|
5403
|
-
const
|
|
5503
|
+
const ownerIdleSinceMs = slot.ownerSessionId === void 0 ? void 0 : idleSinceBySessionId2.get(slot.ownerSessionId);
|
|
5504
|
+
const isLoopDroppedByOwner = ownerIdleSinceMs !== void 0 && loop?.ageMs !== void 0 && ownerIdleSinceMs >= nowMs - loop.ageMs;
|
|
5505
|
+
const watcher = resolveWatcherLiveness(loop, heartbeatAgeMs, isOwnerSessionActive, processes !== null, slot.isHeardByService, isLoopDroppedByOwner);
|
|
5404
5506
|
const reportAgeMs = slot.serviceLoopReport === void 0 ? void 0 : Math.max(0, nowMs - slot.serviceLoopReport.writtenAtMs);
|
|
5405
5507
|
for (const pr of slot.openPrs) {
|
|
5406
5508
|
const live = liveStatusesByUrl.get(pr.url);
|
|
@@ -5415,6 +5517,7 @@ function assessWatchSlots(slots, processes, activeSessionIds, liveStatusesByUrl,
|
|
|
5415
5517
|
heartbeatAgeMs,
|
|
5416
5518
|
ownerSessionId: slot.ownerSessionId,
|
|
5417
5519
|
isOwnerSessionActive,
|
|
5520
|
+
isLoopDroppedByOwner,
|
|
5418
5521
|
live,
|
|
5419
5522
|
ownerEscalation: resolveOwnerEscalation(slot.lastSeenJson, slot.followupLogLines, pr, live?.headSha),
|
|
5420
5523
|
isHeardByService: slot.isHeardByService,
|
|
@@ -5461,11 +5564,12 @@ function watcherFinding(assessment, subject) {
|
|
|
5461
5564
|
);
|
|
5462
5565
|
}
|
|
5463
5566
|
if (assessment.watcher === "deaf" /* Deaf */) {
|
|
5567
|
+
const ownerState = assessment.isLoopDroppedByOwner ? "is running but no longer holds it" : "has ended";
|
|
5464
5568
|
return finding(
|
|
5465
5569
|
"WATCHER_DEAF" /* WatcherDeaf */,
|
|
5466
5570
|
[assessment.pr.url, assessment.loopPid],
|
|
5467
5571
|
subject,
|
|
5468
|
-
`watch loop pid ${assessment.loopPid} reports to no session: its owner session ${shortSessionId(assessment.ownerSessionId ?? "")}
|
|
5572
|
+
`watch loop pid ${assessment.loopPid} reports to no session: its owner session ${shortSessionId(assessment.ownerSessionId ?? "")} ${ownerState}`
|
|
5469
5573
|
);
|
|
5470
5574
|
}
|
|
5471
5575
|
return void 0;
|
|
@@ -5906,6 +6010,75 @@ function advancePendingReload(pending, report) {
|
|
|
5906
6010
|
skippedSessionIds: [...pending.skippedSessionIds, ...report.untrustedSessionIds]
|
|
5907
6011
|
};
|
|
5908
6012
|
}
|
|
6013
|
+
|
|
6014
|
+
// src/watch/remote-control.ts
|
|
6015
|
+
function advanceClaudeLogin(previous, observed, nowMs) {
|
|
6016
|
+
if (observed.accountUuid === void 0) return previous;
|
|
6017
|
+
if (previous === void 0) return { accountUuid: observed.accountUuid, observedAtMs: nowMs };
|
|
6018
|
+
if (previous.accountUuid === observed.accountUuid) return { ...previous, observedAtMs: nowMs };
|
|
6019
|
+
const writtenAtMs = Math.min(observed.credentialsWrittenAtMs ?? nowMs, nowMs);
|
|
6020
|
+
return { accountUuid: observed.accountUuid, effectiveAtMs: Math.max(previous.observedAtMs, writtenAtMs), observedAtMs: nowMs };
|
|
6021
|
+
}
|
|
6022
|
+
function remoteControlGapOf(session, login) {
|
|
6023
|
+
if (session.remoteControlSessionId === void 0) return "no Remote Control link";
|
|
6024
|
+
const isStartedBeforeLogin = login.effectiveAtMs !== void 0 && session.startedAtMs !== void 0 && session.startedAtMs < login.effectiveAtMs;
|
|
6025
|
+
return isStartedBeforeLogin ? "linked under an earlier login" : void 0;
|
|
6026
|
+
}
|
|
6027
|
+
function isTurnOver(session) {
|
|
6028
|
+
if (session.processStatus !== void 0) {
|
|
6029
|
+
return session.processStatus === "idle" /* Idle */ || session.processStatus === "shell" /* Shell */;
|
|
6030
|
+
}
|
|
6031
|
+
return session.turnStatus === "idle" /* Idle */;
|
|
6032
|
+
}
|
|
6033
|
+
function recentRestartsMs(restartsBySession, sessionId, nowMs) {
|
|
6034
|
+
return (restartsBySession[sessionId] ?? []).map((restartedAt) => Date.parse(restartedAt)).filter((restartedAtMs) => !Number.isNaN(restartedAtMs) && nowMs - restartedAtMs < REMOTE_CONTROL_RESTART_WINDOW_MS);
|
|
6035
|
+
}
|
|
6036
|
+
function decide2(session, action, reason) {
|
|
6037
|
+
return { sessionId: session.sessionId, shortId: session.shortId, name: session.name, cwd: session.cwd, action, reason };
|
|
6038
|
+
}
|
|
6039
|
+
function planRemoteControlRestarts(sessions, login, restartsBySession, nowMs, maxRestarts) {
|
|
6040
|
+
let restartsPlanned = 0;
|
|
6041
|
+
const connectedSessionIds = [];
|
|
6042
|
+
const decisions = [];
|
|
6043
|
+
for (const session of sessions) {
|
|
6044
|
+
if (session.agent !== "claude" /* Claude */ || !hasLiveProcess(session)) continue;
|
|
6045
|
+
const gap = remoteControlGapOf(session, login);
|
|
6046
|
+
if (gap === void 0) {
|
|
6047
|
+
connectedSessionIds.push(session.sessionId);
|
|
6048
|
+
continue;
|
|
6049
|
+
}
|
|
6050
|
+
const restartsMs = recentRestartsMs(restartsBySession, session.sessionId, nowMs);
|
|
6051
|
+
if (session.kind === "interactive" /* Interactive */) {
|
|
6052
|
+
decisions.push(decide2(session, "notify-manual" /* NotifyManual */, `${gap} \u2014 terminal session, run /remote-control in it`));
|
|
6053
|
+
} else if (session.kind !== "background" /* Background */ || !session.shortId) {
|
|
6054
|
+
decisions.push(decide2(session, "skip" /* Skip */, `${gap} \u2014 not a background session the watch can restart`));
|
|
6055
|
+
} else if (restartsMs.length >= REMOTE_CONTROL_MAX_RESTARTS) {
|
|
6056
|
+
decisions.push(decide2(session, "skip" /* Skip */, `${gap} after ${restartsMs.length} restarts today`));
|
|
6057
|
+
} else if (nowMs - Math.max(-Infinity, ...restartsMs) < REMOTE_CONTROL_RESTART_COOLDOWN_MS) {
|
|
6058
|
+
decisions.push(decide2(session, "skip" /* Skip */, `${gap} \u2014 restarted recently, waiting for it to link`));
|
|
6059
|
+
} else if (!isTurnOver(session)) {
|
|
6060
|
+
decisions.push(decide2(session, "defer" /* Defer */, `${gap} \u2014 turn in flight (${session.processStatus ?? session.turnStatus ?? "unknown"})`));
|
|
6061
|
+
} else if (restartsPlanned >= maxRestarts) {
|
|
6062
|
+
decisions.push(decide2(session, "defer" /* Defer */, `${gap} \u2014 restart cap reached this pass`));
|
|
6063
|
+
} else {
|
|
6064
|
+
restartsPlanned++;
|
|
6065
|
+
decisions.push(decide2(session, "respawn" /* Respawn */, gap));
|
|
6066
|
+
}
|
|
6067
|
+
}
|
|
6068
|
+
return { connectedSessionIds, decisions };
|
|
6069
|
+
}
|
|
6070
|
+
function recordRemoteControlRestarts(restartsBySession, report, nowMs) {
|
|
6071
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
6072
|
+
const nextRestartsBySession = {};
|
|
6073
|
+
for (const sessionId of Object.keys(restartsBySession)) {
|
|
6074
|
+
const recent = recentRestartsMs(restartsBySession, sessionId, nowMs).map((restartedAtMs) => new Date(restartedAtMs).toISOString());
|
|
6075
|
+
if (recent.length > 0) nextRestartsBySession[sessionId] = recent;
|
|
6076
|
+
}
|
|
6077
|
+
for (const sessionId of [...report.respawnedSessionIds, ...report.failedSessionIds, ...report.untrustedSessionIds]) {
|
|
6078
|
+
nextRestartsBySession[sessionId] = [...nextRestartsBySession[sessionId] ?? [], nowIso];
|
|
6079
|
+
}
|
|
6080
|
+
return nextRestartsBySession;
|
|
6081
|
+
}
|
|
5909
6082
|
function resolvePluginRoot(env = process.env) {
|
|
5910
6083
|
return env.CLAUDE_PLUGIN_ROOT || env.CURSOR_PLUGIN_ROOT || dirname(dirname(fileURLToPath(import.meta.url)));
|
|
5911
6084
|
}
|
|
@@ -6273,6 +6446,7 @@ function createDefaultWatchTickDeps(options) {
|
|
|
6273
6446
|
notify: createDesktopNotifier(process.platform, defaultCommandRunner),
|
|
6274
6447
|
isAutoReloadEnabled: options.isAutoReloadEnabled,
|
|
6275
6448
|
isRecoveryEnabled: options.isRecoveryEnabled,
|
|
6449
|
+
isRemoteControlKeptOn: options.isRemoteControlKeptOn,
|
|
6276
6450
|
launchServiceLoop: createServiceLoopLauncher(process.platform, defaultCommandRunner)
|
|
6277
6451
|
};
|
|
6278
6452
|
}
|
|
@@ -6292,18 +6466,26 @@ function observeSessions(deps, installs) {
|
|
|
6292
6466
|
const sessions = [...claudeSessions ?? [], ...readCodexSessions(deps.paths.codexSessionsDir, deps.nowMs)];
|
|
6293
6467
|
return { processes, claudeSessions, sessions };
|
|
6294
6468
|
}
|
|
6469
|
+
function idleSinceBySessionId(runningSessions) {
|
|
6470
|
+
return new Map(
|
|
6471
|
+
runningSessions.flatMap(
|
|
6472
|
+
(session) => session.processStatus === "idle" /* Idle */ && session.processStatusSinceMs !== void 0 ? [[session.sessionId, session.processStatusSinceMs]] : []
|
|
6473
|
+
)
|
|
6474
|
+
);
|
|
6475
|
+
}
|
|
6295
6476
|
function isRestartAttempted(recovery) {
|
|
6296
|
-
return recovery.outcome === "resumed" /* Resumed */ || recovery.outcome === "resume-failed" /* ResumeFailed */;
|
|
6477
|
+
return recovery.outcome === "resumed" /* Resumed */ || recovery.outcome === "resume-failed" /* ResumeFailed */ || recovery.outcome === "resume-forked" /* ResumeForked */;
|
|
6297
6478
|
}
|
|
6298
6479
|
function watchOwnersOf(slots) {
|
|
6299
6480
|
return new Set(slots.flatMap((slot) => !isTerminalPrState(slot.live?.state) && slot.ownerSessionId ? [slot.ownerSessionId] : []));
|
|
6300
6481
|
}
|
|
6301
|
-
function reloadEvents(report, at) {
|
|
6482
|
+
function reloadEvents(report, at, restartPurpose) {
|
|
6302
6483
|
const decisionById = new Map(report.decisions.map((decision) => [decision.sessionId, decision]));
|
|
6303
6484
|
const describe = (sessionId) => `session ${decisionById.get(sessionId)?.name ?? shortSessionId(sessionId)}`;
|
|
6485
|
+
const purposeOf = (sessionId) => restartPurpose === void 0 ? {} : { message: `${restartPurpose}: ${decisionById.get(sessionId)?.reason ?? ""}` };
|
|
6304
6486
|
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) })),
|
|
6487
|
+
...report.respawnedSessionIds.map((sessionId) => ({ at, kind: "respawned" /* Respawned */, subject: describe(sessionId), ...purposeOf(sessionId) })),
|
|
6488
|
+
...report.failedSessionIds.map((sessionId) => ({ at, kind: "respawn-failed" /* RespawnFailed */, subject: describe(sessionId), ...purposeOf(sessionId) })),
|
|
6307
6489
|
...report.untrustedSessionIds.map((sessionId) => ({
|
|
6308
6490
|
at,
|
|
6309
6491
|
kind: "restart-refused" /* RestartRefused */,
|
|
@@ -6321,12 +6503,24 @@ function runReloadPass(record, claudeSessions, watchOwnerSessionIds, deps, at) {
|
|
|
6321
6503
|
const report = executeReloadFanOut(decisions, deps.runCommand, canRestartVia(deps));
|
|
6322
6504
|
return { report, pendingReload: advancePendingReload(record, report), events: reloadEvents(report, at) };
|
|
6323
6505
|
}
|
|
6506
|
+
function runRemoteControlPass(login, claudeSessions, restartsBySession, maxRestarts, deps, at) {
|
|
6507
|
+
const plan = planRemoteControlRestarts(claudeSessions, login, restartsBySession, deps.nowMs, maxRestarts);
|
|
6508
|
+
const executed = executeReloadFanOut(plan.decisions, deps.runCommand, canRestartVia(deps));
|
|
6509
|
+
return {
|
|
6510
|
+
report: { ...executed, connectedSessionIds: plan.connectedSessionIds },
|
|
6511
|
+
restartsBySession: recordRemoteControlRestarts(restartsBySession, executed, deps.nowMs),
|
|
6512
|
+
events: reloadEvents(executed, at, "restarted to reconnect Remote Control")
|
|
6513
|
+
};
|
|
6514
|
+
}
|
|
6324
6515
|
function canRestartVia(deps) {
|
|
6325
6516
|
return (cwd) => canRestartSessionIn(cwd, deps.runCommand);
|
|
6326
6517
|
}
|
|
6327
6518
|
function waitForExitVia(deps) {
|
|
6328
6519
|
return (sessionId) => waitForSessionExit(deps.runCommand, deps.paths.claudeSessionRegistryDir, sessionId, deps.sleep);
|
|
6329
6520
|
}
|
|
6521
|
+
function isSessionRunningVia(deps) {
|
|
6522
|
+
return (sessionId) => isSessionRunning(deps.runCommand, deps.paths.claudeSessionRegistryDir, sessionId) !== false;
|
|
6523
|
+
}
|
|
6330
6524
|
function reloadTargetAtMs(deps, installs) {
|
|
6331
6525
|
return resolveReloadTargetAtMs(installs, readMugglePluginUpdatedAtMs(deps.paths.claudeInstalledPluginsFile));
|
|
6332
6526
|
}
|
|
@@ -6366,7 +6560,7 @@ function runTickUnlocked(deps) {
|
|
|
6366
6560
|
at
|
|
6367
6561
|
);
|
|
6368
6562
|
for (const cachedStatus of cachedStatuses) liveStatusesByUrl.set(cachedStatus.url, cachedStatus);
|
|
6369
|
-
const allAssessments = assessWatchSlots(slots, processes, activeSessionIds, liveStatusesByUrl, deps.nowMs);
|
|
6563
|
+
const allAssessments = assessWatchSlots(slots, processes, activeSessionIds, liveStatusesByUrl, deps.nowMs, idleSinceBySessionId(runningSessions));
|
|
6370
6564
|
const closeOut = closeOutTerminalSlots(allAssessments, deps, at);
|
|
6371
6565
|
const assessments = allAssessments.filter((assessment) => !closeOut.closedOutSlugs.includes(assessment.slug));
|
|
6372
6566
|
const findings = deriveWatchFindings(assessments, processes ?? [], isPollingDisabled);
|
|
@@ -6394,7 +6588,8 @@ function runTickUnlocked(deps) {
|
|
|
6394
6588
|
claudeProjectsDir: deps.paths.claudeProjectsDir,
|
|
6395
6589
|
nowMs: deps.nowMs,
|
|
6396
6590
|
canRestartIn: canRestartVia(deps),
|
|
6397
|
-
waitForExit: waitForExitVia(deps)
|
|
6591
|
+
waitForExit: waitForExitVia(deps),
|
|
6592
|
+
isSessionRunning: isSessionRunningVia(deps)
|
|
6398
6593
|
});
|
|
6399
6594
|
recoveries = executed.recoveries;
|
|
6400
6595
|
recoveryAttemptsByOwner = executed.attemptsByOwner;
|
|
@@ -6409,16 +6604,34 @@ function runTickUnlocked(deps) {
|
|
|
6409
6604
|
serviceArms = started.serviceArms;
|
|
6410
6605
|
events.push(...started.events);
|
|
6411
6606
|
}
|
|
6607
|
+
const restartedSessionIds = new Set(recoveries.filter(isRestartAttempted).map((recovery) => recovery.ownerSessionId));
|
|
6412
6608
|
let pendingReload = state.pendingReload;
|
|
6413
6609
|
let reload;
|
|
6414
6610
|
const targetAtMs = reloadTargetAtMs(deps, installs);
|
|
6415
6611
|
if (deps.isAutoReloadEnabled && targetAtMs !== void 0 && claudeSessions !== null) {
|
|
6416
|
-
const
|
|
6417
|
-
const reloadableSessions = claudeSessions.filter((session) => !restartedOwnerIds.has(session.sessionId));
|
|
6612
|
+
const reloadableSessions = claudeSessions.filter((session) => !restartedSessionIds.has(session.sessionId));
|
|
6418
6613
|
const pass = runReloadPass(reloadRecordFor(state, targetAtMs), reloadableSessions, watchOwnersOf(assessments), deps, at);
|
|
6419
6614
|
pendingReload = pass.pendingReload;
|
|
6420
6615
|
reload = pass.report;
|
|
6421
6616
|
events.push(...pass.events);
|
|
6617
|
+
for (const sessionId of [...reload.respawnedSessionIds, ...reload.failedSessionIds]) restartedSessionIds.add(sessionId);
|
|
6618
|
+
}
|
|
6619
|
+
const observedLogin = readClaudeLogin(deps.paths.claudeGlobalConfigFile, deps.paths.claudeCredentialsFile);
|
|
6620
|
+
const claudeLogin = advanceClaudeLogin(state.claudeLogin, observedLogin, deps.nowMs);
|
|
6621
|
+
let remoteControlRestartsBySession = state.remoteControlRestartsBySession ?? {};
|
|
6622
|
+
let remoteControl;
|
|
6623
|
+
if (deps.isRemoteControlKeptOn && claudeSessions !== null && claudeLogin !== void 0 && observedLogin.accountUuid !== void 0 && isRemoteControlAtStartup(deps.paths.claudeSettingsFile)) {
|
|
6624
|
+
const pass = runRemoteControlPass(
|
|
6625
|
+
claudeLogin,
|
|
6626
|
+
claudeSessions.filter((session) => !restartedSessionIds.has(session.sessionId)),
|
|
6627
|
+
remoteControlRestartsBySession,
|
|
6628
|
+
Math.max(0, MAX_RESPAWNS_PER_TICK - (reload ? reload.respawnedSessionIds.length + reload.failedSessionIds.length : 0)),
|
|
6629
|
+
deps,
|
|
6630
|
+
at
|
|
6631
|
+
);
|
|
6632
|
+
remoteControl = pass.report;
|
|
6633
|
+
remoteControlRestartsBySession = pass.restartsBySession;
|
|
6634
|
+
events.push(...pass.events);
|
|
6422
6635
|
}
|
|
6423
6636
|
appendWatchEvents(deps.paths.watchDir, events);
|
|
6424
6637
|
notifyIfAny(deps, composeTickNotification(transitions.raised, recoveries, reload));
|
|
@@ -6426,7 +6639,9 @@ function runTickUnlocked(deps) {
|
|
|
6426
6639
|
activeFindings: transitions.activeFindings,
|
|
6427
6640
|
recoveryAttemptsByOwner,
|
|
6428
6641
|
terminalPrsByUrl,
|
|
6429
|
-
pendingReload
|
|
6642
|
+
pendingReload,
|
|
6643
|
+
claudeLogin,
|
|
6644
|
+
remoteControlRestartsBySession
|
|
6430
6645
|
});
|
|
6431
6646
|
const snapshot = {
|
|
6432
6647
|
takenAt: at,
|
|
@@ -6439,7 +6654,8 @@ function runTickUnlocked(deps) {
|
|
|
6439
6654
|
findings,
|
|
6440
6655
|
recoveries,
|
|
6441
6656
|
serviceArms,
|
|
6442
|
-
reload
|
|
6657
|
+
reload,
|
|
6658
|
+
remoteControl
|
|
6443
6659
|
};
|
|
6444
6660
|
writeWatchSnapshot(deps.paths.watchDir, snapshot);
|
|
6445
6661
|
return snapshot;
|
|
@@ -6493,7 +6709,11 @@ function failWith(message) {
|
|
|
6493
6709
|
function watchTickCommand(flags) {
|
|
6494
6710
|
try {
|
|
6495
6711
|
const snapshot = runWatchTick(
|
|
6496
|
-
createDefaultWatchTickDeps({
|
|
6712
|
+
createDefaultWatchTickDeps({
|
|
6713
|
+
isAutoReloadEnabled: flags.autoReload,
|
|
6714
|
+
isRecoveryEnabled: flags.recovery,
|
|
6715
|
+
isRemoteControlKeptOn: flags.remoteControl
|
|
6716
|
+
})
|
|
6497
6717
|
);
|
|
6498
6718
|
if (flags.json) {
|
|
6499
6719
|
process.stdout.write(`${JSON.stringify(snapshot, null, 2)}
|
|
@@ -6533,7 +6753,10 @@ function renderReloadReport(report, isDryRun) {
|
|
|
6533
6753
|
function watchReloadPluginsCommand(flags) {
|
|
6534
6754
|
try {
|
|
6535
6755
|
const isDryRun = flags.dryRun === true;
|
|
6536
|
-
const report = runReloadPluginsPass(
|
|
6756
|
+
const report = runReloadPluginsPass(
|
|
6757
|
+
createDefaultWatchTickDeps({ isAutoReloadEnabled: true, isRecoveryEnabled: false, isRemoteControlKeptOn: false }),
|
|
6758
|
+
isDryRun
|
|
6759
|
+
);
|
|
6537
6760
|
if (!report) {
|
|
6538
6761
|
failWith("could not list Claude sessions (`claude agents --json` failed)");
|
|
6539
6762
|
return;
|
|
@@ -6618,7 +6841,7 @@ function createProgram() {
|
|
|
6618
6841
|
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
6842
|
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
6843
|
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);
|
|
6844
|
+
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
6845
|
watch.command("status").description("Show the last watch pass: sessions, processes, PR watches, findings").option("--json", "Print the snapshot as JSON").action(watchStatusCommand);
|
|
6623
6846
|
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
6847
|
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
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-
|
|
1
|
+
export { src_exports as commands, createUnifiedMcpServer, server_exports as server } from './chunk-IZQGF3F2.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.
|
|
4
|
+
"version": "5.31.0-staging.272",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
|
-
"version": "5.31.0-staging.
|
|
3
|
+
"version": "5.31.0-staging.272",
|
|
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.
|
|
5
|
+
"version": "5.31.0-staging.272",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/dist/plugin/.mcp.json
CHANGED
|
@@ -61,7 +61,7 @@ The slot's opt-in to recovery by the muggle watch service (`muggle watch`, a per
|
|
|
61
61
|
|
|
62
62
|
Recovery requires all of: the slot is registered, the PR is open on GitHub, no loop is heard (no loop process carries the slot's `--slot` path, or the one that does prints into a session that has ended), the heartbeat is older than the re-arm hand-off, `polling.disabled` is absent, `owner.json` names an owner, and that owner is not mid-turn. The service then runs `pr-watch-arm.sh` itself, detached, with `MUGGLE_PR_WATCH_LISTENER=muggle-watch`. The loop records [`watch-listener`](#watch-listener) and writes its output to [`watch-service.out`](#watch-serviceout), and no session spends a turn on it.
|
|
63
63
|
|
|
64
|
-
When that loop exits on an `EVENT` or `TERMINAL` line, the service wakes the owner with `/muggle:muggle-pr-followup <slug> <n> --wake=<event|terminal>`, naming that slot and PR only. It resumes the owner in the background from the project dir its transcript records. A running owner is woken only as a background session idle between turns: it is stopped first, and only after a throwaway background launch confirms Claude will resume it in that workspace. An owner that is mid-turn, waiting on its user, or open in a terminal keeps the report, and the service alerts once the report is overdue. The service never wakes the ended owner of a merged or closed PR; it closes that slot out instead. An `EXPIRED` loop is restarted without waking anyone. The service caps loop starts and wakes per pass, caps failed wakes per owner, and holds every wake while the account sits at a usage limit.
|
|
64
|
+
When that loop exits on an `EVENT` or `TERMINAL` line, the service wakes the owner with `/muggle:muggle-pr-followup <slug> <n> --wake=<event|terminal>`, naming that slot and PR only. It resumes the owner in the background from the project dir its transcript records. A running owner is woken only as a background session idle between turns: it is stopped first, and only after a throwaway background launch confirms Claude will resume it in that workspace. The resume runs only once neither `claude agents` nor Claude's own session registry shows a process for the owner. If Claude still answers with a copy under a new id, the service stops and removes that copy and counts the wake as failed, so the agent view never gains a duplicate. An owner that is mid-turn, waiting on its user, or open in a terminal keeps the report, and the service alerts once the report is overdue. The service never wakes the ended owner of a merged or closed PR; it closes that slot out instead. An `EXPIRED` loop is restarted without waking anyone. The service caps loop starts and wakes per pass, caps failed wakes per owner, and holds every wake while the account sits at a usage limit.
|
|
65
65
|
|
|
66
66
|
**Absent ⇒ observed only.** The service still reports on the slot's watcher, CI and branch standing, but never starts its loop or wakes its owner. Slots armed before this file existed stay that way until their next arm. The file lives in the slot, so a `.stopped` rename or a `result.md` retires it with the slot.
|
|
67
67
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"release": "5.26.0",
|
|
3
|
-
"buildId": "run-
|
|
4
|
-
"commitSha": "
|
|
5
|
-
"buildTime": "2026-
|
|
3
|
+
"buildId": "run-272-1",
|
|
4
|
+
"commitSha": "76a7b1d3658b798a4b809e758e42e7128e23aba1",
|
|
5
|
+
"buildTime": "2026-11-01T06:59:53Z",
|
|
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.
|
|
4
|
+
"version": "5.31.0-staging.272",
|
|
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.
|
|
4
|
+
"version": "5.31.0-staging.272",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Muggle AI",
|
|
7
7
|
"email": "support@muggle-ai.com"
|
package/plugin/.codex-mcp.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "muggle",
|
|
3
|
-
"version": "5.31.0-staging.
|
|
3
|
+
"version": "5.31.0-staging.272",
|
|
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.
|
|
5
|
+
"version": "5.31.0-staging.272",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Muggle AI",
|
|
8
8
|
"email": "support@muggle-ai.com"
|
package/plugin/.mcp.json
CHANGED
|
@@ -61,7 +61,7 @@ The slot's opt-in to recovery by the muggle watch service (`muggle watch`, a per
|
|
|
61
61
|
|
|
62
62
|
Recovery requires all of: the slot is registered, the PR is open on GitHub, no loop is heard (no loop process carries the slot's `--slot` path, or the one that does prints into a session that has ended), the heartbeat is older than the re-arm hand-off, `polling.disabled` is absent, `owner.json` names an owner, and that owner is not mid-turn. The service then runs `pr-watch-arm.sh` itself, detached, with `MUGGLE_PR_WATCH_LISTENER=muggle-watch`. The loop records [`watch-listener`](#watch-listener) and writes its output to [`watch-service.out`](#watch-serviceout), and no session spends a turn on it.
|
|
63
63
|
|
|
64
|
-
When that loop exits on an `EVENT` or `TERMINAL` line, the service wakes the owner with `/muggle:muggle-pr-followup <slug> <n> --wake=<event|terminal>`, naming that slot and PR only. It resumes the owner in the background from the project dir its transcript records. A running owner is woken only as a background session idle between turns: it is stopped first, and only after a throwaway background launch confirms Claude will resume it in that workspace. An owner that is mid-turn, waiting on its user, or open in a terminal keeps the report, and the service alerts once the report is overdue. The service never wakes the ended owner of a merged or closed PR; it closes that slot out instead. An `EXPIRED` loop is restarted without waking anyone. The service caps loop starts and wakes per pass, caps failed wakes per owner, and holds every wake while the account sits at a usage limit.
|
|
64
|
+
When that loop exits on an `EVENT` or `TERMINAL` line, the service wakes the owner with `/muggle:muggle-pr-followup <slug> <n> --wake=<event|terminal>`, naming that slot and PR only. It resumes the owner in the background from the project dir its transcript records. A running owner is woken only as a background session idle between turns: it is stopped first, and only after a throwaway background launch confirms Claude will resume it in that workspace. The resume runs only once neither `claude agents` nor Claude's own session registry shows a process for the owner. If Claude still answers with a copy under a new id, the service stops and removes that copy and counts the wake as failed, so the agent view never gains a duplicate. An owner that is mid-turn, waiting on its user, or open in a terminal keeps the report, and the service alerts once the report is overdue. The service never wakes the ended owner of a merged or closed PR; it closes that slot out instead. An `EXPIRED` loop is restarted without waking anyone. The service caps loop starts and wakes per pass, caps failed wakes per owner, and holds every wake while the account sits at a usage limit.
|
|
65
65
|
|
|
66
66
|
**Absent ⇒ observed only.** The service still reports on the slot's watcher, CI and branch standing, but never starts its loop or wakes its owner. Slots armed before this file existed stay that way until their next arm. The file lives in the slot, so a `.stopped` rename or a `result.md` retires it with the slot.
|
|
67
67
|
|
|
@@ -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)
|
|
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. A wake that Claude answers with a copy of the session under a new id counts as failed, and the copy is stopped and removed. 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
|
|
10
|
-
- **
|
|
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,
|
|
5
|
-
#
|
|
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
|