claude-continue/bin/ccc
2026-01-15 06:19:57 -08:00

612 lines
18 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# ccc - Smart tmux wrapper for Claude Code
#
# Runs Claude Code inside tmux for terminal crash resilience.
# Sessions persist even if the terminal crashes or is closed.
#
# Usage:
# ccc # Smart attach-or-create for current directory
# cccstatus # Show session status for current directory
# ccclist # Show all sessions with directories
# cccattach NAME # Attach to specific session
# ccctasks # Show persisted tasks
# cccreview # LLM-analyze tasks (run after reboot)
# cccresume [n] # Show/resume reviewed tasks
# ccckill [NAME] # Kill session (current dir if NAME omitted)
# cccnew # Force create new session
# ccchelp # Show help
#
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
GRAY='\033[0;90m'
NC='\033[0m'
TASKS_DIR="$HOME/.local/claude/tasks"
REVIEWS_DIR="$HOME/.local/claude/reviews"
YELLOW='\033[0;33m'
check_dependencies() {
if ! command -v tmux &>/dev/null; then
echo -e "${RED}Error: tmux is not installed${NC}"
exit 1
fi
if ! command -v claude &>/dev/null; then
echo -e "${RED}Error: claude is not installed${NC}"
exit 1
fi
}
generate_session_name() {
local dir="${1:-$(pwd)}"
local hash=$(echo "$dir" | md5sum | cut -c1-4)
local last_component=$(basename "$dir")
# Sanitize for tmux (no dots, limited special chars)
last_component=$(echo "$last_component" | tr '.' '-' | tr -cd '[:alnum:]-@_')
echo "claude-${hash}-${last_component}"
}
session_exists() {
tmux has-session -t "$1" 2>/dev/null
}
show_session_info() {
local session="$1"
local info=$(tmux list-sessions -F "#{session_name}|#{session_created_string}" 2>/dev/null | grep "^${session}|" || true)
if [[ -n "$info" ]]; then
local created=$(echo "$info" | cut -d'|' -f2)
echo -e " ${GRAY}Created: $created${NC}"
fi
}
create_session() {
local dir="$1"
local session_name="$2"
# Create detached session in target directory
tmux new-session -d -s "$session_name" -c "$dir"
# Store the original directory as session environment variable
tmux set-environment -t "$session_name" CC_DIR "$dir"
# Configure for Claude Code
tmux set-option -t "$session_name" default-terminal "tmux-256color"
tmux set-option -t "$session_name" mouse on
# Start claude
tmux send-keys -t "$session_name" "claude" Enter
# Attach
tmux attach-session -t "$session_name"
}
get_session_dir() {
local session="$1"
tmux show-environment -t "$session" CC_DIR 2>/dev/null | cut -d'=' -f2- || echo ""
}
get_session_prompt() {
local dir="$1"
[[ -z "$dir" ]] && return
# Extract last component for simpler matching (e.g., @packages)
local last_component=$(basename "$dir")
# Find most recent task file matching this directory component
local task_file=$(ls -t "$TASKS_DIR"/*"${last_component}"*.txt 2>/dev/null | head -1)
[[ -z "$task_file" ]] && return
# Extract prompt (lines after the blank line, first 50 chars)
local prompt=$(awk '/^$/{found=1; next} found{print; exit}' "$task_file" 2>/dev/null)
echo "${prompt:0:50}"
}
smart_attach() {
local dir="${1:-$(pwd)}"
local session_name=$(generate_session_name "$dir")
if session_exists "$session_name"; then
echo -e "${GREEN}Attaching to existing session:${NC} $session_name"
show_session_info "$session_name"
tmux attach-session -t "$session_name"
else
echo -e "${BLUE}Creating new session:${NC} $session_name"
create_session "$dir" "$session_name"
fi
}
list_sessions() {
echo -e "${BLUE}=== Claude Code Sessions ===${NC}"
echo ""
local sessions=$(tmux list-sessions -F "#{session_name}|#{session_created_string}" 2>/dev/null | grep "^claude-" || true)
local current_session=$(generate_session_name "$(pwd)")
if [[ -z "$sessions" ]]; then
echo "No claude sessions found."
echo ""
echo -e "${GRAY}Current directory: $(pwd)${NC}"
echo -e "${GRAY}Would create: $current_session${NC}"
return 0
fi
echo "$sessions" | while IFS='|' read -r name created; do
local dir=$(get_session_dir "$name")
local short_dir="${dir/#$HOME/~}"
local prompt=$(get_session_prompt "$dir")
local marker=" "
local color=""
# Highlight current directory's session
if [[ "$name" == "$current_session" ]]; then
marker="*"
color="${GREEN}"
fi
# Show session with context
printf "${color}%s %s${NC}\n" "$marker" "$name"
printf " ${GRAY}%s${NC}\n" "$short_dir"
if [[ -n "$prompt" ]]; then
printf " ${GRAY}\"%s...\"${NC}\n" "$prompt"
fi
echo ""
done
echo -e "${GRAY}* = current directory${NC}"
}
show_tasks() {
echo -e "${BLUE}=== Persisted Tasks ===${NC}"
echo ""
if [[ ! -d "$TASKS_DIR" ]] || [[ -z "$(ls -A "$TASKS_DIR" 2>/dev/null)" ]]; then
echo "No persisted tasks found."
return 0
fi
# Show most recent 10 tasks with prompts
for task_file in $(ls -t "$TASKS_DIR"/*.txt 2>/dev/null | head -10); do
[[ -f "$task_file" ]] || continue
local basename=$(basename "$task_file" .txt)
local timestamp=$(echo "$basename" | cut -d'_' -f1)
local project=$(echo "$basename" | cut -d'_' -f2- | cut -d'-' -f1)
local prompt=$(awk '/^$/{found=1; next} found{print; exit}' "$task_file" 2>/dev/null)
# Convert timestamp to human-readable
local date_str=$(date -d "@$((timestamp / 1000))" "+%m-%d %H:%M" 2>/dev/null || echo "$timestamp")
echo -e "${GREEN}$project${NC} ${GRAY}($date_str)${NC}"
if [[ -n "$prompt" ]]; then
echo -e " ${GRAY}\"${prompt:0:60}...\"${NC}"
fi
echo ""
done
}
kill_session() {
local target="$1"
if [[ -z "$target" ]]; then
target=$(generate_session_name)
fi
if session_exists "$target"; then
tmux kill-session -t "$target"
echo -e "${GREEN}Killed session:${NC} $target"
else
echo -e "${RED}Session not found:${NC} $target"
return 1
fi
}
review_tasks() {
echo -e "${BLUE}=== Reviewing Tasks with LLM ===${NC}"
echo ""
mkdir -p "$REVIEWS_DIR"
if [[ ! -d "$TASKS_DIR" ]] || [[ -z "$(ls -A "$TASKS_DIR" 2>/dev/null)" ]]; then
echo "No tasks to review."
return 0
fi
local task_files=$(ls -t "$TASKS_DIR"/*.txt 2>/dev/null | head -15)
local count=0
for task_file in $task_files; do
[[ -f "$task_file" ]] || continue
count=$((count + 1))
local basename=$(basename "$task_file" .txt)
local timestamp=$(echo "$basename" | cut -d'_' -f1)
local project=$(echo "$basename" | cut -d'_' -f2- | cut -d'-' -f1)
local review_file="$REVIEWS_DIR/${basename}.md"
# Skip if recently reviewed (within last hour)
if [[ -f "$review_file" ]]; then
local review_age=$(( $(date +%s) - $(stat -c %Y "$review_file") ))
if [[ $review_age -lt 3600 ]]; then
echo -e "${GRAY}[$count] $project - already reviewed${NC}"
continue
fi
fi
echo -e "${YELLOW}[$count] Reviewing: $project...${NC}"
# Read task content
local task_content=$(cat "$task_file")
# Get age in hours
local now_ms=$(($(date +%s) * 1000))
local age_hours=$(( (now_ms - timestamp) / 1000 / 3600 ))
# Use Claude to analyze
local review=$(claude --print -p "You are analyzing a saved task for session recovery. Be concise.
TASK FILE:
$task_content
TASK AGE: ${age_hours} hours
Analyze and respond with EXACTLY this format (no other text):
STATUS: [ACTIVE|COOLING|STALE]
- ACTIVE = recent (<4h) and incomplete work
- COOLING = paused work (4-24h) or waiting on something
- STALE = old (>24h) or completed work
SUMMARY: [1 sentence describing what was being worked on]
NEXT_STEP: [1 sentence describing what to do next to resume, or 'N/A' if complete]
DIRECTORY: [extract from task file]" 2>/dev/null || echo "STATUS: UNKNOWN
SUMMARY: Failed to analyze
NEXT_STEP: Review manually
DIRECTORY: unknown")
# Save review
cat > "$review_file" << EOF
# Task Review: $project
Generated: $(date -Iseconds)
Task ID: $timestamp
$review
---
Source: $task_file
EOF
echo -e "${GREEN} Done${NC}"
done
echo ""
echo -e "Reviews saved to ${GRAY}$REVIEWS_DIR/${NC}"
echo -e "Run ${BLUE}ccc resume${NC} to see results and resume a task"
}
show_reviews() {
echo -e "${BLUE}=== Task Reviews ===${NC}"
echo ""
if [[ ! -d "$REVIEWS_DIR" ]] || [[ -z "$(ls -A "$REVIEWS_DIR" 2>/dev/null)" ]]; then
echo "No reviews found. Run ${BLUE}ccc review${NC} first."
return 0
fi
local index=0
local active_tasks=()
local cooling_tasks=()
local stale_tasks=()
# Categorize reviews
for review_file in $(ls -t "$REVIEWS_DIR"/*.md 2>/dev/null); do
[[ -f "$review_file" ]] || continue
local status=$(grep "^STATUS:" "$review_file" | head -1 | cut -d: -f2 | tr -d ' ')
local summary=$(grep "^SUMMARY:" "$review_file" | head -1 | cut -d: -f2-)
local next_step=$(grep "^NEXT_STEP:" "$review_file" | head -1 | cut -d: -f2-)
local directory=$(grep "^DIRECTORY:" "$review_file" | head -1 | cut -d: -f2- | tr -d ' ')
local basename=$(basename "$review_file" .md)
local project=$(echo "$basename" | cut -d'_' -f2- | cut -d'-' -f1)
case "$status" in
ACTIVE) active_tasks+=("$project|$summary|$next_step|$directory|$review_file") ;;
COOLING) cooling_tasks+=("$project|$summary|$next_step|$directory|$review_file") ;;
*) stale_tasks+=("$project|$summary|$next_step|$directory|$review_file") ;;
esac
done
# Show active tasks
if [[ ${#active_tasks[@]} -gt 0 ]]; then
echo -e "${GREEN}ACTIVE (resume immediately)${NC}"
for task in "${active_tasks[@]}"; do
index=$((index + 1))
IFS='|' read -r project summary next_step directory file <<< "$task"
echo -e " ${GREEN}[$index]${NC} $project"
echo -e " ${GRAY}$summary${NC}"
echo -e " ${BLUE}Next:${NC}$next_step"
echo ""
done
fi
# Show cooling tasks
if [[ ${#cooling_tasks[@]} -gt 0 ]]; then
echo -e "${YELLOW}COOLING (can resume)${NC}"
for task in "${cooling_tasks[@]}"; do
index=$((index + 1))
IFS='|' read -r project summary next_step directory file <<< "$task"
echo -e " ${YELLOW}[$index]${NC} $project"
echo -e " ${GRAY}$summary${NC}"
echo ""
done
fi
# Show stale count
if [[ ${#stale_tasks[@]} -gt 0 ]]; then
echo -e "${GRAY}STALE: ${#stale_tasks[@]} tasks (run 'ccc tasks' to see all)${NC}"
fi
echo ""
echo -e "Resume with: ${BLUE}ccc resume <number>${NC}"
}
resume_task() {
local selector="$1"
if [[ -z "$selector" ]]; then
show_reviews
return 0
fi
# Build indexed list of reviews (same order as show_reviews)
local all_tasks=()
for review_file in $(ls -t "$REVIEWS_DIR"/*.md 2>/dev/null); do
[[ -f "$review_file" ]] || continue
local status=$(grep "^STATUS:" "$review_file" | head -1 | cut -d: -f2 | tr -d ' ')
local basename=$(basename "$review_file" .md)
local project=$(echo "$basename" | cut -d'_' -f2- | cut -d'-' -f1)
# Order: ACTIVE first, then COOLING, then STALE
case "$status" in
ACTIVE) all_tasks=("$project|$review_file" "${all_tasks[@]}") ;;
COOLING) all_tasks+=("$project|$review_file") ;;
*) all_tasks+=("$project|$review_file") ;;
esac
done
local target_task=""
# Check if selector is a number
if [[ "$selector" =~ ^[0-9]+$ ]]; then
local idx=$((selector - 1))
if [[ $idx -ge 0 ]] && [[ $idx -lt ${#all_tasks[@]} ]]; then
target_task="${all_tasks[$idx]}"
fi
else
# Search by project name
for task in "${all_tasks[@]}"; do
local project=$(echo "$task" | cut -d'|' -f1)
if [[ "$project" == *"$selector"* ]]; then
target_task="$task"
break
fi
done
fi
if [[ -z "$target_task" ]]; then
echo -e "${RED}Task not found: $selector${NC}"
echo "Run ${BLUE}ccc resume${NC} to see available tasks"
return 1
fi
IFS='|' read -r project review_file <<< "$target_task"
# Extract directory from the original task file
local task_basename=$(basename "$review_file" .md)
local task_file="$TASKS_DIR/${task_basename}.txt"
local directory=""
if [[ -f "$task_file" ]]; then
directory=$(grep "^\[Directory:" "$task_file" | head -1 | sed 's/\[Directory: \(.*\)\]/\1/')
fi
if [[ -z "$directory" ]] || [[ ! -d "$directory" ]]; then
echo -e "${RED}Directory not found: $directory${NC}"
return 1
fi
# Extract review content
local summary=$(grep "^SUMMARY:" "$review_file" | head -1 | cut -d: -f2-)
local next_step=$(grep "^NEXT_STEP:" "$review_file" | head -1 | cut -d: -f2-)
local status=$(grep "^STATUS:" "$review_file" | head -1 | cut -d: -f2 | tr -d ' ')
# Read original task content for context
local original_prompt=$(awk '/^$/{found=1; next} found && !/^---/{print}' "$task_file" 2>/dev/null | head -20)
# Create handoff file in project directory
local handoff_dir="$directory/.claude/handoffs"
local handoff_timestamp=$(date +%Y%m%d_%H%M%S)
local handoff_name=$(echo "$project" | tr -cd '[:alnum:]-_')
local handoff_file="$handoff_dir/${handoff_timestamp}_${handoff_name}.md"
mkdir -p "$handoff_dir"
cat > "$handoff_file" << EOF
# Session Handoff: $project
**Status**: $status
**Generated**: $(date -Iseconds)
**Directory**: $directory
## Context
$summary
## What Was Being Worked On
$original_prompt
## Next Step
$next_step
## Instructions
Continue the work described above. The previous session ended unexpectedly (system crash/reboot).
Pick up where we left off based on the context provided.
EOF
echo -e "${GREEN}=== Handoff Created ===${NC}"
echo ""
echo -e "Project: ${BLUE}$project${NC}"
echo -e "Directory: ${GRAY}$directory${NC}"
echo -e "Status: $status"
echo ""
echo -e "Context: $summary"
echo -e "Next: $next_step"
echo ""
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
echo ""
echo -e "To resume, run these commands:"
echo ""
echo -e " ${GREEN}cd $directory${NC}"
echo -e " ${GREEN}ccc${NC}"
echo ""
echo -e "Then paste this into Claude:"
echo ""
echo -e " ${BLUE}Read the handoff at .claude/handoffs/${handoff_timestamp}_${handoff_name}.md and continue the work${NC}"
echo ""
echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
# Copy the instruction to clipboard if xclip available
if command -v xclip &>/dev/null; then
echo "Read the handoff at .claude/handoffs/${handoff_timestamp}_${handoff_name}.md and continue the work" | xclip -selection clipboard
echo ""
echo -e "${GREEN}(Instruction copied to clipboard)${NC}"
fi
}
show_status() {
local dir="$(pwd)"
local session_name=$(generate_session_name "$dir")
local short_dir="${dir/#$HOME/~}"
local prompt=$(get_session_prompt "$dir")
echo -e "${BLUE}=== Current Directory ===${NC}"
echo -e " Path: $short_dir"
echo -e " Session: $session_name"
if [[ -n "$prompt" ]]; then
echo -e " Task: \"$prompt...\""
fi
echo ""
if session_exists "$session_name"; then
local info=$(tmux list-sessions -F "#{session_name}|#{session_created_string}|#{session_activity_string}" 2>/dev/null | grep "^${session_name}|" || true)
if [[ -n "$info" ]]; then
local created=$(echo "$info" | cut -d'|' -f2)
local activity=$(echo "$info" | cut -d'|' -f3)
echo -e "${GREEN}Session running${NC}"
echo -e " Created: $created"
echo -e " Activity: $activity"
echo ""
echo -e "Run ${BLUE}ccc${NC} to attach"
fi
else
echo -e "${GRAY}No active session${NC}"
echo ""
echo -e "Run ${BLUE}ccc${NC} to create"
fi
}
show_help() {
cat << 'EOF'
ccc - Smart tmux wrapper for Claude Code
USAGE:
ccc [command] [args]
COMMANDS:
(default) Smart attach-or-create session for current directory
status, s Show session status for current directory
list, ls Show all claude tmux sessions with directories
attach, a Attach to session by name
tasks, t Show persisted tasks from ~/.local/claude/tasks/
review, r Use LLM to analyze and categorize all tasks
resume [n] Show reviewed tasks, or resume task by number/name
kill, k Kill session (by name, or current dir if omitted)
new, n Force create new session even if one exists
help, -h Show this help message
RECOVERY WORKFLOW (after reboot):
ccc review # LLM analyzes all tasks, categorizes them
ccc resume # Shows: ACTIVE, COOLING, STALE tasks
ccc resume 1 # Creates handoff file, shows paste instruction
EXAMPLES:
ccc # Start/attach claude for current project
ccc status # Check if session exists for this dir
ccc list # See all sessions with their directories
ccc review # Analyze tasks after system reboot
ccc resume # See reviewed tasks
ccc resume 2 # Resume second task
ccc resume @packages # Resume by project name
KEYBINDINGS (in tmux):
Ctrl+B D Detach from session (keeps claude running)
Ctrl+B [ Enter scroll mode (arrows to scroll, q to exit)
EOF
}
main() {
check_dependencies
local cmd="${1:-}"
case "$cmd" in
"")
smart_attach "$(pwd)"
;;
status|s)
show_status
;;
list|ls)
list_sessions
;;
attach|a)
if [[ -n "${2:-}" ]]; then
tmux attach-session -t "$2"
else
echo -e "${RED}Usage: ccc attach SESSION_NAME${NC}"
exit 1
fi
;;
tasks|t)
show_tasks
;;
review|r)
review_tasks
;;
resume)
resume_task "${2:-}"
;;
kill|k)
kill_session "${2:-}"
;;
new|n)
local session_name="$(generate_session_name)-$(date +%s)"
create_session "$(pwd)" "$session_name"
;;
help|-h|--help)
show_help
;;
*)
echo -e "${RED}Unknown command:${NC} $cmd"
show_help
exit 1
;;
esac
}
main "$@"