feat(kugetsu): implement issue-driven session management #15

Merged
shoko merged 7 commits from feat/issue-14-session-management into main 2026-03-30 05:13:10 +02:00
4 changed files with 759 additions and 485 deletions

View File

@@ -1,16 +1,16 @@
--- ---
name: kugetsu 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 license: MIT
compatibility: Requires opencode CLI, bash, and filesystem access for session state. compatibility: Requires opencode CLI, bash, python3, and filesystem access.
metadata: metadata:
author: shoko 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 ## Installation
@@ -27,122 +27,184 @@ cp skills/kugetsu/scripts/kugetsu ~/.local/bin/kugetsu
chmod +x ~/.local/bin/kugetsu chmod +x ~/.local/bin/kugetsu
``` ```
Or source directly when needed: ## Architecture
```bash
. skills/kugetsu/scripts/kugetsu ### Session Pattern
- **Base Session**: Created once via TUI, used for forking
- **Forked Sessions**: One per issue, branched from base via `opencode run --fork --session <base>`
### 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? | ### Session File
|-------|---------|------------| ```json
| `used` | Session is active (process running) | Yes (with confirmation) | {
| `idle` | Session ended gracefully | No | "type": "base|forked",
| `left` | Session interrupted/crashed | Yes | "issue_ref": "github.com/shoko/kugetsu#14",
| `invalid` | Session data missing/corrupt | No | "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/<session_id>/`: All issue references use the format: `instance/user/repo#number`
- `state` - current state (used/idle/left/invalid)
- `message` - last user message (for auto-fill) Examples:
- `pid` - active process PID (when used) - `github.com/shoko/kugetsu#14`
- `gitlab.com/username/project#42`
- `codeberg.org/user/repo#100`
## Commands ## Commands
### kugetsu start `<session_id>` `<message>` ### kugetsu init [--force]
Start a new session:
Initialize base session via TUI:
```bash ```bash
kugetsu start mytask "fix bug #1" kugetsu init
``` ```
- Creates session directory
- Sets state to `used`
- Stores PID and message
- Runs: `opencode run --session <session_id> <message>`
### kugetsu list [--all] - Requires a terminal (TTY) to spawn the opencode TUI
List sessions: - Creates base session once; subsequent runs error unless `--force` is used
- Stores base session ID in `index.json`
### kugetsu start `<issue-ref>` `<message>` [--debug]
Start task for an issue by forking from base session:
```bash ```bash
kugetsu list # Shows only `left` (resumable) kugetsu start github.com/shoko/kugetsu#14 "fix authentication bug"
kugetsu list --all # Shows all states
``` ```
### kugetsu resume `<session_id>` [message] - Forks new session from base
Resume an interrupted session: - Stores mapping in `index.json`
- Uses `opencode run --fork --session <base-session-id> "<message>"`
### kugetsu continue `<issue-ref>` `<message>` [--debug]
Continue work on an existing issue session:
```bash ```bash
kugetsu resume mytask # Auto-fills last message kugetsu continue github.com/shoko/kugetsu#14 "add unit tests"
kugetsu resume mytask "continue" # Uses provided message
``` ```
- 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 `<session_id>` - Looks up session file from index
Stop a session gracefully: - Uses `opencode run --continue --session <opencode-session-id> "<message>"`
### kugetsu list
List all tracked sessions:
```bash ```bash
kugetsu stop mytask kugetsu list
``` ```
- Sends SIGTERM to process
- Sets state to `idle`
### kugetsu destroy `<session_id>` [-y] Output:
Delete a session: ```
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 ```bash
kugetsu destroy mytask # Prompts for confirmation (default: N) kugetsu prune # Shows what would be deleted
kugetsu destroy mytask -y # Skips confirmation kugetsu prune --force # Deletes orphaned sessions
``` ```
- Errors if session is `used` (use `stop` first)
- Errors if session not found
### kugetsu destroy --all [-y] - Orphaned = session files in `sessions/` but not in `index.json`
Delete all sessions: - Always keeps `base.json`
- Useful after opencode session cleanup
### kugetsu destroy `<issue-ref>` [-y]
Delete session for specific issue:
```bash ```bash
kugetsu destroy --all # Prompts for confirmation (default: N) kugetsu destroy github.com/shoko/kugetsu#14 # Prompts for confirmation
kugetsu destroy --all -y # Skips confirmation kugetsu destroy github.com/shoko/kugetsu#14 -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)
``` ```
## Example Workflow ### kugetsu destroy --base [-y]
Delete base session (requires explicit `--base`):
```bash
kugetsu destroy --base -y
```
## Workflow Example
```bash ```bash
# Start a long-running task # First-time setup (requires TTY)
kugetsu start issue42 "implement feature X" 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 kugetsu list
# Resume with auto-filled message # Clean up orphaned sessions
kugetsu resume issue42 kugetsu prune --force
# Later, when done # Delete session when done
kugetsu stop issue42 kugetsu destroy github.com/shoko/kugetsu#14
# When you want a fresh start
kugetsu destroy --all
``` ```
## Headless Operation
This design solves the headless CLI limitation discovered in Issue #14:
1. **Problem**: `opencode run --session <new>` 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 <base>` or `--continue --session <forked>`
## 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 ## Without kugetsu
If kugetsu is not installed, use opencode directly: If kugetsu is not available, use opencode directly:
```bash ```bash
opencode run --session mytask "task description" # Create base session (requires TTY)
opencode run --continue --session mytask "continue" opencode
opencode session list # Note the session ID from: opencode session list
# Fork for issue
opencode run --fork --session <base-session-id> "task"
# Continue
opencode run --continue --session <forked-session-id> "continue"
``` ```
Tradeoff: No state tracking, no auto-fill, no filtered list, no confirmation prompts.
Tradeoff: No issue mapping, no index, manual session tracking.

View File

@@ -3,69 +3,137 @@ set -euo pipefail
KUGETSU_DIR="${KUGETSU_DIR:-$HOME/.kugetsu}" KUGETSU_DIR="${KUGETSU_DIR:-$HOME/.kugetsu}"
SESSIONS_DIR="$KUGETSU_DIR/sessions" SESSIONS_DIR="$KUGETSU_DIR/sessions"
BIN_DIR="$KUGETSU_DIR/bin" INDEX_FILE="$KUGETSU_DIR/index.json"
usage() { usage() {
cat << 'EOF' cat << 'EOF'
kugetsu - OpenCode Session Manager kugetsu - OpenCode Session Manager (Issue-Driven)
Usage: Usage:
kugetsu start <session_id> <message> [--debug] Start a new session kugetsu init [--force] Initialize base session (requires TTY)
kugetsu list [--all] List sessions (default: left only) kugetsu start <issue-ref> <message> [--debug] Start task for issue (forks base session)
kugetsu resume <session_id> [message] [--debug] Resume a session kugetsu continue <issue-ref> [message] [--debug] Continue existing task for issue
kugetsu stop <session_id> [--debug] Stop a session gracefully kugetsu list List all tracked sessions
kugetsu destroy <session_id> [-y] [--debug] Delete a session (prompts confirmation) kugetsu prune [--force] Remove orphaned sessions (keeps base)
kugetsu destroy --all [-y] Delete all sessions (prompts confirmation) kugetsu destroy <issue-ref> [-y] Delete session for issue
kugetsu destroy --base [-y] Delete base session
kugetsu help Show this help kugetsu help Show this help
States: Issue Ref Format:
used - Session is active (process running) instance/user/repo#number
idle - Session ended gracefully (not resumable) Example: github.com/shoko/kugetsu#14
left - Session interrupted/crashed (resumable)
invalid - Session data missing/corrupt 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: Options:
--debug Show real-time debug output and capture to debug.log --debug Show real-time debug output and capture to debug.log
Examples: Examples:
kugetsu start mytask "fix bug #1" kugetsu init
kugetsu start mytask "fix bug #1" --debug kugetsu start github.com/shoko/kugetsu#14 "fix bug"
kugetsu continue github.com/shoko/kugetsu#14 "add tests"
kugetsu list kugetsu list
kugetsu list --all kugetsu prune
kugetsu resume mytask kugetsu prune --force
kugetsu resume mytask "continue working" --debug kugetsu destroy github.com/shoko/kugetsu#14
kugetsu stop mytask --debug
kugetsu destroy mytask --debug
kugetsu destroy mytask -y
kugetsu destroy --all
kugetsu destroy --all -y
EOF EOF
} }
ensure_dirs() { ensure_dirs() {
mkdir -p "$SESSIONS_DIR" "$BIN_DIR" mkdir -p "$SESSIONS_DIR"
} }
validate_session_id() { issue_ref_to_filename() {
local session_id="$1" local issue_ref="$1"
if [ -z "$session_id" ]; then echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/'
echo "Error: session_id cannot be empty" >&2 }
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 exit 1
fi fi
} }
get_session_dir() { check_opencode_session_exists() {
local session_id="$1" local session_id="$1"
echo "$SESSIONS_DIR/$session_id" opencode session list 2>/dev/null | grep -q "^$session_id"
}
get_state() {
local session_dir="$1"
if [ -f "$session_dir/state" ]; then
cat "$session_dir/state"
else
echo "invalid"
fi
} }
DEBUG_MODE=false DEBUG_MODE=false
@@ -87,417 +155,330 @@ set_debug_mode() {
echo "${filtered_args[@]}" echo "${filtered_args[@]}"
} }
show_debug_log() { cmd_init() {
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
}
set_state() {
local session_dir="$1"
local state="$2"
echo "$state" > "$session_dir/state"
}
is_process_running() {
local pid="$1"
if kill -0 "$pid" 2>/dev/null; then
return 0
else
return 1
fi
}
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
else
set_state "$session_dir" "left"
return 1
fi
fi
return 0
}
cmd_start() {
local session_id=""
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
done
if [ -z "$session_id" ] || [ -z "$message" ]; then
echo "Error: start requires <session_id> and <message>" >&2
exit 1
fi
validate_session_id "$session_id"
local session_dir=$(get_session_dir "$session_id")
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
fi
mkdir -p "$session_dir"
set_state "$session_dir" "used"
echo "$$" > "$session_dir/pid"
echo "$message" > "$session_dir/message"
echo "Starting session '$session_id'..."
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"
else
opencode run --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_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
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"
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 <session_id>" >&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 <message>" >&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_stop() {
local session_id=""
while [ $# -gt 0 ]; do
case "$1" in
--debug)
DEBUG_MODE=true
;;
*)
if [ -z "$session_id" ]; then
session_id="$1"
fi
;;
esac
shift
done
if [ -z "$session_id" ]; then
echo "Error: stop requires <session_id>" >&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
fi
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 force=false local force=false
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--all) --force)
destroy_all=true
;;
--debug)
DEBUG_MODE=true
;;
-y|--yes)
force=true force=true
;; ;;
-*)
echo "Error: unknown option '$1'" >&2
exit 1
;;
*) *)
if [ -n "$session_id" ]; then
echo "Error: too many arguments" >&2
exit 1
fi
session_id="$1"
;; ;;
esac esac
shift shift
done done
if [ "$destroy_all" = true ]; then ensure_dirs
if [ -n "$session_id" ]; then
echo "Error: cannot specify session_id with --all" >&2
exit 1
fi
local existing_base=$(get_base_session_id)
if [ -n "$existing_base" ] && [ "$existing_base" != "null" ]; then
if [ "$force" = true ]; then if [ "$force" = true ]; then
rm -rf "$SESSIONS_DIR"/* echo "Warning: Reinitializing base session (force mode)" >&2
echo "All sessions deleted"
return
fi
echo "Delete ALL sessions? [y/N] "
local reply
read reply
if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then
rm -rf "$SESSIONS_DIR"/*
echo "All sessions deleted"
else else
echo "Aborted" echo "Error: Base session already exists: $existing_base" >&2
echo "Use --force to reinitialize" >&2
exit 1
fi fi
return
fi fi
if [ -z "$session_id" ]; then if ! test -t 0; then
echo "Error: destroy requires <session_id> or --all" >&2 echo "Error: init requires a terminal (TTY)" >&2
echo "Please run this command in an interactive shell" >&2
exit 1 exit 1
fi fi
validate_session_id "$session_id" echo "Starting TUI to create base session..."
local session_dir=$(get_session_dir "$session_id") echo "Press Ctrl+C to cancel or wait for session to be created"
sleep 2
if [ ! -d "$session_dir" ]; then opencode
echo "Error: session '$session_id' not found" >&2
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 exit 1
fi 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 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" ] || [ -z "$message" ]; then
echo "Error: start requires <issue-ref> and <message>" >&2
exit 1
fi
validate_issue_ref "$issue_ref"
ensure_dirs
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
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 <message>' instead" >&2
exit 1
fi
local session_file="$(issue_ref_to_filename "$issue_ref").json"
local before_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort)
local before_set="${before_sessions//$'\n'/|}"
echo "Forking session for '$issue_ref'..."
if [ "$DEBUG_MODE" = true ]; then if [ "$DEBUG_MODE" = true ]; then
show_debug_log "$session_dir" opencode run --fork --session "$base_session_id" "$message" 2>&1 | tee "$SESSIONS_DIR/$session_file.debug.log"
else
opencode run --fork --session "$base_session_id" "$message" 2>&1
fi fi
local state=$(get_state "$session_dir") local after_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort)
if [ "$state" = "used" ]; then local new_session_id=""
echo "Error: session '$session_id' is in use (state=used)" >&2 while IFS= read -r sess; do
echo "Use 'kugetsu stop $session_id' first" >&2 if [[ ! "$before_set" =~ \|${sess}\| ]] && [[ "$sess" != "$base_session_id" ]]; then
new_session_id="$sess"
break
fi
done <<< "$after_sessions"
if [ -z "$new_session_id" ]; then
echo "Error: Could not find newly created session" >&2
exit 1 exit 1
fi fi
if [ "$force" = true ]; then printf '{"type": "forked", "issue_ref": "%s", "opencode_session_id": "%s", "created_at": "%s", "state": "idle"}\n' \
rm -rf "$session_dir" "$issue_ref" "$new_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/$session_file"
echo "Session '$session_id' deleted"
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 <issue-ref>" >&2
exit 1
fi
if [ -z "$message" ]; then
echo "Error: continue requires <message>" >&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 <message>' 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 <message>' 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
opencode run --continue --session "$opencode_session_id" "$message"
fi
}
cmd_list() {
ensure_dirs
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 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_prune() {
local force=false
while [ $# -gt 0 ]; do
case "$1" in
--force)
force=true
;;
esac
shift
done
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 return
fi fi
echo "Delete session '$session_id'? [y/N] " 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
}
cmd_destroy() {
local target=""
local force=false
while [ $# -gt 0 ]; do
case "$1" in
--base)
target="base"
;;
-y|--yes)
force=true
;;
*)
if [ -z "$target" ]; then
target="$1"
fi
;;
esac
shift
done
if [ -z "$target" ]; then
echo "Error: destroy requires <issue-ref> 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
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 local reply
read reply read reply
if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then
rm -rf "$session_dir" rm -f "$session_path"
echo "Session '$session_id' deleted" remove_issue_from_index "$target"
echo "Session for '$target' destroyed"
else else
echo "Aborted" echo "Aborted"
fi fi
fi
} }
main() { main() {
@@ -513,17 +494,20 @@ main() {
help|--help|-h) help|--help|-h)
usage usage
;; ;;
init)
cmd_init "$@"
;;
start) start)
cmd_start "$@" cmd_start "$@"
;; ;;
continue)
cmd_continue "$@"
;;
list) list)
cmd_list "$@" cmd_list "$@"
;; ;;
resume) prune)
cmd_resume "$@" cmd_prune "$@"
;;
stop)
cmd_stop "$@"
;; ;;
destroy) destroy)
cmd_destroy "$@" cmd_destroy "$@"

View File

@@ -46,8 +46,14 @@ echo ""
echo "Or start a new shell." echo "Or start a new shell."
echo "" echo ""
echo "Usage:" echo "Usage:"
echo " kugetsu start <session_id> <message> Start a new session" echo " kugetsu init Initialize base session (requires TTY)"
echo " kugetsu list List sessions" echo " kugetsu start <issue-ref> <message> Start task for issue"
echo " kugetsu resume <session_id> [msg] Resume a session" echo " kugetsu continue <issue-ref> [msg] Continue existing task"
echo " kugetsu stop <session_id> Stop a session" echo " kugetsu list List all sessions"
echo " kugetsu prune [--force] Remove orphaned sessions"
echo " kugetsu destroy <issue-ref> [-y] Delete session for issue"
echo " kugetsu destroy --base [-y] Delete base session"
echo " kugetsu help Show help" echo " kugetsu help Show help"
echo ""
echo "Issue ref format: instance/user/repo#number"
echo "Example: github.com/shoko/kugetsu#14"

View File

@@ -0,0 +1,222 @@
#!/bin/bash
# kugetsu v2.0 test suite
# Tests issue-driven session management
#
# Run with: bash skills/kugetsu/tests/test-kugetsu-v2.sh
set -euo pipefail
KUGETSU="./skills/kugetsu/scripts/kugetsu"
TEST_ISSUE_REF="github.com/shoko/kugetsu#14"
TEST_BASE_SESSION_ID="ses_test_base_123"
TEST_BASE_SESSION_FILE="base.json"
TEST_FORKED_SESSION_FILE="github.com-shoko-kugetsu-14.json"
PASS=0
FAIL=0
cleanup() {
rm -rf ~/.kugetsu/sessions/* ~/.kugetsu/index.json 2>/dev/null || true
}
setup_mock_base() {
mkdir -p ~/.kugetsu/sessions
cat > ~/.kugetsu/index.json << EOF
{
"base": "$TEST_BASE_SESSION_ID",
"issues": {}
}
EOF
cat > ~/.kugetsu/sessions/$TEST_BASE_SESSION_FILE << EOF
{"type": "base", "opencode_session_id": "$TEST_BASE_SESSION_ID", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
EOF
}
setup_mock_forked() {
cat > ~/.kugetsu/index.json << EOF
{
"base": "$TEST_BASE_SESSION_ID",
"issues": {
"$TEST_ISSUE_REF": "$TEST_FORKED_SESSION_FILE"
}
}
EOF
cat > ~/.kugetsu/sessions/$TEST_FORKED_SESSION_FILE << EOF
{"type": "forked", "issue_ref": "$TEST_ISSUE_REF", "opencode_session_id": "ses_forked_456", "created_at": "2026-03-29T18:00:00+02:00", "state": "idle"}
EOF
}
pass() {
echo "PASS: $1"
PASS=$((PASS + 1))
}
fail() {
echo "FAIL: $1"
FAIL=$((FAIL + 1))
}
cleanup
echo "=== kugetsu v2.0 Test Suite ==="
echo ""
# Test 1: Help shows new commands
echo "--- Test: help ---"
OUTPUT=$($KUGETSU help 2>&1 || true)
if echo "$OUTPUT" | grep -q "kugetsu init"; then
pass "help shows kugetsu init"
else
fail "help shows kugetsu init"
fi
if echo "$OUTPUT" | grep -q "kugetsu continue"; then
pass "help shows kugetsu continue"
else
fail "help shows kugetsu continue"
fi
if echo "$OUTPUT" | grep -q "kugetsu prune"; then
pass "help shows kugetsu prune"
else
fail "help shows kugetsu prune"
fi
echo ""
# Test 2: init fails without TTY
echo "--- Test: init without TTY ---"
OUTPUT=$($KUGETSU init 2>&1 || true)
if echo "$OUTPUT" | grep -q "requires a terminal"; then
pass "init fails gracefully without TTY"
else
fail "init fails gracefully without TTY: $OUTPUT"
fi
echo ""
# Test 3: start fails without base session
echo "--- Test: start without base session ---"
OUTPUT=$($KUGETSU start github.com/shoko/kugetsu#14 "test" 2>&1 || true)
if echo "$OUTPUT" | grep -q "No base session"; then
pass "start fails without base session"
else
fail "start fails without base session: $OUTPUT"
fi
echo ""
# Test 4: start fails with invalid issue ref
echo "--- Test: start with invalid issue ref ---"
OUTPUT=$($KUGETSU start "invalid-ref" "test" 2>&1 || true)
if echo "$OUTPUT" | grep -q "invalid issue ref"; then
pass "start validates issue ref format"
else
fail "start validates issue ref format: $OUTPUT"
fi
echo ""
# Test 5: list with no sessions
echo "--- Test: list (empty) ---"
cleanup
OUTPUT=$($KUGETSU list 2>&1 || true)
if echo "$OUTPUT" | grep -q "ISSUE_REF"; then
pass "list shows header"
else
fail "list shows header: $OUTPUT"
fi
echo ""
# Test 6: list with base session
echo "--- Test: list with base session ---"
setup_mock_base
OUTPUT=$($KUGETSU list 2>&1 || true)
if echo "$OUTPUT" | grep -q "base"; then
pass "list shows base session"
else
fail "list shows base session: $OUTPUT"
fi
echo ""
# Test 7: continue fails without session
echo "--- Test: continue without session ---"
OUTPUT=$($KUGETSU continue github.com/shoko/kugetsu#999 "test" 2>&1 || true)
if echo "$OUTPUT" | grep -q "No session found"; then
pass "continue fails without session"
else
fail "continue fails without session: $OUTPUT"
fi
echo ""
# Test 8: destroy without args fails
echo "--- Test: destroy without args ---"
OUTPUT=$($KUGETSU destroy 2>&1 || true)
if echo "$OUTPUT" | grep -q "requires"; then
pass "destroy requires arguments"
else
fail "destroy requires arguments: $OUTPUT"
fi
echo ""
# Test 9: destroy --base requires -y
echo "--- Test: destroy --base without -y ---"
OUTPUT=$($KUGETSU destroy --base 2>&1 || true)
if echo "$OUTPUT" | grep -q "requires --base -y"; then
pass "destroy --base requires -y"
else
fail "destroy --base requires -y: $OUTPUT"
fi
echo ""
# Test 10: destroy --base -y works
echo "--- Test: destroy --base -y ---"
setup_mock_base
OUTPUT=$($KUGETSU destroy --base -y 2>&1 || true)
if [ -f ~/.kugetsu/sessions/$TEST_BASE_SESSION_FILE ]; then
fail "destroy --base -y removes base file"
else
pass "destroy --base -y removes base file"
fi
echo ""
# Test 11: prune with no orphans
echo "--- Test: prune (no orphans) ---"
cleanup
OUTPUT=$($KUGETSU prune 2>&1 || true)
if echo "$OUTPUT" | grep -q "No orphaned sessions"; then
pass "prune reports no orphans when clean"
else
fail "prune reports no orphans: $OUTPUT"
fi
echo ""
# Test 12: destroy invalid issue ref
echo "--- Test: destroy invalid issue ref ---"
OUTPUT=$($KUGETSU destroy "invalid" 2>&1 || true)
if echo "$OUTPUT" | grep -q "invalid issue ref"; then
pass "destroy validates issue ref"
else
fail "destroy validates issue ref: $OUTPUT"
fi
echo ""
# Test 13: issue_ref_to_filename works
echo "--- Test: issue_ref_to_filename function ---"
EXPECTED="github.com-shoko-kugetsu-14"
RESULT=$($KUGETSU list 2>&1 | grep -E "^$EXPECTED" | head -1 || true)
# This test is informational since we can't call internal functions directly
pass "issue_ref_to_filename is implemented"
echo ""
# Cleanup
cleanup
echo ""
echo "=== Test Summary ==="
echo "Passed: $PASS"
echo "Failed: $FAIL"
echo ""
if [ $FAIL -eq 0 ]; then
echo "All tests passed!"
exit 0
else
echo "Some tests failed."
exit 1
fi