tool: add /ask-{claude,gemini,codex,opencode} slash commands

ober

c39e1d6afe631a9ea3bbd442b32e69d47647484e

diff --git a/build-binary.ss b/build-binary.ss
index 5ea0507..3af704a 100644
--- a/build-binary.ss
+++ b/build-binary.ss
@@ -118,6 +118,7 @@
     "lib/jcode/tool/web"
     "lib/jcode/tool/batch"
     "lib/jcode/tool/git"
+    "lib/jcode/tool/external-llm"
     "lib/jcode/tool/lsp"
     "lib/jcode/mcp/client"
     "lib/jcode/ui/tui-ffi"
@@ -308,6 +309,8 @@
       "std/os/path"
       "std/os/shell"
       "std/os/sysmon"
+      "std/security/capsicum"
+      "std/os/sandbox"
       "std/text/json"
       "std/text/glob"
       "std/net/tcp"
diff --git a/src/jcode/tool/external-llm.ss b/src/jcode/tool/external-llm.ss
new file mode 100644
index 0000000..a9a30b8
--- /dev/null
+++ b/src/jcode/tool/external-llm.ss
@@ -0,0 +1,149 @@
+;;; jcode external-LLM second-opinion runner
+;;;
+;;; Spawns an external LLM CLI (claude / gemini / codex / opencode) under
+;;; jerboa's (std os sandbox) — Landlock on Linux, Seatbelt on macOS,
+;;; Capsicum on FreeBSD, pledge+unveil on OpenBSD. The CLI's own sandbox
+;;; is disabled because the kernel-level profile we apply is the real
+;;; isolation; nesting two sandboxes nondeterministically deadlocks on
+;;; macOS Seatbelt.
+;;;
+;;; Read paths:  system roots (/usr, /bin, /etc, /opt, /Library, /System).
+;;; Write paths: cwd, /tmp, only THIS provider's auth/config dir
+;;;              (~/.claude, ~/.gemini, ~/.codex, ~/.config/opencode etc).
+;;; Exec paths:  /usr/bin, /bin, /usr/local/bin, /opt/homebrew/bin.
+;;; Every other CLI's tokens, ~/.ssh, ~/.aws, etc. are denied.
+
+(export ask-external-llm
+        external-llm-providers)
+
+(import :jerboa/core
+        :jerboa/runtime
+        :std/os/path
+        :std/os/sandbox
+        :std/misc/ports
+        :std/misc/string
+        :jcode/core/log)
+
+(def logger (make-logger "tool.external-llm"))
+
+(def (home)
+  (or (getenv "HOME") "/"))
+
+(def (external-llm-providers)
+  '(claude gemini codex opencode))
+
+;; (label argv-builder cli-auth-paths)
+;;   argv-builder : prompt-string -> list-of-strings (full argv incl. exe)
+;;   cli-auth-paths : list of writable directories the CLI must reach for
+;;                    auth/state. EVERY OTHER provider's auth dir stays
+;;                    invisible to this one.
+(def (provider-spec name)
+  (case name
+    ((claude)
+     (list "claude"
+           (lambda (prompt)
+             (list "claude" "-p" prompt
+                   "--output-format" "text"
+                   "--dangerously-skip-permissions"
+                   "--no-session-persistence"))
+           (list (path-join (home) ".claude")
+                 (path-join (home) ".claude.json")
+                 (path-join (home) "Library/Application Support/claude"))))
+    ((gemini)
+     (list "gemini"
+           (lambda (prompt)
+             (list "gemini" "-p" prompt
+                   "--yolo" "--skip-trust"))
+           (list (path-join (home) ".gemini")
+                 (path-join (home) ".config/gemini"))))
+    ((codex)
+     (list "codex"
+           (lambda (prompt)
+             (list "codex" "exec"
+                   "--sandbox" "danger-full-access"
+                   "--skip-git-repo-check"
+                   prompt))
+           (list (path-join (home) ".codex")
+                 (path-join (home) ".config/codex"))))
+    ((opencode)
+     (list "opencode"
+           (lambda (prompt)
+             (list "opencode" "run"
+                   "--dangerously-skip-permissions"
+                   prompt))
+           (list (path-join (home) ".config/opencode")
+                 (path-join (home) ".local/share/opencode")
+                 (path-join (home) ".cache/opencode"))))
+    (else #f)))
+
+;; POSIX single-quote escape: anything between single quotes is literal
+;; except for the single quote itself, which we close-quote, escape with
+;; backslash, then re-open. Handles newlines, $, backticks, the lot.
+(def (sh-single-quote s)
+  (let ((out (open-output-string)))
+    (display "'" out)
+    (let lp ((cs (string->list s)))
+      (cond
+        ((null? cs) (void))
+        ((char=? (car cs) #\')
+         (display "'\\''" out)
+         (lp (cdr cs)))
+        (else
+         (display (car cs) out)
+         (lp (cdr cs)))))
+    (display "'" out)
+    (get-output-string out)))
+
+(def (build-cmdline argv stdout-path)
+  (string-append
+    (string-join (map sh-single-quote argv) " ")
+    " > " (sh-single-quote stdout-path) " 2>&1"))
+
+(def (read-text-or-empty path)
+  (try
+    (if (file-exists? path) (read-file-string path) "")
+    (catch (e) "")))
+
+(def (ask-external-llm provider prompt)
+  "Run PROVIDER's CLI in a sandboxed child, pass PROMPT, return captured
+   stdout+stderr text. PROVIDER is one of the symbols returned by
+   external-llm-providers. On failure returns a string starting with
+   'ERROR: '."
+  (cond
+    ((not (string? prompt))
+     "ERROR: prompt must be a string")
+    (else
+     (let ((spec (provider-spec provider)))
+       (cond
+         ((not spec)
+          (format "ERROR: unknown provider ~a" provider))
+         (else
+          (run-with-spec spec prompt)))))))
+
+(def (run-with-spec spec prompt)
+  (let* ((label    (car spec))
+         (build    (cadr spec))
+         (auth     (caddr spec))
+         (argv     (build prompt))
+         (cwd      (current-directory))
+         (tmp-out  (path-join "/tmp" (format "jcode-ask-~a.log" label)))
+         (cmd      (build-cmdline argv tmp-out))
+         (read-paths
+           (list "/usr" "/bin" "/sbin" "/etc" "/opt" "/Library" "/System"
+                 "/private/etc" "/private/var/db" "/dev"
+                 (path-join (home) ".gitconfig")
+                 (path-join (home) ".config/git")))
+         (write-paths
+           (cons cwd (cons "/tmp" (cons "/private/tmp" auth))))
+         (exec-paths
+           (list "/usr/bin" "/bin" "/usr/local/bin" "/opt/homebrew/bin")))
+    (log-info logger "ask-external-llm" `((provider . ,label) (cmd . ,cmd)))
+    (let ((status
+           (try
+             (sandbox-run/command read-paths write-paths exec-paths cmd)
+             (catch (e) -1))))
+      (let ((text (read-text-or-empty tmp-out)))
+        (cond
+          ((eqv? status 0) text)
+          (else (format "ERROR: ~a exited with status ~a\n\n~a"
+                        label status text)))))))
diff --git a/src/jcode/ui/tui.ss b/src/jcode/ui/tui.ss
index 5fc41b8..7b07932 100644
--- a/src/jcode/ui/tui.ss
+++ b/src/jcode/ui/tui.ss
@@ -33,6 +33,7 @@
         :jcode/tool/web
         :jcode/tool/batch
         :jcode/tool/git
+        :jcode/tool/external-llm
         :jcode/mcp/client
         :jcode/tool/lsp
         :jcode/core/plugin
@@ -484,6 +485,10 @@
                "  /sessions   List sessions"
                "  /search <term>  Search session history"
                "  /mcp        Toggle MCP tools on/off (for local models)"
+               "  /ask-claude    Second opinion from claude CLI (sandboxed)"
+               "  /ask-gemini    Second opinion from gemini CLI (sandboxed)"
+               "  /ask-codex     Second opinion from codex CLI (sandboxed)"
+               "  /ask-opencode  Second opinion from opencode CLI (sandboxed)"
                "  /theme      Cycle theme (Ctrl-T)"
                "  /sidebar    Toggle sidebar (Ctrl-B)"
                "  /quit       Exit"
@@ -544,6 +549,12 @@
       ((equal? cmd "sidebar")
        (app-state-sidebar-visible?-set! state (not (app-state-sidebar-visible? state)))
        (reflow-all! state))
+      ((or (equal? cmd "ask-claude")
+           (equal? cmd "ask-gemini")
+           (equal? cmd "ask-codex")
+           (equal? cmd "ask-opencode"))
+       (handle-ask-external! state
+         (string->symbol (substring cmd 4 (string-length cmd)))))
       ((equal? cmd "sessions")
        (let ((sessions (session-list)))
          (add-message! state
@@ -840,6 +851,14 @@
      (add-message! state (msg-block-system "(interrupted)"))
      (app-state-agent-busy?-set! state #f)
      (app-state-dirty?-set! state #t))
+    ((list 'ask-result provider result)
+     (tui-log "apply-agent-event: ask-result ~a" provider)
+     (add-message! state
+       (msg-block-system
+         (format "[~a second opinion]\n~a" provider result)))
+     (app-state-agent-busy?-set! state #f)
+     (app-state-scroll-offset-set! state 0)
+     (app-state-dirty?-set! state #t))
     (_ (tui-log "apply-agent-event: unknown event ~s" ev))))
 
 ;; ---- Agent integration ----
@@ -913,6 +932,79 @@
                   (else
                    (send-worker-event! gen (list 'agent-error msg))))))))))))
 
+;; ---- /ask-* second-opinion runners ----
+
+(def (handle-ask-external! state provider)
+  "Send the most recent question/answer pair to PROVIDER's CLI under
+   jerboa's sandbox and inject its reply as a system message."
+  (cond
+    ((app-state-agent-busy? state)
+     (add-message! state
+       (msg-block-system
+         "Wait for the current response to finish, then re-run /ask-*.")))
+    (else
+     (let ((prompt (build-second-opinion-prompt state)))
+       (cond
+         ((not prompt)
+          (add-message! state
+            (msg-block-system
+              "No prior question/answer to review. Ask something first.")))
+         (else
+          (add-message! state
+            (msg-block-user (format "/ask-~a" provider)))
+          (add-message! state
+            (msg-block-system
+              (format "Asking ~a for a second opinion (sandboxed)..." provider)))
+          (app-state-agent-busy?-set! state #t)
+          (app-state-scroll-offset-set! state 0)
+          (app-state-dirty?-set! state #t)
+          (run-ask-worker! state provider prompt)))))))
+
+(def (build-second-opinion-prompt state)
+  ;; Pull last 6 user/assistant messages (3 pairs); skip empty assistant
+  ;; placeholders so we don't ship a half-streamed turn.
+  (let* ((qa (filter
+               (lambda (m)
+                 (and (memq (msg-block-role m) '(user assistant))
+                      (not (string-empty? (msg-block-content m)))))
+               (app-state-messages state)))
+         (n (length qa)))
+    (cond
+      ((< n 2) #f)
+      (else
+       (let* ((take-from (max 0 (- n 6)))
+              (recent (list-tail qa take-from))
+              (lines (map
+                       (lambda (m)
+                         (format "[~a]\n~a"
+                           (if (eq? (msg-block-role m) 'user)
+                             "USER"
+                             "ASSISTANT")
+                           (msg-block-content m)))
+                       recent)))
+         (string-append
+           "Please review the following exchange and offer a second opinion. "
+           "Where do you agree with the assistant's answer? Where do you "
+           "disagree, and why? Be concise.\n\n"
+           "--- Conversation ---\n"
+           (string-join lines "\n\n")))))))
+
+(def (run-ask-worker! state provider prompt)
+  ;; Capture err-port + log-level so the spawned thread doesn't fprintf
+  ;; into the termbox-owned TTY (same hazard as run-agent!).
+  (let ((gen (begin (bump-tui-run-gen!) (tui-run-gen)))
+        (err-port (current-error-port))
+        (log-lvl (current-log-level)))
+    (spawn
+      (lambda ()
+        (parameterize ((current-error-port err-port)
+                       (current-log-level log-lvl))
+          (let ((result
+                  (try (ask-external-llm provider prompt)
+                    (catch (e) (format "ERROR: ~a" (err->string e))))))
+            (send-worker-event! gen
+              (list 'ask-result provider result))))))))
+
 (def (tui-stream-token! state token)
   "Handle a streaming token from the LLM — called from agent thread."
   (let ((buf (app-state-stream-buf state)))