Skip to content
Merged
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- MCP: recover remote servers with stale connections (e.g. after suspend): tool-call timeouts now probe and re-initialize; add `mcpKeepAliveSeconds` pings; 404 triggers re-init.
- Update `github-copilot` default model to `gpt-5.5`.
- Add advisory `chatStatusChanged` hook reporting aggregate chat status (running/idle/stopping, awaiting-user-input, pending/running tool calls).

## 0.140.0

Expand Down
1 change: 1 addition & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -861,6 +861,7 @@
"sessionEnd",
"chatStart",
"chatEnd",
"chatStatusChanged",
"subagentStart",
"subagentPostRequest",
"preRequest",
Expand Down
43 changes: 39 additions & 4 deletions docs/config/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ This page first explains the mechanics shared by **all** hooks, then provides a
| [`sessionEnd`](#sessionend) | Server shutting down | Side effects |
| [`chatStart`](#chatstart) | New or resumed chat | Inject context, stop the turn |
| [`chatEnd`](#chatend) | Chat deleted | Side effects (advisory) |
| [`chatStatusChanged`](#chatstatuschanged) | Chat aggregate status changed | Side effects (advisory) |
| [`subagentStart`](#subagentstart) | Before a subagent's first prompt | Inject context, stop the turn |
| [`preRequest`](#prerequest) | Before prompt sent to LLM | Rewrite prompt, inject context, block |
| [`postRequest`](#postrequest) | After a primary-agent prompt finished | Trigger a follow-up turn |
Expand Down Expand Up @@ -125,8 +126,8 @@ These behave the same wherever they are supported:

| Field | Type | Effect |
|-------|------|--------|
| `systemMessage` | string | A standalone user-facing message, shown as `Hook '<name>': <text>` on exit `0`, independent of `visible`/`suppressOutput`. No effect on `sessionStart`/`sessionEnd`/`chatEnd` (no chat UI). |
| `suppressOutput` | boolean | Hide only the execution block's body (stdout/stderr and effect lines). No effect on `sessionStart`/`sessionEnd`/`chatEnd` (no chat UI). |
| `systemMessage` | string | A standalone user-facing message, shown as `Hook '<name>': <text>` on exit `0`, independent of `visible`/`suppressOutput`. No effect on `sessionStart`/`sessionEnd`/`chatEnd`/`chatStatusChanged` (no chat UI). |
| `suppressOutput` | boolean | Hide only the execution block's body (stdout/stderr and effect lines). No effect on `sessionStart`/`sessionEnd`/`chatEnd`/`chatStatusChanged` (no chat UI). |
| `continue` | boolean | `false` stops the remaining hooks for the event; for turn-scoped hooks it also stops the turn. |
| `stopReason` | string | User-only explanation shown when `continue: false` stops a turn. **Never sent to the LLM.** |

Expand All @@ -139,7 +140,7 @@ The two ways a hook intervenes are **not** interchangeable:
- **`continue: false`** (exit `0`) always stops ECA from running the **remaining hooks** of the same event. For **turn-scoped** hooks it *additionally* stops the current turn and surfaces `Turn stopped by hook '<name>': <stopReason>` (or `Turn stopped by hook '<name>'.` with no reason).
- **`exit 2`** is a surgical, hook-specific intervention (see each hook). It **never** stops the hook chain.

**Turn-scoped hooks** are `chatStart`, `subagentStart`, `preRequest`, `postRequest`, `subagentPostRequest`, `preCompact`, `postCompact`, `preToolCall`, and `postToolCall`. The rest — `sessionStart`, `sessionEnd`, `chatEnd` — only skip their remaining peers.
**Turn-scoped hooks** are `chatStart`, `subagentStart`, `preRequest`, `postRequest`, `subagentPostRequest`, `preCompact`, `postCompact`, `preToolCall`, and `postToolCall`. The rest — `sessionStart`, `sessionEnd`, `chatEnd`, `chatStatusChanged` — only skip their remaining peers.

When a hook stops the turn it ends immediately: remaining `postRequest`/`subagentPostRequest` hooks do not run and no `followUp`/continuation fires.

Expand All @@ -157,7 +158,7 @@ Two controls gate the block (`systemMessage` is unaffected by both):
| `visible: false` | Hides the **whole** block — the hook run is fully silent. |
| `suppressOutput: true` | Hides only the block **body**; the block header still appears so you can see the hook ran. |

Effect-only fields (`updatedInput`, `approval`, `replacedOutput`) change behavior without creating standalone messages, but a visible block still surfaces them as effect lines so you can see what changed. Hooks without a UI (`sessionStart`, `sessionEnd`, `chatEnd`) ignore `systemMessage`/`suppressOutput`.
Effect-only fields (`updatedInput`, `approval`, `replacedOutput`) change behavior without creating standalone messages, but a visible block still surfaces them as effect lines so you can see what changed. Hooks without a UI (`sessionStart`, `sessionEnd`, `chatEnd`, `chatStatusChanged`) ignore `systemMessage`/`suppressOutput`.

### Base input

Expand Down Expand Up @@ -208,6 +209,40 @@ Fires when a chat is deleted. **Advisory and best-effort** — side effects only
- **Honored output** — none. No `additionalContext`/`stopReason`/`suppressOutput` (no UI). `continue: false` only stops later `chatEnd` hooks; it does not affect deletion.
- **Exit 2** — non-blocking error.

### `chatStatusChanged`

Fires whenever the aggregate chat status snapshot changes. **Advisory and best-effort** — side effects only (notifications, dashboards, telemetry).

- **Input adds** — `status`, `awaiting_user_input`, `pending_approval_tool_call_ids`, `pending_question_tool_call_ids`, `running_tool_call_ids`, and, only while blocked on the user, `waiting_reason` (`toolApproval` or `userQuestion`). Subagent chats also include `parent_chat_id`.
- **Honored output** — none. No `additionalContext`/`updatedInput`/`approval`/`replacedOutput`/`stopReason`/`systemMessage`/`suppressOutput` (no UI). `continue: false` only stops later `chatStatusChanged` hooks for the same event; it does not affect the chat lifecycle and cannot suppress later status emissions.
- **Exit 2** — non-blocking error.

Each payload is a full snapshot, never a delta, and the hook fires only when the snapshot actually changed — it never receives a duplicate of the previous snapshot. A consumer that attaches mid-turn may see nothing until the next transition.

This hook can fire many times per turn (every tool start/end changes the aggregate). Keep the script fast:

```bash
#!/usr/bin/env bash
payload=$(cat)
( slow-notify "$payload" & ) # detach; exit immediately
exit 0
```

Example configuration:

```json
{
"hooks": {
"notify-status": {
"type": "chatStatusChanged",
"actions": [
{"type": "shell", "file": "~/.config/eca/hooks/chat-status.sh"}
]
}
}
}
```

### `subagentStart`

Fires before a subagent's first prompt. Use to inject context scoped to the subagent.
Expand Down
2 changes: 1 addition & 1 deletion src/eca/db.clj
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@
;; error before any token arrived. Cleanup of
;; stale chats is handled by
;; cleanup-old-chats! instead.
(-> (update-vals chats #(dissoc % :tool-calls))
(-> (update-vals chats #(dissoc % :tool-calls :last-status-payload))
stamp-chat-ids)))))

(defn ^:private normalize-db-for-global-write [db]
Expand Down
61 changes: 33 additions & 28 deletions src/eca/features/chat.clj
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,7 @@
(swap! db* update-in [:chats chat-id] dissoc :prompt-finished?)
(swap! db* assoc-in [:chats chat-id :updated-at] (System/currentTimeMillis))
(messenger/chat-status-changed messenger {:chat-id chat-id :status :running})
(lifecycle/trigger-chat-status-hook! chat-ctx)
(swap! db* assoc-in [:chats chat-id :prompt-id] prompt-id)
(swap! db* assoc-in [:chats chat-id :model] full-model)
;; Persist the variant the chat is currently using so subsequent
Expand Down Expand Up @@ -1245,7 +1246,8 @@
;; Only notify client if finish-chat-prompt! hasn't already run,
;; otherwise the belated statusChanged causes duplicate finished handling.
(when-not (get-in @db* [:chats chat-id :prompt-finished?])
(messenger/chat-status-changed (:messenger chat-ctx) {:chat-id chat-id :status :idle}))
(messenger/chat-status-changed (:messenger chat-ctx) {:chat-id chat-id :status :idle})
(lifecycle/trigger-chat-status-hook! chat-ctx))
(db/update-workspaces-cache! @db* metrics))))))))))

