disinto/lib/agent-session.sh
openhands 63d4179f38 fix: Deduplicate hook entries in settings.json on repeated create_agent_session calls (#299)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:19:58 +00:00

278 lines
9.8 KiB
Bash
Raw 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]
# 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
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).
# Optionally installs a PostToolUse hook for phase file write detection.
# 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}"
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
rm -f "$idle_marker"
tmux new-session -d -s "$session" -c "$workdir" \
"claude --dangerously-skip-permissions" 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, failed, break).
# 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
if ! 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)
;; # 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
# 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:needs_human)
_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
}
# 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"
}
# 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:]'
}