diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs
--- a/crates/engine/src/lisp/tests.rs
+++ b/crates/engine/src/lisp/tests.rs
@@ -876,3 +876,28 @@ fn angtof_reads_the_string_in_the_given_units() {
     assert_eq!(ok(&mut s, "(angtof \"180\" 7)"), "nil");
     assert_eq!(ok(&mut s, "(angtof \"180\" \"x\")"), "nil");
 }
+
+/// #1920: the value of an expression typed at a prompt is the answer, not another expression.
+/// `(setq q "!q")` returned `"!q"`, which was evaluated again as `!q`, forever, until the stack
+/// overflowed and the process aborted (no `catch_unwind` catches that).
+#[test]
+fn a_lisp_value_at_a_prompt_is_not_evaluated_again() {
+    let mut s = session();
+    s.cmdline("circle (setq q \"!q\")").unwrap();
+    assert_eq!(ok(&mut s, "q"), "\"!q\"");
+    // "!q" is no centre point, so CIRCLE still asks for one; the session carries on.
+    assert!(s.running.is_some());
+    s.cancel();
+    // A value that is itself an expression stays text too: `(+ 1 2)` is not evaluated to 3.
+    s.cmdline("circle 0,0 (setq r \"(+ 1 2)\")").unwrap();
+    assert!(s.running.is_some(), "\"(+ 1 2)\" is no radius");
+    s.cancel();
+    // Values that answer the prompt still do.
+    s.cmdline("(setq r \"4\")").unwrap();
+    s.cmdline("circle 0,0 !r").unwrap();
+    let r = s.doc().unwrap().model.iter().find_map(|e| match &e.kind {
+        cadcraft_doc::EntityKind::Circle(c) => Some(c.radius),
+        _ => None,
+    });
+    assert_eq!(r, Some(4.0));
+}
diff --git a/crates/engine/src/session/cmdline.rs b/crates/engine/src/session/cmdline.rs
--- a/crates/engine/src/session/cmdline.rs
+++ b/crates/engine/src/session/cmdline.rs
@@ -69,6 +69,13 @@ impl Session {
 
     /// One token typed while a command runs.
     pub(crate) fn typed(&mut self, t: &str) -> Result<()> {
+        self.typed_token(t, true)
+    }
+
+    /// One token typed while a command runs; `lisp` evaluates a LISP expression first. The value
+    /// of that expression is the answer, so it comes back with `lisp` off: re-evaluating it would
+    /// let `(setq q "!q")` evaluate itself forever and overflow the stack (#1920).
+    fn typed_token(&mut self, t: &str, lisp: bool) -> Result<()> {
         let Some(prompt) = self.current_prompt() else { return Ok(()) };
         let tt = t.trim();
         if tt.is_empty() {
@@ -84,7 +91,7 @@ impl Session {
         }
         // A LISP expression typed at a command's prompt: its value is the answer.
         // Not at a free-text prompt: there the text is the answer (CAL takes "(3+4)*2").
-        if lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() {
+        if lisp && lisp_expression(tt) && !prompt.accept.text && self.lisp_prompt().is_none() && !self.lisp_groups_undo() {
             let src = tt.strip_prefix('!').unwrap_or(tt).to_string();
             let v = match self.lisp_eval(&src, false, true, Vec::new()) {
                 Ok(Some(Ok(v))) => v,
@@ -96,9 +103,9 @@ impl Session {
             };
             return match v {
                 lisp::value::Value::Nil => Ok(()),
-                lisp::value::Value::Int(i) => self.typed(&i.to_string()),
-                lisp::value::Value::Real(r) => self.typed(&format!("{r}")),
-                lisp::value::Value::Str(s) => self.typed(&s),
+                lisp::value::Value::Int(i) => self.typed_token(&i.to_string(), false),
+                lisp::value::Value::Real(r) => self.typed_token(&format!("{r}"), false),
+                lisp::value::Value::Str(s) => self.typed_token(&s, false),
                 other => match other.as_point() {
                     Some(p) => self.input(Input::Point(Vec2::new(p[0], p[1]))),
                     None => {
