stravinsky 0.2.40__py3-none-any.whl → 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/token_refresh.py +130 -0
  3. mcp_bridge/cli/__init__.py +6 -0
  4. mcp_bridge/cli/install_hooks.py +1265 -0
  5. mcp_bridge/cli/session_report.py +585 -0
  6. mcp_bridge/hooks/HOOKS_SETTINGS.json +175 -0
  7. mcp_bridge/hooks/README.md +215 -0
  8. mcp_bridge/hooks/__init__.py +119 -43
  9. mcp_bridge/hooks/edit_recovery.py +42 -37
  10. mcp_bridge/hooks/git_noninteractive.py +89 -0
  11. mcp_bridge/hooks/keyword_detector.py +30 -0
  12. mcp_bridge/hooks/manager.py +50 -0
  13. mcp_bridge/hooks/notification_hook.py +103 -0
  14. mcp_bridge/hooks/parallel_enforcer.py +127 -0
  15. mcp_bridge/hooks/parallel_execution.py +111 -0
  16. mcp_bridge/hooks/pre_compact.py +123 -0
  17. mcp_bridge/hooks/preemptive_compaction.py +81 -7
  18. mcp_bridge/hooks/rules_injector.py +507 -0
  19. mcp_bridge/hooks/session_idle.py +116 -0
  20. mcp_bridge/hooks/session_notifier.py +125 -0
  21. mcp_bridge/{native_hooks → hooks}/stravinsky_mode.py +51 -16
  22. mcp_bridge/hooks/subagent_stop.py +98 -0
  23. mcp_bridge/hooks/task_validator.py +73 -0
  24. mcp_bridge/hooks/tmux_manager.py +141 -0
  25. mcp_bridge/hooks/todo_continuation.py +90 -0
  26. mcp_bridge/hooks/todo_delegation.py +88 -0
  27. mcp_bridge/hooks/tool_messaging.py +164 -0
  28. mcp_bridge/hooks/truncator.py +21 -17
  29. mcp_bridge/notifications.py +151 -0
  30. mcp_bridge/prompts/__init__.py +3 -1
  31. mcp_bridge/prompts/dewey.py +30 -20
  32. mcp_bridge/prompts/explore.py +46 -8
  33. mcp_bridge/prompts/multimodal.py +24 -3
  34. mcp_bridge/prompts/planner.py +222 -0
  35. mcp_bridge/prompts/stravinsky.py +107 -28
  36. mcp_bridge/server.py +170 -10
  37. mcp_bridge/server_tools.py +554 -32
  38. mcp_bridge/tools/agent_manager.py +316 -106
  39. mcp_bridge/tools/background_tasks.py +2 -1
  40. mcp_bridge/tools/code_search.py +97 -11
  41. mcp_bridge/tools/lsp/__init__.py +7 -0
  42. mcp_bridge/tools/lsp/manager.py +448 -0
  43. mcp_bridge/tools/lsp/tools.py +637 -150
  44. mcp_bridge/tools/model_invoke.py +270 -47
  45. mcp_bridge/tools/semantic_search.py +2492 -0
  46. mcp_bridge/tools/templates.py +32 -18
  47. stravinsky-0.3.4.dist-info/METADATA +420 -0
  48. stravinsky-0.3.4.dist-info/RECORD +79 -0
  49. stravinsky-0.3.4.dist-info/entry_points.txt +5 -0
  50. mcp_bridge/native_hooks/edit_recovery.py +0 -46
  51. mcp_bridge/native_hooks/truncator.py +0 -23
  52. stravinsky-0.2.40.dist-info/METADATA +0 -204
  53. stravinsky-0.2.40.dist-info/RECORD +0 -57
  54. stravinsky-0.2.40.dist-info/entry_points.txt +0 -3
  55. /mcp_bridge/{native_hooks → hooks}/context.py +0 -0
  56. {stravinsky-0.2.40.dist-info → stravinsky-0.3.4.dist-info}/WHEEL +0 -0
