From 05ddc279a53f428488da9c59613f96655e73386d Mon Sep 17 00:00:00 2001 From: Calum MacRae Date: Wed, 5 Nov 2025 15:29:00 +0000 Subject: [PATCH 01/10] feat: add tool-call-update hook for extension support Add agent-shell-tool-call-update-functions hook to enable extensions to react to tool call status changes. This is called after the tool call state is updated but before UI changes, allowing extensions to: - Follow files when tool calls become active (status: in_progress) - Track tool call lifecycle for logging/monitoring - Add custom behavior for specific tool call types The hook receives STATE and UPDATE, where UPDATE contains toolCallId, status, content, and locations from the ACP protocol. --- agent-shell.el | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/agent-shell.el b/agent-shell.el index 68acb54..6bfbe6e 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -126,6 +126,21 @@ See https://github.com/xenodium/agent-shell/issues/119" :type 'boolean :group 'agent-shell) +(defcustom agent-shell-tool-call-update-functions nil + "Abnormal hook run when a tool call is updated. +Each function is called with STATE and UPDATE alist, where UPDATE contains: + - toolCallId: string + - status: string (pending, in_progress, completed, failed) + - content: tool call content array + - locations: array of location objects (path, line) + +Functions should not modify STATE or UPDATE directly. + +This hook is called after tool call state is updated but before +the dialog block is updated in the UI." + :type 'hook + :group 'agent-shell) + (cl-defun agent-shell--make-acp-client (&key command command-params environment-variables @@ -595,6 +610,8 @@ Flow: (cons :content (map-elt update 'content))) (when-let ((diff (agent-shell--make-diff-info (map-elt update 'content)))) (list (cons :diff diff))))) + ;; Run extension hooks after state update but before UI update + (run-hook-with-args 'agent-shell-tool-call-update-functions state update) (let* ((diff (map-nested-elt state `(:tool-calls ,.toolCallId :diff))) (output (concat "\n\n" From 7c79d7af77354bc5d1b7cbccc08d3aafd5a8376f Mon Sep 17 00:00:00 2001 From: Calum MacRae Date: Wed, 5 Nov 2025 15:29:23 +0000 Subject: [PATCH 02/10] feat: add basic state access functions for extensions Add agent-shell-get-state and agent-shell-get-client to provide read-only access to agent shell state and ACP client. These functions allow extensions to: - Query current agent session state - Access the ACP client for protocol operations - Safely read state without internal dependencies Extensions should treat the returned state as read-only. --- agent-shell.el | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/agent-shell.el b/agent-shell.el index 6bfbe6e..f07cfcd 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -400,6 +400,23 @@ Returns an empty string if no icon should be displayed." (interactive) (message "agent-shell v%s" agent-shell--version)) +;;; Extension API + +(defun agent-shell-get-state (&optional buffer) + "Get agent shell state from BUFFER (defaults to current buffer). +Returns nil if buffer is not an agent-shell buffer. +Note: The returned state should be treated as read-only by extensions." + (with-current-buffer (or buffer (current-buffer)) + (when (and (boundp 'agent-shell--state) + (derived-mode-p 'agent-shell-mode)) + agent-shell--state))) + +(defun agent-shell-get-client (&optional buffer) + "Get ACP client from BUFFER's agent shell state. +BUFFER defaults to current buffer. +Returns nil if buffer is not an agent-shell buffer." + (map-elt (agent-shell-get-state buffer) :client)) + (defun agent-shell-interrupt (&optional force) "Interrupt in-progress request and reject all pending permissions. When FORCE is non-nil, skip confirmation prompt." From d8ab2524724272cce0cc8bcbdcbba11424663acb Mon Sep 17 00:00:00 2001 From: Calum MacRae Date: Wed, 5 Nov 2025 15:29:44 +0000 Subject: [PATCH 03/10] feat: add tool-call state management for extensions Add agent-shell-tool-call-get and agent-shell-tool-call-put to allow extensions to manage tool call specific state. These functions enable extensions to: - Retrieve tool call metadata (rawInput, status, locations, etc.) - Store extension-specific data per tool call (timers, overlays, etc.) - Track tool call lifecycle without modifying core state structure This is essential for extensions that need to maintain state across multiple tool call updates, such as debouncing timers for follow-edits or tracking preview overlays for permission queueing. --- agent-shell.el | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/agent-shell.el b/agent-shell.el index f07cfcd..1074f72 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -417,6 +417,22 @@ BUFFER defaults to current buffer. Returns nil if buffer is not an agent-shell buffer." (map-elt (agent-shell-get-state buffer) :client)) +(defun agent-shell-tool-call-get (tool-call-id &optional buffer) + "Get tool call data for TOOL-CALL-ID from BUFFER's agent shell state. +BUFFER defaults to current buffer. +Returns the tool call alist or nil if not found." + (when-let ((state (agent-shell-get-state buffer))) + (map-nested-elt state (list :tool-calls tool-call-id)))) + +(defun agent-shell-tool-call-put (tool-call-id data &optional buffer) + "Store tool call DATA for TOOL-CALL-ID in BUFFER's agent shell state. +BUFFER defaults to current buffer. +DATA should be an alist that will be merged with existing data. +Returns non-nil on success." + (when-let ((state (agent-shell-get-state buffer))) + (agent-shell--save-tool-call state tool-call-id data) + t)) + (defun agent-shell-interrupt (&optional force) "Interrupt in-progress request and reject all pending permissions. When FORCE is non-nil, skip confirmation prompt." From 95db57ca0ead7be7fa40794f2e39ec2031fd97f8 Mon Sep 17 00:00:00 2001 From: Calum MacRae Date: Wed, 5 Nov 2025 15:30:04 +0000 Subject: [PATCH 04/10] feat: add path resolution helper for extensions Add agent-shell-resolve-path as a public API wrapper around agent-shell--resolve-path. This allows extensions to: - Apply configured path transformations consistently - Handle devcontainer path mapping transparently - Avoid depending on internal implementation details Extensions implementing follow-edits need this to correctly resolve file paths from ACP tool call locations before opening buffers. --- agent-shell.el | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/agent-shell.el b/agent-shell.el index 1074f72..0bf9ee2 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -433,6 +433,15 @@ Returns non-nil on success." (agent-shell--save-tool-call state tool-call-id data) t)) +(defun agent-shell-resolve-path (path) + "Resolve PATH using configured path resolver. +This applies any path transformations configured via +`agent-shell-path-resolver-function'. + +Extensions should use this instead of `agent-shell--resolve-path' +to ensure consistent path handling across the system." + (agent-shell--resolve-path path)) + (defun agent-shell-interrupt (&optional force) "Interrupt in-progress request and reject all pending permissions. When FORCE is non-nil, skip confirmation prompt." From de9d8bd9f1a08ee7b782dadc7ea55fd8559ba30f Mon Sep 17 00:00:00 2001 From: Calum MacRae Date: Wed, 5 Nov 2025 15:30:54 +0000 Subject: [PATCH 05/10] feat: add permission-request hook for custom queueing Add agent-shell-permission-request-functions hook to allow extensions to intercept and handle permission requests before default processing. This hook uses run-hook-with-args-until-success, meaning: - If any hook function returns non-nil, default handling is skipped - Extensions take full ownership of showing UI and sending responses - Multiple extensions can coexist (first to return non-nil wins) This enables extensions to: - Implement custom permission queueing (one at a time) - Add preview overlays before showing permission dialogs - Batch permissions or add custom approval workflows - Track permission patterns for analytics Extensions using this hook must call agent-shell-send-permission-response to send the ACP response when ready. --- agent-shell.el | 63 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index 0bf9ee2..155ccbf 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -141,6 +141,22 @@ the dialog block is updated in the UI." :type 'hook :group 'agent-shell) +(defcustom agent-shell-permission-request-functions nil + "Abnormal hook run when a permission request is received. +Each function is called with STATE and REQUEST alist. +If any function returns non-nil, default permission handling is skipped. +This allows extensions to implement custom permission queueing. + +Functions receive: + - STATE: agent shell state + - REQUEST: full request alist from session/request_permission + +Functions can return: + - nil: Continue with default handling + - non-nil: Skip default handling (extension handles it)" + :type 'hook + :group 'agent-shell) + (cl-defun agent-shell--make-acp-client (&key command command-params environment-variables @@ -745,28 +761,31 @@ Flow: "Handle incoming request using SHELL, STATE, and REQUEST." (let-alist request (cond ((equal .method "session/request_permission") - (agent-shell--save-tool-call - state .params.toolCall.toolCallId - (append (list (cons :title .params.toolCall.title) - (cons :status .params.toolCall.status) - (cons :kind .params.toolCall.kind) - (cons :permission-request-id .id)) - (when-let ((diff (agent-shell--make-diff-info .params.toolCall.content))) - (list (cons :diff diff))))) - (agent-shell--update-dialog-block - :state state - ;; block-id must be the same as the one used - ;; in agent-shell--delete-dialog-block param. - :block-id (format "permission-%s" .params.toolCall.toolCallId) - :body (with-current-buffer (map-elt state :buffer) - (agent-shell--make-tool-call-permission-text - :request request - :client (map-elt state :client) - :state state)) - :expanded t - :navigation 'never) - (agent-shell-jump-to-latest-permission-button-row) - (map-put! state :last-entry-type "session/request_permission")) + ;; Run extension hooks first - if any return non-nil, skip default handling + (unless (run-hook-with-args-until-success 'agent-shell-permission-request-functions + state request) + (agent-shell--save-tool-call + state .params.toolCall.toolCallId + (append (list (cons :title .params.toolCall.title) + (cons :status .params.toolCall.status) + (cons :kind .params.toolCall.kind) + (cons :permission-request-id .id)) + (when-let ((diff (agent-shell--make-diff-info .params.toolCall.content))) + (list (cons :diff diff))))) + (agent-shell--update-dialog-block + :state state + ;; block-id must be the same as the one used + ;; in agent-shell--delete-dialog-block param. + :block-id (format "permission-%s" .params.toolCall.toolCallId) + :body (with-current-buffer (map-elt state :buffer) + (agent-shell--make-tool-call-permission-text + :request request + :client (map-elt state :client) + :state state)) + :expanded t + :navigation 'never) + (agent-shell-jump-to-latest-permission-button-row) + (map-put! state :last-entry-type "session/request_permission"))) ((equal .method "fs/read_text_file") (agent-shell--on-fs-read-text-file-request :state state From a872b2a479a87d610e80fd1933836064855654b1 Mon Sep 17 00:00:00 2001 From: Calum MacRae Date: Wed, 5 Nov 2025 15:31:26 +0000 Subject: [PATCH 06/10] feat: add permission response control for extensions Add agent-shell-send-permission-response to allow extensions to send permission responses and clean up UI at their own timing. This function: - Finds the tool-call-id associated with a permission request-id - Sends the ACP permission response with the chosen option - Cleans up the permission dialog from the UI - Returns non-nil on success This is essential for extensions implementing permission queueing, which need to defer responses until the user has reviewed permissions sequentially. Extensions intercept requests via the permission-request hook and later call this function when ready to respond. --- agent-shell.el | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/agent-shell.el b/agent-shell.el index 155ccbf..537a2be 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -458,6 +458,35 @@ Extensions should use this instead of `agent-shell--resolve-path' to ensure consistent path handling across the system." (agent-shell--resolve-path path)) +(defun agent-shell-send-permission-response (request-id option-id &optional buffer) + "Send permission response for REQUEST-ID with OPTION-ID. +BUFFER defaults to current buffer. +Returns non-nil on success. + +This function is intended for use by extensions implementing custom +permission queueing via `agent-shell-permission-request-functions'. +It sends the ACP response and cleans up the permission dialog UI. + +Extensions that intercept permissions are responsible for calling this +function to send responses at the appropriate time." + (when-let ((state (agent-shell-get-state buffer)) + (client (map-elt state :client))) + (with-current-buffer (or buffer (current-buffer)) + ;; Find the tool-call-id for this request + (when-let ((tool-call-entry + (seq-find (lambda (entry) + (equal (map-elt (cdr entry) :permission-request-id) + request-id)) + (map-elt state :tool-calls)))) + (let ((tool-call-id (car tool-call-entry))) + (agent-shell--send-permission-response + :client client + :request-id request-id + :option-id option-id + :state state + :tool-call-id tool-call-id) + t))))) + (defun agent-shell-interrupt (&optional force) "Interrupt in-progress request and reject all pending permissions. When FORCE is non-nil, skip confirmation prompt." From e8de0b9b9884f1551074ec9f7441771bf3de088c Mon Sep 17 00:00:00 2001 From: Calum MacRae Date: Wed, 5 Nov 2025 15:31:50 +0000 Subject: [PATCH 07/10] feat: add dialog-block cleanup control for extensions Add agent-shell-delete-dialog-block to allow extensions to remove dialog blocks they have created in the agent shell buffer. This function wraps agent-shell--delete-dialog-block and provides: - Safe buffer-local state access - Proper error handling - Return value indicating success Extensions implementing permission queueing may need this to clean up custom dialog blocks they create for progress indicators or previews. --- agent-shell.el | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/agent-shell.el b/agent-shell.el index 537a2be..40debbf 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -487,6 +487,17 @@ function to send responses at the appropriate time." :tool-call-id tool-call-id) t))))) +(defun agent-shell-delete-dialog-block (block-id &optional buffer) + "Delete dialog block BLOCK-ID from BUFFER. +BUFFER defaults to current buffer. +Returns non-nil on success. + +This function is intended for use by extensions that create custom +dialog blocks and need to clean them up later." + (when-let ((state (agent-shell-get-state buffer))) + (agent-shell--delete-dialog-block :state state :block-id block-id) + t)) + (defun agent-shell-interrupt (&optional force) "Interrupt in-progress request and reject all pending permissions. When FORCE is non-nil, skip confirmation prompt." From 089cd164dc4224ac22033b18c66cf03ee1db9e1e Mon Sep 17 00:00:00 2001 From: Calum MacRae Date: Wed, 5 Nov 2025 15:32:43 +0000 Subject: [PATCH 08/10] feat: add file-write hook for post-write effects Add agent-shell-file-write-functions hook to enable extensions to react to file writes after they complete. This hook is called after the file is written and saved but before the ACP response is sent, allowing extensions to: - Highlight changed regions in the buffer (follow-edits) - Track file modifications for analytics - Trigger post-write actions (linting, formatting, etc.) The hook receives STATE, PATH, CONTENT, and TOOL-CALL-ID. The tool-call-id is found by searching tool calls for a rawInput with matching file_path, enabling extensions to access edit metadata (old_string, new_string) for precise highlighting. --- agent-shell.el | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/agent-shell.el b/agent-shell.el index 40debbf..440b65d 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -157,6 +157,21 @@ Functions can return: :type 'hook :group 'agent-shell) +(defcustom agent-shell-file-write-functions nil + "Abnormal hook run after a file is successfully written. +Each function is called with STATE, PATH, CONTENT, and TOOL-CALL-ID. + +Functions receive: + - STATE: agent shell state + - PATH: absolute file path that was written + - CONTENT: full file content that was written + - TOOL-CALL-ID: tool call ID that triggered the write (may be nil) + +This hook is called after the file is written and saved, but before +the ACP response is sent." + :type 'hook + :group 'agent-shell) + (cl-defun agent-shell--make-acp-client (&key command command-params environment-variables @@ -928,6 +943,19 @@ If the buffer's file has changed, prompt the user to reload it." ;; No open buffer, write to file directly. (with-temp-file path (insert content))) + ;; Find tool-call-id for this write operation + (let ((tool-call-id + (car (seq-find (lambda (entry) + (let* ((tc-data (cdr entry)) + (tc-raw-input (map-elt tc-data :rawInput)) + (tc-path (and tc-raw-input + (map-elt tc-raw-input 'file_path)))) + (and tc-path + (string= (agent-shell--resolve-path tc-path) path)))) + (map-elt state :tool-calls))))) + ;; Run extension hooks after write completes but before response + (run-hook-with-args 'agent-shell-file-write-functions + state path content tool-call-id)) (acp-send-response :client (map-elt state :client) :response (acp-make-fs-write-text-file-response From f1de30c2cdc2f4376e3dd615678a49c7b0e50d8f Mon Sep 17 00:00:00 2001 From: Calum MacRae Date: Wed, 5 Nov 2025 17:30:21 +0000 Subject: [PATCH 09/10] fix(extensions): invoke tool-call-update hook for initial tool_call events The agent-shell-tool-call-update-functions hook was only being called for tool_call_update events, missing the initial tool_call notification when operations begin. This prevented extensions from reacting to the in_progress status change. Additionally, save locations and rawInput data from tool_call events to enable extensions to access file paths and edit details. --- agent-shell.el | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/agent-shell.el b/agent-shell.el index 440b65d..fd5ad92 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -645,9 +645,13 @@ Flow: (cons :kind (map-elt update 'kind)) (cons :command (map-nested-elt update '(rawInput command))) (cons :description (map-nested-elt update '(rawInput description))) - (cons :content (map-elt update 'content))) + (cons :content (map-elt update 'content)) + (cons :locations (map-elt update 'locations)) + (cons :rawInput (map-elt update 'rawInput))) (when-let ((diff (agent-shell--make-diff-info (map-elt update 'content)))) (list (cons :diff diff))))) + ;; Run extension hooks after state update but before UI update + (run-hook-with-args 'agent-shell-tool-call-update-functions state update) (let ((tool-call-labels (agent-shell-make-tool-call-label state (map-elt update 'toolCallId)))) (agent-shell--update-dialog-block From a1276840394f53ccae925a35bf9bfa450a23e07d Mon Sep 17 00:00:00 2001 From: Calum MacRae Date: Wed, 5 Nov 2025 22:19:10 +0000 Subject: [PATCH 10/10] feat(extensions): add permission response hook and View button control Add new extension capabilities: - Add `agent-shell-permission-response-functions` hook that fires after any permission response is sent (accept/reject/always). This allows extensions to clean up their state (e.g., preview overlays) at the correct time, immediately after the user makes a decision. - Add `agent-shell-show-permission-diff-button` defcustom to control whether the View button is shown in permission dialogs. Extensions that provide their own inline diff preview can set this to nil, making the approach generic and extension-agnostic. The permission response hook is called with STATE, REQUEST-ID, TOOL-CALL-ID, OPTION-ID, and CANCELLED parameters, giving extensions complete context about the permission resolution. --- agent-shell.el | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/agent-shell.el b/agent-shell.el index fd5ad92..4ffda87 100644 --- a/agent-shell.el +++ b/agent-shell.el @@ -172,6 +172,22 @@ the ACP response is sent." :type 'hook :group 'agent-shell) +(defcustom agent-shell-permission-response-functions nil + "Abnormal hook run after a permission response is sent. +Each function is called with STATE, REQUEST-ID, TOOL-CALL-ID, OPTION-ID, and CANCELLED. + +Functions receive: + - STATE: agent shell state + - REQUEST-ID: the permission request ID + - TOOL-CALL-ID: tool call ID that required permission + - OPTION-ID: the selected option ID (nil if cancelled) + - CANCELLED: non-nil if permission was cancelled/rejected + +This hook is called after the ACP response is sent and dialog is cleaned up. +Extensions can use this to clean up their own state (e.g., preview overlays)." + :type 'hook + :group 'agent-shell) + (cl-defun agent-shell--make-acp-client (&key command command-params environment-variables @@ -201,6 +217,14 @@ See `acp-make-initialize-request' for details." :type 'boolean :group 'agent-shell) +(defcustom agent-shell-show-permission-diff-button t + "Whether to show the View button in permission dialogs for file edits. +When non-nil, displays a button allowing users to view diffs in a separate +buffer. Extensions that provide their own inline diff preview can set this +to nil." + :type 'boolean + :group 'agent-shell) + (defcustom agent-shell-display-action '(display-buffer-same-window) "Display action for agent shell buffers. @@ -2583,7 +2607,7 @@ For example: ;; May as well interrupt so you can course-correct. (agent-shell-interrupt t)))))) ;; Add diff keybinding if diff info is available - (when diff + (when (and diff agent-shell-show-permission-diff-button) (define-key map "v" (agent-shell--make-diff-viewing-function :diff diff :actions actions @@ -2594,7 +2618,7 @@ For example: ;; Add interrupt keybinding (define-key map (kbd "C-c C-c") #'agent-shell-interrupt) map)) - (diff-button (when diff + (diff-button (when (and diff agent-shell-show-permission-diff-button) (agent-shell--make-permission-button :text "View (v)" :help "Press v to view diff" @@ -2679,6 +2703,9 @@ MESSAGE-TEXT: Optional message to display after sending the response." ;; block-id must be the same as the one used as ;; agent-shell--update-dialog-block param by "session/request_permission". (agent-shell--delete-dialog-block :state state :block-id (format "permission-%s" tool-call-id)) + ;; Run extension hooks after response sent and dialog cleaned up + (run-hook-with-args 'agent-shell-permission-response-functions + state request-id tool-call-id option-id cancelled) (let ((updated-tool-calls (map-copy (map-elt state :tool-calls)))) (map-delete updated-tool-calls tool-call-id) (map-put! state :tool-calls updated-tool-calls))