diff --git a/apps/effectcraft-web/src/files.rs b/apps/effectcraft-web/src/files.rs
--- a/apps/effectcraft-web/src/files.rs
+++ b/apps/effectcraft-web/src/files.rs
@@ -151,6 +151,10 @@ impl Services for WebServices {
     fn is_dir(&self, _path: &str) -> bool {
         false
     }
+    /// The table's keys are the paths themselves.
+    fn full_path(&self, path: &str) -> String {
+        path.to_string()
+    }
 }
 
 /// Media import from the table (the bytes are also handed to the media pool, which decodes them).
diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs
--- a/crates/engine/src/cmd/file_more.rs
+++ b/crates/engine/src/cmd/file_more.rs
@@ -430,6 +430,20 @@ fn collect_files(s: &mut Session, p: &Value) -> Result<Value> {
 /// array or JSON lines of `{"command": id, "params": {…}}` (also accepts the control channel's
 /// `{"method": "engine.execute", "params": {"id", "params"}}`).
 fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
+    // File ▸ Scripts ▸ Recent Script Files ▸ <script> (0 is the most recent).
+    if let Some(i) = p.get("recent") {
+        let i = i.as_u64().ok_or_else(|| bad("file.runScript", format!("`recent` must be an index, not {i}")))?;
+        let script = match s.prefs.recent_scripts.get(usize::try_from(i).unwrap_or(usize::MAX)) {
+            Some(x) => x.clone(),
+            None if s.prefs.recent_scripts.is_empty() => return Err(bad("file.runScript", "no recent script files")),
+            None => return Err(bad("file.runScript", format!("no recent script file #{i}"))),
+        };
+        let q = match script {
+            crate::prefs::RecentScript::Path(path) => json!({"path": path}),
+            crate::prefs::RecentScript::Name(name) => json!({"name": name}),
+        };
+        return run_script(s, &q);
+    }
     // File ▸ Scripts ▸ <installed or sample script>.
     if let Some(name) = str_p(p, "name")
         && p.get("path").is_none()
@@ -446,15 +460,20 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
             return Err(bad("file.runScript", JSXBIN));
         }
         let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
+        let full = s.services.full_path(path);
         let code = String::from_utf8_lossy(&bytes).to_string();
         let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
         let out = run_js(s, &code, path, false)?;
-        return report_js(s, &name, out);
+        let result = report_js(s, &name, out)?;
+        remember_script(s, crate::prefs::RecentScript::Path(full));
+        return Ok(result);
     }
