From 5dc00563e7ebb6b365b14acaf72278e357e68a1e Mon Sep 17 00:00:00 2001 From: Lilith Date: Sat, 10 Jan 2026 10:39:51 -0800 Subject: [PATCH] feat: initial claude-continue CLI package --- README.md | 82 +++++++ bin/cc | 612 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 27 +++ 3 files changed, 721 insertions(+) create mode 100644 README.md create mode 100755 bin/cc create mode 100644 package.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a6754d --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# @lilith/claude-continue + +Smart tmux wrapper for Claude Code with crash recovery and LLM-powered task review. + +## Features + +- **Terminal crash resilience**: Claude runs in tmux, survives terminal crashes +- **Smart session management**: One session per directory, auto-attach/create +- **LLM-powered task review**: Analyzes saved tasks, categorizes as ACTIVE/COOLING/STALE +- **Handoff generation**: Creates context-rich handoff files for session resumption + +## Installation + +```bash +# Link to local bin +ln -sf ~/Code/@packages/@cli/claude-continue/bin/cc ~/.local/bin/cc +``` + +## Usage + +```bash +cc # Start/attach claude for current directory +cc status # Show session status for current directory +cc list # Show all sessions with directories +cc tasks # Show persisted tasks with prompts +cc review # LLM-analyze tasks (run after reboot) +cc resume [n] # Show/resume reviewed tasks +cc kill # Kill session for current directory +cc help # Show all commands +``` + +## Recovery Workflow + +After system crash/reboot: + +```bash +cc review # LLM analyzes all tasks, categorizes them +cc resume # Shows: ACTIVE, COOLING, STALE tasks +cc resume 1 # Creates handoff, shows paste instruction +``` + +Then: +```bash +cd +cc +# Paste: "Read the handoff at .claude/handoffs/_.md and continue the work" +``` + +## Session Management + +Sessions are named by directory: `claude--` + +```bash +cc list # See all running sessions +cc attach # Attach to specific session +cc kill # Kill specific session +``` + +## Task Categories + +After `cc review`: + +| Status | Meaning | Action | +|--------|---------|--------| +| **ACTIVE** | Recent (<4h), incomplete | Resume immediately | +| **COOLING** | Paused (4-24h), waiting | Can resume | +| **STALE** | Old (>24h) or complete | Archive/ignore | + +## tmux Keybindings + +| Key | Action | +|-----|--------| +| `Ctrl+B D` | Detach (keeps claude running) | +| `Ctrl+B [` | Enter scroll mode | + +## Files + +| Location | Purpose | +|----------|---------| +| `~/.local/claude/tasks/` | Saved task prompts | +| `~/.local/claude/reviews/` | LLM-generated reviews | +| `/.claude/handoffs/` | Handoff files for resumption | diff --git a/bin/cc b/bin/cc new file mode 100755 index 0000000..72185b2 --- /dev/null +++ b/bin/cc @@ -0,0 +1,612 @@ +#!/usr/bin/env bash +# +# cc - 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: +# cc # Smart attach-or-create for current directory +# cc status # Show session status for current directory +# cc list # Show all sessions with directories +# cc attach NAME # Attach to specific session +# cc tasks # Show persisted tasks +# cc review # LLM-analyze tasks (run after reboot) +# cc resume [n] # Show/resume reviewed tasks +# cc kill [NAME] # Kill session (current dir if NAME omitted) +# cc new # Force create new session +# cc help # 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}cc 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}cc 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 'cc tasks' to see all)${NC}" + fi + + echo "" + echo -e "Resume with: ${BLUE}cc resume ${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}cc 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}cc${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}cc${NC} to attach" + fi + else + echo -e "${GRAY}No active session${NC}" + echo "" + echo -e "Run ${BLUE}cc${NC} to create" + fi +} + +show_help() { + cat << 'EOF' +cc - Smart tmux wrapper for Claude Code + +USAGE: + cc [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): + cc review # LLM analyzes all tasks, categorizes them + cc resume # Shows: ACTIVE, COOLING, STALE tasks + cc resume 1 # Creates handoff file, shows paste instruction + +EXAMPLES: + cc # Start/attach claude for current project + cc status # Check if session exists for this dir + cc list # See all sessions with their directories + cc review # Analyze tasks after system reboot + cc resume # See reviewed tasks + cc resume 2 # Resume second task + cc 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: cc 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 "$@" diff --git a/package.json b/package.json new file mode 100644 index 0000000..21f8b60 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "@lilith/claude-continue", + "version": "1.0.0", + "description": "Smart tmux wrapper for Claude Code with crash recovery and LLM-powered task review", + "bin": { + "cc": "./bin/cc" + }, + "scripts": { + "postinstall": "echo 'Run: ln -sf $(pwd)/bin/cc ~/.local/bin/cc'" + }, + "keywords": [ + "claude", + "tmux", + "cli", + "session-management", + "crash-recovery" + ], + "author": "lilith", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://forge.nasty.sh/lilith/claude-continue.git" + }, + "publishConfig": { + "registry": "http://forge.nasty.sh/api/packages/lilith/npm/" + } +}