Skip to content
Open
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
318 changes: 311 additions & 7 deletions agent-shell.el
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,8 @@ HEARTBEAT, and AUTHENTICATE-REQUEST-MAKER."
(cons :available-commands nil)
(cons :available-modes nil)
(cons :prompt-capabilities nil)
(cons :session-capabilities nil)
(cons :resume-session-id nil)
(cons :pending-requests nil)))

(defvar-local agent-shell--state
Expand Down Expand Up @@ -737,10 +739,18 @@ Flow:
(map-put! (agent-shell--state) :authenticated t)
(agent-shell--handle :command command :shell shell))))
((not (map-nested-elt (agent-shell--state) '(:session :id)))
(agent-shell--initiate-session
:shell shell
:on-session-init (lambda ()
(agent-shell--handle :command command :shell shell))))
(if (map-elt (agent-shell--state) :resume-session-id)
;; Resume existing session
(agent-shell--resume-session
:shell shell
:session-id (map-elt (agent-shell--state) :resume-session-id)
:on-session-resumed (lambda ()
(agent-shell--handle :command command :shell shell)))
;; Create new session
(agent-shell--initiate-session
:shell shell
:on-session-init (lambda ()
(agent-shell--handle :command command :shell shell)))))
((and (map-nested-elt (agent-shell--state) '(:agent-config :default-model-id))
(funcall (map-nested-elt (agent-shell--state)
'(:agent-config :default-model-id)))
Expand Down Expand Up @@ -1757,13 +1767,15 @@ FUNCTION should be a function accepting keyword arguments (&key ...)."
(list (car pair) (cdr pair)))
alist)))

(cl-defun agent-shell--start (&key config no-focus new-session)
(cl-defun agent-shell--start (&key config no-focus new-session resume-session-id cwd)
"Programmatically start shell with CONFIG.

See `agent-shell-make-agent-config' for config format.

Set NO-FOCUS to start in background.
Set NEW-SESSION to start a separate new session."
Set NEW-SESSION to start a separate new session.
Set RESUME-SESSION-ID to resume an existing session by ID.
Set CWD to override the working directory."
(unless (version<= "0.84.9" shell-maker-version)
(error "Please update shell-maker to version 0.84.9 or newer"))
(unless (version<= "0.8.3" acp-package-version)
Expand All @@ -1777,7 +1789,7 @@ variable (see makunbound)"))
:prompt (map-elt config :shell-prompt)
:prompt-regexp (map-elt config :shell-prompt-regexp)))
(agent-shell--shell-maker-config shell-maker-config)
(default-directory (agent-shell-cwd))
(default-directory (or cwd (agent-shell-cwd)))
(shell-buffer
(shell-maker-start agent-shell--shell-maker-config
t ; Always use no-focus, handle display below
Expand Down Expand Up @@ -1824,6 +1836,9 @@ variable (see makunbound)"))
:needs-authentication (map-elt config :needs-authentication)
:authenticate-request-maker (map-elt config :authenticate-request-maker)
:agent-config config))
;; Set resume-session-id if provided
(when resume-session-id
(map-put! agent-shell--state :resume-session-id resume-session-id))
;; Initialize buffer-local shell-maker-config
(setq-local agent-shell--shell-maker-config shell-maker-config)
(agent-shell--update-header-and-mode-line)
Expand Down Expand Up @@ -2492,6 +2507,16 @@ Must provide ON-INITIATED (lambda ())."
(map-put! agent-shell--state :prompt-capabilities
(list (cons :image (map-elt prompt-capabilities 'image))
(cons :embedded-context (map-elt prompt-capabilities 'embeddedContext)))))
;; Save session capabilities from agent (resume, fork, list)
;; Note: In ACP, capabilities like resume/fork/list are indicated by
;; presence of the key with an empty object {}, which json-read parses
;; as nil. So we check key existence, not value truthiness.
(when-let ((session-capabilities
(map-nested-elt response '(agentCapabilities sessionCapabilities))))
(map-put! agent-shell--state :session-capabilities
(list (cons :resume (map-contains-key session-capabilities 'resume))
(cons :fork (map-contains-key session-capabilities 'fork))
(cons :list (map-contains-key session-capabilities 'list)))))
;; Save available modes from agent, converting to internal symbols
(when-let ((modes (map-elt response 'modes)))
(map-put! agent-shell--state :available-modes
Expand Down Expand Up @@ -2650,10 +2675,135 @@ Must provide ON-SESSION-INIT (lambda ())."
:body (agent-shell--format-available-modes
(agent-shell--get-available-modes agent-shell--state))))
(agent-shell--update-header-and-mode-line)
;; Auto-save session for potential resumption
(when-let ((session-id (map-nested-elt agent-shell--state '(:session :id)))
(agent-id (map-nested-elt agent-shell--state '(:agent-config :identifier))))
(agent-shell--save-session
:session-id session-id
:agent-identifier agent-id
:cwd (agent-shell-cwd)))
(funcall on-session-init))
:on-failure (agent-shell--make-error-handler
:state agent-shell--state :shell shell)))

(cl-defun agent-shell--resume-session (&key shell session-id on-session-resumed)
"Resume an existing ACP session with SHELL.

SESSION-ID is the ID of the session to resume.
Must provide ON-SESSION-RESUMED (lambda ()).

This function first initializes the agent to discover its capabilities,
then attempts session/resume if supported, falling back to session/start
if not supported or if resume fails."
(unless on-session-resumed
(error "Missing required argument: :on-session-resumed"))
(unless session-id
(error "Missing required argument: :session-id"))
;; First initialize the agent to discover capabilities
(agent-shell--initiate-handshake
:shell shell
:on-initiated
(lambda ()
;; Now check if agent supports session resume
(if (map-nested-elt (agent-shell--state) '(:session-capabilities :resume))
(agent-shell--do-session-resume
:shell shell
:session-id session-id
:on-session-resumed on-session-resumed)
;; Agent doesn't support session resume - fall back to new session
(agent-shell--update-fragment
:state agent-shell--state
:block-id "starting"
:body "\n\nAgent does not support session resume. Creating new session..."
:append t)
;; Delete the stale saved session
(agent-shell--delete-saved-session session-id)
;; Clear resume-session-id and create new session
(map-put! agent-shell--state :resume-session-id nil)
(agent-shell--initiate-session
:shell shell
:on-session-init on-session-resumed)))))

(cl-defun agent-shell--do-session-resume (&key shell session-id on-session-resumed)
"Perform the actual session/resume request.

SHELL is the shell instance.
SESSION-ID is the ID of the session to resume.
ON-SESSION-RESUMED is called on success.

This is an internal function called by `agent-shell--resume-session'
after agent initialization confirms resume capability."
(with-current-buffer (map-elt (agent-shell--state) :buffer)
(agent-shell--update-fragment
:state (agent-shell--state)
:block-id "starting"
:body "\n\nResuming session..."
:append t))
(acp-send-request
:client (map-elt (agent-shell--state) :client)
:request (acp-make-session-resume-request
:session-id session-id
:cwd (agent-shell--resolve-path (agent-shell-cwd))
:mcp-servers (agent-shell--mcp-servers))
:buffer (current-buffer)
:on-success (lambda (response)
(map-put! agent-shell--state
:session (list (cons :id session-id)
(cons :mode-id (map-nested-elt response '(modes currentModeId)))
(cons :modes (mapcar (lambda (mode)
`((:id . ,(map-elt mode 'id))
(:name . ,(map-elt mode 'name))
(:description . ,(map-elt mode 'description))))
(map-nested-elt response '(modes availableModes))))
(cons :model-id (map-nested-elt response '(models currentModelId)))
(cons :models (mapcar (lambda (model)
`((:model-id . ,(map-elt model 'modelId))
(:name . ,(map-elt model 'name))
(:description . ,(map-elt model 'description))))
(map-nested-elt response '(models availableModels))))))
(agent-shell--update-fragment
:state agent-shell--state
:block-id "starting"
:label-left (format "%s %s"
(agent-shell--status-label "completed")
(propertize "Resuming session" 'font-lock-face 'font-lock-doc-markup-face))
:body "\n\nResumed"
:append t)
(agent-shell--update-header-and-mode-line)
;; Update session access time
(agent-shell--update-session-access-time session-id)
(when (map-nested-elt agent-shell--state '(:session :models))
(agent-shell--update-fragment
:state agent-shell--state
:block-id "available_models"
:label-left (propertize "Available models" 'font-lock-face 'font-lock-doc-markup-face)
:body (agent-shell--format-available-models
(map-nested-elt agent-shell--state '(:session :models)))))
(when (agent-shell--get-available-modes agent-shell--state)
(agent-shell--update-fragment
:state agent-shell--state
:block-id "available_modes"
:label-left (propertize "Available modes" 'font-lock-face 'font-lock-doc-markup-face)
:body (agent-shell--format-available-modes
(agent-shell--get-available-modes agent-shell--state))))
(agent-shell--update-header-and-mode-line)
(funcall on-session-resumed))
:on-failure (lambda (error &optional _message)
;; On failure, fall back to creating a new session
(agent-shell--update-fragment
:state agent-shell--state
:block-id "starting"
:body (format "\n\nFailed to resume session: %s\nCreating new session..."
(or (map-elt error 'message) "Unknown error"))
:append t)
;; Delete the stale saved session
(agent-shell--delete-saved-session session-id)
;; Clear resume-session-id and create new session
(map-put! agent-shell--state :resume-session-id nil)
(agent-shell--initiate-session
:shell shell
:on-session-init on-session-resumed))))

(defun agent-shell--mcp-servers ()
"Return normalized MCP servers configuration for JSON serialization.

Expand Down Expand Up @@ -4372,6 +4522,160 @@ Includes STATUS, TITLE, KIND, DESCRIPTION, COMMAND, and OUTPUT."
(error "Transcript file does not exist: %s" agent-shell--transcript-file))
(find-file agent-shell--transcript-file))

;;; Session Persistence

(defcustom agent-shell-session-persistence-enabled t
"Whether to persist session IDs for resumption.
When non-nil, session IDs are saved to disk and can be resumed later
using `agent-shell-resume-session'."
:type 'boolean
:group 'agent-shell)

(defcustom agent-shell-sessions-directory-function
#'agent-shell--default-sessions-directory
"Function to determine the directory for storing session files.
Called with no arguments, should return a directory path string."
:type 'function
:group 'agent-shell)

(defun agent-shell--default-sessions-directory ()
"Return the default directory for storing session files.
Uses `.agent-shell/sessions' in the project root."
(expand-file-name ".agent-shell/sessions" (agent-shell-cwd)))

(defun agent-shell--session-file-path (session-id)
"Return the file path for SESSION-ID."
(let ((dir (funcall agent-shell-sessions-directory-function)))
(expand-file-name (concat session-id ".json") dir)))

(cl-defun agent-shell--save-session (&key session-id agent-identifier cwd)
"Save session metadata to disk.
SESSION-ID is the session identifier from the agent.
AGENT-IDENTIFIER is the symbol identifying the agent type.
CWD is the working directory for the session."
(when (and agent-shell-session-persistence-enabled session-id)
(let* ((dir (funcall agent-shell-sessions-directory-function))
(filepath (agent-shell--session-file-path session-id))
(metadata `((sessionId . ,session-id)
Copy link
Owner

@xenodium xenodium Feb 1, 2026

Choose a reason for hiding this comment

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

Can we use self-evaluating symbols? That is start with :. Also - please, like we do for state. https://github.com/xenodium/agent-shell?tab=readme-ov-file#maps-use-alists

(agentIdentifier . ,(symbol-name agent-identifier))
(cwd . ,cwd)
(createdAt . ,(format-time-string "%FT%T%z"))
(lastAccessedAt . ,(format-time-string "%FT%T%z")))))
(condition-case err
(progn
(make-directory dir t)
(with-temp-file filepath
(insert (json-serialize metadata)))
Copy link
Owner

Choose a reason for hiding this comment

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

We should consider saving as a printed elisp data structure:

  (with-temp-file filepath
    (prin1 metadata (current-buffer)))

(message "Session saved: %s" session-id))
(error
(message "Failed to save session: %S" err))))))

(defun agent-shell--load-session-metadata (session-id)
"Load session metadata for SESSION-ID from disk.
Returns an alist with session metadata or nil if not found."
(let ((filepath (agent-shell--session-file-path session-id)))
(when (file-exists-p filepath)
(condition-case nil
(with-temp-buffer
(insert-file-contents filepath)
(json-parse-buffer :object-type 'alist))
(error nil)))))

(defun agent-shell--update-session-access-time (session-id)
"Update the lastAccessedAt timestamp for SESSION-ID."
(when-let ((metadata (agent-shell--load-session-metadata session-id)))
(let ((filepath (agent-shell--session-file-path session-id)))
(setf (alist-get 'lastAccessedAt metadata)
Copy link
Owner

Choose a reason for hiding this comment

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

(format-time-string "%FT%T%z"))
(condition-case nil
(with-temp-file filepath
(insert (json-serialize metadata)))
(error nil)))))

(defun agent-shell--list-saved-sessions ()
"Return a list of saved session metadata alists.
Sessions are sorted by lastAccessedAt, most recent first."
(let ((dir (funcall agent-shell-sessions-directory-function)))
(when (file-directory-p dir)
(let ((sessions nil))
(dolist (file (directory-files dir t "\\.json$"))
(condition-case nil
(with-temp-buffer
(insert-file-contents file)
(push (json-parse-buffer :object-type 'alist) sessions))
(error nil)))
(sort sessions
(lambda (a b)
(string> (or (alist-get 'lastAccessedAt a) "")
(or (alist-get 'lastAccessedAt b) ""))))))))

(defun agent-shell--delete-saved-session (session-id)
"Delete the saved session file for SESSION-ID."
(let ((filepath (agent-shell--session-file-path session-id)))
(when (file-exists-p filepath)
(delete-file filepath))))

(defun agent-shell--format-session-for-display (session)
"Format SESSION metadata for display in completion."
(let* ((id (alist-get 'sessionId session))
(agent (alist-get 'agentIdentifier session))
Copy link
Owner

Choose a reason for hiding this comment

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

Please prefer map.el here and elsewhere (ie. alist-get instances) is possible https://github.com/xenodium/agent-shell?tab=readme-ov-file#mapel

(created (alist-get 'createdAt session))
(accessed (alist-get 'lastAccessedAt session)))
(format "%s (%s) - %s"
(truncate-string-to-width id 20 nil nil "...")
agent
(or accessed created "unknown"))))

(defun agent-shell--supports-session-resume-p ()
"Return non-nil if current agent supports session/resume."
(map-nested-elt agent-shell--state '(:session-capabilities :resume)))

;;;###autoload
(defun agent-shell-resume-session ()
"Resume a previously saved session.
Prompts for a session to resume from the list of saved sessions."
(interactive)
(let ((sessions (agent-shell--list-saved-sessions)))
(unless sessions
(user-error "No saved sessions found"))
(let* ((choices (mapcar (lambda (s)
(cons (agent-shell--format-session-for-display s) s))
sessions))
(selection (completing-read "Resume session: " choices nil t))
(session (cdr (assoc selection choices))))
(unless session
(user-error "No session selected"))
(let* ((session-id (alist-get 'sessionId session))
(agent-id (intern (alist-get 'agentIdentifier session)))
(cwd (alist-get 'cwd session))
(config (seq-find (lambda (c)
(eq (map-elt c :identifier) agent-id))
agent-shell-agent-configs)))
(unless config
(user-error "Agent configuration not found for: %s" agent-id))
(agent-shell--start :config config
:resume-session-id session-id
:cwd cwd)))))

;;;###autoload
(defun agent-shell-delete-saved-session ()
"Delete a saved session from disk."
(interactive)
(let ((sessions (agent-shell--list-saved-sessions)))
(unless sessions
(user-error "No saved sessions found"))
(let* ((choices (mapcar (lambda (s)
(cons (agent-shell--format-session-for-display s) s))
sessions))
(selection (completing-read "Delete session: " choices nil t))
(session (cdr (assoc selection choices))))
(unless session
(user-error "No session selected"))
(when (y-or-n-p (format "Delete session %s? "
(alist-get 'sessionId session)))
(agent-shell--delete-saved-session (alist-get 'sessionId session))
(message "Session deleted")))))

;;; Queueing

(cl-defun agent-shell--process-pending-request ()
Expand Down