+    let mut script_path = None;
     let steps: Vec<Value> = match (p.get("steps"), str_p(p, "path")) {
         (Some(Value::Array(a)), _) => a.clone(),
         (_, Some(path)) => {
             let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
+            script_path = Some(s.services.full_path(path));
             let text = String::from_utf8_lossy(&bytes).to_string();
             match serde_json::from_str::<Value>(&text) {
                 Ok(Value::Array(a)) => a,
@@ -487,9 +506,19 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
         let r = r.map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?;
         results.push(r);
     }
+    if let Some(path) = script_path {
+        remember_script(s, crate::prefs::RecentScript::Path(path));
+    }
     Ok(json!({"steps": results.len(), "results": results}))
 }
 
+/// Remember a script that runs for File ▸ Scripts ▸ Recent Script Files.
+pub(super) fn remember_script(s: &mut Session, script: crate::prefs::RecentScript) {
+    s.prefs.push_recent_script(script);
+    s.save_prefs();
+    s.prefs_revision += 1;
+}
+
 /// `.jsx` / `.js` (and `.jsxbin`, rejected) are JavaScript; `.json` / `.jsonl` / other files are
 /// command scripts.
 fn is_js_script(path: &str) -> bool {
@@ -1017,7 +1046,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Run Script File...",
             ["File", "Scripts"],
             None,
-            "{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | steps: [{command, params}]}",
+            "{path (.jsx/.js JavaScript, or a .jsonl/.json command script) | name (an installed or sample script, see file.scripts.list) | recent: index (File ▸ Scripts ▸ Recent Script Files, 0 = the most recent) | steps: [{command, params}]}",
             always,
             run_script
         ),
diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs
--- a/crates/engine/src/cmd/scripts.rs
+++ b/crates/engine/src/cmd/scripts.rs
@@ -241,7 +241,9 @@ pub(crate) fn run_named(s: &mut Session, name: &str) -> Result<Value> {
     if panel {
         return open_panel(s, &json!({"name": name}));
     }
-    super::file_more::run_js_named(s, &code, name)
+    let result = super::file_more::run_js_named(s, &code, name)?;
+    super::file_more::remember_script(s, crate::prefs::RecentScript::Name(name.to_string()));
+    Ok(result)
 }
 
 /// The code a ScriptUI panel runs with: `this` is a dockable Panel titled after the script.
@@ -553,6 +555,144 @@ mod tests {
         let _ = std::fs::remove_dir_all(&root);
     }
 
+    /// File ▸ Scripts ▸ Recent Script Files: the ten scripts run last, newest first, kept in the
+    /// settings; Cmd+Alt+Shift+D runs the first one again.
+    #[test]
+    fn recent_script_files_run_again() {
+        use crate::prefs::{MAX_RECENT_SCRIPTS, PREFS_FILE, Prefs, RecentScript};
+        let (mut s, root) = session_in_tmp("recent");
+        assert_eq!(crate::menus::dynamic(&s, "recentScripts", &Default::default()), (vec![], Some("No Recent Script Files")));
+        assert!(bad_params(&s.execute("file.runScript", json!({"recent": 0}))));
+        assert_eq!(s.shortcuts().shortcut_of("file.runScript", &json!({"recent": 0})), Some("Cmd+Alt+Shift+D"));
+        let tool = root.join("Tool.jsx");
+        std::fs::write(&tool, "tool").unwrap();
+        let tool = tool.to_string_lossy().into_owned();
+        s.execute("file.runScript", json!({"path": tool})).unwrap();
+        s.execute("file.runScript", json!({"name": "Rename Layers.jsx"})).unwrap();
+        // A ScriptUI panel opened from File ▸ Scripts is not a recent script file.
+        std::fs::write(root.join("settings").join(PANELS_DIR).join("Panel.jsx"), "panel").unwrap();
+        s.execute("file.runScript", json!({"name": "Panel.jsx"})).unwrap();
+        let (path, name) = (RecentScript::Path(tool.clone()), RecentScript::Name("Rename Layers.jsx".into()));
+        assert_eq!(s.prefs.recent_scripts, [name.clone(), path.clone()]);
+        let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
+        assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Rename Layers.jsx", "Tool.jsx"]);
+        assert_eq!((e[1].command.as_str(), &e[1].params), ("file.runScript", &json!({"recent": 1})));
+        // Running one again runs that file and moves it to the top.
+        assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], "tool");
+        assert_eq!(s.prefs.recent_scripts, [path.clone(), name.clone()]);
+        let sample = BUNDLED.iter().find(|b| b.0 == "Rename Layers.jsx").unwrap().3;
+        assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
+        assert!(bad_params(&s.execute("file.runScript", json!({"recent": 2}))));
+        assert!(bad_params(&s.execute("file.runScript", json!({"recent": "Tool.jsx"}))));
+        // A file named like a bundled script runs again as that file.
+        let local = root.join("Rename Layers.jsx");
+        std::fs::write(&local, "local").unwrap();
+        s.execute("file.runScript", json!({"path": local.to_string_lossy()})).unwrap();
+        assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "local");
+        assert_eq!(s.execute("file.runScript", json!({"recent": 1})).unwrap()["ran"], sample);
+        // Ten at most, saved with the settings.
+        let mut last = String::new();
+        for i in 0..MAX_RECENT_SCRIPTS + 2 {
+            let f = root.join(format!("Step {i}.jsonl"));
+            std::fs::write(&f, "").unwrap();
+            last = f.to_string_lossy().into_owned();
+            s.execute("file.runScript", json!({"path": last})).unwrap();
+        }
+        assert_eq!(s.prefs.recent_scripts.len(), MAX_RECENT_SCRIPTS);
+        assert_eq!(s.prefs.recent_scripts[0], RecentScript::Path(last));
+        let saved = std::fs::read_to_string(root.join("settings").join(PREFS_FILE)).unwrap();
+        assert_eq!(Prefs::from_json(&saved).recent_scripts, s.prefs.recent_scripts);
+        let _ = std::fs::remove_dir_all(&root);
+    }
+
+    #[test]
+    fn recent_script_history_uses_the_selected_script() {
+        let (mut s, root) = session_in_tmp("recent-panel-collision");
+        let name = "Rename Layers.jsx";
+        std::fs::write(root.join("settings").join(PANELS_DIR).join(name), "panel").unwrap();
+        assert!(super::scripts(&s).iter().any(|entry| entry.name == name && !entry.panel));
+        assert!(script_code(&s, name, None).unwrap().0);
+        s.execute("file.runScript", json!({"name": name})).unwrap();
+        assert!(s.prefs.recent_scripts.is_empty(), "the selected panel must not enter recent script history");
+        let _ = std::fs::remove_dir_all(root);
+    }
+
+    #[test]
+    fn recent_script_history_keeps_successful_entries_when_scripts_fail() {
+        fn fail_or_echo(_: &mut Session, request: &ScriptRequest) -> Value {
+            if request.code == "fail" { json!({"error": {"message": "script failed"}}) } else { json!({"ran": request.code}) }
+        }
+        let (mut s, root) = session_in_tmp("recent-failed");
+        s.script = Some(fail_or_echo);
+        let good = root.join("good.jsonl");
+        std::fs::write(&good, "[]").unwrap();
+        s.execute("file.runScript", json!({"path": good})).unwrap();
+        let remembered = s.prefs.recent_scripts.clone();
+        for (name, code) in [("parse.jsonl", "{"), ("step.jsonl", r#"{"command":"does.not.exist"}"#), ("missing-command.jsonl", "{}"), ("runtime.jsx", "fail")]
+        {
+            let path = root.join(name);
+            std::fs::write(&path, code).unwrap();
+            assert!(s.execute("file.runScript", json!({"path": path})).is_err(), "{name}");
+            assert_eq!(s.prefs.recent_scripts, remembered, "{name}");
+        }
+        std::fs::write(root.join("settings").join(SCRIPTS_DIR).join("Failed.jsx"), "fail").unwrap();
+        assert!(s.execute("file.runScript", json!({"name": "Failed.jsx"})).is_err());
+        assert_eq!(s.prefs.recent_scripts, remembered);
+        let _ = std::fs::remove_dir_all(root);
+    }
+
+    /// Reads relative paths from a working directory that a test can change.
+    struct InFolder(std::sync::Mutex<PathBuf>);
+
+    impl InFolder {
+        fn at(&self, path: &str) -> PathBuf {
+            self.0.lock().unwrap().join(path)
+        }
+    }
+
+    impl crate::Services for InFolder {
+        fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
+            std::fs::read(self.at(path))
+        }
+        fn write_file(&self, path: &str, data: &[u8]) -> std::io::Result<()> {
+            std::fs::write(self.at(path), data)
+        }
+        fn full_path(&self, path: &str) -> String {
+            self.at(path).to_string_lossy().into_owned()
+        }
+    }
+
+    /// A script run from a relative path runs the same file again from Recent Script Files after
+    /// the working directory changes, and two scripts with the same file name show their paths.
+    #[test]
+    fn recent_script_files_keep_the_file_that_ran() {
+        let (mut s, root) = session_in_tmp("recent-relative");
+        let (a, b) = (root.join("a"), root.join("b"));
+        for (dir, code) in [(&a, "a"), (&b, "b")] {
+            std::fs::create_dir_all(dir).unwrap();
+            std::fs::write(dir.join("Tool.jsx"), code).unwrap();
+        }
+        let services = Arc::new(InFolder(std::sync::Mutex::new(a.clone())));
+        s.services = services.clone();
+        assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "a");
+        *services.0.lock().unwrap() = b.clone();
+        assert_eq!(s.execute("file.runScript", json!({"recent": 0})).unwrap()["ran"], "a");
+        let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
+        assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Tool.jsx"]);
+        assert_eq!(s.execute("file.runScript", json!({"path": "Tool.jsx"})).unwrap()["ran"], "b");
+        let (e, _) = crate::menus::dynamic(&s, "recentScripts", &Default::default());
+        let (a, b) = (a.join("Tool.jsx").to_string_lossy().into_owned(), b.join("Tool.jsx").to_string_lossy().into_owned());
+        assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), [b.as_str(), a.as_str()]);
+        let _ = std::fs::remove_dir_all(&root);
+    }
+
+    /// Native script paths are kept absolute.
+    #[test]
+    fn native_full_paths_are_absolute() {
+        use crate::Services;
+        assert!(std::path::Path::new(&crate::FsServices.full_path("Tool.jsx")).is_absolute());
+    }
+
     #[test]
     fn only_scripts_are_read_or_removed() {
         // A settings file in the Scripts folder that is not a script (.jsx / .js).
diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs
--- a/crates/engine/src/menus.rs
+++ b/crates/engine/src/menus.rs
@@ -61,6 +61,12 @@ fn file_name(p: &str) -> String {
     std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.to_string())
 }
 
