tool: add /ask-{claude,gemini,codex,opencode} slash commands
ober
c39e1d6afe631a9ea3bbd442b32e69d47647484e
--- 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" new file mode 100644 --- /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))))))) --- 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)))