(defn ^:private send-mcp-prompt!
Expand Down Expand Up @@ -1583,13 +1585,15 @@
:model "error"
:status :error})))))))

(defn tool-call-approve [{:keys [chat-id tool-call-id save]} db* messenger metrics]
(defn tool-call-approve
[{:keys [chat-id tool-call-id save]} db* messenger config metrics]
(logger/with-chat-context chat-id (db/parent-chat-id @db* chat-id)
(if-not (get-in @db* [:chats chat-id :tool-calls tool-call-id])
(logger/warn logger-tag "tool-call-approve ignored: unknown chat or tool-call"
{:chat-id chat-id :tool-call-id tool-call-id})
(let [chat-ctx {:chat-id chat-id
:db* db*
:config config
:metrics metrics
:messenger messenger}]
(tc/transition-tool-call! db* chat-ctx tool-call-id :user-approve
Expand All @@ -1599,13 +1603,15 @@
(let [tool-call-name (get-in @db* [:chats chat-id :tool-calls tool-call-id :name])]
(swap! db* assoc-in [:tool-calls tool-call-name :remember-to-approve?] true)))))))

(defn tool-call-reject [{:keys [chat-id tool-call-id]} db* messenger metrics]
(defn tool-call-reject
[{:keys [chat-id tool-call-id]} db* messenger config metrics]
(logger/with-chat-context chat-id (db/parent-chat-id @db* chat-id)
(if-not (get-in @db* [:chats chat-id :tool-calls tool-call-id])
(logger/warn logger-tag "tool-call-reject ignored: unknown chat or tool-call"
{:chat-id chat-id :tool-call-id tool-call-id})
(let [chat-ctx {:chat-id chat-id
:db* db*
:config config
:metrics metrics
:messenger messenger}]
(tc/transition-tool-call! db* chat-ctx tool-call-id :user-reject
Expand Down Expand Up @@ -1667,31 +1673,30 @@
(logger/info logger-tag "Steer message removed" {:chat-id chat-id})))))

(defn prompt-stop
([params db* messenger metrics]
(prompt-stop params db* messenger metrics {}))
([{:keys [chat-id]} db* messenger metrics {:keys [silent?]}]
(logger/with-chat-context chat-id (db/parent-chat-id @db* chat-id)
(when (identical? :running (get-in @db* [:chats chat-id :status]))
;; Set :stopping immediately to prevent race with stream callbacks
;; that check status via assert-chat-not-stopped! or cancelled?
(swap! db* assoc-in [:chats chat-id :status] :stopping)
(let [chat-ctx {:chat-id chat-id
:db* db*
:metrics metrics
:messenger messenger
:parent-chat-id (db/parent-chat-id @db* chat-id)}]
(when-not silent?
(lifecycle/send-content! chat-ctx :system {:type :text
:text "\nPrompt stopped"}))

;; Handle each active tool call
(doseq [[tool-call-id _] (tc/get-active-tool-calls @db* chat-id)]
(tc/transition-tool-call! db* chat-ctx tool-call-id :stop-requested
{:reason {:code :user-prompt-stop
:text "Tool call rejected because of user prompt stop"}}))
;; Clear compacting flags so finish-chat-prompt! isn't blocked
(swap! db* update-in [:chats chat-id] dissoc :auto-compacting? :compacting?)
(lifecycle/finish-chat-prompt! :stopping (lifecycle/strip-hook-callbacks chat-ctx)))))))
[{:keys [chat-id]} db* messenger config metrics & {:keys [silent?]}]
(logger/with-chat-context chat-id (db/parent-chat-id @db* chat-id)
(when (identical? :running (get-in @db* [:chats chat-id :status]))
;; Set :stopping immediately to prevent race with stream callbacks
;; that check status via assert-chat-not-stopped! or cancelled?
(swap! db* assoc-in [:chats chat-id :status] :stopping)
(let [chat-ctx {:chat-id chat-id
:db* db*
:config config
:metrics metrics
:messenger messenger
:parent-chat-id (db/parent-chat-id @db* chat-id)}]
(when-not silent?
(lifecycle/send-content! chat-ctx :system {:type :text
:text "\nPrompt stopped"}))

;; Handle each active tool call
(doseq [[tool-call-id _] (tc/get-active-tool-calls @db* chat-id)]
(tc/transition-tool-call! db* chat-ctx tool-call-id :stop-requested
{:reason {:code :user-prompt-stop
:text "Tool call rejected because of user prompt stop"}}))
;; Clear compacting flags so finish-chat-prompt! isn't blocked
(swap! db* update-in [:chats chat-id] dissoc :auto-compacting? :compacting?)
(lifecycle/finish-chat-prompt! :stopping (lifecycle/strip-hook-callbacks chat-ctx))))))

(defn delete-chat
[{:keys [chat-id]} db* messenger config metrics]
Expand Down
67 changes: 64 additions & 3 deletions src/eca/features/chat/lifecycle.clj
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@
:id id})))