+/// The labels of a recent-files list: `names[i]`, or the whole of `full[i]` when another entry
+/// has the same name, so entries for two files named `Tool.jsx` tell them apart.
+fn distinct_labels(names: Vec<String>, full: &[String]) -> Vec<String> {
+    names.iter().zip(full).map(|(n, f)| if names.iter().filter(|m| *m == n).count() > 1 { f.clone() } else { n.clone() }).collect()
+}
+
 fn dyn_entry(label: String, command: &str, params: Value) -> MenuEntry {
     MenuEntry { label, command: command.into(), params, shortcut: None, alt_shortcuts: vec![] }
 }
@@ -72,26 +78,34 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
 }
 
 /// The entries of a `@dynamic:<name>` node, and the disabled placeholder shown when there are
-/// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
+/// none: `recentProjects`, `recentFootage`, `recentPresets`, `recentScripts` (File ▸ Scripts ▸
+/// Recent Script Files), `history` (undo steps, newest
 /// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and
 /// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and
 /// `scriptPanels` (ScriptUI panels).
 pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
     use serde_json::json;
     let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
-        list.iter()
-            .enumerate()
-            .map(|(i, p)| {
-                let n =
-                    if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) };
-                dyn_entry(n, cmd, json!({"index": i}))
-            })
-            .collect()
+        let names = list
+            .iter()
+            .map(
+                |p| if stem { std::path::Path::new(p).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.clone()) } else { file_name(p) },
+            )
+            .collect();
+        distinct_labels(names, list).into_iter().enumerate().map(|(i, n)| dyn_entry(n, cmd, json!({"index": i}))).collect()
     };
     match name {
         "recentProjects" => (indexed(&s.prefs.recent_projects, "file.openRecent", false), Some("No Recent Projects")),
         "recentFootage" => (indexed(&s.prefs.recent_footage, "file.importRecent", false), Some("No Recent Footage")),
         "recentPresets" => (indexed(&s.prefs.recent_presets, "anim.applyRecentPreset", true), Some("No Recent Presets")),
+        "recentScripts" => {
+            let full: Vec<String> = s.prefs.recent_scripts.iter().map(|r| r.text().to_string()).collect();
+            let names = full.iter().map(|p| file_name(p)).collect();
+            (
+                distinct_labels(names, &full).into_iter().enumerate().map(|(i, n)| dyn_entry(n, "file.runScript", json!({"recent": i}))).collect(),
+                Some("No Recent Script Files"),
+            )
+        }
         "history" => (
             s.history.undo.iter().rev().enumerate().map(|(i, (l, _))| dyn_entry(format!("Undo {l}"), "edit.history", json!({"steps": i + 1}))).collect(),
             Some("No History"),
@@ -576,6 +590,8 @@ File
     Install Script File... | file.installScript
     Install ScriptUI Panel... | file.installScriptUIPanel
     Run Script File... | file.runScript
+    Recent Script Files
+      @dynamic:recentScripts
   ---
   Create Proxy
     Still... | file.createProxy {"kind":"still"}
diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs
--- a/crates/engine/src/services.rs
+++ b/crates/engine/src/services.rs
@@ -37,6 +37,12 @@ pub trait Services: Send + Sync {
     fn is_dir(&self, path: &str) -> bool {
         std::path::Path::new(path).is_dir()
     }
+    /// `path` as a list of recent files keeps it (File ▸ Scripts ▸ Recent Script Files): a
+    /// relative path is made absolute against the working directory, so it opens the same file
+    /// after the working directory changes.
+    fn full_path(&self, path: &str) -> String {
+        std::path::absolute(path).map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|_| path.to_string())
+    }
 }
 
 /// Native filesystem.
diff --git a/crates/engine/src/settings/prefs.rs b/crates/engine/src/settings/prefs.rs
--- a/crates/engine/src/settings/prefs.rs
+++ b/crates/engine/src/settings/prefs.rs
@@ -28,6 +28,7 @@ pub use choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES
 pub use dialog::{Item, Kind, Page, pages, pages_json, todo_keys};
 pub use document::{migrate, page_id, section_key};
 pub use queries::{CacheBudgets, WEB_LAYER_CACHE, WEB_MEDIA_CACHE, mask_color};
+pub use recent::{MAX_RECENT_SCRIPTS, RecentScript};
 pub use sections::{
     Appearance, Audio, AutoSave, CompPreset, CompositionPrefs, Disk, Export, FacePrefs, General, Grids, Import, LabelPref, Memory, Previews, ProjectPrefs,
     RotoPrefs, Scripting, Startup, ThreeD, TypePrefs, Video,
@@ -74,6 +75,8 @@ pub struct Prefs {
     /// Animation ▸ Recent Animation Presets (`.ecpreset` paths or built-in preset ids), newest
     /// first.
     pub recent_presets: Vec<String>,
+    /// File ▸ Scripts ▸ Recent Script Files, newest first, at most [`MAX_RECENT_SCRIPTS`].
+    pub recent_scripts: Vec<RecentScript>,
     /// Character panel: recently used font families, newest first.
     pub recent_fonts: Vec<String>,
     /// Effects & Presets: the starred effects' ids, in the order starred.
@@ -117,6 +120,7 @@ impl Default for Prefs {
             recent_projects: vec![],
             recent_footage: vec![],
             recent_presets: vec![],
+            recent_scripts: vec![],
             recent_fonts: vec![],
             effects_favorites: vec![],
             preview: crate::preview::PreviewSettings::default(),
diff --git a/crates/engine/src/settings/prefs/document.rs b/crates/engine/src/settings/prefs/document.rs
--- a/crates/engine/src/settings/prefs/document.rs
+++ b/crates/engine/src/settings/prefs/document.rs
@@ -138,6 +138,7 @@ impl Prefs {
         self.grids.grid_spacing = self.grids.grid_spacing.clamp(1.0, 10_000.0);
         let n = self.general.recent_items as usize;
         self.recent_projects.truncate(n);
+        self.recent_scripts.truncate(MAX_RECENT_SCRIPTS);
     }
 
     /// The value at a dotted key (`general.undoLevels`, `labels.3.color`); `""` = everything.
@@ -203,6 +204,7 @@ impl Prefs {
                 recent_projects: std::mem::take(&mut self.recent_projects),
                 recent_footage: std::mem::take(&mut self.recent_footage),
                 recent_presets: std::mem::take(&mut self.recent_presets),
+                recent_scripts: std::mem::take(&mut self.recent_scripts),
                 recent_fonts: std::mem::take(&mut self.recent_fonts),
                 effects_favorites: std::mem::take(&mut self.effects_favorites),
                 preview: std::mem::take(&mut self.preview),
diff --git a/crates/engine/src/settings/prefs/recent.rs b/crates/engine/src/settings/prefs/recent.rs
--- a/crates/engine/src/settings/prefs/recent.rs
+++ b/crates/engine/src/settings/prefs/recent.rs
@@ -45,6 +45,34 @@ impl Prefs {
     pub fn push_recent_preset(&mut self, path: &str) {
         push_mru(&mut self.recent_presets, path, self.general.recent_items as usize);
     }
+
+    /// Remember a script that ran for File ▸ Scripts ▸ Recent Script Files.
+    pub fn push_recent_script(&mut self, script: RecentScript) {
+        self.recent_scripts.retain(|x| *x != script);
+        self.recent_scripts.insert(0, script);
+        self.recent_scripts.truncate(MAX_RECENT_SCRIPTS);
+    }
+}
+
+/// Most scripts File ▸ Scripts ▸ Recent Script Files lists.
+pub const MAX_RECENT_SCRIPTS: usize = 10;
+
+/// A File ▸ Scripts ▸ Recent Script Files entry, stored as the `file.runScript` parameter that
+/// ran it: `{"path": …}` for a script file, `{"name": …}` for an installed or bundled script.
+#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub enum RecentScript {
+    Path(String),
+    Name(String),
+}
+
+impl RecentScript {
+    /// The file's path or the script's name.
+    pub fn text(&self) -> &str {
+        match self {
+            RecentScript::Path(s) | RecentScript::Name(s) => s,
+        }
+    }
 }
 
 /// Most effects Effects & Presets keeps starred (a hand-edited settings file can hold more).
diff --git a/crates/engine/src/settings/shortcuts.rs b/crates/engine/src/settings/shortcuts.rs
--- a/crates/engine/src/settings/shortcuts.rs
+++ b/crates/engine/src/settings/shortcuts.rs
@@ -105,6 +105,21 @@ pub fn bindables(ui: &[UiCommand]) -> Vec<Bindable> {
             &mut out,
         );
     }
+    // File ▸ Scripts ▸ Recent Script Files lists its scripts at run time; the first one, the most
+    // recent, has a key.
+    let recent = json!({"recent": 0});
+    push(
+        Bindable {
+            key: binding_key("file.runScript", &recent),
+            command: "file.runScript".into(),
+            params: recent,
+            label: "Run Most Recent Script File".into(),
+            path: vec!["File".into(), "Scripts".into(), "Recent Script Files".into()],
+            scope: APP_SCOPE.into(),
+            defaults: normalize("Cmd+Alt+Shift+D").into_iter().collect(),
+        },
+        &mut out,
+    );
     for c in crate::command_specs() {
         if !c.journal || entries.iter().any(|(_, e)| e.command == c.id) {
             continue;
diff --git a/crates/engine/src/tests/m145.rs b/crates/engine/src/tests/m145.rs
--- a/crates/engine/src/tests/m145.rs
+++ b/crates/engine/src/tests/m145.rs
@@ -708,6 +708,11 @@ fn dynamic_menus_and_window_viewer_labels() {
     s.prefs.push_recent("/p/One.ecproj");
     let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
     assert_eq!((e[0].label.as_str(), e[0].command.as_str()), ("One.ecproj", "file.openRecent"));
+    // Two projects with the same file name show their paths.
+    s.prefs.push_recent("/q/One.ecproj");
+    s.prefs.push_recent("/p/Two.ecproj");
+    let (e, _) = crate::menus::dynamic(&s, "recentProjects", &Default::default());
+    assert_eq!(e.iter().map(|e| e.label.as_str()).collect::<Vec<_>>(), ["Two.ecproj", "/q/One.ecproj", "/p/One.ecproj"]);
     assert_eq!(crate::menus::dynamic(&s, "history", &Default::default()).0.len(), s.history.undo.len());
     let a = solid(&mut s, "#ff0000");
     s.state.selected_layers = vec![LayerId(a)];
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -469,6 +469,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "安装脚本文件..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "安装 ScriptUI 面板..."),
     ("file.runScript", "Run Script File...", "运行脚本文件..."),
+    ("", "Recent Script Files", "最近的脚本文件"),
     ("", "Create Proxy", "创建代理"),
     ("file.createProxy", "Still...", "静止图像..."),
     ("file.createProxy", "Movie...", "影片..."),
@@ -1104,6 +1105,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "安裝指令碼檔案..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "安裝 ScriptUI 面板..."),
     ("file.runScript", "Run Script File...", "執行指令碼檔案..."),
+    ("", "Recent Script Files", "最近使用的指令碼檔案"),
     ("", "Create Proxy", "建立代理"),
     ("file.createProxy", "Still...", "靜態影像..."),
     ("file.createProxy", "Movie...", "影片..."),
@@ -1736,6 +1738,7 @@ const JAPANESE: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "スクリプトファイルをインストール..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUIパネルをインストール..."),
     ("file.runScript", "Run Script File...", "スクリプトファイルを実行..."),
+    ("", "Recent Script Files", "最近使用したスクリプトファイル"),
     ("", "Create Proxy", "プロキシを作成"),
     ("file.createProxy", "Still...", "静止画..."),
     ("file.createProxy", "Movie...", "ムービー..."),
@@ -2371,6 +2374,7 @@ const UKRAINIAN: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "Встановити файл скрипту..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Встановити панель ScriptUI..."),
     ("file.runScript", "Run Script File...", "Виконати файл скрипту..."),
+    ("", "Recent Script Files", "Нещодавні файли скриптів"),
     ("", "Create Proxy", "Створити проксі"),
     ("file.createProxy", "Still...", "Зображення..."),
     ("file.createProxy", "Movie...", "Відео..."),
@@ -3007,6 +3011,7 @@ const SPANISH: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "Instalar archivo de script..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Instalar panel de ScriptUI..."),
     ("file.runScript", "Run Script File...", "Ejecutar archivo de script..."),
+    ("", "Recent Script Files", "Archivos de script recientes"),
     ("", "Create Proxy", "Crear proxy"),
     ("file.createProxy", "Still...", "Imagen fija..."),
     ("file.createProxy", "Movie...", "Película..."),
@@ -3641,6 +3646,7 @@ const PORTUGUESE: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "Instalar arquivo de script..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Instalar painel ScriptUI..."),
     ("file.runScript", "Run Script File...", "Executar arquivo de script..."),
+    ("", "Recent Script Files", "Arquivos de script recentes"),
     ("", "Create Proxy", "Criar proxy"),
     ("file.createProxy", "Still...", "Imagem fixa..."),
     ("file.createProxy", "Movie...", "Filme..."),
@@ -4283,6 +4289,7 @@ const RUSSIAN: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "Установить файл скрипта..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Установить панель ScriptUI..."),
     ("file.runScript", "Run Script File...", "Выполнить файл скрипта..."),
+    ("", "Recent Script Files", "Недавние файлы скриптов"),
     ("", "Create Proxy", "Создать суррогат"),
     ("file.createProxy", "Still...", "Стоп-кадр..."),
     ("file.createProxy", "Movie...", "Видео..."),
@@ -4917,6 +4924,7 @@ const FRENCH: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "Installer un fichier de script..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Installer un panneau ScriptUI..."),
     ("file.runScript", "Run Script File...", "Exécuter un fichier de script..."),
