From ef4c3d9080a01f32b83e0b6e472ec2ca2e469181 Mon Sep 17 00:00:00 2001 From: shokollm <270575765+shokollm@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:20:49 +0000 Subject: [PATCH] feat(kugetsu): cmd_delegate calls cmd_start directly for parallelization (fixes #75) - Add extract_issue_ref_from_message() to parse issue ref from delegation message - Modify cmd_delegate to call cmd_start directly when issue ref is found - Falls back to PM agent if no issue ref can be extracted - Enables parallel execution by bypassing PM for issue-based tasks --- kugetsu | 1225 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1225 insertions(+) create mode 100755 kugetsu diff --git a/kugetsu b/kugetsu new file mode 100755 index 0000000..6e03ac0 --- /dev/null +++ b/kugetsu @@ -0,0 +1,1225 @@ +#!/bin/bash +set -euo pipefail + +KUGETSU_DIR="${KUGETSU_DIR:-$HOME/.kugetsu}" +SESSIONS_DIR="$KUGETSU_DIR/sessions" +WORKTREES_DIR="$KUGETSU_DIR/worktrees" +REPOS_CONFIG="$KUGETSU_DIR/repos.json" +INDEX_FILE="$KUGETSU_DIR/index.json" +NOTIFICATIONS_FILE="$KUGETSU_DIR/notifications.json" +LOGS_DIR="$KUGETSU_DIR/logs" +MAX_CONCURRENT_AGENTS="${MAX_CONCURRENT_AGENTS:-3}" + +count_active_dev_sessions() { + local count=0 + if [ -d "$SESSIONS_DIR" ]; then + for session_file in "$SESSIONS_DIR"/*.json; do + if [ -f "$session_file" ]; then + local filename=$(basename "$session_file") + if [ "$filename" != "base.json" ]; then + count=$((count + 1)) + fi + fi + done + fi + echo "$count" +} + +usage() { + cat << 'EOF' +kugetsu - OpenCode Session Manager (Issue-Driven) + +Usage: + kugetsu init [--force] Initialize base + pm-agent sessions (requires TTY) + kugetsu start [--debug] Start task for issue (forks base session) + kugetsu continue [message] [--debug] Continue existing task for issue + kugetsu delegate Parse issue ref and call cmd_start directly (parallel) + kugetsu logs [n] Show recent delegation logs (default: 10) + kugetsu status Check kugetsu initialization status + kugetsu doctor [--fix] Diagnose and fix kugetsu issues + kugetsu notify [list|clear] Show or clear notifications + kugetsu list List all tracked sessions + kugetsu prune [--force] Remove orphaned sessions (keeps base + pm-agent) + kugetsu destroy [-y] Delete session for issue + kugetsu destroy --pm-agent [-y] Delete pm-agent session (not recommended) + kugetsu destroy --base [-y] Delete base session + kugetsu help Show this help + +Issue Ref Format: + instance/user/repo#number + Example: github.com/shoko/kugetsu#14 + +Commands: + init Create base + pm-agent sessions via TUI. Requires terminal access. + Use --force to reinitialize if sessions exist. + start Fork new session from base for specific issue. + Requires pm-agent to be running (created by init). + continue Continue work on existing issue session. + delegate Parse issue ref from message and call cmd_start directly. + Falls back to PM agent if no issue ref found. + logs Show recent delegation logs. + Default: 10 most recent. Use 'kugetsu logs 20' for more. + status Check if kugetsu is initialized and PM agent is active. + doctor Diagnose kugetsu issues. Use --fix to attempt repairs. + notify Show or clear notifications from PM agent. + Use 'kugetsu notify list' to see unread notifications. + list Show all sessions (base + pm-agent + forked issues). + prune Remove sessions not in index (orphaned from opencode). + Use --force to skip confirmation. + destroy Delete specific issue, pm-agent, or base session. + +Options: + --debug Show real-time debug output and capture to debug.log + +PM Context: + kugetsu reads ~/.kugetsu/pm-agent.md (if exists) and injects it + into the PM agent session at init time. This allows customizing PM + behavior without recreating the session. + +Notifications: + PM Agent writes task completion notifications to ~/.kugetsu/notifications.json + Use 'kugetsu notify list' to see unread notifications. + +Examples: + kugetsu init + kugetsu status + kugetsu delegate "work on issue #5" + kugetsu logs + kugetsu logs 20 + kugetsu doctor + kugetsu doctor --fix + kugetsu notify list + kugetsu notify clear + kugetsu start github.com/shoko/kugetsu#14 "fix bug" + kugetsu continue github.com/shoko/kugetsu#14 "add tests" + kugetsu list +EOF +} + +ensure_dirs() { + mkdir -p "$SESSIONS_DIR" +} + +ensure_worktree_dir() { + mkdir -p "$WORKTREES_DIR" +} + +issue_ref_to_worktree_name() { + local issue_ref="$1" + echo "$issue_ref" | sed 's/[\/:]/-/g' | sed 's/#/-/' +} + +issue_ref_to_worktree_path() { + local issue_ref="$1" + local worktree_name=$(issue_ref_to_worktree_name "$issue_ref") + echo "$WORKTREES_DIR/$worktree_name" +} + +issue_ref_to_branch_name() { + local issue_ref="$1" + local number_part=$(echo "$issue_ref" | grep -oE '#[0-9]+$' || echo "") + if [ -n "$number_part" ]; then + echo "fix/issue-${number_part#\#}" + else + local identifier=$(echo "$issue_ref" | grep -oE '#[^-]+$' || echo "") + if [ -n "$identifier" ]; then + local clean_id=$(echo "$identifier" | sed 's/^#//' | sed 's/-/_/g') + echo "fix/${clean_id}" + else + echo "fix/issue-temp" + fi + fi +} + +get_repo_url() { + local issue_ref="$1" + if [ -f "$REPOS_CONFIG" ]; then + local url=$(python3 -c "import json, sys; d=json.load(open('$REPOS_CONFIG')); print(d.get('$issue_ref', ''))" 2>/dev/null || echo "") + if [ -n "$url" ]; then + echo "$url" + return + fi + fi + local instance=$(echo "$issue_ref" | cut -d'/' -f1 | cut -d'#' -f1) + local owner=$(echo "$issue_ref" | sed 's/.*\/\([^/]*\)\/.*/\1/' | sed 's/#.*//') + local repo=$(echo "$issue_ref" | sed 's/.*\///' | sed 's/#.*//') + echo "https://${instance}/${owner}/${repo}.git" +} + +worktree_exists() { + local issue_ref="$1" + local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") + [ -d "$worktree_path" ] +} + +create_worktree() { + local issue_ref="$1" + local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") + local branch_name=$(issue_ref_to_branch_name "$issue_ref") + local repo_url=$(get_repo_url "$issue_ref") + + if [ -z "$repo_url" ]; then + echo "Error: Cannot determine repo URL for '$issue_ref'" >&2 + echo "Please add to $REPOS_CONFIG or ensure worktree exists" >&2 + exit 1 + fi + + ensure_worktree_dir + + if worktree_exists "$issue_ref"; then + echo "Removing existing worktree at '$worktree_path'..." + git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path" + fi + + echo "Creating worktree at '$worktree_path'..." + git clone "$repo_url" "$worktree_path" 2>/dev/null || { + echo "Error: Failed to clone repository" >&2 + exit 1 + } + + echo "Creating branch '$branch_name'..." + (cd "$worktree_path" && git checkout -b "$branch_name" origin/main 2>/dev/null || git checkout -b "$branch_name" main 2>/dev/null) || { + echo "Warning: Could not checkout branch (may need to run from within worktree after session)" >&2 + } + + echo "Worktree created at: $worktree_path" +} + +remove_worktree_for_issue() { + local issue_ref="$1" + local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") + + if worktree_exists "$issue_ref"; then + echo "Removing worktree at '$worktree_path'..." + git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path" + fi +} + +get_worktree_path_for_session() { + local session_file="$1" + if [ -f "$session_file" ]; then + python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', ''))" 2>/dev/null || echo "" + else + echo "" + fi +} + +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, "pm_agent": null, "issues": {}}' + fi +} + +write_index() { + local base="$1" + local pm_agent="$2" + local issues_json="$3" + local temp_file="$INDEX_FILE.tmp.$$" + printf '{"base": %s, "pm_agent": %s, "issues": %s}\n' "$base" "$pm_agent" "$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_pm_agent_session_id() { + local index=$(read_index) + echo "$index" | python3 -c "import sys, json; d=json.load(sys.stdin); print(d.get('pm_agent') 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 pm_agent=$(get_pm_agent_session_id) + local issues_json=$(read_index | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d['issues']))") + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then + write_index "\"$base_session_id\"" "null" "$issues_json" + else + write_index "\"$base_session_id\"" "\"$pm_agent\"" "$issues_json" + fi +} + +set_pm_agent_in_index() { + local pm_agent_session_id="$1" + local base=$(get_base_session_id) + local issues_json=$(read_index | python3 -c "import sys, json; d=json.load(sys.stdin); print(json.dumps(d['issues']))") + if [ -z "$base" ] || [ "$base" = "null" ]; then + write_index "null" "\"$pm_agent_session_id\"" "$issues_json" + else + write_index "\"$base\"" "\"$pm_agent_session_id\"" "$issues_json" + fi +} + +add_issue_to_index() { + local issue_ref="$1" + local session_file="$2" + local index=$(read_index) + local base=$(get_base_session_id) + local pm_agent=$(get_pm_agent_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 [ -z "$base" ] || [ "$base" = "null" ]; then + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then + write_index "null" "null" "$new_issues" + else + write_index "null" "\"$pm_agent\"" "$new_issues" + fi + else + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then + write_index "\"$base\"" "null" "$new_issues" + else + write_index "\"$base\"" "\"$pm_agent\"" "$new_issues" + fi + fi +} + +remove_issue_from_index() { + local issue_ref="$1" + local index=$(read_index) + local base=$(get_base_session_id) + local pm_agent=$(get_pm_agent_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 [ -z "$base" ] || [ "$base" = "null" ]; then + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then + write_index "null" "null" "$new_issues" + else + write_index "null" "\"$pm_agent\"" "$new_issues" + fi + else + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ]; then + write_index "\"$base\"" "null" "$new_issues" + else + write_index "\"$base\"" "\"$pm_agent\"" "$new_issues" + fi + 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 +} + +check_opencode_session_exists() { + local session_id="$1" + opencode session list --format json 2>/dev/null | grep -q "\"$session_id\"" +} + +kugetsu_get_pm_context() { + local user_pm_context="${KUGETSU_DIR}/pm-agent.md" + local skill_pm_context="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../pm/SKILL.md" + + if [ -f "$user_pm_context" ]; then + cat "$user_pm_context" + elif [ -f "$skill_pm_context" ]; then + cat "$skill_pm_context" + else + echo "" + fi +} + +kugetsu_add_notification() { + local type="$1" + local message="$2" + local issue_ref="${3:-}" + local gitea_url="${4:-}" + + mkdir -p "$(dirname "$NOTIFICATIONS_FILE")" + + local notification=$(python3 << PYEOF +import json +import os +from datetime import datetime + +notification = { + "type": "$type", + "message": "$message", + "issue_ref": "$issue_ref" if "$issue_ref" else None, + "gitea_url": "$gitea_url" if "$gitea_url" else None, + "timestamp": datetime.now().isoformat(), + "read": False +} + +file_path = os.path.expanduser("$NOTIFICATIONS_FILE") +notifications = [] + +if os.path.exists(file_path): + try: + with open(file_path, 'r') as f: + notifications = json.load(f) + except: + notifications = [] + +notifications.append(notification) + +with open(file_path, 'w') as f: + json.dump(notifications, f, indent=2) + +print("Notification added") +PYEOF +) + echo "$notification" +} + +kugetsu_get_notifications() { + local limit="${1:-10}" + + if [ ! -f "$NOTIFICATIONS_FILE" ]; then + echo "[]" + return + fi + + python3 << PYEOF +import json +import os +from datetime import datetime + +file_path = os.path.expanduser("$NOTIFICATIONS_FILE") + +if not os.path.exists(file_path): + print("[]") + exit(0) + +try: + with open(file_path, 'r') as f: + notifications = json.load(f) + + unread = [n for n in notifications if not n.get("read", False)] + unread.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + + for n in unread[:$limit]: + ts = n.get("timestamp", "unknown") + ntype = n.get("type", "info") + msg = n.get("message", "") + issue = n.get("issue_ref", "") + gitea = n.get("gitea_url", "") + + print(f"[{ts}] {ntype}: {msg}") + if issue: + print(f" Issue: {issue}") + if gitea: + print(f" Link: {gitea}") + print() + + if not unread: + print("No unread notifications.") + +except Exception as e: + print(f"Error reading notifications: {e}") +PYEOF +} + +kugetsu_clear_notifications() { + if [ ! -f "$NOTIFICATIONS_FILE" ]; then + return + fi + + python3 << PYEOF +import json +import os + +file_path = os.path.expanduser("$NOTIFICATIONS_FILE") + +if not os.path.exists(file_path): + exit(0) + +try: + with open(file_path, 'r') as f: + notifications = json.load(f) + + for n in notifications: + n["read"] = True + + with open(file_path, 'w') as f: + json.dump(notifications, f, indent=2) + + print("Notifications marked as read") +except Exception as e: + print(f"Error: {e}") +PYEOF +} + +cmd_notify() { + local action="${1:-}" + + case "$action" in + ""|"list"|"show") + kugetsu_get_notifications 10 + ;; + "clear") + kugetsu_clear_notifications + ;; + *) + echo "Usage: kugetsu notify [list|clear]" + ;; + esac +} + +cmd_status() { + if [ ! -f "$INDEX_FILE" ]; then + echo "kugetsu_not_initialized" + return + fi + + local base=$(get_base_session_id) + local pm_agent=$(get_pm_agent_session_id) + + if [ -z "$base" ] || [ "$base" = "null" ]; then + echo "base_session_missing" + return + fi + + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ] || [ "$pm_agent" = "None" ]; then + echo "pm_agent_missing" + return + fi + + echo "ok" +} + +extract_issue_ref_from_message() { + local message="$1" + + if echo "$message" | grep -qE 'https?://[^/]+/[^/]+/[^/]+/issues/[0-9]+'; then + echo "$message" | grep -oE 'https?://[^/]+/[^/]+/[^/]+/issues/[0-9]+' | while read -r url; do + local instance=$(echo "$url" | grep -oE 'https?://[^/]+' | sed 's|https?://||') + local path=$(echo "$url" | grep -oE '/[^/]+/[^/]+/[^/]+') + local issue_num=$(echo "$url" | grep -oE '[0-9]+$') + echo "${instance}${path}#${issue_num}" + done + return + fi + + if echo "$message" | grep -qE '[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+#[0-9]+'; then + echo "$message" | grep -oE '[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/[a-zA-Z0-9_-]+/[a-zA-Z0-9_.-]+#[0-9]+' | head -1 + return + fi + + if echo "$message" | grep -qE '#[0-9]+'; then + local num=$(echo "$message" | grep -oE '#[0-9]+' | head -1) + if [ -f "$REPOS_CONFIG" ]; then + local first_repo=$(python3 -c "import json; d=json.load(open('$REPOS_CONFIG')); print(list(d.values())[0] if d else '')" 2>/dev/null || echo "") + if [ -n "$first_repo" ]; then + local owner_repo=$(echo "$first_repo" | sed 's|https\?://||' | sed 's|\.git$||') + echo "${owner_repo}${num}" + return + fi + fi + echo "github.com/unknown/repo${num}" + return + fi + + echo "" +} + +cmd_delegate() { + local message="${1:-}" + + if [ -z "$message" ]; then + echo "Error: message is required" >&2 + echo "Usage: kugetsu delegate " >&2 + exit 1 + fi + + local issue_ref=$(extract_issue_ref_from_message "$message") + + if [ -n "$issue_ref" ] && [[ "$issue_ref" =~ \#[0-9]+$ ]]; then + cmd_start "$issue_ref" "$message" + return + fi + + local pm_session=$(get_pm_agent_session_id) + if [ -z "$pm_session" ] || [ "$pm_session" = "null" ] || [ "$pm_session" = "None" ]; then + echo "Error: PM agent session not found. Run 'kugetsu init' first." >&2 + exit 1 + fi + + mkdir -p "$LOGS_DIR" + local log_file="$LOGS_DIR/delegate-$(date +%s).log" + nohup sh -c "GITEA_TOKEN='${GITEA_TOKEN:-4c85c4c92637b33230a1f550287e63a0d1cef7a0}' opencode run '$message' --continue --session '$pm_session' >> '$log_file' 2>&1" > /dev/null 2>&1 & + disown + echo "Delegated to PM agent (logged to $(basename "$log_file"))" +} + +cmd_logs() { + local count="${1:-10}" + + if [ ! -d "$LOGS_DIR" ]; then + echo "No logs found." + return + fi + + # Log rotation: delete logs older than 7 days + find "$LOGS_DIR" -type f -mtime +7 -delete 2>/dev/null + + ls -lt "$LOGS_DIR" | head -$((count + 1)) | tail -$count | while read line; do + echo "$line" + done +} + +cmd_doctor() { + local fix=false + + while [ $# -gt 0 ]; do + case "$1" in + --fix) + fix=true + ;; + *) + ;; + esac + shift + done + + echo "=== kugetsu doctor ===" + echo "" + + local issues=0 + + if [ ! -f "$INDEX_FILE" ]; then + echo "[ISSUE] kugetsu not initialized (index.json missing)" + issues=$((issues + 1)) + else + echo "[OK] kugetsu initialized" + + local base=$(get_base_session_id) + if [ -z "$base" ] || [ "$base" = "null" ]; then + echo "[ISSUE] Base session missing" + issues=$((issues + 1)) + else + echo "[OK] Base session: $base" + fi + + local pm_agent=$(get_pm_agent_session_id) + if [ -z "$pm_agent" ] || [ "$pm_agent" = "null" ] || [ "$pm_agent" = "None" ]; then + echo "[ISSUE] PM agent session missing" + issues=$((issues + 1)) + else + echo "[OK] PM agent: $pm_agent" + fi + + local pm_context_file="${KUGETSU_DIR}/pm-agent.md" + if [ -f "$pm_context_file" ]; then + echo "[OK] PM context file exists" + else + echo "[INFO] PM context file not found (optional): $pm_context_file" + fi + fi + + echo "" + if [ $issues -eq 0 ]; then + echo "No issues found." + else + echo "Found $issues issue(s)." + fi + + if [ "$fix" = true ] && [ $issues -gt 0 ]; then + echo "" + echo "Running fixes..." + + if [ ! -f "$INDEX_FILE" ]; then + echo "Cannot fix: not initialized. Run 'kugetsu init' first." + else + local pm_agent=$(get_pm_agent_session_id) + if [ -n "$pm_agent" ] && [ "$pm_agent" != "null" ] && [ "$pm_agent" != "None" ]; then + echo "[FIX] Recreating PM agent session..." + local base=$(get_base_session_id) + if [ -n "$base" ] && [ "$base" != "null" ]; then + rm -f "$SESSIONS_DIR/pm-agent.json" + + local before_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort) + local before_set="${before_sessions//$'\n'/|}" + + local pm_context=$(kugetsu_get_pm_context) + if [ -n "$pm_context" ]; then + opencode run "You are a PM (Project Manager) agent. Your role is to coordinate task delegation and review PRs. $pm_context" --fork --session "$base" 2>&1 || true + else + opencode run "You are a PM (Project Manager) agent. Your role is to coordinate task delegation and review PRs. Wait for instructions." --fork --session "$base" 2>&1 || true + fi + + local after_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort) + local new_pm_session_id="" + while IFS= read -r sess; do + if [[ ! "$before_set" =~ \|${sess}\| ]] && [[ "$sess" != "$base" ]]; then + new_pm_session_id="$sess" + break + fi + done <<< "$after_sessions" + + if [ -n "$new_pm_session_id" ]; then + printf '{"type": "pm_agent", "opencode_session_id": "%s", "created_at": "%s", "state": "idle"}\n' \ + "$new_pm_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/pm-agent.json" + set_pm_agent_in_index "$new_pm_session_id" + echo "[FIX] PM agent recreated: $new_pm_session_id" + else + echo "[FIX] Warning: Could not detect new PM session ID" + fi + else + echo "[FIX] Cannot recreate PM agent: base session missing" + fi + else + echo "[FIX] Cannot fix: PM agent not initialized. Run 'kugetsu init' first." + fi + fi + fi +} + +DEBUG_MODE=false + +set_debug_mode() { + DEBUG_MODE=false + local filtered_args=() + while [ $# -gt 0 ]; do + case "$1" in + --debug) + DEBUG_MODE=true + ;; + *) + filtered_args+=("$1") + ;; + esac + shift + done + echo "${filtered_args[@]}" +} + +cmd_init() { + local force=false + + while [ $# -gt 0 ]; do + case "$1" in + --force) + force=true + ;; + *) + ;; + esac + shift + done + + ensure_dirs + + local existing_base=$(get_base_session_id) + local existing_pm=$(get_pm_agent_session_id) + + if [ -n "$existing_base" ] && [ "$existing_base" != "null" ]; then + if [ "$force" = true ]; then + echo "Warning: Reinitializing sessions (force mode)" >&2 + else + echo "Error: Base session already exists: $existing_base" >&2 + echo "Use --force to reinitialize" >&2 + exit 1 + fi + fi + + 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" + + echo "" + echo "Creating PM agent session..." + sleep 1 + + local before_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort) + local before_set="${before_sessions//$'\n'/|}" + + local pm_context=$(kugetsu_get_pm_context) + local pm_prompt="You are a PM (Project Manager) agent. Your role is to coordinate task delegation and review PRs. Wait for instructions." + if [ -n "$pm_context" ]; then + pm_prompt="You are a PM (Project Manager) agent. Your role is to coordinate task delegation and review PRs. $pm_context" + fi + + # Set GIT_EDITOR to cat for non-interactive git operations (rebase, etc.) + export GIT_EDITOR=cat + export EDITOR=cat + + opencode run "$pm_prompt" --fork --session "$new_session_id" 2>&1 || true + + local after_sessions=$(opencode session list 2>/dev/null | grep -oP '^ses_\w+' | sort) + local new_pm_session_id="" + while IFS= read -r sess; do + if [[ ! "$before_set" =~ \|${sess}\| ]] && [[ "$sess" != "$new_session_id" ]]; then + new_pm_session_id="$sess" + break + fi + done <<< "$after_sessions" + + if [ -z "$new_pm_session_id" ]; then + echo "Warning: Could not detect PM agent session ID. It may still have been created." >&2 + else + local pm_session_file="pm-agent.json" + printf '{"type": "pm_agent", "opencode_session_id": "%s", "created_at": "%s", "state": "idle"}\n' \ + "$new_pm_session_id" "$(date -Iseconds)" > "$SESSIONS_DIR/$pm_session_file" + set_pm_agent_in_index "$new_pm_session_id" + echo "PM agent session initialized: $new_pm_session_id" + fi + + echo "" + echo "Initialization complete!" + echo "- Base session: $new_session_id" + echo "- PM agent: ${new_pm_session_id:-created by hermes}" +} + +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 and " >&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 pm_agent_session_id=$(get_pm_agent_session_id) + if [ -z "$pm_agent_session_id" ] || [ "$pm_agent_session_id" = "null" ]; then + echo "Error: No PM agent session. Run 'kugetsu init' first to create it." >&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 ' instead" >&2 + exit 1 + fi + + local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") + create_worktree "$issue_ref" + + 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'..." + + # Session-counting: count actual dev sessions, reject if at limit + local active_count=$(count_active_dev_sessions) + if [ "$active_count" -ge "$MAX_CONCURRENT_AGENTS" ]; then + echo "Error: Max concurrent agents ($MAX_CONCURRENT_AGENTS) reached" >&2 + echo "Active sessions: $active_count" >&2 + remove_worktree_for_issue "$issue_ref" + exit 1 + fi + + if [ "$DEBUG_MODE" = true ]; then + opencode run "$message" --fork --session "$base_session_id" --dir "$worktree_path" 2>&1 | tee "$SESSIONS_DIR/$session_file.debug.log" & + else + opencode run "$message" --fork --session "$base_session_id" --dir "$worktree_path" 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 + remove_worktree_for_issue "$issue_ref" + exit 1 + fi + + printf '{"type": "forked", "issue_ref": "%s", "opencode_session_id": "%s", "worktree_path": "%s", "created_at": "%s", "state": "idle"}\n' \ + "$issue_ref" "$new_session_id" "$worktree_path" "$(date -Iseconds)" > "$SESSIONS_DIR/$session_file" + + add_issue_to_index "$issue_ref" "$session_file" + + echo "Session started for '$issue_ref': $new_session_id" + echo "Worktree: $worktree_path" +} + +cmd_continue() { + local session_name="" + local message="" + local args=("$@") + + args=$(set_debug_mode "${args[@]}") + + for arg in $args; do + if [ -z "$session_name" ]; then + session_name="$arg" + elif [ -z "$message" ]; then + message="$arg" + fi + done + + if [ -z "$session_name" ]; then + echo "Error: continue requires " >&2 + exit 1 + fi + + if [ -z "$message" ]; then + echo "Error: continue requires " >&2 + exit 1 + fi + + local session_file=$(get_session_for_issue "$session_name") + if [ -z "$session_file" ] || [ "$session_file" = "null" ]; then + echo "Error: No session found for '$session_name'" >&2 + echo "Use 'kugetsu start ' 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 ' to recreate" >&2 + exit 1 + fi + + local opencode_session_id=$(python3 -c "import json; print(json.load(open('$session_path'))['opencode_session_id'])") + local worktree_path=$(python3 -c "import json; print(json.load(open('$session_path')).get('worktree_path', ''))" 2>/dev/null || echo "") + + echo "Continuing session for '$session_name'..." + # Note: --continue always allowed (existing sessions don't count toward limit) + if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then + echo "Using worktree: $worktree_path" + if [ "$DEBUG_MODE" = true ]; then + opencode run "$message" --continue --session "$opencode_session_id" --dir "$worktree_path" 2>&1 | tee "$session_path.debug.log" & + else + opencode run "$message" --continue --session "$opencode_session_id" --dir "$worktree_path" 2>&1 & + fi + else + if [ "$DEBUG_MODE" = true ]; then + opencode run "$message" --continue --session "$opencode_session_id" 2>&1 | tee "$session_path.debug.log" & + else + opencode run "$message" --continue --session "$opencode_session_id" 2>&1 & + fi + fi +} + +cmd_list() { + ensure_dirs + + printf "%-50s %-10s %-25s %-40s\n" "ISSUE_REF" "TYPE" "SESSION_ID" "WORKTREE" + printf "%-50s %-10s %-25s %-40s\n" "─────────" "─────" "──────────" "────────" + + local base_session_id=$(get_base_session_id) + if [ -n "$base_session_id" ] && [ "$base_session_id" != "null" ]; then + printf "%-50s %-10s %-25s %-40s\n" "(base)" "base" "$base_session_id" "N/A" + fi + + local pm_agent_session_id=$(get_pm_agent_session_id) + if [ -n "$pm_agent_session_id" ] && [ "$pm_agent_session_id" != "null" ]; then + local pm_created="N/A" + if [ -f "$SESSIONS_DIR/pm-agent.json" ]; then + pm_created=$(python3 -c "import json; print(json.load(open('$SESSIONS_DIR/pm-agent.json'))['created_at'])" 2>/dev/null || echo "N/A") + fi + printf "%-50s %-10s %-25s %-40s\n" "(pm-agent)" "pm_agent" "$pm_agent_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" ] || [ "$filename" = "pm-agent" ]; 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") + local worktree=$(python3 -c "import json; print(json.load(open('$session_file')).get('worktree_path', 'N/A'))" 2>/dev/null || echo "N/A") + + printf "%-50s %-10s %-25s %-40s\n" "$issue_ref" "forked" "$sess_id" "$worktree" + fi + done +} + +cmd_prune() { + local force=false + + while [ $# -gt 0 ]; do + case "$1" in + --force) + force=true + ;; + esac + shift + done + + ensure_dirs + ensure_worktree_dir + + 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'); sessions.add('pm-agent.json'); print('\n'.join(sessions))" 2>/dev/null || echo -e "base.json\npm-agent.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 + + local orphaned_worktrees=() + if [ -d "$WORKTREES_DIR" ]; then + for worktree_path in "$WORKTREES_DIR"/*; do + if [ -d "$worktree_path" ]; then + local worktree_name=$(basename "$worktree_path") + local session_name="${worktree_name}.json" + if ! echo "$index_session_files" | grep -q "^${session_name}$"; then + orphaned_worktrees+=("$worktree_path") + fi + fi + done + fi + + if [ ${#orphaned[@]} -eq 0 ] && [ ${#orphaned_worktrees[@]} -eq 0 ]; then + echo "No orphaned sessions or worktrees found" + return + fi + + if [ ${#orphaned[@]} -gt 0 ]; then + echo "Found ${#orphaned[@]} orphaned session(s):" + for f in "${orphaned[@]}"; do + echo " - $(basename "$f")" + done + fi + + if [ ${#orphaned_worktrees[@]} -gt 0 ]; then + echo "Found ${#orphaned_worktrees[@]} orphaned worktree(s):" + for wt in "${orphaned_worktrees[@]}"; do + echo " - $(basename "$wt")" + done + fi + + if [ "$force" = true ]; then + echo "Removing orphaned items (force mode)..." + for f in "${orphaned[@]}"; do + rm -f "$f" + echo "Removed session: $(basename "$f")" + done + for wt in "${orphaned_worktrees[@]}"; do + git worktree remove "$wt" 2>/dev/null || rm -rf "$wt" + echo "Removed worktree: $(basename "$wt")" + 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" + ;; + --pm-agent) + target="pm-agent" + ;; + -y|--yes) + force=true + ;; + *) + if [ -z "$target" ]; then + target="$1" + fi + ;; + esac + shift + done + + if [ -z "$target" ]; then + echo "Error: destroy requires , --base, or --pm-agent" >&2 + exit 1 + fi + + if [ "$target" = "base" ]; then + if [ "$force" = true ]; then + rm -f "$SESSIONS_DIR/base.json" + local pm_agent=$(get_pm_agent_session_id) + if [ -n "$pm_agent" ] && [ "$pm_agent" != "null" ]; then + rm -f "$SESSIONS_DIR/pm-agent.json" + echo '{"base": null, "pm_agent": null, "issues": {}}' > "$INDEX_FILE" + else + echo '{"base": null, "pm_agent": null, "issues": {}}' > "$INDEX_FILE" + fi + echo "Base session destroyed" + else + echo "Error: destroying base session requires --base -y" >&2 + exit 1 + fi + return + fi + + if [ "$target" = "pm-agent" ]; then + if [ "$force" = true ]; then + rm -f "$SESSIONS_DIR/pm-agent.json" + local base=$(get_base_session_id) + if [ -n "$base" ] && [ "$base" != "null" ]; then + write_index "\"$base\"" "null" "{}" + else + write_index "null" "null" "{}" + fi + echo "PM agent session destroyed" + else + echo "Error: destroying pm-agent session requires --pm-agent -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 + remove_worktree_for_issue "$target" + rm -f "$session_path" + remove_issue_from_index "$target" + echo "Session for '$target' destroyed" + else + echo "Delete session and worktree for '$target'? [y/N] " + local reply + read reply + if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then + remove_worktree_for_issue "$target" + rm -f "$session_path" + remove_issue_from_index "$target" + echo "Session for '$target' destroyed" + else + echo "Aborted" + fi + fi +} + +main() { + if [ $# -eq 0 ]; then + usage + exit 1 + fi + + local command="$1" + shift + + case "$command" in + help|--help|-h) + usage + ;; + init) + cmd_init "$@" + ;; + start) + cmd_start "$@" + ;; + continue) + cmd_continue "$@" + ;; + delegate) + cmd_delegate "$@" + ;; + logs) + shift + cmd_logs "$@" + ;; + status) + cmd_status + ;; + doctor) + cmd_doctor "$@" + ;; + notify) + cmd_notify "$@" + ;; + list) + cmd_list "$@" + ;; + prune) + cmd_prune "$@" + ;; + destroy) + cmd_destroy "$@" + ;; + *) + echo "Error: unknown command '$command'" >&2 + usage + exit 1 + ;; + esac +} + +main "$@" \ No newline at end of file