Compare commits

...

8 Commits

Author SHA1 Message Date
3e0144ea7c Merge pull request 'feat(kugetsu): implement issue-driven session management' (#15) from feat/issue-14-session-management into main 2026-03-30 05:13:10 +02:00
shokollm
b422b33aa6 fix(kugetsu): use before/after session list to detect forked session
Compare session list before and after fork to reliably detect which
session is the newly created one. Avoids relying on parsing output
that may not contain session ID.
2026-03-29 20:25:41 +00:00
shokollm
636a41f41b fix(kugetsu): create session file before opencode fork
Create placeholder session file and add to index BEFORE running
opencode. This ensures we have a record even if opencode takes
long time or times out. Update with real session ID after fork.
2026-03-29 20:19:38 +00:00
shokollm
c51a886aa6 fix(kugetsu): capture forked session ID from opencode output
The --fork flag outputs the new session ID. Parse that instead of
relying on session list which may return wrong session when multiple
exist. Added fallback to session list parsing.
2026-03-29 20:16:51 +00:00
shokollm
7f3952ff9d test(kugetsu): add v2.0 test suite for issue-driven session management 2026-03-29 20:01:16 +00:00
shokollm
f2ab637d1f fix(kugetsu): update install script with new commands 2026-03-29 20:00:17 +00:00
shokollm
e014d7bfb9 fix(kugetsu): fix bash substitution error in cmd_start
The function call inside ${} syntax was invalid. Changed to use
command substitution $(...) instead.
2026-03-29 19:53:42 +00:00
shokollm
7146e3bd92 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
2026-03-29 19:51:51 +00:00
4 changed files with 759 additions and 485 deletions

View File

@@ -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 <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? |
|-------|---------|------------|
| `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/<session_id>/`:
- `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 `<session_id>` `<message>`
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 <session_id> <message>`
### 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 `<issue-ref>` `<message>` [--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 `<session_id>` [message]
Resume an interrupted session:
- Forks new session from base
- 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
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 `<session_id>`
Stop a session gracefully:
- Looks up session file from index
- Uses `opencode run --continue --session <opencode-session-id> "<message>"`
### kugetsu list
List all tracked sessions:
```bash
kugetsu stop mytask
kugetsu list
```
- Sends SIGTERM to process
- Sets state to `idle`
### kugetsu destroy `<session_id>` [-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 `<issue-ref>` [-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 <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
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 <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}"
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 <session_id> <message> [--debug] Start a new session
kugetsu list [--all] List sessions (default: left only)
kugetsu resume <session_id> [message] [--debug] Resume a session
kugetsu stop <session_id> [--debug] Stop a session gracefully
kugetsu destroy <session_id> [-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 <issue-ref> <message> [--debug] Start task for issue (forks base session)
kugetsu continue <issue-ref> [message] [--debug] Continue existing task for issue
kugetsu list List all tracked sessions
kugetsu prune [--force] Remove orphaned sessions (keeps base)
kugetsu destroy <issue-ref> [-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,329 @@ 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
}
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
cmd_init() {
local force=false
while [ $# -gt 0 ]; do
case "$1" in
--all)
destroy_all=true
;;
--debug)
DEBUG_MODE=true
;;
-y|--yes)
--force)
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
shift
done
if [ "$destroy_all" = true ]; then
if [ -n "$session_id" ]; then
echo "Error: cannot specify session_id with --all" >&2
ensure_dirs
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
echo "Error: Base session already exists: $existing_base" >&2
echo "Use --force to reinitialize" >&2
exit 1
fi
fi
if [ "$force" = true ]; then
rm -rf "$SESSIONS_DIR"/*
echo "All sessions deleted"
return
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 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
echo "Delete ALL sessions? [y/N] "
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
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
local after_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort)
local new_session_id=""
while IFS= read -r sess; do
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
fi
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 <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
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
}
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
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 <session_id> 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
}
@@ -513,17 +494,20 @@ main() {
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 "$@"

View File

@@ -46,8 +46,14 @@ echo ""
echo "Or start a new shell."
echo ""
echo "Usage:"
echo " kugetsu start <session_id> <message> Start a new session"
echo " kugetsu list List sessions"
echo " kugetsu resume <session_id> [msg] Resume a session"
echo " kugetsu stop <session_id> Stop a session"
echo " kugetsu help Show help"
echo " kugetsu init Initialize base session (requires TTY)"
echo " kugetsu start <issue-ref> <message> Start task for issue"
echo " kugetsu continue <issue-ref> [msg] Continue existing task"
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 ""
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