+    ("", "Recent Script Files", "Fichiers de script récents"),
     ("", "Create Proxy", "Créer un proxy"),
     ("file.createProxy", "Still...", "Image fixe..."),
     ("file.createProxy", "Movie...", "Vidéo..."),
@@ -5551,6 +5559,7 @@ const GERMAN: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "Skriptdatei installieren..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI-Bedienfeld installieren..."),
     ("file.runScript", "Run Script File...", "Skriptdatei ausführen..."),
+    ("", "Recent Script Files", "Zuletzt verwendete Skriptdateien"),
     ("", "Create Proxy", "Proxy erstellen"),
     ("file.createProxy", "Still...", "Standbild..."),
     ("file.createProxy", "Movie...", "Film..."),
@@ -6185,6 +6194,7 @@ const CZECH: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "Nainstalovat soubor skriptu..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "Nainstalovat panel ScriptUI..."),
     ("file.runScript", "Run Script File...", "Spustit soubor skriptu..."),
+    ("", "Recent Script Files", "Nedávné soubory skriptů"),
     ("", "Create Proxy", "Vytvořit zástupný soubor"),
     ("file.createProxy", "Still...", "Statický snímek..."),
     ("file.createProxy", "Movie...", "Video..."),
@@ -6816,6 +6826,7 @@ const GEORGIAN: &[(&str, &str, &str)] = &[
     ("file.installScript", "Install Script File...", "სკრიპტის ფაილის დაყენება..."),
     ("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI პანელის დაყენება..."),
     ("file.runScript", "Run Script File...", "სკრიპტის ფაილის გაშვება..."),
+    ("", "Recent Script Files", "ბოლო სკრიპტის ფაილები"),
     ("", "Create Proxy", "პროქსის შექმნა"),
     ("file.createProxy", "Still...", "სტატიკური კადრი..."),
     ("file.createProxy", "Movie...", "ვიდეო..."),
diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs
--- a/crates/ui-egui/src/menus/bar.rs
+++ b/crates/ui-egui/src/menus/bar.rs
@@ -160,6 +160,7 @@ fn menu_nodes(
                         "recentProjects" => app.session.prefs.recent_projects.get(i).cloned(),
                         "recentFootage" => app.session.prefs.recent_footage.get(i).cloned(),
                         "recentPresets" => app.session.prefs.recent_presets.get(i).cloned(),
+                        "recentScripts" => app.session.prefs.recent_scripts.get(i).map(|r| r.text().to_string()),
                         _ => None,
                     };
                     let r = ui.add_enabled(entry_enabled(app, e), egui::Button::new((gutter(entry_checked(app, e) == Some(true)), e.label.as_str())));
diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs
--- a/crates/ui-egui/src/menus/frontend.rs
+++ b/crates/ui-egui/src/menus/frontend.rs
@@ -548,7 +548,7 @@ pub(super) fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) ->
         "file.importProject" => ("path", Ask::Open(&["ecproj", "ecprojx"])),
         "file.importTimeline" => ("path", Ask::Open(&["xml", "fcpxml", "otio", "edl", "aaf", "omf"])),
         "render.saveCurrentPreview" => ("path", Ask::Save("Preview.mp4")),
