disinto/lib/agent-session.sh
openhands 23949083c0 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>
2026-03-26 14:53:56 +00:00

486 lines
18 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# agent-session.sh — Shared tmux + Claude interactive session helpers
#
# Source this into agent orchestrator scripts for reusable session management.
#
# Functions:
# agent_wait_for_claude_ready SESSION_NAME [TIMEOUT_SECS]
# agent_inject_into_session SESSION_NAME TEXT
# agent_kill_session SESSION_NAME
# monitor_phase_loop PHASE_FILE IDLE_TIMEOUT_SECS CALLBACK_FN [SESSION_NAME]
# session_lock_acquire [TIMEOUT_SECS]
# session_lock_release
# --- Cooperative session lock (fd-based) ---
# File descriptor for the session lock. Set by create_agent_session().
# Callers can release/re-acquire via session_lock_release/session_lock_acquire
# to allow other Claude sessions during idle phases (awaiting_review/awaiting_ci).
SESSION_LOCK_FD=""
# Release the session lock without closing the file descriptor.
# The fd stays open so it can be re-acquired later.
session_lock_release() {
if [ -n "${SESSION_LOCK_FD:-}" ]; then
flock -u "$SESSION_LOCK_FD"
fi
}
# Re-acquire the session lock. Blocks until available or timeout.
# Opens the lock fd if not already open (for use by external callers).
# Args: [timeout_secs] (default 300)
# Returns 0 on success, 1 on timeout/error.
# shellcheck disable=SC2120 # timeout arg is used by external callers
session_lock_acquire() {
local timeout="${1:-300}"
if [ -z "${SESSION_LOCK_FD:-}" ]; then
local lock_dir="${HOME}/.claude"
mkdir -p "$lock_dir"
exec {SESSION_LOCK_FD}>>"${lock_dir}/session.lock"
fi
flock -w "$timeout" "$SESSION_LOCK_FD"
}
# Wait for the Claude ready prompt in a tmux pane.
# Returns 0 if ready within TIMEOUT_SECS (default 120), 1 otherwise.
agent_wait_for_claude_ready() {
local session="$1"
local timeout="${2:-120}"
local elapsed=0
while [ "$elapsed" -lt "$timeout" ]; do
if tmux capture-pane -t "$session" -p 2>/dev/null | grep -q ''; then
return 0
fi
sleep 2
elapsed=$((elapsed + 2))
done
return 1
}
# Paste TEXT into SESSION (waits for Claude to be ready first), then press Enter.
agent_inject_into_session() {
local session="$1"
local text="$2"
local tmpfile
# Re-acquire session lock before injecting — Claude will resume working
# shellcheck disable=SC2119 # using default timeout
session_lock_acquire || true
agent_wait_for_claude_ready "$session" 120 || true
# Clear idle marker — new work incoming
rm -f "/tmp/claude-idle-${session}.ts"
tmpfile=$(mktemp /tmp/agent-inject-XXXXXX)
printf '%s' "$text" > "$tmpfile"
tmux load-buffer -b "agent-inject-$$" "$tmpfile"
tmux paste-buffer -t "$session" -b "agent-inject-$$"
sleep 0.5
tmux send-keys -t "$session" "" Enter
tmux delete-buffer -b "agent-inject-$$" 2>/dev/null || true
rm -f "$tmpfile"
}
# Create a tmux session running Claude in the given workdir.
# Installs a Stop hook for idle detection (see monitor_phase_loop).
# Installs a PreToolUse hook to guard destructive Bash operations.
# Optionally installs a PostToolUse hook for phase file write detection.
# Optionally installs a StopFailure hook for immediate phase file update on API error.
# Args: session workdir [phase_file]
# Returns 0 if session is ready, 1 otherwise.
create_agent_session() {
local session="$1"
local workdir="${2:-.}"
local phase_file="${3:-}"
# Prepare settings directory for hooks
mkdir -p "${workdir}/.claude"
local settings="${workdir}/.claude/settings.json"
# Install Stop hook for idle detection: when Claude finishes a response,
# the hook writes a timestamp to a marker file. monitor_phase_loop checks
# this marker instead of fragile tmux pane scraping.
local idle_marker="/tmp/claude-idle-${session}.ts"
local hook_script="${FACTORY_ROOT}/lib/hooks/on-idle-stop.sh"
if [ -x "$hook_script" ]; then
local hook_cmd="${hook_script} ${idle_marker}"
# When a phase file is available, pass it and the session name so the
# hook can nudge Claude if it returns to the prompt without signalling.
if [ -n "$phase_file" ]; then
hook_cmd="${hook_script} ${idle_marker} ${phase_file} ${session}"
fi
if [ -f "$settings" ]; then
# Append our Stop hook to existing project settings
jq --arg cmd "$hook_cmd" '
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 "$hook_cmd" '{
hooks: {
Stop: [{
matcher: "",
hooks: [{type: "command", command: $cmd}]
}]
}
}' > "$settings"
fi
fi
# Install PostToolUse hook for phase file write detection: when Claude
# writes to the phase file via Bash or Write, the hook writes a marker
# so monitor_phase_loop can react immediately instead of waiting for
# the next mtime-based poll cycle.
if [ -n "$phase_file" ]; then
local phase_marker="/tmp/phase-changed-${session}.marker"
local phase_hook_script="${FACTORY_ROOT}/lib/hooks/on-phase-change.sh"
if [ -x "$phase_hook_script" ]; then
local phase_hook_cmd="${phase_hook_script} ${phase_file} ${phase_marker}"
if [ -f "$settings" ]; then
jq --arg cmd "$phase_hook_cmd" '
if (.hooks.PostToolUse // [] | any(.[]; .hooks[]?.command == $cmd))
then .
else .hooks.PostToolUse = (.hooks.PostToolUse // []) + [{
matcher: "Bash|Write",
hooks: [{type: "command", command: $cmd}]
}]
end
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
else
jq -n --arg cmd "$phase_hook_cmd" '{
hooks: {
PostToolUse: [{
matcher: "Bash|Write",
hooks: [{type: "command", command: $cmd}]
}]
}
}' > "$settings"
fi
rm -f "$phase_marker"
fi
fi
# Install StopFailure hook for immediate phase file update on API error:
# when Claude hits a rate limit, server error, billing error, or auth failure,
# the hook writes PHASE:failed to the phase file and touches the phase-changed
# marker so monitor_phase_loop picks it up within one poll cycle instead of
# waiting for idle timeout (up to 2 hours).
if [ -n "$phase_file" ]; then
local stop_failure_hook_script="${FACTORY_ROOT}/lib/hooks/on-stop-failure.sh"
if [ -x "$stop_failure_hook_script" ]; then
# phase_marker is defined in the PostToolUse block above; redeclare so
# this block is self-contained if that block is ever removed.
local sf_phase_marker="/tmp/phase-changed-${session}.marker"
local stop_failure_hook_cmd="${stop_failure_hook_script} ${phase_file} ${sf_phase_marker}"
if [ -f "$settings" ]; then
jq --arg cmd "$stop_failure_hook_cmd" '
if (.hooks.StopFailure // [] | any(.[]; .hooks[]?.command == $cmd))
then .
else .hooks.StopFailure = (.hooks.StopFailure // []) + [{
matcher: "rate_limit|server_error|authentication_failed|billing_error",
hooks: [{type: "command", command: $cmd}]
}]
end
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
else
jq -n --arg cmd "$stop_failure_hook_cmd" '{
hooks: {
StopFailure: [{
matcher: "rate_limit|server_error|authentication_failed|billing_error",
hooks: [{type: "command", command: $cmd}]
}]
}
}' > "$settings"
fi
fi
fi
# Install PreToolUse hook for destructive operation guard: blocks force push
# to primary branch, rm -rf outside worktree, direct API merge calls, and
# checkout/switch to primary branch. Claude sees the denial reason on exit 2
# and can self-correct.
local guard_hook_script="${FACTORY_ROOT}/lib/hooks/on-pretooluse-guard.sh"
if [ -x "$guard_hook_script" ]; then
local abs_workdir
abs_workdir=$(cd "$workdir" 2>/dev/null && pwd) || abs_workdir="$workdir"
local guard_hook_cmd="${guard_hook_script} ${PRIMARY_BRANCH:-main} ${abs_workdir} ${session}"
if [ -f "$settings" ]; then
jq --arg cmd "$guard_hook_cmd" '
if (.hooks.PreToolUse // [] | any(.[]; .hooks[]?.command == $cmd))
then .
else .hooks.PreToolUse = (.hooks.PreToolUse // []) + [{
matcher: "Bash",
hooks: [{type: "command", command: $cmd}]
}]
end
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
else
jq -n --arg cmd "$guard_hook_cmd" '{
hooks: {
PreToolUse: [{
matcher: "Bash",
hooks: [{type: "command", command: $cmd}]
}]
}
}' > "$settings"
fi
fi
# Install SessionEnd hook for guaranteed cleanup: when the Claude session
# exits (clean or crash), write a termination marker so monitor_phase_loop
# detects the exit faster than tmux has-session polling alone.
local exit_marker="/tmp/claude-exited-${session}.ts"
local session_end_hook_script="${FACTORY_ROOT}/lib/hooks/on-session-end.sh"
if [ -x "$session_end_hook_script" ]; then
local session_end_hook_cmd="${session_end_hook_script} ${exit_marker}"
if [ -f "$settings" ]; then
jq --arg cmd "$session_end_hook_cmd" '
if (.hooks.SessionEnd // [] | any(.[]; .hooks[]?.command == $cmd))
then .
else .hooks.SessionEnd = (.hooks.SessionEnd // []) + [{
matcher: "",
hooks: [{type: "command", command: $cmd}]
}]
end
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
else
jq -n --arg cmd "$session_end_hook_cmd" '{
hooks: {
SessionEnd: [{
matcher: "",
hooks: [{type: "command", command: $cmd}]
}]
}
}' > "$settings"
fi
fi
rm -f "$exit_marker"
# Install SessionStart hook for context re-injection after compaction:
# when Claude Code compacts context during long sessions, the phase protocol
# instructions are lost. This hook fires after each compaction and outputs
# the content of a context file so Claude retains critical instructions.
# The context file is written by callers via write_compact_context().
if [ -n "$phase_file" ]; then
local compact_hook_script="${FACTORY_ROOT}/lib/hooks/on-compact-reinject.sh"
if [ -x "$compact_hook_script" ]; then
local context_file="${phase_file%.phase}.context"
local compact_hook_cmd="${compact_hook_script} ${context_file}"
if [ -f "$settings" ]; then
jq --arg cmd "$compact_hook_cmd" '
if (.hooks.SessionStart // [] | any(.[]; .hooks[]?.command == $cmd))
then .
else .hooks.SessionStart = (.hooks.SessionStart // []) + [{
matcher: "compact",
hooks: [{type: "command", command: $cmd}]
}]
end
' "$settings" > "${settings}.tmp" && mv "${settings}.tmp" "$settings"
else
jq -n --arg cmd "$compact_hook_cmd" '{
hooks: {
SessionStart: [{
matcher: "compact",
hooks: [{type: "command", command: $cmd}]
}]
}
}' > "$settings"
fi
fi
fi
rm -f "$idle_marker"
local model_flag=""
if [ -n "${CLAUDE_MODEL:-}" ]; then
model_flag="--model ${CLAUDE_MODEL}"
fi
# Acquire a session-level mutex via fd-based flock to prevent concurrent
# Claude sessions from racing on OAuth token refresh. Unlike the previous
# command-wrapper flock, the fd approach allows callers to release the lock
# during idle phases (awaiting_review/awaiting_ci) and re-acquire before
# injecting the next prompt. See #724.
# Use ~/.claude/session.lock so the lock is shared across containers when
# the host ~/.claude directory is bind-mounted.
local lock_dir="${HOME}/.claude"
mkdir -p "$lock_dir"
local claude_lock="${lock_dir}/session.lock"
if [ -z "${SESSION_LOCK_FD:-}" ]; then
exec {SESSION_LOCK_FD}>>"${claude_lock}"
fi
if ! flock -w 300 "$SESSION_LOCK_FD"; then
return 1
fi
local claude_cmd="claude --dangerously-skip-permissions ${model_flag}"
tmux new-session -d -s "$session" -c "$workdir" \
"$claude_cmd" 2>/dev/null
sleep 1
tmux has-session -t "$session" 2>/dev/null || return 1
agent_wait_for_claude_ready "$session" 120 || return 1
return 0
}
# Inject a prompt/formula into a session (alias for agent_inject_into_session).
inject_formula() {
agent_inject_into_session "$@"
}
# Monitor a phase file, calling a callback on changes and handling idle timeout.
# Sets _MONITOR_LOOP_EXIT to the exit reason (idle_timeout, idle_prompt, done, crashed, PHASE:failed, PHASE:escalate).
# Sets _MONITOR_SESSION to the resolved session name (arg 4 or $SESSION_NAME).
# Callbacks should reference _MONITOR_SESSION instead of $SESSION_NAME directly.
# Args: phase_file idle_timeout_secs callback_fn [session_name]
# session_name — tmux session to health-check; falls back to $SESSION_NAME global
#
# Idle detection: uses a Stop hook marker file (written by lib/hooks/on-idle-stop.sh)
# to detect when Claude finishes responding without writing a phase signal.
# If the marker exists for 3 consecutive polls with no phase written, the session
# is killed and the callback invoked with "PHASE:failed".
monitor_phase_loop() {
local phase_file="$1"
local idle_timeout="$2"
local callback="$3"
local _session="${4:-${SESSION_NAME:-}}"
# Export resolved session name so callbacks can reference it regardless of
# which session was passed to monitor_phase_loop (analogous to _MONITOR_LOOP_EXIT).
export _MONITOR_SESSION="$_session"
local poll_interval="${PHASE_POLL_INTERVAL:-10}"
local last_mtime=0
local idle_elapsed=0
local idle_pane_count=0
while true; do
sleep "$poll_interval"
idle_elapsed=$(( idle_elapsed + poll_interval ))
# Session health check: SessionEnd hook marker provides fast detection,
# tmux has-session is the fallback for unclean exits (e.g. tmux crash).
local exit_marker="/tmp/claude-exited-${_session}.ts"
if [ -f "$exit_marker" ] || ! tmux has-session -t "${_session}" 2>/dev/null; then
local current_phase
current_phase=$(head -1 "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
case "$current_phase" in
PHASE:done|PHASE:failed|PHASE:merged|PHASE:escalate)
;; # terminal — fall through to phase handler
*)
# Call callback with "crashed" — let agent-specific code handle recovery
if type "${callback}" &>/dev/null; then
"$callback" "PHASE:crashed"
fi
# If callback didn't restart session, break
if ! tmux has-session -t "${_session}" 2>/dev/null; then
_MONITOR_LOOP_EXIT="crashed"
return 1
fi
idle_elapsed=0
idle_pane_count=0
continue
;;
esac
fi
# Check phase-changed marker from PostToolUse hook — if present, the hook
# detected a phase file write so we reset last_mtime to force processing
# this cycle instead of waiting for the next mtime change.
local phase_marker="/tmp/phase-changed-${_session}.marker"
if [ -f "$phase_marker" ]; then
rm -f "$phase_marker"
last_mtime=0
fi
# Check phase file for changes
local phase_mtime
phase_mtime=$(stat -c %Y "$phase_file" 2>/dev/null || echo 0)
local current_phase
current_phase=$(head -1 "$phase_file" 2>/dev/null | tr -d '[:space:]' || true)
if [ -z "$current_phase" ] || [ "$phase_mtime" -le "$last_mtime" ]; then
# No phase change — check idle timeout
if [ "$idle_elapsed" -ge "$idle_timeout" ]; then
_MONITOR_LOOP_EXIT="idle_timeout"
agent_kill_session "${_session}"
return 0
fi
# Idle detection via Stop hook: the on-idle-stop.sh hook writes a marker
# file when Claude finishes a response. If the marker exists and no phase
# has been written, Claude returned to the prompt without following the
# phase protocol. 3 consecutive polls = confirmed idle (not mid-turn).
local idle_marker="/tmp/claude-idle-${_session}.ts"
if [ -z "$current_phase" ] && [ -f "$idle_marker" ]; then
idle_pane_count=$(( idle_pane_count + 1 ))
if [ "$idle_pane_count" -ge 3 ]; then
_MONITOR_LOOP_EXIT="idle_prompt"
# Session is killed before the callback is invoked.
# Callbacks that handle PHASE:failed must not assume the session is alive.
agent_kill_session "${_session}"
if type "${callback}" &>/dev/null; then
"$callback" "PHASE:failed"
fi
return 0
fi
else
idle_pane_count=0
fi
continue
fi
# Phase changed
last_mtime="$phase_mtime"
# shellcheck disable=SC2034 # read by phase-handler.sh callback
LAST_PHASE_MTIME="$phase_mtime"
idle_elapsed=0
idle_pane_count=0
# Terminal phases
case "$current_phase" in
PHASE:done|PHASE:merged)
_MONITOR_LOOP_EXIT="done"
if type "${callback}" &>/dev/null; then
"$callback" "$current_phase"
fi
return 0
;;
PHASE:failed|PHASE:escalate)
_MONITOR_LOOP_EXIT="$current_phase"
if type "${callback}" &>/dev/null; then
"$callback" "$current_phase"
fi
return 0
;;
esac
# Non-terminal phase — call callback
if type "${callback}" &>/dev/null; then
"$callback" "$current_phase"
fi
done
}
# Write context to a file for re-injection after context compaction.
# The SessionStart compact hook reads this file and outputs it to stdout.
# Args: phase_file content
write_compact_context() {
local phase_file="$1"
local content="$2"
local context_file="${phase_file%.phase}.context"
printf '%s\n' "$content" > "$context_file"
}
# Kill a tmux session gracefully (no-op if not found).
agent_kill_session() {
local session="${1:-}"
[ -n "$session" ] && tmux kill-session -t "$session" 2>/dev/null || true
rm -f "/tmp/claude-idle-${session}.ts"
rm -f "/tmp/phase-changed-${session}.marker"
rm -f "/tmp/claude-exited-${session}.ts"
rm -f "/tmp/claude-nudge-${session}.count"
}
# Read the current phase from a phase file, stripped of whitespace.
# Usage: read_phase [file] — defaults to $PHASE_FILE
read_phase() {
local file="${1:-${PHASE_FILE:-}}"
{ cat "$file" 2>/dev/null || true; } | head -1 | tr -d '[:space:]'
}