Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.agent-shell/
21 changes: 21 additions & 0 deletions README.org
Original file line number Diff line number Diff line change
Expand Up @@ -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/<host>/<path>/=

*** 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
Expand Down
75 changes: 75 additions & 0 deletions agent-shell-tramp.el
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

;;; 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
43 changes: 35 additions & 8 deletions agent-shell.el
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a thought: since agent-shell--resolve-tramp-path does nothing for non-tramp paths (so is save to call in all cases), you might want to hardcode calling it into agent-shell--resolve-path, probably even before funcall-ing agent-shell-path-resolver-function. That way, TRAMP support would be entirely transparent.

"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)

Expand All @@ -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\" \".\")
Expand All @@ -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
Expand Down Expand Up @@ -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 ()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: you might want to consider naming this agent-shell--tramp-temp-dir and moving it into agent-shell-tramp.el. It's quite similar in spirit to agent-shell--tramp-transcript-dir imho.

"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/")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be just (locate-user-emacs-file "cache/")?

Further down in lines 2213 and 2240, you're file-name-concat-ing another "agent-shell", so would end up with ".../agent-shell/cache/agent-shell".

(temporary-file-directory)))

(defun agent-shell--get-devcontainer-workspace-path (cwd)
"Return devcontainer workspaceFolder for CWD, or default value if none found.

Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -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/<host>/<remote-path>/."
(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))
Expand Down
30 changes: 30 additions & 0 deletions tests/agent-shell-tests.el
Original file line number Diff line number Diff line change
Expand Up @@ -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