(defn notify-after-hook-action! [chat-ctx {:keys [id name parsed raw-output raw-error exit type hook-type visible? tool-call-id]}]
(let [advisory-chat-end? (= :chatEnd hook-type)
;; JSON output fields are only honored on exit 0 (and never for chatEnd).
parsed-effects (when (and (not advisory-chat-end?) (zero? exit)) parsed)
(let [advisory-chat-hook? (contains? #{:chatEnd :chatStatusChanged} hook-type)
;; JSON output fields are only honored on exit 0 (and never for advisory chat hooks).
parsed-effects (when (and (not advisory-chat-hook?) (zero? exit)) parsed)
suppress? (boolean (get parsed-effects "suppressOutput"))]
;; Two independent channels:
;; 1. Execution block, gated by `visible?`. If a started event was sent we must
Expand Down Expand Up @@ -362,11 +362,72 @@
:stop-reason (:stop-reason stop-result)
:stop-hook-name (:stop-hook-name stop-result)}))

(defn ^:private chat-status-payload
"Build the aggregate chatStatusChanged hook payload from a db snapshot."
[db chat-id]
(let [chat (get-in db [:chats chat-id])
tool-calls (:tool-calls chat)
ask-user? (fn [tc]
(and (= "ask_user" (:name tc))
(= "eca" (:server tc))))
ids-by (fn [pred]
(->> tool-calls
(filter (fn [[_ tc]] (pred tc)))
(map key)
sort
vec))
pending-approval (ids-by #(= :waiting-approval (:status %)))
pending-question (ids-by #(and (= :executing (:status %)) (ask-user? %)))
running (ids-by #(and (= :executing (:status %)) (not (ask-user? %))))
awaiting? (boolean (or (seq pending-approval) (seq pending-question)))]
(cond-> {:chat-id chat-id
:status (or (:status chat) :idle)
:awaiting-user-input awaiting?
:pending-approval-tool-call-ids pending-approval
:pending-question-tool-call-ids pending-question
:running-tool-call-ids running}
awaiting? (assoc :waiting-reason (if (seq pending-approval)
"toolApproval"
"userQuestion")))))

(defn trigger-chat-status-hook!
"Trigger the advisory chatStatusChanged hook when the aggregate chat status
payload changed since the last trigger.

The protocol `chat/statusChanged` notification is emitted separately and
carries only the chat id and status. This hook receives the aggregate status,
including awaiting-user-input and tool-call ids.

Dedup state is stored at [:chats chat-id :last-status-payload]. Missing chats
are ignored."
[{:keys [db* chat-id config] :as chat-ctx}]
(when (some #(= :chatStatusChanged (keyword (:type %))) (vals (:hooks config)))
(let [path [:chats chat-id :last-status-payload]
;; Compute inside the swap so the stored payload matches the db snapshot.
[old-db new-db] (swap-vals! db* (fn [db]
(if (get-in db [:chats chat-id])
(assoc-in db path (chat-status-payload db chat-id))
db)))
payload (get-in new-db path)]
(when (and (get-in new-db [:chats chat-id])
(not= (get-in old-db path) payload))
(f.hooks/trigger-if-matches!
:chatStatusChanged
(merge (f.hooks/chat-hook-data new-db chat-ctx)
(when (get-in new-db [:chats chat-id :subagent])
{:parent-chat-id (db/parent-chat-id new-db chat-id)})
(dissoc payload :chat-id))
;; No action callbacks: advisory hooks do not render in the chat UI.
{}
new-db
config)))))

(defn ^:private apply-status-transition!
"Update chat status, send status/progress events, set created-at if needed."
[{:keys [chat-id db* messenger] :as chat-ctx} status]
(swap! db* assoc-in [:chats chat-id :status] status)
(messenger/chat-status-changed messenger {:chat-id chat-id :status status})
(trigger-chat-status-hook! chat-ctx)
(send-content! chat-ctx :system
{:type :progress
:state :finished})
Expand Down
2 changes: 2 additions & 0 deletions src/eca/features/chat/tool_calls.clj
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,8 @@
(doseq [action actions]
(execute-action! action db* chat-ctx tool-call-id event-data))

(lifecycle/trigger-chat-status-hook! (assoc chat-ctx :db* db*))

{:status status :actions actions}))

(def ^:private hook-approval-rank
Expand Down
4 changes: 2 additions & 2 deletions src/eca/features/commands.clj
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,8 @@

(def ^:private hook-type-order
"Display order for hook types in /hooks output."
["sessionStart" "sessionEnd" "chatStart" "chatEnd" "subagentStart" "preRequest"
"postRequest" "subagentPostRequest" "preToolCall" "postToolCall"
["sessionStart" "sessionEnd" "chatStart" "chatEnd" "chatStatusChanged" "subagentStart"
"preRequest" "postRequest" "subagentPostRequest" "preToolCall" "postToolCall"
"preCompact" "postCompact"])

(defn ^:private hooks-msg [config]
Expand Down
2 changes: 1 addition & 1 deletion src/eca/features/hooks.clj
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@
(defn run-hook-action!
"Execute a single hook action. Supported hook types:
- :sessionStart, :sessionEnd (session lifecycle)
- :chatStart, :chatEnd (chat lifecycle)
- :chatStart, :chatEnd, :chatStatusChanged (chat lifecycle)
- :subagentStart, :subagentPostRequest (subagent lifecycle)
- :preRequest, :postRequest (prompt lifecycle)
- :preToolCall, :postToolCall (tool lifecycle)
Expand Down
6 changes: 3 additions & 3 deletions src/eca/features/tools/agent.clj
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,10 @@

(defn ^:private stop-subagent-chat!
"Stop a running subagent chat silently (parent already shows 'Prompt stopped')."
[db* messenger metrics subagent-chat-id agent-name]
[db* messenger config metrics subagent-chat-id agent-name]
(let [prompt-stop (requiring-resolve 'eca.features.chat/prompt-stop)]
(try
(prompt-stop {:chat-id subagent-chat-id} db* messenger metrics {:silent? true})
(prompt-stop {:chat-id subagent-chat-id} db* messenger config metrics {:silent? true})
(catch Exception e
(logger/warn logger-tag (format "Error stopping subagent '%s': %s" agent-name (.getMessage e)))))))

Expand Down Expand Up @@ -211,7 +211,7 @@
;; Wait for subagent to complete by polling status
(let [stopped-result (fn []
(logger/info logger-tag (format "Agent '%s' stopped by parent chat" agent-name))
(stop-subagent-chat! db* messenger metrics subagent-chat-id agent-name)
(stop-subagent-chat! db* messenger config metrics subagent-chat-id agent-name)
{:error true
:contents [{:type :text
:text (format "Agent '%s' was stopped because the parent chat was stopped." agent-name)}]})]
Expand Down
Loading
Loading