diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c80726 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.agent-shell/ diff --git a/README.org b/README.org index 525f391..8e0d59f 100644 --- a/README.org +++ b/README.org @@ -450,6 +450,27 @@ Optional: to prevent the agent running inside the container to access your local All of the above settings can be applied on a per-project basis using [[https://www.gnu.org/software/emacs/manual/html_node/emacs/Directory-Variables.html][directory-local variables]]. +** Running agents on remote hosts via TRAMP (Experimental) + +=agent-shell= supports running agents on remote hosts accessed via TRAMP. When you open a project on a remote machine (e.g., =/ssh:user@host:/path/to/project=), the agent automatically runs on that remote machine via Emacs' file-handler mechanism. + +*** How it works + +When the working directory is a TRAMP path: +- The agent runs on the remote host automatically +- File paths are converted between TRAMP format and remote-local format +- Transcripts are saved locally in =~/.agent-shell/transcripts///= + +*** Requirements + +1. *Agent must be installed on the remote host* - e.g., =copilot=, =claude-code-acp= +2. *SSH key-based authentication recommended* - interactive password prompts may not work +3. *Remote shell must have correct PATH* - ensure your remote =.bashrc= sets up PATH for non-interactive shells + +*** Known limitations + +- *Session data is local*: Transcripts are saved locally to avoid TRAMP write latency. + ** Keybindings - =C-c C-c= - Interrupt current agent operation diff --git a/agent-shell-tramp.el b/agent-shell-tramp.el new file mode 100644 index 0000000..5d8fef6 --- /dev/null +++ b/agent-shell-tramp.el @@ -0,0 +1,75 @@ +;;; agent-shell-tramp.el --- TRAMP support for agent-shell -*- lexical-binding: t; -*- + +;; Copyright (C) 2024 Alvaro Ramirez + +;; Author: Alvaro Ramirez https://xenodium.com +;; URL: https://github.com/xenodium/agent-shell + +;; This package is free software; you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation; either version 3, or (at your option) +;; any later version. + +;; This package is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with GNU Emacs. If not, see . + +;;; Commentary: +;; +;; This file provides TRAMP support for agent-shell, allowing agents +;; to run on remote hosts accessed via TRAMP. +;; +;; TRAMP support works automatically when `default-directory' is a +;; TRAMP path (e.g., /ssh:host:/path). The agent runs on the remote +;; host via Emacs' file-handler mechanism. +;; + +;;; Code: + +(declare-function agent-shell-cwd "agent-shell") + +(declare-function tramp-tramp-file-p "tramp") +(declare-function tramp-dissect-file-name "tramp") +(declare-function tramp-file-name-host "tramp") +(declare-function tramp-file-name-localname "tramp") +(declare-function tramp-make-tramp-file-name "tramp") + +(defun agent-shell--resolve-tramp-path (path) + "Resolve PATH between TRAMP format and remote-local format. + +For example: +- /ssh:host:/project/README.md => /project/README.md +- /project/README.md => /ssh:host:/project/README.md" + (require 'tramp) + (let* ((cwd (agent-shell-cwd)) + (tramp-vec (and (tramp-tramp-file-p cwd) + (tramp-dissect-file-name cwd)))) + (cond + ;; Path is already a TRAMP path - strip the prefix for the agent + ((tramp-tramp-file-p path) + (tramp-file-name-localname (tramp-dissect-file-name path))) + ;; Path is a remote-local path - add TRAMP prefix for Emacs + (tramp-vec + (tramp-make-tramp-file-name tramp-vec path)) + ;; Not in a TRAMP context + (t path)))) + +(defun agent-shell--tramp-transcript-dir (cwd) + "Return local transcript directory for TRAMP CWD. +Returns nil if CWD is not a TRAMP path." + (when (and (fboundp 'tramp-tramp-file-p) + (tramp-tramp-file-p cwd)) + (require 'tramp) + (let* ((vec (tramp-dissect-file-name cwd)) + (host (tramp-file-name-host vec)) + (localname (tramp-file-name-localname vec)) + (safe-path (replace-regexp-in-string "/" "_" (string-trim localname "/" "/")))) + (expand-file-name (format ".agent-shell/transcripts/%s/%s" host safe-path) + (expand-file-name "~"))))) + +(provide 'agent-shell-tramp) +;;; agent-shell-tramp.el ends here diff --git a/agent-shell.el b/agent-shell.el index 20d0cd0..caff124 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -115,11 +115,14 @@ When non-nil, user message sections are expanded." :type 'boolean :group 'agent-shell) -(defcustom agent-shell-path-resolver-function nil +(autoload 'agent-shell--resolve-tramp-path "agent-shell-tramp") + +(defcustom agent-shell-path-resolver-function #'agent-shell--resolve-tramp-path "Function for resolving remote paths on the local file-system, and vice versa. Expects a function that takes the path as its single argument, and -returns the resolved path. Set to nil to disable mapping." +returns the resolved path. The default handles TRAMP paths automatically. +Set to nil or #\\='identity to disable path resolution." :type 'function :group 'agent-shell) @@ -128,7 +131,10 @@ returns the resolved path. Set to nil to disable mapping." When non-nil, both the agent command and shell commands will be executed using this runner. Can be a list of strings or a function -that takes a buffer and returns a list. +that takes a buffer and returns a list (or nil for local execution). + +Note: TRAMP remote execution is handled automatically via Emacs' +file-handler mechanism and does not require this setting. Example for static devcontainer: \\='(\"devcontainer\" \"exec\" \"--workspace-folder\" \".\") @@ -146,7 +152,7 @@ Example for per-session containers: (if (string-match \"project-a\" (buffer-name buffer)) \\='(\"docker\" \"exec\" \"project-a-dev\" \"--\") \\='(\"docker\" \"exec\" \"project-b-dev\" \"--\")))" - :type '(choice (repeat string) function) + :type '(choice (const nil) (repeat string) function) :group 'agent-shell) (defcustom agent-shell-section-functions nil @@ -1175,6 +1181,18 @@ If the buffer's file has changed, prompt the user to reload it." "Resolve PATH using `agent-shell-path-resolver-function'." (funcall (or agent-shell-path-resolver-function #'identity) path)) +;; TRAMP support is in agent-shell-tramp.el +(declare-function agent-shell--tramp-transcript-dir "agent-shell-tramp") + +(defun agent-shell--local-temp-directory () + "Return a local temporary directory, even when `default-directory' is remote. +This ensures temp files (like cached icons) are always stored locally." + (if (and (fboundp 'tramp-tramp-file-p) + (tramp-tramp-file-p default-directory)) + ;; When in a TRAMP buffer, use Emacs-standard cache directory + (locate-user-emacs-file "agent-shell/cache/") + (temporary-file-directory))) + (defun agent-shell--get-devcontainer-workspace-path (cwd) "Return devcontainer workspaceFolder for CWD, or default value if none found. @@ -2190,7 +2208,9 @@ Icon names starting with https:// are downloaded directly from that location." url)) ;; For lobe-icons names, use the original filename (file-name-nondirectory url))) - (cache-dir (file-name-concat (temporary-file-directory) "agent-shell" mode)) + ;; Always use local temp directory, even when default-directory is remote + (local-temp-dir (agent-shell--local-temp-directory)) + (cache-dir (file-name-concat local-temp-dir "agent-shell" mode)) (cache-path (expand-file-name filename cache-dir))) (unless (file-exists-p cache-path) (make-directory cache-dir t) @@ -2215,7 +2235,9 @@ Return file path of the generated SVG." (let* ((icon-text (char-to-string (string-to-char icon-name))) (mode (if (eq (frame-parameter nil 'background-mode) 'dark) "dark" "light")) (filename (format "%s-%s.svg" icon-name width)) - (cache-dir (file-name-concat (temporary-file-directory) "agent-shell" mode)) + ;; Always use local temp directory, even when default-directory is remote + (local-temp-dir (agent-shell--local-temp-directory)) + (cache-dir (file-name-concat local-temp-dir "agent-shell" mode)) (cache-path (expand-file-name filename cache-dir)) (font-size (* 0.7 width)) (x (/ width 2)) @@ -4158,8 +4180,13 @@ When nil, transcript saving is disabled." For example: - project/.agent-shell/transcripts/." - (let* ((dir (expand-file-name ".agent-shell/transcripts" (agent-shell-cwd))) + project/.agent-shell/transcripts/. + +For TRAMP paths, saves locally in ~/.agent-shell/transcripts///." + (let* ((cwd (agent-shell-cwd)) + (dir (or (agent-shell--tramp-transcript-dir cwd) + ;; Local paths use project root as before + (expand-file-name ".agent-shell/transcripts" cwd))) (filename (format-time-string "%F-%H-%M-%S.md")) (filepath (expand-file-name filename dir))) filepath)) diff --git a/tests/agent-shell-tests.el b/tests/agent-shell-tests.el index 5516ac3..1ba93cc 100644 --- a/tests/agent-shell-tests.el +++ b/tests/agent-shell-tests.el @@ -737,5 +737,35 @@ code block content with spaces [((name . "simple") (command . "simple-server"))])))) +;;; TRAMP Support Tests + +(ert-deftest agent-shell--resolve-tramp-path-strip-prefix-test () + "Test that resolver strips TRAMP prefix from paths." + (require 'tramp) + (should (equal (agent-shell--resolve-tramp-path "/ssh:host:/project/file.el") + "/project/file.el"))) + +(ert-deftest agent-shell--resolve-tramp-path-identity-test () + "Test that resolver returns local paths unchanged when not in TRAMP context." + (let ((default-directory "/tmp/local/")) + (should (equal (agent-shell--resolve-tramp-path "/tmp/local/file.el") + "/tmp/local/file.el")))) + +(ert-deftest agent-shell--local-temp-directory-local-test () + "Test that local-temp-directory returns normal temp dir for local paths." + (let ((default-directory "/tmp/local/")) + (should (stringp (agent-shell--local-temp-directory))) + (should-not (string-prefix-p "/ssh:" (agent-shell--local-temp-directory))))) + +(ert-deftest agent-shell--local-temp-directory-tramp-test () + "Test that local-temp-directory returns local path for TRAMP paths." + (require 'tramp) + (let ((default-directory "/ssh:host:/remote/")) + (let ((temp-dir (expand-file-name (agent-shell--local-temp-directory)))) + (should (stringp temp-dir)) + (should-not (string-prefix-p "/ssh:" temp-dir)) + ;; Should be under Emacs user directory + (should (string-prefix-p (expand-file-name user-emacs-directory) temp-dir))))) + (provide 'agent-shell-tests) ;;; agent-shell-tests.el ends here