You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by wy...@apache.org on 2026/09/24 00:15:14 UTC

(maka) branch feat/runtime-host-rust updated: feat(tui): persist and reconcile plugin submissions

This is an automated email from the ASF dual-hosted git repository.

M4n5ter pushed a commit to branch feat/runtime-host-rust
in repository https://gitbox.apache.org/repos/asf/maka.git


The following commit(s) were added to refs/heads/feat/runtime-host-rust by this push:
     new 058a6672af feat(tui): persist and reconcile plugin submissions
058a6672af is described below

commit 058a6672af116d87c5adf9b0561bdd596650ae1f
Author: M4n5ter <m4...@gmail.com>
AuthorDate: Thu Sep 24 08:14:52 2026 +0800

    feat(tui): persist and reconcile plugin submissions
    
    Save frozen forms and consent before dispatch through the shared checkpoint barrier. Recover through the original plugin entry, query durable creation receipts, and permit only explicit idempotent replay. Preserve uncertain attempts across retry rejection and confirm forgetting. Validate crash recovery against a real Host and PTY, with compact localized recovery controls.
    
    Generated-by: Codex
---
 crates/cli/tests/integration/tui/attachments.rs    |   2 +-
 crates/cli/tests/integration/tui/reading.rs        |   2 +-
 crates/cli/tests/integration/tui/recovery.rs       |  39 ++-
 crates/cli/tests/integration/tui/references.rs     |   2 +-
 crates/cli/tests/integration/tui/revision.rs       |   2 +-
 crates/cli/tests/integration/tui/scheduler.rs      |   2 +
 .../tests/integration/tui/scheduler/recovery.rs    | 119 ++++++++
 crates/cli/tests/integration/tui/skills.rs         |   2 +-
 crates/plugins/src/terminal_ui.rs                  |   2 +-
 crates/plugins/src/terminal_ui/page.rs             |  24 +-
 crates/protocol/src/lib.rs                         |   2 +-
 .../runtime-host/tests/fixtures/remote-plugin.mjs  |   2 +-
 crates/scheduler/src/plugin/terminal.rs            |   3 +
 crates/scheduler/src/plugin/terminal/create.rs     |  19 ++
 crates/scheduler/src/plugin/terminal/timing.rs     |   1 +
 crates/skills/src/plugin/terminal.rs               |   4 +
 crates/tui/locales/en.ftl                          |   7 +
 crates/tui/locales/zh-CN.ftl                       |   7 +
 crates/tui/locales/zh-TW.ftl                       |   7 +
 crates/tui/src/lib.rs                              |  30 +-
 crates/tui/src/pages/extensions.rs                 | 314 ++++++++++++++++++++-
 crates/tui/src/pages/extensions/io.rs              |  44 ++-
 crates/tui/src/pages/extensions/saved.rs           | 212 ++++++++++++++
 crates/tui/src/pages/extensions/view.rs            |  60 +++-
 crates/tui/src/state.rs                            |  96 ++++++-
 crates/tui/src/state/snapshot.rs                   |  14 +-
 crates/tui/src/view.rs                             |   4 +
 packages/runtime-host/src/protocol/index.ts        |   2 +-
 28 files changed, 972 insertions(+), 52 deletions(-)

