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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aline-ai
3
- Version: 0.5.1
3
+ Version: 0.5.3
4
4
  Summary: Shared AI memory; everyone knows everything in teams
5
5
  Author: Sharemind
6
6
  License: MIT
@@ -1,5 +1,5 @@
1
- aline_ai-0.5.1.dist-info/licenses/LICENSE,sha256=H8wTqV5IF1oHw_HbBtS1PSDU8G_q81yblEIL_JfV8Vo,1077
2
- realign/__init__.py,sha256=0C8PfQuAdk_vdDgO-OK3_uootZ8Q9edqLPiGDtLh-_8,1623
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/stop_hook.py,sha256=PGpG_w00mY-Vu25mY-Z9zt45TA-SKF6RNQq1559EO6I,10895
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=BY0m8sQxaQcGYRrexciaFN2mfO3B1slIkAnh_xg1CLo,25504
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=GZ1zF6xCCeNQwqhC7EQQqDacG3NSUU86HrbCE70I1Ls,19148
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=A1_-42eTmZVerwAD7o9iZF_2lzH4Ed9O9BgNm6oRWSI,22970
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.1.dist-info/METADATA,sha256=UTXP26c_IlOZXH9BMSRTK7pCspxlJbuPipUvpdwMVsI,1597
88
- aline_ai-0.5.1.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
89
- aline_ai-0.5.1.dist-info/entry_points.txt,sha256=TvYELpMoWsUTcQdMV8tBHxCbEf_LbK4sESqK3r8PM6Y,78
90
- aline_ai-0.5.1.dist-info/top_level.txt,sha256=yIL3s2xv9nf1GwD5n71Aq_JEIV4AfzCIDNKBzewuRm4,8
91
- aline_ai-0.5.1.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.10.1)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
realign/__init__.py CHANGED
@@ -3,7 +3,7 @@
3
3
  import hashlib
4
4
  from pathlib import Path
5
5
 
6
- __version__ = "0.5.0"
6
+ __version__ = "0.5.3"
7
7
 
8
8
 
9
9
  def get_realign_dir(project_root: Path) -> Path:
@@ -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
- prefix = " ".join(f"{k}={shlex.quote(v)}" for k, v in env.items())
148
- return f"{prefix} {command}"
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
- context_id = parts[7] if len(parts) > 7 and parts[7] else None
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
- detail_line = "claude"
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"claude #{self._short_id(w.session_id)}"
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
- if not (ok_global_stop and ok_global_submit and ok_project_stop and ok_project_submit):
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