Skip to content

Instantly share code, notes, and snippets.

@shrimpwagon
Created October 14, 2025 11:53
Show Gist options
  • Save shrimpwagon/447257a334fb98ae206024b5ecbd7f58 to your computer and use it in GitHub Desktop.
Save shrimpwagon/447257a334fb98ae206024b5ecbd7f58 to your computer and use it in GitHub Desktop.
Codex Session Manager (Bash + whiptail): per-project Codex session pointers under ~/.codex/projects/<hash>, with TUI to start/resume/rename/delete/trim sessions, sorted by last-used. Zero writes to your project.

Codex Session Manager (per‑project, whiptail)

Codex-run is a tiny Bash TUI (whiptail) that manages Codex sessions per project — without writing anything into your project tree.

Highlights

  • Per‑project session pointers stored at ~/.codex/projects/<project-hash>/<session-title>.id
  • Start NEW sessions, then optionally save with a friendly name
  • Resume existing sessions quickly (sorted by Last Used)
  • Manage sessions: Continue, Rename, Delete, Trim (keeps last N lines of JSONL)
  • Preserves spaces in session titles for readability
  • No writes inside your project directory

How it works

  • Project identity is a short SHA1 of pwd (first 16 hex chars)
  • Pointer files contain only the Codex session UUID
  • “Last Used” is the pointer file mtime and updates on Continue/Rename/Save
  • Trim finds the Codex JSONL under ~/.codex/sessions and keeps the last N lines

Requirements

  • bash, whiptail
  • codex CLI in PATH

Install

  • Save codex_run.sh somewhere on PATH (e.g., ~/bin/codex-run) and make it executable:
    • chmod +x ~/bin/codex-run

Usage

  1. cd into your project directory
  2. Run codex-run
  3. Main menu shows:
    • NEW Session
    • Existing sessions, prefixed with YYYY-MM-DD HH:MM:SS (Last Used)
  4. Selecting a session opens actions:
    • Continue — resumes codex resume <uuid>
    • Rename — change the session title (spaces allowed)
    • Delete — removes the pointer file
    • Trim — keeps the last N lines of the Codex JSONL
    • Back — return to main menu

New session flow

  • Runs codex first
  • After Codex closes: choose to save, give it a title, and paste the session UUID
  • Returns to the main menu

Notes

  • Session list is ordered by last used (most recent first)
  • The window title shows the current project hash; the prompt shows your project name
#!/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment