Compare commits

..

20 Commits

Author SHA1 Message Date
shokollm
6e9472b5e2 fix(kugetsu): detect session via DB query instead of opencode session list
opencode session list doesn't show sessions in ~/.kugetsu-worktrees/ directories.
This caused detection to fail even though sessions were being created.

Now we query the database directly for sessions matching the worktree path.
Also fixed database path in fix_session_permissions (was ~/.opencode/, should be ~/.local/share/opencode/).
2026-04-02 11:45:35 +00:00
shokollm
775f73348a fix(kugetsu): update forked session permissions after detection
Previously we only fixed base session permissions before forking.
But permissions are NOT inherited from parent to child.

Now we update the newly created session's permissions immediately
after detection, ensuring the forked session can access external
directories like ~/.kugetsu/worktrees/.
2026-04-02 11:15:27 +00:00
2e9081f4f5 Merge pull request 'fix(kugetsu): call fix_session_permissions before forking' (#109) from fix/prefork-permissions into main 2026-04-02 13:10:54 +02:00
shokollm
f7ac2f35fe fix(kugetsu): call fix_session_permissions before forking
- Call fix_session_permissions in cmd_start before forking to ensure
  base session has correct permissions for external_directory access
- Add debug logging to show forked session's directory and permissions
  after creation to help diagnose permission inheritance issues
2026-04-02 11:08:30 +00:00
97d7511e56 Merge pull request 'fix(kugetsu): session detection ordering bug and debugging' (#108) from fix/session-detection-v2 into main 2026-04-02 12:26:57 +02:00
shokollm
cd12a0cda8 fix(kugetsu): fix session detection ordering and add DB debugging
1. Move session detection BEFORE checking if fork process is still running.
   Previous code broke out of loop if forked process exited, skipping detection.

2. Add database query debugging when detection fails to help diagnose
   why opencode session list might miss newly created sessions.
2026-04-02 09:57:27 +00:00
ffdf5e34c8 Merge pull request 'fix(kugetsu): improve session detection in cmd_start with retry logic and logging' (#107) from fix/start-session-detection into main 2026-04-02 11:41:52 +02:00
shokollm
b3ac73a283 Merge origin/main into fix/start-session-detection
Resolve conflict: use cd approach for worktree, keep retry logic
2026-04-02 09:39:45 +00:00
shokollm
1128b3dfa8 fix(kugetsu): improve session detection in cmd_start with retry logic and logging
- Capture fork output to log file for debugging
- Track fork PID to detect if process exits early
- Retry session detection up to 10 seconds instead of 1 second
- Show fork log output when session creation fails
- Improve error message to indicate timeout
2026-04-02 09:29:30 +00:00
90f46a778a Merge pull request 'fix: use cd + worktree inside parent dir instead of --dir flag (fixes #105)' (#106) from fix/worktree-isolation-via-cd into main 2026-04-02 10:29:32 +02:00
shokollm
ede47439b0 fix: use cd + worktree inside parent dir instead of --dir flag
Issue #105: opencode run --fork/--continue --dir <path> fails to create sessions

Root cause: The --dir flag breaks session creation in opencode. Sessions
fail to be created when --dir is used with --fork or --continue.

Solution: Instead of using --dir flag, create worktrees inside the parent
session's directory and use 'cd $worktree_path && opencode run ...' to
change directory before running opencode.

Key changes:
- Worktrees now created at $PWD/.kugetsu-worktrees/{issue-ref}/ instead
  of $WORKTREES_DIR/{issue-ref}/
- .kugetsu-worktrees is a hidden directory (git ignored by default)
- cmd_start and cmd_continue now use 'cd && opencode run' instead of
  'opencode run --dir'

This approach works because:
1. Worktree is inside parent's directory tree (permission granted)
2. cd properly changes working directory before opencode runs
3. Session gets created with correct directory set
4. No .gitignore entry needed (. prefix makes it hidden from git)
2026-04-02 08:18:17 +00:00
a690788498 Merge pull request 'chore: documentation updates and quick fixes' (#104) from fix/documentation-and-quick-fixes into main 2026-04-02 06:07:13 +02:00
shokollm
5f841e6e4a chore: documentation updates and quick fixes
- Add missing command docs to SKILL.md (delegate, logs, status, doctor, notify, server, queue)
- Add KUGETSU_VERBOSITY to config options table
- Delete outdated test-kugetsu.sh (tests non-existent resume/stop commands)
- Fix Phase 3 status in kugetsu-architecture.md (was In Progress, now Implemented)
- Fix env rm quote handling (use grep -v instead of sed)
- Add error handling for opencode init command
2026-04-02 04:06:39 +00:00
3d6abdf678 Merge pull request 'feat(kugetsu): add KUGETSU_VERBOSITY for PM agent output control' (#103) from feat/issue-46-verbosity-v6 into main 2026-04-02 05:48:10 +02:00
shokollm
538d9fba80 feat(kugetsu): add KUGETSU_VERBOSITY with verbose/default/quiet modes 2026-04-02 03:47:40 +00:00
shokollm
1e69b1abc4 Revert "feat(kugetsu): add lock mechanism for worktree coordination"
This reverts commit d62ecb884e.
2026-04-02 03:26:39 +00:00
shokollm
dbfd8e7028 Revert "feat(kugetsu): add queue infrastructure for autonomous PM"
This reverts commit 21a32cd937.
2026-04-02 03:25:56 +00:00
shokollm
d62ecb884e feat(kugetsu): add lock mechanism for worktree coordination 2026-04-02 03:18:36 +00:00
shokollm
21a32cd937 feat(kugetsu): add queue infrastructure for autonomous PM 2026-04-02 03:18:28 +00:00
shokollm
785e4edad5 feat(kugetsu): add KUGETSU_VERBOSITY for PM agent output control (total|verbose|hybrid) 2026-04-02 03:18:03 +00:00
4 changed files with 236 additions and 532 deletions

View File

@@ -326,7 +326,7 @@ When a Coding Agent starts, it:
| Phase 1 | ✅ Complete | SSH + Tailscale remote access | | Phase 1 | ✅ Complete | SSH + Tailscale remote access |
| Phase 1b | ✅ Complete | Tailscale VPN setup | | Phase 1b | ✅ Complete | Tailscale VPN setup |
| Phase 2 | 📋 Planned | API Interface | | Phase 2 | 📋 Planned | API Interface |
| Phase 3 | 🔄 In Progress | Chat Integration (Telegram) | | Phase 3 | Implemented | Chat Integration (Telegram) |
| Phase 4 | 📋 Planned | Web Dashboard | | Phase 4 | 📋 Planned | Web Dashboard |
### 6.2 Current Implementation ### 6.2 Current Implementation

View File

@@ -48,6 +48,7 @@ A default config file is created during `kugetsu init` with commented examples:
|----------|---------|-------------| |----------|---------|-------------|
| `MAX_CONCURRENT_AGENTS` | 3 | Maximum number of concurrent dev agents | | `MAX_CONCURRENT_AGENTS` | 3 | Maximum number of concurrent dev agents |
| `KUGETSU_TEMP_DIR` | `~/.local/share/opencode/tool-output` | Temp directory for subagent tool output (useful in headless environments where /tmp is restricted) | | `KUGETSU_TEMP_DIR` | `~/.local/share/opencode/tool-output` | Temp directory for subagent tool output (useful in headless environments where /tmp is restricted) |
| `KUGETSU_VERBOSITY` | `default` | PM agent verbosity level: `verbose`, `default`, or `quiet` |
### Environment Variables for Agents ### Environment Variables for Agents
@@ -255,6 +256,90 @@ kugetsu destroy --base -y
**Note**: Destroying base also destroys PM agent since PM depends on base. **Note**: Destroying base also destroys PM agent since PM depends on base.
### kugetsu delegate `<message>`
Send a message to the PM agent for task coordination (fire-and-forget):
```bash
kugetsu delegate "work on issue #14"
kugetsu delegate "review PR #92"
```
- Non-blocking: returns immediately, runs in background
- PM agent processes the message asynchronously
- Uses `KUGETSU_VERBOSITY` env var to control PM agent output verbosity
- Log output stored in `~/.kugetsu/logs/delegate-<timestamp>.log`
### kugetsu logs [n]
Show recent delegation logs:
```bash
kugetsu logs # Show last 10 logs
kugetsu logs 20 # Show last 20 logs
```
- Logs are stored in `~/.kugetsu/logs/`
- Automatically deletes logs older than 7 days
### kugetsu status
Check if kugetsu is properly initialized:
```bash
kugetsu status
```
Output:
- `kugetsu_not_initialized` - No index file
- `base_session_missing` - Base session not found
- `pm_agent_missing` - PM agent not found
- `ok` - Everything is initialized
### kugetsu doctor [--fix]
Diagnose and fix kugetsu issues:
```bash
kugetsu doctor # Show diagnostic info
kugetsu doctor --fix # Attempt automatic repairs
```
- Checks index file existence
- Validates base and PM agent sessions
- With `--fix`: recreates PM agent if missing
- With `--fix-permissions`: fixes session permissions in opencode database
### kugetsu notify [list|clear]
Show or clear notifications from PM agent:
```bash
kugetsu notify list # Show unread notifications (default)
kugetsu notify clear # Mark all as read
```
- PM agent writes task completion notifications to `~/.kugetsu/notifications.json`
- Shows timestamp, type, message, and issue ref for each notification
### kugetsu server <list|add|remove|default|get>
Manage git server configurations:
```bash
kugetsu server list # List all configured servers
kugetsu server add github https://github.com # Add a server
kugetsu server remove gitlab # Remove a server
kugetsu server default github # Set default server
kugetsu server get github # Get server URL
```
### kugetsu queue <list|enqueue|dequeue|clear>
Manage task queue for autonomous PM operation:
```bash
kugetsu queue list # Show queued tasks
kugetsu queue enqueue "task" # Add task to queue
kugetsu queue dequeue # Remove next task from queue
kugetsu queue clear # Clear all queued tasks
```
- Queue stored in `~/.kugetsu/queue.json`
## Workflow Example ## Workflow Example
```bash ```bash

View File

@@ -9,10 +9,10 @@ INDEX_FILE="$KUGETSU_DIR/index.json"
NOTIFICATIONS_FILE="$KUGETSU_DIR/notifications.json" NOTIFICATIONS_FILE="$KUGETSU_DIR/notifications.json"
LOGS_DIR="$KUGETSU_DIR/logs" LOGS_DIR="$KUGETSU_DIR/logs"
ENV_DIR="${ENV_DIR:-$KUGETSU_DIR/env}" ENV_DIR="${ENV_DIR:-$KUGETSU_DIR/env}"
QUEUE_FILE="$KUGETSU_DIR/queue.json" VERBOSITY_DIR="$KUGETSU_DIR/verbosity"
LOCKS_DIR="$KUGETSU_DIR/locks"
MAX_CONCURRENT_AGENTS="${MAX_CONCURRENT_AGENTS:-3}" MAX_CONCURRENT_AGENTS="${MAX_CONCURRENT_AGENTS:-3}"
POLL_INTERVAL="${POLL_INTERVAL:-600}" KUGETSU_VERBOSITY="${KUGETSU_VERBOSITY:-default}"
# Load user config overrides (~/.kugetsu/config) # Load user config overrides (~/.kugetsu/config)
if [ -f "$KUGETSU_DIR/config" ]; then if [ -f "$KUGETSU_DIR/config" ]; then
@@ -59,87 +59,6 @@ count_active_dev_sessions() {
echo "$count" echo "$count"
} }
acquire_lock() {
local issue_ref="$1"
local session_id="$2"
local lock_file="$LOCKS_DIR/${issue_ref//[^a-zA-Z0-9._-]/_}.lock"
mkdir -p "$LOCKS_DIR"
if [ -f "$lock_file" ]; then
local lock_pid=$(cat "$lock_file" 2>/dev/null | cut -d: -f1)
local lock_session=$(cat "$lock_file" 2>/dev/null | cut -d: -f2)
if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then
echo "Stale lock detected, removing..."
rm -f "$lock_file"
elif [ "$lock_session" = "$session_id" ]; then
echo "Already holding lock for $issue_ref"
return 0
else
echo "Error: $issue_ref is locked by session $lock_session (PID $lock_pid)" >&2
return 1
fi
fi
echo "${BASHPID}:${session_id}:$(date +%s)" > "$lock_file"
echo "Lock acquired for $issue_ref: $lock_file"
return 0
}
release_lock() {
local issue_ref="$1"
local lock_file="$LOCKS_DIR/${issue_ref//[^a-zA-Z0-9._-]/_}.lock"
if [ ! -f "$lock_file" ]; then
return 0
fi
local lock_pid=$(cat "$lock_file" 2>/dev/null | cut -d: -f1)
if [ "$lock_pid" = "$BASHPID" ]; then
rm -f "$lock_file"
echo "Lock released for $issue_ref"
else
echo "Error: Cannot release lock held by PID $lock_pid (current: $BASHPID)" >&2
return 1
fi
}
release_all_locks() {
local session_id="$1"
if [ ! -d "$LOCKS_DIR" ]; then
return 0
fi
for lock_file in "$LOCKS_DIR"/*.lock; do
[ -f "$lock_file" ] || continue
local lock_session=$(cat "$lock_file" 2>/dev/null | cut -d: -f2)
if [ "$lock_session" = "$session_id" ]; then
rm -f "$lock_file"
echo "Released stale lock: $(basename "$lock_file")"
fi
done
}
check_lock() {
local issue_ref="$1"
local lock_file="$LOCKS_DIR/${issue_ref//[^a-zA-Z0-9._-]/_}.lock"
if [ -f "$lock_file" ]; then
local lock_pid=$(cat "$lock_file" 2>/dev/null | cut -d: -f1)
local lock_session=$(cat "$lock_file" 2>/dev/null | cut -d: -f2)
if [ -n "$lock_pid" ] && ! kill -0 "$lock_pid" 2>/dev/null; then
echo "Stale lock detected, removing..."
rm -f "$lock_file"
return 1
fi
echo "Locked by session $lock_session (PID $lock_pid)"
return 0
fi
return 1
}
usage() { usage() {
cat << 'EOF' cat << 'EOF'
kugetsu - OpenCode Session Manager (Issue-Driven) kugetsu - OpenCode Session Manager (Issue-Driven)
@@ -227,8 +146,9 @@ issue_ref_to_worktree_name() {
issue_ref_to_worktree_path() { issue_ref_to_worktree_path() {
local issue_ref="$1" local issue_ref="$1"
local parent_dir="${2:-$WORKTREES_DIR}"
local worktree_name=$(issue_ref_to_worktree_name "$issue_ref") local worktree_name=$(issue_ref_to_worktree_name "$issue_ref")
echo "$WORKTREES_DIR/$worktree_name" echo "$parent_dir/.kugetsu-worktrees/$worktree_name"
} }
issue_ref_to_branch_name() { issue_ref_to_branch_name() {
@@ -276,13 +196,15 @@ get_repo_url() {
worktree_exists() { worktree_exists() {
local issue_ref="$1" local issue_ref="$1"
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") local parent_dir="${2:-$PWD}"
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$parent_dir")
[ -d "$worktree_path" ] [ -d "$worktree_path" ]
} }
create_worktree() { create_worktree() {
local issue_ref="$1" local issue_ref="$1"
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") local parent_dir="${2:-$PWD}"
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$parent_dir")
local branch_name=$(issue_ref_to_branch_name "$issue_ref") local branch_name=$(issue_ref_to_branch_name "$issue_ref")
local repo_url=$(get_repo_url "$issue_ref") local repo_url=$(get_repo_url "$issue_ref")
@@ -292,9 +214,10 @@ create_worktree() {
exit 1 exit 1
fi fi
ensure_worktree_dir local worktree_parent_dir=$(dirname "$worktree_path")
mkdir -p "$worktree_parent_dir"
if worktree_exists "$issue_ref"; then if worktree_exists "$issue_ref" "$parent_dir"; then
echo "Removing existing worktree at '$worktree_path'..." echo "Removing existing worktree at '$worktree_path'..."
git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path" git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path"
fi fi
@@ -315,9 +238,10 @@ create_worktree() {
remove_worktree_for_issue() { remove_worktree_for_issue() {
local issue_ref="$1" local issue_ref="$1"
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") local parent_dir="${2:-$PWD}"
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$parent_dir")
if worktree_exists "$issue_ref"; then if worktree_exists "$issue_ref" "$parent_dir"; then
echo "Removing worktree at '$worktree_path'..." echo "Removing worktree at '$worktree_path'..."
git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path" git worktree remove "$worktree_path" 2>/dev/null || rm -rf "$worktree_path"
fi fi
@@ -629,8 +553,62 @@ cmd_status() {
echo "ok" echo "ok"
} }
get_verbosity_context() {
local verbosity="${KUGETSU_VERBOSITY:-default}"
local verbosity_file="$VERBOSITY_DIR/${verbosity}.md"
if [ -f "$verbosity_file" ]; then
cat "$verbosity_file"
else
echo "## Verbosity: $verbosity"
fi
}
init_verbosity_templates() {
mkdir -p "$VERBOSITY_DIR"
if [ ! -f "$VERBOSITY_DIR/verbose.md" ]; then
cat > "$VERBOSITY_DIR/verbose.md" << 'EOF'
## Verbosity: Verbose
You are operating in HIGH verbosity mode. Include ALL available context:
- Full command outputs and their results
- Detailed reasoning and thinking process
- All file changes with diffs when relevant
- Complete log excerpts
- Comprehensive status updates
- Ask clarifying questions when uncertain
EOF
fi
if [ ! -f "$VERBOSITY_DIR/default.md" ]; then
cat > "$VERBOSITY_DIR/default.md" << 'EOF'
## Verbosity: Default
You are operating in NORMAL verbosity mode. Provide balanced output:
- Standard command outputs and key results
- Moderate reasoning detail
- Important file changes summarized
- Regular status updates
EOF
fi
if [ ! -f "$VERBOSITY_DIR/quiet.md" ]; then
cat > "$VERBOSITY_DIR/quiet.md" << 'EOF'
## Verbosity: Quiet
You are operating in QUIET verbosity mode. Keep output minimal:
- Only essential information
- Brief status updates (1-2 sentences)
- Final decisions only
- Yes/No answers when appropriate
EOF
fi
}
cmd_delegate() { cmd_delegate() {
local message="${1:-}" local message="${1:-}"
local verbosity="${KUGETSU_VERBOSITY:-default}"
if [ -z "$message" ]; then if [ -z "$message" ]; then
echo "Error: message is required" >&2 echo "Error: message is required" >&2
@@ -650,7 +628,7 @@ cmd_delegate() {
local temp_dir="${KUGETSU_TEMP_DIR:-$HOME/.local/share/opencode/tool-output}" local temp_dir="${KUGETSU_TEMP_DIR:-$HOME/.local/share/opencode/tool-output}"
mkdir -p "$ENV_DIR" mkdir -p "$ENV_DIR"
local env_sh="set -a; export KUGETSU_TEMP_DIR='$temp_dir'; " local env_sh="set -a; export KUGETSU_TEMP_DIR='$temp_dir'; export KUGETSU_VERBOSITY='$verbosity'; "
if [ -f "$ENV_DIR/pm-agent.env" ]; then if [ -f "$ENV_DIR/pm-agent.env" ]; then
env_sh="${env_sh}source '$ENV_DIR/pm-agent.env'; " env_sh="${env_sh}source '$ENV_DIR/pm-agent.env'; "
elif [ -f "$ENV_DIR/default.env" ]; then elif [ -f "$ENV_DIR/default.env" ]; then
@@ -661,6 +639,7 @@ cmd_delegate() {
nohup sh -c "${env_sh}opencode run '$message' --continue --session '$pm_session' >> '$log_file' 2>&1" > /dev/null 2>&1 & nohup sh -c "${env_sh}opencode run '$message' --continue --session '$pm_session' >> '$log_file' 2>&1" > /dev/null 2>&1 &
disown disown
echo "Delegated to PM agent (logged to $(basename "$log_file"))" echo "Delegated to PM agent (logged to $(basename "$log_file"))"
echo "Verbosity: $verbosity"
} }
cmd_logs() { cmd_logs() {
@@ -679,116 +658,6 @@ cmd_logs() {
done done
} }
init_queue() {
if [ ! -f "$QUEUE_FILE" ]; then
cat > "$QUEUE_FILE" << 'EOF'
{
"dev_followups": [],
"user_interrupts": [],
"background": []
}
EOF
fi
}
cmd_queue() {
local action="${1:-}"
init_queue
case "$action" in
""|"list")
local total=0
echo "Queue status:"
for tier in dev_followups user_interrupts background; do
local count=$(python3 -c "import json; d=json.load(open('$QUEUE_FILE')); print(len(d.get('$tier', [])))" 2>/dev/null || echo 0)
total=$((total + count))
if [ "$count" -eq 0 ]; then
echo " $tier (0): (empty)"
else
echo " $tier ($count):"
python3 -c "import json, sys; d=json.load(open('$QUEUE_FILE')); [print(f' [{t[\"id\"]}] {t[\"message\"][:60]}') for t in d.get('$tier', [])]" 2>/dev/null || echo " (error reading)"
fi
done
echo "Total queued: $total"
;;
"enqueue")
local tier="${2:-}"
local message="${3:-}"
if [ -z "$tier" ] || [ -z "$message" ]; then
echo "Usage: kugetsu queue enqueue <tier> <message>" >&2
echo " tier: dev_followups, user_interrupts, or background" >&2
exit 1
fi
if [[ ! "$tier" =~ ^(dev_followups|user_interrupts|background)$ ]]; then
echo "Error: Invalid tier '$tier'" >&2
echo " Valid tiers: dev_followups, user_interrupts, background" >&2
exit 1
fi
local id="qe-$(date +%s)-$$"
python3 << EOF
import json
with open('$QUEUE_FILE', 'r') as f:
d = json.load(f)
d.setdefault('$tier', []).append({
'id': '$id',
'message': '$message',
'created': '$(date -Iseconds)'
})
with open('$QUEUE_FILE', 'w') as f:
json.dump(d, f, indent=2)
print('Enqueued to $tier: [$id] $message')
EOF
;;
"dequeue")
local tier="${2:-}"
local result=$(python3 << EOF
import json
with open('$QUEUE_FILE', 'r') as f:
d = json.load(f)
tiers = ['dev_followups', 'user_interrupts', 'background'] if not '$tier' else ['$tier']
for t in tiers:
if d.get(t) and len(d[t]) > 0:
task = d[t].pop(0)
with open('$QUEUE_FILE', 'w') as f:
json.dump(d, f, indent=2)
print(f'{t}|{task["id"]}|{task["message"]}')
break
else:
print('Queue empty')
EOF
)
if [ "$result" = "Queue empty" ]; then
echo "$result"
exit 1
fi
echo "$result"
;;
"clear")
cat > "$QUEUE_FILE" << 'EOF'
{
"dev_followups": [],
"user_interrupts": [],
"background": []
}
EOF
echo "Queue cleared"
;;
*)
echo "Usage: kugetsu queue <list|enqueue|dequeue|clear>" >&2
echo "" >&2
echo "Commands:" >&2
echo " list Show queue status" >&2
echo " enqueue <tier> <msg> Add task to queue" >&2
echo " dequeue [tier] Remove and return next task" >&2
echo " clear Clear all queued tasks" >&2
echo "" >&2
echo "Tiers: dev_followups, user_interrupts, background" >&2
exit 1
;;
esac
}
cmd_env() { cmd_env() {
local action="${1:-}" local action="${1:-}"
local agent_type="${2:-}" local agent_type="${2:-}"
@@ -871,7 +740,7 @@ cmd_env() {
fi fi
local file="$ENV_DIR/${target}.env" local file="$ENV_DIR/${target}.env"
if [ -f "$file" ]; then if [ -f "$file" ]; then
sed -i "/^${key}=/d" "$file" grep -v "^${key}=" "$file" > "$file.tmp" && mv "$file.tmp" "$file"
echo "Removed $key from ${target}.env" echo "Removed $key from ${target}.env"
fi fi
;; ;;
@@ -1007,7 +876,7 @@ cmd_doctor() {
} }
fix_session_permissions() { fix_session_permissions() {
local opencode_db="${OPENCODE_DB:-$HOME/.opencode/opencode.db}" local opencode_db="${OPENCODE_DB:-$HOME/.local/share/opencode/opencode.db}"
if [ ! -f "$opencode_db" ]; then if [ ! -f "$opencode_db" ]; then
echo "[ERROR] opencode database not found: $opencode_db" echo "[ERROR] opencode database not found: $opencode_db"
@@ -1184,6 +1053,9 @@ cmd_init() {
# Max concurrent dev agents (default: 3) # Max concurrent dev agents (default: 3)
# MAX_CONCURRENT_AGENTS=5 # MAX_CONCURRENT_AGENTS=5
# Verbosity level for PM agent output (verbose, default, or quiet)
# KUGETSU_VERBOSITY=default
# Git server configurations # Git server configurations
# Format: GIT_SERVERS["hostname"]="https://hostname" # Format: GIT_SERVERS["hostname"]="https://hostname"
# Add servers with: kugetsu server add <name> <url> # Add servers with: kugetsu server add <name> <url>
@@ -1239,7 +1111,11 @@ EOF
echo "Press Ctrl+C to cancel or wait for session to be created" echo "Press Ctrl+C to cancel or wait for session to be created"
sleep 2 sleep 2
opencode if ! opencode; then
echo "Error: opencode TUI failed to start" >&2
echo "Please ensure opencode is installed and accessible" >&2
exit 1
fi
local session_ids=$(opencode session list 2>/dev/null | grep -E '^ses_' | awk '{print $1}' | tail -1) local session_ids=$(opencode session list 2>/dev/null | grep -E '^ses_' | awk '{print $1}' | tail -1)
if [ -z "$session_ids" ]; then if [ -z "$session_ids" ]; then
@@ -1344,25 +1220,12 @@ cmd_start() {
exit 1 exit 1
fi fi
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref") local parent_dir="$PWD"
local worktree_path=$(issue_ref_to_worktree_path "$issue_ref" "$parent_dir")
trap 'release_lock "$issue_ref" 2>/dev/null; exit' EXIT INT TERM create_worktree "$issue_ref" "$parent_dir"
if ! acquire_lock "$issue_ref" "$base_session_id"; then
echo "Error: Could not acquire lock for '$issue_ref'" >&2
exit 1
fi
create_worktree "$issue_ref"
local session_file="$(issue_ref_to_filename "$issue_ref").json" local session_file="$(issue_ref_to_filename "$issue_ref").json"
# Get list of sessions before fork to compare against after
declare -a before_sessions=()
while IFS= read -r sess; do
before_sessions+=("$sess")
done < <(opencode session list 2>/dev/null | grep -oP '^ses_\w+')
echo "Forking session for '$issue_ref'..." echo "Forking session for '$issue_ref'..."
# Session-counting: count actual dev sessions, reject if at limit # Session-counting: count actual dev sessions, reject if at limit
@@ -1370,48 +1233,88 @@ cmd_start() {
if [ "$active_count" -ge "$MAX_CONCURRENT_AGENTS" ]; then if [ "$active_count" -ge "$MAX_CONCURRENT_AGENTS" ]; then
echo "Error: Max concurrent agents ($MAX_CONCURRENT_AGENTS) reached" >&2 echo "Error: Max concurrent agents ($MAX_CONCURRENT_AGENTS) reached" >&2
echo "Active sessions: $active_count" >&2 echo "Active sessions: $active_count" >&2
remove_worktree_for_issue "$issue_ref" remove_worktree_for_issue "$issue_ref" "$parent_dir"
exit 1 exit 1
fi fi
local fork_log="$SESSIONS_DIR/$session_file.fork.log"
local opencode_db="${OPENCODE_DB:-$HOME/.local/share/opencode/opencode.db}"
fix_session_permissions
if [ "$DEBUG_MODE" = true ]; then 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" & (cd "$worktree_path" && opencode run "$message" --fork --session "$base_session_id" 2>&1) | tee "$fork_log" &
else else
opencode run "$message" --fork --session "$base_session_id" --dir "$worktree_path" 2>&1 & (cd "$worktree_path" && opencode run "$message" --fork --session "$base_session_id" 2>&1) >> "$fork_log" &
fi fi
# Wait briefly for session to be created local fork_pid=$!
local max_attempts=10
local attempt=1
local new_session_id=""
local fork_log_output=""
while [ $attempt -le $max_attempts ]; do
sleep 1 sleep 1
# Find the new session by comparing before/after lists new_session_id=$(python3 -c "
# Skip any session that existed before the fork and skip base/pm-agent import sqlite3
local new_session_id="" conn = sqlite3.connect('$opencode_db')
while IFS= read -r sess; do cursor = conn.cursor()
# Skip base and pm-agent cursor.execute(\"SELECT id FROM session WHERE directory = '$worktree_path' ORDER BY time_created DESC LIMIT 1\")
[ "$sess" = "$base_session_id" ] && continue result = cursor.fetchone()
[ "$sess" = "$pm_agent_session_id" ] && continue if result:
print(result[0])
" 2>/dev/null || echo "")
# Check if this session existed before if [ -n "$new_session_id" ] && [ "$new_session_id" != "$base_session_id" ] && [ "$new_session_id" != "$pm_agent_session_id" ]; then
local existed_before=false
for before_sess in "${before_sessions[@]}"; do
if [ "$sess" = "$before_sess" ]; then
existed_before=true
break break
fi fi
if ! kill -0 $fork_pid 2>/dev/null; then
fork_log_output=$(tail -20 "$fork_log" 2>/dev/null || echo "(log empty or unavailable)")
break
fi
attempt=$((attempt + 1))
done done
if [ "$existed_before" = false ]; then
new_session_id="$sess"
break
fi
done < <(opencode session list 2>/dev/null | grep -oP '^ses_\w+')
if [ -z "$new_session_id" ]; then if [ -z "$new_session_id" ]; then
echo "Error: Could not find newly created session" >&2 echo "Error: Could not find newly created session after ${max_attempts}s" >&2
if [ -n "$fork_log_output" ]; then
echo "Fork log output:" >&2
echo "$fork_log_output" >&2
fi
remove_worktree_for_issue "$issue_ref" remove_worktree_for_issue "$issue_ref"
exit 1 exit 1
fi fi
echo "Updating permissions for new session: $new_session_id"
python3 -c "
import sqlite3
conn = sqlite3.connect('$opencode_db')
cursor = conn.cursor()
PERMISSION_JSON = '[{\"permission\":\"question\",\"pattern\":\"*\",\"action\":\"deny\"},{\"permission\":\"plan_enter\",\"pattern\":\"*\",\"action\":\"deny\"},{\"permission\":\"plan_exit\",\"pattern\":\"*\",\"action\":\"deny\"},{\"permission\":\"external_directory\",\"pattern\":\"*\",\"action\":\"allow\"}]'
cursor.execute('UPDATE session SET permission = ? WHERE id = ?', (PERMISSION_JSON, '$new_session_id'))
conn.commit()
print('[OK] Session permissions updated')
"
if [ "$DEBUG_MODE" = true ]; then
echo "[DEBUG] Forked session permissions check:"
python3 -c "
import sqlite3
conn = sqlite3.connect('$opencode_db')
cursor = conn.cursor()
cursor.execute(\"SELECT id, directory, permission FROM session WHERE id = '$new_session_id'\")
for row in cursor.fetchall():
print(' ID:', row[0])
print(' Directory:', row[1])
print(' Permission:', row[2])
" 2>/dev/null || echo " (failed to query DB)"
fi
printf '{"type": "forked", "issue_ref": "%s", "opencode_session_id": "%s", "worktree_path": "%s", "created_at": "%s", "state": "idle"}\n' \ 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" "$issue_ref" "$new_session_id" "$worktree_path" "$(date -Iseconds)" > "$SESSIONS_DIR/$session_file"
@@ -1419,8 +1322,6 @@ cmd_start() {
echo "Session started for '$issue_ref': $new_session_id" echo "Session started for '$issue_ref': $new_session_id"
echo "Worktree: $worktree_path" echo "Worktree: $worktree_path"
release_lock "$issue_ref"
} }
cmd_continue() { cmd_continue() {
@@ -1470,9 +1371,9 @@ cmd_continue() {
if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then if [ -n "$worktree_path" ] && [ -d "$worktree_path" ]; then
echo "Using worktree: $worktree_path" echo "Using worktree: $worktree_path"
if [ "$DEBUG_MODE" = true ]; then if [ "$DEBUG_MODE" = true ]; then
opencode run "$message" --continue --session "$opencode_session_id" --dir "$worktree_path" 2>&1 | tee "$session_path.debug.log" & (cd "$worktree_path" && opencode run "$message" --continue --session "$opencode_session_id" 2>&1) | tee "$session_path.debug.log" &
else else
opencode run "$message" --continue --session "$opencode_session_id" --dir "$worktree_path" 2>&1 & (cd "$worktree_path" && opencode run "$message" --continue --session "$opencode_session_id" 2>&1) &
fi fi
else else
if [ "$DEBUG_MODE" = true ]; then if [ "$DEBUG_MODE" = true ]; then
@@ -1674,7 +1575,6 @@ cmd_destroy() {
if [ "$force" = true ]; then if [ "$force" = true ]; then
remove_worktree_for_issue "$target" remove_worktree_for_issue "$target"
release_lock "$target" 2>/dev/null || true
rm -f "$session_path" rm -f "$session_path"
remove_issue_from_index "$target" remove_issue_from_index "$target"
echo "Session for '$target' destroyed" echo "Session for '$target' destroyed"
@@ -1684,7 +1584,6 @@ cmd_destroy() {
read reply read reply
if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then if [ "$reply" = "y" ] || [ "$reply" = "Y" ]; then
remove_worktree_for_issue "$target" remove_worktree_for_issue "$target"
release_lock "$target" 2>/dev/null || true
rm -f "$session_path" rm -f "$session_path"
remove_issue_from_index "$target" remove_issue_from_index "$target"
echo "Session for '$target' destroyed" echo "Session for '$target' destroyed"
@@ -1729,9 +1628,6 @@ main() {
server) server)
cmd_server "$@" cmd_server "$@"
;; ;;
queue)
cmd_queue "$@"
;;
env) env)
cmd_env "$@" cmd_env "$@"
;; ;;

View File

@@ -1,277 +0,0 @@
#!/bin/bash
# kugetsu test suite
# Run with: bash skills/kugetsu/tests/test-kugetsu.sh
#
# Memory management approach:
# - Sequential test execution (no parallel)
# - Cleanup between tests that spawn opencode
# - No hard memory cap (ulimit -v breaks Bun/opencode)
# - If OOM occurs, it is a known failure mode
set -euo pipefail
KUGETSU="./skills/kugetsu/scripts/kugetsu"
TEST_SESSION_PREFIX="kugetsu-test-"
PASS=0
FAIL=0
cleanup_sessions() {
for dir in ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}*; do
[ -d "$dir" ] && rm -rf "$dir" 2>/dev/null || true
done
}
cleanup_opencode() {
pkill -f "opencode.*${TEST_SESSION_PREFIX}" 2>/dev/null || true
pkill -f "kugetsu.*${TEST_SESSION_PREFIX}" 2>/dev/null || true
sleep 0.5
}
cleanup() {
cleanup_sessions
cleanup_opencode
}
pass() {
echo "✅ PASS: $1"
PASS=$((PASS + 1))
}
fail() {
echo "❌ FAIL: $1"
FAIL=$((FAIL + 1))
}
cleanup
echo "=== kugetsu Test Suite ==="
echo ""
# Test 1: Help
echo "--- Test: help ---"
if $KUGETSU help 2>&1 | grep -q "kugetsu - OpenCode Session Manager"; then
pass "help displays usage"
else
fail "help displays usage"
fi
echo ""
# Test 2: List empty
echo "--- Test: list (empty) ---"
if $KUGETSU list 2>&1 | grep -q "SESSION_ID"; then
pass "list shows header even when empty"
else
fail "list shows header even when empty"
fi
echo ""
# Test 3: List --all empty
echo "--- Test: list --all (empty) ---"
if $KUGETSU list --all 2>&1 | grep -q "SESSION_ID"; then
pass "list --all shows header even when empty"
else
fail "list --all shows header even when empty"
fi
echo ""
# Test 4: Start session (quick exit)
echo "--- Test: start session ---"
if timeout 15 bash -c "$KUGETSU start ${TEST_SESSION_PREFIX}start-test 'echo hello'" 2>&1; then
if [ -d ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}start-test ]; then
pass "start creates session directory"
else
fail "start creates session directory"
fi
else
fail "start runs successfully"
fi
echo ""
# Test 5: List shows only left by default
echo "--- Test: list default filters non-left ---"
if ! $KUGETSU list 2>&1 | grep -q "${TEST_SESSION_PREFIX}start-test"; then
pass "list default hides idle sessions"
else
fail "list default hides idle sessions"
fi
echo ""
# Test 6: List --all shows all
echo "--- Test: list --all shows all states ---"
if $KUGETSU list --all 2>&1 | grep -q "${TEST_SESSION_PREFIX}start-test"; then
pass "list --all shows all sessions"
else
fail "list --all shows all sessions"
fi
echo ""
# Test 7: Resume with auto-fill
echo "--- Test: resume auto-fill ---"
mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-test
echo "left" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-test/state
echo "continue this task" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-test/message
OUTPUT=$(timeout 10 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}resume-test" 2>&1 || true)
if echo "$OUTPUT" | grep -q "Auto-filled message: continue this task"; then
pass "resume auto-fills stored message"
else
fail "resume auto-fills stored message"
fi
cleanup
echo ""
# Test 8: Resume with provided message overrides
echo "--- Test: resume with message overrides ---"
mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-override
echo "left" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-override/state
echo "original message" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}resume-override/message
OUTPUT=$(timeout 30 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}resume-override 'new message'" 2>&1 || true)
if echo "$OUTPUT" | grep -q "new message" && ! echo "$OUTPUT" | grep -q "Auto-filled message"; then
pass "resume uses provided message over auto-fill"
else
fail "resume uses provided message over auto-fill: $OUTPUT"
fi
cleanup
echo ""
# Test 9: Resume idle session fails
echo "--- Test: resume idle session fails ---"
rm -rf ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}idle-test 2>/dev/null
mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}idle-test
echo "idle" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}idle-test/state
OUTPUT=$(timeout 5 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}idle-test" 2>&1 || true)
if echo "$OUTPUT" | grep -q "cannot be resumed"; then
pass "resume idle session fails with message"
else
echo "DEBUG: $OUTPUT"
fail "resume idle session fails with message"
fi
echo ""
# Test 10: Resume non-existent session fails
echo "--- Test: resume non-existent session fails ---"
rm -rf ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}nonexistent 2>/dev/null
OUTPUT=$(timeout 5 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}nonexistent" 2>&1 || true)
if echo "$OUTPUT" | grep -q "not found"; then
pass "resume non-existent session fails"
else
echo "DEBUG: $OUTPUT"
fail "resume non-existent session fails"
fi
echo ""
# Test 11: Stop non-used session fails
echo "--- Test: stop non-used session fails ---"
rm -rf ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}notused 2>/dev/null
mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}notused
echo "idle" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}notused/state
OUTPUT=$(timeout 5 bash -c "$KUGETSU stop ${TEST_SESSION_PREFIX}notused" 2>&1 || true)
if echo "$OUTPUT" | grep -q "not in use"; then
pass "stop non-used session fails"
else
echo "DEBUG: $OUTPUT"
fail "stop non-used session fails"
fi
echo ""
# Test 12: Start existing left session resumes instead
echo "--- Test: start on left session resumes ---"
mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}left-start
echo "left" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}left-start/state
echo "original task" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}left-start/message
OUTPUT=$(timeout 10 bash -c "$KUGETSU start ${TEST_SESSION_PREFIX}left-start 'new task'" 2>&1 || true)
if echo "$OUTPUT" | grep -q "Resuming instead"; then
pass "start on left session resumes"
else
fail "start on left session resumes"
fi
cleanup
echo ""
# ============================================================================
# FLAKY TESTS - Commented out due to timing/process behavior issues
# ============================================================================
# Test: Stop active session (FLAKY - timing dependent)
# echo "--- Test: stop active session (FLAKY) ---"
# (
# timeout 20 bash -c "$KUGETSU start ${TEST_SESSION_PREFIX}stop-test 'sleep 30'" 2>&1 &
# KUGETSU_PID=$!
# sleep 3
#
# # Check session is in use
# if ! $KUGETSU list --all 2>&1 | grep -q "${TEST_SESSION_PREFIX}stop-test.*used"; then
# echo "⚠️ SKIP (FLAKY): Could not verify session was used"
# elif timeout 5 bash -c "$KUGETSU stop ${TEST_SESSION_PREFIX}stop-test" 2>&1; then
# if [ "$(cat ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}stop-test/state 2>/dev/null)" = "idle" ]; then
# echo "✅ PASS (FLAKY): stop transitions to idle"
# else
# echo "❌ FAIL (FLAKY): stop does not transition to idle"
# fi
# else
# echo "❌ FAIL (FLAKY): stop command failed"
# fi
#
# wait $KUGETSU_PID 2>/dev/null || true
# ) 2>&1 || true
# Test: Interrupt session leaves state as left (FLAKY - opencode signal handling)
# echo "--- Test: interrupt session leaves left (FLAKY) ---"
# (
# bash -c "$KUGETSU start ${TEST_SESSION_PREFIX}interrupt-test 'sleep 30'" 2>&1 &
# KUGETSU_PID=$!
# sleep 3
#
# # Find and kill opencode process
# OPENCODE_PID=$(pgrep -f "opencode.*${TEST_SESSION_PREFIX}interrupt-test" | head -1 || true)
# if [ -n "$OPENCODE_PID" ]; then
# kill -9 $OPENCODE_PID 2>/dev/null || true
# fi
#
# wait $KUGETSU_PID 2>/dev/null || true
# sleep 1
#
# STATE=$(cat ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}interrupt-test/state 2>/dev/null || echo "unknown")
# if [ "$STATE" = "left" ]; then
# echo "✅ PASS (FLAKY): interrupt leaves state as left"
# else
# echo "❌ FAIL (FLAKY): interrupt left state=$STATE (expected left)"
# fi
# ) 2>&1 || true
# Test: Concurrent resume attempts (FLAKY - race condition)
# echo "--- Test: concurrent resume (FLAKY) ---"
# mkdir -p ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}concurrent
# echo "left" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}concurrent/state
# echo "test task" > ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}concurrent/message
#
# (
# timeout 10 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}concurrent" 2>&1 &
# timeout 10 bash -c "$KUGETSU resume ${TEST_SESSION_PREFIX}concurrent" 2>&1
# ) 2>&1 || true
#
# echo "⚠️ NOTE (FLAKY): This test is informational only - no assertion"
# rm -rf ~/.kugetsu/sessions/${TEST_SESSION_PREFIX}concurrent
# ============================================================================
# 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