diff --git a/AGENTS.md b/AGENTS.md index 31d0868..053c4ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,11 +218,12 @@ issues labeled `action` that have no active tmux session, then spawns **Session lifecycle**: 1. `action-poll.sh` finds open `action` issues with no active tmux session. 2. Spawns `action-agent.sh `. -3. Agent creates tmux session `action-{issue_num}`, injects prompt (formula + prior comments). -4. Claude executes formula steps using Bash and other tools, posts progress as issue comments. -5. For human input: Claude sends a Matrix message and waits; the reply is injected into the session by `matrix_listener.sh`. -6. When complete: Claude closes the issue with a summary comment. Session exits. -7. Poll detects no active session on next run — nothing further to do. +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. Agent creates tmux session `action-{issue_num}`, injects prompt (formula + prior comments). +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. For human input: Claude sends a Matrix message and waits; the reply is injected into the session by `matrix_listener.sh`. +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**: - `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/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/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 | --- diff --git a/action/action-agent.sh b/action/action-agent.sh index 1793c21..f6e529b 100644 --- a/action/action-agent.sh +++ b/action/action-agent.sh @@ -96,6 +96,8 @@ THREAD_ID="" if [ -n "${_thread_id:-}" ]; then printf '%s' "$_thread_id" > "$THREAD_FILE" 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) 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 diff --git a/lib/agent-session.sh b/lib/agent-session.sh index 1d797fe..806e137 100644 --- a/lib/agent-session.sh +++ b/lib/agent-session.sh @@ -120,6 +120,32 @@ create_agent_session() { 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" tmux new-session -d -s "$session" -c "$workdir" \ "claude --dangerously-skip-permissions" 2>/dev/null diff --git a/lib/hooks/on-stop-matrix.sh b/lib/hooks/on-stop-matrix.sh new file mode 100755 index 0000000..c85276b --- /dev/null +++ b/lib/hooks/on-stop-matrix.sh @@ -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