From 7146e3bd92c4e707cb78b03163af95ea67c5c8bd Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Sun, 29 Mar 2026 19:51:51 +0000 Subject: [PATCH] feat(kugetsu): implement issue-driven session management - Add kugetsu init to create base session via TUI - Add kugetsu start/continue for issue-based task handling - Add kugetsu list/prune/destroy for session lifecycle - Implement directory files + index.json storage pattern - Use issue ref format: instance/user/repo#number - Fork from base session enables headless operation Solves: opencode headless CLI limitation discovered in issue #14 --- skills/kugetsu/SKILL.md | 232 ++++++---- skills/kugetsu/scripts/kugetsu | 760 ++++++++++++++++----------------- 2 files changed, 512 insertions(+), 480 deletions(-) diff --git a/skills/kugetsu/SKILL.md b/skills/kugetsu/SKILL.md index 3245794..17f0537 100644 --- a/skills/kugetsu/SKILL.md +++ b/skills/kugetsu/SKILL.md @@ -1,16 +1,16 @@ --- name: kugetsu -description: Session manager for opencode CLI. Use when managing long-running opencode sessions, resuming interrupted work, or tracking session state across disconnects. Features state tracking (used/idle/left), auto-fill last message on resume, and safe locking via confirmation prompts. +description: Issue-driven session manager for opencode CLI. Manages base sessions and per-issue forked sessions with automatic indexing for headless orchestration. license: MIT -compatibility: Requires opencode CLI, bash, and filesystem access for session state. +compatibility: Requires opencode CLI, bash, python3, and filesystem access. metadata: author: shoko - version: "1.1" + version: "2.0" --- -# kugetsu - OpenCode Session Manager +# kugetsu - OpenCode Session Manager (Issue-Driven) -Manages opencode CLI sessions with state tracking and safe resume. +Manages opencode sessions with a base session + forked session pattern optimized for headless orchestration. ## Installation @@ -27,122 +27,184 @@ cp skills/kugetsu/scripts/kugetsu ~/.local/bin/kugetsu chmod +x ~/.local/bin/kugetsu ``` -Or source directly when needed: -```bash -. skills/kugetsu/scripts/kugetsu +## Architecture + +### Session Pattern +- **Base Session**: Created once via TUI, used for forking +- **Forked Sessions**: One per issue, branched from base via `opencode run --fork --session ` + +### Directory Structure +``` +~/.kugetsu/ +├── sessions/ +│ ├── base.json # Base session metadata +│ └── github.com-shoko-kugetsu-14.json # Forked session per issue +└── index.json # Maps issue refs to session files ``` -## Session State +### Index File +```json +{ + "base": "ses_abc123", + "issues": { + "github.com/shoko/kugetsu#14": "github.com-shoko-kugetsu-14.json" + } +} +``` -| State | Meaning | Resumable? | -|-------|---------|------------| -| `used` | Session is active (process running) | Yes (with confirmation) | -| `idle` | Session ended gracefully | No | -| `left` | Session interrupted/crashed | Yes | -| `invalid` | Session data missing/corrupt | No | +### Session File +```json +{ + "type": "base|forked", + "issue_ref": "github.com/shoko/kugetsu#14", + "opencode_session_id": "ses_xyz789", + "created_at": "2026-03-29T18:16:10+02:00", + "state": "idle" +} +``` -## Session Directory +## Issue Ref Format -Sessions are stored in `~/.kugetsu/sessions//`: -- `state` - current state (used/idle/left/invalid) -- `message` - last user message (for auto-fill) -- `pid` - active process PID (when used) +All issue references use the format: `instance/user/repo#number` + +Examples: +- `github.com/shoko/kugetsu#14` +- `gitlab.com/username/project#42` +- `codeberg.org/user/repo#100` ## Commands -### kugetsu start `` `` -Start a new session: +### kugetsu init [--force] + +Initialize base session via TUI: ```bash -kugetsu start mytask "fix bug #1" +kugetsu init ``` -- Creates session directory -- Sets state to `used` -- Stores PID and message -- Runs: `opencode run --session ` -### kugetsu list [--all] -List sessions: +- Requires a terminal (TTY) to spawn the opencode TUI +- Creates base session once; subsequent runs error unless `--force` is used +- Stores base session ID in `index.json` + +### kugetsu start `` `` [--debug] + +Start task for an issue by forking from base session: ```bash -kugetsu list # Shows only `left` (resumable) -kugetsu list --all # Shows all states +kugetsu start github.com/shoko/kugetsu#14 "fix authentication bug" ``` -### kugetsu resume `` [message] -Resume an interrupted session: +- Forks new session from base +- Stores mapping in `index.json` +- Uses `opencode run --fork --session ""` + +### kugetsu continue `` `` [--debug] + +Continue work on an existing issue session: ```bash -kugetsu resume mytask # Auto-fills last message -kugetsu resume mytask "continue" # Uses provided message +kugetsu continue github.com/shoko/kugetsu#14 "add unit tests" ``` -- If state is `used`: prompts for confirmation (someone else might be using) -- If state is `idle`: errors (not resumable) -- If state is `left`: proceeds with message -### kugetsu stop `` -Stop a session gracefully: +- Looks up session file from index +- Uses `opencode run --continue --session ""` + +### kugetsu list + +List all tracked sessions: ```bash -kugetsu stop mytask +kugetsu list ``` -- Sends SIGTERM to process -- Sets state to `idle` -### kugetsu destroy `` [-y] -Delete a session: +Output: +``` +ISSUE_REF TYPE SESSION_ID CREATED +────────────────────────────────────────────────────────────────────────────────────────────────── +(base) base ses_abc123 N/A +github.com/shoko/kugetsu#14 forked ses_xyz789 2026-03-29T18:16:10+02:00 +``` + +### kugetsu prune [--force] + +Remove orphaned sessions (files not in index): ```bash -kugetsu destroy mytask # Prompts for confirmation (default: N) -kugetsu destroy mytask -y # Skips confirmation +kugetsu prune # Shows what would be deleted +kugetsu prune --force # Deletes orphaned sessions ``` -- Errors if session is `used` (use `stop` first) -- Errors if session not found -### kugetsu destroy --all [-y] -Delete all sessions: +- Orphaned = session files in `sessions/` but not in `index.json` +- Always keeps `base.json` +- Useful after opencode session cleanup + +### kugetsu destroy `` [-y] + +Delete session for specific issue: ```bash -kugetsu destroy --all # Prompts for confirmation (default: N) -kugetsu destroy --all -y # Skips confirmation -``` -- Useful for fresh start - -### kugetsu help -Show usage help. - -## State Transitions - -``` -start ──────────────► used ──────► idle (stop/SIGTERM) - │ - └──────► left (kill/SIGINT/crash) - │ - ▼ - destroy (delete) +kugetsu destroy github.com/shoko/kugetsu#14 # Prompts for confirmation +kugetsu destroy github.com/shoko/kugetsu#14 -y # Skips confirmation ``` -## Example Workflow +### kugetsu destroy --base [-y] + +Delete base session (requires explicit `--base`): +```bash +kugetsu destroy --base -y +``` + +## Workflow Example ```bash -# Start a long-running task -kugetsu start issue42 "implement feature X" +# First-time setup (requires TTY) +kugetsu init -# ... time passes, connection drops ... +# Start work on issue +kugetsu start github.com/shoko/kugetsu#14 "implement feature X" -# Check what sessions are resumable +# Continue later +kugetsu continue github.com/shoko/kugetsu#14 "add tests" + +# Continue again +kugetsu continue github.com/shoko/kugetsu#14 "fix failing test" + +# List all sessions kugetsu list -# Resume with auto-filled message -kugetsu resume issue42 +# Clean up orphaned sessions +kugetsu prune --force -# Later, when done -kugetsu stop issue42 - -# When you want a fresh start -kugetsu destroy --all +# Delete session when done +kugetsu destroy github.com/shoko/kugetsu#14 ``` +## Headless Operation + +This design solves the headless CLI limitation discovered in Issue #14: + +1. **Problem**: `opencode run --session ` doesn't work headlessly (SSE stream terminates) +2. **Solution**: Fork from existing base session, which works headlessly + +The pattern: +- Base session created once via TUI (interactive) +- All subsequent work uses `--fork --session ` or `--continue --session ` + +## Recovery + +If opencode sessions become out of sync: + +1. `kugetsu list` shows tracked sessions +2. `kugetsu prune` removes orphaned files +3. For full reset: `kugetsu destroy --base -y && kugetsu init` + ## Without kugetsu -If kugetsu is not installed, use opencode directly: +If kugetsu is not available, use opencode directly: ```bash -opencode run --session mytask "task description" -opencode run --continue --session mytask "continue" -opencode session list +# Create base session (requires TTY) +opencode +# Note the session ID from: opencode session list + +# Fork for issue +opencode run --fork --session "task" + +# Continue +opencode run --continue --session "continue" ``` -Tradeoff: No state tracking, no auto-fill, no filtered list, no confirmation prompts. + +Tradeoff: No issue mapping, no index, manual session tracking. \ No newline at end of file diff --git a/skills/kugetsu/scripts/kugetsu b/skills/kugetsu/scripts/kugetsu index f7c7710..7468171 100755 --- a/skills/kugetsu/scripts/kugetsu +++ b/skills/kugetsu/scripts/kugetsu @@ -3,69 +3,137 @@ set -euo pipefail KUGETSU_DIR="${KUGETSU_DIR:-$HOME/.kugetsu}" SESSIONS_DIR="$KUGETSU_DIR/sessions" -BIN_DIR="$KUGETSU_DIR/bin" +INDEX_FILE="$KUGETSU_DIR/index.json" usage() { cat << 'EOF' -kugetsu - OpenCode Session Manager +kugetsu - OpenCode Session Manager (Issue-Driven) Usage: - kugetsu start [--debug] Start a new session - kugetsu list [--all] List sessions (default: left only) - kugetsu resume [message] [--debug] Resume a session - kugetsu stop [--debug] Stop a session gracefully - kugetsu destroy [-y] [--debug] Delete a session (prompts confirmation) - kugetsu destroy --all [-y] Delete all sessions (prompts confirmation) - kugetsu help Show this help + kugetsu init [--force] Initialize base session (requires TTY) + kugetsu start [--debug] Start task for issue (forks base session) + kugetsu continue [message] [--debug] Continue existing task for issue + kugetsu list List all tracked sessions + kugetsu prune [--force] Remove orphaned sessions (keeps base) + kugetsu destroy [-y] Delete session for issue + kugetsu destroy --base [-y] Delete base session + kugetsu help Show this help -States: - used - Session is active (process running) - idle - Session ended gracefully (not resumable) - left - Session interrupted/crashed (resumable) - invalid - Session data missing/corrupt +Issue Ref Format: + instance/user/repo#number + Example: github.com/shoko/kugetsu#14 + +Commands: + init Create base session via TUI. Requires terminal access. + Use --force to reinitialize if base session exists. + start Fork new session from base for specific issue. + continue Continue work on existing issue session. + list Show all sessions (base + forked issues). + prune Remove sessions not in index (orphaned from opencode). + Use --force to skip confirmation. + destroy Delete specific issue session or base session. Options: --debug Show real-time debug output and capture to debug.log Examples: - kugetsu start mytask "fix bug #1" - kugetsu start mytask "fix bug #1" --debug + kugetsu init + kugetsu start github.com/shoko/kugetsu#14 "fix bug" + kugetsu continue github.com/shoko/kugetsu#14 "add tests" kugetsu list - kugetsu list --all - kugetsu resume mytask - kugetsu resume mytask "continue working" --debug - kugetsu stop mytask --debug - kugetsu destroy mytask --debug - kugetsu destroy mytask -y - kugetsu destroy --all - kugetsu destroy --all -y + kugetsu prune + kugetsu prune --force + kugetsu destroy github.com/shoko/kugetsu#14 EOF } ensure_dirs() { - mkdir -p "$SESSIONS_DIR" "$BIN_DIR" + mkdir -p "$SESSIONS_DIR" } -validate_session_id() { - local session_id="$1" - if [ -z "$session_id" ]; then - echo "Error: session_id cannot be empty" >&2 +issue_ref_to_filename() { + local issue_ref="$1" + echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/' +} + +filename_to_issue_ref() { + local filename="$1" + local name="${filename%.json}" + echo "$name" | sed 's/-\([0-9]*\)$/#\1' | sed 's/-/\//g' +} + +read_index() { + if [ -f "$INDEX_FILE" ]; then + cat "$INDEX_FILE" + else + echo '{"base": null, "issues": {}}' + fi +} + +write_index() { + local base="$1" + local issues_json="$2" + local temp_file="$INDEX_FILE.tmp.$$" + printf '{"base": %s, "issues": %s}\n' "$base" "$issues_json" > "$temp_file" + mv "$temp_file" "$INDEX_FILE" +} + +get_base_session_id() { + local index=$(read_index) + echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('base') or '')" +} + +get_session_for_issue() { + local issue_ref="$1" + local index=$(read_index) + echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d['issues'].get('$issue_ref') or '')" +} + +set_base_in_index() { + local base_session_id="$1" + local issues_json=$(read_index | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d['issues']))") + write_index "\"$base_session_id\"" "$issues_json" +} + +add_issue_to_index() { + local issue_ref="$1" + local session_file="$2" + local index=$(read_index) + local base=$(get_base_session_id) + local issues=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d['issues']))") + local new_issues=$(echo "$issues" | python3 -c "import sys, json; d=json.load(sys.stdin); d['$issue_ref']='$session_file'; print(json.dumps(d))") + if [ "$base" = "null" ] || [ -z "$base" ]; then + write_index "null" "$new_issues" + else + write_index "\"$base\"" "$new_issues" + fi +} + +remove_issue_from_index() { + local issue_ref="$1" + local index=$(read_index) + local base=$(get_base_session_id) + local new_issues=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); d['issues'].pop('$issue_ref', None); print(json.dumps(d['issues']))") + if [ "$base" = "null" ] || [ -z "$base" ]; then + write_index "null" "$new_issues" + else + write_index "\"$base\"" "$new_issues" + fi +} + +validate_issue_ref() { + local issue_ref="$1" + if [[ ! "$issue_ref" =~ ^[^/]+/[^/]+/[^#]+#[0-9]+$ ]]; then + echo "Error: invalid issue ref format" >&2 + echo "Expected: instance/user/repo#number" >&2 + echo "Example: github.com/shoko/kugetsu#14" >&2 exit 1 fi } -get_session_dir() { +check_opencode_session_exists() { local session_id="$1" - echo "$SESSIONS_DIR/$session_id" -} - -get_state() { - local session_dir="$1" - if [ -f "$session_dir/state" ]; then - cat "$session_dir/state" - else - echo "invalid" - fi + opencode session list 2>/dev/null | grep -q "^$session_id" } DEBUG_MODE=false @@ -87,416 +155,315 @@ set_debug_mode() { echo "${filtered_args[@]}" } -show_debug_log() { - local session_dir="$1" - local debug_log="$session_dir/debug.log" - if [ -f "$debug_log" ]; then - echo "=== Debug Log ===" - cat "$debug_log" - echo "=== End Debug Log ===" - else - echo "No debug log found" - fi -} +cmd_init() { + local force=false -set_state() { - local session_dir="$1" - local state="$2" - echo "$state" > "$session_dir/state" -} + while [ $# -gt 0 ]; do + case "$1" in + --force) + force=true + ;; + *) + ;; + esac + shift + done -is_process_running() { - local pid="$1" - if kill -0 "$pid" 2>/dev/null; then - return 0 - else - return 1 - fi -} + ensure_dirs -check_and_update_state() { - local session_dir="$1" - local state=$(get_state "$session_dir") - - if [ "$state" = "used" ]; then - local pid_file="$session_dir/pid" - if [ -f "$pid_file" ]; then - local pid=$(cat "$pid_file") - if ! is_process_running "$pid"; then - set_state "$session_dir" "left" - return 1 - fi + local existing_base=$(get_base_session_id) + if [ -n "$existing_base" ] && [ "$existing_base" != "null" ]; then + if [ "$force" = true ]; then + echo "Warning: Reinitializing base session (force mode)" >&2 else - set_state "$session_dir" "left" - return 1 + echo "Error: Base session already exists: $existing_base" >&2 + echo "Use --force to reinitialize" >&2 + exit 1 fi fi - return 0 + + if ! test -t 0; then + echo "Error: init requires a terminal (TTY)" >&2 + echo "Please run this command in an interactive shell" >&2 + exit 1 + fi + + echo "Starting TUI to create base session..." + echo "Press Ctrl+C to cancel or wait for session to be created" + sleep 2 + + opencode + + local session_ids=$(opencode session list 2>/dev/null | grep -E '^ses_' | awk '{print $1}' | tail -1) + if [ -z "$session_ids" ]; then + echo "Error: Could not find newly created session" >&2 + exit 1 + fi + + local new_session_id=$(echo "$session_ids" | tail -1) + local session_file="base.json" + + printf '{"type": "base", "opencode_session_id": "%s", "created_at": "%s", "state": "idle"}\n' \ + "$new_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/$session_file" + + set_base_in_index "$new_session_id" + + echo "Base session initialized: $new_session_id" } cmd_start() { - local session_id="" + local issue_ref="" local message="" - - while [ $# -gt 0 ]; do - case "$1" in - --debug) - DEBUG_MODE=true - ;; - *) - if [ -z "$session_id" ]; then - session_id="$1" - elif [ -z "$message" ]; then - message="$1" - fi - ;; - esac - shift + local args=("$@") + + args=$(set_debug_mode "${args[@]}") + + for arg in $args; do + if [ -z "$issue_ref" ]; then + issue_ref="$arg" + elif [ -z "$message" ]; then + message="$arg" + fi done - - if [ -z "$session_id" ] || [ -z "$message" ]; then - echo "Error: start requires and " >&2 + + if [ -z "$issue_ref" ] || [ -z "$message" ]; then + echo "Error: start requires and " >&2 exit 1 fi - - validate_session_id "$session_id" - local session_dir=$(get_session_dir "$session_id") - + + validate_issue_ref "$issue_ref" ensure_dirs - - if [ -d "$session_dir" ]; then - local state=$(get_state "$session_dir") - check_and_update_state "$session_dir" - state=$(get_state "$session_dir") - - if [ "$state" = "used" ]; then - echo "Error: session '$session_id' is already in use (state=used)" >&2 - echo "Use 'kugetsu list' to see all sessions, or 'kugetsu resume $session_id' to resume" >&2 - exit 1 - fi - - if [ "$state" = "left" ]; then - echo "Warning: session '$session_id' was left interrupted" >&2 - echo "Resuming instead of starting new..." >&2 - cmd_resume "$session_id" "$message" - return - fi + + local base_session_id=$(get_base_session_id) + if [ -z "$base_session_id" ] || [ "$base_session_id" = "null" ]; then + echo "Error: No base session. Run 'kugetsu init' first." >&2 + exit 1 fi - - mkdir -p "$session_dir" - set_state "$session_dir" "used" - echo "$$" > "$session_dir/pid" - echo "$message" > "$session_dir/message" - - echo "Starting session '$session_id'..." + + local existing_session=$(get_session_for_issue "$issue_ref") + if [ -n "$existing_session" ] && [ "$existing_session" != "null" ]; then + echo "Error: Session for '$issue_ref' already exists" >&2 + echo "Use 'kugetsu continue $issue_ref ' instead" >&2 + exit 1 + fi + + local session_file="${issue_ref_to_filename "$issue_ref"}.json" + + echo "Forking session for '$issue_ref'..." if [ "$DEBUG_MODE" = true ]; then - stdbuf -oL opencode run --print-logs --log-level DEBUG --session "$session_id" "$message" 2>&1 | tee "$session_dir/debug.log" + opencode run --fork --session "$base_session_id" "$message" 2>&1 | tee "$SESSIONS_DIR/$session_file.debug.log" else - opencode run --session "$session_id" "$message" + opencode run --fork --session "$base_session_id" "$message" fi - local exit_code=$? - - if [ $exit_code -eq 0 ]; then - set_state "$session_dir" "idle" + + local new_session_ids=$(opencode session list 2>/dev/null | grep -E '^ses_' | awk '{print $1}' | tail -1) + local new_session_id=$(echo "$new_session_ids" | tail -1) + + printf '{"type": "forked", "issue_ref": "%s", "opencode_session_id": "%s", "created_at": "%s", "state": "idle"}\n' \ + "$issue_ref" "$new_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/$session_file" + + add_issue_to_index "$issue_ref" "$session_file" + + echo "Session started for '$issue_ref': $new_session_id" +} + +cmd_continue() { + local issue_ref="" + local message="" + local args=("$@") + + args=$(set_debug_mode "${args[@]}") + + for arg in $args; do + if [ -z "$issue_ref" ]; then + issue_ref="$arg" + elif [ -z "$message" ]; then + message="$arg" + fi + done + + if [ -z "$issue_ref" ]; then + echo "Error: continue requires " >&2 + exit 1 + fi + + if [ -z "$message" ]; then + echo "Error: continue requires " >&2 + exit 1 + fi + + validate_issue_ref "$issue_ref" + + local session_file=$(get_session_for_issue "$issue_ref") + if [ -z "$session_file" ] || [ "$session_file" = "null" ]; then + echo "Error: No session found for '$issue_ref'" >&2 + echo "Use 'kugetsu start $issue_ref ' to create one" >&2 + exit 1 + fi + + local session_path="$SESSIONS_DIR/$session_file" + if [ ! -f "$session_path" ]; then + echo "Error: Session file missing: $session_path" >&2 + echo "Run 'kugetsu start $issue_ref ' to recreate" >&2 + exit 1 + fi + + local opencode_session_id=$(python3 -c "import json; print(json.load(open('$session_path'))['opencode_session_id'])") + + if ! check_opencode_session_exists "$opencode_session_id"; then + echo "Warning: Session may have expired in opencode" >&2 + echo "Attempting to continue anyway..." >&2 + fi + + echo "Continuing session for '$issue_ref'..." + if [ "$DEBUG_MODE" = true ]; then + opencode run --continue --session "$opencode_session_id" "$message" 2>&1 | tee "$session_path.debug.log" else - set_state "$session_dir" "left" + opencode run --continue --session "$opencode_session_id" "$message" fi - - rm -f "$session_dir/pid" } cmd_list() { - local show_all=false - if [ $# -ge 1 ] && [ "$1" = "--all" ]; then - show_all=true - fi - ensure_dirs - - printf "%-20s %-10s %-s\n" "SESSION_ID" "STATE" "LAST_MESSAGE" - printf "%-20s %-10s %-s\n" "──────────" "─────" "───────────" - - for session_dir in "$SESSIONS_DIR"/*; do - if [ -d "$session_dir" ]; then - local session_id=$(basename "$session_dir") - check_and_update_state "$session_dir" - local state=$(get_state "$session_dir") - - if [ "$show_all" = false ] && [ "$state" != "left" ]; then + + printf "%-50s %-10s %-25s %s\n" "ISSUE_REF" "TYPE" "SESSION_ID" "CREATED" + printf "%-50s %-10s %-25s %s\n" "─────────" "─────" "──────────" "───────" + + local base_session_id=$(get_base_session_id) + if [ -n "$base_session_id" ] && [ "$base_session_id" != "null" ]; then + printf "%-50s %-10s %-25s %s\n" "(base)" "base" "$base_session_id" "N/A" + fi + + local index=$(read_index) + local issue_refs=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print('\n'.join(d['issues'].keys()))" 2>/dev/null || true) + + for session_file in "$SESSIONS_DIR"/*.json; do + if [ -f "$session_file" ]; then + local filename=$(basename "$session_file" .json) + if [ "$filename" = "base" ]; then continue fi - - local message="" - if [ -f "$session_dir/message" ]; then - message=$(cat "$session_dir/message") - if [ ${#message} -gt 40 ]; then - message="${message:0:37}..." - fi - fi - - printf "%-20s %-10s %-s\n" "$session_id" "$state" "$message" + + local issue_ref=$(python3 -c "import json; print(json.load(open('$session_file'))['issue_ref'])" 2>/dev/null || echo "$filename") + local sess_id=$(python3 -c "import json; print(json.load(open('$session_file'))['opencode_session_id'])" 2>/dev/null || echo "unknown") + local created=$(python3 -c "import json; print(json.load(open('$session_file'))['created_at'])" 2>/dev/null || echo "unknown") + + printf "%-50s %-10s %-25s %s\n" "$issue_ref" "forked" "$sess_id" "$created" fi done } -cmd_resume() { - local session_id="" - local message="" - local args=("$@") - - for arg in "${args[@]}"; do - case "$arg" in - --debug) - DEBUG_MODE=true - ;; - *) - if [ -z "$session_id" ]; then - session_id="$arg" - elif [ -z "$message" ]; then - message="$arg" - fi - ;; - esac - done - - if [ -z "$session_id" ]; then - echo "Error: resume requires " >&2 - exit 1 - fi - - validate_session_id "$session_id" - - local session_dir=$(get_session_dir "$session_id") - - if [ ! -d "$session_dir" ]; then - echo "Error: session '$session_id' not found" >&2 - exit 1 - fi - - check_and_update_state "$session_dir" - local state=$(get_state "$session_dir") - - if [ "$state" = "used" ]; then - echo "Warning: session '$session_id' is marked as used" >&2 - local pid="" - if [ -f "$session_dir/pid" ]; then - pid=$(cat "$session_dir/pid") - fi - if [ -n "$pid" ] && is_process_running "$pid"; then - echo "Error: process $pid is still running for this session" >&2 - exit 1 - else - set_state "$session_dir" "left" - state="left" - fi - fi - - if [ "$state" = "idle" ]; then - echo "Error: session '$session_id' ended gracefully (state=idle)" >&2 - echo "This session cannot be resumed. Start a new session instead." >&2 - exit 1 - fi - - if [ "$state" = "invalid" ]; then - echo "Error: session '$session_id' is invalid (state=invalid)" >&2 - exit 1 - fi - - if [ -z "$message" ]; then - if [ -f "$session_dir/message" ]; then - message=$(cat "$session_dir/message") - echo "Auto-filled message: $message" - else - echo "Error: no message stored for session '$session_id'" >&2 - echo "Provide a message as second argument: kugetsu resume $session_id " >&2 - exit 1 - fi - else - echo "Using provided message: $message" - fi - - set_state "$session_dir" "used" - echo "$$" > "$session_dir/pid" - echo "$message" > "$session_dir/message" - - echo "Resuming session '$session_id'..." - if [ "$DEBUG_MODE" = true ]; then - stdbuf -oL opencode run --print-logs --log-level DEBUG --continue --session "$session_id" "$message" 2>&1 | tee "$session_dir/debug.log" - else - opencode run --continue --session "$session_id" "$message" - fi - local exit_code=$? - - if [ $exit_code -eq 0 ]; then - set_state "$session_dir" "idle" - else - set_state "$session_dir" "left" - fi - - rm -f "$session_dir/pid" -} +cmd_prune() { + local force=false -cmd_stop() { - local session_id="" - while [ $# -gt 0 ]; do case "$1" in - --debug) - DEBUG_MODE=true - ;; - *) - if [ -z "$session_id" ]; then - session_id="$1" - fi + --force) + force=true ;; esac shift done - - if [ -z "$session_id" ]; then - echo "Error: stop requires " >&2 - exit 1 - fi - - validate_session_id "$session_id" - local session_dir=$(get_session_dir "$session_id") - - if [ ! -d "$session_dir" ]; then - echo "Error: session '$session_id' not found" >&2 - exit 1 - fi - - if [ "$DEBUG_MODE" = true ]; then - show_debug_log "$session_dir" - fi - - local state=$(get_state "$session_dir") - - if [ "$state" != "used" ]; then - echo "Error: session '$session_id' is not in use (state=$state)" >&2 - exit 1 - fi - - local pid="" - if [ -f "$session_dir/pid" ]; then - pid=$(cat "$session_dir/pid") - fi - - if [ -n "$pid" ] && is_process_running "$pid"; then - echo "Sending SIGTERM to process $pid..." - kill -TERM "$pid" 2>/dev/null || true - - local count=0 - while is_process_running "$pid" && [ $count -lt 10 ]; do - sleep 0.5 - count=$((count + 1)) - done - - if is_process_running "$pid"; then - echo "Process still running, sending SIGKILL..." >&2 - kill -KILL "$pid" 2>/dev/null || true + + ensure_dirs + + local index=$(read_index) + local index_session_files=$(echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); sessions=set(d['issues'].values()); sessions.add('base.json'); print('\n'.join(sessions))" 2>/dev/null || echo "base.json") + + local orphaned=() + for session_file in "$SESSIONS_DIR"/*.json; do + if [ -f "$session_file" ]; then + local filename=$(basename "$session_file") + if ! echo "$index_session_files" | grep -q "^$filename$"; then + orphaned+=("$session_file") + fi fi + done + + if [ ${#orphaned[@]} -eq 0 ]; then + echo "No orphaned sessions found" + return + fi + + echo "Found ${#orphaned[@]} orphaned session(s):" + for f in "${orphaned[@]}"; do + echo " - $(basename "$f")" + done + + if [ "$force" = true ]; then + echo "Removing orphaned sessions (force mode)..." + for f in "${orphaned[@]}"; do + rm -f "$f" + echo "Removed: $(basename "$f")" + done + else + echo "Run with --force to remove" fi - - set_state "$session_dir" "idle" - rm -f "$session_dir/pid" - - echo "Session '$session_id' stopped" } cmd_destroy() { - local session_id="" - local destroy_all=false + local target="" local force=false - + while [ $# -gt 0 ]; do case "$1" in - --all) - destroy_all=true - ;; - --debug) - DEBUG_MODE=true + --base) + target="base" ;; -y|--yes) force=true ;; - -*) - echo "Error: unknown option '$1'" >&2 - exit 1 - ;; *) - if [ -n "$session_id" ]; then - echo "Error: too many arguments" >&2 - exit 1 + if [ -z "$target" ]; then + target="$1" fi - session_id="$1" ;; esac shift done - - if [ "$destroy_all" = true ]; then - if [ -n "$session_id" ]; then - echo "Error: cannot specify session_id with --all" >&2 + + if [ -z "$target" ]; then + echo "Error: destroy requires or --base" >&2 + exit 1 + fi + + if [ "$target" = "base" ]; then + if [ "$force" = true ]; then + rm -f "$SESSIONS_DIR/base.json" + echo '{"base": null, "issues": {}}' > "$INDEX_FILE" + echo "Base session destroyed" + else + echo "Error: destroying base session requires --base -y" >&2 exit 1 fi - - if [ "$force" = true ]; then - rm -rf "$SESSIONS_DIR"/* - echo "All sessions deleted" - return - fi - - echo "Delete ALL sessions? [y/N] " + return + fi + + validate_issue_ref "$target" + + local session_file=$(get_session_for_issue "$target") + if [ -z "$session_file" ] || [ "$session_file" = "null" ]; then + echo "Error: No session found for '$target'" >&2 + exit 1 + fi + + local session_path="$SESSIONS_DIR/$session_file" + + if [ "$force" = true ]; then + rm -f "$session_path" + remove_issue_from_index "$target" + echo "Session for '$target' destroyed" + else + echo "Delete session for '$target'? [y/N] " local reply read reply if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then - rm -rf "$SESSIONS_DIR"/* - echo "All sessions deleted" + rm -f "$session_path" + remove_issue_from_index "$target" + echo "Session for '$target' destroyed" else echo "Aborted" fi - return - fi - - if [ -z "$session_id" ]; then - echo "Error: destroy requires or --all" >&2 - exit 1 - fi - - validate_session_id "$session_id" - local session_dir=$(get_session_dir "$session_id") - - if [ ! -d "$session_dir" ]; then - echo "Error: session '$session_id' not found" >&2 - exit 1 - fi - - if [ "$DEBUG_MODE" = true ]; then - show_debug_log "$session_dir" - fi - - local state=$(get_state "$session_dir") - if [ "$state" = "used" ]; then - echo "Error: session '$session_id' is in use (state=used)" >&2 - echo "Use 'kugetsu stop $session_id' first" >&2 - exit 1 - fi - - if [ "$force" = true ]; then - rm -rf "$session_dir" - echo "Session '$session_id' deleted" - return - fi - - echo "Delete session '$session_id'? [y/N] " - local reply - read reply - if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then - rm -rf "$session_dir" - echo "Session '$session_id' deleted" - else - echo "Aborted" fi } @@ -505,25 +472,28 @@ main() { usage exit 1 fi - + local command="$1" shift - + case "$command" in help|--help|-h) usage ;; + init) + cmd_init "$@" + ;; start) cmd_start "$@" ;; + continue) + cmd_continue "$@" + ;; list) cmd_list "$@" ;; - resume) - cmd_resume "$@" - ;; - stop) - cmd_stop "$@" + prune) + cmd_prune "$@" ;; destroy) cmd_destroy "$@" @@ -536,4 +506,4 @@ main() { esac } -main "$@" +main "$@" \ No newline at end of file