aline-ai 0.5.1__py3-none-any.whl → 0.5.3__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.
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.3.dist-info}/METADATA +1 -1
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.3.dist-info}/RECORD +13 -11
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.3.dist-info}/WHEEL +1 -1
- realign/__init__.py +1 -1
- realign/claude_hooks/permission_request_hook.py +154 -0
- realign/claude_hooks/permission_request_hook_installer.py +241 -0
- realign/claude_hooks/stop_hook.py +28 -0
- realign/commands/init.py +56 -1
- realign/dashboard/tmux_manager.py +52 -3
- realign/dashboard/widgets/terminal_panel.py +41 -5
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.3.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.3.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.5.1.dist-info → aline_ai-0.5.3.dist-info}/top_level.txt +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
aline_ai-0.5.
|
|
2
|
-
realign/__init__.py,sha256=
|
|
1
|
+
aline_ai-0.5.3.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
|
|
2
|
+
realign/__init__.py,sha256=ii28HcuehiV7QZMbEa-1t_hZkRPYlMzW801qcAZLYzo,1623
|
|
3
3
|
realign/claude_detector.py,sha256=hU5OcFO7JH9BCVbmajAmz4TIP-EQuvz9VlpsRYuSoVM,2792
|
|
4
4
|
realign/cli.py,sha256=CGuAGp4s7LCV7DOuF54eF2s6xe30oie7zkCF8xttXaU,29207
|
|
5
5
|
realign/codex_detector.py,sha256=vDjRWHycjzX9Pavv8IwrznMf5oyFKBn4FvqYYy3I0s4,4141
|
|
@@ -24,7 +24,9 @@ realign/adapters/codex.py,sha256=o9XyZEPDVqskHgXSkCDovu3yJM8x6HTIIobU90MGE3s,207
|
|
|
24
24
|
realign/adapters/gemini.py,sha256=Oaucgz6l_6Cb0GDdt0ant_Pun6B1CfrDMN9B4irIHXc,2636
|
|
25
25
|
realign/adapters/registry.py,sha256=gJf9MSfd0clt653eBfcM17snrSDXeQDwVXal0N2NrHo,3303
|
|
26
26
|
realign/claude_hooks/__init__.py,sha256=-2CiH5UIjPQzok2pBQ8yIVXB5oFxkzLhFXQ8NDDVy0c,594
|
|
27
|
-
realign/claude_hooks/
|
|
27
|
+
realign/claude_hooks/permission_request_hook.py,sha256=p3rTT5zyFPUzSeGW2_5uW5P7cwyK109UoFRwAd47X88,5220
|
|
28
|
+
realign/claude_hooks/permission_request_hook_installer.py,sha256=B05ey_7OT3tlJBGzBlmy6DFJdW3OGMZCCrk1HSXl0Cs,7800
|
|
29
|
+
realign/claude_hooks/stop_hook.py,sha256=bcWr9jCDZErijSzVSP-kdfmoZ6DHYc6hkRaJ_A2PmQ8,11989
|
|
28
30
|
realign/claude_hooks/stop_hook_installer.py,sha256=BjzabUrAPLCA0m_8ZQWqDW4eCujdPTzlwUNdVGxtRk4,7238
|
|
29
31
|
realign/claude_hooks/terminal_state.py,sha256=ZvdQ-ZmqEltdMoNk3lXVsbpvbAQEmf2hxTCY_8WFu9g,2586
|
|
30
32
|
realign/claude_hooks/user_prompt_submit_hook.py,sha256=WD-UavhBTueN2TPfnZrnPC7DFYGEeptjUEF21EJn7Qo,10312
|
|
@@ -35,7 +37,7 @@ realign/commands/config.py,sha256=rVwWUgLQDoRh25bjNzsN2eC77aiaPB5D77UtxgS3RlY,67
|
|
|
35
37
|
realign/commands/context.py,sha256=tD5jQG4kXPfV57PrS1Du3JUzvY6RCbNbxsOQWdomrPg,7057
|
|
36
38
|
realign/commands/export_shares.py,sha256=6omUtPK8OJsWfrzeQAx5LbM8-lOiONZtWa9btpVumVQ,136179
|
|
37
39
|
realign/commands/import_shares.py,sha256=bDf-_1hnlFbqBvVnLTg8Qm4JheQcjTFhhmiBlg75M7k,25314
|
|
38
|
-
realign/commands/init.py,sha256=
|
|
40
|
+
realign/commands/init.py,sha256=ZJpmB0QhKHzEi8d8g8i5QKgnEFl8V6Kt7vFEPOWHzxM,27454
|
|
39
41
|
realign/commands/restore.py,sha256=59WXsS09EN6wbYgmc1xYdArF-qhCMbmEwyj8vGLmxTc,11057
|
|
40
42
|
realign/commands/search.py,sha256=_ovwHzDNSZEVP4EdOcg9ELQ5y_84BvXRHhKKALHD738,25173
|
|
41
43
|
realign/commands/upgrade.py,sha256=7FuuLTQ3KyH33GD8RWMh1JMwFnVV3naolS-SQ5vxCjE,8547
|
|
@@ -43,7 +45,7 @@ realign/commands/watcher.py,sha256=6EBzIc439ClqS4UG8iGd_tfb5MQMPeieAgjCc-M-NEI,1
|
|
|
43
45
|
realign/commands/worker.py,sha256=LErEjB9T9_XatuLTS9Wn0BbSFc3ah0O9lFTzQOin4qU,22673
|
|
44
46
|
realign/dashboard/__init__.py,sha256=QZkHTsGityH8UkF8rmvA3xW7dMXNe0swEWr443qfgCM,128
|
|
45
47
|
realign/dashboard/app.py,sha256=SOlQ6QHt01-Jj5Daa5xyigrNhR-gXC3jupH24lcbeLY,11935
|
|
46
|
-
realign/dashboard/tmux_manager.py,sha256=
|
|
48
|
+
realign/dashboard/tmux_manager.py,sha256=XDfznyF9-j5fKdxzSNtGWh8L0i7_AG3S_MZhgYLtsZ8,21066
|
|
47
49
|
realign/dashboard/screens/__init__.py,sha256=x42K31sqL5KVMtufOnZjG8LnFN7hQyN5-z8CySqbwlM,304
|
|
48
50
|
realign/dashboard/screens/create_event.py,sha256=nPMZMOekduxLXBTjMmLJEQMhb33RXWRIY0uHbD5fmmM,5484
|
|
49
51
|
realign/dashboard/screens/event_detail.py,sha256=WJFO7pryO0DZIMtyA-IOriFYtSLZ5Ri5AVSzjYiW1BQ,20842
|
|
@@ -57,7 +59,7 @@ realign/dashboard/widgets/header.py,sha256=ESejMT53T3sbtRrlCGJk8smUv0ts8binkshzC
|
|
|
57
59
|
realign/dashboard/widgets/openable_table.py,sha256=GeJPDEYp0kRHShqvmPMzAePpYXRZHUNqcWNnxqsqxjA,1963
|
|
58
60
|
realign/dashboard/widgets/search_panel.py,sha256=D0NXVIdNXpcnnVETfA44Muue4CyZq5XkMj4vfpNb3LQ,8635
|
|
59
61
|
realign/dashboard/widgets/sessions_table.py,sha256=CfSy93AbAqGGDFxEQW4u59twZDoVnHyuMmPcSOZTiW4,21852
|
|
60
|
-
realign/dashboard/widgets/terminal_panel.py,sha256=
|
|
62
|
+
realign/dashboard/widgets/terminal_panel.py,sha256=85FTGJVd8sRW_AoWJXFUTaVWIaB_kqtREpwkv7_GcYc,24560
|
|
61
63
|
realign/dashboard/widgets/watcher_panel.py,sha256=ThQfVi_GOP-KR1GDNh5WsSmPJ11TEiP_fuuyxd2sdEk,19662
|
|
62
64
|
realign/dashboard/widgets/worker_panel.py,sha256=ufJYpW0nIPcek7GHa1nuMkrpNU9AY0WvtnWzhNZneW0,18547
|
|
63
65
|
realign/db/__init__.py,sha256=dqFKTskcVA7qCn7JAXeUz_c5T0_g--e9BAVokmk2Ys0,1874
|
|
@@ -84,8 +86,8 @@ realign/triggers/next_turn_trigger.py,sha256=CU6jsIxA_uoV-ROIke0iwEh4-on8vuataLG
|
|
|
84
86
|
realign/triggers/registry.py,sha256=AVlMm5xjzWLCnJMuzvfw4hMdNGlLqSTsgd3VCOZ-cHs,3799
|
|
85
87
|
realign/triggers/turn_status.py,sha256=tPHUB3NnnBfMVCIYdrFt5W1840IUGEZ-3v2GEtC981g,5987
|
|
86
88
|
realign/triggers/turn_summary.py,sha256=f3hEUshgv9skJ9AbfWpoYs417lsv_HK2A_vpPjgryO4,4467
|
|
87
|
-
aline_ai-0.5.
|
|
88
|
-
aline_ai-0.5.
|
|
89
|
-
aline_ai-0.5.
|
|
90
|
-
aline_ai-0.5.
|
|
91
|
-
aline_ai-0.5.
|
|
89
|
+
aline_ai-0.5.3.dist-info/METADATA,sha256=6R443dwfUWv_LioywElkXwak1fzgKbrJz_hcaeSLjo0,1597
|
|
90
|
+
aline_ai-0.5.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
91
|
+
aline_ai-0.5.3.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
|
|
92
|
+
aline_ai-0.5.3.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
|
|
93
|
+
aline_ai-0.5.3.dist-info/RECORD,,
|
realign/__init__.py
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Claude Code PermissionRequest Hook - Set attention state when permission is needed
|
|
4
|
+
|
|
5
|
+
When Claude Code needs user approval for a tool, this script is called.
|
|
6
|
+
It sets the @aline_attention tmux window option to notify the dashboard.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
This script is called via Claude Code's PermissionRequest hook mechanism,
|
|
10
|
+
receiving stdin JSON and environment variables.
|
|
11
|
+
|
|
12
|
+
Environment variables:
|
|
13
|
+
ALINE_TERMINAL_ID - Terminal ID for the current window
|
|
14
|
+
ALINE_INNER_TMUX_SOCKET - Inner tmux socket name
|
|
15
|
+
ALINE_INNER_TMUX_SESSION - Inner tmux session name
|
|
16
|
+
|
|
17
|
+
stdin JSON format:
|
|
18
|
+
{
|
|
19
|
+
"session_id": "...",
|
|
20
|
+
"tool_name": "...",
|
|
21
|
+
"tool_input": {...}
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import json
|
|
28
|
+
import subprocess
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def main():
|
|
32
|
+
"""Main function"""
|
|
33
|
+
try:
|
|
34
|
+
# Read stdin JSON (optional, we mainly care about setting attention)
|
|
35
|
+
stdin_data = sys.stdin.read()
|
|
36
|
+
try:
|
|
37
|
+
data = json.loads(stdin_data) if stdin_data.strip() else {}
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
data = {}
|
|
40
|
+
|
|
41
|
+
# From environment
|
|
42
|
+
terminal_id = os.environ.get("ALINE_TERMINAL_ID", "")
|
|
43
|
+
inner_socket = os.environ.get("ALINE_INNER_TMUX_SOCKET", "")
|
|
44
|
+
inner_session = os.environ.get("ALINE_INNER_TMUX_SESSION", "")
|
|
45
|
+
|
|
46
|
+
# Try to get terminal_id from tmux if not in env
|
|
47
|
+
if not terminal_id:
|
|
48
|
+
try:
|
|
49
|
+
terminal_id = (
|
|
50
|
+
subprocess.run(
|
|
51
|
+
["tmux", "display-message", "-p", "#{@aline_terminal_id}"],
|
|
52
|
+
text=True,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
check=False,
|
|
55
|
+
).stdout
|
|
56
|
+
or ""
|
|
57
|
+
).strip()
|
|
58
|
+
except Exception:
|
|
59
|
+
terminal_id = ""
|
|
60
|
+
|
|
61
|
+
# Set attention state on the tmux window
|
|
62
|
+
try:
|
|
63
|
+
if terminal_id and inner_socket and inner_session:
|
|
64
|
+
# Find the window with matching terminal_id
|
|
65
|
+
proc = subprocess.run(
|
|
66
|
+
[
|
|
67
|
+
"tmux",
|
|
68
|
+
"-L",
|
|
69
|
+
inner_socket,
|
|
70
|
+
"list-windows",
|
|
71
|
+
"-t",
|
|
72
|
+
inner_session,
|
|
73
|
+
"-F",
|
|
74
|
+
"#{window_id}\t#{@aline_terminal_id}",
|
|
75
|
+
],
|
|
76
|
+
text=True,
|
|
77
|
+
capture_output=True,
|
|
78
|
+
check=False,
|
|
79
|
+
)
|
|
80
|
+
for line in (proc.stdout or "").splitlines():
|
|
81
|
+
parts = line.split("\t", 1)
|
|
82
|
+
if len(parts) != 2:
|
|
83
|
+
continue
|
|
84
|
+
window_id, win_terminal_id = parts
|
|
85
|
+
if win_terminal_id == terminal_id:
|
|
86
|
+
subprocess.run(
|
|
87
|
+
[
|
|
88
|
+
"tmux",
|
|
89
|
+
"-L",
|
|
90
|
+
inner_socket,
|
|
91
|
+
"set-option",
|
|
92
|
+
"-w",
|
|
93
|
+
"-t",
|
|
94
|
+
window_id,
|
|
95
|
+
"@aline_attention",
|
|
96
|
+
"permission_request",
|
|
97
|
+
],
|
|
98
|
+
check=False,
|
|
99
|
+
)
|
|
100
|
+
break
|
|
101
|
+
else:
|
|
102
|
+
# Fallback: try to set on current window
|
|
103
|
+
window_id = (
|
|
104
|
+
subprocess.run(
|
|
105
|
+
["tmux", "display-message", "-p", "#{window_id}"],
|
|
106
|
+
text=True,
|
|
107
|
+
capture_output=True,
|
|
108
|
+
check=False,
|
|
109
|
+
).stdout
|
|
110
|
+
or ""
|
|
111
|
+
).strip()
|
|
112
|
+
|
|
113
|
+
should_tag = bool(terminal_id)
|
|
114
|
+
if not should_tag:
|
|
115
|
+
try:
|
|
116
|
+
context_id = (
|
|
117
|
+
subprocess.run(
|
|
118
|
+
["tmux", "display-message", "-p", "#{@aline_context_id}"],
|
|
119
|
+
text=True,
|
|
120
|
+
capture_output=True,
|
|
121
|
+
check=False,
|
|
122
|
+
).stdout
|
|
123
|
+
or ""
|
|
124
|
+
).strip()
|
|
125
|
+
should_tag = bool(context_id)
|
|
126
|
+
except Exception:
|
|
127
|
+
should_tag = False
|
|
128
|
+
|
|
129
|
+
if window_id and should_tag:
|
|
130
|
+
subprocess.run(
|
|
131
|
+
[
|
|
132
|
+
"tmux",
|
|
133
|
+
"set-option",
|
|
134
|
+
"-w",
|
|
135
|
+
"-t",
|
|
136
|
+
window_id,
|
|
137
|
+
"@aline_attention",
|
|
138
|
+
"permission_request",
|
|
139
|
+
],
|
|
140
|
+
check=False,
|
|
141
|
+
)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
# Exit 0 - don't block the permission request
|
|
146
|
+
sys.exit(0)
|
|
147
|
+
|
|
148
|
+
except Exception as e:
|
|
149
|
+
# Fail silently to not affect Claude Code's normal operation
|
|
150
|
+
sys.exit(0)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
main()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Code PermissionRequest Hook Auto-installer
|
|
3
|
+
|
|
4
|
+
Automatically installs Aline's PermissionRequest hook into Claude Code's configuration.
|
|
5
|
+
Uses append mode - does not overwrite user's existing hooks configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from ..logging_config import setup_logger
|
|
14
|
+
|
|
15
|
+
logger = setup_logger('realign.hooks.permission_request_installer', 'hooks_installer.log')
|
|
16
|
+
|
|
17
|
+
# Marker to identify Aline hook for later uninstallation
|
|
18
|
+
ALINE_HOOK_MARKER = "aline-permission-request-hook"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_permission_request_hook_script_path() -> Path:
|
|
22
|
+
"""Get path to permission_request_hook.py script"""
|
|
23
|
+
return Path(__file__).parent / 'permission_request_hook.py'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_permission_request_hook_command() -> str:
|
|
27
|
+
"""
|
|
28
|
+
Get the PermissionRequest hook execution command.
|
|
29
|
+
|
|
30
|
+
Uses direct script path to avoid module import issues.
|
|
31
|
+
"""
|
|
32
|
+
script_path = get_permission_request_hook_script_path()
|
|
33
|
+
return f"{sys.executable} {script_path}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_settings_path(project_path: Optional[Path] = None) -> Path:
|
|
37
|
+
"""
|
|
38
|
+
Get Claude Code settings.json path.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
project_path: If provided, returns project-level config path; otherwise global config.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
Path to settings.json
|
|
45
|
+
"""
|
|
46
|
+
if project_path:
|
|
47
|
+
return project_path / '.claude' / 'settings.local.json'
|
|
48
|
+
return Path.home() / '.claude' / 'settings.json'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def is_hook_installed(settings_path: Path) -> bool:
|
|
52
|
+
"""
|
|
53
|
+
Check if Aline PermissionRequest hook is installed.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
settings_path: Path to settings.json
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
True if installed
|
|
60
|
+
"""
|
|
61
|
+
if not settings_path.exists():
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
settings = json.loads(settings_path.read_text())
|
|
66
|
+
permission_hooks = settings.get('hooks', {}).get('PermissionRequest', [])
|
|
67
|
+
|
|
68
|
+
for hook_config in permission_hooks:
|
|
69
|
+
hooks_list = hook_config.get('hooks', [])
|
|
70
|
+
for h in hooks_list:
|
|
71
|
+
command = h.get('command', '')
|
|
72
|
+
if ALINE_HOOK_MARKER in command or 'permission_request_hook' in command:
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
except Exception:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def install_permission_request_hook(
|
|
82
|
+
settings_path: Optional[Path] = None,
|
|
83
|
+
force: bool = False,
|
|
84
|
+
quiet: bool = False,
|
|
85
|
+
) -> bool:
|
|
86
|
+
"""
|
|
87
|
+
Install Claude Code PermissionRequest hook.
|
|
88
|
+
|
|
89
|
+
Uses append mode: does not overwrite user's existing hooks configuration.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
settings_path: Path to settings.json (defaults to global config)
|
|
93
|
+
force: If True, reinstall even if already installed
|
|
94
|
+
quiet: If True, don't output any messages
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
True if installation successful or already installed
|
|
98
|
+
"""
|
|
99
|
+
if settings_path is None:
|
|
100
|
+
settings_path = get_settings_path()
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
# Check if already installed
|
|
104
|
+
if not force and is_hook_installed(settings_path):
|
|
105
|
+
logger.debug("Aline PermissionRequest hook already installed")
|
|
106
|
+
return True
|
|
107
|
+
|
|
108
|
+
# Read existing settings
|
|
109
|
+
settings = {}
|
|
110
|
+
if settings_path.exists():
|
|
111
|
+
try:
|
|
112
|
+
settings = json.loads(settings_path.read_text())
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
logger.warning(f"Invalid JSON in {settings_path}, creating new settings")
|
|
115
|
+
settings = {}
|
|
116
|
+
|
|
117
|
+
# Ensure hooks structure exists
|
|
118
|
+
if 'hooks' not in settings:
|
|
119
|
+
settings['hooks'] = {}
|
|
120
|
+
if 'PermissionRequest' not in settings['hooks']:
|
|
121
|
+
settings['hooks']['PermissionRequest'] = []
|
|
122
|
+
|
|
123
|
+
# If force install, remove old Aline hook first
|
|
124
|
+
if force:
|
|
125
|
+
permission_hooks = settings['hooks']['PermissionRequest']
|
|
126
|
+
new_hooks = []
|
|
127
|
+
for hook_config in permission_hooks:
|
|
128
|
+
hooks_list = hook_config.get('hooks', [])
|
|
129
|
+
filtered = [
|
|
130
|
+
h for h in hooks_list
|
|
131
|
+
if ALINE_HOOK_MARKER not in h.get('command', '')
|
|
132
|
+
and 'permission_request_hook' not in h.get('command', '')
|
|
133
|
+
]
|
|
134
|
+
if filtered:
|
|
135
|
+
hook_config['hooks'] = filtered
|
|
136
|
+
new_hooks.append(hook_config)
|
|
137
|
+
settings['hooks']['PermissionRequest'] = new_hooks
|
|
138
|
+
|
|
139
|
+
# Append Aline hook with matcher for all tools
|
|
140
|
+
hook_command = get_permission_request_hook_command()
|
|
141
|
+
aline_hook = {
|
|
142
|
+
'matcher': '*', # Match all tools
|
|
143
|
+
'hooks': [{
|
|
144
|
+
'type': 'command',
|
|
145
|
+
'command': f'{hook_command} # {ALINE_HOOK_MARKER}'
|
|
146
|
+
}]
|
|
147
|
+
}
|
|
148
|
+
settings['hooks']['PermissionRequest'].append(aline_hook)
|
|
149
|
+
|
|
150
|
+
# Write back settings
|
|
151
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
settings_path.write_text(json.dumps(settings, indent=2))
|
|
153
|
+
|
|
154
|
+
logger.info(f"Aline PermissionRequest hook installed to {settings_path}")
|
|
155
|
+
if not quiet:
|
|
156
|
+
print(f"[Aline] PermissionRequest hook installed to {settings_path}", file=sys.stderr)
|
|
157
|
+
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
logger.error(f"Failed to install PermissionRequest hook: {e}")
|
|
162
|
+
if not quiet:
|
|
163
|
+
print(f"[Aline] Failed to install PermissionRequest hook: {e}", file=sys.stderr)
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def uninstall_permission_request_hook(
|
|
168
|
+
settings_path: Optional[Path] = None,
|
|
169
|
+
quiet: bool = False,
|
|
170
|
+
) -> bool:
|
|
171
|
+
"""
|
|
172
|
+
Uninstall Aline PermissionRequest hook.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
settings_path: Path to settings.json (defaults to global config)
|
|
176
|
+
quiet: If True, don't output any messages
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
True if uninstallation successful
|
|
180
|
+
"""
|
|
181
|
+
if settings_path is None:
|
|
182
|
+
settings_path = get_settings_path()
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
if not settings_path.exists():
|
|
186
|
+
return True
|
|
187
|
+
|
|
188
|
+
settings = json.loads(settings_path.read_text())
|
|
189
|
+
permission_hooks = settings.get('hooks', {}).get('PermissionRequest', [])
|
|
190
|
+
|
|
191
|
+
# Filter out Aline hook
|
|
192
|
+
new_hooks = []
|
|
193
|
+
removed = False
|
|
194
|
+
for hook_config in permission_hooks:
|
|
195
|
+
hooks_list = hook_config.get('hooks', [])
|
|
196
|
+
filtered = [
|
|
197
|
+
h for h in hooks_list
|
|
198
|
+
if ALINE_HOOK_MARKER not in h.get('command', '')
|
|
199
|
+
and 'permission_request_hook' not in h.get('command', '')
|
|
200
|
+
]
|
|
201
|
+
if len(filtered) < len(hooks_list):
|
|
202
|
+
removed = True
|
|
203
|
+
if filtered:
|
|
204
|
+
hook_config['hooks'] = filtered
|
|
205
|
+
new_hooks.append(hook_config)
|
|
206
|
+
|
|
207
|
+
if removed:
|
|
208
|
+
settings['hooks']['PermissionRequest'] = new_hooks
|
|
209
|
+
settings_path.write_text(json.dumps(settings, indent=2))
|
|
210
|
+
logger.info("Aline PermissionRequest hook uninstalled")
|
|
211
|
+
if not quiet:
|
|
212
|
+
print("[Aline] PermissionRequest hook uninstalled", file=sys.stderr)
|
|
213
|
+
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
except Exception as e:
|
|
217
|
+
logger.error(f"Failed to uninstall PermissionRequest hook: {e}")
|
|
218
|
+
if not quiet:
|
|
219
|
+
print(f"[Aline] Failed to uninstall PermissionRequest hook: {e}", file=sys.stderr)
|
|
220
|
+
return False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def ensure_permission_request_hook_installed(quiet: bool = True) -> bool:
|
|
224
|
+
"""
|
|
225
|
+
Ensure PermissionRequest hook is installed (called at watcher startup).
|
|
226
|
+
|
|
227
|
+
This is an idempotent operation - will not reinstall if already installed.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
quiet: If True, only output message on first install
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
True if hook is available
|
|
234
|
+
"""
|
|
235
|
+
settings_path = get_settings_path()
|
|
236
|
+
|
|
237
|
+
if is_hook_installed(settings_path):
|
|
238
|
+
logger.debug("PermissionRequest hook already installed, skipping")
|
|
239
|
+
return True
|
|
240
|
+
|
|
241
|
+
return install_permission_request_hook(settings_path, quiet=quiet)
|
|
@@ -175,6 +175,21 @@ def main():
|
|
|
175
175
|
],
|
|
176
176
|
check=False,
|
|
177
177
|
)
|
|
178
|
+
# Set attention state to notify dashboard
|
|
179
|
+
subprocess.run(
|
|
180
|
+
[
|
|
181
|
+
"tmux",
|
|
182
|
+
"-L",
|
|
183
|
+
inner_socket,
|
|
184
|
+
"set-option",
|
|
185
|
+
"-w",
|
|
186
|
+
"-t",
|
|
187
|
+
window_id,
|
|
188
|
+
"@aline_attention",
|
|
189
|
+
"stop",
|
|
190
|
+
],
|
|
191
|
+
check=False,
|
|
192
|
+
)
|
|
178
193
|
if transcript_path:
|
|
179
194
|
subprocess.run(
|
|
180
195
|
[
|
|
@@ -254,6 +269,19 @@ def main():
|
|
|
254
269
|
],
|
|
255
270
|
check=False,
|
|
256
271
|
)
|
|
272
|
+
# Set attention state to notify dashboard
|
|
273
|
+
subprocess.run(
|
|
274
|
+
[
|
|
275
|
+
"tmux",
|
|
276
|
+
"set-option",
|
|
277
|
+
"-w",
|
|
278
|
+
"-t",
|
|
279
|
+
window_id,
|
|
280
|
+
"@aline_attention",
|
|
281
|
+
"stop",
|
|
282
|
+
],
|
|
283
|
+
check=False,
|
|
284
|
+
)
|
|
257
285
|
if transcript_path:
|
|
258
286
|
subprocess.run(
|
|
259
287
|
[
|
realign/commands/init.py
CHANGED
|
@@ -425,6 +425,48 @@ def _initialize_skills() -> Path:
|
|
|
425
425
|
return skill_root
|
|
426
426
|
|
|
427
427
|
|
|
428
|
+
def _initialize_claude_hooks() -> Tuple[bool, list]:
|
|
429
|
+
"""Initialize Claude Code hooks (Stop, UserPromptSubmit, PermissionRequest).
|
|
430
|
+
|
|
431
|
+
Installs all Aline hooks to the global Claude Code settings.
|
|
432
|
+
Does not overwrite existing hooks.
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
(all_success, list of installed hook names)
|
|
436
|
+
"""
|
|
437
|
+
installed_hooks = []
|
|
438
|
+
all_success = True
|
|
439
|
+
|
|
440
|
+
try:
|
|
441
|
+
from ..claude_hooks.stop_hook_installer import ensure_stop_hook_installed
|
|
442
|
+
if ensure_stop_hook_installed(quiet=True):
|
|
443
|
+
installed_hooks.append("Stop")
|
|
444
|
+
else:
|
|
445
|
+
all_success = False
|
|
446
|
+
except Exception:
|
|
447
|
+
all_success = False
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
from ..claude_hooks.user_prompt_submit_hook_installer import ensure_user_prompt_submit_hook_installed
|
|
451
|
+
if ensure_user_prompt_submit_hook_installed(quiet=True):
|
|
452
|
+
installed_hooks.append("UserPromptSubmit")
|
|
453
|
+
else:
|
|
454
|
+
all_success = False
|
|
455
|
+
except Exception:
|
|
456
|
+
all_success = False
|
|
457
|
+
|
|
458
|
+
try:
|
|
459
|
+
from ..claude_hooks.permission_request_hook_installer import ensure_permission_request_hook_installed
|
|
460
|
+
if ensure_permission_request_hook_installed(quiet=True):
|
|
461
|
+
installed_hooks.append("PermissionRequest")
|
|
462
|
+
else:
|
|
463
|
+
all_success = False
|
|
464
|
+
except Exception:
|
|
465
|
+
all_success = False
|
|
466
|
+
|
|
467
|
+
return all_success, installed_hooks
|
|
468
|
+
|
|
469
|
+
|
|
428
470
|
def init_global(
|
|
429
471
|
force: bool = False,
|
|
430
472
|
) -> Dict[str, Any]:
|
|
@@ -444,6 +486,7 @@ def init_global(
|
|
|
444
486
|
"prompts_dir": None,
|
|
445
487
|
"tmux_conf": None,
|
|
446
488
|
"skills_path": None,
|
|
489
|
+
"hooks_installed": None,
|
|
447
490
|
"message": "",
|
|
448
491
|
"errors": [],
|
|
449
492
|
}
|
|
@@ -512,8 +555,14 @@ def init_global(
|
|
|
512
555
|
skills_path = _initialize_skills()
|
|
513
556
|
result["skills_path"] = str(skills_path)
|
|
514
557
|
|
|
558
|
+
# Initialize Claude Code hooks (Stop, UserPromptSubmit, PermissionRequest)
|
|
559
|
+
hooks_success, hooks_installed = _initialize_claude_hooks()
|
|
560
|
+
result["hooks_installed"] = hooks_installed
|
|
561
|
+
if not hooks_success:
|
|
562
|
+
result["errors"].append("Some Claude Code hooks failed to install")
|
|
563
|
+
|
|
515
564
|
result["success"] = True
|
|
516
|
-
result["message"] = "Aline initialized successfully (global config + database + prompts + tmux + skills ready)"
|
|
565
|
+
result["message"] = "Aline initialized successfully (global config + database + prompts + tmux + skills + hooks ready)"
|
|
517
566
|
|
|
518
567
|
except Exception as e:
|
|
519
568
|
result["errors"].append(f"Initialization failed: {e}")
|
|
@@ -589,6 +638,12 @@ def init_command(
|
|
|
589
638
|
console.print(f" Tmux: [cyan]{result.get('tmux_conf', 'N/A')}[/cyan]")
|
|
590
639
|
console.print(f" Skills: [cyan]{result.get('skills_path', 'N/A')}[/cyan]")
|
|
591
640
|
|
|
641
|
+
hooks_installed = result.get("hooks_installed") or []
|
|
642
|
+
if hooks_installed:
|
|
643
|
+
console.print(f" Hooks: [cyan]{', '.join(hooks_installed)}[/cyan]")
|
|
644
|
+
else:
|
|
645
|
+
console.print(" Hooks: [yellow]None installed[/yellow]")
|
|
646
|
+
|
|
592
647
|
if result.get("success") and should_start:
|
|
593
648
|
console.print("\n[bold]Watcher:[/bold]")
|
|
594
649
|
if watcher_started:
|
|
@@ -38,6 +38,7 @@ OPT_SESSION_TYPE = "@aline_session_type"
|
|
|
38
38
|
OPT_SESSION_ID = "@aline_session_id"
|
|
39
39
|
OPT_TRANSCRIPT_PATH = "@aline_transcript_path"
|
|
40
40
|
OPT_CONTEXT_ID = "@aline_context_id"
|
|
41
|
+
OPT_ATTENTION = "@aline_attention"
|
|
41
42
|
|
|
42
43
|
|
|
43
44
|
@dataclass(frozen=True)
|
|
@@ -49,7 +50,9 @@ class InnerWindow:
|
|
|
49
50
|
provider: str | None = None
|
|
50
51
|
session_type: str | None = None
|
|
51
52
|
session_id: str | None = None
|
|
53
|
+
transcript_path: str | None = None
|
|
52
54
|
context_id: str | None = None
|
|
55
|
+
attention: str | None = None # "permission_request", "stop", or None
|
|
53
56
|
|
|
54
57
|
|
|
55
58
|
def tmux_available() -> bool:
|
|
@@ -144,8 +147,35 @@ def new_context_id(prefix: str = "cc") -> str:
|
|
|
144
147
|
def shell_command_with_env(command: str, env: dict[str, str]) -> str:
|
|
145
148
|
if not env:
|
|
146
149
|
return command
|
|
147
|
-
|
|
148
|
-
|
|
150
|
+
# Important: callers often pass compound shell commands like `cd ... && zsh -lc ...`.
|
|
151
|
+
# `VAR=... cd ... && ...` only applies VAR to the first command (`cd`) in POSIX sh.
|
|
152
|
+
# Wrap in a subshell so env vars apply to the entire script.
|
|
153
|
+
assignments = " ".join(f"{k}={shlex.quote(v)}" for k, v in env.items())
|
|
154
|
+
return f"env {assignments} sh -lc {shlex.quote(command)}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_SESSION_ID_FROM_TRANSCRIPT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{7,}$")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _session_id_from_transcript_path(transcript_path: str | None) -> str | None:
|
|
161
|
+
raw = (transcript_path or "").strip()
|
|
162
|
+
if not raw:
|
|
163
|
+
return None
|
|
164
|
+
try:
|
|
165
|
+
path = Path(raw)
|
|
166
|
+
except Exception:
|
|
167
|
+
return None
|
|
168
|
+
if path.suffix != ".jsonl":
|
|
169
|
+
return None
|
|
170
|
+
stem = (path.stem or "").strip()
|
|
171
|
+
if not stem:
|
|
172
|
+
return None
|
|
173
|
+
# Heuristic guard: avoid overwriting with generic filenames like "transcript.jsonl".
|
|
174
|
+
if not _SESSION_ID_FROM_TRANSCRIPT_RE.fullmatch(stem):
|
|
175
|
+
return None
|
|
176
|
+
if not any(ch.isdigit() for ch in stem):
|
|
177
|
+
return None
|
|
178
|
+
return stem
|
|
149
179
|
|
|
150
180
|
|
|
151
181
|
def _load_terminal_state() -> dict[str, dict[str, str]]:
|
|
@@ -449,7 +479,11 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
449
479
|
+ "}\t#{"
|
|
450
480
|
+ OPT_SESSION_ID
|
|
451
481
|
+ "}\t#{"
|
|
482
|
+
+ OPT_TRANSCRIPT_PATH
|
|
483
|
+
+ "}\t#{"
|
|
452
484
|
+ OPT_CONTEXT_ID
|
|
485
|
+
+ "}\t#{"
|
|
486
|
+
+ OPT_ATTENTION
|
|
453
487
|
+ "}",
|
|
454
488
|
],
|
|
455
489
|
capture=True,
|
|
@@ -468,7 +502,9 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
468
502
|
provider = parts[4] if len(parts) > 4 and parts[4] else None
|
|
469
503
|
session_type = parts[5] if len(parts) > 5 and parts[5] else None
|
|
470
504
|
session_id = parts[6] if len(parts) > 6 and parts[6] else None
|
|
471
|
-
|
|
505
|
+
transcript_path = parts[7] if len(parts) > 7 and parts[7] else None
|
|
506
|
+
context_id = parts[8] if len(parts) > 8 and parts[8] else None
|
|
507
|
+
attention = parts[9] if len(parts) > 9 and parts[9] else None
|
|
472
508
|
|
|
473
509
|
if terminal_id:
|
|
474
510
|
persisted = state.get(terminal_id) or {}
|
|
@@ -478,9 +514,15 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
478
514
|
session_type = persisted.get("session_type") or session_type
|
|
479
515
|
if not session_id:
|
|
480
516
|
session_id = persisted.get("session_id") or session_id
|
|
517
|
+
if not transcript_path:
|
|
518
|
+
transcript_path = persisted.get("transcript_path") or transcript_path
|
|
481
519
|
if not context_id:
|
|
482
520
|
context_id = persisted.get("context_id") or context_id
|
|
483
521
|
|
|
522
|
+
transcript_session_id = _session_id_from_transcript_path(transcript_path)
|
|
523
|
+
if transcript_session_id:
|
|
524
|
+
session_id = transcript_session_id
|
|
525
|
+
|
|
484
526
|
windows.append(
|
|
485
527
|
InnerWindow(
|
|
486
528
|
window_id=window_id,
|
|
@@ -490,7 +532,9 @@ def list_inner_windows() -> list[InnerWindow]:
|
|
|
490
532
|
provider=provider,
|
|
491
533
|
session_type=session_type,
|
|
492
534
|
session_id=session_id,
|
|
535
|
+
transcript_path=transcript_path,
|
|
493
536
|
context_id=context_id,
|
|
537
|
+
attention=attention,
|
|
494
538
|
)
|
|
495
539
|
)
|
|
496
540
|
return windows
|
|
@@ -507,6 +551,11 @@ def set_inner_window_options(window_id: str, options: dict[str, str]) -> bool:
|
|
|
507
551
|
return ok
|
|
508
552
|
|
|
509
553
|
|
|
554
|
+
def clear_attention(window_id: str) -> bool:
|
|
555
|
+
"""Clear attention state on a window."""
|
|
556
|
+
return set_inner_window_options(window_id, {OPT_ATTENTION: ""})
|
|
557
|
+
|
|
558
|
+
|
|
510
559
|
def kill_inner_window(window_id: str) -> bool:
|
|
511
560
|
if not ensure_inner_session():
|
|
512
561
|
return False
|
|
@@ -76,11 +76,13 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
76
76
|
|
|
77
77
|
TerminalPanel .terminal-row {
|
|
78
78
|
height: auto;
|
|
79
|
+
min-height: 2;
|
|
79
80
|
margin: 0 0 1 0;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
TerminalPanel .terminal-row Button.terminal-switch {
|
|
83
84
|
width: 1fr;
|
|
85
|
+
height: 2;
|
|
84
86
|
margin: 0;
|
|
85
87
|
padding: 0 1;
|
|
86
88
|
text-align: left;
|
|
@@ -90,15 +92,28 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
90
92
|
TerminalPanel .terminal-row Button.terminal-close {
|
|
91
93
|
width: 2;
|
|
92
94
|
min-width: 2;
|
|
95
|
+
height: 2;
|
|
93
96
|
margin-left: 1;
|
|
94
97
|
padding: 0;
|
|
98
|
+
content-align: center middle;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
TerminalPanel .terminal-row .attention-dot {
|
|
102
|
+
width: 2;
|
|
103
|
+
min-width: 2;
|
|
104
|
+
height: 2;
|
|
105
|
+
color: $error;
|
|
106
|
+
content-align: center middle;
|
|
107
|
+
margin-right: 0;
|
|
95
108
|
}
|
|
96
109
|
|
|
97
110
|
TerminalPanel .terminal-row Button.terminal-toggle {
|
|
98
111
|
width: 2;
|
|
99
112
|
min-width: 2;
|
|
113
|
+
height: 2;
|
|
100
114
|
margin-left: 1;
|
|
101
115
|
padding: 0;
|
|
116
|
+
content-align: center middle;
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
TerminalPanel .context-sessions {
|
|
@@ -328,6 +343,9 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
328
343
|
safe = self._safe_id_fragment(w.window_id)
|
|
329
344
|
row = Horizontal(classes="terminal-row")
|
|
330
345
|
await container.mount(row)
|
|
346
|
+
# Show attention dot if window needs attention
|
|
347
|
+
if w.attention:
|
|
348
|
+
await row.mount(Static("●", classes="attention-dot"))
|
|
331
349
|
switch_classes = "terminal-switch active" if w.active else "terminal-switch"
|
|
332
350
|
loaded_ids: list[str] = []
|
|
333
351
|
raw_sessions = 0
|
|
@@ -418,18 +436,20 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
418
436
|
raw_events: int = 0,
|
|
419
437
|
) -> str | Text:
|
|
420
438
|
if not self._is_claude_window(w):
|
|
421
|
-
return w.window_name
|
|
439
|
+
return Text(w.window_name, no_wrap=True, overflow="ellipsis")
|
|
422
440
|
|
|
423
441
|
title = titles.get(w.session_id or "", "").strip() if w.session_id else ""
|
|
424
442
|
header = title or ("Claude" if w.session_id else "New Claude")
|
|
425
443
|
|
|
426
|
-
details = Text()
|
|
444
|
+
details = Text(no_wrap=True, overflow="ellipsis")
|
|
427
445
|
details.append(header)
|
|
428
446
|
details.append("\n")
|
|
429
447
|
|
|
430
|
-
|
|
448
|
+
# Include window_name to distinguish terminals with same session_id
|
|
449
|
+
window_name = w.window_name or ""
|
|
450
|
+
detail_line = f"[{window_name}]" if window_name else "claude"
|
|
431
451
|
if w.session_id:
|
|
432
|
-
detail_line = f"
|
|
452
|
+
detail_line = f"{detail_line} #{self._short_id(w.session_id)}"
|
|
433
453
|
if w.active:
|
|
434
454
|
loaded_count = raw_sessions + raw_events
|
|
435
455
|
detail_line = f"{detail_line} | loaded context: {loaded_count}"
|
|
@@ -539,17 +559,30 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
539
559
|
get_settings_path as get_submit_settings_path,
|
|
540
560
|
install_user_prompt_submit_hook,
|
|
541
561
|
)
|
|
562
|
+
from ...claude_hooks.permission_request_hook_installer import (
|
|
563
|
+
ensure_permission_request_hook_installed,
|
|
564
|
+
get_settings_path as get_permission_settings_path,
|
|
565
|
+
install_permission_request_hook,
|
|
566
|
+
)
|
|
542
567
|
|
|
543
568
|
ok_global_stop = ensure_stop_hook_installed(quiet=True)
|
|
544
569
|
ok_global_submit = ensure_user_prompt_submit_hook_installed(quiet=True)
|
|
570
|
+
ok_global_permission = ensure_permission_request_hook_installed(quiet=True)
|
|
545
571
|
|
|
546
572
|
project_root = Path(workspace)
|
|
547
573
|
ok_project_stop = install_stop_hook(get_stop_settings_path(project_root), quiet=True)
|
|
548
574
|
ok_project_submit = install_user_prompt_submit_hook(
|
|
549
575
|
get_submit_settings_path(project_root), quiet=True
|
|
550
576
|
)
|
|
577
|
+
ok_project_permission = install_permission_request_hook(
|
|
578
|
+
get_permission_settings_path(project_root), quiet=True
|
|
579
|
+
)
|
|
551
580
|
|
|
552
|
-
|
|
581
|
+
all_hooks_ok = (
|
|
582
|
+
ok_global_stop and ok_global_submit and ok_global_permission
|
|
583
|
+
and ok_project_stop and ok_project_submit and ok_project_permission
|
|
584
|
+
)
|
|
585
|
+
if not all_hooks_ok:
|
|
553
586
|
self.app.notify(
|
|
554
587
|
"Claude hooks not fully installed; session id/title may not update",
|
|
555
588
|
title="Terminal",
|
|
@@ -598,6 +631,9 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
598
631
|
window_id = event.button.name or ""
|
|
599
632
|
if not window_id or not tmux_manager.select_inner_window(window_id):
|
|
600
633
|
self.app.notify("Failed to switch terminal", title="Terminal", severity="error")
|
|
634
|
+
# Clear attention when user clicks on terminal
|
|
635
|
+
if window_id:
|
|
636
|
+
tmux_manager.clear_attention(window_id)
|
|
601
637
|
self._expanded_window_id = None
|
|
602
638
|
await self.refresh_data()
|
|
603
639
|
return
|
|
File without changes
|
|
File without changes
|
|
File without changes
|