|
#!/usr/bin/env bash |
|
set -euo pipefail |
|
|
|
# ============================================= |
|
# Codex Session Manager (per-project, whiptail) |
|
# ============================================= |
|
# What it does |
|
# - Manages Codex "session pointers" per project without touching the project tree. |
|
# - Stores pointers at: ~/.codex/projects/<project-hash>/<session-title>.id |
|
# - Lets you start new sessions and optionally save them, or resume existing ones. |
|
# - Adds management actions: Continue, Rename, Delete, and Trim (log size). |
|
# - Orders sessions by "Last Used" (pointer mtime), shown left of each title. |
|
# |
|
# How it works |
|
# - Project identity is a short SHA1 hash of the current working directory. |
|
# - Each pointer file contains the Codex session UUID (one line). |
|
# - "Last Used" updates when you Continue, Rename, or Save a session. |
|
# - Trimming affects the Codex JSONL log under ~/.codex/sessions (keeps last N lines). |
|
# |
|
# Usage |
|
# - Run `codex-run` in your project directory. |
|
# - Main menu shows: "NEW Session" and your saved sessions with last-used timestamps. |
|
# - Select a saved session to open: Continue / Rename / Delete / Trim / Back. |
|
# - Starting NEW runs Codex first; after it closes you can choose to save it by name and paste the session ID. |
|
# - All flows return to the main menu for quick iteration. |
|
# ============================================= |
|
|
|
# --- Require interactive TTY and whiptail --- |
|
if [[ ! -t 0 || ! -t 1 ]]; then |
|
echo "This script must be run in an interactive terminal (TTY)." >&2 |
|
exit 1 |
|
fi |
|
if ! command -v whiptail >/dev/null 2>&1; then |
|
echo "whiptail is required. Install it (e.g., sudo apt-get install whiptail) and try again." >&2 |
|
exit 1 |
|
fi |
|
|
|
# --- Session storage (global, per-project hash) --- |
|
PROJECT_DIR="$(pwd)" |
|
PROJECT_NAME="$(basename "$PROJECT_DIR")" |
|
# Use a short, stable hash of the project path for folder name (16 hex chars) |
|
PROJECT_HASH="$(printf "%s" "$PROJECT_DIR" | sha1sum | cut -c1-16)" |
|
BASE_CODEX_DIR="$HOME/.codex" |
|
PROJ_CODEX_DIR="$BASE_CODEX_DIR/projects/$PROJECT_HASH" |
|
SESS_DIR="$PROJ_CODEX_DIR" |
|
UUID_REGEX='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' |
|
mkdir -p "$SESS_DIR" |
|
|
|
# ---------- Helpers ---------- |
|
format_sessions_list() { |
|
# Output: "<epoch>\t<date>\t<title>" sorted by last-used desc |
|
shopt -s nullglob |
|
for f in "$SESS_DIR"/*.id; do |
|
local title epoch nice |
|
title="$(basename "$f" .id)" |
|
epoch="$(stat -c %Y "$f" 2>/dev/null || echo 0)" |
|
nice="$(date -d "@$epoch" '+%Y-%m-%d %H:%M:%S')" |
|
printf "%s\t%s\t%s\n" "$epoch" "$nice" "$title" |
|
done | sort -r -n -k1,1 |
|
shopt -u nullglob |
|
} |
|
|
|
prompt_session_name() { |
|
local name="" |
|
read -r -p "Session name: " name |
|
# Trim leading/trailing whitespace but preserve internal spaces |
|
name="$(printf '%s' "$name" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" |
|
if [[ -z "$name" ]]; then |
|
echo "Session name cannot be empty" >&2 |
|
return 1 |
|
fi |
|
if [[ "$name" == *"/"* ]]; then |
|
echo "Session name cannot contain '/'" >&2 |
|
return 1 |
|
fi |
|
printf "%s" "$name" |
|
} |
|
|
|
prompt_session_id() { |
|
local session_id="" |
|
read -r -p "Paste session id (UUID): " session_id |
|
session_id="$(echo -n "$session_id" | tr -d '\r\n' | xargs)" |
|
if ! [[ "$session_id" =~ $UUID_REGEX ]]; then |
|
echo "That does not look like a valid session id" >&2 |
|
return 1 |
|
fi |
|
printf "%s" "$session_id" |
|
} |
|
|
|
resume_existing() { |
|
local title="$1"; shift || true |
|
local id_file="$SESS_DIR/$title.id" |
|
if [[ ! -f "$id_file" ]]; then |
|
echo "Session file not found: $id_file" >&2 |
|
exit 1 |
|
fi |
|
local session_id |
|
session_id="$(tr -d '\r\n' < "$id_file")" |
|
if [[ -z "$session_id" ]]; then |
|
echo "Empty session id in $id_file" >&2 |
|
exit 1 |
|
fi |
|
echo "Resuming '$title' ($session_id)" |
|
codex resume "$session_id" "$@" || true |
|
touch "$id_file" |
|
} |
|
|
|
start_new_then_save() { |
|
codex "$@" || true |
|
|
|
echo |
|
read -r -p "Save this session pointer here? [y/N]: " ans |
|
ans=${ans:-N} |
|
if ! [[ "$ans" =~ ^[Yy]$ ]]; then |
|
echo "Not saved" |
|
return 0 |
|
fi |
|
|
|
local title |
|
if ! title="$(prompt_session_name)"; then |
|
echo "Canceled" |
|
return 1 |
|
fi |
|
|
|
local id_file="$SESS_DIR/$title.id" |
|
if [[ -e "$id_file" ]]; then |
|
read -r -p "Session '$title' exists. Overwrite? [y/N]: " ow |
|
ow=${ow:-N} |
|
if ! [[ "$ow" =~ ^[Yy]$ ]]; then |
|
echo "Canceled" |
|
return 0 |
|
fi |
|
fi |
|
|
|
local sid |
|
if ! sid="$(prompt_session_id)"; then |
|
echo "Canceled" |
|
return 1 |
|
fi |
|
|
|
printf '%s' "$sid" > "$id_file" |
|
touch "$id_file" |
|
echo "Saved: $id_file" |
|
} |
|
|
|
# ---------- Additional Actions ---------- |
|
rename_session() { |
|
local old_title="$1" |
|
local old_file="$SESS_DIR/$old_title.id" |
|
local new_title |
|
new_title=$(whiptail --inputbox "Rename session:\nCurrent: $old_title" 10 70 "$old_title" 3>&1 1>&2 2>&3) || return 1 |
|
# Trim spaces around |
|
new_title="$(printf '%s' "$new_title" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" |
|
if [[ -z "$new_title" ]]; then |
|
whiptail --msgbox "Name cannot be empty." 8 40 |
|
return 1 |
|
fi |
|
if [[ "$new_title" == *"/"* ]]; then |
|
whiptail --msgbox "Name cannot contain '/'." 8 40 |
|
return 1 |
|
fi |
|
local new_file="$SESS_DIR/$new_title.id" |
|
if [[ "$new_title" == "$old_title" ]]; then |
|
return 0 |
|
fi |
|
if [[ -e "$new_file" ]]; then |
|
if ! whiptail --yesno "Session '$new_title' exists. Overwrite?" 8 50; then |
|
return 1 |
|
fi |
|
fi |
|
mv -f -- "$old_file" "$new_file" |
|
touch -- "$new_file" |
|
printf '%s' "$new_title" |
|
} |
|
|
|
delete_session() { |
|
local title="$1" |
|
local id_file="$SESS_DIR/$title.id" |
|
if whiptail --yesno "Delete session '$title'?" 8 50; then |
|
rm -f -- "$id_file" |
|
whiptail --msgbox "Deleted: $title" 8 40 |
|
fi |
|
} |
|
|
|
trim_session_by_id() { |
|
local session_id="$1" |
|
local keep_lines="$2" |
|
local session_dir="$HOME/.codex/sessions" |
|
local session_file |
|
session_file=$(find "$session_dir" -type f -name "*${session_id}*.jsonl" 2>/dev/null | head -n 1 || true) |
|
if [[ -z "$session_file" || ! -f "$session_file" ]]; then |
|
whiptail --msgbox "No session file found for ID: $session_id" 8 60 |
|
return 1 |
|
fi |
|
# Perform trim |
|
tail -n "$keep_lines" -- "$session_file" > "${session_file}.tmp" && mv "${session_file}.tmp" "$session_file" |
|
local new_size |
|
new_size=$(du -h -- "$session_file" | cut -f1) |
|
whiptail --msgbox "Trimmed to last $keep_lines lines. New size: $new_size" 9 60 |
|
} |
|
|
|
# ---------- Menus (whiptail ONLY) ---------- |
|
# Build items: pair of TAG ITEM. We'll use TAG == TITLE so selection returns the title directly. |
|
# For NEW, TAG == NEW, ITEM == "NEW Session". We'll hide tags with --notags so only the item shows. |
|
|
|
main_menu() { |
|
local items=( "NEW" "NEW Session" ) |
|
while IFS=$'\t' read -r _epoch nice title; do |
|
items+=( "$title" "${nice} ${title}" ) |
|
done < <(format_sessions_list) |
|
whiptail --notags --title "Codex Sessions (${PROJECT_HASH})" --menu "Pick a ${PROJECT_NAME} project session:" 20 70 12 "${items[@]}" 3>&1 1>&2 2>&3 |
|
} |
|
|
|
session_menu() { |
|
local title="$1" |
|
whiptail --notags --title "Session: $title (${PROJECT_HASH})" --menu "Choose action for $title:" 18 60 10 \ |
|
CONTINUE "Continue" \ |
|
RENAME "Rename" \ |
|
DELETE "Delete" \ |
|
TRIM "Trim" \ |
|
BACK "Back" \ |
|
3>&1 1>&2 2>&3 |
|
} |
|
|
|
# Looping menu |
|
while true; do |
|
choice=$(main_menu) || exit 0 |
|
if [[ "$choice" == "NEW" ]]; then |
|
start_new_then_save "$@" |
|
# After finishing, loop back to main menu |
|
continue |
|
fi |
|
|
|
# Existing session submenu |
|
while true; do |
|
action=$(session_menu "$choice") || break |
|
case "$action" in |
|
CONTINUE) |
|
resume_existing "$choice" "$@" |
|
;; |
|
RENAME) |
|
new_choice=$(rename_session "$choice") || true |
|
if [[ -n "$new_choice" ]]; then |
|
choice="$new_choice" |
|
fi |
|
;; |
|
DELETE) |
|
delete_session "$choice" |
|
break |
|
;; |
|
TRIM) |
|
id_file="$SESS_DIR/$choice.id" |
|
if [[ ! -f "$id_file" ]]; then |
|
whiptail --msgbox "Pointer missing for '$choice'" 8 50 |
|
break |
|
fi |
|
sid="$(tr -d '\r\n' < "$id_file")" |
|
if [[ -z "$sid" ]]; then |
|
whiptail --msgbox "Empty session id for '$choice'" 8 50 |
|
break |
|
fi |
|
keep=$(whiptail --inputbox "Lines to keep (default 100):" 10 50 "100" 3>&1 1>&2 2>&3) || continue |
|
keep=${keep:-100} |
|
if ! [[ "$keep" =~ ^[0-9]+$ ]]; then |
|
whiptail --msgbox "Please enter a valid positive integer." 8 50 |
|
continue |
|
fi |
|
trim_session_by_id "$sid" "$keep" |
|
;; |
|
BACK) |
|
break |
|
;; |
|
esac |
|
done |
|
done |