@@ -0,0 +1,116 @@
1
+ """
2
+ Session Idle Hook - Stop Hook Implementation.
3
+
4
+ Detects when session becomes idle with incomplete todos and injects
5
+ a continuation prompt to force task completion.
6
+
7
+ Based on oh-my-opencode's todo-continuation-enforcer pattern.
8
+ """
9
+
10
+ import logging
11
+ from typing import Any, Dict, Optional
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Continuation prompt injected when session is idle with incomplete todos
16
+ TODO_CONTINUATION_PROMPT = """
17
+ [SYSTEM REMINDER - TODO CONTINUATION]
18
+
19
+ You have incomplete tasks in your todo list. Continue working on the next pending task.
20
+
21
+ RULES:
22
+ - Proceed immediately without asking for permission
23
+ - Mark the current task as in_progress before starting
24
+ - Mark each task complete when finished
25
+ - Do NOT stop until all tasks are done
26
+ - If blocked, create a new task describing what needs to be resolved
27
+
28
+ STATUS CHECK:
29
+ Use TodoWrite to check your current task status and continue with the next pending item.
30
+ """
31
+
32
+ # Track sessions to prevent duplicate injections
33
+ _idle_sessions: Dict[str, bool] = {}
34
+ _last_activity: Dict[str, float] = {}
35
+
36
+
37
+ async def session_idle_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
38
+ """
39
+ Pre-model-invoke hook that detects idle sessions with incomplete todos.
40
+
41
+ Checks if:
42
+ 1. The conversation has pending todos
43
+ 2. The session has been idle (no recent tool calls)
44
+ 3. A continuation hasn't already been injected
45
+
46
+ If all conditions met, injects TODO_CONTINUATION_PROMPT.
47
+ """
48
+ import time
49
+
50
+ prompt = params.get("prompt", "")
51
+ session_id = params.get("session_id", "default")
52
+
53
+ # Skip if already contains continuation reminder
54
+ if "[SYSTEM REMINDER - TODO CONTINUATION]" in prompt:
55
+ return None
56
+
57
+ # Skip if this is a fresh prompt (user just typed something)
58
+ if params.get("is_user_message", False):
59
+ _last_activity[session_id] = time.time()
60
+ _idle_sessions[session_id] = False
61
+ return None
62
+
63
+ # Check for pending todos in the prompt/context
64
+ has_pending_todos = _detect_pending_todos(prompt)
65
+
66
+ if not has_pending_todos:
67
+ return None
68
+
69
+ # Check idle threshold (2 seconds of no activity)
70
+ current_time = time.time()
71
+ last_activity = _last_activity.get(session_id, current_time)
72
+ idle_seconds = current_time - last_activity
73
+
74
+ if idle_seconds < 2.0:
75
+ return None
76
+
77
+ # Check if already injected for this idle period
78
+ if _idle_sessions.get(session_id, False):
79
+ return None
80
+
81
+ # Mark as injected and inject continuation
82
+ _idle_sessions[session_id] = True
83
+ logger.info(f"[SessionIdleHook] Injecting TODO continuation for session {session_id}")
84
+
85
+ modified_prompt = prompt + "\n\n" + TODO_CONTINUATION_PROMPT
86
+
87
+ return {**params, "prompt": modified_prompt}
88
+
89
+
90
+ def _detect_pending_todos(prompt: str) -> bool:
91
+ """
92
+ Detect if there are pending todos in the conversation.
93
+
94
+ Looks for patterns like:
95
+ - [pending] or status: pending
96
+ - TodoWrite with pending items
97
+ - Incomplete task lists
98
+ """
99
+ pending_patterns = [
100
+ "[pending]",
101
+ "status: pending",
102
+ '"status": "pending"',
103
+ "pending tasks",
104
+ "incomplete tasks",
105
+ "remaining todos",
106
+ ]
107
+
108
+ prompt_lower = prompt.lower()
109
+ return any(pattern.lower() in prompt_lower for pattern in pending_patterns)
110
+
111
+
112
+ def reset_session(session_id: str = "default"):
113
+ """Reset idle state for a session (call when user provides new input)."""
114
+ _idle_sessions[session_id] = False
115
+ import time
116
+ _last_activity[session_id] = time.time()
@@ -0,0 +1,125 @@
1
+ """
2
+ Session Notification Hook.
3
+
4
+ Provides OS-level desktop notifications when sessions are idle.
5
+ """
6
+
7
+ import logging
8
+ import platform
9
+ import subprocess
10
+ from typing import Any, Dict, Optional, Set
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Track which sessions have been notified (avoid duplicates)
15
+ _notified_sessions: Set[str] = set()
16
+
17
+
18
+ def get_notification_command(title: str, message: str, sound: bool = True) -> Optional[list]:
19
+ """
20
+ Get platform-specific notification command.
21
+
22
+ Returns command as list of args, or None if platform not supported.
23
+ """
24
+ system = platform.system()
25
+
26
+ if system == "Darwin": # macOS
27
+ # Use osascript for macOS notifications
28
+ script = f'display notification "{message}" with title "{title}"'
29
+ if sound:
30
+ script += ' sound name "Glass"'
31
+ return ["osascript", "-e", script]
32
+
33
+ elif system == "Linux":
34
+ # Use notify-send for Linux
35
+ cmd = ["notify-send", title, message]
36
+ if sound:
37
+ cmd.extend(["--urgency=normal"])
38
+ return cmd
39
+
40
+ elif system == "Windows":
41
+ # Use PowerShell for Windows notifications
42
+ ps_script = f"""
43
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
44
+ [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
45
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
46
+
47
+ $template = @"
48
+ <toast>
49
+ <visual>
50
+ <binding template="ToastGeneric">
51
+ <text>{title}</text>
52
+ <text>{message}</text>
53
+ </binding>
54
+ </visual>
55
+ </toast>
56
+ "@
57
+
58
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
59
+ $xml.LoadXml($template)
60
+ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
61
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Stravinsky").Show($toast)
62
+ """
63
+ return ["powershell", "-Command", ps_script]
64
+
65
+ return None
66
+
67
+
68
+ async def session_notifier_hook(
69
+ session_id: str,
70
+ has_pending_todos: bool,
71
+ idle_seconds: float,
72
+ params: Dict[str, Any]
73
+ ) -> None:
74
+ """
75
+ Session idle hook that sends desktop notification.
76
+
77
+ Called when session becomes idle with pending work.
78
+ """
79
+ # Skip if already notified for this session
80
+ if session_id in _notified_sessions:
81
+ return
82
+
83
+ # Skip if no pending work
84
+ if not has_pending_todos:
85
+ return
86
+
87
+ # Skip if idle time is too short (< 5 seconds)
88
+ if idle_seconds < 5.0:
89
+ return
90
+
91
+ # Prepare notification
92
+ title = "Stravinsky Session Idle"
93
+ message = f"Session has pending todos and has been idle for {int(idle_seconds)}s"
94
+
95
+ # Get platform-specific command
96
+ cmd = get_notification_command(title, message, sound=True)
97
+
98
+ if not cmd:
99
+ logger.warning(f"[SessionNotifier] Desktop notifications not supported on {platform.system()}")
100
+ return
101
+
102
+ try:
103
+ # Send notification (non-blocking)
104
+ subprocess.Popen(
105
+ cmd,
106
+ stdout=subprocess.DEVNULL,
107
+ stderr=subprocess.DEVNULL,
108
+ start_new_session=True
109
+ )
110
+
111
+ # Mark as notified
112
+ _notified_sessions.add(session_id)
113
+ logger.info(f"[SessionNotifier] Sent desktop notification for session {session_id}")
114
+
115
+ except FileNotFoundError:
116
+ logger.warning(f"[SessionNotifier] Notification command not found: {cmd[0]}")
117
+ except Exception as e:
118
+ logger.error(f"[SessionNotifier] Failed to send notification: {e}")
119
+
120
+
121
+ def clear_notification_state(session_id: str) -> None:
122
+ """
123
+ Clear notification state for a session (called when session resumes activity).
124
+ """
125
+ _notified_sessions.discard(session_id)
@@ -3,12 +3,13 @@
3
3
  Stravinsky Mode Enforcer Hook
