fix: Remove Matrix integration — notifications move to forge + OpenClaw (#732)

Remove all Matrix/Dendrite infrastructure:
- Delete lib/matrix_listener.sh (long-poll daemon), lib/matrix_listener.service
  (systemd unit), lib/hooks/on-stop-matrix.sh (response streaming hook)
- Remove matrix_send() and matrix_send_ctx() from lib/env.sh
- Remove MATRIX_HOMESERVER auto-detection, MATRIX_THREAD_MAP from lib/env.sh
- Remove [matrix] section parsing from lib/load-project.sh
- Remove Matrix hook installation from lib/agent-session.sh
- Remove notify/notify_ctx helpers and Matrix thread tracking from
  dev/dev-agent.sh and action/action-agent.sh
- Remove all matrix_send calls from dev-poll.sh, phase-handler.sh,
  action-poll.sh, vault-poll.sh, vault-fire.sh, vault-reject.sh,
  review-poll.sh, review-pr.sh, supervisor-poll.sh, formula-session.sh
- Remove Matrix listener startup from docker/agents/entrypoint.sh
- Remove append_dendrite_compose() and setup_matrix() from bin/disinto
- Remove --matrix flag from disinto init
- Clean Matrix references from .env.example, projects/*.toml.example,
  formulas/*.toml, AGENTS.md, BOOTSTRAP.md, README.md, RESOURCES.md,
  PHASE-PROTOCOL.md, and all agent AGENTS.md/PROMPT.md files

Status visibility now via Codeberg PR/issue activity. Human interaction
via vault items through forge. Proactive alerts via OpenClaw heartbeats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-26 14:53:56 +00:00
parent 7996bb6c06
commit 23949083c0
43 changed files with 73 additions and 1157 deletions

View file

@ -6,12 +6,11 @@ sourced as needed.
| File | What it provides | Sourced by |
|---|---|---|
| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`), `woodpecker_api()`, `wpdb()`, `matrix_send()`, `matrix_send_ctx()`. Auto-loads project TOML if `PROJECT_TOML` is set. Auto-detects `MATRIX_HOMESERVER`: defaults to `http://dendrite:8008` inside a container (`DISINTO_CONTAINER=1`) or `http://localhost:8008` on bare metal; can be overridden via `.env`. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. | Every agent |
| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`), `woodpecker_api()`, `wpdb()`. Auto-loads project TOML if `PROJECT_TOML` is set. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. | Every agent |
| `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \<reason>" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status <sha>` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number <sha>` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. | dev-poll, review-poll, review-pr, supervisor-poll |
| `lib/ci-debug.sh` | CLI tool for Woodpecker CI: `list`, `status`, `logs`, `failures` subcommands. Not sourced — run directly. | Humans / dev-agent (tool access) |
| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `FORGE_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`, `FORGE_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, mirror 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` / `blocked by #N` patterns. Inline scan skips fenced code blocks to prevent false positives from code examples in issue bodies. 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 tmux session injection (dev, action, vault, review) or well-known files (`/tmp/{agent}-escalation-reply` for supervisor/gardener). Handles all agent reply routing. Uses `nohup` for robustness and validates TOML path before passing to exec-inject.sh. In compose mode, started as a background process by `docker/agents/entrypoint.sh`; on bare metal, run as systemd service (see `matrix_listener.service`). | Standalone daemon |
| `lib/formula-session.sh` | `acquire_cron_lock()`, `check_memory()`, `load_formula()`, `build_context_block()`, `consume_escalation_reply()`, `start_formula_session()`, `formula_phase_callback()`, `build_prompt_footer()`, `build_graph_section()`, `run_formula_and_monitor(AGENT [TIMEOUT] [CALLBACK])` — shared helpers for formula-driven cron agents (lock, memory guard, formula loading, prompt assembly, tmux session, monitor loop, crash recovery). `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `formula_phase_callback()` handles `PHASE:escalate` (unified escalation path — kills the session; callers may follow up via Matrix). `run_formula_and_monitor` accepts an optional CALLBACK (default: `formula_phase_callback`) so callers can install custom merge-through or escalation handlers. | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh, action-agent.sh |
| `lib/guard.sh` | `check_active(agent_name)` — reads `$FACTORY_ROOT/state/.{agent_name}-active`; exits 0 (skip) if the file is absent. Factory is off by default — state files must be created to enable each agent. **Logs a message to stderr** when skipping (`[check_active] SKIP: state file not found`), so agent dropout is visible in cron logs. Sourced by dev-poll.sh, review-poll.sh, action-poll.sh, predictor-run.sh, supervisor-run.sh. | cron entry points |
| `lib/mirrors.sh` | `mirror_push()` — pushes `$PRIMARY_BRANCH` + tags to all configured mirror remotes (fire-and-forget background pushes). Reads `MIRROR_NAMES` and `MIRROR_*` vars exported by `load-project.sh` from the `[mirrors]` TOML section. Failures are logged but never block the pipeline. Sourced by dev-poll.sh and dev/phase-handler.sh — called after every successful merge. | dev-poll.sh, phase-handler.sh |
@ -19,4 +18,4 @@ sourced as needed.
| `lib/secret-scan.sh` | `scan_for_secrets()` — detects potential secrets (API keys, bearer tokens, private keys, URLs with embedded credentials) in text; returns 1 if secrets found. `redact_secrets()` — replaces detected secret patterns with `[REDACTED]`. | file-action-issue.sh, phase-handler.sh |
| `lib/file-action-issue.sh` | `file_action_issue()` — dedup check, secret scan, label lookup, and issue creation for formula-driven cron wrappers. Sets `FILED_ISSUE_NUM` on success. Returns 4 if secrets detected in body. | (available for future use) |
| `lib/tea-helpers.sh` | `tea_file_issue(title, body, labels...)` — create issue via tea CLI with secret scanning; sets `FILED_ISSUE_NUM`. `tea_relabel(issue_num, labels...)` — replace labels using tea's `edit` subcommand (not `label`). `tea_comment(issue_num, body)` — add comment with secret scanning. `tea_close(issue_num)` — close issue. All use `TEA_LOGIN` and `FORGE_REPO` from env.sh. Labels by name (no ID lookup). Tea binary download verified via sha256 checksum. Sourced by env.sh when `tea` binary is available. | env.sh (conditional) |
| `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()`, `write_compact_context()`. `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. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `MATRIX_THREAD_ID` is exported, also installs a Stop hook (`on-stop-matrix.sh`) that streams each Claude response to the Matrix thread. When `phase_file` is set, passes it to the idle stop hook (`on-idle-stop.sh`) so the hook can **nudge Claude** (up to 2 times) if Claude returns to the prompt without writing to the phase file — the hook injects a tmux reminder asking Claude to signal PHASE:done or PHASE:awaiting_ci. The PreToolUse guard hook (`on-pretooluse-guard.sh`) receives the session name as a third argument — formula agents (`gardener-*`, `planner-*`, `predictor-*`, `supervisor-*`) are identified this way and allowed to access `FACTORY_ROOT` from worktrees (they need env.sh, AGENTS.md, formulas/, lib/). **OAuth flock**: when `DISINTO_CONTAINER=1`, Claude CLI is wrapped in `flock -w 300 ~/.claude/session.lock` to queue concurrent token refresh attempts and prevent rotation races across agents sharing the same credentials. `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 `PHASE:escalate` / other `PHASE:*` string. **Unified escalation**: `PHASE:escalate` is the signal that a session needs human input (renamed from `PHASE:needs_human`). **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, action-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()`, `write_compact_context()`. `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. Also installs a StopFailure hook (matcher `rate_limit\|server_error\|authentication_failed\|billing_error`) that writes `PHASE:failed` with an `api_error` reason to the phase file and touches the phase-changed marker, so the orchestrator discovers API errors within one poll cycle instead of waiting for idle timeout. Also installs a SessionStart hook (matcher `compact`) that re-injects phase protocol instructions after context compaction — callers write the context file via `write_compact_context(phase_file, content)`, and the hook (`on-compact-reinject.sh`) outputs the file content to stdout so Claude retains critical instructions. When `phase_file` is set, passes it to the idle stop hook (`on-idle-stop.sh`) so the hook can **nudge Claude** (up to 2 times) if Claude returns to the prompt without writing to the phase file — the hook injects a tmux reminder asking Claude to signal PHASE:done or PHASE:awaiting_ci. The PreToolUse guard hook (`on-pretooluse-guard.sh`) receives the session name as a third argument — formula agents (`gardener-*`, `planner-*`, `predictor-*`, `supervisor-*`) are identified this way and allowed to access `FACTORY_ROOT` from worktrees (they need env.sh, AGENTS.md, formulas/, lib/). **OAuth flock**: when `DISINTO_CONTAINER=1`, Claude CLI is wrapped in `flock -w 300 ~/.claude/session.lock` to queue concurrent token refresh attempts and prevent rotation races across agents sharing the same credentials. `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 `PHASE:escalate` / other `PHASE:*` string. **Unified escalation**: `PHASE:escalate` is the signal that a session needs human input (renamed from `PHASE:needs_human`). **Callers must handle `idle_prompt`** in both their callback and their post-loop exit handler — see [`docs/PHASE-PROTOCOL.md` idle_prompt](docs/PHASE-PROTOCOL.md#idle_prompt-exit-reason) for the full contract. | dev-agent.sh, action-agent.sh |

View file

@ -290,32 +290,6 @@ 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"
local model_flag=""
if [ -n "${CLAUDE_MODEL:-}" ]; then

View file

@ -85,18 +85,6 @@ export CLAUDE_TIMEOUT="${CLAUDE_TIMEOUT:-7200}"
# Factory processes must never phone home or auto-update mid-session (#725).
export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
# Matrix homeserver: inside compose Dendrite is at http://dendrite:8008,
# on bare metal it defaults to http://localhost:8008.
if [ -z "${MATRIX_HOMESERVER:-}" ]; then
if [ "${DISINTO_CONTAINER:-}" = "1" ]; then
export MATRIX_HOMESERVER="http://dendrite:8008"
else
export MATRIX_HOMESERVER="http://localhost:8008"
fi
else
export MATRIX_HOMESERVER
fi
# Shared log helper
log() {
printf '[%s] %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*"
@ -158,80 +146,6 @@ wpdb() {
-t "$@" 2>/dev/null
}
# Matrix messaging helper — usage: matrix_send <prefix> <message> [thread_event_id] [context_tag]
# Returns event_id on stdout. Registers threads for listener dispatch.
# context_tag is stored in the thread map (e.g. issue number) for routing replies.
# Thread map: use persistent data dir inside container, /tmp on bare metal
if [ "${DISINTO_CONTAINER:-}" = "1" ]; then
MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-${DISINTO_DATA_DIR}/matrix-thread-map}"
else
MATRIX_THREAD_MAP="${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}"
fi
matrix_send() {
[ -z "${MATRIX_TOKEN:-}" ] && return 0
local prefix="$1" msg="$2" thread_id="${3:-}" ctx_tag="${4:-}"
local room_encoded="${MATRIX_ROOM_ID//!/%21}"
local txn
txn="$(date +%s%N)$$"
local body
if [ -n "$thread_id" ]; then
body=$(jq -nc --arg m "[${prefix}] ${msg}" --arg t "$thread_id" \
'{msgtype:"m.text",body:$m,"m.relates_to":{rel_type:"m.thread",event_id:$t}}')
else
body=$(jq -nc --arg m "[${prefix}] ${msg}" '{msgtype:"m.text",body:$m}')
fi
local response
response=$(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" 2>/dev/null) || return 0
local event_id
event_id=$(printf '%s' "$response" | jq -r '.event_id // empty' 2>/dev/null)
if [ -n "$event_id" ]; then
printf '%s' "$event_id"
# Register thread root for listener dispatch (escalations only)
if [ -z "$thread_id" ]; then
printf '%s\t%s\t%s\t%s\t%s\n' "$event_id" "$prefix" "$(date +%s)" "${ctx_tag}" "${PROJECT_NAME:-}" >> "$MATRIX_THREAD_MAP" 2>/dev/null || true
fi
fi
}
# matrix_send_ctx — Send rich Matrix message with HTML formatting
# Usage: matrix_send_ctx <prefix> <plain_text> <html_body> [thread_event_id]
# Use for notifications that benefit from links, code blocks, or structured content.
matrix_send_ctx() {
[ -z "${MATRIX_TOKEN:-}" ] && return 0
local prefix="$1" plain="$2" html="$3" thread_id="${4:-}"
local room_encoded="${MATRIX_ROOM_ID//!/%21}"
local txn
txn="$(date +%s%N)$$"
local body
if [ -n "$thread_id" ]; then
body=$(jq -nc \
--arg m "[${prefix}] ${plain}" \
--arg h "<b>[${prefix}]</b> ${html}" \
--arg t "$thread_id" \
'{msgtype:"m.text",body:$m,format:"org.matrix.custom.html",formatted_body:$h,"m.relates_to":{rel_type:"m.thread",event_id:$t}}')
else
body=$(jq -nc \
--arg m "[${prefix}] ${plain}" \
--arg h "<b>[${prefix}]</b> ${html}" \
'{msgtype:"m.text",body:$m,format:"org.matrix.custom.html",formatted_body:$h}')
fi
local response
response=$(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" 2>/dev/null) || return 0
local event_id
event_id=$(printf '%s' "$response" | jq -r '.event_id // empty' 2>/dev/null)
if [ -n "$event_id" ]; then
printf '%s' "$event_id"
fi
}
# Source tea helpers (available when tea binary is installed)
if command -v tea &>/dev/null; then
# shellcheck source=tea-helpers.sh

View file

@ -327,7 +327,6 @@ run_formula_and_monitor() {
agent_inject_into_session "$SESSION_NAME" "$PROMPT"
log "Prompt sent to tmux session"
matrix_send "$agent_name" "${agent_name^} session started for ${FORGE_REPO}" 2>/dev/null || true
log "Monitoring phase file: ${PHASE_FILE}"
_FORMULA_CRASH_COUNT=0
@ -351,8 +350,6 @@ run_formula_and_monitor() {
esac
fi
matrix_send "$agent_name" "${agent_name^} session finished (${FINAL_PHASE:-no phase})" 2>/dev/null || true
# Preserve worktree on crash for debugging; clean up on success
if [ "${_MONITOR_LOOP_EXIT:-}" = "crashed" ]; then
log "PRESERVED crashed worktree for debugging: ${_FORMULA_SESSION_WORKDIR:-}"

View file

@ -1,45 +0,0 @@
#!/usr/bin/env 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

View file

@ -58,17 +58,6 @@ svc = cfg.get('services', {})
if 'containers' in svc:
emit('PROJECT_CONTAINERS', svc['containers'])
# [matrix] section
mx = cfg.get('matrix', {})
if 'room_id' in mx:
emit('MATRIX_ROOM_ID', mx['room_id'])
if 'bot_user' in mx:
emit('MATRIX_BOT_USER', mx['bot_user'])
if 'token_env' in mx:
emit('MATRIX_TOKEN_ENV', mx['token_env'])
if 'mention_user' in mx:
emit('MATRIX_MENTION_USER', mx['mention_user'])
# [monitoring] section
mon = cfg.get('monitoring', {})
for key in ['check_prs', 'check_dev_agent', 'check_pipeline_stall']:
@ -110,10 +99,4 @@ if [ -z "${PROJECT_REPO_ROOT:-}" ] && [ -n "${PROJECT_NAME:-}" ]; then
export PROJECT_REPO_ROOT="/home/${USER}/${PROJECT_NAME}"
fi
# Resolve MATRIX_TOKEN from env var name (token_env points to an env var, not the token itself)
if [ -n "${MATRIX_TOKEN_ENV:-}" ]; then
export MATRIX_TOKEN="${!MATRIX_TOKEN_ENV:-}"
unset MATRIX_TOKEN_ENV
fi
unset _PROJECT_TOML _PROJECT_VARS _key _val

View file

@ -1,17 +0,0 @@
# Legacy systemd unit for bare-metal deployments (disinto init --bare).
# In compose mode, the matrix listener runs inside the agent container
# as a background process — see docker/agents/entrypoint.sh.
[Unit]
Description=Disinto Matrix Listener
After=network.target dendrite.service
[Service]
Type=simple
ExecStart=/home/admin/disinto/lib/matrix_listener.sh
Restart=always
RestartSec=10
User=admin
WorkingDirectory=/home/admin/disinto
[Install]
WantedBy=multi-user.target

View file

@ -1,350 +0,0 @@
#!/usr/bin/env bash
# matrix_listener.sh — Long-poll Matrix sync daemon
#
# Listens for replies in the Matrix coordination room and dispatches them
# to the appropriate agent via well-known files.
#
# Dispatch:
# Thread reply to [supervisor] message → /tmp/supervisor-escalation-reply
# Thread reply to [gardener] message → /tmp/gardener-escalation-reply
# Thread reply to [dev] message → injected into dev tmux session (or /tmp/dev-escalation-reply)
# Thread reply to [review] message → injected into review tmux session
# Thread reply to [vault] message → APPROVE/REJECT dispatched via vault-fire/vault-reject
# Thread reply to [action] message → injected into action tmux session
#
# In compose mode, started by docker/agents/entrypoint.sh as a background process.
# On bare metal, run as systemd service (see matrix_listener.service) or manually:
# ./matrix_listener.sh
set -euo pipefail
# Load shared environment
source "$(dirname "$0")/../lib/env.sh"
# Pidfile guard — prevent duplicate listener processes.
# Inside a container the PID file from a previous run is stale (container
# restart resets the PID namespace), so we only honour it when the recorded
# PID is still alive.
PIDFILE="/tmp/matrix-listener.pid"
if [ -f "$PIDFILE" ]; then
OLD_PID=$(cat "$PIDFILE" 2>/dev/null || true)
if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then
echo "Listener already running (PID $OLD_PID)" >&2
exit 0
fi
# Stale pidfile (previous container run or crashed process) — remove it
rm -f "$PIDFILE"
fi
echo $$ > "$PIDFILE"
trap 'rm -f "$PIDFILE"' EXIT
SINCE_FILE="/tmp/matrix-listener-since"
THREAD_MAP="${MATRIX_THREAD_MAP:-/tmp/matrix-thread-map}"
ACKED_FILE="/tmp/matrix-listener-acked"
LOGFILE="${FACTORY_ROOT}/supervisor/matrix-listener.log"
SYNC_TIMEOUT=30000 # 30s long-poll
BACKOFF=5
MAX_BACKOFF=60
log() {
printf '[%s] listener: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
}
# Validate Matrix config
if [ -z "${MATRIX_TOKEN:-}" ] || [ -z "${MATRIX_ROOM_ID:-}" ]; then
echo "MATRIX_TOKEN and MATRIX_ROOM_ID must be set in .env" >&2
exit 1
fi
# Build sync filter — only our room, only messages
FILTER=$(jq -nc --arg room "$MATRIX_ROOM_ID" '{
room: {
rooms: [$room],
timeline: {types: ["m.room.message"], limit: 20},
state: {types: []},
ephemeral: {types: []}
},
presence: {types: []}
}')
# Load previous sync token
SINCE=""
if [ -f "$SINCE_FILE" ]; then
SINCE=$(cat "$SINCE_FILE" 2>/dev/null || true)
fi
log "started (since=${SINCE:-initial})"
# Do an initial sync without timeout to catch up, then switch to long-poll
INITIAL=true
while true; do
# Build sync URL
SYNC_URL="${MATRIX_HOMESERVER}/_matrix/client/v3/sync?filter=$(jq -rn --arg f "$FILTER" '$f | @uri')&timeout=${SYNC_TIMEOUT}"
if [ -n "$SINCE" ]; then
SYNC_URL="${SYNC_URL}&since=${SINCE}"
fi
if [ "$INITIAL" = true ]; then
# First sync: no timeout, just catch up
SYNC_URL="${MATRIX_HOMESERVER}/_matrix/client/v3/sync?filter=$(jq -rn --arg f "$FILTER" '$f | @uri')"
[ -n "$SINCE" ] && SYNC_URL="${SYNC_URL}&since=${SINCE}"
INITIAL=false
fi
# Long-poll
RESPONSE=$(curl -s --max-time $((SYNC_TIMEOUT / 1000 + 30)) \
-H "Authorization: Bearer ${MATRIX_TOKEN}" \
"$SYNC_URL" 2>/dev/null) || {
log "sync failed, backing off ${BACKOFF}s"
sleep "$BACKOFF"
BACKOFF=$((BACKOFF * 2 > MAX_BACKOFF ? MAX_BACKOFF : BACKOFF * 2))
continue
}
# Reset backoff on success
BACKOFF=5
# Extract next_batch
NEXT_BATCH=$(printf '%s' "$RESPONSE" | jq -r '.next_batch // empty' 2>/dev/null)
if [ -z "$NEXT_BATCH" ]; then
log "no next_batch in response"
sleep 5
continue
fi
# Save cursor
printf '%s' "$NEXT_BATCH" > "$SINCE_FILE"
SINCE="$NEXT_BATCH"
# Extract timeline events from our room
EVENTS=$(printf '%s' "$RESPONSE" | jq -c --arg room "$MATRIX_ROOM_ID" '
.rooms.join[$room].timeline.events[]? |
select(.type == "m.room.message") |
select(.sender != "'"${MATRIX_BOT_USER}"'")
' 2>/dev/null) || continue
[ -z "$EVENTS" ] && continue
while IFS= read -r event; do
SENDER=$(printf '%s' "$event" | jq -r '.sender')
BODY=$(printf '%s' "$event" | jq -r '.content.body // ""')
# Check if this is a thread reply
THREAD_ROOT=$(printf '%s' "$event" | jq -r '.content."m.relates_to" | select(.rel_type == "m.thread") | .event_id // empty' 2>/dev/null)
if [ -z "$THREAD_ROOT" ] || [ -z "$BODY" ]; then
continue
fi
# Look up thread root in our mapping
if [ ! -f "$THREAD_MAP" ]; then
continue
fi
AGENT=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $2}' "$THREAD_MAP" 2>/dev/null)
if [ -z "$AGENT" ]; then
log "reply to unknown thread ${THREAD_ROOT:0:20} from ${SENDER}"
continue
fi
log "reply from ${SENDER} to [${AGENT}] thread: ${BODY:0:100}"
case "$AGENT" in
supervisor)
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/supervisor-escalation-reply
# Acknowledge
matrix_send "supervisor" "✓ received, will act on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true
;;
gardener)
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/gardener-escalation-reply
matrix_send "gardener" "✓ received, will act on next poll" "$THREAD_ROOT" >/dev/null 2>&1 || true
;;
dev)
# Route reply into the dev tmux session using context_tag (issue number)
# Thread map columns: 1=thread_id, 2=agent, 3=timestamp, 4=issue, 5=project
DEV_ISSUE=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $4}' "$THREAD_MAP" 2>/dev/null || true)
DEV_PROJECT=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $5}' "$THREAD_MAP" 2>/dev/null || true)
DEV_INJECTED=false
if [ -n "$DEV_ISSUE" ]; then
DEV_SESSION="dev-${DEV_PROJECT}-${DEV_ISSUE}"
DEV_PHASE_FILE="/tmp/dev-session-${DEV_PROJECT}-${DEV_ISSUE}.phase"
if tmux has-session -t "$DEV_SESSION" 2>/dev/null; then
DEV_CUR_PHASE=$(head -1 "$DEV_PHASE_FILE" 2>/dev/null | tr -d '[:space:]' || true)
if [ "$DEV_CUR_PHASE" = "PHASE:escalate" ] || [ "$DEV_CUR_PHASE" = "PHASE:awaiting_review" ]; then
DEV_INJECT_MSG="Human guidance from ${SENDER} in Matrix:
${BODY}
Consider this guidance for your current work."
DEV_INJECT_TMP=$(mktemp /tmp/dev-q-inject-XXXXXX)
printf '%s' "$DEV_INJECT_MSG" > "$DEV_INJECT_TMP"
tmux load-buffer -b "dev-q-${DEV_ISSUE}" "$DEV_INJECT_TMP" || true
tmux paste-buffer -t "$DEV_SESSION" -b "dev-q-${DEV_ISSUE}" || true
sleep 0.5
tmux send-keys -t "$DEV_SESSION" "" Enter || true
tmux delete-buffer -b "dev-q-${DEV_ISSUE}" 2>/dev/null || true
rm -f "$DEV_INJECT_TMP"
DEV_INJECTED=true
log "human guidance from ${SENDER} injected into ${DEV_SESSION}"
# Reply on first successful injection only — no reply on subsequent ones
if ! grep -qF "$THREAD_ROOT" "$ACKED_FILE" 2>/dev/null; then
matrix_send "dev" "✓ Guidance forwarded to dev session for #${DEV_ISSUE}" "$THREAD_ROOT" >/dev/null 2>&1 || true
printf '%s\n' "$THREAD_ROOT" >> "$ACKED_FILE"
fi
else
log "WARN: dev session '${DEV_SESSION}' busy (phase: ${DEV_CUR_PHASE:-active}), queuing message for issue #${DEV_ISSUE}"
matrix_send "dev" "❌ Could not inject: dev session for #${DEV_ISSUE} is busy (phase: ${DEV_CUR_PHASE:-active}), message queued" "$THREAD_ROOT" >/dev/null 2>&1 || true
fi
else
log "WARN: tmux session '${DEV_SESSION}' not found for issue #${DEV_ISSUE} (project: ${DEV_PROJECT:-UNSET})"
matrix_send "dev" "❌ Could not inject: tmux session '${DEV_SESSION}' not found (project: ${DEV_PROJECT:-UNSET})" "$THREAD_ROOT" >/dev/null 2>&1 || true
fi
else
log "dev thread ${THREAD_ROOT:0:20} has no issue mapping"
matrix_send "dev" "❌ Could not inject: no issue mapping for this thread" "$THREAD_ROOT" >/dev/null 2>&1 || true
fi
# Only write to flat file when direct injection didn't happen,
# to avoid supervisor/gardener poll re-injecting the same message.
if [ "$DEV_INJECTED" = false ]; then
printf '%s\t%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$SENDER" "$BODY" >> /tmp/dev-escalation-reply
fi
;;
review)
# Route human questions to persistent review tmux session
# Thread map columns: 1=thread_id, 2=agent, 3=timestamp, 4=pr_num, 5=project
REVIEW_PR_NUM=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $4}' "$THREAD_MAP" 2>/dev/null || true)
REVIEW_PROJECT=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $5}' "$THREAD_MAP" 2>/dev/null || true)
if [ -n "$REVIEW_PR_NUM" ]; then
REVIEW_SESSION="review-${REVIEW_PROJECT}-${REVIEW_PR_NUM}"
REVIEW_PHASE_FILE="/tmp/review-session-${REVIEW_PROJECT}-${REVIEW_PR_NUM}.phase"
if tmux has-session -t "$REVIEW_SESSION" 2>/dev/null; then
# Skip injection if Claude is mid-review (phase file absent = actively writing)
REVIEW_CUR_PHASE=$(head -1 "$REVIEW_PHASE_FILE" 2>/dev/null | tr -d '[:space:]' || true)
if [ -z "$REVIEW_CUR_PHASE" ]; then
log "WARN: review session '${REVIEW_SESSION}' is mid-review, deferring question for PR #${REVIEW_PR_NUM}"
matrix_send "review" "❌ Could not inject: reviewer is mid-review for PR #${REVIEW_PR_NUM}, try again shortly" "$THREAD_ROOT" >/dev/null 2>&1 || true
else
REVIEW_INJECT_MSG="Human question from ${SENDER} in Matrix:
${BODY}
Please answer this question about your review. Explain your reasoning."
REVIEW_INJECT_TMP=$(mktemp /tmp/review-q-inject-XXXXXX)
printf '%s' "$REVIEW_INJECT_MSG" > "$REVIEW_INJECT_TMP"
tmux load-buffer -b "review-q-${REVIEW_PR_NUM}" "$REVIEW_INJECT_TMP" || true
tmux paste-buffer -t "$REVIEW_SESSION" -b "review-q-${REVIEW_PR_NUM}" || true
sleep 0.5
tmux send-keys -t "$REVIEW_SESSION" "" Enter || true
tmux delete-buffer -b "review-q-${REVIEW_PR_NUM}" 2>/dev/null || true
rm -f "$REVIEW_INJECT_TMP"
log "review question from ${SENDER} injected into ${REVIEW_SESSION}"
# Reply on first successful injection only
if ! grep -qF "$THREAD_ROOT" "$ACKED_FILE" 2>/dev/null; then
matrix_send "review" "✓ Question forwarded to reviewer session for PR #${REVIEW_PR_NUM}" "$THREAD_ROOT" >/dev/null 2>&1 || true
printf '%s\n' "$THREAD_ROOT" >> "$ACKED_FILE"
fi
fi
else
log "WARN: tmux session '${REVIEW_SESSION}' not found for PR #${REVIEW_PR_NUM} (project: ${REVIEW_PROJECT:-UNSET})"
matrix_send "review" "❌ Could not inject: tmux session '${REVIEW_SESSION}' not found (project: ${REVIEW_PROJECT:-UNSET})" "$THREAD_ROOT" >/dev/null 2>&1 || true
fi
else
log "review thread ${THREAD_ROOT:0:20} has no PR mapping"
matrix_send "review" "❌ Could not inject: no PR mapping for this thread" "$THREAD_ROOT" >/dev/null 2>&1 || true
fi
;;
action)
# Route reply into the action tmux session using context_tag (issue number)
ACTION_ISSUE=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $4}' "$THREAD_MAP" 2>/dev/null || true)
ACTION_PROJECT=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $5}' "$THREAD_MAP" 2>/dev/null || true)
if [ -n "$ACTION_ISSUE" ]; then
ACTION_SESSION="action-${ACTION_PROJECT}-${ACTION_ISSUE}"
if tmux has-session -t "$ACTION_SESSION" 2>/dev/null; then
ACTION_INJECT_MSG="Human reply from ${SENDER} in Matrix:
${BODY}
Continue with the action formula based on this response."
ACTION_INJECT_TMP=$(mktemp /tmp/action-q-inject-XXXXXX)
printf '%s' "$ACTION_INJECT_MSG" > "$ACTION_INJECT_TMP"
tmux load-buffer -b "action-q-${ACTION_ISSUE}" "$ACTION_INJECT_TMP" || true
tmux paste-buffer -t "$ACTION_SESSION" -b "action-q-${ACTION_ISSUE}" || true
sleep 0.5
tmux send-keys -t "$ACTION_SESSION" "" Enter || true
tmux delete-buffer -b "action-q-${ACTION_ISSUE}" 2>/dev/null || true
rm -f "$ACTION_INJECT_TMP"
log "human reply from ${SENDER} injected into ${ACTION_SESSION}"
# Reply on first successful injection only
if ! grep -qF "$THREAD_ROOT" "$ACKED_FILE" 2>/dev/null; then
matrix_send "action" "✓ Reply forwarded to action session for issue #${ACTION_ISSUE}" "$THREAD_ROOT" >/dev/null 2>&1 || true
printf '%s\n' "$THREAD_ROOT" >> "$ACKED_FILE"
fi
else
log "WARN: tmux session '${ACTION_SESSION}' not found for issue #${ACTION_ISSUE}"
matrix_send "action" "❌ Could not inject: tmux session '${ACTION_SESSION}' not found" "$THREAD_ROOT" >/dev/null 2>&1 || true
fi
else
log "action thread ${THREAD_ROOT:0:20} has no issue mapping"
matrix_send "action" "❌ Could not inject: no issue mapping for this thread" "$THREAD_ROOT" >/dev/null 2>&1 || true
fi
;;
vault)
# Route reply to vault tmux session if one exists (unified escalation path)
VAULT_ISSUE=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $4}' "$THREAD_MAP" 2>/dev/null || true)
VAULT_PROJECT=$(awk -F'\t' -v id="$THREAD_ROOT" '$1 == id {print $5}' "$THREAD_MAP" 2>/dev/null || true)
VAULT_INJECTED=false
if [ -n "$VAULT_ISSUE" ]; then
VAULT_SESSION="vault-${VAULT_PROJECT:-default}-${VAULT_ISSUE}"
if tmux has-session -t "$VAULT_SESSION" 2>/dev/null; then
VAULT_INJECT_MSG="Human reply from ${SENDER} in Matrix:
${BODY}
Interpret this response and decide how to proceed."
VAULT_INJECT_TMP=$(mktemp /tmp/vault-q-inject-XXXXXX)
printf '%s' "$VAULT_INJECT_MSG" > "$VAULT_INJECT_TMP"
tmux load-buffer -b "vault-q-${VAULT_ISSUE}" "$VAULT_INJECT_TMP" || true
tmux paste-buffer -t "$VAULT_SESSION" -b "vault-q-${VAULT_ISSUE}" || true
sleep 0.5
tmux send-keys -t "$VAULT_SESSION" "" Enter || true
tmux delete-buffer -b "vault-q-${VAULT_ISSUE}" 2>/dev/null || true
rm -f "$VAULT_INJECT_TMP"
VAULT_INJECTED=true
log "human reply from ${SENDER} injected into ${VAULT_SESSION}"
if ! grep -qF "$THREAD_ROOT" "$ACKED_FILE" 2>/dev/null; then
matrix_send "vault" "✓ Reply forwarded to vault session" "$THREAD_ROOT" >/dev/null 2>&1 || true
printf '%s\n' "$THREAD_ROOT" >> "$ACKED_FILE"
fi
fi
fi
# Fallback: parse APPROVE/REJECT for non-session vault actions
if [ "$VAULT_INJECTED" = false ]; then
VAULT_CMD=$(echo "$BODY" | tr '[:lower:]' '[:upper:]' | grep -oP '^\s*(APPROVE|REJECT)\s+\S+' | head -1 || true)
if [ -n "$VAULT_CMD" ]; then
VAULT_ACTION=$(echo "$VAULT_CMD" | awk '{print $1}')
VAULT_ID=$(echo "$BODY" | awk '{print $2}') # preserve original case for ID
log "vault dispatch: $VAULT_ACTION $VAULT_ID"
VAULT_DIR="${FACTORY_ROOT}/vault"
if [ "$VAULT_ACTION" = "APPROVE" ]; then
if bash "${VAULT_DIR}/vault-fire.sh" "$VAULT_ID" >> "${VAULT_DIR}/vault.log" 2>&1; then
matrix_send "vault" "✓ approved and fired: ${VAULT_ID}" "$THREAD_ROOT" >/dev/null 2>&1 || true
else
matrix_send "vault" "✓ approved but fire failed — will retry: ${VAULT_ID}" "$THREAD_ROOT" >/dev/null 2>&1 || true
fi
else
bash "${VAULT_DIR}/vault-reject.sh" "$VAULT_ID" "rejected by ${SENDER}" >> "${VAULT_DIR}/vault.log" 2>&1 || true
matrix_send "vault" "✓ rejected: ${VAULT_ID}" "$THREAD_ROOT" >/dev/null 2>&1 || true
fi
else
log "vault: free-text reply (no session, no APPROVE/REJECT): ${BODY:0:100}"
matrix_send "vault" "⚠️ No active vault session. Reply with APPROVE <id> or REJECT <id>, or wait for a vault session to start." "$THREAD_ROOT" >/dev/null 2>&1 || true
fi
fi
;;
*)
log "no handler for agent '${AGENT}'"
;;
esac
done <<< "$EVENTS"
done