-        "file.runScript" if params.get("name").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
+        "file.runScript" if params.get("name").is_none() && params.get("recent").is_none() => ("path", Ask::Open(&["jsx", "js", "jsonl", "json", "txt"])),
         "file.installScript" | "file.installScriptUIPanel" => ("path", Ask::Open(&["jsx", "js"])),
         "effect.plugins.load" if params.get("folder").is_none() => ("path", Ask::Open(&["wasm", "wat"])),
         "file.replaceFootage" => ("path", Ask::Import),
diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs
--- a/crates/ui-egui/src/menus/tests.rs
+++ b/crates/ui-egui/src/menus/tests.rs
@@ -299,6 +299,25 @@ fn save_dialogs_offer_the_file_extension() {
     assert_eq!(*asked.borrow(), ["project Frame.png"]);
 }
 
+/// File ▸ Scripts ▸ Recent Script Files and its key run a script again without a file dialog.
+#[test]
+fn recent_script_files_run_without_a_dialog() {
+    let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
+    let ctx = egui::Context::default();
+    let dir = std::env::temp_dir().join(format!("ec-recent-script-{}", std::process::id()));
+    std::fs::create_dir_all(&dir).unwrap();
+    let script = dir.join("New Comp.jsonl");
+    std::fs::write(&script, r#"{"command": "comp.new", "params": {"name": "From Script", "width": 64, "height": 36, "frameRate": 30, "duration": 1}}"#)
+        .unwrap();
+    app.session.execute("file.runScript", json!({"path": script.to_string_lossy()})).unwrap();
+    let (m, k, id, params) =
+        bindings(&app.session).into_iter().find(|b| b.2 == "file.runScript" && b.3 == json!({"recent": 0})).expect("the most recent script has a key");
+    assert_eq!((m, k), parse_shortcut("Cmd+Alt+Shift+D").unwrap());
+    assert_eq!(invoke(&mut app, &ctx, &id, params).unwrap()["steps"], 1);
+    assert_eq!(app.session.project.items.values().filter(|i| i.name.starts_with("From Script")).count(), 2);
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
 /// #511: with a host's non-blocking dialogs, a command's file dialog opens without running
 /// a nested event loop inside the frame (a drag over the window then aborted the app on
 /// macOS); the command runs on a later frame with the answer, and a cancelled one not at all.
diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs
--- a/crates/ui-egui/src/native_menu.rs
+++ b/crates/ui-egui/src/native_menu.rs
@@ -340,6 +340,7 @@ pub fn state_key(app: &EffectcraftApp) -> u64 {
     s.journal.last().map(|j| &j.0).hash(&mut h);
     s.prefs.recent_projects.hash(&mut h);
     (&s.prefs.recent_footage, &s.prefs.recent_presets).hash(&mut h);
+    s.prefs.recent_scripts.hash(&mut h);
     s.history.undo.iter().for_each(|(l, _)| l.hash(&mut h));
     s.state.open_comps.hash(&mut h);
     s.active_comp_id().and_then(|c| s.state.views3d.get(&c)).map(|v| format!("{:?}", v.current)).hash(&mut h);
diff --git a/docs/plugins.md b/docs/plugins.md
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -14,6 +14,7 @@ Guide):
 | Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) |
 | Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same |
 | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) |
