#!/usr/bin/env bash # openclaw-task — manage long-running tmux tasks on devvm # # Canonical source: infra/stacks/openclaw/files/openclaw-task.sh # Installed to /usr/local/bin/openclaw-task on devvm so non-interactive # SSH (e.g. `ssh devvm openclaw-task list`) finds it on the default PATH. # # Sessions are prefixed `openclaw-task-` to avoid colliding with the # user's own tmux work. Persistent transcripts live in # ~/openclaw-tasks/.log via `tmux pipe-pane`. Sessions and logs # survive OpenClaw pod restarts (they live on devvm, not in the pod). set -euo pipefail # Use full paths because non-interactive SSH does not source ~/.profile # or ~/.bashrc (see memory id=740). TMUX_BIN=/usr/bin/tmux CLAUDE_BIN=/usr/local/bin/claude # installed as symlink to /home/wizard/.local/bin/claude PREFIX=openclaw-task- TASK_DIR=${OPENCLAW_TASK_DIR:-$HOME/openclaw-tasks} mkdir -p "$TASK_DIR" die() { echo "openclaw-task: $*" >&2; exit 1; } session_name() { printf 'openclaw-task-%s' "$1"; } require_session() { local name="$1" "$TMUX_BIN" has-session -t "$name" 2>/dev/null || die "no session '$name' (use 'openclaw-task list')" } usage() { cat < spawn detached tmux session openclaw-task claude [prompt...] spawn interactive claude in a session; if prompt given, send-keys it + Enter openclaw-task send tmux send-keys passthrough (you must pass 'Enter' literal for newline) openclaw-task capture [lines] last of pane (default 1000) openclaw-task log cat the persistent pipe-pane log openclaw-task tail tail -f the persistent log openclaw-task list all openclaw task ids (one per line) openclaw-task status 'running' or 'ended' openclaw-task kill kill session (log file kept) openclaw-task purge kill + delete log file EXAMPLES openclaw-task new build-foo "cd ~/code/foo && make all 2>&1" openclaw-task claude diag-frigate openclaw-task send diag-frigate "investigate gpu crashloop" Enter openclaw-task capture diag-frigate 200 openclaw-task list EOF } cmd_new() { [ $# -lt 2 ] && die "usage: openclaw-task new " local id="$1"; shift local name; name=$(session_name "$id") if "$TMUX_BIN" has-session -t "$name" 2>/dev/null; then die "session '$name' already exists" fi local log="$TASK_DIR/$id.log" : > "$log" # Start an idle interactive bash so pipe-pane can attach BEFORE the # user's command runs. If we passed the command directly to # new-session, its first lines beat pipe-pane to the pane and never # land in the log. "$TMUX_BIN" new-session -d -s "$name" bash --norc -i "$TMUX_BIN" pipe-pane -o -t "$name" "cat >> '$log'" sleep 0.2 "$TMUX_BIN" send-keys -t "$name" "$*" Enter # Auto-exit propagating the command's status so the tmux session # ends when the command does. "$TMUX_BIN" send-keys -t "$name" 'exit $?' Enter printf 'session: %s\nlog: %s\n' "$name" "$log" } cmd_claude() { [ $# -lt 1 ] && die "usage: openclaw-task claude [prompt...]" local id="$1"; shift local name; name=$(session_name "$id") if "$TMUX_BIN" has-session -t "$name" 2>/dev/null; then die "session '$name' already exists (use 'send' to add prompts)" fi local log="$TASK_DIR/$id.log" : > "$log" # sleep+exec lets pipe-pane attach before claude prints its banner. "$TMUX_BIN" new-session -d -s "$name" bash -c "sleep 0.3; exec '$CLAUDE_BIN'" "$TMUX_BIN" pipe-pane -o -t "$name" "cat >> '$log'" if [ $# -gt 0 ]; then # Wait for claude to come up before sending the prompt sleep 2 "$TMUX_BIN" send-keys -t "$name" "$*" Enter fi printf 'session: %s\nlog: %s\n' "$name" "$log" } cmd_send() { [ $# -lt 2 ] && die "usage: openclaw-task send " local id="$1"; shift local name; name=$(session_name "$id") require_session "$name" "$TMUX_BIN" send-keys -t "$name" "$@" } cmd_capture() { [ $# -lt 1 ] && die "usage: openclaw-task capture [lines]" local id="$1" local lines="${2:-1000}" local name; name=$(session_name "$id") require_session "$name" "$TMUX_BIN" capture-pane -t "$name" -p -S "-$lines" } cmd_log() { [ $# -lt 1 ] && die "usage: openclaw-task log " local id="$1" local log="$TASK_DIR/$id.log" [ -f "$log" ] || die "no log file for '$id' (looked at $log)" cat "$log" } cmd_tail() { [ $# -lt 1 ] && die "usage: openclaw-task tail " local id="$1" local log="$TASK_DIR/$id.log" [ -f "$log" ] || die "no log file for '$id' (looked at $log)" tail -n 100 -f "$log" } cmd_list() { "$TMUX_BIN" list-sessions -F '#{session_name}' 2>/dev/null \ | grep "^$PREFIX" \ | sed "s|^$PREFIX||" \ || true } cmd_status() { [ $# -lt 1 ] && die "usage: openclaw-task status " local id="$1" local name; name=$(session_name "$id") if "$TMUX_BIN" has-session -t "$name" 2>/dev/null; then echo running else echo ended fi } cmd_kill() { [ $# -lt 1 ] && die "usage: openclaw-task kill " local id="$1" local name; name=$(session_name "$id") require_session "$name" "$TMUX_BIN" kill-session -t "$name" } cmd_purge() { [ $# -lt 1 ] && die "usage: openclaw-task purge " local id="$1" local name; name=$(session_name "$id") "$TMUX_BIN" kill-session -t "$name" 2>/dev/null || true rm -f "$TASK_DIR/$id.log" echo "purged: $id" } case "${1:-help}" in new) shift; cmd_new "$@" ;; claude) shift; cmd_claude "$@" ;; send) shift; cmd_send "$@" ;; capture) shift; cmd_capture "$@" ;; log) shift; cmd_log "$@" ;; tail) shift; cmd_tail "$@" ;; list) shift; cmd_list "$@" ;; status) shift; cmd_status "$@" ;; kill) shift; cmd_kill "$@" ;; purge) shift; cmd_purge "$@" ;; help|-h|--help) usage ;; *) usage; exit 2 ;; esac