fix: feat: stream action-agent Claude output to Matrix thread (#293)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-19 23:43:29 +00:00
parent 362ba489dc
commit 424a53c9f7
4 changed files with 80 additions and 6 deletions

View file

@ -218,11 +218,12 @@ issues labeled `action` that have no active tmux session, then spawns
**Session lifecycle**: **Session lifecycle**:
1. `action-poll.sh` finds open `action` issues with no active tmux session. 1. `action-poll.sh` finds open `action` issues with no active tmux session.
2. Spawns `action-agent.sh <issue_num>`. 2. Spawns `action-agent.sh <issue_num>`.
3. Agent creates tmux session `action-{issue_num}`, injects prompt (formula + prior comments). 3. Agent creates Matrix thread, exports `MATRIX_THREAD_ID` so Claude's output streams to the thread via a Stop hook (`on-stop-matrix.sh`).
4. Claude executes formula steps using Bash and other tools, posts progress as issue comments. 4. Agent creates tmux session `action-{issue_num}`, injects prompt (formula + prior comments).
5. For human input: Claude sends a Matrix message and waits; the reply is injected into the session by `matrix_listener.sh`. 5. Claude executes formula steps using Bash and other tools, posts progress as issue comments. Each Claude turn is streamed to the Matrix thread for real-time human visibility.
6. When complete: Claude closes the issue with a summary comment. Session exits. 6. For human input: Claude sends a Matrix message and waits; the reply is injected into the session by `matrix_listener.sh`.
7. Poll detects no active session on next run — nothing further to do. 7. When complete: Claude closes the issue with a summary comment. Session exits.
8. Poll detects no active session on next run — nothing further to do.
**Environment variables consumed**: **Environment variables consumed**:
- `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `CODEBERG_WEB` - `CODEBERG_TOKEN`, `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `CODEBERG_WEB`
@ -266,7 +267,7 @@ sourced as needed.
| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `CODEBERG_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, Matrix config, etc.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) | | `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `CODEBERG_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, Matrix config, etc.). | env.sh (when `PROJECT_TOML` is set), supervisor-poll (per-project iteration) |
| `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` patterns. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll | | `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` patterns. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll, supervisor-poll |
| `lib/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via well-known files (`/tmp/{agent}-escalation-reply`). Handles supervisor, gardener, dev, review, vault, and action reply routing. Run as systemd service. | Standalone daemon | | `lib/matrix_listener.sh` | Long-poll Matrix sync daemon. Dispatches thread replies to the correct agent via well-known files (`/tmp/{agent}-escalation-reply`). Handles supervisor, gardener, dev, review, vault, and action reply routing. Run as systemd service. | Standalone daemon |
| `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or a `PHASE:*` string. Agents must handle `idle_prompt` in both their callback and their post-loop exit handler. | dev-agent.sh, gardener-agent.sh | | `lib/agent-session.sh` | Shared tmux + Claude session helpers: `create_agent_session()`, `inject_formula()`, `agent_wait_for_claude_ready()`, `agent_inject_into_session()`, `agent_kill_session()`, `monitor_phase_loop()`, `read_phase()`. `create_agent_session(session, workdir, [phase_file])` optionally installs a PostToolUse hook (matcher `Bash\|Write`) that detects phase file writes in real-time — when Claude writes to the phase file, the hook writes a marker so `monitor_phase_loop` reacts on the next poll instead of waiting for mtime changes. When `MATRIX_THREAD_ID` is exported, also installs a Stop hook (`on-stop-matrix.sh`) that streams each Claude response to the Matrix thread. `monitor_phase_loop` sets `_MONITOR_LOOP_EXIT` to one of: `done`, `idle_timeout`, `idle_prompt` (Claude returned to `` for 3 consecutive polls without writing any phase — callback invoked with `PHASE:failed`, session already dead), `crashed`, or a `PHASE:*` string. Agents must handle `idle_prompt` in both their callback and their post-loop exit handler. | dev-agent.sh, gardener-agent.sh |
--- ---

View file

@ -96,6 +96,8 @@ THREAD_ID=""
if [ -n "${_thread_id:-}" ]; then if [ -n "${_thread_id:-}" ]; then
printf '%s' "$_thread_id" > "$THREAD_FILE" printf '%s' "$_thread_id" > "$THREAD_FILE"
THREAD_ID="$_thread_id" THREAD_ID="$_thread_id"
# Export for on-stop-matrix.sh hook (streams Claude output to thread)
export MATRIX_THREAD_ID="$_thread_id"
# Register thread root in map for listener dispatch (column 4 = issue number) # Register thread root in map for listener dispatch (column 4 = issue number)
printf '%s\t%s\t%s\t%s\n' "$_thread_id" "action" "$(date +%s)" "${ISSUE}" \ printf '%s\t%s\t%s\t%s\n' "$_thread_id" "action" "$(date +%s)" "${ISSUE}" \
>> "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true >> "${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}" 2>/dev/null || true

View file

@ -120,6 +120,32 @@ create_agent_session() {
fi fi
fi fi
# Install Stop hook for Matrix streaming: when MATRIX_THREAD_ID is set,
# each Claude response is posted to the Matrix thread so humans can follow.
local matrix_hook_script="${FACTORY_ROOT}/lib/hooks/on-stop-matrix.sh"
if [ -n "${MATRIX_THREAD_ID:-}" ] && [ -x "$matrix_hook_script" ]; then
if [ -f "$settings" ]; then
jq --arg cmd "$matrix_hook_script" '
if (.hooks.Stop // [] | any(.[]; .hooks[]?.command == $cmd))
then .
else .hooks.Stop = (.hooks.Stop // []) + [{
matcher: "",
hooks: [{type: "command", command: $cmd}]
}]
end
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
else
jq -n --arg cmd "$matrix_hook_script" '{
hooks: {
Stop: [{
matcher: "",
hooks: [{type: "command", command: $cmd}]
}]
}
}' > "$settings"
fi
fi
rm -f "$idle_marker" rm -f "$idle_marker"
tmux new-session -d -s "$session" -c "$workdir" \ tmux new-session -d -s "$session" -c "$workdir" \
"claude --dangerously-skip-permissions" 2>/dev/null "claude --dangerously-skip-permissions" 2>/dev/null

45
lib/hooks/on-stop-matrix.sh Executable file
View file

@ -0,0 +1,45 @@
#!/bin/bash
# on-stop-matrix.sh — Stop hook: post Claude response to Matrix thread.
#
# Called by Claude Code after each assistant turn. Reads the response from
# the hook JSON and posts it to the Matrix thread for this action session.
#
# Requires env vars: MATRIX_THREAD_ID, MATRIX_TOKEN, MATRIX_HOMESERVER, MATRIX_ROOM_ID
#
# Usage (in .claude/settings.json):
# {"type": "command", "command": "/path/to/on-stop-matrix.sh"}
# Exit early if Matrix thread not configured
if [ -z "${MATRIX_THREAD_ID:-}" ] || [ -z "${MATRIX_TOKEN:-}" ] \
|| [ -z "${MATRIX_HOMESERVER:-}" ] || [ -z "${MATRIX_ROOM_ID:-}" ]; then
cat > /dev/null
exit 0
fi
input=$(cat)
# Extract response text from hook JSON
response=$(printf '%s' "$input" | jq -r '.last_assistant_message // empty' 2>/dev/null)
[ -z "$response" ] && exit 0
# Truncate long output for readability (keep to ~4000 chars)
MAX_LEN=4000
if [ "${#response}" -gt "$MAX_LEN" ]; then
response="${response:0:$MAX_LEN}
... [truncated]"
fi
# Post to Matrix thread
room_encoded="${MATRIX_ROOM_ID//!/%21}"
txn="$(date +%s%N)$$"
body=$(jq -nc \
--arg m "$response" \
--arg t "$MATRIX_THREAD_ID" \
'{msgtype:"m.text",body:$m,"m.relates_to":{rel_type:"m.thread",event_id:$t}}')
curl -s -X PUT \
-H "Authorization: Bearer ${MATRIX_TOKEN}" \
-H "Content-Type: application/json" \
"${MATRIX_HOMESERVER}/_matrix/client/v3/rooms/${room_encoded}/send/m.room.message/${txn}" \
-d "$body" > /dev/null 2>&1 || true