Initial commit
This commit is contained in:
17
.claude-plugin/plugin.json
Normal file
17
.claude-plugin/plugin.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
README.md
Normal file
3
README.md
Normal file
@@ -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.
|
||||||
47
commands/cleanup.md
Normal file
47
commands/cleanup.md
Normal file
@@ -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 <session-name>
|
||||||
|
|
||||||
|
# Kill session AND remove worktree
|
||||||
|
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/cleanup.py <session-name> --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 <branch-name>`
|
||||||
|
- Create PR: `gh pr create --head <branch-name>`
|
||||||
|
- Delete branch: `git branch -d <branch-name>`
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
Always review changes before removing the worktree. Use `/worktree:status` to check commits first.
|
||||||
69
commands/help.md
Normal file
69
commands/help.md
Normal file
@@ -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 <task>` | Launch a new background task in a separate worktree |
|
||||||
|
| `/worktree-task:status [name]` | Check status of all or specific task |
|
||||||
|
| `/worktree-task:resume <name>` | Resume an interrupted task |
|
||||||
|
| `/worktree-task:cleanup <name>` | Clean up completed task and worktree |
|
||||||
|
| `/worktree-task:merge <branch>` | Merge a feature branch into current branch |
|
||||||
|
| `/worktree-task:rebase <branch>` | 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-<name>`)
|
||||||
|
- Task prompts: `/tmp/claude_task_prompt.txt` (temporary)
|
||||||
|
- Monitor logs: `<plugin-dir>/.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
|
||||||
42
commands/launch.md
Normal file
42
commands/launch.md
Normal file
@@ -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 "<branch-name>" "<task-description>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 `../<project>-<branch-name>`
|
||||||
|
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 <session>` to take over interactively.
|
||||||
44
commands/merge.md
Normal file
44
commands/merge.md
Normal file
@@ -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 "<feature-branch>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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-<feature>-to-<target>` to monitor progress.
|
||||||
44
commands/rebase.md
Normal file
44
commands/rebase.md
Normal file
@@ -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 "<feature-branch>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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-<target>-onto-<feature>` to monitor progress.
|
||||||
51
commands/resume.md
Normal file
51
commands/resume.md
Normal file
@@ -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 <session-name>
|
||||||
|
|
||||||
|
# Send custom message
|
||||||
|
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/resume.py <session-name> "<message>"
|
||||||
|
|
||||||
|
# Retry last failed operation
|
||||||
|
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/resume.py <session-name> --retry
|
||||||
|
|
||||||
|
# Check status only (don't send message)
|
||||||
|
python3 ${CLAUDE_PLUGIN_ROOT}/scripts/resume.py <session-name> --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
|
||||||
43
commands/status.md
Normal file
43
commands/status.md
Normal file
@@ -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 <session-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 <session>` - Resume an interrupted task
|
||||||
|
- `/worktree:cleanup <session>` - Clean up a completed task
|
||||||
|
- `tmux attach -t <session>` - Take over interactively
|
||||||
143
hooks/handlers/on-session-end.py
Executable file
143
hooks/handlers/on-session-end.py
Executable file
@@ -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()
|
||||||
467
hooks/handlers/on-session-start.py
Executable file
467
hooks/handlers/on-session-start.py
Executable file
@@ -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()
|
||||||
152
hooks/handlers/on-stop.py
Executable file
152
hooks/handlers/on-stop.py
Executable file
@@ -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()
|
||||||
37
hooks/hooks.json
Normal file
37
hooks/hooks.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
89
plugin.lock.json
Normal file
89
plugin.lock.json
Normal file
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
102
skills/worktree-task/SKILL.md
Normal file
102
skills/worktree-task/SKILL.md
Normal file
@@ -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 <branch-name> "<task-description>"
|
||||||
|
```
|
||||||
|
|
||||||
|
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 <session-name> [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 <session-name> [--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: `../<project>-<branch-name>`
|
||||||
|
- tmux session names have `/` and `.` replaced with `-`
|
||||||
|
- Use `tmux attach -t <session>` 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)
|
||||||
Reference in New Issue
Block a user