commit eb3239052633e7c7612278e11deca35c483febea Author: Zhongwei Li Date: Sun Nov 30 08:46:45 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..2126fbf --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,17 @@ +{ + "name": "worktree-task", + "description": "Manage large coding tasks using git worktrees and background Claude Code sessions. Supports launching, monitoring, resuming, and cleanup of autonomous tasks with alert notifications.", + "version": "1.0.0", + "author": { + "name": "ourines" + }, + "skills": [ + "./skills" + ], + "commands": [ + "./commands" + ], + "hooks": [ + "./hooks" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..41e3a20 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# worktree-task + +Manage large coding tasks using git worktrees and background Claude Code sessions. Supports launching, monitoring, resuming, and cleanup of autonomous tasks with alert notifications. diff --git a/commands/cleanup.md b/commands/cleanup.md new file mode 100644 index 0000000..dd8da2b --- /dev/null +++ b/commands/cleanup.md @@ -0,0 +1,47 @@ +--- +description: Clean up a completed worktree task +allowed-tools: Bash +--- + +# Cleanup Worktree Task + +Clean up a completed or abandoned worktree task by killing the tmux session and optionally removing the git worktree. + +## Usage + +Provide: +1. **Session name** - The tmux session to clean up +2. **--remove-worktree** (optional) - Also remove the git worktree directory + +## Example + +User: "Cleanup the my-feature worktree task" +User: "Cleanup my-feature and remove the worktree" + +## Execution + +```bash +# Kill session only (keep worktree for review) +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/cleanup.py + +# Kill session AND remove worktree +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/cleanup.py --remove-worktree +``` + +## What It Does + +1. Kills the tmux session +2. Lists remaining git worktrees +3. If `--remove-worktree`: removes the worktree directory +4. Shows next steps (merge branch, create PR) + +## After Cleanup + +The branch still exists. To complete: +- Merge: `git merge ` +- Create PR: `gh pr create --head ` +- Delete branch: `git branch -d ` + +## Note + +Always review changes before removing the worktree. Use `/worktree:status` to check commits first. diff --git a/commands/help.md b/commands/help.md new file mode 100644 index 0000000..ac240db --- /dev/null +++ b/commands/help.md @@ -0,0 +1,69 @@ +--- +description: Show help for worktree-task plugin +allowed-tools: "" +--- + +# Worktree Task Plugin Help + +A plugin for managing large coding tasks using git worktrees and background Claude Code sessions. + +## Commands + +| Command | Description | +|---------|-------------| +| `/worktree-task:launch ` | Launch a new background task in a separate worktree | +| `/worktree-task:status [name]` | Check status of all or specific task | +| `/worktree-task:resume ` | Resume an interrupted task | +| `/worktree-task:cleanup ` | Clean up completed task and worktree | +| `/worktree-task:merge ` | Merge a feature branch into current branch | +| `/worktree-task:rebase ` | Rebase current branch onto a feature branch | +| `/worktree-task:help` | Show this help message | + +## Quick Start + +```bash +# 1. Launch a complex task in the background +/worktree-task:launch "Implement user authentication with OAuth2" + +# 2. Check progress +/worktree-task:status + +# 3. When done, merge the changes +/worktree-task:merge feature-auth + +# 4. Clean up +/worktree-task:cleanup feature-auth +``` + +## How It Works + +1. **Launch** creates a git worktree (isolated copy of your repo) and spawns a Claude Code instance in a tmux session +2. **Monitor** via `/worktree-task:status` to see progress, logs, and completion state +3. **Resume** if task gets interrupted (rate limits, API errors, network issues) +4. **Merge/Rebase** with automatic conflict resolution powered by Claude +5. **Cleanup** removes the tmux session and worktree when done + +## Best Practices + +- Use for tasks that take 10+ minutes or require extensive changes +- Keep task descriptions clear and specific +- Check status periodically to monitor progress +- Use `--keep-worktree` with cleanup if you want to review changes first + +## File Locations + +- Worktrees: Created in parent directory of your repo (e.g., `../worktree-task-`) +- Task prompts: `/tmp/claude_task_prompt.txt` (temporary) +- Monitor logs: `/.monitor_cron.log` + +## Requirements + +- macOS (for notifications) +- tmux +- git +- Python 3.8+ + +## Links + +- GitHub: https://github.com/ourines/worktree-task-plugin +- Issues: https://github.com/ourines/worktree-task-plugin/issues diff --git a/commands/launch.md b/commands/launch.md new file mode 100644 index 0000000..2e731a2 --- /dev/null +++ b/commands/launch.md @@ -0,0 +1,42 @@ +--- +description: Launch a new background worktree task +allowed-tools: Bash +--- + +# Launch Worktree Task + +Launch a background Claude Code session in a separate git worktree to execute a large task autonomously. + +## Usage + +Provide: + +1. **Branch name** - The git branch for this task (e.g., `feature/my-task`) +2. **Task description** - What the background Claude should do + +## Example + +User: "Launch a worktree task on branch feature/auth to implement the authentication module" + +## Execution + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/launch.py "" "" +``` + +## Prerequisites + +- Git working directory must be clean (commit or stash changes first) +- tmux must be installed +- Branch name will be created if it doesn't exist + +## What Happens + +1. Creates a git worktree at `../-` +2. Creates a tmux session named after the branch +3. Launches Claude Code with `--dangerously-skip-permissions` +4. Sends the task prompt with instructions to use Task tool for each phase + +## After Launch + +Use `/worktree:status` to monitor progress or `tmux attach -t ` to take over interactively. diff --git a/commands/merge.md b/commands/merge.md new file mode 100644 index 0000000..98d966d --- /dev/null +++ b/commands/merge.md @@ -0,0 +1,44 @@ +--- +description: Merge a feature branch into current branch +allowed-tools: Bash +--- + +# Merge Feature Branch + +Merge a completed feature branch into the current branch using worktree + tmux with automatic conflict resolution. + +## Usage + +Provide the feature branch name to merge into the current branch. + +## Example + +User on `dev` branch: "Merge feature-auth into dev" +User on `main` branch: "Merge feature/new-api into main" + +## Execution + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/merge.py "" +``` + +## Prerequisites + +- Must be on the target branch (e.g., dev, main) +- Feature branch must exist +- tmux must be installed + +## What Happens + +1. Checks if feature branch has a worktree +2. If worktree exists: + - Enters the worktree + - Rebases feature branch onto current branch + - Resolves any conflicts +3. Returns to main repo +4. Merges feature branch into current branch +5. Resolves any merge conflicts using Claude Code in tmux + +## After Launch + +Use `tmux attach -t merge--to-` to monitor progress. diff --git a/commands/rebase.md b/commands/rebase.md new file mode 100644 index 0000000..9b20ac8 --- /dev/null +++ b/commands/rebase.md @@ -0,0 +1,44 @@ +--- +description: Rebase current branch onto a feature branch +allowed-tools: Bash +--- + +# Rebase onto Feature Branch + +Rebase the current branch onto a feature branch using worktree + tmux with automatic conflict resolution. + +## Usage + +Provide the feature branch name to rebase the current branch onto. + +## Example + +User on `dev` branch: "Rebase dev onto feature-auth" +User on `main` branch: "Rebase main onto feature/new-api" + +## Execution + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/rebase.py "" +``` + +## Prerequisites + +- Must be on the target branch (e.g., dev, main) +- Feature branch must exist +- tmux must be installed + +## What Happens + +1. Checks if feature branch has a worktree +2. If worktree exists: + - Enters the worktree + - Rebases feature branch onto current branch + - Resolves any conflicts +3. Returns to main repo +4. Rebases current branch onto feature branch +5. Resolves any rebase conflicts using Claude Code in tmux + +## After Launch + +Use `tmux attach -t rebase--onto-` to monitor progress. diff --git a/commands/resume.md b/commands/resume.md new file mode 100644 index 0000000..2301273 --- /dev/null +++ b/commands/resume.md @@ -0,0 +1,51 @@ +--- +description: Resume an interrupted worktree task +allowed-tools: Bash +--- + +# Resume Worktree Task + +Resume a background worktree task that was interrupted (rate limit, API error, timeout, or waiting for input). + +## Usage + +Provide: +1. **Session name** - The tmux session to resume +2. **Message** (optional) - Custom instruction for Claude + +## Example + +User: "Resume the my-feature worktree task" +User: "Resume my-feature with message 'Continue from phase 4'" + +## Execution + +```bash +# Auto-detect error and send appropriate message +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/resume.py + +# Send custom message +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/resume.py "" + +# Retry last failed operation +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/resume.py --retry + +# Check status only (don't send message) +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/resume.py --check +``` + +## Auto-Detection + +The script automatically detects: +- `rate_limit` - Waits and sends resume message +- `api_error` - Sends retry message +- `timeout` - Sends retry message +- `waiting_input` - Sends continue message + +## What It Does + +1. Checks if session exists +2. Captures recent output to detect error type +3. Generates appropriate resume message +4. Sends message to Claude with Enter confirmation +5. Shows response after brief wait diff --git a/commands/status.md b/commands/status.md new file mode 100644 index 0000000..9586a06 --- /dev/null +++ b/commands/status.md @@ -0,0 +1,43 @@ +--- +description: Check status of worktree tasks +allowed-tools: Bash +--- + +# Worktree Task Status + +Check the status of background worktree tasks. + +## Usage + +- Without arguments: List all active tmux sessions and git worktrees +- With session name: Show detailed status for that session + +## Example + +User: "Check status of worktree tasks" +User: "What's the status of the my-feature task?" + +## Execution + +```bash +# List all tasks +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/status.py + +# Check specific task +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/status.py +``` + +## Output Includes + +- Active tmux sessions +- Git worktrees +- Current branch and changed files +- Recent commits +- Last activity (tmux output) + +## Quick Actions + +After checking status, you can: +- `/worktree:resume ` - Resume an interrupted task +- `/worktree:cleanup ` - Clean up a completed task +- `tmux attach -t ` - Take over interactively diff --git a/hooks/handlers/on-session-end.py b/hooks/handlers/on-session-end.py new file mode 100755 index 0000000..cca4825 --- /dev/null +++ b/hooks/handlers/on-session-end.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +""" +Hook handler for SessionEnd event. +Sends macOS notification and logs when a worktree session ends. +""" + +import json +import os +import subprocess +import sys +from datetime import datetime +from pathlib import Path + + +def send_macos_notification(title: str, message: str, sound: str = "default"): + """Send a macOS notification using osascript.""" + script = f''' + display notification "{message}" with title "{title}" sound name "{sound}" + ''' + subprocess.run(["osascript", "-e", script], capture_output=True) + + +def send_terminal_notifier(title: str, message: str, subtitle: str = None): + """Send notification via terminal-notifier if available.""" + try: + cmd = [ + "terminal-notifier", + "-title", title, + "-message", message, + "-sound", "default", + "-group", "worktree-task" + ] + if subtitle: + cmd.extend(["-subtitle", subtitle]) + subprocess.run(cmd, capture_output=True, check=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def log_session_end(session_id: str, reason: str, cwd: str): + """Log session end to a file for history tracking.""" + log_dir = Path.home() / ".claude" / "plugins" / "worktree-task" / "logs" + log_dir.mkdir(parents=True, exist_ok=True) + + log_file = log_dir / "session-history.log" + + entry = { + "timestamp": datetime.now().isoformat(), + "session_id": session_id, + "reason": reason, + "cwd": cwd + } + + with open(log_file, "a") as f: + f.write(json.dumps(entry) + "\n") + + +def get_session_info() -> dict: + """Try to determine session context.""" + cwd = os.getcwd() + + info = { + "cwd": cwd, + "is_worktree": False, + "branch": None, + "project": None + } + + # Get current branch + try: + branch_result = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, text=True, cwd=cwd + ) + if branch_result.returncode == 0: + info["branch"] = branch_result.stdout.strip() + except Exception: + pass + + # Check if worktree by path pattern + if "-" in os.path.basename(cwd) and "worktree" not in cwd.lower(): + # Might be a worktree like "project-feature-branch" + info["is_worktree"] = True + + # Also check git worktree list + try: + result = subprocess.run( + ["git", "rev-parse", "--git-common-dir"], + capture_output=True, text=True, cwd=cwd + ) + git_common = result.stdout.strip() + git_dir_result = subprocess.run( + ["git", "rev-parse", "--git-dir"], + capture_output=True, text=True, cwd=cwd + ) + git_dir = git_dir_result.stdout.strip() + + # If git-dir != git-common-dir, we're in a worktree + if git_common != git_dir and ".git/worktrees" in git_dir: + info["is_worktree"] = True + except Exception: + pass + + # Extract project name + info["project"] = os.path.basename(cwd) + + return info + + +def main(): + # Read hook input from stdin + try: + hook_input = json.load(sys.stdin) + except json.JSONDecodeError: + hook_input = {} + + session_id = hook_input.get("session_id", "unknown") + reason = hook_input.get("reason", "unknown") # e.g., "clear", "logout", "exit" + + session_info = get_session_info() + + # Log all worktree session ends + if session_info.get("is_worktree"): + log_session_end(session_id, reason, session_info.get("cwd", "")) + + branch = session_info.get("branch", "unknown") + project = session_info.get("project", "unknown") + + title = "Worktree Session Ended" + message = f"Branch: {branch}" + subtitle = f"Reason: {reason}" + + # Try terminal-notifier first, fallback to osascript + if not send_terminal_notifier(title, message, subtitle): + send_macos_notification(title, f"{message} ({reason})") + + # Always exit 0 to not block Claude + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/hooks/handlers/on-session-start.py b/hooks/handlers/on-session-start.py new file mode 100755 index 0000000..c2fc2fc --- /dev/null +++ b/hooks/handlers/on-session-start.py @@ -0,0 +1,467 @@ +#!/usr/bin/env python3 +""" +Hook handler for SessionStart event. +Checks if the plugin has upstream updates available and notifies the user. + +This hook runs at session start and provides a non-blocking notification +if updates are available, without interrupting the session. + +Update detection uses Claude Code's plugin system: +- Reads installed plugin info from ~/.claude/plugins/installed_plugins.json +- Fetches latest release info from GitHub Releases API +- Suggests using /plugin commands to update +""" + +import json +import os +import subprocess +import sys +import time +import urllib.request +import urllib.error +from pathlib import Path + + +# Cache settings +CACHE_FILE = Path.home() / ".claude" / "plugins" / ".worktree-task-update-cache.json" +CACHE_TTL_SECONDS = 24 * 60 * 60 # 24 hours + +# Plugin identification +PLUGIN_NAME = "worktree-task" +MARKETPLACE_NAME = "worktree-task-plugin" +PLUGIN_ID = f"{PLUGIN_NAME}@{MARKETPLACE_NAME}" + +# GitHub repository info +GITHUB_OWNER = "ourines" +GITHUB_REPO = "worktree-task-plugin" +GITHUB_RELEASES_API = f"https://api.github.com/repos/{GITHUB_OWNER}/{GITHUB_REPO}/releases/latest" + +# Promotional links (shown occasionally in update notifications) +PROMO_LINKS = { + "twitter": "https://x.com/ourines_", + "github": f"https://github.com/{GITHUB_OWNER}/{GITHUB_REPO}", +} + + +def get_claude_plugins_dir() -> Path: + """Get the Claude Code plugins directory.""" + return Path.home() / ".claude" / "plugins" + + +def load_cache() -> dict: + """Load update check cache from disk.""" + if not CACHE_FILE.exists(): + return {} + try: + with open(CACHE_FILE, "r") as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + + +def save_cache(cache: dict) -> None: + """Save update check cache to disk.""" + try: + CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(CACHE_FILE, "w") as f: + json.dump(cache, f) + except IOError: + pass + + +def is_cache_valid(cache: dict) -> bool: + """Check if cache is still valid (within TTL).""" + last_check = cache.get("last_check", 0) + return (time.time() - last_check) < CACHE_TTL_SECONDS + + +def get_installed_plugin_info() -> dict: + """ + Read installed plugin info from Claude Code's installed_plugins.json. + + Returns dict with: + - version: str + - gitCommitSha: str + - installPath: str + - found: bool + """ + result = { + "version": "", + "gitCommitSha": "", + "installPath": "", + "found": False + } + + plugins_file = get_claude_plugins_dir() / "installed_plugins.json" + if not plugins_file.exists(): + return result + + try: + with open(plugins_file, "r") as f: + data = json.load(f) + + plugin_info = data.get("plugins", {}).get(PLUGIN_ID, {}) + if plugin_info: + result["version"] = plugin_info.get("version", "") + result["gitCommitSha"] = plugin_info.get("gitCommitSha", "") + result["installPath"] = plugin_info.get("installPath", "") + result["found"] = True + except (json.JSONDecodeError, IOError): + pass + + return result + + +def get_marketplace_info() -> dict: + """ + Read marketplace info from Claude Code's known_marketplaces.json. + + Returns dict with: + - repo: str (e.g., "ourines/worktree-task-plugin") + - installLocation: str + - found: bool + """ + result = { + "repo": "", + "installLocation": "", + "found": False + } + + marketplaces_file = get_claude_plugins_dir() / "known_marketplaces.json" + if not marketplaces_file.exists(): + return result + + try: + with open(marketplaces_file, "r") as f: + data = json.load(f) + + marketplace_info = data.get(MARKETPLACE_NAME, {}) + if marketplace_info: + source = marketplace_info.get("source", {}) + result["repo"] = source.get("repo", "") + result["installLocation"] = marketplace_info.get("installLocation", "") + result["found"] = True + except (json.JSONDecodeError, IOError): + pass + + return result + + +def run_git_command(cmd: list, cwd: str = None) -> tuple[bool, str]: + """Run a git command and return (success, output).""" + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + cwd=cwd, + timeout=5 # Reduced from 15s to 5s for faster startup + ) + return result.returncode == 0, result.stdout.strip() + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return False, "" + + +def get_local_commit_sha(install_path: str) -> str: + """Get the actual commit SHA from the installed plugin directory (not from installed_plugins.json).""" + if not install_path or not os.path.isdir(install_path): + return "" + success, sha = run_git_command(["git", "rev-parse", "HEAD"], install_path) + return sha if success else "" + + +def fetch_github_release() -> dict: + """ + Fetch latest release info from GitHub Releases API. + + Returns dict with: + - tag_name: str (e.g., "v1.0.0") + - name: str (release title) + - body: str (release notes in markdown) + - published_at: str + - html_url: str (link to release page) + - found: bool + - error: str + """ + result = { + "tag_name": "", + "name": "", + "body": "", + "published_at": "", + "html_url": "", + "found": False, + "error": "" + } + + try: + req = urllib.request.Request( + GITHUB_RELEASES_API, + headers={ + "Accept": "application/vnd.github.v3+json", + "User-Agent": "worktree-task-plugin" + } + ) + with urllib.request.urlopen(req, timeout=3) as response: + data = json.loads(response.read().decode("utf-8")) + result["tag_name"] = data.get("tag_name", "") + result["name"] = data.get("name", "") + result["body"] = data.get("body", "") + result["published_at"] = data.get("published_at", "") + result["html_url"] = data.get("html_url", "") + result["found"] = True + except urllib.error.HTTPError as e: + if e.code == 404: + result["error"] = "No releases found" + else: + result["error"] = f"HTTP error: {e.code}" + except urllib.error.URLError as e: + result["error"] = f"Network error: {e.reason}" + except (json.JSONDecodeError, TimeoutError) as e: + result["error"] = str(e) + + return result + + +def parse_version(tag: str) -> tuple: + """Parse version tag like 'v1.2.3' into tuple (1, 2, 3) for comparison.""" + import re + match = re.match(r"v?(\d+)\.(\d+)\.(\d+)", tag) + if match: + return tuple(int(x) for x in match.groups()) + return (0, 0, 0) + + +def check_remote_updates(install_path: str, local_sha: str, local_version: str) -> dict: + """ + Check if there are updates available using GitHub Releases API. + Falls back to git commit comparison if no releases found. + + Returns dict with: + - has_updates: bool + - update_type: str ("release" or "commit") + - remote_version: str (for release updates) + - remote_sha: str (for commit updates) + - release_name: str + - release_notes: str + - release_url: str + - behind_count: int + - error: str + """ + result = { + "has_updates": False, + "update_type": "", + "remote_version": "", + "remote_sha": "", + "release_name": "", + "release_notes": "", + "release_url": "", + "behind_count": 0, + "error": "" + } + + # Try GitHub Releases API first + release_info = fetch_github_release() + + if release_info["found"]: + remote_version = release_info["tag_name"] + result["remote_version"] = remote_version + result["release_name"] = release_info["name"] or remote_version + result["release_url"] = release_info["html_url"] + + # Parse release notes (truncate if too long) + body = release_info["body"] or "" + if len(body) > 500: + body = body[:500] + "..." + result["release_notes"] = body + + # Compare versions + if local_version: + local_ver = parse_version(local_version) + remote_ver = parse_version(remote_version) + if remote_ver > local_ver: + result["has_updates"] = True + result["update_type"] = "release" + return result + + # Fallback: Check git commits if no release or version match + if not install_path or not os.path.isdir(install_path): + result["error"] = release_info.get("error", "Install path not found") + return result + + # Check if it's a git repository + success, _ = run_git_command(["git", "rev-parse", "--git-dir"], install_path) + if not success: + result["error"] = "Not a git repository" + return result + + # Fetch updates from remote (silent) + success, _ = run_git_command(["git", "fetch", "--quiet", "origin"], install_path) + if not success: + result["error"] = "Failed to fetch from remote" + return result + + # Get current branch + success, branch = run_git_command(["git", "branch", "--show-current"], install_path) + if not success: + branch = "main" + + # Get remote HEAD commit + remote_ref = f"origin/{branch}" + success, remote_sha = run_git_command(["git", "rev-parse", remote_ref], install_path) + if success: + result["remote_sha"] = remote_sha[:8] + + # Compare with local SHA + if local_sha and remote_sha: + if local_sha != remote_sha and not remote_sha.startswith(local_sha[:8]): + success, behind_count = run_git_command( + ["git", "rev-list", "--count", f"{local_sha}..{remote_ref}"], + install_path + ) + if success and behind_count.isdigit(): + count = int(behind_count) + if count > 0: + result["has_updates"] = True + result["update_type"] = "commit" + result["behind_count"] = count + + return result + + +def format_release_notes(notes: str, max_lines: int = 5) -> str: + """Format release notes for display, limiting lines.""" + if not notes: + return "" + lines = notes.strip().split("\n") + formatted = [] + for line in lines[:max_lines]: + # Clean up markdown formatting for terminal display + line = line.strip() + if line.startswith("- "): + formatted.append(f" • {line[2:]}") + elif line.startswith("* "): + formatted.append(f" • {line[2:]}") + elif line.startswith("# "): + continue # Skip headers + elif line.startswith("## "): + formatted.append(f" {line[3:]}") + elif line: + formatted.append(f" {line}") + if len(lines) > max_lines: + formatted.append(f" ... and {len(lines) - max_lines} more") + return "\n".join(formatted) + + +def main(): + # Read hook input from stdin + try: + hook_input = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + hook_input = {} + + source = hook_input.get("source", "startup") + + # Only check on startup, skip for resume/clear/compact to avoid repeated checks + if source != "startup": + sys.exit(0) + + # Prepare output + output = { + "hookSpecificOutput": { + "hookEventName": "SessionStart" + } + } + + # Get installed plugin info + plugin_info = get_installed_plugin_info() + if not plugin_info["found"]: + # Plugin not found in installed_plugins.json, skip check + print(json.dumps(output)) + sys.exit(0) + + # Get marketplace info + marketplace_info = get_marketplace_info() + install_path = plugin_info["installPath"] or marketplace_info["installLocation"] + + if not install_path: + print(json.dumps(output)) + sys.exit(0) + + # Get actual local commit SHA from install directory (not from installed_plugins.json which may be stale) + local_sha = get_local_commit_sha(install_path) + if not local_sha: + # Fallback to installed_plugins.json if git command fails + local_sha = plugin_info["gitCommitSha"] + local_version = plugin_info["version"] + + # Check cache - skip network requests if recently checked with same local SHA + cache = load_cache() + if is_cache_valid(cache) and cache.get("local_sha") == local_sha: + # Use cached result + if cache.get("has_updates") and cache.get("message"): + output["systemMessage"] = cache["message"] + print(json.dumps(output)) + sys.exit(0) + + # Check for updates (use version for release comparison) + update_info = check_remote_updates(install_path, local_sha, local_version) + + message = None + if update_info.get("has_updates"): + update_type = update_info["update_type"] + + if update_type == "release": + # Release-based update notification + remote_version = update_info["remote_version"] + release_name = update_info["release_name"] + + message = f"šŸš€ Worktree Task Plugin: New release available!" + message += f"\n {local_version or 'current'} → {remote_version}" + if release_name and release_name != remote_version: + message += f" ({release_name})" + + # Show release notes + if update_info["release_notes"]: + formatted_notes = format_release_notes(update_info["release_notes"]) + if formatted_notes: + message += f"\n\nšŸ“‹ What's New:\n{formatted_notes}" + + # Release URL + if update_info["release_url"]: + message += f"\n\nšŸ”— Details: {update_info['release_url']}" + else: + # Commit-based update notification (fallback) + behind_count = update_info["behind_count"] + local_sha_short = local_sha[:8] if local_sha else "unknown" + remote_sha = update_info["remote_sha"] + + message = f"šŸ”„ Worktree Task Plugin: {behind_count} update(s) available" + if local_sha_short and remote_sha: + message += f" ({local_sha_short} → {remote_sha})" + + # Update commands + message += f"\n\nšŸ“¦ To update:\n" + message += f" /plugin uninstall {PLUGIN_ID}\n" + message += f" /plugin install {PLUGIN_ID}" + + # Promotional footer (subtle, at the very end) + message += f"\n\n─────────────────────────────" + message += f"\n⭐ Like this plugin? Star us: {PROMO_LINKS['github']}" + + output["systemMessage"] = message + + # Save to cache + save_cache({ + "last_check": time.time(), + "local_sha": local_sha, + "has_updates": update_info.get("has_updates", False), + "message": message + }) + + # Output JSON result + print(json.dumps(output)) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/hooks/handlers/on-stop.py b/hooks/handlers/on-stop.py new file mode 100755 index 0000000..0448aaa --- /dev/null +++ b/hooks/handlers/on-stop.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +""" +Hook handler for Stop event. +Sends macOS notification when a worktree task session stops. + +Only triggers for sessions running inside a tmux worktree-task session, +not regular Claude Code sessions. +""" + +import json +import os +import random +import subprocess +import sys + + +# Promotional messages - shown occasionally in notifications +PROMO_MESSAGES = [ + "⭐ Star us on GitHub!", + "šŸš€ Powered by worktree-task-plugin", + "šŸ’” Try codex.markets for more API capacity", +] + +# Probability of showing promo (30%) +PROMO_CHANCE = 0.3 + + +def is_worktree_task_session() -> bool: + """ + Check if we're running inside a worktree-task tmux session. + + Returns True only if: + 1. TMUX env var is set (running inside tmux) + 2. Session name starts with 'worktree-' + """ + # Must be in tmux + if not os.environ.get("TMUX"): + return False + + # Check tmux session name + try: + result = subprocess.run( + ["tmux", "display-message", "-p", "#S"], + capture_output=True, text=True, check=True + ) + session_name = result.stdout.strip() + return session_name.startswith("worktree-") + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + +def send_macos_notification(title: str, message: str, sound: str = "default") -> bool: + """ + Send a macOS notification using osascript. + Returns True if notification was sent successfully, False otherwise. + """ + # Escape quotes in message + escaped_message = message.replace('"', '\\"') + escaped_title = title.replace('"', '\\"') + + script = f'display notification "{escaped_message}" with title "{escaped_title}" sound name "{sound}"' + try: + result = subprocess.run( + ["osascript", "-e", script], + capture_output=True, + check=True, + timeout=5 + ) + return True + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError): + return False + + +def get_session_info() -> dict: + """Get info about the current session.""" + cwd = os.getcwd() + info = { + "cwd": cwd, + "branch": None, + "tmux_session": None + } + + # Get current branch + try: + result = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, text=True, cwd=cwd + ) + if result.returncode == 0: + info["branch"] = result.stdout.strip() + except FileNotFoundError: + pass + + # Get tmux session name + if os.environ.get("TMUX"): + try: + result = subprocess.run( + ["tmux", "display-message", "-p", "#S"], + capture_output=True, text=True + ) + if result.returncode == 0: + info["tmux_session"] = result.stdout.strip() + except FileNotFoundError: + pass + + return info + + +def main(): + # Early exit: only run for worktree-task sessions + if not is_worktree_task_session(): + sys.exit(0) + + # Read hook input from stdin (may be empty) + try: + hook_input = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + hook_input = {} + + session_info = get_session_info() + branch = session_info.get("branch") or "unknown" + tmux_session = session_info.get("tmux_session") or "worktree" + + # Extract task name from tmux session (worktree-{task_name}) + task_name = tmux_session.replace("worktree-", "") if tmux_session else "task" + + title = "āœ… Worktree Task Completed" + message = f"'{task_name}' on branch '{branch}' finished" + + # Occasionally add a subtle promo + promo = "" + if random.random() < PROMO_CHANCE: + promo = random.choice(PROMO_MESSAGES) + + # Try macOS notification first + notification_message = f"{message}\n{promo}" if promo else message + notification_sent = send_macos_notification(title, notification_message) + + # Fallback: output systemMessage (always visible to user, no context cost) + # Only if notification failed or as a reliable backup + if not notification_sent: + output = { + "systemMessage": f"{title}\n{message}" + (f"\n{promo}" if promo else "") + } + print(json.dumps(output)) + + # Always exit 0 to not block Claude + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..ce686e2 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,37 @@ +{ + "description": "Worktree task hooks for alerts and update checking", + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/handlers/on-session-start.py", + "timeout": 15 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/handlers/on-stop.py" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/handlers/on-session-end.py" + } + ] + } + ] + } +} diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..264b19c --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,89 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:ourines/worktree-task-plugin:", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "bfbd17c264488863f712a5fe055b2f5daa914f00", + "treeHash": "54232ed2470330f615270dbd71501fa94b2ef899e22d45294cf8d10cb4981af0", + "generatedAt": "2025-11-28T10:27:34.122887Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "worktree-task", + "description": "Manage large coding tasks using git worktrees and background Claude Code sessions. Supports launching, monitoring, resuming, and cleanup of autonomous tasks with alert notifications.", + "version": "1.0.0" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "d6a8798bffef5a5b8a4a8d2584607c7d8074838a7b8c05b4c5868be46d78786a" + }, + { + "path": "hooks/hooks.json", + "sha256": "8df5519f25e5b94bfba50b99e1ecc8d8a4011f94fa62f30e68fbfe65c4f68942" + }, + { + "path": "hooks/handlers/on-session-start.py", + "sha256": "bbc3a8aaf9afa46e740681cd9e8ee3ce315acf4e1542bb07bd8240466950089a" + }, + { + "path": "hooks/handlers/on-session-end.py", + "sha256": "0344c53b426d17392a628b88cb065af0e8c73bf1846c04a69e971ed63e167c0d" + }, + { + "path": "hooks/handlers/on-stop.py", + "sha256": "14ded1dac6fce0e7c5ed69b39e6ff62308c950ad9939e6f09dbf98d19b620a81" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "87c9e3e6754e05f47a67647cd6915fac99babe1c4924648edb33fd5ccb3030b2" + }, + { + "path": "commands/launch.md", + "sha256": "8378745e6a545701863840ab3fa975a0fccca6aaa555cf7650f9a22f8a95e06a" + }, + { + "path": "commands/status.md", + "sha256": "8d2bb5e27d3f2ecf9655698ba751216ef939ced49faaebe58f454a819465626f" + }, + { + "path": "commands/help.md", + "sha256": "dbdb1ec7b71ababf51fd062e51912838496b47641f373768c014106717539021" + }, + { + "path": "commands/resume.md", + "sha256": "2ca97ca6386b53836162a130e554ab07058be6345b17bc2ec57d578f96386603" + }, + { + "path": "commands/rebase.md", + "sha256": "fe088ed85387511b3a913d7dd1e44c3ab9377d7ac23f3f52fbcf589c34ae7e8a" + }, + { + "path": "commands/merge.md", + "sha256": "7289678aa6fa79c4d7ced51371a15dc3e7aa570fee7009047bc48c3b2cc4e4f8" + }, + { + "path": "commands/cleanup.md", + "sha256": "4135fe2582cb2f205edbc09dd4eb210e2b1f6636149ac571ba756f8f2c69a6c3" + }, + { + "path": "skills/worktree-task/SKILL.md", + "sha256": "f64d9d23d4c23c7c5adfa1284f6484930eccd3abe6cb65233ecf8f22989c97fa" + } + ], + "dirSha256": "54232ed2470330f615270dbd71501fa94b2ef899e22d45294cf8d10cb4981af0" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/worktree-task/SKILL.md b/skills/worktree-task/SKILL.md new file mode 100644 index 0000000..12e2a08 --- /dev/null +++ b/skills/worktree-task/SKILL.md @@ -0,0 +1,102 @@ +--- +name: worktree-task +description: Manage large coding tasks using git worktrees and background Claude Code sessions. Use this when users want to execute large, complex tasks (like implementing a new service, major refactoring, or multi-step features) in isolation without blocking the current session. Spawns autonomous Claude Code instances via tmux. +--- + +# Worktree Task Manager + +This skill manages large coding tasks by spawning autonomous Claude Code instances in separate git worktrees via tmux sessions. + +## When to Use + +- User wants to execute a large task (>20 subtasks) without blocking current session +- User mentions "background", "parallel", "worktree", or "autonomous" execution +- Task involves creating a new service, major refactoring, or implementing complex features +- User wants to continue other work while a large task runs + +## Available Commands + +Use these slash commands for precise control: + +| Command | Description | +|---------|-------------| +| `/worktree:launch` | Launch a new background task | +| `/worktree:status` | Check status of all or specific tasks | +| `/worktree:resume` | Resume an interrupted task | +| `/worktree:cleanup` | Clean up completed tasks | + +## Core Workflow + +### 1. Launch a Task + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/launch.py "" +``` + +Example: +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/launch.py feature/my-task "Execute the task: implement new feature. Read the tasks.md and implement all phases." +``` + +The script will: +1. Verify git status is clean (or prompt to commit/stash) +2. Create a git worktree with the specified branch +3. Create a tmux session +4. Launch Claude Code with `--dangerously-skip-permissions` +5. Send the task with instructions to use Task tool for each phase + +### 2. Monitor Progress + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/status.py [session-name] +``` + +Without arguments, lists all active sessions. With a session name, shows detailed status. + +### 3. Resume an Interrupted Task + +If a task is interrupted (rate limit, API error, timeout): + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/resume.py [message] +``` + +Options: +- `--retry` - Retry the last failed task +- `--check` - Only check status, don't send message + +### 4. Cleanup + +```bash +python3 ${CLAUDE_PLUGIN_ROOT}/scripts/cleanup.py [--remove-worktree] +``` + +## Alerts + +This plugin automatically sends macOS notifications when: +- A background task completes successfully +- A task encounters an error (rate limit, API error) +- A session ends + +Configure alerts in `hooks/hooks.json`. + +## Critical Instructions for Spawned Claude + +The spawned Claude Code instance receives these critical instructions: + +1. **MUST use Task tool** - Each major phase must be executed via `Task` tool to prevent context overflow +2. **Silent mode** - No confirmations needed, user has pre-approved all operations +3. **Complete execution** - Do not stop until all tasks are done +4. **Track with TodoWrite** - Create and update todo list for visibility +5. **Commit often** - Make atomic commits after each logical unit + +## Notes + +- Worktrees are created in parent directory: `../-` +- tmux session names have `/` and `.` replaced with `-` +- Use `tmux attach -t ` to take over interactively +- The spawned Claude runs with full permissions (`--dangerously-skip-permissions`) + +--- + +> šŸ’” More Claude Code plugins: [github.com/ourines](https://github.com/ourines)