+| File ▸ Scripts ▸ Recent Script Files: the 10 most recently run scripts; Cmd+Option+Shift+D (Ctrl+Alt+Shift+D on Windows) reruns one | File ▸ Scripts ▸ Recent Script Files lists the 10 scripts run last (script files and File ▸ Scripts entries, newest first, kept in the settings); Cmd+Alt+Shift+D runs the most recent one again (`file.runScript {"recent": 0}`, a key that can be changed in Edit ▸ Keyboard Shortcuts) | Same |
 | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same |
 | `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap |
 | `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |
diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md
--- a/docs/scripting-parity.md
+++ b/docs/scripting-parity.md
@@ -27,6 +27,7 @@ our own scripts; no real-world script library has been run against both apps.
 | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths |
 | `.jsxbin` | yes | no | out of scope | No public specification |
 | Keyboard shortcuts for scripts | yes | no | missing | plugins.md |
+| Recent Script Files | File ▸ Scripts ▸ Recent Script Files (10 scripts), a key to rerun | Same list, kept in the settings; Cmd+Alt+Shift+D reruns the most recent script | same | `recent_script_files_run_again` ([plugins.md](plugins.md)) |
 | Command-line rendering `aerender` | yes | `effectcraft-cli render`, `script`, `exec` | same purpose | `apps/effectcraft-cli` |
 | Essential Graphics scripting hooks | `addToMotionGraphicsTemplate…` | yes (M13.7) | good | — |
 | Agent control (MCP, control channel, every command) | none | yes | beyond | [agents.md](agents.md), [control-protocol.md](control-protocol.md); `list_effects` omits parameter types and ranges (#441) |