diff --git a/crates/cli/tests/integration/tui/attachments.rs b/crates/cli/tests/integration/tui/attachments.rs
index 93353c561b..72c69984ad 100644
--- a/crates/cli/tests/integration/tui/attachments.rs
+++ b/crates/cli/tests/integration/tui/attachments.rs
@@ -122,7 +122,7 @@ fn local_file_upload_survives_routes_and_restart_then_sends_without_text() {
         .join(&client.identity.root_id)
         .join("default/state.json");
     let saved: Value = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
-    assert_eq!(saved["version"], 14);
+    assert_eq!(saved["version"], 15);
     let attachment = saved["attachments"]["files"][0]["attachment"].clone();
     assert!(attachment.is_object());
     let artifact = attachment["ref"]["relativePath"].as_str().unwrap();
diff --git a/crates/cli/tests/integration/tui/reading.rs b/crates/cli/tests/integration/tui/reading.rs
index e8f64e47b6..810e1e7a3b 100644
--- a/crates/cli/tests/integration/tui/reading.rs
+++ b/crates/cli/tests/integration/tui/reading.rs
@@ -151,7 +151,7 @@ fn old_window_selection_and_search_resume_across_pages_and_disk_restart() {
         .unwrap(),
     )
     .unwrap();
-    assert_eq!(saved["version"], 14);
+    assert_eq!(saved["version"], 15);
     let encoded = serde_json::to_string(&saved).unwrap();
     assert!(
         !encoded.contains("History context line"),
diff --git a/crates/cli/tests/integration/tui/recovery.rs b/crates/cli/tests/integration/tui/recovery.rs
index 82fd154555..02e5b00ca1 100644
--- a/crates/cli/tests/integration/tui/recovery.rs
+++ b/crates/cli/tests/integration/tui/recovery.rs
@@ -187,6 +187,13 @@ impl LostReply {
     pub(super) async fn oauth_start(root: &std::path::Path, directory: &std::path::Path) -> Self {
         Self::start_operation(root, directory, false, "oauth.login.start").await
     }
+    pub(super) async fn terminal_submit(
+        root: &std::path::Path,
+        directory: &std::path::Path,
+        after_acceptance: bool,
+    ) -> Self {
+        Self::start_operation(root, directory, after_acceptance, "plugin.remote").await
+    }
 
     pub(super) fn requests(&self) -> Vec<serde_json::Value> {
         self.requests.lock().unwrap().clone()
@@ -225,12 +232,14 @@ impl LostReply {
                 let (read_host, mut write_host) = host.into_split();
                 let mut from_client = BufReader::new(read_client).lines();
                 let mut from_host = BufReader::new(read_host).lines();
+                let mut submission_id = None;
                 loop {
                     tokio::select! {
                         line = from_client.next_line() => {
                             let Ok(Some(line)) = line else { break };
                             let value: serde_json::Value = serde_json::from_str(&line).unwrap();
-                            if value["operation"] == operation {
+                            if value["operation"] == operation && (operation != "plugin.remote" || value["input"]["kind"] == "call" && value["input"]["input"]["kind"] == "submit") {
+                                submission_id = value.get("requestId").cloned();
                                 let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
                                 if operation == "oauth.login.start" {
                                     assert_eq!(saved["root"], discovery.root_id);
@@ -241,16 +250,22 @@ impl LostReply {
                                     // enrollment/query/cancel exercise the actual Host here.
                                     break;
                                 }
-                                let original = &saved["unresolved"][0];
-                                assert_eq!(original["root_id"], discovery.root_id);
-                                assert_eq!(original["origin_epoch"], value["input"]["originHostEpoch"]);
-                                assert_eq!(original["session"], value["input"]["sessionId"]);
-                                assert_eq!(original["id"], value["input"]["messageId"]);
-                                assert_eq!(saved["version"], 14);
-                                assert_eq!(original["content"], value["input"]["content"]);
-                                assert_eq!(original["placement"], value["input"]["placement"]);
-                                assert_eq!(original["input_selections"], value["input"].get("inputSelections").cloned().unwrap_or_else(|| json!({})));
-                                assert_eq!(original["turn_orchestration"], value["input"]["turnOrchestration"]);
+                                if operation == "plugin.remote" {
+                                    assert_eq!(saved["root"], discovery.root_id);
+                                    assert_eq!(saved["extension"]["pending"]["input"], value["input"]["input"]);
+                                    assert_eq!(saved["extension"]["view"]["target"], value["input"]["target"]);
+                                } else {
+                                    let original = &saved["unresolved"][0];
+                                    assert_eq!(original["root_id"], discovery.root_id);
+                                    assert_eq!(original["origin_epoch"], value["input"]["originHostEpoch"]);
+                                    assert_eq!(original["session"], value["input"]["sessionId"]);
+                                    assert_eq!(original["id"], value["input"]["messageId"]);
+                                    assert_eq!(saved["version"], 15);
+                                    assert_eq!(original["content"], value["input"]["content"]);
+                                    assert_eq!(original["placement"], value["input"]["placement"]);
+                                    assert_eq!(original["input_selections"], value["input"].get("inputSelections").cloned().unwrap_or_else(|| json!({})));
+                                    assert_eq!(original["turn_orchestration"], value["input"]["turnOrchestration"]);
+                                }
                                 captured.lock().unwrap().push(value["input"].clone());
                                 if lose && !after_acceptance {
                                     lose = false;
@@ -262,7 +277,7 @@ impl LostReply {
                         line = from_host.next_line() => {
                             let Ok(Some(line)) = line else { break };
                             let value: serde_json::Value = serde_json::from_str(&line).unwrap();
-                            if lose && value["operation"] == operation && value["ok"] == true {
+                            if lose && value["operation"] == operation && value["ok"] == true && value.get("requestId") == submission_id.as_ref() {
                                 lose = false;
                                 break;
                             }
diff --git a/crates/cli/tests/integration/tui/references.rs b/crates/cli/tests/integration/tui/references.rs
index 88591b96ee..df4491faa4 100644
--- a/crates/cli/tests/integration/tui/references.rs
+++ b/crates/cli/tests/integration/tui/references.rs
@@ -103,7 +103,7 @@ fn host_directory_reference_survives_restart_and_sends_without_text_or_registrat
         .join(&client.identity.root_id)
         .join("default/state.json");
     let saved: Value = serde_json::from_slice(&std::fs::read(&state).unwrap()).unwrap();
-    assert_eq!(saved["version"], 14);
+    assert_eq!(saved["version"], 15);
     assert_eq!(
         saved["directories"]["refs"],
         json!([{"hostId":client.identity.root_id,"path":selected}])
diff --git a/crates/cli/tests/integration/tui/revision.rs b/crates/cli/tests/integration/tui/revision.rs
index 99591b8d4f..b31042365f 100644
--- a/crates/cli/tests/integration/tui/revision.rs
+++ b/crates/cli/tests/integration/tui/revision.rs
@@ -149,7 +149,7 @@ fn revision_edits_ordered_inputs_preserves_attachments_and_reopens_without_resub
     tui.send(b"\x11");
     tui.finish();
     let saved: Value = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
-    assert_eq!(saved["version"], 14);
+    assert_eq!(saved["version"], 15);
     assert_eq!(saved["revision"]["stage"], "draft");
     assert!(saved["attachments"].as_object().unwrap().is_empty());
     for input in saved["revision"]["inputs"].as_array().unwrap() {
diff --git a/crates/cli/tests/integration/tui/scheduler.rs b/crates/cli/tests/integration/tui/scheduler.rs
index 1826d5ae6a..0aef7ecf96 100644
--- a/crates/cli/tests/integration/tui/scheduler.rs
+++ b/crates/cli/tests/integration/tui/scheduler.rs
@@ -24,6 +24,8 @@ use maka_protocol::{
 };
 use serde_json::{Value, json};
 
+mod recovery;
+
 #[test]
 fn scheduler_form_edits_multiline_and_fences_stale_writes_without_running_a_model() {
     let directory = tempfile::tempdir().unwrap();
diff --git a/crates/cli/tests/integration/tui/scheduler/recovery.rs b/crates/cli/tests/integration/tui/scheduler/recovery.rs
new file mode 100644
index 0000000000..1d7792bcc8
--- /dev/null
+++ b/crates/cli/tests/integration/tui/scheduler/recovery.rs
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+
+#[test]
+fn terminal_creation_reopens_queries_and_retries_without_duplicating_or_resurrecting_tasks() {
+    for accepted in [false, true] {
+        let directory = tempfile::tempdir().unwrap();
+        let mut host =
+            super::super::super::candidate::CandidateFixture::new(directory.path().join("root"));
+        host.child = Some(
+            Command::new(env!("CARGO_BIN_EXE_maka"))
+                .args(["host", "serve", "--root"])
+                .arg(&host.root)
+                .stdin(Stdio::null())
+                .stdout(Stdio::null())
+                .stderr(Stdio::inherit())
+                .spawn()
+                .unwrap(),
+        );
+        host.wait_for_registration();
+        let runtime = tokio::runtime::Runtime::new().unwrap();
+        runtime.block_on(async {
+            let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+            let client = support::model_client(&host.root, &format!("http://{}/v1", listener.local_addr().unwrap())).await;
+            let binding = RemoteBinding::Package { package_id: "maka.scheduler".into(), method: "terminal".into(), session_id: None };
+            let RemoteResult::Bound { target, .. } = client.plugin_remote(RemoteRequest::Bind { binding: binding.clone() }).await.unwrap() else { panic!("terminal") };
+            let grant = client.request(Operation::PluginAuthorization, json!({"binding":binding,"target":target,"command":{
+                "kind":"approve","request":{"operationId":uuid::Uuid::new_v4(),"title":"Recovery fixture","target":{"kind":"profile"},"capabilities":["notifications"]}
+            }})).await.unwrap();
+            remote(&client, "request", json!({"kind":"remember_grant","id":grant["grant"]["id"]})).await;
+            let proxy = super::super::recovery::LostReply::terminal_submit(&host.root, directory.path(), accepted).await;
+            let mut tui = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+            tui.wait_for("Workspace");
+            tui.filter_command("Plugin pages");
+            tui.click_text("Plugin pages");
+            tui.wait_for("Scheduled tasks");
+            tui.click_text("Scheduled tasks");
+            tui.wait_for("New reminder");
+            tui.click_text("New reminder");
+            tui.wait_for("Once");
+            tui.click_text("Once");
+            tui.wait_for("Content");
+            tui.click_text("Title");
+            tui.send(b"\x1b[200~Saved original reminder\x1b[201~");
+            tui.click_text("Content");
+            tui.send(b"\x1b[200~Original recovery content\x1b[201~");
+            tui.click_text("Create reminder");
+            tui.wait_for("result is unconfirmed");
+            tui.wait_for("connection failed");
+            assert_eq!(proxy.requests().len(), 1);
+            // Crash without a final flush: the pre-dispatch checkpoint is sufficient.
+            tui.child.kill().unwrap();
+            assert!(!tui.child.wait().unwrap().success());
+            drop(tui);
+            let original = proxy.requests()[0]["input"].clone();
+            let operation = original["revision"].as_str().unwrap();
+            let receipt = remote(&client, "terminal", json!({"kind":"recover","route":{"operation":operation}})).await;
+            if accepted {
+                assert_eq!(receipt["kind"], "applied");
+                remote(&client, "request", json!({"kind":"mutate","mutation":{"kind":"delete","taskId":receipt["route"]["task"]}})).await;
+            } else {
+                assert_eq!(receipt["kind"], "unrecorded");
+            }
+            let mut reopened = Pty::spawn(&["--root", host.root.to_str().unwrap()]);
+            reopened.wait_for("Original recovery content");
+            reopened.wait_for("Check original submission");
+            // A restored form is painted before the connection is ready. Do not
+            // click its deliberately disabled recovery controls during startup.
+            reopened.wait_until(|screen| screen.contains("Original recovery content")
+                && !screen.contains("connecting") && !screen.contains("not connected")
+                && !screen.contains("connection failed"));
+            assert_eq!(proxy.requests().len(), 1, "restore must not replay");
+            reopened.click_text("Check original submission");
+            if accepted {
+                reopened.wait_for("Task no longer exists");
+                assert_eq!(proxy.requests().len(), 1, "known receipt needs no resubmission");
+            } else {
+                reopened.wait_for("No committed receipt found yet");
+                reopened.wait_for("Retry original submission");
+                assert_eq!(proxy.requests().len(), 1, "query does not replay");
+                reopened.click_text("Retry original submission");
+                reopened.wait_for("Pause");
+                assert_eq!(proxy.requests().len(), 2);
+                assert_eq!(proxy.requests()[1]["input"], original);
+            }
+            let tasks = remote(&client, "request", json!({"kind":"query","query":{"kind":"list"}})).await;
+            assert_eq!(tasks["tasks"].as_array().unwrap().len(), usize::from(!accepted));
+            reopened.send(b"\x11");
+            reopened.finish();
+            for task in tasks["tasks"].as_array().unwrap() {
+                assert_eq!(task["title"], "Saved original reminder");
+                remote(&client, "request", json!({"kind":"mutate","mutation":{"kind":"delete","taskId":task["id"]}})).await;
+            }
+            assert!(tokio::time::timeout(Duration::from_millis(50), listener.accept()).await.is_err());
+            client.disconnect();
+            drop(proxy);
+        });
+        host.retire_registered();
+        assert!(host.wait_for_exit().success());
+    }
+}
diff --git a/crates/cli/tests/integration/tui/skills.rs b/crates/cli/tests/integration/tui/skills.rs
index dfe3896c8a..28aad573a5 100644
--- a/crates/cli/tests/integration/tui/skills.rs
+++ b/crates/cli/tests/integration/tui/skills.rs
@@ -103,7 +103,7 @@ fn skills_candidates_page_without_execution_and_restore_exact_selection_before_s
         .join(&client.identity.root_id)
         .join("default/state.json");
     let saved: Value = serde_json::from_slice(&std::fs::read(&checkpoint).unwrap()).unwrap();
-    assert_eq!(saved["version"], 14);
+    assert_eq!(saved["version"], 15);
     let picked = saved["skills"]["skills"].as_array().unwrap();
     assert_eq!(picked.len(), 1);
     assert_eq!(picked[0]["name"], "Candidate 128");
diff --git a/crates/plugins/src/terminal_ui.rs b/crates/plugins/src/terminal_ui.rs
index 767f3c28a2..50cb263ffe 100644
--- a/crates/plugins/src/terminal_ui.rs
+++ b/crates/plugins/src/terminal_ui.rs
@@ -25,7 +25,7 @@ use crate::Error;
 use serde::{Deserialize, Serialize};
 use std::collections::BTreeMap;
 
-pub const VERSION: u32 = 2;
+pub const VERSION: u32 = 3;
 
 /// Presentation text is distinct from the stable identity used for navigation.
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
diff --git a/crates/plugins/src/terminal_ui/page.rs b/crates/plugins/src/terminal_ui/page.rs
index 6d625a992f..a3b75fdb5a 100644
--- a/crates/plugins/src/terminal_ui/page.rs
+++ b/crates/plugins/src/terminal_ui/page.rs
@@ -77,11 +77,17 @@ pub struct Action {
     pub enabled: bool,
     /// Only these fields are submitted; unrelated drafts are not implicit input.
     pub fields: Vec<String>,
+    /// Opaque read-only recovery route. Declares that replaying this exact
+    /// submission is idempotent, including after deletion of its result.
+    pub recovery: Option<Value>,
 }
 
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
 pub enum Request {
+    Recover {
+        route: Value,
+    },
     Read {
         route: Value,
     },
@@ -99,6 +105,8 @@ pub enum Request {
 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
 pub enum Reply {
+    /// No committed receipt was observed; this does not prove non-admission.
+    Unrecorded,
     Page {
         page: Page,
     },
@@ -214,6 +222,13 @@ impl Page {
         for action in &self.actions {
             identifier(&action.id)?;
             action.label.validate()?;
+            if let Some(value) = &action.recovery {
+                // null represents absence on the wire, not a replay promise.
+                if value.is_null() {
+                    return Err(invalid());
+                }
+                route(value)?;
+            }
             let mut used = BTreeSet::new();
             if !actions.insert(&action.id)
                 || action
@@ -276,7 +291,7 @@ impl Page {
 impl Request {
     pub fn validate(&self) -> Result<(), Error> {
         match self {
-            Self::Read { route: value } => route(value)?,
+            Self::Read { route: value } | Self::Recover { route: value } => route(value)?,
             Self::Submit {
                 route: value,
                 revision,
@@ -309,7 +324,7 @@ impl Reply {
             Self::Page { page } => page.validate()?,
             Self::Applied { route: value } => route(value)?,
             Self::Rejected { message } => message.validate()?,
-            Self::Conflict => {}
+            Self::Conflict | Self::Unrecorded => {}
             Self::Consent { request } => request.validate()?,
         }
         bounded(self, MAX_BYTES)
@@ -339,6 +354,7 @@ mod tests {
                 label: Text::plain("Save"),
                 enabled: true,
                 fields: vec!["enabled".into()],
+                recovery: None,
             }],
         }
     }
@@ -351,7 +367,7 @@ mod tests {
             .submission(json!(null), "save", fields.clone())
             .unwrap();
         request.validate().unwrap();
-        for variant in 0..7 {
+        for variant in 0..9 {
             let mut invalid = valid.clone();
             match variant {
                 0 => invalid.version += 1,
@@ -360,6 +376,8 @@ mod tests {
                 3 => invalid.body = "\u{001b}[2J".into(),
                 4 => invalid.body = "\u{202e}spoof".into(),
                 5 => invalid.body = "x".repeat(32769),
+                6 => invalid.actions[0].recovery = Some(Value::Null),
+                7 => invalid.actions[0].recovery = Some(json!(vec![Value::Null; 129])),
                 _ => invalid.actions.push(invalid.actions[0].clone()),
             }
             assert!(invalid.validate().is_err(), "{variant}");
diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs
index 693dc126b9..630a06f129 100644
--- a/crates/protocol/src/lib.rs
+++ b/crates/protocol/src/lib.rs
@@ -59,7 +59,7 @@ use serde::Serialize;
 use serde_json::Value;
 
 pub const PROTOCOL_VERSION: u64 = 0;
-pub const COMPATIBILITY_EPOCH: u64 = 185;
+pub const COMPATIBILITY_EPOCH: u64 = 186;
 pub const COMPOSITION_ID: &str = "maka.interactive";
 pub const MAX_MESSAGE_BYTES: usize = 768 * 1024;
 pub const MAX_IN_FLIGHT_DOMAIN_REQUESTS: usize = 64;
diff --git a/crates/runtime-host/tests/fixtures/remote-plugin.mjs b/crates/runtime-host/tests/fixtures/remote-plugin.mjs
index b383ca6a53..b837d9e532 100644
--- a/crates/runtime-host/tests/fixtures/remote-plugin.mjs
+++ b/crates/runtime-host/tests/fixtures/remote-plugin.mjs
@@ -198,7 +198,7 @@ export default async function activate(ctx) {
   );
   const state = { generation: 0, opening: 0, active: 0, stopped: 0 };
   const terminalView = {
-    version: 2,
+    version: 3,
     context: 'application',
     title: { fallback: 'Echo', translations: { 'zh-CN': '回显', 'zh-TW': '回顯' } },
   };
diff --git a/crates/scheduler/src/plugin/terminal.rs b/crates/scheduler/src/plugin/terminal.rs
index 9e2a2211ed..3555e70b9f 100644
--- a/crates/scheduler/src/plugin/terminal.rs
+++ b/crates/scheduler/src/plugin/terminal.rs
@@ -75,6 +75,7 @@ impl Method for View {
             request.validate().map_err(invalid)?;
             let _lease = service.context.admit().map_err(|_| Error::Retired)?;
             let reply = match request {
+                Request::Recover { route } => create::recover(&service, route).await?,
                 Request::Read { route } => read(&service, decode(route)?)?,
                 Request::Submit {
                     route,
@@ -323,6 +324,7 @@ fn detail(task: Task, revision: u64) -> Page {
             label: Text::localized("Save", "保存", "儲存"),
             enabled: true,
             fields: vec!["title".into(), "intent".into()],
+            recovery: None,
         });
     } else {
         page.body.push_str("\n\n");
@@ -340,6 +342,7 @@ fn detail(task: Task, revision: u64) -> Page {
             label,
             enabled: true,
             fields: vec![],
+            recovery: None,
         });
     }
     page
diff --git a/crates/scheduler/src/plugin/terminal/create.rs b/crates/scheduler/src/plugin/terminal/create.rs
index 86d884f5bc..9206ff5e4d 100644
--- a/crates/scheduler/src/plugin/terminal/create.rs
+++ b/crates/scheduler/src/plugin/terminal/create.rs
@@ -138,6 +138,7 @@ pub(super) fn read(service: &Service, creation: Route) -> Result<Reply, Error> {
                 label: Text::localized("Create reminder", "创建提醒", "建立提醒"),
                 enabled: true,
                 fields: page.fields.iter().map(|field| field.id.clone()).collect(),
+                recovery: Some(serde_json::json!({"operation":page.revision})),
             });
         }
     }
@@ -242,6 +243,24 @@ pub(super) async fn submit(
         .map_err(invalid)?,
     })
 }
+pub(super) async fn recover(service: &Service, value: Value) -> Result<Reply, Error> {
+    #[derive(Deserialize)]
+    #[serde(deny_unknown_fields)]
+    struct Recovery {
+        operation: uuid::Uuid,
+    }
+    let recovery: Recovery = serde_json::from_value(value).map_err(invalid)?;
+    match service.creation(recovery.operation).await.map_err(error)? {
+        Some(task) => Ok(Reply::Applied {
+            route: serde_json::to_value(super::Route {
+                task: Some(task),
+                ..Default::default()
+            })
+            .map_err(invalid)?,
+        }),
+        None => Ok(Reply::Unrecorded),
+    }
+}
 fn take(fields: &mut BTreeMap<String, Value>, key: &str) -> Result<String, Error> {
     fields
         .remove(key)
diff --git a/crates/scheduler/src/plugin/terminal/timing.rs b/crates/scheduler/src/plugin/terminal/timing.rs
index 5fbceb7529..e9b09c7520 100644
--- a/crates/scheduler/src/plugin/terminal/timing.rs
+++ b/crates/scheduler/src/plugin/terminal/timing.rs
@@ -66,6 +66,7 @@ pub(super) fn page(task: &Task, revision: u64, timezone: &str) -> Result<Page, E
         label: Text::localized("Save", "保存", "儲存"),
         enabled,
         fields: page.fields.iter().map(|field| field.id.clone()).collect(),
+        recovery: None,
     });
     Ok(page)
 }
diff --git a/crates/skills/src/plugin/terminal.rs b/crates/skills/src/plugin/terminal.rs
index 4e47e1c4bc..83834e68e4 100644
--- a/crates/skills/src/plugin/terminal.rs
+++ b/crates/skills/src/plugin/terminal.rs
@@ -76,6 +76,9 @@ impl Method for View {
                 workspace: view.workspace.target.clone(),
             };
             let reply = match request {
+                Request::Recover { .. } => {
+                    return Err(invalid("This action has no recovery receipt"));
+                }
                 Request::Read { route } => {
                     let route: Route = if route.is_null() {
                         Route::default()
@@ -240,6 +243,7 @@ fn project(
             label: Text::localized("Save", "保存", "儲存"),
             enabled,
             fields: vec!["enabled".into(), "pinned".into()],
+            recovery: None,
         });
     } else {
         for item in items.iter().skip(route.offset).take(WINDOW) {
diff --git a/crates/tui/locales/en.ftl b/crates/tui/locales/en.ftl
index bebc7cab06..75ed772d31 100644
--- a/crates/tui/locales/en.ftl
+++ b/crates/tui/locales/en.ftl
@@ -824,6 +824,13 @@ extensions-cap-history = Read conversation history
 extensions-cap-usage = Read usage
 extensions-cap-pricing = Manage pricing
 extensions-discard = Discard draft and reopen
+extensions-reconcile = Check original submission
+extensions-retry = Retry original submission
+extensions-forget = Forget submission
+extensions-forget-confirm = Forget this saved submission? This does not cancel the operation; it may still complete.
+extensions-restored = Draft restored. The plugin registration must be refreshed before making changes.
+extensions-save-failed = Could not save the submission. Nothing was dispatched; the form is retained.
+extensions-unrecorded = No committed receipt found yet. Retrying uses the same operation identity, without creating a second result.
 extensions-open = Open
 extensions-edit = Edit
 extensions-save = Save
diff --git a/crates/tui/locales/zh-CN.ftl b/crates/tui/locales/zh-CN.ftl
index 81cfc7e936..8e62fcfaff 100644
--- a/crates/tui/locales/zh-CN.ftl
+++ b/crates/tui/locales/zh-CN.ftl
@@ -824,6 +824,13 @@ extensions-cap-history = 读取对话历史
 extensions-cap-usage = 读取用量
 extensions-cap-pricing = 管理定价
 extensions-discard = 放弃草稿并重新打开
+extensions-reconcile = 查询原提交
+extensions-retry = 重试原提交
+extensions-forget = 忘记原提交
+extensions-forget-confirm = 忘记这次保存的提交?这不会取消操作,操作仍可能完成。
+extensions-restored = 已恢复草稿。请重新读取插件状态后再修改。
+extensions-save-failed = 未能保存提交,因此没有派发。表单已保留。
+extensions-unrecorded = 暂未查到提交回执。重试沿用原操作身份,不会重复创建。
 extensions-open = 打开
 extensions-edit = 编辑
 extensions-save = 保存
diff --git a/crates/tui/locales/zh-TW.ftl b/crates/tui/locales/zh-TW.ftl
index aa5872505d..a6edb86aad 100644
--- a/crates/tui/locales/zh-TW.ftl
+++ b/crates/tui/locales/zh-TW.ftl
@@ -824,6 +824,13 @@ extensions-cap-history = 讀取對話歷程
 extensions-cap-usage = 讀取用量
 extensions-cap-pricing = 管理定價
 extensions-discard = 放棄草稿並重新開啟
+extensions-reconcile = 查詢原提交
+extensions-retry = 重試原提交
+extensions-forget = 忘記原提交
+extensions-forget-confirm = 忘記這次儲存的提交?這不會取消操作,操作仍可能完成。
+extensions-restored = 已還原草稿。請重新讀取外掛狀態後再修改。
+extensions-save-failed = 無法儲存提交,因此沒有派發。表單已保留。
+extensions-unrecorded = 尚未查到提交回執。重試沿用原操作身分,不會重複建立。
 extensions-open = 開啟
 extensions-edit = 編輯
 extensions-save = 儲存
diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs
index 794e4e6a9a..3c8d542a4a 100644
--- a/crates/tui/src/lib.rs
+++ b/crates/tui/src/lib.rs
@@ -423,11 +423,23 @@ pub async fn run(options: Options) -> Result<(), Error> {
                 });
             }
             if let Some(request) = app.extensions_request() {
-                let client = client.clone();
-                jobs.spawn(async move {
-                    let result = pages::extensions::execute(&client, &request).await;
-                    Completed::Extension(request, result)
-                });
+                if request.needs_checkpoint() {
+                    if let Some(state) = &mut state {
+                        state.submit_extension(request);
+                    } else {
+                        app.extensions_after_checkpoint(
+                            &request,
+                            &Err("TUI checkpoint unavailable".into()),
+                        );
+                    }
+                } else {
+                    let client = client.clone();
+                    jobs.spawn(async move {
+                        let result = pages::extensions::execute(&client, &request).await;
+                        Completed::Extension(request, result)
+                    });
+                }
+                dirty = true;
             }
             if let Some(request) = app.skills_request() {
                 let client = client.clone();
@@ -811,6 +823,14 @@ pub async fn run(options: Options) -> Result<(), Error> {
                         Completed::Recap(request,result)
                     });
                 }
+                if let Some(request) = written.extension
+                    && app.extensions_after_checkpoint(&request, &written.result)
+                    && let Some(client) = client.clone() {
+                    jobs.spawn(async move {
+                        let result = pages::extensions::execute(&client, &request).await;
+                        Completed::Extension(request, result)
+                    });
+                }
                 if let Some(ticket) = written.attachment
                     && let Some((prepared, transfer)) = app.attachment_after_checkpoint(&ticket, &written.result)
                     && let Some(client) = client.clone() {
diff --git a/crates/tui/src/pages/extensions.rs b/crates/tui/src/pages/extensions.rs
index 2fe3133bcf..c0fd513820 100644
--- a/crates/tui/src/pages/extensions.rs
+++ b/crates/tui/src/pages/extensions.rs
@@ -18,6 +18,8 @@
  */
 
 mod consent;
+mod saved;
+pub use saved::Checkpoint;
 pub(crate) mod io;
 mod view;
 pub use consent::draw as draw_consent;
@@ -45,10 +47,14 @@ pub enum Command {
     Field(usize),
     Submit(usize),
     ApproveConsent,
+    Reconcile,
+    Retry,
     DismissConsent,
     Back,
     Refresh,
     Discard,
+    ConfirmDiscard,
+    CancelDiscard,
     Next,
 }
 impl Command {
@@ -58,11 +64,15 @@ impl Command {
             Self::Back => "extensions-back",
             Self::Refresh => "command-refresh",
             Self::Discard => "extensions-discard",
+            Self::ConfirmDiscard => "extensions-forget",
+            Self::CancelDiscard => "session-cancel",
             Self::Next => "sessions-next",
             Self::Row(_) => "extensions-open",
             Self::Field(_) => "extensions-edit",
             Self::Submit(_) => "extensions-save",
             Self::ApproveConsent => "extensions-authorize",
+            Self::Reconcile => "extensions-reconcile",
+            Self::Retry => "extensions-retry",
             Self::DismissConsent => "session-cancel",
         }
     }
@@ -71,13 +81,29 @@ impl Command {
 #[derive(Clone)]
 pub struct Request {
     generation: u64,
+    replaying: bool,
     root: String,
     epoch: String,
     session: Option<String>,
     pub(super) work: Work,
 }
+impl Request {
+    pub fn needs_checkpoint(&self) -> bool {
+        matches!(
+            self.work,
+            Work::Page {
+                input: Input::Submit { .. },
+                ..
+            } | Work::Authorize { .. }
+        )
+    }
+}
 #[derive(Clone)]
 pub(super) enum Work {
+    Recover {
+        view: Box<TerminalViewProjection>,
+        route: Value,
+    },
     Directory(Option<String>),
     Page {
         view: Box<TerminalViewProjection>,
@@ -108,6 +134,10 @@ pub struct State {
     pub(super) drafts: BTreeMap<String, Value>,
     pub(super) editors: BTreeMap<String, Editor>,
     pending: Option<Work>,
+    unresolved: Option<saved::Pending>,
+    saving: bool,
+    unrecorded: bool,
+    confirm_discard: bool,
     consent: Option<consent::Consent>,
     pub(super) busy: bool,
     writing: bool,
@@ -145,6 +175,9 @@ impl State {
         }
     }
     pub fn disconnect(&mut self) {
+        self.saving = false;
+        self.unrecorded = false;
+        self.confirm_discard = false;
         self.consent = None;
         self.generation += 1;
         self.pending = None;
@@ -154,11 +187,13 @@ impl State {
         self.directory.clear();
         self.next = None;
         // Retain drafts, but revoke every operation on the old registration.
-        self.message = self.blocked.then_some(Message::Local(if self.writing {
-            "extensions-unknown"
-        } else {
-            "extensions-disconnected"
-        }));
+        self.message = self.blocked.then_some(Message::Local(
+            if self.writing || self.unresolved.is_some() {
+                "extensions-unknown"
+            } else {
+                "extensions-disconnected"
+            },
+        ));
         self.writing = false;
     }
     fn read(&mut self) {
@@ -221,6 +256,23 @@ impl State {
 impl App {
     pub fn extensions_actions(&self) -> Vec<Action> {
         let mut commands = Vec::new();
+        if self.extensions.confirm_discard {
+            return [Command::CancelDiscard, Command::ConfirmDiscard]
+                .into_iter()
+                .map(Action::Extension)
+                .collect();
+        }
+        if self
+            .extensions
+            .unresolved
+            .as_ref()
+            .is_some_and(|pending| pending.recovery.is_some())
+        {
+            commands.push(Command::Reconcile);
+            if self.extensions.unrecorded {
+                commands.push(Command::Retry);
+            }
+        }
         if self.extensions.view.is_some() {
             commands.push(Command::Back);
         }
@@ -257,6 +309,8 @@ impl App {
             self.extensions.pending = Some(Work::Directory(None));
         }
         let work = self.extensions.pending.take()?;
+        let replaying = self.extensions.unresolved.is_some();
+        self.extensions.generation += 1;
         self.extensions.busy = true;
         self.extensions.writing = matches!(
             work,
@@ -265,9 +319,35 @@ impl App {
                 ..
             } | Work::Authorize { .. }
         );
+        if self.extensions.writing {
+            let (input, proposal) = match &work {
+                Work::Page { input, .. } => (input.clone(), None),
+                Work::Authorize {
+                    input, proposal, ..
+                } => (input.clone(), Some(proposal.clone())),
+                _ => unreachable!(),
+            };
+            let recovery = match &input {
+                Input::Submit { action, .. } => self
+                    .extensions
+                    .page
+                    .as_ref()
+                    .and_then(|page| page.actions.iter().find(|item| &item.id == action))
+                    .and_then(|action| action.recovery.clone()),
+                _ => None,
+            };
+            self.extensions.unresolved = Some(saved::Pending {
+                input,
+                proposal,
+                recovery,
+            });
+            self.extensions.saving = true;
+            self.extensions.unrecorded = false;
+        }
         self.extensions.message = None;
         Some(Request {
             generation: self.extensions.generation,
+            replaying,
             root: root_id.clone(),
             epoch: epoch.clone(),
             session: self.extensions.session.clone(),
@@ -283,9 +363,38 @@ impl App {
         }
         let state = &mut self.extensions;
         state.busy = false;
+        state.saving = false;
         state.writing = false;
         state.area = None;
+        let recovering = matches!(request.work, Work::Recover { .. });
+        let result = match result {
+            Ok(Output::Recovered { view, reply }) if recovering => {
+                state.view = Some(*view);
+                Ok(Output::Page(reply))
+            }
+            Ok(Output::Recovered { .. }) => Err(io::Failure { unknown: false }),
+            result => result,
+        };
+        // A rejected recovery read says nothing about the original write.
+        if !recovering
+            && !request.replaying
+            && request.needs_checkpoint()
+            && matches!(
+                &result,
+                Ok(Output::Page(
+                    Reply::Consent { .. } | Reply::Conflict | Reply::Rejected { .. }
+                )) | Err(io::Failure { unknown: false })
+            )
+        {
+            state.unresolved = None;
+        }
         match result {
+            Ok(Output::Recovered { .. }) => unreachable!(),
+            Ok(Output::Page(Reply::Unrecorded)) => {
+                state.blocked = true;
+                state.unrecorded = recovering;
+                state.message = Some(Message::Local("extensions-unrecorded"));
+            }
             Ok(Output::Directory(page)) => {
                 state.loaded = true;
                 state.directory = page.items;
@@ -299,6 +408,8 @@ impl App {
             }
             Ok(Output::Page(Reply::Page { page })) => state.install(page),
             Ok(Output::Page(Reply::Consent { request: proposal })) => {
+                // A retry needing consent does not settle an earlier lost write.
+                state.blocked = request.replaying;
                 // Preparing consent does not authorize anything. Leaving the page
                 // cancels presentation, without discarding the form or opening a
                 // delayed modal over another destination.
@@ -323,6 +434,9 @@ impl App {
                 }
             }
             Ok(Output::Page(Reply::Applied { route })) => {
+                state.unresolved = None;
+                state.unrecorded = false;
+                state.blocked = false;
                 state.applied = match &request.work {
                     Work::Page {
                         input:
@@ -349,6 +463,7 @@ impl App {
                 state.message = Some(Message::Local("extensions-conflict"));
             }
             Ok(Output::Page(Reply::Rejected { message })) => {
+                state.blocked = recovering || request.replaying;
                 state.message = Some(Message::Remote(message));
             }
             Err(failure) => {
@@ -371,7 +486,7 @@ impl App {
                 Command::ApproveConsent => {
                     consent.rendered
                         && !state.busy
-                        && !state.blocked
+                        && (!state.blocked || state.unresolved.is_some())
                         && self.navigation.current() == Route::Extensions
                         && matches!(self.connection, ConnectionState::Connected { .. })
                 }
@@ -379,10 +494,7 @@ impl App {
             };
         }
         if *command == Command::Open {
-            return matches!(self.connection, ConnectionState::Connected { .. })
-                && !state.busy
-                && !state.dirty()
-                && !state.blocked;
+            return matches!(self.connection, ConnectionState::Connected { .. }) && !state.busy;
         }
         if !matches!(self.connection, ConnectionState::Connected { .. })
             || self.navigation.current() != Route::Extensions
@@ -392,8 +504,24 @@ impl App {
             return false;
         }
         match command {
+            Command::Reconcile => {
+                !state.confirm_discard
+                    && state
+                        .unresolved
+                        .as_ref()
+                        .is_some_and(|pending| pending.recovery.is_some())
+            }
+            Command::Retry => {
+                !state.confirm_discard
+                    && state.unrecorded
+                    && state
+                        .unresolved
+                        .as_ref()
+                        .is_some_and(|pending| pending.recovery.is_some())
+            }
+            Command::ConfirmDiscard | Command::CancelDiscard => state.confirm_discard,
             Command::Discard => state.dirty() || state.blocked,
-            Command::Back => !state.dirty(),
+            Command::Back => !state.dirty() && state.unresolved.is_none() && !state.blocked,
             Command::Refresh => !state.dirty() && !state.blocked,
             Command::Next => state.view.is_none() && state.next.is_some() && !state.blocked,
             Command::Choose(index) => {
@@ -438,6 +566,13 @@ impl App {
             return;
         }
         if command == Command::Open {
+            if self.extensions.dirty()
+                || self.extensions.blocked
+                || self.extensions.unresolved.is_some()
+            {
+                self.apply(Action::Visit(Route::Extensions));
+                return;
+            }
             let session = match self.navigation.current() {
                 Route::Session(id) => Some(id),
                 _ => None,
@@ -456,6 +591,37 @@ impl App {
         let state = &mut self.extensions;
         state.applied = None;
         match command {
+            Command::Reconcile => {
+                state.unrecorded = false;
+                state.pending = Some(Work::Recover {
+                    view: Box::new(state.view.clone().unwrap()),
+                    route: state.unresolved.as_ref().unwrap().recovery.clone().unwrap(),
+                });
+            }
+            Command::Retry => {
+                state.unrecorded = false;
+                let pending = state.unresolved.as_ref().unwrap();
+                let view = Box::new(state.view.clone().unwrap());
+                state.pending = Some(match &pending.proposal {
+                    Some(proposal) => Work::Authorize {
+                        view,
+                        input: pending.input.clone(),
+                        proposal: proposal.clone(),
+                    },
+                    None => Work::Page {
+                        view,
+                        input: pending.input.clone(),
+                    },
+                });
+            }
+            Command::Discard if state.unresolved.is_some() => {
+                state.confirm_discard = true;
+                state.message = Some(Message::Local("extensions-forget-confirm"));
+            }
+            Command::CancelDiscard => {
+                state.confirm_discard = false;
+                state.message = Some(Message::Local("extensions-unknown"));
+            }
             Command::ApproveConsent => {
                 let consent = state.consent.take().unwrap();
                 state.pending = Some(Work::Authorize {
@@ -528,7 +694,11 @@ impl App {
                     state.pending = Some(Work::Directory(None));
                 }
             }
-            Command::Discard => {
+            Command::Discard | Command::ConfirmDiscard => {
+                state.unresolved = None;
+                state.unrecorded = false;
+                state.confirm_discard = false;
+                state.message = None;
                 state.generation += 1;
                 state.page = None;
                 state.view = None;
@@ -738,7 +908,7 @@ impl App {
 }
 
 #[cfg(test)]
-mod tests {
+pub(crate) mod tests {
     use super::*;
     use crate::i18n::{I18n, LocalePreference};
     use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
@@ -760,7 +930,7 @@ mod tests {
             method: "preferences".into(),
             target: Target {
                 entry_id: "notes".into(),
-                activation: "a".into(),
+                activation: uuid::Uuid::new_v4().to_string(),
                 registration: uuid::Uuid::new_v4(),
             },
             descriptor: Descriptor {
@@ -800,6 +970,7 @@ mod tests {
                 label: Text::plain("Save"),
                 enabled: true,
                 fields: vec!["enabled".into(), "name".into()],
+                recovery: None,
             }],
         }
     }
@@ -816,7 +987,7 @@ mod tests {
             .map(|cell| cell.symbol())
             .collect()
     }
-    fn app() -> App {
+    pub(crate) fn app() -> App {
         let mut app = App::new(
             "/test".into(),
             I18n::new(
@@ -932,6 +1103,9 @@ mod tests {
         };
         let mut app = app();
         app.extensions.drafts.insert("name".into(), json!("Draft"));
+        let editor = app.extensions.editors.get_mut("name").unwrap();
+        editor.clear_if_unchanged("My notes");
+        editor.insert("Draft");
         let prepare = |app: &mut App| {
             app.extensions_action(Command::Submit(0));
             let request = app.extensions_request().unwrap();
@@ -1000,6 +1174,19 @@ mod tests {
         };
         assert_eq!(fields["name"], json!("Draft"));
         assert!(grant.is_none());
+        let saved = app.extensions.checkpoint("root").unwrap();
+        saved.validate("root").unwrap();
+        let mut restored = State::default();
+        restored.restore(saved).unwrap();
+        assert!(restored.pending.is_none());
+        assert!(
+            restored.consent.is_none(),
+            "restoring explicit consent is not another approval"
+        );
+        assert_eq!(
+            restored.unresolved.unwrap().proposal.as_ref(),
+            Some(&proposal)
+        );
         app.extensions_complete(request, Err(io::Failure { unknown: true }));
         assert!(!app.extensions_enabled(&Command::Submit(0)));
         assert!(app.extensions_request().is_none());
@@ -1043,4 +1230,101 @@ mod tests {
         );
         assert!(app.extensions_request().is_none());
     }
+
+    #[test]
+    fn saved_unknown_submission_queries_first_and_retries_only_the_original_idempotent_intent() {
+        let mut app = app();
+        let recovery = json!({"operation":"one"});
+        app.extensions.page.as_mut().unwrap().actions[0].recovery = Some(recovery.clone());
+        app.extensions_action(Command::Field(0));
+        app.extensions_action(Command::Submit(0));
+        let original = app.extensions_request().unwrap();
+        assert!(original.needs_checkpoint());
+        let frozen = app.extensions.unresolved.as_ref().unwrap().input.clone();
+        let saved = serde_json::to_value(app.extensions.checkpoint("root").unwrap()).unwrap();
+        for (pointer, value) in [
+            ("/root", json!("other")),
+            ("/pending/input/revision", json!("different")),
+            ("/pending/input/fields/enabled", json!(true)),
+            ("/pending/recovery", json!({"operation":"other"})),
+            ("/cursors/name/cursor", json!(999)),
+            ("/drafts/name", json!("x".repeat(129))),
+            ("/view/target/activation", json!("not-an-activation")),
+        ] {
+            let mut invalid = saved.clone();
+            *invalid.pointer_mut(pointer).unwrap() = value;
+            assert!(
+                serde_json::from_value::<Checkpoint>(invalid)
+                    .unwrap()
+                    .validate("root")
+                    .is_err(),
+                "{pointer}"
+            );
+        }
+        app.extensions.disconnect();
+        assert!(!app.extensions_after_checkpoint(&original, &Ok(())));
+        app.extensions = State::default();
+        app.extensions
+            .restore(serde_json::from_value(saved).unwrap())
+            .unwrap();
+        assert!(app.extensions_request().is_none());
+        assert!(!app.extensions_enabled(&Command::Retry));
+        assert!(!app.extensions_enabled(&Command::Back));
+        assert!(!app.extensions_enabled(&Command::Submit(0)));
+        app.apply(Action::Visit(Route::Workspace));
+        app.extensions_action(Command::Open);
+        assert_eq!(app.navigation.current(), Route::Extensions);
+        app.extensions_action(Command::Reconcile);
+        let query = app.extensions_request().unwrap();
+        assert!(matches!(&query.work, Work::Recover { route, .. } if route == &recovery));
+        assert!(!query.needs_checkpoint());
+        app.extensions_complete(query, Err(io::Failure { unknown: false }));
+        assert!(app.extensions.unresolved.is_some());
+        assert!(!app.extensions_enabled(&Command::Retry));
+        app.extensions_action(Command::Reconcile);
+        let query = app.extensions_request().unwrap();
+        let mut view = app.extensions.view.clone().unwrap();
+        view.target.registration = uuid::Uuid::new_v4();
+        app.extensions_complete(
+            query,
+            Ok(Output::Recovered {
+                view: Box::new(view.clone()),
+                reply: Reply::Unrecorded,
+            }),
+        );
+        assert!(draw(&mut app, 58, 24).contains("Retry original submission"));
+        app.extensions_action(Command::Retry);
+        let retry = app.extensions_request().unwrap();
+        assert!(retry.needs_checkpoint());
+        assert!(
+            matches!(&retry.work, Work::Page { view: actual, input } if actual.target == view.target && input == &frozen)
+        );
+        assert!(!app.extensions_after_checkpoint(&original, &Ok(())));
+        assert!(app.extensions_after_checkpoint(&retry, &Ok(())));
+        assert!(!app.extensions_after_checkpoint(&retry, &Ok(())));
+        // A rejection of this retry cannot settle the earlier uncertain attempt.
+        app.extensions_complete(retry, Err(io::Failure { unknown: false }));
+        assert!(app.extensions.unresolved.is_some());
+        assert!(!app.extensions_enabled(&Command::Submit(0)));
+        app.extensions_action(Command::Discard);
+        assert!(app.extensions.unresolved.is_some());
+        assert!(app.extensions_request().is_none());
+        assert!(draw(&mut app, 90, 24).contains("does not cancel"));
+        app.extensions_action(Command::CancelDiscard);
+        app.extensions_action(Command::Reconcile);
+        let query = app.extensions_request().unwrap();
+        app.extensions_complete(
+            query,
+            Ok(Output::Recovered {
+                view: Box::new(view),
+                reply: Reply::Applied {
+                    route: json!({"task":"original"}),
+                },
+            }),
+        );
+        assert!(app.extensions.unresolved.is_none());
+        assert!(
+            matches!(app.extensions_request().unwrap().work, Work::Page { input: Input::Read { route }, .. } if route == json!({"task":"original"}))
+        );
+    }
 }
diff --git a/crates/tui/src/pages/extensions/io.rs b/crates/tui/src/pages/extensions/io.rs
index 649c48c0f7..260e229e56 100644
--- a/crates/tui/src/pages/extensions/io.rs
+++ b/crates/tui/src/pages/extensions/io.rs
@@ -21,13 +21,17 @@ use super::{Request, Work};
 use maka_client::{Client, ClientError, RequestFailure};
 use maka_plugins::terminal_ui::page::{Reply, Request as Input};
 use maka_protocol::plugin::{
-    Page, Query, QueryResult, RemoteBinding, RemoteRequest, RemoteResult, TerminalViewProjection,
-    View,
+    Page, Query, QueryResult, RemoteBinding, RemoteKind, RemoteRequest, RemoteResult,
+    TerminalViewProjection, View,
 };
 
 pub enum Output {
     Directory(Page<TerminalViewProjection>),
     Page(Reply),
+    Recovered {
+        view: Box<TerminalViewProjection>,
+        reply: Reply,
+    },
 }
 pub struct Failure {
     pub unknown: bool,
@@ -43,6 +47,39 @@ fn failure(error: RequestFailure, writing: bool) -> Failure {
 }
 pub async fn execute(client: &Client, request: &Request) -> Result<Output, Failure> {
     match &request.work {
+        Work::Recover { view, route } => {
+            let RemoteResult::Bound {
+                target,
+                handler: RemoteKind::Method,
+            } = client
+                .plugin_remote(RemoteRequest::Bind {
+                    binding: binding(view, session(view, request)),
+                })
+                .await
+                .map_err(|error| failure(error, false))?
+            else {
+                return Err(Failure { unknown: false });
+            };
+            // A fresh registration may serve the original entry, never a replacement owner.
+            if target.entry_id != view.target.entry_id {
+                return Err(Failure { unknown: false });
+            }
+            let mut view = view.clone();
+            view.target = target;
+            let Output::Page(reply) = call_page(
+                client,
+                &view,
+                &Input::Recover {
+                    route: route.clone(),
+                },
+                session(&view, request),
+            )
+            .await?
+            else {
+                return Err(Failure { unknown: false });
+            };
+            Ok(Output::Recovered { view, reply })
+        }
         Work::Directory(cursor) => {
             let result = client
                 .plugin_query(Query {
@@ -142,6 +179,9 @@ async fn call_page(
         (
             Input::Read { .. },
             Reply::Page { .. } | Reply::Conflict | Reply::Rejected { .. }
+        ) | (
+            Input::Recover { .. },
+            Reply::Applied { .. } | Reply::Unrecorded | Reply::Conflict | Reply::Rejected { .. }
         ) | (
             Input::Submit { .. },
             Reply::Applied { .. } | Reply::Conflict | Reply::Rejected { .. }
diff --git a/crates/tui/src/pages/extensions/saved.rs b/crates/tui/src/pages/extensions/saved.rs
new file mode 100644
index 0000000000..3d54b0d909
--- /dev/null
+++ b/crates/tui/src/pages/extensions/saved.rs
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+use super::*;
+use crate::editor::saved::Cursor;
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub(super) struct Pending {
+    pub input: Input,
+    pub proposal: Option<maka_plugins::authorization::Request>,
+    pub recovery: Option<Value>,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Checkpoint {
+    root: String,
+    session: Option<String>,
+    view: TerminalViewProjection,
+    page: Page,
+    route: Value,
+    drafts: BTreeMap<String, Value>,
+    cursors: BTreeMap<String, Cursor>,
+    pending: Option<Pending>,
+}
+impl Checkpoint {
+    pub fn validate(&self, root: &str) -> Result<(), String> {
+        if self.root != root
+            || serde_json::to_vec(self).map_err(|e| e.to_string())?.len()
+                > 4 * maka_plugins::terminal_ui::page::MAX_BYTES
+        {
+            return Err("Invalid plugin checkpoint identity or size".into());
+        }
+        self.page.validate().map_err(|e| e.to_string())?;
+        Input::Read {
+            route: self.route.clone(),
+        }
+        .validate()
+        .map_err(|e| e.to_string())?;
+        maka_protocol::plugin::decode_output(
+            maka_protocol::Operation::PluginPlatformQuery,
+            &serde_json::json!({
+                "view":"terminal_views", "items":[self.view], "nextCursor":null
+            }),
+        )
+        .map_err(|e| e.to_string())?;
+        if self
+            .session
+            .as_ref()
+            .is_some_and(|id| id.is_empty() || id.len() > 256 || id.chars().any(char::is_control))
+            || self.view.descriptor.context == Context::Session && self.session.is_none()
+            || self.drafts.len() != self.page.fields.len()
+        {
+            return Err("Invalid plugin checkpoint context or fields".into());
+        }
+        let mut edited = self.page.clone();
+        let mut text_fields = 0;
+        for field in &mut edited.fields {
+            match (&mut field.control, self.drafts.get(&field.id)) {
+                (Control::Toggle { value }, Some(Value::Bool(draft))) => *value = *draft,
+                (Control::Text { value, .. }, Some(Value::String(draft))) => {
+                    *value = draft.clone();
+                    text_fields += 1;
+                    self.cursors
+                        .get(&field.id)
+                        .ok_or("Missing plugin cursor")?
+                        .validate(draft)?;
+                }
+                _ => return Err("Invalid plugin checkpoint field".into()),
+            }
+        }
+        if text_fields != self.cursors.len() {
+            return Err("Invalid plugin checkpoint cursors".into());
+        }
+        edited.validate().map_err(|e| e.to_string())?;
+        if let Some(pending) = &self.pending {
+            let Input::Submit {
+                route,
+                revision,
+                action,
+                fields,
+                grant,
+            } = &pending.input
+            else {
+                return Err("Invalid frozen plugin submission".into());
+            };
+            let mut expected = self
+                .page
+                .submission(self.route.clone(), action, fields.clone())
+                .map_err(|e| e.to_string())?;
+            if let Input::Submit {
+                grant: expected_grant,
+                ..
+            } = &mut expected
+            {
+                *expected_grant = *grant;
+            }
+            if route != &self.route
+                || revision != &self.page.revision
+                || expected != pending.input
+                || fields
+                    .iter()
+                    .any(|(key, value)| self.drafts.get(key) != Some(value))
+                || self
+                    .page
+                    .actions
+                    .iter()
+                    .find(|item| &item.id == action)
+                    .map(|item| &item.recovery)
+                    != Some(&pending.recovery)
+            {
+                return Err("Frozen plugin intent does not match its form".into());
+            }
+            if let Some(proposal) = &pending.proposal {
+                proposal.validate().map_err(|e| e.to_string())?;
+                if grant.is_some() {
+                    return Err("Invalid frozen authorization".into());
+                }
+            }
+        }
+        Ok(())
+    }
+}
+impl State {
+    pub fn checkpoint(&self, root: &str) -> Option<Checkpoint> {
+        if !(self.dirty() || self.blocked || self.unresolved.is_some()) {
+            return None;
+        }
+        Some(Checkpoint {
+            root: root.into(),
+            session: self.session.clone(),
+            view: self.view.clone()?,
+            page: self.page.clone()?,
+            route: self.route.clone(),
+            drafts: self.drafts.clone(),
+            cursors: self
+                .editors
+                .iter()
+                .map(|(id, editor)| (id.clone(), editor.cursor()))
+                .collect(),
+            pending: self.unresolved.clone(),
+        })
+    }
+    pub fn restore(&mut self, checkpoint: Checkpoint) -> Result<(), String> {
+        checkpoint.validate(&checkpoint.root)?;
+        self.install(checkpoint.page);
+        self.view = Some(checkpoint.view);
+        self.session = checkpoint.session;
+        self.route = checkpoint.route;
+        self.drafts = checkpoint.drafts;
+        for (id, cursor) in checkpoint.cursors {
+            let editor = self.editors.get_mut(&id).ok_or("Missing plugin editor")?;
+            let initial = editor.text().to_owned();
+            editor.clear_if_unchanged(&initial);
+            editor.insert(self.drafts[&id].as_str().ok_or("Invalid plugin text")?);
+            editor.clear_history();
+            editor.restore_cursor(cursor)?;
+        }
+        self.unresolved = checkpoint.pending;
+        self.blocked = true;
+        self.loaded = true;
+        self.message = Some(Message::Local(if self.unresolved.is_some() {
+            "extensions-unknown"
+        } else {
+            "extensions-restored"
+        }));
+        Ok(())
+    }
+}
+impl App {
+    pub fn extensions_after_checkpoint(
+        &mut self,
+        request: &Request,
+        result: &Result<(), String>,
+    ) -> bool {
+        let state = &mut self.extensions;
+        if !state.saving || request.generation != state.generation {
+            return false;
+        }
+        state.saving = false;
+        if result.is_ok()
+            && !self.closing
+            && matches!(&self.connection,
+            ConnectionState::Connected { root_id, epoch } if *root_id == request.root && *epoch == request.epoch)
+        {
+            return true;
+        }
+        state.busy = false;
+        state.writing = false;
+        state.blocked = true;
+        state.message = Some(Message::Local("extensions-save-failed"));
+        false
+    }
+}
diff --git a/crates/tui/src/pages/extensions/view.rs b/crates/tui/src/pages/extensions/view.rs
index 5a34158b1e..899407ee13 100644
--- a/crates/tui/src/pages/extensions/view.rs
+++ b/crates/tui/src/pages/extensions/view.rs
@@ -33,11 +33,69 @@ use ratatui::{
 
 pub fn draw(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
     app.extensions.invalidate_geometry();
-    let area = area.inner(Margin::new(2, 1));
+    let mut area = area.inner(Margin::new(2, 1));
     if area.is_empty() {
         return;
     }
     let colors = app.theme.colors();
+    if app.extensions.unresolved.is_some() && !app.extensions.busy && area.height >= 5 {
+        let commands = if app.extensions.confirm_discard {
+            vec![Command::CancelDiscard, Command::ConfirmDiscard]
+        } else {
+            [Command::Reconcile, Command::Retry]
+                .into_iter()
+                .filter(|command| *command != Command::Retry || app.extensions.unrecorded)
+                .filter(|_| {
+                    app.extensions
+                        .unresolved
+                        .as_ref()
+                        .is_some_and(|pending| pending.recovery.is_some())
+                })
+                .collect()
+        };
+        // Keep contextual recovery actions on one line whenever they fit.
+        let buttons: Vec<_> = commands
+            .into_iter()
+            .map(|command| {
+                let label = app.i18n.text(command.label());
+                let width = (unicode_width::UnicodeWidthStr::width(label.as_str()) as u16 + 4)
+                    .min(area.width);
+                (command, label, width)
+            })
+            .collect();
+        let horizontal = buttons.iter().map(|(_, _, width)| width).sum::<u16>()
+            + 2 * buttons.len().saturating_sub(1) as u16
+            <= area.width;
+        let mut x = area.x;
+        let mut y = area.y;
+        for (command, label, width) in &buttons {
+            let selected = app.focus == Focus::Page
+                && app.page_actions().get(app.selected_control)
+                    == Some(&Action::Extension(command.clone()));
+            crate::view::button(
+                frame,
+                app,
+                Rect::new(x, y, *width, 1),
+                label,
+                Action::Extension(command.clone()),
+                selected,
+            );
+            if horizontal {
+                x += width + 2;
+            } else {
+                y += 2;
+            }
+        }
+        let rows = if buttons.is_empty() {
+            0
+        } else if horizontal {
+            2
+        } else {
+            2 * buttons.len() as u16
+        };
+        area.y += rows;
+        area.height = area.height.saturating_sub(rows);
+    }
     let consent = app.extensions.consent_visible();
     let locale = app.i18n.locale().id();
     let form_width = area.width.saturating_sub(1).min(52);
diff --git a/crates/tui/src/state.rs b/crates/tui/src/state.rs
index f2c823b8c5..c89a59c90e 100644
--- a/crates/tui/src/state.rs
+++ b/crates/tui/src/state.rs
@@ -22,7 +22,7 @@ mod store;
 
 use crate::{
     app::App,
-    pages::{branch, manage::oauth, recap, revision, sending::Submission},
+    pages::{branch, extensions, manage::oauth, recap, revision, sending::Submission},
 };
 use maka_client::Error;
 use snapshot::Snapshot;
@@ -41,6 +41,7 @@ pub struct State {
     branch: Option<branch::Request>,
     recap: Option<recap::Request>,
     revision: Option<revision::Request>,
+    extension: Option<extensions::Request>,
     attachment: Option<crate::pages::attachments::Ticket>,
     generation: u64,
     job: Option<Writing>,
@@ -53,6 +54,7 @@ struct Writing {
     branch: Option<branch::Request>,
     recap: Option<recap::Request>,
     revision: Option<revision::Request>,
+    extension: Option<extensions::Request>,
     attachment: Option<crate::pages::attachments::Ticket>,
     generation: u64,
 }
@@ -64,6 +66,7 @@ pub struct Written {
     pub branch: Option<branch::Request>,
     pub recap: Option<recap::Request>,
     pub revision: Option<revision::Request>,
+    pub extension: Option<extensions::Request>,
     pub attachment: Option<crate::pages::attachments::Ticket>,
 }
 impl State {
@@ -98,6 +101,7 @@ impl State {
                     branch: None,
                     recap: None,
                     revision: None,
+                    extension: None,
                     attachment: None,
                     generation: 0,
                     job: None,
@@ -133,6 +137,7 @@ impl State {
         self.branch = None;
         self.recap = None;
         self.revision = None;
+        self.extension = None;
         self.attachment = None;
         let mut requests = std::mem::take(&mut self.requests);
         if let Some(job) = &self.job
@@ -163,6 +168,10 @@ impl State {
         self.revision = Some(request);
         self.force();
     }
+    pub fn submit_extension(&mut self, request: extensions::Request) {
+        self.extension = Some(request);
+        self.force();
+    }
     pub fn idle(&self) -> bool {
         self.job.is_none() && self.deadline.is_none()
     }
@@ -187,6 +196,7 @@ impl State {
             branch: self.branch.take(),
             recap: self.recap.take(),
             revision: self.revision.take(),
+            extension: self.extension.take(),
             attachment: self.attachment.take(),
             generation,
         });
@@ -202,6 +212,11 @@ impl State {
         let job = self.job.take().expect("completed writer");
         Written {
             result,
+            extension: if job.generation == self.generation {
+                job.extension
+            } else {
+                None
+            },
             attachment: if job.generation == self.generation {
                 job.attachment
             } else {
@@ -269,6 +284,7 @@ mod tests {
             branch: None,
             recap: None,
             revision: None,
+            extension: None,
             attachment: None,
             generation: 0,
             job: None,
@@ -394,6 +410,82 @@ mod tests {
         .unwrap()
     }
 
+    #[tokio::test]
+    async fn plugin_submission_requires_its_own_durable_checkpoint_and_never_replays_on_restore() {
+        use crate::pages::extensions::Command;
+        let (directory, mut state, _) = fixture();
+        let mut app = extensions::tests::app();
+        app.connection = ConnectionState::Connected {
+            root_id: ROOT.into(),
+            epoch: "epoch".into(),
+        };
+        app.extensions_action(Command::Field(0));
+        app.extensions_action(Command::Submit(0));
+        let request = app.extensions_request().unwrap();
+        let (release, blocked) = gate(&state).await;
+        state.submit_extension(request.clone());
+        state.start(&app);
+        assert!(
+            tokio::time::timeout(Duration::from_millis(30), state.completed())
+                .await
+                .is_err()
+        );
+        release.send(()).unwrap();
+        blocked.await.unwrap();
+        let completed = written(&mut state).await;
+        assert!(completed.extension.is_some());
+        assert!(completed.result.is_ok(), "{:?}", completed.result);
+        let bytes = read(&directory);
+        assert_eq!(
+            bytes["extension"]["pending"]["input"]["fields"]["enabled"],
+            false
+        );
+        let mut reopened = App::new(
+            "/unused".into(),
+            I18n::new(LocalePreference::Auto, Locale::En),
+        );
+        serde_json::from_value::<Snapshot>(bytes)
+            .unwrap()
+            .restore(&mut reopened, false)
+            .unwrap();
+        assert!(reopened.extensions_request().is_none());
+        assert!(app.extensions_after_checkpoint(&request, &completed.result));
+        assert!(!app.extensions_after_checkpoint(&request, &completed.result));
+        app.extensions_complete(request, Err(extensions::io::Failure { unknown: true }));
+        // No idempotency declaration means no unsafe retry, including after a failed query.
+        assert!(!app.extensions_enabled(&Command::Retry));
+        assert!(!app.extensions_enabled(&Command::Reconcile));
+        app.extensions_action(Command::Discard);
+        assert!(app.extensions_request().is_none());
+        app.extensions_action(Command::ConfirmDiscard);
+        assert!(app.extensions_request().is_some());
+
+        for closing in [false, true] {
+            let mut app = extensions::tests::app();
+            app.connection = ConnectionState::Connected {
+                root_id: ROOT.into(),
+                epoch: "epoch".into(),
+            };
+            app.extensions_action(Command::Submit(0));
+            let request = app.extensions_request().unwrap();
+            app.closing = closing;
+            assert!(!app.extensions_after_checkpoint(&request, &Err("disk failure".into())));
+            assert!(app.extensions.checkpoint(ROOT).is_some());
+            assert!(app.extensions_request().is_none());
+        }
+        let mut app = extensions::tests::app();
+        app.extensions_action(Command::Submit(0));
+        let request = app.extensions_request().unwrap();
+        state.submit_extension(request);
+        let (release, blocked) = gate(&state).await;
+        state.start(&app);
+        state.cancel_requests();
+        app.extensions.disconnect();
+        release.send(()).unwrap();
+        blocked.await.unwrap();
+        assert!(written(&mut state).await.extension.is_none());
+    }
+
     #[tokio::test]
     async fn revision_writer_persists_frozen_identity_and_cancels_obsolete_save_authority() {
         use crate::pages::revision::{Checkpoint, Command};
@@ -438,7 +530,7 @@ mod tests {
         assert_eq!(written.revision, Some(request.clone()));
         assert!(written.result.is_ok());
         let bytes = read(&directory);
-        assert_eq!(bytes["version"], 14);
+        assert_eq!(bytes["version"], 15);
         assert_eq!(bytes["revision"]["copy"]["targetSessionId"], "revised");
         assert_eq!(bytes["revision"]["inputs"][0]["content"]["text"], "edited");
         let mut incomplete = bytes.clone();
diff --git a/crates/tui/src/state/snapshot.rs b/crates/tui/src/state/snapshot.rs
index aa80c25b97..5c5946ad3f 100644
--- a/crates/tui/src/state/snapshot.rs
+++ b/crates/tui/src/state/snapshot.rs
@@ -51,6 +51,7 @@ pub struct Snapshot {
     branch: Option<crate::pages::branch::Checkpoint>,
     recap: Option<crate::pages::recap::Checkpoint>,
     revision: Option<crate::pages::revision::Checkpoint>,
+    extension: Option<crate::pages::extensions::Checkpoint>,
 }
 
 impl Snapshot {
@@ -63,7 +64,7 @@ impl Snapshot {
             .collect();
         unresolved.sort_by(|left, right| left.session.cmp(&right.session));
         Self {
-            version: 14,
+            version: 15,
             attachments: app.attachments.saved.clone(),
             directories: app.directories.clone(),
             skills: app.skills.saved.clone(),
@@ -88,6 +89,7 @@ impl Snapshot {
             branch: app.branch.checkpoint(),
             recap: app.recap.checkpoint(),
             revision: app.revision.checkpoint(),
+            extension: app.extensions.checkpoint(root),
         }
     }
 
@@ -95,7 +97,7 @@ impl Snapshot {
         let id = |id: &str| {
             !id.is_empty() && id.encode_utf16().count() <= 256 && !id.chars().any(char::is_control)
         };
-        if self.version != 14
+        if self.version != 15
             || self.root != root
             || self.tabs.len() > LIMIT
             || self.drafts.len() > LIMIT
@@ -194,6 +196,9 @@ impl Snapshot {
         if let Some(oauth) = &self.oauth {
             oauth.validate()?;
         }
+        if let Some(extension) = &self.extension {
+            extension.validate(root)?;
+        }
         if let Some(revision) = &self.revision {
             revision.validate(root)?;
             if revision.upload_ids().any(|id| !uploads.insert(id)) {
@@ -220,6 +225,9 @@ impl Snapshot {
         if let Some(revision) = self.revision {
             app.revision.restore(revision);
         }
+        if let Some(extension) = self.extension {
+            app.extensions.restore(extension)?;
+        }
         if let Some(recap) = self.recap {
             app.recap.restore(recap);
         }
@@ -425,7 +433,7 @@ mod tests {
         request.input().validate().unwrap();
         original.sending.get_mut("a").unwrap().request = request.clone();
         let saved = serde_json::to_value(Snapshot::capture(&original, "root")).unwrap();
-        assert_eq!(saved["version"], 14);
+        assert_eq!(saved["version"], 15);
         let mut restored = app();
         serde_json::from_value::<Snapshot>(saved.clone())
             .unwrap()
diff --git a/crates/tui/src/view.rs b/crates/tui/src/view.rs
index a780fcbec2..339a9ef6d0 100644
--- a/crates/tui/src/view.rs
+++ b/crates/tui/src/view.rs
@@ -506,6 +506,10 @@ fn icon(app: &App, action: &Action) -> &'static str {
             crate::pages::extensions::Command::Back => ("‹", "<"),
             crate::pages::extensions::Command::Next => ("›", ">"),
             crate::pages::extensions::Command::Discard => ("×", "x"),
+            crate::pages::extensions::Command::Reconcile => ("⌕", "?"),
+            crate::pages::extensions::Command::Retry => ("↥", "^"),
+            crate::pages::extensions::Command::ConfirmDiscard => ("×", "x"),
+            crate::pages::extensions::Command::CancelDiscard => ("‹", "<"),
             _ => ("◇", "E"),
         },
         Action::Visit(Route::Connections) => ("⇄", "C"),
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 08cf36f1cb..f41583db5b 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -110,7 +110,7 @@ export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
 // Increment when the same protocol version no longer guarantees safe Client-Host
 // interoperability. Mismatches are rejected before domain commands are admitted.
 // Native bundle bindings and whole-catalog invalidation require matching clients.
-export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 185 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 186 as const;
 export const RUNTIME_HOST_MAX_MESSAGE_BYTES = 768 * 1024;
 export const RUNTIME_HOST_MAX_IN_FLIGHT_DOMAIN_REQUESTS = 64;
 export const INTERACTIVE_RUNTIME_HOST_COMPOSITION_ID = 'maka.interactive' as const;


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org