4
4
 
5
5
  This PreToolUse hook blocks native file reading tools (Read, Search, Grep, Bash)
6
- when stravinsky orchestrator mode is active, forcing use of agent_spawn instead.
6
+ when stravinsky orchestrator mode is active, forcing use of Task tool for native
7
+ subagent delegation.
7
8
 
8
9
  Stravinsky mode is activated by creating a marker file:
9
10
  ~/.stravinsky_mode
10
11
 
11
- The /strav:stravinsky command should create this file, and it should be
12
+ The /stravinsky command should create this file, and it should be
12
13
  removed when the task is complete.
13
14
 
14
15
  Exit codes:
@@ -27,7 +28,7 @@ STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
27
28
  # Tools to block when in stravinsky mode
28
29
  BLOCKED_TOOLS = {
29
30
  "Read",
30
- "Search",
31
+ "Search",
31
32
  "Grep",
32
33
  "Bash",
33
34
  "MultiEdit",
@@ -38,8 +39,18 @@ BLOCKED_TOOLS = {
38
39
  ALLOWED_TOOLS = {
39
40
  "TodoRead",
40
41
  "TodoWrite",
41
- "Task",
42
- "Agent", # MCP agent tools should be allowed
42
+ "Task", # Native subagent delegation
43
+ "Agent", # MCP agent tools
44
+ }
45
+
46
+ # Agent routing recommendations
47
+ AGENT_ROUTES = {
48
+ "Read": "explore",
49
+ "Grep": "explore",
50
+ "Search": "explore",
51
+ "Bash": "explore",
52
+ "Edit": "code-reviewer",
53
+ "MultiEdit": "code-reviewer",
43
54
  }
44
55
 
45
56
 
@@ -65,33 +76,57 @@ def main():
65
76
  except json.JSONDecodeError:
66
77
  # If we can't parse input, allow the tool
67
78
  sys.exit(0)
68
-
69
- tool_name = hook_input.get("tool_name", "")
70
-
79
+
80
+ tool_name = hook_input.get("toolName", hook_input.get("tool_name", ""))
81
+ params = hook_input.get("params", {})
82
+
71
83
  # Always allow certain tools
72
84
  if tool_name in ALLOWED_TOOLS:
73
85
  sys.exit(0)
74
-
86
+
75
87
  # Check if stravinsky mode is active
76
88
  if not is_stravinsky_mode_active():
77
89
  # Not in stravinsky mode, allow all tools
78
90
  sys.exit(0)
79
-
91
+
80
92
  config = read_stravinsky_mode_config()
81
-
93
+
82
94
  # Check if this tool should be blocked
83
95
  if tool_name in BLOCKED_TOOLS:
96
+ # Determine which agent to delegate to
97
+ agent = AGENT_ROUTES.get(tool_name, "explore")
98
+
99
+ # Get tool context for better messaging
100
+ context = ""
101
+ if tool_name == "Grep":
102
+ pattern = params.get("pattern", "")
103
+ context = f" (searching for '{pattern[:30]}')"
104
+ elif tool_name == "Read":
105
+ file_path = params.get("file_path", "")
106
+ context = f" (reading {os.path.basename(file_path)})" if file_path else ""
107
+
108
+ # User-friendly delegation message
109
+ print(f"🎭 {agent}('Delegating {tool_name}{context}')", file=sys.stderr)
110
+
84
111
  # Block the tool and tell Claude why
85
112
  reason = f"""⚠️ STRAVINSKY MODE ACTIVE - {tool_name} BLOCKED
86
113
 
87
114
  You are in Stravinsky orchestrator mode. Native tools are disabled.
88
115
 
89
- Instead of using {tool_name}, you MUST use:
90
- - stravinsky:agent_spawn with agent_type="explore" for file reading/searching
91
- - stravinsky:agent_spawn with agent_type="dewey" for documentation
116
+ Instead of using {tool_name}, you MUST use Task tool for native subagent delegation:
117
+ - Task(subagent_type="explore", ...) for file reading/searching
118
+ - Task(subagent_type="dewey", ...) for documentation research
119
+ - Task(subagent_type="code-reviewer", ...) for code analysis
120
+ - Task(subagent_type="debugger", ...) for error investigation
121
+ - Task(subagent_type="frontend", ...) for UI/UX work
122
+ - Task(subagent_type="delphi", ...) for strategic architecture decisions
92
123
 
93
124
  Example:
94
- agent_spawn(agent_type="explore", prompt="Read and analyze the file at path/to/file.py")
125
+ Task(
126
+ subagent_type="explore",
127
+ prompt="Read and analyze the authentication module",
128
+ description="Analyze auth"
129
+ )
95
130
 
96
131
  To exit stravinsky mode, run:
97
132
  rm ~/.stravinsky_mode
@@ -100,7 +135,7 @@ To exit stravinsky mode, run:
100
135
  print(reason, file=sys.stderr)
101
136
  # Exit with code 2 to block the tool
102
137
  sys.exit(2)
103
-
138
+
104
139
  # Tool not in block list, allow it
105
140
  sys.exit(0)
106
141
 
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SubagentStop hook: Handler for agent/subagent completion events.
4
+
5
+ Fires when a Claude Code subagent (Task tool) finishes to:
6
+ 1. Output completion status messages
7
+ 2. Verify agent produced expected output
8
+ 3. Block completion if critical validation fails
9
+ 4. Integrate with TODO tracking
10
+
11
+ Exit codes:
12
+ 0 = Allow completion
13
+ 2 = Block completion (force continuation)
14
+ """
15
+
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Optional, Tuple
20
+
21
+
22
+ STRAVINSKY_MODE_FILE = Path.home() / ".stravinsky_mode"
23
+
24
+
25
+ def is_stravinsky_mode() -> bool:
26
+ """Check if stravinsky mode is active."""
27
+ return STRAVINSKY_MODE_FILE.exists()
28
+
29
+
30
+ def extract_subagent_info(hook_input: dict) -> Tuple[str, str, str]:
31
+ """
32
+ Extract subagent information from hook input.
33
+
34
+ Returns: (agent_type, description, status)
35
+ """
36
+ # Try to get from tool parameters or response
37
+ params = hook_input.get("tool_input", hook_input.get("params", {}))
38
+ response = hook_input.get("tool_response", "")
39
+
40
+ agent_type = params.get("subagent_type", "unknown")
41
+ description = params.get("description", "")[:50]
42
+
43
+ # Determine status from response
44
+ status = "completed"
45
+ response_lower = response.lower() if isinstance(response, str) else ""
46
+ if "error" in response_lower or "failed" in response_lower:
47
+ status = "failed"
48
+ elif "timeout" in response_lower:
49
+ status = "timeout"
50
+
51
+ return agent_type, description, status
52
+
53
+
54
+ def format_completion_message(agent_type: str, description: str, status: str) -> str:
55
+ """Format user-friendly completion message."""
56
+ icon = "✓" if status == "completed" else "✗"
57
+ return f"{icon} Subagent {agent_type} {status}: {description}"
58
+
59
+
60
+ def should_block(status: str, agent_type: str) -> bool:
61
+ """
62
+ Determine if we should block completion.
63
+
64
+ Block if:
65
+ - Agent failed AND stravinsky mode active AND critical agent type
66
+ """
67
+ if status != "completed" and is_stravinsky_mode():
68
+ critical_agents = {"delphi", "code-reviewer", "debugger"}
69
+ if agent_type in critical_agents:
70
+ return True
71
+ return False
72
+
73
+
74
+ def main():
75
+ """Main hook entry point."""
76
+ try:
77
+ hook_input = json.load(sys.stdin)
78
+ except (json.JSONDecodeError, EOFError):
79
+ return 0
80
+
81
+ # Extract subagent info
82
+ agent_type, description, status = extract_subagent_info(hook_input)
83
+
84
+ # Output completion message
85
+ message = format_completion_message(agent_type, description, status)
86
+ print(message, file=sys.stderr)
87
+
88
+ # Check if we should block
89
+ if should_block(status, agent_type):
90
+ print(f"\n⚠️ CRITICAL SUBAGENT FAILURE - {agent_type} failed", file=sys.stderr)
91
+ print("Review the error and retry or delegate to delphi.", file=sys.stderr)
92
+ return 2
93
+
94
+ return 0
95
+
96
+
97
+ if __name__ == "__main__":
98
+ sys.exit(main())
@@ -0,0 +1,73 @@
1
+ """
2
+ Task Validator Hook (empty-task-response-detector equivalent).
3
+
4
+ Detects and warns about empty or failed Task tool execution results.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ from typing import Any, Dict, Optional
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ EMPTY_PATTERNS = [
14
+ r"^\s*$", # Completely empty
15
+ r"^null$", # Null response
16
+ r"^None$", # Python None
17
+ r"^undefined$", # JavaScript undefined
18
+ r"^{}$", # Empty JSON object
19
+ r"^\[\]$", # Empty array
20
+ ]
21
+
22
+ TASK_FAILURE_WARNING = """
23
+ [TASK EXECUTION WARNING]
24
+ The background task completed but returned empty or invalid output.
25
+
26
+ Possible causes:
27
+ - Agent terminated prematurely
28
+ - Tool execution failed silently
29
+ - Output was not captured properly
30
+ - Task encountered an unhandled exception
31
+
32
+ Recommended actions:
33
+ 1. Check task logs for errors
34
+ 2. Verify agent has proper tool access
35
+ 3. Re-run task with explicit error handling
36
+ 4. Use agent_progress(task_id) to monitor execution
37
+ """
38
+
39
+
40
+ async def task_validator_hook(
41
+ tool_name: str, tool_input: Dict[str, Any], tool_response: str
42
+ ) -> str:
43
+ """
44
+ Post-tool-call hook that validates Task tool responses.
45
+
46
+ Detects empty/failed responses and injects diagnostic warning.
47
+ """
48
+ # Only validate Task tool responses
49
+ if tool_name not in ["Task", "agent_spawn"]:
50
+ return tool_response
51
+
52
+ # Skip if response is non-empty and meaningful
53
+ if tool_response and len(tool_response.strip()) > 50:
54
+ return tool_response
55
+
56
+ # Check for empty patterns
57
+ response_stripped = tool_response.strip()
58
+ for pattern in EMPTY_PATTERNS:
59
+ if re.match(pattern, response_stripped, re.IGNORECASE):
60
+ logger.warning(
61
+ f"[TaskValidator] Empty/invalid response detected for {tool_name}"
62
+ )
63
+ # Inject warning but preserve original response
64
+ return tool_response + "\n" + TASK_FAILURE_WARNING
65
+
66
+ # Check for suspiciously short responses (< 10 chars)
67
+ if len(response_stripped) < 10:
68
+ logger.warning(
69
+ f"[TaskValidator] Suspiciously short response ({len(response_stripped)} chars)"
70
+ )
71
+ return tool_response + "\n" + TASK_FAILURE_WARNING
72
+
73
+ return tool_response
@@ -0,0 +1,141 @@
1
+ """
2
+ Interactive Bash Session Hook (Tmux Manager).
3
+
4
+ Manages persistent tmux sessions and cleanup.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ import shlex
10
+ import subprocess
11
+ from typing import Any, Dict, List, Optional, Set
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Track tmux sessions created by Stravinsky
16
+ _tracked_sessions: Set[str] = set()
17
+ SESSION_PREFIX = "stravinsky-"
18
+
19
+
20
+ def parse_tmux_command(command: str) -> Optional[str]:
21
+ """
22
+ Parse tmux command to extract session name.
23
+
24
+ Handles quote/escape properly using shlex.
25
+ """
26
+ try:
27
+ # Use shlex to properly parse quoted arguments
28
+ parts = shlex.split(command)
29
+
30
+ # Look for tmux new-session or attach-session
31
+ if "tmux" not in parts:
32
+ return None
33
+
34
+ # Find session name after -t or -s flags
35
+ for i, part in enumerate(parts):
36
+ if part in ["-s", "-t"] and i + 1 < len(parts):
37
+ session_name = parts[i + 1]
38
+ return normalize_session_name(session_name)
39
+
40
+ # Check for inline session name (tmux new -s name)
41
+ for i, part in enumerate(parts):
42
+ if part == "new" or part == "new-session":
43
+ # Look for -s in following parts
44
+ for j in range(i + 1, len(parts)):
45
+ if parts[j] == "-s" and j + 1 < len(parts):
46
+ return normalize_session_name(parts[j + 1])
47
+
48
+ except Exception as e:
49
+ logger.error(f"[TmuxManager] Failed to parse tmux command: {e}")
50
+ return None
51
+
52
+ return None
53
+
54
+
55
+ def normalize_session_name(name: str) -> str:
56
+ """
57
+ Normalize tmux session name (strip window/pane suffixes).
58
+
59
+ Examples:
60
+ "session:0" -> "session"
61
+ "session:window.pane" -> "session"
62
+ """
63
+ # Split on : to remove window/pane references
64
+ return name.split(":")[0]
65
+
66
+
67
+ async def tmux_manager_hook(
68
+ tool_name: str, tool_input: Dict[str, Any], tool_output: Optional[str] = None
69
+ ) -> Optional[str]:
70
+ """
71
+ Post-tool-call hook that tracks tmux sessions.
72
+
73
+ Monitors Bash tool for tmux commands and tracks session names.
74
+ """
75
+ # Only process Bash tool
76
+ if tool_name != "Bash":
77
+ return None
78
+
79
+ command = tool_input.get("command", "")
80
+ if not command or "tmux" not in command:
81
+ return None
82
+
83
+ # Parse session name from tmux command
84
+ session_name = parse_tmux_command(command)
85
+
86
+ if session_name:
87
+ # Track with Stravinsky prefix
88
+ if not session_name.startswith(SESSION_PREFIX):
89
+ session_name = SESSION_PREFIX + session_name
90
+
91
+ _tracked_sessions.add(session_name)
92
+ logger.info(f"[TmuxManager] Tracking tmux session: {session_name}")
93
+
94
+ # Append reminder about active sessions
95
+ if tool_output:
96
+ reminder = f"\n\n[TMUX SESSION] Active session tracked: {session_name}\n" \
97
+ f"Cleanup on session end: kill-session -t {session_name}"
98
+ return tool_output + reminder
99
+
100
+ return tool_output
101
+
102
+
103
+ def cleanup_tmux_sessions() -> List[str]:
104
+ """
105
+ Kill all tracked tmux sessions.
106
+
107
+ Returns list of killed session names.
108
+ """
109
+ killed = []
110
+
111
+ for session_name in _tracked_sessions:
112
+ try:
113
+ # Kill tmux session
114
+ subprocess.run(
115
+ ["tmux", "kill-session", "-t", session_name],
116
+ stdout=subprocess.DEVNULL,
117
+ stderr=subprocess.DEVNULL,
118
+ timeout=5
119
+ )
120
+ killed.append(session_name)
121
+ logger.info(f"[TmuxManager] Killed tmux session: {session_name}")
122
+
123
+ except FileNotFoundError:
124
+ logger.warning("[TmuxManager] tmux command not found")
125
+ break
126
+ except subprocess.TimeoutExpired:
127
+ logger.warning(f"[TmuxManager] Timeout killing session: {session_name}")
128
+ except Exception as e:
129
+ logger.error(f"[TmuxManager] Failed to kill session {session_name}: {e}")
130
+
131
+ # Clear tracked sessions
132
+ _tracked_sessions.clear()
133
+
134
+ return killed
135
+
136
+
137
+ def get_tracked_sessions() -> Set[str]:
138
+ """
139
+ Get set of currently tracked tmux sessions.
140
+ """
141
+ return _tracked_sessions.copy()