;;; agent.el --- Utilities to work with AI agents -*- lexical-binding: t; -*- ;; Author: Pavel Popov ;; Keywords: convenience, tools ;;; Commentary: ;;; Code: (require 'ghostel) (require 'cl-lib) (require 'json) (defconst agent-openrouter-user "content-squad@findhotel.net^adhoc" "User for OpenRouter to lookup the key.") ;;; Functions (defun agent-openrouter-api-key () "Return OpenRouter API key." (cadr (auth-source-user-and-password "openrouter.ai" agent-openrouter-user))) ;;; Commands (defun agent--interactive-dir-arg () "Return the DIR argument for `agent-start-*' commands' interactive spec. Uses the active region as a path when present, otherwise prompts for a directory when called with a prefix argument." (cond ((use-region-p) (buffer-substring-no-properties (region-beginning) (region-end))) (current-prefix-arg (read-directory-name "Directory: " default-directory)))) ;;;###autoload (defun agent-copy-path-spec () "Copy `path:start-end' (or `path:line') for the current region/point." (interactive) (let* ((path (or (buffer-file-name) (user-error "Buffer is not visiting a file"))) (spec (if (use-region-p) (format "%s:%d-%d" path (line-number-at-pos (region-beginning)) (line-number-at-pos (if (and (> (region-end) (region-beginning)) (= (char-before (region-end)) ?\n)) (1- (region-end)) (region-end)))) (format "%s:%d" path (line-number-at-pos))))) (kill-new spec) (message "%s" spec))) (defun agent--claude-buffer-name (title) "Return \"*claude: TITLE*\", or nil when TITLE is nil. A `ghostel-buffer-name-function', so renaming the session renames its buffer." (and title (format "*claude: %s*" title))) ;;;###autoload (defun agent-start-claude-code (&optional dir name resume) "Start Claude Code in Ghostel in directory DIR as a session called NAME. DIR defaults to `default-directory'; interactively, a prefix argument prompts for it. NAME defaults to DIR's last component and is passed to Claude on its command line, so `claude --resume' and the terminal title carry it from the start. With RESUME, a session id, the session goes on where it was left. Returns the session's buffer." (interactive (list (agent--interactive-dir-arg))) (let* ((dir (file-name-as-directory (expand-file-name (or dir default-directory)))) (name (or name (file-name-nondirectory (directory-file-name dir)))) (buf (get-buffer-create (agent--claude-buffer-name name)))) (with-current-buffer buf (setq default-directory dir)) (ghostel-exec buf "claude" (append (and resume (list "--resume" resume)) (list "--remote-control" "--name" name))) (with-current-buffer buf (setq-local ghostel-buffer-name-function #'agent--claude-buffer-name)) (pop-to-buffer buf) buf)) (defcustom agent-type-patience 120 "Seconds `agent-type' waits for the agent to show its input box." :type 'natnum :group 'tools) (defconst agent--input-box (rx bol (>= 3 "─") (* nonl) "\n❯" (* nonl) "\n" (>= 3 "─")) "A coding agent's input box, as Claude Code draws it.") ;;;###autoload (defun agent-type (buffer texts) "Type TEXTS into the agent in BUFFER, each followed by Return. The first is typed once the agent shows its input box, each next one a few seconds after the one before, so the agent takes them one by one; what it gets while it works waits its turn. Give up when BUFFER is gone or the agent shows no input box for `agent-type-patience' seconds." (let ((deadline (+ (float-time) agent-type-patience)) (timer nil)) (setq timer (run-at-time 1 3 (lambda () (unless (and (< (float-time) deadline) (setq texts (agent--type-next buffer texts))) (cancel-timer timer))))))) (defun agent--type-next (buffer texts) "Type the first of TEXTS into the agent in BUFFER when it shows its input box. Return the TEXTS left to type, or nil when there is nothing left to do." (when (and texts (buffer-live-p buffer)) (with-current-buffer buffer (if (not (and ghostel--term (string-match-p agent--input-box (ghostel--copy-all-text ghostel--term)))) texts (ghostel-paste-string (car texts)) ;; a terminal app may take a paste and a key in one read as all paste (run-at-time 0.1 nil (lambda () (when (buffer-live-p buffer) (with-current-buffer buffer (ghostel-send-key "return"))))) (cdr texts))))) ;;;###autoload (defun agent-start-zerostack (&optional dir) "Start ZeroStack in Ghostel in DIR. DIR defaults to `default-directory'; interactively, a prefix argument prompts for it." (interactive (list (agent--interactive-dir-arg))) (let* ((dir (file-name-as-directory (expand-file-name (or dir default-directory)))) (buf (get-buffer-create (format "*zerostack: %s*" (file-name-nondirectory (directory-file-name dir))))) (ghostel-environment `("EDITOR=emacsclient" ,(format "OPENROUTER_API_KEY=%s" (agent-openrouter-api-key))))) (with-current-buffer buf (setq default-directory dir)) (ghostel-exec buf "zerostack") (pop-to-buffer buf))) ;;;###autoload (defun agent-start-pi (&optional dir name) "Start Pi coding agent in Ghostel in DIR, in a buffer called after NAME. DIR defaults to `default-directory'; interactively, a prefix argument prompts for it. NAME defaults to DIR's last component." (interactive (list (agent--interactive-dir-arg))) (let* ((dir (file-name-as-directory (expand-file-name (or dir default-directory)))) (name (or name (file-name-nondirectory (directory-file-name dir)))) (buf (get-buffer-create (format "*pi: %s*" name))) (ghostel-environment `("EDITOR=emacsclient"))) (with-current-buffer buf (setq default-directory dir)) (ghostel-exec buf "pi") (pop-to-buffer buf))) ;;;###autoload (defcustom agent-opencode-port 4096 "Port of the OpenCode server that OpenCode terminals attach to." :type 'integer :group 'tools) (defvar agent--opencode-server nil "The OpenCode server process, or nil.") (defun agent--opencode-url () "Return the address of the OpenCode server." (format "http://127.0.0.1:%d" agent-opencode-port)) (defun agent-opencode-server () "Start the OpenCode server unless it runs, and return its address. Every OpenCode terminal is a client of this one server, so its sessions can also be read and driven over its HTTP API." (interactive) (unless (process-live-p agent--opencode-server) (setq agent--opencode-server (let ((default-directory (expand-file-name "~/"))) (make-process :name "opencode-server" :buffer (get-buffer-create " *opencode-server*") :command (list "opencode" "serve" "--port" (number-to-string agent-opencode-port)) :noquery t))) ;; a client attaching before the server listens gives up (with-timeout (15 (error "The OpenCode server did not start")) (while (not (ignore-errors (delete-process (make-network-process :name "opencode-probe" :host "127.0.0.1" :service agent-opencode-port)) t)) (sleep-for 0.2)))) (agent--opencode-url)) (defun agent-start-opencode (&optional dir name) "Start OpenCode in DIR, attached to the OpenCode server. DIR defaults to `default-directory'; interactively, a prefix argument prompts for it. The buffer is called after NAME; NAME defaults to DIR's last component." (interactive (list (agent--interactive-dir-arg))) (let* ((dir (file-name-as-directory (expand-file-name (or dir default-directory)))) (name (or name (file-name-nondirectory (directory-file-name dir)))) (buf (generate-new-buffer (format "*opencode: %s*" name))) (ghostel-environment '("EDITOR=claude-emacs-prompt"))) (with-current-buffer buf (setq default-directory dir) (unless (derived-mode-p 'ghostel-mode) (ghostel-mode))) (ghostel-exec buf "opencode" (list "attach" (agent-opencode-server) "--dir" (directory-file-name dir))) (pop-to-buffer buf))) ;;;###autoload (cl-defun agent-claude-agents (&key cwd all) "List Claude Code agents. Interactively, run `claude agents' in Ghostel in current directory. Called from Lisp, run `claude agents --json' and return the parsed JSON array of active sessions instead. CWD scopes the listing to background sessions started under that directory (sets --cwd); ALL also includes completed background sessions (sets --all)." (interactive) (if (called-interactively-p 'any) (let ((buf (get-buffer-create "*taylor-swift-and-her-guitar*"))) (ghostel-exec buf "claude" '("agents")) (pop-to-buffer buf)) (let ((args (append (list "claude" "agents" "--json") (when all '("--all")) (when cwd (list "--cwd" (expand-file-name cwd)))))) (condition-case err (json-read-from-string (shell-command-to-string (mapconcat #'shell-quote-argument args " "))) (error (user-error "Failed to list agents: %s" (error-message-string err))))))) (provide 'agent) ;;; agent.el ends here