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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
## Unreleased

- Add OpenAI Responses API for GitHub Copilot models that require it (gpt-5.5, gpt-5.4-mini).

- MCP OAuth: persist and reuse the dynamically-registered client on token refresh, so servers with non-idempotent DCR (e.g. RunLayer) refresh instead of forcing a browser re-login, and recover from expired-token tool errors automatically.
- Add `preCompact`, `postCompact` and `subagentStart` hooks; subagents no longer trigger `chatStart`.
- Add `/hooks` command with optional `description` field in hook config.
- Tool hooks (`preToolCall`/`postToolCall`) now include `tool_call_id` in input data.
- Expand hook contracts: `response` not `prompt`, plain-text `tool_response`, `continue:false` everywhere, `followUp`, `replacedOutput`, standalone `systemMessage`, exact-string matchers.
- Fix `postToolCall continue:false` leaking across turns, `chatStart` `additionalContext` dropped, and `preRequest` exit-2 naming the blocking hook.

## 0.138.1

Expand Down
17 changes: 13 additions & 4 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -855,20 +855,29 @@
"sessionEnd",
"chatStart",
"chatEnd",
"subagentStart",
"subagentPostRequest",
"preRequest",
"postRequest",
"preCompact",
"postCompact",
"preToolCall",
"postToolCall"
]
},
"description": {
"type": "string",
"description": "Brief explanation of the hook's purpose, shown by /hooks.",
"markdownDescription": "Brief explanation of the hook's purpose, shown by `/hooks`."
},
"matcher": {
"description": "Matches *ToolCall hooks; unmatched hooks are skipped. String: legacy regex against server__tool-name. Object: tool selector map with optional argsMatchers.",
"markdownDescription": "Matches `*ToolCall` hooks; unmatched hooks are skipped. **String**: legacy regex against `server__tool-name`. **Object**: tool selector map with optional `argsMatchers`.",
"description": "Matches *ToolCall hooks or compact hooks; unmatched hooks are skipped. For tool hooks, strings are legacy regex against server__tool-name and objects are tool selector maps with optional argsMatchers. For compact hooks, strings are exact matches against the triggered value (manual or auto); omitted matcher runs for both.",
"markdownDescription": "Matches `*ToolCall` hooks or compact hooks; unmatched hooks are skipped. For tool hooks, **String** is a legacy regex against `server__tool-name` and **Object** is a tool selector map with optional `argsMatchers`. For compact hooks, strings are matched as exact strings against the `triggered` value (`manual` or `auto`); omitted matcher runs for both.",
"oneOf": [
{
"type": "string",
"description": "Regex pattern for matching server__tool-name.",
"markdownDescription": "Regex pattern for matching `server__tool-name`."
"description": "Tool hooks: regex pattern for matching server__tool-name. Compact hooks: exact triggered value (manual or auto).",
"markdownDescription": "Tool hooks: regex pattern for matching `server__tool-name`. Compact hooks: exact `triggered` value (`manual` or `auto`)."
},
{
"type": "object",
Expand Down
8 changes: 2 additions & 6 deletions docs/config/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,12 +298,8 @@ If you think your config is relevant to be shared for other people, [open a pull
data = json.loads(sys.argv[1])

hook_type = data.get('hook_type', '')
workspaces = data.get('workspaces', [])
cwd = workspaces[0] if workspaces else ''
# ECA does not provide a session_id; derive one from db_cache_path to get a
# stable per-session identifier, falling back to a constant.
db_path = data.get('db_cache_path', '')
session_id = db_path.split('/')[-2] if db_path else 'eca-default'
cwd = data.get('cwd', '')
session_id = data.get('session_id', 'eca-default')

type_map = {
'sessionStart': 'SessionStart',
Expand Down
415 changes: 374 additions & 41 deletions docs/config/hooks.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ The built-in commands are:
- `/login`: Log into a provider. Ex: `github-copilot`, `anthropic`.
- `/model`: Select model for current chat directly from chat. Ex: `anthropic/claude-sonnet-4-6`.
- `/skills`: List known skills that ECA can load.
- `/hooks`: List active hooks grouped by type, showing name, description, and matcher.
- `/compact`: Compact/summarize conversation helping reduce context window.
- `/resume`: Resume a chat from previous session of this workspace folder.
- `/costs`: Show costs about current session.
Expand Down
6 changes: 6 additions & 0 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,12 @@ interface ChatHookActionFinishedContent {
* The error of this hook if any
*/
error?: string;

/**
* For tool hooks (preToolCall/postToolCall), the id of the tool call this
* hook acted on, so clients can correlate the hook block with its tool call.
*/
toolCallId?: string;
}

/**
Expand Down
1 change: 1 addition & 0 deletions integration-test/integration/chat/commands_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
:arguments [{:name "plugin" :description "Plugin name or plugin@marketplace" :required true}]}
{:name "plugin-uninstall"
:arguments [{:name "plugin" :description "Plugin name" :required true}]}
{:name "hooks" :arguments []}
{:name "eca-info" :arguments nil}]}
resp))))

Expand Down
26 changes: 13 additions & 13 deletions integration-test/integration/chat/hooks_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
(m/embeds
[{:chatId chat-id
:role "system"
:content {:type "text" :text "STOPPED BY HOOK"}}
:content {:type "text" :text "Turn stopped by hook 'stop': STOPPED BY HOOK"}}
{:chatId chat-id
:role "system"
:content {:type "progress" :state "finished"}}])
Expand Down Expand Up @@ -223,9 +223,8 @@
(let [hook-data (json/parse-string (slurp log-path) true)]
(is (match?
{:tool_input {:path (m/pred string?)}
:tool_response [{:type "text"
:text (m/pred #(and (string? %)
(not (string/blank? %))))}]
:tool_response (m/pred #(and (string? %)
(string/includes? % "file1.md")))
:chat_id (m/pred string?)
:server (m/equals "eca")
:db_cache_path (m/pred string?)
Expand All @@ -236,9 +235,7 @@
:error (m/equals false)
:workspaces (m/seq-of (m/pred string?))
:tool_name (m/equals "directory_tree")}
hook-data))
;; Explicitly check that we got some file listing content
(is (string/includes? (get-in hook-data [:tool_response 0 :text]) "file1.md")))))))
hook-data)))))))

(deftest pretoolcall-approval-deny-test
(testing "preToolCall hook can reject tool calls via approval:deny"
Expand Down Expand Up @@ -292,7 +289,7 @@
"Tool call should have been rejected"))))

(deftest pretoolcall-exit-code-rejection-with-stop-test
(testing "preToolCall hook exit code 2 rejects tool and continue:false stops chat"
(testing "preToolCall hook exit 0 with approval:deny + continue:false rejects tool and stops chat"
(let [win? (string/starts-with? (System/getProperty "os.name") "Windows")]
(eca/start-process!)

Expand All @@ -304,10 +301,13 @@
(hooks-init-options
{"reject-and-stop" {:type "preToolCall"
:actions [{:type "shell"
;; Exit code 2 means rejection, with continue:false and stopReason
;; Exit 0: JSON effects apply. approval:deny rejects the
;; tool, additionalContext is the LLM-visible reason,
;; continue:false stops the turn and stopReason is shown
;; to the user.
:shell (if win?
"Write-Output '{\"continue\":false,\"stopReason\":\"Security policy violation\"}'; exit 2"
"echo '{\"continue\":false,\"stopReason\":\"Security policy violation\"}' && exit 2")}]}})})))
"Write-Output '{\"approval\":\"deny\",\"additionalContext\":\"Tool blocked by policy\",\"continue\":false,\"stopReason\":\"Security policy violation\"}'"
"echo '{\"approval\":\"deny\",\"additionalContext\":\"Tool blocked by policy\",\"continue\":false,\"stopReason\":\"Security policy violation\"}'")}]}})})))

(eca/notify! (fixture/initialized-notification))

Expand Down Expand Up @@ -338,10 +338,10 @@
notifications)
"Tool call should have been rejected")

;; Verify stopReason was displayed
;; Verify stopReason was displayed (may be prefixed with the hook name)
(is (some #(and (= "system" (:role %))
(= "text" (get-in % [:content :type]))
(= "Security policy violation" (get-in % [:content :text])))
(string/includes? (str (get-in % [:content :text])) "Security policy violation"))
notifications)
"Stop reason should have been displayed"))))

Expand Down
3 changes: 3 additions & 0 deletions src/eca/db.clj
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@
:refresh-token :string
:expires-at :long}}})

(defn parent-chat-id [db chat-id]
(get-in db [:chats chat-id :parent-chat-id]))

(defonce initial-db
{:client-info {}
:workspace-folders []
Expand Down
Loading
Loading